genesis-editor

Embeds

Videos, posts, music, maps and design files from 33 providers, from links, iframe code or the sites' own embed code, plus sandboxed iframes from anywhere else.

Adding embeds#

  • Paste a link on its own line. A provider URL alone on a line or in a pasted paragraph becomes an embed. Press ⌘Z / Ctrl+Z once to get the link back, or use Convert to link in the embed's bar. A URL inside a sentence stays a link, and links to other sites are never turned into frames.
  • Paste iframe code from a site's "Embed" or "Share" dialog. Provider iframes are rebuilt from our own template; anything else becomes a sandboxed generic embed sized from its width/height.
  • Paste a site's embed code, the <blockquote> plus <script> that X, Instagram, Bluesky, Threads, Reddit and others give you. See Embed code.
  • Paste a page with embeds. Iframes and the sites' own markup become embeds; their scripts are dropped.
  • Type /embed and paste a link, embed code or iframe code into the box. This is the one place a link to an unknown site can become a (sandboxed) embed.

Every embed has a bar with the site, an editable caption, and an Open ↗ link. Many sites refuse to be framed, and a page can't detect that across origins, so the link is always there. Drag the right edge to resize; the width is stored as previewWidth and exported.

Iframes load on their own. Pass embeds.clickToLoad: true if you want a "Load … embed" button first, so a document full of videos doesn't talk to those sites until the reader asks.

Embed code#

Many sites' "Embed" button gives a <blockquote> (or a <div> or <a>) holding the post's text, plus a <script> that swaps it for the real thing. The editor reads that code with or without its script: in pasted HTML, pasted as text (the "Copy code" button), in value / setHTML, and in the /embed box. The pasted script is never run or kept. Only the post's URL and readable text are.

  • The text stays. The blockquote's lines are stored on the block (fallback). They show while the frame loads and when the policy refuses the embed. With the embed tool off, they become paragraphs above the link.
  • It exports back as embed code. getHTML() writes the site's canonical snippet, rebuilt from the stored URL and text (never from the pasted markup), inside our figure. It reads as a quote anywhere, renders as the real post when published with the site's script, and reads back to the same block.

How each site is drawn

Wherever a site has a frame URL, we draw the post in our own sandboxed iframe, rebuilt from the post URL. The site's widget script is never loaded into your page. Only script-only embeds, which have no frame URL, run the site's official script, and only inside an opaque-origin srcdoc frame (sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox", no allow-same-origin).

SiteEmbed code readDrawn withWhy
X / Twitterblockquote.twitter-tweetOur iframeplatform.twitter.com/embed/Tweet.html is the frame widgets.js itself loads.
Instagramblockquote.instagram-mediaOur iframeThe /embed/ page is public and frameable.
TikTokblockquote.tiktok-embedOur iframetiktok.com/embed/v2 is the player embed.js inserts.
Threadsblockquote.text-post-mediaOur iframethreads.net …/embed/ is frameable (threads.com sends X-Frame-Options: DENY).
Blueskyblockquote.bluesky-embedOur iframeembed.bsky.app takes the post's DID URI. Handle-only URLs stay links: the frame needs a DID.
Redditblockquote.reddit-embed-bqOur iframeembed.reddit.com allows any ancestor. Reddit may block automated browsers.
Facebookdiv.fb-post, div.fb-videoOur iframeThe post.php / video.php plugins are the frames the SDK creates.
Pinteresta[data-pin-do=embedPin]Our iframeassets.pinterest.com/ext/embed.html is the frame pinit.js inserts.
Tumblrdiv.tumblr-postOur iframeembed.tumblr.com needs the snippet's key, so the embed URL is the stored URL.
Mastodonblockquote.mastodon-embedOur iframeEvery server serves /@user/id/embed. Listed servers are providers; others become sandboxed generic embeds.
Imgurblockquote.imgur-embed-pubOur iframeimgur.com/…/embed is the frame embed.js inserts.
Flickra[data-flickr-embed]Our iframeThe photo's /player/ page. The widget writes the image itself, so export is the iframe.
Telegramscript[data-telegram-post]Our iframet.me/channel/id?embed=1 is the frame the widget script inserts.
GitHub Gistscript[src^=gist.github.com]Sandboxed widgetScript-only: it writes the gist into the page and has no frame URL.

Spotify, SoundCloud, YouTube, LinkedIn, Giphy and most others only give iframe code, which maps to the same providers. Instagram and TikTok frames ignore the embed's caption text; X frames show the live post.

Exporting embed code

editor.getHTML();                          // default "auto": embed code back as embed code
// <figure data-genesis-embed="snippet" data-provider="twitter" data-url="https://twitter.com/jack/status/20" …>
//   <blockquote class="twitter-tweet"><p>just setting up my twttr</p><p><a href="…">— jack (@jack) March 21, 2006</a></p></blockquote>
// </figure>

editor.getHTML({ embedScripts: true });    // + each site's official <script>, once, for publishing elsewhere
editor.getHTML({ embeds: "iframe" });      // our sandboxed iframes; the text goes in data-fallback
editor.getHTML({ embeds: "link" });        // email and RSS: the text as a quote, then the link

// The same options on toHTML(blocks, options) from "genesis-editor/parser".
Widget embeds (Gist) run the site's script inside the srcdoc frame, and that frame inherits your page's Content Security Policy. If you set one, add getEmbedScriptSrc() to script-src and allow the frame's one-line inline height reporter. Otherwise the frame keeps its default height.

Embed code for your own providers

A provider can declare a snippet matcher. Its selector may match a blockquote, div, a or script, and url reads the page URL from it. That URL must still pass the provider's match, so a look-alike snippet pointing elsewhere is ignored.

const acme = defineEmbedProvider({
  name: "acme",
  label: "Acme Posts",
  match: [/^https:\/\/acme\.social\/p\/(\d+)$/],
  frameOrigins: ["https://embed.acme.social"],
  toEmbed: (url, [, id]) => ({ src: `https://embed.acme.social/p/${id}`, height: 480 }),
  snippet: {
    selector: "blockquote.acme-post",
    url: (el) => el.querySelector("a[href^='https://acme.social/p/']")?.getAttribute("href"),
    // Optional: written back on export (escape anything you add; body is already escaped).
    toHTML: (url, body) => `<blockquote class="acme-post">${body}</blockquote>`,
    script: "https://acme.social/embed.js",
  },
});

Providers#

YouTube youtubeVimeo vimeoTikTok tiktokInstagram instagramX twitterSpotify spotifyLoom loomCodePen codepenFigma figmaGoogle Maps googleMapsDailymotion dailymotionSoundCloud soundcloudCodeSandbox codesandboxGoogle Docs googleDocsAirtable airtableTypeform typeformMiro miroCanva canvaApple Music appleMusicApple Podcasts applePodcastsThreads threadsBluesky blueskyReddit redditFacebook facebookPinterest pinterestTumblr tumblrMastodon mastodonLinkedIn linkedinImgur imgurFlickr flickrGIPHY giphyTelegram telegramGitHub Gist gist

YouTube embeds use youtube-nocookie.com. Tweets use the plain iframe, not widgets.js. Sizes follow each site: 16:9 for video (9:16 for Shorts and TikTok), fixed heights for posts, players and forms.

Policy and security#

<BlockEditor embeds={{ policy: "open" }} />        // providers + sandboxed generic iframes (default)
<BlockEditor embeds={{ policy: "allowlist" }} />   // providers only; unknown iframes become links
<BlockEditor embeds={{ clickToLoad: true }} />     // wait for a click before loading the iframe
<BlockEditor embeds={{ providers: ["youtube", "vimeo", "loom"] }} />
  • Provider frames are rebuilt from the page URL every time they're drawn or exported, so a stored or pasted src is never trusted, and the result must load from the provider's frameOrigins. They get sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-presentation". That includes allow-same-origin, which is safe only because the frame's origin is the provider's, not yours.
  • Generic frames must be https:, can't be your app's own origin, and get sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox": no allow-same-origin, forms or top navigation. They also get no allow features, referrerpolicy="no-referrer", and credentialless where the browser supports it.
  • Pasted sandbox, allow, style and on* attributes are always dropped. Only src, title, width and height are read.
"open" lets any author show any third-party page to readers, which is a way in for phishing look-alikes and trackers. Use "allowlist" for user-generated or multi-tenant content. htmlMode="strict" also allows providers only.

Content Security Policy

getEmbedFrameSrc() lists the origins the enabled providers load from, plus https: when generic embeds are on.

import { getEmbedFrameSrc } from "genesis-editor";

// next.config.ts, middleware, or your server's headers
const frameSrc = getEmbedFrameSrc({ policy: "allowlist" }).join(" ");
// → "https://www.youtube-nocookie.com https://player.vimeo.com https://www.tiktok.com …"
headers.set("Content-Security-Policy", `frame-src ${frameSrc}`);

Custom providers#

import { BlockEditor, defineEmbedProvider } from "genesis-editor";

const acme = defineEmbedProvider({
  name: "acme",
  label: "Acme Video",
  // Anchored page-URL patterns; capture groups go to toEmbed.
  match: [/^https:\/\/video\.acme\.com\/v\/(\d+)$/],
  // The only origins toEmbed may return, and the CSP frame-src entries.
  frameOrigins: ["https://player.acme.com"],
  // Pasted <iframe src> → page URL, so iframe code maps to this provider too.
  fromIframe: (src) => src.host === "player.acme.com" ? `https://video.acme.com/v${src.pathname}` : null,
  toEmbed: (url, [, id]) => ({ src: `https://player.acme.com/${id}`, aspectRatio: "16/9" }),
});

<BlockEditor embedProviders={[acme]} />
// or in a plugin: definePlugin({ name: "acme", embeds: [acme] })

Custom providers are tried before the built-ins, and one named like a built-in replaces it. embeds.providers filters custom and built-in providers alike.

HTML#

getHTML() and toHTML write a figure with our own iframe attributes, which parseHTML reads back to the same block:

<figure data-genesis-embed data-provider="youtube" data-url="https://www.youtube.com/watch?v=dQw4w9WgXcQ" data-aspect-ratio="16/9">
  <iframe src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ" title="YouTube embed" loading="lazy"
    referrerpolicy="strict-origin-when-cross-origin" sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-presentation"
    allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write" allowfullscreen
    style="width:100%;border:0;aspect-ratio:16/9"></iframe>
  <figcaption>Optional caption</figcaption>
</figure>

For email and RSS, where iframes don't work, write link cards instead. They read back to the same blocks too:

import { toHTML } from "genesis-editor/parser";

toHTML(blocks, { embeds: "link" });
// <figure data-genesis-embed="link" data-provider="youtube" data-url="…"><a href="https://www.youtube.com/watch?v=…">YouTube: https://…</a></figure>

With the embed tool off, embeds in pasted or loaded HTML become link paragraphs (blockquote embed code stays a quote; an exported embed's text becomes paragraphs above its link), and the import report counts them under downgrades. report.embeds counts provider embeds, generic embeds, and iframes the policy turned into links (blocked).