@vectojs/markdown 0.11.0 → 0.12.0

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.
@@ -545,6 +545,71 @@ export declare class Markdown extends UIComponent {
545
545
  private paragraphImage;
546
546
  /** One table cell entity, shared by the render arm and the streamed-table path. */
547
547
  private tableCellRichText;
548
+ /**
549
+ * Token types a list item's fast path can render as one `RichText`.
550
+ *
551
+ * An ALLOWLIST, deliberately, following `markstream-vue`'s
552
+ * `SIMPLE_INLINE_TYPES` (`SimpleInlineRenderer/simpleInline.ts:15-31`): a block
553
+ * type is excluded by OMISSION, so a token this renderer has never heard of
554
+ * falls out of the fast path automatically instead of being silently flattened
555
+ * to its raw text. A denylist fails the other way, and the failure is quiet —
556
+ * a formula painted as literal TeX rather than an error — which is why this
557
+ * defect survived so long.
558
+ *
559
+ * Deliberately small, because a list item's DIRECT children are far less varied
560
+ * than they look. Probed against marked 18.0.7: every inline construct
561
+ * (`strong`, `em`, `del`, `codespan`, `link`, `image`, `br`, `escape`, `html`,
562
+ * `inlineMath`) arrives nested one level DEEPER, inside a container whose own
563
+ * type is `text` — so a tight item's direct child list is `text` and nothing
564
+ * else. Listing those inline types here would be dead code.
565
+ *
566
+ * `space` and `checkbox` are included because both are inert here. A blank line
567
+ * between an item's paragraph and its block sibling produces a `space`, and
568
+ * marked unshifts a `checkbox` into every TIGHT GFM task item — the box itself
569
+ * is drawn from `item.task`/`item.checked` by `listItemSpans`, so the token
570
+ * renders nothing on its own. Omitting `checkbox` sent every task item down the
571
+ * block path and moved its marker into a nested entity, which broke four
572
+ * task-list assertions in `Markdown.test.ts`.
573
+ */
574
+ private static readonly INLINE_ITEM_TOKENS;
575
+ /**
576
+ * Does this item consist purely of inline content?
577
+ *
578
+ * True keeps the single-`RichText` fast path, which is not merely an
579
+ * optimization: `updateStreamedList` reuses `stack.children[i]` by calling
580
+ * `setSpans` on it, so an item that becomes a `Stack` forfeits streamed reuse
581
+ * for its entire list. Only pay for a block container when an item holds a
582
+ * block.
583
+ *
584
+ * A lone `paragraph` counts as inline. A LOOSE list re-lexes every item's
585
+ * inline content from `text` to `paragraph` — adding one blank line anywhere
586
+ * flips `token.loose` for the whole list — so treating a single paragraph as a
587
+ * block would drop the fast path for every item of every loose list, the common
588
+ * shape in real prose, for no rendering benefit.
589
+ */
590
+ private itemIsInlineOnly;
591
+ /**
592
+ * Build a list item that holds block-level children.
593
+ *
594
+ * The item becomes a vertical `Stack`: its leading inline run (carrying the
595
+ * marker) first, then every remaining child rendered through the same
596
+ * `renderToken` the document level uses, indented to clear the marker.
597
+ *
598
+ * Recursing rather than special-casing the types we know about is the point — a
599
+ * display formula, a fence, a table, a blockquote, a nested list, an `hr` and a
600
+ * second paragraph all render exactly as they would at indent 0, and a block
601
+ * type added later works here for free.
602
+ *
603
+ * Only the FIRST child can be the lead. Everything after it becomes a block,
604
+ * including a second `paragraph`: an item's two paragraphs are two blocks, and
605
+ * folding them into the lead run would concatenate them into one line with no
606
+ * separation.
607
+ *
608
+ * The lead `RichText` is emitted even when the item has no inline text, because
609
+ * it carries the marker — an item that is nothing but a formula still shows its
610
+ * bullet or ordinal.
611
+ */
612
+ private listItemBlockStack;
548
613
  private listItemSpans;
549
614
  /** Construct the `RichText` for one list item. */
550
615
  private listItemRichText;
package/dist/index.js CHANGED
@@ -1531,7 +1531,7 @@ function renderInlineToRichText(tokens, fallbackText, font, color, maxWidth, the
1531
1531
  onLinkClick
1532
1532
  });
1533
1533
  }
1534
- var Markdown = class extends import_ui.UIComponent {
1534
+ var Markdown = class _Markdown extends import_ui.UIComponent {
1535
1535
  content;
1536
1536
  maxWidth;
1537
1537
  theme;
@@ -2580,6 +2580,109 @@ var Markdown = class extends import_ui.UIComponent {
2580
2580
  onLinkClick: this.onLinkClick
2581
2581
  });
2582
2582
  }
2583
+ /**
2584
+ * Token types a list item's fast path can render as one `RichText`.
2585
+ *
2586
+ * An ALLOWLIST, deliberately, following `markstream-vue`'s
2587
+ * `SIMPLE_INLINE_TYPES` (`SimpleInlineRenderer/simpleInline.ts:15-31`): a block
2588
+ * type is excluded by OMISSION, so a token this renderer has never heard of
2589
+ * falls out of the fast path automatically instead of being silently flattened
2590
+ * to its raw text. A denylist fails the other way, and the failure is quiet —
2591
+ * a formula painted as literal TeX rather than an error — which is why this
2592
+ * defect survived so long.
2593
+ *
2594
+ * Deliberately small, because a list item's DIRECT children are far less varied
2595
+ * than they look. Probed against marked 18.0.7: every inline construct
2596
+ * (`strong`, `em`, `del`, `codespan`, `link`, `image`, `br`, `escape`, `html`,
2597
+ * `inlineMath`) arrives nested one level DEEPER, inside a container whose own
2598
+ * type is `text` — so a tight item's direct child list is `text` and nothing
2599
+ * else. Listing those inline types here would be dead code.
2600
+ *
2601
+ * `space` and `checkbox` are included because both are inert here. A blank line
2602
+ * between an item's paragraph and its block sibling produces a `space`, and
2603
+ * marked unshifts a `checkbox` into every TIGHT GFM task item — the box itself
2604
+ * is drawn from `item.task`/`item.checked` by `listItemSpans`, so the token
2605
+ * renders nothing on its own. Omitting `checkbox` sent every task item down the
2606
+ * block path and moved its marker into a nested entity, which broke four
2607
+ * task-list assertions in `Markdown.test.ts`.
2608
+ */
2609
+ static INLINE_ITEM_TOKENS = /* @__PURE__ */ new Set([
2610
+ "text",
2611
+ "space",
2612
+ "checkbox"
2613
+ ]);
2614
+ /**
2615
+ * Does this item consist purely of inline content?
2616
+ *
2617
+ * True keeps the single-`RichText` fast path, which is not merely an
2618
+ * optimization: `updateStreamedList` reuses `stack.children[i]` by calling
2619
+ * `setSpans` on it, so an item that becomes a `Stack` forfeits streamed reuse
2620
+ * for its entire list. Only pay for a block container when an item holds a
2621
+ * block.
2622
+ *
2623
+ * A lone `paragraph` counts as inline. A LOOSE list re-lexes every item's
2624
+ * inline content from `text` to `paragraph` — adding one blank line anywhere
2625
+ * flips `token.loose` for the whole list — so treating a single paragraph as a
2626
+ * block would drop the fast path for every item of every loose list, the common
2627
+ * shape in real prose, for no rendering benefit.
2628
+ */
2629
+ itemIsInlineOnly(item) {
2630
+ const children = item.tokens;
2631
+ if (!children || children.length === 0) return true;
2632
+ if (children.length === 1 && children[0].type === "paragraph") return true;
2633
+ return children.every((child) => _Markdown.INLINE_ITEM_TOKENS.has(child.type));
2634
+ }
2635
+ /**
2636
+ * Build a list item that holds block-level children.
2637
+ *
2638
+ * The item becomes a vertical `Stack`: its leading inline run (carrying the
2639
+ * marker) first, then every remaining child rendered through the same
2640
+ * `renderToken` the document level uses, indented to clear the marker.
2641
+ *
2642
+ * Recursing rather than special-casing the types we know about is the point — a
2643
+ * display formula, a fence, a table, a blockquote, a nested list, an `hr` and a
2644
+ * second paragraph all render exactly as they would at indent 0, and a block
2645
+ * type added later works here for free.
2646
+ *
2647
+ * Only the FIRST child can be the lead. Everything after it becomes a block,
2648
+ * including a second `paragraph`: an item's two paragraphs are two blocks, and
2649
+ * folding them into the lead run would concatenate them into one line with no
2650
+ * separation.
2651
+ *
2652
+ * The lead `RichText` is emitted even when the item has no inline text, because
2653
+ * it carries the marker — an item that is nothing but a formula still shows its
2654
+ * bullet or ordinal.
2655
+ */
2656
+ listItemBlockStack(token, index, availableWidth, t) {
2657
+ const item = token.items[index];
2658
+ const children = item.tokens ?? [];
2659
+ const stack = new import_ui.Stack({ direction: "vertical", gap: 4 });
2660
+ const first = children[0];
2661
+ const leadChildren = first && (first.type === "text" || first.type === "paragraph") ? [first] : [];
2662
+ const leadToken = {
2663
+ ...token,
2664
+ items: token.items.map((it, i) => i === index ? { ...it, tokens: leadChildren } : it)
2665
+ };
2666
+ stack.add(this.listItemRichText(leadToken, index, availableWidth, t));
2667
+ const indent = Math.round(t.fontSize);
2668
+ const childMetrics = {
2669
+ marginBefore: 0,
2670
+ marginAfter: 0,
2671
+ indentStart: indent,
2672
+ availableWidth: Math.max(1, availableWidth - indent)
2673
+ };
2674
+ for (let i = leadChildren.length; i < children.length; i++) {
2675
+ const el = this.renderTokenWithMetrics(children[i], childMetrics);
2676
+ if (!el) continue;
2677
+ const wrapper = new MarkdownContainer();
2678
+ el.x = indent;
2679
+ wrapper.add(el);
2680
+ wrapper.width = el.width + indent;
2681
+ wrapper.height = el.height;
2682
+ stack.add(wrapper);
2683
+ }
2684
+ return stack;
2685
+ }
2583
2686
  listItemSpans(token, index) {
2584
2687
  const item = token.items[index];
2585
2688
  const num = Number(token.start ?? 1) + index;
@@ -2659,18 +2762,23 @@ var Markdown = class extends import_ui.UIComponent {
2659
2762
  const lastRetained = oldToken.items.length - 1;
2660
2763
  for (let i = 0; i < lastRetained; i++) {
2661
2764
  if (oldToken.items[i].text !== newToken.items[i].text) return false;
2765
+ const isStack = stack.children[i] instanceof import_ui.Stack;
2766
+ if (isStack !== !this.itemIsInlineOnly(newToken.items[i])) return false;
2662
2767
  }
2663
2768
  const availableWidth = this.activeBlockMetrics?.availableWidth ?? this.maxWidth;
2664
2769
  const t = this.theme;
2665
2770
  const tailEntity = stack.children[lastRetained];
2666
2771
  if (oldToken.items[lastRetained].text !== newToken.items[lastRetained].text) {
2772
+ if (!this.itemIsInlineOnly(newToken.items[lastRetained])) return false;
2667
2773
  if (!("setSpans" in tailEntity)) return false;
2668
2774
  tailEntity.setSpans(
2669
2775
  this.listItemSpans(newToken, lastRetained)
2670
2776
  );
2671
2777
  }
2672
2778
  for (let i = oldToken.items.length; i < newToken.items.length; i++) {
2673
- stack.add(this.listItemRichText(newToken, i, availableWidth, t));
2779
+ stack.add(
2780
+ this.itemIsInlineOnly(newToken.items[i]) ? this.listItemRichText(newToken, i, availableWidth, t) : this.listItemBlockStack(newToken, i, availableWidth, t)
2781
+ );
2674
2782
  }
2675
2783
  const last = stack.children.at(-1);
2676
2784
  if (last) stack.resizeLastChild(last);
@@ -3434,7 +3542,9 @@ var Markdown = class extends import_ui.UIComponent {
3434
3542
  const listToken = token;
3435
3543
  const listStack = new import_ui.Stack({ direction: "vertical", gap: 6 });
3436
3544
  for (let i = 0; i < listToken.items.length; i++) {
3437
- listStack.add(this.listItemRichText(listToken, i, availableWidth, t));
3545
+ listStack.add(
3546
+ this.itemIsInlineOnly(listToken.items[i]) ? this.listItemRichText(listToken, i, availableWidth, t) : this.listItemBlockStack(listToken, i, availableWidth, t)
3547
+ );
3438
3548
  }
3439
3549
  return listStack;
3440
3550
  }
package/dist/index.mjs CHANGED
@@ -1499,7 +1499,7 @@ function renderInlineToRichText(tokens, fallbackText, font, color, maxWidth, the
1499
1499
  onLinkClick
1500
1500
  });
1501
1501
  }
1502
- var Markdown = class extends UIComponent {
1502
+ var Markdown = class _Markdown extends UIComponent {
1503
1503
  content;
1504
1504
  maxWidth;
1505
1505
  theme;
@@ -2548,6 +2548,109 @@ var Markdown = class extends UIComponent {
2548
2548
  onLinkClick: this.onLinkClick
2549
2549
  });
2550
2550
  }
2551
+ /**
2552
+ * Token types a list item's fast path can render as one `RichText`.
2553
+ *
2554
+ * An ALLOWLIST, deliberately, following `markstream-vue`'s
2555
+ * `SIMPLE_INLINE_TYPES` (`SimpleInlineRenderer/simpleInline.ts:15-31`): a block
2556
+ * type is excluded by OMISSION, so a token this renderer has never heard of
2557
+ * falls out of the fast path automatically instead of being silently flattened
2558
+ * to its raw text. A denylist fails the other way, and the failure is quiet —
2559
+ * a formula painted as literal TeX rather than an error — which is why this
2560
+ * defect survived so long.
2561
+ *
2562
+ * Deliberately small, because a list item's DIRECT children are far less varied
2563
+ * than they look. Probed against marked 18.0.7: every inline construct
2564
+ * (`strong`, `em`, `del`, `codespan`, `link`, `image`, `br`, `escape`, `html`,
2565
+ * `inlineMath`) arrives nested one level DEEPER, inside a container whose own
2566
+ * type is `text` — so a tight item's direct child list is `text` and nothing
2567
+ * else. Listing those inline types here would be dead code.
2568
+ *
2569
+ * `space` and `checkbox` are included because both are inert here. A blank line
2570
+ * between an item's paragraph and its block sibling produces a `space`, and
2571
+ * marked unshifts a `checkbox` into every TIGHT GFM task item — the box itself
2572
+ * is drawn from `item.task`/`item.checked` by `listItemSpans`, so the token
2573
+ * renders nothing on its own. Omitting `checkbox` sent every task item down the
2574
+ * block path and moved its marker into a nested entity, which broke four
2575
+ * task-list assertions in `Markdown.test.ts`.
2576
+ */
2577
+ static INLINE_ITEM_TOKENS = /* @__PURE__ */ new Set([
2578
+ "text",
2579
+ "space",
2580
+ "checkbox"
2581
+ ]);
2582
+ /**
2583
+ * Does this item consist purely of inline content?
2584
+ *
2585
+ * True keeps the single-`RichText` fast path, which is not merely an
2586
+ * optimization: `updateStreamedList` reuses `stack.children[i]` by calling
2587
+ * `setSpans` on it, so an item that becomes a `Stack` forfeits streamed reuse
2588
+ * for its entire list. Only pay for a block container when an item holds a
2589
+ * block.
2590
+ *
2591
+ * A lone `paragraph` counts as inline. A LOOSE list re-lexes every item's
2592
+ * inline content from `text` to `paragraph` — adding one blank line anywhere
2593
+ * flips `token.loose` for the whole list — so treating a single paragraph as a
2594
+ * block would drop the fast path for every item of every loose list, the common
2595
+ * shape in real prose, for no rendering benefit.
2596
+ */
2597
+ itemIsInlineOnly(item) {
2598
+ const children = item.tokens;
2599
+ if (!children || children.length === 0) return true;
2600
+ if (children.length === 1 && children[0].type === "paragraph") return true;
2601
+ return children.every((child) => _Markdown.INLINE_ITEM_TOKENS.has(child.type));
2602
+ }
2603
+ /**
2604
+ * Build a list item that holds block-level children.
2605
+ *
2606
+ * The item becomes a vertical `Stack`: its leading inline run (carrying the
2607
+ * marker) first, then every remaining child rendered through the same
2608
+ * `renderToken` the document level uses, indented to clear the marker.
2609
+ *
2610
+ * Recursing rather than special-casing the types we know about is the point — a
2611
+ * display formula, a fence, a table, a blockquote, a nested list, an `hr` and a
2612
+ * second paragraph all render exactly as they would at indent 0, and a block
2613
+ * type added later works here for free.
2614
+ *
2615
+ * Only the FIRST child can be the lead. Everything after it becomes a block,
2616
+ * including a second `paragraph`: an item's two paragraphs are two blocks, and
2617
+ * folding them into the lead run would concatenate them into one line with no
2618
+ * separation.
2619
+ *
2620
+ * The lead `RichText` is emitted even when the item has no inline text, because
2621
+ * it carries the marker — an item that is nothing but a formula still shows its
2622
+ * bullet or ordinal.
2623
+ */
2624
+ listItemBlockStack(token, index, availableWidth, t) {
2625
+ const item = token.items[index];
2626
+ const children = item.tokens ?? [];
2627
+ const stack = new Stack({ direction: "vertical", gap: 4 });
2628
+ const first = children[0];
2629
+ const leadChildren = first && (first.type === "text" || first.type === "paragraph") ? [first] : [];
2630
+ const leadToken = {
2631
+ ...token,
2632
+ items: token.items.map((it, i) => i === index ? { ...it, tokens: leadChildren } : it)
2633
+ };
2634
+ stack.add(this.listItemRichText(leadToken, index, availableWidth, t));
2635
+ const indent = Math.round(t.fontSize);
2636
+ const childMetrics = {
2637
+ marginBefore: 0,
2638
+ marginAfter: 0,
2639
+ indentStart: indent,
2640
+ availableWidth: Math.max(1, availableWidth - indent)
2641
+ };
2642
+ for (let i = leadChildren.length; i < children.length; i++) {
2643
+ const el = this.renderTokenWithMetrics(children[i], childMetrics);
2644
+ if (!el) continue;
2645
+ const wrapper = new MarkdownContainer();
2646
+ el.x = indent;
2647
+ wrapper.add(el);
2648
+ wrapper.width = el.width + indent;
2649
+ wrapper.height = el.height;
2650
+ stack.add(wrapper);
2651
+ }
2652
+ return stack;
2653
+ }
2551
2654
  listItemSpans(token, index) {
2552
2655
  const item = token.items[index];
2553
2656
  const num = Number(token.start ?? 1) + index;
@@ -2627,18 +2730,23 @@ var Markdown = class extends UIComponent {
2627
2730
  const lastRetained = oldToken.items.length - 1;
2628
2731
  for (let i = 0; i < lastRetained; i++) {
2629
2732
  if (oldToken.items[i].text !== newToken.items[i].text) return false;
2733
+ const isStack = stack.children[i] instanceof Stack;
2734
+ if (isStack !== !this.itemIsInlineOnly(newToken.items[i])) return false;
2630
2735
  }
2631
2736
  const availableWidth = this.activeBlockMetrics?.availableWidth ?? this.maxWidth;
2632
2737
  const t = this.theme;
2633
2738
  const tailEntity = stack.children[lastRetained];
2634
2739
  if (oldToken.items[lastRetained].text !== newToken.items[lastRetained].text) {
2740
+ if (!this.itemIsInlineOnly(newToken.items[lastRetained])) return false;
2635
2741
  if (!("setSpans" in tailEntity)) return false;
2636
2742
  tailEntity.setSpans(
2637
2743
  this.listItemSpans(newToken, lastRetained)
2638
2744
  );
2639
2745
  }
2640
2746
  for (let i = oldToken.items.length; i < newToken.items.length; i++) {
2641
- stack.add(this.listItemRichText(newToken, i, availableWidth, t));
2747
+ stack.add(
2748
+ this.itemIsInlineOnly(newToken.items[i]) ? this.listItemRichText(newToken, i, availableWidth, t) : this.listItemBlockStack(newToken, i, availableWidth, t)
2749
+ );
2642
2750
  }
2643
2751
  const last = stack.children.at(-1);
2644
2752
  if (last) stack.resizeLastChild(last);
@@ -3402,7 +3510,9 @@ var Markdown = class extends UIComponent {
3402
3510
  const listToken = token;
3403
3511
  const listStack = new Stack({ direction: "vertical", gap: 6 });
3404
3512
  for (let i = 0; i < listToken.items.length; i++) {
3405
- listStack.add(this.listItemRichText(listToken, i, availableWidth, t));
3513
+ listStack.add(
3514
+ this.itemIsInlineOnly(listToken.items[i]) ? this.listItemRichText(listToken, i, availableWidth, t) : this.listItemBlockStack(listToken, i, availableWidth, t)
3515
+ );
3406
3516
  }
3407
3517
  return listStack;
3408
3518
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectojs/markdown",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },