Files
2026-07-13 13:39:12 +08:00

224 lines
6.9 KiB
JavaScript

#!/usr/bin/env node
/**
* 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();