mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:21:10 +02:00
feat(scripts): validate:i18n-parity — lock locale key-sets in CI
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>
This commit is contained in:
parent
9c503b7982
commit
201a085872
2 changed files with 135 additions and 1 deletions
|
|
@ -24,7 +24,8 @@
|
||||||
"validate:theme-variables": "node scripts/validate-theme-variables.mjs",
|
"validate:theme-variables": "node scripts/validate-theme-variables.mjs",
|
||||||
"validate:theme-utilities": "node scripts/validate-theme-utilities.mjs",
|
"validate:theme-utilities": "node scripts/validate-theme-utilities.mjs",
|
||||||
"validate:theme-parity": "node scripts/validate-theme-parity.mjs",
|
"validate:theme-parity": "node scripts/validate-theme-parity.mjs",
|
||||||
"validate:all": "pnpm run validate:turbo && pnpm run validate:pg-schema && pnpm run validate:theme-variables && pnpm run validate:theme-utilities && pnpm run validate:theme-parity && pnpm run check:crypto && pnpm run audit:encrypted-tools",
|
"validate:i18n-parity": "node scripts/validate-i18n-parity.mjs",
|
||||||
|
"validate:all": "pnpm run validate:turbo && pnpm run validate:pg-schema && pnpm run validate:theme-variables && pnpm run validate:theme-utilities && pnpm run validate:theme-parity && pnpm run validate:i18n-parity && pnpm run check:crypto && pnpm run audit:encrypted-tools",
|
||||||
"check:crypto": "node scripts/audit-crypto-registry.mjs",
|
"check:crypto": "node scripts/audit-crypto-registry.mjs",
|
||||||
"check:crypto:seed": "node scripts/audit-crypto-registry.mjs --seed",
|
"check:crypto:seed": "node scripts/audit-crypto-registry.mjs --seed",
|
||||||
"audit:encrypted-tools": "bun run scripts/audit-encrypted-tools.ts",
|
"audit:encrypted-tools": "bun run scripts/audit-encrypted-tools.ts",
|
||||||
|
|
|
||||||
133
scripts/validate-i18n-parity.mjs
Normal file
133
scripts/validate-i18n-parity.mjs
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
#!/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();
|
||||||
Loading…
Add table
Add a link
Reference in a new issue