Verb-mode ?view=download. Lives in src/routes/download.rs and is invoked from Route: view (dispatch) when the query string says view=download. Dispatches per ctx.type; not all resource types are downloadable.
if let Err(resp) = guard::require(req, &resource_path, Role::Read).await {
return resp;
}
Download requires Role::Read — the same gate as viewing the page. If you can see the resource, you can download it; if you cannot, guard::require returns the standard 403 forbidden response and the handler short-circuits. There is no separate “download” role.
After the permission check the handler reads ctx.json, pulls ctx.get("type"), and matches:
singlemdlet item = SingleMd::read(resource_path.clone());
let body = item.read_body(&lang);
let file_name = format!("{}.md", item.file_slug(&lang));
content::binary_response(body.into_bytes(), &file_name, "text/markdown; charset=utf-8", true)
Serves the raw markdown source as an attachment. Filename is <file_slug>.md, MIME is text/markdown; charset=utf-8. The active language comes from crate::lang_for(req), so a /zh/...?view=download request returns the Chinese .md slot.
mdbookBundles every chapter’s .md source into a single .zip. Steps:
book.chapter_count() -> n. Empty book (n == 0) returns 404 No chapters to download.{:0width$}-<slug>.md where width = n.to_string().len().(name, bytes) pairs to crate::zip::archive_files_to_bytes.content::binary_response with MIME application/zip and attachment=true.The zero-padding is load-bearing: it ensures unzip preserves the book’s reading order via filename sort. A 12-chapter book produces 01-intro.md, 02-..., …, 12-conclusion.md; without padding, 10-... would sort before 2-....
The archive name is <folder_slug>.zip, where folder_slug is the last non-empty path segment of resource_path. If the path has none (e.g. /), the name defaults to "mdbook":
let folder_slug = resource_path
.trim_matches('/')
.rsplit('/')
.next()
.filter(|s| !s.is_empty())
.unwrap_or("mdbook");
filelet item = FileContent::read(resource_path.clone());
let bytes = item.read_bytes(&lang);
let fname = item.file_name(&lang);
let mime = content::guess_mime(&fname);
content::binary_response(bytes, &fname, &mime, true)
Serves the raw bytes verbatim under the original filename stored in ctx.json’s file slot. MIME is inferred from the extension via content::guess_mime.
Anything else returns 400 Download not supported for type "...". Container types (board, list, board_group, board_list) are not downloadable — they aggregate children but have no canonical serialized form. link resources are not downloadable either; the URL is the artifact.
content::binary_response shapeThe four-argument binary_response(bytes, filename, mime, attachment) is the universal byte-shipping helper. With attachment=true it sets Content-Disposition: attachment; filename="..."; with attachment=false it would inline-render. All three downloadable types pass true because the verb is explicitly “download.” (The inline-streaming path for FileContent with download_only=true lives in the default render branch, not here.)
singlemd and mdbook honor crate::lang_for(req) to pick which .md slot to serve when the underlying file field is a LangDict. Reaching ?view=download from /zh/handbook/ downloads the Chinese chapters; reaching it from /handbook/ downloads the default-language ones. See Language system for the cookie/prefix resolution order.
file does the same with FileContent::read_bytes and FileContent::file_name. If a particular language slot is missing, the LangDict falls back per its own rules (default language entry) — the download handler doesn’t have to know.
The per-type editors render a “Download” button when their template gets can_download = true. That flag is set by content::render after probing Role::Read on the resource. The button is a plain <a href="?view=download"> — no JS, no fetch — so it works with hreflang prefix URLs the same way the page itself does.
The per-type page templates (singlemd.html, mdbook.html, file.html) include the button; container templates (board.html, list.html) do not, matching the dispatch table above.
For the per-type field schemas that drive what gets packaged: Type: singlemd, Type: mdbook, Type: file. For the verb-routing entry point: Route: view (dispatch).