@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,1079 @@
1
+ import { validateComponentBlock } from "./components.js";
2
+ import {
3
+ FREE_FORM_SECTION_REGISTRY,
4
+ normalizeFreeFormSectionBackground,
5
+ normalizeFreeFormSectionDecoration,
6
+ } from "./free-form-sections.js";
7
+ import { normalizeInlineFactsItems } from "./inline-facts.js";
8
+ import {
9
+ CONTENT_ERROR_CODES as CODES,
10
+ CONTENT_LIMITS,
11
+ contentError,
12
+ documentIsPresent,
13
+ identifier,
14
+ IMAGE_ALIGNMENTS,
15
+ IMAGE_MAX_HEIGHT_OPTIONS,
16
+ IMAGE_PLACEMENTS,
17
+ IMAGE_PRESENTATION_PLACEMENTS,
18
+ isPlainObject,
19
+ isSafeUrl,
20
+ isUuid,
21
+ MARK_TYPE_SET,
22
+ NODE_TYPE_SET,
23
+ normalizeCssLengthOverride,
24
+ pointerSegment,
25
+ TEXT_ALIGNMENTS,
26
+ TIPTAP_SITE_DEFAULT,
27
+ } from "./vocabulary.js";
28
+
29
+ /**
30
+ * The ProseMirror document validator.
31
+ *
32
+ * Every rule here answers one question: *would the canonical renderer, or the
33
+ * editor's own schema, quietly discard this?* The renderer's `default` switch
34
+ * arms drop unknown nodes and marks, `renderHeading` clamps a level of 9 to 4,
35
+ * `wrapMark` drops an unsafe link, `textBlockAttrs` drops an unparseable
36
+ * font size, and `PageImageDeliveryRewriter` leaves an image blank when the
37
+ * `src`/`urls` keys are absent. None of that fails a push. All of it fails a
38
+ * page, after publication, silently — so it fails here instead.
39
+ */
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Attribute rules
43
+ // ---------------------------------------------------------------------------
44
+
45
+ // A spec's `check` returns null when the value is acceptable, or the tail of
46
+ // the sentence "'<attr>' must be …" when it is not.
47
+ const spec = (check, options = {}) => Object.freeze({ check, ...options });
48
+
49
+ const isAbsent = (value) => value === undefined || value === null;
50
+
51
+ /** Optional string attributes accept null: it is every extension's default. */
52
+ const optionalString = () => spec((value) => (isAbsent(value) || typeof value === "string" ? null : "a string"));
53
+
54
+ const optionalEnum = (values, extra = []) =>
55
+ spec((value) => {
56
+ if (isAbsent(value)) return null;
57
+ if (typeof value === "string" && (values.includes(value) || extra.includes(value))) return null;
58
+ return `one of ${[...values, ...extra].map((entry) => `"${entry}"`).join(", ")}`;
59
+ });
60
+
61
+ const optionalPositiveInteger = () =>
62
+ spec((value) => {
63
+ if (isAbsent(value)) return null;
64
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? null : "a positive integer";
65
+ });
66
+
67
+ /**
68
+ * A per-block or per-image CSS override. The renderer runs every one of these
69
+ * through `normalizeCssLengthOverride` and drops whatever fails, so the
70
+ * validator accepts exactly what that function accepts, plus the explicit
71
+ * `"site-default"` sentinel it maps to "no override". An empty string is *not*
72
+ * a sentinel — it is a silent drop — and is refused.
73
+ */
74
+ const optionalCssLength = (options) =>
75
+ spec((value) => {
76
+ if (isAbsent(value) || value === TIPTAP_SITE_DEFAULT) return null;
77
+ if (typeof value === "string" && normalizeCssLengthOverride(value, options) !== null) return null;
78
+ return "a CSS length, a var()/calc() expression"
79
+ + (options?.allowNone ? ", \"none\"" : "")
80
+ + ", or \"site-default\"";
81
+ });
82
+
83
+ /** `textAlign` — the four values `textBlockAttrs` emits a `text-align` for. */
84
+ const TEXT_ALIGN_SPEC = optionalEnum(TEXT_ALIGNMENTS);
85
+
86
+ /** `fontSize` — the advanced text setting, validated as published inline CSS. */
87
+ const FONT_SIZE_SPEC = spec((value) => {
88
+ if (isAbsent(value)) return null;
89
+ if (typeof value === "string" && normalizeCssLengthOverride(value) !== null) return null;
90
+ return "a CSS length, a var() reference, or a calc() expression";
91
+ });
92
+
93
+ const TEXT_BLOCK_ATTRS = Object.freeze({
94
+ textAlign: TEXT_ALIGN_SPEC,
95
+ fontSize: FONT_SIZE_SPEC,
96
+ });
97
+
98
+ const RESPONSIVE_URL_KEYS = Object.freeze(["minWidth", "url", "type"]);
99
+
100
+ /**
101
+ * `urls` — the responsive delivery hints. The renderer's
102
+ * `validResponsiveImageUrls` skips any entry without a truthy `minWidth` and
103
+ * `url`, so a malformed entry disappears from the published `<esp-image>`
104
+ * without a trace. `type` is optional and is emitted when present.
105
+ */
106
+ const URLS_SPEC = spec((value) => {
107
+ if (!Array.isArray(value)) return "an array of { minWidth, url } objects";
108
+ for (const entry of value) {
109
+ if (!isPlainObject(entry)) return "an array of { minWidth, url } objects";
110
+ if (Object.keys(entry).some((key) => !RESPONSIVE_URL_KEYS.includes(key))) {
111
+ return "an array of objects with only minWidth, url, and type keys";
112
+ }
113
+ if (typeof entry.minWidth !== "number" || !Number.isFinite(entry.minWidth) || entry.minWidth <= 0) {
114
+ return "an array whose entries each carry a positive numeric minWidth";
115
+ }
116
+ if (typeof entry.url !== "string" || entry.url === "") {
117
+ return "an array whose entries each carry a non-empty url string";
118
+ }
119
+ if (entry.type !== undefined && typeof entry.type !== "string") {
120
+ return "an array whose entries carry a string type when present";
121
+ }
122
+ }
123
+ return null;
124
+ }, {
125
+ required: true,
126
+ missingCode: CODES.imageKeys,
127
+ missingMessage: "'urls' must be present, even as []: PageImageDeliveryRewriter only replaces delivery URLs on "
128
+ + "keys that already exist, so an image node without a 'urls' key renders with no responsive sources.",
129
+ });
130
+
131
+ const CROP_KEYS = Object.freeze(["x", "y", "width", "height"]);
132
+
133
+ /** `crop` — a `CropRegion` in source fractions, serialised into `data-crop`. */
134
+ const CROP_SPEC = spec((value) => {
135
+ if (isAbsent(value)) return null;
136
+ if (!isPlainObject(value)) return "a { x, y, width, height } object of 0-1 fractions";
137
+ const keys = Object.keys(value);
138
+ if (keys.length !== CROP_KEYS.length || CROP_KEYS.some((key) => !keys.includes(key))) {
139
+ return "an object with exactly the keys x, y, width, and height";
140
+ }
141
+ for (const key of CROP_KEYS) {
142
+ const entry = value[key];
143
+ if (typeof entry !== "number" || !Number.isFinite(entry) || entry < 0 || entry > 1) {
144
+ return "a { x, y, width, height } object of 0-1 fractions";
145
+ }
146
+ }
147
+ return null;
148
+ });
149
+
150
+ /**
151
+ * `taprootImage`. The attribute set is the editor extension's
152
+ * (`extensions/taproot-image.ts`) minus the four the editor strips on save
153
+ * (`TRANSIENT_TAPROOT_IMAGE_ATTRS` in `taproot-tiptap.ts`: `localImage`,
154
+ * `resolvedMaxHeightVh`, `tempId`, `uploading`). Those four are
155
+ * editor-session state — an upload in flight, a blob URL, a placeholder id, a
156
+ * resolved display value — and serialising them publishes a document that
157
+ * references a browser session that no longer exists.
158
+ *
159
+ * `presentationPlacement` is also `rendered: false` in the extension but is
160
+ * kept here, because the editor keeps it too: `renderHTML` skips it while the
161
+ * renderer's `resolvePlacement` reads it, so it is stored state, not session
162
+ * state.
163
+ */
164
+ const IMAGE_ATTRS = Object.freeze({
165
+ imageId: spec(
166
+ (value) => (isUuid(value) ? null : "a canonical lowercase UUID"),
167
+ { required: true },
168
+ ),
169
+ src: spec(
170
+ (value) => (typeof value === "string" ? null : "a string (\"\" is correct for authored content)"),
171
+ {
172
+ required: true,
173
+ missingCode: CODES.imageKeys,
174
+ missingMessage: "'src' must be present, even as \"\": PageImageDeliveryRewriter only replaces delivery URLs "
175
+ + "on keys that already exist, so an image node without a 'src' key renders blank.",
176
+ },
177
+ ),
178
+ urls: URLS_SPEC,
179
+ alt: optionalString(),
180
+ width: optionalPositiveInteger(),
181
+ height: optionalPositiveInteger(),
182
+ crop: CROP_SPEC,
183
+ maxHeightVh: spec((value) => {
184
+ if (isAbsent(value) || value === TIPTAP_SITE_DEFAULT) return null;
185
+ return IMAGE_MAX_HEIGHT_OPTIONS.includes(value)
186
+ ? null
187
+ : `one of ${IMAGE_MAX_HEIGHT_OPTIONS.join(", ")} or "site-default"`;
188
+ }),
189
+ maxHeight: optionalCssLength({ allowNone: true }),
190
+ borderWidth: optionalCssLength(),
191
+ presentationPlacement: optionalEnum(IMAGE_PRESENTATION_PLACEMENTS, [TIPTAP_SITE_DEFAULT]),
192
+ imageAlign: optionalEnum(IMAGE_ALIGNMENTS),
193
+ imagePlacement: optionalEnum(IMAGE_PLACEMENTS),
194
+ // TextAlign is configured for taprootImage as well as paragraph and heading.
195
+ textAlign: TEXT_ALIGN_SPEC,
196
+ });
197
+
198
+ /** The four the editor strips on save — never valid on the wire. */
199
+ const IMAGE_TRANSIENT_ATTRS = Object.freeze(["uploading", "localImage", "tempId", "resolvedMaxHeightVh"]);
200
+
201
+ const COMPONENT_ATTRS = Object.freeze({
202
+ // Both values are checked by `validateComponentBlock`, which owns the
203
+ // registry lookup and the data-shape tables; these specs only establish that
204
+ // the attributes are present at all.
205
+ componentType: spec(() => null, {
206
+ required: true,
207
+ missingCode: CODES.componentUnknown,
208
+ missingMessage: "'componentType' is required and must name one of the eight registry components.",
209
+ }),
210
+ componentData: spec(() => null, {
211
+ required: true,
212
+ missingCode: CODES.componentData,
213
+ missingMessage: "'componentData' is required and must be a JSON string.",
214
+ }),
215
+ });
216
+
217
+ const SECTION_CONTEXT = FREE_FORM_SECTION_REGISTRY.section.attrs.context;
218
+ const SECTION_CONTEXT_PATTERN = new RegExp(SECTION_CONTEXT.pattern, "u");
219
+ const SECTION_ATTRS = Object.freeze({
220
+ context: spec((value) => {
221
+ if (isAbsent(value)) return null;
222
+ return typeof value === "string"
223
+ && [...value].length <= SECTION_CONTEXT.maximumLength
224
+ && SECTION_CONTEXT_PATTERN.test(value)
225
+ ? null
226
+ : `an identifier matching ${SECTION_CONTEXT.pattern} with at most ${SECTION_CONTEXT.maximumLength} characters`;
227
+ }),
228
+ contentPadding: optionalEnum(FREE_FORM_SECTION_REGISTRY.section.attrs.contentPadding.values),
229
+ surface: optionalEnum(FREE_FORM_SECTION_REGISTRY.section.attrs.surface.values),
230
+ // Nested field diagnostics are owned by
231
+ // `normalizeFreeFormSectionBackground` below.
232
+ background: spec(() => null),
233
+ // Nested field diagnostics are owned by
234
+ // `normalizeFreeFormSectionDecoration` below, so this outer spec deliberately
235
+ // does not collapse them into one error at `/attrs/decoration`.
236
+ decoration: spec(() => null),
237
+ });
238
+
239
+ const TABLE_CONTRACT = FREE_FORM_SECTION_REGISTRY.table;
240
+ const TABLE_NODE_TYPES = TABLE_CONTRACT.nodeTypes;
241
+ const TABLE_CAPTION = TABLE_CONTRACT.attrs.caption;
242
+ const INLINE_FACTS_CONTRACT = FREE_FORM_SECTION_REGISTRY.inlineFacts;
243
+
244
+ function scalarLength(value, stopAfter = Number.POSITIVE_INFINITY) {
245
+ let length = 0;
246
+ for (const _character of value) {
247
+ length += 1;
248
+ if (length > stopAfter) break;
249
+ }
250
+ return length;
251
+ }
252
+
253
+ const TABLE_ATTRS = Object.freeze({
254
+ caption: spec((value) => {
255
+ if (isAbsent(value)) return null;
256
+ if (typeof value !== "string" || value.trim() === "") {
257
+ return "non-empty plain text when present";
258
+ }
259
+ return scalarLength(value, TABLE_CAPTION.maxScalars) <= TABLE_CAPTION.maxScalars
260
+ ? null
261
+ : `at most ${TABLE_CAPTION.maxScalars} Unicode scalar values`;
262
+ }, { code: CODES.tableBounds }),
263
+ });
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // Node rules
267
+ // ---------------------------------------------------------------------------
268
+
269
+ const INLINE = Object.freeze(["text", "hardBreak"]);
270
+ const BLOCKS = Object.freeze([
271
+ "paragraph",
272
+ "heading",
273
+ "bulletList",
274
+ "orderedList",
275
+ "blockquote",
276
+ "codeBlock",
277
+ "horizontalRule",
278
+ "taprootImage",
279
+ "componentBlock",
280
+ "rawHtml",
281
+ ]);
282
+ const SECTION_BLOCKS = Object.freeze([
283
+ ...BLOCKS,
284
+ TABLE_NODE_TYPES.table,
285
+ INLINE_FACTS_CONTRACT.nodeType,
286
+ ]);
287
+ const ROOT_BLOCKS = Object.freeze([...SECTION_BLOCKS, FREE_FORM_SECTION_REGISTRY.section.nodeType]);
288
+ const NONE = Object.freeze([]);
289
+
290
+ const rule = (options) => Object.freeze({ attrs: Object.freeze({}), children: NONE, minChildren: 0, ...options });
291
+
292
+ /**
293
+ * The content model, taken from the editor's schema rather than from what the
294
+ * renderer happens to tolerate. The renderer will happily emit `<p><ul>…</ul>`
295
+ * — the browser then hoists the list out of the paragraph — and ProseMirror
296
+ * itself silently repairs a document that violates a node's content
297
+ * expression, which is how a pushed body comes back from a pull with content
298
+ * missing.
299
+ *
300
+ * **Which nodes may carry marks.** In prosemirror-model a mark lands on a node
301
+ * when the node's *parent* type allows that mark: `AddMarkStep.apply` marks
302
+ * every `node.isAtom` in the range where `parent.type.allowsMarkType(...)`, and
303
+ * `ParseContext.insertNode` applies the active mark set under the same test.
304
+ * `markSet` is `null` — every mark allowed — for a type with inline content and
305
+ * no explicit `marks` spec, which here means exactly `paragraph` and `heading`.
306
+ * So the nodes that can arrive carrying marks are the inline atoms that live in
307
+ * those two: `text` and `hardBreak`. Every other node in this vocabulary is a
308
+ * block whose parents (`doc`, `listItem`, `blockquote`) have `markSet == []`
309
+ * and so can never mark a child — `horizontalRule`, `taprootImage`, and
310
+ * `componentBlock` included, since all three are `group: "block"` atoms — and
311
+ * `codeBlock` declares `marks: ""`, which is why its text is refused marks
312
+ * below. If a future extension makes one of them inline, this list changes with
313
+ * it; the criterion, not the list, is the rule.
314
+ */
315
+ const NODE_RULES = Object.freeze({
316
+ doc: rule({ children: ROOT_BLOCKS }),
317
+ paragraph: rule({ attrs: TEXT_BLOCK_ATTRS, children: INLINE }),
318
+ heading: rule({
319
+ attrs: Object.freeze({
320
+ // 1-4 exactly: `renderHeading` clamps anything else into range, so a
321
+ // level of 5 publishes as an `<h4>` and a level of 0 as an `<h1>`.
322
+ level: spec(
323
+ (value) => (typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 4
324
+ ? null
325
+ : "an integer from 1 to 4"),
326
+ { required: true, code: CODES.headingLevel, missingCode: CODES.headingLevel },
327
+ ),
328
+ ...TEXT_BLOCK_ATTRS,
329
+ }),
330
+ children: INLINE,
331
+ }),
332
+ text: rule({ marks: true }),
333
+ // A hard break carries marks for the most ordinary reason there is: select
334
+ // two lines of a paragraph, press Bold, and `AddMarkStep` marks every atom
335
+ // in the range — the `<br>` between them included. Pasting
336
+ // `<strong>a<br>b</strong>` does the same through the DOM parser. The
337
+ // renderer emits `<br>` and ignores the mark, which is a visual no-op rather
338
+ // than a silent mangle (a `<br>` inside `<strong>` looks like a `<br>`), so
339
+ // refusing the shape would hard-block an ordinary pull/push for nothing.
340
+ hardBreak: rule({ marks: true }),
341
+ bulletList: rule({ children: Object.freeze(["listItem"]), minChildren: 1 }),
342
+ orderedList: rule({
343
+ // Tiptap's OrderedList declares `start` (default 1) and `type` (default
344
+ // null), and prosemirror-model's `Node.toJSON` emits a node's attrs
345
+ // whenever its type declares any — so *every* stored ordered list carries
346
+ // `{ "start": 1, "type": null }` and refusing the pair would refuse an
347
+ // ordinary pull/push round trip. Only the defaults are accepted: the
348
+ // renderer publishes a bare `<ol>` and reads neither attribute, so any
349
+ // other value is silently discarded at publish time.
350
+ attrs: Object.freeze({
351
+ start: spec((value) => (isAbsent(value) || value === 1
352
+ ? null
353
+ : "1, null, or absent: the renderer publishes a bare <ol>, so a list starting anywhere else "
354
+ + "would silently publish starting at 1")
355
+ ),
356
+ type: spec((value) => (isAbsent(value)
357
+ ? null
358
+ : "null or absent: the renderer publishes a bare <ol> and carries no list-style type, so any "
359
+ + "other value would silently publish as a decimal list")
360
+ ),
361
+ }),
362
+ children: Object.freeze(["listItem"]),
363
+ minChildren: 1,
364
+ }),
365
+ // ListItem's content expression is `paragraph block*`.
366
+ listItem: rule({ children: BLOCKS, minChildren: 1, firstChild: "paragraph" }),
367
+ blockquote: rule({ children: BLOCKS, minChildren: 1 }),
368
+ codeBlock: rule({
369
+ attrs: Object.freeze({
370
+ language: spec((value) => {
371
+ if (isAbsent(value)) return null;
372
+ return typeof value === "string" && value !== ""
373
+ ? null
374
+ : "a non-empty string (omit it, or use null, for no language)";
375
+ }),
376
+ }),
377
+ // `renderCodeBlock` concatenates `child.text` and ignores everything else,
378
+ // so a nested node inside a code block vanishes and marks are stripped.
379
+ children: Object.freeze(["text"]),
380
+ childMarks: false,
381
+ }),
382
+ horizontalRule: rule({}),
383
+ taprootImage: rule({ attrs: IMAGE_ATTRS, rejectedAttrs: IMAGE_TRANSIENT_ATTRS, imageDelivery: true }),
384
+ componentBlock: rule({ attrs: COMPONENT_ATTRS, component: true }),
385
+ section: rule({
386
+ attrs: SECTION_ATTRS,
387
+ children: SECTION_BLOCKS,
388
+ minChildren: FREE_FORM_SECTION_REGISTRY.section.minimumChildren,
389
+ sectionBackground: true,
390
+ sectionDecoration: true,
391
+ }),
392
+ [INLINE_FACTS_CONTRACT.nodeType]: rule({
393
+ attrs: Object.freeze({
394
+ items: spec(() => null, {
395
+ required: true,
396
+ missingMessage: "'items' is required on an 'inlineFacts' node and must contain 1 through 6 facts.",
397
+ }),
398
+ }),
399
+ inlineFacts: true,
400
+ }),
401
+ rawHtml: rule({
402
+ attrs: Object.freeze({
403
+ html: spec((value) => (typeof value === "string" ? null : "a string"), { required: true }),
404
+ }),
405
+ rawHtml: true,
406
+ }),
407
+ [TABLE_NODE_TYPES.table]: rule({
408
+ attrs: TABLE_ATTRS,
409
+ children: Object.freeze([TABLE_NODE_TYPES.row]),
410
+ table: true,
411
+ }),
412
+ [TABLE_NODE_TYPES.row]: rule({
413
+ children: Object.freeze([TABLE_NODE_TYPES.header, TABLE_NODE_TYPES.cell]),
414
+ tableRow: true,
415
+ }),
416
+ [TABLE_NODE_TYPES.header]: rule({
417
+ children: Object.freeze(["paragraph"]),
418
+ tableCell: "header",
419
+ rejectedTableSpans: TABLE_CONTRACT.cells.spanAttrs,
420
+ }),
421
+ [TABLE_NODE_TYPES.cell]: rule({
422
+ children: Object.freeze(["paragraph"]),
423
+ tableCell: "data",
424
+ rejectedTableSpans: TABLE_CONTRACT.cells.spanAttrs,
425
+ }),
426
+ });
427
+
428
+ // ---------------------------------------------------------------------------
429
+ // Mark rules
430
+ // ---------------------------------------------------------------------------
431
+
432
+ const MARK_RULES = Object.freeze({
433
+ bold: Object.freeze({}),
434
+ italic: Object.freeze({}),
435
+ underline: Object.freeze({}),
436
+ strike: Object.freeze({}),
437
+ code: Object.freeze({}),
438
+ link: Object.freeze({
439
+ href: spec(
440
+ (value) => {
441
+ if (typeof value !== "string" || value === "") return "a URL";
442
+ // `wrapMark` drops the whole anchor when this fails, keeping the text:
443
+ // the link disappears from the published page without an error.
444
+ return isSafeUrl(value)
445
+ ? null
446
+ : "a safe HTTP(S), mailto, tel, non-protocol-relative root, fragment, query, or relative URL without backslashes or ASCII controls";
447
+ },
448
+ { required: true, code: CODES.linkHref, missingCode: CODES.linkHref },
449
+ ),
450
+ // `@tiptap/extension-link` declares these four alongside `href`, and
451
+ // `Mark.toJSON` emits every declared attribute — so every link the editor
452
+ // has ever written carries all five, and refusing any of them would refuse
453
+ // an ordinary pull/push round trip. The renderer ignores them and writes
454
+ // its own `rel`.
455
+ target: optionalString(),
456
+ rel: optionalString(),
457
+ class: optionalString(),
458
+ title: optionalString(),
459
+ }),
460
+ });
461
+
462
+ // ---------------------------------------------------------------------------
463
+ // Traversal
464
+ // ---------------------------------------------------------------------------
465
+
466
+ function report(state, path, code, message) {
467
+ if (state.errors.length >= CONTENT_LIMITS.documentErrors) {
468
+ state.truncated = true;
469
+ return;
470
+ }
471
+ state.errors.push(contentError(path, code, message));
472
+ }
473
+
474
+ /**
475
+ * The complete set of keys prosemirror-model serialises, read off the source:
476
+ * `Node.toJSON` writes `type`, then `attrs` when the node's type declares any,
477
+ * `content` when the node has children, and `marks` when it carries any;
478
+ * `TextNode.toJSON` adds `text`; `Mark.toJSON` writes `type` and `attrs`.
479
+ * There is no fifth node key and no third mark key, which is what makes
480
+ * refusing everything else safe for round-tripping.
481
+ */
482
+ const SERIALIZED_NODE_KEYS = Object.freeze(["type", "attrs", "content", "marks", "text"]);
483
+ const SERIALIZED_MARK_KEYS = Object.freeze(["type", "attrs"]);
484
+
485
+ /**
486
+ * Refuses a key the renderer will never read.
487
+ *
488
+ * This is the quietest failure in the whole format: `{"type": "paragraph",
489
+ * "text": "..."}` — an agent putting text where content belongs — and
490
+ * `"contents"` for `"content"` are *structurally valid* ProseMirror. The
491
+ * document keeps its presence (other blocks carry real content), the server
492
+ * stores it, and the renderer walks `node.content`, finds nothing, and
493
+ * publishes an empty paragraph. The words are simply gone.
494
+ *
495
+ * `attrs` and `marks` stay allowed as keys wherever they might appear, because
496
+ * `checkAttrs` and `checkMarks` already diagnose their *contents* far more
497
+ * precisely than "unexpected key" ever could.
498
+ */
499
+ function checkNodeKeys(node, path, nodeRule, isText, state) {
500
+ for (const key of Object.keys(node)) {
501
+ const known = SERIALIZED_NODE_KEYS.includes(key)
502
+ && (key !== "text" || isText)
503
+ && (key !== "content" || nodeRule.children.length > 0);
504
+ if (known) continue;
505
+ report(
506
+ state,
507
+ `${path}/${pointerSegment(key)}`,
508
+ CODES.nodeKey,
509
+ `'${identifier(key)}' is not part of a '${identifier(node.type)}' node. ProseMirror serialises only type, `
510
+ + "attrs, content, marks, and — on a text node — text; anything else is content the renderer never reads "
511
+ + "and the page publishes without.",
512
+ );
513
+ }
514
+ }
515
+
516
+ function checkMarkKeys(mark, markPath, state) {
517
+ for (const key of Object.keys(mark)) {
518
+ if (SERIALIZED_MARK_KEYS.includes(key)) continue;
519
+ report(
520
+ state,
521
+ `${markPath}/${pointerSegment(key)}`,
522
+ CODES.markKey,
523
+ `'${identifier(key)}' is not part of a '${identifier(mark.type)}' mark. A serialised mark carries only type `
524
+ + "and attrs; a value anywhere else is dropped.",
525
+ );
526
+ }
527
+ }
528
+
529
+ function checkAttrs(node, path, nodeRule, state) {
530
+ const attrsPath = `${path}/attrs`;
531
+ const attrs = node.attrs;
532
+ const present = isPlainObject(attrs);
533
+
534
+ if (!isAbsent(attrs) && !present) {
535
+ report(state, attrsPath, CODES.attrsInvalid, "'attrs' must be an object when present.");
536
+ return;
537
+ }
538
+
539
+ if (present) {
540
+ for (const key of Object.keys(attrs)) {
541
+ if (nodeRule.rejectedTableSpans?.includes(key)) {
542
+ report(
543
+ state,
544
+ `${attrsPath}/${pointerSegment(key)}`,
545
+ CODES.tableSpan,
546
+ `Table cell attribute '${
547
+ identifier(key)
548
+ }' is unsupported. Remove colspan, rowspan, and colwidth; every row must use the same explicit columns.`,
549
+ );
550
+ continue;
551
+ }
552
+ if (nodeRule.rejectedAttrs?.includes(key)) {
553
+ report(
554
+ state,
555
+ `${attrsPath}/${pointerSegment(key)}`,
556
+ CODES.imageTransient,
557
+ `'${identifier(key)}' is editor-session state the extension marks rendered: false. It must never be `
558
+ + "serialised into a published document.",
559
+ );
560
+ continue;
561
+ }
562
+ // Through `Object.hasOwn`, never a bare index: an attribute named
563
+ // `constructor` or `toString` would otherwise resolve to something off
564
+ // `Object.prototype`, and calling `.check` on it would throw out of a
565
+ // function documented never to throw.
566
+ const attrSpec = Object.hasOwn(nodeRule.attrs, key) ? nodeRule.attrs[key] : undefined;
567
+ if (!attrSpec) {
568
+ report(
569
+ state,
570
+ `${attrsPath}/${pointerSegment(key)}`,
571
+ CODES.attrUnknown,
572
+ `'${identifier(key)}' is not an attribute of a '${node.type}' node.`,
573
+ );
574
+ continue;
575
+ }
576
+ const detail = attrSpec.check(attrs[key]);
577
+ if (detail) {
578
+ report(
579
+ state,
580
+ `${attrsPath}/${pointerSegment(key)}`,
581
+ attrSpec.code ?? CODES.attrInvalid,
582
+ `'${key}' must be ${detail}.`,
583
+ );
584
+ }
585
+ }
586
+ }
587
+
588
+ for (const [key, attrSpec] of Object.entries(nodeRule.attrs)) {
589
+ if (!attrSpec.required || (present && Object.hasOwn(attrs, key))) continue;
590
+ report(
591
+ state,
592
+ `${attrsPath}/${key}`,
593
+ attrSpec.missingCode ?? attrSpec.code ?? CODES.attrInvalid,
594
+ attrSpec.missingMessage ?? `'${key}' is required on a '${node.type}' node.`,
595
+ );
596
+ }
597
+
598
+ // A crop can only ride an image whose `src` is the signed crop URL the
599
+ // editor stored. `PageImageDeliveryRewriter` sends any node carrying `crop`
600
+ // down its persisted-crop branch, which looks for a crop token inside
601
+ // `src`/`urls`; authored content sends those keys empty, so no token is
602
+ // found, `NormalizePersistedCropUrls` prunes an already-empty array, and the
603
+ // node is published with no delivery URL at all — a blank image.
604
+ if (nodeRule.imageDelivery && present && !isAbsent(attrs.crop) && attrs.src === "") {
605
+ report(
606
+ state,
607
+ `${attrsPath}/crop`,
608
+ CODES.imageKeys,
609
+ "'crop' can only travel on an image whose 'src' is the signed crop URL the editor stored. Authored content "
610
+ + "must omit it: with an empty 'src' the server finds no crop token, writes no delivery URL, and the image "
611
+ + "publishes blank.",
612
+ );
613
+ }
614
+
615
+ if (nodeRule.sectionDecoration && present && Object.hasOwn(attrs, "decoration")) {
616
+ const result = normalizeFreeFormSectionDecoration(attrs.decoration, `${attrsPath}/decoration`);
617
+ for (const error of result.errors) report(state, error.path, error.code, error.message);
618
+ }
619
+
620
+ if (nodeRule.sectionBackground && present && Object.hasOwn(attrs, "background")) {
621
+ const result = normalizeFreeFormSectionBackground(attrs.background, `${attrsPath}/background`);
622
+ for (const error of result.errors) report(state, error.path, error.code, error.message);
623
+ }
624
+
625
+ if (nodeRule.inlineFacts && present && Object.hasOwn(attrs, "items")) {
626
+ const result = normalizeInlineFactsItems(attrs.items, `${attrsPath}/items`);
627
+ for (const error of result.errors) report(state, error.path, error.code, error.message);
628
+ }
629
+
630
+ // The registry lookup and the eight data tables live in components.js. A
631
+ // missing attribute is already reported above, so the data check runs only
632
+ // once a `componentType` exists to validate against; an absent
633
+ // `componentData` stands in as "{}" so the type is still checked without a
634
+ // second report of the same missing attribute.
635
+ if (nodeRule.component && present && Object.hasOwn(attrs, "componentType")) {
636
+ const data = Object.hasOwn(attrs, "componentData") ? attrs.componentData : "{}";
637
+ for (const error of validateComponentBlock(attrs.componentType, data, attrsPath)) {
638
+ report(state, error.path, error.code, error.message);
639
+ }
640
+ }
641
+ }
642
+
643
+ function checkMarks(node, path, nodeRule, marksAllowed, state) {
644
+ const marks = node.marks;
645
+ if (!nodeRule.marks && Object.hasOwn(node, "marks")) {
646
+ report(
647
+ state,
648
+ `${path}/marks`,
649
+ CODES.markMisplaced,
650
+ `Marks ride text and hard breaks; ProseMirror never puts one on a '${node.type}' node, because a block `
651
+ + "node's parent allows no marks.",
652
+ );
653
+ return;
654
+ }
655
+ if (isAbsent(marks)) return;
656
+ if (!Array.isArray(marks)) {
657
+ report(state, `${path}/marks`, CODES.marksInvalid, "'marks' must be an array when present.");
658
+ return;
659
+ }
660
+ if (marks.length === 0) return;
661
+ if (!marksAllowed) {
662
+ report(
663
+ state,
664
+ `${path}/marks`,
665
+ CODES.markMisplaced,
666
+ "Text inside a code block cannot carry marks; the renderer concatenates the raw text and drops them.",
667
+ );
668
+ return;
669
+ }
670
+
671
+ const seen = new Set();
672
+ marks.forEach((mark, index) => {
673
+ const markPath = `${path}/marks/${index}`;
674
+ if (!isPlainObject(mark) || typeof mark.type !== "string") {
675
+ report(state, markPath, CODES.marksInvalid, "A mark must be an object with a string 'type'.");
676
+ return;
677
+ }
678
+ if (!MARK_TYPE_SET.has(mark.type)) {
679
+ report(
680
+ state,
681
+ markPath,
682
+ CODES.unknownMark,
683
+ `Unknown mark '${identifier(mark.type)}'. The renderer drops it and publishes the text unmarked.`,
684
+ );
685
+ return;
686
+ }
687
+ if (seen.has(mark.type)) {
688
+ report(state, markPath, CODES.marksInvalid, `A text node carries more than one '${mark.type}' mark.`);
689
+ return;
690
+ }
691
+ seen.add(mark.type);
692
+ checkMarkKeys(mark, markPath, state);
693
+ checkMarkAttrs(mark, markPath, state);
694
+ });
695
+ }
696
+
697
+ function checkMarkAttrs(mark, markPath, state) {
698
+ const rules = MARK_RULES[mark.type];
699
+ const attrsPath = `${markPath}/attrs`;
700
+ const attrs = mark.attrs;
701
+ const present = isPlainObject(attrs);
702
+ if (!isAbsent(attrs) && !present) {
703
+ report(state, attrsPath, CODES.attrsInvalid, "'attrs' must be an object when present.");
704
+ return;
705
+ }
706
+ if (present) {
707
+ for (const key of Object.keys(attrs)) {
708
+ // `Object.hasOwn` for the same reason as in `checkAttrs`: a mark
709
+ // attribute named `constructor` must report, not throw.
710
+ const attrSpec = Object.hasOwn(rules, key) ? rules[key] : undefined;
711
+ if (!attrSpec) {
712
+ report(
713
+ state,
714
+ `${attrsPath}/${pointerSegment(key)}`,
715
+ CODES.attrUnknown,
716
+ `'${identifier(key)}' is not an attribute of a '${mark.type}' mark.`,
717
+ );
718
+ continue;
719
+ }
720
+ const detail = attrSpec.check(attrs[key]);
721
+ if (detail) {
722
+ report(
723
+ state,
724
+ `${attrsPath}/${pointerSegment(key)}`,
725
+ attrSpec.code ?? CODES.attrInvalid,
726
+ `'${key}' must be ${detail}.`,
727
+ );
728
+ }
729
+ }
730
+ }
731
+ for (const [key, attrSpec] of Object.entries(rules)) {
732
+ if (!attrSpec.required || (present && Object.hasOwn(attrs, key))) continue;
733
+ report(
734
+ state,
735
+ `${attrsPath}/${key}`,
736
+ attrSpec.missingCode ?? attrSpec.code ?? CODES.attrInvalid,
737
+ attrSpec.missingMessage ?? `'${key}' is required on a '${mark.type}' mark.`,
738
+ );
739
+ }
740
+ }
741
+
742
+ function textDescendantFacts(node, stopAfter) {
743
+ let scalars = 0;
744
+ let hasNonWhitespace = false;
745
+ const stack = [{ node, depth: 0 }];
746
+ while (stack.length > 0 && scalars <= stopAfter) {
747
+ const entry = stack.pop();
748
+ const current = entry.node;
749
+ if (!isPlainObject(current) || entry.depth > CONTENT_LIMITS.documentDepth) continue;
750
+ if (current.type === "text" && typeof current.text === "string") {
751
+ if (current.text.trim() !== "") hasNonWhitespace = true;
752
+ scalars += scalarLength(current.text, stopAfter - scalars);
753
+ continue;
754
+ }
755
+ if (Array.isArray(current.content)) {
756
+ for (let index = current.content.length - 1; index >= 0; index -= 1) {
757
+ stack.push({ node: current.content[index], depth: entry.depth + 1 });
758
+ }
759
+ }
760
+ }
761
+ return { scalars, hasNonWhitespace };
762
+ }
763
+
764
+ function checkTable(node, path, state) {
765
+ const rows = node.content;
766
+ if (!Array.isArray(rows)) {
767
+ report(
768
+ state,
769
+ `${path}/content`,
770
+ CODES.tableHeader,
771
+ "A table must begin with exactly one tableRow whose cells are all tableHeader nodes.",
772
+ );
773
+ return;
774
+ }
775
+
776
+ const dataRows = Math.max(0, rows.length - TABLE_CONTRACT.rows.header);
777
+ if (rows.length === 0) {
778
+ report(
779
+ state,
780
+ `${path}/content`,
781
+ CODES.tableHeader,
782
+ "A table is missing its header row.",
783
+ );
784
+ }
785
+ if (dataRows < TABLE_CONTRACT.rows.minData || dataRows > TABLE_CONTRACT.rows.maxData) {
786
+ report(
787
+ state,
788
+ `${path}/content`,
789
+ CODES.tableBounds,
790
+ `A table must contain exactly one header row followed by ${TABLE_CONTRACT.rows.minData} through ${TABLE_CONTRACT.rows.maxData} data rows.`,
791
+ );
792
+ }
793
+
794
+ let headerColumns;
795
+ rows.forEach((row, rowIndex) => {
796
+ const rowPath = `${path}/content/${rowIndex}`;
797
+ if (!isPlainObject(row) || row.type !== TABLE_NODE_TYPES.row) {
798
+ report(
799
+ state,
800
+ rowPath,
801
+ rowIndex === 0 ? CODES.tableHeader : CODES.tableShape,
802
+ rowIndex === 0
803
+ ? "The first table child must be the one header tableRow."
804
+ : "Every table child after the header must be a data tableRow.",
805
+ );
806
+ return;
807
+ }
808
+ if (!Array.isArray(row.content)) {
809
+ report(
810
+ state,
811
+ `${rowPath}/content`,
812
+ rowIndex === 0 ? CODES.tableHeader : CODES.tableShape,
813
+ "A tableRow must carry a content array of explicit cells.",
814
+ );
815
+ return;
816
+ }
817
+
818
+ const columns = row.content.length;
819
+ if (columns < TABLE_CONTRACT.columns.min || columns > TABLE_CONTRACT.columns.max) {
820
+ report(
821
+ state,
822
+ `${rowPath}/content`,
823
+ CODES.tableBounds,
824
+ `Every table row must contain ${TABLE_CONTRACT.columns.min} through ${TABLE_CONTRACT.columns.max} cells.`,
825
+ );
826
+ }
827
+ if (rowIndex === 0) {
828
+ headerColumns = columns;
829
+ if (row.content.some((cell) => !isPlainObject(cell) || cell.type !== TABLE_NODE_TYPES.header)) {
830
+ report(
831
+ state,
832
+ `${rowPath}/content`,
833
+ CODES.tableHeader,
834
+ `The first row must contain only '${TABLE_NODE_TYPES.header}' nodes.`,
835
+ );
836
+ }
837
+ return;
838
+ }
839
+
840
+ if (row.content.some((cell) => !isPlainObject(cell) || cell.type !== TABLE_NODE_TYPES.cell)) {
841
+ report(
842
+ state,
843
+ `${rowPath}/content`,
844
+ CODES.tableShape,
845
+ `Data rows must contain only '${TABLE_NODE_TYPES.cell}' nodes; a table has exactly one header row.`,
846
+ );
847
+ }
848
+ if (headerColumns !== undefined && columns !== headerColumns) {
849
+ report(
850
+ state,
851
+ `${rowPath}/content`,
852
+ CODES.tableRagged,
853
+ `This row has ${columns} cells; every data row must match the header's ${headerColumns} columns.`,
854
+ );
855
+ }
856
+ });
857
+ }
858
+
859
+ function checkTableCell(node, path, nodeRule, state) {
860
+ const content = node.content;
861
+ if (
862
+ !Array.isArray(content)
863
+ || content.length !== TABLE_CONTRACT.cells.paragraphs
864
+ || !isPlainObject(content[0])
865
+ || content[0].type !== "paragraph"
866
+ ) {
867
+ report(
868
+ state,
869
+ `${path}/content`,
870
+ CODES.tableCellContent,
871
+ `A '${node.type}' must contain exactly one paragraph and no other block content.`,
872
+ );
873
+ }
874
+
875
+ const paragraph = Array.isArray(content) && isPlainObject(content[0]) && content[0].type === "paragraph"
876
+ ? content[0]
877
+ : undefined;
878
+ if (
879
+ paragraph
880
+ && !TABLE_CONTRACT.cells.paragraphAttrs
881
+ && !isAbsent(paragraph.attrs)
882
+ && (!isPlainObject(paragraph.attrs) || Object.keys(paragraph.attrs).length > 0)
883
+ ) {
884
+ report(
885
+ state,
886
+ `${path}/content/0/attrs`,
887
+ CODES.tableCellContent,
888
+ "A table cell paragraph has no appearance attributes; remove its attrs object.",
889
+ );
890
+ }
891
+
892
+ const facts = textDescendantFacts(node, TABLE_CONTRACT.cells.maxTextScalars);
893
+ if (facts.scalars > TABLE_CONTRACT.cells.maxTextScalars) {
894
+ report(
895
+ state,
896
+ `${path}/content`,
897
+ CODES.tableBounds,
898
+ `A table cell may contain at most ${TABLE_CONTRACT.cells.maxTextScalars} Unicode scalar values of text.`,
899
+ );
900
+ }
901
+ if (nodeRule.tableCell === "header" && TABLE_CONTRACT.cells.headerNonWhitespace && !facts.hasNonWhitespace) {
902
+ report(
903
+ state,
904
+ `${path}/content`,
905
+ CODES.tableHeader,
906
+ "Every header cell must contain non-whitespace text.",
907
+ );
908
+ }
909
+ }
910
+
911
+ function checkChildren(node, path, nodeRule, state, depth) {
912
+ // A leaf takes no children at all, and `checkNodeKeys` has already refused a
913
+ // stray `content` on one — reporting it again per child would say the same
914
+ // thing several times over.
915
+ if (nodeRule.children.length === 0) return;
916
+
917
+ const content = node.content;
918
+ if (isAbsent(content)) {
919
+ if (nodeRule.minChildren > 0) {
920
+ report(
921
+ state,
922
+ `${path}/content`,
923
+ CODES.childRequired,
924
+ `A '${node.type}' node must contain at least ${nodeRule.minChildren} child node.`,
925
+ );
926
+ }
927
+ return;
928
+ }
929
+ if (!Array.isArray(content)) {
930
+ report(state, `${path}/content`, CODES.nodeInvalid, "'content' must be an array when present.");
931
+ return;
932
+ }
933
+ if (content.length < nodeRule.minChildren) {
934
+ report(
935
+ state,
936
+ `${path}/content`,
937
+ CODES.childRequired,
938
+ `A '${node.type}' node must contain at least ${nodeRule.minChildren} child node.`,
939
+ );
940
+ }
941
+ content.forEach((child, index) => {
942
+ validateNode(child, `${path}/content/${index}`, nodeRule, index, state, depth + 1);
943
+ });
944
+ }
945
+
946
+ function validateNode(node, path, parentRule, index, state, depth) {
947
+ if (state.errors.length >= CONTENT_LIMITS.documentErrors) {
948
+ state.truncated = true;
949
+ return;
950
+ }
951
+ if (depth > CONTENT_LIMITS.documentDepth) {
952
+ report(state, path, CODES.depthLimit, `Documents may not nest deeper than ${CONTENT_LIMITS.documentDepth} levels.`);
953
+ return;
954
+ }
955
+ if (!isPlainObject(node) || typeof node.type !== "string") {
956
+ report(state, path, CODES.nodeInvalid, "A node must be an object with a string 'type'.");
957
+ return;
958
+ }
959
+ if (!NODE_TYPE_SET.has(node.type)) {
960
+ report(
961
+ state,
962
+ path,
963
+ CODES.unknownNode,
964
+ `Unknown node '${identifier(node.type)}'. The renderer drops it and publishes only its children.`,
965
+ );
966
+ return;
967
+ }
968
+ if (!parentRule.children.includes(node.type)) {
969
+ report(
970
+ state,
971
+ path,
972
+ CODES.childNotAllowed,
973
+ `A '${node.type}' node is not valid inside this node.`,
974
+ );
975
+ return;
976
+ }
977
+ if (parentRule.firstChild && index === 0 && node.type !== parentRule.firstChild) {
978
+ report(
979
+ state,
980
+ path,
981
+ CODES.childNotAllowed,
982
+ `A list item must begin with a '${parentRule.firstChild}' node.`,
983
+ );
984
+ }
985
+
986
+ const nodeRule = NODE_RULES[node.type];
987
+ if (nodeRule.rawHtml && !state.allowRawHtml) {
988
+ report(
989
+ state,
990
+ path,
991
+ CODES.rawHtmlForbidden,
992
+ "A 'rawHtml' node is published verbatim and unsanitised. Pass allowRawHtml to accept one deliberately.",
993
+ );
994
+ }
995
+ if (node.type === "text") {
996
+ if (typeof node.text !== "string") {
997
+ report(state, path, CODES.textInvalid, "A text node must carry a string 'text'.");
998
+ } else if (node.text === "") {
999
+ report(state, path, CODES.textInvalid, "A text node cannot be empty; ProseMirror cannot represent one.");
1000
+ }
1001
+ }
1002
+
1003
+ checkNodeKeys(node, path, nodeRule, node.type === "text", state);
1004
+ checkAttrs(node, path, nodeRule, state);
1005
+ checkMarks(node, path, nodeRule, parentRule.childMarks !== false, state);
1006
+ if (nodeRule.table) checkTable(node, path, state);
1007
+ if (nodeRule.tableCell) checkTableCell(node, path, nodeRule, state);
1008
+ checkChildren(node, path, nodeRule, state, depth);
1009
+ }
1010
+
1011
+ /**
1012
+ * Validates a ProseMirror page body against the vocabulary the published-site
1013
+ * renderer actually understands.
1014
+ *
1015
+ * Returns `{ errors }`; an empty `errors` array means the document is safe to
1016
+ * send. `path` is a JSON pointer into the document (`""` is the document
1017
+ * itself, `/content/3/content/0` its fourth block's first child, and a
1018
+ * component finding continues *through* the `componentData` JSON string into
1019
+ * the parsed data). `code` is one of `CONTENT_ERROR_CODES`.
1020
+ *
1021
+ * `allowRawHtml` opts a caller into `rawHtml` nodes, which are published
1022
+ * unsanitised. It is off by default precisely because an agent should not be
1023
+ * able to emit one by accident.
1024
+ */
1025
+ export function validateDocument(document, options = {}) {
1026
+ const state = {
1027
+ errors: [],
1028
+ truncated: false,
1029
+ allowRawHtml: options?.allowRawHtml === true,
1030
+ };
1031
+
1032
+ if (!isPlainObject(document) || document.type !== "doc") {
1033
+ report(state, "", CODES.docType, "A page body must be an object with type \"doc\".");
1034
+ return finish(state);
1035
+ }
1036
+ if (!Array.isArray(document.content)) {
1037
+ report(state, "/content", CODES.docContent, "A page body must carry a 'content' array.");
1038
+ return finish(state);
1039
+ }
1040
+
1041
+ checkNodeKeys(document, "", NODE_RULES.doc, false, state);
1042
+ checkAttrs(document, "", NODE_RULES.doc, state);
1043
+ checkMarks(document, "", NODE_RULES.doc, true, state);
1044
+ document.content.forEach((node, index) => {
1045
+ validateNode(node, `/content/${index}`, NODE_RULES.doc, index, state, 1);
1046
+ });
1047
+
1048
+ // The server's own gate, named locally. `RichTextBody.IsPresent` refuses a
1049
+ // document with no text, no image, and no component block with a bare "Body
1050
+ // is required" that says nothing about which page or why.
1051
+ if (!documentIsPresent(document)) {
1052
+ report(
1053
+ state,
1054
+ "",
1055
+ CODES.emptyDocument,
1056
+ "A page body must contain text, an image, a component block, or inline facts; the server refuses an empty document.",
1057
+ );
1058
+ }
1059
+
1060
+ return finish(state);
1061
+ }
1062
+
1063
+ function finish(state) {
1064
+ if (state.truncated) {
1065
+ state.errors.push(contentError(
1066
+ "",
1067
+ CODES.errorLimit,
1068
+ `Validation stopped after ${CONTENT_LIMITS.documentErrors} findings; fix these and run it again.`,
1069
+ ));
1070
+ }
1071
+ return Object.freeze({ errors: Object.freeze(state.errors) });
1072
+ }
1073
+
1074
+ /**
1075
+ * Exposed so the renderer-parity test can assert the rules table covers
1076
+ * exactly the vocabulary the copied renderer switches on — a node added
1077
+ * upstream must fail here rather than pass through unvalidated.
1078
+ */
1079
+ export { NODE_RULES };