@wocart/theme 1.5.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,116 @@ All notable changes to `@wocart/theme` are recorded here. The package follows
5
5
  major version, and an export is marked `@deprecated` for at least one minor
6
6
  release before a major removes it.
7
7
 
8
+ ## 2.0.0 — 2026-10-20
9
+
10
+ Blocks: components place other components inside them, some fixed by the
11
+ theme's code and some required, and every product page keeps an order form.
12
+ Style: a merchant changes a section's spacing, layout, size and colors, as
13
+ steps and color schemes the SDK turns into CSS. Areas: a header and a footer
14
+ edited once for every page. Fields: hand-picked products and collections,
15
+ display hints, and fields that show only when they apply.
16
+
17
+ ### Breaking
18
+
19
+ What a 1.x theme or editor may need to change:
20
+
21
+ - A surface whose `pageType` is `product` must guarantee an order form:
22
+ require a section that places a component with `role: "order-form"`
23
+ through fixed or required blocks. A theme without a product surface is
24
+ unaffected.
25
+ - `blocks` is a reserved prop: no component field may use that key.
26
+ - The reader drops what goes past `THEME_LIMITS` (50 per slot, 50 sections,
27
+ 1,000 instances, 6 levels) and reports `limit-exceeded`.
28
+ - An editor on 1.x rejects a manifest that uses the new field kinds
29
+ (`style`, `colorScheme`, `colorSchemes`, `productList`, `collectionList`):
30
+ move an editor to 2.0 before a theme uses them.
31
+ - `ThemeManifest.areas` is always present (a manifest without it reads as
32
+ none), `InstanceLocation` carries `depth`, and a list item cannot hold
33
+ `colorSchemes`.
34
+
35
+ ### Added
36
+
37
+ - Field kinds `style` (panels `spacing`, `layout`, `size`; a value per
38
+ breakpoint), `colorScheme` and `colorSchemes` (the store's schemes, a theme
39
+ setting). `ThemeDefinition.style` gives the platform's steps (`s0`–`s12`)
40
+ and widths their sizes; the manifest carries them.
41
+ - `styleClasses`, `schemeClass`, `themeStyleSheet`, `colorSchemeCss`,
42
+ `contrastRatio`, `STYLE_BREAKPOINTS`, `STYLE_STEPS`, `STYLE_WIDTHS`,
43
+ `STYLE_PANELS`, `COLOR_ROLES`, `COLOR_SCHEMES_MAX`, `THEME_STYLES_PATH`;
44
+ `themeStylesEndpoint` and `@wocart/theme/astro/ThemeStyles.astro`, which
45
+ adds its inline sheet's hash to a page's Content-Security-Policy.
46
+ - `checkThemeSettings` refuses a scheme picker naming a scheme the store does
47
+ not have, and a style the theme gives no size; the manifest diff reports a
48
+ size or panel a theme stops offering as breaking.
49
+ - Areas (`ThemeDefinition.areas`): the parts every page shares, each a
50
+ document stored as the surface `area:<key>` (`areaSurface`), with
51
+ `required` and `max`; the manifest carries them and its diff reports an
52
+ area removed as breaking.
53
+ - Field kinds `productList` and `collectionList`; `display` on `select`
54
+ (dropdown, radio, segmented) and `number` (input, slider); `inline` on
55
+ `richtext`, and `richTextToHtml`'s `inline` option.
56
+ - `visibleIf` on any field and `isFieldVisible`, `CONDITION_DEPTH`.
57
+ - `isPreview(blocks)`, for a placeholder a section draws in an editor's
58
+ preview only.
59
+ - The preview `move` and `restore` patches take `parent`, so reordering
60
+ blocks inside a component shows at once.
61
+
62
+ - `placement` on a component: `"section"`, `"block"` or `"both"`, enforced by
63
+ the document operations.
64
+ - `blocks`, given by the renderer to every component, and the components
65
+ `@wocart/theme/astro/Blocks.astro` and `@wocart/theme/astro/Block.astro`
66
+ that place a slot's blocks and a fixed block where the component wants them
67
+ — any number of slots, where pushed content can fill only one named slot.
68
+ `hasBlocks(blocks, slot)` and the `BlocksHandle` type.
69
+ - Fixed blocks (`fixed` on a definition, `fixed` on an instance): placed by
70
+ code, never removed, moved, copied or hidden, rendered with their defaults
71
+ when a document does not store them. `fixedBlockId`.
72
+ - Required blocks (`required` on a slot or a surface, with `min` and `max`)
73
+ and `max` on a slot or surface: a required block may move but is never lost,
74
+ and a document that lacks one renders it anyway.
75
+ - `role: "order-form"` and the rule that every product surface guarantees an
76
+ order form (`order-form-not-guaranteed`).
77
+ - `THEME_LIMITS`: 50 instances per slot, 50 sections per surface, 1,000
78
+ instances per document, 6 levels of nesting.
79
+ - `completePage(theme, page)`: a document with everything it must hold, for
80
+ an editor to open.
81
+ - `resolveSurfaceDocument(theme, surface, saved)`: the saved document or the
82
+ theme's default for any surface, completed — the policy a theme used to
83
+ write itself.
84
+ - `removeInstance(theme, page, id)` and `setInstanceHidden(theme, page, id,
85
+ hidden)`, which keep required blocks.
86
+ - Issue codes `unknown-fixed`, `fixed-type-mismatch`,
87
+ `missing-required-block`, `limit-exceeded` (reading); `fixed-block`,
88
+ `required-block`, `not-placeable`, `limit-exceeded` (writing);
89
+ `invalid-placement`, `invalid-fixed`, `invalid-role`,
90
+ `order-form-not-guaranteed` (assembly).
91
+
92
+ ### Changed
93
+
94
+ - The reader drops what goes past the limits, reporting it; documents
95
+ written so far are far below them.
96
+ - `InstanceLocation` carries `depth`, and `fixed` for a fixed block.
97
+ - A default document written for another surface reports only that.
98
+
99
+ ### Deprecated
100
+
101
+ - `removeInstance(page, id)` and `setInstanceHidden(page, id, hidden)`: pass
102
+ the theme first.
103
+
104
+ ## 1.6.0 — 2026-10-19
105
+
106
+ An editor's change shows on the preview page at once.
107
+
108
+ ### Added
109
+
110
+ - The preview protocol's `patch` message (`PreviewPatch`): a change the
111
+ editor has already made — move, duplicate, remove and restore a section,
112
+ hide or show one, a field's text — shown by the preview bridge on the page
113
+ as it stands, before the render that follows it. Patches are hints; the
114
+ render stays the page's truth, and a render already on its way when a
115
+ patch arrives is dropped so it cannot undo it. A page that does not know
116
+ patches ignores them. The protocol version stays 1.
117
+
8
118
  ## 1.5.0 — 2026-10-18
9
119
 
10
120
  The editor-facing contract: everything a visual editor needs to discover and
package/README.md CHANGED
@@ -39,8 +39,10 @@ Node 22.12 or newer.
39
39
  | Import | What it holds |
40
40
  | --- | --- |
41
41
  | `@wocart/theme` | definitions, validation, page documents, resolution, document operations, rich text output, the manifest and its diff, languages and messages — framework-neutral |
42
- | `@wocart/theme/astro` | `createReadCache`, `themeManifestEndpoint` (the manifest as a build artifact), the preview markers and the `RendererIssue` type |
42
+ | `@wocart/theme/astro` | `createReadCache`, `themeManifestEndpoint` (the manifest as a build artifact), `hasBlocks`, `isPreview`, `THEME_LIMITS`, `themeStylesEndpoint`, the preview markers and the `BlocksHandle` and `RendererIssue` types |
43
43
  | `@wocart/theme/astro/InstanceView.astro` | the reference renderer component |
44
+ | `@wocart/theme/astro/Blocks.astro`, `@wocart/theme/astro/Block.astro` | a slot's blocks and a fixed block, placed by a component (see [Blocks](#blocks)) |
45
+ | `@wocart/theme/astro/ThemeStyles.astro` | the stylesheet link and the store's color schemes, for a layout's `<head>` (see [Style](#style)) |
44
46
  | `@wocart/theme/image-service` | `mediaImageService` — an Astro image service that resizes media-domain images on the media domain (any storefront host) |
45
47
  | `@wocart/theme/preview` | `startPreviewBridge` — the browser module of a theme's preview page (see [Preview](#preview)) |
46
48
 
@@ -129,7 +131,32 @@ defineComponent({
129
131
  | `category` | a category slug | found in `@wocart/storefront`'s `categories.list()` |
130
132
  | `image` | `{ key, alt?, width?, height?, focal? }` | `key` is the object's key in the store's media (`products/3f2a9c.webp`), never a URL; no default; `alt` per language |
131
133
  | `richtext` | a rich-text document, or one per language | a closed JSON format; `formats` narrows what it may contain |
132
- | `list` | `[{ id, props }]` | item `fields` of any kind but `list`; `min`/`max`; `itemLabel` names the item field an editor shows; default items carry their own ids |
134
+ | `list` | `[{ id, props }]` | item `fields` of any kind but `list` and `colorSchemes`; `min`/`max`; `itemLabel` names the item field an editor shows; default items carry their own ids |
135
+ | `style` | `{ spacing?, layout?, size? }`, a value per breakpoint | `panels` names what it offers; steps and choices only, never CSS (see [Style](#style)) |
136
+ | `colorScheme` | a color scheme's id | one of the store's schemes; the theme needs a `colorSchemes` setting |
137
+ | `colorSchemes` | `[{ id, name, colors }]` | a theme setting, at most one; every role a hex color; at most 24 |
138
+ | `productList` | product handles, in order | distinct; `min`/`max` (at most 50); each read with `products.get(handle)`, a missing one reads as absent |
139
+ | `collectionList` | collection handles, in order | distinct; `min`/`max` (at most 50) |
140
+
141
+ **How an editor shows a field** never changes what is stored:
142
+ `display: "dropdown" | "radio" | "segmented"` on a `select` (alignment is a
143
+ segmented `select` of `start`, `center`, `end`), `display: "input" | "slider"`
144
+ on a `number` (a slider needs `min`, `max` and `step`), and `inline: true` on
145
+ a `richtext` — one line, a single paragraph with marks and links, rendered
146
+ without a block element by `richTextToHtml(doc, { inline: true })`.
147
+
148
+ **A field may show only when it applies.** `visibleIf` is a closed JSON
149
+ condition on the fields beside it (the same component's, list item's, or
150
+ theme settings'): `{ field, equals }`, `{ field, in: [...] }`, `{ all: [...] }`,
151
+ `{ any: [...] }`, `{ not: ... }`, nested at most `CONDITION_DEPTH` (4) deep.
152
+ `isFieldVisible(field, values)` evaluates it for an editor — an unset field
153
+ compares as `null` — and the renderer ignores it; a hidden field keeps its
154
+ value.
155
+
156
+ ```ts
157
+ { key: "source", kind: "select", label: "Products", options: ["latest", "picked"], default: "latest", display: "segmented" },
158
+ { key: "picked", kind: "productList", label: "Picked products", max: 12, visibleIf: { field: "source", equals: "picked" } },
159
+ ```
133
160
 
134
161
  Use `url` for every editor-settable `href`, never `text`: the reader drops an
135
162
  unsafe saved link (`invalid-prop`) and falls back to the field's default, so a
@@ -269,6 +296,144 @@ A component declares where children may go; the document says what is there.
269
296
  displaces an allowed one. The rules travel in the manifest, so an editor can
270
297
  offer only valid insertions.
271
298
 
299
+ ## Blocks
300
+
301
+ A section is often made of parts a merchant arranges — a product section's
302
+ gallery, price, order form, a countdown, "Add to cart" — and some of them must
303
+ always be there. Blocks are components placed inside other components.
304
+
305
+ **Where a component may go.** `placement: "section"` (the top level of a page),
306
+ `"block"` (only inside a slot) or `"both"`; absent means anywhere. The document
307
+ operations enforce it; the renderer never does.
308
+
309
+ **A component places its own blocks.** The renderer gives every component a
310
+ `blocks` prop. Put each slot's blocks, and each fixed block, where the design
311
+ wants them — as many slots as the component declares:
312
+
313
+ ```astro
314
+ ---
315
+ import Blocks from "@wocart/theme/astro/Blocks.astro";
316
+ import Block from "@wocart/theme/astro/Block.astro";
317
+ import { hasBlocks, type BlocksHandle } from "@wocart/theme/astro";
318
+
319
+ const { blocks, title } = Astro.props as { blocks: BlocksHandle; title: string };
320
+ ---
321
+ <section>
322
+ {hasBlocks(blocks, "media") && <div class="media"><Blocks of={blocks} slot="media" /></div>}
323
+ <h1>{title}</h1>
324
+ <Block of={blocks} name="buy-box" />
325
+ </section>
326
+ ```
327
+
328
+ `<slot />` keeps working for children, as before; a component that pulls its
329
+ blocks never pays for content pushed into a slot it does not render. `blocks`
330
+ is a reserved prop: no field may use that key.
331
+
332
+ `isPreview(blocks)` says whether the component renders in an editor's
333
+ preview. A section with nothing to show yet — no products picked, no text
334
+ written — draws a placeholder there, so a merchant can see and select it; a
335
+ shopper's page never shows one.
336
+
337
+ **Fixed blocks** are placed by the component's code, like Shopify's static
338
+ blocks. Declare them so an editor knows them:
339
+
340
+ ```ts
341
+ fixed: [{ name: "buy-box", type: "buy-box", label: "Buy box" }],
342
+ ```
343
+
344
+ An editor changes a fixed block's fields and fills its slots, but cannot
345
+ remove, move, copy or hide it. It renders even when the document does not
346
+ store it, with its defaults and the id `<owner id>~<name>` (`fixedBlockId`).
347
+
348
+ **Required blocks** may move but never disappear. A slot — or a surface, for
349
+ its top-level sections — lists them:
350
+
351
+ ```ts
352
+ slots: [{ name: "default", allow: ["order-form", "countdown", "add-to-cart"], max: 4,
353
+ required: [{ type: "order-form", max: 1 }] }],
354
+ ```
355
+
356
+ The operations refuse to remove, hide or move out the last `min` (default 1)
357
+ of a required type, or to insert past its `max`. A document that lacks one —
358
+ an old document, a theme update — renders it anyway, added at the end of its
359
+ slot, and the reader reports `missing-required-block`.
360
+
361
+ **Every product page keeps an order form.** A component with
362
+ `role: "order-form"` places orders. Every surface whose `pageType` is
363
+ `product` must guarantee at least one through required sections and fixed or
364
+ required blocks; assembly refuses it otherwise (`order-form-not-guaranteed`).
365
+ Give the order form's entry `max: 1` for exactly one. Its fields are not the
366
+ theme's: a store's checkout form is set in the merchant dashboard and enforced
367
+ by the server.
368
+
369
+ **Limits** (`THEME_LIMITS`): 50 instances per slot, 50 sections per surface,
370
+ 1,000 instances per document, 6 levels of nesting below a section. A slot or
371
+ surface `max` may set fewer. The reader renders up to a limit and reports
372
+ `limit-exceeded`; the operations refuse to go past one. Assembly refuses fixed
373
+ and required blocks that contain themselves or nest deeper than the limit.
374
+
375
+ ## Style
376
+
377
+ A merchant changes how a section sits on the page and which colors it wears
378
+ — never by writing CSS. A theme gives sizes to the platform's style steps,
379
+ offers style fields, and the SDK writes the CSS.
380
+
381
+ **Sizes.** The step names are the platform's (`s0` to `s12`); a theme maps the
382
+ ones it offers to pixels, and the widths a section may take:
383
+
384
+ ```ts
385
+ style: { spacing: { s0: 0, s2: 8, s4: 16, s6: 32, s8: 64 }, widths: { wide: 1280, content: 960, narrow: 640 } },
386
+ ```
387
+
388
+ **Style fields.** A `style` field names its panels — `spacing` (padding and
389
+ margin on the block axis, inline padding, gap), `layout` (direction, align,
390
+ justify, wrap) and `size` (width, minimum height). A value holds a choice per
391
+ breakpoint — `base` everywhere, `md` from 768 px, `lg` from 1024 px — phone
392
+ first, on CSS logical properties so right-to-left stores mirror on their own:
393
+
394
+ ```ts
395
+ { key: "style", kind: "style", label: "Style", panels: ["spacing", "size"],
396
+ default: { spacing: { paddingBlockStart: { base: "s4", lg: "s8" } } } }
397
+ ```
398
+
399
+ A writer refuses a step or width the theme gives no size; a reader renders one
400
+ as no style.
401
+
402
+ **Colors.** The store's color schemes are one theme setting, `colorSchemes`:
403
+ named schemes, each a hex color for every role (`background`, `text`,
404
+ `heading`, `accent`, `button`, `buttonText`, `border`). A `colorScheme` field
405
+ picks one by id. A page document never holds a color.
406
+
407
+ **In the page.** Give the element its classes, serve the stylesheet, and put
408
+ the schemes in the `<head>`:
409
+
410
+ ```astro
411
+ ---
412
+ import { schemeClass, styleClasses } from "@wocart/theme";
413
+ const { style, scheme } = Astro.props;
414
+ ---
415
+ <section class={`${schemeClass(scheme)} ${styleClasses(style)}`}>…</section>
416
+ ```
417
+
418
+ ```ts
419
+ // src/pages/wocart-styles.css.ts
420
+ import { themeStylesEndpoint } from "@wocart/theme/astro";
421
+ export const prerender = true;
422
+ export const GET = themeStylesEndpoint(theme);
423
+ ```
424
+
425
+ ```astro
426
+ <!-- the layout's <head> -->
427
+ <ThemeStyles schemes={settings.colorSchemes} /> <!-- @wocart/theme/astro/ThemeStyles.astro -->
428
+ ```
429
+
430
+ The schemes become custom properties on `.wc-scheme-<id>`
431
+ (`--wc-color-background`, `--wc-color-button-text`, …) with the background
432
+ and text applied. On a page with a Content-Security-Policy, `ThemeStyles` adds
433
+ its inline sheet's hash to the policy, so `style-src` needs no
434
+ `'unsafe-inline'`. `contrastRatio(a, b)` gives the WCAG ratio an editor warns
435
+ on (below 4.5 for text).
436
+
272
437
  ## Pages and templates
273
438
 
274
439
  A surface is a document a theme can render. Declare the platform page type it
@@ -297,6 +462,26 @@ surfaces: [
297
462
  it can serve (alternates included); `isInsertableOn(component, surface)`
298
463
  applies the rule.
299
464
 
465
+ **Areas** are the parts every page shares — a header, a footer — composed of
466
+ sections like a page and edited once for the whole store:
467
+
468
+ ```ts
469
+ areas: [
470
+ { key: "header", label: "Header", document: headerDocument, required: [{ type: "menu-bar", max: 1 }], max: 4 },
471
+ { key: "footer", label: "Footer" },
472
+ ],
473
+ ```
474
+
475
+ An area's document is the surface `area:<key>` (`areaSurface("header")`):
476
+ read, completed, checked and edited like a page's, with `required` and `max`
477
+ as a surface has them. A component names `area:header` in
478
+ `capabilities.surfaces` to stay in the header. The layout renders it like a
479
+ page: `resolveSurfaceDocument(theme, "area:header", await store.pageData.get("area:header"))`.
480
+
481
+ A template's preview names the resource it shows: the preview draft's `path`
482
+ (`/products/robe-kabyle`), which `@wocart/storefront`'s `matchStorefrontPath`
483
+ turns into the page type and the route's params.
484
+
300
485
  ## Presets
301
486
 
302
487
  A preset is a ready-made configuration an editor offers when inserting a
@@ -337,11 +522,21 @@ if (!edit.ok) console.log(edit.issue.code, edit.issue.message); // "not-availabl
337
522
 
338
523
  `insertInstance`, `removeInstance`, `moveInstance`, `duplicateInstance`,
339
524
  `setInstanceProps` and `setInstanceHidden` never change their input. They
340
- refuse a component that is `insertable: false` or not available on the
341
- document's surface, a slot that does not take it, an index out of range, a
342
- cycle, an id already in the document, and a value its field refuses.
343
- `checkPageData(theme, page)` is the strict check of a whole document before
344
- saving it; `findInstance` locates an instance.
525
+ refuse a component that is `insertable: false`, not available on the
526
+ document's surface or not placeable there, a slot that does not take it or is
527
+ full, an index out of range, a cycle, an id already in the document, a value
528
+ its field refuses, any change to a fixed block but its fields
529
+ (`fixed-block`), losing a required block (`required-block`) and going past a
530
+ limit (`limit-exceeded`). `removeInstance(theme, page, id)` and
531
+ `setInstanceHidden(theme, page, id, hidden)` take the theme first; their older
532
+ forms without it still refuse a fixed block but cannot know a slot's required
533
+ types. `checkPageData(theme, page)` is the strict check of a whole document
534
+ before saving it; `findInstance` locates an instance, a fixed block included.
535
+
536
+ Open a document with `completePage(theme, page)`: it adds what the document
537
+ must hold — required sections, fixed blocks, required blocks, each with an id
538
+ unique in the document — so what an editor shows is what renders. Inserting a
539
+ component completes it the same way.
345
540
 
346
541
  An editor holds a theme's manifest, not its code:
347
542
  `manifestCatalog(manifest)` makes the catalog these functions take, with no
@@ -406,15 +601,28 @@ framing window, with `source: "wocart-editor"` and the protocol's `version`
406
601
  | The editor sends | The page answers |
407
602
  | --- | --- |
408
603
  | — | `ready` with every instance: `{ id, type, parent, hidden, rect }` |
409
- | `render` `{ draft, revision? }` | `rendered` `{ revision, instances }`, or `error` `{ revision, reason }`; an answer overtaken by a newer render is dropped |
604
+ | `render` `{ draft, revision? }` | `rendered` `{ revision, instances }`, or `error` `{ revision, reason }`; an answer overtaken by a newer render or a patch is dropped |
605
+ | `patch` `{ patches }` | the change shown at once, on the page as it stands, then `layout` (see below) |
410
606
  | `mode` `"edit"` \| `"interactive"` | in `edit`, links and forms do nothing and a click is `select` `{ id }` of the innermost instance |
411
607
  | `scroll-to` `{ id }` | the instance scrolls into view |
412
608
  | `token` `{ token }` | the next render uses it |
413
609
  | — | `hover` `{ id \| null }` as the pointer moves; `layout` after a scroll or resize |
414
610
 
611
+ A `patch` shows a change the editor has already made to its draft before the
612
+ render that follows it arrives — so moving, copying, removing or hiding a
613
+ section, and typing a field's text, show on the page at once. Each patch
614
+ (`PreviewPatch`) is a hint: the page applies what it finds — `move` a section
615
+ to an index, `duplicate` an instance under new ids, `remove` one and
616
+ `restore` it, `hide` one or show it again, replace `text` that reads `from`
617
+ inside an instance with `to` — and leaves the rest to the render, which is
618
+ the page's truth. An editor therefore always sends the render too; a page
619
+ from before 1.6.0 ignores patches and still shows every change, a moment
620
+ later.
621
+
415
622
  A swapped section's scripts do not run again. A section with browser behavior
416
623
  binds it again on `PREVIEW_SECTION_LOAD_EVENT` (`wocart:section-load`,
417
- dispatched on `document` with `detail.id`). When the bridge replaces the whole
624
+ dispatched on `document` with `detail.id`) — also for a section a patch
625
+ copied, restored or showed again. When the bridge replaces the whole
418
626
  page instead, it does what Astro's router does after a navigation — a new
419
627
  `<body>`, the root element's attributes, the title — and dispatches
420
628
  `astro:page-load`, so a script written for the router mounts again as it does
@@ -576,8 +784,15 @@ policy is:
576
784
  reported through `onIssue`.
577
785
  5. **An empty saved document** → honor it. A page the merchant emptied renders empty.
578
786
 
579
- The SDK does not ship this policy as a function, because the shipped default
580
- document is theme content.
787
+ `resolveSurfaceDocument(theme, surface, saved)` applies this policy — and
788
+ also renders the default for a document saved for another surface or another
789
+ theme — then completes the result (`completePage`). The shipped default is
790
+ the surface's `document`:
791
+
792
+ ```ts
793
+ const { page, source, reason, issues } = resolveSurfaceDocument(theme, "home", await store.pageData.get("home"));
794
+ // render page.sections; log reason and issues when source is "default"
795
+ ```
581
796
 
582
797
  ## Theme settings
583
798
 
@@ -0,0 +1,29 @@
1
+ ---
2
+ /**
3
+ * One fixed block of the component that received `blocks`, where its code
4
+ * places it: the stored block, or one with its defaults when the document
5
+ * does not store it. `name` is one the definition's `fixed` lists.
6
+ *
7
+ * <Block of={blocks} name="buy-box" />
8
+ */
9
+ import InstanceView from "./InstanceView.astro";
10
+ import type { BlocksHandle } from "./index.js";
11
+
12
+ interface Props {
13
+ of: BlocksHandle;
14
+ name: string;
15
+ }
16
+
17
+ const { of, name } = Astro.props as Props;
18
+ const instance = Object.hasOwn(of.fixed, name) ? of.fixed[name] : undefined;
19
+ if (instance === undefined) {
20
+ const issue = {
21
+ code: "unknown-fixed" as const,
22
+ message: `"${of.owner.type}" places no fixed block "${name}"; declare it in the definition's \`fixed\``,
23
+ instanceId: of.owner.id,
24
+ };
25
+ if (of.render.onIssue) of.render.onIssue(issue);
26
+ else console.warn(`[@wocart/theme/astro] ${issue.code}: ${issue.message}`);
27
+ }
28
+ ---
29
+ {instance !== undefined && <InstanceView instance={instance} {...of.render} />}
@@ -0,0 +1,23 @@
1
+ ---
2
+ /**
3
+ * The blocks of one slot of the component that received `blocks`, in order,
4
+ * each through `InstanceView` — so `load`, issues, hidden blocks and preview
5
+ * markers work the same at every depth. A component may place any
6
+ * number of its slots this way.
7
+ *
8
+ * <Blocks of={blocks} slot="media" />
9
+ * <Blocks of={blocks} /> (the default slot)
10
+ */
11
+ import InstanceView from "./InstanceView.astro";
12
+ import type { BlocksHandle } from "./index.js";
13
+
14
+ interface Props {
15
+ of: BlocksHandle;
16
+ /** Absent: the default slot. */
17
+ slot?: string | undefined;
18
+ }
19
+
20
+ const { of, slot = "default" } = Astro.props as Props;
21
+ const list = Object.hasOwn(of.slots, slot) ? of.slots[slot]! : [];
22
+ ---
23
+ {list.map((instance) => <InstanceView instance={instance} {...of.render} />)}
@@ -5,17 +5,23 @@
5
5
  *
6
6
  * instance (skipped when `hidden`) → definition (catalog) → resolveInstance (chain + gating)
7
7
  * → definition.load (optional, server-only, degrading)
8
- * → <Component {...props}> with children + one named slot
8
+ * → <Component {...props} blocks> with children + one named slot
9
9
  *
10
- * Named slots: the Astro compiler lifts the first slot-attribute expression of
11
- * a dynamic child expression into the slots-object key, evaluated in component
12
- * scope — so exactly ONE named slot per instance can be routed generically.
13
- * Extra named slots with content are reported, never silently dropped.
10
+ * Blocks: every component receives `blocks`, and places any slot's
11
+ * blocks with `<Blocks of={blocks} slot>` and its fixed blocks with
12
+ * `<Block of={blocks} name>` — as many slots as it declares.
13
+ *
14
+ * Pushed slots, for components that use `<slot />`: the Astro compiler lifts
15
+ * the first slot-attribute expression of a dynamic child expression into the
16
+ * slots-object key, evaluated in component scope — so exactly ONE named slot
17
+ * per instance can be pushed generically. Extra named slots with content are
18
+ * reported, never silently dropped. Slot content renders lazily, so a
19
+ * component that pulls its blocks never renders what was pushed.
14
20
  *
15
21
  * Server-only. No hydration, no directives, no second component system —
16
22
  * the theme's own `.astro` files do all rendering.
17
23
  */
18
- import { previewEndMarker, previewStartMarker, resolveInstance, type RendererIssue } from "./index.js";
24
+ import { THEME_LIMITS, previewEndMarker, previewStartMarker, resolveInstance, type BlocksHandle, type RendererIssue } from "./index.js";
19
25
  import type {
20
26
  ComponentInstance,
21
27
  ReadCache,
@@ -37,9 +43,11 @@ interface Props {
37
43
  * comments an editor's bridge reads. Never set for a shopper's page.
38
44
  */
39
45
  preview?: boolean | undefined;
46
+ /** Levels below the page's sections; past `THEME_LIMITS.depth` nothing renders. */
47
+ depth?: number | undefined;
40
48
  }
41
49
 
42
- const { instance, theme, context, storefront, reads, onIssue, preview = false } = Astro.props as Props;
50
+ const { instance, theme, context, storefront, reads, onIssue, preview = false, depth = 0 } = Astro.props as Props;
43
51
 
44
52
  const report = (issue: RendererIssue): void => {
45
53
  if (onIssue) {
@@ -52,10 +60,18 @@ const report = (issue: RendererIssue): void => {
52
60
  // A hidden instance stays in the document and renders nothing: no lookup,
53
61
  // no `load`, no children.
54
62
  const hidden = instance.hidden === true;
55
- const definition = !hidden && Object.hasOwn(theme.components, instance.type)
63
+ const tooDeep = depth > THEME_LIMITS.depth;
64
+ if (tooDeep && !hidden) {
65
+ report({
66
+ code: "limit-exceeded",
67
+ message: `"${instance.type}" is nested more than ${THEME_LIMITS.depth} levels below a section; nothing rendered`,
68
+ instanceId: instance.id,
69
+ });
70
+ }
71
+ const definition = !hidden && !tooDeep && Object.hasOwn(theme.components, instance.type)
56
72
  ? theme.components[instance.type]
57
73
  : undefined;
58
- if (definition === undefined && !hidden) {
74
+ if (definition === undefined && !hidden && !tooDeep) {
59
75
  report({
60
76
  code: "unknown-type",
61
77
  message: `"${instance.type}" is not in the theme catalog; nothing rendered`,
@@ -123,8 +139,14 @@ if (definition !== undefined) {
123
139
  }
124
140
  }
125
141
 
142
+ const blocks: BlocksHandle = {
143
+ owner: { id: instance.id, type: instance.type },
144
+ slots: { ...resolved.slots, default: resolved.children },
145
+ fixed: resolved.fixed,
146
+ render: { theme, context, storefront, reads, onIssue, preview, depth: depth + 1 },
147
+ };
126
148
  Component = definition.component as RenderableComponent;
127
- renderProps = { ...resolved.props, ...loaded };
149
+ renderProps = { ...resolved.props, ...loaded, blocks };
128
150
  children = resolved.children;
129
151
 
130
152
  const namedEntries = Object.entries(resolved.slots);
@@ -160,6 +182,7 @@ const endMarker = preview ? `<!--${previewEndMarker(instance)}-->` : "";
160
182
  reads={reads}
161
183
  onIssue={onIssue}
162
184
  preview={preview}
185
+ depth={depth + 1}
163
186
  />
164
187
  ))}
165
188
  {firstNamedSlotName !== undefined && (
@@ -173,6 +196,7 @@ const endMarker = preview ? `<!--${previewEndMarker(instance)}-->` : "";
173
196
  reads={reads}
174
197
  onIssue={onIssue}
175
198
  preview={preview}
199
+ depth={depth + 1}
176
200
  />
177
201
  ))}
178
202
  </Fragment>
@@ -0,0 +1,34 @@
1
+ ---
2
+ /**
3
+ * The theme's style in a page's <head>: the stylesheet of style classes
4
+ * (served at `THEME_STYLES_PATH` by `themeStylesEndpoint`) and the store's
5
+ * color schemes as one small inline sheet. On a page with a
6
+ * Content-Security-Policy, the inline sheet's hash joins the policy, so
7
+ * `style-src` needs no 'unsafe-inline'.
8
+ *
9
+ * <ThemeStyles schemes={settings.colorSchemes} />
10
+ */
11
+ import { THEME_STYLES_PATH, colorSchemeCss } from "./index.js";
12
+ import type { ColorScheme } from "../types.js";
13
+
14
+ interface Props {
15
+ schemes?: readonly ColorScheme[] | undefined;
16
+ /** `false` when the theme bundles the style classes itself. */
17
+ stylesheet?: boolean | undefined;
18
+ }
19
+
20
+ const { schemes, stylesheet = true } = Astro.props as Props;
21
+ const css = colorSchemeCss(schemes);
22
+ if (css.length > 0) {
23
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(css)));
24
+ const hash = `sha256-${btoa(String.fromCharCode(...digest))}`;
25
+ const csp = (Astro as unknown as { csp?: { insertStyleHash?: (hash: string) => void } }).csp;
26
+ try {
27
+ csp?.insertStyleHash?.(hash);
28
+ } catch {
29
+ // The page renders without a policy.
30
+ }
31
+ }
32
+ ---
33
+ {stylesheet && <link rel="stylesheet" href={THEME_STYLES_PATH} />}
34
+ {css.length > 0 && <style is:inline set:html={css} />}
@@ -1,5 +1,8 @@
1
- import type { PageDataIssueCode, ReadCache, ThemeDefinition } from "../types.js";
1
+ import type { ComponentInstance, PageDataIssueCode, ReadCache, ThemeDefinition, ThemeRenderContext } from "../types.js";
2
+ import type { StoreClient } from "@wocart/storefront/server";
2
3
  export { resolveInstance } from "../instances.js";
4
+ export { THEME_LIMITS } from "../blocks.js";
5
+ export { THEME_STYLES_PATH, colorSchemeCss } from "../style.js";
3
6
  export { previewEndMarker, previewStartMarker } from "../preview-protocol.js";
4
7
  /**
5
8
  * The theme's manifest as a prerendered endpoint, the file a release and an
@@ -17,6 +20,51 @@ export { previewEndMarker, previewStartMarker } from "../preview-protocol.js";
17
20
  * whose manifest is invalid fails its build instead of shipping it.
18
21
  */
19
22
  export declare function themeManifestEndpoint(theme: ThemeDefinition): () => Response;
23
+ /**
24
+ * The stylesheet of the theme's style classes as a prerendered endpoint, the
25
+ * file `<ThemeStyles>` links (`THEME_STYLES_PATH`):
26
+ *
27
+ * ```ts
28
+ * // src/pages/wocart-styles.css.ts
29
+ * import { themeStylesEndpoint } from "@wocart/theme/astro";
30
+ * import { theme } from "../theme/theme";
31
+ * export const prerender = true;
32
+ * export const GET = themeStylesEndpoint(theme);
33
+ * ```
34
+ */
35
+ export declare function themeStylesEndpoint(theme: ThemeDefinition): () => Response;
36
+ /**
37
+ * What the renderer gives every component as `blocks`: its instance's slots
38
+ * (the default slot under `"default"`), its fixed blocks by name — both
39
+ * already gated and completed — and what rendering them needs. A component
40
+ * passes it to `<Blocks>` and `<Block>`; it never needs to read it.
41
+ */
42
+ export interface BlocksHandle {
43
+ owner: {
44
+ id: string;
45
+ type: string;
46
+ };
47
+ slots: Readonly<Record<string, readonly ComponentInstance[]>>;
48
+ fixed: Readonly<Record<string, ComponentInstance>>;
49
+ render: {
50
+ theme: ThemeDefinition;
51
+ context: ThemeRenderContext;
52
+ storefront: StoreClient;
53
+ reads: ReadCache;
54
+ onIssue: ((issue: RendererIssue) => void) | undefined;
55
+ preview: boolean;
56
+ depth: number;
57
+ };
58
+ }
59
+ /**
60
+ * Whether the component renders in an editor's preview. A section with
61
+ * nothing to show yet — no products picked, no text written — draws a
62
+ * placeholder there, so a merchant can see and select it; a shopper's page
63
+ * never shows one.
64
+ */
65
+ export declare function isPreview(blocks: BlocksHandle | undefined): boolean;
66
+ /** Whether a slot holds a block that renders (not hidden), so a component can leave out an empty wrapper. */
67
+ export declare function hasBlocks(blocks: BlocksHandle | undefined, slot?: string): boolean;
20
68
  /** One renderer-specific degradation, reported through `onIssue`. */
21
69
  export interface RendererIssue {
22
70
  code: PageDataIssueCode | "missing-component" | "load-failed" | "multiple-slots-unsupported";