Skip to content

Commit af8aab1

Browse files
fix(spec,core): resolve locales through the platform table's read aliases, so a zh-TW / zh-HK reader gets zh-Hant (#30601)
domain:spec#3 · os-sales · session_01L6bhgTqqeHSwZYJmApbDCa <!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4Uu5BQt6r6PAGQpDn8cZNzAPa)_ Fixes #30538 Clause-②: yes (widening) Metadata-protocol: semantic Skills-docs: follow-up #30603 — skills/objectstack-i18n/SKILL.md (its in-memory fallback paragraph restates the four-step ladder without the table step; `skills/**` is a Tier H surface, so it is not edited here) Branch: `claude/project-thread-mros1u` (the card thread's platform-assigned branch, restated in the claim 6663692830; not the `claude/issue-30538-…` convention). ## 维护者速读 - 改了什么:服务端两个语言解析器(spec 的 `resolveBundleLocale`、core 的 `resolveLocale`)改为同一份实现,先按平台语言表的读别名找同一语言,再退到基础语言和第一个变体。 - 为什么改:繁体读者(zh-TW / zh-HK / zh-MO)原来拿到第一个注册的 zh-* 包,也就是简体;各 translations 包正在补 zh-Hant,补上后繁体读者仍看简体。 - 风险与回滚:只改「哪一个已注册的包来答」,原来能匹配的请求仍能匹配;表里没有的代码(es-MX、en-GB)照旧。回滚即还原这一个 PR。 - 先看:`packages/spec/src/system/i18n-resolver.ts` 的 `resolveLocaleAmong` 第 3 步;`packages/core/src/fallbacks/memory-i18n.ts`;`packages/core/src/fallbacks/fallbacks.test.ts` 的共享用例表。 - 两分钟验证:`pnpm --filter @objectstack/core exec vitest run src/fallbacks/fallbacks.test.ts`,预期 107 例全绿,其中共享用例表对两个解析器各跑一遍,另有一例断言 `resolveOfferedLocale` 与 `resolveLocale` 答案相同。 - 需要你做的:无,席位落地。 ## What changes - `@objectstack/spec/system` gains `resolveLocaleAmong(requested, available)`, the platform's one locale-matching ladder over a list: exact, case-insensitive, **the same platform locale table language**, base language, first registered variant. - The new step maps the requested code through `platformLocaleTagOf` (the table's read aliases, the one alias list; no second list is added) and picks the key spelled as that table tag, else the first key the table maps to it. So `zh-TW`, `zh-HK`, `zh-MO` reach a registered `zh-Hant`, and `zh`, `zh-CN`, `zh-SG`, `zh-Hans` reach `zh-CN`, in either registration order. - `resolveBundleLocale` keeps its exact fast path and then runs `resolveLocaleAmong` over the bundle's keys. Core's `resolveLocale` **is** `resolveLocaleAmong` (core already imports `@objectstack/spec/system`, so there is no new import edge and no cycle). The two answers cannot drift. - Docblocks that promised the old order are corrected: `resolveBundleLocale`, core's `resolveLocale`, `platformLocaleTagOf` (its paragraph stated that `resolveBundleLocale({ 'zh-Hant': …, 'zh-Hans': … }, 'zh')` answers `zh-Hant`, which this PR makes false), the email ladder of record (`SendTemplateInput.locale` in `packages/spec/src/contracts/email-service.ts`), its copy in `content/docs/automation/email-templates.mdx`, the `stack.zod.ts` email floor docblock, and plugin-email's ladder comment in `email-service.ts`. - `api-surface/system.json` and `export-origins/system.json` regenerated for the new export. - Changeset: `@objectstack/spec` minor, `@objectstack/core` minor. ## Tests - `packages/spec/src/system/i18n-resolver.test.ts`, new top-level describe `resolveBundleLocale — the platform table language step`: zh-TW / zh-HK / zh-MO / zh-tw / zh-Hant → `zh-Hant` and zh-CN / zh / zh-SG / zh-Hans / zh-cn → `zh-CN` in both registration orders; table-tag spelling preferred; a bare `zh` key counts as Simplified; `ja` → `ja-JP`, `es` → `es-ES`, `en` → `en-US` against alias-spelled bundles; unknown codes (`es-MX`, `en-GB`, `pt-BR` over `pt-PT`, `fr`, empty bundle) fall back as before. - `packages/plugins/plugin-email/src/template-locale-fold.test.ts`, **a moved pin**: it asserted that a `zh-MO` recipient of a `zh-TW` + `zh-HK` + `zh-CN` bundle got the Simplified `zh-CN` row, which is this card's defect. It now asserts the Traditional `zh-HK` row in both list orders (plus `zh-TW` → `zh-Hant` and `zh` → `zh-CN` over `en-US` + `zh-CN` + `zh-Hant`), and the variant-expansion pin moves to a code the table does not know (`es-CO` → `es-AR`, both list orders). - `packages/core/src/fallbacks/fallbacks.test.ts`, new top-level describe `resolveLocale and resolveBundleLocale — one shared case list`: one 20-row table run against both resolvers, plus the in-memory i18n service serving a `zh-HK` reader the `zh-Hant` bundle and a `zh` reader the `zh-CN` one. The existing `resolveLocale` pins (including `zh-TW` over `['en', 'zh']` → `zh`) are unchanged and green. ## Acceptance notes - **Declarations differ from the claim's expectation, measured.** `Clause-②: yes (widening)`: the shared implementation needs a list-shaped entry point, so `@objectstack/spec/system` publishes one new function (no export removed or narrowed). `Metadata-protocol: semantic`: a translation bundle key or email template row an author writes as `zh-Hant` now serves `zh-TW` / `zh-HK` / `zh-MO` readers it did not, a bundle registering `zh-Hant` before `zh-CN` now serves `zh` readers the Simplified key, and the published `resolveBundleLocale` docblock promised the old four-step order. `check-changeset-no-major` reading in Verification. - **PM assumption disproved: "before step 4" is not enough.** At `2ffdb649`, `resolveBundleLocale({ zh: {}, 'zh-Hant': {} }, 'zh-TW')` answered `'zh'`: the base-language step hands a Traditional reader a bare `zh` key, which the table reads as Simplified. The table step therefore runs before base language (step 3 of 5). It only adds earlier answers, so no request that matched before stops matching. - **File surface beyond the claim's list.** Docblocks only: `platform-locales.ts` (the `platformLocaleTagOf` paragraph; the table and aliases are untouched), `contracts/email-service.ts`, `stack.zod.ts`, `content/docs/automation/email-templates.mdx`, plugin-email's `email-service.ts` comment. Each restated the old order in a way this change makes false for a bundle that holds both Chinese scripts. One test: plugin-email's `template-locale-fold.test.ts`, whose pin asserted the defect (see Tests). - **Restatements left as they are** (each still true for the tables they describe today): `docs/qa/platform-checklist/areas/integration-system.json` acceptance[3] copies the email ladder (a revisioned QA file; the showcase bundle carries `zh-CN` only, so its verify line is unchanged); `packages/plugins/plugin-auth/src/idp-locale-claim.ts`'s `resolveLocale` summary (plugin-auth's copy tables are keyed `en` / `zh-CN` / `ja-JP` / `es-ES`, whose answers do not move). Carrier: the spec 席 3 seat. - The Console's `pickRenderableLocale` (`ui/packages/app-shell/src/hooks/useUserLocale.ts`) still runs the old four steps; it is #30511's surface and not touched here. - **Merged with `main` (#30589).** The merge in `51ace3fa` met #30589's new `resolveOfferedLocale` in `packages/core/src/fallbacks/memory-i18n.ts`, a second matching ladder. The resolution makes it `return resolveLocaleAmong(...)`, so the repo keeps one ladder. #30589's pin in `fallbacks.test.ts` that `resolveLocale` answers `zh-Hans` for `zh-TW` asserted this card's defect; it now expects `zh-Hant`, and a new case asserts that `resolveOfferedLocale` and `resolveLocale` give the same answer. The contract review's delta (6667101706) measured the one answer that moves for #30589's callers, which are Setup's default-language options (`service-settings/src/localization-locale-options.ts` :125) and the IdP locale claim (`plugin-auth/src/idp-locale-claim.ts` :148). It is a tie-break inside the table step: when a list offers two codes of one table language, the code spelled as the table tag wins over the first one offered (`zh` over `['zh-CN', 'zh-Hans']` answers `zh-Hans`, where `main` answered `zh-CN`). Admission never changes, no shipped bundle registers such a pair, and the `@objectstack/core` changeset carries the sentence. Review findings S1 (three spots in `email-template.zod.ts`, its reference page regenerated) and S2 (the `builtinSystemFieldLabel` and `validation-message.ts` docblocks) landed in `16eafb26`. - Observation, not filed: `resolveBundleLocale`'s exact fast path reads `bundle[requested]`, so over a plain-object bundle a request spelled like an `Object.prototype` member (`constructor`) answers that name. Unchanged by this PR and not measured at a public door; `resolveLocaleAmong` uses `includes` and does not do this. ## Verification Round 2, at head `51ace3fa` (S1 and S2, plus `origin/main` merged in), after a full build (119/119): - CI: 57 `success`, 8 `skipped`, 0 failed. - Tests, all exit 0: `@objectstack/spec` 816 files / 25719 tests + 1 todo; `@objectstack/core` 135 / 3075 (`fallbacks.test.ts` 107); `@objectstack/service-settings` 69 / 1178; `@objectstack/plugin-email` 48 / 801; plugin-auth's locale tests 6 / 202. - Typecheck exit 0 for spec, core and service-settings; spec `check:generated` exit 0. - `dispatch-gates --commands`: 109 derived, all exit 0; `--ran`: 109 run, 0 NOT-MEASURED, 0 UNRUN. Round 1 readings below are at head `9f692b76` unless named. <details><summary>Repro before the change (origin/main 2ffdb64)</summary> ```text resolveBundleLocale({ en, zh-CN, zh-Hant }, 'zh-TW') -> zh-CN resolveLocale('zh-HK', ['en', 'zh-CN', 'zh-Hant']) -> zh-CN resolveBundleLocale({ zh-Hant, zh-CN }, 'zh') -> zh-Hant resolveBundleLocale({ zh, zh-Hant }, 'zh-TW') -> zh ``` </details> <details><summary>Package suites and typecheck</summary> - `pnpm --filter @objectstack/spec test`: Test Files 815 passed (815), Tests 25668 passed, 1 todo. `pnpm --filter @objectstack/spec typecheck`: exit 0. - `pnpm --filter @objectstack/core test`: Test Files 135 passed (135), Tests 3070 passed. `pnpm --filter @objectstack/core typecheck` (tsc, examples, test-typecheck): exit 0. - `pnpm --filter @objectstack/plugin-email test`: Test Files 48 passed (48), Tests 796 passed. `typecheck`: exit 0. - spec `check:generated` named `api-surface/` and `export-origins/` stale; both regenerated and committed. - eslint `--no-inline-config` on the 9 changed source and test files: exit 0. `check-nul-bytes`: exit 0. </details> <details><summary>Consumers reached (targeted locale suites, after a full build 119/119)</summary> - `@objectstack/runtime` (the `/i18n` REST door's `resolveLocale`, hook refusal catalogue, flow run locale): 8 files, 126 passed. - `@objectstack/plugin-auth` (IdP locale claim, MFA and IP-range refusal copy): 6 files, 201 passed. - `@objectstack/service-settings` (currency lock, locale options copy): 2 files, 38 passed. - `@objectstack/objectql` (validation message negotiation): 4 files, 44 passed. - `@objectstack/plugin-approvals`: 2 files, 25 passed. `@objectstack/plugin-audit`: 2 files, 19 passed. - `@objectstack/plugin-email` (template locale fold): 4 files, 98 passed after the moved pin above (1 failed before it: the defect pin). - `@objectstack/service-automation`: 3 files, 43 passed. `@objectstack/service-messaging`: 2 files, 30 passed. `@objectstack/service-i18n`: 2 files, 52 passed. </details> <details><summary>Ablation (remove the table-language step)</summary> - Mutation through `scripts/ablation-replace.mjs --delete` on the step-3 block of `resolveLocaleAmong` (anchor 1 → 0, blob 9707821 → 47872494), spec rebuilt, `ablation-dist-preflight --absent`: marker absent from all 592 built files. - spec `i18n-resolver.test.ts`: 10 failed / 482 passed (every zh-TW / zh-HK / zh-MO / zh / zh-SG / zh-Hans case of the new describe). core `fallbacks.test.ts`: 15 failed / 87 passed (the zh rows of the shared case list for both resolvers, and the in-memory service case). - Restore: blob after restore 9707821 equals HEAD, `git diff HEAD` empty, tree clean; rebuilt, preflight marker present in 2 built files; spec 492 passed, core 102 passed. </details> <details><summary>Derived gates (dispatch-gates --commands at 9f692b7)</summary> - 109 derived families run, every one `exit 0`. `--ran` reconciliation: 109 derived, 109 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero). - `check-changeset-no-major --base origin/main`: no `major` bump; the level axis is PR-scoped and reads this body in CI. - `check-adr-0087-registration --base origin/main`: no declared-breaking changeset. - Families CI runs beyond the derived set: read from this PR's check-runs. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgXFwZkpwurAYNxGpAb)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgXFwZkpwurAYNxGpAb --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 14c67d4 commit af8aab1

16 files changed

Lines changed: 267 additions & 91 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/core': minor
4+
---
5+
6+
fix(spec,core): the server locale resolvers read the platform locale table, so a `zh-TW` / `zh-HK` / `zh-MO` reader gets a registered `zh-Hant` bundle instead of Simplified Chinese
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: semantic
10+
11+
`resolveBundleLocale` (`@objectstack/spec/system`) and `resolveLocale` (`@objectstack/core`) now run one ladder, the new `resolveLocaleAmong(requested, available)` export of `@objectstack/spec/system`: exact, case-insensitive, then **the same platform locale table language**, then base language, then the first registered variant. The new step maps the requested code and each available key through `platformLocaleTagOf` (the table's read aliases) and picks the key spelled as the table tag, else the first key the table maps to that tag.
12+
13+
What changes for a reader: a request for `zh-TW`, `zh-HK` or `zh-MO` reaches a registered `zh-Hant` key, and `zh`, `zh-CN`, `zh-SG` or `zh-Hans` reaches `zh-CN` (or `zh-Hans`), whatever order the bundle registered them in. Before, the first registered `zh-*` key answered both, so a Traditional reader got the Simplified bundle (and with `zh-Hant` registered first, a Simplified reader got the Traditional one). A bare `zh` key now counts as Simplified for a `zh-TW` request, which the base-language step used to hand it. The step also applies to `ja` → `ja-JP`, `es` → `es-ES`, `en` → `en-US` and `pt` → `pt-BR`, which answer as before against today's alias-spelled bundles.
14+
15+
`resolveOfferedLocale` (`@objectstack/core`, the matcher Setup's default-language options and the IdP locale claim use) now answers through the same ladder, so a Traditional request reaches an offered `zh-Hant`. Where a list offers two codes of one table language, the code spelled as the table tag wins over the first one offered: `zh` over `['zh-CN', 'zh-Hans']` answers `zh-Hans`, and `zh-HK` over `['zh-TW', 'zh-Hant']` answers `zh-Hant`.
16+
17+
Unchanged: a code the table does not know (`es-MX`, `en-GB`, `pt-PT`) falls back through base language and variant expansion exactly as before, and a `zh-TW` request still reaches `zh-CN` when no Traditional key is registered. No call that matched before stops matching. Every caller of these functions moves with them, among them the i18n REST door, built-in validation and operation messages, email template locale folding, hook, flow and approval copy, and plugin-auth's refusal copy.

‎content/docs/automation/email-templates.mdx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,8 +257,10 @@ one `EmailServicePlugin` wires), walks:
257257
1. **this locale, folded** against the locale tags the template's bundle carries
258258
by `resolveBundleLocale` (`@objectstack/spec/system`, the BCP-47 fold
259259
plugin-email imports rather than copying): exact, then case-insensitive
260-
(`zh-cn` → `zh-CN`), then base language (`en-GB` → `en`), then variant
261-
expansion (`zh` and `zh-TW` → `zh-CN`), the first match in ascending tag
260+
(`zh-cn` → `zh-CN`), then the same platform locale table language (`zh-TW`
261+
→ a `zh-Hant` row, `zh` → a `zh-CN` row, in any tag order), then base
262+
language (`en-GB` → `en`), then variant expansion (`zh-TW` → `zh-CN` when
263+
the bundle has no Traditional row), the first match in ascending tag
262264
order;
263265
2. **the stack's declared fallback locale**, as
264266
`II18nService.getFallbackLocale()` answers it (`i18n.fallbackLocale`, else

‎content/docs/references/system/email-template.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ const result = EmailTemplateDefinitionSchema.parse(data);
4848
| **name** | `string` | ✅ | Template identifier (dotted snake_case) |
4949
| **label** | `string` | ✅ | Display label |
5050
| **category** | `never` | optional | [REMOVED] `email_template.category` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — nothing read it: the Studio listing never offered the filter facet it promised, and delivery never depended on it. Delete the key. A template is found by its `name`, so group templates with a dotted namespace prefix instead (`auth.password_reset`, `crm.welcome`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
51-
| **locale** | `string` | optional (default: `"en-US"`) | BCP-47 locale (e.g. en-US, zh-CN) — the bundle key. Each rung of the resolver reads one row at one tag exactly, so `en` and `en-US` are different ROWS of one bundle. A call that NAMES a locale, over the built-in template loader, folds it against the bundle's tags (exact, case-insensitive, base language, then variant expansion: `en-GB` reaches `en`, `zh-TW` reaches `zh-CN`), then folds the stack's declared fallback locale (`i18n.fallbackLocale`, else `defaultLocale`) the same way, then tries the literal `en-US`; a replacement template loader matches the tag exactly and retries only `en-US`. `en-US` is the floor every loader honours: when no rung lands on a row the call raises TEMPLATE_NOT_FOUND, which is permanent — the delivery dead-letters with no retry. A call naming NO locale folds nothing and does not dead-letter: it starts at `en-US` and otherwise drops to the bundle's lowest locale tag and renders that silently. Tag the English row `en-US` so every loader has the floor. |
51+
| **locale** | `string` | optional (default: `"en-US"`) | BCP-47 locale (e.g. en-US, zh-CN) — the bundle key. Each rung of the resolver reads one row at one tag exactly, so `en` and `en-US` are different ROWS of one bundle. A call that NAMES a locale, over the built-in template loader, folds it against the bundle's tags (exact, case-insensitive, platform locale table language, base language, then variant expansion: `en-GB` reaches `en`, `zh-TW` reaches `zh-Hant`, else `zh-CN`), then folds the stack's declared fallback locale (`i18n.fallbackLocale`, else `defaultLocale`) the same way, then tries the literal `en-US`; a replacement template loader matches the tag exactly and retries only `en-US`. `en-US` is the floor every loader honours: when no rung lands on a row the call raises TEMPLATE_NOT_FOUND, which is permanent — the delivery dead-letters with no retry. A call naming NO locale folds nothing and does not dead-letter: it starts at `en-US` and otherwise drops to the bundle's lowest locale tag and renders that silently. Tag the English row `en-US` so every loader has the floor. |
5252
| **subject** | `string` | ✅ | Subject template. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused). A formatter is one word, optionally followed by `:` and its argument (text that is not, such as `date-long`, is refused), and it is one the template engine knows (an unknown name, such as a misspelled `curency`, is refused, naming the known formatters). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). |
5353
| **bodyHtml** | `string` | ✅ | HTML body template. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused). A formatter is one word, optionally followed by `:` and its argument (text that is not, such as `date-long`, is refused), and it is one the template engine knows (an unknown name, such as a misspelled `curency`, is refused, naming the known formatters). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). |
5454
| **bodyText** | `string` | optional | Plain-text body template (auto-derived from HTML when omitted). Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused). A formatter is one word, optionally followed by `:` and its argument (text that is not, such as `date-long`, is refused), and it is one the template engine knows (an unknown name, such as a misspelled `curency`, is refused, naming the known formatters). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). |

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

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createMemoryJob } from './memory-job';
55
import { createMemoryI18n, resolveLocale, resolveOfferedLocale } from './memory-i18n';
66
import { CORE_FALLBACK_FACTORIES } from './index';
77
import { readServiceSelfInfo } from '@objectstack/spec/api';
8+
import { resolveBundleLocale } from '@objectstack/spec/system';
89

910
describe('CORE_FALLBACK_FACTORIES', () => {
1011
// [#10746] `job` is deliberately OFF this list — a fallback must not fake
@@ -295,6 +296,56 @@ describe('resolveLocale', () => {
295296
});
296297
});
297298

299+
// One case list, run against core's `resolveLocale` (a list) and the spec's
300+
// `resolveBundleLocale` (a bundle's keys), so the two server resolvers cannot
301+
// drift. The platform locale table's read aliases decide before any
302+
// base-language or first-variant guess: a Traditional reader reaches a
303+
// registered `zh-Hant`, a Simplified one `zh-CN`, in either registration order.
304+
describe('resolveLocale and resolveBundleLocale — one shared case list', () => {
305+
const CASES: Array<[requested: string, available: string[], expected: string | undefined]> = [
306+
['zh-TW', ['en', 'zh-CN', 'zh-Hant'], 'zh-Hant'],
307+
['zh-TW', ['en', 'zh-Hant', 'zh-CN'], 'zh-Hant'],
308+
['zh-HK', ['en', 'zh-CN', 'zh-Hant'], 'zh-Hant'],
309+
['zh-HK', ['en', 'zh-Hant', 'zh-CN'], 'zh-Hant'],
310+
['zh-MO', ['en', 'zh-CN', 'zh-Hant'], 'zh-Hant'],
311+
['zh-MO', ['en', 'zh-Hant', 'zh-CN'], 'zh-Hant'],
312+
['zh-CN', ['en', 'zh-CN', 'zh-Hant'], 'zh-CN'],
313+
['zh-CN', ['en', 'zh-Hant', 'zh-CN'], 'zh-CN'],
314+
['zh', ['en', 'zh-Hant', 'zh-CN'], 'zh-CN'],
315+
['zh-SG', ['en', 'zh-Hant', 'zh-CN'], 'zh-CN'],
316+
['zh-Hans', ['en', 'zh-Hant', 'zh-CN'], 'zh-CN'],
317+
['zh-TW', ['en', 'zh', 'zh-Hant'], 'zh-Hant'],
318+
// Today's alias-spelled bundles keep answering.
319+
['ja', ['en', 'zh-CN', 'ja-JP', 'es-ES'], 'ja-JP'],
320+
['es', ['en', 'zh-CN', 'ja-JP', 'es-ES'], 'es-ES'],
321+
['zh-TW', ['en', 'zh-CN', 'ja-JP', 'es-ES'], 'zh-CN'],
322+
['zh-TW', ['en', 'zh'], 'zh'],
323+
// A code the table does not know falls back as before.
324+
['es-MX', ['en', 'es-ES'], 'es-ES'],
325+
['en-GB', ['en-US', 'en'], 'en'],
326+
['fr', ['en', 'zh-CN'], undefined],
327+
['zh-TW', [], undefined],
328+
];
329+
330+
it.each(CASES)('resolveLocale(%s, %j) → %s', (requested, available, expected) => {
331+
expect(resolveLocale(requested, available)).toBe(expected);
332+
});
333+
334+
it.each(CASES)('resolveBundleLocale(%j over %j) → %s', (requested, available, expected) => {
335+
const bundle = Object.fromEntries(available.map((code) => [code, {}]));
336+
expect(resolveBundleLocale(bundle, requested)).toBe(expected);
337+
});
338+
339+
it('a zh-HK reader of the in-memory service gets the zh-Hant bundle', () => {
340+
const i18n = createMemoryI18n();
341+
i18n.loadTranslations('en', { settings: 'Settings' });
342+
i18n.loadTranslations('zh-CN', { settings: '设置' });
343+
i18n.loadTranslations('zh-Hant', { settings: '設定' });
344+
expect(i18n.t('settings', 'zh-HK')).toBe('設定');
345+
expect(i18n.t('settings', 'zh')).toBe('设置');
346+
});
347+
});
348+
298349
describe('createMemoryI18n locale fallback', () => {
299350
it('should resolve translations via locale fallback (zh → zh-CN)', () => {
300351
const i18n = createMemoryI18n();
@@ -577,14 +628,26 @@ describe('createMemoryI18n declared fallbackLocale (#15694)', () => {
577628
// #30456: the one matcher for "is this locale offered here, and as which code",
578629
// shared by Setup's default language and the IdP locale claim.
579630
describe('resolveOfferedLocale', () => {
631+
it('answers what resolveLocale answers, on the shared case list too', () => {
632+
for (const [requested, available] of [
633+
['zh-TW', ['en', 'zh-CN', 'zh-Hant']],
634+
['zh', ['en', 'zh-Hant', 'zh-CN']],
635+
['zh-HK', ['zh-TW', 'zh-Hant']],
636+
['es-MX', ['en', 'es-ES']],
637+
['fr', ['en', 'zh-CN']],
638+
] as Array<[string, string[]]>) {
639+
expect(resolveOfferedLocale(requested, available)).toBe(resolveLocale(requested, available));
640+
}
641+
});
642+
580643
it('takes resolveLocale\'s exact and case-insensitive steps first', () => {
581644
expect(resolveOfferedLocale('zh-CN', ['en', 'zh-CN'])).toBe('zh-CN');
582645
expect(resolveOfferedLocale('zh-cn', ['en', 'zh-CN'])).toBe('zh-CN');
583646
});
584647

585648
it('then the platform table\'s language, so a script or region lands on its own script', () => {
586-
// resolveLocale alone takes the first `zh-*` listed for either spelling.
587-
expect(resolveLocale('zh-TW', ['zh-Hans', 'zh-Hant'])).toBe('zh-Hans');
649+
// resolveOfferedLocale is resolveLocale: one ladder, so both answer by script.
650+
expect(resolveLocale('zh-TW', ['zh-Hans', 'zh-Hant'])).toBe('zh-Hant');
588651
expect(resolveOfferedLocale('zh-TW', ['zh-Hans', 'zh-Hant'])).toBe('zh-Hant');
589652
expect(resolveOfferedLocale('zh-CN', ['zh-Hant', 'zh-Hans'])).toBe('zh-Hans');
590653
expect(resolveOfferedLocale('en-US', ['zh-CN', 'en'])).toBe('en');

‎packages/core/src/fallbacks/memory-i18n.ts‎

Lines changed: 18 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { normalizeSupportedLocales, platformLocaleTagOf } from '@objectstack/spec/system';
3+
import { normalizeSupportedLocales, resolveLocaleAmong } from '@objectstack/spec/system';
44

55
/**
66
* Recursively merge `source` into `target`. Nested plain objects are merged
@@ -36,35 +36,20 @@ export function deepMerge(
3636
/**
3737
* Resolve a locale code against available locales with fallback.
3838
*
39-
* Fallback chain:
39+
* This is `resolveLocaleAmong` from `@objectstack/spec/system`, the one
40+
* locale-matching ladder the spec's `resolveBundleLocale` also runs, so the
41+
* two resolvers cannot answer differently. Fallback chain:
4042
* 1. Exact match (e.g. `zh-CN` → `zh-CN`)
4143
* 2. Case-insensitive match (e.g. `zh-cn` → `zh-CN`)
42-
* 3. Base language match (e.g. `zh-CN` → `zh`)
43-
* 4. Variant expansion (e.g. `zh` → `zh-CN`)
44+
* 3. The same platform table language, through the table's read aliases
45+
* (e.g. `zh-TW` / `zh-HK` → `zh-Hant`, `zh` → `zh-CN`, `ja` → `ja-JP`)
46+
* 4. Base language match (e.g. `zh-CN` → `zh`)
47+
* 5. Variant expansion (e.g. `es-MX` → `es-ES`)
4448
*
4549
* Returns the matched locale code, or `undefined` when no match is found.
4650
*/
4751
export function resolveLocale(requestedLocale: string, availableLocales: string[]): string | undefined {
48-
if (availableLocales.length === 0) return undefined;
49-
50-
// 1. Exact match
51-
if (availableLocales.includes(requestedLocale)) return requestedLocale;
52-
53-
// 2. Case-insensitive match
54-
const lower = requestedLocale.toLowerCase();
55-
const caseMatch = availableLocales.find(l => l.toLowerCase() === lower);
56-
if (caseMatch) return caseMatch;
57-
58-
// 3. Base language match (zh-CN → zh)
59-
const baseLang = requestedLocale.split('-')[0].toLowerCase();
60-
const baseMatch = availableLocales.find(l => l.toLowerCase() === baseLang);
61-
if (baseMatch) return baseMatch;
62-
63-
// 4. Variant expansion (zh → zh-CN, zh-TW, etc. — first match wins)
64-
const variantMatch = availableLocales.find(l => l.split('-')[0].toLowerCase() === baseLang);
65-
if (variantMatch) return variantMatch;
66-
67-
return undefined;
52+
return resolveLocaleAmong(requestedLocale, availableLocales);
6853
}
6954

7055
/**
@@ -75,32 +60,18 @@ export function resolveLocale(requestedLocale: string, availableLocales: string[
7560
* Setup's default language (`localization.locale`) accepts a value through
7661
* it, and the IdP locale claim (`plugin-auth`'s `resolveIdpLocale`) closes a
7762
* claimed locale through it, so a language one door takes the other takes
78-
* too. Order, first answer wins:
79-
*
80-
* 1. {@link resolveLocale}'s exact and case-insensitive steps.
81-
* 2. The platform locale table: an offered code that means the same table
82-
* language as the request (`platformLocaleTagOf`, through the table's
83-
* read aliases), so `zh-TW` is `zh-Hant` and never `zh-Hans`, and
84-
* `en-US` is `en`. The table's own note prescribes this order: map the
85-
* code to its table language first, then resolve.
86-
* 3. {@link resolveLocale}'s base-language and variant steps (`fr-FR` is an
87-
* offered `fr`, `fr` an offered `fr-FR`).
63+
* too.
8864
*
89-
* Steps 1 and 3 are `resolveLocale`'s own rules; step 2 only decides between two
90-
* offered codes of one base language that the table tells apart by script or
91-
* region (`zh-Hans`/`zh-Hant`), where `resolveLocale` takes the first listed.
65+
* It is {@link resolveLocale}, which is `resolveLocaleAmong` from
66+
* `@objectstack/spec/system`: exact, case-insensitive, the same platform
67+
* locale table language (`platformLocaleTagOf`, through the table's read
68+
* aliases, so `zh-TW` is `zh-Hant` and never `zh-Hans`, and `en-US` is
69+
* `en`), then base language and variant (`fr-FR` is an offered `fr`, `fr` an
70+
* offered `fr-FR`). Kept as its own name for the doors that ask the
71+
* offered-locale question; the ladder is the one every locale resolver runs.
9272
*/
9373
export function resolveOfferedLocale(requested: string, offered: readonly string[]): string | undefined {
94-
if (offered.length === 0) return undefined;
95-
const lower = requested.toLowerCase();
96-
const literal = offered.find((code) => code === requested) ?? offered.find((code) => code.toLowerCase() === lower);
97-
if (literal !== undefined) return literal;
98-
const tag = platformLocaleTagOf(requested);
99-
if (tag !== undefined) {
100-
const sameLanguage = offered.find((code) => platformLocaleTagOf(code) === tag);
101-
if (sameLanguage !== undefined) return sameLanguage;
102-
}
103-
return resolveLocale(requested, [...offered]);
74+
return resolveLocaleAmong(requested, offered);
10475
}
10576

10677
/**

‎packages/plugins/plugin-email/src/email-service.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1494,8 +1494,9 @@ export class EmailService implements IEmailService {
14941494
const wanted = preferred || DEFAULT_TEMPLATE_LOCALE;
14951495
// [#28880] A named locale, over the built-in loader, is folded against the
14961496
// bundle's own tags by the platform's one BCP-47 ladder (exact →
1497-
// case-insensitive → base language → variant expansion, the first match
1498-
// in ascending tag order), then the stack's declared fallback is folded
1497+
// case-insensitive → platform locale table language → base language →
1498+
// variant expansion, the first match in ascending tag order), then the
1499+
// stack's declared fallback is folded
14991500
// the same way, before the `en-US` rung below. Each rung loads the tag it
15001501
// resolved to, exactly. A no-locale call keeps its ladder: en-US, then the
15011502
// loader's own answer.

0 commit comments

Comments
 (0)