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.
This commit is contained in:
maziggy
2026-05-16 12:16:30 +02:00
parent 856b849ffa
commit f5f7531ece
13 changed files with 2476 additions and 1963 deletions
+137
View File
@@ -0,0 +1,137 @@
// Apply a batch of translations to locale files in-place.
//
// Usage: node scripts/apply-translations.mjs <translation-file.json>
//
// The translation file is a JSON object shaped like:
// {
// "de": { "nav.system": "System", "common.optional": "Optional" },
// "fr": { "nav.archives": "Archives" },
// ...
// }
//
// For each (locale, dottedKey, newValue) entry, the script uses the
// TypeScript parser to locate the leaf at that exact dotted path, then
// rewrites the string literal in place — preserving all other content
// (comments, ordering, formatting, surrounding code) untouched.
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 frontendDir = path.resolve(scriptDir, '..');
const localesDir = path.join(frontendDir, 'src/i18n/locales');
const tsPath = path.join(frontendDir, 'node_modules/typescript/lib/typescript.js');
const tsModule = await import(url.pathToFileURL(tsPath).href);
const ts = tsModule.default ?? tsModule;
// Walk the locale's AST, building a map of dottedPath -> string-literal node.
function collectLeafNodes(node, prefix, out) {
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)) {
collectLeafNodes(prop.initializer, p, out);
} else if (
ts.isStringLiteral(prop.initializer) ||
ts.isNoSubstitutionTemplateLiteral(prop.initializer)
) {
out.set(p, prop.initializer);
}
}
}
function loadLocaleNodes(filePath) {
const src = fs.readFileSync(filePath, 'utf8');
const sf = ts.createSourceFile(filePath, src, ts.ScriptTarget.Latest, true);
const leaves = new Map();
ts.forEachChild(sf, (n) => {
if (ts.isExportAssignment(n)) collectLeafNodes(n.expression, '', leaves);
});
return { src, leaves };
}
function literalReplacement(node, newValue) {
// Re-emit the literal preserving its quote style. Locale files use either
// single-quoted strings or backtick template-literal-with-no-substitutions.
const original = node.getText();
const quote = original.startsWith('`') ? '`' : original[0]; // ' or `
if (quote === '`') {
return '`' + newValue.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${') + '`';
}
const esc = newValue.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
return `'${esc}'`;
}
function applyToLocale(code, map) {
const file = path.join(localesDir, `${code}.ts`);
if (!fs.existsSync(file)) {
throw new Error(`Locale file not found: ${file}`);
}
let { src, leaves } = loadLocaleNodes(file);
// Apply edits in reverse-position order so earlier edits don't shift later positions.
const edits = [];
const errors = [];
let applied = 0;
let skipped = 0;
for (const [dottedKey, newValue] of Object.entries(map)) {
const node = leaves.get(dottedKey);
if (!node) {
errors.push(`${code}: key "${dottedKey}" not found in locale file`);
continue;
}
if (node.text === newValue) {
skipped++;
continue;
}
edits.push({
start: node.getStart(),
end: node.getEnd(),
replacement: literalReplacement(node, newValue),
});
applied++;
}
edits.sort((a, b) => b.start - a.start);
for (const e of edits) {
src = src.slice(0, e.start) + e.replacement + src.slice(e.end);
}
if (errors.length) {
console.error(`\n[${code}] errors:`);
for (const e of errors) console.error(` ${e}`);
}
if (applied > 0) {
fs.writeFileSync(file, src, 'utf8');
}
console.log(`[${code}] applied=${applied} skipped(same)=${skipped} errors=${errors.length}`);
return { applied, skipped, errors };
}
async function main() {
const arg = process.argv[2];
if (!arg) {
console.error('Usage: node apply-translations.mjs <translation-file.json>');
process.exit(2);
}
const data = JSON.parse(fs.readFileSync(arg, 'utf8'));
let totalErrors = 0;
for (const [code, map] of Object.entries(data)) {
const { errors } = applyToLocale(code, map);
totalErrors += errors.length;
}
if (totalErrors > 0) {
console.error(`\n${totalErrors} key(s) failed to apply.`);
process.exit(1);
}
}
main();
+215 -1
View File
@@ -1,9 +1,16 @@
// Verifies parity across locale files (en / zh-CN / zh-TW):
// Verifies parity across locale files (en / de / fr / it / ja / pt-BR / zh-CN / zh-TW):
// 1. Leaf-key sets are identical
// 2. Each leaf's {{placeholder}} set is identical
// 3. Plural suffixes: every en key ending in _plural / _one / _other must
// exist in every other locale, and other locales must not introduce an
// _one key that en does not have.
// 4. NEW: leaves in a non-English locale must not be identical to en, unless
// the value is a brand name / technical token / pure punctuation, OR the
// key+locale pair is explicitly listed in IDENTICAL_TO_EN_ALLOWED below.
// Catches the "copy English text into non-English locale to satisfy the
// key-count parity gate" anti-pattern that accumulated 700+ shipped
// strings of debt before the gate was tightened. Add an explicit entry
// ONLY when the string is a real word/term in that target locale.
// Malformed input (missing `export default`, parse errors, non-string leaves,
// unsupported property kinds) fails loudly instead of silently passing the gate.
// Exits 1 with a diagnostic report on any failure, else exits 0.
@@ -100,6 +107,193 @@ function loadLocale(filePath) {
const placeholderRe = /\{\{[^{}]+\}\}/g;
// Heuristic: values that are ALWAYS allowed to match en, regardless of locale.
// Brand names, technical tokens, pure punctuation, very short strings, version
// numbers, hex codes, and ALL-CAPS acronyms. Cognates that happen to be the
// same word in a specific locale go in IDENTICAL_TO_EN_ALLOWED instead.
function isAlwaysAllowedIdentical(value) {
if (!value) return true;
if (/^[\s\W_]+$/.test(value)) return true; // pure punctuation/whitespace
if (value.length <= 2) return true; // single character or 2-char abbrev
if (/^[A-Z][A-Z0-9_]+$/.test(value)) return true; // ALL_CAPS_TOKEN
if (/^v?\d+(\.\d+)+/.test(value)) return true; // version-like
if (/^#[0-9a-fA-F]{3,8}$/.test(value)) return true; // hex color
if (/^\{\{[^}]+\}\}$/.test(value)) return true; // pure placeholder
if (/^[0-9a-fA-F]{6}$/.test(value)) return true; // bare hex color
if (/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(value)) return true; // email
if (/^https?:\/\//.test(value)) return true; // URL
if (/^ON,\s+true,\s+1$/.test(value)) return true; // literal example "ON, true, 1"
// Brand / technical names that ship verbatim everywhere.
if (/^(Bambuddy|BamBuddy|SpoolBuddy|Bambu Lab|Bambu Studio|Bambu Studio 2\.6\+|Bambu Studio sidecar URL|OrcaSlicer|OrcaSlicer sidecar URL|MakerWorld|Spoolman|\(Spoolman\)|Spoolman URL|Tailscale|GitHub|GitLab|Gitea|Forgejo|Discord|MQTT|FTP|HTTPS?|JSON|YAML|RTSP|TLS|SSL|CSRF|OIDC|SSO|SSO \/ OIDC|LDAP|TOTP|2FA|MFA|API|AMS|CRC|SHA256|kWh|MB|GB|KB|RGBA?|HSL|RGB|UTC|ISO|UI|HTTP|HTTP Method|H2D|H2D Pro|X1C|X1E|P1S|P1P|A1|A1 Mini|H2C|N3F|N3S|PETG|PLA|ABS|PA|TPU|PEI|PA-CF|PVA|HIPS|ASA|PC|PETG-HF|G\.code|G-code|gcode|cm³|°C|°F|GCODE|SOURCE|ntfy|Pushover|Telegram|Webhook|Webhook URL|Home Assistant|Home Assistant URL|CallMeBot\/WhatsApp|Bambuddy URL|Cool Plate|Cool Plate SuperTack|Engineering Plate|High Temp Plate|Smooth PEI Plate|Textured PEI Plate|Ext-L|Ext-R|ISO \(YYYY-MM-DD\))$/.test(value)) return true;
return false;
}
// Per-(locale, value) allow-list for strings that are a real word/term in
// that target locale and so legitimately match en.ts. Curated — add an entry
// here ONLY after verifying that the word is correct (not just a shortcut to
// silence the check).
//
// Convention: same shape as the locales themselves — { de: Set, fr: Set, ... }.
// Values are matched exactly. To allow a value across many locales, list it in
// each one (verbosity is the point: every locale's allow-list is an explicit
// translator decision).
// German loanwords / cognates from English are extensive. Most short technical
// UI labels are identical in DE. List below curates the legitimate ones.
const DE_COGNATES = [
'Name', 'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Modus',
'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server', 'Port', 'Bug', 'Job',
'Pause', 'Power', 'System', 'Problem', 'Designer', 'Extruder', 'Firmware',
'Material', 'Original', 'Position', 'Webhook', 'Workflow', 'Slicer',
'Region', 'Normal', 'Orange', 'Branch', 'Budget', 'Commit', 'Global',
'Version', 'Slot', 'Live', 'Rate', 'Host', 'Trend', 'Min', 'Admin', 'Cloud',
'Filament', 'Filaments', 'Software', 'Hardware', 'Avatar', 'Pin', 'Modal',
'Active', 'Plate', 'Layer', 'Total', 'Plus', 'Pro', 'Mini', 'Studio',
'Temperatur', 'Process', 'Service', 'Cache', 'Color', 'Login', 'Logout',
'Action', 'Description', 'Sender', 'Setup', 'Bundle', 'Cluster', 'Tier',
'Standard (100%)', 'Sport (124%)', 'Ludicrous (166%)',
'Smart Plugs', 'Smart Switches', 'Smart Plug', 'High Flow',
'Optional', 'optional', 'Filter', 'Filters', 'optional)',
'Material:', 'Default:', 'Name *', '(System)', '(Inv)',
'Spoolman URL', 'Bundle', 'Slicer Bundles', 'Imported',
'STARTTLS (Port 587)', 'SSL/TLS (Port 465)', 'Sport', 'Standard',
'EC984C,#6CD4BC,A66EB9,D87694',
'Hex', 'Warm', 'Neutral', 'Navigation', 'Screenshot', 'Architecture',
'Backend & Auth', 'Stream Overlay', 'Bambuddy Backend URL',
'Material (optional)', 'Custom Headers (JSON)', '({{count}}/8)',
'AMS holder (30 × 15 mm)', 'Box label (62 × 29 mm)',
'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
'China', 'Proxy', 'Start',
];
// French cognates — many UI labels overlap with English exactly.
const FR_COGNATES = [
'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
'Token', 'Server', 'Port', 'Plate', 'Layer', 'Active', 'Total', 'Avatar',
'Job', 'Modal', 'Pin', 'Pro', 'Mini', 'Studio', 'Excellent', 'Description',
'Action', 'Actions', 'Date', 'Type', 'Cache', 'Service', 'Configuration',
'Archives', 'Maintenance', 'Notifications', 'Notification', 'Position',
'Pause', 'Solution', 'Source', 'Version', 'Format', 'Documentation',
'Mode', 'Format', 'Default', 'Auto', 'Image', 'Audio', 'Video', 'Hex',
'Camera', 'Avatar', 'Information', 'Initialization', 'Inactive', 'Active',
'Print', 'Console', 'Cluster', 'Tier', 'Status URL',
'Smart Plugs', 'Smart Switches', 'Smart Plug', 'High Flow',
'Material:', 'Default:', 'Name *', '(System)', '(Inv)',
'Process', 'Service', 'Service', 'Connect', 'Network', 'Local',
'Sport (124%)', 'Ludicrous (166%)', 'Standard (100%)',
'STARTTLS (Port 587)', 'SSL/TLS (Port 465)',
'Bundle', 'Slicer Bundles', 'Imported',
'Page', 'Note', 'Tare', 'Est.', 'Cloud', 'Style', 'Notes', 'Stock',
'Accent', 'Orange', 'Global', 'Stable', 'Archive', 'visible', 'minutes',
'Message', 'Slicer', 'Rotation', 'Original', 'Direction', 'Architecture',
'notifications', 'Maintenance OK', 'total', 'Provider', 'Token name',
'{{count}} filament', '{{count}} filaments', '{{count}} permissions',
'{{count}} downloads', '{{count}} item', '{{count}} selected',
'({{count}} item)', 'Provisioning...', 'Pressure Advance',
'AMS holder (30 × 15 mm)', 'Box label (62 × 29 mm)',
'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
'({{count}}/8)', 'Custom Headers (JSON)', 'Permissions',
'Expand dispatch details', 'Collapse dispatch details',
'Cancelling upload...', 'Backup in progress...', 'Searching directory...',
'EC984C,#6CD4BC,A66EB9,D87694',
'Proxy', 'Navigation', 'Budget', 'Commit', 'Designer',
'ntfy, Pushover, Discord, etc.',
];
// Italian cognates.
const IT_COGNATES = [
'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
'Token', 'Server', 'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini',
'Studio', 'Cache', 'Service', 'Avatar', 'Slicer', 'Action', 'Actions',
'Format', 'Modal', 'Login', 'Logout', 'Color', 'Plus', 'Job', 'Live',
'Position', 'Original', 'Material', 'Cluster', 'Tier', 'Auto', 'Hex',
'Bundle', 'Slicer Bundles', 'Imported', 'Smart Plugs', 'Smart Switches',
'Smart Plug', 'High Flow', 'Sport (124%)', 'Ludicrous (166%)',
'Standard (100%)', 'STARTTLS (Port 587)', 'SSL/TLS (Port 465)',
'Slot', 'Host', 'File', 'Cloud', 'Admin', 'Silk', '(Inv)', 'Slice',
'Backup', 'Legacy', 'Branch', 'Auto On', 'Display', 'Password',
'Auto Off', 'Dashboard', 'Timestamp', 'Pressure Advance', 'Provisioning...',
'(25%, 50%, 75%)', 'Provider', 'Provider: {{type}}', 'Base: {{name}}',
'Slicing…', 'Designer', 'Firmware', 'Timelapse', 'Commit', 'Budget',
'({{count}}/8)', 'Custom Headers (JSON)', 'ETA {{minutes}} min',
'{{name}} - Timelapse', 'Box label (62 × 29 mm)',
'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
'AMS holder (30 × 15 mm)', 'Hex: #{{hex}}',
'EC984C,#6CD4BC,A66EB9,D87694',
'Proxy', 'Designer',
];
// Japanese: very few cognates because of script difference. Almost
// everything needs translation. Only true loanwords / proper nouns stay.
const JA_COGNATES = [
'OK', 'Bambu', 'Code',
'EU (DD/MM/YYYY)', 'US (MM/DD/YYYY)', 'ON, true, 1',
'({{count}}/8)', 'Custom Headers (JSON)',
'AMS holder (30 × 15 mm)', 'Box label (62 × 29 mm)',
'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
'EC984C,#6CD4BC,A66EB9,D87694',
];
// Portuguese (BR) cognates.
const PT_BR_COGNATES = [
'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server',
'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini', 'Studio', 'Cache',
'Service', 'Avatar', 'Total', 'Active', 'Login', 'Logout', 'Color', 'Hex',
'Slot', 'Live', 'Rate', 'Host', 'Trend', 'Original', 'Auto', 'Bundle',
'Imported', 'Action', 'Actions', 'Slicer Bundles', 'Sport (124%)',
'Ludicrous (166%)', 'Standard (100%)', 'STARTTLS (Port 587)',
'SSL/TLS (Port 465)', 'Smart Plugs', 'Smart Switches', 'High Flow',
'Position', 'Mode', 'Setup', 'Modal',
'Local', 'Metal', 'China', 'Admin', 'Silk', 'Backup', '(Inv)', 'Branch',
'Normal', 'Material', 'Material:', 'Multicolor', 'Designer', 'Firmware',
'Timelapse', 'Est.', 'total', 'Commit', 'Global',
'Base: {{name}}', 'ETA {{minutes}} min', '{{count}} item',
'{{count}} downloads', '({{count}} item)', '(25%, 50%, 75%)',
'({{count}}/8)', 'Custom Headers (JSON)', '{{name}} - Timelapse',
'AMS holder (30 × 15 mm)', 'Box label (62 × 29 mm)',
'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
'Cancelling upload...', 'EC984C,#6CD4BC,A66EB9,D87694',
'Expand dispatch details', 'Collapse dispatch details',
'e.g., Home Assistant, OctoPrint', 'ntfy, Pushover, Discord, etc.',
'Proxy', 'total: {{minutes}} min',
];
// Chinese (Simplified): very few cognates beyond brand names.
const ZH_CN_COGNATES = [
'OK', 'Bambu',
'({{count}}/8)', 'Custom Headers (JSON)', 'AMS holder (30 × 15 mm)',
'Box label (62 × 29 mm)',
'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
'EC984C,#6CD4BC,A66EB9,D87694',
];
const ZH_TW_COGNATES = [
'OK', 'Bambu',
'({{count}}/8)', 'Custom Headers (JSON)', 'AMS holder (30 × 15 mm)',
'Box label (62 × 29 mm)',
'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
'EC984C,#6CD4BC,A66EB9,D87694',
];
const IDENTICAL_TO_EN_ALLOWED = {
de: new Set(DE_COGNATES),
fr: new Set(FR_COGNATES),
it: new Set(IT_COGNATES),
ja: new Set(JA_COGNATES),
'pt-BR': new Set(PT_BR_COGNATES),
'zh-CN': new Set(ZH_CN_COGNATES),
'zh-TW': new Set(ZH_TW_COGNATES),
};
// Pure comparison logic, exported so tests can verify each failure mode
// without going through file IO or the TypeScript parser.
// Input: locales = { code: Map<leafKey, leafString> } (must contain 'en')
@@ -158,6 +352,26 @@ export function compareLocales(locales) {
add(`${code}: plural key mismatch`, pluralIssues);
}
// Check 4: identical-to-en leaks. A non-English leaf whose value exactly
// matches en.ts must either pass the always-allowed heuristic OR be listed
// in IDENTICAL_TO_EN_ALLOWED[code]. Otherwise it's almost certainly an
// untranslated English string that slipped through past parity gates.
for (const [code, map] of Object.entries(locales)) {
if (code === 'en') continue;
const allowed = IDENTICAL_TO_EN_ALLOWED[code] ?? new Set();
const leaks = [];
for (const [key, enValue] of locales.en) {
const localeValue = map.get(key);
if (localeValue === undefined) continue;
if (localeValue !== enValue) continue;
if (isAlwaysAllowedIdentical(enValue)) continue;
if (allowed.has(enValue)) continue;
const preview = enValue.length > 60 ? `${enValue.slice(0, 57)}...` : enValue;
leaks.push(`${key}: "${preview}"`);
}
add(`${code}: leaves identical to en (untranslated?)`, leaks);
}
return { failed: reports.length > 0, reports };
}
+76
View File
@@ -0,0 +1,76 @@
// 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));
+86
View File
@@ -0,0 +1,86 @@
// 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));