@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,545 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CLIENT-ONLY, and the directive is load-bearing rather than tidy.
|
|
5
|
+
*
|
|
6
|
+
* `block-renderer-registry` imports all ~99 block modules, and most of them
|
|
7
|
+
* call hooks without carrying a directive of their own — they have never
|
|
8
|
+
* needed one, because the only path that reaches them is `theme-block.tsx`,
|
|
9
|
+
* which is a client module. Importing the registry from a directive-free
|
|
10
|
+
* module drags that whole tree into whichever graph the importer sits in, so a
|
|
11
|
+
* brand barrel imported from a server layout fails the build at
|
|
12
|
+
* `accordion-block.tsx:1` with "You're importing a module that depends on
|
|
13
|
+
* `useState` into a React Server Component module" — before any of this code
|
|
14
|
+
* gets to run.
|
|
15
|
+
*
|
|
16
|
+
* Registering only on the client costs nothing: `theme-block.tsx` is the one
|
|
17
|
+
* production reader of the registry and is itself a client module, and the
|
|
18
|
+
* server-side CSS generator never consults it. SSR still resolves custom
|
|
19
|
+
* renderers, because Next renders client components on the server through the
|
|
20
|
+
* client module graph.
|
|
21
|
+
*
|
|
22
|
+
* Consequence for brands: the registration barrel is imported from the CLIENT
|
|
23
|
+
* ROOT ONLY, not from the server layout as well. See `custom/index.ts`.
|
|
24
|
+
*/
|
|
25
|
+
import blockRendererRegistry from '../blocks/block-renderer-registry';
|
|
26
|
+
import type { BlockRendererProps } from '../blocks/block-renderer-registry';
|
|
27
|
+
import { announceCustomBlocks } from '../placeholder-registry';
|
|
28
|
+
import { devWarn } from '../utils/dev-warn';
|
|
29
|
+
import { createElement } from 'react';
|
|
30
|
+
import type { ComponentType } from 'react';
|
|
31
|
+
import { CustomBlockHost } from './block-host';
|
|
32
|
+
import {
|
|
33
|
+
addToCatalog,
|
|
34
|
+
getCatalogVendor,
|
|
35
|
+
getCustomBlockDescriptor,
|
|
36
|
+
getCustomBlockTypes,
|
|
37
|
+
setCatalogVendor
|
|
38
|
+
} from './catalog';
|
|
39
|
+
import {
|
|
40
|
+
normalizeSettingChoices,
|
|
41
|
+
settingKeyError,
|
|
42
|
+
settingTypeError
|
|
43
|
+
} from './collect';
|
|
44
|
+
import { fieldPartName } from './editable-tags';
|
|
45
|
+
import { normalizeCustomBlock } from './define';
|
|
46
|
+
import {
|
|
47
|
+
normalizePartsDeclaration,
|
|
48
|
+
partNameError,
|
|
49
|
+
partStateError
|
|
50
|
+
} from './parts';
|
|
51
|
+
import { slotNameError } from './slot-nodes';
|
|
52
|
+
import type {
|
|
53
|
+
CustomBlockDefinition,
|
|
54
|
+
CustomBlockInput,
|
|
55
|
+
CustomBlockOptions,
|
|
56
|
+
CustomSettingDeclaration,
|
|
57
|
+
CustomSlotDeclaration
|
|
58
|
+
} from './define';
|
|
59
|
+
import {
|
|
60
|
+
CUSTOM_LIMITS,
|
|
61
|
+
CUSTOM_SEGMENT_PATTERN,
|
|
62
|
+
materializeDefault
|
|
63
|
+
} from './types';
|
|
64
|
+
import type {
|
|
65
|
+
CustomBlockDescriptor,
|
|
66
|
+
CustomSettingDescriptor,
|
|
67
|
+
CustomSlotDescriptor
|
|
68
|
+
} from './types';
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Keys whose value is `undefined` are stripped rather than written.
|
|
72
|
+
*
|
|
73
|
+
* `JSON.stringify` drops an undefined-valued key entirely, so writing one would
|
|
74
|
+
* make the descriptor and the payload the editor receives structurally
|
|
75
|
+
* different — and `assertSerializable` below, which is the mechanical catch for
|
|
76
|
+
* a function slipping in, would fire on every block that merely omitted an
|
|
77
|
+
* optional field.
|
|
78
|
+
*/
|
|
79
|
+
const withoutUndefined = <T extends object>(value: T): T =>
|
|
80
|
+
Object.fromEntries(
|
|
81
|
+
Object.entries(value).filter(([, entry]) => entry !== undefined)
|
|
82
|
+
) as T;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Path of the first value that changes across a JSON round trip, or null.
|
|
86
|
+
*
|
|
87
|
+
* Compared structurally rather than by scanning for function values, because
|
|
88
|
+
* the round trip is the actual transport: whatever `JSON.stringify` alters here
|
|
89
|
+
* is what the editor will never see. That covers functions and symbols (dropped
|
|
90
|
+
* silently) and the less obvious ones — a Date becomes a string, `NaN` and
|
|
91
|
+
* `Infinity` become null, a class instance loses its prototype.
|
|
92
|
+
*/
|
|
93
|
+
const firstDivergentPath = (
|
|
94
|
+
authored: unknown,
|
|
95
|
+
roundTripped: unknown,
|
|
96
|
+
path: string
|
|
97
|
+
): string | null => {
|
|
98
|
+
if (authored === roundTripped) return null;
|
|
99
|
+
if (typeof authored !== typeof roundTripped) return path;
|
|
100
|
+
if (authored === null || roundTripped === null) return path;
|
|
101
|
+
if (typeof authored !== 'object') return path;
|
|
102
|
+
if (Array.isArray(authored) !== Array.isArray(roundTripped)) return path;
|
|
103
|
+
|
|
104
|
+
const keys = new Set([
|
|
105
|
+
...Object.keys(authored as object),
|
|
106
|
+
...Object.keys(roundTripped as object)
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
for (const key of keys) {
|
|
110
|
+
const divergent = firstDivergentPath(
|
|
111
|
+
(authored as Record<string, unknown>)[key],
|
|
112
|
+
(roundTripped as Record<string, unknown>)[key],
|
|
113
|
+
`${path}.${key}`
|
|
114
|
+
);
|
|
115
|
+
if (divergent) return divergent;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return null;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const assertSerializable = (descriptor: CustomBlockDescriptor): void => {
|
|
122
|
+
let roundTripped: unknown;
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
roundTripped = JSON.parse(JSON.stringify(descriptor));
|
|
126
|
+
} catch (error) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`[pz-theme] Custom block "${descriptor.type}" has a descriptor that cannot be serialized ` +
|
|
129
|
+
`(${String(
|
|
130
|
+
error
|
|
131
|
+
)}). The catalog is posted to the editor, so every declared value has to survive JSON.`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const divergent = firstDivergentPath(descriptor, roundTripped, 'descriptor');
|
|
136
|
+
if (divergent) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`[pz-theme] Custom block "${descriptor.type}" declares a value at ${divergent} that does not survive JSON. ` +
|
|
139
|
+
`The catalog crosses postMessage, which drops functions, symbols and undefined without a word — ` +
|
|
140
|
+
`the editor would receive the descriptor with that key missing and no error anywhere.`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const buildSetting = (
|
|
146
|
+
type: string,
|
|
147
|
+
declaration: CustomSettingDeclaration
|
|
148
|
+
): CustomSettingDescriptor => {
|
|
149
|
+
const keyError = settingKeyError(declaration.key);
|
|
150
|
+
if (keyError) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`[pz-theme] Custom block "${type}" declares a setting: ${keyError}.`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const typeError = settingTypeError(declaration.type);
|
|
157
|
+
if (typeError) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`[pz-theme] Custom block "${type}" declares setting "${declaration.key}" with ${typeError}.`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return withoutUndefined<CustomSettingDescriptor>({
|
|
164
|
+
key: declaration.key,
|
|
165
|
+
type: declaration.type,
|
|
166
|
+
// The panel renders this string as the control's caption; falling back to
|
|
167
|
+
// the key keeps an unlabelled control identifiable instead of blank.
|
|
168
|
+
label: declaration.label ?? declaration.key,
|
|
169
|
+
defaultValue: materializeDefault(declaration.defaultValue),
|
|
170
|
+
// Shared with `useThemeSetting` rather than reimplemented: a block that
|
|
171
|
+
// declares `tone` up front and a component that declares it through the
|
|
172
|
+
// hook have to produce byte-identical option lists, or the same `select`
|
|
173
|
+
// renders differently depending on which door it came through. It lives in
|
|
174
|
+
// `collect.ts` because that is the one module here with no inbound edge, so
|
|
175
|
+
// importing it from either door closes no cycle.
|
|
176
|
+
options: normalizeSettingChoices(declaration.options),
|
|
177
|
+
min: declaration.min,
|
|
178
|
+
max: declaration.max,
|
|
179
|
+
step: declaration.step,
|
|
180
|
+
unit: declaration.unit,
|
|
181
|
+
maxLength: declaration.maxLength,
|
|
182
|
+
helpText: declaration.helpText,
|
|
183
|
+
group: declaration.group,
|
|
184
|
+
showWhen: declaration.showWhen,
|
|
185
|
+
editable: declaration.editable
|
|
186
|
+
});
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* One slot, refusing a name that could not be matched and a name already taken.
|
|
191
|
+
*
|
|
192
|
+
* A duplicate is now a genuine collision rather than a limitation: two slots
|
|
193
|
+
* sharing a name resolve to the SAME slot node, so both would render its
|
|
194
|
+
* children and the composition would show every child twice — with two
|
|
195
|
+
* `data-block-id` wrappers answering to one id, which breaks selection on the
|
|
196
|
+
* canvas as well.
|
|
197
|
+
*
|
|
198
|
+
* `accepts` and `max` are checked here even though nothing on THIS side reads
|
|
199
|
+
* them, and that is the point. They are the only descriptor values a developer
|
|
200
|
+
* can write that pz-theme never looks at, so a mistake in one is invisible
|
|
201
|
+
* until the editor refuses the frame — and the editor refuses the WHOLE
|
|
202
|
+
* catalog, so a single `accepts: []` empties a brand's entire palette with the
|
|
203
|
+
* only diagnosis sitting in a repo the developer is not working in. `accepts:
|
|
204
|
+
* []` is a region nothing can ever go in, and a `max` that is not a positive
|
|
205
|
+
* whole number is not a count; both are declaration bugs wherever they are
|
|
206
|
+
* caught, and only here can the message name the block that wrote them.
|
|
207
|
+
*/
|
|
208
|
+
const buildSlot = (
|
|
209
|
+
type: string,
|
|
210
|
+
declaration: CustomSlotDeclaration,
|
|
211
|
+
alreadyDeclared: readonly CustomSlotDescriptor[]
|
|
212
|
+
): CustomSlotDescriptor => {
|
|
213
|
+
const name = declaration.name ?? '';
|
|
214
|
+
|
|
215
|
+
const nameError = slotNameError(name);
|
|
216
|
+
if (nameError) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`[pz-theme] Custom block "${type}" declares a slot: ${nameError}.`
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (alreadyDeclared.some((slot) => slot.name === name)) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
name === ''
|
|
225
|
+
? `[pz-theme] Custom block "${type}" declares two unnamed slots. A slot is addressed by its ` +
|
|
226
|
+
`name, so both resolve to the same slot node and every child would appear twice. Give them names.`
|
|
227
|
+
: `[pz-theme] Custom block "${type}" declares the slot "${name}" twice. Both resolve to the same ` +
|
|
228
|
+
`slot node, so every child would appear twice — rename one of them.`
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const named = name === '' ? 'its unnamed slot' : `the slot "${name}"`;
|
|
233
|
+
|
|
234
|
+
if (declaration.accepts !== undefined) {
|
|
235
|
+
const malformed =
|
|
236
|
+
!Array.isArray(declaration.accepts) ||
|
|
237
|
+
declaration.accepts.some(
|
|
238
|
+
(entry) => typeof entry !== 'string' || entry === ''
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
if (malformed) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
`[pz-theme] Custom block "${type}" declares ${named} with a malformed accepts list. ` +
|
|
244
|
+
`Every entry has to be a non-empty block type string — the editor refuses the whole catalog ` +
|
|
245
|
+
`over one that is not, and every block in this build would disappear from the palette.`
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (declaration.accepts.length === 0) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
`[pz-theme] Custom block "${type}" declares ${named} with an empty accepts list, which no block ` +
|
|
252
|
+
`could ever satisfy. Omit accepts to take anything; a block that means "no children here" ` +
|
|
253
|
+
`declares no slot at all.`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (
|
|
259
|
+
declaration.max !== undefined &&
|
|
260
|
+
(typeof declaration.max !== 'number' ||
|
|
261
|
+
!Number.isInteger(declaration.max) ||
|
|
262
|
+
declaration.max < 1)
|
|
263
|
+
) {
|
|
264
|
+
throw new Error(
|
|
265
|
+
`[pz-theme] Custom block "${type}" declares ${named} with max ${JSON.stringify(
|
|
266
|
+
declaration.max
|
|
267
|
+
)}, which is not a positive whole number of blocks. Omit max for an unbounded region.`
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return withoutUndefined<CustomSlotDescriptor>({
|
|
272
|
+
name,
|
|
273
|
+
label: declaration.label,
|
|
274
|
+
// Carried for the editor's benefit; pz-theme enforces neither acceptance
|
|
275
|
+
// nor the cap at render time — it only guarantees, above, that what it
|
|
276
|
+
// hands over is a shape the editor will accept.
|
|
277
|
+
accepts: declaration.accepts,
|
|
278
|
+
max: declaration.max
|
|
279
|
+
});
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Binds one type's identity onto the client host.
|
|
284
|
+
*
|
|
285
|
+
* `createElement` rather than a call into `block-host.tsx`, and the wrapper is
|
|
286
|
+
* the price of that. `block-host.tsx` is its own `'use client'` module and has
|
|
287
|
+
* to be — `React.Component` and `createContext` are both `undefined` under the
|
|
288
|
+
* `react-server` condition — so `CustomBlockHost` is a client EXPORT whichever
|
|
289
|
+
* graph ends up holding this file. Referencing a client export as an element
|
|
290
|
+
* type resolves from either graph; CALLING one throws "Attempted to call … from
|
|
291
|
+
* the server" as soon as the reference is the server-side one. `createElement`
|
|
292
|
+
* keeps the binding on the legal side of that line without this module having
|
|
293
|
+
* to reason about where a brand's import landed.
|
|
294
|
+
*/
|
|
295
|
+
const bindHost = (
|
|
296
|
+
type: string,
|
|
297
|
+
definition: CustomBlockDefinition,
|
|
298
|
+
label: string
|
|
299
|
+
): ComponentType<BlockRendererProps> => {
|
|
300
|
+
const Host = (props: BlockRendererProps) =>
|
|
301
|
+
createElement(CustomBlockHost, { ...props, type, label, definition });
|
|
302
|
+
|
|
303
|
+
// Named for the React devtools tree: without this every custom block on the
|
|
304
|
+
// page appears as the same anonymous wrapper. Named for the BINDING and not
|
|
305
|
+
// for the host it renders, which is a separate fiber inside it carrying its
|
|
306
|
+
// own name.
|
|
307
|
+
Host.displayName = `CustomBlock(${type})`;
|
|
308
|
+
|
|
309
|
+
return Host;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const buildDescriptor = (
|
|
313
|
+
type: string,
|
|
314
|
+
name: string,
|
|
315
|
+
options: CustomBlockOptions
|
|
316
|
+
): CustomBlockDescriptor => {
|
|
317
|
+
const declaredSettings = options.settings ?? [];
|
|
318
|
+
|
|
319
|
+
if (declaredSettings.length > CUSTOM_LIMITS.settingsPerType) {
|
|
320
|
+
throw new Error(
|
|
321
|
+
`[pz-theme] Custom block "${type}" declares ${declaredSettings.length} settings, over the ` +
|
|
322
|
+
`${CUSTOM_LIMITS.settingsPerType} cap. The whole catalog is posted to the editor on every ` +
|
|
323
|
+
`placeholder announce; split the block.`
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const settings: CustomSettingDescriptor[] = [];
|
|
328
|
+
const parts: string[] = [];
|
|
329
|
+
|
|
330
|
+
for (const declaration of declaredSettings) {
|
|
331
|
+
if (settings.some((setting) => setting.key === declaration.key)) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
`[pz-theme] Custom block "${type}" declares the setting key "${declaration.key}" twice. ` +
|
|
334
|
+
`Both write to block.properties.${declaration.key}, so one control would silently overwrite the other.`
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const setting = buildSetting(type, declaration);
|
|
339
|
+
settings.push(setting);
|
|
340
|
+
|
|
341
|
+
// Same helper the render path stamps the element with, so a part the
|
|
342
|
+
// catalog advertises is always one the DOM actually carries.
|
|
343
|
+
if (setting.editable) parts.push(fieldPartName(setting.key));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const declaredSlots = options.slots ?? [];
|
|
347
|
+
|
|
348
|
+
// The phase-1 twin of the cap the editor also applies: it drops the whole
|
|
349
|
+
// catalog over the limit, so a build that exceeds it would lose every block
|
|
350
|
+
// in the palette with only the receiving side able to say why.
|
|
351
|
+
if (declaredSlots.length > CUSTOM_LIMITS.slotsPerType) {
|
|
352
|
+
throw new Error(
|
|
353
|
+
`[pz-theme] Custom block "${type}" declares ${declaredSlots.length} slots, over the ` +
|
|
354
|
+
`${CUSTOM_LIMITS.slotsPerType} cap. The whole catalog is posted to the editor on every ` +
|
|
355
|
+
`placeholder announce; split the block.`
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const slots: CustomSlotDescriptor[] = [];
|
|
360
|
+
for (const declaration of declaredSlots) {
|
|
361
|
+
slots.push(buildSlot(type, declaration, slots));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Declared parts join the field-derived ones in the SAME list, after them, so
|
|
365
|
+
// the wire shape stays one array of names — see `CustomBlockDescriptor.parts`
|
|
366
|
+
// for why that list must not become a list of objects.
|
|
367
|
+
const partStates: Record<string, string[]> = {};
|
|
368
|
+
|
|
369
|
+
for (const declared of normalizePartsDeclaration(options.parts)) {
|
|
370
|
+
const nameError = partNameError(declared.name);
|
|
371
|
+
if (nameError) {
|
|
372
|
+
throw new Error(
|
|
373
|
+
`[pz-theme] Custom block "${type}" declares a part: ${nameError}.`
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (parts.includes(declared.name)) {
|
|
378
|
+
throw new Error(
|
|
379
|
+
`[pz-theme] Custom block "${type}" declares the part "${declared.name}" twice. ` +
|
|
380
|
+
`The editor builds one control group per advertised part, so the second group would write ` +
|
|
381
|
+
`the same style keys as the first with nothing to say which won.`
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
for (const state of declared.states) {
|
|
386
|
+
const stateError = partStateError(state);
|
|
387
|
+
if (stateError) {
|
|
388
|
+
throw new Error(
|
|
389
|
+
`[pz-theme] Custom block "${type}" declares part "${declared.name}": ${stateError}.`
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
parts.push(declared.name);
|
|
395
|
+
if (declared.states.length > 0) partStates[declared.name] = declared.states;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (parts.length > CUSTOM_LIMITS.partsPerType) {
|
|
399
|
+
throw new Error(
|
|
400
|
+
`[pz-theme] Custom block "${type}" advertises ${parts.length} parts, over the ` +
|
|
401
|
+
`${CUSTOM_LIMITS.partsPerType} cap. The editor drops the whole catalog above it, so every ` +
|
|
402
|
+
`block in this build would disappear from the palette; declare fewer parts.`
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return withoutUndefined<CustomBlockDescriptor>({
|
|
407
|
+
type,
|
|
408
|
+
name,
|
|
409
|
+
label: options.label ?? name,
|
|
410
|
+
icon: options.icon,
|
|
411
|
+
group: options.group,
|
|
412
|
+
// Runtime collection flips this to true when a `<ThemeSlot />` renders in a
|
|
413
|
+
// block that declared none, so a developer who declares nothing still gets
|
|
414
|
+
// a droppable block.
|
|
415
|
+
hasChildren: slots.length > 0,
|
|
416
|
+
settings,
|
|
417
|
+
slots,
|
|
418
|
+
parts,
|
|
419
|
+
// Omitted rather than sent empty: an editor that has never heard of the key
|
|
420
|
+
// ignores it either way, and a brand that declares no state sends nothing
|
|
421
|
+
// extra on every placeholder announce.
|
|
422
|
+
partStates: Object.keys(partStates).length > 0 ? partStates : undefined
|
|
423
|
+
});
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Registers a barrel of developer-authored blocks under one vendor. The one
|
|
428
|
+
* entry point a brand calls — everything else in `src/custom` is consumed by
|
|
429
|
+
* the block it registers or by the editor bridge that announces it.
|
|
430
|
+
*
|
|
431
|
+
* Import the barrel that calls this from the CLIENT ROOT ONLY — never from the
|
|
432
|
+
* server layout as well. This module is `'use client'` because it must be; the
|
|
433
|
+
* file header explains what importing it from a server graph does to a brand's
|
|
434
|
+
* build, and it is not subtle.
|
|
435
|
+
*
|
|
436
|
+
* ATOMIC ACROSS THE RECORD: everything is validated before anything is
|
|
437
|
+
* mutated. A half-registered barrel is worse than a rejected one — the editor
|
|
438
|
+
* would be offered blocks out of the catalog that the build cannot render, and
|
|
439
|
+
* the merchant would place one and get an unknown-type box on the storefront.
|
|
440
|
+
* Every phase-2 failure mode therefore has a phase-1 twin here, including the
|
|
441
|
+
* two caps `addToCatalog` also enforces.
|
|
442
|
+
*/
|
|
443
|
+
export const registerCustomBlocks = (
|
|
444
|
+
vendor: string,
|
|
445
|
+
record: Record<string, CustomBlockInput>
|
|
446
|
+
): void => {
|
|
447
|
+
if (!CUSTOM_SEGMENT_PATTERN.test(vendor)) {
|
|
448
|
+
throw new Error(
|
|
449
|
+
`[pz-theme] registerCustomBlocks() vendor ${JSON.stringify(
|
|
450
|
+
vendor
|
|
451
|
+
)} is not a valid segment. ` +
|
|
452
|
+
`Use lowercase letters, digits and hyphens, starting with a letter or digit (max 48 characters) — ` +
|
|
453
|
+
`it becomes the middle segment of every type this call registers.`
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const pending: Array<{
|
|
458
|
+
type: string;
|
|
459
|
+
descriptor: CustomBlockDescriptor;
|
|
460
|
+
host: ComponentType<BlockRendererProps>;
|
|
461
|
+
}> = [];
|
|
462
|
+
const claimed = new Set<string>();
|
|
463
|
+
|
|
464
|
+
for (const [name, input] of Object.entries(record ?? {})) {
|
|
465
|
+
if (!CUSTOM_SEGMENT_PATTERN.test(name)) {
|
|
466
|
+
throw new Error(
|
|
467
|
+
`[pz-theme] Custom block name ${JSON.stringify(
|
|
468
|
+
name
|
|
469
|
+
)} is not a valid segment. ` +
|
|
470
|
+
`Use lowercase letters, digits and hyphens, starting with a letter or digit (max 48 characters) — ` +
|
|
471
|
+
`the name is the published type and cannot be changed once a composition references it.`
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const type = `custom:${vendor}:${name}`;
|
|
476
|
+
|
|
477
|
+
// Also the guarantee that phase 2 cannot throw: without it two entries
|
|
478
|
+
// resolving to one type would pass validation and then fail inside
|
|
479
|
+
// `addToCatalog`, half-applying the record.
|
|
480
|
+
if (claimed.has(type)) {
|
|
481
|
+
throw new Error(
|
|
482
|
+
`[pz-theme] Custom block "${type}" appears twice in one registerCustomBlocks() call. ` +
|
|
483
|
+
`Rename one of them — the second would replace the first everywhere it is already placed.`
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Never shadow, never last-wins. `BlockRendererRegistry.register` in
|
|
488
|
+
// `blocks/block-renderer-registry.tsx` is a bare `Map.set`, so a second
|
|
489
|
+
// claim on a type would silently render the later component everywhere the
|
|
490
|
+
// earlier one was placed, with no signal on either side.
|
|
491
|
+
if (
|
|
492
|
+
blockRendererRegistry.hasRenderer(type) ||
|
|
493
|
+
getCustomBlockDescriptor(type)
|
|
494
|
+
) {
|
|
495
|
+
throw new Error(
|
|
496
|
+
`[pz-theme] Custom block "${type}" is already registered in this build. ` +
|
|
497
|
+
`Registration refuses to overwrite it. Rename one of them, or check whether the barrel is ` +
|
|
498
|
+
`being registered twice.`
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const definition = normalizeCustomBlock(input, name);
|
|
503
|
+
const descriptor = buildDescriptor(type, name, definition.options);
|
|
504
|
+
assertSerializable(descriptor);
|
|
505
|
+
|
|
506
|
+
claimed.add(type);
|
|
507
|
+
pending.push({
|
|
508
|
+
type,
|
|
509
|
+
descriptor,
|
|
510
|
+
host: bindHost(type, definition, descriptor.label)
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const total = getCustomBlockTypes().length + pending.length;
|
|
515
|
+
if (total > CUSTOM_LIMITS.typesPerBuild) {
|
|
516
|
+
throw new Error(
|
|
517
|
+
`[pz-theme] This build would declare ${total} custom blocks, over the ` +
|
|
518
|
+
`${CUSTOM_LIMITS.typesPerBuild} cap. The cap exists because the whole catalog is posted to the ` +
|
|
519
|
+
`editor on every placeholder announce; split the build or retire unused blocks.`
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const previousVendor = getCatalogVendor();
|
|
524
|
+
if (previousVendor && previousVendor !== vendor) {
|
|
525
|
+
devWarn(
|
|
526
|
+
'customBlock',
|
|
527
|
+
`Catalog vendor changed from "${previousVendor}" to "${vendor}". The catalog carries ONE vendor ` +
|
|
528
|
+
`field, so every block already registered is announced under the new name — register one vendor per build.`
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
for (const entry of pending) {
|
|
533
|
+
addToCatalog(entry.descriptor);
|
|
534
|
+
blockRendererRegistry.register(entry.type, entry.host);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
setCatalogVendor(vendor);
|
|
538
|
+
|
|
539
|
+
// Tell the editor bridge a catalog now exists. This runs at module
|
|
540
|
+
// evaluation, long before any placeholder has mounted, so it only RE-ARMS
|
|
541
|
+
// the placeholder announce — the flush that eventually goes out carries the
|
|
542
|
+
// slugs and the catalog in one frame, and no frame is posted at all if this
|
|
543
|
+
// document never mounts a placeholder.
|
|
544
|
+
announceCustomBlocks();
|
|
545
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { Block } from '../theme-block';
|
|
2
|
+
import {
|
|
3
|
+
CUSTOM_SLOT_BLOCK_TYPE,
|
|
4
|
+
SLOT_NAME_PATTERN,
|
|
5
|
+
SLOT_NAME_PROPERTY
|
|
6
|
+
} from './types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Reading slots back out of a composition.
|
|
10
|
+
*
|
|
11
|
+
* A declared slot materializes as one reserved-type child of the custom block,
|
|
12
|
+
* so `<ThemeSlot name="promo" />` renders the children of THAT node rather than
|
|
13
|
+
* the block's whole child list. Everything in this file is the read half of
|
|
14
|
+
* that: the editor creates the nodes, the renderer finds them.
|
|
15
|
+
*
|
|
16
|
+
* Directive-free on purpose. `register.ts`, `collect.ts` and `theme-slot.tsx`
|
|
17
|
+
* all need the same answers, and only the last of the three is a client module.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const isSlotNode = (block: Block): boolean =>
|
|
21
|
+
block.type === CUSTOM_SLOT_BLOCK_TYPE;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The name a slot node carries; the empty string for the unnamed slot.
|
|
25
|
+
*
|
|
26
|
+
* Both a bare string and a per-breakpoint bucket are read, and the second
|
|
27
|
+
* branch is not speculative tidiness. Every other value in `block.properties`
|
|
28
|
+
* is per-breakpoint, and `handlePropertyChange` in the editor re-buckets
|
|
29
|
+
* whatever it is given — so the day anything routes this key through a property
|
|
30
|
+
* control, a bare-string-only reader would detach every named slot in every
|
|
31
|
+
* published composition at once, silently. One branch buys immunity from that.
|
|
32
|
+
*/
|
|
33
|
+
export const slotNodeName = (block: Block): string => {
|
|
34
|
+
const raw = block.properties?.[SLOT_NAME_PROPERTY];
|
|
35
|
+
|
|
36
|
+
if (typeof raw === 'string') return raw;
|
|
37
|
+
|
|
38
|
+
if (raw && typeof raw === 'object') {
|
|
39
|
+
const desktop = (raw as Record<string, unknown>).desktop;
|
|
40
|
+
if (typeof desktop === 'string') return desktop;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return '';
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The children one slot renders, or null when the slot has no node yet.
|
|
48
|
+
*
|
|
49
|
+
* Null and `[]` mean different things to the caller and both have to be
|
|
50
|
+
* distinguishable: an empty slot node is a region the merchant emptied, a
|
|
51
|
+
* missing node is a region the merchant has never opened. Neither renders
|
|
52
|
+
* anything on the live storefront, so the distinction costs shoppers nothing —
|
|
53
|
+
* it exists so the designer can say which is which.
|
|
54
|
+
*
|
|
55
|
+
* THE LEGACY BRANCH IS FOR THE UNNAMED SLOT AND NOTHING ELSE. Every composition
|
|
56
|
+
* saved before slots were nodes put the merchant's blocks directly into
|
|
57
|
+
* `block.blocks`, and the only slot that could have held them is the single
|
|
58
|
+
* unnamed one — a named slot was refused at define time, so it can have no
|
|
59
|
+
* legacy content to find. Slot nodes are filtered out of that branch so a block
|
|
60
|
+
* re-saved with nodes does not render another slot's content here as well.
|
|
61
|
+
*/
|
|
62
|
+
export const resolveSlotChildren = (
|
|
63
|
+
block: Block,
|
|
64
|
+
name: string
|
|
65
|
+
): Block[] | null => {
|
|
66
|
+
const children = block.blocks ?? [];
|
|
67
|
+
|
|
68
|
+
const node = children.find(
|
|
69
|
+
(child) => isSlotNode(child) && slotNodeName(child) === name
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
if (node) return node.blocks ?? [];
|
|
73
|
+
|
|
74
|
+
if (name === '') return children.filter((child) => !isSlotNode(child));
|
|
75
|
+
|
|
76
|
+
return null;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** The reason a slot name is refused, or null. Shared by every door. */
|
|
80
|
+
export const slotNameError = (name: unknown): string | null => {
|
|
81
|
+
if (name === '') return null;
|
|
82
|
+
|
|
83
|
+
if (typeof name !== 'string' || !SLOT_NAME_PATTERN.test(name)) {
|
|
84
|
+
return (
|
|
85
|
+
`slot name ${JSON.stringify(name)} is not valid. Use a lowercase name ` +
|
|
86
|
+
`starting with a letter (letters, digits and hyphens, max 32 characters), or omit the ` +
|
|
87
|
+
`name entirely on a block that opens a single region — the name is matched literally ` +
|
|
88
|
+
`against the slot node the editor created`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return null;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Which slot a bare `<ThemeSlot />` means, or an explanation of why it is
|
|
97
|
+
* ambiguous.
|
|
98
|
+
*
|
|
99
|
+
* The single-slot spelling has to keep working untouched, so a block that
|
|
100
|
+
* declares one slot resolves to it WHATEVER it is called — that is also what
|
|
101
|
+
* lets a developer name their only slot without having to repeat the name at
|
|
102
|
+
* the usage. A block that declares an unnamed slot alongside named ones has an
|
|
103
|
+
* unambiguous answer too. Only several slots that are all named leave nothing
|
|
104
|
+
* to resolve, and then the message has to say what the alternatives are,
|
|
105
|
+
* because the developer's next edit is typing one of them.
|
|
106
|
+
*/
|
|
107
|
+
export const resolveUnnamedSlot = (
|
|
108
|
+
declaredNames: readonly string[]
|
|
109
|
+
): { name: string } | { error: string } => {
|
|
110
|
+
if (declaredNames.length <= 1) return { name: declaredNames[0] ?? '' };
|
|
111
|
+
|
|
112
|
+
if (declaredNames.includes('')) return { name: '' };
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
error:
|
|
116
|
+
`a bare <ThemeSlot /> cannot say which of the ${declaredNames.length} declared slots it opens. ` +
|
|
117
|
+
`Name it: ${declaredNames.map((slot) => `<ThemeSlot name="${slot}" />`).join(', ')}`
|
|
118
|
+
};
|
|
119
|
+
};
|