@akinon/pz-theme 2.0.78-rc.1 → 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 +12 -39
  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,331 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+
4
+ // Stub the recursive ThemeBlock (default export) with a countable marker, the
5
+ // same way theme-slot.test.tsx does — a region is only honest if the block it
6
+ // hands over is the one that reaches the renderer.
7
+ jest.mock('../../theme-block', () => ({
8
+ __esModule: true,
9
+ default: ({ block }: { block: { id: string } }) => (
10
+ <div data-testid="tb" data-id={block.id} />
11
+ )
12
+ }));
13
+
14
+ import { CustomBlockContext } from '../block-context';
15
+ import type { CustomBlockContextValue } from '../block-context';
16
+ import { REGION_NAME_PROPERTY } from '../section-types';
17
+ import type { Block } from '../../theme-block';
18
+ import { ThemeRegion } from '../theme-region';
19
+ import { resetDevWarnCacheForTests } from '../../utils/dev-warn';
20
+
21
+ /**
22
+ * A seeded region as the editor writes one: an ORDINARY block of a real type
23
+ * carrying the reserved name property. There is no wrapper node — the merchant
24
+ * opens this row and gets the `image`/`mini-basket` panel itself.
25
+ */
26
+ const regionBlock = (name: string, over: Partial<Block> = {}): Block => ({
27
+ id: `region-${name}`,
28
+ type: 'image',
29
+ label: name,
30
+ value: '',
31
+ properties: { [REGION_NAME_PROPERTY]: name },
32
+ styles: {},
33
+ order: 0,
34
+ hidden: false,
35
+ ...over
36
+ });
37
+
38
+ const contextValue = ({
39
+ blocks,
40
+ isDesigner = false,
41
+ scope = 'section',
42
+ declaredRegionNames
43
+ }: {
44
+ blocks?: Block[];
45
+ isDesigner?: boolean;
46
+ scope?: 'block' | 'section';
47
+ declaredRegionNames?: string[];
48
+ } = {}): CustomBlockContextValue => {
49
+ const node = {
50
+ id: 'sec-1',
51
+ type:
52
+ scope === 'section' ? 'custom:acme:header' : 'custom:acme:promo-strip',
53
+ label: 'Header',
54
+ properties: {},
55
+ styles: {},
56
+ order: 0,
57
+ hidden: false,
58
+ blocks
59
+ };
60
+
61
+ // `node` is what the helpers read and `block` is the compatibility alias;
62
+ // they are the SAME object here, which is what a section host provides.
63
+ return {
64
+ node,
65
+ scope,
66
+ block: node as unknown as Block,
67
+ placeholderId: 'header',
68
+ sectionId: 'sec-1',
69
+ isDesigner,
70
+ currentBreakpoint: 'desktop',
71
+ ...(declaredRegionNames ? { declaredRegionNames } : {})
72
+ };
73
+ };
74
+
75
+ const renderRegion = (
76
+ value: CustomBlockContextValue | null,
77
+ region: React.ReactNode = <ThemeRegion name="search" />
78
+ ) =>
79
+ render(
80
+ value === null ? (
81
+ <>{region}</>
82
+ ) : (
83
+ <CustomBlockContext.Provider value={value}>
84
+ {region}
85
+ </CustomBlockContext.Provider>
86
+ )
87
+ );
88
+
89
+ const markers = (container: HTMLElement) =>
90
+ Array.from(container.querySelectorAll('[data-testid="tb"]'));
91
+
92
+ const bands = (container: HTMLElement) =>
93
+ Array.from(container.querySelectorAll('[data-theme-block-placeholder]'));
94
+
95
+ beforeEach(() => {
96
+ resetDevWarnCacheForTests();
97
+ });
98
+
99
+ describe('ThemeRegion — the seeded block', () => {
100
+ it('renders it through ThemeBlock, where the JSX put the region', () => {
101
+ const { container } = renderRegion(
102
+ contextValue({ blocks: [regionBlock('search')] })
103
+ );
104
+
105
+ expect(markers(container).map((m) => m.getAttribute('data-id'))).toEqual([
106
+ 'region-search'
107
+ ]);
108
+ });
109
+
110
+ it('places each declared region independently in one render', () => {
111
+ const { container } = renderRegion(
112
+ contextValue({
113
+ blocks: [regionBlock('search'), regionBlock('basket')],
114
+ declaredRegionNames: ['search', 'basket']
115
+ }),
116
+ <>
117
+ <ThemeRegion name="basket" />
118
+ <ThemeRegion name="search" />
119
+ </>
120
+ );
121
+
122
+ // The JSX owns the order, not the composition: `basket` is seeded second
123
+ // and rendered first, and no drag in the layer tree can change that.
124
+ expect(markers(container).map((m) => m.getAttribute('data-id'))).toEqual([
125
+ 'region-basket',
126
+ 'region-search'
127
+ ]);
128
+ });
129
+
130
+ it('never renders a merchant composed block for a region it does not carry', () => {
131
+ // The anti-slot-fallback invariant, from the component side. A section's
132
+ // direct children are the merchant's own blocks; a region that fell back
133
+ // to them would draw the entire section inside itself.
134
+ const { container } = renderRegion(
135
+ contextValue({
136
+ blocks: [
137
+ { ...regionBlock('search'), id: 'merchant-hero', properties: {} }
138
+ ],
139
+ isDesigner: true,
140
+ declaredRegionNames: ['search']
141
+ })
142
+ );
143
+
144
+ expect(markers(container)).toHaveLength(0);
145
+ expect(bands(container)).toHaveLength(1);
146
+ });
147
+ });
148
+
149
+ describe('ThemeRegion — a region the merchant deleted', () => {
150
+ it('draws a labelled band in the designer so the hole can be filled', () => {
151
+ const { container } = renderRegion(
152
+ contextValue({
153
+ blocks: [],
154
+ isDesigner: true,
155
+ declaredRegionNames: ['search']
156
+ }),
157
+ <ThemeRegion name="search" label="Store search" />
158
+ );
159
+
160
+ expect(bands(container)).toHaveLength(1);
161
+ expect(container.textContent).toContain('Store search');
162
+ expect(container.textContent).toContain('removed');
163
+ });
164
+
165
+ it('renders nothing at all on the live site', () => {
166
+ // Deleting a region is an ordinary composition edit and has to stay
167
+ // durable — a header whose store search was removed does not grow one back
168
+ // on the next load.
169
+ const { container } = renderRegion(
170
+ contextValue({ blocks: [], declaredRegionNames: ['search'] })
171
+ );
172
+
173
+ expect(container).toBeEmptyDOMElement();
174
+ });
175
+ });
176
+
177
+ describe('ThemeRegion — a hidden region block', () => {
178
+ it('renders nothing live and stays selectable in the designer', () => {
179
+ const blocks = [regionBlock('search', { hidden: true })];
180
+
181
+ const live = renderRegion(contextValue({ blocks }));
182
+ expect(live.container).toBeEmptyDOMElement();
183
+
184
+ // Emptiness is decided AFTER the child pipeline, not from the array
185
+ // length: the designer keeps hidden children mounted so the layer tree can
186
+ // still reach them, and both sides draw what they actually rendered.
187
+ const designer = renderRegion(contextValue({ blocks, isDesigner: true }));
188
+ expect(markers(designer.container)).toHaveLength(1);
189
+ expect(bands(designer.container)).toHaveLength(0);
190
+ });
191
+ });
192
+
193
+ describe('ThemeRegion — the same region placed twice', () => {
194
+ it('renders the seeded block once, not twice', () => {
195
+ const warn = jest
196
+ .spyOn(console, 'warn')
197
+ .mockImplementation(() => undefined);
198
+
199
+ const { container } = renderRegion(
200
+ contextValue({ blocks: [regionBlock('search')] }),
201
+ <>
202
+ <ThemeRegion name="search" />
203
+ <ThemeRegion name="search" />
204
+ </>
205
+ );
206
+
207
+ // Both resolve to the SAME block, so rendering both would answer one
208
+ // `data-block-id` with two wrappers and break selection on the canvas.
209
+ expect(markers(container)).toHaveLength(1);
210
+ expect(warn).toHaveBeenCalledWith(
211
+ expect.stringContaining('more than once')
212
+ );
213
+
214
+ warn.mockRestore();
215
+ });
216
+
217
+ it('explains itself on the designer canvas rather than going blank', () => {
218
+ const warn = jest
219
+ .spyOn(console, 'warn')
220
+ .mockImplementation(() => undefined);
221
+
222
+ const { container } = renderRegion(
223
+ contextValue({ blocks: [regionBlock('search')], isDesigner: true }),
224
+ <>
225
+ <ThemeRegion name="search" />
226
+ <ThemeRegion name="search" />
227
+ </>
228
+ );
229
+
230
+ expect(markers(container)).toHaveLength(1);
231
+ expect(bands(container)).toHaveLength(1);
232
+ expect(container.textContent).toContain('Duplicate region');
233
+
234
+ warn.mockRestore();
235
+ });
236
+ });
237
+
238
+ describe('ThemeRegion — placed where it cannot resolve', () => {
239
+ it('renders nothing outside a custom section and says so', () => {
240
+ const warn = jest
241
+ .spyOn(console, 'warn')
242
+ .mockImplementation(() => undefined);
243
+
244
+ const { container } = renderRegion(null);
245
+
246
+ expect(container).toBeEmptyDOMElement();
247
+ expect(warn).toHaveBeenCalledWith(
248
+ expect.stringContaining('outside a custom section')
249
+ );
250
+
251
+ warn.mockRestore();
252
+ });
253
+
254
+ it('refuses a custom BLOCK with a designer band and nothing live', () => {
255
+ const warn = jest
256
+ .spyOn(console, 'warn')
257
+ .mockImplementation(() => undefined);
258
+
259
+ // A block's children are its slot nodes and its merchant content; a region
260
+ // resolved against them would either find nothing or match a child that
261
+ // happens to carry the reserved property.
262
+ const designer = renderRegion(
263
+ contextValue({
264
+ scope: 'block',
265
+ blocks: [regionBlock('search')],
266
+ isDesigner: true
267
+ })
268
+ );
269
+
270
+ expect(markers(designer.container)).toHaveLength(0);
271
+ expect(bands(designer.container)).toHaveLength(1);
272
+ expect(designer.container.textContent).toContain(
273
+ 'only works inside a custom section'
274
+ );
275
+ // The message has to name the thing the developer actually wanted: a
276
+ // merchant-fillable opening inside a block is a slot.
277
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('<ThemeSlot />'));
278
+
279
+ const live = renderRegion(
280
+ contextValue({ scope: 'block', blocks: [regionBlock('search')] })
281
+ );
282
+
283
+ expect(live.container).toBeEmptyDOMElement();
284
+
285
+ warn.mockRestore();
286
+ });
287
+
288
+ it('refuses a name the editor could never have seeded', () => {
289
+ const warn = jest
290
+ .spyOn(console, 'warn')
291
+ .mockImplementation(() => undefined);
292
+
293
+ const { container } = renderRegion(
294
+ contextValue({ blocks: [regionBlock('search')] }),
295
+ <ThemeRegion name="Store Search" />
296
+ );
297
+
298
+ expect(container).toBeEmptyDOMElement();
299
+ expect(warn).toHaveBeenCalledWith(
300
+ expect.stringContaining('"Store Search" is not valid')
301
+ );
302
+
303
+ warn.mockRestore();
304
+ });
305
+
306
+ it('warns on a name the section never declared, naming the ones it did', () => {
307
+ const warn = jest
308
+ .spyOn(console, 'warn')
309
+ .mockImplementation(() => undefined);
310
+
311
+ // A typo and a merchant deletion look identical on screen — both are an
312
+ // empty band. Only the declaration tells them apart, and a developer
313
+ // staring at the band has nothing else to go on.
314
+ renderRegion(
315
+ contextValue({
316
+ blocks: [regionBlock('search')],
317
+ declaredRegionNames: ['search', 'basket']
318
+ }),
319
+ <ThemeRegion name="baskett" />
320
+ );
321
+
322
+ expect(warn).toHaveBeenCalledWith(
323
+ expect.stringContaining('places a region "baskett" it never declared')
324
+ );
325
+ expect(warn).toHaveBeenCalledWith(
326
+ expect.stringContaining('Declared: search, basket')
327
+ );
328
+
329
+ warn.mockRestore();
330
+ });
331
+ });
@@ -55,8 +55,8 @@ const contextValue = ({
55
55
  isDesigner?: boolean;
56
56
  slotSink?: (descriptor: CustomSlotDescriptor) => void;
57
57
  declaredSlotNames?: string[];
58
- } = {}): CustomBlockContextValue => ({
59
- block: {
58
+ } = {}): CustomBlockContextValue => {
59
+ const block = {
60
60
  id: 'b1',
61
61
  type: 'custom:acme:promo-strip',
62
62
  label: 'Promo strip',
@@ -65,14 +65,22 @@ const contextValue = ({
65
65
  order: 0,
66
66
  hidden: false,
67
67
  blocks
68
- },
69
- placeholderId: 'p',
70
- sectionId: 's',
71
- isDesigner,
72
- currentBreakpoint: 'desktop',
73
- ...(declaredSlotNames ? { declaredSlotNames } : {}),
74
- ...(slotSink ? { collect: { setting: jest.fn(), slot: slotSink } } : {})
75
- });
68
+ };
69
+
70
+ // `node` is what the helpers read and `block` is the compatibility alias;
71
+ // they are the SAME object here, which is what a block host provides.
72
+ return {
73
+ node: block,
74
+ scope: 'block',
75
+ block,
76
+ placeholderId: 'p',
77
+ sectionId: 's',
78
+ isDesigner,
79
+ currentBreakpoint: 'desktop',
80
+ ...(declaredSlotNames ? { declaredSlotNames } : {}),
81
+ ...(slotSink ? { collect: { setting: jest.fn(), slot: slotSink } } : {})
82
+ };
83
+ };
76
84
 
77
85
  const renderSlot = (
78
86
  value: CustomBlockContextValue | null,
@@ -17,8 +17,8 @@ const contextValue = ({
17
17
  properties?: Record<string, unknown>;
18
18
  currentBreakpoint?: string;
19
19
  sink?: SettingSink;
20
- } = {}): CustomBlockContextValue => ({
21
- block: {
20
+ } = {}): CustomBlockContextValue => {
21
+ const block = {
22
22
  id: 'b1',
23
23
  type: 'custom:acme:promo-strip',
24
24
  label: 'Promo strip',
@@ -26,13 +26,21 @@ const contextValue = ({
26
26
  styles: {},
27
27
  order: 0,
28
28
  hidden: false
29
- },
30
- placeholderId: 'p',
31
- sectionId: 's',
32
- isDesigner: false,
33
- currentBreakpoint,
34
- ...(sink ? { collect: { setting: sink, slot: jest.fn() } } : {})
35
- });
29
+ };
30
+
31
+ // `node` is what the helpers read and `block` is the compatibility alias;
32
+ // they are the SAME object here, which is what a block host provides.
33
+ return {
34
+ node: block,
35
+ scope: 'block',
36
+ block,
37
+ placeholderId: 'p',
38
+ sectionId: 's',
39
+ isDesigner: false,
40
+ currentBreakpoint,
41
+ ...(sink ? { collect: { setting: sink, slot: jest.fn() } } : {})
42
+ };
43
+ };
36
44
 
37
45
  const wrapperFor = (value: CustomBlockContextValue | null) => {
38
46
  const Wrapper = ({ children }: { children: React.ReactNode }) =>
@@ -2,13 +2,25 @@
2
2
 
3
3
  import { createContext, useContext } from 'react';
4
4
  import type { Block } from '../theme-block';
5
+ import type { Section } from '../theme-section';
5
6
  import type {
6
7
  CustomSettingDescriptor,
7
8
  CustomSlotDescriptor
8
9
  } from './types';
9
10
 
10
11
  /**
11
- * What `ThemeEditable`, `useThemeSetting` and `ThemeSlot` read.
12
+ * The composition node a custom unit renders from.
13
+ *
14
+ * A `Block` for a custom block, the `Section` itself for a custom section. The
15
+ * two shapes agree on everything the authoring helpers read — `properties` for
16
+ * settings and fields, `blocks` for slots and regions — which is why one
17
+ * context serves both instead of a second one that would have to be threaded
18
+ * through `ThemeSlot`, `ThemeEditable` and `useThemeSetting` in parallel.
19
+ */
20
+ export type CustomNode = Block | Section;
21
+
22
+ /**
23
+ * What `ThemeEditable`, `useThemeSetting`, `ThemeSlot` and `ThemeRegion` read.
12
24
  *
13
25
  * A context rather than props, because the whole point of the authoring model
14
26
  * is that a developer writes ordinary JSX. Threading `block`/`breakpoint`
@@ -16,6 +28,31 @@ import type {
16
28
  * down would be the tax this API exists to remove.
17
29
  */
18
30
  export interface CustomBlockContextValue {
31
+ /**
32
+ * The node this unit renders. READ THIS, not `block` below.
33
+ *
34
+ * For a custom block it is the block; for a custom section it is the section,
35
+ * whose `properties` hold the section's settings and whose `blocks` hold the
36
+ * seeded regions.
37
+ */
38
+ node: CustomNode;
39
+ /**
40
+ * Which kind of unit is rendering.
41
+ *
42
+ * `ThemeRegion` refuses to render outside a section, because a region resolves
43
+ * against `section.blocks` — inside a block it would either find nothing or,
44
+ * worse, match a child that happens to carry the reserved property.
45
+ */
46
+ scope: 'block' | 'section';
47
+ /**
48
+ * The same object as `node`, kept because brand code already reads it.
49
+ *
50
+ * A `Section` satisfies `Block` structurally (it carries every required
51
+ * field), so this stays type-correct for a section host too. New code should
52
+ * read `node`: the name is honest there, and a reader that reaches for a
53
+ * Block-only field like `value` or `isIterator` is then visibly wrong instead
54
+ * of quietly undefined.
55
+ */
19
56
  block: Block;
20
57
  placeholderId: string;
21
58
  sectionId: string;
@@ -23,9 +60,9 @@ export interface CustomBlockContextValue {
23
60
  currentBreakpoint: string;
24
61
  selectedBlockId?: string | null;
25
62
  /**
26
- * The slot names `defineCustomBlock` DECLARED, in declaration order, so a
27
- * bare `<ThemeSlot />` can resolve to the block's single slot whatever it is
28
- * called.
63
+ * The slot names `defineCustomBlock`/`defineCustomSection` DECLARED, in
64
+ * declaration order, so a bare `<ThemeSlot />` can resolve to the unit's
65
+ * single slot whatever it is called.
29
66
  *
30
67
  * From the definition and not from the catalog descriptor, which is the
31
68
  * whole reason it is threaded here. The descriptor's `slots` array is
@@ -36,7 +73,13 @@ export interface CustomBlockContextValue {
36
73
  */
37
74
  declaredSlotNames?: readonly string[];
38
75
  /**
39
- * Runtime collection sink. Present only while the block is rendering inside
76
+ * The region names the section DECLARED. `ThemeRegion` uses it for one thing
77
+ * only: telling a merchant's deletion (ordinary, durable, silent) apart from
78
+ * a developer typo (a name nothing was ever seeded for, worth a warning).
79
+ */
80
+ declaredRegionNames?: readonly string[];
81
+ /**
82
+ * Runtime collection sink. Present only while the unit is rendering inside
40
83
  * a host that is collecting — see `collect.ts`. The build-time extractor is
41
84
  * the authoritative source of the descriptor; this is the fallback that
42
85
  * makes the API work before the extractor exists, and the cross-check that
@@ -52,12 +95,12 @@ export const CustomBlockContext =
52
95
  createContext<CustomBlockContextValue | null>(null);
53
96
 
54
97
  /**
55
- * Returns null outside a custom block rather than throwing.
98
+ * Returns null outside a custom block or section rather than throwing.
56
99
  *
57
- * The three public components have to say something useful when a developer
58
- * uses them in the wrong place — a plain React tree, a built-in block, a unit
59
- * test. Each handles the null itself with a named message; a bare throw here
60
- * would surface as a stack trace pointing at this file rather than at the
100
+ * The public components have to say something useful when a developer uses
101
+ * them in the wrong place — a plain React tree, a built-in block, a unit test.
102
+ * Each handles the null itself with a named message; a bare throw here would
103
+ * surface as a stack trace pointing at this file rather than at the
61
104
  * `<ThemeEditable>` the developer actually mis-placed.
62
105
  */
63
106
  export const useCustomBlockContext = (): CustomBlockContextValue | null =>
@@ -29,8 +29,15 @@ interface CustomBlockErrorBoundaryProps {
29
29
  * gets a labelled box instead, because a block that silently disappears from
30
30
  * the canvas reads as "the editor lost my block" rather than "this component
31
31
  * threw" — and the merchant still needs something to select and delete.
32
+ *
33
+ * Exported because `section-host.tsx` uses the SAME boundary rather than a
34
+ * second copy. A section host has no lower level to fall back to, so the
35
+ * boundary buys it a labelled designer card rather than blast-radius reduction
36
+ * — but a diverged copy would be a second place for the live/designer split to
37
+ * be got wrong, and getting it wrong live means shipping a broken card to
38
+ * shoppers.
32
39
  */
33
- class CustomBlockErrorBoundary extends React.Component<
40
+ export class CustomBlockErrorBoundary extends React.Component<
34
41
  CustomBlockErrorBoundaryProps,
35
42
  { hasError: boolean }
36
43
  > {
@@ -122,6 +129,11 @@ export const CustomBlockHost = ({
122
129
 
123
130
  const value = useMemo<CustomBlockContextValue>(
124
131
  () => ({
132
+ // `node` and `block` are the same object here. The pair exists so a
133
+ // section host can fill `node` with the section while brand code that
134
+ // already reads `block` keeps working — see `block-context.ts`.
135
+ node: block,
136
+ scope: 'block',
125
137
  block,
126
138
  placeholderId,
127
139
  sectionId,
@@ -1,5 +1,6 @@
1
1
  import { devWarn } from '../utils/dev-warn';
2
2
  import { getCustomBlockDescriptor } from './catalog';
3
+ import { getCustomSectionDescriptor } from './section-catalog';
3
4
  import { fieldPartName } from './editable-tags';
4
5
  import { slotNameError } from './slot-nodes';
5
6
  import {
@@ -141,6 +142,19 @@ export interface CustomCollectSink {
141
142
  slot: (descriptor: CustomSlotDescriptor) => void;
142
143
  }
143
144
 
145
+ /**
146
+ * What a sink writes into. Both `CustomBlockDescriptor` and
147
+ * `CustomSectionDescriptor` satisfy it, which is what lets one sink serve
148
+ * either — declared structurally rather than as a union so a third descriptor
149
+ * shape would not have to be added here as well.
150
+ */
151
+ interface CollectTarget {
152
+ settings: CustomSettingDescriptor[];
153
+ slots: CustomSlotDescriptor[];
154
+ parts: string[];
155
+ hasChildren: boolean;
156
+ }
157
+
144
158
  const sinks = new Map<string, CustomCollectSink>();
145
159
  const finished = new Set<string>();
146
160
 
@@ -187,15 +201,22 @@ export const getCollectSink = (type: string): CustomCollectSink => {
187
201
  const existing = sinks.get(type);
188
202
  if (existing) return existing;
189
203
 
190
- const targetDescriptor = () => {
191
- const descriptor = getCustomBlockDescriptor(type);
204
+ // Blocks and sections share one type namespace — registration refuses a type
205
+ // already claimed by either — so one lookup order is unambiguous and a sink
206
+ // can serve both. The shapes agree on everything this file writes: settings,
207
+ // slots and parts. `hasChildren` is on both too, so the slot sink needs no
208
+ // branch of its own.
209
+ const targetDescriptor = (): CollectTarget | undefined => {
210
+ const descriptor: CollectTarget | undefined =
211
+ getCustomBlockDescriptor(type) ?? getCustomSectionDescriptor(type);
212
+
192
213
  if (!descriptor) {
193
214
  devWarn(
194
215
  'customBlock',
195
216
  `"${type}" rendered with no catalog descriptor, so nothing it uses can be collected. ` +
196
- `The block registered into a different module copy than the one rendering it: check that the ` +
197
- `barrel calling registerCustomBlocks() is imported from the CLIENT ROOT, and clear .next — ` +
198
- `webpack keeps serving the old copy of a changed package.`
217
+ `It registered into a different module copy than the one rendering it: check that the ` +
218
+ `barrel calling registerCustomBlocks()/registerCustomSections() is imported from the ` +
219
+ `CLIENT ROOT, and clear .next — webpack keeps serving the old copy of a changed package.`
199
220
  );
200
221
  }
201
222
  return descriptor;