From 12b25d5e0d2ed466ddf2fd6bac64ca65528f31fd Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 17 Jul 2026 04:23:30 -0300 Subject: [PATCH] fix(i18n): treat __MISSING__ sync placeholders as absent in EN fallback (#7258) (#7556) deepMergeFallback in src/i18n/request.ts only substituted the English fallback value when a key was entirely undefined. Keys backfilled by scripts/i18n/sync-ui-keys.mjs with the __MISSING__: sentinel exist on the target object, so they passed through untouched and were rendered raw to the user (395 zh-TW keys, 337 pt-BR keys, systemic across locales). Now any target leaf that still carries the __MISSING__: prefix is treated the same as an absent key, so the clean EN value wins. Does not touch the underlying translation content (395/337 strings) - that is a separate content workstream. --- .../fixes/7258-zhtw-missing-placeholder.md | 1 + src/i18n/request.ts | 19 ++- .../i18n-missing-placeholder-fallback.test.ts | 123 ++++++++++++++++++ 3 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 changelog.d/fixes/7258-zhtw-missing-placeholder.md create mode 100644 tests/unit/i18n-missing-placeholder-fallback.test.ts diff --git a/changelog.d/fixes/7258-zhtw-missing-placeholder.md b/changelog.d/fixes/7258-zhtw-missing-placeholder.md new file mode 100644 index 0000000000..f3b85c0e00 --- /dev/null +++ b/changelog.d/fixes/7258-zhtw-missing-placeholder.md @@ -0,0 +1 @@ +- fix(i18n): treat `__MISSING__:` sync-script placeholders as absent so the EN fallback renders instead of the raw sentinel (#7258) diff --git a/src/i18n/request.ts b/src/i18n/request.ts index a144acedbf..19f053ed47 100644 --- a/src/i18n/request.ts +++ b/src/i18n/request.ts @@ -5,10 +5,25 @@ import type { Locale } from "./config"; const FALLBACK_LOCALE = "en"; +/** + * Sentinel prefix written by `scripts/i18n/sync-ui-keys.mjs` when backfilling a + * locale file with an untranslated key: `__MISSING__:`. Kept in + * sync manually with the scripts (plain .mjs, no shared TS module) — see + * `scripts/i18n/sync-ui-keys.mjs` and `scripts/i18n/check-ui-keys-coverage.mjs`. + */ +export const PLACEHOLDER_PREFIX = "__MISSING__:"; + +function isUntranslatedPlaceholder(value: unknown): boolean { + return typeof value === "string" && value.startsWith(PLACEHOLDER_PREFIX); +} + /** * Deep merge that mutates `target` with values from `source`. * If both have an object at the same key, recurse. - * Otherwise prefer the existing value in `target` (locale-specific wins). + * Otherwise prefer the existing value in `target` (locale-specific wins) — + * unless the target value is an untranslated `__MISSING__:` sentinel written + * by the i18n sync script, in which case it is treated as absent so the + * clean English fallback value wins instead (#7258). */ export function deepMergeFallback( target: Record, @@ -27,7 +42,7 @@ export function deepMergeFallback( !Array.isArray(targetValue) ) { deepMergeFallback(targetValue as Record, sourceValue as Record); - } else if (targetValue === undefined) { + } else if (targetValue === undefined || isUntranslatedPlaceholder(targetValue)) { target[key] = sourceValue; } } diff --git a/tests/unit/i18n-missing-placeholder-fallback.test.ts b/tests/unit/i18n-missing-placeholder-fallback.test.ts new file mode 100644 index 0000000000..d40bbc1fda --- /dev/null +++ b/tests/unit/i18n-missing-placeholder-fallback.test.ts @@ -0,0 +1,123 @@ +/** + * Regression test for #7258 — zh-TW (and other locales) rendering the raw + * `__MISSING__:` sentinel written by `scripts/i18n/sync-ui-keys.mjs` + * instead of falling back to the clean English value. + * + * `deepMergeFallback` (src/i18n/request.ts) previously only substituted the + * EN value when a key was entirely `undefined`; a key that existed but still + * carried the untranslated placeholder passed through untouched and was + * rendered verbatim to the user. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { deepMergeFallback, PLACEHOLDER_PREFIX } from "../../src/i18n/request.ts"; + +const messagesDir = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "src", + "i18n", + "messages" +); + +function loadLocale(locale: string): Record { + const raw = readFileSync(path.join(messagesDir, `${locale}.json`), "utf8"); + return JSON.parse(raw) as Record; +} + +function collectPlaceholderLeaves( + node: unknown, + pathPrefix: string, + out: string[] +): void { + if (node === null || typeof node !== "object") { + if (typeof node === "string" && node.startsWith(PLACEHOLDER_PREFIX)) { + out.push(pathPrefix); + } + return; + } + if (Array.isArray(node)) return; + for (const [key, value] of Object.entries(node as Record)) { + collectPlaceholderLeaves(value, pathPrefix ? `${pathPrefix}.${key}` : key, out); + } +} + +// --------------------------------------------------------------------------- +// 1. Focused repro: the exact keys from the issue report +// --------------------------------------------------------------------------- + +test("#7258 repro: zh-TW keys carry a raw __MISSING__: placeholder before the fix is exercised", () => { + const zhTW = loadLocale("zh-TW"); + const leaves: string[] = []; + collectPlaceholderLeaves(zhTW, "", leaves); + assert.ok( + leaves.length > 0, + "expected zh-TW.json to still contain __MISSING__: placeholders (translation content backlog)" + ); +}); + +test("#7258: deepMergeFallback replaces an untranslated __MISSING__ placeholder with the EN fallback value", () => { + const target: Record = { + localUsageCommand: `${PLACEHOLDER_PREFIX}Run this command locally`, + }; + const source: Record = { + localUsageCommand: "Run this command locally", + }; + const result = deepMergeFallback(target, source); + assert.equal(result.localUsageCommand, "Run this command locally"); + assert.ok(!(result.localUsageCommand as string).startsWith(PLACEHOLDER_PREFIX)); +}); + +test("#7258: deepMergeFallback still lets a real (non-placeholder) locale value win", () => { + const target: Record = { greeting: "Hola" }; + const source: Record = { greeting: "Hello" }; + const result = deepMergeFallback(target, source); + assert.equal(result.greeting, "Hola"); +}); + +test("#7258: deepMergeFallback replaces nested placeholder leaves too", () => { + const target: Record = { + ns: { a: `${PLACEHOLDER_PREFIX}English A`, b: "translated B" }, + }; + const source: Record = { + ns: { a: "English A", b: "English B" }, + }; + const result = deepMergeFallback(target, source); + const ns = result.ns as Record; + assert.equal(ns.a, "English A"); + assert.equal(ns.b, "translated B", "already-translated sibling key is untouched"); +}); + +// --------------------------------------------------------------------------- +// 2. General regression: for every shipped locale, the REAL production merge +// (locale ⟵ EN fallback) leaves zero raw __MISSING__: leaves. +// --------------------------------------------------------------------------- + +test("#7258: after the real EN-fallback merge, no locale has a raw __MISSING__: leaf", () => { + const en = loadLocale("en"); + const locales = readdirSync(messagesDir) + .filter((f) => f.endsWith(".json")) + .map((f) => f.replace(/\.json$/, "")) + .filter((locale) => locale !== "en"); + + assert.ok(locales.length > 0, "expected at least one non-EN locale file"); + + const offenders: Record = {}; + for (const locale of locales) { + const localeMessages = loadLocale(locale); + const merged = deepMergeFallback({ ...localeMessages }, en); + const leaves: string[] = []; + collectPlaceholderLeaves(merged, "", leaves); + if (leaves.length > 0) offenders[locale] = leaves; + } + + assert.deepEqual( + offenders, + {}, + `expected zero __MISSING__: leaves after EN fallback merge, found: ${JSON.stringify(offenders)}` + ); +});