Files
suaveplan/scripts/snapshot-docs-content.ts
2026-05-27 23:47:53 +01:00

335 lines
11 KiB
TypeScript

#!/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:
*
* <repo>/README.md → apps/docs/docs/index.md
* <repo>/_intro.md → (skipped — already in README.md)
* <repo>/STATUS.md → apps/docs/docs/STATUS.md
* <repo>/SUMMARY.md → apps/docs/docs/SUMMARY.md
* <repo>/MANIFEST.md → apps/docs/docs/MANIFEST.md
* <repo>/CLAUDE.md → apps/docs/docs/CLAUDE.md
* <repo>/AGENTS.md → apps/docs/docs/AGENTS.md
* <repo>/packages/<cat>/README.md → apps/docs/docs/packages/<cat>/index.md
* <repo>/packages/<cat>/<plugin>/README.md → apps/docs/docs/packages/<cat>/<plugin>/index.md
* <repo>/packages/<cat>/<plugin>/<pkg>/README.md → apps/docs/docs/packages/<cat>/<plugin>/<pkg>/index.md
* <repo>/packages/<cat>/<plugin>/<pkg>/src/.../[file].md → mirrored under the
* same relative path inside apps/docs/docs/packages/.
* <repo>/openspec/specs/<cap>/spec.md → apps/docs/docs/openspec/specs/<cap>/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 `<sibling>/README.md` end up pointing at `<sibling>/`
* (works with VitePress' clean-URL routing) and links pointing at
* `./<feature>.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<typeof readdirSync>;
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 `<dirname>.md` and `index.md`, drop the
// `index.md` from the walk — both files map to the same Docusaurus
// route under the directory and `<dirname>.md` is the canonical
// module doc by convention. Same logic for `README.md` vs
// `<dirname>.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<string>
): 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<typeof readdirSync>;
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<string>();
// 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();