Repository navigation
feat(EI-248): link preview cards in the Notes field, with a card/inline toggle - #69
Conversation
…ne toggle A URL in a todo's Notes field can now render as a preview card (title, description, site name, image) instead of a plain inline link, toggled either way from the link hover toolbar / the card's own button. Metadata comes from a new GET /api/link-preview Worker branch using Cloudflare's built-in HTMLRewriter, cached in caches.default. Card/inline state is encoded as a fenced ```linkcard block, translated to/from a real BlockNote block outside BlockNote's own markdown pipeline (src/lib/link-preview-markdown.ts) — BlockNote's built-in codeBlock parsing would otherwise silently reclaim the fence. Full design writeup, including two rejected conventions and two bugs found only by live testing (a stray file-toolbar collision, and a focus-loss bug that silently dropped commits), in docs/LINK-PREVIEW.md.
EI-248 Link preview cards in the Notes field, with a card/inline toggle
A URL pasted into a todo's Notes field renders as a plain inline link. Add a preview card rendering — title, description, site name, image — and a UI control to toggle any link between card and inline. Day Notes use the same Card-vs-inline is encoded in the stored markdown as a fenced ```linkcard block for a card, an ordinary Metadata is fetched by a new Scoped deliberately small: fetch on view, no stored metadata, no new synced entity, no schema migration. Out of scope: proxying |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
faite | 1dbaf57 | Aug 30 2026, 01:27 AM |
Closes EI-248.
A URL in a todo's Notes field can now render as a preview card — image, title, description, site name — instead of a plain inline link, toggled either way from the editor. Day Notes use the same
MarkdownField, so they get it for free.The markdown shape, and two conventions that didn't work
The Notes field stores lossy markdown, not BlockNote JSON, so a card has to be markdown too. Two conventions were tried and rejected — both disproven by reading BlockNote 0.53's installed source, not by assumption:
text === hrefto a bare URL, not[url](url)(htmlToMarkdown.ts'sformatLink, citing fix(markdown): avoid autolink angle brackets when copying plain URL l… TypeCellOS/BlockNote#2661). No shape left to hang a flag on — and it would have made every pasted link a card immediately, the opposite of an explicit toggle.linkPreviewblock round-tripping via its owntoExternalHTML/parse. Also dead: BlockNote's tokenizer emits<pre><code data-language="...">for any fence, and the built-incodeBlockclaims any<pre><code>regardless of language. There is no public API to out-prioritize it (two different, easily-confused "priority" concepts exist; neither is exposed for this).What shipped: the
```linkcardfence is the stored shape, butsrc/lib/link-preview-markdown.tsowns the translation outside BlockNote's markdown pipeline — placeholder substitution before parse, mirror image on commit. BlockNote never has to disambiguate our block from a code block, because it never sees one.Two bugs found only by live/e2e testing
blockHasType(..., { url: "string" })— a purely structural check for a prop namedurl, not an actual file-block check. Our block matched by accident. Fixed by naming the prophref.replaceBlockstheir ownonClicktriggers. A focused element vanishing drops focus to<body>without bubbling a blur throughMarkdownEditor's wrapper — the only thing that commits. Convert, click any other field, and the change was gone on reopen. Both handlers now calleditor.focus()after replacing.Neither was caught by unit tests or the first manual pass.
Server
GET /api/link-preview— a new Worker branch (no Route Handlers here;output: exportforbids them). Cloudflare's built-inHTMLRewriter, not a dependency:open-graph-scraper,link-preview-js, andmetascraperall needundiciand cannot run on Workers;unfurl.jsis archived. Session-gated viagetSessionSafe, responses cached incaches.defaultfor 24h.CORS headers are deliberately not part of the cached entry — they are per-caller, the entry is per-target-URL, and mixing them poisoned the cache locally (warming from :8787 then loading :3000 failed CORS on an otherwise correct response).
Validation rejects non-http(s) schemes, over-long URLs, and literal loopback/private/link-local hosts including the cloud metadata address — belt-and-suspenders alongside
global_fetch_strictly_public, which is the runtime defense against DNS rebinding.Scope
Deliberately small, per the brief: fetch on view, no stored metadata, no new synced entity, no schema migration. Explicit toggle only — no auto-card on paste.
og:imageloads direct from the remote origin withreferrerPolicy="no-referrer"; no Worker image proxy in v1.Testing
desktop+phone-iphone), added toSPECSand bothtestMatchlists per AGENTS.md;docs/E2E.md§8.1/§8.2 updated.docs/LINK-PREVIEW.mdhas the full account.Follow-up filed as EI-249 (a dev-bootstrap script for new worktrees — the local-D1/verification friction hit while testing this).
🤖 Generated with Claude Code