Spaces:
Runtime error
Runtime error
| /** | |
| * OmniRoute β internal documentation link checker. | |
| * | |
| * Scans every Markdown file under `docs/` (excluding mirrored translations, | |
| * screenshots, exported diagrams, and superpowers plans) for relative or | |
| * project-rooted internal links and verifies the referenced files exist on | |
| * disk. External URLs (http/https/mailto), telephone links, and pure | |
| * anchor-only links (#section) are ignored. | |
| * | |
| * Supported syntaxes: | |
| * - Markdown links: [label](path "optional title") | |
| * - Reference links: [label]: path "optional title" | |
| * - HTML anchors: <a href="path"> | |
| * - HTML image refs: <img src="path"> | |
| * | |
| * Usage: | |
| * node scripts/check/check-doc-links.mjs # strict (CI gate) | |
| * node scripts/check/check-doc-links.mjs --report # human report, exit 0 | |
| * node scripts/check/check-doc-links.mjs --json # JSON to stdout | |
| * | |
| * Exit codes: | |
| * 0 ok (or --report mode) | |
| * 1 one or more broken internal links detected | |
| */ | |
| import fs from "node:fs"; | |
| import path from "node:path"; | |
| import process from "node:process"; | |
| import { fileURLToPath } from "node:url"; | |
| const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); | |
| const REPO_ROOT = path.resolve(SCRIPT_DIR, "..", ".."); | |
| const DOCS_ROOT = path.join(REPO_ROOT, "docs"); | |
| const EXCLUDE_PREFIXES = [ | |
| path.join(DOCS_ROOT, "i18n") + path.sep, | |
| path.join(DOCS_ROOT, "screenshots") + path.sep, | |
| path.join(DOCS_ROOT, "superpowers") + path.sep, | |
| path.join(DOCS_ROOT, "diagrams", "exported") + path.sep, | |
| ]; | |
| function parseArgs(argv) { | |
| const opts = { report: false, json: false }; | |
| for (const arg of argv.slice(2)) { | |
| if (arg === "--report") opts.report = true; | |
| else if (arg === "--json") opts.json = true; | |
| else if (arg === "--help" || arg === "-h") { | |
| console.log( | |
| [ | |
| "Usage: node scripts/check/check-doc-links.mjs [options]", | |
| "", | |
| " --report Print findings and exit 0 regardless", | |
| " --json Emit JSON report to stdout", | |
| ].join("\n") | |
| ); | |
| process.exit(0); | |
| } | |
| } | |
| return opts; | |
| } | |
| function walkDocs(dir, out) { | |
| for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { | |
| const full = path.join(dir, entry.name); | |
| if (entry.isDirectory()) { | |
| const prefixed = full + path.sep; | |
| if (EXCLUDE_PREFIXES.some((p) => prefixed.startsWith(p))) continue; | |
| walkDocs(full, out); | |
| } else if (entry.isFile() && full.endsWith(".md")) { | |
| if (EXCLUDE_PREFIXES.some((p) => full.startsWith(p))) continue; | |
| out.push(full); | |
| } | |
| } | |
| } | |
| function isExternal(target) { | |
| return ( | |
| /^[a-z][a-z0-9+.-]*:/i.test(target) || // http:, https:, mailto:, tel:, data:, ftp: ... | |
| target.startsWith("//") | |
| ); | |
| } | |
| function stripFragmentAndQuery(target) { | |
| let value = target; | |
| const hashAt = value.indexOf("#"); | |
| if (hashAt !== -1) value = value.slice(0, hashAt); | |
| const queryAt = value.indexOf("?"); | |
| if (queryAt !== -1) value = value.slice(0, queryAt); | |
| return value; | |
| } | |
| function extractLinks(content) { | |
| const links = []; | |
| // Strip fenced code blocks to avoid false positives. | |
| const sanitized = content.replace(/```[\s\S]*?```/g, (m) => " ".repeat(m.length)); | |
| const lines = sanitized.split(/\r?\n/); | |
| for (let i = 0; i < lines.length; i++) { | |
| const line = lines[i]; | |
| const lineNumber = i + 1; | |
| // Markdown inline links: [text](target) β supports nested parens minimally. | |
| const inlineRe = /(!?)\[(?:[^\]]*)\]\(([^)\s]+(?:\s+"[^"]*")?)\)/g; | |
| let match; | |
| while ((match = inlineRe.exec(line)) !== null) { | |
| const raw = match[2].replace(/\s+"[^"]*"$/, "").trim(); | |
| if (raw) links.push({ target: raw, line: lineNumber }); | |
| } | |
| // Reference-style links: [label]: target ("optional title") | |
| const refRe = /^\s{0,3}\[[^\]]+\]:\s+([^\s]+)(?:\s+"[^"]*")?\s*$/; | |
| const refMatch = line.match(refRe); | |
| if (refMatch) links.push({ target: refMatch[1], line: lineNumber }); | |
| // HTML href / src | |
| const htmlRe = /\b(?:href|src)\s*=\s*"([^"]+)"/g; | |
| while ((match = htmlRe.exec(line)) !== null) { | |
| links.push({ target: match[1], line: lineNumber }); | |
| } | |
| } | |
| return links; | |
| } | |
| function resolveTarget(sourceFile, target) { | |
| // /docs/foo.md, /foo, /reference/ENVIRONMENT.md β project-rooted. | |
| if (target.startsWith("/")) { | |
| return path.join(REPO_ROOT, target.replace(/^\/+/, "")); | |
| } | |
| // Otherwise resolve against the source file's directory. | |
| return path.resolve(path.dirname(sourceFile), target); | |
| } | |
| function probeExists(absPath) { | |
| if (fs.existsSync(absPath)) return true; | |
| // Allow links omitting `.md` (some doc viewers do this). | |
| if (!path.extname(absPath) && fs.existsSync(`${absPath}.md`)) return true; | |
| // Allow directory links resolving to an index/README. | |
| if (fs.existsSync(path.join(absPath, "README.md"))) return true; | |
| if (fs.existsSync(path.join(absPath, "index.md"))) return true; | |
| return false; | |
| } | |
| function main() { | |
| const opts = parseArgs(process.argv); | |
| if (!fs.existsSync(DOCS_ROOT)) { | |
| console.error("[doc-links] FAIL β docs/ directory not found"); | |
| process.exit(1); | |
| } | |
| const files = []; | |
| walkDocs(DOCS_ROOT, files); | |
| /** @type {Array<{source:string, line:number, target:string, reason:string}>} */ | |
| const broken = []; | |
| let checkedLinks = 0; | |
| for (const file of files) { | |
| const content = fs.readFileSync(file, "utf8"); | |
| const links = extractLinks(content); | |
| for (const { target, line } of links) { | |
| if (!target) continue; | |
| if (target.startsWith("#")) continue; // anchor-only | |
| if (isExternal(target)) continue; | |
| const clean = stripFragmentAndQuery(target); | |
| if (!clean) continue; // e.g. "?query" alone β ignore | |
| checkedLinks++; | |
| const abs = resolveTarget(file, clean); | |
| if (!probeExists(abs)) { | |
| broken.push({ | |
| source: path.relative(REPO_ROOT, file), | |
| line, | |
| target, | |
| reason: "missing", | |
| }); | |
| } | |
| } | |
| } | |
| if (opts.json) { | |
| process.stdout.write( | |
| JSON.stringify( | |
| { | |
| ok: broken.length === 0, | |
| scannedFiles: files.length, | |
| checkedLinks, | |
| broken, | |
| }, | |
| null, | |
| 2 | |
| ) + "\n" | |
| ); | |
| process.exit(broken.length && !opts.report ? 1 : 0); | |
| } | |
| console.log(`[doc-links] scanned ${files.length} docs, checked ${checkedLinks} internal links`); | |
| if (broken.length === 0) { | |
| console.log("[doc-links] PASS β no broken internal links"); | |
| process.exit(0); | |
| } | |
| // Group by source for readability. | |
| const bySource = new Map(); | |
| for (const entry of broken) { | |
| if (!bySource.has(entry.source)) bySource.set(entry.source, []); | |
| bySource.get(entry.source).push(entry); | |
| } | |
| console.log(`[doc-links] FAIL β ${broken.length} broken link(s) in ${bySource.size} file(s):`); | |
| for (const [source, entries] of bySource) { | |
| console.log(`\n ${source}`); | |
| for (const entry of entries) { | |
| console.log(` line ${entry.line}: ${entry.target}`); | |
| } | |
| } | |
| process.exit(opts.report ? 0 : 1); | |
| } | |
| main(); | |