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
27 changes: 27 additions & 0 deletions .changeset/page-component-slot-positions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@objectstack/spec': minor
'@objectstack/lint': patch
'@objectstack/cli': patch
---

feat(spec): one list of page-component slot positions, derived from the component rows and read by every page walk — `page:card`'s `footer` is now walked by all three (#20940)

The platform has three walks that descend into a page component's `properties` bag, and each kept its own list of where child components hang: the ADR-0087 conversion walker (`children`, `body`, `footer`, `items[].children`), `@objectstack/lint`'s `walkPageComponents` (the same four) and the exported `walkAddressedPageComponents` (`children`, `items[].children`). So a node in a card's `footer` — a declared, rendered slot ("Card footer components (slot)") — was judged by `os lint` and skipped by every consumer of the exported walk: `translatePage` left its copy untranslated, `os i18n extract` offered no key for it, and objectui's validator passed it unjudged.

**`@objectstack/spec` — new exports `pageComponentSlotPositions()` and `PageComponentSlotPosition` (`@objectstack/spec/ui`).** The component rows now mark each composition slot at its declaration, and `pageComponentSlotPositions()` derives the one list from `ComponentPropsMap`: `children`, `footer` and the panel position `items[].children`, plus the tombstoned `body` flagged `retired: true`. The marker changes nothing about the schema it marks — the parse, the JSON Schema and the authorable surface are unchanged. The list is derived on first call and memoized, never at import. `minor` because the package's public surface grows by these two exports.

**`walkAddressedPageComponents` descends `properties.footer`.** It reads the list's authorable entries, in the list's order (`children`, `footer`, then `items[].children`); signature and return shape are unchanged. What follows from it:

- `translatePage` translates the copy of a component in a card footer through `pages.<name>.components.<id>`, like any other nested component.
- `os i18n extract` offers those keys, and `os i18n check` counts them, for a stack whose card footers hold components with an `id` and copy.
- objectui's validator, which judges the nodes this walk visits, now judges a card footer's nodes.

`page:card.body` stays undescended, as #5775 ruled: it is not an authorable spelling.

**The conversion walker reads every entry, the retired one included.** Its reach does not change: it descends `children`, `body`, `footer` and `items[].children`, as before. Stored documents still carry `body`, the renderers still draw it, and a conversion that runs before `page-card-body-to-children` meets the sub-tree there. Within one component the visit order is now `children`, `body`, `footer`, then the panels. That order is observable only as the order of the notices for a component that carries both a direct slot and panels.

**`@objectstack/lint` — `walkPageComponents` reads the list's authorable entries.** It walks `footer` as before, and it stops walking the retired `body` spelling. The walk matches by shape, so this drops a `body` array on any component, not only on `page:card`. #5775 (maintainer ruling 2026-08-06, direction A) made `children` the one composition key. The renderers keep reading `body` only as a back-compat fallback for stored documents. On `page:card` the tombstone's rename prescription still refuses `body`, and so does the thin containers' guidance; the sub-tree is judged once it sits under `children`. So the rules built on this walk no longer report findings about nodes under any component's `body` array. The conversion walker keeps reaching them for stored documents.

**`@objectstack/cli`:** no code change. `os i18n extract` and `os i18n check` pick up the `footer` component keys through the shared walk. The extractor's object-section pass stops reading `record:details` sections under a retired `body`, through lint's walk.

**Why no ADR-0087 ledger entry.** Nothing an author writes moves: no spec key is retired or renamed, no stored `sys_metadata` shape changes, and no conversion or migration id is touched. `objectstack migrate meta` has nothing to act on.
30 changes: 16 additions & 14 deletions packages/cli/src/utils/i18n-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -932,9 +932,10 @@ function walkObjectSections(config: any, out: ExpectedEntry[]): void {
// private copy. That walk exists precisely because duplicating it produced a
// dead rule once already (#3583): components hang off `regions[].components`
// AND `slots.<slot>` (which may be a bare component, not an array), sub-trees
// live inside the untyped `properties` bag (`page:tabs` →
// `properties.items[].children`, `page:card` → `properties.body`/`.footer`),
// and source-authored pages (`kind: 'html' | 'react' | 'jsx'`) hold only a
// live inside the untyped `properties` bag at the positions spec's
// `pageComponentSlotPositions()` derives (`page:tabs` →
// `properties.items[].children`, `page:card` → `properties.footer`, every
// container → `properties.children`), and source-authored pages (`kind: 'html' | 'react' | 'jsx'`) hold only a
// DERIVED region cache that the author never wrote — scaffolding translation
// keys off that cache would invent an authoring surface.
//
Expand Down Expand Up @@ -1099,8 +1100,10 @@ export function authorWarnedTranslationGroups(): ReadonlySet<string> {
* WALK — which components carry those keys — is `walkAddressedPageComponents`,
* the same traversal `translatePage` itself runs (commit c45d8e6b4, completing the key
* list's precedent). The walk owns the roots (`regions[].components[]` AND
* `slots.<slot>`), the descent (`properties.children` AND a panel's
* `properties.items[].children`, depth-capped, cycle-guarded) and the ruled
* `slots.<slot>`), the descent (the authorable slot positions spec derives
* from the component rows — `properties.children`, a card's
* `properties.footer`, a panel's `properties.items[].children` — depth-capped,
* cycle-guarded) and the ruled
* collision arbitration (commit 901355c3b: root level wins outright; among nested
* components, document-order first sighting) — this function used to
* hand-mirror all five and now owns none of them. What it still owns:
Expand All @@ -1113,15 +1116,14 @@ export function authorWarnedTranslationGroups(): ReadonlySet<string> {
* - the `label` either/or: `label` may be authored on the component itself
* or in its props — the same either/or `translatePage` resolves back onto.
*
* ⛔ Deliberately NOT `@objectstack/lint`'s `walkPageComponents`, which is
* WIDER than the resolver in two ways (`properties.body`, `properties.footer`
* — `page:card`'s slots, which the resolver leaves undescended as a renderer
* back-compat fallback rather than an authorable spelling; `slots.<slot>`
* roots and `properties.items[].children` were the other two until #16772
* brought both into the shared walk) and NARROWER in one (it skips
* `kind: 'html' | 'react' | 'jsx'` pages, which `translatePage` walks) —
* either direction of that mismatch is one half of the failure pair
* `PAGE_COMPONENT_COPY_KEYS`' own JSDoc names.
* ⛔ Deliberately NOT `@objectstack/lint`'s `walkPageComponents`. Its descent
* is no longer WIDER than the resolver's — since #20940 both read spec's one
* derived slot list, and `page:card`'s `properties.body` / `.footer` were the
* last positions lint walked and the resolver did not (`slots.<slot>` roots
* and `properties.items[].children` closed at #16772) — but it is still
* NARROWER in one way (it skips `kind: 'html' | 'react' | 'jsx'` pages, which
* `translatePage` walks), and a mismatch in either direction is one half of
* the failure pair `PAGE_COMPONENT_COPY_KEYS`' own JSDoc names.
*/
function emitPageComponentCopy(out: ExpectedEntry[], page: any, name: string): void {
walkAddressedPageComponents(page, (component, { id, nested, addressed }) => {
Expand Down
27 changes: 16 additions & 11 deletions packages/cli/test/platform-page-i18n-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,10 +288,13 @@ const walkParityPage = (): Record<string, any> => ({
'bare-component-id-string',
null,
],
// NOT descended by `translatePage`: `body`/`footer` are a
// renderer-side back-compat fallback, not an authorable
// composition spelling.
// NOT descended by `translatePage`: `body` is the retired
// spelling (#5775) — a renderer-side back-compat fallback, not an
// authorable composition spelling.
body: [{ id: 'card_body_child', type: 'object-metric', properties: { title: 'Body child' } }],
// DESCENDED since #20940 — `footer` is a declared, rendered slot,
// one of the positions spec's `pageComponentSlotPositions()`
// derives from the component rows.
footer: [{ id: 'card_footer_child', type: 'object-metric', properties: { title: 'Footer child' } }],
// DESCENDED since #16772 — a `page:tabs` / `page:accordion`
// panel's `items[].children`, one level below the container.
Expand Down Expand Up @@ -375,19 +378,21 @@ describe('i18n-extract ↔ translatePage walk parity (#13109)', () => {
it('pins the two sets by name, so a shape that stops being reachable is visible', () => {
const page = walkParityPage();
expect([...idsExtractorOffers(page)].sort()).toEqual([
'card', 'inner_flex', 'kpi_1', 'kpi_deep', 'kpi_label', 'nested_header', 'region_metric',
'slot_child', 'slot_list_child', 'tab_child',
'card', 'card_footer_child', 'inner_flex', 'kpi_1', 'kpi_deep', 'kpi_label', 'nested_header',
'region_metric', 'slot_child', 'slot_list_child', 'tab_child',
]);
// `card_body_child` and `card_footer_child` are absent from BOTH sides —
// the shapes `translatePage` does not descend. `tab_child`, `slot_child`
// and `slot_list_child` are present on BOTH sides since #16772 widened
// the shared walk to `items[].children` and to the `slots.<slot>` roots.
// `card_body_child` is absent from BOTH sides — the retired spelling
// `translatePage` does not descend. `card_footer_child` is present on BOTH
// sides since #20940 moved the shared walk onto the rows' one derived slot
// list, which names `footer`. `tab_child`, `slot_child` and
// `slot_list_child` are present on BOTH sides since #16772 widened the
// shared walk to `items[].children` and to the `slots.<slot>` roots.
// `hdr` — the region-level `page:header` — is absent from BOTH sides since
// the ruling. `nested_header` stays: a `page:header` inside a container is
// reached by the id route only, so the id key is the only key it has.
expect([...idsResolverApplies(page)].sort()).toEqual([
'card', 'inner_flex', 'kpi_1', 'kpi_deep', 'kpi_label', 'nested_header', 'region_metric',
'slot_child', 'slot_list_child', 'tab_child',
'card', 'card_footer_child', 'inner_flex', 'kpi_1', 'kpi_deep', 'kpi_label', 'nested_header',
'region_metric', 'slot_child', 'slot_list_child', 'tab_child',
]);
});

Expand Down
2 changes: 1 addition & 1 deletion packages/lint/src/page-envelope-audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
* | door | parses | reaches | blind to |
* |:--|:--|:--|:--|
* | 1 `PageSchema` | the whole page | every schema-typed position (component `visibleWhen`, and any expression key nested in a typed sub-schema) | anything inside `properties` |
* | 2 `PageComponentSchema` | each walked component | components nested INSIDE `properties` (`page:tabs` → `items[].children[]`, `page:card` → `body`/`footer`) | the `properties` bag itself |
* | 2 `PageComponentSchema` | each walked component | components nested INSIDE `properties` (`page:tabs` → `items[].children[]`, `page:card` → `footer`) | the `properties` bag itself |
* | 3 `ComponentPropsMap[type]` | each component's `properties` | expression keys the per-type props schema declares (`record:alert.properties.visible`) | types absent from the map |
*
* Door 2 exists because `PageComponentSchema.properties` is
Expand Down
Loading
Loading