diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml index e6e500f1d5..df81608c10 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml @@ -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: 1d20d767780cb7fcf4f5903da0fbe62f624bc762 -2026-07-30-client-locale-full-rollout.zh.md: 97146d8211aaeb52e80de5cfa104e583b36b3c02 +2026-07-30-client-locale-full-rollout.md: dedfe98ca2b3e64a56518dfa6157244e4d4c16df +2026-07-30-client-locale-full-rollout.zh.md: e9bd1ed19e8b485d812140ab044c779a2ce6e9d3 diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md index 1d20d76778..dedfe98ca2 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md @@ -14,7 +14,7 @@ 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` is the key source and `en satisfies Record` 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 BCP 47 tag for preference storage, dictionary lookup, browser matching, and ``; 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. +**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 ``; `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). diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md index 97146d8211..e9bd1ed19e 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md @@ -14,7 +14,7 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t` **组件文案走标准 `t` 席位;深层子组件用 prop 下传**,类型写 `XxxProps['t']`。字典规范形态不变:`zh satisfies Record` 为 key 源、`en satisfies Record` 锁双语平衡。 -**内置 locale 集合封闭,语言目录可扩展。** 本包只提供 `zh` 与 `en`,类型化命名空间注册仍要求这对双语字典。外部 client 插件通过 `ctx.effect(() => ctx.locale.addLanguage({ id, label, fallback }))` 增加语言,并通过既有的单 locale 字典注册贡献不完整翻译;语言定义与字典可以按任意顺序注册。外部语言 id 同时是偏好存储、字典查找、浏览器匹配和 `` 使用的 BCP 47 标签;内置 `zh` 定义继续使用内部 `zh-CN` 文档标签。每个新增语言都声明一个已注册的 fallback,fallback 自身的定义给出下一层 fallback,整条链必须终止于 `en`;未知目标和循环在注册时失败。每个 key 先在请求的命名空间中沿链查找,再在 `common` 中重复同一条链,最后显示 key 本身。Host 存储开放字符串偏好;不可用的已保存 id 会保持待采用,直至对应语言注册;定义移除后,正在使用的选择会回落到可用的浏览器匹配或 `en`。目录变更推进 `LocaleFace` revision,使语言设置行跟随注册和 dispose。 +**内置 locale 集合封闭,语言目录可扩展。** 本包只提供 `zh` 与 `en`,类型化命名空间注册仍要求这对双语字典。外部 client 插件通过 `ctx.effect(() => ctx.locale.addLanguage({ id, label, fallback }))` 增加语言,并通过既有的单 locale 字典注册贡献不完整翻译;语言定义与字典可以按任意顺序注册。外部语言 id 是经过校验的 BCP 47 标签,同时用于偏好存储、字典查找、浏览器匹配和 ``;该标签承载可互操作的语言语义而非不透明身份,因此 `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)负责。 diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index 820817a77e..fe5a7295ff 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -375,6 +375,7 @@ export class LocaleRuntime { * @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, dict?: LocaleDict): () => void { @@ -382,6 +383,11 @@ 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() diff --git a/packages/client/locale/src/locale-settings.ts b/packages/client/locale/src/locale-settings.ts index 25bee91bdc..236ea6d788 100644 --- a/packages/client/locale/src/locale-settings.ts +++ b/packages/client/locale/src/locale-settings.ts @@ -9,7 +9,7 @@ export const LOCALE_SETTINGS_NAMESPACE = 'locale' export const LOCALE_PREFERENCE_FIELD = 'preference' /** Accepted BCP 47-style language ids. */ -export const LOCALE_ID_PATTERN = /^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/u +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 diff --git a/packages/client/locale/tests/host.client.spec.ts b/packages/client/locale/tests/host.client.spec.ts index 7b2b98137d..0bd264521b 100644 --- a/packages/client/locale/tests/host.client.spec.ts +++ b/packages/client/locale/tests/host.client.spec.ts @@ -26,6 +26,7 @@ describe('locale host', () => { 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) }) diff --git a/packages/client/locale/tests/locale.client.spec.ts b/packages/client/locale/tests/locale.client.spec.ts index b3beebe55b..ef9551f312 100644 --- a/packages/client/locale/tests/locale.client.spec.ts +++ b/packages/client/locale/tests/locale.client.spec.ts @@ -224,6 +224,8 @@ describe('LocaleRuntime', () => { .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' })) @@ -232,6 +234,15 @@ describe('LocaleRuntime', () => { .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' }) diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 177b55fffe..9bb7721efd 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -149,6 +149,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 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>(ns: N): TranslateNS',