#!/usr/bin/env node /** * Vergleicht alle frontend/src/i18n/locales/*.json mit de.json (wie deepMerge in frontend/src/i18n/index.js). * * Metriken pro Locale (Blatt-Strings, flatten): * - inheritedFromDe: Key fehlt in der Locale-Datei → Merge ergibt deutschen Text * - explicitStillDe: Key ist in der Locale gesetzt, Wert ist identisch zu de (Copy-Paste / vergessen) * - differsFromDe: abweichend von Deutsch (Zielsprache oder Englisch-Vorlage) * - enUsLeak: unter differsFromDe: Wert identisch zum en-US-Merge (typisch EN-Vorlage statt Zielsprache) * * Usage: * node scripts/check-i18n-completeness.js * node scripts/check-i18n-completeness.js --top 25 --locale fil */ const fs = require('fs'); const path = require('path'); const ROOT = path.resolve(__dirname, '..'); const SOURCE_DIR = path.join(ROOT, 'frontend', 'src', 'i18n', 'locales'); function deepMergeMessages(base, override) { if (!base || typeof base !== 'object' || Array.isArray(base)) { return override ?? base; } const result = { ...base }; for (const [key, value] of Object.entries(override || {})) { if ( value && typeof value === 'object' && !Array.isArray(value) && result[key] && typeof result[key] === 'object' && !Array.isArray(result[key]) ) { result[key] = deepMergeMessages(result[key], value); } else { result[key] = value; } } return result; } function flatten(obj, prefix = '', out = {}) { for (const [key, value] of Object.entries(obj || {})) { const nextKey = prefix ? `${prefix}.${key}` : key; if (value && typeof value === 'object' && !Array.isArray(value)) { flatten(value, nextKey, out); } else if (typeof value === 'string') { out[nextKey] = value; } } return out; } /** Blatt-Keys, die in `override` explizit als String gesetzt sind */ function overriddenLeafKeys(override, prefix = '', out = new Set()) { if (!override || typeof override !== 'object' || Array.isArray(override)) return out; for (const [key, value] of Object.entries(override)) { const p = prefix ? `${prefix}.${key}` : key; if (value && typeof value === 'object' && !Array.isArray(value)) { overriddenLeafKeys(value, p, out); } else if (typeof value === 'string') { out.add(p); } } return out; } function topPrefixes(keys, n = 12) { const counts = {}; for (const k of keys) { const root = k.split('.')[0]; counts[root] = (counts[root] || 0) + 1; } return Object.entries(counts) .sort((a, b) => b[1] - a[1]) .slice(0, n); } function parseArgs(argv) { let top = 15; let locale = null; for (let i = 2; i < argv.length; i++) { if (argv[i] === '--top' && argv[i + 1]) { top = parseInt(argv[++i], 10) || 15; } else if (argv[i] === '--locale' && argv[i + 1]) { locale = argv[++i]; } } return { top, locale }; } function analyzeLocale(de, deFlat, deKeys, enUSMergedFlat, code) { const loc = JSON.parse(fs.readFileSync(path.join(SOURCE_DIR, `${code}.json`), 'utf8')); const merged = flatten(deepMergeMessages(JSON.parse(JSON.stringify(de)), loc)); const explicit = overriddenLeafKeys(loc); const inheritedKeys = []; const explicitDeKeys = []; const enLeakKeys = []; for (const k of deKeys) { if (merged[k] === deFlat[k]) { if (!explicit.has(k)) inheritedKeys.push(k); else explicitDeKeys.push(k); } else if (merged[k] === enUSMergedFlat[k]) { enLeakKeys.push(k); } } return { code, totalDe: deKeys.length, explicitLeaves: explicit.size, inheritedFromDe: inheritedKeys.length, explicitStillDe: explicitDeKeys.length, differsFromDe: deKeys.length - inheritedKeys.length - explicitDeKeys.length, enUsLeak: enLeakKeys.length, inheritedKeys, explicitDeKeys, enLeakKeys, }; } function main() { const { top, locale: onlyLocale } = parseArgs(process.argv); const de = JSON.parse(fs.readFileSync(path.join(SOURCE_DIR, 'de.json'), 'utf8')); const deFlat = flatten(de); const deKeys = Object.keys(deFlat).sort(); const enUS = JSON.parse(fs.readFileSync(path.join(SOURCE_DIR, 'en-US.json'), 'utf8')); const enUSMergedFlat = flatten(deepMergeMessages(JSON.parse(JSON.stringify(de)), enUS)); const files = fs .readdirSync(SOURCE_DIR) .filter((f) => f.endsWith('.json') && !f.endsWith('.backup')) .sort(); const rows = []; for (const file of files) { if (file === 'de.json') continue; const code = path.basename(file, '.json'); if (onlyLocale && code !== onlyLocale) continue; rows.push(analyzeLocale(de, deFlat, deKeys, enUSMergedFlat, code)); } console.log(`Basis: de.json → ${deKeys.length} Blatt-Strings (flatten)\n`); console.log( [ 'Locale'.padEnd(14), 'explicit'.padStart(8), 'erbtsDE'.padStart(8), '=de expl'.padStart(10), '≠de'.padStart(6), '≈en-US'.padStart(8), ].join(' '), ); console.log('-'.repeat(58)); for (const r of rows.sort((a, b) => b.inheritedFromDe - a.inheritedFromDe)) { console.log( [ r.code.padEnd(14), String(r.explicitLeaves).padStart(8), String(r.inheritedFromDe).padStart(8), String(r.explicitStillDe).padStart(10), String(r.differsFromDe).padStart(6), String(r.enUsLeak).padStart(8), ].join(' '), ); } if (onlyLocale && rows.length === 1) { const r = rows[0]; console.log(`\n--- Top-Namespace (erbte deutsche Strings) für ${r.code} ---`); for (const [p, c] of topPrefixes(r.inheritedKeys, top)) { console.log(` ${p}: ${c}`); } if (r.enLeakKeys.length) { console.log(`\n--- Beispiel en-US-Leaks (erste ${Math.min(12, r.enLeakKeys.length)}) ---`); for (const k of r.enLeakKeys.slice(0, 12)) { console.log(` ${k}`); } } } console.log('\nLegende: erbtsDE = kein Blatt-Override in Locale-JSON (= deutsch); ≈en-US = Wert wie en-US-Merge'); } main();