diff --git a/.changeset/meowdown-embed.md b/.changeset/meowdown-embed.md new file mode 100644 index 00000000..49907d18 --- /dev/null +++ b/.changeset/meowdown-embed.md @@ -0,0 +1,6 @@ +--- +'@meowdown/core': patch +'@meowdown/react': patch +--- + +Render X post and YouTube video cards with `@meowdown/embed`: the elements are now `` and ``, themed by `--meowdown-embed-*` variables. diff --git a/package.json b/package.json index 302aff9f..bbe9062b 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "pnpm --filter ./website run dev", "build": "pnpm -r run build", "preview": "pnpm run build && pnpm --filter ./website run preview", - "ci:publish:snapshot": "pkg-pr-new publish --pnpm ./packages/markdown ./packages/core ./packages/react", + "ci:publish:snapshot": "pkg-pr-new publish --pnpm ./packages/markdown ./packages/embed ./packages/core ./packages/react", "change": "changeset", "ci:version": "changeset version && pnpm install --no-frozen-lockfile", "ci:publish": "changeset publish", diff --git a/packages/core/package.json b/packages/core/package.json index dfef521d..f7bbdfef 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -28,9 +28,9 @@ "@codemirror/language-data": "^6.5.2", "@floating-ui/dom": "^1.8.0", "@lezer/highlight": "^1.2.3", + "@meowdown/embed": "workspace:^", "@meowdown/markdown": "workspace:^", "@ocavue/utils": "^1.8.0", - "@post-embed/elements": "^0.6.0", "@post-embed/exporter": "^0.4.1", "@post-embed/schema": "^0.5.0", "@post-embed/types": "^0.4.0", diff --git a/packages/core/src/extensions/image.ts b/packages/core/src/extensions/image.ts index 3206ca58..c596a923 100644 --- a/packages/core/src/extensions/image.ts +++ b/packages/core/src/extensions/image.ts @@ -1,6 +1,6 @@ +import { registerXPost, type Resolver } from '@meowdown/embed/x' +import { registerYouTubeVideo } from '@meowdown/embed/youtube' import { matchEmbed, type EmbedKind } from '@meowdown/markdown' -import { registerXPost, type Resolver } from '@post-embed/elements/x' -import { registerYouTubeVideo } from '@post-embed/elements/youtube' import type { XPost, YouTubeVideo } from '@post-embed/types' import { defineMarkView, type PlainExtension } from '@prosekit/core' import type { Mark } from '@prosekit/pm/model' @@ -55,7 +55,7 @@ export interface ImageOptions { mediaUrlProtocols?: string[] /** * Resolve the data behind a YouTube video URL, rendered as a - * `post-embed-youtube-video` card. Defaults to `defaultResolveYouTubeVideo`, + * `meowdown-embed-youtube` card. Defaults to `defaultResolveYouTubeVideo`, * which reads YouTube's oEmbed endpoint. */ resolveYouTubeVideo?: YouTubeVideoResolver @@ -321,14 +321,14 @@ class ImageMarkView implements MarkView { this.#attrs.snapshot == null ? undefined : parsePostEmbedSnapshot(this.#attrs.snapshot) if (kind === 'x-post') { registerXPost() - const element = document.createElement('post-embed-x-post') + const element = document.createElement('meowdown-embed-x') element.mediaUrlProtocols = this.#mediaUrlProtocols ?? null element.resolver = this.#resolveXPost element.url = src return element } registerYouTubeVideo() - const element = document.createElement('post-embed-youtube-video') + const element = document.createElement('meowdown-embed-youtube') element.playback = 'inline' element.data = saved?.kind === 'youtube-video' ? saved.data : null element.resolver = this.#persisting(kind, this.#resolveYouTubeVideo) diff --git a/packages/core/src/extensions/post-embed-view.test.tsx b/packages/core/src/extensions/post-embed-view.test.tsx index e7d9f7aa..8f50eb8b 100644 --- a/packages/core/src/extensions/post-embed-view.test.tsx +++ b/packages/core/src/extensions/post-embed-view.test.tsx @@ -14,9 +14,9 @@ import { formatMagicComment, parseMagicComment } from './magic-comment.ts' const pmRoot = page.locate('.ProseMirror') const xPostEmbed = pmRoot.getByTestId('x-post-embed') -const xPostCard = xPostEmbed.locate('[data-post-embed="x-post"]') +const xPostCard = xPostEmbed.locate('[data-meowdown-embed="x"]') const videoEmbed = pmRoot.getByTestId('youtube-video-embed') -const videoCard = videoEmbed.locate('[data-post-embed="youtube-video"]') +const videoCard = videoEmbed.locate('[data-meowdown-embed="youtube"]') const videoResizable = videoEmbed.getByTestId('embed-resizable') const TWEET = '![](https://x.com/jack/status/20)' diff --git a/packages/core/src/extensions/post-embed.ts b/packages/core/src/extensions/post-embed.ts index 1e1e81cb..f2131424 100644 --- a/packages/core/src/extensions/post-embed.ts +++ b/packages/core/src/extensions/post-embed.ts @@ -1,5 +1,5 @@ +import type { Resolver } from '@meowdown/embed/x' import { parseXPostId } from '@meowdown/markdown' -import type { Resolver } from '@post-embed/elements/x' import { fromSyndication } from '@post-embed/exporter/x/syndication' import { XPostSchema, YouTubeVideoSchema } from '@post-embed/schema' import type { XPost, YouTubeVideo } from '@post-embed/types' diff --git a/packages/core/src/style.css b/packages/core/src/style.css index f3fb6805..2300f642 100644 --- a/packages/core/src/style.css +++ b/packages/core/src/style.css @@ -2,8 +2,8 @@ @import '@prosekit/pm/view/style/prosemirror.css' layer(meowdown.base); @import '@prosekit/extensions/list/style.css' layer(meowdown.base); -@import '@post-embed/elements/x/theme.css' layer(meowdown.base); -@import '@post-embed/elements/youtube/theme.css' layer(meowdown.base); +@import '@meowdown/embed/x/theme.css' layer(meowdown.base); +@import '@meowdown/embed/youtube/theme.css' layer(meowdown.base); @layer meowdown.theme { /* --------------------------------------------------------------------------- @@ -698,15 +698,15 @@ * ------------------------------------------------------------------------- */ /* The cards are the atom previews whose text is meant to be selected. */ - .ProseMirror .md-image-view-preview post-embed-x-post, - .ProseMirror .md-image-view-preview post-embed-youtube-video { + .ProseMirror .md-image-view-preview meowdown-embed-x, + .ProseMirror .md-image-view-preview meowdown-embed-youtube { -webkit-user-select: text; user-select: text; } /* The X card sizes itself; a `max-width` here would override its own * `max-width: 100%` and let it overflow a narrow editor. */ - .ProseMirror .md-image-view-preview post-embed-youtube-video { + .ProseMirror .md-image-view-preview meowdown-embed-youtube { display: block; max-width: 550px; } @@ -731,7 +731,7 @@ height: auto !important; } - .ProseMirror .md-embed-resizable post-embed-youtube-video { + .ProseMirror .md-embed-resizable meowdown-embed-youtube { width: 100%; max-width: none; } @@ -949,7 +949,7 @@ /* The card draws its own corners, shadow, or outline; the wrapper's * rounded clip would cut them off whenever the two radii differ. */ .md-image-view-preview[data-post-embed] { - border-radius: var(--post-embed-radius, 0.75rem); + border-radius: var(--meowdown-embed-radius, 0.75rem); overflow: visible; } diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 477d0113..6715d334 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -6,6 +6,9 @@ "erasableSyntaxOnly": true }, "references": [ + { + "path": "../embed/tsconfig.json" + }, { "path": "../markdown/tsconfig.json" }, diff --git a/packages/embed/README.md b/packages/embed/README.md new file mode 100644 index 00000000..5cfc47d7 --- /dev/null +++ b/packages/embed/README.md @@ -0,0 +1,70 @@ +# @meowdown/embed + +Web components for rendering saved X posts and YouTube videos, used by [`@meowdown/core`](https://www.npmjs.com/package/@meowdown/core). + +```sh +npm install @meowdown/embed +``` + +```ts +import '@meowdown/embed/x/theme.css' + +import { registerXPost } from '@meowdown/embed/x' +import type { XPost } from '@post-embed/types' + +export function showPost(container: HTMLElement, post: XPost) { + registerXPost() + const element = document.createElement('meowdown-embed-x') + element.data = post + container.append(element) + return element +} +``` + +`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. + +`@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. + +## Loading a snapshot by URL + +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. + +```ts +import { fromSyndication } from '@post-embed/exporter/x/syndication' + +const element = document.createElement('meowdown-embed-x') +element.resolver = async (url) => { + const id = new URL(url).pathname.split('/').at(-1) + const response = await fetch(`/api/tweet/${id}`) + return response.ok ? fromSyndication(await response.json()) : undefined +} +element.url = 'https://x.com/jack/status/20' +``` + +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. + +## YouTube video + +```ts +import '@meowdown/embed/youtube/theme.css' + +import { registerYouTubeVideo } from '@meowdown/embed/youtube' +import type { YouTubeVideo } from '@post-embed/types' + +export function showVideo(container: HTMLElement, url: string) { + registerYouTubeVideo() + const element = document.createElement('meowdown-embed-youtube') + element.resolver = async (url) => { + const response = await fetch( + `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`, + ) + if (!response.ok) return + return { url, ...(await response.json()) } as YouTubeVideo + } + element.url = url + container.append(element) + return element +} +``` + +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. diff --git a/packages/embed/package.json b/packages/embed/package.json new file mode 100644 index 00000000..4be3bbd2 --- /dev/null +++ b/packages/embed/package.json @@ -0,0 +1,51 @@ +{ + "name": "@meowdown/embed", + "type": "module", + "version": "0.0.1", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/prosekit/meowdown", + "directory": "packages/embed" + }, + "sideEffects": [ + "**/*.css" + ], + "exports": { + "./x": "./src/x/index.ts", + "./x/theme.css": "./src/x/theme.css", + "./youtube": "./src/youtube/index.ts", + "./youtube/theme.css": "./src/youtube/theme.css" + }, + "files": [ + "./dist/**/*.js", + "./dist/**/*.d.ts", + "./dist/**/*.css" + ], + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@aria-ui/core": "^0.2.1", + "@post-embed/schema": "^0.5.0", + "@post-embed/types": "^0.4.0", + "crelt": "^1.0.7" + }, + "devDependencies": { + "@meowdown/vitest": "workspace:*", + "@ocavue/tsconfig": "^0.7.1", + "@ocavue/utils": "^1.8.0", + "@vitest/browser-playwright": "^5.0.1", + "tsdown": "^0.23.0", + "vitest": "^5.0.1" + }, + "publishConfig": { + "exports": { + "./x": "./dist/x/index.js", + "./x/theme.css": "./dist/x/theme.css", + "./youtube": "./dist/youtube/index.js", + "./youtube/theme.css": "./dist/youtube/theme.css" + }, + "access": "public" + } +} diff --git a/packages/embed/src/fetch.ts b/packages/embed/src/fetch.ts new file mode 100644 index 00000000..e3148a48 --- /dev/null +++ b/packages/embed/src/fetch.ts @@ -0,0 +1,99 @@ +import { + createSignal, + type HostElement, + type Signal, + type State, + useEffect as useHostEffect, +} from '@aria-ui/core' + +/** + * Loads the snapshot for `url`. Return `undefined` when nothing was found. + */ +export type Resolver = (url: string) => T | undefined | PromiseLike + +export interface FetchProps { + /** + * A saved snapshot. When set, it is rendered as is and `url` is not fetched. + */ + data: T | null + /** + * The URL to pass to `resolver` when `data` is `null`. + */ + url: string | null + /** + * Called with `url` to load the snapshot when `data` is `null`. + */ + resolver: Resolver | null +} + +/** @internal */ +export interface FetchState { + /** + * The fetched snapshot, or `null` before, during, and after a failed fetch. + */ + fetched: Signal + pending: Signal +} + +/** + * Runs `resolver(url)` whenever `data` is `null` and both `url` and `resolver` + * are set, ignoring results that arrive after the inputs changed or the host + * disconnected. + * + * @internal + */ +export function useFetch( + host: HostElement, + props: State>, + label: string, +): FetchState { + const fetched = createSignal(null) + const pending = createSignal(false) + + useHostEffect(host, () => { + const url = props.url.get() + const resolver = props.resolver.get() + fetched.set(null) + pending.set(false) + if (props.data.get() != null || !url || !resolver) return + + let active = true + const settle = (value: T | undefined) => { + if (!active) return + fetched.set(value ?? null) + pending.set(false) + } + const fail = (error: unknown) => { + if (!active) return + console.error(`[meowdown] Failed to fetch ${label}:`, error) + settle(undefined) + } + + let result: ReturnType> + try { + result = resolver(url) + } catch (error) { + fail(error) + return + } + if (isPromiseLike(result)) { + pending.set(true) + result.then(settle, fail) + } else { + settle(result) + } + return () => { + active = false + } + }) + + return { fetched, pending } +} + +function isPromiseLike(value: T | PromiseLike): value is PromiseLike { + return ( + typeof value === 'object' && + value !== null && + typeof (value as PromiseLike).then === 'function' + ) +} diff --git a/packages/embed/src/render-link.ts b/packages/embed/src/render-link.ts new file mode 100644 index 00000000..806d32b0 --- /dev/null +++ b/packages/embed/src/render-link.ts @@ -0,0 +1,8 @@ +import el from 'crelt' + +import { getSafeUrl } from './safe-url.ts' + +export function renderLink(content: string | Node, destination?: string) { + const href = destination && getSafeUrl(destination) + return href ? el('a', { href, target: '_blank', rel: 'noopener noreferrer' }, content) : content +} diff --git a/packages/embed/src/root.ts b/packages/embed/src/root.ts new file mode 100644 index 00000000..73076b58 --- /dev/null +++ b/packages/embed/src/root.ts @@ -0,0 +1,16 @@ +import type { HostElement } from '@aria-ui/core' + +/** + * The direct `
` child that holds the rendered snapshot. Reused + * across updates so other children the host appended survive; created on + * first use. + */ +export function getRootContainer(host: HostElement): HTMLDivElement { + let container = host.querySelector(':scope > div[data-root]') + if (!container) { + container = host.ownerDocument.createElement('div') + container.dataset.root = '' + host.append(container) + } + return container +} diff --git a/packages/embed/src/safe-url.test.ts b/packages/embed/src/safe-url.test.ts new file mode 100644 index 00000000..39caf9dd --- /dev/null +++ b/packages/embed/src/safe-url.test.ts @@ -0,0 +1,30 @@ +import { expect, it } from 'vitest' + +import { getSafeUrl } from './safe-url.ts' + +it('accepts absolute web URLs', () => { + expect(getSafeUrl('https://example.com/path?q=value#part')).toBe( + 'https://example.com/path?q=value#part', + ) + expect(getSafeUrl('http://example.com')).toBe('http://example.com/') +}) + +it('rejects executable and document-relative destinations', () => { + expect(getSafeUrl('javascript:alert(1)')).toBeUndefined() + expect(getSafeUrl('data:text/html,hello')).toBeUndefined() + expect(getSafeUrl('//example.com')).toBeUndefined() + expect(getSafeUrl('/relative')).toBeUndefined() +}) + +it('rejects credentials and control characters', () => { + expect(getSafeUrl('https://user:password@example.com')).toBeUndefined() + expect(getSafeUrl('https://example.com/\npath')).toBeUndefined() +}) + +it('accepts host-approved media protocols without allowing other protocols', () => { + expect(getSafeUrl('reflect-asset://localhost/1/x-media/123/hash')).toBeUndefined() + expect(getSafeUrl('reflect-asset://localhost/1/x-media/123/hash', ['reflect-asset:'])).toBe( + 'reflect-asset://localhost/1/x-media/123/hash', + ) + expect(getSafeUrl('javascript:alert(1)', ['reflect-asset:'])).toBeUndefined() +}) diff --git a/packages/embed/src/safe-url.ts b/packages/embed/src/safe-url.ts new file mode 100644 index 00000000..45cb914a --- /dev/null +++ b/packages/embed/src/safe-url.ts @@ -0,0 +1,19 @@ +export function getSafeUrl( + value: string, + protocols?: readonly string[] | null, +): string | undefined { + if ( + Array.from(value).some((character) => { + return character.charCodeAt(0) <= 32 || character.charCodeAt(0) === 127 + }) + ) + return + try { + const url = new URL(value) + if (url.username || url.password) return + if (url.protocol === 'https:' || url.protocol === 'http:' || protocols?.includes(url.protocol)) + return url.href + } catch { + return + } +} diff --git a/packages/embed/src/x/features.test.tsx b/packages/embed/src/x/features.test.tsx new file mode 100644 index 00000000..b6c8d30e --- /dev/null +++ b/packages/embed/src/x/features.test.tsx @@ -0,0 +1,162 @@ +import './theme.css' + +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { page } from 'vitest/browser' + +import { createPhoto, createPost, createVideo } from './testing/fixtures.ts' + +import { registerXPost } from './index.ts' + +beforeAll(() => registerXPost()) +afterEach(() => { + document.body.replaceChildren() + vi.restoreAllMocks() +}) + +function mount(post = createPost()) { + delete post.author.avatar + const element = document.createElement('meowdown-embed-x') + element.dataset.testid = 'feature-post' + element.data = post + document.body.append(element) + return element +} +const post = page.getByTestId('feature-post') + +describe('Full post snapshots', () => { + it('renders photos with alt text and preserves native full-image links', async () => { + expect(createPhoto().url).toMatch(/^https?:/u) + const snapshot = createPost('Four pictures') + snapshot.media = Array.from({ length: 4 }, createPhoto) + const element = mount(snapshot) + await expect.element(post.getByText('Four pictures')).toBeVisible() + const images = element.querySelectorAll('[data-media] img') + expect(images).toHaveLength(4) + expect(images[0].alt).toBe('Blue illustrated mountains') + expect(images[0].closest('a')?.href).toBe(images[0].src) + await expect.poll(() => images[0].naturalWidth).toBe(640) + }) + + it('reserves the media aspect ratio before the image loads', () => { + const snapshot = createPost() + snapshot.media = [{ ...createPhoto(), url: 'https://example.invalid/a.jpg' }] + const element = mount(snapshot) + element.style.width = '500px' + const image = element.querySelector('[data-media] img')! + expect(image.complete && image.naturalWidth > 0).toBe(false) + const box = image.getBoundingClientRect() + expect(box.width).toBeGreaterThan(0) + expect(box.height).toBeCloseTo((box.width * 400) / 640, 0) + }) + + it('uses MP4 before HLS, native controls, and opt-in GIF playback', async () => { + const snapshot = createPost() + snapshot.media = [createVideo(), createVideo(true)] + const element = mount(snapshot) + await expect.element(post.getByText('Hello 😀', { exact: false })).toBeVisible() + const videos = element.querySelectorAll('video') + expect(videos).toHaveLength(2) + expect(videos[0].querySelector('source')?.src).toBe('https://example.com/high.mp4') + expect(videos[0].controls).toBe(true) + expect(videos[0].preload).toBe('none') + expect(videos[0].autoplay).toBe(false) + expect(videos[0].loop).toBe(false) + expect(videos[1].loop).toBe(true) + expect(videos[1].muted).toBe(true) + expect(videos[1].autoplay).toBe(false) + const pause = vi.spyOn(videos[0], 'pause') + element.remove() + expect(pause).toHaveBeenCalled() + snapshot.media = [createPhoto()] + element.data = { ...snapshot } + document.body.append(element) + expect(element.querySelector('video')).toBeNull() + }) + + it('renders quotes, reply context, and long-post links', async () => { + const snapshot = createPost('A reply with a quote') + snapshot.quote = { + ...createPost('Quote body'), + id: '222', + media: [createPhoto()], + } + snapshot.replyTo = { handle: 'example', id: '111' } + snapshot.truncated = true + mount(snapshot) + await expect + .element(post.getByRole('article', { name: 'Quoted post' }).getByText('Quote body')) + .toBeVisible() + await expect + .element(post.getByRole('link', { name: 'Replying to @example' })) + .toHaveAttribute('href', 'https://x.com/example/status/111') + await expect.element(post.getByRole('link', { name: 'Show more' })).toBeVisible() + }) + + it('renders dates and edits without engagement controls', async () => { + const snapshot = createPost() + snapshot.author.avatarShape = 'square' + snapshot.edit = 'edited' + const element = mount(snapshot) + expect(element.querySelector('time')?.dateTime).toBe('2026-09-10T00:00:00.000Z') + await expect.element(post.getByText('Edited', { exact: true })).toBeVisible() + await expect.element(post.getByRole('button')).not.toBeInTheDocument() + await expect + .element(post.getByRole('link', { name: /Follow|Like|Reply|Read|on X/ })) + .not.toBeInTheDocument() + expect(element.textContent).not.toMatch(/on X/) + }) + + it('links an earlier version of an edited post to the latest one', async () => { + const snapshot = createPost() + snapshot.edit = 'stale' + mount(snapshot) + await expect + .element(post.getByRole('link', { name: 'View latest' })) + .toHaveAttribute('href', 'https://x.com/example/status/1234567890123456789') + }) + + it('rejects unsafe media URLs and survives missing media and invalid dates', async () => { + const snapshot = createPost() + snapshot.createdAt = 'bad-date' + snapshot.media = [ + { ...createPhoto(), url: 'javascript:alert(1)' }, + { ...createVideo(), sources: [] }, + ] + const element = mount(snapshot) + expect(element.querySelector('[data-media] img, video, time')).toBeNull() + expect(element.querySelectorAll('[data-media-unavailable]')).toHaveLength(2) + snapshot.media = [createPhoto()] + element.data = { ...snapshot } + const image = element.querySelector('[data-media] img')! + image.dispatchEvent(new Event('error')) + await expect + .element(post.getByText('Media could not be loaded.', { exact: false })) + .toBeVisible() + }) + + it('shows a placeholder for media the source marked unavailable', () => { + const snapshot = createPost() + snapshot.media = [{ ...createPhoto(), unavailable: true }, createPhoto()] + const element = mount(snapshot) + expect(element.querySelectorAll('[data-media-unavailable]')).toHaveLength(1) + expect(element.querySelectorAll('[data-media] img')).toHaveLength(1) + }) + + it('handles video source errors', async () => { + const snapshot = createPost() + snapshot.media = [ + createPhoto(), + { + ...createVideo(), + sources: [{ type: 'video/mp4', url: 'https://example.com/video.mp4' }], + }, + ] + const element = mount(snapshot) + expect(element.querySelectorAll('[data-media-item]')).toHaveLength(2) + element.querySelector('source')!.dispatchEvent(new Event('error')) + await expect + .element(post.getByText('Media could not be loaded.', { exact: false }).nth(1)) + .toBeVisible() + expect(element.querySelector('video')?.hidden).toBe(true) + }) +}) diff --git a/packages/embed/src/x/index.ts b/packages/embed/src/x/index.ts new file mode 100644 index 00000000..2e4263ab --- /dev/null +++ b/packages/embed/src/x/index.ts @@ -0,0 +1,12 @@ +import type { XPostElement } from './x-post.ts' + +export type { Resolver } from '../fetch.ts' +export { registerXPost } from './register.ts' +export { useXPost } from './x-post.ts' +export type { XPostElement, XPostProps } from './x-post.ts' + +declare global { + interface HTMLElementTagNameMap { + 'meowdown-embed-x': XPostElement + } +} diff --git a/packages/embed/src/x/register.ts b/packages/embed/src/x/register.ts new file mode 100644 index 00000000..c83d6779 --- /dev/null +++ b/packages/embed/src/x/register.ts @@ -0,0 +1,7 @@ +import { registerCustomElement } from '@aria-ui/core' + +import { XPost } from './x-post.ts' + +export function registerXPost(name = 'meowdown-embed-x'): void { + registerCustomElement(name, class extends XPost {}) +} diff --git a/packages/embed/src/x/render-author.ts b/packages/embed/src/x/render-author.ts new file mode 100644 index 00000000..b0c195a6 --- /dev/null +++ b/packages/embed/src/x/render-author.ts @@ -0,0 +1,48 @@ +import type { XPostAuthor } from '@post-embed/types' +import el from 'crelt' + +import { renderLink } from '../render-link.ts' +import { getSafeUrl } from '../safe-url.ts' +export function renderAuthor( + author: XPostAuthor, + protocols: readonly string[] | null, + date?: Node | string, +) { + if (!author.name && !author.handle) return + const validHandle = /^\w{1,15}$/.test(author.handle) + const avatar = author.avatar && getSafeUrl(author.avatar, protocols) + + return el( + 'header', + { 'data-author': '' }, + avatar + ? el('img', { + 'data-avatar': '', + 'data-shape': author.avatarShape, + src: avatar, + alt: '', + width: 48, + height: 48, + loading: 'lazy', + decoding: 'async', + referrerpolicy: 'no-referrer', + }) + : undefined, + 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}` : undefined, + ), + ) + : undefined, + ), + date ? el('span', { 'data-date': '' }, date) : undefined, + ) +} diff --git a/packages/embed/src/x/render-media.ts b/packages/embed/src/x/render-media.ts new file mode 100644 index 00000000..2a3078f7 --- /dev/null +++ b/packages/embed/src/x/render-media.ts @@ -0,0 +1,131 @@ +import type { XPostMedia } from '@post-embed/types' +import el from 'crelt' + +import { renderLink } from '../render-link.ts' +import { getSafeUrl } from '../safe-url.ts' + +function dimension(value: number): number | undefined { + return Number.isFinite(value) && value > 0 ? Math.round(value) : undefined +} + +function sizeAttrs(media: { width: number; height: number }) { + const width = dimension(media.width) + const height = dimension(media.height) + return { + width, + height, + style: width && height ? `--_ratio: ${width} / ${height}` : undefined, + } +} + +function getOrientation(media: { width: number; height: number }) { + const width = dimension(media.width) + const height = dimension(media.height) + if (!width || !height) return + return height > width * 1.1 ? 'portrait' : width > height * 1.1 ? 'landscape' : 'square' +} + +function renderUnavailable(permalink?: string) { + return el( + 'div', + { 'data-media-unavailable': '' }, + 'Media unavailable. ', + renderLink('View on X', permalink), + ) +} + +function renderItem(media: XPostMedia, protocols: readonly string[] | null, permalink?: string) { + const error = el( + 'div', + { 'data-media-error': '', hidden: true }, + 'Media could not be loaded. ', + renderLink('View on X', permalink), + ) + let content: HTMLAnchorElement | HTMLVideoElement + if (media.type === 'photo') { + const url = getSafeUrl(media.url, protocols) + if (media.unavailable || !url) return renderUnavailable(permalink) + const image = el('img', { + src: url, + alt: media.alt || 'Post image', + ...sizeAttrs(media), + loading: 'lazy', + decoding: 'async', + referrerpolicy: 'no-referrer', + }) + const link = el('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, image) + image.addEventListener('error', () => { + link.hidden = true + error.hidden = false + }) + content = link + } else { + const sources = media.sources + .flatMap((source) => { + const url = getSafeUrl(source.url, protocols) + return url ? [{ ...source, url }] : [] + }) + .sort((a, b) => { + return ( + Number(b.type === 'video/mp4') - Number(a.type === 'video/mp4') || + (b.bitrate || 0) - (a.bitrate || 0) + ) + }) + .map((source) => el('source', { src: source.url, type: source.type })) + if (media.unavailable || sources.length === 0) { + return renderUnavailable(permalink) + } + const gif = media.type === 'gif' + const video = el( + 'video', + { + controls: true, + playsInline: true, + preload: 'none', + 'aria-label': gif ? 'Animated GIF' : 'Post video', + poster: media.poster && getSafeUrl(media.poster, protocols), + ...sizeAttrs(media), + loop: gif, + }, + sources, + renderLink('Watch on X', permalink), + ) + video.muted = gif + const showError = () => { + video.hidden = true + error.hidden = false + } + video.addEventListener('error', showError) + let failedSources = 0 + for (const source of sources) { + source.addEventListener( + 'error', + () => { + failedSources++ + if (failedSources === sources.length) showError() + }, + { once: true }, + ) + } + content = video + } + return el( + 'div', + { 'data-media-item': '', 'data-orientation': getOrientation(media) }, + content, + error, + ) +} + +export function renderMedia( + media: XPostMedia[] | undefined, + protocols: readonly string[] | null, + permalink?: string, +) { + if (!media?.length) return + return el( + 'div', + { 'data-media': '', 'data-count': String(media.length) }, + media.map((item) => renderItem(item, protocols, permalink)), + ) +} diff --git a/packages/embed/src/x/render-post.ts b/packages/embed/src/x/render-post.ts new file mode 100644 index 00000000..2f9c30cd --- /dev/null +++ b/packages/embed/src/x/render-post.ts @@ -0,0 +1,74 @@ +import type { XPost, XPostBase } from '@post-embed/types' +import el from 'crelt' + +import { renderLink } from '../render-link.ts' + +import { renderAuthor } from './render-author.ts' +import { renderMedia } from './render-media.ts' +import { getPermalink, renderDate } from './render-shared.ts' + +function renderBody(post: XPostBase) { + const permalink = getPermalink(post) + return el( + 'p', + { 'data-body': '', dir: 'auto', lang: post.lang || undefined }, + el( + 'span', + { 'data-text': '' }, + post.body.map((segment) => { + return segment.type === 'link' + ? renderLink(segment.text, segment.url) + : segment.text.split('\n').map((line, index) => { + return [index ? el('br', {}) : undefined, line] + }) + }), + ), + post.truncated && permalink ? [' ', renderLink('Show more', permalink)] : undefined, + ) +} + +function renderEdit(post: XPostBase) { + 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') + : undefined + return edited ? el('footer', { 'data-footer': '' }, edited) : undefined +} + +function renderQuoted(post: XPostBase, protocols: readonly string[] | null) { + return el( + 'article', + { 'data-quoted': '', 'aria-label': 'Quoted post' }, + renderAuthor(post.author, protocols, renderDate(post)), + renderBody(post), + renderMedia(post.media, protocols, getPermalink(post)), + renderEdit(post), + ) +} + +export function renderPost(post: XPost, protocols: readonly string[] | null = null) { + const reply = post.replyTo + const replyUrl = + reply && /^\w{1,15}$/.test(reply.handle) && /^\d+$/.test(reply.id) + ? `https://x.com/${reply.handle}/status/${reply.id}` + : undefined + return el( + 'article', + {}, + renderAuthor(post.author, protocols, renderDate(post)), + reply + ? el('div', { 'data-reply-to': '' }, renderLink(`Replying to @${reply.handle}`, replyUrl)) + : undefined, + renderBody(post), + renderMedia(post.media, protocols, getPermalink(post)), + post.quote ? renderQuoted(post.quote, protocols) : undefined, + renderEdit(post), + ) +} diff --git a/packages/embed/src/x/render-shared.ts b/packages/embed/src/x/render-shared.ts new file mode 100644 index 00000000..479f6c8b --- /dev/null +++ b/packages/embed/src/x/render-shared.ts @@ -0,0 +1,38 @@ +import type { XPostBase } from '@post-embed/types' +import el from 'crelt' + +import { renderLink } from '../render-link.ts' + +export function getPermalink(post: Pick): string | undefined { + return /^\w{1,15}$/.test(post.author.handle) && /^\d+$/.test(post.id) + ? `https://x.com/${post.author.handle}/status/${post.id}` + : undefined +} + +/** + * The post date as a permalink: month and day, plus the year when it is not + * the current one. The full date and time are the tooltip. + */ +export function renderDate(post: Pick) { + const date = new Date(post.createdAt) + if (!Number.isFinite(date.getTime())) return + const sameYear = date.getFullYear() === new Date().getFullYear() + return renderLink( + el( + 'time', + { + datetime: date.toISOString(), + title: new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(date), + }, + new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + year: sameYear ? undefined : 'numeric', + }).format(date), + ), + getPermalink(post), + ) +} diff --git a/packages/embed/src/x/testing/fixtures.ts b/packages/embed/src/x/testing/fixtures.ts new file mode 100644 index 00000000..9efaf824 --- /dev/null +++ b/packages/embed/src/x/testing/fixtures.ts @@ -0,0 +1,39 @@ +import type { XPost, XPostMedia } from '@post-embed/types' + +export function createPost(text = 'Hello 😀\nA saved post.'): XPost { + return { + id: '1234567890123456789', + createdAt: '2026-09-10T00:00:00.000Z', + lang: 'en', + author: { + name: 'Example Author', + handle: 'example', + avatar: 'https://example.com/avatar.jpg', + }, + body: text ? [{ type: 'text', text }] : [], + } +} + +export function createPhoto(): Extract { + return { + type: 'photo', + url: new URL('./image.svg?no-inline', import.meta.url).href, + width: 640, + height: 400, + alt: 'Blue illustrated mountains', + } +} + +export function createVideo(gif = false): Extract { + return { + type: gif ? 'gif' : 'video', + poster: createPhoto().url, + width: 640, + height: 400, + sources: [ + { type: 'application/x-mpegURL', url: 'https://example.com/video.m3u8' }, + { type: 'video/mp4', bitrate: 200, url: 'https://example.com/high.mp4' }, + { type: 'video/mp4', bitrate: 100, url: 'https://example.com/low.mp4' }, + ], + } +} diff --git a/packages/embed/src/x/testing/image.svg b/packages/embed/src/x/testing/image.svg new file mode 100644 index 00000000..5bc0d4db --- /dev/null +++ b/packages/embed/src/x/testing/image.svg @@ -0,0 +1 @@ +Post Embed · Image 1 diff --git a/packages/embed/src/x/theme.css b/packages/embed/src/x/theme.css new file mode 100644 index 00000000..0b5aaed7 --- /dev/null +++ b/packages/embed/src/x/theme.css @@ -0,0 +1,395 @@ +@property --meowdown-embed-color { + syntax: '*'; + inherits: true; +} + +@property --meowdown-embed-background { + syntax: '*'; + inherits: true; +} + +@property --meowdown-embed-border-color { + syntax: '*'; + inherits: true; +} + +@property --meowdown-embed-radius { + syntax: '*'; + inherits: true; +} + +@property --meowdown-embed-padding { + syntax: '*'; + inherits: true; +} + +@property --meowdown-embed-muted-color { + syntax: '*'; + inherits: true; +} + +@property --meowdown-embed-link-color { + syntax: '*'; + inherits: true; +} + +/* + * Hard reset. `all: revert` rolls every property on every box inside the card + * back to the user-agent default, so a host rule on `p`, `a`, `img`, + * `article`, `header`, or `footer` (a prose stylesheet, a CSS reset, an + * editor theme) cannot reach in. The reset sits at specificity (0,2,0); + * every rule below is more specific and re-applies the card's own values, + * including the UA defaults the card does not want (`p` margins, `a` colors). + */ +[data-meowdown-embed='x'] [data-root], +[data-meowdown-embed='x'] [data-root] *, +[data-meowdown-embed='x'] [data-root] *::before, +[data-meowdown-embed='x'] [data-root] *::after { + all: revert; + box-sizing: border-box; +} + +[data-meowdown-embed='x'] { + --_hairline: var( + --meowdown-embed-border-color, + color-mix(in srgb, currentColor 14%, transparent) + ); + --_muted: var(--meowdown-embed-muted-color, color-mix(in srgb, currentColor 55%, transparent)); + --_link: var(--meowdown-embed-link-color, #1d9bf0); + --_tint: color-mix(in srgb, currentColor 5%, transparent); + + display: block; + /* As wide as the content, between 18rem and 28rem: a short text post stays + * small, while long text and media fill the full width. */ + width: fit-content; + max-width: min(28rem, 100%); + font: inherit; + color: var(--meowdown-embed-color, inherit); + text-decoration-skip-ink: none; +} + +/* + * The minimum width. A percentage `min-width` on the host resolves to nothing + * inside a shrink-to-fit parent (an inline-block editor wrapper), and a fixed + * one overflows a narrow container. A zero-height spacer instead raises the + * card's intrinsic width to 18rem and still yields to the host's `max-width`. + */ +[data-meowdown-embed='x'] [data-root]::before { + content: ''; + display: block; + width: calc(18rem - 2 * var(--meowdown-embed-padding, 0.75rem)); + max-width: 100%; + height: 0; +} + +[data-meowdown-embed='x'] [data-root] { + display: block; + position: relative; + color: var(--meowdown-embed-color, inherit); + background: var(--meowdown-embed-background, var(--_tint)); + border-radius: var(--meowdown-embed-radius, 0.75rem); + padding: var(--meowdown-embed-padding, 0.75rem); + font-size: 1em; + font-weight: 400; + font-style: normal; + line-height: 1.45; + letter-spacing: normal; + text-align: start; + text-transform: none; + text-indent: 0; + text-shadow: none; + word-spacing: normal; + font-variant: normal; + hyphens: manual; + white-space: normal; + overflow-wrap: anywhere; + cursor: auto; + + & [hidden] { + display: none !important; + } + & p { + margin: 0; + } + & a { + color: var(--_link); + text-decoration: none; + cursor: pointer; + } + & a:hover { + text-decoration: underline; + text-underline-offset: 0.15em; + } + & a:focus-visible { + outline: 2px solid var(--_link); + outline-offset: 2px; + border-radius: 4px; + } + + /* Header */ + & [data-author] { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; + } + & [data-avatar] { + display: block; + flex: none; + width: 2rem; + height: 2rem; + object-fit: cover; + border-radius: 50%; + background: var(--_tint); + } + & [data-avatar][data-shape='square'] { + border-radius: 0.375rem; + } + & [data-avatar][data-shape='hexagon'] { + clip-path: polygon(25% 0, 75% 0, 100% 50%, 75% 100%, 25% 100%, 0 50%); + } + & [data-author-details] { + display: block; + min-width: 0; + flex: 1; + line-height: 1.25; + } + & [data-author-name] { + display: flex; + align-items: center; + gap: 0.25rem; + flex-wrap: wrap; + font-weight: 600; + } + & [data-author-details] > bdi { + font-size: 0.875em; + color: var(--_muted); + } + & [data-author-details] > bdi > a { + color: inherit; + } + & [data-date] { + display: block; + flex: none; + align-self: flex-start; + padding-inline-start: 0.25rem; + font-size: 0.8125em; + line-height: 1.25rem; + white-space: nowrap; + color: var(--_muted); + } + & [data-date] > a { + color: inherit; + } + + /* Body */ + & [data-reply-to] { + display: block; + margin-block-start: 0.375rem; + font-size: 0.8125em; + color: var(--_muted); + } + & [data-body] { + display: block; + margin-block: 0.625rem 0; + font-size: 1em; + line-height: 1.5; + } + & [data-reply-to] + [data-body] { + margin-block-start: 0.25rem; + } + & [data-text] { + white-space: pre-wrap; + } + + /* Media */ + & [data-media] { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 2px; + margin-block-start: 0.75rem; + border-radius: 0.5rem; + overflow: hidden; + background: var(--_hairline); + } + & [data-media][data-count='1'] { + grid-template-columns: minmax(0, 1fr); + } + & [data-media-item] { + display: block; + position: relative; + min-width: 0; + overflow: hidden; + } + /* A hairline keeps white screenshots from dissolving into the card. */ + & [data-media-item]::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 10%, transparent); + pointer-events: none; + } + & [data-media-item] > a { + display: block; + height: 100%; + } + /* A single photo or video fills the width at its own ratio, up to 20rem + * tall; taller media is cropped and the photo links to the original. */ + & [data-media-item] img, + & [data-media-item] video { + display: block; + width: 100%; + height: auto; + aspect-ratio: var(--_ratio, auto); + max-height: 20rem; + object-fit: cover; + background: var(--_tint); + } + & [data-media-item][data-orientation='portrait'] :is(img, video) { + object-position: center 30%; + } + & [data-media]:not([data-count='1']) [data-media-item] :is(img, video) { + height: 100%; + aspect-ratio: 3 / 2; + } + & [data-media][data-count='3'] [data-media-item]:first-child { + grid-row: span 2; + } + & [data-media][data-count='3'] [data-media-item]:first-child :is(img, video) { + aspect-ratio: auto; + } + & [data-media-unavailable], + & [data-media-error] { + display: block; + padding: 0.75rem 1rem; + color: var(--_muted); + background: var(--_tint); + } + + /* Quoted post */ + & [data-quoted] { + display: block; + margin-block-start: 0.75rem; + padding: 0.75rem; + border-radius: 0.5rem; + background: var(--_tint); + } + & [data-quoted] [data-avatar] { + width: 1.375rem; + height: 1.375rem; + } + & [data-quoted] [data-author-details] { + display: flex; + align-items: baseline; + gap: 0 0.35rem; + line-height: 1.375rem; + } + & [data-quoted] [data-author-name], + & [data-quoted] [data-author-details] > bdi { + display: block; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + & [data-quoted] [data-author-name] { + flex: 0 1 auto; + } + & [data-quoted] [data-author-details] > bdi { + flex: 0 100 auto; + min-width: 2.5rem; + font-size: 0.9375em; + } + & [data-quoted] [data-date] { + align-self: center; + } + & [data-quoted] [data-body] { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-clamp: 3; + overflow: hidden; + margin-block-start: 0.25rem; + font-size: 0.9375em; + } + & [data-quoted] [data-media] { + margin-block-start: 0.5rem; + border-radius: 0.375rem; + } + & [data-quoted] [data-media-item] :is(img, video) { + max-height: 10rem; + } + /* Quoted photos shrink to one thumbnail beside the text, with a count + * badge; a quoted video or GIF stays below the text so its player works. */ + & [data-quoted]:has(> [data-media]):not(:has(> [data-media] video)) { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + 'head head' + 'body media' + 'foot foot'; + column-gap: 0.75rem; + + & > [data-author] { + grid-area: head; + } + & > [data-body] { + grid-area: body; + } + & > [data-footer] { + grid-area: foot; + } + & > [data-media] { + grid-area: media; + position: relative; + display: block; + width: 4rem; + height: 4rem; + margin-block-start: 0.375rem; + } + & > [data-media] > [data-media-item] { + width: 100%; + height: 100%; + } + & > [data-media] > [data-media-item]:nth-child(n + 2) { + display: none; + } + & > [data-media] > [data-media-item] img { + height: 100%; + max-height: none; + aspect-ratio: auto; + } + & > [data-media]:not([data-count='1'])::after { + content: attr(data-count); + position: absolute; + inset: auto 0.25rem 0.25rem auto; + min-width: 1.25rem; + padding: 0 0.3125rem; + border-radius: 0.625rem; + font-size: 0.6875rem; + font-weight: 600; + line-height: 1.25rem; + text-align: center; + color: #fff; + background: rgb(0 0 0 / 0.62); + pointer-events: none; + } + } + + /* Footer: only the edit state; the date lives in the header. */ + & [data-footer] { + display: block; + margin-block-start: 0.5rem; + font-size: 0.8125em; + color: var(--_muted); + } + & [data-edited] { + font-style: normal; + } + + /* Fallback */ + & [data-fallback] [data-body] { + color: var(--_muted); + } +} diff --git a/packages/embed/src/x/x-post.test.tsx b/packages/embed/src/x/x-post.test.tsx new file mode 100644 index 00000000..c5252b6b --- /dev/null +++ b/packages/embed/src/x/x-post.test.tsx @@ -0,0 +1,332 @@ +import './theme.css' + +import { sleep } from '@ocavue/utils' +import type { XPost } from '@post-embed/types' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { page, server, userEvent } from 'vitest/browser' + +import { createPhoto, createPost } from './testing/fixtures.ts' + +import { registerXPost, type XPostElement } from './index.ts' + +beforeAll(() => { + registerXPost() +}) + +function mount(text = 'Hello 😀\nA saved post.') { + const element = document.createElement('meowdown-embed-x') + element.dataset.testid = 'post' + element.data = createPost(text) + document.body.append(element) + return element +} + +const post = page.getByTestId('post') + +afterEach(() => { + document.body.replaceChildren() + vi.restoreAllMocks() +}) + +describe('X post', () => { + it.each(['https://example.com/unrelated', 'https://x.com/other/status/456'])( + 'renders explicit data without resolving the unrelated URL %s', + async (url) => { + const element = mount('Explicit snapshot') + const resolver = vi.fn() + element.resolver = resolver + element.url = url + await expect.element(post.getByText('Explicit snapshot')).toBeVisible() + expect(resolver).not.toHaveBeenCalled() + }, + ) + + it('renders selectable light DOM text and native attribution links', async () => { + const element = mount() + await expect.element(post.getByText(/Hello 😀/)).toBeVisible() + await expect + .element(post.getByRole('link', { name: /Sep 10/ })) + .toHaveAttribute('href', 'https://x.com/example/status/1234567890123456789') + expect(element.shadowRoot).toBeNull() + expect(element.hasAttribute('data')).toBe(false) + if (server.browser === 'webkit' && navigator.platform.includes('Mac')) { + await userEvent.keyboard('{Alt>}{Tab}{/Alt}') + } else { + await userEvent.tab() + } + await expect.element(post.getByRole('link', { name: '@example' })).toHaveFocus() + }) + + it('renders markup and character references as literal text', async () => { + mount('A & B ') + await expect.element(post.getByText('A & B ')).toBeVisible() + await expect.element(post.getByRole('img')).not.toBeInTheDocument() + }) + + it('updates the same ID and clears the current view immediately', async () => { + const element = mount('First') + await expect.element(post.getByText('First')).toBeVisible() + element.data = createPost('Second') + expect(element.textContent).not.toContain('First') + await expect.element(post.getByText('Second')).toBeVisible() + element.data = createPost('Late') + element.data = null + await expect.element(post.getByText('This post is unavailable.')).toBeVisible() + expect(element.textContent).not.toContain('Late') + }) + + it('shows invalid input and recovers with a valid snapshot', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const element = mount('Before') + await expect.element(post.getByText('Before')).toBeVisible() + Reflect.set(element, 'data', {}) + await expect.element(post.getByText('This post is unavailable.')).toBeVisible() + expect(error).toHaveBeenCalledWith('[meowdown] Invalid X post data:', expect.any(Array)) + element.data = createPost('Recovered') + await expect.element(post.getByText('Recovered')).toBeVisible() + }) + + it('handles schema defaults without inventing attribution', async () => { + const element = mount() + Reflect.set(element, 'data', { id: '123', author: {} }) + await expect.element(post).toHaveTextContent('') + await expect.element(post.getByRole('link')).not.toBeInTheDocument() + }) + + it('reconnects with the latest data and preserves host children', async () => { + const element = mount('First') + const annotation = document.createElement('span') + annotation.textContent = 'Host annotation' + element.append(annotation) + await expect.element(post.getByText('First')).toBeVisible() + element.data = createPost('Stale') + element.remove() + element.data = createPost('Reconnected') + document.body.append(element) + await expect.element(post.getByText('Reconnected')).toBeVisible() + await expect.element(post.getByText('Host annotation')).toBeVisible() + element.data = null + await expect.element(post.getByText('Host annotation')).toBeVisible() + await expect.element(post.getByText('This post is unavailable.')).toBeVisible() + expect(element.children.length).toBe(2) + }) + + it('reuses only the direct marked container and replaces server content', async () => { + const element = document.createElement('meowdown-embed-x') + element.dataset.testid = 'post' + const annotation = document.createElement('div') + annotation.innerHTML = '
Host annotation
' + const container = document.createElement('div') + container.dataset.root = '' + container.textContent = 'Server fallback' + element.append(annotation, container) + element.data = createPost('Saved snapshot') + document.body.append(element) + await expect.element(post.getByText('Saved snapshot')).toBeVisible() + expect(element.querySelector(':scope > [data-root]')).toBe(container) + expect(element.children.length).toBe(2) + expect(container.textContent).not.toContain('Server fallback') + expect(annotation.textContent).toBe('Host annotation') + element.data = createPost('Updated snapshot') + await expect.element(post.getByText('Updated snapshot')).toBeVisible() + expect(element.querySelector(':scope > [data-root]')).toBe(container) + }) + + it('shows a themed fallback for missing data without logging an error', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const element = document.createElement('meowdown-embed-x') + element.dataset.testid = 'post' + document.body.append(element) + await expect.element(post.getByText('This post is unavailable.')).toBeVisible() + expect(element.querySelector('[data-fallback]')).not.toBeNull() + expect(error).not.toHaveBeenCalled() + }) + + it('preserves whitespace through the theme and inherits custom properties', async () => { + const wrapper = document.createElement('div') + wrapper.style.setProperty('--meowdown-embed-padding', '24px') + wrapper.style.setProperty('--meowdown-embed-color', 'rgb(12, 34, 56)') + document.body.append(wrapper) + const element = mount('First second') + wrapper.append(element) + await expect.element(post.getByText('First second')).toBeVisible() + const content = element.querySelector('[data-text]') + const root = element.querySelector('[data-root]') + if (!content || !root) throw new Error('Missing rendered post parts') + expect(getComputedStyle(content).whiteSpace).toBe('pre-wrap') + expect(getComputedStyle(root).padding).toBe('24px') + expect(getComputedStyle(content).color).toBe('rgb(12, 34, 56)') + expect(element.querySelector('[style]')).toBeNull() + }) + + it('keeps instances independent and registration idempotent', async () => { + const constructor = customElements.get('meowdown-embed-x') + registerXPost() + expect(customElements.get('meowdown-embed-x')).toBe(constructor) + const first = mount('First') + const second = mount('Second') + second.dataset.testid = 'second' + await expect.element(page.getByTestId('second').getByText('Second')).toBeVisible() + first.data = null + await expect.element(page.getByTestId('second').getByText('Second')).toBeVisible() + }) + + it('registers independent custom names alongside the default element', async () => { + registerXPost('custom-x-post') + registerXPost('another-x-post') + registerXPost('custom-x-post') + const element = document.createElement('custom-x-post') as XPostElement + element.dataset.testid = 'custom-post' + element.data = createPost('Custom name') + document.body.append(element) + await expect.element(page.getByTestId('custom-post').getByText('Custom name')).toBeVisible() + await expect + .element(page.getByTestId('custom-post')) + .toHaveAttribute('data-meowdown-embed', 'x') + const another = document.createElement('another-x-post') as XPostElement + another.dataset.testid = 'another-post' + another.data = createPost('Another name') + document.body.append(another) + await expect.element(page.getByTestId('another-post').getByText('Another name')).toBeVisible() + }) + + it('keeps attribution when the body is empty', async () => { + mount('') + await expect.element(post.getByRole('link', { name: /Sep 10/ })).toBeVisible() + await expect.element(post.getByRole('paragraph')).toHaveTextContent('') + }) + + it('does not mutate a frozen snapshot, including its quote', async () => { + const element = mount() + const snapshot: XPost = { + ...createPost('Saved text'), + media: [createPhoto()], + quote: { ...createPost('Quoted text'), id: '2', media: [createPhoto()] }, + } + const original = structuredClone(snapshot) + const freeze = (value: unknown) => { + if (value && typeof value === 'object') { + Object.freeze(value) + for (const item of Object.values(value)) freeze(item) + } + } + freeze(snapshot) + element.data = snapshot + await expect.element(post.getByText('Saved text', { exact: true })).toBeVisible() + await expect.element(post.getByText('Quoted text', { exact: true })).toBeVisible() + expect(snapshot).toEqual(original) + }) + + it('renders unsafe destinations as text', async () => { + const element = mount() + const snapshot = createPost() + snapshot.body = [{ type: 'link', text: 'A link', url: 'javascript:alert(1)' }] + element.data = snapshot + await expect.element(post.getByText('A link')).toBeVisible() + await expect.element(post.getByRole('link', { name: 'A link' })).not.toBeInTheDocument() + }) +}) + +describe('X post fetch', () => { + const url = 'https://x.com/example/status/1234567890123456789' + + function mountRemote(resolver: XPostElement['resolver'], remote = url) { + const element = document.createElement('meowdown-embed-x') + element.dataset.testid = 'post' + element.url = remote + element.resolver = resolver + document.body.append(element) + return element + } + + it('calls `resolver` with `url` and renders the resolved snapshot', async () => { + let resolve!: (post: XPost) => void + const resolver = vi.fn(() => { + return new Promise((r) => { + resolve = r + }) + }) + const element = mountRemote(resolver) + await expect.element(post.getByText('Loading this post…')).toBeVisible() + expect(element.querySelector('[data-fallback][data-pending]')).not.toBeNull() + resolve(createPost('Fetched')) + await expect.element(post.getByText('Fetched')).toBeVisible() + expect(resolver).toHaveBeenCalledTimes(1) + expect(resolver).toHaveBeenCalledWith(url) + expect(element.data).toBeNull() + }) + + it('renders a synchronous result without a pending state', async () => { + const element = mountRemote(() => createPost('Sync')) + expect(element.textContent).toContain('Sync') + expect(element.textContent).not.toContain('Loading') + await expect.element(post.getByText('Sync')).toBeVisible() + }) + + it('shows the fallback when `resolver` finds nothing', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + mountRemote(() => Promise.resolve(undefined)) + await expect.element(post.getByText('This post is unavailable.')).toBeVisible() + expect(error).not.toHaveBeenCalled() + }) + + it('logs a rejected fetch and shows the fallback', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const element = mountRemote(() => Promise.reject(new Error('offline'))) + await expect.element(post.getByText('This post is unavailable.')).toBeVisible() + expect(error).toHaveBeenCalledWith('[meowdown] Failed to fetch X post:', expect.any(Error)) + expect(element.querySelector('[data-pending]')).toBeNull() + }) + + it('rejects a resolver result belonging to another post', async () => { + mountRemote(() => ({ ...createPost('Wrong post'), id: '2' })) + await expect.element(post.getByText('This post is unavailable.')).toBeVisible() + }) + + it('refetches when `url` changes and ignores the stale result', async () => { + const resolvers = new Map void>() + const element = mountRemote((value) => { + return new Promise((resolve) => { + resolvers.set(value, resolve) + }) + }) + await expect.element(post.getByText('Loading this post…')).toBeVisible() + element.url = 'https://x.com/example/status/2' + resolvers.get(url)?.(createPost('Stale')) + await sleep(20) + expect(element.textContent).not.toContain('Stale') + await expect.element(post.getByText('Loading this post…')).toBeVisible() + resolvers.get('https://x.com/example/status/2')?.({ + ...createPost('Fresh'), + id: '2', + }) + await expect.element(post.getByText('Fresh')).toBeVisible() + }) + + it('prefers `data` and fetches only once `data` is cleared', async () => { + const resolver = vi.fn(() => Promise.resolve(createPost('Fetched'))) + const element = document.createElement('meowdown-embed-x') + element.dataset.testid = 'post' + element.data = createPost('Saved') + element.url = url + element.resolver = resolver + document.body.append(element) + await expect.element(post.getByText('Saved')).toBeVisible() + expect(resolver).not.toHaveBeenCalled() + element.data = null + await expect.element(post.getByText('Fetched')).toBeVisible() + expect(resolver).toHaveBeenCalledTimes(1) + }) + + it('does not fetch without both `url` and `resolver`', async () => { + const resolver = vi.fn(() => Promise.resolve(createPost('Fetched'))) + const element = mountRemote(null) + await expect.element(post.getByText('This post is unavailable.')).toBeVisible() + element.url = null + element.resolver = resolver + await expect.element(post.getByText('This post is unavailable.')).toBeVisible() + expect(resolver).not.toHaveBeenCalled() + element.url = url + await expect.element(post.getByText('Fetched')).toBeVisible() + }) +}) diff --git a/packages/embed/src/x/x-post.ts b/packages/embed/src/x/x-post.ts new file mode 100644 index 00000000..df48583b --- /dev/null +++ b/packages/embed/src/x/x-post.ts @@ -0,0 +1,67 @@ +import { + defineCustomElement, + defineProps, + type HostElement, + type State, + useEffect as useHostEffect, +} from '@aria-ui/core' +import { parseXPost, parseXPostId } from '@post-embed/schema' +import type { XPost as XPostSnapshot } from '@post-embed/types' +import el from 'crelt' + +import { type FetchProps, useFetch } from '../fetch.ts' +import { getRootContainer } from '../root.ts' + +import { renderPost } from './render-post.ts' + +export interface XPostProps extends FetchProps { + mediaUrlProtocols: readonly string[] | null +} + +export interface XPostElement extends HTMLElement, XPostProps {} + +/** @internal */ +export function useXPost(host: HostElement, props: State): void { + const { fetched, pending } = useFetch(host, props, 'X post') + + useHostEffect(host, () => { + host.dataset.meowdownEmbed = 'x' + const container = getRootContainer(host) + const data = props.data.get() ?? fetched.get() + const result = data == null ? undefined : parseXPost(data) + + if (result?.issues) { + console.error('[meowdown] Invalid X post data:', result.issues) + } + + const protocols = props.mediaUrlProtocols.get() + const url = props.url.get() + const value = result && !result.issues ? result.value : undefined + // Validate that resolver output belongs to the requested permalink. + const valid = value && (props.data.get() != null || !url || parseXPostId(url) === value.id) + container.replaceChildren(valid ? renderPost(value, protocols) : renderFallback(pending.get())) + return () => { + for (const video of container.querySelectorAll('video')) video.pause() + } + }) +} + +function renderFallback(pending: boolean): HTMLElement { + return el( + 'article', + pending ? { 'data-fallback': '', 'data-pending': '' } : { '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.'), + ) +} + +export const XPost = defineCustomElement( + useXPost, + defineProps({ + data: { default: null, attribute: false }, + url: { default: null, attribute: false }, + resolver: { default: null, attribute: false }, + mediaUrlProtocols: { default: null, attribute: false }, + }), +) diff --git a/packages/embed/src/youtube/index.ts b/packages/embed/src/youtube/index.ts new file mode 100644 index 00000000..42f9a506 --- /dev/null +++ b/packages/embed/src/youtube/index.ts @@ -0,0 +1,12 @@ +import type { YouTubeVideoElement } from './youtube-video.ts' + +export type { Resolver } from '../fetch.ts' +export { registerYouTubeVideo } from './register.ts' +export { useYouTubeVideo } from './youtube-video.ts' +export type { YouTubeVideoElement, YouTubeVideoProps } from './youtube-video.ts' + +declare global { + interface HTMLElementTagNameMap { + 'meowdown-embed-youtube': YouTubeVideoElement + } +} diff --git a/packages/embed/src/youtube/parse-url.test.ts b/packages/embed/src/youtube/parse-url.test.ts new file mode 100644 index 00000000..cfb3d2de --- /dev/null +++ b/packages/embed/src/youtube/parse-url.test.ts @@ -0,0 +1,63 @@ +import { expect, it } from 'vitest' + +import { getEmbedUrl, getWatchUrl, parseYouTubeUrl } from './parse-url.ts' + +it.each([ + 'https://www.youtube.com/watch?v=aqz-KE-bpKQ', + 'https://youtube.com/watch?v=aqz-KE-bpKQ&list=abc', + 'https://m.youtube.com/watch?v=aqz-KE-bpKQ', + 'https://youtu.be/aqz-KE-bpKQ', + 'https://www.youtube.com/embed/aqz-KE-bpKQ', + 'https://www.youtube-nocookie.com/embed/aqz-KE-bpKQ', + 'https://www.youtube.com/live/aqz-KE-bpKQ', +])('parses the video id from %s', (url) => { + expect(parseYouTubeUrl(url)).toEqual({ + videoId: 'aqz-KE-bpKQ', + startSeconds: 0, + short: false, + }) +}) + +it('marks Shorts URLs', () => { + expect(parseYouTubeUrl('https://www.youtube.com/shorts/aqz-KE-bpKQ')).toEqual({ + videoId: 'aqz-KE-bpKQ', + startSeconds: 0, + short: true, + }) +}) + +it('parses start offsets', () => { + expect(parseYouTubeUrl('https://youtu.be/aqz-KE-bpKQ?t=90')?.startSeconds).toBe(90) + expect(parseYouTubeUrl('https://www.youtube.com/watch?v=aqz-KE-bpKQ&t=1m30s')?.startSeconds).toBe( + 90, + ) + expect( + parseYouTubeUrl('https://www.youtube.com/watch?v=aqz-KE-bpKQ&start=3723')?.startSeconds, + ).toBe(3723) + expect(parseYouTubeUrl('https://www.youtube.com/watch?v=aqz-KE-bpKQ&t=later')?.startSeconds).toBe( + 0, + ) +}) + +it.each([ + 'https://www.youtube.com/watch?v=tooShort', + 'https://www.youtube.com/channel/aqz-KE-bpKQ', + 'https://example.com/watch?v=aqz-KE-bpKQ', + 'not a url', + '', +])('rejects %s', (url) => { + expect(parseYouTubeUrl(url)).toBeUndefined() +}) + +it('builds canonical watch and embed URLs', () => { + const ref = { videoId: 'aqz-KE-bpKQ', startSeconds: 0, short: false } + expect(getWatchUrl(ref)).toBe('https://www.youtube.com/watch?v=aqz-KE-bpKQ') + expect(getEmbedUrl(ref)).toBe( + 'https://www.youtube-nocookie.com/embed/aqz-KE-bpKQ?autoplay=1&playsinline=1', + ) + const later = { videoId: 'aqz-KE-bpKQ', startSeconds: 90, short: false } + expect(getWatchUrl(later)).toBe('https://www.youtube.com/watch?v=aqz-KE-bpKQ&t=90') + expect(getEmbedUrl(later)).toBe( + 'https://www.youtube-nocookie.com/embed/aqz-KE-bpKQ?autoplay=1&playsinline=1&start=90', + ) +}) diff --git a/packages/embed/src/youtube/parse-url.ts b/packages/embed/src/youtube/parse-url.ts new file mode 100644 index 00000000..63e12a93 --- /dev/null +++ b/packages/embed/src/youtube/parse-url.ts @@ -0,0 +1,56 @@ +const YOUTUBE_HOSTS = /^(?:www\.|m\.)?(?:youtube\.com|youtube-nocookie\.com)$/i +const YOUTU_BE_HOST = /^(?:www\.)?youtu\.be$/i +const VIDEO_ID = /^[\w-]{11}$/ + +export interface YouTubeVideoRef { + videoId: string + startSeconds: number + short: boolean +} + +export function parseYouTubeUrl(value: string): YouTubeVideoRef | undefined { + let url: URL + try { + url = new URL(value) + } catch { + return + } + let videoId: string | null = null + let short = false + if (YOUTU_BE_HOST.test(url.hostname)) { + videoId = url.pathname.slice(1) + } else if (YOUTUBE_HOSTS.test(url.hostname)) { + const [, first, second] = url.pathname.split('/') + if (url.pathname === '/watch') { + videoId = url.searchParams.get('v') + } else if (first === 'shorts' || first === 'embed' || first === 'live') { + videoId = second ?? null + short = first === 'shorts' + } + } + if (!videoId || !VIDEO_ID.test(videoId)) return + const time = url.searchParams.get('start') ?? url.searchParams.get('t') ?? '' + return { videoId, startSeconds: parseStartSeconds(time), short } +} + +/** + * `90`, `90s`, `1m30s`, `1h2m3s` to seconds; anything else is `0`. + */ +function parseStartSeconds(value: string): number { + if (!value) return 0 + if (/^\d+$/.test(value)) return Number(value) + const matched = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(value) + if (!matched) return 0 + return Number(matched[1] ?? 0) * 3600 + Number(matched[2] ?? 0) * 60 + Number(matched[3] ?? 0) +} + +export function getWatchUrl(ref: YouTubeVideoRef): string { + const time = ref.startSeconds ? `&t=${ref.startSeconds}` : '' + return `https://www.youtube.com/watch?v=${ref.videoId}${time}` +} + +export function getEmbedUrl(ref: YouTubeVideoRef): string { + const params = new URLSearchParams({ autoplay: '1', playsinline: '1' }) + if (ref.startSeconds) params.set('start', String(ref.startSeconds)) + return `https://www.youtube-nocookie.com/embed/${ref.videoId}?${params}` +} diff --git a/packages/embed/src/youtube/register.ts b/packages/embed/src/youtube/register.ts new file mode 100644 index 00000000..4d2b809f --- /dev/null +++ b/packages/embed/src/youtube/register.ts @@ -0,0 +1,7 @@ +import { registerCustomElement } from '@aria-ui/core' + +import { YouTubeVideoCustomElement } from './youtube-video.ts' + +export function registerYouTubeVideo(name = 'meowdown-embed-youtube'): void { + registerCustomElement(name, class extends YouTubeVideoCustomElement {}) +} diff --git a/packages/embed/src/youtube/render-video.ts b/packages/embed/src/youtube/render-video.ts new file mode 100644 index 00000000..b9897e2b --- /dev/null +++ b/packages/embed/src/youtube/render-video.ts @@ -0,0 +1,70 @@ +import type { YouTubeVideo } from '@post-embed/types' +import el from 'crelt' + +import { renderLink } from '../render-link.ts' +import { getSafeUrl } from '../safe-url.ts' + +import { getEmbedUrl, getWatchUrl, type YouTubeVideoRef } from './parse-url.ts' + +export type Playback = 'link' | 'inline' + +function renderFrame(ref: YouTubeVideoRef, title: string) { + return el('iframe', { + src: getEmbedUrl(ref), + title, + allow: + 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen', + allowfullscreen: true, + referrerpolicy: 'strict-origin-when-cross-origin', + }) +} + +export function renderVideo(video: YouTubeVideo, ref: YouTubeVideoRef, playback: Playback) { + const watchUrl = getWatchUrl(ref) + const title = video.title || 'YouTube video' + const poster = getSafeUrl(video.thumbnail_url) + const external = { target: '_blank', rel: 'noopener noreferrer' } + const posterContent = [ + poster + ? el('img', { + src: poster, + alt: '', + width: video.thumbnail_width || undefined, + height: video.thumbnail_height || undefined, + loading: 'lazy', + decoding: 'async', + referrerpolicy: 'no-referrer', + }) + : undefined, + el('span', { 'data-play': '', 'aria-hidden': 'true' }), + ] + let posterElement: HTMLElement + if (playback === 'inline') { + const button = el( + 'button', + { 'data-poster': '', type: 'button', 'aria-label': `Play: ${title}` }, + posterContent, + ) + button.addEventListener('click', () => { + const frame = renderFrame(ref, title) + button.replaceWith(frame) + frame.focus() + }) + posterElement = button + } else { + posterElement = el('a', { 'data-poster': '', href: watchUrl, ...external }, posterContent) + } + return el( + 'article', + { 'data-orientation': ref.short ? 'portrait' : undefined }, + posterElement, + el( + 'div', + { 'data-details': '' }, + el('a', { 'data-title': '', href: watchUrl, ...external }, title), + video.author_name + ? renderLink(el('span', { 'data-author': '' }, video.author_name), video.author_url) + : undefined, + ), + ) +} diff --git a/packages/embed/src/youtube/testing/fixtures.ts b/packages/embed/src/youtube/testing/fixtures.ts new file mode 100644 index 00000000..d92fb994 --- /dev/null +++ b/packages/embed/src/youtube/testing/fixtures.ts @@ -0,0 +1,17 @@ +import type { YouTubeVideo } from '@post-embed/types' + +export function createVideo( + url = 'https://www.youtube.com/watch?v=aqz-KE-bpKQ&t=90', +): YouTubeVideo { + return { + url, + title: 'Big Buck Bunny', + author_name: 'Blender', + author_url: 'https://www.youtube.com/@BlenderOfficial', + thumbnail_url: new URL('./poster.svg?no-inline', import.meta.url).href, + thumbnail_width: 480, + thumbnail_height: 360, + width: 200, + height: 113, + } +} diff --git a/packages/embed/src/youtube/testing/poster.svg b/packages/embed/src/youtube/testing/poster.svg new file mode 100644 index 00000000..dc84855a --- /dev/null +++ b/packages/embed/src/youtube/testing/poster.svg @@ -0,0 +1 @@ + diff --git a/packages/embed/src/youtube/theme.css b/packages/embed/src/youtube/theme.css new file mode 100644 index 00000000..871ac620 --- /dev/null +++ b/packages/embed/src/youtube/theme.css @@ -0,0 +1,173 @@ +@property --meowdown-embed-color { + syntax: '*'; + inherits: true; +} + +@property --meowdown-embed-background { + syntax: '*'; + inherits: true; +} + +@property --meowdown-embed-border-color { + syntax: '*'; + inherits: true; +} + +@property --meowdown-embed-radius { + syntax: '*'; + inherits: true; +} + +@property --meowdown-embed-padding { + syntax: '*'; + inherits: true; +} + +@property --meowdown-embed-muted-color { + syntax: '*'; + inherits: true; +} + +@property --meowdown-embed-link-color { + syntax: '*'; + inherits: true; +} + +@layer meowdown-embed { + :where([data-meowdown-embed='youtube']) { + --_hairline: var( + --meowdown-embed-border-color, + color-mix(in srgb, currentColor 15%, transparent) + ); + --_muted: var(--meowdown-embed-muted-color, color-mix(in srgb, currentColor 58%, transparent)); + --_link: var(--meowdown-embed-link-color, #1d9bf0); + --_radius: var(--meowdown-embed-radius, 1rem); + --_page: var(--meowdown-embed-background, Canvas); + --_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"); + + display: block; + font: inherit; + color: var(--meowdown-embed-color, inherit); + + :where([data-root]) { + overflow-wrap: anywhere; + line-height: 1.35; + } + :where([hidden]) { + display: none !important; + } + :where(a) { + color: var(--_link); + text-decoration: none; + } + :where(a:hover) { + text-decoration: underline; + text-underline-offset: 0.15em; + } + :where(a:focus-visible, button:focus-visible) { + outline: 2px solid var(--_link); + outline-offset: 2px; + border-radius: 4px; + } + + /* The card is the poster. The article is the positioned box that the + * details overlay hangs from. */ + :where(article) { + position: relative; + max-width: 100%; + } + :where([data-orientation='portrait']) { + max-width: 22rem; + margin-inline: auto; + } + :where([data-poster], iframe) { + position: relative; + display: block; + width: 100%; + aspect-ratio: 16 / 9; + border: 0; + border-radius: var(--_radius); + overflow: hidden; + background: color-mix(in srgb, currentColor 6%, transparent); + } + :where([data-orientation='portrait'] [data-poster], [data-orientation='portrait'] iframe) { + aspect-ratio: 9 / 16; + } + :where(button[data-poster]) { + padding: 0; + font: inherit; + color: inherit; + cursor: pointer; + } + :where([data-poster] img) { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + } + /* YouTube's own large play button: the 68x48 shape from the embedded + * player, red with a white triangle. */ + :where([data-play]) { + position: absolute; + inset: 50% auto auto 50%; + width: 68px; + height: 48px; + translate: -50% -50%; + background: var(--_play) center / contain no-repeat; + } + + /* Details: absolutely positioned over the top of the poster by default. + * Variants move or restyle it. */ + :where([data-details]) { + position: absolute; + inset: 0 0 auto 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.125rem; + pointer-events: none; + } + :where([data-details] a, [data-details] span) { + pointer-events: auto; + } + :where([data-title]) { + font-size: 1.0625em; + font-weight: 700; + color: inherit; + } + :where([data-details] > a:has([data-author]), [data-author]) { + font-size: 0.875em; + color: inherit; + } + /* Fallback and pending states keep a plain bordered box. */ + :where([data-fallback]) { + border: 1px solid var(--_hairline); + border-radius: var(--_radius); + padding: var(--meowdown-embed-padding, 1rem); + } + :where([data-fallback] [data-details]) { + position: static; + } + :where([data-body]) { + margin-block: 0.5rem 0; + color: var(--_muted); + } + :where([data-footer]) { + display: block; + margin-block-start: 0.75rem; + font-size: 0.9375em; + color: var(--_muted); + } + + :where([data-details]) { + padding: 0.875rem 1rem 2.5rem; + border-radius: var(--_radius) var(--_radius) 0 0; + background: linear-gradient(rgb(0 0 0 / 0.72), rgb(0 0 0 / 0)); + color: #fff; + } + :where([data-details] > a:has([data-author]), [data-author]) { + color: rgb(255 255 255 / 0.82); + } + } +} diff --git a/packages/embed/src/youtube/youtube-video.test.tsx b/packages/embed/src/youtube/youtube-video.test.tsx new file mode 100644 index 00000000..16dadb0c --- /dev/null +++ b/packages/embed/src/youtube/youtube-video.test.tsx @@ -0,0 +1,193 @@ +import './theme.css' + +import type { YouTubeVideo } from '@post-embed/types' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { page, userEvent } from 'vitest/browser' + +import { createVideo } from './testing/fixtures.ts' + +import { registerYouTubeVideo, type YouTubeVideoElement } from './index.ts' + +beforeAll(() => { + registerYouTubeVideo() +}) + +function mount(data = createVideo()) { + const element = document.createElement('meowdown-embed-youtube') + element.dataset.testid = 'video' + element.data = data + document.body.append(element) + return element +} + +const video = page.getByTestId('video') +const watchUrl = 'https://www.youtube.com/watch?v=aqz-KE-bpKQ&t=90' + +afterEach(() => { + document.body.replaceChildren() + vi.restoreAllMocks() +}) + +describe('YouTube video', () => { + it('renders a light DOM card with watch links and no iframe', async () => { + const element = mount() + await expect + .element(video.getByRole('link', { name: 'Big Buck Bunny' })) + .toHaveAttribute('href', watchUrl) + await expect + .element(video.getByRole('link', { name: 'Blender' })) + .toHaveAttribute('href', 'https://www.youtube.com/@BlenderOfficial') + const poster = element.querySelector('[data-poster]') + expect(poster?.tagName).toBe('A') + expect(poster?.getAttribute('href')).toBe(watchUrl) + const image = poster?.querySelector('img') + expect(image?.getAttribute('referrerpolicy')).toBe('no-referrer') + await expect.poll(() => image?.naturalWidth).toBe(480) + expect(element.querySelector('iframe')).toBeNull() + expect(element.querySelector('button')).toBeNull() + expect(element.shadowRoot).toBeNull() + expect(element.querySelector('[style]')).toBeNull() + expect(element.textContent).not.toContain('aqz-KE-bpKQ&t=90') + }) + + it('loads the player only after a click in inline mode', async () => { + const element = mount() + element.playback = 'inline' + const play = video.getByRole('button', { name: 'Play: Big Buck Bunny' }) + await expect.element(play).toBeVisible() + expect(element.querySelector('iframe')).toBeNull() + await userEvent.click(play) + const frame = video.getByTitle('Big Buck Bunny') + await expect + .element(frame) + .toHaveAttribute( + 'src', + 'https://www.youtube-nocookie.com/embed/aqz-KE-bpKQ?autoplay=1&playsinline=1&start=90', + ) + expect(element.querySelector('iframe')?.getAttribute('allow')).toContain('autoplay') + expect(element.querySelector('button')).toBeNull() + await expect.poll(() => document.activeElement).toBe(element.querySelector('iframe')) + await expect.element(video.getByRole('link', { name: 'Big Buck Bunny' })).toBeVisible() + }) + + it('reads playback from the attribute', async () => { + const element = document.createElement('meowdown-embed-youtube') + element.dataset.testid = 'video' + element.setAttribute('playback', 'inline') + element.data = createVideo() + document.body.append(element) + await expect.element(video.getByRole('button', { name: 'Play: Big Buck Bunny' })).toBeVisible() + element.setAttribute('playback', 'link') + await expect + .element(video.getByRole('button', { name: 'Play: Big Buck Bunny' })) + .not.toBeInTheDocument() + await expect.element(video.getByRole('link', { name: 'Big Buck Bunny' })).toBeVisible() + }) + + it('marks Shorts as portrait', async () => { + const element = mount() + expect(element.querySelector('article')?.hasAttribute('data-orientation')).toBe(false) + // oEmbed reports the same 200x113 player size for Shorts, so the URL is + // the only signal. + element.data = createVideo('https://www.youtube.com/shorts/aqz-KE-bpKQ') + await expect + .element(video.getByRole('link', { name: 'Big Buck Bunny' })) + .toHaveAttribute('href', 'https://www.youtube.com/watch?v=aqz-KE-bpKQ') + expect(element.querySelector('article')?.getAttribute('data-orientation')).toBe('portrait') + }) + + it('renders unsafe author destinations as text', async () => { + mount({ ...createVideo(), author_url: 'javascript:alert(1)' }) + await expect.element(video.getByText('Blender')).toBeVisible() + await expect.element(video.getByRole('link', { name: 'Blender' })).not.toBeInTheDocument() + }) + + it('falls back for unsupported URLs, missing data, and invalid data', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const element = mount(createVideo('https://example.com/video')) + await expect.element(video.getByText('This video is unavailable.')).toBeVisible() + expect(element.querySelector('[data-fallback]')).not.toBeNull() + expect(element.querySelector('a')).toBeNull() + expect(error).not.toHaveBeenCalled() + element.data = createVideo() + await expect.element(video.getByRole('link', { name: 'Big Buck Bunny' })).toBeVisible() + element.data = null + await expect.element(video.getByText('This video is unavailable.')).toBeVisible() + expect(error).not.toHaveBeenCalled() + Reflect.set(element, 'data', 42) + await expect.element(video.getByText('This video is unavailable.')).toBeVisible() + expect(error).toHaveBeenCalledWith('[meowdown] Invalid YouTube video data:', expect.any(Array)) + element.data = createVideo() + await expect.element(video.getByRole('link', { name: 'Big Buck Bunny' })).toBeVisible() + }) + + it('reconnects with the latest data and preserves host children', async () => { + const element = mount() + const annotation = document.createElement('span') + annotation.textContent = 'Host annotation' + element.append(annotation) + await expect.element(video.getByRole('link', { name: 'Big Buck Bunny' })).toBeVisible() + element.remove() + element.data = { ...createVideo(), title: 'Reconnected' } + document.body.append(element) + await expect.element(video.getByRole('link', { name: 'Reconnected' })).toBeVisible() + await expect.element(video.getByText('Host annotation')).toBeVisible() + expect(element.children.length).toBe(2) + }) + + it('keeps registration idempotent and supports custom names', async () => { + const constructor = customElements.get('meowdown-embed-youtube') + registerYouTubeVideo() + expect(customElements.get('meowdown-embed-youtube')).toBe(constructor) + registerYouTubeVideo('custom-youtube-video') + const element = document.createElement('custom-youtube-video') as YouTubeVideoElement + element.dataset.testid = 'custom-video' + element.data = createVideo() + document.body.append(element) + await expect + .element(page.getByTestId('custom-video').getByRole('link', { name: 'Big Buck Bunny' })) + .toBeVisible() + await expect + .element(page.getByTestId('custom-video')) + .toHaveAttribute('data-meowdown-embed', 'youtube') + }) + + it('applies the theme and inherits custom properties', async () => { + const wrapper = document.createElement('div') + wrapper.style.setProperty('--meowdown-embed-radius', '24px') + document.body.append(wrapper) + const element = mount() + wrapper.append(element) + await expect.element(video.getByRole('link', { name: 'Big Buck Bunny' })).toBeVisible() + const poster = element.querySelector('[data-poster]') + const image = element.querySelector('[data-poster] img') + if (!poster || !image) throw new Error('Missing rendered video parts') + expect(getComputedStyle(poster).borderRadius).toBe('24px') + expect(getComputedStyle(image).objectFit).toBe('cover') + }) +}) + +describe('YouTube video fetch', () => { + it('calls `resolver` with `url` and renders the resolved snapshot', async () => { + let resolve!: () => void + const resolver = vi.fn((url: string) => { + return new Promise((r) => { + resolve = () => r(createVideo(url)) + }) + }) + const element = document.createElement('meowdown-embed-youtube') + element.dataset.testid = 'video' + element.url = watchUrl + element.resolver = resolver + document.body.append(element) + await expect.element(video.getByText('Loading this video…')).toBeVisible() + resolve() + await expect + .element(video.getByRole('link', { name: 'Big Buck Bunny' })) + .toHaveAttribute('href', watchUrl) + expect(resolver).toHaveBeenCalledWith(watchUrl) + expect(element.data).toBeNull() + element.url = null + await expect.element(video.getByText('This video is unavailable.')).toBeVisible() + }) +}) diff --git a/packages/embed/src/youtube/youtube-video.ts b/packages/embed/src/youtube/youtube-video.ts new file mode 100644 index 00000000..df4f5e57 --- /dev/null +++ b/packages/embed/src/youtube/youtube-video.ts @@ -0,0 +1,71 @@ +import { + defineCustomElement, + defineProps, + type HostElement, + type State, + useEffect as useHostEffect, +} from '@aria-ui/core' +import { parseYouTubeVideo } from '@post-embed/schema' +import type { YouTubeVideo } from '@post-embed/types' +import el from 'crelt' + +import { type FetchProps, useFetch } from '../fetch.ts' +import { getRootContainer } from '../root.ts' + +import { parseYouTubeUrl } from './parse-url.ts' +import { renderVideo } from './render-video.ts' + +export interface YouTubeVideoProps extends FetchProps { + /** + * `link` (default) opens the watch page; `inline` swaps in the YouTube + * player after a click on the poster. + */ + playback: string +} + +export interface YouTubeVideoElement extends HTMLElement, YouTubeVideoProps {} + +/** @internal */ +export function useYouTubeVideo(host: HostElement, props: State): void { + const { fetched, pending } = useFetch(host, props, 'YouTube video') + + useHostEffect(host, () => { + host.dataset.meowdownEmbed = 'youtube' + const container = getRootContainer(host) + const data = props.data.get() ?? fetched.get() + const playback = props.playback.get() === 'inline' ? 'inline' : 'link' + const result = data == null ? undefined : parseYouTubeVideo(data) + + if (result?.issues) { + console.error('[meowdown] Invalid YouTube video data:', result.issues) + } + + const ref = result && !result.issues ? parseYouTubeUrl(result.value.url) : undefined + + container.replaceChildren( + result && !result.issues && ref + ? renderVideo(result.value, ref, playback) + : renderFallback(pending.get()), + ) + }) +} + +function renderFallback(pending: boolean): HTMLElement { + return el( + 'article', + pending ? { 'data-fallback': '', 'data-pending': '' } : { '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.'), + ) +} + +export const YouTubeVideoCustomElement = defineCustomElement( + useYouTubeVideo, + defineProps({ + data: { default: null, attribute: false }, + url: { default: null, attribute: false }, + resolver: { default: null, attribute: false }, + playback: { default: 'link', attribute: 'playback', type: 'string' }, + }), +) diff --git a/packages/embed/tsconfig.json b/packages/embed/tsconfig.json new file mode 100644 index 00000000..fc88103e --- /dev/null +++ b/packages/embed/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@ocavue/tsconfig/dom/app.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "erasableSyntaxOnly": true + }, + "references": [ + { + "path": "../vitest/tsconfig.json" + } + ] +} diff --git a/packages/embed/tsdown.config.ts b/packages/embed/tsdown.config.ts new file mode 100644 index 00000000..6733a360 --- /dev/null +++ b/packages/embed/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: ['src/x/index.ts', 'src/youtube/index.ts'], + platform: 'neutral', + sourcemap: 'hidden', + minify: false, + target: 'es2022', + copy: [ + { from: 'src/x/theme.css', to: 'dist/x/' }, + { from: 'src/youtube/theme.css', to: 'dist/youtube/' }, + ], +}) diff --git a/packages/embed/vitest.browser.config.ts b/packages/embed/vitest.browser.config.ts new file mode 100644 index 00000000..1b42c443 --- /dev/null +++ b/packages/embed/vitest.browser.config.ts @@ -0,0 +1,3 @@ +import { defineBrowserConfig } from '@meowdown/vitest/config' + +export default defineBrowserConfig({ name: 'embed-browser', groupOrder: 1000 }) diff --git a/packages/embed/vitest.node.config.ts b/packages/embed/vitest.node.config.ts new file mode 100644 index 00000000..96b8a3bd --- /dev/null +++ b/packages/embed/vitest.node.config.ts @@ -0,0 +1,3 @@ +import { defineNodeConfig } from '@meowdown/vitest/config' + +export default defineNodeConfig({ name: 'embed-node', groupOrder: 200 }) diff --git a/packages/react/package.json b/packages/react/package.json index 18b891c5..083ea061 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -26,9 +26,9 @@ "dependencies": { "@base-ui/react": "^1.8.0", "@meowdown/core": "workspace:*", + "@meowdown/embed": "workspace:*", "@meowdown/markdown": "workspace:*", "@ocavue/utils": "^1.8.0", - "@post-embed/elements": "^0.6.0", "@post-embed/types": "^0.4.0", "@prosekit/core": "^0.13.2", "@prosekit/pm": "^0.1.19", diff --git a/packages/react/src/components/editor.test.tsx b/packages/react/src/components/editor.test.tsx index 343e3a00..86372b63 100644 --- a/packages/react/src/components/editor.test.tsx +++ b/packages/react/src/components/editor.test.tsx @@ -580,7 +580,7 @@ describe('X post embed props', () => { resolveXPost={() => createXPost()} />, ) - const card = pmRoot.getByTestId('x-post-embed').locate('[data-post-embed="x-post"]') + const card = pmRoot.getByTestId('x-post-embed').locate('[data-meowdown-embed="x"]') await expect.element(card).toMatchTextContent('just setting up my twttr') }) }) diff --git a/packages/react/src/components/editor.tsx b/packages/react/src/components/editor.tsx index 11c2bf77..62be2ea6 100644 --- a/packages/react/src/components/editor.tsx +++ b/packages/react/src/components/editor.tsx @@ -231,7 +231,7 @@ export interface EditorProps { /** * Resolves the data behind a YouTube video URL, directly or as a promise; - * the video renders as a `post-embed-youtube-video` card. Defaults to + * the video renders as a `meowdown-embed-youtube` card. Defaults to * `defaultResolveYouTubeVideo`, which reads YouTube's oEmbed endpoint. Pass * a stable function (e.g. from `useCallback`). */ diff --git a/packages/react/src/components/markdown-view.test.tsx b/packages/react/src/components/markdown-view.test.tsx index da56eb1e..8acd7d5c 100644 --- a/packages/react/src/components/markdown-view.test.tsx +++ b/packages/react/src/components/markdown-view.test.tsx @@ -243,7 +243,7 @@ describe('MarkdownView', () => { ) try { await renderView('![](https://x.com/jack/status/3001)') - const card = view.getByTestId('x-post-embed').locate('[data-post-embed="x-post"]') + const card = view.getByTestId('x-post-embed').locate('[data-meowdown-embed="x"]') await expect.element(card).toMatchTextContent('fetched by default') expect(fetchSpy).toHaveBeenCalledExactlyOnceWith( 'https://react-tweet.vercel.app/api/tweet/3001', @@ -271,7 +271,7 @@ describe('MarkdownView', () => { await renderView('![](https://x.com/jack/status/20)', { resolveXPost: () => createXPost(), }) - const card = view.getByTestId('x-post-embed').locate('[data-post-embed="x-post"]') + const card = view.getByTestId('x-post-embed').locate('[data-meowdown-embed="x"]') await expect.element(card).toMatchTextContent('just setting up my twttr') }) @@ -281,7 +281,7 @@ describe('MarkdownView', () => { settle = resolve }) await renderView('![](https://x.com/jack/status/20)', { resolveXPost: () => pending }) - const card = view.getByTestId('x-post-embed').locate('[data-post-embed="x-post"]') + const card = view.getByTestId('x-post-embed').locate('[data-meowdown-embed="x"]') await expect.element(card.locate('[data-fallback][data-pending]')).toBeInTheDocument() settle(createXPost()) @@ -295,7 +295,7 @@ describe('MarkdownView', () => { await renderView(`![](https://x.com/jack/status/20)${comment}`, { resolveXPost, }) - const card = view.getByTestId('x-post-embed').locate('[data-post-embed="x-post"]') + const card = view.getByTestId('x-post-embed').locate('[data-meowdown-embed="x"]') await expect.element(card).toMatchTextContent('just setting up my twttr') expect(resolveXPost).toHaveBeenCalledOnce() expect(card.locate('[data-fallback]').query()).toBeNull() @@ -306,7 +306,7 @@ describe('MarkdownView', () => { '![](https://x.com/jack/status/20)', { resolveXPost: () => createXPost() }, ) - const card = view.getByTestId('x-post-embed').locate('[data-post-embed="x-post"]') + const card = view.getByTestId('x-post-embed').locate('[data-meowdown-embed="x"]') await expect.element(card).toMatchTextContent('just setting up my twttr') }) @@ -323,7 +323,7 @@ describe('MarkdownView', () => { await renderView('![](https://youtu.be/aqz-KE-bpKQ)', { resolveYouTubeVideo: () => createYouTubeVideo(), }) - const card = view.getByTestId('youtube-video-embed').locate('[data-post-embed="youtube-video"]') + const card = view.getByTestId('youtube-video-embed').locate('[data-meowdown-embed="youtube"]') await expect.element(card).toMatchTextContent('Big Buck Bunny') expect(view.locate('iframe').query()).toBeNull() }) @@ -332,7 +332,7 @@ describe('MarkdownView', () => { await renderView('![](https://youtu.be/aqz-KE-bpKQ)', { resolveYouTubeVideo: () => createYouTubeVideo(), }) - const card = view.getByTestId('youtube-video-embed').locate('[data-post-embed="youtube-video"]') + const card = view.getByTestId('youtube-video-embed').locate('[data-meowdown-embed="youtube"]') await expect.element(card).toMatchTextContent('Big Buck Bunny') expect(getComputedStyle(card.element()).width).toBe('320px') }) diff --git a/packages/react/src/components/markdown-view.tsx b/packages/react/src/components/markdown-view.tsx index e9714f2e..7d18caaa 100644 --- a/packages/react/src/components/markdown-view.tsx +++ b/packages/react/src/components/markdown-view.tsx @@ -38,9 +38,9 @@ import { type WikilinkClickHandler, type WikilinkResolver, } from '@meowdown/core' +import { registerXPost } from '@meowdown/embed/x' +import { registerYouTubeVideo } from '@meowdown/embed/youtube' import { matchEmbed, type EmbedKind } from '@meowdown/markdown' -import { registerXPost } from '@post-embed/elements/x' -import { registerYouTubeVideo } from '@post-embed/elements/youtube' import type { DOMOutputSpec } from '@prosekit/pm/model' import { Mark, type Node as ProseMirrorNode } from '@prosekit/pm/model' import { clsx } from 'clsx/lite' @@ -155,7 +155,7 @@ export interface MarkdownViewProps { */ resolveFileInfo?: FileInfoResolver /** - * Resolve the data behind an X post URL, rendered as a `post-embed-x-post` + * Resolve the data behind an X post URL, rendered as a `meowdown-embed-x` * card. Defaults to `defaultResolveXPost`. */ resolveXPost?: XPostResolver @@ -165,7 +165,7 @@ export interface MarkdownViewProps { mediaUrlProtocols?: string[] /** * Resolve the data behind a YouTube video URL, rendered as a - * `post-embed-youtube-video` card. Defaults to `defaultResolveYouTubeVideo`. + * `meowdown-embed-youtube` card. Defaults to `defaultResolveYouTubeVideo`. */ resolveYouTubeVideo?: YouTubeVideoResolver /** @@ -327,13 +327,13 @@ function PostEmbed(props: { data-post-embed={kind} > {kind === 'x-post' - ? createElement('post-embed-x-post', { + ? createElement('meowdown-embed-x', { data: null, url: src, resolver: resolveXPost ?? defaultResolveXPost, mediaUrlProtocols: mediaUrlProtocols ?? null, }) - : createElement('post-embed-youtube-video', { + : createElement('meowdown-embed-youtube', { data: saved?.kind === 'youtube-video' ? saved.data : null, url: src, resolver: resolveYouTubeVideo, diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index 3f210275..6d96b72b 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -19,6 +19,9 @@ { "path": "../core/tsconfig.json" }, + { + "path": "../embed/tsconfig.json" + }, { "path": "../markdown/tsconfig.json" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index beb732ac..e74e28ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,15 +78,15 @@ importers: '@lezer/highlight': specifier: ^1.2.3 version: 1.2.3 + '@meowdown/embed': + specifier: workspace:^ + version: link:../embed '@meowdown/markdown': specifier: workspace:^ version: link:../markdown '@ocavue/utils': specifier: ^1.8.0 version: 1.8.0 - '@post-embed/elements': - specifier: ^0.6.0 - version: 0.6.0(typescript@6.0.3) '@post-embed/exporter': specifier: ^0.4.1 version: 0.4.1(typescript@6.0.3) @@ -188,6 +188,40 @@ importers: specifier: ^5.0.1 version: 5.0.1(@types/node@24.13.5)(@vitest/browser-playwright@5.0.1)(@vitest/coverage-v8@5.0.1)(@vitest/ui@5.0.1)(vite@8.3.0) + packages/embed: + dependencies: + '@aria-ui/core': + specifier: ^0.2.1 + version: 0.2.1 + '@post-embed/schema': + specifier: ^0.5.0 + version: 0.5.0(typescript@6.0.3) + '@post-embed/types': + specifier: ^0.4.0 + version: 0.4.0 + crelt: + specifier: ^1.0.7 + version: 1.0.7 + devDependencies: + '@meowdown/vitest': + specifier: workspace:* + version: link:../vitest + '@ocavue/tsconfig': + specifier: ^0.7.1 + version: 0.7.1 + '@ocavue/utils': + specifier: ^1.8.0 + version: 1.8.0 + '@vitest/browser-playwright': + specifier: ^5.0.1 + version: 5.0.1(playwright@1.63.0)(vite@8.3.0)(vitest@5.0.1) + tsdown: + specifier: ^0.23.0 + version: 0.23.0(@tsdown/css@0.23.0)(oxc-resolver@11.24.2)(typescript@6.0.3) + vitest: + specifier: ^5.0.1 + version: 5.0.1(@types/node@24.13.5)(@vitest/browser-playwright@5.0.1)(@vitest/coverage-v8@5.0.1)(@vitest/ui@5.0.1)(vite@8.3.0) + packages/eslint-rules: dependencies: '@typescript-eslint/utils': @@ -243,15 +277,15 @@ importers: '@meowdown/core': specifier: workspace:* version: link:../core + '@meowdown/embed': + specifier: workspace:* + version: link:../embed '@meowdown/markdown': specifier: workspace:* version: link:../markdown '@ocavue/utils': specifier: ^1.8.0 version: 1.8.0 - '@post-embed/elements': - specifier: ^0.6.0 - version: 0.6.0(typescript@6.0.3) '@post-embed/types': specifier: ^0.4.0 version: 0.4.0 @@ -376,6 +410,9 @@ importers: '@meowdown/core': specifier: workspace:* version: link:../packages/core + '@meowdown/embed': + specifier: workspace:* + version: link:../packages/embed '@meowdown/react': specifier: workspace:* version: link:../packages/react @@ -419,6 +456,9 @@ importers: '@ocavue/tsconfig': specifier: ^0.7.1 version: 0.7.1 + '@post-embed/types': + specifier: ^0.4.0 + version: 0.4.0 '@tailwindcss/vite': specifier: ^4.3.3 version: 4.3.3(vite@8.3.0) @@ -2124,9 +2164,6 @@ packages: '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} - '@post-embed/elements@0.6.0': - resolution: {integrity: sha512-Ar0kDGP+cGTuV0vCPsm5SBTwfHrbxBbNxspXUzR8/0d7QACoKAuP0QoJqXm0xgNvnxJggfeDg3K4I+br+IP8rA==} - '@post-embed/exporter@0.4.1': resolution: {integrity: sha512-4c6Y2p0CnjgWkiMQQWtfFwbdQJInguJyZmzyb/2dps3ugJFr1b9IW/zcNJ2KIEZCQO/+ZA/kvADL/9ODEMuPlg==} @@ -7014,15 +7051,6 @@ snapshots: '@poppinss/exception@1.2.3': {} - '@post-embed/elements@0.6.0(typescript@6.0.3)': - dependencies: - '@aria-ui/core': 0.2.1 - '@post-embed/schema': 0.5.0(typescript@6.0.3) - '@post-embed/types': 0.4.0 - crelt: 1.0.7 - transitivePeerDependencies: - - typescript - '@post-embed/exporter@0.4.1(typescript@6.0.3)': dependencies: '@ocavue/utils': 1.8.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fc20d939..8ce74418 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -46,4 +46,3 @@ allowBuilds: # '@post-embed/types': https://pkg.pr.new/@post-embed/types@6d2125d2b99494dc02e98eaf0878f765e62bd2a2 # '@post-embed/schema': https://pkg.pr.new/@post-embed/schema@6d2125d2b99494dc02e98eaf0878f765e62bd2a2 # '@post-embed/exporter': https://pkg.pr.new/@post-embed/exporter@6d2125d2b99494dc02e98eaf0878f765e62bd2a2 -# '@post-embed/elements': https://pkg.pr.new/@post-embed/elements@6d2125d2b99494dc02e98eaf0878f765e62bd2a2 diff --git a/tsconfig.json b/tsconfig.json index 7b8e0c6c..c0b8fd31 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,9 @@ { "path": "./packages/core/tsconfig.json" }, + { + "path": "./packages/embed/tsconfig.json" + }, { "path": "./packages/eslint-rules/tsconfig.json" }, diff --git a/website/package.json b/website/package.json index b54ec07d..d56a2ba2 100644 --- a/website/package.json +++ b/website/package.json @@ -19,6 +19,7 @@ "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.43.12", "@meowdown/core": "workspace:*", + "@meowdown/embed": "workspace:*", "@meowdown/react": "workspace:*", "@ocavue/utils": "^1.8.0", "astro": "^7.3.3", @@ -35,6 +36,7 @@ "@iconify-json/lucide": "^1.2.133", "@iconify-json/simple-icons": "^1.2.96", "@ocavue/tsconfig": "^0.7.1", + "@post-embed/types": "^0.4.0", "@tailwindcss/vite": "^4.3.3", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", diff --git a/website/public/embed/media/motion.mp4 b/website/public/embed/media/motion.mp4 new file mode 100644 index 00000000..6b6e0d9b Binary files /dev/null and b/website/public/embed/media/motion.mp4 differ diff --git a/website/public/embed/media/photo-1.svg b/website/public/embed/media/photo-1.svg new file mode 100644 index 00000000..5bc0d4db --- /dev/null +++ b/website/public/embed/media/photo-1.svg @@ -0,0 +1 @@ +Post Embed · Image 1 diff --git a/website/public/embed/media/photo-2.svg b/website/public/embed/media/photo-2.svg new file mode 100644 index 00000000..0fd955c0 --- /dev/null +++ b/website/public/embed/media/photo-2.svg @@ -0,0 +1 @@ +Post Embed · Image 2 diff --git a/website/public/embed/media/photo-3.svg b/website/public/embed/media/photo-3.svg new file mode 100644 index 00000000..0fe76ed9 --- /dev/null +++ b/website/public/embed/media/photo-3.svg @@ -0,0 +1 @@ +Post Embed · Image 3 diff --git a/website/public/embed/media/photo-4.svg b/website/public/embed/media/photo-4.svg new file mode 100644 index 00000000..225ae880 --- /dev/null +++ b/website/public/embed/media/photo-4.svg @@ -0,0 +1 @@ +Post Embed · Image 4 diff --git a/website/public/embed/media/poster.svg b/website/public/embed/media/poster.svg new file mode 100644 index 00000000..dc84855a --- /dev/null +++ b/website/public/embed/media/poster.svg @@ -0,0 +1 @@ + diff --git a/website/public/embed/samples/avatar-a.svg b/website/public/embed/samples/avatar-a.svg new file mode 100644 index 00000000..dee85bdf --- /dev/null +++ b/website/public/embed/samples/avatar-a.svg @@ -0,0 +1 @@ +M \ No newline at end of file diff --git a/website/public/embed/samples/avatar-b.svg b/website/public/embed/samples/avatar-b.svg new file mode 100644 index 00000000..963d0966 --- /dev/null +++ b/website/public/embed/samples/avatar-b.svg @@ -0,0 +1 @@ +T \ No newline at end of file diff --git a/website/public/embed/samples/avatar-c.svg b/website/public/embed/samples/avatar-c.svg new file mode 100644 index 00000000..76685423 --- /dev/null +++ b/website/public/embed/samples/avatar-c.svg @@ -0,0 +1 @@ +K \ No newline at end of file diff --git a/website/public/embed/samples/avatar-d.svg b/website/public/embed/samples/avatar-d.svg new file mode 100644 index 00000000..0619c432 --- /dev/null +++ b/website/public/embed/samples/avatar-d.svg @@ -0,0 +1 @@ +D \ No newline at end of file diff --git a/website/public/embed/samples/chart.svg b/website/public/embed/samples/chart.svg new file mode 100644 index 00000000..5e75c052 --- /dev/null +++ b/website/public/embed/samples/chart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/public/embed/samples/landscape-1.svg b/website/public/embed/samples/landscape-1.svg new file mode 100644 index 00000000..88036724 --- /dev/null +++ b/website/public/embed/samples/landscape-1.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/website/public/embed/samples/landscape-2.svg b/website/public/embed/samples/landscape-2.svg new file mode 100644 index 00000000..19e4c1ab --- /dev/null +++ b/website/public/embed/samples/landscape-2.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/website/public/embed/samples/landscape-3.svg b/website/public/embed/samples/landscape-3.svg new file mode 100644 index 00000000..b305d5ae --- /dev/null +++ b/website/public/embed/samples/landscape-3.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/website/public/embed/samples/landscape-4.svg b/website/public/embed/samples/landscape-4.svg new file mode 100644 index 00000000..867a611d --- /dev/null +++ b/website/public/embed/samples/landscape-4.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/website/public/embed/samples/phone.svg b/website/public/embed/samples/phone.svg new file mode 100644 index 00000000..eda96097 --- /dev/null +++ b/website/public/embed/samples/phone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/public/embed/samples/portrait-1.svg b/website/public/embed/samples/portrait-1.svg new file mode 100644 index 00000000..6dfaf56a --- /dev/null +++ b/website/public/embed/samples/portrait-1.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/website/public/embed/samples/portrait-video.svg b/website/public/embed/samples/portrait-video.svg new file mode 100644 index 00000000..76dfbe14 --- /dev/null +++ b/website/public/embed/samples/portrait-video.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/website/public/embed/samples/square-1.svg b/website/public/embed/samples/square-1.svg new file mode 100644 index 00000000..57000c00 --- /dev/null +++ b/website/public/embed/samples/square-1.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/website/public/embed/samples/strip.svg b/website/public/embed/samples/strip.svg new file mode 100644 index 00000000..03c08ae9 --- /dev/null +++ b/website/public/embed/samples/strip.svg @@ -0,0 +1 @@ +$ pnpm test ✓ 412 passed (38 files, 6.21s) \ No newline at end of file diff --git a/website/src/stories/embed-gallery.tsx b/website/src/stories/embed-gallery.tsx new file mode 100644 index 00000000..3c1729a7 --- /dev/null +++ b/website/src/stories/embed-gallery.tsx @@ -0,0 +1,114 @@ +import './stories.css' + +import { registerXPost } from '@meowdown/embed/x' +import { registerYouTubeVideo } from '@meowdown/embed/youtube' +import { createElement, type ReactElement, type ReactNode } from 'react' + +import { useMounted } from '../lib/use-mounted.ts' + +import { createSnapshot, type Snapshot } from './embed-samples/snapshots.ts' +import { createXSamples } from './embed-samples/x-samples.ts' +import { createYouTubeSnapshot } from './embed-samples/youtube-snapshots.ts' + +export interface EmbedGalleryProps { + kind: 'x' | 'youtube' +} + +interface GalleryItem { + name: string + card: ReactElement +} + +// The shapes the samples do not cover. The very long post goes last so it +// does not push the rest away. +const X_EDGE_CASES = [ + 'plain', + 'links', + 'rtl', + 'empty', + 'mixed-media', + 'unavailable', + 'broken-media', + 'square-avatar', + 'edited', + 'stale-edit', + 'long', +] as const satisfies Snapshot[] + +// The X card sizes itself; the editor wraps it in an inline-block span. +function getXItems(): GalleryItem[] { + // Registration must precede the element so React sets `data` as a property. + registerXPost() + const posts = [ + ...createXSamples(window.location.origin), + ...X_EDGE_CASES.map((name) => ({ name, post: createSnapshot(name) })), + ] + return posts.map(({ name, post }) => ({ + name, + card: ( + + {createElement('meowdown-embed-x', { data: post })} + + ), + })) +} + +const YOUTUBE_SNAPSHOTS = [ + 'basic', + 'long-title', + 'short', + 'start-time', + 'no-author', + 'missing', +] as const + +// The video card fills its container; the editor gives it this default width. +const YOUTUBE_STYLE = { display: 'block', maxWidth: 550 } + +function getYouTubeItems(): GalleryItem[] { + registerYouTubeVideo() + return [ + ...YOUTUBE_SNAPSHOTS.map((name) => ({ + name, + card: createElement('meowdown-embed-youtube', { + data: createYouTubeSnapshot(name), + style: YOUTUBE_STYLE, + }), + })), + { + name: 'inline playback', + card: createElement('meowdown-embed-youtube', { + data: createYouTubeSnapshot('basic'), + playback: 'inline', + style: YOUTUBE_STYLE, + }), + }, + ] +} + +function GalleryFigure({ name, children }: { name: string; children: ReactNode }) { + return ( +
+
+ {name} +
+ {children} +
+ ) +} + +export function EmbedGallery({ kind }: EmbedGalleryProps) { + // The samples read `window.location.origin`, so they cannot prerender. + const mounted = useMounted() + if (!mounted) return null + const items = kind === 'x' ? getXItems() : getYouTubeItems() + return ( +
+ {items.map((item) => ( + + {item.card} + + ))} +
+ ) +} diff --git a/website/src/stories/embed-samples/media-snapshots.ts b/website/src/stories/embed-samples/media-snapshots.ts new file mode 100644 index 00000000..6837d617 --- /dev/null +++ b/website/src/stories/embed-samples/media-snapshots.ts @@ -0,0 +1,49 @@ +import type { XPost, XPostMedia } from '@post-embed/types' + +export function addMediaSnapshot(post: XPost, name: string): void { + const asset = (file: string) => { + return new URL(`/embed/media/${file}`, window.location.origin).href + } + post.author.avatar = asset('photo-1.svg') + const photo = (index: number): XPostMedia => ({ + type: 'photo', + url: asset(`photo-${index}.svg`), + width: 640, + height: 400, + alt: `Illustrated landscape ${index}`, + }) + const video = (gif = false): XPostMedia => ({ + type: gif ? 'gif' : 'video', + poster: asset('photo-1.svg'), + width: 640, + height: 400, + sources: [{ type: 'video/mp4', bitrate: 256000, url: asset('motion.mp4') }], + }) + if ( + ['photo', 'two-photos', 'three-photos', 'four-photos', 'unavailable', 'broken-media'].includes( + name, + ) + ) { + const count = + name === 'two-photos' ? 2 : name === 'three-photos' ? 3 : name === 'four-photos' ? 4 : 1 + post.media = Array.from({ length: count }, (_, i) => photo(i + 1)) + if (name === 'unavailable') post.media[0].unavailable = true + if (name === 'broken-media' && post.media[0].type === 'photo') + post.media[0].url = asset('missing.svg') + } + if (name === 'video' || name === 'gif') post.media = [video(name === 'gif')] + if (name === 'mixed-media') post.media = [photo(1), video(), photo(2)] + if (name === 'quote') { + post.quote = { + ...structuredClone(post), + id: '987654321', + body: [{ type: 'text', text: 'A quoted post with a picture.' }], + media: [photo(2)], + } + } + if (name === 'reply') post.replyTo = { handle: 'example', id: '987654321' } + if (name === 'square-avatar') post.author.avatarShape = 'square' + if (name === 'edited') post.edit = 'edited' + if (name === 'stale-edit') post.edit = 'stale' + if (name === 'truncated') post.truncated = true +} diff --git a/website/src/stories/embed-samples/snapshots.ts b/website/src/stories/embed-samples/snapshots.ts new file mode 100644 index 00000000..656f6ee9 --- /dev/null +++ b/website/src/stories/embed-samples/snapshots.ts @@ -0,0 +1,66 @@ +import type { XPost } from '@post-embed/types' + +import { addMediaSnapshot } from './media-snapshots.ts' + +function createPost(text = 'Hello 😀\nA saved post.'): XPost { + return { + id: '1234567890123456789', + createdAt: '2026-09-10T00:00:00.000Z', + lang: 'en', + author: { + name: 'Example Author', + handle: 'example', + avatar: 'https://example.com/avatar.jpg', + }, + body: text ? [{ type: 'text', text }] : [], + } +} + +export type Snapshot = + | 'plain' + | 'links' + | 'long' + | 'rtl' + | 'empty' + | 'missing' + | 'photo' + | 'two-photos' + | 'three-photos' + | 'four-photos' + | 'video' + | 'gif' + | 'mixed-media' + | 'unavailable' + | 'broken-media' + | 'quote' + | 'reply' + | 'square-avatar' + | 'edited' + | 'stale-edit' + | 'truncated' + +export function createSnapshot(name: Snapshot): XPost | null { + if (name === 'missing') return null + const texts = { + plain: 'Hello 😀 中文!\nTwo lines, two spaces, and a saved snapshot.', + long: ('A long paragraph with 中文 and emoji 😀. '.repeat(20) + '\n\n').repeat(10), + rtl: 'مرحبا بالعالم\nنص عربي مع English و 😀', + empty: '', + } + const post = createPost( + Object.hasOwn(texts, name) ? texts[name as keyof typeof texts] : `Snapshot: ${name}`, + ) + addMediaSnapshot(post, name) + if (name === 'rtl') post.lang = 'ar' + if (name === 'links') { + post.body = [ + { type: 'text', text: '😀 ' }, + { type: 'link', text: '@example', url: 'https://x.com/example' }, + { type: 'text', text: ' ' }, + { type: 'link', text: '#Astro', url: 'https://x.com/hashtag/Astro' }, + { type: 'text', text: ' ' }, + { type: 'link', text: 'astro.build', url: 'https://astro.build/' }, + ] + } + return post +} diff --git a/website/src/stories/embed-samples/x-samples.ts b/website/src/stories/embed-samples/x-samples.ts new file mode 100644 index 00000000..6fb42077 --- /dev/null +++ b/website/src/stories/embed-samples/x-samples.ts @@ -0,0 +1,197 @@ +import type { XPost, XPostAuthor, XPostMedia } from '@post-embed/types' + +export interface XSample { + name: string + post: XPost | null +} + +export function createXSamples(origin: string): XSample[] { + const asset = (file: string) => new URL(`/embed/samples/${file}`, origin).href + const mira: XPostAuthor = { + name: 'Mira Okafor', + handle: 'miraokafor', + avatar: asset('avatar-a.svg'), + } + const trail: XPostAuthor = { + name: 'Trail Notes', + handle: 'trailnotes', + avatar: asset('avatar-b.svg'), + } + const kai: XPostAuthor = { + name: 'Kai Lindqvist', + handle: 'kai_builds', + avatar: asset('avatar-c.svg'), + } + const docs: XPostAuthor = { + name: 'Docs Team', + handle: 'docsteam', + avatar: asset('avatar-d.svg'), + avatarShape: 'square', + } + const photo = (file: string, width: number, height: number, alt: string): XPostMedia => ({ + type: 'photo', + url: asset(file), + width, + height, + alt, + }) + const video = (poster: string, width: number, height: number, gif = false): XPostMedia => ({ + type: gif ? 'gif' : 'video', + poster: asset(poster), + width, + height, + sources: [ + { + type: 'video/mp4', + bitrate: 256000, + url: new URL('/embed/media/motion.mp4', origin).href, + }, + ], + }) + let id = 1000 + const post = (author: XPostAuthor, text: string, extra: Partial = {}): XPost => ({ + id: String(++id), + createdAt: `2026-09-${String(10 + (id % 9)).padStart(2, '0')}T0${id % 9}:2${id % 7}:00.000Z`, + lang: 'en', + author, + body: [{ type: 'text', text }], + ...extra, + }) + + return [ + { + name: 'Short reply', + post: post(kai, 'Interesting perspective', { + replyTo: { handle: 'miraokafor', id: '999' }, + }), + }, + { + name: 'Landscape video', + post: post( + mira, + 'Damn, the new vector model is 🔥\n\nIt is remarkably good at generating detailed SVGs, and it is very fun to watch it draw.\n\nI have been giving it random products and watching the model turn them into vector graphics 👇', + { media: [video('landscape-1.svg', 1600, 900)] }, + ), + }, + { + name: 'Long text with links', + post: { + ...post(kai, ''), + body: [ + { + type: 'text', + text: 'Introducing our first robot foundation model, zero-shot generalizing to any robot: table-top arms, industrial arms and humanoids.\n- learned directly from human manipulation data\n- no teleop or robot data\n- close to human-level dexterity and efficiency\n- multi-robot collab\n\nPaper and weights: ', + }, + { + type: 'link', + text: 'example.com/om-1', + url: 'https://example.com/om-1', + }, + { type: 'text', text: ' ' }, + { + type: 'link', + text: '#robotics', + url: 'https://x.com/hashtag/robotics', + }, + ], + }, + }, + { + name: 'Landscape photo', + post: post(trail, 'Same trail, ten minutes after sunrise 🌅', { + media: [photo('landscape-2.svg', 1600, 900, 'Green hills by day')], + }), + }, + { + name: 'Portrait photo', + post: post(trail, 'Blue hour from the ridge.', { + media: [photo('portrait-1.svg', 900, 1200, 'Hills at dusk')], + }), + }, + { + name: 'Tall screenshot', + post: post(kai, 'The new inbox ships today. Every row is keyboard reachable.', { + media: [photo('phone.svg', 1170, 2532, 'A phone screenshot')], + }), + }, + { + name: 'Wide strip', + post: post(docs, 'your CI output clearly says it passed', { + replyTo: { handle: 'kai_builds', id: '998' }, + media: [photo('strip.svg', 1056, 76, 'A terminal line')], + }), + }, + { + name: 'Portrait video', + post: post(mira, 'Morning fog rolling over the valley.', { + media: [video('portrait-video.svg', 1080, 1920)], + }), + }, + { + name: 'Two photos', + post: post(trail, 'Two frames from this morning. Same trail.', { + media: [ + photo('landscape-1.svg', 1600, 900, 'Hills at dawn'), + photo('landscape-3.svg', 1600, 900, 'Hills at dusk'), + ], + }), + }, + { + name: 'Three photos', + post: post(trail, 'Dawn, noon, dusk.', { + media: [ + photo('portrait-1.svg', 900, 1200, 'Hills at dusk'), + photo('landscape-2.svg', 1600, 900, 'Hills by day'), + photo('landscape-1.svg', 1600, 900, 'Hills at dawn'), + ], + }), + }, + { + name: 'Four photos', + post: post(trail, 'Four seasons on one ridge.', { + media: [ + photo('landscape-1.svg', 1600, 900, 'Dawn'), + photo('landscape-2.svg', 1600, 900, 'Day'), + photo('landscape-3.svg', 1600, 900, 'Dusk'), + photo('landscape-4.svg', 1600, 900, 'Mint'), + ], + }), + }, + { + name: 'Photo + quote with photo', + post: post(docs, 'This is exactly the approach we wanted for the docs.', { + edit: 'edited', + media: [photo('chart.svg', 1600, 1000, 'A bar chart')], + quote: post( + mira, + 'Shipped a new version: paste saved post data into a custom element and get selectable text, with no requests at render time. It also works offline.', + { media: [photo('square-1.svg', 1200, 1200, 'Hills by day')] }, + ), + }), + }, + { + name: 'Text quote', + post: post(kai, 'Worth reading twice.', { + quote: post( + mira, + 'Most performance work is deleting things. The fastest code is the code that never runs.', + ), + }), + }, + { + name: 'GIF', + post: post(mira, 'me watching the deploy go green', { + media: [video('square-1.svg', 1200, 1200, true)], + }), + }, + { + name: 'Truncated', + post: post( + kai, + 'A long note about how we rebuilt the sync engine. We started with a simple last-writer-wins register and quickly found out that it was not enough for collaborative lists, so we moved to a sequence CRDT and then spent three months making it small', + { truncated: true }, + ), + }, + { name: 'Unavailable', post: null }, + ] +} diff --git a/website/src/stories/embed-samples/youtube-snapshots.ts b/website/src/stories/embed-samples/youtube-snapshots.ts new file mode 100644 index 00000000..6705e25a --- /dev/null +++ b/website/src/stories/embed-samples/youtube-snapshots.ts @@ -0,0 +1,36 @@ +import type { YouTubeVideo } from '@post-embed/types' + +export type YouTubeSnapshot = + | 'basic' + | 'long-title' + | 'short' + | 'start-time' + | 'no-author' + | 'missing' + +export function createYouTubeSnapshot(name: YouTubeSnapshot): YouTubeVideo | null { + if (name === 'missing') return null + const video: YouTubeVideo = { + url: 'https://www.youtube.com/watch?v=aqz-KE-bpKQ', + title: 'Big Buck Bunny 60fps 4K - Official Blender Foundation Short Film', + author_name: 'Blender', + author_url: 'https://www.youtube.com/@BlenderOfficial', + thumbnail_url: new URL('/embed/media/poster.svg', window.location.origin).href, + thumbnail_width: 480, + thumbnail_height: 360, + width: 200, + height: 113, + } + if (name === 'long-title') { + video.title = `${video.title} `.repeat(4).trim() + } + if (name === 'short') video.url = 'https://www.youtube.com/shorts/aqz-KE-bpKQ' + if (name === 'start-time') { + video.url = 'https://youtu.be/aqz-KE-bpKQ?t=1m30s' + } + if (name === 'no-author') { + video.author_name = '' + video.author_url = '' + } + return video +} diff --git a/website/src/stories/embed.stories.ts b/website/src/stories/embed.stories.ts new file mode 100644 index 00000000..8eb93411 --- /dev/null +++ b/website/src/stories/embed.stories.ts @@ -0,0 +1,9 @@ +import { EmbedGallery, type EmbedGalleryProps } from './embed-gallery.tsx' + +export default { + component: EmbedGallery, +} + +export const X = { args: { kind: 'x' } satisfies EmbedGalleryProps } + +export const YouTube = { args: { kind: 'youtube' } satisfies EmbedGalleryProps } diff --git a/website/tsconfig.json b/website/tsconfig.json index 497fc706..2455386b 100644 --- a/website/tsconfig.json +++ b/website/tsconfig.json @@ -10,6 +10,9 @@ { "path": "../packages/core/tsconfig.json" }, + { + "path": "../packages/embed/tsconfig.json" + }, { "path": "../packages/react/tsconfig.json" }