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