Skip to content

feat: add oEmbed support#1243

Open
Copilot wants to merge 3 commits intomasterfrom
copilot/add-oembed-support
Open

feat: add oEmbed support#1243
Copilot wants to merge 3 commits intomasterfrom
copilot/add-oembed-support

Conversation

Copy link
Copy Markdown

Copilot AI commented Apr 20, 2026

Implements the oEmbed open standard to render embeds for providers not handled by dedicated blocks (Flickr, Reddit, TikTok, CodePen, Dailymotion, Giphy, Spotify, Slideshare, Speakerdeck, etc.) and as a richer fallback path.

New: src/blocks/oembed/

fetchOEmbed.ts — pure fetch utility, no React dependency:

  • getOEmbedEndpoint(url): string | null — maps a page URL to its provider's JSON endpoint; matching is done against the parsed hostname (no false positives from substrings in paths/queries)
  • fetchOEmbed(url, signal?): Promise<OEmbedResponse> — fetches typed oEmbed metadata; accepts AbortSignal
  • 29 registered providers via Provider 2-tuple ([hostnames: string[], endpoint: string]): YouTube, Vimeo, Twitter/X, SoundCloud, Instagram, Flickr, Spotify, Reddit, TikTok, CodePen, Dailymotion, Giphy, Slideshare, Speakerdeck, Mixcloud, DeviantArt, TED, Tumblr, Kickstarter, Meetup, 500px, Twitch, Apple Music, Pinterest, Deezer, Bandcamp, Wistia, Loom, Canva
  • Well-known path probing for unregistered providers — when no provider matches, fetchOEmbed sequentially probes {origin}/oembed, /oembed.json, /api/oembed, /services/oembed before giving up

index.tsx — React block:

  • Default render: safe info-card (thumbnail, provider, title linked to the original URL, author) — no raw HTML injected
  • Opt-in HTML: createOEmbedBlock({ renderHtml: true }) injects the provider's html field via dangerouslySetInnerHTML (caller accepts XSS risk)
  • Uses AbortController to cancel in-flight fetches on unmount or url prop change
  • Refetches automatically via componentDidUpdate when url prop changes

Routing & registration

routeToBlock.ts uses getOEmbedEndpoint as the final fallback after pdf / simplePlayer / reactPlayer. The oembed block is added to defaultBlocks in ReactEmbed.tsx.

Usage

// Default — safe info-card, no iframe
<ReactEmbed url="https://www.flickr.com/photos/bees/2362225867/" />

// Opt-in HTML embed (injects provider's <iframe>; caller accepts XSS risk)
import { createOEmbedBlock } from 'react-embed';

const blocks = { oembed: createOEmbedBlock({ renderHtml: true }) };
<ReactEmbed url="https://www.flickr.com/photos/bees/2362225867/" blocks={blocks} />

fetchOEmbed, getOEmbedEndpoint, createOEmbedBlock, OEmbedResponse, OEmbedBlockOptions, and Provider are exported from the package root.

Copilot AI linked an issue Apr 20, 2026 that may be closed by this pull request
1 task
Agent-Logs-Url: https://github.com/streamich/react-embed/sessions/03024238-5624-486d-bf5d-3fe9b9dd6a42

Co-authored-by: streamich <9773803+streamich@users.noreply.github.com>
Copilot AI changed the title [WIP] Add support for oEmbed information retrieval and rendering feat: add oEmbed support Apr 20, 2026
Copilot AI requested a review from streamich April 20, 2026 00:13
@streamich streamich marked this pull request as ready for review April 20, 2026 00:16
Copilot AI review requested due to automatic review settings April 20, 2026 00:16
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds an oEmbed-based fallback block to broaden URL embed coverage (especially for providers without dedicated blocks) and exposes the oEmbed utilities as part of the public package API.

Changes:

  • Introduces a new oembed block with a safe info-card renderer and an opt-in HTML renderer.
  • Adds provider detection + oEmbed fetch utility and exports them from the package root.
  • Routes unknown URLs to the new oembed block as a final fallback and adds Storybook examples.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
src/stories/ReactEmbed.stories.tsx Adds Storybook stories for the new oEmbed block modes.
src/routeToBlock.ts Adds oEmbed routing as a final fallback after existing handlers.
src/index.tsx Exports oEmbed utilities and types from the package root.
src/blocks/oembed/index.tsx Implements the React oEmbed block (card + opt-in HTML injection).
src/blocks/oembed/fetchOEmbed.ts Adds provider detection and oEmbed JSON fetch utility.
src/ReactEmbed.tsx Registers the new oembed block in defaultBlocks.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +82 to +88
export function getOEmbedEndpoint(url: string): string | null {
for (const provider of PROVIDERS) {
if (provider.patterns.some((p) => p.test(url))) {
return `${provider.endpoint}?url=${encodeURIComponent(url)}&format=json`;
}
}
return null;
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.
Comment thread src/blocks/oembed/fetchOEmbed.ts Outdated
Comment on lines +95 to +104
export async function fetchOEmbed(url: string, signal?: AbortSignal): Promise<OEmbedResponse> {
const endpoint = getOEmbedEndpoint(url);
if (!endpoint) {
throw new Error(`No oEmbed provider found for URL: ${url}`);
}
const response = await fetch(endpoint, {signal});
if (!response.ok) {
throw new Error(`oEmbed request failed with status ${response.status}`);
}
return response.json() as Promise<OEmbedResponse>;
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.

fetchOEmbed relies on a global fetch being available. Since this utility is exported from the package root and can be used outside React/browser contexts, it would be safer to either (a) document that callers must provide a fetch polyfill, or (b) accept an injected fetch implementation via an optional parameter to avoid runtime failures in Node/older environments.

Copilot uses AI. Check for mistakes.
Comment on lines +136 to +152
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})},
},
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.
Comment thread src/blocks/oembed/index.tsx Outdated
Comment on lines +79 to +82

const thumbnail =
data.type === 'photo' ? (
<img src={data.url || data.thumbnail_url} alt={data.title || ''} style={thumbnailStyle} />
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.

In the photo branch, the thumbnail always renders even if both data.url and data.thumbnail_url are missing, producing an <img> without a src (React will omit the attribute). This can cause unexpected browser behavior (e.g. requesting the current document). Add a guard so the <img> only renders when a usable URL is present.

Suggested change
const thumbnail =
data.type === 'photo' ? (
<img src={data.url || data.thumbnail_url} alt={data.title || ''} style={thumbnailStyle} />
const photoSrc = data.url || data.thumbnail_url;
const thumbnail =
data.type === 'photo' ? (
photoSrc ? <img src={photoSrc} alt={data.title || ''} style={thumbnailStyle} /> : null

Copilot uses AI. Check for mistakes.
Comment thread src/blocks/oembed/index.tsx Outdated
Comment on lines +141 to +151
componentDidMount() {
const {signal} = this.controller;
fetchOEmbed(this.props.url, signal)
.then((data) => {
if (!signal.aborted) this.setState({data, loading: false});
})
.catch((error) => {
if (!signal.aborted) this.setState({error, loading: false});
});
}

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.

OEmbedBlock only fetches in componentDidMount, so if the parent updates url (e.g. Storybook select / consumer changing props), the component will keep showing the previous oEmbed result. Add componentDidUpdate to detect this.props.url changes, abort any in-flight request, reset state to loading, and refetch for the new URL (or force a remount via a key).

Suggested change
componentDidMount() {
const {signal} = this.controller;
fetchOEmbed(this.props.url, signal)
.then((data) => {
if (!signal.aborted) this.setState({data, loading: false});
})
.catch((error) => {
if (!signal.aborted) this.setState({error, loading: false});
});
}
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();
});
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +103 to +111
<p style={titleStyle}>
{data.author_url ? (
<a href={data.author_url} target="_blank" rel="noopener noreferrer" style={anchorStyle}>
{data.title}
</a>
) : (
data.title
)}
</p>
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.
Comment on lines +163 to +166
if (renderHtml && data.html) {
// Opt-in: inject the provider's own HTML (e.g. an <iframe>)
return renderWrap(<div dangerouslySetInnerHTML={{__html: data.html}} />);
}
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.
@streamich
Copy link
Copy Markdown
Owner

@copilot

  • Make Provider a 2-tuple:
type Provider = [patterns: string[], endpoint: string];
  • Add more providers

  • If provider unknown add ability to probe "well known paths", like https://<domain>/oembed

  • Leave only very important comments, no section delimiter comments.

Copy link
Copy Markdown
Author

Copilot AI commented Apr 20, 2026

@copilot

  • Make Provider a 2-tuple:
    ...

Done in 75a00a8:

  • Provider is now a 2-tuple[patterns: string[], endpoint: string], matching against new URL(url).hostname for exact host-only matching (no substring false-positives).
  • 29 providers registered — added Slideshare, Speakerdeck, Mixcloud, DeviantArt, TED, Tumblr, Kickstarter, Meetup, 500px, Twitch, Apple Music, Pinterest, Deezer, Bandcamp, Wistia, Loom, Canva.
  • Well-known path probing — when no registered provider matches, fetchOEmbed tries {origin}/oembed, /oembed.json, /api/oembed, /services/oembed sequentially (CORS/network errors are swallowed and the next path is tried).
  • Section delimiter comments removed from index.tsx; only functional comments remain.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

oEmbed

3 participants