// DocsLayout — Fumadocs-style three-pane shell for documentation. // // ┌─────────────────────────────────────────────────────┐ // │ TopBar (sticky) │ ← brand · topNav · search · theme // ├──────────┬───────────────────────────────────────┬──┤ // │ Sidebar │ Main content │T │ ← TOC pane (right) // │ (sticky) │ - breadcrumbs │O │ // │ nav │ - title + description │C │ // │ groups │ - children │ │ // │ │ - footer pager (prev / next) │ │ // │ │ - edit-on-github │ │ // └──────────┴───────────────────────────────────────┴──┘ // // The shell is dumb data-in / JSX-out — apps pass nav, brand, current // page metadata, and Link from their router. All visual primitives // (callouts, code blocks, steps) are siblings of this file and // composable in any combination. import { useEffect, useState, type ComponentType, type ReactNode } from 'react' import { cn } from '../cn' import type { ShellLinkProps } from './docShell' import { ChevronLeftIcon, ChevronRightIcon } from '../primitives/docIcons' import { PageHeader } from './pageHeader' // ---------- Public data types ---------- export interface DocsNavItem { readonly label: string readonly href: string /** Visual hint shown next to the label (e.g. "new", "beta"). */ readonly badge?: string } /** A collapsible sub-heading nested one level under a section. Its * entries render indented; the chevron + open/close affordance mirror * the top-level section. */ export interface DocsNavSubGroup { readonly label: string readonly entries: ReadonlyArray readonly defaultOpen?: boolean } export interface DocsNavGroup { readonly section: string /** Entries shown directly under the section header, ABOVE the first * sub-group. Use for a section's 1-2 intro pages. May be empty. */ readonly entries: ReadonlyArray /** Optional nested sub-groups. When present they render as * collapsible sub-headings below `entries`. Sections with no * meaningful taxonomy leave this empty and render flat. */ readonly subgroups?: ReadonlyArray /** Flat entries rendered AFTER the sub-groups — a section's trailing * ungrouped tail (e.g. plugins not assigned to a sub-group). Keeps * them visible + in order without forcing every page into a group. */ readonly trailingEntries?: ReadonlyArray /** Collapse the group by default. Open groups stay open across * navigations because the layout keeps its state in React. */ readonly defaultOpen?: boolean /** Render the contents WITHOUT the section header (promoted one level), * always expanded. Use when the sidebar is already scoped to this one * section by an external switcher, so repeating the section name as a * header would be redundant. */ readonly headerless?: boolean } export interface DocsTocEntry { readonly id: string readonly text: string readonly level: 2 | 3 } export interface DocsBreadcrumb { readonly label: string readonly href?: string } /** User-facing strings for the layout's own chrome. Defaults are English * so the layout works unconfigured; pass catalog values to localize. */ export interface DocsLayoutLabels { /** `aria-label` of the mobile menu-open button. Default `'Toggle menu'`. */ readonly toggleMenu?: string /** `aria-label` of the mobile drawer backdrop. Default `'Close menu'`. */ readonly closeMenu?: string /** `aria-label` of the drawer close button. Default `'Close'`. */ readonly close?: string /** Heading of the mobile drawer. Default `'Menu'`. */ readonly menu?: string /** Heading above the table of contents. Default `'On this page'`. */ readonly onThisPage?: string /** Edit-on-GitHub link text. Default `'Edit this page on GitHub →'`. */ readonly editPage?: string /** Prefix before the last-updated date. Default `'Updated'`. */ readonly updated?: string /** "Previous page" pager caption. Default `'Previous'`. */ readonly previous?: string /** "Next page" pager caption. Default `'Next'`. */ readonly next?: string /** `aria-label` of the prev/next pager nav. Default `'Pagination'`. */ readonly pagination?: string /** `aria-label` of the breadcrumb nav. Default `'Breadcrumb'`. */ readonly breadcrumb?: string } const DEFAULT_DOCS_LABELS: Required = { toggleMenu: 'Toggle menu', closeMenu: 'Close menu', close: 'Close', menu: 'Menu', onThisPage: 'On this page', editPage: 'Edit this page on GitHub →', updated: 'Updated', previous: 'Previous', next: 'Next', pagination: 'Pagination', breadcrumb: 'Breadcrumb', } // ---------- Layout props ---------- interface DocsLayoutProps { readonly brand: ReactNode readonly nav: ReadonlyArray readonly children: ReactNode /** Current pathname — used to highlight the active nav entry + * decide which group opens by default. */ readonly currentPath?: string /** Top-bar slots — render between brand and the right-hand actions. */ readonly topNav?: ReactNode /** Right-hand actions in the top bar (theme toggle, github link, …). */ readonly topRight?: ReactNode /** Optional search slot in the top bar. Drop in `` to wire up the kit's cmd-k modal, or any custom * search button you want. */ readonly search?: ReactNode /** Per-page metadata — when set, the layout renders the breadcrumbs, * title, description, TOC, prev/next pager, and edit-on-github * link. Drop this for landing-style index pages. */ readonly page?: { readonly title: string readonly description?: string readonly section?: string readonly breadcrumbs?: ReadonlyArray readonly toc?: ReadonlyArray readonly prev?: { readonly href: string; readonly label: string; readonly description?: string } readonly next?: { readonly href: string; readonly label: string; readonly description?: string } readonly editUrl?: string readonly lastUpdated?: string } /** Optional client-router Link. Same contract as DocShell. */ readonly LinkComponent?: ComponentType readonly className?: string /** Override the layout's own chrome strings. Defaults are English. */ readonly labels?: DocsLayoutLabels /** Optional footer node — rendered at the very bottom inside the * content column. */ readonly footer?: ReactNode /** Optional node pinned to the TOP of the sidebar (above the nav * groups), in BOTH the desktop aside and the mobile drawer. Use for * a section/category switcher ("root toggle"). Omitted → the sidebar * renders exactly as before. */ readonly sidebarHeader?: ReactNode } const PlainLink = ({ to, className, children }: ShellLinkProps): ReactNode => ( {children} ) const isActiveHref = (href: string, currentPath: string | undefined): boolean => { if (!currentPath) return false if (currentPath === href) return true // Treat /docs/foo/ as a match for /docs/foo (trailing slash tolerance). if (currentPath.replace(/\/$/, '') === href.replace(/\/$/, '')) return true return false } // ---------- Sidebar nav group (collapsible) ---------- // Shared row styling — leaf links AND collapsible sub-group triggers // render identically (same size, colour, padding, left-rail indicator); // a sub-group only adds a chevron on the right. Keeps the sidebar one // uniform list instead of two visually distinct tiers. const NAV_ROW = 'relative w-full py-1.5 pl-4 pr-2 -ml-px text-sm text-left transition-colors border-l-2' const navRowColor = (active: boolean): string => active ? 'text-foreground font-medium border-primary' : 'text-muted-foreground hover:text-foreground border-transparent hover:border-border' // One leaf link row. const NavLeaf = ({ entry, currentPath, Link, }: { readonly entry: DocsNavItem readonly currentPath?: string readonly Link: ComponentType }): ReactNode => { const active = isActiveHref(entry.href, currentPath) return (
  • {entry.label} {entry.badge ? ( {entry.badge} ) : null}
  • ) } // A collapsible sub-group. Its trigger row is styled EXACTLY like a leaf // (NAV_ROW) plus a right-aligned chevron; children render in a further- // indented nested rail when open. Expanded by default when it holds the // active page. const SidebarSubGroup = ({ subgroup, currentPath, Link, }: { readonly subgroup: DocsNavSubGroup readonly currentPath?: string readonly Link: ComponentType }): ReactNode => { const containsActive = subgroup.entries.some((e) => isActiveHref(e.href, currentPath)) const [open, setOpen] = useState(subgroup.defaultOpen ?? containsActive) useEffect(() => { if (containsActive) setOpen(true) }, [containsActive]) return (
  • {open ? (
      {subgroup.entries.map((e) => ( ))}
    ) : null}
  • ) } const SidebarGroup = ({ group, currentPath, Link, }: { readonly group: DocsNavGroup readonly currentPath?: string readonly Link: ComponentType }): ReactNode => { // A section is "open" if explicitly defaultOpen OR contains the active // entry (directly or in any sub-group). Local state lets the user // toggle either way after that. const subgroups = group.subgroups ?? [] const trailingEntries = group.trailingEntries ?? [] const headerless = group.headerless ?? false const containsActive = group.entries.some((e) => isActiveHref(e.href, currentPath)) || subgroups.some((sg) => sg.entries.some((e) => isActiveHref(e.href, currentPath))) || trailingEntries.some((e) => isActiveHref(e.href, currentPath)) const [open, setOpen] = useState(group.defaultOpen ?? containsActive ?? true) useEffect(() => { if (containsActive) setOpen(true) }, [containsActive]) return (
    {headerless ? null : ( )} {(headerless || open) && (group.entries.length > 0 || subgroups.length > 0 || trailingEntries.length > 0) ? (
      {group.entries.map((e) => ( ))} {subgroups.map((sg) => ( ))} {trailingEntries.map((e) => ( ))}
    ) : null}
    ) } // ---------- TOC aside (right column) ---------- const TocAside = ({ toc, editUrl, lastUpdated, t }: { readonly toc: ReadonlyArray readonly editUrl?: string readonly lastUpdated?: string readonly t: Required }): ReactNode => ( ) // ---------- Footer pager ---------- const FooterPager = ({ prev, next, Link, t, }: { readonly prev?: { readonly href: string; readonly label: string; readonly description?: string } readonly next?: { readonly href: string; readonly label: string; readonly description?: string } readonly Link: ComponentType readonly t: Required }): ReactNode => { if (!prev && !next) return null return ( ) } // ---------- Breadcrumbs ---------- const Breadcrumbs = ({ items, Link, ariaLabel, }: { readonly items: ReadonlyArray readonly Link: ComponentType readonly ariaLabel: string }): ReactNode => ( ) // ---------- The layout itself ---------- export const DocsLayout = ({ brand, nav, children, currentPath, topNav, topRight, search, page, LinkComponent = PlainLink, className, labels, footer, sidebarHeader, }: DocsLayoutProps): ReactNode => { const Link = LinkComponent const t = { ...DEFAULT_DOCS_LABELS, ...labels } // Mobile drawer state — only the inline state, no portal/escape // handling here; the search-trigger button can call setMobileOpen // via a parent island when full cmd-k lands. const [mobileOpen, setMobileOpen] = useState(false) return (
    {/* Top bar — shared chrome. The PageHeader owns * sticky / max-width / h-16 / glass-on-scroll fade. The * children below define the docs-specific row: mobile menu, * brand, optional topNav, search, topRight slots. */}
    {brand}
    {topNav ?
    {topNav}
    : null}
    {search ?
    {search}
    : null} {topRight ?
    {topRight}
    : null}
    {/* Sidebar (desktop) */} {/* Sidebar (mobile drawer) */} {mobileOpen ? (
    {/* Backdrop */}
    setMobileOpen(false)} />
    ) : null} {/* Content + TOC */}
    {page?.breadcrumbs && page.breadcrumbs.length > 0 ? ( ) : null} {page?.title ? (

    {page.title}

    ) : null} {page?.description ? (

    {page.description}

    ) : null} {children} {footer ?
    {footer}
    : null}
    {page?.toc !== undefined ? ( ) : null}
    ) }