From e63674e71f2a593a8f99b2d13b2c66cf5ee1d16f Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:57:28 +0300 Subject: [PATCH] Collapsing i18n keys check in CI/CD + fix existing --- .github/workflows/main.yml | 2 + docs/translation.md | 1 + locales/zh/badges.json | 3 +- locales/zh/tournament.json | 2 +- package.json | 3 +- scripts/check-translation-jsons.ts | 42 ++++++++++++------- scripts/collapse-single-plural-keys.ts | 57 ++++++++++++++++++++++---- 7 files changed, 85 insertions(+), 25 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c1f249bc9..d9f6ae94b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -36,6 +36,8 @@ jobs: run: pnpm run knip - name: Check translations jsons run: pnpm run check-translation-jsons:no-write + - name: Check plural key collapse + run: pnpm run check-plural-collapse - name: Check articles run: pnpm run check-articles - name: Check test DB migrations diff --git a/docs/translation.md b/docs/translation.md index 1251fd584..88f6672de 100644 --- a/docs/translation.md +++ b/docs/translation.md @@ -15,5 +15,6 @@ Things to note: - If you want to add a new language, ask Sendou. - Some lines have dynamic parts like `"articleBy": "by {{author}}"`. The `{{author}}` part should appear in the translated version unchanged - don't translate the part inside `{{}}`. - Another special syntax: `"project": "Sendou.ink is a project by <2>Sendou with help from contributors:"`. The `<2>` tags should appear in the translated version, but the text inside them can change. +- Some English keys come in plural variants like `"tournament_one"` and `"tournament_other"`. Languages that only have a single plural form (e.g. Chinese, Japanese, Korean) can only hold **one** translation for such a key, so during syncing these variants are collapsed into a single key (e.g. `"tournament"`). When collapsing, the `_other` value is kept (it's the one that includes the `{{count}}` number), and the other variants are discarded. This means for these languages you should translate the `_other` variant. Any questions please ask Sendou! diff --git a/locales/zh/badges.json b/locales/zh/badges.json index 95dddeffb..663e159bb 100644 --- a/locales/zh/badges.json +++ b/locales/zh/badges.json @@ -2,8 +2,7 @@ "patreon": "Patreon 上的 sendou.ink Supporter", "patreon+": "Patreon 上的 sendou.ink Supporter+", "xp": "到达 {{xpText}} 的奖励", - "tournament_one": "在 {{tournament}} 中获胜的奖励", - "tournament_other": "在 {{tournament}} 中获胜了 {{count}} 次的奖励", + "tournament": "在 {{tournament}} 中获胜了 {{count}} 次的奖励", "forYourEvent": "在我的活动中设置徽章", "managedBy": "由 <0> 管理", "madeBy": "由 <0> 创作", diff --git a/locales/zh/tournament.json b/locales/zh/tournament.json index 43d220467..a74386ad8 100644 --- a/locales/zh/tournament.json +++ b/locales/zh/tournament.json @@ -122,7 +122,7 @@ "staff.editOrganization": "编辑组织", "actions.addSub": "添加替补", "actions.shareLink": "分享您的邀请链接以添加成员: {{inviteLink}}", - "actions.sub.prompt_other": "您仍可以向阵容中添加 {{count}} 名替补", + "actions.sub.prompt": "您仍可以向阵容中添加 {{count}} 名替补", "actions.sub.prompt_zero": "您的阵容已满,无法添加更多替补", "actions.finalize": "正在结束赛事", "actions.finalize.button": "结束赛事", diff --git a/package.json b/package.json index c913da23a..982fc459b 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "test:e2e:flaky-detect": "playwright test --repeat-each=10 --max-failures=1", "test:e2e:generate-seeds": "cross-env DB_PATH=db-test.sqlite3 pnpm run migrate up && cross-env DB_PATH=db-test.sqlite3 vite-node scripts/generate-e2e-seed-dbs.ts", "check-test-db-migrations": "node --experimental-strip-types scripts/check-test-db-migrations.ts", - "checks": "pnpm run biome:fix && pnpm run test:unit:browser && pnpm run check-translation-jsons && pnpm run typecheck && pnpm run knip && pnpm run check-test-db-migrations", + "check-plural-collapse": "node --experimental-strip-types scripts/collapse-single-plural-keys.ts --check", + "checks": "pnpm run biome:fix && pnpm run test:unit:browser && pnpm run check-translation-jsons && pnpm run check-plural-collapse && pnpm run typecheck && pnpm run knip && pnpm run check-test-db-migrations", "setup": "cross-env DB_PATH=db.sqlite3 vite-node ./scripts/setup.ts", "i18n:sync": "node --experimental-strip-types scripts/collapse-single-plural-keys.ts && i18next-locales-sync -e true -p en -s da de es-ES es-US fr-CA fr-EU he it ja ko nl pl pt-BR ru zh -l locales && pnpm run biome:fix", "knip": "knip" diff --git a/scripts/check-translation-jsons.ts b/scripts/check-translation-jsons.ts index 8857d84b2..9582348d4 100644 --- a/scripts/check-translation-jsons.ts +++ b/scripts/check-translation-jsons.ts @@ -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(); - const keys = []; + const pluralBaseKeys = new Set(); + const bareKeys = new Set(); + 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; diff --git a/scripts/collapse-single-plural-keys.ts b/scripts/collapse-single-plural-keys.ts index 6a2e7f1d3..97cb192fc 100644 --- a/scripts/collapse-single-plural-keys.ts +++ b/scripts/collapse-single-plural-keys.ts @@ -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) { const collapsed: Record = {}; - let changed = false; + const chosenFromPreferred = new Set(); + const collapsedBaseKeys = new Set(); 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) { 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) }; }