@wocart/theme 1.6.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.
package/dist/instances.js CHANGED
@@ -8,7 +8,9 @@
8
8
  * → `content[contentKey]` → `field.default` → omit (the component's own
9
9
  * default applies last).
10
10
  */
11
+ import { THEME_LIMITS, completeRequired, resolveFixed, slotCap } from "./blocks.js";
11
12
  import { localize } from "./locales.js";
13
+ import { COLOR_SCHEME_ID_PATTERN, colorSchemesProblem, styleValueProblem } from "./style.js";
12
14
  import { isRichTextDocumentShape, richTextProblem } from "./rich-text.js";
13
15
  import { COLLECTION_HANDLE_PATTERN, COLOR_PATTERN, MEDIA_KEY_PATTERN, MENU_HANDLE_PATTERN, RESOURCE_HANDLE_PATTERN, isLocaleMap, isLocaleMapOf, isSafeLinkTarget, } from "./schemas.js";
14
16
  /**
@@ -49,9 +51,9 @@ export function resolveInstance(definition, instance, scope = {}) {
49
51
  const slotDefinitions = new Map((definition.slots ?? []).map((slot) => [slot.name, slot]));
50
52
  const fromChildren = instance.children ?? [];
51
53
  const fromDefaultSlot = instance.slots?.default ?? [];
54
+ const defaultSlot = slotDefinitions.get("default");
52
55
  let children = [];
53
56
  if (fromChildren.length + fromDefaultSlot.length > 0) {
54
- const defaultSlot = slotDefinitions.get("default");
55
57
  if (defaultSlot === undefined) {
56
58
  issues.push({
57
59
  path: "children",
@@ -67,6 +69,8 @@ export function resolveInstance(definition, instance, scope = {}) {
67
69
  children = applyMultiple(defaultSlot, accepted, "children", issues);
68
70
  }
69
71
  }
72
+ if (defaultSlot !== undefined)
73
+ children = completeSlot(defaultSlot, children, instance.id, "children", issues);
70
74
  const slots = {};
71
75
  for (const [name, list] of Object.entries(instance.slots ?? {})) {
72
76
  if (name === "default") {
@@ -82,9 +86,25 @@ export function resolveInstance(definition, instance, scope = {}) {
82
86
  continue;
83
87
  }
84
88
  const accepted = filterAllowed(slotDefinition, list, `slots.${name}`, issues);
85
- slots[name] = applyMultiple(slotDefinition, accepted, `slots.${name}`, issues);
89
+ slots[name] = completeSlot(slotDefinition, applyMultiple(slotDefinition, accepted, `slots.${name}`, issues), instance.id, `slots.${name}`, issues);
86
90
  }
87
- return { props, children, slots, issues };
91
+ for (const slotDefinition of definition.slots ?? []) {
92
+ const { name } = slotDefinition;
93
+ if (name === "default" || Object.hasOwn(slots, name) || (slotDefinition.required ?? []).length === 0)
94
+ continue;
95
+ slots[name] = completeSlot(slotDefinition, [], instance.id, `slots.${name}`, issues);
96
+ }
97
+ return { props, children, slots, fixed: resolveFixed(definition, instance, issues), issues };
98
+ }
99
+ /** A slot's list as it renders: at most its cap, then every required type present. */
100
+ function completeSlot(slot, list, ownerId, path, issues) {
101
+ const cap = slotCap(slot, THEME_LIMITS.slotInstances);
102
+ let kept = list;
103
+ if (list.length > cap) {
104
+ issues.push({ path, code: "limit-exceeded", message: `slot "${slot.name}" holds at most ${cap} instances; ${list.length - cap} dropped` });
105
+ kept = list.slice(0, cap);
106
+ }
107
+ return completeRequired(kept, slot.required, { id: ownerId, slot: slot.name }, path, issues);
88
108
  }
89
109
  /** Whether a field shows something with no stored value: a default, or content-pack text. */
90
110
  export function hasFallback(field) {
@@ -381,6 +401,37 @@ function mediaReferenceProblem(value, mode) {
381
401
  }
382
402
  return null;
383
403
  }
404
+ /** The most handles a `productList` or `collectionList` holds. */
405
+ export const HANDLE_LIST_MAX = 50;
406
+ /** Why `value` is not a list of distinct handles within `min` and `max`, or `null`. */
407
+ export function handleListProblem(kind, min, max, value) {
408
+ const noun = kind === "productList" ? "product handles" : "collection handles";
409
+ if (!Array.isArray(value))
410
+ return `must be a list of ${noun}`;
411
+ const cap = Math.min(max ?? HANDLE_LIST_MAX, HANDLE_LIST_MAX);
412
+ if (value.length > cap)
413
+ return `holds at most ${cap} ${noun}`;
414
+ if (min !== undefined && value.length < min)
415
+ return `needs at least ${min} ${noun}`;
416
+ const pattern = kind === "productList" ? RESOURCE_HANDLE_PATTERN : COLLECTION_HANDLE_PATTERN;
417
+ const seen = new Set();
418
+ for (const [index, handle] of value.entries()) {
419
+ if (typeof handle !== "string" || !pattern.test(handle))
420
+ return `item ${index} is not a ${kind === "productList" ? "product" : "collection"} handle`;
421
+ if (seen.has(handle))
422
+ return `item ${index} repeats "${handle}"`;
423
+ seen.add(handle);
424
+ }
425
+ return null;
426
+ }
427
+ /** Why a rich-text document is not one line — a single paragraph — or `null`. */
428
+ function inlineProblem(value) {
429
+ const content = isRecord(value) ? value.content : undefined;
430
+ if (!Array.isArray(content) || content.length > 1 || content.some((block) => !isRecord(block) || block.type !== "paragraph")) {
431
+ return "must be one line: a single paragraph, no headings or lists";
432
+ }
433
+ return null;
434
+ }
384
435
  /** On a grid of `step` from `base`, allowing for binary floating point. */
385
436
  export function onStepGrid(value, base, step) {
386
437
  const steps = (value - base) / step;
@@ -448,7 +499,12 @@ function checkKindValue(field, value, mode) {
448
499
  return problem === null ? { ok: true, value } : { ok: false, message: problem };
449
500
  }
450
501
  case "richtext": {
451
- const problem = richTextProblem(value, field.formats);
502
+ const problem = richTextProblem(value, field.formats) ?? (field.inline === true ? inlineProblem(value) : null);
503
+ return problem === null ? { ok: true, value } : { ok: false, message: problem };
504
+ }
505
+ case "productList":
506
+ case "collectionList": {
507
+ const problem = handleListProblem(field.kind, field.min, field.max, value);
452
508
  return problem === null ? { ok: true, value } : { ok: false, message: problem };
453
509
  }
454
510
  case "list":
@@ -460,6 +516,18 @@ function checkKindValue(field, value, mode) {
460
516
  ok: false,
461
517
  message: `must be one of ${field.options.map((option) => JSON.stringify(option)).join(", ")}`,
462
518
  };
519
+ case "style": {
520
+ const problem = styleValueProblem(field.panels, value);
521
+ return problem === null ? { ok: true, value } : { ok: false, message: problem };
522
+ }
523
+ case "colorScheme":
524
+ return typeof value === "string" && COLOR_SCHEME_ID_PATTERN.test(value)
525
+ ? { ok: true, value }
526
+ : { ok: false, message: "must be a color scheme id: lowercase letters, digits and dashes" };
527
+ case "colorSchemes": {
528
+ const problem = colorSchemesProblem(value);
529
+ return problem === null ? { ok: true, value } : { ok: false, message: problem };
530
+ }
463
531
  }
464
532
  }
465
533
  /** A slot's `allow` list keeps the permitted types and reports every other child. */
@@ -28,14 +28,35 @@ export function diffThemeManifests(previous, next) {
28
28
  const after = nextSurfaces.get(surface.key);
29
29
  if (after === undefined)
30
30
  breaking(`surfaces.${surface.key}`, `surface "${surface.key}" was removed`);
31
- else if (after.pageType !== surface.pageType) {
32
- breaking(`surfaces.${surface.key}.pageType`, `surface "${surface.key}" renders "${after.pageType ?? "no page type"}" instead of "${surface.pageType ?? "no page type"}"`);
31
+ else {
32
+ if (after.pageType !== surface.pageType) {
33
+ breaking(`surfaces.${surface.key}.pageType`, `surface "${surface.key}" renders "${after.pageType ?? "no page type"}" instead of "${surface.pageType ?? "no page type"}"`);
34
+ }
35
+ compareCaps(surface, after, `surfaces.${surface.key}`, "sections", breaking, additive);
33
36
  }
34
37
  }
35
38
  for (const surface of next.surfaces) {
36
39
  if (!previous.surfaces.some((before) => before.key === surface.key))
37
40
  additive(`surfaces.${surface.key}`, `surface "${surface.key}" was added`);
38
41
  }
42
+ for (const area of previous.areas) {
43
+ const after = next.areas.find((candidate) => candidate.key === area.key);
44
+ if (after === undefined)
45
+ breaking(`areas.${area.key}`, `area "${area.key}" was removed: its saved document no longer renders`);
46
+ else
47
+ compareCaps(area, after, `areas.${area.key}`, "sections", breaking, additive);
48
+ }
49
+ for (const area of next.areas) {
50
+ if (!previous.areas.some((before) => before.key === area.key))
51
+ additive(`areas.${area.key}`, `area "${area.key}" was added`);
52
+ }
53
+ for (const scale of ["spacing", "widths"]) {
54
+ const after = next.style?.[scale] ?? {};
55
+ for (const key of Object.keys(previous.style?.[scale] ?? {})) {
56
+ if (!Object.hasOwn(after, key))
57
+ breaking(`style.${scale}.${key}`, `the theme no longer gives "${key}" a size: saved styles using it show none`);
58
+ }
59
+ }
39
60
  const settingFields = (manifest) => manifest.settings.flatMap((group) => group.fields);
40
61
  compareFields(settingFields(previous), settingFields(next), "settings", breaking, additive);
41
62
  for (const [locale, pack] of Object.entries(previous.content)) {
@@ -74,6 +95,29 @@ function compareComponents(previous, next, path, breaking, additive) {
74
95
  if (!previous.slots.some((before) => before.name === slot.name))
75
96
  additive(`${path}.slots.${slot.name}`, `slot "${slot.name}" was added`);
76
97
  }
98
+ const nextFixed = new Map((next.fixed ?? []).map((block) => [block.name, block]));
99
+ for (const block of previous.fixed ?? []) {
100
+ const after = nextFixed.get(block.name);
101
+ if (after === undefined)
102
+ breaking(`${path}.fixed.${block.name}`, `fixed block "${block.name}" was removed: its saved values are ignored`);
103
+ else if (after.type !== block.type)
104
+ breaking(`${path}.fixed.${block.name}.type`, `fixed block "${block.name}" is now a "${after.type}", not a "${block.type}": its saved values are ignored`);
105
+ }
106
+ for (const block of next.fixed ?? []) {
107
+ if (!(previous.fixed ?? []).some((before) => before.name === block.name))
108
+ additive(`${path}.fixed.${block.name}`, `fixed block "${block.name}" was added`);
109
+ }
110
+ }
111
+ /** A list's cap and required types: a lower cap hides saved instances; a new requirement only adds. */
112
+ function compareCaps(previous, next, path, what, breaking, additive) {
113
+ if (next.max !== undefined && (previous.max === undefined || next.max < previous.max)) {
114
+ breaking(`${path}.max`, `now holds at most ${next.max} ${what}: saved ones past it are not rendered`);
115
+ }
116
+ for (const entry of next.required ?? []) {
117
+ if (!(previous.required ?? []).some((before) => before.type === entry.type)) {
118
+ additive(`${path}.required`, `"${entry.type}" is now required: a saved document that lacks it gains one`);
119
+ }
120
+ }
77
121
  }
78
122
  function compareSlots(previous, next, path, breaking, additive) {
79
123
  if (previous.multiple !== false && next.multiple === false) {
@@ -95,6 +139,7 @@ function compareSlots(previous, next, path, breaking, additive) {
95
139
  else if (previous.allow !== undefined) {
96
140
  additive(`${path}.allow`, "the slot now accepts any component");
97
141
  }
142
+ compareCaps(previous, next, path, "instances", breaking, additive);
98
143
  }
99
144
  function compareFields(previous, next, path, breaking, additive) {
100
145
  const nextFields = new Map(next.map((field) => [field.key, field]));
@@ -150,6 +195,22 @@ function compareKindRules(previous, next, path, breaking, additive) {
150
195
  else if (removed !== undefined && removed.length > 0)
151
196
  breaking(`${path}.formats`, `the field no longer allows ${removed.join(", ")}`);
152
197
  }
198
+ if ((previous.kind === "productList" || previous.kind === "collectionList") && next.kind === previous.kind) {
199
+ const before = previous;
200
+ const after = next;
201
+ if (after.max !== undefined && (before.max === undefined || after.max < before.max))
202
+ breaking(`${path}.max`, `the list now holds at most ${after.max}`);
203
+ if (after.min !== undefined && (before.min === undefined || after.min > before.min))
204
+ breaking(`${path}.min`, `the list now needs at least ${after.min}`);
205
+ }
206
+ if (previous.kind === "richtext" && next.kind === "richtext" && next.inline === true && previous.inline !== true) {
207
+ breaking(`${path}.inline`, "the field is now one line: saved text with several paragraphs, headings or lists no longer reads");
208
+ }
209
+ if (previous.kind === "style" && next.kind === "style") {
210
+ const removed = previous.panels.filter((panel) => !next.panels.includes(panel));
211
+ if (removed.length > 0)
212
+ breaking(`${path}.panels`, `the field no longer offers ${removed.join(", ")}: saved values for it are ignored`);
213
+ }
153
214
  if (previous.kind === "list" && next.kind === "list") {
154
215
  if (next.max !== undefined && (previous.max === undefined || next.max < previous.max))
155
216
  breaking(`${path}.max`, `the list now holds at most ${next.max} items`);