/** * Builds one sidebar per (product, version, language) out of the loaded pages, * reproducing what docusaurus derived from `_category_.json` and from the * handwritten sidebar files of the runner docs. */ export interface SidebarPage { /** Route id of the page. */ id: string; /** Url of the page. */ href: string; title: string; hidden: boolean; meta: { product: string; version: string; locale: string; prefix: string; dir: string; name: string; order?: number; category?: boolean; }; } export interface SidebarLink { type: 'link'; label: string; href: string; isCurrent: boolean; badge: undefined; attrs: Record; } export interface SidebarGroup { type: 'group'; label: string; entries: SidebarEntry[]; collapsed: boolean; badge: undefined; } export type SidebarEntry = SidebarLink | SidebarGroup; /** A docusaurus sidebar file, as used by the runner docs. */ export type DocusaurusSidebarItem = | string | { type: 'doc'; id: string; label?: string } | { type: 'autogenerated'; dirName: string } | { type: 'category'; label: string; collapsed?: boolean; items: DocusaurusSidebarItem[] }; const last = Number.MAX_SAFE_INTEGER; function link(page: SidebarPage, label = page.title): SidebarLink { return { type: 'link', label, href: page.href, isCurrent: false, badge: undefined, attrs: {} }; } function labelFromDirname(name: string): string { return name .split('-') .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(' '); } /** * Builds the sidebars of every group of pages, keyed by the route prefix of the * group. `manual` provides the docusaurus sidebar of the groups that ship one. */ export function buildSidebars( pages: SidebarPage[], manual: Map = new Map(), ): Map { const groups = new Map(); for (const page of pages) { const list = groups.get(page.meta.prefix); if (list) list.push(page); else groups.set(page.meta.prefix, [page]); } const sidebars = new Map(); for (const [prefix, groupPages] of groups) { const key = `${groupPages[0]!.meta.product}@${groupPages[0]!.meta.version}`; const items = manual.get(key); const tree = treeOf(groupPages); sidebars.set(prefix, items ? fromDocusaurus(items, groupPages, tree) : tree.build('')); } return sidebars; } interface Tree { /** Entries of a directory, ordered as docusaurus ordered them. */ build(dir: string): SidebarEntry[]; /** Localized label of a directory, from its `_category_.json`. */ label(dir: string): string; } /** Sidebar of a directory tree, ordered by `sidebar_position` as docusaurus did. */ function treeOf(pages: SidebarPage[]): Tree { const categories = new Map(); for (const page of pages) { if (!page.meta.category) continue; const dir = [page.meta.dir, page.meta.name].filter(Boolean).join('/'); categories.set(dir, page); } const childDirs = (dir: string) => { const prefix = dir ? `${dir}/` : ''; const found = new Set(); for (const page of pages) { if (!page.meta.dir.startsWith(prefix)) continue; const rest = page.meta.dir.slice(prefix.length); if (!rest) continue; found.add(prefix + rest.split('/')[0]); } return [...found]; }; const build = (dir: string): SidebarEntry[] => { const entries: { sort: [number, number, string]; entry: SidebarEntry }[] = []; for (const child of childDirs(dir)) { const category = categories.get(child); const label = category?.title ?? labelFromDirname(child.split('/').at(-1)!); entries.push({ sort: [category?.meta.order ?? last, 0, label], entry: { type: 'group', label, entries: build(child), collapsed: true, badge: undefined }, }); } for (const page of pages) { if (page.meta.dir !== dir || page.meta.category || page.hidden) continue; entries.push({ sort: [page.meta.order ?? last, 1, page.title], entry: link(page) }); } return entries .sort( (a, b) => a.sort[0] - b.sort[0] || a.sort[1] - b.sort[1] || a.sort[2].localeCompare(b.sort[2]), ) .map((item) => item.entry); }; const label = (dir: string) => categories.get(dir)?.title ?? labelFromDirname(dir.split('/').at(-1)!); return { build, label }; } /** Sidebar described by a docusaurus sidebar file, used by the runner docs. */ function fromDocusaurus( items: DocusaurusSidebarItem[], pages: SidebarPage[], tree: Tree, ): SidebarEntry[] { const byDocId = new Map(); for (const page of pages) { byDocId.set([page.meta.dir, page.meta.name].filter(Boolean).join('/'), page); } const convert = (item: DocusaurusSidebarItem): SidebarEntry | undefined => { if (typeof item === 'string') { const page = byDocId.get(item); return page && link(page); } if (item.type === 'doc') { const page = byDocId.get(item.id); return page && link(page, item.label ?? page.title); } if (item.type === 'autogenerated') { // only reached inside a category, the caller flattens the result return { type: 'group', label: tree.label(item.dirName), entries: tree.build(item.dirName), collapsed: true, badge: undefined, }; } // a category whose only child is an autogenerated directory keeps the // translated label of the directory, like the docusaurus sidebars did const single = item.items.length === 1 && typeof item.items[0] === 'object' && 'dirName' in item.items[0] ? item.items[0] : undefined; if (single) { return { type: 'group', label: tree.label(single.dirName), entries: tree.build(single.dirName), collapsed: item.collapsed ?? true, badge: undefined, }; } return { type: 'group', label: item.label, entries: item.items.map(convert).filter((entry): entry is SidebarEntry => Boolean(entry)), collapsed: item.collapsed ?? true, badge: undefined, }; }; return items.map(convert).filter((entry): entry is SidebarEntry => Boolean(entry)); } /** Marks the entry of the current page and expands the groups leading to it. */ export function markCurrent(entries: SidebarEntry[], pathname: string): SidebarEntry[] { return entries.map((entry) => { if (entry.type === 'link') { return { ...entry, isCurrent: entry.href === pathname }; } const children = markCurrent(entry.entries, pathname); const contains = children.some( (child) => (child.type === 'link' && child.isCurrent) || (child.type === 'group' && !child.collapsed), ); return { ...entry, entries: children, collapsed: entry.collapsed && !contains }; }); } /** Flattens the sidebar into the reading order used for the previous and next links. */ export function flattenSidebar(entries: SidebarEntry[]): SidebarLink[] { return entries.flatMap((entry) => entry.type === 'link' ? [entry] : flattenSidebar(entry.entries), ); }