From 5d9be21090daae37631a860056e2585eb1a392e9 Mon Sep 17 00:00:00 2001 From: Neil Date: Mon, 26 Oct 2026 22:06:54 -0700 Subject: [PATCH] fix(i18n): resolve plugin-pack plurals with the pack's declared locale Plugin language packs register under synthetic plugin resource languages, so i18next's plural resolver fell back to English rules and Russian/Polish/etc. packs never selected their _few/_many forms. Map each pack's resource language to its declared (Intl-supported) locale inside the plural resolver, and fill missing plural categories from the key's _other form so packs that only ship _one/_other stay in-language. Co-authored-by: Evgenii --- src/renderer/src/i18n/i18n.ts | 52 +++++++++++++++++-- .../src/i18n/plugin-pack-plurals.test.ts | 45 ++++++++++++++++ .../plugins/plugin-language-pack-artifact.ts | 2 +- 3 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 src/renderer/src/i18n/plugin-pack-plurals.test.ts diff --git a/src/renderer/src/i18n/i18n.ts b/src/renderer/src/i18n/i18n.ts index a6b8b4089cf6..f3f645e4b5e6 100644 --- a/src/renderer/src/i18n/i18n.ts +++ b/src/renderer/src/i18n/i18n.ts @@ -18,7 +18,10 @@ import { advanceTranslationResourceVersion } from './translation-resource-versio import { DEFAULT_LOCALE, resolveUiLocale } from './supported-languages' import type { SupportedUiLocale } from '../../../shared/ui-locale' import { isPluginUiLanguage, type UiLanguage } from '../../../shared/ui-language' -import type { PluginLanguagePackRegistration } from '../../../shared/plugins/plugin-language-pack-artifact' +import { + isCatalogObject, + type PluginLanguagePackRegistration +} from '../../../shared/plugins/plugin-language-pack-artifact' import { withTimeout } from '../../../shared/promise-timeout-fallback' export const i18n: I18nInstance = i18next.createInstance() @@ -88,6 +91,13 @@ void i18n i18n.store.on('added', advanceTranslationResourceVersion) i18n.store.on('removed', advanceTranslationResourceVersion) +// Synthetic plugin tags would get English plurals; plugin-pack-plurals.test.ts guards i18next upgrades. +const pluralLocaleByResourceLanguage = new Map() +const pluralResolver = i18n.services.pluralResolver +const getPluralRule = pluralResolver.getRule.bind(pluralResolver) +pluralResolver.getRule = (code: string, options?: { ordinal?: boolean }) => + getPluralRule(pluralLocaleByResourceLanguage.get(code) ?? code, options) + export function translate(key: string, fallback: string, options?: TOptions): string { const value = i18n.t(key, { defaultValue: fallback, ...options }) return isPseudoLocalizationLocale(i18n.language) ? pseudoLocalizeString(value) : value @@ -125,10 +135,16 @@ let pluginLanguagePacks: readonly PluginLanguagePackRegistration[] = [] export function getIntlLocale(): string { const active = i18n.language const pack = pluginLanguagePacks.find((entry) => entry.resourceLanguage === active) - const candidate = pack?.locale ?? active + return supportedIntlLocale(pack?.locale ?? active, Intl.DateTimeFormat) +} + +function supportedIntlLocale( + locale: string, + intl: { supportedLocalesOf(locale: string): string[] } +): string { try { // An unsupported tag would silently use the runtime locale; explicitly fall back to the default. - return Intl.DateTimeFormat.supportedLocalesOf(candidate)[0] ?? DEFAULT_LOCALE + return intl.supportedLocalesOf(locale)[0] ?? DEFAULT_LOCALE } catch { return DEFAULT_LOCALE } @@ -138,6 +154,24 @@ export function resolveRendererResourceLanguage(language: string): string { return pluginLanguagePacks.find((pack) => pack.id === language)?.resourceLanguage ?? language } +// Packs translated from English often ship only _one/_other; reuse _other so few/many stay in-language. +function withPluralCategoriesFromOther( + catalog: Record, + categories: readonly string[] +): Record { + const filled: Record = {} + for (const [key, value] of Object.entries(catalog)) { + filled[key] = isCatalogObject(value) ? withPluralCategoriesFromOther(value, categories) : value + if (typeof value === 'string' && key.endsWith('_other')) { + const base = key.slice(0, -'_other'.length) + for (const category of categories) { + filled[`${base}_${category}`] ??= value + } + } + } + return filled +} + export function setRendererPluginLanguagePacks( packs: readonly PluginLanguagePackRegistration[] ): void { @@ -145,9 +179,19 @@ export function setRendererPluginLanguagePacks( i18n.removeResourceBundle(language, 'translation') } registeredPluginLanguages.clear() + pluralLocaleByResourceLanguage.clear() pluginLanguagePacks = packs for (const pack of packs) { - i18n.addResourceBundle(pack.resourceLanguage, 'translation', pack.catalog, true, true) + const pluralLocale = supportedIntlLocale(pack.locale, Intl.PluralRules) + pluralLocaleByResourceLanguage.set(pack.resourceLanguage, pluralLocale) + const categories = new Intl.PluralRules(pluralLocale).resolvedOptions().pluralCategories + i18n.addResourceBundle( + pack.resourceLanguage, + 'translation', + withPluralCategoriesFromOther(pack.catalog, categories), + true, + true + ) registeredPluginLanguages.add(pack.resourceLanguage) } } diff --git a/src/renderer/src/i18n/plugin-pack-plurals.test.ts b/src/renderer/src/i18n/plugin-pack-plurals.test.ts new file mode 100644 index 000000000000..c8abbfc52310 --- /dev/null +++ b/src/renderer/src/i18n/plugin-pack-plurals.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { i18n, setRendererPluginLanguagePacks } from './i18n' +import { pluginLanguageResourceId } from '../../../shared/plugins/plugin-language-pack-artifact' +import { DEFAULT_LOCALE } from './supported-languages' + +const PACK_ID = 'plugin:smwbev.russian/ru-RU' as const +const PACK_RESOURCE = pluginLanguageResourceId(PACK_ID) +const CATALOG = { files_one: 'one', files_few: 'few', files_many: 'many', files_other: 'other' } + +async function activatePack( + locale: string, + catalog: Record = CATALOG +): Promise { + setRendererPluginLanguagePacks([ + { + id: PACK_ID, + resourceLanguage: PACK_RESOURCE, + pluginKey: 'smwbev.russian', + locale, + catalog + } + ]) + await i18n.changeLanguage(PACK_RESOURCE) +} + +function forms(counts: number[]): string[] { + return counts.map((count) => i18n.t('files', { count })) +} + +afterEach(async () => { + setRendererPluginLanguagePacks([]) + await i18n.changeLanguage(DEFAULT_LOCALE) +}) + +describe('plugin pack plurals', () => { + it('selects plural forms by the locale the pack declares', async () => { + await activatePack('ru-RU') + expect(forms([1, 2, 5, 21])).toEqual(['one', 'few', 'many', 'one']) + }) + + it('keeps packs with only _one/_other in-language for few/many counts', async () => { + await activatePack('ru-RU', { files_one: 'one', files_other: 'other' }) + expect(forms([1, 2, 5, 21])).toEqual(['one', 'other', 'other', 'one']) + }) +}) diff --git a/src/shared/plugins/plugin-language-pack-artifact.ts b/src/shared/plugins/plugin-language-pack-artifact.ts index 1c5e2e765e69..917c0066afc5 100644 --- a/src/shared/plugins/plugin-language-pack-artifact.ts +++ b/src/shared/plugins/plugin-language-pack-artifact.ts @@ -66,7 +66,7 @@ type CatalogFrame = { depth: number } -function isCatalogObject(value: unknown): value is Record { +export function isCatalogObject(value: unknown): value is Record { return ( typeof value === 'object' && value !== null &&