@meowdown/embed 0.0.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/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # @meowdown/embed
2
+
3
+ Web components for rendering saved X posts and YouTube videos, used by [`@meowdown/core`](https://www.npmjs.com/package/@meowdown/core).
4
+
5
+ ```sh
6
+ npm install @meowdown/embed
7
+ ```
8
+
9
+ ```ts
10
+ import '@meowdown/embed/x/theme.css'
11
+
12
+ import { registerXPost } from '@meowdown/embed/x'
13
+ import type { XPost } from '@post-embed/types'
14
+
15
+ export function showPost(container: HTMLElement, post: XPost) {
16
+ registerXPost()
17
+ const element = document.createElement('meowdown-embed-x')
18
+ element.data = post
19
+ container.append(element)
20
+ return element
21
+ }
22
+ ```
23
+
24
+ `XPost` is a small snapshot with only what the card renders: the author, the body split into text and link segments, media, an optional quote and reply target, the edit state. Assign a new `XPost` object to `element.data` to update the post, or `null` to clear it. The theme import is optional.
25
+
26
+ `@post-embed/exporter` produces `XPost` values: `fromSyndication` from the syndication API data X's own embeds use, `observeXTweets` from the GraphQL responses of the x.com page.
27
+
28
+ ## Loading a snapshot by URL
29
+
30
+ Set `element.url` and `element.resolver` to let the element load its own snapshot. The element calls `resolver(url)` whenever `data` is `null` and both are set, and renders whatever it returns (a snapshot, a promise of one, or `undefined` when nothing was found). Results from a superseded `url` are dropped. While a promise is pending the fallback card carries a `data-pending` attribute. A saved `data` always wins over the fetched snapshot.
31
+
32
+ ```ts
33
+ import { fromSyndication } from '@post-embed/exporter/x/syndication'
34
+
35
+ const element = document.createElement('meowdown-embed-x')
36
+ element.resolver = async (url) => {
37
+ const id = new URL(url).pathname.split('/').at(-1)
38
+ const response = await fetch(`/api/tweet/${id}`)
39
+ return response.ok ? fromSyndication(await response.json()) : undefined
40
+ }
41
+ element.url = 'https://x.com/jack/status/20'
42
+ ```
43
+
44
+ The syndication API X's own embeds read from does not allow cross-origin requests, so `resolver` for X posts usually goes through your server.
45
+
46
+ ## YouTube video
47
+
48
+ ```ts
49
+ import '@meowdown/embed/youtube/theme.css'
50
+
51
+ import { registerYouTubeVideo } from '@meowdown/embed/youtube'
52
+ import type { YouTubeVideo } from '@post-embed/types'
53
+
54
+ export function showVideo(container: HTMLElement, url: string) {
55
+ registerYouTubeVideo()
56
+ const element = document.createElement('meowdown-embed-youtube')
57
+ element.resolver = async (url) => {
58
+ const response = await fetch(
59
+ `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`,
60
+ )
61
+ if (!response.ok) return
62
+ return { url, ...(await response.json()) } as YouTubeVideo
63
+ }
64
+ element.url = url
65
+ container.append(element)
66
+ return element
67
+ }
68
+ ```
69
+
70
+ The snapshot is the YouTube oEmbed response plus the video `url`; YouTube's oEmbed endpoint allows cross-origin requests, so `resolver` can call it directly, or assign a saved snapshot to `element.data` instead. The element renders a card with the poster, title, channel, and a "Watch on YouTube" link, and never creates an iframe unless you set `playback="inline"` (as an attribute or `element.playback = 'inline'`). In inline mode the poster becomes a play button, and clicking it swaps in the `youtube-nocookie.com` player. The poster loads from the saved `thumbnail_url`; point it at your own copy if the page must not contact Google's image CDN.
@@ -0,0 +1,22 @@
1
+ import { HostElement, State } from "@aria-ui/core";
2
+ //#region src/fetch.d.ts
3
+ /**
4
+ * Loads the snapshot for `url`. Return `undefined` when nothing was found.
5
+ */
6
+ type Resolver<T> = (url: string) => T | undefined | PromiseLike<T | undefined>;
7
+ interface FetchProps<T> {
8
+ /**
9
+ * A saved snapshot. When set, it is rendered as is and `url` is not fetched.
10
+ */
11
+ data: T | null;
12
+ /**
13
+ * The URL to pass to `resolver` when `data` is `null`.
14
+ */
15
+ url: string | null;
16
+ /**
17
+ * Called with `url` to load the snapshot when `data` is `null`.
18
+ */
19
+ resolver: Resolver<T> | null;
20
+ }
21
+ //#endregion
22
+ export { Resolver as n, FetchProps as t };
@@ -0,0 +1,100 @@
1
+ import { createSignal, useEffect } from "@aria-ui/core";
2
+ import el from "crelt";
3
+
4
+ //#region src/fetch.ts
5
+ /**
6
+ * Runs `resolver(url)` whenever `data` is `null` and both `url` and `resolver`
7
+ * are set, ignoring results that arrive after the inputs changed or the host
8
+ * disconnected.
9
+ *
10
+ * @internal
11
+ */
12
+ function useFetch(host, props, label) {
13
+ const fetched = createSignal(null);
14
+ const pending = createSignal(false);
15
+ useEffect(host, () => {
16
+ const url = props.url.get();
17
+ const resolver = props.resolver.get();
18
+ fetched.set(null);
19
+ pending.set(false);
20
+ if (props.data.get() != null || !url || !resolver) return;
21
+ let active = true;
22
+ const settle = (value) => {
23
+ if (!active) return;
24
+ fetched.set(value ?? null);
25
+ pending.set(false);
26
+ };
27
+ const fail = (error) => {
28
+ if (!active) return;
29
+ console.error(`[meowdown] Failed to fetch ${label}:`, error);
30
+ settle(void 0);
31
+ };
32
+ let result;
33
+ try {
34
+ result = resolver(url);
35
+ } catch (error) {
36
+ fail(error);
37
+ return;
38
+ }
39
+ if (isPromiseLike(result)) {
40
+ pending.set(true);
41
+ result.then(settle, fail);
42
+ } else settle(result);
43
+ return () => {
44
+ active = false;
45
+ };
46
+ });
47
+ return {
48
+ fetched,
49
+ pending
50
+ };
51
+ }
52
+ function isPromiseLike(value) {
53
+ return typeof value === "object" && value !== null && typeof value.then === "function";
54
+ }
55
+
56
+ //#endregion
57
+ //#region src/root.ts
58
+ /**
59
+ * The direct `<div data-root>` child that holds the rendered snapshot. Reused
60
+ * across updates so other children the host appended survive; created on
61
+ * first use.
62
+ */
63
+ function getRootContainer(host) {
64
+ let container = host.querySelector(":scope > div[data-root]");
65
+ if (!container) {
66
+ container = host.ownerDocument.createElement("div");
67
+ container.dataset.root = "";
68
+ host.append(container);
69
+ }
70
+ return container;
71
+ }
72
+
73
+ //#endregion
74
+ //#region src/safe-url.ts
75
+ function getSafeUrl(value, protocols) {
76
+ if (Array.from(value).some((character) => {
77
+ return character.charCodeAt(0) <= 32 || character.charCodeAt(0) === 127;
78
+ })) return;
79
+ try {
80
+ const url = new URL(value);
81
+ if (url.username || url.password) return;
82
+ if (url.protocol === "https:" || url.protocol === "http:" || protocols?.includes(url.protocol)) return url.href;
83
+ } catch {
84
+ return;
85
+ }
86
+ }
87
+
88
+ //#endregion
89
+ //#region src/render-link.ts
90
+ function renderLink(content, destination) {
91
+ const href = destination && getSafeUrl(destination);
92
+ return href ? el("a", {
93
+ href,
94
+ target: "_blank",
95
+ rel: "noopener noreferrer"
96
+ }, content) : content;
97
+ }
98
+
99
+ //#endregion
100
+ export { useFetch as i, getSafeUrl as n, getRootContainer as r, renderLink as t };
@@ -0,0 +1,22 @@
1
+ import { n as Resolver, t as FetchProps } from "../fetch-Djnul03u.js";
2
+ import { HostElement, State } from "@aria-ui/core";
3
+ import { XPost } from "@post-embed/types";
4
+ //#region src/x/x-post.d.ts
5
+ interface XPostProps extends FetchProps<XPost> {
6
+ mediaUrlProtocols: readonly string[] | null;
7
+ }
8
+ interface XPostElement extends HTMLElement, XPostProps {}
9
+ /** @internal */
10
+ export declare function useXPost(host: HostElement, props: State<XPostProps>): void;
11
+ //#endregion
12
+ //#region src/x/register.d.ts
13
+ export declare function registerXPost(name?: string): void;
14
+ //#endregion
15
+ //#region src/x/index.d.ts
16
+ declare global {
17
+ interface HTMLElementTagNameMap {
18
+ 'meowdown-embed-x': XPostElement;
19
+ }
20
+ }
21
+ //#endregion
22
+ export type { Resolver, XPostElement, XPostProps };
@@ -0,0 +1,233 @@
1
+ import { i as useFetch, n as getSafeUrl, r as getRootContainer, t as renderLink } from "../render-link-BqiZxTy9.js";
2
+ import { defineCustomElement, defineProps, registerCustomElement, useEffect } from "@aria-ui/core";
3
+ import { parseXPost, parseXPostId } from "@post-embed/schema";
4
+ import el from "crelt";
5
+
6
+ //#region src/x/render-author.ts
7
+ function renderAuthor(author, protocols, date) {
8
+ if (!author.name && !author.handle) return;
9
+ const validHandle = /^\w{1,15}$/.test(author.handle);
10
+ const avatar = author.avatar && getSafeUrl(author.avatar, protocols);
11
+ return el("header", { "data-author": "" }, avatar ? el("img", {
12
+ "data-avatar": "",
13
+ "data-shape": author.avatarShape,
14
+ src: avatar,
15
+ alt: "",
16
+ width: 48,
17
+ height: 48,
18
+ loading: "lazy",
19
+ decoding: "async",
20
+ referrerpolicy: "no-referrer"
21
+ }) : void 0, el("div", { "data-author-details": "" }, el("div", { "data-author-name": "" }, el("bdi", {}, author.name || author.handle)), author.handle ? el("bdi", {}, renderLink(`@${author.handle}`, validHandle ? `https://x.com/${author.handle}` : void 0)) : void 0), date ? el("span", { "data-date": "" }, date) : void 0);
22
+ }
23
+
24
+ //#endregion
25
+ //#region src/x/render-media.ts
26
+ function dimension(value) {
27
+ return Number.isFinite(value) && value > 0 ? Math.round(value) : void 0;
28
+ }
29
+ function sizeAttrs(media) {
30
+ const width = dimension(media.width);
31
+ const height = dimension(media.height);
32
+ return {
33
+ width,
34
+ height,
35
+ style: width && height ? `--_ratio: ${width} / ${height}` : void 0
36
+ };
37
+ }
38
+ function getOrientation(media) {
39
+ const width = dimension(media.width);
40
+ const height = dimension(media.height);
41
+ if (!width || !height) return;
42
+ return height > width * 1.1 ? "portrait" : width > height * 1.1 ? "landscape" : "square";
43
+ }
44
+ function renderUnavailable(permalink) {
45
+ return el("div", { "data-media-unavailable": "" }, "Media unavailable. ", renderLink("View on X", permalink));
46
+ }
47
+ function renderItem(media, protocols, permalink) {
48
+ const error = el("div", {
49
+ "data-media-error": "",
50
+ hidden: true
51
+ }, "Media could not be loaded. ", renderLink("View on X", permalink));
52
+ let content;
53
+ if (media.type === "photo") {
54
+ const url = getSafeUrl(media.url, protocols);
55
+ if (media.unavailable || !url) return renderUnavailable(permalink);
56
+ const image = el("img", {
57
+ src: url,
58
+ alt: media.alt || "Post image",
59
+ ...sizeAttrs(media),
60
+ loading: "lazy",
61
+ decoding: "async",
62
+ referrerpolicy: "no-referrer"
63
+ });
64
+ const link = el("a", {
65
+ href: url,
66
+ target: "_blank",
67
+ rel: "noopener noreferrer"
68
+ }, image);
69
+ image.addEventListener("error", () => {
70
+ link.hidden = true;
71
+ error.hidden = false;
72
+ });
73
+ content = link;
74
+ } else {
75
+ const sources = media.sources.flatMap((source) => {
76
+ const url = getSafeUrl(source.url, protocols);
77
+ return url ? [{
78
+ ...source,
79
+ url
80
+ }] : [];
81
+ }).sort((a, b) => {
82
+ return Number(b.type === "video/mp4") - Number(a.type === "video/mp4") || (b.bitrate || 0) - (a.bitrate || 0);
83
+ }).map((source) => el("source", {
84
+ src: source.url,
85
+ type: source.type
86
+ }));
87
+ if (media.unavailable || sources.length === 0) return renderUnavailable(permalink);
88
+ const gif = media.type === "gif";
89
+ const video = el("video", {
90
+ controls: true,
91
+ playsInline: true,
92
+ preload: "none",
93
+ "aria-label": gif ? "Animated GIF" : "Post video",
94
+ poster: media.poster && getSafeUrl(media.poster, protocols),
95
+ ...sizeAttrs(media),
96
+ loop: gif
97
+ }, sources, renderLink("Watch on X", permalink));
98
+ video.muted = gif;
99
+ const showError = () => {
100
+ video.hidden = true;
101
+ error.hidden = false;
102
+ };
103
+ video.addEventListener("error", showError);
104
+ let failedSources = 0;
105
+ for (const source of sources) source.addEventListener("error", () => {
106
+ failedSources++;
107
+ if (failedSources === sources.length) showError();
108
+ }, { once: true });
109
+ content = video;
110
+ }
111
+ return el("div", {
112
+ "data-media-item": "",
113
+ "data-orientation": getOrientation(media)
114
+ }, content, error);
115
+ }
116
+ function renderMedia(media, protocols, permalink) {
117
+ if (!media?.length) return;
118
+ return el("div", {
119
+ "data-media": "",
120
+ "data-count": String(media.length)
121
+ }, media.map((item) => renderItem(item, protocols, permalink)));
122
+ }
123
+
124
+ //#endregion
125
+ //#region src/x/render-shared.ts
126
+ function getPermalink(post) {
127
+ return /^\w{1,15}$/.test(post.author.handle) && /^\d+$/.test(post.id) ? `https://x.com/${post.author.handle}/status/${post.id}` : void 0;
128
+ }
129
+ /**
130
+ * The post date as a permalink: month and day, plus the year when it is not
131
+ * the current one. The full date and time are the tooltip.
132
+ */
133
+ function renderDate(post) {
134
+ const date = new Date(post.createdAt);
135
+ if (!Number.isFinite(date.getTime())) return;
136
+ const sameYear = date.getFullYear() === (/* @__PURE__ */ new Date()).getFullYear();
137
+ return renderLink(el("time", {
138
+ datetime: date.toISOString(),
139
+ title: new Intl.DateTimeFormat(void 0, {
140
+ dateStyle: "medium",
141
+ timeStyle: "short"
142
+ }).format(date)
143
+ }, new Intl.DateTimeFormat(void 0, {
144
+ month: "short",
145
+ day: "numeric",
146
+ year: sameYear ? void 0 : "numeric"
147
+ }).format(date)), getPermalink(post));
148
+ }
149
+
150
+ //#endregion
151
+ //#region src/x/render-post.ts
152
+ function renderBody(post) {
153
+ const permalink = getPermalink(post);
154
+ return el("p", {
155
+ "data-body": "",
156
+ dir: "auto",
157
+ lang: post.lang || void 0
158
+ }, el("span", { "data-text": "" }, post.body.map((segment) => {
159
+ return segment.type === "link" ? renderLink(segment.text, segment.url) : segment.text.split("\n").map((line, index) => {
160
+ return [index ? el("br", {}) : void 0, line];
161
+ });
162
+ })), post.truncated && permalink ? [" ", renderLink("Show more", permalink)] : void 0);
163
+ }
164
+ function renderEdit(post) {
165
+ const edited = post.edit === "stale" ? el("span", { "data-edited": "" }, "This is an earlier version. ", renderLink("View latest", getPermalink(post))) : post.edit === "edited" ? el("span", { "data-edited": "" }, "Edited") : void 0;
166
+ return edited ? el("footer", { "data-footer": "" }, edited) : void 0;
167
+ }
168
+ function renderQuoted(post, protocols) {
169
+ return el("article", {
170
+ "data-quoted": "",
171
+ "aria-label": "Quoted post"
172
+ }, renderAuthor(post.author, protocols, renderDate(post)), renderBody(post), renderMedia(post.media, protocols, getPermalink(post)), renderEdit(post));
173
+ }
174
+ function renderPost(post, protocols = null) {
175
+ const reply = post.replyTo;
176
+ const replyUrl = reply && /^\w{1,15}$/.test(reply.handle) && /^\d+$/.test(reply.id) ? `https://x.com/${reply.handle}/status/${reply.id}` : void 0;
177
+ return el("article", {}, renderAuthor(post.author, protocols, renderDate(post)), reply ? el("div", { "data-reply-to": "" }, renderLink(`Replying to @${reply.handle}`, replyUrl)) : void 0, renderBody(post), renderMedia(post.media, protocols, getPermalink(post)), post.quote ? renderQuoted(post.quote, protocols) : void 0, renderEdit(post));
178
+ }
179
+
180
+ //#endregion
181
+ //#region src/x/x-post.ts
182
+ /** @internal */
183
+ function useXPost(host, props) {
184
+ const { fetched, pending } = useFetch(host, props, "X post");
185
+ useEffect(host, () => {
186
+ host.dataset.meowdownEmbed = "x";
187
+ const container = getRootContainer(host);
188
+ const data = props.data.get() ?? fetched.get();
189
+ const result = data == null ? void 0 : parseXPost(data);
190
+ if (result?.issues) console.error("[meowdown] Invalid X post data:", result.issues);
191
+ const protocols = props.mediaUrlProtocols.get();
192
+ const url = props.url.get();
193
+ const value = result && !result.issues ? result.value : void 0;
194
+ const valid = value && (props.data.get() != null || !url || parseXPostId(url) === value.id);
195
+ container.replaceChildren(valid ? renderPost(value, protocols) : renderFallback(pending.get()));
196
+ return () => {
197
+ for (const video of container.querySelectorAll("video")) video.pause();
198
+ };
199
+ });
200
+ }
201
+ function renderFallback(pending) {
202
+ return el("article", pending ? {
203
+ "data-fallback": "",
204
+ "data-pending": ""
205
+ } : { "data-fallback": "" }, el("header", { "data-author": "" }, el("bdi", {}, "X post")), el("p", { "data-body": "" }, pending ? "Loading this post…" : "This post is unavailable."), pending ? null : el("footer", { "data-footer": "" }, "No saved post could be displayed."));
206
+ }
207
+ const XPost = defineCustomElement(useXPost, defineProps({
208
+ data: {
209
+ default: null,
210
+ attribute: false
211
+ },
212
+ url: {
213
+ default: null,
214
+ attribute: false
215
+ },
216
+ resolver: {
217
+ default: null,
218
+ attribute: false
219
+ },
220
+ mediaUrlProtocols: {
221
+ default: null,
222
+ attribute: false
223
+ }
224
+ }));
225
+
226
+ //#endregion
227
+ //#region src/x/register.ts
228
+ function registerXPost(name = "meowdown-embed-x") {
229
+ registerCustomElement(name, class extends XPost {});
230
+ }
231
+
232
+ //#endregion
233
+ export { registerXPost, useXPost };
@@ -0,0 +1,395 @@
1
+ @property --meowdown-embed-color {
2
+ syntax: '*';
3
+ inherits: true;
4
+ }
5
+
6
+ @property --meowdown-embed-background {
7
+ syntax: '*';
8
+ inherits: true;
9
+ }
10
+
11
+ @property --meowdown-embed-border-color {
12
+ syntax: '*';
13
+ inherits: true;
14
+ }
15
+
16
+ @property --meowdown-embed-radius {
17
+ syntax: '*';
18
+ inherits: true;
19
+ }
20
+
21
+ @property --meowdown-embed-padding {
22
+ syntax: '*';
23
+ inherits: true;
24
+ }
25
+
26
+ @property --meowdown-embed-muted-color {
27
+ syntax: '*';
28
+ inherits: true;
29
+ }
30
+
31
+ @property --meowdown-embed-link-color {
32
+ syntax: '*';
33
+ inherits: true;
34
+ }
35
+
36
+ /*
37
+ * Hard reset. `all: revert` rolls every property on every box inside the card
38
+ * back to the user-agent default, so a host rule on `p`, `a`, `img`,
39
+ * `article`, `header`, or `footer` (a prose stylesheet, a CSS reset, an
40
+ * editor theme) cannot reach in. The reset sits at specificity (0,2,0);
41
+ * every rule below is more specific and re-applies the card's own values,
42
+ * including the UA defaults the card does not want (`p` margins, `a` colors).
43
+ */
44
+ [data-meowdown-embed='x'] [data-root],
45
+ [data-meowdown-embed='x'] [data-root] *,
46
+ [data-meowdown-embed='x'] [data-root] *::before,
47
+ [data-meowdown-embed='x'] [data-root] *::after {
48
+ all: revert;
49
+ box-sizing: border-box;
50
+ }
51
+
52
+ [data-meowdown-embed='x'] {
53
+ --_hairline: var(
54
+ --meowdown-embed-border-color,
55
+ color-mix(in srgb, currentColor 14%, transparent)
56
+ );
57
+ --_muted: var(--meowdown-embed-muted-color, color-mix(in srgb, currentColor 55%, transparent));
58
+ --_link: var(--meowdown-embed-link-color, #1d9bf0);
59
+ --_tint: color-mix(in srgb, currentColor 5%, transparent);
60
+
61
+ display: block;
62
+ /* As wide as the content, between 18rem and 28rem: a short text post stays
63
+ * small, while long text and media fill the full width. */
64
+ width: fit-content;
65
+ max-width: min(28rem, 100%);
66
+ font: inherit;
67
+ color: var(--meowdown-embed-color, inherit);
68
+ text-decoration-skip-ink: none;
69
+ }
70
+
71
+ /*
72
+ * The minimum width. A percentage `min-width` on the host resolves to nothing
73
+ * inside a shrink-to-fit parent (an inline-block editor wrapper), and a fixed
74
+ * one overflows a narrow container. A zero-height spacer instead raises the
75
+ * card's intrinsic width to 18rem and still yields to the host's `max-width`.
76
+ */
77
+ [data-meowdown-embed='x'] [data-root]::before {
78
+ content: '';
79
+ display: block;
80
+ width: calc(18rem - 2 * var(--meowdown-embed-padding, 0.75rem));
81
+ max-width: 100%;
82
+ height: 0;
83
+ }
84
+
85
+ [data-meowdown-embed='x'] [data-root] {
86
+ display: block;
87
+ position: relative;
88
+ color: var(--meowdown-embed-color, inherit);
89
+ background: var(--meowdown-embed-background, var(--_tint));
90
+ border-radius: var(--meowdown-embed-radius, 0.75rem);
91
+ padding: var(--meowdown-embed-padding, 0.75rem);
92
+ font-size: 1em;
93
+ font-weight: 400;
94
+ font-style: normal;
95
+ line-height: 1.45;
96
+ letter-spacing: normal;
97
+ text-align: start;
98
+ text-transform: none;
99
+ text-indent: 0;
100
+ text-shadow: none;
101
+ word-spacing: normal;
102
+ font-variant: normal;
103
+ hyphens: manual;
104
+ white-space: normal;
105
+ overflow-wrap: anywhere;
106
+ cursor: auto;
107
+
108
+ & [hidden] {
109
+ display: none !important;
110
+ }
111
+ & p {
112
+ margin: 0;
113
+ }
114
+ & a {
115
+ color: var(--_link);
116
+ text-decoration: none;
117
+ cursor: pointer;
118
+ }
119
+ & a:hover {
120
+ text-decoration: underline;
121
+ text-underline-offset: 0.15em;
122
+ }
123
+ & a:focus-visible {
124
+ outline: 2px solid var(--_link);
125
+ outline-offset: 2px;
126
+ border-radius: 4px;
127
+ }
128
+
129
+ /* Header */
130
+ & [data-author] {
131
+ display: flex;
132
+ align-items: center;
133
+ gap: 0.5rem;
134
+ min-width: 0;
135
+ }
136
+ & [data-avatar] {
137
+ display: block;
138
+ flex: none;
139
+ width: 2rem;
140
+ height: 2rem;
141
+ object-fit: cover;
142
+ border-radius: 50%;
143
+ background: var(--_tint);
144
+ }
145
+ & [data-avatar][data-shape='square'] {
146
+ border-radius: 0.375rem;
147
+ }
148
+ & [data-avatar][data-shape='hexagon'] {
149
+ clip-path: polygon(25% 0, 75% 0, 100% 50%, 75% 100%, 25% 100%, 0 50%);
150
+ }
151
+ & [data-author-details] {
152
+ display: block;
153
+ min-width: 0;
154
+ flex: 1;
155
+ line-height: 1.25;
156
+ }
157
+ & [data-author-name] {
158
+ display: flex;
159
+ align-items: center;
160
+ gap: 0.25rem;
161
+ flex-wrap: wrap;
162
+ font-weight: 600;
163
+ }
164
+ & [data-author-details] > bdi {
165
+ font-size: 0.875em;
166
+ color: var(--_muted);
167
+ }
168
+ & [data-author-details] > bdi > a {
169
+ color: inherit;
170
+ }
171
+ & [data-date] {
172
+ display: block;
173
+ flex: none;
174
+ align-self: flex-start;
175
+ padding-inline-start: 0.25rem;
176
+ font-size: 0.8125em;
177
+ line-height: 1.25rem;
178
+ white-space: nowrap;
179
+ color: var(--_muted);
180
+ }
181
+ & [data-date] > a {
182
+ color: inherit;
183
+ }
184
+
185
+ /* Body */
186
+ & [data-reply-to] {
187
+ display: block;
188
+ margin-block-start: 0.375rem;
189
+ font-size: 0.8125em;
190
+ color: var(--_muted);
191
+ }
192
+ & [data-body] {
193
+ display: block;
194
+ margin-block: 0.625rem 0;
195
+ font-size: 1em;
196
+ line-height: 1.5;
197
+ }
198
+ & [data-reply-to] + [data-body] {
199
+ margin-block-start: 0.25rem;
200
+ }
201
+ & [data-text] {
202
+ white-space: pre-wrap;
203
+ }
204
+
205
+ /* Media */
206
+ & [data-media] {
207
+ display: grid;
208
+ grid-template-columns: repeat(2, minmax(0, 1fr));
209
+ gap: 2px;
210
+ margin-block-start: 0.75rem;
211
+ border-radius: 0.5rem;
212
+ overflow: hidden;
213
+ background: var(--_hairline);
214
+ }
215
+ & [data-media][data-count='1'] {
216
+ grid-template-columns: minmax(0, 1fr);
217
+ }
218
+ & [data-media-item] {
219
+ display: block;
220
+ position: relative;
221
+ min-width: 0;
222
+ overflow: hidden;
223
+ }
224
+ /* A hairline keeps white screenshots from dissolving into the card. */
225
+ & [data-media-item]::after {
226
+ content: '';
227
+ position: absolute;
228
+ inset: 0;
229
+ border-radius: inherit;
230
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 10%, transparent);
231
+ pointer-events: none;
232
+ }
233
+ & [data-media-item] > a {
234
+ display: block;
235
+ height: 100%;
236
+ }
237
+ /* A single photo or video fills the width at its own ratio, up to 20rem
238
+ * tall; taller media is cropped and the photo links to the original. */
239
+ & [data-media-item] img,
240
+ & [data-media-item] video {
241
+ display: block;
242
+ width: 100%;
243
+ height: auto;
244
+ aspect-ratio: var(--_ratio, auto);
245
+ max-height: 20rem;
246
+ object-fit: cover;
247
+ background: var(--_tint);
248
+ }
249
+ & [data-media-item][data-orientation='portrait'] :is(img, video) {
250
+ object-position: center 30%;
251
+ }
252
+ & [data-media]:not([data-count='1']) [data-media-item] :is(img, video) {
253
+ height: 100%;
254
+ aspect-ratio: 3 / 2;
255
+ }
256
+ & [data-media][data-count='3'] [data-media-item]:first-child {
257
+ grid-row: span 2;
258
+ }
259
+ & [data-media][data-count='3'] [data-media-item]:first-child :is(img, video) {
260
+ aspect-ratio: auto;
261
+ }
262
+ & [data-media-unavailable],
263
+ & [data-media-error] {
264
+ display: block;
265
+ padding: 0.75rem 1rem;
266
+ color: var(--_muted);
267
+ background: var(--_tint);
268
+ }
269
+
270
+ /* Quoted post */
271
+ & [data-quoted] {
272
+ display: block;
273
+ margin-block-start: 0.75rem;
274
+ padding: 0.75rem;
275
+ border-radius: 0.5rem;
276
+ background: var(--_tint);
277
+ }
278
+ & [data-quoted] [data-avatar] {
279
+ width: 1.375rem;
280
+ height: 1.375rem;
281
+ }
282
+ & [data-quoted] [data-author-details] {
283
+ display: flex;
284
+ align-items: baseline;
285
+ gap: 0 0.35rem;
286
+ line-height: 1.375rem;
287
+ }
288
+ & [data-quoted] [data-author-name],
289
+ & [data-quoted] [data-author-details] > bdi {
290
+ display: block;
291
+ min-width: 0;
292
+ overflow: hidden;
293
+ white-space: nowrap;
294
+ text-overflow: ellipsis;
295
+ }
296
+ & [data-quoted] [data-author-name] {
297
+ flex: 0 1 auto;
298
+ }
299
+ & [data-quoted] [data-author-details] > bdi {
300
+ flex: 0 100 auto;
301
+ min-width: 2.5rem;
302
+ font-size: 0.9375em;
303
+ }
304
+ & [data-quoted] [data-date] {
305
+ align-self: center;
306
+ }
307
+ & [data-quoted] [data-body] {
308
+ display: -webkit-box;
309
+ -webkit-box-orient: vertical;
310
+ -webkit-line-clamp: 3;
311
+ line-clamp: 3;
312
+ overflow: hidden;
313
+ margin-block-start: 0.25rem;
314
+ font-size: 0.9375em;
315
+ }
316
+ & [data-quoted] [data-media] {
317
+ margin-block-start: 0.5rem;
318
+ border-radius: 0.375rem;
319
+ }
320
+ & [data-quoted] [data-media-item] :is(img, video) {
321
+ max-height: 10rem;
322
+ }
323
+ /* Quoted photos shrink to one thumbnail beside the text, with a count
324
+ * badge; a quoted video or GIF stays below the text so its player works. */
325
+ & [data-quoted]:has(> [data-media]):not(:has(> [data-media] video)) {
326
+ display: grid;
327
+ grid-template-columns: minmax(0, 1fr) auto;
328
+ grid-template-areas:
329
+ 'head head'
330
+ 'body media'
331
+ 'foot foot';
332
+ column-gap: 0.75rem;
333
+
334
+ & > [data-author] {
335
+ grid-area: head;
336
+ }
337
+ & > [data-body] {
338
+ grid-area: body;
339
+ }
340
+ & > [data-footer] {
341
+ grid-area: foot;
342
+ }
343
+ & > [data-media] {
344
+ grid-area: media;
345
+ position: relative;
346
+ display: block;
347
+ width: 4rem;
348
+ height: 4rem;
349
+ margin-block-start: 0.375rem;
350
+ }
351
+ & > [data-media] > [data-media-item] {
352
+ width: 100%;
353
+ height: 100%;
354
+ }
355
+ & > [data-media] > [data-media-item]:nth-child(n + 2) {
356
+ display: none;
357
+ }
358
+ & > [data-media] > [data-media-item] img {
359
+ height: 100%;
360
+ max-height: none;
361
+ aspect-ratio: auto;
362
+ }
363
+ & > [data-media]:not([data-count='1'])::after {
364
+ content: attr(data-count);
365
+ position: absolute;
366
+ inset: auto 0.25rem 0.25rem auto;
367
+ min-width: 1.25rem;
368
+ padding: 0 0.3125rem;
369
+ border-radius: 0.625rem;
370
+ font-size: 0.6875rem;
371
+ font-weight: 600;
372
+ line-height: 1.25rem;
373
+ text-align: center;
374
+ color: #fff;
375
+ background: rgb(0 0 0 / 0.62);
376
+ pointer-events: none;
377
+ }
378
+ }
379
+
380
+ /* Footer: only the edit state; the date lives in the header. */
381
+ & [data-footer] {
382
+ display: block;
383
+ margin-block-start: 0.5rem;
384
+ font-size: 0.8125em;
385
+ color: var(--_muted);
386
+ }
387
+ & [data-edited] {
388
+ font-style: normal;
389
+ }
390
+
391
+ /* Fallback */
392
+ & [data-fallback] [data-body] {
393
+ color: var(--_muted);
394
+ }
395
+ }
@@ -0,0 +1,26 @@
1
+ import { n as Resolver, t as FetchProps } from "../fetch-Djnul03u.js";
2
+ import { HostElement, State } from "@aria-ui/core";
3
+ import { YouTubeVideo } from "@post-embed/types";
4
+ //#region src/youtube/youtube-video.d.ts
5
+ interface YouTubeVideoProps extends FetchProps<YouTubeVideo> {
6
+ /**
7
+ * `link` (default) opens the watch page; `inline` swaps in the YouTube
8
+ * player after a click on the poster.
9
+ */
10
+ playback: string;
11
+ }
12
+ interface YouTubeVideoElement extends HTMLElement, YouTubeVideoProps {}
13
+ /** @internal */
14
+ export declare function useYouTubeVideo(host: HostElement, props: State<YouTubeVideoProps>): void;
15
+ //#endregion
16
+ //#region src/youtube/register.d.ts
17
+ export declare function registerYouTubeVideo(name?: string): void;
18
+ //#endregion
19
+ //#region src/youtube/index.d.ts
20
+ declare global {
21
+ interface HTMLElementTagNameMap {
22
+ 'meowdown-embed-youtube': YouTubeVideoElement;
23
+ }
24
+ }
25
+ //#endregion
26
+ export type { Resolver, YouTubeVideoElement, YouTubeVideoProps };
@@ -0,0 +1,164 @@
1
+ import { i as useFetch, n as getSafeUrl, r as getRootContainer, t as renderLink } from "../render-link-BqiZxTy9.js";
2
+ import { defineCustomElement, defineProps, registerCustomElement, useEffect } from "@aria-ui/core";
3
+ import { parseYouTubeVideo } from "@post-embed/schema";
4
+ import el from "crelt";
5
+
6
+ //#region src/youtube/parse-url.ts
7
+ const YOUTUBE_HOSTS = /^(?:www\.|m\.)?(?:youtube\.com|youtube-nocookie\.com)$/i;
8
+ const YOUTU_BE_HOST = /^(?:www\.)?youtu\.be$/i;
9
+ const VIDEO_ID = /^[\w-]{11}$/;
10
+ function parseYouTubeUrl(value) {
11
+ let url;
12
+ try {
13
+ url = new URL(value);
14
+ } catch {
15
+ return;
16
+ }
17
+ let videoId = null;
18
+ let short = false;
19
+ if (YOUTU_BE_HOST.test(url.hostname)) videoId = url.pathname.slice(1);
20
+ else if (YOUTUBE_HOSTS.test(url.hostname)) {
21
+ const [, first, second] = url.pathname.split("/");
22
+ if (url.pathname === "/watch") videoId = url.searchParams.get("v");
23
+ else if (first === "shorts" || first === "embed" || first === "live") {
24
+ videoId = second ?? null;
25
+ short = first === "shorts";
26
+ }
27
+ }
28
+ if (!videoId || !VIDEO_ID.test(videoId)) return;
29
+ const time = url.searchParams.get("start") ?? url.searchParams.get("t") ?? "";
30
+ return {
31
+ videoId,
32
+ startSeconds: parseStartSeconds(time),
33
+ short
34
+ };
35
+ }
36
+ /**
37
+ * `90`, `90s`, `1m30s`, `1h2m3s` to seconds; anything else is `0`.
38
+ */
39
+ function parseStartSeconds(value) {
40
+ if (!value) return 0;
41
+ if (/^\d+$/.test(value)) return Number(value);
42
+ const matched = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(value);
43
+ if (!matched) return 0;
44
+ return Number(matched[1] ?? 0) * 3600 + Number(matched[2] ?? 0) * 60 + Number(matched[3] ?? 0);
45
+ }
46
+ function getWatchUrl(ref) {
47
+ const time = ref.startSeconds ? `&t=${ref.startSeconds}` : "";
48
+ return `https://www.youtube.com/watch?v=${ref.videoId}${time}`;
49
+ }
50
+ function getEmbedUrl(ref) {
51
+ const params = new URLSearchParams({
52
+ autoplay: "1",
53
+ playsinline: "1"
54
+ });
55
+ if (ref.startSeconds) params.set("start", String(ref.startSeconds));
56
+ return `https://www.youtube-nocookie.com/embed/${ref.videoId}?${params}`;
57
+ }
58
+
59
+ //#endregion
60
+ //#region src/youtube/render-video.ts
61
+ function renderFrame(ref, title) {
62
+ return el("iframe", {
63
+ src: getEmbedUrl(ref),
64
+ title,
65
+ allow: "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen",
66
+ allowfullscreen: true,
67
+ referrerpolicy: "strict-origin-when-cross-origin"
68
+ });
69
+ }
70
+ function renderVideo(video, ref, playback) {
71
+ const watchUrl = getWatchUrl(ref);
72
+ const title = video.title || "YouTube video";
73
+ const poster = getSafeUrl(video.thumbnail_url);
74
+ const external = {
75
+ target: "_blank",
76
+ rel: "noopener noreferrer"
77
+ };
78
+ const posterContent = [poster ? el("img", {
79
+ src: poster,
80
+ alt: "",
81
+ width: video.thumbnail_width || void 0,
82
+ height: video.thumbnail_height || void 0,
83
+ loading: "lazy",
84
+ decoding: "async",
85
+ referrerpolicy: "no-referrer"
86
+ }) : void 0, el("span", {
87
+ "data-play": "",
88
+ "aria-hidden": "true"
89
+ })];
90
+ let posterElement;
91
+ if (playback === "inline") {
92
+ const button = el("button", {
93
+ "data-poster": "",
94
+ type: "button",
95
+ "aria-label": `Play: ${title}`
96
+ }, posterContent);
97
+ button.addEventListener("click", () => {
98
+ const frame = renderFrame(ref, title);
99
+ button.replaceWith(frame);
100
+ frame.focus();
101
+ });
102
+ posterElement = button;
103
+ } else posterElement = el("a", {
104
+ "data-poster": "",
105
+ href: watchUrl,
106
+ ...external
107
+ }, posterContent);
108
+ return el("article", { "data-orientation": ref.short ? "portrait" : void 0 }, posterElement, el("div", { "data-details": "" }, el("a", {
109
+ "data-title": "",
110
+ href: watchUrl,
111
+ ...external
112
+ }, title), video.author_name ? renderLink(el("span", { "data-author": "" }, video.author_name), video.author_url) : void 0));
113
+ }
114
+
115
+ //#endregion
116
+ //#region src/youtube/youtube-video.ts
117
+ /** @internal */
118
+ function useYouTubeVideo(host, props) {
119
+ const { fetched, pending } = useFetch(host, props, "YouTube video");
120
+ useEffect(host, () => {
121
+ host.dataset.meowdownEmbed = "youtube";
122
+ const container = getRootContainer(host);
123
+ const data = props.data.get() ?? fetched.get();
124
+ const playback = props.playback.get() === "inline" ? "inline" : "link";
125
+ const result = data == null ? void 0 : parseYouTubeVideo(data);
126
+ if (result?.issues) console.error("[meowdown] Invalid YouTube video data:", result.issues);
127
+ const ref = result && !result.issues ? parseYouTubeUrl(result.value.url) : void 0;
128
+ container.replaceChildren(result && !result.issues && ref ? renderVideo(result.value, ref, playback) : renderFallback(pending.get()));
129
+ });
130
+ }
131
+ function renderFallback(pending) {
132
+ return el("article", pending ? {
133
+ "data-fallback": "",
134
+ "data-pending": ""
135
+ } : { "data-fallback": "" }, el("div", { "data-details": "" }, el("span", { "data-title": "" }, "YouTube video")), el("p", { "data-body": "" }, pending ? "Loading this video…" : "This video is unavailable."), pending ? null : el("footer", { "data-footer": "" }, "No saved video could be displayed."));
136
+ }
137
+ const YouTubeVideoCustomElement = defineCustomElement(useYouTubeVideo, defineProps({
138
+ data: {
139
+ default: null,
140
+ attribute: false
141
+ },
142
+ url: {
143
+ default: null,
144
+ attribute: false
145
+ },
146
+ resolver: {
147
+ default: null,
148
+ attribute: false
149
+ },
150
+ playback: {
151
+ default: "link",
152
+ attribute: "playback",
153
+ type: "string"
154
+ }
155
+ }));
156
+
157
+ //#endregion
158
+ //#region src/youtube/register.ts
159
+ function registerYouTubeVideo(name = "meowdown-embed-youtube") {
160
+ registerCustomElement(name, class extends YouTubeVideoCustomElement {});
161
+ }
162
+
163
+ //#endregion
164
+ export { registerYouTubeVideo, useYouTubeVideo };
@@ -0,0 +1,173 @@
1
+ @property --meowdown-embed-color {
2
+ syntax: '*';
3
+ inherits: true;
4
+ }
5
+
6
+ @property --meowdown-embed-background {
7
+ syntax: '*';
8
+ inherits: true;
9
+ }
10
+
11
+ @property --meowdown-embed-border-color {
12
+ syntax: '*';
13
+ inherits: true;
14
+ }
15
+
16
+ @property --meowdown-embed-radius {
17
+ syntax: '*';
18
+ inherits: true;
19
+ }
20
+
21
+ @property --meowdown-embed-padding {
22
+ syntax: '*';
23
+ inherits: true;
24
+ }
25
+
26
+ @property --meowdown-embed-muted-color {
27
+ syntax: '*';
28
+ inherits: true;
29
+ }
30
+
31
+ @property --meowdown-embed-link-color {
32
+ syntax: '*';
33
+ inherits: true;
34
+ }
35
+
36
+ @layer meowdown-embed {
37
+ :where([data-meowdown-embed='youtube']) {
38
+ --_hairline: var(
39
+ --meowdown-embed-border-color,
40
+ color-mix(in srgb, currentColor 15%, transparent)
41
+ );
42
+ --_muted: var(--meowdown-embed-muted-color, color-mix(in srgb, currentColor 58%, transparent));
43
+ --_link: var(--meowdown-embed-link-color, #1d9bf0);
44
+ --_radius: var(--meowdown-embed-radius, 1rem);
45
+ --_page: var(--meowdown-embed-background, Canvas);
46
+ --_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");
47
+
48
+ display: block;
49
+ font: inherit;
50
+ color: var(--meowdown-embed-color, inherit);
51
+
52
+ :where([data-root]) {
53
+ overflow-wrap: anywhere;
54
+ line-height: 1.35;
55
+ }
56
+ :where([hidden]) {
57
+ display: none !important;
58
+ }
59
+ :where(a) {
60
+ color: var(--_link);
61
+ text-decoration: none;
62
+ }
63
+ :where(a:hover) {
64
+ text-decoration: underline;
65
+ text-underline-offset: 0.15em;
66
+ }
67
+ :where(a:focus-visible, button:focus-visible) {
68
+ outline: 2px solid var(--_link);
69
+ outline-offset: 2px;
70
+ border-radius: 4px;
71
+ }
72
+
73
+ /* The card is the poster. The article is the positioned box that the
74
+ * details overlay hangs from. */
75
+ :where(article) {
76
+ position: relative;
77
+ max-width: 100%;
78
+ }
79
+ :where([data-orientation='portrait']) {
80
+ max-width: 22rem;
81
+ margin-inline: auto;
82
+ }
83
+ :where([data-poster], iframe) {
84
+ position: relative;
85
+ display: block;
86
+ width: 100%;
87
+ aspect-ratio: 16 / 9;
88
+ border: 0;
89
+ border-radius: var(--_radius);
90
+ overflow: hidden;
91
+ background: color-mix(in srgb, currentColor 6%, transparent);
92
+ }
93
+ :where([data-orientation='portrait'] [data-poster], [data-orientation='portrait'] iframe) {
94
+ aspect-ratio: 9 / 16;
95
+ }
96
+ :where(button[data-poster]) {
97
+ padding: 0;
98
+ font: inherit;
99
+ color: inherit;
100
+ cursor: pointer;
101
+ }
102
+ :where([data-poster] img) {
103
+ position: absolute;
104
+ inset: 0;
105
+ width: 100%;
106
+ height: 100%;
107
+ object-fit: cover;
108
+ }
109
+ /* YouTube's own large play button: the 68x48 shape from the embedded
110
+ * player, red with a white triangle. */
111
+ :where([data-play]) {
112
+ position: absolute;
113
+ inset: 50% auto auto 50%;
114
+ width: 68px;
115
+ height: 48px;
116
+ translate: -50% -50%;
117
+ background: var(--_play) center / contain no-repeat;
118
+ }
119
+
120
+ /* Details: absolutely positioned over the top of the poster by default.
121
+ * Variants move or restyle it. */
122
+ :where([data-details]) {
123
+ position: absolute;
124
+ inset: 0 0 auto 0;
125
+ display: flex;
126
+ flex-direction: column;
127
+ align-items: flex-start;
128
+ gap: 0.125rem;
129
+ pointer-events: none;
130
+ }
131
+ :where([data-details] a, [data-details] span) {
132
+ pointer-events: auto;
133
+ }
134
+ :where([data-title]) {
135
+ font-size: 1.0625em;
136
+ font-weight: 700;
137
+ color: inherit;
138
+ }
139
+ :where([data-details] > a:has([data-author]), [data-author]) {
140
+ font-size: 0.875em;
141
+ color: inherit;
142
+ }
143
+ /* Fallback and pending states keep a plain bordered box. */
144
+ :where([data-fallback]) {
145
+ border: 1px solid var(--_hairline);
146
+ border-radius: var(--_radius);
147
+ padding: var(--meowdown-embed-padding, 1rem);
148
+ }
149
+ :where([data-fallback] [data-details]) {
150
+ position: static;
151
+ }
152
+ :where([data-body]) {
153
+ margin-block: 0.5rem 0;
154
+ color: var(--_muted);
155
+ }
156
+ :where([data-footer]) {
157
+ display: block;
158
+ margin-block-start: 0.75rem;
159
+ font-size: 0.9375em;
160
+ color: var(--_muted);
161
+ }
162
+
163
+ :where([data-details]) {
164
+ padding: 0.875rem 1rem 2.5rem;
165
+ border-radius: var(--_radius) var(--_radius) 0 0;
166
+ background: linear-gradient(rgb(0 0 0 / 0.72), rgb(0 0 0 / 0));
167
+ color: #fff;
168
+ }
169
+ :where([data-details] > a:has([data-author]), [data-author]) {
170
+ color: rgb(255 255 255 / 0.82);
171
+ }
172
+ }
173
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@meowdown/embed",
3
+ "type": "module",
4
+ "version": "0.0.1",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/prosekit/meowdown",
9
+ "directory": "packages/embed"
10
+ },
11
+ "sideEffects": [
12
+ "**/*.css"
13
+ ],
14
+ "exports": {
15
+ "./x": "./src/x/index.ts",
16
+ "./x/theme.css": "./src/x/theme.css",
17
+ "./youtube": "./src/youtube/index.ts",
18
+ "./youtube/theme.css": "./src/youtube/theme.css"
19
+ },
20
+ "files": [
21
+ "./dist/**/*.js",
22
+ "./dist/**/*.d.ts",
23
+ "./dist/**/*.css"
24
+ ],
25
+ "scripts": {
26
+ "build": "tsdown"
27
+ },
28
+ "dependencies": {
29
+ "@aria-ui/core": "^0.2.1",
30
+ "@post-embed/schema": "^0.5.0",
31
+ "@post-embed/types": "^0.4.0",
32
+ "crelt": "^1.0.7"
33
+ },
34
+ "devDependencies": {
35
+ "@meowdown/vitest": "workspace:*",
36
+ "@ocavue/tsconfig": "^0.7.1",
37
+ "@ocavue/utils": "^1.8.0",
38
+ "@vitest/browser-playwright": "^5.0.1",
39
+ "tsdown": "^0.23.0",
40
+ "vitest": "^5.0.1"
41
+ },
42
+ "publishConfig": {
43
+ "exports": {
44
+ "./x": "./dist/x/index.js",
45
+ "./x/theme.css": "./dist/x/theme.css",
46
+ "./youtube": "./dist/youtube/index.js",
47
+ "./youtube/theme.css": "./dist/youtube/theme.css"
48
+ },
49
+ "access": "public"
50
+ }
51
+ }