Guide 04 · for designers and front-end developers
Theme and design
How a theme is built, how to make your own and what a template sees. For a designer and a front-end developer: a person or an AI agent.
The theme decides how the site looks; the content decides what it says. The theme knows no text of any page, the content knows no CSS class. They meet by names: the page type picks a template, the section anchor picks a module.
In plain words
The theme is the clothes of the site; the pages are the person. The same person can wear different clothes, and the clothes never decide what the person says. HERON keeps them apart on purpose: texts never go into the theme, and the theme never contains texts.
How they meet. Every page has a type (home, page, article…), and the theme has a template for each type. Every section has a form (list, steps, table…), and the theme has a small module for each form. When HERON builds a page, it picks the template by the type and draws each section with the module of its form.
Example. A section ## Prices {#prices} with a table inside is drawn by the theme's table module. Switch to another theme with its own table module, and the same prices appear in a new design, without editing the page.
You need this page if you make or change a theme. You can skip it if you only write pages: the theme you have already draws them.
What a theme is made of
theme/
├── theme.yaml the passport: page types, their sections, links, images
├── base.html the frame: head, header, footer, the content block
├── templates/ one template per type: home.html, page.html, service.html, 404.html…
├── modules/ one file per module: prose.html, steps.html, faq.html…
├── partials/ pieces of the frame: header.html, footer.html, analytics.html
├── assets/ CSS, JS, fonts: copied to dist as they are
└── i18n/LANG.yaml interface texts: en.yaml, ru.yaml, «Read more» / «Читать далее»
heron new puts in the starter theme: the types home, page, 404 and the modules breadcrumbs, cards, facts, faq, list, prose, steps. Its job is to make the site build from the first minute, not to be lived in. Your own theme starts as a copy of it.
theme.yaml
name: main
version: "1.0"
requires: [contact.phone, organization.name] # what the theme takes from site.yaml
modules: [breadcrumbs, prose, facts, list, steps, faq, cards]
types:
home:
uses: [intro, about, services, faq]
service:
uses: [intro, quick-facts, what, "how:steps", faq]
jsonld: [Service]
page:
uses: [intro, "*"] # "*" means any sections
links: # links between pages
- field: related
type: service
back: mentioned_in
images:
widths: [400, 800, 1200, 1600]
ratios: ["1:1", "16:9"]
formats: [avif, webp]
types.TYPE.useslists the sections the type shows. It is an agreement with the content:heron page service roofputs in drafts of exactly these sections, and the «Not filled in» summary after the build names the empty ones. A section in a file that is not inusesis a line in the report: this is how a typo in an anchor gets caught."how:steps"means the theme expects thestepsform in that section.requireslists paths insite.yamlthe theme cannot work without: if they are empty, the build stops with errorE011before rendering.linksare link fields. HERON finds the pages, checks them and builds the reverse list (back).typein a link limits which page type the field leads to:relatedwithtype: servicewill not link articles to each other. A link without a limit has notype:- {field: related, back: related_from}.imagessets the widths, proportions and formats of cut images andfallback_width, the width of the fallback PNG or JPEG. Only what the templates asked for gets cut.
Every key in detail: theme.yaml in the specification.
The template of a type
The service type is drawn by templates/service.html. The order of the blocks reads from top to bottom:
{% extends "base.html" %}
{% block content %}
{{ mod.breadcrumbs() }}
<h1>{{ page.h1 }}</h1>
{{ mod.facts('quick-facts') }}
{{ mod.prose('what') }}
{{ mod.steps('how') }}
{{ mod.faq('faq') }}
{% for p in page.related.get('related', []) %}
<a href="{{ p.url }}">{{ p.h1 }}</a>
{% endfor %}
{% endblock %}
mod.prose('what') takes the section with the anchor what. No section, nothing at all: no heading, no empty frame, no conditions needed around it. That is why the order of sections in the content does not matter, and a page can lack sections without harm to the layout.
A template that does not know in advance which sections will come picks the module by the form. This is how page.html of the starter theme works:
{% for section in page.sections.values() %}
{% if section.kind == 'faq' %}{{ mod.faq(section.id) }}
{% elif section.kind == 'steps' %}{{ mod.steps(section.id) }}
{% else %}{{ mod.prose(section.id) }}{% endif %}
{% endfor %}
Paths and macros
- Paths in
extends,includeandimportstart from the root of the theme, not from the folder of the template:{% extends "base.html" %},{% extends "templates/page.html" %},{% include "partials/header.html" %}. Writing{% extends "page.html" %}intemplates/landing.htmlgives errorE008with a hint of where the file really is. modis available in every template, so modules need no import. Your own macros are different: an{% import %}inbase.htmlis not visible to child templates (this is how Jinja2 works), so import them in every template that needs them.
A module
A module is the file modules/NAME.html and is called as mod.NAME(anchor). Inside it you have:
| Variable | What it is |
|---|---|
section |
the section: id, title, kind, lead, body, note, callout, links |
items |
the parsed data of the form: steps, question and answer pairs, facts… |
html |
the ready markup of the whole section |
| everything a template sees | page, site, t, picture… |
<section id="{{ section.id }}" class="faq">
{% if section.title %}<h2>{{ section.title }}</h2>{% endif %}
{% for pair in items %}
<details><summary>{{ pair.q }}</summary>{{ pair.a|safe }}</details>
{% endfor %}
</section>
What items holds for each of the twelve forms is in the section forms table, and the forms reference shows how this site's theme draws each of them. Modules without an argument (mod.breadcrumbs()) take their data from page and site.
What a template sees
| Name | What it is |
|---|---|
page |
the page: h1, title, url, type, intro, sections, children, parent, breadcrumbs, translations, related, meta.FIELD |
site |
the whole site.yaml, values already in the language of the page: site.site.name, site.contact.phone |
data |
the contents of data/*.yaml |
nav |
the menu from site.yaml, already as pages: item.url, item.nav_title |
t |
interface texts of the current language: t.read_more |
lang, languages, hreflang |
the current language, all languages, addresses of translations |
page_of(key) |
a page by its key in the current language; the home page is page_of('/') |
picture(src, ratio) |
a ready picture element with srcset, WebP and AVIF and sizes |
media_url(src, ratio, width) |
the address of one cut image |
jsonld() |
the JSON-LD markup of the page, to put into the head |
env |
the build environment: env.indexable, env.analytics |
The full page model and every variable: build and rendering in the specification.
Calling a variable that does not exist stops the build with the template name and line instead of printing nothing: a typo in the theme is caught at once.
Your own page fields
A theme can expect its own fields in the front matter: topic, eyebrow, anything. They need no declaration: HERON puts them into the page as they are.
{% if page.meta.get('eyebrow') %}<p class="eyebrow">{{ page.meta.get('eyebrow') }}</p>{% endif %}
<span>{{ page.meta.get('topic', 'general') }}</span>
page.meta.get('field', fallback) does not fail when a page has no such field. page.meta.topic does fail: that is how typos in HERON's own fields are caught. All your own fields at once are page.meta.extra.
fields in theme.yaml is only for HERON's own fields that the theme expects from a newer version. Your own fields do not go there: HERON does not know them and answers with E023.
Values in the language of the page
A template gets site.yaml already in the language of the page, every block, at any depth: on a Russian page site.contact.address is address_ru, site.site.name is name_ru, site.organization.about is about_ru. No key for the language means the key without the suffix is used. You do not need a filter to pick the language; the keys with suffixes stay available too.
The same rule works in markup substitutions ({{ contact.address }} in the schema block), so the address in the footer and in the markup never disagree.
Interface texts
Texts that belong to the theme and not to a page live in theme/i18n/LANG.yaml:
# theme/i18n/en.yaml
read_more: Read more
faq: Questions and answers
# theme/i18n/ru.yaml
read_more: Читать далее
faq: Вопросы и ответы
In a template it is {{ t.read_more }}. A missing text does not stop the build: the page shows the key name and the report has a warning. Text written straight into a template makes the theme one-language; keep it in i18n.
Pictures in a theme
{{ picture(page.meta.image, '16:9', alt=page.meta.image_alt) }}
HERON gives cut images for the widths and proportions from theme.yaml, fits illustrations and crops photos. The path itself comes from the content; the theme decides only the proportion and the place.
Only what a template asked for gets cut. ratios in theme.yaml are proportions the theme may ask for; the build gets only those that templates really called through picture() or media_url(). A card asks for 16:9, so its picture gets only 16:9 in the build. The fallback file in the original format (for browsers without AVIF and WebP) is one per proportion, images.fallback_width wide (800 by default). Need a wider one? Ask for it directly: media_url(src, '16:9', 1200) for og:image.
In detail: pictures and files.
How to check a theme
heron buildrenders the theme on every page; template errors name the file and the line;- the report after the build lists sections missing from
uses, forms of the wrong kind and missingi18ntexts; heron checkgives the same report without building.
Source
This page mirrors docs/guide/04-theme.md from the prod branch. The repository is updated first, so the file there is always the latest version.
Next guide: SEO and languages.