@wocart/theme 1.3.0 → 1.5.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/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
package/dist/instances.js CHANGED
@@ -9,9 +9,14 @@
9
9
  * default applies last).
10
10
  */
11
11
  import { localize } from "./locales.js";
12
- import { COLLECTION_HANDLE_PATTERN, COLOR_PATTERN, MENU_HANDLE_PATTERN, isLocaleMap, isSafeLinkTarget } from "./schemas.js";
13
- /** Field kinds whose values may differ per language. Numbers, toggles and choices are shared. */
14
- const TRANSLATABLE_KINDS = new Set(["text", "textarea", "url"]);
12
+ import { isRichTextDocumentShape, richTextProblem } from "./rich-text.js";
13
+ import { COLLECTION_HANDLE_PATTERN, COLOR_PATTERN, MEDIA_KEY_PATTERN, MENU_HANDLE_PATTERN, RESOURCE_HANDLE_PATTERN, isLocaleMap, isLocaleMapOf, isSafeLinkTarget, } from "./schemas.js";
14
+ /**
15
+ * Field kinds whose values may differ per language. Numbers, toggles, choices,
16
+ * references and images are shared (an image's alt text is per language).
17
+ */
18
+ const TRANSLATABLE_KINDS = new Set(["text", "textarea", "url", "richtext"]);
19
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
15
20
  const isTranslatable = (field) => TRANSLATABLE_KINDS.has(field.kind);
16
21
  /**
17
22
  * Gate one instance for rendering: field values through the chain, children
@@ -81,16 +86,39 @@ export function resolveInstance(definition, instance, scope = {}) {
81
86
  }
82
87
  return { props, children, slots, issues };
83
88
  }
89
+ /** Whether a field shows something with no stored value: a default, or content-pack text. */
90
+ export function hasFallback(field) {
91
+ if ((field.kind === "text" || field.kind === "textarea") && field.contentKey !== undefined)
92
+ return true;
93
+ return "default" in field && field.default !== undefined;
94
+ }
84
95
  /** An own property only: `constructor` or `toString` must never read the prototype. */
85
96
  export function ownValue(record, key) {
86
97
  return Object.hasOwn(record, key) ? record[key] : undefined;
87
98
  }
99
+ /** The entry for the first language in `locales` that `map` has. */
100
+ function pickLocale(map, locales) {
101
+ for (const locale of locales) {
102
+ if (Object.hasOwn(map, locale))
103
+ return map[locale];
104
+ }
105
+ return undefined;
106
+ }
88
107
  /**
89
108
  * The value an instance stores for this field, in the preferred language. A
90
109
  * per-language map without an entry for any preferred language is not an
91
110
  * error — the chain moves on to the pack and the default.
92
111
  */
93
112
  function storedValueFor(field, raw, locales) {
113
+ if (field.kind === "richtext") {
114
+ if (isRichTextDocumentShape(raw))
115
+ return { kind: "value", value: raw };
116
+ if (!isLocaleMapOf(raw, isRecord)) {
117
+ return { kind: "invalid", message: "must be a rich-text document or a map from language tags to documents" };
118
+ }
119
+ const picked = pickLocale(raw, locales);
120
+ return picked === undefined ? { kind: "untranslated" } : { kind: "value", value: picked };
121
+ }
94
122
  if (!isTranslatable(field) || typeof raw !== "object" || raw === null || Array.isArray(raw)) {
95
123
  return { kind: "value", value: raw };
96
124
  }
@@ -109,15 +137,18 @@ function storedValueFor(field, raw, locales) {
109
137
  * `settings`.
110
138
  */
111
139
  export function resolveField(field, raw, content, locales, issues, prefix) {
140
+ if (field.kind === "list") {
141
+ return resolveListField(field, raw, content, locales, issues, prefix);
142
+ }
112
143
  if (raw !== undefined) {
113
144
  const stored = storedValueFor(field, raw, locales);
114
145
  const checked = stored.kind === "value"
115
- ? checkFieldValue(field, stored.value)
146
+ ? checkKindValue(field, stored.value, "read")
116
147
  : stored.kind === "invalid"
117
148
  ? { ok: false, message: stored.message }
118
149
  : undefined;
119
150
  if (checked?.ok) {
120
- return checked.value;
151
+ return presentValue(field, checked.value, locales);
121
152
  }
122
153
  if (checked !== undefined) {
123
154
  issues.push({
@@ -136,10 +167,14 @@ export function resolveField(field, raw, content, locales, issues, prefix) {
136
167
  }
137
168
  }
138
169
  }
139
- if (field.default !== undefined) {
170
+ if (field.kind !== "image" && field.default !== undefined) {
140
171
  const fallback = field.kind === "text" || field.kind === "textarea"
141
172
  ? localize(field.default, locales)
142
- : field.default;
173
+ : field.kind === "richtext"
174
+ ? isRichTextDocumentShape(field.default)
175
+ ? field.default
176
+ : pickLocale(field.default, locales)
177
+ : field.default;
143
178
  if (fallback !== undefined) {
144
179
  return fallback;
145
180
  }
@@ -153,27 +188,209 @@ export function resolveField(field, raw, content, locales, issues, prefix) {
153
188
  }
154
189
  return undefined;
155
190
  }
191
+ /** A checked value as the component receives it: an image's alt in the rendered language. */
192
+ function presentValue(field, value, locales) {
193
+ if (field.kind !== "image")
194
+ return value;
195
+ const reference = value;
196
+ const image = { key: reference.key };
197
+ if (reference.alt !== undefined) {
198
+ const alt = typeof reference.alt === "string" ? reference.alt : localize(reference.alt, locales);
199
+ if (alt !== undefined)
200
+ image.alt = alt;
201
+ }
202
+ if (reference.width !== undefined)
203
+ image.width = reference.width;
204
+ if (reference.height !== undefined)
205
+ image.height = reference.height;
206
+ if (reference.focal !== undefined) {
207
+ const focal = reference.focal;
208
+ image.focal = { x: focal.x, y: focal.y };
209
+ }
210
+ return image;
211
+ }
212
+ /**
213
+ * A list through the chain: the stored items when the value is a list (each
214
+ * item checked on its own, so one bad item never costs the others), else the
215
+ * default items.
216
+ */
217
+ function resolveListField(field, raw, content, locales, issues, prefix) {
218
+ const path = `${prefix}.${field.key}`;
219
+ if (raw !== undefined) {
220
+ if (Array.isArray(raw))
221
+ return resolveListItems(field, raw, content, locales, issues, path);
222
+ issues.push({ path, code: "invalid-prop", message: `"${field.key}" must be a list of items; treated as absent` });
223
+ }
224
+ if (field.default !== undefined) {
225
+ // Defaults are checked when the theme is assembled; their resolution reports nothing.
226
+ return resolveListItems(field, field.default, content, locales, [], path);
227
+ }
228
+ if (field.required === true) {
229
+ issues.push({ path, code: "missing-required-prop", message: `required field "${field.key}" has no value` });
230
+ }
231
+ return undefined;
232
+ }
233
+ function resolveListItems(field, raw, content, locales, issues, path) {
234
+ const declared = new Set(field.fields.map((itemField) => itemField.key));
235
+ const seenIds = new Set();
236
+ const items = [];
237
+ raw.forEach((item, index) => {
238
+ const itemPath = `${path}[${index}]`;
239
+ if (!isRecord(item) || typeof item.id !== "string" || item.id.length === 0) {
240
+ issues.push({ path: itemPath, code: "invalid-prop", message: "a list item must be an object with a non-empty string `id`; dropped" });
241
+ return;
242
+ }
243
+ if (seenIds.has(item.id)) {
244
+ issues.push({ path: `${itemPath}.id`, code: "duplicate-id", message: `duplicate list item id "${item.id}"; the later item is dropped` });
245
+ return;
246
+ }
247
+ seenIds.add(item.id);
248
+ let props = {};
249
+ if (isRecord(item.props)) {
250
+ props = item.props;
251
+ }
252
+ else if (item.props !== undefined) {
253
+ issues.push({ path: `${itemPath}.props`, code: "invalid-prop", message: "`props` must be an object; the item's fields fall back to their defaults" });
254
+ }
255
+ for (const key of Object.keys(props)) {
256
+ if (!declared.has(key)) {
257
+ issues.push({ path: `${itemPath}.props.${key}`, code: "unknown-prop", message: `"${key}" is not a declared field of the "${field.key}" items` });
258
+ }
259
+ }
260
+ const resolved = { id: item.id };
261
+ for (const itemField of field.fields) {
262
+ if (itemField.key === "id")
263
+ continue;
264
+ const value = resolveField(itemField, ownValue(props, itemField.key), content, locales, issues, `${itemPath}.props`);
265
+ if (value !== undefined)
266
+ resolved[itemField.key] = value;
267
+ }
268
+ items.push(resolved);
269
+ });
270
+ if (field.max !== undefined && items.length > field.max) {
271
+ issues.push({ path, code: "invalid-prop", message: `"${field.key}" has ${items.length} items; only the first ${field.max} are kept` });
272
+ items.length = field.max;
273
+ }
274
+ if (field.min !== undefined && items.length < field.min) {
275
+ issues.push({ path, code: "invalid-prop", message: `"${field.key}" has ${items.length} items; at least ${field.min} expected` });
276
+ }
277
+ return items;
278
+ }
156
279
  /**
157
280
  * What a writer may store: a value that passes the field's rules, or — for a
158
281
  * translatable field — a per-language map whose every entry passes them, so no
159
- * language can hold an unsafe link or a wrong type.
282
+ * language can hold an unsafe link or a wrong type. Stricter than reading: an
283
+ * image reference or list item with keys the contract does not define is
284
+ * refused, never stripped.
160
285
  */
161
286
  export function checkStoredValue(field, value) {
287
+ if (field.kind === "list")
288
+ return checkStoredList(field, value);
289
+ if (field.kind === "richtext") {
290
+ if (isRichTextDocumentShape(value))
291
+ return checkKindValue(field, value, "write");
292
+ if (!isLocaleMapOf(value, isRecord)) {
293
+ return { ok: false, message: "must be a rich-text document or a map from language tags to documents" };
294
+ }
295
+ for (const [locale, document] of Object.entries(value)) {
296
+ const checked = checkKindValue(field, document, "write");
297
+ if (!checked.ok)
298
+ return { ok: false, message: `(${locale}) ${checked.message}` };
299
+ }
300
+ return { ok: true, value };
301
+ }
162
302
  if (!isTranslatable(field) || typeof value !== "object" || value === null || Array.isArray(value)) {
163
- return checkFieldValue(field, value);
303
+ return checkKindValue(field, value, "write");
164
304
  }
165
305
  if (!isLocaleMap(value)) {
166
306
  return { ok: false, message: "must be a string or a map from language tags to strings" };
167
307
  }
168
308
  for (const [locale, text] of Object.entries(value)) {
169
- const checked = checkFieldValue(field, text);
309
+ const checked = checkKindValue(field, text, "write");
170
310
  if (!checked.ok)
171
311
  return { ok: false, message: `(${locale}) ${checked.message}` };
172
312
  }
173
313
  return { ok: true, value };
174
314
  }
175
- /** Kind check shared by resolution and creation. */
176
- function checkFieldValue(field, value) {
315
+ function checkStoredList(field, value) {
316
+ if (!Array.isArray(value))
317
+ return { ok: false, message: "must be a list of items" };
318
+ if (field.min !== undefined && value.length < field.min)
319
+ return { ok: false, message: `must have at least ${field.min} items` };
320
+ if (field.max !== undefined && value.length > field.max)
321
+ return { ok: false, message: `must have at most ${field.max} items` };
322
+ const itemFields = new Map(field.fields.map((itemField) => [itemField.key, itemField]));
323
+ const seenIds = new Set();
324
+ for (const [index, item] of value.entries()) {
325
+ if (!isRecord(item))
326
+ return { ok: false, message: `item ${index} must be an object` };
327
+ const extra = Object.keys(item).find((key) => key !== "id" && key !== "props");
328
+ if (extra !== undefined)
329
+ return { ok: false, message: `item ${index} has an unknown key "${extra}"` };
330
+ if (typeof item.id !== "string" || item.id.length === 0)
331
+ return { ok: false, message: `item ${index} needs a non-empty string \`id\`` };
332
+ if (seenIds.has(item.id))
333
+ return { ok: false, message: `item ${index} repeats the id "${item.id}"` };
334
+ seenIds.add(item.id);
335
+ if (item.props === undefined)
336
+ continue;
337
+ if (!isRecord(item.props))
338
+ return { ok: false, message: `item ${index}: \`props\` must be an object` };
339
+ for (const [key, propValue] of Object.entries(item.props)) {
340
+ const itemField = itemFields.get(key);
341
+ if (itemField === undefined)
342
+ return { ok: false, message: `item ${index}: "${key}" is not a declared field` };
343
+ const checked = checkStoredValue(itemField, propValue);
344
+ if (!checked.ok)
345
+ return { ok: false, message: `item ${index}: "${key}" ${checked.message}` };
346
+ }
347
+ }
348
+ return { ok: true, value };
349
+ }
350
+ const MEDIA_REFERENCE_KEYS = ["key", "alt", "width", "height", "focal"];
351
+ const isUnitInterval = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
352
+ /** Why `value` is not an image reference, or `null`. `write` refuses keys the contract does not define. */
353
+ function mediaReferenceProblem(value, mode) {
354
+ if (!isRecord(value))
355
+ return "must be an image reference { key, alt?, width?, height?, focal? }";
356
+ if (mode === "write") {
357
+ const extra = Object.keys(value).find((key) => !MEDIA_REFERENCE_KEYS.includes(key));
358
+ if (extra !== undefined)
359
+ return `has an unknown key "${extra}"`;
360
+ }
361
+ if (typeof value.key !== "string" || !MEDIA_KEY_PATTERN.test(value.key)) {
362
+ return "must have a `key` naming an object in the store's media (namespace/name, no `..`)";
363
+ }
364
+ if (value.alt !== undefined && typeof value.alt !== "string" && !isLocaleMap(value.alt)) {
365
+ return "`alt` must be a string or a map from language tags to strings";
366
+ }
367
+ for (const dimension of ["width", "height"]) {
368
+ const size = value[dimension];
369
+ if (size !== undefined && !(typeof size === "number" && Number.isInteger(size) && size > 0)) {
370
+ return `\`${dimension}\` must be a positive whole number of pixels`;
371
+ }
372
+ }
373
+ if (value.focal !== undefined) {
374
+ const focal = value.focal;
375
+ const shapeOk = isRecord(focal) &&
376
+ isUnitInterval(focal.x) &&
377
+ isUnitInterval(focal.y) &&
378
+ (mode === "read" || Object.keys(focal).every((key) => key === "x" || key === "y"));
379
+ if (!shapeOk)
380
+ return "`focal` must be { x, y }, each between 0 and 1";
381
+ }
382
+ return null;
383
+ }
384
+ /** On a grid of `step` from `base`, allowing for binary floating point. */
385
+ export function onStepGrid(value, base, step) {
386
+ const steps = (value - base) / step;
387
+ return Math.abs(steps - Math.round(steps)) <= 1e-9 * Math.max(1, Math.abs(steps));
388
+ }
389
+ /**
390
+ * Kind check shared by resolution (`read`) and writing (`write`). Lists are
391
+ * checked item by item by their own paths, never here.
392
+ */
393
+ function checkKindValue(field, value, mode) {
177
394
  switch (field.kind) {
178
395
  case "text":
179
396
  case "textarea":
@@ -190,6 +407,9 @@ function checkFieldValue(field, value) {
190
407
  if (field.max !== undefined && value > field.max) {
191
408
  return { ok: false, message: `must be <= ${field.max}` };
192
409
  }
410
+ if (field.step !== undefined && !onStepGrid(value, field.min ?? 0, field.step)) {
411
+ return { ok: false, message: `must be a multiple of ${field.step} from ${field.min ?? 0}` };
412
+ }
193
413
  return { ok: true, value };
194
414
  }
195
415
  case "boolean":
@@ -215,6 +435,24 @@ function checkFieldValue(field, value) {
215
435
  return typeof value === "string" && COLLECTION_HANDLE_PATTERN.test(value)
216
436
  ? { ok: true, value }
217
437
  : { ok: false, message: "must be a collection handle" };
438
+ case "product":
439
+ return typeof value === "string" && RESOURCE_HANDLE_PATTERN.test(value)
440
+ ? { ok: true, value }
441
+ : { ok: false, message: "must be a product handle" };
442
+ case "category":
443
+ return typeof value === "string" && RESOURCE_HANDLE_PATTERN.test(value)
444
+ ? { ok: true, value }
445
+ : { ok: false, message: "must be a category slug" };
446
+ case "image": {
447
+ const problem = mediaReferenceProblem(value, mode);
448
+ return problem === null ? { ok: true, value } : { ok: false, message: problem };
449
+ }
450
+ case "richtext": {
451
+ const problem = richTextProblem(value, field.formats);
452
+ return problem === null ? { ok: true, value } : { ok: false, message: problem };
453
+ }
454
+ case "list":
455
+ return checkStoredList(field, value);
218
456
  case "select":
219
457
  return typeof value === "string" && field.options.includes(value)
220
458
  ? { ok: true, value }
@@ -290,7 +528,7 @@ export function createInstance(definition, id, props = {}) {
290
528
  materialized[field.key] = provided;
291
529
  continue;
292
530
  }
293
- if (field.default !== undefined) {
531
+ if (field.kind !== "image" && field.default !== undefined) {
294
532
  const checked = checkStoredValue(field, field.default);
295
533
  if (!checked.ok) {
296
534
  throw new TypeError(`createInstance: default for "${field.key}" ${checked.message}`);
@@ -0,0 +1,14 @@
1
+ import type { ThemeManifest } from "./types.js";
2
+ /** One difference, located by a path into the manifest. */
3
+ export interface ManifestChange {
4
+ path: string;
5
+ message: string;
6
+ }
7
+ export interface ManifestDiff {
8
+ /** Changes saved documents, settings or text edits may not survive. */
9
+ breaking: ManifestChange[];
10
+ /** Changes that only add. */
11
+ additive: ManifestChange[];
12
+ }
13
+ /** Compare a theme's manifest with its next version's. */
14
+ export declare function diffThemeManifests(previous: ThemeManifest, next: ThemeManifest): ManifestDiff;
@@ -0,0 +1,160 @@
1
+ /**
2
+ * What changes between two versions of a theme, for the documents and
3
+ * settings merchants already saved. A change is breaking when a saved value
4
+ * could stop being read as it was: something removed or renamed, a kind
5
+ * changed, a rule narrowed, a field newly required with nothing to fill it.
6
+ * Everything else is additive. Saved data is never migrated: the reader
7
+ * degrades with reports, and this diff says beforehand what will degrade.
8
+ */
9
+ import { hasFallback } from "./instances.js";
10
+ /** Compare a theme's manifest with its next version's. */
11
+ export function diffThemeManifests(previous, next) {
12
+ const diff = { breaking: [], additive: [] };
13
+ const breaking = (path, message) => diff.breaking.push({ path, message });
14
+ const additive = (path, message) => diff.additive.push({ path, message });
15
+ if (previous.key !== next.key) {
16
+ breaking("key", `the theme key changed from "${previous.key}" to "${next.key}": every saved edit is keyed by it`);
17
+ }
18
+ for (const locale of previous.locales) {
19
+ if (!next.locales.includes(locale))
20
+ breaking("locales", `the theme no longer covers "${locale}"`);
21
+ }
22
+ for (const locale of next.locales) {
23
+ if (!previous.locales.includes(locale))
24
+ additive("locales", `the theme now covers "${locale}"`);
25
+ }
26
+ const nextSurfaces = new Map(next.surfaces.map((surface) => [surface.key, surface]));
27
+ for (const surface of previous.surfaces) {
28
+ const after = nextSurfaces.get(surface.key);
29
+ if (after === undefined)
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"}"`);
33
+ }
34
+ }
35
+ for (const surface of next.surfaces) {
36
+ if (!previous.surfaces.some((before) => before.key === surface.key))
37
+ additive(`surfaces.${surface.key}`, `surface "${surface.key}" was added`);
38
+ }
39
+ const settingFields = (manifest) => manifest.settings.flatMap((group) => group.fields);
40
+ compareFields(settingFields(previous), settingFields(next), "settings", breaking, additive);
41
+ for (const [locale, pack] of Object.entries(previous.content)) {
42
+ const after = next.content[locale] ?? {};
43
+ for (const key of Object.keys(pack)) {
44
+ if (!Object.hasOwn(after, key))
45
+ breaking(`content.${locale}.${key}`, `content key "${key}" (${locale}) was removed: a merchant's edit of it no longer shows`);
46
+ }
47
+ }
48
+ for (const [type, spec] of Object.entries(previous.components)) {
49
+ const after = next.components[type];
50
+ if (after === undefined) {
51
+ breaking(`components.${type}`, `component "${type}" was removed: saved instances of it render nothing`);
52
+ continue;
53
+ }
54
+ compareComponents(spec, after, `components.${type}`, breaking, additive);
55
+ }
56
+ for (const type of Object.keys(next.components)) {
57
+ if (!Object.hasOwn(previous.components, type))
58
+ additive(`components.${type}`, `component "${type}" was added`);
59
+ }
60
+ return diff;
61
+ }
62
+ function compareComponents(previous, next, path, breaking, additive) {
63
+ compareFields(previous.fields, next.fields, `${path}.fields`, breaking, additive);
64
+ const nextSlots = new Map(next.slots.map((slot) => [slot.name, slot]));
65
+ for (const slot of previous.slots) {
66
+ const after = nextSlots.get(slot.name);
67
+ if (after === undefined) {
68
+ breaking(`${path}.slots.${slot.name}`, `slot "${slot.name}" was removed: its saved children are dropped`);
69
+ continue;
70
+ }
71
+ compareSlots(slot, after, `${path}.slots.${slot.name}`, breaking, additive);
72
+ }
73
+ for (const slot of next.slots) {
74
+ if (!previous.slots.some((before) => before.name === slot.name))
75
+ additive(`${path}.slots.${slot.name}`, `slot "${slot.name}" was added`);
76
+ }
77
+ }
78
+ function compareSlots(previous, next, path, breaking, additive) {
79
+ if (previous.multiple !== false && next.multiple === false) {
80
+ breaking(`${path}.multiple`, "the slot now holds one instance: saved extra children are dropped");
81
+ }
82
+ else if (previous.multiple === false && next.multiple !== false) {
83
+ additive(`${path}.multiple`, "the slot now holds several instances");
84
+ }
85
+ if (next.allow !== undefined) {
86
+ const removed = previous.allow === undefined ? undefined : previous.allow.filter((type) => !next.allow.includes(type));
87
+ if (previous.allow === undefined)
88
+ breaking(`${path}.allow`, `the slot now accepts only ${next.allow.join(", ")}`);
89
+ else if (removed !== undefined && removed.length > 0)
90
+ breaking(`${path}.allow`, `the slot no longer accepts ${removed.join(", ")}`);
91
+ const added = previous.allow === undefined ? [] : next.allow.filter((type) => !previous.allow.includes(type));
92
+ if (added.length > 0)
93
+ additive(`${path}.allow`, `the slot now also accepts ${added.join(", ")}`);
94
+ }
95
+ else if (previous.allow !== undefined) {
96
+ additive(`${path}.allow`, "the slot now accepts any component");
97
+ }
98
+ }
99
+ function compareFields(previous, next, path, breaking, additive) {
100
+ const nextFields = new Map(next.map((field) => [field.key, field]));
101
+ for (const field of previous) {
102
+ const fieldPath = `${path}.${field.key}`;
103
+ const after = nextFields.get(field.key);
104
+ if (after === undefined) {
105
+ breaking(fieldPath, `field "${field.key}" was removed: saved values for it are ignored`);
106
+ continue;
107
+ }
108
+ if (after.kind !== field.kind) {
109
+ breaking(`${fieldPath}.kind`, `field "${field.key}" changed kind from ${field.kind} to ${after.kind}: saved values no longer read`);
110
+ continue;
111
+ }
112
+ if (after.required === true && field.required !== true && !hasFallback(after)) {
113
+ breaking(`${fieldPath}.required`, `field "${field.key}" is now required with no default`);
114
+ }
115
+ compareKindRules(field, after, fieldPath, breaking, additive);
116
+ }
117
+ for (const field of next) {
118
+ if (previous.some((before) => before.key === field.key))
119
+ continue;
120
+ if (field.required === true && !hasFallback(field)) {
121
+ breaking(`${path}.${field.key}`, `required field "${field.key}" was added with no default: saved instances lack it`);
122
+ }
123
+ else {
124
+ additive(`${path}.${field.key}`, `field "${field.key}" was added`);
125
+ }
126
+ }
127
+ }
128
+ function compareKindRules(previous, next, path, breaking, additive) {
129
+ if (previous.kind === "select" && next.kind === "select") {
130
+ const removed = previous.options.filter((option) => !next.options.includes(option));
131
+ if (removed.length > 0)
132
+ breaking(`${path}.options`, `options ${removed.map((option) => `"${option}"`).join(", ")} were removed`);
133
+ const added = next.options.filter((option) => !previous.options.includes(option));
134
+ if (added.length > 0)
135
+ additive(`${path}.options`, `options ${added.map((option) => `"${option}"`).join(", ")} were added`);
136
+ }
137
+ if (previous.kind === "number" && next.kind === "number") {
138
+ if (next.min !== undefined && (previous.min === undefined || next.min > previous.min))
139
+ breaking(`${path}.min`, `the minimum rose to ${next.min}`);
140
+ if (next.max !== undefined && (previous.max === undefined || next.max < previous.max))
141
+ breaking(`${path}.max`, `the maximum fell to ${next.max}`);
142
+ if (next.step !== undefined && next.step !== previous.step)
143
+ breaking(`${path}.step`, `values must now be multiples of ${next.step}`);
144
+ }
145
+ if (previous.kind === "richtext" && next.kind === "richtext" && next.formats !== undefined) {
146
+ const before = previous.formats;
147
+ const removed = before === undefined ? undefined : before.filter((format) => !next.formats.includes(format));
148
+ if (before === undefined)
149
+ breaking(`${path}.formats`, `the field now allows only ${next.formats.join(", ")}`);
150
+ else if (removed !== undefined && removed.length > 0)
151
+ breaking(`${path}.formats`, `the field no longer allows ${removed.join(", ")}`);
152
+ }
153
+ if (previous.kind === "list" && next.kind === "list") {
154
+ if (next.max !== undefined && (previous.max === undefined || next.max < previous.max))
155
+ breaking(`${path}.max`, `the list now holds at most ${next.max} items`);
156
+ if (next.min !== undefined && (previous.min === undefined || next.min > previous.min))
157
+ breaking(`${path}.min`, `the list now needs at least ${next.min} items`);
158
+ compareFields(previous.fields, next.fields, `${path}.fields`, breaking, additive);
159
+ }
160
+ }