Initial Commit
This commit is contained in:
334
scripts/snapshot-docs-content.ts
Normal file
334
scripts/snapshot-docs-content.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
#!/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();
|
||||
Reference in New Issue
Block a user