Skip to content

Commit 4564d6f

Browse files
fix(spec/system): read the script-subtag Chinese spellings (zh-Hant-TW, zh-Hans-CN, …) as platform locale read aliases (#30681)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuV5cRafodM2YQHxeDkdiWJM)_ domain:spec#2 · os-sales · session_01L6bhgVugtCCwBs98FeR27P Fixes #30618 Clause-②: yes (widening) Metadata-protocol: semantic Skills-docs: none — `skills/**` carries no script-subtag or read-alias claim, measured at `d8713f3f`: `git grep -i -E 'zh-Han[st]-[A-Za-z0-9]+' -- skills/` 0 hits, `git grep -E 'PLATFORM_LOCALE_READ_ALIASES|platformLocaleTagOf|read alias|zh-Hant' -- skills/` 0 hits. The one docs page that states the read aliases, `content/docs/protocol/kernel/i18n-standard.mdx` (its "Read aliases" section), is fixed in this PR. ## 维护者速读 - 改了什么:平台语言表的读入别名加了五行:`zh-Hans-CN`、`zh-Hans-SG` 读作 `zh-Hans`,`zh-Hant-TW`、`zh-Hant-HK`、`zh-Hant-MO` 读作 `zh-Hant`。解析阶梯的“同表语言”一步对已注册的 key 也查这张表,所以拼成 `zh-Hant-TW` / `zh-Hans-CN` 的包 key 或可选代码,按它写明的文字归入繁体或简体。别名表仍是逐行列举的封闭清单,不按模式推断,也不按区域回退。文档页 `i18n-standard.mdx` 的 Read aliases 一节同步改正。 - 为什么改:浏览器和操作系统常发带文字子标签的语言代码(如 `zh-Hant-TW`)。原来这五个代码不在表里,同时有简体包和繁体包时,繁体读者拿到的是先注册的那个 zh-* 包;主干上 service-storage 的对象翻译包(`zh-CN` 排在 `zh-Hant` 前)就是这样把繁体读者答成简体。 - 风险与回滚:只改这五个代码(作请求或作已注册的 key 时)由哪个包来答,原来能匹配的请求仍能匹配,表外代码(`pt-PT`、`en-GB`、`zh-Hant-US`)照旧。Setup「默认语言」对这五个代码改用表里的名称(繁體中文 / 简体中文),已存的繁体值改显示在繁體中文选项上;可接受的取值不变。回滚即还原本 PR。 - 先看:`packages/spec/src/system/platform-locales.ts` 的五行别名及其注释;`packages/core/src/fallbacks/fallbacks.test.ts` 的共享用例表(两个解析器、两种注册顺序,含 key 侧用例);`packages/spec/src/system/platform-locales.test.ts` 的封闭清单用例;`content/docs/protocol/kernel/i18n-standard.mdx` 的 Read aliases 一节。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/system/platform-locales.test.ts src/system/i18n-resolver.test.ts`,预期 2 个文件、576 例全绿。 - 需要你做的:无,席位落地。 ## What changes **`PLATFORM_LOCALE_READ_ALIASES`** (`packages/spec/src/system/platform-locales.ts`) gains five rows, one for the script-subtag spelling of each regional Chinese alias. `platformLocaleTagOf`, FROM → TO: | Code | FROM | TO | |:--|:--|:--| | `zh-Hans-CN` | `undefined` | `zh-Hans` | | `zh-Hans-SG` | `undefined` | `zh-Hans` | | `zh-Hant-TW` | `undefined` | `zh-Hant` | | `zh-Hant-HK` | `undefined` | `zh-Hant` | | `zh-Hant-MO` | `undefined` | `zh-Hant` | Case is folded as for every row (`ZH-HANT-tw` reads as `zh-Hant`). `PlatformLocaleReadAlias` gains the five literal keys. The list stays closed: enumerated rows only, no pattern, no region fallback. `zh-Hant-US`, `zh-Hans-US`, `pt-PT`, `de-AT`, `en-GB`, `es-419`, `fr-CA`, `it` and `he` still answer `undefined`. **What the ladder answers for a script-subtag request** (`resolveLocaleAmong`, so `resolveBundleLocale` and core's `resolveLocale` / `resolveOfferedLocale`), FROM → TO, measured by the ablation below: | Request | over `en, zh-CN, zh-Hant` | over `en, zh-Hant, zh-CN` | over `en, zh, zh-Hant` | |:--|:--|:--|:--| | `zh-Hant-TW`, `zh-Hant-HK`, `zh-Hant-MO` | `zh-CN` → `zh-Hant` | `zh-Hant` (unchanged) | `zh` → `zh-Hant` | | `zh-Hans-CN`, `zh-Hans-SG` | `zh-CN` (unchanged) | `zh-Hant` → `zh-CN` | `zh` (unchanged) | **The key side.** The table step maps each registered key through the table too (`i18n-resolver.ts` L573, unchanged), so a bundle key or offered code spelled `zh-Hant-TW` / `zh-Hans-CN` now counts as the script it names for every request the table reads as that script. FROM measured on `main` `a2437b56` (whose `i18n-resolver.ts` and `platform-locales.ts` are unchanged since the merge base `47c457b2`), TO at this head; `resolveLocaleAmong`, `resolveBundleLocale` and core's `resolveLocale` give the same answer in every row: | Request | Keys, in registration order | FROM (`main`) | TO | |:--|:--|:--|:--| | `zh-TW` | `en, zh-CN, zh-Hant-TW` | `zh-CN` | `zh-Hant-TW` | | `zh-TW` | `en, zh-Hant-TW, zh-CN` | `zh-Hant-TW` | `zh-Hant-TW` (unchanged) | | `zh-Hant` | `en, zh-CN, zh-Hant-TW` | `zh-CN` | `zh-Hant-TW` | | `zh-Hant` | `en, zh-Hant-TW, zh-CN` | `zh-Hant-TW` | `zh-Hant-TW` (unchanged) | | `zh-CN` | `en, zh-Hant, zh-Hans-CN` | `zh-Hant` | `zh-Hans-CN` | | `zh-CN` | `en, zh-Hans-CN, zh-Hant` | `zh-Hans-CN` | `zh-Hans-CN` (unchanged) | | `zh-Hant-TW` | `en, zh, zh-Hant-HK` | `zh` | `zh-Hant-HK` | | `zh-Hant-TW` | `en, zh-Hant-HK, zh` | `zh` | `zh-Hant-HK` | | `zh-Hant-TW` | `en, zh-CN` (one script registered) | `zh-CN` | `zh-CN` (unchanged) | No request that matched before stops matching. With no key of the requested script, a script-subtag request still reaches the other script's key, as before (last row). **Docblocks** in `platform-locales.ts`: the read-alias block no longer says "`zh-Hans-CN` is not listed"; it says the five rows exist, that the list is closed, and that a script-subtag spelling without a row (`zh-Hant-US`) answers nothing because rows are read one by one, never as a pattern. The `platformLocaleTagOf` paragraph names `zh-Hans-CN` / `zh-Hant-TW` beside `zh-CN` / `zh-TW`. **Docs page** `content/docs/protocol/kernel/i18n-standard.mdx`, the "Read aliases" section only (L166-207 at this head; nothing outside it moves): - The alias table lists the five rows. Its header is "Code", not "Stored code", and the intro names the script-subtag spellings browsers and operating systems send beside the codes stored values carry. - "Any other code answers `undefined`" stays and is true again: the section now says the list is closed and read row by row, never as a pattern, so `zh-Hant-US` answers `undefined` too. - The paragraph that said the ladder cannot tell the two Chinese scripts apart and answers `zh` / `zh-CN` by key order (false since PR #30601 added the table step) is rewritten against the code. It names the ladder's one function (`resolveLocaleAmong`, which `resolveBundleLocale` and core's `resolveLocale` run) and its five steps, says the table step takes the key spelled as the table tag or else the first key the table maps to it, and says a two-script bundle answers `zh`, `zh-CN`, `zh-Hans-CN` Simplified and `zh-TW`, `zh-Hant-TW` Traditional in any registration order. - A new paragraph names the two kinds of request that registration order still decides: a code with no row (`zh-Hant-US` over `en, zh-CN, zh-Hant` gets `zh-CN`, and `zh-Hant` when that key is registered first) and a request whose table language has no key (`zh-TW` over `en, zh-CN` gets `zh-CN`). - Every example the section states is checked against the spec source at this head by a scratch probe: 41 checks, all hold. **Pins** (no new file; each extends the existing harness): - `packages/spec/src/system/platform-locales.test.ts`: the five rows in the alias table (`toEqual` over the whole map, now fifteen), a typed index on `zh-Hant-TW`, a case-folded spelling of each row (`ZH-HANS-cn`, `zh-hans-sg`, `ZH-HANT-tw`, `zh-hant-hk`, `Zh-Hant-Mo`). The closed-list case drops `zh-Hans-CN` and `zh-Hant-TW`, keeps `pt-PT`, `de-AT`, `en-GB`, `es-419`, `fr-CA`, `it`, `he`, and adds `zh-Hant-US` and `zh-Hans-US`: a script-subtag code without a row still answers `undefined`. - `packages/spec/src/system/i18n-resolver.test.ts`: the two-registration-order loop (`Simplified first` / `Traditional first`) gains `zh-Hant-TW`, `zh-Hant-HK`, `zh-Hant-MO`, `ZH-HANT-tw` → `zh-Hant` and `zh-Hans-CN`, `zh-Hans-SG`, `zh-hans-cn` → `zh-CN`. The "runs before the base-language step" case gains `{ zh, zh-Hant }` for `zh-Hant-TW` → `zh-Hant` and `zh-Hans-CN` → `zh`, and `zh-Hant-TW` over `en, zh, zh-Hant-HK` → `zh-Hant-HK` in both orders. A new case, "reads a key spelled with a script subtag as the script it names, in either registration order", pins the key-side rows above and the one-script control. - `packages/core/src/fallbacks/fallbacks.test.ts`: PR #30601's shared case list (run on both `resolveLocale` and `resolveBundleLocale`) gains twelve request-side rows (each of the five codes in both registration orders, `ZH-HANT-tw`, and `zh-Hant-TW` over `en, zh, zh-Hant`) and the nine key-side rows of the table above (four requests in both registration orders, plus the one-script control). A new case drives the in-memory i18n service in both load orders for `zh-Hant-TW`, `zh-Hant-HK`, `zh-Hans-CN`. **Changeset** `.changeset/30618-script-subtag-read-aliases.md`: `@objectstack/spec` minor. It states the rows, the request-side and key-side moves (each moved key-side row with its before), the Setup effect, and what stays. `@objectstack/core` is not moved (its diff is a test file, outside its `files[]`). ## Verification Head `d8713f3f32b323798a6416046bf4704ed6a9d82b`. Against the last fully built head `e731b654` it adds two commits: `6a1d4627` (the docs section) and `d8713f3f` (the key-side pins and the changeset sentence). That delta is four files: the changeset, the docs page and two test files. `platform-locales.ts` (blob `baefd871`) and `platform-locales.test.ts` (blob `410cf43e`) are byte-identical to `e731b654`, so every non-test source file the branch touches is unchanged since then. Against `main` the branch is six files, +149 / −29 (merge base `47c457b2`). No full build this round: spec was rebuilt by the ablation, then the dependency closures of `@objectstack/core` and of `@objectstack/lint` / `@objectstack/formula` were built (all exit 0). - Tests, all exit 0 at this head: `@objectstack/spec` 817 files / 25799 tests + 1 todo (`platform-locales.test.ts` + `i18n-resolver.test.ts`: 2 / 576); `@objectstack/core` 136 / 3130 (`fallbacks.test.ts` 151). Carried from `e731b654`, code files byte-identical: `@objectstack/service-settings` 69 / 1178, `@objectstack/service-storage` 73 / 1353. - Typecheck exit 0 for spec and core at this head; both compile their tests through `check:test-typecheck` under `tsconfig.test.json`. - eslint `--no-inline-config` on `platform-locales.ts`, `platform-locales.test.ts`, `i18n-resolver.test.ts` and `fallbacks.test.ts`: exit 0. - `dispatch-gates --commands` at `d8713f3f`: 102 derived (79 at `e731b654`; the 23 added came with the docs page, and `6a1d4627` already derived these 102). 99 exit 0 on the first run. Three first answered `PREREQUISITE NOT MET` (exit 3): `check:skill-examples` and `check:lean-entry-closure` were re-run after building the client SDK and objectql closures, exit 0; `check:dual-build-cjs-loads` needs every package built, so its reading is carried from `e731b654` (exit 0), code files byte-identical. `--ran` with the codes measured here: 102 derived, 101 run, 1 NOT-MEASURED (`check:dual-build-cjs-loads`), 0 UNRUN; with the carried reading: 102 derived, 102 run, 0 NOT-MEASURED, 0 UNRUN. - The derivation at `d8713f3f` warned `STALE TREE` (at least 7 commits behind `origin/main`, and 8 files the derivation reads changed); after a fetch, `origin/main` `badf24df` is 12 commits ahead. Re-derived from `origin/main` `badf24df` with this branch merged in (a scratch merge in a separate worktree, clean, never committed): the same 102 commands. - `pnpm --filter @objectstack/spec check:generated`: exit 0, all 14 artifacts up to date. - `check:dev-prereqs` (local-only family; it needs every package built): carried from `e731b654` (exit 0), code files byte-identical. At this head it answers exit 1, naming the unbuilt `dist/` folders; its `--self-test`, which is derived, is exit 0 here. - CI: read from this PR's check-runs. At this head, when read: 65 check-runs, 57 success, 8 skipped, 0 failed. <details><summary>Derived gates at <code>d8713f3f</code> (102, each exit code captured before any pipe)</summary> | Command | Exit | |:--|:--| | `node scripts/check-adr-0087-registration.mjs --base origin/main` | 0 | | `node scripts/check-adr-0087-registration.mjs --self-test` | 0 | | `node scripts/check-adr-links.mjs` | 0 | | `node scripts/check-adr-links.mjs --self-test` | 0 | | `node scripts/check-changeset-no-major.mjs --base origin/main` | 0 | | `node scripts/check-closing-keyword-parity.mjs` | 0 | | `node scripts/check-closing-keyword-parity.mjs --self-test` | 0 | | `node scripts/check-comment-mask-adoption.mjs` | 0 | | `node scripts/check-comment-mask-adoption.mjs --self-test` | 0 | | `node scripts/check-comment-mask-corpus.mjs` | 0 | | `node scripts/check-dev-prereqs.mjs --self-test` | 0 | | `node scripts/check-doc-route-spelling.mjs --advisory` | 0 | | `node scripts/check-doc-route-spelling.mjs --self-test` | 0 | | `node scripts/check-docs-section-name.mjs` | 0 | | `node scripts/check-docs-section-name.mjs --self-test` | 0 | | `node scripts/check-dts-emitted.mjs --self-test` | 0 | | `node scripts/check-empty-changeset.mjs --base origin/main` | 0 | | `node scripts/check-empty-changeset.mjs --self-test` | 0 | | `node scripts/check-issue-citations.mjs` | 0 | | `node scripts/check-plugin-teardown-shape.mjs` | 0 | | `node scripts/check-plugin-teardown-shape.mjs --self-test` | 0 | | `node scripts/check-section-landing-index.mjs` | 0 | | `node scripts/check-section-landing-index.mjs --self-test` | 0 | | `node scripts/ci/assert-turbo-remote-cache.mjs --self-test` | 0 | | `node scripts/ci/plan-test-core-shards.mjs --self-test` | 0 | | `node scripts/ci/prune-turbo-cache.mjs --self-test` | 0 | | `node scripts/docs-audit/check-affected-docs.mjs` | 0 | | `node scripts/docs-audit/check-drift-comment.mjs` | 0 | | `node scripts/pm/release-rehearsal-clone.mjs --self-test` | 0 | | `node scripts/release-pending-publish.mjs --self-test` | 0 | | `pnpm --filter @objectstack/lint run check:doc-formula-expressions` | 0 | | `pnpm --filter @objectstack/lint run check:doc-security-posture` | 0 | | `pnpm --filter @objectstack/spec run check:api-surface` | 0 | | `pnpm --filter @objectstack/spec run check:authorable-surface` | 0 | | `pnpm --filter @objectstack/spec run check:browser-reachable-entries` | 0 | | `pnpm --filter @objectstack/spec run check:docs` | 0 | | `pnpm --filter @objectstack/spec run check:dual-source-exports` | 0 | | `pnpm --filter @objectstack/spec run check:duration-unit-keys` | 0 | | `pnpm --filter @objectstack/spec run check:empty-state` | 0 | | `pnpm --filter @objectstack/spec run check:entry-nameability` | 0 | | `pnpm --filter @objectstack/spec run check:export-origins` | 0 | | `pnpm --filter @objectstack/spec run check:exported-any` | 0 | | `pnpm --filter @objectstack/spec run check:liveness` | 0 | | `pnpm --filter @objectstack/spec run check:llms-txt` | 0 | | `pnpm --filter @objectstack/spec run check:skill-examples` | 0 (re-run after building the client SDK closure; first answered 3, PREREQUISITE NOT MET) | | `pnpm --filter @objectstack/spec run check:skill-refs` | 0 | | `pnpm --filter @objectstack/spec run check:strictness-ledger` | 0 | | `pnpm --filter @objectstack/spec run check:variant-docs` | 0 | | `pnpm --filter @objectstack/spec run check:yaml-examples` | 0 | | `pnpm check:changeset-gate-self-tests` | 0 | | `pnpm check:ci-filter-parity` | 0 | | `pnpm check:closed-ceremony-text` | 0 | | `pnpm check:corpus-claim-drift` | 0 | | `pnpm check:cross-package-test-inputs` | 0 | | `pnpm check:dispatcher-error-vocabulary` | 0 | | `pnpm check:doc-anchors` | 0 | | `pnpm check:doc-authoring` | 0 | | `pnpm check:doc-frontmatter` | 0 | | `pnpm check:docs-audit-scope` | 0 | | `pnpm check:docs-redirects` | 0 | | `pnpm check:docs-single-h1` | 0 | | `pnpm check:docs-spec-enumerations` | 0 | | `pnpm check:docs-transcript-drift` | 0 | | `pnpm check:driver-memory-census` | 0 | | `pnpm check:dts-closure` | 0 | | `pnpm check:dual-build-cjs-loads` | 3 here (PREREQUISITE NOT MET: unbuilt packages, no full build this round); 0 carried from e731b65, code files byte-identical | | `pnpm check:engine-double-contract` | 0 | | `pnpm check:gitlink-declared` | 0 | | `pnpm check:issue-citations` | 0 | | `pnpm check:kernel-hook-pairs` | 0 | | `pnpm check:keyed-text-bounds` | 0 | | `pnpm check:lean-entry-closure` | 0 (re-run after building objectql's closure; first answered 3, PREREQUISITE NOT MET) | | `pnpm check:logger-receiver-detach` | 0 | | `pnpm check:merge-driver` | 0 | | `pnpm check:nul-bytes` | 0 | | `pnpm check:objectql-double-limit` | 0 | | `pnpm check:org-identifier` | 0 | | `pnpm check:page-declaration-shape` | 0 | | `pnpm check:platform-object-tenancy-census` | 0 | | `pnpm check:pm-prior-rulings` | 0 | | `pnpm check:published-files` | 0 | | `pnpm check:published-readme-links` | 0 | | `pnpm check:query-options-erasure` | 0 | | `pnpm check:react-page-adapter-contract` | 0 | | `pnpm check:refd-timer-probe` | 0 | | `pnpm check:registry-log-declared` | 0 | | `pnpm check:rest-log-spy-declared` | 0 | | `pnpm check:role-word` | 0 | | `pnpm check:skill-identifier-liveness` | 0 | | `pnpm check:slot-lookup` | 0 | | `pnpm check:sourcemap-no-sources-content` | 0 | | `pnpm check:spec-docblock-symbol-anchors` | 0 | | `pnpm check:spec-parsed-alias` | 0 | | `pnpm check:system-context-census` | 0 | | `pnpm check:test-source-alias` | 0 | | `pnpm check:tier-file-adoption` | 0 | | `pnpm check:type-check-coverage` | 0 | | `pnpm check:type-check-debt` | 0 | | `pnpm check:undeclared-dep-imports` | 0 | | `pnpm check:vendor-version-stamps` | 0 | | `pnpm check:watch-hint-literal` | 0 | | `pnpm check:where-matcher` | 0 | </details> <details><summary>Ablation: drop the five rows (run at <code>d8713f3f</code>)</summary> - Mutation through `scripts/ablation-replace.mjs --delete` on the five rows and their comment: anchor 1 → 0, blob `baefd871` → `be1e0adf`; on disk the rows count 0. Spec rebuilt (`VERDICT command-exit 0`); `ablation-dist-preflight --absent` for `"zh-Hant-TW": "zh-Hant"` and `"zh-Hans-CN": "zh-Hans"`: exit 0 for both. - spec `platform-locales.test.ts` + `i18n-resolver.test.ts`: **21 failed** / 555 passed (576). Red: the five rows, their five case-folded spellings, the whole-map and typed-key cases, the two-order loop entries (`zh-Hant-TW` / `zh-Hant-HK` / `zh-Hant-MO` / `ZH-HANT-tw` under "Simplified first", `zh-Hans-CN` / `zh-Hans-SG` / `zh-hans-cn` under "Traditional first"), the `{ zh, zh-Hant }` case, and the new key-side case (first failing assertion: `zh-TW` over `en, zh-CN, zh-Hant-TW` received `zh-CN`). The two `zh-Hant-HK` assertions added to the `{ zh, zh-Hant }` case come after an assertion that is already red there, so vitest stops before them; core's list below fails those rows one by one. - core `fallbacks.test.ts`: **26 failed** / 125 passed (151). Red: on each of `resolveLocale` and `resolveBundleLocale`, the seven request-side rows (each code in the order where the other script is registered first, `ZH-HANT-tw`, and `zh-Hant-TW` over `en, zh, zh-Hant`) and five key-side rows (`zh-TW` and `zh-Hant` over `en, zh-CN, zh-Hant-TW`, `zh-CN` over `en, zh-Hant, zh-Hans-CN`, `zh-Hant-TW` over `en, zh, zh-Hant-HK` in both orders); plus the in-memory service case in both load orders. Green, as the FROM column says: the key-side rows in the order where the script-subtag key is registered first, and the one-script control. - A probe of the nine key-side rows through spec's `resolveLocaleAmong` and `resolveBundleLocale` (source) and core's `resolveLocale` (core source over spec's built `dist/`) answers exactly the FROM column on the mutated tree, and exactly the TO column after the restore. - Restore: `git checkout HEAD -- packages/spec/src/system/platform-locales.ts`; blob after restore `baefd8712cec92d2a22aac2a8f3fd909a62635a5` equals the HEAD blob, `git diff HEAD` empty, `git status --porcelain` empty. Rebuilt (exit 0); both markers present (preflight exit 0); spec 576 passed, core 151 passed. - The consumer suites were not read in this run: with only spec's `dist/` on disk, service-settings and service-storage failed to resolve workspace package entries (`@objectstack/metadata-core`) in both legs alike, a setup failure and not a reading. Their reading is carried from the round-one ablation at `eadbe6e8` (same `platform-locales.ts` blob `baefd871`): service-settings 1178 and service-storage 1353 green with and without the rows, so no consumer test pins these answers. </details> <details><summary>Consumer probe (each direct caller, 14 codes, with and without the rows; carried from <code>e731b654</code>, code files byte-identical)</summary> - `platformLocaleTagOf`: the five codes and `ZH-HANT-tw` move `undefined` → table tag; `zh-TW`, `zh-CN`, `zh-Hant`, `zh-Hans` unchanged; `zh-Hant-US`, `zh-Hans-US`, `pt-PT`, `fr-CA` stay `undefined`. - service-settings `localeSelfName`: the five codes move from the runtime display name to the table label (see Acceptance notes); the other nine unchanged. - service-settings option source, four scenarios: `admits` identical for all 14 codes; options move only where a stored or offered code is one of the five. - service-settings `notOfferedCopy`: unchanged for all 14. - service-storage `storageTestCopyLocaleOf`: unchanged for all 14. </details> ## Acceptance notes - **The docs page is fixed in this PR (this round).** The seat widened the file surface to the "Read aliases" section of `content/docs/protocol/kernel/i18n-standard.mdx` (#30618 comment 6671045459), and commit `6a1d4627` edits that section only: the five rows, the closed-list sentence made true, and the ladder paragraphs rewritten against the code (see What changes). So the `Skills-docs:` line is `none`, and the follow-up the earlier body owed is closed. - **Review S1 adopted (this round).** The contract review (comment 6671200273, PASS at `e731b654`) asked that the key-side move be pinned and stated. Commit `d8713f3f` pins it in core's shared list and in spec's table-step cases, both registration orders, with the one-script control, and adds the changeset sentence. FROM was measured on `main` first; it agrees with the review's three rows (`zh-TW` over `en, zh-CN, zh-Hant-TW`: `zh-CN` → `zh-Hant-TW`; `zh-Hant-TW` over `en, zh, zh-Hant-HK`: `zh` → `zh-Hant-HK`; control `zh-Hant-TW` over `en, zh-CN`: `zh-CN`, unchanged). - **The closed list, measured, not re-decided.** A script-subtag code with any other region (`zh-Hant-US`, `zh-Hans-US`) has no row, so it keeps the old path: over service-storage's shipped bundle it reaches `zh-CN`, the first `zh-*` key, for either script. That is the decision of record on this card (enumerated rows only). - **Consumers measured, none edited.** Every direct caller of `platformLocaleTagOf` / `PLATFORM_LOCALE_READ_ALIASES` (`git grep`, three non-test call sites; the map itself is read only inside `platform-locales.ts`): - `packages/spec/src/system/i18n-resolver.ts` L569/L573 (`resolveLocaleAmong` table step): moves, as the tables above, on the request side (L569) and on the key side (L573). - `packages/services/service-settings/src/localization-locale-options.ts` L67 (`localeSelfName`): **moves.** The five codes are named from the table (`zh-Hant-TW` 中文(繁體,台灣) → 繁體中文, `zh-Hant-HK` 中文(繁體字,中國香港特別行政區) → 繁體中文, `zh-Hans-CN` 中文(简体,中国) → 简体中文, likewise `zh-Hans-SG`, `zh-Hant-MO`). Through `resolveOfferedLocale`, a stored `zh-Hant-TW` over offered `en, zh-CN, zh-Hant` used to take the place of the 简体中文 option (`zh-Hant-TW=简体中文`) and now sits on 繁體中文 (`zh-CN=简体中文`, `zh-Hant-TW=繁體中文`); a stored `zh-Hans-CN` over `en, zh-Hant, zh-CN` moved the same way. The accepted set (`admits`) is unchanged for all 14 probed codes in 4 scenarios, and `notOfferedCopy` is unchanged. - `packages/services/service-storage/src/storage-test-copy.ts` L152 (`storageTestCopyLocaleOf`): **does not move.** Its subtag truncation already reached the same tag (`zh-Hant-TW` → `zh-Hant`), for all 14 probed codes. - Ladder consumer reached after the `main` merge: service-storage's object translations (`zh-CN` registered before `zh-Hant`, from #30649) now serve a `zh-Hant-TW` / `zh-Hant-HK` / `zh-Hant-MO` reader the `zh-Hant` objects; the row-less control `zh-Hant-US` still reaches `zh-CN`. - Consumer suites green with and without the rows (service-settings 69 files / 1178 tests, service-storage 73 / 1353): no consumer test pins these answers. - **Declarations, measured.** `Clause-②: yes (widening)`: the published map and `PlatformLocaleReadAlias` gain five keys, nothing removed or narrowed (`check:api-surface` green: the snapshot records names and kinds); the key-side move is a widening too. `Metadata-protocol: semantic`: no key or value is added to what an author writes, but a bundle key or translation item an author keys `zh-Hant` (or `zh-CN` / `zh-Hans`) now serves `zh-Hant-*` (or `zh-Hans-*`) readers it did not serve over a two-script bundle, and a key an author spells `zh-Hant-TW` is now read as `zh-Hant`. Level `minor` on `@objectstack/spec`, the one package the diff moves. - **The first round-one ablation run was a no-op on its `dist/` half**, and is not a reading reported here. Its mutation landed, but the spec build in the mutated leg never got the shared lock (`VERDICT queue-timeout (exit 99)`), so `dist/` still carried the rows. Every later leg aborts unless the build verdict is `command-exit 0` and the dist preflight passes; both held in this round's run. - **Docblocks left as they are, still true:** `resolveLocaleAmong`'s step 3 (`i18n-resolver.ts`) and core's `resolveLocale` / `resolveOfferedLocale` docblocks give `zh-TW` / `zh-HK` examples, not an exhaustive list. The unreleased `.changeset/30001-platform-locale-table.md` says the map is "typed on its ten literal keys", which is true of that change; this changeset states the five added. - No labels sent: the dispatch named none, and `skip-changeset` does not apply (the diff publishes `@objectstack/spec`). --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgVugtCCwBs98FeR27P)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgVugtCCwBs98FeR27P <!-- signed: domain:spec#2 · os-sales · session_01L6bhgVugtCCwBs98FeR27P --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ab44ec9 commit 4564d6f

6 files changed

Lines changed: 149 additions & 29 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
fix(spec/system): the platform locale table reads the script-subtag Chinese spellings, so a `zh-Hant-TW` / `zh-Hant-HK` reader gets a registered `zh-Hant` bundle instead of Simplified Chinese
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: semantic
9+
10+
`PLATFORM_LOCALE_READ_ALIASES` (`@objectstack/spec/system`) gains five rows, one for the script-subtag spelling of each regional Chinese alias: `zh-Hans-CN` and `zh-Hans-SG` read as `zh-Hans`; `zh-Hant-TW`, `zh-Hant-HK` and `zh-Hant-MO` read as `zh-Hant`. `platformLocaleTagOf` answers them (case-insensitively, as for every row), and `PlatformLocaleReadAlias` gains the five literal keys.
11+
12+
What changes for a reader: browsers and operating systems send these spellings. `resolveBundleLocale` and `resolveLocaleAmong` (and so `resolveLocale` and `resolveOfferedLocale` in `@objectstack/core`) now map them through the table before the base-language step, so over a bundle that holds both scripts a `zh-Hant-TW`, `zh-Hant-HK` or `zh-Hant-MO` request reaches the `zh-Hant` key and a `zh-Hans-CN` or `zh-Hans-SG` request reaches the Simplified key (`zh-CN`, `zh-Hans` or a bare `zh`), whatever order the keys were registered in. Before, the first registered `zh-*` key answered them, and over `{ zh, zh-Hant }` a `zh-Hant-TW` request got the bare `zh` (Simplified) key. The table step maps each registered key through the table too, so a bundle key or offered code spelled `zh-Hant-TW` / `zh-Hans-CN` now counts as its script for every request the table reads as that script: `zh-TW` or `zh-Hant` over `[en, zh-CN, zh-Hant-TW]` gets `zh-Hant-TW` (before: `zh-CN`), `zh-CN` over `[en, zh-Hant, zh-Hans-CN]` gets `zh-Hans-CN` (before: `zh-Hant`), and `zh-Hant-TW` over `[en, zh, zh-Hant-HK]` gets `zh-Hant-HK` (before: `zh`). Setup's default-language options (`localization.locale`) name the five codes from the table (`zh-Hant-TW` is 繁體中文, `zh-Hans-CN` is 简体中文) where they used the runtime's display name (中文(繁體,台灣)), and a stored `zh-Hant-TW` default now shows selected on the 繁體中文 option: where the deployment listed `zh-CN` before `zh-Hant`, it used to take the place of the 简体中文 option. The values Setup accepts are unchanged.
13+
14+
Unchanged: the list stays closed. Only these five spellings are added; a script-subtag spelling without a row (`zh-Hant-US`) and every other code the table does not name (`pt-PT`, `en-GB`, `es-419`) answer `undefined` and fall back through base language and variant expansion exactly as before. With no registered key of the requested script, a script-subtag request still reaches the other script's key, as before. No call that matched before stops matching.

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

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -163,33 +163,48 @@ The rule:
163163
- **Adding or removing a language** is a change to the table, followed by one
164164
backfill of every surface. Nothing else declares the list.
165165

166-
**Read aliases.** Codes that stored values already carry keep resolving:
166+
**Read aliases.** Codes that stored values already carry, and the
167+
script-subtag spellings browsers and operating systems send, keep resolving:
167168
`PLATFORM_LOCALE_READ_ALIASES` maps each to a table tag, and
168-
`platformLocaleTagOf(code)` answers the table tag a stored code means.
169+
`platformLocaleTagOf(code)` answers the table tag a code means.
169170

170-
| Stored code | Reads as |
171+
| Code | Reads as |
171172
|:--|:--|
172-
| `zh`, `zh-CN`, `zh-SG` | `zh-Hans` |
173-
| `zh-TW`, `zh-HK`, `zh-MO` | `zh-Hant` |
173+
| `zh`, `zh-CN`, `zh-SG`, `zh-Hans-CN`, `zh-Hans-SG` | `zh-Hans` |
174+
| `zh-TW`, `zh-HK`, `zh-MO`, `zh-Hant-TW`, `zh-Hant-HK`, `zh-Hant-MO` | `zh-Hant` |
174175
| `ja-JP` | `ja` |
175176
| `es-ES` | `es` |
176177
| `pt` | `pt-BR` |
177178
| `en-US` | `en` |
178179

179180
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
181+
`zh-Hans`), and any other code answers `undefined`. The list is closed and is
182+
read row by row, never as a pattern, so no region or script fallback is
183+
guessed: `pt-PT` is not `pt-BR`, and a script-subtag spelling without a row
184+
(`zh-Hant-US`) answers `undefined` too. New writes use the table's tags. The
182185
examples on this page that spell `zh-CN` stay valid, because `zh-CN` is a read
183186
alias of `zh-Hans`.
184187

185188
`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`.
189+
fallback ladder answers which key of a bundle *serves* a request. The ladder is
190+
one function, `resolveLocaleAmong`: `resolveBundleLocale` runs it over a
191+
bundle's keys, and core's `resolveLocale` is the same function. Its steps are
192+
exact match, case-insensitive match, then the **same table language**, then
193+
base language, then the first registered variant. The table-language step maps
194+
the request through `platformLocaleTagOf` and takes the key spelled as that
195+
table tag, else the first key the table maps to it, so the two answers never
196+
contradict each other: `pt` means `pt-BR`, and a request for `pt-BR` is served
197+
by a bundle's `pt` key. A bundle holding both scripts answers `zh`, `zh-CN` and
198+
`zh-Hans-CN` with its Simplified key and `zh-TW` and `zh-Hant-TW` with its
199+
Traditional one, whatever order the keys were registered in.
200+
201+
Two kinds of request still go to the base-language and first-variant steps,
202+
where registration order decides. A code the table does not name skips the
203+
table step: over keys registered as `en`, `zh-CN`, `zh-Hant` a `zh-Hant-US`
204+
request gets `zh-CN`, and with `zh-Hant` registered before `zh-CN` it gets
205+
`zh-Hant`. A request whose table language has no key falls through the same
206+
way: `zh-TW` over `en`, `zh-CN` gets `zh-CN`, because a related language beats
207+
none.
193208

194209
### Locale Fallback
195210

‎packages/core/src/fallbacks/fallbacks.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,30 @@ describe('resolveLocale and resolveBundleLocale — one shared case list', () =>
315315
['zh-SG', ['en', 'zh-Hant', 'zh-CN'], 'zh-CN'],
316316
['zh-Hans', ['en', 'zh-Hant', 'zh-CN'], 'zh-CN'],
317317
['zh-TW', ['en', 'zh', 'zh-Hant'], 'zh-Hant'],
318+
// The script-subtag spellings a browser or OS sends are read aliases too.
319+
['zh-Hant-TW', ['en', 'zh-CN', 'zh-Hant'], 'zh-Hant'],
320+
['zh-Hant-TW', ['en', 'zh-Hant', 'zh-CN'], 'zh-Hant'],
321+
['zh-Hant-HK', ['en', 'zh-CN', 'zh-Hant'], 'zh-Hant'],
322+
['zh-Hant-HK', ['en', 'zh-Hant', 'zh-CN'], 'zh-Hant'],
323+
['zh-Hant-MO', ['en', 'zh-CN', 'zh-Hant'], 'zh-Hant'],
324+
['zh-Hant-MO', ['en', 'zh-Hant', 'zh-CN'], 'zh-Hant'],
325+
['zh-Hans-CN', ['en', 'zh-CN', 'zh-Hant'], 'zh-CN'],
326+
['zh-Hans-CN', ['en', 'zh-Hant', 'zh-CN'], 'zh-CN'],
327+
['zh-Hans-SG', ['en', 'zh-CN', 'zh-Hant'], 'zh-CN'],
328+
['zh-Hans-SG', ['en', 'zh-Hant', 'zh-CN'], 'zh-CN'],
329+
['ZH-HANT-tw', ['en', 'zh-CN', 'zh-Hant'], 'zh-Hant'],
330+
['zh-Hant-TW', ['en', 'zh', 'zh-Hant'], 'zh-Hant'],
331+
// ...and so are keys spelled that way: a key counts as the script it names.
332+
['zh-TW', ['en', 'zh-CN', 'zh-Hant-TW'], 'zh-Hant-TW'],
333+
['zh-TW', ['en', 'zh-Hant-TW', 'zh-CN'], 'zh-Hant-TW'],
334+
['zh-Hant', ['en', 'zh-CN', 'zh-Hant-TW'], 'zh-Hant-TW'],
335+
['zh-Hant', ['en', 'zh-Hant-TW', 'zh-CN'], 'zh-Hant-TW'],
336+
['zh-CN', ['en', 'zh-Hant', 'zh-Hans-CN'], 'zh-Hans-CN'],
337+
['zh-CN', ['en', 'zh-Hans-CN', 'zh-Hant'], 'zh-Hans-CN'],
338+
['zh-Hant-TW', ['en', 'zh', 'zh-Hant-HK'], 'zh-Hant-HK'],
339+
['zh-Hant-TW', ['en', 'zh-Hant-HK', 'zh'], 'zh-Hant-HK'],
340+
// One script registered: a related language still beats none.
341+
['zh-Hant-TW', ['en', 'zh-CN'], 'zh-CN'],
318342
// Today's alias-spelled bundles keep answering.
319343
['ja', ['en', 'zh-CN', 'ja-JP', 'es-ES'], 'ja-JP'],
320344
['es', ['en', 'zh-CN', 'ja-JP', 'es-ES'], 'es-ES'],
@@ -344,6 +368,19 @@ describe('resolveLocale and resolveBundleLocale — one shared case list', () =>
344368
expect(i18n.t('settings', 'zh-HK')).toBe('設定');
345369
expect(i18n.t('settings', 'zh')).toBe('设置');
346370
});
371+
372+
it.each<[order: string, loadOrder: string[]]>([
373+
['Simplified loaded first', ['zh-CN', 'zh-Hant']],
374+
['Traditional loaded first', ['zh-Hant', 'zh-CN']],
375+
])('a script-subtag reader of the in-memory service gets its own script (%s)', (_order, loadOrder) => {
376+
const text: Record<string, string> = { 'zh-CN': '设置', 'zh-Hant': '設定' };
377+
const i18n = createMemoryI18n();
378+
i18n.loadTranslations('en', { settings: 'Settings' });
379+
for (const locale of loadOrder) i18n.loadTranslations(locale, { settings: text[locale] });
380+
expect(i18n.t('settings', 'zh-Hant-TW')).toBe('設定');
381+
expect(i18n.t('settings', 'zh-Hant-HK')).toBe('設定');
382+
expect(i18n.t('settings', 'zh-Hans-CN')).toBe('设置');
383+
});
347384
});
348385

349386
describe('createMemoryI18n locale fallback', () => {

‎packages/spec/src/system/i18n-resolver.test.ts‎

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1048,10 +1048,11 @@ describe('locale fallback resolution (BCP-47)', () => {
10481048

10491049
// The platform locale table's read aliases decide which key serves a request
10501050
// before any base-language or first-variant guess, so a Traditional reader
1051-
// (`zh-TW`, `zh-HK`, `zh-MO`) reaches a `zh-Hant` key and a Simplified one
1052-
// reaches `zh-CN` whatever order the bundle registered them in. Core's
1053-
// `resolveLocale` is `resolveLocaleAmong`; its own suite runs one case list
1054-
// against both resolvers.
1051+
// (`zh-TW`, `zh-HK`, `zh-MO`, or the script-subtag `zh-Hant-TW` a browser
1052+
// sends) reaches a `zh-Hant` key and a Simplified one reaches `zh-CN` whatever
1053+
// order the bundle registered them in. Core's `resolveLocale` is
1054+
// `resolveLocaleAmong`; its own suite runs one case list against both
1055+
// resolvers.
10551056
describe('resolveBundleLocale — the platform table language step', () => {
10561057
const orders: Array<[string, Record<string, unknown>]> = [
10571058
['Simplified first', { en: {}, 'zh-CN': {}, 'zh-Hant': {} }],
@@ -1060,11 +1061,17 @@ describe('resolveBundleLocale — the platform table language step', () => {
10601061

10611062
for (const [name, bundle] of orders) {
10621063
describe(name, () => {
1063-
it.each(['zh-TW', 'zh-HK', 'zh-MO', 'zh-tw', 'zh-Hant'])('%s reaches zh-Hant', (requested) => {
1064+
it.each([
1065+
'zh-TW', 'zh-HK', 'zh-MO', 'zh-tw', 'zh-Hant',
1066+
'zh-Hant-TW', 'zh-Hant-HK', 'zh-Hant-MO', 'ZH-HANT-tw',
1067+
])('%s reaches zh-Hant', (requested) => {
10641068
expect(resolveBundleLocale(bundle, requested)).toBe('zh-Hant');
10651069
});
10661070

1067-
it.each(['zh-CN', 'zh', 'zh-SG', 'zh-Hans', 'zh-cn'])('%s reaches zh-CN', (requested) => {
1071+
it.each([
1072+
'zh-CN', 'zh', 'zh-SG', 'zh-Hans', 'zh-cn',
1073+
'zh-Hans-CN', 'zh-Hans-SG', 'zh-hans-cn',
1074+
])('%s reaches zh-CN', (requested) => {
10681075
expect(resolveBundleLocale(bundle, requested)).toBe('zh-CN');
10691076
});
10701077
});
@@ -1079,6 +1086,24 @@ describe('resolveBundleLocale — the platform table language step', () => {
10791086
it('runs before the base-language step: a bare `zh` key is Simplified', () => {
10801087
expect(resolveBundleLocale({ zh: {}, 'zh-Hant': {} }, 'zh-TW')).toBe('zh-Hant');
10811088
expect(resolveBundleLocale({ 'zh-Hant': {}, zh: {} }, 'zh-CN')).toBe('zh');
1089+
expect(resolveBundleLocale({ zh: {}, 'zh-Hant': {} }, 'zh-Hant-TW')).toBe('zh-Hant');
1090+
expect(resolveBundleLocale({ 'zh-Hant': {}, zh: {} }, 'zh-Hans-CN')).toBe('zh');
1091+
expect(resolveBundleLocale({ en: {}, zh: {}, 'zh-Hant-HK': {} }, 'zh-Hant-TW')).toBe('zh-Hant-HK');
1092+
expect(resolveBundleLocale({ en: {}, 'zh-Hant-HK': {}, zh: {} }, 'zh-Hant-TW')).toBe('zh-Hant-HK');
1093+
});
1094+
1095+
it('reads a key spelled with a script subtag as the script it names, in either registration order', () => {
1096+
for (const keys of [['en', 'zh-CN', 'zh-Hant-TW'], ['en', 'zh-Hant-TW', 'zh-CN']]) {
1097+
const bundle = Object.fromEntries(keys.map((code) => [code, {}]));
1098+
expect(resolveBundleLocale(bundle, 'zh-TW')).toBe('zh-Hant-TW');
1099+
expect(resolveBundleLocale(bundle, 'zh-Hant')).toBe('zh-Hant-TW');
1100+
}
1101+
for (const keys of [['en', 'zh-Hant', 'zh-Hans-CN'], ['en', 'zh-Hans-CN', 'zh-Hant']]) {
1102+
const bundle = Object.fromEntries(keys.map((code) => [code, {}]));
1103+
expect(resolveBundleLocale(bundle, 'zh-CN')).toBe('zh-Hans-CN');
1104+
}
1105+
// One script registered: a related language still beats none.
1106+
expect(resolveBundleLocale({ en: {}, 'zh-CN': {} }, 'zh-Hant-TW')).toBe('zh-CN');
10821107
});
10831108

10841109
it('keeps the alias-spelled bundles of today: ja → ja-JP, es → es-ES, en → en-US', () => {

‎packages/spec/src/system/platform-locales.test.ts‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ describe('PLATFORM_LOCALE_READ_ALIASES: stored codes keep resolving', () => {
6161
['zh-TW', 'zh-Hant'],
6262
['zh-HK', 'zh-Hant'],
6363
['zh-MO', 'zh-Hant'],
64+
// The script-subtag spellings of the five regional aliases (#30618).
65+
['zh-Hans-CN', 'zh-Hans'],
66+
['zh-Hans-SG', 'zh-Hans'],
67+
['zh-Hant-TW', 'zh-Hant'],
68+
['zh-Hant-HK', 'zh-Hant'],
69+
['zh-Hant-MO', 'zh-Hant'],
6470
['ja-JP', 'ja'],
6571
['es-ES', 'es'],
6672
['pt', 'pt-BR'],
@@ -72,13 +78,15 @@ describe('PLATFORM_LOCALE_READ_ALIASES: stored codes keep resolving', () => {
7278
expect(platformLocaleTagOf(code)).toBe(tag);
7379
});
7480

75-
it('the map is exactly those ten aliases', () => {
81+
it('the map is exactly those fifteen aliases', () => {
7682
expect(PLATFORM_LOCALE_READ_ALIASES).toEqual(Object.fromEntries(ALIASES));
7783
});
7884

79-
it('the map is typed on its ten literal keys: an unlisted code does not index it as defined', () => {
85+
it('the map is typed on its fifteen literal keys: an unlisted code does not index it as defined', () => {
8086
const zhCN: 'zh-Hans' = PLATFORM_LOCALE_READ_ALIASES['zh-CN'];
8187
expect(zhCN).toBe('zh-Hans');
88+
const zhHantTW: 'zh-Hant' = PLATFORM_LOCALE_READ_ALIASES['zh-Hant-TW'];
89+
expect(zhHantTW).toBe('zh-Hant');
8290
// @ts-expect-error `pt-PT` is not a read alias, so the index is not typed as a table tag.
8391
expect(PLATFORM_LOCALE_READ_ALIASES['pt-PT']).toBeUndefined();
8492
});
@@ -113,12 +121,19 @@ describe('platformLocaleTagOf', () => {
113121
['EN', 'en'],
114122
['en-us', 'en'],
115123
['ja-jp', 'ja'],
124+
['ZH-HANS-cn', 'zh-Hans'],
125+
['zh-hans-sg', 'zh-Hans'],
126+
['ZH-HANT-tw', 'zh-Hant'],
127+
['zh-hant-hk', 'zh-Hant'],
128+
['Zh-Hant-Mo', 'zh-Hant'],
116129
])('ignores case: %s reads as %s', (code, tag) => {
117130
expect(platformLocaleTagOf(code)).toBe(tag);
118131
});
119132

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'])(
133+
// The list is closed: no region or script fallback is guessed. A
134+
// script-subtag spelling without its own row (`zh-Hant-US`, `zh-Hans-US`)
135+
// answers undefined too: the rows are read one by one, never as a pattern.
136+
it.each(['pt-PT', 'de-AT', 'en-GB', 'es-419', 'fr-CA', 'it', 'he', 'zh-Hant-US', 'zh-Hans-US'])(
122137
'a code the table and the aliases do not list, %s, answers undefined',
123138
(code) => {
124139
expect(platformLocaleTagOf(code)).toBeUndefined();

‎packages/spec/src/system/platform-locales.ts‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,18 @@ export const PLATFORM_LOCALE_TAGS: readonly PlatformLocaleTag[] = Object.freeze(
5959
);
6060

6161
/**
62-
* Read aliases: a code that stored values already carry, mapped to the table
63-
* tag it means. Reads only: a new write uses the table tag itself.
62+
* Read aliases: a code that stored values already carry, or that a browser or
63+
* operating system sends, mapped to the table tag it means. Reads only: a new
64+
* write uses the table tag itself.
65+
*
66+
* Each regional Chinese alias also has a row for its script-subtag spelling,
67+
* which names the table tag outright: `zh-Hans-CN` and `zh-Hans-SG` read as
68+
* `zh-Hans`, `zh-Hant-TW`, `zh-Hant-HK` and `zh-Hant-MO` as `zh-Hant`.
6469
*
6570
* The list is closed. A code it does not name is not guessed at, so there is
66-
* no region fallback here (`pt-PT` is not `pt-BR`, `zh-Hans-CN` is not listed).
71+
* no region or script fallback here: `pt-PT` is not `pt-BR`, and a
72+
* script-subtag spelling without a row (`zh-Hant-US`) answers nothing, because
73+
* the rows are read one by one, never as a pattern.
6774
* Frozen.
6875
*/
6976
export const PLATFORM_LOCALE_READ_ALIASES = Object.freeze({
@@ -73,6 +80,12 @@ export const PLATFORM_LOCALE_READ_ALIASES = Object.freeze({
7380
'zh-TW': 'zh-Hant',
7481
'zh-HK': 'zh-Hant',
7582
'zh-MO': 'zh-Hant',
83+
// The script-subtag spellings of the five regional aliases above (#30618).
84+
'zh-Hans-CN': 'zh-Hans',
85+
'zh-Hans-SG': 'zh-Hans',
86+
'zh-Hant-TW': 'zh-Hant',
87+
'zh-Hant-HK': 'zh-Hant',
88+
'zh-Hant-MO': 'zh-Hant',
7689
'ja-JP': 'ja',
7790
'es-ES': 'es',
7891
pt: 'pt-BR',
@@ -109,8 +122,9 @@ const TAG_BY_FOLDED_CODE: ReadonlyMap<string, PlatformLocaleTag> = new Map<strin
109122
* language, first variant). Its table-language step asks this function of
110123
* both the requested code and each key, so the two never contradict: `pt`
111124
* means `pt-BR`, and a request for `pt-BR` may still be served by a bundle's
112-
* `pt` key; a bundle holding both scripts answers `zh` and `zh-CN` with its
113-
* Simplified key and `zh-TW` with its Traditional one, in any key order.
125+
* `pt` key; a bundle holding both scripts answers `zh`, `zh-CN` and
126+
* `zh-Hans-CN` with its Simplified key and `zh-TW` and `zh-Hant-TW` with its
127+
* Traditional one, in any key order.
114128
*/
115129
export function platformLocaleTagOf(code: unknown): PlatformLocaleTag | undefined {
116130
if (typeof code !== 'string') return undefined;

0 commit comments

Comments
 (0)