comfyui-mcp
Version:
Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),
162 lines (149 loc) • 6.77 kB
JavaScript
/**
* Docs integrity: navigation ↔ files ↔ links.
*
* node scripts/check-docs-links.mjs
*
* Three questions no existing gate answers:
*
* 1. Does every page listed in docs.json navigation actually exist on disk? A missing one
* is a 404 from the sidebar — the most visible kind of broken doc.
* 2. Does every .mdx on disk appear in the navigation? An orphan is a page nobody can reach
* by browsing. `seo.indexing: "all"` means search engines still index it, so an orphan is
* a page that only strangers find.
* 3. Does every relative link resolve to a real page? These rot silently: the target gets
* renamed, the link still renders, and only a reader discovers it.
*
* External links and anchors are reported separately and never fail the run — a network flake
* or a heading rename should not turn CI red on a docs edit.
*/
import fs from 'node:fs';
import path from 'node:path';
const ROOT = path.resolve(import.meta.dirname, '..');
const DOCS = path.join(ROOT, 'docs');
const cfg = JSON.parse(fs.readFileSync(path.join(DOCS, 'docs.json'), 'utf8'));
// Mintlify's docs.json schema enum for navigation.languages[].language.
// Measured 2026-08-18 from https://www.mintlify.com/docs.json — `fa` is NOT in it.
// An unsupported code fails the whole production build and leaves the last good
// deploy up (Korean-only, after #1577, once `fa` landed in #9b241e0).
const MINTLIFY_LANGUAGES = new Set([
"en", "cn", "zh", "zh-Hans", "zh-Hant", "zh-CN", "zh-TW",
"es", "fr", "fr-CA", "fr-ca", "ja", "jp", "ja-jp", "ja-JP",
"pt", "pt-BR", "de", "ko", "it", "ru", "ro", "cs", "id",
"ar", "tr", "hi", "sv", "no", "da", "lv", "nl", "uk", "vi",
"pl", "uz", "he", "ca", "fi", "hu",
]);
/** Every page path the navigation references, wherever it is nested. */
function navPages(node, out = []) {
if (Array.isArray(node)) {
for (const n of node) navPages(n, out);
return out;
}
if (!node || typeof node !== 'object') return out;
for (const [k, v] of Object.entries(node)) {
if (k === 'pages' && Array.isArray(v)) {
for (const p of v) {
if (typeof p === 'string') out.push(p);
else navPages(p, out);
}
} else if (typeof v === 'object') {
navPages(v, out);
}
}
return out;
}
const listed = [...new Set(navPages(cfg.navigation))];
/** Every .mdx/.md page on disk, as a navigation-style path. */
const onDisk = [];
(function walk(dir) {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (!/^(node_modules|_snippets|snippets|images|logo|\.)/.test(e.name)) walk(full);
continue;
}
// BOTH .md and .mdx are published — measured, not assumed: /docs/quickstart (.mdx) and
// /docs/design/agent-backend-injection (.md) both returned 200 on the live site. An
// earlier version globbed .mdx only, on the strength of /docs/README returning 404; that
// was the wrong inference from a single sample. README is excluded by name below, which
// is what that 404 actually shows.
if (!/\.mdx?$/.test(e.name)) continue;
if (/^README\.mdx?$/i.test(e.name)) continue;
onDisk.push(path.relative(DOCS, full).replace(/\\/g, '/').replace(/\.mdx?$/, ''));
}
})(DOCS);
const diskSet = new Set(onDisk);
const listedSet = new Set(listed);
const missing = listed.filter((p) => !diskSet.has(p));
const orphans = onDisk.filter((p) => !listedSet.has(p));
// Relative links inside page bodies.
const RELATIVE = /\[[^\]]*\]\((?!https?:|mailto:|#)([^)\s]+)\)/g;
const brokenLinks = [];
const anchorOnly = [];
for (const page of onDisk) {
const file = fs.existsSync(path.join(DOCS, page + '.mdx'))
? path.join(DOCS, page + '.mdx')
: path.join(DOCS, page + '.md');
const src = fs.readFileSync(file, 'utf8');
for (const m of src.matchAll(RELATIVE)) {
const raw = m[1];
const [target] = raw.split('#');
if (!target) { anchorOnly.push(`${page} → ${raw}`); continue; }
// Resolve relative to the page's own directory, like a browser would.
const base = path.posix.dirname(page);
let resolved = target.startsWith('/')
? target.replace(/^\//, '')
: path.posix.normalize(path.posix.join(base === '.' ? '' : base, target));
resolved = resolved.replace(/\.mdx?$/, '').replace(/\/$/, '');
// A link to a DIRECTORY means that directory's index page — `[blog index](./)` from
// inside blog/ is `blog/index`, and `[home](../)` from a post is the root `index`.
// Without this, every legitimate "back to the section" link reads as broken; it produced
// 6 false positives out of 11 the first time this ran.
if (resolved === '' || resolved === '.') resolved = 'index';
const candidates = [
resolved,
`${resolved}/index`,
resolved.replace(/^docs\//, ''),
`${resolved.replace(/^docs\//, '')}/index`,
];
if (!candidates.some((c) => diskSet.has(c))) brokenLinks.push(`${page} → ${raw}`);
}
}
console.log(`docs pages on disk: ${onDisk.length}`);
console.log(`pages in navigation: ${listed.length}`);
console.log(`anchor-only links (not checked): ${anchorOnly.length}`);
let failures = 0;
const langCodes = (cfg.navigation?.languages ?? [])
.map((l) => l?.language)
.filter((c) => typeof c === "string");
const badLangs = langCodes.filter((c) => !MINTLIFY_LANGUAGES.has(c));
if (badLangs.length) {
failures += badLangs.length;
console.error(
`\n✗ ${badLangs.length} language code(s) Mintlify's docs.json schema does not accept — this fails the production build:`,
);
for (const c of badLangs) console.error(` ${c}`);
}
if (missing.length) {
failures += missing.length;
console.error(`\n✗ ${missing.length} navigation entr(ies) with no file — these 404 from the sidebar:`);
for (const p of missing) console.error(` ${p}`);
}
if (brokenLinks.length) {
failures += brokenLinks.length;
console.error(`\n✗ ${brokenLinks.length} relative link(s) that do not resolve:`);
for (const l of brokenLinks.slice(0, 40)) console.error(` ${l}`);
if (brokenLinks.length > 40) console.error(` … ${brokenLinks.length - 40} more`);
}
if (orphans.length) {
// Reported, not failed: a deliberately unlisted page is a legitimate choice, and
// `seo.indexing: "all"` means it is still reachable from search.
console.log(`\n· ${orphans.length} page(s) on disk but not in navigation (reachable only by URL/search):`);
for (const p of orphans.slice(0, 25)) console.log(` ${p}`);
if (orphans.length > 25) console.log(` … ${orphans.length - 25} more`);
}
if (failures) {
console.error(`\n${failures} broken reference(s).`);
process.exit(1);
}
console.log('\ndocs links OK');