/** * scripts/generate-docs/index.ts * * Entry point of the SmartStack CLI documentation generator. * Reads `templates/skills/<...>/SKILL.md` + `docs-src/*.md`, renders the * Handlebars page templates, and writes everything to `.documentation/`. * * Modes: * npx tsx scripts/generate-docs/index.ts # build * npx tsx scripts/generate-docs/index.ts --check # exit 1 on drift * npx tsx scripts/generate-docs/index.ts --watch # rebuild on change */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { performance } from 'node:perf_hooks'; import { parseAllSkills } from './lib/skill-parser'; import { parseAllPages, markdownToHtml, type PageMeta } from './lib/markdown-parser'; import { validateSidebar, buildSidebarForPage } from './lib/sidebar-builder'; import { collectStats } from './lib/stats'; import { buildContext } from './lib/context-builder'; import { getHandlebars, loadLayoutTemplate, loadPageTemplate } from './lib/handlebars-setup'; import { VERSION } from './lib/version'; const REPO_ROOT = join(__dirname, '..', '..'); const DOCS_OUT = join(REPO_ROOT, '.documentation'); interface CliFlags { check: boolean; watch: boolean; verbose: boolean; } function parseFlags(argv: string[]): CliFlags { return { check: argv.includes('--check'), watch: argv.includes('--watch'), verbose: argv.includes('--verbose') || argv.includes('-v'), }; } /** * Render one page to its final HTML string. The page template renders the * "body" — i.e. main content of the page — and the layout wraps it in the * shared HTML chrome (head, header, sidebar, footer). */ function renderPage(page: PageMeta, allSkills: ReturnType, stats: ReturnType): string { const sidebar = buildSidebarForPage(page.slug); // 0. Handlebars-preprocess the markdown body so pages can embed computed // values ({{stats.skills}}, {{stats.baSkills}}, {{stats.rules.UC}}, …) // instead of baking counters that go stale. const bodyMd = getHandlebars().compile(page.bodyMarkdown, { noEscape: true })({ stats, version: VERSION, }); const preprocessed: PageMeta = { ...page, bodyHtml: markdownToHtml(bodyMd) }; const ctx = buildContext(preprocessed, sidebar, allSkills, stats); // 1. Render the page template (often just `{{{bodyHtml}}}` plus auto-injected // skill grids); the result is the inner content placed in the layout's // {{{bodyHtml}}} slot. const pageTpl = loadPageTemplate(page.pageTemplate); const innerHtml = pageTpl(ctx); // 2. Render the outer layout with the inner content as its body. const layoutTpl = loadLayoutTemplate(); const html = layoutTpl({ ...ctx, bodyHtml: innerHtml }); // 3. Normalise line endings — committed files use LF (see .gitattributes). return html.replace(/\r\n/g, '\n'); } interface RenderResult { /** Output filename (e.g. `gitflow.html`, `manifest.json`) → file content */ generated: Map; pageCount: number; duration: number; } /** * Machine-readable list of the generated pages, in sidebar order. `ss docs` * reads this at runtime instead of maintaining a hardcoded page list. */ function buildManifest(pages: PageMeta[]): string { const sidebarRaw = JSON.parse( readFileSync(join(REPO_ROOT, 'docs-src', '_data', 'sidebar.json'), 'utf-8') ) as { sections: Array<{ items: Array<{ page: string; children?: Array<{ page: string }> }> }> }; const order: string[] = []; const visit = (item: { page: string; children?: Array<{ page: string }> }): void => { order.push(item.page); item.children?.forEach(visit); }; for (const section of sidebarRaw.sections) section.items.forEach(visit); const bySlug = new Map(pages.map((p) => [p.slug, p])); const ordered = [ ...order.filter((s) => bySlug.has(s)), ...pages.map((p) => p.slug).filter((s) => !order.includes(s)), ]; const entries = ordered.map((slug) => { const p = bySlug.get(slug)!; return { slug, icon: p.icon, title: p.title }; }); return JSON.stringify({ generatedBy: 'scripts/generate-docs', pages: entries }, null, 2) + '\n'; } function build(): RenderResult { const t0 = performance.now(); const skills = parseAllSkills(); const pages = parseAllPages(); validateSidebar(pages.map((p) => p.slug)); const stats = collectStats(); const generated = new Map(); for (const page of pages) { generated.set(`${page.slug}.html`, renderPage(page, skills, stats)); } generated.set('manifest.json', buildManifest(pages)); return { generated, pageCount: pages.length, duration: performance.now() - t0 }; } function writeAll(generated: Map): void { if (!existsSync(DOCS_OUT)) mkdirSync(DOCS_OUT, { recursive: true }); for (const [filename, content] of generated) { writeFileSync(join(DOCS_OUT, filename), content, 'utf-8'); } } function checkDrift(generated: Map): number { let drift = 0; for (const [filename, expected] of generated) { const file = join(DOCS_OUT, filename); const actual = existsSync(file) ? readFileSync(file, 'utf-8').replace(/\r\n/g, '\n') : ''; if (actual !== expected) { drift++; // eslint-disable-next-line no-console console.error(`DRIFT: .documentation/${filename} differs from generator output`); if (process.env.DOCS_CHECK_DIFF === '1') { // Minimal first-divergence diff — full diff via the `diff` package on demand const minLen = Math.min(actual.length, expected.length); let i = 0; while (i < minLen && actual[i] === expected[i]) i++; const ctx = (s: string) => s.slice(Math.max(0, i - 40), i + 80); // eslint-disable-next-line no-console console.error(` first diff at offset ${i}:`); // eslint-disable-next-line no-console console.error(` actual: ...${JSON.stringify(ctx(actual))}...`); // eslint-disable-next-line no-console console.error(` expected: ...${JSON.stringify(ctx(expected))}...`); } } } return drift; } async function main(): Promise { const flags = parseFlags(process.argv.slice(2)); const { generated, pageCount, duration } = build(); if (flags.check) { const drift = checkDrift(generated); if (drift > 0) { // eslint-disable-next-line no-console console.error(`\n${drift} file(s) out of sync. Run \`npm run build:docs\` to regenerate.`); return 1; } // eslint-disable-next-line no-console console.log(`docs in sync (${pageCount} pages, ${duration.toFixed(0)}ms)`); return 0; } writeAll(generated); // eslint-disable-next-line no-console console.log(`Generated ${pageCount} pages in ${duration.toFixed(0)}ms → .documentation/`); if (flags.watch) { const { watch } = await import('chokidar'); // chokidar v4 dropped glob support — watch directories and filter events. const watcher = watch( [ join(REPO_ROOT, 'docs-src'), join(REPO_ROOT, 'templates', 'skills'), join(__dirname, 'templates'), ], { ignoreInitial: true, ignored: (path) => /node_modules|[\\/]\.git[\\/]/.test(path), } ); watcher.on('all', (event, path) => { // Under templates/skills only SKILL.md frontmatter feeds the generator. if (/[\\/]templates[\\/]skills[\\/]/.test(path) && !/SKILL\.md$/.test(path)) return; try { // eslint-disable-next-line no-console console.log(`[${event}] ${path} → rebuild`); const { generated: g } = build(); writeAll(g); } catch (err) { // eslint-disable-next-line no-console console.error('build failed:', err); } }); // eslint-disable-next-line no-console console.log('watching for changes (Ctrl+C to stop)…'); return new Promise(() => undefined); // never resolves } return 0; } main().then( (code) => process.exit(code), (err) => { // eslint-disable-next-line no-console console.error(err); process.exit(2); } );