mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
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.
138 lines
4.5 KiB
JavaScript
138 lines
4.5 KiB
JavaScript
// 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();
|