Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/meowdown-embed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@meowdown/core': patch
'@meowdown/react': patch
---

Render X post and YouTube video cards with `@meowdown/embed`: the elements are now `<meowdown-embed-x>` and `<meowdown-embed-youtube>`, themed by `--meowdown-embed-*` variables.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions packages/core/src/extensions/image.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/extensions/post-embed-view.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)'
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/extensions/post-embed.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
14 changes: 7 additions & 7 deletions packages/core/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/* ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}

Expand Down
3 changes: 3 additions & 0 deletions packages/core/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
"erasableSyntaxOnly": true
},
"references": [
{
"path": "../embed/tsconfig.json"
},
{
"path": "../markdown/tsconfig.json"
},
Expand Down
70 changes: 70 additions & 0 deletions packages/embed/README.md
Original file line number Diff line number Diff line change
@@ -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.
51 changes: 51 additions & 0 deletions packages/embed/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
99 changes: 99 additions & 0 deletions packages/embed/src/fetch.ts
Original file line number Diff line number Diff line change
@@ -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<T> = (url: string) => T | undefined | PromiseLike<T | undefined>

export interface FetchProps<T> {
/**
* 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<T> | null
}

/** @internal */
export interface FetchState<T> {
/**
* The fetched snapshot, or `null` before, during, and after a failed fetch.
*/
fetched: Signal<T | null>
pending: Signal<boolean>
}

/**
* 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<T>(
host: HostElement,
props: State<FetchProps<T>>,
label: string,
): FetchState<T> {
const fetched = createSignal<T | null>(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<Resolver<T>>
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<T>(value: T | PromiseLike<T>): value is PromiseLike<T> {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as PromiseLike<T>).then === 'function'
)
}
8 changes: 8 additions & 0 deletions packages/embed/src/render-link.ts
Original file line number Diff line number Diff line change
@@ -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
}
16 changes: 16 additions & 0 deletions packages/embed/src/root.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { HostElement } from '@aria-ui/core'

/**
* The direct `<div data-root>` 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<HTMLDivElement>(':scope > div[data-root]')
if (!container) {
container = host.ownerDocument.createElement('div')
container.dataset.root = ''
host.append(container)
}
return container
}
Loading
Loading