@meowdown/core 0.68.3 → 0.70.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.js CHANGED
@@ -40,6 +40,12 @@ import { normalizeIdentifier } from "micromark-util-normalize-identifier";
40
40
  import { createListRenderingPlugin, createSafariInputMethodWorkaroundPlugin, createToggleCollapsedCommand, defaultAttributesGetter, enterCommand, findCheckboxInListItem, handleListMarkerMouseDown, joinListElements, listToDOM, unwrapListSlice, wrappingListInputRule } from "prosemirror-flat-list";
41
41
  import { defineTableCellSpec, defineTableCommands, defineTableDropIndicator, defineTableEditingPlugin, defineTableHeaderCellSpec, defineTableRowSpec, defineTableSpec, deleteTable, isCellSelection } from "@prosekit/extensions/table";
42
42
  import { closeHistory } from "@prosekit/pm/history";
43
+ import { fromSyndication } from "@post-embed/exporter/x/syndication";
44
+ import { XPostSchema, YouTubeVideoSchema } from "@post-embed/schema";
45
+ import { createLRU } from "lru.min";
46
+ import * as v from "valibot";
47
+ import { registerXPost } from "@post-embed/elements/x";
48
+ import { registerYouTubeVideo } from "@post-embed/elements/youtube";
43
49
  import { registerResizableHandleElement, registerResizableRootElement } from "@prosekit/web/resizable";
44
50
  import { InputRule } from "@prosekit/pm/inputrules";
45
51
 
@@ -1318,7 +1324,8 @@ function definePlainTextPaste() {
1318
1324
  function docToMarkdown(node, options = {}) {
1319
1325
  const out = new MdOut();
1320
1326
  if (options.frontmatter) emitFrontmatter(node.attrs.frontmatter, out);
1321
- emit$1(node, out);
1327
+ const child = node.childCount === 1 ? node.child(0) : void 0;
1328
+ if (!(child && isNodeOfType(child, "paragraph") && child.childCount === 0)) emit$1(node, out);
1322
1329
  return out.finish();
1323
1330
  }
1324
1331
  /**
@@ -1373,6 +1380,7 @@ var MdOut = class {
1373
1380
  this.pendingFirst = null;
1374
1381
  this.atLineStart = true;
1375
1382
  this.deferredBlankPrefix = null;
1383
+ this.contentEnd = 0;
1376
1384
  }
1377
1385
  /**
1378
1386
  * Write `text`, opening each embedded line with the current line prefix.
@@ -1407,20 +1415,30 @@ var MdOut = class {
1407
1415
  }
1408
1416
  }
1409
1417
  /**
1410
- * End a block that owns no line of its own (an empty paragraph): flush the
1411
- * blank line owed by the previous block now and owe the next block a fresh
1412
- * one, so each empty block yields one extra blank line. A marker-bearing
1413
- * block (an empty list item's `- `) still owns its first line and falls
1414
- * through to `closeBlock`. At the very start of the output there is no
1415
- * blank line to flush or owe - leading empty blocks vanish, mirroring the
1416
- * parser, which materializes empty paragraphs only between sibling blocks.
1418
+ * End a block that owns no line of its own (an empty paragraph): it is one
1419
+ * blank line. After another block, the blank line that block owes is this
1420
+ * one, and the next block is owed a fresh one, so each empty block adds a
1421
+ * blank line to the separator. With nothing owed (the start of the output)
1422
+ * the line is written directly and nothing is owed after it: a leading empty
1423
+ * block is a blank line, not a separator. A marker-bearing block (an empty
1424
+ * list item's `- `, an empty quote line's `>`) writes its marker as the line.
1417
1425
  */
1418
1426
  closeEmptyBlock() {
1419
- if (!this.atLineStart || this.pendingFirst !== null) {
1427
+ if (!this.atLineStart) {
1420
1428
  this.closeBlock();
1421
1429
  return;
1422
1430
  }
1423
- if (this.parts.length === 0) return;
1431
+ if (this.pendingFirst !== null) {
1432
+ this.emitDeferredBlankLine();
1433
+ const marker = this.pendingFirst;
1434
+ this.parts.push(marker.endsWith("] ") ? marker : marker.trimEnd(), "\n");
1435
+ this.pendingFirst = null;
1436
+ return;
1437
+ }
1438
+ if (this.deferredBlankPrefix === null) {
1439
+ this.parts.push(this.linePrefix.trimEnd(), "\n");
1440
+ return;
1441
+ }
1424
1442
  this.emitDeferredBlankLine();
1425
1443
  this.deferredBlankPrefix = this.linePrefix;
1426
1444
  }
@@ -1435,7 +1453,10 @@ var MdOut = class {
1435
1453
  this.pendingFirst = null;
1436
1454
  this.atLineStart = false;
1437
1455
  }
1438
- if (!this.atLineStart) this.parts.push("\n");
1456
+ if (!this.atLineStart) {
1457
+ this.parts.push("\n");
1458
+ this.contentEnd = this.parts.length;
1459
+ }
1439
1460
  this.atLineStart = true;
1440
1461
  this.deferredBlankPrefix = this.linePrefix;
1441
1462
  }
@@ -1486,14 +1507,14 @@ var MdOut = class {
1486
1507
  this.pendingFirst = firstLine !== null ? null : savedFirst;
1487
1508
  }
1488
1509
  finish() {
1489
- const text = this.parts.join("");
1510
+ const text = this.parts.slice(0, this.contentEnd).join("");
1490
1511
  let cut = text.length;
1491
1512
  for (let i = text.length - 1; i >= 0; i--) {
1492
1513
  const code = text.charCodeAt(i);
1493
1514
  if (code === 10) cut = i;
1494
1515
  else if (code !== 32 && code !== 9) break;
1495
1516
  }
1496
- return text.slice(0, cut) + "\n";
1517
+ return (cut === 0 ? "" : text.slice(0, cut) + "\n") + this.parts.slice(this.contentEnd).join("") || "\n";
1497
1518
  }
1498
1519
  emitDeferredBlankLine() {
1499
1520
  const prefix = this.deferredBlankPrefix;
@@ -2829,8 +2850,8 @@ const emptyBatchSetMarkStep = new BatchSetMarkStep([]);
2829
2850
 
2830
2851
  //#endregion
2831
2852
  //#region src/extensions/magic-comment.ts
2832
- const MAGIC_COMMENT_RE = /^<!--\s*(\{[^}]*\})\s*-->$/;
2833
- const TRAILING_MAGIC_COMMENT_RE = /(?:<!--\s*\{[^}]*\}\s*-->)+$/;
2853
+ const MAGIC_COMMENT_RE = /^<!--\s*(\{[\s\S]*?\})\s*-->/;
2854
+ const TRAILING_MAGIC_COMMENT_RE = /(?:<!--\s*\{[\s\S]*?\}\s*-->)+$/;
2834
2855
  /**
2835
2856
  * Read the metadata out of a `<!-- {...} -->` comment, or `undefined` when the
2836
2857
  * text is not a comment carrying at least one recognized field.
@@ -2848,21 +2869,28 @@ function parseMagicComment(comment) {
2848
2869
  const width = toPositiveNumber(data.width);
2849
2870
  const height = toPositiveNumber(data.height);
2850
2871
  const noLink = data.noLink === true ? true : void 0;
2851
- if (!width && !height && !noLink) return;
2872
+ const snapshot = isObject(data.snapshot) && !Array.isArray(data.snapshot) ? data.snapshot : void 0;
2873
+ if (!width && !height && !noLink && !snapshot) return;
2852
2874
  return {
2853
2875
  width,
2854
2876
  height,
2855
- noLink
2877
+ noLink,
2878
+ snapshot
2856
2879
  };
2857
2880
  }
2858
2881
  function toPositiveNumber(value) {
2859
2882
  if (typeof value === "number" && Number.isFinite(value) && value > 0) return Math.round(value);
2860
2883
  }
2861
2884
  /**
2862
- * The canonical comment meowdown writes for the metadata.
2885
+ * The canonical comment meowdown writes for the metadata. `--` inside a JSON
2886
+ * string becomes `--`: the inline comment rule of `@lezer/markdown`
2887
+ * (CommonMark 0.30) ends a comment at any `--`, so a snapshot text holding a
2888
+ * double dash would otherwise cut the comment short. Outside strings JSON
2889
+ * only writes `-` as a number sign, never doubled, so the escape keeps the
2890
+ * JSON valid and `JSON.parse` restores the text.
2863
2891
  */
2864
2892
  function formatMagicComment(magic) {
2865
- return `<!-- ${JSON.stringify(magic)} -->`;
2893
+ return `<!-- ${JSON.stringify(magic).replaceAll("--", String.raw`-\u002d`)} -->`;
2866
2894
  }
2867
2895
  /**
2868
2896
  * Drop the trailing run of magic comments from the source text.
@@ -3476,6 +3504,7 @@ function walkImage(node, parentMarks, text, marks, out, options, context, traili
3476
3504
  const title = resolution.title;
3477
3505
  const width = trailing?.magic.width ?? null;
3478
3506
  const height = trailing?.magic.height ?? null;
3507
+ const snapshot = trailing?.magic.snapshot ?? null;
3479
3508
  const to = trailing?.to ?? node.to;
3480
3509
  emit(out, node.from, to, [
3481
3510
  ...parentMarks,
@@ -3486,6 +3515,7 @@ function walkImage(node, parentMarks, text, marks, out, options, context, traili
3486
3515
  title,
3487
3516
  width,
3488
3517
  height,
3518
+ snapshot,
3489
3519
  syntax: null,
3490
3520
  wikiTarget: null
3491
3521
  })
@@ -3559,6 +3589,7 @@ function walkWikiEmbed(node, parentMarks, text, marks, out, options) {
3559
3589
  title: "",
3560
3590
  width: embed.width,
3561
3591
  height: embed.height,
3592
+ snapshot: null,
3562
3593
  syntax: "wikiEmbed",
3563
3594
  wikiTarget: embed.target
3564
3595
  })
@@ -3857,6 +3888,7 @@ function defineMdImage() {
3857
3888
  title: { default: "" },
3858
3889
  width: { default: null },
3859
3890
  height: { default: null },
3891
+ snapshot: { default: null },
3860
3892
  syntax: { default: null },
3861
3893
  wikiTarget: { default: null }
3862
3894
  },
@@ -6157,7 +6189,7 @@ function markdownToDoc(markdown, options = {}) {
6157
6189
  frontmatterBody = body;
6158
6190
  if (matchLength) rest = markdown.slice(matchLength);
6159
6191
  }
6160
- const blocks = collectBlocks(nodes, gfmBlockOnlyParser.parse(rest).cursor(), rest, 0);
6192
+ const blocks = collectBlocks(nodes, gfmBlockOnlyParser.parse(rest).cursor(), rest, 0, rest !== markdown);
6161
6193
  return nodes.doc(frontmatterBody === void 0 ? {} : { frontmatter: frontmatterBody }, blocks);
6162
6194
  }
6163
6195
  /**
@@ -6175,13 +6207,19 @@ function matchFrontmatter(markdown) {
6175
6207
  return [(match[1] ?? "").replace(/\r?\n$/, ""), match[0].length];
6176
6208
  }
6177
6209
  /**
6178
- * Walk the current node's children, converting each block-level child
6179
- * and flattening any node converter that returns multiple siblings
6180
- * (lists are the main case).
6210
+ * Walk the document's children, converting each block-level child and
6211
+ * flattening any node converter that returns multiple siblings (lists are the
6212
+ * main case). Blank lines before the first block and after the last one are
6213
+ * content too, one empty paragraph each; when a frontmatter block was peeled
6214
+ * off ahead of `text`, the first leading blank line is that block's separator.
6181
6215
  */
6182
- function collectBlocks(nodes, cursor, text, column) {
6216
+ function collectBlocks(nodes, cursor, text, column, afterFrontmatter) {
6183
6217
  const out = [];
6184
- if (!cursor.firstChild()) return out;
6218
+ if (!cursor.firstChild()) {
6219
+ appendEmptyParagraphs(out, nodes, Math.max(1, countNewlines(text, 0, text.length)));
6220
+ return out;
6221
+ }
6222
+ appendEmptyParagraphs(out, nodes, countNewlines(text, 0, cursor.from) - (afterFrontmatter ? 1 : 0));
6185
6223
  let previousTo;
6186
6224
  do {
6187
6225
  if (previousTo != null) appendGapParagraphs(out, nodes, text, previousTo, cursor.from);
@@ -6189,6 +6227,8 @@ function collectBlocks(nodes, cursor, text, column) {
6189
6227
  appendBlocks(out, nodes, convertBlock(nodes, cursor, text, column));
6190
6228
  } while (cursor.nextSibling());
6191
6229
  cursor.parent();
6230
+ const end = text.endsWith("\n") ? text.length - 1 : text.length;
6231
+ appendEmptyParagraphs(out, nodes, countNewlines(text, previousTo, end));
6192
6232
  return out;
6193
6233
  }
6194
6234
  /**
@@ -6218,9 +6258,15 @@ function appendBlocks(out, nodes, blocks) {
6218
6258
  * of them (the previous block's own terminator plus one per blank line).
6219
6259
  */
6220
6260
  function appendGapParagraphs(out, nodes, text, gapFrom, gapTo) {
6221
- let newlineCount = 0;
6222
- for (let i = gapFrom; i < gapTo; i++) if (text.charCodeAt(i) === 10) newlineCount++;
6223
- for (let i = 2; i < newlineCount; i++) out.push(nodes.paragraph());
6261
+ appendEmptyParagraphs(out, nodes, countNewlines(text, gapFrom, gapTo) - 2);
6262
+ }
6263
+ function appendEmptyParagraphs(out, nodes, count) {
6264
+ for (let i = 0; i < count; i++) out.push(nodes.paragraph());
6265
+ }
6266
+ function countNewlines(text, from, to) {
6267
+ let count = 0;
6268
+ for (let i = from; i < to; i++) if (text.charCodeAt(i) === 10) count++;
6269
+ return count;
6224
6270
  }
6225
6271
  function convertBlock(nodes, cursor, text, column) {
6226
6272
  switch (cursor.type.id) {
@@ -6459,30 +6505,44 @@ function convertHTMLComment(nodes, cursor, text, column) {
6459
6505
  /**
6460
6506
  * A blockquote's children start at column 0: every column an enclosing container
6461
6507
  * wrote sits in front of the `> ` marker on the line, and comes off with it.
6508
+ *
6509
+ * Blank quote lines before the first block and after the last one are empty
6510
+ * paragraphs, like the document's own edges. The quote's range ends on its last
6511
+ * line rather than after it, so every newline in the trailing slice is a blank
6512
+ * line, and a quote made of markers only has one more line than newlines.
6462
6513
  */
6463
6514
  function convertBlockquote(nodes, cursor, text) {
6464
6515
  const content = [];
6516
+ const from = cursor.from;
6517
+ const to = cursor.to;
6518
+ let previousTo;
6465
6519
  if (cursor.firstChild()) {
6466
- let previousTo;
6467
6520
  do {
6468
6521
  if (cursor.type.id === LEZER_NODE_IDS.QuoteMark) continue;
6469
- if (previousTo != null) appendGapParagraphs(content, nodes, text, previousTo, cursor.from);
6522
+ if (previousTo == null) appendEmptyParagraphs(content, nodes, countNewlines(text, from, cursor.from));
6523
+ else appendGapParagraphs(content, nodes, text, previousTo, cursor.from);
6470
6524
  previousTo = cursor.to;
6471
6525
  appendBlocks(content, nodes, convertBlock(nodes, cursor, text, 0));
6472
6526
  } while (cursor.nextSibling());
6473
6527
  cursor.parent();
6474
6528
  }
6529
+ appendEmptyParagraphs(content, nodes, previousTo == null ? countNewlines(text, from, to) + 1 : countNewlines(text, previousTo, to));
6475
6530
  return nodes.blockquote(content);
6476
6531
  }
6477
6532
  function convertList(nodes, cursor, text, column, kind) {
6478
- const items = [];
6533
+ const out = [];
6479
6534
  if (cursor.firstChild()) {
6480
- do
6481
- if (cursor.type.id === LEZER_NODE_IDS.ListItem) items.push(convertListItem(nodes, cursor, text, column, kind));
6482
- while (cursor.nextSibling());
6535
+ let previousContentEnd;
6536
+ do {
6537
+ if (cursor.type.id !== LEZER_NODE_IDS.ListItem) continue;
6538
+ if (previousContentEnd != null) appendGapParagraphs(out, nodes, text, previousContentEnd, cursor.from);
6539
+ const [item, contentEnd] = convertListItem(nodes, cursor, text, column, kind);
6540
+ out.push(item);
6541
+ previousContentEnd = contentEnd;
6542
+ } while (cursor.nextSibling());
6483
6543
  cursor.parent();
6484
6544
  }
6485
- return items;
6545
+ return out;
6486
6546
  }
6487
6547
  /**
6488
6548
  * The marker style at `cursor`, plus the start number of an ordered item.
@@ -6536,6 +6596,19 @@ function convertTaskItem(nodes, cursor, text, column) {
6536
6596
  paragraph
6537
6597
  };
6538
6598
  }
6599
+ /**
6600
+ * The gap between a list marker ending at `markTo` and the item's first content
6601
+ * at `contentFrom`. Only content that opens on the marker's own line measures a
6602
+ * gap; an item whose content starts on the next line takes the canonical single
6603
+ * space, the column its own continuation lines are indented to. A gap of 5+ is
6604
+ * indented code (a different node, so the content's column would be the code
6605
+ * block's), and 1 is the canonical default; only a 2-4 space gap is a faithful,
6606
+ * content-preserving variation.
6607
+ */
6608
+ function measureMarkerGap(text, contentFrom, markTo, markEndColumn) {
6609
+ const gap = markTo != null && text.lastIndexOf("\n", contentFrom - 1) < markTo ? measureContentColumn(text, contentFrom) - markEndColumn : 1;
6610
+ return gap >= 2 && gap <= 4 ? gap : 1;
6611
+ }
6539
6612
  function convertListItem(nodes, cursor, text, column, kind) {
6540
6613
  const content = [];
6541
6614
  let taskChecked;
@@ -6547,7 +6620,7 @@ function convertListItem(nodes, cursor, text, column, kind) {
6547
6620
  let markEndColumn = 0;
6548
6621
  let markerGap = 1;
6549
6622
  let contentColumn = column + markWidth + markerGap;
6550
- let sawContent = false;
6623
+ let previousTo;
6551
6624
  if (cursor.firstChild()) {
6552
6625
  do {
6553
6626
  if (cursor.type.id === LEZER_NODE_IDS.QuoteMark) continue;
@@ -6560,12 +6633,11 @@ function convertListItem(nodes, cursor, text, column, kind) {
6560
6633
  markEndColumn = measureContentColumn(text, cursor.to);
6561
6634
  continue;
6562
6635
  }
6563
- if (!sawContent) {
6564
- sawContent = true;
6565
- const gap = markTo != null && text.lastIndexOf("\n", cursor.from - 1) < markTo ? measureContentColumn(text, cursor.from) - markEndColumn : 1;
6566
- markerGap = gap >= 2 && gap <= 4 ? gap : 1;
6636
+ if (previousTo == null) {
6637
+ markerGap = measureMarkerGap(text, cursor.from, markTo, markEndColumn);
6567
6638
  contentColumn = column + markWidth + markerGap;
6568
- }
6639
+ } else appendGapParagraphs(content, nodes, text, previousTo, cursor.from);
6640
+ previousTo = cursor.to;
6569
6641
  if (kind === "bullet" && cursor.type.id === LEZER_NODE_IDS.Task) {
6570
6642
  const task = convertTaskItem(nodes, cursor, text, contentColumn);
6571
6643
  taskChecked = task.checked;
@@ -6588,7 +6660,7 @@ function convertListItem(nodes, cursor, text, column, kind) {
6588
6660
  taskMarker,
6589
6661
  markerGap
6590
6662
  };
6591
- return nodes.list(attrs, content);
6663
+ return [nodes.list(attrs, content), previousTo ?? cursor.to];
6592
6664
  }
6593
6665
  function convertCodeBlock(nodes, cursor, text) {
6594
6666
  const indented = cursor.type.id === LEZER_NODE_IDS.CodeBlock;
@@ -6841,105 +6913,38 @@ const codeBlockLanguages = languages.map((language) => ({
6841
6913
  })).filter((language) => !excludeLanguages.has(language.label)).concat(extraLanguages).sort((a, b) => a.label.localeCompare(b.label));
6842
6914
 
6843
6915
  //#endregion
6844
- //#region src/utils/prefers-dark-color-scheme.ts
6845
- /**
6846
- * Whether the user prefers a dark color scheme. Returns `false` in non-browser
6847
- * (SSR) environments where `window` is unavailable.
6848
- */
6849
- function prefersDarkColorScheme() {
6850
- if (typeof window === "undefined") return false;
6851
- return window.matchMedia("(prefers-color-scheme: dark)").matches;
6916
+ //#region src/extensions/paste.ts
6917
+ function getPastedText(event, slice) {
6918
+ const fromClipboard = event.clipboardData?.getData("text/plain");
6919
+ if (fromClipboard) return fromClipboard;
6920
+ return slice.content.textBetween(0, slice.content.size, "\n");
6852
6921
  }
6853
6922
 
6854
6923
  //#endregion
6855
- //#region src/extensions/tweet.ts
6856
- const TWEET_HOSTS = /^(?:www\.|mobile\.)?(?:twitter\.com|x\.com)$/i;
6924
+ //#region src/extensions/post-embed.ts
6925
+ const X_POST_HOSTS = /^(?:www\.|mobile\.)?(?:twitter\.com|x\.com)$/i;
6857
6926
  const STATUS_ID = /\/status(?:es)?\/(\d+)/;
6858
- function parseTweetId(src) {
6859
- let url;
6927
+ const YOUTUBE_HOSTS = /^(?:www\.|m\.)?(?:youtube\.com|youtube-nocookie\.com)$/i;
6928
+ const YOUTU_BE_HOST = /^(?:www\.)?youtu\.be$/i;
6929
+ const VIDEO_ID = /^[\w-]{11}$/;
6930
+ function parseURL(src) {
6860
6931
  try {
6861
- url = new URL(src);
6932
+ return new URL(src);
6862
6933
  } catch {
6863
6934
  return;
6864
6935
  }
6865
- if (!TWEET_HOSTS.test(url.hostname)) return void 0;
6866
- return STATUS_ID.exec(url.pathname)?.[1];
6867
- }
6868
- const matchTweet = (src) => {
6869
- const tweetId = parseTweetId(src);
6870
- if (!tweetId) return;
6871
- const theme = prefersDarkColorScheme() ? "dark" : "light";
6872
- return {
6873
- kind: "tweet",
6874
- key: `tweet:${tweetId}`,
6875
- src: `https://platform.twitter.com/embed/Tweet.html?id=${tweetId}&theme=${theme}&dnt=true`,
6876
- title: "Tweet",
6877
- className: "md-embed md-embed-tweet",
6878
- testid: "tweet-embed"
6879
- };
6880
- };
6881
- /**
6882
- * `Tweet.html` reports its rendered height via `postMessage`; size the iframe to
6883
- * fit and pass each reported height to `onHeight`. Returns a cleanup that
6884
- * removes the listener. The cleanup also runs once the iframe leaves the DOM, so
6885
- * the editor's DOM mark view (which has no destroy hook) is covered, while a
6886
- * React caller can call it on unmount.
6887
- */
6888
- function listenForTweetHeight(iframe, onHeight) {
6889
- const onMessage = (event) => {
6890
- if (event.source !== iframe.contentWindow) return;
6891
- try {
6892
- const height = event.data?.["twttr.embed"]?.params?.[0]?.height;
6893
- if (typeof height === "number" && height > 0) {
6894
- applyTweetHeight(iframe, height);
6895
- onHeight?.(height);
6896
- }
6897
- } catch (error) {
6898
- console.warn("[meowdown] failed to parse tweet resize message:", error);
6899
- }
6900
- };
6901
- window.addEventListener("message", onMessage);
6902
- let cleaned = false;
6903
- const cleanup = () => {
6904
- if (cleaned) return;
6905
- cleaned = true;
6906
- window.removeEventListener("message", onMessage);
6907
- observer.disconnect();
6908
- };
6909
- const observer = new MutationObserver(() => {
6910
- if (!iframe.isConnected) cleanup();
6911
- });
6912
- observer.observe(document.body, {
6913
- childList: true,
6914
- subtree: true
6915
- });
6916
- return cleanup;
6917
6936
  }
6918
6937
  /**
6919
- * Size a tweet iframe.
6938
+ * The post id of an X status URL, or `undefined` for any other `src`.
6920
6939
  */
6921
- function applyTweetHeight(iframe, height) {
6922
- if (height && height > 0) {
6923
- iframe.style.height = `${height}px`;
6924
- iframe.dataset.sized = "";
6925
- }
6940
+ function parseXPostId(src) {
6941
+ const url = parseURL(src);
6942
+ if (!url || !X_POST_HOSTS.test(url.hostname)) return void 0;
6943
+ return STATUS_ID.exec(url.pathname)?.[1];
6926
6944
  }
6927
-
6928
- //#endregion
6929
- //#region src/extensions/youtube.ts
6930
- const YOUTUBE_HOSTS = /^(?:www\.|m\.)?(?:youtube\.com|youtube-nocookie\.com)$/i;
6931
- const YOUTU_BE_HOST = /^(?:www\.)?youtu\.be$/i;
6932
- const VIDEO_ID = /^[\w-]{11}$/;
6933
- /**
6934
- * Extract `{ videoId, startSeconds? }` from any watch/shorts/embed/live/`youtu.be` URL.
6935
- */
6936
- function parseYouTube(src) {
6937
- let url;
6938
- try {
6939
- url = new URL(src);
6940
- } catch {
6941
- return;
6942
- }
6945
+ function isYouTubeVideo(src) {
6946
+ const url = parseURL(src);
6947
+ if (!url) return false;
6943
6948
  let videoId = null;
6944
6949
  if (YOUTU_BE_HOST.test(url.hostname)) videoId = url.pathname.slice(1);
6945
6950
  else if (YOUTUBE_HOSTS.test(url.hostname)) {
@@ -6947,58 +6952,68 @@ function parseYouTube(src) {
6947
6952
  if (url.pathname === "/watch") videoId = url.searchParams.get("v");
6948
6953
  else if (firstSegment === "shorts" || firstSegment === "embed" || firstSegment === "live") videoId = secondSegment ?? null;
6949
6954
  }
6950
- if (!videoId || !VIDEO_ID.test(videoId)) return void 0;
6951
- const timeParam = url.searchParams.get("start") ?? url.searchParams.get("t");
6952
- const startSeconds = timeParam ? parseStartSeconds(timeParam) : void 0;
6953
- return {
6954
- videoId,
6955
- startSeconds
6956
- };
6955
+ return videoId !== null && VIDEO_ID.test(videoId);
6957
6956
  }
6958
6957
  /**
6959
- * `90`, `90s`, `1m30s`, `1h2m3s` to seconds.
6958
+ * The post-embed card an image `src` renders as, or `undefined` for a plain image.
6960
6959
  */
6961
- function parseStartSeconds(value) {
6962
- if (/^\d+$/.test(value)) return Number(value);
6963
- const matched = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(value);
6964
- if (!matched || !matched[1] && !matched[2] && !matched[3]) return void 0;
6965
- return Number(matched[1] ?? 0) * 3600 + Number(matched[2] ?? 0) * 60 + Number(matched[3] ?? 0);
6960
+ function matchPostEmbed(src) {
6961
+ if (parseXPostId(src) !== void 0) return "x-post";
6962
+ if (isYouTubeVideo(src)) return "youtube-video";
6966
6963
  }
6967
- const matchYouTube = (src) => {
6968
- const parsed = parseYouTube(src);
6969
- if (!parsed) return;
6970
- const query = parsed.startSeconds ? `?start=${parsed.startSeconds}` : "";
6971
- return {
6972
- kind: "youtube",
6973
- key: `youtube:${parsed.videoId}:${parsed.startSeconds ?? 0}`,
6974
- src: `https://www.youtube-nocookie.com/embed/${parsed.videoId}${query}`,
6975
- title: "YouTube video",
6976
- className: "md-embed md-embed-youtube",
6977
- testid: "youtube-embed",
6978
- allow: "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen",
6979
- allowFullscreen: true
6980
- };
6981
- };
6982
-
6983
- //#endregion
6984
- //#region src/extensions/embed.ts
6985
- const EMBED_MATCHERS = [matchYouTube, matchTweet];
6986
6964
  /**
6987
- * Detect a tweet/YouTube embed in an image `src`, or `undefined` for a plain image.
6965
+ * Wrap a loader in a 64-entry LRU keyed by URL. A settled URL answers
6966
+ * synchronously, so a revisited document renders its cards in the first
6967
+ * frame; the first visit returns the in-flight load.
6988
6968
  */
6989
- function matchEmbed(src) {
6990
- for (const match of EMBED_MATCHERS) {
6991
- const descriptor = match(src);
6992
- if (descriptor) return descriptor;
6993
- }
6969
+ function cached(load) {
6970
+ const cache = createLRU({ max: 64 });
6971
+ return (url) => {
6972
+ if (cache.has(url)) return cache.get(url);
6973
+ const pending = load(url).then((value) => {
6974
+ cache.set(url, value);
6975
+ return value;
6976
+ }, (error) => {
6977
+ cache.delete(url);
6978
+ throw error;
6979
+ });
6980
+ cache.set(url, pending);
6981
+ return pending;
6982
+ };
6994
6983
  }
6995
-
6996
- //#endregion
6997
- //#region src/extensions/paste.ts
6998
- function getPastedText(event, slice) {
6999
- const fromClipboard = event.clipboardData?.getData("text/plain");
7000
- if (fromClipboard) return fromClipboard;
7001
- return slice.content.textBetween(0, slice.content.size, "\n");
6984
+ const X_POST_API = "https://react-tweet.vercel.app/api/tweet/";
6985
+ const defaultResolveXPost = cached(async (url) => {
6986
+ const id = parseXPostId(url);
6987
+ if (id === void 0) return;
6988
+ const response = await fetch(X_POST_API + id);
6989
+ if (!response.ok) return;
6990
+ const json = await response.json();
6991
+ return fromSyndication(json.data);
6992
+ });
6993
+ const YOUTUBE_OEMBED_API = "https://www.youtube.com/oembed?format=json&url=";
6994
+ const defaultResolveYouTubeVideo = cached(async (url) => {
6995
+ const response = await fetch(YOUTUBE_OEMBED_API + encodeURIComponent(url));
6996
+ if (!response.ok) return;
6997
+ return {
6998
+ url,
6999
+ ...await response.json()
7000
+ };
7001
+ });
7002
+ const PostEmbedSnapshotSchema = v.variant("kind", [v.object({
7003
+ kind: v.literal("x-post"),
7004
+ data: XPostSchema
7005
+ }), v.object({
7006
+ kind: v.literal("youtube-video"),
7007
+ data: YouTubeVideoSchema
7008
+ })]);
7009
+ /**
7010
+ * The snapshot a card renders from a saved JSON object, or `undefined` when
7011
+ * the object does not validate. Unknown fields are dropped, so a persisted
7012
+ * snapshot is exactly what the card needs.
7013
+ */
7014
+ function parsePostEmbedSnapshot(value) {
7015
+ const result = v.safeParse(PostEmbedSnapshotSchema, value);
7016
+ return result.success ? result.output : void 0;
7002
7017
  }
7003
7018
 
7004
7019
  //#endregion
@@ -7007,7 +7022,7 @@ const embedPasteKey = new PluginKey("meowdown-embed-paste");
7007
7022
  function detectEmbedUrl(text) {
7008
7023
  const trimmed = text.trim();
7009
7024
  if (!trimmed || /\s/.test(trimmed)) return void 0;
7010
- return matchEmbed(trimmed) ? trimmed : void 0;
7025
+ return matchPostEmbed(trimmed) ? trimmed : void 0;
7011
7026
  }
7012
7027
  function insertEmbedFromPaste(view, url) {
7013
7028
  const { from, to } = view.state.selection;
@@ -7739,28 +7754,6 @@ function defaultResolveImageUrl(src) {
7739
7754
  */
7740
7755
  const MAX_DISPLAY_HEIGHT = 500;
7741
7756
  /**
7742
- * Build the iframe DOM for an embed descriptor and start its height listener.
7743
- * A persisted tweet height seeds the iframe before `Tweet.html` reports the
7744
- * real one, so a revisited tweet keeps its space instead of shifting layout.
7745
- */
7746
- function buildEmbedIframe(embed, height, onHeight) {
7747
- const iframe = document.createElement("iframe");
7748
- iframe.src = embed.src;
7749
- iframe.title = embed.title;
7750
- iframe.className = embed.className;
7751
- iframe.dataset.testid = embed.testid;
7752
- iframe.loading = "lazy";
7753
- iframe.referrerPolicy = "strict-origin-when-cross-origin";
7754
- iframe.setAttribute("frameborder", "0");
7755
- if (embed.allow) iframe.allow = embed.allow;
7756
- if (embed.allowFullscreen) iframe.allowFullscreen = true;
7757
- if (embed.kind === "tweet") {
7758
- applyTweetHeight(iframe, height);
7759
- listenForTweetHeight(iframe, onHeight);
7760
- }
7761
- return iframe;
7762
- }
7763
- /**
7764
7757
  * Write a persisted display size onto a resizable resizable root.
7765
7758
  */
7766
7759
  function applySize(root, width, height) {
@@ -7828,40 +7821,59 @@ function commitImageSize(view, content, rawWidth, rawHeight) {
7828
7821
  }, true);
7829
7822
  }
7830
7823
  /**
7831
- * Ignore reported tweet heights this close to the persisted one. Fonts, theme,
7832
- * and container width nudge the rendered height by a few pixels per device;
7833
- * writing those back would churn the document on every open.
7824
+ * Persist a resized width into the trailing magic comment. A card's height is
7825
+ * its content's, so any persisted height is dropped rather than carried over.
7834
7826
  */
7835
- const TWEET_HEIGHT_TOLERANCE = 8;
7827
+ function commitEmbedWidth(view, content, rawWidth) {
7828
+ const pos = view.posAtDOM(content, 0);
7829
+ const range = getMarkRangeAt(view.state, pos, "mdImage");
7830
+ if (!range) return;
7831
+ rewriteMagicComment(view, range, {
7832
+ width: Math.round(rawWidth),
7833
+ height: void 0
7834
+ }, true);
7835
+ }
7836
7836
  /**
7837
- * Persist the height a tweet embed reported, so the next load can seed the
7838
- * iframe before the tweet renders. A passive metadata write: outside undo
7839
- * history, skipped in read-only views, and skipped inside the tolerance.
7837
+ * Persist a resolved snapshot into the trailing magic comment. The whole
7838
+ * `![alt](src)<!-- ... -->` range is rewritten, not just the comment: the
7839
+ * transaction stays out of history, and undoing the edit that inserted the
7840
+ * image must delete the snapshot with it. prosemirror-history maps the
7841
+ * inverse deletion through this step, and a position at the end of a
7842
+ * replaced range maps to the end of the replacement, so the mapped deletion
7843
+ * covers the comment. An insertion at the range end would survive that undo
7844
+ * as an orphan comment rendered as plain text.
7840
7845
  */
7841
- function commitTweetHeight(view, content, height) {
7842
- if (!view.editable || !content.isConnected) return;
7846
+ function commitSnapshot(view, content, src, snapshot) {
7843
7847
  const pos = view.posAtDOM(content, 0);
7844
7848
  const range = getMarkRangeAt(view.state, pos, "mdImage");
7845
7849
  if (!range) return;
7846
- const attrs = range.mark.attrs;
7847
- if (attrs.height != null && Math.abs(height - attrs.height) <= TWEET_HEIGHT_TOLERANCE) return;
7848
- rewriteMagicComment(view, range, { height: Math.round(height) }, false);
7850
+ if (range.mark.attrs.src !== src) return;
7851
+ const current = view.state.doc.textBetween(range.from, range.to);
7852
+ const base = stripMagicComment(current);
7853
+ const comment = formatMagicComment({
7854
+ ...parseMagicComment(current.slice(base.length)),
7855
+ snapshot
7856
+ });
7857
+ if (base + comment === current) return;
7858
+ view.dispatch(view.state.tr.insertText(base + comment, range.from, range.to).setMeta("addToHistory", false));
7849
7859
  }
7850
7860
  var ImageMarkView = class {
7851
7861
  #dom;
7852
7862
  #contentDOM;
7853
7863
  #view;
7854
7864
  #resolveImageUrl;
7855
- #persistTweetHeight;
7865
+ #resolveXPost;
7866
+ #resolveYouTubeVideo;
7856
7867
  #attrs;
7857
7868
  #resizableRoot;
7858
7869
  #image;
7859
- #tweetIframe;
7870
+ #destroyed = false;
7860
7871
  constructor(mark, view, options) {
7861
7872
  this.#attrs = mark.attrs;
7862
7873
  this.#view = view;
7863
7874
  this.#resolveImageUrl = options.resolveImageUrl;
7864
- this.#persistTweetHeight = options.persistTweetHeight ?? true;
7875
+ this.#resolveXPost = options.resolveXPost ?? defaultResolveXPost;
7876
+ this.#resolveYouTubeVideo = options.resolveYouTubeVideo ?? defaultResolveYouTubeVideo;
7865
7877
  this.#dom = document.createElement("span");
7866
7878
  this.#dom.className = "md-image-view md-atom-view";
7867
7879
  this.#contentDOM = document.createElement("span");
@@ -7884,67 +7896,105 @@ var ImageMarkView = class {
7884
7896
  update(mark) {
7885
7897
  const next = mark.attrs;
7886
7898
  const previous = this.#attrs;
7887
- if (next.src !== previous.src) return false;
7899
+ if (next.src !== previous.src || next.snapshot !== previous.snapshot) return false;
7888
7900
  this.#attrs = next;
7889
7901
  if (this.#image && next.alt !== previous.alt) this.#image.alt = next.alt;
7890
7902
  if (this.#resizableRoot && (next.width !== previous.width || next.height !== previous.height)) {
7891
7903
  if (this.#image) applyImageDisplaySize(this.#resizableRoot, this.#image, next.width, next.height);
7892
7904
  else applySize(this.#resizableRoot, next.width, next.height);
7893
7905
  }
7894
- if (this.#tweetIframe && next.height !== previous.height) applyTweetHeight(this.#tweetIframe, next.height);
7895
7906
  return true;
7896
7907
  }
7897
7908
  ignoreMutation(mutation) {
7898
7909
  return !this.#contentDOM.contains(mutation.target);
7899
7910
  }
7911
+ destroy() {
7912
+ this.#destroyed = true;
7913
+ }
7900
7914
  /**
7901
- * Build the inline preview for the image `src`: an embed iframe or a resizable `<img>`.
7915
+ * Build the inline preview for the image `src`: a post-embed card or a
7916
+ * resizable `<img>`.
7902
7917
  */
7903
7918
  #renderPreview() {
7904
7919
  const { src } = this.#attrs;
7905
- const embed = matchEmbed(src);
7906
- if (embed) {
7907
- const wrapper = document.createElement("span");
7908
- wrapper.className = "md-image-view-preview md-atom-view-preview";
7909
- const onHeight = this.#persistTweetHeight ? (height) => {
7910
- commitTweetHeight(this.#view, this.#contentDOM, height);
7911
- } : void 0;
7912
- const iframe = buildEmbedIframe(embed, this.#attrs.height, onHeight);
7913
- if (embed.kind === "tweet") this.#tweetIframe = iframe;
7914
- wrapper.appendChild(embed.kind === "youtube" ? this.#buildResizableEmbed(iframe) : iframe);
7920
+ const wrapper = document.createElement("span");
7921
+ wrapper.className = "md-image-view-preview md-atom-view-preview";
7922
+ const kind = matchPostEmbed(src);
7923
+ if (kind) {
7924
+ wrapper.dataset.testid = `${kind}-embed`;
7925
+ wrapper.appendChild(this.#buildPostEmbed(kind, src));
7915
7926
  return wrapper;
7916
7927
  }
7917
7928
  const url = (this.#resolveImageUrl ?? defaultResolveImageUrl)(src);
7918
7929
  if (!url) return void 0;
7919
- const wrapper = document.createElement("span");
7920
- wrapper.className = "md-image-view-preview md-atom-view-preview";
7921
7930
  wrapper.dataset.testid = "image-preview";
7922
7931
  wrapper.appendChild(this.#buildResizableImage(url));
7923
7932
  return wrapper;
7924
7933
  }
7925
7934
  /**
7926
- * A resizable YouTube embed: the same resizable web component as images, with
7927
- * the player's fixed 16:9 ratio, so a drag only ever picks a width. Releasing
7928
- * a drag writes the size into the markdown source as a
7929
- * `<!-- {"width":N,"height":M} -->` comment, exactly like an image.
7935
+ * A post-embed card renders a saved snapshot as is. Without one (or with
7936
+ * one that does not validate or names another kind) it loads through the
7937
+ * resolver, rendering its own pending and unavailable states, and the first
7938
+ * answer is written back into the source so the next open renders in the
7939
+ * first frame with no request.
7940
+ */
7941
+ #buildPostEmbed(kind, src) {
7942
+ const saved = this.#attrs.snapshot == null ? void 0 : parsePostEmbedSnapshot(this.#attrs.snapshot);
7943
+ if (kind === "x-post") {
7944
+ registerXPost();
7945
+ const element = document.createElement("post-embed-x-post");
7946
+ element.data = saved?.kind === "x-post" ? saved.data : null;
7947
+ element.resolver = this.#persisting(kind, this.#resolveXPost);
7948
+ element.url = src;
7949
+ return element;
7950
+ }
7951
+ registerYouTubeVideo();
7952
+ const element = document.createElement("post-embed-youtube-video");
7953
+ element.playback = "inline";
7954
+ element.data = saved?.kind === "youtube-video" ? saved.data : null;
7955
+ element.resolver = this.#persisting(kind, this.#resolveYouTubeVideo);
7956
+ element.url = src;
7957
+ return this.#buildResizableVideo(element);
7958
+ }
7959
+ /**
7960
+ * Wrap a resolver so its first valid answer is persisted under `kind`.
7961
+ * post-embed only calls it while `data` is null, and logs a rejection
7962
+ * itself.
7930
7963
  */
7931
- #buildResizableEmbed(iframe) {
7964
+ #persisting(kind, resolver) {
7965
+ return (url) => {
7966
+ const result = resolver(url);
7967
+ Promise.resolve(result).then((value) => {
7968
+ if (this.#destroyed || value == null) return;
7969
+ const snapshot = parsePostEmbedSnapshot({
7970
+ kind,
7971
+ data: value
7972
+ });
7973
+ if (snapshot) commitSnapshot(this.#view, this.#contentDOM, url, snapshot);
7974
+ }, () => {});
7975
+ return result;
7976
+ };
7977
+ }
7978
+ /**
7979
+ * A resizable video card: the same resizable web component as images, but
7980
+ * only the dragged width is persisted, as `<!-- {"width":N} -->`; the card
7981
+ * keeps its content height.
7982
+ */
7983
+ #buildResizableVideo(element) {
7932
7984
  registerResizableRootElement();
7933
7985
  registerResizableHandleElement();
7934
7986
  const root = document.createElement("prosekit-resizable-root");
7935
7987
  root.className = "md-embed-resizable";
7936
7988
  root.dataset.testid = "embed-resizable";
7937
- root.setAttribute("data-aspect-ratio", String(16 / 9));
7938
- applySize(root, this.#attrs.width, this.#attrs.height);
7939
- root.appendChild(iframe);
7989
+ applySize(root, this.#attrs.width, null);
7990
+ root.appendChild(element);
7940
7991
  const handle = document.createElement("prosekit-resizable-handle");
7941
7992
  handle.className = "md-image-resize-handle";
7942
7993
  handle.setAttribute("position", "bottom-right");
7943
7994
  handle.addEventListener("click", (event) => event.stopPropagation());
7944
7995
  root.appendChild(handle);
7945
7996
  root.addEventListener("resizeEnd", (event) => {
7946
- const { width: nextWidth, height: nextHeight } = event.detail;
7947
- commitImageSize(this.#view, this.#contentDOM, nextWidth, nextHeight);
7997
+ commitEmbedWidth(this.#view, this.#contentDOM, event.detail.width);
7948
7998
  });
7949
7999
  this.#resizableRoot = root;
7950
8000
  return root;
@@ -8809,4 +8859,4 @@ function getVirtualElementFromRange(view, range) {
8809
8859
  }
8810
8860
 
8811
8861
  //#endregion
8812
- export { EDITOR_KEY_BINDINGS, Priority, buildFileMarkdown, checkRoundTrip, codeBlockLanguages, collectReferenceDefinitions, defaultResolveImageUrl, defineBulletAfterHeading, defineCodeBlockPreviewPlugin, defineCodeBlockSyntaxHighlight, defineEditorExtension, defineEmbedPaste, defineExitBoundaryHandler, defineFileClickHandler, defineFilePaste, defineFileView, defineFollowLinkHandler, defineHTMLComment, defineImage, defineImageClickHandler, defineLinkClickHandler, defineLinkCommands, defineLinkEditKeymap, defineLinkHoverHandler, defineLinkPaste, defineMath, definePendingReplacementHandler, definePlaceholder, defineReadonly, defineSearchStatusHandler, defineSubstitution, defineTagClickHandler, defineViewAttributes, defineVirtualCaret, defineWikilinkClickHandler, defineWikilinkHoverHandler, defineWikilinkTrigger, docToMarkdown, formatFileSize, formatSizedWikiEmbed, getCodeTokens, getFileKind, getIsComposing, getLinkText, getLinkUnitAt, getMarkBuilders, getPendingReplacement, getSearchStatus, getSelectedText, getTableColumnAlign, getTextblockDisplayText, getVirtualElementFromRange, inlineTextToMarkChunks, inlineTextToMarkChunksWithContext, insertLink, isCodeBlockPreviewHiddenDecoration, isLinkTextForHref, isMarkOfType, isModEvent, isNodeOfType, isSelectionInTableCell, listenForTweetHeight, loadKaTeX, markdownToDoc, matchEmbed, normalizeHref, parseWikiEmbed, removeLink, renderMathInto, updateLink, wikiEmbedBasename, withPriority };
8862
+ export { EDITOR_KEY_BINDINGS, Priority, buildFileMarkdown, checkRoundTrip, codeBlockLanguages, collectReferenceDefinitions, defaultResolveImageUrl, defaultResolveXPost, defaultResolveYouTubeVideo, defineBulletAfterHeading, defineCodeBlockPreviewPlugin, defineCodeBlockSyntaxHighlight, defineEditorExtension, defineEmbedPaste, defineExitBoundaryHandler, defineFileClickHandler, defineFilePaste, defineFileView, defineFollowLinkHandler, defineHTMLComment, defineImage, defineImageClickHandler, defineLinkClickHandler, defineLinkCommands, defineLinkEditKeymap, defineLinkHoverHandler, defineLinkPaste, defineMath, definePendingReplacementHandler, definePlaceholder, defineReadonly, defineSearchStatusHandler, defineSubstitution, defineTagClickHandler, defineViewAttributes, defineVirtualCaret, defineWikilinkClickHandler, defineWikilinkHoverHandler, defineWikilinkTrigger, docToMarkdown, formatFileSize, formatSizedWikiEmbed, getCodeTokens, getFileKind, getIsComposing, getLinkText, getLinkUnitAt, getMarkBuilders, getPendingReplacement, getSearchStatus, getSelectedText, getTableColumnAlign, getTextblockDisplayText, getVirtualElementFromRange, inlineTextToMarkChunks, inlineTextToMarkChunksWithContext, insertLink, isCodeBlockPreviewHiddenDecoration, isLinkTextForHref, isMarkOfType, isModEvent, isNodeOfType, isReferenceDefinitionNode, isSelectionInTableCell, loadKaTeX, markdownToDoc, matchPostEmbed, normalizeHref, parsePostEmbedSnapshot, parseWikiEmbed, parseXPostId, removeLink, renderMathInto, updateLink, wikiEmbedBasename, withPriority };