@wocart/theme 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Composition rules a writer applies when it adds a component to a document:
3
+ * where a component may go, and how a preset becomes instances. Pure: no
4
+ * I/O, no randomness — ids come from the caller.
5
+ */
6
+ import { createInstance } from "./instances.js";
7
+ /** Whether a surface key is `entry` itself or one of its alternates (`product.compact` for `product`). */
8
+ export function surfaceCovers(entry, surface) {
9
+ return surface === entry || surface.startsWith(`${entry}.`);
10
+ }
11
+ /**
12
+ * Whether an editor may insert this component on `surface`: it is not marked
13
+ * `insertable: false`, and its `capabilities.surfaces` (absent = any surface)
14
+ * cover the surface. Accepts a definition or its manifest spec.
15
+ */
16
+ export function isInsertableOn(component, surface) {
17
+ const capabilities = component.capabilities;
18
+ if (capabilities?.insertable === false)
19
+ return false;
20
+ const surfaces = capabilities?.surfaces;
21
+ return surfaces === undefined || surfaces.some((entry) => surfaceCovers(entry, surface));
22
+ }
23
+ /**
24
+ * The instances a preset describes: the component with the preset's props
25
+ * over its field defaults (`createInstance`), and every child component the
26
+ * same way, each with an id from `newId`. Throws on what is a programming
27
+ * error — an unknown component or preset, or props its fields refuse — as
28
+ * `createInstance` does; `validateThemeDefinition` reports these at assembly.
29
+ */
30
+ export function instantiatePreset(theme, type, presetKey, newId) {
31
+ const definition = Object.hasOwn(theme.components, type) ? theme.components[type] : undefined;
32
+ if (definition === undefined)
33
+ throw new TypeError(`instantiatePreset: "${type}" is not in the theme catalog`);
34
+ const preset = (definition.presets ?? []).find((candidate) => candidate.key === presetKey);
35
+ if (preset === undefined)
36
+ throw new TypeError(`instantiatePreset: "${type}" has no preset "${presetKey}"`);
37
+ return build(theme, { ...presetBody(preset), type }, newId);
38
+ }
39
+ function presetBody(preset) {
40
+ const body = {};
41
+ if (preset.props !== undefined)
42
+ body.props = preset.props;
43
+ if (preset.children !== undefined)
44
+ body.children = preset.children;
45
+ if (preset.slots !== undefined)
46
+ body.slots = preset.slots;
47
+ return body;
48
+ }
49
+ function build(theme, node, newId) {
50
+ const definition = Object.hasOwn(theme.components, node.type) ? theme.components[node.type] : undefined;
51
+ if (definition === undefined)
52
+ throw new TypeError(`instantiatePreset: "${node.type}" is not in the theme catalog`);
53
+ const instance = createInstance(definition, newId(), node.props ?? {});
54
+ if (node.children !== undefined)
55
+ instance.children = node.children.map((child) => build(theme, child, newId));
56
+ if (node.slots !== undefined) {
57
+ instance.slots = Object.fromEntries(Object.entries(node.slots).map(([name, list]) => [name, list.map((child) => build(theme, child, newId))]));
58
+ }
59
+ return instance;
60
+ }
@@ -4,7 +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 { COLOR_PATTERN, MENU_HANDLE_PATTERN, COLLECTION_HANDLE_PATTERN, SURFACE_KEY_PATTERN, THEME_KEY_PATTERN, TYPE_KEY_PATTERN, isLocaleMap, isLocaleTag, isSafeLinkTarget, } from "./schemas.js";
7
+ import { instantiatePreset } from "./composition.js";
8
+ import { checkStoredValue, onStepGrid } from "./instances.js";
9
+ import { validatePageData } from "./page-data.js";
10
+ import { RICH_TEXT_FORMATS, isRichTextDocumentShape, richTextProblem } from "./rich-text.js";
11
+ 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";
8
12
  export function defineComponent(definition) {
9
13
  return definition;
10
14
  }
@@ -61,7 +65,6 @@ function inspectLocales(locales, issues) {
61
65
  /** Check the whole catalog: key/type identity, field rules, slot rules. */
62
66
  export function validateThemeDefinition(theme, options = {}) {
63
67
  const issues = [];
64
- const contentKeys = options.contentKeys === undefined ? undefined : new Set(options.contentKeys);
65
68
  if (!isRecord(theme)) {
66
69
  addIssue(issues, "", "invalid-theme", "theme must be an object");
67
70
  return issues;
@@ -77,6 +80,12 @@ export function validateThemeDefinition(theme, options = {}) {
77
80
  return issues;
78
81
  }
79
82
  const locales = inspectLocales(theme.locales, issues);
83
+ if (theme.preview !== undefined && typeof theme.preview !== "boolean") {
84
+ addIssue(issues, "preview", "invalid-theme", "`preview` must be a boolean");
85
+ }
86
+ const packKeys = theme.content === undefined ? undefined : inspectContent(theme.content, issues, locales, theme.locales !== undefined);
87
+ // An explicit key set wins; declared packs supply one otherwise.
88
+ const contentKeys = options.contentKeys !== undefined ? new Set(options.contentKeys) : packKeys;
80
89
  // Surfaces are optional theme-level metadata for the serializable manifest
81
90
  // (`themeManifest`). Absent means "declares none"; present entries are
82
91
  // checked so a typo fails loudly at assembly time. Never consulted by page
@@ -93,6 +102,9 @@ export function validateThemeDefinition(theme, options = {}) {
93
102
  contentKeys,
94
103
  catalogKeys: new Set(Object.keys(theme.components)),
95
104
  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")),
96
108
  };
97
109
  if (theme.settings !== undefined)
98
110
  inspectSettings(theme.settings, issues, rules);
@@ -108,8 +120,91 @@ export function validateThemeDefinition(theme, options = {}) {
108
120
  addIssue(issues, `${path}.type`, "type-mismatch", `definition type "${definition.type}" must equal its catalog key "${key}"`);
109
121
  }
110
122
  }
123
+ // Documents and presets are read through the catalog, so they are checked
124
+ // once the catalog itself is sound.
125
+ if (issues.length === 0)
126
+ inspectComposition(theme, issues);
111
127
  return issues;
112
128
  }
129
+ /**
130
+ * The theme's own packs: one per language, each a map of keys to text.
131
+ * Returns the keys every pack has — what a `contentKey` may name.
132
+ */
133
+ function inspectContent(content, issues, locales, localesDeclared) {
134
+ if (!isRecord(content)) {
135
+ addIssue(issues, "content", "invalid-content", "`content` must map language tags to content packs");
136
+ return undefined;
137
+ }
138
+ // An empty map declares no packs, as an absent one does (the manifest normalizes to it).
139
+ if (Object.keys(content).length === 0)
140
+ return undefined;
141
+ let common;
142
+ for (const [locale, pack] of Object.entries(content)) {
143
+ const path = `content.${locale}`;
144
+ if (!isLocaleTag(locale)) {
145
+ addIssue(issues, path, "invalid-content", `${JSON.stringify(locale)} is not a BCP 47 language tag in canonical case`);
146
+ continue;
147
+ }
148
+ if (localesDeclared && !locales.includes(locale)) {
149
+ addIssue(issues, path, "invalid-content", `"${locale}" is not one of the theme's languages`);
150
+ }
151
+ if (!isRecord(pack)) {
152
+ addIssue(issues, path, "invalid-content", "a content pack must map keys to text");
153
+ continue;
154
+ }
155
+ for (const [key, text] of Object.entries(pack)) {
156
+ if (key.length === 0 || key === "__proto__" || typeof text !== "string") {
157
+ addIssue(issues, `${path}.${key}`, "invalid-content", "a content pack maps non-empty keys to strings");
158
+ }
159
+ }
160
+ const keys = new Set(Object.keys(pack));
161
+ common = common === undefined ? keys : new Set([...common].filter((key) => keys.has(key)));
162
+ }
163
+ for (const locale of locales) {
164
+ if (!Object.hasOwn(content, locale)) {
165
+ addIssue(issues, `content.${locale}`, "invalid-content", `the theme declares "${locale}" but ships no content pack for it`);
166
+ }
167
+ }
168
+ return common;
169
+ }
170
+ /** Default documents and presets, read through the (sound) catalog as a writer would. */
171
+ function inspectComposition(theme, issues) {
172
+ (theme.surfaces ?? []).forEach((surface, index) => {
173
+ if (surface.document === undefined)
174
+ 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
+ }
180
+ 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")
183
+ continue;
184
+ addIssue(issues, issue.path === "" ? path : `${path}.${issue.path}`, "invalid-document", issue.message);
185
+ }
186
+ });
187
+ for (const [type, definition] of Object.entries(theme.components)) {
188
+ (definition.presets ?? []).forEach((preset, index) => {
189
+ const path = `components.${type}.presets[${index}]`;
190
+ let counter = 0;
191
+ let instance;
192
+ try {
193
+ instance = instantiatePreset(theme, type, preset.key, () => `preset-${(counter += 1)}`);
194
+ }
195
+ catch (error) {
196
+ addIssue(issues, path, "invalid-preset", error instanceof Error ? error.message : String(error));
197
+ return;
198
+ }
199
+ const checked = validatePageData(theme, { version: 1, surface: "preset", sections: [instance] });
200
+ for (const issue of checked.issues) {
201
+ if (issue.code === "missing-required-prop")
202
+ continue;
203
+ addIssue(issues, `${path}.${issue.path.replace(/^sections\[0\]\.?/, "")}`.replace(/\.$/, ""), "invalid-preset", issue.message);
204
+ }
205
+ });
206
+ }
207
+ }
113
208
  /**
114
209
  * The theme's settings groups: an array of `{ key, label, fields }`, group keys
115
210
  * unique and kebab-case, fields checked like a component's, and every setting
@@ -177,6 +272,22 @@ function inspectSurfaces(surfaces, issues, locales) {
177
272
  if (labelProblem !== null) {
178
273
  addIssue(issues, `${surfacePath}.label`, "invalid-theme", `\`label\` ${labelProblem}`);
179
274
  }
275
+ if (surface.pageType !== undefined && !PAGE_TYPES.includes(surface.pageType)) {
276
+ addIssue(issues, `${surfacePath}.pageType`, "invalid-theme", `\`pageType\` must be one of ${PAGE_TYPES.join(", ")}`);
277
+ }
278
+ if (surface.document !== undefined && !isRecord(surface.document)) {
279
+ addIssue(issues, `${surfacePath}.document`, "invalid-document", "`document` must be a page document");
280
+ }
281
+ if (typeof surface.key === "string" && surface.key.includes(".")) {
282
+ const baseKey = surface.key.slice(0, surface.key.indexOf("."));
283
+ const base = surfaces.find((candidate) => isRecord(candidate) && candidate.key === baseKey);
284
+ if (!isRecord(base)) {
285
+ addIssue(issues, `${surfacePath}.key`, "invalid-theme", `"${surface.key}" is an alternate of "${baseKey}", which the theme does not declare`);
286
+ }
287
+ else if (base.pageType !== surface.pageType) {
288
+ addIssue(issues, `${surfacePath}.pageType`, "invalid-theme", `an alternate renders its base's page type (${JSON.stringify(base.pageType ?? null)})`);
289
+ }
290
+ }
180
291
  });
181
292
  }
182
293
  function inspectDefinition(definition, path, issues, rules) {
@@ -217,11 +328,31 @@ function inspectDefinition(definition, path, issues, rules) {
217
328
  if (!isRecord(definition.capabilities)) {
218
329
  addIssue(issues, `${path}.capabilities`, "invalid-capability", "`capabilities` must be an object");
219
330
  }
220
- else if (definition.capabilities.insertable !== undefined &&
221
- typeof definition.capabilities.insertable !== "boolean") {
222
- addIssue(issues, `${path}.capabilities.insertable`, "invalid-capability", "`insertable` must be a boolean");
331
+ else {
332
+ if (definition.capabilities.insertable !== undefined &&
333
+ typeof definition.capabilities.insertable !== "boolean") {
334
+ addIssue(issues, `${path}.capabilities.insertable`, "invalid-capability", "`insertable` must be a boolean");
335
+ }
336
+ const surfaces = definition.capabilities.surfaces;
337
+ if (surfaces !== undefined) {
338
+ if (!Array.isArray(surfaces) || surfaces.length === 0) {
339
+ addIssue(issues, `${path}.capabilities.surfaces`, "invalid-capability", "`surfaces` must be a non-empty array of surface keys (omit it for any surface)");
340
+ }
341
+ else {
342
+ surfaces.forEach((surface, index) => {
343
+ if (typeof surface !== "string" || !rules.surfaceKeys.has(surface)) {
344
+ addIssue(issues, `${path}.capabilities.surfaces[${index}]`, "invalid-capability", `${JSON.stringify(surface)} is not a surface this theme declares`);
345
+ }
346
+ else if (surfaces.indexOf(surface) !== index) {
347
+ addIssue(issues, `${path}.capabilities.surfaces[${index}]`, "invalid-capability", `duplicate surface "${surface}"`);
348
+ }
349
+ });
350
+ }
351
+ }
223
352
  }
224
353
  }
354
+ if (definition.presets !== undefined)
355
+ inspectPresets(definition.presets, path, issues, rules);
225
356
  if (definition.fields !== undefined) {
226
357
  if (!Array.isArray(definition.fields)) {
227
358
  addIssue(issues, `${path}.fields`, "invalid-field", "`fields` must be an array");
@@ -239,7 +370,7 @@ function inspectDefinition(definition, path, issues, rules) {
239
370
  }
240
371
  }
241
372
  }
242
- function inspectFields(fields, path, issues, rules) {
373
+ function inspectFields(fields, path, issues, rules, { inList = false } = {}) {
243
374
  const { contentKeys, locales } = rules;
244
375
  const seenKeys = new Set();
245
376
  fields.forEach((field, index) => {
@@ -248,6 +379,17 @@ function inspectFields(fields, path, issues, rules) {
248
379
  addIssue(issues, fieldPath, "invalid-field", "field must be an object");
249
380
  return;
250
381
  }
382
+ if (inList) {
383
+ if (field.kind === "list") {
384
+ addIssue(issues, `${fieldPath}.kind`, "invalid-field", "a list item cannot hold another list");
385
+ }
386
+ if (field.key === "id") {
387
+ addIssue(issues, `${fieldPath}.key`, "invalid-field", "`id` cannot be a list item field: it is the item's own identity");
388
+ }
389
+ if (field.contentKey !== undefined) {
390
+ addIssue(issues, `${fieldPath}.contentKey`, "invalid-field", "a list item field cannot default from the content pack: every item would read the same key");
391
+ }
392
+ }
251
393
  if (typeof field.key !== "string" || field.key.length === 0) {
252
394
  addIssue(issues, `${fieldPath}.key`, "invalid-field", "`key` must be a non-empty string");
253
395
  }
@@ -311,6 +453,9 @@ function inspectFields(fields, path, issues, rules) {
311
453
  field.min > field.max) {
312
454
  addIssue(issues, fieldPath, "invalid-field", "`min` must be <= `max`");
313
455
  }
456
+ if (field.step !== undefined && !(typeof field.step === "number" && Number.isFinite(field.step) && field.step > 0)) {
457
+ addIssue(issues, `${fieldPath}.step`, "invalid-field", "`step` must be a finite number greater than 0");
458
+ }
314
459
  // A default outside the declared bounds would sail through assembly
315
460
  // and then behave incoherently at runtime (`resolveInstance` trusts
316
461
  // authored defaults while rejecting the same out-of-range instance
@@ -323,6 +468,10 @@ function inspectFields(fields, path, issues, rules) {
323
468
  if (typeof field.max === "number" && field.default > field.max) {
324
469
  addIssue(issues, `${fieldPath}.default`, "invalid-field", `\`default\` must be <= ${field.max}`);
325
470
  }
471
+ const base = typeof field.min === "number" ? field.min : 0;
472
+ if (typeof field.step === "number" && field.step > 0 && !onStepGrid(field.default, base, field.step)) {
473
+ addIssue(issues, `${fieldPath}.default`, "invalid-field", `\`default\` must be a multiple of ${field.step} from ${base}`);
474
+ }
326
475
  }
327
476
  break;
328
477
  }
@@ -343,6 +492,22 @@ function inspectFields(fields, path, issues, rules) {
343
492
  (typeof field.default !== "string" || !options.includes(field.default))) {
344
493
  addIssue(issues, `${fieldPath}.default`, "invalid-field", "`default` must be one of `options`");
345
494
  }
495
+ if (field.optionLabels !== undefined) {
496
+ if (!isRecord(field.optionLabels)) {
497
+ addIssue(issues, `${fieldPath}.optionLabels`, "invalid-field", "`optionLabels` must map options to labels");
498
+ }
499
+ else {
500
+ for (const [option, label] of Object.entries(field.optionLabels)) {
501
+ if (!Array.isArray(options) || !options.includes(option)) {
502
+ addIssue(issues, `${fieldPath}.optionLabels.${option}`, "invalid-field", `"${option}" is not one of \`options\``);
503
+ continue;
504
+ }
505
+ const problem = localizedTextProblem(label, locales, { allowEmpty: false });
506
+ if (problem !== null)
507
+ addIssue(issues, `${fieldPath}.optionLabels.${option}`, "invalid-field", `the label ${problem}`);
508
+ }
509
+ }
510
+ }
346
511
  break;
347
512
  }
348
513
  case "color": {
@@ -363,6 +528,28 @@ function inspectFields(fields, path, issues, rules) {
363
528
  }
364
529
  break;
365
530
  }
531
+ case "product":
532
+ case "category": {
533
+ if (field.default !== undefined && (typeof field.default !== "string" || !RESOURCE_HANDLE_PATTERN.test(field.default))) {
534
+ addIssue(issues, `${fieldPath}.default`, "invalid-field", `\`default\` must be a ${field.kind === "product" ? "product handle" : "category slug"}: no spaces, slashes, \`?\`, \`#\` or \`%\``);
535
+ }
536
+ break;
537
+ }
538
+ case "image": {
539
+ if (field.default !== undefined) {
540
+ addIssue(issues, `${fieldPath}.default`, "invalid-field", "an image field has no default: a theme cannot know a store's media");
541
+ }
542
+ break;
543
+ }
544
+ case "richtext": {
545
+ inspectRichTextField(field, fieldPath, issues, locales);
546
+ break;
547
+ }
548
+ case "list": {
549
+ if (!inList)
550
+ inspectListField(field, fieldPath, issues, rules);
551
+ break;
552
+ }
366
553
  case "url": {
367
554
  if (field.default !== undefined &&
368
555
  (typeof field.default !== "string" || !isSafeLinkTarget(field.default))) {
@@ -375,6 +562,118 @@ function inspectFields(fields, path, issues, rules) {
375
562
  }
376
563
  });
377
564
  }
565
+ /** Preset shapes; what they build is checked once the catalog is sound (`inspectComposition`). */
566
+ function inspectPresets(presets, path, issues, rules) {
567
+ if (!Array.isArray(presets)) {
568
+ addIssue(issues, `${path}.presets`, "invalid-preset", "`presets` must be an array");
569
+ return;
570
+ }
571
+ const keys = new Set();
572
+ presets.forEach((preset, index) => {
573
+ const presetPath = `${path}.presets[${index}]`;
574
+ if (!isRecord(preset)) {
575
+ addIssue(issues, presetPath, "invalid-preset", "a preset must be an object");
576
+ return;
577
+ }
578
+ if (typeof preset.key !== "string" || !TYPE_KEY_PATTERN.test(preset.key)) {
579
+ addIssue(issues, `${presetPath}.key`, "invalid-preset", "`key` must be lowercase kebab-case");
580
+ }
581
+ else if (keys.has(preset.key)) {
582
+ addIssue(issues, `${presetPath}.key`, "invalid-preset", `duplicate preset "${preset.key}"`);
583
+ }
584
+ else {
585
+ keys.add(preset.key);
586
+ }
587
+ const labelProblem = localizedTextProblem(preset.label, rules.locales, { allowEmpty: false });
588
+ if (labelProblem !== null)
589
+ addIssue(issues, `${presetPath}.label`, "invalid-preset", `\`label\` ${labelProblem}`);
590
+ if (preset.description !== undefined) {
591
+ const problem = localizedTextProblem(preset.description, rules.locales, { allowEmpty: false });
592
+ if (problem !== null)
593
+ addIssue(issues, `${presetPath}.description`, "invalid-preset", `\`description\` ${problem}`);
594
+ }
595
+ if (preset.props !== undefined && !isRecord(preset.props)) {
596
+ addIssue(issues, `${presetPath}.props`, "invalid-preset", "`props` must be an object");
597
+ }
598
+ });
599
+ }
600
+ function inspectRichTextField(field, fieldPath, issues, locales) {
601
+ if (field.contentKey !== undefined) {
602
+ addIssue(issues, `${fieldPath}.contentKey`, "invalid-field", "a rich-text field cannot default from the content pack, which holds plain text");
603
+ }
604
+ let formats;
605
+ if (field.formats !== undefined) {
606
+ const declared = field.formats;
607
+ if (!Array.isArray(declared) || declared.length === 0) {
608
+ addIssue(issues, `${fieldPath}.formats`, "invalid-field", "`formats` must be a non-empty array (omit it to allow every format)");
609
+ }
610
+ else {
611
+ const seen = new Set();
612
+ declared.forEach((format, index) => {
613
+ if (!RICH_TEXT_FORMATS.includes(format)) {
614
+ addIssue(issues, `${fieldPath}.formats[${index}]`, "invalid-field", `unknown rich-text format ${JSON.stringify(format)}; known: ${RICH_TEXT_FORMATS.join(", ")}`);
615
+ }
616
+ else if (seen.has(format)) {
617
+ addIssue(issues, `${fieldPath}.formats[${index}]`, "invalid-field", `duplicate format "${String(format)}"`);
618
+ }
619
+ seen.add(format);
620
+ });
621
+ formats = declared.filter((format) => RICH_TEXT_FORMATS.includes(format));
622
+ }
623
+ }
624
+ if (field.default === undefined)
625
+ return;
626
+ const value = field.default;
627
+ if (isRichTextDocumentShape(value)) {
628
+ const problem = richTextProblem(value, formats);
629
+ if (problem !== null)
630
+ addIssue(issues, `${fieldPath}.default`, "invalid-field", `\`default\` ${problem}`);
631
+ return;
632
+ }
633
+ if (!isLocaleMapOf(value, isRecord)) {
634
+ addIssue(issues, `${fieldPath}.default`, "invalid-field", "`default` must be a rich-text document or a map from language tags to documents");
635
+ return;
636
+ }
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}`);
641
+ }
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(", ")}`);
645
+ }
646
+ }
647
+ function inspectListField(field, fieldPath, issues, rules) {
648
+ const itemFields = field.fields;
649
+ if (!Array.isArray(itemFields) || itemFields.length === 0) {
650
+ addIssue(issues, `${fieldPath}.fields`, "invalid-field", "`fields` must be a non-empty array of item fields");
651
+ return;
652
+ }
653
+ const before = issues.length;
654
+ inspectFields(itemFields, fieldPath, issues, rules, { inList: true });
655
+ const itemFieldsValid = issues.length === before;
656
+ for (const bound of ["min", "max"]) {
657
+ const value = field[bound];
658
+ if (value !== undefined && !(typeof value === "number" && Number.isInteger(value) && value >= (bound === "max" ? 1 : 0))) {
659
+ addIssue(issues, `${fieldPath}.${bound}`, "invalid-field", `\`${bound}\` must be a whole number${bound === "max" ? " of at least 1" : " of at least 0"}`);
660
+ }
661
+ }
662
+ if (typeof field.min === "number" && typeof field.max === "number" && field.min > field.max) {
663
+ addIssue(issues, fieldPath, "invalid-field", "`min` must be <= `max`");
664
+ }
665
+ if (field.itemLabel !== undefined) {
666
+ const labelField = itemFields.find((itemField) => isRecord(itemField) && itemField.key === field.itemLabel);
667
+ if (!isRecord(labelField) || (labelField.kind !== "text" && labelField.kind !== "textarea")) {
668
+ addIssue(issues, `${fieldPath}.itemLabel`, "invalid-field", "`itemLabel` must name one of the item's `text` or `textarea` fields");
669
+ }
670
+ }
671
+ if (field.default !== undefined && itemFieldsValid) {
672
+ const checked = checkStoredValue(field, field.default);
673
+ if (!checked.ok)
674
+ addIssue(issues, `${fieldPath}.default`, "invalid-field", `\`default\` ${checked.message}`);
675
+ }
676
+ }
378
677
  function inspectSlots(slots, path, issues, catalogKeys) {
379
678
  const seenNames = new Set();
380
679
  slots.forEach((slot, index) => {
package/dist/index.d.ts CHANGED
@@ -3,18 +3,31 @@
3
3
  *
4
4
  * Code-owned: `defineComponent` / `defineTheme` / `validateThemeDefinition`.
5
5
  * Data-owned: `parsePageData` / `validatePageData` / `resolveInstance` /
6
- * `createInstance` (+ schemas).
7
- * Serializable: `themeManifest` / `validateThemeManifest`.
6
+ * `createInstance` / `checkFieldValue`, and the document operations a writer
7
+ * applies (`insertInstance`, …, `checkPageData`).
8
+ * Serializable: `themeManifest` / `validateThemeManifest` /
9
+ * `manifestCatalog` / `diffThemeManifests`.
8
10
  *
9
11
  * This entry is framework-neutral. The reference Astro renderer lives in the
10
12
  * `./astro` subpath.
11
13
  */
12
14
  export { defineComponent, defineTheme, validateThemeDefinition } from "./definitions.js";
13
15
  export { parsePageData, validatePageData } from "./page-data.js";
14
- export { resolveInstance, createInstance } from "./instances.js";
16
+ export { resolveInstance, createInstance, checkStoredValue as checkFieldValue } from "./instances.js";
17
+ export type { CheckedValue as FieldValueCheck } from "./instances.js";
18
+ export { RICH_TEXT_FORMATS, richTextToHtml, richTextToPlainText } from "./rich-text.js";
19
+ export { instantiatePreset, isInsertableOn } from "./composition.js";
20
+ export { checkPageData, duplicateInstance, findInstance, insertInstance, moveInstance, newInstanceId, removeInstance, setInstanceHidden, setInstanceProps, } from "./operations.js";
21
+ export type { DocumentEdit, DocumentIssue, DocumentIssueCode, InstanceLocation, InstancePlacement, } from "./operations.js";
22
+ export { THEME_SDK_VERSION } from "./version.js";
23
+ export type { RichTextHtmlOptions } from "./rich-text.js";
15
24
  export { resolveThemeSettings, checkThemeSettings } from "./settings.js";
16
25
  export { localize } from "./locales.js";
17
26
  export { PLURAL_CATEGORIES, formatMessage, messageForms, pluralFormIssues } from "./messages.js";
18
27
  export type { MessageOptions, MessageValues, PluralCategory } from "./messages.js";
19
- export { THEME_MANIFEST_CONTRACT_VERSION, themeManifest, validateThemeManifest, } from "./manifest.js";
20
- export type { JsonValue, JsonObject, LocaleCode, LocalizedText, ResolutionScope, FieldKind, FieldDefinition, FieldValue, InferProps, FieldValues, SlotDefinition, ComponentCapabilities, ComponentDefinition, ComponentSpec, SurfaceSpec, ThemeDefinition, SettingsGroup, InferSettings, ThemeSettingsIssue, ThemeSettingsIssueCode, ThemeSettingsResult, ColorFieldDefinition, MenuFieldDefinition, CollectionFieldDefinition, ThemeManifest, ThemeManifestIssue, ThemeManifestIssueCode, ThemeManifestResult, ComponentInstance, PageData, ThemeRenderContext, LoadContext, LoadFn, ReadCache, PageDataIssue, PageDataIssueCode, PageDataResult, ResolvedInstance, ThemeDefinitionIssue, ThemeDefinitionIssueCode, ThemeValidationOptions, } from "./types.js";
28
+ export { THEME_MANIFEST_CONTRACT_VERSION, THEME_MANIFEST_PATH, manifestCatalog, themeManifest, validateThemeManifest, } from "./manifest.js";
29
+ export { diffThemeManifests } from "./manifest-diff.js";
30
+ export { PREVIEW_PROTOCOL_VERSION, THEME_PREVIEW_PATH, parsePreviewMarker, previewEndMarker, previewStartMarker, } from "./preview-protocol.js";
31
+ export type { PreviewEditorMessage, PreviewInstance, PreviewMode, PreviewPageMessage, PreviewPatch, PreviewRect, } from "./preview-protocol.js";
32
+ 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";
package/dist/index.js CHANGED
@@ -3,16 +3,24 @@
3
3
  *
4
4
  * Code-owned: `defineComponent` / `defineTheme` / `validateThemeDefinition`.
5
5
  * Data-owned: `parsePageData` / `validatePageData` / `resolveInstance` /
6
- * `createInstance` (+ schemas).
7
- * Serializable: `themeManifest` / `validateThemeManifest`.
6
+ * `createInstance` / `checkFieldValue`, and the document operations a writer
7
+ * applies (`insertInstance`, …, `checkPageData`).
8
+ * Serializable: `themeManifest` / `validateThemeManifest` /
9
+ * `manifestCatalog` / `diffThemeManifests`.
8
10
  *
9
11
  * This entry is framework-neutral. The reference Astro renderer lives in the
10
12
  * `./astro` subpath.
11
13
  */
12
14
  export { defineComponent, defineTheme, validateThemeDefinition } from "./definitions.js";
13
15
  export { parsePageData, validatePageData } from "./page-data.js";
14
- export { resolveInstance, createInstance } from "./instances.js";
16
+ export { resolveInstance, createInstance, checkStoredValue as checkFieldValue } from "./instances.js";
17
+ export { RICH_TEXT_FORMATS, richTextToHtml, richTextToPlainText } from "./rich-text.js";
18
+ export { instantiatePreset, isInsertableOn } from "./composition.js";
19
+ export { checkPageData, duplicateInstance, findInstance, insertInstance, moveInstance, newInstanceId, removeInstance, setInstanceHidden, setInstanceProps, } from "./operations.js";
20
+ export { THEME_SDK_VERSION } from "./version.js";
15
21
  export { resolveThemeSettings, checkThemeSettings } from "./settings.js";
16
22
  export { localize } from "./locales.js";
17
23
  export { PLURAL_CATEGORIES, formatMessage, messageForms, pluralFormIssues } from "./messages.js";
18
- export { THEME_MANIFEST_CONTRACT_VERSION, themeManifest, validateThemeManifest, } from "./manifest.js";
24
+ export { THEME_MANIFEST_CONTRACT_VERSION, THEME_MANIFEST_PATH, manifestCatalog, themeManifest, validateThemeManifest, } from "./manifest.js";
25
+ export { diffThemeManifests } from "./manifest-diff.js";
26
+ export { PREVIEW_PROTOCOL_VERSION, THEME_PREVIEW_PATH, parsePreviewMarker, previewEndMarker, previewStartMarker, } from "./preview-protocol.js";
@@ -12,6 +12,8 @@ export type CheckedValue = {
12
12
  ok: false;
13
13
  message: string;
14
14
  };
15
+ /** Whether a field shows something with no stored value: a default, or content-pack text. */
16
+ export declare function hasFallback(field: FieldDefinition): boolean;
15
17
  /** An own property only: `constructor` or `toString` must never read the prototype. */
16
18
  export declare function ownValue<T>(record: Record<string, T>, key: string): T | undefined;
17
19
  /**
@@ -23,9 +25,13 @@ export declare function resolveField(field: FieldDefinition, raw: unknown, conte
23
25
  /**
24
26
  * What a writer may store: a value that passes the field's rules, or — for a
25
27
  * translatable field — a per-language map whose every entry passes them, so no
26
- * language can hold an unsafe link or a wrong type.
28
+ * language can hold an unsafe link or a wrong type. Stricter than reading: an
29
+ * image reference or list item with keys the contract does not define is
30
+ * refused, never stripped.
27
31
  */
28
32
  export declare function checkStoredValue(field: FieldDefinition, value: unknown): CheckedValue;
33
+ /** On a grid of `step` from `base`, allowing for binary floating point. */
34
+ export declare function onStepGrid(value: number, base: number, step: number): boolean;
29
35
  /**
30
36
  * Create a new instance with insertion-time defaults:
31
37
  * materialises `field.default` values only — content-keyed fields stay unset