diff --git a/app/[[...path]]/page.tsx b/app/[[...path]]/page.tsx index c5e6d24211a291..74829f0a17b24b 100644 --- a/app/[[...path]]/page.tsx +++ b/app/[[...path]]/page.tsx @@ -7,7 +7,7 @@ import {apiCategories} from 'sentry-docs/build/resolveOpenAPI'; import {ApiCategoryPage} from 'sentry-docs/components/apiCategoryPage'; import {ApiPage} from 'sentry-docs/components/apiPage'; import {DocPage} from 'sentry-docs/components/docPage'; -import {Home} from 'sentry-docs/components/home'; +import Home from 'sentry-docs/components/home'; import {Include} from 'sentry-docs/components/include'; import {PageLoadMetrics} from 'sentry-docs/components/pageLoadMetrics'; import {PlatformContent} from 'sentry-docs/components/platformContent'; @@ -126,8 +126,6 @@ export default async function Page(props: {params: Promise<{path?: string[]}>}) const pageNode = nodeForPath(rootNode, params.path ?? ''); if (!pageNode) { - // eslint-disable-next-line no-console - console.warn('no page node', params.path); return notFound(); } diff --git a/app/not-found.tsx b/app/not-found.tsx index 7d5eb07076fdc8..c310826f16cc30 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -4,7 +4,7 @@ import {useEffect, useState} from 'react'; import {Button} from '@radix-ui/themes'; import {usePathname} from 'next/navigation'; -import {Header} from 'sentry-docs/components/header'; +import Header from 'sentry-docs/components/header'; import {Search} from 'sentry-docs/components/search'; import {DocMetrics} from 'sentry-docs/metrics'; @@ -39,7 +39,7 @@ export default function NotFound() { const reportUrl = `https://github.com/getsentry/sentry-docs/issues/new?template=issue-platform-404.yml&title=🔗 404 Error&url=${brokenUrl}`; return (
-
+

Page Not Found

We couldn't find the page you were looking for :(

diff --git a/package.json b/package.json index 8e76182dcc1e38..5822b2b1c1fc3e 100644 --- a/package.json +++ b/package.json @@ -91,8 +91,9 @@ "next-themes": "^0.3.0", "nextjs-toploader": "^1.6.6", "p-limit": "^6.2.0", - "platformicons": "^9.0.6", + "platformicons": "^9.0.7", "prism-sentry": "^1.0.2", + "query-string": "^9.3.1", "react": "^19.2.4", "react-dom": "^19.2.4", "react-feather": "^2.0.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0cc772d42a60e9..ce48deb47cd085 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,11 +176,14 @@ importers: specifier: ^6.2.0 version: 6.2.0 platformicons: - specifier: ^9.0.6 + specifier: ^9.0.7 version: 9.0.7(react@19.2.4) prism-sentry: specifier: ^1.0.2 version: 1.0.2 + query-string: + specifier: ^9.3.1 + version: 9.3.1 react: specifier: ^19.2.4 version: 19.2.4 @@ -4225,6 +4228,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -4924,6 +4932,10 @@ packages: decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + decode-uri-component@0.4.1: + resolution: {integrity: sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==} + engines: {node: '>=14.16'} + dedent@1.7.1: resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==} peerDependencies: @@ -5483,6 +5495,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + filter-obj@5.1.0: + resolution: {integrity: sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==} + engines: {node: '>=14.16'} + find-root@1.1.0: resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} @@ -7121,6 +7137,10 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + query-string@9.3.1: + resolution: {integrity: sha512-5fBfMOcDi5SA9qj5jZhWAcTtDfKF5WFdd2uD9nVNlbxVv1baq65aALy6qofpNEGELHvisjjasxQp7BlM9gvMzw==} + engines: {node: '>=18'} + querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} @@ -7642,6 +7662,10 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + split-on-first@3.0.0: + resolution: {integrity: sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==} + engines: {node: '>=12'} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -13228,9 +13252,9 @@ snapshots: dependencies: acorn: 8.15.0 - acorn-import-phases@1.0.4(acorn@8.15.0): + acorn-import-phases@1.0.4(acorn@8.16.0): dependencies: - acorn: 8.15.0 + acorn: 8.16.0 acorn-jsx@5.3.2(acorn@8.15.0): dependencies: @@ -13246,6 +13270,8 @@ snapshots: acorn@8.15.0: {} + acorn@8.16.0: {} + agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -14029,6 +14055,8 @@ snapshots: dependencies: character-entities: 2.0.2 + decode-uri-component@0.4.1: {} + dedent@1.7.1(babel-plugin-macros@3.1.0): optionalDependencies: babel-plugin-macros: 3.1.0 @@ -14851,6 +14879,8 @@ snapshots: dependencies: to-regex-range: 5.0.1 + filter-obj@5.1.0: {} + find-root@1.1.0: {} find-up@4.1.0: @@ -17178,6 +17208,12 @@ snapshots: pure-rand@6.1.0: {} + query-string@9.3.1: + dependencies: + decode-uri-component: 0.4.1 + filter-obj: 5.1.0 + split-on-first: 3.0.0 + querystringify@2.2.0: {} queue-microtask@1.2.3: {} @@ -18049,6 +18085,8 @@ snapshots: space-separated-tokens@2.0.2: {} + split-on-first@3.0.0: {} + sprintf-js@1.0.3: {} stable-hash@0.0.5: {} @@ -18284,7 +18322,7 @@ snapshots: terser@5.46.0: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.15.0 + acorn: 8.16.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -18876,8 +18914,8 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.15.0 - acorn-import-phases: 1.0.4(acorn@8.15.0) + acorn: 8.16.0 + acorn-import-phases: 1.0.4(acorn@8.16.0) browserslist: 4.28.1 chrome-trace-event: 1.0.4 enhanced-resolve: 5.19.0 diff --git a/src/components/TopNavClient.tsx b/src/components/TopNavClient.tsx new file mode 100644 index 00000000000000..fdad7c5b945a48 --- /dev/null +++ b/src/components/TopNavClient.tsx @@ -0,0 +1,711 @@ +'use client'; +import {useEffect, useRef, useState} from 'react'; +import ReactDOM from 'react-dom'; +import Link from 'next/link'; +import {usePathname, useRouter} from 'next/navigation'; + +import {Platform} from 'sentry-docs/types'; + +import platformSelectorStyles from './platformSelector/style.module.scss'; + +import {mainSectionsWithDropdowns, productSections} from './navigationData'; +import {PlatformSelector} from './platformSelector'; + +const mainSections = mainSectionsWithDropdowns; + +// Add a helper hook for portal dropdown positioning +function useDropdownPosition(triggerRef, open) { + const [position, setPosition] = useState({top: 0, left: 0, width: 0}); + useEffect(() => { + function updatePosition() { + if (triggerRef.current && open) { + const rect = triggerRef.current.getBoundingClientRect(); + setPosition({ + top: rect.bottom + window.scrollY, + left: rect.left + window.scrollX, + width: rect.width, + }); + } + } + updatePosition(); + if (open) { + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition, true); + } + return () => { + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition, true); + }; + }, [triggerRef, open]); + return position; +} + +export default function TopNavClient({platforms}: {platforms: Platform[]}) { + const [platformDropdownOpen, setPlatformDropdownOpen] = useState(false); + const [platformDropdownByClick, setPlatformDropdownByClick] = useState(false); + const platformBtnRef = useRef(null); + const platformDropdownRef = useRef(null); + const pathname = usePathname(); + const router = useRouter(); + + // Compute the SDK link href - use stored platform URL if available + const [sdkLinkHref, setSdkLinkHref] = useState('/platforms/'); + + // Update href after hydration to check localStorage + useEffect(() => { + const storedPlatform = localStorage.getItem('active-platform'); + if (storedPlatform) { + // Find the platform URL from our platforms list + const platform = platforms.find(p => p.key === storedPlatform); + if (platform) { + setSdkLinkHref(platform.url); + return; + } + // Also check guides + for (const p of platforms) { + const guide = p.guides.find(g => g.key === storedPlatform); + if (guide) { + setSdkLinkHref(guide.url); + return; + } + } + } + }, [platforms]); + + // Click handler - use client-side navigation for stored platform redirect + const handleSdkClick = (e: React.MouseEvent) => { + const storedPlatform = localStorage.getItem('active-platform'); + if (storedPlatform && platforms && platforms.length > 0) { + // First check if it's a platform key + const platform = platforms.find(p => p.key === storedPlatform); + if (platform) { + e.preventDefault(); + router.push(platform.url); + return; + } + // Then check guides + for (const p of platforms) { + if (p.guides) { + const guide = p.guides.find(g => g.key === storedPlatform); + if (guide) { + e.preventDefault(); + router.push(guide.url); + return; + } + } + } + } + }; + + const closeTimers = useRef<{ + concepts?: NodeJS.Timeout; + more?: NodeJS.Timeout; + products?: NodeJS.Timeout; + sdks?: NodeJS.Timeout; + }>({}); + const [productsDropdownOpen, setProductsDropdownOpen] = useState(false); + const [conceptsDropdownOpen, setConceptsDropdownOpen] = useState(false); + const [moreDropdownOpen, setMoreDropdownOpen] = useState(false); + const productsBtnRef = useRef(null); + const conceptsBtnRef = useRef(null); + const moreBtnRef = useRef(null); + const productsDropdownRef = useRef(null); + const conceptsDropdownRef = useRef(null); + const moreDropdownRef = useRef(null); + const navRef = useRef(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + + // Close dropdowns on outside click if opened by click + useEffect(() => { + function handleClick(e: MouseEvent) { + if (platformDropdownOpen && platformDropdownByClick) { + if ( + !platformBtnRef.current?.contains(e.target as Node) && + !platformDropdownRef.current?.contains(e.target as Node) + ) { + setPlatformDropdownOpen(false); + setPlatformDropdownByClick(false); + } + } + if (productsDropdownOpen) { + if ( + !productsBtnRef.current?.contains(e.target as Node) && + !productsDropdownRef.current?.contains(e.target as Node) + ) { + setProductsDropdownOpen(false); + } + } + if (conceptsDropdownOpen) { + if ( + !conceptsBtnRef.current?.contains(e.target as Node) && + !conceptsDropdownRef.current?.contains(e.target as Node) + ) { + setConceptsDropdownOpen(false); + } + } + if (moreDropdownOpen) { + if ( + !moreBtnRef.current?.contains(e.target as Node) && + !moreDropdownRef.current?.contains(e.target as Node) + ) { + setMoreDropdownOpen(false); + } + } + } + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [ + platformDropdownOpen, + platformDropdownByClick, + productsDropdownOpen, + conceptsDropdownOpen, + moreDropdownOpen, + ]); + + useEffect(() => { + function updateScrollState() { + const nav = navRef.current; + if (!nav) return; + setCanScrollLeft(nav.scrollLeft > 0); + setCanScrollRight(nav.scrollLeft + nav.clientWidth < nav.scrollWidth - 1); + } + updateScrollState(); + const nav = navRef.current; + if (nav) { + nav.addEventListener('scroll', updateScrollState); + } + window.addEventListener('resize', updateScrollState); + return () => { + if (nav) nav.removeEventListener('scroll', updateScrollState); + window.removeEventListener('resize', updateScrollState); + }; + }, []); + + function scrollNavBy(amount: number) { + const nav = navRef.current; + if (nav) { + nav.scrollBy({left: amount, behavior: 'smooth'}); + } + } + + // For each dropdown, use the hook and portal rendering + // Example for Products: + const productsPosition = useDropdownPosition(productsBtnRef, productsDropdownOpen); + const sdksPosition = useDropdownPosition(platformBtnRef, platformDropdownOpen); + const conceptsPosition = useDropdownPosition(conceptsBtnRef, conceptsDropdownOpen); + const morePosition = useDropdownPosition(moreBtnRef, moreDropdownOpen); + + return ( +
+
+ {canScrollLeft && ( + + )} + {canScrollRight && ( + + )} +
+
    + {mainSections.map(section => ( +
  • + {section.label === 'Product' ? ( +
    { + clearTimeout(closeTimers.current.products); + setProductsDropdownOpen(true); + setConceptsDropdownOpen(false); + setMoreDropdownOpen(false); + setPlatformDropdownOpen(false); + }} + onMouseLeave={() => { + closeTimers.current.products = setTimeout(() => { + setProductsDropdownOpen(false); + }, 150); + }} + > + +
    + ) : section.label === 'Concepts' ? ( +
    { + clearTimeout(closeTimers.current.concepts); + setConceptsDropdownOpen(true); + setProductsDropdownOpen(false); + setMoreDropdownOpen(false); + setPlatformDropdownOpen(false); + }} + onMouseLeave={() => { + closeTimers.current.concepts = setTimeout(() => { + setConceptsDropdownOpen(false); + }, 150); + }} + > + +
    + ) : section.label === 'More' ? ( +
    { + clearTimeout(closeTimers.current.more); + setMoreDropdownOpen(true); + setProductsDropdownOpen(false); + setConceptsDropdownOpen(false); + setPlatformDropdownOpen(false); + }} + onMouseLeave={() => { + closeTimers.current.more = setTimeout(() => { + setMoreDropdownOpen(false); + }, 150); + }} + > + +
    + ) : section.label === 'SDKs' ? ( + + {section.label} + + ) : ( + + {section.label} + + )} +
  • + ))} +
+
+
+ {/* Portal-based dropdowns */} + {productsDropdownOpen && + ReactDOM.createPortal( +
e.stopPropagation()} + onMouseEnter={() => { + clearTimeout(closeTimers.current.products); + }} + onMouseLeave={() => { + closeTimers.current.products = setTimeout(() => { + setProductsDropdownOpen(false); + }, 150); + }} + > + + {productSections.map(product => ( + + {product.label} + + ))} +
, + document.body + )} + {platformDropdownOpen && + ReactDOM.createPortal( +
e.stopPropagation()} + onMouseEnter={() => { + clearTimeout(closeTimers.current.sdks); + }} + onMouseLeave={() => { + closeTimers.current.sdks = setTimeout(() => { + setPlatformDropdownOpen(false); + setPlatformDropdownByClick(false); + }, 150); + }} + > + + +
, + document.body + )} + {conceptsDropdownOpen && + ReactDOM.createPortal( +
e.stopPropagation()} + onMouseEnter={() => { + clearTimeout(closeTimers.current.concepts); + }} + onMouseLeave={() => { + closeTimers.current.concepts = setTimeout(() => { + setConceptsDropdownOpen(false); + }, 150); + }} + > + + {mainSections + .find(s => s.label === 'Concepts') + ?.dropdown?.map(dropdown => ( + + {dropdown.label} + + ))} +
, + document.body + )} + {moreDropdownOpen && + ReactDOM.createPortal( +
e.stopPropagation()} + onMouseEnter={() => { + clearTimeout(closeTimers.current.more); + }} + onMouseLeave={() => { + closeTimers.current.more = setTimeout(() => { + setMoreDropdownOpen(false); + }, 150); + }} + > + + {mainSections + .find(s => s.label === 'More') + ?.dropdown?.map(dropdown => ( + + {dropdown.label} + + ))} +
, + document.body + )} + +
+ ); +} diff --git a/src/components/breadcrumbs/index.tsx b/src/components/breadcrumbs/index.tsx index a6c4335b869b5b..ffd989bdcba26c 100644 --- a/src/components/breadcrumbs/index.tsx +++ b/src/components/breadcrumbs/index.tsx @@ -1,34 +1,43 @@ -import {DocNode} from 'sentry-docs/docTree'; +'use client'; + +import Link from 'next/link'; +import {useRouter} from 'next/navigation'; import styles from './style.module.scss'; -import {SmartLink} from '../smartLink'; +export type BreadcrumbItem = { + title: string; + to: string; +}; type BreadcrumbsProps = { - leafNode: DocNode; + items: BreadcrumbItem[]; }; -export function Breadcrumbs({leafNode}: BreadcrumbsProps) { - const breadcrumbs: {title: string; to: string}[] = []; - - for (let node: DocNode | undefined = leafNode; node; node = node.parent) { - if (node && !node.missing) { - const to = node.path === '/' ? node.path : `/${node.path}/`; - const title = node.frontmatter.sidebar_title ?? node.frontmatter.title; +export function Breadcrumbs({items}: BreadcrumbsProps) { + const router = useRouter(); - breadcrumbs.unshift({ - to, - title, - }); - } - } + const handlePlatformsClick = (e: React.MouseEvent) => { + e.preventDefault(); + // Clear stored platform so SDK selector resets + localStorage.removeItem('active-platform'); + // Navigate after clearing localStorage + router.push('/platforms/'); + }; return (
    - {breadcrumbs.map(b => { + {items.map(b => { + const isPlatformsLink = b.to === '/platforms/'; return (
  • - {b.title} + {isPlatformsLink ? ( + + {b.title} + + ) : ( + {b.title} + )}
  • ); })} diff --git a/src/components/breadcrumbs/utils.ts b/src/components/breadcrumbs/utils.ts new file mode 100644 index 00000000000000..2ea0fb9c1d295e --- /dev/null +++ b/src/components/breadcrumbs/utils.ts @@ -0,0 +1,22 @@ +import {DocNode} from 'sentry-docs/docTree'; + +import {BreadcrumbItem} from './index'; + +// Helper function to build breadcrumbs from DocNode (for use in server components) +export function buildBreadcrumbs(leafNode: DocNode | undefined): BreadcrumbItem[] { + const breadcrumbs: BreadcrumbItem[] = []; + + for (let node: DocNode | undefined = leafNode; node; node = node.parent) { + if (node && !node.missing) { + const to = node.path === '/' ? node.path : `/${node.path}/`; + const title = node.frontmatter.sidebar_title ?? node.frontmatter.title; + + breadcrumbs.unshift({ + to, + title, + }); + } + } + + return breadcrumbs; +} diff --git a/src/components/card.tsx b/src/components/card.tsx index b17746da26ed1f..a266aa63aba00a 100644 --- a/src/components/card.tsx +++ b/src/components/card.tsx @@ -19,7 +19,7 @@ export function Card({ }) { return ( -
    +
    -
    - +
    {sidebar ?? ( @@ -94,7 +100,7 @@ export async function DocPage({
    - {leafNode && }{' '} + {' '}
    diff --git a/src/components/header.tsx b/src/components/header.tsx index 9a23d685f2b234..0d86d23f8f9edb 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -1,25 +1,22 @@ 'use client'; -import {HamburgerMenuIcon} from '@radix-ui/react-icons'; +import {useCallback, useEffect, useState} from 'react'; +import {Cross1Icon, HamburgerMenuIcon} from '@radix-ui/react-icons'; +import {Button} from '@radix-ui/themes'; import dynamic from 'next/dynamic'; import Image from 'next/image'; import Link from 'next/link'; import SentryLogoSVG from 'sentry-docs/logos/sentry-logo-dark.svg'; +import {Platform} from 'sentry-docs/types'; import sidebarStyles from './sidebar/style.module.scss'; -import {NavLink} from './navlink'; +import {MagicIcon} from './cutomIcons/magic'; +import {useHomeSearchVisibility} from './homeSearchVisibility'; +import {mainSections} from './navigationData'; import {ThemeToggle} from './theme-toggle'; - -// Lazy load MobileMenu since it's only visible on small screens -const MobileMenu = dynamic( - () => import('./mobileMenu').then(mod => ({default: mod.MobileMenu})), - { - ssr: false, - loading: () =>
    , - } -); +import TopNavClient from './TopNavClient'; // Lazy load Search to reduce initial bundle size. // Search includes Algolia and @sentry-internal/global-search which add significant JS. @@ -28,7 +25,10 @@ const MobileMenu = dynamic( const Search = dynamic(() => import('./search').then(mod => ({default: mod.Search})), { ssr: false, loading: () => ( -
    +
    ), }); @@ -38,77 +38,174 @@ type Props = { pathname: string; searchPlatforms: string[]; noSearch?: boolean; + platforms?: Platform[]; useStoredSearchPlatforms?: boolean; }; -export function Header({ +export default function Header({ pathname, searchPlatforms, noSearch, useStoredSearchPlatforms, + platforms = [], }: Props) { + const isHomePage = pathname === '/'; + const [homeSearchVisible, setHomeSearchVisible] = useState(true); + const [homeMobileNavOpen, setHomeMobileNavOpen] = useState(false); + const [sidebarOpen, setSidebarOpen] = useState(false); + + // Listen for home search visibility changes + useHomeSearchVisibility( + useCallback((isVisible: boolean) => { + setHomeSearchVisible(isVisible); + }, []) + ); + + // Track sidebar checkbox state for non-home pages + useEffect(() => { + if (isHomePage) { + return undefined; + } + + const checkbox = document.getElementById(sidebarToggleId) as HTMLInputElement | null; + if (!checkbox) { + return undefined; + } + + const handleChange = () => { + setSidebarOpen(checkbox.checked); + }; + + // Set initial state + setSidebarOpen(checkbox.checked); + + checkbox.addEventListener('change', handleChange); + return () => checkbox.removeEventListener('change', handleChange); + }, [isHomePage]); + + // Show header search if: not on home page, OR on home page but home search is scrolled out of view + const showHeaderSearch = !isHomePage || !homeSearchVisible; + return (
    {/* define a header-height variable for consumption by other components */} - - + {/* Home page mobile navigation overlay */} + {isHomePage && homeMobileNavOpen && ( +
    + +
    + )}
    ); } diff --git a/src/components/home.tsx b/src/components/home.tsx index 000a2af9f1b3dd..ea55f6c00398a8 100644 --- a/src/components/home.tsx +++ b/src/components/home.tsx @@ -1,54 +1,298 @@ -import Image from 'next/image'; - import {Banner} from 'sentry-docs/components/banner'; -import {SentryWordmarkLogo} from 'sentry-docs/components/wordmarkLogo'; +import {extractPlatforms, getDocsRootNode} from 'sentry-docs/docTree'; +import AiSentryHero from 'sentry-docs/imgs/ai-sentry-hero.png'; import PlugImage from 'sentry-docs/imgs/api.png'; import ChatBubble from 'sentry-docs/imgs/chat-bubble.png'; import TerminalImage from 'sentry-docs/imgs/cli.png'; import ConceptsImage from 'sentry-docs/imgs/concepts-reference.png'; -import HeroImage from 'sentry-docs/imgs/home_illustration.png'; +import ErrorMonitoringHero from 'sentry-docs/imgs/error-monitoring-hero.png'; import AiImage from 'sentry-docs/imgs/integrate.png'; import OrganizationImage from 'sentry-docs/imgs/organization.png'; import CalculatorImage from 'sentry-docs/imgs/pricing.png'; import RocketImage from 'sentry-docs/imgs/rocket.png'; import SecurityImage from 'sentry-docs/imgs/security.png'; +import SquiggleSVG from 'sentry-docs/imgs/squiggle.svg'; import SupportImage from 'sentry-docs/imgs/support.png'; import AskAiSearchParams from './askAiSearchParams'; import {Card} from './card'; -import {Header} from './header'; +import Header from './header'; +import {HomeSearchObserver} from './homeSearchVisibility'; import {NavLink, NavLinkProps} from './navlink'; import {PlatformFilter} from './platformFilter'; +import {PlatformIcon} from './platformIcon'; +import {Search} from './search'; +import {SentryWordmarkLogo} from './wordmarkLogo'; -export function Home() { +export default async function Home() { + const rootNode = await getDocsRootNode(); + const platforms = extractPlatforms(rootNode); + const mostViewedSDKs = [ + { + key: 'javascript-nextjs', + title: 'Next.js', + url: '/platforms/javascript/guides/nextjs/', + }, + {key: 'javascript-node', title: 'Node.js', url: '/platforms/javascript/guides/node/'}, + {key: 'javascript-react', title: 'React', url: '/platforms/javascript/guides/react/'}, + {key: 'react-native', title: 'React Native', url: '/platforms/react-native/'}, + {key: 'python', title: 'Python', url: '/platforms/python/'}, + {key: 'php-laravel', title: 'Laravel', url: '/platforms/php/guides/laravel/'}, + {key: 'apple', title: 'Apple', url: '/platforms/apple/'}, + {key: 'android', title: 'Android', url: '/platforms/android/'}, + {key: 'dart', title: 'Dart', url: '/platforms/dart/'}, + {key: 'javascript', title: 'JavaScript', url: '/platforms/javascript/'}, + {key: 'unity', title: 'Unity', url: '/platforms/unity/'}, + {key: 'unreal', title: 'Unreal', url: '/platforms/unreal/'}, + ]; return (
    -
    +
    -
    -
    -
    -

    - Welcome to Sentry Docs -

    -

    - Sentry provides end-to-end distributed tracing, enabling developers to - identify and debug performance issues and errors across their systems and - services. -

    -
    -
    - Sentry's hero image + {/* Hero Banner with Two-Column Layout */} +
    + {/* Single decorative squiggle at top right */} + + + {/* Content Container */} +
    +
    + {/* Left Column: Welcome Text + Search */} +
    +

    + Welcome To Sentry Docs +

    +

    + Sentry provides end-to-end distributed tracing, enabling developers to + identify and debug performance issues and errors across their systems and + services. +

    + +
    + +
    +
    +
    + + {/* Right Column: SDK Section */} +
    +
    +
    + {mostViewedSDKs.map(platform => ( + + + + {platform.title} + + + ))} +
    + + GO TO ALL SDKS + +
    +
    +
    +
    + {/* Sentry Features Header */} +

    Sentry features

    +
    + {/* Sentry Debugging */} +
    +
    + Sentry Debugging +
    +
    +

    + Sentry Debugging +

    +

    + Monitor, identify, and resolve errors and performance issues across your + applications using{' '} + + Error Monitoring + + ,{' '} + + Tracing + + ,{' '} + + Session Replay + {' '} + + Logs + + , and{' '} + + more + + . +

    +
    +
    - + {/* AI for Agents and AI in Sentry */} +
    +
    + AI in Sentry +
    +
    +

    + AI for Agents and AI in Sentry +

    +

    + Use Sentry's{' '} + + Skills + {' '} + and{' '} + + MCP server + {' '} + with your agents, and start{' '} + + monitoring your AI features + + . Fix code faster with{' '} + + AI-powered root cause analysis + {' '} + in debugging your issues. Use{' '} + + AI code review + {' '} + to review your PRs and prevent errors. +

    +
    +
    +
    +
    + +

    Get to know us

    -
    - Security, Legal & PII - Contribute - - Support - - - Self-hosting Sentry - - - Developer docs - - - Sentry discord - - - - +
    + {/* First row */} +
    + Security, Legal & PII + Contribute + + Support + + + Changelog + +
    + {/* Second row */} +
    + + Sandbox + + + Self-hosting Sentry + + + Developer docs + + + Sentry Discord + + + + +

    © {new Date().getFullYear()} • Sentry is a registered trademark of Functional @@ -179,6 +437,61 @@ export function Home() {

    +
    ); } diff --git a/src/components/homeSearchVisibility.tsx b/src/components/homeSearchVisibility.tsx new file mode 100644 index 00000000000000..36fbcced0c4b2f --- /dev/null +++ b/src/components/homeSearchVisibility.tsx @@ -0,0 +1,56 @@ +'use client'; + +import {useEffect, useRef} from 'react'; + +// Custom event to communicate search visibility across components +const SEARCH_VISIBILITY_EVENT = 'home-search-visibility'; + +export function HomeSearchObserver({children}: {children: React.ReactNode}) { + const ref = useRef(null); + + useEffect(() => { + const element = ref.current; + if (!element) { + return undefined; + } + + const observer = new IntersectionObserver( + ([entry]) => { + const isVisible = entry.isIntersecting; + (window as any).__homeSearchVisible = isVisible; + window.dispatchEvent( + new CustomEvent(SEARCH_VISIBILITY_EVENT, {detail: {isVisible}}) + ); + }, + { + threshold: 0, + rootMargin: '-64px 0px 0px 0px', // Account for header height + } + ); + + observer.observe(element); + return () => observer.disconnect(); + }, []); + + return ( +
    + {children} +
    + ); +} + +export function useHomeSearchVisibility(callback: (isVisible: boolean) => void) { + useEffect(() => { + const handler = (e: CustomEvent<{isVisible: boolean}>) => { + callback(e.detail.isVisible); + }; + + window.addEventListener(SEARCH_VISIBILITY_EVENT as any, handler as EventListener); + return () => { + window.removeEventListener( + SEARCH_VISIBILITY_EVENT as any, + handler as EventListener + ); + }; + }, [callback]); +} diff --git a/src/components/mobileMenu/index.tsx b/src/components/mobileMenu/index.tsx index 300566be954882..0a6c8409b3bd85 100644 --- a/src/components/mobileMenu/index.tsx +++ b/src/components/mobileMenu/index.tsx @@ -8,7 +8,7 @@ import {Search} from 'sentry-docs/components/search'; import styles from './styles.module.scss'; -import {ThemeToggle} from '../theme-toggle'; +import {mainSections} from '../navigationData'; type Props = { pathname: string; @@ -18,7 +18,6 @@ type Props = { export function MobileMenu({pathname, searchPlatforms}: Props) { return (
    - + )} +
    + {guides.map(guide => ( + + ))} + + ); + } return ( {/* This is a hack. The Label allows us to have a clickable button inside the item without triggering its selection */} @@ -310,13 +460,17 @@ function PlatformItem({ - + {guides.map(guide => { - return ; + return ; })} ); @@ -355,20 +509,64 @@ function PlatformItem({ type GuideItemProps = { guide: (PlatformGuide | PlatformIntegration) & {isLastGuide: boolean}; + dropdownStyle?: boolean; + listOnly?: boolean; }; -function GuideItem({guide}: GuideItemProps) { +function GuideItem({guide, dropdownStyle = false, listOnly = false}: GuideItemProps) { + if (listOnly) { + return ( +
    { + if (typeof window !== 'undefined') { + localStorage.setItem('active-platform', guide.key); + window.location.href = guide.url; + } + }} + > + + + {/* replace dots with zero width space + period to allow text wrapping before periods + without breaking words in weird places + */} + {(guide.title ?? guide.name ?? guide.key).replace(/\./g, '\u200B.')} + +
    + ); + } return ( - +
    diff --git a/src/components/search/search.module.scss b/src/components/search/search.module.scss index 6b564adc036c59..18195b2d62782d 100644 --- a/src/components/search/search.module.scss +++ b/src/components/search/search.module.scss @@ -17,31 +17,35 @@ } .search { - --sgs-bg-color: var(--gray-2); + --sgs-bg-color: #ffffff; --sgs-color-border: var(--desatPurple12); --sgs-color-white: #ffffff; --sgs-color-progress-indicator: var(--desatPurple1); - --sgs-color-result-heading-background: var(--desatPurple4); + --sgs-color-result-heading-background: var(--accent-purple); --sgs-color-result-heading-text: #ffffff; - --sgs-color-hit-text: var(--desatPurple1); - --sgs-color-hit-highlight: var(--flame0); - --sgs-color-hit-context: var(--desatPurple4); - --sgs-color-hit-hover-background: var(--lightestPurpleBackground); - --sgs-color-expand-results-background: var(--lightestPurpleBackground); - --sgs-color-expand-results-text: var(--desatPurple6); + --sgs-color-hit-text: var(--gray-12); + --sgs-color-hit-highlight: var(--accent-purple); + --sgs-color-hit-context: var(--gray-11); + --sgs-color-hit-hover-background: var(--gray-a3); + --sgs-color-expand-results-background: var(--gray-a3); + --sgs-color-expand-results-text: var(--gray-11); position: relative; box-sizing: border-box; + width: 100%; } + + .search-bar { display: flex; flex-direction: row; align-items: center; gap: 1rem; + width: 100%; @media screen and (max-width: 768px) { flex-direction: column; @@ -77,7 +81,6 @@ border-color: var(--accent-purple); box-shadow: 0 0 0 0.2rem var(--accent-purple-light); background-color: var(--gray-a1); - min-width: 20rem; } &::placeholder { color: var(--foreground-secondary); @@ -87,6 +90,8 @@ .input-wrapper { position: relative; width: 100%; + flex: 1; + min-width: 0; } .search-hotkey { @@ -106,23 +111,29 @@ .sgs-search-results { position: absolute; margin-top: 1rem; - z-index: 5; - box-shadow: var(--shadow-6); - border-radius: 0.25rem; - background-color: var(--sgs-bg-color); + z-index: 100; + box-shadow: 0 4px 24px 0 rgba(0, 0, 0, 0.12), 0 2px 8px 0 rgba(0, 0, 0, 0.08); + border-radius: 1rem; + background-color: #ffffff; font-size: 0.875rem; max-height: 80vh; display: flex; flex-direction: column; overflow: hidden; width: 500px; - border: 1.5px solid var(--gray-a4); + border: 1px solid var(--gray-a3); @media screen and (max-width: 768px) { width: calc(100vw - 4 * 1rem); left: auto; } + :global(.dark) & { + background-color: var(--gray-2); + box-shadow: 0 4px 24px 0 rgba(0, 0, 0, 0.4), 0 2px 8px 0 rgba(0, 0, 0, 0.3); + border: 1px solid var(--gray-5); + } + :global(.logo) { margin-top: 0.75rem; margin-bottom: 0.5rem; @@ -150,10 +161,12 @@ .sgs-hit-list { list-style: none; margin: 0; - padding: 0.25rem; + padding: 0.25rem 0.5rem; &.sgs-offsite { background-color: var(--sgs-color-hit-hover-background); + border-radius: 0.5rem; + margin: 0.25rem 0.5rem; .sgs-hit-item > a:hover { background-color: var(--sgs-bg-color); @@ -163,6 +176,7 @@ .sgs-hit-item { scroll-margin: 10px; + margin: 0.25rem; mark { color: var(--sgs-color-hit-highlight); @@ -174,9 +188,10 @@ display: block; text-decoration: none; color: var(--sgs-color-hit-text); - padding: 0.75rem; line-height: 1.5; + border-radius: 0.5rem; + transition: background-color 0.15s ease; } a:hover, @@ -193,7 +208,7 @@ .sgs-ai { color: var(--sgs-color-hit-text); - padding: 0.25rem; + padding: 0.5rem; &-button { padding: 0.75rem; @@ -202,6 +217,8 @@ align-items: center; gap: 0.75rem; color: inherit; + border-radius: 0.5rem; + transition: background-color 0.15s ease; &:hover, .sgs-ai-focused & { diff --git a/src/components/sidebar/MobileSidebarNav.tsx b/src/components/sidebar/MobileSidebarNav.tsx new file mode 100644 index 00000000000000..7b2acadaef31a8 --- /dev/null +++ b/src/components/sidebar/MobileSidebarNav.tsx @@ -0,0 +1,98 @@ +'use client'; + +import {useEffect, useState} from 'react'; +import Link from 'next/link'; +import {usePathname} from 'next/navigation'; + +import {Platform} from 'sentry-docs/types'; + +import {mainSections} from '../navigationData'; + +export function MobileSidebarNav({platforms = []}: {platforms?: Platform[]}) { + const pathname = usePathname(); + + const isActive = (href: string) => pathname?.startsWith(href); + + // Compute the SDK link href - use stored platform URL if available + const [sdkLinkHref, setSdkLinkHref] = useState('/platforms/'); + + // Update href after hydration to check localStorage + useEffect(() => { + const storedPlatform = localStorage.getItem('active-platform'); + if (storedPlatform && platforms.length > 0) { + const platform = platforms.find(p => p.key === storedPlatform); + if (platform) { + setSdkLinkHref(platform.url); + return; + } + for (const p of platforms) { + const guide = p.guides.find(g => g.key === storedPlatform); + if (guide) { + setSdkLinkHref(guide.url); + return; + } + } + } + }, [platforms]); + + // Click handler as fallback for SDKs link + const handleSdkClick = (e: React.MouseEvent) => { + const storedPlatform = localStorage.getItem('active-platform'); + if (storedPlatform && platforms && platforms.length > 0) { + // First check if it's a platform key + const platform = platforms.find(p => p.key === storedPlatform); + if (platform) { + e.preventDefault(); + window.location.href = platform.url; + return; + } + // Then check guides + for (const p of platforms) { + if (p.guides) { + const guide = p.guides.find(g => g.key === storedPlatform); + if (guide) { + e.preventDefault(); + window.location.href = guide.url; + return; + } + } + } + } + }; + + return ( +
    + {/* Main navigation sections - simple links that navigate to index pages */} + +
    + ); +} diff --git a/src/components/sidebar/SidebarMoreLinks.tsx b/src/components/sidebar/SidebarMoreLinks.tsx new file mode 100644 index 00000000000000..420aa416f32aae --- /dev/null +++ b/src/components/sidebar/SidebarMoreLinks.tsx @@ -0,0 +1,63 @@ +'use client'; + +import {Fragment, useState} from 'react'; +import {ChevronDownIcon, ChevronRightIcon} from '@radix-ui/react-icons'; + +import styles from './style.module.scss'; + +import {SidebarLink} from './sidebarLink'; + +export function SidebarMoreLinks() { + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
      +
    • +
        + {/* Always visible links */} + + + + {/* Collapsible "More" section - styled to match SidebarLink */} + + {isExpanded && ( + + + + + + + + )} +
      +
    • +
    + ); +} diff --git a/src/components/sidebar/dynamicNav.tsx b/src/components/sidebar/dynamicNav.tsx index 19993df49a822c..88287f0316fcc1 100644 --- a/src/components/sidebar/dynamicNav.tsx +++ b/src/components/sidebar/dynamicNav.tsx @@ -296,7 +296,7 @@ export function DynamicNav({ } const {path} = serverContext(); - const isActive = path.join('/').indexOf(root) === 0; + const isActive = getUnversionedPath(path, false) === root; const linkPath = `/${path.join('/')}/`; const unversionedPath = getUnversionedPath(path, false); @@ -320,7 +320,7 @@ export function DynamicNav({ return (
  • {header} - {(!collapsible || isActive) && entity.children && ( + {entity.children && entity.children.length > 0 && (!collapsible || isActive) && (
      {isPlatformSidebar && ( = { 'ai-agent-monitoring-browser': 'ai-agent-monitoring', }; -const activeLinkSelector = `.${styles.sidebar} .toc-item .active`; - export const sidebarToggleId = styles['navbar-menu-toggle']; +const activeLinkSelector = `.${styles.sidebar} .toc-item .active`; + export async function Sidebar({path, versions}: SidebarProps) { const rootNode = await getDocsRootNode(); @@ -41,92 +44,127 @@ export async function Sidebar({path, versions}: SidebarProps) { ); } + // Extract basic platforms list for SDK link redirection (used by MobileSidebarNav) + const basicPlatforms: Platform[] = !rootNode + ? [] + : extractPlatforms(rootNode).map(platform => ({ + ...platform, + guides: platform.guides, + })); + const currentPlatform = getCurrentPlatform(rootNode, path); const currentGuide = getCurrentGuide(rootNode, path); - const platforms: Platform[] = !rootNode - ? [] - : extractPlatforms(rootNode).map(platform => { - // take the :path in /platforms/:platformName/:path - // or /platforms/:platformName/guides/:guideName/:path when we're in a guide - const currentPathParts = path.slice(currentGuide ? 4 : 2); - const lastPart = currentPathParts[currentPathParts.length - 1]; - const equivalentPath = EQUIVALENT_PATHS[lastPart]; + // Only show the platform selector and sidebar for SDKs/platforms section + if (path[0] === 'platforms') { + const platforms: Platform[] = !rootNode + ? [] + : extractPlatforms(rootNode).map(platform => { + // take the :path in /platforms/:platformName/:path + // or /platforms/:platformName/guides/:guideName/:path when we're in a guide + const currentPathParts = path.slice(currentGuide ? 4 : 2); + const lastPart = currentPathParts[currentPathParts.length - 1]; + const equivalentPath = EQUIVALENT_PATHS[lastPart]; - const platformPageForCurrentPath = - nodeForPath(rootNode, ['platforms', platform.name, ...currentPathParts]) || - // try equivalent path (e.g., ai-agent-monitoring <-> ai-agent-monitoring-browser) - (equivalentPath && + const platformPageForCurrentPath = + nodeForPath(rootNode, ['platforms', platform.name, ...currentPathParts]) || + // try equivalent path (e.g., ai-agent-monitoring <-> ai-agent-monitoring-browser) + (equivalentPath && + nodeForPath(rootNode, [ + 'platforms', + platform.name, + ...currentPathParts.slice(0, -1), + equivalentPath, + ])) || + // try to go one page higher, example: go to /usage/ from /usage/something nodeForPath(rootNode, [ 'platforms', platform.name, ...currentPathParts.slice(0, -1), - equivalentPath, - ])) || - // try to go one page higher, example: go to /usage/ from /usage/something - nodeForPath(rootNode, [ - 'platforms', - platform.name, - ...currentPathParts.slice(0, -1), - ]); + ]); - return { - ...platform, - url: - platformPageForCurrentPath && !platformPageForCurrentPath.missing - ? '/' + platformPageForCurrentPath.path + '/' - : platform.url, - guides: platform.guides.map(guide => { - const guidePageForCurrentPath = - nodeForPath(rootNode, [ - 'platforms', - platform.name, - 'guides', - guide.name, - ...currentPathParts, - ]) || - // try equivalent path (e.g., ai-agent-monitoring <-> ai-agent-monitoring-browser) - (equivalentPath && + return { + ...platform, + url: + platformPageForCurrentPath && !platformPageForCurrentPath.missing + ? '/' + platformPageForCurrentPath.path + '/' + : platform.url, + guides: platform.guides.map(guide => { + const guidePageForCurrentPath = nodeForPath(rootNode, [ 'platforms', platform.name, 'guides', guide.name, - ...currentPathParts.slice(0, -1), - equivalentPath, - ])); - return guidePageForCurrentPath && !guidePageForCurrentPath.missing - ? { - ...guide, - url: '/' + guidePageForCurrentPath.path + '/', - } - : guide; - }), - }; - }); + ...currentPathParts, + ]) || + // try equivalent path (e.g., ai-agent-monitoring <-> ai-agent-monitoring-browser) + (equivalentPath && + nodeForPath(rootNode, [ + 'platforms', + platform.name, + 'guides', + guide.name, + ...currentPathParts.slice(0, -1), + equivalentPath, + ])); + return guidePageForCurrentPath && !guidePageForCurrentPath.missing + ? { + ...guide, + url: '/' + guidePageForCurrentPath.path + '/', + } + : guide; + }), + }; + }); + + return ( + + ); + } + // For all other sections, just show the sidebar navigation (no platform selector) return ( ); diff --git a/src/components/sidebar/productSidebar.tsx b/src/components/sidebar/productSidebar.tsx index 0425a4a0440c1a..d392e63efc59d4 100644 --- a/src/components/sidebar/productSidebar.tsx +++ b/src/components/sidebar/productSidebar.tsx @@ -1,7 +1,6 @@ import {nodeForPath} from 'sentry-docs/docTree'; import {DynamicNav, toTree} from './dynamicNav'; -import {SidebarLink, SidebarSeparator} from './sidebarLink'; import {NavNode, ProductSidebarProps} from './types'; import {docNodeToNavNode, getNavNodes} from './utils'; @@ -19,7 +18,6 @@ export function ProductSidebar({rootNode, items}: ProductSidebarProps) {
        {items.map(item => { const tree = itemTree(item.root); - return ( tree && ( ) ); })}
      - -
        -
      • -
          - - - - - -
        -
      • -
      + {/* External links menu removed from here */}
  • ); } diff --git a/src/components/sidebar/sidebarLink.tsx b/src/components/sidebar/sidebarLink.tsx index 0ecd33382f58c2..498dfe391ddc7b 100644 --- a/src/components/sidebar/sidebarLink.tsx +++ b/src/components/sidebar/sidebarLink.tsx @@ -13,12 +13,14 @@ export function SidebarLink({ collapsible, onClick, topLevel = false, + className, beta = false, isNew = false, }: { href: string; title: string; beta?: boolean; + className?: string; collapsible?: boolean; isActive?: boolean; isNew?: boolean; @@ -34,7 +36,7 @@ export function SidebarLink({ onClick={onClick} className={`${styles['sidebar-link']} ${isActive ? 'active' : ''} ${ topLevel ? styles['sidebar-link-top-level'] : '' - }`} + } ${className ?? ''}`} data-sidebar-link >
    diff --git a/src/components/sidebar/sidebarNavigation.tsx b/src/components/sidebar/sidebarNavigation.tsx index 6494295f277cdb..cf91d4e1786348 100644 --- a/src/components/sidebar/sidebarNavigation.tsx +++ b/src/components/sidebar/sidebarNavigation.tsx @@ -9,66 +9,34 @@ import {SidebarSeparator} from './sidebarLink'; import {NavNode} from './types'; import {docNodeToNavNode, getNavNodes} from './utils'; -/** a root of `"some-root"` maps to the `/some-root/` url */ -// todo: we should probably get rid of this -const productSidebarItems = [ - { - title: 'Sentry for AI', - root: 'ai', - }, - { - title: 'Guides', - root: 'guides', - }, - { - title: 'Account Settings', - root: 'account', - }, - { - title: 'Organization Settings', - root: 'organization', - }, - { - title: 'Product Walkthroughs', - root: 'product', - }, - { - title: 'Pricing & Billing', - root: 'pricing', - }, - { - title: 'Sentry CLI', - root: 'cli', - }, - { - title: 'Sentry API', - root: 'api', - }, - { - title: 'Security, Legal, & PII', - root: 'security-legal-pii', - }, - { - title: 'Concepts & Reference', - root: 'concepts', - }, - { - title: 'Documentation Changelog', - root: 'changelog', - }, -]; - export async function SidebarNavigation({path}: {path: string[]}) { const rootNode = await getDocsRootNode(); - // product docs and platform-redirect page - if ( - productSidebarItems.some(el => el.root === path[0]) || - path[0] === 'platform-redirect' - ) { - return ; + + // Product section: just show the sidebar for /product/ and its children + if (path[0] === 'product') { + return ( + + ); + } + + // AI section + if (path[0] === 'ai') { + return ( + + ); } - // /platforms/:platformName/guides/:guideName + // Guides section + if (path[0] === 'guides') { + return ( + + ); + } + + // SDKs/Platforms if (path[0] === 'platforms') { const platformName = path[1]; const guideName = path[3]; @@ -84,12 +52,90 @@ export async function SidebarNavigation({path}: {path: string[]}) { )} - ); } - // contributing pages + // Concepts & Reference (includes CLI which is part of Concepts) + if (path[0] === 'concepts' || path[0] === 'cli') { + return ( +
      + + + + + +
    + ); + } + + // "More" section - Admin Settings + Security, Legal, & PII + if ( + path[0] === 'organization' || + path[0] === 'account' || + path[0] === 'pricing' || + path[0] === 'security-legal-pii' + ) { + const moreItems = [ + {title: 'Account Settings', root: 'account'}, + {title: 'Organization Settings', root: 'organization'}, + {title: 'Pricing & Billing', root: 'pricing'}, + {title: 'Security, Legal, & PII', root: 'security-legal-pii'}, + ]; + return ; + } + + // API Reference + if (path[0] === 'api') { + return ( +
      + +
    + ); + } + + // Contributing pages if (path[0] === 'contributing') { const contribNode = nodeForPath(rootNode, 'contributing'); if (contribNode) { @@ -106,6 +152,30 @@ export async function SidebarNavigation({path}: {path: string[]}) { } } + // Documentation Changelog + if (path[0] === 'changelog') { + const changelogNode = nodeForPath(rootNode, 'changelog'); + if (changelogNode) { + const changelogNodes: NavNode[] = getNavNodes([changelogNode], docNodeToNavNode); + return ( +
      + +
    + ); + } + // Return empty sidebar if no changelog node exists + return
      ; + } + + // Platform redirect page - no sidebar needed + if (path[0] === 'platform-redirect') { + return
        ; + } + // This should never happen, all cases need to be handled above throw new Error(`Unknown path: ${path.join('/')} - cannot render sidebar`); } diff --git a/src/components/sidebar/style.module.scss b/src/components/sidebar/style.module.scss index 3c6815b2782aeb..b9e2185356fb15 100644 --- a/src/components/sidebar/style.module.scss +++ b/src/components/sidebar/style.module.scss @@ -7,6 +7,7 @@ } } .sidebar { + margin-top: 0px; --sidebar-item-bg-hover: var(--accent-purple-light); --sidebar-item-color: var(--accent-purple); /* Light mode: use pure white background */ @@ -20,8 +21,8 @@ position: fixed; display: none; flex-shrink: 0; - height: 100vh; - overflow-y: auto; + height: calc(100vh - var(--header-height, 64px)); + overflow: hidden; /* Minimal, accessible scrollbar styling */ /* Firefox */ @@ -63,15 +64,14 @@ } } - // the checkbox controls the sidebar visibility on mobile - // otherwise it's always visible on desktop - @media only screen and (max-width: 1130px) { + // the checkbox controls the sidebar visibility on mobile/tablet + // otherwise it's always visible on desktop (768px+) + @media only screen and (max-width: 767px) { &:has(> #navbar-menu-toggle:checked) { display: flex; & + :global(.main-content) { - margin-left: var(--sidebar-width); - width: calc(100% - var(--sidebar-width)); + margin-left: 0; } } @@ -84,17 +84,12 @@ } } - // 0 margin on mobile for main content - @media only screen and (max-width: 768px) { - &:has(> #navbar-menu-toggle:checked) + :global(.main-content) { - margin-left: 0; - } - } - .toc { font-size: 0.875rem; flex: 1; overflow: auto; + min-height: 0; // Critical for flex children to allow shrinking and enable scrolling + -webkit-overflow-scrolling: touch; // Smooth momentum scrolling on iOS @media only screen and (min-width: 768px) { display: block; @@ -156,6 +151,18 @@ } } +.sidebar-main { + flex: 1; + overflow: auto; + min-height: 0; // Critical for flex children to allow shrinking and enable scrolling + -webkit-overflow-scrolling: touch; // Smooth momentum scrolling on iOS +} + +.sidebar-external-links { + flex: 0 0 auto; + padding-bottom: 0; +} + .sidebar-link-content { display: flex; align-items: center; diff --git a/src/docTree.ts b/src/docTree.ts index e32ce6122a2ba2..f79f5aebabcc04 100644 --- a/src/docTree.ts +++ b/src/docTree.ts @@ -146,28 +146,30 @@ function frontmatterToTree(frontmatter: FrontMatter[]): DocNode { rootNode.children.push(node); slugMap[slug] = node; } else { - const parentSlug = slugParts.slice(0, slugParts.length - 1).join('/'); - let parent: DocNode | undefined = slugMap[parentSlug]; - if (!parent) { - const grandparentSlug = slugParts.slice(0, slugParts.length - 2).join('/'); - const grandparent = slugMap[grandparentSlug]; - if (!grandparent) { - throw new Error('missing parent and grandparent: ' + parentSlug); - } - parent = { + let parent: DocNode | undefined; + // Walk up the tree and create missing parents as needed + for (let i = slugParts.length - 1; i > 0; i--) { + const parentSlug = slugParts.slice(0, i).join('/'); + parent = slugMap[parentSlug]; + if (parent) break; + + // Create missing parent node + const grandparentSlug = slugParts.slice(0, i - 1).join('/'); + const grandparent = slugMap[grandparentSlug] || rootNode; + const missingParent: DocNode = { path: parentSlug, - slug: slugParts[slugParts.length - 2], + slug: slugParts[i - 1], frontmatter: { - slug: slugParts[slugParts.length - 2], - // not ideal + slug: slugParts[i - 1], title: '', }, parent: grandparent, children: [], missing: true, }; - grandparent.children.push(parent); - slugMap[parentSlug] = parent; + grandparent.children.push(missingParent); + slugMap[parentSlug] = missingParent; + parent = missingParent; } const node = { path: slug, @@ -178,7 +180,7 @@ function frontmatterToTree(frontmatter: FrontMatter[]): DocNode { missing: false, sourcePath: doc.sourcePath, }; - parent.children.push(node); + parent!.children.push(node); slugMap[slug] = node; } }); diff --git a/src/imgs/Linkedin-1128x191.png b/src/imgs/Linkedin-1128x191.png new file mode 100644 index 00000000000000..f6e404e0860833 Binary files /dev/null and b/src/imgs/Linkedin-1128x191.png differ diff --git a/src/imgs/ai-sentry-hero.png b/src/imgs/ai-sentry-hero.png new file mode 100644 index 00000000000000..cca064cc84cb96 Binary files /dev/null and b/src/imgs/ai-sentry-hero.png differ diff --git a/src/imgs/background-gradient-afternoon.png b/src/imgs/background-gradient-afternoon.png new file mode 100644 index 00000000000000..dcf5c2ae48036b Binary files /dev/null and b/src/imgs/background-gradient-afternoon.png differ diff --git a/src/imgs/error-monitoring-hero.png b/src/imgs/error-monitoring-hero.png new file mode 100644 index 00000000000000..9003d88382117d Binary files /dev/null and b/src/imgs/error-monitoring-hero.png differ diff --git a/src/imgs/pink-shape-06.png b/src/imgs/pink-shape-06.png new file mode 100644 index 00000000000000..ac41a4a1e5151b Binary files /dev/null and b/src/imgs/pink-shape-06.png differ diff --git a/src/imgs/squiggle.svg b/src/imgs/squiggle.svg new file mode 100644 index 00000000000000..d41e49ab3b8581 --- /dev/null +++ b/src/imgs/squiggle.svg @@ -0,0 +1,614 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/imgs/yellow-shape-05.png b/src/imgs/yellow-shape-05.png new file mode 100644 index 00000000000000..a7a62885b1efa5 Binary files /dev/null and b/src/imgs/yellow-shape-05.png differ diff --git a/src/imgs/yellow-shape-06.png b/src/imgs/yellow-shape-06.png new file mode 100644 index 00000000000000..c606603115c3fe Binary files /dev/null and b/src/imgs/yellow-shape-06.png differ diff --git a/src/imgs/yellow-shape-08.png b/src/imgs/yellow-shape-08.png new file mode 100644 index 00000000000000..98da0da4d6ff56 Binary files /dev/null and b/src/imgs/yellow-shape-08.png differ diff --git a/src/imgs/yellow-shape-13.png b/src/imgs/yellow-shape-13.png new file mode 100644 index 00000000000000..67756a7117d55b Binary files /dev/null and b/src/imgs/yellow-shape-13.png differ diff --git a/src/mdx.ts b/src/mdx.ts index 5a3087cfbe91f0..4116815d570ebd 100644 --- a/src/mdx.ts +++ b/src/mdx.ts @@ -15,7 +15,7 @@ import { createBrotliCompress, createBrotliDecompress, } from 'node:zlib'; -import {limitFunction} from 'p-limit'; +import pLimit from 'p-limit'; import rehypeAutolinkHeadings from 'rehype-autolink-headings'; import rehypePresetMinify from 'rehype-preset-minify'; import rehypePrismDiff from 'rehype-prism-diff'; @@ -256,29 +256,27 @@ export async function getDevDocsFrontMatterUncached(): Promise { const folder = 'develop-docs'; const docsPath = path.join(root, folder); const files = await getAllFilesRecursively(docsPath); + const limit = pLimit(FILE_CONCURRENCY_LIMIT); const frontMatters = ( await Promise.all( - files.map( - limitFunction( - async file => { - const fileName = file.slice(docsPath.length + 1); - if (path.extname(fileName) !== '.md' && path.extname(fileName) !== '.mdx') { - return undefined; - } + files.map(file => + limit(async () => { + const fileName = file.slice(docsPath.length + 1); + if (path.extname(fileName) !== '.md' && path.extname(fileName) !== '.mdx') { + return undefined; + } - const source = await readFile(file, 'utf8'); - const {data: frontmatter} = matter(source); - return { - ...(frontmatter as FrontMatter), - slug: fileName.replace(/\/index.mdx?$/, '').replace(/\.mdx?$/, ''), - sourcePath: path.join(folder, fileName), - }; - }, - {concurrency: FILE_CONCURRENCY_LIMIT} - ) + const source = await readFile(file, 'utf8'); + const {data: frontmatter} = matter(source); + return { + ...(frontmatter as FrontMatter), + slug: fileName.replace(/\/index.mdx?$/, '').replace(/\.mdx?$/, ''), + sourcePath: path.join(folder, fileName), + }; + }) ) ) - ).filter(isNotNil); + ).filter(isNotNil) as FrontMatter[]; return frontMatters; } @@ -311,30 +309,28 @@ async function getAllFilesFrontMatter(): Promise { const docsPath = path.join(root, 'docs'); const files = await getAllFilesRecursively(docsPath); const allFrontMatter: FrontMatter[] = []; + const limit = pLimit(FILE_CONCURRENCY_LIMIT); await Promise.all( - files.map( - limitFunction( - async file => { - const fileName = file.slice(docsPath.length + 1); - if (path.extname(fileName) !== '.md' && path.extname(fileName) !== '.mdx') { - return; - } + files.map(file => + limit(async () => { + const fileName = file.slice(docsPath.length + 1); + if (path.extname(fileName) !== '.md' && path.extname(fileName) !== '.mdx') { + return; + } - if (fileName.indexOf('/common/') !== -1) { - return; - } + if (fileName.indexOf('/common/') !== -1) { + return; + } - const source = await readFile(file, 'utf8'); - const {data: frontmatter} = matter(source); - allFrontMatter.push({ - ...(frontmatter as FrontMatter), - slug: formatSlug(fileName), - sourcePath: path.join('docs', fileName), - }); - }, - {concurrency: FILE_CONCURRENCY_LIMIT} - ) + const source = await readFile(file, 'utf8'); + const {data: frontmatter} = matter(source); + allFrontMatter.push({ + ...(frontmatter as FrontMatter), + slug: formatSlug(fileName), + sourcePath: path.join('docs', fileName), + }); + }) ) ); @@ -371,50 +367,44 @@ async function getAllFilesFrontMatter(): Promise { ); const commonFiles = await Promise.all( - commonFileNames.map( - limitFunction( - async commonFileName => { - const source = await readFile(commonFileName, 'utf8'); - const {data: frontmatter} = matter(source); - return {commonFileName, frontmatter: frontmatter as FrontMatter}; - }, - {concurrency: FILE_CONCURRENCY_LIMIT} - ) + commonFileNames.map(commonFileName => + limit(async () => { + const source = await readFile(commonFileName, 'utf8'); + const {data: frontmatter} = matter(source); + return {commonFileName, frontmatter: frontmatter as FrontMatter}; + }) ) ); await Promise.all( - commonFiles.map( - limitFunction( - async f => { - if (!isSupported(f.frontmatter, platformName)) { - return; - } + commonFiles.map(f => + limit(async () => { + if (!isSupported(f.frontmatter, platformName)) { + return; + } - const subpath = f.commonFileName.slice(commonPath.length + 1); - const slug = f.commonFileName - .slice(docsPath.length + 1) - .replace(/\/common\//, '/'); - const noFrontMatter = ( - await Promise.allSettled([ - access(path.join(docsPath, slug)), - access(path.join(docsPath, slug.replace('/index.mdx', '.mdx'))), - ]) - ).every(r => r.status === 'rejected'); - if (noFrontMatter) { - let frontmatter = f.frontmatter; - if (subpath === 'index.mdx') { - frontmatter = {...frontmatter, ...platformFrontmatter}; - } - allFrontMatter.push({ - ...frontmatter, - slug: formatSlug(slug), - sourcePath: 'docs/' + f.commonFileName.slice(docsPath.length + 1), - }); + const subpath = f.commonFileName.slice(commonPath.length + 1); + const slug = f.commonFileName + .slice(docsPath.length + 1) + .replace(/\/common\//, '/'); + const noFrontMatter = ( + await Promise.allSettled([ + access(path.join(docsPath, slug)), + access(path.join(docsPath, slug.replace('/index.mdx', '.mdx'))), + ]) + ).every(r => r.status === 'rejected'); + if (noFrontMatter) { + let frontmatter = f.frontmatter; + if (subpath === 'index.mdx') { + frontmatter = {...frontmatter, ...platformFrontmatter}; } - }, - {concurrency: FILE_CONCURRENCY_LIMIT} - ) + allFrontMatter.push({ + ...frontmatter, + slug: formatSlug(slug), + sourcePath: 'docs/' + f.commonFileName.slice(docsPath.length + 1), + }); + } + }) ) ); @@ -444,40 +434,37 @@ async function getAllFilesFrontMatter(): Promise { } await Promise.all( - commonFiles.map( - limitFunction( - async f => { - if (!isSupported(f.frontmatter, platformName, guideName)) { - return; - } - - const subpath = f.commonFileName.slice(commonPath.length + 1); - const slug = path.join( - 'platforms', - platformName, - 'guides', - guideName, - subpath - ); - try { - await access(path.join(docsPath, slug)); - return; - } catch { - // pass - } - - let frontmatter = f.frontmatter; - if (subpath === 'index.mdx') { - frontmatter = {...frontmatter, ...guideFrontmatter}; - } - allFrontMatter.push({ - ...frontmatter, - slug: formatSlug(slug), - sourcePath: 'docs/' + f.commonFileName.slice(docsPath.length + 1), - }); - }, - {concurrency: FILE_CONCURRENCY_LIMIT} - ) + commonFiles.map(f => + limit(async () => { + if (!isSupported(f.frontmatter, platformName, guideName)) { + return; + } + + const subpath = f.commonFileName.slice(commonPath.length + 1); + const slug = path.join( + 'platforms', + platformName, + 'guides', + guideName, + subpath + ); + try { + await access(path.join(docsPath, slug)); + return; + } catch { + // pass + } + + let frontmatter = f.frontmatter; + if (subpath === 'index.mdx') { + frontmatter = {...frontmatter, ...guideFrontmatter}; + } + allFrontMatter.push({ + ...frontmatter, + slug: formatSlug(slug), + sourcePath: 'docs/' + f.commonFileName.slice(docsPath.length + 1), + }); + }) ) ); } diff --git a/src/seer-image.jpg b/src/seer-image.jpg new file mode 100644 index 00000000000000..47fb3a79025b7c Binary files /dev/null and b/src/seer-image.jpg differ