mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Merge pull request #2966 from deepseek-harness/worktree-locale2
feat(locale): allow external language registration
This commit is contained in:
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md
|
||||
2026-07-30-client-locale-full-rollout.md: aeb4deae28b0dfdb9ab75fd64fe3143958cd6910
|
||||
2026-07-30-client-locale-full-rollout.zh.md: a642b6062cb3dc7a2dfa22dd5d8cf7d9a02e3104
|
||||
2026-07-30-client-locale-full-rollout.md: dedfe98ca2b3e64a56518dfa6157244e4d4c16df
|
||||
2026-07-30-client-locale-full-rollout.zh.md: e9bd1ed19e8b485d812140ab044c779a2ce6e9d3
|
||||
|
||||
@@ -14,6 +14,8 @@ After the typed locale standard seat landed (`locale:` on register → framework
|
||||
|
||||
**Component copy rides the standard `t` seat; deep children take `t` as a plain prop** typed `XxxProps['t']`. The dictionary canon is unchanged: `zh satisfies Record<string, string>` is the key source and `en satisfies Record<XxxKey, string>` locks bilingual balance.
|
||||
|
||||
**The built-in locale set is closed; the language catalog is extensible.** The package contributes only `zh` and `en`, and typed namespace registration continues to require that bilingual pair. An external client plugin adds a language through `ctx.effect(() => ctx.locale.addLanguage({ id, label, fallback }))` and contributes partial translations through the existing single-locale dictionary registration; language definitions and dictionaries may register in either order. An external language id is its validated BCP 47 tag for preference storage, dictionary lookup, browser matching, and `<html lang>`; `LocaleId` remains a string because the tag carries interoperable language semantics rather than opaque identity. The built-in `zh` definition retains its internal `zh-CN` document tag. Every added language names a registered fallback whose own definition supplies the next fallback, and the chain must terminate at `en`; unknown targets and cycles fail at registration. For each key, lookup walks that chain in the requested namespace, then repeats it in `common`, before displaying the key itself. The Host stores an open string preference; an unavailable saved id remains pending until its language registers, while removal returns an active selection to the available browser match or `en`. Catalog changes advance the `LocaleFace` revision so the Language row follows registration and disposal.
|
||||
|
||||
**Zero-Cordis atoms (ui-primitives) take copy as required props.** `HoverCard`, structured Tool blocks, JSON/Markdown renderers, `ConnectionBanner`, and modal chrome remain runtime-independent; localized plugins pass complete dictionary-driven label objects from their own `t` seat and memoize cache-sensitive objects on the `t` identity. The removal of language-bearing defaults and the complete prop inventory are owned by the [locale-owned copy decision](2026-08-23-locale-owned-client-ui-copy.md).
|
||||
|
||||
**Every product-authored UI phrase is translated.** Client fallbacks, design labels, trajectory inspection, accessibility names, and formatter units are dictionary-owned under the [locale-owned copy decision](2026-08-23-locale-owned-client-ui-copy.md). User/model/provider/wire text and protocol or code tokens remain verbatim data. Framework-free boot markup still runs before the locale service; the localized application replaces its product copy after activation.
|
||||
@@ -37,4 +39,4 @@ The "apply layer subscribes to `locale/change` and re-registers for fresh labels
|
||||
- A language switch refreshes the whole UI instantly with zero re-registration; adopting a new package is three steps (dictionary + declare-merge + `locale: NS`), no hand-written glue.
|
||||
- Cost: list-label consumers must know `resolveSlotLabel` (a raw `options.label` read can now hold a function); the `SlotLabel` type catches most misuse statically.
|
||||
- ui-primitives require localized label props, so adding a primitive render site also adds an explicit copy owner; omission fails typechecking instead of selecting a hidden language.
|
||||
- Pinning e2e to English means the zh copy surface is covered mainly by package-level component specs and the settings language-switch scenario; browser e2e no longer asserts zh copy. The opening/fallback locale (a browser naming no shipped language, or a non-browser run) is `en`, not zh — see [browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md).
|
||||
- Pinning e2e to English means the zh copy surface is covered mainly by package-level component specs and the settings language-switch scenario; browser e2e no longer asserts zh copy. The opening/fallback locale (a browser naming no registered language, or a non-browser run) is `en`, not zh — see [browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md).
|
||||
|
||||
@@ -14,6 +14,8 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`
|
||||
|
||||
**组件文案走标准 `t` 席位;深层子组件用 prop 下传**,类型写 `XxxProps['t']`。字典规范形态不变:`zh satisfies Record<string, string>` 为 key 源、`en satisfies Record<XxxKey, string>` 锁双语平衡。
|
||||
|
||||
**内置 locale 集合封闭,语言目录可扩展。** 本包只提供 `zh` 与 `en`,类型化命名空间注册仍要求这对双语字典。外部 client 插件通过 `ctx.effect(() => ctx.locale.addLanguage({ id, label, fallback }))` 增加语言,并通过既有的单 locale 字典注册贡献不完整翻译;语言定义与字典可以按任意顺序注册。外部语言 id 是经过校验的 BCP 47 标签,同时用于偏好存储、字典查找、浏览器匹配和 `<html lang>`;该标签承载可互操作的语言语义而非不透明身份,因此 `LocaleId` 保持 string。内置 `zh` 定义继续使用内部 `zh-CN` 文档标签。每个新增语言都声明一个已注册的 fallback,fallback 自身的定义给出下一层 fallback,整条链必须终止于 `en`;未知目标和循环在注册时失败。每个 key 先在请求的命名空间中沿链查找,再在 `common` 中重复同一条链,最后显示 key 本身。Host 存储开放字符串偏好;不可用的已保存 id 会保持待采用,直至对应语言注册;定义移除后,正在使用的选择会回落到可用的浏览器匹配或 `en`。目录变更推进 `LocaleFace` revision,使语言设置行跟随注册和 dispose。
|
||||
|
||||
**zero-Cordis 原子组件(ui-primitives)通过必填 prop 接收文案。** `HoverCard`、结构化工具块、JSON/Markdown 渲染器、`ConnectionBanner` 和 modal chrome 均保持运行时独立;已本地化插件从自己的 `t` 席位传入完整的字典驱动 label 对象,对缓存敏感的对象按 `t` 身份 memo。移除带语言默认值以及完整 prop 清单由 [locale 归属文案决策](2026-08-23-locale-owned-client-ui-copy.zh.md)负责。
|
||||
|
||||
**所有产品编写的 UI 短语都翻译。** client 兜底文案、设计 label、trajectory 检查面、无障碍名称和格式化单位均按 [locale 归属文案决策](2026-08-23-locale-owned-client-ui-copy.zh.md)进入字典。用户/模型/提供方/wire 文本以及协议或代码 token 仍作为数据原样呈现。不依赖框架的 boot 标记仍早于 locale 服务运行;本地化应用激活后会替换其中的产品文案。
|
||||
@@ -37,4 +39,4 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`
|
||||
- 语言切换全 UI 即时刷新且零重注册;新包接入 = 字典 + declare-merge + `locale: NS` 三步,无手写胶水。
|
||||
- 代价:list label 的消费方必须知道 `resolveSlotLabel`(裸读 `options.label` 现在可能拿到函数);类型上 `SlotLabel` 已挡住多数误用。
|
||||
- ui-primitives 要求本地化 label prop,因此新增原子组件渲染点也必须新增明确的文案 owner;遗漏会在类型检查失败,而不是选择隐藏语言。
|
||||
- e2e 英文钉死意味着 zh 文案面主要靠包级组件测试与 settings 语言切换用例覆盖,浏览器 e2e 不再验证 zh 文案。开场/回落 locale(声明了本应用都不支持语言的浏览器,或非浏览器运行)是 `en` 而非 `zh`,见 [browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.zh.md)。
|
||||
- e2e 英文钉死意味着 zh 文案面主要靠包级组件测试与 settings 语言切换用例覆盖,浏览器 e2e 不再验证 zh 文案。开场/回落 locale(浏览器未声明任何已注册语言,或非浏览器运行)是 `en` 而非 `zh`,见 [browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.zh.md)。
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md
|
||||
2026-07-31-browser-derived-initial-locale.md: 66fd56327aeb4463bfb8f6426ce7f7962d339782
|
||||
2026-07-31-browser-derived-initial-locale.zh.md: 721a785aa476951e7254c50230ddc092b9f8b211
|
||||
2026-07-31-browser-derived-initial-locale.md: 28b5c98d5148854cc231e70064e91b21cd5c1184
|
||||
2026-07-31-browser-derived-initial-locale.zh.md: 34741af01a737b8ab9bb4394385a9140ace8f506
|
||||
|
||||
@@ -8,21 +8,21 @@ English | [中文](2026-07-31-browser-derived-initial-locale.zh.md)
|
||||
|
||||
The Settings Language row opened every first visit in Chinese: `LocaleRuntime` read `dsh.locale` from localStorage and fell straight back to `zh` when nothing was stored. The browser already states which languages its user reads — `navigator.languages` is that statement — and the app ignored it, so an English reader met a Chinese product and had to find a Chinese-labelled settings row to escape it. The fallback was doing two jobs at once: the last resort for an unresolvable locale, and the answer for every user who had simply never chosen.
|
||||
|
||||
Reading the browser fixed the readers whose browser names a language this app ships, but left the residual case wrong: a browser asking for neither `zh` nor `en` (`fr`, `de`) still fell back to `zh`. Those readers are the least likely to read Chinese.
|
||||
Reading the browser fixes readers whose browser names a registered language, but the product still needs a stable residual when the current catalog has no match. With only the built-in catalog, a browser asking for neither `zh` nor `en` (`fr`, `de`) reaches that case, and those readers are the least likely to read Chinese.
|
||||
|
||||
## Decision
|
||||
|
||||
**The provisional locale resolves through the browser, then `FALLBACK_LOCALE` (`en`); an explicit Host preference replaces it live.** `resolveInitialLocale()` in `packages/client/locale/src/client/index.ts` runs at service construction and expresses the browser/fallback order. The nonblocking settings lifecycle then applies optional `locale.preference` from `$DSH_HOME/settings.yaml`; absence leaves the browser-derived value active.
|
||||
**The provisional locale resolves through the browser, then `FALLBACK_LOCALE` (`en`); an explicit Host preference replaces it live.** `resolveInitialLocale()` in `packages/client/locale/src/client/index.ts` runs at service construction and after each language-catalog change, expressing the browser/fallback order over the definitions currently registered. The nonblocking settings lifecycle then applies optional `locale.preference` from `$DSH_HOME/settings.yaml`; absence leaves the browser-derived value active, while an unavailable saved id remains pending and takes effect if that language registers later.
|
||||
|
||||
**One constant serves both the opening locale and the dictionary fallback, because the dictionaries are symmetric.** `FALLBACK_LOCALE` answers both "which language does the UI open in when the browser names none we ship" and "which dictionary backs a key the active locale misses". Those are different questions, and splitting them into two constants would be right if either answer had to differ — but every shipped `zh`/`en` pair declares identical key sets, so the fallback step always resolves and both answers are `en`. The residual case points at English rather than zh because a browser naming neither shipped language is the reader least likely to read Chinese. `scripts/locale-dictionary-parity.spec.ts` gates the symmetry the shared constant depends on: a key added to one side only fails that spec by name, instead of surfacing later as a bare key such as `list.aria` in a running UI.
|
||||
**One constant serves both the opening residual and the dictionary-chain terminus.** `FALLBACK_LOCALE` answers both "which language does the UI open in when the browser names none registered" and "where must every declared dictionary fallback chain end". Those are different questions, and splitting them into two constants would be right if either answer had to differ. External languages may contribute partial dictionaries and declare intermediate fallbacks; every chain still reaches `en`. Every built-in `zh`/`en` pair declares identical key sets, so its final fallback resolves, while `scripts/locale-dictionary-parity.spec.ts` rejects a key added to only one built-in side instead of letting it surface later as a bare key such as `list.aria` in a running UI.
|
||||
|
||||
**Browser matching is on the primary subtag, over the ordered list.** `detectBrowserLocale()` walks `[...(navigator.languages ?? []), navigator.language]` and returns the first entry whose primary subtag names a shipped locale, so `zh-Hans-CN` and `zh-TW` both land on `zh` and `en-GB` on `en`, while a browser asking only for languages this app does not ship (`fr`, `de`) yields nothing and leaves `FALLBACK_LOCALE` in charge. `navigator.language` trails the list and covers its absence on hosts that ship a Navigator without `languages` — the DOM lib types it as always present, so that tolerance carries a narrow lint exception, the same environment-boundary distrust the `localStorage` guards already express.
|
||||
**Browser matching uses the registered catalog and the browser's ordered list.** `detectBrowserLocale()` walks `[...(navigator.languages ?? []), navigator.language]`. Each browser tag first matches a registered id exactly and then by primary subtag, so a registered `pt-BR` wins for that exact request, while `zh-Hans-CN` and an unmatched `zh-TW` land on the built-in `zh`, and `en-GB` lands on `en`. A browser asking only for unregistered languages (`fr`, `de` with the built-in catalog) yields nothing and leaves `FALLBACK_LOCALE` in charge. Registering or removing a language recomputes this provisional result. `navigator.language` trails the list and covers its absence on hosts that ship a Navigator without `languages`; tolerating that runtime omission follows the same environment-boundary distrust as the `localStorage` guards.
|
||||
|
||||
**`window`, not `navigator`, is the browser test.** Node ≥ 21 exposes a global `navigator` reporting the machine's own language, so gating on `navigator` would let a node boot of the client tree resolve to the machine's language instead of the documented fallback. Gating on `window` keeps every non-browser run on `FALLBACK_LOCALE`.
|
||||
|
||||
**An explicit choice is durable.** `setLocale` writes through the Host settings API, so a user who picked a language keeps it across browser origins and system languages that share the same DSH home. Nothing writes the detected locale back: detection is re-derived every boot and stays invisible to the “has the user chosen?” question.
|
||||
|
||||
**`<html lang>` follows the resolved locale, and the served markup cannot.** `apps/web/index.html` is one static file serving every visitor, so whatever it declares is wrong for somebody: resolution happens in the client, after the document is parsed. The locale plugin therefore sets `document.documentElement.lang` from the active locale — once at activation, because detection or an adopted Host preference may already disagree with the markup, and again on every switch. The markup declares the product default (`en`) so the pre-boot document is not actively misleading. Assistive technology and browser features (pronunciation rules, translation offers, font fallback, spell check) read this attribute, so a stale value misreports the document language rather than merely looking untidy. The attribute carries a BCP 47 tag rather than the app's locale id: `zh` alone leaves the script ambiguous, so the shipped Chinese copy declares `zh-CN`.
|
||||
**`<html lang>` follows the resolved locale, and the served markup cannot.** `apps/web/index.html` is one static file serving every visitor, so whatever it declares is wrong for somebody: resolution happens in the client, after the document is parsed. The locale plugin therefore sets `document.documentElement.lang` from the active locale — once at activation, because detection or an adopted Host preference may already disagree with the markup, and again on every switch. The markup declares the product default (`en`) so the pre-boot document is not actively misleading. Assistive technology and browser features (pronunciation rules, translation offers, font fallback, spell check) read this attribute, so a stale value misreports the document language rather than merely looking untidy. An external language id is already its BCP 47 tag and reaches the attribute unchanged; the built-in `zh` shorthand remains the sole exception and declares `zh-CN`, because `zh` alone leaves the script ambiguous.
|
||||
|
||||
**The browser e2e lane pins browser language.** Scenarios asserting Chinese copy (`access-confirmation`, `models-settings`, `onboarding-deepseek-config`, `settings-chrome`) open their page with `locale: ZH_BROWSER_LOCALE` from `apps/web/tests/support.ts`; `newEnglishPage` advertises `en-US`. `settings-chrome.e2e.ts` opens a fresh Host home with no explicit locale twice: an `en-US` browser and an `fr-FR` one both reach an English surface. The `fr-FR` scenario is the one that pins the fallback — an `en-US` browser would land on English under detection or fallback alike, so only an unshipped language distinguishes them, and the zh scenarios prove detection still overrides the fallback.
|
||||
|
||||
@@ -30,7 +30,7 @@ Reading the browser fixed the readers whose browser names a language this app sh
|
||||
|
||||
- **`Intl.DateTimeFormat().resolvedOptions().locale` or a single `navigator.language` read**: both collapse the user's ordered preference list to one tag, so a `['de', 'en', 'zh']` reader gets zh instead of en. The list is the part of the browser statement worth reading.
|
||||
- **Persisting the detected locale on first boot**: it would make detection a one-time event and let a stale first visit outlive a changed browser language, and it destroys the distinction the resolution order rests on — a stored value would no longer mean "the user chose this".
|
||||
- **Full BCP 47 negotiation (`Intl.LocaleMatcher`-style lookup, region and script weighting)**: with exactly two shipped locales that differ in language, primary-subtag matching is the whole of the correct answer; a negotiation layer would be untestable surface with no behavior to justify it.
|
||||
- **Full BCP 47 negotiation (`Intl.LocaleMatcher`-style lookup, region and script weighting)**: language registrations provide explicit ids, while dictionary fallback is separately explicit. Exact-id then primary-subtag matching preserves the built-in behavior without inventing an implicit distance policy between externally registered variants.
|
||||
- **A cordis config key for the fallback locale**: the deployment does not vary here — the fallback is the product's answer for "no signal at all", not a knob. Repo policy reserves `Config` fields for deployment-varying choices with a current consumer.
|
||||
- **Two constants, one for the opening locale and one for the dictionary fallback**: it separates two genuinely different questions, and would be required if the answers differed. They do not: the dictionaries are symmetric, so both are `en`, and a second constant would be two names for one value plus a rule nothing enforces. The symmetry itself is worth enforcing, so it is gated directly instead.
|
||||
- **Keeping `zh` as the dictionary fallback while opening in `en`**: it reads as the conservative choice, but with symmetric dictionaries it never resolves a key that `en` would not, so it buys nothing; and where it would matter — a key present only in `zh` — rendering Chinese text inside an otherwise English UI is worse than the bare key a reviewer would notice.
|
||||
@@ -39,8 +39,8 @@ Reading the browser fixed the readers whose browser names a language this app sh
|
||||
|
||||
## Consequences
|
||||
|
||||
- A first visit from an English browser lands in English, a Chinese browser in Chinese, and a browser naming neither lands in English rather than Chinese. The Language row still shows the same two self-described options, so the escape hatch is unchanged in either direction.
|
||||
- Dictionary resolution reverses direction: a key missing from the active locale now falls to `en`, not `zh`. With symmetric dictionaries no shipped key changes behavior, which is why the parity gate exists — it is the assumption that reversal rests on.
|
||||
- A first visit chooses the first registered language matched from the browser's ordered list. With only the built-in catalog, an English browser lands in English, a Chinese browser in Chinese, and a browser naming neither lands in English rather than Chinese; external registrations join the same Language row and matching process.
|
||||
- Dictionary resolution ends at `en`: a built-in `zh` miss reaches it directly, while an external language follows its declared per-key chain first. Symmetric built-in dictionaries keep shipped copy complete, which is why the parity gate exists.
|
||||
- `<html lang>` now reports the language on screen in both directions, which closes [#2160](https://github.com/deepseek-harness/deepseek-harness/issues/2160). A client that never activates the locale plugin keeps the served default, so the attribute degrades to the old static behavior rather than to a blank value.
|
||||
- Non-browser runs of the client tree (node boots, the non-jsdom unit lane) now open in `en`. Specs that assert shipped Chinese copy must set `setLocale('zh')` explicitly on the runtime they construct; a suite-level `usePinnedBrowserLanguages('zh-CN')` only works in files that also declare `@vitest-environment jsdom`, because without a `window` the detection path never reads `navigator` at all. Seven `*.client.spec.ts` files carried such a dead pin and were relying on the old `zh` fallback instead.
|
||||
- Detection cost is one array walk per service construction and no implicit settings write; an explicit Host preference may cause one live convergence after plugin activation.
|
||||
- Detection cost is one array walk per service construction or language-catalog change and no implicit settings write; an explicit Host preference may cause one live convergence after plugin activation or when its pending language registers.
|
||||
|
||||
@@ -8,21 +8,21 @@ Status: implemented
|
||||
|
||||
设置里的语言行在每一次首访时都以中文开场:`LocaleRuntime` 从 localStorage 读取 `dsh.locale`,读不到就直接回落到 `zh`。浏览器本已声明其使用者阅读哪些语言——`navigator.languages` 就是这份声明——而应用对此视而不见,于是英文读者迎面撞上一个中文产品,还得先找到一行中文标签的设置项才能脱身。回落值当时同时承担两份职责:既是无法解析出 locale 时的最后兜底,也是所有从未做过选择的用户拿到的答案。
|
||||
|
||||
读取浏览器修好了那些浏览器声明了本应用所提供语言的读者,但残余情形依然是错的:既不请求 `zh` 也不请求 `en` 的浏览器(`fr`、`de`)仍会回落到 `zh`。这些读者恰恰最不可能阅读中文。
|
||||
读取浏览器可以让浏览器声明了已注册语言的读者获得对应界面,但当当前目录没有匹配项时,产品仍需一个稳定的最终选择。若目录中只有内置语言,请求既非 `zh` 也非 `en` 的浏览器(`fr`、`de`)就会进入这种情形,而这些读者恰恰最不可能阅读中文。
|
||||
|
||||
## Decision
|
||||
|
||||
**暂定 locale 先经浏览器、再经 `FALLBACK_LOCALE`(`en`)解析;显式 Host 偏好会实时替换它。** `packages/client/locale/src/client/index.ts` 中的 `resolveInitialLocale()` 在服务构造时运行,并表达浏览器/回落顺序。随后,非阻塞 settings 生命周期会应用 `$DSH_HOME/settings.yaml` 中可选的 `locale.preference`;若该值缺失,则继续使用由浏览器派生的值。
|
||||
**暂定 locale 先经浏览器、再经 `FALLBACK_LOCALE`(`en`)解析;显式 Host 偏好会实时替换它。** `packages/client/locale/src/client/index.ts` 中的 `resolveInitialLocale()` 在服务构造时和每次语言目录变化后运行,依据当时已注册的定义表达浏览器/回落顺序。随后,非阻塞 settings 生命周期会应用 `$DSH_HOME/settings.yaml` 中可选的 `locale.preference`;若该值缺失,则继续使用由浏览器派生的值;若已保存的 id 暂不可用,则保留待采用状态,并在对应语言注册后生效。
|
||||
|
||||
**开场 locale 与字典回落值共用一个常量,因为两侧字典是对称的。** `FALLBACK_LOCALE` 同时回答「浏览器未声明任何本应用提供的语言时,界面以哪种语言开场」与「当前 locale 的字典缺失某个 key 时由哪本字典兜住」。这是两个不同的问题,若其中任一答案必须不同,拆成两个常量才是对的——但每一对已提供的 `zh`/`en` 字典都声明了完全相同的 key 集合,因此回落这一步总能解析成功,两个答案都是 `en`。残余情形指向英文而非 `zh`,是因为一个声明了本应用都不支持的语言的浏览器,其读者最不可能读中文。`scripts/locale-dictionary-parity.spec.ts` 为这个共用常量所依赖的对称性设了门禁:只加在一侧的 key 会让该用例指名失败,而不是日后在运行中的界面里显现为形如 `list.aria` 的裸 key。
|
||||
**开场时的最终回落与字典链终点共用一个常量。** `FALLBACK_LOCALE` 同时回答「浏览器未声明任何已注册语言时,界面以哪种语言开场」与「每条已声明的字典 fallback 链必须在哪里结束」。这是两个不同的问题,若其中任一答案必须不同,拆成两个常量才是对的。外部语言可以贡献不完整字典并声明中间 fallback,但每条链最终仍到达 `en`。每一对内置 `zh`/`en` 字典都声明完全相同的 key 集合,因此最后一次回落能够解析;`scripts/locale-dictionary-parity.spec.ts` 会拒绝只加在内置一侧的 key,避免它日后在运行中的界面里显现为形如 `list.aria` 的裸 key。
|
||||
|
||||
**浏览器匹配按主子标签进行,且遍历有序列表。** `detectBrowserLocale()` 遍历 `[...(navigator.languages ?? []), navigator.language]`,返回主子标签命中已提供 locale 的首个条目,因此 `zh-Hans-CN` 与 `zh-TW` 同归 `zh`、`en-GB` 归 `en`;而只请求本应用不提供的语言(`fr`、`de`)的浏览器则什么都匹配不到,交由 `FALLBACK_LOCALE` 接管。`navigator.language` 排在列表之后,并兜住那些 Navigator 上没有 `languages` 的宿主——DOM 库把它标注为必然存在,所以这份容忍带一条窄口径 lint 例外,与 `localStorage` 守卫表达的环境边界不信任同源。
|
||||
**浏览器匹配使用已注册目录和浏览器的有序列表。** `detectBrowserLocale()` 遍历 `[...(navigator.languages ?? []), navigator.language]`。每个浏览器标签先精确匹配已注册 id,再按主子标签匹配,因此已注册的 `pt-BR` 会响应同名请求;`zh-Hans-CN` 与未精确命中的 `zh-TW` 会落到内置 `zh`,`en-GB` 会落到 `en`。若浏览器只请求未注册语言(在只有内置目录时如 `fr`、`de`),匹配不会产生结果,并由 `FALLBACK_LOCALE` 接管。语言注册或移除时会重新计算这一暂定结果。`navigator.language` 排在列表之后,并兜住那些 Navigator 上没有 `languages` 的宿主;容忍该运行时缺失与 `localStorage` 守卫表达的环境边界不信任同源。
|
||||
|
||||
**判定浏览器用的是 `window` 而非 `navigator`。** Node ≥ 21 暴露全局 `navigator` 并报告机器自身语言,因此以 `navigator` 把关会让 node 启动客户端树时解析成机器语言,而非文档约定的回落值。以 `window` 把关可使所有非浏览器运行都停留在 `FALLBACK_LOCALE`。
|
||||
|
||||
**显式选择具有持久性。** `setLocale` 通过 Host settings API 写入,因此选过语言的用户可在共享同一 DSH home 的不同浏览器 origin 与系统语言之间保留原选择。没有任何代码把探测到的 locale 写回:探测在每次启动时重新推导,对「用户是否做过选择」这一问题始终不可见。
|
||||
|
||||
**`<html lang>` 跟随解析出的 locale,而所服务的 markup 做不到这一点。** `apps/web/index.html` 是一份静态文件,服务所有访问者,因此它声明什么都必然对某些人是错的:解析发生在客户端,在文档被解析之后。于是由 locale 插件依据当前 locale 设置 `document.documentElement.lang`——激活时设置一次,因为探测结果或已采纳的 Host 偏好可能已与 markup 不一致;此后每次切换再设置一次。markup 声明产品默认值(`en`),使启动前的文档不至于主动误导。无障碍技术与浏览器功能(发音规则、翻译提示、字体回退、拼写检查)都读取该属性,因此陈旧的值是在误报文档语言,而不只是看起来不整齐。该属性承载 BCP 47 标签而非应用内部的 locale id:单独的 `zh` 会使文字(script)含义不明,因此已提供的中文文案声明 `zh-CN`。
|
||||
**`<html lang>` 跟随解析出的 locale,而所服务的 markup 做不到这一点。** `apps/web/index.html` 是一份静态文件,服务所有访问者,因此它声明什么都必然对某些人是错的:解析发生在客户端,在文档被解析之后。于是由 locale 插件依据当前 locale 设置 `document.documentElement.lang`——激活时设置一次,因为探测结果或已采纳的 Host 偏好可能已与 markup 不一致;此后每次切换再设置一次。markup 声明产品默认值(`en`),使启动前的文档不至于主动误导。无障碍技术与浏览器功能(发音规则、翻译提示、字体回退、拼写检查)都读取该属性,因此陈旧的值是在误报文档语言,而不只是看起来不整齐。外部语言 id 本身就是 BCP 47 标签,会原样进入该属性;内置 `zh` 简写是唯一例外,它声明为 `zh-CN`,因为单独的 `zh` 会使文字(script)含义不明。
|
||||
|
||||
**浏览器 e2e 车道固定浏览器语言。** 断言中文文案的场景(`access-confirmation`、`models-settings`、`onboarding-deepseek-config`、`settings-chrome`)以 `apps/web/tests/support.ts` 的 `locale: ZH_BROWSER_LOCALE` 打开页面;`newEnglishPage` 声明 `en-US`。`settings-chrome.e2e.ts` 两次使用没有显式 locale 的全新 Host home:`en-US` 浏览器与 `fr-FR` 浏览器都会抵达英文界面。真正钉住回落值的是 `fr-FR` 那个场景——`en-US` 浏览器无论走探测还是走回落都会落在英文,因此只有本应用不提供的语言才能区分二者,而中文场景则证明探测仍然覆盖回落值。
|
||||
|
||||
@@ -30,7 +30,7 @@ Status: implemented
|
||||
|
||||
- **`Intl.DateTimeFormat().resolvedOptions().locale` 或单读 `navigator.language`**:两者都把用户的有序偏好列表塌缩成一个标签,于是 `['de', 'en', 'zh']` 的读者拿到的是 zh 而非 en。列表恰恰是浏览器这份声明里最值得读的部分。
|
||||
- **首次启动即持久化探测结果**:那会把探测变成一次性事件,让一次陈旧的首访凌驾于此后改变的浏览器语言之上,也摧毁了整个解析顺序所依赖的区分——存储值将不再意味着「用户选了它」。
|
||||
- **完整的 BCP 47 协商(`Intl.LocaleMatcher` 式查找、地区与文字权重)**:在只提供两个语言互异的 locale 时,主子标签匹配就是正确答案的全部;协商层只会带来无行为支撑、也无从测试的表面积。
|
||||
- **完整的 BCP 47 协商(`Intl.LocaleMatcher` 式查找、地区与文字权重)**:语言注册会提供明确的 id,字典 fallback 也有独立的显式配置。先精确匹配 id、再匹配主子标签,既保留了内置行为,也无需在外部注册的变体之间虚构隐式距离策略。
|
||||
- **为回落 locale 增加一个 Cordis 配置键**:此处部署之间并无差异——回落值是产品对「完全没有信号」给出的答案,不是旋钮。仓库策略把 `Config` 字段留给有当前消费方、且随部署变化的选择。
|
||||
- **拆成两个常量,一个管开场 locale、一个管字典回落**:它区分了两个确实不同的问题,若两个答案不同也确有必要。但它们并不不同:字典是对称的,因此两者都是 `en`,第二个常量只会是同一个值的两个名字,外加一条无人强制的规则。对称性本身值得强制,所以直接为它设门禁。
|
||||
- **开场用 `en`、字典回落仍保留 `zh`**:这看起来是保守选择,但在字典对称的前提下,它能解析的 key 与 `en` 完全相同,因此毫无收益;而在它真正会起作用的情形——某个 key 只存在于 `zh`——在整体英文的界面里渲染出中文文本,比让 reviewer 一眼看见裸 key 更糟。
|
||||
@@ -39,8 +39,8 @@ Status: implemented
|
||||
|
||||
## Consequences
|
||||
|
||||
- 来自英文浏览器的首访落在英文界面,中文浏览器落在中文界面,而两者皆未声明的浏览器落在英文而非中文界面。语言行依然呈现同样两个以自身语言自述的选项,两个方向的脱身通道都未改变。
|
||||
- 字典解析方向发生反转:当前 locale 缺失的 key 现在回落到 `en` 而非 `zh`。在字典对称的前提下,没有任何已提供的 key 行为发生变化——这正是那道对称性门禁存在的原因:它是这次反转所依赖的前提。
|
||||
- 首次访问会从浏览器的有序列表中选择第一个匹配的已注册语言。若目录中只有内置语言,英文浏览器进入英文界面,中文浏览器进入中文界面,两者皆未声明的浏览器则进入英文而非中文界面;外部注册项会加入同一个语言行与匹配过程。
|
||||
- 字典解析最终到达 `en`:内置 `zh` 缺失 key 时直接到达它,外部语言则先按自己声明的链逐 key 回落。内置字典对称性保证已提供的文案完整,这正是对称性门禁存在的原因。
|
||||
- `<html lang>` 现在在两个方向上都如实报告屏幕上的语言,这也关闭了 [#2160](https://github.com/deepseek-harness/deepseek-harness/issues/2160)。若某个客户端从未激活 locale 插件,则保留所服务的默认值,因此该属性退化为旧的静态行为,而不会退化为空值。
|
||||
- 客户端树的非浏览器运行(node 启动、非 jsdom 单测车道)现在以 `en` 开场。断言已提供中文文案的用例必须在其构造的 runtime 上显式调用 `setLocale('zh')`;套件级的 `usePinnedBrowserLanguages('zh-CN')` 仅在同时声明了 `@vitest-environment jsdom` 的文件中生效,因为没有 `window` 时探测路径根本不会读取 `navigator`。此前有七个 `*.client.spec.ts` 文件带着这样一条失效的固定语句,实际依赖的是旧的 `zh` 回落值。
|
||||
- 探测的代价是每次服务构造遍历一次数组,且不会隐式写入 settings;插件激活后,显式 Host 偏好可能引发一次实时收敛。
|
||||
- 探测的代价是每次服务构造或语言目录变化时遍历一次数组,且不会隐式写入 settings;插件激活后或待采用语言注册时,显式 Host 偏好可能引发一次实时收敛。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/locale/README.md
|
||||
README.md: 1fa0262e7c1e8aa50f12fd2b7533d97ea4737199
|
||||
README.zh.md: 45cf657d5f8455fe128f378c2a2b77d919a441da
|
||||
README.md: aa8c25143d7a3a04b39dd4e6a2d7198a35de8942
|
||||
README.zh.md: ed89c1344747c7cbb18fe81aac0df9233abe029d
|
||||
|
||||
@@ -2,7 +2,31 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
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. The Client keeps Host settings persistence disabled on non-loopback pages, so their locale selection remains process-local even though Connection authenticates every API method. `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). 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)). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary.
|
||||
Locale plugin: LocaleRuntime — the preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the first registered language `navigator` asks for (full-tag then primary-subtag matching, with `en` when none match). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. A saved external locale waits for its definition to register rather than becoming active while unavailable. The Client keeps Host settings persistence disabled on non-loopback pages, so their locale selection remains process-local even though Connection authenticates every API method. `locale/change` fires on switches, and the plugin points `<html lang>` at the external language id or the built-in language's document tag on activation and on every switch. 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)). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary.
|
||||
|
||||
The package ships only `zh` and `en`. External client plugins add a selectable language with `ctx.effect(() => ctx.locale.addLanguage({ id, label, fallback }))` and add its dictionaries through the existing `register(ns, locale, dict)` form; definitions and dictionaries may register in either order. Unloading the definition removes it from the selector and returns an active selection to the available browser/default locale. An external id is a non-empty ASCII BCP 47-style tag used for persistence, dictionary lookup, browser matching, and `<html lang>`. Its fallback must already be registered, and the resulting chain must terminate at `en`; unknown targets, duplicate ids, and cycles fail at registration. For each key, lookup walks the chain in the requested namespace, repeats it in `common`, then displays the key. The typed `register(ns, { zh, en })` form remains checked against `LocaleNamespaceMap` and requires both built-in dictionaries. LocaleRuntime 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 them for dictionary owners).
|
||||
|
||||
## Language-pack registration
|
||||
|
||||
Register the definition and each translated namespace as effects owned by the language-pack plugin:
|
||||
|
||||
```js
|
||||
export const inject = ['locale']
|
||||
|
||||
export function apply(ctx) {
|
||||
ctx.effect(
|
||||
() => ctx.locale.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' }),
|
||||
'my-locale: language',
|
||||
)
|
||||
ctx.effect(
|
||||
() => ctx.locale.register('common', 'ja', {
|
||||
cancel: 'キャンセル',
|
||||
close: '閉じる',
|
||||
}),
|
||||
'my-locale: common dictionary',
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -15,3 +39,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
@@ -2,7 +2,31 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
locale 插件:LocaleRuntime——`zh`/`en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `en`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。Client 在非 loopback 页面禁用 Host settings 持久化,因此这些页面的 locale 选择仍只保留在进程内,尽管 Connection 会认证每个 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 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。产品编写的 Client UI 文本必须经这些 typed 字典或已本地化原子组件 prop 进入展示;`verify-client-ui-i18n` 会强制这项源码归属([决策](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md))。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md)拥有。
|
||||
locale 插件:LocaleRuntime——偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的第一个已注册语言(先匹配完整标签,再匹配主子标签;若都不匹配,则使用 `en`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。已保存但尚未注册定义的外部 locale 会等待该定义注册,不会在不可用时成为当前语言。Client 在非 loopback 页面禁用 Host settings 持久化,因此这些页面的 locale 选择仍只保留在进程内,尽管 Connection 会认证每个 API 方法。`locale/change` 仅在切换语言时触发;插件会在激活时以及每次切换时把 `<html lang>` 指向外部语言 id 或内置语言的文档标签。产品编写的 Client UI 文本必须经这些 typed 字典或已本地化原子组件 prop 进入展示;`verify-client-ui-i18n` 会强制这项源码归属([决策](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md))。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md)拥有。
|
||||
|
||||
本包只内置 `zh` 与 `en`。外部 client 插件通过 `ctx.effect(() => ctx.locale.addLanguage({ id, label, fallback }))` 增加可选语言,并通过既有 `register(ns, locale, dict)` 形式增加该语言的字典;定义与字典可以按任意顺序注册。定义卸载后,它会从选择器移除,正在使用的选择则回落到当前可用的浏览器语言或默认语言。外部 id 是用于持久化、字典查找、浏览器匹配和 `<html lang>` 的非空 ASCII BCP 47 风格标签。其 fallback 必须已注册,形成的链必须终止于 `en`;未知目标、重复 id 和循环会在注册时失败。每个 key 先在请求的命名空间中沿链查找,再在 `common` 中重复该链,最后显示 key。类型化 `register(ns, { zh, en })` 形式仍按 `LocaleNamespaceMap` 检查,并要求两份内置字典。LocaleRuntime 实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅供字典所有者使用)。
|
||||
|
||||
## 语言包注册
|
||||
|
||||
语言包插件把语言定义和每个已翻译命名空间注册为自身拥有的 effect:
|
||||
|
||||
```js
|
||||
export const inject = ['locale']
|
||||
|
||||
export function apply(ctx) {
|
||||
ctx.effect(
|
||||
() => ctx.locale.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' }),
|
||||
'my-locale: language',
|
||||
)
|
||||
ctx.effect(
|
||||
() => ctx.locale.register('common', 'ja', {
|
||||
cancel: 'キャンセル',
|
||||
close: '閉じる',
|
||||
}),
|
||||
'my-locale: common dictionary',
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -15,3 +39,4 @@ locale 插件:LocaleRuntime——`zh`/`en` 偏好以 `locale.preference` 存
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **注册表持有的文本只读取一次翻译**——在 slot 渲染路径之外于注册时捕获的文案(例如 command 注册表中的 `/model` 命令描述)在重新注册前保持注册时的语言;slot 渲染的文案随切换实时更新。
|
||||
- **语言包负责语言特有行为**——注册表提供选择、持久化、浏览器匹配、逐 key 回退和 `<html lang>`;它不增加复数规则或双向布局。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-locale",
|
||||
"description": "Locale plugin: Host-backed zh/en preference, browser-derived fallback, locale snapshots, and typed namespace dictionaries",
|
||||
"description": "Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries",
|
||||
"version": "0.1.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
* preference row into the settings General section — the locale feature owns
|
||||
* its own settings surface.
|
||||
*/
|
||||
/* oxlint-disable typescript/no-redundant-type-constituents --
|
||||
* `keyof LocaleNamespaceMap & string` is the declare-merge key pattern (see
|
||||
* ui-slots): in THIS unit the map holds only this package's own merges, but
|
||||
* consumers merge more namespaces in and the intersection keeps them
|
||||
* string-typed. The rule fires on the narrow-map view, not real redundancy. */
|
||||
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
import {
|
||||
type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS,
|
||||
@@ -20,7 +15,8 @@ import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
// Type-only: pulls the SlotRegistry service merge (ctx.slots).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import {
|
||||
LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, type LocaleSettings,
|
||||
LOCALE_ID_PATTERN, LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE,
|
||||
type BuiltInLocaleId, type LocaleId, type LocaleSettings,
|
||||
} from '../locale-settings.ts'
|
||||
import { en, zh, type CommonKey } from '../locales/index.ts'
|
||||
import {
|
||||
@@ -33,7 +29,7 @@ import { createLanguageRowStore } from './settings-store.ts'
|
||||
export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageRow.tsx'
|
||||
export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts'
|
||||
export type { CommonKey } from '../locales/index.ts'
|
||||
export type { LocaleId, LocaleSettings } from '../locale-settings.ts'
|
||||
export type { BuiltInLocaleId, LocaleId, LocaleSettings } from '../locale-settings.ts'
|
||||
|
||||
// The translate currency lives in ui-slots (the render machinery synthesizes
|
||||
// the seat); re-exported here so dictionary owners import one package.
|
||||
@@ -52,12 +48,24 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
/** Locale dictionary: flat key to template string ({name} placeholders). */
|
||||
export type LocaleDict = Record<string, string>
|
||||
|
||||
/** One selectable locale: id plus its self-described display name. */
|
||||
export interface LocaleDefinition {
|
||||
/** Locale id (persisted; the setLocale argument). */
|
||||
/** Input accepted when a language-pack plugin adds a selectable language. */
|
||||
export interface LanguageRegistration {
|
||||
/** Stable BCP 47-style id stored as the locale preference. */
|
||||
id: LocaleId
|
||||
/** Display name in its own language (中文 / English). */
|
||||
/** Display name written in the represented language. */
|
||||
label: string
|
||||
/** Registered language consulted when this language lacks a dictionary key. */
|
||||
fallback: LocaleId
|
||||
}
|
||||
|
||||
/** One normalized selectable locale published in snapshots. */
|
||||
export interface LocaleDefinition {
|
||||
/** Stable id persisted by {@link LocaleRuntime.setLocale}. */
|
||||
readonly id: LocaleId
|
||||
/** Display name written in the represented language. */
|
||||
readonly label: string
|
||||
/** Next language in the per-key fallback chain; absent only for English. */
|
||||
readonly fallback?: LocaleId
|
||||
}
|
||||
|
||||
/** Immutable locale state published on every change. */
|
||||
@@ -88,15 +96,15 @@ declare module '@deepseek-ai/cordis' {
|
||||
}
|
||||
|
||||
/**
|
||||
* English is both the locale the UI opens in when the browser names no shipped
|
||||
* English is both the locale the UI opens in when the browser names no registered
|
||||
* language (and for non-browser runs), and the dictionary consulted after the
|
||||
* active locale misses a key. One constant serves both because the shipped
|
||||
* `zh`/`en` dictionaries carry identical key sets, so neither direction can
|
||||
* leave a key unresolved; the residual case points at English rather than
|
||||
* zh because a browser naming neither shipped language is the reader least
|
||||
* zh because a browser naming no registered language is the reader least
|
||||
* likely to read Chinese.
|
||||
*/
|
||||
export const FALLBACK_LOCALE: LocaleId = 'en'
|
||||
export const FALLBACK_LOCALE: BuiltInLocaleId = 'en'
|
||||
|
||||
/** Shared namespace for shell-level texts. */
|
||||
export const COMMON_NS = 'common'
|
||||
@@ -104,53 +112,66 @@ export const COMMON_NS = 'common'
|
||||
/** Namespace owning this feature's settings-row copy. */
|
||||
export const SETTINGS_NS = 'settings.locale'
|
||||
|
||||
/** The two shipped locales. */
|
||||
const LOCALES: readonly LocaleDefinition[] = Object.freeze([
|
||||
{ id: 'zh', label: '中文' },
|
||||
{ id: 'en', label: 'English' },
|
||||
])
|
||||
/** The two locales and dictionaries shipped by this package. */
|
||||
const BUILT_IN_LOCALE_METADATA = {
|
||||
zh: { label: '中文', fallback: 'en' },
|
||||
en: { label: 'English' },
|
||||
} as const satisfies Record<BuiltInLocaleId, Omit<LocaleDefinition, 'id'>>
|
||||
const BUILT_IN_LOCALES: readonly LocaleDefinition[] = Object.freeze(
|
||||
LOCALE_IDS.map(id => Object.freeze({ id, ...BUILT_IN_LOCALE_METADATA[id] })),
|
||||
)
|
||||
|
||||
/**
|
||||
* `<html lang>` tag per shipped locale. The locale id is the app's own
|
||||
* vocabulary (primary subtag); the document attribute wants a BCP 47 tag,
|
||||
* which assistive technology and browser features (pronunciation rules,
|
||||
* translation offers, font fallback, spell check) read to pick their own
|
||||
* behavior. `zh` alone leaves the script ambiguous, so the shipped Chinese
|
||||
* copy names the variant it actually is.
|
||||
*/
|
||||
const DOCUMENT_LANGUAGE: Record<LocaleId, string> = { zh: 'zh-CN', en: 'en' }
|
||||
/** Case-insensitive key for BCP 47-style ids. */
|
||||
function localeKey(value: string): string {
|
||||
return value.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Point `<html lang>` at the active locale. Called on every locale change,
|
||||
* so the attribute tracks the UI instead of standing at whatever the served
|
||||
* markup happened to declare.
|
||||
* @param active - the active locale id.
|
||||
*/
|
||||
function syncDocumentLanguage(active: LocaleId): void {
|
||||
// Non-browser runs (node boots of the client tree) have no document.
|
||||
if (typeof document === 'undefined') return
|
||||
document.documentElement.lang = DOCUMENT_LANGUAGE[active]
|
||||
/** Validate and detach a language-pack contribution from its mutable input. */
|
||||
function normalizeLanguage(input: LanguageRegistration): Readonly<LanguageRegistration> {
|
||||
if (!LOCALE_ID_PATTERN.test(input.id)) {
|
||||
throw new Error(`locale id "${input.id}" is not a BCP 47-style tag`)
|
||||
}
|
||||
if (input.label.trim() === '') throw new Error('locale label must not be empty')
|
||||
if (!LOCALE_ID_PATTERN.test(input.fallback)) {
|
||||
throw new Error(`locale fallback "${input.fallback}" is not a BCP 47-style tag`)
|
||||
}
|
||||
return Object.freeze({ id: input.id, label: input.label, fallback: input.fallback })
|
||||
}
|
||||
|
||||
/**
|
||||
* Dictionary registry plus locale preference. Lookup chain per key: the
|
||||
* entry's namespace in the active locale -> that namespace's en fallback ->
|
||||
* the shared common namespace (active, then en) -> the key itself (missing
|
||||
* text stays visible, fail loud in the UI rather than blank). Reads go
|
||||
* through {@link getLocale}; writes only through {@link setLocale};
|
||||
* continuous sync through the `locale/change` event, or through the
|
||||
* LocaleFace getSnapshot/subscribe pair the render machinery consumes
|
||||
* (installed via `ctx.slots.installLocale`).
|
||||
* Point `<html lang>` at the active locale, keeping the served document in
|
||||
* sync with locale snapshot changes.
|
||||
* @param snapshot - current locale state, including the active definition.
|
||||
*/
|
||||
function syncDocumentLanguage(snapshot: LocaleSnapshot): void {
|
||||
// Non-browser runs (node boots of the client tree) have no document.
|
||||
if (typeof document === 'undefined') return
|
||||
document.documentElement.lang = snapshot.active === 'zh' ? 'zh-CN' : snapshot.active
|
||||
}
|
||||
|
||||
/**
|
||||
* Dictionary registry plus locale preference. Lookup walks the active
|
||||
* language's declared fallback chain in the entry namespace, then repeats it
|
||||
* in the shared common namespace before showing the key itself. Reads go
|
||||
* through {@link getLocale}; preferences change only through
|
||||
* {@link setLocale}, while language packs extend the catalog through
|
||||
* {@link addLanguage}. Continuous sync uses the `locale/change` event or
|
||||
* the LocaleFace getSnapshot/subscribe pair installed through
|
||||
* `ctx.slots.installLocale`.
|
||||
*/
|
||||
export class LocaleRuntime {
|
||||
private dicts = new Map<string, Map<string, LocaleDict>>()
|
||||
private bound = new Map<string, Translate>()
|
||||
private catalog = new Map<string, LocaleDefinition>()
|
||||
private fallbackChains = new Map<string, readonly LocaleId[]>()
|
||||
private snapshot: LocaleSnapshot
|
||||
private listeners = new Set<() => void>()
|
||||
private readonly ctx: ClientContext
|
||||
private readonly host: SettingsScope<LocaleSettings> | undefined
|
||||
/** Browser-derived locale standing wherever no explicit Host selection does. */
|
||||
private readonly provisional: LocaleId
|
||||
private provisional: LocaleId
|
||||
/** Last explicit selection, including one awaiting an external registration. */
|
||||
private preference: LocaleId | undefined
|
||||
|
||||
/**
|
||||
* @param ctx - owning context (change events are emitted on it; the scope
|
||||
@@ -161,8 +182,10 @@ export class LocaleRuntime {
|
||||
constructor(ctx: ClientContext, host?: SettingsScope<LocaleSettings>) {
|
||||
this.ctx = ctx
|
||||
this.host = host
|
||||
this.provisional = resolveInitialLocale()
|
||||
this.snapshot = Object.freeze({ active: this.provisional, locales: LOCALES, revision: 0 })
|
||||
for (const locale of BUILT_IN_LOCALES) this.catalog.set(localeKey(locale.id), locale)
|
||||
const locales = this.localeList()
|
||||
this.provisional = resolveInitialLocale(locales)
|
||||
this.snapshot = Object.freeze({ active: this.provisional, locales, revision: 0 })
|
||||
if (host !== undefined) {
|
||||
ctx.effect(() => host.subscribe(() => { this.adopt(host) }), 'locale: settings scope adoption')
|
||||
this.adopt(host)
|
||||
@@ -189,7 +212,7 @@ export class LocaleRuntime {
|
||||
/**
|
||||
* LocaleFace subscribe: notified on every snapshot change (locale switch
|
||||
* or dictionary registration — registrations bump the revision so already
|
||||
* rendered outlets pick up late-arriving dictionaries).
|
||||
* rendered outlets pick up late-arriving dictionaries and locale definitions).
|
||||
* @param fn - change callback.
|
||||
* @returns unsubscribe.
|
||||
*/
|
||||
@@ -211,12 +234,49 @@ export class LocaleRuntime {
|
||||
* @param id - a registered locale id; unknown ids throw.
|
||||
*/
|
||||
setLocale(id: string): void {
|
||||
const match = this.snapshot.locales.find(l => l.id === id)
|
||||
const match = this.catalog.get(localeKey(id))
|
||||
if (match === undefined) throw new Error(`locale "${id}" is not registered`)
|
||||
this.preference = match.id
|
||||
if (this.snapshot.active !== match.id) this.publish(match.id, true)
|
||||
void this.host?.set(LOCALE_PREFERENCE_FIELD, match.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add one selectable language to the shared catalog. Its fallback must
|
||||
* already be registered, and following fallback definitions must terminate
|
||||
* at English. Dictionaries may register before or after this definition.
|
||||
* Registration rechecks an unresolved Host preference and the browser's
|
||||
* ordered language list. The caller owns the returned disposer; removing an
|
||||
* active language falls back without clearing the stored id.
|
||||
* @param input - stable id, self-described label, and fallback language id.
|
||||
* @returns idempotent disposer removing this exact definition.
|
||||
* @throws when fields are malformed, the id is occupied, or the fallback
|
||||
* target is unknown or creates a cycle.
|
||||
*/
|
||||
addLanguage(input: LanguageRegistration): () => void {
|
||||
const candidate = normalizeLanguage(input)
|
||||
const key = localeKey(candidate.id)
|
||||
if (this.catalog.has(key)) throw new Error(`locale "${candidate.id}" is already registered`)
|
||||
const fallback = this.catalog.get(localeKey(candidate.fallback))
|
||||
if (fallback === undefined) {
|
||||
throw new Error(`locale fallback "${candidate.fallback}" is not registered`)
|
||||
}
|
||||
const language = Object.freeze({ ...candidate, fallback: fallback.id })
|
||||
this.catalog.set(key, language)
|
||||
try {
|
||||
this.assertFallbackChain(language.id)
|
||||
} catch (error) {
|
||||
this.catalog.delete(key)
|
||||
throw error
|
||||
}
|
||||
this.publishCatalog()
|
||||
return () => {
|
||||
if (this.catalog.get(key) !== language) return
|
||||
this.catalog.delete(key)
|
||||
this.publishCatalog()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt the scope's accepted durable selection without writing it back; an
|
||||
* absent selection returns to the browser-derived locale.
|
||||
@@ -225,11 +285,76 @@ export class LocaleRuntime {
|
||||
private adopt(host: SettingsScope<LocaleSettings>): void {
|
||||
const section = host.getSnapshot().value
|
||||
if (section === undefined) return
|
||||
const target = section.preference ?? this.provisional
|
||||
this.preference = section.preference
|
||||
const target = this.resolveActive()
|
||||
if (this.snapshot.active === target) return
|
||||
this.publish(target, true)
|
||||
}
|
||||
|
||||
/** Recompute browser fallback and publish the current catalog. */
|
||||
private publishCatalog(): void {
|
||||
this.fallbackChains.clear()
|
||||
const locales = this.localeList()
|
||||
this.provisional = resolveInitialLocale(locales)
|
||||
const active = this.resolveActive()
|
||||
this.publish(active, active !== this.snapshot.active, locales)
|
||||
}
|
||||
|
||||
/** Resolve an explicit preference only while its definition is available. */
|
||||
private resolveActive(): LocaleId {
|
||||
if (this.preference === undefined) return this.provisional
|
||||
return this.catalog.get(localeKey(this.preference))?.id ?? this.provisional
|
||||
}
|
||||
|
||||
/** Snapshot the catalog in registration order. */
|
||||
private localeList(): readonly LocaleDefinition[] {
|
||||
return Object.freeze([...this.catalog.values()])
|
||||
}
|
||||
|
||||
/** Fail a new definition whose complete fallback path does not reach English. */
|
||||
private assertFallbackChain(start: LocaleId): void {
|
||||
const seen = new Set<string>()
|
||||
let current = this.catalog.get(localeKey(start))
|
||||
while (current !== undefined) {
|
||||
const key = localeKey(current.id)
|
||||
if (seen.has(key)) throw new Error(`locale fallback cycle includes "${current.id}"`)
|
||||
seen.add(key)
|
||||
if (key === localeKey(FALLBACK_LOCALE)) return
|
||||
/* v8 ignore next -- English is the only built-in terminal and every
|
||||
* language accepted by addLanguage has a required fallback. */
|
||||
if (current.fallback === undefined) {
|
||||
throw new Error(`locale "${current.id}" fallback chain does not reach "${FALLBACK_LOCALE}"`)
|
||||
}
|
||||
const next = this.catalog.get(localeKey(current.fallback))
|
||||
if (next === undefined) {
|
||||
throw new Error(`locale fallback "${current.fallback}" is not registered`)
|
||||
}
|
||||
current = next
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a lookup chain, falling directly to English across an unload gap. */
|
||||
private fallbackChain(start: LocaleId): readonly LocaleId[] {
|
||||
const startKey = localeKey(start)
|
||||
const cached = this.fallbackChains.get(startKey)
|
||||
if (cached !== undefined) return cached
|
||||
const chain: LocaleId[] = []
|
||||
const seen = new Set<string>()
|
||||
let current = this.catalog.get(startKey)
|
||||
while (current !== undefined && !seen.has(localeKey(current.id))) {
|
||||
const key = localeKey(current.id)
|
||||
seen.add(key)
|
||||
chain.push(current.id)
|
||||
current = current.fallback === undefined
|
||||
? undefined
|
||||
: this.catalog.get(localeKey(current.fallback))
|
||||
}
|
||||
if (!seen.has(localeKey(FALLBACK_LOCALE))) chain.push(FALLBACK_LOCALE)
|
||||
const resolved = Object.freeze(chain)
|
||||
this.fallbackChains.set(startKey, resolved)
|
||||
return resolved
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a declared namespace's dictionaries, all locales in one call —
|
||||
* the typed form: each dictionary is checked against the namespace's
|
||||
@@ -239,17 +364,18 @@ export class LocaleRuntime {
|
||||
* namespace's texts have one owner). Registration bumps the revision so
|
||||
* mounted outlets pick up late-arriving dictionaries.
|
||||
* @param ns - a namespace merged into LocaleNamespaceMap.
|
||||
* @param dicts - complete dictionaries keyed by locale id.
|
||||
* @param dicts - complete dictionaries keyed by built-in locale id.
|
||||
* @returns disposer removing every locale registered by this call (idempotent).
|
||||
*/
|
||||
register<N extends keyof LocaleNamespaceMap & string>(ns: N, dicts: Record<LocaleId, LocaleDictOf<N>>): () => void
|
||||
register<N extends Extract<keyof LocaleNamespaceMap, string>>(ns: N, dicts: Record<BuiltInLocaleId, LocaleDictOf<N>>): () => void
|
||||
/**
|
||||
* Single-locale untyped form for namespaces outside the merge table
|
||||
* (dynamic composition, tests).
|
||||
* Single-locale untyped form for language-pack contributions and namespaces
|
||||
* outside the merge table.
|
||||
* @param ns - namespace.
|
||||
* @param locale - locale tag.
|
||||
* @param dict - dictionary.
|
||||
* @returns disposer (idempotent).
|
||||
* @throws when locale is not a BCP 47-style tag.
|
||||
*/
|
||||
register(ns: string, locale: string, dict: LocaleDict): () => void
|
||||
register(ns: string, localeOrDicts: string | Record<string, LocaleDict>, dict?: LocaleDict): () => void {
|
||||
@@ -257,15 +383,22 @@ export class LocaleRuntime {
|
||||
// Overload guarantees dict on the single-locale arm.
|
||||
? [[localeOrDicts, dict as LocaleDict]]
|
||||
: Object.entries(localeOrDicts)
|
||||
for (const [locale] of pairs) {
|
||||
if (!LOCALE_ID_PATTERN.test(locale)) {
|
||||
throw new Error(`locale id "${locale}" is not a BCP 47-style tag`)
|
||||
}
|
||||
}
|
||||
let locales = this.dicts.get(ns)
|
||||
if (!locales) {
|
||||
locales = new Map()
|
||||
this.dicts.set(ns, locales)
|
||||
}
|
||||
for (const [locale] of pairs) {
|
||||
if (locales.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`)
|
||||
if (locales.has(localeKey(locale))) {
|
||||
throw new Error(`locale namespace "${ns}" already has locale "${locale}"`)
|
||||
}
|
||||
}
|
||||
for (const [locale, entries] of pairs) locales.set(locale, entries)
|
||||
for (const [locale, entries] of pairs) locales.set(localeKey(locale), entries)
|
||||
this.publish(this.snapshot.active, false)
|
||||
return () => {
|
||||
const owner = this.dicts.get(ns)
|
||||
@@ -274,8 +407,9 @@ export class LocaleRuntime {
|
||||
if (!owner) return
|
||||
let removed = false
|
||||
for (const [locale, entries] of pairs) {
|
||||
if (owner.get(locale) === entries) {
|
||||
owner.delete(locale)
|
||||
const key = localeKey(locale)
|
||||
if (owner.get(key) === entries) {
|
||||
owner.delete(key)
|
||||
removed = true
|
||||
}
|
||||
}
|
||||
@@ -292,7 +426,7 @@ export class LocaleRuntime {
|
||||
* @param ns - a namespace merged into LocaleNamespaceMap.
|
||||
* @returns the typed translate function (reads the active locale at call time).
|
||||
*/
|
||||
bind<N extends keyof LocaleNamespaceMap & string>(ns: N): TranslateNS<N>
|
||||
bind<N extends Extract<keyof LocaleNamespaceMap, string>>(ns: N): TranslateNS<N>
|
||||
/**
|
||||
* Untyped form for namespaces outside the merge table (dynamic
|
||||
* composition, tests).
|
||||
@@ -311,17 +445,22 @@ export class LocaleRuntime {
|
||||
}
|
||||
|
||||
private translate(ns: string, key: string, params?: Record<string, unknown>): string {
|
||||
const template = this.lookup(ns, key)
|
||||
?? (ns !== COMMON_NS ? this.lookup(COMMON_NS, key) : undefined)
|
||||
const chain = this.fallbackChain(this.snapshot.active)
|
||||
const template = this.lookup(ns, key, chain)
|
||||
?? (ns !== COMMON_NS ? this.lookup(COMMON_NS, key, chain) : undefined)
|
||||
?? key
|
||||
if (!params) return template
|
||||
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
|
||||
name in params ? String(params[name]) : match)
|
||||
}
|
||||
|
||||
private lookup(ns: string, key: string): string | undefined {
|
||||
private lookup(ns: string, key: string, chain: readonly LocaleId[]): string | undefined {
|
||||
const locales = this.dicts.get(ns)
|
||||
return locales?.get(this.snapshot.active)?.[key] ?? locales?.get(FALLBACK_LOCALE)?.[key]
|
||||
for (const locale of chain) {
|
||||
const value = locales?.get(localeKey(locale))?.[key]
|
||||
if (value !== undefined) return value
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -331,10 +470,14 @@ export class LocaleRuntime {
|
||||
* registration-heavy boot cannot storm event listeners (which may
|
||||
* re-register slots in response).
|
||||
*/
|
||||
private publish(active: LocaleId, localeChanged: boolean): void {
|
||||
private publish(
|
||||
active: LocaleId,
|
||||
localeChanged: boolean,
|
||||
locales: readonly LocaleDefinition[] = this.snapshot.locales,
|
||||
): void {
|
||||
this.snapshot = Object.freeze({
|
||||
active,
|
||||
locales: this.snapshot.locales,
|
||||
locales,
|
||||
revision: this.snapshot.revision + 1,
|
||||
})
|
||||
if (localeChanged) this.ctx.emit('locale/change', this.snapshot)
|
||||
@@ -354,29 +497,32 @@ export class LocaleRuntime {
|
||||
* The browser's own language wins over {@link FALLBACK_LOCALE}; an explicit
|
||||
* Host preference may replace this provisional value after plugin activation.
|
||||
*/
|
||||
function resolveInitialLocale(): LocaleId {
|
||||
return detectBrowserLocale() ?? FALLBACK_LOCALE
|
||||
function resolveInitialLocale(locales: readonly LocaleDefinition[]): LocaleId {
|
||||
return detectBrowserLocale(locales) ?? FALLBACK_LOCALE
|
||||
}
|
||||
|
||||
/**
|
||||
* The first shipped locale the browser asks for, matched on the primary
|
||||
* subtag so every regional variant lands on its language (`zh-Hans-CN` -> zh,
|
||||
* `en-GB` -> en). `window` is the browser test, not `navigator`: Node exposes
|
||||
* a global `navigator` reporting the machine's own language, which would
|
||||
* otherwise decide the locale for non-browser runs (node e2e booting the
|
||||
* client tree). `navigator.language` trails the ordered `languages` list and
|
||||
* covers its absence on hosts that expose only the single tag.
|
||||
* The first registered locale the browser asks for. Each browser tag first
|
||||
* matches a locale id exactly, then its primary subtag, so an exact regional
|
||||
* registration wins before a language-wide fallback.
|
||||
* `window` is the browser test, not `navigator`: Node exposes a global
|
||||
* `navigator` reporting the machine's own language, which must not decide the
|
||||
* locale for non-browser runs. `navigator.language` trails the ordered
|
||||
* `languages` list and covers hosts exposing only the single tag.
|
||||
* @param locales - definitions currently available to the browser.
|
||||
* @returns the first matching locale id, or undefined.
|
||||
*/
|
||||
function detectBrowserLocale(): LocaleId | undefined {
|
||||
function detectBrowserLocale(locales: readonly LocaleDefinition[]): LocaleId | undefined {
|
||||
if (typeof window === 'undefined') return undefined
|
||||
/* oxlint-disable-next-line typescript/no-unnecessary-condition --
|
||||
* The DOM lib types `languages` as always present; embedders and older
|
||||
* WebViews ship a Navigator without it, and spreading undefined would
|
||||
* throw at boot. */
|
||||
for (const tag of [...(navigator.languages ?? []), navigator.language]) {
|
||||
const primary = tag.toLowerCase().split('-')[0]
|
||||
const match = LOCALES.find(locale => locale.id === primary)
|
||||
if (match) return match.id
|
||||
// Embedders and older WebViews may omit the DOM-typed `languages` property.
|
||||
const languages = (navigator as { readonly languages?: readonly string[] }).languages
|
||||
for (const tag of [...(languages ?? []), navigator.language]) {
|
||||
const requested = localeKey(tag)
|
||||
const exact = locales.find(locale => localeKey(locale.id) === requested)
|
||||
if (exact !== undefined) return exact.id
|
||||
const primary = requested.split('-')[0]
|
||||
const match = locales.find(locale => localeKey(locale.id).split('-')[0] === primary)
|
||||
if (match !== undefined) return match.id
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -402,24 +548,25 @@ export function apply(ctx: ClientContext): void {
|
||||
|
||||
const store = createLanguageRowStore()
|
||||
let bound: BoundActions<typeof store> | undefined
|
||||
const sync = (snapshot: LocaleSnapshot): void => {
|
||||
syncDocumentLanguage(snapshot.active)
|
||||
const sync = (): void => {
|
||||
const snapshot = locale.getSnapshot()
|
||||
syncDocumentLanguage(snapshot)
|
||||
bound?.sync(
|
||||
snapshot.active,
|
||||
snapshot.locales.map(l => ({ id: l.id, label: l.label })),
|
||||
snapshot.revision,
|
||||
)
|
||||
}
|
||||
ctx.on('locale/change', sync)
|
||||
ctx.effect(() => locale.subscribe(sync), 'locale: language row and document synchronization')
|
||||
// The served markup declares one language; the resolved locale may differ
|
||||
// (browser detection, or a stored preference adopted after activation), so
|
||||
// state it once at activation rather than waiting for the first change.
|
||||
syncDocumentLanguage(locale.getLocale().active)
|
||||
sync()
|
||||
const injected = (actions: BoundActions<typeof store>): LanguageRowInjected => {
|
||||
bound = actions
|
||||
// Re-sync from the getter so no event is lost between registration and
|
||||
// first render (the store's revision guard drops stale duplicates).
|
||||
sync(locale.getLocale())
|
||||
sync()
|
||||
return {
|
||||
setLocale: (id) => { locale.setLocale(id) },
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { LOCALE_SETTINGS_NAMESPACE, LocaleSettingsSchema } from './locale-settin
|
||||
|
||||
export {
|
||||
LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE,
|
||||
type LocaleId, type LocaleSettings,
|
||||
type BuiltInLocaleId, type LocaleId, type LocaleSettings,
|
||||
} from './locale-settings.ts'
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,10 +15,9 @@ export const name = 'client-locale-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: ns-by-locale dictionary registry with a stable
|
||||
* bind(ns) API — it emits no cordis events and owns no cross-plugin
|
||||
* mutable relation; fallback-chain resolution and locale-store behavior are
|
||||
* asserted directly by this package's behavior specs.
|
||||
* No runtime invariant: the locale catalog and dictionaries have no
|
||||
* independent runtime source to compare against; registration disposal,
|
||||
* preference resolution, and fallback lookup are asserted by behavior specs.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
|
||||
@@ -8,11 +8,17 @@ export const LOCALE_SETTINGS_NAMESPACE = 'locale'
|
||||
/** Field carrying an explicit locale selection; absence delegates to the browser. */
|
||||
export const LOCALE_PREFERENCE_FIELD = 'preference'
|
||||
|
||||
/** Accepted BCP 47-style language ids. */
|
||||
export const LOCALE_ID_PATTERN = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/u
|
||||
|
||||
/** Locale identifiers shipped by the browser client. */
|
||||
export const LOCALE_IDS = ['zh', 'en'] as const
|
||||
|
||||
/** Shipped locale identifier. */
|
||||
export type LocaleId = typeof LOCALE_IDS[number]
|
||||
/** Locale identifier shipped by the browser client. */
|
||||
export type BuiltInLocaleId = typeof LOCALE_IDS[number]
|
||||
|
||||
/** Open locale identifier accepted from language-pack plugins. */
|
||||
export type LocaleId = string
|
||||
|
||||
/** Durable locale section shared by the Host schema and the browser scope. */
|
||||
export interface LocaleSettings {
|
||||
@@ -22,5 +28,5 @@ export interface LocaleSettings {
|
||||
|
||||
/** Durable locale schema; also the wire envelope the browser scope validates against. */
|
||||
export const LocaleSettingsSchema: z<LocaleSettings> = z.object({
|
||||
[LOCALE_PREFERENCE_FIELD]: z.union([...LOCALE_IDS]).required(false),
|
||||
[LOCALE_PREFERENCE_FIELD]: z.string().pattern(LOCALE_ID_PATTERN).required(false),
|
||||
})
|
||||
|
||||
@@ -128,6 +128,30 @@ describe('locale apply', () => {
|
||||
await vi.waitFor(() => { expect(b.mutate).toHaveBeenCalledTimes(2) })
|
||||
})
|
||||
|
||||
it('projects external locale registration and disposal into the Language row', async () => {
|
||||
const b = await bench()
|
||||
declareItems(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const { instance } = faceOf(b.slots)
|
||||
|
||||
const languagePack = b.ctx.plugin({
|
||||
inject: ['locale'],
|
||||
apply: packCtx => packCtx.effect(
|
||||
() => packCtx.locale.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' }),
|
||||
'test language pack registration',
|
||||
),
|
||||
})
|
||||
await languagePack.await()
|
||||
expect(instance.getSnapshot().options).toEqual([
|
||||
{ id: 'zh', label: '中文' },
|
||||
{ id: 'en', label: 'English' },
|
||||
{ id: 'ja', label: '日本語' },
|
||||
])
|
||||
|
||||
await languagePack.dispose()
|
||||
expect(instance.getSnapshot().options.map(option => option.id)).toEqual(['zh', 'en'])
|
||||
})
|
||||
|
||||
it('loads and refreshes the explicit Host preference after nonblocking activation', async () => {
|
||||
const b = await bench()
|
||||
// The shared mirror read once at bench time; a Host-side change reaches it
|
||||
|
||||
@@ -91,4 +91,11 @@ describe('document language', () => {
|
||||
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') })
|
||||
await vi.waitFor(() => { expect(langOf()).toBe('en') })
|
||||
})
|
||||
|
||||
it('uses an external locale definition for the document language', async () => {
|
||||
const { locale } = await bench()
|
||||
locale.addLanguage({ id: 'pt-BR', label: 'Português', fallback: 'en' })
|
||||
locale.setLocale('pt-BR')
|
||||
expect(langOf()).toBe('pt-BR')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@ class MemorySettings extends SettingsProvider {
|
||||
}
|
||||
|
||||
describe('locale host', () => {
|
||||
it('registers an optional explicit locale preference with the Host settings lifecycle', async () => {
|
||||
it('registers an open locale preference with the Host settings lifecycle', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(MemorySettings).await()
|
||||
const fiber = ctx.plugin({ apply })
|
||||
@@ -23,7 +23,10 @@ describe('locale host', () => {
|
||||
expect(ctx.settings.get(ns)).toEqual({})
|
||||
await ctx.settings.update(ns, { preference: 'en' })
|
||||
expect(ctx.settings.get(ns)).toEqual({ preference: 'en' })
|
||||
await expect(ctx.settings.update(ns, { preference: 'fr' })).rejects.toThrow()
|
||||
await ctx.settings.update(ns, { preference: 'pt-BR' })
|
||||
expect(ctx.settings.get(ns)).toEqual({ preference: 'pt-BR' })
|
||||
await expect(ctx.settings.update(ns, { preference: 'bad locale' })).rejects.toThrow()
|
||||
await expect(ctx.settings.update(ns, { preference: '123' })).rejects.toThrow()
|
||||
await fiber.dispose()
|
||||
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns)
|
||||
})
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('LocaleRuntime', () => {
|
||||
it('rejects duplicate (ns, locale) and disposer only removes its own dict', () => {
|
||||
const { svc } = make()
|
||||
const dispose = svc.register('ns', 'zh', { k: 'v1' })
|
||||
expect(() => svc.register('ns', 'zh', { k: 'v2' })).toThrow('already has locale')
|
||||
expect(() => svc.register('ns', 'ZH', { k: 'v2' })).toThrow('already has locale')
|
||||
dispose()
|
||||
const t = svc.bind('ns')
|
||||
expect(t('k')).toBe('k')
|
||||
@@ -182,6 +182,112 @@ describe('LocaleRuntime', () => {
|
||||
expect(() => { svc.setLocale('fr') }).toThrow('not registered')
|
||||
})
|
||||
|
||||
it('registers an external locale for selection, translation, persistence, and reversible disposal', () => {
|
||||
const host = stubSettingsScope<LocaleSettings>()
|
||||
const { svc, events } = make(host)
|
||||
svc.register('ns', 'en', { hello: 'Hello' })
|
||||
svc.register('ns', 'JA', { hello: 'こんにちは' })
|
||||
const dispose = svc.addLanguage({ id: 'ja', label: '日本語', fallback: 'EN' })
|
||||
expect(svc.getLocale().locales).toContainEqual({ id: 'ja', label: '日本語', fallback: 'en' })
|
||||
|
||||
svc.setLocale('JA')
|
||||
expect(svc.getLocale().active).toBe('ja')
|
||||
expect(svc.bind('ns')('hello')).toBe('こんにちは')
|
||||
expect(host.set).toHaveBeenCalledWith('preference', 'ja')
|
||||
|
||||
dispose()
|
||||
expect(svc.getLocale().active).toBe('zh')
|
||||
expect(svc.getLocale().locales.map(locale => locale.id)).toEqual(['zh', 'en'])
|
||||
expect(svc.bind('ns')('hello')).toBe('Hello')
|
||||
const revision = svc.getLocale().revision
|
||||
dispose()
|
||||
expect(svc.getLocale().revision).toBe(revision)
|
||||
expect(events.map(snapshot => snapshot.active)).toEqual(['ja', 'zh'])
|
||||
})
|
||||
|
||||
it('uses fallback copy until a language dictionary registers later', () => {
|
||||
const { svc } = make()
|
||||
svc.register('ns', 'en', { hello: 'Hello' })
|
||||
svc.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' })
|
||||
svc.setLocale('ja')
|
||||
expect(svc.bind('ns')('hello')).toBe('Hello')
|
||||
|
||||
const revision = svc.getLocale().revision
|
||||
svc.register('ns', 'ja', { hello: 'こんにちは' })
|
||||
expect(svc.getLocale().revision).toBe(revision + 1)
|
||||
expect(svc.bind('ns')('hello')).toBe('こんにちは')
|
||||
})
|
||||
|
||||
it('rejects duplicate and malformed locale definitions', () => {
|
||||
const { svc } = make()
|
||||
expect(() => svc.addLanguage({ id: 'EN', label: 'Other English', fallback: 'en' }))
|
||||
.toThrow('already registered')
|
||||
expect(() => svc.addLanguage({ id: 'bad locale', label: 'Bad', fallback: 'en' }))
|
||||
.toThrow('not a BCP 47-style tag')
|
||||
expect(() => svc.addLanguage({ id: '123', label: 'Numeric', fallback: 'en' }))
|
||||
.toThrow('not a BCP 47-style tag')
|
||||
expect(() => svc.addLanguage({ id: 'fr', label: ' ', fallback: 'en' }))
|
||||
.toThrow('label must not be empty')
|
||||
expect(() => svc.addLanguage({ id: 'fr', label: 'Français', fallback: 'bad tag' }))
|
||||
.toThrow('locale fallback')
|
||||
expect(() => svc.addLanguage({ id: 'fr', label: 'Français', fallback: 'de' }))
|
||||
.toThrow('not registered')
|
||||
})
|
||||
|
||||
it('rejects malformed locale ids before dictionary registration', () => {
|
||||
const { svc } = make()
|
||||
expect(() => svc.register('ns', 'bad locale', { hello: 'Bad' }))
|
||||
.toThrow('not a BCP 47-style tag')
|
||||
expect(() => svc.register('ns', '123', { hello: 'Numeric' }))
|
||||
.toThrow('not a BCP 47-style tag')
|
||||
expect(svc.bind('ns')('hello')).toBe('hello')
|
||||
})
|
||||
|
||||
it('walks each language fallback recursively for every dictionary key', () => {
|
||||
const { svc } = make()
|
||||
svc.register('ns', 'en', { base: 'English', shared: 'English shared' })
|
||||
svc.register('ns', 'fr', { shared: 'Français' })
|
||||
svc.register('ns', 'fr-CA', { local: 'Québec' })
|
||||
svc.register('common', 'en', { commonBase: 'Common English' })
|
||||
svc.register('common', 'fr', { commonShared: 'Common French' })
|
||||
svc.addLanguage({ id: 'fr', label: 'Français', fallback: 'en' })
|
||||
svc.addLanguage({ id: 'fr-CA', label: 'Français (Canada)', fallback: 'fr' })
|
||||
svc.setLocale('fr-CA')
|
||||
const t = svc.bind('ns')
|
||||
expect(t('local')).toBe('Québec')
|
||||
expect(t('shared')).toBe('Français')
|
||||
expect(t('base')).toBe('English')
|
||||
expect(t('commonShared')).toBe('Common French')
|
||||
expect(t('commonBase')).toBe('Common English')
|
||||
})
|
||||
|
||||
it('rejects a fallback cycle exposed by re-registering an unloaded language', () => {
|
||||
const { svc } = make()
|
||||
svc.register('ns', 'en', { base: 'English' })
|
||||
const removeFr = svc.addLanguage({ id: 'fr', label: 'Français', fallback: 'en' })
|
||||
svc.addLanguage({ id: 'fr-CA', label: 'Français (Canada)', fallback: 'fr' })
|
||||
svc.setLocale('fr-CA')
|
||||
removeFr()
|
||||
expect(svc.bind('ns')('base')).toBe('English')
|
||||
expect(() => svc.addLanguage({ id: 'de', label: 'Deutsch', fallback: 'fr-CA' }))
|
||||
.toThrow('locale fallback "fr" is not registered')
|
||||
expect(() => svc.addLanguage({ id: 'fr', label: 'Français', fallback: 'fr-CA' }))
|
||||
.toThrow('fallback cycle')
|
||||
expect(svc.getLocale().locales.map(locale => locale.id)).toEqual(['zh', 'en', 'fr-CA'])
|
||||
})
|
||||
|
||||
it('adopts a saved external locale when its definition registers later', () => {
|
||||
const host = stubSettingsScope<LocaleSettings>()
|
||||
const { svc, events } = make(host)
|
||||
host.publish({ status: 'ready', value: { preference: 'ja' }, revision: 1, writable: true })
|
||||
expect(svc.getLocale().active).toBe('zh')
|
||||
|
||||
svc.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' })
|
||||
expect(svc.getLocale().active).toBe('ja')
|
||||
expect(events.map(snapshot => snapshot.active)).toEqual(['ja'])
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adopts a Host preference over the browser language without writing it back', () => {
|
||||
const host = stubSettingsScope<LocaleSettings>()
|
||||
const { svc, events } = make(host)
|
||||
@@ -232,6 +338,16 @@ describe('LocaleRuntime', () => {
|
||||
expect(make().svc.getLocale().active).toBe('en')
|
||||
})
|
||||
|
||||
it('re-evaluates browser languages as external definitions register and unload', () => {
|
||||
stubLanguages('pt-BR', 'zh-CN')
|
||||
const { svc } = make()
|
||||
expect(svc.getLocale().active).toBe('zh')
|
||||
const dispose = svc.addLanguage({ id: 'pt-BR', label: 'Português (Brasil)', fallback: 'en' })
|
||||
expect(svc.getLocale().active).toBe('pt-BR')
|
||||
dispose()
|
||||
expect(svc.getLocale().active).toBe('zh')
|
||||
})
|
||||
|
||||
it('runs outside a browser (node boots): the default decides and the machine language does not', () => {
|
||||
vi.stubGlobal('window', undefined)
|
||||
// Node exposes its own global navigator; without a window it must not
|
||||
@@ -272,10 +388,10 @@ describe('LocaleRuntime', () => {
|
||||
expect(svc.bind('ns2')('onlyZh')).toBe('onlyZh')
|
||||
})
|
||||
|
||||
it('exposes the two shipped locales with self-described labels', () => {
|
||||
it('starts with exactly the two shipped locales and their fallback relation', () => {
|
||||
const { svc } = make()
|
||||
expect(svc.getLocale().locales).toEqual([
|
||||
{ id: 'zh', label: '中文' },
|
||||
{ id: 'zh', label: '中文', fallback: 'en' },
|
||||
{ id: 'en', label: 'English' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -106,7 +106,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
{
|
||||
key: 'locale',
|
||||
summary: 'Dictionary registry plus locale preference.',
|
||||
description: 'Dictionary registry plus locale preference. Lookup chain per key: the entry\'s namespace in the active locale -> that namespace\'s en fallback -> the shared common namespace (active, then en) -> the key itself (missing text stays visible, fail loud in the UI rather than blank). Reads go through getLocale; writes only through setLocale; continuous sync through the `locale/change` event, or through the LocaleFace getSnapshot/subscribe pair the render machinery consumes (installed via `ctx.slots.installLocale`).',
|
||||
description: 'Dictionary registry plus locale preference. Lookup walks the active language\'s declared fallback chain in the entry namespace, then repeats it in the shared common namespace before showing the key itself. Reads go through getLocale; preferences change only through setLocale, while language packs extend the catalog through addLanguage. Continuous sync uses the `locale/change` event or the LocaleFace getSnapshot/subscribe pair installed through `ctx.slots.installLocale`.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'getLocale(): LocaleSnapshot',
|
||||
@@ -122,7 +122,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'subscribe(fn: () => void): () => void',
|
||||
description: 'LocaleFace subscribe: notified on every snapshot change (locale switch or dictionary registration — registrations bump the revision so already rendered outlets pick up late-arriving dictionaries).',
|
||||
description: 'LocaleFace subscribe: notified on every snapshot change (locale switch or dictionary registration — registrations bump the revision so already rendered outlets pick up late-arriving dictionaries and locale definitions).',
|
||||
parameters: [{ name: 'fn', description: 'change callback.' }],
|
||||
returns: 'unsubscribe.',
|
||||
},
|
||||
@@ -132,19 +132,27 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
parameters: [{ name: 'id', description: 'a registered locale id; unknown ids throw.' }],
|
||||
},
|
||||
{
|
||||
signature: 'register<N extends keyof LocaleNamespaceMap & string>(ns: N, dicts: Record<LocaleId, LocaleDictOf<N>>): () => void',
|
||||
signature: 'addLanguage(input: LanguageRegistration): () => void',
|
||||
description: 'Add one selectable language to the shared catalog. Its fallback must already be registered, and following fallback definitions must terminate at English. Dictionaries may register before or after this definition. Registration rechecks an unresolved Host preference and the browser\'s ordered language list. The caller owns the returned disposer; removing an active language falls back without clearing the stored id.',
|
||||
parameters: [{ name: 'input', description: 'stable id, self-described label, and fallback language id.' }],
|
||||
returns: 'idempotent disposer removing this exact definition.',
|
||||
throws: ['when fields are malformed, the id is occupied, or the fallback target is unknown or creates a cycle.'],
|
||||
},
|
||||
{
|
||||
signature: 'register<N extends Extract<keyof LocaleNamespaceMap, string>>(ns: N, dicts: Record<BuiltInLocaleId, LocaleDictOf<N>>): () => void',
|
||||
description: 'Register a declared namespace\'s dictionaries, all locales in one call — the typed form: each dictionary is checked against the namespace\'s LocaleNamespaceMap key union (a missing or extra key is a compile error), and every shipped locale is required (bilingual balance enforced at registration). Duplicate (ns, locale) throws (single occupant; a namespace\'s texts have one owner). Registration bumps the revision so mounted outlets pick up late-arriving dictionaries.',
|
||||
parameters: [{ name: 'ns', description: 'a namespace merged into LocaleNamespaceMap.' }, { name: 'dicts', description: 'complete dictionaries keyed by locale id.' }],
|
||||
parameters: [{ name: 'ns', description: 'a namespace merged into LocaleNamespaceMap.' }, { name: 'dicts', description: 'complete dictionaries keyed by built-in locale id.' }],
|
||||
returns: 'disposer removing every locale registered by this call (idempotent).',
|
||||
},
|
||||
{
|
||||
signature: 'register(ns: string, locale: string, dict: LocaleDict): () => void',
|
||||
description: 'Single-locale untyped form for namespaces outside the merge table (dynamic composition, tests).',
|
||||
description: 'Single-locale untyped form for language-pack contributions and namespaces outside the merge table.',
|
||||
parameters: [{ name: 'ns', description: 'namespace.' }, { name: 'locale', description: 'locale tag.' }, { name: 'dict', description: 'dictionary.' }],
|
||||
returns: 'disposer (idempotent).',
|
||||
throws: ['when locale is not a BCP 47-style tag.'],
|
||||
},
|
||||
{
|
||||
signature: 'bind<N extends keyof LocaleNamespaceMap & string>(ns: N): TranslateNS<N>',
|
||||
signature: 'bind<N extends Extract<keyof LocaleNamespaceMap, string>>(ns: N): TranslateNS<N>',
|
||||
description: 'Bind a declared namespace to a translate function typed to its dictionary key union (plus the shared common vocabulary) — the same key domain the framework-injected `t` seat carries. The returned reference is stable per namespace (repeat binds return the same function), so it can ride inject surfaces without breaking memoization.',
|
||||
parameters: [{ name: 'ns', description: 'a namespace merged into LocaleNamespaceMap.' }],
|
||||
returns: 'the typed translate function (reads the active locale at call time).',
|
||||
@@ -433,6 +441,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'BoundActions',
|
||||
declaration: 'export type BoundActions<H> = H extends StoreHandle<infer T, infer A> ? BakedActions<T, A> : never;',
|
||||
},
|
||||
{
|
||||
name: 'BuiltInLocaleId',
|
||||
declaration: 'export type BuiltInLocaleId = typeof LOCALE_IDS[number];',
|
||||
},
|
||||
{
|
||||
name: 'ChainKeysOf',
|
||||
declaration: 'export type ChainKeysOf<S extends keyof SlotMap & string> = S extends unknown ? (SlotMap[S][\'kind\'] extends \'chain\' ? S : never) : never;',
|
||||
@@ -537,9 +549,13 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'KeyPropsOf',
|
||||
declaration: 'export type KeyPropsOf<K extends keyof SlotMap & string, EntryKey extends EntryKeyOf<K>> = SlotMap[K] extends {\n kind: \'keyed\';\n keyProps: infer P extends object;\n} ? EntryKey extends keyof P ? P[EntryKey] extends object ? P[EntryKey] : never : never : object;',
|
||||
},
|
||||
{
|
||||
name: 'LanguageRegistration',
|
||||
declaration: 'export interface LanguageRegistration {\n id: LocaleId;\n label: string;\n fallback: LocaleId;\n}',
|
||||
},
|
||||
{
|
||||
name: 'LocaleDefinition',
|
||||
declaration: 'export interface LocaleDefinition {\n id: LocaleId;\n label: string;\n}',
|
||||
declaration: 'export interface LocaleDefinition {\n readonly id: LocaleId;\n readonly label: string;\n readonly fallback?: LocaleId;\n}',
|
||||
},
|
||||
{
|
||||
name: 'LocaleDict',
|
||||
@@ -551,7 +567,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'LocaleId',
|
||||
declaration: 'export type LocaleId = typeof LOCALE_IDS[number];',
|
||||
declaration: 'export type LocaleId = string;',
|
||||
},
|
||||
{
|
||||
name: 'LocaleKeysOf',
|
||||
|
||||
@@ -11,7 +11,7 @@ const CLIENT_OUT = 'packages/extensions/cordis-client-runner/src/client/api-cata
|
||||
|
||||
const CLIENT_SERVICES: Readonly<Record<string, readonly string[]>> = {
|
||||
layout: ['toggleSidebar', 'openDetails', 'closeDetails'],
|
||||
locale: ['getLocale', 'getSnapshot', 'subscribe', 'setLocale', 'register', 'bind'],
|
||||
locale: ['getLocale', 'getSnapshot', 'subscribe', 'setLocale', 'addLanguage', 'register', 'bind'],
|
||||
sessions: ['open', 'openSubagent', 'setSubagentCatalogOpen', 'refreshSubagents', 'search', 'fork', 'scope', 'binding'],
|
||||
slots: ['register', 'inject'],
|
||||
theme: ['getTheme', 'setTheme', 'register', 'overrideTokens'],
|
||||
|
||||
Reference in New Issue
Block a user