/** * 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() const version = useStore($version) // `version` is the registry's change token and must key the translator's // identity: memoized consumers (React.memo, React Compiler output) cache // render slices on `t` itself, so a stable `t` over a mutated registry // serves stale strings after a late bundle registration. return useCallback( (key: string, ...args: unknown[]) => translatePlugin(pluginId, locale, key, args), // eslint-disable-next-line react-hooks/exhaustive-deps [pluginId, locale, version] ) }