Menu

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 as assets/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:

#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Library {
    Media,
    Assets,
}

impl Library {
    pub fn dir(self) -> &'static str {
        match self {
            Library::Media => MEDIA_DIR,
            Library::Assets => ASSETS_DIR,
        }
    }
}

pub fn list(site: &Site, library: Library) -> Result<Vec<MediaFile>, SiteError> {
    let folder = site.root.join(library.dir());
    // …
}

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:

let { siteRoot, library = "media", onpick, only, onmoved, editing = false }: Props = $props();

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:

fn for_each_markdown(block: &mut Block, change: &mut impl FnMut(&mut String)) {
    match block {
        Block::Text { markdown } => change(markdown),
        Block::Hero(hero) => hero.text.iter_mut().for_each(change),
        Block::Faq(faq) => faq.items.iter_mut().for_each(|item| change(&mut item.answer)),
        // …
    }
}

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:

fn download_location(name: &str, links: &LinkContext, root: &Path) -> (String, PathBuf) {
    let asset = root.join(ASSETS_DIR).join(name);
    let old = root.join(MEDIA_DIR).join(name);
    if !asset.is_file() && old.is_file() {
        (links.media(name), old)
    } else {
        (links.asset(name), asset)
    }
}

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