From c10fbcb0d28a7bb5aba0da910565bdb6b534580d Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Wed, 28 Oct 2026 20:21:12 -0700 Subject: [PATCH] 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 : initReactI18next already binds the instance. --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- src/main/i18n/main-i18n.ts | 93 +++----------- .../ContextualTourOverlay.test.tsx | 6 +- ...ontextual-tour-overlay-measurement.test.ts | 12 +- .../github-project/ProjectRoadmap.test.tsx | 6 +- .../src/components/settings/SshPane.test.tsx | 6 +- .../settings/jira-integration-card.test.tsx | 6 +- .../task-tracker-integration-cards.test.tsx | 8 +- .../agent-launch-pane-notice.test.ts | 4 +- src/renderer/src/i18n/I18nProvider.tsx | 4 +- src/renderer/src/i18n/i18n.ts | 115 ++++-------------- src/renderer/src/i18n/lazy-locale.test.ts | 33 +++-- .../src/i18n/localized-catalog.test.ts | 8 +- src/shared/orca-i18n-instance.ts | 93 ++++++++++++++ src/shared/ui-locale.ts | 56 +-------- 14 files changed, 187 insertions(+), 263 deletions(-) create mode 100644 src/shared/orca-i18n-instance.ts diff --git a/src/main/i18n/main-i18n.ts b/src/main/i18n/main-i18n.ts index b38936884af..2b5d7811a4e 100644 --- a/src/main/i18n/main-i18n.ts +++ b/src/main/i18n/main-i18n.ts @@ -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 Promise<{ default: Record }>>( + 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] - 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 { 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 { 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 -} diff --git a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.test.tsx b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.test.tsx index 1dc183c1a19..f2350c7002a 100644 --- a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.test.tsx +++ b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.test.tsx @@ -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)) diff --git a/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts b/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts index 1988845cda8..0c01acbd88d 100644 --- a/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts +++ b/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts @@ -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) diff --git a/src/renderer/src/components/github-project/ProjectRoadmap.test.tsx b/src/renderer/src/components/github-project/ProjectRoadmap.test.tsx index aa5fa24361c..01bccd22837 100644 --- a/src/renderer/src/components/github-project/ProjectRoadmap.test.tsx +++ b/src/renderer/src/components/github-project/ProjectRoadmap.test.tsx @@ -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( diff --git a/src/renderer/src/components/settings/SshPane.test.tsx b/src/renderer/src/components/settings/SshPane.test.tsx index 889c65eac63..d14b965a9ba 100644 --- a/src/renderer/src/components/settings/SshPane.test.tsx +++ b/src/renderer/src/components/settings/SshPane.test.tsx @@ -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 diff --git a/src/renderer/src/components/settings/jira-integration-card.test.tsx b/src/renderer/src/components/settings/jira-integration-card.test.tsx index 62734058dfc..f8207e09b11 100644 --- a/src/renderer/src/components/settings/jira-integration-card.test.tsx +++ b/src/renderer/src/components/settings/jira-integration-card.test.tsx @@ -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) => ({ diff --git a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx index f95b09808af..015c84b7a88 100644 --- a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx +++ b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx @@ -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, diff --git a/src/renderer/src/components/terminal-pane/agent-launch-pane-notice.test.ts b/src/renderer/src/components/terminal-pane/agent-launch-pane-notice.test.ts index 50552130cef..635f9455cef 100644 --- a/src/renderer/src/components/terminal-pane/agent-launch-pane-notice.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-launch-pane-notice.test.ts @@ -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', diff --git a/src/renderer/src/i18n/I18nProvider.tsx b/src/renderer/src/i18n/I18nProvider.tsx index c64c3878fee..0542b3b825b 100644 --- a/src/renderer/src/i18n/I18nProvider.tsx +++ b/src/renderer/src/i18n/I18nProvider.tsx @@ -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 {children} + // initReactI18next already binds useTranslation to this instance. + return <>{children} } diff --git a/src/renderer/src/i18n/i18n.ts b/src/renderer/src/i18n/i18n.ts index a07f1dae456..fc8306f0065 100644 --- a/src/renderer/src/i18n/i18n.ts +++ b/src/renderer/src/i18n/i18n.ts @@ -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, string> = { @@ -46,9 +37,7 @@ const NON_DEFAULT_LOCALE_URLS: Record, 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(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> { @@ -62,9 +51,11 @@ async function fetchLocaleCatalog(url: string): Promise> // The provider, onboarding switcher and backend can ask for one locale at once; share the fetch. const catalogRequests = new Map>>() -function loadLocaleCatalog(language: string, url: string): Promise> { +/** Undefined for English and unknown locales, which need no catalog load. */ +function loadLocaleCatalog(language: string): Promise> | 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 {}, - 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() 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 { - 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 { - 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) } diff --git a/src/renderer/src/i18n/lazy-locale.test.ts b/src/renderer/src/i18n/lazy-locale.test.ts index c1b70b62906..28ce5cda6bb 100644 --- a/src/renderer/src/i18n/lazy-locale.test.ts +++ b/src/renderer/src/i18n/lazy-locale.test.ts @@ -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') }) }) diff --git a/src/renderer/src/i18n/localized-catalog.test.ts b/src/renderer/src/i18n/localized-catalog.test.ts index 0dfea2dab39..942f2b044fe 100644 --- a/src/renderer/src/i18n/localized-catalog.test.ts +++ b/src/renderer/src/i18n/localized-catalog.test.ts @@ -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('舊卡片樣式') diff --git a/src/shared/orca-i18n-instance.ts b/src/shared/orca-i18n-instance.ts new file mode 100644 index 00000000000..bf73b634f46 --- /dev/null +++ b/src/shared/orca-i18n-instance.ts @@ -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> | undefined + englishResources: Record + 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) + } +} diff --git a/src/shared/ui-locale.ts b/src/shared/ui-locale.ts index 8cc2a3b13ca..3e12a8ee204 100644 --- a/src/shared/ui-locale.ts +++ b/src/shared/ui-locale.ts @@ -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 {