335 lines
11 KiB
TypeScript
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();
|