@wocart/theme 1.4.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 +71 -0
- package/README.md +299 -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 +475 -0
- package/dist/preview-protocol.d.ts +147 -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
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* The preview page's side of the preview protocol: a small browser module a
|
|
4
|
+
* theme loads on its preview route only. It finds the instances the renderer
|
|
5
|
+
* marked, tells the editor where they are and which one the merchant points
|
|
6
|
+
* at, and re-renders a draft the editor sends by posting it back to the
|
|
7
|
+
* storefront and swapping in only the sections that changed — or, when the
|
|
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.
|
|
11
|
+
*
|
|
12
|
+
* It draws nothing — outlines, toolbars and panels are the editor's. It posts
|
|
13
|
+
* only to the editor origin it is given and reads only that origin's messages
|
|
14
|
+
* from the framing window.
|
|
15
|
+
*/
|
|
16
|
+
import { PREVIEW_PROTOCOL_VERSION, THEME_PREVIEW_PATH, parsePreviewMarker, previewEndMarker, previewStartMarker, } from "./preview-protocol.js";
|
|
17
|
+
/**
|
|
18
|
+
* Dispatched on `document` for each section the bridge swapped in, with the
|
|
19
|
+
* instance id as `detail.id`: a section with browser behavior binds it again.
|
|
20
|
+
* When the whole page was replaced instead, the bridge dispatches Astro's own
|
|
21
|
+
* `astro:page-load`, as Astro's router does after it swaps a page.
|
|
22
|
+
*/
|
|
23
|
+
export const PREVIEW_SECTION_LOAD_EVENT = "wocart:section-load";
|
|
24
|
+
const ASTRO_PAGE_LOAD_EVENT = "astro:page-load";
|
|
25
|
+
const SHOW_COMMENT = 0x80;
|
|
26
|
+
const SHOW_TEXT = 0x4;
|
|
27
|
+
const ELEMENT_NODE = 1;
|
|
28
|
+
const TEXT_NODE = 3;
|
|
29
|
+
const COMMENT_NODE = 8;
|
|
30
|
+
/**
|
|
31
|
+
* Every complete marker pair under `root`, in document order, each with the
|
|
32
|
+
* pair that encloses it. Pairs nest or are ignored: a start still open when an
|
|
33
|
+
* enclosing pair closes never pairs.
|
|
34
|
+
*/
|
|
35
|
+
function collect(root) {
|
|
36
|
+
const pairs = [];
|
|
37
|
+
const open = [];
|
|
38
|
+
const walker = (root.ownerDocument ?? root).createTreeWalker(root, SHOW_COMMENT);
|
|
39
|
+
let position = 0;
|
|
40
|
+
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
|
|
41
|
+
const marker = parsePreviewMarker(node.data);
|
|
42
|
+
if (marker === null)
|
|
43
|
+
continue;
|
|
44
|
+
position += 1;
|
|
45
|
+
if (marker.kind === "start") {
|
|
46
|
+
open.push({ id: marker.id, type: marker.type, hidden: marker.hidden, start: node, opened: position });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const index = open.map((entry) => entry.id).lastIndexOf(marker.id);
|
|
50
|
+
if (index === -1)
|
|
51
|
+
continue;
|
|
52
|
+
pairs.push({ ...open.splice(index)[0], end: node, closed: position });
|
|
53
|
+
}
|
|
54
|
+
pairs.sort((a, b) => a.opened - b.opened);
|
|
55
|
+
return pairs.map(({ opened, closed, ...entry }) => {
|
|
56
|
+
const enclosing = pairs.filter((other) => other.opened < opened && closed < other.closed);
|
|
57
|
+
return { ...entry, parent: enclosing.at(-1)?.id ?? null, depth: enclosing.length };
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/** The nodes between a pair of markers, or `null` when the pair are not siblings. */
|
|
61
|
+
function between(entry) {
|
|
62
|
+
const nodes = [];
|
|
63
|
+
for (let node = entry.start.nextSibling; node !== entry.end; node = node.nextSibling) {
|
|
64
|
+
if (node === null)
|
|
65
|
+
return null;
|
|
66
|
+
nodes.push(node);
|
|
67
|
+
}
|
|
68
|
+
return nodes;
|
|
69
|
+
}
|
|
70
|
+
function markup(nodes) {
|
|
71
|
+
return nodes
|
|
72
|
+
.map((node) => {
|
|
73
|
+
if (node.nodeType === ELEMENT_NODE)
|
|
74
|
+
return node.outerHTML;
|
|
75
|
+
if (node.nodeType === COMMENT_NODE)
|
|
76
|
+
return `<!--${node.data}-->`;
|
|
77
|
+
return node.textContent ?? "";
|
|
78
|
+
})
|
|
79
|
+
.join("");
|
|
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
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The page with every section's content left out — the root's attributes and
|
|
107
|
+
* the body around the sections: what must not change for sections to be
|
|
108
|
+
* swapped alone.
|
|
109
|
+
*/
|
|
110
|
+
function frameOf(doc) {
|
|
111
|
+
const copy = doc.body.cloneNode(true);
|
|
112
|
+
for (const entry of collect(copy)) {
|
|
113
|
+
if (entry.parent !== null)
|
|
114
|
+
continue;
|
|
115
|
+
const nodes = between(entry);
|
|
116
|
+
if (nodes === null)
|
|
117
|
+
return null;
|
|
118
|
+
for (const node of nodes)
|
|
119
|
+
node.parentNode?.removeChild(node);
|
|
120
|
+
}
|
|
121
|
+
const root = doc.documentElement;
|
|
122
|
+
const attributes = root.getAttributeNames().map((name) => `${name}=${JSON.stringify(root.getAttribute(name))}`);
|
|
123
|
+
return `${attributes.join(" ")}\n${copy.outerHTML}`;
|
|
124
|
+
}
|
|
125
|
+
function stylesOf(doc) {
|
|
126
|
+
return Array.from(doc.head.querySelectorAll('style, link[rel="stylesheet"]'));
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Starts the bridge on a preview page. It posts `ready` with every marked
|
|
130
|
+
* instance, `layout` as the page scrolls or resizes, `hover` as the merchant
|
|
131
|
+
* points, `select` as they click (in `edit` mode, where links and forms do
|
|
132
|
+
* nothing), and `rendered` after each draft it swaps in.
|
|
133
|
+
*/
|
|
134
|
+
export function startPreviewBridge(options) {
|
|
135
|
+
const win = (options.window ?? window);
|
|
136
|
+
const doc = win.document;
|
|
137
|
+
const endpoint = options.endpoint ?? THEME_PREVIEW_PATH;
|
|
138
|
+
let token = options.token;
|
|
139
|
+
let mode = "edit";
|
|
140
|
+
let marked = collect(doc.body);
|
|
141
|
+
let hovered = null;
|
|
142
|
+
let frame = 0;
|
|
143
|
+
let latest = 0;
|
|
144
|
+
let stopped = false;
|
|
145
|
+
const post = (message) => win.parent.postMessage({ source: "wocart-preview", version: PREVIEW_PROTOCOL_VERSION, ...message }, options.editorOrigin);
|
|
146
|
+
const rangeOf = (entry) => {
|
|
147
|
+
const range = doc.createRange();
|
|
148
|
+
range.setStartAfter(entry.start);
|
|
149
|
+
range.setEndBefore(entry.end);
|
|
150
|
+
return range;
|
|
151
|
+
};
|
|
152
|
+
const instances = () => marked.map((entry) => {
|
|
153
|
+
const box = rangeOf(entry).getBoundingClientRect();
|
|
154
|
+
const rect = box.width > 0 || box.height > 0 ? { x: box.x, y: box.y, width: box.width, height: box.height } : null;
|
|
155
|
+
return { id: entry.id, type: entry.type, parent: entry.parent, hidden: entry.hidden, rect };
|
|
156
|
+
});
|
|
157
|
+
const innermostAt = (target) => {
|
|
158
|
+
if (!(target instanceof win.Node))
|
|
159
|
+
return null;
|
|
160
|
+
let best;
|
|
161
|
+
for (const entry of marked) {
|
|
162
|
+
if (best !== undefined && entry.depth <= best.depth)
|
|
163
|
+
continue;
|
|
164
|
+
if (between(entry)?.some((node) => node.contains(target)))
|
|
165
|
+
best = entry;
|
|
166
|
+
}
|
|
167
|
+
return best?.id ?? null;
|
|
168
|
+
};
|
|
169
|
+
const reportLayout = () => {
|
|
170
|
+
if (frame !== 0)
|
|
171
|
+
return;
|
|
172
|
+
frame = win.requestAnimationFrame(() => {
|
|
173
|
+
frame = 0;
|
|
174
|
+
post({ type: "layout", instances: instances() });
|
|
175
|
+
});
|
|
176
|
+
};
|
|
177
|
+
const onClick = (event) => {
|
|
178
|
+
if (mode !== "edit")
|
|
179
|
+
return;
|
|
180
|
+
event.preventDefault();
|
|
181
|
+
event.stopPropagation();
|
|
182
|
+
const id = innermostAt(event.target);
|
|
183
|
+
if (id !== null)
|
|
184
|
+
post({ type: "select", id });
|
|
185
|
+
};
|
|
186
|
+
const onSubmit = (event) => {
|
|
187
|
+
if (mode !== "edit")
|
|
188
|
+
return;
|
|
189
|
+
event.preventDefault();
|
|
190
|
+
event.stopPropagation();
|
|
191
|
+
};
|
|
192
|
+
const onOver = (event) => {
|
|
193
|
+
const id = innermostAt(event.target);
|
|
194
|
+
if (id === hovered)
|
|
195
|
+
return;
|
|
196
|
+
hovered = id;
|
|
197
|
+
post({ type: "hover", id });
|
|
198
|
+
};
|
|
199
|
+
const onLeave = () => {
|
|
200
|
+
if (hovered === null)
|
|
201
|
+
return;
|
|
202
|
+
hovered = null;
|
|
203
|
+
post({ type: "hover", id: null });
|
|
204
|
+
};
|
|
205
|
+
const swapHead = (next) => {
|
|
206
|
+
const current = stylesOf(doc);
|
|
207
|
+
const wanted = stylesOf(next);
|
|
208
|
+
if (current.length === wanted.length) {
|
|
209
|
+
current.forEach((node, index) => {
|
|
210
|
+
if (node.outerHTML !== wanted[index].outerHTML)
|
|
211
|
+
node.replaceWith(doc.importNode(wanted[index], true));
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
for (const node of current)
|
|
216
|
+
node.remove();
|
|
217
|
+
for (const node of wanted)
|
|
218
|
+
doc.head.appendChild(doc.importNode(node, true));
|
|
219
|
+
}
|
|
220
|
+
if (next.title !== doc.title)
|
|
221
|
+
doc.title = next.title;
|
|
222
|
+
};
|
|
223
|
+
const swapPage = (next) => {
|
|
224
|
+
const root = doc.documentElement;
|
|
225
|
+
for (const name of root.getAttributeNames())
|
|
226
|
+
if (!next.documentElement.hasAttribute(name))
|
|
227
|
+
root.removeAttribute(name);
|
|
228
|
+
for (const name of next.documentElement.getAttributeNames())
|
|
229
|
+
root.setAttribute(name, next.documentElement.getAttribute(name));
|
|
230
|
+
const { scrollX, scrollY } = win;
|
|
231
|
+
doc.body.replaceWith(doc.importNode(next.body, true));
|
|
232
|
+
win.scrollTo(scrollX, scrollY);
|
|
233
|
+
};
|
|
234
|
+
/** Swaps `next` in; `null` when the whole page was replaced, else the sections that were. */
|
|
235
|
+
const swap = (next) => {
|
|
236
|
+
swapHead(next);
|
|
237
|
+
const before = frameOf(doc);
|
|
238
|
+
if (before === null || before !== frameOf(next)) {
|
|
239
|
+
swapPage(next);
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
const incoming = collect(next.body);
|
|
243
|
+
const swapped = [];
|
|
244
|
+
for (const entry of marked) {
|
|
245
|
+
if (entry.parent !== null)
|
|
246
|
+
continue;
|
|
247
|
+
const counterpart = incoming.find((candidate) => candidate.parent === null && candidate.id === entry.id);
|
|
248
|
+
const old = between(entry);
|
|
249
|
+
const fresh = counterpart === undefined ? null : between(counterpart);
|
|
250
|
+
if (old === null || fresh === null || markup(old) === markup(fresh))
|
|
251
|
+
continue;
|
|
252
|
+
for (const node of old)
|
|
253
|
+
node.parentNode?.removeChild(node);
|
|
254
|
+
for (const node of fresh)
|
|
255
|
+
entry.end.parentNode?.insertBefore(doc.importNode(node, true), entry.end);
|
|
256
|
+
swapped.push(entry.id);
|
|
257
|
+
}
|
|
258
|
+
return swapped;
|
|
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
|
+
};
|
|
386
|
+
const render = async (draft, revision) => {
|
|
387
|
+
const ticket = ++latest;
|
|
388
|
+
try {
|
|
389
|
+
const response = await win.fetch(endpoint, {
|
|
390
|
+
method: "POST",
|
|
391
|
+
headers: { "Content-Type": "application/json", Accept: "text/html" },
|
|
392
|
+
body: JSON.stringify({ token, draft }),
|
|
393
|
+
credentials: "same-origin",
|
|
394
|
+
});
|
|
395
|
+
const html = await response.text();
|
|
396
|
+
if (stopped || ticket !== latest)
|
|
397
|
+
return;
|
|
398
|
+
if (!response.ok) {
|
|
399
|
+
post({ type: "error", revision, reason: `the preview answered ${response.status}` });
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const swapped = swap(new win.DOMParser().parseFromString(html, "text/html"));
|
|
403
|
+
marked = collect(doc.body);
|
|
404
|
+
if (swapped === null) {
|
|
405
|
+
doc.dispatchEvent(new win.Event(ASTRO_PAGE_LOAD_EVENT));
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
for (const id of swapped)
|
|
409
|
+
doc.dispatchEvent(new win.CustomEvent(PREVIEW_SECTION_LOAD_EVENT, { detail: { id } }));
|
|
410
|
+
}
|
|
411
|
+
post({ type: "rendered", revision, instances: instances() });
|
|
412
|
+
}
|
|
413
|
+
catch (error) {
|
|
414
|
+
if (stopped || ticket !== latest)
|
|
415
|
+
return;
|
|
416
|
+
post({ type: "error", revision, reason: error instanceof Error ? error.message : String(error) });
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
const onMessage = (event) => {
|
|
420
|
+
if (event.origin !== options.editorOrigin || event.source !== win.parent)
|
|
421
|
+
return;
|
|
422
|
+
const message = event.data;
|
|
423
|
+
if (typeof message !== "object" || message === null)
|
|
424
|
+
return;
|
|
425
|
+
if (message.source !== "wocart-editor" || message.version !== PREVIEW_PROTOCOL_VERSION)
|
|
426
|
+
return;
|
|
427
|
+
switch (message.type) {
|
|
428
|
+
case "render":
|
|
429
|
+
if (typeof message.draft === "object" && message.draft !== null && !Array.isArray(message.draft)) {
|
|
430
|
+
void render(message.draft, typeof message.revision === "number" ? message.revision : null);
|
|
431
|
+
}
|
|
432
|
+
break;
|
|
433
|
+
case "patch":
|
|
434
|
+
if (Array.isArray(message.patches))
|
|
435
|
+
patchPage(message.patches);
|
|
436
|
+
break;
|
|
437
|
+
case "mode":
|
|
438
|
+
if (message.mode === "edit" || message.mode === "interactive")
|
|
439
|
+
mode = message.mode;
|
|
440
|
+
break;
|
|
441
|
+
case "scroll-to": {
|
|
442
|
+
const entry = marked.find((candidate) => candidate.id === message.id);
|
|
443
|
+
const first = entry === undefined ? undefined : between(entry)?.find((node) => node.nodeType === ELEMENT_NODE);
|
|
444
|
+
first?.scrollIntoView({ block: "start", behavior: "smooth" });
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
case "token":
|
|
448
|
+
if (typeof message.token === "string" && message.token !== "")
|
|
449
|
+
token = message.token;
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
win.addEventListener("message", onMessage);
|
|
454
|
+
win.addEventListener("scroll", reportLayout, { passive: true });
|
|
455
|
+
win.addEventListener("resize", reportLayout);
|
|
456
|
+
doc.addEventListener("click", onClick, true);
|
|
457
|
+
doc.addEventListener("submit", onSubmit, true);
|
|
458
|
+
doc.addEventListener("mouseover", onOver, true);
|
|
459
|
+
doc.documentElement.addEventListener("mouseleave", onLeave);
|
|
460
|
+
post({ type: "ready", instances: instances() });
|
|
461
|
+
return {
|
|
462
|
+
stop() {
|
|
463
|
+
win.removeEventListener("message", onMessage);
|
|
464
|
+
win.removeEventListener("scroll", reportLayout);
|
|
465
|
+
win.removeEventListener("resize", reportLayout);
|
|
466
|
+
doc.removeEventListener("click", onClick, true);
|
|
467
|
+
doc.removeEventListener("submit", onSubmit, true);
|
|
468
|
+
doc.removeEventListener("mouseover", onOver, true);
|
|
469
|
+
doc.documentElement.removeEventListener("mouseleave", onLeave);
|
|
470
|
+
if (frame !== 0)
|
|
471
|
+
win.cancelAnimationFrame(frame);
|
|
472
|
+
stopped = true;
|
|
473
|
+
},
|
|
474
|
+
};
|
|
475
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The preview protocol: how a framed storefront page in preview mode and the
|
|
3
|
+
* editor that frames it talk, and how the page marks its sections. Plain data
|
|
4
|
+
* and string helpers — shared by the renderer (`InstanceView`), the page's
|
|
5
|
+
* bridge (`@wocart/theme/preview`) and an editor, so the three cannot drift.
|
|
6
|
+
*
|
|
7
|
+
* Every message is `{ source, version, type, … }`. The page sends
|
|
8
|
+
* `source: "wocart-preview"`; the editor sends `source: "wocart-editor"`. Both
|
|
9
|
+
* sides post with the other's exact origin and check `event.origin` and
|
|
10
|
+
* `event.source` before reading a message.
|
|
11
|
+
*/
|
|
12
|
+
/** Incremented on a change an older counterpart could not read. */
|
|
13
|
+
export declare const PREVIEW_PROTOCOL_VERSION: 1;
|
|
14
|
+
/** Where a theme that supports preview serves it: a POST route of the storefront. */
|
|
15
|
+
export declare const THEME_PREVIEW_PATH = "/wocart-preview";
|
|
16
|
+
/** A section's place on the page, in the frame's viewport coordinates. */
|
|
17
|
+
export interface PreviewRect {
|
|
18
|
+
x: number;
|
|
19
|
+
y: number;
|
|
20
|
+
width: number;
|
|
21
|
+
height: number;
|
|
22
|
+
}
|
|
23
|
+
/** One rendered instance, as the page reports it. `rect` is null when it shows nothing. */
|
|
24
|
+
export interface PreviewInstance {
|
|
25
|
+
id: string;
|
|
26
|
+
type: string;
|
|
27
|
+
/** The instance it is inside, or `null` for a section of the document. */
|
|
28
|
+
parent: string | null;
|
|
29
|
+
hidden: boolean;
|
|
30
|
+
rect: PreviewRect | null;
|
|
31
|
+
}
|
|
32
|
+
/** What the preview page tells the editor. */
|
|
33
|
+
export type PreviewPageMessage = {
|
|
34
|
+
source: "wocart-preview";
|
|
35
|
+
version: typeof PREVIEW_PROTOCOL_VERSION;
|
|
36
|
+
} & ({
|
|
37
|
+
type: "ready";
|
|
38
|
+
instances: PreviewInstance[];
|
|
39
|
+
} | {
|
|
40
|
+
type: "layout";
|
|
41
|
+
instances: PreviewInstance[];
|
|
42
|
+
} | {
|
|
43
|
+
type: "rendered";
|
|
44
|
+
revision: number | null;
|
|
45
|
+
instances: PreviewInstance[];
|
|
46
|
+
} | {
|
|
47
|
+
type: "select";
|
|
48
|
+
id: string;
|
|
49
|
+
} | {
|
|
50
|
+
type: "hover";
|
|
51
|
+
id: string | null;
|
|
52
|
+
} | {
|
|
53
|
+
type: "error";
|
|
54
|
+
revision: number | null;
|
|
55
|
+
reason: string;
|
|
56
|
+
});
|
|
57
|
+
/** The interaction an editor allows: `edit` turns clicks into selections; `interactive` lets the page behave. */
|
|
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
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* What the editor tells the preview page. `draft` is a `PreviewDraft` of
|
|
104
|
+
* `@wocart/storefront`; `revision`, when given, comes back on the `rendered`
|
|
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.
|
|
107
|
+
*/
|
|
108
|
+
export type PreviewEditorMessage = {
|
|
109
|
+
source: "wocart-editor";
|
|
110
|
+
version: typeof PREVIEW_PROTOCOL_VERSION;
|
|
111
|
+
} & ({
|
|
112
|
+
type: "render";
|
|
113
|
+
draft: Record<string, unknown>;
|
|
114
|
+
revision?: number;
|
|
115
|
+
} | {
|
|
116
|
+
type: "patch";
|
|
117
|
+
patches: readonly PreviewPatch[];
|
|
118
|
+
} | {
|
|
119
|
+
type: "mode";
|
|
120
|
+
mode: PreviewMode;
|
|
121
|
+
} | {
|
|
122
|
+
type: "scroll-to";
|
|
123
|
+
id: string;
|
|
124
|
+
} | {
|
|
125
|
+
type: "token";
|
|
126
|
+
token: string;
|
|
127
|
+
});
|
|
128
|
+
/** The comment text that opens an instance's nodes. */
|
|
129
|
+
export declare function previewStartMarker(instance: {
|
|
130
|
+
id: string;
|
|
131
|
+
type: string;
|
|
132
|
+
hidden?: boolean;
|
|
133
|
+
}): string;
|
|
134
|
+
/** The comment text that closes them. */
|
|
135
|
+
export declare function previewEndMarker(instance: {
|
|
136
|
+
id: string;
|
|
137
|
+
}): string;
|
|
138
|
+
/** What a marker comment says, or `null` for any other comment. */
|
|
139
|
+
export declare function parsePreviewMarker(text: string): {
|
|
140
|
+
kind: "start";
|
|
141
|
+
id: string;
|
|
142
|
+
type: string;
|
|
143
|
+
hidden: boolean;
|
|
144
|
+
} | {
|
|
145
|
+
kind: "end";
|
|
146
|
+
id: string;
|
|
147
|
+
} | null;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The preview protocol: how a framed storefront page in preview mode and the
|
|
3
|
+
* editor that frames it talk, and how the page marks its sections. Plain data
|
|
4
|
+
* and string helpers — shared by the renderer (`InstanceView`), the page's
|
|
5
|
+
* bridge (`@wocart/theme/preview`) and an editor, so the three cannot drift.
|
|
6
|
+
*
|
|
7
|
+
* Every message is `{ source, version, type, … }`. The page sends
|
|
8
|
+
* `source: "wocart-preview"`; the editor sends `source: "wocart-editor"`. Both
|
|
9
|
+
* sides post with the other's exact origin and check `event.origin` and
|
|
10
|
+
* `event.source` before reading a message.
|
|
11
|
+
*/
|
|
12
|
+
/** Incremented on a change an older counterpart could not read. */
|
|
13
|
+
export const PREVIEW_PROTOCOL_VERSION = 1;
|
|
14
|
+
/** Where a theme that supports preview serves it: a POST route of the storefront. */
|
|
15
|
+
export const THEME_PREVIEW_PATH = "/wocart-preview";
|
|
16
|
+
// ── Section markers ─────────────────────────────────────────────────────────
|
|
17
|
+
// A preview render brackets each instance with two HTML comments, so the page
|
|
18
|
+
// can find every section's nodes — even a section that rendered nothing — and
|
|
19
|
+
// a shopper's page carries none of them.
|
|
20
|
+
const START = "wocart:start";
|
|
21
|
+
const END = "wocart:end";
|
|
22
|
+
/** The comment text that opens an instance's nodes. */
|
|
23
|
+
export function previewStartMarker(instance) {
|
|
24
|
+
return `${START} id=${encodeURIComponent(instance.id)} type=${encodeURIComponent(instance.type)}${instance.hidden === true ? " hidden" : ""}`;
|
|
25
|
+
}
|
|
26
|
+
/** The comment text that closes them. */
|
|
27
|
+
export function previewEndMarker(instance) {
|
|
28
|
+
return `${END} id=${encodeURIComponent(instance.id)}`;
|
|
29
|
+
}
|
|
30
|
+
/** What a marker comment says, or `null` for any other comment. */
|
|
31
|
+
export function parsePreviewMarker(text) {
|
|
32
|
+
const start = /^wocart:start id=([^ ]+) type=([^ ]+)( hidden)?$/.exec(text);
|
|
33
|
+
if (start) {
|
|
34
|
+
try {
|
|
35
|
+
return { kind: "start", id: decodeURIComponent(start[1]), type: decodeURIComponent(start[2]), hidden: start[3] !== undefined };
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const end = /^wocart:end id=([^ ]+)$/.exec(text);
|
|
42
|
+
if (end) {
|
|
43
|
+
try {
|
|
44
|
+
return { kind: "end", id: decodeURIComponent(end[1]) };
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|