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:
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.
(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.
Snippet<[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.
Snippet<[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): ajavascript:ordata:link a collaborator stores renders no card. - The card is an
awithtarget="_blank"andrel="noopener noreferrer nofollow"(yourcard’s too, throughlink). - An unfurled
imageoriconis kept only when it is anhttp(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).