feat(desktop): inline embed detection + module primitives

Pure, synchronous URL→descriptor matchers for YouTube, Vimeo, Instagram,
Pinterest, TikTok, X, Spotify, Google Maps and OpenStreetMap, plus the shared
embed primitives (error boundary, fail card, escape-html, dark-mode hook,
sizing token). Declares the mermaid + dompurify deps used by the fenced
renderers.
This commit is contained in:
Brooklyn Nicholson 2026-06-26 03:21:59 -05:00
parent 7d568293f9
commit 81ac562bf0
19 changed files with 666 additions and 0 deletions

View file

@ -81,11 +81,13 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dnd-core": "^14.0.1",
"dompurify": "^3.4.11",
"hast-util-from-html-isomorphic": "^2.0.0",
"hast-util-to-text": "^4.0.2",
"ignore": "^7.0.5",
"katex": "^0.16.45",
"leva": "^0.10.1",
"mermaid": "^11.15.0",
"motion": "^12.38.0",
"nanostores": "^1.3.0",
"node-pty": "1.1.0",

View file

@ -0,0 +1,4 @@
// Shared height cap for inline embeds. Ratio embeds cap their width off this in
// UrlEmbed so height follows the aspect ratio; fenced renderers (mermaid, svg)
// reuse it directly. Pure CSS — no measuring.
export const EMBED_MAX_H = '33dvh'

View file

@ -0,0 +1,3 @@
export function escapeHtml(value: string): string {
return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}

View file

@ -0,0 +1,7 @@
export function EmbedFail({ label }: { label: string }) {
return (
<span className="grid min-h-32 w-full place-items-center p-4">
<span className="text-xs font-medium text-(--ui-red)">Failed to load {label} embed</span>
</span>
)
}

View file

@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest'
import type { FrameEmbed, TweetEmbed } from './types'
import { detectEmbed, isEmbeddableUrl } from './index'
function frame(url: string): FrameEmbed {
const descriptor = detectEmbed(url)
if (!descriptor || descriptor.renderer !== 'frame') {
throw new Error(`expected a frame embed for ${url}`)
}
return descriptor
}
describe('detectEmbed — YouTube', () => {
it.each([
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
'https://youtu.be/dQw4w9WgXcQ',
'https://www.youtube.com/shorts/dQw4w9WgXcQ',
'https://m.youtube.com/watch?v=dQw4w9WgXcQ',
'https://www.youtube.com/embed/dQw4w9WgXcQ',
'https://www.youtube.com/live/dQw4w9WgXcQ'
])('resolves %s to the privacy-enhanced embed of the same id', url => {
const embed = frame(url)
expect(embed.provider).toBe('youtube')
expect(embed.id).toBe('youtube:dQw4w9WgXcQ')
expect(embed.embedUrl).toContain('youtube-nocookie.com/embed/dQw4w9WgXcQ')
})
it('carries a start time from t/start through to the embed', () => {
expect(frame('https://youtu.be/dQw4w9WgXcQ?t=90').embedUrl).toContain('start=90')
expect(frame('https://youtu.be/dQw4w9WgXcQ?t=1m30s').embedUrl).toContain('start=90')
})
it('rejects ids that are not 11 chars', () => {
expect(detectEmbed('https://www.youtube.com/watch?v=short')).toBeNull()
})
})
describe('detectEmbed — other frame providers', () => {
it('resolves Vimeo numeric ids across path shapes', () => {
expect(frame('https://vimeo.com/76979871').embedUrl).toBe('https://player.vimeo.com/video/76979871')
expect(frame('https://vimeo.com/channels/staffpicks/76979871').id).toBe('vimeo:76979871')
})
it('resolves Instagram posts and reels', () => {
expect(frame('https://www.instagram.com/p/CabcDEF123/').embedUrl).toBe(
'https://www.instagram.com/p/CabcDEF123/embed'
)
expect(frame('https://www.instagram.com/reel/CabcDEF123/').embedUrl).toContain('/reel/CabcDEF123/embed')
expect(frame('https://www.instagram.com/reels/CabcDEF123/').embedUrl).toContain('/reel/CabcDEF123/embed')
})
it('resolves Pinterest pins across locale hosts', () => {
expect(frame('https://www.pinterest.com/pin/1234567890/').embedUrl).toBe(
'https://assets.pinterest.com/ext/embed.html?id=1234567890'
)
expect(frame('https://fr.pinterest.com/pin/1234567890/').provider).toBe('pinterest')
})
it('resolves TikTok videos to the official player', () => {
expect(frame('https://www.tiktok.com/@user/video/7212345678901234567').embedUrl).toBe(
'https://www.tiktok.com/player/v1/7212345678901234567'
)
})
it('resolves Spotify tracks, collections, and locale-prefixed urls', () => {
expect(frame('https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT').embedUrl).toBe(
'https://open.spotify.com/embed/track/4cOdK2wGLETKBW3PvgPWqT'
)
expect(frame('https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M').provider).toBe('spotify')
expect(frame('https://open.spotify.com/intl-de/album/1DFixLWuPkv3KT3TnV35m3').id).toBe(
'spotify:album:1DFixLWuPkv3KT3TnV35m3'
)
expect(detectEmbed('https://open.spotify.com/track/')).toBeNull()
})
})
describe('detectEmbed — maps', () => {
it('resolves Google Maps coordinates with zoom', () => {
const embed = frame('https://www.google.com/maps/@40.7128,-74.0060,12z')
expect(embed.provider).toBe('googlemaps')
expect(embed.embedUrl).toContain('output=embed')
expect(embed.embedUrl).toContain('q=40.7128%2C-74.006')
expect(embed.embedUrl).toContain('z=12')
})
it('resolves a Google Maps place name', () => {
expect(frame('https://www.google.com/maps/place/Eiffel+Tower/').embedUrl).toContain('q=Eiffel+Tower')
})
it('resolves OpenStreetMap fragment state to a bbox embed', () => {
const embed = frame('https://www.openstreetmap.org/#map=12/40.7128/-74.0060')
expect(embed.provider).toBe('openstreetmap')
expect(embed.embedUrl).toContain('export/embed.html')
expect(embed.embedUrl).toContain('marker=40.7128%2C-74.006')
expect(embed.embedUrl).toContain('bbox=')
})
})
describe('detectEmbed — Twitter/X', () => {
it('resolves twitter.com and x.com status urls to a tweet descriptor', () => {
for (const url of ['https://twitter.com/jack/status/20', 'https://x.com/jack/status/20']) {
const descriptor = detectEmbed(url)
expect(descriptor?.renderer).toBe('tweet')
expect((descriptor as TweetEmbed).tweetId).toBe('20')
}
})
})
describe('detectEmbed — non-matches', () => {
it.each([
'https://example.com/watch?v=dQw4w9WgXcQ',
'https://github.com/NousResearch/hermes',
'not-a-url',
'ftp://youtube.com/watch?v=dQw4w9WgXcQ',
'mailto:someone@youtube.com'
])('returns null for %s', url => {
expect(detectEmbed(url)).toBeNull()
expect(isEmbeddableUrl(url)).toBe(false)
})
it('handles empty input without throwing', () => {
expect(detectEmbed(undefined)).toBeNull()
expect(detectEmbed('')).toBeNull()
})
})

View file

@ -0,0 +1,54 @@
import { instagram } from './instagram'
import { maps } from './maps'
import { pinterest } from './pinterest'
import { spotify } from './spotify'
import { tiktok } from './tiktok'
import { twitter } from './twitter'
import type { EmbedDescriptor, EmbedMatcher } from './types'
import { vimeo } from './vimeo'
import { youtube } from './youtube'
export type { EmbedDescriptor, EmbedProvider, EmbedRenderer, FrameEmbed, TweetEmbed } from './types'
// All provider hosts are disjoint, so order is irrelevant — first match wins.
const MATCHERS: EmbedMatcher[] = [youtube, vimeo, instagram, pinterest, tiktok, twitter, spotify, maps]
function parseUrl(raw: string): URL | null {
try {
const url = new URL(raw)
return url.protocol === 'http:' || url.protocol === 'https:' ? url : null
} catch {
return null
}
}
/**
* Resolve a URL to a rich-embed descriptor, or null when no provider matches.
* Pure and synchronous safe to call during render.
*/
export function detectEmbed(rawUrl: string | null | undefined): EmbedDescriptor | null {
if (!rawUrl) {
return null
}
const url = parseUrl(rawUrl)
if (!url) {
return null
}
for (const match of MATCHERS) {
const descriptor = match(url)
if (descriptor) {
return descriptor
}
}
return null
}
export function isEmbeddableUrl(rawUrl: string | null | undefined): boolean {
return detectEmbed(rawUrl) !== null
}

View file

@ -0,0 +1,26 @@
import { bareHost, type EmbedMatcher } from './types'
export const instagram: EmbedMatcher = url => {
if (bareHost(url.hostname) !== 'instagram.com') {
return null
}
const [typeRaw, code] = url.pathname.split('/').filter(Boolean)
const type = typeRaw === 'reels' ? 'reel' : typeRaw
if (!code || !['p', 'reel', 'tv'].includes(type || '') || !/^[A-Za-z0-9_-]+$/.test(code)) {
return null
}
return {
embedUrl: `https://www.instagram.com/${type}/${code}/embed`,
// Placeholder height for content-visibility; embed.js self-sizes in-document.
height: 450,
id: `instagram:${code}`,
label: 'Instagram',
maxWidth: 400,
provider: 'instagram',
renderer: 'frame',
sourceUrl: url.toString()
}
}

View file

@ -0,0 +1,95 @@
import { bareHost, type EmbedMatcher, type FrameEmbed } from './types'
// `@lat,lng` (optionally `,<zoom>z`) as it appears in Google Maps URLs.
const LATLNG_RE = /@(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)(?:,(\d+(?:\.\d+)?)z)?/
function googleMapsEmbed(url: URL): FrameEmbed | null {
const host = bareHost(url.hostname)
if (host !== 'google.com' && host !== 'maps.google.com' && !host.startsWith('google.')) {
return null
}
const isMapsPath = host.startsWith('maps.') || url.pathname.startsWith('/maps')
if (!isMapsPath) {
return null
}
// Prefer explicit coordinates; then a `q=` query; then a `/place/<name>`.
const coords = url.pathname.match(LATLNG_RE)
const placeName = url.pathname.match(/\/place\/([^/@]+)/)
const query = url.searchParams.get('q') || url.searchParams.get('query')
let q = ''
let zoom = ''
if (coords) {
q = `${coords[1]},${coords[2]}`
zoom = coords[3] ? String(Math.round(Number(coords[3]))) : ''
} else if (query) {
q = query
} else if (placeName) {
q = decodeURIComponent(placeName[1].replace(/\+/g, ' '))
}
if (!q) {
return null
}
// `output=embed` is the long-standing keyless Maps embed surface.
const params = new URLSearchParams({ output: 'embed', q })
if (zoom) {
params.set('z', zoom)
}
return {
aspectRatio: 16 / 10,
embedUrl: `https://maps.google.com/maps?${params.toString()}`,
id: `googlemaps:${q}${zoom ? `@${zoom}` : ''}`,
label: 'Google Maps',
maxWidth: 640,
provider: 'googlemaps',
renderer: 'frame',
sourceUrl: url.toString()
}
}
function openStreetMapEmbed(url: URL): FrameEmbed | null {
if (bareHost(url.hostname) !== 'openstreetmap.org') {
return null
}
// State lives in the fragment: `#map=<zoom>/<lat>/<lng>`.
const match = url.hash.match(/map=(\d+(?:\.\d+)?)\/(-?\d+(?:\.\d+)?)\/(-?\d+(?:\.\d+)?)/)
if (!match) {
return null
}
const zoom = Number(match[1])
const lat = Number(match[2])
const lng = Number(match[3])
// Degrees spanned at this zoom; halved for the bbox half-extent.
const lonDelta = 360 / 2 ** zoom
const latDelta = lonDelta / 2
const bbox = [lng - lonDelta / 2, lat - latDelta / 2, lng + lonDelta / 2, lat + latDelta / 2]
.map(value => value.toFixed(5))
.join(',')
const params = new URLSearchParams({ bbox, layer: 'mapnik', marker: `${lat},${lng}` })
return {
aspectRatio: 16 / 10,
embedUrl: `https://www.openstreetmap.org/export/embed.html?${params.toString()}`,
id: `openstreetmap:${lat},${lng}@${zoom}`,
label: 'OpenStreetMap',
maxWidth: 640,
provider: 'openstreetmap',
renderer: 'frame',
sourceUrl: url.toString()
}
}
export const maps: EmbedMatcher = url => googleMapsEmbed(url) || openStreetMapEmbed(url)

View file

@ -0,0 +1,28 @@
import { bareHost, type EmbedMatcher } from './types'
export const pinterest: EmbedMatcher = url => {
// Pinterest runs many locale TLDs (pinterest.co.uk, fr.pinterest.com, ...).
if (!bareHost(url.hostname).includes('pinterest.')) {
return null
}
const segments = url.pathname.split('/').filter(Boolean)
if (segments[0] !== 'pin' || !/^\d+$/.test(segments[1] || '')) {
return null
}
const id = segments[1]
return {
embedUrl: `https://assets.pinterest.com/ext/embed.html?id=${id}`,
// Pinterest's "small" pin size — the default card is too dominant inline.
height: 380,
id: `pinterest:${id}`,
label: 'Pinterest',
maxWidth: 236,
provider: 'pinterest',
renderer: 'frame',
sourceUrl: url.toString()
}
}

View file

@ -0,0 +1,34 @@
import { bareHost, type EmbedMatcher } from './types'
// Spotify's embed has only two layouts: compact (≤152) and full (352). Any
// in-between height renders the compact player and pads the rest with grey, so
// we snap to the compact size for every type — tight, no dead space.
const COMPACT_HEIGHT = 152
const EMBED_TYPES = new Set(['album', 'artist', 'episode', 'playlist', 'show', 'track'])
export const spotify: EmbedMatcher = url => {
if (bareHost(url.hostname) !== 'open.spotify.com') {
return null
}
// Drop an optional locale prefix (`/intl-de/track/...`).
const segments = url.pathname.split('/').filter(Boolean)
const start = segments[0]?.startsWith('intl-') ? 1 : 0
const type = segments[start] || ''
const id = segments[start + 1] || ''
if (!EMBED_TYPES.has(type) || !/^[A-Za-z0-9]+$/.test(id)) {
return null
}
return {
embedUrl: `https://open.spotify.com/embed/${type}/${id}`,
height: COMPACT_HEIGHT,
id: `spotify:${type}:${id}`,
label: 'Spotify',
maxWidth: 480,
provider: 'spotify',
renderer: 'frame',
sourceUrl: url.toString()
}
}

View file

@ -0,0 +1,28 @@
import { bareHost, type EmbedMatcher } from './types'
export const tiktok: EmbedMatcher = url => {
if (bareHost(url.hostname) !== 'tiktok.com') {
return null
}
const segments = url.pathname.split('/').filter(Boolean)
const videoIndex = segments.indexOf('video')
const id = videoIndex >= 0 ? segments[videoIndex + 1] : ''
if (!/^\d+$/.test(id || '')) {
return null
}
return {
// The official player is a clean dark video iframe (no white blockquote
// chrome), so it goes through the plain-iframe frame path, sized 9:16.
aspectRatio: 9 / 16,
embedUrl: `https://www.tiktok.com/player/v1/${id}`,
id: `tiktok:${id}`,
label: 'TikTok',
maxWidth: 365,
provider: 'tiktok',
renderer: 'frame',
sourceUrl: url.toString()
}
}

View file

@ -0,0 +1,27 @@
import { bareHost, type EmbedMatcher } from './types'
export const twitter: EmbedMatcher = url => {
const host = bareHost(url.hostname)
if (host !== 'twitter.com' && host !== 'x.com') {
return null
}
const segments = url.pathname.split('/').filter(Boolean)
const statusIndex = segments.indexOf('status')
const id = statusIndex >= 0 ? segments[statusIndex + 1] : ''
if (!/^\d+$/.test(id || '')) {
return null
}
return {
id: `twitter:${id}`,
label: 'X',
maxWidth: 480,
provider: 'twitter',
renderer: 'tweet',
sourceUrl: url.toString(),
tweetId: id
}
}

View file

@ -0,0 +1,60 @@
// Embed provider model. Detection is pure, synchronous, and dependency-free so
// it is safe to run during render and trivial to unit-test. Rendering lives in
// the lazy renderers (see ../registry.tsx) keyed off `renderer`.
export type EmbedProvider =
| 'googlemaps'
| 'instagram'
| 'openstreetmap'
| 'pinterest'
| 'spotify'
| 'tiktok'
| 'twitter'
| 'vimeo'
| 'youtube'
/** Which lazy renderer materialises the descriptor. */
export type EmbedRenderer = 'frame' | 'tweet'
interface EmbedLayout {
/** Frame aspect ratio (width / height). For video/maps. */
aspectRatio?: number
/** Fixed pixel height for non-ratio embeds (Instagram, Pinterest, Spotify). */
height?: number
/** Max rendered width in px; falls back to the conversation column. */
maxWidth?: number
}
interface BaseEmbed extends EmbedLayout {
/** Stable id for React keys / dedupe. */
id: string
/** Human-facing provider name (e.g. "YouTube"). */
label: string
provider: EmbedProvider
renderer: EmbedRenderer
/** Canonical URL opened in the system browser from the card. */
sourceUrl: string
}
/** A provider whose embed is a single iframe URL (video, post, map, ...). */
export interface FrameEmbed extends BaseEmbed {
/** URL loaded inside the iframe. */
embedUrl: string
renderer: 'frame'
}
/** Twitter/X ships no iframe URL — only a widget script (see social-embed.tsx). */
export interface TweetEmbed extends BaseEmbed {
renderer: 'tweet'
tweetId: string
}
export type EmbedDescriptor = FrameEmbed | TweetEmbed
/** A provider matcher. Receives a parsed http(s) URL; returns null if unmatched. */
export type EmbedMatcher = (url: URL) => EmbedDescriptor | null
/** Strip a leading `www.`/`m.`/`mobile.` so host checks read cleanly. */
export function bareHost(host: string): string {
return host.replace(/^(?:www|m|mobile)\./i, '').toLowerCase()
}

View file

@ -0,0 +1,32 @@
import { bareHost, type EmbedMatcher } from './types'
export const vimeo: EmbedMatcher = url => {
const host = bareHost(url.hostname)
if (host !== 'vimeo.com' && host !== 'player.vimeo.com') {
return null
}
// The clip id is the last all-digits segment, covering vimeo.com/123,
// /channels/x/123, /groups/x/videos/123, and player/video/123.
const id = url.pathname
.split('/')
.filter(Boolean)
.reverse()
.find(segment => /^\d+$/.test(segment))
if (!id) {
return null
}
return {
aspectRatio: 16 / 9,
embedUrl: `https://player.vimeo.com/video/${id}`,
id: `vimeo:${id}`,
label: 'Vimeo',
maxWidth: 640,
provider: 'vimeo',
renderer: 'frame',
sourceUrl: url.toString()
}
}

View file

@ -0,0 +1,65 @@
import { bareHost, type EmbedMatcher } from './types'
const YOUTUBE_ID_RE = /^[A-Za-z0-9_-]{11}$/
// `t`/`start` accept either raw seconds ("90") or the "1m30s" form.
function startSeconds(value: string | null): number | undefined {
if (!value) {
return undefined
}
if (/^\d+$/.test(value)) {
return Number(value)
}
const match = value.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/)
if (!match || !match[0]) {
return undefined
}
const seconds = Number(match[1] || 0) * 3600 + Number(match[2] || 0) * 60 + Number(match[3] || 0)
return seconds > 0 ? seconds : undefined
}
export const youtube: EmbedMatcher = url => {
const host = bareHost(url.hostname)
const segments = url.pathname.split('/').filter(Boolean)
let id = ''
if (host === 'youtu.be') {
id = segments[0] || ''
} else if (host === 'youtube.com' || host === 'youtube-nocookie.com') {
if (segments[0] === 'watch') {
id = url.searchParams.get('v') || ''
} else if (['embed', 'shorts', 'live', 'v'].includes(segments[0] || '')) {
id = segments[1] || ''
}
} else {
return null
}
if (!YOUTUBE_ID_RE.test(id)) {
return null
}
const params = new URLSearchParams({ modestbranding: '1', rel: '0' })
const start = startSeconds(url.searchParams.get('t') || url.searchParams.get('start'))
if (start) {
params.set('start', String(start))
}
return {
aspectRatio: 16 / 9,
embedUrl: `https://www.youtube-nocookie.com/embed/${id}?${params.toString()}`,
id: `youtube:${id}`,
label: 'YouTube',
maxWidth: 640,
provider: 'youtube',
renderer: 'frame',
sourceUrl: url.toString()
}
}

View file

@ -0,0 +1,34 @@
import { Component, type ReactNode } from 'react'
interface Props {
children: ReactNode
/** Rendered in place of the subtree when a render throws. */
fallback: ReactNode
/** Changing this clears a caught error (e.g. new source for a re-parse). */
resetKey?: string
}
/**
* Local boundary for rich renderers (Mermaid parse throws, malformed SVG, a
* provider widget blowing up). A failed embed must never blank the transcript
* we show the `fallback` (typically the raw source) and recover when `resetKey`
* changes. Unlike MessageRenderBoundary this swallows ALL render errors, because
* the blast radius is one self-contained block, not the message tree.
*/
export class RichBoundary extends Component<Props, { failed: boolean }> {
state = { failed: false }
static getDerivedStateFromError() {
return { failed: true }
}
componentDidUpdate(prev: Props) {
if (this.state.failed && prev.resetKey !== this.props.resetKey) {
this.setState({ failed: false })
}
}
render() {
return this.state.failed ? this.props.fallback : this.props.children
}
}

View file

@ -0,0 +1,8 @@
// Shared prop contract for fenced-block renderers (mermaid, svg). Kept in its
// own module so renderers and the registry can both import it without a cycle.
export interface RichFenceProps {
code: string
/** True while the surrounding message is still streaming. Renderers that can
* throw on partial input (e.g. mermaid) defer until this is false. */
streaming?: boolean
}

View file

@ -0,0 +1,24 @@
import { useEffect, useState } from 'react'
// Tracks the app's dark/light mode off the `dark` class on <html> (set by
// themes/context.tsx). Embeds that theme their own content (tweets) read this.
export function useIsDark(): boolean {
const [dark, setDark] = useState(
() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
)
useEffect(() => {
if (typeof document === 'undefined') {
return
}
const root = document.documentElement
const observer = new MutationObserver(() => setDark(root.classList.contains('dark')))
observer.observe(root, { attributeFilter: ['class'], attributes: true })
return () => observer.disconnect()
}, [])
return dark
}

2
package-lock.json generated
View file

@ -95,11 +95,13 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dnd-core": "^14.0.1",
"dompurify": "^3.4.11",
"hast-util-from-html-isomorphic": "^2.0.0",
"hast-util-to-text": "^4.0.2",
"ignore": "^7.0.5",
"katex": "^0.16.45",
"leva": "^0.10.1",
"mermaid": "^11.15.0",
"motion": "^12.38.0",
"nanostores": "^1.3.0",
"node-pty": "1.1.0",