@akinon/pz-theme 2.0.71-rc.0 → 2.0.71
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 +10 -31
- package/package.json +4 -4
- package/src/__tests__/custom-catalog-announce.test.ts +276 -0
- package/src/blocks/group-block.tsx +9 -57
- package/src/blocks/render-composed-children.tsx +104 -0
- package/src/custom/README.md +249 -0
- package/src/custom/__tests__/define.test.tsx +619 -0
- package/src/custom/__tests__/module-boundaries.test.ts +156 -0
- package/src/custom/__tests__/public-api.test.tsx +427 -0
- package/src/custom/__tests__/slot-node-wire.test.tsx +309 -0
- package/src/custom/__tests__/slot-nodes.test.ts +144 -0
- package/src/custom/__tests__/theme-editable.test.tsx +486 -0
- package/src/custom/__tests__/theme-slot.test.tsx +387 -0
- package/src/custom/__tests__/use-theme-setting.test.tsx +260 -0
- package/src/custom/block-context.ts +64 -0
- package/src/custom/block-host.tsx +162 -0
- package/src/custom/catalog.ts +97 -0
- package/src/custom/collect.ts +342 -0
- package/src/custom/define.tsx +172 -0
- package/src/custom/editable-tags.ts +120 -0
- package/src/custom/index.ts +105 -0
- package/src/custom/parts.ts +108 -0
- package/src/custom/property-types.ts +70 -0
- package/src/custom/register.ts +545 -0
- package/src/custom/slot-nodes.ts +119 -0
- package/src/custom/theme-editable.tsx +342 -0
- package/src/custom/theme-slot.tsx +210 -0
- package/src/custom/types.ts +251 -0
- package/src/custom/use-theme-setting.ts +225 -0
- package/src/index.ts +66 -0
- package/src/placeholder-registry.ts +168 -16
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { getResponsiveValue } from '../utils';
|
|
6
|
+
import { devWarn } from '../utils/dev-warn';
|
|
7
|
+
import { partAttrs } from '../utils/part-styles';
|
|
8
|
+
import { useCustomBlockContext } from './block-context';
|
|
9
|
+
import type { CustomBlockContextValue } from './block-context';
|
|
10
|
+
import type { PropertyType } from './property-types';
|
|
11
|
+
import {
|
|
12
|
+
EDITABLE_KIND_PROPERTY_TYPES,
|
|
13
|
+
EDITABLE_KIND_TARGET,
|
|
14
|
+
fieldPartName,
|
|
15
|
+
hrefKeyFor,
|
|
16
|
+
inferEditableKind,
|
|
17
|
+
isEditableKind,
|
|
18
|
+
type EditableKind
|
|
19
|
+
} from './editable-tags';
|
|
20
|
+
import {
|
|
21
|
+
RESERVED_SETTING_KEYS,
|
|
22
|
+
SETTING_KEY_PATTERN,
|
|
23
|
+
materializeDefault
|
|
24
|
+
} from './types';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Reports one field to the runtime collection sink.
|
|
28
|
+
*
|
|
29
|
+
* `defaultValue` crosses `postMessage` to the editor, so only a primitive can
|
|
30
|
+
* be reported — the authored default of `<h2>New <b>season</b></h2>` is a React
|
|
31
|
+
* element tree, which is unserializable AND meaningless to a text input.
|
|
32
|
+
* Omitting it there is deliberate: the element still renders its own markup as
|
|
33
|
+
* the fallback, so the storefront looks right; only the panel's "reset to
|
|
34
|
+
* default" has nothing to offer.
|
|
35
|
+
*/
|
|
36
|
+
const reportField = (
|
|
37
|
+
collect: CustomBlockContextValue['collect'],
|
|
38
|
+
field: {
|
|
39
|
+
key: string;
|
|
40
|
+
type: PropertyType;
|
|
41
|
+
label: string;
|
|
42
|
+
authoredDefault: unknown;
|
|
43
|
+
/**
|
|
44
|
+
* Whether an element on screen will actually carry `field-<key>`. False for
|
|
45
|
+
* the href half of a link: one `ThemeEditable` clones ONE element, and that
|
|
46
|
+
* element already carries the label half's part.
|
|
47
|
+
*/
|
|
48
|
+
hasPart: boolean;
|
|
49
|
+
}
|
|
50
|
+
): void => {
|
|
51
|
+
if (!collect) return;
|
|
52
|
+
|
|
53
|
+
const serializable =
|
|
54
|
+
typeof field.authoredDefault === 'string' ||
|
|
55
|
+
typeof field.authoredDefault === 'number' ||
|
|
56
|
+
typeof field.authoredDefault === 'boolean'
|
|
57
|
+
? field.authoredDefault
|
|
58
|
+
: undefined;
|
|
59
|
+
|
|
60
|
+
collect.setting({
|
|
61
|
+
key: field.key,
|
|
62
|
+
type: field.type,
|
|
63
|
+
label: field.label,
|
|
64
|
+
defaultValue: materializeDefault(serializable),
|
|
65
|
+
// `editable` is not decoration: the setting sink in `getCollectSink`
|
|
66
|
+
// (collect.ts) and `buildDescriptor` (register.ts) both turn it into a
|
|
67
|
+
// `field-<key>` entry in `descriptor.parts`, which is the list the editor
|
|
68
|
+
// offers as click targets and style scopes. Setting it for a key no
|
|
69
|
+
// element carries would advertise a part whose generated selector matches
|
|
70
|
+
// nothing — a control offered with nothing behind it, which is the exact
|
|
71
|
+
// failure the parts list exists to rule out.
|
|
72
|
+
...(field.hasPart ? { editable: true } : {})
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Unwraps the editor's image/file picker shape.
|
|
78
|
+
*
|
|
79
|
+
* That picker stores `{ url }` rather than a bare string (the same shape
|
|
80
|
+
* `pickImageSource` in blocks/image-block.tsx already unwraps), and `{ url }`
|
|
81
|
+
* has no `desktop` key — so feeding it straight to `getResponsiveValue` would
|
|
82
|
+
* return the fallback and the merchant's chosen image would never appear.
|
|
83
|
+
* Applied on both sides of the responsive read because either level can carry
|
|
84
|
+
* it: `{url}` when unbucketed, `{desktop: {url}}` when per-breakpoint.
|
|
85
|
+
*/
|
|
86
|
+
const unwrapUrlValue = (value: unknown): unknown => {
|
|
87
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
|
|
88
|
+
|
|
89
|
+
const url = (value as { url?: unknown }).url;
|
|
90
|
+
return typeof url === 'string' ? url : value;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const resolveFieldValue = (
|
|
94
|
+
raw: unknown,
|
|
95
|
+
carriesUrl: boolean,
|
|
96
|
+
authoredDefault: unknown,
|
|
97
|
+
breakpoint: string
|
|
98
|
+
): unknown => {
|
|
99
|
+
const value = carriesUrl ? unwrapUrlValue(raw) : raw;
|
|
100
|
+
const resolved = getResponsiveValue(value, breakpoint, authoredDefault);
|
|
101
|
+
|
|
102
|
+
return carriesUrl ? unwrapUrlValue(resolved) : resolved;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** `ctaLabel` -> `Cta Label`. A starting point the developer can override later. */
|
|
106
|
+
const labelFromKey = (key: string): string => {
|
|
107
|
+
const spaced = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2');
|
|
108
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export interface ThemeEditableProps {
|
|
112
|
+
/** Becomes the key in `block.properties` and the `field-<name>` part. */
|
|
113
|
+
name: string;
|
|
114
|
+
/** Overrides tag inference. The escape hatch for a container tag. */
|
|
115
|
+
type?: EditableKind;
|
|
116
|
+
children?: React.ReactNode;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Turns one element of a developer's JSX into a merchant-editable field.
|
|
121
|
+
*
|
|
122
|
+
* THE AUTHORED JSX IS THE DEFAULT. `<ThemeEditable name="headline"><h2>New
|
|
123
|
+
* season</h2></ThemeEditable>` declares a text field whose default is "New
|
|
124
|
+
* season" — read off the child, because there is nowhere else for a developer
|
|
125
|
+
* to state it and not needing a second declaration is the whole point of the
|
|
126
|
+
* design. The default is reported through the collect sink so it reaches the
|
|
127
|
+
* descriptor the editor renders its panel from.
|
|
128
|
+
*
|
|
129
|
+
* INJECTION IS A RE-RENDER, NEVER A DOM WRITE. The merchant value is resolved
|
|
130
|
+
* and `cloneElement`d onto the child, so React owns the result. This is the
|
|
131
|
+
* one thing the retired native widget got wrong: it patched `el.src` and
|
|
132
|
+
* `el.textContent` after mount, and React overwrote both on its next render —
|
|
133
|
+
* the value appeared, then vanished on the first unrelated state change.
|
|
134
|
+
*
|
|
135
|
+
* Clearing is per breakpoint, and that asymmetry is `getResponsiveValue`'s,
|
|
136
|
+
* not a local choice: a BARE `''` counts as unset and falls back to the
|
|
137
|
+
* authored JSX, while `{ desktop: '' }` resolves to the empty string and the
|
|
138
|
+
* field really is blank. The bucketed form is the only one the panel ever
|
|
139
|
+
* writes, so a merchant who clears the input gets an empty element rather than
|
|
140
|
+
* the developer's placeholder coming back — which is what clearing has to mean
|
|
141
|
+
* for it to be usable at all.
|
|
142
|
+
*
|
|
143
|
+
* Refusals are loud in development (a throw naming the wrapper) and inert in
|
|
144
|
+
* production (the child renders untouched plus a dev warning that never
|
|
145
|
+
* reaches a shopper). A mis-wrapped element must never blank a live
|
|
146
|
+
* storefront: the block around it may be the header.
|
|
147
|
+
*/
|
|
148
|
+
export function ThemeEditable({
|
|
149
|
+
name,
|
|
150
|
+
type,
|
|
151
|
+
children
|
|
152
|
+
}: ThemeEditableProps): React.ReactElement {
|
|
153
|
+
const context = useCustomBlockContext();
|
|
154
|
+
|
|
155
|
+
// Untouched rather than empty, so a `<ThemeEditable>` copy-pasted into a
|
|
156
|
+
// plain component keeps rendering what the developer wrote while the warning
|
|
157
|
+
// tells them why nothing is editable.
|
|
158
|
+
if (!context) {
|
|
159
|
+
devWarn(
|
|
160
|
+
'ThemeEditable',
|
|
161
|
+
`"${name}" is outside a custom block, so it has no properties to read. ` +
|
|
162
|
+
`Wrap the component with defineCustomBlock() and render it through a ` +
|
|
163
|
+
`custom block type; the child renders unchanged in the meantime.`
|
|
164
|
+
);
|
|
165
|
+
return <>{children}</>;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Dev throws so the developer sees the offending wrapper in the stack;
|
|
169
|
+
// production degrades to the authored markup, which is always a valid page.
|
|
170
|
+
const refuse = (reason: string): React.ReactElement => {
|
|
171
|
+
const message = `<ThemeEditable name="${name}"> ${reason}`;
|
|
172
|
+
|
|
173
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
174
|
+
throw new Error(`[pz-theme] ${message}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
devWarn('ThemeEditable', message);
|
|
178
|
+
return <>{children}</>;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
if (!SETTING_KEY_PATTERN.test(name)) {
|
|
182
|
+
return refuse(
|
|
183
|
+
`has an unusable name. A field name must start with a lowercase letter ` +
|
|
184
|
+
`and contain only letters and digits (max 32) — it becomes both an ` +
|
|
185
|
+
`object key in block.properties and a data-part value.`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (RESERVED_SETTING_KEYS.includes(name)) {
|
|
190
|
+
return refuse(
|
|
191
|
+
`uses "${name}", which the editor's property panel already treats ` +
|
|
192
|
+
`specially. The field would inherit behaviour it never asked for; ` +
|
|
193
|
+
`pick another name.`
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (type !== undefined && !isEditableKind(type)) {
|
|
198
|
+
return refuse(
|
|
199
|
+
`was given type="${String(type)}", which is not an editable kind. ` +
|
|
200
|
+
`Use text, image, link or file.`
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const childArray = React.Children.toArray(children);
|
|
205
|
+
|
|
206
|
+
if (childArray.length === 0) {
|
|
207
|
+
return refuse(
|
|
208
|
+
`has no element child. It must wrap exactly one element — the element ` +
|
|
209
|
+
`is what carries the value and states the default.`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (childArray.length > 1) {
|
|
214
|
+
return refuse(
|
|
215
|
+
`wraps ${childArray.length} children. It must wrap exactly one element; ` +
|
|
216
|
+
`cloning cannot know which of several should carry the value.`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const child = childArray[0];
|
|
221
|
+
|
|
222
|
+
if (!React.isValidElement(child)) {
|
|
223
|
+
return refuse(
|
|
224
|
+
`wraps a bare value rather than an element. Put the content in an ` +
|
|
225
|
+
`element (for example <p>…</p>) so there is a prop to write it to.`
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// A component's props are its own contract — `src`/`children` may mean
|
|
230
|
+
// nothing to it, or something else entirely. Only a host element has props
|
|
231
|
+
// whose meaning is fixed by HTML.
|
|
232
|
+
if (typeof child.type !== 'string') {
|
|
233
|
+
return refuse(
|
|
234
|
+
`wraps a component rather than a host element. Cloning cannot know ` +
|
|
235
|
+
`which of its props carries the value; wrap the element inside the ` +
|
|
236
|
+
`component instead.`
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const tag = child.type;
|
|
241
|
+
const kind = type ?? inferEditableKind(tag);
|
|
242
|
+
|
|
243
|
+
if (!kind) {
|
|
244
|
+
return refuse(
|
|
245
|
+
`wraps <${tag}>, which has no unambiguous place to put a value. Add an ` +
|
|
246
|
+
`explicit type (text, image, link or file), or wrap the specific ` +
|
|
247
|
+
`element that holds the content.`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const hrefKey = kind === 'link' ? hrefKeyFor(name) : null;
|
|
252
|
+
|
|
253
|
+
// The suffix pushes a long name past the 32-character cap, and the derived
|
|
254
|
+
// key has to satisfy the same pattern as the one the developer typed —
|
|
255
|
+
// otherwise the href silently has nowhere to be stored.
|
|
256
|
+
if (
|
|
257
|
+
hrefKey &&
|
|
258
|
+
(!SETTING_KEY_PATTERN.test(hrefKey) ||
|
|
259
|
+
RESERVED_SETTING_KEYS.includes(hrefKey))
|
|
260
|
+
) {
|
|
261
|
+
return refuse(
|
|
262
|
+
`would need a companion key "${hrefKey}" for its destination, which is ` +
|
|
263
|
+
`not a usable field name. Shorten the name.`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const childProps = child.props as Record<string, unknown>;
|
|
268
|
+
const valueProp = EDITABLE_KIND_TARGET[kind];
|
|
269
|
+
const authoredValue = childProps[valueProp];
|
|
270
|
+
const authoredHref = hrefKey ? childProps.href : undefined;
|
|
271
|
+
|
|
272
|
+
// Theirs wins. A developer who wrote their own `data-part` did it to style
|
|
273
|
+
// the element, and those part names are already published in compositions —
|
|
274
|
+
// overwriting one would drop the styles the merchant already saved. Decided
|
|
275
|
+
// before the field is reported, because whether `field-<name>` ends up on the
|
|
276
|
+
// page is exactly what the report has to say.
|
|
277
|
+
const stampsFieldPart = typeof childProps['data-part'] !== 'string';
|
|
278
|
+
|
|
279
|
+
// Reported during render, not from an effect: the collection pass is a
|
|
280
|
+
// server render (see collect.ts), and effects never run there.
|
|
281
|
+
reportField(context.collect, {
|
|
282
|
+
key: name,
|
|
283
|
+
type: EDITABLE_KIND_PROPERTY_TYPES[kind],
|
|
284
|
+
label: labelFromKey(name),
|
|
285
|
+
authoredDefault: authoredValue,
|
|
286
|
+
hasPart: stampsFieldPart
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
if (hrefKey) {
|
|
290
|
+
// The destination rides on the same `<a>` as the label and has no element
|
|
291
|
+
// of its own, so it is a panel control, not a part.
|
|
292
|
+
reportField(context.collect, {
|
|
293
|
+
key: hrefKey,
|
|
294
|
+
type: 'url',
|
|
295
|
+
label: `${labelFromKey(name)} link`,
|
|
296
|
+
authoredDefault: authoredHref,
|
|
297
|
+
hasPart: false
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const properties = (context.block.properties ?? {}) as Record<
|
|
302
|
+
string,
|
|
303
|
+
unknown
|
|
304
|
+
>;
|
|
305
|
+
|
|
306
|
+
const nextProps: Record<string, unknown> = {
|
|
307
|
+
[valueProp]: resolveFieldValue(
|
|
308
|
+
properties[name],
|
|
309
|
+
kind === 'image' || kind === 'file',
|
|
310
|
+
authoredValue,
|
|
311
|
+
context.currentBreakpoint
|
|
312
|
+
)
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
if (hrefKey) {
|
|
316
|
+
// A destination is a plain string in every control that writes one, so it
|
|
317
|
+
// never carries the picker's `{ url }` wrapper.
|
|
318
|
+
nextProps.href = resolveFieldValue(
|
|
319
|
+
properties[hrefKey],
|
|
320
|
+
false,
|
|
321
|
+
authoredHref,
|
|
322
|
+
context.currentBreakpoint
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (stampsFieldPart) {
|
|
327
|
+
// `use-designer-features.tsx` resolves the nearest `[data-part]` under the
|
|
328
|
+
// click and ships its value as `partName` on SELECT_BLOCK, so this one
|
|
329
|
+
// attribute is what makes clicking the element on canvas open its control.
|
|
330
|
+
// When the developer kept their own part the field is still edited from the
|
|
331
|
+
// property panel — it is only the click-to-focus shortcut, and the `parts`
|
|
332
|
+
// entry that would have advertised it, that are given up.
|
|
333
|
+
Object.assign(nextProps, partAttrs(fieldPartName(name)));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return React.cloneElement(
|
|
337
|
+
child as React.ReactElement<Record<string, unknown>>,
|
|
338
|
+
nextProps
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export default ThemeEditable;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useId } from 'react';
|
|
4
|
+
import { DesignerPlaceholder } from '../blocks/product-block-utils';
|
|
5
|
+
import { renderComposedChildren } from '../blocks/render-composed-children';
|
|
6
|
+
import { devWarn } from '../utils/dev-warn';
|
|
7
|
+
import { useCustomBlockContext } from './block-context';
|
|
8
|
+
import type { CustomBlockContextValue } from './block-context';
|
|
9
|
+
import {
|
|
10
|
+
resolveSlotChildren,
|
|
11
|
+
resolveUnnamedSlot,
|
|
12
|
+
slotNameError
|
|
13
|
+
} from './slot-nodes';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Which slot this instance opens, or why it cannot be told.
|
|
17
|
+
*
|
|
18
|
+
* A written name is taken at its word even when the block declared no slot of
|
|
19
|
+
* that name: a block that declares nothing at all is a supported shape, and its
|
|
20
|
+
* slots are discovered by rendering it.
|
|
21
|
+
*/
|
|
22
|
+
const resolveSlot = (
|
|
23
|
+
name: string | undefined,
|
|
24
|
+
declaredNames: readonly string[]
|
|
25
|
+
): { name: string } | { error: string } => {
|
|
26
|
+
if (name === undefined) return resolveUnnamedSlot(declaredNames);
|
|
27
|
+
|
|
28
|
+
const nameError = slotNameError(name);
|
|
29
|
+
|
|
30
|
+
return nameError ? { error: nameError } : { name };
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export interface ThemeSlotProps {
|
|
34
|
+
/** Shown on the empty band in the designer, e.g. "Promo content". */
|
|
35
|
+
label?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Which declared slot this opens. Omit it on a block that opens a single
|
|
38
|
+
* region — that spelling is what every block written against the original
|
|
39
|
+
* single-slot API uses, and it keeps its meaning exactly.
|
|
40
|
+
*/
|
|
41
|
+
name?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Which `ThemeSlot` instance owns each NAME in this block instance.
|
|
46
|
+
*
|
|
47
|
+
* Keyed by the context VALUE rather than the block id, which buys three things
|
|
48
|
+
* at once: the scope is one block instance in one render pass, two concurrent
|
|
49
|
+
* server renders of the same block id cannot see each other's claim, and the
|
|
50
|
+
* entry is collected with the context object instead of leaking — server
|
|
51
|
+
* renders never run the cleanup effect below.
|
|
52
|
+
*
|
|
53
|
+
* The claim is still needed now that slots are nodes, and it means something
|
|
54
|
+
* sharper than it did: two `<ThemeSlot name="promo" />` in one component are a
|
|
55
|
+
* REAL duplicate, both resolving to the same slot node. Rendering both would
|
|
56
|
+
* put that node's children on screen twice, and two `data-block-id` wrappers
|
|
57
|
+
* answering to one id break selection in the editor.
|
|
58
|
+
*/
|
|
59
|
+
const slotOwners = new WeakMap<CustomBlockContextValue, Map<string, string>>();
|
|
60
|
+
|
|
61
|
+
const claimSlot = (
|
|
62
|
+
context: CustomBlockContextValue,
|
|
63
|
+
name: string,
|
|
64
|
+
instanceId: string
|
|
65
|
+
): boolean => {
|
|
66
|
+
const claims = slotOwners.get(context) ?? new Map<string, string>();
|
|
67
|
+
slotOwners.set(context, claims);
|
|
68
|
+
|
|
69
|
+
const owner = claims.get(name);
|
|
70
|
+
if (owner === undefined) {
|
|
71
|
+
claims.set(name, instanceId);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return owner === instanceId;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const releaseSlot = (
|
|
79
|
+
context: CustomBlockContextValue,
|
|
80
|
+
name: string,
|
|
81
|
+
instanceId: string
|
|
82
|
+
): void => {
|
|
83
|
+
const claims = slotOwners.get(context);
|
|
84
|
+
if (claims?.get(name) === instanceId) claims.delete(name);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A merchant-fillable hole in a custom block: everything a merchant drops
|
|
89
|
+
* inside it renders here, through the same child pipeline every built-in
|
|
90
|
+
* container uses.
|
|
91
|
+
*
|
|
92
|
+
* ONE NODE PER SLOT. A declared slot materializes as a reserved-type child of
|
|
93
|
+
* the custom block and this renders THAT node's children, so a block can open
|
|
94
|
+
* several regions and each is filled independently — the editor addresses an
|
|
95
|
+
* insertion as `(placeholderId, sectionId, parentBlockId, index)`, and a slot
|
|
96
|
+
* that is a node is simply the parent.
|
|
97
|
+
*
|
|
98
|
+
* A SLOT WITH NO NODE IS NOT AN ERROR. Nothing creates the nodes until the
|
|
99
|
+
* merchant opens the region, and every composition saved before slots were
|
|
100
|
+
* nodes has none at all. Both cases draw the empty band in the designer and
|
|
101
|
+
* render nothing on the live site; `resolveSlotChildren` is what keeps the
|
|
102
|
+
* older shape rendering.
|
|
103
|
+
*
|
|
104
|
+
* EMPTY BEHAVES DIFFERENTLY PER SIDE. The designer draws a dashed band so the
|
|
105
|
+
* hole is visible and droppable; the live storefront renders nothing at all.
|
|
106
|
+
* That distinction is the whole point of the split — the empty branch of
|
|
107
|
+
* `GroupBlock` (blocks/group-block.tsx) still ships the literal string
|
|
108
|
+
* "Empty group block" to shoppers, which is exactly the outcome this must
|
|
109
|
+
* not repeat.
|
|
110
|
+
*/
|
|
111
|
+
export function ThemeSlot({ label, name }: ThemeSlotProps) {
|
|
112
|
+
const context = useCustomBlockContext();
|
|
113
|
+
// Stable for the life of the instance and distinct between two instances,
|
|
114
|
+
// which is what separates "the same slot re-rendering" from "a second slot
|
|
115
|
+
// claiming the same name".
|
|
116
|
+
const instanceId = useId();
|
|
117
|
+
|
|
118
|
+
const resolved = resolveSlot(name, context?.declaredSlotNames ?? []);
|
|
119
|
+
const slotName = 'name' in resolved ? resolved.name : null;
|
|
120
|
+
const ownsSlot =
|
|
121
|
+
context && slotName !== null
|
|
122
|
+
? claimSlot(context, slotName, instanceId)
|
|
123
|
+
: false;
|
|
124
|
+
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (!context || slotName === null || !ownsSlot) return;
|
|
127
|
+
|
|
128
|
+
// Hand the claim back on unmount so a slot behind a condition
|
|
129
|
+
// (`{isWide && <ThemeSlot name="aside" />}`) does not lock the block out of
|
|
130
|
+
// that name for the rest of the page's life.
|
|
131
|
+
return () => releaseSlot(context, slotName, instanceId);
|
|
132
|
+
}, [context, slotName, ownsSlot, instanceId]);
|
|
133
|
+
|
|
134
|
+
if (!context) {
|
|
135
|
+
devWarn(
|
|
136
|
+
'ThemeSlot',
|
|
137
|
+
'A ThemeSlot rendered outside a custom block, so there is no composition ' +
|
|
138
|
+
'to read children from and nothing is rendered. Render this component ' +
|
|
139
|
+
'through a block registered with registerCustomBlocks().'
|
|
140
|
+
);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const { block, isDesigner } = context;
|
|
145
|
+
|
|
146
|
+
if (slotName === null) {
|
|
147
|
+
const reason = (resolved as { error: string }).error;
|
|
148
|
+
|
|
149
|
+
devWarn(
|
|
150
|
+
'ThemeSlot',
|
|
151
|
+
`The custom block "${block.type}" renders a slot that cannot be resolved: ${reason}.`
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
return isDesigner ? (
|
|
155
|
+
<DesignerPlaceholder label={`Unresolved slot — ${reason}`} />
|
|
156
|
+
) : null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!ownsSlot) {
|
|
160
|
+
const named = slotName === '' ? 'an unnamed slot' : `the slot "${slotName}"`;
|
|
161
|
+
|
|
162
|
+
devWarn(
|
|
163
|
+
'ThemeSlot',
|
|
164
|
+
`The custom block "${block.type}" declares more than one ThemeSlot for ${named}. ` +
|
|
165
|
+
'Both resolve to the same slot node, so its children would render twice and two ' +
|
|
166
|
+
'wrappers would answer to one block id; the extra slot renders nothing. Give each ' +
|
|
167
|
+
'ThemeSlot its own name.'
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
return isDesigner ? (
|
|
171
|
+
<DesignerPlaceholder
|
|
172
|
+
label={`Duplicate slot — ${named} is already open in this block`}
|
|
173
|
+
/>
|
|
174
|
+
) : null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Reported during render, like the settings sink: the collection pass reads
|
|
178
|
+
// it synchronously and effects do not run on the server.
|
|
179
|
+
context.collect?.slot(
|
|
180
|
+
label === undefined ? { name: slotName } : { name: slotName, label }
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const slotChildren = resolveSlotChildren(block, slotName);
|
|
184
|
+
|
|
185
|
+
const children = renderComposedChildren({
|
|
186
|
+
// Null is "this slot has no node yet", which renders exactly like an empty
|
|
187
|
+
// one — the merchant has simply never put anything here.
|
|
188
|
+
blocks: slotChildren ?? [],
|
|
189
|
+
placeholderId: context.placeholderId,
|
|
190
|
+
sectionId: context.sectionId,
|
|
191
|
+
isDesigner,
|
|
192
|
+
selectedBlockId: context.selectedBlockId,
|
|
193
|
+
currentBreakpoint: context.currentBreakpoint
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Empty is decided AFTER the pipeline, not from the array length: a slot
|
|
197
|
+
// holding only hidden children is empty on the storefront and full in the
|
|
198
|
+
// designer, and both sides have to draw what they actually rendered.
|
|
199
|
+
if (children.length === 0) {
|
|
200
|
+
return isDesigner ? (
|
|
201
|
+
<DesignerPlaceholder
|
|
202
|
+
label={`${label ?? (slotName || 'Slot')} — empty, add blocks from the editor`}
|
|
203
|
+
/>
|
|
204
|
+
) : null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return <>{children}</>;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export default ThemeSlot;
|