import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { readFrontmatter } from '../src/lib/docs-frontmatter.ts'; const scriptDir = dirname(fileURLToPath(import.meta.url)); const componentsDir = resolve(scriptDir, '../content/docs/components'); const outputPath = resolve(scriptDir, '../src/generated/components-index.generated.ts'); interface ComponentIndexEntry { description: string; name: string; url: string; } interface ComponentIndexGroup { entries: Array; title: string; } /** * Emits the docs app's single runtime source of truth for the component catalogue: one entry per * component guide, grouped by category in sidebar order. The components landing page renders from * this array, so the documented set can never drift from the guides that live on disk. */ export function generateComponentsIndex(rootDir: string = componentsDir): string { const groups = readGroups(rootDir); const groupEntries = groups .map( (group) => `\t{ \t\ttitle: ${quote(group.title)}, \t\tentries: [ ${group.entries .map((entry) => { return `\t\t\t{ description: ${quote(entry.description)}, name: ${quote(entry.name)}, url: ${quote(entry.url)} },`; }) .join('\n')} \t\t], \t},`, ) .join('\n'); return `// Generated by scripts/generate-components-index.ts — do not edit. /** One component guide: its title, one-line description, and URL. */ export interface ComponentIndexEntry { description: string; name: string; url: string; } /** A component category with its guides, in sidebar order. */ export interface ComponentIndexGroup { entries: ReadonlyArray; title: string; } /** Every component guide, grouped by category, in sidebar order. */ export const componentIndexGroups: ReadonlyArray = [ ${groupEntries} ]; `; } function readGroups(rootDir: string): ReadonlyArray { const rootMeta = readJson(resolve(rootDir, 'meta.json')) as { pages?: Array }; const entries = rootMeta.pages ?? []; const groups: Array = []; let currentTitle = 'Components'; // Stays undefined until a source-backed guide arrives, so separators never emit an empty group. let currentGroup: ComponentIndexGroup | undefined; for (const entry of entries) { if (isSeparator(entry)) { currentTitle = entry.slice(3, -3); currentGroup = undefined; continue; } const guidePath = resolve(rootDir, `${entry}.mdx`); if (!existsSync(guidePath)) continue; const frontmatter = readFrontmatter(readFileSync(guidePath, 'utf8')); if (frontmatter.source === undefined) continue; if (!currentGroup) { currentGroup = { entries: [], title: currentTitle }; groups.push(currentGroup); } currentGroup.entries.push({ description: frontmatter.description ?? '', name: frontmatter.title ?? entry, url: `/components/${entry}`, }); } return groups; } function isSeparator(value: string): boolean { return /^---.+---$/.test(value); } function readJson(path: string): unknown { return JSON.parse(readFileSync(path, 'utf8')); } function quote(text: string): string { return `'${text.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`; } if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const output = generateComponentsIndex(); mkdirSync(dirname(outputPath), { recursive: true }); writeFileSync(outputPath, output); const entryCount = readGroups(componentsDir).flatMap((group) => group.entries).length; // oxlint-disable-next-line no-console console.log(`generate-components-index: wrote ${entryCount} entries`); }