@wocart/theme 1.5.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/page-data.js CHANGED
@@ -7,6 +7,7 @@
7
7
  * failed; a structurally invalid section is dropped with an issue; the
8
8
  * document itself is not rewritten by `validatePageData`.
9
9
  */
10
+ import { THEME_LIMITS, completeRequired, slotCap, surfaceRules } from "./blocks.js";
10
11
  import { resolveInstance } from "./instances.js";
11
12
  import { ComponentInstanceSchema, SURFACE_KEY_PATTERN, THEME_KEY_PATTERN } from "./schemas.js";
12
13
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
@@ -30,14 +31,21 @@ export function parsePageData(input) {
30
31
  return envelopeFailure("`sections` must be an array of component instances");
31
32
  }
32
33
  const issues = [];
33
- const seenIds = new Set();
34
+ const state = { seenIds: new Set(), count: 0, overCap: 0 };
34
35
  const sections = [];
35
36
  input.sections.forEach((raw, index) => {
36
- const instance = sanitizeInstance(raw, `sections[${index}]`, seenIds, issues);
37
+ if (sections.length >= THEME_LIMITS.sections) {
38
+ issues.push({ path: `sections[${index}]`, code: "limit-exceeded", message: `a document holds at most ${THEME_LIMITS.sections} sections; dropped` });
39
+ return;
40
+ }
41
+ const instance = sanitizeInstance(raw, `sections[${index}]`, state, issues);
37
42
  if (instance !== null) {
38
43
  sections.push(instance);
39
44
  }
40
45
  });
46
+ if (state.overCap > 0) {
47
+ issues.push({ path: "sections", code: "limit-exceeded", message: `a document holds at most ${THEME_LIMITS.documentInstances} instances; ${state.overCap} dropped` });
48
+ }
41
49
  // The envelope is forward-compatible: unknown top-level keys are
42
50
  // carried through verbatim so a later writer can record metadata without a
43
51
  // `version` bump. `PageData` deliberately does not declare them — see its
@@ -112,13 +120,26 @@ export function validatePageData(theme, input, scope = {}) {
112
120
  walk(child, `${path}.slots.${name}[${index}]`);
113
121
  });
114
122
  }
123
+ for (const [name, block] of Object.entries(resolved.fixed)) {
124
+ walk(block, `${path}.fixed.${name}`);
125
+ }
115
126
  };
116
- parsed.page.sections.forEach((section, index) => {
127
+ const spec = surfaceRules(theme, parsed.page.surface);
128
+ const cap = slotCap(spec, THEME_LIMITS.sections);
129
+ if (parsed.page.sections.length > cap) {
130
+ issues.push({ path: "sections", code: "limit-exceeded", message: `surface "${parsed.page.surface}" holds at most ${cap} sections; ${parsed.page.sections.length - cap} are not rendered` });
131
+ }
132
+ const sections = completeRequired(parsed.page.sections.slice(0, cap), spec?.required, { id: parsed.page.surface, slot: "sections" }, "sections", issues);
133
+ sections.forEach((section, index) => {
117
134
  walk(section, `sections[${index}]`);
118
135
  });
119
136
  return { page: parsed.page, issues };
120
137
  }
121
- function sanitizeInstance(raw, path, seenIds, issues) {
138
+ const subtreeSize = (instance) => 1 +
139
+ (instance.children ?? []).reduce((sum, child) => sum + subtreeSize(child), 0) +
140
+ Object.values(instance.slots ?? {}).reduce((sum, list) => sum + list.reduce((inner, child) => inner + subtreeSize(child), 0), 0) +
141
+ Object.values(instance.fixed ?? {}).reduce((sum, block) => sum + subtreeSize(block), 0);
142
+ function sanitizeInstance(raw, path, state, issues) {
122
143
  const parsed = ComponentInstanceSchema.safeParse(raw);
123
144
  if (!parsed.success) {
124
145
  const detail = parsed.error.issues[0]?.message ?? "invalid shape";
@@ -129,15 +150,25 @@ function sanitizeInstance(raw, path, seenIds, issues) {
129
150
  });
130
151
  return null;
131
152
  }
132
- return pruneDuplicateIds(parsed.data, path, seenIds, issues);
153
+ return pruneInstance(parsed.data, path, state, issues, 0);
133
154
  }
134
155
  /**
135
- * Deterministic rebuild with duplicate ids pruned (depth-first, first wins).
136
- * Property presence is preserved — a valid document round-trips unchanged;
137
- * pruning to empty (all duplicates dropped) keeps the empty container.
156
+ * Deterministic rebuild with duplicate ids pruned (depth-first, first wins)
157
+ * and the platform's caps applied: nesting deeper than `THEME_LIMITS.depth`
158
+ * and instances past `THEME_LIMITS.documentInstances` are dropped and
159
+ * reported. Property presence is preserved — a valid document round-trips
160
+ * unchanged; pruning to empty keeps the empty container.
138
161
  */
139
- function pruneDuplicateIds(instance, path, seenIds, issues) {
140
- if (seenIds.has(instance.id)) {
162
+ function pruneInstance(instance, path, state, issues, depth) {
163
+ if (depth > THEME_LIMITS.depth) {
164
+ issues.push({ path, code: "limit-exceeded", message: `nesting goes at most ${THEME_LIMITS.depth} levels below a section; dropped` });
165
+ return null;
166
+ }
167
+ if (state.count >= THEME_LIMITS.documentInstances) {
168
+ state.overCap += subtreeSize(instance);
169
+ return null;
170
+ }
171
+ if (state.seenIds.has(instance.id)) {
141
172
  issues.push({
142
173
  path: `${path}.id`,
143
174
  code: "duplicate-id",
@@ -145,7 +176,8 @@ function pruneDuplicateIds(instance, path, seenIds, issues) {
145
176
  });
146
177
  return null;
147
178
  }
148
- seenIds.add(instance.id);
179
+ state.seenIds.add(instance.id);
180
+ state.count += 1;
149
181
  const pruned = { id: instance.id, type: instance.type };
150
182
  if (instance.props !== undefined) {
151
183
  pruned.props = instance.props;
@@ -153,29 +185,33 @@ function pruneDuplicateIds(instance, path, seenIds, issues) {
153
185
  if (instance.hidden !== undefined) {
154
186
  pruned.hidden = instance.hidden;
155
187
  }
156
- if (instance.children !== undefined) {
157
- const children = [];
158
- instance.children.forEach((child, index) => {
159
- const sane = pruneDuplicateIds(child, `${path}.children[${index}]`, seenIds, issues);
160
- if (sane !== null) {
161
- children.push(sane);
162
- }
188
+ const pruneList = (list, listPath) => {
189
+ const sane = [];
190
+ list.forEach((child, index) => {
191
+ const kept = pruneInstance(child, `${listPath}[${index}]`, state, issues, depth + 1);
192
+ if (kept !== null)
193
+ sane.push(kept);
163
194
  });
164
- pruned.children = children;
195
+ return sane;
196
+ };
197
+ if (instance.children !== undefined) {
198
+ pruned.children = pruneList(instance.children, `${path}.children`);
165
199
  }
166
200
  if (instance.slots !== undefined) {
167
201
  const slots = {};
168
202
  for (const [name, list] of Object.entries(instance.slots)) {
169
- const saneList = [];
170
- list.forEach((child, index) => {
171
- const sane = pruneDuplicateIds(child, `${path}.slots.${name}[${index}]`, seenIds, issues);
172
- if (sane !== null) {
173
- saneList.push(sane);
174
- }
175
- });
176
- slots[name] = saneList;
203
+ slots[name] = pruneList(list, `${path}.slots.${name}`);
177
204
  }
178
205
  pruned.slots = slots;
179
206
  }
207
+ if (instance.fixed !== undefined) {
208
+ const fixed = {};
209
+ for (const [name, block] of Object.entries(instance.fixed)) {
210
+ const kept = pruneInstance(block, `${path}.fixed.${name}`, state, issues, depth + 1);
211
+ if (kept !== null)
212
+ fixed[name] = kept;
213
+ }
214
+ pruned.fixed = fixed;
215
+ }
180
216
  return pruned;
181
217
  }
@@ -6,12 +6,14 @@
6
6
  * at, and re-renders a draft the editor sends by posting it back to the
7
7
  * storefront and swapping in only the sections that changed — or, when the
8
8
  * page around them changed, the whole page, the way Astro's router swaps one.
9
+ * A change the editor patches is shown at once, on the page as it stands,
10
+ * until that render arrives.
9
11
  *
10
12
  * It draws nothing — outlines, toolbars and panels are the editor's. It posts
11
13
  * only to the editor origin it is given and reads only that origin's messages
12
14
  * from the framing window.
13
15
  */
14
- import { PREVIEW_PROTOCOL_VERSION, THEME_PREVIEW_PATH, parsePreviewMarker, } from "./preview-protocol.js";
16
+ import { PREVIEW_PROTOCOL_VERSION, THEME_PREVIEW_PATH, parsePreviewMarker, previewEndMarker, previewStartMarker, } from "./preview-protocol.js";
15
17
  /**
16
18
  * Dispatched on `document` for each section the bridge swapped in, with the
17
19
  * instance id as `detail.id`: a section with browser behavior binds it again.
@@ -21,7 +23,9 @@ import { PREVIEW_PROTOCOL_VERSION, THEME_PREVIEW_PATH, parsePreviewMarker, } fro
21
23
  export const PREVIEW_SECTION_LOAD_EVENT = "wocart:section-load";
22
24
  const ASTRO_PAGE_LOAD_EVENT = "astro:page-load";
23
25
  const SHOW_COMMENT = 0x80;
26
+ const SHOW_TEXT = 0x4;
24
27
  const ELEMENT_NODE = 1;
28
+ const TEXT_NODE = 3;
25
29
  const COMMENT_NODE = 8;
26
30
  /**
27
31
  * Every complete marker pair under `root`, in document order, each with the
@@ -74,6 +78,30 @@ function markup(nodes) {
74
78
  })
75
79
  .join("");
76
80
  }
81
+ /** An instance's nodes with its markers: what moves, copies or goes with it. */
82
+ function withMarkers(entry) {
83
+ const inside = between(entry);
84
+ return inside === null ? null : [entry.start, ...inside, entry.end];
85
+ }
86
+ /** Text nodes under `nodes` that a merchant reads — never a script's or a style's. */
87
+ function textNodes(nodes) {
88
+ const found = [];
89
+ for (const node of nodes) {
90
+ if (node.nodeType === TEXT_NODE) {
91
+ found.push(node);
92
+ continue;
93
+ }
94
+ if (node.nodeType !== ELEMENT_NODE)
95
+ continue;
96
+ const walker = node.ownerDocument.createTreeWalker(node, SHOW_TEXT);
97
+ for (let text = walker.nextNode(); text !== null; text = walker.nextNode()) {
98
+ const tag = text.parentElement?.tagName;
99
+ if (tag !== "SCRIPT" && tag !== "STYLE" && tag !== "TEMPLATE")
100
+ found.push(text);
101
+ }
102
+ }
103
+ return found;
104
+ }
77
105
  /**
78
106
  * The page with every section's content left out — the root's attributes and
79
107
  * the body around the sections: what must not change for sections to be
@@ -229,6 +257,135 @@ export function startPreviewBridge(options) {
229
257
  }
230
258
  return swapped;
231
259
  };
260
+ /** What a `remove` or `hide` patch took off the page, to put back on `restore` or show. */
261
+ const removed = new Map();
262
+ const hiddenNodes = new Map();
263
+ const find = (id) => marked.find((entry) => entry.id === id);
264
+ /**
265
+ * Puts `nodes` at `index` among the instances of `owner` (`null`: the
266
+ * page's sections) other than `id`. An empty slot has no place to find, so
267
+ * its move waits for the render.
268
+ */
269
+ const place = (nodes, id, index, owner) => {
270
+ const others = marked.filter((entry) => entry.parent === owner && entry.id !== id);
271
+ if (!Number.isInteger(index) || index < 0 || index > others.length)
272
+ return false;
273
+ const before = others[index]?.start ?? others.at(-1)?.end.nextSibling ?? null;
274
+ const parent = others[index]?.start.parentNode ?? others.at(-1)?.end.parentNode;
275
+ if (!parent)
276
+ return false;
277
+ for (const node of nodes)
278
+ parent.insertBefore(node, before);
279
+ return true;
280
+ };
281
+ const relabel = (marker, ids) => {
282
+ const parsed = parsePreviewMarker(marker.data);
283
+ if (parsed === null || !Object.hasOwn(ids, parsed.id))
284
+ return;
285
+ const id = ids[parsed.id];
286
+ marker.data = parsed.kind === "start" ? previewStartMarker({ id, type: parsed.type, hidden: parsed.hidden }) : previewEndMarker({ id });
287
+ };
288
+ /** Applies one patch to the page as it stands; returns the sections it put in. */
289
+ const apply = (patch) => {
290
+ const entry = find(patch.id);
291
+ switch (patch.op) {
292
+ case "move": {
293
+ if (entry === undefined || entry.parent !== (patch.parent ?? null))
294
+ return [];
295
+ const nodes = withMarkers(entry);
296
+ if (nodes !== null)
297
+ place(nodes, entry.id, patch.index, entry.parent);
298
+ return [];
299
+ }
300
+ case "duplicate": {
301
+ const nodes = entry === undefined ? null : withMarkers(entry);
302
+ if (entry === undefined || nodes === null || !Object.hasOwn(patch.ids, entry.id))
303
+ return [];
304
+ const copies = nodes.map((node) => node.cloneNode(true));
305
+ for (const copy of copies) {
306
+ if (copy.nodeType === COMMENT_NODE)
307
+ relabel(copy, patch.ids);
308
+ else if (copy.nodeType === ELEMENT_NODE) {
309
+ const walker = doc.createTreeWalker(copy, SHOW_COMMENT);
310
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode())
311
+ relabel(node, patch.ids);
312
+ }
313
+ }
314
+ const after = entry.end.nextSibling;
315
+ for (const copy of copies)
316
+ entry.end.parentNode.insertBefore(copy, after);
317
+ return [patch.ids[entry.id]];
318
+ }
319
+ case "remove": {
320
+ const nodes = entry === undefined ? null : withMarkers(entry);
321
+ if (nodes === null)
322
+ return [];
323
+ for (const node of nodes)
324
+ node.parentNode?.removeChild(node);
325
+ removed.set(patch.id, nodes);
326
+ return [];
327
+ }
328
+ case "restore": {
329
+ const nodes = removed.get(patch.id);
330
+ if (entry !== undefined || nodes === undefined || !place(nodes, patch.id, patch.index, patch.parent ?? null))
331
+ return [];
332
+ removed.delete(patch.id);
333
+ return [patch.id];
334
+ }
335
+ case "hide": {
336
+ if (entry === undefined || entry.hidden === patch.hidden)
337
+ return [];
338
+ const inside = between(entry);
339
+ if (inside === null)
340
+ return [];
341
+ if (patch.hidden) {
342
+ for (const node of inside)
343
+ node.parentNode?.removeChild(node);
344
+ hiddenNodes.set(entry.id, inside);
345
+ }
346
+ else {
347
+ const kept = hiddenNodes.get(entry.id);
348
+ if (kept === undefined)
349
+ return [];
350
+ for (const node of kept)
351
+ entry.end.parentNode.insertBefore(node, entry.end);
352
+ hiddenNodes.delete(entry.id);
353
+ }
354
+ entry.start.data = previewStartMarker({ id: entry.id, type: entry.type, hidden: patch.hidden });
355
+ return patch.hidden ? [] : [entry.id];
356
+ }
357
+ case "text": {
358
+ const inside = entry === undefined ? null : between(entry);
359
+ const from = patch.from.trim();
360
+ if (inside === null || from === "" || typeof patch.to !== "string")
361
+ return [];
362
+ for (const node of textNodes(inside)) {
363
+ if (node.data.trim() !== from)
364
+ continue;
365
+ const lead = /^\s*/.exec(node.data)[0];
366
+ const trail = /\s*$/.exec(node.data)[0];
367
+ node.data = `${lead}${patch.to}${trail}`;
368
+ }
369
+ return [];
370
+ }
371
+ default:
372
+ return [];
373
+ }
374
+ };
375
+ /** Shows `patches` at once; a render already on its way would undo them, so it is dropped. */
376
+ const patchPage = (patches) => {
377
+ latest += 1;
378
+ const loaded = [];
379
+ for (const patch of patches) {
380
+ if (typeof patch !== "object" || patch === null || typeof patch.id !== "string")
381
+ continue;
382
+ loaded.push(...apply(patch));
383
+ marked = collect(doc.body);
384
+ }
385
+ for (const id of loaded)
386
+ doc.dispatchEvent(new win.CustomEvent(PREVIEW_SECTION_LOAD_EVENT, { detail: { id } }));
387
+ post({ type: "layout", instances: instances() });
388
+ };
232
389
  const render = async (draft, revision) => {
233
390
  const ticket = ++latest;
234
391
  try {
@@ -276,6 +433,10 @@ export function startPreviewBridge(options) {
276
433
  void render(message.draft, typeof message.revision === "number" ? message.revision : null);
277
434
  }
278
435
  break;
436
+ case "patch":
437
+ if (Array.isArray(message.patches))
438
+ patchPage(message.patches);
439
+ break;
279
440
  case "mode":
280
441
  if (message.mode === "edit" || message.mode === "interactive")
281
442
  mode = message.mode;
@@ -56,10 +56,59 @@ export type PreviewPageMessage = {
56
56
  });
57
57
  /** The interaction an editor allows: `edit` turns clicks into selections; `interactive` lets the page behave. */
58
58
  export type PreviewMode = "edit" | "interactive";
59
+ /**
60
+ * A change the editor has already made to its draft, for the page to show at
61
+ * once, before the `render` that follows it. A patch is a hint: the page
62
+ * applies what it can find and leaves the rest to that render, which is the
63
+ * page's truth — so an editor always sends the render too, and a page that
64
+ * does not know patches (before `@wocart/theme` 1.6.0) still shows every
65
+ * change, a moment later.
66
+ *
67
+ * - `move`: an instance to `index` among its siblings, counted once it has
68
+ * left its place (`moveInstance`): the document's sections, or with
69
+ * `parent` the blocks of that instance. A move to another parent is left to
70
+ * the render.
71
+ * - `duplicate`: a copy of an instance right after it; `ids` maps every
72
+ * instance id inside it, its own included, to its copy's.
73
+ * - `remove`: an instance and everything in it; `restore` puts what a
74
+ * `remove` took back at `index` among the sections, or among `parent`'s
75
+ * blocks.
76
+ * - `hide`: an instance switched off (it shows nothing) or back on.
77
+ * - `text`: inside an instance, text that reads `from` now reads `to` — a
78
+ * field's text as the theme prints it.
79
+ */
80
+ export type PreviewPatch = {
81
+ op: "move";
82
+ id: string;
83
+ index: number;
84
+ parent?: string;
85
+ } | {
86
+ op: "duplicate";
87
+ id: string;
88
+ ids: Readonly<Record<string, string>>;
89
+ } | {
90
+ op: "remove";
91
+ id: string;
92
+ } | {
93
+ op: "restore";
94
+ id: string;
95
+ index: number;
96
+ parent?: string;
97
+ } | {
98
+ op: "hide";
99
+ id: string;
100
+ hidden: boolean;
101
+ } | {
102
+ op: "text";
103
+ id: string;
104
+ from: string;
105
+ to: string;
106
+ };
59
107
  /**
60
108
  * What the editor tells the preview page. `draft` is a `PreviewDraft` of
61
109
  * `@wocart/storefront`; `revision`, when given, comes back on the `rendered`
62
- * or `error` it causes, and a render answered after a newer one is dropped.
110
+ * or `error` it causes, and a render answered after a newer one is dropped —
111
+ * as is one answered after a `patch`, which the next render follows.
63
112
  */
64
113
  export type PreviewEditorMessage = {
65
114
  source: "wocart-editor";
@@ -68,6 +117,9 @@ export type PreviewEditorMessage = {
68
117
  type: "render";
69
118
  draft: Record<string, unknown>;
70
119
  revision?: number;
120
+ } | {
121
+ type: "patch";
122
+ patches: readonly PreviewPatch[];
71
123
  } | {
72
124
  type: "mode";
73
125
  mode: PreviewMode;
@@ -16,6 +16,8 @@ export interface RichTextHtmlOptions {
16
16
  * result is escaped; a target that fails the link rule never reaches it.
17
17
  */
18
18
  href?: (target: string) => string;
19
+ /** `true`: the first paragraph's text only, without a block element — for a field with `inline: true`. */
20
+ inline?: boolean;
19
21
  }
20
22
  /**
21
23
  * The document as HTML: `<p>`, `<h2>`–`<h4>`, `<ul>`/`<ol>`, `<strong>`, `<em>`,
package/dist/rich-text.js CHANGED
@@ -157,6 +157,10 @@ const escapeHtml = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").r
157
157
  export function richTextToHtml(document, options = {}) {
158
158
  if (!isRecord(document) || !Array.isArray(document.content))
159
159
  return "";
160
+ if (options.inline === true) {
161
+ const first = document.content.find((block) => isRecord(block) && block.type === "paragraph");
162
+ return first === undefined ? "" : inlinesHtml(first.content, options);
163
+ }
160
164
  return document.content.map((block) => blockHtml(block, options)).join("");
161
165
  }
162
166
  function blockHtml(block, options) {
package/dist/schemas.d.ts CHANGED
@@ -14,7 +14,8 @@ export declare const THEME_KEY_PATTERN: RegExp;
14
14
  export declare const TYPE_KEY_PATTERN: RegExp;
15
15
  /**
16
16
  * The page-document **surface** key — the address of a composed page
17
- * (`home` today; `page:<slug>` / `lp:<slug>` are reserved conventions).
17
+ * (`home`, `product`, …; `area:<key>` is an area's document, and
18
+ * `page:<slug>` / `lp:<slug>` are reserved conventions).
18
19
  *
19
20
  * The server applies the same pattern. Module-level on purpose: it is part of
20
21
  * the persisted-data contract,
package/dist/schemas.js CHANGED
@@ -13,7 +13,8 @@ export const THEME_KEY_PATTERN = /^(?=.{1,64}$)[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
13
13
  export const TYPE_KEY_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
14
14
  /**
15
15
  * The page-document **surface** key — the address of a composed page
16
- * (`home` today; `page:<slug>` / `lp:<slug>` are reserved conventions).
16
+ * (`home`, `product`, …; `area:<key>` is an area's document, and
17
+ * `page:<slug>` / `lp:<slug>` are reserved conventions).
17
18
  *
18
19
  * The server applies the same pattern. Module-level on purpose: it is part of
19
20
  * the persisted-data contract,
@@ -126,6 +127,7 @@ export const ComponentInstanceSchema = z.lazy(() => z.object({
126
127
  props: z.exactOptional(z.record(z.string(), JsonValueSchema)),
127
128
  children: z.exactOptional(z.array(ComponentInstanceSchema)),
128
129
  slots: z.exactOptional(z.record(z.string(), z.array(ComponentInstanceSchema))),
130
+ fixed: z.exactOptional(z.record(z.string(), ComponentInstanceSchema)),
129
131
  hidden: z.exactOptional(z.boolean()),
130
132
  }));
131
133
  /**
package/dist/settings.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * content pack, for text) → absent.
12
12
  */
13
13
  import { checkStoredValue, ownValue, resolveField } from "./instances.js";
14
+ import { styleSizeProblem } from "./style.js";
14
15
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
15
16
  function declaredSettings(theme) {
16
17
  const fields = new Map();
@@ -75,8 +76,26 @@ export function checkThemeSettings(theme, values) {
75
76
  continue;
76
77
  }
77
78
  const checked = checkStoredValue(field, value);
78
- if (!checked.ok)
79
+ if (!checked.ok) {
79
80
  issues.push({ path: `settings.${key}`, code: "invalid-setting", message: `"${key}" ${checked.message}` });
81
+ continue;
82
+ }
83
+ if (field.kind === "style") {
84
+ const problem = styleSizeProblem(theme.style, value);
85
+ if (problem !== null)
86
+ issues.push({ path: `settings.${key}`, code: "invalid-setting", message: `"${key}" ${problem}` });
87
+ }
88
+ }
89
+ const schemesField = [...declared.values()].find((field) => field.kind === "colorSchemes");
90
+ if (schemesField?.kind === "colorSchemes") {
91
+ const stored = ownValue(values, schemesField.key);
92
+ const schemes = stored !== undefined && checkStoredValue(schemesField, stored).ok ? stored : schemesField.default;
93
+ const ids = new Set(schemes.map((scheme) => scheme.id));
94
+ for (const [key, value] of Object.entries(values)) {
95
+ if (declared.get(key)?.kind === "colorScheme" && typeof value === "string" && !ids.has(value)) {
96
+ issues.push({ path: `settings.${key}`, code: "invalid-setting", message: `"${key}" names the color scheme "${value}", which the store does not have` });
97
+ }
98
+ }
80
99
  }
81
100
  return issues;
82
101
  }
@@ -0,0 +1,45 @@
1
+ import type { ColorScheme, StylePanel, StyleValue, ThemeStyle } from "./types.js";
2
+ /** The platform's breakpoints, phone first, with the width each starts at. */
3
+ export declare const STYLE_BREAKPOINTS: readonly [readonly ["base", 0], readonly ["md", 768], readonly ["lg", 1024]];
4
+ export declare const STYLE_STEPS: readonly ["s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8", "s9", "s10", "s11", "s12"];
5
+ export declare const STYLE_WIDTHS: readonly ["wide", "content", "narrow"];
6
+ export declare const STYLE_PANELS: readonly ["spacing", "layout", "size"];
7
+ export declare const COLOR_ROLES: readonly ["background", "text", "heading", "accent", "button", "buttonText", "border"];
8
+ /** A color scheme's id: what a `colorScheme` value names. */
9
+ export declare const COLOR_SCHEME_ID_PATTERN: RegExp;
10
+ /** The most color schemes a store keeps. */
11
+ export declare const COLOR_SCHEMES_MAX = 24;
12
+ /** Where a built theme serves the stylesheet of its style classes (`themeStylesEndpoint`). */
13
+ export declare const THEME_STYLES_PATH = "/wocart-styles.css";
14
+ /** Why `value` is not a style value a field offering `panels` may hold, or `null`. */
15
+ export declare function styleValueProblem(panels: readonly StylePanel[], value: unknown): string | null;
16
+ /**
17
+ * Why a theme's sizes leave part of a style value without a style: a step or
18
+ * width the theme does not size. What a writer stores must be sized; a
19
+ * reader renders an unsized value as no style.
20
+ */
21
+ export declare function styleSizeProblem(style: ThemeStyle | undefined, value: StyleValue): string | null;
22
+ /** The classes that give an element its style value, from the theme's stylesheet (`themeStyleSheet`). */
23
+ export declare function styleClasses(value: StyleValue | undefined): string;
24
+ /**
25
+ * Every class a theme's sizes allow, phone first: the base rules, then each
26
+ * wider breakpoint in its media query. A step or width the theme does not
27
+ * size has no class.
28
+ */
29
+ export declare function themeStyleSheet(style: ThemeStyle | undefined): string;
30
+ /** Why `value` is not a list of color schemes, or `null`. */
31
+ export declare function colorSchemesProblem(value: unknown): string | null;
32
+ /** The class that gives an element a color scheme, or `""` for what is not a scheme id. */
33
+ export declare function schemeClass(id: string | undefined): string;
34
+ /**
35
+ * The store's color schemes as CSS: each scheme's roles as custom properties
36
+ * (`--wc-color-background`, `--wc-color-button-text`, …) with its background
37
+ * and text applied, on `.wc-scheme-<id>`. A scheme with a malformed id or
38
+ * color is left out whole.
39
+ */
40
+ export declare function colorSchemeCss(schemes: readonly ColorScheme[] | undefined): string;
41
+ /**
42
+ * The WCAG contrast ratio of two hex colors, from 1 to 21. Text needs 4.5 to
43
+ * meet AA; an editor warns below it. `NaN` for what is not a hex color.
44
+ */
45
+ export declare function contrastRatio(a: string, b: string): number;