@orkestrel/markdown 0.0.6 → 0.0.8

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