@wocart/theme 2.3.0 → 3.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/README.md CHANGED
@@ -2,26 +2,14 @@
2
2
 
3
3
  The theme contract for [Wocart](https://github.com/bighadj22/codflow)
4
4
  storefronts. A theme declares its components in code — real `.astro`
5
- components, wrapped with typed, editable fields — and a store's pages are saved
6
- as JSON documents that reference them. This package validates both sides,
7
- resolves a saved document against the theme, and renders it with a reference
8
- Astro renderer.
5
+ components with typed, editable fields — and a store's pages are saved as JSON
6
+ documents that reference them. This package validates both, resolves a saved
7
+ document against the theme, and renders it with a reference Astro renderer.
9
8
 
10
- - **Code-owned definitions.** `defineComponent` and `defineTheme` describe
11
- each component's fields, defaults, slots and server-side data loading.
12
- - **JSON page documents.** `parsePageData` and `validatePageData` read a saved
13
- page forgivingly: every problem is reported, and nothing a merchant saved can
14
- break the rest of the page.
15
- - **A serializable manifest.** `themeManifest` turns a theme into plain JSON
16
- that editors and tools can read without running theme code.
17
- - **Editor-ready.** Images, rich text, lists and product references as
18
- fields; page types and reusable templates; presets; hidden sections; and the
19
- document operations every editor applies — all discoverable from the
20
- manifest, which a build emits as a file.
21
- - **Many languages.** Text values may be translated per language, and
22
- `formatMessage` handles placeholders and CLDR plurals.
23
- - **A reference renderer.** `InstanceView.astro` renders a document section by
24
- section through your own components.
9
+ **Documentation: [docs.wocart.store/sdk/theme](https://docs.wocart.store/sdk/theme)**
10
+ — also in [French](https://docs.wocart.store/fr/sdk/theme) and
11
+ [Arabic](https://docs.wocart.store/ar/sdk/theme). Start with the
12
+ [tutorial](https://docs.wocart.store/sdk/theme/tutorial).
25
13
 
26
14
  ## Install
27
15
 
@@ -29,1078 +17,37 @@ Astro renderer.
29
17
  npm install @wocart/theme @wocart/storefront
30
18
  ```
31
19
 
32
- `@wocart/storefront` is a required peer: the render context carries its
33
- `StoreConfig` and data loaders receive its `StoreClient`. `astro` (7.x) is an
34
- optional peer, needed only for the `./astro` renderer. ESM only; tooling needs
35
- Node 22.12 or newer.
20
+ `@wocart/storefront` 2 is a required peer; `astro` 7 is an optional peer, for
21
+ the `./astro` renderer. ESM only; Node 22.12 or newer for tooling.
36
22
 
37
- ## Entry points
38
-
39
- | Import | What it holds |
40
- | --- | --- |
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), `hasBlocks`, `isPreview`, `THEME_LIMITS`, `themeStylesEndpoint`, `previewTarget`, the preview markers and the `BlocksHandle` and `RendererIssue` types |
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)) |
46
- | `@wocart/theme/image-service` | `mediaImageService` — an Astro image service that resizes media-domain images on the media domain (any storefront host) |
47
- | `@wocart/theme/preview` | `startPreviewBridge` — the browser module of a theme's preview page (see [Preview](#preview)) |
48
- | `@wocart/theme/lifecycle` | `bindEach`, `onEachPage`, `PREVIEW_SECTION_LOAD_EVENT` — binding browser behaviour so it survives page and section swaps; tiny, without the bridge (see [Browser scripts](#browser-scripts)) |
49
- | `@wocart/theme/messages` | `formatMessage`, `messageForms`, `pluralMessageKeys`, `pluralFormIssues` for browser code (see [Messages](#messages-placeholders-and-plurals)) |
50
-
51
- Browser code imports only what builds no validator: `./lifecycle`,
52
- `./messages`, and from the root the style, rich-text and message helpers. A
53
- test of this package bundles each of them and fails if Zod or the editor's
54
- bridge reaches a shopper's page.
55
-
56
- ## Use
23
+ ## Quick start
57
24
 
58
25
  ```ts
59
- import {
60
- defineComponent,
61
- defineTheme,
62
- parsePageData,
63
- validateThemeDefinition,
64
- } from "@wocart/theme";
26
+ import { defineComponent, defineTheme } from "@wocart/theme";
65
27
  import Hero from "./components/Hero.astro";
66
28
 
67
- const hero = defineComponent({
29
+ export const hero = defineComponent({
68
30
  type: "hero",
69
- component: Hero, // the real .astro component — untouched
31
+ component: Hero,
70
32
  label: "Hero",
71
- fields: [{ key: "heroTitle", kind: "text", label: "Title", contentKey: "heroTitle" }],
72
- capabilities: { insertable: true },
33
+ fields: [{ key: "title", kind: "text", label: "Title", default: "Fast delivery" }],
73
34
  });
74
35
 
75
36
  export const theme = defineTheme({ key: "my-theme", version: "1.0.0", components: { hero } });
76
-
77
- const definitionIssues = validateThemeDefinition(theme); // [] when the catalog is coherent
78
- const { page, issues } = parsePageData(savedDocument); // parse-and-degrade
79
- ```
80
-
81
- Then render the page in an `.astro` file:
82
-
83
- ```astro
84
- ---
85
- import InstanceView from "@wocart/theme/astro/InstanceView.astro";
86
- import { createReadCache } from "@wocart/theme/astro";
87
-
88
- const reads = createReadCache(); // one per render, shared by every section
89
- ---
90
- {page?.sections.map((section) => (
91
- <InstanceView instance={section} theme={theme} context={context}
92
- storefront={store} reads={reads} />
93
- ))}
94
- ```
95
-
96
- `context` is the request's `ThemeRenderContext` (the store config, your
97
- content pack for the language, `locale`, `isRTL`, `url`), and `store` is the
98
- server-side `StoreClient` from `@wocart/storefront/server`.
99
-
100
- Renderer behavior: `resolveInstance` fills defaults and gates composition;
101
- `load` (optional, server-only) may enrich props and degrades to base props on
102
- failure; unknown types render nothing — every degradation, including invalid
103
- props and undeclared children/slots, is reported through `onIssue` (default:
104
- `console.warn`). The issue contains a stable `code`, `message`, `instanceId`,
105
- and, for page-data resolution issues, `path`.
106
-
107
- `load` receives the instance's resolved props typed from the component's own
108
- fields, so a section reads its settings without a cast:
109
-
110
- ```ts
111
- defineComponent({
112
- type: "best-sellers",
113
- component: BestSellers,
114
- label: { ar: "الأكثر مبيعاً", fr: "Meilleures ventes" },
115
- fields: [
116
- { key: "collection", kind: "collection", label: { ar: "المجموعة", fr: "Collection" }, default: "best-sellers" },
117
- { key: "limit", kind: "number", label: { ar: "العدد", fr: "Nombre" }, default: 8, min: 1, max: 24 },
118
- ],
119
- load: async ({ storefront, props }) => ({
120
- products: await storefront.products.list({ collection: props.collection, limit: props.limit }),
121
- }),
122
- });
123
- ```
124
-
125
- ## Field kinds
126
-
127
- | Kind | Value | Rules |
128
- |---|---|---|
129
- | `text`, `textarea` | string | `default` or `contentKey` (from the theme's content pack), never both |
130
- | `number` | finite number | optional `min`/`max`; a `default` must sit inside them; with `step`, values sit on the grid that starts at `min` (or 0) |
131
- | `boolean` | boolean | |
132
- | `select` | one of `options` | `default` must be one of the options; `optionLabels` gives each option the words an editor shows, per language |
133
- | `color` | `#rgb` or `#rrggbb` | anything else (a color name, `url(…)`) is rejected at assembly and reported on read |
134
- | `collection` | a collection handle (`best-sellers`) | lowercase letters, digits and hyphens; its products are listed with `@wocart/storefront`'s `products.list({ collection })`, in the merchant's order |
135
- | `menu` | a menu handle (`main-menu`) | lowercase letters, digits and hyphens; the store's menu is read with `@wocart/storefront`'s `menus.get(handle)` |
136
- | `url` | a link target | a site path (`/products`), `#fragment`, `?query`, `http(s)://`, `mailto:` or `tel:`; `javascript:`, `data:`, `//host` and backslash tricks are rejected at assembly and reported on read |
137
- | `product` | a product handle | read with `@wocart/storefront`'s `products.get(handle)`; a renamed or deleted product reads as no product |
138
- | `category` | a category slug | found in `@wocart/storefront`'s `categories.list()` |
139
- | `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 |
140
- | `richtext` | a rich-text document, or one per language | a closed JSON format; `formats` narrows what it may contain |
141
- | `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 |
142
- | `style` | `{ spacing?, layout?, size? }`, a value per breakpoint | `panels` names what it offers; steps and choices only, never CSS (see [Style](#style)) |
143
- | `colorScheme` | a color scheme's id | one of the store's schemes; the theme needs a `colorSchemes` setting |
144
- | `colorSchemes` | `[{ id, name, colors }]` | a theme setting, at most one; every role a hex color; at most 24 |
145
- | `productList` | product handles, in order | distinct; `min`/`max` (at most 50); each read with `products.get(handle)`, a missing one reads as absent |
146
- | `collectionList` | collection handles, in order | distinct; `min`/`max` (at most 50) |
147
-
148
- **How an editor shows a field** never changes what is stored:
149
- `display: "dropdown" | "radio" | "segmented"` on a `select` (alignment is a
150
- segmented `select` of `start`, `center`, `end`), `display: "input" | "slider"`
151
- on a `number` (a slider needs `min`, `max` and `step`), and `inline: true` on
152
- a `richtext` — one line, a single paragraph with marks and links, rendered
153
- without a block element by `richTextToHtml(doc, { inline: true })`.
154
-
155
- **A field may show only when it applies.** `visibleIf` is a closed JSON
156
- condition on the fields beside it (the same component's, list item's, or
157
- theme settings'): `{ field, equals }`, `{ field, in: [...] }`, `{ all: [...] }`,
158
- `{ any: [...] }`, `{ not: ... }`, nested at most `CONDITION_DEPTH` (4) deep.
159
- `isFieldVisible(field, values)` evaluates it for an editor — an unset field
160
- compares as `null` — and the renderer ignores it; a hidden field keeps its
161
- value.
162
-
163
- ```ts
164
- { key: "source", kind: "select", label: "Products", options: ["latest", "picked"], default: "latest", display: "segmented" },
165
- { key: "picked", kind: "productList", label: "Picked products", max: 12, visibleIf: { field: "source", equals: "picked" } },
166
- ```
167
-
168
- Use `url` for every editor-settable `href`, never `text`: the reader drops an
169
- unsafe saved link (`invalid-prop`) and falls back to the field's default, so a
170
- document can never inject a script URL.
171
-
172
- The SDK checks a value as a writer must before saving it:
173
- `checkFieldValue(field, value)` returns `{ ok: true }` or the reason. It is
174
- stricter than reading: an image reference or a list item with a key the
175
- contract does not define is refused, where the reader ignores it.
176
-
177
- ### Images
178
-
179
- An image value names the object in the store's own media bucket; the URL is
180
- derived at render, so a domain or host change never rewrites a document.
181
- Resolve it with `@wocart/storefront`'s `mediaUrl`:
182
-
183
- ```astro
184
- ---
185
- import { mediaUrl } from "@wocart/storefront";
186
- import { MEDIA_DOMAIN } from "astro:env/server";
187
- const { image } = Astro.props as InferProps<typeof banner>; // ResolvedImage: alt in the rendered language
188
- const src = image && mediaUrl(image.key, MEDIA_DOMAIN);
189
- ---
190
- {src && <img src={src} alt={image.alt ?? ""} width={image.width} height={image.height} />}
191
- ```
192
-
193
- The SDK never uploads: the editor places files in the store's media and
194
- stores the key. An image field has no default — a theme cannot know a store's
195
- media — so the component renders its own fallback.
196
-
197
- ### Rich text
198
-
199
- A rich-text value is `{ type: "doc", version: 1, content }`: paragraphs,
200
- headings 2–4, bullet and ordered lists, line breaks, bold, italic, underline,
201
- strike and links that follow the `url` rule. Nothing else is representable, so
202
- a stored value cannot carry markup or scripts. Render it with the SDK:
203
-
204
- ```astro
205
- ---
206
- import { richTextToHtml } from "@wocart/theme";
207
- ---
208
- {body && <div set:html={richTextToHtml(body, { href: store.href })} />}
209
- ```
210
-
211
- `richTextToHtml` escapes every text and attribute and drops what is not in the
212
- format even for a value that was never validated; `href` lets the theme
213
- localize site links. `richTextToPlainText` gives the words for a meta
214
- description or an `aria-label`. A field's `formats` (`["bold", "link"]`)
215
- narrows what a merchant may use.
216
-
217
- The rich-text types are plain JSON (`RichTextDocument` is a `JsonObject`), so
218
- a preset or a default can hold a document without a cast.
219
-
220
- ### Lists
221
-
222
- ```ts
223
- {
224
- key: "badges",
225
- kind: "list",
226
- label: "Badges",
227
- max: 4,
228
- itemLabel: "title",
229
- fields: [
230
- { key: "title", kind: "text", label: "Title" },
231
- { key: "icon", kind: "select", label: "Icon", options: ["truck", "cash"], default: "truck" },
232
- ],
233
- default: [{ id: "cod", props: { title: { ar: "الدفع عند الاستلام", fr: "Paiement à la livraison" }, icon: "cash" } }],
234
- }
235
- ```
236
-
237
- A component receives `[{ id, title, icon }]`. Each item's id is unique in its
238
- list, so reordering never changes which item is which; the reader drops a
239
- broken or repeated item and keeps the others, and keeps the first `max`. Item
240
- fields cannot default from the content pack: every item would read the same
241
- key.
242
-
243
- ## Languages
244
-
245
- The contract is built for many languages; a store serves the ones it enables.
246
- A single-language store is the same contract with one language.
247
-
248
- - **The render context says which language to render**: `locale` (a BCP 47
249
- tag: `ar`, `fr`, `ar-DZ`) and optionally `fallbackLocales`, tried in order.
250
- `content` is the theme's pack for that language.
251
- - **`text`, `textarea` and `url` values may be translated.** A saved value is
252
- either one string, the same in every language (a brand name), or one value
253
- per language: `{ "ar": "توصيل مجاني", "fr": "Livraison gratuite" }`.
254
- Resolution takes the requested language, then the fallback chain, then
255
- `content[contentKey]`, then `default`. A missing translation is not an error;
256
- a malformed map (`FR`, a number, an empty map) is reported as `invalid-prop`.
257
- Numbers, toggles and choices are shared by every language.
258
- - **The writer checks every language.** `createInstance` refuses a map with an
259
- unsafe link or a wrong type in any language, naming that language.
260
- - **Editor-facing text can be translated**: a component's `label` and `group`,
261
- a field's `label`, `description` and text `default`, a surface's `label`.
262
- - **A theme declares the languages its packs cover**:
263
- `defineTheme({ locales: ["ar", "fr", "en"], … })`. The validator then fails
264
- any translated label or default that misses one of them, and the manifest
265
- carries the list so an editor knows what the theme can serve.
266
- - **`localize(value, locales)`** picks the text for the first preferred
267
- language; use it wherever a theme or tool shows a translated label.
268
-
269
- A page-document writer must save translatable copy as a per-language map.
270
- Saving a plain string declares it the same in every language.
271
-
272
- ## Typed props
273
-
274
- A component's props come from its definition, so derive them instead of
275
- writing them twice. `InferProps` types each field by kind (a `select` narrows
276
- to its options), makes a field with a `default` always present and every other
277
- field optional, and merges in whatever `load` returns, exactly as the renderer
278
- does:
279
-
280
- ```astro
281
- ---
282
- import type { InferProps } from "@wocart/theme";
283
- import type { productGrid } from "../catalog";
284
-
285
- const { products = [], limit } = Astro.props as InferProps<typeof productGrid>;
286
- ---
287
- ```
288
-
289
- Define components with `defineComponent` so keys, kinds and options keep their
290
- literal types. Reusable fields are plain values: declare one once and list it
291
- in several definitions.
292
-
293
- ## Composition rules (slots)
294
-
295
- A component declares where children may go; the document says what is there.
296
-
297
- - `slots: [{ name: "default" }]` accepts children. A component with no
298
- `default` slot drops children with `undeclared-children`.
299
- - `multiple: false` keeps the first child and reports the rest
300
- (`slot-not-multiple`).
301
- - `allow: ["product-grid", "note"]` limits a slot to those component types.
302
- Assembly reports a type that is not in the catalog; the reader drops any other
303
- child with `disallowed-child`. Omit `allow` to accept any type.
304
-
305
- `allow` is applied before `multiple: false`, so a disallowed child never
306
- displaces an allowed one. The rules travel in the manifest, so an editor can
307
- offer only valid insertions.
308
-
309
- ## Blocks
310
-
311
- A section is often made of parts a merchant arranges — a product section's
312
- gallery, price, order form, a countdown, "Add to cart" — and some of them must
313
- always be there. Blocks are components placed inside other components.
314
-
315
- **Where a component may go.** `placement: "section"` (the top level of a page),
316
- `"block"` (only inside a slot) or `"both"`; absent means anywhere. The document
317
- operations enforce it; the renderer never does.
318
-
319
- **A component places its own blocks.** The renderer gives every component a
320
- `blocks` prop. Put each slot's blocks, and each fixed block, where the design
321
- wants them — as many slots as the component declares:
322
-
323
- ```astro
324
- ---
325
- import Blocks from "@wocart/theme/astro/Blocks.astro";
326
- import Block from "@wocart/theme/astro/Block.astro";
327
- import { hasBlocks, type BlocksHandle } from "@wocart/theme/astro";
328
-
329
- const { blocks, title } = Astro.props as { blocks: BlocksHandle; title: string };
330
- ---
331
- <section>
332
- {hasBlocks(blocks, "media") && <div class="media"><Blocks of={blocks} slot="media" /></div>}
333
- <h1>{title}</h1>
334
- <Block of={blocks} name="buy-box" />
335
- </section>
336
- ```
337
-
338
- `<slot />` keeps working for children, as before; a component that pulls its
339
- blocks never pays for content pushed into a slot it does not render. `blocks`
340
- is a reserved prop: no field may use that key.
341
-
342
- `isPreview(blocks)` says whether the component renders in an editor's
343
- preview. A section with nothing to show yet — no products picked, no text
344
- written — draws a placeholder there, so a merchant can see and select it; a
345
- shopper's page never shows one.
346
-
347
- **Fixed blocks** are placed by the component's code, like Shopify's static
348
- blocks. Declare them so an editor knows them:
349
-
350
- ```ts
351
- fixed: [{ name: "buy-box", type: "buy-box", label: "Buy box" }],
352
- ```
353
-
354
- An editor changes a fixed block's fields and fills its slots, but cannot
355
- remove, move, copy or hide it. It renders even when the document does not
356
- store it, with its defaults and the id `<owner id>~<name>` (`fixedBlockId`).
357
-
358
- **Required blocks** may move but never disappear. A slot — or a surface, for
359
- its top-level sections — lists them:
360
-
361
- ```ts
362
- slots: [{ name: "default", allow: ["order-form", "countdown", "add-to-cart"], max: 4,
363
- required: [{ type: "order-form", max: 1 }] }],
364
- ```
365
-
366
- The operations refuse to remove, hide or move out the last `min` (default 1)
367
- of a required type, or to insert past its `max`. A document that lacks one —
368
- an old document, a theme update — renders it anyway, added at the end of its
369
- slot, and the reader reports `missing-required-block`.
370
-
371
- **Every product page keeps an order form.** A component with
372
- `role: "order-form"` places orders. Every surface whose `pageType` is
373
- `product` must guarantee at least one through required sections and fixed or
374
- required blocks; assembly refuses it otherwise (`order-form-not-guaranteed`).
375
- Give the order form's entry `max: 1` for exactly one. Its fields are not the
376
- theme's: a store's checkout form is set in the merchant dashboard and enforced
377
- by the server.
378
-
379
- **Limits** (`THEME_LIMITS`): 50 instances per slot, 50 sections per surface,
380
- 1,000 instances per document, 6 levels of nesting below a section. A slot or
381
- surface `max` may set fewer. The reader renders up to a limit and reports
382
- `limit-exceeded`; the operations refuse to go past one. Assembly refuses fixed
383
- and required blocks that contain themselves or nest deeper than the limit.
384
-
385
- ## Style
386
-
387
- A merchant changes how a section sits on the page and which colors it wears
388
- — never by writing CSS. A theme gives sizes to the platform's style steps,
389
- offers style fields, and the SDK writes the CSS.
390
-
391
- **Sizes.** The step names are the platform's (`s0` to `s12`); a theme maps the
392
- ones it offers to pixels, and the widths a section may take:
393
-
394
- ```ts
395
- style: { spacing: { s0: 0, s2: 8, s4: 16, s6: 32, s8: 64 }, widths: { wide: 1280, content: 960, narrow: 640 } },
396
- ```
397
-
398
- **Style fields.** A `style` field names its panels — `spacing` (padding and
399
- margin on the block axis, inline padding, gap), `layout` (direction, align,
400
- justify, wrap) and `size` (width, minimum height). A value holds a choice per
401
- breakpoint — `base` everywhere, `md` from 768 px, `lg` from 1024 px — phone
402
- first, on CSS logical properties so right-to-left stores mirror on their own:
403
-
404
- ```ts
405
- { key: "style", kind: "style", label: "Style", panels: ["spacing", "size"],
406
- default: { spacing: { paddingBlockStart: { base: "s4", lg: "s8" } } } }
407
- ```
408
-
409
- A writer refuses a step or width the theme gives no size; a reader renders one
410
- as no style.
411
-
412
- **Colors.** The store's color schemes are one theme setting, `colorSchemes`:
413
- named schemes, each a hex color for every role (`background`, `text`,
414
- `heading`, `accent`, `button`, `buttonText`, `border`). A `colorScheme` field
415
- picks one by id. A page document never holds a color.
416
-
417
- **In the page.** Give the element its classes, serve the stylesheet, and put
418
- the schemes in the `<head>`:
419
-
420
- ```astro
421
- ---
422
- import { schemeClass, styleClasses } from "@wocart/theme";
423
- const { style, scheme } = Astro.props;
424
- ---
425
- <section class={`${schemeClass(scheme)} ${styleClasses(style)}`}>…</section>
426
- ```
427
-
428
- ```ts
429
- // src/pages/wocart-styles.css.ts
430
- import { themeStylesEndpoint } from "@wocart/theme/astro";
431
- export const prerender = true;
432
- export const GET = themeStylesEndpoint(theme);
433
- ```
434
-
435
- ```astro
436
- <!-- the layout's <head> -->
437
- <ThemeStyles schemes={settings.colorSchemes} /> <!-- @wocart/theme/astro/ThemeStyles.astro -->
438
- ```
439
-
440
- The schemes become custom properties on `.wc-scheme-<id>`
441
- (`--wc-color-background`, `--wc-color-button-text`, …) with the background
442
- and text applied. `schemeClass(id)` also gives the element `wc-scheme`
443
- (`SCHEME_MARKER_CLASS`), the one class every element wearing a scheme
444
- carries. Derive the theme's own tokens there and on the page, so they follow
445
- the scheme of the nearest element that wears one:
446
-
447
- ```css
448
- :root, .wc-scheme {
449
- --surface: var(--wc-color-background, #fff);
450
- --ink: var(--wc-color-text, #1d1d1f);
451
- --line: color-mix(in srgb, var(--wc-color-border, #e5e5e5) 70%, transparent);
452
- }
453
- ```
454
-
455
- Without that rule a token computed once on `:root` keeps the page's colors
456
- inside a section that wears another scheme. On a page with a Content-Security-Policy, `ThemeStyles` adds
457
- its inline sheet's hash to the policy, so `style-src` needs no
458
- `'unsafe-inline'`. `contrastRatio(a, b)` gives the WCAG ratio an editor warns
459
- on (below 4.5 for text).
460
-
461
- ## Pages and templates
462
-
463
- A surface is a document a theme can render. Declare the platform page type it
464
- renders and, optionally, its default document:
465
-
466
- ```ts
467
- import homeDocument from "./documents/home.json";
468
-
469
- surfaces: [
470
- { key: "home", label: "Home", pageType: "home", document: homeDocument },
471
- { key: "product", label: "Product", pageType: "product" },
472
- { key: "product.compact", label: "Compact product", pageType: "product" },
473
- ],
474
- ```
475
-
476
- - A page type with a resource — `product`, `category`, `collection`, `page`,
477
- `landing` — makes the surface a **template**: one document renders every
478
- product (or category…), and its sections read the resource from
479
- `context.params` (see the route example under Ownership rules). There is no
480
- document per product.
481
- - `product.compact` is an **alternate** of `product`: the base must be
482
- declared and render the same page type.
483
- - A surface's `document` is checked at assembly against the surface and the
484
- catalog, and travels in the manifest as the theme's default.
485
- - `capabilities: { surfaces: ["product"] }` keeps a component to the surfaces
486
- it can serve (alternates included); `isInsertableOn(component, surface)`
487
- applies the rule.
488
-
489
- **Areas** are the parts every page shares — a header, a footer — composed of
490
- sections like a page and edited once for the whole store:
491
-
492
- ```ts
493
- areas: [
494
- { key: "header", label: "Header", document: headerDocument, required: [{ type: "menu-bar", max: 1 }], max: 4 },
495
- { key: "footer", label: "Footer" },
496
- ],
497
- ```
498
-
499
- An area's document is the surface `area:<key>` (`areaSurface("header")`):
500
- read, completed, checked and edited like a page's, with `required` and `max`
501
- as a surface has them. A component names `area:header` in
502
- `capabilities.surfaces` to stay in the header. The layout renders it like a
503
- page: `resolveSurfaceDocument(theme, "area:header", await store.pageData.get("area:header"))`.
504
-
505
- A template's preview names the resource it shows: the preview draft's `path`
506
- (`/products/robe-kabyle`), which `@wocart/storefront`'s `matchStorefrontPath`
507
- turns into the page type and the route's params.
508
-
509
- ## Presets
510
-
511
- A preset is a ready-made configuration an editor offers when inserting a
512
- component — props over the field defaults, and child components for its slots:
513
-
514
- ```ts
515
- presets: [
516
- {
517
- key: "with-grid",
518
- label: "Hero with products",
519
- props: { layout: "columns" },
520
- children: [{ type: "hero", props: { align: "center" } }],
521
- slots: { aside: [{ type: "product-grid", props: { limit: 2 } }] },
522
- },
523
- ],
524
- ```
525
-
526
- `instantiatePreset(theme, type, key, newId)` builds the instances, each with an
527
- id from `newId`. Presets are checked at assembly by building them. A preset's
528
- props may hold rich text as it is written:
529
-
530
- ```ts
531
- const welcome: RichTextDocument = { type: "doc", version: 1, content: [{ type: "paragraph", content: [{ type: "text", text: "Bienvenue" }] }] };
532
- presets: [{ key: "welcome", label: "Welcome", props: { body: welcome } }],
533
- ```
534
-
535
- ## Hidden sections
536
-
537
- `{ "id": "…", "type": "hero", "hidden": true }` keeps a section in the
538
- document without rendering it — a merchant switched it off. The renderer skips
539
- it, its `load` and its children; the reader still checks it.
540
-
541
- ## Editing documents
542
-
543
- Every writer — an editor, a CLI, an assistant — applies the same rules
544
- through pure operations that return a new document or say why they refused:
545
-
546
- ```ts
547
- import { createInstance, insertInstance, moveInstance, newInstanceId, checkPageData } from "@wocart/theme";
548
-
549
- const edit = insertInstance(theme, page, createInstance(theme.components.hero, newInstanceId()), { index: 0 });
550
- if (!edit.ok) console.log(edit.issue.code, edit.issue.message); // "not-available", "slot-full", …
551
37
  ```
552
38
 
553
- `insertInstance`, `removeInstance`, `moveInstance`, `duplicateInstance`,
554
- `setInstanceProps` and `setInstanceHidden` never change their input. They
555
- refuse a component that is `insertable: false`, not available on the
556
- document's surface or not placeable there, a slot that does not take it or is
557
- full, an index out of range, a cycle, an id already in the document, a value
558
- its field refuses, any change to a fixed block but its fields
559
- (`fixed-block`), losing a required block (`required-block`) and going past a
560
- limit (`limit-exceeded`). `removeInstance(theme, page, id)` and
561
- `setInstanceHidden(theme, page, id, hidden)` take the theme first; their older
562
- forms without it still refuse a fixed block but cannot know a slot's required
563
- types. `checkPageData(theme, page)` is the strict check of a whole document
564
- before saving it; `findInstance` locates an instance, a fixed block included.
565
-
566
- Open a document with `completePage(theme, page)`: it adds what the document
567
- must hold — required sections, fixed blocks, required blocks, each with an id
568
- unique in the document — so what an editor shows is what renders. Inserting a
569
- component completes it the same way.
570
-
571
- An editor holds a theme's manifest, not its code:
572
- `manifestCatalog(manifest)` makes the catalog these functions take, with no
573
- component implementations.
574
-
575
- ## Preview
576
-
577
- An editor shows a merchant's unsaved draft on the live storefront, rendered by
578
- the theme itself — the same components, data and CSS a shopper gets. A theme
579
- that does this sets `preview: true` (the manifest says so) and serves a route
580
- at `THEME_PREVIEW_PATH` (`/wocart-preview`). The editor frames it with
581
- `GET /wocart-preview?token=…&surface=home&locale=fr`, and the framed page
582
- re-renders each draft the editor sends by posting it back to the route as
583
- JSON — never a cross-site form, so Astro's `checkOrigin` stays on. Three parts
584
- meet there:
585
-
586
- 1. **The request.** `readPreviewRequest` from `@wocart/storefront/server`
587
- checks the editor's signed token and returns the draft — which surface and
588
- language to show and, once the editor sends one, the document, settings
589
- and text edits (its guide: "Previewing a draft for an editor"). What a
590
- draft leaves out renders as saved. The route answers with
591
- `previewResponseHeaders`, so only that editor can frame it and nothing
592
- stores it; no route rule may cache it.
593
- 2. **The markers.** `<InstanceView preview …>` brackets each instance's nodes
594
- with two comments — `wocart:start id=… type=…[ hidden]` and
595
- `wocart:end id=…` (`previewStartMarker`, `previewEndMarker`,
596
- `parsePreviewMarker`). Comments, not wrapper elements, so a grid or a
597
- `> *` selector lays out exactly as it does for a shopper; a hidden instance
598
- keeps its markers with nothing between them. A shopper's page has none.
599
- 3. **The bridge.** `startPreviewBridge({ editorOrigin, token })` from
600
- `@wocart/theme/preview` runs on the preview page. It reports every marked
601
- instance to the editor, turns clicks into selections, and re-renders the
602
- drafts the editor sends — swapping in only the sections whose markup
603
- changed, or the whole page when sections were added, moved or hidden, or
604
- the page around them changed.
605
-
606
- ```astro
607
- ---
608
- // src/pages/wocart-preview.astro — the middleware read the request
609
- export const prerender = false;
610
- const { claims, token } = Astro.locals.preview;
611
- for (const [name, value] of Object.entries(previewResponseHeaders(claims))) Astro.response.headers.set(name, value);
612
- ---
613
- <Layout>
614
- {sections.map((instance) => (
615
- <InstanceView preview {instance} {theme} {context} {storefront} {reads} />
616
- ))}
617
- <wocart-preview-bridge data-editor={claims.editor} data-token={token} hidden />
618
- </Layout>
619
- <script>
620
- import { startPreviewBridge } from "@wocart/theme/preview";
621
- const host = document.querySelector<HTMLElement>("wocart-preview-bridge")!;
622
- startPreviewBridge({ editorOrigin: host.dataset.editor!, token: host.dataset.token! });
623
- </script>
624
- ```
625
-
626
- The bridge draws nothing — outlines, toolbars and panels are the editor's. It
627
- posts only to `editorOrigin` and reads only messages from it, sent by the
628
- framing window, with `source: "wocart-editor"` and the protocol's `version`
629
- (`PREVIEW_PROTOCOL_VERSION`):
39
+ ## Entry points
630
40
 
631
- | The editor sends | The page answers |
41
+ | Import | Holds |
632
42
  | --- | --- |
633
- | — | `ready` with every instance: `{ id, type, parent, hidden, rect }` |
634
- | `render` `{ draft, revision? }` | `rendered` `{ revision, instances }`, or `error` `{ revision, reason }`; an answer overtaken by a newer render or a patch is dropped |
635
- | `patch` `{ patches }` | the change shown at once, on the page as it stands, then `layout` (see below) |
636
- | `mode` `"edit"` \| `"interactive"` | in `edit`, links and forms do nothing and a click is `select` `{ id }` of the innermost instance |
637
- | `scroll-to` `{ id }` | the instance scrolls into view |
638
- | `token` `{ token }` | the next render uses it |
639
- | — | `hover` `{ id \| null }` as the pointer moves; `layout` after a scroll or resize |
640
-
641
- A `patch` shows a change the editor has already made to its draft before the
642
- render that follows it arrives — so moving, copying, removing or hiding a
643
- section, and typing a field's text, show on the page at once. Each patch
644
- (`PreviewPatch`) is a hint: the page applies what it finds — `move` a section
645
- to an index, `duplicate` an instance under new ids, `remove` one and
646
- `restore` it, `hide` one or show it again, replace `text` that reads `from`
647
- inside an instance with `to` — and leaves the rest to the render, which is
648
- the page's truth. An editor therefore always sends the render too; a page
649
- from before 1.6.0 ignores patches and still shows every change, a moment
650
- later.
651
-
652
- A swapped section's scripts do not run again. A section with browser behavior
653
- binds it again on `PREVIEW_SECTION_LOAD_EVENT` (`wocart:section-load`,
654
- dispatched on `document` with `detail.id`) — also for a section a patch
655
- copied, restored or showed again. `bindEach` (see [Browser scripts](#browser-scripts))
656
- does it for you. When the bridge replaces the whole
657
- page instead, it does what Astro's router does after a navigation — a new
658
- `<body>`, the root element's attributes, the title — and dispatches
659
- `astro:page-load`, so a script written for the router mounts again as it does
660
- there. Changed stylesheets are replaced in place; unchanged ones never reload.
661
-
662
- ### Which page a preview shows
663
-
664
- `previewTarget(theme, draft, client)` from `@wocart/theme/astro` decides it
665
- for every theme: the surface to render, its route parameters and its
666
- document — or null, answered 404, for a surface the theme does not declare.
667
-
668
- - A template previews the resource the draft's path names, else the store's
669
- first product, category, default collection or text page; a store with
670
- none still previews, with empty `params`, so its sections can be arranged.
671
- - An area (`area:header`) previews on the page its path names when the theme
672
- composes it, else home; `area` names it, and the page's own document is
673
- the saved one.
674
- - The resource is chosen by the surface's `pageType`, so an alternate
675
- (`product.compact`) previews like its base.
676
-
677
- ```astro
678
- ---
679
- const target = await previewTarget(theme, Astro.locals.preview.draft, Astro.locals.store.client);
680
- if (!target) return new Response(null, { status: 404, headers: { "Cache-Control": "no-store" } });
681
- ---
682
- <Sections surface={target.surface} params={target.params} document={target.document} preview={target.area === null} />
683
- ```
684
-
685
- ## Browser scripts
686
-
687
- A theme's browser behaviour meets markup three ways: the page loads, Astro's
688
- router swaps the page (`astro:page-load`), an editor's preview swaps one
689
- section (`PREVIEW_SECTION_LOAD_EVENT`). `@wocart/theme/lifecycle` binds it
690
- once per element whichever way it came:
691
-
692
- ```ts
693
- import { bindEach, onEachPage } from "@wocart/theme/lifecycle";
694
-
695
- bindEach<HTMLFormElement>("[data-order-form]", (form) => {
696
- const onResize = () => layout(form);
697
- window.addEventListener("resize", onResize);
698
- return () => window.removeEventListener("resize", onResize);
699
- });
700
-
701
- onEachPage(() => {
702
- const timer = setInterval(tick, 1000);
703
- return () => clearInterval(timer);
704
- });
705
- ```
706
-
707
- A bind's teardown runs when its element leaves — before Astro swaps the page
708
- out, or when a later pass finds it gone — so document and window listeners
709
- never pile up. Both return a function that stops them.
710
-
711
- ## Theme manifest (serializable catalogue)
712
-
713
- A `ThemeDefinition` mixes data (types, fields, defaults) with runtime (the live
714
- component reference, the server-only `load` hook), so tooling cannot read it
715
- without executing theme modules. `themeManifest(theme)` derives the data-only
716
- half — a JSON-serializable `ThemeManifest` — for editors, CLIs, and marketplaces:
717
-
718
- ```ts
719
- import { themeManifest, validateThemeManifest } from "@wocart/theme";
720
-
721
- const manifest = themeManifest(theme);
722
- // {
723
- // contractVersion: 1,
724
- // key: "my-theme", version: "1.0.0",
725
- // sdkVersion: "1.5.0", // the @wocart/theme that generated it
726
- // surfaces: [], // declared surfaces (possibly none)
727
- // locales: [], settings: [], content: {},
728
- // components: { hero: { type, label, group, fields, slots, capabilities, presets } },
729
- // } — no `component`, no `load`, JSON-round-trip stable.
730
-
731
- const { manifest: checked, issues } = validateThemeManifest(
732
- JSON.parse(JSON.stringify(manifest)),
733
- );
734
- // `checked` is non-null only for a structurally sound manifest; a non-semver
735
- // theme `version` is the one advisory case (manifest returned with an
736
- // `invalid-version` issue). Declare extra surfaces on the theme when it can
737
- // render them: `defineTheme({ key, version, surfaces: [{ key: "home",
738
- // label: "Home" }], components })` — declaration only, never enforcement:
739
- // page parsing and resolution never consult the list.
740
- ```
741
-
742
- ### Manifest guarantees
743
-
744
- What a validated manifest promises a consumer:
745
-
746
- - **Derived, not authored.** The manifest is generated from the theme's
747
- `ThemeDefinition` by `themeManifest()` — there is exactly one source of truth.
748
- A hand-written manifest is validated strictly, but never treated as the
749
- catalogue; the theme's code is.
750
- - **Data-only.** No component implementations, loaders, functions, symbols,
751
- bigints, non-finite numbers, or circular references. `validateThemeManifest`
752
- rejects all of these as `non-serializable`, including `component`/`load` keys
753
- whose values would otherwise serialize.
754
- - **Normalized shapes.** `fields` and `slots` are always arrays (absent means
755
- `[]`); every record key equals its component's `type`. Unknown keys are
756
- **stripped, not carried** — so an additive field introduced by a newer producer
757
- under the same `contractVersion` is ignored by this reader rather than
758
- failing it. That is the forward-compat tradeoff: old readers stay green, but
759
- they cannot see new metadata until updated.
760
- - **Validation mirrors assembly.** Relational rules (`contentKey`/`default`
761
- exclusivity, number `default` within `min`/`max`, select `default` in
762
- `options`, slot-name shape, key/type agreement) are checked identically by
763
- `validateThemeDefinition` and `validateThemeManifest`, so a manifest derived
764
- from a valid theme always validates clean.
765
-
766
- What it deliberately excludes: implementations and loaders (runtime-only).
767
- Content packs travel only when the theme declares them (`defineTheme({
768
- content })`).
769
-
770
- ### The manifest as a build artifact
771
-
772
- A theme's definition imports its `.astro` components, so it cannot be loaded
773
- outside Astro's build. Emit the manifest as a prerendered page at
774
- `THEME_MANIFEST_PATH` (`/wocart-theme.json`):
775
-
776
- ```ts
777
- // src/pages/wocart-theme.json.ts
778
- import { themeManifestEndpoint } from "@wocart/theme/astro";
779
- import { theme } from "../theme/theme";
780
- export const prerender = true;
781
- export const GET = themeManifestEndpoint(theme);
782
- ```
783
-
784
- The manifest is validated when the module loads: an invalid one fails the
785
- build instead of shipping.
786
-
787
- ### What changes between versions
788
-
789
- `diffThemeManifests(previous, next)` lists the changes saved documents,
790
- settings and text edits may not survive (`breaking`) — a removed component,
791
- field, slot, option, surface or content key; a changed kind or page type; a
792
- narrowed range, list, `allow` list or rich-text formats; a slot made single; a
793
- field newly required with no default — and those that only add. A breaking
794
- change belongs in a new major version of the theme.
795
-
796
- ### Versioning policy
797
-
798
- Three versioned contracts coexist — keep them separate:
799
-
800
- - `contractVersion` identifies the **manifest schema and interpretation
801
- contract**. It starts at `1` and increments only on a breaking change to the
802
- manifest shape or how consumers must interpret it. Ordinary additive,
803
- backward-compatible metadata does not bump it. An unsupported version fails
804
- clearly (`unsupported-contract`), never silently.
805
- - The theme's own `version` (e.g. `"1.0.0"`) versions the **theme**. The runtime
806
- `ThemeDefinition` contract accepts any non-empty string (unchanged); the
807
- manifest validator additionally *reports* (never rejects) a non-semver value
808
- so version hygiene is visible without breaking registration.
809
- - `PageData.version` (currently `1`) versions the **page-document envelope** —
810
- untouched by the manifest contract.
811
-
812
- **One named slot per instance.** The reference renderer fills at most one named
813
- slot; a second named slot *with content* is reported as
814
- `multiple-slots-unsupported` and is never silently dropped. This is an Astro
815
- compiler boundary (a dynamic child expression can lift only one slot attribute
816
- into the slots-object key), not a temporary limitation — see `SlotDefinition`
817
- for the escape hatch.
818
-
819
- ## Ownership rules
820
-
821
- The Theme SDK owns composition mechanics: code-owned catalogs, JSON page data,
822
- field validation, defaults, slot gating, server-only loads, per-render read
823
- sharing, and structured degradation reports. A theme owns its Astro markup,
824
- layout, styles, copy packs, route adapters, and browser interaction wiring.
825
- The Storefront SDK owns commerce data, browser state machines, server adapters,
826
- screen-value projections, and platform error contracts.
827
-
828
- `InstanceView` does not write page documents, mutate catalog definitions, call
829
- the dashboard, or create themes. Editors, CLIs, publishing, and persistence
830
- are outside this package.
831
-
832
- For a real Astro theme, create one `ReadCache` outside the section map, pass a
833
- request-built `ThemeRenderContext`, and pass the server-only `StoreClient`
834
- explicitly. A `load` function must read through `reads.get(key, loader)` so
835
- siblings share a resource within that render. Validate the catalog during the
836
- theme's build/test path and treat non-empty issue arrays as actionable
837
- diagnostics.
838
-
839
- On a templated surface (`/products/[slug]`, `/category/[slug]`), pass
840
- `params: Astro.params` in the render context. A section reads its route
841
- resource from `context.params`, under a `reads` key that includes the
842
- parameter, so the page and every section share one read:
843
-
844
- ```ts
845
- load: async ({ storefront, reads, context }) => ({
846
- product: await reads.get(`product:${context.params?.slug}`, () =>
847
- storefront.products.get(context.params?.slug ?? ""),
848
- ),
849
- }),
850
- ```
851
-
852
- ## Saved documents
853
-
854
- A stored page document can be missing, malformed, or built for a catalog that
855
- has since changed. The SDK reports every degradation and never throws the page
856
- away. Which document to render instead is a product decision; the recommended
857
- policy is:
858
-
859
- 1. **No saved document** → render the theme's shipped default document.
860
- 2. **Envelope invalid** (`parsePageData` returns `page: null`) → render the shipped default.
861
- 3. **Saved sections map to no catalog type at all** (a theme switch left nothing
862
- renderable) → render the shipped default.
863
- 4. **Partial overlap** → render what maps. Unknown types render nothing and are
864
- reported through `onIssue`.
865
- 5. **An empty saved document** → honor it. A page the merchant emptied renders empty.
866
-
867
- `resolveSurfaceDocument(theme, surface, saved)` applies this policy — and
868
- also renders the default for a document saved for another surface or another
869
- theme — then completes the result (`completePage`). The shipped default is
870
- the surface's `document`:
871
-
872
- ```ts
873
- const { page, source, reason, issues } = resolveSurfaceDocument(theme, "home", await store.pageData.get("home"));
874
- // render page.sections; log reason and issues when source is "default"
875
- ```
876
-
877
- ## Theme settings
878
-
879
- A theme's global choices — its font, which menu the header shows, a header
880
- color — are **settings**: declared once on the theme, in groups, with the same
881
- field kinds as a section. The merchant's values are stored per store and per
882
- theme (a value only means something against the theme that declared it) and
883
- reach the theme as `StoreConfig.themeSettings` from `@wocart/storefront`.
884
-
885
- ```ts
886
- export const theme = defineTheme({
887
- key: "my-theme",
888
- version: "1.0.0",
889
- locales: ["ar", "fr"],
890
- settings: [
891
- {
892
- key: "header",
893
- label: { ar: "الرأس", fr: "En-tête" },
894
- fields: [
895
- { key: "headerMenu", kind: "menu", label: { ar: "القائمة", fr: "Menu" }, default: "main-menu" },
896
- { key: "font", kind: "select", label: { ar: "الخط", fr: "Police" }, options: ["Cairo", "Tajawal"], default: "Cairo" },
897
- ],
898
- },
899
- ],
900
- components: { … },
901
- });
902
-
903
- const { values, issues } = resolveThemeSettings(theme, config?.themeSettings, { locales: [locale] });
904
- values.headerMenu; // "main-menu" — values: InferSettings<typeof theme>, from 1.1.1
905
- ```
906
-
907
- - **Reading never fails.** Each setting takes the merchant's value when the
908
- declaration accepts it, else the default (or the content pack, for text),
909
- else it is absent. A value the theme no longer accepts — a removed option, a
910
- color that is not one, a setting since deleted — is reported in `issues`
911
- (`invalid-setting`, `unknown-setting`, `invalid-settings`) and falls back.
912
- - **Keys are unique across groups**; groups are for the editor, values are flat.
913
- - **A writer checks strictly** with `checkThemeSettings(theme, values)` before
914
- saving: every key declared, every value accepted, in every language.
915
- - Settings travel in the manifest (`manifest.settings`), so an editor lists
916
- them without running the theme.
917
- - Store identity — the logo, the brand colors — is not a theme setting: it
918
- belongs to the store and survives a theme change.
919
-
920
- ## Content and copy
921
-
922
- The theme owns its content. The SDK does not model a content pack, a taxonomy,
923
- or a copy system — it only reads the resolved pack the request hands it.
924
-
925
- - **`ThemeRenderContext.content`** is an opaque, flat `Record<string, string>` —
926
- the theme's pack with store overrides already merged.
927
- - **`FieldDefinition.contentKey`** names a key *in that map*. It is the only
928
- bridge between the contract and the theme's copy.
929
- - **The precedence chain** is: instance value → `content[contentKey]` →
930
- `field.default` → omit (the component's own default applies last). A field
931
- with neither `contentKey` nor `default` is simply absent from `props`.
932
- - **Declare the packs on the theme** — `defineTheme({ content: { ar, fr } })`
933
- — and every `contentKey` must exist in every pack, each declared language
934
- needs its pack, and the manifest carries them, so an editor shows the text a
935
- field inherits and lists the labels a merchant may override.
936
- - **Or check against a key set.** `validateThemeDefinition(theme, {
937
- contentKeys })` reports every `contentKey` missing from it as
938
- `unknown-content-key`; an explicit set wins over declared packs, and an empty
939
- array reports every declared key.
940
-
941
- Both ways of consuming copy are legitimate, and the theme picks: a component can
942
- take per-field props, or read the whole pack and overlay the resolved values
943
- itself. The SDK deliberately ships neither helper — that would promote one
944
- theme's implementation choice into platform API.
945
-
946
- ### Messages: placeholders and plurals
947
-
948
- `formatMessage(pack, key, values, { locale, formatNumber? })` is the SDK's one
949
- message rule — Shopify's locale-file model on the platform's own CLDR data
950
- (`Intl.PluralRules`), no message-format dependency:
951
-
952
- - `{name}` placeholders: every occurrence is filled, numbers are formatted for
953
- the language (or by `formatNumber`), an unknown placeholder stays visible.
954
- - A plural message is flat keys, one per CLDR form — `cartItems_one`,
955
- `cartItems_few`, `cartItems_other` — chosen by `values.count`; `_other` is
956
- the fallback, and an explicit `_zero` is used for 0 in any language.
957
- - `pluralFormIssues(pack, locale)` lists the forms a pack is missing for its
958
- language, or carries but never uses — run it over every pack in a test.
959
- - `messageForms(pack, key)` collects one message's keys to hand a browser
960
- script; `pluralMessageKeys(pack)` names every plural message in a pack.
961
-
962
- Browser code imports the same functions from `@wocart/theme/messages`, an
963
- entry with nothing else in it.
964
-
965
- Packs stay `Record<string, string>`, so store overrides and the editor treat
966
- plural forms like any other key.
967
-
968
- ## Capabilities are advisory
969
-
970
- `ComponentDefinition.capabilities` (`insertable`, `surfaces`) is metadata for an **editor/CLI insertion
971
- surface**, not renderer enforcement. Capabilities several components share go
972
- through `defineCapabilities`:
973
-
974
- ```ts
975
- const ANY_PAGE = defineCapabilities({ surfaces: ["home", "product", "collection"] });
976
- const grid = defineComponent({ type: "product-grid", capabilities: ANY_PAGE, /* … */ });
977
- ```
978
-
979
- A plain constant inferred from a literal (`{ surfaces: [...] as const }`) can
980
- make TypeScript read a definition's `fields` in two shapes when its `load`
981
- types its props from them; the error then names an unrelated field. Neither `parsePageData` nor
982
- `resolveInstance` consults it: a saved document containing a component marked
983
- `insertable: false` still renders, because the render path never second-guesses
984
- a document that was already saved. Any future enforcement belongs at insertion
985
- time, in the writer.
986
-
987
- ## Compatibility
988
-
989
- Page data is a persisted format, and the reader is deliberately forgiving — but
990
- forgiveness is degradation with a report, not silent migration. There is no
991
- migration engine, by design; what follows is the whole policy:
992
-
993
- - **Envelope version.** `PageData.version` is `1`, and only `1` parses. Unknown
994
- *top-level* keys are carried through verbatim (a writer may record metadata
995
- without a version bump); unknown keys *inside* an instance are stripped.
996
- - **Removed or renamed component type.** The section reports `unknown-type` and
997
- renders nothing; siblings survive. Never rename a `type` once documents
998
- reference it — treat it as a breaking change for saved pages.
999
- - **Renamed, removed, or retyped field.** A stored value for an undeclared key
1000
- reports `unknown-prop` and is ignored; a value that fails the field's kind or
1001
- bounds reports `invalid-prop` and falls through the defaults chain
1002
- (instance → `content[contentKey]` → `field.default` → omit). A `required`
1003
- field with no resolvable value reports `missing-required-prop`.
1004
- - **Removed slot or narrowed `allow` list.** Saved children that no longer fit
1005
- report `unknown-slot` or `disallowed-child` and are dropped. Widen a slot's
1006
- rules freely, but narrow them only as a breaking change for saved pages.
1007
- - **Duplicate instance ids.** First wins depth-first; later duplicates are
1008
- dropped with `duplicate-id`. Generate stable unique ids at creation.
1009
- - **What never breaks the page.** Any section-level failure preserves the rest
1010
- of the page. Only an envelope failure (`version`, `surface`, `sections`
1011
- shape) returns `page: null`.
1012
- - **Theme versions.** The runtime accepts any non-empty `version` string;
1013
- stick to semver so the manifest validator stays quiet, and compare releases
1014
- with `diffThemeManifests`.
1015
- - **Provenance.** A document may name the theme that wrote it,
1016
- `"theme": { "key", "version" }`; `validatePageData` reports `theme-mismatch`
1017
- for another theme's document, and a malformed one is dropped with
1018
- `invalid-provenance`.
1019
- - **Features and renderers.** An older renderer strips instance keys it does
1020
- not know, so it would render a `hidden` section. The manifest's `sdkVersion`
1021
- says which renderer a theme ships; an editor writes `hidden` only for 1.5.0
1022
- or later.
1023
-
1024
- ## Images
1025
-
1026
- `@wocart/theme/image-service` resizes every `<Image>` of a media-domain image
1027
- with Cloudflare Image Resizing, on the media domain itself
1028
- (`https://<media>/cdn-cgi/image/…`). The media domain is a Cloudflare zone
1029
- wherever the storefront runs, so a storefront on Cloudflare and one on Vercel
1030
- share the same URLs and edge cache. Any other image is served as it is; with no
1031
- media domain, every image passes through.
1032
-
1033
- The domain is read on each image, not at build — one build serves every store.
1034
- Bind it once:
1035
-
1036
- ```ts
1037
- // src/image-service.ts
1038
- import { MEDIA_DOMAIN } from "astro:env/server";
1039
- import { mediaImageService } from "@wocart/theme/image-service";
1040
- export default mediaImageService(() => MEDIA_DOMAIN);
1041
- ```
1042
-
1043
- ```js
1044
- // astro.config.mjs
1045
- image: { service: { entrypoint: "./src/image-service.ts" } },
1046
- // @astrojs/cloudflare: imageService: "custom" · @astrojs/vercel: leave imageService off
1047
- ```
1048
-
1049
- The media domain's zone needs Image Transformations on (Images →
1050
- Transformations; included in the Free plan).
1051
-
1052
- ## Testing your theme
1053
-
1054
- A theme proves it honors the contract with four checks in its own test suite:
1055
-
1056
- 1. **The catalog assembles clean** — with its packs declared on the theme,
1057
- its default documents and presets are checked too:
1058
- `expect(validateThemeDefinition(theme)).toEqual([])`. To check something
1059
- of every component, iterate `componentsOf(theme)`, which lists them as
1060
- `ComponentDefinition`s.
1061
- 2. **The manifest survives a JSON round trip**:
1062
- `validateThemeManifest(JSON.parse(JSON.stringify(themeManifest(theme))))`
1063
- returns no issues.
1064
- 3. **Every shipped document validates**:
1065
- `validatePageData(theme, document, { content: pack, locales: [locale] })`
1066
- returns no issues for each default page the theme ships.
1067
- 4. **Every shipped document renders**: render each section through
1068
- `InstanceView` with Astro's Container API, one `createReadCache()` per page
1069
- and a stand-in `StoreClient`, and assert that `onIssue` receives nothing
1070
- unexpected:
1071
-
1072
- ```ts
1073
- import { experimental_AstroContainer as AstroContainer } from "astro/container";
1074
- import { createReadCache, type RendererIssue } from "@wocart/theme/astro";
1075
- import InstanceView from "@wocart/theme/astro/InstanceView.astro";
1076
- import type { StoreClient } from "@wocart/storefront/server";
1077
-
1078
- const container = await AstroContainer.create();
1079
- const reads = createReadCache();
1080
- const issues: RendererIssue[] = [];
1081
- const storefront = { products: { list: async () => products } } as unknown as StoreClient;
1082
-
1083
- for (const section of page.sections) {
1084
- await container.renderToString(InstanceView, {
1085
- props: { instance: section, theme, context, storefront, reads, onIssue: (i) => issues.push(i) },
1086
- });
1087
- }
1088
- expect(issues).toEqual([]);
1089
- ```
1090
-
1091
- Also test the degradations you rely on, such as a failing `load` still
1092
- rendering base props.
1093
-
1094
- `createInstance` refuses to build an instance the reader would flag: wrong-typed
1095
- values, unknown keys, and a `required` field with no value, default, or
1096
- `contentKey`.
1097
-
1098
- ## Versioning
1099
-
1100
- This package follows [semantic versioning](https://semver.org/): breaking
1101
- changes ship only in a new major version and are listed in the
1102
- [changelog](./CHANGELOG.md). The manifest's `contractVersion` and the page
1103
- document's `version` are versioned separately, as described above.
43
+ | `@wocart/theme` | definitions, validation, page documents, resolution, rich text, the manifest, languages and messages |
44
+ | `@wocart/theme/astro` | the read cache, the manifest and styles endpoints, the preview target |
45
+ | `@wocart/theme/astro/InstanceView.astro`, `Blocks.astro`, `Block.astro`, `ThemeStyles.astro` | the reference renderer |
46
+ | `@wocart/theme/image-service` | `mediaImageService` — resizes store images through the store's media delivery |
47
+ | `@wocart/theme/preview` | the preview bridge, for a theme's preview page |
48
+ | `@wocart/theme/lifecycle`, `@wocart/theme/messages` | small browser modules: binding behaviour across page swaps, messages and plurals |
49
+
50
+ [CHANGELOG.md](./CHANGELOG.md) lists every release.
1104
51
 
1105
52
  ## License
1106
53