@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.
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
  }
@@ -260,11 +260,14 @@ export function startPreviewBridge(options) {
260
260
  /** What a `remove` or `hide` patch took off the page, to put back on `restore` or show. */
261
261
  const removed = new Map();
262
262
  const hiddenNodes = new Map();
263
- const sectionsOf = () => marked.filter((entry) => entry.parent === null);
264
263
  const find = (id) => marked.find((entry) => entry.id === id);
265
- /** Puts `nodes` at `index` among the document's sections other than `id`. */
266
- const place = (nodes, id, index) => {
267
- const others = sectionsOf().filter((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);
268
271
  if (!Number.isInteger(index) || index < 0 || index > others.length)
269
272
  return false;
270
273
  const before = others[index]?.start ?? others.at(-1)?.end.nextSibling ?? null;
@@ -287,11 +290,11 @@ export function startPreviewBridge(options) {
287
290
  const entry = find(patch.id);
288
291
  switch (patch.op) {
289
292
  case "move": {
290
- if (entry === undefined || entry.parent !== null)
293
+ if (entry === undefined || entry.parent !== (patch.parent ?? null))
291
294
  return [];
292
295
  const nodes = withMarkers(entry);
293
296
  if (nodes !== null)
294
- place(nodes, entry.id, patch.index);
297
+ place(nodes, entry.id, patch.index, entry.parent);
295
298
  return [];
296
299
  }
297
300
  case "duplicate": {
@@ -324,7 +327,7 @@ export function startPreviewBridge(options) {
324
327
  }
325
328
  case "restore": {
326
329
  const nodes = removed.get(patch.id);
327
- if (entry !== undefined || nodes === undefined || !place(nodes, patch.id, patch.index))
330
+ if (entry !== undefined || nodes === undefined || !place(nodes, patch.id, patch.index, patch.parent ?? null))
328
331
  return [];
329
332
  removed.delete(patch.id);
330
333
  return [patch.id];
@@ -64,12 +64,15 @@ export type PreviewMode = "edit" | "interactive";
64
64
  * does not know patches (before `@wocart/theme` 1.6.0) still shows every
65
65
  * change, a moment later.
66
66
  *
67
- * - `move`: a section of the document to `index` among the sections, counted
68
- * once it has left its place (`moveInstance`).
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.
69
71
  * - `duplicate`: a copy of an instance right after it; `ids` maps every
70
72
  * instance id inside it, its own included, to its copy's.
71
73
  * - `remove`: an instance and everything in it; `restore` puts what a
72
- * `remove` took back, as a section of the document at `index`.
74
+ * `remove` took back at `index` among the sections, or among `parent`'s
75
+ * blocks.
73
76
  * - `hide`: an instance switched off (it shows nothing) or back on.
74
77
  * - `text`: inside an instance, text that reads `from` now reads `to` — a
75
78
  * field's text as the theme prints it.
@@ -78,6 +81,7 @@ export type PreviewPatch = {
78
81
  op: "move";
79
82
  id: string;
80
83
  index: number;
84
+ parent?: string;
81
85
  } | {
82
86
  op: "duplicate";
83
87
  id: string;
@@ -89,6 +93,7 @@ export type PreviewPatch = {
89
93
  op: "restore";
90
94
  id: string;
91
95
  index: number;
96
+ parent?: string;
92
97
  } | {
93
98
  op: "hide";
94
99
  id: string;
@@ -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;
package/dist/style.js ADDED
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Style: how a section or block sits on the page and which colors it wears,
3
+ * stored as steps and choices — never CSS — and turned into CSS by the SDK.
4
+ *
5
+ * - A `style` value holds the platform's step names (`s0`…`s12`) and closed
6
+ * choices per breakpoint; `styleClasses` gives its classes, and
7
+ * `themeStyleSheet` makes the one stylesheet those classes come from, out of
8
+ * the sizes the theme gives the steps. A value can therefore never carry a
9
+ * property, a selector or a size the theme did not declare.
10
+ * - Colors come from the store's color schemes; `colorSchemeCss` writes them
11
+ * as custom properties on `.wc-scheme-<id>`, dropping anything that is not
12
+ * a scheme id and a hex color.
13
+ */
14
+ import { COLOR_PATTERN, isLocaleMap } from "./schemas.js";
15
+ /** The platform's breakpoints, phone first, with the width each starts at. */
16
+ export const STYLE_BREAKPOINTS = [
17
+ ["base", 0],
18
+ ["md", 768],
19
+ ["lg", 1024],
20
+ ];
21
+ export const STYLE_STEPS = ["s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8", "s9", "s10", "s11", "s12"];
22
+ export const STYLE_WIDTHS = ["wide", "content", "narrow"];
23
+ export const STYLE_PANELS = ["spacing", "layout", "size"];
24
+ export const COLOR_ROLES = ["background", "text", "heading", "accent", "button", "buttonText", "border"];
25
+ /** A color scheme's id: what a `colorScheme` value names. */
26
+ export const COLOR_SCHEME_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/;
27
+ /** The most color schemes a store keeps. */
28
+ export const COLOR_SCHEMES_MAX = 24;
29
+ /** Where a built theme serves the stylesheet of its style classes (`themeStylesEndpoint`). */
30
+ export const THEME_STYLES_PATH = "/wocart-styles.css";
31
+ const px = (size) => (size === undefined ? null : `${size}px`);
32
+ const step = (property) => (value, style) => {
33
+ const size = px(style.spacing?.[value]);
34
+ return size === null ? null : `${property}:${size}`;
35
+ };
36
+ const choice = (property, map) => (value) => `${property}:${map[String(value)]}`;
37
+ const PROPERTIES = {
38
+ spacing: {
39
+ paddingBlockStart: { prefix: "pbs", values: STYLE_STEPS, css: step("padding-block-start") },
40
+ paddingBlockEnd: { prefix: "pbe", values: STYLE_STEPS, css: step("padding-block-end") },
41
+ paddingInline: { prefix: "pi", values: STYLE_STEPS, css: step("padding-inline") },
42
+ marginBlockStart: { prefix: "mbs", values: STYLE_STEPS, css: step("margin-block-start") },
43
+ marginBlockEnd: { prefix: "mbe", values: STYLE_STEPS, css: step("margin-block-end") },
44
+ gap: { prefix: "gap", values: STYLE_STEPS, css: step("gap") },
45
+ },
46
+ layout: {
47
+ direction: { prefix: "dir", values: ["row", "column"], css: choice("flex-direction", { row: "row", column: "column" }) },
48
+ align: {
49
+ prefix: "items",
50
+ values: ["start", "center", "end", "stretch"],
51
+ css: choice("align-items", { start: "flex-start", center: "center", end: "flex-end", stretch: "stretch" }),
52
+ },
53
+ justify: {
54
+ prefix: "justify",
55
+ values: ["start", "center", "end", "between"],
56
+ css: choice("justify-content", { start: "flex-start", center: "center", end: "flex-end", between: "space-between" }),
57
+ },
58
+ wrap: { prefix: "wrap", values: [true, false], css: choice("flex-wrap", { true: "wrap", false: "nowrap" }) },
59
+ },
60
+ size: {
61
+ width: {
62
+ prefix: "w",
63
+ values: ["full", ...STYLE_WIDTHS],
64
+ css: (value, style) => {
65
+ if (value === "full")
66
+ return "width:100%;max-width:none";
67
+ const size = px(style.widths?.[value]);
68
+ return size === null ? null : `width:100%;max-width:${size};margin-inline:auto`;
69
+ },
70
+ },
71
+ minHeight: { prefix: "mh", values: STYLE_STEPS, css: step("min-height") },
72
+ },
73
+ };
74
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
75
+ const token = (value) => (value === true ? "on" : value === false ? "off" : value);
76
+ const className = (breakpoint, prefix, value) => `wc-${breakpoint === "base" ? "" : `${breakpoint}-`}${prefix}-${token(value)}`;
77
+ /** Why `value` is not a style value a field offering `panels` may hold, or `null`. */
78
+ export function styleValueProblem(panels, value) {
79
+ if (!isRecord(value))
80
+ return "must be a style value: { spacing?, layout?, size? }";
81
+ for (const [panel, properties] of Object.entries(value)) {
82
+ if (!STYLE_PANELS.includes(panel) || !panels.includes(panel)) {
83
+ return `"${panel}" is not a panel this field offers (${panels.join(", ")})`;
84
+ }
85
+ if (!isRecord(properties))
86
+ return `\`${panel}\` must be an object of properties`;
87
+ const known = PROPERTIES[panel];
88
+ for (const [name, responsive] of Object.entries(properties)) {
89
+ const property = Object.hasOwn(known, name) ? known[name] : undefined;
90
+ if (property === undefined)
91
+ return `"${name}" is not a ${panel} property`;
92
+ if (!isRecord(responsive) || !Object.hasOwn(responsive, "base"))
93
+ return `\`${panel}.${name}\` must be { base, md?, lg? }`;
94
+ for (const [breakpoint, choice] of Object.entries(responsive)) {
95
+ if (!STYLE_BREAKPOINTS.some(([name]) => name === breakpoint))
96
+ return `"${breakpoint}" is not a breakpoint (base, md, lg)`;
97
+ if (!property.values.includes(choice))
98
+ return `\`${panel}.${name}.${breakpoint}\` must be one of ${property.values.join(", ")}`;
99
+ }
100
+ }
101
+ }
102
+ return null;
103
+ }
104
+ /**
105
+ * Why a theme's sizes leave part of a style value without a style: a step or
106
+ * width the theme does not size. What a writer stores must be sized; a
107
+ * reader renders an unsized value as no style.
108
+ */
109
+ export function styleSizeProblem(style, value) {
110
+ for (const [panel, properties] of Object.entries(value)) {
111
+ for (const [name, responsive] of Object.entries(properties ?? {})) {
112
+ const property = PROPERTIES[panel]?.[name];
113
+ if (property === undefined)
114
+ continue;
115
+ for (const choice of Object.values(responsive)) {
116
+ if (property.css(choice, style ?? {}) === null)
117
+ return `"${String(choice)}" is not a size this theme gives (${panel}.${name})`;
118
+ }
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+ /** The classes that give an element its style value, from the theme's stylesheet (`themeStyleSheet`). */
124
+ export function styleClasses(value) {
125
+ if (!isRecord(value))
126
+ return "";
127
+ const classes = [];
128
+ for (const panel of STYLE_PANELS) {
129
+ const properties = value[panel];
130
+ if (!isRecord(properties))
131
+ continue;
132
+ let used = false;
133
+ for (const [name, property] of Object.entries(PROPERTIES[panel])) {
134
+ const responsive = properties[name];
135
+ if (!isRecord(responsive) || !Object.hasOwn(responsive, "base"))
136
+ continue;
137
+ for (const [breakpoint] of STYLE_BREAKPOINTS) {
138
+ const choice = responsive[breakpoint];
139
+ if (choice === undefined || !property.values.includes(choice))
140
+ continue;
141
+ classes.push(className(breakpoint, property.prefix, choice));
142
+ used = true;
143
+ }
144
+ }
145
+ if (panel === "layout" && used)
146
+ classes.unshift("wc-flex");
147
+ }
148
+ return classes.join(" ");
149
+ }
150
+ /**
151
+ * Every class a theme's sizes allow, phone first: the base rules, then each
152
+ * wider breakpoint in its media query. A step or width the theme does not
153
+ * size has no class.
154
+ */
155
+ export function themeStyleSheet(style) {
156
+ const sizes = style ?? {};
157
+ const rules = (breakpoint) => {
158
+ const out = breakpoint === "base" ? [".wc-flex{display:flex}"] : [];
159
+ for (const panel of STYLE_PANELS) {
160
+ for (const property of Object.values(PROPERTIES[panel])) {
161
+ for (const value of property.values) {
162
+ const css = property.css(value, sizes);
163
+ if (css !== null)
164
+ out.push(`.${className(breakpoint, property.prefix, value)}{${css}}`);
165
+ }
166
+ }
167
+ }
168
+ return out.join("");
169
+ };
170
+ return STYLE_BREAKPOINTS.map(([breakpoint, from]) => (from === 0 ? rules(breakpoint) : `@media (min-width:${from}px){${rules(breakpoint)}}`)).join("");
171
+ }
172
+ /** Why `value` is not a list of color schemes, or `null`. */
173
+ export function colorSchemesProblem(value) {
174
+ if (!Array.isArray(value) || value.length === 0)
175
+ return "must be a non-empty list of color schemes";
176
+ if (value.length > COLOR_SCHEMES_MAX)
177
+ return `holds at most ${COLOR_SCHEMES_MAX} color schemes`;
178
+ const ids = new Set();
179
+ for (const [index, scheme] of value.entries()) {
180
+ if (!isRecord(scheme))
181
+ return `scheme ${index} must be { id, name, colors }`;
182
+ const extra = Object.keys(scheme).find((key) => key !== "id" && key !== "name" && key !== "colors");
183
+ if (extra !== undefined)
184
+ return `scheme ${index} has an unknown key "${extra}"`;
185
+ if (typeof scheme.id !== "string" || !COLOR_SCHEME_ID_PATTERN.test(scheme.id))
186
+ return `scheme ${index} needs an \`id\` of lowercase letters, digits and dashes`;
187
+ if (ids.has(scheme.id))
188
+ return `scheme ${index} repeats the id "${scheme.id}"`;
189
+ ids.add(scheme.id);
190
+ const name = scheme.name;
191
+ if (!((typeof name === "string" && name.length > 0) || isLocaleMap(name)))
192
+ return `scheme "${scheme.id}" needs a \`name\``;
193
+ if (!isRecord(scheme.colors))
194
+ return `scheme "${scheme.id}" needs \`colors\``;
195
+ for (const role of COLOR_ROLES) {
196
+ const color = scheme.colors[role];
197
+ if (typeof color !== "string" || !COLOR_PATTERN.test(color))
198
+ return `scheme "${scheme.id}" needs a hex color for "${role}"`;
199
+ }
200
+ const unknown = Object.keys(scheme.colors).find((role) => !COLOR_ROLES.includes(role));
201
+ if (unknown !== undefined)
202
+ return `scheme "${scheme.id}" has an unknown role "${unknown}"`;
203
+ }
204
+ return null;
205
+ }
206
+ /** The class that gives an element a color scheme, or `""` for what is not a scheme id. */
207
+ export function schemeClass(id) {
208
+ return typeof id === "string" && COLOR_SCHEME_ID_PATTERN.test(id) ? `wc-scheme-${id}` : "";
209
+ }
210
+ const roleProperty = (role) => `--wc-color-${role.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`;
211
+ /**
212
+ * The store's color schemes as CSS: each scheme's roles as custom properties
213
+ * (`--wc-color-background`, `--wc-color-button-text`, …) with its background
214
+ * and text applied, on `.wc-scheme-<id>`. A scheme with a malformed id or
215
+ * color is left out whole.
216
+ */
217
+ export function colorSchemeCss(schemes) {
218
+ return (schemes ?? [])
219
+ .filter((scheme) => isRecord(scheme) &&
220
+ typeof scheme.id === "string" &&
221
+ COLOR_SCHEME_ID_PATTERN.test(scheme.id) &&
222
+ isRecord(scheme.colors) &&
223
+ COLOR_ROLES.every((role) => typeof scheme.colors[role] === "string" && COLOR_PATTERN.test(scheme.colors[role])))
224
+ .map((scheme) => {
225
+ const roles = COLOR_ROLES.map((role) => `${roleProperty(role)}:${scheme.colors[role]}`).join(";");
226
+ return `.wc-scheme-${scheme.id}{${roles};background-color:var(--wc-color-background);color:var(--wc-color-text)}`;
227
+ })
228
+ .join("");
229
+ }
230
+ const channel = (value) => {
231
+ const unit = value / 255;
232
+ return unit <= 0.03928 ? unit / 12.92 : ((unit + 0.055) / 1.055) ** 2.4;
233
+ };
234
+ function luminance(hex) {
235
+ const digits = hex.length === 4 ? [...hex.slice(1)].map((digit) => digit + digit).join("") : hex.slice(1);
236
+ const [r, g, b] = [0, 2, 4].map((at) => parseInt(digits.slice(at, at + 2), 16));
237
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
238
+ }
239
+ /**
240
+ * The WCAG contrast ratio of two hex colors, from 1 to 21. Text needs 4.5 to
241
+ * meet AA; an editor warns below it. `NaN` for what is not a hex color.
242
+ */
243
+ export function contrastRatio(a, b) {
244
+ if (!COLOR_PATTERN.test(a) || !COLOR_PATTERN.test(b))
245
+ return Number.NaN;
246
+ const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x);
247
+ return (light + 0.05) / (dark + 0.05);
248
+ }