@vectojs/markdown 0.12.0 → 0.14.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.
package/dist/index.mjs CHANGED
@@ -423,7 +423,259 @@ function createStreamController(host, options = {}) {
423
423
  }
424
424
 
425
425
  // src/Markdown.ts
426
- import { measureText, RichText, Stack, Table, Text, Image, UIComponent } from "@vectojs/ui";
426
+ import {
427
+ measureText as measureText2,
428
+ RichText,
429
+ Stack,
430
+ Table,
431
+ Text,
432
+ Image,
433
+ UIComponent as UIComponent2
434
+ } from "@vectojs/ui";
435
+
436
+ // src/blockAffordances.ts
437
+ import { Button, measureText, UIComponent } from "@vectojs/ui";
438
+ var LANGUAGE_EXTENSIONS = {
439
+ bash: "sh",
440
+ c: "c",
441
+ cpp: "cpp",
442
+ cs: "cs",
443
+ css: "css",
444
+ diff: "diff",
445
+ dockerfile: "dockerfile",
446
+ go: "go",
447
+ graphql: "graphql",
448
+ haskell: "hs",
449
+ html: "html",
450
+ java: "java",
451
+ javascript: "js",
452
+ js: "js",
453
+ json: "json",
454
+ jsonc: "jsonc",
455
+ jsx: "jsx",
456
+ kotlin: "kt",
457
+ latex: "tex",
458
+ lua: "lua",
459
+ make: "mk",
460
+ markdown: "md",
461
+ md: "md",
462
+ nix: "nix",
463
+ php: "php",
464
+ python: "py",
465
+ py: "py",
466
+ ruby: "rb",
467
+ rust: "rs",
468
+ rs: "rs",
469
+ scss: "scss",
470
+ sh: "sh",
471
+ shell: "sh",
472
+ sql: "sql",
473
+ svelte: "svelte",
474
+ swift: "swift",
475
+ tex: "tex",
476
+ toml: "toml",
477
+ ts: "ts",
478
+ tsx: "tsx",
479
+ typescript: "ts",
480
+ vue: "vue",
481
+ xml: "xml",
482
+ yaml: "yaml",
483
+ yml: "yaml",
484
+ zig: "zig",
485
+ zsh: "sh"
486
+ };
487
+ function extensionForLanguage(lang) {
488
+ const first = lang.trim().toLowerCase().split(/[\s:,{]/)[0] ?? "";
489
+ return LANGUAGE_EXTENSIONS[first] ?? "txt";
490
+ }
491
+ function mimeForLanguage(lang) {
492
+ const ext = extensionForLanguage(lang);
493
+ if (ext === "json" || ext === "jsonc") return "application/json";
494
+ if (ext === "html") return "text/html";
495
+ if (ext === "css") return "text/css";
496
+ if (ext === "xml" || ext === "svelte" || ext === "vue") return "text/plain";
497
+ return "text/plain";
498
+ }
499
+ function escapeCsvField(value) {
500
+ let needsQuoting = false;
501
+ let hasQuote = false;
502
+ for (const char of value) {
503
+ if (char === '"') {
504
+ hasQuote = true;
505
+ needsQuoting = true;
506
+ break;
507
+ }
508
+ if (char === "," || char === "\n" || char === "\r") needsQuoting = true;
509
+ }
510
+ if (!needsQuoting) return value;
511
+ return hasQuote ? `"${value.replace(/"/g, '""')}"` : `"${value}"`;
512
+ }
513
+ function escapeMarkdownTableCell(cell) {
514
+ let needsEscaping = false;
515
+ for (const char of cell) {
516
+ if (char === "\\" || char === "|") {
517
+ needsEscaping = true;
518
+ break;
519
+ }
520
+ }
521
+ if (!needsEscaping) return cell;
522
+ return cell.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
523
+ }
524
+ function tableToCsv(table) {
525
+ const lines = [table.headers.map(escapeCsvField).join(",")];
526
+ for (const row of table.rows) lines.push(row.map(escapeCsvField).join(","));
527
+ return `\uFEFF${lines.join("\r\n")}`;
528
+ }
529
+ function tableToMarkdown(table) {
530
+ const header = `| ${table.headers.map(escapeMarkdownTableCell).join(" | ")} |`;
531
+ const divider = `| ${table.headers.map((_cell, index) => {
532
+ switch (table.align[index]) {
533
+ case "left":
534
+ return ":---";
535
+ case "center":
536
+ return ":---:";
537
+ case "right":
538
+ return "---:";
539
+ default:
540
+ return "---";
541
+ }
542
+ }).join(" | ")} |`;
543
+ const body = table.rows.map(
544
+ (row) => `| ${table.headers.map((_cell, index) => escapeMarkdownTableCell(row[index] ?? "")).join(" | ")} |`
545
+ );
546
+ return [header, divider, ...body].join("\n");
547
+ }
548
+ function defaultWriteClipboard(text) {
549
+ const clipboard = globalThis.navigator?.clipboard;
550
+ clipboard?.writeText?.(text);
551
+ }
552
+ function defaultSaveFile(filename, content, mimeType) {
553
+ const doc = globalThis.document;
554
+ if (!doc?.body) return;
555
+ const blob = new Blob([content], { type: mimeType });
556
+ const url = URL.createObjectURL(blob);
557
+ const anchor = doc.createElement("a");
558
+ anchor.href = url;
559
+ anchor.download = filename;
560
+ doc.body.appendChild(anchor);
561
+ anchor.click();
562
+ doc.body.removeChild(anchor);
563
+ URL.revokeObjectURL(url);
564
+ }
565
+ var BlockAffordanceButton = class _BlockAffordanceButton extends Button {
566
+ constructor(label, successLabel, act, opts = {}) {
567
+ super(label, { ...opts, onClick: () => this.run() });
568
+ this.act = act;
569
+ this.restingLabel = label;
570
+ this.successLabel = successLabel;
571
+ this.width = Math.max(this.width, measureText(successLabel, this.font) + 24);
572
+ }
573
+ act;
574
+ /** How long the confirmation label stays up, in ms. */
575
+ static FEEDBACK_MS = 1600;
576
+ restingLabel;
577
+ successLabel;
578
+ feedbackTimer;
579
+ /**
580
+ * Runs the action, then shows the confirmation.
581
+ *
582
+ * The action runs first and a throw propagates: a clipboard write the browser
583
+ * rejected must not be reported as a success.
584
+ */
585
+ run() {
586
+ this.act();
587
+ this.setTransientLabel(this.successLabel);
588
+ if (this.feedbackTimer !== void 0) clearTimeout(this.feedbackTimer);
589
+ this.feedbackTimer = setTimeout(() => {
590
+ this.setTransientLabel(this.restingLabel);
591
+ this.feedbackTimer = void 0;
592
+ }, _BlockAffordanceButton.FEEDBACK_MS);
593
+ }
594
+ setTransientLabel(label) {
595
+ this.label = label;
596
+ this.textWidth = measureText(label, this.font);
597
+ this.scene?.markDirty();
598
+ }
599
+ /**
600
+ * The label a reader hears is the one they see, transient confirmation
601
+ * included, so an AT user gets the same feedback a sighted user does.
602
+ */
603
+ getA11yAttributes() {
604
+ return { ...super.getA11yAttributes(), label: this.label };
605
+ }
606
+ /** Clears the pending revert so a destroyed block leaves no timer behind. */
607
+ destroy() {
608
+ if (this.feedbackTimer !== void 0) {
609
+ clearTimeout(this.feedbackTimer);
610
+ this.feedbackTimer = void 0;
611
+ }
612
+ super.destroy();
613
+ }
614
+ };
615
+ var BlockWithAffordances = class _BlockWithAffordances extends UIComponent {
616
+ constructor(block, controls) {
617
+ super();
618
+ this.block = block;
619
+ this.controls = controls;
620
+ this.add(block);
621
+ for (const control of controls) this.add(control);
622
+ this.layoutAffordances();
623
+ }
624
+ block;
625
+ controls;
626
+ /** Gap between the block's edges and the controls, in px. */
627
+ static INSET = 8;
628
+ /** Gap between adjacent controls, in px. */
629
+ static GAP = 6;
630
+ /**
631
+ * Places the controls right-aligned along the block's top edge.
632
+ *
633
+ * Laid out right-to-left from the block's right edge so the first control in
634
+ * the list ends up leftmost, which keeps DOM order (and therefore tab order and
635
+ * the a11y reading order) matching the visual order.
636
+ */
637
+ layoutAffordances() {
638
+ this.width = this.block.width;
639
+ this.height = this.block.height;
640
+ let right = this.block.width - _BlockWithAffordances.INSET;
641
+ for (let i = this.controls.length - 1; i >= 0; i--) {
642
+ const control = this.controls[i];
643
+ control.x = right - control.width;
644
+ control.y = _BlockWithAffordances.INSET;
645
+ right = control.x - _BlockWithAffordances.GAP;
646
+ }
647
+ }
648
+ /**
649
+ * Re-places the controls after the block's own box changed.
650
+ *
651
+ * Called by the owner when a block is resized or its content grew; the controls
652
+ * are anchored to the right edge, so a width change moves them.
653
+ */
654
+ refreshAffordances() {
655
+ this.layoutAffordances();
656
+ this.scene?.markDirty();
657
+ }
658
+ /** The wrapper is a pass-through: its size is the block's size. */
659
+ getLayoutControlledProperties() {
660
+ return ["x", "y"];
661
+ }
662
+ /**
663
+ * Projected as a group so assistive technology reports one labelled region
664
+ * containing the block and its controls, rather than two unrelated siblings.
665
+ */
666
+ getA11yAttributes() {
667
+ return { role: "group", pointerEvents: "none" };
668
+ }
669
+ render() {
670
+ }
671
+ };
672
+ function tableContentOf(token) {
673
+ return {
674
+ headers: token.header.map((cell) => cell.text),
675
+ rows: token.rows.map((row) => row.map((cell) => cell.text)),
676
+ align: token.align
677
+ };
678
+ }
427
679
 
428
680
  // src/frontMatter.ts
429
681
  var OPEN_RE = /^---[ \t]*\r?\n/;
@@ -668,7 +920,59 @@ function isFenceClosed(raw) {
668
920
  return false;
669
921
  }
670
922
  function paragraphHasImage(token) {
671
- return token.tokens?.some((child) => child.type === "image") === true;
923
+ return containsImage(token.tokens);
924
+ }
925
+ function containsImage(tokens) {
926
+ if (!tokens) return false;
927
+ for (const token of tokens) {
928
+ if (token.type === "image") return true;
929
+ if (containsImage(token.tokens)) return true;
930
+ }
931
+ return false;
932
+ }
933
+ function imagesOf(tokens) {
934
+ const images = [];
935
+ for (const token of tokens ?? []) {
936
+ if (token.type === "image") {
937
+ images.push(token);
938
+ continue;
939
+ }
940
+ images.push(...imagesOf(token.tokens));
941
+ }
942
+ return images;
943
+ }
944
+ function stripImages(token) {
945
+ const children = token.tokens;
946
+ if (!children) return token;
947
+ const kept = [];
948
+ for (const child of children) {
949
+ if (child.type === "image") continue;
950
+ const grandchildren = child.tokens;
951
+ if (grandchildren && containsImage(grandchildren)) {
952
+ const stripped = stripImages(child);
953
+ const remaining = stripped.tokens;
954
+ if (remaining && remaining.length > 0) kept.push(stripped);
955
+ continue;
956
+ }
957
+ kept.push(child);
958
+ }
959
+ return { ...token, tokens: kept };
960
+ }
961
+ function liftNestedImages(tokens) {
962
+ const lifted = [];
963
+ for (const token of tokens) {
964
+ if (token.type === "image") {
965
+ lifted.push(token);
966
+ continue;
967
+ }
968
+ const children = token.tokens;
969
+ if (children && containsImage(children)) {
970
+ lifted.push(...liftNestedImages(children));
971
+ continue;
972
+ }
973
+ lifted.push(token);
974
+ }
975
+ return lifted;
672
976
  }
673
977
  function lastIndexOfImage(tokens) {
674
978
  for (let i = tokens.length - 1; i >= 0; i--) {
@@ -679,7 +983,7 @@ function lastIndexOfImage(tokens) {
679
983
  function expectedImageParagraphChildren(tokens) {
680
984
  let children = 0;
681
985
  let inTextRun = false;
682
- for (const token of tokens) {
986
+ for (const token of liftNestedImages(tokens)) {
683
987
  if (token.type === "image") {
684
988
  children++;
685
989
  inTextRun = false;
@@ -839,6 +1143,33 @@ var MarkdownContainer = class extends Entity {
839
1143
  render(_r) {
840
1144
  }
841
1145
  };
1146
+ var MathBlock = class extends MarkdownContainer {
1147
+ /**
1148
+ * The TeX source, exactly as written between the delimiters.
1149
+ *
1150
+ * Also the projected text and the accessible name, so this is the one string a
1151
+ * reader can find, select, and copy.
1152
+ */
1153
+ formula;
1154
+ /** The `data:image/svg+xml` URI of the typeset glyphs. */
1155
+ svgUri;
1156
+ constructor(formula, svgUri) {
1157
+ super();
1158
+ this.formula = formula;
1159
+ this.svgUri = svgUri;
1160
+ }
1161
+ getDevtoolsDescriptor() {
1162
+ return {
1163
+ kind: "MathBlock",
1164
+ groups: [
1165
+ {
1166
+ label: "Math",
1167
+ fields: [{ label: "formula", value: this.formula, readOnly: true }]
1168
+ }
1169
+ ]
1170
+ };
1171
+ }
1172
+ };
842
1173
  var KEYWORD_SETS = {
843
1174
  js: /* @__PURE__ */ new Set([
844
1175
  "const",
@@ -1088,7 +1419,7 @@ function highlightLine(line, lang, theme) {
1088
1419
  flush(theme.codeColor);
1089
1420
  return segments;
1090
1421
  }
1091
- var CodeBlock = class extends UIComponent {
1422
+ var CodeBlock = class extends UIComponent2 {
1092
1423
  lines;
1093
1424
  grid = null;
1094
1425
  /** Raw (unhighlighted) lines of the last build, for prefix reuse in buildLines. */
@@ -1221,7 +1552,7 @@ var CodeBlock = class extends UIComponent {
1221
1552
  this.height = this.pad * 2 + rawLines.length * this.lineH;
1222
1553
  }
1223
1554
  ensureGrid() {
1224
- const cellWidth = this.cellWidth || Math.max(1, measureText("M", this.codeFont));
1555
+ const cellWidth = this.cellWidth || Math.max(1, measureText2("M", this.codeFont));
1225
1556
  if (!this.grid || this.grid.source !== this.source || this.grid.font !== this.codeFont || this.grid.cellWidth !== cellWidth) {
1226
1557
  this.grid = prepareContentGrid(this.source, {
1227
1558
  font: this.codeFont,
@@ -1499,12 +1830,24 @@ function renderInlineToRichText(tokens, fallbackText, font, color, maxWidth, the
1499
1830
  onLinkClick
1500
1831
  });
1501
1832
  }
1502
- var Markdown = class _Markdown extends UIComponent {
1833
+ var Markdown = class _Markdown extends UIComponent2 {
1503
1834
  content;
1504
1835
  maxWidth;
1505
1836
  theme;
1506
1837
  onLinkClick;
1507
1838
  selectable;
1839
+ /**
1840
+ * Whether code blocks and tables carry copy / download controls.
1841
+ *
1842
+ * Read when a block entity is built, so it affects blocks rendered from here on
1843
+ * rather than retroactively; a document does not rebuild to gain or lose an
1844
+ * affordance.
1845
+ */
1846
+ blockAffordances;
1847
+ /** Clipboard writer used by the copy controls. */
1848
+ writeClipboard;
1849
+ /** File saver used by the download controls. */
1850
+ saveFile;
1508
1851
  activeBlockMetrics = null;
1509
1852
  /**
1510
1853
  * Called after a streamed append has re-laid-out the document.
@@ -1729,6 +2072,9 @@ var Markdown = class _Markdown extends UIComponent {
1729
2072
  this.onLinkClick = opts.onLinkClick;
1730
2073
  this.selectable = opts.selectable ?? true;
1731
2074
  this._userTiming = opts.userTiming ?? false;
2075
+ this.blockAffordances = opts.blockAffordances ?? false;
2076
+ this.writeClipboard = opts.writeClipboard ?? defaultWriteClipboard;
2077
+ this.saveFile = opts.saveFile ?? defaultSaveFile;
1732
2078
  this.content = new Stack({ direction: "vertical", gap: 16 });
1733
2079
  this.add(this.content);
1734
2080
  this.rawMarkdown = "";
@@ -2517,6 +2863,73 @@ var Markdown = class _Markdown extends UIComponent {
2517
2863
  * policy for a zero-dimension source is a separate decision from notifying
2518
2864
  * the scene, which is the actual defect here.
2519
2865
  */
2866
+ /**
2867
+ * Wraps a block in its copy / download controls, or returns it untouched.
2868
+ *
2869
+ * The controls are built lazily through `make` so a document with
2870
+ * `blockAffordances` off pays nothing — not the closures, not the measurement
2871
+ * `BlockAffordanceButton` does in its constructor.
2872
+ */
2873
+ withBlockAffordances(block, make) {
2874
+ if (!this.blockAffordances) return block;
2875
+ const controls = make();
2876
+ return controls.length > 0 ? new BlockWithAffordances(block, controls) : block;
2877
+ }
2878
+ /** Copy and download controls for one fenced code block. */
2879
+ codeBlockAffordances(source, lang) {
2880
+ const opts = this.affordanceButtonOptions();
2881
+ return [
2882
+ new BlockAffordanceButton("Copy code", "Copied", () => this.writeClipboard(source), opts),
2883
+ new BlockAffordanceButton(
2884
+ "Download code",
2885
+ "Saved",
2886
+ () => this.saveFile(`code.${extensionForLanguage(lang)}`, source, mimeForLanguage(lang)),
2887
+ opts
2888
+ )
2889
+ ];
2890
+ }
2891
+ /** Copy (as Markdown) and download (as CSV) controls for one table. */
2892
+ tableAffordances(tblToken) {
2893
+ const content = tableContentOf(tblToken);
2894
+ const opts = this.affordanceButtonOptions();
2895
+ return [
2896
+ // Markdown rather than CSV for the clipboard: the reader copied it out of a
2897
+ // Markdown document and the overwhelmingly likely destination is another
2898
+ // one. CSV is what the download is for, where a spreadsheet is the target.
2899
+ new BlockAffordanceButton(
2900
+ "Copy table",
2901
+ "Copied",
2902
+ () => this.writeClipboard(tableToMarkdown(content)),
2903
+ opts
2904
+ ),
2905
+ new BlockAffordanceButton(
2906
+ "Download table",
2907
+ "Saved",
2908
+ () => this.saveFile("table.csv", tableToCsv(content), "text/csv;charset=utf-8"),
2909
+ opts
2910
+ )
2911
+ ];
2912
+ }
2913
+ /**
2914
+ * Button styling for the affordances, derived from the document theme.
2915
+ *
2916
+ * Themed rather than hardcoded so a light-theme document does not get the dark
2917
+ * default palette. `focusColor` is set explicitly from the theme's accent
2918
+ * because `Button`'s default cyan is tuned for the dark palette and reads as
2919
+ * off-brand elsewhere — while a focus ring is the one affordance a keyboard
2920
+ * user cannot do without.
2921
+ */
2922
+ affordanceButtonOptions() {
2923
+ return {
2924
+ font: `600 12px ${this.theme.bodyFont}`,
2925
+ padding: 6,
2926
+ radius: 6,
2927
+ bg: this.theme.codeBgColor,
2928
+ hoverBg: this.theme.tableHeaderBgColor,
2929
+ color: this.theme.textColor,
2930
+ focusColor: this.theme.codeColor
2931
+ };
2932
+ }
2520
2933
  paragraphImage(imgToken, availableWidth) {
2521
2934
  const initialWidth = Math.min(800, availableWidth);
2522
2935
  const initialHeight = Math.round(initialWidth * 0.6);
@@ -2597,6 +3010,7 @@ var Markdown = class _Markdown extends UIComponent {
2597
3010
  itemIsInlineOnly(item) {
2598
3011
  const children = item.tokens;
2599
3012
  if (!children || children.length === 0) return true;
3013
+ if (containsImage(children)) return false;
2600
3014
  if (children.length === 1 && children[0].type === "paragraph") return true;
2601
3015
  return children.every((child) => _Markdown.INLINE_ITEM_TOKENS.has(child.type));
2602
3016
  }
@@ -2626,7 +3040,10 @@ var Markdown = class _Markdown extends UIComponent {
2626
3040
  const children = item.tokens ?? [];
2627
3041
  const stack = new Stack({ direction: "vertical", gap: 4 });
2628
3042
  const first = children[0];
2629
- const leadChildren = first && (first.type === "text" || first.type === "paragraph") ? [first] : [];
3043
+ const firstIsInline = Boolean(first) && (first.type === "text" || first.type === "paragraph");
3044
+ const leadHasImage = firstIsInline && containsImage(first.tokens);
3045
+ const leadChildren = firstIsInline ? [leadHasImage ? stripImages(first) : first] : [];
3046
+ const leadImages = leadHasImage ? imagesOf(first.tokens) : [];
2630
3047
  const leadToken = {
2631
3048
  ...token,
2632
3049
  items: token.items.map((it, i) => i === index ? { ...it, tokens: leadChildren } : it)
@@ -2639,6 +3056,13 @@ var Markdown = class _Markdown extends UIComponent {
2639
3056
  indentStart: indent,
2640
3057
  availableWidth: Math.max(1, availableWidth - indent)
2641
3058
  };
3059
+ for (const image of leadImages) {
3060
+ const el = this.paragraphImage(image, childMetrics.availableWidth);
3061
+ const wrapper = new MarkdownContainer();
3062
+ el.x = indent;
3063
+ wrapper.add(el);
3064
+ stack.add(wrapper);
3065
+ }
2642
3066
  for (let i = leadChildren.length; i < children.length; i++) {
2643
3067
  const el = this.renderTokenWithMetrics(children[i], childMetrics);
2644
3068
  if (!el) continue;
@@ -3361,23 +3785,40 @@ var Markdown = class _Markdown extends UIComponent {
3361
3785
  if (!mathData) return null;
3362
3786
  const intrinsicW = exToPx(mathData.widthEx, t.fontSize);
3363
3787
  const intrinsicH = exToPx(mathData.heightEx, t.fontSize);
3364
- const mathImg = new Image(mathData.uri, {
3365
- width: Math.min(availableWidth, intrinsicW),
3366
- height: intrinsicH * Math.min(1, availableWidth / intrinsicW),
3367
- alt: formula,
3368
- // The SVG decodes asynchronously and Image paints a placeholder until it
3369
- // lands. Without this an `onDemand` scene, which repaints only when marked
3370
- // dirty, leaves the formula a blank slab forever.
3371
- onLoad: () => {
3372
- this.scene?.markDirty();
3788
+ const scale = Math.min(1, availableWidth / intrinsicW);
3789
+ const width = intrinsicW * scale;
3790
+ const height = intrinsicH * scale;
3791
+ const uri = mathData.uri;
3792
+ const math = new RichText(
3793
+ [
3794
+ {
3795
+ text: OBJECT_REPLACEMENT,
3796
+ object: {
3797
+ width,
3798
+ height,
3799
+ // The TeX source is what a reader copies and what a screen reader
3800
+ // announces. KaTeX's dual-layer contract carries the same string in an
3801
+ // `<annotation encoding="application/x-tex">`; here the projection is
3802
+ // the semantic layer, so one copy of the source serves both.
3803
+ alt: formula,
3804
+ paint: (surface, box) => paintInlineMath(uri, surface, box)
3805
+ }
3806
+ }
3807
+ ],
3808
+ {
3809
+ font: `${t.fontSize}px ${t.bodyFont}`,
3810
+ color: t.textColor,
3811
+ maxWidth: availableWidth,
3812
+ selectable: this.selectable
3373
3813
  }
3374
- });
3375
- const wrapper = new MarkdownContainer();
3376
- mathImg.x = 16;
3377
- mathImg.y = 8;
3378
- wrapper.add(mathImg);
3379
- wrapper.width = mathImg.width + 16;
3380
- wrapper.height = mathImg.height + 16;
3814
+ );
3815
+ this.subscribeInlineMathRepaint();
3816
+ const wrapper = new MathBlock(formula, uri);
3817
+ math.x = 16;
3818
+ math.y = 8;
3819
+ wrapper.add(math);
3820
+ wrapper.width = width + 16;
3821
+ wrapper.height = height + 16;
3381
3822
  return wrapper;
3382
3823
  }
3383
3824
  renderToken(token) {
@@ -3439,7 +3880,7 @@ var Markdown = class _Markdown extends UIComponent {
3439
3880
  currentTokens = [];
3440
3881
  }
3441
3882
  };
3442
- for (const child of pToken.tokens) {
3883
+ for (const child of liftNestedImages(pToken.tokens)) {
3443
3884
  if (child.type === "image") {
3444
3885
  flushText();
3445
3886
  stack.add(this.paragraphImage(child, availableWidth));
@@ -3467,7 +3908,10 @@ var Markdown = class _Markdown extends UIComponent {
3467
3908
  const mathBlock = this.renderDisplayMath(codeToken.text, availableWidth);
3468
3909
  if (mathBlock) return mathBlock;
3469
3910
  }
3470
- return new CodeBlock(codeToken.text, lang, availableWidth, t, this.selectable);
3911
+ return this.withBlockAffordances(
3912
+ new CodeBlock(codeToken.text, lang, availableWidth, t, this.selectable),
3913
+ () => this.codeBlockAffordances(codeToken.text, lang)
3914
+ );
3471
3915
  }
3472
3916
  // ── Blockquotes ──────────────────────────────────────────────────
3473
3917
  case "blockquote": {
@@ -3523,21 +3967,24 @@ var Markdown = class _Markdown extends UIComponent {
3523
3967
  const rows = tblToken.rows.map(
3524
3968
  (row) => row.map((cell) => this.tableCellRichText(cell, false, t))
3525
3969
  );
3526
- return new Table({
3527
- headers,
3528
- rows,
3529
- // `| :--- | :---: | ---: |` already resolves to this on the token; it
3530
- // was previously discarded, so every column rendered left-aligned.
3531
- align: tblToken.align,
3532
- width: availableWidth,
3533
- textColor: t.textColor,
3534
- headerTextColor: t.headingColor,
3535
- font: `${t.fontSize - 2}px ${t.bodyFont}`,
3536
- borderColor: t.hrColor,
3537
- bg: t.tableBgColor,
3538
- headerBg: t.tableHeaderBgColor,
3539
- selectable: this.selectable
3540
- });
3970
+ return this.withBlockAffordances(
3971
+ new Table({
3972
+ headers,
3973
+ rows,
3974
+ // `| :--- | :---: | ---: |` already resolves to this on the token; it
3975
+ // was previously discarded, so every column rendered left-aligned.
3976
+ align: tblToken.align,
3977
+ width: availableWidth,
3978
+ textColor: t.textColor,
3979
+ headerTextColor: t.headingColor,
3980
+ font: `${t.fontSize - 2}px ${t.bodyFont}`,
3981
+ borderColor: t.hrColor,
3982
+ bg: t.tableBgColor,
3983
+ headerBg: t.tableHeaderBgColor,
3984
+ selectable: this.selectable
3985
+ }),
3986
+ () => this.tableAffordances(tblToken)
3987
+ );
3541
3988
  }
3542
3989
  // ── Horizontal rule ──────────────────────────────────────────────
3543
3990
  case "hr":
@@ -3572,12 +4019,22 @@ var Markdown = class _Markdown extends UIComponent {
3572
4019
  }
3573
4020
  };
3574
4021
  export {
4022
+ BlockAffordanceButton,
4023
+ BlockWithAffordances,
3575
4024
  CodeBlock,
3576
4025
  Markdown,
4026
+ MathBlock,
3577
4027
  codeAtlas,
3578
4028
  codeAtlasStats,
4029
+ escapeCsvField,
4030
+ escapeMarkdownTableCell,
4031
+ extensionForLanguage,
3579
4032
  isMathJaxReady,
4033
+ mimeForLanguage,
3580
4034
  parseFrontMatterFields,
3581
4035
  preloadMathJax,
3582
- scanFrontMatter
4036
+ scanFrontMatter,
4037
+ tableContentOf,
4038
+ tableToCsv,
4039
+ tableToMarkdown
3583
4040
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectojs/markdown",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -40,7 +40,7 @@
40
40
  "scripts": {
41
41
  "build": "(cd ../ui && bun run build) && node scripts/build-worker.js && tsup src/index.ts --format cjs,esm --clean && tsc -p tsconfig.build.json",
42
42
  "test": "vitest run",
43
- "test:e2e": "bun e2e/blockquote-layout.e2e.ts && bun e2e/stream-controller.e2e.ts && bun e2e/lazy-math.e2e.ts && bun e2e/paragraph-image-repaint.e2e.ts && bun e2e/selection-fidelity.e2e.ts && bun e2e/code-atlas-dpr.e2e.ts && bun e2e/set-max-width.e2e.ts"
43
+ "test:e2e": "bun e2e/blockquote-layout.e2e.ts && bun e2e/stream-controller.e2e.ts && bun e2e/lazy-math.e2e.ts && bun e2e/paragraph-image-repaint.e2e.ts && bun e2e/selection-fidelity.e2e.ts && bun e2e/code-atlas-dpr.e2e.ts && bun e2e/set-max-width.e2e.ts && bun e2e/block-affordances.e2e.ts"
44
44
  },
45
45
  "dependencies": {
46
46
  "marked": "^18.0.7",