Files
SnapOtter/scripts/i18n/adapters/docs-md.mjs
T
SnapOtterandGitHub 00b651c9f8 feat(i18n): 21-language pipeline, landing/docs/API wiring, landing+API translations
Shared Claude Code translation pipeline (scripts/i18n, no API key) plus Astro/VitePress/Scalar i18n wiring. Landing and API reference translated into all 20 languages; docs i18n wiring + English source anchors. The translated docs markdown (apps/docs/<locale>/**, 3,620 files) follows in a companion PR because it exceeds GitHub's per-PR CI file limit.
2026-07-11 13:01:55 +08:00

339 lines
11 KiB
JavaScript

// scripts/i18n/adapters/docs-md.mjs
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { localeCodes } from "../lib/shared-i18n.mjs";
import { slugify } from "../lib/slugify.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
// scripts/i18n/adapters -> repo root -> apps/docs
const DEFAULT_ROOT = join(HERE, "..", "..", "..", "apps", "docs");
const LOCALE_DIRS = new Set(localeCodes().filter((c) => c !== "en"));
const SKIP_DIRS = new Set(["node_modules", ".vitepress", "public", ...LOCALE_DIRS]);
// Docs-dialect masking tokens (distinct delimiters from the shared mask lib so
// the two layers never collide).
const DTOKEN = (i) => `⟦DOCS${i}⟧`;
const DTOKEN_RE = /⟦DOCS\d+⟧/g;
/**
* List every root markdown file (relative POSIX path), excluding locale subtrees
* and non-content dirs.
* @param {string} root
* @returns {Promise<string[]>}
*/
async function listRootMarkdown(root) {
const out = [];
async function walk(dir) {
const entries = await readdir(dir, { withFileTypes: true });
for (const ent of entries) {
const abs = join(dir, ent.name);
if (ent.isDirectory()) {
if (SKIP_DIRS.has(ent.name)) continue;
await walk(abs);
} else if (ent.name.endsWith(".md")) {
out.push(relative(root, abs).split(sep).join("/"));
}
}
}
await walk(root);
return out.sort();
}
/**
* Split frontmatter from body. Returns { fm, body } where fm is the raw
* frontmatter block WITHOUT the fences (or null), and body is everything after.
* @param {string} text
* @returns {{ fm: string|null, body: string }}
*/
function splitFrontmatter(text) {
const m = text.match(/^---\n([\s\S]*?)\n---\n?/);
if (!m) return { fm: null, body: text };
return { fm: m[1], body: text.slice(m[0].length) };
}
/**
* Read a single scalar value from a raw frontmatter block, e.g. key: value.
* @param {string|null} fm
* @param {string} key
* @returns {string|undefined}
*/
function fmGet(fm, key) {
if (!fm) return undefined;
const m = fm.match(new RegExp(`^${key}:\\s*(.*)$`, "m"));
if (!m) return undefined;
return m[1].trim().replace(/^["']|["']$/g, "");
}
/**
* Split a body into fenced-code segments and prose segments so heading/anchor and
* docs-mask logic never touch lines inside ``` fences.
* @param {string} body
* @returns {Array<{ code: boolean, text: string }>}
*/
function segmentFences(body) {
const parts = [];
const re = /(^|\n)(```|~~~)[\s\S]*?\n\2/g;
let last = 0;
let m;
while ((m = re.exec(body)) !== null) {
if (m.index > last) parts.push({ code: false, text: body.slice(last, m.index) });
parts.push({ code: true, text: m[0] });
last = m.index + m[0].length;
}
if (last < body.length) parts.push({ code: false, text: body.slice(last) });
return parts;
}
/**
* Inject `{#slug}` into ATX headings that lack an explicit anchor. Idempotent.
* Only runs over non-code segments.
* @param {string} body
* @returns {string}
*/
function injectAnchors(body) {
// De-duplicate slugs exactly like markdown-it-anchor: a base slug already used
// in the file gets "-1", "-2", ... appended (start index 1). Explicit {#id}
// anchors already in the source claim their slot too, so injected slugs never
// collide with them. Seen-set spans every prose segment, matching VitePress.
const seen = new Set();
const unique = (base) => {
let slug = base;
let i = 1;
while (seen.has(slug)) {
slug = `${base}-${i}`;
i += 1;
}
seen.add(slug);
return slug;
};
return segmentFences(body)
.map((seg) => {
if (seg.code) return seg.text;
return seg.text.replace(/^(#{1,6})[ \t]+(.+?)[ \t]*$/gm, (line, hashes, title) => {
const explicit = title.match(/\{#([^}]+)\}\s*$/);
if (explicit) {
seen.add(explicit[1]); // pre-existing anchor claims its slug
return line;
}
const clean = title.replace(/[ \t]+#*$/, ""); // drop optional closing ATX hashes
return `${hashes} ${clean} {#${unique(slugify(clean))}}`;
});
})
.join("");
}
/**
* Docs-dialect pre-mask over non-code segments: hide `:::` container markers
* (keep the title label), `[[toc]]`, and explicit `{#anchor}` slugs so the model
* never rewrites them. Returns masked text plus the token table.
* @param {string} body
* @returns {{ masked: string, tokens: string[] }}
*/
function maskDocs(body) {
const tokens = [];
const push = (v) => {
tokens.push(v);
return DTOKEN(tokens.length - 1);
};
const masked = segmentFences(body)
.map((seg) => {
if (seg.code) return seg.text;
let out = seg.text;
// ::: type (mask the "::: type " marker, leave the title text after it)
out = out.replace(/^(:{3,})[ \t]*([a-zA-Z-]+)[ \t]*/gm, (_m, colons, type) =>
push(`${colons} ${type} `),
);
// bare closing :::
out = out.replace(/^:{3,}[ \t]*$/gm, (m) => push(m));
// [[toc]]
out = out.replace(/\[\[toc\]\]/gi, (m) => push(m));
// explicit {#anchor}
out = out.replace(/\{#[^}\n]+\}/g, (m) => push(m));
return out;
})
.join("");
return { masked, tokens };
}
/**
* Restore docs-dialect tokens.
* @param {string} masked
* @param {string[]} tokens
* @returns {string}
*/
function restoreDocs(masked, tokens) {
return String(masked).replace(DTOKEN_RE, (t) => {
const m = t.match(/DOCS(\d+)/);
const i = m ? Number(m[1]) : Number.NaN;
return tokens[i] ?? t;
});
}
/**
* Rewrite internal absolute links (/guide/x, /tools/y, /api/z) to /<locale>/...
* Leaves external URLs, already-prefixed links, and root asset paths alone.
* @param {string} body
* @param {string} locale
* @returns {string}
*/
function rewriteLinks(body, locale) {
return body.replace(/(\]\()(\/[^)\s]*)(\))/g, (_m, open, url, close) => {
if (url.startsWith(`/${locale}/`)) return `${open}${url}${close}`;
// Do not prefix asset paths VitePress serves from root public/.
if (/^\/(fonts|screenshots|logo|favicon|apple-touch|og-|llms)/.test(url)) {
return `${open}${url}${close}`;
}
return `${open}/${locale}${url}${close}`;
});
}
/**
* Insert or replace scalar frontmatter keys, preserving existing ones.
* @param {string} text
* @param {Record<string,string>} fields
* @returns {string}
*/
function upsertFrontmatter(text, fields) {
const { fm, body } = splitFrontmatter(text);
const lines = fm != null ? fm.split("\n") : [];
const seen = new Set();
const next = lines.map((line) => {
const m = line.match(/^([A-Za-z0-9_]+):/);
if (m && fields[m[1]] !== undefined) {
seen.add(m[1]);
return `${m[1]}: ${fields[m[1]]}`;
}
return line;
});
for (const [k, v] of Object.entries(fields)) {
if (!seen.has(k)) next.push(`${k}: ${v}`);
}
return `---\n${next.join("\n")}\n---\n${body}`;
}
/**
* Double-quote bare `description`/`title` frontmatter values so a translated
* value containing a colon, `#`, or other YAML-significant character does not
* break frontmatter parsing (VitePress loads it as YAML).
* @param {string} text
* @returns {string}
*/
export function quoteFrontmatterScalars(text) {
const { fm, body } = splitFrontmatter(text);
if (fm == null) return text;
const next = fm.split("\n").map((line) => {
const m = line.match(/^(description|title):[ \t]*(.*)$/);
if (!m) return line;
const val = m[2];
// Leave empty, already-quoted, or block-scalar values alone.
if (val === "" || /^["'|>]/.test(val)) return line;
const escaped = val.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return `${m[1]}: "${escaped}"`;
});
return `---\n${next.join("\n")}\n---\n${body}`;
}
/** Recursively list markdown under a locale dir, relative + POSIX. */
async function listLocaleMarkdown(dir) {
const out = [];
async function walk(d) {
const entries = await readdir(d, { withFileTypes: true });
for (const ent of entries) {
const abs = join(d, ent.name);
if (ent.isDirectory()) await walk(abs);
else if (ent.name.endsWith(".md")) out.push(relative(dir, abs).split(sep).join("/"));
}
}
await walk(dir);
return out.sort();
}
/**
* Build the docs adapter. `root` defaults to apps/docs; tests override it.
* @param {{ root?: string }} [opts]
*/
export function createDocsAdapter({ root = DEFAULT_ROOT } = {}) {
// id -> docs token table, populated by extract() and consumed by write() in the
// same run so the shared translator never needs to know about docs tokens.
const tokenTables = new Map();
return {
name: "docs",
async extract() {
const files = await listRootMarkdown(root);
const units = [];
for (const id of files) {
const abs = join(root, id);
const original = await readFile(abs, "utf8");
const { fm, body } = splitFrontmatter(original);
const anchored = injectAnchors(body);
const rebuilt = fm != null ? `---\n${fm}\n---\n${anchored}` : anchored;
// Persist anchored English source in place (idempotent) so the running
// site and every locale share the same stable slugs.
if (rebuilt !== original) await writeFile(abs, rebuilt, "utf8");
const { masked, tokens } = maskDocs(anchored);
tokenTables.set(id, tokens);
const sourceText = fm != null ? `---\n${fm}\n---\n${masked}` : masked;
units.push({ id, sourceText, kind: "markdown" });
}
return units;
},
async load(locale) {
const dir = join(root, locale);
const map = new Map();
let files = [];
try {
files = await listLocaleMarkdown(dir);
} catch {
return map; // no locale subtree yet
}
for (const rel of files) {
const text = await readFile(join(dir, rel), "utf8");
const { fm } = splitFrontmatter(text);
map.set(rel, {
text,
sourceHash: fmGet(fm, "i18n_source_hash") ?? "",
provenance: fmGet(fm, "i18n_provenance") === "human" ? "human" : "machine",
outputHash: fmGet(fm, "i18n_output_hash") ?? "",
stale: fmGet(fm, "i18n_stale") === "true",
});
}
return map;
},
async write(locale, entries) {
for (const [id, entry] of entries) {
const abs = join(root, locale, id);
await mkdir(dirname(abs), { recursive: true });
const table = tokenTables.get(id) ?? [];
const undone = restoreDocs(entry.text, table);
const linked = quoteFrontmatterScalars(rewriteLinks(undone, locale));
const withMeta = upsertFrontmatter(linked, {
i18n_source_hash: entry.sourceHash,
i18n_provenance: entry.provenance,
i18n_output_hash: entry.outputHash,
...(entry.stale ? { i18n_stale: "true" } : {}),
});
await writeFile(abs, withMeta, "utf8");
}
},
// Adapter extra (not part of the 3-method contract): write the English body
// under the locale path, flagged so a real translation replaces it later.
async writeFallback(locale, id) {
const abs = join(root, locale, id);
const original = await readFile(join(root, id), "utf8");
await mkdir(dirname(abs), { recursive: true });
const linked = rewriteLinks(original, locale);
const flagged = upsertFrontmatter(linked, { i18n_fallback: "true" });
await writeFile(abs, flagged, "utf8");
},
};
}
export const adapter = createDocsAdapter();