Type: file

A file resource wraps an arbitrary uploaded blob — PDF, ZIP, image, text — with a markdown description and a Download button. Implementation: src/content/file.rs.

Purpose

Surface a single downloadable file inside the doc tree. The view page shows the resource’s desc rendered as markdown plus a Download <filename> button; ?view=download streams the raw bytes. When download_only=true, the resource URL itself streams the bytes inline (no template wrapper).

ctx.json shape

Single-file form:

{
    "type": "file",
    "name": {
        "en": "Members directory",
        "zh": "成员名册"
    },
    "desc": {
        "en": "PDF list of current members.",
        "zh": "现任成员 PDF 名册。"
    },
    "file": "sf_members.pdf",
    "download_only": false
}

Per-language file form:

{
    "type": "file",
    "name": { "en": "Onboarding kit" },
    "file": {
        "en": "onboarding_en.zip",
        "ja": "onboarding_ja.zip",
        "zh": "onboarding_zh.zip"
    }
}

Field reference

FieldTypeRequiredNotes
typestring — must be "file"yesDiscriminator.
nameLangDictyesSee Common: Header and LangDict.
iconstringoptionalURL or path.
descOptionLangDict (markdown)optionalRendered above the Download button via fop::compile_markdown.
bannerOptionLangDictoptionalBanner image URL.
filestring OR LangDictyesFull filename including extension. Per-language form supports different files per locale.
download_onlybooloptional, default falseWhen true, GET on the resource URL streams the bytes inline (no page chrome). When false, the URL shows the desc + Download button.

file differs from the singlemd field of the same name: here the extension is included in the value (sf_members.pdf, not sf_members).

File-on-disk layout

programfiles/content/<resource_folder>/
    ctx.json
    <file>                ← e.g. sf_members.pdf, or onboarding_en.zip etc.

The blob lives at programfiles/content/<resource_folder>/<file>. Path-prefixing is done by fop::write_file_bytes and fop::read_file_bytes — they prepend programfiles/content/ to any path passed in. Never bypass these helpers with a direct std::fs::write on a resource-shaped path: the path is URL-shaped (no programfiles/content/ prefix), so a direct write resolves against the process CWD and fails with ENOENT. This is a real regression — write_file_bytes exists exactly to prevent it.

Render flow

content.rs::render checks download_only first:

  • download_only == true — call binary_response(bytes, fname, mime, attachment=false). The page chrome is bypassed entirely. The URL behaves like a direct file link.
  • download_only == false — call FileContent::render, which produces:
<div class="container-func">
    <div class="markdown-content">{compiled-desc-markdown}</div>
    <a class="btn btn-pink mt-3" href="?view=download">Download {file_name}</a>
</div>

Then wrap in banner + breadcrumb + SEO meta via general.html.

Editor capabilities

?view=edit (see Route: edit) allows:

  • Rename for the active language (rename).
  • Set/clear icon (set_icon — empty string clears).
  • Set/clear desc for the active language (set_desc — empty clears that language; clearing the last language drops the whole desc key from disk).
  • Set/clear file slug per language (set_file_slug — empty removes that language entry; setting introduces a new one).
  • Toggle download_only.
  • Link an existing file under the node — point file[lang] at a sibling filename without uploading.
  • Upload a new file — multipart POST with the file part. Uploads:
    • Run the original filename through sanitize_upload_filename, which strips path components (anything before / or \) and rejects ., .., and any ..* traversal patterns.
    • Auto-set the file slug for the active language to the sanitized filename.
    • Write the bytes via fop::write_file_bytes.

The active language is set via the navbar dropdown before upload — uploading in JA mode updates file.ja, not file.en.

Download behavior

?view=download streams the bytes with the original filename and a MIME guessed by content::guess_mime (PNG, JPG, GIF, SVG, PDF, ZIP, TXT, MD, JSON → known types; everything else → application/octet-stream). Content-Disposition is attachment for explicit downloads and inline when download_only=true. See Route: download.

Save semantics

FileContent::save() rebuilds ctx.json from scratch (it does not preserve unknown keys, unlike singlemd/mdbook). The serialised JSON contains:

  • type: "file"
  • The Header (name always; icon/desc/banner only when set)
  • file — current LangDict value
  • download_only: trueonly when true; the field is omitted when false, so a freshly defaulted resource looks clean.

Rebuilding from scratch is deliberate: it ensures cleared optional fields actually disappear from disk instead of lingering from the previous read.

Gotchas / common edit mistakes

  • Editing the file slug in JSON without renaming or replacing the blob on disk. The Download button will 404 — read_file_bytes returns an empty Vec and the response streams zero bytes.
  • Forgetting the extension in file. Unlike singlemd, file resources need the full filename. Without .pdf, MIME detection drops to application/octet-stream and browsers download a name-less file.
  • Uploading in the wrong language. The upload pathway always writes to the currently-selected language. Switch the navbar dropdown first.
  • Bypassing fop::write_file_bytes. Don’t. The function exists to prefix programfiles/content/; without it you get ENOENT.
  • Traversal in upload filenames. sanitize_upload_filename catches ., .., and ..*. Don’t disable it.
  • Toggling download_only on a non-renderable file. The dispatcher serves bytes with inline disposition. Browsers will try to render — they’ll succeed for PDFs and images, and offer download for unknown types.