Ctrl K
ComunidadGuías…

scripts/validate-docs.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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.`,
  );