fix: docs broken links and improve link checker (#13056)

* docs: fix broken links checker and add CI docs

- Replace buggy mint broken-links with existing docs:check-links script
- Fix zh-CN/vps.md broken links (/railway  /install/railway)
- Add docs/ci.md explaining CI pipeline
- Add Experiments group to docs.json navigation

* improve docs checker
This commit is contained in:
max
2026-02-09 18:45:06 -08:00
committed by GitHub
parent c2b2d535fb
commit c4d9b6eadb

View File

@@ -48,8 +48,8 @@ function normalizeRoute(p) {
} }
/** @param {string} text */ /** @param {string} text */
function stripCodeFences(text) { function stripInlineCode(text) {
return text.replace(/```[\s\S]*?```/g, ""); return text.replace(/`[^`]+`/g, "");
} }
const docsConfig = JSON.parse(fs.readFileSync(DOCS_JSON_PATH, "utf8")); const docsConfig = JSON.parse(fs.readFileSync(DOCS_JSON_PATH, "utf8"));
@@ -68,13 +68,14 @@ const routes = new Set();
for (const abs of markdownFiles) { for (const abs of markdownFiles) {
const rel = normalizeSlashes(path.relative(DOCS_DIR, abs)); const rel = normalizeSlashes(path.relative(DOCS_DIR, abs));
const text = fs.readFileSync(abs, "utf8");
const slug = rel.replace(/\.(md|mdx)$/i, ""); const slug = rel.replace(/\.(md|mdx)$/i, "");
routes.add(normalizeRoute(slug)); const route = normalizeRoute(slug);
routes.add(route);
if (slug.endsWith("/index")) { if (slug.endsWith("/index")) {
routes.add(normalizeRoute(slug.slice(0, -"/index".length))); routes.add(normalizeRoute(slug.slice(0, -"/index".length)));
} }
const text = fs.readFileSync(abs, "utf8");
if (!text.startsWith("---")) { if (!text.startsWith("---")) {
continue; continue;
} }
@@ -114,83 +115,108 @@ function resolveRoute(route) {
const markdownLinkRegex = /!?\[[^\]]*\]\(([^)]+)\)/g; const markdownLinkRegex = /!?\[[^\]]*\]\(([^)]+)\)/g;
/** @type {{file: string; link: string; reason: string}[]} */ /** @type {{file: string; line: number; link: string; reason: string}[]} */
const broken = []; const broken = [];
let checked = 0; let checked = 0;
for (const abs of markdownFiles) { for (const abs of markdownFiles) {
const rel = normalizeSlashes(path.relative(DOCS_DIR, abs)); const rel = normalizeSlashes(path.relative(DOCS_DIR, abs));
const baseDir = normalizeSlashes(path.dirname(rel)); const baseDir = normalizeSlashes(path.dirname(rel));
const text = stripCodeFences(fs.readFileSync(abs, "utf8")); const rawText = fs.readFileSync(abs, "utf8");
const lines = rawText.split("\n");
for (const match of text.matchAll(markdownLinkRegex)) { // Track if we're inside a code fence
const raw = match[1]?.trim(); let inCodeFence = false;
if (!raw) {
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
let line = lines[lineNum];
// Toggle code fence state
if (line.trim().startsWith("```")) {
inCodeFence = !inCodeFence;
continue; continue;
} }
if (/^(https?:|mailto:|tel:|data:|#)/i.test(raw)) { if (inCodeFence) {
continue; continue;
} }
const clean = raw.split("#")[0].split("?")[0]; // Strip inline code to avoid false positives
if (!clean) { line = stripInlineCode(line);
continue;
}
checked++;
if (clean.startsWith("/")) { for (const match of line.matchAll(markdownLinkRegex)) {
const route = normalizeRoute(clean); const raw = match[1]?.trim();
const resolvedRoute = resolveRoute(route); if (!raw) {
if (resolvedRoute.ok) { continue;
}
// Skip external links, mailto, tel, data, and same-page anchors
if (/^(https?:|mailto:|tel:|data:|#)/i.test(raw)) {
continue; continue;
} }
const staticRel = route.replace(/^\//, ""); const [pathPart] = raw.split("#");
if (relAllFiles.has(staticRel)) { const clean = pathPart.split("?")[0];
if (!clean) {
// Same-page anchor only (already skipped above)
continue;
}
checked++;
if (clean.startsWith("/")) {
const route = normalizeRoute(clean);
const resolvedRoute = resolveRoute(route);
if (!resolvedRoute.ok) {
const staticRel = route.replace(/^\//, "");
if (!relAllFiles.has(staticRel)) {
broken.push({
file: rel,
line: lineNum + 1,
link: raw,
reason: `route/file not found (terminal: ${resolvedRoute.terminal})`,
});
continue;
}
}
// Skip anchor validation - Mintlify generates anchors from MDX components,
// accordions, and config schemas that we can't reliably extract from markdown.
continue; continue;
} }
broken.push({ // Relative placeholder strings used in code examples (for example "url")
file: rel, // are intentionally skipped.
link: raw, if (!clean.startsWith(".") && !clean.includes("/")) {
reason: `route/file not found (terminal: ${resolvedRoute.terminal})`, continue;
}); }
continue;
}
// Relative placeholder strings used in code examples (for example "url") const normalizedRel = normalizeSlashes(path.normalize(path.join(baseDir, clean)));
// 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,
line: lineNum + 1,
link: raw,
reason: "relative file not found",
});
}
continue;
}
if (/\.[a-zA-Z0-9]+$/.test(normalizedRel)) { const candidates = [
if (!relAllFiles.has(normalizedRel)) { normalizedRel,
`${normalizedRel}.md`,
`${normalizedRel}.mdx`,
`${normalizedRel}/index.md`,
`${normalizedRel}/index.mdx`,
];
if (!candidates.some((candidate) => relAllFiles.has(candidate))) {
broken.push({ broken.push({
file: rel, file: rel,
line: lineNum + 1,
link: raw, link: raw,
reason: "relative file not found", reason: "relative doc target 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",
});
} }
} }
} }
@@ -199,7 +225,7 @@ console.log(`checked_internal_links=${checked}`);
console.log(`broken_links=${broken.length}`); console.log(`broken_links=${broken.length}`);
for (const item of broken) { for (const item of broken) {
console.log(`${item.file} :: ${item.link} :: ${item.reason}`); console.log(`${item.file}:${item.line} :: ${item.link} :: ${item.reason}`);
} }
if (broken.length > 0) { if (broken.length > 0) {