Statac

Shape the site

Templates

A template is a file in templates/ that pages are built in: HTML, with places for each page’s values.

Write a template

In templates/page.html:

<!doctype html>
<html lang="{{ page.language }}" dir="{{ page.direction }}">
<title>{{ headline(page) }} - {{ site.title }}</title>
{% if page.title %}<h1>{{ page.title }}</h1>{% endif %}
{{ page.content }}
</html>

{{ ... }} shows a value. {% if ... %} shows a part only where something is so, and {% for ... %} shows it once for each item. The language is Jinja, as MiniJinja reads it.

A value is written with <, > and & made safe. page.content is HTML already and goes in as it is, as does a value passed through safe.

Two things on a built page are Statac’s own until a file of yours takes their place:

What Statac writes Yours in its place
The colours for code code.css, published with each build assets/code.css
Six pieces of the writing: headings, links, code blocks, inline code, a footnote’s marker and the notes at the foot Plain HTML A file for the piece in snippets/markdown/

Which template builds a page?

A page is built with the first of these:

  1. the template its own layout: names, as layout: post;
  2. the template its type: names, where templates/ has one;
  3. a layout: handed down from its folder or statac.yaml, as pages.layout: post, unless the page speaks for a list;
  4. home for the home page, folder for any other page that speaks for a list, and page for the rest.

The pages that speak for a list are a folder’s index.md, a tag’s or a series’ page, a page with posts: or each:, and the later pages of each: see the page at a list’s address.

On a page with each:, layout: names the template of the pages it makes, not its own: see make a page for each item.

A template is named by its file’s name without .html: post is templates/post.html. One for another kind of file keeps its ending, as layout: sitemap.xml, and what it shows is made safe for that kind: as markup in HTML and XML, as JSON values in .json, and not at all in .txt.

Statac also looks for two templates by name:

Template Builds
templates/feed.xml Each list’s feed. A feed at another ending, such as /feed.json, is built with the template of that name, templates/feed.json.
templates/redirect.html The small page at each old address, and each page with redirect_to.

Share a frame with extends

In templates/post.html:

{% extends "base" %}
{% block main %}
<article>
{{ page.content }}
</article>
{% endblock %}

templates/base.html holds the whole page, with {% block main %}{% endblock %} where each page’s own part goes. Each block takes the place of the block of the same name there.

Statac has no include, import or from. For a piece used in many templates, such as a post in a list, write a snippet and call it: {{ card(post) }}.

What a template is handed

Template Is handed
Any page’s page, the page being built, and site, the same on every page.
A feed’s list, the list; posts, its dated posts, newest first, whatever order the list keeps; page, the page speaking for the list; and site.
redirect.html to, where readers are sent; title; page, the page writing the redirect; and site. See a redirect page.
A snippet What its call hands it, and page and site.

Every page a template meets has the names page has, so post.url works as page.url does. In a snippet too, page is the page being built, not the post a call hands it.

Serve’s panel shows every name a template can write on a page, with its value there.

The site’s words

{% set words = site.data.words[page.language] %}
<a href="#content">{{ words.skip }}</a>

Statac hands a template nothing called words. A new site keeps the words its pages show in data/words.yaml, under each language’s code, and each template that shows one reads them with the set line above. The line in base.html doesn’t reach a template that extends it. See the words your pages show.

When a page has no such value

{% if page.summary %}
<p>{{ page.summary }}</p>
{% endif %}

Where a template shows a value a page doesn’t have, Statac stops rather than publish an empty space. Say what a page without it shows, as above, or show nothing with {{ page.summary | default("") }}. Defaults chain: {{ page.description | default(page.summary) | default("") }}.

A filter such as Does with a page without the field
selectattr("group", "eq", "a"), rejectattr("group", "eq", "a") selectattr leaves it out, and rejectattr keeps it.
sort(attribute="rank") Puts it after the rest, in the order it came.
groupby("rank"), unique(attribute="rank"), map(attribute="rank") Stops. Leave such pages out first with selectattr("rank", "defined").

A test such as {% if page.subtitle is defined %} never stops: it may ask about a field no page has yet.

How the built page’s source is laid out

<main>
    {{ page.content }}
</main>

builds as

<main>
    <p>The first paragraph.</p>
    <ul>
      <li>One</li>
      <li>Two</li>
    </ul>
</main>

Statac adds no indentation and no blank line of its own, and takes none of yours away.

  • A line holding only a template tag, such as {% if %} or {% for %}, leaves nothing behind.
  • A value on a line of its own, such as {{ page.content }} or a snippet call, has each of its lines indented as that line is. Where it comes to nothing, the line isn’t written.
  • Lines inside <pre>, <textarea> and an attribute’s value are left exactly as they are.

What a template sees on page

Name What it gives Example
page.title The title in the page’s front matter. A page Statac makes for a list has none. <h1>{{ page.title }}</h1>
page.summary The summary in its front matter. {{ page.summary | default("") }}
page.date, page.updated Its dates, shown in the site’s date_format, each with its year, month, day and weekday. <time datetime="{{ page.date | moment }}">{{ page.date }}</time>
page.content The writing, built into HTML. Shown on another page, as in a list or a feed, its relative addresses are written out in full: see links. {{ page.content }}
page.excerpt The start of the writing, built into HTML: up to its <!--more--> line, or else its first paragraph. A page with neither has none. See excerpts. {{ post.excerpt }}
page.excerpt_marked true where the excerpt ends at a <!--more--> line. {% if post.excerpt_marked %}
page.opening The words of the first paragraph, as text with no markup. A page with no paragraph has none. {{ page.summary | default(page.opening) | default("") }}
page.words How many words a reader reads, code blocks and footnote markers left out. In Chinese and Japanese, each character is one. {{ [1, page.words // 200] | max }} min read
page.maths true where the writing holds maths. A list showing excerpts asks each post, post.maths. {% if page.maths %}{{ css("maths.css") }}{% endif %}
page.toc The page’s headings, each with title, id, url (a link to it on the page), level, and the headings beneath it as headings. {% for heading in page.toc %}
page.url Where the page is published, the site’s path included: /posts/post-1/. <a href="{{ post.url }}">
page.slug The name in the page’s address, its last part: post-1. <article id="{{ page.slug }}">
page.source The file the page was read from: content/posts/post-1.md. {{ page.source }}
page.language, page.direction The page’s language, by its code, and ltr or rtl. <html lang="{{ page.language }}" dir="{{ page.direction }}">
page.translations The same page in the site’s other languages, the main language first. Empty where it has none. See translations. {% for other in page.translations %}
page.tags, page.series, page.folder Its tags, its series, and the folder it is filed in. {% if "Rust" in page.tags %}
page.list, page.posts, page.number On a page that speaks for a list: the list, the posts this page of it shows, and which of its pages this is, from 1. See lists in templates. {% for post in page.posts %}
page.previous, page.next The pages either side of this one. See previous and next. {% if page.next %}
page.post On a page made for a post by each: site.posts, the post. See a page for each post. {{ headline(page.post) }}
page.key On a page made from a record in data/, the record’s name. See make a page for each item. {{ page.key }}
page.card, page.og_title The picture a link preview shows, with its url, width, height and alt, and what the page is called where it is shared. A page with no card has no page.card. {% if page.card is defined %}
page.webmentions What other sites said of the page: said, the replies, mentions and bookmarks, and faces, the likes and reposts, each oldest first. Every page has it, empty where nothing was said. See what a mention gives. {% for mention in page.webmentions.said %}
page.search_box() A search box: id, the id its element takes, and first, whether it is the first on the page. {% set box = page.search_box() %}
page.<name> Any field of your own. {{ page.book.author }}

Note:

page.title has no value on a page with no title:. Where a template names pages, as a list or a <title> does, write headline(post): it names every page.

Pictures and files beside the page

Name What it gives Example
page.photo("photo-1.jpg") A picture: src, avif, fallback, sizes, full, width, height, alt, caption, name, and each line of the picture’s own .yaml file. It takes sizes, full=true and decorative=true. <img src="{{ page.photo("photo-1.jpg").src }}">
page.photos(["photo-1.jpg", "photo-2.jpg"]), page.photos() Several pictures, each as page.photo gives it: those named, in order, or every photograph beside a page with a folder of its own, by file name. {% for picture in page.photos() %}
page.file("notes-1.pdf") Any file: url, name, size in bytes, text, code and language. <a href="{{ page.file("notes-1.pdf").url }}">
page.video("video-1.mp4") A video: src and type. <source src="{{ page.video("video-1.mp4").src }}">
page.audio("recording-1.m4a") A recording: src and type. <source src="{{ page.audio("recording-1.m4a").src }}">
page.youtube(address), page.vimeo(address) A video kept on YouTube or Vimeo, from its address or its id alone: id, start, embed and still. {{ page.youtube("abcdefghijk").embed }}

A file gives url, the address to link to. A picture, a video and a recording give src, for the element that shows them. A name is found as the writing finds it, and any page can be asked, as post.photo(post.cover). Pictures in templates and files and videos in a template say what each field holds.

What a mention gives

Name What it gives
kind replied, liked, reposted, bookmarked or mentioned.
url The sender’s page.
author, author_url Who sent it, and their own page.
published When, as a date.
words What they wrote.
face The sender’s picture, as page.photo gives one.

Each is plain text, never HTML, and each but kind and url may be missing, so ask first. Show words as they are: markdown or safe would put another site’s HTML into your page. See webmentions.

What a template sees on site

Name What it gives Example
site.title, site.language As in the settings. On a site in several languages, those of the page being built. {{ site.title }}
site.url The site’s url, with no / at its end, or the one --url gives for one build. {{ site.url }}
site.host The host in the site’s url: example.org for https://example.org/blog/. A site with no url has none. {% if site.host == "example.org" %}
site.today The day the site is built on: today in the site’s timezone, or the day --today names. {{ site.today | date("2025") }}
site.languages Each language’s code, the main one first. Asked by a code, that language’s home page. A site that names no language has en alone. {{ site.languages.en.url }}
site.posts Every post, newest first. {% for post in site.posts[:5] %}
site.pages Every page the site publishes, in the order of their addresses: a list’s later pages, the 404 page, sitemap.xml and robots.txt among them, but no redirects. {% for entry in site.pages %}
site.tags, site.series Every tag and every series, A to Z. {% for tag in site.tags %}
site.folders Every folder with a page of its own, in the order of their addresses. {% for folder in site.folders %}
site.feed The address of the site’s own feed, or nothing where none is published. A list’s is list.feed. {% if site.feed %}
site.data.<name> A file in data/, by its name. See data files. {% for link in site.data.links %}
site.<name> Any value of your own in statac.yaml. {{ site.tagline }}

On a site in several languages, site.posts, site.folders, site.tags, site.series and site.feed are those of the page’s language, and site.pages is every language’s: see each language in templates.

Filters

Jinja’s usual filters are there, such as default, upper, lower, join, replace, length and safe. Statac adds these:

Name What it gives Example
date A date in a style of its own, written by example as date_format is. See how dates look. {{ page.date | date("Wed 31 Dec") }}
moment A date as a machine reads it, 2026-10-04T15:04:00+01:00, for <time datetime>. moment("rss") gives the form RSS uses. {{ page.date | moment }}
markdown Text read as Markdown, such as a summary with emphasis in it: what it builds. {{ page.summary | markdown }}
highlight Text as coloured code, for the inside of a <code>, the language named as a code block names it. The page needs the code stylesheet. <pre class="code"><code>{{ page.example | highlight("rust") }}</code></pre>
plain The words of some HTML as one line, its markup taken off. {{ post.excerpt | plain }}
truncate Text cut at a word to fit a number of characters, with an ending. {{ page.summary | truncate(160, "…") }}
digits A number, or text with numbers in it, in the digits of the page’s language: ۱۲ for 12 on a Persian page. Only 0 to 9 change. Handed HTML, it gives the words alone, as plain does. {{ "Page 3 of 10" | digits }} gives Page ۳ of ۱۰
absolute An address starting with / made whole, with the start of the site’s url in front. Handed HTML, such as post.content, it does this to each address in an href, src, srcset or poster. Text joined to HTML with ~ is text, so nothing in it is made whole. It needs the site’s url. {{ page.url | absolute }} gives https://example.org/posts/post-1/
json A value written as JSON, safe inside a <script>, and inside an attribute in single quotes, never double. data-posts='{{ posts | json }}'
tagged, in, where The pages of a list carrying a tag, filed in a folder, or whose field holds a value. See lists made with posts:. {{ site.posts | in("blog") | tagged("Rust") }}
newest_first A list of pages in date order, newest first. {% for post in related(page) | newest_first %}
newest_date The newest date among a list of pages. A list with no dated page has none. {{ posts | newest_date | moment }}
urlencode Text made fit to be part of an address. ?text={{ headline(page) | urlencode }}

What markdown builds

In the text Built
Maths As the writing’s is.
A snippet call Shown as written.
A link, a code block, inline code, a callout By the site’s own piece for it, where it has one. A code block isn’t coloured.
A heading, a picture, a footnote As Statac builds them: a heading has no id, a picture’s address is left as written, and footnotes are numbered from 1 in each value, with the ids note-1 and back-1.

Warning:

Two values with footnotes on one page, or one beside the page’s own footnotes, repeat the ids note-1 and back-1, and Statac says nothing of it.

Functions

Name What it gives Example
css("site.css") A stylesheet’s <link>, to a file in assets/ by its own name. See assets. {{ css("site.css") }}
js("app.js") A script’s <script>, to a file in assets/. {{ js("app.js") }}
asset("fonts/font-1.woff2") The address a file in assets/ is published at, for an element you write yourself. href="{{ asset("fonts/font-1.woff2") }}"
url("/about/") An address inside the site, wherever it is published: /blog/about/ on a site at https://example.org/blog/, and /about/ on one at the top of its domain. It takes an address starting with one /, and keeps what follows a ? or a #. A link written without it is left as written. In the writing, the same is @url(/about/). <a href="{{ url("/about/") }}">
headline(page) A page’s name, as text with no markup: see what headline calls a page. <title>{{ headline(page) }}</title>
related(page) The posts sharing a tag with the page, those sharing most first. {% for post in related(page) %}
feed_id(page) Any page’s id in a feed: urn:uuid: and an id made from the site’s feed_key and the page’s address, or its own id:. For a list’s page, its feed’s own id. It needs the site’s feed_key. See entry ids. <id>{{ feed_id(post) }}</id>
webmention() The line naming where other sites send the site’s webmentions, for the <head>. Without webmentions: true in the settings it writes nothing. See webmentions. {{ webmention() }}

Each snippet in snippets/ is called the same way, by its file’s name: {{ card(post) }} places snippets/card.html. See snippets.

What headline calls a page

headline(page) gives the first there is of:

  1. its title:, as written;
  2. for the home page, the site’s title;
  3. the # heading its writing opens with, as a reader reads it, its punctuation set and its Markdown gone: # Using `grep` gives Using grep;
  4. for a page that speaks for a list, such as a folder’s or a tag’s, the list’s name, a tag as it is shown;
  5. its summary, or else the words of its opening paragraph, cut at a word to 70 characters with … after;
  6. its date, written as the site writes dates;
  7. its address.

A list’s later pages are called what its first page is. A page marked untitled is called by its date before its summary: see pages without titles.

A template that calls such a page something else asks whether it has a title: {% if page.title is defined %}.

Where next?

Something unclear or wrong? Open an issue on GitHub.