@orkestrel/markdown 0.0.11 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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. 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
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
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
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
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
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
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
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
@@ -451,109 +366,188 @@ var isMarkdownDocument = recordOf({
451
366
  *
452
367
  * @param lines - The markdown lines to parse.
453
368
  * @param depth - The current recursion depth (blockquotes/lists increment it).
369
+ * @param spans - The optional operation-owned node span recorder.
370
+ * @param end - The original-source end of this line run, including a removed terminator.
454
371
  * @returns The parsed block nodes.
455
372
  *
456
373
  * @example
457
374
  * ```ts
458
- * parseBlocks(['# Hi'], 0) // [{ element: 'heading', level: 1, children: [...] }]
375
+ * parseBlocks(splitLines('# Hi'), 0) // [{ element: 'heading', level: 1, children: [...] }]
459
376
  * ```
460
377
  */
461
- function parseBlocks(lines, depth) {
462
- if (depth >= 64) return lines.length > 0 ? [{
463
- element: "paragraph",
464
- children: [{
378
+ function parseBlocks(lines, depth, spans = /* @__PURE__ */ new Map(), end) {
379
+ const text = lines.map((line) => line.text);
380
+ if (depth >= 64) {
381
+ if (lines.length === 0) return [];
382
+ const source = joinSources(lines, "\n");
383
+ const inline = {
465
384
  element: "text",
466
- value: lines.join("\n")
467
- }]
468
- }] : [];
385
+ value: source.text
386
+ };
387
+ const paragraph = {
388
+ element: "paragraph",
389
+ children: [inline]
390
+ };
391
+ const span = projectSpan(source, 0, source.text.length);
392
+ if (span !== void 0) {
393
+ spans.set(inline, span);
394
+ spans.set(paragraph, span);
395
+ }
396
+ return [paragraph];
397
+ }
469
398
  const blocks = [];
470
399
  let index = 0;
471
400
  while (index < lines.length) {
472
- const line = lines[index] ?? "";
401
+ const line = text[index] ?? "";
473
402
  if (isBlankLine(line)) {
474
403
  index += 1;
475
404
  continue;
476
405
  }
477
406
  const fence = extractFence(line);
478
407
  if (fence) {
408
+ const start = index;
479
409
  const body = [];
410
+ let closed = false;
480
411
  index += 1;
481
- while (index < lines.length && !isFenceClose(lines[index] ?? "", fence.marker)) {
482
- body.push(lines[index] ?? "");
412
+ while (index < lines.length && !isFenceClose(text[index] ?? "", fence.marker)) {
413
+ const bodyLine = lines[index];
414
+ if (bodyLine !== void 0) body.push(bodyLine);
483
415
  index += 1;
484
416
  }
485
- index += 1;
486
- blocks.push({
417
+ if (index < lines.length) {
418
+ closed = true;
419
+ index += 1;
420
+ }
421
+ const node = {
487
422
  element: "codeBlock",
488
423
  ...fence.lang === void 0 ? {} : { lang: fence.lang },
489
- code: body.join("\n")
490
- });
424
+ code: joinSources(body, "\n").text
425
+ };
426
+ const source = joinSources(lines.slice(start, index), "\n");
427
+ const span = projectSpan(source, 0, source.text.length);
428
+ if (span !== void 0) spans.set(node, !closed && end !== void 0 ? {
429
+ start: span.start,
430
+ end
431
+ } : span);
432
+ blocks.push(node);
491
433
  continue;
492
434
  }
493
435
  if (isThematicBreak(line)) {
494
- blocks.push({ element: "thematicBreak" });
436
+ const node = { element: "thematicBreak" };
437
+ const source = lines[index];
438
+ const span = source === void 0 ? void 0 : projectSpan(source, 0, source.text.length);
439
+ if (span !== void 0) spans.set(node, span);
440
+ blocks.push(node);
495
441
  index += 1;
496
442
  continue;
497
443
  }
498
444
  const heading = extractHeading(line);
499
445
  if (heading) {
500
- blocks.push({
446
+ const source = lines[index];
447
+ const content = source === void 0 ? {
448
+ text: heading.text,
449
+ segments: []
450
+ } : sliceSource(source, heading.offset, heading.offset + heading.text.length);
451
+ const node = {
501
452
  element: "heading",
502
453
  level: heading.level,
503
- children: parseInline(heading.text)
504
- });
454
+ children: coalesceText(scanInlineSource(content, 0, content.text.length, spans), spans)
455
+ };
456
+ const span = source === void 0 ? void 0 : projectSpan(source, 0, source.text.length);
457
+ if (span !== void 0) spans.set(node, span);
458
+ blocks.push(node);
505
459
  index += 1;
506
460
  continue;
507
461
  }
508
462
  if (isQuote(line)) {
463
+ const start = index;
509
464
  const quoted = [];
510
- while (index < lines.length && isQuote(lines[index] ?? "")) {
511
- quoted.push(stripQuote(lines[index] ?? ""));
465
+ while (index < lines.length && isQuote(text[index] ?? "")) {
466
+ const quotedLine = lines[index];
467
+ if (quotedLine === void 0) break;
468
+ quoted.push(stripQuote(quotedLine));
512
469
  index += 1;
513
470
  }
514
- blocks.push({
471
+ const source = joinSources(lines.slice(start, index), "\n");
472
+ const span = projectSpan(source, 0, source.text.length);
473
+ const node = {
515
474
  element: "blockquote",
516
- children: parseBlocks(quoted, depth + 1)
517
- });
475
+ children: parseBlocks(quoted, depth + 1, spans, index === lines.length && end !== void 0 ? end : span?.end)
476
+ };
477
+ if (span !== void 0) spans.set(node, span);
478
+ blocks.push(node);
518
479
  continue;
519
480
  }
520
- if (isTableStart(line, lines[index + 1])) {
521
- const table = collectTable(lines, index);
481
+ if (isTableStart(line, text[index + 1])) {
482
+ const table = collectTable(lines, index, spans);
522
483
  blocks.push(table.node);
523
484
  index = table.next;
524
485
  continue;
525
486
  }
526
487
  if (extractListItem(line)) {
527
- const list = collectList(lines, index, depth);
488
+ const list = collectList(lines, index, depth, spans, end);
528
489
  blocks.push(list.node);
529
490
  index = list.next;
530
491
  continue;
531
492
  }
493
+ const start = index;
532
494
  const paragraph = [];
533
- while (index < lines.length && !isBlankLine(lines[index] ?? "") && !(isNonEmptyArray(paragraph) && startsBlock(lines, index))) {
534
- paragraph.push(lines[index] ?? "");
495
+ while (index < lines.length && !isBlankLine(text[index] ?? "") && !(isNonEmptyArray(paragraph) && startsBlock(text, index))) {
496
+ const paragraphLine = lines[index];
497
+ if (paragraphLine !== void 0) paragraph.push(paragraphLine);
535
498
  index += 1;
536
499
  }
537
- const source = paragraph.map((paragraphLine, position) => position < paragraph.length - 1 && paragraphLine.endsWith(" ") ? `${paragraphLine.trim()} ` : paragraphLine.trim()).join("\n");
538
- blocks.push({
500
+ const source = joinSources(paragraph.map((paragraphLine, position) => normalizeParagraphLine(paragraphLine, position < paragraph.length - 1)), "\n");
501
+ const node = {
539
502
  element: "paragraph",
540
- children: parseInline(source)
541
- });
503
+ children: coalesceText(scanInlineSource(source, 0, source.text.length, spans), spans)
504
+ };
505
+ const region = joinSources(lines.slice(start, index), "\n");
506
+ const span = projectSpan(region, 0, region.text.length);
507
+ if (span !== void 0) spans.set(node, span);
508
+ blocks.push(node);
542
509
  }
543
510
  return blocks;
544
511
  }
545
512
  /**
546
- * Parses a markdown string into a typed {@link MarkdownDocument} AST via the
513
+ * Parses a markdown string into a typed {@link MarkdownDocument} AST through the
547
514
  * block phase.
548
515
  *
549
516
  * @param markdown - The markdown source to parse.
550
517
  * @returns The parsed document.
518
+ *
519
+ * @example
520
+ * ```ts
521
+ * parseDocument('# Hi') // { element: 'document', children: [{ element: 'heading', ... }] }
522
+ * ```
551
523
  */
552
524
  function parseDocument(markdown) {
553
- return {
525
+ const [document] = parseProvenance(markdown);
526
+ return document;
527
+ }
528
+ /**
529
+ * Parses a markdown string into a document and its original-source spans.
530
+ *
531
+ * @param markdown - The markdown source to parse.
532
+ * @returns The parsed document and its node-identity span map.
533
+ *
534
+ * @example
535
+ * ```ts
536
+ * const [document, spans] = parseProvenance('# Hi')
537
+ * spans.get(document) // { start: 0, end: 4 }
538
+ * ```
539
+ */
540
+ function parseProvenance(markdown) {
541
+ const spans = /* @__PURE__ */ new Map();
542
+ const document = {
554
543
  element: "document",
555
- children: parseBlocks(splitLines(markdown), 0)
544
+ children: parseBlocks(splitLines(markdown), 0, spans, markdown.length)
556
545
  };
546
+ spans.set(document, {
547
+ start: 0,
548
+ end: markdown.length
549
+ });
550
+ return [document, spans];
557
551
  }
558
552
  /**
559
553
  * Parses inline markdown text (emphasis, code spans, links, images, and hard
@@ -561,462 +555,426 @@ function parseDocument(markdown) {
561
555
  *
562
556
  * @param text - The inline markdown text to parse.
563
557
  * @returns The parsed inline nodes.
558
+ *
559
+ * @example
560
+ * ```ts
561
+ * parseInline('a *b*') // [{ element: 'text', value: 'a ' }, { element: 'emphasis', ... }]
562
+ * ```
564
563
  */
565
564
  function parseInline(text) {
566
565
  return coalesceText(scanInline(text, 0, text.length));
567
566
  }
568
567
  //#endregion
569
- //#region src/core/Markdown.ts
568
+ //#region src/core/helpers.ts
570
569
  /**
571
- * A stateful, parsed markdown document - wraps a typed {@link MarkdownDocument} AST
572
- * with the query (`find` / `filter` / `reduce` / iteration), rewrite (`map`), fold, and
573
- * streaming operations {@link MarkdownInterface} declares.
570
+ * Splits a markdown document into offset-bearing lines while normalizing CRLF and
571
+ * bare CR terminators at the line boundary. A single trailing terminator does not
572
+ * yield a final empty line.
574
573
  *
575
- * @remarks
576
- * - **Construction.** Given a `string`, the constructor runs {@link parseDocument} (the
577
- * block phase then the inline phase) to build the AST. Given a {@link MarkdownDocument},
578
- * the document is adopted AS-IS and is NOT re-validated - a caller adopting an
579
- * untrusted value should gate it with `isMarkdownDocument` first.
580
- * - **Immutable.** {@link map} never mutates the stored AST - it returns a NEW `Markdown`
581
- * instance; the document root invariant (`element: 'document'`) always holds.
582
- * - **Traversal order.** {@link walk} and the `find` / `filter` / `reduce` queries built
583
- * on it walk the AST depth-first, pre-order, root-inclusive (via {@link walkNodes});
584
- * `stream` is shallow - only the document's direct block children.
574
+ * @param markdown - The raw markdown source
575
+ * @returns The document's lines with their original-string coordinates
585
576
  *
586
577
  * @example
587
578
  * ```ts
588
- * import { Markdown, isHeadingNode, renderMarkdown } from '@src/core'
589
- *
590
- * const markdown = new Markdown('# Title\n\nA **bold** [link](https://x.dev).')
591
- * const heading = markdown.find(isHeadingNode) // the HeadingNode, or undefined
592
- * const shouted = markdown.map((node) =>
593
- * node.element === 'text' ? { element: 'text', value: node.value.toUpperCase() } : node,
594
- * )
595
- * renderMarkdown(shouted.document) // '# TITLE\n\nA **BOLD** [LINK](https://x.dev).'
579
+ * splitLines('a\r\nb') // [{ text: 'a', segments: [{ offset: 0, start: 0, end: 1 }] }, ...]
596
580
  * ```
597
581
  */
598
- var Markdown = class Markdown {
599
- #document;
600
- constructor(input) {
601
- this.#document = typeof input === "string" ? parseDocument(input) : input;
602
- }
603
- /** The stored {@link MarkdownDocument} AST root. */
604
- get document() {
605
- return this.#document;
582
+ function splitLines(markdown) {
583
+ const lines = [];
584
+ let start = 0;
585
+ let index = 0;
586
+ while (index < markdown.length) {
587
+ const character = markdown[index];
588
+ if (character !== "\r" && character !== "\n") {
589
+ index += 1;
590
+ continue;
591
+ }
592
+ lines.push({
593
+ text: markdown.slice(start, index),
594
+ segments: [{
595
+ offset: 0,
596
+ start,
597
+ end: index
598
+ }]
599
+ });
600
+ index += character === "\r" && markdown[index + 1] === "\n" ? 2 : 1;
601
+ start = index;
606
602
  }
607
- /**
608
- * THE deep traversal - a lazy, depth-first, pre-order, root-inclusive generator
609
- * over every {@link MarkdownNode} in the document. `find` / `filter` / `reduce`
610
- * all iterate this single traversal.
611
- *
612
- * @example
613
- * ```ts
614
- * for (const node of markdown.walk()) {
615
- * // every node, depth-first, pre-order, root-inclusive
616
- * }
617
- *
618
- * // also consumable by for-await - JS accepts a sync iterable in for-await
619
- * for await (const node of markdown.walk()) {
620
- * // same sequence, no separate async iterator needed
621
- * }
622
- * ```
623
- */
624
- *walk() {
625
- yield* walkNodes(this.#document);
603
+ lines.push({
604
+ text: markdown.slice(start),
605
+ segments: [{
606
+ offset: 0,
607
+ start,
608
+ end: markdown.length
609
+ }]
610
+ });
611
+ if (lines.length > 1 && lines[lines.length - 1]?.text === "") lines.pop();
612
+ return lines;
613
+ }
614
+ /**
615
+ * Slices derived markdown text and narrows each intersecting source segment to the
616
+ * same text-relative range.
617
+ *
618
+ * @param source - The offset-bearing source to slice
619
+ * @param from - The inclusive text offset
620
+ * @param to - The exclusive text offset
621
+ * @returns The sliced text and its narrowed original-string segments
622
+ *
623
+ * @example
624
+ * ```ts
625
+ * sliceSource({ text: 'abc', segments: [{ offset: 0, start: 4, end: 7 }] }, 1, 3)
626
+ * // { text: 'bc', segments: [{ offset: 0, start: 5, end: 7 }] }
627
+ * ```
628
+ */
629
+ function sliceSource(source, from, to) {
630
+ const start = Math.max(0, Math.min(from, source.text.length));
631
+ const end = Math.max(start, Math.min(to, source.text.length));
632
+ const segments = [];
633
+ for (let index = 0; index < source.segments.length; index += 1) {
634
+ const segment = source.segments[index];
635
+ if (segment === void 0) continue;
636
+ const next = source.segments[index + 1];
637
+ const limit = Math.min(segment.offset + (segment.end - segment.start), next === void 0 ? source.text.length : next.offset);
638
+ const overlapStart = Math.max(start, segment.offset);
639
+ const overlapEnd = Math.min(end, limit);
640
+ const empty = segment.offset === limit && overlapStart === segment.offset;
641
+ if (overlapStart >= overlapEnd && !empty) continue;
642
+ const originalStart = overlapStart === limit ? segment.end : Math.min(segment.end, segment.start + overlapStart - segment.offset);
643
+ const originalEnd = overlapEnd === limit ? segment.end : Math.min(segment.end, segment.start + overlapEnd - segment.offset);
644
+ segments.push({
645
+ offset: overlapStart - start,
646
+ start: originalStart,
647
+ end: originalEnd
648
+ });
626
649
  }
627
- find(predicate) {
628
- for (const node of this.walk()) if (predicate(node)) return node;
650
+ return {
651
+ text: source.text.slice(start, end),
652
+ segments
653
+ };
654
+ }
655
+ /**
656
+ * Joins offset-bearing markdown sources while mapping a separator to the original
657
+ * region between adjacent mapped sources.
658
+ *
659
+ * @param sources - The sources to join
660
+ * @param separator - The derived text inserted between sources
661
+ * @returns The joined text and every source-backed segment
662
+ *
663
+ * @example
664
+ * ```ts
665
+ * joinSources(splitLines('a\nb'), '\n')
666
+ * // { text: 'a\nb', segments: [...] }
667
+ * ```
668
+ */
669
+ function joinSources(sources, separator) {
670
+ let text = "";
671
+ const segments = [];
672
+ for (let index = 0; index < sources.length; index += 1) {
673
+ const source = sources[index];
674
+ if (source === void 0) continue;
675
+ if (index > 0) {
676
+ const previous = sources[index - 1];
677
+ const left = previous?.segments[previous.segments.length - 1];
678
+ const right = source.segments[0];
679
+ if (separator.length > 0 && left !== void 0 && right !== void 0 && left.end < right.start) segments.push({
680
+ offset: text.length,
681
+ start: left.end,
682
+ end: right.start
683
+ });
684
+ text += separator;
685
+ }
686
+ for (const segment of source.segments) segments.push({
687
+ offset: text.length + segment.offset,
688
+ start: segment.start,
689
+ end: segment.end
690
+ });
691
+ text += source.text;
629
692
  }
630
- filter(predicate) {
631
- const out = [];
632
- for (const node of this.walk()) if (predicate(node)) out.push(node);
633
- return out;
634
- }
635
- /** Rewrites the AST bottom-up (copy-on-write) and returns a new {@link Markdown}. */
636
- map(rewrite) {
637
- return new Markdown(rewriteDocument(this.#document, rewrite));
638
- }
639
- /** Folds the AST depth-first, pre-order into an accumulator. */
640
- reduce(callback, initial) {
641
- let accumulator = initial;
642
- for (const node of this.walk()) accumulator = callback(accumulator, node);
643
- return accumulator;
644
- }
645
- /** Runs a total catamorphism over the document using a {@link MarkdownHandlers} table. */
646
- fold(handlers) {
647
- return foldNode(this.#document, handlers, 0);
648
- }
649
- /**
650
- * A web-standard {@link ReadableStream} over the document's top-level block nodes
651
- * (shallow, source order) - a fresh, pull-based source per call: one block is
652
- * enqueued per `pull`, so a slow reader's backpressure is respected. Cancellable,
653
- * async-iterable wherever the platform supports it (Node, Deno), and pipeable
654
- * through any {@link TransformStream} / {@link WritableStream}.
655
- *
656
- * @example
657
- * ```ts
658
- * // universal - works in every ReadableStream-supporting environment
659
- * const reader = markdown.stream().getReader()
660
- * for (let result = await reader.read(); !result.done; result = await reader.read()) {
661
- * console.log(result.value) // one BlockNode
662
- * }
663
- *
664
- * // Node / Deno / Firefox support async iteration of ReadableStream natively;
665
- * // other environments should use the reader loop above instead.
666
- * for await (const block of markdown.stream()) {
667
- * console.log(block)
668
- * }
669
- * ```
670
- */
671
- stream() {
672
- const blocks = this.#document.children;
673
- let index = 0;
674
- return new ReadableStream({ pull(controller) {
675
- if (index < blocks.length) {
676
- const block = blocks[index];
677
- if (block === void 0) {
678
- controller.close();
679
- return;
680
- }
681
- controller.enqueue(block);
682
- index += 1;
683
- } else controller.close();
684
- } });
685
- }
686
- };
687
- //#endregion
688
- //#region src/core/shapers.ts
693
+ return {
694
+ text,
695
+ segments
696
+ };
697
+ }
689
698
  /**
690
- * The shape of a {@link TextNode} - a plain-text leaf inline run.
699
+ * Projects a derived text range through its segments to a half-open region of the
700
+ * original markdown string.
701
+ *
702
+ * @param source - The offset-bearing source carrying the range
703
+ * @param from - The inclusive derived-text boundary
704
+ * @param to - The exclusive derived-text boundary
705
+ * @returns The original-string span, or `undefined` when either boundary is unmapped
691
706
  *
692
707
  * @example
693
708
  * ```ts
694
- * import { createContract } from '@orkestrel/contract'
695
- * import { textShape } from '@src/core'
696
- *
697
- * const text = createContract(textShape)
698
- * text.is({ element: 'text', value: 'hi' }) // true
709
+ * projectSpan({ text: 'a', segments: [{ offset: 0, start: 4, end: 5 }] }, 0, 1)
710
+ * // { start: 4, end: 5 }
699
711
  * ```
700
712
  */
701
- var textShape = objectShape({
702
- element: literalShape(["text"]),
703
- value: stringShape()
704
- });
713
+ function projectSpan(source, from, to) {
714
+ if (from < 0 || to < from || to > source.text.length) return void 0;
715
+ let start;
716
+ let end;
717
+ for (let index = 0; index < source.segments.length; index += 1) {
718
+ const segment = source.segments[index];
719
+ if (segment === void 0) continue;
720
+ const next = source.segments[index + 1];
721
+ const limit = Math.min(segment.offset + (segment.end - segment.start), next === void 0 ? source.text.length : next.offset);
722
+ if (from === to && from >= segment.offset && from <= limit) {
723
+ if (next !== void 0 && from === next.offset) continue;
724
+ const position = from === limit ? segment.end : Math.min(segment.end, segment.start + from - segment.offset);
725
+ return {
726
+ start: position,
727
+ end: position
728
+ };
729
+ }
730
+ if (start === void 0 && from >= segment.offset && from < limit) start = segment.start + from - segment.offset;
731
+ if (to > segment.offset && to <= limit) end = to === limit ? segment.end : Math.min(segment.end, segment.start + to - segment.offset);
732
+ }
733
+ return start === void 0 || end === void 0 ? void 0 : {
734
+ start,
735
+ end
736
+ };
737
+ }
705
738
  /**
706
- * The shape of a {@link CodeSpanNode} - an inline code span (`` `code` ``).
739
+ * Trims an offset-bearing source without losing the coordinates of its retained text.
740
+ *
741
+ * @param source - The source to trim
742
+ * @returns The trimmed text and its narrowed original-string segments
707
743
  *
708
744
  * @example
709
745
  * ```ts
710
- * import { createContract } from '@orkestrel/contract'
711
- * import { codeSpanShape } from '@src/core'
712
- *
713
- * const codeSpan = createContract(codeSpanShape)
714
- * codeSpan.is({ element: 'codeSpan', value: 'const x = 1' }) // true
746
+ * trimSource({ text: ' a ', segments: [{ offset: 0, start: 4, end: 7 }] })
747
+ * // { text: 'a', segments: [{ offset: 0, start: 5, end: 6 }] }
715
748
  * ```
716
749
  */
717
- var codeSpanShape = objectShape({
718
- element: literalShape(["codeSpan"]),
719
- value: stringShape()
720
- });
750
+ function trimSource(source) {
751
+ const start = source.text.length - source.text.trimStart().length;
752
+ const end = source.text.trimEnd().length;
753
+ return sliceSource(source, start, Math.max(start, end));
754
+ }
721
755
  /**
722
- * The shape of a {@link LineBreakNode} - a GFM hard line-break leaf.
756
+ * Normalizes one paragraph line while retaining the full source run consumed by a
757
+ * trailing-space hard break.
758
+ *
759
+ * @param source - The offset-bearing paragraph line
760
+ * @param breaks - If `true`, preserves a trailing run of at least two spaces as the
761
+ * scanner's two-space hard-break syntax; if `false`, trims the line normally
762
+ * @returns The normalized line and its original-string segments
723
763
  *
724
764
  * @example
725
765
  * ```ts
726
- * import { createContract } from '@orkestrel/contract'
727
- * import { lineBreakShape } from '@src/core'
728
- *
729
- * const lineBreak = createContract(lineBreakShape)
730
- * lineBreak.is({ element: 'break' }) // true
766
+ * normalizeParagraphLine(splitLines('text \nnext')[0], true).text // 'text '
731
767
  * ```
732
768
  */
733
- var lineBreakShape = objectShape({ element: literalShape(["break"]) });
769
+ function normalizeParagraphLine(source, breaks) {
770
+ if (!breaks || !source.text.endsWith(" ")) return trimSource(source);
771
+ const contentEnd = source.text.trimEnd().length;
772
+ const content = trimSource(sliceSource(source, 0, contentEnd));
773
+ const span = projectSpan(source, contentEnd, source.text.length);
774
+ return joinSources([content, {
775
+ text: " ",
776
+ segments: span === void 0 ? [] : [{
777
+ offset: 0,
778
+ start: span.start,
779
+ end: span.end
780
+ }]
781
+ }], "");
782
+ }
734
783
  /**
735
- * The shape of a {@link CodeBlockNode} - a fenced code block. `lang` is
736
- * optional (absent when the opening fence carries no info-string).
784
+ * Counts the leading space / tab characters on `line` (a tab counts as one) - the
785
+ * indent that decides whether a list item's continuation belongs to the item.
786
+ *
787
+ * @param line - The line to measure
788
+ * @returns The number of leading space / tab characters
737
789
  *
738
790
  * @example
739
791
  * ```ts
740
- * import { createContract } from '@orkestrel/contract'
741
- * import { codeBlockShape } from '@src/core'
742
- *
743
- * const codeBlock = createContract(codeBlockShape)
744
- * codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
745
- * codeBlock.is({ element: 'codeBlock', code: 'x', lang: 'ts' }) // true
792
+ * countIndent(' text') // 2
746
793
  * ```
747
794
  */
748
- var codeBlockShape = objectShape({
749
- element: literalShape(["codeBlock"]),
750
- lang: optionalShape(stringShape()),
751
- code: stringShape()
752
- });
795
+ function countIndent(line) {
796
+ let count = 0;
797
+ for (const character of line) if (character === " " || character === " ") count += 1;
798
+ else break;
799
+ return count;
800
+ }
753
801
  /**
754
- * The shape of a {@link ThematicBreakNode} - a horizontal rule. Carries no
755
- * fields beyond its `element` discriminant.
802
+ * Checks whether `character` is whitespace under the emphasis flanking rule - a space, a
803
+ * tab, or a newline.
804
+ *
805
+ * @param character - The character to test
806
+ * @returns True if the flanking rule counts it as whitespace; false otherwise
756
807
  *
757
808
  * @example
758
809
  * ```ts
759
- * import { createContract } from '@orkestrel/contract'
760
- * import { thematicBreakShape } from '@src/core'
761
- *
762
- * const thematicBreak = createContract(thematicBreakShape)
763
- * thematicBreak.is({ element: 'thematicBreak' }) // true
810
+ * isFlankingWhitespace(' ') // true
811
+ * isFlankingWhitespace('a') // false
764
812
  * ```
765
813
  */
766
- var thematicBreakShape = objectShape({ element: literalShape(["thematicBreak"]) });
814
+ function isFlankingWhitespace(character) {
815
+ return character === " " || character === " " || character === "\n";
816
+ }
767
817
  /**
768
- * The shape of a {@link TableAlign} - the per-column GFM table alignment
769
- * literal.
818
+ * Checks whether `character` is escapable by a leading backslash - the ASCII punctuation
819
+ * markdown gives meaning to (so `\*` becomes `*` but `\.` stays `\.`).
820
+ *
821
+ * @param character - The single character after a backslash
822
+ * @returns True if a backslash before it is an escape; false otherwise
770
823
  *
771
824
  * @example
772
825
  * ```ts
773
- * import { createContract } from '@orkestrel/contract'
774
- * import { tableAlignShape } from '@src/core'
775
- *
776
- * const tableAlign = createContract(tableAlignShape)
777
- * tableAlign.is('left') // true
778
- * tableAlign.is('center') // true
779
- * tableAlign.is('top') // false
826
+ * isEscapable('*') // true
827
+ * isEscapable('a') // false
780
828
  * ```
781
829
  */
782
- var tableAlignShape = literalShape([
783
- "left",
784
- "right",
785
- "center"
786
- ]);
830
+ function isEscapable(character) {
831
+ return /[\\`*_{}[\]()#+\-.!>~|]/.test(character);
832
+ }
787
833
  /**
788
- * The shape of {@link ListItemMatch} - the parsed parts of a single list-item
789
- * line the block phase's list detector returns. Fully non-recursive (no
790
- * nested node fields), so every field shapes directly.
834
+ * Checks whether `line` is blank - empty, or containing only whitespace - the markdown
835
+ * definition of a blank line that block parsing uses to separate paragraphs, skip
836
+ * gaps, and end list continuations.
837
+ *
838
+ * @param line - The candidate line
839
+ * @returns True if the line is blank; false otherwise
791
840
  *
792
841
  * @example
793
842
  * ```ts
794
- * import { createContract } from '@orkestrel/contract'
795
- * import { listItemMatchShape } from '@src/core'
796
- *
797
- * const listItemParts = createContract(listItemMatchShape)
798
- * listItemParts.is({ ordered: false, start: 1, content: 'hi', indent: 0, marker: 2 }) // true
843
+ * isBlankLine(' ') // true
799
844
  * ```
800
845
  */
801
- var listItemMatchShape = objectShape({
802
- ordered: booleanShape(),
803
- start: integerShape(),
804
- content: stringShape(),
805
- indent: integerShape(),
806
- marker: integerShape()
807
- });
808
- //#endregion
809
- //#region src/core/factories.ts
846
+ function isBlankLine(line) {
847
+ return isEmptyString(line.trim());
848
+ }
810
849
  /**
811
- * Create an HTML-to-markdown projection with absent fields defaulted from
812
- * {@link EMPTY_PROJECTION} and the block/inline exclusivity invariant enforced.
813
- *
814
- * @remarks
815
- * A block-bearing projection cannot also expose inline content. Callers may provide
816
- * both views, but `inlines` is flushed whenever `blocks` is non-empty.
850
+ * Checks whether `line` is a blockquote line (`>` optionally indented up to three spaces) -
851
+ * its content is de-quoted by {@link stripQuote}.
817
852
  *
818
- * @param parts - The projection fields to provide
819
- * @returns A complete invariant-preserving projection
853
+ * @param line - The candidate line
854
+ * @returns True if the line begins a blockquote; false otherwise
820
855
  *
821
856
  * @example
822
857
  * ```ts
823
- * createProjection({
824
- * blocks: [{ element: 'thematicBreak' }],
825
- * inlines: [{ element: 'text', value: 'discarded' }],
826
- * })
827
- * // { blocks: [{ element: 'thematicBreak' }], inlines: [], text: '', cells: [], rows: [] }
858
+ * isQuote('> quoted') // true
828
859
  * ```
829
860
  */
830
- function createProjection(parts = {}) {
831
- const blocks = parts.blocks ?? EMPTY_PROJECTION.blocks;
832
- return {
833
- blocks,
834
- inlines: blocks.length === 0 ? parts.inlines ?? EMPTY_PROJECTION.inlines : [],
835
- text: parts.text ?? EMPTY_PROJECTION.text,
836
- cells: parts.cells ?? EMPTY_PROJECTION.cells,
837
- rows: parts.rows ?? EMPTY_PROJECTION.rows
838
- };
861
+ function isQuote(line) {
862
+ return /^\s{0,3}>/.test(line);
839
863
  }
840
864
  /**
841
- * Create a stateful markdown handle from a markdown string or an already-parsed
842
- * {@link MarkdownDocument} - a typed AST plus the query, rewrite, and fold operations
843
- * {@link MarkdownInterface} exposes.
844
- *
845
- * @remarks
846
- * Given a `string`, runs a block phase (headings / paragraphs / lists / GFM tables /
847
- * fenced code / blockquotes / thematic breaks) then an inline phase (emphasis /
848
- * inline code / links / images / hard breaks) to build a render-agnostic
849
- * {@link MarkdownDocument}. Given a
850
- * {@link MarkdownDocument}, adopts it AS-IS without re-validation - gate an untrusted
851
- * value with `isMarkdownDocument` first. Pure + total parse (malformed markdown
852
- * degrades to text, never throws) and zero-dependency - a hand-written scanner, no
853
- * regex-only structural parse, linear-time (no ReDoS).
865
+ * Checks whether `line` closes a fence opened by `marker` - the same fence character, a run
866
+ * at least as long, and nothing else but surrounding whitespace.
854
867
  *
855
- * @param input - A markdown string to parse, or an already-parsed {@link MarkdownDocument}
856
- * @returns A working {@link MarkdownInterface}
868
+ * @param line - The candidate closing line
869
+ * @param marker - The opening fence's marker run (from {@link extractFence})
870
+ * @returns True if `line` closes the fence; false otherwise
857
871
  *
858
872
  * @example
859
873
  * ```ts
860
- * import { createMarkdown } from '@src/core'
861
- *
862
- * const markdown = createMarkdown('# Hi\n\nRead the [guide](./guide.md).')
863
- * markdown.document.children[0] // { element: 'heading', ... }
874
+ * isFenceClose('```', '```') // true
864
875
  * ```
865
876
  */
866
- function createMarkdown(input) {
867
- return new Markdown(input);
877
+ function isFenceClose(line, marker) {
878
+ const character = marker[0] === "~" ? "~" : "`";
879
+ let index = 0;
880
+ while (index < line.length && isFenceWhitespace(line[index])) index++;
881
+ let run = 0;
882
+ while (index < line.length && line[index] === character) {
883
+ run++;
884
+ index++;
885
+ }
886
+ if (run < marker.length) return false;
887
+ while (index < line.length && isFenceWhitespace(line[index])) index++;
888
+ return index === line.length;
868
889
  }
869
890
  /**
870
- * Compile the {@link textShape} into a {@link ContractInterface} for
871
- * {@link TextNode} - a guard, coercing parser, JSON Schema, and seeded
872
- * generator from one shape declaration (AGENTS §14).
891
+ * Checks whether `character` is a regex-`\s`-equivalent whitespace character - the
892
+ * character class {@link isFenceClose}'s scan treats as surrounding padding.
873
893
  *
874
- * @returns A `TextNode` contract bundling `schema` / `is` / `parse` / `generate`
894
+ * @param character - The single character to test, or `undefined` past the end of a line
895
+ * @returns True if it is whitespace; false otherwise
875
896
  *
876
897
  * @example
877
898
  * ```ts
878
- * import { createTextContract } from '@src/core'
879
- *
880
- * const text = createTextContract()
881
- * text.is({ element: 'text', value: 'hi' }) // true
899
+ * isFenceWhitespace(' ') // true
900
+ * isFenceWhitespace(undefined) // false
882
901
  * ```
883
902
  */
884
- function createTextContract() {
885
- return createContract(textShape);
903
+ function isFenceWhitespace(character) {
904
+ return character === " " || character === " " || character === "\n" || character === "\r" || character === "\f" || character === "\v";
886
905
  }
887
906
  /**
888
- * Compile the {@link codeSpanShape} into a {@link ContractInterface} for
889
- * {@link CodeSpanNode} - a guard, coercing parser, JSON Schema, and seeded
890
- * generator from one shape declaration (AGENTS §14).
907
+ * Checks whether `line` is a thematic break (horizontal rule) - three or more of the SAME
908
+ * marker `-`, `*`, or `_` (optionally space-separated) and nothing else (`---`,
909
+ * `***`, `___`, `- - -`).
891
910
  *
892
- * @returns A `CodeSpanNode` contract bundling `schema` / `is` / `parse` / `generate`
911
+ * @param line - The candidate line
912
+ * @returns True if the line is a thematic break; false otherwise
893
913
  *
894
914
  * @example
895
915
  * ```ts
896
- * import { createCodeSpanContract } from '@src/core'
897
- *
898
- * const codeSpan = createCodeSpanContract()
899
- * codeSpan.is({ element: 'codeSpan', value: 'const x = 1' }) // true
900
- * ```
901
- */
902
- function createCodeSpanContract() {
903
- return createContract(codeSpanShape);
904
- }
905
- /**
906
- * Compile the {@link lineBreakShape} into a {@link ContractInterface} for
907
- * {@link LineBreakNode}.
908
- *
909
- * @returns A `LineBreakNode` contract bundling `schema` / `is` / `parse` / `generate`
910
- *
911
- * @example
912
- * ```ts
913
- * import { createLineBreakContract } from '@src/core'
914
- *
915
- * createLineBreakContract().is({ element: 'break' }) // true
916
- * ```
917
- */
918
- function createLineBreakContract() {
919
- return createContract(lineBreakShape);
920
- }
921
- /**
922
- * Compile the {@link codeBlockShape} into a {@link ContractInterface} for
923
- * {@link CodeBlockNode} - a guard, coercing parser, JSON Schema, and seeded
924
- * generator from one shape declaration (AGENTS §14).
925
- *
926
- * @returns A `CodeBlockNode` contract bundling `schema` / `is` / `parse` / `generate`
927
- *
928
- * @example
929
- * ```ts
930
- * import { createCodeBlockContract } from '@src/core'
931
- *
932
- * const codeBlock = createCodeBlockContract()
933
- * codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
934
- * ```
935
- */
936
- function createCodeBlockContract() {
937
- return createContract(codeBlockShape);
938
- }
939
- /**
940
- * Compile the {@link thematicBreakShape} into a {@link ContractInterface} for
941
- * {@link ThematicBreakNode} - a guard, coercing parser, JSON Schema, and
942
- * seeded generator from one shape declaration (AGENTS §14).
943
- *
944
- * @returns A `ThematicBreakNode` contract bundling `schema` / `is` / `parse` / `generate`
945
- *
946
- * @example
947
- * ```ts
948
- * import { createThematicBreakContract } from '@src/core'
949
- *
950
- * const thematicBreak = createThematicBreakContract()
951
- * thematicBreak.is({ element: 'thematicBreak' }) // true
952
- * ```
953
- */
954
- function createThematicBreakContract() {
955
- return createContract(thematicBreakShape);
956
- }
957
- //#endregion
958
- //#region src/core/helpers.ts
959
- /**
960
- * Normalize line endings to `\n` and split a markdown document into its lines - CRLF
961
- * (`\r\n`) and bare CR (`\r`) both collapse to `\n` first, so a Windows-origin
962
- * document parses identically. A single trailing newline does not yield a final
963
- * empty line.
964
- *
965
- * @param markdown - The raw markdown source
966
- * @returns The document's lines, line-terminators stripped
967
- *
968
- * @example
969
- * ```ts
970
- * splitLines('a\r\nb\nc') // ['a', 'b', 'c']
916
+ * isThematicBreak('---') // true
971
917
  * ```
972
918
  */
973
- function splitLines(markdown) {
974
- const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
975
- if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
976
- return lines;
919
+ function isThematicBreak(line) {
920
+ const stripped = line.trim().replace(/\s+/g, "");
921
+ if (stripped.length < 3) return false;
922
+ const marker = stripped[0];
923
+ if (marker !== "-" && marker !== "*" && marker !== "_") return false;
924
+ return [...stripped].every((character) => character === marker);
977
925
  }
978
926
  /**
979
- * The count of leading space / tab characters on `line` (a tab counts as one) - the
980
- * indent that decides whether a list item's continuation belongs to the item.
927
+ * Checks whether the pair (`header`, `delimiter`) opens a GFM table - `delimiter` is a row of
928
+ * `|`-separated cells each matching `:?-+:?`, the GFM rule that a table requires a
929
+ * header row IMMEDIATELY followed by a delimiter row.
981
930
  *
982
- * @param line - The line to measure
983
- * @returns The number of leading space / tab characters
931
+ * @param header - The candidate header line
932
+ * @param delimiter - The line after it (the candidate delimiter)
933
+ * @returns True if the two lines open a table; false otherwise
984
934
  *
985
935
  * @example
986
936
  * ```ts
987
- * countIndent(' text') // 2
937
+ * isTableStart('| a |', '| - |') // true
988
938
  * ```
989
939
  */
990
- function countIndent(line) {
991
- let count = 0;
992
- for (const character of line) if (character === " " || character === " ") count += 1;
993
- else break;
994
- return count;
940
+ function isTableStart(header, delimiter) {
941
+ if (delimiter === void 0 || !header.includes("|")) return false;
942
+ const cells = splitTableRow(delimiter);
943
+ if (cells.length === 0) return false;
944
+ return cells.every((cell) => /^:?-+:?$/.test(cell.trim()));
995
945
  }
996
946
  /**
997
- * Extract an ATX heading line (`#` … `######` followed by text) into its
998
- * `{ level, text }`, or `undefined` when `line` is not a heading. A run of more than 6
999
- * `#`s, or `#`s not followed by whitespace + text, is not a
1000
- * heading; an optional closing `###` run is stripped.
947
+ * Extracts an ATX heading line (`#` … `######` followed by text) into its level,
948
+ * trimmed text, and the text's offset inside the line. A run of more than 6 `#`s, or
949
+ * `#`s not followed by whitespace + text, is not a heading; an optional closing
950
+ * `###` run is stripped.
1001
951
  *
1002
952
  * @param line - The candidate line
1003
- * @returns The heading level (1–6) and its raw inline text, or `undefined`
953
+ * @returns The heading level (1–6), raw inline text, and text offset, or `undefined`
1004
954
  *
1005
955
  * @example
1006
956
  * ```ts
1007
- * extractHeading('## Title') // { level: 2, text: 'Title' }
957
+ * extractHeading('## Title') // { level: 2, text: 'Title', offset: 3 }
1008
958
  * ```
1009
959
  */
1010
960
  function extractHeading(line) {
1011
- const match = /^(#{1,6})(?:\s+(.*))?$/.exec(line.trimStart());
961
+ const trimmed = line.trimStart();
962
+ const match = /^(#{1,6})(?:\s+(.*))?$/.exec(trimmed);
1012
963
  if (!match || match[1] === void 0) return void 0;
964
+ const level = match[1].length;
965
+ const raw = match[2] ?? "";
966
+ const withoutClosing = raw.replace(/\s+#+\s*$/, "");
967
+ const text = withoutClosing.trim();
968
+ const found = raw.length === 0 ? trimmed.length : trimmed.indexOf(raw, level);
969
+ const content = found < 0 ? trimmed.length : found;
1013
970
  return {
1014
- level: match[1].length,
1015
- text: (match[2] ?? "").replace(/\s+#+\s*$/, "").trim()
971
+ level,
972
+ text,
973
+ offset: line.length - trimmed.length + content + withoutClosing.length - withoutClosing.trimStart().length
1016
974
  };
1017
975
  }
1018
976
  /**
1019
- * Extract a fenced-code opening line (```` ``` ```` or `~~~`, optionally with an info
977
+ * Extracts a fenced-code opening line (```` ``` ```` or `~~~`, optionally with an info
1020
978
  * string) into its `{ marker, lang }`, or `undefined` when `line` is not a fence
1021
979
  * opener. `marker` is the exact fence run (the closer must match the same character +
1022
980
  * at least the same length); `lang` is the first word of the info string.
@@ -1041,7 +999,7 @@ function extractFence(line) {
1041
999
  };
1042
1000
  }
1043
1001
  /**
1044
- * Extract a list-item line (`-` / `*` / `+` bullet, or `1.` / `1)` ordinal, followed by
1002
+ * Extracts a list-item line (`-` / `*` / `+` bullet, or `1.` / `1)` ordinal, followed by
1045
1003
  * a space) into its {@link ListItemMatch}, or `undefined` when `line` is not a list
1046
1004
  * item. `content` is the text after the marker; `marker` is the full marker-plus-space
1047
1005
  * width (for measuring a continuation's indent).
@@ -1081,24 +1039,27 @@ function extractListItem(line) {
1081
1039
  }
1082
1040
  }
1083
1041
  /**
1084
- * Strip one level of blockquote marker (`>` plus one optional following space) from a
1085
- * blockquote line, so the de-quoted lines re-parse as nested blocks.
1042
+ * Strips one level of blockquote marker (`>` plus one optional following space) from
1043
+ * an offset-bearing blockquote line, so the de-quoted source re-parses as nested
1044
+ * blocks without losing its original coordinates.
1086
1045
  *
1087
- * @param line - A blockquote line (per {@link isQuote})
1088
- * @returns The line with its leading `>` (and one space) removed
1046
+ * @param source - A blockquote line (per {@link isQuote})
1047
+ * @returns The source with its leading `>` and optional space removed
1089
1048
  *
1090
1049
  * @example
1091
1050
  * ```ts
1092
- * stripQuote('> text') // 'text'
1051
+ * stripQuote({ text: '> text', segments: [{ offset: 0, start: 0, end: 6 }] })
1052
+ * // { text: 'text', segments: [{ offset: 0, start: 2, end: 6 }] }
1093
1053
  * ```
1094
1054
  */
1095
- function stripQuote(line) {
1096
- return line.replace(/^\s{0,3}>\s?/, "");
1055
+ function stripQuote(source) {
1056
+ return sliceSource(source, (/^\s{0,3}>\s?/.exec(source.text)?.[0] ?? "").length, source.text.length);
1097
1057
  }
1098
1058
  /**
1099
- * Split one GFM table row into its cell strings - outer pipes are optional, an escaped
1059
+ * Splits one GFM table row into its cell strings - outer pipes are optional, an escaped
1100
1060
  * pipe (`\|`) inside a cell is NOT a separator (it becomes a literal `|`), and the
1101
- * empty leading / trailing cell produced by an outer `|` is dropped.
1061
+ * empty leading / trailing cell produced by an outer `|` is dropped. Derives the string
1062
+ * form from {@link splitTableSources}, which owns the escaped-pipe splitting rule.
1102
1063
  *
1103
1064
  * @param row - The raw table row line
1104
1065
  * @returns The row's cells, in column order
@@ -1109,26 +1070,59 @@ function stripQuote(line) {
1109
1070
  * ```
1110
1071
  */
1111
1072
  function splitTableRow(row) {
1073
+ return splitTableSources({
1074
+ text: row,
1075
+ segments: []
1076
+ }).map((cell) => cell.text);
1077
+ }
1078
+ /**
1079
+ * Splits an offset-bearing GFM table row into offset-bearing cells, retaining the
1080
+ * complete source spelling of an escaped pipe while exposing its literal value.
1081
+ *
1082
+ * @param row - The offset-bearing table row
1083
+ * @returns The row's cells with their original-string coordinates
1084
+ *
1085
+ * @example
1086
+ * ```ts
1087
+ * splitTableSources(splitLines('| a\\|b |')[0]).map((cell) => cell.text) // [' a|b ']
1088
+ * ```
1089
+ */
1090
+ function splitTableSources(row) {
1091
+ const source = trimSource(row);
1112
1092
  const cells = [];
1113
- let current = "";
1114
- const trimmed = row.trim();
1115
- for (let index = 0; index < trimmed.length; index += 1) {
1116
- const character = trimmed[index];
1117
- if (character === "\\" && trimmed[index + 1] === "|") {
1118
- current += "|";
1093
+ let pieces = [];
1094
+ let start = 0;
1095
+ for (let index = 0; index < source.text.length; index += 1) {
1096
+ const character = source.text[index];
1097
+ if (character === "\\" && source.text[index + 1] === "|") {
1098
+ pieces.push(sliceSource(source, start, index));
1099
+ const span = projectSpan(source, index, index + 2);
1100
+ pieces.push({
1101
+ text: "|",
1102
+ segments: span === void 0 ? [] : [{
1103
+ offset: 0,
1104
+ start: span.start,
1105
+ end: span.end
1106
+ }]
1107
+ });
1119
1108
  index += 1;
1120
- } else if (character === "|") {
1121
- cells.push(current);
1122
- current = "";
1123
- } else current += character;
1109
+ start = index + 1;
1110
+ continue;
1111
+ }
1112
+ if (character !== "|") continue;
1113
+ pieces.push(sliceSource(source, start, index));
1114
+ cells.push(joinSources(pieces, ""));
1115
+ pieces = [];
1116
+ start = index + 1;
1124
1117
  }
1125
- cells.push(current);
1126
- if (isNonEmptyArray(cells) && isEmptyString((cells[0] ?? "").trim())) cells.shift();
1127
- if (isNonEmptyArray(cells) && isEmptyString((cells[cells.length - 1] ?? "").trim())) cells.pop();
1118
+ pieces.push(sliceSource(source, start, source.text.length));
1119
+ cells.push(joinSources(pieces, ""));
1120
+ if (isNonEmptyArray(cells) && isEmptyString((cells[0]?.text ?? "").trim())) cells.shift();
1121
+ if (isNonEmptyArray(cells) && isEmptyString((cells[cells.length - 1]?.text ?? "").trim())) cells.pop();
1128
1122
  return cells;
1129
1123
  }
1130
1124
  /**
1131
- * Derive the per-column {@link TableAlign} list from a GFM delimiter row - `:---`
1125
+ * Derives the per-column {@link TableAlign} list from a GFM delimiter row - `:---`
1132
1126
  * left, `---:` right, `:---:` center, and `---` as the explicit no-alignment
1133
1127
  * marker represented by `null`.
1134
1128
  *
@@ -1152,7 +1146,7 @@ function delimiterToAlignments(delimiter) {
1152
1146
  });
1153
1147
  }
1154
1148
  /**
1155
- * Whether the line at `index` starts a NEW block kind (heading / fence / thematic
1149
+ * Checks whether the line at `index` starts a NEW block kind (heading / fence / thematic
1156
1150
  * break / blockquote / list / table) - the paragraph collector stops at such a line
1157
1151
  * so a block following a paragraph without a blank line still parses (a trusted-input
1158
1152
  * caller writing a `##` heading directly under a paragraph, with no intervening blank
@@ -1160,7 +1154,7 @@ function delimiterToAlignments(delimiter) {
1160
1154
  *
1161
1155
  * @param lines - The document's lines
1162
1156
  * @param index - The line index to test
1163
- * @returns `true` when the line begins a different block
1157
+ * @returns True if the line begins a different block; false otherwise
1164
1158
  *
1165
1159
  * @example
1166
1160
  * ```ts
@@ -1172,7 +1166,7 @@ function startsBlock(lines, index) {
1172
1166
  return extractHeading(line) !== void 0 || extractFence(line) !== void 0 || isThematicBreak(line) || isQuote(line) || extractListItem(line) !== void 0 || isTableStart(line, lines[index + 1]);
1173
1167
  }
1174
1168
  /**
1175
- * Resolve backslash escapes in a raw string to their literal characters - used for a
1169
+ * Resolves backslash escapes in a raw string to their literal characters - used for a
1176
1170
  * link `href` (which is not otherwise inline-parsed) and any plain text run.
1177
1171
  *
1178
1172
  * @param text - The raw text possibly carrying `\x` escapes
@@ -1195,10 +1189,11 @@ function unescapeText(text) {
1195
1189
  return out;
1196
1190
  }
1197
1191
  /**
1198
- * Merge adjacent text nodes into one - the inline scanner emits a text node per
1192
+ * Merges adjacent text nodes into one - the inline scanner emits a text node per
1199
1193
  * unrecognized character, so coalescing keeps the AST clean and assertion-friendly.
1200
1194
  *
1201
1195
  * @param nodes - The inline nodes (possibly with adjacent text runs)
1196
+ * @param spans - The optional operation-owned node span recorder
1202
1197
  * @returns The nodes with consecutive text nodes concatenated
1203
1198
  *
1204
1199
  * @example
@@ -1207,20 +1202,32 @@ function unescapeText(text) {
1207
1202
  * // [{ element: 'text', value: 'ab' }]
1208
1203
  * ```
1209
1204
  */
1210
- function coalesceText(nodes) {
1205
+ function coalesceText(nodes, spans) {
1211
1206
  const out = [];
1212
1207
  for (const node of nodes) {
1213
1208
  const last = out[out.length - 1];
1214
- if (node.element === "text" && last !== void 0 && last.element === "text") out[out.length - 1] = {
1215
- element: "text",
1216
- value: last.value + node.value
1217
- };
1218
- else out.push(node);
1209
+ if (node.element === "text" && last !== void 0 && last.element === "text") {
1210
+ const merged = {
1211
+ element: "text",
1212
+ value: last.value + node.value
1213
+ };
1214
+ const left = spans?.get(last);
1215
+ const right = spans?.get(node);
1216
+ if (spans !== void 0) {
1217
+ spans.delete(last);
1218
+ spans.delete(node);
1219
+ if (left !== void 0 && right !== void 0) spans.set(merged, {
1220
+ start: left.start,
1221
+ end: right.end
1222
+ });
1223
+ }
1224
+ out[out.length - 1] = merged;
1225
+ } else out.push(node);
1219
1226
  }
1220
1227
  return out;
1221
1228
  }
1222
1229
  /**
1223
- * Scan an inline code span at `start` (a `` ` ``-run … a matching `` ` ``-run of the
1230
+ * Scans an inline code span at `start` (a `` ` ``-run … a matching `` ` ``-run of the
1224
1231
  * SAME length, the CommonMark rule that lets a span contain backticks). Returns the
1225
1232
  * span's literal text + end index, or `undefined` when no matching closer exists (it
1226
1233
  * then degrades to literal backticks).
@@ -1255,26 +1262,22 @@ function scanCode(source, start, to) {
1255
1262
  }
1256
1263
  }
1257
1264
  /**
1258
- * Scan a link `[text](href)` at `start` - the text runs to a BALANCED `]`, then `(`
1265
+ * Locates a link `[text](href)` at `start` - the text runs to a BALANCED `]`, then `(`
1259
1266
  * must immediately follow and the destination runs to the matching `)` (both respect
1260
- * nested delimiters + escapes). Returns the link node, or `undefined` when the shape
1267
+ * nested delimiters + escapes). Returns the label close and syntax end, or `undefined` when the shape
1261
1268
  * does not hold (it then degrades to a literal `[`).
1262
1269
  *
1263
1270
  * @param source - The inline source text
1264
1271
  * @param start - The index of the opening `[`
1265
1272
  * @param to - The exclusive end of the scan window
1266
- * @param depth - The current inline-recursion depth (defaults to 0 at the entry point);
1267
- * at {@link MAX_DEPTH} the link's text children degrade to literal text instead of
1268
- * recursing further
1269
- * @returns The parsed {@link LinkNode} + end index, or `undefined`
1273
+ * @returns The label close and syntax end indices, or `undefined`
1270
1274
  *
1271
1275
  * @example
1272
1276
  * ```ts
1273
- * scanLink('[text](url)', 0, 11)
1274
- * // { node: { element: 'link', href: 'url', children: [...] }, end: 11 }
1277
+ * locateLink('[text](url)', 0, 11) // { close: 5, end: 11 }
1275
1278
  * ```
1276
1279
  */
1277
- function scanLink(source, start, to, depth = 0) {
1280
+ function locateLink(source, start, to) {
1278
1281
  let bracketDepth = 0;
1279
1282
  let close = -1;
1280
1283
  for (let index = start; index < to; index += 1) {
@@ -1311,44 +1314,69 @@ function scanLink(source, start, to, depth = 0) {
1311
1314
  }
1312
1315
  }
1313
1316
  if (parenClose === -1) return void 0;
1317
+ return {
1318
+ close,
1319
+ end: parenClose + 1
1320
+ };
1321
+ }
1322
+ /**
1323
+ * Scans a link `[text](href)` at `start` - the text runs to a BALANCED `]`, then `(`
1324
+ * must immediately follow and the destination runs to the matching `)` (both respect
1325
+ * nested delimiters + escapes) through {@link locateLink}, and returns the parsed node
1326
+ * and end index. Returns `undefined` when the shape does not hold (it then degrades to
1327
+ * a literal `[`).
1328
+ *
1329
+ * @param source - The inline source text
1330
+ * @param start - The index of the opening `[`
1331
+ * @param to - The exclusive end of the scan window
1332
+ * @param depth - The current inline-recursion depth, forwarded to {@link scanInline}
1333
+ * incremented by one for the link text's children. At {@link MAX_DEPTH} that
1334
+ * recursion emits the text as a single literal text node instead of scanning it.
1335
+ * @returns The parsed link and end index, or `undefined` when the shape does not hold
1336
+ *
1337
+ * @example
1338
+ * ```ts
1339
+ * scanLink('[text](url)', 0, 11)
1340
+ * // { node: { element: 'link', href: 'url', children: [{ element: 'text', value: 'text' }] }, end: 11 }
1341
+ * ```
1342
+ */
1343
+ function scanLink(source, start, to, depth = 0) {
1344
+ const located = locateLink(source, start, to);
1345
+ if (located === void 0) return void 0;
1314
1346
  return {
1315
1347
  node: {
1316
1348
  element: "link",
1317
- href: unescapeText(source.slice(close + 2, parenClose).trim()),
1318
- children: scanInline(source, start + 1, close, depth + 1)
1349
+ href: unescapeText(source.slice(located.close + 2, located.end - 1).trim()),
1350
+ children: scanInline(source, start + 1, located.close, depth + 1)
1319
1351
  },
1320
- end: parenClose + 1
1352
+ end: located.end
1321
1353
  };
1322
1354
  }
1323
1355
  /**
1324
- * Scan an emphasis run at `start` (`*` / `_`, doubled for strong) - finds the nearest
1356
+ * Locates an emphasis run at `start` (`*` / `_`, doubled for strong) - finds the nearest
1325
1357
  * matching closing run of the same marker + width while skipping complete nested
1326
1358
  * runs from the other marker family, and requires non-space immediately inside both
1327
1359
  * delimiters (the CommonMark flanking simplification that blocks `* x *`). Returns
1328
- * the emphasis node, or `undefined` when no valid closer exists (it then degrades to
1360
+ * the content and syntax bounds, or `undefined` when no valid closer exists (it then degrades to
1329
1361
  * a literal marker).
1330
1362
  *
1331
1363
  * @param source - The inline source text
1332
1364
  * @param start - The index of the opening marker
1333
1365
  * @param to - The exclusive end of the scan window
1334
- * @param depth - The current inline-recursion depth (defaults to 0 at the entry point);
1335
- * at {@link MAX_DEPTH} the emphasis's children degrade to literal text instead of
1336
- * recursing further
1337
- * @returns The parsed {@link EmphasisNode} + end index, or `undefined`
1366
+ * @returns The content and syntax bounds, or `undefined`
1338
1367
  *
1339
1368
  * @example
1340
1369
  * ```ts
1341
- * scanEmphasis('*em*', 0, 4)
1342
- * // { node: { element: 'emphasis', strong: false, children: [...] }, end: 4 }
1370
+ * locateEmphasis('*em*', 0, 4) // { strong: false, open: 1, close: 3, end: 4 }
1343
1371
  * ```
1344
1372
  */
1345
- function scanEmphasis(source, start, to, depth = 0) {
1373
+ function locateEmphasis(source, start, to) {
1346
1374
  const marker = source[start] ?? "";
1347
1375
  let run = 0;
1348
1376
  while (start + run < to && source[start + run] === marker && run < 2) run += 1;
1349
1377
  const strong = run === 2;
1350
1378
  const openEnd = start + run;
1351
- if (openEnd >= to || isWhitespace(source[openEnd] ?? "")) return void 0;
1379
+ if (openEnd >= to || isFlankingWhitespace(source[openEnd] ?? "")) return void 0;
1352
1380
  let index = openEnd;
1353
1381
  while (index < to) {
1354
1382
  const character = source[index] ?? "";
@@ -1362,7 +1390,7 @@ function scanEmphasis(source, start, to, depth = 0) {
1362
1390
  continue;
1363
1391
  }
1364
1392
  if ((character === "*" || character === "_") && character !== marker) {
1365
- const nested = scanEmphasis(source, index, to, depth + 1);
1393
+ const nested = locateEmphasis(source, index, to);
1366
1394
  if (nested !== void 0) {
1367
1395
  index = nested.end;
1368
1396
  continue;
@@ -1371,12 +1399,10 @@ function scanEmphasis(source, start, to, depth = 0) {
1371
1399
  if (character === marker) {
1372
1400
  let closeRun = 0;
1373
1401
  while (index + closeRun < to && source[index + closeRun] === marker) closeRun += 1;
1374
- if (closeRun >= run && !isWhitespace(source[index - 1] ?? "")) return {
1375
- node: {
1376
- element: "emphasis",
1377
- strong,
1378
- children: scanInline(source, openEnd, index, depth + 1)
1379
- },
1402
+ if (closeRun >= run && !isFlankingWhitespace(source[index - 1] ?? "")) return {
1403
+ strong,
1404
+ open: openEnd,
1405
+ close: index,
1380
1406
  end: index + run
1381
1407
  };
1382
1408
  index += closeRun;
@@ -1386,7 +1412,41 @@ function scanEmphasis(source, start, to, depth = 0) {
1386
1412
  }
1387
1413
  }
1388
1414
  /**
1389
- * Scan the window `[from, to)` of `source` into inline nodes - the single recursive
1415
+ * Scans an emphasis run at `start` (`*` / `_`, doubled for strong) - finds the nearest
1416
+ * matching closing run of the same marker + width while skipping complete nested runs
1417
+ * from the other marker family, and requires non-space immediately inside both
1418
+ * delimiters (the CommonMark flanking simplification that blocks `* x *`) through
1419
+ * {@link locateEmphasis}, and returns the parsed node and end index. Returns
1420
+ * `undefined` when no valid closer exists (it then degrades to a literal marker).
1421
+ *
1422
+ * @param source - The inline source text
1423
+ * @param start - The index of the opening marker
1424
+ * @param to - The exclusive end of the scan window
1425
+ * @param depth - The current inline-recursion depth, forwarded to {@link scanInline}
1426
+ * incremented by one for the run's children. At {@link MAX_DEPTH} that recursion
1427
+ * emits the content as a single literal text node instead of scanning it.
1428
+ * @returns The parsed emphasis and end index, or `undefined` when no closer exists
1429
+ *
1430
+ * @example
1431
+ * ```ts
1432
+ * scanEmphasis('*em*', 0, 4)
1433
+ * // { node: { element: 'emphasis', strong: false, children: [{ element: 'text', value: 'em' }] }, end: 4 }
1434
+ * ```
1435
+ */
1436
+ function scanEmphasis(source, start, to, depth = 0) {
1437
+ const located = locateEmphasis(source, start, to);
1438
+ if (located === void 0) return void 0;
1439
+ return {
1440
+ node: {
1441
+ element: "emphasis",
1442
+ strong: located.strong,
1443
+ children: scanInline(source, located.open, located.close, depth + 1)
1444
+ },
1445
+ end: located.end
1446
+ };
1447
+ }
1448
+ /**
1449
+ * Scans the window `[from, to)` of `source` into inline nodes - the single recursive
1390
1450
  * engine the inline phase runs on (emphasis, link text, and image alternative
1391
1451
  * content recurse through it). Linear:
1392
1452
  * each character is consumed once; a failed construct emits its opening character as
@@ -1396,10 +1456,11 @@ function scanEmphasis(source, start, to, depth = 0) {
1396
1456
  * @param from - The inclusive start of the scan window
1397
1457
  * @param to - The exclusive end of the scan window
1398
1458
  * @param depth - The current inline-recursion depth (defaults to 0 at the entry point);
1399
- * incremented by one on every recursive descent through {@link scanLink} /
1400
- * {@link scanEmphasis}. At {@link MAX_DEPTH} the window is never scanned for markup -
1401
- * it emits as a single literal text node - so pathological nesting (`[[[[…`,
1402
- * `****…`) cannot exhaust the call stack.
1459
+ * incremented by one on every recursive descent {@link scanInlineSource} makes into
1460
+ * itself for a link's text, an image's alternative content, or an emphasis run's
1461
+ * children. At {@link MAX_DEPTH} the window is never scanned for markup - it emits as
1462
+ * a single literal text node - so pathological nesting (`[[[[…`, `****…`) cannot
1463
+ * exhaust the call stack.
1403
1464
  * @returns The parsed inline nodes (NOT yet coalesced)
1404
1465
  *
1405
1466
  * @example
@@ -1408,40 +1469,88 @@ function scanEmphasis(source, start, to, depth = 0) {
1408
1469
  * ```
1409
1470
  */
1410
1471
  function scanInline(source, from, to, depth = 0) {
1411
- if (depth >= 64) return from < to ? [{
1412
- element: "text",
1413
- value: source.slice(from, to)
1414
- }] : [];
1472
+ return scanInlineSource({
1473
+ text: source,
1474
+ segments: [{
1475
+ offset: 0,
1476
+ start: 0,
1477
+ end: source.length
1478
+ }]
1479
+ }, from, to, /* @__PURE__ */ new Map(), depth);
1480
+ }
1481
+ /**
1482
+ * Scans an offset-bearing inline window with the same engine as {@link scanInline}
1483
+ * and records each emitted node against the original markdown string.
1484
+ *
1485
+ * @param source - The offset-bearing inline source
1486
+ * @param from - The inclusive start of the scan window
1487
+ * @param to - The exclusive end of the scan window
1488
+ * @param spans - The operation-owned node span recorder
1489
+ * @param depth - The current inline-recursion depth, incremented by one on every
1490
+ * recursive descent this function makes into itself for a link's text, an image's
1491
+ * alternative content, or an emphasis run's children
1492
+ * @returns The parsed inline nodes before adjacent text coalescing
1493
+ *
1494
+ * @example
1495
+ * ```ts
1496
+ * scanInlineSource(
1497
+ * { text: 'hi *there*', segments: [{ offset: 0, start: 0, end: 10 }] },
1498
+ * 0,
1499
+ * 10,
1500
+ * new Map(),
1501
+ * )
1502
+ * // [{ element: 'text', value: 'hi ' }, { element: 'emphasis', ... }]
1503
+ * ```
1504
+ */
1505
+ function scanInlineSource(source, from, to, spans, depth = 0) {
1506
+ if (depth >= 64) if (from < to) {
1507
+ const node = {
1508
+ element: "text",
1509
+ value: source.text.slice(from, to)
1510
+ };
1511
+ const span = projectSpan(source, from, to);
1512
+ if (span !== void 0) spans.set(node, span);
1513
+ return [node];
1514
+ } else return [];
1415
1515
  const nodes = [];
1416
1516
  let index = from;
1417
1517
  let pending = "";
1518
+ let pendingStart = from;
1418
1519
  while (index < to) {
1419
- const character = source[index] ?? "";
1420
- if (character === "\\" && index + 1 < to && isEscapable(source[index + 1] ?? "")) {
1421
- pending += source[index + 1] ?? "";
1520
+ const character = source.text[index] ?? "";
1521
+ if (character === "\\" && index + 1 < to && isEscapable(source.text[index + 1] ?? "")) {
1522
+ if (pending.length === 0) pendingStart = index;
1523
+ pending += source.text[index + 1] ?? "";
1422
1524
  index += 2;
1423
1525
  continue;
1424
1526
  }
1425
1527
  if (character === " ") {
1426
1528
  let spaceEnd = index;
1427
- while (spaceEnd < to && source[spaceEnd] === " ") spaceEnd += 1;
1428
- if (spaceEnd - index >= 2 && source[spaceEnd] === "\n") {
1529
+ while (spaceEnd < to && source.text[spaceEnd] === " ") spaceEnd += 1;
1530
+ if (spaceEnd - index >= 2 && source.text[spaceEnd] === "\n") {
1429
1531
  if (pending.length > 0) {
1430
- nodes.push({
1532
+ const node = {
1431
1533
  element: "text",
1432
1534
  value: pending
1433
- });
1535
+ };
1536
+ const span = projectSpan(source, pendingStart, index);
1537
+ if (span !== void 0) spans.set(node, span);
1538
+ nodes.push(node);
1434
1539
  pending = "";
1435
1540
  }
1436
- nodes.push({ element: "break" });
1541
+ const node = { element: "break" };
1542
+ const span = projectSpan(source, index, spaceEnd + 1);
1543
+ if (span !== void 0) spans.set(node, span);
1544
+ nodes.push(node);
1437
1545
  index = spaceEnd + 1;
1546
+ pendingStart = index;
1438
1547
  continue;
1439
1548
  }
1440
1549
  }
1441
1550
  let scanned;
1442
1551
  let end = index;
1443
1552
  if (character === "`") {
1444
- const span = scanCode(source, index, to);
1553
+ const span = scanCode(source.text, index, to);
1445
1554
  if (span) {
1446
1555
  scanned = {
1447
1556
  element: "codeSpan",
@@ -1450,50 +1559,70 @@ function scanInline(source, from, to, depth = 0) {
1450
1559
  end = span.end;
1451
1560
  }
1452
1561
  }
1453
- if (character === "!" && source[index + 1] === "[") {
1454
- const link = scanLink(source, index + 1, to, depth);
1455
- if (link) {
1562
+ if (character === "!" && source.text[index + 1] === "[") {
1563
+ const link = locateLink(source.text, index + 1, to);
1564
+ if (link !== void 0) {
1456
1565
  scanned = {
1457
1566
  element: "image",
1458
- src: link.node.href,
1459
- children: link.node.children
1567
+ src: unescapeText(source.text.slice(link.close + 2, link.end - 1).trim()),
1568
+ children: coalesceText(scanInlineSource(source, index + 2, link.close, spans, depth + 1), spans)
1460
1569
  };
1461
1570
  end = link.end;
1462
1571
  }
1463
1572
  }
1464
1573
  if (character === "[") {
1465
- const link = scanLink(source, index, to, depth);
1466
- if (link) {
1467
- scanned = link.node;
1574
+ const link = locateLink(source.text, index, to);
1575
+ if (link !== void 0) {
1576
+ scanned = {
1577
+ element: "link",
1578
+ href: unescapeText(source.text.slice(link.close + 2, link.end - 1).trim()),
1579
+ children: coalesceText(scanInlineSource(source, index + 1, link.close, spans, depth + 1), spans)
1580
+ };
1468
1581
  end = link.end;
1469
1582
  }
1470
1583
  }
1471
1584
  if (character === "*" || character === "_") {
1472
- const emphasis = scanEmphasis(source, index, to, depth);
1473
- if (emphasis) {
1474
- scanned = emphasis.node;
1585
+ const emphasis = locateEmphasis(source.text, index, to);
1586
+ if (emphasis !== void 0) {
1587
+ scanned = {
1588
+ element: "emphasis",
1589
+ strong: emphasis.strong,
1590
+ children: coalesceText(scanInlineSource(source, emphasis.open, emphasis.close, spans, depth + 1), spans)
1591
+ };
1475
1592
  end = emphasis.end;
1476
1593
  }
1477
1594
  }
1478
1595
  if (scanned !== void 0) {
1479
1596
  if (pending.length > 0) {
1480
- nodes.push({
1597
+ const node = {
1481
1598
  element: "text",
1482
1599
  value: pending
1483
- });
1600
+ };
1601
+ const span = projectSpan(source, pendingStart, index);
1602
+ if (span !== void 0) spans.set(node, span);
1603
+ nodes.push(node);
1484
1604
  pending = "";
1485
1605
  }
1606
+ const span = projectSpan(source, index, end);
1607
+ if (span !== void 0) spans.set(scanned, span);
1486
1608
  nodes.push(scanned);
1487
1609
  index = end;
1610
+ pendingStart = index;
1488
1611
  continue;
1489
1612
  }
1613
+ if (pending.length === 0) pendingStart = index;
1490
1614
  pending += character;
1491
1615
  index += 1;
1492
1616
  }
1493
- if (pending.length > 0) nodes.push({
1494
- element: "text",
1495
- value: pending
1496
- });
1617
+ if (pending.length > 0) {
1618
+ const node = {
1619
+ element: "text",
1620
+ value: pending
1621
+ };
1622
+ const span = projectSpan(source, pendingStart, index);
1623
+ if (span !== void 0) spans.set(node, span);
1624
+ nodes.push(node);
1625
+ }
1497
1626
  return nodes;
1498
1627
  }
1499
1628
  /**
@@ -1502,36 +1631,56 @@ function scanInline(source, from, to, depth = 0) {
1502
1631
  *
1503
1632
  * @param lines - The markdown lines to scan.
1504
1633
  * @param start - The index of the header row.
1634
+ * @param spans - The optional operation-owned node span recorder.
1505
1635
  * @returns The parsed table node and the index of the first line after it.
1506
1636
  *
1507
1637
  * @example
1508
1638
  * ```ts
1509
- * collectTable(['| a |', '| - |'], 0) // { node: { element: 'table', ... }, next: 2 }
1639
+ * collectTable(splitLines('| a |\n| - |'), 0) // { node: { element: 'table', ... }, next: 2 }
1510
1640
  * ```
1511
1641
  */
1512
- function collectTable(lines, start) {
1513
- const headerCells = splitTableRow(lines[start] ?? "");
1642
+ function collectTable(lines, start, spans = /* @__PURE__ */ new Map()) {
1643
+ const headerCells = splitTableSources(lines[start] ?? {
1644
+ text: "",
1645
+ segments: []
1646
+ });
1514
1647
  const columns = headerCells.length;
1515
- const header = headerCells.map((cell) => parseInline(cell.trim()));
1516
- const align = delimiterToAlignments(lines[start + 1] ?? "");
1648
+ const header = headerCells.map((cell) => {
1649
+ const source = trimSource(cell);
1650
+ return coalesceText(scanInlineSource(source, 0, source.text.length, spans), spans);
1651
+ });
1652
+ const align = delimiterToAlignments(lines[start + 1]?.text ?? "");
1517
1653
  const padded = [];
1518
1654
  for (let column = 0; column < columns; column += 1) padded.push(align[column] ?? null);
1519
1655
  const rows = [];
1520
1656
  let index = start + 2;
1521
- while (index < lines.length && !isBlankLine(lines[index] ?? "") && (lines[index] ?? "").includes("|")) {
1522
- const cells = splitTableRow(lines[index] ?? "");
1657
+ while (index < lines.length && !isBlankLine(lines[index]?.text ?? "") && (lines[index]?.text ?? "").includes("|")) {
1658
+ const cells = splitTableSources(lines[index] ?? {
1659
+ text: "",
1660
+ segments: []
1661
+ });
1523
1662
  const row = [];
1524
- for (let column = 0; column < columns; column += 1) row.push(parseInline((cells[column] ?? "").trim()));
1663
+ for (let column = 0; column < columns; column += 1) {
1664
+ const source = trimSource(cells[column] ?? {
1665
+ text: "",
1666
+ segments: []
1667
+ });
1668
+ row.push(coalesceText(scanInlineSource(source, 0, source.text.length, spans), spans));
1669
+ }
1525
1670
  rows.push(row);
1526
1671
  index += 1;
1527
1672
  }
1673
+ const node = {
1674
+ element: "table",
1675
+ header,
1676
+ rows,
1677
+ align: padded
1678
+ };
1679
+ const source = joinSources(lines.slice(start, index), "\n");
1680
+ const span = projectSpan(source, 0, source.text.length);
1681
+ if (span !== void 0) spans.set(node, span);
1528
1682
  return {
1529
- node: {
1530
- element: "table",
1531
- header,
1532
- rows,
1533
- align: padded
1534
- },
1683
+ node,
1535
1684
  next: index
1536
1685
  };
1537
1686
  }
@@ -1542,15 +1691,18 @@ function collectTable(lines, start) {
1542
1691
  * @param lines - The markdown lines to scan.
1543
1692
  * @param start - The index of the first list item.
1544
1693
  * @param depth - The current recursion depth (each item recurses at `depth + 1`).
1694
+ * @param spans - The optional operation-owned node span recorder.
1695
+ * @param end - The original-source end of this line run, including a removed terminator.
1545
1696
  * @returns The parsed list node and the index of the first line after it.
1546
1697
  *
1547
1698
  * @example
1548
1699
  * ```ts
1549
- * collectList(['- item'], 0, 0) // { node: { element: 'list', ... }, next: 1 }
1700
+ * collectList(splitLines('- item'), 0, 0) // { node: { element: 'list', ... }, next: 1 }
1550
1701
  * ```
1551
1702
  */
1552
- function collectList(lines, start, depth) {
1553
- const first = extractListItem(lines[start] ?? "");
1703
+ function collectList(lines, start, depth, spans = /* @__PURE__ */ new Map(), end) {
1704
+ const text = lines.map((line) => line.text);
1705
+ const first = extractListItem(text[start] ?? "");
1554
1706
  const ordered = first?.ordered ?? false;
1555
1707
  const startOrdinal = first?.start ?? 1;
1556
1708
  const topIndent = first?.indent ?? 0;
@@ -1558,7 +1710,7 @@ function collectList(lines, start, depth) {
1558
1710
  const chain = [];
1559
1711
  let nested = true;
1560
1712
  for (let cursor = start; cursor < lines.length; cursor += 1) {
1561
- const parsed = extractListItem(lines[cursor] ?? "");
1713
+ const parsed = extractListItem(text[cursor] ?? "");
1562
1714
  const previous = chain[chain.length - 1];
1563
1715
  if (parsed === void 0 || previous !== void 0 && (previous.content.length > 0 || parsed.indent !== previous.marker)) {
1564
1716
  nested = false;
@@ -1569,29 +1721,48 @@ function collectList(lines, start, depth) {
1569
1721
  const remaining = 64 - depth;
1570
1722
  if (nested && remaining > 0 && chain.length > remaining) {
1571
1723
  const terminal = chain[remaining - 1];
1572
- if (terminal !== void 0) {
1573
- const source = [terminal.content];
1574
- for (let cursor = start + remaining; cursor < lines.length; cursor += 1) source.push((lines[cursor] ?? "").slice(terminal.marker));
1575
- let children = [{
1724
+ const terminalLine = lines[start + remaining - 1];
1725
+ if (terminal !== void 0 && terminalLine !== void 0) {
1726
+ const sources = [sliceSource(terminalLine, terminal.marker, terminalLine.text.length)];
1727
+ for (let cursor = start + remaining; cursor < lines.length; cursor += 1) {
1728
+ const line = lines[cursor];
1729
+ if (line !== void 0) sources.push(sliceSource(line, terminal.marker, line.text.length));
1730
+ }
1731
+ const source = joinSources(sources, "\n");
1732
+ const textNode = {
1733
+ element: "text",
1734
+ value: source.text
1735
+ };
1736
+ const paragraph = {
1576
1737
  element: "paragraph",
1577
- children: [{
1578
- element: "text",
1579
- value: source.join("\n")
1580
- }]
1581
- }];
1738
+ children: [textNode]
1739
+ };
1740
+ const residualSpan = projectSpan(source, 0, source.text.length);
1741
+ if (residualSpan !== void 0) {
1742
+ spans.set(textNode, residualSpan);
1743
+ spans.set(paragraph, residualSpan);
1744
+ }
1745
+ let children = [paragraph];
1582
1746
  let node;
1583
1747
  for (let cursor = remaining - 1; cursor >= 0; cursor -= 1) {
1584
1748
  const parsed = chain[cursor];
1585
1749
  if (parsed === void 0) continue;
1750
+ const item = {
1751
+ element: "listItem",
1752
+ children
1753
+ };
1586
1754
  node = {
1587
1755
  element: "list",
1588
1756
  ordered: parsed.ordered,
1589
1757
  start: parsed.start,
1590
- items: [{
1591
- element: "listItem",
1592
- children
1593
- }]
1758
+ items: [item]
1594
1759
  };
1760
+ const region = joinSources(lines.slice(start + cursor).map((line) => sliceSource(line, parsed.indent, line.text.length)), "\n");
1761
+ const span = projectSpan(region, 0, region.text.length);
1762
+ if (span !== void 0) {
1763
+ spans.set(item, span);
1764
+ spans.set(node, span);
1765
+ }
1595
1766
  children = [node];
1596
1767
  }
1597
1768
  if (node !== void 0) return {
@@ -1602,48 +1773,64 @@ function collectList(lines, start, depth) {
1602
1773
  }
1603
1774
  let index = start;
1604
1775
  while (index < lines.length) {
1605
- const parsed = extractListItem(lines[index] ?? "");
1776
+ const parsed = extractListItem(text[index] ?? "");
1606
1777
  if (!parsed || parsed.indent > topIndent || parsed.ordered !== ordered) break;
1607
- const itemLines = [parsed.content];
1778
+ const itemStart = index;
1779
+ const itemLine = lines[index];
1780
+ if (itemLine === void 0) break;
1781
+ const itemLines = [sliceSource(itemLine, parsed.marker, itemLine.text.length)];
1608
1782
  const continuation = parsed.marker;
1609
1783
  index += 1;
1610
1784
  while (index < lines.length) {
1611
- const next = lines[index] ?? "";
1785
+ const nextSource = lines[index];
1786
+ if (nextSource === void 0) break;
1787
+ const next = nextSource.text;
1612
1788
  if (isBlankLine(next)) {
1613
- const after = lines[index + 1] ?? "";
1789
+ const after = lines[index + 1]?.text ?? "";
1614
1790
  if (index + 1 < lines.length && !isBlankLine(after) && countIndent(after) >= continuation) {
1615
- itemLines.push("");
1791
+ itemLines.push(sliceSource(nextSource, 0, 0));
1616
1792
  index += 1;
1617
1793
  continue;
1618
1794
  }
1619
1795
  break;
1620
1796
  }
1621
1797
  if (countIndent(next) >= continuation) {
1622
- itemLines.push(next.slice(continuation));
1798
+ itemLines.push(sliceSource(nextSource, continuation, next.length));
1623
1799
  index += 1;
1624
1800
  continue;
1625
1801
  }
1626
- if (extractListItem(next) || startsBlock(lines, index)) break;
1627
- itemLines.push(next.trim());
1802
+ if (extractListItem(next) || startsBlock(text, index)) break;
1803
+ itemLines.push(trimSource(nextSource));
1628
1804
  index += 1;
1629
1805
  }
1630
- items.push({
1806
+ const tail = itemLines[itemLines.length - 1];
1807
+ const segment = tail?.segments[tail.segments.length - 1];
1808
+ const itemEnd = index === lines.length && end !== void 0 ? end : segment?.end;
1809
+ const item = {
1631
1810
  element: "listItem",
1632
- children: parseBlocks(itemLines, depth + 1)
1633
- });
1811
+ children: parseBlocks(itemLines, depth + 1, spans, itemEnd)
1812
+ };
1813
+ const source = joinSources(lines.slice(itemStart, index), "\n");
1814
+ const span = projectSpan(source, 0, source.text.length);
1815
+ if (span !== void 0) spans.set(item, span);
1816
+ items.push(item);
1634
1817
  }
1818
+ const node = {
1819
+ element: "list",
1820
+ ordered,
1821
+ start: startOrdinal,
1822
+ items
1823
+ };
1824
+ const source = joinSources(lines.slice(start, index), "\n");
1825
+ const span = projectSpan(source, 0, source.text.length);
1826
+ if (span !== void 0) spans.set(node, span);
1635
1827
  return {
1636
- node: {
1637
- element: "list",
1638
- ordered,
1639
- start: startOrdinal,
1640
- items
1641
- },
1828
+ node,
1642
1829
  next: index
1643
1830
  };
1644
1831
  }
1645
1832
  /**
1646
- * Project a {@link MarkdownNode} into an unsanitized {@link HTMLDocument}.
1833
+ * Projects a {@link MarkdownNode} into an unsanitized {@link HTMLDocument}.
1647
1834
  *
1648
1835
  * @remarks
1649
1836
  * The projection is pure and iterative. Text and attribute values remain literal for
@@ -1972,29 +2159,7 @@ function markdownToHTML(node) {
1972
2159
  };
1973
2160
  }
1974
2161
  /**
1975
- * Render a {@link MarkdownNode} to sanitized canonical HTML.
1976
- *
1977
- * @remarks
1978
- * Markdown widens `@orkestrel/html`'s attribute floor by exactly `src`, because image
1979
- * syntax is meaningless without its source. `src` is still a URL attribute, so the
1980
- * floor refuses `javascript:`, `data:`, `vbscript:`, and `file:` values. A stricter
1981
- * consumer can compose {@link markdownToHTML} with `@orkestrel/html`'s `HTML` class
1982
- * directly.
1983
- *
1984
- * @param node - The markdown document or bare node to render
1985
- * @returns Sanitized canonical HTML
1986
- *
1987
- * @example
1988
- * ```ts
1989
- * renderHTML({ element: 'paragraph', children: [{ element: 'text', value: 'a & b' }] })
1990
- * // '<p>a &amp; b</p>'
1991
- * ```
1992
- */
1993
- function renderHTML(node) {
1994
- return renderHTML$1(new HTML(markdownToHTML(node)).sanitize({ attributes: [...SAFE_ATTRIBUTES, "src"] }).document);
1995
- }
1996
- /**
1997
- * Render a {@link MarkdownNode} to its CANONICAL markdown source - the inverse
2162
+ * Renders a {@link MarkdownNode} to its CANONICAL markdown source - the inverse
1998
2163
  * projection of `renderHTML`, and the serializer a `parse(renderMarkdown(doc))`
1999
2164
  * round-trip is built on. Canonical forms: `*` / `**` emphasis at even emphasis
2000
2165
  * nesting depths and `_` / `__` at odd depths, `- ` bullets, `N. ` sequential
@@ -2003,8 +2168,8 @@ function renderHTML(node) {
2003
2168
  * `> `-prefixed blockquote lines, GFM tables (1-space-padded cells, `\|`-escaped
2004
2169
  * pipes, an alignment delimiter row), `[text](href)` links, `![alt](src)` images,
2005
2170
  * and two-space hard breaks. A `text` node's literal content is backslash-escaped
2006
- * wherever it would otherwise re-parse as markup (AGENTS §14 parse↔render
2007
- * soundness).
2171
+ * wherever it would otherwise re-parse as markup, so parsing the rendered source
2172
+ * returns the node it was rendered from.
2008
2173
  *
2009
2174
  * @remarks
2010
2175
  * Total: never throws. At {@link MAX_DEPTH} a value-bearing node degrades to its
@@ -2289,31 +2454,61 @@ function renderMarkdown(node) {
2289
2454
  return "";
2290
2455
  }
2291
2456
  /**
2292
- * Trim the whitespace at the two ends of an inline run - the leading whitespace of a
2293
- * leading text node and the trailing whitespace of a trailing one - dropping either
2294
- * node when nothing survives.
2457
+ * Builds an HTML-to-markdown projection with absent fields defaulted from
2458
+ * {@link EMPTY_PROJECTION} and the block/inline exclusivity invariant enforced.
2295
2459
  *
2296
2460
  * @remarks
2297
- * Markdown trims every line of a paragraph, a heading's text, and a table cell, so an
2298
- * untrimmed run would come back from a re-parse a different AST. Expects a coalesced
2299
- * run (see {@link coalesceText}): only the outermost node on each side is examined.
2461
+ * A block-bearing projection cannot also expose inline content. Callers may provide
2462
+ * both views, but `inlines` is flushed whenever `blocks` is non-empty.
2300
2463
  *
2301
- * @param nodes - The inline run to trim
2302
- * @returns The run with its edge whitespace removed
2464
+ * @param parts - The projection fields to provide
2465
+ * @returns A complete invariant-preserving projection
2303
2466
  *
2304
2467
  * @example
2305
2468
  * ```ts
2306
- * trimInlines([{ element: 'text', value: ' a ' }]) // [{ element: 'text', value: 'a' }]
2469
+ * createProjection({
2470
+ * blocks: [{ element: 'thematicBreak' }],
2471
+ * inlines: [{ element: 'text', value: 'discarded' }],
2472
+ * })
2473
+ * // { blocks: [{ element: 'thematicBreak' }], inlines: [], text: '', cells: [], rows: [] }
2307
2474
  * ```
2308
2475
  */
2309
- function trimInlines(nodes) {
2310
- const out = [];
2311
- for (const node of nodes) if (node !== void 0) out.push(node);
2312
- const first = out[0];
2313
- if (first !== void 0 && first.element === "text") {
2314
- const value = first.value.replace(/^\s+/, "");
2315
- if (isEmptyString(value)) out.shift();
2316
- else out[0] = {
2476
+ function createProjection(parts = {}) {
2477
+ const blocks = parts.blocks ?? EMPTY_PROJECTION.blocks;
2478
+ return {
2479
+ blocks,
2480
+ inlines: blocks.length === 0 ? parts.inlines ?? EMPTY_PROJECTION.inlines : [],
2481
+ text: parts.text ?? EMPTY_PROJECTION.text,
2482
+ cells: parts.cells ?? EMPTY_PROJECTION.cells,
2483
+ rows: parts.rows ?? EMPTY_PROJECTION.rows
2484
+ };
2485
+ }
2486
+ /**
2487
+ * Trims the whitespace at the two ends of an inline run - the leading whitespace of a
2488
+ * leading text node and the trailing whitespace of a trailing one - dropping either
2489
+ * node when nothing survives.
2490
+ *
2491
+ * @remarks
2492
+ * Markdown trims every line of a paragraph, a heading's text, and a table cell, so an
2493
+ * untrimmed run would come back from a re-parse a different AST. Expects a coalesced
2494
+ * run (see {@link coalesceText}): only the outermost node on each side is examined.
2495
+ *
2496
+ * @param nodes - The inline run to trim
2497
+ * @returns The run with its edge whitespace removed
2498
+ *
2499
+ * @example
2500
+ * ```ts
2501
+ * trimInlines([{ element: 'text', value: ' a ' }]) // [{ element: 'text', value: 'a' }]
2502
+ * ```
2503
+ */
2504
+ function trimInlines(nodes) {
2505
+ const out = [];
2506
+ for (const node of nodes) if (node !== void 0) out.push(node);
2507
+ const first = out[0];
2508
+ if (first !== void 0 && first.element === "text") {
2509
+ const value = first.value.replace(/^\s+/, "");
2510
+ if (isEmptyString(value)) out.shift();
2511
+ else out[0] = {
2317
2512
  element: "text",
2318
2513
  value
2319
2514
  };
@@ -2330,7 +2525,7 @@ function trimInlines(nodes) {
2330
2525
  return out;
2331
2526
  }
2332
2527
  /**
2333
- * Reduce an inline run to the shape markdown can actually write back: adjacent text
2528
+ * Reduces an inline run to the shape markdown can actually write back: adjacent text
2334
2529
  * coalesced, empty text dropped, and every hard break either kept as a real line
2335
2530
  * ending or spent as a space.
2336
2531
  *
@@ -2343,8 +2538,8 @@ function trimInlines(nodes) {
2343
2538
  * becomes the space it stood for.
2344
2539
  *
2345
2540
  * @param nodes - The inline run to normalize
2346
- * @param breaks - Whether the target context can carry a hard break at all; `false` for
2347
- * a heading or a table cell, where every break becomes a space
2541
+ * @param breaks - If `true`, keeps each hard break as a real line ending; if `false`, spends
2542
+ * every break as the space it stood for, as a heading or a table cell requires
2348
2543
  * @returns The normalized run
2349
2544
  *
2350
2545
  * @example
@@ -2395,7 +2590,7 @@ function normalizeInlines(nodes, breaks) {
2395
2590
  return coalesceText(out);
2396
2591
  }
2397
2592
  /**
2398
- * Combine the projections of one node's children into the projection of that node -
2593
+ * Combines the projections of one node's children into the projection of that node -
2399
2594
  * the single place inline runs become paragraphs, so no ancestor has to decide it
2400
2595
  * twice.
2401
2596
  *
@@ -2482,7 +2677,7 @@ function mergeProjections(children) {
2482
2677
  });
2483
2678
  }
2484
2679
  /**
2485
- * Read a projection as BLOCK content - the view a document, a blockquote, and a list
2680
+ * Reads a projection as BLOCK content - the view a document, a blockquote, and a list
2486
2681
  * item each need.
2487
2682
  *
2488
2683
  * @remarks
@@ -2528,7 +2723,7 @@ function projectionToBlocks(projection) {
2528
2723
  return blocks;
2529
2724
  }
2530
2725
  /**
2531
- * Read a projection as INLINE content - the view a link, an emphasis, and a table cell
2726
+ * Reads a projection as INLINE content - the view a link, an emphasis, and a table cell
2532
2727
  * each need.
2533
2728
  *
2534
2729
  * @remarks
@@ -2548,14 +2743,14 @@ function projectionToBlocks(projection) {
2548
2743
  */
2549
2744
  function projectionToInlines(projection) {
2550
2745
  if (!isNonEmptyArray(projection.blocks) && !isNonEmptyArray(projection.cells) && !isNonEmptyArray(projection.rows)) return coalesceText(projection.inlines);
2551
- const value = projectionToBlocks(projection).map(flattenText).join(" ").replace(/\s+/g, " ").trim();
2746
+ const value = collapseSpace(projectionToBlocks(projection).map(flattenText).join(" "));
2552
2747
  return isEmptyString(value) ? [] : [{
2553
2748
  element: "text",
2554
2749
  value
2555
2750
  }];
2556
2751
  }
2557
2752
  /**
2558
- * Project one HTML leaf - a text node, a comment, or a doctype - to its
2753
+ * Projects one HTML leaf - a text node, a comment, or a doctype - to its
2559
2754
  * {@link MarkdownProjection}.
2560
2755
  *
2561
2756
  * @remarks
@@ -2585,7 +2780,7 @@ function projectHTMLLeaf(leaf) {
2585
2780
  });
2586
2781
  }
2587
2782
  /**
2588
- * Project one HTML container - the document root or an element - from its children's
2783
+ * Projects one HTML container - the document root or an element - from its children's
2589
2784
  * already-computed projections. THE element mapping, and the only place that decides
2590
2785
  * what an HTML tag becomes in markdown.
2591
2786
  *
@@ -2734,7 +2929,7 @@ function projectHTMLNode(node, children) {
2734
2929
  text: merged.text
2735
2930
  });
2736
2931
  case "img": {
2737
- const alt = (attributeOf(node, "alt") ?? "").replace(/\s+/g, " ").trim();
2932
+ const alt = collapseSpace(attributeOf(node, "alt") ?? "");
2738
2933
  return createProjection({
2739
2934
  inlines: [{
2740
2935
  element: "image",
@@ -2892,7 +3087,7 @@ function projectHTMLNode(node, children) {
2892
3087
  return merged;
2893
3088
  }
2894
3089
  /**
2895
- * Project an `@orkestrel/html` {@link HTMLNode} into a {@link MarkdownDocument} - the
3090
+ * Projects an `@orkestrel/html` {@link HTMLNode} into a {@link MarkdownDocument} - the
2896
3091
  * HTML→markdown direction, and the inverse of {@link markdownToHTML}.
2897
3092
  *
2898
3093
  * @remarks
@@ -2948,7 +3143,7 @@ function htmlToMarkdown(node) {
2948
3143
  };
2949
3144
  }
2950
3145
  /**
2951
- * Depth-first, pre-order, root-inclusive traversal of a {@link MarkdownNode} - yields
3146
+ * Walks a {@link MarkdownNode} depth-first, pre-order, root-inclusive - yields
2952
3147
  * the node itself, then recurses into its children (block children, list items,
2953
3148
  * image/link inline children, table header/row cells' inline nodes) in walk order.
2954
3149
  *
@@ -3011,7 +3206,7 @@ function* walkNodes(node) {
3011
3206
  }
3012
3207
  }
3013
3208
  /**
3014
- * Fold a {@link MarkdownNode} into a `T` via a total catamorphism - children are
3209
+ * Folds a {@link MarkdownNode} into a `T` through a total catamorphism - children are
3015
3210
  * folded first (post-order), then the node's own {@link MarkdownHandler} is invoked
3016
3211
  * with the already-folded children.
3017
3212
  *
@@ -3028,13 +3223,13 @@ function* walkNodes(node) {
3028
3223
  * with an empty children list instead of recursing further.
3029
3224
  *
3030
3225
  * @param node - The AST node to fold
3031
- * @param handlers - The total {@link MarkdownHandlers} table, one handler per element
3226
+ * @param handlers - The total {@link MarkdownHandlerMap} table, one handler per element
3032
3227
  * @param depth - The starting recursion depth (pass `0` at the entry point)
3033
3228
  * @returns The folded `T`
3034
3229
  *
3035
3230
  * @example
3036
3231
  * ```ts
3037
- * const countHandlers: MarkdownHandlers<number> = {
3232
+ * const countHandlers: MarkdownHandlerMap<number> = {
3038
3233
  * document: (_, children) => children.reduce((a, b) => a + b, 1),
3039
3234
  * // ...one handler per element, each summing its folded children
3040
3235
  * }
@@ -3163,19 +3358,20 @@ function foldNode(node, handlers, depth) {
3163
3358
  }
3164
3359
  }
3165
3360
  /**
3166
- * Rewrite a {@link MarkdownDocument} bottom-up (copy-on-write) - each node's children
3361
+ * Rewrites a {@link MarkdownDocument} bottom-up (copy-on-write) - each node's children
3167
3362
  * are rewritten first (post-order), then `rewrite` is applied to the node itself; the
3168
3363
  * document ROOT is never passed to `rewrite` (the `element: 'document'` invariant
3169
3364
  * always holds). A table's inline cells and a list's items ARE rewritten.
3170
3365
  *
3171
3366
  * @remarks
3172
- * Never mutates `document` - every level is rebuilt into a fresh object/array, even
3173
- * when `rewrite` returns its input unchanged. When `rewrite` returns a node whose
3174
- * `element` does not fit the slot it was called for (a block slot handed a
3367
+ * Never mutates `document`. An unchanged subtree keeps its input identity. A parent
3368
+ * is rebuilt only when an accepted child changes, and the returned derivation map
3369
+ * associates each rebuilt output with its input node. When `rewrite` returns a node
3370
+ * whose `element` does not fit the slot it was called for (a block slot handed a
3175
3371
  * non-{@link BlockNode}, an inline slot handed a non-{@link InlineNode}, a list-item
3176
- * slot handed a non-`listItem`), the ill-fitting result is discarded and the
3177
- * freshly-rebuilt (unrewritten-at-this-level) node is kept instead - `rewriteDocument`
3178
- * stays total and never produces a structurally invalid document.
3372
+ * slot handed a non-`listItem`), the ill-fitting result is discarded and the accepted
3373
+ * input child is reused - `rewriteDocument` stays total and never produces a
3374
+ * structurally invalid document.
3179
3375
  *
3180
3376
  * Descent is capped at {@link MAX_DEPTH}, the same cap {@link walkNodes} and
3181
3377
  * {@link foldNode} observe: at `depth >= MAX_DEPTH` the subtree is passed through
@@ -3185,11 +3381,11 @@ function foldNode(node, handlers, depth) {
3185
3381
  *
3186
3382
  * @param document - The document AST to rewrite
3187
3383
  * @param rewrite - The bottom-up {@link MarkdownRewriteHandler}
3188
- * @returns A new, rewritten {@link MarkdownDocument}
3384
+ * @returns The rewritten document and its output-to-input derivations
3189
3385
  *
3190
3386
  * @example
3191
3387
  * ```ts
3192
- * rewriteDocument(document, (node) =>
3388
+ * const [rewritten, derivations] = rewriteDocument(document, (node) =>
3193
3389
  * node.element === 'text' ? { element: 'text', value: node.value.toUpperCase() } : node,
3194
3390
  * )
3195
3391
  * ```
@@ -3202,6 +3398,7 @@ function rewriteDocument(document, rewrite) {
3202
3398
  count: 0
3203
3399
  }];
3204
3400
  const values = [];
3401
+ const derivations = /* @__PURE__ */ new Map();
3205
3402
  while (stack.length > 0) {
3206
3403
  const frame = stack.pop();
3207
3404
  if (frame === void 0) continue;
@@ -3255,6 +3452,7 @@ function rewriteDocument(document, rewrite) {
3255
3452
  }
3256
3453
  const children = frame.count === 0 ? [] : values.splice(values.length - frame.count, frame.count);
3257
3454
  let rebuilt = current;
3455
+ let changed = false;
3258
3456
  switch (current.element) {
3259
3457
  case "document": {
3260
3458
  const blocks = [];
@@ -3262,16 +3460,16 @@ function rewriteDocument(document, rewrite) {
3262
3460
  for (const block of current.children) {
3263
3461
  if (block === void 0) continue;
3264
3462
  const child = children[offset];
3265
- blocks.push(child !== void 0 && isBlockNode(child) ? child : block);
3463
+ const accepted = child !== void 0 && isBlockNode(child) ? child : block;
3464
+ blocks.push(accepted);
3465
+ if (accepted !== block) changed = true;
3266
3466
  offset += 1;
3267
3467
  }
3268
- const result = {
3468
+ if (changed) rebuilt = {
3269
3469
  element: "document",
3270
3470
  children: blocks
3271
3471
  };
3272
- if (stack.length === 0) return result;
3273
- values.push(result);
3274
- continue;
3472
+ break;
3275
3473
  }
3276
3474
  case "heading":
3277
3475
  case "paragraph": {
@@ -3280,10 +3478,12 @@ function rewriteDocument(document, rewrite) {
3280
3478
  for (const inline of current.children) {
3281
3479
  if (inline === void 0) continue;
3282
3480
  const child = children[offset];
3283
- inlines.push(child !== void 0 && isInlineNode(child) ? child : inline);
3481
+ const accepted = child !== void 0 && isInlineNode(child) ? child : inline;
3482
+ inlines.push(accepted);
3483
+ if (accepted !== inline) changed = true;
3284
3484
  offset += 1;
3285
3485
  }
3286
- rebuilt = {
3486
+ if (changed) rebuilt = {
3287
3487
  ...current,
3288
3488
  children: inlines
3289
3489
  };
@@ -3295,10 +3495,12 @@ function rewriteDocument(document, rewrite) {
3295
3495
  for (const block of current.children) {
3296
3496
  if (block === void 0) continue;
3297
3497
  const child = children[offset];
3298
- blocks.push(child !== void 0 && isBlockNode(child) ? child : block);
3498
+ const accepted = child !== void 0 && isBlockNode(child) ? child : block;
3499
+ blocks.push(accepted);
3500
+ if (accepted !== block) changed = true;
3299
3501
  offset += 1;
3300
3502
  }
3301
- rebuilt = {
3503
+ if (changed) rebuilt = {
3302
3504
  ...current,
3303
3505
  children: blocks
3304
3506
  };
@@ -3310,10 +3512,12 @@ function rewriteDocument(document, rewrite) {
3310
3512
  for (const block of current.children) {
3311
3513
  if (block === void 0) continue;
3312
3514
  const child = children[offset];
3313
- blocks.push(child !== void 0 && isBlockNode(child) ? child : block);
3515
+ const accepted = child !== void 0 && isBlockNode(child) ? child : block;
3516
+ blocks.push(accepted);
3517
+ if (accepted !== block) changed = true;
3314
3518
  offset += 1;
3315
3519
  }
3316
- rebuilt = {
3520
+ if (changed) rebuilt = {
3317
3521
  element: "listItem",
3318
3522
  children: blocks
3319
3523
  };
@@ -3327,10 +3531,12 @@ function rewriteDocument(document, rewrite) {
3327
3531
  for (const inline of current.children) {
3328
3532
  if (inline === void 0) continue;
3329
3533
  const child = children[offset];
3330
- inlines.push(child !== void 0 && isInlineNode(child) ? child : inline);
3534
+ const accepted = child !== void 0 && isInlineNode(child) ? child : inline;
3535
+ inlines.push(accepted);
3536
+ if (accepted !== inline) changed = true;
3331
3537
  offset += 1;
3332
3538
  }
3333
- rebuilt = {
3539
+ if (changed) rebuilt = {
3334
3540
  ...current,
3335
3541
  children: inlines
3336
3542
  };
@@ -3342,10 +3548,12 @@ function rewriteDocument(document, rewrite) {
3342
3548
  for (const item of current.items) {
3343
3549
  if (item === void 0) continue;
3344
3550
  const child = children[offset];
3345
- items.push(child?.element === "listItem" ? child : item);
3551
+ const accepted = child?.element === "listItem" ? child : item;
3552
+ items.push(accepted);
3553
+ if (accepted !== item) changed = true;
3346
3554
  offset += 1;
3347
3555
  }
3348
- rebuilt = {
3556
+ if (changed) rebuilt = {
3349
3557
  ...current,
3350
3558
  items
3351
3559
  };
@@ -3360,7 +3568,9 @@ function rewriteDocument(document, rewrite) {
3360
3568
  for (const inline of cell) {
3361
3569
  if (inline === void 0) continue;
3362
3570
  const child = children[offset];
3363
- inlines.push(child !== void 0 && isInlineNode(child) ? child : inline);
3571
+ const accepted = child !== void 0 && isInlineNode(child) ? child : inline;
3572
+ inlines.push(accepted);
3573
+ if (accepted !== inline) changed = true;
3364
3574
  offset += 1;
3365
3575
  }
3366
3576
  header.push(inlines);
@@ -3375,14 +3585,16 @@ function rewriteDocument(document, rewrite) {
3375
3585
  for (const inline of cell) {
3376
3586
  if (inline === void 0) continue;
3377
3587
  const child = children[offset];
3378
- inlines.push(child !== void 0 && isInlineNode(child) ? child : inline);
3588
+ const accepted = child !== void 0 && isInlineNode(child) ? child : inline;
3589
+ inlines.push(accepted);
3590
+ if (accepted !== inline) changed = true;
3379
3591
  offset += 1;
3380
3592
  }
3381
3593
  cells.push(inlines);
3382
3594
  }
3383
3595
  rows.push(cells);
3384
3596
  }
3385
- rebuilt = {
3597
+ if (changed) rebuilt = {
3386
3598
  ...current,
3387
3599
  header,
3388
3600
  rows
@@ -3390,6 +3602,14 @@ function rewriteDocument(document, rewrite) {
3390
3602
  break;
3391
3603
  }
3392
3604
  }
3605
+ if (rebuilt !== current) derivations.set(rebuilt, current);
3606
+ if (current.element === "document") {
3607
+ const result = rebuilt.element === "document" ? rebuilt : current;
3608
+ const output = new Set(walkNodes(result));
3609
+ const retained = /* @__PURE__ */ new Map();
3610
+ for (const [node, source] of derivations) if (output.has(node)) retained.set(node, source);
3611
+ return [result, retained];
3612
+ }
3393
3613
  const result = rewrite(rebuilt);
3394
3614
  let accepted = rebuilt;
3395
3615
  switch (current.element) {
@@ -3412,15 +3632,16 @@ function rewriteDocument(document, rewrite) {
3412
3632
  break;
3413
3633
  case "listItem": if (result.element === "listItem") accepted = result;
3414
3634
  }
3635
+ if (accepted !== rebuilt && accepted !== current) {
3636
+ if (derivations.has(accepted) && derivations.get(accepted) !== current) derivations.set(accepted, void 0);
3637
+ else derivations.set(accepted, current);
3638
+ }
3415
3639
  values.push(accepted);
3416
3640
  }
3417
- return {
3418
- element: "document",
3419
- children: [...document.children]
3420
- };
3641
+ return [document, /* @__PURE__ */ new Map()];
3421
3642
  }
3422
3643
  /**
3423
- * Concatenate the `value` / `code` content of every descendant text / code-span /
3644
+ * Concatenates the `value` / `code` content of every descendant text / code-span /
3424
3645
  * code-block node under `node`, including image alternative content, in walk order -
3425
3646
  * the plain-text projection of an AST (search indexing, word counts, a text-only
3426
3647
  * preview).
@@ -3493,6 +3714,458 @@ function flattenText(node) {
3493
3714
  return value;
3494
3715
  }
3495
3716
  //#endregion
3496
- 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, lineBreakShape, listItemMatchShape, markdownToHTML, mergeProjections, normalizeInlines, parseBlocks, parseDocument, parseInline, projectHTMLLeaf, projectHTMLNode, projectionToBlocks, projectionToInlines, renderHTML, renderMarkdown, rewriteDocument, scanCode, scanEmphasis, scanInline, scanLink, splitLines, splitTableRow, startsBlock, stripQuote, tableAlignShape, textShape, thematicBreakShape, trimInlines, unescapeText, walkNodes };
3717
+ //#region src/core/compilers.ts
3718
+ /**
3719
+ * Renders a {@link MarkdownNode} to sanitized canonical HTML.
3720
+ *
3721
+ * @remarks
3722
+ * Markdown widens `@orkestrel/html`'s attribute floor by exactly `src`, because image
3723
+ * syntax is meaningless without its source. `src` is still a URL attribute, so the
3724
+ * floor refuses `javascript:`, `data:`, `vbscript:`, and `file:` values. A stricter
3725
+ * consumer can compose {@link markdownToHTML} with `@orkestrel/html`'s `HTML` class
3726
+ * directly.
3727
+ *
3728
+ * @param node - The markdown document or bare node to render
3729
+ * @returns Sanitized canonical HTML
3730
+ *
3731
+ * @example
3732
+ * ```ts
3733
+ * renderHTML({ element: 'paragraph', children: [{ element: 'text', value: 'a & b' }] })
3734
+ * // '<p>a &amp; b</p>'
3735
+ * ```
3736
+ */
3737
+ function renderHTML(node) {
3738
+ return renderHTML$1(new HTML(markdownToHTML(node)).sanitize({ attributes: [...SAFE_ATTRIBUTES, "src"] }).document);
3739
+ }
3740
+ //#endregion
3741
+ //#region src/core/shapers.ts
3742
+ /**
3743
+ * Describes the shape of a {@link TextNode} - a plain-text leaf inline run.
3744
+ *
3745
+ * @example
3746
+ * ```ts
3747
+ * import { createContract } from '@orkestrel/contract'
3748
+ * import { textShape } from '@src/core'
3749
+ *
3750
+ * const text = createContract(textShape)
3751
+ * text.is({ element: 'text', value: 'hi' }) // true
3752
+ * ```
3753
+ */
3754
+ var textShape = objectShape({
3755
+ element: literalShape(["text"]),
3756
+ value: stringShape()
3757
+ });
3758
+ /**
3759
+ * Describes the shape of a {@link CodeSpanNode} - an inline code span (`` `code` ``).
3760
+ *
3761
+ * @example
3762
+ * ```ts
3763
+ * import { createContract } from '@orkestrel/contract'
3764
+ * import { codeSpanShape } from '@src/core'
3765
+ *
3766
+ * const codeSpan = createContract(codeSpanShape)
3767
+ * codeSpan.is({ element: 'codeSpan', value: 'const x = 1' }) // true
3768
+ * ```
3769
+ */
3770
+ var codeSpanShape = objectShape({
3771
+ element: literalShape(["codeSpan"]),
3772
+ value: stringShape()
3773
+ });
3774
+ /**
3775
+ * Describes the shape of a {@link LineBreakNode} - a GFM hard line-break leaf.
3776
+ *
3777
+ * @example
3778
+ * ```ts
3779
+ * import { createContract } from '@orkestrel/contract'
3780
+ * import { lineBreakShape } from '@src/core'
3781
+ *
3782
+ * const lineBreak = createContract(lineBreakShape)
3783
+ * lineBreak.is({ element: 'break' }) // true
3784
+ * ```
3785
+ */
3786
+ var lineBreakShape = objectShape({ element: literalShape(["break"]) });
3787
+ /**
3788
+ * Describes the shape of a {@link CodeBlockNode} - a fenced code block. `lang` is
3789
+ * optional (absent when the opening fence carries no info-string).
3790
+ *
3791
+ * @example
3792
+ * ```ts
3793
+ * import { createContract } from '@orkestrel/contract'
3794
+ * import { codeBlockShape } from '@src/core'
3795
+ *
3796
+ * const codeBlock = createContract(codeBlockShape)
3797
+ * codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
3798
+ * codeBlock.is({ element: 'codeBlock', code: 'x', lang: 'ts' }) // true
3799
+ * ```
3800
+ */
3801
+ var codeBlockShape = objectShape({
3802
+ element: literalShape(["codeBlock"]),
3803
+ lang: optionalShape(stringShape()),
3804
+ code: stringShape()
3805
+ });
3806
+ /**
3807
+ * Describes the shape of a {@link ThematicBreakNode} - a horizontal rule. Carries no
3808
+ * fields beyond its `element` discriminant.
3809
+ *
3810
+ * @example
3811
+ * ```ts
3812
+ * import { createContract } from '@orkestrel/contract'
3813
+ * import { thematicBreakShape } from '@src/core'
3814
+ *
3815
+ * const thematicBreak = createContract(thematicBreakShape)
3816
+ * thematicBreak.is({ element: 'thematicBreak' }) // true
3817
+ * ```
3818
+ */
3819
+ var thematicBreakShape = objectShape({ element: literalShape(["thematicBreak"]) });
3820
+ /**
3821
+ * Describes the shape of a {@link TableAlign} - the per-column GFM table alignment
3822
+ * literal.
3823
+ *
3824
+ * @example
3825
+ * ```ts
3826
+ * import { createContract } from '@orkestrel/contract'
3827
+ * import { tableAlignShape } from '@src/core'
3828
+ *
3829
+ * const tableAlign = createContract(tableAlignShape)
3830
+ * tableAlign.is('left') // true
3831
+ * tableAlign.is('center') // true
3832
+ * tableAlign.is('top') // false
3833
+ * ```
3834
+ */
3835
+ var tableAlignShape = literalShape([
3836
+ "left",
3837
+ "right",
3838
+ "center"
3839
+ ]);
3840
+ /**
3841
+ * Describes the shape of {@link ListItemMatch} - the parsed parts of a single list-item
3842
+ * line the block phase's list detector returns. Fully non-recursive (no
3843
+ * nested node fields), so every field shapes directly.
3844
+ *
3845
+ * @example
3846
+ * ```ts
3847
+ * import { createContract } from '@orkestrel/contract'
3848
+ * import { listItemMatchShape } from '@src/core'
3849
+ *
3850
+ * const listItemParts = createContract(listItemMatchShape)
3851
+ * listItemParts.is({ ordered: false, start: 1, content: 'hi', indent: 0, marker: 2 }) // true
3852
+ * ```
3853
+ */
3854
+ var listItemMatchShape = objectShape({
3855
+ ordered: booleanShape(),
3856
+ start: integerShape(),
3857
+ content: stringShape(),
3858
+ indent: integerShape(),
3859
+ marker: integerShape()
3860
+ });
3861
+ //#endregion
3862
+ //#region src/core/Markdown.ts
3863
+ /**
3864
+ * Wraps a typed {@link MarkdownDocument} AST as a stateful, parsed markdown document
3865
+ * with the query (`find` / `filter` / `reduce` / iteration), rewrite (`map`), fold, and
3866
+ * streaming operations {@link MarkdownInterface} declares.
3867
+ *
3868
+ * @remarks
3869
+ * - **Construction.** Given a `string`, the constructor runs {@link parseProvenance} (the
3870
+ * block phase then the inline phase) once, keeping the AST and a COPY of the span map
3871
+ * that parse recorded. Given a {@link MarkdownDocument}, the document is adopted AS-IS
3872
+ * and is NOT re-validated - gate an untrusted value with `isMarkdownDocument` first.
3873
+ * - **Provenance.** {@link span} reads the region of the ORIGINAL constructor string a
3874
+ * node was produced from, and it is handle-relative: a string-constructed handle exposes
3875
+ * the regions of the nodes it parsed, an adopted document exposes none, and a node from
3876
+ * another handle reports `undefined` here whatever that handle reports. Each call
3877
+ * returns a fresh value. A node reports the region THIS handle holds for its identity,
3878
+ * else the region of the direct input a rewrite named for it, else `undefined`: a text
3879
+ * run the parse joined from adjacent scanner output reports the region enclosing its
3880
+ * parts, and only a rewrite output that holds no region of its own and was assembled
3881
+ * from separate source nodes reports `undefined`.
3882
+ * {@link map} carries provenance across the rewrite: an unchanged node keeps its
3883
+ * region, a one-source replacement takes the region of the node it replaced, and a
3884
+ * rebuilt parent takes its original's.
3885
+ * - **Immutable.** {@link map} never mutates the stored AST - it returns a NEW `Markdown`
3886
+ * instance; the document root invariant (`element: 'document'`) always holds. An
3887
+ * identity rewrite still returns a new handle, over the same document tree.
3888
+ * - **Traversal order.** {@link walk} and the `find` / `filter` / `reduce` queries built
3889
+ * on it walk the AST depth-first, pre-order, root-inclusive (through {@link walkNodes});
3890
+ * `stream` is shallow - only the document's direct block children.
3891
+ *
3892
+ * @example
3893
+ * ```ts
3894
+ * import { Markdown, isHeadingNode, renderMarkdown } from '@src/core'
3895
+ *
3896
+ * const markdown = new Markdown('# Title\n\nA **bold** [link](https://x.dev).')
3897
+ * const heading = markdown.find(isHeadingNode) // the HeadingNode, or undefined
3898
+ * const shouted = markdown.map((node) =>
3899
+ * node.element === 'text' ? { element: 'text', value: node.value.toUpperCase() } : node,
3900
+ * )
3901
+ * renderMarkdown(shouted.document) // '# TITLE\n\nA **BOLD** [LINK](https://x.dev).'
3902
+ * ```
3903
+ */
3904
+ var Markdown = class Markdown {
3905
+ #document;
3906
+ #spans;
3907
+ constructor(input) {
3908
+ if (typeof input === "string") {
3909
+ const [document, spans] = parseProvenance(input);
3910
+ this.#document = document;
3911
+ this.#spans = new Map(spans);
3912
+ } else {
3913
+ this.#document = input;
3914
+ this.#spans = /* @__PURE__ */ new Map();
3915
+ }
3916
+ }
3917
+ /** Holds the stored {@link MarkdownDocument} AST root. */
3918
+ get document() {
3919
+ return this.#document;
3920
+ }
3921
+ /**
3922
+ * Reads the region of the original markdown string a node of this handle's tree was
3923
+ * produced from.
3924
+ *
3925
+ * @param node - The node whose provenance to read
3926
+ * @returns A fresh {@link MarkdownSpan}, or `undefined` when this handle holds no
3927
+ * region for the node
3928
+ *
3929
+ * @example
3930
+ * ```ts
3931
+ * const source = '# Title\n\npara'
3932
+ * const markdown = new Markdown(source)
3933
+ * const heading = markdown.find(isHeadingNode)
3934
+ * const span = heading && markdown.span(heading)
3935
+ * span && source.slice(span.start, span.end) // '# Title'
3936
+ * ```
3937
+ */
3938
+ span(node) {
3939
+ const span = this.#spans.get(node);
3940
+ return span === void 0 ? void 0 : {
3941
+ start: span.start,
3942
+ end: span.end
3943
+ };
3944
+ }
3945
+ /**
3946
+ * Returns THE deep traversal - a lazy, depth-first, pre-order, root-inclusive generator
3947
+ * over every {@link MarkdownNode} in the document. `find` / `filter` / `reduce`
3948
+ * all iterate this single traversal.
3949
+ *
3950
+ * @example
3951
+ * ```ts
3952
+ * for (const node of markdown.walk()) {
3953
+ * // every node, depth-first, pre-order, root-inclusive
3954
+ * }
3955
+ *
3956
+ * // also consumable by for-await - JS accepts a sync iterable in for-await
3957
+ * for await (const node of markdown.walk()) {
3958
+ * // same sequence, no separate async iterator needed
3959
+ * }
3960
+ * ```
3961
+ */
3962
+ *walk() {
3963
+ yield* walkNodes(this.#document);
3964
+ }
3965
+ find(predicate) {
3966
+ for (const node of this.walk()) if (predicate(node)) return node;
3967
+ }
3968
+ filter(predicate) {
3969
+ const out = [];
3970
+ for (const node of this.walk()) if (predicate(node)) out.push(node);
3971
+ return out;
3972
+ }
3973
+ /**
3974
+ * Rewrites the AST bottom-up (copy-on-write) and returns a new {@link Markdown},
3975
+ * carrying each output node's provenance across the rewrite. A rewrite that returns
3976
+ * its node unchanged shares that subtree instead of copying it, so an identity
3977
+ * rewrite copies no node and still returns a new handle.
3978
+ *
3979
+ * @param rewrite - The bottom-up node rewrite
3980
+ * @returns A new handle over the rewritten document
3981
+ */
3982
+ map(rewrite) {
3983
+ const [document, derivations] = rewriteDocument(this.#document, rewrite);
3984
+ return this.#derive(document, derivations);
3985
+ }
3986
+ /** Folds the AST depth-first, pre-order into an accumulator. */
3987
+ reduce(callback, initial) {
3988
+ let accumulator = initial;
3989
+ for (const node of this.walk()) accumulator = callback(accumulator, node);
3990
+ return accumulator;
3991
+ }
3992
+ /** Runs a total catamorphism over the document using a {@link MarkdownHandlerMap} table. */
3993
+ fold(handlers) {
3994
+ return foldNode(this.#document, handlers, 0);
3995
+ }
3996
+ /**
3997
+ * Returns a web-standard {@link ReadableStream} over the document's top-level block nodes
3998
+ * (shallow, source order) - a fresh, pull-based source per call: one block is
3999
+ * enqueued per `pull`, so a slow reader's backpressure is respected. Cancellable,
4000
+ * async-iterable wherever the platform supports it (Node, Deno), and pipeable
4001
+ * through any {@link TransformStream} / {@link WritableStream}.
4002
+ *
4003
+ * @example
4004
+ * ```ts
4005
+ * // universal - works in every ReadableStream-supporting environment
4006
+ * const reader = markdown.stream().getReader()
4007
+ * for (let result = await reader.read(); !result.done; result = await reader.read()) {
4008
+ * console.log(result.value) // one BlockNode
4009
+ * }
4010
+ *
4011
+ * // Node / Deno / Firefox support async iteration of ReadableStream natively;
4012
+ * // other environments use the reader loop shown earlier.
4013
+ * for await (const block of markdown.stream()) {
4014
+ * console.log(block)
4015
+ * }
4016
+ * ```
4017
+ */
4018
+ stream() {
4019
+ const blocks = this.#document.children;
4020
+ let index = 0;
4021
+ return new ReadableStream({ pull(controller) {
4022
+ if (index < blocks.length) {
4023
+ const block = blocks[index];
4024
+ if (block === void 0) {
4025
+ controller.close();
4026
+ return;
4027
+ }
4028
+ controller.enqueue(block);
4029
+ index += 1;
4030
+ } else controller.close();
4031
+ } });
4032
+ }
4033
+ #derive(document, derivations) {
4034
+ const derived = new Markdown(document);
4035
+ for (const node of walkNodes(document)) {
4036
+ const own = this.#spans.get(node);
4037
+ if (own !== void 0) {
4038
+ derived.#spans.set(node, own);
4039
+ continue;
4040
+ }
4041
+ const source = derivations.get(node);
4042
+ if (source === void 0) continue;
4043
+ const span = this.#spans.get(source);
4044
+ if (span !== void 0) derived.#spans.set(node, span);
4045
+ }
4046
+ return derived;
4047
+ }
4048
+ };
4049
+ //#endregion
4050
+ //#region src/core/factories.ts
4051
+ /**
4052
+ * Creates a stateful markdown handle from a markdown string or an already-parsed
4053
+ * {@link MarkdownDocument} - a typed AST plus the query, rewrite, and fold operations
4054
+ * {@link MarkdownInterface} exposes.
4055
+ *
4056
+ * @remarks
4057
+ * Given a `string`, runs a block phase (headings / paragraphs / lists / GFM tables /
4058
+ * fenced code / blockquotes / thematic breaks) then an inline phase (emphasis /
4059
+ * inline code / links / images / hard breaks) to build a render-agnostic
4060
+ * {@link MarkdownDocument}. Given a
4061
+ * {@link MarkdownDocument}, adopts it AS-IS without re-validation - gate an untrusted
4062
+ * value with `isMarkdownDocument` first. Pure + total parse (malformed markdown
4063
+ * degrades to text, never throws) and zero-dependency - a hand-written scanner, no
4064
+ * regex-only structural parse, linear-time (no ReDoS).
4065
+ *
4066
+ * @param input - A markdown string to parse, or an already-parsed {@link MarkdownDocument}
4067
+ * @returns A working {@link MarkdownInterface}
4068
+ *
4069
+ * @example
4070
+ * ```ts
4071
+ * import { createMarkdown } from '@src/core'
4072
+ *
4073
+ * const markdown = createMarkdown('# Hi\n\nRead the [guide](./guide.md).')
4074
+ * markdown.document.children[0] // { element: 'heading', ... }
4075
+ * ```
4076
+ */
4077
+ function createMarkdown(input) {
4078
+ return new Markdown(input);
4079
+ }
4080
+ /**
4081
+ * Compiles the {@link textShape} into a {@link ContractInterface} for
4082
+ * {@link TextNode} - a guard, coercing parser, JSON Schema, and seeded
4083
+ * generator from one shape declaration.
4084
+ *
4085
+ * @returns A `TextNode` contract bundling `schema` / `is` / `parse` / `generate`
4086
+ *
4087
+ * @example
4088
+ * ```ts
4089
+ * import { createTextContract } from '@src/core'
4090
+ *
4091
+ * const text = createTextContract()
4092
+ * text.is({ element: 'text', value: 'hi' }) // true
4093
+ * ```
4094
+ */
4095
+ function createTextContract() {
4096
+ return createContract(textShape);
4097
+ }
4098
+ /**
4099
+ * Compiles the {@link codeSpanShape} into a {@link ContractInterface} for
4100
+ * {@link CodeSpanNode} - a guard, coercing parser, JSON Schema, and seeded
4101
+ * generator from one shape declaration.
4102
+ *
4103
+ * @returns A `CodeSpanNode` contract bundling `schema` / `is` / `parse` / `generate`
4104
+ *
4105
+ * @example
4106
+ * ```ts
4107
+ * import { createCodeSpanContract } from '@src/core'
4108
+ *
4109
+ * const codeSpan = createCodeSpanContract()
4110
+ * codeSpan.is({ element: 'codeSpan', value: 'const x = 1' }) // true
4111
+ * ```
4112
+ */
4113
+ function createCodeSpanContract() {
4114
+ return createContract(codeSpanShape);
4115
+ }
4116
+ /**
4117
+ * Compiles the {@link lineBreakShape} into a {@link ContractInterface} for
4118
+ * {@link LineBreakNode}.
4119
+ *
4120
+ * @returns A `LineBreakNode` contract bundling `schema` / `is` / `parse` / `generate`
4121
+ *
4122
+ * @example
4123
+ * ```ts
4124
+ * import { createLineBreakContract } from '@src/core'
4125
+ *
4126
+ * createLineBreakContract().is({ element: 'break' }) // true
4127
+ * ```
4128
+ */
4129
+ function createLineBreakContract() {
4130
+ return createContract(lineBreakShape);
4131
+ }
4132
+ /**
4133
+ * Compiles the {@link codeBlockShape} into a {@link ContractInterface} for
4134
+ * {@link CodeBlockNode} - a guard, coercing parser, JSON Schema, and seeded
4135
+ * generator from one shape declaration.
4136
+ *
4137
+ * @returns A `CodeBlockNode` contract bundling `schema` / `is` / `parse` / `generate`
4138
+ *
4139
+ * @example
4140
+ * ```ts
4141
+ * import { createCodeBlockContract } from '@src/core'
4142
+ *
4143
+ * const codeBlock = createCodeBlockContract()
4144
+ * codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
4145
+ * ```
4146
+ */
4147
+ function createCodeBlockContract() {
4148
+ return createContract(codeBlockShape);
4149
+ }
4150
+ /**
4151
+ * Compiles the {@link thematicBreakShape} into a {@link ContractInterface} for
4152
+ * {@link ThematicBreakNode} - a guard, coercing parser, JSON Schema, and
4153
+ * seeded generator from one shape declaration.
4154
+ *
4155
+ * @returns A `ThematicBreakNode` contract bundling `schema` / `is` / `parse` / `generate`
4156
+ *
4157
+ * @example
4158
+ * ```ts
4159
+ * import { createThematicBreakContract } from '@src/core'
4160
+ *
4161
+ * const thematicBreak = createThematicBreakContract()
4162
+ * thematicBreak.is({ element: 'thematicBreak' }) // true
4163
+ * ```
4164
+ */
4165
+ function createThematicBreakContract() {
4166
+ return createContract(thematicBreakShape);
4167
+ }
4168
+ //#endregion
4169
+ 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 };
3497
4170
 
3498
4171
  //# sourceMappingURL=index.js.map