import { parseHTML } from 'linkedom'
/** Parsed oEmbed response for a page URL. */
export type OembedInfo = {
/** Embed height in pixels. `0` if not provided by the oEmbed endpoint. */
height: number
/** Raw HTML embed code from the oEmbed provider. */
html: string
/** Thumbnail image URL, if the provider supplies one. */
thumbnailUrl: string | undefined
/** Media title from the oEmbed response, if available. */
title: string | undefined
/** Embed width in pixels. `0` if not provided by the oEmbed endpoint. */
width: number
}
/**
* Discovers and fetches oEmbed metadata for a page URL.
*
* Follows redirects (e.g. short URLs like dai.ly/ → dailymotion.com/), parses
* the page HTML for a ``
* tag, then fetches the oEmbed JSON endpoint.
*/
export async function fetchOEmbed(pageUrl: string): Promise {
// Step 1: Fetch the page HTML, following redirects.
const pageResponse = await fetch(pageUrl)
if (!pageResponse.ok) {
throw new Error(
`Failed to fetch page "${pageUrl}" (${String(pageResponse.status)}). Provide a direct media file URL or set the "service" prop explicitly.`,
)
}
const canonicalUrl = pageResponse.url
const html = await pageResponse.text()
// Step 2: Parse the HTML for the oEmbed discovery link.
const { document } = parseHTML(html)
const oembedLink = document.querySelector('link[rel="alternate"][type="application/json+oembed"]')
if (!oembedLink) {
throw new Error(
`No oEmbed provider found for "${canonicalUrl}". Provide a direct media file URL or set the "service" prop explicitly.`,
)
}
const endpointUrl = oembedLink.getAttribute('href') ?? undefined
if (endpointUrl === undefined || endpointUrl === '') {
throw new Error(
`oEmbed link tag found but has no href for "${canonicalUrl}". Set the "service" prop explicitly.`,
)
}
// Step 3: Fetch the oEmbed JSON response.
const oembedResponse = await fetch(endpointUrl)
if (!oembedResponse.ok) {
throw new Error(
`oEmbed endpoint request failed (${String(oembedResponse.status)}) for "${canonicalUrl}"`,
)
}
const data = (await oembedResponse.json()) as {
height?: number
html?: string
thumbnail_url?: string // eslint-disable-line ts/naming-convention -- oEmbed API field name
title?: string
width?: number
}
if (data.html === undefined || data.html === '') {
throw new Error(
`oEmbed response for "${canonicalUrl}" contains no embed HTML. This URL may not support rich/video embeds.`,
)
}
return {
height: data.height ?? 0,
html: data.html,
thumbnailUrl: data.thumbnail_url,
title: data.title,
width: data.width ?? 0,
}
}
/**
* Sanitizes oEmbed provider HTML: adds a missing `title` attribute on iframes
* (for accessibility), removes deprecated attributes (`frameborder`,
* `scrolling`), and lowercases non-standard attribute names.
*/
export function sanitizeEmbedHtml(html: string, title: string): string {
const deprecatedAttributes = new Set(['frameborder', 'scrolling'])
// Wrap in a full document — linkedom only wires `document.body` to the
// parsed content when the input is a complete HTML document. A bare
// fragment (or one wrapped in a lone `` tag) parses into a tree
// where `document.body` is a synthesized empty element, so serializing it
// silently returns an empty string.
const { document } = parseHTML(`${html}`)
for (const iframe of document.querySelectorAll('iframe')) {
if (!iframe.hasAttribute('title')) {
iframe.setAttribute('title', title)
}
// Snapshot the attribute list — removing/renaming while iterating the
// live collection skips entries
// eslint-disable-next-line unicorn/no-useless-spread -- the copy is intentional, see above
for (const { name, value } of [...iframe.attributes]) {
if (deprecatedAttributes.has(name.toLowerCase())) {
iframe.removeAttribute(name)
} else if (name !== name.toLowerCase()) {
iframe.removeAttribute(name)
iframe.setAttribute(name.toLowerCase(), value)
}
}
}
return document.body.innerHTML
}