refactor(i18n): share one i18next setup between main and renderer (#35621)

* perf(i18n): load built-in catalogs once, without copies or a runtime plural fill

Each catalog load parsed the JSON, rebuilt it to fill CLDR plural forms
from _other, then deep-copied and deep-merged it into the store (about
26 ms after parsing per 1.3 MB catalog), and concurrent loads of one
locale fetched it twice.

- sync:localization-catalog now commits the plural forms a locale's
  CLDR rules need beside each _other (fr/es/pt many, tr one, a few
  de/ru), and verify:localization-catalog fails when one is missing.
  Plural forms may include {{count}} where English omits it.
- Renderer and main load built-in catalogs as-is; plugin packs keep the
  runtime fill.
- Renderer catalog requests are shared per locale; a timed-out request
  is dropped so a retry fetches again. Bundles are added with skipCopy.

* fix(i18n): require {{count}} in plural forms when English shows it

* refactor(i18n): share one i18next setup between main and renderer

- createOrcaI18n (src/shared/orca-i18n-instance.ts) owns the backend,
  init options, plugin-pack registration, resource-language resolution
  and the translate wrapper; main and renderer inject only how a
  built-in catalog loads (dynamic import vs. fetch) and extra modules.
- Remove setRendererUiLanguage (tests only); tests load then switch.
- resolveUiLocale maps built-ins by lookup instead of an if-chain, and
  catalog URLs/loaders are direct map lookups.
- Drop <I18nextProvider>: initReactI18next already binds the instance.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-28 20:21:12 -07:00
committed by GitHub
co-authored by OrcaWin
parent 73c6b6d2ef
commit c10fbcb0d2
14 changed files with 187 additions and 263 deletions
+19 -74
View File
@@ -1,22 +1,11 @@
import { getSystemLanguageDetection } from './system-language'
import i18next, {
type BackendModule,
type i18n as I18nInstance,
type ReadCallback,
type TOptions
} from 'i18next'
import type { i18n as I18nInstance } from 'i18next'
import { resolvePluralFamilyKey } from '../../shared/i18n-plural-family'
import { isPseudoLocalizationLocale, pseudoLocalizeString } from '../../shared/pseudo-localization'
import { createOrcaI18n } from '../../shared/orca-i18n-instance'
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 languageGeneration = 0
let pluginLanguagePacks: readonly PluginLanguagePackRegistration[] = []
// English uses caller fallbacks; load only the selected non-English catalog after startup.
const LAZY_LOCALE_LOADERS: Record<
@@ -34,57 +23,32 @@ const LAZY_LOCALE_LOADERS: Record<
zh: () => import('../../renderer/src/i18n/locales/zh.json'),
'zh-Hant': () => import('../../renderer/src/i18n/locales/zh-Hant.json')
}
const LAZY_LOCALE_LOADER_MAP = new Map<string, () => Promise<{ default: Record<string, unknown> }>>(
Object.entries(LAZY_LOCALE_LOADERS)
)
const lazyLocaleBackend: BackendModule = {
type: 'backend',
init: () => {},
read: (language: string, _namespace: string, callback: ReadCallback) => {
const loader = LAZY_LOCALE_LOADERS[language as Exclude<SupportedUiLocale, 'en'>]
if (!loader) {
// English comes from translateMain defaultValue fallbacks.
callback(null, false)
return
}
loader().then(
(mod) => callback(null, mod.default),
(error) => callback(error instanceof Error ? error : new Error(String(error)), false)
)
}
}
// Initializes synchronously, so startup dialogs can translate before settings load.
const orcaI18n = createOrcaI18n({
loadCatalog: (language) => LAZY_LOCALE_LOADER_MAP.get(language)?.().then((mod) => mod.default),
englishResources: {}
})
export const mainI18n: I18nInstance = orcaI18n.i18n
export const translateMain = orcaI18n.translate
export const setMainPluginLanguagePacks = orcaI18n.setPluginLanguagePacks
export function getMainSystemLocale(): string {
return getSystemLanguageDetection()?.locale ?? DEFAULT_UI_LOCALE
}
// 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
}
})
export async function setMainUiLanguage(language: UiLanguage): Promise<string> {
const request = ++languageGeneration
const selectedLocale = resolveUiLocale(
language,
language === UI_LANGUAGE_SYSTEM ? getMainSystemLocale() : DEFAULT_UI_LOCALE
const locale = orcaI18n.resolveResourceLanguage(
resolveUiLocale(
language,
language === UI_LANGUAGE_SYSTEM ? getMainSystemLocale() : DEFAULT_UI_LOCALE
)
)
const locale =
pluginLanguagePacks.find((pack) => pack.id === selectedLocale)?.resourceLanguage ??
(selectedLocale.startsWith('plugin:') ? DEFAULT_UI_LOCALE : selectedLocale)
if (mainI18n.language !== locale) {
await mainI18n.loadLanguages(locale)
if (request !== languageGeneration) {
@@ -106,22 +70,3 @@ export async function prepareEarlyMainUiLanguage(): Promise<void> {
console.warn('[i18n] Could not localize early startup dialogs:', error)
}
}
export function setMainPluginLanguagePacks(
packs: readonly PluginLanguagePackRegistration[]
): boolean {
if (pluginLanguagePacks === packs) {
return false
}
const changed = replacePluginLanguagePackResources(mainI18n, pluginLanguagePacks, packs)
pluginLanguagePacks = packs
return changed.size > 0
}
export function translateMain(key: string, fallback: string, options?: TOptions): string {
const value = mainI18n.t(resolvePluralFamilyKey(key, options), {
defaultValue: fallback,
...options
})
return isPseudoLocalizationLocale(mainI18n.language) ? pseudoLocalizeString(value) : value
}
@@ -14,7 +14,7 @@ import {
} from './ContextualTourOverlaySurface'
import { getContextualTourPanelHost } from './contextual-tour-gate'
import { useAppStore } from '@/store'
import { setRendererUiLanguage } from '@/i18n/i18n'
import { i18n, loadRendererUiLanguage } from '@/i18n/i18n'
const baseRenderState: ActiveTourRenderState = {
rect: {
@@ -381,11 +381,11 @@ function renderLocalizedSurface(isLastStep: boolean): ReactElement {
describe('ContextualTourOverlaySurface localization', () => {
afterEach(async () => {
await setRendererUiLanguage('en')
await i18n.changeLanguage(await loadRendererUiLanguage('en'))
})
it('renders default tour actions in Korean when the UI locale is Korean', async () => {
await setRendererUiLanguage('ko')
await i18n.changeLanguage(await loadRendererUiLanguage('ko'))
const firstStep = renderToStaticMarkup(renderLocalizedSurface(false))
const finalStep = renderToStaticMarkup(renderLocalizedSurface(true))
@@ -2,7 +2,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getContextualTour } from '../../../../shared/contextual-tours'
import { setRendererUiLanguage } from '@/i18n/i18n'
import { i18n, loadRendererUiLanguage } from '@/i18n/i18n'
import {
getContextualTourDisplayProgress,
getContextualTourMeasurementAction,
@@ -12,12 +12,12 @@ import {
afterEach(async () => {
document.body.replaceChildren()
await setRendererUiLanguage('en')
await i18n.changeLanguage(await loadRendererUiLanguage('en'))
})
describe('contextual tour overlay measurement', () => {
it('renders the automation tour copy in Korean when the UI locale is Korean', async () => {
await setRendererUiLanguage('ko')
await i18n.changeLanguage(await loadRendererUiLanguage('ko'))
const target = document.createElement('button')
target.setAttribute('data-contextual-tour-target', 'automations-create')
target.getBoundingClientRect = () => new DOMRect(0, 0, 20, 20)
@@ -42,7 +42,7 @@ describe('contextual tour overlay measurement', () => {
})
it('renders the automation results step in Korean when the UI locale is Korean', async () => {
await setRendererUiLanguage('ko')
await i18n.changeLanguage(await loadRendererUiLanguage('ko'))
const target = document.createElement('div')
target.setAttribute('data-contextual-tour-target', 'automations-runs')
target.getBoundingClientRect = () => new DOMRect(0, 0, 20, 20)
@@ -67,7 +67,7 @@ describe('contextual tour overlay measurement', () => {
})
it('localizes the title, shortcut body, and action label of non-automation steps', async () => {
await setRendererUiLanguage('ko')
await i18n.changeLanguage(await loadRendererUiLanguage('ko'))
const target = document.createElement('div')
target.setAttribute('data-contextual-tour-target', 'terminal-pane-split-target')
target.getBoundingClientRect = () => new DOMRect(0, 0, 20, 20)
@@ -95,7 +95,7 @@ describe('contextual tour overlay measurement', () => {
})
it('keeps localized copy on its own step when a step is inserted before it', async () => {
await setRendererUiLanguage('ko')
await i18n.changeLanguage(await loadRendererUiLanguage('ko'))
const target = document.createElement('button')
target.setAttribute('data-contextual-tour-target', 'automations-create')
target.getBoundingClientRect = () => new DOMRect(0, 0, 20, 20)
@@ -4,7 +4,7 @@ import type { ReactNode } from 'react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import ProjectRoadmap from './ProjectRoadmap'
import { i18n, setRendererPluginLanguagePacks, setRendererUiLanguage } from '@/i18n/i18n'
import { i18n, loadRendererUiLanguage, setRendererPluginLanguagePacks } from '@/i18n/i18n'
import { DEFAULT_LOCALE } from '@/i18n/supported-languages'
import { pluginLanguageResourceId } from '../../../../shared/plugins/plugin-language-pack-artifact'
import type {
@@ -102,7 +102,7 @@ afterEach(async () => {
vi.useRealTimers()
window.localStorage.clear()
setRendererPluginLanguagePacks([])
await setRendererUiLanguage(DEFAULT_LOCALE)
await i18n.changeLanguage(await loadRendererUiLanguage(DEFAULT_LOCALE))
})
describe('ProjectRoadmap', () => {
@@ -121,7 +121,7 @@ describe('ProjectRoadmap', () => {
catalog: { common: { cancel: 'Cancelar' } }
}
])
await setRendererUiLanguage(packId)
await i18n.changeLanguage(await loadRendererUiLanguage(packId))
expect(i18n.resolvedLanguage).toBe(resourceLanguage)
render(
@@ -5,7 +5,7 @@ import { createRoot } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { resetSshConfigPassiveSyncForTests } from '../../lib/ssh-config-passive-sync'
import type { SshConnectionState, SshTarget } from '../../../../shared/ssh-types'
import { setRendererUiLanguage } from '@/i18n/i18n'
import { i18n, loadRendererUiLanguage } from '@/i18n/i18n'
import { TooltipProvider } from '@/components/ui/tooltip'
import { SshPane } from './SshPane'
@@ -95,14 +95,14 @@ afterEach(async () => {
resetSshConfigPassiveSyncForTests()
mocks.connectionStates.clear()
vi.clearAllMocks()
await setRendererUiLanguage('en')
await i18n.changeLanguage(await loadRendererUiLanguage('en'))
})
describe('SSH import count notices', () => {
it.each(['en', 'ko'] as const)(
'uses complete %s sentences and preserves the no-change branch',
async (language) => {
await setRendererUiLanguage(language)
await i18n.changeLanguage(await loadRendererUiLanguage(language))
const importConfig = vi.fn(
async (_args?: {
reAdopt?: boolean
@@ -3,7 +3,7 @@
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { setRendererUiLanguage } from '@/i18n/i18n'
import { i18n, loadRendererUiLanguage } from '@/i18n/i18n'
import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
import { JiraIntegrationCard } from './jira-integration-card'
@@ -93,13 +93,13 @@ describe('JiraIntegrationCard account scope', () => {
container?.remove()
container = null
mocks.store.current = null
await setRendererUiLanguage('en')
await i18n.changeLanguage(await loadRendererUiLanguage('en'))
})
it.each(['en', 'ko'] as const)(
'renders complete %s connection counts without an English suffix',
async (language) => {
await setRendererUiLanguage(language)
await i18n.changeLanguage(await loadRendererUiLanguage(language))
for (const count of [0, 1, 2]) {
const state = installStore({ activeRuntimeEnvironmentId: null })
state.jiraStatus.sites = Array.from({ length: count }, (_, index) => ({
@@ -5,7 +5,7 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getExecutionHostLabel } from '../../../../shared/execution-host'
import es from '@/i18n/locales/es.json'
import { setRendererUiLanguage, translate } from '@/i18n/i18n'
import { i18n, loadRendererUiLanguage, translate } from '@/i18n/i18n'
import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
import { TooltipProvider } from '../ui/tooltip'
import { LinearIntegrationCard } from './task-tracker-integration-cards'
@@ -122,14 +122,14 @@ describe('LinearIntegrationCard account scope', () => {
container?.remove()
container = null
mocks.store.current = null
await setRendererUiLanguage('en')
await i18n.changeLanguage(await loadRendererUiLanguage('en'))
Reflect.deleteProperty(window, 'api')
})
it.each(['en', 'ko'] as const)(
'renders complete %s connection counts without an English suffix',
async (language) => {
await setRendererUiLanguage(language)
await i18n.changeLanguage(await loadRendererUiLanguage(language))
for (const count of [0, 1, 2]) {
const state = installStore(true)
state.linearStatus.workspaces = Array.from({ length: count }, (_, index) => ({
@@ -157,7 +157,7 @@ describe('LinearIntegrationCard account scope', () => {
)
it('translates a coded connection failure at the display site', async () => {
await setRendererUiLanguage('es')
await i18n.changeLanguage(await loadRendererUiLanguage('es'))
const state = installStore(true)
state.testLinearConnection = vi.fn(async () => ({
ok: false,
@@ -3,7 +3,7 @@
import React from 'react'
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { i18n, setRendererUiLanguage } from '@/i18n/i18n'
import { i18n, loadRendererUiLanguage } from '@/i18n/i18n'
import { AgentLaunchPaneNotice } from './AgentLaunchPaneNotice'
import { TerminalErrorToast } from './TerminalErrorToast'
import { agentLaunchPaneOutcomeForLeaf } from './agent-launch-pane-notice-text'
@@ -30,7 +30,7 @@ describe('localized startup failure surfaces', () => {
unknown: '无法确认 Codex 已启动。请先检查其终端,再重试。'
}
] as const)('renders both real mappings with the shipped $language catalog', async (copy) => {
await setRendererUiLanguage(copy.language)
await i18n.changeLanguage(await loadRendererUiLanguage(copy.language))
for (const [code, expected, refusal] of [
[
'agent_session_exited_during_start',
+2 -2
View File
@@ -1,5 +1,4 @@
import { useEffect, useRef, type ReactNode } from 'react'
import { I18nextProvider } from 'react-i18next'
import { useAppStore } from '../store'
import { i18n, setRendererPluginLanguagePacks, loadRendererUiLanguage } from './i18n'
import { usePluginLanguagePacks } from '../store/plugin-language-packs'
@@ -62,5 +61,6 @@ export function I18nProvider({ children }: { children: ReactNode }): React.JSX.E
}
}, [uiLanguage, activePack, retryCount, systemLocale])
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>
// initReactI18next already binds useTranslation to this instance.
return <>{children}</>
}
+25 -90
View File
@@ -1,9 +1,4 @@
import i18next, {
type BackendModule,
type i18n as I18nInstance,
type ReadCallback,
type TOptions
} from 'i18next'
import type { TOptions } from 'i18next'
import { initReactI18next } from 'react-i18next'
import enRuntimeRequired from './en-runtime-required.json'
@@ -17,20 +12,16 @@ import ruCatalogUrl from './locales/ru.json?url'
import trCatalogUrl from './locales/tr.json?url'
import zhCatalogUrl from './locales/zh.json?url'
import zhHantCatalogUrl from './locales/zh-Hant.json?url'
import { isPseudoLocalizationLocale, pseudoLocalizeString } from './pseudo-localization'
import {
advanceTranslationResourceVersion,
getTranslationResourceVersion
} from './translation-resource-version'
import { DEFAULT_LOCALE, resolveUiLocale } from './supported-languages'
import type { SupportedUiLocale } from '../../../shared/ui-locale'
import { isPluginUiLanguage, type UiLanguage } from '../../../shared/ui-language'
import 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 } from '../../../shared/i18n-plural-family'
export const i18n: I18nInstance = i18next.createInstance()
import { createOrcaI18n } from '../../../shared/orca-i18n-instance'
// Fetch catalogs as data: Chromium caches rejected module imports for the document's lifetime.
const NON_DEFAULT_LOCALE_URLS: Record<Exclude<SupportedUiLocale, 'en'>, string> = {
@@ -46,9 +37,7 @@ const NON_DEFAULT_LOCALE_URLS: Record<Exclude<SupportedUiLocale, 'en'>, string>
'zh-Hant': zhHantCatalogUrl
}
function localeCatalogUrl(language: string): string | undefined {
return Object.entries(NON_DEFAULT_LOCALE_URLS).find(([name]) => name === language)?.[1]
}
const LOCALE_CATALOG_URLS = new Map<string, string>(Object.entries(NON_DEFAULT_LOCALE_URLS))
// Built-in catalogs ship every CLDR plural form (sync:localization-catalog), so they load as-is.
async function fetchLocaleCatalog(url: string): Promise<Record<string, unknown>> {
@@ -62,9 +51,11 @@ async function fetchLocaleCatalog(url: string): Promise<Record<string, unknown>>
// The provider, onboarding switcher and backend can ask for one locale at once; share the fetch.
const catalogRequests = new Map<string, Promise<Record<string, unknown>>>()
function loadLocaleCatalog(language: string, url: string): Promise<Record<string, unknown>> {
/** Undefined for English and unknown locales, which need no catalog load. */
function loadLocaleCatalog(language: string): Promise<Record<string, unknown>> | undefined {
const pending = catalogRequests.get(language)
if (pending) {
const url = LOCALE_CATALOG_URLS.get(language)
if (pending || !url) {
return pending
}
const request = fetchLocaleCatalog(url).finally(() => {
@@ -76,48 +67,15 @@ function loadLocaleCatalog(language: string, url: string): Promise<Record<string
return request
}
const lazyLocaleBackend: BackendModule = {
type: 'backend',
init: () => {},
read: (language: string, _namespace: string, callback: ReadCallback) => {
const url = localeCatalogUrl(language)
if (!url) {
// Bundled English/unknown locales need no backend load; let i18next use in-memory fallback.
callback(null, false)
return
}
loadLocaleCatalog(language, url).then(
(catalog) => callback(null, catalog),
(error) => callback(error instanceof Error ? error : new Error(String(error)), false)
)
}
}
const orcaI18n = createOrcaI18n({
loadCatalog: loadLocaleCatalog,
// Inline defaults cover most English; sync:localization-runtime-catalog retains plural/dynamic/edited entries. en.json remains the translator source for lazy catalogs.
englishResources: enRuntimeRequired,
modules: [initReactI18next],
initOptions: { react: { useSuspense: false } }
})
void i18n
.use(lazyLocaleBackend)
.use(initReactI18next)
.init({
fallbackLng: DEFAULT_LOCALE,
lng: DEFAULT_LOCALE,
// Default 'all' expands zh-Hant to [zh-Hant, zh, en], so missing keys would render Simplified.
load: 'currentOnly',
// Serve eager English from resources while the backend lazily supplies other languages.
partialBundledLanguages: true,
resources: {
en: {
// Inline defaults cover most English; sync:localization-runtime-catalog retains plural/dynamic/edited entries. en.json remains the translator source for lazy catalogs.
translation: enRuntimeRequired
}
},
// An empty catalog value falls back to English instead of rendering blank.
returnEmptyString: false,
interpolation: {
escapeValue: false
},
react: {
useSuspense: false
}
})
export const i18n = orcaI18n.i18n
// Plugin packs can replace translations without changing the active language.
i18n.store.on('added', advanceTranslationResourceVersion)
@@ -129,14 +87,9 @@ const PLAIN_TRANSLATION_CACHE_LIMIT = 5000
const plainTranslationCache = new Map<string, string>()
let plainTranslationCacheScope = ''
function translateUncached(key: string, fallback: string, options?: TOptions): string {
const value = i18n.t(resolvePluralFamilyKey(key, options), { defaultValue: fallback, ...options })
return isPseudoLocalizationLocale(i18n.language) ? pseudoLocalizeString(value) : value
}
export function translate(key: string, fallback: string, options?: TOptions): string {
if (options !== undefined) {
return translateUncached(key, fallback, options)
return orcaI18n.translate(key, fallback, options)
}
const scope = `${i18n.isInitialized}\0${i18n.language}\0${getTranslationResourceVersion()}`
if (
@@ -151,21 +104,17 @@ export function translate(key: string, fallback: string, options?: TOptions): st
if (cached !== undefined) {
return cached
}
const value = translateUncached(key, fallback)
const value = orcaI18n.translate(key, fallback)
plainTranslationCache.set(cacheKey, value)
return value
}
export async function loadRendererUiLanguage(language: UiLanguage): Promise<string> {
const resolved = resolveUiLocale(language)
const resourceLanguage = resolveRendererResourceLanguage(resolved)
const locale =
isPluginUiLanguage(language) && resourceLanguage === resolved
? DEFAULT_LOCALE
: resourceLanguage
const url = localeCatalogUrl(locale)
if (url && !i18n.hasResourceBundle(locale, 'translation')) {
const request = loadLocaleCatalog(locale, url)
const locale = orcaI18n.resolveResourceLanguage(resolveUiLocale(language))
const request = i18n.hasResourceBundle(locale, 'translation')
? undefined
: loadLocaleCatalog(locale)
if (request) {
const catalog = await withTimeout(request, 5000, null)
if (catalog === null) {
// A retry after a timeout must start a fresh request instead of joining the stalled one.
@@ -182,19 +131,10 @@ export async function loadRendererUiLanguage(language: UiLanguage): Promise<stri
return locale
}
export async function setRendererUiLanguage(language: UiLanguage): Promise<void> {
const locale = await loadRendererUiLanguage(language)
if (i18n.language !== locale) {
await i18n.changeLanguage(locale)
}
}
let pluginLanguagePacks: readonly PluginLanguagePackRegistration[] = []
/** 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)
const pack = orcaI18n.getPluginLanguagePacks().find((entry) => entry.resourceLanguage === active)
return supportedIntlLocale(pack?.locale ?? active, Intl.DateTimeFormat)
}
@@ -210,13 +150,8 @@ function supportedIntlLocale(
}
}
export function resolveRendererResourceLanguage(language: string): string {
return pluginLanguagePacks.find((pack) => pack.id === language)?.resourceLanguage ?? language
}
export function setRendererPluginLanguagePacks(
packs: readonly PluginLanguagePackRegistration[]
): void {
replacePluginLanguagePackResources(i18n, pluginLanguagePacks, packs)
pluginLanguagePacks = packs
orcaI18n.setPluginLanguagePacks(packs)
}
+14 -19
View File
@@ -8,12 +8,7 @@ import {
UI_LANGUAGE_RUSSIAN,
UI_LANGUAGE_SPANISH
} from '../../../shared/ui-language'
import {
i18n,
loadRendererUiLanguage,
setRendererPluginLanguagePacks,
setRendererUiLanguage
} from './i18n'
import { i18n, loadRendererUiLanguage, setRendererPluginLanguagePacks } from './i18n'
import { pluginLanguageResourceId } from '../../../shared/plugins/plugin-language-pack-artifact'
// Every changeLanguage caller must transparently load real non-English translations before its promise settles.
@@ -28,8 +23,8 @@ describe('renderer i18n lazy locale loading', () => {
expect(i18n.t('menu.file', { defaultValue: 'File' })).toBe('File')
})
it('lazy-loads Spanish via setRendererUiLanguage before it resolves', async () => {
await setRendererUiLanguage(UI_LANGUAGE_SPANISH)
it('lazy-loads Spanish before switching to it', async () => {
await i18n.changeLanguage(await loadRendererUiLanguage(UI_LANGUAGE_SPANISH))
expect(i18n.language).toBe('es')
expect(i18n.t('menu.file', { defaultValue: 'File' })).toBe('Archivo')
})
@@ -39,14 +34,14 @@ describe('renderer i18n lazy locale loading', () => {
expect(i18n.t('menu.file', { defaultValue: 'File' })).not.toBe('File')
})
it('lazy-loads Portuguese via setRendererUiLanguage before it resolves', async () => {
await setRendererUiLanguage(UI_LANGUAGE_PORTUGUESE)
it('lazy-loads Portuguese before switching to it', async () => {
await i18n.changeLanguage(await loadRendererUiLanguage(UI_LANGUAGE_PORTUGUESE))
expect(i18n.language).toBe('pt')
expect(i18n.t('menu.file', { defaultValue: 'File' })).not.toBe('File')
})
it('loads Russian and selects its complete CLDR plural family', async () => {
await setRendererUiLanguage(UI_LANGUAGE_RUSSIAN)
await i18n.changeLanguage(await loadRendererUiLanguage(UI_LANGUAGE_RUSSIAN))
expect(i18n.language).toBe('ru')
expect(i18n.t('menu.file', { defaultValue: 'File' })).toBe('Файл')
for (const [count, expected] of [
@@ -71,7 +66,7 @@ describe('renderer i18n lazy locale loading', () => {
it('falls back from Traditional Chinese to English, never Simplified', async () => {
i18n.addResourceBundle('zh', 'translation', { zhOnlyKey: '简体' }, true, true)
await setRendererUiLanguage(UI_LANGUAGE_CHINESE_TRADITIONAL)
await i18n.changeLanguage(await loadRendererUiLanguage(UI_LANGUAGE_CHINESE_TRADITIONAL))
expect(i18n.language).toBe('zh-Hant')
expect(i18n.t('menu.file', { defaultValue: 'File' })).not.toBe('File')
expect(i18n.t('zhOnlyKey', { defaultValue: 'English' })).toBe('English')
@@ -102,11 +97,11 @@ describe('renderer i18n lazy locale loading', () => {
)
}
try {
await expect(setRendererUiLanguage('ja')).rejects.toThrow('Language catalog unavailable')
await expect(loadRendererUiLanguage('ja')).rejects.toThrow('Language catalog unavailable')
expect(i18n.language).toBe('en')
expect(i18n.hasResourceBundle('ja', 'translation')).toBe(false)
await setRendererUiLanguage('ja')
await i18n.changeLanguage(await loadRendererUiLanguage('ja'))
expect(i18n.language).toBe('ja')
expect(i18n.t('menu.file', { defaultValue: 'File' })).toBe('ファイル')
expect(request).toHaveBeenCalledTimes(2)
@@ -117,17 +112,17 @@ describe('renderer i18n lazy locale loading', () => {
)
it('uses the inline English default when a target catalog omits a key', async () => {
await setRendererUiLanguage(UI_LANGUAGE_SPANISH)
await i18n.changeLanguage(await loadRendererUiLanguage(UI_LANGUAGE_SPANISH))
expect(i18n.t('missing.renderer.feature', { defaultValue: 'English fallback' })).toBe(
'English fallback'
)
})
it('returns to English from a lazily-loaded locale', async () => {
await setRendererUiLanguage(UI_LANGUAGE_SPANISH)
await i18n.changeLanguage(await loadRendererUiLanguage(UI_LANGUAGE_SPANISH))
expect(i18n.t('menu.file', { defaultValue: 'File' })).toBe('Archivo')
await setRendererUiLanguage(UI_LANGUAGE_ENGLISH)
await i18n.changeLanguage(await loadRendererUiLanguage(UI_LANGUAGE_ENGLISH))
expect(i18n.t('menu.file', { defaultValue: 'File' })).toBe('File')
})
@@ -143,12 +138,12 @@ describe('renderer i18n lazy locale loading', () => {
}
])
await setRendererUiLanguage(id)
await i18n.changeLanguage(await loadRendererUiLanguage(id))
expect(i18n.language).toBe(pluginLanguageResourceId(id))
expect(i18n.t('menu.file', { defaultValue: 'File' })).toBe('Arquivo Orca')
setRendererPluginLanguagePacks([])
await setRendererUiLanguage(id)
await i18n.changeLanguage(await loadRendererUiLanguage(id))
expect(i18n.language).toBe('en')
})
})
@@ -4,7 +4,7 @@ import {
getExperimentalPaneSearchEntries,
getExperimentalSearchEntry
} from '../components/settings/experimental-search'
import { i18n, setRendererPluginLanguagePacks, setRendererUiLanguage, translate } from './i18n'
import { i18n, loadRendererUiLanguage, setRendererPluginLanguagePacks, translate } from './i18n'
import { createLocalizedCatalog } from './localized-catalog'
const PACK_ID = 'plugin:orca-test.traditional-chinese/zh-TW' as const
@@ -56,7 +56,7 @@ describe('localized catalog caching', () => {
it('refreshes a cached fallback when resources are added to the active language', async () => {
setRendererPluginLanguagePacks([PACK])
await setRendererUiLanguage(PACK_ID)
await i18n.changeLanguage(await loadRendererUiLanguage(PACK_ID))
const getCatalog = createLocalizedCatalog(() => ({ title: translate('menu.file', 'File') }))
expect(getCatalog().title).toBe('File')
@@ -69,7 +69,7 @@ describe('localized catalog caching', () => {
it('refreshes to the fallback when the active plugin resources are removed', async () => {
setRendererPluginLanguagePacks([{ ...PACK, catalog: { menu: { file: '檔案' } } }])
await setRendererUiLanguage(PACK_ID)
await i18n.changeLanguage(await loadRendererUiLanguage(PACK_ID))
const getCatalog = createLocalizedCatalog(() => ({ title: translate('menu.file', 'File') }))
expect(getCatalog().title).toBe('檔案')
@@ -98,7 +98,7 @@ describe('localized catalog caching', () => {
}
})
setRendererPluginLanguagePacks([packWithTitles('終端關注', '舊卡片樣式')])
await setRendererUiLanguage(PACK_ID)
await i18n.changeLanguage(await loadRendererUiLanguage(PACK_ID))
const original = getExperimentalPaneSearchEntries()
expect(getExperimentalSearchEntry().terminalAttention.title).toBe('終端關注')
expect(getExperimentalSearchEntry().newWorktreeCardStyle.title).toBe('舊卡片樣式')
+93
View File
@@ -0,0 +1,93 @@
import i18next, {
type BackendModule,
type i18n as I18nInstance,
type InitOptions,
type Module,
type ReadCallback,
type TOptions
} from 'i18next'
import { resolvePluralFamilyKey } from './i18n-plural-family'
import { isPseudoLocalizationLocale, pseudoLocalizeString } from './pseudo-localization'
import { DEFAULT_UI_LOCALE } from './ui-locale'
import type { PluginLanguagePackRegistration } from './plugins/plugin-language-pack-artifact'
import { replacePluginLanguagePackResources } from './plugins/plugin-language-pack-i18n-resources'
export type OrcaI18n = {
i18n: I18nInstance
translate: (key: string, fallback: string, options?: TOptions) => string
/** Returns whether any pack's resources changed. */
setPluginLanguagePacks: (packs: readonly PluginLanguagePackRegistration[]) => boolean
getPluginLanguagePacks: () => readonly PluginLanguagePackRegistration[]
/** Maps a resolved UI locale to its i18next resource language; an unknown pack falls back to English. */
resolveResourceLanguage: (locale: string) => string
}
/** Main and renderer share one setup; only how a built-in catalog loads differs. */
export function createOrcaI18n(options: {
/** Undefined for English and unknown locales, which use caller fallbacks. */
loadCatalog: (language: string) => Promise<Record<string, unknown>> | undefined
englishResources: Record<string, unknown>
modules?: Module[]
initOptions?: InitOptions
}): OrcaI18n {
const i18n = i18next.createInstance()
let pluginLanguagePacks: readonly PluginLanguagePackRegistration[] = []
const backend: BackendModule = {
type: 'backend',
init: () => {},
read: (language: string, _namespace: string, callback: ReadCallback) => {
const load = options.loadCatalog(language)
if (!load) {
callback(null, false)
return
}
load.then(
(catalog) => callback(null, catalog),
(error) => callback(error instanceof Error ? error : new Error(String(error)), false)
)
}
}
i18n.use(backend)
for (const module of options.modules ?? []) {
i18n.use(module)
}
// In-memory resources make init synchronous, so callers can translate at module load.
void i18n.init({
fallbackLng: DEFAULT_UI_LOCALE,
lng: DEFAULT_UI_LOCALE,
// Default 'all' expands zh-Hant to [zh-Hant, zh, en], so missing keys would render Simplified.
load: 'currentOnly',
// Serve eager English from resources while the backend lazily supplies other languages.
partialBundledLanguages: true,
resources: { [DEFAULT_UI_LOCALE]: { translation: options.englishResources } },
// An empty catalog value falls back to English instead of rendering blank.
returnEmptyString: false,
interpolation: { escapeValue: false },
...options.initOptions
})
return {
i18n,
translate: (key, fallback, translateOptions) => {
const value = i18n.t(resolvePluralFamilyKey(key, translateOptions), {
defaultValue: fallback,
...translateOptions
})
return isPseudoLocalizationLocale(i18n.language) ? pseudoLocalizeString(value) : value
},
setPluginLanguagePacks: (packs) => {
if (packs === pluginLanguagePacks) {
return false
}
const changed = replacePluginLanguagePackResources(i18n, pluginLanguagePacks, packs)
pluginLanguagePacks = packs
return changed.size > 0
},
getPluginLanguagePacks: () => pluginLanguagePacks,
resolveResourceLanguage: (locale) =>
pluginLanguagePacks.find((pack) => pack.id === locale)?.resourceLanguage ??
(locale.startsWith('plugin:') ? DEFAULT_UI_LOCALE : locale)
}
}
+6 -50
View File
@@ -1,19 +1,4 @@
import {
UI_LANGUAGE_CHINESE,
UI_LANGUAGE_CHINESE_TRADITIONAL,
UI_LANGUAGE_ENGLISH,
UI_LANGUAGE_FRENCH,
UI_LANGUAGE_GERMAN,
UI_LANGUAGE_JAPANESE,
UI_LANGUAGE_KOREAN,
UI_LANGUAGE_PORTUGUESE,
UI_LANGUAGE_RUSSIAN,
UI_LANGUAGE_SPANISH,
UI_LANGUAGE_SYSTEM,
UI_LANGUAGE_TURKISH,
isPluginUiLanguage,
type UiLanguage
} from './ui-language'
import { UI_LANGUAGE_SYSTEM, isPluginUiLanguage, type UiLanguage } from './ui-language'
export const SUPPORTED_UI_LOCALES = [
'en',
@@ -146,40 +131,11 @@ export function resolveUiLocale(
if (isPluginUiLanguage(language)) {
return language
}
if (language === UI_LANGUAGE_ENGLISH) {
return DEFAULT_UI_LOCALE
}
if (language === UI_LANGUAGE_CHINESE) {
return 'zh'
}
if (language === UI_LANGUAGE_CHINESE_TRADITIONAL) {
return 'zh-Hant'
}
if (language === UI_LANGUAGE_KOREAN) {
return 'ko'
}
if (language === UI_LANGUAGE_JAPANESE) {
return 'ja'
}
if (language === UI_LANGUAGE_SPANISH) {
return 'es'
}
if (language === UI_LANGUAGE_FRENCH) {
return 'fr'
}
if (language === UI_LANGUAGE_PORTUGUESE) {
return 'pt'
}
if (language === UI_LANGUAGE_RUSSIAN) {
return 'ru'
}
if (language === UI_LANGUAGE_TURKISH) {
return 'tr'
}
if (language === UI_LANGUAGE_GERMAN) {
return 'de'
}
return normalizeSupportedUiLocale(systemLocale)
// Built-in UI language values are their own locale codes; only 'system' needs detection.
return (
SUPPORTED_UI_LOCALES.find((locale) => locale === language) ??
normalizeSupportedUiLocale(systemLocale)
)
}
export function getRendererSystemLocale(): string {