Skip to content

Commit eee4149

Browse files
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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
feat(spec/system): export the platform locale table `PLATFORM_LOCALES` and its read aliases
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
- **`PLATFORM_LOCALES`** (from `@objectstack/spec/system`): the one list of languages the platform supports, as frozen rows `{ tag, label, rtl }` in menu order: `en`, `zh-Hans`, `zh-Hant`, `es`, `ar` (right to left), `fr`, `pt-BR`, `ru`, `de`, `ja`, `ko`, each labelled with its own native name. `PLATFORM_LOCALE_TAGS` is the tag list and `PlatformLocaleTag` the literal union of the tags.
11+
- **`PLATFORM_LOCALE_READ_ALIASES`** (typed on its ten literal keys, `PlatformLocaleReadAlias`) and **`platformLocaleTagOf(code)`**: the codes stored values already carry, mapped to the table tag they mean (`zh`, `zh-CN`, `zh-SG` to `zh-Hans`; `zh-TW`, `zh-HK`, `zh-MO` to `zh-Hant`; `ja-JP` to `ja`; `es-ES` to `es`; `pt` to `pt-BR`; `en-US` to `en`). A table tag answers itself and the match ignores case (`zh-cn` reads as `zh-Hans`). Any other code answers `undefined`; no region fallback is guessed.
12+
- The table is the target every surface is backfilled to; today the Console ships ten packs without `zh-Hant`, and platform metadata and the built-in emails ship four locales. Nothing reads the table yet and no schema refuses a tag that is not on it: every locale value that parsed before still parses. The protocol page `i18n-standard` gains the table, the read aliases and the rule (same-PR follow; a language is added or removed by changing the table and backfilling once).

‎content/docs/protocol/kernel/i18n-standard.mdx‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,72 @@ Examples:
125125
- `zh-CN` — Chinese (Simplified, China)
126126
- `zh-TW` — Chinese (Traditional, Taiwan)
127127

128+
### Platform Locale Table
129+
130+
The platform supports **one** list of languages, exported from
131+
`@objectstack/spec/system` as `PLATFORM_LOCALES` (rows `{ tag, label, rtl }`,
132+
in menu order) with its tags as `PLATFORM_LOCALE_TAGS`. There are no tiers.
133+
The table is the rule every surface is held to: the Console UI, platform
134+
metadata and the built-in emails each carry every language on it.
135+
136+
<Callout type="warn">
137+
**The target, not yet the state.** Today the Console ships ten packs (`en`,
138+
`zh`, `es`, `ar`, `fr`, `pt`, `ru`, `de`, `ja`, `ko`; no `zh-Hant`), platform
139+
metadata ships four locales (`en`, `zh-CN`, `ja-JP`, `es-ES`) and the built-in
140+
emails four (`en-US`, `zh-CN`, `ja-JP`, `es-ES`, with `en-US` as the floor).
141+
The surfaces are backfilled to the table in later phases.
142+
</Callout>
143+
144+
| Language | Tag | Right to left |
145+
|:--|:--|:--|
146+
| English | `en` | |
147+
| 简体中文 | `zh-Hans` | |
148+
| 繁體中文 | `zh-Hant` | |
149+
| Español | `es` | |
150+
| العربية | `ar` | yes |
151+
| Français | `fr` | |
152+
| Português (Brasil) | `pt-BR` | |
153+
| Русский | `ru` | |
154+
| Deutsch | `de` | |
155+
| 日本語 | `ja` | |
156+
| 한국어 | `ko` | |
157+
158+
The rule:
159+
160+
- **Same-PR follow.** A change that adds a translatable string adds it for every
161+
language its surface carries, in the same pull request; once a surface is
162+
backfilled, that is every language on the table.
163+
- **Adding or removing a language** is a change to the table, followed by one
164+
backfill of every surface. Nothing else declares the list.
165+
166+
**Read aliases.** Codes that stored values already carry keep resolving:
167+
`PLATFORM_LOCALE_READ_ALIASES` maps each to a table tag, and
168+
`platformLocaleTagOf(code)` answers the table tag a stored code means.
169+
170+
| Stored code | Reads as |
171+
|:--|:--|
172+
| `zh`, `zh-CN`, `zh-SG` | `zh-Hans` |
173+
| `zh-TW`, `zh-HK`, `zh-MO` | `zh-Hant` |
174+
| `ja-JP` | `ja` |
175+
| `es-ES` | `es` |
176+
| `pt` | `pt-BR` |
177+
| `en-US` | `en` |
178+
179+
A table tag answers itself, the match ignores case (`zh-cn` reads as
180+
`zh-Hans`), and any other code answers `undefined`: no region fallback is
181+
guessed, so `pt-PT` is not `pt-BR`. New writes use the table's tags. The
182+
examples on this page that spell `zh-CN` stay valid, because `zh-CN` is a read
183+
alias of `zh-Hans`.
184+
185+
`platformLocaleTagOf` answers which table language a code *means*; the
186+
fallback ladder below answers which key of a bundle *serves* a request. A
187+
reader that uses both maps the code first and then resolves the table tag
188+
against the bundle, so they do not contradict each other: `pt` means `pt-BR`,
189+
and a request for `pt-BR` may still be served by a bundle's `pt` key. The
190+
ladder alone cannot tell the two Chinese scripts apart: once a bundle holds
191+
both `zh-Hans` and `zh-Hant`, a request for `zh` or `zh-CN` is answered by key
192+
order (`zh-Hant` when that key comes first), where the table says `zh-Hans`.
193+
128194
### Locale Fallback
129195

130196
If exact locale not found, ObjectStack falls back to **language-only** locale:

‎packages/spec/api-surface/system.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,9 @@
513513
"PAGE_FORM_COMPONENT_TYPE (const)",
514514
"PAGE_TABS_COMPONENT_TYPE (const)",
515515
"PKG_CONVENTIONS (const)",
516+
"PLATFORM_LOCALES (const)",
517+
"PLATFORM_LOCALE_READ_ALIASES (const)",
518+
"PLATFORM_LOCALE_TAGS (const)",
516519
"PLATFORM_OBJECTS_BY_PACKAGE (const)",
517520
"PLATFORM_OBJECT_PREFIXES (const)",
518521
"PLATFORM_PROVIDED_OBJECT_NAMES (const)",
@@ -533,6 +536,9 @@
533536
"Plan (type)",
534537
"PlanParsed (type)",
535538
"PlanSchema (const)",
539+
"PlatformLocale (interface)",
540+
"PlatformLocaleReadAlias (type)",
541+
"PlatformLocaleTag (type)",
536542
"PlatformTranslationBundle (type)",
537543
"PlatformTranslationBundleSchema (const)",
538544
"PlatformTranslationData (type)",
@@ -876,6 +882,7 @@
876882
"operationMessageTranslationKey (function)",
877883
"organizationOwnershipShapeGenerationOf (function)",
878884
"pageTabItemKey (function)",
885+
"platformLocaleTagOf (function)",
879886
"preferredLocaleFromHeader (function)",
880887
"renderOperationMessage (function)",
881888
"renderValidationMessage (function)",

‎packages/spec/export-origins/system.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,9 @@
492492
"PAGE_FORM_COMPONENT_TYPE": "src/system/i18n-resolver.ts#PAGE_FORM_COMPONENT_TYPE (const)",
493493
"PAGE_TABS_COMPONENT_TYPE": "src/system/i18n-resolver.ts#PAGE_TABS_COMPONENT_TYPE (const)",
494494
"PKG_CONVENTIONS": "src/system/constants/paths.ts#PKG_CONVENTIONS (const)",
495+
"PLATFORM_LOCALES": "src/system/platform-locales.ts#PLATFORM_LOCALES (const)",
496+
"PLATFORM_LOCALE_READ_ALIASES": "src/system/platform-locales.ts#PLATFORM_LOCALE_READ_ALIASES (const)",
497+
"PLATFORM_LOCALE_TAGS": "src/system/platform-locales.ts#PLATFORM_LOCALE_TAGS (const)",
495498
"PLATFORM_OBJECTS_BY_PACKAGE": "src/system/constants/platform-object-names.ts#PLATFORM_OBJECTS_BY_PACKAGE (const)",
496499
"PLATFORM_OBJECT_PREFIXES": "src/system/constants/platform-object-names.ts#PLATFORM_OBJECT_PREFIXES (const)",
497500
"PLATFORM_PROVIDED_OBJECT_NAMES": "src/system/constants/platform-object-names.ts#PLATFORM_PROVIDED_OBJECT_NAMES (const)",
@@ -512,6 +515,9 @@
512515
"Plan": "src/system/license.zod.ts#Plan (type)",
513516
"PlanParsed": "src/system/license.zod.ts#PlanParsed (type)",
514517
"PlanSchema": "src/system/license.zod.ts#PlanSchema (const)",
518+
"PlatformLocale": "src/system/platform-locales.ts#PlatformLocale (interface)",
519+
"PlatformLocaleReadAlias": "src/system/platform-locales.ts#PlatformLocaleReadAlias (type)",
520+
"PlatformLocaleTag": "src/system/platform-locales.ts#PlatformLocaleTag (type)",
515521
"PlatformTranslationBundle": "src/system/translation.zod.ts#PlatformTranslationBundle (type)",
516522
"PlatformTranslationBundleSchema": "src/system/translation.zod.ts#PlatformTranslationBundleSchema (const)",
517523
"PlatformTranslationData": "src/system/translation.zod.ts#PlatformTranslationData (type)",
@@ -837,6 +843,7 @@
837843
"operationMessageTranslationKey": "src/system/operation-message.ts#operationMessageTranslationKey (function)",
838844
"organizationOwnershipShapeGenerationOf": "src/system/migration.zod.ts#organizationOwnershipShapeGenerationOf (function)",
839845
"pageTabItemKey": "src/system/i18n-resolver.ts#pageTabItemKey (function)",
846+
"platformLocaleTagOf": "src/system/platform-locales.ts#platformLocaleTagOf (function)",
840847
"preferredLocaleFromHeader": "src/system/i18n-resolver.ts#preferredLocaleFromHeader (function)",
841848
"renderOperationMessage": "src/system/operation-message.ts#renderOperationMessage (function)",
842849
"renderValidationMessage": "src/system/validation-message.ts#renderValidationMessage (function)",

‎packages/spec/src/system/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@ export * from './worker.zod';
126126
export * from './notification.zod';
127127
export * from './translation.zod';
128128
export * from './i18n-resolver';
129+
// The platform locale table (ruling F of #29349): the one list of languages
130+
// the platform supports, and the read aliases that keep stored codes valid.
131+
export * from './platform-locales';
129132
// Localized templates for the built-in field-validation messages (#3957).
130133
export * from './validation-message';
131134
// Localized templates for OPERATION-level data refusals (#7307) — a write the
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Pins for the platform locale table (ruling F of #29349, phase 1 of #29428,
4+
// card #30001): the table whole, its read aliases one by one, the
5+
// case-insensitive lookup and the closed list (no region fallback).
6+
7+
import { describe, it, expect } from 'vitest';
8+
import {
9+
PLATFORM_LOCALES,
10+
PLATFORM_LOCALE_TAGS,
11+
PLATFORM_LOCALE_READ_ALIASES,
12+
platformLocaleTagOf,
13+
type PlatformLocaleReadAlias,
14+
type PlatformLocaleTag,
15+
} from './platform-locales';
16+
import * as SystemBarrel from './index';
17+
18+
describe('PLATFORM_LOCALES: the table, pinned whole', () => {
19+
it('holds the eleven languages, in menu order, with their native labels and rtl only on ar', () => {
20+
expect(PLATFORM_LOCALES).toEqual([
21+
{ tag: 'en', label: 'English', rtl: false },
22+
{ tag: 'zh-Hans', label: '简体中文', rtl: false },
23+
{ tag: 'zh-Hant', label: '繁體中文', rtl: false },
24+
{ tag: 'es', label: 'Español', rtl: false },
25+
{ tag: 'ar', label: 'العربية', rtl: true },
26+
{ tag: 'fr', label: 'Français', rtl: false },
27+
{ tag: 'pt-BR', label: 'Português (Brasil)', rtl: false },
28+
{ tag: 'ru', label: 'Русский', rtl: false },
29+
{ tag: 'de', label: 'Deutsch', rtl: false },
30+
{ tag: 'ja', label: '日本語', rtl: false },
31+
{ tag: 'ko', label: '한국어', rtl: false },
32+
]);
33+
expect(PLATFORM_LOCALES.filter((row) => row.rtl).map((row) => row.tag)).toEqual(['ar']);
34+
});
35+
36+
it('PLATFORM_LOCALE_TAGS is the table tags in table order', () => {
37+
expect(PLATFORM_LOCALE_TAGS).toEqual(PLATFORM_LOCALES.map((row) => row.tag));
38+
expect(new Set(PLATFORM_LOCALE_TAGS).size).toBe(PLATFORM_LOCALE_TAGS.length);
39+
});
40+
41+
it('the table, every row, the tag list and the alias map are frozen', () => {
42+
expect(Object.isFrozen(PLATFORM_LOCALES)).toBe(true);
43+
for (const row of PLATFORM_LOCALES) expect(Object.isFrozen(row)).toBe(true);
44+
expect(Object.isFrozen(PLATFORM_LOCALE_TAGS)).toBe(true);
45+
expect(Object.isFrozen(PLATFORM_LOCALE_READ_ALIASES)).toBe(true);
46+
});
47+
48+
it('is exported from the system barrel', () => {
49+
expect(SystemBarrel.PLATFORM_LOCALES).toBe(PLATFORM_LOCALES);
50+
expect(SystemBarrel.PLATFORM_LOCALE_TAGS).toBe(PLATFORM_LOCALE_TAGS);
51+
expect(SystemBarrel.PLATFORM_LOCALE_READ_ALIASES).toBe(PLATFORM_LOCALE_READ_ALIASES);
52+
expect(SystemBarrel.platformLocaleTagOf).toBe(platformLocaleTagOf);
53+
});
54+
});
55+
56+
describe('PLATFORM_LOCALE_READ_ALIASES: stored codes keep resolving', () => {
57+
const ALIASES: ReadonlyArray<readonly [PlatformLocaleReadAlias, PlatformLocaleTag]> = [
58+
['zh', 'zh-Hans'],
59+
['zh-CN', 'zh-Hans'],
60+
['zh-SG', 'zh-Hans'],
61+
['zh-TW', 'zh-Hant'],
62+
['zh-HK', 'zh-Hant'],
63+
['zh-MO', 'zh-Hant'],
64+
['ja-JP', 'ja'],
65+
['es-ES', 'es'],
66+
['pt', 'pt-BR'],
67+
['en-US', 'en'],
68+
];
69+
70+
it.each(ALIASES)('%s reads as %s', (code, tag) => {
71+
expect(PLATFORM_LOCALE_READ_ALIASES[code]).toBe(tag);
72+
expect(platformLocaleTagOf(code)).toBe(tag);
73+
});
74+
75+
it('the map is exactly those ten aliases', () => {
76+
expect(PLATFORM_LOCALE_READ_ALIASES).toEqual(Object.fromEntries(ALIASES));
77+
});
78+
79+
it('the map is typed on its ten literal keys: an unlisted code does not index it as defined', () => {
80+
const zhCN: 'zh-Hans' = PLATFORM_LOCALE_READ_ALIASES['zh-CN'];
81+
expect(zhCN).toBe('zh-Hans');
82+
// @ts-expect-error `pt-PT` is not a read alias, so the index is not typed as a table tag.
83+
expect(PLATFORM_LOCALE_READ_ALIASES['pt-PT']).toBeUndefined();
84+
});
85+
86+
it('every alias target is a table tag', () => {
87+
for (const tag of Object.values(PLATFORM_LOCALE_READ_ALIASES)) {
88+
expect(PLATFORM_LOCALE_TAGS).toContain(tag);
89+
}
90+
});
91+
92+
it('no alias key is itself a table tag, in any case', () => {
93+
const folded = new Set(PLATFORM_LOCALE_TAGS.map((tag) => tag.toLowerCase()));
94+
for (const code of Object.keys(PLATFORM_LOCALE_READ_ALIASES)) {
95+
expect(folded.has(code.toLowerCase())).toBe(false);
96+
}
97+
});
98+
});
99+
100+
describe('platformLocaleTagOf', () => {
101+
it.each(PLATFORM_LOCALE_TAGS.map((tag) => [tag]))('the table tag %s answers itself', (tag) => {
102+
expect(platformLocaleTagOf(tag)).toBe(tag);
103+
});
104+
105+
// BCP 47 tags are case-insensitive, and stored user locales are not all in
106+
// canonical case (#29895 records `zh-cn`).
107+
it.each([
108+
['zh-cn', 'zh-Hans'],
109+
['ZH-TW', 'zh-Hant'],
110+
['zh-hans', 'zh-Hans'],
111+
['ZH-HANT', 'zh-Hant'],
112+
['pt-br', 'pt-BR'],
113+
['EN', 'en'],
114+
['en-us', 'en'],
115+
['ja-jp', 'ja'],
116+
])('ignores case: %s reads as %s', (code, tag) => {
117+
expect(platformLocaleTagOf(code)).toBe(tag);
118+
});
119+
120+
// The list is closed: no region or script fallback is guessed.
121+
it.each(['pt-PT', 'de-AT', 'en-GB', 'zh-Hans-CN', 'es-419', 'fr-CA', 'it', 'he', 'zh-Hant-TW'])(
122+
'a code the table and the aliases do not list, %s, answers undefined',
123+
(code) => {
124+
expect(platformLocaleTagOf(code)).toBeUndefined();
125+
},
126+
);
127+
128+
it.each(['zh_CN', ' en', 'en ', 'Chinese', '', 'constructor', 'toString', '__proto__'])(
129+
'nothing but case is corrected: %j answers undefined',
130+
(code) => {
131+
expect(platformLocaleTagOf(code)).toBeUndefined();
132+
},
133+
);
134+
135+
it.each([undefined, null, 42, {}, ['en']])('a non-string, %j, answers undefined', (value) => {
136+
expect(platformLocaleTagOf(value)).toBeUndefined();
137+
});
138+
});

0 commit comments

Comments
 (0)