@orkestrel/markdown 0.0.12 → 0.0.14

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.
@@ -3,17 +3,17 @@ let _orkestrel_contract = require("@orkestrel/contract");
3
3
  let _orkestrel_html = require("@orkestrel/html");
4
4
  //#region src/core/constants.ts
5
5
  /**
6
- * The maximum recursion depth the parse pipeline (`parseDocument` and its
7
- * `parsers.ts` helpers) and the `helpers.ts` traversal / projection functions
8
- * (`markdownToHTML`, `renderHTML`, `renderMarkdown`, `walkNodes`, `foldNode`,
9
- * `rewriteDocument`) honor before degrading. It bounds blockquote nesting, inline
6
+ * Caps the recursion depth the parse pipeline (`parseDocument` and its
7
+ * `parsers.ts` helpers), the `helpers.ts` traversal / projection functions
8
+ * (`markdownToHTML`, `renderMarkdown`, `walkNodes`, `foldNode`, `rewriteDocument`),
9
+ * and the `compilers.ts` renderer (`renderHTML`) honor before degrading, at 64. It bounds blockquote nesting, inline
10
10
  * nesting (emphasis / links), and traversal / projection recursion so pathological
11
11
  * or hostile input cannot exhaust the call stack. {@link htmlToMarkdown} is the
12
12
  * inherited exception: its fold and depth cap belong to `@orkestrel/html`.
13
13
  */
14
14
  var MAX_DEPTH = 64;
15
15
  /**
16
- * The frozen empty HTML-to-markdown projection from which projection factories
16
+ * Holds the frozen empty HTML-to-markdown projection from which projection factories
17
17
  * default every absent field.
18
18
  *
19
19
  * @example
@@ -32,182 +32,66 @@ var EMPTY_PROJECTION = Object.freeze({
32
32
  //#endregion
33
33
  //#region src/core/validators.ts
34
34
  /**
35
- * Whether `character` is an inline whitespace character (space / tab / newline) - the
36
- * emphasis flanking rule's space test.
35
+ * Determines whether a node is a heading block.
37
36
  *
38
- * @param character - The character to test
39
- * @returns `true` when it is inline whitespace
40
- *
41
- * @example
42
- * ```ts
43
- * isWhitespace(' ') // true
44
- * isWhitespace('a') // false
45
- * ```
46
- */
47
- function isWhitespace(character) {
48
- return character === " " || character === " " || character === "\n";
49
- }
50
- /**
51
- * Whether `character` is escapable by a leading backslash - the ASCII punctuation
52
- * markdown gives meaning to (so `\*` becomes `*` but `\.` stays `\.`).
53
- *
54
- * @param character - The single character after a backslash
55
- * @returns `true` when a backslash before it is an escape
56
- *
57
- * @example
58
- * ```ts
59
- * isEscapable('*') // true
60
- * isEscapable('a') // false
61
- * ```
62
- */
63
- function isEscapable(character) {
64
- return /[\\`*_{}[\]()#+\-.!>~|]/.test(character);
65
- }
66
- /**
67
- * Whether `line` is blank - empty, or containing only whitespace - the markdown
68
- * definition of a blank line that block parsing uses to separate paragraphs, skip
69
- * gaps, and end list continuations.
70
- *
71
- * @param line - The candidate line
72
- * @returns `true` when the line is blank
73
- *
74
- * @example
75
- * ```ts
76
- * isBlankLine(' ') // true
77
- * ```
78
- */
79
- function isBlankLine(line) {
80
- return (0, _orkestrel_contract.isEmptyString)(line.trim());
81
- }
82
- /**
83
- * Whether `line` is a blockquote line (`>` optionally indented up to three spaces) -
84
- * its content is de-quoted by {@link stripQuote}.
85
- *
86
- * @param line - The candidate line
87
- * @returns `true` when the line begins a blockquote
88
- *
89
- * @example
90
- * ```ts
91
- * isQuote('> quoted') // true
92
- * ```
93
- */
94
- function isQuote(line) {
95
- return /^\s{0,3}>/.test(line);
96
- }
97
- /**
98
- * Whether `line` closes a fence opened by `marker` - the same fence character, a run
99
- * at least as long, and nothing else but surrounding whitespace.
100
- *
101
- * @param line - The candidate closing line
102
- * @param marker - The opening fence's marker run (from {@link extractFence})
103
- * @returns `true` when `line` closes the fence
104
- *
105
- * @example
106
- * ```ts
107
- * isFenceClose('```', '```') // true
108
- * ```
109
- */
110
- function isFenceClose(line, marker) {
111
- const character = marker[0] === "~" ? "~" : "`";
112
- let index = 0;
113
- while (index < line.length && isFenceWhitespace(line[index])) index++;
114
- let run = 0;
115
- while (index < line.length && line[index] === character) {
116
- run++;
117
- index++;
118
- }
119
- if (run < marker.length) return false;
120
- while (index < line.length && isFenceWhitespace(line[index])) index++;
121
- return index === line.length;
122
- }
123
- /**
124
- * Whether `character` is a regex-`\s`-equivalent whitespace character - the
125
- * character class {@link isFenceClose}'s scan treats as surrounding padding.
126
- *
127
- * @param character - The single character to test, or `undefined` past the end of a line
128
- * @returns `true` when it is whitespace
37
+ * @param node - The AST node to test
38
+ * @returns True if the node is a {@link HeadingNode}; false otherwise
129
39
  *
130
40
  * @example
131
41
  * ```ts
132
- * isFenceWhitespace(' ') // true
133
- * isFenceWhitespace(undefined) // false
42
+ * isHeadingNode({ element: 'heading', level: 1, children: [] }) // true
134
43
  * ```
135
44
  */
136
- function isFenceWhitespace(character) {
137
- return character === " " || character === " " || character === "\n" || character === "\r" || character === "\f" || character === "\v";
45
+ function isHeadingNode(node) {
46
+ return node.element === "heading";
138
47
  }
139
48
  /**
140
- * Whether `line` is a thematic break (horizontal rule) - three or more of the SAME
141
- * marker `-`, `*`, or `_` (optionally space-separated) and nothing else (`---`,
142
- * `***`, `___`, `- - -`).
49
+ * Determines whether a node is a paragraph block.
143
50
  *
144
- * @param line - The candidate line
145
- * @returns `true` when the line is a thematic break
51
+ * @param node - The AST node to test
52
+ * @returns True if the node is a {@link ParagraphNode}; false otherwise
146
53
  *
147
54
  * @example
148
55
  * ```ts
149
- * isThematicBreak('---') // true
56
+ * isParagraphNode({ element: 'paragraph', children: [] }) // true
150
57
  * ```
151
58
  */
152
- function isThematicBreak(line) {
153
- const stripped = line.trim().replace(/\s+/g, "");
154
- if (stripped.length < 3) return false;
155
- const marker = stripped[0];
156
- if (marker !== "-" && marker !== "*" && marker !== "_") return false;
157
- return [...stripped].every((character) => character === marker);
59
+ function isParagraphNode(node) {
60
+ return node.element === "paragraph";
158
61
  }
159
62
  /**
160
- * Whether the pair (`header`, `delimiter`) opens a GFM table - `delimiter` is a row of
161
- * `|`-separated cells each matching `:?-+:?`, the GFM rule that a table requires a
162
- * header row IMMEDIATELY followed by a delimiter row.
63
+ * Determines whether a node is a list block.
163
64
  *
164
- * @param header - The candidate header line
165
- * @param delimiter - The line after it (the candidate delimiter)
166
- * @returns `true` when the two lines open a table
65
+ * @param node - The AST node to test
66
+ * @returns True if the node is a {@link ListNode}; false otherwise
167
67
  *
168
68
  * @example
169
69
  * ```ts
170
- * isTableStart('| a |', '| - |') // true
70
+ * isListNode({ element: 'list', ordered: false, start: 1, items: [] }) // true
171
71
  * ```
172
72
  */
173
- function isTableStart(header, delimiter) {
174
- if (delimiter === void 0 || !header.includes("|")) return false;
175
- const cells = splitTableRow(delimiter);
176
- if (cells.length === 0) return false;
177
- return cells.every((cell) => /^:?-+:?$/.test(cell.trim()));
178
- }
179
- /** Determine whether a node is a heading block. */
180
- function isHeadingNode(node) {
181
- return node.element === "heading";
73
+ function isListNode(node) {
74
+ return node.element === "list";
182
75
  }
183
76
  /**
184
- * Determine whether a node is a paragraph block.
77
+ * Determines whether a node is a GFM table block.
185
78
  *
186
- * @example
187
- * ```ts
188
- * isParagraphNode({ element: 'paragraph', children: [] }) // true
189
- * ```
190
- */
191
- function isParagraphNode(node) {
192
- return node.element === "paragraph";
193
- }
194
- /**
195
- * Determine whether a node is a list block.
79
+ * @param node - The AST node to test
80
+ * @returns True if the node is a {@link TableNode}; false otherwise
196
81
  *
197
82
  * @example
198
83
  * ```ts
199
- * isListNode({ element: 'list', ordered: false, start: 1, items: [] }) // true
84
+ * isTableNode({ element: 'table', header: [], rows: [], align: [] }) // true
200
85
  * ```
201
86
  */
202
- function isListNode(node) {
203
- return node.element === "list";
204
- }
205
- /** Determine whether a node is a GFM table block. */
206
87
  function isTableNode(node) {
207
88
  return node.element === "table";
208
89
  }
209
90
  /**
210
- * Determine whether a node is a fenced code block.
91
+ * Determines whether a node is a fenced code block.
92
+ *
93
+ * @param node - The AST node to test
94
+ * @returns True if the node is a {@link CodeBlockNode}; false otherwise
211
95
  *
212
96
  * @example
213
97
  * ```ts
@@ -218,7 +102,10 @@ function isCodeBlockNode(node) {
218
102
  return node.element === "codeBlock";
219
103
  }
220
104
  /**
221
- * Determine whether a node is a blockquote block.
105
+ * Determines whether a node is a blockquote block.
106
+ *
107
+ * @param node - The AST node to test
108
+ * @returns True if the node is a {@link BlockquoteNode}; false otherwise
222
109
  *
223
110
  * @example
224
111
  * ```ts
@@ -229,7 +116,10 @@ function isBlockquoteNode(node) {
229
116
  return node.element === "blockquote";
230
117
  }
231
118
  /**
232
- * Determine whether a node is a thematic break (horizontal rule) block.
119
+ * Determines whether a node is a thematic break (horizontal rule) block.
120
+ *
121
+ * @param node - The AST node to test
122
+ * @returns True if the node is a {@link ThematicBreakNode}; false otherwise
233
123
  *
234
124
  * @example
235
125
  * ```ts
@@ -240,7 +130,10 @@ function isThematicBreakNode(node) {
240
130
  return node.element === "thematicBreak";
241
131
  }
242
132
  /**
243
- * Determine whether a node is a plain text run.
133
+ * Determines whether a node is a plain text run.
134
+ *
135
+ * @param node - The AST node to test
136
+ * @returns True if the node is a {@link TextNode}; false otherwise
244
137
  *
245
138
  * @example
246
139
  * ```ts
@@ -251,7 +144,10 @@ function isTextNode(node) {
251
144
  return node.element === "text";
252
145
  }
253
146
  /**
254
- * Determine whether a node is an emphasis run (`*em*` / `**strong**`).
147
+ * Determines whether a node is an emphasis run (`*em*` / `**strong**`).
148
+ *
149
+ * @param node - The AST node to test
150
+ * @returns True if the node is an {@link EmphasisNode}; false otherwise
255
151
  *
256
152
  * @example
257
153
  * ```ts
@@ -262,12 +158,15 @@ function isEmphasisNode(node) {
262
158
  return node.element === "emphasis";
263
159
  }
264
160
  /**
265
- * Determine whether a node is an inline code span.
161
+ * Determines whether a node is an inline code span.
266
162
  *
267
163
  * @remarks
268
- * Narrows to {@link CodeSpanNode} - the node whose `element` discriminant is
164
+ * Narrows to {@link CodeSpanNode} the node whose `element` discriminant is
269
165
  * `'codeSpan'`.
270
166
  *
167
+ * @param node - The AST node to test
168
+ * @returns True if the node is a {@link CodeSpanNode}; false otherwise
169
+ *
271
170
  * @example
272
171
  * ```ts
273
172
  * isCodeSpanNode({ element: 'codeSpan', value: 'x' }) // true
@@ -277,7 +176,10 @@ function isCodeSpanNode(node) {
277
176
  return node.element === "codeSpan";
278
177
  }
279
178
  /**
280
- * Determine whether a node is a GFM hard line break.
179
+ * Determines whether a node is a GFM hard line break.
180
+ *
181
+ * @param node - The AST node to test
182
+ * @returns True if the node is a {@link LineBreakNode}; false otherwise
281
183
  *
282
184
  * @example
283
185
  * ```ts
@@ -287,12 +189,25 @@ function isCodeSpanNode(node) {
287
189
  function isLineBreakNode(node) {
288
190
  return node.element === "break";
289
191
  }
290
- /** Determine whether a node is a link. */
192
+ /**
193
+ * Determines whether a node is a link.
194
+ *
195
+ * @param node - The AST node to test
196
+ * @returns True if the node is a {@link LinkNode}; false otherwise
197
+ *
198
+ * @example
199
+ * ```ts
200
+ * isLinkNode({ element: 'link', href: 'https://example.dev', children: [] }) // true
201
+ * ```
202
+ */
291
203
  function isLinkNode(node) {
292
204
  return node.element === "link";
293
205
  }
294
206
  /**
295
- * Determine whether a node is an image.
207
+ * Determines whether a node is an image.
208
+ *
209
+ * @param node - The AST node to test
210
+ * @returns True if the node is an {@link ImageNode}; false otherwise
296
211
  *
297
212
  * @example
298
213
  * ```ts
@@ -303,16 +218,16 @@ function isImageNode(node) {
303
218
  return node.element === "image";
304
219
  }
305
220
  /**
306
- * Determine whether an arbitrary value is a valid {@link InlineNode} - a text
221
+ * Determines whether an arbitrary value is a valid {@link InlineNode} a text
307
222
  * run, emphasis, code span, hard break, link, or image, recursively validated.
308
223
  *
309
224
  * @remarks
310
- * Total: never throws, even on cyclic or pathologically deep input - every
225
+ * Total: never throws, even on cyclic or pathologically deep input every
311
226
  * combinator involved (`unionOf`, `recordOf`, `arrayOf`, `lazyOf`) is
312
- * throw-contained per the `@orkestrel/contract` guard contract (AGENTS §14).
227
+ * throw-contained per the `@orkestrel/contract` guard contract.
313
228
  *
314
229
  * @param value - The value to test
315
- * @returns `true` when `value` is a well-formed {@link InlineNode}
230
+ * @returns True if `value` is a well-formed {@link InlineNode}; false otherwise
316
231
  *
317
232
  * @example
318
233
  * ```ts
@@ -342,19 +257,19 @@ var isInlineNode = (0, _orkestrel_contract.unionOf)((0, _orkestrel_contract.reco
342
257
  children: (0, _orkestrel_contract.arrayOf)((0, _orkestrel_contract.lazyOf)(() => isInlineNode))
343
258
  }));
344
259
  /**
345
- * Determine whether an arbitrary value is a valid {@link BlockNode} - a
260
+ * Determines whether an arbitrary value is a valid {@link BlockNode} a
346
261
  * heading, paragraph, list, table, code block, blockquote, or thematic break,
347
262
  * recursively validated.
348
263
  *
349
264
  * @remarks
350
- * Total: never throws, even on cyclic or pathologically deep input - every
265
+ * Total: never throws, even on cyclic or pathologically deep input every
351
266
  * combinator involved (`unionOf`, `recordOf`, `arrayOf`, `lazyOf`) is
352
- * throw-contained per the `@orkestrel/contract` guard contract (AGENTS §14).
267
+ * throw-contained per the `@orkestrel/contract` guard contract.
353
268
  * A list item's shape is inlined here (and in {@link isMarkdownNode}) rather
354
- * than named separately - it is used at exactly these two sites.
269
+ * than named separately it is used at exactly these two sites.
355
270
  *
356
271
  * @param value - The value to test
357
- * @returns `true` when `value` is a well-formed {@link BlockNode}
272
+ * @returns True if `value` is a well-formed {@link BlockNode}; false otherwise
358
273
  *
359
274
  * @example
360
275
  * ```ts
@@ -393,19 +308,19 @@ var isBlockNode = (0, _orkestrel_contract.unionOf)((0, _orkestrel_contract.recor
393
308
  children: (0, _orkestrel_contract.arrayOf)((0, _orkestrel_contract.lazyOf)(() => isBlockNode))
394
309
  }), (0, _orkestrel_contract.recordOf)({ element: (0, _orkestrel_contract.literalOf)("thematicBreak") }));
395
310
  /**
396
- * Determine whether an arbitrary value is a valid {@link MarkdownNode} - the
311
+ * Determines whether an arbitrary value is a valid {@link MarkdownNode} the
397
312
  * {@link MarkdownDocument} root, a {@link BlockNode}, a {@link ListItemNode}, or
398
313
  * an {@link InlineNode}, recursively validated.
399
314
  *
400
315
  * @remarks
401
- * Total: never throws, even on cyclic or pathologically deep input - every
316
+ * Total: never throws, even on cyclic or pathologically deep input every
402
317
  * combinator involved (`unionOf`, `recordOf`, `arrayOf`, `lazyOf`) is
403
- * throw-contained per the `@orkestrel/contract` guard contract (AGENTS §14).
318
+ * throw-contained per the `@orkestrel/contract` guard contract.
404
319
  * A list item's shape is inlined here (and in {@link isBlockNode}) rather than
405
- * named separately - it is used at exactly these two sites.
320
+ * named separately it is used at exactly these two sites.
406
321
  *
407
322
  * @param value - The value to test
408
- * @returns `true` when `value` is a well-formed {@link MarkdownNode}
323
+ * @returns True if `value` is a well-formed {@link MarkdownNode}; false otherwise
409
324
  *
410
325
  * @example
411
326
  * ```ts
@@ -420,17 +335,17 @@ var isMarkdownNode = (0, _orkestrel_contract.unionOf)((0, _orkestrel_contract.la
420
335
  children: (0, _orkestrel_contract.arrayOf)((0, _orkestrel_contract.lazyOf)(() => isBlockNode))
421
336
  }), (0, _orkestrel_contract.lazyOf)(() => isInlineNode));
422
337
  /**
423
- * Determine whether an arbitrary value is a valid {@link MarkdownDocument} -
338
+ * Determines whether an arbitrary value is a valid {@link MarkdownDocument}
424
339
  * the parsed-AST root {@link parseDocument} returns, recursively
425
340
  * validated.
426
341
  *
427
342
  * @remarks
428
- * Total: never throws, even on cyclic or pathologically deep input - every
343
+ * Total: never throws, even on cyclic or pathologically deep input every
429
344
  * combinator involved (`recordOf`, `arrayOf`) is throw-contained per the
430
- * `@orkestrel/contract` guard contract (AGENTS §14).
345
+ * `@orkestrel/contract` guard contract.
431
346
  *
432
347
  * @param value - The value to test
433
- * @returns `true` when `value` is a well-formed {@link MarkdownDocument}
348
+ * @returns True if `value` is a well-formed {@link MarkdownDocument}; false otherwise
434
349
  *
435
350
  * @example
436
351
  * ```ts
@@ -596,21 +511,34 @@ function parseBlocks(lines, depth, spans = /* @__PURE__ */ new Map(), end) {
596
511
  return blocks;
597
512
  }
598
513
  /**
599
- * Parses a markdown string into a typed {@link MarkdownDocument} AST via the
600
- * block phase.
514
+ * Parses a markdown string into a typed {@link MarkdownDocument} AST through the
515
+ * block phase — the document half of what {@link parseProvenance} returns. Malformed
516
+ * markdown degrades to literal text, so the parse never throws.
601
517
  *
602
518
  * @param markdown - The markdown source to parse.
603
519
  * @returns The parsed document.
520
+ *
521
+ * @example
522
+ * ```ts
523
+ * parseDocument('# Hi') // { element: 'document', children: [{ element: 'heading', ... }] }
524
+ * ```
604
525
  */
605
526
  function parseDocument(markdown) {
606
527
  const [document] = parseProvenance(markdown);
607
528
  return document;
608
529
  }
609
530
  /**
610
- * Parses a markdown string into a document and its original-source spans.
531
+ * Parses a markdown string into a document and its original-source spans. Malformed
532
+ * markdown degrades to literal text, so the parse never throws.
611
533
  *
612
534
  * @param markdown - The markdown source to parse.
613
535
  * @returns The parsed document and its node-identity span map.
536
+ *
537
+ * @example
538
+ * ```ts
539
+ * const [document, spans] = parseProvenance('# Hi')
540
+ * spans.get(document) // { start: 0, end: 4 }
541
+ * ```
614
542
  */
615
543
  function parseProvenance(markdown) {
616
544
  const spans = /* @__PURE__ */ new Map();
@@ -626,718 +554,411 @@ function parseProvenance(markdown) {
626
554
  }
627
555
  /**
628
556
  * Parses inline markdown text (emphasis, code spans, links, images, and hard
629
- * breaks) into inline AST nodes, coalescing adjacent text runs.
557
+ * breaks) into inline AST nodes, coalescing adjacent text runs and reading no block
558
+ * structure. Malformed markdown degrades to literal text, so the parse never throws.
630
559
  *
631
560
  * @param text - The inline markdown text to parse.
632
561
  * @returns The parsed inline nodes.
562
+ *
563
+ * @example
564
+ * ```ts
565
+ * parseInline('a *b*') // [{ element: 'text', value: 'a ' }, { element: 'emphasis', ... }]
566
+ * ```
633
567
  */
634
568
  function parseInline(text) {
635
569
  return coalesceText(scanInline(text, 0, text.length));
636
570
  }
637
571
  //#endregion
638
- //#region src/core/Markdown.ts
572
+ //#region src/core/helpers.ts
639
573
  /**
640
- * A stateful, parsed markdown document - wraps a typed {@link MarkdownDocument} AST
641
- * with the query (`find` / `filter` / `reduce` / iteration), rewrite (`map`), fold, and
642
- * streaming operations {@link MarkdownInterface} declares.
574
+ * Splits a markdown document into offset-bearing lines while normalizing CRLF and
575
+ * bare CR terminators at the line boundary. A single trailing terminator does not
576
+ * yield a final empty line.
643
577
  *
644
- * @remarks
645
- * - **Construction.** Given a `string`, the constructor runs {@link parseProvenance} (the
646
- * block phase then the inline phase) once, keeping the AST and a COPY of the span map
647
- * that parse recorded. Given a {@link MarkdownDocument}, the document is adopted AS-IS
648
- * and is NOT re-validated - gate an untrusted value with `isMarkdownDocument` first.
649
- * - **Provenance.** {@link span} reads the region of the ORIGINAL constructor string a
650
- * node was produced from, and it is handle-relative: a string-constructed handle exposes
651
- * the regions of the nodes it parsed, an adopted document exposes none, and a node from
652
- * another handle reports `undefined` here whatever that handle reports. Each call
653
- * returns a fresh value. A node reports the region THIS handle holds for its identity,
654
- * else the region of the direct input a rewrite named for it, else `undefined`: a text
655
- * run the parse joined from adjacent scanner output reports the region enclosing its
656
- * parts, and only a rewrite output that holds no region of its own and was assembled
657
- * from separate source nodes reports `undefined`.
658
- * {@link map} carries provenance across the rewrite: an unchanged node keeps its
659
- * region, a one-source replacement takes the region of the node it replaced, and a
660
- * rebuilt parent takes its original's.
661
- * - **Immutable.** {@link map} never mutates the stored AST - it returns a NEW `Markdown`
662
- * instance; the document root invariant (`element: 'document'`) always holds. An
663
- * identity rewrite still returns a new handle, over the same document tree.
664
- * - **Traversal order.** {@link walk} and the `find` / `filter` / `reduce` queries built
665
- * on it walk the AST depth-first, pre-order, root-inclusive (via {@link walkNodes});
666
- * `stream` is shallow - only the document's direct block children.
578
+ * @param markdown - The raw markdown source
579
+ * @returns The document's lines with their original-string coordinates
667
580
  *
668
581
  * @example
669
582
  * ```ts
670
- * import { Markdown, isHeadingNode, renderMarkdown } from '@src/core'
671
- *
672
- * const markdown = new Markdown('# Title\n\nA **bold** [link](https://x.dev).')
673
- * const heading = markdown.find(isHeadingNode) // the HeadingNode, or undefined
674
- * const shouted = markdown.map((node) =>
675
- * node.element === 'text' ? { element: 'text', value: node.value.toUpperCase() } : node,
676
- * )
677
- * renderMarkdown(shouted.document) // '# TITLE\n\nA **BOLD** [LINK](https://x.dev).'
583
+ * splitLines('a\r\nb') // [{ text: 'a', segments: [{ offset: 0, start: 0, end: 1 }] }, ...]
678
584
  * ```
679
585
  */
680
- var Markdown = class Markdown {
681
- #document;
682
- #spans;
683
- constructor(input) {
684
- if (typeof input === "string") {
685
- const [document, spans] = parseProvenance(input);
686
- this.#document = document;
687
- this.#spans = new Map(spans);
688
- } else {
689
- this.#document = input;
690
- this.#spans = /* @__PURE__ */ new Map();
586
+ function splitLines(markdown) {
587
+ const lines = [];
588
+ let start = 0;
589
+ let index = 0;
590
+ while (index < markdown.length) {
591
+ const character = markdown[index];
592
+ if (character !== "\r" && character !== "\n") {
593
+ index += 1;
594
+ continue;
691
595
  }
596
+ lines.push({
597
+ text: markdown.slice(start, index),
598
+ segments: [{
599
+ offset: 0,
600
+ start,
601
+ end: index
602
+ }]
603
+ });
604
+ index += character === "\r" && markdown[index + 1] === "\n" ? 2 : 1;
605
+ start = index;
692
606
  }
693
- /** The stored {@link MarkdownDocument} AST root. */
694
- get document() {
695
- return this.#document;
696
- }
697
- /**
698
- * Reads the region of the original markdown string a node of this handle's tree was
699
- * produced from.
700
- *
701
- * @param node - The node whose provenance to read
702
- * @returns A fresh {@link MarkdownSpan}, or `undefined` when this handle holds no
703
- * region for the node
704
- *
705
- * @example
706
- * ```ts
707
- * const source = '# Title\n\npara'
708
- * const markdown = new Markdown(source)
709
- * const heading = markdown.find(isHeadingNode)
710
- * const span = heading && markdown.span(heading)
711
- * span && source.slice(span.start, span.end) // '# Title'
712
- * ```
713
- */
714
- span(node) {
715
- const span = this.#spans.get(node);
716
- return span === void 0 ? void 0 : {
717
- start: span.start,
718
- end: span.end
719
- };
720
- }
721
- /**
722
- * THE deep traversal - a lazy, depth-first, pre-order, root-inclusive generator
723
- * over every {@link MarkdownNode} in the document. `find` / `filter` / `reduce`
724
- * all iterate this single traversal.
725
- *
726
- * @example
727
- * ```ts
728
- * for (const node of markdown.walk()) {
729
- * // every node, depth-first, pre-order, root-inclusive
730
- * }
731
- *
732
- * // also consumable by for-await - JS accepts a sync iterable in for-await
733
- * for await (const node of markdown.walk()) {
734
- * // same sequence, no separate async iterator needed
735
- * }
736
- * ```
737
- */
738
- *walk() {
739
- yield* walkNodes(this.#document);
740
- }
741
- find(predicate) {
742
- for (const node of this.walk()) if (predicate(node)) return node;
743
- }
744
- filter(predicate) {
745
- const out = [];
746
- for (const node of this.walk()) if (predicate(node)) out.push(node);
747
- return out;
748
- }
749
- /**
750
- * Rewrites the AST bottom-up (copy-on-write) and returns a new {@link Markdown},
751
- * carrying each output node's provenance across the rewrite. A rewrite that returns
752
- * its node unchanged shares that subtree instead of copying it, so an identity
753
- * rewrite copies no node and still returns a new handle.
754
- *
755
- * @param rewrite - The bottom-up node rewrite
756
- * @returns A new handle over the rewritten document
757
- */
758
- map(rewrite) {
759
- const [document, derivations] = rewriteDocument(this.#document, rewrite);
760
- return this.#derive(document, derivations);
761
- }
762
- /** Folds the AST depth-first, pre-order into an accumulator. */
763
- reduce(callback, initial) {
764
- let accumulator = initial;
765
- for (const node of this.walk()) accumulator = callback(accumulator, node);
766
- return accumulator;
767
- }
768
- /** Runs a total catamorphism over the document using a {@link MarkdownHandlers} table. */
769
- fold(handlers) {
770
- return foldNode(this.#document, handlers, 0);
771
- }
772
- /**
773
- * A web-standard {@link ReadableStream} over the document's top-level block nodes
774
- * (shallow, source order) - a fresh, pull-based source per call: one block is
775
- * enqueued per `pull`, so a slow reader's backpressure is respected. Cancellable,
776
- * async-iterable wherever the platform supports it (Node, Deno), and pipeable
777
- * through any {@link TransformStream} / {@link WritableStream}.
778
- *
779
- * @example
780
- * ```ts
781
- * // universal - works in every ReadableStream-supporting environment
782
- * const reader = markdown.stream().getReader()
783
- * for (let result = await reader.read(); !result.done; result = await reader.read()) {
784
- * console.log(result.value) // one BlockNode
785
- * }
786
- *
787
- * // Node / Deno / Firefox support async iteration of ReadableStream natively;
788
- * // other environments should use the reader loop above instead.
789
- * for await (const block of markdown.stream()) {
790
- * console.log(block)
791
- * }
792
- * ```
793
- */
794
- stream() {
795
- const blocks = this.#document.children;
796
- let index = 0;
797
- return new ReadableStream({ pull(controller) {
798
- if (index < blocks.length) {
799
- const block = blocks[index];
800
- if (block === void 0) {
801
- controller.close();
802
- return;
803
- }
804
- controller.enqueue(block);
805
- index += 1;
806
- } else controller.close();
807
- } });
808
- }
809
- #derive(document, derivations) {
810
- const derived = new Markdown(document);
811
- for (const node of walkNodes(document)) {
812
- const own = this.#spans.get(node);
813
- if (own !== void 0) {
814
- derived.#spans.set(node, own);
815
- continue;
816
- }
817
- const source = derivations.get(node);
818
- if (source === void 0) continue;
819
- const span = this.#spans.get(source);
820
- if (span !== void 0) derived.#spans.set(node, span);
821
- }
822
- return derived;
823
- }
824
- };
825
- //#endregion
826
- //#region src/core/shapers.ts
607
+ lines.push({
608
+ text: markdown.slice(start),
609
+ segments: [{
610
+ offset: 0,
611
+ start,
612
+ end: markdown.length
613
+ }]
614
+ });
615
+ if (lines.length > 1 && lines[lines.length - 1]?.text === "") lines.pop();
616
+ return lines;
617
+ }
827
618
  /**
828
- * The shape of a {@link TextNode} - a plain-text leaf inline run.
619
+ * Slices derived markdown text and narrows each intersecting source segment to the
620
+ * same text-relative range.
621
+ *
622
+ * @param source - The offset-bearing source to slice
623
+ * @param from - The inclusive text offset
624
+ * @param to - The exclusive text offset
625
+ * @returns The sliced text and its narrowed original-string segments
829
626
  *
830
627
  * @example
831
628
  * ```ts
832
- * import { createContract } from '@orkestrel/contract'
833
- * import { textShape } from '@src/core'
834
- *
835
- * const text = createContract(textShape)
836
- * text.is({ element: 'text', value: 'hi' }) // true
629
+ * sliceSource({ text: 'abc', segments: [{ offset: 0, start: 4, end: 7 }] }, 1, 3)
630
+ * // { text: 'bc', segments: [{ offset: 0, start: 5, end: 7 }] }
837
631
  * ```
838
632
  */
839
- var textShape = (0, _orkestrel_contract.objectShape)({
840
- element: (0, _orkestrel_contract.literalShape)(["text"]),
841
- value: (0, _orkestrel_contract.stringShape)()
842
- });
633
+ function sliceSource(source, from, to) {
634
+ const start = Math.max(0, Math.min(from, source.text.length));
635
+ const end = Math.max(start, Math.min(to, source.text.length));
636
+ const segments = [];
637
+ for (let index = 0; index < source.segments.length; index += 1) {
638
+ const segment = source.segments[index];
639
+ if (segment === void 0) continue;
640
+ const next = source.segments[index + 1];
641
+ const limit = Math.min(segment.offset + (segment.end - segment.start), next === void 0 ? source.text.length : next.offset);
642
+ const overlapStart = Math.max(start, segment.offset);
643
+ const overlapEnd = Math.min(end, limit);
644
+ const empty = segment.offset === limit && overlapStart === segment.offset;
645
+ if (overlapStart >= overlapEnd && !empty) continue;
646
+ const originalStart = overlapStart === limit ? segment.end : Math.min(segment.end, segment.start + overlapStart - segment.offset);
647
+ const originalEnd = overlapEnd === limit ? segment.end : Math.min(segment.end, segment.start + overlapEnd - segment.offset);
648
+ segments.push({
649
+ offset: overlapStart - start,
650
+ start: originalStart,
651
+ end: originalEnd
652
+ });
653
+ }
654
+ return {
655
+ text: source.text.slice(start, end),
656
+ segments
657
+ };
658
+ }
843
659
  /**
844
- * The shape of a {@link CodeSpanNode} - an inline code span (`` `code` ``).
660
+ * Joins offset-bearing markdown sources while mapping a separator to the original
661
+ * region between adjacent mapped sources.
662
+ *
663
+ * @param sources - The sources to join
664
+ * @param separator - The derived text inserted between sources
665
+ * @returns The joined text and every source-backed segment
845
666
  *
846
667
  * @example
847
668
  * ```ts
848
- * import { createContract } from '@orkestrel/contract'
849
- * import { codeSpanShape } from '@src/core'
850
- *
851
- * const codeSpan = createContract(codeSpanShape)
852
- * codeSpan.is({ element: 'codeSpan', value: 'const x = 1' }) // true
669
+ * joinSources(splitLines('a\nb'), '\n')
670
+ * // { text: 'a\nb', segments: [...] }
853
671
  * ```
854
672
  */
855
- var codeSpanShape = (0, _orkestrel_contract.objectShape)({
856
- element: (0, _orkestrel_contract.literalShape)(["codeSpan"]),
857
- value: (0, _orkestrel_contract.stringShape)()
858
- });
673
+ function joinSources(sources, separator) {
674
+ let text = "";
675
+ const segments = [];
676
+ for (let index = 0; index < sources.length; index += 1) {
677
+ const source = sources[index];
678
+ if (source === void 0) continue;
679
+ if (index > 0) {
680
+ const previous = sources[index - 1];
681
+ const left = previous?.segments[previous.segments.length - 1];
682
+ const right = source.segments[0];
683
+ if (separator.length > 0 && left !== void 0 && right !== void 0 && left.end < right.start) segments.push({
684
+ offset: text.length,
685
+ start: left.end,
686
+ end: right.start
687
+ });
688
+ text += separator;
689
+ }
690
+ for (const segment of source.segments) segments.push({
691
+ offset: text.length + segment.offset,
692
+ start: segment.start,
693
+ end: segment.end
694
+ });
695
+ text += source.text;
696
+ }
697
+ return {
698
+ text,
699
+ segments
700
+ };
701
+ }
859
702
  /**
860
- * The shape of a {@link LineBreakNode} - a GFM hard line-break leaf.
703
+ * Projects a derived text range through its segments to a half-open region of the
704
+ * original markdown string.
705
+ *
706
+ * @param source - The offset-bearing source carrying the range
707
+ * @param from - The inclusive derived-text boundary
708
+ * @param to - The exclusive derived-text boundary
709
+ * @returns The original-string span, or `undefined` when either boundary is unmapped
861
710
  *
862
711
  * @example
863
712
  * ```ts
864
- * import { createContract } from '@orkestrel/contract'
865
- * import { lineBreakShape } from '@src/core'
866
- *
867
- * const lineBreak = createContract(lineBreakShape)
868
- * lineBreak.is({ element: 'break' }) // true
713
+ * projectSpan({ text: 'a', segments: [{ offset: 0, start: 4, end: 5 }] }, 0, 1)
714
+ * // { start: 4, end: 5 }
869
715
  * ```
870
716
  */
871
- var lineBreakShape = (0, _orkestrel_contract.objectShape)({ element: (0, _orkestrel_contract.literalShape)(["break"]) });
717
+ function projectSpan(source, from, to) {
718
+ if (from < 0 || to < from || to > source.text.length) return void 0;
719
+ let start;
720
+ let end;
721
+ for (let index = 0; index < source.segments.length; index += 1) {
722
+ const segment = source.segments[index];
723
+ if (segment === void 0) continue;
724
+ const next = source.segments[index + 1];
725
+ const limit = Math.min(segment.offset + (segment.end - segment.start), next === void 0 ? source.text.length : next.offset);
726
+ if (from === to && from >= segment.offset && from <= limit) {
727
+ if (next !== void 0 && from === next.offset) continue;
728
+ const position = from === limit ? segment.end : Math.min(segment.end, segment.start + from - segment.offset);
729
+ return {
730
+ start: position,
731
+ end: position
732
+ };
733
+ }
734
+ if (start === void 0 && from >= segment.offset && from < limit) start = segment.start + from - segment.offset;
735
+ if (to > segment.offset && to <= limit) end = to === limit ? segment.end : Math.min(segment.end, segment.start + to - segment.offset);
736
+ }
737
+ return start === void 0 || end === void 0 ? void 0 : {
738
+ start,
739
+ end
740
+ };
741
+ }
872
742
  /**
873
- * The shape of a {@link CodeBlockNode} - a fenced code block. `lang` is
874
- * optional (absent when the opening fence carries no info-string).
743
+ * Trims an offset-bearing source without losing the coordinates of its retained text.
744
+ *
745
+ * @param source - The source to trim
746
+ * @returns The trimmed text and its narrowed original-string segments
875
747
  *
876
748
  * @example
877
749
  * ```ts
878
- * import { createContract } from '@orkestrel/contract'
879
- * import { codeBlockShape } from '@src/core'
880
- *
881
- * const codeBlock = createContract(codeBlockShape)
882
- * codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
883
- * codeBlock.is({ element: 'codeBlock', code: 'x', lang: 'ts' }) // true
750
+ * trimSource({ text: ' a ', segments: [{ offset: 0, start: 4, end: 7 }] })
751
+ * // { text: 'a', segments: [{ offset: 0, start: 5, end: 6 }] }
884
752
  * ```
885
753
  */
886
- var codeBlockShape = (0, _orkestrel_contract.objectShape)({
887
- element: (0, _orkestrel_contract.literalShape)(["codeBlock"]),
888
- lang: (0, _orkestrel_contract.optionalShape)((0, _orkestrel_contract.stringShape)()),
889
- code: (0, _orkestrel_contract.stringShape)()
890
- });
754
+ function trimSource(source) {
755
+ const start = source.text.length - source.text.trimStart().length;
756
+ const end = source.text.trimEnd().length;
757
+ return sliceSource(source, start, Math.max(start, end));
758
+ }
891
759
  /**
892
- * The shape of a {@link ThematicBreakNode} - a horizontal rule. Carries no
893
- * fields beyond its `element` discriminant.
760
+ * Normalizes one paragraph line while retaining the full source run consumed by a
761
+ * trailing-space hard break.
762
+ *
763
+ * @param source - The offset-bearing paragraph line
764
+ * @param breaks - If `true`, preserves a trailing run of at least two spaces as the
765
+ * scanner's two-space hard-break syntax; if `false`, trims the line normally
766
+ * @returns The normalized line and its original-string segments
894
767
  *
895
768
  * @example
896
769
  * ```ts
897
- * import { createContract } from '@orkestrel/contract'
898
- * import { thematicBreakShape } from '@src/core'
899
- *
900
- * const thematicBreak = createContract(thematicBreakShape)
901
- * thematicBreak.is({ element: 'thematicBreak' }) // true
770
+ * normalizeParagraphLine(splitLines('text \nnext')[0], true).text // 'text '
902
771
  * ```
903
772
  */
904
- var thematicBreakShape = (0, _orkestrel_contract.objectShape)({ element: (0, _orkestrel_contract.literalShape)(["thematicBreak"]) });
773
+ function normalizeParagraphLine(source, breaks) {
774
+ if (!breaks || !source.text.endsWith(" ")) return trimSource(source);
775
+ const contentEnd = source.text.trimEnd().length;
776
+ const content = trimSource(sliceSource(source, 0, contentEnd));
777
+ const span = projectSpan(source, contentEnd, source.text.length);
778
+ return joinSources([content, {
779
+ text: " ",
780
+ segments: span === void 0 ? [] : [{
781
+ offset: 0,
782
+ start: span.start,
783
+ end: span.end
784
+ }]
785
+ }], "");
786
+ }
905
787
  /**
906
- * The shape of a {@link TableAlign} - the per-column GFM table alignment
907
- * literal.
908
- *
909
- * @example
910
- * ```ts
911
- * import { createContract } from '@orkestrel/contract'
912
- * import { tableAlignShape } from '@src/core'
788
+ * Counts the leading space / tab characters on `line` (a tab counts as one) — the
789
+ * indent that decides whether a list item's continuation belongs to the item.
913
790
  *
914
- * const tableAlign = createContract(tableAlignShape)
915
- * tableAlign.is('left') // true
916
- * tableAlign.is('center') // true
917
- * tableAlign.is('top') // false
918
- * ```
919
- */
920
- var tableAlignShape = (0, _orkestrel_contract.literalShape)([
921
- "left",
922
- "right",
923
- "center"
924
- ]);
925
- /**
926
- * The shape of {@link ListItemMatch} - the parsed parts of a single list-item
927
- * line the block phase's list detector returns. Fully non-recursive (no
928
- * nested node fields), so every field shapes directly.
791
+ * @param line - The line to measure
792
+ * @returns The number of leading space / tab characters
929
793
  *
930
794
  * @example
931
795
  * ```ts
932
- * import { createContract } from '@orkestrel/contract'
933
- * import { listItemMatchShape } from '@src/core'
934
- *
935
- * const listItemParts = createContract(listItemMatchShape)
936
- * listItemParts.is({ ordered: false, start: 1, content: 'hi', indent: 0, marker: 2 }) // true
796
+ * countIndent(' text') // 2
937
797
  * ```
938
798
  */
939
- var listItemMatchShape = (0, _orkestrel_contract.objectShape)({
940
- ordered: (0, _orkestrel_contract.booleanShape)(),
941
- start: (0, _orkestrel_contract.integerShape)(),
942
- content: (0, _orkestrel_contract.stringShape)(),
943
- indent: (0, _orkestrel_contract.integerShape)(),
944
- marker: (0, _orkestrel_contract.integerShape)()
945
- });
946
- //#endregion
947
- //#region src/core/factories.ts
799
+ function countIndent(line) {
800
+ let count = 0;
801
+ for (const character of line) if (character === " " || character === " ") count += 1;
802
+ else break;
803
+ return count;
804
+ }
948
805
  /**
949
- * Create an HTML-to-markdown projection with absent fields defaulted from
950
- * {@link EMPTY_PROJECTION} and the block/inline exclusivity invariant enforced.
951
- *
952
- * @remarks
953
- * A block-bearing projection cannot also expose inline content. Callers may provide
954
- * both views, but `inlines` is flushed whenever `blocks` is non-empty.
806
+ * Checks whether `character` is whitespace under the emphasis flanking rule — a space, a
807
+ * tab, or a newline.
955
808
  *
956
- * @param parts - The projection fields to provide
957
- * @returns A complete invariant-preserving projection
809
+ * @param character - The character to test
810
+ * @returns True if the flanking rule counts it as whitespace; false otherwise
958
811
  *
959
812
  * @example
960
813
  * ```ts
961
- * createProjection({
962
- * blocks: [{ element: 'thematicBreak' }],
963
- * inlines: [{ element: 'text', value: 'discarded' }],
964
- * })
965
- * // { blocks: [{ element: 'thematicBreak' }], inlines: [], text: '', cells: [], rows: [] }
814
+ * isFlankingWhitespace(' ') // true
815
+ * isFlankingWhitespace('a') // false
966
816
  * ```
967
817
  */
968
- function createProjection(parts = {}) {
969
- const blocks = parts.blocks ?? EMPTY_PROJECTION.blocks;
970
- return {
971
- blocks,
972
- inlines: blocks.length === 0 ? parts.inlines ?? EMPTY_PROJECTION.inlines : [],
973
- text: parts.text ?? EMPTY_PROJECTION.text,
974
- cells: parts.cells ?? EMPTY_PROJECTION.cells,
975
- rows: parts.rows ?? EMPTY_PROJECTION.rows
976
- };
818
+ function isFlankingWhitespace(character) {
819
+ return character === " " || character === " " || character === "\n";
977
820
  }
978
821
  /**
979
- * Create a stateful markdown handle from a markdown string or an already-parsed
980
- * {@link MarkdownDocument} - a typed AST plus the query, rewrite, and fold operations
981
- * {@link MarkdownInterface} exposes.
982
- *
983
- * @remarks
984
- * Given a `string`, runs a block phase (headings / paragraphs / lists / GFM tables /
985
- * fenced code / blockquotes / thematic breaks) then an inline phase (emphasis /
986
- * inline code / links / images / hard breaks) to build a render-agnostic
987
- * {@link MarkdownDocument}. Given a
988
- * {@link MarkdownDocument}, adopts it AS-IS without re-validation - gate an untrusted
989
- * value with `isMarkdownDocument` first. Pure + total parse (malformed markdown
990
- * degrades to text, never throws) and zero-dependency - a hand-written scanner, no
991
- * regex-only structural parse, linear-time (no ReDoS).
822
+ * Checks whether `character` is escapable by a leading backslash the ASCII punctuation
823
+ * markdown gives meaning to (so `\*` becomes `*` but `\.` stays `\.`).
992
824
  *
993
- * @param input - A markdown string to parse, or an already-parsed {@link MarkdownDocument}
994
- * @returns A working {@link MarkdownInterface}
825
+ * @param character - The single character after a backslash
826
+ * @returns True if a backslash before it is an escape; false otherwise
995
827
  *
996
828
  * @example
997
829
  * ```ts
998
- * import { createMarkdown } from '@src/core'
999
- *
1000
- * const markdown = createMarkdown('# Hi\n\nRead the [guide](./guide.md).')
1001
- * markdown.document.children[0] // { element: 'heading', ... }
830
+ * isEscapable('*') // true
831
+ * isEscapable('a') // false
1002
832
  * ```
1003
833
  */
1004
- function createMarkdown(input) {
1005
- return new Markdown(input);
834
+ function isEscapable(character) {
835
+ return /[\\`*_{}[\]()#+\-.!>~|]/.test(character);
1006
836
  }
1007
837
  /**
1008
- * Compile the {@link textShape} into a {@link ContractInterface} for
1009
- * {@link TextNode} - a guard, coercing parser, JSON Schema, and seeded
1010
- * generator from one shape declaration (AGENTS §14).
838
+ * Checks whether `line` is blank empty, or containing only whitespace — the markdown
839
+ * definition of a blank line that block parsing uses to separate paragraphs, skip
840
+ * gaps, and end list continuations.
1011
841
  *
1012
- * @returns A `TextNode` contract bundling `schema` / `is` / `parse` / `generate`
842
+ * @param line - The candidate line
843
+ * @returns True if the line is blank; false otherwise
1013
844
  *
1014
845
  * @example
1015
846
  * ```ts
1016
- * import { createTextContract } from '@src/core'
1017
- *
1018
- * const text = createTextContract()
1019
- * text.is({ element: 'text', value: 'hi' }) // true
847
+ * isBlankLine(' ') // true
1020
848
  * ```
1021
849
  */
1022
- function createTextContract() {
1023
- return (0, _orkestrel_contract.createContract)(textShape);
850
+ function isBlankLine(line) {
851
+ return (0, _orkestrel_contract.isEmptyString)(line.trim());
1024
852
  }
1025
853
  /**
1026
- * Compile the {@link codeSpanShape} into a {@link ContractInterface} for
1027
- * {@link CodeSpanNode} - a guard, coercing parser, JSON Schema, and seeded
1028
- * generator from one shape declaration (AGENTS §14).
854
+ * Checks whether `line` is a blockquote line (`>` optionally indented up to three spaces) —
855
+ * its content is de-quoted by {@link stripQuote}.
1029
856
  *
1030
- * @returns A `CodeSpanNode` contract bundling `schema` / `is` / `parse` / `generate`
857
+ * @param line - The candidate line
858
+ * @returns True if the line begins a blockquote; false otherwise
1031
859
  *
1032
860
  * @example
1033
861
  * ```ts
1034
- * import { createCodeSpanContract } from '@src/core'
1035
- *
1036
- * const codeSpan = createCodeSpanContract()
1037
- * codeSpan.is({ element: 'codeSpan', value: 'const x = 1' }) // true
862
+ * isQuote('> quoted') // true
1038
863
  * ```
1039
864
  */
1040
- function createCodeSpanContract() {
1041
- return (0, _orkestrel_contract.createContract)(codeSpanShape);
865
+ function isQuote(line) {
866
+ return /^\s{0,3}>/.test(line);
1042
867
  }
1043
868
  /**
1044
- * Compile the {@link lineBreakShape} into a {@link ContractInterface} for
1045
- * {@link LineBreakNode}.
869
+ * Checks whether `line` closes a fence opened by `marker` — the same fence character, a run
870
+ * at least as long, and nothing else but surrounding whitespace.
1046
871
  *
1047
- * @returns A `LineBreakNode` contract bundling `schema` / `is` / `parse` / `generate`
872
+ * @param line - The candidate closing line
873
+ * @param marker - The opening fence's marker run (from {@link extractFence})
874
+ * @returns True if `line` closes the fence; false otherwise
1048
875
  *
1049
876
  * @example
1050
877
  * ```ts
1051
- * import { createLineBreakContract } from '@src/core'
1052
- *
1053
- * createLineBreakContract().is({ element: 'break' }) // true
878
+ * isFenceClose('```', '```') // true
1054
879
  * ```
1055
880
  */
1056
- function createLineBreakContract() {
1057
- return (0, _orkestrel_contract.createContract)(lineBreakShape);
881
+ function isFenceClose(line, marker) {
882
+ const character = marker[0] === "~" ? "~" : "`";
883
+ let index = 0;
884
+ while (index < line.length && isFenceWhitespace(line[index])) index++;
885
+ let run = 0;
886
+ while (index < line.length && line[index] === character) {
887
+ run++;
888
+ index++;
889
+ }
890
+ if (run < marker.length) return false;
891
+ while (index < line.length && isFenceWhitespace(line[index])) index++;
892
+ return index === line.length;
1058
893
  }
1059
894
  /**
1060
- * Compile the {@link codeBlockShape} into a {@link ContractInterface} for
1061
- * {@link CodeBlockNode} - a guard, coercing parser, JSON Schema, and seeded
1062
- * generator from one shape declaration (AGENTS §14).
895
+ * Checks whether `character` is a regex-`\s`-equivalent whitespace character — the
896
+ * character class {@link isFenceClose}'s scan treats as surrounding padding.
1063
897
  *
1064
- * @returns A `CodeBlockNode` contract bundling `schema` / `is` / `parse` / `generate`
898
+ * @param character - The single character to test, or `undefined` past the end of a line
899
+ * @returns True if it is whitespace; false otherwise
1065
900
  *
1066
901
  * @example
1067
902
  * ```ts
1068
- * import { createCodeBlockContract } from '@src/core'
1069
- *
1070
- * const codeBlock = createCodeBlockContract()
1071
- * codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
903
+ * isFenceWhitespace(' ') // true
904
+ * isFenceWhitespace(undefined) // false
1072
905
  * ```
1073
906
  */
1074
- function createCodeBlockContract() {
1075
- return (0, _orkestrel_contract.createContract)(codeBlockShape);
907
+ function isFenceWhitespace(character) {
908
+ return character === " " || character === " " || character === "\n" || character === "\r" || character === "\f" || character === "\v";
1076
909
  }
1077
910
  /**
1078
- * Compile the {@link thematicBreakShape} into a {@link ContractInterface} for
1079
- * {@link ThematicBreakNode} - a guard, coercing parser, JSON Schema, and
1080
- * seeded generator from one shape declaration (AGENTS §14).
911
+ * Checks whether `line` is a thematic break (horizontal rule) — three or more of the same
912
+ * marker `-`, `*`, or `_` (optionally space-separated) and nothing else (`---`,
913
+ * `***`, `___`, `- - -`).
1081
914
  *
1082
- * @returns A `ThematicBreakNode` contract bundling `schema` / `is` / `parse` / `generate`
915
+ * @param line - The candidate line
916
+ * @returns True if the line is a thematic break; false otherwise
1083
917
  *
1084
918
  * @example
1085
919
  * ```ts
1086
- * import { createThematicBreakContract } from '@src/core'
1087
- *
1088
- * const thematicBreak = createThematicBreakContract()
1089
- * thematicBreak.is({ element: 'thematicBreak' }) // true
920
+ * isThematicBreak('---') // true
1090
921
  * ```
1091
922
  */
1092
- function createThematicBreakContract() {
1093
- return (0, _orkestrel_contract.createContract)(thematicBreakShape);
923
+ function isThematicBreak(line) {
924
+ const stripped = line.trim().replace(/\s+/g, "");
925
+ if (stripped.length < 3) return false;
926
+ const marker = stripped[0];
927
+ if (marker !== "-" && marker !== "*" && marker !== "_") return false;
928
+ return [...stripped].every((character) => character === marker);
1094
929
  }
1095
- //#endregion
1096
- //#region src/core/helpers.ts
1097
930
  /**
1098
- * Splits a markdown document into offset-bearing lines while normalizing CRLF and
1099
- * bare CR terminators at the line boundary. A single trailing terminator does not
1100
- * yield a final empty line.
931
+ * Checks whether the pair (`header`, `delimiter`) opens a GFM table `delimiter` is a row of
932
+ * `|`-separated cells each matching `:?-+:?`, the GFM rule that a table requires a
933
+ * header row immediately followed by a delimiter row.
1101
934
  *
1102
- * @param markdown - The raw markdown source
1103
- * @returns The document's lines with their original-string coordinates
935
+ * @param header - The candidate header line
936
+ * @param delimiter - The line after it (the candidate delimiter)
937
+ * @returns True if the two lines open a table; false otherwise
1104
938
  *
1105
939
  * @example
1106
940
  * ```ts
1107
- * splitLines('a\r\nb') // [{ text: 'a', segments: [{ offset: 0, start: 0, end: 1 }] }, ...]
941
+ * isTableStart('| a |', '| - |') // true
1108
942
  * ```
1109
943
  */
1110
- function splitLines(markdown) {
1111
- const lines = [];
1112
- let start = 0;
1113
- let index = 0;
1114
- while (index < markdown.length) {
1115
- const character = markdown[index];
1116
- if (character !== "\r" && character !== "\n") {
1117
- index += 1;
1118
- continue;
1119
- }
1120
- lines.push({
1121
- text: markdown.slice(start, index),
1122
- segments: [{
1123
- offset: 0,
1124
- start,
1125
- end: index
1126
- }]
1127
- });
1128
- index += character === "\r" && markdown[index + 1] === "\n" ? 2 : 1;
1129
- start = index;
1130
- }
1131
- lines.push({
1132
- text: markdown.slice(start),
1133
- segments: [{
1134
- offset: 0,
1135
- start,
1136
- end: markdown.length
1137
- }]
1138
- });
1139
- if (lines.length > 1 && lines[lines.length - 1]?.text === "") lines.pop();
1140
- return lines;
944
+ function isTableStart(header, delimiter) {
945
+ if (delimiter === void 0 || !header.includes("|")) return false;
946
+ const cells = splitTableRow(delimiter);
947
+ if (cells.length === 0) return false;
948
+ return cells.every((cell) => /^:?-+:?$/.test(cell.trim()));
1141
949
  }
1142
950
  /**
1143
- * Slices derived markdown text and narrows each intersecting source segment to the
1144
- * same text-relative range.
951
+ * Extracts an ATX heading line (`#` `######` followed by text) into its level,
952
+ * trimmed text, and the text's offset inside the line. A run of more than 6 `#`s, or
953
+ * `#`s not followed by whitespace + text, is not a heading; an optional closing
954
+ * `###` run is stripped.
1145
955
  *
1146
- * @param source - The offset-bearing source to slice
1147
- * @param from - The inclusive text offset
1148
- * @param to - The exclusive text offset
1149
- * @returns The sliced text and its narrowed original-string segments
956
+ * @param line - The candidate line
957
+ * @returns The heading level (1–6), raw inline text, and text offset, or `undefined`
1150
958
  *
1151
959
  * @example
1152
960
  * ```ts
1153
- * sliceSource({ text: 'abc', segments: [{ offset: 0, start: 4, end: 7 }] }, 1, 3)
1154
- * // { text: 'bc', segments: [{ offset: 0, start: 5, end: 7 }] }
1155
- * ```
1156
- */
1157
- function sliceSource(source, from, to) {
1158
- const start = Math.max(0, Math.min(from, source.text.length));
1159
- const end = Math.max(start, Math.min(to, source.text.length));
1160
- const segments = [];
1161
- for (let index = 0; index < source.segments.length; index += 1) {
1162
- const segment = source.segments[index];
1163
- if (segment === void 0) continue;
1164
- const next = source.segments[index + 1];
1165
- const limit = Math.min(segment.offset + (segment.end - segment.start), next === void 0 ? source.text.length : next.offset);
1166
- const overlapStart = Math.max(start, segment.offset);
1167
- const overlapEnd = Math.min(end, limit);
1168
- const empty = segment.offset === limit && overlapStart === segment.offset;
1169
- if (overlapStart >= overlapEnd && !empty) continue;
1170
- const originalStart = overlapStart === limit ? segment.end : Math.min(segment.end, segment.start + overlapStart - segment.offset);
1171
- const originalEnd = overlapEnd === limit ? segment.end : Math.min(segment.end, segment.start + overlapEnd - segment.offset);
1172
- segments.push({
1173
- offset: overlapStart - start,
1174
- start: originalStart,
1175
- end: originalEnd
1176
- });
1177
- }
1178
- return {
1179
- text: source.text.slice(start, end),
1180
- segments
1181
- };
1182
- }
1183
- /**
1184
- * Joins offset-bearing markdown sources while mapping a separator to the original
1185
- * region between adjacent mapped sources.
1186
- *
1187
- * @param sources - The sources to join
1188
- * @param separator - The derived text inserted between sources
1189
- * @returns The joined text and every source-backed segment
1190
- *
1191
- * @example
1192
- * ```ts
1193
- * joinSources(splitLines('a\nb'), '\n')
1194
- * // { text: 'a\nb', segments: [...] }
1195
- * ```
1196
- */
1197
- function joinSources(sources, separator) {
1198
- let text = "";
1199
- const segments = [];
1200
- for (let index = 0; index < sources.length; index += 1) {
1201
- const source = sources[index];
1202
- if (source === void 0) continue;
1203
- if (index > 0) {
1204
- const previous = sources[index - 1];
1205
- const left = previous?.segments[previous.segments.length - 1];
1206
- const right = source.segments[0];
1207
- if (separator.length > 0 && left !== void 0 && right !== void 0 && left.end < right.start) segments.push({
1208
- offset: text.length,
1209
- start: left.end,
1210
- end: right.start
1211
- });
1212
- text += separator;
1213
- }
1214
- for (const segment of source.segments) segments.push({
1215
- offset: text.length + segment.offset,
1216
- start: segment.start,
1217
- end: segment.end
1218
- });
1219
- text += source.text;
1220
- }
1221
- return {
1222
- text,
1223
- segments
1224
- };
1225
- }
1226
- /**
1227
- * Projects a derived text range through its segments to a half-open region of the
1228
- * original markdown string.
1229
- *
1230
- * @param source - The offset-bearing source carrying the range
1231
- * @param from - The inclusive derived-text boundary
1232
- * @param to - The exclusive derived-text boundary
1233
- * @returns The original-string span, or `undefined` when either boundary is unmapped
1234
- *
1235
- * @example
1236
- * ```ts
1237
- * projectSpan({ text: 'a', segments: [{ offset: 0, start: 4, end: 5 }] }, 0, 1)
1238
- * // { start: 4, end: 5 }
1239
- * ```
1240
- */
1241
- function projectSpan(source, from, to) {
1242
- if (from < 0 || to < from || to > source.text.length) return void 0;
1243
- let start;
1244
- let end;
1245
- for (let index = 0; index < source.segments.length; index += 1) {
1246
- const segment = source.segments[index];
1247
- if (segment === void 0) continue;
1248
- const next = source.segments[index + 1];
1249
- const limit = Math.min(segment.offset + (segment.end - segment.start), next === void 0 ? source.text.length : next.offset);
1250
- if (from === to && from >= segment.offset && from <= limit) {
1251
- if (next !== void 0 && from === next.offset) continue;
1252
- const position = from === limit ? segment.end : Math.min(segment.end, segment.start + from - segment.offset);
1253
- return {
1254
- start: position,
1255
- end: position
1256
- };
1257
- }
1258
- if (start === void 0 && from >= segment.offset && from < limit) start = segment.start + from - segment.offset;
1259
- if (to > segment.offset && to <= limit) end = to === limit ? segment.end : Math.min(segment.end, segment.start + to - segment.offset);
1260
- }
1261
- return start === void 0 || end === void 0 ? void 0 : {
1262
- start,
1263
- end
1264
- };
1265
- }
1266
- /**
1267
- * Trims an offset-bearing source without losing the coordinates of its retained text.
1268
- *
1269
- * @param source - The source to trim
1270
- * @returns The trimmed text and its narrowed original-string segments
1271
- *
1272
- * @example
1273
- * ```ts
1274
- * trimSource({ text: ' a ', segments: [{ offset: 0, start: 4, end: 7 }] })
1275
- * // { text: 'a', segments: [{ offset: 0, start: 5, end: 6 }] }
1276
- * ```
1277
- */
1278
- function trimSource(source) {
1279
- const start = source.text.length - source.text.trimStart().length;
1280
- const end = source.text.trimEnd().length;
1281
- return sliceSource(source, start, Math.max(start, end));
1282
- }
1283
- /**
1284
- * Normalizes one paragraph line while retaining the full source run consumed by a
1285
- * trailing-space hard break.
1286
- *
1287
- * @param source - The offset-bearing paragraph line
1288
- * @param breaks - If `true`, preserves a trailing run of at least two spaces as the
1289
- * scanner's two-space hard-break syntax; if `false`, trims the line normally
1290
- * @returns The normalized line and its original-string segments
1291
- *
1292
- * @example
1293
- * ```ts
1294
- * normalizeParagraphLine(splitLines('text \nnext')[0], true).text // 'text '
1295
- * ```
1296
- */
1297
- function normalizeParagraphLine(source, breaks) {
1298
- if (!breaks || !source.text.endsWith(" ")) return trimSource(source);
1299
- const contentEnd = source.text.trimEnd().length;
1300
- const content = trimSource(sliceSource(source, 0, contentEnd));
1301
- const span = projectSpan(source, contentEnd, source.text.length);
1302
- return joinSources([content, {
1303
- text: " ",
1304
- segments: span === void 0 ? [] : [{
1305
- offset: 0,
1306
- start: span.start,
1307
- end: span.end
1308
- }]
1309
- }], "");
1310
- }
1311
- /**
1312
- * The count of leading space / tab characters on `line` (a tab counts as one) - the
1313
- * indent that decides whether a list item's continuation belongs to the item.
1314
- *
1315
- * @param line - The line to measure
1316
- * @returns The number of leading space / tab characters
1317
- *
1318
- * @example
1319
- * ```ts
1320
- * countIndent(' text') // 2
1321
- * ```
1322
- */
1323
- function countIndent(line) {
1324
- let count = 0;
1325
- for (const character of line) if (character === " " || character === " ") count += 1;
1326
- else break;
1327
- return count;
1328
- }
1329
- /**
1330
- * Extracts an ATX heading line (`#` … `######` followed by text) into its level,
1331
- * trimmed text, and the text's offset inside the line. A run of more than 6 `#`s, or
1332
- * `#`s not followed by whitespace + text, is not a heading; an optional closing
1333
- * `###` run is stripped.
1334
- *
1335
- * @param line - The candidate line
1336
- * @returns The heading level (1–6), raw inline text, and text offset, or `undefined`
1337
- *
1338
- * @example
1339
- * ```ts
1340
- * extractHeading('## Title') // { level: 2, text: 'Title', offset: 3 }
961
+ * extractHeading('## Title') // { level: 2, text: 'Title', offset: 3 }
1341
962
  * ```
1342
963
  */
1343
964
  function extractHeading(line) {
@@ -1357,7 +978,7 @@ function extractHeading(line) {
1357
978
  };
1358
979
  }
1359
980
  /**
1360
- * Extract a fenced-code opening line (```` ``` ```` or `~~~`, optionally with an info
981
+ * Extracts a fenced-code opening line (```` ``` ```` or `~~~`, optionally with an info
1361
982
  * string) into its `{ marker, lang }`, or `undefined` when `line` is not a fence
1362
983
  * opener. `marker` is the exact fence run (the closer must match the same character +
1363
984
  * at least the same length); `lang` is the first word of the info string.
@@ -1382,7 +1003,7 @@ function extractFence(line) {
1382
1003
  };
1383
1004
  }
1384
1005
  /**
1385
- * Extract a list-item line (`-` / `*` / `+` bullet, or `1.` / `1)` ordinal, followed by
1006
+ * Extracts a list-item line (`-` / `*` / `+` bullet, or `1.` / `1)` ordinal, followed by
1386
1007
  * a space) into its {@link ListItemMatch}, or `undefined` when `line` is not a list
1387
1008
  * item. `content` is the text after the marker; `marker` is the full marker-plus-space
1388
1009
  * width (for measuring a continuation's indent).
@@ -1439,10 +1060,11 @@ function stripQuote(source) {
1439
1060
  return sliceSource(source, (/^\s{0,3}>\s?/.exec(source.text)?.[0] ?? "").length, source.text.length);
1440
1061
  }
1441
1062
  /**
1442
- * Split one GFM table row into its cell strings - outer pipes are optional, an escaped
1443
- * pipe (`\|`) inside a cell is NOT a separator (it becomes a literal `|`), and the
1444
- * empty leading / trailing cell produced by an outer `|` is dropped. Derives the string
1445
- * form from {@link splitTableSources}, which owns the escaped-pipe splitting rule.
1063
+ * Splits one GFM table row into its cell strings outer pipes are optional, a pipe
1064
+ * escaped by a leading backslash inside a cell is not a separator (it becomes a literal
1065
+ * pipe character), and the empty leading / trailing cell an outer pipe produces is
1066
+ * dropped. Derives the string form from {@link splitTableSources}, which owns the
1067
+ * escaped-pipe splitting rule.
1446
1068
  *
1447
1069
  * @param row - The raw table row line
1448
1070
  * @returns The row's cells, in column order
@@ -1505,7 +1127,7 @@ function splitTableSources(row) {
1505
1127
  return cells;
1506
1128
  }
1507
1129
  /**
1508
- * Derive the per-column {@link TableAlign} list from a GFM delimiter row - `:---`
1130
+ * Derives the per-column {@link TableAlign} list from a GFM delimiter row `:---`
1509
1131
  * left, `---:` right, `:---:` center, and `---` as the explicit no-alignment
1510
1132
  * marker represented by `null`.
1511
1133
  *
@@ -1529,15 +1151,15 @@ function delimiterToAlignments(delimiter) {
1529
1151
  });
1530
1152
  }
1531
1153
  /**
1532
- * Whether the line at `index` starts a NEW block kind (heading / fence / thematic
1533
- * break / blockquote / list / table) - the paragraph collector stops at such a line
1154
+ * Checks whether the line at `index` starts a new block kind (heading / fence / thematic
1155
+ * break / blockquote / list / table) the paragraph collector stops at such a line
1534
1156
  * so a block following a paragraph without a blank line still parses (a trusted-input
1535
1157
  * caller writing a `##` heading directly under a paragraph, with no intervening blank
1536
1158
  * line).
1537
1159
  *
1538
1160
  * @param lines - The document's lines
1539
1161
  * @param index - The line index to test
1540
- * @returns `true` when the line begins a different block
1162
+ * @returns True if the line begins a different block; false otherwise
1541
1163
  *
1542
1164
  * @example
1543
1165
  * ```ts
@@ -1549,7 +1171,7 @@ function startsBlock(lines, index) {
1549
1171
  return extractHeading(line) !== void 0 || extractFence(line) !== void 0 || isThematicBreak(line) || isQuote(line) || extractListItem(line) !== void 0 || isTableStart(line, lines[index + 1]);
1550
1172
  }
1551
1173
  /**
1552
- * Resolve backslash escapes in a raw string to their literal characters - used for a
1174
+ * Resolves backslash escapes in a raw string to their literal characters used for a
1553
1175
  * link `href` (which is not otherwise inline-parsed) and any plain text run.
1554
1176
  *
1555
1177
  * @param text - The raw text possibly carrying `\x` escapes
@@ -1572,7 +1194,7 @@ function unescapeText(text) {
1572
1194
  return out;
1573
1195
  }
1574
1196
  /**
1575
- * Merge adjacent text nodes into one - the inline scanner emits a text node per
1197
+ * Merges adjacent text nodes into one the inline scanner emits a text node per
1576
1198
  * unrecognized character, so coalescing keeps the AST clean and assertion-friendly.
1577
1199
  *
1578
1200
  * @param nodes - The inline nodes (possibly with adjacent text runs)
@@ -1610,8 +1232,8 @@ function coalesceText(nodes, spans) {
1610
1232
  return out;
1611
1233
  }
1612
1234
  /**
1613
- * Scan an inline code span at `start` (a `` ` ``-run … a matching `` ` ``-run of the
1614
- * SAME length, the CommonMark rule that lets a span contain backticks). Returns the
1235
+ * Scans an inline code span at `start` (a `` ` ``-run … a matching `` ` ``-run of the
1236
+ * same length, the CommonMark rule that lets a span contain backticks). Returns the
1615
1237
  * span's literal text + end index, or `undefined` when no matching closer exists (it
1616
1238
  * then degrades to literal backticks).
1617
1239
  *
@@ -1645,7 +1267,7 @@ function scanCode(source, start, to) {
1645
1267
  }
1646
1268
  }
1647
1269
  /**
1648
- * Locates a link `[text](href)` at `start` - the text runs to a BALANCED `]`, then `(`
1270
+ * Locates a link `[text](href)` at `start` the text runs to a balanced `]`, then `(`
1649
1271
  * must immediately follow and the destination runs to the matching `)` (both respect
1650
1272
  * nested delimiters + escapes). Returns the label close and syntax end, or `undefined` when the shape
1651
1273
  * does not hold (it then degrades to a literal `[`).
@@ -1703,7 +1325,7 @@ function locateLink(source, start, to) {
1703
1325
  };
1704
1326
  }
1705
1327
  /**
1706
- * Scans a link `[text](href)` at `start` - the text runs to a BALANCED `]`, then `(`
1328
+ * Scans a link `[text](href)` at `start` the text runs to a balanced `]`, then `(`
1707
1329
  * must immediately follow and the destination runs to the matching `)` (both respect
1708
1330
  * nested delimiters + escapes) through {@link locateLink}, and returns the parsed node
1709
1331
  * and end index. Returns `undefined` when the shape does not hold (it then degrades to
@@ -1736,7 +1358,7 @@ function scanLink(source, start, to, depth = 0) {
1736
1358
  };
1737
1359
  }
1738
1360
  /**
1739
- * Locates an emphasis run at `start` (`*` / `_`, doubled for strong) - finds the nearest
1361
+ * Locates an emphasis run at `start` (`*` / `_`, doubled for strong) finds the nearest
1740
1362
  * matching closing run of the same marker + width while skipping complete nested
1741
1363
  * runs from the other marker family, and requires non-space immediately inside both
1742
1364
  * delimiters (the CommonMark flanking simplification that blocks `* x *`). Returns
@@ -1759,7 +1381,7 @@ function locateEmphasis(source, start, to) {
1759
1381
  while (start + run < to && source[start + run] === marker && run < 2) run += 1;
1760
1382
  const strong = run === 2;
1761
1383
  const openEnd = start + run;
1762
- if (openEnd >= to || isWhitespace(source[openEnd] ?? "")) return void 0;
1384
+ if (openEnd >= to || isFlankingWhitespace(source[openEnd] ?? "")) return void 0;
1763
1385
  let index = openEnd;
1764
1386
  while (index < to) {
1765
1387
  const character = source[index] ?? "";
@@ -1782,7 +1404,7 @@ function locateEmphasis(source, start, to) {
1782
1404
  if (character === marker) {
1783
1405
  let closeRun = 0;
1784
1406
  while (index + closeRun < to && source[index + closeRun] === marker) closeRun += 1;
1785
- if (closeRun >= run && !isWhitespace(source[index - 1] ?? "")) return {
1407
+ if (closeRun >= run && !isFlankingWhitespace(source[index - 1] ?? "")) return {
1786
1408
  strong,
1787
1409
  open: openEnd,
1788
1410
  close: index,
@@ -1795,7 +1417,7 @@ function locateEmphasis(source, start, to) {
1795
1417
  }
1796
1418
  }
1797
1419
  /**
1798
- * Scans an emphasis run at `start` (`*` / `_`, doubled for strong) - finds the nearest
1420
+ * Scans an emphasis run at `start` (`*` / `_`, doubled for strong) finds the nearest
1799
1421
  * matching closing run of the same marker + width while skipping complete nested runs
1800
1422
  * from the other marker family, and requires non-space immediately inside both
1801
1423
  * delimiters (the CommonMark flanking simplification that blocks `* x *`) through
@@ -1829,7 +1451,7 @@ function scanEmphasis(source, start, to, depth = 0) {
1829
1451
  };
1830
1452
  }
1831
1453
  /**
1832
- * Scan the window `[from, to)` of `source` into inline nodes - the single recursive
1454
+ * Scans the window `[from, to)` of `source` into inline nodes the single recursive
1833
1455
  * engine the inline phase runs on (emphasis, link text, and image alternative
1834
1456
  * content recurse through it). Linear:
1835
1457
  * each character is consumed once; a failed construct emits its opening character as
@@ -1839,11 +1461,12 @@ function scanEmphasis(source, start, to, depth = 0) {
1839
1461
  * @param from - The inclusive start of the scan window
1840
1462
  * @param to - The exclusive end of the scan window
1841
1463
  * @param depth - The current inline-recursion depth (defaults to 0 at the entry point);
1842
- * incremented by one on every recursive descent through {@link scanLink} /
1843
- * {@link scanEmphasis}. At {@link MAX_DEPTH} the window is never scanned for markup -
1844
- * it emits as a single literal text node - so pathological nesting (`[[[[…`,
1845
- * `****…`) cannot exhaust the call stack.
1846
- * @returns The parsed inline nodes (NOT yet coalesced)
1464
+ * incremented by one on every recursive descent {@link scanInlineSource} makes into
1465
+ * itself for a link's text, an image's alternative content, or an emphasis run's
1466
+ * children. At {@link MAX_DEPTH} the window is never scanned for markup it emits as
1467
+ * a single literal text node — so pathological nesting (`[[[[…`, `****…`) cannot
1468
+ * exhaust the call stack.
1469
+ * @returns The parsed inline nodes (not yet coalesced)
1847
1470
  *
1848
1471
  * @example
1849
1472
  * ```ts
@@ -1868,7 +1491,9 @@ function scanInline(source, from, to, depth = 0) {
1868
1491
  * @param from - The inclusive start of the scan window
1869
1492
  * @param to - The exclusive end of the scan window
1870
1493
  * @param spans - The operation-owned node span recorder
1871
- * @param depth - The current inline-recursion depth
1494
+ * @param depth - The current inline-recursion depth, incremented by one on every
1495
+ * recursive descent this function makes into itself for a link's text, an image's
1496
+ * alternative content, or an emphasis run's children
1872
1497
  * @returns The parsed inline nodes before adjacent text coalescing
1873
1498
  *
1874
1499
  * @example
@@ -2210,7 +1835,7 @@ function collectList(lines, start, depth, spans = /* @__PURE__ */ new Map(), end
2210
1835
  };
2211
1836
  }
2212
1837
  /**
2213
- * Project a {@link MarkdownNode} into an unsanitized {@link HTMLDocument}.
1838
+ * Projects a {@link MarkdownNode} into an unsanitized {@link HTMLDocument}.
2214
1839
  *
2215
1840
  * @remarks
2216
1841
  * The projection is pure and iterative. Text and attribute values remain literal for
@@ -2539,39 +2164,17 @@ function markdownToHTML(node) {
2539
2164
  };
2540
2165
  }
2541
2166
  /**
2542
- * Render a {@link MarkdownNode} to sanitized canonical HTML.
2543
- *
2544
- * @remarks
2545
- * Markdown widens `@orkestrel/html`'s attribute floor by exactly `src`, because image
2546
- * syntax is meaningless without its source. `src` is still a URL attribute, so the
2547
- * floor refuses `javascript:`, `data:`, `vbscript:`, and `file:` values. A stricter
2548
- * consumer can compose {@link markdownToHTML} with `@orkestrel/html`'s `HTML` class
2549
- * directly.
2550
- *
2551
- * @param node - The markdown document or bare node to render
2552
- * @returns Sanitized canonical HTML
2553
- *
2554
- * @example
2555
- * ```ts
2556
- * renderHTML({ element: 'paragraph', children: [{ element: 'text', value: 'a & b' }] })
2557
- * // '<p>a &amp; b</p>'
2558
- * ```
2559
- */
2560
- function renderHTML(node) {
2561
- return (0, _orkestrel_html.renderHTML)(new _orkestrel_html.HTML(markdownToHTML(node)).sanitize({ attributes: [..._orkestrel_html.SAFE_ATTRIBUTES, "src"] }).document);
2562
- }
2563
- /**
2564
- * Render a {@link MarkdownNode} to its CANONICAL markdown source - the inverse
2565
- * projection of `renderHTML`, and the serializer a `parse(renderMarkdown(doc))`
2167
+ * Renders a {@link MarkdownNode} to its canonical markdown source — the inverse
2168
+ * projection of `renderHTML`. It is the serializer a `parse(renderMarkdown(doc))`
2566
2169
  * round-trip is built on. Canonical forms: `*` / `**` emphasis at even emphasis
2567
2170
  * nesting depths and `_` / `__` at odd depths, `- ` bullets, `N. ` sequential
2568
2171
  * ordinals (from the list's `start`), `---` thematic breaks, fenced code blocks
2569
2172
  * (backtick run widened past any 3+ backtick run inside the body), ATX headings,
2570
- * `> `-prefixed blockquote lines, GFM tables (1-space-padded cells, `\|`-escaped
2571
- * pipes, an alignment delimiter row), `[text](href)` links, `![alt](src)` images,
2572
- * and two-space hard breaks. A `text` node's literal content is backslash-escaped
2573
- * wherever it would otherwise re-parse as markup (AGENTS §14 parse↔render
2574
- * soundness).
2173
+ * `> `-prefixed blockquote lines, GFM tables (1-space-padded cells, a backslash
2174
+ * before each literal pipe, an alignment delimiter row), `[text](href)` links,
2175
+ * `![alt](src)` images, and two-space hard breaks. A `text` node's literal content is backslash-escaped
2176
+ * wherever it would otherwise re-parse as markup, so parsing the rendered source
2177
+ * returns the node it was rendered from.
2575
2178
  *
2576
2179
  * @remarks
2577
2180
  * Total: never throws. At {@link MAX_DEPTH} a value-bearing node degrades to its
@@ -2856,8 +2459,38 @@ function renderMarkdown(node) {
2856
2459
  return "";
2857
2460
  }
2858
2461
  /**
2859
- * Trim the whitespace at the two ends of an inline run - the leading whitespace of a
2860
- * leading text node and the trailing whitespace of a trailing one - dropping either
2462
+ * Builds an HTML-to-markdown projection with absent fields defaulted from
2463
+ * {@link EMPTY_PROJECTION} and the block/inline exclusivity invariant enforced.
2464
+ *
2465
+ * @remarks
2466
+ * A block-bearing projection cannot also expose inline content. Callers may provide
2467
+ * both views, but `inlines` is flushed whenever `blocks` is non-empty.
2468
+ *
2469
+ * @param parts - The projection fields to provide
2470
+ * @returns A complete invariant-preserving projection
2471
+ *
2472
+ * @example
2473
+ * ```ts
2474
+ * createProjection({
2475
+ * blocks: [{ element: 'thematicBreak' }],
2476
+ * inlines: [{ element: 'text', value: 'discarded' }],
2477
+ * })
2478
+ * // { blocks: [{ element: 'thematicBreak' }], inlines: [], text: '', cells: [], rows: [] }
2479
+ * ```
2480
+ */
2481
+ function createProjection(parts = {}) {
2482
+ const blocks = parts.blocks ?? EMPTY_PROJECTION.blocks;
2483
+ return {
2484
+ blocks,
2485
+ inlines: blocks.length === 0 ? parts.inlines ?? EMPTY_PROJECTION.inlines : [],
2486
+ text: parts.text ?? EMPTY_PROJECTION.text,
2487
+ cells: parts.cells ?? EMPTY_PROJECTION.cells,
2488
+ rows: parts.rows ?? EMPTY_PROJECTION.rows
2489
+ };
2490
+ }
2491
+ /**
2492
+ * Trims the whitespace at the two ends of an inline run — the leading whitespace of a
2493
+ * leading text node and the trailing whitespace of a trailing one — dropping either
2861
2494
  * node when nothing survives.
2862
2495
  *
2863
2496
  * @remarks
@@ -2897,21 +2530,21 @@ function trimInlines(nodes) {
2897
2530
  return out;
2898
2531
  }
2899
2532
  /**
2900
- * Reduce an inline run to the shape markdown can actually write back: adjacent text
2533
+ * Reduces an inline run to the shape markdown can actually write back: adjacent text
2901
2534
  * coalesced, empty text dropped, and every hard break either kept as a real line
2902
2535
  * ending or spent as a space.
2903
2536
  *
2904
2537
  * @remarks
2905
- * A hard break is ` \n` in markdown source, so it survives a re-parse only BETWEEN
2538
+ * A hard break is ` \n` in markdown source, so it survives a re-parse only between
2906
2539
  * two lines of content and only with no whitespace touching it: a leading or trailing
2907
2540
  * break has no line to end, a run of breaks reads as one blank line (which would end
2908
2541
  * the paragraph), and a space beside one is eaten by the parser's line trimming. Where
2909
- * a break cannot be written at all - a heading and a table cell are one line each - it
2542
+ * a break cannot be written at all a heading and a table cell are one line each it
2910
2543
  * becomes the space it stood for.
2911
2544
  *
2912
2545
  * @param nodes - The inline run to normalize
2913
- * @param breaks - Whether the target context can carry a hard break at all; `false` for
2914
- * a heading or a table cell, where every break becomes a space
2546
+ * @param breaks - If `true`, keeps each hard break as a real line ending; if `false`, spends
2547
+ * every break as the space it stood for, as a heading or a table cell requires
2915
2548
  * @returns The normalized run
2916
2549
  *
2917
2550
  * @example
@@ -2962,14 +2595,14 @@ function normalizeInlines(nodes, breaks) {
2962
2595
  return coalesceText(out);
2963
2596
  }
2964
2597
  /**
2965
- * Combine the projections of one node's children into the projection of that node -
2598
+ * Combines the projections of one node's children into the projection of that node
2966
2599
  * the single place inline runs become paragraphs, so no ancestor has to decide it
2967
2600
  * twice.
2968
2601
  *
2969
2602
  * @remarks
2970
2603
  * A child is either inline or block, never both, so merging preserves source order
2971
2604
  * exactly: an inline run is held pending until a block arrives, then written out as a
2972
- * paragraph BEFORE it. That is what keeps `<div>lead<p>a</p></div>` two paragraphs in
2605
+ * paragraph before it. That is what keeps `<div>lead<p>a</p></div>` two paragraphs in
2973
2606
  * the order they were written rather than two lists that lost their interleaving. A
2974
2607
  * pending run carrying no text is dropped rather than becoming a blank paragraph.
2975
2608
  * Direct cells become one row before a later row, while cells/rows before a block
@@ -3049,7 +2682,7 @@ function mergeProjections(children) {
3049
2682
  });
3050
2683
  }
3051
2684
  /**
3052
- * Read a projection as BLOCK content - the view a document, a blockquote, and a list
2685
+ * Reads a projection as block content the view a document, a blockquote, and a list
3053
2686
  * item each need.
3054
2687
  *
3055
2688
  * @remarks
@@ -3095,7 +2728,7 @@ function projectionToBlocks(projection) {
3095
2728
  return blocks;
3096
2729
  }
3097
2730
  /**
3098
- * Read a projection as INLINE content - the view a link, an emphasis, and a table cell
2731
+ * Reads a projection as inline content the view a link, an emphasis, and a table cell
3099
2732
  * each need.
3100
2733
  *
3101
2734
  * @remarks
@@ -3115,14 +2748,14 @@ function projectionToBlocks(projection) {
3115
2748
  */
3116
2749
  function projectionToInlines(projection) {
3117
2750
  if (!(0, _orkestrel_contract.isNonEmptyArray)(projection.blocks) && !(0, _orkestrel_contract.isNonEmptyArray)(projection.cells) && !(0, _orkestrel_contract.isNonEmptyArray)(projection.rows)) return coalesceText(projection.inlines);
3118
- const value = projectionToBlocks(projection).map(flattenText).join(" ").replace(/\s+/g, " ").trim();
2751
+ const value = (0, _orkestrel_html.collapseSpace)(projectionToBlocks(projection).map(flattenText).join(" "));
3119
2752
  return (0, _orkestrel_contract.isEmptyString)(value) ? [] : [{
3120
2753
  element: "text",
3121
2754
  value
3122
2755
  }];
3123
2756
  }
3124
2757
  /**
3125
- * Project one HTML leaf - a text node, a comment, or a doctype - to its
2758
+ * Projects one HTML leaf a text node, a comment, or a doctype to its
3126
2759
  * {@link MarkdownProjection}.
3127
2760
  *
3128
2761
  * @remarks
@@ -3152,8 +2785,8 @@ function projectHTMLLeaf(leaf) {
3152
2785
  });
3153
2786
  }
3154
2787
  /**
3155
- * Project one HTML container - the document root or an element - from its children's
3156
- * already-computed projections. THE element mapping, and the only place that decides
2788
+ * Projects one HTML container the document root or an element from its children's
2789
+ * already-computed projections. The element mapping, and the only place that decides
3157
2790
  * what an HTML tag becomes in markdown.
3158
2791
  *
3159
2792
  * @remarks
@@ -3165,13 +2798,13 @@ function projectHTMLLeaf(leaf) {
3165
2798
  * inline runs wrapped in paragraphs; `ul` / `ol` a list, ordered from the tag and
3166
2799
  * numbered from `start`; `th` / `td`, `tr`, and `table` a GFM table whose column
3167
2800
  * alignment comes from each header-position cell's `align` attribute. Every
3168
- * `UNSAFE_ELEMENTS` subtree contributes nothing at all, text included. Every OTHER
2801
+ * `UNSAFE_ELEMENTS` subtree contributes nothing at all, text included. Every other
3169
2802
  * element unwraps to its children, so wrapper soup melts while its content keeps its
3170
- * shape - `<div><p>a</p><p>b</p></div>` stays two paragraphs.
2803
+ * shape `<div><p>a</p><p>b</p></div>` stays two paragraphs.
3171
2804
  *
3172
2805
  * Three mappings read their own node rather than only their children's projections,
3173
2806
  * because HTML puts the fact in a position rather than in a value: a `pre` takes its
3174
- * body from its `code` child's raw text, and a list takes one item per `li` child - so
2807
+ * body from its `code` child's raw text, and a list takes one item per `li` child so
3175
2808
  * an empty `<li>` is still an item, while the whitespace between two of them is not.
3176
2809
  * A `tr` accepts only its own direct cells, and a table derives the first `th`-bearing
3177
2810
  * row from its own source structure.
@@ -3301,7 +2934,7 @@ function projectHTMLNode(node, children) {
3301
2934
  text: merged.text
3302
2935
  });
3303
2936
  case "img": {
3304
- const alt = ((0, _orkestrel_html.attributeOf)(node, "alt") ?? "").replace(/\s+/g, " ").trim();
2937
+ const alt = (0, _orkestrel_html.collapseSpace)((0, _orkestrel_html.attributeOf)(node, "alt") ?? "");
3305
2938
  return createProjection({
3306
2939
  inlines: [{
3307
2940
  element: "image",
@@ -3459,36 +3092,36 @@ function projectHTMLNode(node, children) {
3459
3092
  return merged;
3460
3093
  }
3461
3094
  /**
3462
- * Project an `@orkestrel/html` {@link HTMLNode} into a {@link MarkdownDocument} - the
3095
+ * Projects an `@orkestrel/html` {@link HTMLNode} into a {@link MarkdownDocument} the
3463
3096
  * HTML→markdown direction, and the inverse of {@link markdownToHTML}.
3464
3097
  *
3465
3098
  * @remarks
3466
- * **Engine.** One total handler table - {@link projectHTMLNode} for the containers,
3467
- * {@link projectHTMLLeaf} for the leaves - folded by `@orkestrel/html`'s own `foldNode`, so
3099
+ * **Engine.** One total handler table {@link projectHTMLNode} for the containers,
3100
+ * {@link projectHTMLLeaf} for the leaves folded by `@orkestrel/html`'s own `foldNode`, so
3468
3101
  * depth capping, cycle safety, and bottom-up ordering are inherited rather than
3469
3102
  * rebuilt. Total: hostile, cyclic, and pathologically deep input degrades instead of
3470
3103
  * throwing.
3471
3104
  *
3472
3105
  * **Composed depth.** Both packages cap recursion at 64, and html's cap is reached
3473
- * first: a document nested past it projects to a chain bounded by THAT cap, with the
3106
+ * first: a document nested past it projects to a chain bounded by that cap, with the
3474
3107
  * content below it truncated before markdown ever sees it. Since the projected chain
3475
3108
  * can be a level or two deeper than {@link MAX_DEPTH}, the serializer's own cap can
3476
- * then truncate again - so the anchor law below is a law within the depth budget, and
3109
+ * then truncate again so the anchor law that follows is a law within the depth budget, and
3477
3110
  * beyond it only totality is promised.
3478
3111
  *
3479
3112
  * **Safety.** Every `href` and `src` is re-sanitized through
3480
3113
  * `sanitizeURL(value, SAFE_URL_SCHEMES)` whether or not the AST was ever sanitized,
3481
3114
  * because a hand-built one never was. A refused destination empties to `''` and the
3482
- * link or image is KEPT - `[text]()` - since a bad URL is no reason to lose the words
3115
+ * link or image is kept `[text]()` because a bad URL is no reason to lose the words
3483
3116
  * around it. An `UNSAFE_ELEMENTS` subtree contributes nothing at all, text included, so
3484
3117
  * a `script` body can never resurface as prose.
3485
3118
  *
3486
3119
  * **The anchor law.** HTML→markdown is lossy, so the fixpoint that matters is the
3487
- * PROJECTED AST, not the input bytes:
3120
+ * projected AST, not the input bytes:
3488
3121
  * `parseDocument(renderMarkdown(htmlToMarkdown(x)))` deep-equals `htmlToMarkdown(x)`.
3489
3122
  * The projection therefore emits canonical markdown shapes rather than literal
3490
- * translations - whitespace collapsed, edges trimmed, a blank paragraph dropped, a hard
3491
- * break only where a line can end - because a shape markdown cannot write back is a
3123
+ * translations whitespace collapsed, edges trimmed, a blank paragraph dropped, a hard
3124
+ * break only where a line can end because a shape markdown cannot write back is a
3492
3125
  * shape this projection has no business producing.
3493
3126
  *
3494
3127
  * @param node - The HTML document or bare node to project
@@ -3515,7 +3148,7 @@ function htmlToMarkdown(node) {
3515
3148
  };
3516
3149
  }
3517
3150
  /**
3518
- * Depth-first, pre-order, root-inclusive traversal of a {@link MarkdownNode} - yields
3151
+ * Walks a {@link MarkdownNode} depth-first, pre-order, root-inclusive yields
3519
3152
  * the node itself, then recurses into its children (block children, list items,
3520
3153
  * image/link inline children, table header/row cells' inline nodes) in walk order.
3521
3154
  *
@@ -3578,16 +3211,16 @@ function* walkNodes(node) {
3578
3211
  }
3579
3212
  }
3580
3213
  /**
3581
- * Fold a {@link MarkdownNode} into a `T` via a total catamorphism - children are
3214
+ * Folds a {@link MarkdownNode} into a `T` through a total catamorphism children are
3582
3215
  * folded first (post-order), then the node's own {@link MarkdownHandler} is invoked
3583
3216
  * with the already-folded children.
3584
3217
  *
3585
3218
  * @remarks
3586
- * **Table contract.** A {@link TableNode} has no single `children` array - its cells
3219
+ * **Table contract.** A {@link TableNode} has no single `children` array its cells
3587
3220
  * live in `header` (one inline-node list per column) and `rows` (a list of such
3588
- * rows). The `table` handler receives ONE folded `T` per inline node, flattened in
3589
- * walk order across ALL cells - every header cell's inline nodes (column order), then
3590
- * every body row's cells' inline nodes (row order, then column order) - and reads
3221
+ * rows). The `table` handler receives one folded `T` per inline node, flattened in
3222
+ * walk order across all cells every header cell's inline nodes (column order), then
3223
+ * every body row's cells' inline nodes (row order, then column order) and reads
3591
3224
  * `node.header[c].length` / `node.rows[r][c].length` off the table node itself to
3592
3225
  * recover cell boundaries within the flat list.
3593
3226
  *
@@ -3595,13 +3228,13 @@ function* walkNodes(node) {
3595
3228
  * with an empty children list instead of recursing further.
3596
3229
  *
3597
3230
  * @param node - The AST node to fold
3598
- * @param handlers - The total {@link MarkdownHandlers} table, one handler per element
3231
+ * @param handlers - The total {@link MarkdownHandlerMap} table, one handler per element
3599
3232
  * @param depth - The starting recursion depth (pass `0` at the entry point)
3600
3233
  * @returns The folded `T`
3601
3234
  *
3602
3235
  * @example
3603
3236
  * ```ts
3604
- * const countHandlers: MarkdownHandlers<number> = {
3237
+ * const countHandlers: MarkdownHandlerMap<number> = {
3605
3238
  * document: (_, children) => children.reduce((a, b) => a + b, 1),
3606
3239
  * // ...one handler per element, each summing its folded children
3607
3240
  * }
@@ -3730,10 +3363,10 @@ function foldNode(node, handlers, depth) {
3730
3363
  }
3731
3364
  }
3732
3365
  /**
3733
- * Rewrite a {@link MarkdownDocument} bottom-up (copy-on-write) - each node's children
3366
+ * Rewrites a {@link MarkdownDocument} bottom-up (copy-on-write) each node's children
3734
3367
  * are rewritten first (post-order), then `rewrite` is applied to the node itself; the
3735
- * document ROOT is never passed to `rewrite` (the `element: 'document'` invariant
3736
- * always holds). A table's inline cells and a list's items ARE rewritten.
3368
+ * document root is never passed to `rewrite` (the `element: 'document'` invariant
3369
+ * always holds). A table's inline cells and a list's items are rewritten too.
3737
3370
  *
3738
3371
  * @remarks
3739
3372
  * Never mutates `document`. An unchanged subtree keeps its input identity. A parent
@@ -3742,12 +3375,12 @@ function foldNode(node, handlers, depth) {
3742
3375
  * whose `element` does not fit the slot it was called for (a block slot handed a
3743
3376
  * non-{@link BlockNode}, an inline slot handed a non-{@link InlineNode}, a list-item
3744
3377
  * slot handed a non-`listItem`), the ill-fitting result is discarded and the accepted
3745
- * input child is reused - `rewriteDocument` stays total and never produces a
3378
+ * input child is reused `rewriteDocument` stays total and never produces a
3746
3379
  * structurally invalid document.
3747
3380
  *
3748
3381
  * Descent is capped at {@link MAX_DEPTH}, the same cap {@link walkNodes} and
3749
3382
  * {@link foldNode} observe: at `depth >= MAX_DEPTH` the subtree is passed through
3750
- * UNCHANGED (by reference, not rebuilt, and `rewrite` is not invoked on it) instead of
3383
+ * unchanged (by reference, not rebuilt, and `rewrite` is not invoked on it) instead of
3751
3384
  * recursing further, so a pathologically deep adopted document cannot exhaust the
3752
3385
  * call stack. {@link MarkdownInterface.map} inherits this cap since it delegates here.
3753
3386
  *
@@ -4013,8 +3646,8 @@ function rewriteDocument(document, rewrite) {
4013
3646
  return [document, /* @__PURE__ */ new Map()];
4014
3647
  }
4015
3648
  /**
4016
- * Concatenate the `value` / `code` content of every descendant text / code-span /
4017
- * code-block node under `node`, including image alternative content, in walk order -
3649
+ * Concatenates the `value` / `code` content of every descendant text / code-span /
3650
+ * code-block node under `node`, including image alternative content, in walk order
4018
3651
  * the plain-text projection of an AST (search indexing, word counts, a text-only
4019
3652
  * preview).
4020
3653
  *
@@ -4086,6 +3719,461 @@ function flattenText(node) {
4086
3719
  return value;
4087
3720
  }
4088
3721
  //#endregion
3722
+ //#region src/core/compilers.ts
3723
+ /**
3724
+ * Renders a {@link MarkdownNode} to sanitized canonical HTML.
3725
+ *
3726
+ * @remarks
3727
+ * Sanitization is unconditional: the function takes one argument and declares no
3728
+ * options, so no call shape opts out of it.
3729
+ *
3730
+ * Markdown widens `@orkestrel/html`'s attribute floor by exactly `src`, because image
3731
+ * syntax is meaningless without its source. `src` is still a URL attribute, so the
3732
+ * floor refuses `javascript:`, `data:`, `vbscript:`, and `file:` values. A stricter
3733
+ * consumer can compose {@link markdownToHTML} with `@orkestrel/html`'s `HTML` class
3734
+ * directly.
3735
+ *
3736
+ * @param node - The markdown document or bare node to render
3737
+ * @returns Sanitized canonical HTML
3738
+ *
3739
+ * @example
3740
+ * ```ts
3741
+ * renderHTML({ element: 'paragraph', children: [{ element: 'text', value: 'a & b' }] })
3742
+ * // '<p>a &amp; b</p>'
3743
+ * ```
3744
+ */
3745
+ function renderHTML(node) {
3746
+ return (0, _orkestrel_html.renderHTML)(new _orkestrel_html.HTML(markdownToHTML(node)).sanitize({ attributes: [..._orkestrel_html.SAFE_ATTRIBUTES, "src"] }).document);
3747
+ }
3748
+ //#endregion
3749
+ //#region src/core/shapers.ts
3750
+ /**
3751
+ * Describes the shape of a {@link TextNode} — a plain-text leaf inline run.
3752
+ *
3753
+ * @example
3754
+ * ```ts
3755
+ * import { createContract } from '@orkestrel/contract'
3756
+ * import { textShape } from '@src/core'
3757
+ *
3758
+ * const text = createContract(textShape)
3759
+ * text.is({ element: 'text', value: 'hi' }) // true
3760
+ * ```
3761
+ */
3762
+ var textShape = (0, _orkestrel_contract.objectShape)({
3763
+ element: (0, _orkestrel_contract.literalShape)(["text"]),
3764
+ value: (0, _orkestrel_contract.stringShape)()
3765
+ });
3766
+ /**
3767
+ * Describes the shape of a {@link CodeSpanNode} — an inline code span (`` `code` ``).
3768
+ *
3769
+ * @example
3770
+ * ```ts
3771
+ * import { createContract } from '@orkestrel/contract'
3772
+ * import { codeSpanShape } from '@src/core'
3773
+ *
3774
+ * const codeSpan = createContract(codeSpanShape)
3775
+ * codeSpan.is({ element: 'codeSpan', value: 'const x = 1' }) // true
3776
+ * ```
3777
+ */
3778
+ var codeSpanShape = (0, _orkestrel_contract.objectShape)({
3779
+ element: (0, _orkestrel_contract.literalShape)(["codeSpan"]),
3780
+ value: (0, _orkestrel_contract.stringShape)()
3781
+ });
3782
+ /**
3783
+ * Describes the shape of a {@link LineBreakNode} — a GFM hard line-break leaf.
3784
+ *
3785
+ * @example
3786
+ * ```ts
3787
+ * import { createContract } from '@orkestrel/contract'
3788
+ * import { lineBreakShape } from '@src/core'
3789
+ *
3790
+ * const lineBreak = createContract(lineBreakShape)
3791
+ * lineBreak.is({ element: 'break' }) // true
3792
+ * ```
3793
+ */
3794
+ var lineBreakShape = (0, _orkestrel_contract.objectShape)({ element: (0, _orkestrel_contract.literalShape)(["break"]) });
3795
+ /**
3796
+ * Describes the shape of a {@link CodeBlockNode} — a fenced code block. `lang` is
3797
+ * optional (absent when the opening fence carries no info-string).
3798
+ *
3799
+ * @example
3800
+ * ```ts
3801
+ * import { createContract } from '@orkestrel/contract'
3802
+ * import { codeBlockShape } from '@src/core'
3803
+ *
3804
+ * const codeBlock = createContract(codeBlockShape)
3805
+ * codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
3806
+ * codeBlock.is({ element: 'codeBlock', code: 'x', lang: 'ts' }) // true
3807
+ * ```
3808
+ */
3809
+ var codeBlockShape = (0, _orkestrel_contract.objectShape)({
3810
+ element: (0, _orkestrel_contract.literalShape)(["codeBlock"]),
3811
+ lang: (0, _orkestrel_contract.optionalShape)((0, _orkestrel_contract.stringShape)()),
3812
+ code: (0, _orkestrel_contract.stringShape)()
3813
+ });
3814
+ /**
3815
+ * Describes the shape of a {@link ThematicBreakNode} — a horizontal rule. Carries no
3816
+ * fields beyond its `element` discriminant.
3817
+ *
3818
+ * @example
3819
+ * ```ts
3820
+ * import { createContract } from '@orkestrel/contract'
3821
+ * import { thematicBreakShape } from '@src/core'
3822
+ *
3823
+ * const thematicBreak = createContract(thematicBreakShape)
3824
+ * thematicBreak.is({ element: 'thematicBreak' }) // true
3825
+ * ```
3826
+ */
3827
+ var thematicBreakShape = (0, _orkestrel_contract.objectShape)({ element: (0, _orkestrel_contract.literalShape)(["thematicBreak"]) });
3828
+ /**
3829
+ * Describes the shape of a {@link TableAlign} — the per-column GFM table alignment
3830
+ * literal. Absence is no member of it, so the shape refuses the `null` a bare `---`
3831
+ * delimiter takes in a `TableNode`'s `align` list.
3832
+ *
3833
+ * @example
3834
+ * ```ts
3835
+ * import { createContract } from '@orkestrel/contract'
3836
+ * import { tableAlignShape } from '@src/core'
3837
+ *
3838
+ * const tableAlign = createContract(tableAlignShape)
3839
+ * tableAlign.is('left') // true
3840
+ * tableAlign.is('center') // true
3841
+ * tableAlign.is('top') // false
3842
+ * ```
3843
+ */
3844
+ var tableAlignShape = (0, _orkestrel_contract.literalShape)([
3845
+ "left",
3846
+ "right",
3847
+ "center"
3848
+ ]);
3849
+ /**
3850
+ * Describes the shape of {@link ListItemMatch} — the parsed parts of a single list-item
3851
+ * line the block phase's list detector returns. Fully non-recursive (no
3852
+ * nested node fields), so every field shapes directly.
3853
+ *
3854
+ * @example
3855
+ * ```ts
3856
+ * import { createContract } from '@orkestrel/contract'
3857
+ * import { listItemMatchShape } from '@src/core'
3858
+ *
3859
+ * const listItemParts = createContract(listItemMatchShape)
3860
+ * listItemParts.is({ ordered: false, start: 1, content: 'hi', indent: 0, marker: 2 }) // true
3861
+ * ```
3862
+ */
3863
+ var listItemMatchShape = (0, _orkestrel_contract.objectShape)({
3864
+ ordered: (0, _orkestrel_contract.booleanShape)(),
3865
+ start: (0, _orkestrel_contract.integerShape)(),
3866
+ content: (0, _orkestrel_contract.stringShape)(),
3867
+ indent: (0, _orkestrel_contract.integerShape)(),
3868
+ marker: (0, _orkestrel_contract.integerShape)()
3869
+ });
3870
+ //#endregion
3871
+ //#region src/core/Markdown.ts
3872
+ /**
3873
+ * Wraps a typed {@link MarkdownDocument} AST as a stateful, parsed markdown document
3874
+ * with the query (`find` / `filter` / `reduce` / iteration), rewrite (`map`), fold, and
3875
+ * streaming operations {@link MarkdownInterface} declares.
3876
+ *
3877
+ * @remarks
3878
+ * - **Construction.** Given a `string`, the constructor runs {@link parseProvenance} (the
3879
+ * block phase then the inline phase) once, keeping the AST and a copy of the span map
3880
+ * that parse recorded. Given a {@link MarkdownDocument}, the document is adopted as-is
3881
+ * and is not re-validated — gate an untrusted value with `isMarkdownDocument` first.
3882
+ * - **Provenance.** {@link span} reads the region of the original constructor string a
3883
+ * node was produced from, and it is handle-relative: a string-constructed handle exposes
3884
+ * the regions of the nodes it parsed, an adopted document exposes none, and a node from
3885
+ * another handle reports `undefined` here whatever that handle reports. Each call
3886
+ * returns a fresh value. A node reports the region this handle holds for its identity,
3887
+ * else the region of the direct input a rewrite named for it, else `undefined`: a text
3888
+ * run the parse joined from adjacent scanner output reports the region enclosing its
3889
+ * parts, and only a rewrite output that holds no region of its own and was assembled
3890
+ * from separate source nodes reports `undefined`.
3891
+ * {@link map} carries provenance across the rewrite: an unchanged node keeps its
3892
+ * region, a one-source replacement takes the region of the node it replaced, and a
3893
+ * rebuilt parent takes its original's.
3894
+ * - **Immutable.** {@link map} never mutates the stored AST — it returns a new `Markdown`
3895
+ * instance; the document root invariant (`element: 'document'`) always holds. An
3896
+ * identity rewrite still returns a new handle, over the same document tree.
3897
+ * - **Traversal order.** {@link walk} and the `find` / `filter` / `reduce` queries built
3898
+ * on it walk the AST depth-first, pre-order, root-inclusive (through {@link walkNodes});
3899
+ * `stream` is shallow — only the document's direct block children.
3900
+ *
3901
+ * @example Construct from a string and narrow with a guard
3902
+ * ```ts
3903
+ * import { Markdown, isHeadingNode } from '@orkestrel/markdown'
3904
+ *
3905
+ * const markdown = new Markdown('# Title\n\nA **bold** [link](https://x.dev).')
3906
+ * markdown.document.children[0] // { element: 'heading', level: 1, children: [...] }
3907
+ *
3908
+ * const heading = markdown.find(isHeadingNode) // HeadingNode | undefined, narrowed
3909
+ * if (heading !== undefined) heading.level // number — narrowed to HeadingNode
3910
+ * ```
3911
+ */
3912
+ var Markdown = class Markdown {
3913
+ #document;
3914
+ #spans;
3915
+ constructor(input) {
3916
+ if (typeof input === "string") {
3917
+ const [document, spans] = parseProvenance(input);
3918
+ this.#document = document;
3919
+ this.#spans = new Map(spans);
3920
+ } else {
3921
+ this.#document = input;
3922
+ this.#spans = /* @__PURE__ */ new Map();
3923
+ }
3924
+ }
3925
+ /** Holds the stored {@link MarkdownDocument} AST root. */
3926
+ get document() {
3927
+ return this.#document;
3928
+ }
3929
+ /**
3930
+ * Reads the region of the original markdown string a node of this handle's tree was
3931
+ * produced from.
3932
+ *
3933
+ * @param node - The node whose provenance to read
3934
+ * @returns A fresh {@link MarkdownSpan}, or `undefined` when this handle holds no
3935
+ * region for the node
3936
+ *
3937
+ * @example
3938
+ * ```ts
3939
+ * const source = '# Title\n\npara'
3940
+ * const markdown = new Markdown(source)
3941
+ * const heading = markdown.find(isHeadingNode)
3942
+ * const span = heading && markdown.span(heading)
3943
+ * span && source.slice(span.start, span.end) // '# Title'
3944
+ * ```
3945
+ */
3946
+ span(node) {
3947
+ const span = this.#spans.get(node);
3948
+ return span === void 0 ? void 0 : {
3949
+ start: span.start,
3950
+ end: span.end
3951
+ };
3952
+ }
3953
+ /**
3954
+ * Returns the deep traversal — a lazy, depth-first, pre-order, root-inclusive generator
3955
+ * over every {@link MarkdownNode} in the document. `find` / `filter` / `reduce`
3956
+ * all iterate this single traversal.
3957
+ *
3958
+ * @example
3959
+ * ```ts
3960
+ * for (const node of markdown.walk()) {
3961
+ * // every node, depth-first, pre-order, root-inclusive
3962
+ * }
3963
+ *
3964
+ * // also consumable by for-await - JS accepts a sync iterable in for-await
3965
+ * for await (const node of markdown.walk()) {
3966
+ * // same sequence, no separate async iterator needed
3967
+ * }
3968
+ * ```
3969
+ */
3970
+ *walk() {
3971
+ yield* walkNodes(this.#document);
3972
+ }
3973
+ find(predicate) {
3974
+ for (const node of this.walk()) if (predicate(node)) return node;
3975
+ }
3976
+ filter(predicate) {
3977
+ const out = [];
3978
+ for (const node of this.walk()) if (predicate(node)) out.push(node);
3979
+ return out;
3980
+ }
3981
+ /**
3982
+ * Rewrites the AST bottom-up (copy-on-write) and returns a new {@link Markdown},
3983
+ * carrying each output node's provenance across the rewrite. A rewrite that returns
3984
+ * its node unchanged shares that subtree instead of copying it, so an identity
3985
+ * rewrite copies no node and still returns a new handle.
3986
+ *
3987
+ * @param rewrite - The bottom-up node rewrite
3988
+ * @returns A new handle over the rewritten document
3989
+ */
3990
+ map(rewrite) {
3991
+ const [document, derivations] = rewriteDocument(this.#document, rewrite);
3992
+ return this.#derive(document, derivations);
3993
+ }
3994
+ /** Folds the AST depth-first, pre-order into an accumulator. */
3995
+ reduce(callback, initial) {
3996
+ let accumulator = initial;
3997
+ for (const node of this.walk()) accumulator = callback(accumulator, node);
3998
+ return accumulator;
3999
+ }
4000
+ /** Runs a total catamorphism over the document using a {@link MarkdownHandlerMap} table. */
4001
+ fold(handlers) {
4002
+ return foldNode(this.#document, handlers, 0);
4003
+ }
4004
+ /**
4005
+ * Returns a web-standard {@link ReadableStream} over the document's top-level block nodes
4006
+ * (shallow, source order) — a fresh, pull-based source per call: one block is
4007
+ * enqueued per `pull`, so a slow reader's backpressure is respected. Cancellable,
4008
+ * async-iterable wherever the platform supports it (Node, Deno), and pipeable
4009
+ * through any {@link TransformStream} / {@link WritableStream}.
4010
+ *
4011
+ * @example
4012
+ * ```ts
4013
+ * // universal - works in every ReadableStream-supporting environment
4014
+ * const reader = markdown.stream().getReader()
4015
+ * for (let result = await reader.read(); !result.done; result = await reader.read()) {
4016
+ * console.log(result.value) // one BlockNode
4017
+ * }
4018
+ *
4019
+ * // Node / Deno / Firefox support async iteration of ReadableStream natively;
4020
+ * // other environments use the reader loop shown earlier.
4021
+ * for await (const block of markdown.stream()) {
4022
+ * console.log(block)
4023
+ * }
4024
+ * ```
4025
+ */
4026
+ stream() {
4027
+ const blocks = this.#document.children;
4028
+ let index = 0;
4029
+ return new ReadableStream({ pull(controller) {
4030
+ if (index < blocks.length) {
4031
+ const block = blocks[index];
4032
+ if (block === void 0) {
4033
+ controller.close();
4034
+ return;
4035
+ }
4036
+ controller.enqueue(block);
4037
+ index += 1;
4038
+ } else controller.close();
4039
+ } });
4040
+ }
4041
+ #derive(document, derivations) {
4042
+ const derived = new Markdown(document);
4043
+ for (const node of walkNodes(document)) {
4044
+ const own = this.#spans.get(node);
4045
+ if (own !== void 0) {
4046
+ derived.#spans.set(node, own);
4047
+ continue;
4048
+ }
4049
+ const source = derivations.get(node);
4050
+ if (source === void 0) continue;
4051
+ const span = this.#spans.get(source);
4052
+ if (span !== void 0) derived.#spans.set(node, span);
4053
+ }
4054
+ return derived;
4055
+ }
4056
+ };
4057
+ //#endregion
4058
+ //#region src/core/factories.ts
4059
+ /**
4060
+ * Creates a stateful markdown handle from a markdown string or an already-parsed
4061
+ * {@link MarkdownDocument} — a typed AST plus the query, rewrite, and fold operations
4062
+ * {@link MarkdownInterface} exposes.
4063
+ *
4064
+ * @remarks
4065
+ * Given a `string`, runs a block phase (headings / paragraphs / lists / GFM tables /
4066
+ * fenced code / blockquotes / thematic breaks) then an inline phase (emphasis /
4067
+ * inline code / links / images / hard breaks) to build a render-agnostic
4068
+ * {@link MarkdownDocument}. Given a
4069
+ * {@link MarkdownDocument}, adopts it as-is without re-validation — gate an untrusted
4070
+ * value with `isMarkdownDocument` first. Pure + total parse (malformed markdown
4071
+ * degrades to text, never throws) and zero-dependency — a hand-written scanner, no
4072
+ * regex-only structural parse, linear-time (no ReDoS).
4073
+ *
4074
+ * @param input - A markdown string to parse, or an already-parsed {@link MarkdownDocument}
4075
+ * @returns A working {@link MarkdownInterface}
4076
+ *
4077
+ * @example
4078
+ * ```ts
4079
+ * import { createMarkdown } from '@src/core'
4080
+ *
4081
+ * const markdown = createMarkdown('# Hi\n\nRead the [guide](./guide.md).')
4082
+ * markdown.document.children[0] // { element: 'heading', ... }
4083
+ * ```
4084
+ */
4085
+ function createMarkdown(input) {
4086
+ return new Markdown(input);
4087
+ }
4088
+ /**
4089
+ * Compiles the {@link textShape} into a {@link ContractInterface} for
4090
+ * {@link TextNode} — a guard, coercing parser, JSON Schema, and seeded
4091
+ * generator from one shape declaration.
4092
+ *
4093
+ * @returns A `TextNode` contract bundling `schema` / `is` / `parse` / `generate`
4094
+ *
4095
+ * @example
4096
+ * ```ts
4097
+ * import { createTextContract } from '@src/core'
4098
+ *
4099
+ * const text = createTextContract()
4100
+ * text.is({ element: 'text', value: 'hi' }) // true
4101
+ * ```
4102
+ */
4103
+ function createTextContract() {
4104
+ return (0, _orkestrel_contract.createContract)(textShape);
4105
+ }
4106
+ /**
4107
+ * Compiles the {@link codeSpanShape} into a {@link ContractInterface} for
4108
+ * {@link CodeSpanNode} — a guard, coercing parser, JSON Schema, and seeded
4109
+ * generator from one shape declaration.
4110
+ *
4111
+ * @returns A `CodeSpanNode` contract bundling `schema` / `is` / `parse` / `generate`
4112
+ *
4113
+ * @example
4114
+ * ```ts
4115
+ * import { createCodeSpanContract } from '@src/core'
4116
+ *
4117
+ * const codeSpan = createCodeSpanContract()
4118
+ * codeSpan.is({ element: 'codeSpan', value: 'const x = 1' }) // true
4119
+ * ```
4120
+ */
4121
+ function createCodeSpanContract() {
4122
+ return (0, _orkestrel_contract.createContract)(codeSpanShape);
4123
+ }
4124
+ /**
4125
+ * Compiles the {@link lineBreakShape} into a {@link ContractInterface} for
4126
+ * {@link LineBreakNode}.
4127
+ *
4128
+ * @returns A `LineBreakNode` contract bundling `schema` / `is` / `parse` / `generate`
4129
+ *
4130
+ * @example
4131
+ * ```ts
4132
+ * import { createLineBreakContract } from '@src/core'
4133
+ *
4134
+ * createLineBreakContract().is({ element: 'break' }) // true
4135
+ * ```
4136
+ */
4137
+ function createLineBreakContract() {
4138
+ return (0, _orkestrel_contract.createContract)(lineBreakShape);
4139
+ }
4140
+ /**
4141
+ * Compiles the {@link codeBlockShape} into a {@link ContractInterface} for
4142
+ * {@link CodeBlockNode} — a guard, coercing parser, JSON Schema, and seeded
4143
+ * generator from one shape declaration.
4144
+ *
4145
+ * @returns A `CodeBlockNode` contract bundling `schema` / `is` / `parse` / `generate`
4146
+ *
4147
+ * @example
4148
+ * ```ts
4149
+ * import { createCodeBlockContract } from '@src/core'
4150
+ *
4151
+ * const codeBlock = createCodeBlockContract()
4152
+ * codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
4153
+ * ```
4154
+ */
4155
+ function createCodeBlockContract() {
4156
+ return (0, _orkestrel_contract.createContract)(codeBlockShape);
4157
+ }
4158
+ /**
4159
+ * Compiles the {@link thematicBreakShape} into a {@link ContractInterface} for
4160
+ * {@link ThematicBreakNode} — a guard, coercing parser, JSON Schema, and
4161
+ * seeded generator from one shape declaration.
4162
+ *
4163
+ * @returns A `ThematicBreakNode` contract bundling `schema` / `is` / `parse` / `generate`
4164
+ *
4165
+ * @example
4166
+ * ```ts
4167
+ * import { createThematicBreakContract } from '@src/core'
4168
+ *
4169
+ * const thematicBreak = createThematicBreakContract()
4170
+ * thematicBreak.is({ element: 'thematicBreak' }) // true
4171
+ * ```
4172
+ */
4173
+ function createThematicBreakContract() {
4174
+ return (0, _orkestrel_contract.createContract)(thematicBreakShape);
4175
+ }
4176
+ //#endregion
4089
4177
  exports.EMPTY_PROJECTION = EMPTY_PROJECTION;
4090
4178
  exports.MAX_DEPTH = MAX_DEPTH;
4091
4179
  exports.Markdown = Markdown;
@@ -4118,6 +4206,7 @@ exports.isEmphasisNode = isEmphasisNode;
4118
4206
  exports.isEscapable = isEscapable;
4119
4207
  exports.isFenceClose = isFenceClose;
4120
4208
  exports.isFenceWhitespace = isFenceWhitespace;
4209
+ exports.isFlankingWhitespace = isFlankingWhitespace;
4121
4210
  exports.isHeadingNode = isHeadingNode;
4122
4211
  exports.isImageNode = isImageNode;
4123
4212
  exports.isInlineNode = isInlineNode;
@@ -4133,7 +4222,6 @@ exports.isTableStart = isTableStart;
4133
4222
  exports.isTextNode = isTextNode;
4134
4223
  exports.isThematicBreak = isThematicBreak;
4135
4224
  exports.isThematicBreakNode = isThematicBreakNode;
4136
- exports.isWhitespace = isWhitespace;
4137
4225
  exports.joinSources = joinSources;
4138
4226
  exports.lineBreakShape = lineBreakShape;
4139
4227
  exports.listItemMatchShape = listItemMatchShape;