@akinon/pz-theme 2.0.78-rc.1 → 2.0.78

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +12 -39
  2. package/package.json +4 -4
  3. package/src/__tests__/custom-catalog-announce.test.ts +73 -0
  4. package/src/chrome/__tests__/chrome-mode.test.ts +174 -0
  5. package/src/chrome/chrome-mode.ts +61 -0
  6. package/src/custom/README-sections.md +289 -0
  7. package/src/custom/README.md +7 -0
  8. package/src/custom/__tests__/custom-section-bindings.test.tsx +327 -0
  9. package/src/custom/__tests__/define-section.test.tsx +481 -0
  10. package/src/custom/__tests__/module-boundaries.test.ts +2 -0
  11. package/src/custom/__tests__/public-api.test.tsx +123 -0
  12. package/src/custom/__tests__/region-node-wire.test.tsx +302 -0
  13. package/src/custom/__tests__/region-nodes.test.ts +161 -0
  14. package/src/custom/__tests__/theme-editable.test.tsx +22 -10
  15. package/src/custom/__tests__/theme-region.test.tsx +331 -0
  16. package/src/custom/__tests__/theme-slot.test.tsx +18 -10
  17. package/src/custom/__tests__/use-theme-setting.test.tsx +17 -9
  18. package/src/custom/block-context.ts +53 -10
  19. package/src/custom/block-host.tsx +13 -1
  20. package/src/custom/collect.ts +26 -5
  21. package/src/custom/define-section.tsx +163 -0
  22. package/src/custom/index.ts +83 -1
  23. package/src/custom/region-nodes.ts +72 -0
  24. package/src/custom/register.ts +419 -48
  25. package/src/custom/section-catalog.ts +98 -0
  26. package/src/custom/section-definitions.ts +59 -0
  27. package/src/custom/section-host.tsx +125 -0
  28. package/src/custom/section-types.ts +158 -0
  29. package/src/custom/theme-editable.tsx +3 -1
  30. package/src/custom/theme-region.tsx +213 -0
  31. package/src/custom/theme-slot.tsx +8 -7
  32. package/src/custom/types.ts +10 -1
  33. package/src/custom/use-theme-setting.ts +6 -4
  34. package/src/hooks/use-designer-features.tsx +41 -0
  35. package/src/index.ts +25 -1
  36. package/src/placeholder-registry.ts +34 -5
  37. package/src/sections/__tests__/custom-renderer-section-types.test.ts +29 -0
  38. package/src/sections/__tests__/section-part-selection.test.tsx +277 -0
  39. package/src/sections/custom-renderer-section-types.ts +19 -0
  40. package/src/sections/section-wrapper.tsx +12 -1
  41. package/src/theme-section.tsx +162 -3
  42. package/src/utils/__tests__/docked-state-styles.test.ts +249 -0
  43. package/src/utils/__tests__/section-content-width.test.ts +28 -0
  44. package/src/utils/__tests__/section-max-width.test.ts +53 -0
  45. package/src/utils/index.ts +31 -3
  46. package/src/utils/part-styles.ts +17 -1
@@ -36,6 +36,27 @@ import {
36
36
  getCustomBlockTypes,
37
37
  setCatalogVendor
38
38
  } from './catalog';
39
+ import {
40
+ addSectionToCatalog,
41
+ getCustomSectionDescriptor,
42
+ getCustomSectionTypes
43
+ } from './section-catalog';
44
+ import {
45
+ addSectionDefinition,
46
+ hasCustomSectionDefinition
47
+ } from './section-definitions';
48
+ import { normalizeCustomSection } from './define-section';
49
+ import type { CustomSectionDefinition } from './define-section';
50
+ import type {
51
+ CustomRegionDeclaration,
52
+ CustomSectionInput,
53
+ CustomSectionOptions
54
+ } from './define-section';
55
+ import { CUSTOM_SECTION_LIMITS, regionNameError } from './section-types';
56
+ import type {
57
+ CustomRegionDescriptor,
58
+ CustomSectionDescriptor
59
+ } from './section-types';
39
60
  import {
40
61
  normalizeSettingChoices,
41
62
  settingKeyError,
@@ -120,14 +141,19 @@ const firstDivergentPath = (
120
141
  return null;
121
142
  };
122
143
 
123
- const assertSerializable = (descriptor: CustomBlockDescriptor): void => {
144
+ /**
145
+ * Shared by both doors: the round trip is the actual transport either way, and
146
+ * a section descriptor carries more hand-written data than a block's (a
147
+ * region's seeded properties and styles), so it needs the check more.
148
+ */
149
+ const assertSerializable = (descriptor: { type: string }): void => {
124
150
  let roundTripped: unknown;
125
151
 
126
152
  try {
127
153
  roundTripped = JSON.parse(JSON.stringify(descriptor));
128
154
  } catch (error) {
129
155
  throw new Error(
130
- `[pz-theme] Custom block "${descriptor.type}" has a descriptor that cannot be serialized ` +
156
+ `[pz-theme] Custom unit "${descriptor.type}" has a descriptor that cannot be serialized ` +
131
157
  `(${String(
132
158
  error
133
159
  )}). The catalog is posted to the editor, so every declared value has to survive JSON.`
@@ -137,7 +163,7 @@ const assertSerializable = (descriptor: CustomBlockDescriptor): void => {
137
163
  const divergent = firstDivergentPath(descriptor, roundTripped, 'descriptor');
138
164
  if (divergent) {
139
165
  throw new Error(
140
- `[pz-theme] Custom block "${descriptor.type}" declares a value at ${divergent} that does not survive JSON. ` +
166
+ `[pz-theme] Custom unit "${descriptor.type}" declares a value at ${divergent} that does not survive JSON. ` +
141
167
  `The catalog crosses postMessage, which drops functions, symbols and undefined without a word — ` +
142
168
  `the editor would receive the descriptor with that key missing and no error anywhere.`
143
169
  );
@@ -311,29 +337,36 @@ const bindHost = (
311
337
  return Host;
312
338
  };
313
339
 
314
- const buildDescriptor = (
340
+ /**
341
+ * The settings half of a descriptor, shared by both doors.
342
+ *
343
+ * A second copy of this loop would drift within a release: the two doors have
344
+ * to refuse the same duplicate key, apply the same cap and derive the same
345
+ * `field-<key>` parts, and a section whose settings behaved differently from a
346
+ * block's would be a difference nobody could explain from the authoring API.
347
+ */
348
+ const buildSettings = (
349
+ unit: string,
315
350
  type: string,
316
- name: string,
317
- options: CustomBlockOptions
318
- ): CustomBlockDescriptor => {
319
- const declaredSettings = options.settings ?? [];
320
-
321
- if (declaredSettings.length > CUSTOM_LIMITS.settingsPerType) {
351
+ declared: readonly CustomSettingDeclaration[],
352
+ cap: number
353
+ ): { settings: CustomSettingDescriptor[]; parts: string[] } => {
354
+ if (declared.length > cap) {
322
355
  throw new Error(
323
- `[pz-theme] Custom block "${type}" declares ${declaredSettings.length} settings, over the ` +
324
- `${CUSTOM_LIMITS.settingsPerType} cap. The whole catalog is posted to the editor on every ` +
325
- `placeholder announce; split the block.`
356
+ `[pz-theme] Custom ${unit} "${type}" declares ${declared.length} settings, over the ` +
357
+ `${cap} cap. The whole catalog is posted to the editor on every ` +
358
+ `placeholder announce; split it.`
326
359
  );
327
360
  }
328
361
 
329
362
  const settings: CustomSettingDescriptor[] = [];
330
363
  const parts: string[] = [];
331
364
 
332
- for (const declaration of declaredSettings) {
365
+ for (const declaration of declared) {
333
366
  if (settings.some((setting) => setting.key === declaration.key)) {
334
367
  throw new Error(
335
- `[pz-theme] Custom block "${type}" declares the setting key "${declaration.key}" twice. ` +
336
- `Both write to block.properties.${declaration.key}, so one control would silently overwrite the other.`
368
+ `[pz-theme] Custom ${unit} "${type}" declares the setting key "${declaration.key}" twice. ` +
369
+ `Both write to properties.${declaration.key}, so one control would silently overwrite the other.`
337
370
  );
338
371
  }
339
372
 
@@ -345,40 +378,33 @@ const buildDescriptor = (
345
378
  if (setting.editable) parts.push(fieldPartName(setting.key));
346
379
  }
347
380
 
348
- const declaredSlots = options.slots ?? [];
349
-
350
- // The phase-1 twin of the cap the editor also applies: it drops the whole
351
- // catalog over the limit, so a build that exceeds it would lose every block
352
- // in the palette with only the receiving side able to say why.
353
- if (declaredSlots.length > CUSTOM_LIMITS.slotsPerType) {
354
- throw new Error(
355
- `[pz-theme] Custom block "${type}" declares ${declaredSlots.length} slots, over the ` +
356
- `${CUSTOM_LIMITS.slotsPerType} cap. The whole catalog is posted to the editor on every ` +
357
- `placeholder announce; split the block.`
358
- );
359
- }
360
-
361
- const slots: CustomSlotDescriptor[] = [];
362
- for (const declaration of declaredSlots) {
363
- slots.push(buildSlot(type, declaration, slots));
364
- }
381
+ return { settings, parts };
382
+ };
365
383
 
366
- // Declared parts join the field-derived ones in the SAME list, after them, so
367
- // the wire shape stays one array of names — see `CustomBlockDescriptor.parts`
368
- // for why that list must not become a list of objects.
384
+ /**
385
+ * Appends the developer-declared parts to the field-derived ones already in
386
+ * `parts`, and returns the states each declared. Shared by both doors.
387
+ */
388
+ const buildParts = (
389
+ unit: string,
390
+ type: string,
391
+ declaration: CustomBlockOptions['parts'],
392
+ parts: string[],
393
+ cap: number
394
+ ): Record<string, string[]> => {
369
395
  const partStates: Record<string, string[]> = {};
370
396
 
371
- for (const declared of normalizePartsDeclaration(options.parts)) {
397
+ for (const declared of normalizePartsDeclaration(declaration)) {
372
398
  const nameError = partNameError(declared.name);
373
399
  if (nameError) {
374
400
  throw new Error(
375
- `[pz-theme] Custom block "${type}" declares a part: ${nameError}.`
401
+ `[pz-theme] Custom ${unit} "${type}" declares a part: ${nameError}.`
376
402
  );
377
403
  }
378
404
 
379
405
  if (parts.includes(declared.name)) {
380
406
  throw new Error(
381
- `[pz-theme] Custom block "${type}" declares the part "${declared.name}" twice. ` +
407
+ `[pz-theme] Custom ${unit} "${type}" declares the part "${declared.name}" twice. ` +
382
408
  `The editor builds one control group per advertised part, so the second group would write ` +
383
409
  `the same style keys as the first with nothing to say which won.`
384
410
  );
@@ -388,7 +414,7 @@ const buildDescriptor = (
388
414
  const stateError = partStateError(state);
389
415
  if (stateError) {
390
416
  throw new Error(
391
- `[pz-theme] Custom block "${type}" declares part "${declared.name}": ${stateError}.`
417
+ `[pz-theme] Custom ${unit} "${type}" declares part "${declared.name}": ${stateError}.`
392
418
  );
393
419
  }
394
420
  }
@@ -397,14 +423,58 @@ const buildDescriptor = (
397
423
  if (declared.states.length > 0) partStates[declared.name] = declared.states;
398
424
  }
399
425
 
400
- if (parts.length > CUSTOM_LIMITS.partsPerType) {
426
+ if (parts.length > cap) {
401
427
  throw new Error(
402
- `[pz-theme] Custom block "${type}" advertises ${parts.length} parts, over the ` +
403
- `${CUSTOM_LIMITS.partsPerType} cap. The editor drops the whole catalog above it, so every ` +
404
- `block in this build would disappear from the palette; declare fewer parts.`
428
+ `[pz-theme] Custom ${unit} "${type}" advertises ${parts.length} parts, over the ` +
429
+ `${cap} cap. The editor drops the whole catalog above it, so everything in this build ` +
430
+ `would disappear from the palette; declare fewer parts.`
405
431
  );
406
432
  }
407
433
 
434
+ return partStates;
435
+ };
436
+
437
+ const buildDescriptor = (
438
+ type: string,
439
+ name: string,
440
+ options: CustomBlockOptions
441
+ ): CustomBlockDescriptor => {
442
+ const { settings, parts } = buildSettings(
443
+ 'block',
444
+ type,
445
+ options.settings ?? [],
446
+ CUSTOM_LIMITS.settingsPerType
447
+ );
448
+
449
+ const declaredSlots = options.slots ?? [];
450
+
451
+ // The phase-1 twin of the cap the editor also applies: it drops the whole
452
+ // catalog over the limit, so a build that exceeds it would lose every block
453
+ // in the palette with only the receiving side able to say why.
454
+ if (declaredSlots.length > CUSTOM_LIMITS.slotsPerType) {
455
+ throw new Error(
456
+ `[pz-theme] Custom block "${type}" declares ${declaredSlots.length} slots, over the ` +
457
+ `${CUSTOM_LIMITS.slotsPerType} cap. The whole catalog is posted to the editor on every ` +
458
+ `placeholder announce; split the block.`
459
+ );
460
+ }
461
+
462
+ const slots: CustomSlotDescriptor[] = [];
463
+ for (const declaration of declaredSlots) {
464
+ slots.push(buildSlot(type, declaration, slots));
465
+ }
466
+
467
+ // Declared parts join the field-derived ones in the SAME list, after them, so
468
+ // the wire shape stays one array of names — see `CustomBlockDescriptor.parts`
469
+ // for why that list must not become a list of objects.
470
+ const partStates = buildParts(
471
+ 'block',
472
+ type,
473
+ options.parts,
474
+ parts,
475
+ CUSTOM_LIMITS.partsPerType
476
+ );
477
+
408
478
  return withoutUndefined<CustomBlockDescriptor>({
409
479
  type,
410
480
  name,
@@ -425,6 +495,183 @@ const buildDescriptor = (
425
495
  });
426
496
  };
427
497
 
498
+ const isPlainRecord = (value: unknown): value is Record<string, unknown> =>
499
+ typeof value === 'object' && value !== null && !Array.isArray(value);
500
+
501
+ /**
502
+ * One region, refusing everything the editor would refuse the whole catalog
503
+ * over — and one thing the editor cannot see at all.
504
+ *
505
+ * `block` is the type the region is born as, and it is NOT validated against a
506
+ * registry here. It cannot be: `blockRendererRegistry` holds the built-ins this
507
+ * package ships, but a region may legitimately be born as another of the same
508
+ * brand's custom blocks, whose barrel may not have evaluated yet. The editor
509
+ * resolves it through `getBlockConfig`, which composes the built-ins with the
510
+ * brand's own catalog, and answers with an honest unknown-type card if it truly
511
+ * does not exist. What IS checked here is that the value is a usable type
512
+ * string at all, because an empty one seeds a block the merchant can neither
513
+ * see nor delete.
514
+ */
515
+ const buildRegion = (
516
+ type: string,
517
+ declaration: CustomRegionDeclaration,
518
+ alreadyDeclared: readonly CustomRegionDescriptor[]
519
+ ): CustomRegionDescriptor => {
520
+ const nameError = regionNameError(declaration.name);
521
+ if (nameError) {
522
+ throw new Error(
523
+ `[pz-theme] Custom section "${type}" declares a region: ${nameError}.`
524
+ );
525
+ }
526
+
527
+ if (alreadyDeclared.some((region) => region.name === declaration.name)) {
528
+ throw new Error(
529
+ `[pz-theme] Custom section "${type}" declares the region "${declaration.name}" twice. ` +
530
+ `A region is addressed by its name, so both <ThemeRegion> placements would resolve to the ` +
531
+ `same seeded block and it would render twice — rename one of them.`
532
+ );
533
+ }
534
+
535
+ if (typeof declaration.block !== 'string' || declaration.block === '') {
536
+ throw new Error(
537
+ `[pz-theme] Custom section "${type}" declares the region "${declaration.name}" with no block ` +
538
+ `type. Name the block it is born as — "search-box", "image", "mini-basket" — which is what ` +
539
+ `gives the merchant that block's own panel and part groups.`
540
+ );
541
+ }
542
+
543
+ for (const [field, value] of [
544
+ ['properties', declaration.properties],
545
+ ['styles', declaration.styles]
546
+ ] as const) {
547
+ if (value !== undefined && !isPlainRecord(value)) {
548
+ throw new Error(
549
+ `[pz-theme] Custom section "${type}" declares the region "${declaration.name}" with a ` +
550
+ `${field} value that is not a plain object. The seed is written straight into the ` +
551
+ `composition, so it has to be the shape the composition stores.`
552
+ );
553
+ }
554
+ }
555
+
556
+ return withoutUndefined<CustomRegionDescriptor>({
557
+ name: declaration.name,
558
+ label: declaration.label,
559
+ block: declaration.block,
560
+ properties: declaration.properties,
561
+ styles: declaration.styles,
562
+ // Unvalidated beyond `assertSerializable` on the whole descriptor: a
563
+ // block's `value` is that block type's own shape — a string for an image, a
564
+ // record for a slider — and this module knows none of them. What it does
565
+ // guarantee is that whatever is written here survives JSON, which is the
566
+ // only property the transport needs.
567
+ value: declaration.value
568
+ });
569
+ };
570
+
571
+ const buildSectionDescriptor = (
572
+ type: string,
573
+ name: string,
574
+ options: CustomSectionOptions
575
+ ): CustomSectionDescriptor => {
576
+ const { settings, parts } = buildSettings(
577
+ 'section',
578
+ type,
579
+ options.settings ?? [],
580
+ CUSTOM_SECTION_LIMITS.settingsPerType
581
+ );
582
+
583
+ const declaredRegions = options.regions ?? [];
584
+
585
+ if (declaredRegions.length > CUSTOM_SECTION_LIMITS.regionsPerType) {
586
+ throw new Error(
587
+ `[pz-theme] Custom section "${type}" declares ${declaredRegions.length} regions, over the ` +
588
+ `${CUSTOM_SECTION_LIMITS.regionsPerType} cap. Every region is seeded into the composition ` +
589
+ `when the merchant adds the section; split the section.`
590
+ );
591
+ }
592
+
593
+ const regions: CustomRegionDescriptor[] = [];
594
+ for (const declaration of declaredRegions) {
595
+ regions.push(buildRegion(type, declaration, regions));
596
+ }
597
+
598
+ const declaredSlots = options.slots ?? [];
599
+
600
+ if (declaredSlots.length > CUSTOM_SECTION_LIMITS.slotsPerType) {
601
+ throw new Error(
602
+ `[pz-theme] Custom section "${type}" declares ${declaredSlots.length} slots, over the ` +
603
+ `${CUSTOM_SECTION_LIMITS.slotsPerType} cap. The whole catalog is posted to the editor on ` +
604
+ `every placeholder announce; split the section.`
605
+ );
606
+ }
607
+
608
+ const slots: CustomSlotDescriptor[] = [];
609
+ for (const declaration of declaredSlots) {
610
+ slots.push(buildSlot(type, declaration, slots));
611
+ }
612
+
613
+ // A region and a slot are two different promises to the merchant, but they
614
+ // both become a row in one tree under one section. Two rows with one name is
615
+ // ambiguous in the UI even though the resolvers never collide, so it is
616
+ // refused where the message can name both.
617
+ for (const region of regions) {
618
+ if (slots.some((slot) => slot.name === region.name)) {
619
+ throw new Error(
620
+ `[pz-theme] Custom section "${type}" declares "${region.name}" as both a region and a slot. ` +
621
+ `They would appear as two rows with one name in the layer tree — rename one of them.`
622
+ );
623
+ }
624
+ }
625
+
626
+ const partStates = buildParts(
627
+ 'section',
628
+ type,
629
+ options.parts,
630
+ parts,
631
+ CUSTOM_SECTION_LIMITS.partsPerType
632
+ );
633
+
634
+ if (options.styles !== undefined && !isPlainRecord(options.styles)) {
635
+ throw new Error(
636
+ `[pz-theme] Custom section "${type}" declares styles that are not a plain object. The seed is ` +
637
+ `written straight into the composition, so it has to be the shape the composition stores.`
638
+ );
639
+ }
640
+
641
+ if (
642
+ options.placeholders !== undefined &&
643
+ (!Array.isArray(options.placeholders) ||
644
+ options.placeholders.some(
645
+ (slug) => typeof slug !== 'string' || slug === ''
646
+ ))
647
+ ) {
648
+ throw new Error(
649
+ `[pz-theme] Custom section "${type}" declares a malformed placeholders list. Every entry has ` +
650
+ `to be a non-empty placeholder slug such as "header" — omit it to offer the section ` +
651
+ `everywhere.`
652
+ );
653
+ }
654
+
655
+ return withoutUndefined<CustomSectionDescriptor>({
656
+ type,
657
+ name,
658
+ label: options.label ?? name,
659
+ icon: options.icon,
660
+ group: options.group,
661
+ // A section always hosts children — its regions, its slots, or both — and
662
+ // the editor reads the same field off its own registry entry.
663
+ hasChildren: true,
664
+ settings,
665
+ regions,
666
+ slots,
667
+ parts,
668
+ partStates: Object.keys(partStates).length > 0 ? partStates : undefined,
669
+ styles: options.styles,
670
+ placeholders: options.placeholders,
671
+ lockRootTransforms: options.lockRootTransforms
672
+ });
673
+ };
674
+
428
675
  /**
429
676
  * Registers a barrel of developer-authored blocks under one vendor. The one
430
677
  * entry point a brand calls — everything else in `src/custom` is consumed by
@@ -490,14 +737,24 @@ export const registerCustomBlocks = (
490
737
  // `blocks/block-renderer-registry.tsx` is a bare `Map.set`, so a second
491
738
  // claim on a type would silently render the later component everywhere the
492
739
  // earlier one was placed, with no signal on either side.
740
+ //
741
+ // ALL FOUR STORES, because blocks and sections share one type namespace.
742
+ // Checking only the block half here made the guard one-directional: a
743
+ // section registered first, then a block of the same type, was accepted
744
+ // and left `custom:acme:header` present in every store at once — after
745
+ // which the editor resolves it as a block or a section depending on which
746
+ // lookup ran first, which is the failure the section door's own comment
747
+ // describes and this one has to refuse identically.
493
748
  if (
494
749
  blockRendererRegistry.hasRenderer(type) ||
495
- getCustomBlockDescriptor(type)
750
+ getCustomBlockDescriptor(type) ||
751
+ hasCustomSectionDefinition(type) ||
752
+ getCustomSectionDescriptor(type)
496
753
  ) {
497
754
  throw new Error(
498
- `[pz-theme] Custom block "${type}" is already registered in this build. ` +
499
- `Registration refuses to overwrite it. Rename one of them, or check whether the barrel is ` +
500
- `being registered twice.`
755
+ `[pz-theme] "${type}" is already registered in this build, as a block or as a section. ` +
756
+ `Blocks and sections share one type namespace, so registration refuses to overwrite it. ` +
757
+ `Rename one of them, or check whether the barrel is being registered twice.`
501
758
  );
502
759
  }
503
760
 
@@ -545,3 +802,117 @@ export const registerCustomBlocks = (
545
802
  // document never mounts a placeholder.
546
803
  announceCustomBlocks();
547
804
  };
805
+
806
+
807
+ /**
808
+ * Registers a barrel of developer-authored SECTIONS under one vendor.
809
+ *
810
+ * The section twin of `registerCustomBlocks`, and everything the file header
811
+ * says about that function applies here unchanged: import the barrel that calls
812
+ * this from the CLIENT ROOT ONLY, and the whole record is validated before
813
+ * anything is mutated.
814
+ *
815
+ * ONE TYPE NAMESPACE, TWO STORES. A `custom:<vendor>:<name>` may be claimed by
816
+ * a block or by a section and never by both — the check below consults all four
817
+ * stores. They share the grammar, so nothing downstream can tell the two apart
818
+ * from the string alone: the editor resolves a section type through its section
819
+ * catalog and a block type through its block catalog, and a type present in
820
+ * both would resolve differently depending on which lookup ran, which is the
821
+ * kind of bug that only shows up in one composition on one page.
822
+ */
823
+ export const registerCustomSections = (
824
+ vendor: string,
825
+ record: Record<string, CustomSectionInput>
826
+ ): void => {
827
+ if (!CUSTOM_SEGMENT_PATTERN.test(vendor)) {
828
+ throw new Error(
829
+ `[pz-theme] registerCustomSections() vendor ${JSON.stringify(
830
+ vendor
831
+ )} is not a valid segment. ` +
832
+ `Use lowercase letters, digits and hyphens, starting with a letter or digit (max 48 characters) — ` +
833
+ `it becomes the middle segment of every type this call registers.`
834
+ );
835
+ }
836
+
837
+ const pending: Array<{
838
+ type: string;
839
+ descriptor: CustomSectionDescriptor;
840
+ definition: CustomSectionDefinition;
841
+ }> = [];
842
+ const claimed = new Set<string>();
843
+
844
+ for (const [name, input] of Object.entries(record ?? {})) {
845
+ if (!CUSTOM_SEGMENT_PATTERN.test(name)) {
846
+ throw new Error(
847
+ `[pz-theme] Custom section name ${JSON.stringify(
848
+ name
849
+ )} is not a valid segment. ` +
850
+ `Use lowercase letters, digits and hyphens, starting with a letter or digit (max 48 characters) — ` +
851
+ `the name is the published type and cannot be changed once a composition references it.`
852
+ );
853
+ }
854
+
855
+ const type = `custom:${vendor}:${name}`;
856
+
857
+ if (claimed.has(type)) {
858
+ throw new Error(
859
+ `[pz-theme] Custom section "${type}" appears twice in one registerCustomSections() call. ` +
860
+ `Rename one of them — the second would replace the first everywhere it is already placed.`
861
+ );
862
+ }
863
+
864
+ // All four stores, because the two namespaces are one. A type already
865
+ // claimed by a BLOCK is refused here with a message that says so, rather
866
+ // than registering a section the editor would then resolve as a block.
867
+ if (
868
+ hasCustomSectionDefinition(type) ||
869
+ getCustomSectionDescriptor(type) ||
870
+ blockRendererRegistry.hasRenderer(type) ||
871
+ getCustomBlockDescriptor(type)
872
+ ) {
873
+ throw new Error(
874
+ `[pz-theme] "${type}" is already registered in this build, as a block or as a section. ` +
875
+ `Blocks and sections share one type namespace, so registration refuses to overwrite it. ` +
876
+ `Rename one of them, or check whether the barrel is being registered twice.`
877
+ );
878
+ }
879
+
880
+ const definition = normalizeCustomSection(input, name);
881
+ const descriptor = buildSectionDescriptor(type, name, definition.options);
882
+ assertSerializable(descriptor);
883
+
884
+ claimed.add(type);
885
+ pending.push({ type, descriptor, definition });
886
+ }
887
+
888
+ const total = getCustomSectionTypes().length + pending.length;
889
+ if (total > CUSTOM_SECTION_LIMITS.sectionTypesPerBuild) {
890
+ throw new Error(
891
+ `[pz-theme] This build would declare ${total} custom sections, over the ` +
892
+ `${CUSTOM_SECTION_LIMITS.sectionTypesPerBuild} cap. The cap exists because the whole catalog ` +
893
+ `is posted to the editor on every placeholder announce; split the build or retire unused sections.`
894
+ );
895
+ }
896
+
897
+ const previousVendor = getCatalogVendor();
898
+ if (previousVendor && previousVendor !== vendor) {
899
+ devWarn(
900
+ 'customSection',
901
+ `Catalog vendor changed from "${previousVendor}" to "${vendor}". The catalog carries ONE vendor ` +
902
+ `field, so everything already registered is announced under the new name — register one vendor per build.`
903
+ );
904
+ }
905
+
906
+ for (const entry of pending) {
907
+ addSectionToCatalog(entry.descriptor);
908
+ addSectionDefinition(entry.type, entry.definition);
909
+ }
910
+
911
+ setCatalogVendor(vendor);
912
+
913
+ // The same re-arm the block door uses, and the same reason: this runs at
914
+ // module evaluation, long before any placeholder has mounted, so it only
915
+ // schedules the announce that will eventually carry the slugs and both
916
+ // catalogs in one frame.
917
+ announceCustomBlocks();
918
+ };
@@ -0,0 +1,98 @@
1
+ import { devWarn } from '../utils/dev-warn';
2
+ import { CUSTOM_SECTION_LIMITS } from './section-types';
3
+ import type {
4
+ CustomSectionCatalog,
5
+ CustomSectionDescriptor
6
+ } from './section-types';
7
+
8
+ /**
9
+ * The serializable half of every registered custom section, for the editor.
10
+ *
11
+ * A SECOND map beside the block catalog rather than a `sections` key inside it.
12
+ * The editor validates the block catalog element by element and drops the whole
13
+ * thing on an unexpected shape, so nesting sections inside `CustomCatalog`
14
+ * would make every block in a build vanish from the palette of any editor older
15
+ * than this change. Sections ride as a sibling field instead — see
16
+ * `placeholder-registry.ts` — and get their own byte budget with it.
17
+ *
18
+ * The vendor string is shared with the block catalog through `catalog.ts`, not
19
+ * duplicated: a build has one vendor, and two sources for it is one more place
20
+ * for them to disagree.
21
+ *
22
+ * Directive-free, like `catalog.ts` and for the same reason:
23
+ * `placeholder-registry.ts` calls `getCustomSectionCatalog()` without a
24
+ * directive of its own, and a `'use client'` here would turn that call into a
25
+ * client reference that throws on the server side.
26
+ */
27
+ const sectionCatalog = new Map<string, CustomSectionDescriptor>();
28
+
29
+ /**
30
+ * Adds a descriptor, refusing rather than overwriting.
31
+ *
32
+ * Last-wins would be silent in the same way it is for blocks: nothing in the
33
+ * render path would report that two sections claimed one type, and every
34
+ * placement of the first would start rendering the second.
35
+ */
36
+ export const addSectionToCatalog = (descriptor: CustomSectionDescriptor) => {
37
+ if (sectionCatalog.has(descriptor.type)) {
38
+ throw new Error(
39
+ `[pz-theme] Custom section "${descriptor.type}" is defined twice in this build. ` +
40
+ `Type names must be unique — rename one of them.`
41
+ );
42
+ }
43
+
44
+ if (sectionCatalog.size >= CUSTOM_SECTION_LIMITS.sectionTypesPerBuild) {
45
+ throw new Error(
46
+ `[pz-theme] This build declares more than ${CUSTOM_SECTION_LIMITS.sectionTypesPerBuild} custom ` +
47
+ `sections. The cap exists because the whole catalog is posted to the editor on every ` +
48
+ `placeholder announce; split the build or retire unused sections.`
49
+ );
50
+ }
51
+
52
+ sectionCatalog.set(descriptor.type, descriptor);
53
+ };
54
+
55
+ export const getCustomSectionDescriptor = (
56
+ type: string
57
+ ): CustomSectionDescriptor | undefined => sectionCatalog.get(type);
58
+
59
+ export const getCustomSectionTypes = (): string[] =>
60
+ Array.from(sectionCatalog.keys());
61
+
62
+ /**
63
+ * The payload the storefront announces. Null when nothing is registered, so the
64
+ * announcer omits the field entirely rather than posting an empty catalog on
65
+ * every placeholder mount.
66
+ */
67
+ export const getCustomSectionCatalog = (
68
+ vendor: string
69
+ ): CustomSectionCatalog | null => {
70
+ if (sectionCatalog.size === 0) return null;
71
+
72
+ const payload: CustomSectionCatalog = {
73
+ vendor,
74
+ sections: Array.from(sectionCatalog.values())
75
+ };
76
+
77
+ // Measured at the source for the same reason the block catalog is: a section
78
+ // descriptor grows with every region's seeded properties and styles, and the
79
+ // editor drops the WHOLE catalog when it arrives over-cap. Failing here names
80
+ // the build that did it; failing at the receiver names nothing, in a repo the
81
+ // developer is not working in.
82
+ const bytes = JSON.stringify(payload).length;
83
+ if (bytes > CUSTOM_SECTION_LIMITS.catalogBytes) {
84
+ devWarn(
85
+ 'customSectionCatalog',
86
+ `Section catalog is ${bytes} bytes, over the ${CUSTOM_SECTION_LIMITS.catalogBytes} byte cap. ` +
87
+ `The editor will reject it and this brand's whole section band will be empty; trim the ` +
88
+ `seeded region properties and styles, which are what a section descriptor is mostly made of.`
89
+ );
90
+ }
91
+
92
+ return payload;
93
+ };
94
+
95
+ /** Test-only. Module state outlives a test file otherwise. */
96
+ export const resetCustomSectionCatalog = () => {
97
+ sectionCatalog.clear();
98
+ };