Skip to content

Commit 0e00d36

Browse files
fix(spec): a locale bundle's page label no longer replaces a page:header title that says something else (#22534)
Fixes #22508 Clause-②: no ## What changes `translatePage` (`packages/spec/src/system/i18n-resolver.ts`) used to resolve a root-level `page:header` title as the bundle's `pages.NAME.title`, else the bundle's `pages.NAME.label`, and write it over whatever the header authored. A locale pack that translated the page label and left out the title therefore replaced a record page's dynamic `'{name}'` heading with the page's static name in that locale, with nothing warning at build, lint or serve. The header title now resolves through ONE function, `resolvePageHeaderTitle`, called from the one visitor both roots pass through (a region's entry and a `slots.SLOT` entry both arrive with `nested: false`), so the two roots cannot disagree: 1. the bundle's `pages.NAME.title`, across the locale chain; 2. the header's AUTHORED title, left as written (a template, a plain string, or an inline locale map); 3. the bundle's `pages.NAME.label`, only where the header authors no title, or where its title restates the page's own authored `label`. The declared contract is restated with that limit in the same PR: the `pages` JSDoc and the `pages.NAME.title` describe in `translation.zod.ts` (and the regenerated `content/docs/references/system/translation.mdx`), the `translatePage` docblock, the hand-written `content/docs/ui/translations.mdx` callout, and the `pages` row of `packages/spec/liveness/translation.json`. ## One deviation from the triage text, measured, and why Triage `6085431021` reads: "The bundle's page `label` stands in only where the authored header has no title of its own." Step 3 above adds one case to that: a header title that restates the page's own `label`. The triage expected nothing to rely on the label replacing an authored title. The measurement says something does: - `os i18n extract` (`packages/cli/src/utils/i18n-extract.ts`, the page-header pass) deliberately offers NO `pages.NAME.title` key when the header title restates the page label: "`title` duplicating `label` is the common case and resolves via the label fallback, only emit it when the two genuinely differ" (`sameAuthored`). Every skeleton it has written relies on the label reaching such a header. - In this repo, three shipped pages are that shape: `marketplace_installed`, `cloud_connection_settings` (`@objectstack/cloud-connection`) and `connect_agent` (`@objectstack/mcp`). Each authors a header title equal to its page label, and `@objectstack/platform-objects`' bundles carry `label` + `subtitle` and no `title` for them in all four locales (en, zh-CN, ja-JP, es-ES). - Applied literally, the triage sentence turns those three Setup headers back to English in zh-CN / ja-JP / es-ES. The ablation below measures it: `packages/cli/test/platform-page-i18n-parity.test.ts` ("rewrites the page:header copy end-to-end for a non-English locale") goes red for all three. Step 3's second case keeps the triage's own principle ("A bundle supplies a translation of an attribute; it does not swap one attribute for another over authored text"): when the authored title IS the page label's text, the label's translation is a translation of that very text. It also keeps the resolver and the extractor a matched pair: the extractor omits `title` exactly when the resolver will read the label for it. The comparison (`sameAuthoredText`) mirrors the extractor's `sameAuthored` (strings by text, inline maps as sets of non-empty entries). The seat may rule otherwise; the alternative is in the report's `open_questions`. What still changes for an author (named in the changeset): a header whose title says something else than the page label, in a locale whose pack carries `label` but no `title`, now shows the authored title. In this repo that population is zero (H3 below); the reporting app (hotcrm) already writes `title` in every pack. ## Measurements (dispatch hypotheses) - **H1, holds.** `translatePage` is reached only through `translateMetadataDocument('page', …)` (the `METADATA_DOCUMENT_TRANSLATORS` table). Its only runtime callers are `translateMetaList` and `translateMetaDocument` in `packages/rest/src/meta-item-read-gate.ts`, which serve `GET /api/v1/meta/page` and `GET /api/v1/meta/page/NAME`. The console reads those doors: objectui's `useObjectLabel` resolves only `pages.NAME.label` client-side (the page's nav label) and nothing reads `pages.NAME.title` there. One fix covers both reach points the card names. - **H2, holds.** The authored title reaches `translatePage` as written in `properties.title` (the REST door serves the stored or packaged document; `PageComponentSchema` is strict, so `properties` is the only place a title can be authored). Pinned at both roots: a template (`'{name}'`), a plain string, an inline locale map, absent (`{}`, `''`), and a title restating the label (string and map forms). - **H3, does not hold as stated.** In-repo bundles with a `pages` group: `@objectstack/platform-objects` (4 locales, 6 page entries each) and `examples/app-showcase` (zh-CN, 4 entries, `components` only). Pages whose bundle carries `label` and no `title` while a root-level header authors a title: **3** (the three Setup pages above), all of them title-restates-label, all kept translated by step 3. Pages of the defect shape (header title differs from the label, pack has `label` and no `title`): **0**. The `sys_*_detail` pages carry a label-only entry but no `page:header`. app-crm, app-todo and app-multi-package carry no page translations. Clause-② measured: no accept set or public surface widens. No schema, key, accepted value, default or export changes; the helpers are module-private. What changes is what a translated page RENDERS, which is resolver behaviour. The one schema-adjacent change is the `pages.NAME.title` describe string. ## Tests All readings below are at head `194157f5e1` (the last commit; no commit followed any of them). Wall-clock figures are shared-box seconds. - `pnpm --filter @objectstack/spec exec vitest run --project local`: 631 files, 18842 passed, 1 todo. `--project repo`: 54 files, 915 passed. `src/system/i18n-resolver.test.ts` alone: 327 passed (the new `#22508` block is 13 cases: six per root plus the REST-door case). - `pnpm --filter @objectstack/spec typecheck` (tsc, scripts typecheck, `check:test-typecheck`): exit 0. - `@objectstack/cli` unit layer, the two files that feed `translatePage` and the extractor: `test/platform-page-i18n-parity.test.ts`, `test/i18n-extract.test.ts`, 51 passed (CLI resolves `@objectstack/spec` through `dist`, built at this head). - `@objectstack/rest` (the one runtime caller): `pnpm --filter @objectstack/rest exec vitest run`: 273 files, 5166 passed, 327 skipped (rest resolves `@objectstack/spec` through `dist`, rebuilt clean after the ablation). - Fixture triage (one fixture): `regionHeaderWithId()` in the batch #58 block omitted `PageSchema`'s required `label` while its header restated `homePage()`'s label. Disposition: add the declaration (`label: 'Sales Home'`), so the fixture is the shape it was cut from. No other fixture in the rule's consumer radius changed (platform-objects, cli, lint, rest fixtures read; only the CLI parity test exercises the header title, and it stays green). - eslint, narrowed and proven: `eslint --no-inline-config --format json` on the three changed TS files reports 3 files, 0 errors, 0 warnings. Population: `eslint.config.mjs` lints `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`, so the `.md`, `.mdx` and `.json` files in this diff are outside it. Invariance: the config never enables type-aware linting (no `parserOptions.project`, no typed rules, stated in the config itself), so this diff cannot move a verdict on an untouched file. ### Ablations (one-shot, both legs restored and proven) - Leg 1, the old fallback restored (`resolvePageHeaderTitle` answering bundle title, else bundle label, whatever the header authors), through `scripts/ablation-replace.mjs` (anchor hit 1, blob changed): `i18n-resolver.test.ts` 7 failed / 320 passed. The 7 are exactly the predicted ones: template, plain string and inline map at each root, plus the REST-door case. The controls (pack with `title` replaces; no authored title shows the label; title restating the label) stayed green. Restored: blob equals HEAD, `git diff HEAD` empty. - Leg 2, the triage sentence applied literally (the restates-the-label disjunct removed), spec rebuilt, `ablation-dist-preflight` found the marker in `dist`: spec 6 failed / 321 passed (the two restates-label pins, the original `connect_agent` pins and the batch #58 pins), and `platform-page-i18n-parity.test.ts` "rewrites the page:header copy end-to-end for a non-English locale" went red at its first page, `marketplace_installed` (`titleChanged: false`: the zh-CN header stayed "Installed Apps"). Restore leg: source blob equals HEAD, spec rebuilt, preflight `--absent`: marker absent from all 232 built files, tree clean. ### Gates `node scripts/pm/dispatch-gates.mjs --commands` derived 111 families from this diff (the 85 the dispatch named plus 26 the docs and changeset paths add). All 111 ran and recorded an exit code; `--ran` reconciles 111 derived, 111 run, 0 NOT MEASURED. 110 exit 0. One exits 1: `pnpm check:platform-checklist`, on `docs/qa/platform-checklist/areas/attachments-storage.json`, whose anchor `attachment-access-hooks.ts#canEdit` lost its declaration in `ce3d0ad419` (#22513). That is red on `main` at this branch's base and at `origin/main` `ee8751d41e` too; this diff touches neither file. Seven gates first refused with PREREQUISITE NOT MET (exit 3, unbuilt `lint` / `client-react`); they were re-run after the build and pass, as were the dist-reading gates after the ablation's restore rebuild. ## Acceptance notes - **Ruled control, recorded rather than changed:** a root-level `page:header` with NO authored title still receives the bundle's page `label` (the triage's third pin). On a record page that is not neutral: `PageHeaderProps.title` documents "omit to let the renderer derive the heading from the record", and objectui's `PageHeaderRenderer` draws `properties.title` when one is present, so a pack that translates the page label replaces a record-derived heading with the static label, the same failure as this card in another shape. In this repo the population is zero: no root-level `page:header` without a title sits on a page with a translated label (the `sys_*_detail` pages carry no header). A Studio-seeded record page stores its synthesized header without a title, so it can be reached outside this repo; measured only by reading, not at a door. Raised as an open question in the report, not changed here. - **Locale chain, unchanged:** step 1 walks the request's whole locale chain (the locale, then its declared `fallbackChain`) before step 2. So a request whose own pack has only `label`, while a pack on its fallback chain has `title`, shows that fallback title over the authored one. That is still the bundle's title, which the triage put first. - **Pre-existing red on main:** `pnpm check:platform-checklist` (see Gates). Its carrier is whoever re-anchors the `attachments-storage` checklist item after `ce3d0ad419`; not this PR's file surface. - Generated output: `content/docs/references/system/translation.mdx` was regenerated by `gen:docs` from the new describe (`check:generated` named exactly that one artifact stale). No other generated artifact moved; `authorable-surface.base.json` untouched. --- _Generated by [Claude Code](https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7731d70 commit 0e00d36

7 files changed

Lines changed: 208 additions & 18 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
A locale bundle's page `label` no longer replaces a `page:header` title that says something else, such as a dynamic `'{name}'`
6+
7+
Clause-②: no
8+
9+
`translatePage` resolved a root-level `page:header` title as the bundle's `pages.<name>.title`, else the bundle's `pages.<name>.label`, and wrote it over whatever the header authored. A pack that translated the page label and left out the title therefore turned a record page's `'{name}'` heading into the page's static name in that locale, with no warning at build, lint or serve.
10+
11+
The header title now resolves in this order, at a region root and a `slots.<slot>` root alike:
12+
13+
1. the bundle's `pages.<name>.title`;
14+
2. the title the header authors (a template, a plain string or an inline locale map), left as written;
15+
3. the bundle's `pages.<name>.label`, only where the header authors no title, or where its title restates the page's own `label`. That second case is the one `os i18n extract` already covers by offering `label` alone, so those headers keep their translation.
16+
17+
The doors that serve the translated page are `GET /api/v1/meta/page/:name` and `GET /api/v1/meta/page`, through `translateMetadataDocument('page', …)`, and the console reads them. What changes for an author: a header whose title differs from the page label, in a locale whose pack carries `label` but no `title`, now shows the authored title. To translate that header, add `pages.<name>.title` to the pack (`os i18n extract` offers that key for such a header). No schema, key, accepted value or export changes. The `pages.<name>.title` describe and the reference page now state this order.

‎content/docs/references/system/translation.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ Translation data for a single object
249249
| :--- | :--- | :--- | :--- |
250250
| **label** | `string` | optional | Translated page label (nav / breadcrumb) |
251251
| **description** | `string` | optional | Translated page description |
252-
| **title** | `string` | optional | Translated `page:header` title (defaults to `label`) |
252+
| **title** | `string` | optional | Translated `page:header` title. When omitted the header keeps its authored title; `label` stands in only where the header authors no title or its title restates the page label |
253253
| **subtitle** | `string` | optional | Translated `page:header` subtitle |
254254
| **components** | `Record<string, { title?: string; description?: string; label?: string; placeholder?: string; … }>` | optional | Per-component copy keyed by component id (`PageComponentSchema.id`) |
255255

@@ -448,7 +448,7 @@ Translation data for a single object
448448
| :--- | :--- | :--- | :--- |
449449
| **label** | `string` | optional | Translated page label (nav / breadcrumb) |
450450
| **description** | `string` | optional | Translated page description |
451-
| **title** | `string` | optional | Translated `page:header` title (defaults to `label`) |
451+
| **title** | `string` | optional | Translated `page:header` title. When omitted the header keeps its authored title; `label` stands in only where the header authors no title or its title restates the page label |
452452
| **subtitle** | `string` | optional | Translated `page:header` subtitle |
453453
| **components** | `Record<string, { title?: string; description?: string; label?: string; placeholder?: string; … }>` | optional | Per-component copy keyed by component id (`PageComponentSchema.id`) |
454454

@@ -608,7 +608,7 @@ Translation data for a single object
608608
| :--- | :--- | :--- | :--- |
609609
| **label** | `string` | optional | Translated page label (nav / breadcrumb) |
610610
| **description** | `string` | optional | Translated page description |
611-
| **title** | `string` | optional | Translated `page:header` title (defaults to `label`) |
611+
| **title** | `string` | optional | Translated `page:header` title. When omitted the header keeps its authored title; `label` stands in only where the header authors no title or its title restates the page label |
612612
| **subtitle** | `string` | optional | Translated `page:header` subtitle |
613613
| **components** | `Record<string, { title?: string; description?: string; label?: string; placeholder?: string; … }>` | optional | Per-component copy keyed by component id (`PageComponentSchema.id`) |
614614

‎content/docs/ui/translations.mdx‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,13 @@ saying so.
126126
**Page headers are keyed by page name (#3589).** A page's `page:header`
127127
component has no stable id, so its `properties.title` / `properties.subtitle`
128128
are addressed through the page itself: `pages.<name>.title` / `subtitle`.
129-
`title` falls back to `pages.<name>.label`, so a page whose header title
130-
matches its nav label needs only `label`. Every `page:header` in the page's
131-
regions receives the same copy.
129+
When `title` is omitted, the header keeps the title its author wrote, and
130+
`pages.<name>.label` stands in only where the header authors no title or its
131+
title restates the page's `label`. So a page whose header title matches its
132+
nav label needs only `label`, while a header whose title says something else,
133+
such as a dynamic `'{name}'`, keeps it until the bundle supplies a `title`.
134+
Every root-level `page:header` (a region's entry or a `slots.<slot>` entry)
135+
is resolved by this one rule.
132136
</Callout>
133137

134138
<Callout type="info">

‎packages/spec/liveness/translation.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,8 @@
109109
"pages": {
110110
"status": "live",
111111
"verifiedAt": "2026-08-28",
112-
"evidence": "packages/spec/src/system/i18n-resolver.ts#lookupPageAttr (`pages.<name>.<attr>` — label/description/title/subtitle, title falling back to label); packages/spec/src/system/i18n-resolver.ts#lookupPageComponentCopy (`pages.<name>.components.<id>` — header copy keyed by page name because page:header instances carry no stable id)",
113-
"note": "translatePage: label/description/title/subtitle (title falls back to label; header copy keyed by page name because page:header instances carry no stable id). 2026-08-28: RE-ANCHORED (commit 8f10a79f7) and REPOINTED — `:636` had rotted onto `function lookupAppAttr`, the APP group's reader, ~226 lines above the page ones. Re-closed by hand against 8cb96ec41."
112+
"evidence": "packages/spec/src/system/i18n-resolver.ts#lookupPageAttr (`pages.<name>.<attr>` — label/description/title/subtitle; an omitted title keeps the authored header title, the label standing in only where the header authors none or restates the page label); packages/spec/src/system/i18n-resolver.ts#lookupPageComponentCopy (`pages.<name>.components.<id>` — header copy keyed by page name because page:header instances carry no stable id)",
113+
"note": "translatePage: label/description/title/subtitle (an omitted title keeps the authored header title, the label standing in only where the header authors none or restates the page label; header copy keyed by page name because page:header instances carry no stable id). 2026-08-28: RE-ANCHORED (commit 8f10a79f7) and REPOINTED — `:636` had rotted onto `function lookupAppAttr`, the APP group's reader, ~226 lines above the page ones. Re-closed by hand against 8cb96ec41."
114114
},
115115
"flows": {
116116
"status": "live",

‎packages/spec/src/system/i18n-resolver.test.ts‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1724,8 +1724,12 @@ describe('translatePage', () => {
17241724
// widened to the open bag it is: the overlay adds keys the literal does
17251725
// not spell out, and inferring it as `{title}` alone would make reading
17261726
// the result a type error.
1727+
// `label` is `PageSchema`'s required key, and the header title restates
1728+
// it, exactly as on `homePage()`: that is the shape whose title the
1729+
// page-name route answers from `pages.<name>.label` (#22508).
17271730
const regionHeaderWithId = () => ({
17281731
name: 'sales_home_page',
1732+
label: 'Sales Home',
17291733
regions: [{
17301734
name: 'header',
17311735
components: [{
@@ -2722,6 +2726,93 @@ describe('resolveTabLabel — filter-preset tab labels, keyed under `_tabs`', ()
27222726
});
27232727
});
27242728

2729+
// #22508: the header title used to be "the bundle's page `title`, else the
2730+
// bundle's page `label`", spread over whatever the header authored. So a pack
2731+
// carrying `label` and no `title` (the natural partial translation) turned a
2732+
// record page's dynamic `'{name}'` heading into the page's static name in that
2733+
// locale, with no diagnostic. The order is now: bundle `title`, then the
2734+
// AUTHORED title, then the bundle `label` only where the header authors no
2735+
// title or its title restates the page's own `label`. Pinned at both roots,
2736+
// because both reach the one resolution through the same visitor.
2737+
describe('translatePage — a bundle label never replaces a header title that says something else', () => {
2738+
const PAGE = 'account_detail_page';
2739+
type Root = 'region' | 'slot';
2740+
const ROOTS: readonly Root[] = ['region', 'slot'];
2741+
2742+
const pageWith = (root: Root, header: Record<string, unknown>, label: any = 'Account Detail'): any => {
2743+
const component = { type: 'page:header', properties: header };
2744+
return root === 'region'
2745+
? { name: PAGE, label, regions: [{ name: 'header', components: [component] }] }
2746+
: { name: PAGE, label, kind: 'slotted', regions: [], slots: { header: component } };
2747+
};
2748+
const headerOf = (root: Root, page: any): Record<string, unknown> =>
2749+
root === 'region' ? page.regions[0].components[0].properties : page.slots.header.properties;
2750+
2751+
/** The measured hotcrm shape: `label` in the pack, no `title`. */
2752+
const labelOnly: TranslationBundle = {
2753+
en: { pages: { [PAGE]: { label: 'Account Detail' } } },
2754+
'zh-CN': { pages: { [PAGE]: { label: '客户详情', subtitle: '客户概览' } } },
2755+
};
2756+
const withTitle: TranslationBundle = {
2757+
'zh-CN': { pages: { [PAGE]: { label: '客户详情', title: '客户 {name}' } } },
2758+
};
2759+
2760+
for (const root of ROOTS) {
2761+
describe(`at a ${root} root`, () => {
2762+
it('keeps an authored template title (`{name}`) when the pack carries `label` and no `title`', () => {
2763+
for (const locale of ['en', 'zh-CN']) {
2764+
const out = translatePage(pageWith(root, { title: '{name}' }), labelOnly, { locale });
2765+
expect({ locale, title: headerOf(root, out).title }).toEqual({ locale, title: '{name}' });
2766+
}
2767+
// The page's own label still translates; only the header title is guarded.
2768+
expect(translatePage(pageWith(root, { title: '{name}' }), labelOnly, { locale: 'zh-CN' }).label).toBe('客户详情');
2769+
});
2770+
2771+
it('keeps an authored plain-string title that differs from the page label', () => {
2772+
const out = translatePage(pageWith(root, { title: 'Customer overview' }), labelOnly, { locale: 'zh-CN' });
2773+
expect(headerOf(root, out).title).toBe('Customer overview');
2774+
});
2775+
2776+
it('keeps an authored inline locale map title, and still applies the bundle subtitle beside it', () => {
2777+
const title = { en: '{name}', 'zh-CN': '{name}(客户)' };
2778+
const out = translatePage(pageWith(root, { title, subtitle: 'Overview' }), labelOnly, { locale: 'zh-CN' });
2779+
expect(headerOf(root, out)).toEqual({ title, subtitle: '客户概览' });
2780+
});
2781+
2782+
it('CONTROL: a pack with `title` replaces the authored title', () => {
2783+
const out = translatePage(pageWith(root, { title: '{name}' }), withTitle, { locale: 'zh-CN' });
2784+
expect(headerOf(root, out).title).toBe('客户 {name}');
2785+
});
2786+
2787+
it('CONTROL: a header with no authored title still shows the bundle `label`', () => {
2788+
for (const header of [{}, { title: '' }, { subtitle: 'Overview' }]) {
2789+
const out = translatePage(pageWith(root, header), labelOnly, { locale: 'zh-CN' });
2790+
expect({ header, title: headerOf(root, out).title }).toEqual({ header, title: '客户详情' });
2791+
}
2792+
});
2793+
2794+
it('a header title that restates the page label takes the label translation (the extractor offers no `title` key for it)', () => {
2795+
const out = translatePage(pageWith(root, { title: 'Account Detail' }), labelOnly, { locale: 'zh-CN' });
2796+
expect(headerOf(root, out).title).toBe('客户详情');
2797+
2798+
// The same rule for the inline-map form: one map restating the other,
2799+
// entry for entry, in a different key order.
2800+
const label = { en: 'Account Detail', 'zh-CN': '客户详情页' };
2801+
const restated = { 'zh-CN': '客户详情页', en: 'Account Detail' };
2802+
const mapped = translatePage(pageWith(root, { title: restated }, label), labelOnly, { locale: 'zh-CN' });
2803+
expect(headerOf(root, mapped).title).toBe('客户详情');
2804+
});
2805+
});
2806+
}
2807+
2808+
it('serves the same answer through `translateMetadataDocument`, the function the REST page doors call', () => {
2809+
for (const root of ROOTS) {
2810+
const out = translateMetadataDocument('page', pageWith(root, { title: '{name}' }), labelOnly, { locale: 'en' });
2811+
expect({ root, title: headerOf(root, out).title }).toEqual({ root, title: '{name}' });
2812+
}
2813+
});
2814+
});
2815+
27252816
describe('translatePage — filter-preset tab bar', () => {
27262817
const bundle: TranslationBundle = {
27272818
'zh-CN': {

‎packages/spec/src/system/i18n-resolver.ts‎

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1842,6 +1842,67 @@ function lookupPageAttr(
18421842
return undefined;
18431843
}
18441844

1845+
/**
1846+
* Authored text in either `I18nLabel` form: a non-empty string, or the
1847+
* non-empty entries of an inline locale map. `undefined` when the value
1848+
* authors no text, which is how a `page:header` with no `title` (or an empty
1849+
* one) reads: the renderer draws no heading of its own for it either.
1850+
*/
1851+
function authoredLabelText(value: unknown): string | Record<string, string> | undefined {
1852+
if (typeof value === 'string') return value.length > 0 ? value : undefined;
1853+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
1854+
const entries = Object.entries(value as Record<string, unknown>)
1855+
.filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].length > 0);
1856+
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
1857+
}
1858+
1859+
/**
1860+
* Do two authored values say the same text? Strings compare as strings; two
1861+
* inline locale maps compare as sets of entries (key order is not text).
1862+
* Mirrors the CLI extractor's `sameAuthored`, which decides whether
1863+
* `os i18n extract` offers `pages.<name>.title` at all.
1864+
*/
1865+
function sameAuthoredText(a: string | Record<string, string>, b: unknown): boolean {
1866+
const other = authoredLabelText(b);
1867+
if (other === undefined) return false;
1868+
if (typeof a === 'string' || typeof other === 'string') return a === other;
1869+
const aKeys = Object.keys(a).sort();
1870+
const bKeys = Object.keys(other).sort();
1871+
return aKeys.length === bKeys.length && aKeys.every((k, i) => bKeys[i] === k && a[k] === other[k]);
1872+
}
1873+
1874+
/**
1875+
* The ONE header-title resolution for a root-level `page:header`. A region's
1876+
* entry and a `slots.<slot>` entry both reach it through the same visitor in
1877+
* {@link translatePage}, so the two roots cannot disagree.
1878+
*
1879+
* A bundle translates an attribute. It does not put one attribute in place of
1880+
* another over text the author wrote. The order:
1881+
*
1882+
* 1. the bundle's `pages.<name>.title`, across the locale chain;
1883+
* 2. the header's AUTHORED title, left as written: a template such as
1884+
* `'{name}'`, a plain string, or an inline locale map;
1885+
* 3. the bundle's `pages.<name>.label`, in two cases only: the header authors
1886+
* no title, or its title restates the page's own authored `label`. In the
1887+
* second case the label's translation IS a translation of that text, and
1888+
* `os i18n extract` offers no `title` key for such a header (it offers the
1889+
* label alone), so reading the label here is what translates it.
1890+
*
1891+
* `undefined` means "leave the authored title alone".
1892+
*/
1893+
function resolvePageHeaderTitle(
1894+
bundleTitle: string | undefined,
1895+
bundleLabel: string | undefined,
1896+
authoredTitle: unknown,
1897+
authoredPageLabel: unknown,
1898+
): string | undefined {
1899+
if (bundleTitle !== undefined) return bundleTitle;
1900+
if (bundleLabel === undefined) return undefined;
1901+
const own = authoredLabelText(authoredTitle);
1902+
if (own === undefined || sameAuthoredText(own, authoredPageLabel)) return bundleLabel;
1903+
return undefined;
1904+
}
1905+
18451906
/**
18461907
* The copy keys `pages.<name>.components.<id>` carries (#6080). Measured
18471908
* against `ComponentPropsMap` — see the schema's own note for which component
@@ -2197,9 +2258,13 @@ export function walkAddressedPageComponents(
21972258
* mutated.
21982259
*
21992260
* Header copy is addressed by **page name** rather than by component id because
2200-
* `page:header` instances carry no stable id in practice. `title` falls back to
2201-
* `pages.<name>.label` so translators need not repeat a string that is normally
2202-
* identical to the page's nav label.
2261+
* `page:header` instances carry no stable id in practice. The header title is
2262+
* `pages.<name>.title`; when the bundle omits it, the header keeps its
2263+
* AUTHORED title, and `pages.<name>.label` stands in only where the header
2264+
* authors no title or its title restates the page's own `label`, so a
2265+
* translator need not repeat a string identical to the page's nav label
2266+
* ({@link resolvePageHeaderTitle}). A label never replaces a header title
2267+
* that says something else, a dynamic `'{name}'` above all.
22032268
*
22042269
* **One component, one address.** A ROOT-LEVEL `page:header` — an entry of a
22052270
* region's `components[]`, or of a `slots.<slot>` on a `kind: 'slotted'` page
@@ -2283,10 +2348,12 @@ export function translatePage<T extends PageLike>(
22832348
const name = doc.name;
22842349
if (!name || !bundle) return doc;
22852350

2286-
const label = lookupPageAttr(bundle, name, 'label', opts) ?? doc.label;
2351+
const bundleLabel = lookupPageAttr(bundle, name, 'label', opts);
2352+
const label = bundleLabel ?? doc.label;
22872353
const description = lookupPageAttr(bundle, name, 'description', opts) ?? doc.description;
2288-
const headerTitle = lookupPageAttr(bundle, name, 'title', opts)
2289-
?? lookupPageAttr(bundle, name, 'label', opts);
2354+
// The header title is decided per header, against what that header authors
2355+
// (`resolvePageHeaderTitle`); only the bundle half is page-wide.
2356+
const bundleHeaderTitle = lookupPageAttr(bundle, name, 'title', opts);
22902357
const headerSubtitle = lookupPageAttr(bundle, name, 'subtitle', opts);
22912358

22922359
// The traversal — roots, descent, depth cap, cycle guard, collision
@@ -2349,6 +2416,11 @@ export function translatePage<T extends PageLike>(
23492416
// root level — nested components are reached by the id route only.
23502417
if (nested) return next;
23512418
if (next.type !== PAGE_HEADER_COMPONENT) return next;
2419+
// One call for a region root and a slot root alike: both arrive here with
2420+
// `nested: false`, so the fallback order cannot differ between them.
2421+
const headerTitle = resolvePageHeaderTitle(
2422+
bundleHeaderTitle, bundleLabel, next.properties?.title, doc.label,
2423+
);
23522424
if (headerTitle === undefined && headerSubtitle === undefined) return next;
23532425
return {
23542426
...next,

0 commit comments

Comments
 (0)