From 228655a73ac39c532c2f1fd5ccd82712eea64116 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 19 Jul 2026 00:03:39 -0400 Subject: [PATCH 1/4] =?UTF-8?q?refactor(desktop):=20share=20one=20active?= =?UTF-8?q?=E2=86=92default=E2=86=92key=20i18n=20resolver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the fallback walk out of `translateNow` into a single `translateFrom` that takes a per-locale message source, so any translator (core catalog or a plugin's bundles) reuses the exact dot-path walk, interpolation, and active-locale/English/raw-key chain. Adds `getRuntimeI18nLocale`. --- apps/desktop/src/i18n/runtime.ts | 49 ++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/i18n/runtime.ts b/apps/desktop/src/i18n/runtime.ts index b9276aaf9651..71d2162c15fc 100644 --- a/apps/desktop/src/i18n/runtime.ts +++ b/apps/desktop/src/i18n/runtime.ts @@ -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 { return typeof value === 'object' && value !== null && !Array.isArray(value) } -function resolvePath(catalog: Translations, key: string): unknown { - return key.split('.').reduce((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((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) +} From 6282b5dcda6468ea215002718f113110966bec25 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 19 Jul 2026 00:03:39 -0400 Subject: [PATCH 2/4] feat(desktop): plugin-scoped i18n registry + usePluginI18n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plugin ships its own locale bundles and registers them under its id — never editing core en.ts — exactly like ctx.storage namespaces persistence. The registry merges repeated registrations and drops a plugin's bundles on dispose (tracked by the loader like register/socket), resolving through the shared translator: active locale -> the plugin's own en -> the raw key. Two consumers, symmetric with core: usePluginI18n(id) for React UI (re-renders on a locale switch or a late registration) and a module-level t for handlers/stores. --- apps/desktop/src/i18n/index.ts | 11 ++ apps/desktop/src/i18n/plugin-i18n.test.tsx | 126 +++++++++++++++++++++ apps/desktop/src/i18n/plugin-i18n.ts | 113 ++++++++++++++++++ 3 files changed, 250 insertions(+) create mode 100644 apps/desktop/src/i18n/plugin-i18n.test.tsx create mode 100644 apps/desktop/src/i18n/plugin-i18n.ts diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index b8e0d5ea2ea5..6d85837889da 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -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' diff --git a/apps/desktop/src/i18n/plugin-i18n.test.tsx b/apps/desktop/src/i18n/plugin-i18n.test.tsx new file mode 100644 index 000000000000..44e99d588e4c --- /dev/null +++ b/apps/desktop/src/i18n/plugin-i18n.test.tsx @@ -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

{t('greet')}

+} + +function SwitchToJa() { + const { setLocale } = useI18n() + + return ( + + ) +} + +describe('usePluginI18n', () => { + it('re-renders on a locale switch', () => { + const dispose = registerPluginLocales('hooked', { + en: { greet: 'hello' }, + ja: { greet: 'こんにちは' } + }) + + render( + + + + + ) + + 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( + + + + ) + + expect(screen.getByTestId('copy').textContent).toBe('greet') + + let dispose = () => {} + act(() => { + dispose = registerPluginLocales('late', { en: { greet: 'landed' } }) + }) + + expect(screen.getByTestId('copy').textContent).toBe('landed') + + dispose() + }) +}) diff --git a/apps/desktop/src/i18n/plugin-i18n.ts b/apps/desktop/src/i18n/plugin-i18n.ts new file mode 100644 index 000000000000..6e1c00e5f0e9 --- /dev/null +++ b/apps/desktop/src/i18n/plugin-i18n.ts @@ -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> + +/** 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>() + +/** 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 { + 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() + 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]) +} From bba18c6008c39154bb57af006152592e942dc13e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 19 Jul 2026 00:03:39 -0400 Subject: [PATCH 3/4] feat(desktop): expose ctx.i18n on the plugin SDK Wire the scoped translator into the plugin context alongside storage/rest/ socket, and export usePluginI18n + the bundle types from @hermes/plugin-sdk. The runtime shim re-exports the barrel automatically, so disk/third-party plugins get the same door as bundled ones. --- apps/desktop/src/contrib/plugin.ts | 7 ++++++- apps/desktop/src/sdk/index.ts | 15 +++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/contrib/plugin.ts b/apps/desktop/src/contrib/plugin.ts index c8a34d59df78..551f626e62ff 100644 --- a/apps/desktop/src/contrib/plugin.ts +++ b/apps/desktop/src/contrib/plugin.ts @@ -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: (path: string, opts?: PluginRestOptions) => pluginRest(pluginId, path, opts), socket: (path, onMessage) => track(pluginSocket(pluginId, path, onMessage)), - storage: createPluginStorage(pluginId) + storage: createPluginStorage(pluginId), + i18n: createPluginI18n(pluginId, track) } } diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 34b55419b06f..285f843dcc23 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -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' From 45be429b3bc41e0ebd3d7129b70fd293b1dd6b36 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 19 Jul 2026 00:03:39 -0400 Subject: [PATCH 4/4] docs(desktop): document plugin ctx.i18n in the plugins skill Add the ctx.i18n.register / usePluginI18n surface to the desktop-plugins skill and a bilingual (en/ja) example to the starter template. --- skills/hermes-desktop-plugins/SKILL.md | 6 ++++ .../templates/plugin.js | 36 +++++++++++++++---- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/skills/hermes-desktop-plugins/SKILL.md b/skills/hermes-desktop-plugins/SKILL.md index 71af411b7052..93b9853962ed 100644 --- a/skills/hermes-desktop-plugins/SKILL.md +++ b/skills/hermes-desktop-plugins/SKILL.md @@ -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. diff --git a/skills/hermes-desktop-plugins/templates/plugin.js b/skills/hermes-desktop-plugins/templates/plugin.js index 803c11d2da76..e3e75ed474aa 100644 --- a/skills/hermes-desktop-plugins/templates/plugin.js +++ b/skills/hermes-desktop-plugins/templates/plugin.js @@ -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: