diff --git a/.changeset/rest-translate-options-default-locale.md b/.changeset/rest-translate-options-default-locale.md new file mode 100644 index 0000000000..ab37741797 --- /dev/null +++ b/.changeset/rest-translate-options-default-locale.md @@ -0,0 +1,9 @@ +--- +"@objectstack/rest": patch +--- + +fix(rest): the metadata reads pass the declared default locale to the label resolvers, so a request for it answers with the authored label (#15711) + +`translateOptionsFor` — the single seam every metadata-document translation in the REST server goes through — now threads `i18n.getDefaultLocale()` into `ResolveOptions.defaultLocale` beside the declared fallback chain it has passed since #14882. Both accessors are optional on `II18nService` and both are feature-detected: a provider that declares no default gets no default, one that declares no fallback gets no chain, and the seam never answers `'en'` on a provider's behalf. + +Measured on the reporter's stack shape (`defaultLocale: 'zh-CN'`, `fallbackLocale: 'en'`, an `en` bundle and no `zh-CN` bundle): `GET /api/v1/meta/object/kpi_entry_sheet` with `Accept-Language: zh-CN` — or with no header at all, which resolves to the default — now serves the authored `填报单`, not the `en` bundle's `Entry Sheet`; a `fr` request still walks the declared `en` bundle; an `en` request still gets the `en` bundle. Pinned in `meta-i18n-declared-fallback-chain.test.ts` §4 and §5. diff --git a/.changeset/spec-i18n-default-locale-authored-label.md b/.changeset/spec-i18n-default-locale-authored-label.md new file mode 100644 index 0000000000..c5239cbbd1 --- /dev/null +++ b/.changeset/spec-i18n-default-locale-authored-label.md @@ -0,0 +1,22 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): the authored label is the default locale's text — `ResolveOptions.defaultLocale` skips the fallback chain for a default-locale request, and a chain-less caller no longer falls to a literal `en` (#15711) + + + +**BREAKING** (launch-window convention: ships as `minor`; this entry is the signal) — the second facet of the #15711 ruling moves a published default of the `@objectstack/spec/system` label resolvers. A caller that passes no `fallbackChain` used to get a literal `['en']`; it now gets `[]`, "requested locale, then the authored label". Nothing silently falls to `en` because a literal said so: a chain is consulted only when someone declared it. In this repo the blast radius is zero production callers (the REST serving layer has declared its chain since #14882; one pin flips); out-of-repo hosts unmeasured. A host that relied on the implicit `en` declares it as `fallbackChain: ['en']`. + +## The ruling (#15711, recorded 2026-09-05) + +A workspace that authors its metadata labels in its default locale (`i18n.defaultLocale: 'zh-CN'`, inline `label: '填报单'`) and ships a courtesy `en` bundle used to serve `Entry Sheet` to a `zh-CN` request whenever its declared chain named `en` — a reflexive `fallbackLocale: 'en'` in an AI-authored config was enough. `os i18n check` already counted the authored text as the default locale's coverage; the runtime did not. Ruled A: **the authored label IS the default locale's text**. + +- `ResolveOptions` gains an optional `defaultLocale?: string` — the deployment's default locale, the language its labels are authored in. When the requested locale names it (BCP-47 tags compare case-insensitively, the same rule `resolveBundleLocale` applies), the resolvers consult the requested locale's own bundle and then answer with the authored label; the fallback chain is not walked. +- `fallbackChain` keeps its full meaning for every non-default request: a `fr` request still walks the `fr` bundle, then the declared `en` bundle, then the authored label. +- A bundle entry for the default locale still wins when one is shipped, so `os i18n extract --locales=zh-CN` keeps working — optional now, not required. +- `II18nService.getDefaultLocale()` documents that it is also what the serving layer threads into `ResolveOptions.defaultLocale`; `@objectstack/rest` passes it through its single `translateOptionsFor` seam (that package's own changeset). + +Unchanged: `os i18n check`; both boot paths (`os serve` and the dev plugin still collapse the declaration to `fallbackLocale || defaultLocale || 'en'` before constructing the service); every request whose locale is not the default. + +Not taken, ruled out on the card: the rule living only in `packages/rest` (every other host would re-implement it and spec could not pin it); requiring every supported locale to ship a bundle (a generated bundle that duplicates the app's own source text, the stale-translation class already closed); documenting the divergence. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 815ccac008..0b357f0e8b 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -158,7 +158,7 @@ The largest single consumer — **17 of the 105 sites**. |:--|:---|:---|:---|:---| | 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:138` | | 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` | -| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5083`, `:6509`, `:6757`, `:7188`, `:7381` | +| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5101`, `:6527`, `:6775`, `:7206`, `:7399` | | 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` | | 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:422`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | | 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` | diff --git a/content/docs/protocol/kernel/i18n-standard.mdx b/content/docs/protocol/kernel/i18n-standard.mdx index 9f41798a29..ceddb3c218 100644 --- a/content/docs/protocol/kernel/i18n-standard.mdx +++ b/content/docs/protocol/kernel/i18n-standard.mdx @@ -150,6 +150,15 @@ Fallback to: pt ✗ (not found) Fallback to: en (system default) ✓ ``` +The "system default" step is the stack's **declared** fallback +(`i18n.fallbackLocale`, else `defaultLocale`), never a literal `en`. For +metadata labels one more rule applies (#15711): the inline `label:` authored +on the metadata **is** the default locale's text, so a request for +`defaultLocale` stops at the authored label without consulting any other +locale's bundle — a `zh-CN` workspace with a courtesy `en` bundle serves +Chinese to `zh-CN` and English to `en`. See +[Translations](/docs/ui/translations#how-a-locale-is-chosen). + ## Translation Bundles Translations are stored in **JSON files** organized by locale and namespace. diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 32e6a0b77a..bb11e44876 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -147,9 +147,23 @@ Per request, in this order: Within the bundle, matching walks: exact (`zh-CN`) → case-insensitive → **base language** (`zh-CN` → `zh`) → variant expansion (`zh` → `zh-CN`). If -nothing matches, the chain falls back to `en`, and finally to the literal label -on the metadata. **Translation lookup never throws** — a missing string -degrades to the next best text. +nothing matches, the request walks the stack's **declared** fallback locale +(`fallbackLocale`, else `defaultLocale`), and finally the literal label on the +metadata. **Translation lookup never throws** — a missing string degrades to +the next best text. + +Two rules keep the declaration honest (#14882, #15711): + +- **The authored label is the default locale's text.** A request for + `defaultLocale` consults that locale's own bundle and then answers with the + inline `label:` — it never walks the fallback chain. A stack declaring + `defaultLocale: 'zh-CN'` with `fallbackLocale: 'en'` and only an `en` bundle + serves its authored Chinese to a `zh-CN` request, and its `en` bundle to + everyone else. Shipping a `zh-CN` bundle (`os i18n extract --locales=zh-CN`) + still works and still wins; it is optional. +- **Nothing falls to `en` unless it was declared.** The chain is what the + stack declares; a caller of the `@objectstack/spec/system` resolvers that + declares no `fallbackChain` gets "requested locale, then the authored label". Resolved labels are served straight from the REST metadata endpoints (the locale is part of the ETag), so the Console and any SDUI client get translated @@ -240,7 +254,10 @@ reports nothing.** No `i18n` block and no bundles means one active locale, the default one, and your inline `label:` is already that locale's text — so there is no gap to report and no need to reach for `--skip-i18n`. The same holds if your source language isn't English: declare `defaultLocale: 'zh-CN'` and the -tooling stops asking for English you never promised. +tooling stops asking for English you never promised. The runtime reads the +declaration the same way (#15711): a request for the default locale answers +with the authored label, so what the gate counts as covered is what gets +served. Translating is therefore opt-in, but once you opt in it covers the **whole** declared surface — every row of the table above, including action labels diff --git a/packages/rest/src/meta-i18n-declared-fallback-chain.test.ts b/packages/rest/src/meta-i18n-declared-fallback-chain.test.ts index a59c5a9fa3..8feedf5c0c 100644 --- a/packages/rest/src/meta-i18n-declared-fallback-chain.test.ts +++ b/packages/rest/src/meta-i18n-declared-fallback-chain.test.ts @@ -2,17 +2,23 @@ /** * #14882 — the metadata reads hand the document translators the DECLARED - * fallback chain, not the resolver's literal `['en']`. + * fallback chain, not a literal `en`; #15711 — and the DECLARED default + * locale, so a request for it answers with the authored label. * * The RULE lives in `@objectstack/spec/system`: the label resolvers walk - * `requested locale → fallbackChain → authored label`, and honour whatever - * chain they are handed (pinned in `i18n-resolver.test.ts`). What can only be - * tested here is the PLUMBING — that every seam translating a metadata - * document passes `fallbackChain: [i18n.getFallbackLocale()]`, the locale the - * i18n service's own `t()` falls back to, which `I18nServicePlugin` receives - * from the stack's `i18n` config as `fallbackLocale || defaultLocale || 'en'`. - * Before this, every seam passed NO chain, so the declared `fallbackLocale` - * never reached the resolver and `en` was consulted before the authored label. + * `requested locale → fallbackChain → authored label` for a non-default + * request, skip the chain for a request that names `defaultLocale` (the + * authored label IS the default locale's text, ruled on #15711), and invent + * no chain for a caller that declares none (pinned in + * `i18n-resolver.test.ts`). What can only be tested here is the PLUMBING — + * that every seam translating a metadata document passes + * `fallbackChain: [i18n.getFallbackLocale()]`, the locale the i18n service's + * own `t()` falls back to, which `I18nServicePlugin` receives from the + * stack's `i18n` config as `fallbackLocale || defaultLocale || 'en'`, and + * `defaultLocale: i18n.getDefaultLocale()`, the same accessor a header-less + * request already resolves its locale from. Before #14882, every seam passed + * NO chain, so the declared `fallbackLocale` never reached the resolver and + * `en` was consulted before the authored label. * * The fixture is the card's workspace: labels authored in the default locale * (`zh-CN`), a courtesy `en` bundle for English users, and NO `zh-CN` bundle @@ -23,7 +29,7 @@ * * Seams covered: `GET /meta/:type/:name` (object and app), `GET /meta/:type` * (list), `GET /meta` (the types listing) — and the feature-detection - * contract for a service that declares no fallback. + * contract for a service that declares no fallback, or no default. */ import { describe, it, expect, vi } from 'vitest'; @@ -286,36 +292,83 @@ describe('#14882 §3 — controls', () => { // §4 — the feature-detection contract: no declaration, no invented chain // --------------------------------------------------------------------------- -describe('#14882 §4 — a service that declares no fallback keeps the resolver default', () => { - // The serving layer threads a DECLARATION; it does not derive one. An - // i18n provider without the accessor (or answering `undefined`) gets no - // chain, so the resolver's own `['en']` default applies exactly as it did - // before this card — the pre-#14882 answer, pinned so a later "helpful" - // derivation from `getDefaultLocale()` cannot land unnoticed (it would - // decide the contract question §5 leaves open). - it('a provider without getFallbackLocale answers as before (en consulted)', async () => { +describe('#14882 §4 / #15711 — a service that declares no fallback gets no chain, and no en is invented', () => { + // The serving layer threads DECLARATIONS; it derives nothing. An i18n + // provider without `getFallbackLocale` (or answering `undefined`) gets no + // chain, and since #15711 the resolver's own default is `[]`: a request + // walks `requested locale → authored label`, and `en` is consulted only + // when it is requested or declared. `getDefaultLocale()` IS threaded now + // (#15711 ruled the question §5 used to leave open), so the `zh-CN` + // request below answers authored for two independent reasons; the `fr` + // request isolates the second facet, where only the `[]` default applies. + it('a provider without getFallbackLocale: zh-CN (the default) and fr (not) both answer authored', async () => { const legacy = i18nFor({ bundles: { 'zh-CN': {}, en: EN_DATA }, defaultLocale: 'zh-CN', fallbackLocale: null }); - expect((await readItem(makeRest(legacy), 'object', 'kpi_entry_sheet', 'zh-CN')).label).toBe('Entry Sheet'); + expect(labelsOf(await readItem(makeRest(legacy), 'object', 'kpi_entry_sheet', 'zh-CN'))).toEqual(AUTHORED); + expect(labelsOf(await readItem(makeRest(legacy), 'object', 'kpi_entry_sheet', 'fr'))).toEqual(AUTHORED); + // Control — an `en` request still finds its own bundle. + expect((await readItem(makeRest(legacy), 'object', 'kpi_entry_sheet', 'en')).label).toBe('Entry Sheet'); }); - it('a provider answering undefined answers as before (en consulted)', async () => { + it('a provider answering undefined answers the same', async () => { const undeclared = i18nFor({ bundles: { 'zh-CN': {}, en: EN_DATA }, defaultLocale: 'zh-CN', fallbackLocale: undefined }); - expect((await readItem(makeRest(undeclared), 'object', 'kpi_entry_sheet', 'zh-CN')).label).toBe('Entry Sheet'); + expect((await readItem(makeRest(undeclared), 'object', 'kpi_entry_sheet', 'zh-CN')).label).toBe('填报单'); + expect((await readItem(makeRest(undeclared), 'object', 'kpi_entry_sheet', 'fr')).label).toBe('填报单'); + }); + + it('a provider without getDefaultLocale gets no default: nothing is the default, the chain is all there is', async () => { + // No `defaultLocale` is threaded (the seam passes nothing, never + // `'en'`), so the default-locale rule is off and the DECLARED `en` + // chain is walked for a `zh-CN` request exactly as #14882 pinned it. + const noDefault = i18nFor({ bundles: { 'zh-CN': {}, en: EN_DATA }, defaultLocale: 'zh-CN', fallbackLocale: 'en' }); + delete noDefault.getDefaultLocale; + expect((await readItem(makeRest(noDefault), 'object', 'kpi_entry_sheet', 'zh-CN')).label).toBe('Entry Sheet'); }); }); // --------------------------------------------------------------------------- -// §5 — a stack that DECLARES en as its fallback is honoured as it reads +// §5 — a stack that DECLARES en as its fallback: honoured for every request +// but the default-locale one (#15711) // --------------------------------------------------------------------------- -describe('#14882 §5 — a declared en fallback still consults en before the authored label', () => { - // `defaultLocale: 'zh-CN'`, `fallbackLocale: 'en'`, no zh-CN bundle. - // Pinned as it answers today — the `en` bundle — because whether the - // authored label is the default-locale source (and so should outrank a - // declared fallback's bundle) is a CONTRACT question this card does not - // decide. #14882 changes which chain reaches the resolver, nothing else. - it('GET /meta/object/:name serves the en bundle for a zh-CN request', async () => { - const enFallback = i18nFor({ bundles: { 'zh-CN': {}, en: EN_DATA }, defaultLocale: 'zh-CN', fallbackLocale: 'en' }); - expect((await readItem(makeRest(enFallback), 'object', 'kpi_entry_sheet', 'zh-CN')).label).toBe('Entry Sheet'); +describe('#15711 §5 — a declared en fallback never outranks the authored label for a default-locale request', () => { + // `defaultLocale: 'zh-CN'`, `fallbackLocale: 'en'`, no zh-CN bundle — the + // reflexive AI-authored config. #14882 pinned this as it answered then + // (`Entry Sheet`) because whether the authored label is the default-locale + // source was a CONTRACT question that card did not decide. #15711 ruled + // it: the authored label IS the default locale's text. + const enFallback = () => + i18nFor({ bundles: { 'zh-CN': {}, en: EN_DATA }, defaultLocale: 'zh-CN', fallbackLocale: 'en' }); + + it('GET /meta/object/:name serves the authored 填报单 for a zh-CN request', async () => { + expect(labelsOf(await readItem(makeRest(enFallback()), 'object', 'kpi_entry_sheet', 'zh-CN'))).toEqual(AUTHORED); + }); + + it('GET /meta/app/:name, the list read and the types listing agree', async () => { + expect((await readItem(makeRest(enFallback()), 'app', 'kpi_app', 'zh-CN')).label).toBe('KPI 考核管理'); + const [sheet] = await readList(makeRest(enFallback()), 'object', 'zh-CN'); + expect(labelsOf(sheet)).toEqual(AUTHORED); + const body = await readTypes(makeRest(enFallback()), 'zh-CN'); + expect(body.entries.find((e: any) => e.type === 'object').label).toBe('对象'); + }); + + it('with NO Accept-Language the request falls to the default locale and answers authored', async () => { + expect(labelsOf(await readItem(makeRest(enFallback()), 'object', 'kpi_entry_sheet', undefined))).toEqual(AUTHORED); + }); + + it('a NON-default request still walks fr → en bundle → authored', async () => { + // `fallbackLocale` keeps its full meaning for every other locale. + expect(labelsOf(await readItem(makeRest(enFallback()), 'object', 'kpi_entry_sheet', 'fr'))).toEqual(ENGLISH); + expect((await readItem(makeRest(enFallback()), 'app', 'kpi_app', 'fr')).label).toBe('KPI Assessment'); + }); + + it('an en request on the same stack still gets the en bundle', async () => { + expect(labelsOf(await readItem(makeRest(enFallback()), 'object', 'kpi_entry_sheet', 'en'))).toEqual(ENGLISH); + }); + + it('a shipped zh-CN bundle still wins; a key it omits is authored, never en', async () => { + const withZh = i18nFor({ bundles: { 'zh-CN': ZH_DATA, en: EN_DATA }, defaultLocale: 'zh-CN', fallbackLocale: 'en' }); + const item = await readItem(makeRest(withZh), 'object', 'kpi_entry_sheet', 'zh-CN'); + expect(item.label).toBe('填报单(bundle)'); + expect(item.fields.name.label).toBe('填报单名称'); }); }); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 3be127592e..9b7fd544ff 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3187,11 +3187,18 @@ export class RestServer { /** * [#14882] The `ResolveOptions` every metadata-document translation in - * this server hands `@objectstack/spec/system`: the request's locale plus - * the deployment's DECLARED fallback chain. + * this server hands `@objectstack/spec/system`: the request's locale, the + * deployment's DECLARED fallback chain, and [#15711] the deployment's + * DEFAULT locale. This is the single seam; every resolver call in this + * file spreads it, so a rule about which locale answers lives in the + * resolvers (`packages/spec`) and this seam only threads declarations. + * + * The resolvers walk `requested locale → fallbackChain → authored label` + * for a NON-default request, and answer `requested locale → authored + * label` for a request that names `defaultLocale` (the authored label IS + * the default locale's text — ruled on #15711). A caller that declares + * no chain gets no chain: the resolver's own default is `[]`, not `en`. * - * The resolvers walk `requested locale → fallbackChain → authored label`, - * and default the chain to a literal `['en']` when a caller passes none. * Every seam here used to pass none, so the stack's `i18n.fallbackLocale` * never reached the chain: a `zh-CN` workspace that shipped a courtesy * `en` bundle served `Entry Sheet` to a `zh-CN` request, ahead of its own @@ -3201,21 +3208,32 @@ export class RestServer { * locale its own `t()` falls back to, which `I18nServicePlugin` receives * as `fallbackLocale || defaultLocale || 'en'` from the stack config — so * a bundle label and a `t()` message agree on which locale comes second. - * Feature-detected like `getPackagedObjectBase`: a service that does not - * declare a fallback (the method is optional on `II18nService`, and the - * core in-memory fallback has no declared one) gets NO chain, so the - * resolver's own default applies exactly as before — the serving layer - * threads a declaration, it does not invent one. ⛔ Not derived from - * `getDefaultLocale()`: that would decide, for a stack declaring - * `defaultLocale: 'zh-CN'` with `fallbackLocale: 'en'`, whether the - * authored label or the `en` bundle answers a `zh-CN` request — a - * contract question this seam must not answer on its own. + * The default locale is read from `getDefaultLocale()`, the same accessor + * `extractLocale` already answers a header-less request from, so the + * request that falls to the default and the rule that recognises the + * default read one value. Both are feature-detected like + * `getPackagedObjectBase` (both methods are optional on `II18nService`; + * the core in-memory fallback declares neither): a service that does not + * declare a fallback gets NO chain, and one that does not declare a + * default gets NO default — the serving layer threads a declaration, it + * never invents one, and it never answers `'en'` on a provider's behalf. + * + * With `defaultLocale` threaded, a stack declaring `defaultLocale: + * 'zh-CN'` with a reflexive `fallbackLocale: 'en'` serves its authored + * `填报单` to a `zh-CN` request and its `en` bundle to every other one — + * the contract question this docblock once refused to answer on its own + * (#14882 left it to #15711, which ruled it). */ - private static translateOptionsFor(i18n: any, locale: string): { locale: string; fallbackChain?: string[] } { + private static translateOptionsFor( + i18n: any, + locale: string, + ): { locale: string; fallbackChain?: string[]; defaultLocale?: string } { const fallback = i18n && typeof i18n.getFallbackLocale === 'function' ? i18n.getFallbackLocale() : undefined; - return typeof fallback === 'string' && fallback.length > 0 - ? { locale, fallbackChain: [fallback] } - : { locale }; + const def = i18n && typeof i18n.getDefaultLocale === 'function' ? i18n.getDefaultLocale() : undefined; + const opts: { locale: string; fallbackChain?: string[]; defaultLocale?: string } = { locale }; + if (typeof fallback === 'string' && fallback.length > 0) opts.fallbackChain = [fallback]; + if (typeof def === 'string' && def.length > 0) opts.defaultLocale = def; + return opts; } /** diff --git a/packages/spec/src/contracts/i18n-service.ts b/packages/spec/src/contracts/i18n-service.ts index 7ff1bf45b4..7b30cc0409 100644 --- a/packages/spec/src/contracts/i18n-service.ts +++ b/packages/spec/src/contracts/i18n-service.ts @@ -47,6 +47,13 @@ export interface II18nService { /** * Get the current default locale + * + * [#15711] Also the language the deployment's metadata labels are + * authored in: the serving layer threads it into the document + * translators' `ResolveOptions.defaultLocale` (`@objectstack/spec/system`), + * so a request for the default locale answers with the authored label + * instead of walking the declared fallback chain — the authored label IS + * the default locale's text. Absent, no request is treated as the default. * @returns BCP-47 locale code */ getDefaultLocale?(): string; @@ -80,7 +87,9 @@ export interface II18nService { * second. `undefined` means NOTHING was declared — a provider that has no * fallback of its own omits the method or answers `undefined`, and the * serving layer then leaves the resolver's own default in place rather - * than inventing a chain. + * than inventing a chain. [#15711] That default is `[]` (requested + * locale, then the authored label), and a request for + * {@link getDefaultLocale} never walks this chain at all. * * @returns BCP-47 locale code, or `undefined` when no fallback is declared */ diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index d2640dace7..7ddb91759c 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -1357,11 +1357,13 @@ describe('translatePage', () => { it('resolves key by key across the locale chain, not entry by entry', () => { // `zh-CN` translates only `title` for this id; `description` must still // fall back to `en` rather than being dropped because the zh entry won. + // [#15711] The chain is DECLARED: a chain-less caller now gets the + // authored text for the omitted key, never a literal `en`. const partial: TranslationBundle = { 'zh-CN': { pages: { sales_home_page: { components: { ai_briefing: { title: '询问 AI 助手' } } } } }, en: { pages: { sales_home_page: { components: { ai_briefing: { description: 'Open the assistant panel.' } } } } }, }; - const out = translatePage(homePage(), partial, { locale: 'zh-CN' }); + const out = translatePage(homePage(), partial, { locale: 'zh-CN', fallbackChain: ['en'] }); expect(byId(out, 'ai_briefing').properties.title).toBe('询问 AI 助手'); expect(byId(out, 'ai_briefing').properties.description).toBe('Open the assistant panel.'); }); @@ -2933,13 +2935,15 @@ describe('translateFlow (#11287)', () => { }); it('resolves KEY BY KEY across the locale chain — a partial zh entry still falls back to en', () => { + // [#15711] `en` is DECLARED on the chain; a chain-less caller now gets the + // authored text for the omitted key, never a literal `en`. const partialZh: FlowTestBundle = { 'zh-CN': { flows: { lead_conversion: { screens: { screen_1: { title: '转化详情' } } } }, }, en: bundle.en, }; - const out = translateFlow(leadConversion(), partialZh, { locale: 'zh-CN' }); + const out = translateFlow(leadConversion(), partialZh, { locale: 'zh-CN', fallbackChain: ['en'] }); const screen = screenOf(out); expect(screen.config.title).toBe('转化详情'); expect(screen.config.fields.find((f: any) => f.name === 'createOpportunity').label) @@ -3589,26 +3593,73 @@ describe('#14882 — a declared fallback chain, at the resolver', () => { expect(translateMetadataDocument('object', SHEET, withZh, ZH_WORKSPACE).label).toBe('填报单(bundle)'); }); - it('a chain that DECLARES en still consults en before the authored label', () => { - // A stack declaring `defaultLocale: 'zh-CN'` with `fallbackLocale: 'en'` - // and no zh-CN bundle. Pinned as it answers today — `en` bundle text — - // because whether the authored label is the default-locale source (and - // so should outrank a declared fallback's bundle) is a CONTRACT question - // this card does not decide. #14882 changes only which chain the serving - // layer hands in; a declared `en` is honoured exactly as it reads. + // ------------------------------------------------------------------------- + // #15711 — ruled A: the authored label IS the default-locale text + // ------------------------------------------------------------------------- + + it('[#15711] a chain that DECLARES en does not outrank the authored label for a default-locale request', () => { + // A stack declaring `defaultLocale: 'zh-CN'` with a reflexive + // `fallbackLocale: 'en'` and no zh-CN bundle — the trap #14882 pinned as + // it answered then (`Entry Sheet`, the declared `en` outranking the + // author). Ruled on #15711: the authored label is the default locale's + // text, so a `zh-CN` request never reaches the declared `en`. The chain + // is untouched; what changed is that the request names the default. + const ruled = { locale: 'zh-CN', fallbackChain: ['en'], defaultLocale: 'zh-CN' }; + expect(labelsOf(translateMetadataDocument('object', SHEET, EN_ONLY, ruled))).toEqual(AUTHORED); + expect(translateMetadataDocument('app', KPI_APP, EN_ONLY, ruled).label).toBe('KPI 考核管理'); + // Control — the same options WITHOUT `defaultLocale` still walk the + // chain: the rule is keyed on the declaration, not on the tag's spelling. expect(translateMetadataDocument('object', SHEET, EN_ONLY, { locale: 'zh-CN', fallbackChain: ['en'] }).label) .toBe('Entry Sheet'); }); - it("a caller that declares NO chain keeps the resolver's literal en default", () => { - // The pre-#14882 shape every zh-CN request walked, kept green on purpose: - // the resolver's default is unchanged by this card (its only production - // caller now declares a chain), so a caller passing nothing still gets - // `['en']`. Whether that default should become "no fallback" is singled - // out for contract review, not decided here. - expect(translateMetadataDocument('object', SHEET, EN_ONLY, { locale: 'zh-CN' }).label).toBe('Entry Sheet'); - // An explicit empty chain is "requested locale, then the authored label". + it('[#15711] a NON-default request still walks the declared chain: fr → en bundle → authored', () => { + // `fallbackLocale` keeps its full meaning for every non-default request. + const fr = { locale: 'fr', fallbackChain: ['en'], defaultLocale: 'zh-CN' }; + expect(labelsOf(translateMetadataDocument('object', SHEET, EN_ONLY, fr))).toEqual(ENGLISH); + expect(translateMetadataDocument('app', KPI_APP, EN_ONLY, fr).label).toBe('KPI Assessment'); + // and an `en` request on the same stack still gets the courtesy `en` bundle. + const en = { locale: 'en', fallbackChain: ['en'], defaultLocale: 'zh-CN' }; + expect(labelsOf(translateMetadataDocument('object', SHEET, EN_ONLY, en))).toEqual(ENGLISH); + }); + + it('[#15711] a default-locale bundle, when shipped, still wins over the authored label', () => { + // Only the CHAIN is skipped: the requested locale's own bundle is + // consulted first, so the reporter's `os i18n extract --locales=zh-CN` + // layout keeps working — optional now, not dead. + const withZh: TranslationBundle = { + ...EN_ONLY, + 'zh-CN': { objects: { kpi_entry_sheet: { label: '填报单(bundle)' } } }, + }; + const ruled = { locale: 'zh-CN', fallbackChain: ['en'], defaultLocale: 'zh-CN' }; + const out = translateMetadataDocument('object', SHEET, withZh, ruled); + expect(out.label).toBe('填报单(bundle)'); + // A key the zh-CN bundle omits resolves to the AUTHORED text, never to `en`. + expect(out.fields.name.label).toBe('填报单名称'); + }); + + it('[#15711] the default-locale match is a BCP-47 comparison: zh-cn names zh-CN', () => { + const lower = { locale: 'zh-cn', fallbackChain: ['en'], defaultLocale: 'zh-CN' }; + expect(translateMetadataDocument('object', SHEET, EN_ONLY, lower).label).toBe('填报单'); + }); + + it("[#15711] a caller that declares NO chain gets 'requested locale, then the authored label' — no literal en", () => { + // The ruling's second facet: `fallbackChain ?? ['en']` became + // `fallbackChain ?? []`. A chain-less caller (a host outside this repo; + // the core in-memory i18n fallback, #15694) no longer has `en` consulted + // because a literal said so — the `en` bundle answers only when `en` is + // requested or DECLARED. Pinned on a NON-default request too, where the + // default-locale rule cannot be the reason. + expect(translateMetadataDocument('object', SHEET, EN_ONLY, { locale: 'zh-CN' }).label).toBe('填报单'); + expect(labelsOf(translateMetadataDocument('object', SHEET, EN_ONLY, { locale: 'fr' }))).toEqual(AUTHORED); + expect(translateMetadataDocument('app', KPI_APP, EN_ONLY, { locale: 'fr' }).label).toBe('KPI 考核管理'); + // An explicit empty chain reads the same. expect(translateMetadataDocument('object', SHEET, EN_ONLY, { locale: 'zh-CN', fallbackChain: [] }).label) .toBe('填报单'); + // Controls — an `en` request still finds its own bundle with no chain at + // all, and a DECLARED `en` is still consulted for a non-default request. + expect(translateMetadataDocument('object', SHEET, EN_ONLY, { locale: 'en' }).label).toBe('Entry Sheet'); + expect(translateMetadataDocument('object', SHEET, EN_ONLY, { locale: 'fr', fallbackChain: ['en'] }).label) + .toBe('Entry Sheet'); }); }); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 4385ec7aac..4c95369c62 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -32,9 +32,11 @@ * .successMessage / .params..* * * Lookup order: requested locale → each entry of `fallbackChain` → literal - * `label` from the metadata. Helpers never throw — they - * always return at minimum the metadata literal so unconfigured languages - * gracefully degrade. + * `label` from the metadata — except that a request for the deployment's + * `defaultLocale` never walks the chain: the authored label IS the default + * locale's text (#15711), so it answers right after the requested locale's + * own bundle. Helpers never throw — they always return at minimum the + * metadata literal so unconfigured languages gracefully degrade. * * ## The OTHER half of `I18nLabel`, and where it lives * @@ -203,13 +205,37 @@ export interface ResolveOptions { * here, so a `zh-CN` workspace resolves `zh-CN → zh-CN → authored label` * and a courtesy `en` bundle is consulted only when `en` is requested. * Every entry is consulted BEFORE the authored label, so an entry the - * deployment did not declare is a locale that can outrank the author. + * deployment did not declare is a locale that can outrank the author — + * for every NON-default request. A request for {@link defaultLocale} never + * reaches the chain (#15711). * - * Defaults to `['en']` only when the caller declares nothing at all - * (no `fallbackChain` key); an explicit `[]` means "requested locale, then - * the authored label". + * [#15711] Defaults to `[]` — "requested locale, then the authored label" + * — when the caller declares nothing at all (no `fallbackChain` key), and + * an explicit `[]` reads the same. Nothing falls to `en` because a literal + * said so: a chain is consulted only when someone declared it. (Before + * #15711 a chain-less caller got a literal `['en']`.) */ fallbackChain?: string[]; + /** + * [#15711] The deployment's DEFAULT locale — the language its metadata + * labels are authored in (`i18n.defaultLocale`). When `locale` names it + * (BCP-47 tags compare case-insensitively, the same rule + * {@link resolveBundleLocale} applies), the resolvers consult the requested + * locale's own bundle and then answer with the authored label: the chain + * is NOT walked, because the authored label IS the default locale's text. + * That is what `os i18n check` already claims when it reports the default + * locale as fully covered by authored text, and what a stack declaring + * `defaultLocale: 'zh-CN'` with a reflexive `fallbackLocale: 'en'` needs so + * its courtesy `en` bundle cannot outrank the author for a `zh-CN` request + * (ruled on #15711 — the contract question #14882 left open). + * + * Supplied by the caller that can see the declaration: the serving layer + * reads `II18nService.getDefaultLocale()`. Absent, no request is the + * default one and every request walks `locale → fallbackChain → authored`. + * A bundle entry for the default locale still wins when one is shipped + * (`os i18n extract --locales=` keeps working; it is optional). + */ + defaultLocale?: string; } /** @@ -293,9 +319,30 @@ function pickData( return resolved !== undefined ? bundle[resolved] : undefined; } +/** + * [#15711] Whether `locale` names the deployment's default locale. BCP-47 + * tags are case-insensitive (`zh-cn` names `zh-CN`), the same rule + * {@link resolveBundleLocale} applies; a different tag (`zh` against + * `zh-CN`) is a different locale. + */ +function isDefaultLocale(locale: string, defaultLocale: string | undefined): boolean { + return typeof defaultLocale === 'string' + && defaultLocale.length > 0 + && locale.toLowerCase() === defaultLocale.toLowerCase(); +} + +/** + * The locales a lookup consults, in order: the requested locale, then the + * DECLARED `fallbackChain` — unless the requested locale is the deployment's + * `defaultLocale`, in which case the chain is skipped and the authored label + * answers right after the requested locale's own bundle (#15711: the + * authored label is the default locale's text). A caller that declares no + * chain gets the requested locale alone — nothing is invented (#15711's + * second facet; the default used to be a literal `['en']`). + */ function localeChain(opts?: ResolveOptions): string[] { const locale = opts?.locale ?? 'en'; - const fallbacks = opts?.fallbackChain ?? ['en']; + const fallbacks = isDefaultLocale(locale, opts?.defaultLocale) ? [] : (opts?.fallbackChain ?? []); // Preserve order, drop duplicates. const seen = new Set(); const chain: string[] = []; @@ -1398,9 +1445,9 @@ export type PageComponentCopyKey = typeof PAGE_COMPONENT_COPY_KEYS[number]; * Per-component copy for one component id, resolved across the locale chain. * * Resolved KEY BY KEY rather than by taking the first locale that has an entry - * for the id: a partially-translated `zh` entry must still fall back to `en` - * for the keys it omits, which is how every other resolver on this surface - * behaves. + * for the id: a partially-translated `zh` entry must still fall back to the + * next locale on the DECLARED chain (`en` in the pins) for the keys it omits, + * which is how every other resolver on this surface behaves. */ function lookupPageComponentCopy( bundle: TranslationBundle | undefined, @@ -2916,7 +2963,7 @@ function lookupFlowLabel( /** * Per-screen copy for one node id, resolved across the locale chain — KEY BY * KEY, like {@link lookupPageComponentCopy}: a partially-translated `zh` entry - * must still fall back to `en` for the keys it omits. + * must still fall back to the next DECLARED locale for the keys it omits. */ function lookupFlowScreenCopy( bundle: TranslationBundle | undefined,