Meridian handbook
Templates

The template language

The Pongo2 syntax Meridian templates use (printing, expressions, filters, keyword arguments, inheritance, includes, macros, loops, whitespace) and the escaping and security rules the engine enforces on every template.

Meridian templates are written in Pongo2, a Go implementation of Django’s template language. If you know Django, Jinja or Twig, most of it reads the same. This page covers the syntax as Meridian uses it, including what Meridian adds (keyword arguments), what it removes, and the escaping rules every template is held to.

Syntax at a glance

{% extends "base.html" %}

{% block body %}
  {# A comment, never rendered #}
  <h1>{{ entry.title }}</h1>

  {% if entry.summary %}
    <p>{{ entry.summary|truncate:160 }}</p>
  {% endif %}

  <ul>
  {% for place in query('listing').orderBy('title').limit(6).all() %}
    <li><a href="{{ place.uri|url }}">{{ place.title }}</a></li>
  {% empty %}
    <li>{{ t('places.none') }}</li>
  {% endfor %}
  </ul>
{% endblock %}
DelimiterUse
{{ expression }}Print a value, escaped for HTML
{% tag ... %}A tag: control flow, inheritance, includes, and the engine’s own tags
{# ... #}A comment on one line
{% comment %}...{% endcomment %}A comment over several lines

Expressions

FormExampleNotes
Strings"text", 'text'Either quote
Numbers3, 2.5
Booleans and nothingtrue, false, nilnil prints nothing
Lists["listing", "event"]A list literal. There is no map literal: {"a": 1} does not parse
Attributesentry.title, entry["title"], locales.0.codeA missing attribute is empty, and engine-dmo check reports it when it can know
Arithmetic+ - * / % ^+ also joins two strings. / on two integers is integer division (10 / 4 is 2); 10.0 / 4 is 2.5. ^ is power
Comparison== != < > <= >=
Logicand or not, && ||Parentheses group
Membership"a" in "abc", x in listFor “not in”, write not (x in list)

A {{ }} or {% %} must be written on one line: a newline inside one is a parse error (Newline not allowed within tag/variable), so a long query chain stays on a single line.

Not supported: the ~ concatenation operator (use + or the add filter), // and **, and the inline x if y else z form (use {% if %}).

A boolean prints as True or False. Print a word from the string catalogue with t() or use the yesno filter instead.

Filters

A filter transforms the value to its left. Filters chain left to right, and a filter takes at most one argument, after a colon:

{{ entry.title|upper }}
{{ entry.summary|truncate:120 }}
{{ entry.publishedAt|date:"long" }}
{{ entry.description|plain|truncate:160 }}

Every filter is listed on Filters. Anything that needs more than one option is a function or a value’s helper with keyword arguments.

Functions and keyword arguments

Globals such as path(), query() and t() are called with parentheses. Meridian adds keyword arguments to Pongo2: inside the parentheses of a call, name=value passes an option by name.

{{ path('/places', locale='ja-JP') }}
{{ siteUrl('/events', site='towns') }}
{{ entry.heroImage.picture('hero', sizes='100vw', priority=true, class='hero__img') }}
{{ entry.website.link(class='button', aria-describedby='booking-note') }}

The rules:

  • Positional arguments come first. A positional argument after a keyword is an error.
  • A keyword name may contain hyphens, so aria-label= and data-track= are keywords.
  • Keywords work inside {{ }} and inside the expressions of {% %} tags, wherever a call has parentheses.
  • Each function and helper declares the keywords it takes. engine-dmo check refuses, at the keyword’s line and column, a keyword the callee does not take (with a “did you mean”), a keyword given twice, and any keyword passed to a function that takes none, such as t(). The same rule is enforced at render time.
  • A tag’s own name=value arguments outside parentheses belong to the tag, as in {% component 'card' tone='stale' %} and {% with total=items|length %}. Macros take positional arguments only.

The functions and their keywords are listed on Functions. A field value’s helpers (img(), link(), render() and the rest) are listed per field type by engine-dmo describe fieldtypes.

Inheritance

A layout declares blocks; a child template extends it and replaces the blocks it needs. {{ block.Super }} prints the parent’s content of the block being replaced.

templates/_shared/base.html
<title>{% block title %}{{ page.title }} | {{ site.name }}{% endblock %}</title>
<main id="main">{% block body %}{% endblock %}</main>
templates/_shared/listing/entry.html
{% extends "base.html" %}
{% block title %}{{ entry.title }}, {{ block.Super }}{% endblock %}
{% block body %}<h1>{{ entry.title }}</h1>{% endblock %}

Put {% extends %} first in the file; anything in a child template outside its blocks is not rendered. A file that extends the name it is itself resolved as reaches the same name further along the site’s template chain, which is how a site overrides one block of a shared template (see The template chain).

Includes

{% include "partials/footer.html" %} renders another template in place with the current context. with name=value adds variables for the included template, and only gives it those variables and nothing else, not even the globals:

{% include "partials/footer.html" %}
{% include "partials/greeting.html" with name="Ana" %}

Partials are checked on their own

engine-dmo check checks every template file against the page context, without knowing what an include passed it. A partial that reads a variable only an include ... with provides is reported as "name" is not in the template context, which fails the check. Write a partial that needs arguments as a macro or a component instead, and keep included partials to what every page has (site, t(), path(), entry on an entry page and so on).

Macros

A macro is a reusable piece of markup with positional arguments and defaults. To use it from another file, mark it export and import it by name:

templates/_shared/partials/macros.html
{% macro badge(text, tone="info") export %}<span class="badge badge--{{ tone }}">{{ text }}</span>{% endmacro %}
{% import "partials/macros.html" badge %}
{{ badge(t('listing.new')) }}
{{ badge(t('listing.closed'), "warning") }}

For anything with named props, slots or attributes, use a component: it is checked against a declared contract.

Variables and scope

{% set count = items|length %}
{% with total=pagination.total per=pagination.per %}{{ total }} / {{ per }}{% endwith %}

{% set %} binds a name for the rest of the current scope. {% with %} binds names for its body only. A block template, a rich text serialiser, a component’s markup and an included partial with only do not see the page’s set variables; they see the page’s globals.

Loops

{% for item in items %}
  <li class="{% cycle 'odd' 'even' %}">{{ forloop.Counter }}. {{ item.title }}</li>
{% empty %}
  <li>{{ t('index.empty') }}</li>
{% endfor %}

{% for key, value in settings %}{{ key }}: {{ value }}{% endfor %}
{% for code in ["en-GB", "cy-GB"] reversed %}{{ code }}{% endfor %}
{% for code in ["en-GB", "cy-GB"] sorted %}{{ code }}{% endfor %}
Inside a loopValue
forloop.CounterPosition from 1
forloop.Counter0Position from 0
forloop.RevcounterItems left, counting this one
forloop.Revcounter0Items left after this one
forloop.First, forloop.LastWhether this is the first or last pass
forloop.ParentloopThe enclosing loop’s forloop

Two loop variables (key, value) iterate a map. Over a list, use one.

Whitespace

A hyphen inside a delimiter trims the whitespace on that side, including newlines:

<ul>
  {%- for x in items -%}
  <li>{{- x.title -}}</li>
  {%- endfor -%}
</ul>

{% spaceless %}...{% endspaceless %} removes whitespace between tags in its body.

Autoescaping

Every value printed with {{ }} is escaped for HTML. This cannot be turned off from a template:

  • {% autoescape off %}, the safe filter and the safeseq filter are not available. A template that uses them does not compile: Usage of filter 'safe' is not allowed.
  • raw prints a value unescaped only when the engine produced it as markup. Values that are already safe are the output of the engine’s own helpers and filters: |ptext, |markdown, |nl2br, |widont, |json, |url, |attr, an image’s picture(), a link’s link(), head(), jsonld() and the like. These print correctly without raw; raw exists so a template can say so. On any other value raw is an error: |raw requires a Safe value.

The engine’s safe values are typed for the place they go: HTML, a URL, a JavaScript value, a CSS value or a set of attributes. Content an editor typed is never one of them, so content never becomes markup by accident.

Why safe is refused: a template that marks a stored value safe turns every editor, importer and integration that can write that value into someone who can put script on the public site. If a site has a genuine need, the site opts in explicitly in site.yaml and owns the consequence:

config/sites/main/site.yaml
templates:
  allow_unescaped: true

Two Pongo2 filters print markup unescaped

truncatechars_html and truncatewords_html return their input as markup without escaping it, even with allow_unescaped off, and engine-dmo check does not flag them. Never apply them to content. To shorten text use truncate, words or excerpt, which return plain text that is escaped.

The security rules

engine-dmo check reads every template with an HTML context tracker and refuses a value printed where escaping does not protect it:

Where the value is printedRuleFinding
Inside <script>Must go through |json: const data = {{ entry|json }};script-context
href, src, action, formaction, srcset, posterThe first value in the attribute must go through |url, or come from path(), url(), siteUrl(), assets.*, feedUrl(), calendarUrl() or |icsUrl. |url rejects javascript: and data:url-context
An on* event handler attributeNot allowed. Use a data-on attribute and pass values in data-* attributesevent-handler
A style attribute or a <style> elementNot allowed. Use a class, or a custom property set from a data attributestyle-context
An attribute value without quotesNot allowed: write name="{{ value }}"unquoted-attr
Inside a <template> elementNot allowed: its content is cloned by script after escapingtemplate-element
<a href="{{ entry.uri|url }}">{{ entry.title }}</a>
<a href="{{ path('listing') }}">{{ t('nav.places') }}</a>
<script type="application/json" id="map-data">{{ entry.address|json }}</script>

What Meridian removes from Pongo2

These Pongo2 built-ins are not available in site templates, because they ignore the request locale, duplicate an engine filter, or expose the server:

RemovedUse instead
{% now %}now, today and the date filters, which use the site’s time zone and the visitor’s locale
{% ssi %}, {% autoescape %}{% include %}; see Autoescaping
timesince, timeuntil|relative
filesizeformat|filesize
e, escapeseqEscaping is automatic
safe, safeseq|raw on an engine-produced value
json_script|json inside a script element
dictsort, dictsortreversedSort in the query: orderBy()
unordered_listA loop, or {% nav %} for a tree

Development only

Outside production (engine.environment other than production), dump(value) and the dump filter print a value as a navigable tree, and dump() with no argument prints the whole context. In production they are not registered, so a stray one fails to compile rather than leaking data.

Errors

A template error always carries the file, the line, the column and the source line with a caret:

_shared/page/entry.html:12:14: Filter 'frobnicate' does not exist.
   12 |   <p>{{ entry.title|frobnicate }}</p>
      |              ^

engine-dmo check reports every problem in every template together. Under engine-dmo serve --dev a failed render shows the same detail on a development error page.