Collapsing i18n keys check in CI/CD + fix existing

This commit is contained in:
Kalle
2026-07-11 13:57:28 +03:00
parent 0cdd722959
commit e63674e71f
7 changed files with 85 additions and 25 deletions

View File

@@ -12,6 +12,13 @@ const dontWrite = process.argv.includes(NO_WRITE_KEY);
const KNOWN_SUFFIXES = ["_zero", "_one", "_two", "_few", "_many", "_other"];
// `_zero` is an i18next `count === 0` override, not a CLDR plural category. It
// may legitimately sit alongside the bare singular key that single-plural
// languages (zh, ja, ko) use after `i18n:sync` collapses their plural forms, so
// unlike the real plural suffixes it never counts as a with/without-suffix clash.
const ZERO_SUFFIX = "_zero";
const PLURAL_SUFFIXES = KNOWN_SUFFIXES.filter((sfx) => sfx !== ZERO_SUFFIX);
const REPO_TRANSLATIONS_INFO_URL =
"https://github.com/sendou-ink/sendou.ink/blob/main/docs/translation.md";
@@ -187,40 +194,47 @@ function getKeysWithoutSuffix(
lang: string,
file: string,
): string[] {
const foundSuffixKeys = new Set<string>();
const keys = [];
const pluralBaseKeys = new Set<string>();
const bareKeys = new Set<string>();
const keys: string[] = [];
const pushOnce = (key: string) => {
if (!keys.includes(key)) keys.push(key);
};
for (const [key, value] of Object.entries(translations)) {
if (value === "") {
continue; // Consider key missing if untranslated
}
const suffix = KNOWN_SUFFIXES.find((sfx) => key.endsWith(sfx));
if (key.endsWith(ZERO_SUFFIX)) {
// `_zero` override always maps to its base key and never clashes with it
pushOnce(key.slice(0, -ZERO_SUFFIX.length));
continue;
}
const suffix = PLURAL_SUFFIXES.find((sfx) => key.endsWith(sfx));
if (!suffix) {
if (foundSuffixKeys.has(key)) {
if (pluralBaseKeys.has(key)) {
throw new Error(
`Found same key with and without suffixes in ${lang}/${file}: ${key}`,
);
}
keys.push(key);
bareKeys.add(key);
pushOnce(key);
continue;
}
const baseKey = key.replace(suffix, "");
const baseKey = key.slice(0, -suffix.length);
if (foundSuffixKeys.has(baseKey)) {
// Already found this key with a suffix. Duplicates are handled elsewhere.
continue;
}
if (keys.includes(baseKey)) {
if (bareKeys.has(baseKey)) {
throw new Error(
`Found same key with and without suffixes in ${lang}/${file}: ${baseKey}`,
);
}
keys.push(baseKey);
foundSuffixKeys.add(baseKey);
pluralBaseKeys.add(baseKey);
pushOnce(baseKey);
}
return keys;

View File

@@ -9,12 +9,24 @@ const __dirname = path.dirname(__filename);
const LOCALES_PATH = path.join(__dirname, "..", "locales");
const PRIMARY_LANGUAGE = "en";
// When passed, no files are written. Instead the script exits non-zero if any
// file would be collapsed, so CI can fail a PR that committed suffixed plural
// keys in a single-plural language without running `pnpm run i18n:sync`.
const CHECK_KEY = "--check";
const checkOnly = process.argv.includes(CHECK_KEY);
// `_zero` is intentionally excluded: it is not a CLDR plural category but an
// i18next special-case override for `count === 0`. `i18next-locales-sync` treats
// e.g. `foo_zero` as a plain key (English has no `_zero` plural form), so it is
// preserved across every language and must not be collapsed into `foo`.
const COLLAPSIBLE_PLURAL_SUFFIXES = ["_one", "_two", "_few", "_many", "_other"];
// The plural form whose value we keep when collapsing. `_other` is preferred
// because it is the form i18next resolves at runtime for these languages and it
// typically carries the `{{count}}` interpolation. The remaining forms are only
// used as a fallback when `_other` has no translated value.
const PREFERRED_SUFFIX = "_other";
// `i18next-locales-sync` stores plural keys for languages whose CLDR cardinal
// rule has a single category ("other" only, e.g. zh, ja, ko) under the bare
// singular key instead of suffixed `_one`/`_other` keys. When a translator adds
@@ -26,6 +38,8 @@ const languages = fs
.readdirSync(LOCALES_PATH)
.filter((lang) => lang !== PRIMARY_LANGUAGE && !lang.startsWith("."));
const wouldCollapse: Array<{ lang: string; file: string; keys: string[] }> = [];
for (const lang of languages) {
if (!isSinglePluralLanguage(lang)) continue;
@@ -41,14 +55,32 @@ for (const lang of languages) {
string
>;
const { collapsed, changed } = collapsePluralKeys(content);
if (!changed) continue;
const { collapsed, collapsedBaseKeys } = collapsePluralKeys(content);
if (collapsedBaseKeys.length === 0) continue;
if (checkOnly) {
wouldCollapse.push({ lang, file, keys: collapsedBaseKeys });
continue;
}
fs.writeFileSync(filePath, `${JSON.stringify(collapsed, null, "\t")}\n`);
console.info(`collapsed plural keys in ${lang}/${file}`);
}
}
if (checkOnly) {
if (wouldCollapse.length > 0) {
console.error(
"Found suffixed plural keys in single-plural languages that `pnpm run i18n:sync` would collapse. Run it and commit the result.",
);
for (const { lang, file, keys } of wouldCollapse) {
console.error(` ${lang}/${file}: ${keys.join(", ")}`);
}
process.exit(1);
}
console.info("no plural keys to collapse in single-plural languages");
}
function isSinglePluralLanguage(lang: string) {
return (
new Intl.PluralRules(lang).resolvedOptions().pluralCategories.length === 1
@@ -57,7 +89,8 @@ function isSinglePluralLanguage(lang: string) {
function collapsePluralKeys(content: Record<string, string>) {
const collapsed: Record<string, string> = {};
let changed = false;
const chosenFromPreferred = new Set<string>();
const collapsedBaseKeys = new Set<string>();
for (const [key, value] of Object.entries(content)) {
const suffix = COLLAPSIBLE_PLURAL_SUFFIXES.find((sfx) => key.endsWith(sfx));
@@ -66,14 +99,24 @@ function collapsePluralKeys(content: Record<string, string>) {
continue;
}
changed = true;
const baseKey = key.slice(0, -suffix.length);
collapsedBaseKeys.add(baseKey);
const isPreferred = suffix === PREFERRED_SUFFIX;
// keep the first non-empty value found across the plural forms
if (!(baseKey in collapsed) || (!collapsed[baseKey] && value)) {
// prefer the `_other` value (it carries {{count}}); otherwise keep the
// first non-empty value found across the remaining plural forms
const shouldReplace =
!(baseKey in collapsed) ||
(!collapsed[baseKey] && !!value) ||
(isPreferred && !!value && !chosenFromPreferred.has(baseKey));
if (shouldReplace) {
collapsed[baseKey] = value;
if (isPreferred && value) {
chosenFromPreferred.add(baseKey);
}
}
}
return { collapsed, changed };
return { collapsed, collapsedBaseKeys: Array.from(collapsedBaseKeys) };
}