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. - Text rendered inside an `<img>`, canvas, or other non-text payload.
- Content fetched from a third-party server at runtime (announcements, remote - Content fetched from a third-party server at runtime (announcements, remote
catalogs) rather than from DSH itself. catalogs) rather than from DSH itself.
- The long tail of one-off strings the dictionary does not yet cover. - Chinese inside regular expressions that match Chinese *input*. `/失败|错误|无法/`
classifies a status message and `/^[一-龥]{2}$/` validates a two-character name;
The shipped dictionary holds roughly 1,570 entries. Coverage is concentrated on translating those would change plugin behaviour instead of removing text.
strings that appear repeatedly; rare one-off sentences are usually left in This is 111 characters across four bundles, all of it code rather than copy.
Chinese, which is why the all-or-nothing rule matters.
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 ## Development
```bash ```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/ 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. 1. `dshmarket_dict.json` — harvested pairs.
2. `UI_TRANSLATIONS` in `patch-plugins.py` — harvested pairs. 2. `UI_TRANSLATIONS` in `patch-plugins.py` — harvested pairs.
3. `src/dictionary.extra.json` — curated entries. 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. 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 Add or fix translations in `src/dictionary.extra.json`, then run
`npm run dict && npm run build`. `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 ## License

View file

@ -40,7 +40,9 @@
"mt": "node scripts/mt-fill.mjs", "mt": "node scripts/mt-fill.mjs",
"dict": "node scripts/merge-dict.mjs && node scripts/merge-comments.mjs", "dict": "node scripts/merge-dict.mjs && node scripts/merge-comments.mjs",
"build": "node scripts/build.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": { "peerDependencies": {
"@deepseek-ai/cordis": ">=4.0.0" "@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;