@wocart/theme 1.6.0 → 2.1.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,113 @@ 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.1.0 — 2026-10-21
9
+
10
+ The release check now sees where a section may be placed.
11
+
12
+ ### Changed
13
+
14
+ - `diffThemeManifests` treats a component that is no longer insertable, or that
15
+ is limited to a narrower list of surfaces, as a breaking change. Widening a
16
+ component's surfaces, or making it insertable again, is additive. Before, a
17
+ theme could narrow where a section sits without the release check noticing.
18
+
19
+ ## 2.0.0 — 2026-10-20
20
+
21
+ Blocks: components place other components inside them, some fixed by the
22
+ theme's code and some required, and every product page keeps an order form.
23
+ Style: a merchant changes a section's spacing, layout, size and colors, as
24
+ steps and color schemes the SDK turns into CSS. Areas: a header and a footer
25
+ edited once for every page. Fields: hand-picked products and collections,
26
+ display hints, and fields that show only when they apply.
27
+
28
+ ### Breaking
29
+
30
+ What a 1.x theme or editor may need to change:
31
+
32
+ - A surface whose `pageType` is `product` must guarantee an order form:
33
+ require a section that places a component with `role: "order-form"`
34
+ through fixed or required blocks. A theme without a product surface is
35
+ unaffected.
36
+ - `blocks` is a reserved prop: no component field may use that key.
37
+ - The reader drops what goes past `THEME_LIMITS` (50 per slot, 50 sections,
38
+ 1,000 instances, 6 levels) and reports `limit-exceeded`.
39
+ - An editor on 1.x rejects a manifest that uses the new field kinds
40
+ (`style`, `colorScheme`, `colorSchemes`, `productList`, `collectionList`):
41
+ move an editor to 2.0 before a theme uses them.
42
+ - `ThemeManifest.areas` is always present (a manifest without it reads as
43
+ none), `InstanceLocation` carries `depth`, and a list item cannot hold
44
+ `colorSchemes`.
45
+
46
+ ### Added
47
+
48
+ - Field kinds `style` (panels `spacing`, `layout`, `size`; a value per
49
+ breakpoint), `colorScheme` and `colorSchemes` (the store's schemes, a theme
50
+ setting). `ThemeDefinition.style` gives the platform's steps (`s0`–`s12`)
51
+ and widths their sizes; the manifest carries them.
52
+ - `styleClasses`, `schemeClass`, `themeStyleSheet`, `colorSchemeCss`,
53
+ `contrastRatio`, `STYLE_BREAKPOINTS`, `STYLE_STEPS`, `STYLE_WIDTHS`,
54
+ `STYLE_PANELS`, `COLOR_ROLES`, `COLOR_SCHEMES_MAX`, `THEME_STYLES_PATH`;
55
+ `themeStylesEndpoint` and `@wocart/theme/astro/ThemeStyles.astro`, which
56
+ adds its inline sheet's hash to a page's Content-Security-Policy.
57
+ - `checkThemeSettings` refuses a scheme picker naming a scheme the store does
58
+ not have, and a style the theme gives no size; the manifest diff reports a
59
+ size or panel a theme stops offering as breaking.
60
+ - Areas (`ThemeDefinition.areas`): the parts every page shares, each a
61
+ document stored as the surface `area:<key>` (`areaSurface`), with
62
+ `required` and `max`; the manifest carries them and its diff reports an
63
+ area removed as breaking.
64
+ - Field kinds `productList` and `collectionList`; `display` on `select`
65
+ (dropdown, radio, segmented) and `number` (input, slider); `inline` on
66
+ `richtext`, and `richTextToHtml`'s `inline` option.
67
+ - `visibleIf` on any field and `isFieldVisible`, `CONDITION_DEPTH`.
68
+ - `isPreview(blocks)`, for a placeholder a section draws in an editor's
69
+ preview only.
70
+ - The preview `move` and `restore` patches take `parent`, so reordering
71
+ blocks inside a component shows at once.
72
+
73
+ - `placement` on a component: `"section"`, `"block"` or `"both"`, enforced by
74
+ the document operations.
75
+ - `blocks`, given by the renderer to every component, and the components
76
+ `@wocart/theme/astro/Blocks.astro` and `@wocart/theme/astro/Block.astro`
77
+ that place a slot's blocks and a fixed block where the component wants them
78
+ — any number of slots, where pushed content can fill only one named slot.
79
+ `hasBlocks(blocks, slot)` and the `BlocksHandle` type.
80
+ - Fixed blocks (`fixed` on a definition, `fixed` on an instance): placed by
81
+ code, never removed, moved, copied or hidden, rendered with their defaults
82
+ when a document does not store them. `fixedBlockId`.
83
+ - Required blocks (`required` on a slot or a surface, with `min` and `max`)
84
+ and `max` on a slot or surface: a required block may move but is never lost,
85
+ and a document that lacks one renders it anyway.
86
+ - `role: "order-form"` and the rule that every product surface guarantees an
87
+ order form (`order-form-not-guaranteed`).
88
+ - `THEME_LIMITS`: 50 instances per slot, 50 sections per surface, 1,000
89
+ instances per document, 6 levels of nesting.
90
+ - `completePage(theme, page)`: a document with everything it must hold, for
91
+ an editor to open.
92
+ - `resolveSurfaceDocument(theme, surface, saved)`: the saved document or the
93
+ theme's default for any surface, completed — the policy a theme used to
94
+ write itself.
95
+ - `removeInstance(theme, page, id)` and `setInstanceHidden(theme, page, id,
96
+ hidden)`, which keep required blocks.
97
+ - Issue codes `unknown-fixed`, `fixed-type-mismatch`,
98
+ `missing-required-block`, `limit-exceeded` (reading); `fixed-block`,
99
+ `required-block`, `not-placeable`, `limit-exceeded` (writing);
100
+ `invalid-placement`, `invalid-fixed`, `invalid-role`,
101
+ `order-form-not-guaranteed` (assembly).
102
+
103
+ ### Changed
104
+
105
+ - The reader drops what goes past the limits, reporting it; documents
106
+ written so far are far below them.
107
+ - `InstanceLocation` carries `depth`, and `fixed` for a fixed block.
108
+ - A default document written for another surface reports only that.
109
+
110
+ ### Deprecated
111
+
112
+ - `removeInstance(page, id)` and `setInstanceHidden(page, id, hidden)`: pass
113
+ the theme first.
114
+
8
115
  ## 1.6.0 — 2026-10-19
9
116
 
10
117
  An editor's change shows on the preview page at once.
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
@@ -589,8 +784,15 @@ policy is:
589
784
  reported through `onIssue`.
590
785
  5. **An empty saved document** → honor it. A page the merchant emptied renders empty.
591
786
 
592
- The SDK does not ship this policy as a function, because the shipped default
593
- 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
+ ```
594
796
 
595
797
  ## Theme settings
596
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";