chinese2english/scripts/merge-dict.mjs
spearzificial 64831fe317 feat: translate 100% of served UI literals, including template literals
The remaining Chinese was one measurement bug and one structural gap.

Measurement: the harvester and every earlier gap report walked the profile
tree only. 55 of the 58 served client bundles live in the dsh install tree,
so whole sentences were never in scope and the strict coverage rule left
them in Chinese. Scanning the tree the browser actually receives showed
74.27% coverage, not the ~100% previously claimed.

Structure: rewriteScript skipped every backtick literal, and skipped any
literal containing ${. That is where user-visible text like
`剩余 ${hours} 小时 ${minutes} 分` lived, so it could never be translated.

- scripts/analyze.ts walks the resolved served-bundle set (exported to
  ~/.cache/c2e/served.json by scripts/served.mjs) instead of a plugin root.
- src/dictionary.residual.json: 1,452 entries covering the measured gap.
- src/rewrite.ts: substituteInterpolated handles template literals. Only an
  exact dictionary hit is accepted, because segmenting a template rebuilds
  the sentence from word fragments (`已退出 ${n} 个网页端会话` became
  `Exited ${n} itemsWeb pageSessions`). A translation that changes the
  number of interpolations is rejected.
- src/strings.ts: scanLiteralEnd tracks ${ depth through nested strings and
  nested template literals. Previously `` `a ${c ? `b` : ""}` `` was cut at
  the inner backtick and rewritten as a fragment.
- scripts/merge-dict.mjs: bilingual keys (`中文 / English`) resolve to their
  human-written tail instead of being machine-translated. Also merges the
  residual dictionary.
- scripts/mt-fill.mjs: guards templates by interpolation count, skips
  bilingual keys, and walks both plugin roots.

Verified over all 78 served bundles via the runtime entry point
translateScript: 54/54 bundles rewritten, residual Chinese in display
literals 9 -> 2 strings (2 CJK chars), both inside CSS comments within a
stylesheet string, so no user-visible text remains. All 5,437 Chinese-bearing
display literals now resolve.
2026-10-18 20:22:35 +05:30

112 lines
4.5 KiB
JavaScript

import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
// Merges the two translation sources already in this repo into one canonical
// dictionary payload. Dependency-free; rerun with `npm run dict`.
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const pairs = new Map();
const marketPath = join(root, "dshmarket_dict.json");
if (existsSync(marketPath)) {
const data = JSON.parse(readFileSync(marketPath, "utf8"));
for (const [zh, en] of Object.entries(data)) pairs.set(zh, en);
}
// patch-plugins.py carries its pairs inline as `"zh": "en",` lines.
const patchPath = join(root, "patch-plugins.py");
if (existsSync(patchPath)) {
const src = readFileSync(patchPath, "utf8");
const body = src.slice(src.indexOf("UI_TRANSLATIONS = {"));
const re = /^\s*"((?:[^"\\]|\\.)*)"\s*:\s*"((?:[^"\\]|\\.)*)"\s*,\s*$/gm;
let m;
while ((m = re.exec(body))) pairs.set(m[1], m[2]);
}
// Curated supplement, measured against the real served bundles. Merged after
// the harvested sources so it overrides anything they got wrong.
const extraPath = join(root, "src", "dictionary.extra.json");
if (existsSync(extraPath)) {
const data = JSON.parse(readFileSync(extraPath, "utf8"));
for (const [zh, en] of Object.entries(data)) pairs.set(zh, en);
}
// Machine translations of the literals harvested from installed bundles, filled
// by `npm run mt`. Lower confidence than the curated sources above, so it is
// merged after `dictionary.extra.json` and before the author-written pairs.
const mtPath = join(root, "src", "dictionary.mt.json");
if (existsSync(mtPath)) {
const data = JSON.parse(readFileSync(mtPath, "utf8"));
for (const [zh, en] of Object.entries(data)) pairs.set(zh, en);
}
// Machine translations of the literals that the harvested bundles still left in
// Chinese, measured by scripts/analyze over the served bundle set.
const residualPath = join(root, "src", "dictionary.residual.json");
if (existsSync(residualPath)) {
const data = JSON.parse(readFileSync(residualPath, "utf8"));
for (const [zh, en] of Object.entries(data)) pairs.set(zh, en);
}
// Author-authored `{ zh, en }` pairs lifted from plugin locale maps. This is the
// highest-quality source: a human wrote both sides, so it beats every
// machine-harvested pair and is merged last.
const localePairsPath = join(root, "locale-pairs.json");
if (existsSync(localePairsPath)) {
const data = JSON.parse(readFileSync(localePairsPath, "utf8"));
for (const [zh, en] of Object.entries(data)) pairs.set(zh, en);
}
// A handful of upstream entries are Malay, not English. Emitting them would
// inject foreign words into an English UI, so they are replaced by name.
const NON_ENGLISH = new Map([
["当前", "Current"],
["本地", "Local"],
]);
// Some harvested keys are already bilingual, e.g. `中文说明 / English text`.
// Translating the whole key produces a translation of the Chinese half glued to
// the English half, so the human-written tail is used verbatim instead. The
// tail must be substantial, otherwise a slash is just punctuation inside a
// sentence.
function bilingualTail(zh) {
const halves = zh.split(/\s+\/\s+/);
if (halves.length < 2) return undefined;
const head = halves.slice(0, -1).join(" / ");
const tail = halves[halves.length - 1].trim();
if ((head.match(/[\u4e00-\u9fa5]/g) ?? []).length < 6) return undefined;
if (tail.length < 12 || !/^[\x20-\x7e]+$/.test(tail) || !/[A-Za-z]/.test(tail)) return undefined;
if (/[\u4e00-\u9fa5]/.test(tail)) return undefined;
return tail;
}
const clean = {};
let dropped = 0;
let rewroteBilingual = 0;
for (const [zh, en] of pairs) {
// Guard the substitution contract: a key must be Chinese and must actually
// differ from its replacement, otherwise rewriting is a no-op with risk.
if (!/[\u4e00-\u9fa5]/.test(zh) || !en || zh === en) {
dropped++;
continue;
}
const tail = bilingualTail(zh);
if (tail !== undefined && tail !== en) {
rewroteBilingual++;
clean[zh] = tail;
continue;
}
clean[zh] = NON_ENGLISH.get(zh) ?? en;
}
const sorted = Object.fromEntries(
Object.entries(clean).sort((a, b) => b[0].length - a[0].length || a[0].localeCompare(b[0])),
);
writeFileSync(join(root, "src", "dictionary.json"), JSON.stringify(sorted, null, 2) + "\n");
const lengths = Object.keys(sorted).map((k) => k.length);
console.log(
`dictionary: ${Object.keys(sorted).length} entries` +
` (dropped ${dropped}, bilingual ${rewroteBilingual}), key length ${Math.min(...lengths)}-${Math.max(...lengths)}`,
);