feat(i18n): 接入 @huanlin/dsh-plugin-better-locale 支持 ja 覆盖

新增 ja 词典(src/client/locales-ja.ts,326 keys,与 zh key-set-equal),
通过可选 peer @huanlin/dsh-plugin-better-locale 接入第三语言覆盖:

- locales.ts: 新增 attachBetterLocale(store) + ja dict export;t() 先查
  store.getOverride(active, 'betterSidebar', key),命中则返回覆盖文本,
  否则走原 zh/en 链;isZh() 在 override 激活时返回 false
- client/index.tsx: apply 中 ctx.get('betterLocale') 取得 override store
  (optional,未安装时为 undefined),attachBetterLocale 注入 t();同时
  register('betterSidebar', { ja }) 把 ja 词典注册进 better-locale 的
  store,让外部 ctx.locale.lookup 调用者也能拿到 ja 文本
- Sidebar.tsx: 新增 betterLocaleActive uSES 订阅 store.subscribe,覆盖
  切换时全树重渲染(DSH locale 的 active 字段不变,既有 localeRevision
  uSES 不会触发)
- package.json: @huanlin/dsh-plugin-better-locale 加为 optional peer
  + devDep link
- tests/locales.spec.ts: 新增 7 个 better-locale override 测试(命中/
  fallback/detach/interpolate/ja dict 完整性 smoke check)

未安装 better-locale 时 ctx.get 返回 undefined,整段为 no-op,zh/en
行为完全不变。
This commit is contained in:
HuanLinOTO
2026-08-23 00:52:20 +08:00
parent d9b8f15d9e
commit 7ceee777b4
9 changed files with 607 additions and 6 deletions
+70 -3
View File
@@ -1,5 +1,5 @@
/**
* Minimal zh/en copy for the sidebar. The copy follows the DSH i18n system:
* Minimal zh/en/ja copy for the sidebar. The copy follows the DSH i18n system:
* the client apply attaches the locale service (`ctx.locale`, provided by
* `@deepseek-ai/dsh-client-locale`) through {@link attachLocale}, and
* `t()`/`isZh()` resolve the active locale from it — the Host-backed
@@ -7,6 +7,18 @@
* Without an attached service (standalone/test compositions) the browser
* language is used, matching the previous behavior. The dictionaries are
* also registered into the DSH locale registry under {@link LOCALE_NS}.
*
* ja (Japanese) is opt-in through `@huanlin/dsh-plugin-better-locale`: when
* that plugin is installed, the client apply also calls
* {@link attachBetterLocale} with the override store. `t()` then consults
* the store's active override id first; if it is `'ja'` (or any id whose
* dict has the requested key) the ja text wins, otherwise the existing
* zh/en chain runs unchanged. better-locale itself patches
* `LocaleRuntime.prototype.lookup` so DSH's own translate chain also
* returns ja where the `betterSidebar` namespace has a ja entry — that
* path covers external callers of `ctx.locale.bind('betterSidebar')`,
* while the override-aware `t()` here covers better-sidebar's own
* components (which bypass `ctx.locale` and call `t()` directly).
*/
/** The zh dictionary (also registered into the DSH locale registry under {@link LOCALE_NS}). */
@@ -675,9 +687,28 @@ export const en: Record<keyof typeof zh, string> = {
*/
export const LOCALE_NS = 'betterSidebar'
// The ja dictionary lives in a sibling file (326 keys) so this module
// stays readable. Type-checked against the zh key set: a missing or extra
// ja key is a compile error.
import { ja as jaDict } from './locales-ja.ts'
/** The ja dictionary (key-set-equal to zh, enforced by the type annotation). */
export const ja: Record<keyof typeof zh, string> = jaDict as Record<keyof typeof zh, string>
/** The DSH locale service attached by the client apply (absent → browser detection). */
let localeService: { getSnapshot(): { active: string } } | undefined
/**
* The better-locale override store attached by the client apply
* (absent → no override; the zh/en chain runs). The store's `active`
* field holds the user's chosen override id (e.g. `'ja'`); `undefined`
* means "no override, use DSH native zh/en".
*/
let betterLocaleStore: {
readonly active: string | undefined
getOverride(dshActive: string, ns: string, key: string): string | undefined
} | undefined
/**
* Attach (or detach, with undefined) the DSH locale service. The sidebar
* mounts its own React root outside the slot system's locale seat, so the
@@ -689,6 +720,23 @@ export function attachLocale(service: { getSnapshot(): { active: string } } | un
localeService = service
}
/**
* Attach (or detach, with undefined) the better-locale override store.
* When attached with an active override, `t()` consults the store's
* `getOverride(active, LOCALE_NS, key)` first; if it returns a string,
* that text wins over the zh/en chain. Detaching (or the store's active
* being `undefined`) restores the zh/en chain unchanged.
*
* The Sidebar root subscribes to the store separately (see Sidebar.tsx)
* so an override change re-renders the whole tree — the locale service's
* own revision bump (which better-locale triggers via `publish(active, true)`)
* does NOT fire the existing `localeRevision` uSES because that snapshot
* reads `getSnapshot().active` (unchanged) rather than `revision`.
*/
export function attachBetterLocale(store: typeof betterLocaleStore): void {
betterLocaleStore = store
}
/**
* The active locale id ('zh' | 'en'): the DSH locale service's snapshot when
* attached, else the browser language.
@@ -704,8 +752,24 @@ export type CopyKey = keyof typeof zh
/** Translate a copy key; `{name}` placeholders interpolate from `params`. */
export function t(key: CopyKey, params?: Record<string, string | number>): string {
const dict = activeLocale().toLowerCase().startsWith('zh') ? zh : en
let text = dict[key]
// 1. better-locale override (e.g. ja) wins when an override is active
// and the store has a translation for this (ns, key). The DSH active
// locale is forwarded for forward compatibility (a future mode could
// gate overrides on DSH being 'en'); the current store ignores it.
const dshActive = localeService?.getSnapshot().active ?? ''
const override = betterLocaleStore?.getOverride(dshActive, LOCALE_NS, key)
let text: string | undefined = override
// 2. Fall back to the zh/en chain when no override matched.
if (text === undefined) {
const dict = activeLocale().toLowerCase().startsWith('zh') ? zh : en
text = dict[key]
}
if (text === undefined) {
// Key missing from every dict (should not happen — zh is the source of
// truth and en/ja are checked against it). Return the key itself so the
// UI shows something identifiable rather than `undefined`.
text = key
}
if (params !== undefined) {
for (const [name, value] of Object.entries(params)) {
text = text.replaceAll(`{${name}}`, String(value))
@@ -716,6 +780,9 @@ export function t(key: CopyKey, params?: Record<string, string | number>): strin
/** Whether the active locale is Chinese (used for selectors). */
export function isZh(): boolean {
// An active override is not Chinese — short-circuit so selectors pick
// the non-zh branch (e.g. date format, pluralization).
if (betterLocaleStore?.active !== undefined) return false
return activeLocale().toLowerCase().startsWith('zh')
}