diff --git a/lib/__tests__/plugin-i18n.test.ts b/lib/__tests__/plugin-i18n.test.ts new file mode 100644 index 000000000..eced2762a --- /dev/null +++ b/lib/__tests__/plugin-i18n.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { lookup, interpolate, localeChain } from '../plugin-sandbox/plugin-i18n'; + +const tables = { + en: { hello: 'Hello {name}', only_en: 'English only' }, + de: { hello: 'Hallo {name}' }, + 'zh-TW': { hello: '你好 {name}' }, +}; + +describe('plugin translations', () => { + it('falls back from a regional locale to its language', () => { + // de-CH used to skip straight to English. + expect(lookup(tables, 'de-CH', 'hello')).toBe('Hallo {name}'); + }); + + it('matches locale names regardless of case', () => { + expect(lookup(tables, 'zh-tw', 'hello')).toBe('你好 {name}'); + }); + + it('falls back to English, then reports a missing key', () => { + expect(lookup(tables, 'de', 'only_en')).toBe('English only'); + expect(lookup(tables, 'de', 'missing')).toBeUndefined(); + }); + + it('builds the chain most specific first', () => { + expect(localeChain('pt-BR')).toEqual(['pt-BR', 'pt', 'en']); + }); + + it('fills placeholders', () => { + expect(interpolate('Hallo {name}', { name: 'Ana' })).toBe('Hallo Ana'); + }); +}); diff --git a/lib/__tests__/plugin-sandbox-loader.test.ts b/lib/__tests__/plugin-sandbox-loader.test.ts index 1df200de3..70e0b3b03 100644 --- a/lib/__tests__/plugin-sandbox-loader.test.ts +++ b/lib/__tests__/plugin-sandbox-loader.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ downloadManagedBundle: vi.fn(), createBackgroundInstance: vi.fn(), registerShortcuts: vi.fn((..._args: unknown[]) => () => {}), + setLocale: vi.fn(), })); vi.mock('../plugin-storage', () => ({ @@ -22,6 +23,7 @@ vi.mock('../plugin-sandbox/bundle-fetch', () => ({ })); vi.mock('../plugin-sandbox/host-bridge', () => ({ createBackgroundInstance: (...args: unknown[]) => mocks.createBackgroundInstance(...args), + forEachInstance: (_id: string | null, fn: (i: { setLocale: (l: string) => void }) => void) => fn({ setLocale: mocks.setLocale }), SandboxInstance: class {}, })); vi.mock('../plugin-sandbox/host-api', () => ({ @@ -31,7 +33,7 @@ vi.mock('../plugin-sandbox/shortcuts', () => ({ registerShortcuts: (...args: unknown[]) => mocks.registerShortcuts(...args), })); -import { loadSandboxedPlugin, unloadSandboxedPlugin, setSandboxStoreAccessor } from '../plugin-sandbox/loader'; +import { loadSandboxedPlugin, unloadSandboxedPlugin, setSandboxStoreAccessor, setSandboxLocale } from '../plugin-sandbox/loader'; import { get as getActive } from '../plugin-sandbox/registry'; const CODE = 'export default { activate() {} }'; @@ -205,3 +207,15 @@ describe('plugin shortcuts', () => { expect(mocks.registerShortcuts).toHaveBeenCalledWith(expect.anything(), SHORTCUT); }); }); + +describe('setSandboxLocale', () => { + it('switches running plugins to the new language', () => { + // Running plugins used to keep the old language until a reload. + setSandboxLocale('en'); + mocks.setLocale.mockClear(); + + setSandboxLocale('de'); + + expect(mocks.setLocale).toHaveBeenCalledWith('de'); + }); +}); diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index 679661ae4..d7ecf6fe0 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -16,7 +16,7 @@ import { import { useMessageListTabsStore } from '@/stores/message-list-tabs-store'; import { verifyBundle } from './bundle-integrity'; import { downloadManagedBundle } from './bundle-fetch'; -import { createBackgroundInstance } from './host-bridge'; +import { createBackgroundInstance, forEachInstance } from './host-bridge'; import { resolvePluginTier } from './tier'; import { register as registerActive, deregister as deregisterActive, all as allActiveEntries } from './registry'; import { cancelPluginDialogs } from './host-api'; @@ -51,12 +51,17 @@ export function setSandboxLocale(locale: string): void { // good locale back to '' - the initial 'en' default stands until the real // locale arrives via the store subscription. if (!locale) return; + const changed = locale !== currentLocale; currentLocale = locale; // Background instances read `currentLocale` at load time; the slot-iframe // component reads this global at spawn time (plugin-iframe-slot.tsx). Keep - // both in step from one place. Already-running instances are not re-pushed, - // so a locale switch only affects plugins/slots loaded afterwards. + // both in step from one place. (globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = locale; + if (!changed) return; + // Running plugins switch too: their slots re-render in the new language + // and their onLocaleChange handlers run. + forEachInstance(null, (instance) => instance.setLocale(locale)); + void themeHooks.onLocaleChange.emit({ locale }); } // ─── Bundle fetch ───────────────────────────────────────────── diff --git a/lib/plugin-sandbox/plugin-i18n.ts b/lib/plugin-sandbox/plugin-i18n.ts new file mode 100644 index 000000000..52d84283c --- /dev/null +++ b/lib/plugin-sandbox/plugin-i18n.ts @@ -0,0 +1,40 @@ +// Translation lookup for plugin strings (api.i18n.t). Pure, so the runtime +// inside the sandbox and the tests share it. + +export type LocaleTables = Record>; + +/** + * Locales to try for `locale`, most specific first: `de-CH` → `de-CH`, `de`, + * then `en`. Matching ignores case (`zh-tw` finds `zh-TW`). + */ +export function localeChain(locale: string): string[] { + const chain: string[] = []; + const parts = (locale || 'en').split('-'); + for (let i = parts.length; i > 0; i--) chain.push(parts.slice(0, i).join('-')); + if (!chain.includes('en')) chain.push('en'); + return chain; +} + +function findTable(tables: LocaleTables, locale: string): Record | undefined { + if (tables[locale]) return tables[locale]; + const lower = locale.toLowerCase(); + const key = Object.keys(tables).find((k) => k.toLowerCase() === lower); + return key ? tables[key] : undefined; +} + +/** The text for `key`, or undefined when no table has it. */ +export function lookup(tables: LocaleTables, locale: string, key: string): string | undefined { + for (const loc of localeChain(locale)) { + const value = findTable(tables, loc)?.[key]; + if (typeof value === 'string') return value; + } + return undefined; +} + +/** Replace `{name}` placeholders. */ +export function interpolate(text: string, vars?: Record): string { + if (!vars) return text; + let out = text; + for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v)); + return out; +} diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 03970b477..93982f340 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -28,6 +28,7 @@ import type { SlotInit, } from './protocol'; import { themeSnapshotToCSS, type ThemeSnapshot } from './host-theme'; +import { interpolate, lookup, type LocaleTables } from './plugin-i18n'; import type { SlotName } from '../plugin-types'; import { AddressBook, ContactCard } from '../jmap/types'; import { EncryptionAtRestConfig, PublicKeyInput } from '@/stores/account-security-store'; @@ -66,6 +67,19 @@ const pendingApi = new Map void; reject: (err // The plugin's settings. One object for the iframe's lifetime, updated in // place on 'settings-change', so code that kept a reference sees new values. const settingsState: Record = {}; + +// The app language, pushed by the host on init and on every change. +const localeListeners = new Set<(locale: string) => void>(); +function currentLocale(): string { + return (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en'; +} +function applyLocale(locale: string): void { + (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = locale; + document.documentElement.lang = locale; + for (const listener of localeListeners) { + try { listener(locale); } catch (err) { console.error('[plugin] locale listener failed', err); } + } +} const settingsListeners = new Set<(settings: Record) => void>(); function applySettings(next: Record): void { @@ -229,6 +243,7 @@ interface PluginKeywordCounts { function buildPluginApi(manifest: PluginManifest) { applySettings({ ...manifest.settings }); + const localeTables: LocaleTables = JSON.parse(JSON.stringify(manifest.locales ?? {})); return { plugin: { id: manifest.id, @@ -495,22 +510,27 @@ function buildPluginApi(manifest: PluginManifest) { }, // Localization for plugins. The host pushes the active locale (init + // 'locale-change'); `t` resolves a key against the plugin's declared - // `locales` map (manifest.locales), falling back to English then the key - // itself, with optional {placeholder} interpolation. + // `locales` map (manifest.locales) plus anything added at runtime, + // trying `de-CH`, then `de`, then English, then the key itself, with + // optional {placeholder} interpolation. i18n: { get locale(): string { - return (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en'; + return currentLocale(); }, + getLocale: () => currentLocale(), t(key: string, vars?: Record): string { - const loc = (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en'; - const tables = manifest.locales || {}; - let out = tables[loc]?.[key] ?? tables['en']?.[key] ?? key; - if (vars) { - for (const [k, v] of Object.entries(vars)) { - out = out.split('{' + k + '}').join(String(v)); - } - } - return out; + return interpolate(lookup(localeTables, currentLocale(), key) ?? key, vars); + }, + /** Whether any table has `key`, for `has(k) ? t(k) : fallback`. */ + has: (key: string) => lookup(localeTables, currentLocale(), key) !== undefined, + /** Add or override strings for one locale. */ + addTranslations(locale: string, strings: Record): void { + localeTables[locale] = { ...(localeTables[locale] ?? {}), ...strings }; + }, + /** Called with the new locale when the user switches language. Returns an unsubscribe function. */ + onLocaleChange: (listener: (locale: string) => void) => { + localeListeners.add(listener); + return () => { localeListeners.delete(listener); }; }, }, }; @@ -664,7 +684,11 @@ function bootSlot(payload: SlotInit): void { React.useEffect(() => { const bump = () => setSettingsVersion((v) => v + 1); settingsListeners.add(bump); - return () => { settingsListeners.delete(bump); }; + localeListeners.add(bump); + return () => { + settingsListeners.delete(bump); + localeListeners.delete(bump); + }; }, []); React.useEffect(() => { if (!wrapRef.current) return; @@ -698,7 +722,7 @@ async function handleInit(payload: InitPayload): Promise { mode = payload.mode; // Make the active locale available to plugin code (api.i18n) right away - // not only after the first 'locale-change' push. - (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = payload.locale; + applyLocale(payload.locale); try { if (payload.mode === 'background') { await bootBackground(payload); @@ -780,7 +804,7 @@ function handleHostMessage(ev: MessageEvent): void { } case 'locale-change': - (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = msg.locale; + applyLocale(msg.locale); break; case 'theme-change':