rmyndharis--openwa
4ce4204b6c
CI / Lint (push) Failing after 2s
CI / Build (push) Has been skipped
SDK CI / PHP SDK (push) Failing after 1s
Split PHP SDK / PHP SDK tests (push) Failing after 1s
CI / Test (push) Failing after 1s
CI / Dashboard (push) Failing after 0s
SDK CI / JavaScript SDK (push) Failing after 0s
SDK CI / Python SDK (push) Failing after 2s
SDK CI / Java SDK (push) Failing after 1s
Split PHP SDK / Mirror sdk/php -> rmyndharis/openwa-php (push) Has been skipped
CI / Test (PostgreSQL migrations) (push) Failing after 7m47s
CI / Docker Build (push) Has been skipped
126 行
4.7 KiB
JavaScript
126 行
4.7 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* i18n locale parity check.
|
|
*
|
|
* Loads the reference locale (en.json) and every other locale in src/i18n/locales, then asserts:
|
|
* 1. KEY PARITY (hard fail): every nested key path in en.json exists in each locale.
|
|
* 2. PLACEHOLDER PARITY (hard fail): a translated string carries the SAME `{{token}}` interpolation
|
|
* placeholders as the reference — a localized/renamed token (e.g. `{{nombre}}` instead of
|
|
* `{{name}}`) silently breaks interpolation, which a key-presence check can't see.
|
|
* 3. UNTRANSLATED PROSE (warning): a long leaf value byte-identical to en.json is very likely still
|
|
* English — surfaced as a non-fatal drift signal (short coincidental matches are ignored).
|
|
*
|
|
* Wire into CI with: `npm run i18n:check`
|
|
*/
|
|
import { readdirSync, readFileSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join } from 'node:path';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const LOCALES_DIR = join(__dirname, '..', 'src', 'i18n', 'locales');
|
|
const REFERENCE = 'en.json';
|
|
// A leaf value identical to the reference is only flagged when at least this long — short UI words
|
|
// (e.g. "Media", "OK") legitimately coincide across languages, full sentences almost never do.
|
|
const UNTRANSLATED_MIN_LEN = 20;
|
|
|
|
/** Flatten a nested object into a Set of dot-separated key paths (leaf keys only). */
|
|
function flatten(obj, prefix = '', out = new Set()) {
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
const path = prefix ? `${prefix}.${key}` : key;
|
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
flatten(value, path, out);
|
|
} else {
|
|
out.add(path);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Flatten to a Map of leaf path -> leaf value (string leaves matter for the value checks). */
|
|
function flattenEntries(obj, prefix = '', out = new Map()) {
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
const path = prefix ? `${prefix}.${key}` : key;
|
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
flattenEntries(value, path, out);
|
|
} else {
|
|
out.set(path, value);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** The set of `{{token}}` interpolation placeholders in a string. */
|
|
function placeholders(str) {
|
|
const set = new Set();
|
|
for (const m of str.matchAll(/\{\{\s*([^}]+?)\s*\}\}/g)) set.add(m[1]);
|
|
return set;
|
|
}
|
|
|
|
function setsEqual(a, b) {
|
|
return a.size === b.size && [...a].every((x) => b.has(x));
|
|
}
|
|
|
|
function load(file) {
|
|
return JSON.parse(readFileSync(join(LOCALES_DIR, file), 'utf8'));
|
|
}
|
|
|
|
const referenceKeys = flatten(load(REFERENCE));
|
|
const referenceEntries = flattenEntries(load(REFERENCE));
|
|
const localeFiles = readdirSync(LOCALES_DIR)
|
|
.filter((f) => f.endsWith('.json') && f !== REFERENCE)
|
|
.sort();
|
|
|
|
let hasErrors = false;
|
|
|
|
for (const file of localeFiles) {
|
|
const keys = flatten(load(file));
|
|
const entries = flattenEntries(load(file));
|
|
const missing = [...referenceKeys].filter((k) => !keys.has(k)).sort();
|
|
const extra = [...keys].filter((k) => !referenceKeys.has(k)).sort();
|
|
|
|
const placeholderMismatches = [];
|
|
const untranslated = [];
|
|
for (const [path, refVal] of referenceEntries) {
|
|
if (typeof refVal !== 'string') continue;
|
|
const val = entries.get(path);
|
|
if (typeof val !== 'string') continue;
|
|
if (!setsEqual(placeholders(refVal), placeholders(val))) placeholderMismatches.push(path);
|
|
if (refVal === val && refVal.length >= UNTRANSLATED_MIN_LEN) untranslated.push(path);
|
|
}
|
|
|
|
if (missing.length > 0) {
|
|
hasErrors = true;
|
|
console.error(`\n[FAIL] ${file}: missing ${missing.length} key(s) present in ${REFERENCE}:`);
|
|
for (const k of missing) console.error(` - ${k}`);
|
|
} else {
|
|
console.log(`[OK] ${file}: all ${referenceKeys.size} keys present`);
|
|
}
|
|
|
|
if (placeholderMismatches.length > 0) {
|
|
hasErrors = true;
|
|
console.error(`[FAIL] ${file}: ${placeholderMismatches.length} key(s) with mismatched {{placeholders}}:`);
|
|
for (const k of placeholderMismatches) {
|
|
console.error(` ! ${k}: expected ${[...placeholders(referenceEntries.get(k))].join(', ') || '(none)'}`);
|
|
}
|
|
}
|
|
|
|
if (extra.length > 0) {
|
|
// Extra keys are a warning, not a hard failure: they do not break i18n parity
|
|
// against the reference, but they signal drift worth cleaning up.
|
|
console.warn(`[WARN] ${file}: ${extra.length} extra key(s) not in ${REFERENCE}:`);
|
|
for (const k of extra) console.warn(` ~ ${k}`);
|
|
}
|
|
|
|
if (untranslated.length > 0) {
|
|
console.warn(`[WARN] ${file}: ${untranslated.length} long value(s) identical to ${REFERENCE} (likely untranslated):`);
|
|
for (const k of untranslated) console.warn(` ? ${k}`);
|
|
}
|
|
}
|
|
|
|
if (hasErrors) {
|
|
console.error('\ni18n parity check FAILED.');
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log('\ni18n parity check passed.');
|