mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 09:07:40 +00:00
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:
+19
-74
@@ -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))
|
||||
|
||||
+6
-6
@@ -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',
|
||||
|
||||
@@ -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}</>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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('舊卡片樣式')
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user