mirror of
https://github.com/vercel/eve.git
synced 2026-09-20 05:35:39 +08:00
09aa810829
Signed-off-by: Colton Padden <colton.padden@vercel.com>
357 lines
12 KiB
JavaScript
Executable File
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);
|