@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 +70 -0
- package/dist/fetch-Djnul03u.d.ts +22 -0
- package/dist/render-link-BqiZxTy9.js +100 -0
- package/dist/x/index.d.ts +22 -0
- package/dist/x/index.js +233 -0
- package/dist/x/theme.css +395 -0
- package/dist/youtube/index.d.ts +26 -0
- package/dist/youtube/index.js +164 -0
- package/dist/youtube/theme.css +173 -0
- package/package.json +51 -0
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 };
|
package/dist/x/index.js
ADDED
|
@@ -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 };
|
package/dist/x/theme.css
ADDED
|
@@ -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
|
+
}
|