@deepseek-ai/dsh-client-locale 0.1.1-rc.2 → 0.1.2-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/client/locale/README.md
5
- README.md: 3fb5cce334e59b36c30f22a863f8e91d260f2ac9
6
- README.zh.md: 10fb3547376c8e960165a04fb4ea64ec8dd6f982
5
+ README.md: 7c2264bb59165c7c4e213516b4337fd5bc854bf8
6
+ README.zh.md: 1693f7fce4b3aab33e79d1fe20943050ff992188
package/README.md CHANGED
@@ -1,12 +1,119 @@
1
+ ---
2
+ description: "Localization for the web GUI: the zh/en preference, browser-derived fallback, typed namespace dictionaries, and the framework translation seat, for users and plugin authors."
3
+ kind: "package-reference"
4
+ ---
5
+
1
6
  # @deepseek-ai/dsh-client-locale
2
7
 
3
8
  English | [中文](README.zh.md)
4
9
 
5
- Locale plugin: LocaleRuntime — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `en` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. Remote browsers retain only a process-local selection because the settings API is loopback-only. `locale/change` fires on switches, and the plugin points `<html lang>` at the active locale (`zh-CN`/`en`) on activation and on every switch. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS<ns>`; lookup chain ns → common → en → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary.
10
+ ## Summary
11
+
12
+ `dsh-client-locale` localizes the web GUI: users choose from the registered languages in Settings → General, and the UI copy switches immediately. The package ships `zh` and `en`, while external client plugins can add languages and their namespace dictionaries. On a loopback page, the choice persists as `locale.preference` in `$DSH_HOME/settings.yaml`; a non-loopback page keeps its selection process-local even though Connection authenticates every API method. A fresh browser starts provisionally in the first registered language requested by `navigator` until an allowed Host preference arrives and replaces it live. Plugin authors receive full type checking for the built-in dictionary form and translate through the framework `t` seat; copy rendered through slots follows language switches without a reload.
13
+
14
+ ## Table of Contents
15
+
16
+ - [Use this package](#use-this-package)
17
+ - [Understand the implementation](#understand-the-implementation)
18
+ - [Further Exploration](#further-exploration)
19
+ - [Model Experience](#model-experience)
20
+ - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
21
+ - [Dev Note](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## Use this package
27
+
28
+ Use it wherever the web GUI needs a language switch or translated copy: the shipped settings row covers users, and plugin authors register their own dictionaries. Nothing needs configuration to mount — the package activates with the client tree.
29
+
30
+ ### Choosing a language
31
+
32
+ Open Settings → General and select a registered language. The active locale is applied immediately: the UI copy switches, `<html lang>` points at the external id or built-in document tag, and the choice is written to the durable settings section. A browser without an explicit Host preference selects the first registered language that matches `navigator` by full tag and then primary subtag, falling back to English. A stored external locale waits for its definition to register instead of becoming active while unavailable.
33
+
34
+ ### Registering a dictionary
35
+
36
+ Call `ctx.locale.register(ns, { zh, en })` with a namespace merged into `LocaleNamespaceMap`; the compiler checks every key against the namespace's typed key union and requires both shipped locales. Consumers translate through `ctx.locale.bind(ns)` or the framework-injected `t` seat. A dictionary registered after the UI is already mounted is picked up without a remount.
37
+
38
+ ### Registering a language pack
39
+
40
+ An external client plugin registers the language definition and each translated namespace as owned effects; definitions and dictionaries may register in either order:
41
+
42
+ ```js
43
+ export const inject = ['locale']
44
+
45
+ export function apply(ctx) {
46
+ ctx.effect(
47
+ () => ctx.locale.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' }),
48
+ 'my-locale: language',
49
+ )
50
+ ctx.effect(
51
+ () => ctx.locale.register('common', 'ja', {
52
+ cancel: 'キャンセル',
53
+ close: '閉じる',
54
+ }),
55
+ 'my-locale: common dictionary',
56
+ )
57
+ }
58
+ ```
59
+
60
+ An external id is a non-empty ASCII BCP 47-style tag. Its fallback must already be registered, and the chain must terminate at `en`; unknown targets, duplicate ids, and cycles fail at registration. Lookup walks the fallback chain in the requested namespace, repeats it in `common`, then displays the key. Unloading a definition removes it from the selector and returns an active selection to the available browser/default locale.
61
+
62
+ ### What the Host half does
63
+
64
+ The Host persists the preference through the settings service on loopback pages. The Client deliberately withholds that settings scope on non-loopback pages, so their locale selection remains process-local even though Connection authenticates every API method.
65
+
66
+ -----
67
+
68
+ <a id="understand-the-implementation"></a>
69
+ ## Understand the implementation
70
+
71
+ <details>
72
+ <summary>Implementation internals — click to expand</summary>
73
+
74
+ This section explains how the locale service is built; observable behavior is covered in [Use this package](#use-this-package).
6
75
 
76
+ ### Design concept
77
+
78
+ One `LocaleRuntime` owns the preference and the dictionary registry, and is itself the slot system's `LocaleFace`: `getSnapshot`/`subscribe` back the framework-injected `t` seat through `ctx.slots.installLocale`. The immutable snapshot carries the active locale, the selectable locales, and a monotonic revision; dictionary registration and locale switches both advance the revision, but only a switch emits the `locale/change` event. Product-authored Client UI text must enter through these typed dictionaries or an already-localized primitive prop; `verify-client-ui-i18n` enforces that source ownership ([decision](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)).
79
+
80
+ ### Preference resolution
81
+
82
+ The provisional locale comes from the browser (`navigator.languages` matched by full tag and then primary subtag, English as the fallback), standing in until the allowed Host-backed settings scope delivers its stored preference. The Host read runs after plugin activation so an unavailable or withheld settings scope cannot block the page, and the result replaces the provisional value live. A stored external locale waits for its definition to register. `setLocale` is the only write entry; it persists even when the id already matches the active locale, because the active value may be provisional and must survive a different browser sharing the same home.
83
+
84
+ ### Dictionary lookup
85
+
86
+ The typed object form requires complete dictionaries for both built-in locales. The per-locale form lets language packs register each namespace independently. For each key, lookup walks the active language's declared fallback chain in the requested namespace, repeats that chain in `common`, then displays the key itself. Bound translate functions retain stable identity per namespace so they can ride inject surfaces without breaking memoization.
87
+
88
+ ### Source map
89
+
90
+ | File | Role |
91
+ |---|---|
92
+ | [`src/client/index.ts`](src/client/index.ts) | `LocaleRuntime`, dictionary registry, Language row registration, `locale/change` event |
93
+ | [`src/index.ts`](src/index.ts) | Node half: registers the `locale` settings namespace |
94
+ | [`src/locale-settings.ts`](src/locale-settings.ts) | The durable schema for `locale.preference` |
95
+ | [`src/locales/`](src/locales/) | The shipped `zh`/`en` dictionaries |
96
+
97
+ </details>
98
+
99
+ -----
100
+
101
+ <a id="further-exploration"></a>
102
+ ## Further Exploration
103
+
104
+ Read these when the locale contract is not enough: the slot face it implements, the settings surface it rides, and the persistence decision behind the preference.
105
+
106
+ - [Client slot system](../ui-slots/README.md) — the slot model and the `LocaleFace` seat this package implements.
107
+ - [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) — why the preference persists in Host settings instead of the browser.
108
+ - [Settings group map](../../settings/README.md) — the settings service that stores the preference.
109
+ - [Client group map](../README.md) — the browser half this package belongs to.
110
+
111
+ -----
112
+
113
+ <a id="model-experience"></a>
7
114
  ## Model Experience
8
115
 
9
- None, as the locale registry serves browser UI copy; nothing here reaches a model request.
116
+ None, as the locale service is a browser-side UI plugin layer that registers nothing model-facing.
10
117
 
11
118
  #### KV Cache effect
12
119
 
@@ -14,5 +121,20 @@ None; this package neither assembles nor sends a provider request.
14
121
 
15
122
  ## Known Limitations and Deferred Work
16
123
 
17
- - **Some surfaces keep inline copy** — Settings rows, the sidebar, question composer, and model select use locale seats; other packages still own static text directly.
124
+ <a id="known-limitations-and-deferred-work"></a>
125
+
126
+
127
+ These limits define where localization is incomplete or frozen at registration time. They are current package constraints, not a task backlog.
128
+
18
129
  - **Registry-held text reads its translation once** — copy captured at registration time outside the slot render path (e.g. the `/model` command description in the command registry) keeps the language it was registered under until re-registration; slot-rendered copy follows switches live.
130
+ - **Language packs own language-specific behavior** — the registry supplies selection, persistence, browser matching, key fallback, and `<html lang>`; it does not add plural rules or bidirectional layout.
131
+
132
+ <a id="dev-note"></a>
133
+ ### Dev Note
134
+
135
+ <details>
136
+ <summary>Working context for maintainers — click to expand</summary>
137
+
138
+ None.
139
+
140
+ </details>
package/README.zh.md CHANGED
@@ -1,18 +1,140 @@
1
+ ---
2
+ description: "面向用户与插件作者的 web GUI 本地化说明:zh/en 偏好、浏览器派生回退、类型化命名空间词典与框架翻译席位。"
3
+ kind: "package-reference"
4
+ ---
5
+
1
6
  # @deepseek-ai/dsh-client-locale
2
7
 
3
8
  [English](README.md) | 中文
4
9
 
5
- locale 插件:LocaleRuntime——`zh`/`en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `en`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。settings API 仅限回环请求,因此远程浏览器的选择仅保留在进程内。`locale/change` 仅在切换语言时触发;插件会在激活时以及每次切换时把 `<html lang>` 指向当前 locale(`zh-CN`/`en`)。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS<ns>`;查找链 ns → common → en → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md)拥有。
10
+ ## 概述
11
+
12
+ `dsh-client-locale` 为 web GUI 提供本地化:用户在“设置 → 常规”中从已注册语言中选择,UI 文案会立即切换。本包内置 `zh` 与 `en`,外部 client 插件可以增加语言及其命名空间字典。在 loopback 页面上,该选择以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;非 loopback 页面即使由 Connection 认证所有 API 方法,也只在进程内保留选择。全新浏览器会先临时使用 `navigator` 请求的第一个已注册语言,直到允许读取的 Host 偏好到达并实时替换。插件作者使用内置字典形式时会获得完整类型检查,并通过框架 `t` 席位翻译;经 slot 渲染的文案会随语言切换即时更新。
13
+
14
+ ## 目录
15
+
16
+ - [使用本包](#use-this-package)
17
+ - [理解实现](#understand-the-implementation)
18
+ - [进一步探索](#further-exploration)
19
+ - [模型体验](#model-experience)
20
+ - [已知限制与延期工作](#known-limitations-and-deferred-work)
21
+ - [开发备注](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## 使用本包
27
+
28
+ 只要 web GUI 需要语言切换或翻译文案就使用它:已发布的设置行覆盖用户侧,插件作者则注册自己的词典。挂载无需任何配置——本包随客户端树一起激活。
29
+
30
+ ### 选择语言
31
+
32
+ 打开“设置 → 常规”并选择一种已注册语言。生效中的 locale 会立即应用:UI 文案切换、`<html lang>` 指向外部 id 或内置语言的文档标签,选择写入持久设置分区。没有显式 Host 偏好的浏览器会按完整标签、再按主子标签选择 `navigator` 请求的第一个已注册语言,无法匹配时回退到 English。已存储的外部 locale 会等待其定义注册,不会在不可用时生效。
33
+
34
+ ### 注册词典
35
+
36
+ 用已合并进 `LocaleNamespaceMap` 的命名空间调用 `ctx.locale.register(ns, { zh, en })`;编译器会对照该命名空间的类型化键并集检查每个键,并要求两个内置 locale 齐全。消费方通过 `ctx.locale.bind(ns)` 或框架注入的 `t` 席位翻译。UI 已挂载后再注册的词典无需重新挂载即可生效。
37
+
38
+ ### 注册语言包
39
+
40
+ 外部 client 插件把语言定义和每个已翻译命名空间注册为自身拥有的 effect;定义与字典可以按任意顺序注册:
41
+
42
+ ```js
43
+ export const inject = ['locale']
44
+
45
+ export function apply(ctx) {
46
+ ctx.effect(
47
+ () => ctx.locale.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' }),
48
+ 'my-locale: language',
49
+ )
50
+ ctx.effect(
51
+ () => ctx.locale.register('common', 'ja', {
52
+ cancel: 'キャンセル',
53
+ close: '閉じる',
54
+ }),
55
+ 'my-locale: common dictionary',
56
+ )
57
+ }
58
+ ```
59
+
60
+ 外部 id 必须是非空的 ASCII BCP 47 风格标签。它的 fallback 必须已经注册,且整条链必须终止于 `en`;未知目标、重复 id 与循环会在注册时失败。查找时先在请求命名空间内遍历生效语言的 fallback 链,再在 `common` 中遍历该链,最后显示键本身。卸载语言定义会将其从选择器移除,并让生效中的选择回落到可用的浏览器语言或默认语言。
61
+
62
+ ### Host 半侧做什么
63
+
64
+ Host 通过 settings 服务为 loopback 页面持久化偏好。Client 会刻意拒绝非 loopback 页面使用该 settings scope,因此即使 Connection 认证所有 API 方法,它们的 locale 选择仍只存在于进程内。
65
+
66
+ -----
67
+
68
+ <a id="understand-the-implementation"></a>
69
+ ## 理解实现
70
+
71
+ <details>
72
+ <summary>实现细节——点击展开</summary>
73
+
74
+ 本节解释 locale 服务的构建方式;可观察行为已在[使用本包](#use-this-package)中说明。
6
75
 
76
+ ### 设计理念
77
+
78
+ 一个 `LocaleRuntime` 同时拥有偏好与词典注册表,并且自身就是 slot 系统的 `LocaleFace`:`getSnapshot`/`subscribe` 通过 `ctx.slots.installLocale` 支撑框架注入的 `t` 席位。不可变快照携带生效中的 locale、可选择的 locale 列表与单调 revision;词典注册与 locale 切换都会推进 revision,但只有切换会发出 `locale/change` 事件。产品编写的 Client UI 文本必须来自这些带类型的字典,或来自已经本地化的 primitive prop;`verify-client-ui-i18n` 强制执行该源码归属(见[决策](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md))。
79
+
80
+ ### 偏好解析
81
+
82
+ 临时 locale 来自浏览器(`navigator.languages` 先按完整标签、再按主子标签匹配,以 English 作为回退),在允许使用的 Host-backed settings scope 送达其存储偏好之前生效。Host 读取在插件激活后运行,因此 settings scope 不可用或被拒绝都不会阻塞页面,结果会实时替换临时值。已存储的外部 locale 会等待其定义注册。`setLocale` 是唯一写入入口;即使 id 已与生效中的 locale 匹配也会持久化,因为生效中的值可能是临时的,必须能在共享同一 home 的其他浏览器上存活。
83
+
84
+ ### 词典查找
85
+
86
+ 带类型的对象形式要求两个内置 locale 都有完整字典;逐 locale 形式允许语言包独立注册每个命名空间。逐键查找会先在请求命名空间中沿生效语言声明的 fallback 链查找,再在 `common` 中重复该链,最后显示键本身。绑定的翻译函数按命名空间保持稳定身份,因此可以挂在 inject 表面上而不破坏 memoization。
87
+
88
+ ### 源码地图
89
+
90
+ | 文件 | 职责 |
91
+ |---|---|
92
+ | [`src/client/index.ts`](src/client/index.ts) | `LocaleRuntime`、词典注册表、Language 行注册、`locale/change` 事件 |
93
+ | [`src/index.ts`](src/index.ts) | node 半侧:注册 `locale` 设置命名空间 |
94
+ | [`src/locale-settings.ts`](src/locale-settings.ts) | `locale.preference` 的持久 schema |
95
+ | [`src/locales/`](src/locales/) | 已发布的 `zh`/`en` 词典 |
96
+
97
+ </details>
98
+
99
+ -----
100
+
101
+ <a id="further-exploration"></a>
102
+ ## 进一步探索
103
+
104
+ 当 locale 约定不够用时阅读以下页面:它所实现的 slot 面孔、它所依托的设置界面,以及偏好背后的持久化决策。
105
+
106
+ - [客户端 slot 系统](../ui-slots/README.zh.md)——本包实现的 slot 模型与 `LocaleFace` 席位。
107
+ - [Host 支撑偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md)——偏好为何持久化在 Host 设置中而非浏览器里。
108
+ - [设置组地图](../../settings/README.zh.md)——存储该偏好的设置服务。
109
+ - [客户端组地图](../README.zh.md)——本包所属的浏览器半侧。
110
+
111
+ -----
112
+
113
+ <a id="model-experience"></a>
7
114
  ## 模型体验
8
115
 
9
- 无。locale 注册表为浏览器 UI 文案提供服务;这里没有任何内容进入模型请求。
116
+ 无。locale 服务属于浏览器侧 UI 插件层,不注册任何面向模型的内容。
10
117
 
11
118
  #### KV Cache 影响
12
119
 
13
120
  无;该包既不组装也不发送提供方请求。
14
121
 
15
- ## 已知限制与暂缓事项
122
+ ## 已知限制与延期工作
123
+
124
+ <a id="known-limitations-and-deferred-work"></a>
125
+
126
+
127
+ 这些限制说明本地化在哪些地方不完整,或在注册时被冻结。它们是当前包约束,不是任务积压。
16
128
 
17
- - **部分界面仍保留内联文案**——设置行、侧边栏、问题作答器和模型选择使用 locale seat;其他包仍直接拥有静态文本。
18
129
  - **注册表持有的文本只读取一次翻译**——在 slot 渲染路径之外于注册时捕获的文案(例如 command 注册表中的 `/model` 命令描述)在重新注册前保持注册时的语言;slot 渲染的文案随切换实时更新。
130
+ - **语言包负责语言特有行为**——注册表提供选择、持久化、浏览器匹配、逐 key 回退和 `<html lang>`;它不增加复数规则或双向布局。
131
+
132
+ <a id="dev-note"></a>
133
+ ### 开发备注
134
+
135
+ <details>
136
+ <summary>维护者的工作上下文——点击展开</summary>
137
+
138
+ 无。
139
+
140
+ </details>
package/lib/client.js CHANGED
@@ -7,7 +7,7 @@ window.__ModuleLoader__.load({
7
7
  let react_jsx_runtime = require("react/jsx-runtime");
8
8
  let react = require("react");
9
9
  let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
10
- let _deepseek_ai_dsh_client_runtime_client = require("@deepseek-ai/dsh-client-runtime/client");
10
+ let _deepseek_ai_dsh_client_store = require("@deepseek-ai/dsh-client-store");
11
11
  //#region ../../../vendor/cosmokit/src/misc.ts
12
12
  /** Return true when a value is `null` or `undefined`. */
13
13
  function isNullable(value) {
@@ -806,9 +806,11 @@ window.__ModuleLoader__.load({
806
806
  const LOCALE_SETTINGS_NAMESPACE = "locale";
807
807
  /** Field carrying an explicit locale selection; absence delegates to the browser. */
808
808
  const LOCALE_PREFERENCE_FIELD = "preference";
809
+ /** Accepted BCP 47-style language ids. */
810
+ const LOCALE_ID_PATTERN = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/u;
809
811
  /** Locale identifiers shipped by the browser client. */
810
812
  const LOCALE_IDS = ["zh", "en"];
811
- Schema.object({ [LOCALE_PREFERENCE_FIELD]: Schema.union([...LOCALE_IDS]).required(false) });
813
+ Schema.object({ [LOCALE_PREFERENCE_FIELD]: Schema.string().pattern(LOCALE_ID_PATTERN).required(false) });
812
814
  //#endregion
813
815
  //#region lib/types/locales/zh.js
814
816
  /** zh base dictionary for the common namespace: cross-feature standard words. */
@@ -818,6 +820,13 @@ window.__ModuleLoader__.load({
818
820
  "close": "关闭",
819
821
  "copy": "复制",
820
822
  "copied": "复制成功",
823
+ "copy.failed": "复制失败",
824
+ "copy.value": "复制值",
825
+ "copy.json": "复制 JSON",
826
+ "copy.path": "复制属性路径",
827
+ "copy.prettyJson": "复制格式化 JSON",
828
+ "copy.compactJson": "复制紧凑 JSON",
829
+ "copy.optionsHint": "{action};右键点击可选择复制方式",
821
830
  "retry": "重试",
822
831
  "loading": "加载中…",
823
832
  "load.failed": "加载失败",
@@ -834,9 +843,17 @@ window.__ModuleLoader__.load({
834
843
  "collapse": "收起",
835
844
  "expand": "展开",
836
845
  "back": "返回",
846
+ "brand.localBuild": "DSH 本地构建",
837
847
  "unknown": "未知",
838
848
  "none": "无",
839
- "truncated": "已截断"
849
+ "truncated": "已截断",
850
+ "json.collapseNode": "收起 JSON 节点",
851
+ "json.expandNode": "展开 JSON 节点",
852
+ "json.label": "JSON",
853
+ "markdown.footnotes": "脚注",
854
+ "markdown.truncatedCharacters": "… 已截断,共 {total} 字符",
855
+ "number.thousand": "{value}K",
856
+ "number.million": "{value}M"
840
857
  };
841
858
  //#endregion
842
859
  //#region lib/types/locales/en.js
@@ -847,6 +864,13 @@ window.__ModuleLoader__.load({
847
864
  "close": "Close",
848
865
  "copy": "Copy",
849
866
  "copied": "Copied",
867
+ "copy.failed": "Copy failed",
868
+ "copy.value": "Copy value",
869
+ "copy.json": "Copy JSON",
870
+ "copy.path": "Copy property path",
871
+ "copy.prettyJson": "Copy pretty JSON",
872
+ "copy.compactJson": "Copy compact JSON",
873
+ "copy.optionsHint": "{action}; right-click for copy options",
850
874
  "retry": "Retry",
851
875
  "loading": "Loading…",
852
876
  "load.failed": "Failed to load",
@@ -863,9 +887,17 @@ window.__ModuleLoader__.load({
863
887
  "collapse": "Collapse",
864
888
  "expand": "Expand",
865
889
  "back": "Back",
890
+ "brand.localBuild": "DSH Local Build",
866
891
  "unknown": "Unknown",
867
892
  "none": "None",
868
- "truncated": "Truncated"
893
+ "truncated": "Truncated",
894
+ "json.collapseNode": "Collapse JSON node",
895
+ "json.expandNode": "Expand JSON node",
896
+ "json.label": "JSON",
897
+ "markdown.footnotes": "Footnotes",
898
+ "markdown.truncatedCharacters": "… truncated at {total} characters",
899
+ "number.thousand": "{value}K",
900
+ "number.million": "{value}M"
869
901
  };
870
902
  //#endregion
871
903
  //#region lib/types/locales/settings.js
@@ -959,7 +991,7 @@ window.__ModuleLoader__.load({
959
991
  * @returns the store handle.
960
992
  */
961
993
  function createLanguageRowStore() {
962
- return (0, _deepseek_ai_dsh_client_runtime_client.defineStore)({
994
+ return (0, _deepseek_ai_dsh_client_store.defineStore)({
963
995
  init: () => ({
964
996
  active: "",
965
997
  options: [],
@@ -976,12 +1008,12 @@ window.__ModuleLoader__.load({
976
1008
  //#endregion
977
1009
  //#region lib/types/client/index.js
978
1010
  /**
979
- * English is both the locale the UI opens in when the browser names no shipped
1011
+ * English is both the locale the UI opens in when the browser names no registered
980
1012
  * language (and for non-browser runs), and the dictionary consulted after the
981
1013
  * active locale misses a key. One constant serves both because the shipped
982
1014
  * `zh`/`en` dictionaries carry identical key sets, so neither direction can
983
1015
  * leave a key unresolved; the residual case points at English rather than
984
- * zh because a browser naming neither shipped language is the reader least
1016
+ * zh because a browser naming no registered language is the reader least
985
1017
  * likely to read Chinese.
986
1018
  */
987
1019
  const FALLBACK_LOCALE = "en";
@@ -989,55 +1021,65 @@ window.__ModuleLoader__.load({
989
1021
  const COMMON_NS = "common";
990
1022
  /** Namespace owning this feature's settings-row copy. */
991
1023
  const SETTINGS_NS = "settings.locale";
992
- /** The two shipped locales. */
993
- const LOCALES = Object.freeze([{
994
- id: "zh",
995
- label: "中文"
996
- }, {
997
- id: "en",
998
- label: "English"
999
- }]);
1000
- /**
1001
- * `<html lang>` tag per shipped locale. The locale id is the app's own
1002
- * vocabulary (primary subtag); the document attribute wants a BCP 47 tag,
1003
- * which assistive technology and browser features (pronunciation rules,
1004
- * translation offers, font fallback, spell check) read to pick their own
1005
- * behavior. `zh` alone leaves the script ambiguous, so the shipped Chinese
1006
- * copy names the variant it actually is.
1007
- */
1008
- const DOCUMENT_LANGUAGE = {
1009
- zh: "zh-CN",
1010
- en: "en"
1024
+ /** The two locales and dictionaries shipped by this package. */
1025
+ const BUILT_IN_LOCALE_METADATA = {
1026
+ zh: {
1027
+ label: "中文",
1028
+ fallback: "en"
1029
+ },
1030
+ en: { label: "English" }
1011
1031
  };
1032
+ const BUILT_IN_LOCALES = Object.freeze(LOCALE_IDS.map((id) => Object.freeze({
1033
+ id,
1034
+ ...BUILT_IN_LOCALE_METADATA[id]
1035
+ })));
1036
+ /** Case-insensitive key for BCP 47-style ids. */
1037
+ function localeKey(value) {
1038
+ return value.toLowerCase();
1039
+ }
1040
+ /** Validate and detach a language-pack contribution from its mutable input. */
1041
+ function normalizeLanguage(input) {
1042
+ if (!LOCALE_ID_PATTERN.test(input.id)) throw new Error(`locale id "${input.id}" is not a BCP 47-style tag`);
1043
+ if (input.label.trim() === "") throw new Error("locale label must not be empty");
1044
+ if (!LOCALE_ID_PATTERN.test(input.fallback)) throw new Error(`locale fallback "${input.fallback}" is not a BCP 47-style tag`);
1045
+ return Object.freeze({
1046
+ id: input.id,
1047
+ label: input.label,
1048
+ fallback: input.fallback
1049
+ });
1050
+ }
1012
1051
  /**
1013
- * Point `<html lang>` at the active locale. Called on every locale change,
1014
- * so the attribute tracks the UI instead of standing at whatever the served
1015
- * markup happened to declare.
1016
- * @param active - the active locale id.
1052
+ * Point `<html lang>` at the active locale, keeping the served document in
1053
+ * sync with locale snapshot changes.
1054
+ * @param snapshot - current locale state, including the active definition.
1017
1055
  */
1018
- function syncDocumentLanguage(active) {
1056
+ function syncDocumentLanguage(snapshot) {
1019
1057
  if (typeof document === "undefined") return;
1020
- document.documentElement.lang = DOCUMENT_LANGUAGE[active];
1058
+ document.documentElement.lang = snapshot.active === "zh" ? "zh-CN" : snapshot.active;
1021
1059
  }
1022
1060
  /**
1023
- * Dictionary registry plus locale preference. Lookup chain per key: the
1024
- * entry's namespace in the active locale -> that namespace's en fallback ->
1025
- * the shared common namespace (active, then en) -> the key itself (missing
1026
- * text stays visible, fail loud in the UI rather than blank). Reads go
1027
- * through {@link getLocale}; writes only through {@link setLocale};
1028
- * continuous sync through the `locale/change` event, or through the
1029
- * LocaleFace getSnapshot/subscribe pair the render machinery consumes
1030
- * (installed via `ctx.slots.installLocale`).
1061
+ * Dictionary registry plus locale preference. Lookup walks the active
1062
+ * language's declared fallback chain in the entry namespace, then repeats it
1063
+ * in the shared common namespace before showing the key itself. Reads go
1064
+ * through {@link getLocale}; preferences change only through
1065
+ * {@link setLocale}, while language packs extend the catalog through
1066
+ * {@link addLanguage}. Continuous sync uses the `locale/change` event or
1067
+ * the LocaleFace getSnapshot/subscribe pair installed through
1068
+ * `ctx.slots.installLocale`.
1031
1069
  */
1032
1070
  var LocaleRuntime = class {
1033
1071
  dicts = /* @__PURE__ */ new Map();
1034
1072
  bound = /* @__PURE__ */ new Map();
1073
+ catalog = /* @__PURE__ */ new Map();
1074
+ fallbackChains = /* @__PURE__ */ new Map();
1035
1075
  snapshot;
1036
1076
  listeners = /* @__PURE__ */ new Set();
1037
1077
  ctx;
1038
1078
  host;
1039
1079
  /** Browser-derived locale standing wherever no explicit Host selection does. */
1040
1080
  provisional;
1081
+ /** Last explicit selection, including one awaiting an external registration. */
1082
+ preference;
1041
1083
  /**
1042
1084
  * @param ctx - owning context (change events are emitted on it; the scope
1043
1085
  * listener is released through ctx.effect on dispose).
@@ -1047,10 +1089,12 @@ window.__ModuleLoader__.load({
1047
1089
  constructor(ctx, host) {
1048
1090
  this.ctx = ctx;
1049
1091
  this.host = host;
1050
- this.provisional = resolveInitialLocale();
1092
+ for (const locale of BUILT_IN_LOCALES) this.catalog.set(localeKey(locale.id), locale);
1093
+ const locales = this.localeList();
1094
+ this.provisional = resolveInitialLocale(locales);
1051
1095
  this.snapshot = Object.freeze({
1052
1096
  active: this.provisional,
1053
- locales: LOCALES,
1097
+ locales,
1054
1098
  revision: 0
1055
1099
  });
1056
1100
  if (host !== void 0) {
@@ -1078,7 +1122,7 @@ window.__ModuleLoader__.load({
1078
1122
  /**
1079
1123
  * LocaleFace subscribe: notified on every snapshot change (locale switch
1080
1124
  * or dictionary registration — registrations bump the revision so already
1081
- * rendered outlets pick up late-arriving dictionaries).
1125
+ * rendered outlets pick up late-arriving dictionaries and locale definitions).
1082
1126
  * @param fn - change callback.
1083
1127
  * @returns unsubscribe.
1084
1128
  */
@@ -1101,12 +1145,49 @@ window.__ModuleLoader__.load({
1101
1145
  * @param id - a registered locale id; unknown ids throw.
1102
1146
  */
1103
1147
  setLocale(id) {
1104
- const match = this.snapshot.locales.find((l) => l.id === id);
1148
+ const match = this.catalog.get(localeKey(id));
1105
1149
  if (match === void 0) throw new Error(`locale "${id}" is not registered`);
1150
+ this.preference = match.id;
1106
1151
  if (this.snapshot.active !== match.id) this.publish(match.id, true);
1107
1152
  this.host?.set(LOCALE_PREFERENCE_FIELD, match.id);
1108
1153
  }
1109
1154
  /**
1155
+ * Add one selectable language to the shared catalog. Its fallback must
1156
+ * already be registered, and following fallback definitions must terminate
1157
+ * at English. Dictionaries may register before or after this definition.
1158
+ * Registration rechecks an unresolved Host preference and the browser's
1159
+ * ordered language list. The caller owns the returned disposer; removing an
1160
+ * active language falls back without clearing the stored id.
1161
+ * @param input - stable id, self-described label, and fallback language id.
1162
+ * @returns idempotent disposer removing this exact definition.
1163
+ * @throws when fields are malformed, the id is occupied, or the fallback
1164
+ * target is unknown or creates a cycle.
1165
+ */
1166
+ addLanguage(input) {
1167
+ const candidate = normalizeLanguage(input);
1168
+ const key = localeKey(candidate.id);
1169
+ if (this.catalog.has(key)) throw new Error(`locale "${candidate.id}" is already registered`);
1170
+ const fallback = this.catalog.get(localeKey(candidate.fallback));
1171
+ if (fallback === void 0) throw new Error(`locale fallback "${candidate.fallback}" is not registered`);
1172
+ const language = Object.freeze({
1173
+ ...candidate,
1174
+ fallback: fallback.id
1175
+ });
1176
+ this.catalog.set(key, language);
1177
+ try {
1178
+ this.assertFallbackChain(language.id);
1179
+ } catch (error) {
1180
+ this.catalog.delete(key);
1181
+ throw error;
1182
+ }
1183
+ this.publishCatalog();
1184
+ return () => {
1185
+ if (this.catalog.get(key) !== language) return;
1186
+ this.catalog.delete(key);
1187
+ this.publishCatalog();
1188
+ };
1189
+ }
1190
+ /**
1110
1191
  * Adopt the scope's accepted durable selection without writing it back; an
1111
1192
  * absent selection returns to the browser-derived locale.
1112
1193
  * @param host - the constructor-narrowed scope driving this adoption.
@@ -1114,19 +1195,74 @@ window.__ModuleLoader__.load({
1114
1195
  adopt(host) {
1115
1196
  const section = host.getSnapshot().value;
1116
1197
  if (section === void 0) return;
1117
- const target = section.preference ?? this.provisional;
1198
+ this.preference = section.preference;
1199
+ const target = this.resolveActive();
1118
1200
  if (this.snapshot.active === target) return;
1119
1201
  this.publish(target, true);
1120
1202
  }
1203
+ /** Recompute browser fallback and publish the current catalog. */
1204
+ publishCatalog() {
1205
+ this.fallbackChains.clear();
1206
+ const locales = this.localeList();
1207
+ this.provisional = resolveInitialLocale(locales);
1208
+ const active = this.resolveActive();
1209
+ this.publish(active, active !== this.snapshot.active, locales);
1210
+ }
1211
+ /** Resolve an explicit preference only while its definition is available. */
1212
+ resolveActive() {
1213
+ if (this.preference === void 0) return this.provisional;
1214
+ return this.catalog.get(localeKey(this.preference))?.id ?? this.provisional;
1215
+ }
1216
+ /** Snapshot the catalog in registration order. */
1217
+ localeList() {
1218
+ return Object.freeze([...this.catalog.values()]);
1219
+ }
1220
+ /** Fail a new definition whose complete fallback path does not reach English. */
1221
+ assertFallbackChain(start) {
1222
+ const seen = /* @__PURE__ */ new Set();
1223
+ let current = this.catalog.get(localeKey(start));
1224
+ while (current !== void 0) {
1225
+ const key = localeKey(current.id);
1226
+ if (seen.has(key)) throw new Error(`locale fallback cycle includes "${current.id}"`);
1227
+ seen.add(key);
1228
+ if (key === localeKey("en")) return;
1229
+ /* v8 ignore next -- English is the only built-in terminal and every
1230
+ * language accepted by addLanguage has a required fallback. */
1231
+ if (current.fallback === void 0) throw new Error(`locale "${current.id}" fallback chain does not reach "en"`);
1232
+ const next = this.catalog.get(localeKey(current.fallback));
1233
+ if (next === void 0) throw new Error(`locale fallback "${current.fallback}" is not registered`);
1234
+ current = next;
1235
+ }
1236
+ }
1237
+ /** Resolve a lookup chain, falling directly to English across an unload gap. */
1238
+ fallbackChain(start) {
1239
+ const startKey = localeKey(start);
1240
+ const cached = this.fallbackChains.get(startKey);
1241
+ if (cached !== void 0) return cached;
1242
+ const chain = [];
1243
+ const seen = /* @__PURE__ */ new Set();
1244
+ let current = this.catalog.get(startKey);
1245
+ while (current !== void 0 && !seen.has(localeKey(current.id))) {
1246
+ const key = localeKey(current.id);
1247
+ seen.add(key);
1248
+ chain.push(current.id);
1249
+ current = current.fallback === void 0 ? void 0 : this.catalog.get(localeKey(current.fallback));
1250
+ }
1251
+ if (!seen.has(localeKey("en"))) chain.push("en");
1252
+ const resolved = Object.freeze(chain);
1253
+ this.fallbackChains.set(startKey, resolved);
1254
+ return resolved;
1255
+ }
1121
1256
  register(ns, localeOrDicts, dict) {
1122
1257
  const pairs = typeof localeOrDicts === "string" ? [[localeOrDicts, dict]] : Object.entries(localeOrDicts);
1258
+ for (const [locale] of pairs) if (!LOCALE_ID_PATTERN.test(locale)) throw new Error(`locale id "${locale}" is not a BCP 47-style tag`);
1123
1259
  let locales = this.dicts.get(ns);
1124
1260
  if (!locales) {
1125
1261
  locales = /* @__PURE__ */ new Map();
1126
1262
  this.dicts.set(ns, locales);
1127
1263
  }
1128
- for (const [locale] of pairs) if (locales.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`);
1129
- for (const [locale, entries] of pairs) locales.set(locale, entries);
1264
+ for (const [locale] of pairs) if (locales.has(localeKey(locale))) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`);
1265
+ for (const [locale, entries] of pairs) locales.set(localeKey(locale), entries);
1130
1266
  this.publish(this.snapshot.active, false);
1131
1267
  return () => {
1132
1268
  const owner = this.dicts.get(ns);
@@ -1134,9 +1270,12 @@ window.__ModuleLoader__.load({
1134
1270
  * first register and never removed, so the disposer always finds it. */
1135
1271
  if (!owner) return;
1136
1272
  let removed = false;
1137
- for (const [locale, entries] of pairs) if (owner.get(locale) === entries) {
1138
- owner.delete(locale);
1139
- removed = true;
1273
+ for (const [locale, entries] of pairs) {
1274
+ const key = localeKey(locale);
1275
+ if (owner.get(key) === entries) {
1276
+ owner.delete(key);
1277
+ removed = true;
1278
+ }
1140
1279
  }
1141
1280
  if (removed) this.publish(this.snapshot.active, false);
1142
1281
  };
@@ -1151,13 +1290,17 @@ window.__ModuleLoader__.load({
1151
1290
  return t;
1152
1291
  }
1153
1292
  translate(ns, key, params) {
1154
- const template = this.lookup(ns, key) ?? (ns !== "common" ? this.lookup("common", key) : void 0) ?? key;
1293
+ const chain = this.fallbackChain(this.snapshot.active);
1294
+ const template = this.lookup(ns, key, chain) ?? (ns !== "common" ? this.lookup("common", key, chain) : void 0) ?? key;
1155
1295
  if (!params) return template;
1156
1296
  return template.replace(/\{(\w+)\}/g, (match, name) => name in params ? String(params[name]) : match);
1157
1297
  }
1158
- lookup(ns, key) {
1298
+ lookup(ns, key, chain) {
1159
1299
  const locales = this.dicts.get(ns);
1160
- return locales?.get(this.snapshot.active)?.[key] ?? locales?.get("en")?.[key];
1300
+ for (const locale of chain) {
1301
+ const value = locales?.get(localeKey(locale))?.[key];
1302
+ if (value !== void 0) return value;
1303
+ }
1161
1304
  }
1162
1305
  /**
1163
1306
  * Advance the snapshot revision and notify LocaleFace subscribers (render
@@ -1166,10 +1309,10 @@ window.__ModuleLoader__.load({
1166
1309
  * registration-heavy boot cannot storm event listeners (which may
1167
1310
  * re-register slots in response).
1168
1311
  */
1169
- publish(active, localeChanged) {
1312
+ publish(active, localeChanged, locales = this.snapshot.locales) {
1170
1313
  this.snapshot = Object.freeze({
1171
1314
  active,
1172
- locales: this.snapshot.locales,
1315
+ locales,
1173
1316
  revision: this.snapshot.revision + 1
1174
1317
  });
1175
1318
  if (localeChanged) this.ctx.emit("locale/change", this.snapshot);
@@ -1184,30 +1327,35 @@ window.__ModuleLoader__.load({
1184
1327
  * The browser's own language wins over {@link FALLBACK_LOCALE}; an explicit
1185
1328
  * Host preference may replace this provisional value after plugin activation.
1186
1329
  */
1187
- function resolveInitialLocale() {
1188
- return detectBrowserLocale() ?? "en";
1330
+ function resolveInitialLocale(locales) {
1331
+ return detectBrowserLocale(locales) ?? "en";
1189
1332
  }
1190
1333
  /**
1191
- * The first shipped locale the browser asks for, matched on the primary
1192
- * subtag so every regional variant lands on its language (`zh-Hans-CN` -> zh,
1193
- * `en-GB` -> en). `window` is the browser test, not `navigator`: Node exposes
1194
- * a global `navigator` reporting the machine's own language, which would
1195
- * otherwise decide the locale for non-browser runs (node e2e booting the
1196
- * client tree). `navigator.language` trails the ordered `languages` list and
1197
- * covers its absence on hosts that expose only the single tag.
1334
+ * The first registered locale the browser asks for. Each browser tag first
1335
+ * matches a locale id exactly, then its primary subtag, so an exact regional
1336
+ * registration wins before a language-wide fallback.
1337
+ * `window` is the browser test, not `navigator`: Node exposes a global
1338
+ * `navigator` reporting the machine's own language, which must not decide the
1339
+ * locale for non-browser runs. `navigator.language` trails the ordered
1340
+ * `languages` list and covers hosts exposing only the single tag.
1341
+ * @param locales - definitions currently available to the browser.
1342
+ * @returns the first matching locale id, or undefined.
1198
1343
  */
1199
- function detectBrowserLocale() {
1344
+ function detectBrowserLocale(locales) {
1200
1345
  if (typeof window === "undefined") return void 0;
1201
- for (const tag of [...navigator.languages ?? [], navigator.language]) {
1202
- const primary = tag.toLowerCase().split("-")[0];
1203
- const match = LOCALES.find((locale) => locale.id === primary);
1204
- if (match) return match.id;
1346
+ const languages = navigator.languages;
1347
+ for (const tag of [...languages ?? [], navigator.language]) {
1348
+ const requested = localeKey(tag);
1349
+ const exact = locales.find((locale) => localeKey(locale.id) === requested);
1350
+ if (exact !== void 0) return exact.id;
1351
+ const primary = requested.split("-")[0];
1352
+ const match = locales.find((locale) => localeKey(locale.id).split("-")[0] === primary);
1353
+ if (match !== void 0) return match.id;
1205
1354
  }
1206
1355
  }
1207
1356
  /** Required services: slot registration plus the settings transport. */
1208
1357
  const inject = [
1209
1358
  "slots",
1210
- "connection",
1211
1359
  "remote",
1212
1360
  "settingsScope"
1213
1361
  ];
@@ -1231,18 +1379,19 @@ window.__ModuleLoader__.load({
1231
1379
  ctx.slots.installLocale(locale);
1232
1380
  const store = createLanguageRowStore();
1233
1381
  let bound;
1234
- const sync = (snapshot) => {
1235
- syncDocumentLanguage(snapshot.active);
1382
+ const sync = () => {
1383
+ const snapshot = locale.getSnapshot();
1384
+ syncDocumentLanguage(snapshot);
1236
1385
  bound?.sync(snapshot.active, snapshot.locales.map((l) => ({
1237
1386
  id: l.id,
1238
1387
  label: l.label
1239
1388
  })), snapshot.revision);
1240
1389
  };
1241
- ctx.on("locale/change", sync);
1242
- syncDocumentLanguage(locale.getLocale().active);
1390
+ ctx.effect(() => locale.subscribe(sync), "locale: language row and document synchronization");
1391
+ sync();
1243
1392
  const injected = (actions) => {
1244
1393
  bound = actions;
1245
- sync(locale.getLocale());
1394
+ sync();
1246
1395
  return { setLocale: (id) => {
1247
1396
  locale.setLocale(id);
1248
1397
  } };
package/lib/index.js CHANGED
@@ -1,4 +1,3 @@
1
- import { settingsNamespace } from "@deepseek-ai/dsh-settings";
2
1
  import z from "@deepseek-ai/schemastery";
3
2
  //#region lib/types/locale-settings.js
4
3
  /** Locale preference stored in the Host user-settings document. */
@@ -6,10 +5,12 @@ import z from "@deepseek-ai/schemastery";
6
5
  const LOCALE_SETTINGS_NAMESPACE = "locale";
7
6
  /** Field carrying an explicit locale selection; absence delegates to the browser. */
8
7
  const LOCALE_PREFERENCE_FIELD = "preference";
8
+ /** Accepted BCP 47-style language ids. */
9
+ const LOCALE_ID_PATTERN = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/u;
9
10
  /** Locale identifiers shipped by the browser client. */
10
11
  const LOCALE_IDS = ["zh", "en"];
11
12
  /** Durable locale schema; also the wire envelope the browser scope validates against. */
12
- const LocaleSettingsSchema = z.object({ [LOCALE_PREFERENCE_FIELD]: z.union([...LOCALE_IDS]).required(false) });
13
+ const LocaleSettingsSchema = z.object({ [LOCALE_PREFERENCE_FIELD]: z.string().pattern(LOCALE_ID_PATTERN).required(false) });
13
14
  //#endregion
14
15
  //#region lib/types/index.js
15
16
  /** Host registration for the browser locale preference. */
@@ -19,7 +20,7 @@ const LocaleSettingsSchema = z.object({ [LOCALE_PREFERENCE_FIELD]: z.union([...L
19
20
  */
20
21
  function apply(ctx) {
21
22
  ctx.inject(["settings"], (settingsCtx) => {
22
- settingsCtx.settings.register(settingsNamespace(LOCALE_SETTINGS_NAMESPACE), LocaleSettingsSchema);
23
+ settingsCtx.settings.register(LOCALE_SETTINGS_NAMESPACE, LocaleSettingsSchema);
23
24
  });
24
25
  }
25
26
  //#endregion
package/lib/invariant.js CHANGED
@@ -9,10 +9,9 @@ const name = "client-locale-invariant";
9
9
  /** Service required before the companion can reserve package ownership. */
10
10
  const inject = ["invariants"];
11
11
  /**
12
- * No runtime invariant: ns-by-locale dictionary registry with a stable
13
- * bind(ns) API it emits no cordis events and owns no cross-plugin
14
- * mutable relation; fallback-chain resolution and locale-store behavior are
15
- * asserted directly by this package's behavior specs.
12
+ * No runtime invariant: the locale catalog and dictionaries have no
13
+ * independent runtime source to compare against; registration disposal,
14
+ * preference resolution, and fallback lookup are asserted by behavior specs.
16
15
  */
17
16
  const install = () => {};
18
17
  /**
@@ -4,16 +4,16 @@
4
4
  * preference row into the settings General section — the locale feature owns
5
5
  * its own settings surface.
6
6
  */
7
- import type { Context } from '@deepseek-ai/cordis';
7
+ import type { Context as ClientContext } from '@deepseek-ai/cordis';
8
8
  import { type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS } from '@deepseek-ai/dsh-client-ui-slots';
9
- import type { ClientContext, SettingsScope } from '@deepseek-ai/dsh-client-runtime/client';
10
- import { type LocaleId, type LocaleSettings } from '../locale-settings.ts';
9
+ import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client';
10
+ import { type BuiltInLocaleId, type LocaleId, type LocaleSettings } from '../locale-settings.ts';
11
11
  import { type CommonKey } from '../locales/index.ts';
12
12
  import { type SettingsLocaleKey } from '../locales/settings.ts';
13
13
  export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageRow.tsx';
14
14
  export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts';
15
15
  export type { CommonKey } from '../locales/index.ts';
16
- export type { LocaleId, LocaleSettings } from '../locale-settings.ts';
16
+ export type { BuiltInLocaleId, LocaleId, LocaleSettings } from '../locale-settings.ts';
17
17
  export type { Translate, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots';
18
18
  declare module '@deepseek-ai/dsh-client-ui-slots' {
19
19
  interface LocaleNamespaceMap {
@@ -25,12 +25,23 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
25
25
  }
26
26
  /** Locale dictionary: flat key to template string ({name} placeholders). */
27
27
  export type LocaleDict = Record<string, string>;
28
- /** One selectable locale: id plus its self-described display name. */
29
- export interface LocaleDefinition {
30
- /** Locale id (persisted; the setLocale argument). */
28
+ /** Input accepted when a language-pack plugin adds a selectable language. */
29
+ export interface LanguageRegistration {
30
+ /** Stable BCP 47-style id stored as the locale preference. */
31
31
  id: LocaleId;
32
- /** Display name in its own language (中文 / English). */
32
+ /** Display name written in the represented language. */
33
33
  label: string;
34
+ /** Registered language consulted when this language lacks a dictionary key. */
35
+ fallback: LocaleId;
36
+ }
37
+ /** One normalized selectable locale published in snapshots. */
38
+ export interface LocaleDefinition {
39
+ /** Stable id persisted by {@link LocaleRuntime.setLocale}. */
40
+ readonly id: LocaleId;
41
+ /** Display name written in the represented language. */
42
+ readonly label: string;
43
+ /** Next language in the per-key fallback chain; absent only for English. */
44
+ readonly fallback?: LocaleId;
34
45
  }
35
46
  /** Immutable locale state published on every change. */
36
47
  export interface LocaleSnapshot {
@@ -58,45 +69,49 @@ declare module '@deepseek-ai/cordis' {
58
69
  }
59
70
  }
60
71
  /**
61
- * English is both the locale the UI opens in when the browser names no shipped
72
+ * English is both the locale the UI opens in when the browser names no registered
62
73
  * language (and for non-browser runs), and the dictionary consulted after the
63
74
  * active locale misses a key. One constant serves both because the shipped
64
75
  * `zh`/`en` dictionaries carry identical key sets, so neither direction can
65
76
  * leave a key unresolved; the residual case points at English rather than
66
- * zh because a browser naming neither shipped language is the reader least
77
+ * zh because a browser naming no registered language is the reader least
67
78
  * likely to read Chinese.
68
79
  */
69
- export declare const FALLBACK_LOCALE: LocaleId;
80
+ export declare const FALLBACK_LOCALE: BuiltInLocaleId;
70
81
  /** Shared namespace for shell-level texts. */
71
82
  export declare const COMMON_NS = "common";
72
83
  /** Namespace owning this feature's settings-row copy. */
73
84
  export declare const SETTINGS_NS = "settings.locale";
74
85
  /**
75
- * Dictionary registry plus locale preference. Lookup chain per key: the
76
- * entry's namespace in the active locale -> that namespace's en fallback ->
77
- * the shared common namespace (active, then en) -> the key itself (missing
78
- * text stays visible, fail loud in the UI rather than blank). Reads go
79
- * through {@link getLocale}; writes only through {@link setLocale};
80
- * continuous sync through the `locale/change` event, or through the
81
- * LocaleFace getSnapshot/subscribe pair the render machinery consumes
82
- * (installed via `ctx.slots.installLocale`).
86
+ * Dictionary registry plus locale preference. Lookup walks the active
87
+ * language's declared fallback chain in the entry namespace, then repeats it
88
+ * in the shared common namespace before showing the key itself. Reads go
89
+ * through {@link getLocale}; preferences change only through
90
+ * {@link setLocale}, while language packs extend the catalog through
91
+ * {@link addLanguage}. Continuous sync uses the `locale/change` event or
92
+ * the LocaleFace getSnapshot/subscribe pair installed through
93
+ * `ctx.slots.installLocale`.
83
94
  */
84
95
  export declare class LocaleRuntime {
85
96
  private dicts;
86
97
  private bound;
98
+ private catalog;
99
+ private fallbackChains;
87
100
  private snapshot;
88
101
  private listeners;
89
102
  private readonly ctx;
90
103
  private readonly host;
91
104
  /** Browser-derived locale standing wherever no explicit Host selection does. */
92
- private readonly provisional;
105
+ private provisional;
106
+ /** Last explicit selection, including one awaiting an external registration. */
107
+ private preference;
93
108
  /**
94
109
  * @param ctx - owning context (change events are emitted on it; the scope
95
110
  * listener is released through ctx.effect on dispose).
96
111
  * @param host - durable preference scope owned by the providing plugin;
97
112
  * absent compositions (standalone dictionary registries) stay process-local.
98
113
  */
99
- constructor(ctx: Context, host?: SettingsScope<LocaleSettings>);
114
+ constructor(ctx: ClientContext, host?: SettingsScope<LocaleSettings>);
100
115
  /**
101
116
  * Read the current immutable locale snapshot.
102
117
  * @returns the current snapshot (stable reference until the next change).
@@ -111,7 +126,7 @@ export declare class LocaleRuntime {
111
126
  /**
112
127
  * LocaleFace subscribe: notified on every snapshot change (locale switch
113
128
  * or dictionary registration — registrations bump the revision so already
114
- * rendered outlets pick up late-arriving dictionaries).
129
+ * rendered outlets pick up late-arriving dictionaries and locale definitions).
115
130
  * @param fn - change callback.
116
131
  * @returns unsubscribe.
117
132
  */
@@ -129,12 +144,35 @@ export declare class LocaleRuntime {
129
144
  * @param id - a registered locale id; unknown ids throw.
130
145
  */
131
146
  setLocale(id: string): void;
147
+ /**
148
+ * Add one selectable language to the shared catalog. Its fallback must
149
+ * already be registered, and following fallback definitions must terminate
150
+ * at English. Dictionaries may register before or after this definition.
151
+ * Registration rechecks an unresolved Host preference and the browser's
152
+ * ordered language list. The caller owns the returned disposer; removing an
153
+ * active language falls back without clearing the stored id.
154
+ * @param input - stable id, self-described label, and fallback language id.
155
+ * @returns idempotent disposer removing this exact definition.
156
+ * @throws when fields are malformed, the id is occupied, or the fallback
157
+ * target is unknown or creates a cycle.
158
+ */
159
+ addLanguage(input: LanguageRegistration): () => void;
132
160
  /**
133
161
  * Adopt the scope's accepted durable selection without writing it back; an
134
162
  * absent selection returns to the browser-derived locale.
135
163
  * @param host - the constructor-narrowed scope driving this adoption.
136
164
  */
137
165
  private adopt;
166
+ /** Recompute browser fallback and publish the current catalog. */
167
+ private publishCatalog;
168
+ /** Resolve an explicit preference only while its definition is available. */
169
+ private resolveActive;
170
+ /** Snapshot the catalog in registration order. */
171
+ private localeList;
172
+ /** Fail a new definition whose complete fallback path does not reach English. */
173
+ private assertFallbackChain;
174
+ /** Resolve a lookup chain, falling directly to English across an unload gap. */
175
+ private fallbackChain;
138
176
  /**
139
177
  * Register a declared namespace's dictionaries, all locales in one call —
140
178
  * the typed form: each dictionary is checked against the namespace's
@@ -144,17 +182,18 @@ export declare class LocaleRuntime {
144
182
  * namespace's texts have one owner). Registration bumps the revision so
145
183
  * mounted outlets pick up late-arriving dictionaries.
146
184
  * @param ns - a namespace merged into LocaleNamespaceMap.
147
- * @param dicts - complete dictionaries keyed by locale id.
185
+ * @param dicts - complete dictionaries keyed by built-in locale id.
148
186
  * @returns disposer removing every locale registered by this call (idempotent).
149
187
  */
150
- register<N extends keyof LocaleNamespaceMap & string>(ns: N, dicts: Record<LocaleId, LocaleDictOf<N>>): () => void;
188
+ register<N extends Extract<keyof LocaleNamespaceMap, string>>(ns: N, dicts: Record<BuiltInLocaleId, LocaleDictOf<N>>): () => void;
151
189
  /**
152
- * Single-locale untyped form for namespaces outside the merge table
153
- * (dynamic composition, tests).
190
+ * Single-locale untyped form for language-pack contributions and namespaces
191
+ * outside the merge table.
154
192
  * @param ns - namespace.
155
193
  * @param locale - locale tag.
156
194
  * @param dict - dictionary.
157
195
  * @returns disposer (idempotent).
196
+ * @throws when locale is not a BCP 47-style tag.
158
197
  */
159
198
  register(ns: string, locale: string, dict: LocaleDict): () => void;
160
199
  /**
@@ -166,7 +205,7 @@ export declare class LocaleRuntime {
166
205
  * @param ns - a namespace merged into LocaleNamespaceMap.
167
206
  * @returns the typed translate function (reads the active locale at call time).
168
207
  */
169
- bind<N extends keyof LocaleNamespaceMap & string>(ns: N): TranslateNS<N>;
208
+ bind<N extends Extract<keyof LocaleNamespaceMap, string>>(ns: N): TranslateNS<N>;
170
209
  /**
171
210
  * Untyped form for namespaces outside the merge table (dynamic
172
211
  * composition, tests).
@@ -3,7 +3,7 @@
3
3
  * plugin's apply-world change listener is the only writer; the row component
4
4
  * reads via props.useStore.
5
5
  */
6
- import { type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client';
6
+ import { type EngineStoreHandle } from '@deepseek-ai/dsh-client-store';
7
7
  /** One selectable locale row (id + self-described label). */
8
8
  export interface LanguageOptionRow {
9
9
  /** Locale id (the setLocale argument). */
@@ -1,6 +1,6 @@
1
1
  /** Host registration for the browser locale preference. */
2
2
  import type { Context } from '@deepseek-ai/cordis';
3
- export { LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, type LocaleSettings, } from './locale-settings.ts';
3
+ export { LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type BuiltInLocaleId, type LocaleId, type LocaleSettings, } from './locale-settings.ts';
4
4
  /**
5
5
  * Register the durable locale section when a settings provider exists.
6
6
  * @param ctx - Host context whose optional settings service owns the section.
@@ -4,10 +4,14 @@ import z from '@deepseek-ai/schemastery';
4
4
  export declare const LOCALE_SETTINGS_NAMESPACE = "locale";
5
5
  /** Field carrying an explicit locale selection; absence delegates to the browser. */
6
6
  export declare const LOCALE_PREFERENCE_FIELD = "preference";
7
+ /** Accepted BCP 47-style language ids. */
8
+ export declare const LOCALE_ID_PATTERN: RegExp;
7
9
  /** Locale identifiers shipped by the browser client. */
8
10
  export declare const LOCALE_IDS: readonly ["zh", "en"];
9
- /** Shipped locale identifier. */
10
- export type LocaleId = typeof LOCALE_IDS[number];
11
+ /** Locale identifier shipped by the browser client. */
12
+ export type BuiltInLocaleId = typeof LOCALE_IDS[number];
13
+ /** Open locale identifier accepted from language-pack plugins. */
14
+ export type LocaleId = string;
11
15
  /** Durable locale section shared by the Host schema and the browser scope. */
12
16
  export interface LocaleSettings {
13
17
  /** Explicit locale selection; absence delegates to the browser. */
@@ -5,6 +5,13 @@ export declare const en: {
5
5
  close: string;
6
6
  copy: string;
7
7
  copied: string;
8
+ 'copy.failed': string;
9
+ 'copy.value': string;
10
+ 'copy.json': string;
11
+ 'copy.path': string;
12
+ 'copy.prettyJson': string;
13
+ 'copy.compactJson': string;
14
+ 'copy.optionsHint': string;
8
15
  retry: string;
9
16
  loading: string;
10
17
  'load.failed': string;
@@ -21,8 +28,16 @@ export declare const en: {
21
28
  collapse: string;
22
29
  expand: string;
23
30
  back: string;
31
+ 'brand.localBuild': string;
24
32
  unknown: string;
25
33
  none: string;
26
34
  truncated: string;
35
+ 'json.collapseNode': string;
36
+ 'json.expandNode': string;
37
+ 'json.label': string;
38
+ 'markdown.footnotes': string;
39
+ 'markdown.truncatedCharacters': string;
40
+ 'number.thousand': string;
41
+ 'number.million': string;
27
42
  };
28
43
  //# sourceMappingURL=en.d.ts.map
@@ -5,6 +5,13 @@ export declare const zh: {
5
5
  close: string;
6
6
  copy: string;
7
7
  copied: string;
8
+ 'copy.failed': string;
9
+ 'copy.value': string;
10
+ 'copy.json': string;
11
+ 'copy.path': string;
12
+ 'copy.prettyJson': string;
13
+ 'copy.compactJson': string;
14
+ 'copy.optionsHint': string;
8
15
  retry: string;
9
16
  loading: string;
10
17
  'load.failed': string;
@@ -21,9 +28,17 @@ export declare const zh: {
21
28
  collapse: string;
22
29
  expand: string;
23
30
  back: string;
31
+ 'brand.localBuild': string;
24
32
  unknown: string;
25
33
  none: string;
26
34
  truncated: string;
35
+ 'json.collapseNode': string;
36
+ 'json.expandNode': string;
37
+ 'json.label': string;
38
+ 'markdown.footnotes': string;
39
+ 'markdown.truncatedCharacters': string;
40
+ 'number.thousand': string;
41
+ 'number.million': string;
27
42
  };
28
43
  /** The common vocabulary key union (zh is the key-set source of truth). */
29
44
  export type CommonKey = keyof typeof zh;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-client-locale",
3
- "description": "Locale plugin: Host-backed zh/en preference, browser-derived fallback, locale snapshots, and typed namespace dictionaries",
4
- "version": "0.1.1-rc.2",
3
+ "description": "Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries",
4
+ "version": "0.1.2-alpha.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -33,7 +33,7 @@
33
33
  "client": {
34
34
  "inject": [
35
35
  "@deepseek-ai/dsh-client-connection",
36
- "@deepseek-ai/dsh-client-runtime",
36
+ "@deepseek-ai/dsh-client-ui-renderer",
37
37
  "@deepseek-ai/dsh-client-ui-settings",
38
38
  "@deepseek-ai/dsh-api-remotes"
39
39
  ],
@@ -43,30 +43,25 @@
43
43
  },
44
44
  "license": "MIT",
45
45
  "peerDependencies": {
46
- "@deepseek-ai/cordis": "^4.0.1",
47
- "@deepseek-ai/dsh-client-connection": "^0.1.1-rc.2",
48
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.1-rc.2",
49
- "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
50
- "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
51
- "@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
52
- "@deepseek-ai/dsh-api-remotes": "^0.1.1-rc.2"
46
+ "@deepseek-ai/cordis": "^4.0.2"
53
47
  },
54
48
  "devDependencies": {
55
49
  "@types/react": "~18.3.1",
56
50
  "react": "^18.2.0",
57
- "@deepseek-ai/cordis": "^4.0.1",
58
- "@deepseek-ai/dsh-api-remotes": "^0.1.1-rc.2",
59
- "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
60
- "@deepseek-ai/dsh-client-test-runtime": "^0.1.1-rc.2",
61
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.1-rc.2",
62
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.2",
63
- "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
64
- "@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
65
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.1-rc.2",
66
- "@deepseek-ai/dsh-client-connection": "^0.1.1-rc.2"
51
+ "@deepseek-ai/cordis": "^4.0.2",
52
+ "@deepseek-ai/dsh-api-remotes": "^0.1.2-alpha.2",
53
+ "@deepseek-ai/dsh-client-store": "^0.1.2-alpha.2",
54
+ "@deepseek-ai/dsh-client-test-runtime": "^0.1.2-alpha.2",
55
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-alpha.2",
56
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.2-alpha.2",
57
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.2-alpha.2",
58
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.2",
59
+ "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.2",
60
+ "@deepseek-ai/dsh-client-connection": "^0.1.2-alpha.2",
61
+ "@deepseek-ai/dsh-settings": "^0.1.2-alpha.2"
67
62
  },
68
63
  "dependencies": {
69
- "@deepseek-ai/schemastery": "^3.18.1"
64
+ "@deepseek-ai/schemastery": "^3.18.2"
70
65
  },
71
66
  "files": [
72
67
  "lib/index.js",