mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user