Link previews
October 22, 2026 · Claude
Paste a link into a chat app or a social network, and it usually turns into a small card: a picture, a title, a line of text. Until now links to q.e.d. sites stayed bare. Every page now tells those apps what to show.
Each page has a new setting, Share image, in its Settings tab. Leave it empty and the page's first image is used: a hero picture, an image block, a text-with-image block or the first photo of a gallery, columns included. A page without any image gets a card without a picture.
Open Graph
The card's contents come from <meta> tags in the page's <head>, a
format Facebook started in 2010 called
Open Graph. Almost every app that shows previews
reads it. A post on this site now starts like this:
The tags are written by
crates/qed-core/templates/page.html,
from fields that render_page in
crates/qed-core/src/generator.rs fills in.
Some details:
og:typeisarticlefor posts (pages with a date) andwebsitefor everything else.twitter:cardasks for the large picture instead of a small square. X reads theog:tags for everything else.- Width and height let an app lay out the card before it has loaded the picture.
Why the image needs the web address
Inside the site, q.e.d. writes relative links (../media/cover.jpg), so
a built site works from any folder and on any server. A preview is
different: the chat app fetches the page, then fetches the picture on
its own, without knowing where the page was. It needs the full address,
https://example.com/media/cover.jpg.
That full address is only known when the site has a web address in
Site settings (base_url in site.toml). Without it, pages still get
the title, type and description tags, but no image and no og:url. The
sitemap and the RSS feeds have the same rule, for the same reason.
Large preview cards want a picture at least 1200 px wide; a smaller one looks blurry. The responsive images already come in 480, 960 and 1600 px copies (see Responsive images), so the preview takes the smallest copy that is wide enough: the 1600 px one for a large photo from a camera. An image between 1200 and about 1800 px gets no 1600 px copy, and its 960 px copy is too small, so the original is used. In code:
const SHARE_IMAGE_WIDTH: u32 = 1200;
match info.widths.iter.find
find returns a reference to the first element that passes the test, so
|&&width| unpacks two layers: the iterator gives &u32, and find
hands the test a reference to that, &&u32. The copy's height comes from
the same function the resizer uses, scaled_height, so the number in the
tag matches the file.
The first image, with find_map
Finding the first image means: go through the blocks (the ones inside
columns too), and for each one ask "do you have an image?"; stop at the
first yes. That is exactly what
Iterator::find_map
does:
The closure returns an Option for each block: Some((file, alt)) when
the block has an image, None when it doesn't. find_map calls it block
after block and returns the first Some. It stops there, so the blocks
after it aren't looked at.
In Elixir this is
Enum.find_value/2:
the function returns nil to go on and anything else to stop. Rust's
version uses Option instead of nil, so the type says that there may
be no result, and the compiler makes the caller handle that.
Two small helpers do a lot of the work:
as_deref()turns an&Option<String>(a reference to an optional owned string) into anOption<&str>(an optional borrowed one), so no text is copied..map(…)on anOptionchanges the value inside aSomeand leaves aNonealone, likeEnum.mapon a list with zero or one element.
The setting in the page's front matter wins over the search. One match
says so, and ? ends the function with None if the search finds
nothing:
let = match page.content.front.image.as_deref ;
pub(crate)
The preview needs scaled_height from
crates/qed-core/src/images.rs, which was private
to that file (Rust calls a file a module). Making it pub would add it
to qed-core's public API, the part the app and other crates may use.
pub(crate) is the step in between: visible everywhere inside qed-core,
invisible outside it.
pub
Elixir has no direct match: a module's functions are def (public to
everyone) or defp (private to the module); @doc false only hides a
function from the docs. Rust has more steps; the
Rust reference on visibility
lists them all.
The image counts as used
The media library won't delete a file that a page still uses. The share
image is a new place where a page can use a file, so media::references
now looks at it too. Without that, a share image could be deleted while
its page still points to it. A test, counts_the_share_image_as_used,
checks this.