@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.
- package/CHANGELOG.md +12 -39
- package/package.json +4 -4
- package/src/__tests__/custom-catalog-announce.test.ts +73 -0
- package/src/chrome/__tests__/chrome-mode.test.ts +174 -0
- package/src/chrome/chrome-mode.ts +61 -0
- package/src/custom/README-sections.md +289 -0
- package/src/custom/README.md +7 -0
- package/src/custom/__tests__/custom-section-bindings.test.tsx +327 -0
- package/src/custom/__tests__/define-section.test.tsx +481 -0
- package/src/custom/__tests__/module-boundaries.test.ts +2 -0
- package/src/custom/__tests__/public-api.test.tsx +123 -0
- package/src/custom/__tests__/region-node-wire.test.tsx +302 -0
- package/src/custom/__tests__/region-nodes.test.ts +161 -0
- package/src/custom/__tests__/theme-editable.test.tsx +22 -10
- package/src/custom/__tests__/theme-region.test.tsx +331 -0
- package/src/custom/__tests__/theme-slot.test.tsx +18 -10
- package/src/custom/__tests__/use-theme-setting.test.tsx +17 -9
- package/src/custom/block-context.ts +53 -10
- package/src/custom/block-host.tsx +13 -1
- package/src/custom/collect.ts +26 -5
- package/src/custom/define-section.tsx +163 -0
- package/src/custom/index.ts +83 -1
- package/src/custom/region-nodes.ts +72 -0
- package/src/custom/register.ts +419 -48
- package/src/custom/section-catalog.ts +98 -0
- package/src/custom/section-definitions.ts +59 -0
- package/src/custom/section-host.tsx +125 -0
- package/src/custom/section-types.ts +158 -0
- package/src/custom/theme-editable.tsx +3 -1
- package/src/custom/theme-region.tsx +213 -0
- package/src/custom/theme-slot.tsx +8 -7
- package/src/custom/types.ts +10 -1
- package/src/custom/use-theme-setting.ts +6 -4
- package/src/hooks/use-designer-features.tsx +41 -0
- package/src/index.ts +25 -1
- package/src/placeholder-registry.ts +34 -5
- package/src/sections/__tests__/custom-renderer-section-types.test.ts +29 -0
- package/src/sections/__tests__/section-part-selection.test.tsx +277 -0
- package/src/sections/custom-renderer-section-types.ts +19 -0
- package/src/sections/section-wrapper.tsx +12 -1
- package/src/theme-section.tsx +162 -3
- package/src/utils/__tests__/docked-state-styles.test.ts +249 -0
- package/src/utils/__tests__/section-content-width.test.ts +28 -0
- package/src/utils/__tests__/section-max-width.test.ts +53 -0
- package/src/utils/index.ts +31 -3
- package/src/utils/part-styles.ts +17 -1
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { CustomSectionDefinition } from './define-section';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The RENDER half of section registration: which component draws a
|
|
5
|
+
* `custom:<vendor>:<name>` section.
|
|
6
|
+
*
|
|
7
|
+
* A map of its own rather than an entry in `sectionRendererRegistry`, for two
|
|
8
|
+
* measured reasons:
|
|
9
|
+
*
|
|
10
|
+
* 1. `sections/__tests__/custom-renderer-section-types.test.ts` asserts that
|
|
11
|
+
* `CUSTOM_RENDERER_SECTION_TYPES` — a hand-maintained literal the CSS
|
|
12
|
+
* generator reads because it cannot import the registry without closing a
|
|
13
|
+
* module cycle — equals `sectionRendererRegistry.registeredTypes()` exactly.
|
|
14
|
+
* A brand registering into that registry would make `registeredTypes()`
|
|
15
|
+
* non-static and break the lock on every build that loaded a brand barrel.
|
|
16
|
+
* 2. `SectionRendererProps` carries no `pageContext`, and widening it is a
|
|
17
|
+
* package-wide type edit for one consumer. `theme-section.tsx` dispatches to
|
|
18
|
+
* this map directly and hands the host whatever it needs.
|
|
19
|
+
*
|
|
20
|
+
* Directive-free: it stores values and imports nothing that calls a hook, so
|
|
21
|
+
* both graphs can hold a copy. Only the client graph ever fills one, because
|
|
22
|
+
* `registerCustomSections` is client-only for the same reason
|
|
23
|
+
* `registerCustomBlocks` is.
|
|
24
|
+
*/
|
|
25
|
+
const definitions = new Map<string, CustomSectionDefinition>();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Registers the component for a type, refusing rather than overwriting.
|
|
29
|
+
*
|
|
30
|
+
* The refusal is the point. Both stores are checked at registration — this one
|
|
31
|
+
* and the catalog — because a type present in only one of them is a section the
|
|
32
|
+
* editor offers and the storefront cannot draw, or the reverse.
|
|
33
|
+
*/
|
|
34
|
+
export const addSectionDefinition = (
|
|
35
|
+
type: string,
|
|
36
|
+
definition: CustomSectionDefinition
|
|
37
|
+
): void => {
|
|
38
|
+
if (definitions.has(type)) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`[pz-theme] Custom section "${type}" already has a renderer in this build. ` +
|
|
41
|
+
`Registration refuses to overwrite it — the second component would draw every placement ` +
|
|
42
|
+
`of the first with no signal on either side.`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
definitions.set(type, definition);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const getCustomSectionDefinition = (
|
|
50
|
+
type: string
|
|
51
|
+
): CustomSectionDefinition | undefined => definitions.get(type);
|
|
52
|
+
|
|
53
|
+
export const hasCustomSectionDefinition = (type: string): boolean =>
|
|
54
|
+
definitions.has(type);
|
|
55
|
+
|
|
56
|
+
/** Test-only. Module state outlives a test file otherwise. */
|
|
57
|
+
export const resetCustomSectionDefinitions = (): void => {
|
|
58
|
+
definitions.clear();
|
|
59
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useMemo } from 'react';
|
|
4
|
+
import type { Block } from '../theme-block';
|
|
5
|
+
import type { Section } from '../theme-section';
|
|
6
|
+
import { CustomBlockContext } from './block-context';
|
|
7
|
+
import type { CustomBlockContextValue } from './block-context';
|
|
8
|
+
import { CustomBlockErrorBoundary } from './block-host';
|
|
9
|
+
import { finishCollection, getCollectSink, isCollecting } from './collect';
|
|
10
|
+
import type { CustomSectionDefinition } from './define-section';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Adapts a developer SECTION component to the composition.
|
|
14
|
+
*
|
|
15
|
+
* The section twin of `CustomBlockHost`, and deliberately its near-copy: it
|
|
16
|
+
* converts the section node into the same `CustomBlockContext` the block host
|
|
17
|
+
* provides, so `useThemeSetting`, `ThemeEditable` and `ThemeSlot` work
|
|
18
|
+
* identically inside a section without a second implementation of any of them.
|
|
19
|
+
* The one thing only a section carries is `declaredRegionNames`, which
|
|
20
|
+
* `ThemeRegion` reads.
|
|
21
|
+
*
|
|
22
|
+
* IT DRAWS NO BOX OF ITS OWN. `SectionWrapper` in `theme-section.tsx` already
|
|
23
|
+
* supplies the `<section data-section-id>`, the designer outline, the action
|
|
24
|
+
* toolbar and the `data-section-content` element the content-width rule targets
|
|
25
|
+
* — that wrapper is precisely what gives a custom section the full section root
|
|
26
|
+
* panel. A host that drew its own element would put an unstyleable div between
|
|
27
|
+
* the two and quietly break both.
|
|
28
|
+
*/
|
|
29
|
+
export interface CustomSectionHostProps {
|
|
30
|
+
/** The `custom:<vendor>:<name>` this instance renders. */
|
|
31
|
+
type: string;
|
|
32
|
+
/** Picker label, reused by the error box. */
|
|
33
|
+
label: string;
|
|
34
|
+
definition: CustomSectionDefinition;
|
|
35
|
+
section: Section;
|
|
36
|
+
placeholderId: string;
|
|
37
|
+
isDesigner: boolean;
|
|
38
|
+
selectedBlockId?: string | null;
|
|
39
|
+
currentBreakpoint?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const CustomSectionHost = ({
|
|
43
|
+
type,
|
|
44
|
+
label,
|
|
45
|
+
definition,
|
|
46
|
+
section,
|
|
47
|
+
placeholderId,
|
|
48
|
+
isDesigner,
|
|
49
|
+
selectedBlockId,
|
|
50
|
+
currentBreakpoint = 'desktop'
|
|
51
|
+
}: CustomSectionHostProps) => {
|
|
52
|
+
const BrandSection = definition.component;
|
|
53
|
+
|
|
54
|
+
// Read during render, not in the effect: the sink has to be in the context
|
|
55
|
+
// BEFORE the developer component runs, because that is when
|
|
56
|
+
// `useThemeSetting`/`ThemeEditable` report themselves.
|
|
57
|
+
const collecting = isCollecting(type);
|
|
58
|
+
|
|
59
|
+
// Closing on commit rather than on the render call is what makes the
|
|
60
|
+
// StrictMode double render harmless — see `finishCollection`.
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
finishCollection(type);
|
|
63
|
+
}, [type]);
|
|
64
|
+
|
|
65
|
+
// Read off the DEFINITION rather than the catalog descriptor, for the reason
|
|
66
|
+
// the block host spells out: runtime collection appends to the descriptor's
|
|
67
|
+
// slot list as slots render, and a list that grew mid-render would resolve a
|
|
68
|
+
// bare `<ThemeSlot />` onto whichever name happened to render first.
|
|
69
|
+
const declaredSlotNames = useMemo(
|
|
70
|
+
() => (definition.options.slots ?? []).map((slot) => slot.name ?? ''),
|
|
71
|
+
[definition]
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const declaredRegionNames = useMemo(
|
|
75
|
+
() => (definition.options.regions ?? []).map((region) => region.name),
|
|
76
|
+
[definition]
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const value = useMemo<CustomBlockContextValue>(
|
|
80
|
+
() => ({
|
|
81
|
+
node: section,
|
|
82
|
+
scope: 'section',
|
|
83
|
+
// A `Section` carries every field `Block` requires, so the compatibility
|
|
84
|
+
// alias stays type-correct. New readers take `node`.
|
|
85
|
+
block: section as unknown as Block,
|
|
86
|
+
placeholderId,
|
|
87
|
+
// The section IS the parent of its regions, so its own id is the address
|
|
88
|
+
// every editor action for a region child is sent with.
|
|
89
|
+
sectionId: section.id,
|
|
90
|
+
isDesigner,
|
|
91
|
+
currentBreakpoint,
|
|
92
|
+
selectedBlockId,
|
|
93
|
+
declaredSlotNames,
|
|
94
|
+
declaredRegionNames,
|
|
95
|
+
collect: collecting ? getCollectSink(type) : undefined
|
|
96
|
+
}),
|
|
97
|
+
[
|
|
98
|
+
type,
|
|
99
|
+
section,
|
|
100
|
+
placeholderId,
|
|
101
|
+
isDesigner,
|
|
102
|
+
currentBreakpoint,
|
|
103
|
+
selectedBlockId,
|
|
104
|
+
declaredSlotNames,
|
|
105
|
+
declaredRegionNames,
|
|
106
|
+
collecting
|
|
107
|
+
]
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<CustomBlockContext.Provider value={value}>
|
|
112
|
+
<CustomBlockErrorBoundary
|
|
113
|
+
type={type}
|
|
114
|
+
label={label}
|
|
115
|
+
isDesigner={isDesigner}
|
|
116
|
+
>
|
|
117
|
+
<BrandSection />
|
|
118
|
+
</CustomBlockErrorBoundary>
|
|
119
|
+
</CustomBlockContext.Provider>
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// Named for the React devtools tree: without this every custom section on the
|
|
124
|
+
// page appears as the same anonymous arrow function.
|
|
125
|
+
CustomSectionHost.displayName = 'CustomSectionHost';
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CustomSettingDescriptor,
|
|
3
|
+
CustomSlotDescriptor
|
|
4
|
+
} from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The wire grammar for developer-authored SECTIONS.
|
|
8
|
+
*
|
|
9
|
+
* A section reuses the block grammar for its type (`custom:<vendor>:<name>` —
|
|
10
|
+
* `CUSTOM_TYPE_PATTERN` in `types.ts`) because the two namespaces are checked
|
|
11
|
+
* against each other at registration: one type string can be claimed by a block
|
|
12
|
+
* or by a section, never by both. Sharing the pattern is what makes that check
|
|
13
|
+
* a single lookup instead of two spellings that could drift apart.
|
|
14
|
+
*
|
|
15
|
+
* Directive-free, like `types.ts` and for the same reason: `section-catalog.ts`
|
|
16
|
+
* is read from `placeholder-registry.ts`, which carries no directive of its own.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The property carrying a region's name on the block that was seeded for it.
|
|
21
|
+
*
|
|
22
|
+
* The exact mirror of `SLOT_NAME_PROPERTY`, and reserved for exactly the same
|
|
23
|
+
* reason: `block.label` is renameable from the layer tree (`RENAME_BLOCK`), so
|
|
24
|
+
* binding a region to its label would let a merchant detach the region by
|
|
25
|
+
* renaming a row, with nothing anywhere to say why the area went empty.
|
|
26
|
+
*
|
|
27
|
+
* A region is NOT a reserved-type wrapper node. It is an ordinary block of a
|
|
28
|
+
* real type — a `search-box`, an `image`, a `mini-basket` — that happens to
|
|
29
|
+
* carry this property, which is the whole point: the merchant gets that block's
|
|
30
|
+
* own panel, its shared controls and its part groups with no new machinery.
|
|
31
|
+
*/
|
|
32
|
+
export const REGION_NAME_PROPERTY = 'regionName';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A region name. Deliberately the same shape as a slot name: it is never
|
|
36
|
+
* interpolated into a selector, but it must match LITERALLY between the
|
|
37
|
+
* component's `<ThemeRegion name="…">` and the block the editor seeded, across
|
|
38
|
+
* a JSON round trip.
|
|
39
|
+
*/
|
|
40
|
+
export const REGION_NAME_PATTERN = /^[a-z][a-zA-Z0-9-]{0,31}$/;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Caps for the section catalog. Every one is a LITERAL and none is derived
|
|
44
|
+
* from `CUSTOM_LIMITS`, for the reason spelled out there: each is mirrored
|
|
45
|
+
* independently in the editor, and the receiver's answer to an over-cap catalog
|
|
46
|
+
* is to drop all of it. An expression here would let one number move on the
|
|
47
|
+
* sender alone and empty a brand's whole section band.
|
|
48
|
+
*
|
|
49
|
+
* `catalogBytes` is measured over the SECTION catalog on its own. The section
|
|
50
|
+
* payload rides as a sibling field of `customBlocks` rather than inside it
|
|
51
|
+
* (see `placeholder-registry.ts`), so the two budgets are separate on both
|
|
52
|
+
* sides — a brand with a large block palette does not lose its sections, and a
|
|
53
|
+
* single oversized section descriptor does not empty the block palette.
|
|
54
|
+
*/
|
|
55
|
+
export const CUSTOM_SECTION_LIMITS = {
|
|
56
|
+
/**
|
|
57
|
+
* Lower than the block cap on purpose. A section descriptor is the larger of
|
|
58
|
+
* the two — it carries a region list with seeded properties and styles — and
|
|
59
|
+
* a brand needs far fewer of them: sections are pages and chrome regions,
|
|
60
|
+
* blocks are the vocabulary inside them.
|
|
61
|
+
*/
|
|
62
|
+
sectionTypesPerBuild: 16,
|
|
63
|
+
settingsPerType: 24,
|
|
64
|
+
regionsPerType: 24,
|
|
65
|
+
slotsPerType: 8,
|
|
66
|
+
partsPerType: 32,
|
|
67
|
+
catalogBytes: 256 * 1024
|
|
68
|
+
} as const;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* One child the developer named and PLACED in JSX.
|
|
72
|
+
*
|
|
73
|
+
* `block` is the built-in (or already-registered custom) block type the region
|
|
74
|
+
* is born as. The developer writes no descriptor for it: the block registry
|
|
75
|
+
* supplies the panel, the shared controls, the 161 advanced CSS controls and
|
|
76
|
+
* the part groups, exactly as it does for a block the merchant dropped by hand.
|
|
77
|
+
*
|
|
78
|
+
* `properties` and `styles` are the SEED — what the block looks like the moment
|
|
79
|
+
* the merchant adds the section, before anyone has touched it. They are written
|
|
80
|
+
* once, at insert; nothing re-applies them afterwards, so a merchant's edit is
|
|
81
|
+
* never overwritten by a later load.
|
|
82
|
+
*/
|
|
83
|
+
export interface CustomRegionDescriptor {
|
|
84
|
+
name: string;
|
|
85
|
+
label?: string;
|
|
86
|
+
block: string;
|
|
87
|
+
properties?: Record<string, unknown>;
|
|
88
|
+
styles?: Record<string, unknown>;
|
|
89
|
+
/**
|
|
90
|
+
* The block's own `value`, for the block types that keep their content there
|
|
91
|
+
* rather than in `properties` — an `image` reads `block.value` for its src, a
|
|
92
|
+
* `text` reads it for its text. Without this a seeded logo would come up
|
|
93
|
+
* blank and the merchant would have to find and re-upload an asset the brand
|
|
94
|
+
* already ships.
|
|
95
|
+
*
|
|
96
|
+
* Seeded ONCE, at insert, like `properties` and `styles`.
|
|
97
|
+
*/
|
|
98
|
+
value?: unknown;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface CustomSectionDescriptor {
|
|
102
|
+
type: string;
|
|
103
|
+
name: string;
|
|
104
|
+
label: string;
|
|
105
|
+
icon?: string;
|
|
106
|
+
group?: string;
|
|
107
|
+
/**
|
|
108
|
+
* Always true. A section always hosts children — its regions, its slots, or
|
|
109
|
+
* both — and the editor's `SectionRegistryItem` carries the same field, so
|
|
110
|
+
* this maps straight across rather than being re-derived there.
|
|
111
|
+
*/
|
|
112
|
+
hasChildren: boolean;
|
|
113
|
+
settings: CustomSettingDescriptor[];
|
|
114
|
+
regions: CustomRegionDescriptor[];
|
|
115
|
+
slots: CustomSlotDescriptor[];
|
|
116
|
+
/** ONE FLAT LIST OF STRINGS — see `CustomBlockDescriptor.parts`. */
|
|
117
|
+
parts: string[];
|
|
118
|
+
partStates?: Record<string, string[]>;
|
|
119
|
+
/** The section root's styles at insert. Same seed rule as a region's. */
|
|
120
|
+
styles?: Record<string, unknown>;
|
|
121
|
+
/**
|
|
122
|
+
* Placeholder slugs this section is offered on. Absent means anywhere.
|
|
123
|
+
*
|
|
124
|
+
* A brand's header section has no meaning on the basket page, and the editor
|
|
125
|
+
* already has the machinery to scope a catalogue entry
|
|
126
|
+
* (`isTemplateOfferedForPlaceholder`) — this is the developer's handle on it.
|
|
127
|
+
*/
|
|
128
|
+
placeholders?: string[];
|
|
129
|
+
/**
|
|
130
|
+
* Removes `rotate`, `scale`, `translate`, `filter` and `backdrop-filter` from
|
|
131
|
+
* the section root's advanced controls.
|
|
132
|
+
*
|
|
133
|
+
* Not tidiness. Each of those five creates a containing block for a
|
|
134
|
+
* `position: fixed` descendant, so setting any one of them on the root of a
|
|
135
|
+
* fixed header collapses it into the root's own box — measured at 1247px
|
|
136
|
+
* becoming 600px. A section whose design depends on a fixed or sticky child
|
|
137
|
+
* sets this and the trap stops being reachable from the panel.
|
|
138
|
+
*/
|
|
139
|
+
lockRootTransforms?: boolean;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface CustomSectionCatalog {
|
|
143
|
+
vendor: string;
|
|
144
|
+
sections: CustomSectionDescriptor[];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The reason a region name is refused, or null. Shared by every door. */
|
|
148
|
+
export const regionNameError = (name: unknown): string | null => {
|
|
149
|
+
if (typeof name !== 'string' || !REGION_NAME_PATTERN.test(name)) {
|
|
150
|
+
return (
|
|
151
|
+
`region name ${JSON.stringify(name)} is not valid. Use a lowercase name ` +
|
|
152
|
+
`starting with a letter (letters, digits and hyphens, max 32 characters) — the name is ` +
|
|
153
|
+
`matched literally against the block the editor seeded for it`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return null;
|
|
158
|
+
};
|
|
@@ -298,7 +298,9 @@ export function ThemeEditable({
|
|
|
298
298
|
});
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
-
|
|
301
|
+
// `node` rather than `block`: a field authored inside a custom SECTION lives
|
|
302
|
+
// in the section's own properties, which is where the editor writes it.
|
|
303
|
+
const properties = (context.node.properties ?? {}) as Record<
|
|
302
304
|
string,
|
|
303
305
|
unknown
|
|
304
306
|
>;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useId } from 'react';
|
|
4
|
+
import { DesignerPlaceholder } from '../blocks/product-block-utils';
|
|
5
|
+
import { renderComposedChildren } from '../blocks/render-composed-children';
|
|
6
|
+
import { devWarn } from '../utils/dev-warn';
|
|
7
|
+
import { useCustomBlockContext } from './block-context';
|
|
8
|
+
import type { CustomBlockContextValue } from './block-context';
|
|
9
|
+
import { findRegionChild } from './region-nodes';
|
|
10
|
+
import { regionNameError } from './section-types';
|
|
11
|
+
|
|
12
|
+
export interface ThemeRegionProps {
|
|
13
|
+
/**
|
|
14
|
+
* Which declared region this places. Required — there is no unnamed region,
|
|
15
|
+
* because a section's direct children are the merchant's ordinary composed
|
|
16
|
+
* blocks and a nameless fallback would swallow all of them.
|
|
17
|
+
*/
|
|
18
|
+
name: string;
|
|
19
|
+
/** Shown on the empty band in the designer. Defaults to the region name. */
|
|
20
|
+
label?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Which `ThemeRegion` instance owns each NAME in this section instance.
|
|
25
|
+
*
|
|
26
|
+
* Keyed by the context VALUE rather than the section id, for the reasons the
|
|
27
|
+
* slot claim map documents: the scope is one section instance in one render
|
|
28
|
+
* pass, two concurrent server renders cannot see each other's claim, and the
|
|
29
|
+
* entry is collected with the context object instead of leaking.
|
|
30
|
+
*
|
|
31
|
+
* Two `<ThemeRegion name="search" />` in one component is a REAL duplicate —
|
|
32
|
+
* both resolve to the SAME seeded block, so the block would render twice and
|
|
33
|
+
* two `data-block-id` wrappers would answer to one id, which breaks selection
|
|
34
|
+
* on the canvas.
|
|
35
|
+
*/
|
|
36
|
+
const regionOwners = new WeakMap<
|
|
37
|
+
CustomBlockContextValue,
|
|
38
|
+
Map<string, string>
|
|
39
|
+
>();
|
|
40
|
+
|
|
41
|
+
const claimRegion = (
|
|
42
|
+
context: CustomBlockContextValue,
|
|
43
|
+
name: string,
|
|
44
|
+
instanceId: string
|
|
45
|
+
): boolean => {
|
|
46
|
+
const claims = regionOwners.get(context) ?? new Map<string, string>();
|
|
47
|
+
regionOwners.set(context, claims);
|
|
48
|
+
|
|
49
|
+
const owner = claims.get(name);
|
|
50
|
+
if (owner === undefined) {
|
|
51
|
+
claims.set(name, instanceId);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return owner === instanceId;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const releaseRegion = (
|
|
59
|
+
context: CustomBlockContextValue,
|
|
60
|
+
name: string,
|
|
61
|
+
instanceId: string
|
|
62
|
+
): void => {
|
|
63
|
+
const claims = regionOwners.get(context);
|
|
64
|
+
if (claims?.get(name) === instanceId) claims.delete(name);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A child the developer NAMED and PLACED: the block seeded for this region
|
|
69
|
+
* renders exactly here.
|
|
70
|
+
*
|
|
71
|
+
* A REGION IS NOT A SLOT. A slot is an opening the merchant fills with whatever
|
|
72
|
+
* they choose; a region is one block the developer chose the type of, put in a
|
|
73
|
+
* particular place in the markup, and handed to the merchant to style, configure
|
|
74
|
+
* and — if they want — delete. The merchant cannot reorder it or insert beside
|
|
75
|
+
* it: the JSX owns the layout, and no drag in the layer tree can change where
|
|
76
|
+
* this renders.
|
|
77
|
+
*
|
|
78
|
+
* ONE ARRAY ELEMENT THROUGH THE SAME FUNCTION EVERY CONTAINER USES. Passing the
|
|
79
|
+
* seeded block through `renderComposedChildren` is what gives it the visibility
|
|
80
|
+
* filter, all six editor actions, its `data-block-id` wrapper and therefore the
|
|
81
|
+
* CSS the panel writes for it — for free, and identically to a block the
|
|
82
|
+
* merchant dropped by hand. Rendering `<ThemeBlock>` directly here would lose
|
|
83
|
+
* every one of those and each loss would be silent.
|
|
84
|
+
*
|
|
85
|
+
* A MISSING CHILD IS NOT AN ERROR. The merchant may have deleted it, which is an
|
|
86
|
+
* ordinary composition edit and must stay durable — a header whose language
|
|
87
|
+
* switcher was removed does not grow one back on the next load. The designer
|
|
88
|
+
* gets a labelled band so the hole is visible and the region can be re-added;
|
|
89
|
+
* the live storefront renders nothing.
|
|
90
|
+
*/
|
|
91
|
+
export function ThemeRegion({ name, label }: ThemeRegionProps) {
|
|
92
|
+
const context = useCustomBlockContext();
|
|
93
|
+
// Stable for the life of the instance and distinct between two instances,
|
|
94
|
+
// which is what separates "the same region re-rendering" from "a second
|
|
95
|
+
// region claiming the same name".
|
|
96
|
+
const instanceId = useId();
|
|
97
|
+
|
|
98
|
+
const nameError = regionNameError(name);
|
|
99
|
+
const inSection = context?.scope === 'section';
|
|
100
|
+
const claimable = context && inSection && !nameError;
|
|
101
|
+
const ownsRegion = claimable ? claimRegion(context, name, instanceId) : false;
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (!context || !claimable || !ownsRegion) return;
|
|
105
|
+
|
|
106
|
+
// Hand the claim back on unmount so a region behind a condition
|
|
107
|
+
// (`{isWide && <ThemeRegion name="search" />}`) does not lock the section
|
|
108
|
+
// out of that name for the rest of the page's life.
|
|
109
|
+
return () => releaseRegion(context, name, instanceId);
|
|
110
|
+
}, [context, claimable, ownsRegion, name, instanceId]);
|
|
111
|
+
|
|
112
|
+
if (!context) {
|
|
113
|
+
devWarn(
|
|
114
|
+
'ThemeRegion',
|
|
115
|
+
'A ThemeRegion rendered outside a custom section, so there is no ' +
|
|
116
|
+
'composition to read the region from and nothing is rendered. Render ' +
|
|
117
|
+
'this component through a section registered with registerCustomSections().'
|
|
118
|
+
);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const { node, isDesigner } = context;
|
|
123
|
+
|
|
124
|
+
if (!inSection) {
|
|
125
|
+
devWarn(
|
|
126
|
+
'ThemeRegion',
|
|
127
|
+
`A ThemeRegion rendered inside the custom BLOCK "${node.type}". Regions are ` +
|
|
128
|
+
'placed by a custom section and resolve against the section\'s own children; ' +
|
|
129
|
+
'inside a block there is nothing to resolve. Use <ThemeSlot /> for a ' +
|
|
130
|
+
'merchant-fillable opening in a block.'
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
return isDesigner ? (
|
|
134
|
+
<DesignerPlaceholder label="ThemeRegion only works inside a custom section" />
|
|
135
|
+
) : null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (nameError) {
|
|
139
|
+
devWarn(
|
|
140
|
+
'ThemeRegion',
|
|
141
|
+
`The custom section "${node.type}" renders a region that cannot be resolved: ${nameError}.`
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
return isDesigner ? (
|
|
145
|
+
<DesignerPlaceholder label={`Unresolved region — ${nameError}`} />
|
|
146
|
+
) : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!ownsRegion) {
|
|
150
|
+
devWarn(
|
|
151
|
+
'ThemeRegion',
|
|
152
|
+
`The custom section "${node.type}" renders the region "${name}" more than once. ` +
|
|
153
|
+
'Both resolve to the same seeded block, so it would render twice and two wrappers ' +
|
|
154
|
+
'would answer to one block id; the extra region renders nothing.'
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
return isDesigner ? (
|
|
158
|
+
<DesignerPlaceholder
|
|
159
|
+
label={`Duplicate region — "${name}" is already placed in this section`}
|
|
160
|
+
/>
|
|
161
|
+
) : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const declared = context.declaredRegionNames;
|
|
165
|
+
|
|
166
|
+
// A name the section never declared can never have been seeded, so this is a
|
|
167
|
+
// typo rather than a merchant edit — and the two look identical on screen.
|
|
168
|
+
// Warned once per render pass rather than swallowed, because the alternative
|
|
169
|
+
// is a developer staring at an empty band that the editor has no row for.
|
|
170
|
+
if (declared && !declared.includes(name)) {
|
|
171
|
+
devWarn(
|
|
172
|
+
'ThemeRegion',
|
|
173
|
+
`The custom section "${node.type}" places a region "${name}" it never declared, so nothing ` +
|
|
174
|
+
`was ever seeded for it. Add it to the regions list in defineCustomSection(). ` +
|
|
175
|
+
`Declared: ${declared.length > 0 ? declared.join(', ') : '(none)'}.`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const child = findRegionChild(node, name);
|
|
180
|
+
|
|
181
|
+
if (!child) {
|
|
182
|
+
return isDesigner ? (
|
|
183
|
+
<DesignerPlaceholder
|
|
184
|
+
label={`${label ?? name} — removed, add it back from the editor`}
|
|
185
|
+
/>
|
|
186
|
+
) : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const children = renderComposedChildren({
|
|
190
|
+
blocks: [child],
|
|
191
|
+
placeholderId: context.placeholderId,
|
|
192
|
+
// The section IS the parent here, so its own id is the address every editor
|
|
193
|
+
// action for this child is sent with.
|
|
194
|
+
sectionId: context.sectionId,
|
|
195
|
+
isDesigner,
|
|
196
|
+
selectedBlockId: context.selectedBlockId,
|
|
197
|
+
currentBreakpoint: context.currentBreakpoint
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Empty is decided AFTER the pipeline, not from the array length: a region
|
|
201
|
+
// whose block is hidden renders nothing on the storefront and a selectable
|
|
202
|
+
// row in the designer, and both sides have to draw what they actually
|
|
203
|
+
// rendered.
|
|
204
|
+
if (children.length === 0) {
|
|
205
|
+
return isDesigner ? (
|
|
206
|
+
<DesignerPlaceholder label={`${label ?? name} — hidden`} />
|
|
207
|
+
) : null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return <>{children}</>;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export default ThemeRegion;
|
|
@@ -134,21 +134,22 @@ export function ThemeSlot({ label, name }: ThemeSlotProps) {
|
|
|
134
134
|
if (!context) {
|
|
135
135
|
devWarn(
|
|
136
136
|
'ThemeSlot',
|
|
137
|
-
'A ThemeSlot rendered outside a custom block, so there is no
|
|
138
|
-
'to read children from and nothing is rendered. Render this
|
|
139
|
-
'through a
|
|
137
|
+
'A ThemeSlot rendered outside a custom block or section, so there is no ' +
|
|
138
|
+
'composition to read children from and nothing is rendered. Render this ' +
|
|
139
|
+
'component through a unit registered with registerCustomBlocks() or ' +
|
|
140
|
+
'registerCustomSections().'
|
|
140
141
|
);
|
|
141
142
|
return null;
|
|
142
143
|
}
|
|
143
144
|
|
|
144
|
-
const {
|
|
145
|
+
const { node, isDesigner } = context;
|
|
145
146
|
|
|
146
147
|
if (slotName === null) {
|
|
147
148
|
const reason = (resolved as { error: string }).error;
|
|
148
149
|
|
|
149
150
|
devWarn(
|
|
150
151
|
'ThemeSlot',
|
|
151
|
-
`The custom
|
|
152
|
+
`The custom unit "${node.type}" renders a slot that cannot be resolved: ${reason}.`
|
|
152
153
|
);
|
|
153
154
|
|
|
154
155
|
return isDesigner ? (
|
|
@@ -161,7 +162,7 @@ export function ThemeSlot({ label, name }: ThemeSlotProps) {
|
|
|
161
162
|
|
|
162
163
|
devWarn(
|
|
163
164
|
'ThemeSlot',
|
|
164
|
-
`The custom
|
|
165
|
+
`The custom unit "${node.type}" declares more than one ThemeSlot for ${named}. ` +
|
|
165
166
|
'Both resolve to the same slot node, so its children would render twice and two ' +
|
|
166
167
|
'wrappers would answer to one block id; the extra slot renders nothing. Give each ' +
|
|
167
168
|
'ThemeSlot its own name.'
|
|
@@ -180,7 +181,7 @@ export function ThemeSlot({ label, name }: ThemeSlotProps) {
|
|
|
180
181
|
label === undefined ? { name: slotName } : { name: slotName, label }
|
|
181
182
|
);
|
|
182
183
|
|
|
183
|
-
const slotChildren = resolveSlotChildren(
|
|
184
|
+
const slotChildren = resolveSlotChildren(node, slotName);
|
|
184
185
|
|
|
185
186
|
const children = renderComposedChildren({
|
|
186
187
|
// Null is "this slot has no node yet", which renders exactly like an empty
|
package/src/custom/types.ts
CHANGED
|
@@ -111,7 +111,16 @@ export const RESERVED_SETTING_KEYS: readonly string[] = [
|
|
|
111
111
|
'customCss',
|
|
112
112
|
'publish-window',
|
|
113
113
|
'typographyVariant',
|
|
114
|
-
'role'
|
|
114
|
+
'role',
|
|
115
|
+
// The two reserved node properties. Neither is a panel key, so neither is
|
|
116
|
+
// here for the reason the ones above are — they are here because a setting
|
|
117
|
+
// sharing one of these names would write the SAME key on the same node the
|
|
118
|
+
// resolver reads: `useThemeSetting('regionName', …)` on a section writes
|
|
119
|
+
// `section.properties.regionName`, one level away from the region binding it
|
|
120
|
+
// would appear to be addressing. Refusing the name costs a developer nothing
|
|
121
|
+
// and closes a near-miss that would be very hard to see.
|
|
122
|
+
'slotName',
|
|
123
|
+
'regionName'
|
|
115
124
|
];
|
|
116
125
|
|
|
117
126
|
/**
|
|
@@ -206,10 +206,12 @@ export function useThemeSetting<
|
|
|
206
206
|
|
|
207
207
|
const breakpoint = context.currentBreakpoint;
|
|
208
208
|
|
|
209
|
-
// `
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
|
|
209
|
+
// `node.properties[key]`, never `node.value` — `value` is one slot per block,
|
|
210
|
+
// so the second setting would overwrite the first, and the backend drops
|
|
211
|
+
// attribute keys that were never declared. `node` rather than `block` so a
|
|
212
|
+
// setting declared inside a custom SECTION reads the section's own
|
|
213
|
+
// properties, which is where the editor writes it.
|
|
214
|
+
const stored = context.node.properties?.[key];
|
|
213
215
|
|
|
214
216
|
// The sentinel again, for the same reason: handing `getResponsiveValue` an
|
|
215
217
|
// undefined fallback would resolve an undeclared default to `''`, so a
|