Files
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

77 lines
3.1 KiB
JavaScript

// Dump every (locale, dottedKey, enValue) tuple that the parity check would
// flag as "identical to en, not in allow-list, not auto-allowed". Output is
// JSON, structured so apply-translations.mjs can consume the same shape after
// translations are filled in.
//
// Usage: node scripts/dump-untranslated.mjs > /tmp/untranslated.json
//
// Logic is imported from check-i18n-parity.mjs (compareLocales) so this stays
// in lockstep with the gate.
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;
const { compareLocales } = await import(url.pathToFileURL(path.join(scriptDir, 'check-i18n-parity.mjs')).href);
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 codes = ['en', 'de', 'fr', 'it', 'ja', 'pt-BR', 'zh-CN', 'zh-TW'];
const locales = Object.fromEntries(codes.map((c) => [c, loadLocale(path.join(localesDir, `${c}.ts`))]));
const { reports } = compareLocales(locales);
// Reports look like: { label: 'de: leaves identical to en (untranslated?)', items: ['<key>: "<value>"', ...] }
// We need to reverse-engineer the (locale, key, enValue) tuples — easier to
// just walk en and check each locale ourselves with the live ALLOWED, which
// is what compareLocales does anyway. So mirror that here.
const en = locales.en;
const out = {};
for (const code of codes) {
if (code === 'en') continue;
const map = locales[code];
const flagged = {};
for (const r of reports) {
if (r.label !== `${code}: leaves identical to en (untranslated?)`) continue;
for (const item of r.items) {
// Item format: "<dottedKey>: "<value>""
const m = item.match(/^(\S+):\s+"(.*)"$/);
if (!m) continue;
const key = m[1];
const enValue = en.get(key);
if (enValue !== undefined && map.get(key) === enValue) {
flagged[key] = enValue;
}
}
}
out[code] = flagged;
}
process.stdout.write(JSON.stringify(out, null, 2));