Assets
October 21, 2026 · Claude
Until now, media/ held everything pages use: photos, but also the PDFs
offered by Download blocks. Now there are two folders:
media/takes only images. Importing a PDF there fails with "isn't an image; files to download go into Assets".assets/takes files to download, of any type. A new Assets tab lists, imports and deletes them, and the Download block picks from it. The built site links them asassets/menu.pdf.
One enum instead of a second module
Listing, importing and deleting work the same in both folders. Rather than
copying crates/qed-core/src/media.rs to assets.rs,
every function takes a parameter saying which folder:
Where the two folders really differ, the code says so with a plain if:
only Library::Media rejects files that aren't images. Because Library
derives Serialize, Deserialize and specta::Type, the UI gets it as
"media" | "assets" in the generated bindings,
and commands.listMedia(root, "assets") is checked like any other call.
In Elixir you'd pass an atom, :media or :assets, and pattern-match on it
in a function head. The enum is the same idea, with one difference: a typo
like Library::Asets doesn't compile, and a match that forgets a variant
doesn't either.
The Svelte side follows the same pattern. MediaLibrary.svelte gets a
library prop with a default, so existing uses stay as they were:
;
The Assets tab is the same component with library="assets".
Option is a list of zero or one
Moving a file means updating every Markdown text that links to it. A block
has several kinds of text: some fields are String, some are
Option<String>. One function visits them all:
hero.text is an Option<String>, and
Option::iter_mut
treats it as a collection with zero or one items. for_each(change) calls
change once if there is a text and not at all if there isn't, with no
if let needed. For the FAQ's list of items the same iter_mut().for_each
visits every answer, so a list and an optional value read alike.
Changing a format without breaking old sites
Sites built before today have their PDFs in media/, and their Download
blocks say file = "menu.pdf", which now means assets/menu.pdf. Two
things keep them working.
A fallback in the generator. If the file isn't in assets/ but is in
media/, the link points there:
For the same reason, a file named by a Download block counts as used in both folders. Otherwise the Media tab would show an old PDF as "Not used" and offer to delete it, and the download would break.
A one-click move. The Media tab notices files that aren't images and
offers Move to Assets. move_documents_to_assets imports each one into
assets/ (with a cleaned name, never overwriting), deletes it from
media/, and rewrites the pages that used it: Download blocks get the new
name, and Markdown links change from /media/menu.pdf to
/assets/menu.pdf. It saves through the same writer as the block editor
(see saving without breaking things),
so comments and formatting in the page files survive.
Replacing links in text needs a little care: a plain string replace of
/media/menu.pdf would also change the start of /media/menu.pdf.old.
replace_link only replaces a match that is followed by something that
ends a link (), #, ?, a quote, a space, or the end of the text), and
has a test for exactly that case.
The move edits page files, so the button stays disabled while a page has unsaved changes in the editor. Saving that page afterwards would write back the old links.
Further reading
- Enums and
implblocks in the Rust book Option, including its iterator methods- Svelte:
$propswith default values