Media library

Media library

Named storages — what they are, why they exist, every knob, and where to find the deeper docs.

Not verified yet

The CMS's media library is built around named storages you declare in .norcube/cms/config.yml. Each storage pins where bytes live on disk, what's allowed in, and what shape the value takes when it lands in your page's frontmatter. Image and file fields opt into a storage by name; the picker and upload UI gate on the storage's config.

This page is the entry point. The deeper concepts each live on their own page:

Why named storages

The pre-storages flow was "the editor's picker shows the whole repo and every upload goes to one hardcoded directory." Fine for the smallest sites; breaks the moment you have more than one kind of asset:

  • An icon library the designer maintains in src/assets/icons/*.svg — edited via PR, not the CMS. The editor needs a picker scoped to that directory, SVG-only, with no upload affordance.
  • User uploads going to public/uploads/, collision-safe filenames, served from the deployed site as /uploads/....
  • A public/favicon directory at the repo root that needs neither validation nor a special picker — just the legacy whole-repo flow.

Named storages let you express all three side-by-side, and let each field opt into the right one.

Minimal config

# .norcube/cms/config.yml
storages:
  uploads:
    type: repo
    path: public/uploads
    publicUrl: /uploads
    accept: image/*, application/pdf
    max_size: 10485760 # 10 MiB

defaultStorage: uploads
# In a schema block
hero:
  type: image
  storage: uploads

A field with no storage: falls back to defaultStorage. Sites with no storages: block at all keep the old whole-repo picker (so nothing breaks on migration).

Storage knobs

Every storage takes the same set of common knobs plus driver-specific ones.

KnobValuesEffect
typerepoThe only shipped driver. R2 / S3 / GCS are scaffolded but rejected at resolve time.
pathrepo-relative directoryWhere the bytes live on disk. Leading / trailing slashes are trimmed.
publicUrlURL prefix (or empty)Drives the frontmatter shape. See Frontmatter shape.
moderead-write (default), read-onlyread-only hides the upload button AND rejects POSTs server-side.
acceptMIME glob list (image/*, image/svg+xml)Picker filter + server-side upload validation.
max_sizebytesServer-side pre-upload check; the picker hints the limit on the dropzone. A hard 25 MiB ceiling applies on top of this regardless (see below).
namingslug, slug+hash, uuid, empty (= original)Server-side filename derivation. See Naming + dedup.
inlineref (default), svgsvg inlines the markup into frontmatter. See Inline modes.
deduphashShort-circuit identical uploads. Requires naming: slug+hash. See Naming + dedup.

Read-only storages

mode: read-only hides the upload affordance in the picker AND rejects the POST server-side. Use this for asset libraries maintained outside the CMS — designer-owned icon sets, build-time-generated thumbnails, anything where the authoritative writer is git itself.

storages:
  icons:
    type: repo
    path: src/assets/icons
    accept: image/svg+xml
    mode: read-only

The server-side check is the trust boundary — hiding the button alone would let a determined caller bypass via a direct POST /sites/:id/media. Both gates run.

Upload reliability

Uploads go through a local mirror of your repo — the CMS commits the file and pushes to your branch via git itself, not the GitHub Contents API.

Why: GitHub's Contents API caps the request body around 4.8 MB, and uploading a binary requires base64-encoding (adds ~33% to the payload), so a 3.6 MB image is already at the cap. Above that the API returns 502s that surface as an unhelpful HTML "Unicorn!" page — not a useful error.

Plain git handles binary natively, no base64 envelope. This is transparent to you as a user: same upload button, same progress bar, files just don't fail past a megabyte.

There is still one absolute ceiling. Every upload is capped at 25 MiB regardless of a storage's max_size — a larger file is rejected with a 413 before anything is committed. Set a lower per-storage max_size when you want a tighter limit; 25 MiB is the hard upper bound you can't raise from config.

Default storage

defaultStorage: controls which storage a field with no storage: key inherits. When unset, the first declared storage is the implicit default (YAML map order is preserved through the loader).

storages:
  uploads:
    type: repo
    path: public/uploads
    publicUrl: /uploads
  icons:
    type: repo
    path: src/assets/icons
    mode: read-only
    inline: svg

defaultStorage: uploads # explicit; otherwise "uploads" because declared first

Pick defaultStorage: explicitly when the order of your storages might change for unrelated reasons — config-editor reorders, alphabetisation, etc. The explicit value is stable; the implicit "first declared" tracks authoring order.

The Media library page

The Media item in the CMS sidebar is a full-page browser over your storages — the same files the field picker shows, with room to manage them.

  • Storage switcher. Sites with more than one declared storage get a selector; the active storage is reflected in the URL (?storage=) so a view is shareable. Sites with no storages: block browse the legacy media folder instead.
  • Folders. Navigate into subdirectories with a breadcrumb back to the storage root. The current folder is in the URL (?path=).
  • Search. Typing in the search box switches from folder browsing to a recursive, storage-wide search by filename — matching files surface with their full path, plus the folders that contain them. Clear the box to go back to browsing.
  • Upload lands in the folder you're viewing (not just the storage root), with a live progress bar and an optimistic tile.
  • New folder. Because git can't track an empty directory, a new folder is materialised with a hidden .gitkeep placeholder — you never see it; the listing filters it out.
  • Rename / move and delete (files and folders; folders recurse) commit straight to your branch. Rename refuses to overwrite an existing file. Delete is idempotent.
  • Preview. Clicking a file opens a detail dialog — a large preview for images, plus Copy reference path, which gives you the exact value an image/file field would store (honouring the storage's publicUrl).

Previews and thumbnails are served through short-lived signed URLs, so the library can render private-repo assets without exposing them publicly. The links are minted on demand and expire on their own.

Read-only storages (mode: read-only) show the library for browsing but hide upload, new-folder, rename, and delete — the same server-side gate that rejects uploads also rejects these mutations.

What's not there yet

Storage features on the list but not shipped:

  • R2 / S3 / GCS drivers. Only type: repo (files in git) ships today. Cloud-object drivers are on the way but not wired.
  • optimize.svgo / optimize.webp / optimize.resize — server-side optimisation pipeline for images on upload.
  • cdn.rewrite_to — path substitution beyond what publicUrl already covers.
  • versioning: append-hash — cache-busting via query-param suffixes on stored URLs.

On this page