@orkestrel/markdown 0.0.12 → 0.0.13

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. 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
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
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
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
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
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
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
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,11 +511,16 @@ 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
514
+ * Parses a markdown string into a typed {@link MarkdownDocument} AST through the
600
515
  * block phase.
601
516
  *
602
517
  * @param markdown - The markdown source to parse.
603
518
  * @returns The parsed document.
519
+ *
520
+ * @example
521
+ * ```ts
522
+ * parseDocument('# Hi') // { element: 'document', children: [{ element: 'heading', ... }] }
523
+ * ```
604
524
  */
605
525
  function parseDocument(markdown) {
606
526
  const [document] = parseProvenance(markdown);
@@ -611,6 +531,12 @@ function parseDocument(markdown) {
611
531
  *
612
532
  * @param markdown - The markdown source to parse.
613
533
  * @returns The parsed document and its node-identity span map.
534
+ *
535
+ * @example
536
+ * ```ts
537
+ * const [document, spans] = parseProvenance('# Hi')
538
+ * spans.get(document) // { start: 0, end: 4 }
539
+ * ```
614
540
  */
615
541
  function parseProvenance(markdown) {
616
542
  const spans = /* @__PURE__ */ new Map();
@@ -630,714 +556,406 @@ function parseProvenance(markdown) {
630
556
  *
631
557
  * @param text - The inline markdown text to parse.
632
558
  * @returns The parsed inline nodes.
559
+ *
560
+ * @example
561
+ * ```ts
562
+ * parseInline('a *b*') // [{ element: 'text', value: 'a ' }, { element: 'emphasis', ... }]
563
+ * ```
633
564
  */
634
565
  function parseInline(text) {
635
566
  return coalesceText(scanInline(text, 0, text.length));
636
567
  }
637
568
  //#endregion
638
- //#region src/core/Markdown.ts
569
+ //#region src/core/helpers.ts
639
570
  /**
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.
571
+ * Splits a markdown document into offset-bearing lines while normalizing CRLF and
572
+ * bare CR terminators at the line boundary. A single trailing terminator does not
573
+ * yield a final empty line.
643
574
  *
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.
575
+ * @param markdown - The raw markdown source
576
+ * @returns The document's lines with their original-string coordinates
667
577
  *
668
578
  * @example
669
579
  * ```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).'
580
+ * splitLines('a\r\nb') // [{ text: 'a', segments: [{ offset: 0, start: 0, end: 1 }] }, ...]
678
581
  * ```
679
582
  */
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();
583
+ function splitLines(markdown) {
584
+ const lines = [];
585
+ let start = 0;
586
+ let index = 0;
587
+ while (index < markdown.length) {
588
+ const character = markdown[index];
589
+ if (character !== "\r" && character !== "\n") {
590
+ index += 1;
591
+ continue;
691
592
  }
593
+ lines.push({
594
+ text: markdown.slice(start, index),
595
+ segments: [{
596
+ offset: 0,
597
+ start,
598
+ end: index
599
+ }]
600
+ });
601
+ index += character === "\r" && markdown[index + 1] === "\n" ? 2 : 1;
602
+ start = index;
692
603
  }
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
604
+ lines.push({
605
+ text: markdown.slice(start),
606
+ segments: [{
607
+ offset: 0,
608
+ start,
609
+ end: markdown.length
610
+ }]
611
+ });
612
+ if (lines.length > 1 && lines[lines.length - 1]?.text === "") lines.pop();
613
+ return lines;
614
+ }
827
615
  /**
828
- * The shape of a {@link TextNode} - a plain-text leaf inline run.
616
+ * Slices derived markdown text and narrows each intersecting source segment to the
617
+ * same text-relative range.
618
+ *
619
+ * @param source - The offset-bearing source to slice
620
+ * @param from - The inclusive text offset
621
+ * @param to - The exclusive text offset
622
+ * @returns The sliced text and its narrowed original-string segments
829
623
  *
830
624
  * @example
831
625
  * ```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
626
+ * sliceSource({ text: 'abc', segments: [{ offset: 0, start: 4, end: 7 }] }, 1, 3)
627
+ * // { text: 'bc', segments: [{ offset: 0, start: 5, end: 7 }] }
837
628
  * ```
838
629
  */
839
- var textShape = (0, _orkestrel_contract.objectShape)({
840
- element: (0, _orkestrel_contract.literalShape)(["text"]),
841
- value: (0, _orkestrel_contract.stringShape)()
842
- });
630
+ function sliceSource(source, from, to) {
631
+ const start = Math.max(0, Math.min(from, source.text.length));
632
+ const end = Math.max(start, Math.min(to, source.text.length));
633
+ const segments = [];
634
+ for (let index = 0; index < source.segments.length; index += 1) {
635
+ const segment = source.segments[index];
636
+ if (segment === void 0) continue;
637
+ const next = source.segments[index + 1];
638
+ const limit = Math.min(segment.offset + (segment.end - segment.start), next === void 0 ? source.text.length : next.offset);
639
+ const overlapStart = Math.max(start, segment.offset);
640
+ const overlapEnd = Math.min(end, limit);
641
+ const empty = segment.offset === limit && overlapStart === segment.offset;
642
+ if (overlapStart >= overlapEnd && !empty) continue;
643
+ const originalStart = overlapStart === limit ? segment.end : Math.min(segment.end, segment.start + overlapStart - segment.offset);
644
+ const originalEnd = overlapEnd === limit ? segment.end : Math.min(segment.end, segment.start + overlapEnd - segment.offset);
645
+ segments.push({
646
+ offset: overlapStart - start,
647
+ start: originalStart,
648
+ end: originalEnd
649
+ });
650
+ }
651
+ return {
652
+ text: source.text.slice(start, end),
653
+ segments
654
+ };
655
+ }
843
656
  /**
844
- * The shape of a {@link CodeSpanNode} - an inline code span (`` `code` ``).
657
+ * Joins offset-bearing markdown sources while mapping a separator to the original
658
+ * region between adjacent mapped sources.
659
+ *
660
+ * @param sources - The sources to join
661
+ * @param separator - The derived text inserted between sources
662
+ * @returns The joined text and every source-backed segment
845
663
  *
846
664
  * @example
847
665
  * ```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
666
+ * joinSources(splitLines('a\nb'), '\n')
667
+ * // { text: 'a\nb', segments: [...] }
853
668
  * ```
854
669
  */
855
- var codeSpanShape = (0, _orkestrel_contract.objectShape)({
856
- element: (0, _orkestrel_contract.literalShape)(["codeSpan"]),
857
- value: (0, _orkestrel_contract.stringShape)()
858
- });
670
+ function joinSources(sources, separator) {
671
+ let text = "";
672
+ const segments = [];
673
+ for (let index = 0; index < sources.length; index += 1) {
674
+ const source = sources[index];
675
+ if (source === void 0) continue;
676
+ if (index > 0) {
677
+ const previous = sources[index - 1];
678
+ const left = previous?.segments[previous.segments.length - 1];
679
+ const right = source.segments[0];
680
+ if (separator.length > 0 && left !== void 0 && right !== void 0 && left.end < right.start) segments.push({
681
+ offset: text.length,
682
+ start: left.end,
683
+ end: right.start
684
+ });
685
+ text += separator;
686
+ }
687
+ for (const segment of source.segments) segments.push({
688
+ offset: text.length + segment.offset,
689
+ start: segment.start,
690
+ end: segment.end
691
+ });
692
+ text += source.text;
693
+ }
694
+ return {
695
+ text,
696
+ segments
697
+ };
698
+ }
859
699
  /**
860
- * The shape of a {@link LineBreakNode} - a GFM hard line-break leaf.
700
+ * Projects a derived text range through its segments to a half-open region of the
701
+ * original markdown string.
702
+ *
703
+ * @param source - The offset-bearing source carrying the range
704
+ * @param from - The inclusive derived-text boundary
705
+ * @param to - The exclusive derived-text boundary
706
+ * @returns The original-string span, or `undefined` when either boundary is unmapped
861
707
  *
862
708
  * @example
863
709
  * ```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
710
+ * projectSpan({ text: 'a', segments: [{ offset: 0, start: 4, end: 5 }] }, 0, 1)
711
+ * // { start: 4, end: 5 }
869
712
  * ```
870
713
  */
871
- var lineBreakShape = (0, _orkestrel_contract.objectShape)({ element: (0, _orkestrel_contract.literalShape)(["break"]) });
714
+ function projectSpan(source, from, to) {
715
+ if (from < 0 || to < from || to > source.text.length) return void 0;
716
+ let start;
717
+ let end;
718
+ for (let index = 0; index < source.segments.length; index += 1) {
719
+ const segment = source.segments[index];
720
+ if (segment === void 0) continue;
721
+ const next = source.segments[index + 1];
722
+ const limit = Math.min(segment.offset + (segment.end - segment.start), next === void 0 ? source.text.length : next.offset);
723
+ if (from === to && from >= segment.offset && from <= limit) {
724
+ if (next !== void 0 && from === next.offset) continue;
725
+ const position = from === limit ? segment.end : Math.min(segment.end, segment.start + from - segment.offset);
726
+ return {
727
+ start: position,
728
+ end: position
729
+ };
730
+ }
731
+ if (start === void 0 && from >= segment.offset && from < limit) start = segment.start + from - segment.offset;
732
+ if (to > segment.offset && to <= limit) end = to === limit ? segment.end : Math.min(segment.end, segment.start + to - segment.offset);
733
+ }
734
+ return start === void 0 || end === void 0 ? void 0 : {
735
+ start,
736
+ end
737
+ };
738
+ }
872
739
  /**
873
- * The shape of a {@link CodeBlockNode} - a fenced code block. `lang` is
874
- * optional (absent when the opening fence carries no info-string).
740
+ * Trims an offset-bearing source without losing the coordinates of its retained text.
741
+ *
742
+ * @param source - The source to trim
743
+ * @returns The trimmed text and its narrowed original-string segments
875
744
  *
876
745
  * @example
877
746
  * ```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
747
+ * trimSource({ text: ' a ', segments: [{ offset: 0, start: 4, end: 7 }] })
748
+ * // { text: 'a', segments: [{ offset: 0, start: 5, end: 6 }] }
884
749
  * ```
885
750
  */
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
- });
891
- /**
892
- * The shape of a {@link ThematicBreakNode} - a horizontal rule. Carries no
893
- * fields beyond its `element` discriminant.
751
+ function trimSource(source) {
752
+ const start = source.text.length - source.text.trimStart().length;
753
+ const end = source.text.trimEnd().length;
754
+ return sliceSource(source, start, Math.max(start, end));
755
+ }
756
+ /**
757
+ * Normalizes one paragraph line while retaining the full source run consumed by a
758
+ * trailing-space hard break.
759
+ *
760
+ * @param source - The offset-bearing paragraph line
761
+ * @param breaks - If `true`, preserves a trailing run of at least two spaces as the
762
+ * scanner's two-space hard-break syntax; if `false`, trims the line normally
763
+ * @returns The normalized line and its original-string segments
894
764
  *
895
765
  * @example
896
766
  * ```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
767
+ * normalizeParagraphLine(splitLines('text \nnext')[0], true).text // 'text '
902
768
  * ```
903
769
  */
904
- var thematicBreakShape = (0, _orkestrel_contract.objectShape)({ element: (0, _orkestrel_contract.literalShape)(["thematicBreak"]) });
770
+ function normalizeParagraphLine(source, breaks) {
771
+ if (!breaks || !source.text.endsWith(" ")) return trimSource(source);
772
+ const contentEnd = source.text.trimEnd().length;
773
+ const content = trimSource(sliceSource(source, 0, contentEnd));
774
+ const span = projectSpan(source, contentEnd, source.text.length);
775
+ return joinSources([content, {
776
+ text: " ",
777
+ segments: span === void 0 ? [] : [{
778
+ offset: 0,
779
+ start: span.start,
780
+ end: span.end
781
+ }]
782
+ }], "");
783
+ }
905
784
  /**
906
- * The shape of a {@link TableAlign} - the per-column GFM table alignment
907
- * literal.
785
+ * Counts the leading space / tab characters on `line` (a tab counts as one) - the
786
+ * indent that decides whether a list item's continuation belongs to the item.
787
+ *
788
+ * @param line - The line to measure
789
+ * @returns The number of leading space / tab characters
908
790
  *
909
791
  * @example
910
792
  * ```ts
911
- * import { createContract } from '@orkestrel/contract'
912
- * import { tableAlignShape } from '@src/core'
913
- *
914
- * const tableAlign = createContract(tableAlignShape)
915
- * tableAlign.is('left') // true
916
- * tableAlign.is('center') // true
917
- * tableAlign.is('top') // false
793
+ * countIndent(' text') // 2
918
794
  * ```
919
795
  */
920
- var tableAlignShape = (0, _orkestrel_contract.literalShape)([
921
- "left",
922
- "right",
923
- "center"
924
- ]);
796
+ function countIndent(line) {
797
+ let count = 0;
798
+ for (const character of line) if (character === " " || character === " ") count += 1;
799
+ else break;
800
+ return count;
801
+ }
925
802
  /**
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.
803
+ * Checks whether `character` is whitespace under the emphasis flanking rule - a space, a
804
+ * tab, or a newline.
805
+ *
806
+ * @param character - The character to test
807
+ * @returns True if the flanking rule counts it as whitespace; false otherwise
929
808
  *
930
809
  * @example
931
810
  * ```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
811
+ * isFlankingWhitespace(' ') // true
812
+ * isFlankingWhitespace('a') // false
937
813
  * ```
938
814
  */
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
815
+ function isFlankingWhitespace(character) {
816
+ return character === " " || character === " " || character === "\n";
817
+ }
948
818
  /**
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.
819
+ * Checks whether `character` is escapable by a leading backslash - the ASCII punctuation
820
+ * markdown gives meaning to (so `\*` becomes `*` but `\.` stays `\.`).
955
821
  *
956
- * @param parts - The projection fields to provide
957
- * @returns A complete invariant-preserving projection
822
+ * @param character - The single character after a backslash
823
+ * @returns True if a backslash before it is an escape; false otherwise
958
824
  *
959
825
  * @example
960
826
  * ```ts
961
- * createProjection({
962
- * blocks: [{ element: 'thematicBreak' }],
963
- * inlines: [{ element: 'text', value: 'discarded' }],
964
- * })
965
- * // { blocks: [{ element: 'thematicBreak' }], inlines: [], text: '', cells: [], rows: [] }
827
+ * isEscapable('*') // true
828
+ * isEscapable('a') // false
966
829
  * ```
967
830
  */
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
- };
831
+ function isEscapable(character) {
832
+ return /[\\`*_{}[\]()#+\-.!>~|]/.test(character);
977
833
  }
978
834
  /**
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).
835
+ * Checks whether `line` is blank - empty, or containing only whitespace - the markdown
836
+ * definition of a blank line that block parsing uses to separate paragraphs, skip
837
+ * gaps, and end list continuations.
992
838
  *
993
- * @param input - A markdown string to parse, or an already-parsed {@link MarkdownDocument}
994
- * @returns A working {@link MarkdownInterface}
839
+ * @param line - The candidate line
840
+ * @returns True if the line is blank; false otherwise
995
841
  *
996
842
  * @example
997
843
  * ```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', ... }
844
+ * isBlankLine(' ') // true
1002
845
  * ```
1003
846
  */
1004
- function createMarkdown(input) {
1005
- return new Markdown(input);
847
+ function isBlankLine(line) {
848
+ return (0, _orkestrel_contract.isEmptyString)(line.trim());
1006
849
  }
1007
850
  /**
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).
851
+ * Checks whether `line` is a blockquote line (`>` optionally indented up to three spaces) -
852
+ * its content is de-quoted by {@link stripQuote}.
1011
853
  *
1012
- * @returns A `TextNode` contract bundling `schema` / `is` / `parse` / `generate`
854
+ * @param line - The candidate line
855
+ * @returns True if the line begins a blockquote; false otherwise
1013
856
  *
1014
857
  * @example
1015
858
  * ```ts
1016
- * import { createTextContract } from '@src/core'
1017
- *
1018
- * const text = createTextContract()
1019
- * text.is({ element: 'text', value: 'hi' }) // true
859
+ * isQuote('> quoted') // true
1020
860
  * ```
1021
861
  */
1022
- function createTextContract() {
1023
- return (0, _orkestrel_contract.createContract)(textShape);
862
+ function isQuote(line) {
863
+ return /^\s{0,3}>/.test(line);
1024
864
  }
1025
865
  /**
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).
866
+ * Checks whether `line` closes a fence opened by `marker` - the same fence character, a run
867
+ * at least as long, and nothing else but surrounding whitespace.
1029
868
  *
1030
- * @returns A `CodeSpanNode` contract bundling `schema` / `is` / `parse` / `generate`
869
+ * @param line - The candidate closing line
870
+ * @param marker - The opening fence's marker run (from {@link extractFence})
871
+ * @returns True if `line` closes the fence; false otherwise
1031
872
  *
1032
873
  * @example
1033
874
  * ```ts
1034
- * import { createCodeSpanContract } from '@src/core'
1035
- *
1036
- * const codeSpan = createCodeSpanContract()
1037
- * codeSpan.is({ element: 'codeSpan', value: 'const x = 1' }) // true
875
+ * isFenceClose('```', '```') // true
1038
876
  * ```
1039
877
  */
1040
- function createCodeSpanContract() {
1041
- return (0, _orkestrel_contract.createContract)(codeSpanShape);
878
+ function isFenceClose(line, marker) {
879
+ const character = marker[0] === "~" ? "~" : "`";
880
+ let index = 0;
881
+ while (index < line.length && isFenceWhitespace(line[index])) index++;
882
+ let run = 0;
883
+ while (index < line.length && line[index] === character) {
884
+ run++;
885
+ index++;
886
+ }
887
+ if (run < marker.length) return false;
888
+ while (index < line.length && isFenceWhitespace(line[index])) index++;
889
+ return index === line.length;
1042
890
  }
1043
891
  /**
1044
- * Compile the {@link lineBreakShape} into a {@link ContractInterface} for
1045
- * {@link LineBreakNode}.
892
+ * Checks whether `character` is a regex-`\s`-equivalent whitespace character - the
893
+ * character class {@link isFenceClose}'s scan treats as surrounding padding.
1046
894
  *
1047
- * @returns A `LineBreakNode` contract bundling `schema` / `is` / `parse` / `generate`
895
+ * @param character - The single character to test, or `undefined` past the end of a line
896
+ * @returns True if it is whitespace; false otherwise
1048
897
  *
1049
898
  * @example
1050
899
  * ```ts
1051
- * import { createLineBreakContract } from '@src/core'
1052
- *
1053
- * createLineBreakContract().is({ element: 'break' }) // true
900
+ * isFenceWhitespace(' ') // true
901
+ * isFenceWhitespace(undefined) // false
1054
902
  * ```
1055
903
  */
1056
- function createLineBreakContract() {
1057
- return (0, _orkestrel_contract.createContract)(lineBreakShape);
904
+ function isFenceWhitespace(character) {
905
+ return character === " " || character === " " || character === "\n" || character === "\r" || character === "\f" || character === "\v";
1058
906
  }
1059
907
  /**
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).
908
+ * Checks whether `line` is a thematic break (horizontal rule) - three or more of the SAME
909
+ * marker `-`, `*`, or `_` (optionally space-separated) and nothing else (`---`,
910
+ * `***`, `___`, `- - -`).
1063
911
  *
1064
- * @returns A `CodeBlockNode` contract bundling `schema` / `is` / `parse` / `generate`
912
+ * @param line - The candidate line
913
+ * @returns True if the line is a thematic break; false otherwise
1065
914
  *
1066
915
  * @example
1067
916
  * ```ts
1068
- * import { createCodeBlockContract } from '@src/core'
1069
- *
1070
- * const codeBlock = createCodeBlockContract()
1071
- * codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
917
+ * isThematicBreak('---') // true
1072
918
  * ```
1073
919
  */
1074
- function createCodeBlockContract() {
1075
- return (0, _orkestrel_contract.createContract)(codeBlockShape);
920
+ function isThematicBreak(line) {
921
+ const stripped = line.trim().replace(/\s+/g, "");
922
+ if (stripped.length < 3) return false;
923
+ const marker = stripped[0];
924
+ if (marker !== "-" && marker !== "*" && marker !== "_") return false;
925
+ return [...stripped].every((character) => character === marker);
1076
926
  }
1077
927
  /**
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).
928
+ * Checks whether the pair (`header`, `delimiter`) opens a GFM table - `delimiter` is a row of
929
+ * `|`-separated cells each matching `:?-+:?`, the GFM rule that a table requires a
930
+ * header row IMMEDIATELY followed by a delimiter row.
1081
931
  *
1082
- * @returns A `ThematicBreakNode` contract bundling `schema` / `is` / `parse` / `generate`
932
+ * @param header - The candidate header line
933
+ * @param delimiter - The line after it (the candidate delimiter)
934
+ * @returns True if the two lines open a table; false otherwise
1083
935
  *
1084
936
  * @example
1085
937
  * ```ts
1086
- * import { createThematicBreakContract } from '@src/core'
1087
- *
1088
- * const thematicBreak = createThematicBreakContract()
1089
- * thematicBreak.is({ element: 'thematicBreak' }) // true
938
+ * isTableStart('| a |', '| - |') // true
1090
939
  * ```
1091
940
  */
1092
- function createThematicBreakContract() {
1093
- return (0, _orkestrel_contract.createContract)(thematicBreakShape);
941
+ function isTableStart(header, delimiter) {
942
+ if (delimiter === void 0 || !header.includes("|")) return false;
943
+ const cells = splitTableRow(delimiter);
944
+ if (cells.length === 0) return false;
945
+ return cells.every((cell) => /^:?-+:?$/.test(cell.trim()));
1094
946
  }
1095
- //#endregion
1096
- //#region src/core/helpers.ts
1097
947
  /**
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.
948
+ * Extracts an ATX heading line (`#` `######` followed by text) into its level,
949
+ * trimmed text, and the text's offset inside the line. A run of more than 6 `#`s, or
950
+ * `#`s not followed by whitespace + text, is not a heading; an optional closing
951
+ * `###` run is stripped.
1101
952
  *
1102
- * @param markdown - The raw markdown source
1103
- * @returns The document's lines with their original-string coordinates
953
+ * @param line - The candidate line
954
+ * @returns The heading level (1–6), raw inline text, and text offset, or `undefined`
1104
955
  *
1105
956
  * @example
1106
957
  * ```ts
1107
- * splitLines('a\r\nb') // [{ text: 'a', segments: [{ offset: 0, start: 0, end: 1 }] }, ...]
1108
- * ```
1109
- */
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;
1141
- }
1142
- /**
1143
- * Slices derived markdown text and narrows each intersecting source segment to the
1144
- * same text-relative range.
1145
- *
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
1150
- *
1151
- * @example
1152
- * ```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 }
958
+ * extractHeading('## Title') // { level: 2, text: 'Title', offset: 3 }
1341
959
  * ```
1342
960
  */
1343
961
  function extractHeading(line) {
@@ -1357,7 +975,7 @@ function extractHeading(line) {
1357
975
  };
1358
976
  }
1359
977
  /**
1360
- * Extract a fenced-code opening line (```` ``` ```` or `~~~`, optionally with an info
978
+ * Extracts a fenced-code opening line (```` ``` ```` or `~~~`, optionally with an info
1361
979
  * string) into its `{ marker, lang }`, or `undefined` when `line` is not a fence
1362
980
  * opener. `marker` is the exact fence run (the closer must match the same character +
1363
981
  * at least the same length); `lang` is the first word of the info string.
@@ -1382,7 +1000,7 @@ function extractFence(line) {
1382
1000
  };
1383
1001
  }
1384
1002
  /**
1385
- * Extract a list-item line (`-` / `*` / `+` bullet, or `1.` / `1)` ordinal, followed by
1003
+ * Extracts a list-item line (`-` / `*` / `+` bullet, or `1.` / `1)` ordinal, followed by
1386
1004
  * a space) into its {@link ListItemMatch}, or `undefined` when `line` is not a list
1387
1005
  * item. `content` is the text after the marker; `marker` is the full marker-plus-space
1388
1006
  * width (for measuring a continuation's indent).
@@ -1439,7 +1057,7 @@ function stripQuote(source) {
1439
1057
  return sliceSource(source, (/^\s{0,3}>\s?/.exec(source.text)?.[0] ?? "").length, source.text.length);
1440
1058
  }
1441
1059
  /**
1442
- * Split one GFM table row into its cell strings - outer pipes are optional, an escaped
1060
+ * Splits one GFM table row into its cell strings - outer pipes are optional, an escaped
1443
1061
  * pipe (`\|`) inside a cell is NOT a separator (it becomes a literal `|`), and the
1444
1062
  * empty leading / trailing cell produced by an outer `|` is dropped. Derives the string
1445
1063
  * form from {@link splitTableSources}, which owns the escaped-pipe splitting rule.
@@ -1505,7 +1123,7 @@ function splitTableSources(row) {
1505
1123
  return cells;
1506
1124
  }
1507
1125
  /**
1508
- * Derive the per-column {@link TableAlign} list from a GFM delimiter row - `:---`
1126
+ * Derives the per-column {@link TableAlign} list from a GFM delimiter row - `:---`
1509
1127
  * left, `---:` right, `:---:` center, and `---` as the explicit no-alignment
1510
1128
  * marker represented by `null`.
1511
1129
  *
@@ -1529,7 +1147,7 @@ function delimiterToAlignments(delimiter) {
1529
1147
  });
1530
1148
  }
1531
1149
  /**
1532
- * Whether the line at `index` starts a NEW block kind (heading / fence / thematic
1150
+ * Checks whether the line at `index` starts a NEW block kind (heading / fence / thematic
1533
1151
  * break / blockquote / list / table) - the paragraph collector stops at such a line
1534
1152
  * so a block following a paragraph without a blank line still parses (a trusted-input
1535
1153
  * caller writing a `##` heading directly under a paragraph, with no intervening blank
@@ -1537,7 +1155,7 @@ function delimiterToAlignments(delimiter) {
1537
1155
  *
1538
1156
  * @param lines - The document's lines
1539
1157
  * @param index - The line index to test
1540
- * @returns `true` when the line begins a different block
1158
+ * @returns True if the line begins a different block; false otherwise
1541
1159
  *
1542
1160
  * @example
1543
1161
  * ```ts
@@ -1549,7 +1167,7 @@ function startsBlock(lines, index) {
1549
1167
  return extractHeading(line) !== void 0 || extractFence(line) !== void 0 || isThematicBreak(line) || isQuote(line) || extractListItem(line) !== void 0 || isTableStart(line, lines[index + 1]);
1550
1168
  }
1551
1169
  /**
1552
- * Resolve backslash escapes in a raw string to their literal characters - used for a
1170
+ * Resolves backslash escapes in a raw string to their literal characters - used for a
1553
1171
  * link `href` (which is not otherwise inline-parsed) and any plain text run.
1554
1172
  *
1555
1173
  * @param text - The raw text possibly carrying `\x` escapes
@@ -1572,7 +1190,7 @@ function unescapeText(text) {
1572
1190
  return out;
1573
1191
  }
1574
1192
  /**
1575
- * Merge adjacent text nodes into one - the inline scanner emits a text node per
1193
+ * Merges adjacent text nodes into one - the inline scanner emits a text node per
1576
1194
  * unrecognized character, so coalescing keeps the AST clean and assertion-friendly.
1577
1195
  *
1578
1196
  * @param nodes - The inline nodes (possibly with adjacent text runs)
@@ -1610,7 +1228,7 @@ function coalesceText(nodes, spans) {
1610
1228
  return out;
1611
1229
  }
1612
1230
  /**
1613
- * Scan an inline code span at `start` (a `` ` ``-run … a matching `` ` ``-run of the
1231
+ * Scans an inline code span at `start` (a `` ` ``-run … a matching `` ` ``-run of the
1614
1232
  * SAME length, the CommonMark rule that lets a span contain backticks). Returns the
1615
1233
  * span's literal text + end index, or `undefined` when no matching closer exists (it
1616
1234
  * then degrades to literal backticks).
@@ -1759,7 +1377,7 @@ function locateEmphasis(source, start, to) {
1759
1377
  while (start + run < to && source[start + run] === marker && run < 2) run += 1;
1760
1378
  const strong = run === 2;
1761
1379
  const openEnd = start + run;
1762
- if (openEnd >= to || isWhitespace(source[openEnd] ?? "")) return void 0;
1380
+ if (openEnd >= to || isFlankingWhitespace(source[openEnd] ?? "")) return void 0;
1763
1381
  let index = openEnd;
1764
1382
  while (index < to) {
1765
1383
  const character = source[index] ?? "";
@@ -1782,7 +1400,7 @@ function locateEmphasis(source, start, to) {
1782
1400
  if (character === marker) {
1783
1401
  let closeRun = 0;
1784
1402
  while (index + closeRun < to && source[index + closeRun] === marker) closeRun += 1;
1785
- if (closeRun >= run && !isWhitespace(source[index - 1] ?? "")) return {
1403
+ if (closeRun >= run && !isFlankingWhitespace(source[index - 1] ?? "")) return {
1786
1404
  strong,
1787
1405
  open: openEnd,
1788
1406
  close: index,
@@ -1829,7 +1447,7 @@ function scanEmphasis(source, start, to, depth = 0) {
1829
1447
  };
1830
1448
  }
1831
1449
  /**
1832
- * Scan the window `[from, to)` of `source` into inline nodes - the single recursive
1450
+ * Scans the window `[from, to)` of `source` into inline nodes - the single recursive
1833
1451
  * engine the inline phase runs on (emphasis, link text, and image alternative
1834
1452
  * content recurse through it). Linear:
1835
1453
  * each character is consumed once; a failed construct emits its opening character as
@@ -1839,10 +1457,11 @@ function scanEmphasis(source, start, to, depth = 0) {
1839
1457
  * @param from - The inclusive start of the scan window
1840
1458
  * @param to - The exclusive end of the scan window
1841
1459
  * @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.
1460
+ * incremented by one on every recursive descent {@link scanInlineSource} makes into
1461
+ * itself for a link's text, an image's alternative content, or an emphasis run's
1462
+ * children. At {@link MAX_DEPTH} the window is never scanned for markup - it emits as
1463
+ * a single literal text node - so pathological nesting (`[[[[…`, `****…`) cannot
1464
+ * exhaust the call stack.
1846
1465
  * @returns The parsed inline nodes (NOT yet coalesced)
1847
1466
  *
1848
1467
  * @example
@@ -1868,7 +1487,9 @@ function scanInline(source, from, to, depth = 0) {
1868
1487
  * @param from - The inclusive start of the scan window
1869
1488
  * @param to - The exclusive end of the scan window
1870
1489
  * @param spans - The operation-owned node span recorder
1871
- * @param depth - The current inline-recursion depth
1490
+ * @param depth - The current inline-recursion depth, incremented by one on every
1491
+ * recursive descent this function makes into itself for a link's text, an image's
1492
+ * alternative content, or an emphasis run's children
1872
1493
  * @returns The parsed inline nodes before adjacent text coalescing
1873
1494
  *
1874
1495
  * @example
@@ -2210,7 +1831,7 @@ function collectList(lines, start, depth, spans = /* @__PURE__ */ new Map(), end
2210
1831
  };
2211
1832
  }
2212
1833
  /**
2213
- * Project a {@link MarkdownNode} into an unsanitized {@link HTMLDocument}.
1834
+ * Projects a {@link MarkdownNode} into an unsanitized {@link HTMLDocument}.
2214
1835
  *
2215
1836
  * @remarks
2216
1837
  * The projection is pure and iterative. Text and attribute values remain literal for
@@ -2539,29 +2160,7 @@ function markdownToHTML(node) {
2539
2160
  };
2540
2161
  }
2541
2162
  /**
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
2163
+ * Renders a {@link MarkdownNode} to its CANONICAL markdown source - the inverse
2565
2164
  * projection of `renderHTML`, and the serializer a `parse(renderMarkdown(doc))`
2566
2165
  * round-trip is built on. Canonical forms: `*` / `**` emphasis at even emphasis
2567
2166
  * nesting depths and `_` / `__` at odd depths, `- ` bullets, `N. ` sequential
@@ -2570,8 +2169,8 @@ function renderHTML(node) {
2570
2169
  * `> `-prefixed blockquote lines, GFM tables (1-space-padded cells, `\|`-escaped
2571
2170
  * pipes, an alignment delimiter row), `[text](href)` links, `![alt](src)` images,
2572
2171
  * 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).
2172
+ * wherever it would otherwise re-parse as markup, so parsing the rendered source
2173
+ * returns the node it was rendered from.
2575
2174
  *
2576
2175
  * @remarks
2577
2176
  * Total: never throws. At {@link MAX_DEPTH} a value-bearing node degrades to its
@@ -2856,32 +2455,62 @@ function renderMarkdown(node) {
2856
2455
  return "";
2857
2456
  }
2858
2457
  /**
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
2861
- * node when nothing survives.
2458
+ * Builds an HTML-to-markdown projection with absent fields defaulted from
2459
+ * {@link EMPTY_PROJECTION} and the block/inline exclusivity invariant enforced.
2862
2460
  *
2863
2461
  * @remarks
2864
- * Markdown trims every line of a paragraph, a heading's text, and a table cell, so an
2865
- * untrimmed run would come back from a re-parse a different AST. Expects a coalesced
2866
- * run (see {@link coalesceText}): only the outermost node on each side is examined.
2462
+ * A block-bearing projection cannot also expose inline content. Callers may provide
2463
+ * both views, but `inlines` is flushed whenever `blocks` is non-empty.
2867
2464
  *
2868
- * @param nodes - The inline run to trim
2869
- * @returns The run with its edge whitespace removed
2465
+ * @param parts - The projection fields to provide
2466
+ * @returns A complete invariant-preserving projection
2870
2467
  *
2871
2468
  * @example
2872
2469
  * ```ts
2873
- * trimInlines([{ element: 'text', value: ' a ' }]) // [{ element: 'text', value: 'a' }]
2470
+ * createProjection({
2471
+ * blocks: [{ element: 'thematicBreak' }],
2472
+ * inlines: [{ element: 'text', value: 'discarded' }],
2473
+ * })
2474
+ * // { blocks: [{ element: 'thematicBreak' }], inlines: [], text: '', cells: [], rows: [] }
2874
2475
  * ```
2875
2476
  */
2876
- function trimInlines(nodes) {
2877
- const out = [];
2878
- for (const node of nodes) if (node !== void 0) out.push(node);
2879
- const first = out[0];
2880
- if (first !== void 0 && first.element === "text") {
2881
- const value = first.value.replace(/^\s+/, "");
2882
- if ((0, _orkestrel_contract.isEmptyString)(value)) out.shift();
2883
- else out[0] = {
2884
- element: "text",
2477
+ function createProjection(parts = {}) {
2478
+ const blocks = parts.blocks ?? EMPTY_PROJECTION.blocks;
2479
+ return {
2480
+ blocks,
2481
+ inlines: blocks.length === 0 ? parts.inlines ?? EMPTY_PROJECTION.inlines : [],
2482
+ text: parts.text ?? EMPTY_PROJECTION.text,
2483
+ cells: parts.cells ?? EMPTY_PROJECTION.cells,
2484
+ rows: parts.rows ?? EMPTY_PROJECTION.rows
2485
+ };
2486
+ }
2487
+ /**
2488
+ * Trims the whitespace at the two ends of an inline run - the leading whitespace of a
2489
+ * leading text node and the trailing whitespace of a trailing one - dropping either
2490
+ * node when nothing survives.
2491
+ *
2492
+ * @remarks
2493
+ * Markdown trims every line of a paragraph, a heading's text, and a table cell, so an
2494
+ * untrimmed run would come back from a re-parse a different AST. Expects a coalesced
2495
+ * run (see {@link coalesceText}): only the outermost node on each side is examined.
2496
+ *
2497
+ * @param nodes - The inline run to trim
2498
+ * @returns The run with its edge whitespace removed
2499
+ *
2500
+ * @example
2501
+ * ```ts
2502
+ * trimInlines([{ element: 'text', value: ' a ' }]) // [{ element: 'text', value: 'a' }]
2503
+ * ```
2504
+ */
2505
+ function trimInlines(nodes) {
2506
+ const out = [];
2507
+ for (const node of nodes) if (node !== void 0) out.push(node);
2508
+ const first = out[0];
2509
+ if (first !== void 0 && first.element === "text") {
2510
+ const value = first.value.replace(/^\s+/, "");
2511
+ if ((0, _orkestrel_contract.isEmptyString)(value)) out.shift();
2512
+ else out[0] = {
2513
+ element: "text",
2885
2514
  value
2886
2515
  };
2887
2516
  }
@@ -2897,7 +2526,7 @@ function trimInlines(nodes) {
2897
2526
  return out;
2898
2527
  }
2899
2528
  /**
2900
- * Reduce an inline run to the shape markdown can actually write back: adjacent text
2529
+ * Reduces an inline run to the shape markdown can actually write back: adjacent text
2901
2530
  * coalesced, empty text dropped, and every hard break either kept as a real line
2902
2531
  * ending or spent as a space.
2903
2532
  *
@@ -2910,8 +2539,8 @@ function trimInlines(nodes) {
2910
2539
  * becomes the space it stood for.
2911
2540
  *
2912
2541
  * @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
2542
+ * @param breaks - If `true`, keeps each hard break as a real line ending; if `false`, spends
2543
+ * every break as the space it stood for, as a heading or a table cell requires
2915
2544
  * @returns The normalized run
2916
2545
  *
2917
2546
  * @example
@@ -2962,7 +2591,7 @@ function normalizeInlines(nodes, breaks) {
2962
2591
  return coalesceText(out);
2963
2592
  }
2964
2593
  /**
2965
- * Combine the projections of one node's children into the projection of that node -
2594
+ * Combines the projections of one node's children into the projection of that node -
2966
2595
  * the single place inline runs become paragraphs, so no ancestor has to decide it
2967
2596
  * twice.
2968
2597
  *
@@ -3049,7 +2678,7 @@ function mergeProjections(children) {
3049
2678
  });
3050
2679
  }
3051
2680
  /**
3052
- * Read a projection as BLOCK content - the view a document, a blockquote, and a list
2681
+ * Reads a projection as BLOCK content - the view a document, a blockquote, and a list
3053
2682
  * item each need.
3054
2683
  *
3055
2684
  * @remarks
@@ -3095,7 +2724,7 @@ function projectionToBlocks(projection) {
3095
2724
  return blocks;
3096
2725
  }
3097
2726
  /**
3098
- * Read a projection as INLINE content - the view a link, an emphasis, and a table cell
2727
+ * Reads a projection as INLINE content - the view a link, an emphasis, and a table cell
3099
2728
  * each need.
3100
2729
  *
3101
2730
  * @remarks
@@ -3115,14 +2744,14 @@ function projectionToBlocks(projection) {
3115
2744
  */
3116
2745
  function projectionToInlines(projection) {
3117
2746
  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();
2747
+ const value = (0, _orkestrel_html.collapseSpace)(projectionToBlocks(projection).map(flattenText).join(" "));
3119
2748
  return (0, _orkestrel_contract.isEmptyString)(value) ? [] : [{
3120
2749
  element: "text",
3121
2750
  value
3122
2751
  }];
3123
2752
  }
3124
2753
  /**
3125
- * Project one HTML leaf - a text node, a comment, or a doctype - to its
2754
+ * Projects one HTML leaf - a text node, a comment, or a doctype - to its
3126
2755
  * {@link MarkdownProjection}.
3127
2756
  *
3128
2757
  * @remarks
@@ -3152,7 +2781,7 @@ function projectHTMLLeaf(leaf) {
3152
2781
  });
3153
2782
  }
3154
2783
  /**
3155
- * Project one HTML container - the document root or an element - from its children's
2784
+ * Projects one HTML container - the document root or an element - from its children's
3156
2785
  * already-computed projections. THE element mapping, and the only place that decides
3157
2786
  * what an HTML tag becomes in markdown.
3158
2787
  *
@@ -3301,7 +2930,7 @@ function projectHTMLNode(node, children) {
3301
2930
  text: merged.text
3302
2931
  });
3303
2932
  case "img": {
3304
- const alt = ((0, _orkestrel_html.attributeOf)(node, "alt") ?? "").replace(/\s+/g, " ").trim();
2933
+ const alt = (0, _orkestrel_html.collapseSpace)((0, _orkestrel_html.attributeOf)(node, "alt") ?? "");
3305
2934
  return createProjection({
3306
2935
  inlines: [{
3307
2936
  element: "image",
@@ -3459,7 +3088,7 @@ function projectHTMLNode(node, children) {
3459
3088
  return merged;
3460
3089
  }
3461
3090
  /**
3462
- * Project an `@orkestrel/html` {@link HTMLNode} into a {@link MarkdownDocument} - the
3091
+ * Projects an `@orkestrel/html` {@link HTMLNode} into a {@link MarkdownDocument} - the
3463
3092
  * HTML→markdown direction, and the inverse of {@link markdownToHTML}.
3464
3093
  *
3465
3094
  * @remarks
@@ -3515,7 +3144,7 @@ function htmlToMarkdown(node) {
3515
3144
  };
3516
3145
  }
3517
3146
  /**
3518
- * Depth-first, pre-order, root-inclusive traversal of a {@link MarkdownNode} - yields
3147
+ * Walks a {@link MarkdownNode} depth-first, pre-order, root-inclusive - yields
3519
3148
  * the node itself, then recurses into its children (block children, list items,
3520
3149
  * image/link inline children, table header/row cells' inline nodes) in walk order.
3521
3150
  *
@@ -3578,7 +3207,7 @@ function* walkNodes(node) {
3578
3207
  }
3579
3208
  }
3580
3209
  /**
3581
- * Fold a {@link MarkdownNode} into a `T` via a total catamorphism - children are
3210
+ * Folds a {@link MarkdownNode} into a `T` through a total catamorphism - children are
3582
3211
  * folded first (post-order), then the node's own {@link MarkdownHandler} is invoked
3583
3212
  * with the already-folded children.
3584
3213
  *
@@ -3595,13 +3224,13 @@ function* walkNodes(node) {
3595
3224
  * with an empty children list instead of recursing further.
3596
3225
  *
3597
3226
  * @param node - The AST node to fold
3598
- * @param handlers - The total {@link MarkdownHandlers} table, one handler per element
3227
+ * @param handlers - The total {@link MarkdownHandlerMap} table, one handler per element
3599
3228
  * @param depth - The starting recursion depth (pass `0` at the entry point)
3600
3229
  * @returns The folded `T`
3601
3230
  *
3602
3231
  * @example
3603
3232
  * ```ts
3604
- * const countHandlers: MarkdownHandlers<number> = {
3233
+ * const countHandlers: MarkdownHandlerMap<number> = {
3605
3234
  * document: (_, children) => children.reduce((a, b) => a + b, 1),
3606
3235
  * // ...one handler per element, each summing its folded children
3607
3236
  * }
@@ -3730,7 +3359,7 @@ function foldNode(node, handlers, depth) {
3730
3359
  }
3731
3360
  }
3732
3361
  /**
3733
- * Rewrite a {@link MarkdownDocument} bottom-up (copy-on-write) - each node's children
3362
+ * Rewrites a {@link MarkdownDocument} bottom-up (copy-on-write) - each node's children
3734
3363
  * are rewritten first (post-order), then `rewrite` is applied to the node itself; the
3735
3364
  * document ROOT is never passed to `rewrite` (the `element: 'document'` invariant
3736
3365
  * always holds). A table's inline cells and a list's items ARE rewritten.
@@ -4013,7 +3642,7 @@ function rewriteDocument(document, rewrite) {
4013
3642
  return [document, /* @__PURE__ */ new Map()];
4014
3643
  }
4015
3644
  /**
4016
- * Concatenate the `value` / `code` content of every descendant text / code-span /
3645
+ * Concatenates the `value` / `code` content of every descendant text / code-span /
4017
3646
  * code-block node under `node`, including image alternative content, in walk order -
4018
3647
  * the plain-text projection of an AST (search indexing, word counts, a text-only
4019
3648
  * preview).
@@ -4086,6 +3715,458 @@ function flattenText(node) {
4086
3715
  return value;
4087
3716
  }
4088
3717
  //#endregion
3718
+ //#region src/core/compilers.ts
3719
+ /**
3720
+ * Renders a {@link MarkdownNode} to sanitized canonical HTML.
3721
+ *
3722
+ * @remarks
3723
+ * Markdown widens `@orkestrel/html`'s attribute floor by exactly `src`, because image
3724
+ * syntax is meaningless without its source. `src` is still a URL attribute, so the
3725
+ * floor refuses `javascript:`, `data:`, `vbscript:`, and `file:` values. A stricter
3726
+ * consumer can compose {@link markdownToHTML} with `@orkestrel/html`'s `HTML` class
3727
+ * directly.
3728
+ *
3729
+ * @param node - The markdown document or bare node to render
3730
+ * @returns Sanitized canonical HTML
3731
+ *
3732
+ * @example
3733
+ * ```ts
3734
+ * renderHTML({ element: 'paragraph', children: [{ element: 'text', value: 'a & b' }] })
3735
+ * // '<p>a &amp; b</p>'
3736
+ * ```
3737
+ */
3738
+ function renderHTML(node) {
3739
+ return (0, _orkestrel_html.renderHTML)(new _orkestrel_html.HTML(markdownToHTML(node)).sanitize({ attributes: [..._orkestrel_html.SAFE_ATTRIBUTES, "src"] }).document);
3740
+ }
3741
+ //#endregion
3742
+ //#region src/core/shapers.ts
3743
+ /**
3744
+ * Describes the shape of a {@link TextNode} - a plain-text leaf inline run.
3745
+ *
3746
+ * @example
3747
+ * ```ts
3748
+ * import { createContract } from '@orkestrel/contract'
3749
+ * import { textShape } from '@src/core'
3750
+ *
3751
+ * const text = createContract(textShape)
3752
+ * text.is({ element: 'text', value: 'hi' }) // true
3753
+ * ```
3754
+ */
3755
+ var textShape = (0, _orkestrel_contract.objectShape)({
3756
+ element: (0, _orkestrel_contract.literalShape)(["text"]),
3757
+ value: (0, _orkestrel_contract.stringShape)()
3758
+ });
3759
+ /**
3760
+ * Describes the shape of a {@link CodeSpanNode} - an inline code span (`` `code` ``).
3761
+ *
3762
+ * @example
3763
+ * ```ts
3764
+ * import { createContract } from '@orkestrel/contract'
3765
+ * import { codeSpanShape } from '@src/core'
3766
+ *
3767
+ * const codeSpan = createContract(codeSpanShape)
3768
+ * codeSpan.is({ element: 'codeSpan', value: 'const x = 1' }) // true
3769
+ * ```
3770
+ */
3771
+ var codeSpanShape = (0, _orkestrel_contract.objectShape)({
3772
+ element: (0, _orkestrel_contract.literalShape)(["codeSpan"]),
3773
+ value: (0, _orkestrel_contract.stringShape)()
3774
+ });
3775
+ /**
3776
+ * Describes the shape of a {@link LineBreakNode} - a GFM hard line-break leaf.
3777
+ *
3778
+ * @example
3779
+ * ```ts
3780
+ * import { createContract } from '@orkestrel/contract'
3781
+ * import { lineBreakShape } from '@src/core'
3782
+ *
3783
+ * const lineBreak = createContract(lineBreakShape)
3784
+ * lineBreak.is({ element: 'break' }) // true
3785
+ * ```
3786
+ */
3787
+ var lineBreakShape = (0, _orkestrel_contract.objectShape)({ element: (0, _orkestrel_contract.literalShape)(["break"]) });
3788
+ /**
3789
+ * Describes the shape of a {@link CodeBlockNode} - a fenced code block. `lang` is
3790
+ * optional (absent when the opening fence carries no info-string).
3791
+ *
3792
+ * @example
3793
+ * ```ts
3794
+ * import { createContract } from '@orkestrel/contract'
3795
+ * import { codeBlockShape } from '@src/core'
3796
+ *
3797
+ * const codeBlock = createContract(codeBlockShape)
3798
+ * codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
3799
+ * codeBlock.is({ element: 'codeBlock', code: 'x', lang: 'ts' }) // true
3800
+ * ```
3801
+ */
3802
+ var codeBlockShape = (0, _orkestrel_contract.objectShape)({
3803
+ element: (0, _orkestrel_contract.literalShape)(["codeBlock"]),
3804
+ lang: (0, _orkestrel_contract.optionalShape)((0, _orkestrel_contract.stringShape)()),
3805
+ code: (0, _orkestrel_contract.stringShape)()
3806
+ });
3807
+ /**
3808
+ * Describes the shape of a {@link ThematicBreakNode} - a horizontal rule. Carries no
3809
+ * fields beyond its `element` discriminant.
3810
+ *
3811
+ * @example
3812
+ * ```ts
3813
+ * import { createContract } from '@orkestrel/contract'
3814
+ * import { thematicBreakShape } from '@src/core'
3815
+ *
3816
+ * const thematicBreak = createContract(thematicBreakShape)
3817
+ * thematicBreak.is({ element: 'thematicBreak' }) // true
3818
+ * ```
3819
+ */
3820
+ var thematicBreakShape = (0, _orkestrel_contract.objectShape)({ element: (0, _orkestrel_contract.literalShape)(["thematicBreak"]) });
3821
+ /**
3822
+ * Describes the shape of a {@link TableAlign} - the per-column GFM table alignment
3823
+ * literal.
3824
+ *
3825
+ * @example
3826
+ * ```ts
3827
+ * import { createContract } from '@orkestrel/contract'
3828
+ * import { tableAlignShape } from '@src/core'
3829
+ *
3830
+ * const tableAlign = createContract(tableAlignShape)
3831
+ * tableAlign.is('left') // true
3832
+ * tableAlign.is('center') // true
3833
+ * tableAlign.is('top') // false
3834
+ * ```
3835
+ */
3836
+ var tableAlignShape = (0, _orkestrel_contract.literalShape)([
3837
+ "left",
3838
+ "right",
3839
+ "center"
3840
+ ]);
3841
+ /**
3842
+ * Describes the shape of {@link ListItemMatch} - the parsed parts of a single list-item
3843
+ * line the block phase's list detector returns. Fully non-recursive (no
3844
+ * nested node fields), so every field shapes directly.
3845
+ *
3846
+ * @example
3847
+ * ```ts
3848
+ * import { createContract } from '@orkestrel/contract'
3849
+ * import { listItemMatchShape } from '@src/core'
3850
+ *
3851
+ * const listItemParts = createContract(listItemMatchShape)
3852
+ * listItemParts.is({ ordered: false, start: 1, content: 'hi', indent: 0, marker: 2 }) // true
3853
+ * ```
3854
+ */
3855
+ var listItemMatchShape = (0, _orkestrel_contract.objectShape)({
3856
+ ordered: (0, _orkestrel_contract.booleanShape)(),
3857
+ start: (0, _orkestrel_contract.integerShape)(),
3858
+ content: (0, _orkestrel_contract.stringShape)(),
3859
+ indent: (0, _orkestrel_contract.integerShape)(),
3860
+ marker: (0, _orkestrel_contract.integerShape)()
3861
+ });
3862
+ //#endregion
3863
+ //#region src/core/Markdown.ts
3864
+ /**
3865
+ * Wraps a typed {@link MarkdownDocument} AST as a stateful, parsed markdown document
3866
+ * with the query (`find` / `filter` / `reduce` / iteration), rewrite (`map`), fold, and
3867
+ * streaming operations {@link MarkdownInterface} declares.
3868
+ *
3869
+ * @remarks
3870
+ * - **Construction.** Given a `string`, the constructor runs {@link parseProvenance} (the
3871
+ * block phase then the inline phase) once, keeping the AST and a COPY of the span map
3872
+ * that parse recorded. Given a {@link MarkdownDocument}, the document is adopted AS-IS
3873
+ * and is NOT re-validated - gate an untrusted value with `isMarkdownDocument` first.
3874
+ * - **Provenance.** {@link span} reads the region of the ORIGINAL constructor string a
3875
+ * node was produced from, and it is handle-relative: a string-constructed handle exposes
3876
+ * the regions of the nodes it parsed, an adopted document exposes none, and a node from
3877
+ * another handle reports `undefined` here whatever that handle reports. Each call
3878
+ * returns a fresh value. A node reports the region THIS handle holds for its identity,
3879
+ * else the region of the direct input a rewrite named for it, else `undefined`: a text
3880
+ * run the parse joined from adjacent scanner output reports the region enclosing its
3881
+ * parts, and only a rewrite output that holds no region of its own and was assembled
3882
+ * from separate source nodes reports `undefined`.
3883
+ * {@link map} carries provenance across the rewrite: an unchanged node keeps its
3884
+ * region, a one-source replacement takes the region of the node it replaced, and a
3885
+ * rebuilt parent takes its original's.
3886
+ * - **Immutable.** {@link map} never mutates the stored AST - it returns a NEW `Markdown`
3887
+ * instance; the document root invariant (`element: 'document'`) always holds. An
3888
+ * identity rewrite still returns a new handle, over the same document tree.
3889
+ * - **Traversal order.** {@link walk} and the `find` / `filter` / `reduce` queries built
3890
+ * on it walk the AST depth-first, pre-order, root-inclusive (through {@link walkNodes});
3891
+ * `stream` is shallow - only the document's direct block children.
3892
+ *
3893
+ * @example
3894
+ * ```ts
3895
+ * import { Markdown, isHeadingNode, renderMarkdown } from '@src/core'
3896
+ *
3897
+ * const markdown = new Markdown('# Title\n\nA **bold** [link](https://x.dev).')
3898
+ * const heading = markdown.find(isHeadingNode) // the HeadingNode, or undefined
3899
+ * const shouted = markdown.map((node) =>
3900
+ * node.element === 'text' ? { element: 'text', value: node.value.toUpperCase() } : node,
3901
+ * )
3902
+ * renderMarkdown(shouted.document) // '# TITLE\n\nA **BOLD** [LINK](https://x.dev).'
3903
+ * ```
3904
+ */
3905
+ var Markdown = class Markdown {
3906
+ #document;
3907
+ #spans;
3908
+ constructor(input) {
3909
+ if (typeof input === "string") {
3910
+ const [document, spans] = parseProvenance(input);
3911
+ this.#document = document;
3912
+ this.#spans = new Map(spans);
3913
+ } else {
3914
+ this.#document = input;
3915
+ this.#spans = /* @__PURE__ */ new Map();
3916
+ }
3917
+ }
3918
+ /** Holds the stored {@link MarkdownDocument} AST root. */
3919
+ get document() {
3920
+ return this.#document;
3921
+ }
3922
+ /**
3923
+ * Reads the region of the original markdown string a node of this handle's tree was
3924
+ * produced from.
3925
+ *
3926
+ * @param node - The node whose provenance to read
3927
+ * @returns A fresh {@link MarkdownSpan}, or `undefined` when this handle holds no
3928
+ * region for the node
3929
+ *
3930
+ * @example
3931
+ * ```ts
3932
+ * const source = '# Title\n\npara'
3933
+ * const markdown = new Markdown(source)
3934
+ * const heading = markdown.find(isHeadingNode)
3935
+ * const span = heading && markdown.span(heading)
3936
+ * span && source.slice(span.start, span.end) // '# Title'
3937
+ * ```
3938
+ */
3939
+ span(node) {
3940
+ const span = this.#spans.get(node);
3941
+ return span === void 0 ? void 0 : {
3942
+ start: span.start,
3943
+ end: span.end
3944
+ };
3945
+ }
3946
+ /**
3947
+ * Returns THE deep traversal - a lazy, depth-first, pre-order, root-inclusive generator
3948
+ * over every {@link MarkdownNode} in the document. `find` / `filter` / `reduce`
3949
+ * all iterate this single traversal.
3950
+ *
3951
+ * @example
3952
+ * ```ts
3953
+ * for (const node of markdown.walk()) {
3954
+ * // every node, depth-first, pre-order, root-inclusive
3955
+ * }
3956
+ *
3957
+ * // also consumable by for-await - JS accepts a sync iterable in for-await
3958
+ * for await (const node of markdown.walk()) {
3959
+ * // same sequence, no separate async iterator needed
3960
+ * }
3961
+ * ```
3962
+ */
3963
+ *walk() {
3964
+ yield* walkNodes(this.#document);
3965
+ }
3966
+ find(predicate) {
3967
+ for (const node of this.walk()) if (predicate(node)) return node;
3968
+ }
3969
+ filter(predicate) {
3970
+ const out = [];
3971
+ for (const node of this.walk()) if (predicate(node)) out.push(node);
3972
+ return out;
3973
+ }
3974
+ /**
3975
+ * Rewrites the AST bottom-up (copy-on-write) and returns a new {@link Markdown},
3976
+ * carrying each output node's provenance across the rewrite. A rewrite that returns
3977
+ * its node unchanged shares that subtree instead of copying it, so an identity
3978
+ * rewrite copies no node and still returns a new handle.
3979
+ *
3980
+ * @param rewrite - The bottom-up node rewrite
3981
+ * @returns A new handle over the rewritten document
3982
+ */
3983
+ map(rewrite) {
3984
+ const [document, derivations] = rewriteDocument(this.#document, rewrite);
3985
+ return this.#derive(document, derivations);
3986
+ }
3987
+ /** Folds the AST depth-first, pre-order into an accumulator. */
3988
+ reduce(callback, initial) {
3989
+ let accumulator = initial;
3990
+ for (const node of this.walk()) accumulator = callback(accumulator, node);
3991
+ return accumulator;
3992
+ }
3993
+ /** Runs a total catamorphism over the document using a {@link MarkdownHandlerMap} table. */
3994
+ fold(handlers) {
3995
+ return foldNode(this.#document, handlers, 0);
3996
+ }
3997
+ /**
3998
+ * Returns a web-standard {@link ReadableStream} over the document's top-level block nodes
3999
+ * (shallow, source order) - a fresh, pull-based source per call: one block is
4000
+ * enqueued per `pull`, so a slow reader's backpressure is respected. Cancellable,
4001
+ * async-iterable wherever the platform supports it (Node, Deno), and pipeable
4002
+ * through any {@link TransformStream} / {@link WritableStream}.
4003
+ *
4004
+ * @example
4005
+ * ```ts
4006
+ * // universal - works in every ReadableStream-supporting environment
4007
+ * const reader = markdown.stream().getReader()
4008
+ * for (let result = await reader.read(); !result.done; result = await reader.read()) {
4009
+ * console.log(result.value) // one BlockNode
4010
+ * }
4011
+ *
4012
+ * // Node / Deno / Firefox support async iteration of ReadableStream natively;
4013
+ * // other environments use the reader loop shown earlier.
4014
+ * for await (const block of markdown.stream()) {
4015
+ * console.log(block)
4016
+ * }
4017
+ * ```
4018
+ */
4019
+ stream() {
4020
+ const blocks = this.#document.children;
4021
+ let index = 0;
4022
+ return new ReadableStream({ pull(controller) {
4023
+ if (index < blocks.length) {
4024
+ const block = blocks[index];
4025
+ if (block === void 0) {
4026
+ controller.close();
4027
+ return;
4028
+ }
4029
+ controller.enqueue(block);
4030
+ index += 1;
4031
+ } else controller.close();
4032
+ } });
4033
+ }
4034
+ #derive(document, derivations) {
4035
+ const derived = new Markdown(document);
4036
+ for (const node of walkNodes(document)) {
4037
+ const own = this.#spans.get(node);
4038
+ if (own !== void 0) {
4039
+ derived.#spans.set(node, own);
4040
+ continue;
4041
+ }
4042
+ const source = derivations.get(node);
4043
+ if (source === void 0) continue;
4044
+ const span = this.#spans.get(source);
4045
+ if (span !== void 0) derived.#spans.set(node, span);
4046
+ }
4047
+ return derived;
4048
+ }
4049
+ };
4050
+ //#endregion
4051
+ //#region src/core/factories.ts
4052
+ /**
4053
+ * Creates a stateful markdown handle from a markdown string or an already-parsed
4054
+ * {@link MarkdownDocument} - a typed AST plus the query, rewrite, and fold operations
4055
+ * {@link MarkdownInterface} exposes.
4056
+ *
4057
+ * @remarks
4058
+ * Given a `string`, runs a block phase (headings / paragraphs / lists / GFM tables /
4059
+ * fenced code / blockquotes / thematic breaks) then an inline phase (emphasis /
4060
+ * inline code / links / images / hard breaks) to build a render-agnostic
4061
+ * {@link MarkdownDocument}. Given a
4062
+ * {@link MarkdownDocument}, adopts it AS-IS without re-validation - gate an untrusted
4063
+ * value with `isMarkdownDocument` first. Pure + total parse (malformed markdown
4064
+ * degrades to text, never throws) and zero-dependency - a hand-written scanner, no
4065
+ * regex-only structural parse, linear-time (no ReDoS).
4066
+ *
4067
+ * @param input - A markdown string to parse, or an already-parsed {@link MarkdownDocument}
4068
+ * @returns A working {@link MarkdownInterface}
4069
+ *
4070
+ * @example
4071
+ * ```ts
4072
+ * import { createMarkdown } from '@src/core'
4073
+ *
4074
+ * const markdown = createMarkdown('# Hi\n\nRead the [guide](./guide.md).')
4075
+ * markdown.document.children[0] // { element: 'heading', ... }
4076
+ * ```
4077
+ */
4078
+ function createMarkdown(input) {
4079
+ return new Markdown(input);
4080
+ }
4081
+ /**
4082
+ * Compiles the {@link textShape} into a {@link ContractInterface} for
4083
+ * {@link TextNode} - a guard, coercing parser, JSON Schema, and seeded
4084
+ * generator from one shape declaration.
4085
+ *
4086
+ * @returns A `TextNode` contract bundling `schema` / `is` / `parse` / `generate`
4087
+ *
4088
+ * @example
4089
+ * ```ts
4090
+ * import { createTextContract } from '@src/core'
4091
+ *
4092
+ * const text = createTextContract()
4093
+ * text.is({ element: 'text', value: 'hi' }) // true
4094
+ * ```
4095
+ */
4096
+ function createTextContract() {
4097
+ return (0, _orkestrel_contract.createContract)(textShape);
4098
+ }
4099
+ /**
4100
+ * Compiles the {@link codeSpanShape} into a {@link ContractInterface} for
4101
+ * {@link CodeSpanNode} - a guard, coercing parser, JSON Schema, and seeded
4102
+ * generator from one shape declaration.
4103
+ *
4104
+ * @returns A `CodeSpanNode` contract bundling `schema` / `is` / `parse` / `generate`
4105
+ *
4106
+ * @example
4107
+ * ```ts
4108
+ * import { createCodeSpanContract } from '@src/core'
4109
+ *
4110
+ * const codeSpan = createCodeSpanContract()
4111
+ * codeSpan.is({ element: 'codeSpan', value: 'const x = 1' }) // true
4112
+ * ```
4113
+ */
4114
+ function createCodeSpanContract() {
4115
+ return (0, _orkestrel_contract.createContract)(codeSpanShape);
4116
+ }
4117
+ /**
4118
+ * Compiles the {@link lineBreakShape} into a {@link ContractInterface} for
4119
+ * {@link LineBreakNode}.
4120
+ *
4121
+ * @returns A `LineBreakNode` contract bundling `schema` / `is` / `parse` / `generate`
4122
+ *
4123
+ * @example
4124
+ * ```ts
4125
+ * import { createLineBreakContract } from '@src/core'
4126
+ *
4127
+ * createLineBreakContract().is({ element: 'break' }) // true
4128
+ * ```
4129
+ */
4130
+ function createLineBreakContract() {
4131
+ return (0, _orkestrel_contract.createContract)(lineBreakShape);
4132
+ }
4133
+ /**
4134
+ * Compiles the {@link codeBlockShape} into a {@link ContractInterface} for
4135
+ * {@link CodeBlockNode} - a guard, coercing parser, JSON Schema, and seeded
4136
+ * generator from one shape declaration.
4137
+ *
4138
+ * @returns A `CodeBlockNode` contract bundling `schema` / `is` / `parse` / `generate`
4139
+ *
4140
+ * @example
4141
+ * ```ts
4142
+ * import { createCodeBlockContract } from '@src/core'
4143
+ *
4144
+ * const codeBlock = createCodeBlockContract()
4145
+ * codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
4146
+ * ```
4147
+ */
4148
+ function createCodeBlockContract() {
4149
+ return (0, _orkestrel_contract.createContract)(codeBlockShape);
4150
+ }
4151
+ /**
4152
+ * Compiles the {@link thematicBreakShape} into a {@link ContractInterface} for
4153
+ * {@link ThematicBreakNode} - a guard, coercing parser, JSON Schema, and
4154
+ * seeded generator from one shape declaration.
4155
+ *
4156
+ * @returns A `ThematicBreakNode` contract bundling `schema` / `is` / `parse` / `generate`
4157
+ *
4158
+ * @example
4159
+ * ```ts
4160
+ * import { createThematicBreakContract } from '@src/core'
4161
+ *
4162
+ * const thematicBreak = createThematicBreakContract()
4163
+ * thematicBreak.is({ element: 'thematicBreak' }) // true
4164
+ * ```
4165
+ */
4166
+ function createThematicBreakContract() {
4167
+ return (0, _orkestrel_contract.createContract)(thematicBreakShape);
4168
+ }
4169
+ //#endregion
4089
4170
  exports.EMPTY_PROJECTION = EMPTY_PROJECTION;
4090
4171
  exports.MAX_DEPTH = MAX_DEPTH;
4091
4172
  exports.Markdown = Markdown;
@@ -4118,6 +4199,7 @@ exports.isEmphasisNode = isEmphasisNode;
4118
4199
  exports.isEscapable = isEscapable;
4119
4200
  exports.isFenceClose = isFenceClose;
4120
4201
  exports.isFenceWhitespace = isFenceWhitespace;
4202
+ exports.isFlankingWhitespace = isFlankingWhitespace;
4121
4203
  exports.isHeadingNode = isHeadingNode;
4122
4204
  exports.isImageNode = isImageNode;
4123
4205
  exports.isInlineNode = isInlineNode;
@@ -4133,7 +4215,6 @@ exports.isTableStart = isTableStart;
4133
4215
  exports.isTextNode = isTextNode;
4134
4216
  exports.isThematicBreak = isThematicBreak;
4135
4217
  exports.isThematicBreakNode = isThematicBreakNode;
4136
- exports.isWhitespace = isWhitespace;
4137
4218
  exports.joinSources = joinSources;
4138
4219
  exports.lineBreakShape = lineBreakShape;
4139
4220
  exports.listItemMatchShape = listItemMatchShape;