Skip to content
Open
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
1 change: 1 addition & 0 deletions src/ReactEmbed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const defaultBlocks: Blocks = {
imgur: React.lazy(() => import('./blocks/imgur')),
instagram: React.lazy(() => import('./blocks/instagram')),
jsfiddle: React.lazy(() => import('./blocks/jsfiddle')),
oembed: React.lazy(() => import('./blocks/oembed')),
pdf: React.lazy(() => import('./blocks/pdf')),
reactPlayer: React.lazy(() => import('./blocks/react-player')),
replit: React.lazy(() => import('./blocks/replit')),
Expand Down
110 changes: 110 additions & 0 deletions src/blocks/oembed/fetchOEmbed.ts
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;
Comment on lines +63 to +75
Copy link

Copilot AI Apr 20, 2026

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 with new URL(url) and match against hostname (or anchor the regex to the domain) to ensure only real provider domains are matched.

Copilot uses AI. Check for mistakes.
}

/**
* 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}`);
}
193 changes: 193 additions & 0 deletions src/blocks/oembed/index.tsx
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
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The title link uses data.author_url, which (per oEmbed) is the author/profile URL, not the resource URL. This will send users to the author page when clicking the embed title. Consider linking the title to the original props.url (or data.url for photo type) and keep author_url only for the author line.

Copilot uses AI. Check for mistakes.
)}
{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
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dangerouslySetInnerHTML injects provider-supplied HTML without any sanitization. Even if this is opt-in, it creates an easy XSS footgun when consumers embed user-provided URLs. Consider adding a sanitization/validation hook (e.g. option to transform/sanitize data.html) and/or additional guardrails/documentation in the API to make the risk explicit.

Copilot uses AI. Check for mistakes.

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();
2 changes: 2 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import * as React from 'react';
import {ReactEmbedProps} from './ReactEmbed';

export * from './ReactEmbed';
export {fetchOEmbed, getOEmbedEndpoint, createOEmbedBlock} from './blocks/oembed';
export type {OEmbedResponse, OEmbedBlockOptions, Provider} from './blocks/oembed';

const Resource = React.lazy(() => import('./ReactEmbed') as any);

Expand Down
3 changes: 3 additions & 0 deletions src/routeToBlock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {Blocks, ReactEmbedRouter, ParsedUrl} from '.';
import canPlaySimplePlayer from './blocks/react-simple-player/canPlay';
import canPlay from './blocks/react-player/canPlay';
import canPlayPdf from './blocks/pdf/canPlay';
import {getOEmbedEndpoint} from './blocks/oembed/fetchOEmbed';

const routeTwitter: ReactEmbedRouter = (blocks, {pathname}) => {
const steps = pathname.split('/');
Expand Down Expand Up @@ -102,6 +103,8 @@ const routeToBlock: ReactEmbedRouter = (blocks: Blocks, parsed: ParsedUrl) => {
return [blocks.simplePlayer, ''];
} else if (canPlay(url)) {
return [blocks.reactPlayer, ''];
} else if (getOEmbedEndpoint(url)) {
return [blocks.oembed, ''];
} else {
return undefined;
}
Expand Down
33 changes: 33 additions & 0 deletions src/stories/ReactEmbed.stories.tsx
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 = [
Expand Down Expand Up @@ -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
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The OEmbedCard/OEmbedHtml stories default to oEmbedUrls[0] which is a Vimeo URL. routeToBlock routes Vimeo to reactPlayer via canPlay(url) before the oEmbed fallback, so these stories may not actually exercise the new oEmbed block by default. Consider defaulting to a URL that routes to oembed (e.g. Spotify/Flickr/CodePen) or explicitly overriding routing for the story.

Copilot uses AI. Check for mistakes.
argTypes: {
url: {
options: oEmbedUrls,
control: {type: 'select'},
},
},
};