mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
Neuer Validator im Stil von validate:theme-parity. Scannt apps/mana/apps/web/src/lib/i18n/locales/<namespace>/<locale>.json und failt hart, sobald ein Locale-File vom kanonischen DE-Key-Set abweicht (fehlende oder überzählige Keys). - DE ist canonical weil fallbackLocale='de' in i18n/index.ts. Missing keys führten zu mixed-language UI, extra keys sind tote Altlasten. - In validate:all verdrahtet — CI failt ab sofort bei neuem Drift. - Smoke-Test: 35 namespaces × 5 locales, 2724 canonical keys clean. - Failure-Test bestätigt: künstlicher extra-key in apps/it.json führt zu exit 1 mit klarer Fehlermeldung. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
133 lines
4.5 KiB
JavaScript
133 lines
4.5 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Validate that every i18n namespace under apps/mana/apps/web has the
|
||
* same key-set across all supported locales. A missing key would fall
|
||
* back to the default locale (DE) at runtime — which silently produces
|
||
* mixed-language UI for non-German users instead of a loud failure.
|
||
* An extra key (present in one locale but not DE) is an obsolete
|
||
* legacy string that nothing references any more.
|
||
*
|
||
* Canonical locale: DE (the `fallbackLocale` in src/lib/i18n/index.ts).
|
||
* Every other locale must mirror DE's key-set exactly.
|
||
*
|
||
* Scope: apps/mana/apps/web/src/lib/i18n/locales/<namespace>/<locale>.json
|
||
*
|
||
* Usage:
|
||
* node scripts/validate-i18n-parity.mjs
|
||
*/
|
||
|
||
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { dirname, join } from 'node:path';
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
const REPO_ROOT = join(__dirname, '..');
|
||
const LOCALES_DIR = join(REPO_ROOT, 'apps/mana/apps/web/src/lib/i18n/locales');
|
||
const CANONICAL_LOCALE = 'de';
|
||
const SUPPORTED_LOCALES = ['de', 'en', 'it', 'fr', 'es'];
|
||
|
||
function flattenKeys(obj, prefix = '') {
|
||
const keys = [];
|
||
for (const [k, v] of Object.entries(obj)) {
|
||
const path = prefix ? `${prefix}.${k}` : k;
|
||
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
||
keys.push(...flattenKeys(v, path));
|
||
} else {
|
||
keys.push(path);
|
||
}
|
||
}
|
||
return keys;
|
||
}
|
||
|
||
function validate() {
|
||
if (!existsSync(LOCALES_DIR)) {
|
||
console.log('✓ i18n parity: locales directory not found — skipped.');
|
||
return;
|
||
}
|
||
|
||
const namespaces = readdirSync(LOCALES_DIR).filter((f) =>
|
||
statSync(join(LOCALES_DIR, f)).isDirectory()
|
||
);
|
||
|
||
const violations = [];
|
||
let totalKeys = 0;
|
||
|
||
for (const ns of namespaces) {
|
||
const canonicalPath = join(LOCALES_DIR, ns, `${CANONICAL_LOCALE}.json`);
|
||
if (!existsSync(canonicalPath)) {
|
||
violations.push({ ns, locale: CANONICAL_LOCALE, kind: 'ns-missing-canonical' });
|
||
continue;
|
||
}
|
||
|
||
let canonical;
|
||
try {
|
||
canonical = new Set(flattenKeys(JSON.parse(readFileSync(canonicalPath, 'utf8'))));
|
||
} catch (err) {
|
||
violations.push({ ns, locale: CANONICAL_LOCALE, kind: 'parse-error', err: err.message });
|
||
continue;
|
||
}
|
||
totalKeys += canonical.size;
|
||
|
||
for (const locale of SUPPORTED_LOCALES) {
|
||
if (locale === CANONICAL_LOCALE) continue;
|
||
const path = join(LOCALES_DIR, ns, `${locale}.json`);
|
||
if (!existsSync(path)) {
|
||
violations.push({ ns, locale, kind: 'file-missing' });
|
||
continue;
|
||
}
|
||
|
||
let keys;
|
||
try {
|
||
keys = new Set(flattenKeys(JSON.parse(readFileSync(path, 'utf8'))));
|
||
} catch (err) {
|
||
violations.push({ ns, locale, kind: 'parse-error', err: err.message });
|
||
continue;
|
||
}
|
||
|
||
const missing = [...canonical].filter((k) => !keys.has(k));
|
||
const extra = [...keys].filter((k) => !canonical.has(k));
|
||
|
||
if (missing.length > 0) violations.push({ ns, locale, kind: 'missing', keys: missing });
|
||
if (extra.length > 0) violations.push({ ns, locale, kind: 'extra', keys: extra });
|
||
}
|
||
}
|
||
|
||
if (violations.length > 0) {
|
||
console.error(`\n✗ i18n parity check FAILED (${violations.length} issue(s)):\n`);
|
||
for (const v of violations) {
|
||
if (v.kind === 'missing') {
|
||
console.error(
|
||
` ${v.ns}/${v.locale}.json — ${v.keys.length} key(s) missing vs ${CANONICAL_LOCALE}:`
|
||
);
|
||
for (const k of v.keys.slice(0, 8)) console.error(` - ${k}`);
|
||
if (v.keys.length > 8) console.error(` … +${v.keys.length - 8} more`);
|
||
} else if (v.kind === 'extra') {
|
||
console.error(
|
||
` ${v.ns}/${v.locale}.json — ${v.keys.length} key(s) not in ${CANONICAL_LOCALE} (legacy?):`
|
||
);
|
||
for (const k of v.keys.slice(0, 8)) console.error(` - ${k}`);
|
||
if (v.keys.length > 8) console.error(` … +${v.keys.length - 8} more`);
|
||
} else if (v.kind === 'file-missing') {
|
||
console.error(` ${v.ns}/${v.locale}.json — file does not exist`);
|
||
} else if (v.kind === 'ns-missing-canonical') {
|
||
console.error(
|
||
` ${v.ns}/ — canonical ${CANONICAL_LOCALE}.json missing; cannot validate namespace`
|
||
);
|
||
} else if (v.kind === 'parse-error') {
|
||
console.error(` ${v.ns}/${v.locale}.json — JSON parse error: ${v.err}`);
|
||
}
|
||
}
|
||
console.error(
|
||
`\nEvery locale file must mirror ${CANONICAL_LOCALE}.json's key-set exactly.\n` +
|
||
`Missing keys fall back to ${CANONICAL_LOCALE} at runtime (mixed-language UI);\n` +
|
||
`extra keys are dead code. Add the translation or delete the key.\n`
|
||
);
|
||
process.exit(1);
|
||
}
|
||
|
||
console.log(
|
||
`✓ i18n parity: ${namespaces.length} namespaces × ${SUPPORTED_LOCALES.length} locales — ${totalKeys} canonical keys, all aligned.`
|
||
);
|
||
}
|
||
|
||
validate();
|