Skip to content

feat(EI-248): link preview cards in the Notes field, with a card/inline toggle - #69

Merged
RobErskine merged 1 commit into
mainfrom
rob/link-preview-cards
Aug 30, 2026
Merged

RobErskine merged 1 commit into
mainfrom
rob/link-preview-cards

Conversation

@RobErskine

Copy link
Copy Markdown
Owner

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:

  1. "Link text equals its href → card." Dead on arrival: BlockNote's exporter collapses text === href to a bare URL, not [url](url) (htmlToMarkdown.ts's formatLink, 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.
  2. A linkPreview block round-tripping via its own toExternalHTML/parse. Also dead: BlockNote's tokenizer emits <pre><code data-language="..."> for any fence, and the built-in codeBlock claims 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 ```linkcard fence is the stored shape, but src/lib/link-preview-markdown.ts owns 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

  • A stray file toolbar over every card. BlockNote's default File-block toolbar buttons key off blockHasType(..., { url: "string" }) — a purely structural check for a prop named url, not an actual file-block check. Our block matched by accident. Fixed by naming the prop href.
  • Silently discarded conversions. Both toggle buttons are removed from the DOM by the replaceBlocks their own onClick triggers. A focused element vanishing drops focus to <body> without bubbling a blur through MarkdownEditor's wrapper — the only thing that commits. Convert, click any other field, and the change was gone on reopen. Both handlers now call editor.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: export forbids them). Cloudflare's built-in HTMLRewriter, not a dependency: open-graph-scraper, link-preview-js, and metascraper all need undici and cannot run on Workers; unfurl.js is archived. Session-gated via getSessionSafe, responses cached in caches.default for 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:image loads direct from the remote origin with referrerPolicy="no-referrer"; no Worker image proxy in v1.

Testing

  • 51 unit tests across the round-trip contract, URL validation, tag normalization, and the in-flight dedup cache.
  • 3 e2e tests on both gate projects (desktop + phone-iphone), added to SPECS and both testMatch lists per AGENTS.md; docs/E2E.md §8.1/§8.2 updated.
  • Full suite green post-rebase: 2204 unit tests, both typechecks, lint, both production builds.
  • Verified live in a real browser (paste → card → close → reopen → still a card, and back).

docs/LINK-PREVIEW.md has 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

…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.
@linear-code

linear-code Bot commented Aug 30, 2026 •

Copy link
Copy Markdown
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 MarkdownField, so they get it too.

Card-vs-inline is encoded in the stored markdown as a fenced ```linkcard block for a card, an ordinary [title](url) link otherwise. A small module (`link-preview-markdown.ts`) translates between the fence and the live editor block outside BlockNote's own markdown pipeline, because BlockNote's built-in code-block parsing would otherwise silently reclaim the fence. Full writeup in `docs/LINK-PREVIEW.md`.

Metadata is fetched by a new GET /api/link-preview Worker branch using Cloudflare's built-in HTMLRewriter — not the @ldlfylt/link-preview-card npm package, which is an abandoned hobby project whose documented cache prop is not implemented, and not open-graph-scraper / link-preview-js / metascraper, which all need undici and cannot run on Workers. Responses are cached in caches.default for 24h.

Scoped deliberately small: fetch on view, no stored metadata, no new synced entity, no schema migration.

Out of scope: proxying og:image through the Worker; auto-card on paste; persisting metadata for offline/link-rot survival.

Review in Linear

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
faite 1dbaf57 Aug 30 2026, 01:27 AM

@RobErskine
RobErskine merged commit 998da92 into main Aug 30, 2026
3 checks passed
@RobErskine
RobErskine deleted the rob/link-preview-cards branch August 30, 2026 01:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant