genesis-editor

Link previews

Links to other sites as cards with the page's title, description, image and site name, plus links on images.

Cards#

  • Paste a link on its own line. A URL alone on a line, or a pasted paragraph that is only a link, becomes a card. Press ⌘Z / Ctrl+Z once to get the link back. A URL inside a sentence stays a link, and links an embed provider handles become embeds instead.
  • Type /Link preview and paste a link into the box.
  • Turn a link into a card with the card button in the formatting toolbar, on a line that is only a link. Convert to link in the card's bar goes back.

A new card shows Loading preview… while the metadata is fetched. When the lookup fails or the page has no title, description or image, the card turns back into a plain link. The metadata is stored in the block, so a saved document draws its cards without fetching anything; a read-only editor never fetches.

Where the metadata comes from#

Previews are off until you configure a source, the same way uploads are. Without one, URLs stay links and the /Link preview item isn't offered.

An endpoint

<BlockEditor linkPreview={{ endpoint: "/api/link-preview" }} />
// GET /api/link-preview?url=https%3A%2F%2Fexample.com%2Fpost
// → { "title": "…", "description": "…", "image": "https://…/og.png", "siteName": "…", "favicon": "https://…/favicon.ico" }

<BlockEditor linkPreview={{ endpoint: "/api/link-preview", method: "POST", headers: async () => ({ Authorization: await token() }) }} />
// POST /api/link-preview  { "url": "https://example.com/post" }

Every field is optional. Relative image and favicon URLs are resolved against the page.

Your own lookup

<BlockEditor
  linkPreview={{
    resolve: async (url, { signal }) => {
      const response = await fetch(`https://api.example.com/unfurl?url=${encodeURIComponent(url)}`, { signal });
      if (!response.ok) return null; // no card: the URL stays a link
      const meta = await response.json();
      return { title: meta.title, description: meta.summary, image: meta.thumbnail, siteName: meta.publisher };
    },
  }}
/>
Your endpoint fetches pages users paste, so it must refuse private, loopback and link-local addresses (SSRF), check every redirect, cap the download and time out. The demo's /api/mock-link-preview does all of that, reads Open Graph and Twitter tags with patterns (the page is never run), and caches results in memory only.

What's stored#

{
  type: "linkPreview",
  props: { url, title, description, image, siteName, favicon },
}

Whatever the source returns is cleaned before it is stored, and again on export and import: text becomes one line of plain text with control characters removed (title up to 300 characters, description 1,000, site name 100), and image and favicon must be absolute http(s) URLs. Images load with referrerpolicy="no-referrer" and disappear if they fail.

HTML#

getHTML() and toHTML write a card as one <a>, which reads as a link with its image and text anywhere, and back to the same block in parseHTML:

<a href="https://example.com/post" data-genesis-link-preview data-title="A post" data-description="What it's about"
   data-image="https://example.com/og.png" data-site-name="Example" data-favicon="https://example.com/favicon.ico">
  <img src="https://example.com/og.png" alt=""> <strong>A post</strong> <span>What it's about</span> <small>Example · example.com</small>
</a>

With the linkPreview tool off (see Tools), a card in loaded or pasted HTML becomes a link paragraph with the card's title, and the import report counts it under downgrades.

Select an image and use the link button in its toolbar to add, edit or remove a link, and to open it in a new tab. A linked image shows where it points in the editor; clicking the image still selects it. Links must be http, https, mailto, tel or relative; javascript: and other schemes are refused. On export:

<a href="https://example.com/" target="_blank" rel="noopener noreferrer"><img src="…" alt="…"></a>

Pasted and loaded <a href><img></a> keeps the link, and target="_blank" sets Open in a new tab.