diff --git a/.changeset/30538-locale-table-read-aliases.md b/.changeset/30538-locale-table-read-aliases.md new file mode 100644 index 00000000000..847a63501a7 --- /dev/null +++ b/.changeset/30538-locale-table-read-aliases.md @@ -0,0 +1,17 @@ +--- +'@objectstack/spec': minor +'@objectstack/core': minor +--- + +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 + +Clause-②: yes (widening) +Metadata-protocol: semantic + +`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. + +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. + +`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`. + +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. diff --git a/content/docs/automation/email-templates.mdx b/content/docs/automation/email-templates.mdx index acc46ddff95..2c26fd84dc0 100644 --- a/content/docs/automation/email-templates.mdx +++ b/content/docs/automation/email-templates.mdx @@ -257,8 +257,10 @@ one `EmailServicePlugin` wires), walks: 1. **this locale, folded** against the locale tags the template's bundle carries by `resolveBundleLocale` (`@objectstack/spec/system`, the BCP-47 fold plugin-email imports rather than copying): exact, then case-insensitive - (`zh-cn` → `zh-CN`), then base language (`en-GB` → `en`), then variant - expansion (`zh` and `zh-TW` → `zh-CN`), the first match in ascending tag + (`zh-cn` → `zh-CN`), then the same platform locale table language (`zh-TW` + → a `zh-Hant` row, `zh` → a `zh-CN` row, in any tag order), then base + language (`en-GB` → `en`), then variant expansion (`zh-TW` → `zh-CN` when + the bundle has no Traditional row), the first match in ascending tag order; 2. **the stack's declared fallback locale**, as `II18nService.getFallbackLocale()` answers it (`i18n.fallbackLocale`, else diff --git a/content/docs/references/system/email-template.mdx b/content/docs/references/system/email-template.mdx index 63ca088b76d..41f876bc451 100644 --- a/content/docs/references/system/email-template.mdx +++ b/content/docs/references/system/email-template.mdx @@ -48,7 +48,7 @@ const result = EmailTemplateDefinitionSchema.parse(data); | **name** | `string` | ✅ | Template identifier (dotted snake_case) | | **label** | `string` | ✅ | Display label | | **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. | -| **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. | +| **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. | | **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`). | | **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`). | | **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`). | diff --git a/packages/core/src/fallbacks/fallbacks.test.ts b/packages/core/src/fallbacks/fallbacks.test.ts index f6346e93faa..bc025a78eea 100644 --- a/packages/core/src/fallbacks/fallbacks.test.ts +++ b/packages/core/src/fallbacks/fallbacks.test.ts @@ -5,6 +5,7 @@ import { createMemoryJob } from './memory-job'; import { createMemoryI18n, resolveLocale, resolveOfferedLocale } from './memory-i18n'; import { CORE_FALLBACK_FACTORIES } from './index'; import { readServiceSelfInfo } from '@objectstack/spec/api'; +import { resolveBundleLocale } from '@objectstack/spec/system'; describe('CORE_FALLBACK_FACTORIES', () => { // [#10746] `job` is deliberately OFF this list — a fallback must not fake @@ -295,6 +296,56 @@ describe('resolveLocale', () => { }); }); +// One case list, run against core's `resolveLocale` (a list) and the spec's +// `resolveBundleLocale` (a bundle's keys), so the two server resolvers cannot +// drift. The platform locale table's read aliases decide before any +// base-language or first-variant guess: a Traditional reader reaches a +// registered `zh-Hant`, a Simplified one `zh-CN`, in either registration order. +describe('resolveLocale and resolveBundleLocale — one shared case list', () => { + const CASES: Array<[requested: string, available: string[], expected: string | undefined]> = [ + ['zh-TW', ['en', 'zh-CN', 'zh-Hant'], 'zh-Hant'], + ['zh-TW', ['en', 'zh-Hant', 'zh-CN'], 'zh-Hant'], + ['zh-HK', ['en', 'zh-CN', 'zh-Hant'], 'zh-Hant'], + ['zh-HK', ['en', 'zh-Hant', 'zh-CN'], 'zh-Hant'], + ['zh-MO', ['en', 'zh-CN', 'zh-Hant'], 'zh-Hant'], + ['zh-MO', ['en', 'zh-Hant', 'zh-CN'], 'zh-Hant'], + ['zh-CN', ['en', 'zh-CN', 'zh-Hant'], 'zh-CN'], + ['zh-CN', ['en', 'zh-Hant', 'zh-CN'], 'zh-CN'], + ['zh', ['en', 'zh-Hant', 'zh-CN'], 'zh-CN'], + ['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'], + // 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'], + ['zh-TW', ['en', 'zh-CN', 'ja-JP', 'es-ES'], 'zh-CN'], + ['zh-TW', ['en', 'zh'], 'zh'], + // A code the table does not know falls back as before. + ['es-MX', ['en', 'es-ES'], 'es-ES'], + ['en-GB', ['en-US', 'en'], 'en'], + ['fr', ['en', 'zh-CN'], undefined], + ['zh-TW', [], undefined], + ]; + + it.each(CASES)('resolveLocale(%s, %j) → %s', (requested, available, expected) => { + expect(resolveLocale(requested, available)).toBe(expected); + }); + + it.each(CASES)('resolveBundleLocale(%j over %j) → %s', (requested, available, expected) => { + const bundle = Object.fromEntries(available.map((code) => [code, {}])); + expect(resolveBundleLocale(bundle, requested)).toBe(expected); + }); + + it('a zh-HK reader of the in-memory service gets the zh-Hant bundle', () => { + const i18n = createMemoryI18n(); + i18n.loadTranslations('en', { settings: 'Settings' }); + i18n.loadTranslations('zh-CN', { settings: '设置' }); + i18n.loadTranslations('zh-Hant', { settings: '設定' }); + expect(i18n.t('settings', 'zh-HK')).toBe('設定'); + expect(i18n.t('settings', 'zh')).toBe('设置'); + }); +}); + describe('createMemoryI18n locale fallback', () => { it('should resolve translations via locale fallback (zh → zh-CN)', () => { const i18n = createMemoryI18n(); @@ -577,14 +628,26 @@ describe('createMemoryI18n declared fallbackLocale (#15694)', () => { // #30456: the one matcher for "is this locale offered here, and as which code", // shared by Setup's default language and the IdP locale claim. describe('resolveOfferedLocale', () => { + it('answers what resolveLocale answers, on the shared case list too', () => { + for (const [requested, available] of [ + ['zh-TW', ['en', 'zh-CN', 'zh-Hant']], + ['zh', ['en', 'zh-Hant', 'zh-CN']], + ['zh-HK', ['zh-TW', 'zh-Hant']], + ['es-MX', ['en', 'es-ES']], + ['fr', ['en', 'zh-CN']], + ] as Array<[string, string[]]>) { + expect(resolveOfferedLocale(requested, available)).toBe(resolveLocale(requested, available)); + } + }); + it('takes resolveLocale\'s exact and case-insensitive steps first', () => { expect(resolveOfferedLocale('zh-CN', ['en', 'zh-CN'])).toBe('zh-CN'); expect(resolveOfferedLocale('zh-cn', ['en', 'zh-CN'])).toBe('zh-CN'); }); it('then the platform table\'s language, so a script or region lands on its own script', () => { - // resolveLocale alone takes the first `zh-*` listed for either spelling. - expect(resolveLocale('zh-TW', ['zh-Hans', 'zh-Hant'])).toBe('zh-Hans'); + // resolveOfferedLocale is resolveLocale: one ladder, so both answer by script. + expect(resolveLocale('zh-TW', ['zh-Hans', 'zh-Hant'])).toBe('zh-Hant'); expect(resolveOfferedLocale('zh-TW', ['zh-Hans', 'zh-Hant'])).toBe('zh-Hant'); expect(resolveOfferedLocale('zh-CN', ['zh-Hant', 'zh-Hans'])).toBe('zh-Hans'); expect(resolveOfferedLocale('en-US', ['zh-CN', 'en'])).toBe('en'); diff --git a/packages/core/src/fallbacks/memory-i18n.ts b/packages/core/src/fallbacks/memory-i18n.ts index 11028c28ded..b118b985711 100644 --- a/packages/core/src/fallbacks/memory-i18n.ts +++ b/packages/core/src/fallbacks/memory-i18n.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { normalizeSupportedLocales, platformLocaleTagOf } from '@objectstack/spec/system'; +import { normalizeSupportedLocales, resolveLocaleAmong } from '@objectstack/spec/system'; /** * Recursively merge `source` into `target`. Nested plain objects are merged @@ -36,35 +36,20 @@ export function deepMerge( /** * Resolve a locale code against available locales with fallback. * - * Fallback chain: + * This is `resolveLocaleAmong` from `@objectstack/spec/system`, the one + * locale-matching ladder the spec's `resolveBundleLocale` also runs, so the + * two resolvers cannot answer differently. Fallback chain: * 1. Exact match (e.g. `zh-CN` → `zh-CN`) * 2. Case-insensitive match (e.g. `zh-cn` → `zh-CN`) - * 3. Base language match (e.g. `zh-CN` → `zh`) - * 4. Variant expansion (e.g. `zh` → `zh-CN`) + * 3. The same platform table language, through the table's read aliases + * (e.g. `zh-TW` / `zh-HK` → `zh-Hant`, `zh` → `zh-CN`, `ja` → `ja-JP`) + * 4. Base language match (e.g. `zh-CN` → `zh`) + * 5. Variant expansion (e.g. `es-MX` → `es-ES`) * * Returns the matched locale code, or `undefined` when no match is found. */ export function resolveLocale(requestedLocale: string, availableLocales: string[]): string | undefined { - if (availableLocales.length === 0) return undefined; - - // 1. Exact match - if (availableLocales.includes(requestedLocale)) return requestedLocale; - - // 2. Case-insensitive match - const lower = requestedLocale.toLowerCase(); - const caseMatch = availableLocales.find(l => l.toLowerCase() === lower); - if (caseMatch) return caseMatch; - - // 3. Base language match (zh-CN → zh) - const baseLang = requestedLocale.split('-')[0].toLowerCase(); - const baseMatch = availableLocales.find(l => l.toLowerCase() === baseLang); - if (baseMatch) return baseMatch; - - // 4. Variant expansion (zh → zh-CN, zh-TW, etc. — first match wins) - const variantMatch = availableLocales.find(l => l.split('-')[0].toLowerCase() === baseLang); - if (variantMatch) return variantMatch; - - return undefined; + return resolveLocaleAmong(requestedLocale, availableLocales); } /** @@ -75,32 +60,18 @@ export function resolveLocale(requestedLocale: string, availableLocales: string[ * Setup's default language (`localization.locale`) accepts a value through * it, and the IdP locale claim (`plugin-auth`'s `resolveIdpLocale`) closes a * claimed locale through it, so a language one door takes the other takes - * too. Order, first answer wins: - * - * 1. {@link resolveLocale}'s exact and case-insensitive steps. - * 2. The platform locale table: an offered code that means the same table - * language as the request (`platformLocaleTagOf`, through the table's - * read aliases), so `zh-TW` is `zh-Hant` and never `zh-Hans`, and - * `en-US` is `en`. The table's own note prescribes this order: map the - * code to its table language first, then resolve. - * 3. {@link resolveLocale}'s base-language and variant steps (`fr-FR` is an - * offered `fr`, `fr` an offered `fr-FR`). + * too. * - * Steps 1 and 3 are `resolveLocale`'s own rules; step 2 only decides between two - * offered codes of one base language that the table tells apart by script or - * region (`zh-Hans`/`zh-Hant`), where `resolveLocale` takes the first listed. + * It is {@link resolveLocale}, which is `resolveLocaleAmong` from + * `@objectstack/spec/system`: exact, case-insensitive, the same platform + * locale table language (`platformLocaleTagOf`, through the table's read + * aliases, so `zh-TW` is `zh-Hant` and never `zh-Hans`, and `en-US` is + * `en`), then base language and variant (`fr-FR` is an offered `fr`, `fr` an + * offered `fr-FR`). Kept as its own name for the doors that ask the + * offered-locale question; the ladder is the one every locale resolver runs. */ export function resolveOfferedLocale(requested: string, offered: readonly string[]): string | undefined { - if (offered.length === 0) return undefined; - const lower = requested.toLowerCase(); - const literal = offered.find((code) => code === requested) ?? offered.find((code) => code.toLowerCase() === lower); - if (literal !== undefined) return literal; - const tag = platformLocaleTagOf(requested); - if (tag !== undefined) { - const sameLanguage = offered.find((code) => platformLocaleTagOf(code) === tag); - if (sameLanguage !== undefined) return sameLanguage; - } - return resolveLocale(requested, [...offered]); + return resolveLocaleAmong(requested, offered); } /** diff --git a/packages/plugins/plugin-email/src/email-service.ts b/packages/plugins/plugin-email/src/email-service.ts index cecf8aa07e3..a7f8e86c24d 100644 --- a/packages/plugins/plugin-email/src/email-service.ts +++ b/packages/plugins/plugin-email/src/email-service.ts @@ -1494,8 +1494,9 @@ export class EmailService implements IEmailService { const wanted = preferred || DEFAULT_TEMPLATE_LOCALE; // [#28880] A named locale, over the built-in loader, is folded against the // bundle's own tags by the platform's one BCP-47 ladder (exact → - // case-insensitive → base language → variant expansion, the first match - // in ascending tag order), then the stack's declared fallback is folded + // case-insensitive → platform locale table language → base language → + // variant expansion, the first match in ascending tag order), then the + // stack's declared fallback is folded // the same way, before the `en-US` rung below. Each rung loads the tag it // resolved to, exactly. A no-locale call keeps its ladder: en-US, then the // loader's own answer. diff --git a/packages/plugins/plugin-email/src/template-locale-fold.test.ts b/packages/plugins/plugin-email/src/template-locale-fold.test.ts index f47384d2b8d..e2e88a89a12 100644 --- a/packages/plugins/plugin-email/src/template-locale-fold.test.ts +++ b/packages/plugins/plugin-email/src/template-locale-fold.test.ts @@ -2,14 +2,17 @@ // // [#28880] The template locale ladder for a call that NAMES a locale is the // platform's: the recipient's tag folded against the bundle's locales by -// `resolveBundleLocale` (exact → case-insensitive → base language → variant -// expansion), then the stack's declared fallback folded the same way, then +// `resolveBundleLocale` (exact → case-insensitive → platform locale table +// language → base language → variant expansion), then the stack's declared +// fallback folded the same way, then // `en-US`, then `TEMPLATE_NOT_FOUND`. Measured on `origin/main` (`b15b634c`) // before this file: the service tried the named tag exactly, then `en-US`, so // an `en-GB` or `zh-TW` recipient of an `en` + `zh-CN` bundle dead-lettered. // // Variant expansion picks the first base-language match in ascending tag -// order, so the answer does not depend on the order the list carries. +// order, so the answer does not depend on the order the list carries. The +// table-language step runs first: a `zh-MO` recipient reaches a Traditional +// row (`zh-HK`, `zh-TW`) before any Simplified one. // // The folding is the built-in registry loader's bundle read; a replacement // loader that offers no locale list keeps the exact ladder (pinned last). @@ -78,8 +81,15 @@ describe('[#28880] a named recipient locale is folded against the bundle', () => }); it('variant expansion takes the first base-language match in ascending tag order, whatever the list order', async () => { - expect(await sentLocale(['zh-TW', 'zh-HK', 'zh-CN'], 'zh-MO')).toBe('zh-CN'); - expect(await sentLocale(['zh-CN', 'zh-HK', 'zh-TW'], 'zh-MO')).toBe('zh-CN'); + expect(await sentLocale(['es-MX', 'es-AR', 'en-US'], 'es-CO')).toBe('es-AR'); + expect(await sentLocale(['en-US', 'es-AR', 'es-MX'], 'es-CO')).toBe('es-AR'); + }); + + it('a Traditional recipient reaches a Traditional row before a Simplified one, whatever the list order', async () => { + expect(await sentLocale(['zh-TW', 'zh-HK', 'zh-CN'], 'zh-MO')).toBe('zh-HK'); + expect(await sentLocale(['zh-CN', 'zh-HK', 'zh-TW'], 'zh-MO')).toBe('zh-HK'); + expect(await sentLocale(['en-US', 'zh-CN', 'zh-Hant'], 'zh-TW')).toBe('zh-Hant'); + expect(await sentLocale(['en-US', 'zh-CN', 'zh-Hant'], 'zh')).toBe('zh-CN'); }); it('an exact hit wins over every fold', async () => { diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index fdc86bd2edb..7bb5e80e870 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -897,6 +897,7 @@ "resolveDocLocale (function)", "resolveFlowScreenFieldOptions (function)", "resolveFlowScreenTitle (function)", + "resolveLocaleAmong (function)", "resolveMetadataFormLabels (function)", "resolveMetadataFormSchemaTitles (function)", "resolveMetadataTypeDescription (function)", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index 88441eecff2..1f373e0ae06 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -858,6 +858,7 @@ "resolveDocLocale": "src/system/doc.zod.ts#resolveDocLocale (function)", "resolveFlowScreenFieldOptions": "src/system/i18n-resolver.ts#resolveFlowScreenFieldOptions (function)", "resolveFlowScreenTitle": "src/system/i18n-resolver.ts#resolveFlowScreenTitle (function)", + "resolveLocaleAmong": "src/system/i18n-resolver.ts#resolveLocaleAmong (function)", "resolveMetadataFormLabels": "src/system/i18n-resolver.ts#resolveMetadataFormLabels (function)", "resolveMetadataFormSchemaTitles": "src/system/i18n-resolver.ts#resolveMetadataFormSchemaTitles (function)", "resolveMetadataTypeDescription": "src/system/i18n-resolver.ts#resolveMetadataTypeDescription (function)", diff --git a/packages/spec/src/contracts/email-service.ts b/packages/spec/src/contracts/email-service.ts index 12ed4d3e1ad..f99bab0a398 100644 --- a/packages/spec/src/contracts/email-service.ts +++ b/packages/spec/src/contracts/email-service.ts @@ -192,9 +192,11 @@ export interface SendTemplateInput { * 1. this locale, folded against the locale tags the template's bundle * carries by `resolveBundleLocale` (`@objectstack/spec/system`, the * BCP-47 fold plugin-email imports rather than copying): exact, then - * case-insensitive (`zh-cn` → `zh-CN`), then base language (`en-GB` → - * `en`), then variant expansion (`zh` and `zh-TW` → `zh-CN`), the first - * match in ascending tag order; + * case-insensitive (`zh-cn` → `zh-CN`), then the same platform locale + * table language (`zh-TW` → a `zh-Hant` row, `zh` → a `zh-CN` row, in + * any tag order), then base language (`en-GB` → `en`), then variant + * expansion (`zh-TW` → `zh-CN` when the bundle has no Traditional + * row), the first match in ascending tag order; * 2. the stack's declared fallback locale, as * `II18nService.getFallbackLocale()` answers it (`i18n.fallbackLocale`, * else `defaultLocale`), folded the same way. A host whose i18n service diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index f3aa7d2f778..e249bb9759f 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -6045,7 +6045,8 @@ const warnedEmailTemplateFloors = new Set(); * `IEmailService.sendTemplate` reads one row at one tag exactly. For a call * that NAMES a locale, over the built-in registry template loader, it folds * that locale against the bundle's tags (`resolveBundleLocale`: exact, - * case-insensitive, base language, variant expansion), then the stack's + * case-insensitive, platform table language, base language, variant + * expansion), then the stack's * declared fallback locale (`i18n.fallbackLocale`, else `defaultLocale`) the * same way, then the literal `en-US` (#28880); a replacement `TemplateLoader` * matches the named tag exactly and retries only `en-US`. So a bundle with no diff --git a/packages/spec/src/system/email-template.zod.ts b/packages/spec/src/system/email-template.zod.ts index 90e7b85e1d2..366afd2681f 100644 --- a/packages/spec/src/system/email-template.zod.ts +++ b/packages/spec/src/system/email-template.zod.ts @@ -35,8 +35,9 @@ import { strictObject } from '../shared/strict-object'; * * Named because those two roles are the same fact and authors keep reading it * as neither. Over the built-in registry template loader the resolver folds a - * named locale against the bundle's own tags (exact, case-insensitive, base - * language, variant expansion) and then tries the stack's declared fallback + * named locale against the bundle's own tags (exact, case-insensitive, + * platform locale table language, base language, variant expansion) and then + * tries the stack's declared fallback * (`i18n.fallbackLocale`, else `defaultLocale`) folded the same way, so an `en` * row is reached, absent a row of their own, from `en-GB`, and from any tag on * a stack whose declared fallback is `en`. This literal is the one rung that @@ -178,12 +179,14 @@ export const EmailTemplateDefinitionSchema = lazySchema(() => strictObject({ * (`IEmailService.sendTemplate`) reads one row at one tag **exactly**, rung * by rung; what it folds is the tag it asks for (#28880). For a call that * NAMES a locale, over the built-in registry template loader, it folds that - * locale against the bundle's own tags (exact, case-insensitive, base - * language, then variant expansion, the first match in ascending tag order), + * locale against the bundle's own tags (exact, case-insensitive, platform + * locale table language, base language, then variant expansion, the first + * match in ascending tag order), * then folds the stack's declared fallback locale (`i18n.fallbackLocale`, * else `defaultLocale`) the same way, then tries the literal * {@link EMAIL_TEMPLATE_FLOOR_LOCALE}. So, absent a row of their own, `en-GB` - * and `en-US` reach an `en` row and `zh-TW` reaches `zh-CN`, while `fr` + * and `en-US` reach an `en` row and `zh-TW` reaches `zh-Hant` (else + * `zh-CN`), while `fr` * reaches an `en` row only through the declared fallback. * * ⚠️ `en-US` is still the bundle's FLOOR: the one rung that holds whatever @@ -221,8 +224,9 @@ export const EmailTemplateDefinitionSchema = lazySchema(() => strictObject({ '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`, " + + '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 — ' diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index 6d53a4b086c..8c4bc7d9ba0 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -17,6 +17,8 @@ import { normalizeSupportedLocales, objectValidationMessageKey, TRANSLATABLE_METADATA_TYPES, + resolveBundleLocale, + resolveLocaleAmong, } from './i18n-resolver'; // #14253 — the dataset fixture is PARSED by the authoring schema rather than // hand-shaped, so a translator addressing a member name the schema does not @@ -1044,6 +1046,64 @@ 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. +describe('resolveBundleLocale — the platform table language step', () => { + const orders: Array<[string, Record]> = [ + ['Simplified first', { en: {}, 'zh-CN': {}, 'zh-Hant': {} }], + ['Traditional first', { en: {}, 'zh-Hant': {}, 'zh-CN': {} }], + ]; + + 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) => { + expect(resolveBundleLocale(bundle, requested)).toBe('zh-Hant'); + }); + + it.each(['zh-CN', 'zh', 'zh-SG', 'zh-Hans', 'zh-cn'])('%s reaches zh-CN', (requested) => { + expect(resolveBundleLocale(bundle, requested)).toBe('zh-CN'); + }); + }); + } + + it('prefers the table tag spelling, then the first key the table maps to it', () => { + expect(resolveBundleLocale({ 'zh-TW': {}, 'zh-Hant': {} }, 'zh-HK')).toBe('zh-Hant'); + expect(resolveBundleLocale({ 'zh-HK': {}, 'zh-TW': {} }, 'zh-MO')).toBe('zh-HK'); + expect(resolveBundleLocale({ 'zh-CN': {}, 'zh-TW': {} }, 'zh-Hant')).toBe('zh-TW'); + }); + + 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'); + }); + + it('keeps the alias-spelled bundles of today: ja → ja-JP, es → es-ES, en → en-US', () => { + const bundle = { 'en-US': {}, 'zh-CN': {}, 'ja-JP': {}, 'es-ES': {} }; + expect(resolveBundleLocale(bundle, 'ja')).toBe('ja-JP'); + expect(resolveBundleLocale(bundle, 'es')).toBe('es-ES'); + expect(resolveBundleLocale(bundle, 'en')).toBe('en-US'); + expect(resolveBundleLocale(bundle, 'zh-TW')).toBe('zh-CN'); + }); + + it('a code the table does not know falls back as before', () => { + expect(resolveBundleLocale({ en: {}, 'es-ES': {} }, 'es-MX')).toBe('es-ES'); + expect(resolveBundleLocale({ en: {}, 'en-US': {} }, 'en-GB')).toBe('en'); + expect(resolveBundleLocale({ 'pt-PT': {} }, 'pt-BR')).toBe('pt-PT'); + expect(resolveBundleLocale({ en: {}, 'zh-CN': {} }, 'fr')).toBeUndefined(); + expect(resolveBundleLocale({}, 'zh-TW')).toBeUndefined(); + }); + + it('resolveLocaleAmong answers the same over a list', () => { + expect(resolveLocaleAmong('zh-TW', ['en', 'zh-CN', 'zh-Hant'])).toBe('zh-Hant'); + expect(resolveLocaleAmong('zh', ['zh-Hant', 'zh-CN'])).toBe('zh-CN'); + expect(resolveLocaleAmong('zh-TW', [])).toBeUndefined(); + }); +}); + describe('translateApp', () => { const bundle: TranslationBundle = { 'zh-CN': { diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 5bae7180d0c..f0c99b1a5e6 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -85,6 +85,7 @@ import { mapFlowNodeList } from '../conversions/walk.js'; import { flowScreenFieldOptionKey } from '../automation/flow-screen-option-key.js'; import { pageComponentSlotPositions } from '../ui/component.zod.js'; import { templateSlotSource } from '../automation/flow-text-slot-template.js'; +import { platformLocaleTagOf } from './platform-locales.js'; import type { PlatformTranslationBundle, @@ -505,11 +506,12 @@ function overlayRecord(under: Record, top: Record code.toLowerCase() === lower); if (caseMatch) return caseMatch; + // 3. The same platform table language (e.g. `zh-TW` → `zh-Hant`). + const tag = platformLocaleTagOf(requested); + if (tag !== undefined) { + const folded = tag.toLowerCase(); + const tagMatch = available.find((code) => code.toLowerCase() === folded) + ?? available.find((code) => platformLocaleTagOf(code) === tag); + if (tagMatch) return tagMatch; + } + const base = lower.split('-')[0]; - // 3. Base-language match (e.g. `zh-CN` → `zh`). + // 4. Base-language match (e.g. `zh-CN` → `zh`). const baseMatch = available.find((code) => code.toLowerCase() === base); if (baseMatch) return baseMatch; - // 4. Variant expansion (e.g. `zh` → `zh-CN`; first registered variant wins). + // 5. Variant expansion (e.g. `zh` → `zh-CN`; first registered variant wins). const variantMatch = available.find((code) => code.toLowerCase().split('-')[0] === base); if (variantMatch) return variantMatch; @@ -4429,9 +4472,10 @@ export const BUILTIN_SYSTEM_FIELD_LABEL_LOCALES: readonly string[] = Object.free /** * Fallback label for a platform-injected system field, honouring the same - * locale-matching rules as bundle lookup (exact → case-insensitive → base - * language → variant). Applied only when the field still carries its injected - * English default — an author's custom label is never overridden. + * locale-matching rules as bundle lookup (exact → case-insensitive → platform + * table language → base language → variant). Applied only when the field + * still carries its injected English default — an author's custom label is + * never overridden. */ function builtinSystemFieldLabel( fieldName: string, diff --git a/packages/spec/src/system/platform-locales.ts b/packages/spec/src/system/platform-locales.ts index 15366130595..ef49c0c854d 100644 --- a/packages/spec/src/system/platform-locales.ts +++ b/packages/spec/src/system/platform-locales.ts @@ -105,14 +105,12 @@ const TAG_BY_FOLDED_CODE: ReadonlyMap = new Map', locale)` — a deployment's * `translation` override. A miss is detected by the II18nService contract * of echoing the key back. - * 2. the built-in catalog for the locale (BCP-47 matched: exact → - * case-insensitive → base language → variant). + * 2. the built-in catalog for the locale (BCP-47 matched by + * `resolveBundleLocale`: exact → case-insensitive → platform table + * language → base language → variant). * 3. the built-in catalog for `en`. * 4. `