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),
287 lines (267 loc) • 13.8 kB
JavaScript
/**
* Structural comparison of a localized docs page against its English source.
*
* node scripts/check-docs-locale.mjs ko
* node scripts/check-docs-locale.mjs # every locale directory under docs/
*
* Why this exists alongside the reviewing agent: an agent judges MEANING, which is the part
* a script cannot do — and it is also the part that fails softly. What fails HARD is
* structure. A translated shell command, a dropped code fence, a relative link that no longer
* resolves from inside `ko/` — those break for every reader, and they are exactly what a
* deterministic check catches every time and a reviewer catches most of the time.
*
* The rules below are ordered by how badly they hurt a reader who hits them:
*
* 1. a code block whose contents changed — they will run it and it will fail
* 2. a link that cannot resolve — a dead end mid-task
* 3. missing or untranslated frontmatter — the search snippet, and the page title
* 4. a lost or added heading — the page no longer matches its English counterpart
*/
import fs from 'node:fs';
import path from 'node:path';
const ROOT = path.resolve(import.meta.dirname, '..');
const DOCS = path.join(ROOT, 'docs');
/**
* Fenced code blocks, contents only.
*
* The leading `[ \t]*` is load-bearing. Most fences in these docs are INDENTED inside an MDX
* component (`<Step>`, `<Tab>`, `<Card>`), and anchoring the fence to column 0 saw 0 of 1
* blocks in quickstart, 3 of 6 in installation and 1 of 4 in panel — the rule that matters
* most, silently checking about half of what it claimed to. A mutation inside a component's
* code block sailed straight through.
*/
const fences = (src) => {
const out = [];
const re = /^[ \t]*```[^\n]*\n([\s\S]*?)^[ \t]*```/gm;
let m;
while ((m = re.exec(src)) !== null) out.push(m[1]);
return out;
};
const headings = (src) => (src.match(/^#{1,6} /gm) || []).map((h) => h.trim());
/** MDX component opens, e.g. <Steps>, <Step title="…">, <Note> — names only. */
const components = (src) =>
(src.match(/<([A-Z][A-Za-z0-9]*)\b/g) || []).map((c) => c.slice(1)).sort();
const frontmatter = (src) => {
const m = src.match(/^---\n([\s\S]*?)\n---/);
if (!m) return null;
const out = {};
for (const line of m[1].split('\n')) {
const kv = line.match(/^(\w+):\s*(.*)$/);
if (kv) out[kv[1]] = kv[2].replace(/^["']|["']$/g, '');
}
return out;
};
const links = (src) =>
[...src.matchAll(/\[[^\]]*\]\(([^)\s]+)\)/g)].map((m) => m[1]).filter((h) => !/^https?:|^mailto:/.test(h));
/**
* The anchor a heading generates. Mintlify lowercases, strips punctuation, and joins words
* with hyphens; non-Latin scripts pass through unchanged.
*
* This exists because translating a heading SILENTLY changes its anchor. `## Setup` is
* `#setup`; translate it to `## 설정` and every `#setup` link — in this page and in every
* other page that deep-links to it — resolves to nothing. The reviewing agents found two
* instances of this in five pages, which is a high enough rate that it needs a machine check
* before eleven more locales are generated.
*/
const slugify = (heading) =>
heading
.replace(/^#{1,6}\s+/, '')
.trim()
.toLowerCase()
// \p{M} — COMBINING MARKS — are kept. They are not decoration: in Arabic, Hebrew, and the
// Indic scripts a mark is part of the word. Dropping them is a Latin-centric assumption,
// and it fired the moment a non-Latin locale arrived: the Arabic heading
// "3. تشغيل منسّق اللوحة" contains a shadda (U+0651, category Mn), so this slugified to
// ...منسق while the link written beside it kept the mark, and the gate reported a correct
// cross-page anchor as broken. Excluding marks here would have pushed a translator to
// "fix" the prose instead.
.replace(/[^\p{L}\p{N}\p{M}\s-]/gu, '')
// Each space becomes its own hyphen — runs are NOT collapsed. "readiness & onboarding"
// loses the ampersand and keeps both surrounding spaces, so Mintlify emits
// `readiness--onboarding` with two hyphens. Collapsing here reported two correct,
// English-inherited links as broken.
.replace(/\s/g, '-');
const anchors = (src) => new Set((src.match(/^#{1,6} .+$/gm) || []).map(slugify));
const localeDirs = process.argv.slice(2).filter((a) => !a.startsWith('-'));
const locales = localeDirs.length
? localeDirs
: fs
.readdirSync(DOCS, { withFileTypes: true })
.filter((e) => e.isDirectory() && /^[a-z]{2}(-[A-Za-z]+)?$/.test(e.name))
.map((e) => e.name);
let failures = 0;
const fail = (loc, page, msg) => {
failures++;
console.error(` ✗ ${loc}/${page}: ${msg}`);
};
for (const loc of locales) {
const dir = path.join(DOCS, loc);
if (!fs.existsSync(dir)) {
console.log(` · ${loc} absent`);
continue;
}
const pages = fs.readdirSync(dir).filter((f) => /\.mdx?$/.test(f));
const localized = new Set(pages.map((f) => f.replace(/\.mdx?$/, '')));
let checked = 0;
for (const file of pages) {
const slug = file.replace(/\.mdx?$/, '');
const srcPath = fs.existsSync(path.join(DOCS, `${slug}.mdx`))
? path.join(DOCS, `${slug}.mdx`)
: path.join(DOCS, `${slug}.md`);
if (!fs.existsSync(srcPath)) {
fail(loc, slug, `no English source at docs/${slug}.mdx — an orphan translation`);
continue;
}
checked++;
// Normalise line endings before ANY comparison. On Windows the English pages come out of
// git as CRLF while a freshly written translation is LF, so a byte comparison reports
// every single code block as "modified" — which is exactly what this check did on its
// first run, against three files whose code was in fact identical. Git normalises on
// commit; the difference is never real content.
const eol = (s) => s.replace(/\r\n/g, '\n');
const en = eol(fs.readFileSync(srcPath, 'utf8'));
const tr = eol(fs.readFileSync(path.join(dir, file), 'utf8'));
// 1. Code blocks: same count, byte-identical contents.
const ef = fences(en);
const tf = fences(tr);
if (ef.length !== tf.length) {
fail(loc, slug, `${ef.length} code block(s) in English, ${tf.length} here`);
} else {
for (let i = 0; i < ef.length; i++) {
if (ef[i] !== tf[i]) {
fail(loc, slug, `code block #${i + 1} was modified — a reader runs this verbatim`);
break;
}
}
}
// 2. Links resolve, none are relative (they break from inside the locale directory), and
// every anchor exists in the page it points at.
const ownAnchors = anchors(tr);
for (const href of links(tr)) {
const [rawTarget, frag] = href.split('#');
// Same-page anchor: must match a heading in THIS file, post-translation.
if (!rawTarget) {
if (frag && !ownAnchors.has(frag.toLowerCase())) {
fail(loc, slug, `anchor "#${frag}" matches no heading here — a translated heading changes its slug`);
}
continue;
}
if (/^\.\.?\//.test(href)) {
fail(loc, slug, `relative link "${href}" — use an absolute /docs/... path from a locale directory`);
continue;
}
// A DOUBLED prefix is the one link form that genuinely 404s, and the normalization below
// would hide it: `/docs/docs/backends` strips to `docs/backends`, which resolves on disk.
// Measured on the live site: /docs/backends and /backends both render the real page;
// /docs/docs/backends returns the same shell as a nonsense URL. Two review agents reached
// OPPOSITE conclusions about the prefix by reasoning from repo conventions, which is why
// this is pinned to an observation rather than an argument.
// `(\/|$)` — the trailing-slash-only form let the bare `/docs/docs` through, which is
// the same 404 without a suffix.
if (/^\/docs\/docs(\/|$)/.test(rawTarget)) {
fail(loc, slug, `link "${href}" doubles the /docs prefix — it 404s; use "/docs/..." once`);
continue;
}
// ONE form, enforced. Both `/docs/backends` and `/backends` render the real page, so
// this is not correctness — it is anti-churn. With ko/fr at 36 prefixed links and
// ja/zh/es at 30 prefixed + 6 bare, a review agent inside any single file could justify
// "fixing" it either direction, and one did: it stripped the prefix from zh/installation
// to match ja, arguing from repo conventions that the prefix was invented. It is not.
// Pinning the form removes the ambiguity that made the rewrite look correct.
// Only DOC PAGES. A root-absolute path with a file extension is a site asset or a
// well-known file — /robots.txt, /sitemap.xml, /favicon.svg — and prefixing those with
// /docs turns a working link into a 404. Rewriting a correct link to satisfy a gate is
// the worst outcome available here, so the rule is scoped to extensionless paths.
// Any extension length (`/site.webmanifest` is 12), and any underscore-prefixed route
// (`/_next/...`) — the earlier `/_/` alternative only matched a literal single underscore
// segment, which is not a thing. Getting this wrong tells an author to rewrite a working
// asset link into a 404, so it errs toward exempting.
const looksLikeAsset =
/\.[a-z0-9]+$/i.test(rawTarget) || /^\/(api|assets|static|_[a-z0-9-]*)\//i.test(rawTarget);
// `(?!docs\/)` alone rejected the bare `/docs` link — the lookahead wants a slash after
// "docs", which the docs root does not have — so a correct link to the docs home was
// reported as missing the prefix it already is.
const alreadyPrefixed = /^\/docs(\/|$)/.test(rawTarget);
const isSiteAsset = /^\/(images|logo)\//.test(rawTarget);
if (!looksLikeAsset && !alreadyPrefixed && !isSiteAsset && /^\/[a-z]/.test(rawTarget)) {
fail(
loc,
slug,
`link "${href}" is missing the /docs prefix — localized pages use "/docs/..." ` +
`everywhere (both forms resolve; we pin one so agents stop rewriting each other)`,
);
continue;
}
// `/docs` (no trailing slash) is the docs ROOT, not a page called "docs". Stripping only
// `/docs/` left the bare form as the slug "docs", which resolves to no file and was
// reported as a broken link — a correct docs-home link failing the gate.
const target = rawTarget
.replace(/^\/docs(\/|$)/, '')
.replace(/^\//, '')
.replace(/\/$/, '');
if (!target) continue;
const isLocal = target.startsWith(`${loc}/`);
const bare = isLocal ? target.slice(loc.length + 1) : target;
const exists = isLocal
? localized.has(bare)
: fs.existsSync(path.join(DOCS, `${bare}.mdx`)) ||
fs.existsSync(path.join(DOCS, `${bare}.md`)) ||
fs.existsSync(path.join(DOCS, bare));
if (!exists) {
fail(loc, slug, `link "${href}" resolves to nothing`);
continue;
}
// Cross-page anchor: check it against the headings of the page actually linked to,
// which for a localized target means the TRANSLATED headings.
if (frag) {
const targetFile = isLocal
? path.join(dir, `${bare}.mdx`)
: fs.existsSync(path.join(DOCS, `${bare}.mdx`))
? path.join(DOCS, `${bare}.mdx`)
: path.join(DOCS, `${bare}.md`);
if (fs.existsSync(targetFile)) {
const targetAnchors = anchors(eol(fs.readFileSync(targetFile, 'utf8')));
if (!targetAnchors.has(frag.toLowerCase())) {
fail(loc, slug, `anchor "#${frag}" does not exist in ${isLocal ? `${loc}/` : ''}${bare}`);
}
}
}
}
// 3. Frontmatter present, and actually translated.
const ef2 = frontmatter(en);
const tf2 = frontmatter(tr);
if (!tf2) {
fail(loc, slug, 'no frontmatter');
} else {
// "Present, and not byte-identical to English" is the rule that works for EVERY locale.
// An earlier version also demanded non-Latin characters, which is correct for ko/ja/ru/ar
// and simply wrong for fr/es/pt-BR/tr — a French title is Latin script and still
// translated. A rule that fires on four of eleven correct locales is worse than no rule.
// `description` is always prose, so an identical one means it was never translated —
// and it is the search snippet, which is the whole SEO point. `title` is often a bare
// product name ("ComfyUI MCP", "Docker", "RunPod") where leaving it identical is
// correct, so it only has to be PRESENT.
if (ef2?.title && !tf2.title) fail(loc, slug, 'frontmatter title missing');
if (ef2?.description) {
if (!tf2.description) fail(loc, slug, 'frontmatter description missing');
else if (tf2.description === ef2.description)
fail(loc, slug, 'frontmatter description is still English — this is the search snippet');
}
if (ef2?.icon && tf2.icon !== ef2.icon) fail(loc, slug, `icon changed (${ef2.icon} → ${tf2.icon})`);
}
// 4. Shape: heading count and component set.
const eh = headings(en).length;
const th = headings(tr).length;
if (eh !== th) fail(loc, slug, `${eh} heading(s) in English, ${th} here`);
const ec = components(en).join(',');
const tc = components(tr).join(',');
if (ec !== tc) fail(loc, slug, `MDX components differ — English [${ec}] vs [${tc}]`);
}
console.log(` ${failures ? '·' : '✓'} ${loc} — ${checked} page(s) compared`);
}
if (failures) {
console.error(`\n${failures} structural problem(s).`);
process.exit(1);
}
console.log('\nlocalized docs OK');