项目文件夹

文件
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:24:08 +08:00

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.');