Template Syntax
The rest of this guide documents the helpers Accent layers on top of the template
engine - filters like slugify, functions like media(), and the page context. This
page documents the layer underneath: the base template language you type in every
.jinja file. It is scoped to exactly what Accent enables, so everything here works in an
Accent theme without any extra configuration.
Engine and version
Accent CMS uses MiniJinja - a Rust implementation of Jinja2-style templating. This release ships MiniJinja 2.22 with these features enabled:
| Feature | What it gives you |
|---|---|
builtins | The built-in filters and tests listed below |
macros | {% macro %}, {% import %}, {% from … import … %} |
multi_template | {% extends %}, {% include %}, {% block %} inheritance |
loader | Templates are loaded by name from your theme’s templates/ directory |
json | The tojson filter, and JSON auto-escaping for .json/.yaml template names |
Some optional MiniJinja features are not enabled, so the following are unavailable in Accent themes:
- Loop controls -
{% break %}and{% continue %}will raise a template error. - The
urlencodefilter - encode URLs in your content or a custom filter instead.
For the exhaustive language reference, see the upstream MiniJinja syntax documentation, which always matches the version pinned above.
Delimiters
Three delimiters drive every template:
{{ expression }} {# prints a value #}
{% statement %} {# executes logic: if / for / block / set / … #}
{# comment #} {# stripped from the output entirely #}
Variables and expressions
Inside {{ … }} (and the conditions of {% … %}) you can use the full expression
grammar.
Attribute and index access. Dotted access reads object attributes or map keys;
brackets do the same and also index sequences. A dotted integer indexes a sequence in
the middle of a chain, so rows.0.title is the title of the first row:
{{ page.title }} {# attribute / map key #}
{{ page.custom["hero"] }} {# bracket lookup #}
{{ page.tags.0 }} {# first tag (dotted-integer index) #}
{{ matrix.0.1 }} {# nested: column 1 of row 0 #}
Operators.
| Kind | Operators |
|---|---|
| Math | + - * / // (floor) % ** (power) |
| Comparison | == != < <= > >= |
| Logic | and or not |
| Membership | in, not in |
| String concatenation | ~ |
~ joins values as strings - handy for building paths and class names:
<link href="/{{ ('theme/assets/' ~ css) | fingerprint }}">
Printing booleans and none. Printed values follow Jinja2 spelling, so a boolean
prints as True or False and a none prints as None - capitalised, like Python.
That is rarely the token you want in markup: HTML attributes, JSON literals, and
JavaScript comparisons all expect lowercase. Two filters give you the web spelling:
{{ page.custom.featured }} {# True - Jinja2 spelling #}
{{ page.custom.featured | lower }} {# true - for HTML attributes #}
{{ page.custom.featured | tojson }} {# true - for JSON and JS literals #}
Reach for | lower when the value lands in an attribute a script reads back, and
| tojson when it lands inside a JSON document or a <script> block:
<article data-featured="{{ page.custom.featured | lower }}">
<script type="application/json">{"featured": {{ page.custom.featured | tojson }}}</script>
A bare boolean in a {% if %} condition is unaffected - this is only about how the
value is printed.
Chained comparisons read like math and Python (added in MiniJinja 2.20):
{% if 0 < page.custom.priority < 10 %}in range{% endif %}
Tests use is. They check a property of a value rather than transforming it:
{% if page.custom.hero is defined %}…{% endif %}
{% if page.subtitle is none %}…{% endif %}
{% if loop.index is odd %}…{% endif %}
{% if name is startingwith("draft-") %}…{% endif %}
The or operator doubles as a fallback for missing/false values - the theme uses it for
optional fields: {{ page.language or site.language }}.
Control flow
Conditionals:
{% if page.tags %}
<ul>…</ul>
{% elif page.custom.featured %}
<span class="badge">Featured</span>
{% else %}
<p>No tags.</p>
{% endif %}
Loops. A {% for %} exposes a loop object - loop.index (1-based),
loop.index0, loop.first, loop.last, loop.length, and loop.cycle(...). An
optional {% else %} runs when the sequence is empty:
{% for tag in page.tags %}
<a href="/tags/{{ tag }}">{{ tag }}</a>{% if not loop.last %}, {% endif %}
{% else %}
<em>Untagged</em>
{% endfor %}
Remember {% break %} / {% continue %} are not available - filter the sequence (with
selectattr, reject, slice, …) before the loop instead.
Assignments and scoped blocks
{% set columns = 3 %} {# inline assignment #}
{% set greeting %}Hello {{ name }}{% endset %} {# block capture #}
{% with total = a + b %} {# scoped variable #}
{{ total }}
{% endwith %}
{% filter upper %}shouted{% endfilter %} {# apply a filter to a block #}
A {% set %} made inside a {% for %} body is local to one iteration: the next
iteration starts with it unset again, so it cannot be used to carry a running total
across the loop. Use a namespace() when you need a value to survive iterations:
{% set ns = namespace(total=0) %}
{% for row in rows %}
{% set ns.total = ns.total + row.count %}
{% endfor %}
{{ ns.total }}
Template inheritance and reuse
This is how a theme avoids repeating its layout. A base template declares
{% block %} holes; child templates {% extends %} it and fill them.
{# base.html.jinja #}
<title>{% block title %}{{ page.title }} - {{ site.name }}{% endblock %}</title>
<main>{% block content %}{% endblock %}</main>
{# default.html.jinja #}
{% extends "base.html.jinja" %}
{% block content %}
<h1>{{ page.title }}</h1>
{{ page.content | safe }}
{% endblock %}
-
{{ super() }}inside a child block renders the parent block’s content. -
A required block forces every child to override it (new in 2.20). Its body may contain only whitespace or comments:
{% block content required %}{% endblock %}
Includes pull another template inline; macros are reusable snippets you import:
{% include "partials/markdown-link.html.jinja" %}
{% from "_macros/edition-badge.jinja" import edition_badge %}
{{ edition_badge("pro") }}
{# _macros/edition-badge.jinja #}
{% macro edition_badge(edition) %}
{% if edition == "pro" %}<span class="edition-pro">Pro</span>{% endif %}
{% endmacro %}
Whitespace control
A - next to a delimiter trims adjacent whitespace - useful for clean inline output:
{%- for tag in page.tags -%}
{{ tag }}
{%- endfor -%}
{{- value -}} trims around an expression the same way.
Auto-escaping
Accent does not configure escaping manually - it uses MiniJinja’s default, which is chosen by template name:
| Template name ends in… | Auto-escape | Effect |
|---|---|---|
.html.jinja (also .htm/.xml) | HTML | {{ }} output is HTML-escaped |
.json.jinja, .yaml.jinja/.yml.jinja, .js.jinja | JSON | output is JSON-escaped |
.md.jinja and everything else | None | output is emitted verbatim |
(The engine strips the trailing .jinja first, then looks at the remaining extension.)
This is why HTML templates pass trusted markup through the safe filter - it marks a
value as already-safe so it is not escaped:
{{ page.content | safe }} {# rendered HTML, do not escape #}
{{ user_supplied }} {# escaped automatically in .html.jinja #}
Markdown templates (*.md.jinja) are not auto-escaped, so | safe is unnecessary
there. You can also force a region with {% autoescape "html" %}…{% endautoescape %} (or
{% autoescape false %} to disable it).
Safety survives the string filters. upper, lower, title, capitalize, trim,
replace, indent, and join carry the safe marking of their input through to their
result, so transforming already-safe markup no longer double-escapes it:
{{ page.content | safe | trim }} {# still safe - not re-escaped #}
The same rule protects the other direction: when join stitches a safe separator
between unsafe items, the items are still escaped individually.
Built-in filters
Available with the shipped feature set (chain them with |):
safe · escape/e · upper · lower · title · capitalize · replace ·
trim · indent · default · length · first · last · min · max ·
sum · abs · round · int · float · string · bool · list ·
join · split · lines · reverse · sort · unique · slice · batch ·
items · dictsort · attr · map · select · selectattr · reject ·
rejectattr · groupby · chain · zip · format · pprint · tojson
{{ page.title | lower | replace(" ", "-") }}
{{ posts | selectattr("custom.featured") | list | length }}
{{ page | tojson }}
split yields a real sequence, so you can index it - including from the end - and slice
it without piping through list first:
{{ page.url | trim("/") | split("/") | last }} {# last path segment #}
{{ page.url | trim("/") | split("/") | first }} {# top-level section #}
Built-in tests
Used after is (negate with is not):
defined · undefined · none · safe · boolean · number · integer ·
float · string · sequence · mapping · iterable · odd · even ·
divisibleby · startingwith · endingwith · lower · upper · true ·
false · in · eq/== · ne/!= · lt/< · le/<= · gt/> ·
ge/>= · sameas
What Accent adds on top
Everything above is the stock engine. Accent registers additional filters
(date, truncate, slugify, fingerprint, …) and functions (now(), media(),
responsive_image(), debug(), get_env(), get_hash(), …) documented in
Filters and Functions, and a rich
page context.
See it in the default theme
The bundled default theme exercises every construct on this page:
- Inheritance -
templates/base.html.jinjadeclares{% block title %}/{% block content %};templates/default.html.jinjadoes{% extends "base.html.jinja" %}. - Loops, conditionals,
set,~,is defined,or- all appear in the<head>ofbase.html.jinja(e.g.{% for css in theme.assets.css %},{% set analytics_id = get_env(name="ACCENT_ANALYTICS_ID") %}, the canonical-link{% if request and request.path %}guard). - Macros +
import-templates/_macros/edition-badge.jinjadefines a{% macro %}that pages import with{% from "_macros/edition-badge.jinja" import edition_badge %}.
Copy any of these as a starting point for your own theme.