HERON

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.uses lists the sections the type shows. It is an agreement with the content: heron page service roof puts 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 in uses is a line in the report: this is how a typo in an anchor gets caught. "how:steps" means the theme expects the steps form in that section.
  • requires lists paths in site.yaml the theme cannot work without: if they are empty, the build stops with error E011 before rendering.
  • links are link fields. HERON finds the pages, checks them and builds the reverse list (back). type in a link limits which page type the field leads to: related with type: service will not link articles to each other. A link without a limit has no type: - {field: related, back: related_from}.
  • images sets the widths, proportions and formats of cut images and fallback_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, include and import start 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" %} in templates/landing.html gives error E008 with a hint of where the file really is.
  • mod is available in every template, so modules need no import. Your own macros are different: an {% import %} in base.html is 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 build renders 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 missing i18n texts;
  • heron check gives 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.