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 %} | Delimiter | Use |
|---|---|
{{ 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
| Form | Example | Notes |
|---|---|---|
| Strings | "text", 'text' | Either quote |
| Numbers | 3, 2.5 | |
| Booleans and nothing | true, false, nil | nil prints nothing |
| Lists | ["listing", "event"] | A list literal. There is no map literal: {"a": 1} does not parse |
| Attributes | entry.title, entry["title"], locales.0.code | A 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 | == != < > <= >= | |
| Logic | and or not, && || | Parentheses group |
| Membership | "a" in "abc", x in list | For “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=anddata-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 checkrefuses, 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 ast(). The same rule is enforced at render time. - A tag’s own
name=valuearguments 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.
<title>{% block title %}{{ page.title }} | {{ site.name }}{% endblock %}</title>
<main id="main">{% block body %}{% endblock %}</main> {% 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:
{% 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 loop | Value |
|---|---|
forloop.Counter | Position from 1 |
forloop.Counter0 | Position from 0 |
forloop.Revcounter | Items left, counting this one |
forloop.Revcounter0 | Items left after this one |
forloop.First, forloop.Last | Whether this is the first or last pass |
forloop.Parentloop | The 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 %}, thesafefilter and thesafeseqfilter are not available. A template that uses them does not compile:Usage of filter 'safe' is not allowed.rawprints 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’spicture(), a link’slink(),head(),jsonld()and the like. These print correctly withoutraw;rawexists so a template can say so. On any other valuerawis 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:
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 printed | Rule | Finding |
|---|---|---|
Inside <script> | Must go through |json: const data = {{ entry|json }}; | script-context |
href, src, action, formaction, srcset, poster | The 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 attribute | Not allowed. Use a data-on attribute and pass values in data-* attributes | event-handler |
A style attribute or a <style> element | Not allowed. Use a class, or a custom property set from a data attribute | style-context |
| An attribute value without quotes | Not allowed: write name="{{ value }}" | unquoted-attr |
Inside a <template> element | Not allowed: its content is cloned by script after escaping | template-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:
| Removed | Use 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, escapeseq | Escaping is automatic |
safe, safeseq | |raw on an engine-produced value |
json_script | |json inside a script element |
dictsort, dictsortreversed | Sort in the query: orderBy() |
unordered_list | A 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.
Templates
How a request becomes a page, which template file renders each kind of page, how the template chain resolves names, and the commands that show what a template receives.
The page context
Exactly what each kind of page template receives (a document's entry and its common fields, an index page's items, pagination and facets, the search page's results, error pages, block and rich text templates) and how to print the real context of any URL.