@orkestrel/markdown 0.0.6 → 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,1074 +444,1227 @@ var isMarkdownDocument = recordOf({
412
444
  children: arrayOf(isBlockNode)
413
445
  });
414
446
  //#endregion
415
- //#region src/core/helpers.ts
416
- /**
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.
421
- *
422
- * @param markdown - The raw markdown source
423
- * @returns The document's lines, line-terminators stripped
424
- *
425
- * @example
426
- * ```ts
427
- * splitLines('a\r\nb\nc') // ['a', 'b', 'c']
428
- * ```
429
- */
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;
434
- }
435
- /**
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.
438
- *
439
- * @param line - The line to measure
440
- * @returns The number of leading space / tab characters
441
- *
442
- * @example
443
- * ```ts
444
- * leadingIndent(' text') // 2
445
- * ```
446
- */
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;
452
- }
447
+ //#region src/core/parsers.ts
453
448
  /**
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.
449
+ * Parses a run of markdown lines into a block AST, recursing into nested
450
+ * blockquotes, list items, and depth-capped degrade paragraphs.
458
451
  *
459
- * @param line - The candidate line
460
- * @returns The heading level (1–6) and its raw inline text, or `undefined`
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.
461
455
  *
462
456
  * @example
463
457
  * ```ts
464
- * extractHeading('## Title') // { level: 2, text: 'Title' }
458
+ * parseBlocks(['# Hi'], 0) // [{ element: 'heading', level: 1, children: [...] }]
465
459
  * ```
466
460
  */
467
- function extractHeading(line) {
468
- const match = /^(#{1,6})(?:\s+(.*))?$/.exec(line.trimStart());
469
- if (!match || match[1] === void 0) return void 0;
470
- return {
471
- level: match[1].length,
472
- text: (match[2] ?? "").replace(/\s+#+\s*$/, "").trim()
473
- };
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;
474
544
  }
475
545
  /**
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.
546
+ * Collects a GFM table starting at a header row, parsing the header, the
547
+ * alignment row, and every contiguous body row that follows.
480
548
  *
481
- * @param line - The candidate line
482
- * @returns The fence marker run and its language tag, or `undefined`
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.
483
552
  *
484
553
  * @example
485
554
  * ```ts
486
- * extractFence('```ts') // { marker: '```', lang: 'ts' }
555
+ * collectTable(['| a |', '| - |'], 0) // { node: { element: 'table', ... }, next: 2 }
487
556
  * ```
488
557
  */
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;
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
+ }
495
574
  return {
496
- marker: match[1],
497
- lang
575
+ node: {
576
+ element: "table",
577
+ header,
578
+ rows,
579
+ align: padded
580
+ },
581
+ next: index
498
582
  };
499
583
  }
500
584
  /**
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).
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.
505
587
  *
506
- * @param line - The candidate line
507
- * @returns The list-item parts, or `undefined` when not a list item
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.
508
592
  *
509
593
  * @example
510
594
  * ```ts
511
- * extractListItem('- item') // { ordered: false, start: 1, content: 'item', indent: 0, marker: 2 }
595
+ * collectList(['- item'], 0, 0) // { node: { element: 'list', ... }, next: 1 }
512
596
  * ```
513
597
  */
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
- };
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);
526
614
  }
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
- };
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
+ }
538
648
  }
539
- }
540
- /**
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
546
- *
547
- * @example
548
- * ```ts
549
- * stripQuote('> text') // 'text'
550
- * ```
551
- */
552
- function stripQuote(line) {
553
- return line.replace(/^\s{0,3}>\s?/, "");
554
- }
555
- /**
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
562
- *
563
- * @example
564
- * ```ts
565
- * splitTableRow('|a|b|') // ['a', 'b']
566
- * ```
567
- */
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 += "|";
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());
576
674
  index += 1;
577
- } else if (character === "|") {
578
- cells.push(current);
579
- current = "";
580
- } else current += character;
675
+ }
676
+ items.push({
677
+ element: "listItem",
678
+ children: parseBlocks(itemLines, depth + 1)
679
+ });
581
680
  }
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;
681
+ return {
682
+ node: {
683
+ element: "list",
684
+ ordered,
685
+ start: startOrdinal,
686
+ items
687
+ },
688
+ next: index
689
+ };
586
690
  }
587
691
  /**
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
692
+ * Parses a markdown string into a typed {@link MarkdownDocument} AST via the
693
+ * block phase.
593
694
  *
594
- * @example
595
- * ```ts
596
- * tableAlignments('| :--- | ---: |') // ['left', 'right']
597
- * ```
695
+ * @param markdown - The markdown source to parse.
696
+ * @returns The parsed document.
598
697
  */
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
- });
698
+ function parseDocument(markdown) {
699
+ return {
700
+ element: "document",
701
+ children: parseBlocks(splitLines(markdown), 0)
702
+ };
609
703
  }
610
704
  /**
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
705
+ * Parses inline markdown text (emphasis, code spans, links, images, and hard
706
+ * breaks) into inline AST nodes, coalescing adjacent text runs.
620
707
  *
621
- * @example
622
- * ```ts
623
- * startsBlock(['text', '## Heading'], 1) // true
624
- * ```
708
+ * @param text - The inline markdown text to parse.
709
+ * @returns The parsed inline nodes.
625
710
  */
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]);
711
+ function parseInline(text) {
712
+ return coalesceText(scanInline(text, 0, text.length));
629
713
  }
714
+ //#endregion
715
+ //#region src/core/Markdown.ts
630
716
  /**
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.
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.
633
720
  *
634
- * @param text - The raw text possibly carrying `\x` escapes
635
- * @returns The text with escapable `\x` reduced to `x`
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.
636
731
  *
637
732
  * @example
638
733
  * ```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
- }
653
- /**
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.
656
- *
657
- * @param nodes - The inline nodes (possibly with adjacent text runs)
658
- * @returns The nodes with consecutive text nodes concatenated
734
+ * import { Markdown, isHeadingNode, renderMarkdown } from '@src/core'
659
735
  *
660
- * @example
661
- * ```ts
662
- * coalesceText([{ element: 'text', value: 'a' }, { element: 'text', value: 'b' }])
663
- * // [{ element: 'text', value: 'ab' }]
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).'
664
742
  * ```
665
743
  */
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);
744
+ var Markdown = class Markdown {
745
+ #document;
746
+ constructor(input) {
747
+ this.#document = typeof input === "string" ? parseDocument(input) : input;
675
748
  }
676
- return out;
677
- }
678
- /**
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).
683
- *
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`
688
- *
689
- * @example
690
- * ```ts
691
- * scanCode('`code`', 0, 6) // { value: 'code', end: 6 }
692
- * ```
693
- */
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;
749
+ /** The stored {@link MarkdownDocument} AST root. */
750
+ get document() {
751
+ return this.#document;
711
752
  }
712
- }
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
713
835
  /**
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 `[`).
718
- *
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`
836
+ * The shape of a {@link TextNode} - a plain-text leaf inline run.
726
837
  *
727
838
  * @example
728
839
  * ```ts
729
- * scanLink('[text](url)', 0, 11)
730
- * // { node: { element: 'link', href: 'url', children: [...] }, end: 11 }
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
731
845
  * ```
732
846
  */
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
- };
778
- }
847
+ var textShape = objectShape({
848
+ element: literalShape(["text"]),
849
+ value: stringShape()
850
+ });
779
851
  /**
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).
785
- *
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`
852
+ * The shape of a {@link CodeSpanNode} - an inline code span (`` `code` ``).
793
853
  *
794
854
  * @example
795
855
  * ```ts
796
- * scanEmphasis('*em*', 0, 4)
797
- * // { node: { element: 'emphasis', strong: false, children: [...] }, end: 4 }
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
798
861
  * ```
799
862
  */
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
- }
835
- }
863
+ var codeSpanShape = objectShape({
864
+ element: literalShape(["codeSpan"]),
865
+ value: stringShape()
866
+ });
836
867
  /**
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).
868
+ * The shape of a {@link LineBreakNode} - a GFM hard line-break leaf.
841
869
  *
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)
870
+ * @example
871
+ * ```ts
872
+ * import { createContract } from '@orkestrel/contract'
873
+ * import { lineBreakShape } from '@src/core'
874
+ *
875
+ * const lineBreak = createContract(lineBreakShape)
876
+ * lineBreak.is({ element: 'break' }) // true
877
+ * ```
878
+ */
879
+ var lineBreakShape = objectShape({ element: literalShape(["break"]) });
880
+ /**
881
+ * The shape of a {@link CodeBlockNode} - a fenced code block. `lang` is
882
+ * optional (absent when the opening fence carries no info-string).
851
883
  *
852
884
  * @example
853
885
  * ```ts
854
- * scanInline('hi *there*', 0, 10) // [{ element: 'text', value: 'hi ' }, { element: 'emphasis', ... }]
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
855
892
  * ```
856
893
  */
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
- while (index < to) {
866
- const character = source[index] ?? "";
867
- if (character === "\\" && index + 1 < to && isEscapable(source[index + 1] ?? "")) {
868
- pending += source[index + 1] ?? "";
869
- index += 2;
870
- continue;
871
- }
872
- let scanned;
873
- let end = index;
874
- if (character === "`") {
875
- const span = scanCode(source, index, to);
876
- if (span) {
877
- scanned = {
878
- element: "codeSpan",
879
- value: span.value
880
- };
881
- end = span.end;
882
- }
883
- }
884
- if (character === "[") {
885
- const link = scanLink(source, index, to, depth);
886
- if (link) {
887
- scanned = link.node;
888
- end = link.end;
889
- }
890
- }
891
- if (character === "*" || character === "_") {
892
- const emphasis = scanEmphasis(source, index, to, depth);
893
- if (emphasis) {
894
- scanned = emphasis.node;
895
- end = emphasis.end;
896
- }
897
- }
898
- if (scanned !== void 0) {
899
- if (pending.length > 0) {
900
- nodes.push({
901
- element: "text",
902
- value: pending
903
- });
904
- pending = "";
905
- }
906
- nodes.push(scanned);
907
- index = end;
908
- continue;
909
- }
910
- pending += character;
911
- index += 1;
912
- }
913
- if (pending.length > 0) nodes.push({
914
- element: "text",
915
- value: pending
916
- });
917
- return nodes;
918
- }
894
+ var codeBlockShape = objectShape({
895
+ element: literalShape(["codeBlock"]),
896
+ lang: optionalShape(stringShape()),
897
+ code: stringShape()
898
+ });
919
899
  /**
920
- * HTML-escape text content - `&` / `<` / `>` / `"` / `'` to their entities - so text
921
- * from a markdown document can never inject markup. The renderer applies this to every
922
- * text run, code body, and (escaped further) attribute value.
923
- *
924
- * @param text - The raw text
925
- * @returns The HTML-escaped text
900
+ * The shape of a {@link ThematicBreakNode} - a horizontal rule. Carries no
901
+ * fields beyond its `element` discriminant.
926
902
  *
927
903
  * @example
928
904
  * ```ts
929
- * escapeHtml('<a>&"\'') // '&lt;a&gt;&amp;&quot;&#39;'
905
+ * import { createContract } from '@orkestrel/contract'
906
+ * import { thematicBreakShape } from '@src/core'
907
+ *
908
+ * const thematicBreak = createContract(thematicBreakShape)
909
+ * thematicBreak.is({ element: 'thematicBreak' }) // true
930
910
  * ```
931
911
  */
932
- function escapeHtml(text) {
933
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
934
- }
912
+ var thematicBreakShape = objectShape({ element: literalShape(["thematicBreak"]) });
935
913
  /**
936
- * Sanitize + HTML-attribute-escape a link `href` - a destination whose scheme is not
937
- * in {@link SAFE_URL_SCHEMES} (notably `javascript:` / `data:` / `vbscript:`), or that
938
- * is protocol-relative (`//host/path`, or a backslash variant a browser normalizes to
939
- * the same effect - `\\host`, `/\host`, `\/host` - inherits whatever scheme the
940
- * embedding page is served over, including an unsafe one), is dropped to an empty
941
- * string; a relative / anchor / scheme-less (and non-protocol-relative) destination
942
- * (including a SINGLE leading `/` or `\`) is kept;
943
- * the surviving value is then HTML-escaped. Defence-in-depth against an XSS `href`,
944
- * even though the input is trusted.
914
+ * The shape of a {@link TableAlign} - the per-column GFM table alignment
915
+ * literal.
916
+ *
917
+ * @example
918
+ * ```ts
919
+ * import { createContract } from '@orkestrel/contract'
920
+ * import { tableAlignShape } from '@src/core'
945
921
  *
946
- * @param href - The raw link destination
947
- * @returns A safe, escaped `href` (empty when the scheme is unsafe or protocol-relative)
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.
948
937
  *
949
938
  * @example
950
939
  * ```ts
951
- * sanitizeUrl('javascript:alert(1)') // ''
952
- * sanitizeUrl('/path') // '/path'
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
953
945
  * ```
954
946
  */
955
- function sanitizeUrl(href) {
956
- let cleaned = "";
957
- for (const character of href) {
958
- const code = character.codePointAt(0) ?? 0;
959
- if (code > 32 && !(code >= 127 && code <= 159)) cleaned += character;
960
- }
961
- if (/^[/\\]{2}/.exec(cleaned)) return "";
962
- const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(cleaned);
963
- if (scheme && scheme[1] !== void 0 && !SAFE_URL_SCHEMES.has(scheme[1].toLowerCase())) return "";
964
- return escapeHtml(cleaned);
965
- }
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
966
956
  /**
967
- * Render a {@link MarkdownNode} (typically a {@link MarkdownDocument}) to a safe HTML
968
- * string - the recursive AST HTML engine (headings, paragraphs, lists, GFM tables,
969
- * fenced code, blockquotes, links, emphasis, inline code), escaping every text run and
970
- * sanitizing every link `href`.
957
+ * Create an HTML-to-markdown projection with absent fields defaulted from
958
+ * {@link EMPTY_PROJECTION} and the block/inline exclusivity invariant enforced.
971
959
  *
972
960
  * @remarks
973
- * Total: never throws. At {@link MAX_DEPTH} a value-bearing node (`text` / `codeSpan`)
974
- * degrades to its escaped `value`; any other node degrades to `''` instead of
975
- * recursing further, so pathologically deep input cannot exhaust the call stack.
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.
976
963
  *
977
- * @param node - The AST node to render (a full document, or any sub-node)
978
- * @returns The rendered, XSS-safe HTML string
964
+ * @param parts - The projection fields to provide
965
+ * @returns A complete invariant-preserving projection
979
966
  *
980
967
  * @example
981
968
  * ```ts
982
- * renderHTML({ element: 'document', children: [
983
- * { element: 'heading', level: 1, children: [{ element: 'text', value: 'Hi' }] },
984
- * ] })
985
- * // '<h1>Hi</h1>'
969
+ * createProjection({
970
+ * blocks: [{ element: 'thematicBreak' }],
971
+ * inlines: [{ element: 'text', value: 'discarded' }],
972
+ * })
973
+ * // { blocks: [{ element: 'thematicBreak' }], inlines: [], text: '', cells: [], rows: [] }
986
974
  * ```
987
975
  */
988
- function renderHTML(node) {
989
- const stack = [{
990
- node,
991
- depth: 0,
992
- expanded: false,
993
- count: 0
994
- }];
995
- const values = [];
996
- while (stack.length > 0) {
997
- const frame = stack.pop();
998
- if (frame === void 0) continue;
999
- const current = frame.node;
1000
- if (!frame.expanded) {
1001
- if (frame.depth >= 64) {
1002
- values.push("value" in current && typeof current.value === "string" ? escapeHtml(current.value) : "");
1003
- continue;
1004
- }
1005
- const children = [];
1006
- let depth = frame.depth + 1;
1007
- switch (current.element) {
1008
- case "document":
1009
- case "heading":
1010
- case "paragraph":
1011
- case "blockquote":
1012
- for (const child of current.children) if (child !== void 0) children.push(child);
1013
- break;
1014
- case "listItem": {
1015
- const only = current.children[0];
1016
- if (current.children.length === 1 && only !== void 0 && only.element === "paragraph") {
1017
- for (const child of only.children) if (child !== void 0) children.push(child);
1018
- } else for (const child of current.children) if (child !== void 0) children.push(child);
1019
- break;
1020
- }
1021
- case "emphasis":
1022
- case "link":
1023
- for (const child of current.children) if (child !== void 0) children.push(child);
1024
- depth += 1;
1025
- break;
1026
- case "list":
1027
- for (const child of current.items) if (child !== void 0) children.push(child);
1028
- break;
1029
- case "table":
1030
- for (const cell of current.header) if (cell !== void 0) {
1031
- for (const child of cell) if (child !== void 0) children.push(child);
1032
- }
1033
- for (const row of current.rows) if (row !== void 0) {
1034
- for (const cell of row) if (cell !== void 0) {
1035
- for (const child of cell) if (child !== void 0) children.push(child);
1036
- }
1037
- }
1038
- depth += 1;
1039
- break;
1040
- }
1041
- stack.push({
1042
- ...frame,
1043
- expanded: true,
1044
- count: children.length
1045
- });
1046
- for (let index = children.length - 1; index >= 0; index -= 1) {
1047
- const child = children[index];
1048
- if (child !== void 0) stack.push({
1049
- node: child,
1050
- depth,
1051
- expanded: false,
1052
- count: 0
1053
- });
1054
- }
1055
- continue;
1056
- }
1057
- const children = frame.count === 0 ? [] : values.splice(values.length - frame.count, frame.count);
1058
- let value = "";
1059
- switch (current.element) {
1060
- case "document":
1061
- value = children.join("\n");
1062
- break;
1063
- case "heading":
1064
- value = `<h${current.level}>${children.join("")}</h${current.level}>`;
1065
- break;
1066
- case "paragraph":
1067
- value = `<p>${children.join("")}</p>`;
1068
- break;
1069
- case "thematicBreak":
1070
- value = "<hr>";
1071
- break;
1072
- case "blockquote":
1073
- value = `<blockquote>\n${children.join("\n")}\n</blockquote>`;
1074
- break;
1075
- case "codeBlock":
1076
- value = `<pre>${current.lang === void 0 ? "<code>" : `<code class="language-${escapeHtml(current.lang)}">`}${escapeHtml(current.code)}</code></pre>`;
1077
- break;
1078
- case "list": {
1079
- const items = children.join("\n");
1080
- if (!current.ordered) {
1081
- value = `<ul>\n${items}\n</ul>`;
1082
- break;
1083
- }
1084
- value = `<ol${current.start !== 1 ? ` start="${current.start}"` : ""}>\n${items}\n</ol>`;
1085
- break;
1086
- }
1087
- case "listItem":
1088
- value = `<li>${children.join(current.children.length === 1 && current.children[0]?.element === "paragraph" ? "" : "\n")}</li>`;
1089
- break;
1090
- case "table": {
1091
- let offset = 0;
1092
- const header = [];
1093
- for (const [column, cell] of current.header.entries()) {
1094
- if (cell === void 0) continue;
1095
- const align = current.align[column];
1096
- const style = align === "left" || align === "right" || align === "center" ? ` style="text-align:${align}"` : "";
1097
- let count = 0;
1098
- for (const child of cell) if (child !== void 0) count += 1;
1099
- header.push(`<th${style}>${children.slice(offset, offset + count).join("")}</th>`);
1100
- offset += count;
1101
- }
1102
- const rows = [];
1103
- for (const row of current.rows) {
1104
- const cells = [];
1105
- for (const [column, cell] of row.entries()) {
1106
- if (cell === void 0) continue;
1107
- const align = current.align[column];
1108
- const style = align === "left" || align === "right" || align === "center" ? ` style="text-align:${align}"` : "";
1109
- let count = 0;
1110
- for (const child of cell) if (child !== void 0) count += 1;
1111
- cells.push(`<td${style}>${children.slice(offset, offset + count).join("")}</td>`);
1112
- offset += count;
1113
- }
1114
- rows.push(`<tr>${cells.join("")}</tr>`);
1115
- }
1116
- const body = rows.join("\n");
1117
- const bodyHtml = isNonEmptyArray(current.rows) ? `\n<tbody>\n${body}\n</tbody>` : "";
1118
- value = `<table>\n<thead>\n<tr>${header.join("")}</tr>\n</thead>${bodyHtml}\n</table>`;
1119
- break;
1120
- }
1121
- case "text":
1122
- value = escapeHtml(current.value);
1123
- break;
1124
- case "emphasis":
1125
- value = current.strong ? `<strong>${children.join("")}</strong>` : `<em>${children.join("")}</em>`;
1126
- break;
1127
- case "codeSpan":
1128
- value = `<code>${escapeHtml(current.value)}</code>`;
1129
- break;
1130
- case "link":
1131
- value = `<a href="${sanitizeUrl(current.href)}">${children.join("")}</a>`;
1132
- break;
1133
- default:
1134
- value = "";
1135
- break;
1136
- }
1137
- if (stack.length === 0) return value;
1138
- values.push(value);
1139
- }
1140
- return "";
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
+ };
1141
985
  }
1142
986
  /**
1143
- * Render a {@link MarkdownNode} to its CANONICAL markdown source - the inverse
1144
- * projection of `renderHTML`, and the serializer a `parse(renderMarkdown(doc))`
1145
- * round-trip is built on. Canonical forms: `*em*` / `**strong**` (underscore emphasis
1146
- * normalizes to asterisks), `- ` bullets, `N. ` sequential ordinals (from the list's
1147
- * `start`), `---` thematic breaks, fenced code blocks (backtick run widened past any
1148
- * 3+ backtick run inside the body), ATX headings, `> `-prefixed blockquote lines, GFM
1149
- * tables (1-space-padded cells, `\|`-escaped pipes, an alignment delimiter row), and
1150
- * `[text](href)` links. A `text` node's literal content is backslash-escaped wherever
1151
- * it would otherwise re-parse as markup (AGENTS §14 parse↔render soundness).
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.
1152
990
  *
1153
991
  * @remarks
1154
- * Total: never throws. At {@link MAX_DEPTH} a value-bearing node degrades to its
1155
- * escaped `value`; any other node degrades to `''`. Blocks are joined by exactly one
1156
- * blank line; a document with zero blocks renders `''`.
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).
1157
1000
  *
1158
- * @param node - The AST node to render (a full document, or any sub-node)
1159
- * @returns The canonical markdown source
1001
+ * @param input - A markdown string to parse, or an already-parsed {@link MarkdownDocument}
1002
+ * @returns A working {@link MarkdownInterface}
1160
1003
  *
1161
1004
  * @example
1162
1005
  * ```ts
1163
- * renderMarkdown({ element: 'document', children: [
1164
- * { element: 'heading', level: 2, children: [{ element: 'text', value: 'Hi' }] },
1165
- * ] })
1166
- * // '## Hi'
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', ... }
1167
1010
  * ```
1168
1011
  */
1169
- function renderMarkdown(node) {
1170
- const stack = [{
1171
- node,
1172
- depth: 0,
1173
- expanded: false,
1174
- count: 0,
1175
- escaped: ""
1176
- }];
1177
- const values = [];
1178
- while (stack.length > 0) {
1179
- const frame = stack.pop();
1180
- if (frame === void 0) continue;
1181
- const current = frame.node;
1182
- if (!frame.expanded) {
1183
- let escaped = "";
1184
- if ((frame.depth >= 64 || current.element === "text") && "value" in current && typeof current.value === "string") for (let index = 0; index < current.value.length; index += 1) {
1185
- const character = current.value[index] ?? "";
1186
- const atLineStart = index === 0 || current.value[index - 1] === "\n";
1187
- if (character === "\\" || character === "*" || character === "_" || character === "`" || character === "[" || character === "]") {
1188
- escaped += `\\${character}`;
1189
- continue;
1190
- }
1191
- if (atLineStart) {
1192
- if (character === "#" || character === ">") {
1193
- escaped += `\\${character}`;
1194
- continue;
1195
- }
1196
- if ((character === "-" || character === "+") && (current.value[index + 1] ?? " ") === " ") {
1197
- escaped += `\\${character}`;
1198
- continue;
1199
- }
1200
- if (/[0-9]/.test(character)) {
1201
- let end = index;
1202
- while (end < current.value.length && /[0-9]/.test(current.value[end] ?? "")) end += 1;
1203
- const marker = current.value[end];
1204
- if ((marker === "." || marker === ")") && current.value[end + 1] === " ") {
1205
- escaped += `${current.value.slice(index, end)}\\${marker}`;
1206
- index = end;
1207
- continue;
1208
- }
1209
- }
1210
- }
1211
- escaped += character;
1212
- }
1213
- if (frame.depth >= 64) {
1214
- values.push(escaped);
1215
- continue;
1216
- }
1217
- const children = [];
1218
- let depth = frame.depth + 1;
1219
- switch (current.element) {
1220
- case "document":
1221
- case "heading":
1222
- case "paragraph":
1223
- case "blockquote":
1224
- case "listItem":
1225
- case "emphasis":
1226
- case "link":
1227
- for (const child of current.children) if (child !== void 0) children.push(child);
1228
- break;
1229
- case "list":
1230
- for (const child of current.items) if (child !== void 0) children.push(child);
1231
- break;
1232
- case "table":
1233
- for (const cell of current.header) if (cell !== void 0) {
1234
- for (const child of cell) if (child !== void 0) children.push(child);
1235
- }
1236
- for (const row of current.rows) {
1237
- if (row === void 0) continue;
1238
- for (let column = 0; column < current.header.length; column += 1) {
1239
- const cell = row[column];
1240
- if (cell !== void 0) {
1241
- for (const child of cell) if (child !== void 0) children.push(child);
1242
- }
1243
- }
1244
- }
1245
- depth += 1;
1246
- break;
1247
- }
1248
- stack.push({
1249
- ...frame,
1250
- expanded: true,
1251
- count: children.length,
1252
- escaped
1253
- });
1254
- for (let index = children.length - 1; index >= 0; index -= 1) {
1255
- const child = children[index];
1256
- if (child !== void 0) stack.push({
1257
- node: child,
1258
- depth,
1259
- expanded: false,
1260
- count: 0,
1261
- escaped: ""
1262
- });
1263
- }
1012
+ function createMarkdown(input) {
1013
+ return new Markdown(input);
1014
+ }
1015
+ /**
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).
1019
+ *
1020
+ * @returns A `TextNode` contract bundling `schema` / `is` / `parse` / `generate`
1021
+ *
1022
+ * @example
1023
+ * ```ts
1024
+ * import { createTextContract } from '@src/core'
1025
+ *
1026
+ * const text = createTextContract()
1027
+ * text.is({ element: 'text', value: 'hi' }) // true
1028
+ * ```
1029
+ */
1030
+ function createTextContract() {
1031
+ return createContract(textShape);
1032
+ }
1033
+ /**
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).
1037
+ *
1038
+ * @returns A `CodeSpanNode` contract bundling `schema` / `is` / `parse` / `generate`
1039
+ *
1040
+ * @example
1041
+ * ```ts
1042
+ * import { createCodeSpanContract } from '@src/core'
1043
+ *
1044
+ * const codeSpan = createCodeSpanContract()
1045
+ * codeSpan.is({ element: 'codeSpan', value: 'const x = 1' }) // true
1046
+ * ```
1047
+ */
1048
+ function createCodeSpanContract() {
1049
+ return createContract(codeSpanShape);
1050
+ }
1051
+ /**
1052
+ * Compile the {@link lineBreakShape} into a {@link ContractInterface} for
1053
+ * {@link LineBreakNode}.
1054
+ *
1055
+ * @returns A `LineBreakNode` contract bundling `schema` / `is` / `parse` / `generate`
1056
+ *
1057
+ * @example
1058
+ * ```ts
1059
+ * import { createLineBreakContract } from '@src/core'
1060
+ *
1061
+ * createLineBreakContract().is({ element: 'break' }) // true
1062
+ * ```
1063
+ */
1064
+ function createLineBreakContract() {
1065
+ return createContract(lineBreakShape);
1066
+ }
1067
+ /**
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).
1071
+ *
1072
+ * @returns A `CodeBlockNode` contract bundling `schema` / `is` / `parse` / `generate`
1073
+ *
1074
+ * @example
1075
+ * ```ts
1076
+ * import { createCodeBlockContract } from '@src/core'
1077
+ *
1078
+ * const codeBlock = createCodeBlockContract()
1079
+ * codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
1080
+ * ```
1081
+ */
1082
+ function createCodeBlockContract() {
1083
+ return createContract(codeBlockShape);
1084
+ }
1085
+ /**
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).
1089
+ *
1090
+ * @returns A `ThematicBreakNode` contract bundling `schema` / `is` / `parse` / `generate`
1091
+ *
1092
+ * @example
1093
+ * ```ts
1094
+ * import { createThematicBreakContract } from '@src/core'
1095
+ *
1096
+ * const thematicBreak = createThematicBreakContract()
1097
+ * thematicBreak.is({ element: 'thematicBreak' }) // true
1098
+ * ```
1099
+ */
1100
+ function createThematicBreakContract() {
1101
+ return createContract(thematicBreakShape);
1102
+ }
1103
+ //#endregion
1104
+ //#region src/core/helpers.ts
1105
+ /**
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.
1110
+ *
1111
+ * @param markdown - The raw markdown source
1112
+ * @returns The document's lines, line-terminators stripped
1113
+ *
1114
+ * @example
1115
+ * ```ts
1116
+ * splitLines('a\r\nb\nc') // ['a', 'b', 'c']
1117
+ * ```
1118
+ */
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;
1123
+ }
1124
+ /**
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.
1127
+ *
1128
+ * @param line - The line to measure
1129
+ * @returns The number of leading space / tab characters
1130
+ *
1131
+ * @example
1132
+ * ```ts
1133
+ * countIndent(' text') // 2
1134
+ * ```
1135
+ */
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;
1141
+ }
1142
+ /**
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.
1147
+ *
1148
+ * @param line - The candidate line
1149
+ * @returns The heading level (1–6) and its raw inline text, or `undefined`
1150
+ *
1151
+ * @example
1152
+ * ```ts
1153
+ * extractHeading('## Title') // { level: 2, text: 'Title' }
1154
+ * ```
1155
+ */
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
+ };
1163
+ }
1164
+ /**
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.
1169
+ *
1170
+ * @param line - The candidate line
1171
+ * @returns The fence marker run and its language tag, or `undefined`
1172
+ *
1173
+ * @example
1174
+ * ```ts
1175
+ * extractFence('```ts') // { marker: '```', lang: 'ts' }
1176
+ * ```
1177
+ */
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;
1184
+ return {
1185
+ marker: match[1],
1186
+ lang
1187
+ };
1188
+ }
1189
+ /**
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).
1194
+ *
1195
+ * @param line - The candidate line
1196
+ * @returns The list-item parts, or `undefined` when not a list item
1197
+ *
1198
+ * @example
1199
+ * ```ts
1200
+ * extractListItem('- item') // { ordered: false, start: 1, content: 'item', indent: 0, marker: 2 }
1201
+ * ```
1202
+ */
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
+ };
1227
+ }
1228
+ }
1229
+ /**
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.
1232
+ *
1233
+ * @param line - A blockquote line (per {@link isQuote})
1234
+ * @returns The line with its leading `>` (and one space) removed
1235
+ *
1236
+ * @example
1237
+ * ```ts
1238
+ * stripQuote('> text') // 'text'
1239
+ * ```
1240
+ */
1241
+ function stripQuote(line) {
1242
+ return line.replace(/^\s{0,3}>\s?/, "");
1243
+ }
1244
+ /**
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.
1248
+ *
1249
+ * @param row - The raw table row line
1250
+ * @returns The row's cells, in column order
1251
+ *
1252
+ * @example
1253
+ * ```ts
1254
+ * splitTableRow('|a|b|') // ['a', 'b']
1255
+ * ```
1256
+ */
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;
1270
+ }
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;
1275
+ }
1276
+ /**
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`.
1280
+ *
1281
+ * @param delimiter - The table's delimiter row
1282
+ * @returns One alignment per column, in column order
1283
+ *
1284
+ * @example
1285
+ * ```ts
1286
+ * delimiterToAlignments('| :--- | ---: |') // ['left', 'right']
1287
+ * ```
1288
+ */
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
+ });
1299
+ }
1300
+ /**
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).
1306
+ *
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
1310
+ *
1311
+ * @example
1312
+ * ```ts
1313
+ * startsBlock(['text', '## Heading'], 1) // true
1314
+ * ```
1315
+ */
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
+ }
1320
+ /**
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.
1323
+ *
1324
+ * @param text - The raw text possibly carrying `\x` escapes
1325
+ * @returns The text with escapable `\x` reduced to `x`
1326
+ *
1327
+ * @example
1328
+ * ```ts
1329
+ * unescapeText('\\*hi\\*') // '*hi*'
1330
+ * ```
1331
+ */
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
+ }
1343
+ /**
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.
1346
+ *
1347
+ * @param nodes - The inline nodes (possibly with adjacent text runs)
1348
+ * @returns The nodes with consecutive text nodes concatenated
1349
+ *
1350
+ * @example
1351
+ * ```ts
1352
+ * coalesceText([{ element: 'text', value: 'a' }, { element: 'text', value: 'b' }])
1353
+ * // [{ element: 'text', value: 'ab' }]
1354
+ * ```
1355
+ */
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
+ }
1368
+ /**
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`
1378
+ *
1379
+ * @example
1380
+ * ```ts
1381
+ * scanCode('`code`', 0, 6) // { value: 'code', end: 6 }
1382
+ * ```
1383
+ */
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
+ }
1403
+ /**
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 `[`).
1408
+ *
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`
1416
+ *
1417
+ * @example
1418
+ * ```ts
1419
+ * scanLink('[text](url)', 0, 11)
1420
+ * // { node: { element: 'link', href: 'url', children: [...] }, end: 11 }
1421
+ * ```
1422
+ */
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;
1264
1430
  continue;
1265
1431
  }
1266
- const children = frame.count === 0 ? [] : values.splice(values.length - frame.count, frame.count);
1267
- let value = "";
1268
- switch (current.element) {
1269
- case "codeBlock":
1270
- case "codeSpan": {
1271
- const body = current.element === "codeBlock" ? current.code : current.value;
1272
- let longest = 0;
1273
- let run = 0;
1274
- for (const character of body) if (character === "`") {
1275
- run += 1;
1276
- longest = Math.max(longest, run);
1277
- } else run = 0;
1278
- const fence = "`".repeat(Math.max(current.element === "codeBlock" ? 3 : 1, longest + 1));
1279
- if (current.element === "codeBlock") {
1280
- value = `${fence}${current.lang === void 0 ? "" : current.lang}\n${current.code}\n${fence}`;
1281
- break;
1282
- }
1283
- const pad = current.value.startsWith("`") || current.value.endsWith("`") ? " " : "";
1284
- value = `${fence}${pad}${current.value}${pad}${fence}`;
1285
- break;
1286
- }
1287
- case "document":
1288
- value = children.join("\n\n");
1289
- break;
1290
- case "heading": {
1291
- const escaped = children.join("").replace(/(^|[^\\])(#+)$/, (_match, before, hashes) => {
1292
- return `${before}\\${hashes[0] ?? ""}${hashes.slice(1)}`;
1293
- });
1294
- value = `${"#".repeat(current.level)} ${escaped}`;
1295
- break;
1296
- }
1297
- case "paragraph":
1298
- value = children.join("");
1299
- break;
1300
- case "thematicBreak":
1301
- value = "---";
1302
- break;
1303
- case "blockquote":
1304
- value = children.join("\n\n").split("\n").map((line) => line === "" ? ">" : `> ${line}`).join("\n");
1305
- break;
1306
- case "list": {
1307
- const items = [];
1308
- let ordinal = current.start;
1309
- for (const body of children) {
1310
- const marker = current.ordered ? `${ordinal}. ` : "- ";
1311
- ordinal += 1;
1312
- const pad = " ".repeat(marker.length);
1313
- items.push(body.split("\n").map((line, index) => index === 0 ? marker + line : line === "" ? "" : pad + line).join("\n"));
1314
- }
1315
- value = items.join("\n");
1316
- break;
1317
- }
1318
- case "listItem":
1319
- value = children.join("\n\n");
1320
- break;
1321
- case "table": {
1322
- let offset = 0;
1323
- const header = [];
1324
- for (const cell of current.header) {
1325
- if (cell === void 0) {
1326
- header.push("");
1327
- continue;
1328
- }
1329
- let count = 0;
1330
- for (const child of cell) if (child !== void 0) count += 1;
1331
- header.push(children.slice(offset, offset + count).join("").replace(/\|/g, "\\|"));
1332
- offset += count;
1333
- }
1334
- const delimiter = current.align.map((align) => {
1335
- if (align === "left") return ":--";
1336
- if (align === "right") return "--:";
1337
- if (align === "center") return ":-:";
1338
- return "---";
1339
- });
1340
- const rows = [];
1341
- for (const row of current.rows) {
1342
- const cells = [];
1343
- for (let column = 0; column < current.header.length; column += 1) {
1344
- const cell = row[column];
1345
- if (cell === void 0) {
1346
- cells.push("");
1347
- continue;
1348
- }
1349
- let count = 0;
1350
- for (const child of cell) if (child !== void 0) count += 1;
1351
- cells.push(children.slice(offset, offset + count).join("").replace(/\|/g, "\\|"));
1352
- offset += count;
1353
- }
1354
- rows.push(`| ${cells.join(" | ")} |`);
1355
- }
1356
- value = [
1357
- `| ${header.join(" | ")} |`,
1358
- `| ${delimiter.join(" | ")} |`,
1359
- ...rows
1360
- ].join("\n");
1432
+ if (character === "[") bracketDepth += 1;
1433
+ else if (character === "]") {
1434
+ bracketDepth -= 1;
1435
+ if (bracketDepth === 0) {
1436
+ close = index;
1361
1437
  break;
1362
1438
  }
1363
- case "text":
1364
- value = frame.escaped;
1365
- break;
1366
- case "emphasis": {
1367
- const marker = current.strong ? "**" : "*";
1368
- value = `${marker}${children.join("")}${marker}`;
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;
1369
1455
  break;
1370
1456
  }
1371
- case "link": {
1372
- const href = current.href.replace(/[\\()]/g, (character) => `\\${character}`);
1373
- value = `[${children.join("")}](${href})`;
1374
- break;
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;
1375
1515
  }
1376
- default:
1377
- value = "";
1378
- break;
1379
1516
  }
1380
- if (stack.length === 0) return value;
1381
- values.push(value);
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;
1382
1532
  }
1383
- return "";
1384
1533
  }
1385
1534
  /**
1386
- * Depth-first, pre-order, root-inclusive traversal of a {@link MarkdownNode} - yields
1387
- * the node itself, then recurses into its children (block children, list items, table
1388
- * header/row cells' inline nodes) in walk order.
1389
- *
1390
- * @remarks
1391
- * Total: never throws. Descent stops at {@link MAX_DEPTH} (the node at the cap is
1392
- * still yielded; its children are not) so pathologically deep input cannot exhaust
1393
- * the call stack.
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).
1394
1540
  *
1395
- * @param node - The AST node to walk (a full document, or any sub-node)
1396
- * @returns A generator yielding every visited node, pre-order
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)
1397
1550
  *
1398
1551
  * @example
1399
1552
  * ```ts
1400
- * const doc = { element: 'document', children: [{ element: 'thematicBreak' }] } as const
1401
- * [...walkNodes(doc)].map((node) => node.element) // ['document', 'thematicBreak']
1553
+ * scanInline('hi *there*', 0, 10) // [{ element: 'text', value: 'hi ' }, { element: 'emphasis', ... }]
1402
1554
  * ```
1403
1555
  */
1404
- function* walkNodes(node) {
1405
- const stack = [{
1406
- node,
1407
- depth: 0
1408
- }];
1409
- while (stack.length > 0) {
1410
- const frame = stack.pop();
1411
- if (frame === void 0) continue;
1412
- yield frame.node;
1413
- if (frame.depth >= 64) continue;
1414
- const children = [];
1415
- switch (frame.node.element) {
1416
- case "document":
1417
- case "heading":
1418
- case "paragraph":
1419
- case "blockquote":
1420
- case "listItem":
1421
- case "emphasis":
1422
- case "link":
1423
- for (const child of frame.node.children) if (child !== void 0) children.push(child);
1424
- break;
1425
- case "list":
1426
- for (const child of frame.node.items) if (child !== void 0) children.push(child);
1427
- break;
1428
- case "table":
1429
- for (const cell of frame.node.header) if (cell !== void 0) {
1430
- for (const child of cell) if (child !== void 0) children.push(child);
1431
- }
1432
- for (const row of frame.node.rows) if (row !== void 0) {
1433
- for (const cell of row) if (cell !== void 0) {
1434
- for (const child of cell) if (child !== void 0) children.push(child);
1435
- }
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 = "";
1436
1581
  }
1437
- break;
1582
+ nodes.push({ element: "break" });
1583
+ index = spaceEnd + 1;
1584
+ continue;
1585
+ }
1438
1586
  }
1439
- for (let index = children.length - 1; index >= 0; index -= 1) {
1440
- const child = children[index];
1441
- if (child !== void 0) stack.push({
1442
- node: child,
1443
- depth: frame.depth + 1
1444
- });
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;
1445
1635
  }
1636
+ pending += character;
1637
+ index += 1;
1446
1638
  }
1639
+ if (pending.length > 0) nodes.push({
1640
+ element: "text",
1641
+ value: pending
1642
+ });
1643
+ return nodes;
1447
1644
  }
1448
1645
  /**
1449
- * Fold a {@link MarkdownNode} into a `T` via a total catamorphism - children are
1450
- * folded first (post-order), then the node's own {@link MarkdownHandler} is invoked
1451
- * with the already-folded children.
1646
+ * Project a {@link MarkdownNode} into an unsanitized {@link HTMLDocument}.
1452
1647
  *
1453
1648
  * @remarks
1454
- * **Table contract.** A {@link TableNode} has no single `children` array - its cells
1455
- * live in `header` (one inline-node list per column) and `rows` (a list of such
1456
- * rows). The `table` handler receives ONE folded `T` per inline node, flattened in
1457
- * walk order across ALL cells - every header cell's inline nodes (column order), then
1458
- * every body row's cells' inline nodes (row order, then column order) - and reads
1459
- * `node.header[c].length` / `node.rows[r][c].length` off the table node itself to
1460
- * recover cell boundaries within the flat list.
1461
- *
1462
- * Total: never throws. At `depth >= {@link MAX_DEPTH}` the node's handler is invoked
1463
- * with an empty children list instead of recursing further.
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.
1464
1654
  *
1465
- * @param node - The AST node to fold
1466
- * @param handlers - The total {@link MarkdownHandlers} table, one handler per element
1467
- * @param depth - The starting recursion depth (pass `0` at the entry point)
1468
- * @returns The folded `T`
1655
+ * @param node - The markdown document or bare node to project
1656
+ * @returns An unsanitized HTML document wrapping the projected node or nodes
1469
1657
  *
1470
1658
  * @example
1471
1659
  * ```ts
1472
- * const countHandlers: MarkdownHandlers<number> = {
1473
- * document: (_, children) => children.reduce((a, b) => a + b, 1),
1474
- * // ...one handler per element, each summing its folded children
1475
- * }
1476
- * foldNode(document, countHandlers, 0) // total node count
1660
+ * markdownToHTML({ element: 'text', value: 'a & b' })
1661
+ * // { category: 'document', children: [{ category: 'text', value: 'a & b' }] }
1477
1662
  * ```
1478
1663
  */
1479
- function foldNode(node, handlers, depth) {
1664
+ function markdownToHTML(node) {
1480
1665
  const stack = [{
1481
1666
  node,
1482
- depth,
1667
+ depth: 0,
1483
1668
  expanded: false,
1484
1669
  count: 0
1485
1670
  }];
@@ -1487,31 +1672,62 @@ function foldNode(node, handlers, depth) {
1487
1672
  while (stack.length > 0) {
1488
1673
  const frame = stack.pop();
1489
1674
  if (frame === void 0) continue;
1675
+ const current = frame.node;
1490
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
+ }
1491
1684
  const children = [];
1492
- if (frame.depth < 64) switch (frame.node.element) {
1685
+ let depth = frame.depth;
1686
+ switch (current.element) {
1493
1687
  case "document":
1688
+ for (const child of current.children) if (child !== void 0) children.push(child);
1689
+ break;
1494
1690
  case "heading":
1495
1691
  case "paragraph":
1496
1692
  case "blockquote":
1497
- case "listItem":
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
+ }
1498
1704
  case "emphasis":
1499
1705
  case "link":
1500
- for (const child of frame.node.children) if (child !== void 0) children.push(child);
1706
+ for (const child of current.children) if (child !== void 0) children.push(child);
1707
+ depth += 1;
1501
1708
  break;
1502
1709
  case "list":
1503
- for (const child of frame.node.items) if (child !== void 0) children.push(child);
1710
+ for (const child of current.items) if (child !== void 0) children.push(child);
1711
+ depth += 1;
1504
1712
  break;
1505
1713
  case "table":
1506
- for (const cell of frame.node.header) if (cell !== void 0) {
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) {
1507
1719
  for (const child of cell) if (child !== void 0) children.push(child);
1508
1720
  }
1509
- for (const row of frame.node.rows) if (row !== void 0) {
1721
+ for (const row of current.rows) if (row !== void 0) {
1510
1722
  for (const cell of row) if (cell !== void 0) {
1511
1723
  for (const child of cell) if (child !== void 0) children.push(child);
1512
1724
  }
1513
1725
  }
1514
- break;
1726
+ depth += 4;
1727
+ }
1728
+ if (current.element === "codeBlock" && frame.depth + 2 > 64) {
1729
+ values.push(void 0);
1730
+ continue;
1515
1731
  }
1516
1732
  stack.push({
1517
1733
  ...frame,
@@ -1522,7 +1738,7 @@ function foldNode(node, handlers, depth) {
1522
1738
  const child = children[index];
1523
1739
  if (child !== void 0) stack.push({
1524
1740
  node: child,
1525
- depth: frame.depth + 1,
1741
+ depth,
1526
1742
  expanded: false,
1527
1743
  count: 0
1528
1744
  });
@@ -1530,105 +1746,291 @@ function foldNode(node, handlers, depth) {
1530
1746
  continue;
1531
1747
  }
1532
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);
1533
1751
  let value;
1534
- switch (frame.node.element) {
1752
+ switch (current.element) {
1535
1753
  case "document":
1536
- value = handlers.document(frame.node, children);
1754
+ value = {
1755
+ category: "document",
1756
+ children: projected
1757
+ };
1537
1758
  break;
1538
1759
  case "heading":
1539
- value = handlers.heading(frame.node, children);
1760
+ value = {
1761
+ category: "element",
1762
+ name: `h${current.level}`,
1763
+ attributes: [],
1764
+ children: projected
1765
+ };
1540
1766
  break;
1541
1767
  case "paragraph":
1542
- value = handlers.paragraph(frame.node, children);
1768
+ value = {
1769
+ category: "element",
1770
+ name: "p",
1771
+ attributes: [],
1772
+ children: projected
1773
+ };
1543
1774
  break;
1544
1775
  case "thematicBreak":
1545
- value = handlers.thematicBreak(frame.node, children);
1776
+ value = {
1777
+ category: "element",
1778
+ name: "hr",
1779
+ attributes: [],
1780
+ children: []
1781
+ };
1546
1782
  break;
1547
1783
  case "blockquote":
1548
- value = handlers.blockquote(frame.node, children);
1784
+ value = {
1785
+ category: "element",
1786
+ name: "blockquote",
1787
+ attributes: [],
1788
+ children: projected
1789
+ };
1549
1790
  break;
1550
1791
  case "codeBlock":
1551
- value = handlers.codeBlock(frame.node, children);
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
+ };
1552
1809
  break;
1553
1810
  case "list":
1554
- value = handlers.list(frame.node, children);
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
+ };
1555
1820
  break;
1556
1821
  case "listItem":
1557
- value = handlers.listItem(frame.node, children);
1822
+ value = {
1823
+ category: "element",
1824
+ name: "li",
1825
+ attributes: [],
1826
+ children: projected
1827
+ };
1558
1828
  break;
1559
- case "table":
1560
- value = handlers.table(frame.node, children);
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
+ };
1561
1903
  break;
1904
+ }
1562
1905
  case "text":
1563
- value = handlers.text(frame.node, children);
1906
+ value = {
1907
+ category: "text",
1908
+ value: current.value
1909
+ };
1564
1910
  break;
1565
1911
  case "emphasis":
1566
- value = handlers.emphasis(frame.node, children);
1912
+ value = {
1913
+ category: "element",
1914
+ name: current.strong ? "strong" : "em",
1915
+ attributes: [],
1916
+ children: projected
1917
+ };
1567
1918
  break;
1568
1919
  case "codeSpan":
1569
- value = handlers.codeSpan(frame.node, children);
1920
+ value = {
1921
+ category: "element",
1922
+ name: "code",
1923
+ attributes: [],
1924
+ children: [{
1925
+ category: "text",
1926
+ value: current.value
1927
+ }]
1928
+ };
1570
1929
  break;
1571
1930
  case "link":
1572
- value = handlers.link(frame.node, children);
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
+ };
1573
1962
  break;
1963
+ default: value = void 0;
1574
1964
  }
1575
- if (stack.length === 0) return value;
1576
1965
  values.push(value);
1577
1966
  }
1578
- switch (node.element) {
1579
- case "document": return handlers.document(node, []);
1580
- case "heading": return handlers.heading(node, []);
1581
- case "paragraph": return handlers.paragraph(node, []);
1582
- case "thematicBreak": return handlers.thematicBreak(node, []);
1583
- case "blockquote": return handlers.blockquote(node, []);
1584
- case "codeBlock": return handlers.codeBlock(node, []);
1585
- case "list": return handlers.list(node, []);
1586
- case "listItem": return handlers.listItem(node, []);
1587
- case "table": return handlers.table(node, []);
1588
- case "text": return handlers.text(node, []);
1589
- case "emphasis": return handlers.emphasis(node, []);
1590
- case "codeSpan": return handlers.codeSpan(node, []);
1591
- case "link": return handlers.link(node, []);
1592
- }
1967
+ const projected = values[0];
1968
+ if (projected?.category === "document") return projected;
1969
+ return {
1970
+ category: "document",
1971
+ children: projected === void 0 ? [] : [projected]
1972
+ };
1593
1973
  }
1594
1974
  /**
1595
- * Rewrite a {@link MarkdownDocument} bottom-up (copy-on-write) - each node's children
1596
- * are rewritten first (post-order), then `rewrite` is applied to the node itself; the
1597
- * document ROOT is never passed to `rewrite` (the `element: 'document'` invariant
1598
- * always holds). A table's inline cells and a list's items ARE rewritten.
1975
+ * Render a {@link MarkdownNode} to sanitized canonical HTML.
1599
1976
  *
1600
1977
  * @remarks
1601
- * Never mutates `document` - every level is rebuilt into a fresh object/array, even
1602
- * when `rewrite` returns its input unchanged. When `rewrite` returns a node whose
1603
- * `element` does not fit the slot it was called for (a block slot handed a
1604
- * non-{@link BlockNode}, an inline slot handed a non-{@link InlineNode}, a list-item
1605
- * slot handed a non-`listItem`), the ill-fitting result is discarded and the
1606
- * freshly-rebuilt (unrewritten-at-this-level) node is kept instead - `rewriteDocument`
1607
- * stays total and never produces a structurally invalid document.
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.
1608
1983
  *
1609
- * Descent is capped at {@link MAX_DEPTH}, the same cap {@link walkNodes} and
1610
- * {@link foldNode} observe: at `depth >= MAX_DEPTH` the subtree is passed through
1611
- * UNCHANGED (by reference, not rebuilt, and `rewrite` is not invoked on it) instead of
1612
- * recursing further, so a pathologically deep adopted document cannot exhaust the
1613
- * call stack. {@link MarkdownInterface.map} inherits this cap since it delegates here.
1984
+ * @param node - The markdown document or bare node to render
1985
+ * @returns Sanitized canonical HTML
1614
1986
  *
1615
- * @param document - The document AST to rewrite
1616
- * @param rewrite - The bottom-up {@link MarkdownRewriteHandler}
1617
- * @returns A new, rewritten {@link MarkdownDocument}
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
1618
2016
  *
1619
2017
  * @example
1620
2018
  * ```ts
1621
- * rewriteDocument(document, (node) =>
1622
- * node.element === 'text' ? { element: 'text', value: node.value.toUpperCase() } : node,
1623
- * )
2019
+ * renderMarkdown({ element: 'document', children: [
2020
+ * { element: 'heading', level: 2, children: [{ element: 'text', value: 'Hi' }] },
2021
+ * ] })
2022
+ * // '## Hi'
1624
2023
  * ```
1625
2024
  */
1626
- function rewriteDocument(document, rewrite) {
2025
+ function renderMarkdown(node) {
1627
2026
  const stack = [{
1628
- node: document,
1629
- depth: -1,
2027
+ node,
2028
+ depth: 0,
1630
2029
  expanded: false,
1631
- count: 0
2030
+ count: 0,
2031
+ escaped: "",
2032
+ escapeBang: false,
2033
+ nesting: 0
1632
2034
  }];
1633
2035
  const values = [];
1634
2036
  while (stack.length > 0) {
@@ -1636,887 +2038,1461 @@ function rewriteDocument(document, rewrite) {
1636
2038
  if (frame === void 0) continue;
1637
2039
  const current = frame.node;
1638
2040
  if (!frame.expanded) {
1639
- if (current.element !== "document" && frame.depth >= 64) {
1640
- values.push(current);
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);
1641
2081
  continue;
1642
2082
  }
1643
- const children = [];
2083
+ const groups = [];
2084
+ const adjacent = [];
2085
+ let depth = frame.depth + 1;
1644
2086
  switch (current.element) {
1645
2087
  case "document":
1646
- case "heading":
1647
- case "paragraph":
1648
2088
  case "blockquote":
1649
2089
  case "listItem":
2090
+ groups.push(current.children);
2091
+ adjacent.push(false);
2092
+ break;
2093
+ case "heading":
2094
+ case "paragraph":
1650
2095
  case "emphasis":
1651
2096
  case "link":
1652
- for (const child of current.children) if (child !== void 0) children.push(child);
2097
+ case "image":
2098
+ groups.push(current.children);
2099
+ adjacent.push(true);
1653
2100
  break;
1654
2101
  case "list":
1655
- for (const child of current.items) if (child !== void 0) children.push(child);
2102
+ groups.push(current.items);
2103
+ adjacent.push(false);
1656
2104
  break;
1657
2105
  case "table":
1658
2106
  for (const cell of current.header) if (cell !== void 0) {
1659
- for (const child of cell) if (child !== void 0) children.push(child);
2107
+ groups.push(cell);
2108
+ adjacent.push(true);
1660
2109
  }
1661
- for (const row of current.rows) if (row !== void 0) {
1662
- for (const cell of row) if (cell !== void 0) {
1663
- for (const child of cell) if (child !== void 0) children.push(child);
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
+ }
1664
2118
  }
1665
2119
  }
1666
- break;
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
+ }
1667
2143
  }
1668
2144
  stack.push({
1669
2145
  ...frame,
1670
2146
  expanded: true,
1671
- count: children.length
2147
+ count: children.length,
2148
+ escaped
1672
2149
  });
1673
- const depth = current.element === "document" ? 0 : frame.depth + 1;
2150
+ const nesting = current.element === "emphasis" ? frame.nesting + 1 : frame.nesting;
1674
2151
  for (let index = children.length - 1; index >= 0; index -= 1) {
1675
2152
  const child = children[index];
1676
2153
  if (child !== void 0) stack.push({
1677
2154
  node: child,
1678
2155
  depth,
1679
2156
  expanded: false,
1680
- count: 0
2157
+ count: 0,
2158
+ escaped: "",
2159
+ escapeBang: escapeBangs[index] === true && depth < 64,
2160
+ nesting
1681
2161
  });
1682
2162
  }
1683
2163
  continue;
1684
2164
  }
1685
2165
  const children = frame.count === 0 ? [] : values.splice(values.length - frame.count, frame.count);
1686
- let rebuilt = current;
2166
+ let value = "";
1687
2167
  switch (current.element) {
1688
- case "document": {
1689
- const blocks = [];
1690
- let offset = 0;
1691
- for (const block of current.children) {
1692
- if (block === void 0) continue;
1693
- const child = children[offset];
1694
- blocks.push(child !== void 0 && isBlockNode(child) ? child : block);
1695
- offset += 1;
1696
- }
1697
- const result = {
1698
- element: "document",
1699
- children: blocks
1700
- };
1701
- if (stack.length === 0) return result;
1702
- values.push(result);
1703
- continue;
1704
- }
1705
- case "heading":
1706
- case "paragraph": {
1707
- const inlines = [];
1708
- let offset = 0;
1709
- for (const inline of current.children) {
1710
- if (inline === void 0) continue;
1711
- const child = children[offset];
1712
- inlines.push(child !== void 0 && isInlineNode(child) ? child : inline);
1713
- offset += 1;
1714
- }
1715
- rebuilt = {
1716
- ...current,
1717
- children: inlines
1718
- };
1719
- break;
1720
- }
1721
- case "blockquote": {
1722
- const blocks = [];
1723
- let offset = 0;
1724
- for (const block of current.children) {
1725
- if (block === void 0) continue;
1726
- const child = children[offset];
1727
- blocks.push(child !== void 0 && isBlockNode(child) ? child : block);
1728
- offset += 1;
1729
- }
1730
- rebuilt = {
1731
- ...current,
1732
- children: blocks
1733
- };
1734
- break;
1735
- }
1736
- case "listItem": {
1737
- const blocks = [];
1738
- let offset = 0;
1739
- for (const block of current.children) {
1740
- if (block === void 0) continue;
1741
- const child = children[offset];
1742
- blocks.push(child !== void 0 && isBlockNode(child) ? child : block);
1743
- offset += 1;
1744
- }
1745
- rebuilt = {
1746
- element: "listItem",
1747
- children: blocks
1748
- };
1749
- break;
1750
- }
1751
- case "emphasis":
1752
- case "link": {
1753
- const inlines = [];
1754
- let offset = 0;
1755
- for (const inline of current.children) {
1756
- if (inline === void 0) continue;
1757
- const child = children[offset];
1758
- inlines.push(child !== void 0 && isInlineNode(child) ? child : inline);
1759
- offset += 1;
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;
1760
2181
  }
1761
- rebuilt = {
1762
- ...current,
1763
- children: inlines
1764
- };
2182
+ const pad = current.value.startsWith("`") || current.value.endsWith("`") ? " " : "";
2183
+ value = `${fence}${pad}${current.value}${pad}${fence}`;
1765
2184
  break;
1766
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;
1767
2208
  case "list": {
1768
2209
  const items = [];
1769
- let offset = 0;
1770
- for (const item of current.items) {
1771
- if (item === void 0) continue;
1772
- const child = children[offset];
1773
- items.push(child?.element === "listItem" ? child : item);
1774
- offset += 1;
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"));
1775
2220
  }
1776
- rebuilt = {
1777
- ...current,
1778
- items
1779
- };
2221
+ value = items.join("\n");
1780
2222
  break;
1781
2223
  }
2224
+ case "listItem":
2225
+ value = children.join("\n\n");
2226
+ break;
1782
2227
  case "table": {
1783
2228
  let offset = 0;
1784
2229
  const header = [];
1785
2230
  for (const cell of current.header) {
1786
- if (cell === void 0) continue;
1787
- const inlines = [];
1788
- for (const inline of cell) {
1789
- if (inline === void 0) continue;
1790
- const child = children[offset];
1791
- inlines.push(child !== void 0 && isInlineNode(child) ? child : inline);
1792
- offset += 1;
2231
+ if (cell === void 0) {
2232
+ header.push("");
2233
+ continue;
1793
2234
  }
1794
- header.push(inlines);
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;
1795
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
+ });
1796
2247
  const rows = [];
1797
2248
  for (const row of current.rows) {
1798
- if (row === void 0) continue;
1799
2249
  const cells = [];
1800
- for (const cell of row) {
1801
- if (cell === void 0) continue;
1802
- const inlines = [];
1803
- for (const inline of cell) {
1804
- if (inline === void 0) continue;
1805
- const child = children[offset];
1806
- inlines.push(child !== void 0 && isInlineNode(child) ? child : inline);
1807
- offset += 1;
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;
1808
2255
  }
1809
- cells.push(inlines);
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;
1810
2260
  }
1811
- rows.push(cells);
2261
+ rows.push(`| ${cells.join(" | ")} |`);
1812
2262
  }
1813
- rebuilt = {
1814
- ...current,
1815
- header,
1816
- rows
1817
- };
2263
+ value = [
2264
+ `| ${header.join(" | ")} |`,
2265
+ `| ${delimiter.join(" | ")} |`,
2266
+ ...rows
2267
+ ].join("\n");
1818
2268
  break;
1819
2269
  }
1820
- }
1821
- const result = rewrite(rebuilt);
1822
- let accepted = rebuilt;
1823
- switch (current.element) {
1824
2270
  case "text":
1825
- case "emphasis":
1826
- case "codeSpan":
1827
- case "link":
1828
- if (isInlineNode(result)) accepted = result;
2271
+ value = frame.escaped;
1829
2272
  break;
1830
- case "heading":
1831
- case "paragraph":
1832
- case "list":
1833
- case "table":
1834
- case "codeBlock":
1835
- case "blockquote":
1836
- case "thematicBreak":
1837
- if (isBlockNode(result)) accepted = result;
2273
+ case "emphasis": {
2274
+ const marker = frame.nesting % 2 === 0 ? current.strong ? "**" : "*" : current.strong ? "__" : "_";
2275
+ value = `${marker}${children.join("")}${marker}`;
1838
2276
  break;
1839
- case "listItem":
1840
- if (result.element === "listItem") accepted = result;
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})`;
1841
2282
  break;
2283
+ }
2284
+ default: value = "";
1842
2285
  }
1843
- values.push(accepted);
2286
+ if (stack.length === 0) return value;
2287
+ values.push(value);
1844
2288
  }
1845
- return {
1846
- element: "document",
1847
- children: [...document.children]
1848
- };
2289
+ return "";
1849
2290
  }
1850
2291
  /**
1851
- * Concatenate the `value` / `code` content of every descendant text / code-span /
1852
- * code-block node under `node`, in walk order - the plain-text projection of an AST
1853
- * (search indexing, word counts, a text-only preview).
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.
1854
2295
  *
1855
2296
  * @remarks
1856
- * Total: never throws. Descent stops at {@link MAX_DEPTH} (contributes `''` past the
1857
- * cap instead of recursing further).
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.
1858
2300
  *
1859
- * @param node - The AST node to flatten (a full document, or any sub-node)
1860
- * @returns The concatenated text content
2301
+ * @param nodes - The inline run to trim
2302
+ * @returns The run with its edge whitespace removed
1861
2303
  *
1862
2304
  * @example
1863
2305
  * ```ts
1864
- * flattenText({ element: 'paragraph', children: [
1865
- * { element: 'text', value: 'a ' },
1866
- * { element: 'codeSpan', value: 'b' },
1867
- * ] })
1868
- * // 'a b'
2306
+ * trimInlines([{ element: 'text', value: ' a ' }]) // [{ element: 'text', value: 'a' }]
1869
2307
  * ```
1870
2308
  */
1871
- function flattenText(node) {
1872
- const stack = [{
1873
- node,
1874
- depth: 0
1875
- }];
1876
- let value = "";
1877
- while (stack.length > 0) {
1878
- const frame = stack.pop();
1879
- if (frame === void 0 || frame.depth >= 64) continue;
1880
- const children = [];
1881
- switch (frame.node.element) {
1882
- case "text":
1883
- case "codeSpan":
1884
- value += frame.node.value;
1885
- break;
1886
- case "codeBlock":
1887
- value += frame.node.code;
1888
- break;
1889
- case "document":
1890
- case "heading":
1891
- case "paragraph":
1892
- case "blockquote":
1893
- case "listItem":
1894
- case "emphasis":
1895
- case "link":
1896
- for (const child of frame.node.children) if (child !== void 0) children.push(child);
1897
- break;
1898
- case "list":
1899
- for (const child of frame.node.items) if (child !== void 0) children.push(child);
1900
- break;
1901
- case "table":
1902
- for (const cell of frame.node.header) if (cell !== void 0) {
1903
- for (const child of cell) if (child !== void 0) children.push(child);
1904
- }
1905
- for (const row of frame.node.rows) if (row !== void 0) {
1906
- for (const cell of row) if (cell !== void 0) {
1907
- for (const child of cell) if (child !== void 0) children.push(child);
1908
- }
1909
- }
1910
- break;
1911
- }
1912
- for (let index = children.length - 1; index >= 0; index -= 1) {
1913
- const child = children[index];
1914
- if (child !== void 0) stack.push({
1915
- node: child,
1916
- depth: frame.depth + 1
1917
- });
1918
- }
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
+ };
1919
2320
  }
1920
- return value;
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
+ };
2329
+ }
2330
+ return out;
1921
2331
  }
1922
- //#endregion
1923
- //#region src/core/parsers.ts
1924
2332
  /**
1925
- * Parses a run of markdown lines into a block AST, recursing into nested
1926
- * blockquotes, list items, and depth-capped degrade paragraphs.
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.
1927
2336
  *
1928
- * @param lines - The markdown lines to parse.
1929
- * @param depth - The current recursion depth (blockquotes/lists increment it).
1930
- * @returns The parsed block nodes.
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
1931
2349
  *
1932
2350
  * @example
1933
2351
  * ```ts
1934
- * parseBlocks(['# Hi'], 0) // [{ element: 'heading', level: 1, children: [...] }]
2352
+ * normalizeInlines([{ element: 'break' }, { element: 'text', value: 'a' }], true)
2353
+ * // [{ element: 'text', value: 'a' }] - a leading break has no line to end
1935
2354
  * ```
1936
2355
  */
1937
- function parseBlocks(lines, depth) {
1938
- if (depth >= 64) return lines.length > 0 ? [{
1939
- element: "paragraph",
1940
- children: [{
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({
1941
2361
  element: "text",
1942
- value: lines.join("\n")
1943
- }]
1944
- }] : [];
1945
- const blocks = [];
1946
- let index = 0;
1947
- while (index < lines.length) {
1948
- const line = lines[index] ?? "";
1949
- if (isBlankLine(line)) {
1950
- index += 1;
1951
- continue;
1952
- }
1953
- const fence = extractFence(line);
1954
- if (fence) {
1955
- const body = [];
1956
- index += 1;
1957
- while (index < lines.length && !isFenceClose(lines[index] ?? "", fence.marker)) {
1958
- body.push(lines[index] ?? "");
1959
- index += 1;
1960
- }
1961
- index += 1;
1962
- blocks.push({
1963
- element: "codeBlock",
1964
- ...fence.lang === void 0 ? {} : { lang: fence.lang },
1965
- code: body.join("\n")
2362
+ value: " "
2363
+ });
2364
+ else spent.push(node);
2365
+ }
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
1966
2375
  });
1967
2376
  continue;
1968
2377
  }
1969
- if (isThematicBreak(line)) {
1970
- blocks.push({ element: "thematicBreak" });
1971
- index += 1;
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);
1972
2390
  continue;
1973
2391
  }
1974
- const heading = extractHeading(line);
1975
- if (heading) {
1976
- blocks.push({
1977
- element: "heading",
1978
- level: heading.level,
1979
- children: parseInline(heading.text)
2392
+ out.push(node);
2393
+ }
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
1980
2442
  });
1981
- index += 1;
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);
1982
2450
  continue;
1983
2451
  }
1984
- if (isQuote(line)) {
1985
- const quoted = [];
1986
- while (index < lines.length && isQuote(lines[index] ?? "")) {
1987
- quoted.push(stripQuote(lines[index] ?? ""));
1988
- index += 1;
2452
+ if (isNonEmptyArray(child.rows)) {
2453
+ if (isNonEmptyArray(cells)) {
2454
+ rows.push([...cells]);
2455
+ cells.length = 0;
1989
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);
2461
+ }
2462
+ if (isNonEmptyArray(rows) && isNonEmptyArray(cells)) {
2463
+ rows.push([...cells]);
2464
+ cells.length = 0;
2465
+ }
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;
1990
2510
  blocks.push({
1991
- element: "blockquote",
1992
- children: parseBlocks(quoted, depth + 1)
2511
+ element: "paragraph",
2512
+ children: cell.inlines
1993
2513
  });
1994
- continue;
1995
- }
1996
- if (isTableStart(line, lines[index + 1])) {
1997
- const table = collectTable(lines, index);
1998
- blocks.push(table.node);
1999
- index = table.next;
2000
- continue;
2001
- }
2002
- if (extractListItem(line)) {
2003
- const list = collectList(lines, index, depth);
2004
- blocks.push(list.node);
2005
- index = list.next;
2006
- continue;
2007
- }
2008
- const paragraph = [];
2009
- while (index < lines.length && !isBlankLine(lines[index] ?? "") && !(isNonEmptyArray(paragraph) && startsBlock(lines, index))) {
2010
- paragraph.push((lines[index] ?? "").trim());
2011
- index += 1;
2012
2514
  }
2515
+ }
2516
+ for (const cell of projection.cells) {
2517
+ if (cell === void 0 || !isNonEmptyArray(cell.inlines)) continue;
2013
2518
  blocks.push({
2014
2519
  element: "paragraph",
2015
- children: parseInline(paragraph.join("\n"))
2520
+ children: cell.inlines
2016
2521
  });
2017
2522
  }
2523
+ const paragraph = trimInlines(normalizeInlines(projection.inlines, true));
2524
+ if (isNonEmptyArray(paragraph)) blocks.push({
2525
+ element: "paragraph",
2526
+ children: paragraph
2527
+ });
2018
2528
  return blocks;
2019
2529
  }
2020
2530
  /**
2021
- * Collects a GFM table starting at a header row, parsing the header, the
2022
- * alignment row, and every contiguous body row that follows.
2531
+ * Read a projection as INLINE content - the view a link, an emphasis, and a table cell
2532
+ * each need.
2023
2533
  *
2024
- * @param lines - The markdown lines to scan.
2025
- * @param start - The index of the header row.
2026
- * @returns The parsed table node and the index of the first line after it.
2534
+ * @remarks
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.
2539
+ *
2540
+ * @param projection - The projection to read
2541
+ * @returns Its inline content
2027
2542
  *
2028
2543
  * @example
2029
2544
  * ```ts
2030
- * collectTable(['| a |', '| - |'], 0) // { node: { element: 'table', ... }, next: 2 }
2545
+ * projectionToInlines(createProjection({ inlines: [{ element: 'break' }] }))
2546
+ * // [{ element: 'break' }]
2031
2547
  * ```
2032
2548
  */
2033
- function collectTable(lines, start) {
2034
- const headerCells = splitTableRow(lines[start] ?? "");
2035
- const columns = headerCells.length;
2036
- const header = headerCells.map((cell) => parseInline(cell.trim()));
2037
- const align = tableAlignments(lines[start + 1] ?? "");
2038
- const padded = [];
2039
- for (let column = 0; column < columns; column += 1) padded.push(align[column] ?? "none");
2040
- const rows = [];
2041
- let index = start + 2;
2042
- while (index < lines.length && !isBlankLine(lines[index] ?? "") && (lines[index] ?? "").includes("|")) {
2043
- const cells = splitTableRow(lines[index] ?? "");
2044
- const row = [];
2045
- for (let column = 0; column < columns; column += 1) row.push(parseInline((cells[column] ?? "").trim()));
2046
- rows.push(row);
2047
- index += 1;
2048
- }
2049
- return {
2050
- node: {
2051
- element: "table",
2052
- header,
2053
- rows,
2054
- align: padded
2055
- },
2056
- next: index
2057
- };
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
+ }];
2058
2556
  }
2059
2557
  /**
2060
- * Collects a list starting at the first item, gathering sibling items at the
2061
- * same indent/ordering and recursing into each item's own block content.
2558
+ * Project one HTML leaf - a text node, a comment, or a doctype - to its
2559
+ * {@link MarkdownProjection}.
2062
2560
  *
2063
- * @param lines - The markdown lines to scan.
2064
- * @param start - The index of the first list item.
2065
- * @param depth - The current recursion depth (each item recurses at `depth + 1`).
2066
- * @returns The parsed list node and the index of the first line after it.
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
2067
2569
  *
2068
2570
  * @example
2069
2571
  * ```ts
2070
- * collectList(['- item'], 0, 0) // { node: { element: 'list', ... }, next: 1 }
2572
+ * projectHTMLLeaf({ category: 'text', value: 'a\n b' }).inlines
2573
+ * // [{ element: 'text', value: 'a b' }]
2071
2574
  * ```
2072
2575
  */
2073
- function collectList(lines, start, depth) {
2074
- const first = extractListItem(lines[start] ?? "");
2075
- const ordered = first?.ordered ?? false;
2076
- const startOrdinal = first?.start ?? 1;
2077
- const topIndent = first?.indent ?? 0;
2078
- const items = [];
2079
- const chain = [];
2080
- let nested = true;
2081
- for (let cursor = start; cursor < lines.length; cursor += 1) {
2082
- const parsed = extractListItem(lines[cursor] ?? "");
2083
- const previous = chain[chain.length - 1];
2084
- if (parsed === void 0 || previous !== void 0 && (previous.content.length > 0 || parsed.indent !== previous.marker)) {
2085
- nested = false;
2086
- break;
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
+ });
2087
2683
  }
2088
- chain.push(parsed);
2089
- }
2090
- const remaining = 64 - depth;
2091
- if (nested && remaining > 0 && chain.length > remaining) {
2092
- const terminal = chain[remaining - 1];
2093
- if (terminal !== void 0) {
2094
- const source = [terminal.content];
2095
- for (let cursor = start + remaining; cursor < lines.length; cursor += 1) source.push((lines[cursor] ?? "").slice(terminal.marker));
2096
- let children = [{
2097
- element: "paragraph",
2098
- children: [{
2099
- element: "text",
2100
- value: source.join("\n")
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))
2101
2759
  }]
2102
- }];
2103
- let node;
2104
- for (let cursor = remaining - 1; cursor >= 0; cursor -= 1) {
2105
- const parsed = chain[cursor];
2106
- if (parsed === void 0) continue;
2107
- node = {
2108
- element: "list",
2109
- ordered: parsed.ordered,
2110
- start: parsed.start,
2111
- items: [{
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({
2112
2783
  element: "listItem",
2113
- children
2114
- }]
2115
- };
2116
- children = [node];
2784
+ children: blocks
2785
+ });
2786
+ continue;
2787
+ }
2788
+ if (isNonEmptyArray(blocks)) items.push({
2789
+ element: "listItem",
2790
+ children: blocks
2791
+ });
2117
2792
  }
2118
- if (node !== void 0) return {
2119
- node,
2120
- next: lines.length
2121
- };
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
+ });
2122
2805
  }
2123
- }
2124
- let index = start;
2125
- while (index < lines.length) {
2126
- const parsed = extractListItem(lines[index] ?? "");
2127
- if (!parsed || parsed.indent > topIndent || parsed.ordered !== ordered) break;
2128
- const itemLines = [parsed.content];
2129
- const continuation = parsed.marker;
2130
- index += 1;
2131
- while (index < lines.length) {
2132
- const next = lines[index] ?? "";
2133
- if (isBlankLine(next)) {
2134
- const after = lines[index + 1] ?? "";
2135
- if (index + 1 < lines.length && !isBlankLine(after) && leadingIndent(after) >= continuation) {
2136
- itemLines.push("");
2137
- index += 1;
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);
2138
2842
  continue;
2139
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;
2140
2854
  break;
2141
2855
  }
2142
- if (leadingIndent(next) >= continuation) {
2143
- itemLines.push(next.slice(continuation));
2144
- index += 1;
2145
- continue;
2856
+ if (position === void 0) for (const [index, structural] of rowed.entries()) {
2857
+ if (!structural) continue;
2858
+ position = index;
2859
+ break;
2146
2860
  }
2147
- if (extractListItem(next) || startsBlock(lines, index)) break;
2148
- itemLines.push(next.trim());
2149
- index += 1;
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
+ });
2150
2890
  }
2151
- items.push({
2152
- element: "listItem",
2153
- children: parseBlocks(itemLines, depth + 1)
2154
- });
2155
2891
  }
2156
- return {
2157
- node: {
2158
- element: "list",
2159
- ordered,
2160
- start: startOrdinal,
2161
- items
2162
- },
2163
- next: index
2164
- };
2165
- }
2166
- /**
2167
- * Parses a markdown string into a typed {@link MarkdownDocument} AST via the
2168
- * block phase.
2169
- *
2170
- * @param markdown - The markdown source to parse.
2171
- * @returns The parsed document.
2172
- */
2173
- function parseDocument(markdown) {
2174
- return {
2175
- element: "document",
2176
- children: parseBlocks(splitLines(markdown), 0)
2177
- };
2892
+ return merged;
2178
2893
  }
2179
2894
  /**
2180
- * Parses inline markdown text (emphasis, code spans, links) into inline AST
2181
- * nodes, coalescing adjacent text runs.
2895
+ * Project an `@orkestrel/html` {@link HTMLNode} into a {@link MarkdownDocument} - the
2896
+ * HTML→markdown direction, and the inverse of {@link markdownToHTML}.
2182
2897
  *
2183
- * @param text - The inline markdown text to parse.
2184
- * @returns The parsed inline nodes.
2185
- */
2186
- function parseInline(text) {
2187
- return coalesceText(scanInline(text, 0, text.length));
2188
- }
2189
- //#endregion
2190
- //#region src/core/shapers.ts
2191
- /**
2192
- * The shape of a {@link TextNode} - a plain-text leaf inline run.
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
2193
2929
  *
2194
2930
  * @example
2195
2931
  * ```ts
2196
- * import { createContract } from '@orkestrel/contract'
2197
- * import { textShape } from '@src/core'
2932
+ * import { parseDocument } from '@orkestrel/html'
2198
2933
  *
2199
- * const text = createContract(textShape)
2200
- * text.is({ element: 'text', value: 'hi' }) // true
2934
+ * htmlToMarkdown(parseDocument('<h1>Title</h1>'))
2935
+ * // { element: 'document', children: [{ element: 'heading', level: 1, children: [...] }] }
2201
2936
  * ```
2202
2937
  */
2203
- var textShape = objectShape({
2204
- element: literalShape(["text"]),
2205
- value: stringShape()
2206
- });
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
+ };
2949
+ }
2207
2950
  /**
2208
- * The shape of a {@link CodeSpanNode} - an inline code span (`` `code` ``).
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.
2209
2954
  *
2210
- * @example
2211
- * ```ts
2212
- * import { createContract } from '@orkestrel/contract'
2213
- * import { codeSpanShape } from '@src/core'
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.
2214
2959
  *
2215
- * const codeSpan = createContract(codeSpanShape)
2216
- * codeSpan.is({ element: 'codeSpan', value: 'const x = 1' }) // true
2217
- * ```
2218
- */
2219
- var codeSpanShape = objectShape({
2220
- element: literalShape(["codeSpan"]),
2221
- value: stringShape()
2222
- });
2223
- /**
2224
- * The shape of a {@link CodeBlockNode} - a fenced code block. `lang` is
2225
- * optional (absent when the opening fence carries no info-string).
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
2226
2962
  *
2227
2963
  * @example
2228
2964
  * ```ts
2229
- * import { createContract } from '@orkestrel/contract'
2230
- * import { codeBlockShape } from '@src/core'
2231
- *
2232
- * const codeBlock = createContract(codeBlockShape)
2233
- * codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
2234
- * codeBlock.is({ element: 'codeBlock', code: 'x', lang: 'ts' }) // true
2965
+ * const doc = { element: 'document', children: [{ element: 'thematicBreak' }] } as const
2966
+ * [...walkNodes(doc)].map((node) => node.element) // ['document', 'thematicBreak']
2235
2967
  * ```
2236
2968
  */
2237
- var codeBlockShape = objectShape({
2238
- element: literalShape(["codeBlock"]),
2239
- lang: optionalShape(stringShape()),
2240
- code: stringShape()
2241
- });
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
+ }
3012
+ }
2242
3013
  /**
2243
- * The shape of a {@link ThematicBreakNode} - a horizontal rule. Carries no
2244
- * fields beyond its `element` discriminant.
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.
2245
3017
  *
2246
- * @example
2247
- * ```ts
2248
- * import { createContract } from '@orkestrel/contract'
2249
- * import { thematicBreakShape } from '@src/core'
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.
2250
3026
  *
2251
- * const thematicBreak = createContract(thematicBreakShape)
2252
- * thematicBreak.is({ element: 'thematicBreak' }) // true
2253
- * ```
2254
- */
2255
- var thematicBreakShape = objectShape({ element: literalShape(["thematicBreak"]) });
2256
- /**
2257
- * The shape of a {@link TableAlign} - the per-column GFM table alignment
2258
- * literal.
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`
2259
3034
  *
2260
3035
  * @example
2261
3036
  * ```ts
2262
- * import { createContract } from '@orkestrel/contract'
2263
- * import { tableAlignShape } from '@src/core'
2264
- *
2265
- * const tableAlign = createContract(tableAlignShape)
2266
- * tableAlign.is('left') // true
2267
- * tableAlign.is('center') // true
2268
- * tableAlign.is('top') // false
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
2269
3042
  * ```
2270
3043
  */
2271
- var tableAlignShape = literalShape([
2272
- "none",
2273
- "left",
2274
- "right",
2275
- "center"
2276
- ]);
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
+ }
3164
+ }
2277
3165
  /**
2278
- * The shape of {@link ListItemParts} - the parsed parts of a single list-item
2279
- * line the block phase's list detector returns. Fully non-recursive (no
2280
- * nested node fields), so every field shapes directly.
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.
2281
3170
  *
2282
- * @example
2283
- * ```ts
2284
- * import { createContract } from '@orkestrel/contract'
2285
- * import { listItemPartsShape } from '@src/core'
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.
2286
3179
  *
2287
- * const listItemParts = createContract(listItemPartsShape)
2288
- * listItemParts.is({ ordered: false, start: 1, content: 'hi', indent: 0, marker: 2 }) // true
2289
- * ```
2290
- */
2291
- var listItemPartsShape = objectShape({
2292
- ordered: booleanShape(),
2293
- start: integerShape(),
2294
- content: stringShape(),
2295
- indent: integerShape(),
2296
- marker: integerShape()
2297
- });
2298
- //#endregion
2299
- //#region src/core/Markdown.ts
2300
- /**
2301
- * A stateful, parsed markdown document - wraps a typed {@link MarkdownDocument} AST
2302
- * with the query (`find` / `filter` / `reduce` / iteration), rewrite (`map`), fold, and
2303
- * streaming operations {@link MarkdownInterface} declares.
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.
2304
3185
  *
2305
- * @remarks
2306
- * - **Construction.** Given a `string`, the constructor runs {@link parseDocument} (the
2307
- * block phase then the inline phase) to build the AST. Given a {@link MarkdownDocument},
2308
- * the document is adopted AS-IS and is NOT re-validated - a caller adopting an
2309
- * untrusted value should gate it with `isMarkdownDocument` first.
2310
- * - **Immutable.** {@link map} never mutates the stored AST - it returns a NEW `Markdown`
2311
- * instance; the document root invariant (`element: 'document'`) always holds.
2312
- * - **Traversal order.** {@link walk} and the `find` / `filter` / `reduce` queries built
2313
- * on it walk the AST depth-first, pre-order, root-inclusive (via {@link walkNodes});
2314
- * `stream` is shallow - only the document's direct block children.
3186
+ * @param document - The document AST to rewrite
3187
+ * @param rewrite - The bottom-up {@link MarkdownRewriteHandler}
3188
+ * @returns A new, rewritten {@link MarkdownDocument}
2315
3189
  *
2316
3190
  * @example
2317
3191
  * ```ts
2318
- * import { Markdown, isHeadingNode, renderMarkdown } from '@src/core'
2319
- *
2320
- * const markdown = new Markdown('# Title\n\nA **bold** [link](https://x.dev).')
2321
- * const heading = markdown.find(isHeadingNode) // the HeadingNode, or undefined
2322
- * const shouted = markdown.map((node) =>
3192
+ * rewriteDocument(document, (node) =>
2323
3193
  * node.element === 'text' ? { element: 'text', value: node.value.toUpperCase() } : node,
2324
3194
  * )
2325
- * renderMarkdown(shouted.document) // '# TITLE\n\nA **BOLD** [LINK](https://x.dev).'
2326
3195
  * ```
2327
3196
  */
2328
- var Markdown = class Markdown {
2329
- #document;
2330
- constructor(input) {
2331
- this.#document = typeof input === "string" ? parseDocument(input) : input;
2332
- }
2333
- /** The stored {@link MarkdownDocument} AST root. */
2334
- get document() {
2335
- return this.#document;
2336
- }
2337
- /**
2338
- * THE deep traversal - a lazy, depth-first, pre-order, root-inclusive generator
2339
- * over every {@link MarkdownNode} in the document. `find` / `filter` / `reduce`
2340
- * all iterate this single traversal.
2341
- *
2342
- * @example
2343
- * ```ts
2344
- * for (const node of markdown.walk()) {
2345
- * // every node, depth-first, pre-order, root-inclusive
2346
- * }
2347
- *
2348
- * // also consumable by for-await - JS accepts a sync iterable in for-await
2349
- * for await (const node of markdown.walk()) {
2350
- * // same sequence, no separate async iterator needed
2351
- * }
2352
- * ```
2353
- */
2354
- *walk() {
2355
- yield* walkNodes(this.#document);
2356
- }
2357
- find(predicate) {
2358
- for (const node of this.walk()) if (predicate(node)) return node;
2359
- }
2360
- filter(predicate) {
2361
- const out = [];
2362
- for (const node of this.walk()) if (predicate(node)) out.push(node);
2363
- return out;
2364
- }
2365
- /** Rewrites the AST bottom-up (copy-on-write) and returns a new {@link Markdown}. */
2366
- map(rewrite) {
2367
- return new Markdown(rewriteDocument(this.#document, rewrite));
2368
- }
2369
- /** Folds the AST depth-first, pre-order into an accumulator. */
2370
- reduce(callback, initial) {
2371
- let accumulator = initial;
2372
- for (const node of this.walk()) accumulator = callback(accumulator, node);
2373
- return accumulator;
2374
- }
2375
- /** Runs a total catamorphism over the document using a {@link MarkdownHandlers} table. */
2376
- fold(handlers) {
2377
- return foldNode(this.#document, handlers, 0);
2378
- }
2379
- /**
2380
- * A web-standard {@link ReadableStream} over the document's top-level block nodes
2381
- * (shallow, source order) - a fresh, pull-based source per call: one block is
2382
- * enqueued per `pull`, so a slow reader's backpressure is respected. Cancellable,
2383
- * async-iterable wherever the platform supports it (Node, Deno), and pipeable
2384
- * through any {@link TransformStream} / {@link WritableStream}.
2385
- *
2386
- * @example
2387
- * ```ts
2388
- * // universal - works in every ReadableStream-supporting environment
2389
- * const reader = markdown.stream().getReader()
2390
- * for (let result = await reader.read(); !result.done; result = await reader.read()) {
2391
- * console.log(result.value) // one BlockNode
2392
- * }
2393
- *
2394
- * // Node / Deno / Firefox support async iteration of ReadableStream natively;
2395
- * // other environments should use the reader loop above instead.
2396
- * for await (const block of markdown.stream()) {
2397
- * console.log(block)
2398
- * }
2399
- * ```
2400
- */
2401
- stream() {
2402
- const blocks = this.#document.children;
2403
- let index = 0;
2404
- return new ReadableStream({ pull(controller) {
2405
- if (index < blocks.length) {
2406
- const block = blocks[index];
2407
- if (block === void 0) {
2408
- controller.close();
2409
- return;
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;
2410
3315
  }
2411
- controller.enqueue(block);
2412
- index += 1;
2413
- } else controller.close();
2414
- } });
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);
2415
3416
  }
2416
- };
2417
- //#endregion
2418
- //#region src/core/factories.ts
2419
- /**
2420
- * Create a stateful markdown handle from a markdown string or an already-parsed
2421
- * {@link MarkdownDocument} - a typed AST plus the query, rewrite, and fold operations
2422
- * {@link MarkdownInterface} exposes.
2423
- *
2424
- * @remarks
2425
- * Given a `string`, runs a block phase (headings / paragraphs / lists / GFM tables /
2426
- * fenced code / blockquotes / thematic breaks) then an inline phase (emphasis /
2427
- * inline code / links) to build a render-agnostic {@link MarkdownDocument}. Given a
2428
- * {@link MarkdownDocument}, adopts it AS-IS without re-validation - gate an untrusted
2429
- * value with `isMarkdownDocument` first. Pure + total parse (malformed markdown
2430
- * degrades to text, never throws) and zero-dependency - a hand-written scanner, no
2431
- * regex-only structural parse, linear-time (no ReDoS).
2432
- *
2433
- * @param input - A markdown string to parse, or an already-parsed {@link MarkdownDocument}
2434
- * @returns A working {@link MarkdownInterface}
2435
- *
2436
- * @example
2437
- * ```ts
2438
- * import { createMarkdown } from '@src/core'
2439
- *
2440
- * const markdown = createMarkdown('# Hi\n\nRead the [guide](./guide.md).')
2441
- * markdown.document.children[0] // { element: 'heading', ... }
2442
- * ```
2443
- */
2444
- function createMarkdown(input) {
2445
- return new Markdown(input);
2446
- }
2447
- /**
2448
- * Compile the {@link textShape} into a {@link ContractInterface} for
2449
- * {@link TextNode} - a guard, coercing parser, JSON Schema, and seeded
2450
- * generator from one shape declaration (AGENTS §14).
2451
- *
2452
- * @returns A `TextNode` contract bundling `schema` / `is` / `parse` / `generate`
2453
- *
2454
- * @example
2455
- * ```ts
2456
- * import { createTextContract } from '@src/core'
2457
- *
2458
- * const text = createTextContract()
2459
- * text.is({ element: 'text', value: 'hi' }) // true
2460
- * ```
2461
- */
2462
- function createTextContract() {
2463
- return createContract(textShape);
2464
- }
2465
- /**
2466
- * Compile the {@link codeSpanShape} into a {@link ContractInterface} for
2467
- * {@link CodeSpanNode} - a guard, coercing parser, JSON Schema, and seeded
2468
- * generator from one shape declaration (AGENTS §14).
2469
- *
2470
- * @returns A `CodeSpanNode` contract bundling `schema` / `is` / `parse` / `generate`
2471
- *
2472
- * @example
2473
- * ```ts
2474
- * import { createCodeSpanContract } from '@src/core'
2475
- *
2476
- * const codeSpan = createCodeSpanContract()
2477
- * codeSpan.is({ element: 'codeSpan', value: 'const x = 1' }) // true
2478
- * ```
2479
- */
2480
- function createCodeSpanContract() {
2481
- return createContract(codeSpanShape);
3417
+ return {
3418
+ element: "document",
3419
+ children: [...document.children]
3420
+ };
2482
3421
  }
2483
3422
  /**
2484
- * Compile the {@link codeBlockShape} into a {@link ContractInterface} for
2485
- * {@link CodeBlockNode} - a guard, coercing parser, JSON Schema, and seeded
2486
- * generator from one shape declaration (AGENTS §14).
2487
- *
2488
- * @returns A `CodeBlockNode` contract bundling `schema` / `is` / `parse` / `generate`
2489
- *
2490
- * @example
2491
- * ```ts
2492
- * import { createCodeBlockContract } from '@src/core'
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).
2493
3427
  *
2494
- * const codeBlock = createCodeBlockContract()
2495
- * codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
2496
- * ```
2497
- */
2498
- function createCodeBlockContract() {
2499
- return createContract(codeBlockShape);
2500
- }
2501
- /**
2502
- * Compile the {@link thematicBreakShape} into a {@link ContractInterface} for
2503
- * {@link ThematicBreakNode} - a guard, coercing parser, JSON Schema, and
2504
- * seeded generator from one shape declaration (AGENTS §14).
3428
+ * @remarks
3429
+ * Total: never throws. Descent stops at {@link MAX_DEPTH} (contributes `''` past the
3430
+ * cap instead of recursing further).
2505
3431
  *
2506
- * @returns A `ThematicBreakNode` contract bundling `schema` / `is` / `parse` / `generate`
3432
+ * @param node - The AST node to flatten (a full document, or any sub-node)
3433
+ * @returns The concatenated text content
2507
3434
  *
2508
3435
  * @example
2509
3436
  * ```ts
2510
- * import { createThematicBreakContract } from '@src/core'
2511
- *
2512
- * const thematicBreak = createThematicBreakContract()
2513
- * thematicBreak.is({ element: 'thematicBreak' }) // true
3437
+ * flattenText({ element: 'paragraph', children: [
3438
+ * { element: 'text', value: 'a ' },
3439
+ * { element: 'codeSpan', value: 'b' },
3440
+ * ] })
3441
+ * // 'a b'
2514
3442
  * ```
2515
3443
  */
2516
- function createThematicBreakContract() {
2517
- 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;
2518
3494
  }
2519
3495
  //#endregion
2520
- 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 };
2521
3497
 
2522
3498
  //# sourceMappingURL=index.js.map