fix(locale): validate contributed language tags

This commit is contained in:
imccyu
2026-08-25 20:40:26 +08:00
parent bbe00b0db2
commit 45b9f2db44
8 changed files with 24 additions and 5 deletions
@@ -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
@@ -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<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 BCP 47 tag for preference storage, dictionary lookup, browser matching, and `<html lang>`; 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 `<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).
@@ -14,7 +14,7 @@ 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 同时是偏好存储、字典查找、浏览器匹配和 `<html lang>` 使用的 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 标签,同时用于偏好存储、字典查找、浏览器匹配和 `<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)负责。
@@ -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<string, LocaleDict>, 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()
@@ -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
@@ -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)
})
@@ -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' })
@@ -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<N extends Extract<keyof LocaleNamespaceMap, string>>(ns: N): TranslateNS<N>',