[client] Warn on missing translations and check placeholders (#8090)

This commit is contained in:
Edward
2026-10-06 16:59:26 +02:00
committed by GitHub
parent 2623feeb5b
commit 51a9d32cfc
2 changed files with 99 additions and 36 deletions
+96 -34
View File
@@ -1,13 +1,20 @@
#!/usr/bin/env node
// Validates that every shipped translation bundle carries exactly the same set
// of keys as the English source of truth. English (en) defines the keys; every
// other locale declared in _index.json must match it 1:1:
// Validates every shipped translation bundle against the English source of
// truth. English (en) defines the keys; for every other locale declared in
// _index.json:
//
// - no missing keys — a missing key silently falls back to English at runtime
// (see i18n bundle fallback), so the gap never surfaces to users or CI
// without this check;
// - no orphaned keys — keys left behind after an English key is renamed or
// removed are dead weight and a sign the locale is drifting.
// - missing keys are reported as a warning, not a failure — a missing key
// falls back to English at runtime (see i18n bundle fallback), and Crowdin
// exports only translated strings (skip_untranslated_strings in
// crowdin.yml), so a locale is routinely behind English until translators
// catch up;
// - orphaned keys fail — keys left behind after an English key is renamed or
// removed are dead weight and a sign the locale is drifting;
// - empty messages fail — a present key with an empty, whitespace-only or
// missing message renders blank instead of falling back to English;
// - placeholder mismatches fail — a translation must use exactly the
// {placeholders} of its English string, otherwise a value silently never
// renders (or a literal "{name}" leaks into the UI).
//
// Pure Node, no dependencies, so it runs without installing the frontend
// toolchain.
@@ -23,17 +30,40 @@ const SOURCE = "en";
const localesDir = join(dirname(fileURLToPath(import.meta.url)), "locales");
const isCI = Boolean(process.env.GITHUB_ACTIONS);
// Matches the i18next interpolation configured in the frontend
// (prefix "{", suffix "}") and the Go bundle's applyPlaceholders.
const PLACEHOLDER = /\{([^{}\s]+)\}/g;
function readJSON(path) {
return JSON.parse(readFileSync(path, "utf8"));
}
function keysOf(langCode) {
return Object.keys(readJSON(join(localesDir, langCode, "common.json")));
function messagesOf(langCode) {
const entries = readJSON(join(localesDir, langCode, "common.json"));
const messages = new Map();
for (const [key, entry] of Object.entries(entries)) {
// null marks an unusable entry (missing or non-string message).
messages.set(key, typeof entry?.message === "string" ? entry.message : null);
}
return messages;
}
// Emit a GitHub Actions annotation so failures render inline on the PR diff.
function annotate(file, message) {
if (isCI) console.log(`::error file=${file}::${message}`);
function placeholdersOf(message) {
// Code-point order: placeholder names are identifiers, not prose.
return [...new Set([...message.matchAll(PLACEHOLDER)].map((m) => m[1]))].sort((a, b) => {
if (a < b) return -1;
if (a > b) return 1;
return 0;
});
}
function formatPlaceholders(names) {
return names.length ? names.map((n) => `{${n}}`).join(", ") : "none";
}
// Emit a GitHub Actions annotation so findings render inline on the PR diff.
function annotate(level, file, message) {
if (isCI) console.log(`::${level} file=${file}::${message}`);
}
const index = readJSON(join(localesDir, "_index.json"));
@@ -44,8 +74,13 @@ if (!declared.includes(SOURCE)) {
process.exit(1);
}
const sourceKeys = keysOf(SOURCE);
const sourceSet = new Set(sourceKeys);
const source = messagesOf(SOURCE);
const sourceKeys = [...source.keys()];
const emptySource = sourceKeys.filter((k) => !source.get(k)?.trim());
if (emptySource.length) {
console.error(`FATAL: ${SOURCE}/common.json has empty or missing messages: ${emptySource.join(", ")}`);
process.exit(1);
}
console.log(`Source of truth: ${SOURCE}/common.json — ${sourceKeys.length} keys\n`);
let failed = false;
@@ -54,40 +89,67 @@ for (const code of declared) {
if (code === SOURCE) continue;
const file = `client/ui/i18n/locales/${code}/common.json`;
let keys;
let messages;
try {
keys = keysOf(code);
messages = messagesOf(code);
} catch (e) {
failed = true;
const msg = `bundle is declared in _index.json but common.json is missing or invalid (${e.message})`;
console.error(`✗ ${code}: ${msg}`);
annotate("client/ui/i18n/locales/_index.json", `${code}: ${msg}`);
annotate("error", "client/ui/i18n/locales/_index.json", `${code}: ${msg}`);
continue;
}
const set = new Set(keys);
const missing = sourceKeys.filter((k) => !set.has(k));
const extra = keys.filter((k) => !sourceSet.has(k));
if (missing.length === 0 && extra.length === 0) {
console.log(`✓ ${code}: ${keys.length} keys`);
continue;
const missing = sourceKeys.filter((k) => !messages.has(k));
const extra = [...messages.keys()].filter((k) => !source.has(k));
const empty = [];
const badPlaceholders = [];
for (const [key, message] of messages) {
if (!source.has(key)) continue;
if (!message?.trim()) {
empty.push(key);
continue;
}
const want = placeholdersOf(source.get(key));
const got = placeholdersOf(message);
if (want.length !== got.length || want.some((name, i) => name !== got[i])) {
badPlaceholders.push(`${key} (expected ${formatPlaceholders(want)}, got ${formatPlaceholders(got)})`);
}
}
failed = true;
console.error(`✗ ${code}: ${keys.length} keys (expected ${sourceKeys.length})`);
const translated = sourceKeys.length - missing.length - empty.length;
const coverage = Math.floor((translated / sourceKeys.length) * 100);
const hasErrors = extra.length > 0 || empty.length > 0 || badPlaceholders.length > 0;
let mark = "✓";
if (hasErrors) mark = "✗";
else if (missing.length) mark = "⚠";
const log = hasErrors ? console.error : console.log;
log(`${mark} ${code}: ${translated}/${sourceKeys.length} keys translated (${coverage}%)`);
if (missing.length) {
console.error(` missing ${missing.length}: ${missing.join(", ")}`);
annotate(file, `Missing ${missing.length} key(s) present in ${SOURCE}: ${missing.join(", ")}`);
console.warn(` missing ${missing.length} (falls back to English): ${missing.join(", ")}`);
annotate("warning", file, `Missing ${missing.length} key(s) present in ${SOURCE}, shown in English: ${missing.join(", ")}`);
}
if (extra.length) {
failed = true;
console.error(` extra ${extra.length}: ${extra.join(", ")}`);
annotate(file, `Has ${extra.length} key(s) not present in ${SOURCE}: ${extra.join(", ")}`);
annotate("error", file, `Has ${extra.length} key(s) not present in ${SOURCE}: ${extra.join(", ")}`);
}
if (empty.length) {
failed = true;
console.error(` empty message ${empty.length} (renders blank): ${empty.join(", ")}`);
annotate("error", file, `Empty or missing message in ${empty.length} key(s), renders blank: ${empty.join(", ")}`);
}
if (badPlaceholders.length) {
failed = true;
console.error(` placeholder mismatch ${badPlaceholders.length}: ${badPlaceholders.join("; ")}`);
annotate("error", file, `Placeholders differ from ${SOURCE} in ${badPlaceholders.length} key(s): ${badPlaceholders.join("; ")}`);
}
}
// Locale directories present on disk but not declared in _index.json are never
// loaded by the app — surface them so dead translation files don't rot silently.
// Locale directories present on disk but not declared in _index.json are not
// offered in the language picker — surface them so dead translation files don't
// rot silently.
const onDisk = readdirSync(localesDir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name);
@@ -98,7 +160,7 @@ if (undeclared.length) {
console.log();
if (failed) {
console.error("Translation check FAILED — every locale must match the English key set.");
console.error("Translation check FAILED — fix orphaned keys, empty messages and placeholder mismatches above.");
process.exit(1);
}
console.log("Translation check passed — all locales match the English key set.");
console.log("Translation check passed — no orphaned keys, empty messages or placeholder mismatches.");