@orkestrel/markdown 0.0.5 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,27 +1,33 @@
1
- import { arrayOf, booleanShape, createContract, integerShape, isBoolean, isEmptyString, isNonEmptyArray, isNonEmptyString, isNumber, isString, lazyOf, literalOf, literalShape, objectShape, optionalShape, parseInteger, recordOf, stringShape, unionOf } from "@orkestrel/contract";
1
+ import { arrayOf, booleanShape, createContract, integerShape, isBoolean, isEmptyString, isNonEmptyArray, isNonEmptyString, isNumber, isString, lazyOf, literalOf, literalShape, nullableOf, objectShape, optionalShape, parseInteger, recordOf, stringShape, unionOf } from "@orkestrel/contract";
2
+ import { HTML, SAFE_ATTRIBUTES, SAFE_URL_SCHEMES, TABLE_ALIGNMENTS, UNSAFE_ELEMENTS, attributeOf, foldNode as foldNode$1, renderHTML as renderHTML$1, renderText, sanitizeURL } from "@orkestrel/html";
2
3
  //#region src/core/constants.ts
3
4
  /**
4
- * The URL schemes `renderHTML` permits on a link `href` - anything else (notably
5
- * `javascript:`, `data:`, `vbscript:`, `file:`) is dropped to an empty `href` so a
6
- * hostile link can never execute. Frozen, lower-case; a relative / anchor /
7
- * scheme-less `href` (no `scheme:` prefix) is always allowed.
8
- */
9
- var SAFE_URL_SCHEMES = /* @__PURE__ */ new Set([
10
- "http",
11
- "https",
12
- "mailto",
13
- "tel"
14
- ]);
15
- /**
16
5
  * The maximum recursion depth the parse pipeline (`parseDocument` and its
17
- * `parsers.ts` helpers) and the `helpers.ts` traversal / render functions
18
- * (`renderHTML`, `renderMarkdown`, `walkNodes`, `foldNode`) honor before degrading to
19
- * literal text - bounds blockquote nesting, inline nesting (emphasis / links), and
20
- * traversal/render recursion so pathological or hostile input (deeply nested
21
- * blockquotes, runaway emphasis) cannot exhaust the call stack. Past this depth the
22
- * parser treats the remaining content as literal text instead of recursing further.
6
+ * `parsers.ts` helpers) and the `helpers.ts` traversal / projection functions
7
+ * (`markdownToHTML`, `renderHTML`, `renderMarkdown`, `walkNodes`, `foldNode`,
8
+ * `rewriteDocument`) honor before degrading. It bounds blockquote nesting, inline
9
+ * nesting (emphasis / links), and traversal / projection recursion so pathological
10
+ * or hostile input cannot exhaust the call stack. {@link htmlToMarkdown} is the
11
+ * inherited exception: its fold and depth cap belong to `@orkestrel/html`.
23
12
  */
24
13
  var MAX_DEPTH = 64;
14
+ /**
15
+ * The frozen empty HTML-to-markdown projection from which projection factories
16
+ * default every absent field.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * EMPTY_PROJECTION.blocks // []
21
+ * Object.isFrozen(EMPTY_PROJECTION) // true
22
+ * ```
23
+ */
24
+ var EMPTY_PROJECTION = Object.freeze({
25
+ blocks: Object.freeze([]),
26
+ inlines: Object.freeze([]),
27
+ text: "",
28
+ cells: Object.freeze([]),
29
+ rows: Object.freeze([])
30
+ });
25
31
  //#endregion
26
32
  //#region src/core/validators.ts
27
33
  /**
@@ -269,13 +275,35 @@ function isEmphasisNode(node) {
269
275
  function isCodeSpanNode(node) {
270
276
  return node.element === "codeSpan";
271
277
  }
278
+ /**
279
+ * Determine whether a node is a GFM hard line break.
280
+ *
281
+ * @example
282
+ * ```ts
283
+ * isLineBreakNode({ element: 'break' }) // true
284
+ * ```
285
+ */
286
+ function isLineBreakNode(node) {
287
+ return node.element === "break";
288
+ }
272
289
  /** Determine whether a node is a link. */
273
290
  function isLinkNode(node) {
274
291
  return node.element === "link";
275
292
  }
276
293
  /**
294
+ * Determine whether a node is an image.
295
+ *
296
+ * @example
297
+ * ```ts
298
+ * isImageNode({ element: 'image', src: 'x.png', children: [] }) // true
299
+ * ```
300
+ */
301
+ function isImageNode(node) {
302
+ return node.element === "image";
303
+ }
304
+ /**
277
305
  * Determine whether an arbitrary value is a valid {@link InlineNode} - a text
278
- * run, emphasis, code span, or link, recursively validated.
306
+ * run, emphasis, code span, hard break, link, or image, recursively validated.
279
307
  *
280
308
  * @remarks
281
309
  * Total: never throws, even on cyclic or pathologically deep input - every
@@ -303,10 +331,14 @@ var isInlineNode = unionOf(recordOf({
303
331
  }), recordOf({
304
332
  element: literalOf("codeSpan"),
305
333
  value: isString
306
- }), recordOf({
334
+ }), recordOf({ element: literalOf("break") }), recordOf({
307
335
  element: literalOf("link"),
308
336
  href: isString,
309
337
  children: arrayOf(lazyOf(() => isInlineNode))
338
+ }), recordOf({
339
+ element: literalOf("image"),
340
+ src: isString,
341
+ children: arrayOf(lazyOf(() => isInlineNode))
310
342
  }));
311
343
  /**
312
344
  * Determine whether an arbitrary value is a valid {@link BlockNode} - a
@@ -350,7 +382,7 @@ var isBlockNode = unionOf(recordOf({
350
382
  element: literalOf("table"),
351
383
  header: arrayOf(arrayOf(isInlineNode)),
352
384
  rows: arrayOf(arrayOf(arrayOf(isInlineNode))),
353
- align: arrayOf(literalOf("none", "left", "right", "center"))
385
+ align: arrayOf(nullableOf(literalOf("left", "right", "center")))
354
386
  }), recordOf({
355
387
  element: literalOf("codeBlock"),
356
388
  lang: isString,
@@ -412,1580 +444,3055 @@ var isMarkdownDocument = recordOf({
412
444
  children: arrayOf(isBlockNode)
413
445
  });
414
446
  //#endregion
415
- //#region src/core/helpers.ts
447
+ //#region src/core/parsers.ts
416
448
  /**
417
- * Normalize line endings to `\n` and split a markdown document into its lines - CRLF
418
- * (`\r\n`) and bare CR (`\r`) both collapse to `\n` first, so a Windows-origin
419
- * document parses identically. A single trailing newline does not yield a final
420
- * empty line.
449
+ * Parses a run of markdown lines into a block AST, recursing into nested
450
+ * blockquotes, list items, and depth-capped degrade paragraphs.
421
451
  *
422
- * @param markdown - The raw markdown source
423
- * @returns The document's lines, line-terminators stripped
452
+ * @param lines - The markdown lines to parse.
453
+ * @param depth - The current recursion depth (blockquotes/lists increment it).
454
+ * @returns The parsed block nodes.
424
455
  *
425
456
  * @example
426
457
  * ```ts
427
- * splitLines('a\r\nb\nc') // ['a', 'b', 'c']
458
+ * parseBlocks(['# Hi'], 0) // [{ element: 'heading', level: 1, children: [...] }]
428
459
  * ```
429
460
  */
430
- function splitLines(markdown) {
431
- const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
432
- if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
433
- return lines;
461
+ function parseBlocks(lines, depth) {
462
+ if (depth >= 64) return lines.length > 0 ? [{
463
+ element: "paragraph",
464
+ children: [{
465
+ element: "text",
466
+ value: lines.join("\n")
467
+ }]
468
+ }] : [];
469
+ const blocks = [];
470
+ let index = 0;
471
+ while (index < lines.length) {
472
+ const line = lines[index] ?? "";
473
+ if (isBlankLine(line)) {
474
+ index += 1;
475
+ continue;
476
+ }
477
+ const fence = extractFence(line);
478
+ if (fence) {
479
+ const body = [];
480
+ index += 1;
481
+ while (index < lines.length && !isFenceClose(lines[index] ?? "", fence.marker)) {
482
+ body.push(lines[index] ?? "");
483
+ index += 1;
484
+ }
485
+ index += 1;
486
+ blocks.push({
487
+ element: "codeBlock",
488
+ ...fence.lang === void 0 ? {} : { lang: fence.lang },
489
+ code: body.join("\n")
490
+ });
491
+ continue;
492
+ }
493
+ if (isThematicBreak(line)) {
494
+ blocks.push({ element: "thematicBreak" });
495
+ index += 1;
496
+ continue;
497
+ }
498
+ const heading = extractHeading(line);
499
+ if (heading) {
500
+ blocks.push({
501
+ element: "heading",
502
+ level: heading.level,
503
+ children: parseInline(heading.text)
504
+ });
505
+ index += 1;
506
+ continue;
507
+ }
508
+ if (isQuote(line)) {
509
+ const quoted = [];
510
+ while (index < lines.length && isQuote(lines[index] ?? "")) {
511
+ quoted.push(stripQuote(lines[index] ?? ""));
512
+ index += 1;
513
+ }
514
+ blocks.push({
515
+ element: "blockquote",
516
+ children: parseBlocks(quoted, depth + 1)
517
+ });
518
+ continue;
519
+ }
520
+ if (isTableStart(line, lines[index + 1])) {
521
+ const table = collectTable(lines, index);
522
+ blocks.push(table.node);
523
+ index = table.next;
524
+ continue;
525
+ }
526
+ if (extractListItem(line)) {
527
+ const list = collectList(lines, index, depth);
528
+ blocks.push(list.node);
529
+ index = list.next;
530
+ continue;
531
+ }
532
+ const paragraph = [];
533
+ while (index < lines.length && !isBlankLine(lines[index] ?? "") && !(isNonEmptyArray(paragraph) && startsBlock(lines, index))) {
534
+ paragraph.push(lines[index] ?? "");
535
+ index += 1;
536
+ }
537
+ const source = paragraph.map((paragraphLine, position) => position < paragraph.length - 1 && paragraphLine.endsWith(" ") ? `${paragraphLine.trim()} ` : paragraphLine.trim()).join("\n");
538
+ blocks.push({
539
+ element: "paragraph",
540
+ children: parseInline(source)
541
+ });
542
+ }
543
+ return blocks;
434
544
  }
435
545
  /**
436
- * The count of leading space / tab characters on `line` (a tab counts as one) - the
437
- * indent that decides whether a list item's continuation belongs to the item.
546
+ * Collects a GFM table starting at a header row, parsing the header, the
547
+ * alignment row, and every contiguous body row that follows.
438
548
  *
439
- * @param line - The line to measure
440
- * @returns The number of leading space / tab characters
549
+ * @param lines - The markdown lines to scan.
550
+ * @param start - The index of the header row.
551
+ * @returns The parsed table node and the index of the first line after it.
441
552
  *
442
553
  * @example
443
554
  * ```ts
444
- * leadingIndent(' text') // 2
555
+ * collectTable(['| a |', '| - |'], 0) // { node: { element: 'table', ... }, next: 2 }
445
556
  * ```
446
557
  */
447
- function leadingIndent(line) {
448
- let count = 0;
449
- for (const character of line) if (character === " " || character === " ") count += 1;
450
- else break;
451
- return count;
558
+ function collectTable(lines, start) {
559
+ const headerCells = splitTableRow(lines[start] ?? "");
560
+ const columns = headerCells.length;
561
+ const header = headerCells.map((cell) => parseInline(cell.trim()));
562
+ const align = delimiterToAlignments(lines[start + 1] ?? "");
563
+ const padded = [];
564
+ for (let column = 0; column < columns; column += 1) padded.push(align[column] ?? null);
565
+ const rows = [];
566
+ let index = start + 2;
567
+ while (index < lines.length && !isBlankLine(lines[index] ?? "") && (lines[index] ?? "").includes("|")) {
568
+ const cells = splitTableRow(lines[index] ?? "");
569
+ const row = [];
570
+ for (let column = 0; column < columns; column += 1) row.push(parseInline((cells[column] ?? "").trim()));
571
+ rows.push(row);
572
+ index += 1;
573
+ }
574
+ return {
575
+ node: {
576
+ element: "table",
577
+ header,
578
+ rows,
579
+ align: padded
580
+ },
581
+ next: index
582
+ };
452
583
  }
453
584
  /**
454
- * Extract an ATX heading line (`#` `######` followed by text) into its
455
- * `{ level, text }`, or `undefined` when `line` is not a heading. A run of more than 6
456
- * `#`s, or `#`s not followed by whitespace + text, is not a
457
- * heading; an optional closing `###` run is stripped.
585
+ * Collects a list starting at the first item, gathering sibling items at the
586
+ * same indent/ordering and recursing into each item's own block content.
458
587
  *
459
- * @param line - The candidate line
460
- * @returns The heading level (1–6) and its raw inline text, or `undefined`
588
+ * @param lines - The markdown lines to scan.
589
+ * @param start - The index of the first list item.
590
+ * @param depth - The current recursion depth (each item recurses at `depth + 1`).
591
+ * @returns The parsed list node and the index of the first line after it.
461
592
  *
462
593
  * @example
463
594
  * ```ts
464
- * extractHeading('## Title') // { level: 2, text: 'Title' }
595
+ * collectList(['- item'], 0, 0) // { node: { element: 'list', ... }, next: 1 }
465
596
  * ```
466
597
  */
467
- function extractHeading(line) {
468
- const match = /^(#{1,6})(?:\s+(.*))?$/.exec(line.trimStart());
469
- if (!match || match[1] === void 0) return void 0;
598
+ function collectList(lines, start, depth) {
599
+ const first = extractListItem(lines[start] ?? "");
600
+ const ordered = first?.ordered ?? false;
601
+ const startOrdinal = first?.start ?? 1;
602
+ const topIndent = first?.indent ?? 0;
603
+ const items = [];
604
+ const chain = [];
605
+ let nested = true;
606
+ for (let cursor = start; cursor < lines.length; cursor += 1) {
607
+ const parsed = extractListItem(lines[cursor] ?? "");
608
+ const previous = chain[chain.length - 1];
609
+ if (parsed === void 0 || previous !== void 0 && (previous.content.length > 0 || parsed.indent !== previous.marker)) {
610
+ nested = false;
611
+ break;
612
+ }
613
+ chain.push(parsed);
614
+ }
615
+ const remaining = 64 - depth;
616
+ if (nested && remaining > 0 && chain.length > remaining) {
617
+ const terminal = chain[remaining - 1];
618
+ if (terminal !== void 0) {
619
+ const source = [terminal.content];
620
+ for (let cursor = start + remaining; cursor < lines.length; cursor += 1) source.push((lines[cursor] ?? "").slice(terminal.marker));
621
+ let children = [{
622
+ element: "paragraph",
623
+ children: [{
624
+ element: "text",
625
+ value: source.join("\n")
626
+ }]
627
+ }];
628
+ let node;
629
+ for (let cursor = remaining - 1; cursor >= 0; cursor -= 1) {
630
+ const parsed = chain[cursor];
631
+ if (parsed === void 0) continue;
632
+ node = {
633
+ element: "list",
634
+ ordered: parsed.ordered,
635
+ start: parsed.start,
636
+ items: [{
637
+ element: "listItem",
638
+ children
639
+ }]
640
+ };
641
+ children = [node];
642
+ }
643
+ if (node !== void 0) return {
644
+ node,
645
+ next: lines.length
646
+ };
647
+ }
648
+ }
649
+ let index = start;
650
+ while (index < lines.length) {
651
+ const parsed = extractListItem(lines[index] ?? "");
652
+ if (!parsed || parsed.indent > topIndent || parsed.ordered !== ordered) break;
653
+ const itemLines = [parsed.content];
654
+ const continuation = parsed.marker;
655
+ index += 1;
656
+ while (index < lines.length) {
657
+ const next = lines[index] ?? "";
658
+ if (isBlankLine(next)) {
659
+ const after = lines[index + 1] ?? "";
660
+ if (index + 1 < lines.length && !isBlankLine(after) && countIndent(after) >= continuation) {
661
+ itemLines.push("");
662
+ index += 1;
663
+ continue;
664
+ }
665
+ break;
666
+ }
667
+ if (countIndent(next) >= continuation) {
668
+ itemLines.push(next.slice(continuation));
669
+ index += 1;
670
+ continue;
671
+ }
672
+ if (extractListItem(next) || startsBlock(lines, index)) break;
673
+ itemLines.push(next.trim());
674
+ index += 1;
675
+ }
676
+ items.push({
677
+ element: "listItem",
678
+ children: parseBlocks(itemLines, depth + 1)
679
+ });
680
+ }
470
681
  return {
471
- level: match[1].length,
472
- text: (match[2] ?? "").replace(/\s+#+\s*$/, "").trim()
682
+ node: {
683
+ element: "list",
684
+ ordered,
685
+ start: startOrdinal,
686
+ items
687
+ },
688
+ next: index
473
689
  };
474
690
  }
475
691
  /**
476
- * Extract a fenced-code opening line (```` ``` ```` or `~~~`, optionally with an info
477
- * string) into its `{ marker, lang }`, or `undefined` when `line` is not a fence
478
- * opener. `marker` is the exact fence run (the closer must match the same character +
479
- * at least the same length); `lang` is the first word of the info string.
480
- *
481
- * @param line - The candidate line
482
- * @returns The fence marker run and its language tag, or `undefined`
692
+ * Parses a markdown string into a typed {@link MarkdownDocument} AST via the
693
+ * block phase.
483
694
  *
484
- * @example
485
- * ```ts
486
- * extractFence('```ts') // { marker: '```', lang: 'ts' }
487
- * ```
695
+ * @param markdown - The markdown source to parse.
696
+ * @returns The parsed document.
488
697
  */
489
- function extractFence(line) {
490
- const match = /^\s*(`{3,}|~{3,})\s*(.*)$/.exec(line);
491
- if (!match || match[1] === void 0) return void 0;
492
- const info = (match[2] ?? "").trim();
493
- if (match[1].startsWith("`") && info.includes("`")) return void 0;
494
- const lang = isNonEmptyString(info) ? info.split(/\s+/)[0] : void 0;
698
+ function parseDocument(markdown) {
495
699
  return {
496
- marker: match[1],
497
- lang
498
- };
700
+ element: "document",
701
+ children: parseBlocks(splitLines(markdown), 0)
702
+ };
499
703
  }
500
704
  /**
501
- * Extract a list-item line (`-` / `*` / `+` bullet, or `1.` / `1)` ordinal, followed by
502
- * a space) into its {@link ListItemParts}, or `undefined` when `line` is not a list
503
- * item. `content` is the text after the marker; `marker` is the full marker-plus-space
504
- * width (for measuring a continuation's indent).
705
+ * Parses inline markdown text (emphasis, code spans, links, images, and hard
706
+ * breaks) into inline AST nodes, coalescing adjacent text runs.
505
707
  *
506
- * @param line - The candidate line
507
- * @returns The list-item parts, or `undefined` when not a list item
708
+ * @param text - The inline markdown text to parse.
709
+ * @returns The parsed inline nodes.
710
+ */
711
+ function parseInline(text) {
712
+ return coalesceText(scanInline(text, 0, text.length));
713
+ }
714
+ //#endregion
715
+ //#region src/core/Markdown.ts
716
+ /**
717
+ * A stateful, parsed markdown document - wraps a typed {@link MarkdownDocument} AST
718
+ * with the query (`find` / `filter` / `reduce` / iteration), rewrite (`map`), fold, and
719
+ * streaming operations {@link MarkdownInterface} declares.
720
+ *
721
+ * @remarks
722
+ * - **Construction.** Given a `string`, the constructor runs {@link parseDocument} (the
723
+ * block phase then the inline phase) to build the AST. Given a {@link MarkdownDocument},
724
+ * the document is adopted AS-IS and is NOT re-validated - a caller adopting an
725
+ * untrusted value should gate it with `isMarkdownDocument` first.
726
+ * - **Immutable.** {@link map} never mutates the stored AST - it returns a NEW `Markdown`
727
+ * instance; the document root invariant (`element: 'document'`) always holds.
728
+ * - **Traversal order.** {@link walk} and the `find` / `filter` / `reduce` queries built
729
+ * on it walk the AST depth-first, pre-order, root-inclusive (via {@link walkNodes});
730
+ * `stream` is shallow - only the document's direct block children.
508
731
  *
509
732
  * @example
510
733
  * ```ts
511
- * extractListItem('- item') // { ordered: false, start: 1, content: 'item', indent: 0, marker: 2 }
734
+ * import { Markdown, isHeadingNode, renderMarkdown } from '@src/core'
735
+ *
736
+ * const markdown = new Markdown('# Title\n\nA **bold** [link](https://x.dev).')
737
+ * const heading = markdown.find(isHeadingNode) // the HeadingNode, or undefined
738
+ * const shouted = markdown.map((node) =>
739
+ * node.element === 'text' ? { element: 'text', value: node.value.toUpperCase() } : node,
740
+ * )
741
+ * renderMarkdown(shouted.document) // '# TITLE\n\nA **BOLD** [LINK](https://x.dev).'
512
742
  * ```
513
743
  */
514
- function extractListItem(line) {
515
- const unordered = /^(\s*)([-*+])\s+(.*)$/.exec(line);
516
- if (unordered && unordered[1] !== void 0) {
517
- const indent = unordered[1].length;
518
- const content = unordered[3] ?? "";
519
- return {
520
- ordered: false,
521
- start: 1,
522
- content,
523
- indent,
524
- marker: line.length - content.length
525
- };
744
+ var Markdown = class Markdown {
745
+ #document;
746
+ constructor(input) {
747
+ this.#document = typeof input === "string" ? parseDocument(input) : input;
526
748
  }
527
- const ordered = /^(\s*)(\d{1,9})[.)]\s+(.*)$/.exec(line);
528
- if (ordered && ordered[1] !== void 0 && ordered[2] !== void 0) {
529
- const indent = ordered[1].length;
530
- const content = ordered[3] ?? "";
531
- return {
532
- ordered: true,
533
- start: parseInteger(ordered[2]) ?? 1,
534
- content,
535
- indent,
536
- marker: line.length - content.length
537
- };
749
+ /** The stored {@link MarkdownDocument} AST root. */
750
+ get document() {
751
+ return this.#document;
538
752
  }
539
- }
753
+ /**
754
+ * THE deep traversal - a lazy, depth-first, pre-order, root-inclusive generator
755
+ * over every {@link MarkdownNode} in the document. `find` / `filter` / `reduce`
756
+ * all iterate this single traversal.
757
+ *
758
+ * @example
759
+ * ```ts
760
+ * for (const node of markdown.walk()) {
761
+ * // every node, depth-first, pre-order, root-inclusive
762
+ * }
763
+ *
764
+ * // also consumable by for-await - JS accepts a sync iterable in for-await
765
+ * for await (const node of markdown.walk()) {
766
+ * // same sequence, no separate async iterator needed
767
+ * }
768
+ * ```
769
+ */
770
+ *walk() {
771
+ yield* walkNodes(this.#document);
772
+ }
773
+ find(predicate) {
774
+ for (const node of this.walk()) if (predicate(node)) return node;
775
+ }
776
+ filter(predicate) {
777
+ const out = [];
778
+ for (const node of this.walk()) if (predicate(node)) out.push(node);
779
+ return out;
780
+ }
781
+ /** Rewrites the AST bottom-up (copy-on-write) and returns a new {@link Markdown}. */
782
+ map(rewrite) {
783
+ return new Markdown(rewriteDocument(this.#document, rewrite));
784
+ }
785
+ /** Folds the AST depth-first, pre-order into an accumulator. */
786
+ reduce(callback, initial) {
787
+ let accumulator = initial;
788
+ for (const node of this.walk()) accumulator = callback(accumulator, node);
789
+ return accumulator;
790
+ }
791
+ /** Runs a total catamorphism over the document using a {@link MarkdownHandlers} table. */
792
+ fold(handlers) {
793
+ return foldNode(this.#document, handlers, 0);
794
+ }
795
+ /**
796
+ * A web-standard {@link ReadableStream} over the document's top-level block nodes
797
+ * (shallow, source order) - a fresh, pull-based source per call: one block is
798
+ * enqueued per `pull`, so a slow reader's backpressure is respected. Cancellable,
799
+ * async-iterable wherever the platform supports it (Node, Deno), and pipeable
800
+ * through any {@link TransformStream} / {@link WritableStream}.
801
+ *
802
+ * @example
803
+ * ```ts
804
+ * // universal - works in every ReadableStream-supporting environment
805
+ * const reader = markdown.stream().getReader()
806
+ * for (let result = await reader.read(); !result.done; result = await reader.read()) {
807
+ * console.log(result.value) // one BlockNode
808
+ * }
809
+ *
810
+ * // Node / Deno / Firefox support async iteration of ReadableStream natively;
811
+ * // other environments should use the reader loop above instead.
812
+ * for await (const block of markdown.stream()) {
813
+ * console.log(block)
814
+ * }
815
+ * ```
816
+ */
817
+ stream() {
818
+ const blocks = this.#document.children;
819
+ let index = 0;
820
+ return new ReadableStream({ pull(controller) {
821
+ if (index < blocks.length) {
822
+ const block = blocks[index];
823
+ if (block === void 0) {
824
+ controller.close();
825
+ return;
826
+ }
827
+ controller.enqueue(block);
828
+ index += 1;
829
+ } else controller.close();
830
+ } });
831
+ }
832
+ };
833
+ //#endregion
834
+ //#region src/core/shapers.ts
540
835
  /**
541
- * Strip one level of blockquote marker (`>` plus one optional following space) from a
542
- * blockquote line, so the de-quoted lines re-parse as nested blocks.
543
- *
544
- * @param line - A blockquote line (per {@link isQuote})
545
- * @returns The line with its leading `>` (and one space) removed
836
+ * The shape of a {@link TextNode} - a plain-text leaf inline run.
546
837
  *
547
838
  * @example
548
839
  * ```ts
549
- * stripQuote('> text') // 'text'
840
+ * import { createContract } from '@orkestrel/contract'
841
+ * import { textShape } from '@src/core'
842
+ *
843
+ * const text = createContract(textShape)
844
+ * text.is({ element: 'text', value: 'hi' }) // true
550
845
  * ```
551
846
  */
552
- function stripQuote(line) {
553
- return line.replace(/^\s{0,3}>\s?/, "");
554
- }
847
+ var textShape = objectShape({
848
+ element: literalShape(["text"]),
849
+ value: stringShape()
850
+ });
555
851
  /**
556
- * Split one GFM table row into its cell strings - outer pipes are optional, an escaped
557
- * pipe (`\|`) inside a cell is NOT a separator (it becomes a literal `|`), and the
558
- * empty leading / trailing cell produced by an outer `|` is dropped.
559
- *
560
- * @param row - The raw table row line
561
- * @returns The row's cells, in column order
852
+ * The shape of a {@link CodeSpanNode} - an inline code span (`` `code` ``).
562
853
  *
563
854
  * @example
564
855
  * ```ts
565
- * splitTableRow('|a|b|') // ['a', 'b']
856
+ * import { createContract } from '@orkestrel/contract'
857
+ * import { codeSpanShape } from '@src/core'
858
+ *
859
+ * const codeSpan = createContract(codeSpanShape)
860
+ * codeSpan.is({ element: 'codeSpan', value: 'const x = 1' }) // true
566
861
  * ```
567
862
  */
568
- function splitTableRow(row) {
569
- const cells = [];
570
- let current = "";
571
- const trimmed = row.trim();
572
- for (let index = 0; index < trimmed.length; index += 1) {
573
- const character = trimmed[index];
574
- if (character === "\\" && trimmed[index + 1] === "|") {
575
- current += "|";
576
- index += 1;
577
- } else if (character === "|") {
578
- cells.push(current);
579
- current = "";
580
- } else current += character;
581
- }
582
- cells.push(current);
583
- if (isNonEmptyArray(cells) && isEmptyString((cells[0] ?? "").trim())) cells.shift();
584
- if (isNonEmptyArray(cells) && isEmptyString((cells[cells.length - 1] ?? "").trim())) cells.pop();
585
- return cells;
586
- }
863
+ var codeSpanShape = objectShape({
864
+ element: literalShape(["codeSpan"]),
865
+ value: stringShape()
866
+ });
587
867
  /**
588
- * Derive the per-column {@link TableAlign} list from a GFM delimiter row - `:---`
589
- * left, `---:` right, `:---:` center, `---` none.
590
- *
591
- * @param delimiter - The table's delimiter row
592
- * @returns One alignment per column, in column order
868
+ * The shape of a {@link LineBreakNode} - a GFM hard line-break leaf.
593
869
  *
594
870
  * @example
595
871
  * ```ts
596
- * tableAlignments('| :--- | ---: |') // ['left', 'right']
872
+ * import { createContract } from '@orkestrel/contract'
873
+ * import { lineBreakShape } from '@src/core'
874
+ *
875
+ * const lineBreak = createContract(lineBreakShape)
876
+ * lineBreak.is({ element: 'break' }) // true
597
877
  * ```
598
878
  */
599
- function tableAlignments(delimiter) {
600
- return splitTableRow(delimiter).map((cell) => {
601
- const text = cell.trim();
602
- const left = text.startsWith(":");
603
- const right = text.endsWith(":");
604
- if (left && right) return "center";
605
- if (right) return "right";
606
- if (left) return "left";
607
- return "none";
608
- });
609
- }
879
+ var lineBreakShape = objectShape({ element: literalShape(["break"]) });
610
880
  /**
611
- * Whether the line at `index` starts a NEW block kind (heading / fence / thematic
612
- * break / blockquote / list / table) - the paragraph collector stops at such a line
613
- * so a block following a paragraph without a blank line still parses (a trusted-input
614
- * caller writing a `##` heading directly under a paragraph, with no intervening blank
615
- * line).
616
- *
617
- * @param lines - The document's lines
618
- * @param index - The line index to test
619
- * @returns `true` when the line begins a different block
881
+ * The shape of a {@link CodeBlockNode} - a fenced code block. `lang` is
882
+ * optional (absent when the opening fence carries no info-string).
620
883
  *
621
884
  * @example
622
885
  * ```ts
623
- * startsBlock(['text', '## Heading'], 1) // true
886
+ * import { createContract } from '@orkestrel/contract'
887
+ * import { codeBlockShape } from '@src/core'
888
+ *
889
+ * const codeBlock = createContract(codeBlockShape)
890
+ * codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
891
+ * codeBlock.is({ element: 'codeBlock', code: 'x', lang: 'ts' }) // true
624
892
  * ```
625
893
  */
626
- function startsBlock(lines, index) {
627
- const line = lines[index] ?? "";
628
- return extractHeading(line) !== void 0 || extractFence(line) !== void 0 || isThematicBreak(line) || isQuote(line) || extractListItem(line) !== void 0 || isTableStart(line, lines[index + 1]);
629
- }
894
+ var codeBlockShape = objectShape({
895
+ element: literalShape(["codeBlock"]),
896
+ lang: optionalShape(stringShape()),
897
+ code: stringShape()
898
+ });
630
899
  /**
631
- * Resolve backslash escapes in a raw string to their literal characters - used for a
632
- * link `href` (which is not otherwise inline-parsed) and any plain text run.
633
- *
634
- * @param text - The raw text possibly carrying `\x` escapes
635
- * @returns The text with escapable `\x` reduced to `x`
900
+ * The shape of a {@link ThematicBreakNode} - a horizontal rule. Carries no
901
+ * fields beyond its `element` discriminant.
636
902
  *
637
903
  * @example
638
904
  * ```ts
639
- * unescapeText('\\*hi\\*') // '*hi*'
640
- * ```
641
- */
642
- function unescapeText(text) {
643
- let out = "";
644
- for (let index = 0; index < text.length; index += 1) {
645
- const character = text[index] ?? "";
646
- if (character === "\\" && isEscapable(text[index + 1] ?? "")) {
647
- out += text[index + 1] ?? "";
648
- index += 1;
649
- } else out += character;
650
- }
651
- return out;
652
- }
905
+ * import { createContract } from '@orkestrel/contract'
906
+ * import { thematicBreakShape } from '@src/core'
907
+ *
908
+ * const thematicBreak = createContract(thematicBreakShape)
909
+ * thematicBreak.is({ element: 'thematicBreak' }) // true
910
+ * ```
911
+ */
912
+ var thematicBreakShape = objectShape({ element: literalShape(["thematicBreak"]) });
653
913
  /**
654
- * Merge adjacent text nodes into one - the inline scanner emits a text node per
655
- * unrecognized character, so coalescing keeps the AST clean and assertion-friendly.
914
+ * The shape of a {@link TableAlign} - the per-column GFM table alignment
915
+ * literal.
656
916
  *
657
- * @param nodes - The inline nodes (possibly with adjacent text runs)
658
- * @returns The nodes with consecutive text nodes concatenated
917
+ * @example
918
+ * ```ts
919
+ * import { createContract } from '@orkestrel/contract'
920
+ * import { tableAlignShape } from '@src/core'
921
+ *
922
+ * const tableAlign = createContract(tableAlignShape)
923
+ * tableAlign.is('left') // true
924
+ * tableAlign.is('center') // true
925
+ * tableAlign.is('top') // false
926
+ * ```
927
+ */
928
+ var tableAlignShape = literalShape([
929
+ "left",
930
+ "right",
931
+ "center"
932
+ ]);
933
+ /**
934
+ * The shape of {@link ListItemMatch} - the parsed parts of a single list-item
935
+ * line the block phase's list detector returns. Fully non-recursive (no
936
+ * nested node fields), so every field shapes directly.
659
937
  *
660
938
  * @example
661
939
  * ```ts
662
- * coalesceText([{ element: 'text', value: 'a' }, { element: 'text', value: 'b' }])
663
- * // [{ element: 'text', value: 'ab' }]
940
+ * import { createContract } from '@orkestrel/contract'
941
+ * import { listItemMatchShape } from '@src/core'
942
+ *
943
+ * const listItemParts = createContract(listItemMatchShape)
944
+ * listItemParts.is({ ordered: false, start: 1, content: 'hi', indent: 0, marker: 2 }) // true
664
945
  * ```
665
946
  */
666
- function coalesceText(nodes) {
667
- const out = [];
668
- for (const node of nodes) {
669
- const last = out[out.length - 1];
670
- if (node.element === "text" && last !== void 0 && last.element === "text") out[out.length - 1] = {
671
- element: "text",
672
- value: last.value + node.value
673
- };
674
- else out.push(node);
675
- }
676
- return out;
677
- }
947
+ var listItemMatchShape = objectShape({
948
+ ordered: booleanShape(),
949
+ start: integerShape(),
950
+ content: stringShape(),
951
+ indent: integerShape(),
952
+ marker: integerShape()
953
+ });
954
+ //#endregion
955
+ //#region src/core/factories.ts
678
956
  /**
679
- * Scan an inline code span at `start` (a `` ` ``-run … a matching `` ` ``-run of the
680
- * SAME length, the CommonMark rule that lets a span contain backticks). Returns the
681
- * span's literal text + end index, or `undefined` when no matching closer exists (it
682
- * then degrades to literal backticks).
957
+ * Create an HTML-to-markdown projection with absent fields defaulted from
958
+ * {@link EMPTY_PROJECTION} and the block/inline exclusivity invariant enforced.
683
959
  *
684
- * @param source - The inline source text
685
- * @param start - The index of the opening backtick
686
- * @param to - The exclusive end of the scan window
687
- * @returns The span text + end index, or `undefined`
960
+ * @remarks
961
+ * A block-bearing projection cannot also expose inline content. Callers may provide
962
+ * both views, but `inlines` is flushed whenever `blocks` is non-empty.
963
+ *
964
+ * @param parts - The projection fields to provide
965
+ * @returns A complete invariant-preserving projection
688
966
  *
689
967
  * @example
690
968
  * ```ts
691
- * scanCode('`code`', 0, 6) // { value: 'code', end: 6 }
969
+ * createProjection({
970
+ * blocks: [{ element: 'thematicBreak' }],
971
+ * inlines: [{ element: 'text', value: 'discarded' }],
972
+ * })
973
+ * // { blocks: [{ element: 'thematicBreak' }], inlines: [], text: '', cells: [], rows: [] }
692
974
  * ```
693
975
  */
694
- function scanCode(source, start, to) {
695
- let run = 0;
696
- while (start + run < to && source[start + run] === "`") run += 1;
697
- const open = "`".repeat(run);
698
- let search = start + run;
699
- for (;;) {
700
- const closeAt = source.indexOf(open, search);
701
- if (closeAt === -1 || closeAt + run > to) return void 0;
702
- if (source[closeAt - 1] !== "`" && source[closeAt + run] !== "`") {
703
- let value = source.slice(start + run, closeAt);
704
- if (value.length > 2 && value.startsWith(" ") && value.endsWith(" ") && value.trim().length > 0) value = value.slice(1, -1);
705
- return {
706
- value,
707
- end: closeAt + run
708
- };
709
- }
710
- search = closeAt + 1;
711
- }
976
+ function createProjection(parts = {}) {
977
+ const blocks = parts.blocks ?? EMPTY_PROJECTION.blocks;
978
+ return {
979
+ blocks,
980
+ inlines: blocks.length === 0 ? parts.inlines ?? EMPTY_PROJECTION.inlines : [],
981
+ text: parts.text ?? EMPTY_PROJECTION.text,
982
+ cells: parts.cells ?? EMPTY_PROJECTION.cells,
983
+ rows: parts.rows ?? EMPTY_PROJECTION.rows
984
+ };
712
985
  }
713
986
  /**
714
- * Scan a link `[text](href)` at `start` - the text runs to a BALANCED `]`, then `(`
715
- * must immediately follow and the destination runs to the matching `)` (both respect
716
- * nested delimiters + escapes). Returns the link node, or `undefined` when the shape
717
- * does not hold (it then degrades to a literal `[`).
987
+ * Create a stateful markdown handle from a markdown string or an already-parsed
988
+ * {@link MarkdownDocument} - a typed AST plus the query, rewrite, and fold operations
989
+ * {@link MarkdownInterface} exposes.
718
990
  *
719
- * @param source - The inline source text
720
- * @param start - The index of the opening `[`
721
- * @param to - The exclusive end of the scan window
722
- * @param depth - The current inline-recursion depth (defaults to 0 at the entry point);
723
- * at {@link MAX_DEPTH} the link's text children degrade to literal text instead of
724
- * recursing further
725
- * @returns The parsed {@link LinkNode} + end index, or `undefined`
991
+ * @remarks
992
+ * Given a `string`, runs a block phase (headings / paragraphs / lists / GFM tables /
993
+ * fenced code / blockquotes / thematic breaks) then an inline phase (emphasis /
994
+ * inline code / links / images / hard breaks) to build a render-agnostic
995
+ * {@link MarkdownDocument}. Given a
996
+ * {@link MarkdownDocument}, adopts it AS-IS without re-validation - gate an untrusted
997
+ * value with `isMarkdownDocument` first. Pure + total parse (malformed markdown
998
+ * degrades to text, never throws) and zero-dependency - a hand-written scanner, no
999
+ * regex-only structural parse, linear-time (no ReDoS).
1000
+ *
1001
+ * @param input - A markdown string to parse, or an already-parsed {@link MarkdownDocument}
1002
+ * @returns A working {@link MarkdownInterface}
726
1003
  *
727
1004
  * @example
728
1005
  * ```ts
729
- * scanLink('[text](url)', 0, 11)
730
- * // { node: { element: 'link', href: 'url', children: [...] }, end: 11 }
1006
+ * import { createMarkdown } from '@src/core'
1007
+ *
1008
+ * const markdown = createMarkdown('# Hi\n\nRead the [guide](./guide.md).')
1009
+ * markdown.document.children[0] // { element: 'heading', ... }
731
1010
  * ```
732
1011
  */
733
- function scanLink(source, start, to, depth = 0) {
734
- let bracketDepth = 0;
735
- let close = -1;
736
- for (let index = start; index < to; index += 1) {
737
- const character = source[index] ?? "";
738
- if (character === "\\") {
739
- index += 1;
740
- continue;
741
- }
742
- if (character === "[") bracketDepth += 1;
743
- else if (character === "]") {
744
- bracketDepth -= 1;
745
- if (bracketDepth === 0) {
746
- close = index;
747
- break;
748
- }
749
- }
750
- }
751
- if (close === -1 || source[close + 1] !== "(") return void 0;
752
- let parenDepth = 0;
753
- let parenClose = -1;
754
- for (let index = close + 1; index < to; index += 1) {
755
- const character = source[index] ?? "";
756
- if (character === "\\") {
757
- index += 1;
758
- continue;
759
- }
760
- if (character === "(") parenDepth += 1;
761
- else if (character === ")") {
762
- parenDepth -= 1;
763
- if (parenDepth === 0) {
764
- parenClose = index;
765
- break;
766
- }
767
- }
768
- }
769
- if (parenClose === -1) return void 0;
770
- return {
771
- node: {
772
- element: "link",
773
- href: unescapeText(source.slice(close + 2, parenClose).trim()),
774
- children: scanInline(source, start + 1, close, depth + 1)
775
- },
776
- end: parenClose + 1
777
- };
1012
+ function createMarkdown(input) {
1013
+ return new Markdown(input);
778
1014
  }
779
1015
  /**
780
- * Scan an emphasis run at `start` (`*` / `_`, doubled for strong) - finds the nearest
781
- * matching closing run of the same marker + width, requiring non-space immediately
782
- * inside both delimiters (the CommonMark flanking simplification that blocks `* x *`).
783
- * Returns the emphasis node, or `undefined` when no valid closer exists (it then
784
- * degrades to a literal marker).
1016
+ * Compile the {@link textShape} into a {@link ContractInterface} for
1017
+ * {@link TextNode} - a guard, coercing parser, JSON Schema, and seeded
1018
+ * generator from one shape declaration (AGENTS §14).
785
1019
  *
786
- * @param source - The inline source text
787
- * @param start - The index of the opening marker
788
- * @param to - The exclusive end of the scan window
789
- * @param depth - The current inline-recursion depth (defaults to 0 at the entry point);
790
- * at {@link MAX_DEPTH} the emphasis's children degrade to literal text instead of
791
- * recursing further
792
- * @returns The parsed {@link EmphasisNode} + end index, or `undefined`
1020
+ * @returns A `TextNode` contract bundling `schema` / `is` / `parse` / `generate`
793
1021
  *
794
1022
  * @example
795
1023
  * ```ts
796
- * scanEmphasis('*em*', 0, 4)
797
- * // { node: { element: 'emphasis', strong: false, children: [...] }, end: 4 }
1024
+ * import { createTextContract } from '@src/core'
1025
+ *
1026
+ * const text = createTextContract()
1027
+ * text.is({ element: 'text', value: 'hi' }) // true
798
1028
  * ```
799
1029
  */
800
- function scanEmphasis(source, start, to, depth = 0) {
801
- const marker = source[start] ?? "";
802
- let run = 0;
803
- while (start + run < to && source[start + run] === marker && run < 2) run += 1;
804
- const strong = run === 2;
805
- const openEnd = start + run;
806
- if (openEnd >= to || isWhitespace(source[openEnd] ?? "")) return void 0;
807
- let index = openEnd;
808
- while (index < to) {
809
- const character = source[index] ?? "";
810
- if (character === "\\") {
811
- index += 2;
812
- continue;
813
- }
814
- if (character === "`") {
815
- const span = scanCode(source, index, to);
816
- index = span ? span.end : index + 1;
817
- continue;
818
- }
819
- if (character === marker) {
820
- let closeRun = 0;
821
- while (index + closeRun < to && source[index + closeRun] === marker) closeRun += 1;
822
- if (closeRun >= run && !isWhitespace(source[index - 1] ?? "")) return {
823
- node: {
824
- element: "emphasis",
825
- strong,
826
- children: scanInline(source, openEnd, index, depth + 1)
827
- },
828
- end: index + run
829
- };
830
- index += closeRun;
831
- continue;
832
- }
833
- index += 1;
834
- }
1030
+ function createTextContract() {
1031
+ return createContract(textShape);
835
1032
  }
836
1033
  /**
837
- * Scan the window `[from, to)` of `source` into inline nodes - the single recursive
838
- * engine the inline phase runs on (emphasis / link text recurse through it). Linear:
839
- * each character is consumed once; a failed construct emits its opening character as
840
- * text and advances by one, so there is no re-scan (no ReDoS).
1034
+ * Compile the {@link codeSpanShape} into a {@link ContractInterface} for
1035
+ * {@link CodeSpanNode} - a guard, coercing parser, JSON Schema, and seeded
1036
+ * generator from one shape declaration (AGENTS §14).
841
1037
  *
842
- * @param source - The inline source text
843
- * @param from - The inclusive start of the scan window
844
- * @param to - The exclusive end of the scan window
845
- * @param depth - The current inline-recursion depth (defaults to 0 at the entry point);
846
- * incremented by one on every recursive descent through {@link scanLink} /
847
- * {@link scanEmphasis}. At {@link MAX_DEPTH} the window is never scanned for markup -
848
- * it emits as a single literal text node - so pathological nesting (`[[[[…`,
849
- * `****…`) cannot exhaust the call stack.
850
- * @returns The parsed inline nodes (NOT yet coalesced)
1038
+ * @returns A `CodeSpanNode` contract bundling `schema` / `is` / `parse` / `generate`
851
1039
  *
852
1040
  * @example
853
1041
  * ```ts
854
- * scanInline('hi *there*', 0, 10) // [{ element: 'text', value: 'hi ' }, { element: 'emphasis', ... }]
1042
+ * import { createCodeSpanContract } from '@src/core'
1043
+ *
1044
+ * const codeSpan = createCodeSpanContract()
1045
+ * codeSpan.is({ element: 'codeSpan', value: 'const x = 1' }) // true
855
1046
  * ```
856
1047
  */
857
- function scanInline(source, from, to, depth = 0) {
858
- if (depth >= 64) return from < to ? [{
859
- element: "text",
860
- value: source.slice(from, to)
861
- }] : [];
862
- const nodes = [];
863
- let index = from;
864
- let pending = "";
865
- const flush = () => {
866
- if (pending.length > 0) {
867
- nodes.push({
868
- element: "text",
869
- value: pending
870
- });
871
- pending = "";
872
- }
873
- };
874
- while (index < to) {
875
- const character = source[index] ?? "";
876
- if (character === "\\" && index + 1 < to && isEscapable(source[index + 1] ?? "")) {
877
- pending += source[index + 1] ?? "";
878
- index += 2;
879
- continue;
880
- }
881
- if (character === "`") {
882
- const span = scanCode(source, index, to);
883
- if (span) {
884
- flush();
885
- nodes.push({
886
- element: "codeSpan",
887
- value: span.value
888
- });
889
- index = span.end;
890
- continue;
891
- }
892
- }
893
- if (character === "[") {
894
- const link = scanLink(source, index, to, depth);
895
- if (link) {
896
- flush();
897
- nodes.push(link.node);
898
- index = link.end;
899
- continue;
900
- }
901
- }
902
- if (character === "*" || character === "_") {
903
- const emphasis = scanEmphasis(source, index, to, depth);
904
- if (emphasis) {
905
- flush();
906
- nodes.push(emphasis.node);
907
- index = emphasis.end;
908
- continue;
909
- }
910
- }
911
- pending += character;
912
- index += 1;
913
- }
914
- flush();
915
- return nodes;
1048
+ function createCodeSpanContract() {
1049
+ return createContract(codeSpanShape);
916
1050
  }
917
1051
  /**
918
- * HTML-escape text content - `&` / `<` / `>` / `"` / `'` to their entities - so text
919
- * from a markdown document can never inject markup. The renderer applies this to every
920
- * text run, code body, and (escaped further) attribute value.
1052
+ * Compile the {@link lineBreakShape} into a {@link ContractInterface} for
1053
+ * {@link LineBreakNode}.
921
1054
  *
922
- * @param text - The raw text
923
- * @returns The HTML-escaped text
1055
+ * @returns A `LineBreakNode` contract bundling `schema` / `is` / `parse` / `generate`
924
1056
  *
925
1057
  * @example
926
1058
  * ```ts
927
- * escapeHtml('<a>&"\'') // '&lt;a&gt;&amp;&quot;&#39;'
1059
+ * import { createLineBreakContract } from '@src/core'
1060
+ *
1061
+ * createLineBreakContract().is({ element: 'break' }) // true
928
1062
  * ```
929
1063
  */
930
- function escapeHtml(text) {
931
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1064
+ function createLineBreakContract() {
1065
+ return createContract(lineBreakShape);
932
1066
  }
933
1067
  /**
934
- * Sanitize + HTML-attribute-escape a link `href` - a destination whose scheme is not
935
- * in {@link SAFE_URL_SCHEMES} (notably `javascript:` / `data:` / `vbscript:`), or that
936
- * is protocol-relative (`//host/path`, or a backslash variant a browser normalizes to
937
- * the same effect - `\\host`, `/\host`, `\/host` - inherits whatever scheme the
938
- * embedding page is served over, including an unsafe one), is dropped to an empty
939
- * string; a relative / anchor / scheme-less (and non-protocol-relative) destination
940
- * (including a SINGLE leading `/` or `\`) is kept;
941
- * the surviving value is then HTML-escaped. Defence-in-depth against an XSS `href`,
942
- * even though the input is trusted.
1068
+ * Compile the {@link codeBlockShape} into a {@link ContractInterface} for
1069
+ * {@link CodeBlockNode} - a guard, coercing parser, JSON Schema, and seeded
1070
+ * generator from one shape declaration (AGENTS §14).
943
1071
  *
944
- * @param href - The raw link destination
945
- * @returns A safe, escaped `href` (empty when the scheme is unsafe or protocol-relative)
1072
+ * @returns A `CodeBlockNode` contract bundling `schema` / `is` / `parse` / `generate`
946
1073
  *
947
1074
  * @example
948
1075
  * ```ts
949
- * sanitizeUrl('javascript:alert(1)') // ''
950
- * sanitizeUrl('/path') // '/path'
1076
+ * import { createCodeBlockContract } from '@src/core'
1077
+ *
1078
+ * const codeBlock = createCodeBlockContract()
1079
+ * codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
951
1080
  * ```
952
1081
  */
953
- function sanitizeUrl(href) {
954
- let cleaned = "";
955
- for (const character of href) {
956
- const code = character.codePointAt(0) ?? 0;
957
- if (code > 32 && !(code >= 127 && code <= 159)) cleaned += character;
958
- }
959
- if (/^[/\\]{2}/.exec(cleaned)) return "";
960
- const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(cleaned);
961
- if (scheme && scheme[1] !== void 0 && !SAFE_URL_SCHEMES.has(scheme[1].toLowerCase())) return "";
962
- return escapeHtml(cleaned);
1082
+ function createCodeBlockContract() {
1083
+ return createContract(codeBlockShape);
963
1084
  }
964
1085
  /**
965
- * Render a {@link MarkdownNode} (typically a {@link MarkdownDocument}) to a safe HTML
966
- * string - the recursive AST → HTML engine (headings, paragraphs, lists, GFM tables,
967
- * fenced code, blockquotes, links, emphasis, inline code), escaping every text run and
968
- * sanitizing every link `href`.
969
- *
970
- * @remarks
971
- * Total: never throws. At {@link MAX_DEPTH} a value-bearing node (`text` / `codeSpan`)
972
- * degrades to its escaped `value`; any other node degrades to `''` instead of
973
- * recursing further, so pathologically deep input cannot exhaust the call stack. The
974
- * recursive engine and its per-shape sub-steps (inline concatenation, table cell,
975
- * tight list-item) are nested inner functions - the only exported surface is
976
- * `renderHTML` itself.
1086
+ * Compile the {@link thematicBreakShape} into a {@link ContractInterface} for
1087
+ * {@link ThematicBreakNode} - a guard, coercing parser, JSON Schema, and
1088
+ * seeded generator from one shape declaration (AGENTS §14).
977
1089
  *
978
- * @param node - The AST node to render (a full document, or any sub-node)
979
- * @returns The rendered, XSS-safe HTML string
1090
+ * @returns A `ThematicBreakNode` contract bundling `schema` / `is` / `parse` / `generate`
980
1091
  *
981
1092
  * @example
982
1093
  * ```ts
983
- * renderHTML({ element: 'document', children: [
984
- * { element: 'heading', level: 1, children: [{ element: 'text', value: 'Hi' }] },
985
- * ] })
986
- * // '<h1>Hi</h1>'
1094
+ * import { createThematicBreakContract } from '@src/core'
1095
+ *
1096
+ * const thematicBreak = createThematicBreakContract()
1097
+ * thematicBreak.is({ element: 'thematicBreak' }) // true
987
1098
  * ```
988
1099
  */
989
- function renderHTML(node) {
990
- function render(current, depth) {
991
- if (depth >= 64) return "value" in current && typeof current.value === "string" ? escapeHtml(current.value) : "";
992
- switch (current.element) {
993
- case "document": return current.children.map((child) => render(child, depth + 1)).join("\n");
994
- case "heading": return `<h${current.level}>${renderInline(current.children, depth)}</h${current.level}>`;
995
- case "paragraph": return `<p>${renderInline(current.children, depth)}</p>`;
996
- case "thematicBreak": return "<hr>";
997
- case "blockquote": return `<blockquote>\n${current.children.map((child) => render(child, depth + 1)).join("\n")}\n</blockquote>`;
998
- case "codeBlock": return `<pre>${current.lang === void 0 ? "<code>" : `<code class="language-${escapeHtml(current.lang)}">`}${escapeHtml(current.code)}</code></pre>`;
999
- case "list": {
1000
- const items = current.items.map((item) => render(item, depth + 1)).join("\n");
1001
- if (!current.ordered) return `<ul>\n${items}\n</ul>`;
1002
- return `<ol${current.start !== 1 ? ` start="${current.start}"` : ""}>\n${items}\n</ol>`;
1003
- }
1004
- case "listItem": return `<li>${renderItem(current.children, depth)}</li>`;
1005
- case "table": {
1006
- const head = `<tr>${current.header.map((cell, column) => renderCell("th", cell, current.align[column], depth)).join("")}</tr>`;
1007
- const body = current.rows.map((row) => `<tr>${row.map((cell, column) => renderCell("td", cell, current.align[column], depth)).join("")}</tr>`).join("\n");
1008
- return `<table>\n<thead>\n${head}\n</thead>${isNonEmptyArray(current.rows) ? `\n<tbody>\n${body}\n</tbody>` : ""}\n</table>`;
1009
- }
1010
- case "text": return escapeHtml(current.value);
1011
- case "emphasis": return current.strong ? `<strong>${renderInline(current.children, depth + 1)}</strong>` : `<em>${renderInline(current.children, depth + 1)}</em>`;
1012
- case "codeSpan": return `<code>${escapeHtml(current.value)}</code>`;
1013
- case "link": return `<a href="${sanitizeUrl(current.href)}">${renderInline(current.children, depth + 1)}</a>`;
1014
- default: return "";
1015
- }
1016
- }
1017
- function renderInline(nodes, depth) {
1018
- return nodes.map((child) => render(child, depth + 1)).join("");
1019
- }
1020
- function renderCell(tag, cell, align, depth) {
1021
- return `<${tag}${align === "left" || align === "right" || align === "center" ? ` style="text-align:${align}"` : ""}>${renderInline(cell, depth + 1)}</${tag}>`;
1022
- }
1023
- function renderItem(children, depth) {
1024
- if (children.length === 1) {
1025
- const only = children[0];
1026
- if (only !== void 0 && only.element === "paragraph") return renderInline(only.children, depth);
1027
- }
1028
- return children.map((child) => render(child, depth + 1)).join("\n");
1029
- }
1030
- return render(node, 0);
1100
+ function createThematicBreakContract() {
1101
+ return createContract(thematicBreakShape);
1031
1102
  }
1103
+ //#endregion
1104
+ //#region src/core/helpers.ts
1032
1105
  /**
1033
- * Render a {@link MarkdownNode} to its CANONICAL markdown source - the inverse
1034
- * projection of `renderHTML`, and the serializer a `parse(renderMarkdown(doc))`
1035
- * round-trip is built on. Canonical forms: `*em*` / `**strong**` (underscore emphasis
1036
- * normalizes to asterisks), `- ` bullets, `N. ` sequential ordinals (from the list's
1037
- * `start`), `---` thematic breaks, fenced code blocks (backtick run widened past any
1038
- * 3+ backtick run inside the body), ATX headings, `> `-prefixed blockquote lines, GFM
1039
- * tables (1-space-padded cells, `\|`-escaped pipes, an alignment delimiter row), and
1040
- * `[text](href)` links. A `text` node's literal content is backslash-escaped wherever
1041
- * it would otherwise re-parse as markup (AGENTS §14 parse↔render soundness).
1042
- *
1043
- * @remarks
1044
- * Total: never throws. At {@link MAX_DEPTH} a value-bearing node degrades to its
1045
- * escaped `value`; any other node degrades to `''`. Blocks are joined by exactly one
1046
- * blank line; a document with zero blocks renders `''`.
1106
+ * Normalize line endings to `\n` and split a markdown document into its lines - CRLF
1107
+ * (`\r\n`) and bare CR (`\r`) both collapse to `\n` first, so a Windows-origin
1108
+ * document parses identically. A single trailing newline does not yield a final
1109
+ * empty line.
1047
1110
  *
1048
- * @param node - The AST node to render (a full document, or any sub-node)
1049
- * @returns The canonical markdown source
1111
+ * @param markdown - The raw markdown source
1112
+ * @returns The document's lines, line-terminators stripped
1050
1113
  *
1051
1114
  * @example
1052
1115
  * ```ts
1053
- * renderMarkdown({ element: 'document', children: [
1054
- * { element: 'heading', level: 2, children: [{ element: 'text', value: 'Hi' }] },
1055
- * ] })
1056
- * // '## Hi'
1116
+ * splitLines('a\r\nb\nc') // ['a', 'b', 'c']
1057
1117
  * ```
1058
1118
  */
1059
- function renderMarkdown(node) {
1060
- function escapeText(value) {
1061
- let out = "";
1062
- for (let index = 0; index < value.length; index += 1) {
1063
- const character = value[index] ?? "";
1064
- const atLineStart = index === 0 || value[index - 1] === "\n";
1065
- if (character === "\\" || character === "*" || character === "_" || character === "`" || character === "[" || character === "]") {
1066
- out += `\\${character}`;
1067
- continue;
1068
- }
1069
- if (atLineStart) {
1070
- if (character === "#" || character === ">") {
1071
- out += `\\${character}`;
1072
- continue;
1073
- }
1074
- if ((character === "-" || character === "+") && (value[index + 1] ?? " ") === " ") {
1075
- out += `\\${character}`;
1076
- continue;
1077
- }
1078
- if (/[0-9]/.test(character)) {
1079
- let end = index;
1080
- while (end < value.length && /[0-9]/.test(value[end] ?? "")) end += 1;
1081
- const marker = value[end];
1082
- if ((marker === "." || marker === ")") && value[end + 1] === " ") {
1083
- out += `${value.slice(index, end)}\\${marker}`;
1084
- index = end;
1085
- continue;
1086
- }
1087
- }
1088
- }
1089
- out += character;
1090
- }
1091
- return out;
1092
- }
1093
- function fenceFor(body, minimum) {
1094
- let longest = 0;
1095
- let run = 0;
1096
- for (const character of body) if (character === "`") {
1097
- run += 1;
1098
- longest = Math.max(longest, run);
1099
- } else run = 0;
1100
- return "`".repeat(Math.max(minimum, longest + 1));
1101
- }
1102
- function renderInline(nodes, depth) {
1103
- return nodes.map((child) => render(child, depth + 1)).join("");
1104
- }
1105
- function renderBlocks(blocks, depth) {
1106
- return blocks.map((block) => render(block, depth + 1)).join("\n\n");
1107
- }
1108
- function renderItem(item, marker, depth) {
1109
- const body = renderBlocks(item.children, depth + 1);
1110
- const pad = " ".repeat(marker.length);
1111
- return body.split("\n").map((line, index) => index === 0 ? marker + line : line === "" ? "" : pad + line).join("\n");
1112
- }
1113
- function renderCell(cell, depth) {
1114
- return renderInline(cell, depth + 1).replace(/\|/g, "\\|");
1115
- }
1116
- function renderTable(current, depth) {
1117
- const columns = current.header.length;
1118
- return [
1119
- `| ${current.header.map((cell) => renderCell(cell, depth)).join(" | ")} |`,
1120
- `| ${current.align.map((align) => {
1121
- if (align === "left") return ":--";
1122
- if (align === "right") return "--:";
1123
- if (align === "center") return ":-:";
1124
- return "---";
1125
- }).join(" | ")} |`,
1126
- ...current.rows.map((row) => {
1127
- const cells = [];
1128
- for (let column = 0; column < columns; column += 1) {
1129
- const cell = row[column];
1130
- cells.push(cell === void 0 ? "" : renderCell(cell, depth));
1131
- }
1132
- return `| ${cells.join(" | ")} |`;
1133
- })
1134
- ].join("\n");
1135
- }
1136
- function render(current, depth) {
1137
- if (depth >= 64) return "value" in current && typeof current.value === "string" ? escapeText(current.value) : "";
1138
- switch (current.element) {
1139
- case "document": return renderBlocks(current.children, depth);
1140
- case "heading": {
1141
- const escaped = renderInline(current.children, depth).replace(/(^|[^\\])(#+)$/, (_match, pre, hashes) => {
1142
- return `${pre}\\${hashes[0] ?? ""}${hashes.slice(1)}`;
1143
- });
1144
- return `${"#".repeat(current.level)} ${escaped}`;
1145
- }
1146
- case "paragraph": return renderInline(current.children, depth);
1147
- case "thematicBreak": return "---";
1148
- case "blockquote": return renderBlocks(current.children, depth).split("\n").map((line) => line === "" ? ">" : `> ${line}`).join("\n");
1149
- case "codeBlock": {
1150
- const fence = fenceFor(current.code, 3);
1151
- return `${fence}${current.lang === void 0 ? "" : current.lang}\n${current.code}\n${fence}`;
1152
- }
1153
- case "list": {
1154
- let ordinal = current.start;
1155
- return current.items.map((item) => {
1156
- return renderItem(item, current.ordered ? `${ordinal++}. ` : "- ", depth);
1157
- }).join("\n");
1158
- }
1159
- case "listItem": return renderBlocks(current.children, depth);
1160
- case "table": return renderTable(current, depth);
1161
- case "text": return escapeText(current.value);
1162
- case "emphasis": {
1163
- const marker = current.strong ? "**" : "*";
1164
- return `${marker}${renderInline(current.children, depth)}${marker}`;
1165
- }
1166
- case "codeSpan": {
1167
- const fence = fenceFor(current.value, 1);
1168
- const pad = current.value.startsWith("`") || current.value.endsWith("`") ? " " : "";
1169
- return `${fence}${pad}${current.value}${pad}${fence}`;
1170
- }
1171
- case "link": {
1172
- const href = current.href.replace(/[\\()]/g, (character) => `\\${character}`);
1173
- return `[${renderInline(current.children, depth)}](${href})`;
1174
- }
1175
- default: return "";
1176
- }
1177
- }
1178
- return render(node, 0);
1119
+ function splitLines(markdown) {
1120
+ const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
1121
+ if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
1122
+ return lines;
1179
1123
  }
1180
1124
  /**
1181
- * Depth-first, pre-order, root-inclusive traversal of a {@link MarkdownNode} - yields
1182
- * the node itself, then recurses into its children (block children, list items, table
1183
- * header/row cells' inline nodes) in walk order.
1184
- *
1185
- * @remarks
1186
- * Total: never throws. Descent stops at {@link MAX_DEPTH} (the node at the cap is
1187
- * still yielded; its children are not) so pathologically deep input cannot exhaust
1188
- * the call stack.
1125
+ * The count of leading space / tab characters on `line` (a tab counts as one) - the
1126
+ * indent that decides whether a list item's continuation belongs to the item.
1189
1127
  *
1190
- * @param node - The AST node to walk (a full document, or any sub-node)
1191
- * @returns A generator yielding every visited node, pre-order
1128
+ * @param line - The line to measure
1129
+ * @returns The number of leading space / tab characters
1192
1130
  *
1193
1131
  * @example
1194
1132
  * ```ts
1195
- * const doc = { element: 'document', children: [{ element: 'thematicBreak' }] } as const
1196
- * [...walkNodes(doc)].map((node) => node.element) // ['document', 'thematicBreak']
1133
+ * countIndent(' text') // 2
1197
1134
  * ```
1198
1135
  */
1199
- function* walkNodes(node) {
1200
- function* walk(current, depth) {
1201
- yield current;
1202
- if (depth >= 64) return;
1203
- switch (current.element) {
1204
- case "document":
1205
- case "heading":
1206
- case "paragraph":
1207
- case "blockquote":
1208
- case "listItem":
1209
- case "emphasis":
1210
- case "link":
1211
- for (const child of current.children) yield* walk(child, depth + 1);
1212
- return;
1213
- case "list":
1214
- for (const item of current.items) yield* walk(item, depth + 1);
1215
- return;
1216
- case "table":
1217
- for (const cell of current.header) for (const inline of cell) yield* walk(inline, depth + 1);
1218
- for (const row of current.rows) for (const cell of row) for (const inline of cell) yield* walk(inline, depth + 1);
1219
- return;
1220
- default: return;
1221
- }
1222
- }
1223
- yield* walk(node, 0);
1136
+ function countIndent(line) {
1137
+ let count = 0;
1138
+ for (const character of line) if (character === " " || character === " ") count += 1;
1139
+ else break;
1140
+ return count;
1224
1141
  }
1225
1142
  /**
1226
- * Fold a {@link MarkdownNode} into a `T` via a total catamorphism - children are
1227
- * folded first (post-order), then the node's own {@link MarkdownHandler} is invoked
1228
- * with the already-folded children.
1229
- *
1230
- * @remarks
1231
- * **Table contract.** A {@link TableNode} has no single `children` array - its cells
1232
- * live in `header` (one inline-node list per column) and `rows` (a list of such
1233
- * rows). The `table` handler receives ONE folded `T` per inline node, flattened in
1234
- * walk order across ALL cells - every header cell's inline nodes (column order), then
1235
- * every body row's cells' inline nodes (row order, then column order) - and reads
1236
- * `node.header[c].length` / `node.rows[r][c].length` off the table node itself to
1237
- * recover cell boundaries within the flat list.
1238
- *
1239
- * Total: never throws. At `depth >= {@link MAX_DEPTH}` the node's handler is invoked
1240
- * with an empty children list instead of recursing further.
1143
+ * Extract an ATX heading line (`#` `######` followed by text) into its
1144
+ * `{ level, text }`, or `undefined` when `line` is not a heading. A run of more than 6
1145
+ * `#`s, or `#`s not followed by whitespace + text, is not a
1146
+ * heading; an optional closing `###` run is stripped.
1241
1147
  *
1242
- * @param node - The AST node to fold
1243
- * @param handlers - The total {@link MarkdownHandlers} table, one handler per element
1244
- * @param depth - The starting recursion depth (pass `0` at the entry point)
1245
- * @returns The folded `T`
1148
+ * @param line - The candidate line
1149
+ * @returns The heading level (1–6) and its raw inline text, or `undefined`
1246
1150
  *
1247
1151
  * @example
1248
1152
  * ```ts
1249
- * const countHandlers: MarkdownHandlers<number> = {
1250
- * document: (_, children) => children.reduce((a, b) => a + b, 1),
1251
- * // ...one handler per element, each summing its folded children
1252
- * }
1253
- * foldNode(document, countHandlers, 0) // total node count
1153
+ * extractHeading('## Title') // { level: 2, text: 'Title' }
1254
1154
  * ```
1255
1155
  */
1256
- function foldNode(node, handlers, depth) {
1257
- function dispatch(current, children) {
1258
- switch (current.element) {
1259
- case "document": return handlers.document(current, children);
1260
- case "heading": return handlers.heading(current, children);
1261
- case "paragraph": return handlers.paragraph(current, children);
1262
- case "thematicBreak": return handlers.thematicBreak(current, children);
1263
- case "blockquote": return handlers.blockquote(current, children);
1264
- case "codeBlock": return handlers.codeBlock(current, children);
1265
- case "list": return handlers.list(current, children);
1266
- case "listItem": return handlers.listItem(current, children);
1267
- case "table": return handlers.table(current, children);
1268
- case "text": return handlers.text(current, children);
1269
- case "emphasis": return handlers.emphasis(current, children);
1270
- case "codeSpan": return handlers.codeSpan(current, children);
1271
- case "link": return handlers.link(current, children);
1272
- }
1273
- }
1274
- function childNodes(current) {
1275
- switch (current.element) {
1276
- case "document":
1277
- case "heading":
1278
- case "paragraph":
1279
- case "blockquote":
1280
- case "listItem":
1281
- case "emphasis":
1282
- case "link": return current.children;
1283
- case "list": return current.items;
1284
- case "table": {
1285
- const header = current.header.flatMap((cell) => cell);
1286
- const rows = current.rows.flatMap((row) => row.flatMap((cell) => cell));
1287
- return [...header, ...rows];
1288
- }
1289
- default: return [];
1290
- }
1291
- }
1292
- function fold(current, level) {
1293
- if (level >= 64) return dispatch(current, []);
1294
- return dispatch(current, childNodes(current).map((child) => fold(child, level + 1)));
1295
- }
1296
- return fold(node, depth);
1156
+ function extractHeading(line) {
1157
+ const match = /^(#{1,6})(?:\s+(.*))?$/.exec(line.trimStart());
1158
+ if (!match || match[1] === void 0) return void 0;
1159
+ return {
1160
+ level: match[1].length,
1161
+ text: (match[2] ?? "").replace(/\s+#+\s*$/, "").trim()
1162
+ };
1297
1163
  }
1298
1164
  /**
1299
- * Rewrite a {@link MarkdownDocument} bottom-up (copy-on-write) - each node's children
1300
- * are rewritten first (post-order), then `rewrite` is applied to the node itself; the
1301
- * document ROOT is never passed to `rewrite` (the `element: 'document'` invariant
1302
- * always holds). A table's inline cells and a list's items ARE rewritten.
1303
- *
1304
- * @remarks
1305
- * Never mutates `document` - every level is rebuilt into a fresh object/array, even
1306
- * when `rewrite` returns its input unchanged. When `rewrite` returns a node whose
1307
- * `element` does not fit the slot it was called for (a block slot handed a
1308
- * non-{@link BlockNode}, an inline slot handed a non-{@link InlineNode}, a list-item
1309
- * slot handed a non-`listItem`), the ill-fitting result is discarded and the
1310
- * freshly-rebuilt (unrewritten-at-this-level) node is kept instead - `rewriteDocument`
1311
- * stays total and never produces a structurally invalid document.
1165
+ * Extract a fenced-code opening line (```` ``` ```` or `~~~`, optionally with an info
1166
+ * string) into its `{ marker, lang }`, or `undefined` when `line` is not a fence
1167
+ * opener. `marker` is the exact fence run (the closer must match the same character +
1168
+ * at least the same length); `lang` is the first word of the info string.
1312
1169
  *
1313
- * Descent is capped at {@link MAX_DEPTH}, the same cap {@link walkNodes} and
1314
- * {@link foldNode} observe: at `depth >= MAX_DEPTH` the subtree is passed through
1315
- * UNCHANGED (by reference, not rebuilt, and `rewrite` is not invoked on it) instead of
1316
- * recursing further, so a pathologically deep adopted document cannot exhaust the
1317
- * call stack. {@link MarkdownInterface.map} inherits this cap since it delegates here.
1318
- *
1319
- * @param document - The document AST to rewrite
1320
- * @param rewrite - The bottom-up {@link MarkdownRewriteHandler}
1321
- * @returns A new, rewritten {@link MarkdownDocument}
1170
+ * @param line - The candidate line
1171
+ * @returns The fence marker run and its language tag, or `undefined`
1322
1172
  *
1323
1173
  * @example
1324
1174
  * ```ts
1325
- * rewriteDocument(document, (node) =>
1326
- * node.element === 'text' ? { element: 'text', value: node.value.toUpperCase() } : node,
1327
- * )
1175
+ * extractFence('```ts') // { marker: '```', lang: 'ts' }
1328
1176
  * ```
1329
1177
  */
1330
- function rewriteDocument(document, rewrite) {
1331
- function rewriteInline(node, depth) {
1332
- if (depth >= 64) return node;
1333
- const rebuilt = rebuildInline(node, depth);
1334
- const result = rewrite(rebuilt);
1335
- return isInlineNode(result) ? result : rebuilt;
1336
- }
1337
- function rewriteBlock(node, depth) {
1338
- if (depth >= 64) return node;
1339
- const rebuilt = rebuildBlock(node, depth);
1340
- const result = rewrite(rebuilt);
1341
- return isBlockNode(result) ? result : rebuilt;
1342
- }
1343
- function rewriteItem(item, depth) {
1344
- if (depth >= 64) return item;
1345
- const rebuilt = {
1346
- element: "listItem",
1347
- children: item.children.map((child) => rewriteBlock(child, depth + 1))
1348
- };
1349
- const result = rewrite(rebuilt);
1350
- return result.element === "listItem" ? result : rebuilt;
1351
- }
1352
- function rebuildInline(node, depth) {
1353
- switch (node.element) {
1354
- case "emphasis": return {
1355
- ...node,
1356
- children: node.children.map((child) => rewriteInline(child, depth + 1))
1357
- };
1358
- case "link": return {
1359
- ...node,
1360
- children: node.children.map((child) => rewriteInline(child, depth + 1))
1361
- };
1362
- case "text":
1363
- case "codeSpan": return node;
1364
- }
1365
- }
1366
- function rebuildBlock(node, depth) {
1367
- switch (node.element) {
1368
- case "heading": return {
1369
- ...node,
1370
- children: node.children.map((child) => rewriteInline(child, depth + 1))
1371
- };
1372
- case "paragraph": return {
1373
- ...node,
1374
- children: node.children.map((child) => rewriteInline(child, depth + 1))
1375
- };
1376
- case "blockquote": return {
1377
- ...node,
1378
- children: node.children.map((child) => rewriteBlock(child, depth + 1))
1379
- };
1380
- case "list": return {
1381
- ...node,
1382
- items: node.items.map((item) => rewriteItem(item, depth + 1))
1383
- };
1384
- case "table": return {
1385
- ...node,
1386
- header: node.header.map((cell) => cell.map((inline) => rewriteInline(inline, depth + 1))),
1387
- rows: node.rows.map((row) => row.map((cell) => cell.map((inline) => rewriteInline(inline, depth + 1))))
1388
- };
1389
- case "codeBlock":
1390
- case "thematicBreak": return node;
1391
- }
1392
- }
1178
+ function extractFence(line) {
1179
+ const match = /^\s*(`{3,}|~{3,})\s*(.*)$/.exec(line);
1180
+ if (!match || match[1] === void 0) return void 0;
1181
+ const info = (match[2] ?? "").trim();
1182
+ if (match[1].startsWith("`") && info.includes("`")) return void 0;
1183
+ const lang = isNonEmptyString(info) ? info.split(/\s+/)[0] : void 0;
1393
1184
  return {
1394
- element: "document",
1395
- children: document.children.map((child) => rewriteBlock(child, 0))
1185
+ marker: match[1],
1186
+ lang
1396
1187
  };
1397
1188
  }
1398
1189
  /**
1399
- * Concatenate the `value` / `code` content of every descendant text / code-span /
1400
- * code-block node under `node`, in walk order - the plain-text projection of an AST
1401
- * (search indexing, word counts, a text-only preview).
1402
- *
1403
- * @remarks
1404
- * Total: never throws. Descent stops at {@link MAX_DEPTH} (contributes `''` past the
1405
- * cap instead of recursing further).
1190
+ * Extract a list-item line (`-` / `*` / `+` bullet, or `1.` / `1)` ordinal, followed by
1191
+ * a space) into its {@link ListItemMatch}, or `undefined` when `line` is not a list
1192
+ * item. `content` is the text after the marker; `marker` is the full marker-plus-space
1193
+ * width (for measuring a continuation's indent).
1406
1194
  *
1407
- * @param node - The AST node to flatten (a full document, or any sub-node)
1408
- * @returns The concatenated text content
1195
+ * @param line - The candidate line
1196
+ * @returns The list-item parts, or `undefined` when not a list item
1409
1197
  *
1410
1198
  * @example
1411
1199
  * ```ts
1412
- * flattenText({ element: 'paragraph', children: [
1413
- * { element: 'text', value: 'a ' },
1414
- * { element: 'codeSpan', value: 'b' },
1415
- * ] })
1416
- * // 'a b'
1200
+ * extractListItem('- item') // { ordered: false, start: 1, content: 'item', indent: 0, marker: 2 }
1417
1201
  * ```
1418
1202
  */
1419
- function flattenText(node) {
1420
- function flatten(current, depth) {
1421
- if (depth >= 64) return "";
1422
- switch (current.element) {
1423
- case "text": return current.value;
1424
- case "codeSpan": return current.value;
1425
- case "codeBlock": return current.code;
1426
- case "document":
1427
- case "heading":
1428
- case "paragraph":
1429
- case "blockquote":
1430
- case "listItem":
1431
- case "emphasis":
1432
- case "link": return current.children.map((child) => flatten(child, depth + 1)).join("");
1433
- case "list": return current.items.map((item) => flatten(item, depth + 1)).join("");
1434
- case "table": return current.header.map((cell) => cell.map((inline) => flatten(inline, depth + 1)).join("")).join("") + current.rows.map((row) => row.map((cell) => cell.map((inline) => flatten(inline, depth + 1)).join("")).join("")).join("");
1435
- case "thematicBreak": return "";
1436
- default: return "";
1437
- }
1203
+ function extractListItem(line) {
1204
+ const unordered = /^(\s*)([-*+])\s+(.*)$/.exec(line);
1205
+ if (unordered && unordered[1] !== void 0) {
1206
+ const indent = unordered[1].length;
1207
+ const content = unordered[3] ?? "";
1208
+ return {
1209
+ ordered: false,
1210
+ start: 1,
1211
+ content,
1212
+ indent,
1213
+ marker: line.length - content.length
1214
+ };
1215
+ }
1216
+ const ordered = /^(\s*)(\d{1,9})[.)]\s+(.*)$/.exec(line);
1217
+ if (ordered && ordered[1] !== void 0 && ordered[2] !== void 0) {
1218
+ const indent = ordered[1].length;
1219
+ const content = ordered[3] ?? "";
1220
+ return {
1221
+ ordered: true,
1222
+ start: parseInteger(ordered[2]) ?? 1,
1223
+ content,
1224
+ indent,
1225
+ marker: line.length - content.length
1226
+ };
1438
1227
  }
1439
- return flatten(node, 0);
1440
1228
  }
1441
- //#endregion
1442
- //#region src/core/parsers.ts
1443
1229
  /**
1444
- * Parses a run of markdown lines into a block AST, recursing into nested
1445
- * blockquotes, list items, and depth-capped degrade paragraphs.
1230
+ * Strip one level of blockquote marker (`>` plus one optional following space) from a
1231
+ * blockquote line, so the de-quoted lines re-parse as nested blocks.
1446
1232
  *
1447
- * @param lines - The markdown lines to parse.
1448
- * @param depth - The current recursion depth (blockquotes/lists increment it).
1449
- * @returns The parsed block nodes.
1233
+ * @param line - A blockquote line (per {@link isQuote})
1234
+ * @returns The line with its leading `>` (and one space) removed
1450
1235
  *
1451
1236
  * @example
1452
1237
  * ```ts
1453
- * parseBlocks(['# Hi'], 0) // [{ element: 'heading', level: 1, children: [...] }]
1238
+ * stripQuote('> text') // 'text'
1454
1239
  * ```
1455
1240
  */
1456
- function parseBlocks(lines, depth) {
1457
- if (depth >= 64) return lines.length > 0 ? [{
1458
- element: "paragraph",
1459
- children: [{
1460
- element: "text",
1461
- value: lines.join("\n")
1462
- }]
1463
- }] : [];
1464
- const blocks = [];
1465
- let index = 0;
1466
- while (index < lines.length) {
1467
- const line = lines[index] ?? "";
1468
- if (isBlankLine(line)) {
1469
- index += 1;
1470
- continue;
1471
- }
1472
- const fence = extractFence(line);
1473
- if (fence) {
1474
- const body = [];
1475
- index += 1;
1476
- while (index < lines.length && !isFenceClose(lines[index] ?? "", fence.marker)) {
1477
- body.push(lines[index] ?? "");
1478
- index += 1;
1479
- }
1480
- index += 1;
1481
- blocks.push({
1482
- element: "codeBlock",
1483
- ...fence.lang === void 0 ? {} : { lang: fence.lang },
1484
- code: body.join("\n")
1485
- });
1486
- continue;
1487
- }
1488
- if (isThematicBreak(line)) {
1489
- blocks.push({ element: "thematicBreak" });
1490
- index += 1;
1491
- continue;
1492
- }
1493
- const heading = extractHeading(line);
1494
- if (heading) {
1495
- blocks.push({
1496
- element: "heading",
1497
- level: heading.level,
1498
- children: parseInline(heading.text)
1499
- });
1500
- index += 1;
1501
- continue;
1502
- }
1503
- if (isQuote(line)) {
1504
- const quoted = [];
1505
- while (index < lines.length && isQuote(lines[index] ?? "")) {
1506
- quoted.push(stripQuote(lines[index] ?? ""));
1507
- index += 1;
1508
- }
1509
- blocks.push({
1510
- element: "blockquote",
1511
- children: parseBlocks(quoted, depth + 1)
1512
- });
1513
- continue;
1514
- }
1515
- if (isTableStart(line, lines[index + 1])) {
1516
- const table = collectTable(lines, index);
1517
- blocks.push(table.node);
1518
- index = table.next;
1519
- continue;
1520
- }
1521
- if (extractListItem(line)) {
1522
- const list = collectList(lines, index, depth);
1523
- blocks.push(list.node);
1524
- index = list.next;
1525
- continue;
1526
- }
1527
- const paragraph = [];
1528
- while (index < lines.length && !isBlankLine(lines[index] ?? "") && !(isNonEmptyArray(paragraph) && startsBlock(lines, index))) {
1529
- paragraph.push((lines[index] ?? "").trim());
1530
- index += 1;
1531
- }
1532
- blocks.push({
1533
- element: "paragraph",
1534
- children: parseInline(paragraph.join("\n"))
1535
- });
1536
- }
1537
- return blocks;
1241
+ function stripQuote(line) {
1242
+ return line.replace(/^\s{0,3}>\s?/, "");
1538
1243
  }
1539
1244
  /**
1540
- * Collects a GFM table starting at a header row, parsing the header, the
1541
- * alignment row, and every contiguous body row that follows.
1245
+ * Split one GFM table row into its cell strings - outer pipes are optional, an escaped
1246
+ * pipe (`\|`) inside a cell is NOT a separator (it becomes a literal `|`), and the
1247
+ * empty leading / trailing cell produced by an outer `|` is dropped.
1542
1248
  *
1543
- * @param lines - The markdown lines to scan.
1544
- * @param start - The index of the header row.
1545
- * @returns The parsed table node and the index of the first line after it.
1249
+ * @param row - The raw table row line
1250
+ * @returns The row's cells, in column order
1546
1251
  *
1547
1252
  * @example
1548
1253
  * ```ts
1549
- * collectTable(['| a |', '| - |'], 0) // { node: { element: 'table', ... }, next: 2 }
1254
+ * splitTableRow('|a|b|') // ['a', 'b']
1550
1255
  * ```
1551
1256
  */
1552
- function collectTable(lines, start) {
1553
- const headerCells = splitTableRow(lines[start] ?? "");
1554
- const columns = headerCells.length;
1555
- const header = headerCells.map((cell) => parseInline(cell.trim()));
1556
- const align = tableAlignments(lines[start + 1] ?? "");
1557
- const padded = [];
1558
- for (let column = 0; column < columns; column += 1) padded.push(align[column] ?? "none");
1559
- const rows = [];
1560
- let index = start + 2;
1561
- while (index < lines.length && !isBlankLine(lines[index] ?? "") && (lines[index] ?? "").includes("|")) {
1562
- const cells = splitTableRow(lines[index] ?? "");
1563
- const row = [];
1564
- for (let column = 0; column < columns; column += 1) row.push(parseInline((cells[column] ?? "").trim()));
1565
- rows.push(row);
1566
- index += 1;
1257
+ function splitTableRow(row) {
1258
+ const cells = [];
1259
+ let current = "";
1260
+ const trimmed = row.trim();
1261
+ for (let index = 0; index < trimmed.length; index += 1) {
1262
+ const character = trimmed[index];
1263
+ if (character === "\\" && trimmed[index + 1] === "|") {
1264
+ current += "|";
1265
+ index += 1;
1266
+ } else if (character === "|") {
1267
+ cells.push(current);
1268
+ current = "";
1269
+ } else current += character;
1567
1270
  }
1568
- return {
1569
- node: {
1570
- element: "table",
1571
- header,
1572
- rows,
1573
- align: padded
1574
- },
1575
- next: index
1576
- };
1271
+ cells.push(current);
1272
+ if (isNonEmptyArray(cells) && isEmptyString((cells[0] ?? "").trim())) cells.shift();
1273
+ if (isNonEmptyArray(cells) && isEmptyString((cells[cells.length - 1] ?? "").trim())) cells.pop();
1274
+ return cells;
1577
1275
  }
1578
1276
  /**
1579
- * Collects a list starting at the first item, gathering sibling items at the
1580
- * same indent/ordering and recursing into each item's own block content.
1277
+ * Derive the per-column {@link TableAlign} list from a GFM delimiter row - `:---`
1278
+ * left, `---:` right, `:---:` center, and `---` as the explicit no-alignment
1279
+ * marker represented by `null`.
1581
1280
  *
1582
- * @param lines - The markdown lines to scan.
1583
- * @param start - The index of the first list item.
1584
- * @param depth - The current recursion depth (each item recurses at `depth + 1`).
1585
- * @returns The parsed list node and the index of the first line after it.
1281
+ * @param delimiter - The table's delimiter row
1282
+ * @returns One alignment per column, in column order
1586
1283
  *
1587
1284
  * @example
1588
1285
  * ```ts
1589
- * collectList(['- item'], 0, 0) // { node: { element: 'list', ... }, next: 1 }
1286
+ * delimiterToAlignments('| :--- | ---: |') // ['left', 'right']
1590
1287
  * ```
1591
1288
  */
1592
- function collectList(lines, start, depth) {
1593
- const first = extractListItem(lines[start] ?? "");
1594
- const ordered = first?.ordered ?? false;
1595
- const startOrdinal = first?.start ?? 1;
1596
- const topIndent = first?.indent ?? 0;
1597
- const items = [];
1598
- let index = start;
1599
- while (index < lines.length) {
1600
- const parsed = extractListItem(lines[index] ?? "");
1601
- if (!parsed || parsed.indent > topIndent || parsed.ordered !== ordered) break;
1602
- const itemLines = [parsed.content];
1603
- const continuation = parsed.marker;
1604
- index += 1;
1605
- while (index < lines.length) {
1606
- const next = lines[index] ?? "";
1607
- if (isBlankLine(next)) {
1608
- const after = lines[index + 1] ?? "";
1609
- if (index + 1 < lines.length && !isBlankLine(after) && leadingIndent(after) >= continuation) {
1610
- itemLines.push("");
1611
- index += 1;
1612
- continue;
1613
- }
1614
- break;
1615
- }
1616
- if (leadingIndent(next) >= continuation) {
1617
- itemLines.push(next.slice(continuation));
1618
- index += 1;
1619
- continue;
1620
- }
1621
- if (extractListItem(next) || startsBlock(lines, index)) break;
1622
- itemLines.push(next.trim());
1623
- index += 1;
1624
- }
1625
- items.push({
1626
- element: "listItem",
1627
- children: parseBlocks(itemLines, depth + 1)
1628
- });
1629
- }
1630
- return {
1631
- node: {
1632
- element: "list",
1633
- ordered,
1634
- start: startOrdinal,
1635
- items
1636
- },
1637
- next: index
1638
- };
1639
- }
1640
- /**
1641
- * Parses a markdown string into a typed {@link MarkdownDocument} AST via the
1642
- * block phase.
1643
- *
1644
- * @param markdown - The markdown source to parse.
1645
- * @returns The parsed document.
1646
- */
1647
- function parseDocument(markdown) {
1648
- return {
1649
- element: "document",
1650
- children: parseBlocks(splitLines(markdown), 0)
1651
- };
1289
+ function delimiterToAlignments(delimiter) {
1290
+ return splitTableRow(delimiter).map((cell) => {
1291
+ const text = cell.trim();
1292
+ const left = text.startsWith(":");
1293
+ const right = text.endsWith(":");
1294
+ if (left && right) return "center";
1295
+ if (right) return "right";
1296
+ if (left) return "left";
1297
+ return null;
1298
+ });
1652
1299
  }
1653
1300
  /**
1654
- * Parses inline markdown text (emphasis, code spans, links) into inline AST
1655
- * nodes, coalescing adjacent text runs.
1301
+ * Whether the line at `index` starts a NEW block kind (heading / fence / thematic
1302
+ * break / blockquote / list / table) - the paragraph collector stops at such a line
1303
+ * so a block following a paragraph without a blank line still parses (a trusted-input
1304
+ * caller writing a `##` heading directly under a paragraph, with no intervening blank
1305
+ * line).
1656
1306
  *
1657
- * @param text - The inline markdown text to parse.
1658
- * @returns The parsed inline nodes.
1659
- */
1660
- function parseInline(text) {
1661
- return coalesceText(scanInline(text, 0, text.length));
1662
- }
1663
- //#endregion
1664
- //#region src/core/shapers.ts
1665
- /**
1666
- * The shape of a {@link TextNode} - a plain-text leaf inline run.
1307
+ * @param lines - The document's lines
1308
+ * @param index - The line index to test
1309
+ * @returns `true` when the line begins a different block
1667
1310
  *
1668
1311
  * @example
1669
1312
  * ```ts
1670
- * import { createContract } from '@orkestrel/contract'
1671
- * import { textShape } from '@src/core'
1672
- *
1673
- * const text = createContract(textShape)
1674
- * text.is({ element: 'text', value: 'hi' }) // true
1313
+ * startsBlock(['text', '## Heading'], 1) // true
1675
1314
  * ```
1676
1315
  */
1677
- var textShape = objectShape({
1678
- element: literalShape(["text"]),
1679
- value: stringShape()
1680
- });
1316
+ function startsBlock(lines, index) {
1317
+ const line = lines[index] ?? "";
1318
+ return extractHeading(line) !== void 0 || extractFence(line) !== void 0 || isThematicBreak(line) || isQuote(line) || extractListItem(line) !== void 0 || isTableStart(line, lines[index + 1]);
1319
+ }
1681
1320
  /**
1682
- * The shape of a {@link CodeSpanNode} - an inline code span (`` `code` ``).
1683
- *
1684
- * @example
1685
- * ```ts
1686
- * import { createContract } from '@orkestrel/contract'
1687
- * import { codeSpanShape } from '@src/core'
1321
+ * Resolve backslash escapes in a raw string to their literal characters - used for a
1322
+ * link `href` (which is not otherwise inline-parsed) and any plain text run.
1688
1323
  *
1689
- * const codeSpan = createContract(codeSpanShape)
1690
- * codeSpan.is({ element: 'codeSpan', value: 'const x = 1' }) // true
1691
- * ```
1692
- */
1693
- var codeSpanShape = objectShape({
1694
- element: literalShape(["codeSpan"]),
1695
- value: stringShape()
1696
- });
1697
- /**
1698
- * The shape of a {@link CodeBlockNode} - a fenced code block. `lang` is
1699
- * optional (absent when the opening fence carries no info-string).
1324
+ * @param text - The raw text possibly carrying `\x` escapes
1325
+ * @returns The text with escapable `\x` reduced to `x`
1700
1326
  *
1701
1327
  * @example
1702
1328
  * ```ts
1703
- * import { createContract } from '@orkestrel/contract'
1704
- * import { codeBlockShape } from '@src/core'
1705
- *
1706
- * const codeBlock = createContract(codeBlockShape)
1707
- * codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
1708
- * codeBlock.is({ element: 'codeBlock', code: 'x', lang: 'ts' }) // true
1329
+ * unescapeText('\\*hi\\*') // '*hi*'
1709
1330
  * ```
1710
1331
  */
1711
- var codeBlockShape = objectShape({
1712
- element: literalShape(["codeBlock"]),
1713
- lang: optionalShape(stringShape()),
1714
- code: stringShape()
1715
- });
1332
+ function unescapeText(text) {
1333
+ let out = "";
1334
+ for (let index = 0; index < text.length; index += 1) {
1335
+ const character = text[index] ?? "";
1336
+ if (character === "\\" && isEscapable(text[index + 1] ?? "")) {
1337
+ out += text[index + 1] ?? "";
1338
+ index += 1;
1339
+ } else out += character;
1340
+ }
1341
+ return out;
1342
+ }
1716
1343
  /**
1717
- * The shape of a {@link ThematicBreakNode} - a horizontal rule. Carries no
1718
- * fields beyond its `element` discriminant.
1719
- *
1720
- * @example
1721
- * ```ts
1722
- * import { createContract } from '@orkestrel/contract'
1723
- * import { thematicBreakShape } from '@src/core'
1344
+ * Merge adjacent text nodes into one - the inline scanner emits a text node per
1345
+ * unrecognized character, so coalescing keeps the AST clean and assertion-friendly.
1724
1346
  *
1725
- * const thematicBreak = createContract(thematicBreakShape)
1726
- * thematicBreak.is({ element: 'thematicBreak' }) // true
1727
- * ```
1728
- */
1729
- var thematicBreakShape = objectShape({ element: literalShape(["thematicBreak"]) });
1730
- /**
1731
- * The shape of a {@link TableAlign} - the per-column GFM table alignment
1732
- * literal.
1347
+ * @param nodes - The inline nodes (possibly with adjacent text runs)
1348
+ * @returns The nodes with consecutive text nodes concatenated
1733
1349
  *
1734
1350
  * @example
1735
1351
  * ```ts
1736
- * import { createContract } from '@orkestrel/contract'
1737
- * import { tableAlignShape } from '@src/core'
1738
- *
1739
- * const tableAlign = createContract(tableAlignShape)
1740
- * tableAlign.is('left') // true
1741
- * tableAlign.is('center') // true
1742
- * tableAlign.is('top') // false
1352
+ * coalesceText([{ element: 'text', value: 'a' }, { element: 'text', value: 'b' }])
1353
+ * // [{ element: 'text', value: 'ab' }]
1743
1354
  * ```
1744
1355
  */
1745
- var tableAlignShape = literalShape([
1746
- "none",
1747
- "left",
1748
- "right",
1749
- "center"
1750
- ]);
1356
+ function coalesceText(nodes) {
1357
+ const out = [];
1358
+ for (const node of nodes) {
1359
+ const last = out[out.length - 1];
1360
+ if (node.element === "text" && last !== void 0 && last.element === "text") out[out.length - 1] = {
1361
+ element: "text",
1362
+ value: last.value + node.value
1363
+ };
1364
+ else out.push(node);
1365
+ }
1366
+ return out;
1367
+ }
1751
1368
  /**
1752
- * The shape of {@link ListItemParts} - the parsed parts of a single list-item
1753
- * line the block phase's list detector returns. Fully non-recursive (no
1754
- * nested node fields), so every field shapes directly.
1369
+ * Scan an inline code span at `start` (a `` ` ``-run … a matching `` ` ``-run of the
1370
+ * SAME length, the CommonMark rule that lets a span contain backticks). Returns the
1371
+ * span's literal text + end index, or `undefined` when no matching closer exists (it
1372
+ * then degrades to literal backticks).
1373
+ *
1374
+ * @param source - The inline source text
1375
+ * @param start - The index of the opening backtick
1376
+ * @param to - The exclusive end of the scan window
1377
+ * @returns The span text + end index, or `undefined`
1755
1378
  *
1756
1379
  * @example
1757
1380
  * ```ts
1758
- * import { createContract } from '@orkestrel/contract'
1759
- * import { listItemPartsShape } from '@src/core'
1760
- *
1761
- * const listItemParts = createContract(listItemPartsShape)
1762
- * listItemParts.is({ ordered: false, start: 1, content: 'hi', indent: 0, marker: 2 }) // true
1381
+ * scanCode('`code`', 0, 6) // { value: 'code', end: 6 }
1763
1382
  * ```
1764
1383
  */
1765
- var listItemPartsShape = objectShape({
1766
- ordered: booleanShape(),
1767
- start: integerShape(),
1768
- content: stringShape(),
1769
- indent: integerShape(),
1770
- marker: integerShape()
1771
- });
1772
- //#endregion
1773
- //#region src/core/Markdown.ts
1384
+ function scanCode(source, start, to) {
1385
+ let run = 0;
1386
+ while (start + run < to && source[start + run] === "`") run += 1;
1387
+ const open = "`".repeat(run);
1388
+ let search = start + run;
1389
+ for (;;) {
1390
+ const closeAt = source.indexOf(open, search);
1391
+ if (closeAt === -1 || closeAt + run > to) return void 0;
1392
+ if (source[closeAt - 1] !== "`" && source[closeAt + run] !== "`") {
1393
+ let value = source.slice(start + run, closeAt);
1394
+ if (value.length > 2 && value.startsWith(" ") && value.endsWith(" ") && value.trim().length > 0) value = value.slice(1, -1);
1395
+ return {
1396
+ value,
1397
+ end: closeAt + run
1398
+ };
1399
+ }
1400
+ search = closeAt + 1;
1401
+ }
1402
+ }
1774
1403
  /**
1775
- * A stateful, parsed markdown document - wraps a typed {@link MarkdownDocument} AST
1776
- * with the query (`find` / `filter` / `reduce` / iteration), rewrite (`map`), fold, and
1777
- * streaming operations {@link MarkdownInterface} declares.
1404
+ * Scan a link `[text](href)` at `start` - the text runs to a BALANCED `]`, then `(`
1405
+ * must immediately follow and the destination runs to the matching `)` (both respect
1406
+ * nested delimiters + escapes). Returns the link node, or `undefined` when the shape
1407
+ * does not hold (it then degrades to a literal `[`).
1778
1408
  *
1779
- * @remarks
1780
- * - **Construction.** Given a `string`, the constructor runs {@link parseDocument} (the
1781
- * block phase then the inline phase) to build the AST. Given a {@link MarkdownDocument},
1782
- * the document is adopted AS-IS and is NOT re-validated - a caller adopting an
1783
- * untrusted value should gate it with `isMarkdownDocument` first.
1784
- * - **Immutable.** {@link map} never mutates the stored AST - it returns a NEW `Markdown`
1785
- * instance; the document root invariant (`element: 'document'`) always holds.
1786
- * - **Traversal order.** {@link walk} and the `find` / `filter` / `reduce` queries built
1787
- * on it walk the AST depth-first, pre-order, root-inclusive (via {@link walkNodes});
1788
- * `stream` is shallow - only the document's direct block children.
1409
+ * @param source - The inline source text
1410
+ * @param start - The index of the opening `[`
1411
+ * @param to - The exclusive end of the scan window
1412
+ * @param depth - The current inline-recursion depth (defaults to 0 at the entry point);
1413
+ * at {@link MAX_DEPTH} the link's text children degrade to literal text instead of
1414
+ * recursing further
1415
+ * @returns The parsed {@link LinkNode} + end index, or `undefined`
1789
1416
  *
1790
1417
  * @example
1791
1418
  * ```ts
1792
- * import { Markdown, isHeadingNode, renderMarkdown } from '@src/core'
1793
- *
1794
- * const markdown = new Markdown('# Title\n\nA **bold** [link](https://x.dev).')
1795
- * const heading = markdown.find(isHeadingNode) // the HeadingNode, or undefined
1796
- * const shouted = markdown.map((node) =>
1797
- * node.element === 'text' ? { element: 'text', value: node.value.toUpperCase() } : node,
1798
- * )
1799
- * renderMarkdown(shouted.document) // '# TITLE\n\nA **BOLD** [LINK](https://x.dev).'
1419
+ * scanLink('[text](url)', 0, 11)
1420
+ * // { node: { element: 'link', href: 'url', children: [...] }, end: 11 }
1800
1421
  * ```
1801
1422
  */
1802
- var Markdown = class Markdown {
1803
- #document;
1804
- constructor(input) {
1805
- this.#document = typeof input === "string" ? parseDocument(input) : input;
1806
- }
1807
- /** The stored {@link MarkdownDocument} AST root. */
1808
- get document() {
1809
- return this.#document;
1423
+ function scanLink(source, start, to, depth = 0) {
1424
+ let bracketDepth = 0;
1425
+ let close = -1;
1426
+ for (let index = start; index < to; index += 1) {
1427
+ const character = source[index] ?? "";
1428
+ if (character === "\\") {
1429
+ index += 1;
1430
+ continue;
1431
+ }
1432
+ if (character === "[") bracketDepth += 1;
1433
+ else if (character === "]") {
1434
+ bracketDepth -= 1;
1435
+ if (bracketDepth === 0) {
1436
+ close = index;
1437
+ break;
1438
+ }
1439
+ }
1440
+ }
1441
+ if (close === -1 || source[close + 1] !== "(") return void 0;
1442
+ let parenDepth = 0;
1443
+ let parenClose = -1;
1444
+ for (let index = close + 1; index < to; index += 1) {
1445
+ const character = source[index] ?? "";
1446
+ if (character === "\\") {
1447
+ index += 1;
1448
+ continue;
1449
+ }
1450
+ if (character === "(") parenDepth += 1;
1451
+ else if (character === ")") {
1452
+ parenDepth -= 1;
1453
+ if (parenDepth === 0) {
1454
+ parenClose = index;
1455
+ break;
1456
+ }
1457
+ }
1458
+ }
1459
+ if (parenClose === -1) return void 0;
1460
+ return {
1461
+ node: {
1462
+ element: "link",
1463
+ href: unescapeText(source.slice(close + 2, parenClose).trim()),
1464
+ children: scanInline(source, start + 1, close, depth + 1)
1465
+ },
1466
+ end: parenClose + 1
1467
+ };
1468
+ }
1469
+ /**
1470
+ * Scan an emphasis run at `start` (`*` / `_`, doubled for strong) - finds the nearest
1471
+ * matching closing run of the same marker + width while skipping complete nested
1472
+ * runs from the other marker family, and requires non-space immediately inside both
1473
+ * delimiters (the CommonMark flanking simplification that blocks `* x *`). Returns
1474
+ * the emphasis node, or `undefined` when no valid closer exists (it then degrades to
1475
+ * a literal marker).
1476
+ *
1477
+ * @param source - The inline source text
1478
+ * @param start - The index of the opening marker
1479
+ * @param to - The exclusive end of the scan window
1480
+ * @param depth - The current inline-recursion depth (defaults to 0 at the entry point);
1481
+ * at {@link MAX_DEPTH} the emphasis's children degrade to literal text instead of
1482
+ * recursing further
1483
+ * @returns The parsed {@link EmphasisNode} + end index, or `undefined`
1484
+ *
1485
+ * @example
1486
+ * ```ts
1487
+ * scanEmphasis('*em*', 0, 4)
1488
+ * // { node: { element: 'emphasis', strong: false, children: [...] }, end: 4 }
1489
+ * ```
1490
+ */
1491
+ function scanEmphasis(source, start, to, depth = 0) {
1492
+ const marker = source[start] ?? "";
1493
+ let run = 0;
1494
+ while (start + run < to && source[start + run] === marker && run < 2) run += 1;
1495
+ const strong = run === 2;
1496
+ const openEnd = start + run;
1497
+ if (openEnd >= to || isWhitespace(source[openEnd] ?? "")) return void 0;
1498
+ let index = openEnd;
1499
+ while (index < to) {
1500
+ const character = source[index] ?? "";
1501
+ if (character === "\\") {
1502
+ index += 2;
1503
+ continue;
1504
+ }
1505
+ if (character === "`") {
1506
+ const span = scanCode(source, index, to);
1507
+ index = span ? span.end : index + 1;
1508
+ continue;
1509
+ }
1510
+ if ((character === "*" || character === "_") && character !== marker) {
1511
+ const nested = scanEmphasis(source, index, to, depth + 1);
1512
+ if (nested !== void 0) {
1513
+ index = nested.end;
1514
+ continue;
1515
+ }
1516
+ }
1517
+ if (character === marker) {
1518
+ let closeRun = 0;
1519
+ while (index + closeRun < to && source[index + closeRun] === marker) closeRun += 1;
1520
+ if (closeRun >= run && !isWhitespace(source[index - 1] ?? "")) return {
1521
+ node: {
1522
+ element: "emphasis",
1523
+ strong,
1524
+ children: scanInline(source, openEnd, index, depth + 1)
1525
+ },
1526
+ end: index + run
1527
+ };
1528
+ index += closeRun;
1529
+ continue;
1530
+ }
1531
+ index += 1;
1532
+ }
1533
+ }
1534
+ /**
1535
+ * Scan the window `[from, to)` of `source` into inline nodes - the single recursive
1536
+ * engine the inline phase runs on (emphasis, link text, and image alternative
1537
+ * content recurse through it). Linear:
1538
+ * each character is consumed once; a failed construct emits its opening character as
1539
+ * text and advances by one, so there is no re-scan (no ReDoS).
1540
+ *
1541
+ * @param source - The inline source text
1542
+ * @param from - The inclusive start of the scan window
1543
+ * @param to - The exclusive end of the scan window
1544
+ * @param depth - The current inline-recursion depth (defaults to 0 at the entry point);
1545
+ * incremented by one on every recursive descent through {@link scanLink} /
1546
+ * {@link scanEmphasis}. At {@link MAX_DEPTH} the window is never scanned for markup -
1547
+ * it emits as a single literal text node - so pathological nesting (`[[[[…`,
1548
+ * `****…`) cannot exhaust the call stack.
1549
+ * @returns The parsed inline nodes (NOT yet coalesced)
1550
+ *
1551
+ * @example
1552
+ * ```ts
1553
+ * scanInline('hi *there*', 0, 10) // [{ element: 'text', value: 'hi ' }, { element: 'emphasis', ... }]
1554
+ * ```
1555
+ */
1556
+ function scanInline(source, from, to, depth = 0) {
1557
+ if (depth >= 64) return from < to ? [{
1558
+ element: "text",
1559
+ value: source.slice(from, to)
1560
+ }] : [];
1561
+ const nodes = [];
1562
+ let index = from;
1563
+ let pending = "";
1564
+ while (index < to) {
1565
+ const character = source[index] ?? "";
1566
+ if (character === "\\" && index + 1 < to && isEscapable(source[index + 1] ?? "")) {
1567
+ pending += source[index + 1] ?? "";
1568
+ index += 2;
1569
+ continue;
1570
+ }
1571
+ if (character === " ") {
1572
+ let spaceEnd = index;
1573
+ while (spaceEnd < to && source[spaceEnd] === " ") spaceEnd += 1;
1574
+ if (spaceEnd - index >= 2 && source[spaceEnd] === "\n") {
1575
+ if (pending.length > 0) {
1576
+ nodes.push({
1577
+ element: "text",
1578
+ value: pending
1579
+ });
1580
+ pending = "";
1581
+ }
1582
+ nodes.push({ element: "break" });
1583
+ index = spaceEnd + 1;
1584
+ continue;
1585
+ }
1586
+ }
1587
+ let scanned;
1588
+ let end = index;
1589
+ if (character === "`") {
1590
+ const span = scanCode(source, index, to);
1591
+ if (span) {
1592
+ scanned = {
1593
+ element: "codeSpan",
1594
+ value: span.value
1595
+ };
1596
+ end = span.end;
1597
+ }
1598
+ }
1599
+ if (character === "!" && source[index + 1] === "[") {
1600
+ const link = scanLink(source, index + 1, to, depth);
1601
+ if (link) {
1602
+ scanned = {
1603
+ element: "image",
1604
+ src: link.node.href,
1605
+ children: link.node.children
1606
+ };
1607
+ end = link.end;
1608
+ }
1609
+ }
1610
+ if (character === "[") {
1611
+ const link = scanLink(source, index, to, depth);
1612
+ if (link) {
1613
+ scanned = link.node;
1614
+ end = link.end;
1615
+ }
1616
+ }
1617
+ if (character === "*" || character === "_") {
1618
+ const emphasis = scanEmphasis(source, index, to, depth);
1619
+ if (emphasis) {
1620
+ scanned = emphasis.node;
1621
+ end = emphasis.end;
1622
+ }
1623
+ }
1624
+ if (scanned !== void 0) {
1625
+ if (pending.length > 0) {
1626
+ nodes.push({
1627
+ element: "text",
1628
+ value: pending
1629
+ });
1630
+ pending = "";
1631
+ }
1632
+ nodes.push(scanned);
1633
+ index = end;
1634
+ continue;
1635
+ }
1636
+ pending += character;
1637
+ index += 1;
1638
+ }
1639
+ if (pending.length > 0) nodes.push({
1640
+ element: "text",
1641
+ value: pending
1642
+ });
1643
+ return nodes;
1644
+ }
1645
+ /**
1646
+ * Project a {@link MarkdownNode} into an unsanitized {@link HTMLDocument}.
1647
+ *
1648
+ * @remarks
1649
+ * The projection is pure and iterative. Text and attribute values remain literal for
1650
+ * `@orkestrel/html` to encode, and URL values remain unsanitized so callers can choose
1651
+ * their own HTML policy. Projected HTML element depth, including generated `pre > code`
1652
+ * and table scaffolding, never exceeds {@link MAX_DEPTH}. At the cap a node carrying a
1653
+ * string `value` degrades to a text node and a structural node contributes nothing.
1654
+ *
1655
+ * @param node - The markdown document or bare node to project
1656
+ * @returns An unsanitized HTML document wrapping the projected node or nodes
1657
+ *
1658
+ * @example
1659
+ * ```ts
1660
+ * markdownToHTML({ element: 'text', value: 'a & b' })
1661
+ * // { category: 'document', children: [{ category: 'text', value: 'a & b' }] }
1662
+ * ```
1663
+ */
1664
+ function markdownToHTML(node) {
1665
+ const stack = [{
1666
+ node,
1667
+ depth: 0,
1668
+ expanded: false,
1669
+ count: 0
1670
+ }];
1671
+ const values = [];
1672
+ while (stack.length > 0) {
1673
+ const frame = stack.pop();
1674
+ if (frame === void 0) continue;
1675
+ const current = frame.node;
1676
+ if (!frame.expanded) {
1677
+ if (frame.depth >= 64) {
1678
+ values.push("value" in current && typeof current.value === "string" ? {
1679
+ category: "text",
1680
+ value: current.value
1681
+ } : void 0);
1682
+ continue;
1683
+ }
1684
+ const children = [];
1685
+ let depth = frame.depth;
1686
+ switch (current.element) {
1687
+ case "document":
1688
+ for (const child of current.children) if (child !== void 0) children.push(child);
1689
+ break;
1690
+ case "heading":
1691
+ case "paragraph":
1692
+ case "blockquote":
1693
+ for (const child of current.children) if (child !== void 0) children.push(child);
1694
+ depth += 1;
1695
+ break;
1696
+ case "listItem": {
1697
+ const only = current.children[0];
1698
+ if (current.children.length === 1 && only !== void 0 && only.element === "paragraph") {
1699
+ for (const child of only.children) if (child !== void 0) children.push(child);
1700
+ } else for (const child of current.children) if (child !== void 0) children.push(child);
1701
+ depth += 1;
1702
+ break;
1703
+ }
1704
+ case "emphasis":
1705
+ case "link":
1706
+ for (const child of current.children) if (child !== void 0) children.push(child);
1707
+ depth += 1;
1708
+ break;
1709
+ case "list":
1710
+ for (const child of current.items) if (child !== void 0) children.push(child);
1711
+ depth += 1;
1712
+ break;
1713
+ case "table":
1714
+ if (frame.depth + 4 > 64) {
1715
+ values.push(void 0);
1716
+ continue;
1717
+ }
1718
+ for (const cell of current.header) if (cell !== void 0) {
1719
+ for (const child of cell) if (child !== void 0) children.push(child);
1720
+ }
1721
+ for (const row of current.rows) if (row !== void 0) {
1722
+ for (const cell of row) if (cell !== void 0) {
1723
+ for (const child of cell) if (child !== void 0) children.push(child);
1724
+ }
1725
+ }
1726
+ depth += 4;
1727
+ }
1728
+ if (current.element === "codeBlock" && frame.depth + 2 > 64) {
1729
+ values.push(void 0);
1730
+ continue;
1731
+ }
1732
+ stack.push({
1733
+ ...frame,
1734
+ expanded: true,
1735
+ count: children.length
1736
+ });
1737
+ for (let index = children.length - 1; index >= 0; index -= 1) {
1738
+ const child = children[index];
1739
+ if (child !== void 0) stack.push({
1740
+ node: child,
1741
+ depth,
1742
+ expanded: false,
1743
+ count: 0
1744
+ });
1745
+ }
1746
+ continue;
1747
+ }
1748
+ const children = frame.count === 0 ? [] : values.splice(values.length - frame.count, frame.count);
1749
+ const projected = [];
1750
+ for (const child of children) if (child !== void 0) projected.push(child);
1751
+ let value;
1752
+ switch (current.element) {
1753
+ case "document":
1754
+ value = {
1755
+ category: "document",
1756
+ children: projected
1757
+ };
1758
+ break;
1759
+ case "heading":
1760
+ value = {
1761
+ category: "element",
1762
+ name: `h${current.level}`,
1763
+ attributes: [],
1764
+ children: projected
1765
+ };
1766
+ break;
1767
+ case "paragraph":
1768
+ value = {
1769
+ category: "element",
1770
+ name: "p",
1771
+ attributes: [],
1772
+ children: projected
1773
+ };
1774
+ break;
1775
+ case "thematicBreak":
1776
+ value = {
1777
+ category: "element",
1778
+ name: "hr",
1779
+ attributes: [],
1780
+ children: []
1781
+ };
1782
+ break;
1783
+ case "blockquote":
1784
+ value = {
1785
+ category: "element",
1786
+ name: "blockquote",
1787
+ attributes: [],
1788
+ children: projected
1789
+ };
1790
+ break;
1791
+ case "codeBlock":
1792
+ value = {
1793
+ category: "element",
1794
+ name: "pre",
1795
+ attributes: [],
1796
+ children: [{
1797
+ category: "element",
1798
+ name: "code",
1799
+ attributes: current.lang === void 0 ? [] : [{
1800
+ name: "class",
1801
+ value: `language-${current.lang}`
1802
+ }],
1803
+ children: [{
1804
+ category: "text",
1805
+ value: current.code
1806
+ }]
1807
+ }]
1808
+ };
1809
+ break;
1810
+ case "list":
1811
+ value = {
1812
+ category: "element",
1813
+ name: current.ordered ? "ol" : "ul",
1814
+ attributes: current.ordered && current.start !== 1 ? [{
1815
+ name: "start",
1816
+ value: String(current.start)
1817
+ }] : [],
1818
+ children: projected
1819
+ };
1820
+ break;
1821
+ case "listItem":
1822
+ value = {
1823
+ category: "element",
1824
+ name: "li",
1825
+ attributes: [],
1826
+ children: projected
1827
+ };
1828
+ break;
1829
+ case "table": {
1830
+ let offset = 0;
1831
+ const header = [];
1832
+ for (const [column, cell] of current.header.entries()) {
1833
+ if (cell === void 0) continue;
1834
+ const align = current.align[column];
1835
+ const attributes = align === "left" || align === "right" || align === "center" ? [{
1836
+ name: "align",
1837
+ value: align
1838
+ }] : [];
1839
+ let count = 0;
1840
+ for (const child of cell) if (child !== void 0) count += 1;
1841
+ const cellChildren = [];
1842
+ for (const child of children.slice(offset, offset + count)) if (child !== void 0) cellChildren.push(child);
1843
+ header.push({
1844
+ category: "element",
1845
+ name: "th",
1846
+ attributes,
1847
+ children: cellChildren
1848
+ });
1849
+ offset += count;
1850
+ }
1851
+ const rows = [];
1852
+ for (const row of current.rows) {
1853
+ const cells = [];
1854
+ for (const [column, cell] of row.entries()) {
1855
+ if (cell === void 0) continue;
1856
+ const align = current.align[column];
1857
+ const attributes = align === "left" || align === "right" || align === "center" ? [{
1858
+ name: "align",
1859
+ value: align
1860
+ }] : [];
1861
+ let count = 0;
1862
+ for (const child of cell) if (child !== void 0) count += 1;
1863
+ const cellChildren = [];
1864
+ for (const child of children.slice(offset, offset + count)) if (child !== void 0) cellChildren.push(child);
1865
+ cells.push({
1866
+ category: "element",
1867
+ name: "td",
1868
+ attributes,
1869
+ children: cellChildren
1870
+ });
1871
+ offset += count;
1872
+ }
1873
+ rows.push({
1874
+ category: "element",
1875
+ name: "tr",
1876
+ attributes: [],
1877
+ children: cells
1878
+ });
1879
+ }
1880
+ const tableChildren = [{
1881
+ category: "element",
1882
+ name: "thead",
1883
+ attributes: [],
1884
+ children: [{
1885
+ category: "element",
1886
+ name: "tr",
1887
+ attributes: [],
1888
+ children: header
1889
+ }]
1890
+ }];
1891
+ if (isNonEmptyArray(current.rows)) tableChildren.push({
1892
+ category: "element",
1893
+ name: "tbody",
1894
+ attributes: [],
1895
+ children: rows
1896
+ });
1897
+ value = {
1898
+ category: "element",
1899
+ name: "table",
1900
+ attributes: [],
1901
+ children: tableChildren
1902
+ };
1903
+ break;
1904
+ }
1905
+ case "text":
1906
+ value = {
1907
+ category: "text",
1908
+ value: current.value
1909
+ };
1910
+ break;
1911
+ case "emphasis":
1912
+ value = {
1913
+ category: "element",
1914
+ name: current.strong ? "strong" : "em",
1915
+ attributes: [],
1916
+ children: projected
1917
+ };
1918
+ break;
1919
+ case "codeSpan":
1920
+ value = {
1921
+ category: "element",
1922
+ name: "code",
1923
+ attributes: [],
1924
+ children: [{
1925
+ category: "text",
1926
+ value: current.value
1927
+ }]
1928
+ };
1929
+ break;
1930
+ case "link":
1931
+ value = {
1932
+ category: "element",
1933
+ name: "a",
1934
+ attributes: [{
1935
+ name: "href",
1936
+ value: current.href
1937
+ }],
1938
+ children: projected
1939
+ };
1940
+ break;
1941
+ case "image":
1942
+ value = {
1943
+ category: "element",
1944
+ name: "img",
1945
+ attributes: [{
1946
+ name: "src",
1947
+ value: current.src
1948
+ }, {
1949
+ name: "alt",
1950
+ value: flattenText(current)
1951
+ }],
1952
+ children: []
1953
+ };
1954
+ break;
1955
+ case "break":
1956
+ value = {
1957
+ category: "element",
1958
+ name: "br",
1959
+ attributes: [],
1960
+ children: []
1961
+ };
1962
+ break;
1963
+ default: value = void 0;
1964
+ }
1965
+ values.push(value);
1966
+ }
1967
+ const projected = values[0];
1968
+ if (projected?.category === "document") return projected;
1969
+ return {
1970
+ category: "document",
1971
+ children: projected === void 0 ? [] : [projected]
1972
+ };
1973
+ }
1974
+ /**
1975
+ * Render a {@link MarkdownNode} to sanitized canonical HTML.
1976
+ *
1977
+ * @remarks
1978
+ * Markdown widens `@orkestrel/html`'s attribute floor by exactly `src`, because image
1979
+ * syntax is meaningless without its source. `src` is still a URL attribute, so the
1980
+ * floor refuses `javascript:`, `data:`, `vbscript:`, and `file:` values. A stricter
1981
+ * consumer can compose {@link markdownToHTML} with `@orkestrel/html`'s `HTML` class
1982
+ * directly.
1983
+ *
1984
+ * @param node - The markdown document or bare node to render
1985
+ * @returns Sanitized canonical HTML
1986
+ *
1987
+ * @example
1988
+ * ```ts
1989
+ * renderHTML({ element: 'paragraph', children: [{ element: 'text', value: 'a & b' }] })
1990
+ * // '<p>a &amp; b</p>'
1991
+ * ```
1992
+ */
1993
+ function renderHTML(node) {
1994
+ return renderHTML$1(new HTML(markdownToHTML(node)).sanitize({ attributes: [...SAFE_ATTRIBUTES, "src"] }).document);
1995
+ }
1996
+ /**
1997
+ * Render a {@link MarkdownNode} to its CANONICAL markdown source - the inverse
1998
+ * projection of `renderHTML`, and the serializer a `parse(renderMarkdown(doc))`
1999
+ * round-trip is built on. Canonical forms: `*` / `**` emphasis at even emphasis
2000
+ * nesting depths and `_` / `__` at odd depths, `- ` bullets, `N. ` sequential
2001
+ * ordinals (from the list's `start`), `---` thematic breaks, fenced code blocks
2002
+ * (backtick run widened past any 3+ backtick run inside the body), ATX headings,
2003
+ * `> `-prefixed blockquote lines, GFM tables (1-space-padded cells, `\|`-escaped
2004
+ * pipes, an alignment delimiter row), `[text](href)` links, `![alt](src)` images,
2005
+ * and two-space hard breaks. A `text` node's literal content is backslash-escaped
2006
+ * wherever it would otherwise re-parse as markup (AGENTS §14 parse↔render
2007
+ * soundness).
2008
+ *
2009
+ * @remarks
2010
+ * Total: never throws. At {@link MAX_DEPTH} a value-bearing node degrades to its
2011
+ * escaped `value`; any other node degrades to `''`. Blocks are joined by exactly one
2012
+ * blank line; a document with zero blocks renders `''`.
2013
+ *
2014
+ * @param node - The AST node to render (a full document, or any sub-node)
2015
+ * @returns The canonical markdown source
2016
+ *
2017
+ * @example
2018
+ * ```ts
2019
+ * renderMarkdown({ element: 'document', children: [
2020
+ * { element: 'heading', level: 2, children: [{ element: 'text', value: 'Hi' }] },
2021
+ * ] })
2022
+ * // '## Hi'
2023
+ * ```
2024
+ */
2025
+ function renderMarkdown(node) {
2026
+ const stack = [{
2027
+ node,
2028
+ depth: 0,
2029
+ expanded: false,
2030
+ count: 0,
2031
+ escaped: "",
2032
+ escapeBang: false,
2033
+ nesting: 0
2034
+ }];
2035
+ const values = [];
2036
+ while (stack.length > 0) {
2037
+ const frame = stack.pop();
2038
+ if (frame === void 0) continue;
2039
+ const current = frame.node;
2040
+ if (!frame.expanded) {
2041
+ let escaped = "";
2042
+ if ((frame.depth >= 64 || current.element === "text") && "value" in current && typeof current.value === "string") for (let index = 0; index < current.value.length; index += 1) {
2043
+ const character = current.value[index] ?? "";
2044
+ const atLineStart = index === 0 || current.value[index - 1] === "\n";
2045
+ if (current.element === "text" && character === "!" && index === current.value.length - 1 && frame.escapeBang) {
2046
+ escaped += "\\!";
2047
+ continue;
2048
+ }
2049
+ if (character === "\\" || character === "*" || character === "_" || character === "`" || character === "[" || character === "]") {
2050
+ escaped += `\\${character}`;
2051
+ continue;
2052
+ }
2053
+ if (atLineStart) {
2054
+ if (character === "#" || character === ">") {
2055
+ escaped += `\\${character}`;
2056
+ continue;
2057
+ }
2058
+ if ((character === "-" || character === "~") && current.value[index + 1] === character && current.value[index + 2] === character) {
2059
+ escaped += `\\${character}`;
2060
+ continue;
2061
+ }
2062
+ if ((character === "-" || character === "+") && (current.value[index + 1] ?? " ") === " ") {
2063
+ escaped += `\\${character}`;
2064
+ continue;
2065
+ }
2066
+ if (/[0-9]/.test(character)) {
2067
+ let end = index;
2068
+ while (end < current.value.length && /[0-9]/.test(current.value[end] ?? "")) end += 1;
2069
+ const marker = current.value[end];
2070
+ if ((marker === "." || marker === ")") && current.value[end + 1] === " ") {
2071
+ escaped += `${current.value.slice(index, end)}\\${marker}`;
2072
+ index = end;
2073
+ continue;
2074
+ }
2075
+ }
2076
+ }
2077
+ escaped += character;
2078
+ }
2079
+ if (frame.depth >= 64) {
2080
+ values.push(escaped);
2081
+ continue;
2082
+ }
2083
+ const groups = [];
2084
+ const adjacent = [];
2085
+ let depth = frame.depth + 1;
2086
+ switch (current.element) {
2087
+ case "document":
2088
+ case "blockquote":
2089
+ case "listItem":
2090
+ groups.push(current.children);
2091
+ adjacent.push(false);
2092
+ break;
2093
+ case "heading":
2094
+ case "paragraph":
2095
+ case "emphasis":
2096
+ case "link":
2097
+ case "image":
2098
+ groups.push(current.children);
2099
+ adjacent.push(true);
2100
+ break;
2101
+ case "list":
2102
+ groups.push(current.items);
2103
+ adjacent.push(false);
2104
+ break;
2105
+ case "table":
2106
+ for (const cell of current.header) if (cell !== void 0) {
2107
+ groups.push(cell);
2108
+ adjacent.push(true);
2109
+ }
2110
+ for (const row of current.rows) {
2111
+ if (row === void 0) continue;
2112
+ for (let column = 0; column < current.header.length; column += 1) {
2113
+ const cell = row[column];
2114
+ if (cell !== void 0) {
2115
+ groups.push(cell);
2116
+ adjacent.push(true);
2117
+ }
2118
+ }
2119
+ }
2120
+ depth += 1;
2121
+ }
2122
+ const children = [];
2123
+ const escapeBangs = [];
2124
+ for (let groupIndex = 0; groupIndex < groups.length; groupIndex += 1) {
2125
+ const group = groups[groupIndex];
2126
+ if (group === void 0) continue;
2127
+ for (let position = 0; position < group.length; position += 1) {
2128
+ const child = group[position];
2129
+ if (child === void 0) continue;
2130
+ let escapeBang = false;
2131
+ if (adjacent[groupIndex] === true) {
2132
+ let nextPosition = position + 1;
2133
+ let next = group[nextPosition];
2134
+ while (next === void 0 && nextPosition < group.length) {
2135
+ nextPosition += 1;
2136
+ next = group[nextPosition];
2137
+ }
2138
+ escapeBang = next?.element === "link";
2139
+ }
2140
+ children.push(child);
2141
+ escapeBangs.push(escapeBang);
2142
+ }
2143
+ }
2144
+ stack.push({
2145
+ ...frame,
2146
+ expanded: true,
2147
+ count: children.length,
2148
+ escaped
2149
+ });
2150
+ const nesting = current.element === "emphasis" ? frame.nesting + 1 : frame.nesting;
2151
+ for (let index = children.length - 1; index >= 0; index -= 1) {
2152
+ const child = children[index];
2153
+ if (child !== void 0) stack.push({
2154
+ node: child,
2155
+ depth,
2156
+ expanded: false,
2157
+ count: 0,
2158
+ escaped: "",
2159
+ escapeBang: escapeBangs[index] === true && depth < 64,
2160
+ nesting
2161
+ });
2162
+ }
2163
+ continue;
2164
+ }
2165
+ const children = frame.count === 0 ? [] : values.splice(values.length - frame.count, frame.count);
2166
+ let value = "";
2167
+ switch (current.element) {
2168
+ case "codeBlock":
2169
+ case "codeSpan": {
2170
+ const body = current.element === "codeBlock" ? current.code : current.value;
2171
+ let longest = 0;
2172
+ let run = 0;
2173
+ for (const character of body) if (character === "`") {
2174
+ run += 1;
2175
+ longest = Math.max(longest, run);
2176
+ } else run = 0;
2177
+ const fence = "`".repeat(Math.max(current.element === "codeBlock" ? 3 : 1, longest + 1));
2178
+ if (current.element === "codeBlock") {
2179
+ value = `${fence}${current.lang === void 0 ? "" : current.lang}\n${current.code}\n${fence}`;
2180
+ break;
2181
+ }
2182
+ const pad = current.value.startsWith("`") || current.value.endsWith("`") ? " " : "";
2183
+ value = `${fence}${pad}${current.value}${pad}${fence}`;
2184
+ break;
2185
+ }
2186
+ case "break":
2187
+ value = " \n";
2188
+ break;
2189
+ case "document":
2190
+ value = children.join("\n\n");
2191
+ break;
2192
+ case "heading": {
2193
+ const escaped = children.join("").replace(/(^|[^\\])(#+)$/, (_match, before, hashes) => {
2194
+ return `${before}\\${hashes[0] ?? ""}${hashes.slice(1)}`;
2195
+ });
2196
+ value = `${"#".repeat(current.level)} ${escaped}`;
2197
+ break;
2198
+ }
2199
+ case "paragraph":
2200
+ value = children.join("");
2201
+ break;
2202
+ case "thematicBreak":
2203
+ value = "---";
2204
+ break;
2205
+ case "blockquote":
2206
+ value = children.join("\n\n").split("\n").map((line) => line === "" ? ">" : `> ${line}`).join("\n");
2207
+ break;
2208
+ case "list": {
2209
+ const items = [];
2210
+ let ordinal = current.start;
2211
+ for (const [position, body] of children.entries()) {
2212
+ const marker = current.ordered ? `${ordinal}. ` : "- ";
2213
+ ordinal += 1;
2214
+ const pad = " ".repeat(marker.length);
2215
+ if (current.items[position]?.children[0]?.element === "table") {
2216
+ items.push(`${marker}\n${body.split("\n").map((line) => pad + line).join("\n")}`);
2217
+ continue;
2218
+ }
2219
+ items.push(body.split("\n").map((line, index) => index === 0 ? marker + line : line === "" ? "" : pad + line).join("\n"));
2220
+ }
2221
+ value = items.join("\n");
2222
+ break;
2223
+ }
2224
+ case "listItem":
2225
+ value = children.join("\n\n");
2226
+ break;
2227
+ case "table": {
2228
+ let offset = 0;
2229
+ const header = [];
2230
+ for (const cell of current.header) {
2231
+ if (cell === void 0) {
2232
+ header.push("");
2233
+ continue;
2234
+ }
2235
+ let count = 0;
2236
+ for (const child of cell) if (child !== void 0) count += 1;
2237
+ header.push(children.slice(offset, offset + count).join("").replace(/\|/g, "\\|"));
2238
+ offset += count;
2239
+ }
2240
+ const delimiter = current.align.map((align) => {
2241
+ if (align === null) return "---";
2242
+ if (align === "left") return ":---";
2243
+ if (align === "right") return "---:";
2244
+ if (align === "center") return ":---:";
2245
+ return "---";
2246
+ });
2247
+ const rows = [];
2248
+ for (const row of current.rows) {
2249
+ const cells = [];
2250
+ for (let column = 0; column < current.header.length; column += 1) {
2251
+ const cell = row[column];
2252
+ if (cell === void 0) {
2253
+ cells.push("");
2254
+ continue;
2255
+ }
2256
+ let count = 0;
2257
+ for (const child of cell) if (child !== void 0) count += 1;
2258
+ cells.push(children.slice(offset, offset + count).join("").replace(/\|/g, "\\|"));
2259
+ offset += count;
2260
+ }
2261
+ rows.push(`| ${cells.join(" | ")} |`);
2262
+ }
2263
+ value = [
2264
+ `| ${header.join(" | ")} |`,
2265
+ `| ${delimiter.join(" | ")} |`,
2266
+ ...rows
2267
+ ].join("\n");
2268
+ break;
2269
+ }
2270
+ case "text":
2271
+ value = frame.escaped;
2272
+ break;
2273
+ case "emphasis": {
2274
+ const marker = frame.nesting % 2 === 0 ? current.strong ? "**" : "*" : current.strong ? "__" : "_";
2275
+ value = `${marker}${children.join("")}${marker}`;
2276
+ break;
2277
+ }
2278
+ case "link":
2279
+ case "image": {
2280
+ const escaped = (current.element === "link" ? current.href : current.src).replace(/[\\()]/g, (character) => `\\${character}`);
2281
+ value = `${current.element === "image" ? "!" : ""}[${children.join("")}](${escaped})`;
2282
+ break;
2283
+ }
2284
+ default: value = "";
2285
+ }
2286
+ if (stack.length === 0) return value;
2287
+ values.push(value);
1810
2288
  }
1811
- /**
1812
- * THE deep traversal - a lazy, depth-first, pre-order, root-inclusive generator
1813
- * over every {@link MarkdownNode} in the document. `find` / `filter` / `reduce`
1814
- * all iterate this single traversal.
1815
- *
1816
- * @example
1817
- * ```ts
1818
- * for (const node of markdown.walk()) {
1819
- * // every node, depth-first, pre-order, root-inclusive
1820
- * }
1821
- *
1822
- * // also consumable by for-await - JS accepts a sync iterable in for-await
1823
- * for await (const node of markdown.walk()) {
1824
- * // same sequence, no separate async iterator needed
1825
- * }
1826
- * ```
1827
- */
1828
- *walk() {
1829
- yield* walkNodes(this.#document);
2289
+ return "";
2290
+ }
2291
+ /**
2292
+ * Trim the whitespace at the two ends of an inline run - the leading whitespace of a
2293
+ * leading text node and the trailing whitespace of a trailing one - dropping either
2294
+ * node when nothing survives.
2295
+ *
2296
+ * @remarks
2297
+ * Markdown trims every line of a paragraph, a heading's text, and a table cell, so an
2298
+ * untrimmed run would come back from a re-parse a different AST. Expects a coalesced
2299
+ * run (see {@link coalesceText}): only the outermost node on each side is examined.
2300
+ *
2301
+ * @param nodes - The inline run to trim
2302
+ * @returns The run with its edge whitespace removed
2303
+ *
2304
+ * @example
2305
+ * ```ts
2306
+ * trimInlines([{ element: 'text', value: ' a ' }]) // [{ element: 'text', value: 'a' }]
2307
+ * ```
2308
+ */
2309
+ function trimInlines(nodes) {
2310
+ const out = [];
2311
+ for (const node of nodes) if (node !== void 0) out.push(node);
2312
+ const first = out[0];
2313
+ if (first !== void 0 && first.element === "text") {
2314
+ const value = first.value.replace(/^\s+/, "");
2315
+ if (isEmptyString(value)) out.shift();
2316
+ else out[0] = {
2317
+ element: "text",
2318
+ value
2319
+ };
1830
2320
  }
1831
- find(predicate) {
1832
- for (const node of this.walk()) if (predicate(node)) return node;
2321
+ const last = out[out.length - 1];
2322
+ if (last !== void 0 && last.element === "text") {
2323
+ const value = last.value.replace(/\s+$/, "");
2324
+ if (isEmptyString(value)) out.pop();
2325
+ else out[out.length - 1] = {
2326
+ element: "text",
2327
+ value
2328
+ };
1833
2329
  }
1834
- filter(predicate) {
1835
- const out = [];
1836
- for (const node of this.walk()) if (predicate(node)) out.push(node);
1837
- return out;
2330
+ return out;
2331
+ }
2332
+ /**
2333
+ * Reduce an inline run to the shape markdown can actually write back: adjacent text
2334
+ * coalesced, empty text dropped, and every hard break either kept as a real line
2335
+ * ending or spent as a space.
2336
+ *
2337
+ * @remarks
2338
+ * A hard break is ` \n` in markdown source, so it survives a re-parse only BETWEEN
2339
+ * two lines of content and only with no whitespace touching it: a leading or trailing
2340
+ * break has no line to end, a run of breaks reads as one blank line (which would end
2341
+ * the paragraph), and a space beside one is eaten by the parser's line trimming. Where
2342
+ * a break cannot be written at all - a heading and a table cell are one line each - it
2343
+ * becomes the space it stood for.
2344
+ *
2345
+ * @param nodes - The inline run to normalize
2346
+ * @param breaks - Whether the target context can carry a hard break at all; `false` for
2347
+ * a heading or a table cell, where every break becomes a space
2348
+ * @returns The normalized run
2349
+ *
2350
+ * @example
2351
+ * ```ts
2352
+ * normalizeInlines([{ element: 'break' }, { element: 'text', value: 'a' }], true)
2353
+ * // [{ element: 'text', value: 'a' }] - a leading break has no line to end
2354
+ * ```
2355
+ */
2356
+ function normalizeInlines(nodes, breaks) {
2357
+ const spent = [];
2358
+ for (const node of nodes) {
2359
+ if (node === void 0) continue;
2360
+ if (node.element === "break" && !breaks) spent.push({
2361
+ element: "text",
2362
+ value: " "
2363
+ });
2364
+ else spent.push(node);
1838
2365
  }
1839
- /** Rewrites the AST bottom-up (copy-on-write) and returns a new {@link Markdown}. */
1840
- map(rewrite) {
1841
- return new Markdown(rewriteDocument(this.#document, rewrite));
2366
+ const out = [];
2367
+ for (const node of coalesceText(spent)) {
2368
+ if (node === void 0) continue;
2369
+ const previous = out[out.length - 1];
2370
+ if (node.element === "text") {
2371
+ const value = previous?.element === "break" ? node.value.replace(/^\s+/, "") : node.value;
2372
+ if (!isEmptyString(value)) out.push({
2373
+ element: "text",
2374
+ value
2375
+ });
2376
+ continue;
2377
+ }
2378
+ if (node.element === "break") {
2379
+ if (previous === void 0 || previous.element === "break") continue;
2380
+ if (previous.element === "text") {
2381
+ const value = previous.value.replace(/\s+$/, "");
2382
+ if (isEmptyString(value)) out.pop();
2383
+ else out[out.length - 1] = {
2384
+ element: "text",
2385
+ value
2386
+ };
2387
+ }
2388
+ if (out.length === 0) continue;
2389
+ out.push(node);
2390
+ continue;
2391
+ }
2392
+ out.push(node);
1842
2393
  }
1843
- /** Folds the AST depth-first, pre-order into an accumulator. */
1844
- reduce(callback, initial) {
1845
- let accumulator = initial;
1846
- for (const node of this.walk()) accumulator = callback(accumulator, node);
1847
- return accumulator;
2394
+ while (out.length > 0 && out[out.length - 1]?.element === "break") out.pop();
2395
+ return coalesceText(out);
2396
+ }
2397
+ /**
2398
+ * Combine the projections of one node's children into the projection of that node -
2399
+ * the single place inline runs become paragraphs, so no ancestor has to decide it
2400
+ * twice.
2401
+ *
2402
+ * @remarks
2403
+ * A child is either inline or block, never both, so merging preserves source order
2404
+ * exactly: an inline run is held pending until a block arrives, then written out as a
2405
+ * paragraph BEFORE it. That is what keeps `<div>lead<p>a</p></div>` two paragraphs in
2406
+ * the order they were written rather than two lists that lost their interleaving. A
2407
+ * pending run carrying no text is dropped rather than becoming a blank paragraph.
2408
+ * Direct cells become one row before a later row, while cells/rows before a block
2409
+ * materialize as paragraphs at that exact source position.
2410
+ *
2411
+ * @param children - The children's projections, in source order
2412
+ * @returns Their combined projection
2413
+ *
2414
+ * @example
2415
+ * ```ts
2416
+ * mergeProjections([
2417
+ * createProjection({ inlines: [{ element: 'text', value: 'a' }], text: 'a' }),
2418
+ * createProjection({ blocks: [{ element: 'thematicBreak' }] }),
2419
+ * ]).blocks
2420
+ * // [{ element: 'paragraph', children: [...] }, { element: 'thematicBreak' }]
2421
+ * ```
2422
+ */
2423
+ function mergeProjections(children) {
2424
+ const blocks = [];
2425
+ const cells = [];
2426
+ const rows = [];
2427
+ let pending = [];
2428
+ let text = "";
2429
+ for (const child of children) {
2430
+ if (child === void 0) continue;
2431
+ text += child.text;
2432
+ if (isNonEmptyArray(child.blocks)) {
2433
+ const flushed = trimInlines(normalizeInlines(pending, true));
2434
+ if (isNonEmptyArray(flushed)) blocks.push({
2435
+ element: "paragraph",
2436
+ children: flushed
2437
+ });
2438
+ pending = [];
2439
+ for (const row of rows) for (const cell of row) if (cell !== void 0 && isNonEmptyArray(cell.inlines)) blocks.push({
2440
+ element: "paragraph",
2441
+ children: cell.inlines
2442
+ });
2443
+ rows.length = 0;
2444
+ for (const cell of cells) if (cell !== void 0 && isNonEmptyArray(cell.inlines)) blocks.push({
2445
+ element: "paragraph",
2446
+ children: cell.inlines
2447
+ });
2448
+ cells.length = 0;
2449
+ for (const block of projectionToBlocks(child)) blocks.push(block);
2450
+ continue;
2451
+ }
2452
+ if (isNonEmptyArray(child.rows)) {
2453
+ if (isNonEmptyArray(cells)) {
2454
+ rows.push([...cells]);
2455
+ cells.length = 0;
2456
+ }
2457
+ for (const row of child.rows) if (row !== void 0) rows.push(row);
2458
+ }
2459
+ for (const cell of child.cells) if (cell !== void 0) cells.push(cell);
2460
+ for (const inline of child.inlines) if (inline !== void 0) pending.push(inline);
1848
2461
  }
1849
- /** Runs a total catamorphism over the document using a {@link MarkdownHandlers} table. */
1850
- fold(handlers) {
1851
- return foldNode(this.#document, handlers, 0);
2462
+ if (isNonEmptyArray(rows) && isNonEmptyArray(cells)) {
2463
+ rows.push([...cells]);
2464
+ cells.length = 0;
1852
2465
  }
1853
- /**
1854
- * A web-standard {@link ReadableStream} over the document's top-level block nodes
1855
- * (shallow, source order) - a fresh, pull-based source per call: one block is
1856
- * enqueued per `pull`, so a slow reader's backpressure is respected. Cancellable,
1857
- * async-iterable wherever the platform supports it (Node, Deno), and pipeable
1858
- * through any {@link TransformStream} / {@link WritableStream}.
1859
- *
1860
- * @example
1861
- * ```ts
1862
- * // universal - works in every ReadableStream-supporting environment
1863
- * const reader = markdown.stream().getReader()
1864
- * for (let result = await reader.read(); !result.done; result = await reader.read()) {
1865
- * console.log(result.value) // one BlockNode
1866
- * }
1867
- *
1868
- * // Node / Deno / Firefox support async iteration of ReadableStream natively;
1869
- * // other environments should use the reader loop above instead.
1870
- * for await (const block of markdown.stream()) {
1871
- * console.log(block)
1872
- * }
1873
- * ```
1874
- */
1875
- stream() {
1876
- const blocks = this.#document.children;
1877
- let index = 0;
1878
- return new ReadableStream({ pull(controller) {
1879
- if (index < blocks.length) {
1880
- controller.enqueue(blocks[index]);
1881
- index += 1;
1882
- } else controller.close();
1883
- } });
2466
+ if (!isNonEmptyArray(blocks)) return createProjection({
2467
+ inlines: coalesceText(pending),
2468
+ text,
2469
+ cells,
2470
+ rows
2471
+ });
2472
+ const flushed = trimInlines(normalizeInlines(pending, true));
2473
+ if (isNonEmptyArray(flushed)) blocks.push({
2474
+ element: "paragraph",
2475
+ children: flushed
2476
+ });
2477
+ return createProjection({
2478
+ blocks,
2479
+ text,
2480
+ cells,
2481
+ rows
2482
+ });
2483
+ }
2484
+ /**
2485
+ * Read a projection as BLOCK content - the view a document, a blockquote, and a list
2486
+ * item each need.
2487
+ *
2488
+ * @remarks
2489
+ * A bare inline run becomes one paragraph, and a run carrying no text becomes nothing
2490
+ * at all, because a blank paragraph is unwritable in markdown. A cell or a row that
2491
+ * never reached a table is unwrapped here rather than dropped: a stray `<td>` is still
2492
+ * someone's content.
2493
+ *
2494
+ * @param projection - The projection to read
2495
+ * @returns Its block content
2496
+ *
2497
+ * @example
2498
+ * ```ts
2499
+ * projectionToBlocks(createProjection({ inlines: [{ element: 'text', value: 'a' }], text: 'a' }))
2500
+ * // [{ element: 'paragraph', children: [{ element: 'text', value: 'a' }] }]
2501
+ * ```
2502
+ */
2503
+ function projectionToBlocks(projection) {
2504
+ const blocks = [];
2505
+ for (const block of projection.blocks) if (block !== void 0) blocks.push(block);
2506
+ for (const row of projection.rows) {
2507
+ if (row === void 0) continue;
2508
+ for (const cell of row) {
2509
+ if (cell === void 0 || !isNonEmptyArray(cell.inlines)) continue;
2510
+ blocks.push({
2511
+ element: "paragraph",
2512
+ children: cell.inlines
2513
+ });
2514
+ }
1884
2515
  }
1885
- };
1886
- //#endregion
1887
- //#region src/core/factories.ts
2516
+ for (const cell of projection.cells) {
2517
+ if (cell === void 0 || !isNonEmptyArray(cell.inlines)) continue;
2518
+ blocks.push({
2519
+ element: "paragraph",
2520
+ children: cell.inlines
2521
+ });
2522
+ }
2523
+ const paragraph = trimInlines(normalizeInlines(projection.inlines, true));
2524
+ if (isNonEmptyArray(paragraph)) blocks.push({
2525
+ element: "paragraph",
2526
+ children: paragraph
2527
+ });
2528
+ return blocks;
2529
+ }
1888
2530
  /**
1889
- * Create a stateful markdown handle from a markdown string or an already-parsed
1890
- * {@link MarkdownDocument} - a typed AST plus the query, rewrite, and fold operations
1891
- * {@link MarkdownInterface} exposes.
2531
+ * Read a projection as INLINE content - the view a link, an emphasis, and a table cell
2532
+ * each need.
1892
2533
  *
1893
2534
  * @remarks
1894
- * Given a `string`, runs a block phase (headings / paragraphs / lists / GFM tables /
1895
- * fenced code / blockquotes / thematic breaks) then an inline phase (emphasis /
1896
- * inline code / links) to build a render-agnostic {@link MarkdownDocument}. Given a
1897
- * {@link MarkdownDocument}, adopts it AS-IS without re-validation - gate an untrusted
1898
- * value with `isMarkdownDocument` first. Pure + total parse (malformed markdown
1899
- * degrades to text, never throws) and zero-dependency - a hand-written scanner, no
1900
- * regex-only structural parse, linear-time (no ReDoS).
2535
+ * Inline content passes through as itself. Block content cannot: markdown has no way to
2536
+ * put a paragraph inside a table cell, so it flattens to one text node of its own words,
2537
+ * joined and whitespace-collapsed. Content that carries no text flattens to nothing
2538
+ * rather than to an empty text node, which is a shape the parser never produces.
1901
2539
  *
1902
- * @param input - A markdown string to parse, or an already-parsed {@link MarkdownDocument}
1903
- * @returns A working {@link MarkdownInterface}
2540
+ * @param projection - The projection to read
2541
+ * @returns Its inline content
1904
2542
  *
1905
2543
  * @example
1906
2544
  * ```ts
1907
- * import { createMarkdown } from '@src/core'
2545
+ * projectionToInlines(createProjection({ inlines: [{ element: 'break' }] }))
2546
+ * // [{ element: 'break' }]
2547
+ * ```
2548
+ */
2549
+ function projectionToInlines(projection) {
2550
+ if (!isNonEmptyArray(projection.blocks) && !isNonEmptyArray(projection.cells) && !isNonEmptyArray(projection.rows)) return coalesceText(projection.inlines);
2551
+ const value = projectionToBlocks(projection).map(flattenText).join(" ").replace(/\s+/g, " ").trim();
2552
+ return isEmptyString(value) ? [] : [{
2553
+ element: "text",
2554
+ value
2555
+ }];
2556
+ }
2557
+ /**
2558
+ * Project one HTML leaf - a text node, a comment, or a doctype - to its
2559
+ * {@link MarkdownProjection}.
1908
2560
  *
1909
- * const markdown = createMarkdown('# Hi\n\nRead the [guide](./guide.md).')
1910
- * markdown.document.children[0] // { element: 'heading', ... }
2561
+ * @remarks
2562
+ * Text collapses each whitespace run to one space, which is both what HTML means by it
2563
+ * and all markdown can write back; the raw value travels on in `text` for the two
2564
+ * places that need it verbatim, a code span and a `pre > code` body. A comment and a
2565
+ * doctype carry nothing into markdown and project to nothing.
2566
+ *
2567
+ * @param leaf - The leaf node to project
2568
+ * @returns Its projection
2569
+ *
2570
+ * @example
2571
+ * ```ts
2572
+ * projectHTMLLeaf({ category: 'text', value: 'a\n b' }).inlines
2573
+ * // [{ element: 'text', value: 'a b' }]
2574
+ * ```
2575
+ */
2576
+ function projectHTMLLeaf(leaf) {
2577
+ if (leaf.category !== "text") return createProjection();
2578
+ const value = leaf.value.replace(/\s+/g, " ");
2579
+ return createProjection({
2580
+ inlines: isEmptyString(value) ? [] : [{
2581
+ element: "text",
2582
+ value
2583
+ }],
2584
+ text: leaf.value
2585
+ });
2586
+ }
2587
+ /**
2588
+ * Project one HTML container - the document root or an element - from its children's
2589
+ * already-computed projections. THE element mapping, and the only place that decides
2590
+ * what an HTML tag becomes in markdown.
2591
+ *
2592
+ * @remarks
2593
+ * `h1`-`h6` become headings; `p` a paragraph; `strong` / `b` and `em` / `i` emphasis;
2594
+ * `code` a code span; `pre` a code block, verbatim through a first `code` element child
2595
+ * (its `language-` class naming the language) and through `renderText` otherwise; `a`
2596
+ * and `img` a link and an image, each destination re-sanitized; `br` and `hr` a hard
2597
+ * break and a thematic break; `blockquote` and `li` their block content, with bare
2598
+ * inline runs wrapped in paragraphs; `ul` / `ol` a list, ordered from the tag and
2599
+ * numbered from `start`; `th` / `td`, `tr`, and `table` a GFM table whose column
2600
+ * alignment comes from each header-position cell's `align` attribute. Every
2601
+ * `UNSAFE_ELEMENTS` subtree contributes nothing at all, text included. Every OTHER
2602
+ * element unwraps to its children, so wrapper soup melts while its content keeps its
2603
+ * shape - `<div><p>a</p><p>b</p></div>` stays two paragraphs.
2604
+ *
2605
+ * Three mappings read their own node rather than only their children's projections,
2606
+ * because HTML puts the fact in a position rather than in a value: a `pre` takes its
2607
+ * body from its `code` child's raw text, and a list takes one item per `li` child - so
2608
+ * an empty `<li>` is still an item, while the whitespace between two of them is not.
2609
+ * A `tr` accepts only its own direct cells, and a table derives the first `th`-bearing
2610
+ * row from its own source structure.
2611
+ *
2612
+ * @param node - The document root or element to project
2613
+ * @param children - Its children's projections, in source order
2614
+ * @returns Its projection
2615
+ *
2616
+ * @example
2617
+ * ```ts
2618
+ * projectHTMLNode({ category: 'element', name: 'hr', attributes: [], children: [] }, []).blocks
2619
+ * // [{ element: 'thematicBreak' }]
2620
+ * ```
2621
+ */
2622
+ function projectHTMLNode(node, children) {
2623
+ if (node.category === "document") return mergeProjections(children);
2624
+ if (UNSAFE_ELEMENTS.includes(node.name)) return createProjection();
2625
+ const merged = mergeProjections(children);
2626
+ const level = /^h([1-6])$/.exec(node.name);
2627
+ if (level !== null) return createProjection({
2628
+ blocks: [{
2629
+ element: "heading",
2630
+ level: parseInteger(level[1]) ?? 1,
2631
+ children: trimInlines(normalizeInlines(projectionToInlines(merged), false))
2632
+ }],
2633
+ text: merged.text
2634
+ });
2635
+ switch (node.name) {
2636
+ case "p":
2637
+ case "li": return createProjection({
2638
+ blocks: projectionToBlocks(merged),
2639
+ text: merged.text
2640
+ });
2641
+ case "blockquote": return createProjection({
2642
+ blocks: [{
2643
+ element: "blockquote",
2644
+ children: projectionToBlocks(merged)
2645
+ }],
2646
+ text: merged.text
2647
+ });
2648
+ case "hr": return createProjection({
2649
+ blocks: [{ element: "thematicBreak" }],
2650
+ text: ""
2651
+ });
2652
+ case "br": return createProjection({
2653
+ inlines: [{ element: "break" }],
2654
+ text: "\n"
2655
+ });
2656
+ case "strong":
2657
+ case "b":
2658
+ case "em":
2659
+ case "i": {
2660
+ const content = projectionToInlines(merged);
2661
+ const inner = trimInlines(normalizeInlines(content, true));
2662
+ if (!isNonEmptyArray(inner)) return createProjection({ text: merged.text });
2663
+ const first = content[0];
2664
+ const last = content[content.length - 1];
2665
+ const inlines = [];
2666
+ if (first?.element === "text" && /^\s/.test(first.value)) inlines.push({
2667
+ element: "text",
2668
+ value: " "
2669
+ });
2670
+ inlines.push({
2671
+ element: "emphasis",
2672
+ strong: node.name === "strong" || node.name === "b",
2673
+ children: inner
2674
+ });
2675
+ if (last?.element === "text" && /\s$/.test(last.value)) inlines.push({
2676
+ element: "text",
2677
+ value: " "
2678
+ });
2679
+ return createProjection({
2680
+ inlines,
2681
+ text: merged.text
2682
+ });
2683
+ }
2684
+ case "code": {
2685
+ const body = merged.text.replace(/\r\n?/g, "\n").replace(/\s*\n\s*/g, " ");
2686
+ const value = body.length > 2 && body.startsWith(" ") && body.endsWith(" ") && !isEmptyString(body.trim()) ? body.trim() : body;
2687
+ return createProjection({
2688
+ inlines: isEmptyString(value) ? [] : [{
2689
+ element: "codeSpan",
2690
+ value
2691
+ }],
2692
+ text: merged.text
2693
+ });
2694
+ }
2695
+ case "pre": {
2696
+ let position = -1;
2697
+ for (const [index, child] of node.children.entries()) {
2698
+ if (child?.category !== "element") continue;
2699
+ position = index;
2700
+ break;
2701
+ }
2702
+ const source = position === -1 ? void 0 : node.children[position];
2703
+ const projected = position === -1 ? void 0 : children[position];
2704
+ if (source?.category === "element" && source.name === "code" && projected !== void 0) {
2705
+ let lang;
2706
+ for (const token of (attributeOf(source, "class") ?? "").split(/\s+/)) {
2707
+ if (!token.startsWith("language-") || token.length <= 9 || token.includes("`")) continue;
2708
+ lang = token.slice(9);
2709
+ break;
2710
+ }
2711
+ return createProjection({
2712
+ blocks: [{
2713
+ element: "codeBlock",
2714
+ ...lang === void 0 ? {} : { lang },
2715
+ code: projected.text.replace(/\r\n?/g, "\n")
2716
+ }],
2717
+ text: merged.text
2718
+ });
2719
+ }
2720
+ return createProjection({
2721
+ blocks: [{
2722
+ element: "codeBlock",
2723
+ code: renderText(node).replace(/\r\n?/g, "\n")
2724
+ }],
2725
+ text: merged.text
2726
+ });
2727
+ }
2728
+ case "a": return createProjection({
2729
+ inlines: [{
2730
+ element: "link",
2731
+ href: sanitizeURL(attributeOf(node, "href") ?? "", SAFE_URL_SCHEMES),
2732
+ children: normalizeInlines(projectionToInlines(merged), true)
2733
+ }],
2734
+ text: merged.text
2735
+ });
2736
+ case "img": {
2737
+ const alt = (attributeOf(node, "alt") ?? "").replace(/\s+/g, " ").trim();
2738
+ return createProjection({
2739
+ inlines: [{
2740
+ element: "image",
2741
+ src: sanitizeURL(attributeOf(node, "src") ?? "", SAFE_URL_SCHEMES),
2742
+ children: isEmptyString(alt) ? [] : [{
2743
+ element: "text",
2744
+ value: alt
2745
+ }]
2746
+ }],
2747
+ text: ""
2748
+ });
2749
+ }
2750
+ case "th":
2751
+ case "td": {
2752
+ const declared = (attributeOf(node, "align") ?? "").trim().toLowerCase();
2753
+ const align = TABLE_ALIGNMENTS.includes(declared) && (declared === "left" || declared === "right" || declared === "center") ? declared : void 0;
2754
+ return createProjection({
2755
+ text: merged.text,
2756
+ cells: [{
2757
+ align,
2758
+ inlines: trimInlines(normalizeInlines(projectionToInlines(merged), false))
2759
+ }]
2760
+ });
2761
+ }
2762
+ case "tr": {
2763
+ const cells = [];
2764
+ for (const [index, child] of children.entries()) {
2765
+ const source = node.children[index];
2766
+ if (source?.category !== "element" || source.name !== "th" && source.name !== "td" || child === void 0) continue;
2767
+ for (const cell of child.cells) if (cell !== void 0) cells.push(cell);
2768
+ }
2769
+ return createProjection({
2770
+ text: merged.text,
2771
+ rows: [cells]
2772
+ });
2773
+ }
2774
+ case "ul":
2775
+ case "ol": {
2776
+ const items = [];
2777
+ for (const [index, child] of children.entries()) {
2778
+ if (child === void 0) continue;
2779
+ const source = node.children[index];
2780
+ const blocks = projectionToBlocks(child);
2781
+ if (source?.category === "element" && source.name === "li") {
2782
+ items.push({
2783
+ element: "listItem",
2784
+ children: blocks
2785
+ });
2786
+ continue;
2787
+ }
2788
+ if (isNonEmptyArray(blocks)) items.push({
2789
+ element: "listItem",
2790
+ children: blocks
2791
+ });
2792
+ }
2793
+ if (!isNonEmptyArray(items)) return createProjection({ text: merged.text });
2794
+ const ordered = node.name === "ol";
2795
+ const declared = parseInteger(attributeOf(node, "start"));
2796
+ return createProjection({
2797
+ blocks: [{
2798
+ element: "list",
2799
+ ordered,
2800
+ start: ordered && declared !== void 0 && declared >= 0 && declared <= 999999999 ? declared : 1,
2801
+ items
2802
+ }],
2803
+ text: merged.text
2804
+ });
2805
+ }
2806
+ case "table": {
2807
+ const rows = [];
2808
+ for (const row of merged.rows) if (row !== void 0) rows.push(row);
2809
+ if (isNonEmptyArray(merged.cells)) rows.push(merged.cells);
2810
+ const headings = [];
2811
+ const rowed = [];
2812
+ const sources = [{
2813
+ children: node.children,
2814
+ index: 0,
2815
+ direct: false
2816
+ }];
2817
+ while (sources.length > 0) {
2818
+ const source = sources.pop();
2819
+ if (source === void 0) continue;
2820
+ if (source.index >= source.children.length) continue;
2821
+ const child = source.children[source.index];
2822
+ source.index += 1;
2823
+ sources.push(source);
2824
+ if (child?.category !== "element") continue;
2825
+ if (child.name === "th" || child.name === "td") {
2826
+ if (!source.direct) {
2827
+ headings.push(false);
2828
+ rowed.push(false);
2829
+ }
2830
+ source.direct = true;
2831
+ continue;
2832
+ }
2833
+ source.direct = false;
2834
+ if (child.name === "tr") {
2835
+ let heading = false;
2836
+ for (const cell of child.children) if (cell?.category === "element" && cell.name === "th") {
2837
+ heading = true;
2838
+ break;
2839
+ }
2840
+ headings.push(heading);
2841
+ rowed.push(true);
2842
+ continue;
2843
+ }
2844
+ sources.push({
2845
+ children: child.children,
2846
+ index: 0,
2847
+ direct: false
2848
+ });
2849
+ }
2850
+ let position;
2851
+ for (const [index, heading] of headings.entries()) {
2852
+ if (!heading) continue;
2853
+ position = index;
2854
+ break;
2855
+ }
2856
+ if (position === void 0) for (const [index, structural] of rowed.entries()) {
2857
+ if (!structural) continue;
2858
+ position = index;
2859
+ break;
2860
+ }
2861
+ const headerRow = position === void 0 ? void 0 : rows[position];
2862
+ const columns = headerRow?.length ?? rows[0]?.length ?? 0;
2863
+ if (columns === 0) return createProjection({
2864
+ blocks: projectionToBlocks(merged),
2865
+ text: merged.text
2866
+ });
2867
+ const header = [];
2868
+ const align = [];
2869
+ for (let column = 0; column < columns; column += 1) {
2870
+ const cell = headerRow?.[column];
2871
+ header.push(cell?.inlines ?? []);
2872
+ align.push(cell?.align ?? null);
2873
+ }
2874
+ const body = [];
2875
+ for (const [index, row] of rows.entries()) {
2876
+ if (row === void 0 || index === position) continue;
2877
+ const cells = [];
2878
+ for (let column = 0; column < header.length; column += 1) cells.push(row[column]?.inlines ?? []);
2879
+ body.push(cells);
2880
+ }
2881
+ return createProjection({
2882
+ blocks: [{
2883
+ element: "table",
2884
+ header,
2885
+ rows: body,
2886
+ align
2887
+ }],
2888
+ text: merged.text
2889
+ });
2890
+ }
2891
+ }
2892
+ return merged;
2893
+ }
2894
+ /**
2895
+ * Project an `@orkestrel/html` {@link HTMLNode} into a {@link MarkdownDocument} - the
2896
+ * HTML→markdown direction, and the inverse of {@link markdownToHTML}.
2897
+ *
2898
+ * @remarks
2899
+ * **Engine.** One total handler table - {@link projectHTMLNode} for the containers,
2900
+ * {@link projectHTMLLeaf} for the leaves - folded by `@orkestrel/html`'s own `foldNode`, so
2901
+ * depth capping, cycle safety, and bottom-up ordering are inherited rather than
2902
+ * rebuilt. Total: hostile, cyclic, and pathologically deep input degrades instead of
2903
+ * throwing.
2904
+ *
2905
+ * **Composed depth.** Both packages cap recursion at 64, and html's cap is reached
2906
+ * first: a document nested past it projects to a chain bounded by THAT cap, with the
2907
+ * content below it truncated before markdown ever sees it. Since the projected chain
2908
+ * can be a level or two deeper than {@link MAX_DEPTH}, the serializer's own cap can
2909
+ * then truncate again - so the anchor law below is a law within the depth budget, and
2910
+ * beyond it only totality is promised.
2911
+ *
2912
+ * **Safety.** Every `href` and `src` is re-sanitized through
2913
+ * `sanitizeURL(value, SAFE_URL_SCHEMES)` whether or not the AST was ever sanitized,
2914
+ * because a hand-built one never was. A refused destination empties to `''` and the
2915
+ * link or image is KEPT - `[text]()` - since a bad URL is no reason to lose the words
2916
+ * around it. An `UNSAFE_ELEMENTS` subtree contributes nothing at all, text included, so
2917
+ * a `script` body can never resurface as prose.
2918
+ *
2919
+ * **The anchor law.** HTML→markdown is lossy, so the fixpoint that matters is the
2920
+ * PROJECTED AST, not the input bytes:
2921
+ * `parseDocument(renderMarkdown(htmlToMarkdown(x)))` deep-equals `htmlToMarkdown(x)`.
2922
+ * The projection therefore emits canonical markdown shapes rather than literal
2923
+ * translations - whitespace collapsed, edges trimmed, a blank paragraph dropped, a hard
2924
+ * break only where a line can end - because a shape markdown cannot write back is a
2925
+ * shape this projection has no business producing.
2926
+ *
2927
+ * @param node - The HTML document or bare node to project
2928
+ * @returns The projected markdown document
2929
+ *
2930
+ * @example
2931
+ * ```ts
2932
+ * import { parseDocument } from '@orkestrel/html'
2933
+ *
2934
+ * htmlToMarkdown(parseDocument('<h1>Title</h1>'))
2935
+ * // { element: 'document', children: [{ element: 'heading', level: 1, children: [...] }] }
1911
2936
  * ```
1912
2937
  */
1913
- function createMarkdown(input) {
1914
- return new Markdown(input);
2938
+ function htmlToMarkdown(node) {
2939
+ return {
2940
+ element: "document",
2941
+ children: projectionToBlocks(foldNode$1(node, {
2942
+ document: projectHTMLNode,
2943
+ element: projectHTMLNode,
2944
+ text: projectHTMLLeaf,
2945
+ comment: projectHTMLLeaf,
2946
+ doctype: projectHTMLLeaf
2947
+ }))
2948
+ };
1915
2949
  }
1916
2950
  /**
1917
- * Compile the {@link textShape} into a {@link ContractInterface} for
1918
- * {@link TextNode} - a guard, coercing parser, JSON Schema, and seeded
1919
- * generator from one shape declaration (AGENTS §14).
2951
+ * Depth-first, pre-order, root-inclusive traversal of a {@link MarkdownNode} - yields
2952
+ * the node itself, then recurses into its children (block children, list items,
2953
+ * image/link inline children, table header/row cells' inline nodes) in walk order.
1920
2954
  *
1921
- * @returns A `TextNode` contract bundling `schema` / `is` / `parse` / `generate`
2955
+ * @remarks
2956
+ * Total: never throws. Descent stops at {@link MAX_DEPTH} (the node at the cap is
2957
+ * still yielded; its children are not) so pathologically deep input cannot exhaust
2958
+ * the call stack.
2959
+ *
2960
+ * @param node - The AST node to walk (a full document, or any sub-node)
2961
+ * @returns A generator yielding every visited node, pre-order
1922
2962
  *
1923
2963
  * @example
1924
2964
  * ```ts
1925
- * import { createTextContract } from '@src/core'
1926
- *
1927
- * const text = createTextContract()
1928
- * text.is({ element: 'text', value: 'hi' }) // true
2965
+ * const doc = { element: 'document', children: [{ element: 'thematicBreak' }] } as const
2966
+ * [...walkNodes(doc)].map((node) => node.element) // ['document', 'thematicBreak']
1929
2967
  * ```
1930
2968
  */
1931
- function createTextContract() {
1932
- return createContract(textShape);
2969
+ function* walkNodes(node) {
2970
+ const stack = [{
2971
+ node,
2972
+ depth: 0
2973
+ }];
2974
+ while (stack.length > 0) {
2975
+ const frame = stack.pop();
2976
+ if (frame === void 0) continue;
2977
+ yield frame.node;
2978
+ if (frame.depth >= 64) continue;
2979
+ const children = [];
2980
+ switch (frame.node.element) {
2981
+ case "document":
2982
+ case "heading":
2983
+ case "paragraph":
2984
+ case "blockquote":
2985
+ case "listItem":
2986
+ case "emphasis":
2987
+ case "link":
2988
+ case "image":
2989
+ for (const child of frame.node.children) if (child !== void 0) children.push(child);
2990
+ break;
2991
+ case "list":
2992
+ for (const child of frame.node.items) if (child !== void 0) children.push(child);
2993
+ break;
2994
+ case "table":
2995
+ for (const cell of frame.node.header) if (cell !== void 0) {
2996
+ for (const child of cell) if (child !== void 0) children.push(child);
2997
+ }
2998
+ for (const row of frame.node.rows) if (row !== void 0) {
2999
+ for (const cell of row) if (cell !== void 0) {
3000
+ for (const child of cell) if (child !== void 0) children.push(child);
3001
+ }
3002
+ }
3003
+ }
3004
+ for (let index = children.length - 1; index >= 0; index -= 1) {
3005
+ const child = children[index];
3006
+ if (child !== void 0) stack.push({
3007
+ node: child,
3008
+ depth: frame.depth + 1
3009
+ });
3010
+ }
3011
+ }
1933
3012
  }
1934
3013
  /**
1935
- * Compile the {@link codeSpanShape} into a {@link ContractInterface} for
1936
- * {@link CodeSpanNode} - a guard, coercing parser, JSON Schema, and seeded
1937
- * generator from one shape declaration (AGENTS §14).
3014
+ * Fold a {@link MarkdownNode} into a `T` via a total catamorphism - children are
3015
+ * folded first (post-order), then the node's own {@link MarkdownHandler} is invoked
3016
+ * with the already-folded children.
1938
3017
  *
1939
- * @returns A `CodeSpanNode` contract bundling `schema` / `is` / `parse` / `generate`
3018
+ * @remarks
3019
+ * **Table contract.** A {@link TableNode} has no single `children` array - its cells
3020
+ * live in `header` (one inline-node list per column) and `rows` (a list of such
3021
+ * rows). The `table` handler receives ONE folded `T` per inline node, flattened in
3022
+ * walk order across ALL cells - every header cell's inline nodes (column order), then
3023
+ * every body row's cells' inline nodes (row order, then column order) - and reads
3024
+ * `node.header[c].length` / `node.rows[r][c].length` off the table node itself to
3025
+ * recover cell boundaries within the flat list.
3026
+ *
3027
+ * Total: never throws. At `depth >= {@link MAX_DEPTH}` the node's handler is invoked
3028
+ * with an empty children list instead of recursing further.
3029
+ *
3030
+ * @param node - The AST node to fold
3031
+ * @param handlers - The total {@link MarkdownHandlers} table, one handler per element
3032
+ * @param depth - The starting recursion depth (pass `0` at the entry point)
3033
+ * @returns The folded `T`
1940
3034
  *
1941
3035
  * @example
1942
3036
  * ```ts
1943
- * import { createCodeSpanContract } from '@src/core'
1944
- *
1945
- * const codeSpan = createCodeSpanContract()
1946
- * codeSpan.is({ element: 'codeSpan', value: 'const x = 1' }) // true
3037
+ * const countHandlers: MarkdownHandlers<number> = {
3038
+ * document: (_, children) => children.reduce((a, b) => a + b, 1),
3039
+ * // ...one handler per element, each summing its folded children
3040
+ * }
3041
+ * foldNode(document, countHandlers, 0) // total node count
1947
3042
  * ```
1948
3043
  */
1949
- function createCodeSpanContract() {
1950
- return createContract(codeSpanShape);
3044
+ function foldNode(node, handlers, depth) {
3045
+ const stack = [{
3046
+ node,
3047
+ depth,
3048
+ expanded: false,
3049
+ count: 0
3050
+ }];
3051
+ const values = [];
3052
+ while (stack.length > 0) {
3053
+ const frame = stack.pop();
3054
+ if (frame === void 0) continue;
3055
+ if (!frame.expanded) {
3056
+ const children = [];
3057
+ if (frame.depth < 64) switch (frame.node.element) {
3058
+ case "document":
3059
+ case "heading":
3060
+ case "paragraph":
3061
+ case "blockquote":
3062
+ case "listItem":
3063
+ case "emphasis":
3064
+ case "link":
3065
+ case "image":
3066
+ for (const child of frame.node.children) if (child !== void 0) children.push(child);
3067
+ break;
3068
+ case "list":
3069
+ for (const child of frame.node.items) if (child !== void 0) children.push(child);
3070
+ break;
3071
+ case "table":
3072
+ for (const cell of frame.node.header) if (cell !== void 0) {
3073
+ for (const child of cell) if (child !== void 0) children.push(child);
3074
+ }
3075
+ for (const row of frame.node.rows) if (row !== void 0) {
3076
+ for (const cell of row) if (cell !== void 0) {
3077
+ for (const child of cell) if (child !== void 0) children.push(child);
3078
+ }
3079
+ }
3080
+ }
3081
+ stack.push({
3082
+ ...frame,
3083
+ expanded: true,
3084
+ count: children.length
3085
+ });
3086
+ for (let index = children.length - 1; index >= 0; index -= 1) {
3087
+ const child = children[index];
3088
+ if (child !== void 0) stack.push({
3089
+ node: child,
3090
+ depth: frame.depth + 1,
3091
+ expanded: false,
3092
+ count: 0
3093
+ });
3094
+ }
3095
+ continue;
3096
+ }
3097
+ const children = frame.count === 0 ? [] : values.splice(values.length - frame.count, frame.count);
3098
+ let value;
3099
+ switch (frame.node.element) {
3100
+ case "document":
3101
+ value = handlers.document(frame.node, children);
3102
+ break;
3103
+ case "heading":
3104
+ value = handlers.heading(frame.node, children);
3105
+ break;
3106
+ case "paragraph":
3107
+ value = handlers.paragraph(frame.node, children);
3108
+ break;
3109
+ case "thematicBreak":
3110
+ value = handlers.thematicBreak(frame.node, children);
3111
+ break;
3112
+ case "blockquote":
3113
+ value = handlers.blockquote(frame.node, children);
3114
+ break;
3115
+ case "codeBlock":
3116
+ value = handlers.codeBlock(frame.node, children);
3117
+ break;
3118
+ case "list":
3119
+ value = handlers.list(frame.node, children);
3120
+ break;
3121
+ case "listItem":
3122
+ value = handlers.listItem(frame.node, children);
3123
+ break;
3124
+ case "table":
3125
+ value = handlers.table(frame.node, children);
3126
+ break;
3127
+ case "text":
3128
+ value = handlers.text(frame.node, children);
3129
+ break;
3130
+ case "emphasis":
3131
+ value = handlers.emphasis(frame.node, children);
3132
+ break;
3133
+ case "codeSpan":
3134
+ value = handlers.codeSpan(frame.node, children);
3135
+ break;
3136
+ case "break":
3137
+ value = handlers.break(frame.node, children);
3138
+ break;
3139
+ case "link":
3140
+ value = handlers.link(frame.node, children);
3141
+ break;
3142
+ case "image": value = handlers.image(frame.node, children);
3143
+ }
3144
+ if (stack.length === 0) return value;
3145
+ values.push(value);
3146
+ }
3147
+ switch (node.element) {
3148
+ case "document": return handlers.document(node, []);
3149
+ case "heading": return handlers.heading(node, []);
3150
+ case "paragraph": return handlers.paragraph(node, []);
3151
+ case "thematicBreak": return handlers.thematicBreak(node, []);
3152
+ case "blockquote": return handlers.blockquote(node, []);
3153
+ case "codeBlock": return handlers.codeBlock(node, []);
3154
+ case "list": return handlers.list(node, []);
3155
+ case "listItem": return handlers.listItem(node, []);
3156
+ case "table": return handlers.table(node, []);
3157
+ case "text": return handlers.text(node, []);
3158
+ case "emphasis": return handlers.emphasis(node, []);
3159
+ case "codeSpan": return handlers.codeSpan(node, []);
3160
+ case "break": return handlers.break(node, []);
3161
+ case "link": return handlers.link(node, []);
3162
+ case "image": return handlers.image(node, []);
3163
+ }
1951
3164
  }
1952
3165
  /**
1953
- * Compile the {@link codeBlockShape} into a {@link ContractInterface} for
1954
- * {@link CodeBlockNode} - a guard, coercing parser, JSON Schema, and seeded
1955
- * generator from one shape declaration (AGENTS §14).
3166
+ * Rewrite a {@link MarkdownDocument} bottom-up (copy-on-write) - each node's children
3167
+ * are rewritten first (post-order), then `rewrite` is applied to the node itself; the
3168
+ * document ROOT is never passed to `rewrite` (the `element: 'document'` invariant
3169
+ * always holds). A table's inline cells and a list's items ARE rewritten.
1956
3170
  *
1957
- * @returns A `CodeBlockNode` contract bundling `schema` / `is` / `parse` / `generate`
3171
+ * @remarks
3172
+ * Never mutates `document` - every level is rebuilt into a fresh object/array, even
3173
+ * when `rewrite` returns its input unchanged. When `rewrite` returns a node whose
3174
+ * `element` does not fit the slot it was called for (a block slot handed a
3175
+ * non-{@link BlockNode}, an inline slot handed a non-{@link InlineNode}, a list-item
3176
+ * slot handed a non-`listItem`), the ill-fitting result is discarded and the
3177
+ * freshly-rebuilt (unrewritten-at-this-level) node is kept instead - `rewriteDocument`
3178
+ * stays total and never produces a structurally invalid document.
3179
+ *
3180
+ * Descent is capped at {@link MAX_DEPTH}, the same cap {@link walkNodes} and
3181
+ * {@link foldNode} observe: at `depth >= MAX_DEPTH` the subtree is passed through
3182
+ * UNCHANGED (by reference, not rebuilt, and `rewrite` is not invoked on it) instead of
3183
+ * recursing further, so a pathologically deep adopted document cannot exhaust the
3184
+ * call stack. {@link MarkdownInterface.map} inherits this cap since it delegates here.
3185
+ *
3186
+ * @param document - The document AST to rewrite
3187
+ * @param rewrite - The bottom-up {@link MarkdownRewriteHandler}
3188
+ * @returns A new, rewritten {@link MarkdownDocument}
1958
3189
  *
1959
3190
  * @example
1960
3191
  * ```ts
1961
- * import { createCodeBlockContract } from '@src/core'
1962
- *
1963
- * const codeBlock = createCodeBlockContract()
1964
- * codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
3192
+ * rewriteDocument(document, (node) =>
3193
+ * node.element === 'text' ? { element: 'text', value: node.value.toUpperCase() } : node,
3194
+ * )
1965
3195
  * ```
1966
3196
  */
1967
- function createCodeBlockContract() {
1968
- return createContract(codeBlockShape);
3197
+ function rewriteDocument(document, rewrite) {
3198
+ const stack = [{
3199
+ node: document,
3200
+ depth: -1,
3201
+ expanded: false,
3202
+ count: 0
3203
+ }];
3204
+ const values = [];
3205
+ while (stack.length > 0) {
3206
+ const frame = stack.pop();
3207
+ if (frame === void 0) continue;
3208
+ const current = frame.node;
3209
+ if (!frame.expanded) {
3210
+ if (current.element !== "document" && frame.depth >= 64) {
3211
+ values.push(current);
3212
+ continue;
3213
+ }
3214
+ const children = [];
3215
+ switch (current.element) {
3216
+ case "document":
3217
+ case "heading":
3218
+ case "paragraph":
3219
+ case "blockquote":
3220
+ case "listItem":
3221
+ case "emphasis":
3222
+ case "link":
3223
+ case "image":
3224
+ for (const child of current.children) if (child !== void 0) children.push(child);
3225
+ break;
3226
+ case "list":
3227
+ for (const child of current.items) if (child !== void 0) children.push(child);
3228
+ break;
3229
+ case "table":
3230
+ for (const cell of current.header) if (cell !== void 0) {
3231
+ for (const child of cell) if (child !== void 0) children.push(child);
3232
+ }
3233
+ for (const row of current.rows) if (row !== void 0) {
3234
+ for (const cell of row) if (cell !== void 0) {
3235
+ for (const child of cell) if (child !== void 0) children.push(child);
3236
+ }
3237
+ }
3238
+ }
3239
+ stack.push({
3240
+ ...frame,
3241
+ expanded: true,
3242
+ count: children.length
3243
+ });
3244
+ const depth = current.element === "document" ? 0 : frame.depth + 1;
3245
+ for (let index = children.length - 1; index >= 0; index -= 1) {
3246
+ const child = children[index];
3247
+ if (child !== void 0) stack.push({
3248
+ node: child,
3249
+ depth,
3250
+ expanded: false,
3251
+ count: 0
3252
+ });
3253
+ }
3254
+ continue;
3255
+ }
3256
+ const children = frame.count === 0 ? [] : values.splice(values.length - frame.count, frame.count);
3257
+ let rebuilt = current;
3258
+ switch (current.element) {
3259
+ case "document": {
3260
+ const blocks = [];
3261
+ let offset = 0;
3262
+ for (const block of current.children) {
3263
+ if (block === void 0) continue;
3264
+ const child = children[offset];
3265
+ blocks.push(child !== void 0 && isBlockNode(child) ? child : block);
3266
+ offset += 1;
3267
+ }
3268
+ const result = {
3269
+ element: "document",
3270
+ children: blocks
3271
+ };
3272
+ if (stack.length === 0) return result;
3273
+ values.push(result);
3274
+ continue;
3275
+ }
3276
+ case "heading":
3277
+ case "paragraph": {
3278
+ const inlines = [];
3279
+ let offset = 0;
3280
+ for (const inline of current.children) {
3281
+ if (inline === void 0) continue;
3282
+ const child = children[offset];
3283
+ inlines.push(child !== void 0 && isInlineNode(child) ? child : inline);
3284
+ offset += 1;
3285
+ }
3286
+ rebuilt = {
3287
+ ...current,
3288
+ children: inlines
3289
+ };
3290
+ break;
3291
+ }
3292
+ case "blockquote": {
3293
+ const blocks = [];
3294
+ let offset = 0;
3295
+ for (const block of current.children) {
3296
+ if (block === void 0) continue;
3297
+ const child = children[offset];
3298
+ blocks.push(child !== void 0 && isBlockNode(child) ? child : block);
3299
+ offset += 1;
3300
+ }
3301
+ rebuilt = {
3302
+ ...current,
3303
+ children: blocks
3304
+ };
3305
+ break;
3306
+ }
3307
+ case "listItem": {
3308
+ const blocks = [];
3309
+ let offset = 0;
3310
+ for (const block of current.children) {
3311
+ if (block === void 0) continue;
3312
+ const child = children[offset];
3313
+ blocks.push(child !== void 0 && isBlockNode(child) ? child : block);
3314
+ offset += 1;
3315
+ }
3316
+ rebuilt = {
3317
+ element: "listItem",
3318
+ children: blocks
3319
+ };
3320
+ break;
3321
+ }
3322
+ case "emphasis":
3323
+ case "link":
3324
+ case "image": {
3325
+ const inlines = [];
3326
+ let offset = 0;
3327
+ for (const inline of current.children) {
3328
+ if (inline === void 0) continue;
3329
+ const child = children[offset];
3330
+ inlines.push(child !== void 0 && isInlineNode(child) ? child : inline);
3331
+ offset += 1;
3332
+ }
3333
+ rebuilt = {
3334
+ ...current,
3335
+ children: inlines
3336
+ };
3337
+ break;
3338
+ }
3339
+ case "list": {
3340
+ const items = [];
3341
+ let offset = 0;
3342
+ for (const item of current.items) {
3343
+ if (item === void 0) continue;
3344
+ const child = children[offset];
3345
+ items.push(child?.element === "listItem" ? child : item);
3346
+ offset += 1;
3347
+ }
3348
+ rebuilt = {
3349
+ ...current,
3350
+ items
3351
+ };
3352
+ break;
3353
+ }
3354
+ case "table": {
3355
+ let offset = 0;
3356
+ const header = [];
3357
+ for (const cell of current.header) {
3358
+ if (cell === void 0) continue;
3359
+ const inlines = [];
3360
+ for (const inline of cell) {
3361
+ if (inline === void 0) continue;
3362
+ const child = children[offset];
3363
+ inlines.push(child !== void 0 && isInlineNode(child) ? child : inline);
3364
+ offset += 1;
3365
+ }
3366
+ header.push(inlines);
3367
+ }
3368
+ const rows = [];
3369
+ for (const row of current.rows) {
3370
+ if (row === void 0) continue;
3371
+ const cells = [];
3372
+ for (const cell of row) {
3373
+ if (cell === void 0) continue;
3374
+ const inlines = [];
3375
+ for (const inline of cell) {
3376
+ if (inline === void 0) continue;
3377
+ const child = children[offset];
3378
+ inlines.push(child !== void 0 && isInlineNode(child) ? child : inline);
3379
+ offset += 1;
3380
+ }
3381
+ cells.push(inlines);
3382
+ }
3383
+ rows.push(cells);
3384
+ }
3385
+ rebuilt = {
3386
+ ...current,
3387
+ header,
3388
+ rows
3389
+ };
3390
+ break;
3391
+ }
3392
+ }
3393
+ const result = rewrite(rebuilt);
3394
+ let accepted = rebuilt;
3395
+ switch (current.element) {
3396
+ case "text":
3397
+ case "emphasis":
3398
+ case "codeSpan":
3399
+ case "break":
3400
+ case "link":
3401
+ case "image":
3402
+ if (isInlineNode(result)) accepted = result;
3403
+ break;
3404
+ case "heading":
3405
+ case "paragraph":
3406
+ case "list":
3407
+ case "table":
3408
+ case "codeBlock":
3409
+ case "blockquote":
3410
+ case "thematicBreak":
3411
+ if (isBlockNode(result)) accepted = result;
3412
+ break;
3413
+ case "listItem": if (result.element === "listItem") accepted = result;
3414
+ }
3415
+ values.push(accepted);
3416
+ }
3417
+ return {
3418
+ element: "document",
3419
+ children: [...document.children]
3420
+ };
1969
3421
  }
1970
3422
  /**
1971
- * Compile the {@link thematicBreakShape} into a {@link ContractInterface} for
1972
- * {@link ThematicBreakNode} - a guard, coercing parser, JSON Schema, and
1973
- * seeded generator from one shape declaration (AGENTS §14).
3423
+ * Concatenate the `value` / `code` content of every descendant text / code-span /
3424
+ * code-block node under `node`, including image alternative content, in walk order -
3425
+ * the plain-text projection of an AST (search indexing, word counts, a text-only
3426
+ * preview).
1974
3427
  *
1975
- * @returns A `ThematicBreakNode` contract bundling `schema` / `is` / `parse` / `generate`
3428
+ * @remarks
3429
+ * Total: never throws. Descent stops at {@link MAX_DEPTH} (contributes `''` past the
3430
+ * cap instead of recursing further).
3431
+ *
3432
+ * @param node - The AST node to flatten (a full document, or any sub-node)
3433
+ * @returns The concatenated text content
1976
3434
  *
1977
3435
  * @example
1978
3436
  * ```ts
1979
- * import { createThematicBreakContract } from '@src/core'
1980
- *
1981
- * const thematicBreak = createThematicBreakContract()
1982
- * thematicBreak.is({ element: 'thematicBreak' }) // true
3437
+ * flattenText({ element: 'paragraph', children: [
3438
+ * { element: 'text', value: 'a ' },
3439
+ * { element: 'codeSpan', value: 'b' },
3440
+ * ] })
3441
+ * // 'a b'
1983
3442
  * ```
1984
3443
  */
1985
- function createThematicBreakContract() {
1986
- return createContract(thematicBreakShape);
3444
+ function flattenText(node) {
3445
+ const stack = [{
3446
+ node,
3447
+ depth: 0
3448
+ }];
3449
+ let value = "";
3450
+ while (stack.length > 0) {
3451
+ const frame = stack.pop();
3452
+ if (frame === void 0 || frame.depth >= 64) continue;
3453
+ const children = [];
3454
+ switch (frame.node.element) {
3455
+ case "text":
3456
+ case "codeSpan":
3457
+ value += frame.node.value;
3458
+ break;
3459
+ case "codeBlock":
3460
+ value += frame.node.code;
3461
+ break;
3462
+ case "document":
3463
+ case "heading":
3464
+ case "paragraph":
3465
+ case "blockquote":
3466
+ case "listItem":
3467
+ case "emphasis":
3468
+ case "link":
3469
+ case "image":
3470
+ for (const child of frame.node.children) if (child !== void 0) children.push(child);
3471
+ break;
3472
+ case "list":
3473
+ for (const child of frame.node.items) if (child !== void 0) children.push(child);
3474
+ break;
3475
+ case "table":
3476
+ for (const cell of frame.node.header) if (cell !== void 0) {
3477
+ for (const child of cell) if (child !== void 0) children.push(child);
3478
+ }
3479
+ for (const row of frame.node.rows) if (row !== void 0) {
3480
+ for (const cell of row) if (cell !== void 0) {
3481
+ for (const child of cell) if (child !== void 0) children.push(child);
3482
+ }
3483
+ }
3484
+ }
3485
+ for (let index = children.length - 1; index >= 0; index -= 1) {
3486
+ const child = children[index];
3487
+ if (child !== void 0) stack.push({
3488
+ node: child,
3489
+ depth: frame.depth + 1
3490
+ });
3491
+ }
3492
+ }
3493
+ return value;
1987
3494
  }
1988
3495
  //#endregion
1989
- export { MAX_DEPTH, Markdown, SAFE_URL_SCHEMES, coalesceText, codeBlockShape, codeSpanShape, collectList, collectTable, createCodeBlockContract, createCodeSpanContract, createMarkdown, createTextContract, createThematicBreakContract, escapeHtml, extractFence, extractHeading, extractListItem, flattenText, foldNode, isBlankLine, isBlockNode, isBlockquoteNode, isCodeBlockNode, isCodeSpanNode, isEmphasisNode, isEscapable, isFenceClose, isFenceWhitespace, isHeadingNode, isInlineNode, isLinkNode, isListNode, isMarkdownDocument, isMarkdownNode, isParagraphNode, isQuote, isTableNode, isTableStart, isTextNode, isThematicBreak, isThematicBreakNode, isWhitespace, leadingIndent, listItemPartsShape, parseBlocks, parseDocument, parseInline, renderHTML, renderMarkdown, rewriteDocument, sanitizeUrl, scanCode, scanEmphasis, scanInline, scanLink, splitLines, splitTableRow, startsBlock, stripQuote, tableAlignShape, tableAlignments, textShape, thematicBreakShape, unescapeText, walkNodes };
3496
+ export { EMPTY_PROJECTION, MAX_DEPTH, Markdown, coalesceText, codeBlockShape, codeSpanShape, collectList, collectTable, countIndent, createCodeBlockContract, createCodeSpanContract, createLineBreakContract, createMarkdown, createProjection, createTextContract, createThematicBreakContract, delimiterToAlignments, extractFence, extractHeading, extractListItem, flattenText, foldNode, htmlToMarkdown, isBlankLine, isBlockNode, isBlockquoteNode, isCodeBlockNode, isCodeSpanNode, isEmphasisNode, isEscapable, isFenceClose, isFenceWhitespace, isHeadingNode, isImageNode, isInlineNode, isLineBreakNode, isLinkNode, isListNode, isMarkdownDocument, isMarkdownNode, isParagraphNode, isQuote, isTableNode, isTableStart, isTextNode, isThematicBreak, isThematicBreakNode, isWhitespace, lineBreakShape, listItemMatchShape, markdownToHTML, mergeProjections, normalizeInlines, parseBlocks, parseDocument, parseInline, projectHTMLLeaf, projectHTMLNode, projectionToBlocks, projectionToInlines, renderHTML, renderMarkdown, rewriteDocument, scanCode, scanEmphasis, scanInline, scanLink, splitLines, splitTableRow, startsBlock, stripQuote, tableAlignShape, textShape, thematicBreakShape, trimInlines, unescapeText, walkNodes };
1990
3497
 
1991
3498
  //# sourceMappingURL=index.js.map