fix(i18n): restore three placeholders dropped from the pt catalogue (#11325)

Validated on a 17-PR combined board: i18n-placeholder-parity within the board's 287/287, typecheck:core clean. Restores 3 dropped placeholders in pt.json (the visible one: the cache tile's subtitle was repeating its own label instead of showing the total) and adds a 42-locale placeholder-set gate so this class of drift can't recur silently. Thank you @ntdat812!
This commit is contained in:
Nguyen Thanh Dat
2026-08-24 11:49:48 +07:00
committed by GitHub
parent 24ac71465e
commit 04b2c47940
3 changed files with 98 additions and 3 deletions

View File

@@ -0,0 +1 @@
- **fix(i18n):** three `pt` strings had dropped their placeholders — the cache tile's subtitle repeated its own label instead of showing `{total}` — and a unit test now enforces placeholder parity with `en` across all locales ([#11325](https://github.com/diegosouzapw/OmniRoute/pull/11325))

View File

@@ -4242,7 +4242,7 @@
"smokeSendSuccessWithTask": "message/send ok (tarefa {taskId}).",
"smokeSendSuccess": "message/send ok.",
"smokeStreamFailed": "Teste de fumo message/stream falhou.",
"smokeStreamSuccessWithTask": "message/stream ok (tarefa {taskId}).",
"smokeStreamSuccessWithTask": "message/stream ok (tarefa {taskId}{stateSuffix}).",
"smokeStreamNoTaskId": "message/stream terminou sem ID de tarefa.",
"health": "Estado de saúde",
"ok": "OK",
@@ -10202,7 +10202,7 @@
"scanning": "A analisar...",
"opencodeIntegration": "Integração OpenCode",
"opencodeDetected": "opencode {version} detetado",
"opencodeDesc": "Gera um {configFile} pronto a usar com a tua configuração OmniRoute",
"opencodeDesc": "Gera um {configFile} pronto a usar com o URL base do OmniRoute e todos os modelos disponíveis — coloca-o na raiz do teu projeto e executa {command}.",
"downloadConfig": "Descarregar {file}",
"downloaded": "Descarregado!",
"setupGuideTitle": "Guia de configuração",
@@ -10395,7 +10395,7 @@
"dbEntries": "Entradas na BD",
"dbEntriesSub": "Persistido (SQLite)",
"cacheHits": "Acertos de cache",
"cacheHitsSub": "Acertos",
"cacheHitsSub": "de {total} no total",
"tokensSaved": "Tokens Poupançados",
"tokensSavedSub": "Estimado a partir de acertos",
"hitRate": "Taxa de acertos",

View File

@@ -0,0 +1,94 @@
// A translation that drops a placeholder silently loses the value it carried:
// the string still renders, just without the number, path or command the
// English copy promised. Nothing checked for that, and three strings had
// drifted (all in `pt`):
//
// a2aDashboard.smokeStreamSuccessWithTask lost {stateSuffix}
// agents.opencodeDesc lost {command}
// cache.cacheHitsSub lost {total} ("of {total} total" -> "Acertos")
//
// Placeholder sets are compared, not counts or order: a locale may reorder or
// repeat them, but it may not introduce one English never defined (it would
// render literally) or drop one (its value disappears).
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";
const messagesDir = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"..",
"src",
"i18n",
"messages"
);
type Json = { [key: string]: string | Json };
function loadLocale(file: string): Json {
return JSON.parse(readFileSync(path.join(messagesDir, file), "utf8")) as Json;
}
function flatten(value: Json, prefix = ""): Map<string, string> {
const out = new Map<string, string>();
for (const [key, child] of Object.entries(value)) {
const dotted = prefix ? `${prefix}.${key}` : key;
if (typeof child === "string") out.set(dotted, child);
else if (child && typeof child === "object") {
for (const [k, v] of flatten(child, dotted)) out.set(k, v);
}
}
return out;
}
/**
* Names an ICU message interpolates: `{name}` and the argument of a typed
* placeholder such as `{count, plural, ...}`. Nested sub-messages are covered
* because the scan is a plain sweep of the whole string.
*/
function placeholders(message: string): Set<string> {
return new Set(
[...message.matchAll(/\{\s*([a-zA-Z0-9_]+)\s*[,}]/g)].map((match) => match[1])
);
}
const english = flatten(loadLocale("en.json"));
const locales = readdirSync(messagesDir)
.filter((file) => file.endsWith(".json") && file !== "en.json")
.sort();
test("every locale keeps the placeholders its English source defines", () => {
const drift: string[] = [];
for (const file of locales) {
for (const [key, translated] of flatten(loadLocale(file))) {
const source = english.get(key);
if (typeof source !== "string") continue;
const expected = placeholders(source);
const actual = placeholders(translated);
const missing = [...expected].filter((name) => !actual.has(name));
const unknown = [...actual].filter((name) => !expected.has(name));
if (missing.length === 0 && unknown.length === 0) continue;
drift.push(
`${file} ${key}\n` +
` en: ${source}\n` +
` ${file.replace(".json", "")}: ${translated}\n` +
` missing=[${missing.join(", ")}] unknown=[${unknown.join(", ")}]`
);
}
}
assert.deepEqual(drift, [], `\n placeholder drift:\n ${drift.join("\n ")}\n`);
});
test("the checker itself recognises the drift it is meant to catch", () => {
// Without this the test above could pass by never matching anything.
assert.deepEqual([...placeholders("of {total} total")], ["total"]);
assert.deepEqual([...placeholders("ok (task {taskId}{stateSuffix}).")], ["taskId", "stateSuffix"]);
assert.deepEqual([...placeholders("{count, plural, one {# item} other {# items}}")], ["count"]);
assert.deepEqual([...placeholders("Acertos")], []);
});