docs: record the measurement traps and script them

Coverage was over-reported twice, both times by measuring the wrong thing:
once by scanning a plugin root instead of the served bundle set, once with a
non-global regex that returned one hit per file instead of a character count.
Both produced a confident "100%" while the interface was still Chinese, so
the measurement is now part of the repo instead of being done by eye.

- scripts/served.mjs resolves the boot HTML's combo URLs to the on-disk files
  the browser receives, searching lib/, client/, dist/ and the package root.
- scripts/measure.mts counts Chinese on the runtime output, decoding \uXXXX
  and separating comment text from text that renders.
- README documents the merge order, the comment dictionary, and both traps.
This commit is contained in:
spearzificial 2026-10-18 22:33:30 +05:30
parent 23327d2bd5
commit ab0179545a
4 changed files with 164 additions and 8 deletions

View file

@ -52,12 +52,14 @@ The client half does one thing: it selects the `en` locale through
- Text rendered inside an `<img>`, 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

View file

@ -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"

60
scripts/measure.mts Normal file
View file

@ -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 <served.json>
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}`);

66
scripts/served.mjs Normal file
View file

@ -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 <boot.html> [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 <boot.html> [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(/&amp;/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 `<pkg>/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;