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:
OrcaWin
2026-10-28 15:18:31 -07:00
committed by GitHub
co-authored by OrcaWin
parent 64344142fa
commit 95a83ccc6c
12 changed files with 108 additions and 118 deletions
+24 -8
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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,
+5 -27
View File
@@ -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)
}
}
+1 -6
View File
@@ -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 -1
View File
@@ -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
)
}
}