@meowdown/core 0.69.0 → 0.70.1

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
@@ -8,7 +8,9 @@ import { EditorView } from "@prosekit/pm/view";
8
8
  import { EditorNode, Mark, ProseMirrorNode } from "@prosekit/pm/model";
9
9
  import { ListAttrs } from "@prosekit/extensions/list";
10
10
  import { HorizontalRuleExtension } from "@prosekit/extensions/horizontal-rule";
11
+ import { Resolver } from "@post-embed/elements/x";
11
12
  import { render } from "katex";
13
+ import { XPost, YouTubeVideo } from "@post-embed/types";
12
14
  import { VirtualElement } from "@floating-ui/dom";
13
15
  //#region src/converters/check-roundtrip.d.ts
14
16
  /**
@@ -202,6 +204,10 @@ interface MdImageAttrs {
202
204
  * Display height in CSS pixels from the trailing comment, or `null`.
203
205
  */
204
206
  height: number | null;
207
+ /**
208
+ * The saved post-embed snapshot from the trailing comment, or `null`.
209
+ */
210
+ snapshot: object | null;
205
211
  /**
206
212
  * `wikiEmbed` when the source is `![[target]]`; otherwise `null`.
207
213
  */
@@ -1034,62 +1040,6 @@ export declare const codeBlockLanguages: ReadonlyArray<LanguageItem>;
1034
1040
  */
1035
1041
  export declare function defineEmbedPaste(): PlainExtension;
1036
1042
  //#endregion
1037
- //#region src/extensions/embed-types.d.ts
1038
- /**
1039
- * A framework-agnostic description of an embed iframe. Both the editor's DOM
1040
- * mark view and the static React renderer build their own `<iframe>` from it, so
1041
- * the URL/attribute logic lives in one place while each side renders natively.
1042
- */
1043
- interface EmbedDescriptor {
1044
- readonly kind: 'tweet' | 'youtube';
1045
- /**
1046
- * Stable identity for the widget, unique per rendered embed (e.g. the video or
1047
- * tweet id). Keeps ProseMirror from rebuilding the iframe on every edit, and
1048
- * keys the React element, so the embed never reloads.
1049
- */
1050
- readonly key: string;
1051
- /**
1052
- * The iframe `src`.
1053
- */
1054
- readonly src: string;
1055
- /**
1056
- * The iframe `title`.
1057
- */
1058
- readonly title: string;
1059
- /**
1060
- * The iframe `class`, e.g. `md-embed md-embed-tweet`.
1061
- */
1062
- readonly className: string;
1063
- /**
1064
- * The iframe `data-testid`.
1065
- */
1066
- readonly testid: string;
1067
- /**
1068
- * The iframe `allow` policy, when the embed needs one.
1069
- */
1070
- readonly allow?: string;
1071
- /**
1072
- * Whether the iframe is fullscreen-capable.
1073
- */
1074
- readonly allowFullscreen?: boolean;
1075
- }
1076
- //#endregion
1077
- //#region src/extensions/tweet.d.ts
1078
- /**
1079
- * `Tweet.html` reports its rendered height via `postMessage`; size the iframe to
1080
- * fit and pass each reported height to `onHeight`. Returns a cleanup that
1081
- * removes the listener. The cleanup also runs once the iframe leaves the DOM, so
1082
- * the editor's DOM mark view (which has no destroy hook) is covered, while a
1083
- * React caller can call it on unmount.
1084
- */
1085
- export declare function listenForTweetHeight(iframe: HTMLIFrameElement, onHeight?: (height: number) => void): () => void;
1086
- //#endregion
1087
- //#region src/extensions/embed.d.ts
1088
- /**
1089
- * Detect a tweet/YouTube embed in an image `src`, or `undefined` for a plain image.
1090
- */
1091
- export declare function matchEmbed(src: string): EmbedDescriptor | undefined;
1092
- //#endregion
1093
1043
  //#region src/extensions/exit-boundary.d.ts
1094
1044
  /**
1095
1045
  * Payload for {@link ExitBoundaryHandler}.
@@ -1363,6 +1313,39 @@ interface FollowLinkHandlers {
1363
1313
  */
1364
1314
  export declare function defineFollowLinkHandler(handlers: FollowLinkHandlers): PlainExtension;
1365
1315
  //#endregion
1316
+ //#region src/extensions/post-embed.d.ts
1317
+ type XPostResolver = Resolver<XPost>;
1318
+ type YouTubeVideoResolver = Resolver<YouTubeVideo>;
1319
+ type PostEmbedKind = 'x-post' | 'youtube-video';
1320
+ /**
1321
+ * The post id of an X status URL, or `undefined` for any other `src`.
1322
+ */
1323
+ export declare function parseXPostId(src: string): string | undefined;
1324
+ /**
1325
+ * The post-embed card an image `src` renders as, or `undefined` for a plain image.
1326
+ */
1327
+ export declare function matchPostEmbed(src: string): PostEmbedKind | undefined;
1328
+ export declare const defaultResolveXPost: XPostResolver;
1329
+ export declare const defaultResolveYouTubeVideo: YouTubeVideoResolver;
1330
+ /**
1331
+ * The `snapshot` field of an image's magic comment: the card kind and the
1332
+ * data post-embed renders. The kind is stored explicitly, so a saved snapshot
1333
+ * is validated once, against the schema it names.
1334
+ */
1335
+ type PostEmbedSnapshot = {
1336
+ kind: 'x-post';
1337
+ data: XPost;
1338
+ } | {
1339
+ kind: 'youtube-video';
1340
+ data: YouTubeVideo;
1341
+ };
1342
+ /**
1343
+ * The snapshot a card renders from a saved JSON object, or `undefined` when
1344
+ * the object does not validate. Unknown fields are dropped, so a persisted
1345
+ * snapshot is exactly what the card needs.
1346
+ */
1347
+ export declare function parsePostEmbedSnapshot(value: unknown): PostEmbedSnapshot | undefined;
1348
+ //#endregion
1366
1349
  //#region src/extensions/image.d.ts
1367
1350
  type ImageUrlResolver = (src: string) => string | undefined;
1368
1351
  /**
@@ -1375,12 +1358,17 @@ interface ImageOptions {
1375
1358
  */
1376
1359
  resolveImageUrl?: ImageUrlResolver;
1377
1360
  /**
1378
- * Whether to write the height a tweet embed reports back into the trailing
1379
- * size comment, so the next load can seed the iframe at its final height.
1380
- * Defaults to `true`; disable when the document must never change without a
1381
- * user edit (e.g. deterministic tests).
1361
+ * Resolve the data behind an X post URL, rendered as a `post-embed-x-post`
1362
+ * card. Defaults to `defaultResolveXPost`, which fetches through
1363
+ * react-tweet's hosted proxy.
1364
+ */
1365
+ resolveXPost?: XPostResolver;
1366
+ /**
1367
+ * Resolve the data behind a YouTube video URL, rendered as a
1368
+ * `post-embed-youtube-video` card. Defaults to `defaultResolveYouTubeVideo`,
1369
+ * which reads YouTube's oEmbed endpoint.
1382
1370
  */
1383
- persistTweetHeight?: boolean;
1371
+ resolveYouTubeVideo?: YouTubeVideoResolver;
1384
1372
  }
1385
1373
  /**
1386
1374
  * Show an `src` as-is when it is an http(s) URL, otherwise skip rendering it.
@@ -1641,4 +1629,4 @@ export declare function getSelectedText(state: EditorState): string;
1641
1629
  */
1642
1630
  export declare function getVirtualElementFromRange(view: EditorView, range: PositionRange): VirtualElement;
1643
1631
  //#endregion
1644
- export { type AcceptPendingReplacementOptions, type CheckRoundTripOptions, type CodeBlockAttrs, type CodeBlockFenceStyle, type CodeToken, type DocToMarkdownOptions, type EditorExtension, type EditorExtensionOptions, type EmbedDescriptor, type ExitBoundaryHandler, type ExitBoundaryOptions, type FileClickHandler, type FileClickPayload, type FileInfo, type FileInfoResolver, type FileLinkOptions, type FileLinkPayload, type FileLinkResolver, type FilePasteHandler, type FilePasteOptions, type FileSaveErrorHandler, type FileViewOptions, type FollowLinkHandlers, type ImageClickHandler, type ImageClickPayload, type ImageOptions, type InlineMarkContext, type InlineMarkOptions, type KaTeXRender, type LanguageItem, type LinkAttrs, type LinkClickHandler, type LinkClickPayload, type LinkCopyHandler, type LinkCopyPayload, type LinkEditHandler, type LinkEditOptions, type LinkHoverHandler, type LinkHoverOptions, type LinkPreview, type LinkPreviewResolver, type LinkUnit, type ListMarker, type MarkChunk, type MarkMode, type MarkName, type MarkdownToDocOptions, type MdFileAttrs, type MdImageAttrs, type MdLinkTextAttrs, type MdMathAttrs, type MdWikilinkAttrs, type MeowdownCodeBlockAttrs, type MeowdownHTMLCommentAttrs, type MeowdownListAttrs, type MeowdownTableCellAttrs, type NodeName, type ParsedWikiEmbed, type PendingReplacement, type PendingReplacementEvent, type PendingReplacementHandler, type PendingReplacementMode, type PendingReplacementOutcome, type PlaceholderOptions, type PositionRange, Priority, type ReferenceDefinition, type ReferenceDefinitionIndex, type ReferenceDefinitions, type RoundTripFidelity, type SearchStatus, type SearchStatusHandler, type StartPendingReplacementOptions, type TableColumnAlign, type TagClickHandler, type TagClickPayload, type TypedEditor, type TypedMarkBuilders, type VirtualElement, type WikiEmbedOptions, type WikiEmbedResolution, type WikiEmbedResolver, type WikilinkClickHandler, type WikilinkClickPayload, type WikilinkHoverHandler, type WikilinkHoverHit, type WikilinkOptions, type WikilinkPayload, type WikilinkResolution, type WikilinkResolver, defineCodeBlockPreviewPlugin, definePlaceholder, defineReadonly, defineSearchStatusHandler, getSearchStatus, isCodeBlockPreviewHiddenDecoration, withPriority };
1632
+ export { type AcceptPendingReplacementOptions, type CheckRoundTripOptions, type CodeBlockAttrs, type CodeBlockFenceStyle, type CodeToken, type DocToMarkdownOptions, type EditorExtension, type EditorExtensionOptions, type ExitBoundaryHandler, type ExitBoundaryOptions, type FileClickHandler, type FileClickPayload, type FileInfo, type FileInfoResolver, type FileLinkOptions, type FileLinkPayload, type FileLinkResolver, type FilePasteHandler, type FilePasteOptions, type FileSaveErrorHandler, type FileViewOptions, type FollowLinkHandlers, type ImageClickHandler, type ImageClickPayload, type ImageOptions, type InlineMarkContext, type InlineMarkOptions, type KaTeXRender, type LanguageItem, type LinkAttrs, type LinkClickHandler, type LinkClickPayload, type LinkCopyHandler, type LinkCopyPayload, type LinkEditHandler, type LinkEditOptions, type LinkHoverHandler, type LinkHoverOptions, type LinkPreview, type LinkPreviewResolver, type LinkUnit, type ListMarker, type MarkChunk, type MarkMode, type MarkName, type MarkdownToDocOptions, type MdFileAttrs, type MdImageAttrs, type MdLinkTextAttrs, type MdMathAttrs, type MdWikilinkAttrs, type MeowdownCodeBlockAttrs, type MeowdownHTMLCommentAttrs, type MeowdownListAttrs, type MeowdownTableCellAttrs, type NodeName, type ParsedWikiEmbed, type PendingReplacement, type PendingReplacementEvent, type PendingReplacementHandler, type PendingReplacementMode, type PendingReplacementOutcome, type PlaceholderOptions, type PositionRange, type PostEmbedKind, type PostEmbedSnapshot, Priority, type ReferenceDefinition, type ReferenceDefinitionIndex, type ReferenceDefinitions, type RoundTripFidelity, type SearchStatus, type SearchStatusHandler, type StartPendingReplacementOptions, type TableColumnAlign, type TagClickHandler, type TagClickPayload, type TypedEditor, type TypedMarkBuilders, type VirtualElement, type WikiEmbedOptions, type WikiEmbedResolution, type WikiEmbedResolver, type WikilinkClickHandler, type WikilinkClickPayload, type WikilinkHoverHandler, type WikilinkHoverHit, type WikilinkOptions, type WikilinkPayload, type WikilinkResolution, type WikilinkResolver, type XPostResolver, type YouTubeVideoResolver, defineCodeBlockPreviewPlugin, definePlaceholder, defineReadonly, defineSearchStatusHandler, getSearchStatus, isCodeBlockPreviewHiddenDecoration, withPriority };
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
 
@@ -2844,8 +2850,8 @@ const emptyBatchSetMarkStep = new BatchSetMarkStep([]);
2844
2850
 
2845
2851
  //#endregion
2846
2852
  //#region src/extensions/magic-comment.ts
2847
- const MAGIC_COMMENT_RE = /^<!--\s*(\{[^}]*\})\s*-->$/;
2848
- 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*-->)+$/;
2849
2855
  /**
2850
2856
  * Read the metadata out of a `<!-- {...} -->` comment, or `undefined` when the
2851
2857
  * text is not a comment carrying at least one recognized field.
@@ -2863,21 +2869,28 @@ function parseMagicComment(comment) {
2863
2869
  const width = toPositiveNumber(data.width);
2864
2870
  const height = toPositiveNumber(data.height);
2865
2871
  const noLink = data.noLink === true ? true : void 0;
2866
- 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;
2867
2874
  return {
2868
2875
  width,
2869
2876
  height,
2870
- noLink
2877
+ noLink,
2878
+ snapshot
2871
2879
  };
2872
2880
  }
2873
2881
  function toPositiveNumber(value) {
2874
2882
  if (typeof value === "number" && Number.isFinite(value) && value > 0) return Math.round(value);
2875
2883
  }
2876
2884
  /**
2877
- * 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.
2878
2891
  */
2879
2892
  function formatMagicComment(magic) {
2880
- return `<!-- ${JSON.stringify(magic)} -->`;
2893
+ return `<!-- ${JSON.stringify(magic).replaceAll("--", String.raw`-\u002d`)} -->`;
2881
2894
  }
2882
2895
  /**
2883
2896
  * Drop the trailing run of magic comments from the source text.
@@ -3491,6 +3504,7 @@ function walkImage(node, parentMarks, text, marks, out, options, context, traili
3491
3504
  const title = resolution.title;
3492
3505
  const width = trailing?.magic.width ?? null;
3493
3506
  const height = trailing?.magic.height ?? null;
3507
+ const snapshot = trailing?.magic.snapshot ?? null;
3494
3508
  const to = trailing?.to ?? node.to;
3495
3509
  emit(out, node.from, to, [
3496
3510
  ...parentMarks,
@@ -3501,6 +3515,7 @@ function walkImage(node, parentMarks, text, marks, out, options, context, traili
3501
3515
  title,
3502
3516
  width,
3503
3517
  height,
3518
+ snapshot,
3504
3519
  syntax: null,
3505
3520
  wikiTarget: null
3506
3521
  })
@@ -3574,6 +3589,7 @@ function walkWikiEmbed(node, parentMarks, text, marks, out, options) {
3574
3589
  title: "",
3575
3590
  width: embed.width,
3576
3591
  height: embed.height,
3592
+ snapshot: null,
3577
3593
  syntax: "wikiEmbed",
3578
3594
  wikiTarget: embed.target
3579
3595
  })
@@ -3872,6 +3888,7 @@ function defineMdImage() {
3872
3888
  title: { default: "" },
3873
3889
  width: { default: null },
3874
3890
  height: { default: null },
3891
+ snapshot: { default: null },
3875
3892
  syntax: { default: null },
3876
3893
  wikiTarget: { default: null }
3877
3894
  },
@@ -6896,105 +6913,38 @@ const codeBlockLanguages = languages.map((language) => ({
6896
6913
  })).filter((language) => !excludeLanguages.has(language.label)).concat(extraLanguages).sort((a, b) => a.label.localeCompare(b.label));
6897
6914
 
6898
6915
  //#endregion
6899
- //#region src/utils/prefers-dark-color-scheme.ts
6900
- /**
6901
- * Whether the user prefers a dark color scheme. Returns `false` in non-browser
6902
- * (SSR) environments where `window` is unavailable.
6903
- */
6904
- function prefersDarkColorScheme() {
6905
- if (typeof window === "undefined") return false;
6906
- 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");
6907
6921
  }
6908
6922
 
6909
6923
  //#endregion
6910
- //#region src/extensions/tweet.ts
6911
- 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;
6912
6926
  const STATUS_ID = /\/status(?:es)?\/(\d+)/;
6913
- function parseTweetId(src) {
6914
- 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) {
6915
6931
  try {
6916
- url = new URL(src);
6932
+ return new URL(src);
6917
6933
  } catch {
6918
6934
  return;
6919
6935
  }
6920
- if (!TWEET_HOSTS.test(url.hostname)) return void 0;
6921
- return STATUS_ID.exec(url.pathname)?.[1];
6922
6936
  }
6923
- const matchTweet = (src) => {
6924
- const tweetId = parseTweetId(src);
6925
- if (!tweetId) return;
6926
- const theme = prefersDarkColorScheme() ? "dark" : "light";
6927
- return {
6928
- kind: "tweet",
6929
- key: `tweet:${tweetId}`,
6930
- src: `https://platform.twitter.com/embed/Tweet.html?id=${tweetId}&theme=${theme}&dnt=true`,
6931
- title: "Tweet",
6932
- className: "md-embed md-embed-tweet",
6933
- testid: "tweet-embed"
6934
- };
6935
- };
6936
6937
  /**
6937
- * `Tweet.html` reports its rendered height via `postMessage`; size the iframe to
6938
- * fit and pass each reported height to `onHeight`. Returns a cleanup that
6939
- * removes the listener. The cleanup also runs once the iframe leaves the DOM, so
6940
- * the editor's DOM mark view (which has no destroy hook) is covered, while a
6941
- * React caller can call it on unmount.
6938
+ * The post id of an X status URL, or `undefined` for any other `src`.
6942
6939
  */
6943
- function listenForTweetHeight(iframe, onHeight) {
6944
- const onMessage = (event) => {
6945
- if (event.source !== iframe.contentWindow) return;
6946
- try {
6947
- const height = event.data?.["twttr.embed"]?.params?.[0]?.height;
6948
- if (typeof height === "number" && height > 0) {
6949
- applyTweetHeight(iframe, height);
6950
- onHeight?.(height);
6951
- }
6952
- } catch (error) {
6953
- console.warn("[meowdown] failed to parse tweet resize message:", error);
6954
- }
6955
- };
6956
- window.addEventListener("message", onMessage);
6957
- let cleaned = false;
6958
- const cleanup = () => {
6959
- if (cleaned) return;
6960
- cleaned = true;
6961
- window.removeEventListener("message", onMessage);
6962
- observer.disconnect();
6963
- };
6964
- const observer = new MutationObserver(() => {
6965
- if (!iframe.isConnected) cleanup();
6966
- });
6967
- observer.observe(document.body, {
6968
- childList: true,
6969
- subtree: true
6970
- });
6971
- return cleanup;
6972
- }
6973
- /**
6974
- * Size a tweet iframe.
6975
- */
6976
- function applyTweetHeight(iframe, height) {
6977
- if (height && height > 0) {
6978
- iframe.style.height = `${height}px`;
6979
- iframe.dataset.sized = "";
6980
- }
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];
6981
6944
  }
6982
-
6983
- //#endregion
6984
- //#region src/extensions/youtube.ts
6985
- const YOUTUBE_HOSTS = /^(?:www\.|m\.)?(?:youtube\.com|youtube-nocookie\.com)$/i;
6986
- const YOUTU_BE_HOST = /^(?:www\.)?youtu\.be$/i;
6987
- const VIDEO_ID = /^[\w-]{11}$/;
6988
- /**
6989
- * Extract `{ videoId, startSeconds? }` from any watch/shorts/embed/live/`youtu.be` URL.
6990
- */
6991
- function parseYouTube(src) {
6992
- let url;
6993
- try {
6994
- url = new URL(src);
6995
- } catch {
6996
- return;
6997
- }
6945
+ function isYouTubeVideo(src) {
6946
+ const url = parseURL(src);
6947
+ if (!url) return false;
6998
6948
  let videoId = null;
6999
6949
  if (YOUTU_BE_HOST.test(url.hostname)) videoId = url.pathname.slice(1);
7000
6950
  else if (YOUTUBE_HOSTS.test(url.hostname)) {
@@ -7002,58 +6952,68 @@ function parseYouTube(src) {
7002
6952
  if (url.pathname === "/watch") videoId = url.searchParams.get("v");
7003
6953
  else if (firstSegment === "shorts" || firstSegment === "embed" || firstSegment === "live") videoId = secondSegment ?? null;
7004
6954
  }
7005
- if (!videoId || !VIDEO_ID.test(videoId)) return void 0;
7006
- const timeParam = url.searchParams.get("start") ?? url.searchParams.get("t");
7007
- const startSeconds = timeParam ? parseStartSeconds(timeParam) : void 0;
7008
- return {
7009
- videoId,
7010
- startSeconds
7011
- };
6955
+ return videoId !== null && VIDEO_ID.test(videoId);
7012
6956
  }
7013
6957
  /**
7014
- * `90`, `90s`, `1m30s`, `1h2m3s` to seconds.
6958
+ * The post-embed card an image `src` renders as, or `undefined` for a plain image.
7015
6959
  */
7016
- function parseStartSeconds(value) {
7017
- if (/^\d+$/.test(value)) return Number(value);
7018
- const matched = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(value);
7019
- if (!matched || !matched[1] && !matched[2] && !matched[3]) return void 0;
7020
- 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";
7021
6963
  }
7022
- const matchYouTube = (src) => {
7023
- const parsed = parseYouTube(src);
7024
- if (!parsed) return;
7025
- const query = parsed.startSeconds ? `?start=${parsed.startSeconds}` : "";
7026
- return {
7027
- kind: "youtube",
7028
- key: `youtube:${parsed.videoId}:${parsed.startSeconds ?? 0}`,
7029
- src: `https://www.youtube-nocookie.com/embed/${parsed.videoId}${query}`,
7030
- title: "YouTube video",
7031
- className: "md-embed md-embed-youtube",
7032
- testid: "youtube-embed",
7033
- allow: "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen",
7034
- allowFullscreen: true
7035
- };
7036
- };
7037
-
7038
- //#endregion
7039
- //#region src/extensions/embed.ts
7040
- const EMBED_MATCHERS = [matchYouTube, matchTweet];
7041
6964
  /**
7042
- * 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.
7043
6968
  */
7044
- function matchEmbed(src) {
7045
- for (const match of EMBED_MATCHERS) {
7046
- const descriptor = match(src);
7047
- if (descriptor) return descriptor;
7048
- }
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
+ };
7049
6983
  }
7050
-
7051
- //#endregion
7052
- //#region src/extensions/paste.ts
7053
- function getPastedText(event, slice) {
7054
- const fromClipboard = event.clipboardData?.getData("text/plain");
7055
- if (fromClipboard) return fromClipboard;
7056
- 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;
7057
7017
  }
7058
7018
 
7059
7019
  //#endregion
@@ -7062,7 +7022,7 @@ const embedPasteKey = new PluginKey("meowdown-embed-paste");
7062
7022
  function detectEmbedUrl(text) {
7063
7023
  const trimmed = text.trim();
7064
7024
  if (!trimmed || /\s/.test(trimmed)) return void 0;
7065
- return matchEmbed(trimmed) ? trimmed : void 0;
7025
+ return matchPostEmbed(trimmed) ? trimmed : void 0;
7066
7026
  }
7067
7027
  function insertEmbedFromPaste(view, url) {
7068
7028
  const { from, to } = view.state.selection;
@@ -7794,28 +7754,6 @@ function defaultResolveImageUrl(src) {
7794
7754
  */
7795
7755
  const MAX_DISPLAY_HEIGHT = 500;
7796
7756
  /**
7797
- * Build the iframe DOM for an embed descriptor and start its height listener.
7798
- * A persisted tweet height seeds the iframe before `Tweet.html` reports the
7799
- * real one, so a revisited tweet keeps its space instead of shifting layout.
7800
- */
7801
- function buildEmbedIframe(embed, height, onHeight) {
7802
- const iframe = document.createElement("iframe");
7803
- iframe.src = embed.src;
7804
- iframe.title = embed.title;
7805
- iframe.className = embed.className;
7806
- iframe.dataset.testid = embed.testid;
7807
- iframe.loading = "lazy";
7808
- iframe.referrerPolicy = "strict-origin-when-cross-origin";
7809
- iframe.setAttribute("frameborder", "0");
7810
- if (embed.allow) iframe.allow = embed.allow;
7811
- if (embed.allowFullscreen) iframe.allowFullscreen = true;
7812
- if (embed.kind === "tweet") {
7813
- applyTweetHeight(iframe, height);
7814
- listenForTweetHeight(iframe, onHeight);
7815
- }
7816
- return iframe;
7817
- }
7818
- /**
7819
7757
  * Write a persisted display size onto a resizable resizable root.
7820
7758
  */
7821
7759
  function applySize(root, width, height) {
@@ -7883,40 +7821,59 @@ function commitImageSize(view, content, rawWidth, rawHeight) {
7883
7821
  }, true);
7884
7822
  }
7885
7823
  /**
7886
- * Ignore reported tweet heights this close to the persisted one. Fonts, theme,
7887
- * and container width nudge the rendered height by a few pixels per device;
7888
- * 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.
7889
7826
  */
7890
- 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
+ }
7891
7836
  /**
7892
- * Persist the height a tweet embed reported, so the next load can seed the
7893
- * iframe before the tweet renders. A passive metadata write: outside undo
7894
- * 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.
7895
7845
  */
7896
- function commitTweetHeight(view, content, height) {
7897
- if (!view.editable || !content.isConnected) return;
7846
+ function commitSnapshot(view, content, src, snapshot) {
7898
7847
  const pos = view.posAtDOM(content, 0);
7899
7848
  const range = getMarkRangeAt(view.state, pos, "mdImage");
7900
7849
  if (!range) return;
7901
- const attrs = range.mark.attrs;
7902
- if (attrs.height != null && Math.abs(height - attrs.height) <= TWEET_HEIGHT_TOLERANCE) return;
7903
- 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));
7904
7859
  }
7905
7860
  var ImageMarkView = class {
7906
7861
  #dom;
7907
7862
  #contentDOM;
7908
7863
  #view;
7909
7864
  #resolveImageUrl;
7910
- #persistTweetHeight;
7865
+ #resolveXPost;
7866
+ #resolveYouTubeVideo;
7911
7867
  #attrs;
7912
7868
  #resizableRoot;
7913
7869
  #image;
7914
- #tweetIframe;
7870
+ #destroyed = false;
7915
7871
  constructor(mark, view, options) {
7916
7872
  this.#attrs = mark.attrs;
7917
7873
  this.#view = view;
7918
7874
  this.#resolveImageUrl = options.resolveImageUrl;
7919
- this.#persistTweetHeight = options.persistTweetHeight ?? true;
7875
+ this.#resolveXPost = options.resolveXPost ?? defaultResolveXPost;
7876
+ this.#resolveYouTubeVideo = options.resolveYouTubeVideo ?? defaultResolveYouTubeVideo;
7920
7877
  this.#dom = document.createElement("span");
7921
7878
  this.#dom.className = "md-image-view md-atom-view";
7922
7879
  this.#contentDOM = document.createElement("span");
@@ -7939,67 +7896,106 @@ var ImageMarkView = class {
7939
7896
  update(mark) {
7940
7897
  const next = mark.attrs;
7941
7898
  const previous = this.#attrs;
7942
- if (next.src !== previous.src) return false;
7899
+ if (next.src !== previous.src || next.snapshot !== previous.snapshot) return false;
7943
7900
  this.#attrs = next;
7944
7901
  if (this.#image && next.alt !== previous.alt) this.#image.alt = next.alt;
7945
7902
  if (this.#resizableRoot && (next.width !== previous.width || next.height !== previous.height)) {
7946
7903
  if (this.#image) applyImageDisplaySize(this.#resizableRoot, this.#image, next.width, next.height);
7947
7904
  else applySize(this.#resizableRoot, next.width, next.height);
7948
7905
  }
7949
- if (this.#tweetIframe && next.height !== previous.height) applyTweetHeight(this.#tweetIframe, next.height);
7950
7906
  return true;
7951
7907
  }
7952
7908
  ignoreMutation(mutation) {
7953
7909
  return !this.#contentDOM.contains(mutation.target);
7954
7910
  }
7911
+ destroy() {
7912
+ this.#destroyed = true;
7913
+ }
7955
7914
  /**
7956
- * 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>`.
7957
7917
  */
7958
7918
  #renderPreview() {
7959
7919
  const { src } = this.#attrs;
7960
- const embed = matchEmbed(src);
7961
- if (embed) {
7962
- const wrapper = document.createElement("span");
7963
- wrapper.className = "md-image-view-preview md-atom-view-preview";
7964
- const onHeight = this.#persistTweetHeight ? (height) => {
7965
- commitTweetHeight(this.#view, this.#contentDOM, height);
7966
- } : void 0;
7967
- const iframe = buildEmbedIframe(embed, this.#attrs.height, onHeight);
7968
- if (embed.kind === "tweet") this.#tweetIframe = iframe;
7969
- 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.dataset.postEmbed = kind;
7926
+ wrapper.appendChild(this.#buildPostEmbed(kind, src));
7970
7927
  return wrapper;
7971
7928
  }
7972
7929
  const url = (this.#resolveImageUrl ?? defaultResolveImageUrl)(src);
7973
7930
  if (!url) return void 0;
7974
- const wrapper = document.createElement("span");
7975
- wrapper.className = "md-image-view-preview md-atom-view-preview";
7976
7931
  wrapper.dataset.testid = "image-preview";
7977
7932
  wrapper.appendChild(this.#buildResizableImage(url));
7978
7933
  return wrapper;
7979
7934
  }
7980
7935
  /**
7981
- * A resizable YouTube embed: the same resizable web component as images, with
7982
- * the player's fixed 16:9 ratio, so a drag only ever picks a width. Releasing
7983
- * a drag writes the size into the markdown source as a
7984
- * `<!-- {"width":N,"height":M} -->` comment, exactly like an image.
7936
+ * A post-embed card renders a saved snapshot as is. Without one (or with
7937
+ * one that does not validate or names another kind) it loads through the
7938
+ * resolver, rendering its own pending and unavailable states, and the first
7939
+ * answer is written back into the source so the next open renders in the
7940
+ * first frame with no request.
7985
7941
  */
7986
- #buildResizableEmbed(iframe) {
7942
+ #buildPostEmbed(kind, src) {
7943
+ const saved = this.#attrs.snapshot == null ? void 0 : parsePostEmbedSnapshot(this.#attrs.snapshot);
7944
+ if (kind === "x-post") {
7945
+ registerXPost();
7946
+ const element = document.createElement("post-embed-x-post");
7947
+ element.data = saved?.kind === "x-post" ? saved.data : null;
7948
+ element.resolver = this.#persisting(kind, this.#resolveXPost);
7949
+ element.url = src;
7950
+ return element;
7951
+ }
7952
+ registerYouTubeVideo();
7953
+ const element = document.createElement("post-embed-youtube-video");
7954
+ element.playback = "inline";
7955
+ element.data = saved?.kind === "youtube-video" ? saved.data : null;
7956
+ element.resolver = this.#persisting(kind, this.#resolveYouTubeVideo);
7957
+ element.url = src;
7958
+ return this.#buildResizableVideo(element);
7959
+ }
7960
+ /**
7961
+ * Wrap a resolver so its first valid answer is persisted under `kind`.
7962
+ * post-embed only calls it while `data` is null, and logs a rejection
7963
+ * itself.
7964
+ */
7965
+ #persisting(kind, resolver) {
7966
+ return (url) => {
7967
+ const result = resolver(url);
7968
+ Promise.resolve(result).then((value) => {
7969
+ if (this.#destroyed || value == null) return;
7970
+ const snapshot = parsePostEmbedSnapshot({
7971
+ kind,
7972
+ data: value
7973
+ });
7974
+ if (snapshot) commitSnapshot(this.#view, this.#contentDOM, url, snapshot);
7975
+ }, () => {});
7976
+ return result;
7977
+ };
7978
+ }
7979
+ /**
7980
+ * A resizable video card: the same resizable web component as images, but
7981
+ * only the dragged width is persisted, as `<!-- {"width":N} -->`; the card
7982
+ * keeps its content height.
7983
+ */
7984
+ #buildResizableVideo(element) {
7987
7985
  registerResizableRootElement();
7988
7986
  registerResizableHandleElement();
7989
7987
  const root = document.createElement("prosekit-resizable-root");
7990
7988
  root.className = "md-embed-resizable";
7991
7989
  root.dataset.testid = "embed-resizable";
7992
- root.setAttribute("data-aspect-ratio", String(16 / 9));
7993
- applySize(root, this.#attrs.width, this.#attrs.height);
7994
- root.appendChild(iframe);
7990
+ applySize(root, this.#attrs.width, null);
7991
+ root.appendChild(element);
7995
7992
  const handle = document.createElement("prosekit-resizable-handle");
7996
7993
  handle.className = "md-image-resize-handle";
7997
7994
  handle.setAttribute("position", "bottom-right");
7998
7995
  handle.addEventListener("click", (event) => event.stopPropagation());
7999
7996
  root.appendChild(handle);
8000
7997
  root.addEventListener("resizeEnd", (event) => {
8001
- const { width: nextWidth, height: nextHeight } = event.detail;
8002
- commitImageSize(this.#view, this.#contentDOM, nextWidth, nextHeight);
7998
+ commitEmbedWidth(this.#view, this.#contentDOM, event.detail.width);
8003
7999
  });
8004
8000
  this.#resizableRoot = root;
8005
8001
  return root;
@@ -8864,4 +8860,4 @@ function getVirtualElementFromRange(view, range) {
8864
8860
  }
8865
8861
 
8866
8862
  //#endregion
8867
- 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, isReferenceDefinitionNode, isSelectionInTableCell, listenForTweetHeight, loadKaTeX, markdownToDoc, matchEmbed, normalizeHref, parseWikiEmbed, removeLink, renderMathInto, updateLink, wikiEmbedBasename, withPriority };
8863
+ 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 };
package/dist/style.css CHANGED
@@ -147,6 +147,404 @@
147
147
  .prosemirror-flat-list[data-list-kind="toggle"][data-list-collapsable][data-list-collapsed] > .list-content > :nth-child(n+2) {
148
148
  display: none;
149
149
  }
150
+
151
+ @property --post-embed-color {
152
+ syntax: "*";
153
+ inherits: true
154
+ }
155
+
156
+ @property --post-embed-background {
157
+ syntax: "*";
158
+ inherits: true
159
+ }
160
+
161
+ @property --post-embed-border-color {
162
+ syntax: "*";
163
+ inherits: true
164
+ }
165
+
166
+ @property --post-embed-radius {
167
+ syntax: "*";
168
+ inherits: true
169
+ }
170
+
171
+ @property --post-embed-padding {
172
+ syntax: "*";
173
+ inherits: true
174
+ }
175
+
176
+ @property --post-embed-muted-color {
177
+ syntax: "*";
178
+ inherits: true
179
+ }
180
+
181
+ @property --post-embed-link-color {
182
+ syntax: "*";
183
+ inherits: true
184
+ }
185
+
186
+ @layer post-embed {
187
+ :where([data-post-embed="x-post"]) {
188
+ --_hairline: var(--post-embed-border-color, color-mix(in srgb, currentColor 15%, transparent));
189
+ --_muted: var(--post-embed-muted-color, color-mix(in srgb, currentColor 58%, transparent));
190
+ --_link: var(--post-embed-link-color, #1d9bf0);
191
+ font: inherit;
192
+ color: var(--post-embed-color, inherit);
193
+ text-decoration-skip-ink: none;
194
+ display: block;
195
+
196
+ & :where([data-root]) {
197
+ background: var(--post-embed-background, transparent);
198
+ border: 1px solid var(--_hairline);
199
+ border-radius: var(--post-embed-radius, 1rem);
200
+ padding: var(--post-embed-padding, 1rem);
201
+ overflow-wrap: anywhere;
202
+ line-height: 1.4;
203
+ position: relative;
204
+ }
205
+
206
+ & :where([hidden]) {
207
+ display: none !important;
208
+ }
209
+
210
+ & :where(a) {
211
+ color: var(--_link);
212
+ text-decoration: none;
213
+ }
214
+
215
+ & :where(a:hover) {
216
+ text-underline-offset: .15em;
217
+ text-decoration: underline;
218
+ }
219
+
220
+ & :where(a:focus-visible, button:focus-visible) {
221
+ outline: 2px solid var(--_link);
222
+ outline-offset: 2px;
223
+ border-radius: 4px;
224
+ }
225
+
226
+ & :where([data-author]) {
227
+ align-items: center;
228
+ gap: .75rem;
229
+ display: flex;
230
+ }
231
+
232
+ & :where([data-avatar]) {
233
+ object-fit: cover;
234
+ border-radius: 50%;
235
+ flex: none;
236
+ width: 3rem;
237
+ height: 3rem;
238
+ }
239
+
240
+ & :where([data-avatar][data-shape="square"]) {
241
+ border-radius: .5rem;
242
+ }
243
+
244
+ & :where([data-avatar][data-shape="hexagon"]) {
245
+ clip-path: polygon(25% 0, 75% 0, 100% 50%, 75% 100%, 25% 100%, 0 50%);
246
+ }
247
+
248
+ & :where([data-author-details]) {
249
+ min-width: 0;
250
+ color: var(--_muted);
251
+ flex: 1;
252
+ line-height: 1.3;
253
+ }
254
+
255
+ & :where([data-author-name]) {
256
+ color: var(--post-embed-color, inherit);
257
+ flex-wrap: wrap;
258
+ align-items: center;
259
+ gap: .25rem;
260
+ font-weight: 700;
261
+ display: flex;
262
+ }
263
+
264
+ & :where([data-author-details] > bdi > a) {
265
+ color: inherit;
266
+ }
267
+
268
+ & :where([data-reply-to]) {
269
+ color: var(--_muted);
270
+ margin-block-start: .75rem;
271
+ font-size: .9375em;
272
+ }
273
+
274
+ & :where([data-body]) {
275
+ margin-block: .75rem 0;
276
+ font-size: 1.0625em;
277
+ line-height: 1.45;
278
+ }
279
+
280
+ & :where([data-text]) {
281
+ white-space: pre-wrap;
282
+ }
283
+
284
+ & :where([data-media]) {
285
+ border: 1px solid var(--_hairline);
286
+ border-radius: 1rem;
287
+ grid-template-columns: repeat(2, minmax(0, 1fr));
288
+ gap: 2px;
289
+ margin-block-start: .75rem;
290
+ display: grid;
291
+ overflow: hidden;
292
+ }
293
+
294
+ & :where([data-media][data-count="1"]) {
295
+ grid-template-columns: minmax(0, 1fr);
296
+ }
297
+
298
+ & :where([data-media-item]) {
299
+ min-width: 0;
300
+ overflow: hidden;
301
+ }
302
+
303
+ & :where([data-media-item] > a) {
304
+ height: 100%;
305
+ display: block;
306
+ }
307
+
308
+ & :where([data-media-item] img, [data-media-item] video) {
309
+ object-fit: contain;
310
+ background: color-mix(in srgb, currentColor 6%, transparent);
311
+ width: 100%;
312
+ height: auto;
313
+ max-height: 36rem;
314
+ display: block;
315
+ }
316
+
317
+ & :where([data-media]:not([data-count="1"]) [data-media-item] img) {
318
+ aspect-ratio: 1;
319
+ object-fit: cover;
320
+ height: 100%;
321
+ }
322
+
323
+ & :where([data-media][data-count="3"] [data-media-item]:first-child) {
324
+ grid-row: span 2;
325
+ }
326
+
327
+ & :where([data-media-unavailable], [data-media-error]) {
328
+ color: var(--_muted);
329
+ background: color-mix(in srgb, currentColor 4%, transparent);
330
+ padding: .75rem 1rem;
331
+ }
332
+
333
+ & :where([data-quoted]) {
334
+ border: 1px solid var(--_hairline);
335
+ border-radius: 1rem;
336
+ margin-block-start: .75rem;
337
+ padding: .75rem;
338
+ }
339
+
340
+ & :where([data-quoted] [data-author]) {
341
+ gap: .5rem;
342
+ }
343
+
344
+ & :where([data-quoted] [data-avatar]) {
345
+ width: 1.5rem;
346
+ height: 1.5rem;
347
+ }
348
+
349
+ & :where([data-quoted] [data-author-details]) {
350
+ flex-wrap: wrap;
351
+ align-items: baseline;
352
+ gap: 0 .35rem;
353
+ display: flex;
354
+ }
355
+
356
+ & :where([data-quoted] [data-body]) {
357
+ margin-block-start: .375rem;
358
+ font-size: 1em;
359
+ }
360
+
361
+ & :where([data-quoted] [data-media]) {
362
+ border-radius: .75rem;
363
+ }
364
+
365
+ & :where([data-quoted] [data-footer]) {
366
+ margin-block-start: .5rem;
367
+ font-size: .875em;
368
+ }
369
+
370
+ & :where([data-footer]) {
371
+ color: var(--_muted);
372
+ flex-wrap: wrap;
373
+ align-items: center;
374
+ gap: .25rem 0;
375
+ margin-block-start: .75rem;
376
+ font-size: .9375em;
377
+ display: flex;
378
+ }
379
+
380
+ & :where([data-footer] > a) {
381
+ color: inherit;
382
+ }
383
+
384
+ & :where([data-footer] > [data-edited]):before {
385
+ content: "";
386
+ vertical-align: middle;
387
+ background: currentColor;
388
+ border-radius: 50%;
389
+ width: 3px;
390
+ height: 3px;
391
+ margin-inline: .45rem;
392
+ display: inline-block;
393
+ }
394
+
395
+ & :where([data-edited]) {
396
+ font-style: normal;
397
+ }
398
+
399
+ & :where([data-fallback] [data-body]) {
400
+ color: var(--_muted);
401
+ }
402
+ }
403
+
404
+ :where([data-post-embed="youtube-video"]) {
405
+ --_hairline: var(--post-embed-border-color, color-mix(in srgb, currentColor 15%, transparent));
406
+ --_muted: var(--post-embed-muted-color, color-mix(in srgb, currentColor 58%, transparent));
407
+ --_link: var(--post-embed-link-color, #1d9bf0);
408
+ --_radius: var(--post-embed-radius, 1rem);
409
+ --_page: var(--post-embed-background, Canvas);
410
+ --_play: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 68 48'%3E%3Cpath d='M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55C3.97 2.33 2.27 4.81 1.48 7.74.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24S67.94 13.05 66.52 7.74z' fill='%23f00'/%3E%3Cpath d='M45 24 27 14v20z' fill='%23fff'/%3E%3C/svg%3E");
411
+ font: inherit;
412
+ color: var(--post-embed-color, inherit);
413
+ display: block;
414
+
415
+ & :where([data-root]) {
416
+ overflow-wrap: anywhere;
417
+ line-height: 1.35;
418
+ }
419
+
420
+ & :where([hidden]) {
421
+ display: none !important;
422
+ }
423
+
424
+ & :where(a) {
425
+ color: var(--_link);
426
+ text-decoration: none;
427
+ }
428
+
429
+ & :where(a:hover) {
430
+ text-underline-offset: .15em;
431
+ text-decoration: underline;
432
+ }
433
+
434
+ & :where(a:focus-visible, button:focus-visible) {
435
+ outline: 2px solid var(--_link);
436
+ outline-offset: 2px;
437
+ border-radius: 4px;
438
+ }
439
+
440
+ & :where(article) {
441
+ max-width: 100%;
442
+ position: relative;
443
+ }
444
+
445
+ & :where([data-orientation="portrait"]) {
446
+ max-width: 22rem;
447
+ margin-inline: auto;
448
+ }
449
+
450
+ & :where([data-poster], iframe) {
451
+ aspect-ratio: 16 / 9;
452
+ border-radius: var(--_radius);
453
+ background: color-mix(in srgb, currentColor 6%, transparent);
454
+ border: 0;
455
+ width: 100%;
456
+ display: block;
457
+ position: relative;
458
+ overflow: hidden;
459
+ }
460
+
461
+ & :where([data-orientation="portrait"] [data-poster], [data-orientation="portrait"] iframe) {
462
+ aspect-ratio: 9 / 16;
463
+ }
464
+
465
+ & :where(button[data-poster]) {
466
+ font: inherit;
467
+ color: inherit;
468
+ cursor: pointer;
469
+ padding: 0;
470
+ }
471
+
472
+ & :where([data-poster] img) {
473
+ object-fit: cover;
474
+ width: 100%;
475
+ height: 100%;
476
+ position: absolute;
477
+ inset: 0;
478
+ }
479
+
480
+ & :where([data-play]) {
481
+ background: var(--_play) center / contain no-repeat;
482
+ width: 68px;
483
+ height: 48px;
484
+ position: absolute;
485
+ inset: 50% auto auto 50%;
486
+ translate: -50% -50%;
487
+ }
488
+
489
+ & :where([data-details]) {
490
+ pointer-events: none;
491
+ flex-direction: column;
492
+ align-items: flex-start;
493
+ gap: .125rem;
494
+ display: flex;
495
+ position: absolute;
496
+ inset: 0 0 auto;
497
+ }
498
+
499
+ & :where([data-details] a, [data-details] span) {
500
+ pointer-events: auto;
501
+ }
502
+
503
+ & :where([data-title]) {
504
+ color: inherit;
505
+ font-size: 1.0625em;
506
+ font-weight: 700;
507
+ }
508
+
509
+ & :where([data-details] > a:has([data-author]), [data-author]) {
510
+ color: inherit;
511
+ font-size: .875em;
512
+ }
513
+
514
+ & :where([data-fallback]) {
515
+ border: 1px solid var(--_hairline);
516
+ border-radius: var(--_radius);
517
+ padding: var(--post-embed-padding, 1rem);
518
+ }
519
+
520
+ & :where([data-fallback] [data-details]) {
521
+ position: static;
522
+ }
523
+
524
+ & :where([data-body]) {
525
+ color: var(--_muted);
526
+ margin-block: .5rem 0;
527
+ }
528
+
529
+ & :where([data-footer]) {
530
+ color: var(--_muted);
531
+ margin-block-start: .75rem;
532
+ font-size: .9375em;
533
+ display: block;
534
+ }
535
+
536
+ & :where([data-details]) {
537
+ border-radius: var(--_radius) var(--_radius) 0 0;
538
+ color: #fff;
539
+ background: linear-gradient(#000000b8, #0000);
540
+ padding: .875rem 1rem 2.5rem;
541
+ }
542
+
543
+ & :where([data-details] > a:has([data-author]), [data-author]) {
544
+ color: #ffffffd1;
545
+ }
546
+ }
547
+ }
150
548
  }
151
549
 
152
550
  @layer meowdown.theme {
@@ -640,39 +1038,29 @@
640
1038
  padding: 2px 6px;
641
1039
  }
642
1040
 
643
- .ProseMirror .md-embed-youtube {
644
- aspect-ratio: 16 / 9;
645
- border-radius: var(--meowdown-image-radius, .5rem);
646
- border: 0;
647
- width: 100%;
648
- max-width: 100%;
649
- display: block;
650
- }
651
-
652
- .ProseMirror .md-embed-tweet {
653
- border: 0;
654
- width: 100%;
1041
+ .ProseMirror .md-image-view-preview post-embed-x-post, .ProseMirror .md-image-view-preview post-embed-youtube-video {
1042
+ -webkit-user-select: text;
1043
+ user-select: text;
655
1044
  max-width: 550px;
656
1045
  display: block;
657
1046
  }
658
1047
 
659
- .ProseMirror .md-embed-tweet:not([data-sized]) {
660
- min-height: 250px;
1048
+ .ProseMirror .md-image-view-preview:has( > .md-embed-resizable:not([data-width])) {
1049
+ width: 550px;
661
1050
  }
662
1051
 
663
1052
  .ProseMirror .md-embed-resizable {
664
1053
  isolation: isolate;
665
1054
  min-width: 2.5rem;
666
1055
  max-width: 100%;
667
- min-height: 2.5rem;
668
1056
  display: block;
669
1057
  position: relative;
1058
+ height: auto !important;
670
1059
  }
671
1060
 
672
- .ProseMirror .md-embed-resizable .md-embed-youtube {
673
- aspect-ratio: auto;
1061
+ .ProseMirror .md-embed-resizable post-embed-youtube-video {
674
1062
  width: 100%;
675
- height: 100%;
1063
+ max-width: none;
676
1064
  }
677
1065
 
678
1066
  .ProseMirror .md-embed-resizable[data-resizing] iframe {
@@ -820,6 +1208,10 @@
820
1208
  display: inline-block;
821
1209
  }
822
1210
 
1211
+ & .md-image-view-preview[data-post-embed] {
1212
+ border-radius: var(--post-embed-radius, 1rem);
1213
+ }
1214
+
823
1215
  & .md-image-resizable {
824
1216
  vertical-align: bottom;
825
1217
  isolation: isolate;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@meowdown/core",
3
3
  "type": "module",
4
- "version": "0.69.0",
4
+ "version": "0.70.1",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -26,12 +26,17 @@
26
26
  "@floating-ui/dom": "^1.8.0",
27
27
  "@lezer/highlight": "^1.2.3",
28
28
  "@ocavue/utils": "^1.8.0",
29
+ "@post-embed/elements": "^0.3.0",
30
+ "@post-embed/exporter": "^0.3.0",
31
+ "@post-embed/schema": "^0.3.0",
32
+ "@post-embed/types": "^0.3.0",
29
33
  "@prosekit/core": "^0.13.2",
30
34
  "@prosekit/extensions": "^0.18.2",
31
35
  "@prosekit/pm": "^0.1.19",
32
36
  "@prosekit/web": "^0.9.2",
33
37
  "hast-util-to-mdast": "^10.1.2",
34
38
  "katex": "^0.18.5",
39
+ "lru.min": "^1.1.5",
35
40
  "mdast-util-to-markdown": "^2.1.2",
36
41
  "micromark-util-decode-string": "^2.0.1",
37
42
  "micromark-util-normalize-identifier": "^2.0.1",
@@ -43,7 +48,8 @@
43
48
  "remark-stringify": "^11.0.0",
44
49
  "unicode-by-name": "^0.2.0",
45
50
  "unified": "^11.0.5",
46
- "@meowdown/markdown": "^0.69.0"
51
+ "valibot": "^1.5.0",
52
+ "@meowdown/markdown": "^0.70.1"
47
53
  },
48
54
  "devDependencies": {
49
55
  "@ocavue/tsconfig": "^0.7.1",