mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-07 05:59:06 +02:00
[client] Warn on missing translations and check placeholders (#8090)
This commit is contained in:
@@ -36,7 +36,8 @@ jobs:
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
# English (en) is the source of truth for translation keys; every other
|
||||
# locale declared in _index.json must carry the exact same key set.
|
||||
# English (en) is the source of truth for translation keys. Locales declared
|
||||
# in _index.json fail on orphaned keys or placeholder mismatches; missing
|
||||
# keys only warn, since they fall back to English at runtime.
|
||||
- name: Check translation key parity
|
||||
run: node client/ui/i18n/check-translations.mjs
|
||||
|
||||
@@ -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.");
|
||||
|
||||
Reference in New Issue
Block a user