fix(i18n): resolve plugin-pack plurals with the pack's declared locale (#12505)

Plugin language packs register under synthetic plugin<hex> 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: Neil <neil@stably.ai>
This commit is contained in:
Evgenii
2026-10-26 22:42:57 -07:00
committed by GitHub
co-authored by Neil
parent 7815c07874
commit 8d72873b78
3 changed files with 94 additions and 5 deletions
+48 -4
View File
@@ -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<hex> tags would get English plurals; plugin-pack-plurals.test.ts guards i18next upgrades.
const pluralLocaleByResourceLanguage = new Map<string, string>()
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<string, unknown>,
categories: readonly string[]
): Record<string, unknown> {
const filled: Record<string, unknown> = {}
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)
}
}
@@ -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<string, string> = CATALOG
): Promise<void> {
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'])
})
})
@@ -66,7 +66,7 @@ type CatalogFrame = {
depth: number
}
function isCatalogObject(value: unknown): value is Record<string, unknown> {
export function isCatalogObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === 'object' &&
value !== null &&