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)
This commit is contained in:
207
scripts/docs-link-audit.mjs
Normal file
207
scripts/docs-link-audit.mjs
Normal file
@@ -0,0 +1,207 @@
|
||||
#!/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);
|
||||
}
|
||||
Reference in New Issue
Block a user