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:

FeatureWhat it gives you
builtinsThe built-in filters and tests listed below
macros{% macro %}, {% import %}, {% from … import … %}
multi_template{% extends %}, {% include %}, {% block %} inheritance
loaderTemplates are loaded by name from your theme’s templates/ directory
jsonThe 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 urlencode filter - 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.

KindOperators
Math+ - * / // (floor) % ** (power)
Comparison== != < <= > >=
Logicand or not
Membershipin, 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-escapeEffect
.html.jinja (also .htm/.xml)HTML{{ }} output is HTML-escaped
.json.jinja, .yaml.jinja/.yml.jinja, .js.jinjaJSONoutput is JSON-escaped
.md.jinja and everything elseNoneoutput 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.jinja declares {% block title %} / {% block content %}; templates/default.html.jinja does {% extends "base.html.jinja" %}.
  • Loops, conditionals, set, ~, is defined, or - all appear in the <head> of base.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.jinja defines 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.