@akinon/pz-theme 2.0.71-rc.0 → 2.0.71
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -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,172 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import type { CustomPartsDeclaration } from './parts';
|
|
3
|
+
import type { PropertyType } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A developer component takes NO props. Everything it needs — the block, the
|
|
7
|
+
* breakpoint, the designer flag — arrives through `CustomBlockContext`, which
|
|
8
|
+
* is what lets a `<ThemeEditable>` five levels down work without threading.
|
|
9
|
+
* Typing the props as empty is deliberate: a component that declares required
|
|
10
|
+
* props fails to compile here rather than rendering with `undefined` for all
|
|
11
|
+
* of them, because nothing on the render path will ever supply one.
|
|
12
|
+
*/
|
|
13
|
+
export type CustomBlockComponent = React.ComponentType<Record<string, never>>;
|
|
14
|
+
|
|
15
|
+
export interface CustomSettingOption {
|
|
16
|
+
label?: string;
|
|
17
|
+
value: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What a developer writes. Looser than `CustomSettingDescriptor` in exactly two
|
|
22
|
+
* places — `label` may be omitted, and an option may be a bare string/number —
|
|
23
|
+
* because those are the two shapes people write by hand. Registration
|
|
24
|
+
* normalizes both into the descriptor the editor receives.
|
|
25
|
+
*/
|
|
26
|
+
export interface CustomSettingDeclaration {
|
|
27
|
+
key: string;
|
|
28
|
+
type: PropertyType;
|
|
29
|
+
label?: string;
|
|
30
|
+
defaultValue?: unknown;
|
|
31
|
+
options?: Array<CustomSettingOption | string | number>;
|
|
32
|
+
min?: number;
|
|
33
|
+
max?: number;
|
|
34
|
+
step?: number;
|
|
35
|
+
unit?: string;
|
|
36
|
+
maxLength?: number;
|
|
37
|
+
helpText?: string;
|
|
38
|
+
group?: string;
|
|
39
|
+
showWhen?: { key: string; value: unknown };
|
|
40
|
+
/** Content field rather than a behaviour knob — see `CustomSettingDescriptor.editable`. */
|
|
41
|
+
editable?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One merchant-fillable region. Each declared slot materializes as one
|
|
46
|
+
* reserved-type CHILD NODE of the block, so a block declaring three slots gets
|
|
47
|
+
* three tree rows the merchant fills independently.
|
|
48
|
+
*
|
|
49
|
+
* `name` stays optional, and omitting it stays meaningful: a block that
|
|
50
|
+
* declares a single slot needs no name at all, which is exactly what every
|
|
51
|
+
* block written against the original single-slot API already does.
|
|
52
|
+
*/
|
|
53
|
+
export interface CustomSlotDeclaration {
|
|
54
|
+
name?: string;
|
|
55
|
+
label?: string;
|
|
56
|
+
/** Carried to the editor; pz-theme does not enforce acceptance yet. */
|
|
57
|
+
accepts?: string[];
|
|
58
|
+
max?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface CustomBlockOptions {
|
|
62
|
+
/** Shown in the editor's block picker. Defaults to the registration name. */
|
|
63
|
+
label?: string;
|
|
64
|
+
icon?: string;
|
|
65
|
+
group?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Optional. An undeclared block still works: the runtime collector in
|
|
68
|
+
* `collect.ts` fills the descriptor from the first render, and the
|
|
69
|
+
* build-time extractor will supersede both. Declaring is what buys a
|
|
70
|
+
* complete picker entry BEFORE the block has ever been placed once.
|
|
71
|
+
*/
|
|
72
|
+
settings?: CustomSettingDeclaration[];
|
|
73
|
+
slots?: CustomSlotDeclaration[];
|
|
74
|
+
/**
|
|
75
|
+
* Elements the developer stamped with `partAttrs()` that the merchant may
|
|
76
|
+
* restyle from the properties panel.
|
|
77
|
+
*
|
|
78
|
+
* `['row', 'tile']` for parts with one appearance, `{ row: { states:
|
|
79
|
+
* ['hover', 'open'] } }` for the ones whose appearance depends on what the
|
|
80
|
+
* shopper is doing. The field-derived `field-<key>` parts are added for free
|
|
81
|
+
* and must not be declared here.
|
|
82
|
+
*/
|
|
83
|
+
parts?: CustomPartsDeclaration;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Tag value, not a `Symbol`.
|
|
88
|
+
*
|
|
89
|
+
* A brand can end up with two physical copies of pz-theme resolved at once — a
|
|
90
|
+
* hoisted one beside a nested one, or the installed package beside the stale
|
|
91
|
+
* copy webpack keeps serving out of `.next`. Each copy would own a distinct
|
|
92
|
+
* `Symbol()`, so a definition made by one copy would read as an unwrapped
|
|
93
|
+
* component to the other, silently losing every option the developer declared.
|
|
94
|
+
* A string tag compares equal across the copy boundary, and it is greppable.
|
|
95
|
+
*/
|
|
96
|
+
export const CUSTOM_BLOCK_MARKER = 'pz-theme.custom-block';
|
|
97
|
+
|
|
98
|
+
export interface CustomBlockDefinition {
|
|
99
|
+
readonly $$pzCustomBlock: typeof CUSTOM_BLOCK_MARKER;
|
|
100
|
+
component: CustomBlockComponent;
|
|
101
|
+
options: CustomBlockOptions;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** What `registerCustomBlocks` accepts per entry. */
|
|
105
|
+
export type CustomBlockInput = CustomBlockDefinition | CustomBlockComponent;
|
|
106
|
+
|
|
107
|
+
// React.memo() and forwardRef() return objects rather than functions. A brand
|
|
108
|
+
// wrapping its block in either is ordinary React, so both have to pass.
|
|
109
|
+
const isRenderable = (value: unknown): value is CustomBlockComponent =>
|
|
110
|
+
typeof value === 'function' ||
|
|
111
|
+
(typeof value === 'object' && value !== null && '$$typeof' in value);
|
|
112
|
+
|
|
113
|
+
export const isCustomBlockDefinition = (
|
|
114
|
+
value: unknown
|
|
115
|
+
): value is CustomBlockDefinition =>
|
|
116
|
+
typeof value === 'object' &&
|
|
117
|
+
value !== null &&
|
|
118
|
+
(value as { $$pzCustomBlock?: unknown }).$$pzCustomBlock ===
|
|
119
|
+
CUSTOM_BLOCK_MARKER;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Wraps a component with the options the editor needs, and nothing else.
|
|
123
|
+
*
|
|
124
|
+
* It deliberately does NOT know its own name. `Component.name` is the obvious
|
|
125
|
+
* source and it is a trap: every production build minifies it, so a block
|
|
126
|
+
* authored as `PromoStrip` would register as `t` on the storefront and as
|
|
127
|
+
* `PromoStrip` in dev — the same composition would then render in one and be
|
|
128
|
+
* an unknown type in the other. The name lives at registration instead, keyed
|
|
129
|
+
* by the record, where minification cannot reach it.
|
|
130
|
+
*
|
|
131
|
+
* The documented shape is `export default defineCustomBlock(PromoStrip)`.
|
|
132
|
+
*/
|
|
133
|
+
export const defineCustomBlock = (
|
|
134
|
+
component: CustomBlockComponent,
|
|
135
|
+
options: CustomBlockOptions = {}
|
|
136
|
+
): CustomBlockDefinition => {
|
|
137
|
+
if (!isRenderable(component)) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`[pz-theme] defineCustomBlock() expects a React component, received ${typeof component}. ` +
|
|
140
|
+
`Pass the component itself, not the result of calling it.`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return Object.freeze({
|
|
145
|
+
$$pzCustomBlock: CUSTOM_BLOCK_MARKER,
|
|
146
|
+
component,
|
|
147
|
+
options
|
|
148
|
+
});
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Accepts the wrapper or a bare component.
|
|
153
|
+
*
|
|
154
|
+
* Someone will forget `defineCustomBlock` — the barrel entry looks identical
|
|
155
|
+
* either way — and a bare component is a complete block with no declared
|
|
156
|
+
* options, which is a shape the runtime collector already supports. Refusing
|
|
157
|
+
* it would buy nothing but a stack trace.
|
|
158
|
+
*/
|
|
159
|
+
export const normalizeCustomBlock = (
|
|
160
|
+
input: CustomBlockInput,
|
|
161
|
+
name: string
|
|
162
|
+
): CustomBlockDefinition => {
|
|
163
|
+
if (isCustomBlockDefinition(input)) return input;
|
|
164
|
+
|
|
165
|
+
if (isRenderable(input)) return defineCustomBlock(input);
|
|
166
|
+
|
|
167
|
+
throw new Error(
|
|
168
|
+
`[pz-theme] Custom block "${name}" is neither a React component nor a defineCustomBlock() result ` +
|
|
169
|
+
`(received ${input === null ? 'null' : typeof input}). ` +
|
|
170
|
+
`Check the barrel entry — a missing default export lands here as undefined.`
|
|
171
|
+
);
|
|
172
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { PropertyType } from './property-types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a `ThemeEditable` does to the element it wraps.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately NOT `PropertyType`. A kind answers "which prop carries the
|
|
7
|
+
* value", which is a render question; a `PropertyType` answers "which control
|
|
8
|
+
* does the panel draw", which is an editor question. They are one-to-one for
|
|
9
|
+
* three of the four members and one-to-TWO for `link` — a link is a label and
|
|
10
|
+
* a destination, and no single control edits both — so collapsing the two
|
|
11
|
+
* vocabularies would make `link` unrepresentable.
|
|
12
|
+
*/
|
|
13
|
+
export type EditableKind = 'text' | 'image' | 'link' | 'file';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Tag -> kind. The wrapped JSX is the only place a developer states intent, so
|
|
17
|
+
* this map is what lets `<ThemeEditable name="headline"><h2>…</h2></…>` need no
|
|
18
|
+
* `type` prop at all.
|
|
19
|
+
*
|
|
20
|
+
* Only tags whose value target is unambiguous are listed. A `<div>` is absent
|
|
21
|
+
* on purpose: it may hold text, or a background image, or nothing the author
|
|
22
|
+
* meant to be edited, and cloning it with the wrong prop would silently blank
|
|
23
|
+
* the element. An explicit `type` is the escape hatch for those.
|
|
24
|
+
*/
|
|
25
|
+
export const EDITABLE_TAG_KINDS: Readonly<Record<string, EditableKind>> = {
|
|
26
|
+
img: 'image',
|
|
27
|
+
|
|
28
|
+
h1: 'text',
|
|
29
|
+
h2: 'text',
|
|
30
|
+
h3: 'text',
|
|
31
|
+
h4: 'text',
|
|
32
|
+
h5: 'text',
|
|
33
|
+
h6: 'text',
|
|
34
|
+
p: 'text',
|
|
35
|
+
span: 'text',
|
|
36
|
+
strong: 'text',
|
|
37
|
+
em: 'text',
|
|
38
|
+
small: 'text',
|
|
39
|
+
label: 'text',
|
|
40
|
+
li: 'text',
|
|
41
|
+
blockquote: 'text',
|
|
42
|
+
|
|
43
|
+
a: 'link',
|
|
44
|
+
|
|
45
|
+
video: 'file',
|
|
46
|
+
source: 'file'
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const EDITABLE_KINDS: readonly EditableKind[] = [
|
|
50
|
+
'text',
|
|
51
|
+
'image',
|
|
52
|
+
'link',
|
|
53
|
+
'file'
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
export const isEditableKind = (value: unknown): value is EditableKind =>
|
|
57
|
+
typeof value === 'string' &&
|
|
58
|
+
(EDITABLE_KINDS as readonly string[]).includes(value);
|
|
59
|
+
|
|
60
|
+
/** Returns null for a tag with no unambiguous value target — see the map above. */
|
|
61
|
+
export const inferEditableKind = (tag: string): EditableKind | null =>
|
|
62
|
+
EDITABLE_TAG_KINDS[tag] ?? null;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The prop the resolved value is cloned onto.
|
|
66
|
+
*
|
|
67
|
+
* `link` writes `children` here and its href separately, because the href
|
|
68
|
+
* comes from a second setting with its own key.
|
|
69
|
+
*/
|
|
70
|
+
export const EDITABLE_KIND_TARGET: Readonly<
|
|
71
|
+
Record<EditableKind, 'children' | 'src'>
|
|
72
|
+
> = {
|
|
73
|
+
text: 'children',
|
|
74
|
+
image: 'src',
|
|
75
|
+
file: 'src',
|
|
76
|
+
link: 'children'
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The control the editor draws for each kind.
|
|
81
|
+
*
|
|
82
|
+
* Every member resolves to something in `SUPPORTED_PROPERTY_TYPES` — a kind
|
|
83
|
+
* that mapped to `richtext`, `multiselect` or `localetext` would render a
|
|
84
|
+
* control whose value can never reach the element (see property-types.ts).
|
|
85
|
+
*/
|
|
86
|
+
export const EDITABLE_KIND_PROPERTY_TYPES: Readonly<
|
|
87
|
+
Record<EditableKind, PropertyType>
|
|
88
|
+
> = {
|
|
89
|
+
text: 'text',
|
|
90
|
+
image: 'image',
|
|
91
|
+
file: 'file',
|
|
92
|
+
// The label half. The destination half is a separate `url` setting.
|
|
93
|
+
link: 'text'
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Suffix for a link's destination key: `cta` -> `ctaHref`.
|
|
98
|
+
*
|
|
99
|
+
* Capitalized so the derived key still satisfies `SETTING_KEY_PATTERN`, which
|
|
100
|
+
* allows camelCase but no separators.
|
|
101
|
+
*/
|
|
102
|
+
export const LINK_HREF_SUFFIX = 'Href';
|
|
103
|
+
|
|
104
|
+
export const hrefKeyFor = (key: string): string => `${key}${LINK_HREF_SUFFIX}`;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Prefix for the `data-part` a field element carries.
|
|
108
|
+
*
|
|
109
|
+
* Namespaced so a content field can never collide with a styling part the
|
|
110
|
+
* developer chose (`title`, `icon`, `panel` — all already published in live
|
|
111
|
+
* compositions), and so the editor can tell the two apart from the part name
|
|
112
|
+
* alone when it decides whether to offer the content control on the part.
|
|
113
|
+
*
|
|
114
|
+
* `sanitizePartName` (utils/part-styles.ts) keeps `[A-Za-z0-9_-]`, so both the
|
|
115
|
+
* hyphen here and a camelCase key survive into the generated selector intact.
|
|
116
|
+
*/
|
|
117
|
+
export const FIELD_PART_PREFIX = 'field-';
|
|
118
|
+
|
|
119
|
+
export const fieldPartName = (key: string): string =>
|
|
120
|
+
`${FIELD_PART_PREFIX}${key}`;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The custom block authoring API — everything a brand developer imports.
|
|
3
|
+
*
|
|
4
|
+
* THE BRAND BARREL IS IMPORTED FROM THE CLIENT ROOT ONLY — never from the
|
|
5
|
+
* server layout as well. `register.ts` carries `'use client'` because it must:
|
|
6
|
+
* it imports `blockRendererRegistry`, which pulls in all ~99 block modules, and
|
|
7
|
+
* most of those call hooks without a directive of their own. Reaching them from
|
|
8
|
+
* a server graph fails the brand's build at `accordion-block.tsx:1` with
|
|
9
|
+
* "You're importing a module that depends on `useState` into a React Server
|
|
10
|
+
* Component module". This was found by building a brand, not by a test — jest
|
|
11
|
+
* has no RSC graph, so the whole suite was green over it.
|
|
12
|
+
*
|
|
13
|
+
* Client-only registration loses nothing. `theme-block.tsx` is the single
|
|
14
|
+
* production reader of the renderer registry and is itself a client module, the
|
|
15
|
+
* server-side CSS generator never consults it, and SSR still resolves custom
|
|
16
|
+
* renderers because Next renders client components on the server through the
|
|
17
|
+
* client module graph.
|
|
18
|
+
*
|
|
19
|
+
* Use this deep path, not the package root. `@akinon/pz-theme` re-exports the
|
|
20
|
+
* same symbols but also re-exports `ThemePlaceholder`, which imports
|
|
21
|
+
* `server-only`, so importing `registerCustomBlocks` from the root fails a
|
|
22
|
+
* client module with "cannot be imported from a Client Component module".
|
|
23
|
+
*
|
|
24
|
+
* The shape a brand writes:
|
|
25
|
+
*
|
|
26
|
+
* // blocks/promo-strip.tsx
|
|
27
|
+
* import { defineCustomBlock, ThemeEditable, ThemeSlot, useThemeSetting }
|
|
28
|
+
* from '@akinon/pz-theme/src/custom';
|
|
29
|
+
* export default defineCustomBlock(PromoStrip);
|
|
30
|
+
*
|
|
31
|
+
* // blocks/index.ts — imported from the CLIENT ROOT only
|
|
32
|
+
* registerCustomBlocks('acme', { 'promo-strip': PromoStrip });
|
|
33
|
+
*
|
|
34
|
+
* The block NAME lives in that record, never in `Component.name`: every
|
|
35
|
+
* production build minifies the function name, so a block authored as
|
|
36
|
+
* `PromoStrip` would register as `t` on the storefront and as `PromoStrip` in
|
|
37
|
+
* dev, and one composition would render in one build and be an unknown type in
|
|
38
|
+
* the other.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
export { defineCustomBlock } from './define';
|
|
42
|
+
export type {
|
|
43
|
+
CustomBlockComponent,
|
|
44
|
+
CustomBlockDefinition,
|
|
45
|
+
CustomBlockInput,
|
|
46
|
+
CustomBlockOptions,
|
|
47
|
+
CustomSettingDeclaration,
|
|
48
|
+
CustomSettingOption,
|
|
49
|
+
CustomSlotDeclaration
|
|
50
|
+
} from './define';
|
|
51
|
+
|
|
52
|
+
export type { CustomPartDeclaration, CustomPartsDeclaration } from './parts';
|
|
53
|
+
|
|
54
|
+
export { registerCustomBlocks } from './register';
|
|
55
|
+
|
|
56
|
+
export { ThemeEditable } from './theme-editable';
|
|
57
|
+
export type { ThemeEditableProps } from './theme-editable';
|
|
58
|
+
export type { EditableKind } from './editable-tags';
|
|
59
|
+
|
|
60
|
+
export { ThemeSlot } from './theme-slot';
|
|
61
|
+
export type { ThemeSlotProps } from './theme-slot';
|
|
62
|
+
|
|
63
|
+
export { useThemeSetting } from './use-theme-setting';
|
|
64
|
+
export type {
|
|
65
|
+
ThemeSettingChoice,
|
|
66
|
+
ThemeSettingOptions
|
|
67
|
+
} from './use-theme-setting';
|
|
68
|
+
|
|
69
|
+
// What the editor bridge announces. Exported here rather than kept internal
|
|
70
|
+
// because a brand that mounts its own announce path needs the same payload the
|
|
71
|
+
// package would have sent, not a second construction of it.
|
|
72
|
+
export { getCustomCatalog } from './catalog';
|
|
73
|
+
export type {
|
|
74
|
+
CustomBlockDescriptor,
|
|
75
|
+
CustomCatalog,
|
|
76
|
+
CustomSettingDescriptor,
|
|
77
|
+
CustomSlotDescriptor,
|
|
78
|
+
PropertyType
|
|
79
|
+
} from './types';
|
|
80
|
+
|
|
81
|
+
// The predicate, not the pattern. Every consumer that has to treat a
|
|
82
|
+
// developer-authored type differently tests this one function, so the grammar
|
|
83
|
+
// stays in types.ts and cannot be re-derived slightly wrong somewhere else.
|
|
84
|
+
export { CUSTOM_TYPE_PREFIX, isCustomThemeType } from './types';
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The slot-node grammar, exported for the same reason the type predicate is:
|
|
88
|
+
* the editor and the generator each hold a mirror of it, and a mirror can be
|
|
89
|
+
* diffed against one exported constant instead of a literal re-typed slightly
|
|
90
|
+
* wrong. `isSlotNode` is the one spelling of the check.
|
|
91
|
+
*/
|
|
92
|
+
export { CUSTOM_SLOT_BLOCK_TYPE, SLOT_NAME_PROPERTY } from './types';
|
|
93
|
+
export { isSlotNode, slotNodeName } from './slot-nodes';
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Re-exported so a custom block is written against ONE module.
|
|
97
|
+
*
|
|
98
|
+
* `partAttrs` is what makes a developer's own markup addressable from the
|
|
99
|
+
* styling panel, so it is needed by essentially every block that has more than
|
|
100
|
+
* one element — and `ThemeEditable` already stamps its fields with it. Sending
|
|
101
|
+
* a developer to `@akinon/pz-theme/src/utils/part-styles` for the other half of
|
|
102
|
+
* the same job is the kind of split that ends in a hand-written
|
|
103
|
+
* `data-part="…"`, which `sanitizePartName` then has no chance to normalize.
|
|
104
|
+
*/
|
|
105
|
+
export { partAttrs } from '../utils/part-styles';
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { PART_STYLE_STATES } from '../utils/part-styles';
|
|
2
|
+
import { FIELD_PART_PREFIX } from './editable-tags';
|
|
3
|
+
import { PART_NAME_PATTERN } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Developer-declared styling parts.
|
|
7
|
+
*
|
|
8
|
+
* Until now `descriptor.parts` was filled ONLY from `ThemeEditable` fields, so
|
|
9
|
+
* an element a developer stamped with `partAttrs('menuRow')` never reached the
|
|
10
|
+
* properties panel — a brand writing a menu could style its rows in its own
|
|
11
|
+
* stylesheet and nowhere else. The editor side already knows what to do with a
|
|
12
|
+
* declared part (it appends the advanced CSS families, the geometry family and
|
|
13
|
+
* the per-part custom-CSS hatch to any part group it is given); what was
|
|
14
|
+
* missing was the declaration reaching it.
|
|
15
|
+
*
|
|
16
|
+
* States are the reason this is not just a list of names. An active menu row,
|
|
17
|
+
* an open accordion header, a hovered tile — those are the whole point of
|
|
18
|
+
* styling a part, and a part with no states can express none of them.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** What a part declares beyond existing. */
|
|
22
|
+
export interface CustomPartDeclaration {
|
|
23
|
+
/**
|
|
24
|
+
* Must be states the RENDERER can match — `PART_STYLE_STATES`. A state with
|
|
25
|
+
* no selector expression behind it produces a control that writes CSS which
|
|
26
|
+
* can never apply, which reads as "the editor does not save".
|
|
27
|
+
*/
|
|
28
|
+
states?: readonly string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Both spellings a developer writes.
|
|
33
|
+
*
|
|
34
|
+
* The bare array is the cheap one and it is genuinely enough for a part that
|
|
35
|
+
* only ever has one appearance. The record is the one that carries states. They
|
|
36
|
+
* cost one normalizer between them, so both are accepted rather than making
|
|
37
|
+
* every block pay the record's syntax for a part that needs nothing from it.
|
|
38
|
+
*/
|
|
39
|
+
export type CustomPartsDeclaration =
|
|
40
|
+
| readonly string[]
|
|
41
|
+
| Readonly<Record<string, CustomPartDeclaration | true>>;
|
|
42
|
+
|
|
43
|
+
export interface NormalizedPart {
|
|
44
|
+
name: string;
|
|
45
|
+
states: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One shape out of the two, in the order the developer wrote them.
|
|
50
|
+
*
|
|
51
|
+
* Object key order is insertion order for non-numeric string keys, so a record
|
|
52
|
+
* is as deterministic as an array here — which matters because this order is
|
|
53
|
+
* the order the part groups appear in the properties panel.
|
|
54
|
+
*/
|
|
55
|
+
export const normalizePartsDeclaration = (
|
|
56
|
+
declaration: CustomPartsDeclaration | undefined
|
|
57
|
+
): NormalizedPart[] => {
|
|
58
|
+
if (!declaration) return [];
|
|
59
|
+
|
|
60
|
+
if (Array.isArray(declaration)) {
|
|
61
|
+
return (declaration as readonly unknown[]).map((name) => ({
|
|
62
|
+
name: String(name),
|
|
63
|
+
states: []
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return Object.entries(
|
|
68
|
+
declaration as Record<string, CustomPartDeclaration | true>
|
|
69
|
+
).map(([name, value]) => ({
|
|
70
|
+
name,
|
|
71
|
+
states:
|
|
72
|
+
value === true || !value?.states ? [] : Array.from(new Set(value.states))
|
|
73
|
+
}));
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** The reason a declared part name is refused, or null. */
|
|
77
|
+
export const partNameError = (name: unknown): string | null => {
|
|
78
|
+
if (typeof name !== 'string' || !PART_NAME_PATTERN.test(name)) {
|
|
79
|
+
return (
|
|
80
|
+
`part name ${JSON.stringify(name)} could not survive into a CSS selector. ` +
|
|
81
|
+
`Use letters, digits, hyphens and underscores (max 64 characters) — sanitizePartName ` +
|
|
82
|
+
`strips everything else, so the part the catalog advertises and the part the stylesheet ` +
|
|
83
|
+
`matches would be different strings`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (name.startsWith(FIELD_PART_PREFIX)) {
|
|
88
|
+
return (
|
|
89
|
+
`part name "${name}" uses the "${FIELD_PART_PREFIX}" prefix, which is reserved for the parts ` +
|
|
90
|
+
`ThemeEditable derives from a field key. Declaring one collides with the auto-derived part ` +
|
|
91
|
+
`for that field — today if the field exists, and the moment it is added if it does not. Rename it`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return null;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** The reason a declared state is refused, or null. */
|
|
99
|
+
export const partStateError = (state: unknown): string | null => {
|
|
100
|
+
if (typeof state === 'string' && PART_STYLE_STATES.includes(state)) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
`state ${JSON.stringify(state)} has no selector behind it, so a control written into it ` +
|
|
106
|
+
`would emit CSS that can never match. Available: ${PART_STYLE_STATES.join(', ')}`
|
|
107
|
+
);
|
|
108
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The control vocabulary a custom block may declare, mirroring the editor's
|
|
3
|
+
* `PropertyType` union (`pz-dynamic-widget/src/config/base-config.ts:3-20`).
|
|
4
|
+
*
|
|
5
|
+
* Closed on purpose, and closed in the same way every comparable product
|
|
6
|
+
* closes it (Shopify 31, Webflow 9, Puck 11): a brand cannot introduce a
|
|
7
|
+
* control type, because the form is rendered by an editor bundle the brand
|
|
8
|
+
* does not ship and cannot extend. Two of the editor's members are absent
|
|
9
|
+
* here rather than merely unsupported:
|
|
10
|
+
*
|
|
11
|
+
* - `custom` needs a `customRender` FUNCTION, which cannot cross postMessage.
|
|
12
|
+
* - `group` is a container the panel derives, not a leaf a block declares.
|
|
13
|
+
*/
|
|
14
|
+
export const CUSTOM_PROPERTY_TYPES = [
|
|
15
|
+
'text',
|
|
16
|
+
'toggle',
|
|
17
|
+
'select',
|
|
18
|
+
'multiselect',
|
|
19
|
+
'slider',
|
|
20
|
+
'url',
|
|
21
|
+
'color',
|
|
22
|
+
'image',
|
|
23
|
+
'file',
|
|
24
|
+
'richtext',
|
|
25
|
+
'localetext',
|
|
26
|
+
'typography',
|
|
27
|
+
'icon'
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
export type PropertyType = (typeof CUSTOM_PROPERTY_TYPES)[number];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The subset whose value provably survives the full round trip — panel write,
|
|
34
|
+
* persistence, and resolution back into a prop.
|
|
35
|
+
*
|
|
36
|
+
* The three excluded members are excluded for reasons that were verified, not
|
|
37
|
+
* guessed:
|
|
38
|
+
*
|
|
39
|
+
* - `multiselect` and `localetext` are stored UNBUCKETED by the editor
|
|
40
|
+
* (`theme-basic-properties.tsx:1547-1548` passes arrays and locale objects
|
|
41
|
+
* through without the per-breakpoint wrapper). `getResponsiveValue` then
|
|
42
|
+
* rejects an array with a devWarn and returns the fallback, and a locale
|
|
43
|
+
* object has no `desktop` key so it does too. They can never deliver a
|
|
44
|
+
* value until a per-type resolver exists.
|
|
45
|
+
* - `richtext` has no safe renderer here. The allowlist sanitizer this repo
|
|
46
|
+
* already owns parses by assigning to `innerHTML` on a live element, so the
|
|
47
|
+
* sink is its first act — and pz-theme renders server-side, where there is
|
|
48
|
+
* no `document` at all.
|
|
49
|
+
*
|
|
50
|
+
* Declaring one of these throws at define time rather than shipping a control
|
|
51
|
+
* that silently shows the default forever.
|
|
52
|
+
*/
|
|
53
|
+
export const SUPPORTED_PROPERTY_TYPES: readonly PropertyType[] = [
|
|
54
|
+
'text',
|
|
55
|
+
'toggle',
|
|
56
|
+
'select',
|
|
57
|
+
'slider',
|
|
58
|
+
'url',
|
|
59
|
+
'color',
|
|
60
|
+
'image',
|
|
61
|
+
'file',
|
|
62
|
+
'typography',
|
|
63
|
+
'icon'
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
export const isSupportedPropertyType = (
|
|
67
|
+
type: unknown
|
|
68
|
+
): type is PropertyType =>
|
|
69
|
+
typeof type === 'string' &&
|
|
70
|
+
(SUPPORTED_PROPERTY_TYPES as readonly string[]).includes(type);
|