#!/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);