Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/22508-page-header-title-keeps-authored.md
Original file line number Diff line number Diff line change
@@ -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.<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.

The header title now resolves in this order, at a region root and a `slots.<slot>` root alike:

1. the bundle's `pages.<name>.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.<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.

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.
6 changes: 3 additions & 3 deletions content/docs/references/system/translation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { title?: string; description?: string; label?: string; placeholder?: string; … }>` | optional | Per-component copy keyed by component id (`PageComponentSchema.id`) |

Expand Down Expand Up @@ -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<string, { title?: string; description?: string; label?: string; placeholder?: string; … }>` | optional | Per-component copy keyed by component id (`PageComponentSchema.id`) |

Expand Down Expand Up @@ -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<string, { title?: string; description?: string; label?: string; placeholder?: string; … }>` | optional | Per-component copy keyed by component id (`PageComponentSchema.id`) |

Expand Down
10 changes: 7 additions & 3 deletions content/docs/ui/translations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.title` / `subtitle`.
`title` falls back to `pages.<name>.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.<name>.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.<slot>` entry)
is resolved by this one rule.
</Callout>

<Callout type="info">
Expand Down
4 changes: 2 additions & 2 deletions packages/spec/liveness/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@
"pages": {
"status": "live",
"verifiedAt": "2026-08-28",
"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)",
"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.<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)",
"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",
Expand Down
91 changes: 91 additions & 0 deletions packages/spec/src/system/i18n-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.label` (#22508).
const regionHeaderWithId = () => ({
name: 'sales_home_page',
label: 'Sales Home',
regions: [{
name: 'header',
components: [{
Expand Down Expand Up @@ -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<string, unknown>, 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<string, unknown> =>
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': {
Expand Down
84 changes: 78 additions & 6 deletions packages/spec/src/system/i18n-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> | 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<string, unknown>)
.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.<name>.title` at all.
*/
function sameAuthoredText(a: string | Record<string, string>, 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.<slot>` 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.<name>.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.<name>.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.<name>.components.<id>` carries (#6080). Measured
* against `ComponentPropsMap` — see the schema's own note for which component
Expand Down Expand Up @@ -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.<name>.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.<name>.title`; when the bundle omits it, the header keeps its
* AUTHORED title, and `pages.<name>.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.<slot>` on a `kind: 'slotted'` page
Expand Down Expand Up @@ -2283,10 +2348,12 @@ export function translatePage<T extends PageLike>(
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
Expand Down Expand Up @@ -2349,6 +2416,11 @@ export function translatePage<T extends PageLike>(
// 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,
Expand Down
Loading
Loading