Files
2026-08-12 21:49:04 -04:00

357 lines
12 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* CI lint that validates the markdown roots the docs site renders, before the
* site build consumes them.
*
* Two classes of failure are caught here so Vercel preview deploys don't have
* to catch them:
*
* 1. Every site-page markdown file (anything under a configured root that
* is not explicitly excluded) must start with YAML frontmatter
* containing the fields required by that root. Missing fields crash
* fumadocs' schema validation at build time.
*
* 2. For roots whose ordering is driven by `meta.json` (i.e. /docs),
* every site-page markdown file must be reachable from the sidebar nav
* — either listed by slug in an ancestor `meta.json#pages`, covered
* by a `"..."` token in that array, or explicitly allowed as a
* footer-only page. Otherwise the page renders at its URL but never
* appears in the sidebar (easy to miss in review).
*
* Files intentionally not part of the site (the top-level engineer-facing
* README.md in each root) are skipped via isExcluded().
*/
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { dirname, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ROOTS = [
{
label: "docs",
dir: resolve(fileURLToPath(new URL("../docs", import.meta.url))),
requireSidebarCoverage: true,
requiredFrontmatter: ["title", "description"],
},
];
const SIDEBAR_EXEMPT_PAGES = new Set([
// Linked from the global footer instead of the docs sidebar.
"responsible-use.md",
]);
// Only the top-level README.md is excluded. Nested READMEs (e.g.
// channels/README.md) are site pages and must carry a `url:` frontmatter
// override, validated below.
const isExcluded = (relPath) => relPath === "README.md";
const isSidebarExempt = (relPath) => SIDEBAR_EXEMPT_PAGES.has(relPath);
function walkMarkdown(dir) {
const out = [];
for (const entry of readdirSync(dir)) {
const full = `${dir}/${entry}`;
const stat = statSync(full);
if (stat.isDirectory()) {
out.push(...walkMarkdown(full));
} else if (entry.endsWith(".md") || entry.endsWith(".mdx")) {
out.push(full);
}
}
return out;
}
function parseFrontmatter(source) {
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!match) return null;
const data = {};
for (const line of match[1].split(/\r?\n/)) {
const m = line.match(/^([\w-]+):\s*(.*)$/);
if (!m) continue;
const [, key, rawValue] = m;
const value = rawValue.trim().replace(/^["']|["']$/g, "");
data[key] = value;
}
return data;
}
function loadMetaJson(dir) {
try {
return JSON.parse(readFileSync(`${dir}/meta.json`, "utf8"));
} catch {
return null;
}
}
// Collect every slug a meta.json references, plus whether that folder uses
// a `"..."` wildcard. Sibling files in a wildcard folder are auto-included.
function collectNavReferences(rootDir) {
const result = { explicit: new Set(), wildcardFolders: new Set() };
const visit = (dir) => {
const meta = loadMetaJson(dir);
const relDir = relative(rootDir, dir);
if (meta && Array.isArray(meta.pages)) {
for (const entry of meta.pages) {
if (typeof entry !== "string") continue;
if (entry === "..." || entry === "z...z") {
result.wildcardFolders.add(relDir);
continue;
}
if (entry === "---") continue;
if (/^---.+---$/.test(entry)) continue; // labeled separator: ---Group name---
if (entry.startsWith("[")) continue; // [Title](url) custom link
const slug = entry.startsWith("!")
? entry.slice(1)
: entry.startsWith("...")
? entry.slice(3)
: entry;
const key = relDir ? `${relDir}/${slug}` : slug;
result.explicit.add(key);
}
} else if (dir !== rootDir) {
// A folder with no meta.json behaves like an implicit wildcard — every
// sibling .md is auto-included by fumadocs.
result.wildcardFolders.add(relDir);
}
for (const entry of readdirSync(dir)) {
const full = `${dir}/${entry}`;
if (statSync(full).isDirectory()) visit(full);
}
};
visit(rootDir);
return result;
}
function isCoveredByNav(relPath, nav) {
// relPath looks like "foo.md", "tools/human-in-the-loop.mdx",
// "channels/index.md". We map to the slug form meta.json uses.
const slug = relPath.replace(/\.mdx?$/, "");
// Direct match: slug listed in an ancestor meta.json pages[].
if (nav.explicit.has(slug)) return true;
// Folder-indexes: fumadocs treats `channels/index.md` as the landing for
// the `channels` folder; a `"channels"` entry in root meta.json covers it.
if (slug.endsWith("/index")) {
const folderSlug = slug.slice(0, -"/index".length);
if (nav.explicit.has(folderSlug)) return true;
}
// A root-level file is covered by the root "..." wildcard.
const lastSlash = slug.lastIndexOf("/");
const folder = lastSlash === -1 ? "" : slug.slice(0, lastSlash);
if (nav.wildcardFolders.has(folder)) return true;
// A folder reference in an ancestor meta pulls in the whole folder.
if (lastSlash !== -1 && nav.explicit.has(folder)) return true;
return false;
}
const failures = [];
let validatedCount = 0;
for (const root of ROOTS) {
const allFiles = walkMarkdown(root.dir);
const nav = root.requireSidebarCoverage ? collectNavReferences(root.dir) : null;
for (const absPath of allFiles) {
const relPath = relative(root.dir, absPath).split("\\").join("/");
if (isExcluded(relPath)) continue;
if (root.include && !root.include(relPath)) continue;
validatedCount += 1;
const source = readFileSync(absPath, "utf8");
const fm = parseFrontmatter(source);
if (!fm) {
failures.push({
root: root.label,
file: relPath,
issue: "no frontmatter block (expected `---` ... `---` at top)",
});
continue;
}
for (const field of root.requiredFrontmatter) {
if (fm[field]) continue;
failures.push({
root: root.label,
file: relPath,
issue: `frontmatter missing \`${field}\``,
});
}
if (nav && !isSidebarExempt(relPath) && !isCoveredByNav(relPath, nav)) {
failures.push({
root: root.label,
file: relPath,
issue:
"not referenced in any meta.json#pages and not covered by a `...` wildcard — page would be orphaned from the sidebar",
});
}
}
}
// 3. Internal links resolve. Every relative (./ ../) or site-absolute (/docs/)
// markdown link in a doc page must point at a rendered doc URL or folder.
// fumadocs renders broken links as dead clicks; CI should catch them.
function renderedUrl(relPath, source) {
const slug = relPath.replace(/\.mdx?$/, "").replace(/(^|\/)index$/, "");
const override = parseFrontmatter(source)?.url;
const route = override || `/${slug}`;
return `/docs${route}`.replace(/\/$/, "");
}
function checkLinks(rootDir) {
const files = walkMarkdown(rootDir);
const renderedUrls = new Set();
for (const abs of files) {
const rel = relative(rootDir, abs).split("\\").join("/");
if (isExcluded(rel)) continue;
const source = readFileSync(abs, "utf8");
renderedUrls.add(renderedUrl(rel, source));
}
const linkRe = /\]\((\s*[^)]+?)\s*\)/g;
for (const abs of files) {
const rel = relative(rootDir, abs).split("\\").join("/");
if (isExcluded(rel)) continue;
const source = readFileSync(abs, "utf8");
const sourceUrl = renderedUrl(rel, source);
let m;
while ((m = linkRe.exec(source)) !== null) {
let target = m[1].trim();
if (!target) continue;
// Only validate doc-internal links.
const isRel = target.startsWith("./") || target.startsWith("../");
const isSite = target.startsWith("/docs/") || target === "/docs";
if (!isRel && !isSite) continue; // external, mailto, #anchor, bare /eve/* runtime route, etc.
target = target.split("#")[0].split("?")[0];
if (!target) continue; // pure in-page anchor
const resolvedUrl = new URL(target, `https://eve.dev${sourceUrl}`).pathname
.replace(/\/$/, "")
.replace(/\.mdx?$/, "");
if (resolvedUrl === "/docs") continue; // docs root / index
if (renderedUrls.has(resolvedUrl)) continue;
failures.push({
root: "docs",
file: rel,
issue: `broken internal link → \`${m[1].trim()}\` (resolves to \`${resolvedUrl}\`, no such page)`,
});
}
}
}
function checkMetaReferences(rootDir) {
const visit = (dir) => {
const meta = loadMetaJson(dir);
if (meta && Array.isArray(meta.pages)) {
for (const entry of meta.pages) {
if (typeof entry !== "string") continue;
if (entry === "..." || entry === "z...z" || entry === "---") continue;
if (/^---.+---$/.test(entry)) continue;
if (entry.startsWith("[")) continue;
const slug = entry.startsWith("!")
? entry.slice(1)
: entry.startsWith("...")
? entry.slice(3)
: entry;
const candidates = [
resolve(dir, slug),
resolve(dir, `${slug}.md`),
resolve(dir, `${slug}.mdx`),
];
if (candidates.some((candidate) => existsSync(candidate))) continue;
failures.push({
root: "docs",
file: `${relative(rootDir, dir) || "."}/meta.json`,
issue: `meta.json#pages entry \`${entry}\` does not resolve to a page or navigation folder`,
});
}
}
for (const entry of readdirSync(dir)) {
const full = resolve(dir, entry);
if (statSync(full).isDirectory()) visit(full);
}
};
visit(rootDir);
}
function checkRepositoryMarkdownLinks(absPath) {
const source = readFileSync(absPath, "utf8");
const linkRe = /\]\((\s*[^)]+?)\s*\)/g;
let match;
while ((match = linkRe.exec(source)) !== null) {
const target = match[1].trim().split("#")[0].split("?")[0];
if (!target || target.startsWith("/") || /^[a-z]+:/i.test(target)) continue;
if (existsSync(resolve(dirname(absPath), target))) continue;
failures.push({
root: "docs",
file: relative(ROOTS[0].dir, absPath),
issue: `broken repository link → \`${match[1].trim()}\``,
});
}
}
function checkChannelHubLinks(rootDir) {
const hubPath = resolve(rootDir, "channels/overview.mdx");
const source = readFileSync(hubPath, "utf8");
const linkedRoutes = new Set();
const linkRe = /\]\((\s*[^)]+?)\s*\)/g;
let match;
while ((match = linkRe.exec(source)) !== null) {
const target = match[1].trim().split("#")[0].split("?")[0];
if (!target || (!target.startsWith("./") && !target.startsWith("/docs/"))) continue;
linkedRoutes.add(new URL(target, "https://eve.dev/docs/channels/overview").pathname);
}
const meta = loadMetaJson(resolve(rootDir, "channels"));
for (const slug of meta?.pages ?? []) {
if (typeof slug !== "string" || slug === "overview") continue;
const expected = `/docs/channels/${slug}`;
if (linkedRoutes.has(expected)) continue;
failures.push({
root: "docs",
file: "channels/overview.mdx",
issue: `channel hub does not link to canonical child route \`${expected}\``,
});
}
}
checkLinks(ROOTS[0].dir);
checkMetaReferences(ROOTS[0].dir);
checkRepositoryMarkdownLinks(resolve(ROOTS[0].dir, "README.md"));
checkChannelHubLinks(ROOTS[0].dir);
if (failures.length === 0) {
process.stdout.write(
`[docs:check] ok — ${validatedCount} file${validatedCount === 1 ? "" : "s"} validated.\n`,
);
process.exit(0);
}
process.stderr.write("[docs:check] FAIL\n\n");
for (const { root, file, issue } of failures) {
process.stderr.write(` ${root}/${file}\n${issue}\n\n`);
}
process.stderr.write(
[
"Every site-page markdown file under /docs must:",
" 1. Start with frontmatter containing `title` and `description`.",
" 2. Be reachable from the sidebar nav via an ancestor meta.json —",
' either listed by slug in `pages[]`, or covered by a `"..."`',
" wildcard entry, unless explicitly allowed as footer-only.",
"",
"Files intentionally kept off the site (engineer-facing READMEs, etc.)",
"should be excluded by updating isExcluded() in scripts/check-docs.mjs.",
"",
].join("\n"),
);
process.exit(1);