@akinon/pz-theme 2.0.71-beta.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.
@@ -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;