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
+119 -1
View File
@@ -6,7 +6,7 @@
* interpolation.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { LOCALE_NS, attachLocale, en, isZh, relativeTime, t, zh } from '../src/client/locales.ts'
import { LOCALE_NS, attachBetterLocale, attachLocale, en, isZh, ja, relativeTime, t, zh } from '../src/client/locales.ts'
/** Minimal structural fake of the DSH LocaleService face the sidebar uses. */
class FakeLocale {
@@ -35,6 +35,7 @@ function stubNavigatorLanguage(lang: string | undefined): void {
afterEach(() => {
attachLocale(undefined)
attachBetterLocale(undefined)
stubNavigatorLanguage(undefined)
})
@@ -106,4 +107,121 @@ describe('locales (DSH i18n following)', () => {
it('keeps the zh and en dictionaries key-set-equal', () => {
expect(Object.keys(en).sort()).toEqual(Object.keys(zh).sort())
})
it('keeps the ja dictionary key-set-equal to zh', () => {
expect(Object.keys(ja).sort()).toEqual(Object.keys(zh).sort())
})
})
/** Minimal fake of the better-locale override store (the subset t() reads). */
class FakeBetterLocale {
active: string | undefined = undefined
private readonly dict: Record<string, Record<string, Record<string, string>>>
constructor(dict: Record<string, Record<string, Record<string, string>>>) {
this.dict = dict
}
getOverride(_dshActive: string, ns: string, key: string): string | undefined {
if (this.active === undefined) return undefined
return this.dict[ns]?.[this.active]?.[key]
}
subscribe(_listener: () => void): () => void {
return () => {}
}
}
describe('locales (better-locale override)', () => {
it('returns the override text when an override is active and the key exists', () => {
const locale = new FakeLocale()
locale.switchTo('en')
attachLocale(locale)
const betterLocale = new FakeBetterLocale({
[LOCALE_NS]: { ja: { explorer: 'エクスプローラー' } },
})
betterLocale.active = 'ja'
attachBetterLocale(betterLocale)
expect(t('explorer')).toBe('エクスプローラー')
expect(isZh()).toBe(false)
})
it('falls back to the zh/en chain when override is active but the key is missing', () => {
const locale = new FakeLocale()
locale.switchTo('en')
attachLocale(locale)
const betterLocale = new FakeBetterLocale({
[LOCALE_NS]: { ja: { explorer: 'エクスプローラー' } },
})
betterLocale.active = 'ja'
attachBetterLocale(betterLocale)
// 'git' has no ja entry in this fake; should fall back to the en text.
expect(t('git')).toBe('Source Control')
})
it('falls back to the zh/en chain when no override is active', () => {
const locale = new FakeLocale()
locale.switchTo('zh')
attachLocale(locale)
const betterLocale = new FakeBetterLocale({
[LOCALE_NS]: { ja: { explorer: 'エクスプローラー' } },
})
// active is undefined — no override.
attachBetterLocale(betterLocale)
expect(t('explorer')).toBe('资源管理器')
expect(isZh()).toBe(true)
})
it('returns to the zh/en chain after detaching the override store', () => {
const locale = new FakeLocale()
locale.switchTo('en')
attachLocale(locale)
const betterLocale = new FakeBetterLocale({
[LOCALE_NS]: { ja: { explorer: 'エクスプローラー' } },
})
betterLocale.active = 'ja'
attachBetterLocale(betterLocale)
expect(t('explorer')).toBe('エクスプローラー')
attachBetterLocale(undefined)
expect(t('explorer')).toBe('Explorer')
expect(isZh()).toBe(false)
})
it('interpolates {name} placeholders in the override text', () => {
const locale = new FakeLocale()
locale.switchTo('en')
attachLocale(locale)
const betterLocale = new FakeBetterLocale({
[LOCALE_NS]: { ja: { timeMinutesAgo: '{n} 分前' } },
})
betterLocale.active = 'ja'
attachBetterLocale(betterLocale)
expect(t('timeMinutesAgo', { n: 5 })).toBe('5 分前')
})
it('ships a ja dictionary whose every key resolves to a non-empty string', () => {
// Smoke-check the shipped ja dict: every key in zh must have a non-empty
// ja entry. This catches copy-paste mistakes where a key was added to zh
// but its ja translation was left blank or missing.
const locale = new FakeLocale()
locale.switchTo('en')
attachLocale(locale)
const betterLocale = new FakeBetterLocale({ [LOCALE_NS]: { ja } })
betterLocale.active = 'ja'
attachBetterLocale(betterLocale)
for (const key of Object.keys(zh) as (keyof typeof zh)[]) {
const text = t(key)
expect(text, `ja translation for "${key}"`).toBeTruthy()
expect(text, `ja translation for "${key}"`).not.toBe(key)
}
})
})