@meowdown/core 0.65.2 → 0.65.3

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/dist/index.d.ts CHANGED
@@ -14,11 +14,12 @@ import { VirtualElement } from "@floating-ui/dom";
14
14
  /**
15
15
  * How faithfully markdown survives a parse-then-serialize round trip:
16
16
  * - `exact`: byte-identical (modulo the trailing newline).
17
- * - `normalizing`: bytes differ, but only as layout the parser collapses back -
18
- * no non-blank line content is lost and re-parsing the output yields the same
19
- * doc (e.g. a lazy continuation re-indented to its canonical column, or a
20
- * table delimiter row rewritten to canonical dashes).
21
- * - `lossy`: content changed - a non-blank line differs, or the re-parsed doc does.
17
+ * - `normalizing`: bytes differ, but only as layout the parser reads back
18
+ * through - every content line survives and the output re-parses to the same
19
+ * document (e.g. a lazy continuation re-indented to its canonical column, or
20
+ * a table delimiter row rewritten to canonical dashes).
21
+ * - `lossy`: content changed - a content line differs or disappeared, or the
22
+ * output re-parses to a different document.
22
23
  */
23
24
  type RoundTripFidelity = 'exact' | 'normalizing' | 'lossy';
24
25
  /**
package/dist/index.js CHANGED
@@ -43,6 +43,12 @@ import { closeHistory } from "@prosekit/pm/history";
43
43
  import { registerResizableHandleElement, registerResizableRootElement } from "@prosekit/web/resizable";
44
44
  import { InputRule } from "@prosekit/pm/inputrules";
45
45
 
46
+ //#region src/extensions/node-names.ts
47
+ function isNodeOfType(node, name) {
48
+ return node.type.name === name;
49
+ }
50
+
51
+ //#endregion
46
52
  //#region src/utils/composition.ts
47
53
  const COMPOSITION_TAIL_MS = 50;
48
54
  let compositionEndedAt = -1;
@@ -920,12 +926,6 @@ function atomUnitToDOM(atom, sourceText) {
920
926
  }
921
927
  }
922
928
 
923
- //#endregion
924
- //#region src/extensions/node-names.ts
925
- function isNodeOfType(node, name) {
926
- return node.type.name === name;
927
- }
928
-
929
929
  //#endregion
930
930
  //#region src/extensions/heading.ts
931
931
  /**
@@ -1294,27 +1294,6 @@ function definePlainTextPaste() {
1294
1294
  }));
1295
1295
  }
1296
1296
 
1297
- //#endregion
1298
- //#region src/utils/backticks.ts
1299
- /**
1300
- * Length of the longest run of `charCode` in `text`, at least `min`.
1301
- */
1302
- function longestCharRun(text, charCode, min = 0) {
1303
- let longest = min;
1304
- let run = 0;
1305
- for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === charCode) {
1306
- run++;
1307
- if (run > longest) longest = run;
1308
- } else run = 0;
1309
- return longest;
1310
- }
1311
- /**
1312
- * Length of the longest run of backticks in `text`, at least `min`.
1313
- */
1314
- function longestBacktickRun(text, min = 0) {
1315
- return longestCharRun(text, 96, min);
1316
- }
1317
-
1318
1297
  //#endregion
1319
1298
  //#region src/converters/pm-to-md.ts
1320
1299
  /**
@@ -1376,7 +1355,8 @@ function emitHeading(node, out) {
1376
1355
  out.closeBlock();
1377
1356
  return;
1378
1357
  }
1379
- out.write(HEADING_PREFIX[attrs.level] ?? "# ");
1358
+ const prefix = HEADING_PREFIX[attrs.level] ?? "# ";
1359
+ out.write(node.content.size > 0 ? prefix : prefix.slice(0, -1));
1380
1360
  emitInlineChildren(node, out);
1381
1361
  const closingHashes = attrs.closingHashes;
1382
1362
  if (closingHashes != null && closingHashes > 0) out.write(" " + "#".repeat(closingHashes));
@@ -1390,10 +1370,22 @@ var MdOut = class {
1390
1370
  this.atLineStart = true;
1391
1371
  this.deferredBlankPrefix = null;
1392
1372
  }
1393
- write(text) {
1373
+ /**
1374
+ * Write `text`, opening each embedded line with the current line prefix.
1375
+ *
1376
+ * `lazyLines` lets a line the prefix would change the meaning of - a setext
1377
+ * underline, a tab-indented line under a blockquote, a block opener - go out
1378
+ * as a lazy continuation instead (see `continuationPrefix`). Markdown keeps
1379
+ * such a line as text only when it arrives lazily, and the container picks
1380
+ * the paragraph up again on the next line. Only paragraph-like text may ask
1381
+ * for this: an unprefixed line would fall out of a code block, an html
1382
+ * comment, or an HTML block.
1383
+ */
1384
+ write(text, lazyLines = false) {
1394
1385
  if (text === "") return;
1395
1386
  this.emitDeferredBlankLine();
1396
1387
  if (this.atLineStart) {
1388
+ if (this.pendingFirst !== null && isThematicBreak(this.pendingFirst + text)) this.breakMarkerLine();
1397
1389
  this.parts.push(this.pendingFirst ?? this.linePrefix);
1398
1390
  this.pendingFirst = null;
1399
1391
  this.atLineStart = false;
@@ -1403,9 +1395,11 @@ var MdOut = class {
1403
1395
  return;
1404
1396
  }
1405
1397
  const lines = text.split("\n");
1398
+ const lazy = lazyLines && this.linePrefix !== "";
1406
1399
  for (let i = 0; i < lines.length; i++) {
1407
- if (i > 0) this.parts.push("\n", this.linePrefix);
1408
- if (lines[i] !== "") this.parts.push(lines[i]);
1400
+ const line = lines[i];
1401
+ if (i > 0) this.parts.push("\n", lazy ? continuationPrefix(line, this.linePrefix) : this.linePrefix);
1402
+ if (line !== "") this.parts.push(line);
1409
1403
  }
1410
1404
  }
1411
1405
  /**
@@ -1432,7 +1426,8 @@ var MdOut = class {
1432
1426
  closeBlock() {
1433
1427
  if (this.atLineStart && this.pendingFirst !== null) {
1434
1428
  this.emitDeferredBlankLine();
1435
- this.parts.push(this.pendingFirst.trimEnd());
1429
+ const marker = this.pendingFirst;
1430
+ this.parts.push(marker.endsWith("] ") ? marker : marker.trimEnd());
1436
1431
  this.pendingFirst = null;
1437
1432
  this.atLineStart = false;
1438
1433
  }
@@ -1441,6 +1436,18 @@ var MdOut = class {
1441
1436
  this.deferredBlankPrefix = this.linePrefix;
1442
1437
  }
1443
1438
  /**
1439
+ * Give the markers pending on this line a line of their own, so what comes
1440
+ * next opens a new one. Unlike `closeBlock` this owes no blank line: the
1441
+ * items are still part of the same tight list.
1442
+ */
1443
+ breakMarkerLine() {
1444
+ if (this.pendingFirst === null) return;
1445
+ this.emitDeferredBlankLine();
1446
+ this.parts.push(this.pendingFirst.trimEnd(), "\n");
1447
+ this.pendingFirst = null;
1448
+ this.atLineStart = true;
1449
+ }
1450
+ /**
1444
1451
  * Cancel the blank line deferred by the last `closeBlock`, so the next
1445
1452
  * write starts directly on the following line. Used between the blocks of
1446
1453
  * a tight list, where markdown separates items (and an item's paragraph
@@ -1462,14 +1469,24 @@ var MdOut = class {
1462
1469
  this.linePrefix = savedLine + continuation;
1463
1470
  if (firstLine !== null) {
1464
1471
  const base = savedFirst ?? savedLine;
1465
- this.pendingFirst = base + firstLine;
1472
+ if (savedFirst !== null && isThematicBreak(base + firstLine)) {
1473
+ this.breakMarkerLine();
1474
+ this.pendingFirst = savedLine + firstLine;
1475
+ } else this.pendingFirst = base + firstLine;
1466
1476
  }
1467
1477
  fn();
1468
1478
  this.linePrefix = savedLine;
1469
1479
  this.pendingFirst = firstLine !== null ? null : savedFirst;
1470
1480
  }
1471
1481
  finish() {
1472
- return this.parts.join("").replace(/\s+$/, "") + "\n";
1482
+ const text = this.parts.join("");
1483
+ let cut = text.length;
1484
+ for (let i = text.length - 1; i >= 0; i--) {
1485
+ const code = text.charCodeAt(i);
1486
+ if (code === 10) cut = i;
1487
+ else if (code !== 32 && code !== 9) break;
1488
+ }
1489
+ return text.slice(0, cut) + "\n";
1473
1490
  }
1474
1491
  emitDeferredBlankLine() {
1475
1492
  const prefix = this.deferredBlankPrefix;
@@ -1548,13 +1565,39 @@ function emitBlockChildren(node, out, tightItem = false) {
1548
1565
  while (runEnd < count && isNodeOfType(node.child(runEnd), "list")) runEnd++;
1549
1566
  const tightRun = isTightRun(node, index, runEnd);
1550
1567
  for (let item = index; item < runEnd; item++) {
1551
- if (item === index ? tightItem && index > 0 : tightRun) out.suppressBlank();
1552
- emitList(node.child(item), out, tightRun);
1568
+ const child = node.child(item);
1569
+ const isRunStart = item === index;
1570
+ const tight = isRunStart ? tightItem && index > 0 : tightRun;
1571
+ const carriesOn = !isRunStart && listMarkerChar(node.child(item - 1)) === listMarkerChar(child);
1572
+ if (tight && (carriesOn || canInterruptParagraph(child))) out.suppressBlank();
1573
+ emitList(child, out, tightRun);
1553
1574
  }
1554
1575
  index = runEnd;
1555
1576
  }
1556
1577
  }
1557
1578
  /**
1579
+ * Whether `list`'s marker line still opens an item when the line above it is a
1580
+ * paragraph. CommonMark lets a list interrupt a paragraph only when the item
1581
+ * carries content and, if ordered, is numbered 1; anything else reads as more of
1582
+ * the paragraph, so the blank line before it is what keeps it a list at all.
1583
+ */
1584
+ function canInterruptParagraph(list) {
1585
+ const attrs = list.attrs;
1586
+ if (attrs.kind === "ordered" && (attrs.order ?? 1) !== 1) return false;
1587
+ return attrs.kind === "task" || list.childCount > 0 && list.child(0).content.size > 0;
1588
+ }
1589
+ /**
1590
+ * The character a list item's marker ends with: the bullet itself, or the
1591
+ * delimiter after an ordered item's number. A markdown list runs for as long as
1592
+ * this stays the same, and a different one opens a new list.
1593
+ */
1594
+ function listMarkerChar(node) {
1595
+ const { kind, marker, collapsed } = node.attrs;
1596
+ if (kind === "ordered") return marker === ")" ? ")" : ".";
1597
+ if (kind === "task") return marker === "+" ? "+" : marker === "*" ? "*" : "-";
1598
+ return collapsed ? "+" : marker === "*" ? "*" : "-";
1599
+ }
1600
+ /**
1558
1601
  * A run of sibling `list` nodes serializes tight iff every item is "simple":
1559
1602
  * at most one leading paragraph, then only nested lists. Any other shape
1560
1603
  * (multiple paragraphs, a blockquote, a code block, …) needs blank-line
@@ -1576,6 +1619,177 @@ function isTightItem(item) {
1576
1619
  return true;
1577
1620
  }
1578
1621
  /**
1622
+ * The prefix a leaf's continuation line goes out with. The container's full
1623
+ * `prefix` is the default. A line the full prefix would change the meaning of -
1624
+ * a setext underline, a tab whose columns the prefix shrinks below the four
1625
+ * that kept it inert, a block opener the source kept inert with indentation the
1626
+ * parser dedented away - needs a lazy spelling instead: without its `>` marker
1627
+ * the line can only continue the paragraph, because no block may start on a
1628
+ * lazy line.
1629
+ *
1630
+ * The lazy spelling carries `lazyIndent(prefix)`, the columns the parser
1631
+ * dedents an unmarked line by, so the text comes back exactly. It goes out bare
1632
+ * only when the dedent would leave it alone anyway and its own whitespace
1633
+ * measures four columns flush left (or it is a setext underline, which nothing
1634
+ * at the top level turns back into an underline).
1635
+ */
1636
+ function continuationPrefix(line, prefix) {
1637
+ if (measureIndent(line, prefix.length) >= 4) return prefix;
1638
+ const shrunk = measureIndent(line, 0) >= 4;
1639
+ const underline = isSetextUnderline(line);
1640
+ const opens = !shrunk && lineOpensBlock(line);
1641
+ if (!shrunk && !underline && !opens) return prefix;
1642
+ const lazy = lazyIndent(prefix);
1643
+ return !opens && dedentKeepsLine(line, lazy.length) ? "" : lazy;
1644
+ }
1645
+ /**
1646
+ * The lazy spelling's indent: the whitespace the containers write after the
1647
+ * last `>` marker, which is also the columns the parser dedents an unmarked
1648
+ * continuation line by (`sliceColumn` in `md-to-pm.ts`). The single space
1649
+ * directly after the `>` belongs to the marker, not to the indent. A prefix
1650
+ * with no `>` to drop has flush left as its only lazy spelling: its columns
1651
+ * are the list's own indent, and writing them would just re-match the item.
1652
+ */
1653
+ function lazyIndent(prefix) {
1654
+ let start = prefix.length;
1655
+ while (start > 0 && prefix.charCodeAt(start - 1) === 32) start--;
1656
+ if (start === 0 || prefix.charCodeAt(start - 1) !== 62) return "";
1657
+ return prefix.slice(start + 1);
1658
+ }
1659
+ /**
1660
+ * Whether the parser's dedent (`sliceColumn` at `column`) returns `line`
1661
+ * unchanged: its leading whitespace stops short of the column, or a tab in it
1662
+ * reaches past. Such a line round-trips byte-exact when written bare.
1663
+ */
1664
+ function dedentKeepsLine(line, column) {
1665
+ let col = 0;
1666
+ for (let index = 0; col < column; index++) {
1667
+ const code = line.charCodeAt(index);
1668
+ if (code === 32) col += 1;
1669
+ else if (code === 9) {
1670
+ const width = 4 - col % 4;
1671
+ if (col + width > column) return true;
1672
+ col += width;
1673
+ } else return true;
1674
+ }
1675
+ return false;
1676
+ }
1677
+ /**
1678
+ * The line patterns that open an HTML block, the openers of `HTMLBlockStyle`
1679
+ * in `@lezer/markdown`: script/pre/style, a comment, a processing instruction,
1680
+ * a declaration, CDATA, a known block-level tag, and a complete tag alone on
1681
+ * its line. Leading whitespace is stripped before matching.
1682
+ */
1683
+ const HTML_BLOCK_OPEN = [
1684
+ /^<(?:script|pre|style)(?:\s|>|$)/i,
1685
+ /^<!--/,
1686
+ /^<\?/,
1687
+ /^<![A-Z]/,
1688
+ /^<!\[CDATA\[/,
1689
+ /^<\/?(?:address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h1|h2|h3|h4|h5|h6|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|section|source|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)(?:\s|\/?>|$)/i,
1690
+ /^(?:<\/[a-z][\w-]*\s*>|<[a-z][\w-]*(\s+[a-z:_][\w.-]*(?:\s*=\s*(?:[^\s"'=<>`]+|'[^']*'|"[^"]*"))?)*\s*>)\s*$/i
1691
+ ];
1692
+ /**
1693
+ * Whether `text`'s first line opens an HTML block. The last pattern (a
1694
+ * complete tag) cannot interrupt a paragraph, but a lazy spelling of such a
1695
+ * line round-trips all the same, so one check serves both callers.
1696
+ */
1697
+ function opensHTMLBlock(text) {
1698
+ const lineEnd = text.indexOf("\n");
1699
+ const line = lineEnd < 0 ? text : text.slice(0, lineEnd);
1700
+ for (const pattern of HTML_BLOCK_OPEN) if (pattern.test(line)) return true;
1701
+ return false;
1702
+ }
1703
+ /**
1704
+ * Whether `line`, at the start of a container's content, opens a block instead
1705
+ * of continuing the paragraph above it: a blockquote, an ATX heading, a fence,
1706
+ * `$$` math, an HTML block, a thematic break, a bullet item (even an empty
1707
+ * one), and an ordered item that carries content and is numbered 1.
1708
+ */
1709
+ function lineOpensBlock(line) {
1710
+ let index = 0;
1711
+ while (isSpaceOrTab(line.charCodeAt(index))) index++;
1712
+ const first = line.charCodeAt(index);
1713
+ switch (first) {
1714
+ case 62: return true;
1715
+ case 60: return opensHTMLBlock(index === 0 ? line : line.slice(index));
1716
+ case 35: {
1717
+ let end = index + 1;
1718
+ while (line.charCodeAt(end) === 35) end++;
1719
+ return end - index <= 6 && (end === line.length || isSpaceOrTab(line.charCodeAt(end)));
1720
+ }
1721
+ case 96:
1722
+ case 126: {
1723
+ let end = index + 1;
1724
+ while (line.charCodeAt(end) === first) end++;
1725
+ return end - index >= 3;
1726
+ }
1727
+ case 36: return line.charCodeAt(index + 1) === 36;
1728
+ case 95: return isUnderscoreBreak(line, index);
1729
+ case 42:
1730
+ case 45:
1731
+ case 43: return isThematicBreak(line) || index + 1 >= line.length || isSpaceOrTab(line.charCodeAt(index + 1));
1732
+ case 49: {
1733
+ const delimiter = line.charCodeAt(index + 1);
1734
+ return (delimiter === 46 || delimiter === 41) && startsNonEmptyItem(line, index + 1);
1735
+ }
1736
+ default: return false;
1737
+ }
1738
+ }
1739
+ /**
1740
+ * A `___` thematic break: three or more underscores with nothing but spaces
1741
+ * and tabs between them. (`isThematicBreak` covers `-` and `*`, the two break
1742
+ * characters that double as list bullets.)
1743
+ */
1744
+ function isUnderscoreBreak(line, index) {
1745
+ let count = 0;
1746
+ for (; index < line.length; index++) {
1747
+ const code = line.charCodeAt(index);
1748
+ if (code === 95) count++;
1749
+ else if (!isSpaceOrTab(code)) return false;
1750
+ }
1751
+ return count >= 3;
1752
+ }
1753
+ /**
1754
+ * Whether the list delimiter at `index` is followed by the gap and content
1755
+ * (`1. x`) that let an ordered item interrupt a paragraph.
1756
+ */
1757
+ function startsNonEmptyItem(line, index) {
1758
+ if (!isSpaceOrTab(line.charCodeAt(index + 1))) return false;
1759
+ for (let i = index + 2; i < line.length; i++) if (!isSpaceOrTab(line.charCodeAt(i))) return true;
1760
+ return false;
1761
+ }
1762
+ /**
1763
+ * The columns of leading whitespace `line` stands for when it starts at column
1764
+ * `start`, where a tab reaches the next multiple of four.
1765
+ */
1766
+ function measureIndent(line, start) {
1767
+ let col = start;
1768
+ for (let i = 0; i < line.length; i++) {
1769
+ const code = line.charCodeAt(i);
1770
+ if (code === 32) col += 1;
1771
+ else if (code === 9) col += 4 - col % 4;
1772
+ else break;
1773
+ }
1774
+ return col - start;
1775
+ }
1776
+ /**
1777
+ * Whether `line` is a run of one character, `=` or `-`, and so would read as a
1778
+ * setext underline under a container's line prefix. Surrounding whitespace does
1779
+ * not save it: an underline may carry any indentation the prefix leaves under
1780
+ * four columns, and any trailing spaces at all.
1781
+ */
1782
+ function isSetextUnderline(line) {
1783
+ let start = 0;
1784
+ while (start < line.length && isSpaceChar(line.charCodeAt(start))) start++;
1785
+ const first = line.charCodeAt(start);
1786
+ if (first !== 61 && first !== 45) return false;
1787
+ let end = line.length;
1788
+ while (isSpaceChar(line.charCodeAt(end - 1))) end--;
1789
+ for (let i = start + 1; i < end; i++) if (line.charCodeAt(i) !== first) return false;
1790
+ return true;
1791
+ }
1792
+ /**
1579
1793
  * Walk inline children writing text directly. The schema has no marks, so
1580
1794
  * every inline child is currently a text node - but going through this
1581
1795
  * loop instead of `node.textContent` avoids one intermediate string
@@ -1583,23 +1797,45 @@ function isTightItem(item) {
1583
1797
  */
1584
1798
  function emitInlineChildren(node, out) {
1585
1799
  const count = node.childCount;
1800
+ if (count === 0) return;
1801
+ const first = node.child(0).text;
1802
+ const lazy = first == null || first.charCodeAt(0) !== 60 || !opensHTMLBlock(first);
1586
1803
  for (let i = 0; i < count; i++) {
1587
1804
  const child = node.child(i);
1588
- if (child.isText && child.text) out.write(child.text);
1805
+ if (child.isText && child.text) out.write(child.text, lazy);
1589
1806
  }
1590
1807
  }
1591
1808
  function emitList(node, out, tight) {
1592
- const { kind, marker, order, taskMarker, collapsed, markerGap, checked } = node.attrs;
1593
- const bulletMarker = kind === "task" ? marker === "+" ? "+" : marker === "*" ? "*" : "-" : collapsed ? "+" : marker === "*" ? "*" : "-";
1594
- const orderMarker = marker === ")" ? ")" : ".";
1809
+ const { kind, order, taskMarker, markerGap, checked } = node.attrs;
1810
+ const markerChar = listMarkerChar(node);
1595
1811
  const checkMark = taskMarker === "X" ? "X" : "x";
1596
1812
  const gap = Math.min(Math.max(markerGap ?? 1, 1), 4);
1597
- const prefix = `${kind === "ordered" ? `${order ?? 1}${orderMarker}` : bulletMarker}${" ".repeat(gap)}`;
1813
+ const prefix = `${kind === "ordered" ? `${order ?? 1}${markerChar}` : markerChar}${" ".repeat(gap)}`;
1598
1814
  const outputMarker = kind === "task" ? `${prefix}[${checked ? checkMark : " "}] ` : prefix;
1599
1815
  const continuation = " ".repeat(prefix.length);
1600
1816
  out.withPrefix(continuation, outputMarker, () => emitBlockChildren(node, out, tight));
1601
1817
  out.closeBlock();
1602
1818
  }
1819
+ /**
1820
+ * Whether a line of list markers reads as a thematic break: three or more `-`
1821
+ * or `*`, all the same character, with nothing but spaces, tabs and blockquote
1822
+ * markers around them.
1823
+ */
1824
+ function isThematicBreak(line) {
1825
+ let breakChar = 0;
1826
+ let count = 0;
1827
+ for (let i = 0; i < line.length; i++) {
1828
+ const code = line.charCodeAt(i);
1829
+ if (code === 10) break;
1830
+ if (code === 32 || code === 9) continue;
1831
+ if (code === 62 && count === 0) continue;
1832
+ if (code !== 45 && code !== 42) return false;
1833
+ if (breakChar === 0) breakChar = code;
1834
+ else if (code !== breakChar) return false;
1835
+ count++;
1836
+ }
1837
+ return count >= 3;
1838
+ }
1603
1839
  function emitCodeBlock(node, out) {
1604
1840
  const attrs = node.attrs;
1605
1841
  const language = attrs.language || "";
@@ -1624,10 +1860,9 @@ function emitCodeBlock(node, out) {
1624
1860
  return;
1625
1861
  }
1626
1862
  const tilde = attrs.fenceStyle === "tilde";
1627
- const minWidth = longestCharRun(code, tilde ? 126 : 96, 2) + 1;
1628
- const fence = (tilde ? "~" : "`").repeat(Math.max(attrs.fenceLength ?? 0, minWidth));
1863
+ const fence = (tilde ? "~" : "`").repeat(Math.max(attrs.fenceLength ?? 0, minFenceLength(code, tilde)));
1629
1864
  out.write(fence);
1630
- if (language) out.write(language);
1865
+ if (language) out.write(language.startsWith(tilde ? "~" : "`") ? " " + language : language);
1631
1866
  out.write("\n");
1632
1867
  if (code) {
1633
1868
  out.write(code);
@@ -1637,16 +1872,53 @@ function emitCodeBlock(node, out) {
1637
1872
  out.closeBlock();
1638
1873
  }
1639
1874
  /**
1875
+ * The narrowest fence that can hold `code`: wider than every line of it that
1876
+ * would close the fence early, and never under CommonMark's minimum of three. A
1877
+ * closing fence is a run of the fence character alone on its line; a run with
1878
+ * anything else on the line (`` a ``` ``, `` ``` x ``) or four columns in closes
1879
+ * nothing, so the fence holds it as it is.
1880
+ */
1881
+ function minFenceLength(code, tilde) {
1882
+ const fenceChar = tilde ? 126 : 96;
1883
+ let longest = 2;
1884
+ let lineStart = 0;
1885
+ while (lineStart <= code.length) {
1886
+ let lineEnd = code.indexOf("\n", lineStart);
1887
+ if (lineEnd < 0) lineEnd = code.length;
1888
+ let index = lineStart;
1889
+ while (index < lineEnd && code.charCodeAt(index) === 32) index++;
1890
+ if (index - lineStart < 4) {
1891
+ const runStart = index;
1892
+ while (index < lineEnd && code.charCodeAt(index) === fenceChar) index++;
1893
+ const run = index - runStart;
1894
+ while (index < lineEnd && isSpaceOrTab(code.charCodeAt(index))) index++;
1895
+ if (index === lineEnd && run > longest) longest = run;
1896
+ }
1897
+ lineStart = lineEnd + 1;
1898
+ }
1899
+ return longest + 1;
1900
+ }
1901
+ function isSpaceOrTab(char) {
1902
+ return char === 32 || char === 9;
1903
+ }
1904
+ /**
1905
+ * Whether indentation can spell `code` as an indented code block. There is no
1906
+ * line to carry the four columns of a block with no content at all, and none to
1907
+ * carry a blank line at either end.
1908
+ */
1909
+ function canIndentCode(code) {
1910
+ return code !== "" && !code.startsWith("\n") && !code.endsWith("\n");
1911
+ }
1912
+ /**
1640
1913
  * Indent `code` for an indented code block, or return `undefined` for shapes
1641
- * the indented form cannot express (empty content, or a leading or trailing
1642
- * blank line), which fall back to a fence. Blank interior lines stay empty so
1643
- * a round-trip adds no trailing whitespace; `MdOut.write` still prepends the
1644
- * enclosing `linePrefix` (blockquote or list continuation) per line.
1914
+ * the indented form cannot express, which fall back to a fence. Blank interior
1915
+ * lines stay empty so a round-trip adds no trailing whitespace; `MdOut.write`
1916
+ * still prepends the enclosing `linePrefix` (blockquote or list continuation)
1917
+ * per line.
1645
1918
  */
1646
1919
  function toIndentedCode(code) {
1647
- if (code === "") return void 0;
1920
+ if (!canIndentCode(code)) return void 0;
1648
1921
  const lines = code.split("\n");
1649
- if (lines[0] === "" || lines[lines.length - 1] === "") return void 0;
1650
1922
  for (let i = 0; i < lines.length; i++) if (lines[i] !== "") lines[i] = ` ${lines[i]}`;
1651
1923
  return lines.join("\n");
1652
1924
  }
@@ -3817,6 +4089,27 @@ function defineInlineMarks() {
3817
4089
  return union(defineMdMark(), defineMdEm(), defineMdStrong(), defineMdCode(), defineMdLinkText(), defineMdLinkUri(), defineMdLinkTitle(), defineMdDel(), defineMdHighlight(), defineMdTag(), defineMdWikilink(), defineMdImage(), defineMdFile(), defineMdMath(), defineMdPack());
3818
4090
  }
3819
4091
 
4092
+ //#endregion
4093
+ //#region src/utils/backticks.ts
4094
+ /**
4095
+ * Length of the longest run of `charCode` in `text`, at least `min`.
4096
+ */
4097
+ function longestCharRun(text, charCode, min = 0) {
4098
+ let longest = min;
4099
+ let run = 0;
4100
+ for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === charCode) {
4101
+ run++;
4102
+ if (run > longest) longest = run;
4103
+ } else run = 0;
4104
+ return longest;
4105
+ }
4106
+ /**
4107
+ * Length of the longest run of backticks in `text`, at least `min`.
4108
+ */
4109
+ function longestBacktickRun(text, min = 0) {
4110
+ return longestCharRun(text, 96, min);
4111
+ }
4112
+
3820
4113
  //#endregion
3821
4114
  //#region src/extensions/inline-toggle.ts
3822
4115
  const TOGGLE_SPECS = {
@@ -5696,7 +5989,7 @@ function markdownToDoc(markdown, options = {}) {
5696
5989
  frontmatterBody = body;
5697
5990
  if (matchLength) rest = markdown.slice(matchLength);
5698
5991
  }
5699
- const blocks = collectBlocks(nodes, gfmBlockOnlyParser.parse(rest).cursor(), rest);
5992
+ const blocks = collectBlocks(nodes, gfmBlockOnlyParser.parse(rest).cursor(), rest, 0);
5700
5993
  return nodes.doc(frontmatterBody === void 0 ? {} : { frontmatter: frontmatterBody }, blocks);
5701
5994
  }
5702
5995
  /**
@@ -5718,19 +6011,38 @@ function matchFrontmatter(markdown) {
5718
6011
  * and flattening any node converter that returns multiple siblings
5719
6012
  * (lists are the main case).
5720
6013
  */
5721
- function collectBlocks(nodes, cursor, text) {
6014
+ function collectBlocks(nodes, cursor, text, column) {
5722
6015
  const out = [];
5723
6016
  if (!cursor.firstChild()) return out;
5724
6017
  let previousTo;
5725
6018
  do {
5726
6019
  if (previousTo != null) appendGapParagraphs(out, nodes, text, previousTo, cursor.from);
5727
6020
  previousTo = cursor.to;
5728
- out.push(...convertBlock(nodes, cursor, text));
6021
+ appendBlocks(out, nodes, convertBlock(nodes, cursor, text, column));
5729
6022
  } while (cursor.nextSibling());
5730
6023
  cursor.parent();
5731
6024
  return out;
5732
6025
  }
5733
6026
  /**
6027
+ * Append `blocks`, dropping the indented spelling from a code block that lands
6028
+ * right after a list: four columns of indentation would put it inside the item
6029
+ * above instead of after it, so it can only be written as a fence.
6030
+ */
6031
+ function appendBlocks(out, nodes, blocks) {
6032
+ for (const block of blocks) {
6033
+ const attrs = block.attrs;
6034
+ if (attrs.fenceStyle === "indented" && isNodeOfType(block, "codeBlock") && out.length > 0 && isNodeOfType(out[out.length - 1], "list")) {
6035
+ out.push(nodes.codeBlock({
6036
+ language: attrs.language,
6037
+ fenceStyle: null,
6038
+ fenceLength: attrs.fenceLength
6039
+ }, block.textContent));
6040
+ continue;
6041
+ }
6042
+ out.push(block);
6043
+ }
6044
+ }
6045
+ /**
5734
6046
  * Blank lines between two sibling blocks are content: a run of K blank lines
5735
6047
  * is one block separator plus K-1 empty paragraphs. The gap slice between the
5736
6048
  * siblings' ranges holds only line terminators and structural prefixes
@@ -5742,24 +6054,24 @@ function appendGapParagraphs(out, nodes, text, gapFrom, gapTo) {
5742
6054
  for (let i = gapFrom; i < gapTo; i++) if (text.charCodeAt(i) === 10) newlineCount++;
5743
6055
  for (let i = 2; i < newlineCount; i++) out.push(nodes.paragraph());
5744
6056
  }
5745
- function convertBlock(nodes, cursor, text) {
6057
+ function convertBlock(nodes, cursor, text, column) {
5746
6058
  switch (cursor.type.id) {
5747
- case LEZER_NODE_IDS.ATXHeading1: return [convertHeading(nodes, cursor, text, 1, false)];
5748
- case LEZER_NODE_IDS.ATXHeading2: return [convertHeading(nodes, cursor, text, 2, false)];
5749
- case LEZER_NODE_IDS.ATXHeading3: return [convertHeading(nodes, cursor, text, 3, false)];
5750
- case LEZER_NODE_IDS.ATXHeading4: return [convertHeading(nodes, cursor, text, 4, false)];
5751
- case LEZER_NODE_IDS.ATXHeading5: return [convertHeading(nodes, cursor, text, 5, false)];
5752
- case LEZER_NODE_IDS.ATXHeading6: return [convertHeading(nodes, cursor, text, 6, false)];
5753
- case LEZER_NODE_IDS.SetextHeading1: return [convertHeading(nodes, cursor, text, 1, true)];
5754
- case LEZER_NODE_IDS.SetextHeading2: return [convertHeading(nodes, cursor, text, 2, true)];
5755
- case LEZER_NODE_IDS.Paragraph: return [convertParagraph(nodes, cursor, text)];
5756
- case LEZER_NODE_IDS.LinkReference: return [convertParagraph(nodes, cursor, text)];
5757
- case LEZER_NODE_IDS.CommentBlock: return [convertHTMLComment(nodes, cursor, text)];
6059
+ case LEZER_NODE_IDS.ATXHeading1: return [convertHeading(nodes, cursor, text, column, 1, false)];
6060
+ case LEZER_NODE_IDS.ATXHeading2: return [convertHeading(nodes, cursor, text, column, 2, false)];
6061
+ case LEZER_NODE_IDS.ATXHeading3: return [convertHeading(nodes, cursor, text, column, 3, false)];
6062
+ case LEZER_NODE_IDS.ATXHeading4: return [convertHeading(nodes, cursor, text, column, 4, false)];
6063
+ case LEZER_NODE_IDS.ATXHeading5: return [convertHeading(nodes, cursor, text, column, 5, false)];
6064
+ case LEZER_NODE_IDS.ATXHeading6: return [convertHeading(nodes, cursor, text, column, 6, false)];
6065
+ case LEZER_NODE_IDS.SetextHeading1: return [convertHeading(nodes, cursor, text, column, 1, true)];
6066
+ case LEZER_NODE_IDS.SetextHeading2: return [convertHeading(nodes, cursor, text, column, 2, true)];
6067
+ case LEZER_NODE_IDS.Paragraph: return [convertParagraph(nodes, cursor, text, column)];
6068
+ case LEZER_NODE_IDS.LinkReference: return [convertParagraph(nodes, cursor, text, column)];
6069
+ case LEZER_NODE_IDS.CommentBlock: return [convertHTMLComment(nodes, cursor, text, column)];
5758
6070
  case LEZER_NODE_IDS.HTMLBlock:
5759
- case LEZER_NODE_IDS.ProcessingInstructionBlock: return [convertParagraph(nodes, cursor, text)];
6071
+ case LEZER_NODE_IDS.ProcessingInstructionBlock: return [convertParagraph(nodes, cursor, text, column)];
5760
6072
  case LEZER_NODE_IDS.Blockquote: return [convertBlockquote(nodes, cursor, text)];
5761
- case LEZER_NODE_IDS.BulletList: return convertList(nodes, cursor, text, "bullet");
5762
- case LEZER_NODE_IDS.OrderedList: return convertList(nodes, cursor, text, "ordered");
6073
+ case LEZER_NODE_IDS.BulletList: return convertList(nodes, cursor, text, column, "bullet");
6074
+ case LEZER_NODE_IDS.OrderedList: return convertList(nodes, cursor, text, column, "ordered");
5763
6075
  case LEZER_NODE_IDS.FencedCode:
5764
6076
  case LEZER_NODE_IDS.CodeBlock: return [convertCodeBlock(nodes, cursor, text)];
5765
6077
  case LEZER_NODE_IDS.BlockMath: return [convertBlockMath(nodes, cursor, text)];
@@ -5768,14 +6080,14 @@ function convertBlock(nodes, cursor, text) {
5768
6080
  return [nodes.horizontalRule({ marker: marker === "---" ? null : marker })];
5769
6081
  }
5770
6082
  case LEZER_NODE_IDS.Table: return [convertTable(nodes, cursor, text)];
5771
- case LEZER_NODE_IDS.Task: return [convertParagraph(nodes, cursor, text)];
6083
+ case LEZER_NODE_IDS.Task: return [convertParagraph(nodes, cursor, text, column)];
5772
6084
  default:
5773
6085
  if (text.slice(cursor.from, cursor.to).trim() === "") return [];
5774
6086
  console.warn(`[meowdown] unsupported lezer block "${cursor.type.name}"`);
5775
- return [convertParagraph(nodes, cursor, text)];
6087
+ return [convertParagraph(nodes, cursor, text, column)];
5776
6088
  }
5777
6089
  }
5778
- function convertHeading(nodes, cursor, text, level, isSetext) {
6090
+ function convertHeading(nodes, cursor, text, column, level, isSetext) {
5779
6091
  const headingFrom = cursor.from;
5780
6092
  let contentStart = cursor.from;
5781
6093
  let contentEnd = cursor.to;
@@ -5792,13 +6104,14 @@ function convertHeading(nodes, cursor, text, level, isSetext) {
5792
6104
  lastTo = cursor.to;
5793
6105
  } while (cursor.nextSibling());
5794
6106
  if (lastId === LEZER_NODE_IDS.HeaderMark && lastFrom > contentStart) {
5795
- contentEnd = lastFrom;
6107
+ contentEnd = isSetext ? text.lastIndexOf("\n", lastFrom - 1) : lastFrom;
5796
6108
  trailingMarkFrom = lastFrom;
5797
6109
  trailingMarkTo = lastTo;
5798
6110
  }
5799
6111
  cursor.parent();
5800
6112
  }
5801
- const content = dedentContinuation(text.slice(contentStart, contentEnd), measureContentColumn(text, contentStart)).trim();
6113
+ const raw = dedentContinuation(readLeafText(cursor, text, contentStart, contentEnd), column);
6114
+ const content = isSetext ? raw : trimGap(raw);
5802
6115
  const setextUnderline = isSetext ? countUnderlineChars(text, trailingMarkFrom, trailingMarkTo) || 1 : null;
5803
6116
  const closingHashes = !isSetext && trailingMarkFrom >= 0 ? countHashChars(text, trailingMarkFrom, trailingMarkTo) || null : null;
5804
6117
  return nodes.heading({
@@ -5808,6 +6121,20 @@ function convertHeading(nodes, cursor, text, level, isSetext) {
5808
6121
  }, content);
5809
6122
  }
5810
6123
  /**
6124
+ * Trim the whitespace lezer's marker gap can contain: space, tab and the line
6125
+ * ends (`space` in `@lezer/markdown`, matching `isSpaceChar`). A plain
6126
+ * `String.prototype.trim` would also take a no-break or an ideographic space,
6127
+ * which lezer reads as heading text - and a `#` left standing next to one flips
6128
+ * into a closing run on the way back (`# #\u{A0}`).
6129
+ */
6130
+ function trimGap(text) {
6131
+ let start = 0;
6132
+ let end = text.length;
6133
+ while (start < end && isSpaceChar(text.charCodeAt(start))) start++;
6134
+ while (end > start && isSpaceChar(text.charCodeAt(end - 1))) end--;
6135
+ return text.slice(start, end);
6136
+ }
6137
+ /**
5811
6138
  * Count the `=` / `-` characters in a setext underline run.
5812
6139
  */
5813
6140
  function countUnderlineChars(text, from, to) {
@@ -5840,16 +6167,23 @@ function measureContentColumn(text, from) {
5840
6167
  return col;
5841
6168
  }
5842
6169
  /**
5843
- * Drop a line's leading whitespace up to `column`, counting a tab as `4 - col % 4` columns.
6170
+ * Drop a line's leading whitespace up to `column`, counting a tab as `4 - col % 4`
6171
+ * columns. Whitespace the container never wrote is left alone: a tab that would
6172
+ * reach past `column` stands for more columns than the container takes, and a
6173
+ * line whose indentation stops short of `column` was written lazily, without any
6174
+ * prefix at all.
5844
6175
  */
5845
6176
  function sliceColumn(line, column) {
5846
6177
  let col = 0;
5847
6178
  let index = 0;
5848
- while (index < line.length && col < column) {
6179
+ while (col < column) {
5849
6180
  const code = line.charCodeAt(index);
5850
6181
  if (code === 32) col += 1;
5851
- else if (code === 9) col += 4 - col % 4;
5852
- else break;
6182
+ else if (code === 9) {
6183
+ const width = 4 - col % 4;
6184
+ if (col + width > column) break;
6185
+ col += width;
6186
+ } else return line;
5853
6187
  index++;
5854
6188
  }
5855
6189
  return line.slice(index);
@@ -5870,6 +6204,69 @@ function dedentContinuation(content, column) {
5870
6204
  return content.split("\n").map((line, index) => index === 0 ? line : sliceColumn(line, column)).join("\n");
5871
6205
  }
5872
6206
  /**
6207
+ * The blockquote markers inside `from`..`to`, as a flat run of `from, to` pairs
6208
+ * in document order. A marker can sit at any depth: a paragraph carries its own,
6209
+ * while a link reference's lands inside the `LinkLabel` that spans the break.
6210
+ * The cursor ends where it started.
6211
+ */
6212
+ function collectQuoteMarks(cursor, marks, from, to) {
6213
+ if (!cursor.firstChild()) return;
6214
+ do {
6215
+ if (cursor.from >= to) break;
6216
+ if (cursor.to <= from) continue;
6217
+ if (cursor.type.id === LEZER_NODE_IDS.QuoteMark) {
6218
+ marks.push(cursor.from, cursor.to);
6219
+ continue;
6220
+ }
6221
+ collectQuoteMarks(cursor, marks, from, to);
6222
+ } while (cursor.nextSibling());
6223
+ cursor.parent();
6224
+ }
6225
+ /**
6226
+ * The source between `from` and `to`, minus the blockquote markers inside it.
6227
+ *
6228
+ * In block-only parsing a leaf block has no inline children, with one
6229
+ * exception: lezer leaves the `QuoteMark` of every continuation line of a
6230
+ * multi-line block (`> l1\n> l2`) inside the block's own span. A marker takes
6231
+ * the whole line prefix with it, the single space after it included, exactly as
6232
+ * the serializer's own prefix puts it back. Only a space: a tab after the marker
6233
+ * stands for the columns up to the next tab stop, more than the prefix writes
6234
+ * back, so it stays in the text as the indentation it is. A line with no marker
6235
+ * carried no prefix at all - it is a lazy continuation, and every column on it
6236
+ * is its own. Markers outside `from`..`to` (an ATX heading's own marks, a setext
6237
+ * underline's line) are left alone. The cursor ends where it started.
6238
+ */
6239
+ function readLeafText(cursor, text, from, to) {
6240
+ const lineBreak = text.indexOf("\n", from);
6241
+ if (lineBreak < 0 || lineBreak >= to) return text.slice(from, to);
6242
+ const marks = [];
6243
+ collectQuoteMarks(cursor, marks, from, to);
6244
+ let content = "";
6245
+ let pos = from;
6246
+ for (let index = 0; index < marks.length; index += 2) {
6247
+ content += text.slice(pos, Math.max(pos, text.lastIndexOf("\n", marks[index]) + 1));
6248
+ pos = marks[index + 1];
6249
+ if (text.charCodeAt(pos) === 32) pos += 1;
6250
+ }
6251
+ return trimTrailingBlankLines(content + text.slice(pos, to));
6252
+ }
6253
+ /**
6254
+ * Drop the blank lines a block ends with, the line break in front of them
6255
+ * included. A raw HTML or processing-instruction block with no closing sequence
6256
+ * runs its span to the end of the document and swallows them; a leaf block's
6257
+ * text ends where its last line does, which is where the serializer ends its
6258
+ * own output (`MdOut.finish`), so recording more would lose it on the way back.
6259
+ */
6260
+ function trimTrailingBlankLines(content) {
6261
+ let end = content.length;
6262
+ for (let index = end - 1; index >= 0; index--) {
6263
+ const code = content.charCodeAt(index);
6264
+ if (code === 10) end = index;
6265
+ else if (code !== 32 && code !== 9) break;
6266
+ }
6267
+ return content.slice(0, end);
6268
+ }
6269
+ /**
5873
6270
  * Build a paragraph from raw markdown content, dedenting continuation lines so
5874
6271
  * the serializer's own line prefix does not double the indent. A soft line break
5875
6272
  * stays a literal `\n` in a single text node; the paragraph spec's
@@ -5878,25 +6275,8 @@ function dedentContinuation(content, column) {
5878
6275
  function buildParagraph(nodes, content, column) {
5879
6276
  return nodes.paragraph(dedentContinuation(content, column));
5880
6277
  }
5881
- function convertParagraph(nodes, cursor, text) {
5882
- const from = cursor.from;
5883
- const to = cursor.to;
5884
- const column = measureContentColumn(text, from);
5885
- if (cursor.firstChild()) {
5886
- let content = "";
5887
- let pos = from;
5888
- do
5889
- if (cursor.type.id === LEZER_NODE_IDS.QuoteMark) {
5890
- content += text.slice(pos, cursor.from);
5891
- pos = cursor.to;
5892
- if (isSpaceChar(text.charCodeAt(pos))) pos += 1;
5893
- }
5894
- while (cursor.nextSibling());
5895
- cursor.parent();
5896
- content += text.slice(pos, to);
5897
- return buildParagraph(nodes, content, column);
5898
- }
5899
- return buildParagraph(nodes, text.slice(from, to), column);
6278
+ function convertParagraph(nodes, cursor, text, column) {
6279
+ return buildParagraph(nodes, readLeafText(cursor, text, cursor.from, cursor.to), column);
5900
6280
  }
5901
6281
  /**
5902
6282
  * Build the invisible `htmlComment` node from a `CommentBlock`. The raw comment
@@ -5904,11 +6284,14 @@ function convertParagraph(nodes, cursor, text) {
5904
6284
  * continuation lines are dedented like a paragraph's so the serializer's own
5905
6285
  * line prefix re-applies the container indent instead of doubling it.
5906
6286
  */
5907
- function convertHTMLComment(nodes, cursor, text) {
5908
- const column = measureContentColumn(text, cursor.from);
5909
- const content = dedentContinuation(text.slice(cursor.from, cursor.to), column);
6287
+ function convertHTMLComment(nodes, cursor, text, column) {
6288
+ const content = dedentContinuation(readLeafText(cursor, text, cursor.from, cursor.to), column);
5910
6289
  return nodes.htmlComment({ content });
5911
6290
  }
6291
+ /**
6292
+ * A blockquote's children start at column 0: every column an enclosing container
6293
+ * wrote sits in front of the `> ` marker on the line, and comes off with it.
6294
+ */
5912
6295
  function convertBlockquote(nodes, cursor, text) {
5913
6296
  const content = [];
5914
6297
  if (cursor.firstChild()) {
@@ -5917,17 +6300,17 @@ function convertBlockquote(nodes, cursor, text) {
5917
6300
  if (cursor.type.id === LEZER_NODE_IDS.QuoteMark) continue;
5918
6301
  if (previousTo != null) appendGapParagraphs(content, nodes, text, previousTo, cursor.from);
5919
6302
  previousTo = cursor.to;
5920
- content.push(...convertBlock(nodes, cursor, text));
6303
+ appendBlocks(content, nodes, convertBlock(nodes, cursor, text, 0));
5921
6304
  } while (cursor.nextSibling());
5922
6305
  cursor.parent();
5923
6306
  }
5924
6307
  return nodes.blockquote(content);
5925
6308
  }
5926
- function convertList(nodes, cursor, text, kind) {
6309
+ function convertList(nodes, cursor, text, column, kind) {
5927
6310
  const items = [];
5928
6311
  if (cursor.firstChild()) {
5929
6312
  do
5930
- if (cursor.type.id === LEZER_NODE_IDS.ListItem) items.push(convertListItem(nodes, cursor, text, kind));
6313
+ if (cursor.type.id === LEZER_NODE_IDS.ListItem) items.push(convertListItem(nodes, cursor, text, column, kind));
5931
6314
  while (cursor.nextSibling());
5932
6315
  cursor.parent();
5933
6316
  }
@@ -5958,7 +6341,7 @@ function readListMark(cursor, text, kind) {
5958
6341
  * A GFM `Task` leaf (`[ ] text` / `[x] text`, after the list mark). The checkbox
5959
6342
  * becomes the item's own attributes; the text after it becomes its paragraph.
5960
6343
  */
5961
- function convertTaskItem(nodes, cursor, text) {
6344
+ function convertTaskItem(nodes, cursor, text, column) {
5962
6345
  let taskStart = cursor.from;
5963
6346
  const taskEnd = cursor.to;
5964
6347
  let checked = false;
@@ -5978,43 +6361,54 @@ function convertTaskItem(nodes, cursor, text) {
5978
6361
  cursor.parent();
5979
6362
  }
5980
6363
  if (isSpaceChar(text.charCodeAt(taskStart))) taskStart += 1;
5981
- const paragraph = buildParagraph(nodes, text.slice(taskStart, taskEnd), measureContentColumn(text, taskStart));
6364
+ const paragraph = buildParagraph(nodes, text.slice(taskStart, taskEnd), column);
5982
6365
  return {
5983
6366
  checked,
5984
6367
  taskMarker,
5985
6368
  paragraph
5986
6369
  };
5987
6370
  }
5988
- function convertListItem(nodes, cursor, text, kind) {
6371
+ function convertListItem(nodes, cursor, text, column, kind) {
5989
6372
  const content = [];
5990
6373
  let taskChecked;
5991
6374
  let taskMarker;
5992
6375
  let order;
5993
6376
  let marker;
5994
- let markEndColumn;
5995
- let firstContentColumn;
6377
+ let markWidth = 1;
6378
+ let markTo;
6379
+ let markEndColumn = 0;
6380
+ let markerGap = 1;
6381
+ let contentColumn = column + markWidth + markerGap;
6382
+ let sawContent = false;
5996
6383
  if (cursor.firstChild()) {
5997
6384
  do {
5998
- if (cursor.type.id !== LEZER_NODE_IDS.ListMark && firstContentColumn == null) firstContentColumn = measureContentColumn(text, cursor.from);
6385
+ if (cursor.type.id === LEZER_NODE_IDS.QuoteMark) continue;
5999
6386
  if (cursor.type.id === LEZER_NODE_IDS.ListMark) {
6000
6387
  const listMark = readListMark(cursor, text, kind);
6001
6388
  marker = listMark.marker;
6002
6389
  order = listMark.order;
6390
+ markWidth = cursor.to - cursor.from;
6391
+ markTo = cursor.to;
6003
6392
  markEndColumn = measureContentColumn(text, cursor.to);
6004
6393
  continue;
6005
6394
  }
6395
+ if (!sawContent) {
6396
+ sawContent = true;
6397
+ const gap = markTo != null && text.lastIndexOf("\n", cursor.from - 1) < markTo ? measureContentColumn(text, cursor.from) - markEndColumn : 1;
6398
+ markerGap = gap >= 2 && gap <= 4 ? gap : 1;
6399
+ contentColumn = column + markWidth + markerGap;
6400
+ }
6006
6401
  if (kind === "bullet" && cursor.type.id === LEZER_NODE_IDS.Task) {
6007
- const task = convertTaskItem(nodes, cursor, text);
6402
+ const task = convertTaskItem(nodes, cursor, text, contentColumn);
6008
6403
  taskChecked = task.checked;
6009
6404
  taskMarker = task.taskMarker;
6010
6405
  content.push(task.paragraph);
6011
6406
  continue;
6012
6407
  }
6013
- content.push(...convertBlock(nodes, cursor, text));
6408
+ appendBlocks(content, nodes, convertBlock(nodes, cursor, text, contentColumn));
6014
6409
  } while (cursor.nextSibling());
6015
6410
  cursor.parent();
6016
6411
  }
6017
- const gap = firstContentColumn != null && markEndColumn != null ? firstContentColumn - markEndColumn : 1;
6018
6412
  const isTask = taskChecked != null;
6019
6413
  const collapsed = !isTask && kind === "bullet" && marker === "+";
6020
6414
  const attrs = {
@@ -6024,36 +6418,41 @@ function convertListItem(nodes, cursor, text, kind) {
6024
6418
  collapsed,
6025
6419
  marker: collapsed ? null : marker,
6026
6420
  taskMarker,
6027
- markerGap: gap >= 2 && gap <= 4 ? gap : 1
6421
+ markerGap
6028
6422
  };
6029
6423
  return nodes.list(attrs, content);
6030
6424
  }
6031
6425
  function convertCodeBlock(nodes, cursor, text) {
6032
6426
  const indented = cursor.type.id === LEZER_NODE_IDS.CodeBlock;
6427
+ const blockTo = cursor.to;
6033
6428
  let language = "";
6034
6429
  let code = "";
6035
6430
  let fenceStyle = indented ? "indented" : null;
6036
6431
  let fenceLength = null;
6037
6432
  let sawOpeningMark = false;
6433
+ let pos = cursor.from;
6038
6434
  if (cursor.firstChild()) {
6039
- do
6435
+ do {
6436
+ code += uncoveredCode(text, pos, cursor.from);
6437
+ pos = cursor.to;
6040
6438
  switch (cursor.type.id) {
6041
- case LEZER_NODE_IDS.CodeMark: {
6439
+ case LEZER_NODE_IDS.CodeMark:
6042
6440
  if (sawOpeningMark) break;
6043
6441
  sawOpeningMark = true;
6044
6442
  if (text.charCodeAt(cursor.from) === 126) fenceStyle = "tilde";
6045
- const markLength = cursor.to - cursor.from;
6046
- if (markLength > 3) fenceLength = markLength;
6443
+ fenceLength = cursor.to - cursor.from;
6047
6444
  break;
6048
- }
6049
6445
  case LEZER_NODE_IDS.CodeInfo:
6050
6446
  language = text.slice(cursor.from, cursor.to);
6051
6447
  break;
6052
6448
  case LEZER_NODE_IDS.CodeText: code += text.slice(cursor.from, cursor.to);
6053
6449
  }
6054
- while (cursor.nextSibling());
6450
+ } while (cursor.nextSibling());
6055
6451
  cursor.parent();
6452
+ code += uncoveredCode(text, pos, blockTo);
6056
6453
  }
6454
+ if (fenceStyle === "indented" && !canIndentCode(code)) fenceStyle = null;
6455
+ if (fenceLength != null && fenceLength <= minFenceLength(code, fenceStyle === "tilde")) fenceLength = null;
6057
6456
  return nodes.codeBlock({
6058
6457
  language,
6059
6458
  fenceStyle,
@@ -6061,6 +6460,17 @@ function convertCodeBlock(nodes, cursor, text) {
6061
6460
  }, code);
6062
6461
  }
6063
6462
  /**
6463
+ * Text inside a code block that no child node covers is the whitespace in
6464
+ * front of a line's content: indentation, marker spaces, line breaks. The one
6465
+ * exception is a quote marker indented with a tab (`>\t \n\t>2`), where lezer
6466
+ * leaves the content after the marker uncovered too, so anything from a gap's
6467
+ * first non-whitespace character on is code the child list dropped.
6468
+ */
6469
+ function uncoveredCode(text, from, to) {
6470
+ for (let index = from; index < to; index++) if (!isSpaceChar(text.charCodeAt(index))) return text.slice(index, to);
6471
+ return "";
6472
+ }
6473
+ /**
6064
6474
  * A `$$` display math block is a code block whose `language` is `math`; the
6065
6475
  * `dollar` fence style makes it serialize back to `$$` fences.
6066
6476
  */
@@ -6087,15 +6497,29 @@ function convertTable(nodes, cursor, text) {
6087
6497
  cursor.parent();
6088
6498
  }
6089
6499
  const rows = [];
6500
+ let columnCount = aligns.length;
6090
6501
  if (cursor.firstChild()) {
6091
6502
  do {
6092
6503
  const id = cursor.type.id;
6093
- if (id === LEZER_NODE_IDS.TableHeader) rows.push(convertTableRow(nodes, cursor, text, true, aligns));
6094
- else if (id === LEZER_NODE_IDS.TableRow) rows.push(convertTableRow(nodes, cursor, text, false, aligns));
6504
+ if (id !== LEZER_NODE_IDS.TableHeader && id !== LEZER_NODE_IDS.TableRow) continue;
6505
+ const cells = readTableCells(cursor, text);
6506
+ if (cells.length > columnCount) columnCount = cells.length;
6507
+ rows.push({
6508
+ isHeader: id === LEZER_NODE_IDS.TableHeader,
6509
+ cells
6510
+ });
6095
6511
  } while (cursor.nextSibling());
6096
6512
  cursor.parent();
6097
6513
  }
6098
- return nodes.table(rows);
6514
+ return nodes.table(rows.map(({ isHeader, cells }) => {
6515
+ const built = [];
6516
+ for (let column = 0; column < columnCount; column++) {
6517
+ const attrs = { align: aligns[column] ?? null };
6518
+ const paragraph = nodes.paragraph(cells[column] ?? "");
6519
+ built.push(isHeader ? nodes.tableHeaderCell(attrs, paragraph) : nodes.tableCell(attrs, paragraph));
6520
+ }
6521
+ return nodes.tableRow(built);
6522
+ }));
6099
6523
  }
6100
6524
  function parseDelimiterAligns(separator) {
6101
6525
  return separator.split("|").map((segment) => segment.trim()).filter((segment) => segment !== "").map((segment) => {
@@ -6107,71 +6531,88 @@ function parseDelimiterAligns(separator) {
6107
6531
  return null;
6108
6532
  });
6109
6533
  }
6110
- function convertTableRow(nodes, cursor, text, isHeader, aligns) {
6111
- const columnCount = aligns.length;
6112
- const cellTexts = Array(columnCount).fill("");
6113
- if (cursor.firstChild()) {
6114
- const hasLeadingPipe = cursor.type.id === LEZER_NODE_IDS.TableDelimiter;
6115
- let delimiterCount = 0;
6116
- do
6117
- if (cursor.type.id === LEZER_NODE_IDS.TableDelimiter) delimiterCount++;
6118
- else if (cursor.type.id === LEZER_NODE_IDS.TableCell) {
6119
- const column = delimiterCount - (hasLeadingPipe ? 1 : 0);
6120
- if (column >= 0 && column < columnCount) cellTexts[column] = text.slice(cursor.from, cursor.to).trim().replaceAll(String.raw`\|`, "|");
6121
- }
6122
- while (cursor.nextSibling());
6123
- cursor.parent();
6124
- }
6125
- const cells = cellTexts.map((cellText, column) => {
6126
- const paragraph = nodes.paragraph(cellText);
6127
- const attrs = { align: aligns[column] };
6128
- return isHeader ? nodes.tableHeaderCell(attrs, paragraph) : nodes.tableCell(attrs, paragraph);
6129
- });
6130
- return nodes.tableRow(cells);
6534
+ /**
6535
+ * A row's cell texts, indexed by column. `@lezer/markdown` emits no `TableCell`
6536
+ * for an empty cell, so the column comes from the pipes counted so far, not
6537
+ * from the cells seen so far.
6538
+ */
6539
+ function readTableCells(cursor, text) {
6540
+ const cellTexts = [];
6541
+ if (!cursor.firstChild()) return cellTexts;
6542
+ const hasLeadingPipe = cursor.type.id === LEZER_NODE_IDS.TableDelimiter;
6543
+ let delimiterCount = 0;
6544
+ do {
6545
+ if (cursor.type.id === LEZER_NODE_IDS.TableDelimiter) {
6546
+ delimiterCount++;
6547
+ continue;
6548
+ }
6549
+ if (cursor.type.id !== LEZER_NODE_IDS.TableCell) continue;
6550
+ const column = delimiterCount - (hasLeadingPipe ? 1 : 0);
6551
+ if (column < 0) continue;
6552
+ while (cellTexts.length <= column) cellTexts.push("");
6553
+ cellTexts[column] = text.slice(cursor.from, cursor.to).trim().replaceAll(String.raw`\|`, "|");
6554
+ } while (cursor.nextSibling());
6555
+ cursor.parent();
6556
+ return cellTexts;
6131
6557
  }
6132
6558
 
6133
6559
  //#endregion
6134
6560
  //#region src/converters/check-roundtrip.ts
6135
6561
  function trimTrailingNewlines(text) {
6136
- return text.replace(/\n+$/u, "");
6562
+ let end = text.length;
6563
+ while (end > 0 && text.charCodeAt(end - 1) === 10) end--;
6564
+ return text.slice(0, end);
6137
6565
  }
6138
- function isBlankLine(line) {
6139
- return /^[\s>]*$/u.test(line);
6140
- }
6141
- function nonBlankLines(text) {
6142
- return text.split("\n").filter((line) => !isBlankLine(line));
6143
- }
6144
- function collapseWhitespace(line) {
6145
- return line.trim().replaceAll(/\s+/gu, " ");
6566
+ const CONTAINER_PREFIX_RE = /^(?:[\s>]|[-+*](?=\s|$)|\d{1,9}[.)](?=\s|$))+/u;
6567
+ function stripWhitespace(line) {
6568
+ return line.replaceAll(/\s/gu, "");
6146
6569
  }
6147
6570
  const DELIMITER_CELL_RE = /^:?-+:?$/u;
6148
- function canonicalizeDelimiterCell(cell) {
6149
- const alignsLeft = cell.startsWith(":");
6150
- const alignsRight = cell.endsWith(":");
6151
- if (alignsLeft && alignsRight) return ":-:";
6152
- if (alignsLeft) return ":--";
6153
- if (alignsRight) return "--:";
6154
- return "---";
6155
- }
6156
6571
  function canonicalizeTableRow(line) {
6157
6572
  if (!line.includes("|")) return void 0;
6158
- const prefix = /^[\s>]*/u.exec(line)?.[0] ?? "";
6159
- const cells = line.slice(prefix.length).trim().replace(/^\|/u, "").replace(/\|$/u, "").split("|").map((cell) => collapseWhitespace(cell));
6160
- return collapseWhitespace(`${prefix} | ${(cells.every((cell) => DELIMITER_CELL_RE.test(cell)) ? cells.map(canonicalizeDelimiterCell) : cells).join(" | ")} |`);
6573
+ const cells = line.replace(/^\|/u, "").replace(/\|$/u, "").split("|");
6574
+ if (cells.every((cell) => DELIMITER_CELL_RE.test(cell))) return "";
6575
+ return cells.filter((cell) => cell !== "").join("|");
6576
+ }
6577
+ /**
6578
+ * The lines of `text` that carry content, each reduced to that content. A line
6579
+ * left empty by dropping its markers and whitespace (a blank line, a bare `>`,
6580
+ * a list marker whose content is on the next line) carries none and is skipped.
6581
+ */
6582
+ function contentLines(text) {
6583
+ const lines = [];
6584
+ for (const line of text.split("\n")) {
6585
+ const content = stripWhitespace(line.replace(CONTAINER_PREFIX_RE, ""));
6586
+ if (content !== "") lines.push(canonicalizeTableRow(content) ?? content);
6587
+ }
6588
+ return lines;
6161
6589
  }
6162
- function normalizeLine(line) {
6163
- return canonicalizeTableRow(line) ?? collapseWhitespace(line);
6590
+ /**
6591
+ * Whether `wanted` appears in `found` as an ordered subsequence.
6592
+ *
6593
+ * The serializer may write a line the source never had: an unterminated fenced
6594
+ * block gets the closing fence it was missing. Such a line carries no content,
6595
+ * which the document comparison proves - so only a content line that fails to
6596
+ * come back out is loss.
6597
+ */
6598
+ function containsInOrder(found, wanted) {
6599
+ let index = 0;
6600
+ for (const line of found) {
6601
+ if (index === wanted.length) return true;
6602
+ if (line === wanted[index]) index++;
6603
+ }
6604
+ return index === wanted.length;
6164
6605
  }
6165
6606
  /**
6166
6607
  * Classify how `markdown` survives the editor's parse-then-serialize round trip.
6167
6608
  */
6168
6609
  function checkRoundTrip(markdown, options = {}) {
6169
- const doc = markdownToDoc(markdown, { frontmatter: options.frontmatter });
6170
- const serialized = docToMarkdown(doc, { frontmatter: options.frontmatter });
6610
+ const { frontmatter } = options;
6611
+ const doc = markdownToDoc(markdown, { frontmatter });
6612
+ const serialized = docToMarkdown(doc, { frontmatter });
6171
6613
  if (trimTrailingNewlines(serialized) === trimTrailingNewlines(markdown)) return "exact";
6172
- const before = nonBlankLines(markdown);
6173
- const after = nonBlankLines(serialized);
6174
- return before.length === after.length && before.every((line, i) => normalizeLine(line) === normalizeLine(after[i])) ? "normalizing" : "lossy";
6614
+ if (!containsInOrder(contentLines(serialized), contentLines(markdown))) return "lossy";
6615
+ return markdownToDoc(serialized, { frontmatter }).eq(doc) ? "normalizing" : "lossy";
6175
6616
  }
6176
6617
 
6177
6618
  //#endregion
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@meowdown/core",
3
3
  "type": "module",
4
- "version": "0.65.2",
4
+ "version": "0.65.3",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -43,7 +43,7 @@
43
43
  "remark-stringify": "^11.0.0",
44
44
  "unicode-by-name": "^0.2.0",
45
45
  "unified": "^11.0.5",
46
- "@meowdown/markdown": "^0.65.2"
46
+ "@meowdown/markdown": "^0.65.3"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@ocavue/tsconfig": "^0.7.1",
@@ -53,6 +53,7 @@
53
53
  "@vitest/browser-playwright": "^4.1.10",
54
54
  "dedent": "^1.7.2",
55
55
  "diffable-html-snapshot": "^0.3.0",
56
+ "fast-check": "^4.9.0",
56
57
  "tsdown": "^0.23.0-beta.2",
57
58
  "vitest": "^4.1.10",
58
59
  "@meowdown/vitest": "0.0.0"