@orkestrel/markdown 0.0.12 → 0.0.14

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