Skip to content

Commit 554a160

Browse files
huangyiireneclaude
andauthored
fix(spec): the page-name route is canonical for a region-level page:header (#16588)
* fix(spec): the page-name route is canonical for a region-level `page:header` `translatePage` stops reading the id route (`pages.PAGE.components.HEADERID.*`) for a `page:header` at region level. That route was live and PREFERRED there, while the CLI extractor deliberately offers nothing under it for the same component -- so every key read through it was a key no tooling ever offered, counted or reported, and the header's `title` had two addresses while its `subtitle` had one. The gate mirrors the extractor's emission exception in `collectExpectedEntries` (same shape, opposite verb), so the failure pair `walkAddressedPageComponents` exists to prevent cannot reopen from the resolver side. A `page:header` nested inside a container is unchanged: the page-name route does not reach it, so it stays id-only. The walk's `addressed` arbitration is untouched -- a region-level header's id still claims its entry and still blocks a nested namesake, which is what the extractor does too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno * docs(spec): drop the stale "the id wins" sentence and add the changeset The visitor's lead comment still described the id route as beating the page-name route for a header that carries an id -- the sentence the gate below it now contradicts. Rewritten to say what the code does. Changeset: `minor` on `@objectstack/spec`, stating the behaviour change and the FROM -> TO mapping for a bundle that used the components key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno * test(spec): pin that the id route is NOT read for a region-level page:header The batch #58 pin in i18n-resolver.test.ts stayed green with the gate reverted to `if (addressed)`: homeBundle carries pages.sales_home_page.label, so the page-name overlay wins for `title` whether or not the id route was read. The test pinned precedence (page name wins when both routes are present) while its title claimed "page name only" - a pin that cannot fail on the regression it names. Split into two tests. The precedence pin keeps its body under a title that says what it measures. A new pin hands translatePage an id-only bundle (no pages.PAGE.label / title / subtitle), so the page-name overlay has nothing to win with and only the id route could move the header title, and asserts the authored title survives. Two controls keep the empty reading honest: the same id-only bundle translates the component that owns the id, and a nested page:header with that id stays id-addressed. Only the test file changes; the resolver, extractor, cli parity pin and changeset are untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 95f33a3 commit 554a160

5 files changed

Lines changed: 332 additions & 41 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
`translatePage` now reads a region-level `page:header` by **page name only**. The id route
6+
(`pages.<page>.components.<headerId>.*`) is no longer read for that component, even when it carries an `id`.
7+
8+
**Behaviour change, stated plainly:** a bundle that overrode a region-level header's title through
9+
`pages.<page>.components.<headerId>.title` now falls back to `pages.<page>.title` (which itself falls back to
10+
`pages.<page>.label`). The components key still parses — nothing is removed from `TranslationBundleSchema` — it is
11+
simply no longer the address for this one component.
12+
13+
```
14+
FROM pages.<page>.components.<headerId>.title # region-level page:header — no longer read
15+
TO pages.<page>.title # …and .subtitle for the subtitle
16+
```
17+
18+
Fix in one line: move the string from the components entry up to the page's own `title` key, and delete the
19+
components entry for that header id. `os i18n extract` has always offered exactly the `TO` key, so a bundle
20+
generated or checked by the CLI already writes it.
21+
22+
**Blast radius, as measured on the card (inherited, not re-measured here):** HotCRM found **zero** such overrides —
23+
all five of its region-level headers carry ids and none writes the components key.
24+
25+
**Why.** Both sides were deliberate and they disagreed. The extractor skips a region-level `page:header` on purpose
26+
(its copy is offered under `pages.<page>.title` / `.subtitle`, and emitting it twice would put one string under two
27+
keys); the resolver read the id route on purpose (the more specific route wins). Together they produced the exact
28+
failure `walkAddressedPageComponents` was extracted to prevent — the resolver reading an id the extractor omits — so
29+
the key an author reached for won silently while the key the tooling reported as translated lost. The maintainer
30+
ruled (2026-09-06, decision batch #58, verbatim 「同意」) that the page-name route is canonical: one component, one
31+
address. `title` and `subtitle` now follow the same rule, closing the asymmetry where `title` had two addresses and
32+
`subtitle` — never in `PAGE_COMPONENT_COPY_KEYS` — had one.
33+
34+
Unchanged: a `page:header` **nested** inside a container is reached by the id route only, as it always has been; and
35+
a region-level header's id still claims its bundle entry and still blocks a nested namesake, which is what the
36+
extractor does too.
37+
38+
Not `major`: nothing an author can write is removed or renamed. `pages.<page>.components.<id>` remains a declared,
39+
parsing, resolving address for every other component — including a nested `page:header` — so there is no key to
40+
tombstone and no ADR-0087 conversion to register. Recorded here so the choice is checkable rather than assumed.

packages/cli/test/platform-page-i18n-parity.test.ts

Lines changed: 150 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -352,14 +352,18 @@ describe('i18n-extract ↔ translatePage walk parity (#13109)', () => {
352352
// Both directions, named separately so a failure says WHICH half broke.
353353
expect({ offeredButIgnored: [...offered].filter((id) => !applied.has(id)).sort() })
354354
.toEqual({ offeredButIgnored: [] });
355-
// The ONE standing exception, pre-dating this card and deliberate: a
356-
// region-level `page:header`'s copy is offered under `pages.PAGE.title` /
357-
// `.subtitle` instead, because emitting it here too would offer one string
358-
// under two keys. The resolver still honours the id route for it, so it
359-
// shows up as applied-not-offered — listed explicitly rather than filtered
360-
// out of the fixture, so the exception stays visible and bounded to one id.
355+
// NO standing exception any more. This list used to read `['hdr']` — a
356+
// region-level `page:header` carrying an id, whose copy the extractor
357+
// deliberately offers under `pages.PAGE.title` / `.subtitle` while the
358+
// resolver went on honouring the id route for it and PREFERRING it. That
359+
// was the second half of the failure pair `walkAddressedPageComponents`
360+
// exists to prevent (the resolver reading an id the extractor omits), and
361+
// the 2026-09-06 ruling (decision batch #58) closed it by making the
362+
// page-name route canonical for this component. Empty in BOTH directions
363+
// is now the invariant; an entry reappearing here is a regression, not an
364+
// exception to document.
361365
expect({ appliedButNotOffered: [...applied].filter((id) => !offered.has(id)).sort() })
362-
.toEqual({ appliedButNotOffered: ['hdr'] });
366+
.toEqual({ appliedButNotOffered: [] });
363367
});
364368

365369
it('pins the two sets by name, so a shape that stops being reachable is visible', () => {
@@ -369,11 +373,141 @@ describe('i18n-extract ↔ translatePage walk parity (#13109)', () => {
369373
]);
370374
// `card_body_child`, `card_footer_child`, `tab_child` and `slot_child` are
371375
// absent from BOTH sides — the shapes `translatePage` does not descend.
376+
// `hdr` — the region-level `page:header` — is absent from BOTH sides since
377+
// the ruling. `nested_header` stays: a `page:header` inside a container is
378+
// reached by the id route only, so the id key is the only key it has.
372379
expect([...idsResolverApplies(page)].sort()).toEqual([
373-
'card', 'hdr', 'inner_flex', 'kpi_1', 'kpi_deep', 'kpi_label', 'nested_header', 'region_metric',
380+
'card', 'inner_flex', 'kpi_1', 'kpi_deep', 'kpi_label', 'nested_header', 'region_metric',
374381
]);
375382
});
376383

384+
// ── The ruled invariant, pinned directly (decision batch #58, 2026-09-06) ──
385+
//
386+
// Maintainer 「同意」, option 1, verbatim: "The page-name route is canonical
387+
// for a region-level `page:header`. `translatePage` stops reading the id
388+
// route (`pages.PAGE.components.HEADERID.*`) for a `page:header` at region
389+
// level; a `page:header` nested inside a container stays id-only, as its doc
390+
// already says. One component, one address — `title` and `subtitle` now
391+
// follow the same rule."
392+
//
393+
// The set comparisons above measure `title` alone, because that is the key
394+
// both fixtures carry. This case walks the WHOLE shared key list and asserts
395+
// BOTH verbs of the failure pair on one component, in one place: the id key
396+
// is neither OFFERED nor READ. It also pins the three things that must NOT
397+
// move with it — the page-name route still translates the header, a nested
398+
// `page:header` is still id-addressed, and a bundle carrying both routes
399+
// resolves to the page-name one — so a regression that simply stops
400+
// translating region-level headers cannot pass here either.
401+
const headerRoutePage = (): Record<string, any> => ({
402+
name: 'header_route_page',
403+
regions: [
404+
{
405+
name: 'top',
406+
components: [
407+
// Region level, WITH an id — the shape the card measured (hotcrm's
408+
// five headers all carry one).
409+
{ id: 'home_header', type: 'page:header', properties: { title: 'Sales Home', subtitle: 'Welcome back' } },
410+
],
411+
},
412+
{
413+
name: 'main',
414+
components: [
415+
{
416+
id: 'wrap',
417+
type: 'page:card',
418+
properties: {
419+
title: 'Wrap',
420+
children: [
421+
// Nested — the page-name route does not reach it, so the id
422+
// route is the ONLY route it has. Unchanged by the ruling.
423+
{ id: 'inner_header', type: 'page:header', properties: { title: 'Inner header' } },
424+
],
425+
},
426+
},
427+
],
428+
},
429+
],
430+
});
431+
432+
/** Every key of the shared list under one id, sentinel-valued. */
433+
const everyKeyFor = (id: string): Record<string, string> =>
434+
Object.fromEntries(PAGE_COMPONENT_COPY_KEYS.map((k) => [k, `ID::${id}::${k}`]));
435+
436+
it('neither offers nor reads the id key for a region-level `page:header` (batch #58)', () => {
437+
const page = headerRoutePage();
438+
439+
// Half one — the extractor offers NOTHING under `components.home_header`,
440+
// for any key of the shared list.
441+
expect(componentRows(page).filter((r) => r.key.startsWith('home_header.'))).toEqual([]);
442+
443+
// Half two — the resolver reads nothing there either. The bundle offers
444+
// every key of the shared list under that id; a sentinel reaching the
445+
// output would mean the id route is still live for this component.
446+
const idBundle = {
447+
en: {
448+
pages: {
449+
header_route_page: {
450+
components: { home_header: everyKeyFor('home_header'), wrap: everyKeyFor('wrap') },
451+
},
452+
},
453+
},
454+
} as any;
455+
const viaId = translatePage(page as any, idBundle, { locale: 'en' });
456+
const header = viaId.regions[0].components[0];
457+
expect(header.properties).toEqual({ title: 'Sales Home', subtitle: 'Welcome back' });
458+
expect(header.label).toBeUndefined();
459+
// Positive control for that empty: the SAME bundle shape does reach a
460+
// component the id route serves, so the two assertions above are a reading
461+
// and not an inert fixture.
462+
expect(viaId.regions[1].components[0].properties.title).toEqual('ID::wrap::title');
463+
464+
// Half three — the page-name route still translates the header. The fix is
465+
// "one address", not "no address".
466+
const nameBundle = {
467+
en: { pages: { header_route_page: { title: 'BY-NAME', subtitle: 'SUB-BY-NAME' } } },
468+
} as any;
469+
const viaName = translatePage(page as any, nameBundle, { locale: 'en' });
470+
expect(viaName.regions[0].components[0].properties)
471+
.toEqual({ title: 'BY-NAME', subtitle: 'SUB-BY-NAME' });
472+
// ...and it stops at region level: the nested header keeps its literal.
473+
expect(viaName.regions[1].components[0].properties.children[0].properties.title)
474+
.toEqual('Inner header');
475+
476+
// Half four — a nested `page:header` is still reached, by the id route.
477+
const nestedBundle = {
478+
en: {
479+
pages: {
480+
header_route_page: { title: 'BY-NAME', components: { inner_header: { title: 'ID::inner' } } },
481+
},
482+
},
483+
} as any;
484+
const viaNested = translatePage(page as any, nestedBundle, { locale: 'en' });
485+
expect(viaNested.regions[1].components[0].properties.children[0].properties.title)
486+
.toEqual('ID::inner');
487+
// The extractor offers that nested id, which is the other half of "stays
488+
// id-only" — the two sides agree about it as much as about the region one.
489+
expect(componentRows(page).filter((r) => r.key === 'inner_header.title'))
490+
.toEqual([{ key: 'inner_header.title', value: 'Inner header' }]);
491+
492+
// Half five — the card's Leg A shape 3, inverted. Both routes present:
493+
// BEFORE the ruling the id route won here ("ZH-by-ID"); the page-name
494+
// route is canonical now, so the components key is inert on this component
495+
// and the bundle that overrode a header title through it falls back.
496+
const bothBundle = {
497+
en: {
498+
pages: {
499+
header_route_page: {
500+
title: 'BY-NAME',
501+
subtitle: 'SUB-BY-NAME',
502+
components: { home_header: { title: 'BY-ID' } },
503+
},
504+
},
505+
},
506+
} as any;
507+
expect(translatePage(page as any, bothBundle, { locale: 'en' }).regions[0].components[0].properties)
508+
.toEqual({ title: 'BY-NAME', subtitle: 'SUB-BY-NAME' });
509+
});
510+
377511
it('carries the whole shared key list down into nesting, label either/or included', () => {
378512
const rows = componentRows(walkParityPage());
379513
// `label` authored at the component's top level, the same either/or
@@ -414,7 +548,10 @@ describe('i18n-extract ↔ translatePage walk parity (#13109)', () => {
414548
.toEqual([{ key: 'twice.title', value: 'First nested wins' }]);
415549
// A region-level `page:header` emits nothing here, but its id still BLOCKS
416550
// a nested namesake — the resolver counts it as region-level, so offering
417-
// the nested one would be a key the resolver ignores.
551+
// the nested one would be a key the resolver ignores. The ruling changed
552+
// what the resolver READS for that id, not who OWNS it: the arbitration is
553+
// a property of the document and both consumers still decide it the same
554+
// way, which is why this half is unchanged.
418555
expect(rows.filter((r) => r.key.startsWith('hdr_id.'))).toEqual([]);
419556

420557
// And the resolver agrees about which component the entry lands on.
@@ -432,7 +569,10 @@ describe('i18n-extract ↔ translatePage walk parity (#13109)', () => {
432569
const translated = translatePage(page as any, bundle, { locale: 'en' });
433570
const region = translated.regions[0].components;
434571
expect(region[0].properties.title).toEqual('S');
435-
expect(region[1].properties.title).toEqual('H');
572+
// `hdr_id` is the region-level `page:header`: the bundle's `H` under
573+
// `components.hdr_id.title` is NOT read, and this page has no
574+
// `pages.walk_collision_page.title` either, so the authored literal stands.
575+
expect(region[1].properties.title).toEqual('Header holds this id');
436576
const nested = region[2].properties.children;
437577
expect(nested.map((c: any) => c.properties.title)).toEqual([
438578
'Nested namesake loses',

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

Lines changed: 81 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1368,26 +1368,93 @@ describe('translatePage', () => {
13681368
expect(byId(out, 'ai_briefing').properties.description).toBe('Open the assistant panel.');
13691369
});
13701370

1371-
it('lets the id-addressed route win over the page-name route on a header that has an id', () => {
1372-
const doc = {
1371+
// Ruled 2026-09-06 (maintainer, verbatim 「同意」, decision batch #58,
1372+
// option 1): "The page-name route is canonical for a region-level
1373+
// `page:header`. `translatePage` stops reading the id route
1374+
// (`pages.PAGE.components.HEADERID.*`) for a `page:header` at region
1375+
// level; a `page:header` nested inside a container stays id-only, as its
1376+
// doc already says. One component, one address — `title` and `subtitle`
1377+
// now follow the same rule."
1378+
//
1379+
// The ruling has two halves, and one bundle cannot measure both — so two
1380+
// pins. `homeBundle` carries `pages.sales_home_page.label`, and the
1381+
// page-name overlay is applied AFTER the id route and wins for `title`
1382+
// whether or not the id route was read: against that bundle the header's
1383+
// `title` reads `销售看板` on a resolver that still reads the id route.
1384+
// That is a real invariant (precedence when both routes are present) and
1385+
// the first pin names it as exactly that — it is NOT evidence that the id
1386+
// route is closed. The second pin hands the resolver an id-ONLY bundle —
1387+
// no `pages.<name>.label`, no `pages.<name>.title` — so the page-name
1388+
// overlay has nothing to win with and only the id route could move the
1389+
// title. Measured with the gate reverted to `if (addressed)`: the first
1390+
// pin stays green, the second goes red.
1391+
//
1392+
// A region-level `page:header` that carries an id. `properties` is
1393+
// widened to the open bag it is: the overlay adds keys the literal does
1394+
// not spell out, and inferring it as `{title}` alone would make reading
1395+
// the result a type error.
1396+
const regionHeaderWithId = () => ({
1397+
name: 'sales_home_page',
1398+
regions: [{
1399+
name: 'header',
1400+
components: [{
1401+
type: 'page:header',
1402+
id: 'quick_create',
1403+
properties: { title: 'Sales Home' } as Record<string, string>,
1404+
}],
1405+
}],
1406+
});
1407+
1408+
// Previously this asserted the OPPOSITE (`title` resolving to `快速新建`,
1409+
// the id route beating the page-name one). Inverted, not deleted, because
1410+
// the inversion IS the behaviour change the ruling records: the id route
1411+
// was live for this component and preferred, and a bundle that used it
1412+
// now falls back to the page-name key.
1413+
it('prefers the page-name route over the id route for a region-level `page:header` when a bundle carries both (batch #58)', () => {
1414+
const out = translatePage(regionHeaderWithId(), homeBundle, { locale: 'zh-CN' });
1415+
// `components.quick_create.title` (`快速新建`) does not win here — the
1416+
// page-name route does, falling back to `pages.<name>.label` for `title`.
1417+
expect(out.regions[0].components[0].properties.title).toBe('销售看板');
1418+
expect(out.regions[0].components[0].properties.subtitle).toBe('欢迎回来');
1419+
// Control: the very same bundle entry still reaches the component that
1420+
// actually owns it, so the assertion above is about the header's route
1421+
// and not about a bundle that stopped resolving.
1422+
expect(byId(translatePage(homePage(), homeBundle, { locale: 'zh-CN' }), 'quick_create')
1423+
.properties.title).toBe('快速新建');
1424+
});
1425+
1426+
it('does not read the id route for a region-level `page:header` — an id-only bundle leaves its authored title alone (batch #58)', () => {
1427+
// No `pages.<name>.label` / `title` / `subtitle`: the page-name route
1428+
// resolves nothing for this page, so a translated title on the header
1429+
// could only have come from `components.quick_create.title`.
1430+
const idOnly: TranslationBundle = {
1431+
'zh-CN': { pages: { sales_home_page: { components: { quick_create: { title: '快速新建' } } } } },
1432+
};
1433+
const out = translatePage(regionHeaderWithId(), idOnly, { locale: 'zh-CN' });
1434+
expect(out.regions[0].components[0].properties.title).toBe('Sales Home');
1435+
// Control 1: the same id-only bundle DOES translate the component that
1436+
// owns the id, so the authored title above is the header's id route
1437+
// being closed and not a bundle nothing can resolve.
1438+
expect(byId(translatePage(homePage(), idOnly, { locale: 'zh-CN' }), 'quick_create')
1439+
.properties.title).toBe('快速新建');
1440+
// Control 2: a `page:header` NESTED in a container stays id-addressed —
1441+
// the ruling closes the id route at region level only, and the id route
1442+
// is the only one that reaches a nested header.
1443+
const nestedDoc = {
13731444
name: 'sales_home_page',
13741445
regions: [{
1375-
name: 'header',
1376-
// `properties` widened to the open bag it is: the overlay adds keys
1377-
// the literal does not spell out, and inferring it as `{title}` alone
1378-
// would make reading the result a type error.
1446+
name: 'main',
13791447
components: [{
1380-
type: 'page:header',
1381-
id: 'quick_create',
1382-
properties: { title: 'Sales Home' } as Record<string, string>,
1448+
type: 'page:card',
1449+
id: 'wrapper',
1450+
properties: {
1451+
children: [{ type: 'page:header', id: 'quick_create', properties: { title: 'Sales Home' } as Record<string, string> }],
1452+
},
13831453
}],
13841454
}],
13851455
};
1386-
const out = translatePage(doc, homeBundle, { locale: 'zh-CN' });
1387-
// `components.quick_create.title` is more specific than `pages.<name>.label`.
1388-
expect(out.regions[0].components[0].properties.title).toBe('快速新建');
1389-
// The page-name route still supplies what the id route did not.
1390-
expect(out.regions[0].components[0].properties.subtitle).toBe('欢迎回来');
1456+
const nested = translatePage(nestedDoc, idOnly, { locale: 'zh-CN' });
1457+
expect((nested.regions[0].components[0].properties.children as any[])[0].properties.title).toBe('快速新建');
13911458
});
13921459

13931460
it('does not mutate the input page', () => {

0 commit comments

Comments
 (0)