Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/30618-script-subtag-read-aliases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/spec': minor
---

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

Clause-②: yes (widening)
Metadata-protocol: semantic

`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.

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.

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.
43 changes: 29 additions & 14 deletions content/docs/protocol/kernel/i18n-standard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -163,33 +163,48 @@ The rule:
- **Adding or removing a language** is a change to the table, followed by one
backfill of every surface. Nothing else declares the list.

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

| Stored code | Reads as |
| Code | Reads as |
|:--|:--|
| `zh`, `zh-CN`, `zh-SG` | `zh-Hans` |
| `zh-TW`, `zh-HK`, `zh-MO` | `zh-Hant` |
| `zh`, `zh-CN`, `zh-SG`, `zh-Hans-CN`, `zh-Hans-SG` | `zh-Hans` |
| `zh-TW`, `zh-HK`, `zh-MO`, `zh-Hant-TW`, `zh-Hant-HK`, `zh-Hant-MO` | `zh-Hant` |
| `ja-JP` | `ja` |
| `es-ES` | `es` |
| `pt` | `pt-BR` |
| `en-US` | `en` |

A table tag answers itself, the match ignores case (`zh-cn` reads as
`zh-Hans`), and any other code answers `undefined`: no region fallback is
guessed, so `pt-PT` is not `pt-BR`. New writes use the table's tags. The
`zh-Hans`), and any other code answers `undefined`. The list is closed and is
read row by row, never as a pattern, so no region or script fallback is
guessed: `pt-PT` is not `pt-BR`, and a script-subtag spelling without a row
(`zh-Hant-US`) answers `undefined` too. New writes use the table's tags. The
examples on this page that spell `zh-CN` stay valid, because `zh-CN` is a read
alias of `zh-Hans`.

`platformLocaleTagOf` answers which table language a code *means*; the
fallback ladder below answers which key of a bundle *serves* a request. A
reader that uses both maps the code first and then resolves the table tag
against the bundle, so they do not contradict each other: `pt` means `pt-BR`,
and a request for `pt-BR` may still be served by a bundle's `pt` key. The
ladder alone cannot tell the two Chinese scripts apart: once a bundle holds
both `zh-Hans` and `zh-Hant`, a request for `zh` or `zh-CN` is answered by key
order (`zh-Hant` when that key comes first), where the table says `zh-Hans`.
fallback ladder answers which key of a bundle *serves* a request. The ladder is
one function, `resolveLocaleAmong`: `resolveBundleLocale` runs it over a
bundle's keys, and core's `resolveLocale` is the same function. Its steps are
exact match, case-insensitive match, then the **same table language**, then
base language, then the first registered variant. The table-language step maps
the request through `platformLocaleTagOf` and takes the key spelled as that
table tag, else the first key the table maps to it, so the two answers never
contradict each other: `pt` means `pt-BR`, and a request for `pt-BR` is served
by a bundle's `pt` key. A bundle holding both scripts answers `zh`, `zh-CN` and
`zh-Hans-CN` with its Simplified key and `zh-TW` and `zh-Hant-TW` with its
Traditional one, whatever order the keys were registered in.

Two kinds of request still go to the base-language and first-variant steps,
where registration order decides. A code the table does not name skips the
table step: over keys registered as `en`, `zh-CN`, `zh-Hant` a `zh-Hant-US`
request gets `zh-CN`, and with `zh-Hant` registered before `zh-CN` it gets
`zh-Hant`. A request whose table language has no key falls through the same
way: `zh-TW` over `en`, `zh-CN` gets `zh-CN`, because a related language beats
none.

### Locale Fallback

Expand Down
37 changes: 37 additions & 0 deletions packages/core/src/fallbacks/fallbacks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,30 @@ describe('resolveLocale and resolveBundleLocale — one shared case list', () =>
['zh-SG', ['en', 'zh-Hant', 'zh-CN'], 'zh-CN'],
['zh-Hans', ['en', 'zh-Hant', 'zh-CN'], 'zh-CN'],
['zh-TW', ['en', 'zh', 'zh-Hant'], 'zh-Hant'],
// The script-subtag spellings a browser or OS sends are read aliases too.
['zh-Hant-TW', ['en', 'zh-CN', 'zh-Hant'], 'zh-Hant'],
['zh-Hant-TW', ['en', 'zh-Hant', 'zh-CN'], 'zh-Hant'],
['zh-Hant-HK', ['en', 'zh-CN', 'zh-Hant'], 'zh-Hant'],
['zh-Hant-HK', ['en', 'zh-Hant', 'zh-CN'], 'zh-Hant'],
['zh-Hant-MO', ['en', 'zh-CN', 'zh-Hant'], 'zh-Hant'],
['zh-Hant-MO', ['en', 'zh-Hant', 'zh-CN'], 'zh-Hant'],
['zh-Hans-CN', ['en', 'zh-CN', 'zh-Hant'], 'zh-CN'],
['zh-Hans-CN', ['en', 'zh-Hant', 'zh-CN'], 'zh-CN'],
['zh-Hans-SG', ['en', 'zh-CN', 'zh-Hant'], 'zh-CN'],
['zh-Hans-SG', ['en', 'zh-Hant', 'zh-CN'], 'zh-CN'],
['ZH-HANT-tw', ['en', 'zh-CN', 'zh-Hant'], 'zh-Hant'],
['zh-Hant-TW', ['en', 'zh', 'zh-Hant'], 'zh-Hant'],
// ...and so are keys spelled that way: a key counts as the script it names.
['zh-TW', ['en', 'zh-CN', 'zh-Hant-TW'], 'zh-Hant-TW'],
['zh-TW', ['en', 'zh-Hant-TW', 'zh-CN'], 'zh-Hant-TW'],
['zh-Hant', ['en', 'zh-CN', 'zh-Hant-TW'], 'zh-Hant-TW'],
['zh-Hant', ['en', 'zh-Hant-TW', 'zh-CN'], 'zh-Hant-TW'],
['zh-CN', ['en', 'zh-Hant', 'zh-Hans-CN'], 'zh-Hans-CN'],
['zh-CN', ['en', 'zh-Hans-CN', 'zh-Hant'], 'zh-Hans-CN'],
['zh-Hant-TW', ['en', 'zh', 'zh-Hant-HK'], 'zh-Hant-HK'],
['zh-Hant-TW', ['en', 'zh-Hant-HK', 'zh'], 'zh-Hant-HK'],
// One script registered: a related language still beats none.
['zh-Hant-TW', ['en', 'zh-CN'], 'zh-CN'],
// Today's alias-spelled bundles keep answering.
['ja', ['en', 'zh-CN', 'ja-JP', 'es-ES'], 'ja-JP'],
['es', ['en', 'zh-CN', 'ja-JP', 'es-ES'], 'es-ES'],
Expand Down Expand Up @@ -344,6 +368,19 @@ describe('resolveLocale and resolveBundleLocale — one shared case list', () =>
expect(i18n.t('settings', 'zh-HK')).toBe('設定');
expect(i18n.t('settings', 'zh')).toBe('设置');
});

it.each<[order: string, loadOrder: string[]]>([
['Simplified loaded first', ['zh-CN', 'zh-Hant']],
['Traditional loaded first', ['zh-Hant', 'zh-CN']],
])('a script-subtag reader of the in-memory service gets its own script (%s)', (_order, loadOrder) => {
const text: Record<string, string> = { 'zh-CN': '设置', 'zh-Hant': '設定' };
const i18n = createMemoryI18n();
i18n.loadTranslations('en', { settings: 'Settings' });
for (const locale of loadOrder) i18n.loadTranslations(locale, { settings: text[locale] });
expect(i18n.t('settings', 'zh-Hant-TW')).toBe('設定');
expect(i18n.t('settings', 'zh-Hant-HK')).toBe('設定');
expect(i18n.t('settings', 'zh-Hans-CN')).toBe('设置');
});
});

describe('createMemoryI18n locale fallback', () => {
Expand Down
37 changes: 31 additions & 6 deletions packages/spec/src/system/i18n-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1048,10 +1048,11 @@ describe('locale fallback resolution (BCP-47)', () => {

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

for (const [name, bundle] of orders) {
describe(name, () => {
it.each(['zh-TW', 'zh-HK', 'zh-MO', 'zh-tw', 'zh-Hant'])('%s reaches zh-Hant', (requested) => {
it.each([
'zh-TW', 'zh-HK', 'zh-MO', 'zh-tw', 'zh-Hant',
'zh-Hant-TW', 'zh-Hant-HK', 'zh-Hant-MO', 'ZH-HANT-tw',
])('%s reaches zh-Hant', (requested) => {
expect(resolveBundleLocale(bundle, requested)).toBe('zh-Hant');
});

it.each(['zh-CN', 'zh', 'zh-SG', 'zh-Hans', 'zh-cn'])('%s reaches zh-CN', (requested) => {
it.each([
'zh-CN', 'zh', 'zh-SG', 'zh-Hans', 'zh-cn',
'zh-Hans-CN', 'zh-Hans-SG', 'zh-hans-cn',
])('%s reaches zh-CN', (requested) => {
expect(resolveBundleLocale(bundle, requested)).toBe('zh-CN');
});
});
Expand All @@ -1079,6 +1086,24 @@ describe('resolveBundleLocale — the platform table language step', () => {
it('runs before the base-language step: a bare `zh` key is Simplified', () => {
expect(resolveBundleLocale({ zh: {}, 'zh-Hant': {} }, 'zh-TW')).toBe('zh-Hant');
expect(resolveBundleLocale({ 'zh-Hant': {}, zh: {} }, 'zh-CN')).toBe('zh');
expect(resolveBundleLocale({ zh: {}, 'zh-Hant': {} }, 'zh-Hant-TW')).toBe('zh-Hant');
expect(resolveBundleLocale({ 'zh-Hant': {}, zh: {} }, 'zh-Hans-CN')).toBe('zh');
expect(resolveBundleLocale({ en: {}, zh: {}, 'zh-Hant-HK': {} }, 'zh-Hant-TW')).toBe('zh-Hant-HK');
expect(resolveBundleLocale({ en: {}, 'zh-Hant-HK': {}, zh: {} }, 'zh-Hant-TW')).toBe('zh-Hant-HK');
});

it('reads a key spelled with a script subtag as the script it names, in either registration order', () => {
for (const keys of [['en', 'zh-CN', 'zh-Hant-TW'], ['en', 'zh-Hant-TW', 'zh-CN']]) {
const bundle = Object.fromEntries(keys.map((code) => [code, {}]));
expect(resolveBundleLocale(bundle, 'zh-TW')).toBe('zh-Hant-TW');
expect(resolveBundleLocale(bundle, 'zh-Hant')).toBe('zh-Hant-TW');
}
for (const keys of [['en', 'zh-Hant', 'zh-Hans-CN'], ['en', 'zh-Hans-CN', 'zh-Hant']]) {
const bundle = Object.fromEntries(keys.map((code) => [code, {}]));
expect(resolveBundleLocale(bundle, 'zh-CN')).toBe('zh-Hans-CN');
}
// One script registered: a related language still beats none.
expect(resolveBundleLocale({ en: {}, 'zh-CN': {} }, 'zh-Hant-TW')).toBe('zh-CN');
});

it('keeps the alias-spelled bundles of today: ja → ja-JP, es → es-ES, en → en-US', () => {
Expand Down
23 changes: 19 additions & 4 deletions packages/spec/src/system/platform-locales.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ describe('PLATFORM_LOCALE_READ_ALIASES: stored codes keep resolving', () => {
['zh-TW', 'zh-Hant'],
['zh-HK', 'zh-Hant'],
['zh-MO', 'zh-Hant'],
// The script-subtag spellings of the five regional aliases (#30618).
['zh-Hans-CN', 'zh-Hans'],
['zh-Hans-SG', 'zh-Hans'],
['zh-Hant-TW', 'zh-Hant'],
['zh-Hant-HK', 'zh-Hant'],
['zh-Hant-MO', 'zh-Hant'],
['ja-JP', 'ja'],
['es-ES', 'es'],
['pt', 'pt-BR'],
Expand All @@ -72,13 +78,15 @@ describe('PLATFORM_LOCALE_READ_ALIASES: stored codes keep resolving', () => {
expect(platformLocaleTagOf(code)).toBe(tag);
});

it('the map is exactly those ten aliases', () => {
it('the map is exactly those fifteen aliases', () => {
expect(PLATFORM_LOCALE_READ_ALIASES).toEqual(Object.fromEntries(ALIASES));
});

it('the map is typed on its ten literal keys: an unlisted code does not index it as defined', () => {
it('the map is typed on its fifteen literal keys: an unlisted code does not index it as defined', () => {
const zhCN: 'zh-Hans' = PLATFORM_LOCALE_READ_ALIASES['zh-CN'];
expect(zhCN).toBe('zh-Hans');
const zhHantTW: 'zh-Hant' = PLATFORM_LOCALE_READ_ALIASES['zh-Hant-TW'];
expect(zhHantTW).toBe('zh-Hant');
// @ts-expect-error `pt-PT` is not a read alias, so the index is not typed as a table tag.
expect(PLATFORM_LOCALE_READ_ALIASES['pt-PT']).toBeUndefined();
});
Expand Down Expand Up @@ -113,12 +121,19 @@ describe('platformLocaleTagOf', () => {
['EN', 'en'],
['en-us', 'en'],
['ja-jp', 'ja'],
['ZH-HANS-cn', 'zh-Hans'],
['zh-hans-sg', 'zh-Hans'],
['ZH-HANT-tw', 'zh-Hant'],
['zh-hant-hk', 'zh-Hant'],
['Zh-Hant-Mo', 'zh-Hant'],
])('ignores case: %s reads as %s', (code, tag) => {
expect(platformLocaleTagOf(code)).toBe(tag);
});

// The list is closed: no region or script fallback is guessed.
it.each(['pt-PT', 'de-AT', 'en-GB', 'zh-Hans-CN', 'es-419', 'fr-CA', 'it', 'he', 'zh-Hant-TW'])(
// The list is closed: no region or script fallback is guessed. A
// script-subtag spelling without its own row (`zh-Hant-US`, `zh-Hans-US`)
// answers undefined too: the rows are read one by one, never as a pattern.
it.each(['pt-PT', 'de-AT', 'en-GB', 'es-419', 'fr-CA', 'it', 'he', 'zh-Hant-US', 'zh-Hans-US'])(
'a code the table and the aliases do not list, %s, answers undefined',
(code) => {
expect(platformLocaleTagOf(code)).toBeUndefined();
Expand Down
24 changes: 19 additions & 5 deletions packages/spec/src/system/platform-locales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,18 @@ export const PLATFORM_LOCALE_TAGS: readonly PlatformLocaleTag[] = Object.freeze(
);

/**
* Read aliases: a code that stored values already carry, mapped to the table
* tag it means. Reads only: a new write uses the table tag itself.
* Read aliases: a code that stored values already carry, or that a browser or
* operating system sends, mapped to the table tag it means. Reads only: a new
* write uses the table tag itself.
*
* Each regional Chinese alias also has a row for its script-subtag spelling,
* which names the table tag outright: `zh-Hans-CN` and `zh-Hans-SG` read as
* `zh-Hans`, `zh-Hant-TW`, `zh-Hant-HK` and `zh-Hant-MO` as `zh-Hant`.
*
* The list is closed. A code it does not name is not guessed at, so there is
* no region fallback here (`pt-PT` is not `pt-BR`, `zh-Hans-CN` is not listed).
* no region or script fallback here: `pt-PT` is not `pt-BR`, and a
* script-subtag spelling without a row (`zh-Hant-US`) answers nothing, because
* the rows are read one by one, never as a pattern.
* Frozen.
*/
export const PLATFORM_LOCALE_READ_ALIASES = Object.freeze({
Expand All @@ -73,6 +80,12 @@ export const PLATFORM_LOCALE_READ_ALIASES = Object.freeze({
'zh-TW': 'zh-Hant',
'zh-HK': 'zh-Hant',
'zh-MO': 'zh-Hant',
// The script-subtag spellings of the five regional aliases above (#30618).
'zh-Hans-CN': 'zh-Hans',
'zh-Hans-SG': 'zh-Hans',
'zh-Hant-TW': 'zh-Hant',
'zh-Hant-HK': 'zh-Hant',
'zh-Hant-MO': 'zh-Hant',
'ja-JP': 'ja',
'es-ES': 'es',
pt: 'pt-BR',
Expand Down Expand Up @@ -109,8 +122,9 @@ const TAG_BY_FOLDED_CODE: ReadonlyMap<string, PlatformLocaleTag> = new Map<strin
* language, first variant). Its table-language step asks this function of
* both the requested code and each key, so the two never contradict: `pt`
* means `pt-BR`, and a request for `pt-BR` may still be served by a bundle's
* `pt` key; a bundle holding both scripts answers `zh` and `zh-CN` with its
* Simplified key and `zh-TW` with its Traditional one, in any key order.
* `pt` key; a bundle holding both scripts answers `zh`, `zh-CN` and
* `zh-Hans-CN` with its Simplified key and `zh-TW` and `zh-Hant-TW` with its
* Traditional one, in any key order.
*/
export function platformLocaleTagOf(code: unknown): PlatformLocaleTag | undefined {
if (typeof code !== 'string') return undefined;
Expand Down
Loading