Files
openclaw/scripts/docs-link-audit.mjs
Seb Slight 929a3725d3 docs: canonicalize docs paths and align zh navigation (#11428)
* docs(navigation): canonicalize paths and align zh nav

* chore(docs): remove stray .DS_Store

* docs(scripts): add non-mint docs link audit

* docs(nav): fix zh source paths and preserve legacy redirects (#11428) (thanks @sebslight)

* chore(docs): satisfy lint for docs link audit script (#11428) (thanks @sebslight)
2026-02-07 15:40:35 -05:00

208 lines
5.2 KiB
JavaScript

#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
const ROOT = process.cwd();
const DOCS_DIR = path.join(ROOT, "docs");
const DOCS_JSON_PATH = path.join(DOCS_DIR, "docs.json");
if (!fs.existsSync(DOCS_DIR) || !fs.statSync(DOCS_DIR).isDirectory()) {
console.error("docs:check-links: missing docs directory; run from repo root.");
process.exit(1);
}
if (!fs.existsSync(DOCS_JSON_PATH)) {
console.error("docs:check-links: missing docs/docs.json.");
process.exit(1);
}
/** @param {string} dir */
function walk(dir) {
/** @type {string[]} */
const out = [];
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith(".")) {
continue;
}
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
out.push(...walk(full));
} else if (entry.isFile()) {
out.push(full);
}
}
return out;
}
/** @param {string} p */
function normalizeSlashes(p) {
return p.replace(/\\/g, "/");
}
/** @param {string} p */
function normalizeRoute(p) {
const stripped = p.replace(/^\/+|\/+$/g, "");
return stripped ? `/${stripped}` : "/";
}
/** @param {string} text */
function stripCodeFences(text) {
return text.replace(/```[\s\S]*?```/g, "");
}
const docsConfig = JSON.parse(fs.readFileSync(DOCS_JSON_PATH, "utf8"));
const redirects = new Map();
for (const item of docsConfig.redirects || []) {
const source = normalizeRoute(String(item.source || ""));
const destination = normalizeRoute(String(item.destination || ""));
redirects.set(source, destination);
}
const allFiles = walk(DOCS_DIR);
const relAllFiles = new Set(allFiles.map((abs) => normalizeSlashes(path.relative(DOCS_DIR, abs))));
const markdownFiles = allFiles.filter((abs) => /\.(md|mdx)$/i.test(abs));
const routes = new Set();
for (const abs of markdownFiles) {
const rel = normalizeSlashes(path.relative(DOCS_DIR, abs));
const slug = rel.replace(/\.(md|mdx)$/i, "");
routes.add(normalizeRoute(slug));
if (slug.endsWith("/index")) {
routes.add(normalizeRoute(slug.slice(0, -"/index".length)));
}
const text = fs.readFileSync(abs, "utf8");
if (!text.startsWith("---")) {
continue;
}
const end = text.indexOf("\n---", 3);
if (end === -1) {
continue;
}
const frontMatter = text.slice(3, end);
const match = frontMatter.match(/^permalink:\s*(.+)\s*$/m);
if (!match) {
continue;
}
const permalink = String(match[1])
.trim()
.replace(/^['"]|['"]$/g, "");
routes.add(normalizeRoute(permalink));
}
/** @param {string} route */
function resolveRoute(route) {
let current = normalizeRoute(route);
if (current === "/") {
return { ok: true, terminal: "/" };
}
const seen = new Set([current]);
while (redirects.has(current)) {
current = redirects.get(current);
if (seen.has(current)) {
return { ok: false, terminal: current, loop: true };
}
seen.add(current);
}
return { ok: routes.has(current), terminal: current };
}
const markdownLinkRegex = /!?\[[^\]]*\]\(([^)]+)\)/g;
/** @type {{file: string; link: string; reason: string}[]} */
const broken = [];
let checked = 0;
for (const abs of markdownFiles) {
const rel = normalizeSlashes(path.relative(DOCS_DIR, abs));
const baseDir = normalizeSlashes(path.dirname(rel));
const text = stripCodeFences(fs.readFileSync(abs, "utf8"));
for (const match of text.matchAll(markdownLinkRegex)) {
const raw = match[1]?.trim();
if (!raw) {
continue;
}
if (/^(https?:|mailto:|tel:|data:|#)/i.test(raw)) {
continue;
}
const clean = raw.split("#")[0].split("?")[0];
if (!clean) {
continue;
}
checked++;
if (clean.startsWith("/")) {
const route = normalizeRoute(clean);
const resolvedRoute = resolveRoute(route);
if (resolvedRoute.ok) {
continue;
}
const staticRel = route.replace(/^\//, "");
if (relAllFiles.has(staticRel)) {
continue;
}
broken.push({
file: rel,
link: raw,
reason: `route/file not found (terminal: ${resolvedRoute.terminal})`,
});
continue;
}
// Relative placeholder strings used in code examples (for example "url")
// are intentionally skipped.
if (!clean.startsWith(".") && !clean.includes("/")) {
continue;
}
const normalizedRel = normalizeSlashes(path.normalize(path.join(baseDir, clean)));
if (/\.[a-zA-Z0-9]+$/.test(normalizedRel)) {
if (!relAllFiles.has(normalizedRel)) {
broken.push({
file: rel,
link: raw,
reason: "relative file not found",
});
}
continue;
}
const candidates = [
normalizedRel,
`${normalizedRel}.md`,
`${normalizedRel}.mdx`,
`${normalizedRel}/index.md`,
`${normalizedRel}/index.mdx`,
];
if (!candidates.some((candidate) => relAllFiles.has(candidate))) {
broken.push({
file: rel,
link: raw,
reason: "relative doc target not found",
});
}
}
}
console.log(`checked_internal_links=${checked}`);
console.log(`broken_links=${broken.length}`);
for (const item of broken) {
console.log(`${item.file} :: ${item.link} :: ${item.reason}`);
}
if (broken.length > 0) {
process.exit(1);
}