From 95a83ccc6cac384bb3e4eb20684dd055586bcfaf Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Wed, 28 Oct 2026 15:18:31 -0700 Subject: [PATCH] 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> --- src/main/i18n/main-i18n-lazy-locale.test.ts | 32 ++++++-- src/main/i18n/main-i18n.ts | 79 ++++++------------- .../plugin-language-pack-registry.test.ts | 2 +- src/main/startup/main-process-i18n-menu.ts | 3 +- .../window/renderer-recovery-prompt.test.ts | 3 +- .../github-project/ProjectRoadmap.test.tsx | 4 - src/renderer/src/i18n/i18n.ts | 32 ++------ src/renderer/src/i18n/intl-locale.test.ts | 7 +- src/renderer/src/i18n/weekday-names.ts | 2 +- .../plugin-language-pack-artifact.test.ts | 12 +-- .../plugins/plugin-language-pack-artifact.ts | 26 ++++-- .../plugin-language-pack-i18n-resources.ts | 24 ++++++ 12 files changed, 108 insertions(+), 118 deletions(-) create mode 100644 src/shared/plugins/plugin-language-pack-i18n-resources.ts diff --git a/src/main/i18n/main-i18n-lazy-locale.test.ts b/src/main/i18n/main-i18n-lazy-locale.test.ts index 03d51a55ebb..fb599175274 100644 --- a/src/main/i18n/main-i18n-lazy-locale.test.ts +++ b/src/main/i18n/main-i18n-lazy-locale.test.ts @@ -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') + }) }) diff --git a/src/main/i18n/main-i18n.ts b/src/main/i18n/main-i18n.ts index d358a851776..c4e6e5ff6cd 100644 --- a/src/main/i18n/main-i18n.ts +++ b/src/main/i18n/main-i18n.ts @@ -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() // 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 { - 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 { 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 { } } -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) - }) -} diff --git a/src/main/plugins/plugin-language-pack-registry.test.ts b/src/main/plugins/plugin-language-pack-registry.test.ts index 94fd93e45ff..22caf4ffc83 100644 --- a/src/main/plugins/plugin-language-pack-registry.test.ts +++ b/src/main/plugins/plugin-language-pack-registry.test.ts @@ -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' } } diff --git a/src/main/startup/main-process-i18n-menu.ts b/src/main/startup/main-process-i18n-menu.ts index 9ad94e748bc..fcbd510d81a 100644 --- a/src/main/startup/main-process-i18n-menu.ts +++ b/src/main/startup/main-process-i18n-menu.ts @@ -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 { if (!store) { throw new Error('Store must be initialized before menu') } - await ensureMainI18n() await setMainUiLanguage(store.getSettings().uiLanguage) logStartupMilestone('i18n-ready') registerAppMenu({ diff --git a/src/main/window/renderer-recovery-prompt.test.ts b/src/main/window/renderer-recovery-prompt.test.ts index 4d3ea741460..995cd88f4dc 100644 --- a/src/main/window/renderer-recovery-prompt.test.ts +++ b/src/main/window/renderer-recovery-prompt.test.ts @@ -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 & { responses?: describe('presentRendererRecoveryPrompt', () => { beforeEach(async () => { - await ensureMainI18n() await mainI18n.changeLanguage('en') }) diff --git a/src/renderer/src/components/github-project/ProjectRoadmap.test.tsx b/src/renderer/src/components/github-project/ProjectRoadmap.test.tsx index 0f5a0a2e115..aa5fa24361c 100644 --- a/src/renderer/src/components/github-project/ProjectRoadmap.test.tsx +++ b/src/renderer/src/components/github-project/ProjectRoadmap.test.tsx @@ -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, diff --git a/src/renderer/src/i18n/i18n.ts b/src/renderer/src/i18n/i18n.ts index 1242d9d9e09..e92f778e1e0 100644 --- a/src/renderer/src/i18n/i18n.ts +++ b/src/renderer/src/i18n/i18n.ts @@ -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 tags would get English plurals; plugin-pack-plurals.test.ts guards i18next upgrades. -const pluralLocaleByResourceLanguage = new Map() -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() @@ -171,10 +167,9 @@ export async function setRendererUiLanguage(language: UiLanguage): Promise } } -const registeredPluginLanguages = new Set() let pluginLanguagePacks: readonly PluginLanguagePackRegistration[] = [] -/** Resolve an Intl-compatible BCP-47 tag: synthetic plugin 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) - } } diff --git a/src/renderer/src/i18n/intl-locale.test.ts b/src/renderer/src/i18n/intl-locale.test.ts index 86bbd59aad9..8268378f356 100644 --- a/src/renderer/src/i18n/intl-locale.test.ts +++ b/src/renderer/src/i18n/intl-locale.test.ts @@ -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`. + // 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) - }) }) diff --git a/src/renderer/src/i18n/weekday-names.ts b/src/renderer/src/i18n/weekday-names.ts index 9d5ecc856aa..f19c41b6146 100644 --- a/src/renderer/src/i18n/weekday-names.ts +++ b/src/renderer/src/i18n/weekday-names.ts @@ -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. diff --git a/src/shared/plugins/plugin-language-pack-artifact.test.ts b/src/shared/plugins/plugin-language-pack-artifact.test.ts index 2a79c8201b0..41a748eeb9a 100644 --- a/src/shared/plugins/plugin-language-pack-artifact.test.ts +++ b/src/shared/plugins/plugin-language-pack-artifact.test.ts @@ -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) }) diff --git a/src/shared/plugins/plugin-language-pack-artifact.ts b/src/shared/plugins/plugin-language-pack-artifact.ts index 598c7f936b0..20276c23efe 100644 --- a/src/shared/plugins/plugin-language-pack-artifact.ts +++ b/src/shared/plugins/plugin-language-pack-artifact.ts @@ -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 = diff --git a/src/shared/plugins/plugin-language-pack-i18n-resources.ts b/src/shared/plugins/plugin-language-pack-i18n-resources.ts new file mode 100644 index 00000000000..977dffa8d2d --- /dev/null +++ b/src/shared/plugins/plugin-language-pack-i18n-resources.ts @@ -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 + ) + } +}