#!/usr/bin/env bun /** * snapshot-docs-content — mirror the repo's documentation tree into * `apps/docs/docs/` so the SSG (VitePress) can produce a self-contained * static site whose links resolve locally (no GitHub URLs). * * What's mirrored: * * /README.md → apps/docs/docs/index.md * /_intro.md → (skipped — already in README.md) * /STATUS.md → apps/docs/docs/STATUS.md * /SUMMARY.md → apps/docs/docs/SUMMARY.md * /MANIFEST.md → apps/docs/docs/MANIFEST.md * /CLAUDE.md → apps/docs/docs/CLAUDE.md * /AGENTS.md → apps/docs/docs/AGENTS.md * /packages//README.md → apps/docs/docs/packages//index.md * /packages///README.md → apps/docs/docs/packages///index.md * /packages////README.md → apps/docs/docs/packages////index.md * /packages////src/.../[file].md → mirrored under the * same relative path inside apps/docs/docs/packages/. * /openspec/specs//spec.md → apps/docs/docs/openspec/specs//spec.md * * Path translation rules: * - `README.md` files become `index.md` so VitePress maps them to clean * directory URLs (`packages/api-stack/api/api-schemas/`). * - Relative `.md` links inside content are rewritten so that links * pointing at `/README.md` end up pointing at `/` * (works with VitePress' clean-URL routing) and links pointing at * `./.md` remain unchanged. * * Skipped: * - `.types.md` and `SKILL.md` files (excluded from the documentation * hierarchy by convention — see `scripts/analyze-doc-tree.ts`). * - `node_modules`, `dist`, `build`, `coverage`, `.turbo`, `.cache`. * - Everything under `openspec/changes/`, `openspec/archive/`, * `openspec/templates/`, `docs/old-network-planning/`. * * Usage: * bun scripts/snapshot-docs-content.ts # rewrite * bun scripts/snapshot-docs-content.ts --check # exit 1 on drift * bun scripts/snapshot-docs-content.ts --quiet # counts only */ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; import { CONFIG } from "./lib/config.js"; const ROOT = CONFIG.paths.root; const DOCS_OUT = join(ROOT, "apps", "docs", "docs"); const TOP_LEVEL_DOCS: ReadonlyArray<[string, string]> = [ ["README.md", "index.md"], ["STATUS.md", "STATUS.md"], ["SUMMARY.md", "SUMMARY.md"], ["MANIFEST.md", "MANIFEST.md"], ["CLAUDE.md", "CLAUDE.md"], ["AGENTS.md", "AGENTS.md"], ]; const IGNORE_DIR_SEGMENTS: readonly string[] = [ "node_modules", "dist", "build", "coverage", ".turbo", ".vite", ".cache", ".docusaurus", "openspec/changes", "openspec/archive", "openspec/templates", "docs/old-network-planning", ]; const IGNORE_FILE_SUFFIXES: readonly string[] = [".types.md", "SKILL.md"]; function shouldSkipPath(absPath: string): boolean { const rel = relative(ROOT, absPath).replace(/\\/g, "/"); for (const seg of IGNORE_DIR_SEGMENTS) { if (rel === seg || rel.startsWith(`${seg}/`)) return true; } return IGNORE_FILE_SUFFIXES.some((suf) => absPath.endsWith(suf)); } function walkMdFiles(dir: string): string[] { const out: string[] = []; const stack: string[] = [dir]; while (stack.length > 0) { const current = stack.pop() as string; let entries: ReturnType; try { entries = readdirSync(current, { withFileTypes: true }); } catch { continue; } const fileNames = new Set( entries.filter((e) => e.isFile()).map((e) => e.name) ); // If a directory contains both `.md` and `index.md`, drop the // `index.md` from the walk — both files map to the same Docusaurus // route under the directory and `.md` is the canonical // module doc by convention. Same logic for `README.md` vs // `.md`. const dirName = current.split(/[/\\]/).pop() ?? ""; const canonicalDirMd = `${dirName}.md`; const dropIndex = fileNames.has(canonicalDirMd) && fileNames.has("index.md"); const dropReadme = fileNames.has(canonicalDirMd) && fileNames.has("README.md"); for (const entry of entries) { const next = join(current, entry.name); if (shouldSkipPath(next)) continue; if (entry.isDirectory()) { if (entry.name.startsWith(".")) continue; if (entry.name === "node_modules") continue; stack.push(next); continue; } if (!entry.isFile() || !entry.name.endsWith(".md")) continue; if (dropIndex && entry.name === "index.md") continue; if (dropReadme && entry.name === "README.md") continue; out.push(next); } } out.sort(); return out; } /** * Translate a source `.md` path to its destination under `apps/docs/docs/`. * * Returns undefined for files outside the mirrored scope. */ function destinationFor(srcAbs: string): string | undefined { const rel = relative(ROOT, srcAbs).replace(/\\/g, "/"); for (const [from, to] of TOP_LEVEL_DOCS) { if (rel === from) return join(DOCS_OUT, to); } if (rel.startsWith("packages/")) { // Translate trailing `/README.md` to `/index.md` so VitePress routes // the directory cleanly. const translated = rel.replace(/\/README\.md$/u, "/index.md"); return join(DOCS_OUT, translated); } if (rel.startsWith("openspec/specs/")) { return join(DOCS_OUT, rel); } return undefined; } /** * Rewrite relative `.md` links inside content: * - `README.md` → `index.md` so the SSG routes the directory cleanly. * - Links pointing at `.types.md` or `SKILL.md` (intentionally excluded * from the snapshot) collapse to inline-code spans so the link checker * does not flag them as unresolved. */ function rewriteLinks(content: string): string { return content.replace( /\[([^\]]*)\]\(([^)]+\.md)(#[^)]*)?\)/gu, (match, label: string, target: string, anchor: string | undefined) => { if (/^https?:|^mailto:|^\//u.test(target)) return match; if (/\.types\.md$/u.test(target) || /SKILL\.md$/u.test(target)) { return `\`${label || target}\``; } const remapped = target.replace( /(?:^|\/)README\.md$/u, "/index.md" ); return `[${label}](${remapped}${anchor ?? ""})`; } ); } function ensureDir(path: string): void { mkdirSync(dirname(path), { recursive: true }); } function readFileSafe(path: string): string | undefined { try { return readFileSync(path, "utf-8"); } catch { return undefined; } } interface CliOptions { check: boolean; quiet: boolean; } function parseArgs(argv: readonly string[]): CliOptions { let check = false; let quiet = false; for (const arg of argv) { if (arg === "--check") check = true; else if (arg === "--quiet" || arg === "-q") quiet = true; else throw new Error(`Unknown flag: ${arg}`); } return { check, quiet }; } interface Stats { written: number; unchanged: number; skipped: number; } function processFile( srcAbs: string, options: CliOptions, stats: Stats, seenDest: Set ): void { const dest = destinationFor(srcAbs); if (!dest) { stats.skipped++; return; } seenDest.add(resolve(dest)); const original = readFileSafe(srcAbs); if (original === undefined) { stats.skipped++; return; } const next = rewriteLinks(original); const current = readFileSafe(dest) ?? ""; if (current === next) { stats.unchanged++; return; } if (!options.check) { ensureDir(dest); writeFileSync(dest, next); } stats.written++; } function walkSnapshotDir(dir: string): string[] { if (!existsSync(dir)) return []; const out: string[] = []; const stack: string[] = [dir]; while (stack.length > 0) { const current = stack.pop() as string; let entries: ReturnType; try { entries = readdirSync(current, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { const next = join(current, entry.name); if (entry.isDirectory()) { stack.push(next); continue; } if (entry.isFile() && entry.name.endsWith(".md")) out.push(next); } } return out; } function main(): void { let options: CliOptions; try { options = parseArgs(process.argv.slice(2)); } catch (err) { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); } if (!existsSync(DOCS_OUT)) mkdirSync(DOCS_OUT, { recursive: true }); const stats: Stats = { written: 0, unchanged: 0, skipped: 0 }; const seenDest = new Set(); // Top-level docs first. for (const [src] of TOP_LEVEL_DOCS) { const abs = join(ROOT, src); if (existsSync(abs)) processFile(abs, options, stats, seenDest); } // Walk packages/ and openspec/specs/. for (const rootSub of ["packages", "openspec/specs"]) { const abs = join(ROOT, rootSub); if (!existsSync(abs)) continue; for (const md of walkMdFiles(abs)) { processFile(md, options, stats, seenDest); } } // Prune stale files in DOCS_OUT that weren't written this run. Skip // VitePress config dir and any non-`.md` artifacts. let pruned = 0; for (const existing of walkSnapshotDir(DOCS_OUT)) { const normalized = resolve(existing); if (seenDest.has(normalized)) continue; // Preserve hand-authored content under `.vitepress/` and any sibling // non-`.md` assets — only delete `.md` files that the snapshot is // responsible for and no longer produces. if (existing.includes("/.vitepress/")) continue; if (!options.check) rmSync(existing); pruned++; } if (!options.quiet) { console.log(""); console.log(`✏️ Written: ${stats.written}`); console.log(`⏭️ Unchanged: ${stats.unchanged}`); if (stats.skipped > 0) console.log(`⚠️ Skipped: ${stats.skipped}`); if (pruned > 0) console.log(`🗑️ Pruned: ${pruned}`); } else { console.log( `written=${stats.written} unchanged=${stats.unchanged} pruned=${pruned}` ); } if (options.check && (stats.written > 0 || pruned > 0)) { console.error(""); console.error("Drift detected — run without --check to refresh."); process.exit(1); } process.exit(0); } main();