@wocart/theme 1.4.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,63 @@ 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
+
8
65
  ## 1.4.0 — 2026-10-18
9
66
 
10
67
  ### 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,10 +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 |
40
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)) |
41
46
 
42
47
  ## Use
43
48
 
@@ -113,18 +118,91 @@ defineComponent({
113
118
  | Kind | Value | Rules |
114
119
  |---|---|---|
115
120
  | `text`, `textarea` | string | `default` or `contentKey` (from the theme's content pack), never both |
116
- | `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) |
117
122
  | `boolean` | boolean | |
118
- | `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 |
119
124
  | `color` | `#rgb` or `#rrggbb` | anything else (a color name, `url(…)`) is rejected at assembly and reported on read |
120
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 |
121
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)` |
122
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 |
123
133
 
124
134
  Use `url` for every editor-settable `href`, never `text`: the reader drops an
125
135
  unsafe saved link (`invalid-prop`) and falls back to the field's default, so a
126
136
  document can never inject a script URL.
127
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
+
128
206
  ## Languages
129
207
 
130
208
  The contract is built for many languages; a store serves the ones it enables.
@@ -191,6 +269,157 @@ A component declares where children may go; the document says what is there.
191
269
  displaces an allowed one. The rules travel in the manifest, so an editor can
192
270
  offer only valid insertions.
193
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
+
194
423
  ## Theme manifest (serializable catalogue)
195
424
 
196
425
  A `ThemeDefinition` mixes data (types, fields, defaults) with runtime (the live
@@ -205,8 +434,10 @@ const manifest = themeManifest(theme);
205
434
  // {
206
435
  // contractVersion: 1,
207
436
  // key: "my-theme", version: "1.0.0",
437
+ // sdkVersion: "1.5.0", // the @wocart/theme that generated it
208
438
  // surfaces: [], // declared surfaces (possibly none)
209
- // components: { hero: { type, label, group, fields, slots, capabilities } },
439
+ // locales: [], settings: [], content: {},
440
+ // components: { hero: { type, label, group, fields, slots, capabilities, presets } },
210
441
  // } — no `component`, no `load`, JSON-round-trip stable.
211
442
 
212
443
  const { manifest: checked, issues } = validateThemeManifest(
@@ -244,9 +475,35 @@ What a validated manifest promises a consumer:
244
475
  `validateThemeDefinition` and `validateThemeManifest`, so a manifest derived
245
476
  from a valid theme always validates clean.
246
477
 
247
- What it deliberately excludes: implementations and loaders (runtime-only),
248
- content-pack values (only `contentKey` references travel — the pack itself stays
249
- 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.
250
507
 
251
508
  ### Versioning policy
252
509
 
@@ -377,10 +634,13 @@ or a copy system — it only reads the resolved pack the request hands it.
377
634
  - **The precedence chain** is: instance value → `content[contentKey]` →
378
635
  `field.default` → omit (the component's own default applies last). A field
379
636
  with neither `contentKey` nor `default` is simply absent from `props`.
380
- - **Typos are checkable, not inferred.** Pass the pack's key set to
381
- `validateThemeDefinition(theme, { contentKeys })` and every `contentKey` that
382
- is not in it is reported as `unknown-content-key`. Optional and additive:
383
- 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
384
644
  array reports every declared key.
385
645
 
386
646
  Both ways of consuming copy are legitimate, and the theme picks: a component can
@@ -409,7 +669,7 @@ plural forms like any other key.
409
669
 
410
670
  ## Capabilities are advisory
411
671
 
412
- `ComponentDefinition.capabilities` is metadata for an **editor/CLI insertion
672
+ `ComponentDefinition.capabilities` (`insertable`, `surfaces`) is metadata for an **editor/CLI insertion
413
673
  surface**, not renderer enforcement. Neither `parsePageData` nor
414
674
  `resolveInstance` consults it: a saved document containing a component marked
415
675
  `insertable: false` still renders, because the render path never second-guesses
@@ -442,8 +702,16 @@ migration engine, by design; what follows is the whole policy:
442
702
  of the page. Only an envelope failure (`version`, `surface`, `sections`
443
703
  shape) returns `page: null`.
444
704
  - **Theme versions.** The runtime accepts any non-empty `version` string;
445
- stick to semver so the manifest validator stays quiet and future tooling can
446
- 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.
447
715
 
448
716
  ## Images
449
717
 
@@ -477,8 +745,9 @@ Transformations; included in the Free plan).
477
745
 
478
746
  A theme proves it honors the contract with four checks in its own test suite:
479
747
 
480
- 1. **The catalog assembles clean**:
481
- `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([])`.
482
751
  2. **The manifest survives a JSON round trip**:
483
752
  `validateThemeManifest(JSON.parse(JSON.stringify(themeManifest(theme))))`
484
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
@@ -0,0 +1,17 @@
1
+ import type { ComponentDefinition, ComponentInstance, ComponentSpec, ThemeDefinition } from "./types.js";
2
+ /** Whether a surface key is `entry` itself or one of its alternates (`product.compact` for `product`). */
3
+ export declare function surfaceCovers(entry: string, surface: string): boolean;
4
+ /**
5
+ * Whether an editor may insert this component on `surface`: it is not marked
6
+ * `insertable: false`, and its `capabilities.surfaces` (absent = any surface)
7
+ * cover the surface. Accepts a definition or its manifest spec.
8
+ */
9
+ export declare function isInsertableOn(component: ComponentDefinition | ComponentSpec, surface: string): boolean;
10
+ /**
11
+ * The instances a preset describes: the component with the preset's props
12
+ * over its field defaults (`createInstance`), and every child component the
13
+ * same way, each with an id from `newId`. Throws on what is a programming
14
+ * error — an unknown component or preset, or props its fields refuse — as
15
+ * `createInstance` does; `validateThemeDefinition` reports these at assembly.
16
+ */
17
+ export declare function instantiatePreset(theme: ThemeDefinition, type: string, presetKey: string, newId: () => string): ComponentInstance;