scripts/validate-docs.mjs
Rama main · réplica de lectura del repositorio original.
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { dirname, extname, resolve, relative } from "node:path";
import { fileURLToPath } from "node:url";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const docs = resolve(root, "docs");
const errors = [];
const pages = readdirSync(docs).filter((name) => name.endsWith(".md"));
const text = (file) =>
readFileSync(file, "utf8").replace(/^```[^\n]*\n[\s\S]*?^```\s*$/gm, "");
const headings = (file) => {
const seen = new Map();
return new Set(
[...text(file).matchAll(/^#{1,6}\s+(.+)$/gm)].map(([, title]) => {
const slug = title
.trim()
.toLowerCase()
.replace(/<[^>]+>/g, "")
.replace(/[^\p{L}\p{N}_\-\s]/gu, "")
.replace(/\s/g, "-");
const count = seen.get(slug) ?? 0;
seen.set(slug, count + 1);
return count ? `${slug}-${count}` : slug;
}),
);
};
const menu = JSON.parse(readFileSync(resolve(docs, "docs.json"), "utf8")).menu;
const entries = [];
function visit(items) {
for (const item of items) {
if (typeof item === "string") entries.push(item);
else if (item.items) visit(item.items);
else if (item.slug) entries.push(item.slug);
}
}
visit(menu);
for (const name of entries) {
if (name.includes("/") || !pages.includes(`${name}.md`))
errors.push(`Invalid navigation page: ${name}`);
}
for (const name of pages) {
if (
!/^---\n[\s\S]*?^description: .+$/m.test(
readFileSync(resolve(docs, name), "utf8"),
)
)
errors.push(`Missing page description: ${name}`);
if (!entries.includes(name.slice(0, -3)))
errors.push(`Page missing from navigation: ${name}`);
}
if (new Set(entries).size !== entries.length)
errors.push("Duplicate navigation pages");
for (const name of readdirSync(docs)) {
if (statSync(resolve(docs, name)).isDirectory())
errors.push(`Documentation must be flat: ${name}/`);
}
const sources = [
...pages.map((name) => resolve(docs, name)),
...["README.md", "AGENTS.md", "app/README.md", "agent/README.md"].map(
(name) => resolve(root, name),
),
];
for (const source of sources) {
for (const [, raw] of text(source).matchAll(/\[[^\]\n]+\]\(([^)\n]+)\)/g)) {
const target = raw.replace(/^<|>$/g, "").split(/\s+["']/)[0];
if (/^(https?:|mailto:|data:)/.test(target)) continue;
if (dirname(source) === docs && /^(?:\.\/)?[\w-]+\.md(?:#|$)/.test(target)) errors.push(`${relative(root, source)}: use a rendered /docs/page/ URL instead of a Markdown export: ${target}`);
let [path, anchor] = target.split("#");
if (path.startsWith("/docs/"))
path = resolve(docs, path.slice(6).replace(/\/$/, "") || "index") + ".md";
else if (path.startsWith("/")) continue;
else
path = path ? resolve(dirname(source), decodeURIComponent(path)) : source;
if (dirname(source) === docs && relative(docs, path).startsWith(".."))
errors.push(
`${relative(root, source)}: use a published repository URL for ${target}`,
);
if (!existsSync(path))
errors.push(`${relative(root, source)}: missing ${target}`);
else if (
anchor &&
extname(path) === ".md" &&
!headings(path).has(decodeURIComponent(anchor))
)
errors.push(`${relative(root, source)}: missing heading ${target}`);
}
}
if (errors.length) {
console.error(errors.join("\n"));
process.exitCode = 1;
} else
console.log(
`Documentation checked: ${pages.length} flat pages, navigation and local links.`,
);