@wocart/theme 1.6.0 → 2.1.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 +107 -0
- package/README.md +211 -9
- package/dist/astro/Block.astro +29 -0
- package/dist/astro/Blocks.astro +23 -0
- package/dist/astro/InstanceView.astro +34 -10
- package/dist/astro/ThemeStyles.astro +34 -0
- package/dist/astro/index.d.ts +49 -1
- package/dist/astro/index.js +43 -5
- package/dist/blocks.d.ts +61 -0
- package/dist/blocks.js +144 -0
- package/dist/conditions.d.ts +21 -0
- package/dist/conditions.js +65 -0
- package/dist/definitions.js +399 -29
- package/dist/index.d.ts +6 -1
- package/dist/index.js +4 -0
- package/dist/instances.d.ts +4 -0
- package/dist/instances.js +72 -4
- package/dist/manifest-diff.js +95 -2
- package/dist/manifest.d.ts +245 -13
- package/dist/manifest.js +64 -1
- package/dist/operations.d.ts +39 -13
- package/dist/operations.js +253 -55
- package/dist/page-data.js +63 -27
- package/dist/preview-bridge.js +10 -7
- package/dist/preview-protocol.d.ts +8 -3
- package/dist/rich-text.d.ts +2 -0
- package/dist/rich-text.js +4 -0
- package/dist/schemas.d.ts +2 -1
- package/dist/schemas.js +3 -1
- package/dist/settings.js +20 -1
- package/dist/style.d.ts +45 -0
- package/dist/style.js +248 -0
- package/dist/surface-document.d.ts +49 -0
- package/dist/surface-document.js +162 -0
- package/dist/types.d.ts +217 -7
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -1
package/dist/operations.js
CHANGED
|
@@ -7,10 +7,19 @@
|
|
|
7
7
|
* build is one `validatePageData` reads without a report, and that the
|
|
8
8
|
* renderer renders as the writer saw it. How an editor lets a merchant drag,
|
|
9
9
|
* select or undo is the editor's own.
|
|
10
|
+
*
|
|
11
|
+
* Blocks: a fixed block is never removed, moved, duplicated or
|
|
12
|
+
* hidden; a required block never drops below its slot's minimum; a component
|
|
13
|
+
* goes only where its `placement` allows; slots, surfaces, documents and
|
|
14
|
+
* nesting stay within their caps. Operations on a document assume it was
|
|
15
|
+
* completed (`completePage`) when it was opened.
|
|
10
16
|
*/
|
|
17
|
+
import { THEME_LIMITS, composesSurface, placementAllows, slotCap, surfaceRules } from "./blocks.js";
|
|
11
18
|
import { surfaceCovers } from "./composition.js";
|
|
12
19
|
import { checkStoredValue, hasFallback, ownValue } from "./instances.js";
|
|
13
20
|
import { ComponentInstanceSchema } from "./schemas.js";
|
|
21
|
+
import { styleSizeProblem } from "./style.js";
|
|
22
|
+
import { completeInstance } from "./surface-document.js";
|
|
14
23
|
/**
|
|
15
24
|
* A new instance id: `cmp_` and 16 random hex digits. Operations refuse an id
|
|
16
25
|
* the document already holds, so a collision is reported, never written.
|
|
@@ -20,6 +29,7 @@ export function newInstanceId() {
|
|
|
20
29
|
return `cmp_${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
21
30
|
}
|
|
22
31
|
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
32
|
+
const isTheme = (value) => isRecord(value) && isRecord(value.components);
|
|
23
33
|
const definitionOf = (theme, type) => Object.hasOwn(theme.components, type) ? theme.components[type] : undefined;
|
|
24
34
|
const refuse = (code, message, path = "") => ({
|
|
25
35
|
ok: false,
|
|
@@ -37,17 +47,24 @@ function slotLists(node) {
|
|
|
37
47
|
}
|
|
38
48
|
return lists;
|
|
39
49
|
}
|
|
40
|
-
/** Every instance with its location, depth first. */
|
|
41
|
-
function* walk(list, parent, slot) {
|
|
50
|
+
/** Every instance with its location, depth first; fixed blocks after their owner's slots. */
|
|
51
|
+
function* walk(list, parent, slot, depth) {
|
|
42
52
|
for (const [index, instance] of list.entries()) {
|
|
43
|
-
yield { instance, parent, slot, index };
|
|
44
|
-
|
|
45
|
-
|
|
53
|
+
yield { instance, parent, slot, index, depth };
|
|
54
|
+
yield* walkInside(instance, depth);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function* walkInside(instance, depth) {
|
|
58
|
+
for (const [name, children] of slotLists(instance))
|
|
59
|
+
yield* walk(children, instance.id, name, depth + 1);
|
|
60
|
+
for (const [name, block] of Object.entries(instance.fixed ?? {})) {
|
|
61
|
+
yield { instance: block, parent: instance.id, slot: null, index: 0, fixed: name, depth: depth + 1 };
|
|
62
|
+
yield* walkInside(block, depth + 1);
|
|
46
63
|
}
|
|
47
64
|
}
|
|
48
65
|
/** Where `id` is in the document, or `null`. */
|
|
49
66
|
export function findInstance(page, id) {
|
|
50
|
-
for (const location of walk(page.sections, null, null)) {
|
|
67
|
+
for (const location of walk(page.sections, null, null, 0)) {
|
|
51
68
|
if (location.instance.id === id)
|
|
52
69
|
return location;
|
|
53
70
|
}
|
|
@@ -55,10 +72,29 @@ export function findInstance(page, id) {
|
|
|
55
72
|
}
|
|
56
73
|
function documentIds(page) {
|
|
57
74
|
const ids = new Set();
|
|
58
|
-
for (const { instance } of walk(page.sections, null, null))
|
|
75
|
+
for (const { instance } of walk(page.sections, null, null, 0))
|
|
59
76
|
ids.add(instance.id);
|
|
60
77
|
return ids;
|
|
61
78
|
}
|
|
79
|
+
/** Levels an instance tree reaches below itself (a leaf: 0). */
|
|
80
|
+
function height(node) {
|
|
81
|
+
let deepest = 0;
|
|
82
|
+
for (const [, list] of slotLists(node))
|
|
83
|
+
for (const child of list)
|
|
84
|
+
deepest = Math.max(deepest, 1 + height(child));
|
|
85
|
+
for (const block of Object.values(node.fixed ?? {}))
|
|
86
|
+
deepest = Math.max(deepest, 1 + height(block));
|
|
87
|
+
return deepest;
|
|
88
|
+
}
|
|
89
|
+
const countTree = (node) => {
|
|
90
|
+
let count = 1;
|
|
91
|
+
for (const [, list] of slotLists(node))
|
|
92
|
+
for (const child of list)
|
|
93
|
+
count += countTree(child);
|
|
94
|
+
for (const block of Object.values(node.fixed ?? {}))
|
|
95
|
+
count += countTree(block);
|
|
96
|
+
return count;
|
|
97
|
+
};
|
|
62
98
|
/**
|
|
63
99
|
* The editable list a placement names, in a copy of the document: the sections,
|
|
64
100
|
* or a parent's slot. The default slot is normalized to `children` (the order
|
|
@@ -86,35 +122,83 @@ function targetList(page, placement) {
|
|
|
86
122
|
parent.slots[slot] ??= [];
|
|
87
123
|
return parent.slots[slot];
|
|
88
124
|
}
|
|
89
|
-
|
|
90
|
-
|
|
125
|
+
function listRules(theme, page, parentId, slotName) {
|
|
126
|
+
if (parentId === null) {
|
|
127
|
+
const rules = surfaceRules(theme, page.surface);
|
|
128
|
+
return { name: "sections", cap: slotCap(rules, THEME_LIMITS.sections), required: rules.required ?? [], slot: null };
|
|
129
|
+
}
|
|
130
|
+
const parent = findInstance(page, parentId);
|
|
131
|
+
const definition = parent === null ? undefined : definitionOf(theme, parent.instance.type);
|
|
132
|
+
const slot = (definition?.slots ?? []).find((candidate) => candidate.name === (slotName ?? "default"));
|
|
133
|
+
if (slot === undefined)
|
|
134
|
+
return null;
|
|
135
|
+
return { name: slot.name, cap: slotCap(slot, THEME_LIMITS.slotInstances), required: slot.required ?? [], slot };
|
|
136
|
+
}
|
|
137
|
+
const shownOfType = (list, type) => list.filter((instance) => instance.type === type && instance.hidden !== true).length;
|
|
138
|
+
/** Whether taking `instance` out of view in its list would leave the list short of a required type. */
|
|
139
|
+
function leavesRequiredShort(rules, list, instance) {
|
|
140
|
+
if (rules === null || instance.hidden === true)
|
|
141
|
+
return null;
|
|
142
|
+
const entry = rules.required.find((candidate) => candidate.type === instance.type);
|
|
143
|
+
if (entry === undefined)
|
|
144
|
+
return null;
|
|
145
|
+
return shownOfType(list, instance.type) - 1 < (entry.min ?? 1) ? entry : null;
|
|
146
|
+
}
|
|
147
|
+
/** Can `type` go into the placement's list, given what is already there? */
|
|
148
|
+
function placementIssue(theme, page, type, placement, list) {
|
|
149
|
+
const definition = definitionOf(theme, type);
|
|
91
150
|
if (placement.parent === undefined) {
|
|
92
151
|
if (placement.slot !== undefined)
|
|
93
152
|
return refuse("unknown-slot", "the top-level sections have no slots");
|
|
94
|
-
|
|
153
|
+
if (!placementAllows(definition?.placement, "section"))
|
|
154
|
+
return refuse("not-placeable", `"${type}" is placed only inside another component`);
|
|
95
155
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
156
|
+
else {
|
|
157
|
+
const location = findInstance(page, placement.parent);
|
|
158
|
+
if (location === null)
|
|
159
|
+
return refuse("unknown-instance", `no instance "${placement.parent}" in the document`);
|
|
160
|
+
const parentDefinition = definitionOf(theme, location.instance.type);
|
|
161
|
+
if (parentDefinition === undefined)
|
|
162
|
+
return refuse("unknown-type", `"${location.instance.type}" is not in the theme catalog`);
|
|
163
|
+
const name = placement.slot ?? "default";
|
|
164
|
+
const slot = (parentDefinition.slots ?? []).find((candidate) => candidate.name === name);
|
|
165
|
+
if (slot === undefined)
|
|
166
|
+
return refuse("unknown-slot", `"${parentDefinition.type}" declares no slot "${name}"`);
|
|
167
|
+
if (slot.allow !== undefined && !slot.allow.includes(type)) {
|
|
168
|
+
return refuse("not-allowed", `"${type}" is not allowed in slot "${name}" of "${parentDefinition.type}"`);
|
|
169
|
+
}
|
|
170
|
+
if (slot.multiple === false && list.length >= 1) {
|
|
171
|
+
return refuse("slot-full", `slot "${name}" of "${parentDefinition.type}" holds at most one instance`);
|
|
172
|
+
}
|
|
173
|
+
if (!placementAllows(definition?.placement, "block"))
|
|
174
|
+
return refuse("not-placeable", `"${type}" is placed only as a section`);
|
|
108
175
|
}
|
|
109
|
-
|
|
110
|
-
|
|
176
|
+
const rules = listRules(theme, page, placement.parent ?? null, placement.slot ?? null);
|
|
177
|
+
if (rules !== null && list.length >= rules.cap) {
|
|
178
|
+
return refuse("limit-exceeded", `${rules.name === "sections" ? "the surface" : `slot "${rules.name}"`} holds at most ${rules.cap}`);
|
|
179
|
+
}
|
|
180
|
+
const entry = rules?.required.find((candidate) => candidate.type === type);
|
|
181
|
+
if (entry?.max !== undefined && list.filter((instance) => instance.type === type).length >= entry.max) {
|
|
182
|
+
return refuse("required-block", `"${type}" appears at most ${entry.max} time(s) in ${rules.name === "sections" ? "the surface" : `slot "${rules.name}"`}`);
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
/** Whether a tree may sit at `depth` (its root's level), and the document may grow by it. */
|
|
187
|
+
function sizeIssue(page, tree, depth) {
|
|
188
|
+
if (depth + height(tree) > THEME_LIMITS.depth) {
|
|
189
|
+
return refuse("limit-exceeded", `nesting goes at most ${THEME_LIMITS.depth} levels below a section`);
|
|
190
|
+
}
|
|
191
|
+
if (documentIds(page).size + countTree(tree) > THEME_LIMITS.documentInstances) {
|
|
192
|
+
return refuse("limit-exceeded", `a document holds at most ${THEME_LIMITS.documentInstances} instances`);
|
|
111
193
|
}
|
|
112
194
|
return null;
|
|
113
195
|
}
|
|
196
|
+
const placementDepth = (page, placement) => placement.parent === undefined ? 0 : (findInstance(page, placement.parent)?.depth ?? 0) + 1;
|
|
114
197
|
/**
|
|
115
198
|
* An instance tree a writer may store, on this surface: known types, declared
|
|
116
199
|
* props with values their fields accept, required fields filled, slots used as
|
|
117
|
-
* declared
|
|
200
|
+
* declared with their required types present, fixed blocks declared and of
|
|
201
|
+
* their type, every component available on the surface, and ids unique among
|
|
118
202
|
* themselves and `taken`. Returns the first problem.
|
|
119
203
|
*/
|
|
120
204
|
function treeIssue(theme, node, surface, taken, path) {
|
|
@@ -137,6 +221,9 @@ function treeIssue(theme, node, surface, taken, path) {
|
|
|
137
221
|
const checked = checkStoredValue(field, value);
|
|
138
222
|
if (!checked.ok)
|
|
139
223
|
return { path: `${path}.props.${key}`, code: "invalid-props", message: `"${key}" ${checked.message}` };
|
|
224
|
+
const unsized = field.kind === "style" ? styleSizeProblem(theme.style, value) : null;
|
|
225
|
+
if (unsized !== null)
|
|
226
|
+
return { path: `${path}.props.${key}`, code: "invalid-props", message: `"${key}" ${unsized}` };
|
|
140
227
|
}
|
|
141
228
|
for (const field of fields.values()) {
|
|
142
229
|
if (field.required === true && ownValue(props, field.key) === undefined && !hasFallback(field)) {
|
|
@@ -144,7 +231,8 @@ function treeIssue(theme, node, surface, taken, path) {
|
|
|
144
231
|
}
|
|
145
232
|
}
|
|
146
233
|
const slots = new Map((definition.slots ?? []).map((slot) => [slot.name, slot]));
|
|
147
|
-
|
|
234
|
+
const lists = new Map(slotLists(node).map(([name, list, listPath]) => [name, { list, listPath }]));
|
|
235
|
+
for (const [name, { list, listPath }] of lists) {
|
|
148
236
|
const slot = slots.get(name);
|
|
149
237
|
if (slot === undefined) {
|
|
150
238
|
if (list.length === 0)
|
|
@@ -154,16 +242,54 @@ function treeIssue(theme, node, surface, taken, path) {
|
|
|
154
242
|
if (slot.multiple === false && list.length > 1) {
|
|
155
243
|
return { path: `${path}.${listPath}`, code: "slot-full", message: `slot "${name}" of "${node.type}" holds at most one instance` };
|
|
156
244
|
}
|
|
245
|
+
const cap = slotCap(slot, THEME_LIMITS.slotInstances);
|
|
246
|
+
if (list.length > cap) {
|
|
247
|
+
return { path: `${path}.${listPath}`, code: "limit-exceeded", message: `slot "${name}" of "${node.type}" holds at most ${cap}` };
|
|
248
|
+
}
|
|
157
249
|
for (const [index, child] of list.entries()) {
|
|
158
250
|
const childPath = `${path}.${listPath}[${index}]`;
|
|
159
251
|
if (slot.allow !== undefined && !slot.allow.includes(child.type)) {
|
|
160
252
|
return { path: childPath, code: "not-allowed", message: `"${child.type}" is not allowed in slot "${name}" of "${node.type}"` };
|
|
161
253
|
}
|
|
254
|
+
if (!placementAllows(definitionOf(theme, child.type)?.placement, "block")) {
|
|
255
|
+
return { path: childPath, code: "not-placeable", message: `"${child.type}" is placed only as a section` };
|
|
256
|
+
}
|
|
162
257
|
const issue = treeIssue(theme, child, surface, taken, childPath);
|
|
163
258
|
if (issue !== null)
|
|
164
259
|
return issue;
|
|
165
260
|
}
|
|
166
261
|
}
|
|
262
|
+
for (const slot of slots.values()) {
|
|
263
|
+
const listPath = slot.name === "default" ? "children" : `slots.${slot.name}`;
|
|
264
|
+
const issue = requiredCountIssue(slot.required, lists.get(slot.name)?.list ?? [], `${path}.${listPath}`, `slot "${slot.name}" of "${node.type}"`);
|
|
265
|
+
if (issue !== null)
|
|
266
|
+
return issue;
|
|
267
|
+
}
|
|
268
|
+
const declared = new Map((definition.fixed ?? []).map((block) => [block.name, block]));
|
|
269
|
+
for (const [name, block] of Object.entries(node.fixed ?? {})) {
|
|
270
|
+
const blockPath = `${path}.fixed.${name}`;
|
|
271
|
+
const expected = declared.get(name);
|
|
272
|
+
if (expected === undefined)
|
|
273
|
+
return { path: blockPath, code: "fixed-block", message: `"${node.type}" places no fixed block "${name}"` };
|
|
274
|
+
if (block.type !== expected.type)
|
|
275
|
+
return { path: `${blockPath}.type`, code: "fixed-block", message: `the fixed block "${name}" is a "${expected.type}", not a "${block.type}"` };
|
|
276
|
+
if (block.hidden === true)
|
|
277
|
+
return { path: `${blockPath}.hidden`, code: "fixed-block", message: `the fixed block "${name}" cannot be hidden` };
|
|
278
|
+
const issue = treeIssue(theme, block, surface, taken, blockPath);
|
|
279
|
+
if (issue !== null)
|
|
280
|
+
return issue;
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
function requiredCountIssue(required, list, path, where) {
|
|
285
|
+
for (const { type, min = 1, max } of required ?? []) {
|
|
286
|
+
const shown = shownOfType(list, type);
|
|
287
|
+
if (shown < min)
|
|
288
|
+
return { path, code: "required-block", message: `${where} must show at least ${min} "${type}"` };
|
|
289
|
+
if (max !== undefined && list.filter((instance) => instance.type === type).length > max) {
|
|
290
|
+
return { path, code: "required-block", message: `${where} holds at most ${max} "${type}"` };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
167
293
|
return null;
|
|
168
294
|
}
|
|
169
295
|
function parseInstance(instance) {
|
|
@@ -184,9 +310,11 @@ function indexIssue(index, length) {
|
|
|
184
310
|
}
|
|
185
311
|
/**
|
|
186
312
|
* Insert a new instance — from `createInstance` or `instantiatePreset` — at a
|
|
187
|
-
* placement
|
|
188
|
-
*
|
|
189
|
-
* already
|
|
313
|
+
* placement, completed with its fixed and required blocks. Refused when the
|
|
314
|
+
* component is not insertable, not available on the document's surface, not
|
|
315
|
+
* placeable there, when the list is full or already holds the most of its
|
|
316
|
+
* type, when the document would pass its caps, or when any id is already in
|
|
317
|
+
* the document.
|
|
190
318
|
*/
|
|
191
319
|
export function insertInstance(theme, page, instance, placement = {}) {
|
|
192
320
|
const parsed = parseInstance(instance);
|
|
@@ -198,6 +326,8 @@ export function insertInstance(theme, page, instance, placement = {}) {
|
|
|
198
326
|
if (definition.capabilities?.insertable === false) {
|
|
199
327
|
return refuse("not-insertable", `"${parsed.type}" is not inserted by editors`);
|
|
200
328
|
}
|
|
329
|
+
const existing = documentIds(page);
|
|
330
|
+
const tree = completeInstance(theme, parsed, new Set(existing));
|
|
201
331
|
const next = structuredClone(page);
|
|
202
332
|
const list = targetList(next, placement);
|
|
203
333
|
if (list === null)
|
|
@@ -205,58 +335,85 @@ export function insertInstance(theme, page, instance, placement = {}) {
|
|
|
205
335
|
const position = indexIssue(placement.index, list.length);
|
|
206
336
|
if (position !== null)
|
|
207
337
|
return position;
|
|
208
|
-
const slotProblem = placementIssue(theme, next,
|
|
338
|
+
const slotProblem = placementIssue(theme, next, tree.type, placement, list);
|
|
209
339
|
if (slotProblem !== null)
|
|
210
340
|
return slotProblem;
|
|
211
|
-
const
|
|
341
|
+
const size = sizeIssue(page, tree, placementDepth(page, placement));
|
|
342
|
+
if (size !== null)
|
|
343
|
+
return size;
|
|
344
|
+
const issue = treeIssue(theme, tree, next.surface, existing, "instance");
|
|
212
345
|
if (issue !== null)
|
|
213
346
|
return { ok: false, issue };
|
|
214
|
-
list.splice(placement.index ?? list.length, 0,
|
|
347
|
+
list.splice(placement.index ?? list.length, 0, tree);
|
|
215
348
|
return { ok: true, page: next };
|
|
216
349
|
}
|
|
217
|
-
|
|
218
|
-
|
|
350
|
+
export function removeInstance(...args) {
|
|
351
|
+
const [theme, page, id] = isTheme(args[0]) ? args : [null, args[0], args[1]];
|
|
219
352
|
const next = structuredClone(page);
|
|
220
353
|
const location = findInstance(next, id);
|
|
221
354
|
if (location === null)
|
|
222
355
|
return refuse("unknown-instance", `no instance "${id}" in the document`);
|
|
356
|
+
if (location.fixed !== undefined)
|
|
357
|
+
return refuse("fixed-block", `"${id}" is placed by its component's code and cannot be removed`);
|
|
223
358
|
const list = location.parent === null
|
|
224
359
|
? next.sections
|
|
225
360
|
: targetList(next, { parent: location.parent, slot: location.slot ?? "default" });
|
|
226
361
|
if (list === null)
|
|
227
362
|
return refuse("unknown-instance", `no instance "${location.parent}" in the document`);
|
|
363
|
+
if (theme !== null) {
|
|
364
|
+
const short = leavesRequiredShort(listRules(theme, next, location.parent, location.slot), list, location.instance);
|
|
365
|
+
if (short !== null)
|
|
366
|
+
return refuse("required-block", `"${location.instance.type}" is required here (at least ${short.min ?? 1})`);
|
|
367
|
+
}
|
|
228
368
|
list.splice(list.findIndex((candidate) => candidate.id === id), 1);
|
|
229
369
|
return { ok: true, page: next };
|
|
230
370
|
}
|
|
231
371
|
/**
|
|
232
372
|
* Move an instance (with everything inside it) to a placement. `index` counts
|
|
233
373
|
* positions in the destination after the instance has left its old place.
|
|
374
|
+
* A fixed block never moves; a required one moves only within its own list,
|
|
375
|
+
* unless enough of its type stay behind.
|
|
234
376
|
*/
|
|
235
377
|
export function moveInstance(theme, page, id, placement = {}) {
|
|
236
378
|
const location = findInstance(page, id);
|
|
237
379
|
if (location === null)
|
|
238
380
|
return refuse("unknown-instance", `no instance "${id}" in the document`);
|
|
381
|
+
if (location.fixed !== undefined)
|
|
382
|
+
return refuse("fixed-block", `"${id}" is placed by its component's code and cannot be moved`);
|
|
239
383
|
if (placement.parent !== undefined) {
|
|
240
384
|
if (placement.parent === id)
|
|
241
385
|
return refuse("cycle", "an instance cannot be moved into itself");
|
|
242
|
-
for (const inner of walk([location.instance], null, null)) {
|
|
386
|
+
for (const inner of walk([location.instance], null, null, 0)) {
|
|
243
387
|
if (inner.instance.id === placement.parent)
|
|
244
388
|
return refuse("cycle", `"${placement.parent}" is inside "${id}"`);
|
|
245
389
|
}
|
|
246
390
|
}
|
|
247
|
-
const
|
|
248
|
-
if (!
|
|
249
|
-
|
|
250
|
-
|
|
391
|
+
const sameList = (placement.parent ?? null) === location.parent && (location.parent === null || (placement.slot ?? "default") === (location.slot ?? "default"));
|
|
392
|
+
if (!sameList) {
|
|
393
|
+
const from = location.parent === null ? page.sections : (slotLists(findInstance(page, location.parent).instance).find(([name]) => name === (location.slot ?? "default"))?.[1] ?? []);
|
|
394
|
+
const short = leavesRequiredShort(listRules(theme, page, location.parent, location.slot), from, location.instance);
|
|
395
|
+
if (short !== null)
|
|
396
|
+
return refuse("required-block", `"${location.instance.type}" is required where it is (at least ${short.min ?? 1})`);
|
|
397
|
+
}
|
|
398
|
+
const next = structuredClone(page);
|
|
399
|
+
const origin = location.parent === null ? next.sections : targetList(next, { parent: location.parent, slot: location.slot ?? "default" });
|
|
400
|
+
if (origin === null)
|
|
401
|
+
return refuse("unknown-instance", `no instance "${location.parent}" in the document`);
|
|
402
|
+
origin.splice(origin.findIndex((candidate) => candidate.id === id), 1);
|
|
251
403
|
const list = targetList(next, placement);
|
|
252
404
|
if (list === null)
|
|
253
405
|
return refuse("unknown-instance", `no instance "${placement.parent}" in the document`);
|
|
254
406
|
const position = indexIssue(placement.index, list.length);
|
|
255
407
|
if (position !== null)
|
|
256
408
|
return position;
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
409
|
+
if (!sameList) {
|
|
410
|
+
const slotProblem = placementIssue(theme, next, location.instance.type, placement, list);
|
|
411
|
+
if (slotProblem !== null)
|
|
412
|
+
return slotProblem;
|
|
413
|
+
if (placementDepth(next, placement) + height(location.instance) > THEME_LIMITS.depth) {
|
|
414
|
+
return refuse("limit-exceeded", `nesting goes at most ${THEME_LIMITS.depth} levels below a section`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
260
417
|
const surfaces = definitionOf(theme, location.instance.type)?.capabilities?.surfaces;
|
|
261
418
|
if (surfaces !== undefined && !surfaces.some((entry) => surfaceCovers(entry, next.surface))) {
|
|
262
419
|
return refuse("not-available", `"${location.instance.type}" cannot be placed on "${next.surface}"`);
|
|
@@ -267,14 +424,17 @@ export function moveInstance(theme, page, id, placement = {}) {
|
|
|
267
424
|
/**
|
|
268
425
|
* Copy an instance and everything inside it right after the original, every
|
|
269
426
|
* copied instance with an id from `newId`. List items keep their ids: they
|
|
270
|
-
* are unique within their list, not the document.
|
|
427
|
+
* are unique within their list, not the document. A fixed block is never
|
|
428
|
+
* copied.
|
|
271
429
|
*/
|
|
272
430
|
export function duplicateInstance(theme, page, id, newId) {
|
|
273
431
|
const location = findInstance(page, id);
|
|
274
432
|
if (location === null)
|
|
275
433
|
return refuse("unknown-instance", `no instance "${id}" in the document`);
|
|
434
|
+
if (location.fixed !== undefined)
|
|
435
|
+
return refuse("fixed-block", `"${id}" is placed by its component's code and cannot be copied`);
|
|
276
436
|
const copy = structuredClone(location.instance);
|
|
277
|
-
for (const inner of walk([copy], null, null))
|
|
437
|
+
for (const inner of walk([copy], null, null, 0))
|
|
278
438
|
inner.instance.id = newId();
|
|
279
439
|
const placement = location.parent === null
|
|
280
440
|
? { index: location.index + 1 }
|
|
@@ -283,9 +443,12 @@ export function duplicateInstance(theme, page, id, newId) {
|
|
|
283
443
|
const list = targetList(next, placement);
|
|
284
444
|
if (list === null)
|
|
285
445
|
return refuse("unknown-instance", `no instance "${location.parent}" in the document`);
|
|
286
|
-
const slotProblem = placementIssue(theme, next, copy.type, placement, list
|
|
446
|
+
const slotProblem = placementIssue(theme, next, copy.type, placement, list);
|
|
287
447
|
if (slotProblem !== null)
|
|
288
448
|
return slotProblem;
|
|
449
|
+
const size = sizeIssue(page, copy, location.depth);
|
|
450
|
+
if (size !== null)
|
|
451
|
+
return size;
|
|
289
452
|
const issue = treeIssue(theme, copy, next.surface, documentIds(page), "instance");
|
|
290
453
|
if (issue !== null)
|
|
291
454
|
return { ok: false, issue };
|
|
@@ -293,9 +456,9 @@ export function duplicateInstance(theme, page, id, newId) {
|
|
|
293
456
|
return { ok: true, page: next };
|
|
294
457
|
}
|
|
295
458
|
/**
|
|
296
|
-
* Change an instance's props: each value is checked by
|
|
297
|
-
* `undefined` removes the stored value, so the field falls back to
|
|
298
|
-
* content-pack text or default.
|
|
459
|
+
* Change an instance's props — a fixed block's too: each value is checked by
|
|
460
|
+
* its field; `undefined` removes the stored value, so the field falls back to
|
|
461
|
+
* its content-pack text or default.
|
|
299
462
|
*/
|
|
300
463
|
export function setInstanceProps(theme, page, id, changes) {
|
|
301
464
|
const next = structuredClone(page);
|
|
@@ -322,6 +485,9 @@ export function setInstanceProps(theme, page, id, changes) {
|
|
|
322
485
|
const checked = checkStoredValue(field, value);
|
|
323
486
|
if (!checked.ok)
|
|
324
487
|
return refuse("invalid-props", `"${key}" ${checked.message}`, `props.${key}`);
|
|
488
|
+
const unsized = field.kind === "style" ? styleSizeProblem(theme.style, value) : null;
|
|
489
|
+
if (unsized !== null)
|
|
490
|
+
return refuse("invalid-props", `"${key}" ${unsized}`, `props.${key}`);
|
|
325
491
|
props[key] = value;
|
|
326
492
|
}
|
|
327
493
|
if (Object.keys(props).length > 0)
|
|
@@ -330,12 +496,22 @@ export function setInstanceProps(theme, page, id, changes) {
|
|
|
330
496
|
delete instance.props;
|
|
331
497
|
return { ok: true, page: next };
|
|
332
498
|
}
|
|
333
|
-
|
|
334
|
-
|
|
499
|
+
export function setInstanceHidden(...args) {
|
|
500
|
+
const [theme, page, id, hidden] = isTheme(args[0])
|
|
501
|
+
? args
|
|
502
|
+
: [null, args[0], args[1], args[2]];
|
|
335
503
|
const next = structuredClone(page);
|
|
336
504
|
const location = findInstance(next, id);
|
|
337
505
|
if (location === null)
|
|
338
506
|
return refuse("unknown-instance", `no instance "${id}" in the document`);
|
|
507
|
+
if (hidden && location.fixed !== undefined)
|
|
508
|
+
return refuse("fixed-block", `"${id}" is placed by its component's code and cannot be hidden`);
|
|
509
|
+
if (hidden && theme !== null) {
|
|
510
|
+
const list = location.parent === null ? next.sections : (slotLists(findInstance(next, location.parent).instance).find(([name]) => name === (location.slot ?? "default"))?.[1] ?? []);
|
|
511
|
+
const short = leavesRequiredShort(listRules(theme, next, location.parent, location.slot), list, location.instance);
|
|
512
|
+
if (short !== null)
|
|
513
|
+
return refuse("required-block", `"${location.instance.type}" is required here and cannot be hidden`);
|
|
514
|
+
}
|
|
339
515
|
if (hidden)
|
|
340
516
|
location.instance.hidden = true;
|
|
341
517
|
else
|
|
@@ -345,8 +521,9 @@ export function setInstanceHidden(page, id, hidden) {
|
|
|
345
521
|
/**
|
|
346
522
|
* Everything a writer must fix before saving a document for this theme —
|
|
347
523
|
* the strict counterpart of `validatePageData`: an envelope for one of the
|
|
348
|
-
* theme's surfaces when it declares any,
|
|
349
|
-
*
|
|
524
|
+
* theme's surfaces when it declares any, the surface's required sections
|
|
525
|
+
* shown and its cap kept, and every section a tree the operations would have
|
|
526
|
+
* built. An empty list means the document may be saved.
|
|
350
527
|
*/
|
|
351
528
|
export function checkPageData(theme, input) {
|
|
352
529
|
if (!isRecord(input) || input.version !== 1 || typeof input.surface !== "string" || !Array.isArray(input.sections)) {
|
|
@@ -354,14 +531,20 @@ export function checkPageData(theme, input) {
|
|
|
354
531
|
}
|
|
355
532
|
const issues = [];
|
|
356
533
|
const surface = input.surface;
|
|
357
|
-
const
|
|
358
|
-
if (
|
|
359
|
-
issues.push({ path: "surface", code: "invalid-document", message: `"${surface}" is not a surface this theme declares` });
|
|
534
|
+
const declares = (theme.surfaces ?? []).length + (theme.areas ?? []).length > 0;
|
|
535
|
+
if (declares && !composesSurface(theme, surface)) {
|
|
536
|
+
issues.push({ path: "surface", code: "invalid-document", message: `"${surface}" is not a surface or area this theme declares` });
|
|
360
537
|
}
|
|
361
538
|
if (input.theme !== undefined && !(isRecord(input.theme) && input.theme.key === theme.key && typeof input.theme.version === "string" && input.theme.version.length > 0)) {
|
|
362
539
|
issues.push({ path: "theme", code: "invalid-document", message: `\`theme\` must name this theme: { key: "${theme.key}", version }` });
|
|
363
540
|
}
|
|
541
|
+
const rules = surfaceRules(theme, surface);
|
|
542
|
+
const cap = slotCap(rules, THEME_LIMITS.sections);
|
|
543
|
+
if (input.sections.length > cap) {
|
|
544
|
+
issues.push({ path: "sections", code: "limit-exceeded", message: `the surface holds at most ${cap} sections` });
|
|
545
|
+
}
|
|
364
546
|
const taken = new Set();
|
|
547
|
+
const sections = [];
|
|
365
548
|
input.sections.forEach((section, index) => {
|
|
366
549
|
const path = `sections[${index}]`;
|
|
367
550
|
const parsed = parseInstance(section);
|
|
@@ -369,9 +552,24 @@ export function checkPageData(theme, input) {
|
|
|
369
552
|
issues.push({ ...parsed, path });
|
|
370
553
|
return;
|
|
371
554
|
}
|
|
555
|
+
sections.push(parsed);
|
|
556
|
+
if (!placementAllows(definitionOf(theme, parsed.type)?.placement, "section")) {
|
|
557
|
+
issues.push({ path, code: "not-placeable", message: `"${parsed.type}" is placed only inside another component` });
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (height(parsed) > THEME_LIMITS.depth) {
|
|
561
|
+
issues.push({ path, code: "limit-exceeded", message: `nesting goes at most ${THEME_LIMITS.depth} levels below a section` });
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
372
564
|
const issue = treeIssue(theme, parsed, surface, taken, path);
|
|
373
565
|
if (issue !== null)
|
|
374
566
|
issues.push(issue);
|
|
375
567
|
});
|
|
568
|
+
const required = requiredCountIssue(rules.required, sections, "sections", "the surface");
|
|
569
|
+
if (required !== null)
|
|
570
|
+
issues.push(required);
|
|
571
|
+
if (taken.size > THEME_LIMITS.documentInstances) {
|
|
572
|
+
issues.push({ path: "sections", code: "limit-exceeded", message: `a document holds at most ${THEME_LIMITS.documentInstances} instances` });
|
|
573
|
+
}
|
|
376
574
|
return issues;
|
|
377
575
|
}
|