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
17 changes: 17 additions & 0 deletions .changeset/30538-locale-table-read-aliases.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 4 additions & 2 deletions content/docs/automation/email-templates.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/system/email-template.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`). |
Expand Down
67 changes: 65 additions & 2 deletions packages/core/src/fallbacks/fallbacks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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');
Expand Down
65 changes: 18 additions & 47 deletions packages/core/src/fallbacks/memory-i18n.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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);
}

/**
Expand All @@ -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);
}

/**
Expand Down
5 changes: 3 additions & 2 deletions packages/plugins/plugin-email/src/email-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading