@orkestrel/markdown 0.0.5 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -64
- package/dist/src/core/index.cjs +2835 -1316
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +1822 -8
- package/dist/src/core/index.d.ts +1822 -8
- package/dist/src/core/index.js +2819 -1312
- package/dist/src/core/index.js.map +1 -1
- package/package.json +20 -15
- package/dist/src/core/Markdown.d.ts +0 -86
- package/dist/src/core/constants.d.ts +0 -17
- package/dist/src/core/factories.d.ts +0 -92
- package/dist/src/core/helpers.d.ts +0 -451
- package/dist/src/core/parsers.d.ts +0 -66
- package/dist/src/core/shapers.d.ts +0 -105
- package/dist/src/core/types.d.ts +0 -269
- package/dist/src/core/validators.d.ts +0 -287
package/dist/src/core/index.cjs
CHANGED
|
@@ -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 /
|
|
19
|
-
* (`renderHTML`, `renderMarkdown`, `walkNodes`, `foldNode
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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,
|
|
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.
|
|
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,1599 +445,3078 @@ var isMarkdownDocument = (0, _orkestrel_contract.recordOf)({
|
|
|
413
445
|
children: (0, _orkestrel_contract.arrayOf)(isBlockNode)
|
|
414
446
|
});
|
|
415
447
|
//#endregion
|
|
416
|
-
//#region src/core/
|
|
448
|
+
//#region src/core/parsers.ts
|
|
417
449
|
/**
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
* document parses identically. A single trailing newline does not yield a final
|
|
421
|
-
* empty line.
|
|
450
|
+
* Parses a run of markdown lines into a block AST, recursing into nested
|
|
451
|
+
* blockquotes, list items, and depth-capped degrade paragraphs.
|
|
422
452
|
*
|
|
423
|
-
* @param
|
|
424
|
-
* @
|
|
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.
|
|
425
456
|
*
|
|
426
457
|
* @example
|
|
427
458
|
* ```ts
|
|
428
|
-
*
|
|
459
|
+
* parseBlocks(['# Hi'], 0) // [{ element: 'heading', level: 1, children: [...] }]
|
|
429
460
|
* ```
|
|
430
461
|
*/
|
|
431
|
-
function
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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;
|
|
435
545
|
}
|
|
436
546
|
/**
|
|
437
|
-
*
|
|
438
|
-
*
|
|
547
|
+
* Collects a GFM table starting at a header row, parsing the header, the
|
|
548
|
+
* alignment row, and every contiguous body row that follows.
|
|
439
549
|
*
|
|
440
|
-
* @param
|
|
441
|
-
* @
|
|
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.
|
|
442
553
|
*
|
|
443
554
|
* @example
|
|
444
555
|
* ```ts
|
|
445
|
-
*
|
|
556
|
+
* collectTable(['| a |', '| - |'], 0) // { node: { element: 'table', ... }, next: 2 }
|
|
446
557
|
* ```
|
|
447
558
|
*/
|
|
448
|
-
function
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
+
}
|
|
575
|
+
return {
|
|
576
|
+
node: {
|
|
577
|
+
element: "table",
|
|
578
|
+
header,
|
|
579
|
+
rows,
|
|
580
|
+
align: padded
|
|
581
|
+
},
|
|
582
|
+
next: index
|
|
583
|
+
};
|
|
453
584
|
}
|
|
454
585
|
/**
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
* `#`s, or `#`s not followed by whitespace + text, is not a
|
|
458
|
-
* heading; an optional closing `###` run is stripped.
|
|
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.
|
|
459
588
|
*
|
|
460
|
-
* @param
|
|
461
|
-
* @
|
|
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.
|
|
462
593
|
*
|
|
463
594
|
* @example
|
|
464
595
|
* ```ts
|
|
465
|
-
*
|
|
596
|
+
* collectList(['- item'], 0, 0) // { node: { element: 'list', ... }, next: 1 }
|
|
466
597
|
* ```
|
|
467
598
|
*/
|
|
468
|
-
function
|
|
469
|
-
const
|
|
470
|
-
|
|
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);
|
|
615
|
+
}
|
|
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
|
+
}
|
|
649
|
+
}
|
|
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());
|
|
675
|
+
index += 1;
|
|
676
|
+
}
|
|
677
|
+
items.push({
|
|
678
|
+
element: "listItem",
|
|
679
|
+
children: parseBlocks(itemLines, depth + 1)
|
|
680
|
+
});
|
|
681
|
+
}
|
|
471
682
|
return {
|
|
472
|
-
|
|
473
|
-
|
|
683
|
+
node: {
|
|
684
|
+
element: "list",
|
|
685
|
+
ordered,
|
|
686
|
+
start: startOrdinal,
|
|
687
|
+
items
|
|
688
|
+
},
|
|
689
|
+
next: index
|
|
474
690
|
};
|
|
475
691
|
}
|
|
476
692
|
/**
|
|
477
|
-
*
|
|
478
|
-
*
|
|
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.
|
|
481
|
-
*
|
|
482
|
-
* @param line - The candidate line
|
|
483
|
-
* @returns The fence marker run and its language tag, or `undefined`
|
|
693
|
+
* Parses a markdown string into a typed {@link MarkdownDocument} AST via the
|
|
694
|
+
* block phase.
|
|
484
695
|
*
|
|
485
|
-
* @
|
|
486
|
-
*
|
|
487
|
-
* extractFence('```ts') // { marker: '```', lang: 'ts' }
|
|
488
|
-
* ```
|
|
696
|
+
* @param markdown - The markdown source to parse.
|
|
697
|
+
* @returns The parsed document.
|
|
489
698
|
*/
|
|
490
|
-
function
|
|
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;
|
|
699
|
+
function parseDocument(markdown) {
|
|
496
700
|
return {
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
};
|
|
701
|
+
element: "document",
|
|
702
|
+
children: parseBlocks(splitLines(markdown), 0)
|
|
703
|
+
};
|
|
500
704
|
}
|
|
501
705
|
/**
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
* item. `content` is the text after the marker; `marker` is the full marker-plus-space
|
|
505
|
-
* width (for measuring a continuation's indent).
|
|
706
|
+
* Parses inline markdown text (emphasis, code spans, links, images, and hard
|
|
707
|
+
* breaks) into inline AST nodes, coalescing adjacent text runs.
|
|
506
708
|
*
|
|
507
|
-
* @param
|
|
508
|
-
* @returns The
|
|
709
|
+
* @param text - The inline markdown text to parse.
|
|
710
|
+
* @returns The parsed inline nodes.
|
|
711
|
+
*/
|
|
712
|
+
function parseInline(text) {
|
|
713
|
+
return coalesceText(scanInline(text, 0, text.length));
|
|
714
|
+
}
|
|
715
|
+
//#endregion
|
|
716
|
+
//#region src/core/Markdown.ts
|
|
717
|
+
/**
|
|
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.
|
|
721
|
+
*
|
|
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.
|
|
509
732
|
*
|
|
510
733
|
* @example
|
|
511
734
|
* ```ts
|
|
512
|
-
*
|
|
735
|
+
* import { Markdown, isHeadingNode, renderMarkdown } from '@src/core'
|
|
736
|
+
*
|
|
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).'
|
|
513
743
|
* ```
|
|
514
744
|
*/
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
const content = unordered[3] ?? "";
|
|
520
|
-
return {
|
|
521
|
-
ordered: false,
|
|
522
|
-
start: 1,
|
|
523
|
-
content,
|
|
524
|
-
indent,
|
|
525
|
-
marker: line.length - content.length
|
|
526
|
-
};
|
|
745
|
+
var Markdown = class Markdown {
|
|
746
|
+
#document;
|
|
747
|
+
constructor(input) {
|
|
748
|
+
this.#document = typeof input === "string" ? parseDocument(input) : input;
|
|
527
749
|
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
};
|
|
750
|
+
/** The stored {@link MarkdownDocument} AST root. */
|
|
751
|
+
get document() {
|
|
752
|
+
return this.#document;
|
|
539
753
|
}
|
|
540
|
-
|
|
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
|
|
541
836
|
/**
|
|
542
|
-
*
|
|
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
|
|
837
|
+
* The shape of a {@link TextNode} - a plain-text leaf inline run.
|
|
547
838
|
*
|
|
548
839
|
* @example
|
|
549
840
|
* ```ts
|
|
550
|
-
*
|
|
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
|
|
551
846
|
* ```
|
|
552
847
|
*/
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
848
|
+
var textShape = (0, _orkestrel_contract.objectShape)({
|
|
849
|
+
element: (0, _orkestrel_contract.literalShape)(["text"]),
|
|
850
|
+
value: (0, _orkestrel_contract.stringShape)()
|
|
851
|
+
});
|
|
556
852
|
/**
|
|
557
|
-
*
|
|
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
|
|
853
|
+
* The shape of a {@link CodeSpanNode} - an inline code span (`` `code` ``).
|
|
563
854
|
*
|
|
564
855
|
* @example
|
|
565
856
|
* ```ts
|
|
566
|
-
*
|
|
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
|
|
567
862
|
* ```
|
|
568
863
|
*/
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
for (let index = 0; index < trimmed.length; index += 1) {
|
|
574
|
-
const character = trimmed[index];
|
|
575
|
-
if (character === "\\" && trimmed[index + 1] === "|") {
|
|
576
|
-
current += "|";
|
|
577
|
-
index += 1;
|
|
578
|
-
} else if (character === "|") {
|
|
579
|
-
cells.push(current);
|
|
580
|
-
current = "";
|
|
581
|
-
} else current += character;
|
|
582
|
-
}
|
|
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;
|
|
587
|
-
}
|
|
864
|
+
var codeSpanShape = (0, _orkestrel_contract.objectShape)({
|
|
865
|
+
element: (0, _orkestrel_contract.literalShape)(["codeSpan"]),
|
|
866
|
+
value: (0, _orkestrel_contract.stringShape)()
|
|
867
|
+
});
|
|
588
868
|
/**
|
|
589
|
-
*
|
|
590
|
-
* left, `---:` right, `:---:` center, `---` none.
|
|
591
|
-
*
|
|
592
|
-
* @param delimiter - The table's delimiter row
|
|
593
|
-
* @returns One alignment per column, in column order
|
|
869
|
+
* The shape of a {@link LineBreakNode} - a GFM hard line-break leaf.
|
|
594
870
|
*
|
|
595
871
|
* @example
|
|
596
872
|
* ```ts
|
|
597
|
-
*
|
|
873
|
+
* import { createContract } from '@orkestrel/contract'
|
|
874
|
+
* import { lineBreakShape } from '@src/core'
|
|
875
|
+
*
|
|
876
|
+
* const lineBreak = createContract(lineBreakShape)
|
|
877
|
+
* lineBreak.is({ element: 'break' }) // true
|
|
598
878
|
* ```
|
|
599
879
|
*/
|
|
600
|
-
|
|
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
|
-
});
|
|
610
|
-
}
|
|
880
|
+
var lineBreakShape = (0, _orkestrel_contract.objectShape)({ element: (0, _orkestrel_contract.literalShape)(["break"]) });
|
|
611
881
|
/**
|
|
612
|
-
*
|
|
613
|
-
*
|
|
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
|
|
882
|
+
* The shape of a {@link CodeBlockNode} - a fenced code block. `lang` is
|
|
883
|
+
* optional (absent when the opening fence carries no info-string).
|
|
621
884
|
*
|
|
622
885
|
* @example
|
|
623
886
|
* ```ts
|
|
624
|
-
*
|
|
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
|
|
625
893
|
* ```
|
|
626
894
|
*/
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
+
});
|
|
631
900
|
/**
|
|
632
|
-
*
|
|
633
|
-
*
|
|
634
|
-
*
|
|
635
|
-
* @param text - The raw text possibly carrying `\x` escapes
|
|
636
|
-
* @returns The text with escapable `\x` reduced to `x`
|
|
901
|
+
* The shape of a {@link ThematicBreakNode} - a horizontal rule. Carries no
|
|
902
|
+
* fields beyond its `element` discriminant.
|
|
637
903
|
*
|
|
638
904
|
* @example
|
|
639
905
|
* ```ts
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
out += text[index + 1] ?? "";
|
|
649
|
-
index += 1;
|
|
650
|
-
} else out += character;
|
|
651
|
-
}
|
|
652
|
-
return out;
|
|
653
|
-
}
|
|
906
|
+
* import { createContract } from '@orkestrel/contract'
|
|
907
|
+
* import { thematicBreakShape } from '@src/core'
|
|
908
|
+
*
|
|
909
|
+
* const thematicBreak = createContract(thematicBreakShape)
|
|
910
|
+
* thematicBreak.is({ element: 'thematicBreak' }) // true
|
|
911
|
+
* ```
|
|
912
|
+
*/
|
|
913
|
+
var thematicBreakShape = (0, _orkestrel_contract.objectShape)({ element: (0, _orkestrel_contract.literalShape)(["thematicBreak"]) });
|
|
654
914
|
/**
|
|
655
|
-
*
|
|
656
|
-
*
|
|
915
|
+
* The shape of a {@link TableAlign} - the per-column GFM table alignment
|
|
916
|
+
* literal.
|
|
657
917
|
*
|
|
658
|
-
* @
|
|
659
|
-
*
|
|
918
|
+
* @example
|
|
919
|
+
* ```ts
|
|
920
|
+
* import { createContract } from '@orkestrel/contract'
|
|
921
|
+
* import { tableAlignShape } from '@src/core'
|
|
922
|
+
*
|
|
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.
|
|
660
938
|
*
|
|
661
939
|
* @example
|
|
662
940
|
* ```ts
|
|
663
|
-
*
|
|
664
|
-
*
|
|
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
|
|
665
946
|
* ```
|
|
666
947
|
*/
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
}
|
|
677
|
-
return out;
|
|
678
|
-
}
|
|
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
|
|
679
957
|
/**
|
|
680
|
-
*
|
|
681
|
-
*
|
|
682
|
-
* span's literal text + end index, or `undefined` when no matching closer exists (it
|
|
683
|
-
* then degrades to literal backticks).
|
|
958
|
+
* Create an HTML-to-markdown projection with absent fields defaulted from
|
|
959
|
+
* {@link EMPTY_PROJECTION} and the block/inline exclusivity invariant enforced.
|
|
684
960
|
*
|
|
685
|
-
* @
|
|
686
|
-
*
|
|
687
|
-
*
|
|
688
|
-
*
|
|
961
|
+
* @remarks
|
|
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.
|
|
964
|
+
*
|
|
965
|
+
* @param parts - The projection fields to provide
|
|
966
|
+
* @returns A complete invariant-preserving projection
|
|
689
967
|
*
|
|
690
968
|
* @example
|
|
691
969
|
* ```ts
|
|
692
|
-
*
|
|
970
|
+
* createProjection({
|
|
971
|
+
* blocks: [{ element: 'thematicBreak' }],
|
|
972
|
+
* inlines: [{ element: 'text', value: 'discarded' }],
|
|
973
|
+
* })
|
|
974
|
+
* // { blocks: [{ element: 'thematicBreak' }], inlines: [], text: '', cells: [], rows: [] }
|
|
693
975
|
* ```
|
|
694
976
|
*/
|
|
695
|
-
function
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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;
|
|
712
|
-
}
|
|
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
|
+
};
|
|
713
986
|
}
|
|
714
987
|
/**
|
|
715
|
-
*
|
|
716
|
-
*
|
|
717
|
-
*
|
|
718
|
-
* does not hold (it then degrades to a literal `[`).
|
|
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.
|
|
719
991
|
*
|
|
720
|
-
* @
|
|
721
|
-
*
|
|
722
|
-
*
|
|
723
|
-
*
|
|
724
|
-
*
|
|
725
|
-
*
|
|
726
|
-
*
|
|
992
|
+
* @remarks
|
|
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).
|
|
1001
|
+
*
|
|
1002
|
+
* @param input - A markdown string to parse, or an already-parsed {@link MarkdownDocument}
|
|
1003
|
+
* @returns A working {@link MarkdownInterface}
|
|
727
1004
|
*
|
|
728
1005
|
* @example
|
|
729
1006
|
* ```ts
|
|
730
|
-
*
|
|
731
|
-
*
|
|
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', ... }
|
|
732
1011
|
* ```
|
|
733
1012
|
*/
|
|
734
|
-
function
|
|
735
|
-
|
|
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
|
-
};
|
|
1013
|
+
function createMarkdown(input) {
|
|
1014
|
+
return new Markdown(input);
|
|
779
1015
|
}
|
|
780
1016
|
/**
|
|
781
|
-
*
|
|
782
|
-
*
|
|
783
|
-
*
|
|
784
|
-
* Returns the emphasis node, or `undefined` when no valid closer exists (it then
|
|
785
|
-
* degrades to a literal marker).
|
|
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).
|
|
786
1020
|
*
|
|
787
|
-
* @
|
|
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`
|
|
1021
|
+
* @returns A `TextNode` contract bundling `schema` / `is` / `parse` / `generate`
|
|
794
1022
|
*
|
|
795
1023
|
* @example
|
|
796
1024
|
* ```ts
|
|
797
|
-
*
|
|
798
|
-
*
|
|
1025
|
+
* import { createTextContract } from '@src/core'
|
|
1026
|
+
*
|
|
1027
|
+
* const text = createTextContract()
|
|
1028
|
+
* text.is({ element: 'text', value: 'hi' }) // true
|
|
799
1029
|
* ```
|
|
800
1030
|
*/
|
|
801
|
-
function
|
|
802
|
-
|
|
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
|
-
}
|
|
1031
|
+
function createTextContract() {
|
|
1032
|
+
return (0, _orkestrel_contract.createContract)(textShape);
|
|
836
1033
|
}
|
|
837
1034
|
/**
|
|
838
|
-
*
|
|
839
|
-
*
|
|
840
|
-
*
|
|
841
|
-
* text and advances by one, so there is no re-scan (no ReDoS).
|
|
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).
|
|
842
1038
|
*
|
|
843
|
-
* @
|
|
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)
|
|
1039
|
+
* @returns A `CodeSpanNode` contract bundling `schema` / `is` / `parse` / `generate`
|
|
852
1040
|
*
|
|
853
1041
|
* @example
|
|
854
1042
|
* ```ts
|
|
855
|
-
*
|
|
1043
|
+
* import { createCodeSpanContract } from '@src/core'
|
|
1044
|
+
*
|
|
1045
|
+
* const codeSpan = createCodeSpanContract()
|
|
1046
|
+
* codeSpan.is({ element: 'codeSpan', value: 'const x = 1' }) // true
|
|
856
1047
|
* ```
|
|
857
1048
|
*/
|
|
858
|
-
function
|
|
859
|
-
|
|
860
|
-
element: "text",
|
|
861
|
-
value: source.slice(from, to)
|
|
862
|
-
}] : [];
|
|
863
|
-
const nodes = [];
|
|
864
|
-
let index = from;
|
|
865
|
-
let pending = "";
|
|
866
|
-
const flush = () => {
|
|
867
|
-
if (pending.length > 0) {
|
|
868
|
-
nodes.push({
|
|
869
|
-
element: "text",
|
|
870
|
-
value: pending
|
|
871
|
-
});
|
|
872
|
-
pending = "";
|
|
873
|
-
}
|
|
874
|
-
};
|
|
875
|
-
while (index < to) {
|
|
876
|
-
const character = source[index] ?? "";
|
|
877
|
-
if (character === "\\" && index + 1 < to && isEscapable(source[index + 1] ?? "")) {
|
|
878
|
-
pending += source[index + 1] ?? "";
|
|
879
|
-
index += 2;
|
|
880
|
-
continue;
|
|
881
|
-
}
|
|
882
|
-
if (character === "`") {
|
|
883
|
-
const span = scanCode(source, index, to);
|
|
884
|
-
if (span) {
|
|
885
|
-
flush();
|
|
886
|
-
nodes.push({
|
|
887
|
-
element: "codeSpan",
|
|
888
|
-
value: span.value
|
|
889
|
-
});
|
|
890
|
-
index = span.end;
|
|
891
|
-
continue;
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
if (character === "[") {
|
|
895
|
-
const link = scanLink(source, index, to, depth);
|
|
896
|
-
if (link) {
|
|
897
|
-
flush();
|
|
898
|
-
nodes.push(link.node);
|
|
899
|
-
index = link.end;
|
|
900
|
-
continue;
|
|
901
|
-
}
|
|
902
|
-
}
|
|
903
|
-
if (character === "*" || character === "_") {
|
|
904
|
-
const emphasis = scanEmphasis(source, index, to, depth);
|
|
905
|
-
if (emphasis) {
|
|
906
|
-
flush();
|
|
907
|
-
nodes.push(emphasis.node);
|
|
908
|
-
index = emphasis.end;
|
|
909
|
-
continue;
|
|
910
|
-
}
|
|
911
|
-
}
|
|
912
|
-
pending += character;
|
|
913
|
-
index += 1;
|
|
914
|
-
}
|
|
915
|
-
flush();
|
|
916
|
-
return nodes;
|
|
1049
|
+
function createCodeSpanContract() {
|
|
1050
|
+
return (0, _orkestrel_contract.createContract)(codeSpanShape);
|
|
917
1051
|
}
|
|
918
1052
|
/**
|
|
919
|
-
*
|
|
920
|
-
*
|
|
921
|
-
* text run, code body, and (escaped further) attribute value.
|
|
1053
|
+
* Compile the {@link lineBreakShape} into a {@link ContractInterface} for
|
|
1054
|
+
* {@link LineBreakNode}.
|
|
922
1055
|
*
|
|
923
|
-
* @
|
|
924
|
-
* @returns The HTML-escaped text
|
|
1056
|
+
* @returns A `LineBreakNode` contract bundling `schema` / `is` / `parse` / `generate`
|
|
925
1057
|
*
|
|
926
1058
|
* @example
|
|
927
1059
|
* ```ts
|
|
928
|
-
*
|
|
1060
|
+
* import { createLineBreakContract } from '@src/core'
|
|
1061
|
+
*
|
|
1062
|
+
* createLineBreakContract().is({ element: 'break' }) // true
|
|
929
1063
|
* ```
|
|
930
1064
|
*/
|
|
931
|
-
function
|
|
932
|
-
return
|
|
1065
|
+
function createLineBreakContract() {
|
|
1066
|
+
return (0, _orkestrel_contract.createContract)(lineBreakShape);
|
|
933
1067
|
}
|
|
934
1068
|
/**
|
|
935
|
-
*
|
|
936
|
-
*
|
|
937
|
-
*
|
|
938
|
-
* the same effect - `\\host`, `/\host`, `\/host` - inherits whatever scheme the
|
|
939
|
-
* embedding page is served over, including an unsafe one), is dropped to an empty
|
|
940
|
-
* string; a relative / anchor / scheme-less (and non-protocol-relative) destination
|
|
941
|
-
* (including a SINGLE leading `/` or `\`) is kept;
|
|
942
|
-
* the surviving value is then HTML-escaped. Defence-in-depth against an XSS `href`,
|
|
943
|
-
* even though the input is trusted.
|
|
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).
|
|
944
1072
|
*
|
|
945
|
-
* @
|
|
946
|
-
* @returns A safe, escaped `href` (empty when the scheme is unsafe or protocol-relative)
|
|
1073
|
+
* @returns A `CodeBlockNode` contract bundling `schema` / `is` / `parse` / `generate`
|
|
947
1074
|
*
|
|
948
1075
|
* @example
|
|
949
1076
|
* ```ts
|
|
950
|
-
*
|
|
951
|
-
*
|
|
1077
|
+
* import { createCodeBlockContract } from '@src/core'
|
|
1078
|
+
*
|
|
1079
|
+
* const codeBlock = createCodeBlockContract()
|
|
1080
|
+
* codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
|
|
952
1081
|
* ```
|
|
953
1082
|
*/
|
|
954
|
-
function
|
|
955
|
-
|
|
956
|
-
for (const character of href) {
|
|
957
|
-
const code = character.codePointAt(0) ?? 0;
|
|
958
|
-
if (code > 32 && !(code >= 127 && code <= 159)) cleaned += character;
|
|
959
|
-
}
|
|
960
|
-
if (/^[/\\]{2}/.exec(cleaned)) return "";
|
|
961
|
-
const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(cleaned);
|
|
962
|
-
if (scheme && scheme[1] !== void 0 && !SAFE_URL_SCHEMES.has(scheme[1].toLowerCase())) return "";
|
|
963
|
-
return escapeHtml(cleaned);
|
|
1083
|
+
function createCodeBlockContract() {
|
|
1084
|
+
return (0, _orkestrel_contract.createContract)(codeBlockShape);
|
|
964
1085
|
}
|
|
965
1086
|
/**
|
|
966
|
-
*
|
|
967
|
-
*
|
|
968
|
-
*
|
|
969
|
-
* sanitizing every link `href`.
|
|
970
|
-
*
|
|
971
|
-
* @remarks
|
|
972
|
-
* Total: never throws. At {@link MAX_DEPTH} a value-bearing node (`text` / `codeSpan`)
|
|
973
|
-
* degrades to its escaped `value`; any other node degrades to `''` instead of
|
|
974
|
-
* recursing further, so pathologically deep input cannot exhaust the call stack. The
|
|
975
|
-
* recursive engine and its per-shape sub-steps (inline concatenation, table cell,
|
|
976
|
-
* tight list-item) are nested inner functions - the only exported surface is
|
|
977
|
-
* `renderHTML` itself.
|
|
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).
|
|
978
1090
|
*
|
|
979
|
-
* @
|
|
980
|
-
* @returns The rendered, XSS-safe HTML string
|
|
1091
|
+
* @returns A `ThematicBreakNode` contract bundling `schema` / `is` / `parse` / `generate`
|
|
981
1092
|
*
|
|
982
1093
|
* @example
|
|
983
1094
|
* ```ts
|
|
984
|
-
*
|
|
985
|
-
*
|
|
986
|
-
*
|
|
987
|
-
*
|
|
1095
|
+
* import { createThematicBreakContract } from '@src/core'
|
|
1096
|
+
*
|
|
1097
|
+
* const thematicBreak = createThematicBreakContract()
|
|
1098
|
+
* thematicBreak.is({ element: 'thematicBreak' }) // true
|
|
988
1099
|
* ```
|
|
989
1100
|
*/
|
|
990
|
-
function
|
|
991
|
-
|
|
992
|
-
if (depth >= 64) return "value" in current && typeof current.value === "string" ? escapeHtml(current.value) : "";
|
|
993
|
-
switch (current.element) {
|
|
994
|
-
case "document": return current.children.map((child) => render(child, depth + 1)).join("\n");
|
|
995
|
-
case "heading": return `<h${current.level}>${renderInline(current.children, depth)}</h${current.level}>`;
|
|
996
|
-
case "paragraph": return `<p>${renderInline(current.children, depth)}</p>`;
|
|
997
|
-
case "thematicBreak": return "<hr>";
|
|
998
|
-
case "blockquote": return `<blockquote>\n${current.children.map((child) => render(child, depth + 1)).join("\n")}\n</blockquote>`;
|
|
999
|
-
case "codeBlock": return `<pre>${current.lang === void 0 ? "<code>" : `<code class="language-${escapeHtml(current.lang)}">`}${escapeHtml(current.code)}</code></pre>`;
|
|
1000
|
-
case "list": {
|
|
1001
|
-
const items = current.items.map((item) => render(item, depth + 1)).join("\n");
|
|
1002
|
-
if (!current.ordered) return `<ul>\n${items}\n</ul>`;
|
|
1003
|
-
return `<ol${current.start !== 1 ? ` start="${current.start}"` : ""}>\n${items}\n</ol>`;
|
|
1004
|
-
}
|
|
1005
|
-
case "listItem": return `<li>${renderItem(current.children, depth)}</li>`;
|
|
1006
|
-
case "table": {
|
|
1007
|
-
const head = `<tr>${current.header.map((cell, column) => renderCell("th", cell, current.align[column], depth)).join("")}</tr>`;
|
|
1008
|
-
const body = current.rows.map((row) => `<tr>${row.map((cell, column) => renderCell("td", cell, current.align[column], depth)).join("")}</tr>`).join("\n");
|
|
1009
|
-
return `<table>\n<thead>\n${head}\n</thead>${(0, _orkestrel_contract.isNonEmptyArray)(current.rows) ? `\n<tbody>\n${body}\n</tbody>` : ""}\n</table>`;
|
|
1010
|
-
}
|
|
1011
|
-
case "text": return escapeHtml(current.value);
|
|
1012
|
-
case "emphasis": return current.strong ? `<strong>${renderInline(current.children, depth + 1)}</strong>` : `<em>${renderInline(current.children, depth + 1)}</em>`;
|
|
1013
|
-
case "codeSpan": return `<code>${escapeHtml(current.value)}</code>`;
|
|
1014
|
-
case "link": return `<a href="${sanitizeUrl(current.href)}">${renderInline(current.children, depth + 1)}</a>`;
|
|
1015
|
-
default: return "";
|
|
1016
|
-
}
|
|
1017
|
-
}
|
|
1018
|
-
function renderInline(nodes, depth) {
|
|
1019
|
-
return nodes.map((child) => render(child, depth + 1)).join("");
|
|
1020
|
-
}
|
|
1021
|
-
function renderCell(tag, cell, align, depth) {
|
|
1022
|
-
return `<${tag}${align === "left" || align === "right" || align === "center" ? ` style="text-align:${align}"` : ""}>${renderInline(cell, depth + 1)}</${tag}>`;
|
|
1023
|
-
}
|
|
1024
|
-
function renderItem(children, depth) {
|
|
1025
|
-
if (children.length === 1) {
|
|
1026
|
-
const only = children[0];
|
|
1027
|
-
if (only !== void 0 && only.element === "paragraph") return renderInline(only.children, depth);
|
|
1028
|
-
}
|
|
1029
|
-
return children.map((child) => render(child, depth + 1)).join("\n");
|
|
1030
|
-
}
|
|
1031
|
-
return render(node, 0);
|
|
1101
|
+
function createThematicBreakContract() {
|
|
1102
|
+
return (0, _orkestrel_contract.createContract)(thematicBreakShape);
|
|
1032
1103
|
}
|
|
1104
|
+
//#endregion
|
|
1105
|
+
//#region src/core/helpers.ts
|
|
1033
1106
|
/**
|
|
1034
|
-
*
|
|
1035
|
-
*
|
|
1036
|
-
*
|
|
1037
|
-
*
|
|
1038
|
-
* `start`), `---` thematic breaks, fenced code blocks (backtick run widened past any
|
|
1039
|
-
* 3+ backtick run inside the body), ATX headings, `> `-prefixed blockquote lines, GFM
|
|
1040
|
-
* tables (1-space-padded cells, `\|`-escaped pipes, an alignment delimiter row), and
|
|
1041
|
-
* `[text](href)` links. A `text` node's literal content is backslash-escaped wherever
|
|
1042
|
-
* it would otherwise re-parse as markup (AGENTS §14 parse↔render soundness).
|
|
1043
|
-
*
|
|
1044
|
-
* @remarks
|
|
1045
|
-
* Total: never throws. At {@link MAX_DEPTH} a value-bearing node degrades to its
|
|
1046
|
-
* escaped `value`; any other node degrades to `''`. Blocks are joined by exactly one
|
|
1047
|
-
* blank line; a document with zero blocks renders `''`.
|
|
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.
|
|
1048
1111
|
*
|
|
1049
|
-
* @param
|
|
1050
|
-
* @returns The
|
|
1112
|
+
* @param markdown - The raw markdown source
|
|
1113
|
+
* @returns The document's lines, line-terminators stripped
|
|
1051
1114
|
*
|
|
1052
1115
|
* @example
|
|
1053
1116
|
* ```ts
|
|
1054
|
-
*
|
|
1055
|
-
* { element: 'heading', level: 2, children: [{ element: 'text', value: 'Hi' }] },
|
|
1056
|
-
* ] })
|
|
1057
|
-
* // '## Hi'
|
|
1117
|
+
* splitLines('a\r\nb\nc') // ['a', 'b', 'c']
|
|
1058
1118
|
* ```
|
|
1059
1119
|
*/
|
|
1060
|
-
function
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
const character = value[index] ?? "";
|
|
1065
|
-
const atLineStart = index === 0 || value[index - 1] === "\n";
|
|
1066
|
-
if (character === "\\" || character === "*" || character === "_" || character === "`" || character === "[" || character === "]") {
|
|
1067
|
-
out += `\\${character}`;
|
|
1068
|
-
continue;
|
|
1069
|
-
}
|
|
1070
|
-
if (atLineStart) {
|
|
1071
|
-
if (character === "#" || character === ">") {
|
|
1072
|
-
out += `\\${character}`;
|
|
1073
|
-
continue;
|
|
1074
|
-
}
|
|
1075
|
-
if ((character === "-" || character === "+") && (value[index + 1] ?? " ") === " ") {
|
|
1076
|
-
out += `\\${character}`;
|
|
1077
|
-
continue;
|
|
1078
|
-
}
|
|
1079
|
-
if (/[0-9]/.test(character)) {
|
|
1080
|
-
let end = index;
|
|
1081
|
-
while (end < value.length && /[0-9]/.test(value[end] ?? "")) end += 1;
|
|
1082
|
-
const marker = value[end];
|
|
1083
|
-
if ((marker === "." || marker === ")") && value[end + 1] === " ") {
|
|
1084
|
-
out += `${value.slice(index, end)}\\${marker}`;
|
|
1085
|
-
index = end;
|
|
1086
|
-
continue;
|
|
1087
|
-
}
|
|
1088
|
-
}
|
|
1089
|
-
}
|
|
1090
|
-
out += character;
|
|
1091
|
-
}
|
|
1092
|
-
return out;
|
|
1093
|
-
}
|
|
1094
|
-
function fenceFor(body, minimum) {
|
|
1095
|
-
let longest = 0;
|
|
1096
|
-
let run = 0;
|
|
1097
|
-
for (const character of body) if (character === "`") {
|
|
1098
|
-
run += 1;
|
|
1099
|
-
longest = Math.max(longest, run);
|
|
1100
|
-
} else run = 0;
|
|
1101
|
-
return "`".repeat(Math.max(minimum, longest + 1));
|
|
1102
|
-
}
|
|
1103
|
-
function renderInline(nodes, depth) {
|
|
1104
|
-
return nodes.map((child) => render(child, depth + 1)).join("");
|
|
1105
|
-
}
|
|
1106
|
-
function renderBlocks(blocks, depth) {
|
|
1107
|
-
return blocks.map((block) => render(block, depth + 1)).join("\n\n");
|
|
1108
|
-
}
|
|
1109
|
-
function renderItem(item, marker, depth) {
|
|
1110
|
-
const body = renderBlocks(item.children, depth + 1);
|
|
1111
|
-
const pad = " ".repeat(marker.length);
|
|
1112
|
-
return body.split("\n").map((line, index) => index === 0 ? marker + line : line === "" ? "" : pad + line).join("\n");
|
|
1113
|
-
}
|
|
1114
|
-
function renderCell(cell, depth) {
|
|
1115
|
-
return renderInline(cell, depth + 1).replace(/\|/g, "\\|");
|
|
1116
|
-
}
|
|
1117
|
-
function renderTable(current, depth) {
|
|
1118
|
-
const columns = current.header.length;
|
|
1119
|
-
return [
|
|
1120
|
-
`| ${current.header.map((cell) => renderCell(cell, depth)).join(" | ")} |`,
|
|
1121
|
-
`| ${current.align.map((align) => {
|
|
1122
|
-
if (align === "left") return ":--";
|
|
1123
|
-
if (align === "right") return "--:";
|
|
1124
|
-
if (align === "center") return ":-:";
|
|
1125
|
-
return "---";
|
|
1126
|
-
}).join(" | ")} |`,
|
|
1127
|
-
...current.rows.map((row) => {
|
|
1128
|
-
const cells = [];
|
|
1129
|
-
for (let column = 0; column < columns; column += 1) {
|
|
1130
|
-
const cell = row[column];
|
|
1131
|
-
cells.push(cell === void 0 ? "" : renderCell(cell, depth));
|
|
1132
|
-
}
|
|
1133
|
-
return `| ${cells.join(" | ")} |`;
|
|
1134
|
-
})
|
|
1135
|
-
].join("\n");
|
|
1136
|
-
}
|
|
1137
|
-
function render(current, depth) {
|
|
1138
|
-
if (depth >= 64) return "value" in current && typeof current.value === "string" ? escapeText(current.value) : "";
|
|
1139
|
-
switch (current.element) {
|
|
1140
|
-
case "document": return renderBlocks(current.children, depth);
|
|
1141
|
-
case "heading": {
|
|
1142
|
-
const escaped = renderInline(current.children, depth).replace(/(^|[^\\])(#+)$/, (_match, pre, hashes) => {
|
|
1143
|
-
return `${pre}\\${hashes[0] ?? ""}${hashes.slice(1)}`;
|
|
1144
|
-
});
|
|
1145
|
-
return `${"#".repeat(current.level)} ${escaped}`;
|
|
1146
|
-
}
|
|
1147
|
-
case "paragraph": return renderInline(current.children, depth);
|
|
1148
|
-
case "thematicBreak": return "---";
|
|
1149
|
-
case "blockquote": return renderBlocks(current.children, depth).split("\n").map((line) => line === "" ? ">" : `> ${line}`).join("\n");
|
|
1150
|
-
case "codeBlock": {
|
|
1151
|
-
const fence = fenceFor(current.code, 3);
|
|
1152
|
-
return `${fence}${current.lang === void 0 ? "" : current.lang}\n${current.code}\n${fence}`;
|
|
1153
|
-
}
|
|
1154
|
-
case "list": {
|
|
1155
|
-
let ordinal = current.start;
|
|
1156
|
-
return current.items.map((item) => {
|
|
1157
|
-
return renderItem(item, current.ordered ? `${ordinal++}. ` : "- ", depth);
|
|
1158
|
-
}).join("\n");
|
|
1159
|
-
}
|
|
1160
|
-
case "listItem": return renderBlocks(current.children, depth);
|
|
1161
|
-
case "table": return renderTable(current, depth);
|
|
1162
|
-
case "text": return escapeText(current.value);
|
|
1163
|
-
case "emphasis": {
|
|
1164
|
-
const marker = current.strong ? "**" : "*";
|
|
1165
|
-
return `${marker}${renderInline(current.children, depth)}${marker}`;
|
|
1166
|
-
}
|
|
1167
|
-
case "codeSpan": {
|
|
1168
|
-
const fence = fenceFor(current.value, 1);
|
|
1169
|
-
const pad = current.value.startsWith("`") || current.value.endsWith("`") ? " " : "";
|
|
1170
|
-
return `${fence}${pad}${current.value}${pad}${fence}`;
|
|
1171
|
-
}
|
|
1172
|
-
case "link": {
|
|
1173
|
-
const href = current.href.replace(/[\\()]/g, (character) => `\\${character}`);
|
|
1174
|
-
return `[${renderInline(current.children, depth)}](${href})`;
|
|
1175
|
-
}
|
|
1176
|
-
default: return "";
|
|
1177
|
-
}
|
|
1178
|
-
}
|
|
1179
|
-
return render(node, 0);
|
|
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;
|
|
1180
1124
|
}
|
|
1181
1125
|
/**
|
|
1182
|
-
*
|
|
1183
|
-
*
|
|
1184
|
-
* header/row cells' inline nodes) in walk order.
|
|
1185
|
-
*
|
|
1186
|
-
* @remarks
|
|
1187
|
-
* Total: never throws. Descent stops at {@link MAX_DEPTH} (the node at the cap is
|
|
1188
|
-
* still yielded; its children are not) so pathologically deep input cannot exhaust
|
|
1189
|
-
* the call stack.
|
|
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.
|
|
1190
1128
|
*
|
|
1191
|
-
* @param
|
|
1192
|
-
* @returns
|
|
1129
|
+
* @param line - The line to measure
|
|
1130
|
+
* @returns The number of leading space / tab characters
|
|
1193
1131
|
*
|
|
1194
1132
|
* @example
|
|
1195
1133
|
* ```ts
|
|
1196
|
-
*
|
|
1197
|
-
* [...walkNodes(doc)].map((node) => node.element) // ['document', 'thematicBreak']
|
|
1134
|
+
* countIndent(' text') // 2
|
|
1198
1135
|
* ```
|
|
1199
1136
|
*/
|
|
1200
|
-
function
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
case "document":
|
|
1206
|
-
case "heading":
|
|
1207
|
-
case "paragraph":
|
|
1208
|
-
case "blockquote":
|
|
1209
|
-
case "listItem":
|
|
1210
|
-
case "emphasis":
|
|
1211
|
-
case "link":
|
|
1212
|
-
for (const child of current.children) yield* walk(child, depth + 1);
|
|
1213
|
-
return;
|
|
1214
|
-
case "list":
|
|
1215
|
-
for (const item of current.items) yield* walk(item, depth + 1);
|
|
1216
|
-
return;
|
|
1217
|
-
case "table":
|
|
1218
|
-
for (const cell of current.header) for (const inline of cell) yield* walk(inline, depth + 1);
|
|
1219
|
-
for (const row of current.rows) for (const cell of row) for (const inline of cell) yield* walk(inline, depth + 1);
|
|
1220
|
-
return;
|
|
1221
|
-
default: return;
|
|
1222
|
-
}
|
|
1223
|
-
}
|
|
1224
|
-
yield* walk(node, 0);
|
|
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;
|
|
1225
1142
|
}
|
|
1226
1143
|
/**
|
|
1227
|
-
*
|
|
1228
|
-
*
|
|
1229
|
-
*
|
|
1230
|
-
*
|
|
1231
|
-
* @remarks
|
|
1232
|
-
* **Table contract.** A {@link TableNode} has no single `children` array - its cells
|
|
1233
|
-
* live in `header` (one inline-node list per column) and `rows` (a list of such
|
|
1234
|
-
* rows). The `table` handler receives ONE folded `T` per inline node, flattened in
|
|
1235
|
-
* walk order across ALL cells - every header cell's inline nodes (column order), then
|
|
1236
|
-
* every body row's cells' inline nodes (row order, then column order) - and reads
|
|
1237
|
-
* `node.header[c].length` / `node.rows[r][c].length` off the table node itself to
|
|
1238
|
-
* recover cell boundaries within the flat list.
|
|
1239
|
-
*
|
|
1240
|
-
* Total: never throws. At `depth >= {@link MAX_DEPTH}` the node's handler is invoked
|
|
1241
|
-
* with an empty children list instead of recursing further.
|
|
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.
|
|
1242
1148
|
*
|
|
1243
|
-
* @param
|
|
1244
|
-
* @
|
|
1245
|
-
* @param depth - The starting recursion depth (pass `0` at the entry point)
|
|
1246
|
-
* @returns The folded `T`
|
|
1149
|
+
* @param line - The candidate line
|
|
1150
|
+
* @returns The heading level (1–6) and its raw inline text, or `undefined`
|
|
1247
1151
|
*
|
|
1248
1152
|
* @example
|
|
1249
1153
|
* ```ts
|
|
1250
|
-
*
|
|
1251
|
-
* document: (_, children) => children.reduce((a, b) => a + b, 1),
|
|
1252
|
-
* // ...one handler per element, each summing its folded children
|
|
1253
|
-
* }
|
|
1254
|
-
* foldNode(document, countHandlers, 0) // total node count
|
|
1154
|
+
* extractHeading('## Title') // { level: 2, text: 'Title' }
|
|
1255
1155
|
* ```
|
|
1256
1156
|
*/
|
|
1257
|
-
function
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
case "blockquote": return handlers.blockquote(current, children);
|
|
1265
|
-
case "codeBlock": return handlers.codeBlock(current, children);
|
|
1266
|
-
case "list": return handlers.list(current, children);
|
|
1267
|
-
case "listItem": return handlers.listItem(current, children);
|
|
1268
|
-
case "table": return handlers.table(current, children);
|
|
1269
|
-
case "text": return handlers.text(current, children);
|
|
1270
|
-
case "emphasis": return handlers.emphasis(current, children);
|
|
1271
|
-
case "codeSpan": return handlers.codeSpan(current, children);
|
|
1272
|
-
case "link": return handlers.link(current, children);
|
|
1273
|
-
}
|
|
1274
|
-
}
|
|
1275
|
-
function childNodes(current) {
|
|
1276
|
-
switch (current.element) {
|
|
1277
|
-
case "document":
|
|
1278
|
-
case "heading":
|
|
1279
|
-
case "paragraph":
|
|
1280
|
-
case "blockquote":
|
|
1281
|
-
case "listItem":
|
|
1282
|
-
case "emphasis":
|
|
1283
|
-
case "link": return current.children;
|
|
1284
|
-
case "list": return current.items;
|
|
1285
|
-
case "table": {
|
|
1286
|
-
const header = current.header.flatMap((cell) => cell);
|
|
1287
|
-
const rows = current.rows.flatMap((row) => row.flatMap((cell) => cell));
|
|
1288
|
-
return [...header, ...rows];
|
|
1289
|
-
}
|
|
1290
|
-
default: return [];
|
|
1291
|
-
}
|
|
1292
|
-
}
|
|
1293
|
-
function fold(current, level) {
|
|
1294
|
-
if (level >= 64) return dispatch(current, []);
|
|
1295
|
-
return dispatch(current, childNodes(current).map((child) => fold(child, level + 1)));
|
|
1296
|
-
}
|
|
1297
|
-
return fold(node, depth);
|
|
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
|
+
};
|
|
1298
1164
|
}
|
|
1299
1165
|
/**
|
|
1300
|
-
*
|
|
1301
|
-
*
|
|
1302
|
-
*
|
|
1303
|
-
*
|
|
1304
|
-
*
|
|
1305
|
-
* @remarks
|
|
1306
|
-
* Never mutates `document` - every level is rebuilt into a fresh object/array, even
|
|
1307
|
-
* when `rewrite` returns its input unchanged. When `rewrite` returns a node whose
|
|
1308
|
-
* `element` does not fit the slot it was called for (a block slot handed a
|
|
1309
|
-
* non-{@link BlockNode}, an inline slot handed a non-{@link InlineNode}, a list-item
|
|
1310
|
-
* slot handed a non-`listItem`), the ill-fitting result is discarded and the
|
|
1311
|
-
* freshly-rebuilt (unrewritten-at-this-level) node is kept instead - `rewriteDocument`
|
|
1312
|
-
* stays total and never produces a structurally invalid document.
|
|
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.
|
|
1313
1170
|
*
|
|
1314
|
-
*
|
|
1315
|
-
*
|
|
1316
|
-
* UNCHANGED (by reference, not rebuilt, and `rewrite` is not invoked on it) instead of
|
|
1317
|
-
* recursing further, so a pathologically deep adopted document cannot exhaust the
|
|
1318
|
-
* call stack. {@link MarkdownInterface.map} inherits this cap since it delegates here.
|
|
1319
|
-
*
|
|
1320
|
-
* @param document - The document AST to rewrite
|
|
1321
|
-
* @param rewrite - The bottom-up {@link MarkdownRewriteHandler}
|
|
1322
|
-
* @returns A new, rewritten {@link MarkdownDocument}
|
|
1171
|
+
* @param line - The candidate line
|
|
1172
|
+
* @returns The fence marker run and its language tag, or `undefined`
|
|
1323
1173
|
*
|
|
1324
1174
|
* @example
|
|
1325
1175
|
* ```ts
|
|
1326
|
-
*
|
|
1327
|
-
* node.element === 'text' ? { element: 'text', value: node.value.toUpperCase() } : node,
|
|
1328
|
-
* )
|
|
1176
|
+
* extractFence('```ts') // { marker: '```', lang: 'ts' }
|
|
1329
1177
|
* ```
|
|
1330
1178
|
*/
|
|
1331
|
-
function
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
}
|
|
1338
|
-
function rewriteBlock(node, depth) {
|
|
1339
|
-
if (depth >= 64) return node;
|
|
1340
|
-
const rebuilt = rebuildBlock(node, depth);
|
|
1341
|
-
const result = rewrite(rebuilt);
|
|
1342
|
-
return isBlockNode(result) ? result : rebuilt;
|
|
1343
|
-
}
|
|
1344
|
-
function rewriteItem(item, depth) {
|
|
1345
|
-
if (depth >= 64) return item;
|
|
1346
|
-
const rebuilt = {
|
|
1347
|
-
element: "listItem",
|
|
1348
|
-
children: item.children.map((child) => rewriteBlock(child, depth + 1))
|
|
1349
|
-
};
|
|
1350
|
-
const result = rewrite(rebuilt);
|
|
1351
|
-
return result.element === "listItem" ? result : rebuilt;
|
|
1352
|
-
}
|
|
1353
|
-
function rebuildInline(node, depth) {
|
|
1354
|
-
switch (node.element) {
|
|
1355
|
-
case "emphasis": return {
|
|
1356
|
-
...node,
|
|
1357
|
-
children: node.children.map((child) => rewriteInline(child, depth + 1))
|
|
1358
|
-
};
|
|
1359
|
-
case "link": return {
|
|
1360
|
-
...node,
|
|
1361
|
-
children: node.children.map((child) => rewriteInline(child, depth + 1))
|
|
1362
|
-
};
|
|
1363
|
-
case "text":
|
|
1364
|
-
case "codeSpan": return node;
|
|
1365
|
-
}
|
|
1366
|
-
}
|
|
1367
|
-
function rebuildBlock(node, depth) {
|
|
1368
|
-
switch (node.element) {
|
|
1369
|
-
case "heading": return {
|
|
1370
|
-
...node,
|
|
1371
|
-
children: node.children.map((child) => rewriteInline(child, depth + 1))
|
|
1372
|
-
};
|
|
1373
|
-
case "paragraph": return {
|
|
1374
|
-
...node,
|
|
1375
|
-
children: node.children.map((child) => rewriteInline(child, depth + 1))
|
|
1376
|
-
};
|
|
1377
|
-
case "blockquote": return {
|
|
1378
|
-
...node,
|
|
1379
|
-
children: node.children.map((child) => rewriteBlock(child, depth + 1))
|
|
1380
|
-
};
|
|
1381
|
-
case "list": return {
|
|
1382
|
-
...node,
|
|
1383
|
-
items: node.items.map((item) => rewriteItem(item, depth + 1))
|
|
1384
|
-
};
|
|
1385
|
-
case "table": return {
|
|
1386
|
-
...node,
|
|
1387
|
-
header: node.header.map((cell) => cell.map((inline) => rewriteInline(inline, depth + 1))),
|
|
1388
|
-
rows: node.rows.map((row) => row.map((cell) => cell.map((inline) => rewriteInline(inline, depth + 1))))
|
|
1389
|
-
};
|
|
1390
|
-
case "codeBlock":
|
|
1391
|
-
case "thematicBreak": return node;
|
|
1392
|
-
}
|
|
1393
|
-
}
|
|
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;
|
|
1394
1185
|
return {
|
|
1395
|
-
|
|
1396
|
-
|
|
1186
|
+
marker: match[1],
|
|
1187
|
+
lang
|
|
1397
1188
|
};
|
|
1398
1189
|
}
|
|
1399
1190
|
/**
|
|
1400
|
-
*
|
|
1401
|
-
*
|
|
1402
|
-
*
|
|
1403
|
-
*
|
|
1404
|
-
* @remarks
|
|
1405
|
-
* Total: never throws. Descent stops at {@link MAX_DEPTH} (contributes `''` past the
|
|
1406
|
-
* cap instead of recursing further).
|
|
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).
|
|
1407
1195
|
*
|
|
1408
|
-
* @param
|
|
1409
|
-
* @returns The
|
|
1196
|
+
* @param line - The candidate line
|
|
1197
|
+
* @returns The list-item parts, or `undefined` when not a list item
|
|
1410
1198
|
*
|
|
1411
1199
|
* @example
|
|
1412
1200
|
* ```ts
|
|
1413
|
-
*
|
|
1414
|
-
* { element: 'text', value: 'a ' },
|
|
1415
|
-
* { element: 'codeSpan', value: 'b' },
|
|
1416
|
-
* ] })
|
|
1417
|
-
* // 'a b'
|
|
1201
|
+
* extractListItem('- item') // { ordered: false, start: 1, content: 'item', indent: 0, marker: 2 }
|
|
1418
1202
|
* ```
|
|
1419
1203
|
*/
|
|
1420
|
-
function
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
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
|
+
};
|
|
1439
1228
|
}
|
|
1440
|
-
return flatten(node, 0);
|
|
1441
1229
|
}
|
|
1442
|
-
//#endregion
|
|
1443
|
-
//#region src/core/parsers.ts
|
|
1444
1230
|
/**
|
|
1445
|
-
*
|
|
1446
|
-
*
|
|
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.
|
|
1447
1233
|
*
|
|
1448
|
-
* @param
|
|
1449
|
-
* @
|
|
1450
|
-
* @returns The parsed block nodes.
|
|
1234
|
+
* @param line - A blockquote line (per {@link isQuote})
|
|
1235
|
+
* @returns The line with its leading `>` (and one space) removed
|
|
1451
1236
|
*
|
|
1452
1237
|
* @example
|
|
1453
1238
|
* ```ts
|
|
1454
|
-
*
|
|
1239
|
+
* stripQuote('> text') // 'text'
|
|
1455
1240
|
* ```
|
|
1456
1241
|
*/
|
|
1457
|
-
function
|
|
1458
|
-
|
|
1459
|
-
element: "paragraph",
|
|
1460
|
-
children: [{
|
|
1461
|
-
element: "text",
|
|
1462
|
-
value: lines.join("\n")
|
|
1463
|
-
}]
|
|
1464
|
-
}] : [];
|
|
1465
|
-
const blocks = [];
|
|
1466
|
-
let index = 0;
|
|
1467
|
-
while (index < lines.length) {
|
|
1468
|
-
const line = lines[index] ?? "";
|
|
1469
|
-
if (isBlankLine(line)) {
|
|
1470
|
-
index += 1;
|
|
1471
|
-
continue;
|
|
1472
|
-
}
|
|
1473
|
-
const fence = extractFence(line);
|
|
1474
|
-
if (fence) {
|
|
1475
|
-
const body = [];
|
|
1476
|
-
index += 1;
|
|
1477
|
-
while (index < lines.length && !isFenceClose(lines[index] ?? "", fence.marker)) {
|
|
1478
|
-
body.push(lines[index] ?? "");
|
|
1479
|
-
index += 1;
|
|
1480
|
-
}
|
|
1481
|
-
index += 1;
|
|
1482
|
-
blocks.push({
|
|
1483
|
-
element: "codeBlock",
|
|
1484
|
-
...fence.lang === void 0 ? {} : { lang: fence.lang },
|
|
1485
|
-
code: body.join("\n")
|
|
1486
|
-
});
|
|
1487
|
-
continue;
|
|
1488
|
-
}
|
|
1489
|
-
if (isThematicBreak(line)) {
|
|
1490
|
-
blocks.push({ element: "thematicBreak" });
|
|
1491
|
-
index += 1;
|
|
1492
|
-
continue;
|
|
1493
|
-
}
|
|
1494
|
-
const heading = extractHeading(line);
|
|
1495
|
-
if (heading) {
|
|
1496
|
-
blocks.push({
|
|
1497
|
-
element: "heading",
|
|
1498
|
-
level: heading.level,
|
|
1499
|
-
children: parseInline(heading.text)
|
|
1500
|
-
});
|
|
1501
|
-
index += 1;
|
|
1502
|
-
continue;
|
|
1503
|
-
}
|
|
1504
|
-
if (isQuote(line)) {
|
|
1505
|
-
const quoted = [];
|
|
1506
|
-
while (index < lines.length && isQuote(lines[index] ?? "")) {
|
|
1507
|
-
quoted.push(stripQuote(lines[index] ?? ""));
|
|
1508
|
-
index += 1;
|
|
1509
|
-
}
|
|
1510
|
-
blocks.push({
|
|
1511
|
-
element: "blockquote",
|
|
1512
|
-
children: parseBlocks(quoted, depth + 1)
|
|
1513
|
-
});
|
|
1514
|
-
continue;
|
|
1515
|
-
}
|
|
1516
|
-
if (isTableStart(line, lines[index + 1])) {
|
|
1517
|
-
const table = collectTable(lines, index);
|
|
1518
|
-
blocks.push(table.node);
|
|
1519
|
-
index = table.next;
|
|
1520
|
-
continue;
|
|
1521
|
-
}
|
|
1522
|
-
if (extractListItem(line)) {
|
|
1523
|
-
const list = collectList(lines, index, depth);
|
|
1524
|
-
blocks.push(list.node);
|
|
1525
|
-
index = list.next;
|
|
1526
|
-
continue;
|
|
1527
|
-
}
|
|
1528
|
-
const paragraph = [];
|
|
1529
|
-
while (index < lines.length && !isBlankLine(lines[index] ?? "") && !((0, _orkestrel_contract.isNonEmptyArray)(paragraph) && startsBlock(lines, index))) {
|
|
1530
|
-
paragraph.push((lines[index] ?? "").trim());
|
|
1531
|
-
index += 1;
|
|
1532
|
-
}
|
|
1533
|
-
blocks.push({
|
|
1534
|
-
element: "paragraph",
|
|
1535
|
-
children: parseInline(paragraph.join("\n"))
|
|
1536
|
-
});
|
|
1537
|
-
}
|
|
1538
|
-
return blocks;
|
|
1242
|
+
function stripQuote(line) {
|
|
1243
|
+
return line.replace(/^\s{0,3}>\s?/, "");
|
|
1539
1244
|
}
|
|
1540
1245
|
/**
|
|
1541
|
-
*
|
|
1542
|
-
*
|
|
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.
|
|
1543
1249
|
*
|
|
1544
|
-
* @param
|
|
1545
|
-
* @
|
|
1546
|
-
* @returns The parsed table node and the index of the first line after it.
|
|
1250
|
+
* @param row - The raw table row line
|
|
1251
|
+
* @returns The row's cells, in column order
|
|
1547
1252
|
*
|
|
1548
1253
|
* @example
|
|
1549
1254
|
* ```ts
|
|
1550
|
-
*
|
|
1255
|
+
* splitTableRow('|a|b|') // ['a', 'b']
|
|
1551
1256
|
* ```
|
|
1552
1257
|
*/
|
|
1553
|
-
function
|
|
1554
|
-
const
|
|
1555
|
-
|
|
1556
|
-
const
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
rows.push(row);
|
|
1567
|
-
index += 1;
|
|
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;
|
|
1568
1271
|
}
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
rows,
|
|
1574
|
-
align: padded
|
|
1575
|
-
},
|
|
1576
|
-
next: index
|
|
1577
|
-
};
|
|
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;
|
|
1578
1276
|
}
|
|
1579
1277
|
/**
|
|
1580
|
-
*
|
|
1581
|
-
*
|
|
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`.
|
|
1582
1281
|
*
|
|
1583
|
-
* @param
|
|
1584
|
-
* @
|
|
1585
|
-
* @param depth - The current recursion depth (each item recurses at `depth + 1`).
|
|
1586
|
-
* @returns The parsed list node and the index of the first line after it.
|
|
1282
|
+
* @param delimiter - The table's delimiter row
|
|
1283
|
+
* @returns One alignment per column, in column order
|
|
1587
1284
|
*
|
|
1588
1285
|
* @example
|
|
1589
1286
|
* ```ts
|
|
1590
|
-
*
|
|
1287
|
+
* delimiterToAlignments('| :--- | ---: |') // ['left', 'right']
|
|
1591
1288
|
* ```
|
|
1592
1289
|
*/
|
|
1593
|
-
function
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
const itemLines = [parsed.content];
|
|
1604
|
-
const continuation = parsed.marker;
|
|
1605
|
-
index += 1;
|
|
1606
|
-
while (index < lines.length) {
|
|
1607
|
-
const next = lines[index] ?? "";
|
|
1608
|
-
if (isBlankLine(next)) {
|
|
1609
|
-
const after = lines[index + 1] ?? "";
|
|
1610
|
-
if (index + 1 < lines.length && !isBlankLine(after) && leadingIndent(after) >= continuation) {
|
|
1611
|
-
itemLines.push("");
|
|
1612
|
-
index += 1;
|
|
1613
|
-
continue;
|
|
1614
|
-
}
|
|
1615
|
-
break;
|
|
1616
|
-
}
|
|
1617
|
-
if (leadingIndent(next) >= continuation) {
|
|
1618
|
-
itemLines.push(next.slice(continuation));
|
|
1619
|
-
index += 1;
|
|
1620
|
-
continue;
|
|
1621
|
-
}
|
|
1622
|
-
if (extractListItem(next) || startsBlock(lines, index)) break;
|
|
1623
|
-
itemLines.push(next.trim());
|
|
1624
|
-
index += 1;
|
|
1625
|
-
}
|
|
1626
|
-
items.push({
|
|
1627
|
-
element: "listItem",
|
|
1628
|
-
children: parseBlocks(itemLines, depth + 1)
|
|
1629
|
-
});
|
|
1630
|
-
}
|
|
1631
|
-
return {
|
|
1632
|
-
node: {
|
|
1633
|
-
element: "list",
|
|
1634
|
-
ordered,
|
|
1635
|
-
start: startOrdinal,
|
|
1636
|
-
items
|
|
1637
|
-
},
|
|
1638
|
-
next: index
|
|
1639
|
-
};
|
|
1640
|
-
}
|
|
1641
|
-
/**
|
|
1642
|
-
* Parses a markdown string into a typed {@link MarkdownDocument} AST via the
|
|
1643
|
-
* block phase.
|
|
1644
|
-
*
|
|
1645
|
-
* @param markdown - The markdown source to parse.
|
|
1646
|
-
* @returns The parsed document.
|
|
1647
|
-
*/
|
|
1648
|
-
function parseDocument(markdown) {
|
|
1649
|
-
return {
|
|
1650
|
-
element: "document",
|
|
1651
|
-
children: parseBlocks(splitLines(markdown), 0)
|
|
1652
|
-
};
|
|
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
|
+
});
|
|
1653
1300
|
}
|
|
1654
1301
|
/**
|
|
1655
|
-
*
|
|
1656
|
-
*
|
|
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).
|
|
1657
1307
|
*
|
|
1658
|
-
* @param
|
|
1659
|
-
* @
|
|
1660
|
-
|
|
1661
|
-
function parseInline(text) {
|
|
1662
|
-
return coalesceText(scanInline(text, 0, text.length));
|
|
1663
|
-
}
|
|
1664
|
-
//#endregion
|
|
1665
|
-
//#region src/core/shapers.ts
|
|
1666
|
-
/**
|
|
1667
|
-
* The shape of a {@link TextNode} - a plain-text leaf inline run.
|
|
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
|
|
1668
1311
|
*
|
|
1669
1312
|
* @example
|
|
1670
1313
|
* ```ts
|
|
1671
|
-
*
|
|
1672
|
-
* import { textShape } from '@src/core'
|
|
1673
|
-
*
|
|
1674
|
-
* const text = createContract(textShape)
|
|
1675
|
-
* text.is({ element: 'text', value: 'hi' }) // true
|
|
1314
|
+
* startsBlock(['text', '## Heading'], 1) // true
|
|
1676
1315
|
* ```
|
|
1677
1316
|
*/
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
}
|
|
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
|
+
}
|
|
1682
1321
|
/**
|
|
1683
|
-
*
|
|
1684
|
-
*
|
|
1685
|
-
* @example
|
|
1686
|
-
* ```ts
|
|
1687
|
-
* import { createContract } from '@orkestrel/contract'
|
|
1688
|
-
* import { codeSpanShape } from '@src/core'
|
|
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.
|
|
1689
1324
|
*
|
|
1690
|
-
*
|
|
1691
|
-
*
|
|
1692
|
-
* ```
|
|
1693
|
-
*/
|
|
1694
|
-
var codeSpanShape = (0, _orkestrel_contract.objectShape)({
|
|
1695
|
-
element: (0, _orkestrel_contract.literalShape)(["codeSpan"]),
|
|
1696
|
-
value: (0, _orkestrel_contract.stringShape)()
|
|
1697
|
-
});
|
|
1698
|
-
/**
|
|
1699
|
-
* The shape of a {@link CodeBlockNode} - a fenced code block. `lang` is
|
|
1700
|
-
* optional (absent when the opening fence carries no info-string).
|
|
1325
|
+
* @param text - The raw text possibly carrying `\x` escapes
|
|
1326
|
+
* @returns The text with escapable `\x` reduced to `x`
|
|
1701
1327
|
*
|
|
1702
1328
|
* @example
|
|
1703
1329
|
* ```ts
|
|
1704
|
-
*
|
|
1705
|
-
* import { codeBlockShape } from '@src/core'
|
|
1706
|
-
*
|
|
1707
|
-
* const codeBlock = createContract(codeBlockShape)
|
|
1708
|
-
* codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
|
|
1709
|
-
* codeBlock.is({ element: 'codeBlock', code: 'x', lang: 'ts' }) // true
|
|
1330
|
+
* unescapeText('\\*hi\\*') // '*hi*'
|
|
1710
1331
|
* ```
|
|
1711
1332
|
*/
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
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
|
+
}
|
|
1717
1344
|
/**
|
|
1718
|
-
*
|
|
1719
|
-
*
|
|
1720
|
-
*
|
|
1721
|
-
* @example
|
|
1722
|
-
* ```ts
|
|
1723
|
-
* import { createContract } from '@orkestrel/contract'
|
|
1724
|
-
* import { thematicBreakShape } from '@src/core'
|
|
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.
|
|
1725
1347
|
*
|
|
1726
|
-
*
|
|
1727
|
-
*
|
|
1728
|
-
* ```
|
|
1729
|
-
*/
|
|
1730
|
-
var thematicBreakShape = (0, _orkestrel_contract.objectShape)({ element: (0, _orkestrel_contract.literalShape)(["thematicBreak"]) });
|
|
1731
|
-
/**
|
|
1732
|
-
* The shape of a {@link TableAlign} - the per-column GFM table alignment
|
|
1733
|
-
* literal.
|
|
1348
|
+
* @param nodes - The inline nodes (possibly with adjacent text runs)
|
|
1349
|
+
* @returns The nodes with consecutive text nodes concatenated
|
|
1734
1350
|
*
|
|
1735
1351
|
* @example
|
|
1736
1352
|
* ```ts
|
|
1737
|
-
*
|
|
1738
|
-
*
|
|
1739
|
-
*
|
|
1740
|
-
* const tableAlign = createContract(tableAlignShape)
|
|
1741
|
-
* tableAlign.is('left') // true
|
|
1742
|
-
* tableAlign.is('center') // true
|
|
1743
|
-
* tableAlign.is('top') // false
|
|
1353
|
+
* coalesceText([{ element: 'text', value: 'a' }, { element: 'text', value: 'b' }])
|
|
1354
|
+
* // [{ element: 'text', value: 'ab' }]
|
|
1744
1355
|
* ```
|
|
1745
1356
|
*/
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
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
|
+
}
|
|
1752
1369
|
/**
|
|
1753
|
-
*
|
|
1754
|
-
*
|
|
1755
|
-
*
|
|
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`
|
|
1756
1379
|
*
|
|
1757
1380
|
* @example
|
|
1758
1381
|
* ```ts
|
|
1759
|
-
*
|
|
1760
|
-
* import { listItemPartsShape } from '@src/core'
|
|
1761
|
-
*
|
|
1762
|
-
* const listItemParts = createContract(listItemPartsShape)
|
|
1763
|
-
* listItemParts.is({ ordered: false, start: 1, content: 'hi', indent: 0, marker: 2 }) // true
|
|
1382
|
+
* scanCode('`code`', 0, 6) // { value: 'code', end: 6 }
|
|
1764
1383
|
* ```
|
|
1765
1384
|
*/
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
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
|
+
}
|
|
1775
1404
|
/**
|
|
1776
|
-
*
|
|
1777
|
-
*
|
|
1778
|
-
*
|
|
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 `[`).
|
|
1779
1409
|
*
|
|
1780
|
-
* @
|
|
1781
|
-
* -
|
|
1782
|
-
*
|
|
1783
|
-
*
|
|
1784
|
-
*
|
|
1785
|
-
*
|
|
1786
|
-
*
|
|
1787
|
-
* - **Traversal order.** {@link walk} and the `find` / `filter` / `reduce` queries built
|
|
1788
|
-
* on it walk the AST depth-first, pre-order, root-inclusive (via {@link walkNodes});
|
|
1789
|
-
* `stream` is shallow - only the document's direct block children.
|
|
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`
|
|
1790
1417
|
*
|
|
1791
1418
|
* @example
|
|
1792
1419
|
* ```ts
|
|
1793
|
-
*
|
|
1794
|
-
*
|
|
1795
|
-
* const markdown = new Markdown('# Title\n\nA **bold** [link](https://x.dev).')
|
|
1796
|
-
* const heading = markdown.find(isHeadingNode) // the HeadingNode, or undefined
|
|
1797
|
-
* const shouted = markdown.map((node) =>
|
|
1798
|
-
* node.element === 'text' ? { element: 'text', value: node.value.toUpperCase() } : node,
|
|
1799
|
-
* )
|
|
1800
|
-
* renderMarkdown(shouted.document) // '# TITLE\n\nA **BOLD** [LINK](https://x.dev).'
|
|
1420
|
+
* scanLink('[text](url)', 0, 11)
|
|
1421
|
+
* // { node: { element: 'link', href: 'url', children: [...] }, end: 11 }
|
|
1801
1422
|
* ```
|
|
1802
1423
|
*/
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
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;
|
|
1431
|
+
continue;
|
|
1432
|
+
}
|
|
1433
|
+
if (character === "[") bracketDepth += 1;
|
|
1434
|
+
else if (character === "]") {
|
|
1435
|
+
bracketDepth -= 1;
|
|
1436
|
+
if (bracketDepth === 0) {
|
|
1437
|
+
close = index;
|
|
1438
|
+
break;
|
|
1439
|
+
}
|
|
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;
|
|
1456
|
+
break;
|
|
1457
|
+
}
|
|
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;
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
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;
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
/**
|
|
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).
|
|
1541
|
+
*
|
|
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)
|
|
1551
|
+
*
|
|
1552
|
+
* @example
|
|
1553
|
+
* ```ts
|
|
1554
|
+
* scanInline('hi *there*', 0, 10) // [{ element: 'text', value: 'hi ' }, { element: 'emphasis', ... }]
|
|
1555
|
+
* ```
|
|
1556
|
+
*/
|
|
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 = "";
|
|
1582
|
+
}
|
|
1583
|
+
nodes.push({ element: "break" });
|
|
1584
|
+
index = spaceEnd + 1;
|
|
1585
|
+
continue;
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
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;
|
|
1636
|
+
}
|
|
1637
|
+
pending += character;
|
|
1638
|
+
index += 1;
|
|
1639
|
+
}
|
|
1640
|
+
if (pending.length > 0) nodes.push({
|
|
1641
|
+
element: "text",
|
|
1642
|
+
value: pending
|
|
1643
|
+
});
|
|
1644
|
+
return nodes;
|
|
1645
|
+
}
|
|
1646
|
+
/**
|
|
1647
|
+
* Project a {@link MarkdownNode} into an unsanitized {@link HTMLDocument}.
|
|
1648
|
+
*
|
|
1649
|
+
* @remarks
|
|
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.
|
|
1655
|
+
*
|
|
1656
|
+
* @param node - The markdown document or bare node to project
|
|
1657
|
+
* @returns An unsanitized HTML document wrapping the projected node or nodes
|
|
1658
|
+
*
|
|
1659
|
+
* @example
|
|
1660
|
+
* ```ts
|
|
1661
|
+
* markdownToHTML({ element: 'text', value: 'a & b' })
|
|
1662
|
+
* // { category: 'document', children: [{ category: 'text', value: 'a & b' }] }
|
|
1663
|
+
* ```
|
|
1664
|
+
*/
|
|
1665
|
+
function markdownToHTML(node) {
|
|
1666
|
+
const stack = [{
|
|
1667
|
+
node,
|
|
1668
|
+
depth: 0,
|
|
1669
|
+
expanded: false,
|
|
1670
|
+
count: 0
|
|
1671
|
+
}];
|
|
1672
|
+
const values = [];
|
|
1673
|
+
while (stack.length > 0) {
|
|
1674
|
+
const frame = stack.pop();
|
|
1675
|
+
if (frame === void 0) continue;
|
|
1676
|
+
const current = frame.node;
|
|
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
|
+
}
|
|
1685
|
+
const children = [];
|
|
1686
|
+
let depth = frame.depth;
|
|
1687
|
+
switch (current.element) {
|
|
1688
|
+
case "document":
|
|
1689
|
+
for (const child of current.children) if (child !== void 0) children.push(child);
|
|
1690
|
+
break;
|
|
1691
|
+
case "heading":
|
|
1692
|
+
case "paragraph":
|
|
1693
|
+
case "blockquote":
|
|
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
|
+
}
|
|
1705
|
+
case "emphasis":
|
|
1706
|
+
case "link":
|
|
1707
|
+
for (const child of current.children) if (child !== void 0) children.push(child);
|
|
1708
|
+
depth += 1;
|
|
1709
|
+
break;
|
|
1710
|
+
case "list":
|
|
1711
|
+
for (const child of current.items) if (child !== void 0) children.push(child);
|
|
1712
|
+
depth += 1;
|
|
1713
|
+
break;
|
|
1714
|
+
case "table":
|
|
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) {
|
|
1720
|
+
for (const child of cell) if (child !== void 0) children.push(child);
|
|
1721
|
+
}
|
|
1722
|
+
for (const row of current.rows) if (row !== void 0) {
|
|
1723
|
+
for (const cell of row) if (cell !== void 0) {
|
|
1724
|
+
for (const child of cell) if (child !== void 0) children.push(child);
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
depth += 4;
|
|
1728
|
+
}
|
|
1729
|
+
if (current.element === "codeBlock" && frame.depth + 2 > 64) {
|
|
1730
|
+
values.push(void 0);
|
|
1731
|
+
continue;
|
|
1732
|
+
}
|
|
1733
|
+
stack.push({
|
|
1734
|
+
...frame,
|
|
1735
|
+
expanded: true,
|
|
1736
|
+
count: children.length
|
|
1737
|
+
});
|
|
1738
|
+
for (let index = children.length - 1; index >= 0; index -= 1) {
|
|
1739
|
+
const child = children[index];
|
|
1740
|
+
if (child !== void 0) stack.push({
|
|
1741
|
+
node: child,
|
|
1742
|
+
depth,
|
|
1743
|
+
expanded: false,
|
|
1744
|
+
count: 0
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
continue;
|
|
1748
|
+
}
|
|
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);
|
|
1752
|
+
let value;
|
|
1753
|
+
switch (current.element) {
|
|
1754
|
+
case "document":
|
|
1755
|
+
value = {
|
|
1756
|
+
category: "document",
|
|
1757
|
+
children: projected
|
|
1758
|
+
};
|
|
1759
|
+
break;
|
|
1760
|
+
case "heading":
|
|
1761
|
+
value = {
|
|
1762
|
+
category: "element",
|
|
1763
|
+
name: `h${current.level}`,
|
|
1764
|
+
attributes: [],
|
|
1765
|
+
children: projected
|
|
1766
|
+
};
|
|
1767
|
+
break;
|
|
1768
|
+
case "paragraph":
|
|
1769
|
+
value = {
|
|
1770
|
+
category: "element",
|
|
1771
|
+
name: "p",
|
|
1772
|
+
attributes: [],
|
|
1773
|
+
children: projected
|
|
1774
|
+
};
|
|
1775
|
+
break;
|
|
1776
|
+
case "thematicBreak":
|
|
1777
|
+
value = {
|
|
1778
|
+
category: "element",
|
|
1779
|
+
name: "hr",
|
|
1780
|
+
attributes: [],
|
|
1781
|
+
children: []
|
|
1782
|
+
};
|
|
1783
|
+
break;
|
|
1784
|
+
case "blockquote":
|
|
1785
|
+
value = {
|
|
1786
|
+
category: "element",
|
|
1787
|
+
name: "blockquote",
|
|
1788
|
+
attributes: [],
|
|
1789
|
+
children: projected
|
|
1790
|
+
};
|
|
1791
|
+
break;
|
|
1792
|
+
case "codeBlock":
|
|
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
|
+
};
|
|
1810
|
+
break;
|
|
1811
|
+
case "list":
|
|
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
|
+
};
|
|
1821
|
+
break;
|
|
1822
|
+
case "listItem":
|
|
1823
|
+
value = {
|
|
1824
|
+
category: "element",
|
|
1825
|
+
name: "li",
|
|
1826
|
+
attributes: [],
|
|
1827
|
+
children: projected
|
|
1828
|
+
};
|
|
1829
|
+
break;
|
|
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
|
+
};
|
|
1904
|
+
break;
|
|
1905
|
+
}
|
|
1906
|
+
case "text":
|
|
1907
|
+
value = {
|
|
1908
|
+
category: "text",
|
|
1909
|
+
value: current.value
|
|
1910
|
+
};
|
|
1911
|
+
break;
|
|
1912
|
+
case "emphasis":
|
|
1913
|
+
value = {
|
|
1914
|
+
category: "element",
|
|
1915
|
+
name: current.strong ? "strong" : "em",
|
|
1916
|
+
attributes: [],
|
|
1917
|
+
children: projected
|
|
1918
|
+
};
|
|
1919
|
+
break;
|
|
1920
|
+
case "codeSpan":
|
|
1921
|
+
value = {
|
|
1922
|
+
category: "element",
|
|
1923
|
+
name: "code",
|
|
1924
|
+
attributes: [],
|
|
1925
|
+
children: [{
|
|
1926
|
+
category: "text",
|
|
1927
|
+
value: current.value
|
|
1928
|
+
}]
|
|
1929
|
+
};
|
|
1930
|
+
break;
|
|
1931
|
+
case "link":
|
|
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
|
+
};
|
|
1963
|
+
break;
|
|
1964
|
+
default: value = void 0;
|
|
1965
|
+
}
|
|
1966
|
+
values.push(value);
|
|
1967
|
+
}
|
|
1968
|
+
const projected = values[0];
|
|
1969
|
+
if (projected?.category === "document") return projected;
|
|
1970
|
+
return {
|
|
1971
|
+
category: "document",
|
|
1972
|
+
children: projected === void 0 ? [] : [projected]
|
|
1973
|
+
};
|
|
1974
|
+
}
|
|
1975
|
+
/**
|
|
1976
|
+
* Render a {@link MarkdownNode} to sanitized canonical HTML.
|
|
1977
|
+
*
|
|
1978
|
+
* @remarks
|
|
1979
|
+
* Markdown widens `@orkestrel/html`'s attribute floor by exactly `src`, because image
|
|
1980
|
+
* syntax is meaningless without its source. `src` is still a URL attribute, so the
|
|
1981
|
+
* floor refuses `javascript:`, `data:`, `vbscript:`, and `file:` values. A stricter
|
|
1982
|
+
* consumer can compose {@link markdownToHTML} with `@orkestrel/html`'s `HTML` class
|
|
1983
|
+
* directly.
|
|
1984
|
+
*
|
|
1985
|
+
* @param node - The markdown document or bare node to render
|
|
1986
|
+
* @returns Sanitized canonical HTML
|
|
1987
|
+
*
|
|
1988
|
+
* @example
|
|
1989
|
+
* ```ts
|
|
1990
|
+
* renderHTML({ element: 'paragraph', children: [{ element: 'text', value: 'a & b' }] })
|
|
1991
|
+
* // '<p>a & 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, `` 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
|
|
2017
|
+
*
|
|
2018
|
+
* @example
|
|
2019
|
+
* ```ts
|
|
2020
|
+
* renderMarkdown({ element: 'document', children: [
|
|
2021
|
+
* { element: 'heading', level: 2, children: [{ element: 'text', value: 'Hi' }] },
|
|
2022
|
+
* ] })
|
|
2023
|
+
* // '## Hi'
|
|
2024
|
+
* ```
|
|
2025
|
+
*/
|
|
2026
|
+
function renderMarkdown(node) {
|
|
2027
|
+
const stack = [{
|
|
2028
|
+
node,
|
|
2029
|
+
depth: 0,
|
|
2030
|
+
expanded: false,
|
|
2031
|
+
count: 0,
|
|
2032
|
+
escaped: "",
|
|
2033
|
+
escapeBang: false,
|
|
2034
|
+
nesting: 0
|
|
2035
|
+
}];
|
|
2036
|
+
const values = [];
|
|
2037
|
+
while (stack.length > 0) {
|
|
2038
|
+
const frame = stack.pop();
|
|
2039
|
+
if (frame === void 0) continue;
|
|
2040
|
+
const current = frame.node;
|
|
2041
|
+
if (!frame.expanded) {
|
|
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);
|
|
2082
|
+
continue;
|
|
2083
|
+
}
|
|
2084
|
+
const groups = [];
|
|
2085
|
+
const adjacent = [];
|
|
2086
|
+
let depth = frame.depth + 1;
|
|
2087
|
+
switch (current.element) {
|
|
2088
|
+
case "document":
|
|
2089
|
+
case "blockquote":
|
|
2090
|
+
case "listItem":
|
|
2091
|
+
groups.push(current.children);
|
|
2092
|
+
adjacent.push(false);
|
|
2093
|
+
break;
|
|
2094
|
+
case "heading":
|
|
2095
|
+
case "paragraph":
|
|
2096
|
+
case "emphasis":
|
|
2097
|
+
case "link":
|
|
2098
|
+
case "image":
|
|
2099
|
+
groups.push(current.children);
|
|
2100
|
+
adjacent.push(true);
|
|
2101
|
+
break;
|
|
2102
|
+
case "list":
|
|
2103
|
+
groups.push(current.items);
|
|
2104
|
+
adjacent.push(false);
|
|
2105
|
+
break;
|
|
2106
|
+
case "table":
|
|
2107
|
+
for (const cell of current.header) if (cell !== void 0) {
|
|
2108
|
+
groups.push(cell);
|
|
2109
|
+
adjacent.push(true);
|
|
2110
|
+
}
|
|
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
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
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
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
stack.push({
|
|
2146
|
+
...frame,
|
|
2147
|
+
expanded: true,
|
|
2148
|
+
count: children.length,
|
|
2149
|
+
escaped
|
|
2150
|
+
});
|
|
2151
|
+
const nesting = current.element === "emphasis" ? frame.nesting + 1 : frame.nesting;
|
|
2152
|
+
for (let index = children.length - 1; index >= 0; index -= 1) {
|
|
2153
|
+
const child = children[index];
|
|
2154
|
+
if (child !== void 0) stack.push({
|
|
2155
|
+
node: child,
|
|
2156
|
+
depth,
|
|
2157
|
+
expanded: false,
|
|
2158
|
+
count: 0,
|
|
2159
|
+
escaped: "",
|
|
2160
|
+
escapeBang: escapeBangs[index] === true && depth < 64,
|
|
2161
|
+
nesting
|
|
2162
|
+
});
|
|
2163
|
+
}
|
|
2164
|
+
continue;
|
|
2165
|
+
}
|
|
2166
|
+
const children = frame.count === 0 ? [] : values.splice(values.length - frame.count, frame.count);
|
|
2167
|
+
let value = "";
|
|
2168
|
+
switch (current.element) {
|
|
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;
|
|
2182
|
+
}
|
|
2183
|
+
const pad = current.value.startsWith("`") || current.value.endsWith("`") ? " " : "";
|
|
2184
|
+
value = `${fence}${pad}${current.value}${pad}${fence}`;
|
|
2185
|
+
break;
|
|
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;
|
|
2209
|
+
case "list": {
|
|
2210
|
+
const items = [];
|
|
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"));
|
|
2221
|
+
}
|
|
2222
|
+
value = items.join("\n");
|
|
2223
|
+
break;
|
|
2224
|
+
}
|
|
2225
|
+
case "listItem":
|
|
2226
|
+
value = children.join("\n\n");
|
|
2227
|
+
break;
|
|
2228
|
+
case "table": {
|
|
2229
|
+
let offset = 0;
|
|
2230
|
+
const header = [];
|
|
2231
|
+
for (const cell of current.header) {
|
|
2232
|
+
if (cell === void 0) {
|
|
2233
|
+
header.push("");
|
|
2234
|
+
continue;
|
|
2235
|
+
}
|
|
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;
|
|
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
|
+
});
|
|
2248
|
+
const rows = [];
|
|
2249
|
+
for (const row of current.rows) {
|
|
2250
|
+
const cells = [];
|
|
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;
|
|
2256
|
+
}
|
|
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;
|
|
2261
|
+
}
|
|
2262
|
+
rows.push(`| ${cells.join(" | ")} |`);
|
|
2263
|
+
}
|
|
2264
|
+
value = [
|
|
2265
|
+
`| ${header.join(" | ")} |`,
|
|
2266
|
+
`| ${delimiter.join(" | ")} |`,
|
|
2267
|
+
...rows
|
|
2268
|
+
].join("\n");
|
|
2269
|
+
break;
|
|
2270
|
+
}
|
|
2271
|
+
case "text":
|
|
2272
|
+
value = frame.escaped;
|
|
2273
|
+
break;
|
|
2274
|
+
case "emphasis": {
|
|
2275
|
+
const marker = frame.nesting % 2 === 0 ? current.strong ? "**" : "*" : current.strong ? "__" : "_";
|
|
2276
|
+
value = `${marker}${children.join("")}${marker}`;
|
|
2277
|
+
break;
|
|
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})`;
|
|
2283
|
+
break;
|
|
2284
|
+
}
|
|
2285
|
+
default: value = "";
|
|
2286
|
+
}
|
|
2287
|
+
if (stack.length === 0) return value;
|
|
2288
|
+
values.push(value);
|
|
1811
2289
|
}
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
2290
|
+
return "";
|
|
2291
|
+
}
|
|
2292
|
+
/**
|
|
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.
|
|
2296
|
+
*
|
|
2297
|
+
* @remarks
|
|
2298
|
+
* Markdown trims every line of a paragraph, a heading's text, and a table cell, so an
|
|
2299
|
+
* untrimmed run would come back from a re-parse a different AST. Expects a coalesced
|
|
2300
|
+
* run (see {@link coalesceText}): only the outermost node on each side is examined.
|
|
2301
|
+
*
|
|
2302
|
+
* @param nodes - The inline run to trim
|
|
2303
|
+
* @returns The run with its edge whitespace removed
|
|
2304
|
+
*
|
|
2305
|
+
* @example
|
|
2306
|
+
* ```ts
|
|
2307
|
+
* trimInlines([{ element: 'text', value: ' a ' }]) // [{ element: 'text', value: 'a' }]
|
|
2308
|
+
* ```
|
|
2309
|
+
*/
|
|
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
|
+
};
|
|
1831
2321
|
}
|
|
1832
|
-
|
|
1833
|
-
|
|
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
|
+
};
|
|
1834
2330
|
}
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
2331
|
+
return out;
|
|
2332
|
+
}
|
|
2333
|
+
/**
|
|
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.
|
|
2337
|
+
*
|
|
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
|
|
2350
|
+
*
|
|
2351
|
+
* @example
|
|
2352
|
+
* ```ts
|
|
2353
|
+
* normalizeInlines([{ element: 'break' }, { element: 'text', value: 'a' }], true)
|
|
2354
|
+
* // [{ element: 'text', value: 'a' }] - a leading break has no line to end
|
|
2355
|
+
* ```
|
|
2356
|
+
*/
|
|
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({
|
|
2362
|
+
element: "text",
|
|
2363
|
+
value: " "
|
|
2364
|
+
});
|
|
2365
|
+
else spent.push(node);
|
|
1839
2366
|
}
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
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
|
|
2376
|
+
});
|
|
2377
|
+
continue;
|
|
2378
|
+
}
|
|
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);
|
|
2391
|
+
continue;
|
|
2392
|
+
}
|
|
2393
|
+
out.push(node);
|
|
1843
2394
|
}
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
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
|
|
2443
|
+
});
|
|
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);
|
|
2451
|
+
continue;
|
|
2452
|
+
}
|
|
2453
|
+
if ((0, _orkestrel_contract.isNonEmptyArray)(child.rows)) {
|
|
2454
|
+
if ((0, _orkestrel_contract.isNonEmptyArray)(cells)) {
|
|
2455
|
+
rows.push([...cells]);
|
|
2456
|
+
cells.length = 0;
|
|
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);
|
|
1849
2462
|
}
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
2463
|
+
if ((0, _orkestrel_contract.isNonEmptyArray)(rows) && (0, _orkestrel_contract.isNonEmptyArray)(cells)) {
|
|
2464
|
+
rows.push([...cells]);
|
|
2465
|
+
cells.length = 0;
|
|
1853
2466
|
}
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
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;
|
|
2511
|
+
blocks.push({
|
|
2512
|
+
element: "paragraph",
|
|
2513
|
+
children: cell.inlines
|
|
2514
|
+
});
|
|
2515
|
+
}
|
|
1885
2516
|
}
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
2517
|
+
for (const cell of projection.cells) {
|
|
2518
|
+
if (cell === void 0 || !(0, _orkestrel_contract.isNonEmptyArray)(cell.inlines)) continue;
|
|
2519
|
+
blocks.push({
|
|
2520
|
+
element: "paragraph",
|
|
2521
|
+
children: cell.inlines
|
|
2522
|
+
});
|
|
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
|
+
});
|
|
2529
|
+
return blocks;
|
|
2530
|
+
}
|
|
1889
2531
|
/**
|
|
1890
|
-
*
|
|
1891
|
-
*
|
|
1892
|
-
* {@link MarkdownInterface} exposes.
|
|
2532
|
+
* Read a projection as INLINE content - the view a link, an emphasis, and a table cell
|
|
2533
|
+
* each need.
|
|
1893
2534
|
*
|
|
1894
2535
|
* @remarks
|
|
1895
|
-
*
|
|
1896
|
-
*
|
|
1897
|
-
*
|
|
1898
|
-
*
|
|
1899
|
-
* value with `isMarkdownDocument` first. Pure + total parse (malformed markdown
|
|
1900
|
-
* degrades to text, never throws) and zero-dependency - a hand-written scanner, no
|
|
1901
|
-
* regex-only structural parse, linear-time (no ReDoS).
|
|
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.
|
|
1902
2540
|
*
|
|
1903
|
-
* @param
|
|
1904
|
-
* @returns
|
|
2541
|
+
* @param projection - The projection to read
|
|
2542
|
+
* @returns Its inline content
|
|
1905
2543
|
*
|
|
1906
2544
|
* @example
|
|
1907
2545
|
* ```ts
|
|
1908
|
-
*
|
|
2546
|
+
* projectionToInlines(createProjection({ inlines: [{ element: 'break' }] }))
|
|
2547
|
+
* // [{ element: 'break' }]
|
|
2548
|
+
* ```
|
|
2549
|
+
*/
|
|
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
|
+
}];
|
|
2557
|
+
}
|
|
2558
|
+
/**
|
|
2559
|
+
* Project one HTML leaf - a text node, a comment, or a doctype - to its
|
|
2560
|
+
* {@link MarkdownProjection}.
|
|
1909
2561
|
*
|
|
1910
|
-
*
|
|
1911
|
-
*
|
|
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
|
|
2570
|
+
*
|
|
2571
|
+
* @example
|
|
2572
|
+
* ```ts
|
|
2573
|
+
* projectHTMLLeaf({ category: 'text', value: 'a\n b' }).inlines
|
|
2574
|
+
* // [{ element: 'text', value: 'a b' }]
|
|
2575
|
+
* ```
|
|
2576
|
+
*/
|
|
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
|
+
});
|
|
2684
|
+
}
|
|
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))
|
|
2760
|
+
}]
|
|
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({
|
|
2784
|
+
element: "listItem",
|
|
2785
|
+
children: blocks
|
|
2786
|
+
});
|
|
2787
|
+
continue;
|
|
2788
|
+
}
|
|
2789
|
+
if ((0, _orkestrel_contract.isNonEmptyArray)(blocks)) items.push({
|
|
2790
|
+
element: "listItem",
|
|
2791
|
+
children: blocks
|
|
2792
|
+
});
|
|
2793
|
+
}
|
|
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
|
+
});
|
|
2806
|
+
}
|
|
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);
|
|
2843
|
+
continue;
|
|
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;
|
|
2855
|
+
break;
|
|
2856
|
+
}
|
|
2857
|
+
if (position === void 0) for (const [index, structural] of rowed.entries()) {
|
|
2858
|
+
if (!structural) continue;
|
|
2859
|
+
position = index;
|
|
2860
|
+
break;
|
|
2861
|
+
}
|
|
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
|
+
});
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
return merged;
|
|
2894
|
+
}
|
|
2895
|
+
/**
|
|
2896
|
+
* Project an `@orkestrel/html` {@link HTMLNode} into a {@link MarkdownDocument} - the
|
|
2897
|
+
* HTML→markdown direction, and the inverse of {@link markdownToHTML}.
|
|
2898
|
+
*
|
|
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
|
|
2930
|
+
*
|
|
2931
|
+
* @example
|
|
2932
|
+
* ```ts
|
|
2933
|
+
* import { parseDocument } from '@orkestrel/html'
|
|
2934
|
+
*
|
|
2935
|
+
* htmlToMarkdown(parseDocument('<h1>Title</h1>'))
|
|
2936
|
+
* // { element: 'document', children: [{ element: 'heading', level: 1, children: [...] }] }
|
|
1912
2937
|
* ```
|
|
1913
2938
|
*/
|
|
1914
|
-
function
|
|
1915
|
-
return
|
|
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
|
+
};
|
|
1916
2950
|
}
|
|
1917
2951
|
/**
|
|
1918
|
-
*
|
|
1919
|
-
*
|
|
1920
|
-
*
|
|
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.
|
|
1921
2955
|
*
|
|
1922
|
-
* @
|
|
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.
|
|
2960
|
+
*
|
|
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
|
|
1923
2963
|
*
|
|
1924
2964
|
* @example
|
|
1925
2965
|
* ```ts
|
|
1926
|
-
*
|
|
1927
|
-
*
|
|
1928
|
-
* const text = createTextContract()
|
|
1929
|
-
* text.is({ element: 'text', value: 'hi' }) // true
|
|
2966
|
+
* const doc = { element: 'document', children: [{ element: 'thematicBreak' }] } as const
|
|
2967
|
+
* [...walkNodes(doc)].map((node) => node.element) // ['document', 'thematicBreak']
|
|
1930
2968
|
* ```
|
|
1931
2969
|
*/
|
|
1932
|
-
function
|
|
1933
|
-
|
|
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
|
+
}
|
|
1934
3013
|
}
|
|
1935
3014
|
/**
|
|
1936
|
-
*
|
|
1937
|
-
*
|
|
1938
|
-
*
|
|
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.
|
|
1939
3018
|
*
|
|
1940
|
-
* @
|
|
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.
|
|
3027
|
+
*
|
|
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`
|
|
1941
3035
|
*
|
|
1942
3036
|
* @example
|
|
1943
3037
|
* ```ts
|
|
1944
|
-
*
|
|
1945
|
-
*
|
|
1946
|
-
*
|
|
1947
|
-
*
|
|
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
|
|
1948
3043
|
* ```
|
|
1949
3044
|
*/
|
|
1950
|
-
function
|
|
1951
|
-
|
|
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
|
+
}
|
|
1952
3165
|
}
|
|
1953
3166
|
/**
|
|
1954
|
-
*
|
|
1955
|
-
*
|
|
1956
|
-
*
|
|
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.
|
|
1957
3171
|
*
|
|
1958
|
-
* @
|
|
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.
|
|
3180
|
+
*
|
|
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.
|
|
3186
|
+
*
|
|
3187
|
+
* @param document - The document AST to rewrite
|
|
3188
|
+
* @param rewrite - The bottom-up {@link MarkdownRewriteHandler}
|
|
3189
|
+
* @returns A new, rewritten {@link MarkdownDocument}
|
|
1959
3190
|
*
|
|
1960
3191
|
* @example
|
|
1961
3192
|
* ```ts
|
|
1962
|
-
*
|
|
1963
|
-
*
|
|
1964
|
-
*
|
|
1965
|
-
* codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
|
|
3193
|
+
* rewriteDocument(document, (node) =>
|
|
3194
|
+
* node.element === 'text' ? { element: 'text', value: node.value.toUpperCase() } : node,
|
|
3195
|
+
* )
|
|
1966
3196
|
* ```
|
|
1967
3197
|
*/
|
|
1968
|
-
function
|
|
1969
|
-
|
|
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;
|
|
3316
|
+
}
|
|
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);
|
|
3417
|
+
}
|
|
3418
|
+
return {
|
|
3419
|
+
element: "document",
|
|
3420
|
+
children: [...document.children]
|
|
3421
|
+
};
|
|
1970
3422
|
}
|
|
1971
3423
|
/**
|
|
1972
|
-
*
|
|
1973
|
-
*
|
|
1974
|
-
*
|
|
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).
|
|
1975
3428
|
*
|
|
1976
|
-
* @
|
|
3429
|
+
* @remarks
|
|
3430
|
+
* Total: never throws. Descent stops at {@link MAX_DEPTH} (contributes `''` past the
|
|
3431
|
+
* cap instead of recursing further).
|
|
3432
|
+
*
|
|
3433
|
+
* @param node - The AST node to flatten (a full document, or any sub-node)
|
|
3434
|
+
* @returns The concatenated text content
|
|
1977
3435
|
*
|
|
1978
3436
|
* @example
|
|
1979
3437
|
* ```ts
|
|
1980
|
-
*
|
|
1981
|
-
*
|
|
1982
|
-
*
|
|
1983
|
-
*
|
|
3438
|
+
* flattenText({ element: 'paragraph', children: [
|
|
3439
|
+
* { element: 'text', value: 'a ' },
|
|
3440
|
+
* { element: 'codeSpan', value: 'b' },
|
|
3441
|
+
* ] })
|
|
3442
|
+
* // 'a b'
|
|
1984
3443
|
* ```
|
|
1985
3444
|
*/
|
|
1986
|
-
function
|
|
1987
|
-
|
|
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;
|
|
1988
3495
|
}
|
|
1989
3496
|
//#endregion
|
|
3497
|
+
exports.EMPTY_PROJECTION = EMPTY_PROJECTION;
|
|
1990
3498
|
exports.MAX_DEPTH = MAX_DEPTH;
|
|
1991
3499
|
exports.Markdown = Markdown;
|
|
1992
|
-
exports.SAFE_URL_SCHEMES = SAFE_URL_SCHEMES;
|
|
1993
3500
|
exports.coalesceText = coalesceText;
|
|
1994
3501
|
exports.codeBlockShape = codeBlockShape;
|
|
1995
3502
|
exports.codeSpanShape = codeSpanShape;
|
|
1996
3503
|
exports.collectList = collectList;
|
|
1997
3504
|
exports.collectTable = collectTable;
|
|
3505
|
+
exports.countIndent = countIndent;
|
|
1998
3506
|
exports.createCodeBlockContract = createCodeBlockContract;
|
|
1999
3507
|
exports.createCodeSpanContract = createCodeSpanContract;
|
|
3508
|
+
exports.createLineBreakContract = createLineBreakContract;
|
|
2000
3509
|
exports.createMarkdown = createMarkdown;
|
|
3510
|
+
exports.createProjection = createProjection;
|
|
2001
3511
|
exports.createTextContract = createTextContract;
|
|
2002
3512
|
exports.createThematicBreakContract = createThematicBreakContract;
|
|
2003
|
-
exports.
|
|
3513
|
+
exports.delimiterToAlignments = delimiterToAlignments;
|
|
2004
3514
|
exports.extractFence = extractFence;
|
|
2005
3515
|
exports.extractHeading = extractHeading;
|
|
2006
3516
|
exports.extractListItem = extractListItem;
|
|
2007
3517
|
exports.flattenText = flattenText;
|
|
2008
3518
|
exports.foldNode = foldNode;
|
|
3519
|
+
exports.htmlToMarkdown = htmlToMarkdown;
|
|
2009
3520
|
exports.isBlankLine = isBlankLine;
|
|
2010
3521
|
exports.isBlockNode = isBlockNode;
|
|
2011
3522
|
exports.isBlockquoteNode = isBlockquoteNode;
|
|
@@ -2016,7 +3527,9 @@ exports.isEscapable = isEscapable;
|
|
|
2016
3527
|
exports.isFenceClose = isFenceClose;
|
|
2017
3528
|
exports.isFenceWhitespace = isFenceWhitespace;
|
|
2018
3529
|
exports.isHeadingNode = isHeadingNode;
|
|
3530
|
+
exports.isImageNode = isImageNode;
|
|
2019
3531
|
exports.isInlineNode = isInlineNode;
|
|
3532
|
+
exports.isLineBreakNode = isLineBreakNode;
|
|
2020
3533
|
exports.isLinkNode = isLinkNode;
|
|
2021
3534
|
exports.isListNode = isListNode;
|
|
2022
3535
|
exports.isMarkdownDocument = isMarkdownDocument;
|
|
@@ -2029,15 +3542,21 @@ exports.isTextNode = isTextNode;
|
|
|
2029
3542
|
exports.isThematicBreak = isThematicBreak;
|
|
2030
3543
|
exports.isThematicBreakNode = isThematicBreakNode;
|
|
2031
3544
|
exports.isWhitespace = isWhitespace;
|
|
2032
|
-
exports.
|
|
2033
|
-
exports.
|
|
3545
|
+
exports.lineBreakShape = lineBreakShape;
|
|
3546
|
+
exports.listItemMatchShape = listItemMatchShape;
|
|
3547
|
+
exports.markdownToHTML = markdownToHTML;
|
|
3548
|
+
exports.mergeProjections = mergeProjections;
|
|
3549
|
+
exports.normalizeInlines = normalizeInlines;
|
|
2034
3550
|
exports.parseBlocks = parseBlocks;
|
|
2035
3551
|
exports.parseDocument = parseDocument;
|
|
2036
3552
|
exports.parseInline = parseInline;
|
|
3553
|
+
exports.projectHTMLLeaf = projectHTMLLeaf;
|
|
3554
|
+
exports.projectHTMLNode = projectHTMLNode;
|
|
3555
|
+
exports.projectionToBlocks = projectionToBlocks;
|
|
3556
|
+
exports.projectionToInlines = projectionToInlines;
|
|
2037
3557
|
exports.renderHTML = renderHTML;
|
|
2038
3558
|
exports.renderMarkdown = renderMarkdown;
|
|
2039
3559
|
exports.rewriteDocument = rewriteDocument;
|
|
2040
|
-
exports.sanitizeUrl = sanitizeUrl;
|
|
2041
3560
|
exports.scanCode = scanCode;
|
|
2042
3561
|
exports.scanEmphasis = scanEmphasis;
|
|
2043
3562
|
exports.scanInline = scanInline;
|
|
@@ -2047,9 +3566,9 @@ exports.splitTableRow = splitTableRow;
|
|
|
2047
3566
|
exports.startsBlock = startsBlock;
|
|
2048
3567
|
exports.stripQuote = stripQuote;
|
|
2049
3568
|
exports.tableAlignShape = tableAlignShape;
|
|
2050
|
-
exports.tableAlignments = tableAlignments;
|
|
2051
3569
|
exports.textShape = textShape;
|
|
2052
3570
|
exports.thematicBreakShape = thematicBreakShape;
|
|
3571
|
+
exports.trimInlines = trimInlines;
|
|
2053
3572
|
exports.unescapeText = unescapeText;
|
|
2054
3573
|
exports.walkNodes = walkNodes;
|
|
2055
3574
|
|