mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-26 05:12:19 -05:00
Collapsing i18n keys check in CI/CD + fix existing
This commit is contained in:
2
.github/workflows/main.yml
vendored
2
.github/workflows/main.yml
vendored
@@ -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
|
||||
|
||||
@@ -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</2> with help from contributors:"`. The `<2></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!
|
||||
|
||||
@@ -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></0> 管理",
|
||||
"madeBy": "由 <0></0> 创作",
|
||||
|
||||
@@ -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": "结束赛事",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user