@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/CHANGELOG.md +107 -0
- package/README.md +211 -9
- package/dist/astro/Block.astro +29 -0
- package/dist/astro/Blocks.astro +23 -0
- package/dist/astro/InstanceView.astro +34 -10
- package/dist/astro/ThemeStyles.astro +34 -0
- package/dist/astro/index.d.ts +49 -1
- package/dist/astro/index.js +43 -5
- package/dist/blocks.d.ts +61 -0
- package/dist/blocks.js +144 -0
- package/dist/conditions.d.ts +21 -0
- package/dist/conditions.js +65 -0
- package/dist/definitions.js +399 -29
- package/dist/index.d.ts +6 -1
- package/dist/index.js +4 -0
- package/dist/instances.d.ts +4 -0
- package/dist/instances.js +72 -4
- package/dist/manifest-diff.js +95 -2
- package/dist/manifest.d.ts +245 -13
- package/dist/manifest.js +64 -1
- package/dist/operations.d.ts +39 -13
- package/dist/operations.js +253 -55
- package/dist/page-data.js +63 -27
- package/dist/preview-bridge.js +10 -7
- package/dist/preview-protocol.d.ts +8 -3
- package/dist/rich-text.d.ts +2 -0
- package/dist/rich-text.js +4 -0
- package/dist/schemas.d.ts +2 -1
- package/dist/schemas.js +3 -1
- package/dist/settings.js +20 -1
- package/dist/style.d.ts +45 -0
- package/dist/style.js +248 -0
- package/dist/surface-document.d.ts +49 -0
- package/dist/surface-document.js +162 -0
- package/dist/types.d.ts +217 -7
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -1
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
|
|
34
|
+
const state = { seenIds: new Set(), count: 0, overCap: 0 };
|
|
34
35
|
const sections = [];
|
|
35
36
|
input.sections.forEach((raw, index) => {
|
|
36
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
137
|
-
*
|
|
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
|
|
140
|
-
if (
|
|
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
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
if (
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/preview-bridge.js
CHANGED
|
@@ -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
|
-
/**
|
|
266
|
-
|
|
267
|
-
|
|
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`:
|
|
68
|
-
*
|
|
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
|
|
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;
|
package/dist/rich-text.d.ts
CHANGED
|
@@ -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, "&").replace(/</g, "<").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`
|
|
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`
|
|
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
|
}
|
package/dist/style.d.ts
ADDED
|
@@ -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
|
+
}
|