mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(i18n): native plural rules for plugin packs in main, eager main init (#35403)
- Plugin pack i18next resource IDs become BCP-47 private-use tags on the pack's base locale (ru-RU-x-orcapk-...), so Intl and i18next pick the pack's plural rules natively in main and renderer. Drops the renderer pluralResolver.getRule patch; pack registration is shared. Persisted uiLanguage and pack ids keep the plugin: form. - returnEmptyString: false in both instances; main no longer returns the raw uninterpolated fallback for an empty translation. - Main i18n initializes at module load (synchronous with in-memory resources), removing the initialized flag and interpolateFallback. Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -18,19 +18,12 @@ import {
|
||||
UI_LANGUAGE_RUSSIAN,
|
||||
UI_LANGUAGE_SPANISH
|
||||
} from '../../shared/ui-language'
|
||||
import {
|
||||
ensureMainI18n,
|
||||
mainI18n,
|
||||
setMainPluginLanguagePacks,
|
||||
setMainUiLanguage,
|
||||
translateMain
|
||||
} from './main-i18n'
|
||||
import { mainI18n, setMainPluginLanguagePacks, setMainUiLanguage, translateMain } from './main-i18n'
|
||||
import { pluginLanguageResourceId } from '../../shared/plugins/plugin-language-pack-artifact'
|
||||
|
||||
describe('main-i18n lazy locale loading', () => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
beforeEach(async () => {
|
||||
await ensureMainI18n()
|
||||
setMainPluginLanguagePacks([])
|
||||
await setMainUiLanguage(UI_LANGUAGE_ENGLISH)
|
||||
})
|
||||
@@ -136,4 +129,27 @@ describe('main-i18n lazy locale loading', () => {
|
||||
expect(await setMainUiLanguage(id)).toBe('en')
|
||||
expect(translateMain('menu.file', 'File')).toBe('File')
|
||||
})
|
||||
|
||||
it("uses a contributed pack's plural rules and English for its empty values", async () => {
|
||||
const id = 'plugin:orca-samples.russian/ru-RU' as const
|
||||
setMainPluginLanguagePacks([
|
||||
{
|
||||
id,
|
||||
resourceLanguage: pluginLanguageResourceId(id),
|
||||
pluginKey: 'orca-samples.russian',
|
||||
locale: 'ru-RU',
|
||||
catalog: {
|
||||
terminals_one: '{{count}} one',
|
||||
terminals_few: '{{count}} few',
|
||||
terminals_other: '{{count}} other',
|
||||
empty: ''
|
||||
}
|
||||
}
|
||||
])
|
||||
await setMainUiLanguage(id)
|
||||
expect(
|
||||
[1, 3, 5, 21].map((count) => translateMain('terminals_other', '{{count}} terms', { count }))
|
||||
).toEqual(['1 one', '3 few', '5 other', '21 one'])
|
||||
expect(translateMain('empty', '{{count}} terminals', { count: 2 })).toBe('2 terminals')
|
||||
})
|
||||
})
|
||||
|
||||
+24
-55
@@ -14,13 +14,12 @@ import { isPseudoLocalizationLocale, pseudoLocalizeString } from '../../shared/p
|
||||
import { DEFAULT_UI_LOCALE, resolveUiLocale, type SupportedUiLocale } from '../../shared/ui-locale'
|
||||
import { UI_LANGUAGE_SYSTEM, type UiLanguage } from '../../shared/ui-language'
|
||||
import type { PluginLanguagePackRegistration } from '../../shared/plugins/plugin-language-pack-artifact'
|
||||
import { replacePluginLanguagePackResources } from '../../shared/plugins/plugin-language-pack-i18n-resources'
|
||||
|
||||
export const mainI18n: I18nInstance = i18next.createInstance()
|
||||
|
||||
let initialized = false
|
||||
let languageGeneration = 0
|
||||
let pluginLanguagePacks: readonly PluginLanguagePackRegistration[] = []
|
||||
const registeredPluginLanguages = new Set<string>()
|
||||
|
||||
// English uses caller fallbacks; load only the selected non-English catalog after startup.
|
||||
const LAZY_LOCALE_LOADERS: Record<
|
||||
@@ -67,33 +66,28 @@ export function getMainSystemLocale(): string {
|
||||
return getSystemLanguageDetection()?.locale ?? DEFAULT_UI_LOCALE
|
||||
}
|
||||
|
||||
export async function ensureMainI18n(): Promise<I18nInstance> {
|
||||
if (!initialized) {
|
||||
await mainI18n.use(lazyLocaleBackend).init({
|
||||
fallbackLng: DEFAULT_UI_LOCALE,
|
||||
lng: DEFAULT_UI_LOCALE,
|
||||
// Keep zh-Hant from falling back to Simplified zh before English.
|
||||
load: 'currentOnly',
|
||||
// Seed English as loaded; partialBundledLanguages allows lazy non-English catalogs.
|
||||
partialBundledLanguages: true,
|
||||
resources: {
|
||||
en: {
|
||||
translation: {}
|
||||
}
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false
|
||||
}
|
||||
})
|
||||
initialized = true
|
||||
applyMainPluginLanguagePacks()
|
||||
// In-memory resources make init synchronous, so startup dialogs can translate before settings load.
|
||||
void mainI18n.use(lazyLocaleBackend).init({
|
||||
fallbackLng: DEFAULT_UI_LOCALE,
|
||||
lng: DEFAULT_UI_LOCALE,
|
||||
// Keep zh-Hant from falling back to Simplified zh before English.
|
||||
load: 'currentOnly',
|
||||
// Seed English as loaded; partialBundledLanguages allows lazy non-English catalogs.
|
||||
partialBundledLanguages: true,
|
||||
resources: {
|
||||
en: {
|
||||
translation: {}
|
||||
}
|
||||
},
|
||||
// An empty catalog value falls back to English instead of rendering blank.
|
||||
returnEmptyString: false,
|
||||
interpolation: {
|
||||
escapeValue: false
|
||||
}
|
||||
return mainI18n
|
||||
}
|
||||
})
|
||||
|
||||
export async function setMainUiLanguage(language: UiLanguage): Promise<string> {
|
||||
const request = ++languageGeneration
|
||||
await ensureMainI18n()
|
||||
const selectedLocale = resolveUiLocale(
|
||||
language,
|
||||
language === UI_LANGUAGE_SYSTEM ? getMainSystemLocale() : DEFAULT_UI_LOCALE
|
||||
@@ -123,46 +117,21 @@ export async function prepareEarlyMainUiLanguage(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function applyMainPluginLanguagePacks(): void {
|
||||
for (const language of registeredPluginLanguages) {
|
||||
mainI18n.removeResourceBundle(language, 'translation')
|
||||
}
|
||||
registeredPluginLanguages.clear()
|
||||
for (const pack of pluginLanguagePacks) {
|
||||
mainI18n.addResourceBundle(pack.resourceLanguage, 'translation', pack.catalog, true, true)
|
||||
registeredPluginLanguages.add(pack.resourceLanguage)
|
||||
}
|
||||
}
|
||||
|
||||
export function setMainPluginLanguagePacks(
|
||||
packs: readonly PluginLanguagePackRegistration[]
|
||||
): boolean {
|
||||
if (pluginLanguagePacks === packs) {
|
||||
return false
|
||||
}
|
||||
replacePluginLanguagePackResources(mainI18n, pluginLanguagePacks, packs)
|
||||
pluginLanguagePacks = packs
|
||||
if (initialized) {
|
||||
applyMainPluginLanguagePacks()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function translateMain(key: string, fallback: string, options?: TOptions): string {
|
||||
// Menu registration can precede async initialization; use the English fallback then.
|
||||
const raw = initialized
|
||||
? mainI18n.t(resolvePluralFamilyKey(key, options), { defaultValue: fallback, ...options })
|
||||
: interpolateFallback(fallback, options)
|
||||
const value = typeof raw === 'string' && raw.length > 0 ? raw : fallback
|
||||
const value = mainI18n.t(resolvePluralFamilyKey(key, options), {
|
||||
defaultValue: fallback,
|
||||
...options
|
||||
})
|
||||
return isPseudoLocalizationLocale(mainI18n.language) ? pseudoLocalizeString(value) : value
|
||||
}
|
||||
|
||||
// Why: startup dialogs can run before init, and their fallbacks still carry {{placeholders}}.
|
||||
function interpolateFallback(fallback: string, options: TOptions | undefined): string {
|
||||
if (!options) {
|
||||
return fallback
|
||||
}
|
||||
return fallback.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (placeholder, name: string) => {
|
||||
const value: unknown = options[name]
|
||||
return value === undefined || value === null ? placeholder : String(value)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('PluginLanguagePackRegistry', () => {
|
||||
{
|
||||
id: 'plugin:orca-samples.portuguese/pt-BR',
|
||||
resourceLanguage:
|
||||
'plugin0070006c007500670069006e003a006f007200630061002d00730061006d0070006c00650073002e0070006f00720074007500670075006500730065002f00700074002d00420052',
|
||||
'pt-BR-x-orcapk-6f726361-2d73616d-706c6573-2e706f72-74756775-6573652f-70742d42-52',
|
||||
pluginKey: 'orca-samples.portuguese',
|
||||
locale: 'pt-BR',
|
||||
catalog: { common: { save: 'Salvar' } }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import { ensureMainI18n, setMainUiLanguage } from '../i18n/main-i18n'
|
||||
import { setMainUiLanguage } from '../i18n/main-i18n'
|
||||
import {
|
||||
registerAppMenu,
|
||||
rebuildAppMenu,
|
||||
@@ -26,7 +26,6 @@ export async function initializeMainProcessI18nAndMenu(): Promise<void> {
|
||||
if (!store) {
|
||||
throw new Error('Store must be initialized before menu')
|
||||
}
|
||||
await ensureMainI18n()
|
||||
await setMainUiLanguage(store.getSettings().uiLanguage)
|
||||
logStartupMilestone('i18n-ready')
|
||||
registerAppMenu({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ensureMainI18n, mainI18n } from '../i18n/main-i18n'
|
||||
import { mainI18n } from '../i18n/main-i18n'
|
||||
import type { InstallDirAclPoisonDiagnosis } from '../startup/windows-install-dir-acl-recovery'
|
||||
import {
|
||||
presentRendererRecoveryPrompt,
|
||||
@@ -47,7 +47,6 @@ function harness(overrides: Partial<RendererRecoveryPromptDeps> & { responses?:
|
||||
|
||||
describe('presentRendererRecoveryPrompt', () => {
|
||||
beforeEach(async () => {
|
||||
await ensureMainI18n()
|
||||
await mainI18n.changeLanguage('en')
|
||||
})
|
||||
|
||||
|
||||
@@ -112,10 +112,6 @@ describe('ProjectRoadmap', () => {
|
||||
])('renders date ticks and bars with plugin locale %s', async (packLocale, dateLocale) => {
|
||||
const packId = 'plugin:stablyai.orca-portuguese/pt-BR' as const
|
||||
const resourceLanguage = pluginLanguageResourceId(packId)
|
||||
// The resource tag recorded in the Windows crash report on #25021.
|
||||
expect(resourceLanguage).toBe(
|
||||
'plugin0070006c007500670069006e003a0073007400610062006c007900610069002e006f007200630061002d0070006f00720074007500670075006500730065002f00700074002d00420052'
|
||||
)
|
||||
setRendererPluginLanguagePacks([
|
||||
{
|
||||
id: packId,
|
||||
|
||||
@@ -26,6 +26,7 @@ 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 { replacePluginLanguagePackResources } from '../../../shared/plugins/plugin-language-pack-i18n-resources'
|
||||
import { withTimeout } from '../../../shared/promise-timeout-fallback'
|
||||
import {
|
||||
resolvePluralFamilyKey,
|
||||
@@ -94,6 +95,8 @@ void i18n
|
||||
translation: enRuntimeRequired
|
||||
}
|
||||
},
|
||||
// An empty catalog value falls back to English instead of rendering blank.
|
||||
returnEmptyString: false,
|
||||
interpolation: {
|
||||
escapeValue: false
|
||||
},
|
||||
@@ -107,13 +110,6 @@ i18n.store.on('added', advanceTranslationResourceVersion)
|
||||
i18n.store.on('removed', advanceTranslationResourceVersion)
|
||||
i18n.on('languageChanged', 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)
|
||||
|
||||
// Re-rendered chrome resolves hundreds of option-free strings per workspace switch; they only change with language or resources.
|
||||
const PLAIN_TRANSLATION_CACHE_LIMIT = 5000
|
||||
const plainTranslationCache = new Map<string, string>()
|
||||
@@ -171,10 +167,9 @@ export async function setRendererUiLanguage(language: UiLanguage): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
const registeredPluginLanguages = new Set<string>()
|
||||
let pluginLanguagePacks: readonly PluginLanguagePackRegistration[] = []
|
||||
|
||||
/** Resolve an Intl-compatible BCP-47 tag: synthetic plugin<hex> resource names use the pack’s declared tag, then the default locale. */
|
||||
/** Resolve an Intl-compatible BCP-47 tag: plugin pack resources use the pack’s declared tag, then the default locale. */
|
||||
export function getIntlLocale(): string {
|
||||
const active = i18n.language
|
||||
const pack = pluginLanguagePacks.find((entry) => entry.resourceLanguage === active)
|
||||
@@ -200,23 +195,6 @@ export function resolveRendererResourceLanguage(language: string): string {
|
||||
export function setRendererPluginLanguagePacks(
|
||||
packs: readonly PluginLanguagePackRegistration[]
|
||||
): void {
|
||||
for (const language of registeredPluginLanguages) {
|
||||
i18n.removeResourceBundle(language, 'translation')
|
||||
}
|
||||
registeredPluginLanguages.clear()
|
||||
pluralLocaleByResourceLanguage.clear()
|
||||
replacePluginLanguagePackResources(i18n, pluginLanguagePacks, packs)
|
||||
pluginLanguagePacks = packs
|
||||
for (const pack of packs) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('getIntlLocale', () => {
|
||||
it('resolves a plugin resource language to the locale the pack declares', async () => {
|
||||
withSupportedLocales(['ru-RU'])
|
||||
await activate(PACK_RESOURCE, [PACK])
|
||||
// Without the pack lookup this would reach Intl as `plugin<hex>`.
|
||||
// Without the pack lookup this would reach Intl as the private-use resource tag.
|
||||
expect(getIntlLocale()).toBe('ru-RU')
|
||||
})
|
||||
|
||||
@@ -62,9 +62,4 @@ describe('getIntlLocale', () => {
|
||||
await activate(PACK_RESOURCE)
|
||||
expect(getIntlLocale()).toBe(DEFAULT_LOCALE)
|
||||
})
|
||||
|
||||
it('keeps the synthetic resource language unusable for Intl directly', () => {
|
||||
// Unstubbed on purpose: this is a property of the tag, not of ICU data.
|
||||
expect(() => Intl.DateTimeFormat.supportedLocalesOf(PACK_RESOURCE)).toThrow(RangeError)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getIntlLocale } from './i18n'
|
||||
|
||||
// Weekday words follow the UI language; resolve synthetic plugin tags and cache per locale for live switches.
|
||||
// Weekday words follow the UI language; resolve plugin pack tags and cache per locale for live switches.
|
||||
let cached: { locale: string; names: readonly string[] } | null = null
|
||||
|
||||
// 2026-01-04 is Sunday: preserve cron/RRULE Sunday indexing regardless of the locale’s first weekday.
|
||||
|
||||
@@ -8,12 +8,14 @@ import {
|
||||
} from './plugin-language-pack-artifact'
|
||||
|
||||
describe('plugin language-pack artifacts', () => {
|
||||
it('maps qualified IDs to distinct alphanumeric i18next resource languages', () => {
|
||||
const first = pluginLanguageResourceId('plugin:a.bc/d-ef')
|
||||
const second = pluginLanguageResourceId('plugin:ab.c/de-f')
|
||||
it('maps qualified IDs to distinct canonical private-use tags on the pack locale', () => {
|
||||
const first = pluginLanguageResourceId('plugin:a.bc/ru-RU')
|
||||
const second = pluginLanguageResourceId('plugin:ab.c/ru-RU')
|
||||
|
||||
expect(first).toMatch(/^plugin[0-9a-f]+$/)
|
||||
expect(second).toMatch(/^plugin[0-9a-f]+$/)
|
||||
for (const resourceLanguage of [first, second]) {
|
||||
expect(resourceLanguage).toMatch(/^ru-RU-x-orcapk-/)
|
||||
expect(Intl.getCanonicalLocales(resourceLanguage)).toEqual([resourceLanguage])
|
||||
}
|
||||
expect(first).not.toBe(second)
|
||||
})
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ const PROTECTED_TRANSLATION_MODULE = /^plugin/i
|
||||
|
||||
export type PluginLanguagePackRegistration = {
|
||||
id: `plugin:${string}`
|
||||
resourceLanguage: `plugin${string}`
|
||||
resourceLanguage: string
|
||||
pluginKey: string
|
||||
locale: string
|
||||
displayName?: string
|
||||
@@ -40,13 +40,25 @@ export function isPluginLanguagePackRegistration(
|
||||
)
|
||||
}
|
||||
|
||||
export function pluginLanguageResourceId(id: `plugin:${string}`): `plugin${string}` {
|
||||
let encoded = ''
|
||||
for (let index = 0; index < id.length; index += 1) {
|
||||
encoded += id.charCodeAt(index).toString(16).padStart(4, '0')
|
||||
// Internal i18next ID only; settings and IPC keep the qualified `plugin:` ID.
|
||||
export function pluginLanguageResourceId(id: `plugin:${string}`): string {
|
||||
// Fixed-width UTF-8 hex keeps IDs collision-free; private-use subtags allow at most 8 chars.
|
||||
const encoded = Array.from(new TextEncoder().encode(id.slice('plugin:'.length)), (byte) =>
|
||||
byte.toString(16).padStart(2, '0')
|
||||
).join('')
|
||||
const subtags = encoded.match(/.{1,8}/g) ?? []
|
||||
// The real base locale lets Intl and i18next pick the pack's plural rules natively.
|
||||
return `${pluginPackBaseLocale(id.slice(id.lastIndexOf('/') + 1))}-x-orcapk-${subtags.join('-')}`
|
||||
}
|
||||
|
||||
function pluginPackBaseLocale(locale: string): string {
|
||||
try {
|
||||
const supported = Intl.PluralRules.supportedLocalesOf(locale)[0]
|
||||
// Unsupported tags would get the runtime's plural rules; use English like other unknown locales.
|
||||
return supported ? new Intl.Locale(supported).baseName : 'en'
|
||||
} catch {
|
||||
return 'en'
|
||||
}
|
||||
// i18next parses tag punctuation; fixed-width UTF-16 hex provides collision-free qualified-ID keys.
|
||||
return `plugin${encoded}`
|
||||
}
|
||||
|
||||
export type PluginLanguagePackParseResult =
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { i18n as I18nInstance } from 'i18next'
|
||||
import { withPluralCategoriesFromOther } from '../i18n-plural-family'
|
||||
import type { PluginLanguagePackRegistration } from './plugin-language-pack-artifact'
|
||||
|
||||
export function replacePluginLanguagePackResources(
|
||||
i18n: I18nInstance,
|
||||
previous: readonly PluginLanguagePackRegistration[],
|
||||
next: readonly PluginLanguagePackRegistration[]
|
||||
): void {
|
||||
for (const pack of previous) {
|
||||
i18n.removeResourceBundle(pack.resourceLanguage, 'translation')
|
||||
}
|
||||
for (const pack of next) {
|
||||
const categories = new Intl.PluralRules(pack.resourceLanguage).resolvedOptions()
|
||||
.pluralCategories
|
||||
i18n.addResourceBundle(
|
||||
pack.resourceLanguage,
|
||||
'translation',
|
||||
withPluralCategoriesFromOther(pack.catalog, categories),
|
||||
true,
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user