@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.
Files changed (57) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +239 -0
  3. package/bin/taproot-site.js +5 -0
  4. package/package.json +40 -0
  5. package/src/api.js +1507 -0
  6. package/src/appearance-contract.js +311 -0
  7. package/src/atomic-file.js +136 -0
  8. package/src/bounded-file.js +59 -0
  9. package/src/capabilities.js +29 -0
  10. package/src/cli.js +872 -0
  11. package/src/config.js +412 -0
  12. package/src/constants.js +173 -0
  13. package/src/content/components.js +870 -0
  14. package/src/content/free-form-section-registry.json +524 -0
  15. package/src/content/free-form-sections.js +582 -0
  16. package/src/content/index.js +29 -0
  17. package/src/content/inline-facts.js +97 -0
  18. package/src/content/markdown.js +1625 -0
  19. package/src/content/tiptap-prosemirror.ts +1266 -0
  20. package/src/content/validate-document.js +1079 -0
  21. package/src/content/vocabulary.js +397 -0
  22. package/src/credentials.js +544 -0
  23. package/src/errors.js +133 -0
  24. package/src/footer-contract.js +687 -0
  25. package/src/footer-draft-hash.js +203 -0
  26. package/src/footer-workspace.js +136 -0
  27. package/src/image-metadata.js +169 -0
  28. package/src/index.js +16 -0
  29. package/src/json-guard.js +111 -0
  30. package/src/output.js +157 -0
  31. package/src/presentation-reference.js +600 -0
  32. package/src/reference-help.js +927 -0
  33. package/src/session.js +316 -0
  34. package/src/settings-catalog.js +187 -0
  35. package/src/settings.js +157 -0
  36. package/src/theme-validation.js +344 -0
  37. package/src/transport.js +777 -0
  38. package/src/verbs/approve.js +178 -0
  39. package/src/verbs/deploy.js +327 -0
  40. package/src/verbs/env.js +89 -0
  41. package/src/verbs/footer-push.js +115 -0
  42. package/src/verbs/index.js +125 -0
  43. package/src/verbs/login.js +529 -0
  44. package/src/verbs/logout.js +39 -0
  45. package/src/verbs/media-upload.js +319 -0
  46. package/src/verbs/nav-push.js +333 -0
  47. package/src/verbs/pages-push.js +990 -0
  48. package/src/verbs/preview-page.js +107 -0
  49. package/src/verbs/preview-revoke.js +56 -0
  50. package/src/verbs/pull.js +727 -0
  51. package/src/verbs/sites.js +57 -0
  52. package/src/verbs/status.js +94 -0
  53. package/src/verbs/theme-push.js +197 -0
  54. package/src/verbs/use.js +129 -0
  55. package/src/verbs/validate.js +477 -0
  56. package/src/verbs/whoami.js +108 -0
  57. 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
+ }