@akinon/pz-theme 2.0.78-rc.2 → 2.0.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +6 -41
  2. package/package.json +4 -4
  3. package/src/__tests__/custom-catalog-announce.test.ts +73 -0
  4. package/src/chrome/__tests__/chrome-mode.test.ts +174 -0
  5. package/src/chrome/chrome-mode.ts +61 -0
  6. package/src/custom/README-sections.md +289 -0
  7. package/src/custom/README.md +7 -0
  8. package/src/custom/__tests__/custom-section-bindings.test.tsx +327 -0
  9. package/src/custom/__tests__/define-section.test.tsx +481 -0
  10. package/src/custom/__tests__/module-boundaries.test.ts +2 -0
  11. package/src/custom/__tests__/public-api.test.tsx +123 -0
  12. package/src/custom/__tests__/region-node-wire.test.tsx +302 -0
  13. package/src/custom/__tests__/region-nodes.test.ts +161 -0
  14. package/src/custom/__tests__/theme-editable.test.tsx +22 -10
  15. package/src/custom/__tests__/theme-region.test.tsx +331 -0
  16. package/src/custom/__tests__/theme-slot.test.tsx +18 -10
  17. package/src/custom/__tests__/use-theme-setting.test.tsx +17 -9
  18. package/src/custom/block-context.ts +53 -10
  19. package/src/custom/block-host.tsx +13 -1
  20. package/src/custom/collect.ts +26 -5
  21. package/src/custom/define-section.tsx +163 -0
  22. package/src/custom/index.ts +83 -1
  23. package/src/custom/region-nodes.ts +72 -0
  24. package/src/custom/register.ts +419 -48
  25. package/src/custom/section-catalog.ts +98 -0
  26. package/src/custom/section-definitions.ts +59 -0
  27. package/src/custom/section-host.tsx +125 -0
  28. package/src/custom/section-types.ts +158 -0
  29. package/src/custom/theme-editable.tsx +3 -1
  30. package/src/custom/theme-region.tsx +213 -0
  31. package/src/custom/theme-slot.tsx +8 -7
  32. package/src/custom/types.ts +10 -1
  33. package/src/custom/use-theme-setting.ts +6 -4
  34. package/src/hooks/use-designer-features.tsx +41 -0
  35. package/src/index.ts +25 -1
  36. package/src/placeholder-registry.ts +34 -5
  37. package/src/sections/__tests__/custom-renderer-section-types.test.ts +29 -0
  38. package/src/sections/__tests__/section-part-selection.test.tsx +277 -0
  39. package/src/sections/custom-renderer-section-types.ts +19 -0
  40. package/src/sections/section-wrapper.tsx +12 -1
  41. package/src/theme-section.tsx +162 -3
  42. package/src/utils/__tests__/docked-state-styles.test.ts +249 -0
  43. package/src/utils/__tests__/section-content-width.test.ts +28 -0
  44. package/src/utils/__tests__/section-max-width.test.ts +53 -0
  45. package/src/utils/index.ts +31 -3
  46. package/src/utils/part-styles.ts +17 -1
@@ -0,0 +1,289 @@
1
+ # Custom sections
2
+
3
+ A brand developer writes a whole **area** — a header, a footer, eventually a
4
+ product detail page — in their own repo, names the children that go inside it,
5
+ and hands each one to the merchant as an ordinary composed block. The section
6
+ appears in the theme editor's Add Section band under **Brand sections**.
7
+
8
+ Read [`README.md`](./README.md) first. Everything about `ThemeEditable`,
9
+ `useThemeSetting`, `ThemeSlot`, `partAttrs` and the limits works identically
10
+ inside a section; this file is only the difference.
11
+
12
+ ## When a section instead of a block
13
+
14
+ A block, because it is dropped into a section the merchant created, gets the
15
+ near-bare block-root panel and lives inside a wrapper the merchant can put a
16
+ `transform` on — which traps a `position: fixed` descendant, measured once as a
17
+ 1247px header collapsing to 600px.
18
+
19
+ A section root gets the geometry family (`position`, `display`, `height`,
20
+ `max-height`, `z-index`, `grid-template-columns`) and Content Width, and there
21
+ is no wrapper left to spring the trap.
22
+
23
+ **Rule of thumb:** if the thing you are writing owns its own band on the page,
24
+ write a section. If it is one element inside a band the merchant composed,
25
+ write a block.
26
+
27
+ ## The shape
28
+
29
+ ```tsx
30
+ 'use client';
31
+
32
+ import {
33
+ defineCustomSection,
34
+ partAttrs,
35
+ ThemeRegion,
36
+ ThemeSlot
37
+ } from '@akinon/pz-theme/src/custom';
38
+
39
+ function StoreHeader() {
40
+ return (
41
+ <header {...partAttrs('bar')}>
42
+ <div {...partAttrs('row')}>
43
+ <ThemeRegion name="logo" label="Logo" />
44
+
45
+ <nav {...partAttrs('actions')}>
46
+ <ThemeRegion name="search" label="Search" />
47
+ <ThemeRegion name="basket" label="Basket" />
48
+ <ThemeSlot name="extras" label="Extra header actions" />
49
+ </nav>
50
+ </div>
51
+ </header>
52
+ );
53
+ }
54
+
55
+ export default defineCustomSection(StoreHeader, {
56
+ label: 'Store header',
57
+ placeholders: ['header'],
58
+
59
+ regions: [
60
+ { name: 'logo', label: 'Logo', block: 'image', value: '/logo.svg' },
61
+ { name: 'search', label: 'Search', block: 'search-box' },
62
+ { name: 'basket', label: 'Basket', block: 'mini-basket' }
63
+ ],
64
+
65
+ slots: [{ name: 'extras', label: 'Extra header actions' }],
66
+
67
+ parts: ['bar', 'row', 'actions'],
68
+
69
+ styles: {
70
+ position: { desktop: 'fixed' },
71
+ inset: { desktop: '0 0 auto 0' },
72
+ 'background-color': { desktop: 'transparent' },
73
+ ':scrolled/background-color': { desktop: '#000000' }
74
+ },
75
+
76
+ lockRootTransforms: true
77
+ });
78
+ ```
79
+
80
+ Then, from the **client root only**:
81
+
82
+ ```ts
83
+ registerCustomSections('acme', { header: StoreHeader });
84
+ ```
85
+
86
+ ## `ThemeRegion` — the one genuinely new piece
87
+
88
+ A region is a child you named, chose the type of, and placed. Declaring
89
+ `{ name: 'search', block: 'search-box' }` is what buys the whole search-box
90
+ panel: its settings, its shared controls, the 161 advanced CSS controls and its
91
+ part groups. You write no descriptor for it.
92
+
93
+ The merchant can style it, configure it, hide it and delete it. They **cannot**
94
+ reorder it or insert beside it — your JSX owns the layout, and no drag in the
95
+ layer tree changes where it renders.
96
+
97
+ `properties`, `styles` and `value` on a region are a **seed**: what it looks
98
+ like the moment the section is added. They are written once, at insert, and
99
+ nothing re-applies them, so a merchant's edit is never quietly reverted.
100
+ `value` is there for the block types that keep their content there rather than
101
+ in `properties` — an `image` reads `block.value` for its src.
102
+
103
+ **A deleted region draws a labelled band in the designer and nothing at all
104
+ live.** That is not an error state: deleting is an ordinary composition edit and
105
+ has to stay durable. Regions are seeded once, at insert; the editor lists every
106
+ absent one under the section with an **Add back** control, so a deletion is
107
+ reversible without being undone behind the merchant's back.
108
+
109
+ **There is no unnamed region.** A bare `<ThemeSlot />` resolves to a block's
110
+ single slot; a region has no such fallback, because a section's direct children
111
+ are the merchant's own composed blocks and a nameless region would swallow all
112
+ of them.
113
+
114
+ ## Region or slot
115
+
116
+ | | Region | Slot |
117
+ |---|---|---|
118
+ | Who chooses the block type | you | the merchant |
119
+ | Who chooses where it renders | you | you choose where the slot is |
120
+ | How many blocks | exactly one | any number |
121
+ | Merchant can reorder | no | yes, inside the slot |
122
+ | Merchant can delete | yes, and it stays deleted (Add back restores it) | the row itself, no |
123
+
124
+ ## Commerce data
125
+
126
+ Two different things want data and only one of them is yours.
127
+
128
+ **The merchant's blocks resolve their own bindings, and you wire nothing.**
129
+ Every block inside a region or a slot goes through the same pass a block under
130
+ an ordinary section root goes through: `{{...}}` tokens in `dataBinding`, a bare
131
+ `dataBinding` path, and an `isIterator` block repeating over the section's data
132
+ source. That is the point — the merchant composed those blocks before you
133
+ rewrote the area, and rewriting the area must not change what they say. It ran
134
+ only on the generic path once, which meant a header moved into a custom section
135
+ silently fell back to its authored text with nothing logged anywhere.
136
+
137
+ Unlike the generic path, the pass is not gated on the section type: a
138
+ `custom:acme:hero` root resolves bindings whenever there is anything to resolve
139
+ against — a collection source, a static source, or the page's own commerce data.
140
+ With no source at all the walk finds no root and returns the tree untouched, so
141
+ the merchant's authored fallback stands.
142
+
143
+ **Your own JSX reads the page instead.** The section component is code, so it
144
+ asks directly rather than through a binding:
145
+
146
+ ```tsx
147
+ import { useThemePageContext } from '@akinon/pz-theme/src/theme-page-context';
148
+
149
+ const pageContext = useThemePageContext();
150
+ ```
151
+
152
+ Import it from that deep path, not from `@akinon/pz-theme`. The package root
153
+ re-exports `ThemePlaceholder`, which imports `server-only`, and that poisons the
154
+ whole module for a client graph — a section file is a client module, so the root
155
+ import fails the brand's build with "cannot be imported from a Client Component
156
+ module". `@akinon/pz-theme/src/custom` does not re-export it either; the deep
157
+ module is the one route.
158
+
159
+ ## Parts must live on YOUR markup
160
+
161
+ Part rules are generated with an ownership guard that excludes anything inside a
162
+ descendant `[data-block-id]`. Every region renders inside one. So a `data-part`
163
+ stamped on markup a region's block renders is excluded from its own section's
164
+ part rules, and the control the panel drew for it does nothing.
165
+
166
+ Stamp parts on the elements the section itself draws — the bands **around** the
167
+ regions.
168
+
169
+ ## The three page-level states
170
+
171
+ Every other style state is a fact about an element and matches on it: `hover`,
172
+ `selected`, `invalid`. Three are facts about the **page**, and they are the only
173
+ way a composition learns something it cannot see from where it sits.
174
+
175
+ | State | Means | Stamped on | By |
176
+ |---|---|---|---|
177
+ | `scrolled` | the page has moved under the chrome | the chrome slot, `data-scroll-state` | pz-theme (`chrome/scroll-state.tsx`) |
178
+ | `overlay-open` | some overlay is open | `body`, `data-overlay-state` | pz-theme (`hooks/use-overlay.ts`) |
179
+ | `docked` | the chrome sits **above** the page, not over it | `body`, `data-chrome-mode` | **you**, via `setChromeMode` |
180
+
181
+ All three work as a root state on the section itself, `:<state>/<property>`, and
182
+ as a part state, `<part>/<state>/<property>`.
183
+
184
+ **The one rule: pz-theme owns the vocabulary, the brand owns the signal.** The
185
+ attribute names, the tokens and the selectors that read them live here. Nothing
186
+ in pz-theme can know which of your routes carry a hero, so nothing in pz-theme
187
+ stamps `docked`. You do — and through `setChromeMode`, never by hand-writing the
188
+ attribute, because a typo there raises no error and simply produces a header
189
+ that never changes appearance.
190
+
191
+ ### The worked example: a header with two appearances
192
+
193
+ This is what `docked` exists for. Over the home page's hero the header is
194
+ transparent with light content; on every other route it sits above the page,
195
+ solid, with dark content. Which one applies is a fact about the **route**, and a
196
+ route is the one thing a composition cannot ask about. Before this, the choice
197
+ was to keep the header in code and lose every merchant control, or compose it
198
+ and lose the second appearance.
199
+
200
+ ```tsx
201
+ 'use client';
202
+
203
+ const isHome = usePathname() === '/';
204
+
205
+ useEffect(() => setChromeMode(isHome ? null : CHROME_MODE_DOCKED), [isHome]);
206
+ ```
207
+
208
+ `setChromeMode` returns a cleanup, which is why it is returned straight from the
209
+ effect. **Give the signal one owner.** The cleanup restores the value that call
210
+ displaced — a snapshot, not a counted stack — so it unwinds correctly as one
211
+ component mounts, re-stamps and unmounts, and does *not* compose if two
212
+ components both stamp during a route transition.
213
+
214
+ The section then styles all three appearances, and no descriptor changes:
215
+
216
+ ```ts
217
+ styles: {
218
+ 'background-color': { desktop: 'transparent' },
219
+ color: { desktop: '#ffffff' },
220
+
221
+ // Docked: above the page, solid, burgundy content.
222
+ ':docked/background-color': { desktop: '#ffffff' },
223
+ ':docked/color': { desktop: '#4a0e26' },
224
+
225
+ // Still transparent over the hero, but opaque once the page moves.
226
+ ':scrolled/background-color': { desktop: '#ffffff' },
227
+
228
+ // And opaque while the mega menu is open, which the menu cannot say itself.
229
+ ':overlay-open/background-color': { desktop: '#ffffff' }
230
+ }
231
+ ```
232
+
233
+ **Absent means overlay.** The attribute is not written until something writes
234
+ it, so every composition published before `docked` existed renders exactly as it
235
+ did.
236
+
237
+ **`docked` outranks `scrolled` on purpose.** On an inner page both are true at
238
+ once — a docked header is scrolled the moment the shopper moves — and both set
239
+ the same property on the same band. The `docked` selector is qualified with
240
+ `body`, which makes it (0,2,1) against `scrolled` at (0,2,0), so the mode wins
241
+ on specificity rather than on whichever rule happened to be emitted last. That
242
+ is the right answer and not an arbitrary one: `scrolled` describes how far the
243
+ page has slid under a bar that floats over it, and once the bar is docked the
244
+ question has no meaning. If you want a docked header to change on scroll as
245
+ well, say so with a `docked` value that differs from the plain one.
246
+
247
+ ## `lockRootTransforms`
248
+
249
+ Set it on any section whose design contains a `position: fixed` or `sticky`
250
+ descendant. It removes `rotate`, `scale`, `translate`, `filter` and
251
+ `backdrop-filter` from the section root's panel. Each of those creates a
252
+ containing block for a fixed descendant, and a merchant has no way to know that.
253
+
254
+ Remembering to set it is on you. It defaults to off because most sections do not
255
+ contain a fixed child, and withholding controls a section could legitimately use
256
+ is its own kind of lie.
257
+
258
+ ## `placeholders`
259
+
260
+ `placeholders: ['header']` offers the section on the header placeholder and
261
+ nowhere else. Omit it to offer it everywhere. A header section on the basket
262
+ page is noise the merchant has to scroll past.
263
+
264
+ ## Limits
265
+
266
+ Sixteen section types per build, twenty-four settings, twenty-four regions,
267
+ eight slots and thirty-two parts per type, and a 256 KB **section** catalog
268
+ measured separately from the block one.
269
+
270
+ Sixteen rather than the blocks' forty because a section descriptor is the larger
271
+ of the two — it carries a region list with seeded properties, styles and values —
272
+ and a brand needs far fewer of them: sections are pages and chrome regions,
273
+ blocks are the vocabulary inside them.
274
+
275
+ ## One type namespace
276
+
277
+ `custom:acme:header` can be claimed by a block **or** a section, never both.
278
+ Registration refuses either order with a message that says which one already
279
+ holds it.
280
+
281
+ ## Failure behaviour
282
+
283
+ A composition naming a section type this build does not register renders nothing
284
+ on the live site and a labelled, selectable band in the designer — never the
285
+ generic block loop, which would spill the seeded region blocks onto the page as
286
+ loose top-level content in an order you never chose.
287
+
288
+ A section component that throws is caught per-section: nothing live, a labelled
289
+ card in the designer, and everything composed beside it renders normally.
@@ -9,6 +9,13 @@ with ordinary editor content.
9
9
  Nothing here is a schema kept alongside the component. The declaration *is* the
10
10
  usage.
11
11
 
12
+ **Writing a whole area rather than one element inside it?** See
13
+ [`README-sections.md`](./README-sections.md). A custom *section* owns its own
14
+ band on the page: it gets the section root panel (geometry and content width),
15
+ and it names the children that go inside it rather than opening a hole. A custom
16
+ *block* is the right shape for one element the merchant drops into a band they
17
+ composed.
18
+
12
19
  ## The shape
13
20
 
14
21
  A "Complete the look" row for a product page — the merchant retypes its
@@ -0,0 +1,327 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+
4
+ import blockRendererRegistry from '../../blocks/block-renderer-registry';
5
+ import ThemeSection from '../../theme-section';
6
+ import type { Section } from '../../theme-section';
7
+ import { ThemeSettingsProvider } from '../../theme-settings-context';
8
+ import { resetCustomCatalog } from '../catalog';
9
+ import { resetCustomCollection } from '../collect';
10
+ import { defineCustomSection } from '../define-section';
11
+ import { registerCustomSections } from '../register';
12
+ import { resetCustomSectionCatalog } from '../section-catalog';
13
+ import { resetCustomSectionDefinitions } from '../section-definitions';
14
+ import { ThemeRegion } from '../theme-region';
15
+ import { isCustomThemeType } from '../types';
16
+
17
+ /**
18
+ * THE MERCHANT'S DATA HAS TO SURVIVE THE REWRITE.
19
+ *
20
+ * A brand takes a header the merchant already composed — bound text, a repeat
21
+ * over the catalogue — and rewrites the area as a developer-authored section so
22
+ * the JSX can own the layout. The blocks do not change: the same tree moves
23
+ * from under a generic section root to under a `custom:acme:hero` one.
24
+ *
25
+ * It used to lose every binding in the move. The custom branch of
26
+ * `ThemeSection` returned before `resolveIteratorsRecursively` and
27
+ * `resolveBlockBindings` ran, so `ThemeRegion`/`ThemeSlot` handed
28
+ * `renderComposedChildren` raw blocks and `ThemeBlock` resolves nothing: the
29
+ * text fell back to its authored value and the repeat drew one empty row. No
30
+ * console output in the designer, none on the storefront, and nothing in the
31
+ * diff to review — the section markup was the only thing that changed.
32
+ *
33
+ * EVERY CASE IS ASSERTED AS AN EQUALITY BETWEEN THE TWO RENDERS, never against
34
+ * a literal. A literal expectation ("1,299.90 TL") pins today's formatter and
35
+ * says nothing about the two paths agreeing; if the fixture or the formatting
36
+ * moves, an equality test still asks the only question that matters — does the
37
+ * custom root render what the generic root renders. The second case below —
38
+ * "proves that equality is over resolved data" — is what stops that equality
39
+ * passing because both sides are equally broken.
40
+ */
41
+
42
+ const CUSTOM_TYPE = 'custom:acme:hero';
43
+
44
+ /**
45
+ * `custom` is the type a merchant-composed section actually carries, so this is
46
+ * the real before-state of the rewrite, not a synthetic control. It is also the
47
+ * one type the generic path resolves bindings for unconditionally
48
+ * (`shouldApplyGenericBindings`), which is exactly why a `custom:acme:hero`
49
+ * root cannot reuse that predicate: it is not the string `custom`.
50
+ */
51
+ const GENERIC_TYPE = 'custom';
52
+
53
+ const CATALOGUE = [
54
+ { name: 'Spring Sneaker', colour: 'sand' },
55
+ { name: 'Trail Runner', colour: 'moss' },
56
+ { name: 'City Loafer', colour: 'ink' }
57
+ ];
58
+
59
+ /** Fewer rows than the catalogue holds, so the slice is visibly doing something. */
60
+ const ITERATOR_COUNT = 2;
61
+
62
+ /** A collection source, which is the case `shouldApplyGenericBindings` gates on. */
63
+ const DATA_SOURCE = {
64
+ type: 'collection',
65
+ details: { collection: { data: CATALOGUE } }
66
+ };
67
+
68
+ /**
69
+ * The blocks, authored once and rendered under both roots byte for byte.
70
+ *
71
+ * `properties.regionName` is what a section's `<ThemeRegion>` resolves against
72
+ * and is an ordinary unread property everywhere else, so one array serves both
73
+ * fixtures and the ONLY difference between the two renders is `section.type`.
74
+ */
75
+ const authoredBlocks = () => [
76
+ {
77
+ // (a) A `{{...}}` token inside literal text.
78
+ id: 'block-headline',
79
+ type: 'text',
80
+ label: 'Headline',
81
+ value: 'Authored headline fallback',
82
+ properties: {
83
+ regionName: 'headline',
84
+ dataBinding: 'Featured: {{ products[0].name }}'
85
+ },
86
+ styles: {},
87
+ order: 0,
88
+ hidden: false
89
+ },
90
+ {
91
+ // (b) A bare `properties.dataBinding` path.
92
+ id: 'block-lead',
93
+ type: 'text',
94
+ label: 'Lead',
95
+ value: 'Authored lead fallback',
96
+ properties: { regionName: 'lead', dataBinding: 'products[1].name' },
97
+ styles: {},
98
+ order: 1,
99
+ hidden: false
100
+ },
101
+ {
102
+ // (c) A repeat over the section's data source, one bound row template.
103
+ id: 'block-list',
104
+ type: 'group',
105
+ label: 'Catalogue',
106
+ isIterator: true,
107
+ iteratorDataPath: 'products',
108
+ properties: {
109
+ regionName: 'list',
110
+ useIteratorCount: true,
111
+ iteratorCount: ITERATOR_COUNT
112
+ },
113
+ styles: {},
114
+ order: 2,
115
+ hidden: false,
116
+ blocks: [
117
+ {
118
+ id: 'block-row',
119
+ type: 'text',
120
+ label: 'Row',
121
+ value: 'Authored row fallback',
122
+ properties: { dataBinding: 'item.name' },
123
+ styles: {},
124
+ order: 0,
125
+ hidden: false
126
+ }
127
+ ]
128
+ }
129
+ ];
130
+
131
+ const sectionOfType = (type: string): Section =>
132
+ ({
133
+ id: `section-${type}`,
134
+ type,
135
+ name: 'Store hero',
136
+ label: 'Store hero',
137
+ properties: {},
138
+ styles: {},
139
+ order: 0,
140
+ hidden: false,
141
+ dataSource: DATA_SOURCE,
142
+ blocks: authoredBlocks()
143
+ } as unknown as Section);
144
+
145
+ /** Places one region per authored block, in the order they were authored. */
146
+ function AcmeHero() {
147
+ return (
148
+ <div>
149
+ <div data-testid="headline-area">
150
+ <ThemeRegion name="headline" />
151
+ </div>
152
+ <div data-testid="lead-area">
153
+ <ThemeRegion name="lead" />
154
+ </div>
155
+ <div data-testid="list-area">
156
+ <ThemeRegion name="list" />
157
+ </div>
158
+ </div>
159
+ );
160
+ }
161
+
162
+ const registerHero = () =>
163
+ registerCustomSections('acme', {
164
+ hero: defineCustomSection(AcmeHero, {
165
+ label: 'Store hero',
166
+ regions: [
167
+ { name: 'headline', label: 'Headline', block: 'text' },
168
+ { name: 'lead', label: 'Lead', block: 'text' },
169
+ { name: 'list', label: 'Catalogue', block: 'group' }
170
+ ]
171
+ })
172
+ });
173
+
174
+ const rendererMap = (): Map<string, unknown> =>
175
+ (blockRendererRegistry as unknown as { renderers: Map<string, unknown> })
176
+ .renderers;
177
+
178
+ const clearCustomRenderers = (): void => {
179
+ const map = rendererMap();
180
+ Array.from(map.keys())
181
+ .filter(isCustomThemeType)
182
+ .forEach((type) => map.delete(type));
183
+ };
184
+
185
+ const renderSection = (section: Section) =>
186
+ render(
187
+ <ThemeSettingsProvider value={null}>
188
+ <ThemeSection
189
+ section={section}
190
+ placeholderId="header"
191
+ currentBreakpoint="desktop"
192
+ isDesigner={false}
193
+ />
194
+ </ThemeSettingsProvider>
195
+ );
196
+
197
+ /**
198
+ * Collapsed whitespace, because the two roots wrap the same blocks in different
199
+ * markup and a comparison that counted the gaps between elements would be
200
+ * asserting the JSX rather than the data.
201
+ */
202
+ const renderedText = (section: Section): string =>
203
+ (renderSection(section).container.textContent ?? '')
204
+ .replace(/\s+/g, ' ')
205
+ .trim();
206
+
207
+ /** Direct `[data-block-id]` children — the top-level blocks the root rendered. */
208
+ const topLevelBlockIds = (parent: Element | null): string[] =>
209
+ Array.from(parent?.children ?? [])
210
+ .filter((child) => child.hasAttribute('data-block-id'))
211
+ .map((child) => child.getAttribute('data-block-id') ?? '');
212
+
213
+ describe('merchant bindings inside a developer-authored section', () => {
214
+ beforeEach(() => {
215
+ resetCustomCatalog();
216
+ resetCustomCollection();
217
+ resetCustomSectionCatalog();
218
+ resetCustomSectionDefinitions();
219
+ clearCustomRenderers();
220
+ });
221
+
222
+ it('renders the same text as the generic root the blocks came from', () => {
223
+ registerHero();
224
+
225
+ const generic = renderedText(sectionOfType(GENERIC_TYPE));
226
+ const custom = renderedText(sectionOfType(CUSTOM_TYPE));
227
+
228
+ // The whole regression in one line: rewriting the area as a section must
229
+ // change where the blocks render, never what they say.
230
+ expect(custom).toBe(generic);
231
+ });
232
+
233
+ it('proves that equality is over resolved data, not two dead renders', () => {
234
+ registerHero();
235
+
236
+ // Without this the assertion above would pass just as happily if both
237
+ // roots printed the authored fallbacks, which is the state this file
238
+ // exists to forbid.
239
+ const custom = renderedText(sectionOfType(CUSTOM_TYPE));
240
+
241
+ expect(custom).toContain(`Featured: ${CATALOGUE[0].name}`);
242
+ expect(custom).toContain(CATALOGUE[1].name);
243
+ expect(custom).not.toContain('Authored');
244
+ });
245
+
246
+ it.each([
247
+ ['a {{...}} token', 'headline-area', `Featured: ${CATALOGUE[0].name}`],
248
+ ['a properties.dataBinding path', 'lead-area', CATALOGUE[1].name],
249
+ [
250
+ 'an isIterator block over the data source',
251
+ 'list-area',
252
+ // Two rows, `iteratorCount` of them, spelled from the fixture so the
253
+ // case keeps its meaning if the catalogue changes. Concatenated with no
254
+ // separator because adjacent rows put no whitespace between themselves.
255
+ CATALOGUE.slice(0, ITERATOR_COUNT)
256
+ .map((product) => product.name)
257
+ .join('')
258
+ ]
259
+ ])('resolves %s in the region that places it', (_case, area, expected) => {
260
+ registerHero();
261
+ renderSection(sectionOfType(CUSTOM_TYPE));
262
+
263
+ // Region by region rather than on the whole section, so a binding that
264
+ // resolved into the WRONG region cannot pass on the concatenated text.
265
+ const text = (screen.getByTestId(area).textContent ?? '')
266
+ .replace(/\s+/g, ' ')
267
+ .trim();
268
+
269
+ expect(text).toBe(expected);
270
+ });
271
+
272
+ it('leaves the top-level block count untouched when a repeat expands', () => {
273
+ registerHero();
274
+
275
+ // THE INVARIANT `findRegionChild` DEPENDS ON. It matches by property over
276
+ // `section.blocks`, so a resolution pass that spliced a repeat's rows in as
277
+ // siblings would leave every later region resolving against a shifted list.
278
+ // `buildIteratorBlock` expands INSIDE the iterator block, and the custom
279
+ // branch maps one resolved block per authored block — this is what pins
280
+ // both halves of that.
281
+ const { container } = renderSection(sectionOfType(GENERIC_TYPE));
282
+
283
+ expect(
284
+ topLevelBlockIds(container.querySelector('[data-section-content]'))
285
+ ).toEqual(['block-headline', 'block-lead', 'block-list']);
286
+ });
287
+
288
+ it('still resolves each region to its own block after resolution', () => {
289
+ registerHero();
290
+ renderSection(sectionOfType(CUSTOM_TYPE));
291
+
292
+ // One block per region, still the block that carries that region's name —
293
+ // the repeat sitting between them expanded without displacing anything.
294
+ expect(topLevelBlockIds(screen.getByTestId('headline-area'))).toEqual([
295
+ 'block-headline'
296
+ ]);
297
+ expect(topLevelBlockIds(screen.getByTestId('lead-area'))).toEqual([
298
+ 'block-lead'
299
+ ]);
300
+ expect(topLevelBlockIds(screen.getByTestId('list-area'))).toEqual([
301
+ 'block-list'
302
+ ]);
303
+ });
304
+
305
+ it('keeps the authored value when there is nothing to bind against', () => {
306
+ registerHero();
307
+
308
+ // The unconditional binding pass must not invent a value: with no data
309
+ // source and no page context `resolveBlockBindings` finds no binding root
310
+ // and returns the tree unchanged, exactly as the generic path does.
311
+ const withoutData = sectionOfType(CUSTOM_TYPE);
312
+ delete (withoutData as unknown as Record<string, unknown>).dataSource;
313
+
314
+ const custom = renderedText(withoutData);
315
+ const generic = renderedText(
316
+ (() => {
317
+ const genericWithoutData = sectionOfType(GENERIC_TYPE);
318
+ delete (genericWithoutData as unknown as Record<string, unknown>)
319
+ .dataSource;
320
+ return genericWithoutData;
321
+ })()
322
+ );
323
+
324
+ expect(custom).toBe(generic);
325
+ expect(custom).toContain('Authored headline fallback');
326
+ });
327
+ });