@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 +57 -0
- package/README.md +286 -17
- package/dist/astro/InstanceView.astro +21 -5
- package/dist/astro/index.d.ts +15 -14
- package/dist/astro/index.js +44 -0
- package/dist/composition.d.ts +17 -0
- package/dist/composition.js +60 -0
- package/dist/definitions.js +305 -6
- package/dist/index.d.ts +18 -5
- package/dist/index.js +12 -4
- package/dist/instances.d.ts +7 -1
- package/dist/instances.js +251 -13
- package/dist/manifest-diff.d.ts +14 -0
- package/dist/manifest-diff.js +160 -0
- package/dist/manifest.d.ts +192 -19
- package/dist/manifest.js +77 -16
- package/dist/operations.d.ts +71 -0
- package/dist/operations.js +377 -0
- package/dist/page-data.js +29 -2
- package/dist/preview-bridge.d.ts +28 -0
- package/dist/preview-bridge.js +317 -0
- package/dist/preview-protocol.d.ts +100 -0
- package/dist/preview-protocol.js +51 -0
- package/dist/rich-text.d.ts +31 -0
- package/dist/rich-text.js +233 -0
- package/dist/schemas.d.ts +21 -0
- package/dist/schemas.js +35 -2
- package/dist/types.d.ts +259 -15
- package/dist/version.d.ts +7 -0
- package/dist/version.js +7 -0
- package/package.json +7 -2
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
|
-
//
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
- **
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
|
446
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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} />}
|
package/dist/astro/index.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
5
|
+
* The theme's manifest as a prerendered endpoint, the file a release and an
|
|
6
|
+
* editor read (`THEME_MANIFEST_PATH`):
|
|
3
7
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
15
|
-
*
|
|
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
|
-
|
|
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";
|
package/dist/astro/index.js
CHANGED
|
@@ -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;
|