mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user