@wocart/theme 1.5.0 → 1.6.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,20 @@ 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.6.0 — 2026-10-19
9
+
10
+ An editor's change shows on the preview page at once.
11
+
12
+ ### Added
13
+
14
+ - The preview protocol's `patch` message (`PreviewPatch`): a change the
15
+ editor has already made — move, duplicate, remove and restore a section,
16
+ hide or show one, a field's text — shown by the preview bridge on the page
17
+ as it stands, before the render that follows it. Patches are hints; the
18
+ render stays the page's truth, and a render already on its way when a
19
+ patch arrives is dropped so it cannot undo it. A page that does not know
20
+ patches ignores them. The protocol version stays 1.
21
+
8
22
  ## 1.5.0 — 2026-10-18
9
23
 
10
24
  The editor-facing contract: everything a visual editor needs to discover and
package/README.md CHANGED
@@ -406,15 +406,28 @@ framing window, with `source: "wocart-editor"` and the protocol's `version`
406
406
  | The editor sends | The page answers |
407
407
  | --- | --- |
408
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 |
409
+ | `render` `{ draft, revision? }` | `rendered` `{ revision, instances }`, or `error` `{ revision, reason }`; an answer overtaken by a newer render or a patch is dropped |
410
+ | `patch` `{ patches }` | the change shown at once, on the page as it stands, then `layout` (see below) |
410
411
  | `mode` `"edit"` \| `"interactive"` | in `edit`, links and forms do nothing and a click is `select` `{ id }` of the innermost instance |
411
412
  | `scroll-to` `{ id }` | the instance scrolls into view |
412
413
  | `token` `{ token }` | the next render uses it |
413
414
  | — | `hover` `{ id \| null }` as the pointer moves; `layout` after a scroll or resize |
414
415
 
416
+ A `patch` shows a change the editor has already made to its draft before the
417
+ render that follows it arrives — so moving, copying, removing or hiding a
418
+ section, and typing a field's text, show on the page at once. Each patch
419
+ (`PreviewPatch`) is a hint: the page applies what it finds — `move` a section
420
+ to an index, `duplicate` an instance under new ids, `remove` one and
421
+ `restore` it, `hide` one or show it again, replace `text` that reads `from`
422
+ inside an instance with `to` — and leaves the rest to the render, which is
423
+ the page's truth. An editor therefore always sends the render too; a page
424
+ from before 1.6.0 ignores patches and still shows every change, a moment
425
+ later.
426
+
415
427
  A swapped section's scripts do not run again. A section with browser behavior
416
428
  binds it again on `PREVIEW_SECTION_LOAD_EVENT` (`wocart:section-load`,
417
- dispatched on `document` with `detail.id`). When the bridge replaces the whole
429
+ dispatched on `document` with `detail.id`) — also for a section a patch
430
+ copied, restored or showed again. When the bridge replaces the whole
418
431
  page instead, it does what Astro's router does after a navigation — a new
419
432
  `<body>`, the root element's attributes, the title — and dispatches
420
433
  `astro:page-load`, so a script written for the router mounts again as it does
package/dist/index.d.ts CHANGED
@@ -28,6 +28,6 @@ export type { MessageOptions, MessageValues, PluralCategory } from "./messages.j
28
28
  export { THEME_MANIFEST_CONTRACT_VERSION, THEME_MANIFEST_PATH, manifestCatalog, themeManifest, validateThemeManifest, } from "./manifest.js";
29
29
  export { diffThemeManifests } from "./manifest-diff.js";
30
30
  export { PREVIEW_PROTOCOL_VERSION, THEME_PREVIEW_PATH, parsePreviewMarker, previewEndMarker, previewStartMarker, } from "./preview-protocol.js";
31
- export type { PreviewEditorMessage, PreviewInstance, PreviewMode, PreviewPageMessage, PreviewRect, } from "./preview-protocol.js";
31
+ export type { PreviewEditorMessage, PreviewInstance, PreviewMode, PreviewPageMessage, PreviewPatch, PreviewRect, } from "./preview-protocol.js";
32
32
  export type { ManifestChange, ManifestDiff } from "./manifest-diff.js";
33
33
  export type { JsonValue, JsonObject, LocaleCode, LocalizedText, ResolutionScope, FieldKind, FieldDefinition, FieldValue, InferProps, FieldValues, SlotDefinition, ComponentCapabilities, ComponentPreset, PresetInstance, SurfacePageType, ComponentDefinition, ComponentSpec, SurfaceSpec, ThemeDefinition, SettingsGroup, InferSettings, ThemeSettingsIssue, ThemeSettingsIssueCode, ThemeSettingsResult, NumberFieldDefinition, ColorFieldDefinition, MenuFieldDefinition, CollectionFieldDefinition, ProductFieldDefinition, CategoryFieldDefinition, ImageFieldDefinition, RichTextFieldDefinition, RichTextFormat, ListFieldDefinition, ListItemFieldDefinition, ListItem, MediaReference, ResolvedImage, RichTextValue, RichTextDocument, RichTextBlock, RichTextParagraph, RichTextHeading, RichTextList, RichTextListItem, RichTextInline, RichTextText, RichTextBreak, RichTextMark, ThemeManifest, ThemeManifestIssue, ThemeManifestIssueCode, ThemeManifestResult, ComponentInstance, PageData, ThemeRenderContext, LoadContext, LoadFn, ReadCache, PageDataIssue, PageDataIssueCode, PageDataResult, ResolvedInstance, ThemeDefinitionIssue, ThemeDefinitionIssueCode, ThemeValidationOptions, } from "./types.js";
@@ -6,12 +6,14 @@
6
6
  * at, and re-renders a draft the editor sends by posting it back to the
7
7
  * storefront and swapping in only the sections that changed — or, when the
8
8
  * page around them changed, the whole page, the way Astro's router swaps one.
9
+ * A change the editor patches is shown at once, on the page as it stands,
10
+ * until that render arrives.
9
11
  *
10
12
  * It draws nothing — outlines, toolbars and panels are the editor's. It posts
11
13
  * only to the editor origin it is given and reads only that origin's messages
12
14
  * from the framing window.
13
15
  */
14
- import { PREVIEW_PROTOCOL_VERSION, THEME_PREVIEW_PATH, parsePreviewMarker, } from "./preview-protocol.js";
16
+ import { PREVIEW_PROTOCOL_VERSION, THEME_PREVIEW_PATH, parsePreviewMarker, previewEndMarker, previewStartMarker, } from "./preview-protocol.js";
15
17
  /**
16
18
  * Dispatched on `document` for each section the bridge swapped in, with the
17
19
  * instance id as `detail.id`: a section with browser behavior binds it again.
@@ -21,7 +23,9 @@ import { PREVIEW_PROTOCOL_VERSION, THEME_PREVIEW_PATH, parsePreviewMarker, } fro
21
23
  export const PREVIEW_SECTION_LOAD_EVENT = "wocart:section-load";
22
24
  const ASTRO_PAGE_LOAD_EVENT = "astro:page-load";
23
25
  const SHOW_COMMENT = 0x80;
26
+ const SHOW_TEXT = 0x4;
24
27
  const ELEMENT_NODE = 1;
28
+ const TEXT_NODE = 3;
25
29
  const COMMENT_NODE = 8;
26
30
  /**
27
31
  * Every complete marker pair under `root`, in document order, each with the
@@ -74,6 +78,30 @@ function markup(nodes) {
74
78
  })
75
79
  .join("");
76
80
  }
81
+ /** An instance's nodes with its markers: what moves, copies or goes with it. */
82
+ function withMarkers(entry) {
83
+ const inside = between(entry);
84
+ return inside === null ? null : [entry.start, ...inside, entry.end];
85
+ }
86
+ /** Text nodes under `nodes` that a merchant reads — never a script's or a style's. */
87
+ function textNodes(nodes) {
88
+ const found = [];
89
+ for (const node of nodes) {
90
+ if (node.nodeType === TEXT_NODE) {
91
+ found.push(node);
92
+ continue;
93
+ }
94
+ if (node.nodeType !== ELEMENT_NODE)
95
+ continue;
96
+ const walker = node.ownerDocument.createTreeWalker(node, SHOW_TEXT);
97
+ for (let text = walker.nextNode(); text !== null; text = walker.nextNode()) {
98
+ const tag = text.parentElement?.tagName;
99
+ if (tag !== "SCRIPT" && tag !== "STYLE" && tag !== "TEMPLATE")
100
+ found.push(text);
101
+ }
102
+ }
103
+ return found;
104
+ }
77
105
  /**
78
106
  * The page with every section's content left out — the root's attributes and
79
107
  * the body around the sections: what must not change for sections to be
@@ -229,6 +257,132 @@ export function startPreviewBridge(options) {
229
257
  }
230
258
  return swapped;
231
259
  };
260
+ /** What a `remove` or `hide` patch took off the page, to put back on `restore` or show. */
261
+ const removed = new Map();
262
+ const hiddenNodes = new Map();
263
+ const sectionsOf = () => marked.filter((entry) => entry.parent === null);
264
+ const find = (id) => marked.find((entry) => entry.id === id);
265
+ /** Puts `nodes` at `index` among the document's sections other than `id`. */
266
+ const place = (nodes, id, index) => {
267
+ const others = sectionsOf().filter((entry) => entry.id !== id);
268
+ if (!Number.isInteger(index) || index < 0 || index > others.length)
269
+ return false;
270
+ const before = others[index]?.start ?? others.at(-1)?.end.nextSibling ?? null;
271
+ const parent = others[index]?.start.parentNode ?? others.at(-1)?.end.parentNode;
272
+ if (!parent)
273
+ return false;
274
+ for (const node of nodes)
275
+ parent.insertBefore(node, before);
276
+ return true;
277
+ };
278
+ const relabel = (marker, ids) => {
279
+ const parsed = parsePreviewMarker(marker.data);
280
+ if (parsed === null || !Object.hasOwn(ids, parsed.id))
281
+ return;
282
+ const id = ids[parsed.id];
283
+ marker.data = parsed.kind === "start" ? previewStartMarker({ id, type: parsed.type, hidden: parsed.hidden }) : previewEndMarker({ id });
284
+ };
285
+ /** Applies one patch to the page as it stands; returns the sections it put in. */
286
+ const apply = (patch) => {
287
+ const entry = find(patch.id);
288
+ switch (patch.op) {
289
+ case "move": {
290
+ if (entry === undefined || entry.parent !== null)
291
+ return [];
292
+ const nodes = withMarkers(entry);
293
+ if (nodes !== null)
294
+ place(nodes, entry.id, patch.index);
295
+ return [];
296
+ }
297
+ case "duplicate": {
298
+ const nodes = entry === undefined ? null : withMarkers(entry);
299
+ if (entry === undefined || nodes === null || !Object.hasOwn(patch.ids, entry.id))
300
+ return [];
301
+ const copies = nodes.map((node) => node.cloneNode(true));
302
+ for (const copy of copies) {
303
+ if (copy.nodeType === COMMENT_NODE)
304
+ relabel(copy, patch.ids);
305
+ else if (copy.nodeType === ELEMENT_NODE) {
306
+ const walker = doc.createTreeWalker(copy, SHOW_COMMENT);
307
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode())
308
+ relabel(node, patch.ids);
309
+ }
310
+ }
311
+ const after = entry.end.nextSibling;
312
+ for (const copy of copies)
313
+ entry.end.parentNode.insertBefore(copy, after);
314
+ return [patch.ids[entry.id]];
315
+ }
316
+ case "remove": {
317
+ const nodes = entry === undefined ? null : withMarkers(entry);
318
+ if (nodes === null)
319
+ return [];
320
+ for (const node of nodes)
321
+ node.parentNode?.removeChild(node);
322
+ removed.set(patch.id, nodes);
323
+ return [];
324
+ }
325
+ case "restore": {
326
+ const nodes = removed.get(patch.id);
327
+ if (entry !== undefined || nodes === undefined || !place(nodes, patch.id, patch.index))
328
+ return [];
329
+ removed.delete(patch.id);
330
+ return [patch.id];
331
+ }
332
+ case "hide": {
333
+ if (entry === undefined || entry.hidden === patch.hidden)
334
+ return [];
335
+ const inside = between(entry);
336
+ if (inside === null)
337
+ return [];
338
+ if (patch.hidden) {
339
+ for (const node of inside)
340
+ node.parentNode?.removeChild(node);
341
+ hiddenNodes.set(entry.id, inside);
342
+ }
343
+ else {
344
+ const kept = hiddenNodes.get(entry.id);
345
+ if (kept === undefined)
346
+ return [];
347
+ for (const node of kept)
348
+ entry.end.parentNode.insertBefore(node, entry.end);
349
+ hiddenNodes.delete(entry.id);
350
+ }
351
+ entry.start.data = previewStartMarker({ id: entry.id, type: entry.type, hidden: patch.hidden });
352
+ return patch.hidden ? [] : [entry.id];
353
+ }
354
+ case "text": {
355
+ const inside = entry === undefined ? null : between(entry);
356
+ const from = patch.from.trim();
357
+ if (inside === null || from === "" || typeof patch.to !== "string")
358
+ return [];
359
+ for (const node of textNodes(inside)) {
360
+ if (node.data.trim() !== from)
361
+ continue;
362
+ const lead = /^\s*/.exec(node.data)[0];
363
+ const trail = /\s*$/.exec(node.data)[0];
364
+ node.data = `${lead}${patch.to}${trail}`;
365
+ }
366
+ return [];
367
+ }
368
+ default:
369
+ return [];
370
+ }
371
+ };
372
+ /** Shows `patches` at once; a render already on its way would undo them, so it is dropped. */
373
+ const patchPage = (patches) => {
374
+ latest += 1;
375
+ const loaded = [];
376
+ for (const patch of patches) {
377
+ if (typeof patch !== "object" || patch === null || typeof patch.id !== "string")
378
+ continue;
379
+ loaded.push(...apply(patch));
380
+ marked = collect(doc.body);
381
+ }
382
+ for (const id of loaded)
383
+ doc.dispatchEvent(new win.CustomEvent(PREVIEW_SECTION_LOAD_EVENT, { detail: { id } }));
384
+ post({ type: "layout", instances: instances() });
385
+ };
232
386
  const render = async (draft, revision) => {
233
387
  const ticket = ++latest;
234
388
  try {
@@ -276,6 +430,10 @@ export function startPreviewBridge(options) {
276
430
  void render(message.draft, typeof message.revision === "number" ? message.revision : null);
277
431
  }
278
432
  break;
433
+ case "patch":
434
+ if (Array.isArray(message.patches))
435
+ patchPage(message.patches);
436
+ break;
279
437
  case "mode":
280
438
  if (message.mode === "edit" || message.mode === "interactive")
281
439
  mode = message.mode;
@@ -56,10 +56,54 @@ export type PreviewPageMessage = {
56
56
  });
57
57
  /** The interaction an editor allows: `edit` turns clicks into selections; `interactive` lets the page behave. */
58
58
  export type PreviewMode = "edit" | "interactive";
59
+ /**
60
+ * A change the editor has already made to its draft, for the page to show at
61
+ * once, before the `render` that follows it. A patch is a hint: the page
62
+ * applies what it can find and leaves the rest to that render, which is the
63
+ * page's truth — so an editor always sends the render too, and a page that
64
+ * does not know patches (before `@wocart/theme` 1.6.0) still shows every
65
+ * change, a moment later.
66
+ *
67
+ * - `move`: a section of the document to `index` among the sections, counted
68
+ * once it has left its place (`moveInstance`).
69
+ * - `duplicate`: a copy of an instance right after it; `ids` maps every
70
+ * instance id inside it, its own included, to its copy's.
71
+ * - `remove`: an instance and everything in it; `restore` puts what a
72
+ * `remove` took back, as a section of the document at `index`.
73
+ * - `hide`: an instance switched off (it shows nothing) or back on.
74
+ * - `text`: inside an instance, text that reads `from` now reads `to` — a
75
+ * field's text as the theme prints it.
76
+ */
77
+ export type PreviewPatch = {
78
+ op: "move";
79
+ id: string;
80
+ index: number;
81
+ } | {
82
+ op: "duplicate";
83
+ id: string;
84
+ ids: Readonly<Record<string, string>>;
85
+ } | {
86
+ op: "remove";
87
+ id: string;
88
+ } | {
89
+ op: "restore";
90
+ id: string;
91
+ index: number;
92
+ } | {
93
+ op: "hide";
94
+ id: string;
95
+ hidden: boolean;
96
+ } | {
97
+ op: "text";
98
+ id: string;
99
+ from: string;
100
+ to: string;
101
+ };
59
102
  /**
60
103
  * What the editor tells the preview page. `draft` is a `PreviewDraft` of
61
104
  * `@wocart/storefront`; `revision`, when given, comes back on the `rendered`
62
- * or `error` it causes, and a render answered after a newer one is dropped.
105
+ * or `error` it causes, and a render answered after a newer one is dropped —
106
+ * as is one answered after a `patch`, which the next render follows.
63
107
  */
64
108
  export type PreviewEditorMessage = {
65
109
  source: "wocart-editor";
@@ -68,6 +112,9 @@ export type PreviewEditorMessage = {
68
112
  type: "render";
69
113
  draft: Record<string, unknown>;
70
114
  revision?: number;
115
+ } | {
116
+ type: "patch";
117
+ patches: readonly PreviewPatch[];
71
118
  } | {
72
119
  type: "mode";
73
120
  mode: PreviewMode;
package/dist/version.d.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  * reads. Always equal to `package.json`'s version; the package check refuses
5
5
  * a release where they differ.
6
6
  */
7
- export declare const THEME_SDK_VERSION = "1.5.0";
7
+ export declare const THEME_SDK_VERSION = "1.6.0";
package/dist/version.js CHANGED
@@ -4,4 +4,4 @@
4
4
  * reads. Always equal to `package.json`'s version; the package check refuses
5
5
  * a release where they differ.
6
6
  */
7
- export const THEME_SDK_VERSION = "1.5.0";
7
+ export const THEME_SDK_VERSION = "1.6.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wocart/theme",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "The Wocart theme contract: code-owned component definitions, JSON page documents, validation and resolution, and a reference Astro renderer.",
5
5
  "keywords": [
6
6
  "wocart",