diff --git a/app/[[...path]]/page.tsx b/app/[[...path]]/page.tsx index 7c02fdca36d14..a6bcb94913dc5 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 {PlatformContent} from 'sentry-docs/components/platformContent'; import { @@ -75,8 +75,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(); } @@ -111,8 +109,6 @@ export default async function Page(props: {params: Promise<{path?: string[]}>}) doc = await getFileBySlugWithCache(`develop-docs/${params.path?.join('/') ?? ''}`); } catch (e) { if (e.code === 'ENOENT') { - // eslint-disable-next-line no-console - console.error('ENOENT', params.path); return notFound(); } throw e; @@ -149,8 +145,6 @@ export default async function Page(props: {params: Promise<{path?: string[]}>}) doc = await getFileBySlugWithCache(`docs/${pageNode.path}`); } catch (e) { if (e.code === 'ENOENT') { - // eslint-disable-next-line no-console - console.error('ENOENT', pageNode.path); return notFound(); } throw e; diff --git a/app/not-found.tsx b/app/not-found.tsx index a08daad34eb14..75468481883de 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -3,7 +3,7 @@ import {useEffect, useState} from 'react'; import {Button} from '@radix-ui/themes'; -import {Header} from 'sentry-docs/components/header'; +import Header from 'sentry-docs/components/header'; import {Search} from 'sentry-docs/components/search'; export default function NotFound() { @@ -17,7 +17,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/src/components/TopNav.tsx b/src/components/TopNav.tsx new file mode 100644 index 0000000000000..b933a2c55f96e --- /dev/null +++ b/src/components/TopNav.tsx @@ -0,0 +1,9 @@ +import {extractPlatforms, getDocsRootNode} from 'sentry-docs/docTree'; + +import TopNavClient from './TopNavClient'; + +export default async function TopNav() { + const rootNode = await getDocsRootNode(); + const platforms = extractPlatforms(rootNode); + return ; +} diff --git a/src/components/TopNavClient.tsx b/src/components/TopNavClient.tsx new file mode 100644 index 0000000000000..9b2b941712e8f --- /dev/null +++ b/src/components/TopNavClient.tsx @@ -0,0 +1,650 @@ +'use client'; +import {useEffect, useRef, useState} from 'react'; +import ReactDOM from 'react-dom'; +import Link from 'next/link'; +import {usePathname} from 'next/navigation'; + +import {Platform} from 'sentry-docs/types'; + +import platformSelectorStyles from './platformSelector/style.module.scss'; + +import {PlatformSelector} from './platformSelector'; + +const productSections = [ + {label: 'Sentry Basics', href: '/product/sentry-basics/'}, + {label: 'AI in Sentry', href: '/product/ai-in-sentry/'}, + {label: 'Insights', href: '/product/insights/'}, + {label: 'User Feedback', href: '/product/user-feedback/'}, + {label: 'Uptime Monitoring', href: '/product/uptime-monitoring/'}, + {label: 'Dashboards', href: '/product/dashboards/'}, + {label: 'Projects', href: '/product/projects/'}, + {label: 'Explore', href: '/product/explore/'}, + {label: 'Issues', href: '/product/issues/'}, + {label: 'Alerts', href: '/product/alerts/'}, + {label: 'Crons', href: '/product/crons/'}, + {label: 'Releases', href: '/product/releases/'}, + {label: 'Relay', href: '/product/relay/'}, + {label: 'Sentry MCP', href: '/product/sentry-mcp/'}, + {label: 'Sentry Toolbar', href: '/product/sentry-toolbar/'}, + {label: 'Stats', href: '/product/stats/'}, + {label: 'Codecov', href: '/product/codecov/'}, + {label: 'Onboarding', href: '/product/onboarding/'}, +]; + +const mainSections = [ + { + label: 'Products', + href: '/product/', + dropdown: productSections, + }, + {label: 'SDKs', href: '/platforms/'}, + { + label: 'Concepts & Reference', + href: '/concepts/', + dropdown: [ + {label: 'Key Terms', href: '/concepts/key-terms/'}, + {label: 'Search', href: '/concepts/search/'}, + {label: 'Migration', href: '/concepts/migration/'}, + {label: 'Data Management', href: '/concepts/data-management/'}, + {label: 'Sentry CLI', href: '/cli/'}, + ], + }, + { + label: 'Admin', + href: '/organization/', + dropdown: [ + {label: 'Account Settings', href: '/account/'}, + {label: 'Organization Settings', href: '/organization/'}, + {label: 'Pricing & Billing', href: '/pricing'}, + ], + }, + {label: 'API', href: '/api/'}, + {label: 'Security, Legal, & PII', href: '/security-legal-pii/'}, +]; + +// 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 isPlatformsRoute = pathname?.startsWith('/platforms/'); + const closeTimers = useRef<{products?: NodeJS.Timeout; sdks?: NodeJS.Timeout}>({}); + const [productsDropdownOpen, setProductsDropdownOpen] = useState(false); + const [conceptsDropdownOpen, setConceptsDropdownOpen] = useState(false); + const [adminDropdownOpen, setAdminDropdownOpen] = useState(false); + const productsBtnRef = useRef(null); + const conceptsBtnRef = useRef(null); + const adminBtnRef = useRef(null); + const productsDropdownRef = useRef(null); + const conceptsDropdownRef = useRef(null); + const adminDropdownRef = 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 (adminDropdownOpen) { + if ( + !adminBtnRef.current?.contains(e.target as Node) && + !adminDropdownRef.current?.contains(e.target as Node) + ) { + setAdminDropdownOpen(false); + } + } + } + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [ + platformDropdownOpen, + platformDropdownByClick, + productsDropdownOpen, + conceptsDropdownOpen, + adminDropdownOpen, + ]); + + 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 adminPosition = useDropdownPosition(adminBtnRef, adminDropdownOpen); + + return ( +
+
+ {canScrollLeft && ( + + )} + {canScrollRight && ( + + )} +
+
    + {mainSections.map(section => ( +
  • + {section.label === 'Products' ? ( +
    + +
    + ) : section.label === 'SDKs' ? ( +
    + +
    + ) : section.label === 'Concepts & Reference' ? ( +
    + +
    + ) : section.label === 'Admin' ? ( +
    + +
    + ) : ( + + {section.label} + + )} +
  • + ))} +
+
+
+ {/* Portal-based dropdowns */} + {productsDropdownOpen && + ReactDOM.createPortal( +
e.stopPropagation()} + > + + {productSections.map(product => ( + + {product.label} + + ))} +
, + document.body + )} + {platformDropdownOpen && + ReactDOM.createPortal( +
e.stopPropagation()} + > + + +
, + document.body + )} + {conceptsDropdownOpen && + ReactDOM.createPortal( +
e.stopPropagation()} + > + + {mainSections + .find(s => s.label === 'Concepts & Reference') + ?.dropdown?.map(dropdown => ( + + {dropdown.label} + + ))} +
, + document.body + )} + {adminDropdownOpen && + ReactDOM.createPortal( +
e.stopPropagation()} + > + + {mainSections + .find(s => s.label === 'Admin') + ?.dropdown?.map(dropdown => ( + + {dropdown.label} + + ))} +
, + document.body + )} + +
+ ); +} diff --git a/src/components/card.tsx b/src/components/card.tsx index b17746da26ed1..a266aa63aba00 100644 --- a/src/components/card.tsx +++ b/src/components/card.tsx @@ -19,7 +19,7 @@ export function Card({ }) { return ( -
+
-
- +
{sidebar ?? ( )} -
+
+ {leafNode && ( +
+ {' '} + + + +
+ )}
-
- {leafNode && }{' '} - - - -

{frontMatter.title}

diff --git a/src/components/focus-active-link.tsx b/src/components/focus-active-link.tsx index 6a173d025bdd2..3d6600c722c2a 100644 --- a/src/components/focus-active-link.tsx +++ b/src/components/focus-active-link.tsx @@ -1,8 +1,6 @@ 'use client'; import {useEffect} from 'react'; -import {debounce} from 'sentry-docs/utils'; - type Props = { activeLinkSelector: string; }; @@ -10,50 +8,26 @@ type Props = { /** Make sure the active link is visible in the sidebar */ export function ScrollActiveLink({activeLinkSelector}: Props) { useEffect(() => { - const sidebar = document.querySelector('[data-sidebar-link]')?.closest('aside'); - if (!sidebar) { - const noOp = () => {}; - return noOp; - } - const onLinkClick = (e: Event) => { - const target = e.target as HTMLElement; - if (target.hasAttribute('data-sidebar-link')) { - const top = target.getBoundingClientRect().top; - sessionStorage.setItem('sidebar-link-poisition', top.toString()); + const activeLinks = Array.from( + document.querySelectorAll(activeLinkSelector) + ) as HTMLElement[]; + const activeLink = activeLinks[activeLinks.length - 1]; + if (activeLink) { + // Find the closest scrollable sidebar container + const sidebarMain = activeLink.closest('.sidebar-main') as HTMLElement; + if (sidebarMain) { + const linkRect = activeLink.getBoundingClientRect(); + const containerRect = sidebarMain.getBoundingClientRect(); + const isFullyVisible = + linkRect.top >= containerRect.top && linkRect.bottom <= containerRect.bottom; + if (!isFullyVisible) { + activeLink.scrollIntoView({block: 'nearest'}); + } + } else { + // fallback: original behavior + activeLink.scrollIntoView({block: 'nearest'}); } - }; - sidebar.addEventListener('click', onLinkClick); - // track active link position on scroll as well - const onSidebarSroll = debounce(() => { - const activeLink = document.querySelector(activeLinkSelector); - if (activeLink) { - const top = activeLink.getBoundingClientRect().top.toString(); - sessionStorage.setItem('sidebar-link-poisition', top); - } - }, 50); - - sidebar.addEventListener('scroll', onSidebarSroll); - return () => { - sidebar.removeEventListener('click', onLinkClick); - sidebar.removeEventListener('scroll', onSidebarSroll); - }; - }, [activeLinkSelector]); - - useEffect(() => { - const activeLink = document.querySelector(activeLinkSelector); - const sidebar = activeLink?.closest('aside')!; - if (!activeLink || !sidebar) { - return; - } - const previousBoundingRectTop = sessionStorage.getItem('sidebar-link-poisition'); - const currentBoundingRectTop = activeLink.getBoundingClientRect().top; - // scroll the sidebar to make sure the active link is visible & has the same position as when it was clicked - if (!previousBoundingRectTop) { - return; } - const scrollX = 0; - const scrollY = sidebar.scrollTop + currentBoundingRectTop - +previousBoundingRectTop; - sidebar?.scrollTo(scrollX, scrollY); }, [activeLinkSelector]); // don't render anything, just exist as a client-side component for the useEffect. return null; diff --git a/src/components/header.tsx b/src/components/header.tsx index cd4b767843ac7..9c258cafcf508 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -1,17 +1,19 @@ 'use client'; import {HamburgerMenuIcon} from '@radix-ui/react-icons'; +import {Button} from '@radix-ui/themes'; 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 {MobileMenu} from './mobileMenu'; -import {NavLink} from './navlink'; import {Search} from './search'; import {ThemeToggle} from './theme-toggle'; +import TopNavClient from './TopNavClient'; export const sidebarToggleId = sidebarStyles['navbar-menu-toggle']; @@ -19,20 +21,21 @@ 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) { return (
- {/* define a header-height variable for consumption by other components */} - -
+ )} + {!noSearch && ( +
+ + +
+ )}
diff --git a/src/components/home.tsx b/src/components/home.tsx index 701053e154f02..563d0dddd2669 100644 --- a/src/components/home.tsx +++ b/src/components/home.tsx @@ -1,53 +1,286 @@ -import Image from 'next/image'; +import {Tooltip} from '@radix-ui/themes'; import {Banner} from 'sentry-docs/components/banner'; -import {SentryWordmarkLogo} from 'sentry-docs/components/wordmarkLogo'; +import {extractPlatforms, getDocsRootNode} from 'sentry-docs/docTree'; 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 BgLinkedin from 'sentry-docs/imgs/Linkedin-1128x191.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 SupportImage from 'sentry-docs/imgs/support.png'; +import YellowShape08 from 'sentry-docs/imgs/yellow-shape-08.png'; import AskAiSearchParams from './askAiSearchParams'; import {Card} from './card'; -import {Header} from './header'; +import Header from './header'; 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: 'python', title: 'Python', url: '/platforms/python/'}, + {key: 'php-laravel', title: 'Laravel', url: '/platforms/php/guides/laravel/'}, + {key: 'react-native', title: 'React Native', url: '/platforms/react-native/'}, + {key: 'apple', title: 'Apple', url: '/platforms/apple/'}, + {key: 'android', title: 'Android', url: '/platforms/android/'}, + {key: 'dart', title: 'Dart', url: '/platforms/dart/'}, + ]; return (
-
+
+ {/* Slanted Banner with Welcome Header and Subheader */} +
+

+ 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. +

+
+ {/* Search + SDKs row, same width as Sentry Products */} +
+
+ {/* Left column: Search Bar, left-aligned */} +
+
+ +
+
+ {/* Decorative yellow-shape-08 line between search and SDKs */} + decorative line + {/* Right column: Most Viewed SDKs, right-aligned */} +
+
+

+ Most Viewed Sentry SDKs +

+

+ Get started by setting up Sentry in your app to capture your first errors +

+
+ {mostViewedSDKs.map(platform => ( + + + + + {platform.title} + + + + ))} +
+ + See all SDKs + +
+
+
+
-
-
-

- 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 Products Header */} +

Sentry Features

+
+ {/* Column 1: Sentry */} +
+ +

+ Monitor, identify, and resolve errors and performance issues across your + applications using + + {' '}error monitoring + + , + + {' '}tracing + + , + + {' '}session replay + + , and + + {' '}more + + .

-
- Sentry's hero image + {/* Column 2: AI in Sentry */} +
+ +

+ Fix code faster by having{' '} + + Seer + {' '} + automatically find and remedy the root cause of your issues. Ask{' '} + + Sentry Prevent AI + {' '} + to review your PRs, suggest improvements, and build tests. +

- - +
+ +

Get to know us

+
); } diff --git a/src/components/mobileMenu/index.tsx b/src/components/mobileMenu/index.tsx index 300566be95488..61b20824293f3 100644 --- a/src/components/mobileMenu/index.tsx +++ b/src/components/mobileMenu/index.tsx @@ -8,17 +8,23 @@ import {Search} from 'sentry-docs/components/search'; import styles from './styles.module.scss'; -import {ThemeToggle} from '../theme-toggle'; - type Props = { pathname: string; searchPlatforms: string[]; }; export function MobileMenu({pathname, searchPlatforms}: Props) { + const mainSections = [ + {label: 'Products', href: '/product/sentry'}, + {label: 'SDKs', href: '/platforms/'}, + {label: 'Concepts & Reference', href: '/concepts/'}, + {label: 'Admin', href: '/organization/'}, + {label: 'API', href: '/api/'}, + {label: 'Security, Legal, & PII', href: '/security-legal-pii/'}, + ]; + 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 */} @@ -295,13 +417,17 @@ function PlatformItem({ - + {guides.map(guide => { - return ; + return ; })} ); @@ -340,20 +466,63 @@ 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') { + 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 94d28ac749037..22df9fd287578 100644 --- a/src/components/search/search.module.scss +++ b/src/components/search/search.module.scss @@ -77,7 +77,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); diff --git a/src/components/sidebar/dynamicNav.tsx b/src/components/sidebar/dynamicNav.tsx index bf144120f871b..ba79a783c19ed 100644 --- a/src/components/sidebar/dynamicNav.tsx +++ b/src/components/sidebar/dynamicNav.tsx @@ -148,7 +148,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 header = ( @@ -165,7 +165,7 @@ export function DynamicNav({ return (
  • {header} - {(!collapsible || isActive) && entity.children && ( + {entity.children && entity.children.length > 0 && (!collapsible || isActive) && (
      { - const platformPageForCurrentPath = - nodeForPath(rootNode, [ - 'platforms', - platform.name, - // take the :path in /platforms/:platformName/:path - // or /platforms/:platformName/guides/:guideName/:path when we're in a guide - ...path.slice(currentGuide ? 4 : 2), - ]) || - // try to go one page higher, example: go to /usage/ from /usage/something - nodeForPath(rootNode, [ - 'platforms', - platform.name, - ...path.slice(currentGuide ? 4 : 2, path.length - 1), - ]); - - return { - ...platform, - url: - platformPageForCurrentPath && !platformPageForCurrentPath.missing - ? '/' + platformPageForCurrentPath.path + '/' - : platform.url, - guides: platform.guides.map(guide => { - const guidePageForCurrentPath = nodeForPath(rootNode, [ + // Only show the platform selector and sidebar for SDKs/platforms section + if (path[0] === 'platforms') { + const currentPlatform = getCurrentPlatform(rootNode, path); + const currentGuide = getCurrentGuide(rootNode, path); + const platforms: Platform[] = !rootNode + ? [] + : extractPlatforms(rootNode).map(platform => { + const platformPageForCurrentPath = + nodeForPath(rootNode, [ 'platforms', platform.name, - 'guides', - guide.name, ...path.slice(currentGuide ? 4 : 2), + ]) || + nodeForPath(rootNode, [ + 'platforms', + platform.name, + ...path.slice(currentGuide ? 4 : 2, path.length - 1), ]); - return guidePageForCurrentPath && !guidePageForCurrentPath.missing - ? { - ...guide, - url: '/' + guidePageForCurrentPath.path + '/', - } - : guide; - }), - }; - }); + 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, + ...path.slice(currentGuide ? 4 : 2), + ]); + 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 0425a4a0440c1..d392e63efc59d 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 10b50564545b0..618130dc0c353 100644 --- a/src/components/sidebar/sidebarLink.tsx +++ b/src/components/sidebar/sidebarLink.tsx @@ -13,9 +13,11 @@ export function SidebarLink({ collapsible, onClick, topLevel = false, + className, }: { href: string; title: string; + className?: string; collapsible?: boolean; isActive?: boolean; onClick?: () => void; @@ -30,7 +32,7 @@ export function SidebarLink({ onClick={onClick} className={`${styles['sidebar-link']} ${isActive ? 'active' : ''} ${ topLevel ? styles['sidebar-link-top-level'] : '' - }`} + } ${className ?? ''}`} data-sidebar-link >
    {title}
    diff --git a/src/components/sidebar/sidebarNavigation.tsx b/src/components/sidebar/sidebarNavigation.tsx index fb2502e3de077..30742cad3acc5 100644 --- a/src/components/sidebar/sidebarNavigation.tsx +++ b/src/components/sidebar/sidebarNavigation.tsx @@ -9,54 +9,17 @@ 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: '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', - }, -]; - 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 ( + + ); } - // /platforms/:platformName/guides/:guideName + // SDKs/Platforms if (path[0] === 'platforms') { const platformName = path[1]; const guideName = path[3]; @@ -72,12 +35,100 @@ export async function SidebarNavigation({path}: {path: string[]}) { )} - ); } - // contributing pages + // Concepts & Reference + if (path[0] === 'concepts') { + return ( +
      + + + + + +
    + ); + } + + // Admin Settings + if (path[0] === 'organization' || path[0] === 'account' || path[0] === 'pricing') { + const adminItems = [ + {title: 'Account Settings', root: 'account'}, + {title: 'Organization Settings', root: 'organization'}, + {title: 'Pricing & Billing', root: 'pricing'}, + ]; + return ; + } + + // Security, Legal, & PII + if (path[0] === 'security-legal-pii') { + return ( +
      + +
    + ); + } + + // API Reference + if (path[0] === 'api') { + return ( +
      + +
    + ); + } + + // Contributing pages if (path[0] === 'contributing') { const contribNode = nodeForPath(rootNode, 'contributing'); if (contribNode) { @@ -94,6 +145,55 @@ export async function SidebarNavigation({path}: {path: string[]}) { } } + // Sentry CLI (standalone route) + if (path[0] === 'cli') { + 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 2c0b5c169833b..85921e5a19ee2 100644 --- a/src/components/sidebar/style.module.scss +++ b/src/components/sidebar/style.module.scss @@ -5,6 +5,7 @@ } } .sidebar { + margin-top: 0px; --sidebar-item-bg-hover: var(--accent-purple-light); --sidebar-item-color: var(--accent-purple); background-color: var(--gray-1); @@ -18,7 +19,6 @@ display: none; flex-shrink: 0; height: 100vh; - overflow-y: auto; @media only screen and (min-width: 768px) { position: fixed; @@ -130,3 +130,13 @@ background-color: var(--brandDecoration); } } + +.sidebar-main { + flex: 1; + overflow: auto; +} + +.sidebar-external-links { + flex: 0 0 auto; + padding-bottom: 0; +} diff --git a/src/docTree.ts b/src/docTree.ts index 1857c553eb8e6..b1001fd46ea3d 100644 --- a/src/docTree.ts +++ b/src/docTree.ts @@ -91,28 +91,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, @@ -123,7 +125,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 0000000000000..f6e404e086083 Binary files /dev/null and b/src/imgs/Linkedin-1128x191.png differ diff --git a/src/imgs/background-gradient-afternoon.png b/src/imgs/background-gradient-afternoon.png new file mode 100644 index 0000000000000..dcf5c2ae48036 Binary files /dev/null and b/src/imgs/background-gradient-afternoon.png differ diff --git a/src/imgs/pink-shape-06.png b/src/imgs/pink-shape-06.png new file mode 100644 index 0000000000000..ac41a4a1e5151 Binary files /dev/null and b/src/imgs/pink-shape-06.png differ diff --git a/src/imgs/yellow-shape-05.png b/src/imgs/yellow-shape-05.png new file mode 100644 index 0000000000000..a7a62885b1efa 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 0000000000000..c606603115c3f 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 0000000000000..98da0da4d6ff5 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 0000000000000..67756a7117d55 Binary files /dev/null and b/src/imgs/yellow-shape-13.png differ diff --git a/src/mdx.ts b/src/mdx.ts index f9c363d713a77..748d87018f181 100644 --- a/src/mdx.ts +++ b/src/mdx.ts @@ -5,7 +5,7 @@ import yaml from 'js-yaml'; import {bundleMDX} from 'mdx-bundler'; import {access, opendir, readFile} from 'node:fs/promises'; import path from 'node:path'; -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'; @@ -145,29 +145,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; } @@ -184,30 +182,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), + }); + }) ) ); @@ -244,50 +240,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), + }); + } + }) ) ); @@ -317,40 +307,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), + }); + }) ) ); }