chore(i18n): add fill-missing tool and update .gitignore for locale management

Entire-Checkpoint: f957cd9849e0
This commit is contained in:
4gray committed 2026-05-02 17:20:55 +02:00
1 parent 8fd25ac39a
commit 6b1e4a3ba7
3 files changed
+195

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
missing/
patches/
+52
View File
@@ -0,0 +1,52 @@
# i18n fill-missing tool
One-shot helper for bringing every locale file in `apps/web/src/assets/i18n/`
to full coverage of `en.json`.
## How it works
`en.json` is the source of truth. For every other locale, the script:
1. Walks `en.json` in order (top-level + nested).
2. For each leaf key:
- Keeps the locale's existing translation if present.
- Otherwise pulls the translation from `tools/i18n/patches/<lang>.json`
(a flat object keyed by dotted path).
- If neither exists, leaves the key absent and exits non-zero.
3. Writes the merged tree back to the locale file with 4-space indent and a
trailing newline.
Existing translations are never overwritten.
## Workflow
```bash
# 1. Generate one missing-strings dump per locale (flat dotted-path → EN string)
node tools/i18n/fill-missing.mjs --emit-missing
# → tools/i18n/missing/<lang>.json
# 2. Translate each missing/<lang>.json into the equivalent patch file
# in tools/i18n/patches/<lang>.json (same shape: { dotted.path: translated }).
# 3. Merge patches into the locale files
node tools/i18n/fill-missing.mjs
# 4. Verify
node -e "
const fs=require('fs');
const en=JSON.parse(fs.readFileSync('apps/web/src/assets/i18n/en.json','utf8'));
const collect=(d,p='')=>Object.entries(d).flatMap(([k,v])=>v&&typeof v==='object'?collect(v,p?p+'.'+k:k):[p?p+'.'+k:k]);
const enKeys=new Set(collect(en));
for(const f of fs.readdirSync('apps/web/src/assets/i18n').filter(x=>x.endsWith('.json')&&x!=='en.json')){
const k=new Set(collect(JSON.parse(fs.readFileSync('apps/web/src/assets/i18n/'+f,'utf8'))));
console.log(f,'missing:',[...enKeys].filter(x=>!k.has(x)).length,'extra:',[...k].filter(x=>!enKeys.has(x)).length);
}"
```
## Translation rules
- Preserve `{{interpolation}}` placeholders verbatim.
- Preserve any inline HTML tags.
- Match the locale's existing punctuation, casing, and terminology.
- Keep brand names (IPTVnator, Xtream, Stalker, M3U, EPG, MPV, VLC) untranslated.
- Short UI strings stay short — no length blow-up.
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env node
/**
* Mechanical i18n merger.
*
* Modes:
* node tools/i18n/fill-missing.mjs --emit-missing Dump tools/i18n/missing/<lang>.json
* listing every missing key with the EN value.
* node tools/i18n/fill-missing.mjs Merge tools/i18n/patches/<lang>.json into
* apps/web/src/assets/i18n/<lang>.json,
* preserving existing translations and the
* key order from en.json.
*
* The script never invents translations. If a key is missing from both the locale file
* and the patch file, it is left absent and the script exits non-zero.
*/
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
writeFileSync,
} from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(__dirname, '..', '..');
const I18N_DIR = resolve(REPO_ROOT, 'apps/web/src/assets/i18n');
const PATCH_DIR = resolve(__dirname, 'patches');
const MISSING_DIR = resolve(__dirname, 'missing');
const args = new Set(process.argv.slice(2));
const EMIT_MISSING = args.has('--emit-missing');
function readJSON(path) {
return JSON.parse(readFileSync(path, 'utf8'));
}
function writeJSON(path, obj) {
writeFileSync(path, JSON.stringify(obj, null, 4) + '\n');
}
function collectLeaves(obj, prefix = '') {
const leaves = [];
for (const [key, val] of Object.entries(obj)) {
const path = prefix ? `${prefix}.${key}` : key;
if (val && typeof val === 'object' && !Array.isArray(val)) {
leaves.push(...collectLeaves(val, path));
} else {
leaves.push(path);
}
}
return leaves;
}
function getByPath(obj, path) {
return path
.split('.')
.reduce((cur, k) => (cur == null ? cur : cur[k]), obj);
}
/**
* Build a new object whose shape mirrors `template` (key order + nesting),
* pulling values from `existing` first, then `patch`. Keys absent from both
* are pushed to the `missing` array and skipped.
*/
function mergeOrdered(template, existing, patch, missing, prefix = '') {
const out = {};
for (const [key, val] of Object.entries(template)) {
const path = prefix ? `${prefix}.${key}` : key;
if (val && typeof val === 'object' && !Array.isArray(val)) {
const merged = mergeOrdered(val, existing, patch, missing, path);
if (Object.keys(merged).length > 0) out[key] = merged;
} else {
const fromExisting = getByPath(existing, path);
if (typeof fromExisting === 'string') {
out[key] = fromExisting;
} else if (Object.prototype.hasOwnProperty.call(patch, path)) {
out[key] = patch[path];
} else {
missing.push(path);
}
}
}
return out;
}
function listLocaleFiles() {
return readdirSync(I18N_DIR)
.filter((f) => f.endsWith('.json') && f !== 'en.json')
.sort();
}
function emitMissing(en, enKeys) {
if (!existsSync(MISSING_DIR)) mkdirSync(MISSING_DIR, { recursive: true });
for (const file of listLocaleFiles()) {
const lang = file.replace(/\.json$/, '');
const localePath = resolve(I18N_DIR, file);
const localeData = readJSON(localePath);
const localeKeys = new Set(collectLeaves(localeData));
const missing = enKeys.filter((k) => !localeKeys.has(k));
const dump = {};
for (const k of missing) dump[k] = getByPath(en, k);
writeJSON(resolve(MISSING_DIR, `${lang}.json`), dump);
console.log(`emit ${file.padEnd(10)} missing=${missing.length}`);
}
}
function mergeAll(en) {
let failed = false;
for (const file of listLocaleFiles()) {
const lang = file.replace(/\.json$/, '');
const localePath = resolve(I18N_DIR, file);
const patchPath = resolve(PATCH_DIR, `${lang}.json`);
const localeData = readJSON(localePath);
const patch = existsSync(patchPath) ? readJSON(patchPath) : {};
const stillMissing = [];
const merged = mergeOrdered(en, localeData, patch, stillMissing);
writeJSON(localePath, merged);
if (stillMissing.length > 0) {
failed = true;
console.log(
`FAIL ${file.padEnd(10)} still missing ${stillMissing.length} keys: ${stillMissing
.slice(0, 3)
.join(', ')}${stillMissing.length > 3 ? '…' : ''}`
);
} else {
console.log(`ok ${file.padEnd(10)} fully merged`);
}
}
if (failed) process.exit(1);
}
const en = readJSON(resolve(I18N_DIR, 'en.json'));
const enKeys = collectLeaves(en);
console.log(`en.json: ${enKeys.length} leaf keys`);
if (EMIT_MISSING) emitMissing(en, enKeys);
else mergeAll(en);