@akinon/pz-theme 2.0.71-rc.0 → 2.0.72-beta.0
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 +18 -31
- package/package.json +4 -4
- package/src/__tests__/custom-catalog-announce.test.ts +276 -0
- package/src/blocks/group-block.tsx +9 -57
- package/src/blocks/render-composed-children.tsx +104 -0
- package/src/custom/README.md +249 -0
- package/src/custom/__tests__/define.test.tsx +619 -0
- package/src/custom/__tests__/module-boundaries.test.ts +156 -0
- package/src/custom/__tests__/public-api.test.tsx +427 -0
- package/src/custom/__tests__/slot-node-wire.test.tsx +309 -0
- package/src/custom/__tests__/slot-nodes.test.ts +144 -0
- package/src/custom/__tests__/theme-editable.test.tsx +486 -0
- package/src/custom/__tests__/theme-slot.test.tsx +387 -0
- package/src/custom/__tests__/use-theme-setting.test.tsx +260 -0
- package/src/custom/block-context.ts +64 -0
- package/src/custom/block-host.tsx +162 -0
- package/src/custom/catalog.ts +97 -0
- package/src/custom/collect.ts +342 -0
- package/src/custom/define.tsx +172 -0
- package/src/custom/editable-tags.ts +120 -0
- package/src/custom/index.ts +105 -0
- package/src/custom/parts.ts +108 -0
- package/src/custom/property-types.ts +70 -0
- package/src/custom/register.ts +545 -0
- package/src/custom/slot-nodes.ts +119 -0
- package/src/custom/theme-editable.tsx +342 -0
- package/src/custom/theme-slot.tsx +210 -0
- package/src/custom/types.ts +251 -0
- package/src/custom/use-theme-setting.ts +225 -0
- package/src/index.ts +66 -0
- package/src/placeholder-registry.ts +168 -16
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useMemo } from 'react';
|
|
4
|
+
import type { BlockRendererProps } from '../blocks/block-renderer-registry';
|
|
5
|
+
import { DesignerPlaceholder } from '../blocks/product-block-utils';
|
|
6
|
+
import { devWarn } from '../utils/dev-warn';
|
|
7
|
+
import { CustomBlockContext } from './block-context';
|
|
8
|
+
import type { CustomBlockContextValue } from './block-context';
|
|
9
|
+
import { finishCollection, getCollectSink, isCollecting } from './collect';
|
|
10
|
+
import type { CustomBlockDefinition } from './define';
|
|
11
|
+
|
|
12
|
+
interface CustomBlockErrorBoundaryProps {
|
|
13
|
+
type: string;
|
|
14
|
+
label: string;
|
|
15
|
+
isDesigner: boolean;
|
|
16
|
+
children: React.ReactNode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Per-BLOCK isolation, deliberately not the per-section one.
|
|
21
|
+
*
|
|
22
|
+
* `SectionErrorBoundary` already catches this throw, and its recovery is to
|
|
23
|
+
* render the whole section as null — a brand block with a typo would blank the
|
|
24
|
+
* header, the hero and everything else composed beside it. Catching one level
|
|
25
|
+
* lower keeps the blast radius at the block a developer actually wrote, and
|
|
26
|
+
* everything around it renders normally.
|
|
27
|
+
*
|
|
28
|
+
* Live renders nothing (an end user never sees a broken block). The designer
|
|
29
|
+
* gets a labelled box instead, because a block that silently disappears from
|
|
30
|
+
* the canvas reads as "the editor lost my block" rather than "this component
|
|
31
|
+
* threw" — and the merchant still needs something to select and delete.
|
|
32
|
+
*/
|
|
33
|
+
class CustomBlockErrorBoundary extends React.Component<
|
|
34
|
+
CustomBlockErrorBoundaryProps,
|
|
35
|
+
{ hasError: boolean }
|
|
36
|
+
> {
|
|
37
|
+
state = { hasError: false };
|
|
38
|
+
|
|
39
|
+
static getDerivedStateFromError(): { hasError: boolean } {
|
|
40
|
+
return { hasError: true };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
componentDidCatch(error: unknown): void {
|
|
44
|
+
devWarn(
|
|
45
|
+
'customBlock',
|
|
46
|
+
`"${this.props.type}" threw while rendering and was skipped`,
|
|
47
|
+
error
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
render(): React.ReactNode {
|
|
52
|
+
if (!this.state.hasError) return this.props.children;
|
|
53
|
+
|
|
54
|
+
return this.props.isDesigner ? (
|
|
55
|
+
<DesignerPlaceholder
|
|
56
|
+
label={`${this.props.label} failed to render — see the browser console`}
|
|
57
|
+
/>
|
|
58
|
+
) : null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Adapts a developer component to `BlockRendererProps`, which is the only
|
|
64
|
+
* shape `blockRendererRegistry` can hold.
|
|
65
|
+
*
|
|
66
|
+
* The host is what makes the authoring API ordinary React: it converts a
|
|
67
|
+
* renderer's positional props into the context the public helpers read, so a
|
|
68
|
+
* `<ThemeEditable>` nested arbitrarily deep resolves without the developer
|
|
69
|
+
* threading anything through their own tree.
|
|
70
|
+
*
|
|
71
|
+
* A COMPONENT THAT TAKES ITS IDENTITY AS PROPS, never a factory `register.ts`
|
|
72
|
+
* calls. This file carries `'use client'`, and it has to — `React.Component`
|
|
73
|
+
* and `createContext` are both `undefined` under the `react-server` condition,
|
|
74
|
+
* so the error boundary below would die at module evaluation in the RSC graph.
|
|
75
|
+
* That directive also makes every export here a client REFERENCE for anyone
|
|
76
|
+
* holding the server-side copy, and React makes such a reference THROW when it
|
|
77
|
+
* is called. Taking identity as props is what lets `bindHost` in `register.ts`
|
|
78
|
+
* only ever *reference* this component, inside `createElement`, which resolves
|
|
79
|
+
* from either graph — a factory would have to be called.
|
|
80
|
+
*/
|
|
81
|
+
export interface CustomBlockHostProps extends BlockRendererProps {
|
|
82
|
+
/** The `custom:<vendor>:<name>` this instance renders. */
|
|
83
|
+
type: string;
|
|
84
|
+
/** Picker label, reused by the error box below. */
|
|
85
|
+
label: string;
|
|
86
|
+
definition: CustomBlockDefinition;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const CustomBlockHost = ({
|
|
90
|
+
type,
|
|
91
|
+
label,
|
|
92
|
+
definition,
|
|
93
|
+
block,
|
|
94
|
+
placeholderId,
|
|
95
|
+
sectionId,
|
|
96
|
+
isDesigner,
|
|
97
|
+
selectedBlockId,
|
|
98
|
+
currentBreakpoint = 'desktop'
|
|
99
|
+
}: CustomBlockHostProps) => {
|
|
100
|
+
const BrandBlock = definition.component;
|
|
101
|
+
|
|
102
|
+
// Read during render, not in the effect: the sink has to be in the context
|
|
103
|
+
// BEFORE the developer component runs, because that is when
|
|
104
|
+
// `useThemeSetting`/`ThemeEditable` report themselves.
|
|
105
|
+
const collecting = isCollecting(type);
|
|
106
|
+
|
|
107
|
+
// Closing on commit rather than on the render call is what makes the
|
|
108
|
+
// StrictMode double render harmless — see `finishCollection`.
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
finishCollection(type);
|
|
111
|
+
}, [type]);
|
|
112
|
+
|
|
113
|
+
// Read off the DEFINITION rather than the catalog descriptor: the descriptor's
|
|
114
|
+
// slot list grows as runtime collection discovers slots, and `ThemeSlot`
|
|
115
|
+
// resolves a bare `<ThemeSlot />` against this list. A list that grew mid
|
|
116
|
+
// render would resolve the bare slot onto whichever name happened to render
|
|
117
|
+
// first.
|
|
118
|
+
const declaredSlotNames = useMemo(
|
|
119
|
+
() => (definition.options.slots ?? []).map((slot) => slot.name ?? ''),
|
|
120
|
+
[definition]
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const value = useMemo<CustomBlockContextValue>(
|
|
124
|
+
() => ({
|
|
125
|
+
block,
|
|
126
|
+
placeholderId,
|
|
127
|
+
sectionId,
|
|
128
|
+
isDesigner,
|
|
129
|
+
currentBreakpoint,
|
|
130
|
+
selectedBlockId,
|
|
131
|
+
declaredSlotNames,
|
|
132
|
+
collect: collecting ? getCollectSink(type) : undefined
|
|
133
|
+
}),
|
|
134
|
+
[
|
|
135
|
+
type,
|
|
136
|
+
block,
|
|
137
|
+
placeholderId,
|
|
138
|
+
sectionId,
|
|
139
|
+
isDesigner,
|
|
140
|
+
currentBreakpoint,
|
|
141
|
+
selectedBlockId,
|
|
142
|
+
declaredSlotNames,
|
|
143
|
+
collecting
|
|
144
|
+
]
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<CustomBlockContext.Provider value={value}>
|
|
149
|
+
<CustomBlockErrorBoundary
|
|
150
|
+
type={type}
|
|
151
|
+
label={label}
|
|
152
|
+
isDesigner={isDesigner}
|
|
153
|
+
>
|
|
154
|
+
<BrandBlock />
|
|
155
|
+
</CustomBlockErrorBoundary>
|
|
156
|
+
</CustomBlockContext.Provider>
|
|
157
|
+
);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// Named for the React devtools tree: without this every custom block on the
|
|
161
|
+
// page appears as the same anonymous arrow function.
|
|
162
|
+
CustomBlockHost.displayName = 'CustomBlockHost';
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { devWarn } from '../utils/dev-warn';
|
|
2
|
+
import { CUSTOM_LIMITS } from './types';
|
|
3
|
+
import type { CustomBlockDescriptor, CustomCatalog } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The serializable half of every registered custom block, for the editor.
|
|
7
|
+
*
|
|
8
|
+
* Module state, one Map per bundle. That is the same shape `placeholderRegistry`
|
|
9
|
+
* uses and it inherits the same hazard: the client and server module graphs each
|
|
10
|
+
* get their own copy, and only the graph the brand barrel was imported into ever
|
|
11
|
+
* fills one. That is the CLIENT graph and only the client graph —
|
|
12
|
+
* `registerCustomBlocks` is client-only, so a brand imports its barrel from the
|
|
13
|
+
* client root and never from the server layout. Nothing is lost by that: the
|
|
14
|
+
* renderer registry `ThemeBlock` reads lives in the client graph too, and the
|
|
15
|
+
* announce in `placeholder-registry.ts` runs from the client.
|
|
16
|
+
*
|
|
17
|
+
* This file therefore stays free of a directive of its own:
|
|
18
|
+
* `placeholder-registry.ts` calls `getCustomCatalog` without one, and a
|
|
19
|
+
* `'use client'` here would turn that call into a client reference that throws
|
|
20
|
+
* on the server side.
|
|
21
|
+
*/
|
|
22
|
+
const catalog = new Map<string, CustomBlockDescriptor>();
|
|
23
|
+
|
|
24
|
+
let vendor = '';
|
|
25
|
+
|
|
26
|
+
export const setCatalogVendor = (next: string) => {
|
|
27
|
+
vendor = next;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const getCatalogVendor = () => vendor;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Adds a descriptor, refusing rather than overwriting.
|
|
34
|
+
*
|
|
35
|
+
* Last-wins would be silent: `blockRendererRegistry.register` is a bare
|
|
36
|
+
* `Map.set`, so two blocks claiming one type would leave the second rendering
|
|
37
|
+
* everywhere the first was placed, with no signal anywhere.
|
|
38
|
+
*/
|
|
39
|
+
export const addToCatalog = (descriptor: CustomBlockDescriptor) => {
|
|
40
|
+
if (catalog.has(descriptor.type)) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`[pz-theme] Custom block "${descriptor.type}" is defined twice in this build. ` +
|
|
43
|
+
`Type names must be unique — rename one of them.`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (catalog.size >= CUSTOM_LIMITS.typesPerBuild) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`[pz-theme] This build declares more than ${CUSTOM_LIMITS.typesPerBuild} custom blocks. ` +
|
|
50
|
+
`The cap exists because the whole catalog is posted to the editor on every ` +
|
|
51
|
+
`placeholder announce; split the build or retire unused blocks.`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
catalog.set(descriptor.type, descriptor);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const getCustomBlockDescriptor = (
|
|
59
|
+
type: string
|
|
60
|
+
): CustomBlockDescriptor | undefined => catalog.get(type);
|
|
61
|
+
|
|
62
|
+
export const getCustomBlockTypes = (): string[] => Array.from(catalog.keys());
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The payload the storefront announces. Returns null when nothing is
|
|
66
|
+
* registered, so the announcer can omit the field entirely rather than posting
|
|
67
|
+
* an empty catalog every time a placeholder mounts.
|
|
68
|
+
*/
|
|
69
|
+
export const getCustomCatalog = (): CustomCatalog | null => {
|
|
70
|
+
if (catalog.size === 0) return null;
|
|
71
|
+
|
|
72
|
+
const payload: CustomCatalog = {
|
|
73
|
+
vendor,
|
|
74
|
+
blocks: Array.from(catalog.values())
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Measured here rather than trusted: a descriptor grows with every setting,
|
|
78
|
+
// slot and option list, and the editor drops the WHOLE frame's catalog when
|
|
79
|
+
// it arrives over-cap. Failing loudly at the source names the build that did
|
|
80
|
+
// it; failing at the receiver names nothing.
|
|
81
|
+
const bytes = JSON.stringify(payload).length;
|
|
82
|
+
if (bytes > CUSTOM_LIMITS.catalogBytes) {
|
|
83
|
+
devWarn(
|
|
84
|
+
'customCatalog',
|
|
85
|
+
`Catalog is ${bytes} bytes, over the ${CUSTOM_LIMITS.catalogBytes} byte cap. ` +
|
|
86
|
+
`The editor will reject it; reduce option lists or the number of blocks.`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return payload;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** Test-only. Module state outlives a test file otherwise. */
|
|
94
|
+
export const resetCustomCatalog = () => {
|
|
95
|
+
catalog.clear();
|
|
96
|
+
vendor = '';
|
|
97
|
+
};
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { devWarn } from '../utils/dev-warn';
|
|
2
|
+
import { getCustomBlockDescriptor } from './catalog';
|
|
3
|
+
import { fieldPartName } from './editable-tags';
|
|
4
|
+
import { slotNameError } from './slot-nodes';
|
|
5
|
+
import {
|
|
6
|
+
SUPPORTED_PROPERTY_TYPES,
|
|
7
|
+
isSupportedPropertyType
|
|
8
|
+
} from './property-types';
|
|
9
|
+
import {
|
|
10
|
+
CUSTOM_LIMITS,
|
|
11
|
+
RESERVED_SETTING_KEYS,
|
|
12
|
+
SETTING_KEY_PATTERN,
|
|
13
|
+
materializeDefault
|
|
14
|
+
} from './types';
|
|
15
|
+
import type { CustomSettingDescriptor, CustomSlotDescriptor } from './types';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Runtime collection — the fallback that makes the authoring API work before
|
|
19
|
+
* the build-time extractor exists, and the cross-check that catches a stale
|
|
20
|
+
* generated descriptor after it does.
|
|
21
|
+
*
|
|
22
|
+
* A developer who declares nothing still gets a usable block: the first render
|
|
23
|
+
* of each type reports the `useThemeSetting` keys and the `ThemeSlot` it
|
|
24
|
+
* actually used, and those land in the catalog the editor receives. What
|
|
25
|
+
* runtime collection can never do is describe a block the merchant has not
|
|
26
|
+
* placed yet — nothing renders, so nothing is collected. That is the whole
|
|
27
|
+
* reason `defineCustomBlock` takes declarations too.
|
|
28
|
+
*
|
|
29
|
+
* The validators live in THIS file rather than beside the throwing versions in
|
|
30
|
+
* `register.ts` because both paths must apply the identical gate, and this is
|
|
31
|
+
* the only one of the four files with no inbound edge from the others
|
|
32
|
+
* (`register` -> `block-host` -> `collect`). Putting them in `register.ts`
|
|
33
|
+
* would close that chain into a cycle.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The short form a developer actually writes: `options: ['light','dark']`.
|
|
38
|
+
*
|
|
39
|
+
* The object member's `label` is optional because `CustomSettingDeclaration`
|
|
40
|
+
* (define.tsx) accepts a label-less option too, and both doors normalize
|
|
41
|
+
* through the one function below.
|
|
42
|
+
*/
|
|
43
|
+
export type ThemeSettingChoice =
|
|
44
|
+
| string
|
|
45
|
+
| number
|
|
46
|
+
| { label?: string; value: unknown };
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Normalizes the shorthand into the `{label, value}` pairs the editor's select
|
|
50
|
+
* control expects.
|
|
51
|
+
*
|
|
52
|
+
* THE ONE COPY OF THIS RULE. `registerCustomBlocks` takes the same option list
|
|
53
|
+
* from `defineCustomBlock` and calls this rather than carrying its own; a
|
|
54
|
+
* second copy drifted immediately — the declared path filled a missing `label`
|
|
55
|
+
* from the value while the hook path left it unset, so `[{ value: 'light' }]`
|
|
56
|
+
* drew a captionless control through one door and a captioned one through the
|
|
57
|
+
* other.
|
|
58
|
+
*
|
|
59
|
+
* It lives HERE and not beside `useThemeSetting`, which is where it reads more
|
|
60
|
+
* naturally, for the boundary reason `buildSetting` in `register.ts` records
|
|
61
|
+
* beside its `options` field: `use-theme-setting.ts` carries `'use client'` and
|
|
62
|
+
* this file deliberately carries no directive. Keeping the shared normalizer in
|
|
63
|
+
* the directive-free tier of this folder — the tier `catalog.ts` sits in, the
|
|
64
|
+
* one `placeholder-registry.ts` reaches without a directive of its own — leaves
|
|
65
|
+
* it callable from either module graph. Moving it into the hook's module would
|
|
66
|
+
* drag a client boundary along with it, for nothing.
|
|
67
|
+
*/
|
|
68
|
+
export const normalizeSettingChoices = (
|
|
69
|
+
choices: ReadonlyArray<ThemeSettingChoice> | undefined
|
|
70
|
+
): Array<{ label: string; value: unknown }> | undefined => {
|
|
71
|
+
if (!choices) return undefined;
|
|
72
|
+
|
|
73
|
+
return choices.map((choice) =>
|
|
74
|
+
typeof choice === 'object' && choice !== null
|
|
75
|
+
? { label: String(choice.label ?? choice.value), value: choice.value }
|
|
76
|
+
: { label: String(choice), value: choice }
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Why a control type is refused, named per type. "Unsupported" tells a
|
|
82
|
+
* developer nothing actionable; each of these three fails for its own reason
|
|
83
|
+
* and each has a different way out.
|
|
84
|
+
*/
|
|
85
|
+
const UNSUPPORTED_TYPE_REASON: Record<string, string> = {
|
|
86
|
+
multiselect:
|
|
87
|
+
'the editor stores a multiselect value as a bare array, and getResponsiveValue rejects arrays ' +
|
|
88
|
+
'(dev-warns and returns the fallback), so the block would read its authored default forever. ' +
|
|
89
|
+
'Use "select" and store one value until a per-type resolver exists',
|
|
90
|
+
localetext:
|
|
91
|
+
'the editor stores a localetext value as a locale map with no "desktop" key, so getResponsiveValue ' +
|
|
92
|
+
'falls back and the block would read its authored default forever. Use "text" and localize in the component',
|
|
93
|
+
richtext:
|
|
94
|
+
'there is no safe renderer for it here — the sanitizer this repo owns parses by assigning to innerHTML ' +
|
|
95
|
+
'on a live element, and pz-theme renders on the server where there is no document at all. Use "text"'
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** The reason a setting key is refused, or null. Shared by the declared and the collected path. */
|
|
99
|
+
export const settingKeyError = (key: unknown): string | null => {
|
|
100
|
+
if (typeof key !== 'string' || !SETTING_KEY_PATTERN.test(key)) {
|
|
101
|
+
return (
|
|
102
|
+
`key ${JSON.stringify(
|
|
103
|
+
key
|
|
104
|
+
)} is not a valid setting key. Use a camelCase name starting with a ` +
|
|
105
|
+
`lowercase letter (max 32 characters) — the key becomes both an object key in block.properties ` +
|
|
106
|
+
`and the "${fieldPartName(
|
|
107
|
+
'<key>'
|
|
108
|
+
)}" part name, so both grammars have to accept it`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (RESERVED_SETTING_KEYS.includes(key)) {
|
|
113
|
+
return (
|
|
114
|
+
`key "${key}" is reserved. The editor's property panel already treats it specially, so the field ` +
|
|
115
|
+
`would inherit behaviour it never asked for (a bespoke editor, a url() wrapper, or removal from ` +
|
|
116
|
+
`the Content tab). Rename it`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return null;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** The reason a control type is refused, or null. */
|
|
124
|
+
export const settingTypeError = (type: unknown): string | null => {
|
|
125
|
+
if (isSupportedPropertyType(type)) return null;
|
|
126
|
+
|
|
127
|
+
const named = typeof type === 'string' ? type : String(type);
|
|
128
|
+
const reason = UNSUPPORTED_TYPE_REASON[named];
|
|
129
|
+
|
|
130
|
+
if (reason)
|
|
131
|
+
return `control type "${named}" cannot deliver a value: ${reason}`;
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
`control type "${named}" is not one of the editor's control types. ` +
|
|
135
|
+
`Supported: ${SUPPORTED_PROPERTY_TYPES.join(', ')}`
|
|
136
|
+
);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export interface CustomCollectSink {
|
|
140
|
+
setting: (descriptor: CustomSettingDescriptor) => void;
|
|
141
|
+
slot: (descriptor: CustomSlotDescriptor) => void;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const sinks = new Map<string, CustomCollectSink>();
|
|
145
|
+
const finished = new Set<string>();
|
|
146
|
+
|
|
147
|
+
export const isCollecting = (type: string): boolean => !finished.has(type);
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Closed after the first COMMITTED render of a type, never on the first render
|
|
151
|
+
* call: React may render a component twice before committing (StrictMode, a
|
|
152
|
+
* discarded concurrent attempt), and closing on the first call would drop
|
|
153
|
+
* everything the surviving render reported.
|
|
154
|
+
*/
|
|
155
|
+
export const finishCollection = (type: string): void => {
|
|
156
|
+
finished.add(type);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const disagreement = (
|
|
160
|
+
declared: CustomSettingDescriptor,
|
|
161
|
+
collected: CustomSettingDescriptor
|
|
162
|
+
): string | null => {
|
|
163
|
+
if (declared.type !== collected.type) {
|
|
164
|
+
return `type "${declared.type}" vs "${collected.type}"`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const collectedDefault = materializeDefault(collected.defaultValue);
|
|
168
|
+
if (
|
|
169
|
+
JSON.stringify(declared.defaultValue) !== JSON.stringify(collectedDefault)
|
|
170
|
+
) {
|
|
171
|
+
return `default ${JSON.stringify(
|
|
172
|
+
declared.defaultValue
|
|
173
|
+
)} vs ${JSON.stringify(collectedDefault)}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return null;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* One stable sink per type.
|
|
181
|
+
*
|
|
182
|
+
* Stable identity matters because the sink sits inside the context value the
|
|
183
|
+
* host memoizes: a fresh object per render would invalidate that memo on every
|
|
184
|
+
* render of every custom block on the page.
|
|
185
|
+
*/
|
|
186
|
+
export const getCollectSink = (type: string): CustomCollectSink => {
|
|
187
|
+
const existing = sinks.get(type);
|
|
188
|
+
if (existing) return existing;
|
|
189
|
+
|
|
190
|
+
const targetDescriptor = () => {
|
|
191
|
+
const descriptor = getCustomBlockDescriptor(type);
|
|
192
|
+
if (!descriptor) {
|
|
193
|
+
devWarn(
|
|
194
|
+
'customBlock',
|
|
195
|
+
`"${type}" rendered with no catalog descriptor, so nothing it uses can be collected. ` +
|
|
196
|
+
`The block registered into a different module copy than the one rendering it: check that the ` +
|
|
197
|
+
`barrel calling registerCustomBlocks() is imported from the CLIENT ROOT, and clear .next — ` +
|
|
198
|
+
`webpack keeps serving the old copy of a changed package.`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
return descriptor;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const sink: CustomCollectSink = {
|
|
205
|
+
setting: (collected) => {
|
|
206
|
+
if (finished.has(type)) return;
|
|
207
|
+
|
|
208
|
+
const descriptor = targetDescriptor();
|
|
209
|
+
if (!descriptor) return;
|
|
210
|
+
|
|
211
|
+
const keyError = settingKeyError(collected.key);
|
|
212
|
+
const typeError = keyError ? null : settingTypeError(collected.type);
|
|
213
|
+
if (keyError || typeError) {
|
|
214
|
+
devWarn(
|
|
215
|
+
'customBlock',
|
|
216
|
+
`"${type}" used a setting the editor cannot offer: ${
|
|
217
|
+
keyError ?? typeError
|
|
218
|
+
}`
|
|
219
|
+
);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const declared = descriptor.settings.find(
|
|
224
|
+
(setting) => setting.key === collected.key
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
if (declared) {
|
|
228
|
+
// The declaration always wins: it is what the editor already has and
|
|
229
|
+
// what the build-time extractor will regenerate. A mismatch means the
|
|
230
|
+
// declaration no longer describes the component — the stale-descriptor
|
|
231
|
+
// signal this cross-check exists to raise.
|
|
232
|
+
const conflict = disagreement(declared, collected);
|
|
233
|
+
if (conflict) {
|
|
234
|
+
devWarn(
|
|
235
|
+
'customBlock',
|
|
236
|
+
`"${type}" setting "${collected.key}" disagrees with its declaration (${conflict}). ` +
|
|
237
|
+
`The declaration wins; update defineCustomBlock() so the picker matches the component.`
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (descriptor.settings.length >= CUSTOM_LIMITS.settingsPerType) {
|
|
244
|
+
devWarn(
|
|
245
|
+
'customBlock',
|
|
246
|
+
`"${type}" uses more than ${CUSTOM_LIMITS.settingsPerType} settings; "${collected.key}" was ` +
|
|
247
|
+
`dropped. Split the block — the whole catalog is posted to the editor on every placeholder announce.`
|
|
248
|
+
);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
descriptor.settings.push({
|
|
253
|
+
...collected,
|
|
254
|
+
label: collected.label || collected.key,
|
|
255
|
+
defaultValue: materializeDefault(collected.defaultValue)
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// The part name comes from the render path's own helper rather than a
|
|
259
|
+
// second copy of the same interpolation: `ThemeEditable` stamps the
|
|
260
|
+
// element with `partAttrs(fieldPartName(name))`, and a part the catalog
|
|
261
|
+
// advertises but the DOM never carries is a control the editor offers
|
|
262
|
+
// and nothing answers.
|
|
263
|
+
const part = fieldPartName(collected.key);
|
|
264
|
+
|
|
265
|
+
if (!collected.editable || descriptor.parts.includes(part)) return;
|
|
266
|
+
|
|
267
|
+
// The one place a descriptor can grow past the parts cap AFTER
|
|
268
|
+
// registration checked it. `register.ts` counts the declared parts and
|
|
269
|
+
// the declared fields together, but an undeclared `ThemeEditable`
|
|
270
|
+
// discovered at render time adds one more — and the cap is the editor's,
|
|
271
|
+
// which drops the WHOLE catalog above it. Losing this field's part group
|
|
272
|
+
// costs one control; letting it through would cost the brand every block
|
|
273
|
+
// in the palette.
|
|
274
|
+
if (descriptor.parts.length >= CUSTOM_LIMITS.partsPerType) {
|
|
275
|
+
devWarn(
|
|
276
|
+
'customBlock',
|
|
277
|
+
`"${type}" advertises more than ${CUSTOM_LIMITS.partsPerType} parts; the "${part}" part was ` +
|
|
278
|
+
`dropped, so "${collected.key}" has a control in the Content tab but no styling group. ` +
|
|
279
|
+
`Declare fewer parts in defineCustomBlock() — the editor refuses a catalog over the cap outright.`
|
|
280
|
+
);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
descriptor.parts.push(part);
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
slot: (collected) => {
|
|
288
|
+
if (finished.has(type)) return;
|
|
289
|
+
|
|
290
|
+
const descriptor = targetDescriptor();
|
|
291
|
+
if (!descriptor) return;
|
|
292
|
+
|
|
293
|
+
const name = collected.name ?? '';
|
|
294
|
+
|
|
295
|
+
const nameError = slotNameError(name);
|
|
296
|
+
if (nameError) {
|
|
297
|
+
devWarn(
|
|
298
|
+
'customBlock',
|
|
299
|
+
`"${type}" rendered a slot the editor cannot offer: ${nameError}`
|
|
300
|
+
);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Idempotent by name, and it has to be: this runs on every render until
|
|
305
|
+
// the first commit, so a repeat under one name is a re-render, never a
|
|
306
|
+
// second slot. Counting instead would cry wolf on the StrictMode double
|
|
307
|
+
// render — and it would be counting the wrong thing anyway, because a
|
|
308
|
+
// genuine duplicate never reaches this sink: `theme-slot.tsx` claims each
|
|
309
|
+
// NAME once per context value and the losers return without reporting.
|
|
310
|
+
// register.ts refuses the declared form of the same mistake, where the
|
|
311
|
+
// count is unambiguous.
|
|
312
|
+
if (descriptor.slots.some((slot) => slot.name === name)) {
|
|
313
|
+
descriptor.hasChildren = true;
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (descriptor.slots.length >= CUSTOM_LIMITS.slotsPerType) {
|
|
318
|
+
devWarn(
|
|
319
|
+
'customBlock',
|
|
320
|
+
`"${type}" opens more than ${CUSTOM_LIMITS.slotsPerType} slots; "${name}" was dropped. ` +
|
|
321
|
+
`Split the block — the whole catalog is posted to the editor on every placeholder announce.`
|
|
322
|
+
);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
descriptor.slots.push({
|
|
327
|
+
name,
|
|
328
|
+
...(collected.label !== undefined ? { label: collected.label } : {})
|
|
329
|
+
});
|
|
330
|
+
descriptor.hasChildren = true;
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
sinks.set(type, sink);
|
|
335
|
+
return sink;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
/** Test-only. Module state outlives a test file otherwise. */
|
|
339
|
+
export const resetCustomCollection = (): void => {
|
|
340
|
+
sinks.clear();
|
|
341
|
+
finished.clear();
|
|
342
|
+
};
|