@akinon/pz-theme 2.0.71-beta.0 → 2.0.71
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -4
- 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,251 @@
|
|
|
1
|
+
import type { PropertyType } from './property-types';
|
|
2
|
+
|
|
3
|
+
export type { PropertyType };
|
|
4
|
+
|
|
5
|
+
/** Prefix every developer-authored type carries. Permanent once published. */
|
|
6
|
+
export const CUSTOM_TYPE_PREFIX = 'custom:';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `custom:<vendor>:<name>`. Both segments are lowercase and hyphen-separated.
|
|
10
|
+
*
|
|
11
|
+
* Two properties this buys, and both are load-bearing:
|
|
12
|
+
* - It can never collide with a built-in type. Neither closed enum contains a
|
|
13
|
+
* colon, so `custom:acme:hero` and a future built-in `hero` cannot meet.
|
|
14
|
+
* - It is greppable, and `isCustomThemeType` below is the one spelling of it.
|
|
15
|
+
* Most of the code that has to treat a developer-authored type differently
|
|
16
|
+
* lives in the editor and the generator rather than here, so this export is
|
|
17
|
+
* the shape those repos mirror: a mirror can be diffed against this grammar
|
|
18
|
+
* instead of being re-derived slightly wrong.
|
|
19
|
+
*/
|
|
20
|
+
export const CUSTOM_TYPE_PATTERN =
|
|
21
|
+
/^custom:[a-z0-9][a-z0-9-]{0,47}:[a-z0-9][a-z0-9-]{0,47}$/;
|
|
22
|
+
|
|
23
|
+
export const isCustomThemeType = (type: unknown): type is string =>
|
|
24
|
+
typeof type === 'string' && CUSTOM_TYPE_PATTERN.test(type);
|
|
25
|
+
|
|
26
|
+
/** Vendor and block-name segments, validated separately for better errors. */
|
|
27
|
+
export const CUSTOM_SEGMENT_PATTERN = /^[a-z0-9][a-z0-9-]{0,47}$/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A `ThemeEditable`/`useThemeSetting` key. Deliberately narrower than the type
|
|
31
|
+
* segments: it becomes an object key in `block.properties` AND is interpolated
|
|
32
|
+
* into a `data-part` value as `field-<key>`, so it excludes everything the part
|
|
33
|
+
* grammar reserves — `/` is the part-key delimiter and a leading `:` marks a
|
|
34
|
+
* root state.
|
|
35
|
+
*/
|
|
36
|
+
export const SETTING_KEY_PATTERN = /^[a-z][a-zA-Z0-9]{0,31}$/;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A `data-part` value as `sanitizePartName` (utils/part-styles.ts) leaves it —
|
|
40
|
+
* that function keeps `[A-Za-z0-9_-]` and silently drops everything else.
|
|
41
|
+
*
|
|
42
|
+
* A declared part outside this grammar would be advertised in the catalog under
|
|
43
|
+
* one spelling and matched in CSS under another, so the control the editor drew
|
|
44
|
+
* for it would write styles no element on the page answers to. It also excludes
|
|
45
|
+
* both characters the style-key grammar reserves — `/` is the part-key
|
|
46
|
+
* delimiter and a leading `:` marks a root state — so a part name can never be
|
|
47
|
+
* mistaken for a key.
|
|
48
|
+
*/
|
|
49
|
+
export const PART_NAME_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The reserved block type one declared slot materializes as.
|
|
53
|
+
*
|
|
54
|
+
* A slot IS a child node rather than a name resolved against the block's one
|
|
55
|
+
* `block.blocks` array, and that is what makes several slots possible at all:
|
|
56
|
+
* insertion is already addressed as `(placeholderId, sectionId, parentBlockId,
|
|
57
|
+
* index)`, so a slot that is a node is filled by the machinery that exists —
|
|
58
|
+
* the slot node's id is simply the parent id. Three declared slots are three
|
|
59
|
+
* child nodes and three tree rows the merchant fills independently.
|
|
60
|
+
*
|
|
61
|
+
* Deliberately NOT under the `custom:<vendor>:<name>` grammar. That prefix
|
|
62
|
+
* means "a type this brand's build renders", and a slot node is rendered by
|
|
63
|
+
* nobody: `ThemeSlot` reaches past it to its children and the node itself puts
|
|
64
|
+
* no element on the page. One reserved type also means the editor recognises a
|
|
65
|
+
* slot without consulting a brand's catalog.
|
|
66
|
+
*/
|
|
67
|
+
export const CUSTOM_SLOT_BLOCK_TYPE = 'custom-slot';
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The property carrying a slot node's name.
|
|
71
|
+
*
|
|
72
|
+
* `block.properties` and not `block.label`, because the label is the one field
|
|
73
|
+
* a merchant can rename from the layer tree (`RENAME_BLOCK`) — a rename would
|
|
74
|
+
* detach the node from the `<ThemeSlot name="…">` that reads it, and nothing
|
|
75
|
+
* would say why the region went empty. Properties survive a save verbatim: the
|
|
76
|
+
* editor's `buildBlockSchemaForHierarchy` copies the whole object into the
|
|
77
|
+
* widget schema and `reconstructBlock` reads it straight back, key for key.
|
|
78
|
+
*/
|
|
79
|
+
export const SLOT_NAME_PROPERTY = 'slotName';
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A slot name. Narrower than a part name, wider than a setting key.
|
|
83
|
+
*
|
|
84
|
+
* It is never interpolated into a selector, so the part grammar's restrictions
|
|
85
|
+
* do not apply; what it must do is match LITERALLY between the component's
|
|
86
|
+
* `<ThemeSlot name="…">` and the node the editor created, across a JSON round
|
|
87
|
+
* trip. Excluding whitespace and case-collapsing surprises is the whole job.
|
|
88
|
+
*/
|
|
89
|
+
export const SLOT_NAME_PATTERN = /^[a-z][a-zA-Z0-9-]{0,31}$/;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Keys the editor's generic property panel already treats specially. A custom
|
|
93
|
+
* block naming a field one of these would silently inherit behaviour it never
|
|
94
|
+
* asked for — `items` is rerouted to a bespoke slider editor, `background-image`
|
|
95
|
+
* is `url()`-wrapped, `font-family` becomes a Google Font picker, and `htmlId`
|
|
96
|
+
* and `tag` are pulled out of the Content tab entirely.
|
|
97
|
+
*
|
|
98
|
+
* This list is duplicated from the editor on purpose and is the WRONG kind of
|
|
99
|
+
* copy — it is the fourth mirror of a contract in this system. It exists so the
|
|
100
|
+
* refusal works today; the derivation script that makes it self-maintaining is
|
|
101
|
+
* its own ticket, and until that lands this list is a known drift risk.
|
|
102
|
+
*/
|
|
103
|
+
export const RESERVED_SETTING_KEYS: readonly string[] = [
|
|
104
|
+
'items',
|
|
105
|
+
'backgroundImage',
|
|
106
|
+
'background-image',
|
|
107
|
+
'fontFamily',
|
|
108
|
+
'font-family',
|
|
109
|
+
'htmlId',
|
|
110
|
+
'tag',
|
|
111
|
+
'customCss',
|
|
112
|
+
'publish-window',
|
|
113
|
+
'typographyVariant',
|
|
114
|
+
'role'
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Caps. Enforced at the sender; re-enforced on arrival in the editor, whose
|
|
119
|
+
* `CUSTOM_LIMITS` carries the same four numbers.
|
|
120
|
+
*
|
|
121
|
+
* Every one is a literal rather than derived from another, because each is
|
|
122
|
+
* mirrored INDEPENDENTLY on the far side: an expression here would let a change
|
|
123
|
+
* to one cap move a second one on the sender alone, and the receiver's answer
|
|
124
|
+
* to a catalog over any cap is to drop all of it.
|
|
125
|
+
*/
|
|
126
|
+
export const CUSTOM_LIMITS = {
|
|
127
|
+
typesPerBuild: 40,
|
|
128
|
+
settingsPerType: 24,
|
|
129
|
+
slotsPerType: 8,
|
|
130
|
+
/**
|
|
131
|
+
* Every part the catalog advertises — the field-derived ones and the
|
|
132
|
+
* developer-declared ones together.
|
|
133
|
+
*
|
|
134
|
+
* A LITERAL, matching the editor's `CUSTOM_LIMITS.partsPerType`, and not
|
|
135
|
+
* `settingsPerType + slotsPerType` even though 32 is what that arithmetic
|
|
136
|
+
* happens to produce. That sum was the real bound only while `field-<key>`
|
|
137
|
+
* was the one way a part came into existence; a developer now declares parts
|
|
138
|
+
* directly, so the two numbers describe different things and only look
|
|
139
|
+
* related. Left derived, raising the settings cap would silently raise this
|
|
140
|
+
* one on the SENDER while the receiver's stayed at 32 — and the receiver
|
|
141
|
+
* drops the WHOLE catalog over the cap, so a build would lose every block in
|
|
142
|
+
* its palette because an unrelated limit moved.
|
|
143
|
+
*/
|
|
144
|
+
partsPerType: 32,
|
|
145
|
+
catalogBytes: 256 * 1024
|
|
146
|
+
} as const;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The serializable half of a custom block — everything the editor needs and
|
|
150
|
+
* nothing it cannot receive. Functions are structurally impossible here: the
|
|
151
|
+
* editor has no `eval`/`new Function`, and `postMessage` throws on a function
|
|
152
|
+
* anyway, so `condition`/`customRender` can never cross.
|
|
153
|
+
*/
|
|
154
|
+
export interface CustomSettingDescriptor {
|
|
155
|
+
key: string;
|
|
156
|
+
type: PropertyType;
|
|
157
|
+
label: string;
|
|
158
|
+
/** Always per-breakpoint. See `materializeDefault`. */
|
|
159
|
+
defaultValue?: unknown;
|
|
160
|
+
options?: Array<{ label: string; value: unknown }>;
|
|
161
|
+
min?: number;
|
|
162
|
+
max?: number;
|
|
163
|
+
step?: number;
|
|
164
|
+
unit?: string;
|
|
165
|
+
maxLength?: number;
|
|
166
|
+
helpText?: string;
|
|
167
|
+
group?: string;
|
|
168
|
+
showWhen?: { key: string; value: unknown };
|
|
169
|
+
/**
|
|
170
|
+
* Set when the setting came from a `ThemeEditable`, which makes it a content
|
|
171
|
+
* field rather than a behaviour knob. The editor uses it to offer the control
|
|
172
|
+
* on the `field-<key>` part as well as in the Content tab, so clicking the
|
|
173
|
+
* element on canvas lands on the right control.
|
|
174
|
+
*/
|
|
175
|
+
editable?: boolean;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface CustomSlotDescriptor {
|
|
179
|
+
/**
|
|
180
|
+
* The name of the slot NODE this slot renders, matched literally. The empty
|
|
181
|
+
* string is the unnamed slot, which is what every block written against the
|
|
182
|
+
* single-slot API declares and what a legacy composition's children resolve
|
|
183
|
+
* to — see `resolveSlotChildren` in `slot-nodes.ts`.
|
|
184
|
+
*/
|
|
185
|
+
name: string;
|
|
186
|
+
label?: string;
|
|
187
|
+
accepts?: string[];
|
|
188
|
+
max?: number;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface CustomBlockDescriptor {
|
|
192
|
+
type: string;
|
|
193
|
+
name: string;
|
|
194
|
+
label: string;
|
|
195
|
+
icon?: string;
|
|
196
|
+
group?: string;
|
|
197
|
+
hasChildren: boolean;
|
|
198
|
+
settings: CustomSettingDescriptor[];
|
|
199
|
+
slots: CustomSlotDescriptor[];
|
|
200
|
+
/**
|
|
201
|
+
* Every `data-part` the block advertises: the `field-<key>` parts derived
|
|
202
|
+
* from its `ThemeEditable` fields first, then the ones the developer declared
|
|
203
|
+
* through `defineCustomBlock({ parts })`.
|
|
204
|
+
*
|
|
205
|
+
* ONE FLAT LIST OF STRINGS, deliberately. The editor validates this field
|
|
206
|
+
* element by element and drops the whole catalog when an entry is not a
|
|
207
|
+
* string, so turning it into a list of objects to carry the states would make
|
|
208
|
+
* every block in the build vanish from the palette of any editor older than
|
|
209
|
+
* the change. The states ride alongside instead.
|
|
210
|
+
*/
|
|
211
|
+
parts: string[];
|
|
212
|
+
/**
|
|
213
|
+
* Declared states per part, for the parts that declared any — `{ row:
|
|
214
|
+
* ['hover', 'open'] }`. Absent when no part declared a state, which is why it
|
|
215
|
+
* is optional rather than an empty object: an editor that has never heard of
|
|
216
|
+
* it ignores an unknown key, and a brand that declares no state sends nothing
|
|
217
|
+
* extra.
|
|
218
|
+
*
|
|
219
|
+
* Only states `buildPartSelectors` can actually match appear here
|
|
220
|
+
* (`PART_STYLE_STATES`); a state with no selector expression would produce a
|
|
221
|
+
* control writing CSS that can never apply.
|
|
222
|
+
*/
|
|
223
|
+
partStates?: Record<string, string[]>;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface CustomCatalog {
|
|
227
|
+
vendor: string;
|
|
228
|
+
blocks: CustomBlockDescriptor[];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Per-breakpoint shape for every authored default.
|
|
233
|
+
*
|
|
234
|
+
* Not cosmetic. `handlePropertyChange` in the editor re-spreads an existing
|
|
235
|
+
* value only when it is ALREADY per-breakpoint; a bare scalar is replaced
|
|
236
|
+
* wholesale by `{tablet: v}` on the first non-desktop edit, after which
|
|
237
|
+
* `getResponsiveValue` finds no `desktop` key and falls back to the schema
|
|
238
|
+
* default — the merchant's desktop value is gone with no error. Materializing
|
|
239
|
+
* here means an authored default can never be that bare scalar.
|
|
240
|
+
*
|
|
241
|
+
* Values that are already objects (a per-breakpoint map the developer wrote
|
|
242
|
+
* themselves) and arrays (which are stored unbucketed) pass through untouched.
|
|
243
|
+
*/
|
|
244
|
+
export const materializeDefault = (value: unknown): unknown => {
|
|
245
|
+
if (value === undefined) return undefined;
|
|
246
|
+
if (value === null) return value;
|
|
247
|
+
if (Array.isArray(value)) return value;
|
|
248
|
+
if (typeof value === 'object') return value;
|
|
249
|
+
|
|
250
|
+
return { desktop: value };
|
|
251
|
+
};
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { getResponsiveValue } from '../utils';
|
|
4
|
+
import { devWarn } from '../utils/dev-warn';
|
|
5
|
+
import { useCustomBlockContext } from './block-context';
|
|
6
|
+
import {
|
|
7
|
+
normalizeSettingChoices,
|
|
8
|
+
settingKeyError,
|
|
9
|
+
settingTypeError
|
|
10
|
+
} from './collect';
|
|
11
|
+
import type { ThemeSettingChoice } from './collect';
|
|
12
|
+
import type { PropertyType } from './property-types';
|
|
13
|
+
import { materializeDefault } from './types';
|
|
14
|
+
import type { CustomSettingDescriptor } from './types';
|
|
15
|
+
|
|
16
|
+
// Re-exported so a block file still reaches the option shorthand through the
|
|
17
|
+
// module it writes its settings against. The declaration itself moved to
|
|
18
|
+
// `collect.ts` with the normalizer that consumes it — see the note there.
|
|
19
|
+
export type { ThemeSettingChoice };
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What each control type resolves to, so a call needs no type argument — the
|
|
23
|
+
* comparison in the documented example (`tone === 'dark'`) only typechecks
|
|
24
|
+
* because `select` maps to `string`.
|
|
25
|
+
*
|
|
26
|
+
* Total over `PropertyType`, including the three types that throw at runtime:
|
|
27
|
+
* the map is an index, not a permission.
|
|
28
|
+
*/
|
|
29
|
+
interface SettingValueByType {
|
|
30
|
+
text: string;
|
|
31
|
+
select: string;
|
|
32
|
+
url: string;
|
|
33
|
+
color: string;
|
|
34
|
+
icon: string;
|
|
35
|
+
image: string;
|
|
36
|
+
file: string;
|
|
37
|
+
richtext: string;
|
|
38
|
+
toggle: boolean;
|
|
39
|
+
slider: number;
|
|
40
|
+
typography: Record<string, unknown>;
|
|
41
|
+
multiselect: string[];
|
|
42
|
+
localetext: Record<string, string>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Stands in for TypeScript 5.4's `NoInfer`. Brands consume this package as
|
|
47
|
+
* source and compile it with their own TypeScript, several of them older.
|
|
48
|
+
*
|
|
49
|
+
* Without it, `default: 'light'` makes the return type the literal `'light'`
|
|
50
|
+
* and `tone === 'dark'` becomes a compile error about non-overlapping types —
|
|
51
|
+
* the API would reject the exact snippet it documents.
|
|
52
|
+
*/
|
|
53
|
+
type NoInferred<T> = [T][T extends unknown ? 0 : never];
|
|
54
|
+
|
|
55
|
+
export interface ThemeSettingOptions<
|
|
56
|
+
TType extends PropertyType = PropertyType,
|
|
57
|
+
TValue = unknown
|
|
58
|
+
> {
|
|
59
|
+
type: TType;
|
|
60
|
+
label: string;
|
|
61
|
+
default?: NoInferred<TValue>;
|
|
62
|
+
options?: ReadonlyArray<ThemeSettingChoice>;
|
|
63
|
+
min?: number;
|
|
64
|
+
max?: number;
|
|
65
|
+
step?: number;
|
|
66
|
+
unit?: string;
|
|
67
|
+
maxLength?: number;
|
|
68
|
+
helpText?: string;
|
|
69
|
+
group?: string;
|
|
70
|
+
showWhen?: { key: string; value: unknown };
|
|
71
|
+
/**
|
|
72
|
+
* Marks the setting as a content field rather than a behaviour knob, so the
|
|
73
|
+
* editor also offers it on the `field-<key>` part. `ThemeEditable` sets it;
|
|
74
|
+
* a hand-written call has no on-screen element to attach a control to and
|
|
75
|
+
* should leave it alone.
|
|
76
|
+
*/
|
|
77
|
+
editable?: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Throws rather than warns. A malformed key or an unreadable control type
|
|
82
|
+
* produces a panel control that shows its default forever and silently drops
|
|
83
|
+
* whatever the merchant types into it — a failure that surfaces weeks later as
|
|
84
|
+
* "the editor does not save". Production skips the check: by then the
|
|
85
|
+
* declaration is fixed at build time, and a storefront must not go down over a
|
|
86
|
+
* property panel.
|
|
87
|
+
*
|
|
88
|
+
* The rules come from `collect.ts` rather than being restated here. Both paths
|
|
89
|
+
* refuse the same key for the same reason, and a copy of the rule that drifted
|
|
90
|
+
* would be worse than no check at all — the hook would accept a key the
|
|
91
|
+
* collector then dropped, leaving a setting with no control and no error.
|
|
92
|
+
*/
|
|
93
|
+
const assertDeclarationIsValid = (
|
|
94
|
+
key: string,
|
|
95
|
+
options: ThemeSettingOptions<PropertyType, unknown>
|
|
96
|
+
): void => {
|
|
97
|
+
if (process.env.NODE_ENV === 'production') return;
|
|
98
|
+
|
|
99
|
+
const reason = settingKeyError(key) ?? settingTypeError(options.type);
|
|
100
|
+
if (!reason) return;
|
|
101
|
+
|
|
102
|
+
throw new Error(`[pz-theme] useThemeSetting: ${reason}.`);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const buildDescriptor = (
|
|
106
|
+
key: string,
|
|
107
|
+
options: ThemeSettingOptions<PropertyType, unknown>
|
|
108
|
+
): CustomSettingDescriptor => {
|
|
109
|
+
const descriptor: CustomSettingDescriptor = {
|
|
110
|
+
key,
|
|
111
|
+
type: options.type,
|
|
112
|
+
label: options.label
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Per-breakpoint from the start: a bare scalar default is replaced wholesale
|
|
116
|
+
// by the editor on the first non-desktop edit, taking the merchant's desktop
|
|
117
|
+
// value with it. See the note on `materializeDefault`.
|
|
118
|
+
if (options.default !== undefined) {
|
|
119
|
+
descriptor.defaultValue = materializeDefault(options.default);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const choices = normalizeSettingChoices(options.options);
|
|
123
|
+
if (choices) descriptor.options = choices;
|
|
124
|
+
|
|
125
|
+
if (options.min !== undefined) descriptor.min = options.min;
|
|
126
|
+
if (options.max !== undefined) descriptor.max = options.max;
|
|
127
|
+
if (options.step !== undefined) descriptor.step = options.step;
|
|
128
|
+
if (options.unit !== undefined) descriptor.unit = options.unit;
|
|
129
|
+
if (options.maxLength !== undefined) descriptor.maxLength = options.maxLength;
|
|
130
|
+
if (options.helpText !== undefined) descriptor.helpText = options.helpText;
|
|
131
|
+
if (options.group !== undefined) descriptor.group = options.group;
|
|
132
|
+
if (options.showWhen !== undefined) descriptor.showWhen = options.showWhen;
|
|
133
|
+
if (options.editable) descriptor.editable = true;
|
|
134
|
+
|
|
135
|
+
return descriptor;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Separates "no default was authored" from "the authored default is an empty
|
|
140
|
+
* string". `getResponsiveValue` defaults its own third parameter to `''`, so
|
|
141
|
+
* passing `undefined` for the fallback would silently become `''`.
|
|
142
|
+
*/
|
|
143
|
+
const NOT_AUTHORED = Symbol('pz-theme.notAuthored');
|
|
144
|
+
|
|
145
|
+
const resolveAuthoredDefault = (
|
|
146
|
+
value: unknown,
|
|
147
|
+
breakpoint: string
|
|
148
|
+
): unknown => {
|
|
149
|
+
const resolved = getResponsiveValue(value, breakpoint, NOT_AUTHORED);
|
|
150
|
+
|
|
151
|
+
// An object default is a per-breakpoint map by the convention
|
|
152
|
+
// `materializeDefault` sets — except for `typography`, whose natural default
|
|
153
|
+
// is a style object with no `desktop` key. Resolving that would yield
|
|
154
|
+
// nothing and the control would read as unset, so a default that resolves to
|
|
155
|
+
// nothing is handed back whole rather than dropped.
|
|
156
|
+
return resolved === NOT_AUTHORED ? value : resolved;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Declares one merchant-editable value that has no on-screen representation —
|
|
161
|
+
* how many products a grid shows, which category it pulls from, whether a
|
|
162
|
+
* slider auto-advances, which side the image sits on.
|
|
163
|
+
*
|
|
164
|
+
* Without it, every variation becomes its own entry in the add menu: one
|
|
165
|
+
* component shipped four times as "Promo strip (left)", "Promo strip (right)"
|
|
166
|
+
* and so on. `ThemeEditable` covers the other half — the values a merchant
|
|
167
|
+
* edits by clicking the thing itself.
|
|
168
|
+
*
|
|
169
|
+
* A HOOK rather than a prop, and that is the load-bearing part. React requires
|
|
170
|
+
* hooks to be called unconditionally, so the set of settings a component
|
|
171
|
+
* declares is identical on every render — which is what makes the declaration
|
|
172
|
+
* statically knowable and lets the whole property panel be built from a single
|
|
173
|
+
* render pass. A prop bag or a runtime `declare()` call could sit behind an
|
|
174
|
+
* `if`, and the panel would gain and lose controls as the merchant edited.
|
|
175
|
+
*
|
|
176
|
+
* The declaration IS the registration: there is no second schema file to keep
|
|
177
|
+
* in step, so a setting cannot drift from its use.
|
|
178
|
+
*/
|
|
179
|
+
export function useThemeSetting<
|
|
180
|
+
TType extends PropertyType,
|
|
181
|
+
TValue = SettingValueByType[TType]
|
|
182
|
+
>(key: string, options: ThemeSettingOptions<TType, TValue>): TValue {
|
|
183
|
+
const context = useCustomBlockContext();
|
|
184
|
+
const declaration = options as ThemeSettingOptions<PropertyType, unknown>;
|
|
185
|
+
|
|
186
|
+
assertDeclarationIsValid(key, declaration);
|
|
187
|
+
|
|
188
|
+
if (!context) {
|
|
189
|
+
// Never throws: a developer previewing the component in a plain React tree,
|
|
190
|
+
// a storybook or a unit test should see the declared defaults, not a crash.
|
|
191
|
+
// The key rides in the message so distinct misplacements dedupe
|
|
192
|
+
// independently inside `devWarn`.
|
|
193
|
+
devWarn(
|
|
194
|
+
'useThemeSetting',
|
|
195
|
+
`"${key}" was read outside a custom block, so the merchant's value is ` +
|
|
196
|
+
`unreachable and the declared default is used instead. Render this ` +
|
|
197
|
+
`component through a block registered with registerCustomBlocks().`
|
|
198
|
+
);
|
|
199
|
+
return declaration.default as TValue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Reported on every render rather than once from an effect: the collection
|
|
203
|
+
// pass renders the component and reads the sink synchronously, and on the
|
|
204
|
+
// server effects never run at all.
|
|
205
|
+
context.collect?.setting(buildDescriptor(key, declaration));
|
|
206
|
+
|
|
207
|
+
const breakpoint = context.currentBreakpoint;
|
|
208
|
+
|
|
209
|
+
// `block.properties[key]`, never `block.value` — `value` is one slot per
|
|
210
|
+
// block, so the second setting would overwrite the first, and the backend
|
|
211
|
+
// drops attribute keys that were never declared.
|
|
212
|
+
const stored = context.block.properties?.[key];
|
|
213
|
+
|
|
214
|
+
// The sentinel again, for the same reason: handing `getResponsiveValue` an
|
|
215
|
+
// undefined fallback would resolve an undeclared default to `''`, so a
|
|
216
|
+
// `toggle` with no default would come back as an empty string rather than
|
|
217
|
+
// undefined.
|
|
218
|
+
const resolved = getResponsiveValue(stored, breakpoint, NOT_AUTHORED);
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
resolved === NOT_AUTHORED
|
|
222
|
+
? resolveAuthoredDefault(declaration.default, breakpoint)
|
|
223
|
+
: resolved
|
|
224
|
+
) as TValue;
|
|
225
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -138,3 +138,69 @@ export { CheckoutPiecesProvider } from './chrome/checkout-pieces-provider';
|
|
|
138
138
|
export { CheckoutPiece } from './chrome/checkout-piece';
|
|
139
139
|
export { CHECKOUT_PIECE_KEYS } from './chrome/checkout-pieces';
|
|
140
140
|
export type { CheckoutPieceKey } from './chrome/checkout-pieces';
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The custom block authoring API, named export by named export.
|
|
144
|
+
*
|
|
145
|
+
* Curated where the rest of this file is not, and where deep imports
|
|
146
|
+
* (`@akinon/pz-theme/src/custom`) are the de-facto norm everywhere else,
|
|
147
|
+
* because this is the only surface here consumed by code in ANOTHER REPO. A
|
|
148
|
+
* symbol that leaks out of `src/custom` becomes a compile dependency in a
|
|
149
|
+
* brand's block file the moment someone imports it, and removing it back out
|
|
150
|
+
* is then a breaking change in a repo this team does not own. Listing each
|
|
151
|
+
* export makes widening that surface a deliberate edit rather than the side
|
|
152
|
+
* effect of adding a file to the folder — which is exactly what `export *`
|
|
153
|
+
* would make it.
|
|
154
|
+
*
|
|
155
|
+
* `partAttrs` is deliberately absent: `export * from './utils'` above already
|
|
156
|
+
* publishes it at the root. The custom barrel re-exports it so a block file
|
|
157
|
+
* needs one import; adding it here too would be a second spelling of the same
|
|
158
|
+
* symbol from the same entry point.
|
|
159
|
+
*
|
|
160
|
+
* THIS ENTRY POINT IS SERVER-ONLY, which is why `registerCustomBlocks` cannot
|
|
161
|
+
* actually be used through it. `ThemePlaceholder` two dozen lines up imports
|
|
162
|
+
* `server-only`, and that poisons the whole module for a client graph — a
|
|
163
|
+
* brand's client root importing `registerCustomBlocks` from here gets "cannot
|
|
164
|
+
* be imported from a Client Component module" at build time. Registration is
|
|
165
|
+
* client-only (`custom/index.ts` explains why), so the barrel a brand writes
|
|
166
|
+
* imports from `@akinon/pz-theme/src/custom` instead. What these exports buy is
|
|
167
|
+
* discovery and the types, not a registration path.
|
|
168
|
+
*/
|
|
169
|
+
// The slot-node grammar (`CUSTOM_SLOT_BLOCK_TYPE`, `SLOT_NAME_PROPERTY`) and the
|
|
170
|
+
// one spelling of the check (`isSlotNode`) are named here for the repos that
|
|
171
|
+
// mirror the grammar rather than re-deriving it.
|
|
172
|
+
export {
|
|
173
|
+
defineCustomBlock,
|
|
174
|
+
registerCustomBlocks,
|
|
175
|
+
ThemeEditable,
|
|
176
|
+
ThemeSlot,
|
|
177
|
+
useThemeSetting,
|
|
178
|
+
getCustomCatalog,
|
|
179
|
+
isCustomThemeType,
|
|
180
|
+
CUSTOM_TYPE_PREFIX,
|
|
181
|
+
CUSTOM_SLOT_BLOCK_TYPE,
|
|
182
|
+
SLOT_NAME_PROPERTY,
|
|
183
|
+
isSlotNode,
|
|
184
|
+
slotNodeName
|
|
185
|
+
} from './custom';
|
|
186
|
+
export type {
|
|
187
|
+
CustomBlockComponent,
|
|
188
|
+
CustomBlockDefinition,
|
|
189
|
+
CustomBlockDescriptor,
|
|
190
|
+
CustomBlockInput,
|
|
191
|
+
CustomBlockOptions,
|
|
192
|
+
CustomCatalog,
|
|
193
|
+
CustomPartDeclaration,
|
|
194
|
+
CustomPartsDeclaration,
|
|
195
|
+
CustomSettingDeclaration,
|
|
196
|
+
CustomSettingDescriptor,
|
|
197
|
+
CustomSettingOption,
|
|
198
|
+
CustomSlotDeclaration,
|
|
199
|
+
CustomSlotDescriptor,
|
|
200
|
+
EditableKind,
|
|
201
|
+
PropertyType,
|
|
202
|
+
ThemeEditableProps,
|
|
203
|
+
ThemeSettingChoice,
|
|
204
|
+
ThemeSettingOptions,
|
|
205
|
+
ThemeSlotProps
|
|
206
|
+
} from './custom';
|