diff --git a/src/renderer/src/i18n/i18n.ts b/src/renderer/src/i18n/i18n.ts index 725a40e9aaa2..09b04cefd304 100644 --- a/src/renderer/src/i18n/i18n.ts +++ b/src/renderer/src/i18n/i18n.ts @@ -7,6 +7,8 @@ import i18next, { import { initReactI18next } from 'react-i18next' import enRuntimeRequired from './en-runtime-required.json' +import { canonicalizePackLocale } from './pack-locale' +import { applyPluginPluralLocales } from './plugin-plural-locale' import { isPseudoLocalizationLocale, pseudoLocalizeString } from './pseudo-localization' import { DEFAULT_LOCALE, resolveUiLocale } from './supported-languages' import type { SupportedUiLocale } from '../../../shared/ui-locale' @@ -112,7 +114,12 @@ 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 + // Why: the declared tag is author-supplied, so canonicalize before Intl sees + // it. `ru_RU` would otherwise throw and degrade a Russian pack to English. + const candidate = pack ? canonicalizePackLocale(pack.locale) : active + if (!candidate) { + return DEFAULT_LOCALE + } try { // Why: an empty result means Intl has no data for the tag, so fall through // to the default locale instead of letting Intl pick the runtime one. @@ -138,4 +145,7 @@ export function setRendererPluginLanguagePacks( i18n.addResourceBundle(pack.resourceLanguage, 'translation', pack.catalog, true, true) registeredPluginLanguages.add(pack.resourceLanguage) } + // Why: the synthetic resource language carries no locale, so plural selection + // has to be told which one the pack was written for. + applyPluginPluralLocales(i18n, packs) } diff --git a/src/renderer/src/i18n/intl-locale.test.ts b/src/renderer/src/i18n/intl-locale.test.ts index 69f0de084555..4ab6e5ed66b0 100644 --- a/src/renderer/src/i18n/intl-locale.test.ts +++ b/src/renderer/src/i18n/intl-locale.test.ts @@ -57,6 +57,20 @@ describe('getIntlLocale', () => { expect(getIntlLocale()).toBe('ru-RU') }) + it('canonicalizes a POSIX-style tag the pack declared', async () => { + withSupportedLocales(['ru-RU']) + // `ru_RU` is an ordinary manifest slip. Uncanonicalized it makes Intl throw, + // which used to degrade a Russian pack to English dates without a word. + await activate(PACK_RESOURCE, [{ ...PACK, locale: 'ru_RU' }]) + expect(getIntlLocale()).toBe('ru-RU') + }) + + it('falls back to the default locale when the declared tag cannot be canonicalized', async () => { + withSupportedLocales(['ru-RU']) + await activate(PACK_RESOURCE, [{ ...PACK, locale: 'not_a_locale' }]) + expect(getIntlLocale()).toBe(DEFAULT_LOCALE) + }) + it('falls back to the default locale when ICU has no data for the tag', async () => { withSupportedLocales([]) await activate('es') diff --git a/src/renderer/src/i18n/pack-locale.ts b/src/renderer/src/i18n/pack-locale.ts new file mode 100644 index 000000000000..3cbd38881c38 --- /dev/null +++ b/src/renderer/src/i18n/pack-locale.ts @@ -0,0 +1,41 @@ +/** + * Canonical form of the locale a plugin language pack declares. + * + * The manifest accepts any string here, so a pack can ship `ru_RU`, `RU-ru`, + * `not_a_locale`, or `xx`. Every `Intl` consumer needs the same two guarantees + * before it can trust that value: a tag `Intl` will not reject outright, and a + * decision that never lets a malformed tag reach the runtime, where an unknown + * one silently resolves to the host machine's locale. + * + * Canonicalizing here — once — keeps date formatting and plural selection + * agreeing on what a pack declared. Whether the tag is actually *supported* is + * left to each caller, because `Intl.DateTimeFormat` and `Intl.PluralRules` + * carry different data. + */ +// Why: getIntlLocale() runs on render paths, and Intl.getCanonicalLocales costs +// about as much as the supportedLocalesOf call it precedes (~1.6µs here), so +// resolving on every call would double that work. Keys come from installed +// manifests, so the map holds a handful of entries for the process lifetime. +const canonicalByDeclared = new Map() + +export function canonicalizePackLocale(declared: string): string | undefined { + if (canonicalByDeclared.has(declared)) { + return canonicalByDeclared.get(declared) + } + const canonical = resolveCanonicalPackLocale(declared) + canonicalByDeclared.set(declared, canonical) + return canonical +} + +function resolveCanonicalPackLocale(declared: string): string | undefined { + // POSIX-style tags are a common manifest slip; Intl only accepts dashes. + const candidate = declared.trim().replace(/_/g, '-') + if (!candidate) { + return undefined + } + try { + return Intl.getCanonicalLocales(candidate)[0] + } catch { + return undefined + } +} diff --git a/src/renderer/src/i18n/plugin-plural-locale.test.ts b/src/renderer/src/i18n/plugin-plural-locale.test.ts new file mode 100644 index 000000000000..4e8d13f3e878 --- /dev/null +++ b/src/renderer/src/i18n/plugin-plural-locale.test.ts @@ -0,0 +1,136 @@ +/** + * A plugin pack registers under a synthetic `plugin` resource language, + * which i18next cannot turn into `Intl.PluralRules`, so plural selection falls + * back to two English forms. These tests pin that the declared locale is what + * decides the form, and that resource lookup still runs on the synthetic tag. + */ +import { describe, expect, it } from 'vitest' +import i18next, { type i18n as I18nInstance } from 'i18next' +import { + pluginLanguageResourceId, + type PluginLanguagePackRegistration +} from '../../../shared/plugins/plugin-language-pack-artifact' +import { applyPluginPluralLocales, pluralLocaleForResourceLanguage } from './plugin-plural-locale' + +const RESOURCE_LANGUAGE = pluginLanguageResourceId('plugin:smwbev.russian') + +// Russian needs four categories where English has two: `2 сессии` (few) and +// `5 сессий` (many) are different words, and both differ from `1 сессия`. +const RUSSIAN_CATALOG = { + sessions_one: '{{count}} сессия', + sessions_few: '{{count}} сессии', + sessions_many: '{{count}} сессий', + sessions_other: '{{count}} сессии' +} + +function pack(locale: string): PluginLanguagePackRegistration { + return { + id: 'plugin:smwbev.russian', + resourceLanguage: RESOURCE_LANGUAGE, + pluginKey: 'smwbev.russian', + locale, + catalog: RUSSIAN_CATALOG + } +} + +async function instanceWithPack(): Promise { + const instance = i18next.createInstance() + await instance.init({ + lng: 'en', + resources: { + en: { + translation: { sessions_one: '{{count}} session', sessions_other: '{{count}} sessions' } + }, + [RESOURCE_LANGUAGE]: { translation: RUSSIAN_CATALOG } + } + }) + return instance +} + +function render(instance: I18nInstance, counts: number[]): string[] { + return counts.map((count) => instance.t('sessions', { lng: RESOURCE_LANGUAGE, count })) +} + +describe('plugin pack plural rules', () => { + it('selects Russian forms once the pack declares its locale', async () => { + const instance = await instanceWithPack() + + expect(render(instance, [1, 2, 5, 21])).toEqual([ + '1 сессия', + '2 сессии', + '5 сессии', + '21 сессии' + ]) + + applyPluginPluralLocales(instance, [pack('ru-RU')]) + + expect(render(instance, [1, 2, 5, 21])).toEqual([ + '1 сессия', + '2 сессии', + '5 сессий', + '21 сессия' + ]) + }) + + it('keeps resource lookup on the synthetic tag', async () => { + const instance = await instanceWithPack() + applyPluginPluralLocales(instance, [pack('ru-RU')]) + + // A key with no plural forms still resolves from the pack's own bundle. + instance.addResourceBundle(RESOURCE_LANGUAGE, 'translation', { plain: 'Настройки' }, true, true) + + expect(instance.t('plain', { lng: RESOURCE_LANGUAGE })).toBe('Настройки') + expect(instance.t('sessions', { lng: 'en', count: 5 })).toBe('5 sessions') + }) + + it('ignores a pack whose locale is missing or already the resource language', async () => { + const instance = await instanceWithPack() + + applyPluginPluralLocales(instance, [pack('')]) + expect(pluralLocaleForResourceLanguage(RESOURCE_LANGUAGE)).toBeUndefined() + expect(render(instance, [5])).toEqual(['5 сессии']) + + applyPluginPluralLocales(instance, [pack(RESOURCE_LANGUAGE)]) + expect(pluralLocaleForResourceLanguage(RESOURCE_LANGUAGE)).toBeUndefined() + }) + + it('canonicalizes a declared locale that Intl would otherwise reject', async () => { + const instance = await instanceWithPack() + + // `ru_RU` and `RU-ru` are ordinary manifest slips; both name Russian. + applyPluginPluralLocales(instance, [pack('ru_RU')]) + expect(pluralLocaleForResourceLanguage(RESOURCE_LANGUAGE)).toBe('ru-RU') + expect(render(instance, [5])).toEqual(['5 сессий']) + + applyPluginPluralLocales(instance, [pack('RU-ru')]) + expect(pluralLocaleForResourceLanguage(RESOURCE_LANGUAGE)).toBe('ru-RU') + expect(render(instance, [5])).toEqual(['5 сессий']) + }) + + it('ignores a locale Intl cannot resolve instead of inheriting the host locale', async () => { + const instance = await instanceWithPack() + + // `not_a_locale` is malformed; `xx` and `tlh` are well-formed but carry no + // CLDR data, and Intl answers those with the host machine's locale — so the + // rendered forms would depend on whose computer the pack runs on. + for (const declared of ['not_a_locale', 'xx', 'tlh']) { + applyPluginPluralLocales(instance, [pack(declared)]) + expect(pluralLocaleForResourceLanguage(RESOURCE_LANGUAGE)).toBeUndefined() + expect(render(instance, [1, 5])).toEqual(['1 сессия', '5 сессии']) + } + }) + + it('follows a pack that changes its declared locale', async () => { + const instance = await instanceWithPack() + + applyPluginPluralLocales(instance, [pack('ru-RU')]) + expect(render(instance, [5])).toEqual(['5 сессий']) + + // Japanese has a single category, so every count takes `_other`. + applyPluginPluralLocales(instance, [pack('ja-JP')]) + expect(render(instance, [1, 5])).toEqual(['1 сессии', '5 сессии']) + + applyPluginPluralLocales(instance, []) + expect(render(instance, [5])).toEqual(['5 сессии']) + }) +}) diff --git a/src/renderer/src/i18n/plugin-plural-locale.ts b/src/renderer/src/i18n/plugin-plural-locale.ts new file mode 100644 index 000000000000..48988b63211c --- /dev/null +++ b/src/renderer/src/i18n/plugin-plural-locale.ts @@ -0,0 +1,87 @@ +import type { i18n as I18nInstance } from 'i18next' +import type { PluginLanguagePackRegistration } from '../../../shared/plugins/plugin-language-pack-artifact' +import { canonicalizePackLocale } from './pack-locale' + +/** + * Plural rules for plugin language packs. + * + * A pack registers its catalog under the synthetic `plugin` resource + * language, which carries no locale information, so i18next cannot build + * `Intl.PluralRules` from it and falls back to two English forms: a Russian + * pack renders `5 сессии` where the language needs `5 сессий`. Feeding the + * locale the pack declares to the resolver — and only to the resolver — makes + * the CLDR categories reachable while resource lookup keeps running on the + * synthetic tag exactly as before. + */ + +type PluralRule = { resolvedOptions: () => { pluralCategories: string[] } } | undefined +type PluralResolverLike = { + getRule: (code: string, options?: Record) => PluralRule + clearCache?: () => void +} + +const declaredLocales = new Map() +let installedResolver: PluralResolverLike | undefined + +/** + * Keep a declared locale only when `Intl.PluralRules` actually has data for it. + * A well-formed but unknown tag such as `xx` passes canonicalization, and the + * runtime then answers it with the *host* locale — the pack would select forms + * by whichever language the user's machine runs, which its author could never + * reproduce. Returning undefined leaves the lookup empty instead, so the + * resolver keeps running on the synthetic tag and the pack degrades to + * i18next's default. + */ +function pluralLocaleOf(declared: string): string | undefined { + const canonical = canonicalizePackLocale(declared) + if (!canonical) { + return undefined + } + return Intl.PluralRules.supportedLocalesOf(canonical).length > 0 ? canonical : undefined +} + +function resolverOf(instance: I18nInstance): PluralResolverLike | undefined { + const services = (instance as unknown as { services?: { pluralResolver?: PluralResolverLike } }) + .services + return services?.pluralResolver +} + +/** + * Point the resolver at each pack's declared locale. Idempotent: the wrapper is + * installed once, and later calls only refresh the mapping. + */ +export function applyPluginPluralLocales( + instance: I18nInstance, + packs: readonly PluginLanguagePackRegistration[] +): void { + declaredLocales.clear() + for (const pack of packs) { + if (!pack.locale || pack.locale === pack.resourceLanguage) { + continue + } + const canonical = pluralLocaleOf(pack.locale) + if (canonical) { + declaredLocales.set(pack.resourceLanguage, canonical) + } + } + + const resolver = resolverOf(instance) + if (!resolver) { + return + } + // Why: rules are cached per code, so a pack that changes locale between calls + // would otherwise keep the rule resolved for its previous one. + resolver.clearCache?.() + if (installedResolver === resolver) { + return + } + + const base = resolver.getRule.bind(resolver) + resolver.getRule = (code, options) => base(declaredLocales.get(code) ?? code, options) + installedResolver = resolver +} + +/** The locale a synthetic resource language resolves plurals with, for tests. */ +export function pluralLocaleForResourceLanguage(resourceLanguage: string): string | undefined { + return declaredLocales.get(resourceLanguage) +}