@taprootio/site-authoring 0.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/LICENSE +15 -0
- package/README.md +239 -0
- package/bin/taproot-site.js +5 -0
- package/package.json +40 -0
- package/src/api.js +1507 -0
- package/src/appearance-contract.js +311 -0
- package/src/atomic-file.js +136 -0
- package/src/bounded-file.js +59 -0
- package/src/capabilities.js +29 -0
- package/src/cli.js +872 -0
- package/src/config.js +412 -0
- package/src/constants.js +173 -0
- package/src/content/components.js +870 -0
- package/src/content/free-form-section-registry.json +524 -0
- package/src/content/free-form-sections.js +582 -0
- package/src/content/index.js +29 -0
- package/src/content/inline-facts.js +97 -0
- package/src/content/markdown.js +1625 -0
- package/src/content/tiptap-prosemirror.ts +1266 -0
- package/src/content/validate-document.js +1079 -0
- package/src/content/vocabulary.js +397 -0
- package/src/credentials.js +544 -0
- package/src/errors.js +133 -0
- package/src/footer-contract.js +687 -0
- package/src/footer-draft-hash.js +203 -0
- package/src/footer-workspace.js +136 -0
- package/src/image-metadata.js +169 -0
- package/src/index.js +16 -0
- package/src/json-guard.js +111 -0
- package/src/output.js +157 -0
- package/src/presentation-reference.js +600 -0
- package/src/reference-help.js +927 -0
- package/src/session.js +316 -0
- package/src/settings-catalog.js +187 -0
- package/src/settings.js +157 -0
- package/src/theme-validation.js +344 -0
- package/src/transport.js +777 -0
- package/src/verbs/approve.js +178 -0
- package/src/verbs/deploy.js +327 -0
- package/src/verbs/env.js +89 -0
- package/src/verbs/footer-push.js +115 -0
- package/src/verbs/index.js +125 -0
- package/src/verbs/login.js +529 -0
- package/src/verbs/logout.js +39 -0
- package/src/verbs/media-upload.js +319 -0
- package/src/verbs/nav-push.js +333 -0
- package/src/verbs/pages-push.js +990 -0
- package/src/verbs/preview-page.js +107 -0
- package/src/verbs/preview-revoke.js +56 -0
- package/src/verbs/pull.js +727 -0
- package/src/verbs/sites.js +57 -0
- package/src/verbs/status.js +94 -0
- package/src/verbs/theme-push.js +197 -0
- package/src/verbs/use.js +129 -0
- package/src/verbs/validate.js +477 -0
- package/src/verbs/whoami.js +108 -0
- package/src/workspace.js +1085 -0
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
// Espalier 4.2 exposes this registry value only through its root component,
|
|
2
|
+
// whose import also registers `esp-root`. Replace this with the side-effect-free
|
|
3
|
+
// shared registry export when Espalier publishes that package subpath.
|
|
4
|
+
import { BUILT_IN_IMAGE_TEXTURES } from "@taprootio/espalier/root";
|
|
5
|
+
import { hasAsciiControl } from "../errors.js";
|
|
6
|
+
import {
|
|
7
|
+
CONTENT_ERROR_CODES as CODES,
|
|
8
|
+
CONTENT_LIMITS,
|
|
9
|
+
contentError,
|
|
10
|
+
identifier,
|
|
11
|
+
isPlainObject,
|
|
12
|
+
isSafeUrl,
|
|
13
|
+
isUuid,
|
|
14
|
+
pointerSegment,
|
|
15
|
+
} from "./vocabulary.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The eight canonical `componentBlock` data shapes. Seven remain mirrored
|
|
19
|
+
* field for field from the browser component templates; `hero-section` is the
|
|
20
|
+
* pre-adoption replacement owned here and by the published renderer.
|
|
21
|
+
*
|
|
22
|
+
* `componentBlock.attrs.componentData` is a JSON **string**, not an object.
|
|
23
|
+
* Nothing on the server looks inside it: the renderer copies it verbatim into a
|
|
24
|
+
* `data-component-data` attribute and the generator's component renderer reads
|
|
25
|
+
* whatever fields it finds, defaulting anything missing and ignoring anything
|
|
26
|
+
* unknown. A typo'd enum value therefore publishes as a component silently
|
|
27
|
+
* rendered with the wrong layout, and a typo'd field name publishes as a
|
|
28
|
+
* component silently rendered with none of the author's content — which is why
|
|
29
|
+
* these tables exist and why unknown fields are refused.
|
|
30
|
+
*
|
|
31
|
+
* Two deliberate asymmetries:
|
|
32
|
+
*
|
|
33
|
+
* - **Absent optional fields are accepted.** The component registry supplies
|
|
34
|
+
* their initial/default values. Fields explicitly marked required (the hero
|
|
35
|
+
* title and primary action, plus required nested image/action keys) fail when
|
|
36
|
+
* absent.
|
|
37
|
+
* - **A present field is checked strictly**, including nested objects and array
|
|
38
|
+
* items, because a present-but-wrong value is exactly what renders wrong.
|
|
39
|
+
*
|
|
40
|
+
* Field order in each table is the interface's declaration order, and it is
|
|
41
|
+
* the order `canonicalizeComponentData` serialises in.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
const MAX_ITEMS = 100;
|
|
45
|
+
const LATEST_POST_PAGE_TYPES = Object.freeze([
|
|
46
|
+
"TEMPLATE_TYPE_ARTICLE",
|
|
47
|
+
"TEMPLATE_TYPE_ALBUM",
|
|
48
|
+
"TEMPLATE_TYPE_RECIPE",
|
|
49
|
+
"TEMPLATE_TYPE_PLACE_REVIEW",
|
|
50
|
+
"TEMPLATE_TYPE_FREE_FORM",
|
|
51
|
+
]);
|
|
52
|
+
const REGISTERED_TEXTURE_NAME_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
|
53
|
+
|
|
54
|
+
const str = (options = {}) => Object.freeze({ kind: "string", ...options });
|
|
55
|
+
const bool = () => Object.freeze({ kind: "boolean" });
|
|
56
|
+
const num = (options = {}) => Object.freeze({ kind: "number", ...options });
|
|
57
|
+
const enumOf = (...values) => Object.freeze({ kind: "enum", values: Object.freeze(values) });
|
|
58
|
+
const list = (item) => Object.freeze({ kind: "array", item });
|
|
59
|
+
const shape = (fields, options = {}) => Object.freeze({ kind: "object", fields: Object.freeze(fields), ...options });
|
|
60
|
+
const image = () => Object.freeze({ kind: "image" });
|
|
61
|
+
const safeUrl = (options = {}) => Object.freeze({ kind: "safe-url", ...options });
|
|
62
|
+
const actionUrl = () => Object.freeze({ kind: "action-url" });
|
|
63
|
+
const textureName = () => Object.freeze({ kind: "texture-name", maximumLength: 64 });
|
|
64
|
+
const withOmission = (spec, whenOmitted) => Object.freeze({ ...spec, whenOmitted: Object.freeze(whenOmitted) });
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `ComponentImageData`. `src` and `urls` are the only required-by-key fields
|
|
68
|
+
* anywhere in these tables: `PageImageDeliveryRewriter.ReplaceUrlFields` walks
|
|
69
|
+
* the parsed component data and rewrites `src`/`url`/`urls` **only where the
|
|
70
|
+
* key already exists**, so an image object without them keeps whatever the
|
|
71
|
+
* author wrote — or, far more often, renders blank. `imageId` is required for
|
|
72
|
+
* the same reason: `TryResolveImageId` needs it to find the delivery source at
|
|
73
|
+
* all.
|
|
74
|
+
*/
|
|
75
|
+
const COMPONENT_IMAGE_FIELDS = Object.freeze({
|
|
76
|
+
imageId: Object.freeze({ kind: "uuid", required: true }),
|
|
77
|
+
src: Object.freeze({ kind: "string", required: true, missingCode: CODES.imageKeys }),
|
|
78
|
+
urls: Object.freeze({
|
|
79
|
+
kind: "array",
|
|
80
|
+
required: true,
|
|
81
|
+
missingCode: CODES.imageKeys,
|
|
82
|
+
item: shape({
|
|
83
|
+
minWidth: num({ integer: true, minimum: 1 }),
|
|
84
|
+
url: str(),
|
|
85
|
+
}),
|
|
86
|
+
}),
|
|
87
|
+
// Zero is allowed here, unlike on a `taprootImage` node: the editor's
|
|
88
|
+
// `uploadComponentImage` falls back to `width: 0` when the processed image
|
|
89
|
+
// has not reported its dimensions yet, and refusing that would refuse a
|
|
90
|
+
// pulled document on its way back.
|
|
91
|
+
width: num({ integer: true, minimum: 0 }),
|
|
92
|
+
height: num({ integer: true, minimum: 0 }),
|
|
93
|
+
alt: str(),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* `CropState` — persisted so the crop dialog can reopen across sessions. The
|
|
98
|
+
* four crop-region fields are optional in the interface (absent when the user
|
|
99
|
+
* uploaded but cancelled the crop), and the whole object is nullable.
|
|
100
|
+
*/
|
|
101
|
+
const CROP_STATE = shape({
|
|
102
|
+
originalSrc: str(),
|
|
103
|
+
originalWidth: num({ integer: true, minimum: 0 }),
|
|
104
|
+
originalHeight: num({ integer: true, minimum: 0 }),
|
|
105
|
+
cropX: num({ minimum: 0, maximum: 1 }),
|
|
106
|
+
cropY: num({ minimum: 0, maximum: 1 }),
|
|
107
|
+
cropWidth: num({ minimum: 0, maximum: 1 }),
|
|
108
|
+
cropHeight: num({ minimum: 0, maximum: 1 }),
|
|
109
|
+
}, { nullable: true });
|
|
110
|
+
|
|
111
|
+
const HERO_ACTION = shape({
|
|
112
|
+
label: str({ required: true, nonWhitespace: true }),
|
|
113
|
+
url: safeUrl({ required: true, allowEmpty: false }),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/** The pre-adoption `HeroSectionData` replacement. */
|
|
117
|
+
const HERO_SECTION = Object.freeze({
|
|
118
|
+
overline: str(),
|
|
119
|
+
title: str({ required: true, nonWhitespace: true }),
|
|
120
|
+
titleSize: enumOf("normal", "display"),
|
|
121
|
+
lead: str(),
|
|
122
|
+
primaryAction: Object.freeze({ ...HERO_ACTION, required: true }),
|
|
123
|
+
secondaryAction: Object.freeze({ ...HERO_ACTION, nullable: true }),
|
|
124
|
+
alignment: enumOf("start", "center"),
|
|
125
|
+
media: image(),
|
|
126
|
+
mediaArrangement: enumOf("split", "stacked"),
|
|
127
|
+
mediaPosition: enumOf("before", "after"),
|
|
128
|
+
mediaWidth: enumOf("narrow", "equal", "wide"),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* `CtaData`.
|
|
133
|
+
*
|
|
134
|
+
* `variant` narrowed from five values to `primary|danger` at the Espalier 3
|
|
135
|
+
* cutover, which retired the token re-seeding the three geometric values
|
|
136
|
+
* (`analogous-left`, `analogous-right`, `complementary`) depended on. Pages
|
|
137
|
+
* authored earlier may still store one: the generator's component renderer
|
|
138
|
+
* misses it in its own allowlist and emits no attribute at all, so the
|
|
139
|
+
* section publishes as the plain default rather than failing.
|
|
140
|
+
*
|
|
141
|
+
* Authoring one is refused here, and deliberately so. Pulling a pre-cutover
|
|
142
|
+
* page and pushing it back unchanged fails loudly on this field instead of
|
|
143
|
+
* silently rewriting a value the author never touched; the fix is to set a
|
|
144
|
+
* current value, which is also what the editor does on its next save.
|
|
145
|
+
*/
|
|
146
|
+
const CTA = Object.freeze({
|
|
147
|
+
heading: str(),
|
|
148
|
+
description: str(),
|
|
149
|
+
buttonText: str(),
|
|
150
|
+
buttonUrl: actionUrl(),
|
|
151
|
+
variant: enumOf("primary", "danger"),
|
|
152
|
+
borderWidth: num({ integer: true, minimum: 0 }),
|
|
153
|
+
imagePosition: enumOf("top", "left", "right"),
|
|
154
|
+
mediaImage: image(),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
/** `FeatureGridData`. */
|
|
158
|
+
const FEATURE_GRID = Object.freeze({
|
|
159
|
+
items: withOmission(
|
|
160
|
+
list(shape({
|
|
161
|
+
icon: image(),
|
|
162
|
+
title: str(),
|
|
163
|
+
description: str(),
|
|
164
|
+
url: safeUrl(),
|
|
165
|
+
})),
|
|
166
|
+
{ kind: "value", value: Object.freeze([]) },
|
|
167
|
+
),
|
|
168
|
+
columns: num({ values: Object.freeze([2, 3, 4]) }),
|
|
169
|
+
iconSize: enumOf("small", "medium", "large", "xlarge"),
|
|
170
|
+
borderWidth: num({ integer: true, minimum: 0 }),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
/** `TestimonialData` and its `TestimonialItem`. */
|
|
174
|
+
const TESTIMONIAL = Object.freeze({
|
|
175
|
+
items: withOmission(
|
|
176
|
+
list(shape({
|
|
177
|
+
quote: str(),
|
|
178
|
+
authorName: str(),
|
|
179
|
+
authorTitle: str(),
|
|
180
|
+
authorImage: image(),
|
|
181
|
+
})),
|
|
182
|
+
{ kind: "value", value: Object.freeze([]) },
|
|
183
|
+
),
|
|
184
|
+
columns: num({ values: Object.freeze([1, 2, 3]) }),
|
|
185
|
+
carousel: bool(),
|
|
186
|
+
interval: num({ integer: true, minimum: 2000 }),
|
|
187
|
+
borderWidth: num({ integer: true, minimum: 0 }),
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
/** `LatestPostsData` — resolved at generation time. */
|
|
191
|
+
const LATEST_POSTS = Object.freeze({
|
|
192
|
+
count: num({ values: Object.freeze([3, 6, 9, 12]) }),
|
|
193
|
+
columns: num({ values: Object.freeze([2, 3, 4]) }),
|
|
194
|
+
pageTypes: list(enumOf(...LATEST_POST_PAGE_TYPES)),
|
|
195
|
+
showPinned: bool(),
|
|
196
|
+
highlightFirst: bool(),
|
|
197
|
+
showDescription: bool(),
|
|
198
|
+
showDate: bool(),
|
|
199
|
+
showAuthor: bool(),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
/** `CardGridData`. `cropState` is the one genuinely optional field in types.ts. */
|
|
203
|
+
const CARD_GRID = Object.freeze({
|
|
204
|
+
cards: withOmission(
|
|
205
|
+
list(shape({
|
|
206
|
+
image: image(),
|
|
207
|
+
title: str(),
|
|
208
|
+
description: str(),
|
|
209
|
+
linkUrl: safeUrl(),
|
|
210
|
+
cropState: CROP_STATE,
|
|
211
|
+
})),
|
|
212
|
+
{ kind: "value", value: Object.freeze([]) },
|
|
213
|
+
),
|
|
214
|
+
columns: num({ values: Object.freeze([2, 3]) }),
|
|
215
|
+
borderWidth: num({ integer: true, minimum: 0 }),
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
/** `ImageBannerData`. */
|
|
219
|
+
const IMAGE_BANNER = Object.freeze({
|
|
220
|
+
image: image(),
|
|
221
|
+
altText: str(),
|
|
222
|
+
overlayText: str(),
|
|
223
|
+
fontSize: enumOf("large", "standard", "small"),
|
|
224
|
+
focus: shape({
|
|
225
|
+
x: num({ minimum: 0, maximum: 1 }),
|
|
226
|
+
y: num({ minimum: 0, maximum: 1 }),
|
|
227
|
+
}),
|
|
228
|
+
ratio: enumOf("4/1", "3/1", "2/1"),
|
|
229
|
+
compactRatio: enumOf("", "2/1", "3/2", "1/1"),
|
|
230
|
+
contentPosition: enumOf("bottom-start", "bottom", "bottom-end", "center", "top-start", "top", "top-end"),
|
|
231
|
+
scrim: enumOf("auto", "none", "flat", "top", "bottom", "left", "right", "radial"),
|
|
232
|
+
scrimStrength: enumOf("soft", "medium", "strong"),
|
|
233
|
+
bannerScheme: enumOf("auto", "light", "dark"),
|
|
234
|
+
// A built-in or application-registered Espalier texture name.
|
|
235
|
+
texture: textureName(),
|
|
236
|
+
textureScale: enumOf("fine", "medium", "coarse"),
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const SPACER = Object.freeze({
|
|
240
|
+
height: enumOf("small", "medium", "large"),
|
|
241
|
+
showDivider: bool(),
|
|
242
|
+
dividerStyle: enumOf("solid", "dashed", "dotted"),
|
|
243
|
+
dividerWidth: num({ integer: true, minimum: 1 }),
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
function deepFreeze(value) {
|
|
247
|
+
if (value === null || typeof value !== "object" || Object.isFrozen(value)) return value;
|
|
248
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
249
|
+
return Object.freeze(value);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function component(fields, displayName, summary, defaultData, accessibility, example) {
|
|
253
|
+
return deepFreeze({ fields, displayName, summary, defaultData, accessibility, example });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The executable component registry. Validation, canonicalization, and
|
|
258
|
+
* reference help all consume these same records. A component cannot acquire a
|
|
259
|
+
* schema without also declaring its defaults, accessibility guidance, and a
|
|
260
|
+
* validator-backed example.
|
|
261
|
+
*/
|
|
262
|
+
const COMPONENT_DEFINITIONS = Object.freeze({
|
|
263
|
+
"hero-section": component(
|
|
264
|
+
HERO_SECTION,
|
|
265
|
+
"Hero Section",
|
|
266
|
+
"Content-first introduction with a display-capable title, two actions, and optional content media.",
|
|
267
|
+
{
|
|
268
|
+
overline: "",
|
|
269
|
+
title: "Welcome",
|
|
270
|
+
titleSize: "normal",
|
|
271
|
+
lead: "",
|
|
272
|
+
primaryAction: { label: "Learn more", url: "/" },
|
|
273
|
+
secondaryAction: null,
|
|
274
|
+
alignment: "start",
|
|
275
|
+
media: null,
|
|
276
|
+
mediaArrangement: "split",
|
|
277
|
+
mediaPosition: "after",
|
|
278
|
+
mediaWidth: "equal",
|
|
279
|
+
},
|
|
280
|
+
[
|
|
281
|
+
"Use a descriptive alt value on every meaningful image; use an empty alt value only for decorative imagery.",
|
|
282
|
+
"Action labels should describe their destination or action instead of using generic text such as 'Click here'.",
|
|
283
|
+
"The hero title is the page's h1; do not add a second h1 around it.",
|
|
284
|
+
],
|
|
285
|
+
{
|
|
286
|
+
overline: "Riverbend Hot Yoga · Elm Harbor",
|
|
287
|
+
title: "Come as you are. Leave feeling stronger.",
|
|
288
|
+
titleSize: "display",
|
|
289
|
+
lead: "Hot yoga, barre, and wellness practices for every body.",
|
|
290
|
+
primaryAction: { label: "View class schedule", url: "/classes" },
|
|
291
|
+
secondaryAction: { label: "Meet the studio", url: "/about" },
|
|
292
|
+
alignment: "start",
|
|
293
|
+
media: {
|
|
294
|
+
imageId: "3f1c2b4a-5d6e-4f70-8a91-b2c3d4e5f607",
|
|
295
|
+
src: "https://static.example.test/site/img/riverbend-studio.webp",
|
|
296
|
+
urls: [{ minWidth: 640, url: "https://static.example.test/site/img/riverbend-studio-640.webp" }],
|
|
297
|
+
width: 1600,
|
|
298
|
+
height: 1067,
|
|
299
|
+
alt: "A welcoming hot yoga class at Riverbend Wellness",
|
|
300
|
+
},
|
|
301
|
+
mediaArrangement: "split",
|
|
302
|
+
mediaPosition: "after",
|
|
303
|
+
mediaWidth: "wide",
|
|
304
|
+
},
|
|
305
|
+
),
|
|
306
|
+
"cta": component(
|
|
307
|
+
CTA,
|
|
308
|
+
"Call to Action",
|
|
309
|
+
"Focused prompt with supporting copy, a button, and optional media.",
|
|
310
|
+
{
|
|
311
|
+
heading: "",
|
|
312
|
+
description: "",
|
|
313
|
+
buttonText: "",
|
|
314
|
+
buttonUrl: "",
|
|
315
|
+
variant: "primary",
|
|
316
|
+
borderWidth: 0,
|
|
317
|
+
imagePosition: "top",
|
|
318
|
+
mediaImage: null,
|
|
319
|
+
},
|
|
320
|
+
[
|
|
321
|
+
"Button text must describe its action or destination.",
|
|
322
|
+
"Give meaningful media a descriptive alt value and decorative media an empty alt value.",
|
|
323
|
+
],
|
|
324
|
+
{
|
|
325
|
+
heading: "Ready to begin?",
|
|
326
|
+
description: "Reserve a place in this week's introductory class.",
|
|
327
|
+
buttonText: "Reserve a class",
|
|
328
|
+
buttonUrl: "/classes/intro",
|
|
329
|
+
variant: "primary",
|
|
330
|
+
},
|
|
331
|
+
),
|
|
332
|
+
"testimonial": component(
|
|
333
|
+
TESTIMONIAL,
|
|
334
|
+
"Testimonials",
|
|
335
|
+
"Attributed quotations displayed in a grid or timed carousel.",
|
|
336
|
+
{
|
|
337
|
+
items: [{ quote: "", authorName: "", authorTitle: "", authorImage: null }],
|
|
338
|
+
columns: 1,
|
|
339
|
+
carousel: false,
|
|
340
|
+
interval: 5000,
|
|
341
|
+
borderWidth: 0,
|
|
342
|
+
},
|
|
343
|
+
[
|
|
344
|
+
"Identify the speaker with authorName; use authorTitle when it gives useful context.",
|
|
345
|
+
"Author images need descriptive alt text unless they are purely decorative.",
|
|
346
|
+
"Keep carousel intervals long enough for the full quotation to be read.",
|
|
347
|
+
],
|
|
348
|
+
{
|
|
349
|
+
items: [{
|
|
350
|
+
quote: "The coaches made the first session feel welcoming.",
|
|
351
|
+
authorName: "Alex Rivera",
|
|
352
|
+
authorTitle: "Member",
|
|
353
|
+
authorImage: null,
|
|
354
|
+
}],
|
|
355
|
+
columns: 1,
|
|
356
|
+
carousel: false,
|
|
357
|
+
},
|
|
358
|
+
),
|
|
359
|
+
"feature-grid": component(
|
|
360
|
+
FEATURE_GRID,
|
|
361
|
+
"Feature Grid",
|
|
362
|
+
"Two-to-four-column collection of features with optional icons and links.",
|
|
363
|
+
{
|
|
364
|
+
items: [{ icon: null, title: "", description: "", url: "" }],
|
|
365
|
+
columns: 3,
|
|
366
|
+
iconSize: "medium",
|
|
367
|
+
borderWidth: 0,
|
|
368
|
+
},
|
|
369
|
+
[
|
|
370
|
+
"Each linked item needs a title that remains meaningful as link text.",
|
|
371
|
+
"Icon images need descriptive alt text when they convey information; otherwise use an empty alt value.",
|
|
372
|
+
],
|
|
373
|
+
{
|
|
374
|
+
items: [
|
|
375
|
+
{ icon: null, title: "Personal coaching", description: "A plan shaped around your goals.", url: "/coaching" },
|
|
376
|
+
{ icon: null, title: "Flexible classes", description: "Morning and evening sessions.", url: "/classes" },
|
|
377
|
+
],
|
|
378
|
+
columns: 2,
|
|
379
|
+
iconSize: "medium",
|
|
380
|
+
},
|
|
381
|
+
),
|
|
382
|
+
"spacer": component(
|
|
383
|
+
SPACER,
|
|
384
|
+
"Spacer",
|
|
385
|
+
"Intentional vertical separation with an optional decorative divider.",
|
|
386
|
+
{ height: "medium", showDivider: false, dividerStyle: "solid", dividerWidth: 1 },
|
|
387
|
+
["Do not use spacing alone to communicate document structure; keep headings and landmarks semantic."],
|
|
388
|
+
{ height: "large", showDivider: true, dividerStyle: "solid", dividerWidth: 1 },
|
|
389
|
+
),
|
|
390
|
+
"latest-posts": component(
|
|
391
|
+
LATEST_POSTS,
|
|
392
|
+
"Latest Posts",
|
|
393
|
+
"Generation-time list of recent published pages, optionally filtered by page type.",
|
|
394
|
+
{
|
|
395
|
+
count: 6,
|
|
396
|
+
columns: 3,
|
|
397
|
+
pageTypes: [],
|
|
398
|
+
showPinned: true,
|
|
399
|
+
highlightFirst: false,
|
|
400
|
+
showDescription: true,
|
|
401
|
+
showDate: true,
|
|
402
|
+
showAuthor: false,
|
|
403
|
+
},
|
|
404
|
+
["Keep descriptions enabled when titles alone do not make each destination clear."],
|
|
405
|
+
{
|
|
406
|
+
count: 6,
|
|
407
|
+
columns: 3,
|
|
408
|
+
pageTypes: ["TEMPLATE_TYPE_ARTICLE", "TEMPLATE_TYPE_FREE_FORM"],
|
|
409
|
+
showPinned: true,
|
|
410
|
+
highlightFirst: true,
|
|
411
|
+
showDescription: true,
|
|
412
|
+
showDate: true,
|
|
413
|
+
showAuthor: false,
|
|
414
|
+
},
|
|
415
|
+
),
|
|
416
|
+
"card-grid": component(
|
|
417
|
+
CARD_GRID,
|
|
418
|
+
"Card Grid",
|
|
419
|
+
"Two- or three-column collection of linked cards with optional images.",
|
|
420
|
+
{
|
|
421
|
+
cards: [{ image: null, title: "", description: "", linkUrl: "" }],
|
|
422
|
+
columns: 3,
|
|
423
|
+
borderWidth: 0,
|
|
424
|
+
},
|
|
425
|
+
[
|
|
426
|
+
"Every linked card needs a title that describes its destination.",
|
|
427
|
+
"Card images need descriptive alt text unless they are decorative.",
|
|
428
|
+
],
|
|
429
|
+
{
|
|
430
|
+
cards: [
|
|
431
|
+
{
|
|
432
|
+
image: null,
|
|
433
|
+
title: "Beginner program",
|
|
434
|
+
description: "Build a sustainable foundation.",
|
|
435
|
+
linkUrl: "/programs/beginner",
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
image: null,
|
|
439
|
+
title: "Strength program",
|
|
440
|
+
description: "Progressive coaching for experienced members.",
|
|
441
|
+
linkUrl: "/programs/strength",
|
|
442
|
+
},
|
|
443
|
+
],
|
|
444
|
+
columns: 2,
|
|
445
|
+
borderWidth: 1,
|
|
446
|
+
},
|
|
447
|
+
),
|
|
448
|
+
"image-banner": component(
|
|
449
|
+
IMAGE_BANNER,
|
|
450
|
+
"Image Banner",
|
|
451
|
+
"Wide responsive image with focal point, optional overlay text, scrim, and texture.",
|
|
452
|
+
{
|
|
453
|
+
image: null,
|
|
454
|
+
altText: "",
|
|
455
|
+
overlayText: "",
|
|
456
|
+
fontSize: "large",
|
|
457
|
+
focus: { x: 0.5, y: 0.5 },
|
|
458
|
+
ratio: "3/1",
|
|
459
|
+
compactRatio: "3/2",
|
|
460
|
+
contentPosition: "center",
|
|
461
|
+
scrim: "auto",
|
|
462
|
+
scrimStrength: "medium",
|
|
463
|
+
bannerScheme: "auto",
|
|
464
|
+
texture: "none",
|
|
465
|
+
textureScale: "medium",
|
|
466
|
+
},
|
|
467
|
+
[
|
|
468
|
+
"Set altText to describe meaningful banner imagery; leave it empty only when the image is decorative.",
|
|
469
|
+
"Choose a scrim and scheme that keep overlay text readable across the image.",
|
|
470
|
+
],
|
|
471
|
+
{
|
|
472
|
+
image: null,
|
|
473
|
+
altText: "",
|
|
474
|
+
overlayText: "Train together",
|
|
475
|
+
focus: { x: 0.5, y: 0.35 },
|
|
476
|
+
contentPosition: "bottom-start",
|
|
477
|
+
scrim: "bottom",
|
|
478
|
+
scrimStrength: "medium",
|
|
479
|
+
texture: "paper",
|
|
480
|
+
},
|
|
481
|
+
),
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
export const COMPONENT_TYPES = Object.freeze(Object.keys(COMPONENT_DEFINITIONS));
|
|
485
|
+
|
|
486
|
+
/** Kept for validation/canonicalization consumers; derived from the registry. */
|
|
487
|
+
export const COMPONENT_SHAPES = Object.freeze(Object.fromEntries(
|
|
488
|
+
Object.entries(COMPONENT_DEFINITIONS).map(([type, definition]) => [type, definition.fields]),
|
|
489
|
+
));
|
|
490
|
+
|
|
491
|
+
export function getComponentDefinition(componentType) {
|
|
492
|
+
return typeof componentType === "string" && Object.hasOwn(COMPONENT_DEFINITIONS, componentType)
|
|
493
|
+
? COMPONENT_DEFINITIONS[componentType]
|
|
494
|
+
: undefined;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function referenceSchema(spec) {
|
|
498
|
+
switch (spec.kind) {
|
|
499
|
+
case "string":
|
|
500
|
+
return {
|
|
501
|
+
type: "string",
|
|
502
|
+
...(spec.nonWhitespace ? { minLength: 1, description: "Must contain non-whitespace text." } : {}),
|
|
503
|
+
};
|
|
504
|
+
case "safe-url":
|
|
505
|
+
return {
|
|
506
|
+
type: "string",
|
|
507
|
+
format: spec.allowEmpty === false ? "safe-url" : "safe-url-or-empty",
|
|
508
|
+
description:
|
|
509
|
+
`${spec.allowEmpty === false ? "An" : "Empty, or an"} explicit HTTP(S), mailto, or tel URL; a root-relative path that does not begin with //; a fragment; a query; or a relative URL. Backslashes and ASCII control characters are rejected.`,
|
|
510
|
+
};
|
|
511
|
+
case "action-url":
|
|
512
|
+
return {
|
|
513
|
+
type: "string",
|
|
514
|
+
format: "action-url-or-empty",
|
|
515
|
+
description:
|
|
516
|
+
"Empty, an explicit HTTP(S) URL, or a root-relative path that does not begin with //. Backslashes and ASCII control characters are rejected.",
|
|
517
|
+
};
|
|
518
|
+
case "texture-name":
|
|
519
|
+
return {
|
|
520
|
+
type: "string",
|
|
521
|
+
pattern: "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$",
|
|
522
|
+
maxLength: spec.maximumLength,
|
|
523
|
+
builtInValues: [...BUILT_IN_IMAGE_TEXTURES],
|
|
524
|
+
applicationRegistered: {
|
|
525
|
+
accepted: true,
|
|
526
|
+
requirement: "Register the texture with Espalier before rendering the page.",
|
|
527
|
+
},
|
|
528
|
+
description:
|
|
529
|
+
"Names outside the built-in list render with no texture until the application registers them with Espalier.",
|
|
530
|
+
};
|
|
531
|
+
case "boolean":
|
|
532
|
+
return { type: "boolean" };
|
|
533
|
+
case "uuid":
|
|
534
|
+
return { type: "string", format: "lowercase-uuid" };
|
|
535
|
+
case "enum":
|
|
536
|
+
return { type: "string", enum: [...spec.values] };
|
|
537
|
+
case "number": {
|
|
538
|
+
const schema = { type: spec.integer ? "integer" : "number" };
|
|
539
|
+
if (spec.values) schema.enum = [...spec.values];
|
|
540
|
+
if (spec.minimum !== undefined) schema.minimum = spec.minimum;
|
|
541
|
+
if (spec.maximum !== undefined) schema.maximum = spec.maximum;
|
|
542
|
+
return schema;
|
|
543
|
+
}
|
|
544
|
+
case "array":
|
|
545
|
+
return { type: "array", maxItems: MAX_ITEMS, items: referenceSchema(spec.item) };
|
|
546
|
+
case "object":
|
|
547
|
+
return {
|
|
548
|
+
type: spec.nullable ? ["object", "null"] : "object",
|
|
549
|
+
additionalProperties: false,
|
|
550
|
+
properties: referenceProperties(spec.fields),
|
|
551
|
+
};
|
|
552
|
+
case "image":
|
|
553
|
+
return {
|
|
554
|
+
type: ["object", "null"],
|
|
555
|
+
description:
|
|
556
|
+
"null, or a media-upload result. Keep imageId, src, and urls keys so the server can rewrite delivery URLs.",
|
|
557
|
+
additionalProperties: false,
|
|
558
|
+
properties: referenceProperties(COMPONENT_IMAGE_FIELDS),
|
|
559
|
+
};
|
|
560
|
+
default:
|
|
561
|
+
throw new TypeError(`Unknown component schema kind '${spec.kind}'.`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function referenceProperties(fields, defaults) {
|
|
566
|
+
return Object.entries(fields).map(([name, spec]) => ({
|
|
567
|
+
name,
|
|
568
|
+
required: spec.required === true,
|
|
569
|
+
...(defaults === undefined
|
|
570
|
+
? {}
|
|
571
|
+
: {
|
|
572
|
+
editorInitial: Object.hasOwn(defaults, name)
|
|
573
|
+
? { kind: "value", value: defaults[name] }
|
|
574
|
+
: { kind: "omitted" },
|
|
575
|
+
whenOmitted: spec.whenOmitted ?? (spec.required
|
|
576
|
+
? { kind: "invalid" }
|
|
577
|
+
: Object.hasOwn(defaults, name)
|
|
578
|
+
? { kind: "value", value: defaults[name] }
|
|
579
|
+
: { kind: "unspecified" }),
|
|
580
|
+
}),
|
|
581
|
+
schema: referenceSchema(spec),
|
|
582
|
+
}));
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Exact, JSON-serializable property schema derived from the validator table. */
|
|
586
|
+
export function getComponentPropertyReference(componentType) {
|
|
587
|
+
const definition = getComponentDefinition(componentType);
|
|
588
|
+
return definition ? referenceProperties(definition.fields, definition.defaultData) : undefined;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
export function isComponentType(value) {
|
|
592
|
+
return typeof value === "string" && Object.hasOwn(COMPONENT_SHAPES, value);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// ---------------------------------------------------------------------------
|
|
596
|
+
// Validation
|
|
597
|
+
// ---------------------------------------------------------------------------
|
|
598
|
+
|
|
599
|
+
function describe(spec) {
|
|
600
|
+
switch (spec.kind) {
|
|
601
|
+
case "enum":
|
|
602
|
+
return `one of ${spec.values.map((value) => `"${value}"`).join(", ")}`;
|
|
603
|
+
case "number":
|
|
604
|
+
if (spec.values) return `one of ${spec.values.join(", ")}`;
|
|
605
|
+
return spec.integer ? "an integer" : "a number";
|
|
606
|
+
case "array":
|
|
607
|
+
return "an array";
|
|
608
|
+
case "object":
|
|
609
|
+
return "an object";
|
|
610
|
+
case "image":
|
|
611
|
+
return "an image object or null";
|
|
612
|
+
case "uuid":
|
|
613
|
+
return "a canonical lowercase UUID";
|
|
614
|
+
case "safe-url":
|
|
615
|
+
return `${spec.allowEmpty === false ? "a" : "an empty string or a"} safe HTTP(S), mailto, tel, non-protocol-relative root path, fragment, query, or relative URL; backslashes and ASCII control characters are rejected`;
|
|
616
|
+
case "action-url":
|
|
617
|
+
return "an empty string, explicit HTTP(S) URL, or non-protocol-relative root path; backslashes and ASCII control characters are rejected";
|
|
618
|
+
case "texture-name":
|
|
619
|
+
return "a lowercase hyphenated texture identifier of at most 64 characters";
|
|
620
|
+
default:
|
|
621
|
+
return `a ${spec.kind}`;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function validateValue(spec, value, path, errors) {
|
|
626
|
+
const fail = (detail) => {
|
|
627
|
+
errors.push(contentError(path, CODES.componentData, `Component data at '${path}' must be ${detail}.`));
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
// A nullable field's null is the interface's own "not set" value, never a
|
|
631
|
+
// shape violation.
|
|
632
|
+
if (spec.nullable && value === null) return;
|
|
633
|
+
|
|
634
|
+
switch (spec.kind) {
|
|
635
|
+
case "string":
|
|
636
|
+
if (typeof value !== "string") fail("a string");
|
|
637
|
+
else if (spec.nonWhitespace && value.trim() === "") fail("a string containing non-whitespace text");
|
|
638
|
+
return;
|
|
639
|
+
case "safe-url":
|
|
640
|
+
if (
|
|
641
|
+
typeof value !== "string"
|
|
642
|
+
|| (value === "" && spec.allowEmpty === false)
|
|
643
|
+
|| (value !== ""
|
|
644
|
+
&& (value.includes("\\")
|
|
645
|
+
|| hasAsciiControl(value)
|
|
646
|
+
|| value.trim().startsWith("//")
|
|
647
|
+
|| !isSafeUrl(value)))
|
|
648
|
+
) {
|
|
649
|
+
fail(describe(spec));
|
|
650
|
+
}
|
|
651
|
+
return;
|
|
652
|
+
case "action-url": {
|
|
653
|
+
if (typeof value !== "string") {
|
|
654
|
+
fail(describe(spec));
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const trimmed = value.trim();
|
|
658
|
+
const lower = trimmed.toLowerCase();
|
|
659
|
+
if (
|
|
660
|
+
value !== ""
|
|
661
|
+
&& (trimmed === ""
|
|
662
|
+
|| value.includes("\\")
|
|
663
|
+
|| hasAsciiControl(value)
|
|
664
|
+
|| trimmed.startsWith("//")
|
|
665
|
+
|| (!trimmed.startsWith("/") && !lower.startsWith("http://") && !lower.startsWith("https://")))
|
|
666
|
+
) {
|
|
667
|
+
fail(describe(spec));
|
|
668
|
+
}
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
case "texture-name":
|
|
672
|
+
if (
|
|
673
|
+
typeof value !== "string"
|
|
674
|
+
|| value.length > spec.maximumLength
|
|
675
|
+
|| !REGISTERED_TEXTURE_NAME_RE.test(value)
|
|
676
|
+
) {
|
|
677
|
+
fail(describe(spec));
|
|
678
|
+
}
|
|
679
|
+
return;
|
|
680
|
+
case "boolean":
|
|
681
|
+
if (typeof value !== "boolean") fail("a boolean");
|
|
682
|
+
return;
|
|
683
|
+
case "uuid":
|
|
684
|
+
if (!isUuid(value)) fail(describe(spec));
|
|
685
|
+
return;
|
|
686
|
+
case "enum":
|
|
687
|
+
if (typeof value !== "string" || !spec.values.includes(value)) fail(describe(spec));
|
|
688
|
+
return;
|
|
689
|
+
case "number": {
|
|
690
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
691
|
+
fail(describe(spec));
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
if (spec.values && !spec.values.includes(value)) {
|
|
695
|
+
fail(describe(spec));
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
if (spec.integer && !Number.isInteger(value)) {
|
|
699
|
+
fail("an integer");
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
if (spec.minimum !== undefined && value < spec.minimum) {
|
|
703
|
+
fail(`at least ${spec.minimum}`);
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
if (spec.maximum !== undefined && value > spec.maximum) fail(`at most ${spec.maximum}`);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
case "array": {
|
|
710
|
+
if (!Array.isArray(value)) {
|
|
711
|
+
fail("an array");
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (value.length > MAX_ITEMS) {
|
|
715
|
+
fail(`at most ${MAX_ITEMS} entries`);
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
value.forEach((entry, index) => validateValue(spec.item, entry, `${path}/${index}`, errors));
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
case "object": {
|
|
722
|
+
if (!isPlainObject(value)) {
|
|
723
|
+
fail("an object");
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
validateFields(spec.fields, value, path, errors);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
case "image": {
|
|
730
|
+
// `ComponentImageData | null` — null is the documented "no image" value.
|
|
731
|
+
if (value === null) return;
|
|
732
|
+
if (!isPlainObject(value)) {
|
|
733
|
+
fail("an image object or null");
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
validateFields(COMPONENT_IMAGE_FIELDS, value, path, errors);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
default:
|
|
740
|
+
fail("a supported value");
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function validateFields(fields, value, path, errors) {
|
|
745
|
+
for (const key of Object.keys(value)) {
|
|
746
|
+
if (!Object.hasOwn(fields, key)) {
|
|
747
|
+
errors.push(contentError(
|
|
748
|
+
`${path}/${pointerSegment(key)}`,
|
|
749
|
+
CODES.componentData,
|
|
750
|
+
`Component data field '${identifier(key)}' is not part of this component's shape.`,
|
|
751
|
+
));
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
for (const [key, spec] of Object.entries(fields)) {
|
|
755
|
+
if (!Object.hasOwn(value, key)) {
|
|
756
|
+
if (spec.required) {
|
|
757
|
+
const code = spec.missingCode ?? CODES.componentData;
|
|
758
|
+
const message = code === CODES.imageKeys
|
|
759
|
+
? `Component image at '${path}' must include a '${key}' key; the server only rewrites delivery `
|
|
760
|
+
+ "URLs onto keys that are already present, so an image without it renders blank."
|
|
761
|
+
: `Component data field '${key}' is required.`;
|
|
762
|
+
errors.push(contentError(
|
|
763
|
+
`${path}/${key}`,
|
|
764
|
+
code,
|
|
765
|
+
message,
|
|
766
|
+
));
|
|
767
|
+
}
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
validateValue(spec, value[key], `${path}/${key}`, errors);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Validates a `componentBlock`'s `componentType` and `componentData` pair.
|
|
776
|
+
*
|
|
777
|
+
* `basePath` is the JSON pointer of the owning node's `attrs`; findings inside
|
|
778
|
+
* the parsed data are reported at `<basePath>/componentData/<field>/...`, which
|
|
779
|
+
* points *through* the JSON string into the data an agent has to fix.
|
|
780
|
+
*/
|
|
781
|
+
export function validateComponentBlock(componentType, componentData, basePath) {
|
|
782
|
+
const errors = [];
|
|
783
|
+
const dataPath = `${basePath}/componentData`;
|
|
784
|
+
if (!isComponentType(componentType)) {
|
|
785
|
+
errors.push(contentError(
|
|
786
|
+
`${basePath}/componentType`,
|
|
787
|
+
CODES.componentUnknown,
|
|
788
|
+
`Unknown component type '${identifier(componentType)}'. Expected one of ${COMPONENT_TYPES.join(", ")}.`,
|
|
789
|
+
));
|
|
790
|
+
}
|
|
791
|
+
if (typeof componentData !== "string") {
|
|
792
|
+
errors.push(contentError(
|
|
793
|
+
dataPath,
|
|
794
|
+
CODES.componentData,
|
|
795
|
+
"componentData must be a JSON string, not an object — the editor and the renderer both store it as a string.",
|
|
796
|
+
));
|
|
797
|
+
return errors;
|
|
798
|
+
}
|
|
799
|
+
if (Buffer.byteLength(componentData, "utf8") > CONTENT_LIMITS.componentDataBytes) {
|
|
800
|
+
errors.push(contentError(
|
|
801
|
+
dataPath,
|
|
802
|
+
CODES.componentData,
|
|
803
|
+
`componentData exceeds ${CONTENT_LIMITS.componentDataBytes} bytes.`,
|
|
804
|
+
));
|
|
805
|
+
return errors;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
let parsed;
|
|
809
|
+
try {
|
|
810
|
+
parsed = JSON.parse(componentData);
|
|
811
|
+
} catch {
|
|
812
|
+
errors.push(contentError(dataPath, CODES.componentData, "componentData is not valid JSON."));
|
|
813
|
+
return errors;
|
|
814
|
+
}
|
|
815
|
+
if (!isPlainObject(parsed)) {
|
|
816
|
+
errors.push(contentError(dataPath, CODES.componentData, "componentData must be a JSON object."));
|
|
817
|
+
return errors;
|
|
818
|
+
}
|
|
819
|
+
// Through `isComponentType`, never a bare index: a caller-built document can
|
|
820
|
+
// carry an object as `componentType`, and indexing would coerce it through a
|
|
821
|
+
// `toString` this module does not control.
|
|
822
|
+
const fields = isComponentType(componentType) ? COMPONENT_SHAPES[componentType] : undefined;
|
|
823
|
+
if (!fields) return errors;
|
|
824
|
+
validateFields(fields, parsed, dataPath, errors);
|
|
825
|
+
return errors;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Serialises component data in its interface's declaration order.
|
|
830
|
+
*
|
|
831
|
+
* Canonical order matters because `componentData` is a string: two documents
|
|
832
|
+
* whose data differs only by key order are different strings, which means a
|
|
833
|
+
* needless page update, a needless publish, and a diff no reviewer can read.
|
|
834
|
+
* Unknown keys are refused by validation, so ordering by the table loses
|
|
835
|
+
* nothing — but the caller must validate first, and this throws if it did not.
|
|
836
|
+
*/
|
|
837
|
+
export function canonicalizeComponentData(componentType, data) {
|
|
838
|
+
const fields = isComponentType(componentType) ? COMPONENT_SHAPES[componentType] : undefined;
|
|
839
|
+
if (!fields) throw new TypeError("canonicalizeComponentData requires a known component type.");
|
|
840
|
+
if (!isPlainObject(data)) throw new TypeError("canonicalizeComponentData requires an object.");
|
|
841
|
+
const ordered = {};
|
|
842
|
+
for (const key of Object.keys(fields)) {
|
|
843
|
+
if (Object.hasOwn(data, key)) ordered[key] = orderValue(fields[key], data[key]);
|
|
844
|
+
}
|
|
845
|
+
for (const key of Object.keys(data)) {
|
|
846
|
+
if (!Object.hasOwn(fields, key)) throw new TypeError("canonicalizeComponentData requires validated data.");
|
|
847
|
+
}
|
|
848
|
+
return JSON.stringify(ordered);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function orderValue(spec, value) {
|
|
852
|
+
if (spec.kind === "array" && Array.isArray(value)) {
|
|
853
|
+
return value.map((entry) => orderValue(spec.item, entry));
|
|
854
|
+
}
|
|
855
|
+
if (spec.kind === "object" && isPlainObject(value)) {
|
|
856
|
+
return orderFields(spec.fields, value);
|
|
857
|
+
}
|
|
858
|
+
if (spec.kind === "image" && isPlainObject(value)) {
|
|
859
|
+
return orderFields(COMPONENT_IMAGE_FIELDS, value);
|
|
860
|
+
}
|
|
861
|
+
return value;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function orderFields(fields, value) {
|
|
865
|
+
const ordered = {};
|
|
866
|
+
for (const key of Object.keys(fields)) {
|
|
867
|
+
if (Object.hasOwn(value, key)) ordered[key] = orderValue(fields[key], value[key]);
|
|
868
|
+
}
|
|
869
|
+
return ordered;
|
|
870
|
+
}
|