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