@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.
@@ -4,8 +4,11 @@
4
4
  * data — the validator exists so a theme's test suite fails loudly on catalog
5
5
  * bugs when the catalog is assembled.
6
6
  */
7
- import { instantiatePreset } from "./composition.js";
8
- import { checkStoredValue, onStepGrid } from "./instances.js";
7
+ import { COMPONENT_PLACEMENTS, COMPONENT_ROLES, THEME_LIMITS, areaSurface, guaranteedRole, innerGuarantees, placementAllows, surfaceRules, } from "./blocks.js";
8
+ import { instantiatePreset, surfaceCovers } from "./composition.js";
9
+ import { conditionProblem } from "./conditions.js";
10
+ import { COLOR_SCHEME_ID_PATTERN, STYLE_PANELS, STYLE_STEPS, STYLE_WIDTHS, colorSchemesProblem, styleSizeProblem, styleValueProblem, } from "./style.js";
11
+ import { HANDLE_LIST_MAX, checkStoredValue, handleListProblem, onStepGrid } from "./instances.js";
9
12
  import { validatePageData } from "./page-data.js";
10
13
  import { RICH_TEXT_FORMATS, isRichTextDocumentShape, richTextProblem } from "./rich-text.js";
11
14
  import { COLOR_PATTERN, MENU_HANDLE_PATTERN, COLLECTION_HANDLE_PATTERN, PAGE_TYPES, RESOURCE_HANDLE_PATTERN, SURFACE_KEY_PATTERN, THEME_KEY_PATTERN, TYPE_KEY_PATTERN, isLocaleMap, isLocaleMapOf, isLocaleTag, isSafeLinkTarget, } from "./schemas.js";
@@ -95,17 +98,27 @@ export function validateThemeDefinition(theme, options = {}) {
95
98
  addIssue(issues, "surfaces", "invalid-theme", "`surfaces` must be an array of surface declarations");
96
99
  }
97
100
  else {
98
- inspectSurfaces(theme.surfaces, issues, locales);
101
+ inspectSurfaces(theme.surfaces, issues, locales, new Set(Object.keys(theme.components)));
99
102
  }
100
103
  }
104
+ if (theme.areas !== undefined)
105
+ inspectAreas(theme.areas, issues, locales, new Set(Object.keys(theme.components)));
101
106
  const rules = {
102
107
  contentKeys,
103
108
  catalogKeys: new Set(Object.keys(theme.components)),
104
109
  locales,
105
- surfaceKeys: new Set((Array.isArray(theme.surfaces) ? theme.surfaces : [])
106
- .map((surface) => (isRecord(surface) ? surface.key : undefined))
107
- .filter((key) => typeof key === "string")),
110
+ surfaceKeys: new Set([
111
+ ...(Array.isArray(theme.surfaces) ? theme.surfaces : [])
112
+ .map((surface) => (isRecord(surface) ? surface.key : undefined))
113
+ .filter((key) => typeof key === "string"),
114
+ ...(Array.isArray(theme.areas) ? theme.areas : [])
115
+ .map((area) => (isRecord(area) && typeof area.key === "string" ? areaSurface(area.key) : undefined))
116
+ .filter((key) => key !== undefined),
117
+ ]),
118
+ style: isRecord(theme.style) ? theme.style : undefined,
108
119
  };
120
+ if (theme.style !== undefined)
121
+ inspectStyle(theme.style, issues);
109
122
  if (theme.settings !== undefined)
110
123
  inspectSettings(theme.settings, issues, rules);
111
124
  for (const [key, definition] of Object.entries(theme.components)) {
@@ -120,8 +133,12 @@ export function validateThemeDefinition(theme, options = {}) {
120
133
  addIssue(issues, `${path}.type`, "type-mismatch", `definition type "${definition.type}" must equal its catalog key "${key}"`);
121
134
  }
122
135
  }
123
- // Documents and presets are read through the catalog, so they are checked
124
- // once the catalog itself is sound.
136
+ // Block rules span components, and documents and presets are read through
137
+ // the catalog, so each is checked once everything before it is sound.
138
+ if (issues.length === 0)
139
+ inspectColorSchemes(theme, issues);
140
+ if (issues.length === 0)
141
+ inspectBlockRules(theme, issues);
125
142
  if (issues.length === 0)
126
143
  inspectComposition(theme, issues);
127
144
  return issues;
@@ -169,17 +186,21 @@ function inspectContent(content, issues, locales, localesDeclared) {
169
186
  }
170
187
  /** Default documents and presets, read through the (sound) catalog as a writer would. */
171
188
  function inspectComposition(theme, issues) {
172
- (theme.surfaces ?? []).forEach((surface, index) => {
173
- if (surface.document === undefined)
189
+ const documents = [
190
+ ...(theme.surfaces ?? []).map((surface, index) => ({ key: surface.key, document: surface.document, path: `surfaces[${index}].document` })),
191
+ ...(theme.areas ?? []).map((area, index) => ({ key: areaSurface(area.key), document: area.document, path: `areas[${index}].document` })),
192
+ ];
193
+ documents.forEach(({ key, document, path }) => {
194
+ if (document === undefined)
195
+ return;
196
+ const checked = validatePageData(theme, document);
197
+ if (checked.page !== null && checked.page.surface !== key) {
198
+ addIssue(issues, `${path}.surface`, "invalid-document", `the default document is for "${checked.page.surface}", not "${key}"`);
174
199
  return;
175
- const path = `surfaces[${index}].document`;
176
- const checked = validatePageData(theme, surface.document);
177
- if (checked.page !== null && checked.page.surface !== surface.key) {
178
- addIssue(issues, `${path}.surface`, "invalid-document", `the default document is for "${checked.page.surface}", not "${surface.key}"`);
179
200
  }
180
201
  for (const issue of checked.issues) {
181
- // Content-keyed text is filled from the store's pack at render.
182
- if (issue.code === "missing-required-prop")
202
+ // Filled at render: content-keyed text from the store's pack, required blocks by completion.
203
+ if (issue.code === "missing-required-prop" || issue.code === "missing-required-block")
183
204
  continue;
184
205
  addIssue(issues, issue.path === "" ? path : `${path}.${issue.path}`, "invalid-document", issue.message);
185
206
  }
@@ -217,6 +238,9 @@ function inspectSettings(settings, issues, rules) {
217
238
  }
218
239
  const groupKeys = new Set();
219
240
  const settingKeys = new Set();
241
+ const allSettingKeys = new Set(settings.flatMap((group) => isRecord(group) && Array.isArray(group.fields)
242
+ ? group.fields.map((field) => (isRecord(field) && typeof field.key === "string" ? field.key : "")).filter((key) => key !== "")
243
+ : []));
220
244
  settings.forEach((group, index) => {
221
245
  const path = `settings[${index}]`;
222
246
  if (!isRecord(group)) {
@@ -239,7 +263,7 @@ function inspectSettings(settings, issues, rules) {
239
263
  addIssue(issues, `${path}.fields`, "invalid-settings", "`fields` must be an array");
240
264
  return;
241
265
  }
242
- inspectFields(group.fields, path, issues, rules);
266
+ inspectFields(group.fields, path, issues, rules, { settings: true, siblings: allSettingKeys });
243
267
  for (const [fieldIndex, field] of group.fields.entries()) {
244
268
  if (!isRecord(field) || typeof field.key !== "string")
245
269
  continue;
@@ -250,7 +274,38 @@ function inspectSettings(settings, issues, rules) {
250
274
  }
251
275
  });
252
276
  }
253
- function inspectSurfaces(surfaces, issues, locales) {
277
+ /** The parts every page shares: kebab-case keys, once each, labelled, bounded like a surface. */
278
+ function inspectAreas(areas, issues, locales, catalogKeys) {
279
+ if (!Array.isArray(areas)) {
280
+ addIssue(issues, "areas", "invalid-theme", "`areas` must be an array of area declarations");
281
+ return;
282
+ }
283
+ const keys = new Set();
284
+ areas.forEach((area, index) => {
285
+ const path = `areas[${index}]`;
286
+ if (!isRecord(area)) {
287
+ addIssue(issues, path, "invalid-theme", "an area must be { key, label, document?, required?, max? }");
288
+ return;
289
+ }
290
+ if (typeof area.key !== "string" || !TYPE_KEY_PATTERN.test(area.key)) {
291
+ addIssue(issues, `${path}.key`, "invalid-theme", "`key` must be lowercase kebab-case");
292
+ }
293
+ else if (keys.has(area.key)) {
294
+ addIssue(issues, `${path}.key`, "invalid-theme", `duplicate area "${area.key}"`);
295
+ }
296
+ else {
297
+ keys.add(area.key);
298
+ }
299
+ const labelProblem = localizedTextProblem(area.label, locales, { allowEmpty: false });
300
+ if (labelProblem !== null)
301
+ addIssue(issues, `${path}.label`, "invalid-theme", `\`label\` ${labelProblem}`);
302
+ if (area.document !== undefined && !isRecord(area.document)) {
303
+ addIssue(issues, `${path}.document`, "invalid-document", "`document` must be a page document");
304
+ }
305
+ inspectBlockBounds(area, path, issues, catalogKeys, THEME_LIMITS.sections, "invalid-theme");
306
+ });
307
+ }
308
+ function inspectSurfaces(surfaces, issues, locales, catalogKeys) {
254
309
  const seenKeys = new Set();
255
310
  surfaces.forEach((surface, index) => {
256
311
  const surfacePath = `surfaces[${index}]`;
@@ -278,6 +333,7 @@ function inspectSurfaces(surfaces, issues, locales) {
278
333
  if (surface.document !== undefined && !isRecord(surface.document)) {
279
334
  addIssue(issues, `${surfacePath}.document`, "invalid-document", "`document` must be a page document");
280
335
  }
336
+ inspectBlockBounds(surface, surfacePath, issues, catalogKeys, THEME_LIMITS.sections, "invalid-theme");
281
337
  if (typeof surface.key === "string" && surface.key.includes(".")) {
282
338
  const baseKey = surface.key.slice(0, surface.key.indexOf("."));
283
339
  const base = surfaces.find((candidate) => isRecord(candidate) && candidate.key === baseKey);
@@ -358,7 +414,7 @@ function inspectDefinition(definition, path, issues, rules) {
358
414
  addIssue(issues, `${path}.fields`, "invalid-field", "`fields` must be an array");
359
415
  }
360
416
  else {
361
- inspectFields(definition.fields, path, issues, rules);
417
+ inspectFields(definition.fields, path, issues, rules, { props: true });
362
418
  }
363
419
  }
364
420
  if (definition.slots !== undefined) {
@@ -369,10 +425,178 @@ function inspectDefinition(definition, path, issues, rules) {
369
425
  inspectSlots(definition.slots, path, issues, rules.catalogKeys);
370
426
  }
371
427
  }
428
+ if (definition.placement !== undefined && !COMPONENT_PLACEMENTS.includes(definition.placement)) {
429
+ addIssue(issues, `${path}.placement`, "invalid-placement", `\`placement\` must be one of ${COMPONENT_PLACEMENTS.join(", ")}`);
430
+ }
431
+ if (definition.role !== undefined && !COMPONENT_ROLES.includes(definition.role)) {
432
+ addIssue(issues, `${path}.role`, "invalid-role", `\`role\` must be one of ${COMPONENT_ROLES.join(", ")}`);
433
+ }
434
+ if (definition.fixed !== undefined)
435
+ inspectFixed(definition.fixed, path, issues, rules);
436
+ }
437
+ /** The blocks a component's code places: named once each, of a type in the catalog. */
438
+ function inspectFixed(fixed, path, issues, rules) {
439
+ if (!Array.isArray(fixed) || fixed.length === 0) {
440
+ addIssue(issues, `${path}.fixed`, "invalid-fixed", "`fixed` must be a non-empty array of fixed blocks (omit it for none)");
441
+ return;
442
+ }
443
+ const names = new Set();
444
+ fixed.forEach((block, index) => {
445
+ const blockPath = `${path}.fixed[${index}]`;
446
+ if (!isRecord(block)) {
447
+ addIssue(issues, blockPath, "invalid-fixed", "a fixed block must be { name, type, label? }");
448
+ return;
449
+ }
450
+ if (typeof block.name !== "string" || !TYPE_KEY_PATTERN.test(block.name)) {
451
+ addIssue(issues, `${blockPath}.name`, "invalid-fixed", "`name` must be lowercase kebab-case");
452
+ }
453
+ else if (names.has(block.name)) {
454
+ addIssue(issues, `${blockPath}.name`, "invalid-fixed", `duplicate fixed block "${block.name}"`);
455
+ }
456
+ else {
457
+ names.add(block.name);
458
+ }
459
+ if (typeof block.type !== "string" || !rules.catalogKeys.has(block.type)) {
460
+ addIssue(issues, `${blockPath}.type`, "invalid-fixed", `${JSON.stringify(block.type)} is not a component type in this catalog`);
461
+ }
462
+ if (block.label !== undefined) {
463
+ const problem = localizedTextProblem(block.label, rules.locales, { allowEmpty: false });
464
+ if (problem !== null)
465
+ addIssue(issues, `${blockPath}.label`, "invalid-fixed", `\`label\` ${problem}`);
466
+ }
467
+ });
468
+ }
469
+ /**
470
+ * A slot's or surface's `max` and `required`: whole-number bounds within the
471
+ * platform's cap, each required type one the catalog has and the list takes,
472
+ * and the minimums fitting under the cap.
473
+ */
474
+ function inspectBlockBounds(holder, path, issues, catalogKeys, platformCap, code) {
475
+ const { max, required } = holder;
476
+ if (max !== undefined && !(typeof max === "number" && Number.isInteger(max) && max >= 1 && max <= platformCap)) {
477
+ addIssue(issues, `${path}.max`, code, `\`max\` must be a whole number from 1 to ${platformCap}`);
478
+ }
479
+ if (required === undefined)
480
+ return;
481
+ if (!Array.isArray(required) || required.length === 0) {
482
+ addIssue(issues, `${path}.required`, code, "`required` must be a non-empty array of { type, min?, max? } (omit it for none)");
483
+ return;
484
+ }
485
+ const allow = Array.isArray(holder.allow) ? holder.allow : undefined;
486
+ const types = new Set();
487
+ let minimums = 0;
488
+ required.forEach((entry, index) => {
489
+ const entryPath = `${path}.required[${index}]`;
490
+ if (!isRecord(entry)) {
491
+ addIssue(issues, entryPath, code, "a required entry must be { type, min?, max? }");
492
+ return;
493
+ }
494
+ if (typeof entry.type !== "string" || !catalogKeys.has(entry.type)) {
495
+ addIssue(issues, `${entryPath}.type`, code, `${JSON.stringify(entry.type)} is not a component type in this catalog`);
496
+ }
497
+ else if (allow !== undefined && !allow.includes(entry.type)) {
498
+ addIssue(issues, `${entryPath}.type`, code, `"${entry.type}" is required but not in \`allow\``);
499
+ }
500
+ else if (types.has(entry.type)) {
501
+ addIssue(issues, `${entryPath}.type`, code, `"${entry.type}" is required twice`);
502
+ }
503
+ else {
504
+ types.add(entry.type);
505
+ }
506
+ const min = entry.min ?? 1;
507
+ if (!(typeof min === "number" && Number.isInteger(min) && min >= 1)) {
508
+ addIssue(issues, `${entryPath}.min`, code, "`min` must be a whole number of at least 1");
509
+ return;
510
+ }
511
+ if (entry.max !== undefined && !(typeof entry.max === "number" && Number.isInteger(entry.max) && entry.max >= min)) {
512
+ addIssue(issues, `${entryPath}.max`, code, `\`max\` must be a whole number of at least \`min\` (${min})`);
513
+ }
514
+ minimums += min;
515
+ });
516
+ const cap = typeof max === "number" ? Math.min(max, platformCap) : holder.multiple === false ? 1 : platformCap;
517
+ if (minimums > cap)
518
+ addIssue(issues, `${path}.required`, code, `the required minimums (${minimums}) exceed what the list holds (${cap})`);
372
519
  }
373
- function inspectFields(fields, path, issues, rules, { inList = false } = {}) {
520
+ /**
521
+ * Rules that span components, once each one is sound: fixed and required
522
+ * blocks are placeable where they go, never contain themselves, nest within
523
+ * the platform's depth, and every product surface guarantees an order form.
524
+ */
525
+ function inspectBlockRules(theme, issues) {
526
+ const definitionOf = (type) => (Object.hasOwn(theme.components, type) ? theme.components[type] : undefined);
527
+ for (const [type, definition] of Object.entries(theme.components)) {
528
+ for (const [index, block] of (definition.fixed ?? []).entries()) {
529
+ if (!placementAllows(definitionOf(block.type)?.placement, "block")) {
530
+ addIssue(issues, `components.${type}.fixed[${index}].type`, "invalid-fixed", `"${block.type}" is placed only as a section, not as a block`);
531
+ }
532
+ }
533
+ for (const [slotIndex, slot] of (definition.slots ?? []).entries()) {
534
+ for (const [index, entry] of (slot.required ?? []).entries()) {
535
+ if (!placementAllows(definitionOf(entry.type)?.placement, "block")) {
536
+ addIssue(issues, `components.${type}.slots[${slotIndex}].required[${index}].type`, "invalid-slot", `"${entry.type}" is placed only as a section, not as a block`);
537
+ }
538
+ }
539
+ }
540
+ }
541
+ const holders = [
542
+ ...(theme.surfaces ?? []).map((surface, index) => ({ key: surface.key, required: surface.required, path: `surfaces[${index}]` })),
543
+ ...(theme.areas ?? []).map((area, index) => ({ key: areaSurface(area.key), required: area.required, path: `areas[${index}]` })),
544
+ ];
545
+ for (const holder of holders) {
546
+ (holder.required ?? []).forEach((entry, index) => {
547
+ const definition = definitionOf(entry.type);
548
+ const path = `${holder.path}.required[${index}].type`;
549
+ if (!placementAllows(definition?.placement, "section")) {
550
+ addIssue(issues, path, "invalid-theme", `"${entry.type}" is placed only as a block, not as a section`);
551
+ }
552
+ else if (definition?.capabilities?.surfaces !== undefined && !definition.capabilities.surfaces.some((entry) => surfaceCovers(entry, holder.key))) {
553
+ addIssue(issues, path, "invalid-theme", `"${entry.type}" is not available on "${holder.key}"`);
554
+ }
555
+ });
556
+ }
557
+ const depths = new Map();
558
+ const reported = new Set();
559
+ const nesting = (type, trail) => {
560
+ const known = depths.get(type);
561
+ if (known !== undefined)
562
+ return known;
563
+ const loop = trail.indexOf(type);
564
+ if (loop >= 0) {
565
+ const cycle = [...trail.slice(loop), type];
566
+ if (!reported.has(cycle[0])) {
567
+ reported.add(cycle[0]);
568
+ addIssue(issues, `components.${cycle[0]}`, "invalid-fixed", `fixed and required blocks contain themselves: ${cycle.join(" → ")}`);
569
+ }
570
+ return 0;
571
+ }
572
+ const definition = definitionOf(type);
573
+ const inner = definition === undefined ? [] : innerGuarantees(definition);
574
+ const depth = inner.reduce((deepest, { type: child }) => Math.max(deepest, 1 + nesting(child, [...trail, type])), 0);
575
+ depths.set(type, depth);
576
+ return depth;
577
+ };
578
+ for (const type of Object.keys(theme.components)) {
579
+ const depth = nesting(type, []);
580
+ if (depth > THEME_LIMITS.depth) {
581
+ addIssue(issues, `components.${type}`, "invalid-fixed", `fixed and required blocks nest ${depth} levels inside "${type}"; at most ${THEME_LIMITS.depth}`);
582
+ }
583
+ }
584
+ if (issues.length > 0)
585
+ return;
586
+ (theme.surfaces ?? []).forEach((surface, index) => {
587
+ if (surface.pageType !== "product")
588
+ return;
589
+ const rules = surfaceRules(theme, surface.key);
590
+ const guaranteed = (rules.required ?? []).reduce((count, entry) => count + (entry.min ?? 1) * guaranteedRole(theme, entry.type, "order-form"), 0);
591
+ if (guaranteed === 0) {
592
+ addIssue(issues, `surfaces[${index}]`, "order-form-not-guaranteed", `product surface "${surface.key}" must always render an order form: require a section that places a component with role "order-form" through fixed or required blocks`);
593
+ }
594
+ });
595
+ }
596
+ function inspectFields(fields, path, issues, rules, { inList = false, props = false, settings = false, siblings, } = {}) {
374
597
  const { contentKeys, locales } = rules;
375
598
  const seenKeys = new Set();
599
+ const siblingKeys = new Set(fields.map((field) => (isRecord(field) && typeof field.key === "string" ? field.key : "")).filter((key) => key !== ""));
376
600
  fields.forEach((field, index) => {
377
601
  const fieldPath = `${path}.fields[${index}]`;
378
602
  if (!isRecord(field)) {
@@ -396,6 +620,9 @@ function inspectFields(fields, path, issues, rules, { inList = false } = {}) {
396
620
  else if (field.key === "__proto__") {
397
621
  addIssue(issues, `${fieldPath}.key`, "invalid-field", "`__proto__` cannot be a field key: as a prop name it would set a prototype");
398
622
  }
623
+ else if (props && field.key === "blocks") {
624
+ addIssue(issues, `${fieldPath}.key`, "invalid-field", "`blocks` cannot be a field key: the renderer gives every component its blocks under that prop");
625
+ }
399
626
  else if (seenKeys.has(field.key)) {
400
627
  addIssue(issues, `${fieldPath}.key`, "duplicate-field-key", `duplicate field key "${field.key}"`);
401
628
  }
@@ -415,6 +642,11 @@ function inspectFields(fields, path, issues, rules, { inList = false } = {}) {
415
642
  if (field.required !== undefined && typeof field.required !== "boolean") {
416
643
  addIssue(issues, `${fieldPath}.required`, "invalid-field", "`required` must be a boolean");
417
644
  }
645
+ if (field.visibleIf !== undefined) {
646
+ const problem = conditionProblem(field.visibleIf, siblings ?? siblingKeys, typeof field.key === "string" ? field.key : "");
647
+ if (problem !== null)
648
+ addIssue(issues, `${fieldPath}.visibleIf`, "invalid-field", `\`visibleIf\` ${problem}`);
649
+ }
418
650
  switch (field.kind) {
419
651
  case "text":
420
652
  case "textarea": {
@@ -456,6 +688,12 @@ function inspectFields(fields, path, issues, rules, { inList = false } = {}) {
456
688
  if (field.step !== undefined && !(typeof field.step === "number" && Number.isFinite(field.step) && field.step > 0)) {
457
689
  addIssue(issues, `${fieldPath}.step`, "invalid-field", "`step` must be a finite number greater than 0");
458
690
  }
691
+ if (field.display !== undefined && field.display !== "input" && field.display !== "slider") {
692
+ addIssue(issues, `${fieldPath}.display`, "invalid-field", "`display` must be input or slider");
693
+ }
694
+ else if (field.display === "slider" && (field.min === undefined || field.max === undefined || field.step === undefined)) {
695
+ addIssue(issues, `${fieldPath}.display`, "invalid-field", "a slider needs `min`, `max` and `step`");
696
+ }
459
697
  // A default outside the declared bounds would sail through assembly
460
698
  // and then behave incoherently at runtime (`resolveInstance` trusts
461
699
  // authored defaults while rejecting the same out-of-range instance
@@ -492,6 +730,9 @@ function inspectFields(fields, path, issues, rules, { inList = false } = {}) {
492
730
  (typeof field.default !== "string" || !options.includes(field.default))) {
493
731
  addIssue(issues, `${fieldPath}.default`, "invalid-field", "`default` must be one of `options`");
494
732
  }
733
+ if (field.display !== undefined && !["dropdown", "radio", "segmented"].includes(field.display)) {
734
+ addIssue(issues, `${fieldPath}.display`, "invalid-field", "`display` must be dropdown, radio or segmented");
735
+ }
495
736
  if (field.optionLabels !== undefined) {
496
737
  if (!isRecord(field.optionLabels)) {
497
738
  addIssue(issues, `${fieldPath}.optionLabels`, "invalid-field", "`optionLabels` must map options to labels");
@@ -550,6 +791,56 @@ function inspectFields(fields, path, issues, rules, { inList = false } = {}) {
550
791
  inspectListField(field, fieldPath, issues, rules);
551
792
  break;
552
793
  }
794
+ case "style": {
795
+ const panels = field.panels;
796
+ if (!Array.isArray(panels) || panels.length === 0 || !panels.every((panel) => STYLE_PANELS.includes(panel))) {
797
+ addIssue(issues, `${fieldPath}.panels`, "invalid-field", `\`panels\` must be a non-empty array of ${STYLE_PANELS.join(", ")}`);
798
+ }
799
+ else if (new Set(panels).size !== panels.length) {
800
+ addIssue(issues, `${fieldPath}.panels`, "invalid-field", "`panels` names a panel twice");
801
+ }
802
+ else if (field.default !== undefined) {
803
+ const problem = styleValueProblem(panels, field.default) ?? styleSizeProblem(rules.style, field.default);
804
+ if (problem !== null)
805
+ addIssue(issues, `${fieldPath}.default`, "invalid-field", `\`default\` ${problem}`);
806
+ }
807
+ break;
808
+ }
809
+ case "productList":
810
+ case "collectionList": {
811
+ for (const bound of ["min", "max"]) {
812
+ const value = field[bound];
813
+ const least = bound === "max" ? 1 : 0;
814
+ if (value !== undefined && !(typeof value === "number" && Number.isInteger(value) && value >= least && value <= HANDLE_LIST_MAX)) {
815
+ addIssue(issues, `${fieldPath}.${bound}`, "invalid-field", `\`${bound}\` must be a whole number from ${least} to ${HANDLE_LIST_MAX}`);
816
+ }
817
+ }
818
+ if (typeof field.min === "number" && typeof field.max === "number" && field.min > field.max) {
819
+ addIssue(issues, fieldPath, "invalid-field", "`min` must be <= `max`");
820
+ }
821
+ if (field.default !== undefined) {
822
+ const problem = handleListProblem(field.kind, field.min, field.max, field.default);
823
+ if (problem !== null)
824
+ addIssue(issues, `${fieldPath}.default`, "invalid-field", `\`default\` ${problem}`);
825
+ }
826
+ break;
827
+ }
828
+ case "colorScheme": {
829
+ if (field.default !== undefined && (typeof field.default !== "string" || !COLOR_SCHEME_ID_PATTERN.test(field.default))) {
830
+ addIssue(issues, `${fieldPath}.default`, "invalid-field", "`default` must be a color scheme id");
831
+ }
832
+ break;
833
+ }
834
+ case "colorSchemes": {
835
+ if (!settings) {
836
+ addIssue(issues, `${fieldPath}.kind`, "invalid-field", "a `colorSchemes` field holds the store's schemes: it belongs in the theme's settings");
837
+ break;
838
+ }
839
+ const problem = colorSchemesProblem(field.default);
840
+ if (problem !== null)
841
+ addIssue(issues, `${fieldPath}.default`, "invalid-field", `\`default\` (the theme's schemes) ${problem}`);
842
+ break;
843
+ }
553
844
  case "url": {
554
845
  if (field.default !== undefined &&
555
846
  (typeof field.default !== "string" || !isSafeLinkTarget(field.default))) {
@@ -562,6 +853,71 @@ function inspectFields(fields, path, issues, rules, { inList = false } = {}) {
562
853
  }
563
854
  });
564
855
  }
856
+ /** The sizes a theme gives the platform's style steps and widths: whole pixels, within reason. */
857
+ function inspectStyle(style, issues) {
858
+ if (!isRecord(style)) {
859
+ addIssue(issues, "style", "invalid-theme", "`style` must be { spacing?, widths? }");
860
+ return;
861
+ }
862
+ const scales = [
863
+ ["spacing", STYLE_STEPS, 0, 1024],
864
+ ["widths", STYLE_WIDTHS, 160, 4096],
865
+ ];
866
+ for (const key of Object.keys(style)) {
867
+ if (!scales.some(([name]) => name === key))
868
+ addIssue(issues, `style.${key}`, "invalid-theme", `"${key}" is not a style scale (spacing, widths)`);
869
+ }
870
+ for (const [name, keys, min, max] of scales) {
871
+ const scale = style[name];
872
+ if (scale === undefined)
873
+ continue;
874
+ if (!isRecord(scale)) {
875
+ addIssue(issues, `style.${name}`, "invalid-theme", `\`${name}\` must map ${keys.join(", ")} to pixels`);
876
+ continue;
877
+ }
878
+ for (const [key, size] of Object.entries(scale)) {
879
+ if (!keys.includes(key)) {
880
+ addIssue(issues, `style.${name}.${key}`, "invalid-theme", `"${key}" is not one of ${keys.join(", ")}`);
881
+ }
882
+ else if (!(typeof size === "number" && Number.isInteger(size) && size >= min && size <= max)) {
883
+ addIssue(issues, `style.${name}.${key}`, "invalid-theme", `must be a whole number of pixels from ${min} to ${max}`);
884
+ }
885
+ }
886
+ }
887
+ }
888
+ /**
889
+ * The store's color schemes and what picks them: at most one `colorSchemes`
890
+ * setting, which every `colorScheme` field needs, and whose schemes include
891
+ * each picker's default.
892
+ */
893
+ function inspectColorSchemes(theme, issues) {
894
+ const holders = [];
895
+ (theme.settings ?? []).forEach((group, groupIndex) => group.fields.forEach((field, index) => {
896
+ if (field.kind === "colorSchemes")
897
+ holders.push({ path: `settings[${groupIndex}].fields[${index}]`, ids: new Set(field.default.map((scheme) => scheme.id)) });
898
+ }));
899
+ for (const extra of holders.slice(1))
900
+ addIssue(issues, extra.path, "invalid-settings", "a theme has at most one `colorSchemes` setting");
901
+ const ids = holders[0]?.ids;
902
+ const pickers = [];
903
+ const collect = (fields, path) => fields.forEach((field, index) => {
904
+ if (field.kind === "colorScheme")
905
+ pickers.push({ path: `${path}.fields[${index}]`, default: field.default });
906
+ if (field.kind === "list")
907
+ collect(field.fields, `${path}.fields[${index}]`);
908
+ });
909
+ for (const [type, definition] of Object.entries(theme.components))
910
+ collect(definition.fields ?? [], `components.${type}`);
911
+ (theme.settings ?? []).forEach((group, index) => collect(group.fields, `settings[${index}]`));
912
+ for (const picker of pickers) {
913
+ if (ids === undefined) {
914
+ addIssue(issues, picker.path, "invalid-field", "a `colorScheme` field needs the theme's `colorSchemes` setting");
915
+ }
916
+ else if (picker.default !== undefined && !ids.has(picker.default)) {
917
+ addIssue(issues, `${picker.path}.default`, "invalid-field", `"${picker.default}" is not one of the theme's color schemes`);
918
+ }
919
+ }
920
+ }
565
921
  /** Preset shapes; what they build is checked once the catalog is sound (`inspectComposition`). */
566
922
  function inspectPresets(presets, path, issues, rules) {
567
923
  if (!Array.isArray(presets)) {
@@ -621,27 +977,40 @@ function inspectRichTextField(field, fieldPath, issues, locales) {
621
977
  formats = declared.filter((format) => RICH_TEXT_FORMATS.includes(format));
622
978
  }
623
979
  }
980
+ if (field.inline !== undefined && typeof field.inline !== "boolean") {
981
+ addIssue(issues, `${fieldPath}.inline`, "invalid-field", "`inline` must be a boolean");
982
+ }
983
+ else if (field.inline === true && (formats ?? RICH_TEXT_FORMATS).some((format) => format === "heading" || format === "list")) {
984
+ addIssue(issues, `${fieldPath}.formats`, "invalid-field", "an inline field is one line: list `formats` without `heading` or `list`");
985
+ }
624
986
  if (field.default === undefined)
625
987
  return;
626
988
  const value = field.default;
989
+ const before = issues.length;
627
990
  if (isRichTextDocumentShape(value)) {
628
991
  const problem = richTextProblem(value, formats);
629
992
  if (problem !== null)
630
993
  addIssue(issues, `${fieldPath}.default`, "invalid-field", `\`default\` ${problem}`);
631
- return;
632
994
  }
633
- if (!isLocaleMapOf(value, isRecord)) {
995
+ else if (!isLocaleMapOf(value, isRecord)) {
634
996
  addIssue(issues, `${fieldPath}.default`, "invalid-field", "`default` must be a rich-text document or a map from language tags to documents");
635
997
  return;
636
998
  }
637
- for (const [locale, document] of Object.entries(value)) {
638
- const problem = richTextProblem(document, formats);
639
- if (problem !== null)
640
- addIssue(issues, `${fieldPath}.default.${locale}`, "invalid-field", `\`default\` (${locale}) ${problem}`);
999
+ else {
1000
+ for (const [locale, document] of Object.entries(value)) {
1001
+ const problem = richTextProblem(document, formats);
1002
+ if (problem !== null)
1003
+ addIssue(issues, `${fieldPath}.default.${locale}`, "invalid-field", `\`default\` (${locale}) ${problem}`);
1004
+ }
1005
+ const missing = locales.filter((locale) => !Object.hasOwn(value, locale));
1006
+ if (missing.length > 0) {
1007
+ addIssue(issues, `${fieldPath}.default`, "invalid-field", `\`default\` is missing the theme's languages: ${missing.join(", ")}`);
1008
+ }
641
1009
  }
642
- const missing = locales.filter((locale) => !Object.hasOwn(value, locale));
643
- if (missing.length > 0) {
644
- addIssue(issues, `${fieldPath}.default`, "invalid-field", `\`default\` is missing the theme's languages: ${missing.join(", ")}`);
1010
+ if (field.inline === true && issues.length === before) {
1011
+ const checked = checkStoredValue(field, value);
1012
+ if (!checked.ok)
1013
+ addIssue(issues, `${fieldPath}.default`, "invalid-field", `\`default\` ${checked.message}`);
645
1014
  }
646
1015
  }
647
1016
  function inspectListField(field, fieldPath, issues, rules) {
@@ -703,6 +1072,7 @@ function inspectSlots(slots, path, issues, catalogKeys) {
703
1072
  if (slot.allow !== undefined) {
704
1073
  inspectAllow(slot.allow, `${slotPath}.allow`, issues, catalogKeys);
705
1074
  }
1075
+ inspectBlockBounds(slot, slotPath, issues, catalogKeys, THEME_LIMITS.slotInstances, "invalid-slot");
706
1076
  });
707
1077
  }
708
1078
  function inspectAllow(allow, path, issues, catalogKeys) {
package/dist/index.d.ts CHANGED
@@ -17,6 +17,11 @@ export { resolveInstance, createInstance, checkStoredValue as checkFieldValue }
17
17
  export type { CheckedValue as FieldValueCheck } from "./instances.js";
18
18
  export { RICH_TEXT_FORMATS, richTextToHtml, richTextToPlainText } from "./rich-text.js";
19
19
  export { instantiatePreset, isInsertableOn } from "./composition.js";
20
+ export { COMPONENT_PLACEMENTS, COMPONENT_ROLES, THEME_LIMITS, areaSurface, fixedBlockId } from "./blocks.js";
21
+ export { completePage, resolveSurfaceDocument } from "./surface-document.js";
22
+ export { CONDITION_DEPTH, isFieldVisible } from "./conditions.js";
23
+ export { COLOR_ROLES, COLOR_SCHEMES_MAX, STYLE_BREAKPOINTS, STYLE_PANELS, STYLE_STEPS, STYLE_WIDTHS, THEME_STYLES_PATH, colorSchemeCss, contrastRatio, schemeClass, styleClasses, themeStyleSheet, } from "./style.js";
24
+ export type { SurfaceDocument, SurfaceDocumentReason } from "./surface-document.js";
20
25
  export { checkPageData, duplicateInstance, findInstance, insertInstance, moveInstance, newInstanceId, removeInstance, setInstanceHidden, setInstanceProps, } from "./operations.js";
21
26
  export type { DocumentEdit, DocumentIssue, DocumentIssueCode, InstanceLocation, InstancePlacement, } from "./operations.js";
22
27
  export { THEME_SDK_VERSION } from "./version.js";
@@ -30,4 +35,4 @@ export { diffThemeManifests } from "./manifest-diff.js";
30
35
  export { PREVIEW_PROTOCOL_VERSION, THEME_PREVIEW_PATH, parsePreviewMarker, previewEndMarker, previewStartMarker, } from "./preview-protocol.js";
31
36
  export type { PreviewEditorMessage, PreviewInstance, PreviewMode, PreviewPageMessage, PreviewPatch, PreviewRect, } from "./preview-protocol.js";
32
37
  export type { ManifestChange, ManifestDiff } from "./manifest-diff.js";
33
- export type { JsonValue, JsonObject, LocaleCode, LocalizedText, ResolutionScope, FieldKind, FieldDefinition, FieldValue, InferProps, FieldValues, SlotDefinition, ComponentCapabilities, ComponentPreset, PresetInstance, SurfacePageType, ComponentDefinition, ComponentSpec, SurfaceSpec, ThemeDefinition, SettingsGroup, InferSettings, ThemeSettingsIssue, ThemeSettingsIssueCode, ThemeSettingsResult, NumberFieldDefinition, ColorFieldDefinition, MenuFieldDefinition, CollectionFieldDefinition, ProductFieldDefinition, CategoryFieldDefinition, ImageFieldDefinition, RichTextFieldDefinition, RichTextFormat, ListFieldDefinition, ListItemFieldDefinition, ListItem, MediaReference, ResolvedImage, RichTextValue, RichTextDocument, RichTextBlock, RichTextParagraph, RichTextHeading, RichTextList, RichTextListItem, RichTextInline, RichTextText, RichTextBreak, RichTextMark, ThemeManifest, ThemeManifestIssue, ThemeManifestIssueCode, ThemeManifestResult, ComponentInstance, PageData, ThemeRenderContext, LoadContext, LoadFn, ReadCache, PageDataIssue, PageDataIssueCode, PageDataResult, ResolvedInstance, ThemeDefinitionIssue, ThemeDefinitionIssueCode, ThemeValidationOptions, } from "./types.js";
38
+ export type { JsonValue, JsonObject, LocaleCode, LocalizedText, ResolutionScope, FieldKind, FieldDefinition, FieldValue, InferProps, FieldValues, SlotDefinition, RequiredBlock, ComponentPlacement, ComponentRole, FixedBlockDefinition, Breakpoint, Responsive, StyleStep, StyleWidth, StylePanel, StyleValue, StyleFieldDefinition, ColorRole, ColorScheme, ColorSchemeFieldDefinition, ColorSchemesFieldDefinition, ThemeStyle, ProductListFieldDefinition, CollectionListFieldDefinition, FieldCondition, ConditionValue, ComponentCapabilities, ComponentPreset, PresetInstance, SurfacePageType, ComponentDefinition, ComponentSpec, SurfaceSpec, AreaSpec, ThemeDefinition, SettingsGroup, InferSettings, ThemeSettingsIssue, ThemeSettingsIssueCode, ThemeSettingsResult, NumberFieldDefinition, ColorFieldDefinition, MenuFieldDefinition, CollectionFieldDefinition, ProductFieldDefinition, CategoryFieldDefinition, ImageFieldDefinition, RichTextFieldDefinition, RichTextFormat, ListFieldDefinition, ListItemFieldDefinition, ListItem, MediaReference, ResolvedImage, RichTextValue, RichTextDocument, RichTextBlock, RichTextParagraph, RichTextHeading, RichTextList, RichTextListItem, RichTextInline, RichTextText, RichTextBreak, RichTextMark, ThemeManifest, ThemeManifestIssue, ThemeManifestIssueCode, ThemeManifestResult, ComponentInstance, PageData, ThemeRenderContext, LoadContext, LoadFn, ReadCache, PageDataIssue, PageDataIssueCode, PageDataResult, ResolvedInstance, ThemeDefinitionIssue, ThemeDefinitionIssueCode, ThemeValidationOptions, } from "./types.js";
package/dist/index.js CHANGED
@@ -16,6 +16,10 @@ export { parsePageData, validatePageData } from "./page-data.js";
16
16
  export { resolveInstance, createInstance, checkStoredValue as checkFieldValue } from "./instances.js";
17
17
  export { RICH_TEXT_FORMATS, richTextToHtml, richTextToPlainText } from "./rich-text.js";
18
18
  export { instantiatePreset, isInsertableOn } from "./composition.js";
19
+ export { COMPONENT_PLACEMENTS, COMPONENT_ROLES, THEME_LIMITS, areaSurface, fixedBlockId } from "./blocks.js";
20
+ export { completePage, resolveSurfaceDocument } from "./surface-document.js";
21
+ export { CONDITION_DEPTH, isFieldVisible } from "./conditions.js";
22
+ export { COLOR_ROLES, COLOR_SCHEMES_MAX, STYLE_BREAKPOINTS, STYLE_PANELS, STYLE_STEPS, STYLE_WIDTHS, THEME_STYLES_PATH, colorSchemeCss, contrastRatio, schemeClass, styleClasses, themeStyleSheet, } from "./style.js";
19
23
  export { checkPageData, duplicateInstance, findInstance, insertInstance, moveInstance, newInstanceId, removeInstance, setInstanceHidden, setInstanceProps, } from "./operations.js";
20
24
  export { THEME_SDK_VERSION } from "./version.js";
21
25
  export { resolveThemeSettings, checkThemeSettings } from "./settings.js";
@@ -30,6 +30,10 @@ export declare function resolveField(field: FieldDefinition, raw: unknown, conte
30
30
  * refused, never stripped.
31
31
  */
32
32
  export declare function checkStoredValue(field: FieldDefinition, value: unknown): CheckedValue;
33
+ /** The most handles a `productList` or `collectionList` holds. */
34
+ export declare const HANDLE_LIST_MAX = 50;
35
+ /** Why `value` is not a list of distinct handles within `min` and `max`, or `null`. */
36
+ export declare function handleListProblem(kind: "productList" | "collectionList", min: number | undefined, max: number | undefined, value: unknown): string | null;
33
37
  /** On a grid of `step` from `base`, allowing for binary floating point. */
34
38
  export declare function onStepGrid(value: number, base: number, step: number): boolean;
35
39
  /**