@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,1625 @@
1
+ import { SiteAuthoringError } from "../errors.js";
2
+ import { canonicalizeComponentData, isComponentType, validateComponentBlock } from "./components.js";
3
+ import {
4
+ FREE_FORM_SECTION_REGISTRY,
5
+ normalizeFreeFormSectionBackground,
6
+ normalizeFreeFormSectionDecoration,
7
+ } from "./free-form-sections.js";
8
+ import { normalizeInlineFactsItems } from "./inline-facts.js";
9
+ import { validateDocument } from "./validate-document.js";
10
+ import {
11
+ CONTENT_ERROR_CODES as CODES,
12
+ CONTENT_LIMITS,
13
+ documentIsPresent,
14
+ identifier,
15
+ isPlainObject,
16
+ isSafeUrl,
17
+ isUuid,
18
+ MARK_TYPES,
19
+ } from "./vocabulary.js";
20
+
21
+ /**
22
+ * Markdown → ProseMirror, as a bounded CommonMark subset with zero
23
+ * dependencies.
24
+ *
25
+ * The subset is exactly the renderer's vocabulary: paragraphs, ATX headings
26
+ * 1-4, hard breaks, bullet and ordered lists, blockquotes, fenced code with a
27
+ * language, thematic breaks, images, bounded GFM pipe tables,
28
+ * `component:<type>` fences, and `inline-facts` JSON-array fences; and the
29
+ * bold, italic, strike, code, and link marks. **Anything outside it is a hard
30
+ * error naming the construct and the line it appeared on.** That is the whole
31
+ * design rule: a converter that
32
+ * quietly flattened a malformed table into paragraphs, or an `<h5>` into an
33
+ * `<h4>`, would hand an agent a page that looks converted and publishes wrong
34
+ * — the same silent narrowing the renderer already does and this package
35
+ * exists to prevent.
36
+ *
37
+ * Front matter is **not** this module's concern. `pages push` (TR00604 S2)
38
+ * owns the page envelope — path, title, template type, description — and
39
+ * strips the front-matter block before calling here. A document that still
40
+ * starts with one is refused by name rather than parsed as a thematic break
41
+ * followed by prose.
42
+ */
43
+
44
+ const HARD_BREAK = "\n";
45
+ const MAX_LIST_ITEMS = 500;
46
+ const LANGUAGE_RE = /^[A-Za-z0-9_+#.-]{1,32}$/u;
47
+ const ATX_RE = /^ {0,3}(#{1,9})(?: +(.*?))? *$/u;
48
+ const THEMATIC_RE = /^ {0,3}(?:(?:\* *){3,}|(?:- *){3,}|(?:_ *){3,})$/u;
49
+ const SETEXT_RE = /^ {0,3}(?:=+|-+) *$/u;
50
+ const FENCE_RE = /^( {0,3})(`{3,}|~{3,})(.*)$/u;
51
+ const SECTION_HEADER_RE = /^:::section (\{.*\})$/u;
52
+ const SECTION_HEADER_LIKE_RE = /^ *:::section(?=\s|\{|$)/u;
53
+ const SECTION_CLOSE_RE = /^:::$/u;
54
+ const BULLET_RE = /^( *)([-*+])( +|$)(.*)$/u;
55
+ const ORDERED_RE = /^( *)(\d{1,9})([.)])( +|$)(.*)$/u;
56
+ const BLOCKQUOTE_RE = /^ {0,3}>/u;
57
+ const FOOTNOTE_DEFINITION_RE = /^ {0,3}\[\^[^\]]{1,64}\]:/u;
58
+ const REFERENCE_DEFINITION_RE = /^ {0,3}\[[^\]]{1,256}\]: *\S/u;
59
+ const STANDALONE_IMAGE_RE = /^!\[([^\]]*)\]\(([^()\s]+)\)$/u;
60
+ const ENTITY_RE = /^&(?:#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6}|[a-zA-Z][a-zA-Z0-9]{1,31});/u;
61
+ const AUTOLINK_RE = /^<(?:[A-Za-z][A-Za-z0-9+.-]{1,31}:[^<>\s]*|[^\s<>@]+@[^\s<>]+)>/u;
62
+ const HTML_TAG_RE = /^<(?:\/?[A-Za-z][A-Za-z0-9-]*(?:[\s/>])|!--|!|\?)/u;
63
+ const PUNCTUATION_RE = /[!-/:-@[-`{-~]/u;
64
+ const TABLE_CONTRACT = FREE_FORM_SECTION_REGISTRY.table;
65
+ const TABLE_NODE_TYPES = TABLE_CONTRACT.nodeTypes;
66
+ const TABLE_CAPTION_STEM = TABLE_CONTRACT.markdown.captionPrefix.trimEnd();
67
+ const INLINE_FACTS_CONTRACT = FREE_FORM_SECTION_REGISTRY.inlineFacts;
68
+ const TABLE_DELIMITER_TOKEN_RE = new RegExp(
69
+ `^:?-{${TABLE_CONTRACT.markdown.delimiterMinHyphens},}:?$`,
70
+ "u",
71
+ );
72
+ const TABLE_DELIMITERISH_TOKEN_RE = /^:?-*:?$/u;
73
+
74
+ function fail(code, construct, message, line, cause) {
75
+ return new SiteAuthoringError(
76
+ code,
77
+ line === undefined ? message : `${message} (line ${line})`,
78
+ { field: construct, cause },
79
+ );
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Line handling
84
+ // ---------------------------------------------------------------------------
85
+
86
+ function expandLeadingTabs(text) {
87
+ let column = 0;
88
+ let index = 0;
89
+ while (index < text.length && (text[index] === " " || text[index] === "\t")) {
90
+ column += text[index] === "\t" ? 4 - (column % 4) : 1;
91
+ index += 1;
92
+ }
93
+ return " ".repeat(column) + text.slice(index);
94
+ }
95
+
96
+ function indentOf(text) {
97
+ let indent = 0;
98
+ while (indent < text.length && text[indent] === " ") indent += 1;
99
+ return indent;
100
+ }
101
+
102
+ function isBlank(text) {
103
+ return text.trim() === "";
104
+ }
105
+
106
+ /**
107
+ * Splits the source into numbered lines, normalising line endings and refusing
108
+ * control characters. The line numbers travel with the text so every error can
109
+ * name the line the author has to look at, including inside a blockquote or a
110
+ * list item where the text has been re-indented.
111
+ */
112
+ function toLines(markdown) {
113
+ const normalized = markdown.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
114
+ for (const character of normalized) {
115
+ const codePoint = character.codePointAt(0);
116
+ if ((codePoint <= 0x1f && character !== "\n" && character !== "\t") || codePoint === 0x7f) {
117
+ throw fail(CODES.markdownControl, "control character", "The Markdown source contains a control character.");
118
+ }
119
+ }
120
+ return normalized.split("\n").map((text, index) => ({ text, number: index + 1 }));
121
+ }
122
+
123
+ /**
124
+ * Front matter belongs to the caller. Recognising it here turns a confusing
125
+ * "setext heading" error into a sentence that names the real problem.
126
+ */
127
+ function rejectFrontMatter(lines) {
128
+ if (lines.length === 0 || lines[0].text.trim() !== "---") return;
129
+ for (let index = 1; index < lines.length; index += 1) {
130
+ const text = lines[index].text.trim();
131
+ if (text === "---" || text === "...") {
132
+ throw fail(
133
+ CODES.markdownFrontMatter,
134
+ "front matter",
135
+ "The Markdown source still carries a front-matter block. Strip it before converting: the page envelope "
136
+ + "belongs to 'pages push', not to the body.",
137
+ 1,
138
+ );
139
+ }
140
+ if (text !== "" && !/^[A-Za-z_][A-Za-z0-9_-]*:/u.test(text)) return;
141
+ }
142
+ }
143
+
144
+ function scalarLength(value, stopAfter = Number.POSITIVE_INFINITY) {
145
+ let length = 0;
146
+ for (const _character of value) {
147
+ length += 1;
148
+ if (length > stopAfter) break;
149
+ }
150
+ return length;
151
+ }
152
+
153
+ /**
154
+ * Split one physical pipe row without interpreting its inline Markdown.
155
+ * An escaped pipe is consumed here, after cell boundaries are known, so code
156
+ * spans and link destinations receive the same literal pipe as ordinary inline
157
+ * text. Other backslash escapes travel intact into `parseInline`. Unescaped
158
+ * pipes are separators even inside code/link syntax: the bounded subset has
159
+ * one unambiguous literal-pipe spelling.
160
+ */
161
+ function tableRowCells(text) {
162
+ const expanded = expandLeadingTabs(text);
163
+ const indent = indentOf(expanded);
164
+ if (indent > 3) return undefined;
165
+ const source = expanded.slice(indent).trim();
166
+ if (source === "") return undefined;
167
+
168
+ const cells = [];
169
+ let cell = "";
170
+ let separators = 0;
171
+ let endedWithSeparator = false;
172
+ for (let index = 0; index < source.length; index += 1) {
173
+ const character = source[index];
174
+ if (character === "\\" && index + 1 < source.length) {
175
+ const escaped = source[index + 1];
176
+ cell += escaped === "|" ? escaped : character + escaped;
177
+ index += 1;
178
+ endedWithSeparator = false;
179
+ continue;
180
+ }
181
+ if (character === "|") {
182
+ cells.push(cell.trim());
183
+ cell = "";
184
+ separators += 1;
185
+ endedWithSeparator = index === source.length - 1;
186
+ continue;
187
+ }
188
+ cell += character;
189
+ endedWithSeparator = false;
190
+ }
191
+ cells.push(cell.trim());
192
+ if (separators === 0) return undefined;
193
+ if (source.startsWith("|") && cells[0] === "") cells.shift();
194
+ if (endedWithSeparator && cells.at(-1) === "") cells.pop();
195
+ return { cells, separators };
196
+ }
197
+
198
+ function tableDelimiter(text) {
199
+ if (isBlockStart(text)) return undefined;
200
+ const row = tableRowCells(text);
201
+ if (!row || row.cells.length === 0) return undefined;
202
+ const delimiterish = row.cells.every((cell) => TABLE_DELIMITERISH_TOKEN_RE.test(cell));
203
+ const hasValidToken = row.cells.some((cell) => TABLE_DELIMITER_TOKEN_RE.test(cell));
204
+ if (!delimiterish && !hasValidToken) return undefined;
205
+ return {
206
+ ...row,
207
+ hasAlignment: row.cells.some((cell) => TABLE_DELIMITERISH_TOKEN_RE.test(cell) && cell.includes(":")),
208
+ valid: row.cells.every((cell) => TABLE_DELIMITER_TOKEN_RE.test(cell)),
209
+ };
210
+ }
211
+
212
+ /** A caption candidate is consumed only after the next two lines prove a table. */
213
+ function tablePrelude(lines, start) {
214
+ const candidate = lines[start];
215
+ if (!candidate) return undefined;
216
+
217
+ if (candidate.text.startsWith(TABLE_CAPTION_STEM) && start + 2 < lines.length) {
218
+ const headerText = lines[start + 1].text;
219
+ const header = isBlockStart(headerText) ? undefined : tableRowCells(headerText);
220
+ const delimiter = tableDelimiter(lines[start + 2].text);
221
+ if (header && header.cells.length > 0 && delimiter) {
222
+ return { captionLine: candidate, header, headerIndex: start + 1, delimiter, delimiterIndex: start + 2 };
223
+ }
224
+ }
225
+
226
+ if (start + 1 >= lines.length) return undefined;
227
+ const header = isBlockStart(candidate.text) ? undefined : tableRowCells(candidate.text);
228
+ const delimiter = tableDelimiter(lines[start + 1].text);
229
+ return header && header.cells.length > 0 && delimiter
230
+ ? { header, headerIndex: start, delimiter, delimiterIndex: start + 1 }
231
+ : undefined;
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Block parsing
236
+ // ---------------------------------------------------------------------------
237
+
238
+ function isBlockStart(text) {
239
+ return ATX_RE.test(text)
240
+ || THEMATIC_RE.test(text)
241
+ || FENCE_RE.test(text)
242
+ || BLOCKQUOTE_RE.test(text)
243
+ || BULLET_RE.test(text)
244
+ || ORDERED_RE.test(text)
245
+ || FOOTNOTE_DEFINITION_RE.test(text)
246
+ || REFERENCE_DEFINITION_RE.test(text)
247
+ || SECTION_HEADER_LIKE_RE.test(text)
248
+ || SECTION_CLOSE_RE.test(text);
249
+ }
250
+
251
+ function guardBlockConstructs(text, line, nextText) {
252
+ if (FOOTNOTE_DEFINITION_RE.test(text)) {
253
+ throw fail(CODES.markdownFootnote, "footnote", "Footnotes are outside the supported Markdown subset.", line.number);
254
+ }
255
+ if (REFERENCE_DEFINITION_RE.test(text)) {
256
+ throw fail(
257
+ CODES.markdownReferenceLink,
258
+ "reference link definition",
259
+ "Reference-style link definitions are outside the supported Markdown subset; write the URL inline.",
260
+ line.number,
261
+ );
262
+ }
263
+ const row = tableRowCells(text);
264
+ const delimiter = tableDelimiter(text);
265
+ const nextDelimiter = nextText === undefined ? undefined : tableDelimiter(nextText);
266
+ if (delimiter || (row && text.trimStart().startsWith("|")) || (row && nextDelimiter)) {
267
+ throw fail(
268
+ CODES.markdownTable,
269
+ "table",
270
+ "This looks like a malformed pipe table. Use a header row, a matching hyphen delimiter row, at least one data row, and a blank line after the table.",
271
+ line.number,
272
+ );
273
+ }
274
+ }
275
+
276
+ async function parseBlocks(lines, context, depth) {
277
+ if (depth > CONTENT_LIMITS.markdownNesting) {
278
+ throw fail(
279
+ CODES.markdownNesting,
280
+ "nesting",
281
+ `Markdown may not nest more than ${CONTENT_LIMITS.markdownNesting} levels deep.`,
282
+ lines[0]?.number,
283
+ );
284
+ }
285
+
286
+ const nodes = [];
287
+ let index = 0;
288
+ while (index < lines.length) {
289
+ const line = lines[index];
290
+ const text = expandLeadingTabs(line.text);
291
+
292
+ if (isBlank(text)) {
293
+ index += 1;
294
+ continue;
295
+ }
296
+
297
+ // Four columns of indentation is an indented code block in CommonMark,
298
+ // ahead of every other block type. It cannot interrupt a paragraph, so
299
+ // this only ever fires on a line that starts a block.
300
+ if (indentOf(text) >= 4) {
301
+ throw fail(
302
+ CODES.markdownIndentedCode,
303
+ "indented code block",
304
+ "Indented code blocks are outside the supported subset; use a fenced block so the language is explicit.",
305
+ line.number,
306
+ );
307
+ }
308
+
309
+ const table = tablePrelude(lines, index);
310
+ if (table) {
311
+ if (context.blockPlacement !== "root" && context.blockPlacement !== "section") {
312
+ throw fail(
313
+ CODES.tableShape,
314
+ "table",
315
+ "A table is valid only at the document root or directly inside a top-level section; move it out of the list or blockquote.",
316
+ line.number,
317
+ );
318
+ }
319
+ const consumed = consumeTable(lines, table, context);
320
+ nodes.push(consumed.node);
321
+ index = consumed.next;
322
+ continue;
323
+ }
324
+
325
+ if (SECTION_HEADER_LIKE_RE.test(text) || SECTION_CLOSE_RE.test(text)) {
326
+ if (depth !== 0) {
327
+ throw fail(
328
+ CODES.markdownSectionNested,
329
+ "section container",
330
+ "A section container is valid only at the document root; it cannot be nested in a section, list, or blockquote.",
331
+ line.number,
332
+ );
333
+ }
334
+ if (SECTION_CLOSE_RE.test(text)) {
335
+ throw fail(
336
+ CODES.markdownSectionHeader,
337
+ "section container",
338
+ "A section closing marker appeared without a matching top-level section header.",
339
+ line.number,
340
+ );
341
+ }
342
+ const consumed = await consumeSection(lines, index, text, context, depth);
343
+ nodes.push(consumed.node);
344
+ index = consumed.next;
345
+ continue;
346
+ }
347
+
348
+ const fence = FENCE_RE.exec(text);
349
+ if (fence) {
350
+ const consumed = await consumeFence(lines, index, fence, context);
351
+ nodes.push(consumed.node);
352
+ index = consumed.next;
353
+ continue;
354
+ }
355
+
356
+ if (THEMATIC_RE.test(text)) {
357
+ nodes.push({ type: "horizontalRule" });
358
+ index += 1;
359
+ continue;
360
+ }
361
+
362
+ const heading = ATX_RE.exec(text);
363
+ if (heading) {
364
+ const level = heading[1].length;
365
+ if (level > 4) {
366
+ throw fail(
367
+ CODES.headingDepth,
368
+ "heading",
369
+ `Headings deeper than four levels are outside the supported subset; the renderer clamps '${
370
+ "#".repeat(level)
371
+ }' to an <h4>.`,
372
+ line.number,
373
+ );
374
+ }
375
+ const title = stripClosingHashes(heading[2] ?? "");
376
+ const content = parseInline(title, context.scan, line.number, []);
377
+ nodes.push(
378
+ content.length > 0
379
+ ? { type: "heading", attrs: { level }, content }
380
+ : { type: "heading", attrs: { level } },
381
+ );
382
+ index += 1;
383
+ continue;
384
+ }
385
+
386
+ if (BLOCKQUOTE_RE.test(text)) {
387
+ const consumed = await consumeBlockquote(lines, index, context, depth);
388
+ nodes.push(consumed.node);
389
+ index = consumed.next;
390
+ continue;
391
+ }
392
+
393
+ const marker = matchListMarker(text, line);
394
+ if (marker) {
395
+ const consumed = await consumeList(lines, index, marker, context, depth);
396
+ nodes.push(consumed.node);
397
+ index = consumed.next;
398
+ continue;
399
+ }
400
+
401
+ const consumed = await consumeParagraph(lines, index, context);
402
+ if (consumed.node) nodes.push(consumed.node);
403
+ index = consumed.next;
404
+ }
405
+
406
+ return nodes;
407
+ }
408
+
409
+ // ---------------------------------------------------------------------------
410
+ // GFM pipe tables
411
+ // ---------------------------------------------------------------------------
412
+
413
+ function inlineTextFacts(nodes, stopAfter) {
414
+ let scalars = 0;
415
+ let hasNonWhitespace = false;
416
+ for (const node of nodes) {
417
+ if (node.type !== "text" || typeof node.text !== "string") continue;
418
+ if (node.text.trim() !== "") hasNonWhitespace = true;
419
+ scalars += scalarLength(node.text, stopAfter - scalars);
420
+ if (scalars > stopAfter) break;
421
+ }
422
+ return { scalars, hasNonWhitespace };
423
+ }
424
+
425
+ function markdownTableCell(source, type, context, line) {
426
+ const inline = parseInline(source, context.scan, line.number, []);
427
+ const facts = inlineTextFacts(inline, TABLE_CONTRACT.cells.maxTextScalars);
428
+ if (facts.scalars > TABLE_CONTRACT.cells.maxTextScalars) {
429
+ throw fail(
430
+ CODES.tableBounds,
431
+ "table cell",
432
+ `A table cell may contain at most ${TABLE_CONTRACT.cells.maxTextScalars} Unicode scalar values of text. Shorten this cell.`,
433
+ line.number,
434
+ );
435
+ }
436
+ if (type === TABLE_NODE_TYPES.header && TABLE_CONTRACT.cells.headerNonWhitespace && !facts.hasNonWhitespace) {
437
+ throw fail(
438
+ CODES.tableHeader,
439
+ "table header",
440
+ "Every table header cell must contain non-whitespace text.",
441
+ line.number,
442
+ );
443
+ }
444
+
445
+ const paragraph = inline.length > 0 ? { type: "paragraph", content: inline } : { type: "paragraph" };
446
+ return { type, content: [paragraph] };
447
+ }
448
+
449
+ function consumeTable(lines, prelude, context) {
450
+ const headerLine = lines[prelude.headerIndex];
451
+ const delimiterLine = lines[prelude.delimiterIndex];
452
+ let caption;
453
+ if (prelude.captionLine) {
454
+ if (!prelude.captionLine.text.startsWith(TABLE_CONTRACT.markdown.captionPrefix)) {
455
+ throw fail(
456
+ CODES.markdownTable,
457
+ "table caption",
458
+ `A table caption must use the exact '${TABLE_CONTRACT.markdown.captionPrefix}Caption text' form.`,
459
+ prelude.captionLine.number,
460
+ );
461
+ }
462
+ caption = prelude.captionLine.text.slice(TABLE_CONTRACT.markdown.captionPrefix.length);
463
+ if (
464
+ caption.trim() === ""
465
+ || scalarLength(caption, TABLE_CONTRACT.attrs.caption.maxScalars) > TABLE_CONTRACT.attrs.caption.maxScalars
466
+ ) {
467
+ throw fail(
468
+ CODES.tableBounds,
469
+ "table caption",
470
+ `A table caption must be non-empty and no longer than ${TABLE_CONTRACT.attrs.caption.maxScalars} Unicode scalar values.`,
471
+ prelude.captionLine.number,
472
+ );
473
+ }
474
+ }
475
+
476
+ if (prelude.delimiter.hasAlignment) {
477
+ throw fail(
478
+ CODES.markdownTableAlignment,
479
+ "table alignment",
480
+ "Table-column alignment is unsupported. Remove the ':' characters from the delimiter row and use hyphens only.",
481
+ delimiterLine.number,
482
+ );
483
+ }
484
+ if (!prelude.delimiter.valid) {
485
+ throw fail(
486
+ CODES.markdownTable,
487
+ "table delimiter",
488
+ `Every delimiter cell must contain at least ${TABLE_CONTRACT.markdown.delimiterMinHyphens} hyphen and no other text.`,
489
+ delimiterLine.number,
490
+ );
491
+ }
492
+ if (prelude.delimiter.cells.length !== prelude.header.cells.length) {
493
+ throw fail(
494
+ CODES.tableRagged,
495
+ "table delimiter",
496
+ `The delimiter has ${prelude.delimiter.cells.length} cells but the header has ${prelude.header.cells.length}. Make every row the same width.`,
497
+ delimiterLine.number,
498
+ );
499
+ }
500
+ if (
501
+ prelude.header.cells.length < TABLE_CONTRACT.columns.min
502
+ || prelude.header.cells.length > TABLE_CONTRACT.columns.max
503
+ ) {
504
+ throw fail(
505
+ CODES.tableBounds,
506
+ "table columns",
507
+ `A table must contain ${TABLE_CONTRACT.columns.min} through ${TABLE_CONTRACT.columns.max} columns.`,
508
+ headerLine.number,
509
+ );
510
+ }
511
+
512
+ const header = {
513
+ type: TABLE_NODE_TYPES.row,
514
+ content: prelude.header.cells.map((cell) => markdownTableCell(cell, TABLE_NODE_TYPES.header, context, headerLine)),
515
+ };
516
+ const data = [];
517
+ let index = prelude.delimiterIndex + 1;
518
+ while (index < lines.length && !isBlank(expandLeadingTabs(lines[index].text))) {
519
+ const line = lines[index];
520
+ const expanded = expandLeadingTabs(line.text);
521
+ if (isBlockStart(expanded)) {
522
+ guardBlockConstructs(expanded, line, lines[index + 1]?.text);
523
+ throw fail(
524
+ CODES.markdownTable,
525
+ "table row",
526
+ "A blank line must end a table. Before that blank line, every non-blank line must be a pipe-delimited data row.",
527
+ line.number,
528
+ );
529
+ }
530
+ const row = tableRowCells(line.text);
531
+ if (!row) {
532
+ throw fail(
533
+ CODES.markdownTable,
534
+ "table row",
535
+ "A blank line must end a table. Before that blank line, every non-blank line must be a pipe-delimited data row.",
536
+ line.number,
537
+ );
538
+ }
539
+ if (data.length >= TABLE_CONTRACT.rows.maxData) {
540
+ throw fail(
541
+ CODES.tableBounds,
542
+ "table rows",
543
+ `A table may contain at most ${TABLE_CONTRACT.rows.maxData} data rows. Split this table or remove rows.`,
544
+ line.number,
545
+ );
546
+ }
547
+ if (row.cells.length < TABLE_CONTRACT.columns.min || row.cells.length > TABLE_CONTRACT.columns.max) {
548
+ throw fail(
549
+ CODES.tableBounds,
550
+ "table columns",
551
+ `Every table row must contain ${TABLE_CONTRACT.columns.min} through ${TABLE_CONTRACT.columns.max} cells.`,
552
+ line.number,
553
+ );
554
+ }
555
+ if (row.cells.length !== prelude.header.cells.length) {
556
+ throw fail(
557
+ CODES.tableRagged,
558
+ "table row",
559
+ `This row has ${row.cells.length} cells but the header has ${prelude.header.cells.length}. Make every row the same width.`,
560
+ line.number,
561
+ );
562
+ }
563
+ data.push({
564
+ type: TABLE_NODE_TYPES.row,
565
+ content: row.cells.map((cell) => markdownTableCell(cell, TABLE_NODE_TYPES.cell, context, line)),
566
+ });
567
+ index += 1;
568
+ }
569
+
570
+ if (data.length < TABLE_CONTRACT.rows.minData) {
571
+ throw fail(
572
+ CODES.tableBounds,
573
+ "table rows",
574
+ `A table must contain at least ${TABLE_CONTRACT.rows.minData} data row after its header and delimiter.`,
575
+ delimiterLine.number,
576
+ );
577
+ }
578
+
579
+ return {
580
+ node: {
581
+ type: TABLE_NODE_TYPES.table,
582
+ ...(caption === undefined ? {} : { attrs: { caption } }),
583
+ content: [header, ...data],
584
+ },
585
+ next: index,
586
+ };
587
+ }
588
+
589
+ // ---------------------------------------------------------------------------
590
+ // Top-level section containers
591
+ // ---------------------------------------------------------------------------
592
+
593
+ function sectionHeaderAttrs(text, line) {
594
+ const match = SECTION_HEADER_RE.exec(text);
595
+ if (!match) {
596
+ throw fail(
597
+ CODES.markdownSectionHeader,
598
+ "section header",
599
+ "A section header must be exactly ':::section {JSON object}' with no indentation or trailing text.",
600
+ line.number,
601
+ );
602
+ }
603
+
604
+ let parsed;
605
+ try {
606
+ parsed = JSON.parse(match[1]);
607
+ } catch (error) {
608
+ throw fail(
609
+ CODES.markdownSectionHeader,
610
+ "section header",
611
+ "A section header must contain a valid JSON object.",
612
+ line.number,
613
+ error,
614
+ );
615
+ }
616
+ if (!isPlainObject(parsed)) {
617
+ throw fail(
618
+ CODES.markdownSectionHeader,
619
+ "section header",
620
+ "A section header must contain a JSON object, not an array or scalar.",
621
+ line.number,
622
+ );
623
+ }
624
+
625
+ const definitions = FREE_FORM_SECTION_REGISTRY.section.attrs;
626
+ for (const key of Object.keys(parsed)) {
627
+ if (!Object.hasOwn(definitions, key)) {
628
+ throw fail(
629
+ CODES.attrUnknown,
630
+ "section header",
631
+ `Section attribute '${identifier(key)}' is not supported. Expected only ${
632
+ Object.keys(definitions).join(", ")
633
+ }.`,
634
+ line.number,
635
+ );
636
+ }
637
+ }
638
+
639
+ const context = parsed.context;
640
+ const contextDefinition = definitions.context;
641
+ const contextPattern = new RegExp(contextDefinition.pattern, "u");
642
+ if (
643
+ context !== undefined
644
+ && context !== null
645
+ && (
646
+ typeof context !== "string"
647
+ || [...context].length > contextDefinition.maximumLength
648
+ || !contextPattern.test(context)
649
+ )
650
+ ) {
651
+ throw fail(
652
+ CODES.attrInvalid,
653
+ "section header",
654
+ `Section context must match ${contextDefinition.pattern} and contain at most ${contextDefinition.maximumLength} characters.`,
655
+ line.number,
656
+ );
657
+ }
658
+
659
+ for (const name of ["contentPadding", "surface"]) {
660
+ const definition = definitions[name];
661
+ const value = parsed[name];
662
+ if (value !== undefined && (typeof value !== "string" || !definition.values.includes(value))) {
663
+ throw fail(
664
+ CODES.attrInvalid,
665
+ "section header",
666
+ `Section ${name} must be one of ${definition.values.map((entry) => JSON.stringify(entry)).join(", ")}.`,
667
+ line.number,
668
+ );
669
+ }
670
+ }
671
+
672
+ const normalizedDecoration = normalizeFreeFormSectionDecoration(
673
+ parsed.decoration,
674
+ "/attrs/decoration",
675
+ );
676
+ if (normalizedDecoration.errors.length > 0) {
677
+ const [first] = normalizedDecoration.errors;
678
+ throw fail(first.code, first.path, first.message, line.number);
679
+ }
680
+
681
+ const normalizedBackground = normalizeFreeFormSectionBackground(
682
+ parsed.background,
683
+ "/attrs/background",
684
+ );
685
+ if (normalizedBackground.errors.length > 0) {
686
+ const [first] = normalizedBackground.errors;
687
+ throw fail(first.code, first.path, first.message, line.number);
688
+ }
689
+
690
+ return {
691
+ ...(typeof context === "string" ? { context } : {}),
692
+ contentPadding: parsed.contentPadding ?? definitions.contentPadding.default,
693
+ surface: parsed.surface ?? definitions.surface.default,
694
+ ...(normalizedBackground.background ? { background: normalizedBackground.background } : {}),
695
+ ...(normalizedDecoration.decoration ? { decoration: normalizedDecoration.decoration } : {}),
696
+ };
697
+ }
698
+
699
+ function fenceClosing(delimiter) {
700
+ return new RegExp(`^ {0,3}${delimiter[0] === "\`" ? "`" : "~"}{${delimiter.length},} *$`, "u");
701
+ }
702
+
703
+ async function consumeSection(lines, start, headerText, context, depth) {
704
+ const line = lines[start];
705
+ const attrs = sectionHeaderAttrs(headerText, line);
706
+ const body = [];
707
+ let index = start + 1;
708
+ let activeFence;
709
+
710
+ while (index < lines.length) {
711
+ const bodyLine = lines[index];
712
+ const text = expandLeadingTabs(bodyLine.text);
713
+ if (activeFence) {
714
+ body.push(bodyLine);
715
+ if (activeFence.test(text)) activeFence = undefined;
716
+ index += 1;
717
+ continue;
718
+ }
719
+ const fence = FENCE_RE.exec(text);
720
+ if (fence) {
721
+ activeFence = fenceClosing(fence[2]);
722
+ body.push(bodyLine);
723
+ index += 1;
724
+ continue;
725
+ }
726
+ if (SECTION_CLOSE_RE.test(text)) {
727
+ const content = await parseBlocks(body, { ...context, blockPlacement: "section" }, depth + 1);
728
+ if (content.length === 0) {
729
+ throw fail(
730
+ CODES.markdownEmpty,
731
+ "section container",
732
+ "A section container must contain at least one ordinary block.",
733
+ line.number,
734
+ );
735
+ }
736
+ return { node: { type: FREE_FORM_SECTION_REGISTRY.section.nodeType, attrs, content }, next: index + 1 };
737
+ }
738
+ if (SECTION_HEADER_LIKE_RE.test(text)) {
739
+ throw fail(
740
+ CODES.markdownSectionNested,
741
+ "section container",
742
+ "A section container cannot contain another section container.",
743
+ bodyLine.number,
744
+ );
745
+ }
746
+ body.push(bodyLine);
747
+ index += 1;
748
+ }
749
+
750
+ throw fail(
751
+ CODES.markdownSectionUnclosed,
752
+ "section container",
753
+ "A section container was opened but never closed with ':::'.",
754
+ line.number,
755
+ );
756
+ }
757
+
758
+ function stripClosingHashes(title) {
759
+ const trimmed = title.trimEnd();
760
+ const closing = /^(.*?)\s+#+$/u.exec(trimmed);
761
+ if (closing) return closing[1].trimEnd();
762
+ return /^#+$/u.test(trimmed) ? "" : trimmed;
763
+ }
764
+
765
+ // ---------------------------------------------------------------------------
766
+ // Fenced blocks: code, components, and inline facts
767
+ // ---------------------------------------------------------------------------
768
+
769
+ function consumeFence(lines, start, fence, context) {
770
+ const [, indent, delimiter, rawInfo] = fence;
771
+ const info = rawInfo.trim();
772
+ const line = lines[start];
773
+ const closing = fenceClosing(delimiter);
774
+ const body = [];
775
+ let index = start + 1;
776
+ let closed = false;
777
+ while (index < lines.length) {
778
+ const text = lines[index].text;
779
+ if (closing.test(expandLeadingTabs(text))) {
780
+ closed = true;
781
+ index += 1;
782
+ break;
783
+ }
784
+ body.push(stripIndent(text, indent.length));
785
+ index += 1;
786
+ }
787
+ if (!closed) {
788
+ throw fail(
789
+ CODES.markdownUnclosedFence,
790
+ "fenced block",
791
+ "A fenced block was opened but never closed.",
792
+ line.number,
793
+ );
794
+ }
795
+
796
+ if (info.startsWith("component:")) {
797
+ return { node: componentNode(info.slice("component:".length).trim(), body.join("\n"), line), next: index };
798
+ }
799
+ if (info === INLINE_FACTS_CONTRACT.markdown.fence) {
800
+ if (context.blockPlacement !== "root" && context.blockPlacement !== "section") {
801
+ throw fail(
802
+ CODES.markdownInlineFacts,
803
+ "inline facts",
804
+ "An inline-facts block is valid only at the document root or directly inside a top-level section.",
805
+ line.number,
806
+ );
807
+ }
808
+ return { node: inlineFactsNode(body.join("\n"), line), next: index };
809
+ }
810
+ if (info !== "" && !LANGUAGE_RE.test(info)) {
811
+ throw fail(
812
+ CODES.markdownCodeLanguage,
813
+ "code fence info string",
814
+ "A code fence takes a single language token; attributes and titles are outside the supported subset.",
815
+ line.number,
816
+ );
817
+ }
818
+
819
+ const attrs = info === "" ? undefined : { language: info };
820
+ const text = body.join("\n");
821
+ const node = { type: "codeBlock" };
822
+ if (attrs) node.attrs = attrs;
823
+ if (text !== "") node.content = [{ type: "text", text }];
824
+ return { node, next: index };
825
+ }
826
+
827
+ function inlineFactsNode(body, line) {
828
+ let parsed;
829
+ try {
830
+ parsed = JSON.parse(body);
831
+ } catch (error) {
832
+ throw fail(
833
+ CODES.markdownInlineFacts,
834
+ "inline facts",
835
+ "An inline-facts fence body must be a valid JSON array.",
836
+ line.number,
837
+ error,
838
+ );
839
+ }
840
+ const normalized = normalizeInlineFactsItems(parsed);
841
+ if (normalized.errors.length > 0) {
842
+ const [first] = normalized.errors;
843
+ throw fail(first.code, first.path, first.message, line.number);
844
+ }
845
+ return {
846
+ type: INLINE_FACTS_CONTRACT.nodeType,
847
+ attrs: { items: normalized.items },
848
+ };
849
+ }
850
+
851
+ function stripIndent(text, columns) {
852
+ let removed = 0;
853
+ let index = 0;
854
+ while (index < text.length && removed < columns && text[index] === " ") {
855
+ removed += 1;
856
+ index += 1;
857
+ }
858
+ return text.slice(index);
859
+ }
860
+
861
+ function componentNode(componentType, body, line) {
862
+ if (!isComponentType(componentType)) {
863
+ throw fail(
864
+ CODES.componentUnknown,
865
+ "component block",
866
+ `Unknown component type '${identifier(componentType)}' in a component fence.`,
867
+ line.number,
868
+ );
869
+ }
870
+ const source = body.trim() === "" ? "{}" : body;
871
+ const errors = validateComponentBlock(componentType, source, "");
872
+ if (errors.length > 0) {
873
+ throw fail(errors[0].code, "component block", errors[0].message, line.number);
874
+ }
875
+ return {
876
+ type: "componentBlock",
877
+ attrs: {
878
+ componentType,
879
+ componentData: canonicalizeComponentData(componentType, JSON.parse(source)),
880
+ },
881
+ };
882
+ }
883
+
884
+ // ---------------------------------------------------------------------------
885
+ // Blockquotes
886
+ // ---------------------------------------------------------------------------
887
+
888
+ async function consumeBlockquote(lines, start, context, depth) {
889
+ const inner = [];
890
+ let index = start;
891
+ while (index < lines.length) {
892
+ const text = expandLeadingTabs(lines[index].text);
893
+ if (BLOCKQUOTE_RE.test(text)) {
894
+ inner.push({ text: text.replace(/^ {0,3}> ?/u, ""), number: lines[index].number });
895
+ index += 1;
896
+ continue;
897
+ }
898
+ // A lazy continuation line carries on the quoted paragraph; a blank line
899
+ // or a new block ends the quote.
900
+ if (isBlank(text) || isBlockStart(text) || inner.length === 0 || isBlank(inner[inner.length - 1].text)) break;
901
+ inner.push({ text, number: lines[index].number });
902
+ index += 1;
903
+ }
904
+
905
+ const content = await parseBlocks(inner, { ...context, blockPlacement: "nested" }, depth + 1);
906
+ if (content.length === 0) {
907
+ throw fail(
908
+ CODES.markdownEmpty,
909
+ "blockquote",
910
+ "A blockquote must contain content.",
911
+ lines[start].number,
912
+ );
913
+ }
914
+ return { node: { type: "blockquote", content }, next: index };
915
+ }
916
+
917
+ // ---------------------------------------------------------------------------
918
+ // Lists
919
+ // ---------------------------------------------------------------------------
920
+
921
+ function matchListMarker(text, line) {
922
+ const bullet = BULLET_RE.exec(text);
923
+ if (bullet) {
924
+ const [, indent, marker, spacing, rest] = bullet;
925
+ if (marker === "+") {
926
+ throw fail(
927
+ CODES.markdownListMarker,
928
+ "list marker",
929
+ "'+' bullets are outside the supported subset; use '-' or '*'.",
930
+ line.number,
931
+ );
932
+ }
933
+ return {
934
+ ordered: false,
935
+ indent: indent.length,
936
+ contentIndent: indent.length + 1 + Math.max(spacing.length, 1),
937
+ rest,
938
+ };
939
+ }
940
+
941
+ const ordered = ORDERED_RE.exec(text);
942
+ if (!ordered) return undefined;
943
+ const [, indent, number, delimiter, spacing, rest] = ordered;
944
+ if (delimiter === ")") {
945
+ throw fail(
946
+ CODES.markdownListMarker,
947
+ "list marker",
948
+ "')' ordered-list delimiters are outside the supported subset; use '1.'.",
949
+ line.number,
950
+ );
951
+ }
952
+ return {
953
+ ordered: true,
954
+ number: Number(number),
955
+ indent: indent.length,
956
+ contentIndent: indent.length + number.length + 1 + Math.max(spacing.length, 1),
957
+ rest,
958
+ };
959
+ }
960
+
961
+ async function consumeList(lines, start, first, context, depth) {
962
+ if (first.ordered && first.number !== 1) {
963
+ throw fail(
964
+ CODES.markdownListStart,
965
+ "ordered list start",
966
+ `An ordered list must start at 1; the document model carries no start attribute, so '${first.number}.' `
967
+ + "would publish as '1.'.",
968
+ lines[start].number,
969
+ );
970
+ }
971
+
972
+ const items = [];
973
+ let index = start;
974
+ let marker = first;
975
+ while (index < lines.length) {
976
+ if (items.length >= MAX_LIST_ITEMS) {
977
+ throw fail(
978
+ CODES.markdownNesting,
979
+ "list",
980
+ `A list may not exceed ${MAX_LIST_ITEMS} items.`,
981
+ lines[index].number,
982
+ );
983
+ }
984
+ const itemLine = lines[index];
985
+ const itemLines = [{ text: marker.rest, number: itemLine.number }];
986
+ index += 1;
987
+
988
+ while (index < lines.length) {
989
+ const text = expandLeadingTabs(lines[index].text);
990
+ if (isBlank(text)) {
991
+ itemLines.push({ text: "", number: lines[index].number });
992
+ index += 1;
993
+ continue;
994
+ }
995
+ if (indentOf(text) >= marker.contentIndent) {
996
+ itemLines.push({ text: text.slice(marker.contentIndent), number: lines[index].number });
997
+ index += 1;
998
+ continue;
999
+ }
1000
+ // A lazy continuation keeps a paragraph going; anything else ends the item.
1001
+ const previous = itemLines[itemLines.length - 1];
1002
+ if (!isBlockStart(text) && previous && !isBlank(previous.text)) {
1003
+ itemLines.push({ text, number: lines[index].number });
1004
+ index += 1;
1005
+ continue;
1006
+ }
1007
+ break;
1008
+ }
1009
+
1010
+ while (itemLines.length > 0 && isBlank(itemLines[itemLines.length - 1].text)) itemLines.pop();
1011
+ items.push(await listItem(itemLines, itemLine, context, depth));
1012
+
1013
+ // The next item has to be a marker of the same kind at the same indent.
1014
+ let lookahead = index;
1015
+ while (lookahead < lines.length && isBlank(expandLeadingTabs(lines[lookahead].text))) lookahead += 1;
1016
+ if (lookahead >= lines.length) break;
1017
+ const nextText = expandLeadingTabs(lines[lookahead].text);
1018
+ const nextMarker = matchListMarker(nextText, lines[lookahead]);
1019
+ if (!nextMarker || nextMarker.ordered !== first.ordered || nextMarker.indent !== first.indent) break;
1020
+ marker = nextMarker;
1021
+ index = lookahead;
1022
+ }
1023
+
1024
+ return {
1025
+ node: { type: first.ordered ? "orderedList" : "bulletList", content: items },
1026
+ next: index,
1027
+ };
1028
+ }
1029
+
1030
+ async function listItem(itemLines, itemLine, context, depth) {
1031
+ const content = await parseBlocks(itemLines, { ...context, blockPlacement: "nested" }, depth + 1);
1032
+ if (content.length === 0) return { type: "listItem", content: [{ type: "paragraph" }] };
1033
+ if (content[0].type !== "paragraph") {
1034
+ throw fail(
1035
+ CODES.markdownListItem,
1036
+ "list item",
1037
+ "A list item must begin with text; the editor's schema requires a paragraph first.",
1038
+ itemLine.number,
1039
+ );
1040
+ }
1041
+ return { type: "listItem", content };
1042
+ }
1043
+
1044
+ // ---------------------------------------------------------------------------
1045
+ // Paragraphs
1046
+ // ---------------------------------------------------------------------------
1047
+
1048
+ async function consumeParagraph(lines, start, context) {
1049
+ const collected = [];
1050
+ let index = start;
1051
+ while (index < lines.length) {
1052
+ const line = lines[index];
1053
+ const text = expandLeadingTabs(line.text);
1054
+ if (isBlank(text)) break;
1055
+ if (collected.length > 0) {
1056
+ if (SETEXT_RE.test(text)) {
1057
+ throw fail(
1058
+ CODES.markdownSetext,
1059
+ "setext heading",
1060
+ "Setext headings are outside the supported subset; use '#' or '##'.",
1061
+ line.number,
1062
+ );
1063
+ }
1064
+ if (tablePrelude(lines, index)) break;
1065
+ if (isBlockStart(text)) break;
1066
+ }
1067
+ guardBlockConstructs(text, line, index + 1 < lines.length ? expandLeadingTabs(lines[index + 1].text) : undefined);
1068
+ collected.push({ text: text.trim(), number: line.number, raw: text });
1069
+ index += 1;
1070
+ }
1071
+
1072
+ const line = lines[start];
1073
+ const joined = joinParagraphLines(collected);
1074
+ const standalone = STANDALONE_IMAGE_RE.exec(joined);
1075
+ if (standalone) {
1076
+ return { node: await imageNode(standalone[1], standalone[2], context, line), next: index };
1077
+ }
1078
+
1079
+ const content = parseInline(joined, context.scan, line.number, []);
1080
+ return { node: content.length > 0 ? { type: "paragraph", content } : undefined, next: index };
1081
+ }
1082
+
1083
+ /**
1084
+ * Joins a paragraph's lines into one inline string. A line ending in two or
1085
+ * more spaces, or in a single backslash, is a hard break and becomes the
1086
+ * sentinel newline the inline scanner turns into a `hardBreak` node; any other
1087
+ * line ending is a soft break and becomes a space, exactly as the editor
1088
+ * stores continued text. Joining before the inline pass — rather than parsing
1089
+ * each line separately — is what lets `**bold` on one line close on the next.
1090
+ */
1091
+ function joinParagraphLines(collected) {
1092
+ let joined = "";
1093
+ collected.forEach((entry, position) => {
1094
+ const last = position === collected.length - 1;
1095
+ const hardBySpaces = /[ ]{2,}$/u.test(entry.raw);
1096
+ const hardByBackslash = /(^|[^\\])\\$/u.test(entry.text);
1097
+ const text = hardByBackslash ? entry.text.slice(0, -1) : entry.text;
1098
+ joined += text;
1099
+ if (last) return;
1100
+ joined += hardBySpaces || hardByBackslash ? HARD_BREAK : " ";
1101
+ });
1102
+ return joined;
1103
+ }
1104
+
1105
+ // ---------------------------------------------------------------------------
1106
+ // Inline parsing
1107
+ // ---------------------------------------------------------------------------
1108
+
1109
+ const MARK_ORDER = new Map(MARK_TYPES.map((type, position) => [type, position]));
1110
+
1111
+ function sortMarks(marks) {
1112
+ return [...marks].sort((left, right) => MARK_ORDER.get(left.type) - MARK_ORDER.get(right.type));
1113
+ }
1114
+
1115
+ function textNode(text, marks) {
1116
+ const node = { type: "text", text };
1117
+ if (marks.length > 0) node.marks = sortMarks(marks);
1118
+ return node;
1119
+ }
1120
+
1121
+ function sameMarks(left, right) {
1122
+ if (left === right) return true;
1123
+ if (!left || !right || left.length !== right.length) return false;
1124
+ return JSON.stringify(left) === JSON.stringify(right);
1125
+ }
1126
+
1127
+ /** Merges adjacent text nodes carrying identical marks, as ProseMirror does. */
1128
+ function mergeText(nodes) {
1129
+ const merged = [];
1130
+ for (const node of nodes) {
1131
+ const previous = merged[merged.length - 1];
1132
+ if (node.type === "text" && previous?.type === "text" && sameMarks(previous.marks, node.marks)) {
1133
+ previous.text += node.text;
1134
+ continue;
1135
+ }
1136
+ merged.push(node);
1137
+ }
1138
+ return merged;
1139
+ }
1140
+
1141
+ /**
1142
+ * CommonMark's left-flanking rule, simplified: a delimiter opens emphasis only
1143
+ * when it is followed by non-whitespace and preceded by the start of the text,
1144
+ * whitespace, or punctuation. Without it, `a * b * c` becomes italics.
1145
+ */
1146
+ function isFlanking(text, index, length) {
1147
+ const before = index === 0 ? "" : text[index - 1];
1148
+ const after = text[index + length] ?? "";
1149
+ if (after === "" || after.trim() === "") return false;
1150
+ return before === "" || before.trim() === "" || PUNCTUATION_RE.test(before);
1151
+ }
1152
+
1153
+ /** The mirror image: a delimiter closes only when it follows non-whitespace. */
1154
+ function isClosing(text, index, length) {
1155
+ const before = text[index - 1] ?? "";
1156
+ const after = text[index + length] ?? "";
1157
+ if (before === "" || before.trim() === "") return false;
1158
+ return after === "" || after.trim() === "" || PUNCTUATION_RE.test(after);
1159
+ }
1160
+
1161
+ /**
1162
+ * The inline scanner's work budget, carried per conversion.
1163
+ *
1164
+ * Every delimiter search below is linear, and an *unclosed* delimiter searches
1165
+ * to the end of its block before giving up — so a block of nothing but openers
1166
+ * costs a scan per opener and the whole thing is quadratic. `markdownBytes`
1167
+ * bounds the input, not the work: at the 1 MB ceiling that is minutes of
1168
+ * synchronous CPU no caller could interrupt. Each scanning step is charged
1169
+ * against this counter, and exhausting it is a refusal rather than a hang.
1170
+ */
1171
+ function createScanBudget() {
1172
+ return { remaining: CONTENT_LIMITS.inlineScanBudget };
1173
+ }
1174
+
1175
+ function scanExhausted(lineNumber) {
1176
+ return fail(
1177
+ CODES.markdownInput,
1178
+ "inline scanning",
1179
+ "This document needs more inline scanning than one conversion will do. Shorten the block, or close the "
1180
+ + "unmatched '*', '_', '~', '`', '[', or '(' that is making the scanner search to the end of it.",
1181
+ lineNumber,
1182
+ );
1183
+ }
1184
+
1185
+ /**
1186
+ * Finds the closing run of `delimiter`, skipping escapes and code spans so
1187
+ * `` `a * b` `` never closes an emphasis opened outside it.
1188
+ */
1189
+ function findClosing(text, from, delimiter, underscore, scan, lineNumber) {
1190
+ let index = from;
1191
+ while (index < text.length) {
1192
+ if ((scan.remaining -= 1) < 0) throw scanExhausted(lineNumber);
1193
+ const character = text[index];
1194
+ if (character === "\\") {
1195
+ index += 2;
1196
+ continue;
1197
+ }
1198
+ if (character === "`") {
1199
+ const span = matchCodeSpan(text, index, scan, lineNumber);
1200
+ index = span ? span.end : index + 1;
1201
+ continue;
1202
+ }
1203
+ if (text.startsWith(delimiter, index) && (!underscore || isClosing(text, index, delimiter.length))) {
1204
+ return index;
1205
+ }
1206
+ index += 1;
1207
+ }
1208
+ return -1;
1209
+ }
1210
+
1211
+ function matchCodeSpan(text, start, scan, lineNumber) {
1212
+ let length = 0;
1213
+ while (text[start + length] === "`") length += 1;
1214
+ let index = start + length;
1215
+ while (index < text.length) {
1216
+ if ((scan.remaining -= 1) < 0) throw scanExhausted(lineNumber);
1217
+ if (text[index] !== "`") {
1218
+ index += 1;
1219
+ continue;
1220
+ }
1221
+ let run = 0;
1222
+ while (text[index + run] === "`") run += 1;
1223
+ if (run === length) {
1224
+ return { start: start + length, end: index + length, content: text.slice(start + length, index) };
1225
+ }
1226
+ index += run;
1227
+ }
1228
+ return undefined;
1229
+ }
1230
+
1231
+ function findLinkTextEnd(text, start, scan, lineNumber) {
1232
+ let depth = 0;
1233
+ let index = start;
1234
+ while (index < text.length) {
1235
+ if ((scan.remaining -= 1) < 0) throw scanExhausted(lineNumber);
1236
+ const character = text[index];
1237
+ if (character === "\\") {
1238
+ index += 2;
1239
+ continue;
1240
+ }
1241
+ if (character === "`") {
1242
+ const span = matchCodeSpan(text, index, scan, lineNumber);
1243
+ index = span ? span.end : index + 1;
1244
+ continue;
1245
+ }
1246
+ if (character === "[") depth += 1;
1247
+ if (character === "]") {
1248
+ if (depth === 0) return index;
1249
+ depth -= 1;
1250
+ }
1251
+ index += 1;
1252
+ }
1253
+ return -1;
1254
+ }
1255
+
1256
+ function parseInline(text, scan, lineNumber, marks) {
1257
+ const nodes = [];
1258
+ let buffer = "";
1259
+ const flush = () => {
1260
+ if (buffer !== "") {
1261
+ nodes.push(textNode(buffer, marks));
1262
+ buffer = "";
1263
+ }
1264
+ };
1265
+
1266
+ let index = 0;
1267
+ while (index < text.length) {
1268
+ const character = text[index];
1269
+
1270
+ if (character === "\\") {
1271
+ const next = text[index + 1];
1272
+ if (next !== undefined && PUNCTUATION_RE.test(next)) {
1273
+ buffer += next;
1274
+ index += 2;
1275
+ continue;
1276
+ }
1277
+ buffer += character;
1278
+ index += 1;
1279
+ continue;
1280
+ }
1281
+
1282
+ if (character === HARD_BREAK) {
1283
+ flush();
1284
+ nodes.push({ type: "hardBreak" });
1285
+ index += 1;
1286
+ continue;
1287
+ }
1288
+
1289
+ if (character === "&" && ENTITY_RE.test(text.slice(index))) {
1290
+ throw fail(
1291
+ CODES.markdownEntity,
1292
+ "HTML entity",
1293
+ "HTML entities are outside the supported subset; write the character itself.",
1294
+ lineNumber,
1295
+ );
1296
+ }
1297
+
1298
+ if (character === "<") {
1299
+ const rest = text.slice(index);
1300
+ if (AUTOLINK_RE.test(rest)) {
1301
+ throw fail(
1302
+ CODES.markdownAutolink,
1303
+ "autolink",
1304
+ "Autolinks are outside the supported subset; write [text](url).",
1305
+ lineNumber,
1306
+ );
1307
+ }
1308
+ if (HTML_TAG_RE.test(rest)) {
1309
+ throw fail(
1310
+ CODES.markdownHtml,
1311
+ "inline HTML",
1312
+ "Inline HTML is outside the supported subset; it would publish verbatim and unsanitised.",
1313
+ lineNumber,
1314
+ );
1315
+ }
1316
+ buffer += character;
1317
+ index += 1;
1318
+ continue;
1319
+ }
1320
+
1321
+ if (character === "`") {
1322
+ const span = matchCodeSpan(text, index, scan, lineNumber);
1323
+ if (span) {
1324
+ flush();
1325
+ nodes.push(textNode(normalizeCodeSpan(span.content), [...marks, { type: "code" }]));
1326
+ index = span.end;
1327
+ continue;
1328
+ }
1329
+ buffer += character;
1330
+ index += 1;
1331
+ continue;
1332
+ }
1333
+
1334
+ if (character === "!" && text[index + 1] === "[") {
1335
+ throw fail(
1336
+ CODES.markdownImage,
1337
+ "inline image",
1338
+ "An image must stand alone in its own block; the document model has no inline image.",
1339
+ lineNumber,
1340
+ );
1341
+ }
1342
+
1343
+ if (character === "[") {
1344
+ if (text[index + 1] === "^") {
1345
+ throw fail(
1346
+ CODES.markdownFootnote,
1347
+ "footnote",
1348
+ "Footnotes are outside the supported Markdown subset.",
1349
+ lineNumber,
1350
+ );
1351
+ }
1352
+ const link = parseLink(text, index, scan, lineNumber, marks);
1353
+ if (link) {
1354
+ flush();
1355
+ nodes.push(...link.nodes);
1356
+ index = link.end;
1357
+ continue;
1358
+ }
1359
+ buffer += character;
1360
+ index += 1;
1361
+ continue;
1362
+ }
1363
+
1364
+ const emphasis = parseEmphasis(text, index, scan, lineNumber, marks);
1365
+ if (emphasis) {
1366
+ flush();
1367
+ nodes.push(...emphasis.nodes);
1368
+ index = emphasis.end;
1369
+ continue;
1370
+ }
1371
+
1372
+ buffer += character;
1373
+ index += 1;
1374
+ }
1375
+
1376
+ flush();
1377
+ return mergeText(nodes);
1378
+ }
1379
+
1380
+ /** CommonMark strips one space from each end of a code span that has both. */
1381
+ function normalizeCodeSpan(content) {
1382
+ if (content.length > 2 && content.startsWith(" ") && content.endsWith(" ") && content.trim() !== "") {
1383
+ return content.slice(1, -1);
1384
+ }
1385
+ return content;
1386
+ }
1387
+
1388
+ // Longest delimiter first: `***strong emphasis***` has to be recognised
1389
+ // before the `**` and `*` runs inside it, or its asterisks end up as literal
1390
+ // text either side of a half-marked run.
1391
+ const EMPHASIS_DELIMITERS = Object.freeze([
1392
+ { delimiter: "***", types: Object.freeze(["bold", "italic"]), underscore: false },
1393
+ { delimiter: "___", types: Object.freeze(["bold", "italic"]), underscore: true },
1394
+ { delimiter: "~~", types: Object.freeze(["strike"]), underscore: false },
1395
+ { delimiter: "**", types: Object.freeze(["bold"]), underscore: false },
1396
+ { delimiter: "__", types: Object.freeze(["bold"]), underscore: true },
1397
+ { delimiter: "*", types: Object.freeze(["italic"]), underscore: false },
1398
+ { delimiter: "_", types: Object.freeze(["italic"]), underscore: true },
1399
+ ]);
1400
+
1401
+ function parseEmphasis(text, index, scan, lineNumber, marks) {
1402
+ for (const candidate of EMPHASIS_DELIMITERS) {
1403
+ if (!text.startsWith(candidate.delimiter, index)) continue;
1404
+ if (!isFlanking(text, index, candidate.delimiter.length)) continue;
1405
+ // `_` never opens emphasis inside a word, so snake_case_identifiers stay
1406
+ // literal instead of turning into silent italics.
1407
+ if (candidate.underscore && index > 0 && !/[\s\p{P}]/u.test(text[index - 1])) continue;
1408
+ if (candidate.types.some((type) => marks.some((mark) => mark.type === type))) continue;
1409
+ const from = index + candidate.delimiter.length;
1410
+ const close = findClosing(text, from, candidate.delimiter, candidate.underscore, scan, lineNumber);
1411
+ if (close < 0) continue;
1412
+ const inner = text.slice(from, close);
1413
+ if (inner.trim() === "") continue;
1414
+ return {
1415
+ nodes: parseInline(inner, scan, lineNumber, [...marks, ...candidate.types.map((type) => ({ type }))]),
1416
+ end: close + candidate.delimiter.length,
1417
+ };
1418
+ }
1419
+ return undefined;
1420
+ }
1421
+
1422
+ function parseLink(text, index, scan, lineNumber, marks) {
1423
+ const textEnd = findLinkTextEnd(text, index + 1, scan, lineNumber);
1424
+ if (textEnd < 0) return undefined;
1425
+ const after = text[textEnd + 1];
1426
+ if (after === "[") {
1427
+ throw fail(
1428
+ CODES.markdownReferenceLink,
1429
+ "reference link",
1430
+ "Reference-style links are outside the supported subset; write the URL inline.",
1431
+ lineNumber,
1432
+ );
1433
+ }
1434
+ if (after !== "(") return undefined;
1435
+
1436
+ const destinationEnd = findDestinationEnd(text, textEnd + 2, scan, lineNumber);
1437
+ if (destinationEnd < 0) return undefined;
1438
+ const destination = text.slice(textEnd + 2, destinationEnd).trim();
1439
+ if (marks.some((mark) => mark.type === "link")) {
1440
+ throw fail(CODES.markdownLink, "link", "Links cannot be nested.", lineNumber);
1441
+ }
1442
+ if (destination.startsWith("<")) {
1443
+ throw fail(
1444
+ CODES.markdownLink,
1445
+ "link",
1446
+ "Pointy-bracket link destinations are outside the supported subset; write the URL directly.",
1447
+ lineNumber,
1448
+ );
1449
+ }
1450
+ if (/\s/u.test(destination)) {
1451
+ throw fail(
1452
+ CODES.markdownLink,
1453
+ "link",
1454
+ "Link titles are outside the supported subset; the document model has nowhere to put one.",
1455
+ lineNumber,
1456
+ );
1457
+ }
1458
+ if (destination === "" || !isSafeUrl(destination)) {
1459
+ throw fail(
1460
+ CODES.markdownLink,
1461
+ "link",
1462
+ "A link destination must be a safe HTTP(S), mailto, tel, non-protocol-relative root, fragment, query, or relative URL without backslashes or ASCII controls; the renderer drops an unsafe link and publishes only its text.",
1463
+ lineNumber,
1464
+ );
1465
+ }
1466
+
1467
+ const inner = text.slice(index + 1, textEnd);
1468
+ const nodes = parseInline(inner, scan, lineNumber, [...marks, { type: "link", attrs: { href: destination } }]);
1469
+ if (nodes.length === 0) {
1470
+ throw fail(CODES.markdownLink, "link", "A link must have text.", lineNumber);
1471
+ }
1472
+ return { nodes, end: destinationEnd + 1 };
1473
+ }
1474
+
1475
+ // The same unbounded scan as `findLinkTextEnd`, and quadratic for the same
1476
+ // reason: an unbalanced opening parenthesis searches to the end of the block.
1477
+ function findDestinationEnd(text, from, scan, lineNumber) {
1478
+ let depth = 0;
1479
+ let index = from;
1480
+ while (index < text.length) {
1481
+ if ((scan.remaining -= 1) < 0) throw scanExhausted(lineNumber);
1482
+ const character = text[index];
1483
+ if (character === "\\") {
1484
+ index += 2;
1485
+ continue;
1486
+ }
1487
+ if (character === "(") depth += 1;
1488
+ if (character === ")") {
1489
+ if (depth === 0) return index;
1490
+ depth -= 1;
1491
+ }
1492
+ index += 1;
1493
+ }
1494
+ return -1;
1495
+ }
1496
+
1497
+ // ---------------------------------------------------------------------------
1498
+ // Images
1499
+ // ---------------------------------------------------------------------------
1500
+
1501
+ function unescapePunctuation(text) {
1502
+ return text.replace(/\\([!-/:-@[-`{-~])/gu, "$1");
1503
+ }
1504
+
1505
+ /**
1506
+ * Builds a `taprootImage` node from `![alt](reference)`.
1507
+ *
1508
+ * The node **always** carries `"src": ""` and `"urls": []`, whatever the
1509
+ * resolver reported for delivery URLs.
1510
+ * `PageImageDeliveryRewriter.ReplaceUrlFields` rewrites `src`/`url`/`urls`
1511
+ * only where the key already exists, filling them from the site's signed
1512
+ * delivery URLs at read time — so the key must be there and its value must not
1513
+ * be a URL this CLI invented. An image node missing the keys is rewritten into
1514
+ * nothing and renders blank; one carrying a hand-built URL renders a link the
1515
+ * site cannot sign.
1516
+ */
1517
+ async function imageNode(alt, reference, context, line) {
1518
+ const source = unescapePunctuation(reference);
1519
+ if (source.length > CONTENT_LIMITS.imageReferenceLength) {
1520
+ throw fail(CODES.markdownImage, "image", "The image reference is too long.", line.number);
1521
+ }
1522
+
1523
+ let resolved;
1524
+ try {
1525
+ resolved = await context.resolveImage(source);
1526
+ } catch (error) {
1527
+ // The resolver talks to the API, so its message can carry a presigned URL
1528
+ // or the credential. It travels as `cause` and never into the diagnostic.
1529
+ throw fail(
1530
+ CODES.markdownImage,
1531
+ "image",
1532
+ `The image '${identifier(source)}' could not be resolved. Upload it with 'media upload' first.`,
1533
+ line.number,
1534
+ error,
1535
+ );
1536
+ }
1537
+ if (!isPlainObject(resolved) || !isUuid(resolved.imageId)) {
1538
+ throw fail(
1539
+ CODES.markdownImage,
1540
+ "image",
1541
+ `Resolving the image '${identifier(source)}' produced no image id.`,
1542
+ line.number,
1543
+ );
1544
+ }
1545
+
1546
+ const attrs = {
1547
+ imageId: resolved.imageId,
1548
+ src: "",
1549
+ urls: [],
1550
+ alt: unescapePunctuation(alt) || (typeof resolved.alt === "string" ? resolved.alt : ""),
1551
+ };
1552
+ if (Number.isInteger(resolved.width) && resolved.width > 0) attrs.width = resolved.width;
1553
+ if (Number.isInteger(resolved.height) && resolved.height > 0) attrs.height = resolved.height;
1554
+ return { type: "taprootImage", attrs };
1555
+ }
1556
+
1557
+ // ---------------------------------------------------------------------------
1558
+ // Entry point
1559
+ // ---------------------------------------------------------------------------
1560
+
1561
+ /**
1562
+ * The converter's own gate. Every document this module returns has been
1563
+ * through `validateDocument`, so a converter bug surfaces here — named, with
1564
+ * the offending path — rather than as a page that publishes and renders wrong.
1565
+ * Exported for the tests that prove the gate is wired, not for callers.
1566
+ */
1567
+ export function assertConvertedDocument(document) {
1568
+ const { errors } = validateDocument(document);
1569
+ if (errors.length === 0) return;
1570
+ const [first] = errors;
1571
+ throw new SiteAuthoringError(
1572
+ CODES.markdownInvariant,
1573
+ `The converted document failed validation at '${first.path}' with ${first.code}. `
1574
+ + "That is a bug in the Markdown converter, not in the source document.",
1575
+ { field: first.code },
1576
+ );
1577
+ }
1578
+
1579
+ /**
1580
+ * Converts a Markdown document into a ProseMirror page body.
1581
+ *
1582
+ * `resolveImage(reference)` is called once per image with the raw reference
1583
+ * from `![alt](reference)` and must resolve to `{ imageId, src, urls, width,
1584
+ * height, alt }` — an already-uploaded image, resolved by `media upload` — or
1585
+ * throw. Only `imageId`, `width`, `height`, and `alt` are read: see
1586
+ * `imageNode` for why the delivery hints are deliberately discarded.
1587
+ */
1588
+ export async function markdownToProseMirror(markdown, options = {}) {
1589
+ const resolveImage = options?.resolveImage;
1590
+ if (typeof resolveImage !== "function") {
1591
+ throw fail(
1592
+ CODES.markdownResolveImage,
1593
+ "resolveImage",
1594
+ "markdownToProseMirror requires a resolveImage(reference) callback.",
1595
+ );
1596
+ }
1597
+ if (typeof markdown !== "string") {
1598
+ throw fail(CODES.markdownInput, "markdown", "The Markdown source must be a string.");
1599
+ }
1600
+ if (Buffer.byteLength(markdown, "utf8") > CONTENT_LIMITS.markdownBytes) {
1601
+ throw fail(
1602
+ CODES.markdownInput,
1603
+ "markdown",
1604
+ `The Markdown source exceeds ${CONTENT_LIMITS.markdownBytes} bytes.`,
1605
+ );
1606
+ }
1607
+
1608
+ const lines = toLines(markdown);
1609
+ rejectFrontMatter(lines);
1610
+ const content = await parseBlocks(
1611
+ lines,
1612
+ { resolveImage, scan: createScanBudget(), blockPlacement: "root" },
1613
+ 0,
1614
+ );
1615
+ const document = { type: "doc", content };
1616
+ if (!documentIsPresent(document)) {
1617
+ throw fail(
1618
+ CODES.markdownEmpty,
1619
+ "document",
1620
+ "The Markdown source produced an empty page body. A page needs text, an image, a component block, or inline facts.",
1621
+ );
1622
+ }
1623
+ assertConvertedDocument(document);
1624
+ return { doc: document };
1625
+ }