@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.
@@ -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
+ };