/** * 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/`: * one `.html` per page, `manifest.json` (read by `ss docs`) and * `js/search-index.js` (the client-side search index). * * 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 { dirname, 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, placementOf, sidebarOrder } from './lib/sidebar-builder'; import { addHeadingIds, outlinePage, type PageOutline } from './lib/page-outline'; import { assertIndexAnchors, buildSearchIndex, type SearchPageInput } from './lib/search-index'; 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'), }; } interface RenderedPage { html: string; outline: PageOutline; } /** * 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, "on this page" column). */ function renderPage(page: PageMeta, allSkills: ReturnType, stats: ReturnType): RenderedPage { const sidebar = buildSidebarForPage(page.slug); const placement = placementOf(page.slug); // 0. Handlebars-preprocess the markdown body so pages can embed computed // values ({{stats.skills}}, {{stats.baSkills}}, {{stats.rules.UC}}, …) // and partials ({{> deploy-flow}}) instead of baking content that goes 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, placement); // 1. Render the page template (often just `{{{bodyHtml}}}` plus auto-injected // skill lists), then anchor every h2/h3 — marked emits no heading ids. const pageTpl = loadPageTemplate(page.pageTemplate); const innerHtml = addHeadingIds(pageTpl(ctx)); const outline = outlinePage(innerHtml); // 2. Render the outer layout with the inner content as its body and the h2 // list as the "on this page" column. const toc = page.wide ? [] : outline.sections.filter((s) => s.level === 2).map((s) => ({ id: s.id, title: s.title })); const layoutTpl = loadLayoutTemplate(); const html = layoutTpl({ ...ctx, bodyHtml: innerHtml, toc: toc.length >= 2 ? toc : [] }); // 3. Normalise line endings — committed files use LF (see .gitattributes). return { html: html.replace(/\r\n/g, '\n'), outline }; } interface RenderResult { /** Output path under .documentation/ (e.g. `gitflow.html`, `js/search-index.js`) → 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 order = sidebarOrder(); 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'; } /** Position in the sidebar; pages missing from it go last. */ function rank(order: string[], slug: string): number { const i = order.indexOf(slug); return i < 0 ? order.length : i; } 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(); const htmlBySlug = new Map(); const searchPages: SearchPageInput[] = []; const order = sidebarOrder(); const ordered = [...pages].sort((a, b) => rank(order, a.slug) - rank(order, b.slug)); for (const page of ordered) { const { html, outline } = renderPage(page, skills, stats); generated.set(`${page.slug}.html`, html); htmlBySlug.set(page.slug, html); searchPages.push({ slug: page.slug, title: page.title, placement: placementOf(page.slug), outline }); } assertIndexAnchors(searchPages, skills, htmlBySlug); generated.set('manifest.json', buildManifest(pages)); generated.set('js/search-index.js', buildSearchIndex(searchPages, skills, VERSION)); 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) { const file = join(DOCS_OUT, filename); mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, 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); } );