Merge pull request #67303 from NousResearch/bb/desktop-plugin-i18n

feat(desktop): plugin-scoped i18n — ctx.i18n locale bundles (follow-up to #60638)
This commit is contained in:
brooklyn! 2026-07-19 00:25:10 -04:00 committed by GitHub
commit fe3e5cf8aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 336 additions and 27 deletions

View file

@ -13,6 +13,7 @@
*/
import { pluginRest, type PluginRestOptions, pluginSocket } from '@/hermes'
import { createPluginI18n, type PluginI18n } from '@/i18n'
import { readKey, writeKey } from '@/lib/storage'
import { registry } from './registry'
@ -51,6 +52,9 @@ export interface PluginContext {
socket: (path: string, onMessage: (data: unknown) => void) => () => void
/** Plugin-scoped persistence. */
storage: PluginStorage
/** Plugin-scoped i18n: ship + register locale bundles under this plugin,
* resolved against the app's active locale no core `en.ts` edit. */
i18n: PluginI18n
}
export interface HermesPlugin {
@ -106,6 +110,7 @@ export function createPluginContext(pluginId: string, onDispose?: (dispose: () =
registerMany: cs => track(registry.registerMany(cs.map(scope))),
rest: <T>(path: string, opts?: PluginRestOptions) => pluginRest<T>(pluginId, path, opts),
socket: (path, onMessage) => track(pluginSocket(pluginId, path, onMessage)),
storage: createPluginStorage(pluginId)
storage: createPluginStorage(pluginId),
i18n: createPluginI18n(pluginId, track)
}
}

View file

@ -16,5 +16,16 @@ export {
localeConfigValue,
normalizeLocale
} from './languages'
export {
createPluginI18n,
type PluginI18n,
type PluginLocaleBundles,
type PluginMessages,
type PluginMessageValue,
type PluginTranslate,
registerPluginLocales,
translatePlugin,
usePluginI18n
} from './plugin-i18n'
export { setRuntimeI18nLocale, translateNow } from './runtime'
export type { Locale, ToolTitleKey, Translations } from './types'

View file

@ -0,0 +1,126 @@
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { I18nProvider, useI18n } from './context'
import { createPluginI18n, registerPluginLocales, translatePlugin, usePluginI18n } from './plugin-i18n'
import { setRuntimeI18nLocale } from './runtime'
const noopTrack = (dispose: () => void) => dispose
afterEach(() => {
cleanup()
setRuntimeI18nLocale('en')
})
describe('plugin locale registry', () => {
it('resolves the active locale, falling back to English then the raw key', () => {
const dispose = registerPluginLocales('cost', {
en: { panel: { title: 'Cost' }, spent: (n: number) => `$${n} spent` },
ja: { panel: { title: 'コスト' } }
})
expect(translatePlugin('cost', 'ja', 'panel.title', [])).toBe('コスト')
// Missing in ja → English.
expect(translatePlugin('cost', 'ja', 'spent', [7])).toBe('$7 spent')
// Missing everywhere → the key itself.
expect(translatePlugin('cost', 'ja', 'nope', [])).toBe('nope')
dispose()
})
it('scopes bundles per plugin — no cross-read', () => {
const a = registerPluginLocales('a', { en: { hi: 'from a' } })
const b = registerPluginLocales('b', { en: { hi: 'from b' } })
expect(translatePlugin('a', 'en', 'hi', [])).toBe('from a')
expect(translatePlugin('b', 'en', 'hi', [])).toBe('from b')
// An unknown plugin resolves to the key.
expect(translatePlugin('c', 'en', 'hi', [])).toBe('hi')
a()
b()
})
it('merges repeated registrations and drops everything on dispose', () => {
const one = registerPluginLocales('merge', { en: { a: 'A' } })
const two = registerPluginLocales('merge', { en: { b: 'B' }, ja: { a: 'あ' } })
expect(translatePlugin('merge', 'en', 'a', [])).toBe('A')
expect(translatePlugin('merge', 'en', 'b', [])).toBe('B')
expect(translatePlugin('merge', 'ja', 'a', [])).toBe('あ')
one()
two()
expect(translatePlugin('merge', 'en', 'a', [])).toBe('a')
})
it('ctx.i18n.t reads the app runtime locale', () => {
const i18n = createPluginI18n('runtime-plugin', noopTrack)
i18n.register({ en: { greet: 'hello' }, ja: { greet: 'こんにちは' } })
expect(i18n.t('greet')).toBe('hello')
setRuntimeI18nLocale('ja')
expect(i18n.t('greet')).toBe('こんにちは')
})
})
function Probe({ pluginId }: { pluginId: string }) {
const t = usePluginI18n(pluginId)
return <p data-testid="copy">{t('greet')}</p>
}
function SwitchToJa() {
const { setLocale } = useI18n()
return (
<button onClick={() => void setLocale('ja')} type="button">
to ja
</button>
)
}
describe('usePluginI18n', () => {
it('re-renders on a locale switch', () => {
const dispose = registerPluginLocales('hooked', {
en: { greet: 'hello' },
ja: { greet: 'こんにちは' }
})
render(
<I18nProvider configClient={null}>
<SwitchToJa />
<Probe pluginId="hooked" />
</I18nProvider>
)
expect(screen.getByTestId('copy').textContent).toBe('hello')
fireEvent.click(screen.getByRole('button'))
expect(screen.getByTestId('copy').textContent).toBe('こんにちは')
dispose()
})
it('picks up a bundle registered after mount', () => {
render(
<I18nProvider configClient={null}>
<Probe pluginId="late" />
</I18nProvider>
)
expect(screen.getByTestId('copy').textContent).toBe('greet')
let dispose = () => {}
act(() => {
dispose = registerPluginLocales('late', { en: { greet: 'landed' } })
})
expect(screen.getByTestId('copy').textContent).toBe('landed')
dispose()
})
})

View file

@ -0,0 +1,113 @@
/**
* Plugin-scoped i18n the `ctx.storage` analog for locale bundles. A plugin
* ships its own strings and registers them under its id; it never edits core
* `en.ts`. Resolution mirrors the app translator: active locale the plugin's
* own `en` bundle the key itself. The active locale is always the app's
* (`display.language`) plugins follow the user's choice, they don't own it.
*
* Two consumers, same shape as core (`useI18n` / `translateNow`):
* - `usePluginI18n(id)` reactive translator for React UI (re-renders on a
* locale switch or a late bundle registration);
* - `ctx.i18n.t` module-level translator for handlers/stores (non-reactive).
*/
import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
import { useCallback } from 'react'
import { useI18n } from './context'
import { getRuntimeI18nLocale, translateFrom } from './runtime'
import type { Locale } from './types'
/** A leaf message: a literal or an interpolator (`n => `${n} left``). */
export type PluginMessageValue = string | ((...args: never[]) => string)
/** A plugin's messages for one locale nested trees allowed, addressed by
* dot-path (`panel.title`). */
export interface PluginMessages {
[key: string]: PluginMessages | PluginMessageValue
}
/** Locale messages. Keyed by the app's locales so autocomplete guides you;
* a bundle for a locale the app can't select is simply never resolved. */
export type PluginLocaleBundles = Partial<Record<Locale, PluginMessages>>
/** Resolve `key` for this plugin against `args`; falls back to English, then
* the raw key. */
export type PluginTranslate = (key: string, ...args: unknown[]) => string
export interface PluginI18n {
/** Merge locale bundles for this plugin (call once at `register`). Returns a
* disposer that drops the plugin's bundles on unload/reload. */
register: (bundles: PluginLocaleBundles) => () => void
/** Module-level translator against the app's active locale (mirrors
* `translateNow`). Non-reactive in React prefer `usePluginI18n`. */
t: PluginTranslate
}
const registry = new Map<string, Map<Locale, PluginMessages>>()
/** Bumps whenever a plugin's bundles change, so React translators re-render on
* a registration that lands after first paint. */
const $version = atom(0)
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function mergeMessages(base: PluginMessages, overrides: PluginMessages): PluginMessages {
const result: PluginMessages = { ...base }
for (const [key, value] of Object.entries(overrides)) {
const prev = result[key]
result[key] = isRecord(prev) && isRecord(value) ? mergeMessages(prev, value) : value
}
return result
}
export function registerPluginLocales(pluginId: string, bundles: PluginLocaleBundles): () => void {
const byLocale = registry.get(pluginId) ?? new Map<Locale, PluginMessages>()
registry.set(pluginId, byLocale)
for (const [locale, messages] of Object.entries(bundles) as [Locale, PluginMessages | undefined][]) {
if (!messages) {
continue
}
const prev = byLocale.get(locale)
byLocale.set(locale, prev ? mergeMessages(prev, messages) : messages)
}
$version.set($version.get() + 1)
return () => {
registry.delete(pluginId)
$version.set($version.get() + 1)
}
}
export function translatePlugin(pluginId: string, locale: Locale, key: string, args: unknown[]): string {
return translateFrom(l => registry.get(pluginId)?.get(l), locale, key, args)
}
/** Build the `ctx.i18n` door for a plugin. `track` records the disposer so the
* loader tears bundles down on unload (same lifecycle as `register`/`socket`). */
export function createPluginI18n(pluginId: string, track: (dispose: () => void) => () => void): PluginI18n {
return {
register: bundles => track(registerPluginLocales(pluginId, bundles)),
t: (key, ...args) => translatePlugin(pluginId, getRuntimeI18nLocale(), key, args)
}
}
/** Reactive scoped translator for React UI. Re-renders on a locale switch or a
* late bundle registration. Pass your plugin id (your default export's `id`). */
export function usePluginI18n(pluginId: string): PluginTranslate {
const { locale } = useI18n()
// Subscribe so a bundle registered after mount repaints; `translatePlugin`
// reads the live registry, so the memoized closure needs only id + locale.
useStore($version)
return useCallback((key: string, ...args: unknown[]) => translatePlugin(pluginId, locale, key, args), [pluginId, locale])
}

View file

@ -1,6 +1,6 @@
import { TRANSLATIONS } from './catalog'
import { DEFAULT_LOCALE } from './languages'
import type { Locale, Translations } from './types'
import type { Locale } from './types'
let runtimeLocale: Locale = DEFAULT_LOCALE
@ -8,17 +8,13 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function resolvePath(catalog: Translations, key: string): unknown {
return key.split('.').reduce<unknown>((current, part) => {
if (!isRecord(current)) {
return undefined
}
return current[part]
}, catalog)
/** Walk a dot-path (`a.b.c`) into a nested message tree. */
function resolvePath(source: unknown, key: string): unknown {
return key.split('.').reduce<unknown>((current, part) => (isRecord(current) ? current[part] : undefined), source)
}
function renderTranslation(value: unknown, args: unknown[]): string | null {
/** A string is returned as-is, a function is called with `args`, else `null`. */
function render(value: unknown, args: unknown[]): null | string {
if (typeof value === 'string') {
return value
}
@ -30,19 +26,22 @@ function renderTranslation(value: unknown, args: unknown[]): string | null {
return null
}
export function setRuntimeI18nLocale(locale: Locale) {
runtimeLocale = locale
}
export function translateNow(key: string, ...args: unknown[]): string {
const active = renderTranslation(resolvePath(TRANSLATIONS[runtimeLocale], key), args)
/** The active DEFAULT key resolution every translator shares. `source`
* yields a message tree per locale the app catalog, or a plugin's bundles. */
export function translateFrom(
source: (locale: Locale) => unknown,
locale: Locale,
key: string,
args: unknown[]
): string {
const active = render(resolvePath(source(locale), key), args)
if (active !== null) {
return active
}
if (runtimeLocale !== DEFAULT_LOCALE) {
const fallback = renderTranslation(resolvePath(TRANSLATIONS[DEFAULT_LOCALE], key), args)
if (locale !== DEFAULT_LOCALE) {
const fallback = render(resolvePath(source(DEFAULT_LOCALE), key), args)
if (fallback !== null) {
return fallback
@ -51,3 +50,17 @@ export function translateNow(key: string, ...args: unknown[]): string {
return key
}
export function setRuntimeI18nLocale(locale: Locale) {
runtimeLocale = locale
}
/** The locale module-level translators resolve against (the app's active
* `display.language`). Plugin `ctx.i18n.t` reads this too. */
export function getRuntimeI18nLocale(): Locale {
return runtimeLocale
}
export function translateNow(key: string, ...args: unknown[]): string {
return translateFrom(locale => TRANSLATIONS[locale], runtimeLocale, key, args)
}

View file

@ -194,8 +194,19 @@ export type {
* id with your plugin slug (`kanban:board-switcher`). */
export { Contribute, type ContributeProps } from '@/contrib/react/contribute'
export type { Contribution } from '@/contrib/types'
/** Localized copy — plugins reuse the app's strings (and stay translatable). */
export { useI18n } from '@/i18n'
/** Localized copy. `useI18n` reuses the app's strings; `usePluginI18n(id)` +
* `ctx.i18n.register` let a plugin ship its OWN locale bundles, scoped like
* `ctx.storage` and resolved against the app's active locale no core edit. */
export {
type Locale,
type PluginI18n,
type PluginLocaleBundles,
type PluginMessages,
type PluginMessageValue,
type PluginTranslate,
useI18n,
usePluginI18n
} from '@/i18n'
export { triggerHaptic as haptic } from '@/lib/haptics'
/** The app's lucide icon set (RefreshCw, LayoutDashboard, Activity, …). */
export * as icons from '@/lib/icons'

View file

@ -83,6 +83,12 @@ The ONLY import surface is `@hermes/plugin-sdk` (plus `react` /
(renders below Artifacts, lights up at the route) — and/or a
`PALETTE_AREA` command calling `host.navigate('/my-page')`.
- `ctx.storage.get/set/remove` — persistence namespaced to your plugin.
- `ctx.i18n.register({ en, ja, ... })` — ship your OWN locale bundles, scoped
to your plugin (never edit core `en.ts`). Values are literal strings or
interpolator functions; nested trees are addressed by dot-path. Read them
reactively in components with `usePluginI18n(id)` returning `t('key', ...args)`
(re-renders on a locale switch), or via `ctx.i18n.t` in handlers/stores.
Resolution follows the app's active locale, then your `en`, then the raw key.
- Data: `useQuery`/`useMutation`/`useQueryClient`/`queryClient` (the app's ONE
React Query client — cache, dedupe, `refetchInterval`, invalidate like core;
never hand-roll a poll loop), plus `atom`/`computed` for plugin-local state.

View file

@ -10,27 +10,34 @@
* Only these imports resolve: @hermes/plugin-sdk, react, react/jsx-runtime.
*/
import { cn, haptic, host, Tip, useValue } from '@hermes/plugin-sdk'
import { cn, haptic, host, Tip, usePluginI18n, useValue } from '@hermes/plugin-sdk'
import { jsx, jsxs } from 'react/jsx-runtime'
// Ship your OWN strings (never edit core en.ts). `usePluginI18n` resolves them
// against the app's active locale, falling back to `en`, then the raw key.
const ID = 'my-plugin'
function MyPane() {
const t = usePluginI18n(ID)
const gateway = useValue(host.state.gateway)
return jsxs('div', {
className: 'flex h-full flex-col gap-2 p-3 text-sm',
children: [
jsx('div', { className: 'font-medium', children: 'My Plugin Pane' }),
jsx('div', { className: 'font-medium', children: t('paneTitle') }),
jsx('div', {
className: 'text-(--ui-text-tertiary)',
children: `gateway: ${gateway}`
children: t('gateway', gateway)
})
]
})
}
function MyChip() {
const t = usePluginI18n(ID)
return jsx(Tip, {
label: 'My plugin — click me',
label: t('chipTip'),
children: jsx('button', {
className: cn(
'inline-flex h-full items-center gap-1 px-1.5 text-[0.6875rem] transition-colors',
@ -39,7 +46,7 @@ function MyChip() {
type: 'button',
onClick: () => {
haptic('tap')
host.notify({ kind: 'info', message: 'Hello from my plugin!' })
host.notify({ kind: 'info', message: t('hello') })
},
children: 'my-plugin'
})
@ -47,9 +54,26 @@ function MyChip() {
}
export default {
id: 'my-plugin', // must match the folder name
id: ID, // must match the folder name
name: 'My Plugin',
register(ctx) {
// Register locale bundles — scoped to this plugin, torn down on reload.
// Values are literals or interpolators (`arg => `…${arg}…``).
ctx.i18n.register({
en: {
paneTitle: 'My Plugin Pane',
gateway: state => `gateway: ${state}`,
chipTip: 'My plugin — click me',
hello: 'Hello from my plugin!'
},
ja: {
paneTitle: 'マイプラグイン',
gateway: state => `ゲートウェイ: ${state}`,
chipTip: 'マイプラグイン — クリック',
hello: 'マイプラグインからこんにちは!'
}
})
// A layout pane — auto-placed by the placement hint; user can drag it.
// To land on a specific edge instead of stacking, add a dock gesture,
// e.g. below the conversation: