/** * scripts/generate-docs/lib/sidebar-builder.ts * * Reads `docs-src/_data/sidebar.json`, validates that every referenced page * exists in `docs-src/*.md`, and returns a per-page copy grouped into the two * audience zones — `discover` (non-technical readers, rendered first) then * `tech` — with the `active` flag injected on the current page entry. */ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import type { LocalisedString } from './markdown-parser'; const SIDEBAR_PATH = join(__dirname, '..', '..', '..', 'docs-src', '_data', 'sidebar.json'); export type Audience = 'discover' | 'tech'; const AUDIENCES: Audience[] = ['discover', 'tech']; export interface SidebarItem { page: string; icon: string; label: LocalisedString; active?: boolean; /** Optional nested entries rendered indented below the parent. */ children?: SidebarItem[]; } export interface SidebarSection { audience: Audience; /** Group title. Omitted for a section whose items sit directly under the zone label. */ title?: LocalisedString; items: SidebarItem[]; } export interface SidebarZone { audience: Audience; label: LocalisedString; sections: SidebarSection[]; } export interface Sidebar { zones: SidebarZone[]; } interface SidebarFile { zones: Record; sections: SidebarSection[]; } /** Where a page sits in the navigation — feeds the breadcrumb and the search index. */ export interface PagePlacement { audience: Audience; zone: LocalisedString; group?: LocalisedString; label: LocalisedString; } let cached: SidebarFile | null = null; function loadSidebar(): SidebarFile { if (cached) return cached; cached = JSON.parse(readFileSync(SIDEBAR_PATH, 'utf-8')) as SidebarFile; return cached; } function visitItems(sidebar: SidebarFile, fn: (item: SidebarItem, section: SidebarSection) => void): void { const visit = (section: SidebarSection) => (item: SidebarItem): void => { fn(item, section); item.children?.forEach(visit(section)); }; for (const section of sidebar.sections) section.items.forEach(visit(section)); } /** * Throws if any sidebar entry points to a page slug that wasn't found among * the parsed pages — catches "added link to sidebar but never to docs-src" — * or if a section declares no valid audience. Recurses into `children`. */ export function validateSidebar(knownSlugs: string[]): void { const sidebar = loadSidebar(); const known = new Set(knownSlugs); const errors: string[] = []; for (const audience of AUDIENCES) { if (!sidebar.zones?.[audience]) errors.push(`zones.${audience} is missing`); } sidebar.sections.forEach((section, i) => { if (!AUDIENCES.includes(section.audience)) { errors.push(`sections[${i}].audience must be one of ${AUDIENCES.join(' | ')}`); } }); const missing: string[] = []; visitItems(sidebar, (item) => { if (!known.has(item.page)) missing.push(item.page); }); if (missing.length) errors.push(`pages with no docs-src/.md: ${missing.join(', ')}`); if (errors.length) throw new Error(`sidebar.json is invalid:\n - ${errors.join('\n - ')}`); } /** Page slugs in sidebar order (zones first-to-last, then items depth-first). */ export function sidebarOrder(): string[] { const order: string[] = []; const sidebar = loadSidebar(); for (const audience of AUDIENCES) { const zoneSidebar: SidebarFile = { ...sidebar, sections: sidebar.sections.filter((s) => s.audience === audience) }; visitItems(zoneSidebar, (item) => order.push(item.page)); } return order; } /** Zone, group and label of a page, or `undefined` when the page is not in the sidebar. */ export function placementOf(slug: string): PagePlacement | undefined { const sidebar = loadSidebar(); let found: PagePlacement | undefined; visitItems(sidebar, (item, section) => { if (found || item.page !== slug) return; found = { audience: section.audience, zone: sidebar.zones[section.audience], group: section.title, label: item.label, }; }); return found; } /** * Returns a deep-cloned sidebar, grouped by zone, with `active: true` set on * the item (or nested child) whose `page` matches the current page slug. * Cloning keeps each page render independent. */ export function buildSidebarForPage(currentSlug: string): Sidebar { const sidebar = loadSidebar(); const cloneItem = (item: SidebarItem): SidebarItem => ({ ...item, active: item.page === currentSlug, children: item.children?.map(cloneItem), }); return { zones: AUDIENCES.map((audience) => ({ audience, label: sidebar.zones[audience], sections: sidebar.sections .filter((s) => s.audience === audience) .map((s) => ({ ...s, items: s.items.map(cloneItem) })), })), }; }