-
Notifications
You must be signed in to change notification settings - Fork 19
feat: add oEmbed support #1243
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
feat: add oEmbed support #1243
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| /** oEmbed response as defined by https://oembed.com */ | ||
| export interface OEmbedResponse { | ||
| type: 'photo' | 'video' | 'link' | 'rich'; | ||
| version: string; | ||
| title?: string; | ||
| author_name?: string; | ||
| author_url?: string; | ||
| provider_name?: string; | ||
| provider_url?: string; | ||
| cache_age?: number | string; | ||
| thumbnail_url?: string; | ||
| thumbnail_width?: number; | ||
| thumbnail_height?: number; | ||
| width?: number; | ||
| height?: number; | ||
| /** HTML markup for video/rich types */ | ||
| html?: string; | ||
| /** Direct URL for photo type */ | ||
| url?: string; | ||
| } | ||
|
|
||
| /** A registered oEmbed provider: [hostnames[], endpointBase] */ | ||
| export type Provider = [patterns: string[], endpoint: string]; | ||
|
|
||
| const PROVIDERS: Provider[] = [ | ||
| [['www.youtube.com', 'youtube.com', 'youtu.be'], 'https://www.youtube.com/oembed'], | ||
| [['vimeo.com', 'player.vimeo.com'], 'https://vimeo.com/api/oembed.json'], | ||
| [['twitter.com', 'x.com', 'mobile.twitter.com'], 'https://publish.twitter.com/oembed'], | ||
| [['soundcloud.com'], 'https://soundcloud.com/oembed'], | ||
| [['www.instagram.com', 'instagram.com'], 'https://api.instagram.com/oembed/'], | ||
| [['www.flickr.com', 'flickr.com', 'flic.kr'], 'https://www.flickr.com/services/oembed/'], | ||
| [['open.spotify.com'], 'https://open.spotify.com/oembed'], | ||
| [['www.reddit.com', 'reddit.com'], 'https://www.reddit.com/oembed'], | ||
| [['www.tiktok.com', 'tiktok.com', 'vm.tiktok.com'], 'https://www.tiktok.com/oembed'], | ||
| [['codepen.io'], 'https://codepen.io/api/oembed'], | ||
| [['www.dailymotion.com', 'dailymotion.com'], 'https://www.dailymotion.com/services/oembed'], | ||
| [['giphy.com', 'gph.is', 'media.giphy.com'], 'https://giphy.com/services/oembed'], | ||
| [['www.slideshare.net', 'slideshare.net'], 'https://www.slideshare.net/api/oembed/2'], | ||
| [['speakerdeck.com'], 'https://speakerdeck.com/oembed.json'], | ||
| [['www.mixcloud.com', 'mixcloud.com'], 'https://www.mixcloud.com/oembed/'], | ||
| [['www.deviantart.com', 'deviantart.com', 'fav.me', 'sta.sh'], 'https://backend.deviantart.com/oembed'], | ||
| [['www.ted.com', 'ted.com'], 'https://www.ted.com/services/v1/oembed.json'], | ||
| [['www.tumblr.com', 'tumblr.com'], 'https://www.tumblr.com/oembed/1.0'], | ||
| [['www.kickstarter.com', 'kickstarter.com', 'kck.st'], 'https://www.kickstarter.com/services/oembed'], | ||
| [['www.meetup.com', 'meetup.com'], 'https://api.meetup.com/oembed'], | ||
| [['500px.com'], 'https://api.500px.com/v1/photos/oembed'], | ||
| [['www.twitch.tv', 'twitch.tv', 'clips.twitch.tv'], 'https://www.twitch.tv/oembed'], | ||
| [['music.apple.com'], 'https://music.apple.com/oembed'], | ||
| [['www.pinterest.com', 'pinterest.com', 'pin.it'], 'https://www.pinterest.com/oembed.json'], | ||
| [['www.deezer.com', 'deezer.com'], 'https://api.deezer.com/oembed'], | ||
| [['bandcamp.com'], 'https://bandcamp.com/oembed'], | ||
| [['www.wistia.com', 'wistia.com', 'wi.st', 'fast.wistia.net'], 'https://fast.wistia.com/oembed.json'], | ||
| [['www.loom.com', 'loom.com'], 'https://www.loom.com/v1/oembed'], | ||
| [['www.canva.com', 'canva.com'], 'https://www.canva.com/api/oembed'], | ||
| ]; | ||
|
|
||
| const WELL_KNOWN_PATHS = ['/oembed', '/oembed.json', '/api/oembed', '/services/oembed']; | ||
|
|
||
| /** | ||
| * Returns the oEmbed JSON endpoint URL for a given page URL, or `null` if no | ||
| * registered provider matches the hostname. | ||
| */ | ||
| export function getOEmbedEndpoint(url: string): string | null { | ||
| let hostname: string; | ||
| try { | ||
| hostname = new URL(url).hostname; | ||
| } catch { | ||
| return null; | ||
| } | ||
| for (const [patterns, endpoint] of PROVIDERS) { | ||
| if (patterns.includes(hostname)) { | ||
| return `${endpoint}?url=${encodeURIComponent(url)}&format=json`; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * Fetches oEmbed metadata for the given page URL. | ||
| * | ||
| * If no registered provider matches, probes well-known oEmbed paths on the | ||
| * same origin (`/oembed`, `/oembed.json`, `/api/oembed`, `/services/oembed`). | ||
| * Throws if every attempt fails or is blocked by CORS. | ||
| */ | ||
| export async function fetchOEmbed(url: string, signal?: AbortSignal): Promise<OEmbedResponse> { | ||
| const knownEndpoint = getOEmbedEndpoint(url); | ||
| if (knownEndpoint) { | ||
| const response = await fetch(knownEndpoint, {signal}); | ||
| if (!response.ok) throw new Error(`oEmbed request failed with status ${response.status}`); | ||
| return response.json() as Promise<OEmbedResponse>; | ||
| } | ||
|
|
||
| // Probe well-known paths on the provider's own origin | ||
| let origin: string; | ||
| try { | ||
| origin = new URL(url).origin; | ||
| } catch { | ||
| throw new Error(`Invalid URL: ${url}`); | ||
| } | ||
| for (const path of WELL_KNOWN_PATHS) { | ||
| const probeUrl = `${origin}${path}?url=${encodeURIComponent(url)}&format=json`; | ||
| try { | ||
| const response = await fetch(probeUrl, {signal}); | ||
| if (response.ok) return response.json() as Promise<OEmbedResponse>; | ||
| } catch { | ||
| // CORS or network error – try next path | ||
| } | ||
| } | ||
| throw new Error(`No oEmbed provider found for URL: ${url}`); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,193 @@ | ||
| import * as React from 'react'; | ||
| import {BlockProps} from '../..'; | ||
| import {fetchOEmbed, OEmbedResponse} from './fetchOEmbed'; | ||
|
|
||
| export {fetchOEmbed, getOEmbedEndpoint} from './fetchOEmbed'; | ||
| export type {OEmbedResponse, Provider} from './fetchOEmbed'; | ||
|
|
||
| export interface OEmbedBlockOptions { | ||
| /** | ||
| * When `true`, the raw HTML provided by the oEmbed provider (e.g. an | ||
| * `<iframe>`) is injected via `dangerouslySetInnerHTML`. Defaults to | ||
| * `false` – a safe info-card is rendered instead. | ||
| */ | ||
| renderHtml?: boolean; | ||
| } | ||
|
|
||
| const cardStyle: React.CSSProperties = { | ||
| display: 'flex', | ||
| flexDirection: 'column', | ||
| maxWidth: '100%', | ||
| overflow: 'hidden', | ||
| borderRadius: '8px', | ||
| border: '1px solid #E5E9F2', | ||
| fontFamily: 'sans-serif', | ||
| background: '#fff', | ||
| }; | ||
|
|
||
| const cardStyleDark: React.CSSProperties = { | ||
| ...cardStyle, | ||
| border: '1px solid #2e3440', | ||
| background: '#2e3440', | ||
| color: '#eceff4', | ||
| }; | ||
|
|
||
| const thumbnailStyle: React.CSSProperties = { | ||
| display: 'block', | ||
| width: '100%', | ||
| objectFit: 'cover', | ||
| maxHeight: '240px', | ||
| }; | ||
|
|
||
| const bodyStyle: React.CSSProperties = { | ||
| padding: '12px 16px', | ||
| }; | ||
|
|
||
| const providerStyle: React.CSSProperties = { | ||
| fontSize: '11px', | ||
| fontWeight: 600, | ||
| textTransform: 'uppercase', | ||
| letterSpacing: '0.05em', | ||
| color: '#8a9ab0', | ||
| marginBottom: '4px', | ||
| }; | ||
|
|
||
| const titleStyle: React.CSSProperties = { | ||
| fontSize: '15px', | ||
| fontWeight: 600, | ||
| margin: '0 0 4px', | ||
| lineHeight: 1.4, | ||
| }; | ||
|
|
||
| const authorStyle: React.CSSProperties = { | ||
| fontSize: '12px', | ||
| color: '#8a9ab0', | ||
| margin: 0, | ||
| }; | ||
|
|
||
| const anchorStyle: React.CSSProperties = { | ||
| color: 'inherit', | ||
| textDecoration: 'none', | ||
| }; | ||
|
|
||
| function OEmbedCard({data, isDark, url}: {data: OEmbedResponse; isDark: boolean; url: string}) { | ||
| const style = isDark ? cardStyleDark : cardStyle; | ||
| const photoSrc = data.url || data.thumbnail_url; | ||
|
|
||
| const thumbnail = | ||
| data.type === 'photo' ? ( | ||
| photoSrc ? <img src={photoSrc} alt={data.title || ''} style={thumbnailStyle} /> : null | ||
| ) : data.thumbnail_url ? ( | ||
| <img src={data.thumbnail_url} alt={data.title || ''} style={thumbnailStyle} /> | ||
| ) : null; | ||
|
|
||
| return ( | ||
| <div style={style}> | ||
| {thumbnail} | ||
| <div style={bodyStyle}> | ||
| {data.provider_name && ( | ||
| <p style={providerStyle}> | ||
| {data.provider_url ? ( | ||
| <a href={data.provider_url} target="_blank" rel="noopener noreferrer" style={anchorStyle}> | ||
| {data.provider_name} | ||
| </a> | ||
| ) : ( | ||
| data.provider_name | ||
| )} | ||
| </p> | ||
| )} | ||
| {data.title && ( | ||
| <p style={titleStyle}> | ||
| <a href={url} target="_blank" rel="noopener noreferrer" style={anchorStyle}> | ||
| {data.title} | ||
| </a> | ||
| </p> | ||
|
Comment on lines
+100
to
+104
|
||
| )} | ||
| {data.author_name && ( | ||
| <p style={authorStyle}> | ||
| {data.author_url ? ( | ||
| <a href={data.author_url} target="_blank" rel="noopener noreferrer" style={anchorStyle}> | ||
| {data.author_name} | ||
| </a> | ||
| ) : ( | ||
| data.author_name | ||
| )} | ||
| </p> | ||
| )} | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| interface OEmbedBlockState { | ||
| data: OEmbedResponse | null; | ||
| loading: boolean; | ||
| error: Error | null; | ||
| } | ||
|
|
||
| class OEmbedBlock extends React.PureComponent<BlockProps & {renderHtml: boolean}, OEmbedBlockState> { | ||
| state: OEmbedBlockState = {data: null, loading: true, error: null}; | ||
| controller = new AbortController(); | ||
|
|
||
| fetchData() { | ||
| const {signal} = this.controller; | ||
| fetchOEmbed(this.props.url, signal) | ||
| .then((data) => { | ||
| if (!signal.aborted) this.setState({data, loading: false, error: null}); | ||
| }) | ||
| .catch((error) => { | ||
| if (!signal.aborted) this.setState({data: null, error, loading: false}); | ||
| }); | ||
| } | ||
|
|
||
| componentDidMount() { | ||
| this.fetchData(); | ||
| } | ||
|
|
||
| componentDidUpdate(prevProps: BlockProps & {renderHtml: boolean}) { | ||
| if (prevProps.url !== this.props.url) { | ||
| this.controller.abort(); | ||
| this.controller = new AbortController(); | ||
| this.setState({data: null, loading: true, error: null}, () => this.fetchData()); | ||
| } | ||
| } | ||
|
|
||
| componentWillUnmount() { | ||
| this.controller.abort(); | ||
| } | ||
|
|
||
| render() { | ||
| const {renderWrap, renderVoid, renderHtml, isDark, url} = this.props; | ||
| const {data, loading, error} = this.state; | ||
|
|
||
| if (loading) return renderWrap(null); | ||
| if (error || !data) return renderVoid(error || undefined); | ||
|
|
||
| if (renderHtml && data.html) { | ||
| // Opt-in: inject the provider's own HTML (e.g. an <iframe>). Caller accepts XSS risk. | ||
| return renderWrap(<div dangerouslySetInnerHTML={{__html: data.html}} />); | ||
| } | ||
|
Comment on lines
+166
to
+169
|
||
|
|
||
| return renderWrap(<OEmbedCard data={data} isDark={isDark} url={url} />); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Creates an oEmbed block component. | ||
| * | ||
| * ```tsx | ||
| * // Default – renders a safe info-card | ||
| * <ReactEmbed url="https://www.flickr.com/photos/bees/2362225867/" /> | ||
| * | ||
| * // Opt-in HTML rendering (injects the provider's <iframe>; caller accepts XSS risk) | ||
| * const blocks = { ...defaultBlocks, oembed: createOEmbedBlock({ renderHtml: true }) }; | ||
| * <ReactEmbed url="https://www.flickr.com/photos/bees/2362225867/" blocks={blocks} /> | ||
| * ``` | ||
| */ | ||
| export function createOEmbedBlock(options: OEmbedBlockOptions = {}): React.ComponentType<BlockProps> { | ||
| const {renderHtml = false} = options; | ||
| const Block: React.FC<BlockProps> = (props) => <OEmbedBlock {...props} renderHtml={renderHtml} />; | ||
| return Block; | ||
| } | ||
|
|
||
| export default createOEmbedBlock(); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| import * as React from 'react'; | ||
| import Embed from '..'; | ||
| import {createOEmbedBlock} from '../blocks/oembed'; | ||
| import {Box} from './Box'; | ||
|
|
||
| const urls = [ | ||
|
|
@@ -124,3 +125,35 @@ export const Wrapper = { | |
| ), | ||
| }, | ||
| }; | ||
|
|
||
| const oEmbedUrls = [ | ||
| 'https://www.flickr.com/photos/bees/2362225867/', | ||
| 'https://open.spotify.com/track/11dFghVXANMlKmJXsNCbNl', | ||
| 'https://codepen.io/anon/pen/YzPXoeb', | ||
| 'https://speakerdeck.com/holman/git-and-github-secrets', | ||
| ]; | ||
|
|
||
| export const OEmbedCard = { | ||
| args: { | ||
| url: oEmbedUrls[0], | ||
| }, | ||
| argTypes: { | ||
| url: { | ||
| options: oEmbedUrls, | ||
| control: {type: 'select'}, | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| export const OEmbedHtml = { | ||
| args: { | ||
| url: oEmbedUrls[0], | ||
| blocks: {oembed: createOEmbedBlock({renderHtml: true})}, | ||
| }, | ||
|
Comment on lines
+136
to
+152
|
||
| argTypes: { | ||
| url: { | ||
| options: oEmbedUrls, | ||
| control: {type: 'select'}, | ||
| }, | ||
| }, | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Provider detection uses regexes against the full URL string (e.g.
/youtube\.com/), which can false-positive on unrelated URLs that merely contain that substring (path/query) and may leak arbitrary URLs to third-party oEmbed endpoints. Parse withnew URL(url)and match againsthostname(or anchor the regex to the domain) to ensure only real provider domains are matched.