Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/renderer/src/i18n/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.
Expand All @@ -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)
}
14 changes: 14 additions & 0 deletions src/renderer/src/i18n/intl-locale.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
41 changes: 41 additions & 0 deletions src/renderer/src/i18n/pack-locale.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>()

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
}
}
136 changes: 136 additions & 0 deletions src/renderer/src/i18n/plugin-plural-locale.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* A plugin pack registers under a synthetic `plugin<hex>` 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<I18nInstance> {
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 сессии'])
})
})
87 changes: 87 additions & 0 deletions src/renderer/src/i18n/plugin-plural-locale.ts
Original file line number Diff line number Diff line change
@@ -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<hex>` 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<string, unknown>) => PluralRule
clearCache?: () => void
}

const declaredLocales = new Map<string, string>()
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)
}