@akinon/pz-theme 2.0.164-rc.1 → 2.0.165-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.
Files changed (46) hide show
  1. package/CHANGELOG.md +16 -58
  2. package/package.json +4 -4
  3. package/src/__tests__/editor-locale-bridge.test.tsx +1 -32
  4. package/src/__tests__/theme-settings-defaults.test.tsx +395 -0
  5. package/src/__tests__/theme-text-defaults-code-owned.test.tsx +340 -0
  6. package/src/blocks/__tests__/breadcrumb-theme-separator.test.tsx +148 -0
  7. package/src/blocks/__tests__/social-icons-theme-fallback.test.tsx +268 -0
  8. package/src/blocks/__tests__/text-block.test.tsx +6 -34
  9. package/src/blocks/accordion-block.tsx +10 -0
  10. package/src/blocks/hotspot-block.tsx +6 -1
  11. package/src/blocks/listing-breadcrumb-block.tsx +10 -1
  12. package/src/blocks/order-detail-block.tsx +95 -113
  13. package/src/blocks/product-breadcrumb-block.tsx +9 -1
  14. package/src/blocks/social-glyphs.tsx +31 -0
  15. package/src/blocks/social-icons-list-block.tsx +72 -8
  16. package/src/blocks/text-block.tsx +15 -6
  17. package/src/chrome/__tests__/theme-page-up.test.tsx +522 -0
  18. package/src/chrome/theme-page-up.tsx +303 -0
  19. package/src/chrome/use-editor-theme-settings.ts +104 -0
  20. package/src/components/with-designer-features.tsx +27 -0
  21. package/src/custom/README.md +219 -100
  22. package/src/custom/__tests__/use-theme-setting.test.tsx +0 -97
  23. package/src/custom/collect.ts +9 -4
  24. package/src/custom/property-types.ts +15 -15
  25. package/src/custom/use-theme-setting.ts +5 -21
  26. package/src/editor-locale-bridge.tsx +11 -39
  27. package/src/index.ts +15 -0
  28. package/src/overlays/theme-modals.tsx +10 -0
  29. package/src/sections/__tests__/section-wrapper.test.tsx +76 -0
  30. package/src/sections/coupon-banner-section.tsx +15 -1
  31. package/src/sections/section-wrapper.tsx +7 -0
  32. package/src/sections/tabs-section.tsx +13 -0
  33. package/src/theme-placeholder-wrapper.tsx +50 -3
  34. package/src/theme-section.tsx +15 -0
  35. package/src/utils/__tests__/svg-image-source.test.ts +123 -0
  36. package/src/utils/__tests__/theme-settings-css.test.ts +801 -0
  37. package/src/utils/__tests__/theme-settings-readers.test.ts +213 -0
  38. package/src/utils/__tests__/theme-settings-resolver.test.ts +232 -0
  39. package/src/utils/index.ts +36 -1
  40. package/src/utils/svg-image-source.ts +84 -0
  41. package/src/utils/theme-settings-css.ts +717 -0
  42. package/src/utils/theme-settings-readers.ts +205 -0
  43. package/src/blocks/__tests__/order-line-actions.test.tsx +0 -54
  44. package/src/blocks/order-line-actions.ts +0 -45
  45. package/src/utils/__tests__/sanitize-rich-text.test.ts +0 -80
  46. package/src/utils/sanitize-rich-text.ts +0 -215
@@ -1,16 +1,31 @@
1
1
  # Custom blocks
2
2
 
3
- A brand developer writes a React component in their own repo and decides, at the point of use, what a merchant may change about it. The component appears in the theme editor's Add Block picker under **Brand blocks**, its declared fields are editable from the properties panel, and it can open a region the merchant fills with ordinary editor content.
4
-
5
- Nothing here is a schema kept alongside the component. The declaration _is_ the usage.
6
-
7
- **Writing a whole area rather than one element inside it?** See [`README-sections.md`](./README-sections.md). A custom _section_ owns its own band on the page: it gets the section root panel (geometry and content width), and it names the children that go inside it rather than opening a hole. A custom _block_ is the right shape for one element the merchant drops into a band they composed.
8
-
9
- **Want a complete, commented example?** See `examples/header/` at the root of the pz-next repository: one header written at six levels, from basic to advanced, each building on the previous one. They are reference only — not shipped in the npm package and never imported by an app.
3
+ A brand developer writes a React component in their own repo and decides, at the
4
+ point of use, what a merchant may change about it. The component appears in the
5
+ theme editor's Add Block picker under **Brand blocks**, its declared fields are
6
+ editable from the properties panel, and it can open a region the merchant fills
7
+ with ordinary editor content.
8
+
9
+ Nothing here is a schema kept alongside the component. The declaration *is* the
10
+ usage.
11
+
12
+ **Writing a whole area rather than one element inside it?** See
13
+ [`README-sections.md`](./README-sections.md). A custom *section* owns its own
14
+ band on the page: it gets the section root panel (geometry and content width),
15
+ and it names the children that go inside it rather than opening a hole. A custom
16
+ *block* is the right shape for one element the merchant drops into a band they
17
+ composed.
18
+
19
+ **Want a complete, commented example?** See `examples/header/` at the root of
20
+ the pz-next repository: one header written at six levels, from basic to
21
+ advanced, each building on the previous one. They are reference only — not
22
+ shipped in the npm package and never imported by an app.
10
23
 
11
24
  ## The shape
12
25
 
13
- A "Complete the look" row for a product page — the merchant retypes its heading, chooses how many products it shows, drops a campaign banner beside it, and restyles the product cards. One block, all four mechanisms.
26
+ A "Complete the look" row for a product page — the merchant retypes its
27
+ heading, chooses how many products it shows, drops a campaign banner beside it,
28
+ and restyles the product cards. One block, all four mechanisms.
14
29
 
15
30
  ```tsx
16
31
  // src/theme-blocks/complete-the-look.tsx
@@ -58,28 +73,19 @@ function CompleteTheLook() {
58
73
  export default defineCustomBlock(CompleteTheLook, {
59
74
  label: 'Complete the look',
60
75
  settings: [
61
- {
62
- key: 'productCount',
63
- type: 'slider',
64
- label: 'How many products',
65
- defaultValue: 4,
66
- min: 2,
67
- max: 8
68
- },
69
- {
70
- key: 'heading',
71
- type: 'text',
72
- label: 'Heading',
73
- defaultValue: 'Complete the look',
74
- editable: true
75
- }
76
+ { key: 'productCount', type: 'slider', label: 'How many products',
77
+ defaultValue: 4, min: 2, max: 8 },
78
+ { key: 'heading', type: 'text', label: 'Heading',
79
+ defaultValue: 'Complete the look', editable: true }
76
80
  ],
77
81
  slots: [{ name: 'campaign', label: 'Campaign banner' }],
78
82
  parts: { card: { states: ['hover'] }, price: {} }
79
83
  });
80
84
  ```
81
85
 
82
- The product data is the developer's business — it is fetched in the component and the merchant never sees it as a control. What the merchant gets is the heading, the count, the banner region and the card styling.
86
+ The product data is the developer's business — it is fetched in the component
87
+ and the merchant never sees it as a control. What the merchant gets is the
88
+ heading, the count, the banner region and the card styling.
83
89
 
84
90
  ```ts
85
91
  // src/theme-blocks/index.ts
@@ -95,32 +101,51 @@ import './configure-page-context';
95
101
  import '../../theme-blocks';
96
102
  ```
97
103
 
98
- The block name lives in the record passed to `registerCustomBlocks`, never in `Component.name`: a production build minifies function names, so a block authored as `CompleteTheLook` would register as `t` on the storefront and as `CompleteTheLook` in dev, and one composition would render in one build and be an unknown type in the other.
104
+ The block name lives in the record passed to `registerCustomBlocks`, never in
105
+ `Component.name`: a production build minifies function names, so a block
106
+ authored as `CompleteTheLook` would register as `t` on the storefront and as
107
+ `CompleteTheLook` in dev, and one composition would render in one build and be an
108
+ unknown type in the other.
99
109
 
100
110
  ## Import it from the client root only
101
111
 
102
- Not from the server layout as well. `registerCustomBlocks` is a client module because it reaches the renderer registry, which imports every block module — and most of those call hooks without a directive of their own, because the only path that has ever reached them is a client one. Importing it from a server layout fails the brand's build outright.
112
+ Not from the server layout as well. `registerCustomBlocks` is a client module
113
+ because it reaches the renderer registry, which imports every block module — and
114
+ most of those call hooks without a directive of their own, because the only path
115
+ that has ever reached them is a client one. Importing it from a server layout
116
+ fails the brand's build outright.
103
117
 
104
- Client-only registration loses nothing: `ThemeBlock` is the single production reader of the registry and is itself a client module, the server-side CSS generator never consults it, and SSR still resolves custom renderers because Next renders client components on the server through the client graph.
118
+ Client-only registration loses nothing: `ThemeBlock` is the single production
119
+ reader of the registry and is itself a client module, the server-side CSS
120
+ generator never consults it, and SSR still resolves custom renderers because
121
+ Next renders client components on the server through the client graph.
105
122
 
106
- Use the deep path `@akinon/pz-theme/src/custom`, not the package root — the root also re-exports `ThemePlaceholder`, which imports `server-only`.
123
+ Use the deep path `@akinon/pz-theme/src/custom`, not the package root — the root
124
+ also re-exports `ThemePlaceholder`, which imports `server-only`.
107
125
 
108
126
  ## `ThemeEditable`
109
127
 
110
128
  Makes one wrapped element merchant-editable. The wrapped tag decides the control:
111
129
 
112
130
  | Wrapped element | Control |
113
- | --- | --- |
131
+ |---|---|
114
132
  | `<img>` | image picker |
115
133
  | `<h1>`–`<h6>`, `<p>`, `<span>`, `<strong>` | text box |
116
134
  | `<a>` | two controls — link text and href |
117
135
  | `<video>` | file picker |
118
136
 
119
- The authored JSX is the default value: `<ThemeEditable name="heading"><h2>Complete the look</h2></ThemeEditable>` declares a text field whose default is `Complete the look`, because there is nowhere else for a developer to state it.
137
+ The authored JSX is the default value: `<ThemeEditable name="heading"><h2>Complete
138
+ the look</h2></ThemeEditable>` declares a text field whose default is
139
+ `Complete the look`, because there is nowhere else for a developer to state it.
120
140
 
121
- At render the merchant's value is applied by re-rendering the child through React — never by writing to the DOM. Three shapes are refused rather than guessed, and the message says which: a custom component instead of a host element, a bare expression with no element, and an ambiguous multi-child element. `type=` is the explicit escape.
141
+ At render the merchant's value is applied by re-rendering the child through
142
+ React — never by writing to the DOM. Three shapes are refused rather than
143
+ guessed, and the message says which: a custom component instead of a host
144
+ element, a bare expression with no element, and an ambiguous multi-child
145
+ element. `type=` is the explicit escape.
122
146
 
123
- The authored text is written in the store's default language. Its other languages go in `translations`:
147
+ The authored text is written in the store's default language. Its other
148
+ languages go in `translations`:
124
149
 
125
150
  ```tsx
126
151
  <ThemeEditable name="label" translations={{ en: 'MENU' }}>
@@ -128,33 +153,63 @@ The authored text is written in the store's default language. Its other language
128
153
  </ThemeEditable>
129
154
  ```
130
155
 
131
- The editor is told `MENÜ` only. An English page shows `MENU` for as long as the field still says `MENÜ`, including the copy the editor seeds when the block is placed. Once the merchant types their own text, it shows as typed. This is the same rule as a per-language `useThemeSetting` default (below); a setting read for the same key, such as an accessible name, can share one constant with it.
156
+ The editor is told `MENÜ` only. An English page shows `MENU` for as long as
157
+ the field still says `MENÜ`, including the copy the editor seeds when the
158
+ block is placed. Once the merchant types their own text, it shows as typed.
159
+ This is the same rule as a per-language `useThemeSetting` default (below); a
160
+ setting read for the same key, such as an accessible name, can share one
161
+ constant with it.
132
162
 
133
163
  ## Links and the page's language
134
164
 
135
- Storefront paths are stored and authored without a locale: `/yatak-odasi/`, never `/en/yatak-odasi/`. On an English page that bare path is the Turkish site, so a link that goes out as written takes the shopper out of English. A visible `next/link` also prefetches it, which makes the middleware set `pz-locale=tr`, and every request after that comes back Turkish. You do not write the locale yourself; there are three places it goes on for you:
165
+ Storefront paths are stored and authored without a locale: `/yatak-odasi/`,
166
+ never `/en/yatak-odasi/`. On an English page that bare path is the Turkish
167
+ site, so a link that goes out as written takes the shopper out of English. A
168
+ visible `next/link` also prefetches it, which makes the middleware set
169
+ `pz-locale=tr`, and every request after that comes back Turkish. You do not
170
+ write the locale yourself; there are three places it goes on for you:
136
171
 
137
172
  | What you render | What to use |
138
- | --- | --- |
173
+ |---|---|
139
174
  | The `<a>` that `ThemeEditable` wraps | Nothing: the locale is added to its href, whether the merchant set it or you did |
140
175
  | Any other link | `ThemeLink` in place of `next/link`, with the same props |
141
176
  | A bare string: a plain `<a>`, `router.push`, a url read off commerce data | `const localize = useLocalizeHref();` then `localize(url)` |
142
177
 
143
- `ThemeLink` prefetches nothing unless you pass `prefetch`. Next otherwise fetches a link's page the moment it scrolls into view, and because the locale lives in a cookie that any request to an unprefixed path rewrites, one link that slips past these rules would switch the whole session's language without anyone clicking it.
144
-
145
- All three come from `@akinon/pz-theme/src/custom`. An `<a>` nested deeper inside a `ThemeEditable` is not touched — that one is a link of yours, so it takes the row it belongs to above.
146
-
147
- A full URL to this same storefront counts as a path, `www.` or not: merchants paste the address bar, and `https://www.zsazsazsu.com.tr/yeni-urunler/` on an English page becomes `/en/yeni-urunler/` — which also lets the router take it without a page load. Addresses with no localized copy stay as they are: `/api` and `/_next`, and anything ending in a file (`/files/kvkk.pdf`, `/manifest.json`). So do external, `#hash`, `?query` and empty hrefs, and the OAuth `/{provider}/login/` routes.
148
-
149
- No locale is added when the URL strategy hides the page's own: the default locale under `hide-default-locale`, every locale under `subdomain` and `hide-all-locales`. To compare an address with the page's own, localize the stored side rather than stripping the locale off the current one: `localize(node.url) === pathname`.
178
+ `ThemeLink` prefetches nothing unless you pass `prefetch`. Next otherwise
179
+ fetches a link's page the moment it scrolls into view, and because the locale
180
+ lives in a cookie that any request to an unprefixed path rewrites, one link
181
+ that slips past these rules would switch the whole session's language without
182
+ anyone clicking it.
183
+
184
+ All three come from `@akinon/pz-theme/src/custom`. An `<a>` nested deeper
185
+ inside a `ThemeEditable` is not touched — that one is a link of yours, so it
186
+ takes the row it belongs to above.
187
+
188
+ A full URL to this same storefront counts as a path, `www.` or not: merchants
189
+ paste the address bar, and `https://www.zsazsazsu.com.tr/yeni-urunler/` on an
190
+ English page becomes `/en/yeni-urunler/` — which also lets the router take it
191
+ without a page load. Addresses with no localized copy stay as they are: `/api`
192
+ and `/_next`, and anything ending in a file (`/files/kvkk.pdf`,
193
+ `/manifest.json`). So do external, `#hash`, `?query` and empty hrefs, and the
194
+ OAuth `/{provider}/login/` routes.
195
+
196
+ No locale is added when the URL strategy hides the page's own: the default
197
+ locale under `hide-default-locale`, every locale under `subdomain` and
198
+ `hide-all-locales`. To compare an address with the page's own, localize the
199
+ stored side rather than stripping the locale off the current one:
200
+ `localize(node.url) === pathname`.
150
201
 
151
202
  ## `useThemeSetting`
152
203
 
153
- For values with no on-screen representation — how many products to fetch, which category, whether a carousel auto-advances, which side an image sits on. Without it every variation becomes a separate block in the picker.
204
+ For values with no on-screen representation — how many products to fetch, which
205
+ category, whether a carousel auto-advances, which side an image sits on. Without
206
+ it every variation becomes a separate block in the picker.
154
207
 
155
- React's own rule that hooks are called unconditionally is doing useful work here: the language enforces that the declaration set is static.
208
+ React's own rule that hooks are called unconditionally is doing useful work
209
+ here: the language enforces that the declaration set is static.
156
210
 
157
- A `text` default can be written per language, and the hook still returns one string, the entry for the page's locale:
211
+ A `text` default can be written per language, and the hook still returns one
212
+ string, the entry for the page's locale:
158
213
 
159
214
  ```tsx
160
215
  const label = useThemeSetting('label', {
@@ -164,59 +219,89 @@ const label = useThemeSetting('label', {
164
219
  });
165
220
  ```
166
221
 
167
- The editor is only told the default language's entry, so a static `settings` declaration keeps that one plain string (`defaultValue: 'MENÜ'`). The translation applies for as long as the stored value is still that default, including the copy the editor seeds when the block is placed. Once the merchant types their own text, it shows as typed in every language. A text the merchant can translate themselves needs the `localetext` control, which a custom setting cannot declare yet: this package refuses the type (see `collect.ts`) because the editor stores such a value as a locale map with no breakpoint key, and the editor refuses it in a custom catalog for the same reason.
168
-
169
- A `richtext` setting gives the merchant the editor's rich text control and comes back as one HTML string for the page's language, already passed through `sanitizeRichText` (a DOM-free allowlist: paragraphs, headings, lists, emphasis, colour spans and safe links). The control stores the default language's text as a plain string and a locale map (`{ tr: '<p>…</p>', en: '<p>…</p>' }`) once another language has text of its own; the hook reads the page's language, then the default language, then the plain string, so a language the merchant has not written shows the default one. Render it with `dangerouslySetInnerHTML`; the default is written as HTML too:
170
-
171
- ```tsx
172
- const html = useThemeSetting('body', {
173
- type: 'richtext',
174
- label: 'Body',
175
- default: '<p>Sipariş <strong>bugün</strong> kapınızda.</p>'
176
- });
177
-
178
- <div dangerouslySetInnerHTML={{ __html: html }} />;
179
- ```
222
+ The editor is only told the default language's entry, so a static `settings`
223
+ declaration keeps that one plain string (`defaultValue: 'MENÜ'`). The
224
+ translation applies for as long as the stored value is still that default,
225
+ including the copy the editor seeds when the block is placed. Once the
226
+ merchant types their own text, it shows as typed in every language. A text
227
+ the merchant can translate themselves needs the `localetext` control, which a
228
+ custom setting cannot declare yet: this package refuses the type (see
229
+ `collect.ts`) because the editor stores such a value as a locale map with no
230
+ breakpoint key, and the editor refuses it in a custom catalog for the same
231
+ reason.
180
232
 
181
233
  ## `ThemeSlot`
182
234
 
183
- A merchant-fillable region. A block may open several, and each is filled independently.
184
-
185
- **Each slot is a real child node.** A declared slot materializes as one reserved-type child of the custom block (`custom-slot`, carrying its name in `properties.slotName`), and `<ThemeSlot name="campaign" />` renders THAT node's children. That is what makes several slots possible without touching how insertion is addressed: the editor already inserts at `(placeholderId, sectionId, parentBlockId, index)`, and a slot that is a node is simply the parent. A block declaring three slots gets three rows in the layer tree.
186
-
187
- `<ThemeSlot />` with no name opens the block's single slot whatever it is called, so every block written against the original single-slot API keeps working unchanged. A block declaring several NAMED slots has nothing for a bare slot to resolve to, and it is refused with the names listed. Two `<ThemeSlot name="campaign" />` in one component are a real duplicate — both resolve to the same node — so the second is refused: its children would render twice and two wrappers would answer to one block id.
188
-
189
- A slot whose node does not exist yet is not an error: it draws the empty band in the designer and renders nothing live. The editor creates the nodes when the block is inserted and again on every theme load, so a block already sitting in a composition gets the rows for a slot the component gained later — insert alone would leave those regions unreachable, because the merchant's only route into a region is the add-block affordance on its tree row.
190
-
191
- Children sitting directly on the block (the pre-node shape) are the unnamed slot's, and only the unnamed slot's. The node wins that lookup once it exists, so the editor MOVES those children into the unnamed node when it creates it rather than appending the node beside them — appending would leave live content saved, listed in the tree, and drawn nowhere. A block whose type declares only named slots has nothing to move them into, so they stay where they are and stop rendering, which is what changing the component means.
235
+ A merchant-fillable region. A block may open several, and each is filled
236
+ independently.
237
+
238
+ **Each slot is a real child node.** A declared slot materializes as one
239
+ reserved-type child of the custom block (`custom-slot`, carrying its name in
240
+ `properties.slotName`), and `<ThemeSlot name="campaign" />` renders THAT node's
241
+ children. That is what makes several slots possible without touching how
242
+ insertion is addressed: the editor already inserts at `(placeholderId,
243
+ sectionId, parentBlockId, index)`, and a slot that is a node is simply the
244
+ parent. A block declaring three slots gets three rows in the layer tree.
245
+
246
+ `<ThemeSlot />` with no name opens the block's single slot whatever it is
247
+ called, so every block written against the original single-slot API keeps
248
+ working unchanged. A block declaring several NAMED slots has nothing for a bare
249
+ slot to resolve to, and it is refused with the names listed. Two
250
+ `<ThemeSlot name="campaign" />` in one component are a real duplicate — both resolve
251
+ to the same node — so the second is refused: its children would render twice and
252
+ two wrappers would answer to one block id.
253
+
254
+ A slot whose node does not exist yet is not an error: it draws the empty band in
255
+ the designer and renders nothing live. The editor creates the nodes when the
256
+ block is inserted and again on every theme load, so a block already sitting in a
257
+ composition gets the rows for a slot the component gained later — insert alone
258
+ would leave those regions unreachable, because the merchant's only route into a
259
+ region is the add-block affordance on its tree row.
260
+
261
+ Children sitting directly on the block (the pre-node shape) are the unnamed
262
+ slot's, and only the unnamed slot's. The node wins that lookup once it exists,
263
+ so the editor MOVES those children into the unnamed node when it creates it
264
+ rather than appending the node beside them — appending would leave live content
265
+ saved, listed in the tree, and drawn nowhere. A block whose type declares only
266
+ named slots has nothing to move them into, so they stay where they are and stop
267
+ rendering, which is what changing the component means.
192
268
 
193
269
  ### Narrowing what a slot takes
194
270
 
195
271
  ```ts
196
272
  slots: [
197
- {
198
- name: 'campaign',
199
- label: 'Campaign banner',
200
- accepts: ['image', 'text'],
201
- max: 2
202
- },
273
+ { name: 'campaign', label: 'Campaign banner', accepts: ['image', 'text'], max: 2 },
203
274
  { name: 'sizeGuide', label: 'Size guide' }
204
- ];
275
+ ]
205
276
  ```
206
277
 
207
- `accepts` limits the block types the merchant may put in the region and `max` limits how many; omit either for "anything" and "as many as you like". The editor enforces both where a block is WRITTEN into the composition, not only where it is offered, so a drag from elsewhere in the tree obeys them too.
278
+ `accepts` limits the block types the merchant may put in the region and `max`
279
+ limits how many; omit either for "anything" and "as many as you like". The
280
+ editor enforces both where a block is WRITTEN into the composition, not only
281
+ where it is offered, so a drag from elsewhere in the tree obeys them too.
208
282
 
209
- Neither is read by pz-theme — the renderer draws whatever the composition holds — but both are checked at declare time, because they are refused on arrival and the editor refuses the WHOLE catalog rather than one slot. `accepts: []` (a region nothing could ever go in) and a `max` that is not a positive whole number would otherwise empty a brand's entire palette, with the only explanation in a repo the developer is not working in.
283
+ Neither is read by pz-theme — the renderer draws whatever the composition holds
284
+ — but both are checked at declare time, because they are refused on arrival and
285
+ the editor refuses the WHOLE catalog rather than one slot. `accepts: []` (a
286
+ region nothing could ever go in) and a `max` that is not a positive whole number
287
+ would otherwise empty a brand's entire palette, with the only explanation in a
288
+ repo the developer is not working in.
210
289
 
211
290
  ## Detecting the editor canvas
212
291
 
213
- `useCustomBlockContext()` returns the context the block or section renders in, or `null` outside one. Its `isDesigner` is what to branch on when the canvas needs something the live site must not do — keep a menu open so the merchant can style it, or stop a link from navigating on click.
292
+ `useCustomBlockContext()` returns the context the block or section renders in,
293
+ or `null` outside one. Its `isDesigner` is what to branch on when the canvas
294
+ needs something the live site must not do — keep a menu open so the merchant
295
+ can style it, or stop a link from navigating on click.
214
296
 
215
297
  ```ts
216
298
  const isDesigner = useCustomBlockContext()?.isDesigner ?? false;
217
299
  ```
218
300
 
219
- `isDesigner` means "the editing affordances are on", not "the editor is attached". The editor's **Use preview** toggle turns it off while the editor stays open, so the canvas behaves like the live site: whatever you held open closes and links navigate again.
301
+ `isDesigner` means "the editing affordances are on", not "the editor is
302
+ attached". The editor's **Use preview** toggle turns it off while the editor
303
+ stays open, so the canvas behaves like the live site: whatever you held open
304
+ closes and links navigate again.
220
305
 
221
306
  ## Reading the row a block is placed in
222
307
 
@@ -240,7 +325,11 @@ package root is not importable from a client module.
240
325
 
241
326
  ## Reporting what happened
242
327
 
243
- A custom unit can report a moment — a strip was seen, a lookbook was opened, a tab was changed — and the brand decides in its own repo what that moment means to GA4, Insider or its own service. The theme never names an analytics event and never pushes to a tag manager, which is why one renderer can serve brands whose schemas disagree.
328
+ A custom unit can report a moment — a strip was seen, a lookbook was opened, a
329
+ tab was changed — and the brand decides in its own repo what that moment means
330
+ to GA4, Insider or its own service. The theme never names an analytics event and
331
+ never pushes to a tag manager, which is why one renderer can serve brands whose
332
+ schemas disagree.
244
333
 
245
334
  ```tsx
246
335
  import { useThemeSignal } from '@akinon/pz-theme/src/custom';
@@ -258,7 +347,9 @@ const LookbookStrip = () => {
258
347
  };
259
348
  ```
260
349
 
261
- For the "first time it becomes true" case — a section seen, a promo shown — use the latching form. A `null` key means "not ready yet" and reports nothing, which is what stops a unit reporting before the data it describes has resolved:
350
+ For the "first time it becomes true" case — a section seen, a promo shown — use
351
+ the latching form. A `null` key means "not ready yet" and reports nothing, which
352
+ is what stops a unit reporting before the data it describes has resolved:
262
353
 
263
354
  ```tsx
264
355
  useThemeSignalOnce({
@@ -268,7 +359,8 @@ useThemeSignalOnce({
268
359
  });
269
360
  ```
270
361
 
271
- Outside React — a module-level bus, a delegated document listener — call the primitive directly:
362
+ Outside React — a module-level bus, a delegated document listener — call the
363
+ primitive directly:
272
364
 
273
365
  ```ts
274
366
  import { emitThemeSignal } from '@akinon/pz-theme/src/custom';
@@ -293,42 +385,69 @@ useEffect(
293
385
 
294
386
  Four things the emitter does so a call site does not have to:
295
387
 
296
- - **It is silent while the editor is attached, browse mode included.** It reads the editor handshake flag itself, so do NOT wrap a report in an `isDesigner` check — that prop means "the editing affordances are on", goes false under **Use preview**, and a report guarded on it reports a designer's clicks as a shopper's.
297
- - **`{ key, once: true }` is the latch**, reset on client-side navigation. A repeat inside the same page view is a no-op.
298
- - **A ceiling** — 24 per key, 200 per page view. An autoplaying carousel would otherwise emit continuously, billed to whatever the brand forwards to.
299
- - **The payload is closed to flat scalars** and sanitized at emit. A product object, a DOM node or shopper-typed text is dropped with a dev warning, which is how the no-PII rule is enforced rather than reviewed.
300
-
301
- A brand's own moment must be namespaced `custom.<something>`, so it can never collide with a name this package adds later and a bridge can tell the two apart. The package's own names are listed in `THEME_SIGNAL_NAMES`.
388
+ - **It is silent while the editor is attached, browse mode included.** It reads
389
+ the editor handshake flag itself, so do NOT wrap a report in an `isDesigner`
390
+ check — that prop means "the editing affordances are on", goes false under
391
+ **Use preview**, and a report guarded on it reports a designer's clicks as a
392
+ shopper's.
393
+ - **`{ key, once: true }` is the latch**, reset on client-side navigation. A
394
+ repeat inside the same page view is a no-op.
395
+ - **A ceiling** — 24 per key, 200 per page view. An autoplaying carousel would
396
+ otherwise emit continuously, billed to whatever the brand forwards to.
397
+ - **The payload is closed to flat scalars** and sanitized at emit. A product
398
+ object, a DOM node or shopper-typed text is dropped with a dev warning, which
399
+ is how the no-PII rule is enforced rather than reviewed.
400
+
401
+ A brand's own moment must be namespaced `custom.<something>`, so it can never
402
+ collide with a name this package adds later and a bridge can tell the two apart.
403
+ The package's own names are listed in `THEME_SIGNAL_NAMES`.
302
404
 
303
405
  ## Styling parts
304
406
 
305
- The `field-<key>` parts come free from `ThemeEditable`. Everything else a block draws is offered to the properties panel only if it is DECLARED:
407
+ The `field-<key>` parts come free from `ThemeEditable`. Everything else a block
408
+ draws is offered to the properties panel only if it is DECLARED:
306
409
 
307
410
  ```ts
308
- parts: ['row', 'tile']; // the cheap form
309
- parts: {
310
- row: {
311
- states: ['hover', 'open'];
312
- }
313
- } // when appearance depends on state
411
+ parts: ['row', 'tile'] // the cheap form
412
+ parts: { row: { states: ['hover', 'open'] } } // when appearance depends on state
314
413
  ```
315
414
 
316
- Declare the same name you stamp with `partAttrs('row')`, or the panel offers a control nothing on the page answers to. Only states the renderer can actually match are accepted (`PART_STYLE_STATES` in `utils/part-styles.ts`) — a state with no selector emits CSS that can never apply, so it is refused with the available list. The `field-` prefix is reserved and cannot be declared.
415
+ Declare the same name you stamp with `partAttrs('row')`, or the panel offers a
416
+ control nothing on the page answers to. Only states the renderer can actually
417
+ match are accepted (`PART_STYLE_STATES` in `utils/part-styles.ts`) — a state
418
+ with no selector emits CSS that can never apply, so it is refused with the
419
+ available list. The `field-` prefix is reserved and cannot be declared.
317
420
 
318
421
  ## Declare `settings` and `slots`
319
422
 
320
- Runtime collection exists as a fallback, but it only runs on a type's first render and never re-announces — so a block that declares nothing reaches the editor with an empty panel until the editor is reloaded. Declare them.
423
+ Runtime collection exists as a fallback, but it only runs on a type's first
424
+ render and never re-announces — so a block that declares nothing reaches the
425
+ editor with an empty panel until the editor is reloaded. Declare them.
321
426
 
322
427
  ## What the merchant still cannot restyle
323
428
 
324
- A field's own `field-<key>` part carries no states: declaring one is refused, because the `field-` prefix belongs to `ThemeEditable`. To give a field element a hover appearance, wrap it in an element you stamp and declare yourself.
429
+ A field's own `field-<key>` part carries no states: declaring one is refused,
430
+ because the `field-` prefix belongs to `ThemeEditable`. To give a field element
431
+ a hover appearance, wrap it in an element you stamp and declare yourself.
325
432
 
326
433
  ## Limits
327
434
 
328
- Forty types per build, twenty-four settings, eight slots and thirty-two parts per type, and a 256 KB catalog. The whole catalog is posted to the editor on every placeholder announce, which is what the caps are protecting.
435
+ Forty types per build, twenty-four settings, eight slots and thirty-two parts
436
+ per type, and a 256 KB catalog. The whole catalog is posted to the editor on
437
+ every placeholder announce, which is what the caps are protecting.
329
438
 
330
- Every one of them is refused at declare time, because the editor's answer to an over-cap catalog is to drop ALL of it — a single block over the parts cap costs the build every block in the palette. The one cap a declaration cannot fully guarantee is the parts cap: an undeclared `ThemeEditable` discovered at render time adds a `field-<key>` part after registration counted them, and if that would cross the cap the part is dropped with a warning rather than the catalog being put over. The field keeps its control in the Content tab and loses only its styling group.
439
+ Every one of them is refused at declare time, because the editor's answer to an
440
+ over-cap catalog is to drop ALL of it — a single block over the parts cap costs
441
+ the build every block in the palette. The one cap a declaration cannot fully
442
+ guarantee is the parts cap: an undeclared `ThemeEditable` discovered at render
443
+ time adds a `field-<key>` part after registration counted them, and if that
444
+ would cross the cap the part is dropped with a warning rather than the catalog
445
+ being put over. The field keeps its control in the Content tab and loses only
446
+ its styling group.
331
447
 
332
448
  ## Failure behaviour
333
449
 
334
- A composition naming a type this build does not register renders nothing on the live site and a labelled card in the designer, and the editor shows the block as an orphan row rather than dropping it — its stored properties survive a save untouched, so a type that comes back finds its content where it left it.
450
+ A composition naming a type this build does not register renders nothing on the
451
+ live site and a labelled card in the designer, and the editor shows the block as
452
+ an orphan row rather than dropping it — its stored properties survive a save
453
+ untouched, so a type that comes back finds its content where it left it.
@@ -367,100 +367,3 @@ describe('useThemeSetting — invalid declarations throw in development', () =>
367
367
  ).toThrow(/not one of the editor's control types/);
368
368
  });
369
369
  });
370
-
371
- describe('useThemeSetting — richtext', () => {
372
- it('reads the breakpoint’s html and strips what the allowlist refuses', () => {
373
- const { result } = renderHook(
374
- () =>
375
- useThemeSetting('body', {
376
- type: 'richtext',
377
- label: 'Body',
378
- default: '<p>default</p>'
379
- }),
380
- {
381
- wrapper: wrapperFor(
382
- contextValue({
383
- currentBreakpoint: 'mobile',
384
- properties: {
385
- body: {
386
- desktop: '<p>desktop</p>',
387
- mobile:
388
- '<p onclick="x()">mobile <script>x</script><b>bold</b></p>'
389
- }
390
- }
391
- })
392
- )
393
- }
394
- );
395
-
396
- expect(result.current).toBe('<p>mobile <b>bold</b></p>');
397
- });
398
-
399
- it('sanitizes the authored default too', () => {
400
- const { result } = renderHook(
401
- () =>
402
- useThemeSetting('body', {
403
- type: 'richtext',
404
- label: 'Body',
405
- default: '<p><img src=x onerror=alert(1)>safe</p>'
406
- }),
407
- { wrapper: wrapperFor(contextValue()) }
408
- );
409
-
410
- expect(result.current).toBe('<p>safe</p>');
411
- });
412
-
413
- describe('written per language by the rich text control', () => {
414
- // eslint-disable-next-line @typescript-eslint/no-var-requires
415
- const hooks = require('@akinon/next/hooks');
416
- let localizationSpy: jest.SpyInstance;
417
-
418
- const onPage = (locale: string) => {
419
- const original = hooks.useLocalization;
420
- localizationSpy = jest
421
- .spyOn(hooks, 'useLocalization')
422
- .mockImplementation(() => ({
423
- ...original(),
424
- locale,
425
- defaultLocaleValue: 'tr',
426
- locales: [{ value: 'tr' }, { value: 'en' }]
427
- }));
428
- };
429
-
430
- afterEach(() => localizationSpy?.mockRestore());
431
-
432
- const read = (body: unknown) =>
433
- renderHook(
434
- () =>
435
- useThemeSetting('body', {
436
- type: 'richtext',
437
- label: 'Body',
438
- default: '<p>default</p>'
439
- }),
440
- { wrapper: wrapperFor(contextValue({ properties: { body } })) }
441
- ).result.current;
442
-
443
- it('reads the page’s language out of the locale map and sanitizes it', () => {
444
- onPage('tr');
445
-
446
- expect(
447
- read({
448
- en: '<p>old</p>',
449
- tr: '<p>sss <b>bold</b><script>x()</script></p>'
450
- })
451
- ).toBe('<p>sss <b>bold</b></p>');
452
- });
453
-
454
- it('falls back to the default language’s entry', () => {
455
- onPage('en');
456
-
457
- expect(read({ tr: '<p>türkçe</p>' })).toBe('<p>türkçe</p>');
458
- });
459
-
460
- it('still reads a breakpoint map written in the default language', () => {
461
- onPage('tr');
462
-
463
- expect(read({ desktop: '<p>desktop</p>' })).toBe('<p>desktop</p>');
464
- });
465
- });
466
- });
@@ -42,7 +42,9 @@ import type { CustomSettingDescriptor, CustomSlotDescriptor } from './types';
42
42
  * through the one function below.
43
43
  */
44
44
  export type ThemeSettingChoice =
45
- string | number | { label?: string; value: unknown };
45
+ | string
46
+ | number
47
+ | { label?: string; value: unknown };
46
48
 
47
49
  /**
48
50
  * Normalizes the shorthand into the `{label, value}` pairs the editor's select
@@ -78,8 +80,8 @@ export const normalizeSettingChoices = (
78
80
 
79
81
  /**
80
82
  * Why a control type is refused, named per type. "Unsupported" tells a
81
- * developer nothing actionable; each of these fails for its own reason and
82
- * each has a different way out.
83
+ * developer nothing actionable; each of these three fails for its own reason
84
+ * and each has a different way out.
83
85
  */
84
86
  const UNSUPPORTED_TYPE_REASON: Record<string, string> = {
85
87
  multiselect:
@@ -88,7 +90,10 @@ const UNSUPPORTED_TYPE_REASON: Record<string, string> = {
88
90
  'Use "select" and store one value until a per-type resolver exists',
89
91
  localetext:
90
92
  'the editor stores a localetext value as a locale map with no "desktop" key, so getResponsiveValue ' +
91
- 'falls back and the block would read its authored default forever. Use "text" and localize in the component'
93
+ 'falls back and the block would read its authored default forever. Use "text" and localize in the component',
94
+ richtext:
95
+ 'there is no safe renderer for it here — the sanitizer this repo owns parses by assigning to innerHTML ' +
96
+ 'on a live element, and pz-theme renders on the server where there is no document at all. Use "text"'
92
97
  };
93
98
 
94
99
  /** The reason a setting key is refused, or null. Shared by the declared and the collected path. */