@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,582 @@
1
+ import registry from "./free-form-section-registry.json" with { type: "json" };
2
+
3
+ import {
4
+ CONTENT_ERROR_CODES as CODES,
5
+ CONTENT_LIMITS,
6
+ contentError,
7
+ identifier,
8
+ isPlainObject,
9
+ isUuid,
10
+ pointerSegment,
11
+ } from "./vocabulary.js";
12
+
13
+ function deepFreeze(value) {
14
+ if (value === null || typeof value !== "object" || Object.isFrozen(value)) return value;
15
+ for (const child of Object.values(value)) deepFreeze(child);
16
+ return Object.freeze(value);
17
+ }
18
+
19
+ export const FREE_FORM_SECTION_REGISTRY = deepFreeze(registry);
20
+
21
+ function decorationDeliveryUrlIsSafe(value) {
22
+ if (typeof value !== "string" || value === "" || /[\u0000-\u0020\u007f]/u.test(value) || value.includes("\\")) {
23
+ return false;
24
+ }
25
+ if (value.startsWith("/")) return !value.startsWith("//");
26
+ if (!/^https:\/\//iu.test(value)) return false;
27
+ try {
28
+ const parsed = new URL(value);
29
+ return parsed.protocol === "https:"
30
+ && parsed.hostname !== ""
31
+ && parsed.username === ""
32
+ && parsed.password === "";
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ function normalizeProcessedSectionImage(value, definition, path, label, report) {
39
+ let valid = true;
40
+ const reject = (errorPath, code, message) => {
41
+ valid = false;
42
+ report(errorPath, code, message);
43
+ };
44
+
45
+ if (!isPlainObject(value)) {
46
+ reject(path, CODES.attrInvalid, `${label} must be an object.`);
47
+ return undefined;
48
+ }
49
+
50
+ const imageKeys = [...definition.requiredKeys, ...definition.optionalKeys];
51
+ for (const key of Object.keys(value)) {
52
+ if (!imageKeys.includes(key)) {
53
+ reject(
54
+ `${path}/${pointerSegment(key)}`,
55
+ CODES.attrUnknown,
56
+ `${label} field '${identifier(key)}' is not part of the processed-image shape.`,
57
+ );
58
+ }
59
+ }
60
+ for (const key of definition.requiredKeys) {
61
+ if (!Object.hasOwn(value, key)) {
62
+ reject(
63
+ `${path}/${key}`,
64
+ CODES.imageKeys,
65
+ `${label} must include '${key}' so ownership and delivery rewriting can resolve it.`,
66
+ );
67
+ }
68
+ }
69
+
70
+ if (Object.hasOwn(value, "imageId") && !isUuid(value.imageId)) {
71
+ reject(`${path}/imageId`, CODES.attrInvalid, `${label} imageId must be a canonical lowercase UUID.`);
72
+ }
73
+ if (Object.hasOwn(value, "src")) {
74
+ if (typeof value.src !== "string") {
75
+ reject(`${path}/src`, CODES.attrInvalid, `${label} src must be a string.`);
76
+ } else if (!decorationDeliveryUrlIsSafe(value.src)) {
77
+ reject(
78
+ `${path}/src`,
79
+ value.src === "" ? CODES.imageKeys : CODES.attrInvalid,
80
+ value.src === ""
81
+ ? `${label} src has no usable delivery URL. Use the complete processed-image object returned by media upload.`
82
+ : `${label} src must be an HTTPS or non-protocol-relative root URL without credentials, whitespace, controls, or backslashes.`,
83
+ );
84
+ }
85
+ }
86
+
87
+ if (Object.hasOwn(value, "urls")) {
88
+ if (!Array.isArray(value.urls)) {
89
+ reject(`${path}/urls`, CODES.attrInvalid, `${label} urls must be an array.`);
90
+ } else {
91
+ if (value.urls.length < definition.urls.minItems || value.urls.length > definition.urls.maxItems) {
92
+ reject(
93
+ `${path}/urls`,
94
+ CODES.attrInvalid,
95
+ `${label} urls must contain ${definition.urls.minItems} through ${definition.urls.maxItems} responsive candidates.`,
96
+ );
97
+ }
98
+ for (let index = 0; index < value.urls.length; index += 1) {
99
+ const option = value.urls[index];
100
+ const optionPath = `${path}/urls/${index}`;
101
+ if (!isPlainObject(option)) {
102
+ reject(optionPath, CODES.attrInvalid, `A responsive ${label.toLowerCase()} URL must be an object.`);
103
+ continue;
104
+ }
105
+ for (const key of Object.keys(option)) {
106
+ if (!Object.hasOwn(definition.urls.fields, key)) {
107
+ reject(
108
+ `${optionPath}/${pointerSegment(key)}`,
109
+ CODES.attrUnknown,
110
+ `Responsive ${label.toLowerCase()} field '${identifier(key)}' is not supported.`,
111
+ );
112
+ }
113
+ }
114
+ if (
115
+ typeof option.minWidth !== "number"
116
+ || !Number.isInteger(option.minWidth)
117
+ || option.minWidth < definition.urls.fields.minWidth.minimum
118
+ ) {
119
+ reject(`${optionPath}/minWidth`, CODES.attrInvalid, `Responsive ${label.toLowerCase()} minWidth must be a positive integer.`);
120
+ }
121
+ if (typeof option.url !== "string" || !decorationDeliveryUrlIsSafe(option.url)) {
122
+ reject(
123
+ `${optionPath}/url`,
124
+ CODES.attrInvalid,
125
+ `A responsive ${label.toLowerCase()} URL must be HTTPS or a non-protocol-relative root URL without credentials, whitespace, controls, or backslashes.`,
126
+ );
127
+ }
128
+ if (
129
+ option.type !== undefined
130
+ && (typeof option.type !== "string" || !definition.urls.fields.type.values.includes(option.type))
131
+ ) {
132
+ reject(
133
+ `${optionPath}/type`,
134
+ CODES.attrInvalid,
135
+ `Responsive ${label.toLowerCase()} type must be ${definition.urls.fields.type.values.map((entry) => JSON.stringify(entry)).join(" or ")} when present.`,
136
+ );
137
+ }
138
+ }
139
+ if (
140
+ definition.srcMustMatchUrls
141
+ && typeof value.src === "string"
142
+ && !value.urls.some((option) => isPlainObject(option) && option.url === value.src)
143
+ ) {
144
+ reject(`${path}/src`, CODES.attrInvalid, `${label} src must equal one of its responsive urls[].url candidates.`);
145
+ }
146
+ }
147
+ }
148
+
149
+ for (const name of ["width", "height"]) {
150
+ if (
151
+ Object.hasOwn(value, name)
152
+ && (typeof value[name] !== "number" || !Number.isInteger(value[name]) || value[name] <= 0)
153
+ ) {
154
+ reject(`${path}/${name}`, CODES.attrInvalid, `${label} ${name} must be a positive integer.`);
155
+ }
156
+ }
157
+ if (Object.hasOwn(value, "alt") && typeof value.alt !== "string") {
158
+ reject(`${path}/alt`, CODES.attrInvalid, `${label} alt must be a string.`);
159
+ }
160
+
161
+ if (!valid) return undefined;
162
+ return Object.freeze({
163
+ imageId: value.imageId,
164
+ src: value.src,
165
+ urls: Object.freeze(value.urls.map((option) => Object.freeze({
166
+ minWidth: option.minWidth,
167
+ url: option.url,
168
+ ...(option.type === undefined ? {} : { type: option.type }),
169
+ }))),
170
+ ...(Object.hasOwn(value, "width") ? { width: value.width } : {}),
171
+ ...(Object.hasOwn(value, "height") ? { height: value.height } : {}),
172
+ ...(Object.hasOwn(value, "alt") ? { alt: value.alt } : {}),
173
+ });
174
+ }
175
+
176
+ function normalizeSectionFocus(value, definition, path, label, report) {
177
+ if (value === undefined) value = definition.default;
178
+ if (value === null && definition.nullable === true) return null;
179
+ if (!isPlainObject(value)) {
180
+ report(path, CODES.attrInvalid, `${label} must be a closed { x, y } point.`);
181
+ return undefined;
182
+ }
183
+
184
+ let valid = true;
185
+ for (const key of Object.keys(value)) {
186
+ if (!Object.hasOwn(definition.fields, key)) {
187
+ valid = false;
188
+ report(`${path}/${pointerSegment(key)}`, CODES.attrUnknown, `${label} field '${identifier(key)}' is not supported.`);
189
+ }
190
+ }
191
+ const normalized = {};
192
+ for (const [name, coordinate] of Object.entries(definition.fields)) {
193
+ const entry = Object.hasOwn(value, name) ? value[name] : coordinate.default;
194
+ if (
195
+ typeof entry !== "number"
196
+ || !Number.isFinite(entry)
197
+ || entry < coordinate.minimum
198
+ || entry > coordinate.maximum
199
+ ) {
200
+ valid = false;
201
+ report(
202
+ `${path}/${name}`,
203
+ CODES.attrInvalid,
204
+ `${label} ${name} must be a finite number from ${coordinate.minimum} through ${coordinate.maximum}.`,
205
+ );
206
+ }
207
+ normalized[name] = entry;
208
+ }
209
+ return valid ? Object.freeze(normalized) : undefined;
210
+ }
211
+
212
+ /** Validate and canonicalize the registry-owned section photo substrate. */
213
+ export function normalizeFreeFormSectionBackground(
214
+ value,
215
+ path = "/attrs/background",
216
+ source = FREE_FORM_SECTION_REGISTRY,
217
+ ) {
218
+ const errors = [];
219
+ const report = (errorPath, code, message) => {
220
+ if (errors.length < CONTENT_LIMITS.documentErrors) errors.push(contentError(errorPath, code, message));
221
+ };
222
+ const finish = (background) => Object.freeze({ background, errors: Object.freeze(errors) });
223
+
224
+ if (value === undefined || value === null) return finish(undefined);
225
+ if (!isPlainObject(value)) {
226
+ report(path, CODES.attrInvalid, "Section background must be an object when present.");
227
+ return finish(undefined);
228
+ }
229
+
230
+ const fields = source.section.attrs.background.fields;
231
+ for (const key of Object.keys(value)) {
232
+ if (!Object.hasOwn(fields, key)) {
233
+ report(`${path}/${pointerSegment(key)}`, CODES.attrUnknown, `Section background field '${identifier(key)}' is not supported.`);
234
+ }
235
+ }
236
+
237
+ if (!Object.hasOwn(value, "image")) {
238
+ report(`${path}/image`, CODES.imageKeys, "Section background must include a site-owned processed image.");
239
+ }
240
+ const image = Object.hasOwn(value, "image")
241
+ ? normalizeProcessedSectionImage(value.image, fields.image, `${path}/image`, "Section background image", report)
242
+ : undefined;
243
+ const portraitImage = value.portraitImage === undefined || value.portraitImage === null
244
+ ? null
245
+ : normalizeProcessedSectionImage(
246
+ value.portraitImage,
247
+ fields.portraitImage,
248
+ `${path}/portraitImage`,
249
+ "Section background portrait image",
250
+ report,
251
+ );
252
+ const focus = normalizeSectionFocus(value.focus, fields.focus, `${path}/focus`, "Section background focus", report);
253
+ const portraitFocus = normalizeSectionFocus(
254
+ value.portraitFocus,
255
+ fields.portraitFocus,
256
+ `${path}/portraitFocus`,
257
+ "Section background portrait focus",
258
+ report,
259
+ );
260
+ const scrimStrength = Object.hasOwn(value, "scrimStrength")
261
+ ? value.scrimStrength
262
+ : fields.scrimStrength.default;
263
+ if (typeof scrimStrength !== "string" || !fields.scrimStrength.values.includes(scrimStrength)) {
264
+ report(
265
+ `${path}/scrimStrength`,
266
+ CODES.attrInvalid,
267
+ `Section background scrimStrength must be one of ${fields.scrimStrength.values.map((item) => JSON.stringify(item)).join(", ")}.`,
268
+ );
269
+ }
270
+
271
+ if (errors.length > 0 || image === undefined || focus === undefined || portraitFocus === undefined) {
272
+ return finish(undefined);
273
+ }
274
+ return finish(Object.freeze({
275
+ image,
276
+ portraitImage,
277
+ focus,
278
+ portraitFocus,
279
+ scrimStrength,
280
+ }));
281
+ }
282
+
283
+ /**
284
+ * Validate and canonicalize one section decoration from the registry.
285
+ *
286
+ * The returned image keeps the standard `imageId`/`src`/`urls` delivery keys:
287
+ * the API's ownership rewriter only replaces keys already present in the
288
+ * document. URLs are still restricted here because pulled `.pm.json` files and
289
+ * Markdown headers are untrusted authoring input, even though an owned image is
290
+ * re-enriched before the generator sees it.
291
+ */
292
+ export function normalizeFreeFormSectionDecoration(
293
+ value,
294
+ path = "/attrs/decoration",
295
+ source = FREE_FORM_SECTION_REGISTRY,
296
+ ) {
297
+ const errors = [];
298
+ const report = (errorPath, code, message) => {
299
+ if (errors.length < CONTENT_LIMITS.documentErrors) errors.push(contentError(errorPath, code, message));
300
+ };
301
+ const finish = (decoration) => Object.freeze({
302
+ decoration,
303
+ errors: Object.freeze(errors),
304
+ });
305
+
306
+ if (value === undefined || value === null) return finish(undefined);
307
+ if (!isPlainObject(value)) {
308
+ report(path, CODES.attrInvalid, "Section decoration must be an object when present.");
309
+ return finish(undefined);
310
+ }
311
+
312
+ const fields = source.section.attrs.decoration.fields;
313
+ for (const key of Object.keys(value)) {
314
+ if (errors.length >= CONTENT_LIMITS.documentErrors) break;
315
+ if (!Object.hasOwn(fields, key)) {
316
+ report(
317
+ `${path}/${pointerSegment(key)}`,
318
+ CODES.attrUnknown,
319
+ `Section decoration field '${identifier(key)}' is not supported.`,
320
+ );
321
+ }
322
+ }
323
+
324
+ const imagePath = `${path}/image`;
325
+ const imageDefinition = fields.image;
326
+ const image = value.image;
327
+ if (!Object.hasOwn(value, "image")) {
328
+ report(imagePath, CODES.imageKeys, "Section decoration must include a site-owned processed image.");
329
+ } else if (!isPlainObject(image)) {
330
+ report(imagePath, CODES.attrInvalid, "Section decoration image must be an object.");
331
+ } else {
332
+ const imageKeys = [...imageDefinition.requiredKeys, ...imageDefinition.optionalKeys];
333
+ for (const key of Object.keys(image)) {
334
+ if (errors.length >= CONTENT_LIMITS.documentErrors) break;
335
+ if (!imageKeys.includes(key)) {
336
+ report(
337
+ `${imagePath}/${pointerSegment(key)}`,
338
+ CODES.attrUnknown,
339
+ `Section decoration image field '${identifier(key)}' is not part of the processed-image shape.`,
340
+ );
341
+ }
342
+ }
343
+ for (const key of imageDefinition.requiredKeys) {
344
+ if (!Object.hasOwn(image, key)) {
345
+ report(
346
+ `${imagePath}/${key}`,
347
+ CODES.imageKeys,
348
+ `Section decoration image must include '${key}' so ownership and delivery rewriting can resolve it.`,
349
+ );
350
+ }
351
+ }
352
+
353
+ if (Object.hasOwn(image, "imageId") && !isUuid(image.imageId)) {
354
+ report(`${imagePath}/imageId`, CODES.attrInvalid, "Section decoration imageId must be a canonical lowercase UUID.");
355
+ }
356
+
357
+ if (Object.hasOwn(image, "src")) {
358
+ if (typeof image.src !== "string") {
359
+ report(`${imagePath}/src`, CODES.attrInvalid, "Section decoration image src must be a string.");
360
+ } else if (!decorationDeliveryUrlIsSafe(image.src)) {
361
+ report(
362
+ `${imagePath}/src`,
363
+ image.src === "" ? CODES.imageKeys : CODES.attrInvalid,
364
+ image.src === ""
365
+ ? "Section decoration image src has no usable delivery URL. Use the complete processed-image object returned by media upload."
366
+ : "Section decoration image src must be an HTTPS or non-protocol-relative root URL without credentials, whitespace, controls, or backslashes.",
367
+ );
368
+ }
369
+ }
370
+
371
+ if (Object.hasOwn(image, "urls")) {
372
+ if (!Array.isArray(image.urls)) {
373
+ report(`${imagePath}/urls`, CODES.attrInvalid, "Section decoration image urls must be an array.");
374
+ } else {
375
+ if (image.urls.length < imageDefinition.urls.minItems || image.urls.length > imageDefinition.urls.maxItems) {
376
+ report(
377
+ `${imagePath}/urls`,
378
+ CODES.attrInvalid,
379
+ `Section decoration image urls must contain ${imageDefinition.urls.minItems} through ${imageDefinition.urls.maxItems} responsive candidates.`,
380
+ );
381
+ }
382
+ for (let index = 0; index < image.urls.length && errors.length < CONTENT_LIMITS.documentErrors; index += 1) {
383
+ const option = image.urls[index];
384
+ const optionPath = `${imagePath}/urls/${index}`;
385
+ if (!isPlainObject(option)) {
386
+ report(optionPath, CODES.attrInvalid, "A responsive decoration image URL must be an object.");
387
+ continue;
388
+ }
389
+ for (const key of Object.keys(option)) {
390
+ if (!Object.hasOwn(imageDefinition.urls.fields, key)) {
391
+ report(
392
+ `${optionPath}/${pointerSegment(key)}`,
393
+ CODES.attrUnknown,
394
+ `Responsive decoration image field '${identifier(key)}' is not supported.`,
395
+ );
396
+ }
397
+ }
398
+ if (
399
+ typeof option.minWidth !== "number"
400
+ || !Number.isInteger(option.minWidth)
401
+ || option.minWidth < imageDefinition.urls.fields.minWidth.minimum
402
+ ) {
403
+ report(`${optionPath}/minWidth`, CODES.attrInvalid, "Responsive decoration minWidth must be a positive integer.");
404
+ }
405
+ if (typeof option.url !== "string" || !decorationDeliveryUrlIsSafe(option.url)) {
406
+ report(
407
+ `${optionPath}/url`,
408
+ CODES.attrInvalid,
409
+ "A responsive decoration URL must be HTTPS or non-protocol-relative root URL without credentials, whitespace, controls, or backslashes.",
410
+ );
411
+ }
412
+ if (
413
+ option.type !== undefined
414
+ && (
415
+ typeof option.type !== "string"
416
+ || !imageDefinition.urls.fields.type.values.includes(option.type)
417
+ )
418
+ ) {
419
+ report(`${optionPath}/type`, CODES.attrInvalid, "Responsive decoration image type must be 'image/webp'.");
420
+ }
421
+ }
422
+ if (
423
+ imageDefinition.srcMustMatchUrls
424
+ && typeof image.src === "string"
425
+ && !image.urls.some((option) => isPlainObject(option) && option.url === image.src)
426
+ ) {
427
+ report(`${imagePath}/src`, CODES.attrInvalid, "Section decoration image src must equal one of its responsive urls[].url candidates.");
428
+ }
429
+ }
430
+ }
431
+
432
+ for (const name of ["width", "height"]) {
433
+ if (
434
+ Object.hasOwn(image, name)
435
+ && (typeof image[name] !== "number" || !Number.isInteger(image[name]) || image[name] <= 0)
436
+ ) {
437
+ report(`${imagePath}/${name}`, CODES.attrInvalid, `Section decoration image ${name} must be a positive integer.`);
438
+ }
439
+ }
440
+ if (Object.hasOwn(image, "alt") && typeof image.alt !== "string") {
441
+ report(`${imagePath}/alt`, CODES.attrInvalid, "Section decoration image alt must be a string.");
442
+ }
443
+ }
444
+
445
+ for (const [name, definition] of Object.entries(fields)) {
446
+ if (name === "image" || !Object.hasOwn(value, name)) continue;
447
+ const fieldPath = `${path}/${name}`;
448
+ const entry = value[name];
449
+ if (definition.type === "enum") {
450
+ if (typeof entry !== "string" || !definition.values.includes(entry)) {
451
+ report(
452
+ fieldPath,
453
+ CODES.attrInvalid,
454
+ `Section decoration ${name} must be one of ${definition.values.map((item) => JSON.stringify(item)).join(", ")}.`,
455
+ );
456
+ }
457
+ continue;
458
+ }
459
+ if (
460
+ typeof entry !== "number"
461
+ || !Number.isFinite(entry)
462
+ || (definition.type === "integer" && !Number.isInteger(entry))
463
+ || entry < definition.minimum
464
+ || entry > definition.maximum
465
+ ) {
466
+ report(
467
+ fieldPath,
468
+ CODES.attrInvalid,
469
+ `Section decoration ${name} must be ${definition.type === "integer" ? "an integer" : "a finite number"} from ${definition.minimum} through ${definition.maximum}.`,
470
+ );
471
+ }
472
+ }
473
+
474
+ if (errors.length > 0 || !isPlainObject(image)) return finish(undefined);
475
+ const normalizedImage = {
476
+ imageId: image.imageId,
477
+ src: image.src,
478
+ urls: Object.freeze(image.urls.map((option) => Object.freeze({
479
+ minWidth: option.minWidth,
480
+ url: option.url,
481
+ ...(option.type === undefined ? {} : { type: option.type }),
482
+ }))),
483
+ ...(Object.hasOwn(image, "width") ? { width: image.width } : {}),
484
+ ...(Object.hasOwn(image, "height") ? { height: image.height } : {}),
485
+ ...(Object.hasOwn(image, "alt") ? { alt: image.alt } : {}),
486
+ };
487
+ const normalized = { image: Object.freeze(normalizedImage) };
488
+ for (const [name, definition] of Object.entries(fields)) {
489
+ if (name !== "image") normalized[name] = value[name] ?? definition.default;
490
+ }
491
+ return finish(Object.freeze(normalized));
492
+ }
493
+
494
+ /** The exact, sorted context names present in both staged site themes. */
495
+ export function sharedThemeContextNames(lightTheme, darkTheme) {
496
+ const light = isPlainObject(lightTheme?.contexts) ? lightTheme.contexts : {};
497
+ const dark = isPlainObject(darkTheme?.contexts) ? darkTheme.contexts : {};
498
+ return Object.freeze(Object.keys(light).filter((name) => Object.hasOwn(dark, name)).sort());
499
+ }
500
+
501
+ /**
502
+ * Resolve one root document node through the declarative placement registry.
503
+ * Components carry their own placement row; ordinary nodes use `rootNodes`.
504
+ */
505
+ export function freeFormRootPresentation(node, source = FREE_FORM_SECTION_REGISTRY) {
506
+ if (!isPlainObject(node) || typeof node.type !== "string") return undefined;
507
+ if (node.type === "componentBlock") {
508
+ const componentType = isPlainObject(node.attrs) ? node.attrs.componentType : undefined;
509
+ return typeof componentType === "string" && Object.hasOwn(source.components, componentType)
510
+ ? source.components[componentType]
511
+ : undefined;
512
+ }
513
+ return Object.hasOwn(source.rootNodes, node.type) ? source.rootNodes[node.type] : undefined;
514
+ }
515
+
516
+ /** Whether a structurally validated document needs staged theme settings. */
517
+ export function hasNamedFreeFormSectionContext(document, source = FREE_FORM_SECTION_REGISTRY) {
518
+ let found = false;
519
+ const sectionType = source.section.nodeType;
520
+
521
+ function visit(node) {
522
+ if (found || !isPlainObject(node)) return;
523
+ if (
524
+ node.type === sectionType
525
+ && isPlainObject(node.attrs)
526
+ && typeof node.attrs.context === "string"
527
+ && node.attrs.context !== ""
528
+ ) {
529
+ found = true;
530
+ return;
531
+ }
532
+ if (Array.isArray(node.content)) node.content.forEach(visit);
533
+ }
534
+
535
+ visit(document);
536
+ return found;
537
+ }
538
+
539
+ /**
540
+ * Validate explicit section contexts without I/O or mutation. The same helper
541
+ * can guard editor documents and generator inputs once those runtimes consume
542
+ * the registry. Document-shape validation remains `validateDocument`'s job.
543
+ */
544
+ export function validateFreeFormSectionContexts(
545
+ document,
546
+ allowedContexts,
547
+ source = FREE_FORM_SECTION_REGISTRY,
548
+ ) {
549
+ const allowed = new Set(
550
+ Array.isArray(allowedContexts) ? allowedContexts.filter((name) => typeof name === "string") : [],
551
+ );
552
+ const alternatives = [...allowed].sort();
553
+ const suffix = alternatives.length > 0
554
+ ? `Declared alternatives: ${alternatives.join(", ")}.`
555
+ : "The staged light and dark themes declare no shared context alternatives.";
556
+ const errors = [];
557
+ const sectionType = source.section.nodeType;
558
+
559
+ function visit(node, path) {
560
+ if (errors.length >= CONTENT_LIMITS.documentErrors) return false;
561
+ if (!isPlainObject(node)) return true;
562
+ if (node.type === sectionType && isPlainObject(node.attrs) && typeof node.attrs.context === "string") {
563
+ const context = node.attrs.context;
564
+ if (!allowed.has(context)) {
565
+ errors.push(contentError(
566
+ `${path}/attrs/context`,
567
+ CODES.sectionContextUnknown,
568
+ `Section context '${context}' is not declared by both staged themes. ${suffix}`,
569
+ ));
570
+ if (errors.length >= CONTENT_LIMITS.documentErrors) return false;
571
+ }
572
+ }
573
+ if (!Array.isArray(node.content)) return true;
574
+ for (let index = 0; index < node.content.length; index += 1) {
575
+ if (!visit(node.content[index], `${path}/content/${index}`)) return false;
576
+ }
577
+ return true;
578
+ }
579
+
580
+ visit(document, "");
581
+ return Object.freeze({ errors: Object.freeze(errors) });
582
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Content validation and Markdown conversion — the part of this CLI that is
3
+ * not a wrapper over the API.
4
+ *
5
+ * TR00604's defining constraint: the server checks a page body for
6
+ * `RichTextBody.IsPresent` and nothing else. Everything past that gate is
7
+ * accepted, stored, and silently mangled at render time. So the verbs in
8
+ * `../verbs/` push nothing they have not run through `validateDocument` first,
9
+ * and agents author in Markdown through `markdownToProseMirror` rather than
10
+ * hand-assembling document JSON.
11
+ *
12
+ * The module is pure: no network, no filesystem, no clock, no dependencies.
13
+ */
14
+
15
+ export { validateDocument } from "./validate-document.js";
16
+ export { markdownToProseMirror } from "./markdown.js";
17
+ export {
18
+ FREE_FORM_SECTION_REGISTRY,
19
+ freeFormRootPresentation,
20
+ hasNamedFreeFormSectionContext,
21
+ normalizeFreeFormSectionBackground,
22
+ normalizeFreeFormSectionDecoration,
23
+ sharedThemeContextNames,
24
+ validateFreeFormSectionContexts,
25
+ } from "./free-form-sections.js";
26
+ export { normalizeInlineFactsItems } from "./inline-facts.js";
27
+ // The stable codes both of the above report through, so a caller branches on
28
+ // an identity instead of matching message text.
29
+ export { CONTENT_ERROR_CODES } from "./vocabulary.js";