@wocart/theme 1.5.0 → 2.0.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.
@@ -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
- for (const [name, children] of slotLists(instance))
45
- yield* walk(children, instance.id, name);
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
- /** Can `type` go into the placement's slot, given what is already there? */
90
- function placementIssue(theme, page, type, placement, occupied) {
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
- return null;
153
+ if (!placementAllows(definition?.placement, "section"))
154
+ return refuse("not-placeable", `"${type}" is placed only inside another component`);
95
155
  }
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}"`);
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
- if (slot.multiple === false && occupied >= 1) {
110
- return refuse("slot-full", `slot "${name}" of "${definition.type}" holds at most one instance`);
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, every component available on the surface, and ids unique among
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
- for (const [name, list, listPath] of slotLists(node)) {
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. 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.
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, parsed.type, placement, list.length);
338
+ const slotProblem = placementIssue(theme, next, tree.type, placement, list);
209
339
  if (slotProblem !== null)
210
340
  return slotProblem;
211
- const issue = treeIssue(theme, parsed, next.surface, documentIds(page), "instance");
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, structuredClone(parsed));
347
+ list.splice(placement.index ?? list.length, 0, tree);
215
348
  return { ok: true, page: next };
216
349
  }
217
- /** Remove an instance and everything inside it. */
218
- export function removeInstance(page, id) {
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 removed = removeInstance(page, id);
248
- if (!removed.ok)
249
- return removed;
250
- const next = removed.page;
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
- const slotProblem = placementIssue(theme, next, location.instance.type, placement, list.length);
258
- if (slotProblem !== null)
259
- return slotProblem;
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.length);
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 its field;
297
- * `undefined` removes the stored value, so the field falls back to its
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
- /** Switch an instance off (kept, not rendered) or back on. */
334
- export function setInstanceHidden(page, id, hidden) {
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, and every section a tree the
349
- * operations would have built. An empty list means the document may be saved.
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 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` });
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
  }