@akinon/pz-theme 2.0.71-rc.0 → 2.0.72-beta.0

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.
package/CHANGELOG.md CHANGED
@@ -1,36 +1,23 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.71-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [0cf9ea239]
8
- - Updated dependencies [324f97d55]
9
- - Updated dependencies [51ea06888]
10
- - Updated dependencies
11
- - Updated dependencies [2fea4143]
12
- - Updated dependencies [b55acb768]
13
- - Updated dependencies [760258c1c]
14
- - Updated dependencies [143be2b9d]
15
- - Updated dependencies [7889b08fe]
16
- - Updated dependencies [9f8cd3bc5]
17
- - Updated dependencies [bfafa3f49]
18
- - Updated dependencies [57d7eb305]
19
- - Updated dependencies [d99a6a7d5]
20
- - Updated dependencies [9db81a714]
21
- - Updated dependencies [591e345e1]
22
- - Updated dependencies [4de5303c5]
23
- - Updated dependencies [95b139dc1]
24
- - Updated dependencies [1d00f2d06]
25
- - Updated dependencies [4ac7b2a1e]
26
- - Updated dependencies [e9598c71]
27
- - Updated dependencies [4998a9631]
28
- - Updated dependencies [804d2bd6]
29
- - Updated dependencies [3909d3224]
30
- - Updated dependencies [6a3d8a63]
31
- - Updated dependencies [e18836b20]
32
- - @akinon/next@2.0.71-rc.0
33
- - @akinon/theme-contract@2.0.71-rc.0
3
+ ## 2.0.72-beta.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [cbbbfd757]
8
+ - @akinon/next@2.0.72-beta.0
9
+ - @akinon/theme-contract@2.0.72-beta.0
10
+
11
+ ## 2.0.71
12
+
13
+ ### Patch Changes
14
+
15
+ - 4f3f143: ZERO-4852: Add the custom block authoring API (defineCustomBlock, registerCustomBlocks, ThemeEditable, ThemeSlot, useThemeSetting)
16
+ - c84da01: ZERO-4852: Refuse a custom block's slot accepts/max at declare time, and keep runtime part collection under the catalog cap
17
+ - 3e1306b: ZERO-4852: Announce the custom block catalog to the theme editor on the placeholder frame
18
+ - c84da01: ZERO-4852: Let a custom block open several named slots and declare its own styling parts with states
19
+ - @akinon/next@2.0.71
20
+ - @akinon/theme-contract@2.0.71
34
21
 
35
22
  ## 2.0.70
36
23
 
package/package.json CHANGED
@@ -1,26 +1,26 @@
1
1
  {
2
2
  "name": "@akinon/pz-theme",
3
3
  "description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
4
- "version": "2.0.71-rc.0",
4
+ "version": "2.0.72-beta.0",
5
5
  "license": "MIT",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
8
8
  "test": "jest"
9
9
  },
10
10
  "peerDependencies": {
11
- "@akinon/next": "2.0.71-rc.0",
11
+ "@akinon/next": "2.0.72-beta.0",
12
12
  "react": "^18.0.0 || ^19.0.0",
13
13
  "react-dom": "^18.0.0 || ^19.0.0"
14
14
  },
15
15
  "dependencies": {
16
- "@akinon/theme-contract": "2.0.71-rc.0",
16
+ "@akinon/theme-contract": "2.0.72-beta.0",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "tailwind-merge": "^2.5.4",
20
20
  "zod": "4.4.1"
21
21
  },
22
22
  "devDependencies": {
23
- "@akinon/next": "2.0.71-rc.0",
23
+ "@akinon/next": "2.0.72-beta.0",
24
24
  "@testing-library/jest-dom": "5.16.4",
25
25
  "@testing-library/react": "16.3.2",
26
26
  "@testing-library/user-event": "14.1.1",
@@ -0,0 +1,276 @@
1
+ /**
2
+ * What the storefront announces to the editor, and what it refuses to.
3
+ *
4
+ * `getCustomCatalog()` had no caller: a brand could register a custom block
5
+ * and the editor could never learn the type existed. The announce is the only
6
+ * channel, and it is shared with the placeholder slugs — which is what makes
7
+ * the gating interesting. Three answers have to hold at once:
8
+ *
9
+ * - no placeholders, no frame (the editor's subscriber reloads the theme
10
+ * against whatever slugs a frame carries, so an empty one is destructive)
11
+ * - no proven designer, no catalog (a storefront is framed in production by
12
+ * payment providers, and the catalog is brand IP)
13
+ * - every frame carries the catalog whole, so the receiver can replace
14
+ */
15
+
16
+ const EDITOR_ORIGIN = 'http://editor.example';
17
+
18
+ type Registry = typeof import('../placeholder-registry');
19
+ type Register = typeof import('../custom/register');
20
+
21
+ interface Loaded {
22
+ registry: Registry;
23
+ register: Register;
24
+ }
25
+
26
+ let loadedModules: Loaded[] = [];
27
+
28
+ /**
29
+ * A fresh copy of both modules, sharing one isolated graph.
30
+ *
31
+ * The registry, the catalog and the renderer registry are all module state,
32
+ * and `registerCustomBlocks` refuses to register a type twice — so a second
33
+ * test reusing the first one's modules would throw rather than start clean.
34
+ * Every copy is remembered so teardown can undo its window listener.
35
+ */
36
+ const load = (): Loaded => {
37
+ let loaded: Loaded;
38
+ jest.isolateModules(() => {
39
+ loaded = {
40
+ registry: require('../placeholder-registry'),
41
+ register: require('../custom/register')
42
+ };
43
+ });
44
+ loadedModules.push(loaded!);
45
+ return loaded!;
46
+ };
47
+
48
+ const Promo = () => null;
49
+
50
+ describe('PLACEHOLDERS_AVAILABLE announce', () => {
51
+ const postMessage = jest.fn();
52
+
53
+ const frames = () =>
54
+ postMessage.mock.calls.map(([message]) => message.data ?? {});
55
+
56
+ beforeAll(() => {
57
+ // Framed: `postToEditor` drops everything when window.self === window.top.
58
+ Object.defineProperty(window, 'top', {
59
+ configurable: true,
60
+ value: { name: 'designer' }
61
+ });
62
+ Object.defineProperty(window, 'parent', {
63
+ configurable: true,
64
+ value: { postMessage }
65
+ });
66
+ Object.defineProperty(document, 'referrer', {
67
+ configurable: true,
68
+ value: `${EDITOR_ORIGIN}/builder`
69
+ });
70
+ });
71
+
72
+ beforeEach(() => {
73
+ jest.useFakeTimers();
74
+ postMessage.mockClear();
75
+ process.env.NEXT_PUBLIC_THEME_EDITOR_ORIGINS = EDITOR_ORIGIN;
76
+ });
77
+
78
+ afterEach(() => {
79
+ // Every copy attached its designer listener to the ONE jsdom window, and
80
+ // each copy still holds its own placeholder set — so without this the next
81
+ // test announces for all of its predecessors too.
82
+ loadedModules.forEach(({ registry }) =>
83
+ registry.__resetPlaceholderRegistryForTests()
84
+ );
85
+ loadedModules = [];
86
+ jest.useRealTimers();
87
+ delete process.env.NEXT_PUBLIC_THEME_EDITOR_ORIGINS;
88
+ delete (window as { __themeEditorDesigner?: boolean })
89
+ .__themeEditorDesigner;
90
+ });
91
+
92
+ it('posts nothing when a build registers blocks but mounts no placeholder', () => {
93
+ (window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
94
+ true;
95
+ const { register } = load();
96
+
97
+ register.registerCustomBlocks('acme', { 'promo-strip': Promo });
98
+ jest.advanceTimersByTime(500);
99
+
100
+ // The re-arm alone must never produce a frame: the editor's subscriber
101
+ // clears its cache and swaps the active placeholder set the moment one
102
+ // arrives, and this one would carry no slugs at all.
103
+ expect(postMessage).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it('carries placeholders and the catalog in one frame', () => {
107
+ (window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
108
+ true;
109
+ const { registry, register } = load();
110
+
111
+ registry.registerPlaceholder('header');
112
+ // Re-arms the placeholder debounce rather than starting a second timer.
113
+ register.registerCustomBlocks('acme', {
114
+ 'promo-strip': Promo
115
+ });
116
+ jest.advanceTimersByTime(100);
117
+
118
+ expect(postMessage).toHaveBeenCalledTimes(1);
119
+ const [payload] = frames();
120
+ expect(payload.placeholders).toEqual(['header']);
121
+ expect(payload.customBlocks.vendor).toBe('acme');
122
+ expect(payload.customBlocks.blocks.map((block) => block.type)).toEqual([
123
+ 'custom:acme:promo-strip'
124
+ ]);
125
+ });
126
+
127
+ it('omits the catalog key when the build registers no custom block', () => {
128
+ (window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
129
+ true;
130
+ const { registry } = load();
131
+
132
+ registry.registerPlaceholder('header');
133
+ jest.advanceTimersByTime(100);
134
+
135
+ const [payload] = frames();
136
+ expect(payload.placeholders).toEqual(['header']);
137
+ // Absent, not empty — a build with no custom blocks has to be
138
+ // indistinguishable from one whose catalog is not this frame's business.
139
+ expect('customBlocks' in payload).toBe(false);
140
+ });
141
+
142
+ it('withholds the catalog from a frame that has not proven it is the editor', () => {
143
+ const { registry, register } = load();
144
+
145
+ register.registerCustomBlocks('acme', { 'promo-strip': Promo });
146
+ registry.registerPlaceholder('header');
147
+ jest.advanceTimersByTime(100);
148
+
149
+ // Being framed is not the designer flag. Payment providers frame live
150
+ // storefronts; the slugs are route names and still go out, but every block
151
+ // name, setting key and option list this build ships does not.
152
+ const [payload] = frames();
153
+ expect(payload.placeholders).toEqual(['header']);
154
+ expect('customBlocks' in payload).toBe(false);
155
+ });
156
+
157
+ it('re-announces the catalog once the editor proves itself', () => {
158
+ const { registry, register } = load();
159
+
160
+ register.registerCustomBlocks('acme', { 'promo-strip': Promo });
161
+ registry.registerPlaceholder('header');
162
+ jest.advanceTimersByTime(100);
163
+ expect('customBlocks' in frames()[0]).toBe(false);
164
+
165
+ // The designer flag arrives in the editor's reply to IFRAME_READY — a
166
+ // postMessage round trip that is not guaranteed to land inside the 100ms
167
+ // debounce. Without the re-announce the catalog would be lost for the life
168
+ // of the document on exactly the loads where the handshake was slow.
169
+ (window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
170
+ true;
171
+ window.dispatchEvent(
172
+ new MessageEvent('message', {
173
+ origin: EDITOR_ORIGIN,
174
+ source: window.parent as Window,
175
+ data: { type: 'SET_THEME_EDITOR_COOKIE', data: true }
176
+ })
177
+ );
178
+ jest.advanceTimersByTime(100);
179
+
180
+ expect(postMessage).toHaveBeenCalledTimes(2);
181
+ // The vendor and the type the barrel actually produced, not merely one
182
+ // block: this second frame is the whole of what the editor ever learns
183
+ // about this build, and "the block is in the picker" is the only claim a
184
+ // human checks.
185
+ expect(frames()[1].customBlocks.vendor).toBe('acme');
186
+ expect(frames()[1].customBlocks.blocks.map((block) => block.type)).toEqual([
187
+ 'custom:acme:promo-strip'
188
+ ]);
189
+ });
190
+
191
+ it('settles the announce/cookie handshake instead of ping-ponging', () => {
192
+ const { registry, register } = load();
193
+
194
+ register.registerCustomBlocks('acme', { 'promo-strip': Promo });
195
+ registry.registerPlaceholder('header');
196
+ jest.advanceTimersByTime(100);
197
+
198
+ // The editor answers EVERY announce with this cookie, not just the first
199
+ // (theme-iframe-service.ts sends it from the same handler that ingests the
200
+ // catalog). An unlatched re-arm would therefore loop for the life of the
201
+ // document, and every lap costs the editor a full theme reload —
202
+ // loadThemeForPlaceholders clears the widget cache and refetches every
203
+ // widget on each announce it receives.
204
+ (window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
205
+ true;
206
+
207
+ for (let lap = 0; lap < 5; lap += 1) {
208
+ window.dispatchEvent(
209
+ new MessageEvent('message', {
210
+ origin: EDITOR_ORIGIN,
211
+ source: window.parent as Window,
212
+ data: { type: 'SET_THEME_EDITOR_COOKIE', data: true }
213
+ })
214
+ );
215
+ jest.advanceTimersByTime(100);
216
+ }
217
+
218
+ // One frame to say which placeholders mounted, one to hand over the
219
+ // catalog the first could not carry, and then quiet.
220
+ expect(postMessage).toHaveBeenCalledTimes(2);
221
+ expect(frames()[1].customBlocks.blocks).toHaveLength(1);
222
+ });
223
+
224
+ it('does not spend a second frame when the build has no custom block', () => {
225
+ const { registry } = load();
226
+
227
+ registry.registerPlaceholder('header');
228
+ jest.advanceTimersByTime(100);
229
+
230
+ (window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
231
+ true;
232
+ window.dispatchEvent(
233
+ new MessageEvent('message', {
234
+ origin: EDITOR_ORIGIN,
235
+ source: window.parent as Window,
236
+ data: { type: 'SET_THEME_EDITOR_COOKIE', data: true }
237
+ })
238
+ );
239
+ jest.advanceTimersByTime(100);
240
+
241
+ // Every brand shipping today registers no custom block. The re-announce
242
+ // exists to deliver a catalog; with none to deliver it must cost them
243
+ // nothing at all, or this feature would add a theme reload to every
244
+ // preview load in the fleet.
245
+ expect(postMessage).toHaveBeenCalledTimes(1);
246
+ });
247
+
248
+ it('sends the whole catalog every time, so a re-announce replaces', () => {
249
+ (window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
250
+ true;
251
+ const { registry, register } = load();
252
+
253
+ register.registerCustomBlocks('acme', { 'promo-strip': Promo });
254
+ registry.registerPlaceholder('header');
255
+ jest.advanceTimersByTime(100);
256
+
257
+ // A second barrel, then a second announce — as an in-iframe route change
258
+ // produces when its placeholders remount.
259
+ register.registerCustomBlocks('acme', { 'promo-banner': Promo });
260
+ registry.registerPlaceholder('footer');
261
+ jest.advanceTimersByTime(100);
262
+
263
+ const [first, second] = frames();
264
+ expect(first.customBlocks.blocks.map((block) => block.type)).toEqual([
265
+ 'custom:acme:promo-strip'
266
+ ]);
267
+ // The full snapshot, not the delta. The receiver replaces what it holds;
268
+ // if this frame carried only the new block, replacing would drop the
269
+ // first one and the editor would offer a type it can no longer describe.
270
+ expect(second.customBlocks.blocks.map((block) => block.type)).toEqual([
271
+ 'custom:acme:promo-strip',
272
+ 'custom:acme:promo-banner'
273
+ ]);
274
+ expect(second.placeholders).toEqual(['header', 'footer']);
275
+ });
276
+ });
@@ -2,10 +2,7 @@ import React from 'react';
2
2
  import NextLink from 'next/link';
3
3
  import { getResponsiveValue } from '../utils';
4
4
  import { BlockRendererProps } from './block-renderer-registry';
5
- import ThemeBlock from '../theme-block';
6
-
7
- import { postToEditor } from '../editor-bridge';
8
- import { PreviewToEditorMessageType } from '@akinon/theme-contract';
5
+ import { renderComposedChildren } from './render-composed-children';
9
6
 
10
7
  /**
11
8
  * Composed anchors to the storefront's own routes navigate client-side. A
@@ -68,59 +65,14 @@ const GroupBlock = ({
68
65
  );
69
66
  }
70
67
 
71
- const renderedChildren = block.blocks
72
- .filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
73
- .sort((a, b) => (a.order || 0) - (b.order || 0))
74
- .map((childBlock, index) => {
75
- const createActionHandler =
76
- (actionType: PreviewToEditorMessageType) => () => {
77
- if (window.parent) {
78
- postToEditor({
79
- type: actionType,
80
- data: {
81
- placeholderId,
82
- sectionId,
83
- blockId: childBlock.id
84
- }
85
- });
86
- }
87
- };
88
-
89
- const handleRename = (newLabel: string) => {
90
- if (window.parent) {
91
- postToEditor({
92
- type: 'RENAME_BLOCK',
93
- data: {
94
- placeholderId,
95
- sectionId,
96
- blockId: childBlock.id,
97
- label: newLabel
98
- }
99
- });
100
- }
101
- };
102
-
103
- return (
104
- <ThemeBlock
105
- key={childBlock.id || `block-${index}`}
106
- block={childBlock}
107
- placeholderId={placeholderId}
108
- sectionId={sectionId}
109
- isDesigner={isDesigner}
110
- isSelected={selectedBlockId === childBlock.id}
111
- selectedBlockId={selectedBlockId}
112
- currentBreakpoint={currentBreakpoint}
113
- onMoveUp={createActionHandler('MOVE_BLOCK_UP')}
114
- onMoveDown={createActionHandler('MOVE_BLOCK_DOWN')}
115
- onDuplicate={createActionHandler('DUPLICATE_BLOCK')}
116
- onToggleVisibility={createActionHandler(
117
- 'TOGGLE_BLOCK_VISIBILITY'
118
- )}
119
- onDelete={createActionHandler('DELETE_BLOCK')}
120
- onRename={handleRename}
121
- />
122
- );
123
- });
68
+ const renderedChildren = renderComposedChildren({
69
+ blocks: block.blocks,
70
+ placeholderId,
71
+ sectionId,
72
+ isDesigner,
73
+ selectedBlockId,
74
+ currentBreakpoint
75
+ });
124
76
 
125
77
  if (useRouterLink) {
126
78
  return (
@@ -0,0 +1,104 @@
1
+ import React from 'react';
2
+ import { PreviewToEditorMessageType } from '@akinon/theme-contract';
3
+ import ThemeBlock, { Block } from '../theme-block';
4
+ import { postToEditor } from '../editor-bridge';
5
+
6
+ export interface ComposedChildrenOptions {
7
+ /** A container's `block.blocks`. Absent and empty are the same thing here. */
8
+ blocks?: Block[];
9
+ placeholderId: string;
10
+ sectionId: string;
11
+ isDesigner: boolean;
12
+ selectedBlockId?: string | null;
13
+ currentBreakpoint?: string;
14
+ }
15
+
16
+ /**
17
+ * Renders the children of any container block: visibility filter, order sort,
18
+ * and the six editor action callbacks every child needs to be movable,
19
+ * duplicable, hideable, deletable and renamable on the canvas.
20
+ *
21
+ * Extracted because the callbacks are the expensive half. `tabs-section.tsx`
22
+ * hand-writes this same block of `postToEditor` closures twice — once at line
23
+ * 225 for its non-tab children and again at line 363 for the tab strip — and
24
+ * every copy is a place a new message field has to be remembered. A container
25
+ * that reimplemented them would be the third copy, and copies drift: a child
26
+ * whose `onRename` was forgotten simply refuses to rename on the canvas, with
27
+ * nothing logged anywhere.
28
+ *
29
+ * A plain function rather than a component on purpose. A `<ComposedChildren>`
30
+ * wrapper would insert a fiber between the container and its children, and
31
+ * the container's own element (GroupBlock's `<Tag className="contents">`) is
32
+ * frequently `display: contents` precisely so the children inherit the
33
+ * grandparent's grid — an extra layer there is not free. Returning the array
34
+ * keeps the rendered tree byte-identical to hand-writing the loop inline.
35
+ */
36
+ export const renderComposedChildren = ({
37
+ blocks,
38
+ placeholderId,
39
+ sectionId,
40
+ isDesigner,
41
+ selectedBlockId,
42
+ currentBreakpoint
43
+ }: ComposedChildrenOptions): React.ReactElement[] => {
44
+ if (!blocks || blocks.length === 0) return [];
45
+
46
+ return (
47
+ blocks
48
+ // The designer keeps hidden children mounted so they stay selectable in
49
+ // the layer tree; the storefront must never ship them.
50
+ .filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
51
+ .sort((a, b) => (a.order || 0) - (b.order || 0))
52
+ .map((childBlock, index) => {
53
+ const createActionHandler =
54
+ (actionType: PreviewToEditorMessageType) => () => {
55
+ if (window.parent) {
56
+ postToEditor({
57
+ type: actionType,
58
+ data: {
59
+ placeholderId,
60
+ sectionId,
61
+ blockId: childBlock.id
62
+ }
63
+ });
64
+ }
65
+ };
66
+
67
+ // Not foldable into `createActionHandler`: rename is the one action
68
+ // that carries a payload beyond the block identity, so its handler
69
+ // takes an argument the others have no way to supply.
70
+ const handleRename = (newLabel: string) => {
71
+ if (window.parent) {
72
+ postToEditor({
73
+ type: 'RENAME_BLOCK',
74
+ data: {
75
+ placeholderId,
76
+ sectionId,
77
+ blockId: childBlock.id,
78
+ label: newLabel
79
+ }
80
+ });
81
+ }
82
+ };
83
+
84
+ return (
85
+ <ThemeBlock
86
+ key={childBlock.id || `block-${index}`}
87
+ block={childBlock}
88
+ placeholderId={placeholderId}
89
+ sectionId={sectionId}
90
+ isDesigner={isDesigner}
91
+ isSelected={selectedBlockId === childBlock.id}
92
+ selectedBlockId={selectedBlockId}
93
+ currentBreakpoint={currentBreakpoint}
94
+ onMoveUp={createActionHandler('MOVE_BLOCK_UP')}
95
+ onMoveDown={createActionHandler('MOVE_BLOCK_DOWN')}
96
+ onDuplicate={createActionHandler('DUPLICATE_BLOCK')}
97
+ onToggleVisibility={createActionHandler('TOGGLE_BLOCK_VISIBILITY')}
98
+ onDelete={createActionHandler('DELETE_BLOCK')}
99
+ onRename={handleRename}
100
+ />
101
+ );
102
+ })
103
+ );
104
+ };