@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.
@@ -0,0 +1,377 @@
1
+ /**
2
+ * Document operations: the changes a writer makes to a page document, with
3
+ * the rules every writer must apply — an editor, a CLI, an assistant. Each
4
+ * returns a new document or says why it refused; the input is never changed.
5
+ *
6
+ * The rules are the reader's rules made strict: a document these operations
7
+ * build is one `validatePageData` reads without a report, and that the
8
+ * renderer renders as the writer saw it. How an editor lets a merchant drag,
9
+ * select or undo is the editor's own.
10
+ */
11
+ import { surfaceCovers } from "./composition.js";
12
+ import { checkStoredValue, hasFallback, ownValue } from "./instances.js";
13
+ import { ComponentInstanceSchema } from "./schemas.js";
14
+ /**
15
+ * A new instance id: `cmp_` and 16 random hex digits. Operations refuse an id
16
+ * the document already holds, so a collision is reported, never written.
17
+ */
18
+ export function newInstanceId() {
19
+ const bytes = crypto.getRandomValues(new Uint8Array(8));
20
+ return `cmp_${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
21
+ }
22
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
23
+ const definitionOf = (theme, type) => Object.hasOwn(theme.components, type) ? theme.components[type] : undefined;
24
+ const refuse = (code, message, path = "") => ({
25
+ ok: false,
26
+ issue: { path, code, message },
27
+ });
28
+ /** A node's children and slots as one map; the default slot merges `children` and `slots.default`, in render order. */
29
+ function slotLists(node) {
30
+ const lists = [];
31
+ const defaults = [...(node.children ?? []), ...(node.slots?.default ?? [])];
32
+ if (defaults.length > 0 || node.children !== undefined)
33
+ lists.push(["default", defaults, "children"]);
34
+ for (const [name, list] of Object.entries(node.slots ?? {})) {
35
+ if (name !== "default")
36
+ lists.push([name, list, `slots.${name}`]);
37
+ }
38
+ return lists;
39
+ }
40
+ /** Every instance with its location, depth first. */
41
+ function* walk(list, parent, slot) {
42
+ for (const [index, instance] of list.entries()) {
43
+ yield { instance, parent, slot, index };
44
+ for (const [name, children] of slotLists(instance))
45
+ yield* walk(children, instance.id, name);
46
+ }
47
+ }
48
+ /** Where `id` is in the document, or `null`. */
49
+ export function findInstance(page, id) {
50
+ for (const location of walk(page.sections, null, null)) {
51
+ if (location.instance.id === id)
52
+ return location;
53
+ }
54
+ return null;
55
+ }
56
+ function documentIds(page) {
57
+ const ids = new Set();
58
+ for (const { instance } of walk(page.sections, null, null))
59
+ ids.add(instance.id);
60
+ return ids;
61
+ }
62
+ /**
63
+ * The editable list a placement names, in a copy of the document: the sections,
64
+ * or a parent's slot. The default slot is normalized to `children` (the order
65
+ * the reader renders), so a write never leaves content in two places.
66
+ */
67
+ function targetList(page, placement) {
68
+ if (placement.parent === undefined)
69
+ return page.sections;
70
+ const location = findInstance(page, placement.parent);
71
+ if (location === null)
72
+ return null;
73
+ const parent = location.instance;
74
+ const slot = placement.slot ?? "default";
75
+ if (slot === "default") {
76
+ if (parent.slots?.default !== undefined) {
77
+ parent.children = [...(parent.children ?? []), ...parent.slots.default];
78
+ delete parent.slots.default;
79
+ if (Object.keys(parent.slots).length === 0)
80
+ delete parent.slots;
81
+ }
82
+ parent.children ??= [];
83
+ return parent.children;
84
+ }
85
+ parent.slots ??= {};
86
+ parent.slots[slot] ??= [];
87
+ return parent.slots[slot];
88
+ }
89
+ /** Can `type` go into the placement's slot, given what is already there? */
90
+ function placementIssue(theme, page, type, placement, occupied) {
91
+ if (placement.parent === undefined) {
92
+ if (placement.slot !== undefined)
93
+ return refuse("unknown-slot", "the top-level sections have no slots");
94
+ return null;
95
+ }
96
+ const location = findInstance(page, placement.parent);
97
+ if (location === null)
98
+ return refuse("unknown-instance", `no instance "${placement.parent}" in the document`);
99
+ const definition = definitionOf(theme, location.instance.type);
100
+ if (definition === undefined)
101
+ return refuse("unknown-type", `"${location.instance.type}" is not in the theme catalog`);
102
+ const name = placement.slot ?? "default";
103
+ const slot = (definition.slots ?? []).find((candidate) => candidate.name === name);
104
+ if (slot === undefined)
105
+ return refuse("unknown-slot", `"${definition.type}" declares no slot "${name}"`);
106
+ if (slot.allow !== undefined && !slot.allow.includes(type)) {
107
+ return refuse("not-allowed", `"${type}" is not allowed in slot "${name}" of "${definition.type}"`);
108
+ }
109
+ if (slot.multiple === false && occupied >= 1) {
110
+ return refuse("slot-full", `slot "${name}" of "${definition.type}" holds at most one instance`);
111
+ }
112
+ return null;
113
+ }
114
+ /**
115
+ * An instance tree a writer may store, on this surface: known types, declared
116
+ * props with values their fields accept, required fields filled, slots used as
117
+ * declared, every component available on the surface, and ids unique among
118
+ * themselves and `taken`. Returns the first problem.
119
+ */
120
+ function treeIssue(theme, node, surface, taken, path) {
121
+ const definition = definitionOf(theme, node.type);
122
+ if (definition === undefined)
123
+ return { path: `${path}.type`, code: "unknown-type", message: `"${node.type}" is not in the theme catalog` };
124
+ const surfaces = definition.capabilities?.surfaces;
125
+ if (surfaces !== undefined && !surfaces.some((entry) => surfaceCovers(entry, surface))) {
126
+ return { path, code: "not-available", message: `"${node.type}" cannot be placed on "${surface}"` };
127
+ }
128
+ if (taken.has(node.id))
129
+ return { path: `${path}.id`, code: "duplicate-id", message: `the id "${node.id}" is already in the document` };
130
+ taken.add(node.id);
131
+ const fields = new Map((definition.fields ?? []).map((field) => [field.key, field]));
132
+ const props = node.props ?? {};
133
+ for (const [key, value] of Object.entries(props)) {
134
+ const field = fields.get(key);
135
+ if (field === undefined)
136
+ return { path: `${path}.props.${key}`, code: "invalid-props", message: `"${key}" is not a declared field of "${node.type}"` };
137
+ const checked = checkStoredValue(field, value);
138
+ if (!checked.ok)
139
+ return { path: `${path}.props.${key}`, code: "invalid-props", message: `"${key}" ${checked.message}` };
140
+ }
141
+ for (const field of fields.values()) {
142
+ if (field.required === true && ownValue(props, field.key) === undefined && !hasFallback(field)) {
143
+ return { path: `${path}.props.${field.key}`, code: "invalid-props", message: `required field "${field.key}" needs a value` };
144
+ }
145
+ }
146
+ const slots = new Map((definition.slots ?? []).map((slot) => [slot.name, slot]));
147
+ for (const [name, list, listPath] of slotLists(node)) {
148
+ const slot = slots.get(name);
149
+ if (slot === undefined) {
150
+ if (list.length === 0)
151
+ continue;
152
+ return { path: `${path}.${listPath}`, code: "unknown-slot", message: `"${node.type}" declares no slot "${name}"` };
153
+ }
154
+ if (slot.multiple === false && list.length > 1) {
155
+ return { path: `${path}.${listPath}`, code: "slot-full", message: `slot "${name}" of "${node.type}" holds at most one instance` };
156
+ }
157
+ for (const [index, child] of list.entries()) {
158
+ const childPath = `${path}.${listPath}[${index}]`;
159
+ if (slot.allow !== undefined && !slot.allow.includes(child.type)) {
160
+ return { path: childPath, code: "not-allowed", message: `"${child.type}" is not allowed in slot "${name}" of "${node.type}"` };
161
+ }
162
+ const issue = treeIssue(theme, child, surface, taken, childPath);
163
+ if (issue !== null)
164
+ return issue;
165
+ }
166
+ }
167
+ return null;
168
+ }
169
+ function parseInstance(instance) {
170
+ const parsed = ComponentInstanceSchema.safeParse(instance);
171
+ if (!parsed.success) {
172
+ return { path: "", code: "invalid-instance", message: `not an instance: ${parsed.error.issues[0]?.message ?? "invalid shape"}` };
173
+ }
174
+ return parsed.data;
175
+ }
176
+ const isIssue = (value) => "code" in value && "message" in value && !("type" in value);
177
+ function indexIssue(index, length) {
178
+ if (index === undefined)
179
+ return null;
180
+ if (!Number.isInteger(index) || index < 0 || index > length) {
181
+ return refuse("invalid-index", `index ${index} is outside 0…${length}`);
182
+ }
183
+ return null;
184
+ }
185
+ /**
186
+ * Insert a new instance — from `createInstance` or `instantiatePreset` — at a
187
+ * placement. Refused when the component is not insertable or not available on
188
+ * the document's surface, when the slot does not take it, or when any id is
189
+ * already in the document.
190
+ */
191
+ export function insertInstance(theme, page, instance, placement = {}) {
192
+ const parsed = parseInstance(instance);
193
+ if (isIssue(parsed))
194
+ return { ok: false, issue: parsed };
195
+ const definition = definitionOf(theme, parsed.type);
196
+ if (definition === undefined)
197
+ return refuse("unknown-type", `"${parsed.type}" is not in the theme catalog`);
198
+ if (definition.capabilities?.insertable === false) {
199
+ return refuse("not-insertable", `"${parsed.type}" is not inserted by editors`);
200
+ }
201
+ const next = structuredClone(page);
202
+ const list = targetList(next, placement);
203
+ if (list === null)
204
+ return refuse("unknown-instance", `no instance "${placement.parent}" in the document`);
205
+ const position = indexIssue(placement.index, list.length);
206
+ if (position !== null)
207
+ return position;
208
+ const slotProblem = placementIssue(theme, next, parsed.type, placement, list.length);
209
+ if (slotProblem !== null)
210
+ return slotProblem;
211
+ const issue = treeIssue(theme, parsed, next.surface, documentIds(page), "instance");
212
+ if (issue !== null)
213
+ return { ok: false, issue };
214
+ list.splice(placement.index ?? list.length, 0, structuredClone(parsed));
215
+ return { ok: true, page: next };
216
+ }
217
+ /** Remove an instance and everything inside it. */
218
+ export function removeInstance(page, id) {
219
+ const next = structuredClone(page);
220
+ const location = findInstance(next, id);
221
+ if (location === null)
222
+ return refuse("unknown-instance", `no instance "${id}" in the document`);
223
+ const list = location.parent === null
224
+ ? next.sections
225
+ : targetList(next, { parent: location.parent, slot: location.slot ?? "default" });
226
+ if (list === null)
227
+ return refuse("unknown-instance", `no instance "${location.parent}" in the document`);
228
+ list.splice(list.findIndex((candidate) => candidate.id === id), 1);
229
+ return { ok: true, page: next };
230
+ }
231
+ /**
232
+ * Move an instance (with everything inside it) to a placement. `index` counts
233
+ * positions in the destination after the instance has left its old place.
234
+ */
235
+ export function moveInstance(theme, page, id, placement = {}) {
236
+ const location = findInstance(page, id);
237
+ if (location === null)
238
+ return refuse("unknown-instance", `no instance "${id}" in the document`);
239
+ if (placement.parent !== undefined) {
240
+ if (placement.parent === id)
241
+ return refuse("cycle", "an instance cannot be moved into itself");
242
+ for (const inner of walk([location.instance], null, null)) {
243
+ if (inner.instance.id === placement.parent)
244
+ return refuse("cycle", `"${placement.parent}" is inside "${id}"`);
245
+ }
246
+ }
247
+ const removed = removeInstance(page, id);
248
+ if (!removed.ok)
249
+ return removed;
250
+ const next = removed.page;
251
+ const list = targetList(next, placement);
252
+ if (list === null)
253
+ return refuse("unknown-instance", `no instance "${placement.parent}" in the document`);
254
+ const position = indexIssue(placement.index, list.length);
255
+ if (position !== null)
256
+ return position;
257
+ const slotProblem = placementIssue(theme, next, location.instance.type, placement, list.length);
258
+ if (slotProblem !== null)
259
+ return slotProblem;
260
+ const surfaces = definitionOf(theme, location.instance.type)?.capabilities?.surfaces;
261
+ if (surfaces !== undefined && !surfaces.some((entry) => surfaceCovers(entry, next.surface))) {
262
+ return refuse("not-available", `"${location.instance.type}" cannot be placed on "${next.surface}"`);
263
+ }
264
+ list.splice(placement.index ?? list.length, 0, structuredClone(location.instance));
265
+ return { ok: true, page: next };
266
+ }
267
+ /**
268
+ * Copy an instance and everything inside it right after the original, every
269
+ * copied instance with an id from `newId`. List items keep their ids: they
270
+ * are unique within their list, not the document.
271
+ */
272
+ export function duplicateInstance(theme, page, id, newId) {
273
+ const location = findInstance(page, id);
274
+ if (location === null)
275
+ return refuse("unknown-instance", `no instance "${id}" in the document`);
276
+ const copy = structuredClone(location.instance);
277
+ for (const inner of walk([copy], null, null))
278
+ inner.instance.id = newId();
279
+ const placement = location.parent === null
280
+ ? { index: location.index + 1 }
281
+ : { parent: location.parent, slot: location.slot ?? "default", index: location.index + 1 };
282
+ const next = structuredClone(page);
283
+ const list = targetList(next, placement);
284
+ if (list === null)
285
+ return refuse("unknown-instance", `no instance "${location.parent}" in the document`);
286
+ const slotProblem = placementIssue(theme, next, copy.type, placement, list.length);
287
+ if (slotProblem !== null)
288
+ return slotProblem;
289
+ const issue = treeIssue(theme, copy, next.surface, documentIds(page), "instance");
290
+ if (issue !== null)
291
+ return { ok: false, issue };
292
+ list.splice(location.index + 1, 0, copy);
293
+ return { ok: true, page: next };
294
+ }
295
+ /**
296
+ * Change an instance's props: each value is checked by its field;
297
+ * `undefined` removes the stored value, so the field falls back to its
298
+ * content-pack text or default.
299
+ */
300
+ export function setInstanceProps(theme, page, id, changes) {
301
+ const next = structuredClone(page);
302
+ const location = findInstance(next, id);
303
+ if (location === null)
304
+ return refuse("unknown-instance", `no instance "${id}" in the document`);
305
+ const instance = location.instance;
306
+ const definition = definitionOf(theme, instance.type);
307
+ if (definition === undefined)
308
+ return refuse("unknown-type", `"${instance.type}" is not in the theme catalog`);
309
+ const fields = new Map((definition.fields ?? []).map((field) => [field.key, field]));
310
+ const props = { ...(instance.props ?? {}) };
311
+ for (const [key, value] of Object.entries(changes)) {
312
+ const field = fields.get(key);
313
+ if (field === undefined)
314
+ return refuse("invalid-props", `"${key}" is not a declared field of "${instance.type}"`, `props.${key}`);
315
+ if (value === undefined) {
316
+ if (field.required === true && !hasFallback(field)) {
317
+ return refuse("invalid-props", `required field "${key}" needs a value`, `props.${key}`);
318
+ }
319
+ delete props[key];
320
+ continue;
321
+ }
322
+ const checked = checkStoredValue(field, value);
323
+ if (!checked.ok)
324
+ return refuse("invalid-props", `"${key}" ${checked.message}`, `props.${key}`);
325
+ props[key] = value;
326
+ }
327
+ if (Object.keys(props).length > 0)
328
+ instance.props = props;
329
+ else
330
+ delete instance.props;
331
+ return { ok: true, page: next };
332
+ }
333
+ /** Switch an instance off (kept, not rendered) or back on. */
334
+ export function setInstanceHidden(page, id, hidden) {
335
+ const next = structuredClone(page);
336
+ const location = findInstance(next, id);
337
+ if (location === null)
338
+ return refuse("unknown-instance", `no instance "${id}" in the document`);
339
+ if (hidden)
340
+ location.instance.hidden = true;
341
+ else
342
+ delete location.instance.hidden;
343
+ return { ok: true, page: next };
344
+ }
345
+ /**
346
+ * Everything a writer must fix before saving a document for this theme —
347
+ * the strict counterpart of `validatePageData`: an envelope for one of the
348
+ * theme's surfaces when it declares any, and every section a tree the
349
+ * operations would have built. An empty list means the document may be saved.
350
+ */
351
+ export function checkPageData(theme, input) {
352
+ if (!isRecord(input) || input.version !== 1 || typeof input.surface !== "string" || !Array.isArray(input.sections)) {
353
+ return [{ path: "", code: "invalid-document", message: "a page document is { version: 1, surface, sections }" }];
354
+ }
355
+ const issues = [];
356
+ const surface = input.surface;
357
+ const declared = theme.surfaces ?? [];
358
+ if (declared.length > 0 && !declared.some((spec) => spec.key === surface)) {
359
+ issues.push({ path: "surface", code: "invalid-document", message: `"${surface}" is not a surface this theme declares` });
360
+ }
361
+ if (input.theme !== undefined && !(isRecord(input.theme) && input.theme.key === theme.key && typeof input.theme.version === "string" && input.theme.version.length > 0)) {
362
+ issues.push({ path: "theme", code: "invalid-document", message: `\`theme\` must name this theme: { key: "${theme.key}", version }` });
363
+ }
364
+ const taken = new Set();
365
+ input.sections.forEach((section, index) => {
366
+ const path = `sections[${index}]`;
367
+ const parsed = parseInstance(section);
368
+ if (isIssue(parsed)) {
369
+ issues.push({ ...parsed, path });
370
+ return;
371
+ }
372
+ const issue = treeIssue(theme, parsed, surface, taken, path);
373
+ if (issue !== null)
374
+ issues.push(issue);
375
+ });
376
+ return issues;
377
+ }
package/dist/page-data.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * document itself is not rewritten by `validatePageData`.
9
9
  */
10
10
  import { resolveInstance } from "./instances.js";
11
- import { ComponentInstanceSchema, SURFACE_KEY_PATTERN } from "./schemas.js";
11
+ import { ComponentInstanceSchema, SURFACE_KEY_PATTERN, THEME_KEY_PATTERN } from "./schemas.js";
12
12
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
13
13
  const envelopeFailure = (message) => ({
14
14
  page: null,
@@ -45,12 +45,29 @@ export function parsePageData(input) {
45
45
  // name a prototype.
46
46
  const page = { version: 1, surface, sections };
47
47
  for (const key of Object.keys(input)) {
48
- if (key === "version" || key === "surface" || key === "sections")
48
+ if (key === "version" || key === "surface" || key === "sections" || key === "theme")
49
49
  continue;
50
50
  if (key === "__proto__")
51
51
  continue;
52
52
  page[key] = input[key];
53
53
  }
54
+ if (input.theme !== undefined) {
55
+ const theme = input.theme;
56
+ if (isRecord(theme) &&
57
+ typeof theme.key === "string" &&
58
+ THEME_KEY_PATTERN.test(theme.key) &&
59
+ typeof theme.version === "string" &&
60
+ theme.version.length > 0) {
61
+ page.theme = { key: theme.key, version: theme.version };
62
+ }
63
+ else {
64
+ issues.push({
65
+ path: "theme",
66
+ code: "invalid-provenance",
67
+ message: "`theme` must be { key, version } naming the theme that wrote the document; ignored",
68
+ });
69
+ }
70
+ }
54
71
  return { page, issues };
55
72
  }
56
73
  /**
@@ -64,6 +81,13 @@ export function validatePageData(theme, input, scope = {}) {
64
81
  return parsed;
65
82
  }
66
83
  const issues = [...parsed.issues];
84
+ if (parsed.page.theme !== undefined && parsed.page.theme.key !== theme.key) {
85
+ issues.push({
86
+ path: "theme.key",
87
+ code: "theme-mismatch",
88
+ message: `the document was written for theme "${parsed.page.theme.key}", not "${theme.key}"; its sections were composed from another catalog`,
89
+ });
90
+ }
67
91
  const walk = (instance, path) => {
68
92
  const definition = Object.hasOwn(theme.components, instance.type)
69
93
  ? theme.components[instance.type]
@@ -126,6 +150,9 @@ function pruneDuplicateIds(instance, path, seenIds, issues) {
126
150
  if (instance.props !== undefined) {
127
151
  pruned.props = instance.props;
128
152
  }
153
+ if (instance.hidden !== undefined) {
154
+ pruned.hidden = instance.hidden;
155
+ }
129
156
  if (instance.children !== undefined) {
130
157
  const children = [];
131
158
  instance.children.forEach((child, index) => {
@@ -0,0 +1,28 @@
1
+ export interface PreviewBridgeOptions {
2
+ /** The editor's origin: the verified token's `editor` claim. */
3
+ editorOrigin: string;
4
+ /** The verified token, posted back with every re-render. */
5
+ token: string;
6
+ /** The theme's preview route; `THEME_PREVIEW_PATH` by default. */
7
+ endpoint?: string;
8
+ /** The window to run in; the page's own by default. */
9
+ window?: Window;
10
+ }
11
+ export interface PreviewBridge {
12
+ /** Removes every listener; the page keeps what it last rendered. */
13
+ stop(): void;
14
+ }
15
+ /**
16
+ * Dispatched on `document` for each section the bridge swapped in, with the
17
+ * instance id as `detail.id`: a section with browser behavior binds it again.
18
+ * When the whole page was replaced instead, the bridge dispatches Astro's own
19
+ * `astro:page-load`, as Astro's router does after it swaps a page.
20
+ */
21
+ export declare const PREVIEW_SECTION_LOAD_EVENT = "wocart:section-load";
22
+ /**
23
+ * Starts the bridge on a preview page. It posts `ready` with every marked
24
+ * instance, `layout` as the page scrolls or resizes, `hover` as the merchant
25
+ * points, `select` as they click (in `edit` mode, where links and forms do
26
+ * nothing), and `rendered` after each draft it swaps in.
27
+ */
28
+ export declare function startPreviewBridge(options: PreviewBridgeOptions): PreviewBridge;