mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-29 18:51:43 +02:00
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:
@@ -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();
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
Reference in New Issue
Block a user