Repository navigation
Commit eee4149
feat(spec/system): export the platform locale table PLATFORM_LOCALES and its read aliases (#30007)
domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} -->
_Requested by **Yibo Wang** · [project
thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBDkVVYSBVQGNacBgGdXNHyo)_
Fixes #30001
Clause-②: yes (widening)
Metadata-protocol: none
Skills-docs: follow-up owed (the skills lane is paused) —
skills/objectstack-i18n/SKILL.md (the `supportedLocales` example at line
36, the "BCP-47 Locale Codes" note at line 49 and the "Locale codes" row
at line 206 spell `en-US`, `zh-CN`, `ja-JP`, `es-ES` and say nothing of
the platform table or its read aliases) and
skills/objectui/guides/i18n.md (the label-map key lines 134 and 183
spell `zh-CN`, `ja-JP`). All of those spellings except `en-GB` (line 49)
are a table tag or a read alias; `en-GB` is neither, and it stays valid
too, because those lines are app-level `supportedLocales` examples and
the table closes no app's list. So no skills line is false after this
PR; the owed line is a pointer to `PLATFORM_LOCALES`.
Prior rulings read: locale,supportedlocales,zh-hans → 6 hits; ADR-0030
Decision §2, ADR-0032 Decision §3, ADR-0092 D1, ADR-0092 D2, ADR-0098
Decision §1, ADR-0106 D3; thread: not read (no --card); repo:
objectstack-ai/objectstack
ADR-0087: not needed. Nothing is removed, renamed or narrowed, no schema
refuses a tag that is not on the table, and `migrations/` and
`conversions/` are untouched.
## 维护者速读
- **改了什么:** `@objectstack/spec/system` 新导出平台语言表 `PLATFORM_LOCALES`(11
种语言,行 `{ tag, label, rtl }`,按菜单顺序,冻结),以及旧代码的读别名表
`PLATFORM_LOCALE_READ_ALIASES`(按十个字面量键定类型)和查询函数
`platformLocaleTagOf(code)`;i18n
协议文档补上这张表、别名和规则,并写明这张表是各界面要补齐的目标,今天实际只供给 Console 10 个语言包、元数据和邮件各 4 种语言。
- **为什么改:** 裁决 F(#29349)定下平台只认一张 11 种语言的表、无分级;这是 #29428 的第一步,后续
Console、元数据、邮件、语言菜单都从这张表读。
- **风险与回滚:** 低。纯新增导出,目前没有任何读取方,也不拒收任何非表内语言码,现有数据全部照旧解析。回滚即还原本 PR。
- **先看:**
`packages/spec/src/system/platform-locales.ts`(表、别名、大小写不敏感的查询);`packages/spec/src/system/platform-locales.test.ts`(整表与逐条别名的钉)。
- **两分钟验证:** `pnpm --filter @objectstack/spec exec vitest run
src/system/platform-locales.test.ts`,预期 `59 passed`。
- **需要你做的:** 无,席位落地。
## What changes
**The table** (`packages/spec/src/system/platform-locales.ts`, exported
from `@objectstack/spec/system` through the `system` barrel).
| Language (`label`) | `tag` | `rtl` |
|:--|:--|:--|
| English | `en` | false |
| 简体中文 | `zh-Hans` | false |
| 繁體中文 | `zh-Hant` | false |
| Español | `es` | false |
| العربية | `ar` | **true** |
| Français | `fr` | false |
| Português (Brasil) | `pt-BR` | false |
| Русский | `ru` | false |
| Deutsch | `de` | false |
| 日本語 | `ja` | false |
| 한국어 | `ko` | false |
Exports:
- `PLATFORM_LOCALES: readonly PlatformLocale[]`, the rows above in this
order; the array and every row are frozen.
- `PLATFORM_LOCALE_TAGS: readonly PlatformLocaleTag[]`, the tags in
table order, frozen.
- `PlatformLocaleTag`, the literal union of the eleven tags (derived
from the table), `PlatformLocale`, the row interface, and
`PlatformLocaleReadAlias`, the union of the ten alias keys.
- `PLATFORM_LOCALE_READ_ALIASES`, a frozen record typed on its ten
literal keys (`PlatformLocaleReadAlias`, each mapped to its literal
table tag), so indexing it with an unlisted code is a type error rather
than a value typed as defined: `zh`, `zh-CN`, `zh-SG` → `zh-Hans`;
`zh-TW`, `zh-HK`, `zh-MO` → `zh-Hant`; `ja-JP` → `ja`; `es-ES` → `es`;
`pt` → `pt-BR`; `en-US` → `en`.
- `platformLocaleTagOf(code: unknown): PlatformLocaleTag | undefined`, a
pure lookup: a table tag answers itself, an alias answers its target,
anything else answers `undefined`. No region fallback is guessed:
`pt-PT`, `de-AT`, `en-GB`, `zh-Hans-CN` all answer `undefined`.
**The case-sensitivity choice: the lookup ignores case.** `zh-cn` reads
as `zh-Hans`, `ZH-HANT` as `zh-Hant`, `pt-br` as `pt-BR`. Why:
- BCP 47 tags are case-insensitive (RFC 5646 §2.1.1), so `zh-cn` and
`zh-CN` name one locale.
- Stored values are not all in canonical case. `LocaleSchema` folds case
at parse, but only on the shapes that use it (translation keys, `i18n`
config); a user's stored `locale` and an `Accept-Language` value are not
folded, and #29895 records mis-cased stored values such as `zh-cn`.
- The bundle resolver already matches this way (`resolveBundleLocale` in
`i18n-resolver.ts`: exact, then case-insensitive), as does
`isDefaultLocale`. A case-sensitive alias lookup would answer
`undefined` for `zh-cn` where the resolver beside it matches `zh-CN`:
two dialects for one value.
Nothing else is corrected: an underscore (`zh_CN`), surrounding
whitespace, a language name or a non-string answers `undefined`,
matching what `LocaleSchema` refuses. The index is a `Map`, so
`constructor` or `__proto__` never reads an `Object.prototype` member.
Pinned in `platform-locales.test.ts` (the "ignores case" and "nothing
but case is corrected" cases).
**The table is the target, not yet the state.** The module TSDoc and the
docs section state the table as the rule every surface is held to, and
name what each serves today: the Console ships ten packs (`en`, `zh`,
`es`, `ar`, `fr`, `pt`, `ru`, `de`, `ja`, `ko`; no `zh-Hant`), platform
metadata four locales (`en`, `zh-CN`, `ja-JP`, `es-ES`), the built-in
emails four (`en-US`, `zh-CN`, `ja-JP`, `es-ES`, `en-US` the floor). The
same-PR follow rule is worded so it is true today: a new string is added
for every language its surface carries, which is every table language
once that surface is backfilled.
**The lookup and the bundle ladder.** `platformLocaleTagOf` answers
which table language a code means; `resolveBundleLocale`'s ladder
(exact, case, base language, first variant) answers which key of a
bundle serves a request. A reader that uses both maps first and resolves
the table tag second, so they do not contradict: `pt` means `pt-BR`, and
a request for `pt-BR` may still be served by a bundle's `pt` key.
Measured at this head (`npx tsx` on `src/system/i18n-resolver.ts`):
`resolveBundleLocale({ 'zh-Hant': 1, 'zh-Hans': 2 }, 'zh')` answers
`zh-Hant`, the same bundle asked for `zh-CN` answers `zh-Hant` too, and
with the keys in the other order `zh` answers `zh-Hans`. So once a
bundle holds both scripts, the ladder alone answers a bare or regional
Chinese code by key order where the table says `zh-Hans`. The docs
section and the TSDoc say so in a sentence; the resolver is unchanged.
**Where it sits.** The module sits beside `i18n-resolver.ts` and changes
nothing in it: the existing locale-keyed maps there,
`resolveBundleLocale` and `LocaleSchema`'s case fold are untouched.
Nothing reads the table yet; phases 2 to 4 of #29428 wire the readers.
**Docs.** `content/docs/protocol/kernel/i18n-standard.mdx` gains a `###
Platform Locale Table` section under *Locale Format*: the table, the
rule (same-PR follow; a language is added or removed by changing the
table and backfilling once), the read-alias table and the lookup's
behaviour, and one sentence saying the page's `zh-CN` examples stay
valid because `zh-CN` is a read alias. The rest of the page is
unchanged.
**Generated.** `packages/spec/api-surface/system.json` and
`packages/spec/export-origins/system.json` gain the seven new names
(`gen:api-surface`, `gen:export-origins`).
**Changeset.** `.changeset/30001-platform-locale-table.md`,
`@objectstack/spec: patch`, with both declaration lines.
## Tests
`packages/spec/src/system/platform-locales.test.ts`, 59 cases:
- the table pinned whole (tags, order, labels, `rtl`), and `rtl` only on
`ar`;
- the tag list equals the table's tags, with no duplicate;
- the table, every row, the tag list and the alias map are frozen;
- the three value exports and the function are re-exported by the
`system` barrel;
- one case per alias (ten), and the alias map is exactly those ten;
- the map is typed on its ten literal keys: `zh-CN` indexes as
`'zh-Hans'`, and indexing with `pt-PT` is a `@ts-expect-error` (the test
file is compiled by `check:test-typecheck`);
- every alias target is a table tag; no alias key is itself a table tag,
in any case;
- each table tag answers itself (eleven);
- case is ignored (eight spellings);
- codes the list does not name answer `undefined` (nine, including
`pt-PT` and `zh-Hans-CN`);
- an underscore, whitespace, a name, the empty string and
`Object.prototype` names answer `undefined`;
- non-strings answer `undefined`.
<details><summary>Local runs at head a8e8120</summary>
- `pnpm --filter @objectstack/spec test && pnpm --filter
@objectstack/spec typecheck` under os-verify-lock: `Test Files 810
passed (810)`, `Tests 24344 passed | 1 todo (24345)`; typecheck exit 0;
`VERDICT command-exit 0`.
- `pnpm --filter @objectstack/spec check:generated`: 14 of 14 artifacts
up to date, exit 0 (after `gen:api-surface` and `gen:export-origins`,
against a dist built from this head).
- Round 1 (contract review FAIL at 8810e33): commits `ea8473ef7` (docs
+ TSDoc + alias typing) and `a8e812094` (ledgers for
`PlatformLocaleReadAlias`).
- `check-changeset-no-major --base origin/main` with this body as the
event: exit 0, "metadata-protocol `none` … moves no metadata-protocol
baseline", so `none` + `patch` holds.
</details>
## Acceptance notes
- `EMAIL_TEMPLATE_FLOOR_LOCALE` (`en-US`) is untouched; phase 3 decides
it. `en-US` already reads as `en` through the alias map, so that
decision needs no change here.
- `AGENTS.md:647` (the package-count drift the parent names) is not
carried: it is rule text in the paused skills lane.
- No schema refuses a tag that is not on the table, and no gate is added
(ruling F).
- `packages/spec/src/conversions/registry.ts` (under the #29987 split)
is untouched.
## Verification
<details><summary>Derived gates: 102 of 102 run at head 8810e33, every
exit 0</summary>
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 102 families. All ran serially; 95
exited 0 on the first sweep and 7 exited 3 (PREREQUISITE NOT MET: no
built dist for lint, formula, client, client-react, objectql, and the
teardown self-test's pinned fixture commit missing from the shallow
clone). After `pnpm turbo run build --concurrency=2` (VERDICT
command-exit 0) and `git fetch origin 621a487`, those 7 re-ran and
exited 0. `dispatch-gates --ran`: "102 derived famil(ies) accounted for
— 102 run, 0 NOT-MEASURED", exit 0. The record is
`/mnt/project-files/issue-30001/gates-ran.list` in the project folder.
Not run locally (CI owns them): `test:repo`, `pnpm lint`, Test UI,
dogfood (no `!` in the title and no `(narrowing)` arm, so no dogfood
battery is owed).
</details>
---
_Generated by [Claude
Code](https://claude.ai/code/session_01WGufziqXGwk5Cx6oJizsSn)_
Body refreshed by spec seat 3 (fix round 1 on a8e8120; contract review
PASS 6622424793): 2026-11-04T04:19Z
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 73a3ef9 commit eee4149
7 files changed
Lines changed: 353 additions & 0 deletions
File tree
- .changeset
- content/docs/protocol/kernel
- packages/spec
- api-surface
- export-origins
- src/system
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
125 | 125 | | |
126 | 126 | | |
127 | 127 | | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
128 | 194 | | |
129 | 195 | | |
130 | 196 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
513 | 513 | | |
514 | 514 | | |
515 | 515 | | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
516 | 519 | | |
517 | 520 | | |
518 | 521 | | |
| |||
533 | 536 | | |
534 | 537 | | |
535 | 538 | | |
| 539 | + | |
| 540 | + | |
| 541 | + | |
536 | 542 | | |
537 | 543 | | |
538 | 544 | | |
| |||
876 | 882 | | |
877 | 883 | | |
878 | 884 | | |
| 885 | + | |
879 | 886 | | |
880 | 887 | | |
881 | 888 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
492 | 492 | | |
493 | 493 | | |
494 | 494 | | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
495 | 498 | | |
496 | 499 | | |
497 | 500 | | |
| |||
512 | 515 | | |
513 | 516 | | |
514 | 517 | | |
| 518 | + | |
| 519 | + | |
| 520 | + | |
515 | 521 | | |
516 | 522 | | |
517 | 523 | | |
| |||
837 | 843 | | |
838 | 844 | | |
839 | 845 | | |
| 846 | + | |
840 | 847 | | |
841 | 848 | | |
842 | 849 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
126 | 126 | | |
127 | 127 | | |
128 | 128 | | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
129 | 132 | | |
130 | 133 | | |
131 | 134 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
0 commit comments