@wocart/theme 1.3.0 → 1.5.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,79 @@ 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
+ ## 1.5.0 — 2026-10-18
9
+
10
+ The editor-facing contract: everything a visual editor needs to discover and
11
+ edit a theme, from its manifest alone.
12
+
13
+ ### Added
14
+
15
+ - **Field kinds** `image` (`MediaReference`: the object key in the store's
16
+ media, never a URL, with per-language `alt`, size and focal point),
17
+ `richtext` (`RichTextDocument`, a closed JSON format with `formats`),
18
+ `list` (items `{ id, props }`, `min`/`max`, `itemLabel`, default items),
19
+ `product` and `category` (a handle or slug), and `step` on `number`.
20
+ `richTextToHtml`, `richTextToPlainText`, `RICH_TEXT_FORMATS`.
21
+ - `checkFieldValue(field, value)`: the writer's strict check of one value.
22
+ - `optionLabels` on a `select` field: the words an editor shows for each
23
+ option, per language; the stored value stays the option.
24
+ - **Pages and templates**: `SurfaceSpec.pageType` (the storefront's page
25
+ types; a resource page type makes a reusable template), alternates
26
+ (`product.compact`), and `SurfaceSpec.document`, the theme's default.
27
+ - `capabilities.surfaces` and `isInsertableOn(component, surface)`.
28
+ - **Presets** on a component, built with `instantiatePreset`.
29
+ - `ComponentInstance.hidden`: kept in the document, not rendered.
30
+ - `PageData.theme` provenance; issue codes `invalid-provenance` and
31
+ `theme-mismatch`.
32
+ - `ThemeDefinition.content`: the theme's packs; every `contentKey` must exist
33
+ in each, and the manifest carries them.
34
+ - **Document operations**: `insertInstance`, `removeInstance`,
35
+ `moveInstance`, `duplicateInstance`, `setInstanceProps`,
36
+ `setInstanceHidden`, `findInstance`, `newInstanceId`, and `checkPageData`,
37
+ the strict check of a whole document before saving it.
38
+ - `manifestCatalog(manifest)`: the catalog every data function takes, from a
39
+ manifest — an editor never needs the theme's code.
40
+ - `ThemeManifest.sdkVersion`, `content` and per-component `presets`;
41
+ `THEME_SDK_VERSION`.
42
+ - `themeManifestEndpoint(theme)` (`@wocart/theme/astro`) and
43
+ `THEME_MANIFEST_PATH`: the manifest as a prerendered build artifact; an
44
+ invalid manifest fails the build.
45
+ - `diffThemeManifests(previous, next)`: which changes between two versions of
46
+ a theme are breaking for saved data, and which only add.
47
+ - **Preview**: `ThemeDefinition.preview` and `ThemeManifest.preview`;
48
+ `THEME_PREVIEW_PATH`, `PREVIEW_PROTOCOL_VERSION` and the protocol's message
49
+ types; `<InstanceView preview>` marks each instance with comments
50
+ (`previewStartMarker`, `previewEndMarker`, `parsePreviewMarker`); and the
51
+ `@wocart/theme/preview` entry, `startPreviewBridge` and
52
+ `PREVIEW_SECTION_LOAD_EVENT`, the preview page's side of the protocol.
53
+
54
+ ### Changed
55
+
56
+ - `FieldKind`, `FieldDefinition`, `PageDataIssueCode`,
57
+ `ThemeDefinitionIssueCode` and `ThemeManifestIssueCode` have new members: an
58
+ exhaustive `switch` over them needs the new cases.
59
+ - `ComponentSpec.presets` and `ThemeManifest.content` are always present in a
60
+ manifest (`[]` and `{}` when none); `validateThemeManifest` normalizes a
61
+ manifest made before 1.5.0 the same way.
62
+ - An empty `content` map declares no packs, as an absent one does.
63
+ - A manifest made before 1.5.0 reads as `preview: false`.
64
+
65
+ ## 1.4.0 — 2026-10-18
66
+
67
+ ### Added
68
+
69
+ - `@wocart/theme/image-service`: `mediaImageService(() => mediaDomain)`, an
70
+ Astro image service that resizes media-domain images with Cloudflare Image
71
+ Resizing on the media domain itself, so a storefront on Cloudflare and one
72
+ on Vercel get the same URLs and edge cache. Other images are served as they
73
+ are; the domain is read per image, so one build serves every store.
74
+ `mediaImageParams` maps an Astro transform to the resizing options (Astro's
75
+ `fit` in Cloudflare's words).
76
+
77
+ ### Changed
78
+
79
+ - The `@wocart/storefront` peer range is now `^1.14.0` (`mediaImageUrl`).
80
+
8
81
  ## 1.3.0 — 2026-10-17
9
82
 
10
83
  ### Added
package/README.md CHANGED
@@ -14,6 +14,10 @@ Astro renderer.
14
14
  break the rest of the page.
15
15
  - **A serializable manifest.** `themeManifest` turns a theme into plain JSON
16
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.
17
21
  - **Many languages.** Text values may be translated per language, and
18
22
  `formatMessage` handles placeholders and CLDR plurals.
19
23
  - **A reference renderer.** `InstanceView.astro` renders a document section by
@@ -34,9 +38,11 @@ Node 22.12 or newer.
34
38
 
35
39
  | Import | What it holds |
36
40
  | --- | --- |
37
- | `@wocart/theme` | definitions, validation, page documents, resolution, the manifest, languages and messages — framework-neutral |
38
- | `@wocart/theme/astro` | `createReadCache` and the `RendererIssue` type |
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 |
39
43
  | `@wocart/theme/astro/InstanceView.astro` | the reference renderer component |
44
+ | `@wocart/theme/image-service` | `mediaImageService` — an Astro image service that resizes media-domain images on the media domain (any storefront host) |
45
+ | `@wocart/theme/preview` | `startPreviewBridge` — the browser module of a theme's preview page (see [Preview](#preview)) |
40
46
 
41
47
  ## Use
42
48
 
@@ -112,18 +118,91 @@ defineComponent({
112
118
  | Kind | Value | Rules |
113
119
  |---|---|---|
114
120
  | `text`, `textarea` | string | `default` or `contentKey` (from the theme's content pack), never both |
115
- | `number` | finite number | optional `min`/`max`; a `default` must sit inside them |
121
+ | `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) |
116
122
  | `boolean` | boolean | |
117
- | `select` | one of `options` | `default` must be one of the options |
123
+ | `select` | one of `options` | `default` must be one of the options; `optionLabels` gives each option the words an editor shows, per language |
118
124
  | `color` | `#rgb` or `#rrggbb` | anything else (a color name, `url(…)`) is rejected at assembly and reported on read |
119
125
  | `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 |
120
126
  | `menu` | a menu handle (`main-menu`) | lowercase letters, digits and hyphens; the store's menu is read with `@wocart/storefront`'s `menus.get(handle)` |
121
127
  | `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 |
128
+ | `product` | a product handle | read with `@wocart/storefront`'s `products.get(handle)`; a renamed or deleted product reads as no product |
129
+ | `category` | a category slug | found in `@wocart/storefront`'s `categories.list()` |
130
+ | `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
+ | `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 |
122
133
 
123
134
  Use `url` for every editor-settable `href`, never `text`: the reader drops an
124
135
  unsafe saved link (`invalid-prop`) and falls back to the field's default, so a
125
136
  document can never inject a script URL.
126
137
 
138
+ The SDK checks a value as a writer must before saving it:
139
+ `checkFieldValue(field, value)` returns `{ ok: true }` or the reason. It is
140
+ stricter than reading: an image reference or a list item with a key the
141
+ contract does not define is refused, where the reader ignores it.
142
+
143
+ ### Images
144
+
145
+ An image value names the object in the store's own media bucket; the URL is
146
+ derived at render, so a domain or host change never rewrites a document.
147
+ Resolve it with `@wocart/storefront`'s `mediaUrl`:
148
+
149
+ ```astro
150
+ ---
151
+ import { mediaUrl } from "@wocart/storefront";
152
+ import { MEDIA_DOMAIN } from "astro:env/server";
153
+ const { image } = Astro.props as InferProps<typeof banner>; // ResolvedImage: alt in the rendered language
154
+ const src = image && mediaUrl(image.key, MEDIA_DOMAIN);
155
+ ---
156
+ {src && <img src={src} alt={image.alt ?? ""} width={image.width} height={image.height} />}
157
+ ```
158
+
159
+ The SDK never uploads: the editor places files in the store's media and
160
+ stores the key. An image field has no default — a theme cannot know a store's
161
+ media — so the component renders its own fallback.
162
+
163
+ ### Rich text
164
+
165
+ A rich-text value is `{ type: "doc", version: 1, content }`: paragraphs,
166
+ headings 2–4, bullet and ordered lists, line breaks, bold, italic, underline,
167
+ strike and links that follow the `url` rule. Nothing else is representable, so
168
+ a stored value cannot carry markup or scripts. Render it with the SDK:
169
+
170
+ ```astro
171
+ ---
172
+ import { richTextToHtml } from "@wocart/theme";
173
+ ---
174
+ {body && <div set:html={richTextToHtml(body, { href: store.href })} />}
175
+ ```
176
+
177
+ `richTextToHtml` escapes every text and attribute and drops what is not in the
178
+ format even for a value that was never validated; `href` lets the theme
179
+ localize site links. `richTextToPlainText` gives the words for a meta
180
+ description or an `aria-label`. A field's `formats` (`["bold", "link"]`)
181
+ narrows what a merchant may use.
182
+
183
+ ### Lists
184
+
185
+ ```ts
186
+ {
187
+ key: "badges",
188
+ kind: "list",
189
+ label: "Badges",
190
+ max: 4,
191
+ itemLabel: "title",
192
+ fields: [
193
+ { key: "title", kind: "text", label: "Title" },
194
+ { key: "icon", kind: "select", label: "Icon", options: ["truck", "cash"], default: "truck" },
195
+ ],
196
+ default: [{ id: "cod", props: { title: { ar: "الدفع عند الاستلام", fr: "Paiement à la livraison" }, icon: "cash" } }],
197
+ }
198
+ ```
199
+
200
+ A component receives `[{ id, title, icon }]`. Each item's id is unique in its
201
+ list, so reordering never changes which item is which; the reader drops a
202
+ broken or repeated item and keeps the others, and keeps the first `max`. Item
203
+ fields cannot default from the content pack: every item would read the same
204
+ key.
205
+
127
206
  ## Languages
128
207
 
129
208
  The contract is built for many languages; a store serves the ones it enables.
@@ -190,6 +269,157 @@ A component declares where children may go; the document says what is there.
190
269
  displaces an allowed one. The rules travel in the manifest, so an editor can
191
270
  offer only valid insertions.
192
271
 
272
+ ## Pages and templates
273
+
274
+ A surface is a document a theme can render. Declare the platform page type it
275
+ renders and, optionally, its default document:
276
+
277
+ ```ts
278
+ import homeDocument from "./documents/home.json";
279
+
280
+ surfaces: [
281
+ { key: "home", label: "Home", pageType: "home", document: homeDocument },
282
+ { key: "product", label: "Product", pageType: "product" },
283
+ { key: "product.compact", label: "Compact product", pageType: "product" },
284
+ ],
285
+ ```
286
+
287
+ - A page type with a resource — `product`, `category`, `collection`, `page`,
288
+ `landing` — makes the surface a **template**: one document renders every
289
+ product (or category…), and its sections read the resource from
290
+ `context.params` (see the route example under Ownership rules). There is no
291
+ document per product.
292
+ - `product.compact` is an **alternate** of `product`: the base must be
293
+ declared and render the same page type.
294
+ - A surface's `document` is checked at assembly against the surface and the
295
+ catalog, and travels in the manifest as the theme's default.
296
+ - `capabilities: { surfaces: ["product"] }` keeps a component to the surfaces
297
+ it can serve (alternates included); `isInsertableOn(component, surface)`
298
+ applies the rule.
299
+
300
+ ## Presets
301
+
302
+ A preset is a ready-made configuration an editor offers when inserting a
303
+ component — props over the field defaults, and child components for its slots:
304
+
305
+ ```ts
306
+ presets: [
307
+ {
308
+ key: "with-grid",
309
+ label: "Hero with products",
310
+ props: { layout: "columns" },
311
+ children: [{ type: "hero", props: { align: "center" } }],
312
+ slots: { aside: [{ type: "product-grid", props: { limit: 2 } }] },
313
+ },
314
+ ],
315
+ ```
316
+
317
+ `instantiatePreset(theme, type, key, newId)` builds the instances, each with an
318
+ id from `newId`. Presets are checked at assembly by building them.
319
+
320
+ ## Hidden sections
321
+
322
+ `{ "id": "…", "type": "hero", "hidden": true }` keeps a section in the
323
+ document without rendering it — a merchant switched it off. The renderer skips
324
+ it, its `load` and its children; the reader still checks it.
325
+
326
+ ## Editing documents
327
+
328
+ Every writer — an editor, a CLI, an assistant — applies the same rules
329
+ through pure operations that return a new document or say why they refused:
330
+
331
+ ```ts
332
+ import { createInstance, insertInstance, moveInstance, newInstanceId, checkPageData } from "@wocart/theme";
333
+
334
+ const edit = insertInstance(theme, page, createInstance(theme.components.hero, newInstanceId()), { index: 0 });
335
+ if (!edit.ok) console.log(edit.issue.code, edit.issue.message); // "not-available", "slot-full", …
336
+ ```
337
+
338
+ `insertInstance`, `removeInstance`, `moveInstance`, `duplicateInstance`,
339
+ `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.
345
+
346
+ An editor holds a theme's manifest, not its code:
347
+ `manifestCatalog(manifest)` makes the catalog these functions take, with no
348
+ component implementations.
349
+
350
+ ## Preview
351
+
352
+ An editor shows a merchant's unsaved draft on the live storefront, rendered by
353
+ the theme itself — the same components, data and CSS a shopper gets. A theme
354
+ that does this sets `preview: true` (the manifest says so) and serves a route
355
+ at `THEME_PREVIEW_PATH` (`/wocart-preview`). The editor frames it with
356
+ `GET /wocart-preview?token=…&surface=home&locale=fr`, and the framed page
357
+ re-renders each draft the editor sends by posting it back to the route as
358
+ JSON — never a cross-site form, so Astro's `checkOrigin` stays on. Three parts
359
+ meet there:
360
+
361
+ 1. **The request.** `readPreviewRequest` from `@wocart/storefront/server`
362
+ checks the editor's signed token and returns the draft — which surface and
363
+ language to show and, once the editor sends one, the document, settings
364
+ and text edits (its guide: "Previewing a draft for an editor"). What a
365
+ draft leaves out renders as saved. The route answers with
366
+ `previewResponseHeaders`, so only that editor can frame it and nothing
367
+ stores it; no route rule may cache it.
368
+ 2. **The markers.** `<InstanceView preview …>` brackets each instance's nodes
369
+ with two comments — `wocart:start id=… type=…[ hidden]` and
370
+ `wocart:end id=…` (`previewStartMarker`, `previewEndMarker`,
371
+ `parsePreviewMarker`). Comments, not wrapper elements, so a grid or a
372
+ `> *` selector lays out exactly as it does for a shopper; a hidden instance
373
+ keeps its markers with nothing between them. A shopper's page has none.
374
+ 3. **The bridge.** `startPreviewBridge({ editorOrigin, token })` from
375
+ `@wocart/theme/preview` runs on the preview page. It reports every marked
376
+ instance to the editor, turns clicks into selections, and re-renders the
377
+ drafts the editor sends — swapping in only the sections whose markup
378
+ changed, or the whole page when sections were added, moved or hidden, or
379
+ the page around them changed.
380
+
381
+ ```astro
382
+ ---
383
+ // src/pages/wocart-preview.astro — the middleware read the request
384
+ export const prerender = false;
385
+ const { claims, token } = Astro.locals.preview;
386
+ for (const [name, value] of Object.entries(previewResponseHeaders(claims))) Astro.response.headers.set(name, value);
387
+ ---
388
+ <Layout>
389
+ {sections.map((instance) => (
390
+ <InstanceView preview {instance} {theme} {context} {storefront} {reads} />
391
+ ))}
392
+ <wocart-preview-bridge data-editor={claims.editor} data-token={token} hidden />
393
+ </Layout>
394
+ <script>
395
+ import { startPreviewBridge } from "@wocart/theme/preview";
396
+ const host = document.querySelector<HTMLElement>("wocart-preview-bridge")!;
397
+ startPreviewBridge({ editorOrigin: host.dataset.editor!, token: host.dataset.token! });
398
+ </script>
399
+ ```
400
+
401
+ The bridge draws nothing — outlines, toolbars and panels are the editor's. It
402
+ posts only to `editorOrigin` and reads only messages from it, sent by the
403
+ framing window, with `source: "wocart-editor"` and the protocol's `version`
404
+ (`PREVIEW_PROTOCOL_VERSION`):
405
+
406
+ | The editor sends | The page answers |
407
+ | --- | --- |
408
+ | — | `ready` with every instance: `{ id, type, parent, hidden, rect }` |
409
+ | `render` `{ draft, revision? }` | `rendered` `{ revision, instances }`, or `error` `{ revision, reason }`; an answer overtaken by a newer render is dropped |
410
+ | `mode` `"edit"` \| `"interactive"` | in `edit`, links and forms do nothing and a click is `select` `{ id }` of the innermost instance |
411
+ | `scroll-to` `{ id }` | the instance scrolls into view |
412
+ | `token` `{ token }` | the next render uses it |
413
+ | — | `hover` `{ id \| null }` as the pointer moves; `layout` after a scroll or resize |
414
+
415
+ A swapped section's scripts do not run again. A section with browser behavior
416
+ binds it again on `PREVIEW_SECTION_LOAD_EVENT` (`wocart:section-load`,
417
+ dispatched on `document` with `detail.id`). When the bridge replaces the whole
418
+ page instead, it does what Astro's router does after a navigation — a new
419
+ `<body>`, the root element's attributes, the title — and dispatches
420
+ `astro:page-load`, so a script written for the router mounts again as it does
421
+ there. Changed stylesheets are replaced in place; unchanged ones never reload.
422
+
193
423
  ## Theme manifest (serializable catalogue)
194
424
 
195
425
  A `ThemeDefinition` mixes data (types, fields, defaults) with runtime (the live
@@ -204,8 +434,10 @@ const manifest = themeManifest(theme);
204
434
  // {
205
435
  // contractVersion: 1,
206
436
  // key: "my-theme", version: "1.0.0",
437
+ // sdkVersion: "1.5.0", // the @wocart/theme that generated it
207
438
  // surfaces: [], // declared surfaces (possibly none)
208
- // components: { hero: { type, label, group, fields, slots, capabilities } },
439
+ // locales: [], settings: [], content: {},
440
+ // components: { hero: { type, label, group, fields, slots, capabilities, presets } },
209
441
  // } — no `component`, no `load`, JSON-round-trip stable.
210
442
 
211
443
  const { manifest: checked, issues } = validateThemeManifest(
@@ -243,9 +475,35 @@ What a validated manifest promises a consumer:
243
475
  `validateThemeDefinition` and `validateThemeManifest`, so a manifest derived
244
476
  from a valid theme always validates clean.
245
477
 
246
- What it deliberately excludes: implementations and loaders (runtime-only),
247
- content-pack values (only `contentKey` references travel — the pack itself stays
248
- theme-owned), and any write/editing affordance.
478
+ What it deliberately excludes: implementations and loaders (runtime-only).
479
+ Content packs travel only when the theme declares them (`defineTheme({
480
+ content })`).
481
+
482
+ ### The manifest as a build artifact
483
+
484
+ A theme's definition imports its `.astro` components, so it cannot be loaded
485
+ outside Astro's build. Emit the manifest as a prerendered page at
486
+ `THEME_MANIFEST_PATH` (`/wocart-theme.json`):
487
+
488
+ ```ts
489
+ // src/pages/wocart-theme.json.ts
490
+ import { themeManifestEndpoint } from "@wocart/theme/astro";
491
+ import { theme } from "../theme/theme";
492
+ export const prerender = true;
493
+ export const GET = themeManifestEndpoint(theme);
494
+ ```
495
+
496
+ The manifest is validated when the module loads: an invalid one fails the
497
+ build instead of shipping.
498
+
499
+ ### What changes between versions
500
+
501
+ `diffThemeManifests(previous, next)` lists the changes saved documents,
502
+ settings and text edits may not survive (`breaking`) — a removed component,
503
+ field, slot, option, surface or content key; a changed kind or page type; a
504
+ narrowed range, list, `allow` list or rich-text formats; a slot made single; a
505
+ field newly required with no default — and those that only add. A breaking
506
+ change belongs in a new major version of the theme.
249
507
 
250
508
  ### Versioning policy
251
509
 
@@ -376,10 +634,13 @@ or a copy system — it only reads the resolved pack the request hands it.
376
634
  - **The precedence chain** is: instance value → `content[contentKey]` →
377
635
  `field.default` → omit (the component's own default applies last). A field
378
636
  with neither `contentKey` nor `default` is simply absent from `props`.
379
- - **Typos are checkable, not inferred.** Pass the pack's key set to
380
- `validateThemeDefinition(theme, { contentKeys })` and every `contentKey` that
381
- is not in it is reported as `unknown-content-key`. Optional and additive:
382
- with no options the validator behaves exactly as before, and passing an empty
637
+ - **Declare the packs on the theme** — `defineTheme({ content: { ar, fr } })`
638
+ — and every `contentKey` must exist in every pack, each declared language
639
+ needs its pack, and the manifest carries them, so an editor shows the text a
640
+ field inherits and lists the labels a merchant may override.
641
+ - **Or check against a key set.** `validateThemeDefinition(theme, {
642
+ contentKeys })` reports every `contentKey` missing from it as
643
+ `unknown-content-key`; an explicit set wins over declared packs, and an empty
383
644
  array reports every declared key.
384
645
 
385
646
  Both ways of consuming copy are legitimate, and the theme picks: a component can
@@ -408,7 +669,7 @@ plural forms like any other key.
408
669
 
409
670
  ## Capabilities are advisory
410
671
 
411
- `ComponentDefinition.capabilities` is metadata for an **editor/CLI insertion
672
+ `ComponentDefinition.capabilities` (`insertable`, `surfaces`) is metadata for an **editor/CLI insertion
412
673
  surface**, not renderer enforcement. Neither `parsePageData` nor
413
674
  `resolveInstance` consults it: a saved document containing a component marked
414
675
  `insertable: false` still renders, because the render path never second-guesses
@@ -441,15 +702,52 @@ migration engine, by design; what follows is the whole policy:
441
702
  of the page. Only an envelope failure (`version`, `surface`, `sections`
442
703
  shape) returns `page: null`.
443
704
  - **Theme versions.** The runtime accepts any non-empty `version` string;
444
- stick to semver so the manifest validator stays quiet and future tooling can
445
- reason about upgrades.
705
+ stick to semver so the manifest validator stays quiet, and compare releases
706
+ with `diffThemeManifests`.
707
+ - **Provenance.** A document may name the theme that wrote it,
708
+ `"theme": { "key", "version" }`; `validatePageData` reports `theme-mismatch`
709
+ for another theme's document, and a malformed one is dropped with
710
+ `invalid-provenance`.
711
+ - **Features and renderers.** An older renderer strips instance keys it does
712
+ not know, so it would render a `hidden` section. The manifest's `sdkVersion`
713
+ says which renderer a theme ships; an editor writes `hidden` only for 1.5.0
714
+ or later.
715
+
716
+ ## Images
717
+
718
+ `@wocart/theme/image-service` resizes every `<Image>` of a media-domain image
719
+ with Cloudflare Image Resizing, on the media domain itself
720
+ (`https://<media>/cdn-cgi/image/…`). The media domain is a Cloudflare zone
721
+ wherever the storefront runs, so a storefront on Cloudflare and one on Vercel
722
+ share the same URLs and edge cache. Any other image is served as it is; with no
723
+ media domain, every image passes through.
724
+
725
+ The domain is read on each image, not at build — one build serves every store.
726
+ Bind it once:
727
+
728
+ ```ts
729
+ // src/image-service.ts
730
+ import { MEDIA_DOMAIN } from "astro:env/server";
731
+ import { mediaImageService } from "@wocart/theme/image-service";
732
+ export default mediaImageService(() => MEDIA_DOMAIN);
733
+ ```
734
+
735
+ ```js
736
+ // astro.config.mjs
737
+ image: { service: { entrypoint: "./src/image-service.ts" } },
738
+ // @astrojs/cloudflare: imageService: "custom" · @astrojs/vercel: leave imageService off
739
+ ```
740
+
741
+ The media domain's zone needs Image Transformations on (Images →
742
+ Transformations; included in the Free plan).
446
743
 
447
744
  ## Testing your theme
448
745
 
449
746
  A theme proves it honors the contract with four checks in its own test suite:
450
747
 
451
- 1. **The catalog assembles clean**:
452
- `expect(validateThemeDefinition(theme, { contentKeys: Object.keys(pack) })).toEqual([])`.
748
+ 1. **The catalog assembles clean** — with its packs declared on the theme,
749
+ its default documents and presets are checked too:
750
+ `expect(validateThemeDefinition(theme)).toEqual([])`.
453
751
  2. **The manifest survives a JSON round trip**:
454
752
  `validateThemeManifest(JSON.parse(JSON.stringify(themeManifest(theme))))`
455
753
  returns no issues.
@@ -3,7 +3,7 @@
3
3
  * The reference renderer: one recursive component that turns a page-data
4
4
  * instance into the real theme component it names.
5
5
  *
6
- * instance → definition (catalog) → resolveInstance (chain + gating)
6
+ * instance (skipped when `hidden`) → definition (catalog) → resolveInstance (chain + gating)
7
7
  * → definition.load (optional, server-only, degrading)
8
8
  * → <Component {...props}> with children + one named slot
9
9
  *
@@ -15,7 +15,7 @@
15
15
  * Server-only. No hydration, no directives, no second component system —
16
16
  * the theme's own `.astro` files do all rendering.
17
17
  */
18
- import { resolveInstance, type RendererIssue } from "./index.js";
18
+ import { previewEndMarker, previewStartMarker, resolveInstance, type RendererIssue } from "./index.js";
19
19
  import type {
20
20
  ComponentInstance,
21
21
  ReadCache,
@@ -32,9 +32,14 @@ interface Props {
32
32
  reads: ReadCache;
33
33
  /** Overrides the default reporter (console.warn with a stable prefix). */
34
34
  onIssue?: ((issue: RendererIssue) => void) | undefined;
35
+ /**
36
+ * A preview render: each instance's nodes are bracketed with marker
37
+ * comments an editor's bridge reads. Never set for a shopper's page.
38
+ */
39
+ preview?: boolean | undefined;
35
40
  }
36
41
 
37
- const { instance, theme, context, storefront, reads, onIssue } = Astro.props as Props;
42
+ const { instance, theme, context, storefront, reads, onIssue, preview = false } = Astro.props as Props;
38
43
 
39
44
  const report = (issue: RendererIssue): void => {
40
45
  if (onIssue) {
@@ -44,10 +49,13 @@ const report = (issue: RendererIssue): void => {
44
49
  }
45
50
  };
46
51
 
47
- const definition = Object.hasOwn(theme.components, instance.type)
52
+ // A hidden instance stays in the document and renders nothing: no lookup,
53
+ // no `load`, no children.
54
+ const hidden = instance.hidden === true;
55
+ const definition = !hidden && Object.hasOwn(theme.components, instance.type)
48
56
  ? theme.components[instance.type]
49
57
  : undefined;
50
- if (definition === undefined) {
58
+ if (definition === undefined && !hidden) {
51
59
  report({
52
60
  code: "unknown-type",
53
61
  message: `"${instance.type}" is not in the theme catalog; nothing rendered`,
@@ -134,7 +142,12 @@ if (definition !== undefined) {
134
142
  }
135
143
  }
136
144
  }
145
+ // Marker text is the protocol's own encoding: ids and types are URI-encoded,
146
+ // so no saved value can close the comment.
147
+ const startMarker = preview ? `<!--${previewStartMarker(instance)}-->` : "";
148
+ const endMarker = preview ? `<!--${previewEndMarker(instance)}-->` : "";
137
149
  ---
150
+ {preview && <Fragment set:html={startMarker} />}
138
151
  {
139
152
  Component !== undefined ? (
140
153
  <Component {...renderProps}>
@@ -146,6 +159,7 @@ if (definition !== undefined) {
146
159
  storefront={storefront}
147
160
  reads={reads}
148
161
  onIssue={onIssue}
162
+ preview={preview}
149
163
  />
150
164
  ))}
151
165
  {firstNamedSlotName !== undefined && (
@@ -158,6 +172,7 @@ if (definition !== undefined) {
158
172
  storefront={storefront}
159
173
  reads={reads}
160
174
  onIssue={onIssue}
175
+ preview={preview}
161
176
  />
162
177
  ))}
163
178
  </Fragment>
@@ -165,3 +180,4 @@ if (definition !== undefined) {
165
180
  </Component>
166
181
  ) : null
167
182
  }
183
+ {preview && <Fragment set:html={endMarker} />}
@@ -1,21 +1,22 @@
1
+ import type { PageDataIssueCode, ReadCache, ThemeDefinition } from "../types.js";
2
+ export { resolveInstance } from "../instances.js";
3
+ export { previewEndMarker, previewStartMarker } from "../preview-protocol.js";
1
4
  /**
2
- * `@wocart/theme/astro` — the Astro-specific side of the theme SDK.
5
+ * The theme's manifest as a prerendered endpoint, the file a release and an
6
+ * editor read (`THEME_MANIFEST_PATH`):
3
7
  *
4
- * Exports:
5
- * - `createReadCache` — the per-render read sharing the contract requires
6
- * (one read per resource per render).
7
- * - `RendererIssue` — the renderer-specific degradations, reportable via
8
- * `InstanceView`'s `onIssue` prop.
9
- * - `resolveInstance` — re-exported so `InstanceView.astro`'s co-located
10
- * import (`./index.js`) resolves identically in the dev source layout and in
11
- * the published `dist/astro` layout. Same function as the
12
- * root export — never a second implementation.
8
+ * ```ts
9
+ * // src/pages/wocart-theme.json.ts
10
+ * import { themeManifestEndpoint } from "@wocart/theme/astro";
11
+ * import { theme } from "../theme/theme";
12
+ * export const prerender = true;
13
+ * export const GET = themeManifestEndpoint(theme);
14
+ * ```
13
15
  *
14
- * The renderer component itself:
15
- * `@wocart/theme/astro/InstanceView.astro`.
16
+ * The manifest is derived and validated when the module loads, so a theme
17
+ * whose manifest is invalid fails its build instead of shipping it.
16
18
  */
17
- import type { PageDataIssueCode, ReadCache } from "../types.js";
18
- export { resolveInstance } from "../instances.js";
19
+ export declare function themeManifestEndpoint(theme: ThemeDefinition): () => Response;
19
20
  /** One renderer-specific degradation, reported through `onIssue`. */
20
21
  export interface RendererIssue {
21
22
  code: PageDataIssueCode | "missing-component" | "load-failed" | "multiple-slots-unsupported";
@@ -1,4 +1,48 @@
1
+ /**
2
+ * `@wocart/theme/astro` — the Astro-specific side of the theme SDK.
3
+ *
4
+ * Exports:
5
+ * - `createReadCache` — the per-render read sharing the contract requires
6
+ * (one read per resource per render).
7
+ * - `themeManifestEndpoint` — the manifest as a prerendered build artifact.
8
+ * - `RendererIssue` — the renderer-specific degradations, reportable via
9
+ * `InstanceView`'s `onIssue` prop.
10
+ * - `resolveInstance` — re-exported so `InstanceView.astro`'s co-located
11
+ * import (`./index.js`) resolves identically in the dev source layout and in
12
+ * the published `dist/astro` layout. Same function as the
13
+ * root export — never a second implementation.
14
+ *
15
+ * The renderer component itself:
16
+ * `@wocart/theme/astro/InstanceView.astro`.
17
+ */
18
+ import { themeManifest, validateThemeManifest } from "../manifest.js";
1
19
  export { resolveInstance } from "../instances.js";
20
+ export { previewEndMarker, previewStartMarker } from "../preview-protocol.js";
21
+ /**
22
+ * The theme's manifest as a prerendered endpoint, the file a release and an
23
+ * editor read (`THEME_MANIFEST_PATH`):
24
+ *
25
+ * ```ts
26
+ * // src/pages/wocart-theme.json.ts
27
+ * import { themeManifestEndpoint } from "@wocart/theme/astro";
28
+ * import { theme } from "../theme/theme";
29
+ * export const prerender = true;
30
+ * export const GET = themeManifestEndpoint(theme);
31
+ * ```
32
+ *
33
+ * The manifest is derived and validated when the module loads, so a theme
34
+ * whose manifest is invalid fails its build instead of shipping it.
35
+ */
36
+ export function themeManifestEndpoint(theme) {
37
+ const manifest = themeManifest(theme);
38
+ const checked = validateThemeManifest(JSON.parse(JSON.stringify(manifest)));
39
+ if (checked.manifest === null) {
40
+ const issues = checked.issues.map((issue) => `${issue.path || "<manifest>"} ${issue.code}: ${issue.message}`);
41
+ throw new Error(`[@wocart/theme] the theme manifest is invalid:\n ${issues.join("\n ")}`);
42
+ }
43
+ const body = JSON.stringify(manifest);
44
+ return () => new Response(body, { headers: { "Content-Type": "application/json; charset=utf-8" } });
45
+ }
2
46
  /**
3
47
  * The per-render `ReadCache`: the first call for a key
4
48
  * runs the loader; later calls in the same render reuse the in-flight or