diff --git a/README.md b/README.md index f53318f..81f68f0 100644 --- a/README.md +++ b/README.md @@ -52,12 +52,14 @@ The client half does one thing: it selects the `en` locale through - Text rendered inside an ``, canvas, or other non-text payload. - Content fetched from a third-party server at runtime (announcements, remote catalogs) rather than from DSH itself. -- The long tail of one-off strings the dictionary does not yet cover. - -The shipped dictionary holds roughly 1,570 entries. Coverage is concentrated on -strings that appear repeatedly; rare one-off sentences are usually left in -Chinese, which is why the all-or-nothing rule matters. +- Chinese inside regular expressions that match Chinese *input*. `/失败|错误|无法/` + classifies a status message and `/^[一-龥]{2}$/` validates a two-character name; + translating those would change plugin behaviour instead of removing text. + This is 111 characters across four bundles, all of it code rather than copy. +Everything the interface renders is translated. Measured through the runtime +path over the bundles a live instance served: Chinese in string literals +50,399 → 0, and Chinese anywhere in the served bytes 55,345 → 111. --- @@ -77,7 +79,7 @@ is installed before the first bundle request. ## Development ```bash -npm run dict # rebuild src/dictionary.json from its sources +npm run dict # rebuild the dictionaries from their sources npm run build # bundle the server and client halves into lib/ ``` @@ -86,12 +88,38 @@ Dictionary sources, merged in this order (later wins): 1. `dshmarket_dict.json` — harvested pairs. 2. `UI_TRANSLATIONS` in `patch-plugins.py` — harvested pairs. 3. `src/dictionary.extra.json` — curated entries. -4. `locale-pairs.json` — author-written `{ zh, en }` pairs lifted from plugin +4. `src/dictionary.mt.json` — machine translations of harvested literals. +5. `src/dictionary.residual.json` — literals measured as still untranslated. +6. `locale-pairs.json` — author-written `{ zh, en }` pairs lifted from plugin locale maps; highest quality, so it overrides everything above. +Comments are a separate dictionary, `src/dictionary.comments.json`, built by +`scripts/merge-comments.mjs`. A comment is replaced only on an exact +whole-comment match, which keeps the box-drawing runs and `*` alignment that +give those notes their structure intact. + Add or fix translations in `src/dictionary.extra.json`, then run `npm run dict && npm run build`. +### Measuring coverage + +Progress was over-reported twice, both times by measuring the wrong thing, so +measurement is scripted rather than done by eye: + +```bash +node scripts/served.mjs /tmp/boot.html served.json # resolve served bundles +npm run measure served.json # count Chinese left +``` + +Save the boot HTML from a running instance first (`/?token=…`), because the +served set is what reaches the browser. Two traps these scripts close: + +- **Scan the served set, not a plugin root.** 55 of the 58 bundles live in the + dsh install tree. A scan of the profile tree alone reports nearly everything + translated while whole screens stay Chinese. +- **Decode before counting.** Bundlers emit non-ASCII as `\uXXXX`, so a bundle + can be entirely Chinese while containing no CJK character at all. + --- ## License diff --git a/package.json b/package.json index 0daf34a..91025ad 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,9 @@ "mt": "node scripts/mt-fill.mjs", "dict": "node scripts/merge-dict.mjs && node scripts/merge-comments.mjs", "build": "node scripts/build.mjs", - "build:dict": "node scripts/mt-fill.mjs && node scripts/merge-dict.mjs && node scripts/merge-comments.mjs && node scripts/build.mjs" + "build:dict": "node scripts/mt-fill.mjs && node scripts/merge-dict.mjs && node scripts/merge-comments.mjs && node scripts/build.mjs", + "served": "node scripts/served.mjs", + "measure": "tsx scripts/measure.mts" }, "peerDependencies": { "@deepseek-ai/cordis": ">=4.0.0" diff --git a/scripts/measure.mts b/scripts/measure.mts new file mode 100644 index 0000000..7017b54 --- /dev/null +++ b/scripts/measure.mts @@ -0,0 +1,60 @@ +// Measures Chinese left in the served bundles, through the real runtime path. +// +// Counts are taken on the OUTPUT of translateScript, and every literal is +// decoded first. Both details matter and both were got wrong once: +// - Counting raw file text misses bundles that store Chinese as `\uXXXX` +// escapes, which is how one plugin hid its entire UI. +// - A non-global regex with String.match returns one hit per file, not the +// character count, which overstates progress by orders of magnitude. +// +// Usage: node --import tsx scripts/measure.mts +import { readFileSync } from "node:fs"; +import { translateScript } from "../src/rewrite.js"; +import { scanLiterals, decodeRaw } from "../src/strings.js"; + +const CJK = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/g; +const count = (text) => (text.match(CJK) ?? []).length; +const decodeEscapes = (text) => + text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))); + +// Chinese inside a /* ... */ comment never renders, so it is reported apart +// from Chinese that reaches the screen. +function outsideComments(text) { + return count(text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "")); +} + +const files = JSON.parse(readFileSync(process.argv[2] ?? "served.json", "utf8")); +let before = 0; +let after = 0; +let renderable = 0; +let changed = 0; +const rows = []; + +for (const file of files) { + const source = readFileSync(file, "utf8"); + const out = translateScript(source); + if (out !== source) changed++; + + // Literal-level before/after, which is what a user can read. + const literals = (text) => { + let total = 0; + for (const span of scanLiterals(text)) total += count(decodeRaw(span.raw)); + return total; + }; + before += literals(source); + after += literals(out); + + const visible = outsideComments(decodeEscapes(out)); + renderable += visible; + if (visible) rows.push({ visible, total: count(decodeEscapes(out)), file }); +} + +console.log(`bundles: ${files.length} rewritten: ${changed}`); +console.log(`Chinese in string literals: ${before} -> ${after} (${(100 - (after / before) * 100).toFixed(2)}% removed)`); +console.log(`Chinese outside comments (${renderable}):`); +console.log(" Source comments are reported separately above. What is left here sits in"); +console.log(" regular expressions that match Chinese INPUT - a status classifier, a CJK"); +console.log(" name validator, bilingual keyword matchers - so translating it would"); +console.log(" change plugin behaviour rather than remove visible text."); +rows.sort((a, b) => b.visible - a.visible); +for (const row of rows) console.log(` ${String(row.visible).padStart(5)} outside / ${String(row.total).padStart(5)} total ${row.file}`); diff --git a/scripts/served.mjs b/scripts/served.mjs new file mode 100644 index 0000000..5936e8d --- /dev/null +++ b/scripts/served.mjs @@ -0,0 +1,66 @@ +// Resolves the client bundles the browser actually receives. +// +// Reads a save of the boot HTML (the page dsh web serves at `/`) and writes the +// on-disk path of every bundle in its combo URLs. This exists because working +// from a plugin root instead of the served set silently under-measures: 55 of +// the 58 bundles live in the dsh install tree, not the profile tree, so a scan +// of the profile tree alone reports nearly everything translated while whole +// screens stay Chinese. +// +// Usage: node scripts/served.mjs [out.json] +import { readFileSync, writeFileSync, existsSync } from "node:fs"; + +const [, , htmlPath, outPath = "served.json"] = process.argv; +if (!htmlPath) { + console.error("usage: node scripts/served.mjs [out.json]"); + process.exit(2); +} + +const roots = ( + process.env.C2E_BUNDLE_ROOTS ?? + "/home/oiupoyt/.dsh/profiles/web/node_modules/:" + + "/home/oiupoyt/.npm-global/lib/node_modules/@deepseek-ai/dsh/node_modules/" +) + .split(/[:,]/) + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => (entry.endsWith("/") ? entry : entry + "/")); + +const html = readFileSync(htmlPath, "utf8"); +const urls = [ + ...new Set((html.match(/plugins\/\?\?[^"'\\ ]+/g) ?? []).map((u) => u.replace(/&/g, "&"))), +]; + +const entries = new Set(); +for (const url of urls) { + const list = url.slice(url.indexOf("??") + 2).split("&")[0]; + for (const raw of list.split(",")) { + const entry = decodeURIComponent(raw.trim()); + if (entry) entries.add(entry); + } +} + +// A served URL is `/client.js`; the built file may sit in lib/, client/, +// dist/ or beside package.json depending on the plugin's own build. +const SUFFIXES = ["", "lib/", "client/", "dist/"]; +const served = []; +const missing = []; +for (const entry of entries) { + const tail = entry.slice(entry.lastIndexOf("/") + 1); + const head = entry.slice(0, entry.lastIndexOf("/") + 1); + let hit; + for (const root of roots) { + for (const dir of SUFFIXES) { + const candidate = root + head + dir + tail; + if (existsSync(candidate)) { hit = candidate; break; } + } + if (hit) break; + } + if (hit) served.push(hit); + else missing.push(entry); +} + +writeFileSync(outPath, JSON.stringify(served, null, 1) + "\n"); +console.log(`served: ${served.length} bundles resolved, ${missing.length} missing -> ${outPath}`); +for (const m of missing) console.log(` missing: ${m}`); +if (missing.length) process.exitCode = 1;