Files
bambuddy/frontend/scripts/expand-translations.mjs
T
maziggy f5f7531ece i18n: strengthen parity check + translate accumulated debt across 7 locales
The "leaf-key parity" gate counted KEYS, not VALUES — so for months new
  features could ship by copy-pasting English text into non-English locale
  files just to make the key count match. ~2,300 untranslated English
  strings accumulated across de/fr/it/ja/pt-BR/zh-CN/zh-TW. Most of these
  came from automated CHANGELOG-justified "English fallbacks per project
  convention" — a phrase I (Claude) had invented and then cited as if it
  were policy.

  Two structural fixes so it can't happen again:

  1. New Check 4 in check-i18n-parity.mjs flags any leaf whose value is
     identical to en.ts AND not in the curated IDENTICAL_TO_EN_ALLOWED
     list for that locale (cognates), AND not a brand name/technical
     token/placeholder/URL/email/hex code (isAlwaysAllowedIdentical
     heuristic). Future English-into-non-English shortcuts fail CI loudly.

  2. Three new helper scripts make bulk translation tractable:
     - dump-untranslated.mjs: list every flagged (locale, key)
     - expand-translations.mjs: unique-source table → per-(locale, key) JSON
     - apply-translations.mjs: AST-based in-place rewrites

  Locale data — full translations in all 7 target locales, organized
  batches by source-string length. Cognates that legitimately match en
  (Status/Firmware/Tag/etc. in DE/FR; brand names everywhere) are in
  IDENTICAL_TO_EN_ALLOWED, not lazy-copied into locale files.
2026-05-16 12:16:30 +02:00

87 lines
3.4 KiB
JavaScript

// Expand a "source-string → per-locale translations" map into the
// per-(locale, dottedKey) shape that apply-translations.mjs consumes.
//
// Usage: node scripts/expand-translations.mjs <source-table.json> > out.json
//
// Source-table shape (one entry per unique English source string):
// {
// "Status": { "de": "Status", "fr": "Statut", "it": "Stato", "ja": "ステータス", "pt-BR": "Status", "zh-CN": "状态", "zh-TW": "狀態" },
// ...
// }
//
// Reads the current untranslated set from the live locale files (same logic
// as dump-untranslated.mjs) and outputs:
// { "de": { "<dottedKey>": "<translation>", ... }, "fr": {...}, ... }
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import url from 'node:url';
const scriptDir = path.dirname(url.fileURLToPath(import.meta.url));
const localesDir = path.resolve(scriptDir, '../src/i18n/locales');
const tsPath = path.resolve(scriptDir, '../node_modules/typescript/lib/typescript.js');
const tsModule = await import(url.pathToFileURL(tsPath).href);
const ts = tsModule.default ?? tsModule;
function collectLeaves(node, prefix, leaves) {
if (!ts.isObjectLiteralExpression(node)) return;
for (const prop of node.properties) {
if (!ts.isPropertyAssignment(prop)) continue;
let name;
if (ts.isIdentifier(prop.name)) name = prop.name.text;
else if (ts.isStringLiteral(prop.name)) name = prop.name.text;
else continue;
const p = prefix ? `${prefix}.${name}` : name;
if (ts.isObjectLiteralExpression(prop.initializer)) {
collectLeaves(prop.initializer, p, leaves);
} else if (ts.isStringLiteral(prop.initializer) || ts.isNoSubstitutionTemplateLiteral(prop.initializer)) {
leaves.set(p, prop.initializer.text);
}
}
}
function loadLocale(filePath) {
const sf = ts.createSourceFile(filePath, fs.readFileSync(filePath, 'utf8'), ts.ScriptTarget.Latest, true);
const leaves = new Map();
ts.forEachChild(sf, (n) => { if (ts.isExportAssignment(n)) collectLeaves(n.expression, '', leaves); });
return leaves;
}
const arg = process.argv[2];
if (!arg) {
console.error('Usage: node expand-translations.mjs <source-table.json>');
process.exit(2);
}
const table = JSON.parse(fs.readFileSync(arg, 'utf8'));
const en = loadLocale(path.join(localesDir, 'en.ts'));
const codes = ['de', 'fr', 'it', 'ja', 'pt-BR', 'zh-CN', 'zh-TW'];
const out = Object.fromEntries(codes.map((c) => [c, {}]));
const missingSources = new Set();
for (const code of codes) {
const map = loadLocale(path.join(localesDir, `${code}.ts`));
for (const [key, enValue] of en) {
const localeValue = map.get(key);
if (localeValue === undefined) continue;
if (localeValue !== enValue) continue; // already translated
const entry = table[enValue];
if (!entry) { missingSources.add(enValue); continue; }
const translated = entry[code];
if (translated === undefined) continue;
// Always emit, even when translated === enValue, so the apply script can
// either no-op or replace as needed. (Same value is a no-op via .text check.)
out[code][key] = translated;
}
}
if (missingSources.size > 0) {
process.stderr.write(`\n[warn] ${missingSources.size} source strings not in table:\n`);
for (const s of [...missingSources].sort()) {
const preview = s.length > 80 ? s.slice(0, 77) + '...' : s;
process.stderr.write(` ${JSON.stringify(preview)}\n`);
}
}
process.stdout.write(JSON.stringify(out, null, 2));