HERON

Data contract · part 5

Images, files and rules

The fifth part of the data contract: images, video and files, the static/ folder, what a theme requires from HERON, service secrets and the rules a site never breaks.

This page is for people who put photos and documents on a site and for theme authors.

In plain words

Put your photos, videos and documents into the media/ folder and refer to them from a page. HERON does the rest: it makes light copies of every photo in several sizes and modern formats (WebP, AVIF), so a phone downloads a small picture and a large screen gets a sharp one. You never resize anything by hand.

Example. One photo on a page:

![Our team on a roof in Vienna](media/examples/photo.jpg)

From this one line HERON makes several versions of the picture and a tag that lets the browser choose the right size. The text in square brackets is the description for people who cannot see the picture and for search engines; always fill it in.

Two more things live here. static/ is for the rare files that must stay exactly as they are (a verification file from Google, for example). .env holds secrets for the server and never goes into the site folder or into git.

When you need this page. When you add photos, video or PDF files, when a picture looks cropped, or when you set up the server.

Images

media/ is one shared folder for images and video, not split by language. The link and alt live in the md file, so different languages can use different files — for example a scan of a document in each language.

  • links in md are relative to the root: media/services/design.png;
  • alt is required. An image without alt is a build warning;
  • HERON makes versions for screen sizes itself, converts to WebP and AVIF, and sets width, height, srcset and loading="lazy" except on the largest image of the first screen (LCP). Originals are not touched;
  • only what something links to gets into the build. The ratios in images.ratios are what the theme may ask for; what gets cut is what templates asked for with picture() and media_url(). There is one fallback file in the original format per ratio, images.fallback_width wide (800 by default). The full master goes into the build if something links to it (media_url(src), og:image, an image in the page text); otherwise it is not there;
  • og_image is not resized or converted: social networks expect a 1200×630 JPEG or PNG.

The resources folder is called media

It used to be called img. The name promised only images, so there was nowhere to put video: it ended up in static/ and went into the build without any processing. Now media/ holds both.

The move is a one-time pass over the site, and until it is done, the site must still build: HERON has no right to break someone's work for the sake of its own rename. So:

  • there is media/: quiet, this is normal;
  • there is only img: builds as before, one warning about the rename;
  • there are both: E018, the build stops. This is not a transition state but a fork: HERON does not know which folder holds the truth, and guessing costs more;
  • there is media/ and also static/media: also E018. Static files are copied to the root, so both folders aim at the same addresses. A name clash there is a silent overwrite that people notice only once it is live.

Paths inside the site are written from the site root: media/examples/photo.jpg. The ./media/… form is accepted too.

Where HERON looks for files

An image is recognised by its value, not by the field name: HERON does not guess names, and the theme may name its field however it likes. A value counts if it is a path that starts with the media folder and ends with a known extension; a line of prose does not qualify, because the whole value is checked, not a substring.

media/ holds any content file, not only images: a video, a PDF leaflet, a presentation. HERON cuts and converts images, and copies everything else as is. The list of processed extensions stays explicit, but a file outside it is not an error. Otherwise every new file type would need a HERON change, and until then it would sit in static/, bypassing every check.

Only declared files get into the build. A file in media/ that nothing mentions goes nowhere and shows up in the report as "files in media nobody expects". It is not an error — masters are often added before the page is written — but it is no reason to hoard old versions in the folder for years.

A file over 10 MB gives a warning with the page name, whatever its type. It is not an error: a twenty-minute report happens. But a person should learn about it at build time, not from a visitor on mobile data.

Files are looked for wherever a theme can declare them:

  • the image and og_image fields;
  • any extra front matter field, at any depth: a string, a map, a list, a list of maps, a map with lists inside. No need to keep paths and captions in two parallel lists tied only by order;
  • the page body: ordinary markdown image links;
  • data/ reference files: site-wide blocks live there, and an image from there is just as real. An error message names the data file as the owner.

The depth of the walk is limited: YAML anchors create shared objects, and a limit is cheaper than one day catching a walk that loops forever.

A value with its own scheme (https://…, //…) is not a site image: it is someone else's file, and HERON does not look for it on its own disk.

A typo in a path gives E007 with the page name, in a nested field too. Such an image used to vanish silently: no error, no warning, just an empty space on the page.

What not to cut

A theme may say that files under a certain path should not be touched:

images:
  ratios: ["1:1", "16:9", "4:3"]
  widths: [400, 800, 1200, 1600]
  formats: [avif, webp]
  as_is: [media/docs/]

Such a file is copied into the build as is: no versions, no conversion. A scanned document is not an illustration: people open it whole and in one form, and it needs no responsive sizes. Three ratios times four widths in two formats is two dozen files per scan that nobody will ever request, and minutes of build time per dozen.

The path is written as a prefix, like crop. The existence check, E007 and the size warning work as for any other declared file.

A file address for the theme: media_url

A theme used to get only a ready picture element from HERON. There was no way to get a file's address as such — and it is needed all the time: an "open the scan" link, the href of a modal window, a download address, an image in social markup, a link to a document from the text. Without it the file was duplicated in static/: two places you had to remember to fix together.

{{ media_url('media/examples/scan.webp') }}
{{ media_url('media/examples/photo.jpg', ratio='4:3', width=1200) }}
{{ 'media/examples/leaflet.pdf'|media_url }}
  • without parameters: the address of the file itself in the build. That is exactly why the master is copied into the build next to its versions;
  • with ratio: the address of a cut version; width takes the nearest size not smaller than requested, and if there is none, the largest: serving a larger one and letting the browser shrink it is more honest than showing a blur;
  • format picks the version's format; without it, the master's original format, because this is an address for a link or a download, not for picture;
  • if the requested version does not exist: a warning with the path, and the address of the original. Silently returning an empty string is not allowed: that is exactly how images get lost, and people notice it by eye a week after release;
  • if the file is not on disk: the same E007 as for any declared file;
  • it works for files HERON does not process as well: PDF, video, archive.

Video

video: media/examples/tour.mp4
video_poster: media/examples/tour-poster.jpg
video_title: "Workshop tour"

This reaches the template as a ready page.video object with src, poster, title and external fields. The theme does not parse address schemes itself: today it is a local file, tomorrow a third-party host, and every theme would have to learn this again.

  • the video value can be an external link, told apart by the address scheme. A local path is served as a file, a full address as an embedded third-party player. The list of platforms is not hardcoded;
  • video_poster is required with a local file, otherwise E019. Without a poster the browser fetches the first frame, which means loading the video before the visitor pressed play; on mobile data that is tens of megabytes for viewing a page people scrolled past. A third-party player has its own poster, so nothing is required there;
  • video is not re-encoded or cut; it is copied as is. The file and the poster are checked for existence;
  • no video field: page.video is empty and the block is not drawn;
  • a {#video} section in the content gives a caption and description; the file itself comes from front matter.

Illustrations are fitted, photos are cropped

The repository holds one file per item: a master of any ratio with a transparent background. HERON fits it into every ratio the theme asks for and pads it with transparent margins to the required size:

4:3    master, in media/ — any ratio
16:9   catalog and home page card — fitted, transparent margins
1:1    square slot — fitted, transparent margins

Cropping illustrations is forbidden. Photos — portraits, interiors, works — are cut with a centre crop, as before: they have no alpha channel and nowhere to take margins from. There is one sign: alpha means fit, no alpha means crop. The theme may name exceptions explicitly.

The "object touches the edge of the master" warning is gone: with fitting, that stopped being a problem.

Why. A square master with room around the edges is a requirement illustrators do not meet: the drawing almost always reaches the frame edge, and any centre crop cuts it off. Redrawing files for each ratio costs more than fitting, and fitting gives the same result without the margin.

Transparency and themes

Illustrations are stored with an alpha channel (PNG or WebP) and placed on a backing the theme provides. Consequences:

  • JPEG is forbidden for illustrations: no alpha, so the white plate stays forever and glows as a rectangle in the dark theme. JPEG is only for photos;
  • filling the background with the theme colour is not allowed: it is the same rectangle, just coloured;
  • a white background is allowed only as an intermediate step of generation: it is easier for a model to draw a clean outline on a flat background than to produce correct alpha right away. What goes into the repository is the cut-out;
  • no second file for the dark theme. One image works in both if the body of the object is light and the outline is of medium lightness: a dark outline on a dark background gives a contrast of about 2:1, and the silhouette blurs. Specific tones are set by the theme's document, not this one;
  • the backing under an illustration is always neutral. A card in the object's colour swallows the silhouette;
  • photos in the dark theme are not inverted or darkened with a filter.

static is an emergency exit, not a second image folder

static/ is copied into dist/ byte for byte at the same path: no processing, no checks, no mention in content. That is its only purpose and the only reason it exists.

It is for files that must sit at an exact address and that HERON must not touch:

  • webmaster ownership verification;
  • .well-known/security.txt;
  • ads.txt, payment system verification files;
  • site.webmanifest, favicon.

Images, video and documents do not belong there: their home is media/, where they get an existence check, versions for screen sizes and a clear error on a typo. Site design — the logo, interface icons, fonts — does not belong there either: its home is theme/assets/, because it is part of the theme, not of the content.

The rule does not rely on good faith: heron check names any file with an image or video extension found in static/ and suggests moving it to media/. Without this, in six months there would be photos there again: dropping a file past processing is always faster than declaring it in content.

Example. Google asks you to prove you own the site with a file at the root. Put it into static/, and after the build it answers at the same address:

static/google1a2b3c4d.html          →  https://example.com/google1a2b3c4d.html
static/.well-known/security.txt     →  https://example.com/.well-known/security.txt

An empty static/ is the normal state of a site. Most sites have nothing there.

What a theme expects from HERON

A theme lives in the site's repository and is updated separately from HERON. So it declares its requirements right in theme.yaml:

heron: ">=0.1"          # the HERON version the theme is designed for
fields: [nav_title]     # front matter fields the templates use

HERON checks this at the first stage, before reading content. Wrong version: E012; a field in fields that HERON does not know: E023 with the list of fields, instead of one error per page with the useless hint "typo in front matter".

fields is only for HERON's own fields (the front matter table). The theme's own fields (topic, eyebrow) do not go there: HERON does not know them and should not. In a template they are available as page.meta.get('topic').

Both keys are optional. A theme that declares nothing builds as before, until the first mismatch, which then shows up at render time.

.env

The build needs no secrets at all. It runs without network access and reads only the site's files. .env belongs to the services — heron-relay, later the admin panel — and is never mounted into the build container.

The file is set up right away so nothing has to be redone later.

SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_PASSWORD=
TELEGRAM_BOT_TOKEN=
ADMIN_USER=
ADMIN_PASSWORD_HASH=
SESSION_SECRET=
DB_PATH=

Rules of handling:

  • .env is in .gitignore and .dockerignore. The repository holds .env.example with empty values;
  • it is mounted as a file, not through environment: and not through --env-file: environment variables are visible in docker inspect to anyone with access to the socket, a mounted file is not;
  • it is read at the moment of use, not at start;
  • it is mounted only into the container that needs it. nginx knows nothing about mail;
  • the admin password is stored as a hash, not in plain text;
  • secrets never reach the logs. The most common leak is not a hack but a traceback on a connection error.

Separately: to send mail, use the mail service's API key, not the SMTP password of the mailbox. A leaked key means spam from your domain. A leaked mailbox password means access to all of the owner's mail.

What is generated and what is placed by hand

Generated by HERON in dist/, absent from the repository:

sitemap.xml (on a multilingual site, an index and one file per language) · robots.txt · llms.txt (on a multilingual site, also one per language) · 404.html · redirects.map for nginx · JSON-LD inside pages · hreflang and canonical addresses · feed.xml once there are articles.

Placed by hand in static/: the favicon and its variants · site.webmanifest · .well-known/security.txt.

A robots.txt placed by hand starts lying within six months: addresses change, the file does not. The same happens to a sitemap built by a third-party plugin.

Rules that must not be broken

  • No JavaScript to display content. Scripts serve only the theme, the cookie banner, the mobile menu and the FAQ accordion. Numbers and text must be in the HTML: a counter drawn by a script is worth zero to a robot;
  • one page, one H1, and a heading hierarchy without skipped levels;
  • an empty block is not output. A heading with no content is worse than none;
  • facts are not invented. Data the client has not confirmed stays missing and goes to the debt summary instead of being filled with plausible text. An empty block is better than fiction;
  • the target action is available from any page. This is a theme requirement, and the theme is responsible for meeting it on every template.

Source

This page mirrors sections 7–10 of docs/spec/20-data-contract.md on the prod branch. The repository is updated first: open the file on GitHub.