Skip to content
Edytor
Esc
↑↓navigate↵open⌘Jpreview
On this page

Bookmark

The bookmark plugin adds Notion's web bookmark, a card linking to a page with its title, description and images from an unfurl function you provide.

createBookmarkPlugin() adds Notion’s web bookmark: an “Add a web bookmark” panel (your empty snippet, createMediaEmptyPlugin({ empty })) until the block has a link, then a card that opens the page in a new tab, with an editable caption. The card shows the page’s title, description, icon and cover image when your unfurl function answers them, the URL alone otherwise. It is not a default plugin: list it.

<script lang="ts">
	import { Edytor, createBookmarkPlugin } from 'edytor';

	// A browser cannot read most pages: ask your server for their metadata.
	const bookmark = createBookmarkPlugin({
		unfurl: async (url) => {
			const response = await fetch(`/api/unfurl?url=${encodeURIComponent(url)}`);
			return response.ok ? await response.json() : null;
		}
	});
</script>

<Edytor plugins={[bookmark]} />

A slash menu then lists “Web bookmark” under Media, and pasting an http(s) link on an empty line offers to make it a bookmark (Pasting a link on an empty line). createBookmarkPlugin() is the plugin without unfurl.

Options

createBookmarkPlugin(options) takes a BookmarkPluginOptions:

PropType
unfurl?(url: string) => Promise<BookmarkPreview | null | undefined>

Read a page's title, description, cover image and icon. Called when a link is set from the panel or the paste menu. Without it, the card shows the URL.

Type(url: string) => Promise<BookmarkPreview | null | undefined>
menu?Snippet<[UrlPasteController]>

The menu a URL pasted on an empty line opens (Link, Embed, Bookmark); the view's one menu takes the first menu its embed and bookmark plugins pass; without one, the link stays and nothing shows. Keep its ARIA with {@attach controller.popup} and {@attach controller.option(index)}. See Menus and handles.

TypeSnippet<[UrlPasteController]>
card?Snippet<[BookmarkCardPayload]>

The card of a bookmark with a link, replacing Notion's (see [Your card](#your-card)). The first bookmark plugin listed in a view gives it.

TypeSnippet<[BookmarkCardPayload]>

BookmarkPreview is { title?, description?, image?, icon? }, every field optional. The answer is stored on the block, as one data write, when it arrives and the block still is that bookmark in a view that may write: so every collaborator, the room and a readonly viewer see it without unfurling again. A failed or empty answer stores nothing. It arrives whenever the server replies, maybe after more edits, so it is no undo step: undo takes back what the user did (the typing after it, then the conversion), never the unfurled fields alone.

Your server endpoint fetches untrusted pages: restrict it to http(s) URLs on public hosts (no private or loopback addresses), cap the response size and time, and read only the metadata (<title>, og:title, og:description, og:image, the icon link).

Your card

The card is drawn inside the block’s void wrapper ([data-edytor-bookmark]), above the caption. card replaces it with your markup; it receives a BookmarkCardPayload: block (undefined in a suggestion’s preview), url, the unfurled title, description, image and icon (each undefined until unfurl answers it, every URL already an http(s) one), and link, the attachment of your a, as a menu’s row takes its own: target="_blank", rel="noopener noreferrer nofollow" and draggable="false". Render its href={page.url} yourself, so a server-rendered readonly view links too.

<script lang="ts">
	import { Edytor, createBookmarkPlugin, type BookmarkCardPayload } from 'edytor';

	const bookmarks = createBookmarkPlugin({ card });
</script>

{#snippet card(page: BookmarkCardPayload)}
	<a class="card" href={page.url} {@attach page.link}>
		{#if page.icon}<img src={page.icon} alt="" />{/if}
		<strong>{page.title ?? page.url}</strong>
		{#if page.description}<span>{page.description}</span>{/if}
	</a>
{/snippet}

<Edytor plugins={[bookmarks]} />

Security

  • The block stores and renders only an absolute http:/https: link with a host, read by the link sanitizer’s rules (safeWebUrl): a javascript: or data: link a collaborator stores renders no card.
  • The card is an a with target="_blank" and rel="noopener noreferrer nofollow" (your card’s too, through link).
  • An unfurled image or icon is kept only when it is an http(s) URL; the title (300 characters) and description (1,000) are capped, and rendered as text.

The kind

Kind Element Data Preset
bookmark figure { url: string, title?, description?, image?, icon? } Web bookmark (🔖, group Media, keywords link, url, preview)

The block is void like the image: it takes no children and is never merged, and its only text is the caption. The command id is block.bookmark. Its role is in defaultSemantics. In a readonly view the empty block is a passive placeholder, so a viewer never runs unfurl.

Clipboard

  • Copying exports <figure data-edytor-bookmark><a href="…">title</a><figcaption>caption</figcaption></figure>; the plain text is the link, then the caption.
  • Pasting that HTML back imports a bookmark with its link and title. Other links paste as links.

Styling

Selector Matches
[data-edytor-type='bookmark'] The figure
[data-edytor-bookmark] The card’s wrapper (the a inside it)
[data-edytor-bookmark-title] The title (the URL without one)
[data-edytor-bookmark-description] The description
[data-edytor-bookmark-url] The icon and URL line
[data-edytor-bookmark-cover] The cover image

The card’s look (its border, the cover beside the text, the clamped description, the hover) is the Notion theme’s: without it the card is its unstyled link. The empty state uses the shared media selectors (see Embed).

Was this page helpful?