Images

One original per image, one sidecar beside it, and everything else derived. A person can change any part of that by editing a file, without knowing how the pipeline works.

Adding an image

cms media add ~/photos/pylon-single.jpg

That copies the original into src/media/, reads its dimensions, and writes a sidecar stub. It is idempotent: running it twice on the same file replaces the original and leaves the sidecar alone.

By hand, it is a copy and a text file, which is the point:

src/media/pylon-single.jpg
src/media/pylon-single.yml

The sidecar

# pylon-single ยท 787x1400
alt: A tall platform pylon display showing a route badge, a service alert, and a
  list of live departure times with minutes remaining.
decorative: false
contains_text: true
The sidecar
Key Meaning
`alt` What a reader who cannot see the image needs to know. Required unless `decorative`
`decorative` `true` only when the image conveys nothing the text does not. Renders `alt=""` and `aria-hidden`
`contains_text` Flags the image for the WCAG 1.4.9 attestation. Screenshots of a real interface are permitted; text set as a picture is not
`kind` `logo` relaxes one rule: a logo's alt is the organisation's name, which is usually also its filename. That is correct for a mark and wrong for a photograph, so the sidecar says which rather than the check guessing
`sizes` Overrides the slot width the component declared. See below
`widths` Overrides the derived width set entirely. Exact control

Alt text is a build requirement, not a lint. A referenced image with no alt and no deliberate decorative: true fails the build. So does alt text that is just the filename.

Images written in markdown

An image written as ![](pylon-single) inside a prose or markdown field goes through this pipeline like any other: same derived widths, same formats, same alt text from the sidecar. Referencing a name that is not in src/media/ is a build error, not a broken image.

This was not always true, and the consequence is worth recording. Markdown images used to emit a bare <img> with no dimensions, no srcset, and no alt validation, which was a hole straight through every guarantee the pipeline exists to provide. It was found by comparing a rebuilt page against the live one and noticing the rebuilt version shipped the original file.

Prefer shots or features anyway. Both carry a caption and a declared layout slot, and the slot is what lets the build compute sizes rather than assume it.

SVG

A vector needs no variants: it is already resolution-independent. An SVG is content-hashed and copied through, and its dimensions come from the file's own width and height or its viewBox. An SVG with none of those is an error, because without them the browser cannot reserve space for it and every load shifts the layout.

Everything else applies unchanged: alt text is still required, and the file still counts against the page's weight.

How widths are chosen

Every component that takes an image declares the CSS width its slot occupies at each breakpoint. shots, for example:

media: { type: media, required: true, sizes: "(min-width: 60rem) 33rem, calc(100vw - 3rem)" }

That one declaration produces both the sizes attribute in the markup and the widths to generate. The engine expands each clause at four viewport widths, doubles each for 2x screens, drops any width within 1.3x of a larger one because the extra file never pays for itself, and caps at the original's own width.

Because the component set is closed and the stylesheet is known, sizes is computed rather than guessed. Guessed sizes is the most common reason a responsive image still ships four times the bytes it needs on a phone.

To see what happened for any image:

cms media explain live-map-rail
live-map-rail
  original    1800x1181
  sizes       (min-width: 60rem) 33rem, calc(100vw - 3rem)
  from        component slot
  widths      312, 528, 720, 1056, 1440, 1800
  formats     avif, webp, jpg   18 files

Overriding it

The derivation is a good default, not a policy. Two levels of override, both in the sidecar, both reported by cms media explain as coming from there:

sizes: "(min-width: 60rem) 40rem, 100vw"    # this image sits in a wider slot
widths: [400, 900, 1800]                    # exact control, derivation skipped

Use sizes when the layout is what differs. Use widths when you know something about the image the layout does not, such as fine detail that needs a larger top end than the slot alone would ask for.

What gets written

public/assets/img/pylon-single.9780b5e78266-312.avif
                  <name>.<content hash>-<width>.<format>

AVIF, WebP, and a JPEG or PNG fallback, in that order, because a browser takes the first source it understands. The markup carries width and height so nothing shifts on load. The first image on a page is never lazy and gets fetchpriority="high", because it is almost always the element the page's LCP is measured against; everything after it is lazy.

What is committed, and what is not

What is committed, and what is not
Committed Why
`src/media/*` originals and sidecars yes They are the source
`.cms/media.lock.json` yes So a build knows what should exist without running sharp
`.cms/media/` variants no Derived, and large
`public/` no Build output

The lock file is generated and says so in its own first key. Edit the sidecar, never the lock file.

Variants are keyed by content hash, so an unchanged image is never re-encoded, locally or in CI. CI restores the cache by hash and generates only what is missing.

In development

cms dev skips AVIF, which is by far the slowest encode. The dev loop gets WebP and the fallback, the real build gets all three. Nothing else differs, so what you see locally is what ships apart from a format the browser would have preferred.

Next

Continue with fonts.