diff --git a/.github/workflows/ui-translations.yml b/.github/workflows/ui-translations.yml index 24b7c9de2..9ac524495 100644 --- a/.github/workflows/ui-translations.yml +++ b/.github/workflows/ui-translations.yml @@ -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 diff --git a/client/ui/i18n/check-translations.mjs b/client/ui/i18n/check-translations.mjs index bd076e0e0..a6b666337 100644 --- a/client/ui/i18n/check-translations.mjs +++ b/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.");