"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { docsContent, type DocPage, type DocsContent } from "./data";
import { TopBar } from "./sections/topbar";
import { MobileDrawer, NavTree } from "./sections/sidebar";
import { SearchDialog, type SearchEntry } from "./sections/search";
import { DocArticle } from "./sections/content";
import { MobileToc, Toc, useScrollSpy, type TocItem } from "./sections/toc";
import { DocsFooter } from "./sections/footer";
import { PmContext, scrollToHeading, type PackageManager } from "./sections/ui";
export type { DocsContent, DocPage };
export interface DocsTemplateProps {
/** Override brand, versions, navigation, pages or footer. Missing keys fall back to the demo docs. */
content?: Partial<DocsContent>;
/** Page shown first. Defaults to the first page of the first nav group. */
initialPage?: string;
/** Called whenever the reader navigates to another page. */
onPageChange?: (pageId: string) => void;
className?: string;
}
/**
* Orbit Docs — a developer documentation site.
* Sticky top bar with version switcher and ⌘K fuzzy search, collapsible nav tree (drawer on mobile),
* rich content blocks (callouts, package-manager tabs, highlighted code, API tables, steps),
* a scroll-spy "on this page" TOC, prev/next links, a feedback widget and a footer.
*/
export function DocsTemplate({ content, initialPage, onPageChange, className }: DocsTemplateProps) {
const c: DocsContent = { ...docsContent, ...content };
const pages = React.useMemo(() => new Map(c.pages.map((p) => [p.id, p])), [c.pages]);
const order = React.useMemo(() => c.nav.flatMap((g) => g.pages).filter((id) => pages.has(id)), [c.nav, pages]);
const [pageId, setPageId] = React.useState(initialPage && pages.has(initialPage) ? initialPage : (order[0] ?? c.pages[0].id));
const [version, setVersion] = React.useState(c.versions[0]?.id ?? "");
const [pm, setPm] = React.useState<PackageManager>("npm");
const [searchOpen, setSearchOpen] = React.useState(false);
const [drawerOpen, setDrawerOpen] = React.useState(false);
const [isMac, setIsMac] = React.useState(true);
const pendingHeading = React.useRef<string | null | undefined>(undefined);
const page = pages.get(pageId) ?? c.pages[0];
const idx = order.indexOf(page.id);
const group = c.nav.find((g) => g.pages.includes(page.id))?.title ?? "Docs";
const toc: TocItem[] = page.blocks.flatMap((b) => (b.t === "h2" || b.t === "h3" ? [{ id: b.id, text: b.text, level: b.t === "h2" ? 2 : 3 } as TocItem] : []));
const active = useScrollSpy(toc.map((t) => t.id));
React.useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsMac(/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent));
}, []);
// ⌘K / Ctrl+K and "/" open search.
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
const typing = !!target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable);
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setSearchOpen((o) => !o);
} else if (e.key === "/" && !typing) {
e.preventDefault();
setSearchOpen(true);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
// After a page switch: jump to the requested heading, or to the top and focus the title.
React.useLayoutEffect(() => {
const target = pendingHeading.current;
if (target === undefined) return;
pendingHeading.current = undefined;
if (target) requestAnimationFrame(() => scrollToHeading(target));
else {
window.scrollTo({ top: 0, behavior: "auto" });
document.getElementById("od-title")?.focus({ preventScroll: true });
}
}, [pageId]);
const navigate = React.useCallback(
(id: string, heading?: string) => {
setDrawerOpen(false);
setSearchOpen(false);
if (id === pageId) {
if (heading) scrollToHeading(heading);
else window.scrollTo({ top: 0, behavior: "smooth" });
return;
}
pendingHeading.current = heading ?? null;
setPageId(id);
onPageChange?.(id);
},
[pageId, onPageChange],
);
const entries: SearchEntry[] = React.useMemo(() => {
const out: SearchEntry[] = [];
for (const g of c.nav) {
for (const pid of g.pages) {
const p = pages.get(pid);
if (!p) continue;
out.push({ key: p.id, pageId: p.id, title: p.title, group: g.title, page: p.title });
for (const b of p.blocks) {
if (b.t === "h2" || b.t === "h3") out.push({ key: `${p.id}#${b.id}`, pageId: p.id, headingId: b.id, title: b.text, group: g.title, page: p.title });
}
}
}
return out;
}, [c.nav, pages]);
const suggested = entries.filter((e) => !e.headingId && ["quick-start", "installation", "create-client", "webhooks"].includes(e.pageId));
const versionNote = c.versions.find((v) => v.id === version)?.note;
const tree = (key: string) => <NavTree groups={c.nav} pages={pages} active={page.id} onNavigate={(id) => navigate(id)} layoutKey={key} />;
return (
<PmContext.Provider value={{ pm, setPm }}>
<div id="top" className={cn("relative w-full bg-background text-foreground antialiased", className)}>
<a
href="#od-title"
className="sr-only z-[90] rounded-md bg-foreground px-3 py-2 text-sm text-background focus:not-sr-only focus:fixed focus:left-3 focus:top-3"
>
Skip to content
</a>
<TopBar
brand={c.brand}
versions={c.versions}
version={version}
onVersion={setVersion}
links={c.topLinks}
activePage={page.id}
onNavigate={(id) => navigate(id)}
onSearch={() => setSearchOpen(true)}
onMenu={() => setDrawerOpen(true)}
menuOpen={drawerOpen}
isMac={isMac}
/>
<div className="mx-auto max-w-[1440px] px-4 sm:px-6 lg:grid lg:grid-cols-[250px_minmax(0,1fr)] lg:gap-10 xl:grid-cols-[250px_minmax(0,1fr)_220px]">
<div className="hidden border-r lg:block">
<aside className="sticky top-16 h-[calc(100dvh-4rem)] overflow-y-auto py-8 pr-4 [scrollbar-width:thin]" aria-label="Sidebar">
{tree("od-side")}
</aside>
</div>
<main className="mx-auto w-full min-w-0 max-w-3xl py-8 lg:py-12">
<DocArticle
page={page}
group={group}
prev={idx > 0 ? pages.get(order[idx - 1]) : undefined}
next={idx >= 0 && idx < order.length - 1 ? pages.get(order[idx + 1]) : undefined}
versionNote={versionNote}
onNavigate={(id) => navigate(id)}
mobileToc={<MobileToc items={toc} active={active} />}
/>
</main>
<aside className="sticky top-16 hidden h-[calc(100dvh-4rem)] overflow-y-auto py-12 xl:block" aria-label="Page outline">
<Toc items={toc} active={active} />
</aside>
</div>
<DocsFooter brand={c.brand} content={c.footer} />
<MobileDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} brand={c.brand}>
{tree("od-drawer")}
</MobileDrawer>
<SearchDialog
open={searchOpen}
onClose={() => setSearchOpen(false)}
entries={entries}
suggested={suggested}
onSelect={(e) => navigate(e.pageId, e.headingId)}
/>
</div>
</PmContext.Provider>
);
}
export default DocsTemplate;