@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/CHANGELOG.md +110 -0
- package/README.md +226 -11
- 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 +7 -2
- package/dist/index.js +4 -0
- package/dist/instances.d.ts +4 -0
- package/dist/instances.js +72 -4
- package/dist/manifest-diff.js +63 -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 +162 -1
- package/dist/preview-protocol.d.ts +53 -1
- 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/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
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ComponentInstance, PageData, PageDataIssue, ResolutionScope, ThemeDefinition } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The document with everything it must hold: the surface's required sections,
|
|
4
|
+
* each component's fixed blocks (stored, with an id derived from its owner and
|
|
5
|
+
* name) and each slot's required blocks — a hidden required or fixed block is
|
|
6
|
+
* shown again. Added ids are unique in the document. What was added or
|
|
7
|
+
* corrected is reported.
|
|
8
|
+
*/
|
|
9
|
+
export declare function completePage(theme: ThemeDefinition, page: PageData): {
|
|
10
|
+
page: PageData;
|
|
11
|
+
issues: PageDataIssue[];
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* An instance tree with its fixed and required blocks, as an editor inserts
|
|
15
|
+
* it: added ids are unique against `taken`, which receives every id of the
|
|
16
|
+
* tree. Never changes `instance`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function completeInstance(theme: ThemeDefinition, instance: ComponentInstance, taken: Set<string>): ComponentInstance;
|
|
19
|
+
/** Why the theme's default document rendered instead of the saved one. */
|
|
20
|
+
export type SurfaceDocumentReason =
|
|
21
|
+
/** The store saved none. */
|
|
22
|
+
"absent"
|
|
23
|
+
/** What was saved is not a page document this SDK reads. */
|
|
24
|
+
| "invalid"
|
|
25
|
+
/** The saved document is for another surface. */
|
|
26
|
+
| "other-surface"
|
|
27
|
+
/** The saved document was composed for another theme. */
|
|
28
|
+
| "other-theme"
|
|
29
|
+
/** None of the saved sections is in this theme's catalog. */
|
|
30
|
+
| "no-overlap";
|
|
31
|
+
export interface SurfaceDocument {
|
|
32
|
+
/** The document to render, completed. */
|
|
33
|
+
page: PageData;
|
|
34
|
+
source: "saved" | "default";
|
|
35
|
+
/** `null` when the saved document rendered. */
|
|
36
|
+
reason: SurfaceDocumentReason | null;
|
|
37
|
+
/** The saved document's issues, then the default's when it rendered instead. */
|
|
38
|
+
issues: PageDataIssue[];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The page a surface or area renders: the saved document when this theme can
|
|
42
|
+
* render it, else the theme's default (`SurfaceSpec.document` or
|
|
43
|
+
* `AreaSpec.document`, or an empty page). A saved document that names another theme, or whose sections
|
|
44
|
+
* this catalog knows none of, renders the default — a theme switch must not
|
|
45
|
+
* leave an empty page. A deliberately emptied document renders empty. The
|
|
46
|
+
* result is completed (`completePage`), so required and fixed blocks render
|
|
47
|
+
* whatever was saved.
|
|
48
|
+
*/
|
|
49
|
+
export declare function resolveSurfaceDocument(theme: ThemeDefinition, surface: string, saved: unknown, scope?: ResolutionScope): SurfaceDocument;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* From what a store saved to the page that renders, for any surface or area
|
|
3
|
+
* (an area's document is the surface `area:<key>`):
|
|
4
|
+
* which document wins (the saved one, or the theme's default), and that
|
|
5
|
+
* document completed with every fixed and required block. A theme
|
|
6
|
+
* renders `resolveSurfaceDocument(...).page`; an editor edits
|
|
7
|
+
* `completePage(...).page`. Both are pure and never change their input.
|
|
8
|
+
*/
|
|
9
|
+
import { THEME_LIMITS, completeRequired, fixedBlockId, shippedDocument, slotCap, surfaceRules } from "./blocks.js";
|
|
10
|
+
import { parsePageData, validatePageData } from "./page-data.js";
|
|
11
|
+
/**
|
|
12
|
+
* The document with everything it must hold: the surface's required sections,
|
|
13
|
+
* each component's fixed blocks (stored, with an id derived from its owner and
|
|
14
|
+
* name) and each slot's required blocks — a hidden required or fixed block is
|
|
15
|
+
* shown again. Added ids are unique in the document. What was added or
|
|
16
|
+
* corrected is reported.
|
|
17
|
+
*/
|
|
18
|
+
export function completePage(theme, page) {
|
|
19
|
+
const next = structuredClone(page);
|
|
20
|
+
const issues = [];
|
|
21
|
+
const taken = new Set();
|
|
22
|
+
const collect = (node) => {
|
|
23
|
+
taken.add(node.id);
|
|
24
|
+
for (const child of node.children ?? [])
|
|
25
|
+
collect(child);
|
|
26
|
+
for (const list of Object.values(node.slots ?? {}))
|
|
27
|
+
for (const child of list)
|
|
28
|
+
collect(child);
|
|
29
|
+
for (const block of Object.values(node.fixed ?? {}))
|
|
30
|
+
collect(block);
|
|
31
|
+
};
|
|
32
|
+
next.sections.forEach(collect);
|
|
33
|
+
const spec = surfaceRules(theme, next.surface);
|
|
34
|
+
next.sections = completeRequired(next.sections, spec?.required, { id: next.surface, slot: "sections" }, "sections", issues, taken);
|
|
35
|
+
next.sections.forEach((section, index) => completeTree(theme, section, `sections[${index}]`, issues, taken, 0));
|
|
36
|
+
return { page: next, issues };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* An instance tree with its fixed and required blocks, as an editor inserts
|
|
40
|
+
* it: added ids are unique against `taken`, which receives every id of the
|
|
41
|
+
* tree. Never changes `instance`.
|
|
42
|
+
*/
|
|
43
|
+
export function completeInstance(theme, instance, taken) {
|
|
44
|
+
const next = structuredClone(instance);
|
|
45
|
+
const collect = (node) => {
|
|
46
|
+
taken.add(node.id);
|
|
47
|
+
for (const child of node.children ?? [])
|
|
48
|
+
collect(child);
|
|
49
|
+
for (const list of Object.values(node.slots ?? {}))
|
|
50
|
+
for (const child of list)
|
|
51
|
+
collect(child);
|
|
52
|
+
for (const block of Object.values(node.fixed ?? {}))
|
|
53
|
+
collect(block);
|
|
54
|
+
};
|
|
55
|
+
collect(next);
|
|
56
|
+
completeTree(theme, next, "instance", [], taken, 0);
|
|
57
|
+
return next;
|
|
58
|
+
}
|
|
59
|
+
function uniqueId(base, taken) {
|
|
60
|
+
let id = base;
|
|
61
|
+
for (let n = 2; taken.has(id); n += 1)
|
|
62
|
+
id = `${base}~${n}`;
|
|
63
|
+
taken.add(id);
|
|
64
|
+
return id;
|
|
65
|
+
}
|
|
66
|
+
function completeTree(theme, node, path, issues, taken, depth) {
|
|
67
|
+
const definition = Object.hasOwn(theme.components, node.type) ? theme.components[node.type] : undefined;
|
|
68
|
+
if (definition === undefined || depth > THEME_LIMITS.depth)
|
|
69
|
+
return;
|
|
70
|
+
const declared = definition.fixed ?? [];
|
|
71
|
+
const stored = node.fixed ?? {};
|
|
72
|
+
for (const name of Object.keys(stored)) {
|
|
73
|
+
if (!declared.some((block) => block.name === name)) {
|
|
74
|
+
issues.push({ path: `${path}.fixed.${name}`, code: "unknown-fixed", message: `"${node.type}" places no fixed block "${name}"; removed` });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (declared.length > 0) {
|
|
78
|
+
const fixed = {};
|
|
79
|
+
for (const { name, type } of declared) {
|
|
80
|
+
const entry = Object.hasOwn(stored, name) ? stored[name] : undefined;
|
|
81
|
+
if (entry !== undefined && entry.type === type) {
|
|
82
|
+
delete entry.hidden;
|
|
83
|
+
fixed[name] = entry;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (entry !== undefined) {
|
|
87
|
+
issues.push({ path: `${path}.fixed.${name}.type`, code: "fixed-type-mismatch", message: `the fixed block "${name}" is a "${type}", not a "${entry.type}"; replaced by one with its defaults` });
|
|
88
|
+
}
|
|
89
|
+
fixed[name] = { id: uniqueId(fixedBlockId(node.id, name), taken), type };
|
|
90
|
+
}
|
|
91
|
+
node.fixed = fixed;
|
|
92
|
+
}
|
|
93
|
+
else if (node.fixed !== undefined) {
|
|
94
|
+
delete node.fixed;
|
|
95
|
+
}
|
|
96
|
+
for (const slot of definition.slots ?? []) {
|
|
97
|
+
if ((slot.required ?? []).length === 0)
|
|
98
|
+
continue;
|
|
99
|
+
const before = issues.length;
|
|
100
|
+
if (slot.name === "default") {
|
|
101
|
+
const list = [...(node.children ?? []), ...(node.slots?.default ?? [])];
|
|
102
|
+
const done = completeRequired(list, slot.required, { id: node.id, slot: "default" }, `${path}.children`, issues, taken);
|
|
103
|
+
if (issues.length === before)
|
|
104
|
+
continue;
|
|
105
|
+
node.children = done;
|
|
106
|
+
if (node.slots?.default !== undefined) {
|
|
107
|
+
delete node.slots.default;
|
|
108
|
+
if (Object.keys(node.slots).length === 0)
|
|
109
|
+
delete node.slots;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
const list = node.slots !== undefined && Object.hasOwn(node.slots, slot.name) ? node.slots[slot.name] : [];
|
|
114
|
+
const done = completeRequired(list, slot.required, { id: node.id, slot: slot.name }, `${path}.slots.${slot.name}`, issues, taken);
|
|
115
|
+
if (issues.length === before)
|
|
116
|
+
continue;
|
|
117
|
+
node.slots = { ...node.slots, [slot.name]: done };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
(node.children ?? []).forEach((child, index) => completeTree(theme, child, `${path}.children[${index}]`, issues, taken, depth + 1));
|
|
121
|
+
for (const [name, list] of Object.entries(node.slots ?? {})) {
|
|
122
|
+
list.forEach((child, index) => completeTree(theme, child, `${path}.slots.${name}[${index}]`, issues, taken, depth + 1));
|
|
123
|
+
}
|
|
124
|
+
for (const [name, block] of Object.entries(node.fixed ?? {}))
|
|
125
|
+
completeTree(theme, block, `${path}.fixed.${name}`, issues, taken, depth + 1);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The page a surface or area renders: the saved document when this theme can
|
|
129
|
+
* render it, else the theme's default (`SurfaceSpec.document` or
|
|
130
|
+
* `AreaSpec.document`, or an empty page). A saved document that names another theme, or whose sections
|
|
131
|
+
* this catalog knows none of, renders the default — a theme switch must not
|
|
132
|
+
* leave an empty page. A deliberately emptied document renders empty. The
|
|
133
|
+
* result is completed (`completePage`), so required and fixed blocks render
|
|
134
|
+
* whatever was saved.
|
|
135
|
+
*/
|
|
136
|
+
export function resolveSurfaceDocument(theme, surface, saved, scope = {}) {
|
|
137
|
+
const fallback = (reason, savedIssues) => {
|
|
138
|
+
const shipped = parsePageData(shippedDocument(theme, surface) ?? { version: 1, surface, sections: [] });
|
|
139
|
+
const page = shipped.page ?? { version: 1, surface, sections: [] };
|
|
140
|
+
return { page: completePage(theme, page).page, source: "default", reason, issues: [...savedIssues, ...shipped.issues] };
|
|
141
|
+
};
|
|
142
|
+
if (saved === null || saved === undefined)
|
|
143
|
+
return fallback("absent", []);
|
|
144
|
+
const checked = validatePageData(theme, saved, scope);
|
|
145
|
+
if (checked.page === null)
|
|
146
|
+
return fallback("invalid", checked.issues);
|
|
147
|
+
if (checked.page.surface !== surface) {
|
|
148
|
+
return fallback("other-surface", [
|
|
149
|
+
...checked.issues,
|
|
150
|
+
{ path: "surface", code: "envelope-invalid", message: `the saved document is for "${checked.page.surface}", not "${surface}"` },
|
|
151
|
+
]);
|
|
152
|
+
}
|
|
153
|
+
if (checked.page.theme !== undefined && checked.page.theme.key !== theme.key)
|
|
154
|
+
return fallback("other-theme", checked.issues);
|
|
155
|
+
const sections = checked.page.sections;
|
|
156
|
+
if (sections.length > 0 && sections.every((section) => !Object.hasOwn(theme.components, section.type))) {
|
|
157
|
+
return fallback("no-overlap", checked.issues);
|
|
158
|
+
}
|
|
159
|
+
const cap = slotCap(surfaceRules(theme, surface), THEME_LIMITS.sections);
|
|
160
|
+
const renderable = { ...checked.page, sections: sections.slice(0, cap) };
|
|
161
|
+
return { page: completePage(theme, renderable).page, source: "saved", reason: null, issues: checked.issues };
|
|
162
|
+
}
|