diff --git a/.changeset/22508-page-header-title-keeps-authored.md b/.changeset/22508-page-header-title-keeps-authored.md new file mode 100644 index 00000000000..57065af7fde --- /dev/null +++ b/.changeset/22508-page-header-title-keeps-authored.md @@ -0,0 +1,17 @@ +--- +'@objectstack/spec': patch +--- + +A locale bundle's page `label` no longer replaces a `page:header` title that says something else, such as a dynamic `'{name}'` + +Clause-②: no + +`translatePage` resolved a root-level `page:header` title as the bundle's `pages..title`, else the bundle's `pages..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. + +The header title now resolves in this order, at a region root and a `slots.` root alike: + +1. the bundle's `pages..title`; +2. the title the header authors (a template, a plain string or an inline locale map), left as written; +3. the bundle's `pages..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. + +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..title` to the pack (`os i18n extract` offers that key for such a header). No schema, key, accepted value or export changes. The `pages..title` describe and the reference page now state this order. diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index 4e28995a8ea..bf5a7a93a15 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -249,7 +249,7 @@ Translation data for a single object | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated page label (nav / breadcrumb) | | **description** | `string` | optional | Translated page description | -| **title** | `string` | optional | Translated `page:header` title (defaults to `label`) | +| **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 | | **subtitle** | `string` | optional | Translated `page:header` subtitle | | **components** | `Record` | optional | Per-component copy keyed by component id (`PageComponentSchema.id`) | @@ -447,7 +447,7 @@ Translation data for a single object | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated page label (nav / breadcrumb) | | **description** | `string` | optional | Translated page description | -| **title** | `string` | optional | Translated `page:header` title (defaults to `label`) | +| **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 | | **subtitle** | `string` | optional | Translated `page:header` subtitle | | **components** | `Record` | optional | Per-component copy keyed by component id (`PageComponentSchema.id`) | @@ -606,7 +606,7 @@ Translation data for a single object | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated page label (nav / breadcrumb) | | **description** | `string` | optional | Translated page description | -| **title** | `string` | optional | Translated `page:header` title (defaults to `label`) | +| **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 | | **subtitle** | `string` | optional | Translated `page:header` subtitle | | **components** | `Record` | optional | Per-component copy keyed by component id (`PageComponentSchema.id`) | diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 1ca1c6200b0..f3dd7be9762 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -126,9 +126,13 @@ saying so. **Page headers are keyed by page name (#3589).** A page's `page:header` component has no stable id, so its `properties.title` / `properties.subtitle` are addressed through the page itself: `pages..title` / `subtitle`. -`title` falls back to `pages..label`, so a page whose header title -matches its nav label needs only `label`. Every `page:header` in the page's -regions receives the same copy. +When `title` is omitted, the header keeps the title its author wrote, and +`pages..label` stands in only where the header authors no title or its +title restates the page's `label`. So a page whose header title matches its +nav label needs only `label`, while a header whose title says something else, +such as a dynamic `'{name}'`, keeps it until the bundle supplies a `title`. +Every root-level `page:header` (a region's entry or a `slots.` entry) +is resolved by this one rule. diff --git a/packages/spec/liveness/translation.json b/packages/spec/liveness/translation.json index e8bc04eb2d8..2f226946925 100644 --- a/packages/spec/liveness/translation.json +++ b/packages/spec/liveness/translation.json @@ -109,8 +109,8 @@ "pages": { "status": "live", "verifiedAt": "2026-08-28", - "evidence": "packages/spec/src/system/i18n-resolver.ts#lookupPageAttr (`pages..` — label/description/title/subtitle, title falling back to label); packages/spec/src/system/i18n-resolver.ts#lookupPageComponentCopy (`pages..components.` — header copy keyed by page name because page:header instances carry no stable id)", - "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." + "evidence": "packages/spec/src/system/i18n-resolver.ts#lookupPageAttr (`pages..` — 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..components.` — header copy keyed by page name because page:header instances carry no stable id)", + "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." }, "flows": { "status": "live", diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index 7844e25eb27..2255a2c8466 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -1724,8 +1724,12 @@ describe('translatePage', () => { // widened to the open bag it is: the overlay adds keys the literal does // not spell out, and inferring it as `{title}` alone would make reading // the result a type error. + // `label` is `PageSchema`'s required key, and the header title restates + // it, exactly as on `homePage()`: that is the shape whose title the + // page-name route answers from `pages..label` (#22508). const regionHeaderWithId = () => ({ name: 'sales_home_page', + label: 'Sales Home', regions: [{ name: 'header', components: [{ @@ -2722,6 +2726,93 @@ describe('resolveTabLabel — filter-preset tab labels, keyed under `_tabs`', () }); }); +// #22508: the header title used to be "the bundle's page `title`, else the +// bundle's page `label`", spread over whatever the header authored. So a pack +// carrying `label` and no `title` (the natural partial translation) turned a +// record page's dynamic `'{name}'` heading into the page's static name in that +// locale, with no diagnostic. The order is now: bundle `title`, then the +// AUTHORED title, then the bundle `label` only where the header authors no +// title or its title restates the page's own `label`. Pinned at both roots, +// because both reach the one resolution through the same visitor. +describe('translatePage — a bundle label never replaces a header title that says something else', () => { + const PAGE = 'account_detail_page'; + type Root = 'region' | 'slot'; + const ROOTS: readonly Root[] = ['region', 'slot']; + + const pageWith = (root: Root, header: Record, label: any = 'Account Detail'): any => { + const component = { type: 'page:header', properties: header }; + return root === 'region' + ? { name: PAGE, label, regions: [{ name: 'header', components: [component] }] } + : { name: PAGE, label, kind: 'slotted', regions: [], slots: { header: component } }; + }; + const headerOf = (root: Root, page: any): Record => + root === 'region' ? page.regions[0].components[0].properties : page.slots.header.properties; + + /** The measured hotcrm shape: `label` in the pack, no `title`. */ + const labelOnly: TranslationBundle = { + en: { pages: { [PAGE]: { label: 'Account Detail' } } }, + 'zh-CN': { pages: { [PAGE]: { label: '客户详情', subtitle: '客户概览' } } }, + }; + const withTitle: TranslationBundle = { + 'zh-CN': { pages: { [PAGE]: { label: '客户详情', title: '客户 {name}' } } }, + }; + + for (const root of ROOTS) { + describe(`at a ${root} root`, () => { + it('keeps an authored template title (`{name}`) when the pack carries `label` and no `title`', () => { + for (const locale of ['en', 'zh-CN']) { + const out = translatePage(pageWith(root, { title: '{name}' }), labelOnly, { locale }); + expect({ locale, title: headerOf(root, out).title }).toEqual({ locale, title: '{name}' }); + } + // The page's own label still translates; only the header title is guarded. + expect(translatePage(pageWith(root, { title: '{name}' }), labelOnly, { locale: 'zh-CN' }).label).toBe('客户详情'); + }); + + it('keeps an authored plain-string title that differs from the page label', () => { + const out = translatePage(pageWith(root, { title: 'Customer overview' }), labelOnly, { locale: 'zh-CN' }); + expect(headerOf(root, out).title).toBe('Customer overview'); + }); + + it('keeps an authored inline locale map title, and still applies the bundle subtitle beside it', () => { + const title = { en: '{name}', 'zh-CN': '{name}(客户)' }; + const out = translatePage(pageWith(root, { title, subtitle: 'Overview' }), labelOnly, { locale: 'zh-CN' }); + expect(headerOf(root, out)).toEqual({ title, subtitle: '客户概览' }); + }); + + it('CONTROL: a pack with `title` replaces the authored title', () => { + const out = translatePage(pageWith(root, { title: '{name}' }), withTitle, { locale: 'zh-CN' }); + expect(headerOf(root, out).title).toBe('客户 {name}'); + }); + + it('CONTROL: a header with no authored title still shows the bundle `label`', () => { + for (const header of [{}, { title: '' }, { subtitle: 'Overview' }]) { + const out = translatePage(pageWith(root, header), labelOnly, { locale: 'zh-CN' }); + expect({ header, title: headerOf(root, out).title }).toEqual({ header, title: '客户详情' }); + } + }); + + it('a header title that restates the page label takes the label translation (the extractor offers no `title` key for it)', () => { + const out = translatePage(pageWith(root, { title: 'Account Detail' }), labelOnly, { locale: 'zh-CN' }); + expect(headerOf(root, out).title).toBe('客户详情'); + + // The same rule for the inline-map form: one map restating the other, + // entry for entry, in a different key order. + const label = { en: 'Account Detail', 'zh-CN': '客户详情页' }; + const restated = { 'zh-CN': '客户详情页', en: 'Account Detail' }; + const mapped = translatePage(pageWith(root, { title: restated }, label), labelOnly, { locale: 'zh-CN' }); + expect(headerOf(root, mapped).title).toBe('客户详情'); + }); + }); + } + + it('serves the same answer through `translateMetadataDocument`, the function the REST page doors call', () => { + for (const root of ROOTS) { + const out = translateMetadataDocument('page', pageWith(root, { title: '{name}' }), labelOnly, { locale: 'en' }); + expect({ root, title: headerOf(root, out).title }).toEqual({ root, title: '{name}' }); + } + }); +}); + describe('translatePage — filter-preset tab bar', () => { const bundle: TranslationBundle = { 'zh-CN': { diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index a8eade91831..a0b369f7234 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -1842,6 +1842,67 @@ function lookupPageAttr( return undefined; } +/** + * Authored text in either `I18nLabel` form: a non-empty string, or the + * non-empty entries of an inline locale map. `undefined` when the value + * authors no text, which is how a `page:header` with no `title` (or an empty + * one) reads: the renderer draws no heading of its own for it either. + */ +function authoredLabelText(value: unknown): string | Record | undefined { + if (typeof value === 'string') return value.length > 0 ? value : undefined; + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const entries = Object.entries(value as Record) + .filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].length > 0); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + +/** + * Do two authored values say the same text? Strings compare as strings; two + * inline locale maps compare as sets of entries (key order is not text). + * Mirrors the CLI extractor's `sameAuthored`, which decides whether + * `os i18n extract` offers `pages..title` at all. + */ +function sameAuthoredText(a: string | Record, b: unknown): boolean { + const other = authoredLabelText(b); + if (other === undefined) return false; + if (typeof a === 'string' || typeof other === 'string') return a === other; + const aKeys = Object.keys(a).sort(); + const bKeys = Object.keys(other).sort(); + return aKeys.length === bKeys.length && aKeys.every((k, i) => bKeys[i] === k && a[k] === other[k]); +} + +/** + * The ONE header-title resolution for a root-level `page:header`. A region's + * entry and a `slots.` entry both reach it through the same visitor in + * {@link translatePage}, so the two roots cannot disagree. + * + * A bundle translates an attribute. It does not put one attribute in place of + * another over text the author wrote. The order: + * + * 1. the bundle's `pages..title`, across the locale chain; + * 2. the header's AUTHORED title, left as written: a template such as + * `'{name}'`, a plain string, or an inline locale map; + * 3. the bundle's `pages..label`, in two cases only: the header authors + * no title, or its title restates the page's own authored `label`. In the + * second case the label's translation IS a translation of that text, and + * `os i18n extract` offers no `title` key for such a header (it offers the + * label alone), so reading the label here is what translates it. + * + * `undefined` means "leave the authored title alone". + */ +function resolvePageHeaderTitle( + bundleTitle: string | undefined, + bundleLabel: string | undefined, + authoredTitle: unknown, + authoredPageLabel: unknown, +): string | undefined { + if (bundleTitle !== undefined) return bundleTitle; + if (bundleLabel === undefined) return undefined; + const own = authoredLabelText(authoredTitle); + if (own === undefined || sameAuthoredText(own, authoredPageLabel)) return bundleLabel; + return undefined; +} + /** * The copy keys `pages..components.` carries (#6080). Measured * against `ComponentPropsMap` — see the schema's own note for which component @@ -2197,9 +2258,13 @@ export function walkAddressedPageComponents( * mutated. * * Header copy is addressed by **page name** rather than by component id because - * `page:header` instances carry no stable id in practice. `title` falls back to - * `pages..label` so translators need not repeat a string that is normally - * identical to the page's nav label. + * `page:header` instances carry no stable id in practice. The header title is + * `pages..title`; when the bundle omits it, the header keeps its + * AUTHORED title, and `pages..label` stands in only where the header + * authors no title or its title restates the page's own `label`, so a + * translator need not repeat a string identical to the page's nav label + * ({@link resolvePageHeaderTitle}). A label never replaces a header title + * that says something else, a dynamic `'{name}'` above all. * * **One component, one address.** A ROOT-LEVEL `page:header` — an entry of a * region's `components[]`, or of a `slots.` on a `kind: 'slotted'` page @@ -2283,10 +2348,12 @@ export function translatePage( const name = doc.name; if (!name || !bundle) return doc; - const label = lookupPageAttr(bundle, name, 'label', opts) ?? doc.label; + const bundleLabel = lookupPageAttr(bundle, name, 'label', opts); + const label = bundleLabel ?? doc.label; const description = lookupPageAttr(bundle, name, 'description', opts) ?? doc.description; - const headerTitle = lookupPageAttr(bundle, name, 'title', opts) - ?? lookupPageAttr(bundle, name, 'label', opts); + // The header title is decided per header, against what that header authors + // (`resolvePageHeaderTitle`); only the bundle half is page-wide. + const bundleHeaderTitle = lookupPageAttr(bundle, name, 'title', opts); const headerSubtitle = lookupPageAttr(bundle, name, 'subtitle', opts); // The traversal — roots, descent, depth cap, cycle guard, collision @@ -2349,6 +2416,11 @@ export function translatePage( // root level — nested components are reached by the id route only. if (nested) return next; if (next.type !== PAGE_HEADER_COMPONENT) return next; + // One call for a region root and a slot root alike: both arrive here with + // `nested: false`, so the fallback order cannot differ between them. + const headerTitle = resolvePageHeaderTitle( + bundleHeaderTitle, bundleLabel, next.properties?.title, doc.label, + ); if (headerTitle === undefined && headerSubtitle === undefined) return next; return { ...next, diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 2d70234867c..076e346f176 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -1040,9 +1040,15 @@ const appTranslationDataShape = () => ({ * pages..components.. * → that component's `properties.` (#6080) * - * `title` falls back to `label` when omitted, since a page's header title - * and its nav/breadcrumb label are usually the same string — translators - * only author `title` separately when the two genuinely differ. + * When `title` is omitted, the header keeps the title its author wrote. + * `label` stands in for it only where the header authors no title, or where + * its title restates the page's own `label`: a page's header title and its + * nav/breadcrumb label are usually the same string, so translators only + * author `title` separately when the two genuinely differ (and + * `os i18n extract` offers `title` exactly then). A label never replaces a + * header title that says something else, such as a dynamic `'{name}'`: a + * bundle translates an attribute, it does not swap one attribute for + * another over authored text. * * Header copy lives at the TOP level here rather than under `components` * because `page:header` instances carry no stable `id`; the page name is the @@ -1066,7 +1072,7 @@ const appTranslationDataShape = () => ({ }, { label: z.string().optional().describe('Translated page label (nav / breadcrumb)'), description: z.string().optional().describe('Translated page description'), - title: z.string().optional().describe('Translated `page:header` title (defaults to `label`)'), + title: z.string().optional().describe('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'), subtitle: z.string().optional().describe('Translated `page:header` subtitle'), /** * Per-component copy, keyed by the component's `id`