Meridian handbook
Templates

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.

A template’s context is the set of variables it can read. Every page template receives the globals (site, locale, page, request, now and the rest) and the functions (path(), query(), t() and the rest). Each kind of page then adds its own variables, described here.

To see the context of a real page with its values, run:

engine-dmo describe route /places/the-red-lion --json

context in the output is every variable the template receives, with its value; template is the file the site’s chain resolved. It needs the project’s database. engine-dmo template api --json lists the same surface without a database, with your own types’ fields.

Every template starts from the same globals and functions; the kind of page decides what is added on top:

flowchart TD
  G["Globals and functions<br/>site, locale, page, request, t(), path(), query() ..."] --> D["A document's page<br/>entry, its type's key"]
  G --> I["An index page<br/>items, pagination, facets"]
  I --> L["A landing page (engine-dmo)<br/>plus landing"]
  G --> S["The search page<br/>results, pagination"]
  G --> E["Error pages<br/>page only, no entry"]
  D --> B["Block and rich text templates<br/>block or node, plus entry and page"]

Every page

VariableWhat it is
pageThe page itself: always uri, canonical, title, type and isPreview; the other keys depend on the kind of page, below
The globalssite, locale, locales, request, now, today, tomorrow, yesterday, settings, singletons, nav, csrf, assets, devMode, and sites on a project with more than one site. See Globals
The functionspath(), url(), t(), query() and every other function. See Functions

A document’s page

Rendered with <type>/entry.html (or the type’s or variant’s template:). The document is entry, and the same value is also available under the type’s key, so listing/entry.html can write {{ listing.title }} or {{ entry.title }}:

templates/_shared/listing/entry.html
{% extends "base.html" %}
{% block body %}
<article>
  <h1>{{ entry.title }}</h1>
  <p>{{ entry.summary }}</p>
  {% if entry.publishedAt %}<p><time datetime="{{ entry.publishedAt|iso }}">{{ entry.publishedAt|date:"long" }}</time></p>{% endif %}
  {{ entry.description|ptext }}
  {% for tag in entry.tags %}<span class="tag">{{ tag.label }}</span>{% endfor %}
</article>
{% endblock %}

page on a document

KeyValue
page.uriThe path of this page, with its locale prefix
page.canonicalThe absolute canonical URL, on the site’s configured domain. For a document shared from another site, its address on the site that owns it
page.titleThe document’s title
page.typeThe type key
page.isPreviewfalse on a public page
page.alternatesThe locales the document is published in on this site: each locale, hreflang, path and url

Fields every document has

PathValue
entry.idThe document’s id
entry.typeThe type key
entry.uriThe document’s path on this site in the request locale, with the locale prefix. Print it in an href with |url
entry.urlThe same as an absolute URL on the site’s configured domain
entry.slugThe slug in the request locale
entry.titleThe title in the request locale
entry.statuspublished on a public page
entry.localeThe request locale’s code
entry.createdAt, entry.updatedAt, entry.publishedAtDates in the site’s time zone. publishedAt is the locale’s first publication, updatedAt its latest published revision. A type that declares a field of the same name keeps its own value
entry.translationsA map from every enabled locale’s code to locale, exists (published with a live path on this site), stale (it trails its source), path and url (empty when it does not exist)
entry.tagsThe document’s public tags, each key, label (in the request locale), kind, parent, depth, colour, description
entry.seotitle (the site’s seo.title_template applied), description (the summary as plain text), image (the site’s seo.default_image, absolute), noindex and canonical. A type that declares its own seo field keeps it
entry.overriddenThe names of the fields whose value on this page is this site’s own override rather than the shared document’s, sorted; empty otherwise. Every field value is already the resolved one
entry.aiAssistedWhether the revisions shown carry AI-assisted text the site’s disclosure policy discloses; aiDisclosure(entry) prints the sentence
entry.variantThe entry variant’s key, on a type that declares variants only
entry.parentOn a type whose route nests: the parent document, one level deep; empty at a root
entry.ancestorsOn a nesting type: the ancestors, root first; empty at a root or on a flat type
entry.childrenOn a nesting type: the published children in the request locale, title order
entry.siblingsOn a nesting type: the parent’s other children, title order
entry.<field>Every field the type declares, as its field type renders it

engine-dmo check holds entry.<field> and <type key>.<field> to the schema, so {{ entry.photo }} on a type without a photo field is an error. It does not follow a document reached any other way: a loop variable over items, query() results or a relation ({% for w in entry.relatedWalks %}{{ w.photo }}), or a component prop. A field those do not have prints nothing, with no error or warning at check or at render, even with template.strict: true. Check those names against engine-dmo describe type <type> and read the output of engine-dmo render.

There is no entry.author. A byline is a field you declare, such as a relation to a person or a text field.

With engine-dmo, the modules add properties: entry.organisation (the organisation that owns a listing, from the CRM module, when it has one) and entry.landings (the live landing pages a place has, or that a document is a result of). See the DMO section.

Field values

Each field prints sensibly on its own and has properties and helpers for everything else:

Field typeIn a template
text, slugA string
number, booleanA number or a boolean. Format numbers with |number or |currency
date, datetimeA date value for the date filters: |date, |datetime, |time, |iso, |relative
selectAn option, value and label, or a list of them
portable_textRich text: print it with |ptext, as plain text with |plain, its headings with |toc
relationThe related document as an entry (or a list), resolved one level deep. A target that is not published in the locale or not shown on this site is left out
assetA media value: url, alt, width, height and the rest, and img(), picture(), figure(), link(). See Images
linkA link value: url, text and the rest, and link(), attributes(). See Links
geo, address, phone, opening_hours, video, form, repeater, blocks, recurrence, geo_shapeSee Fields

engine-dmo describe fieldtypes lists every field type’s properties and helpers, with the keywords each helper takes. engine-dmo check refuses a property or helper a field’s type does not have.

Guides and collections

A guide or collection document’s entry also has:

PathValue
entry.listThe list as entries, in order: a guide’s live items’ documents, or a collection’s page of documents
entry.listTotalHow many documents the list holds in all
entry.itemsGuides: the live items, each document (an entry) and note (plain text)
entry.paginationCollections: the page of the list that ?page= asked for, shaped as an index page’s pagination. A page past the last answers 404
entry.jsonldThe page’s CollectionPage and ItemList structured data, which jsonld() merges into the page graph

On a collection’s page pagination is also set at the top level of the context.

An index page

Rendered with <type>/index.html at the route’s index: address. The route’s paginate: sets the page size.

templates/_shared/listing/index.html
{% extends "base.html" %}
{% block body %}
<h1>{{ t('places.title') }}</h1>
<p>{{ t('index.count', pagination.total) }}</p>
<ul>
  {% for item in items %}<li><a href="{{ item.uri|url }}">{{ item.title }}</a></li>{% endfor %}
</ul>
{% if pagination.pages > 1 %}
<nav aria-label="{{ t('pager.label') }}">
  {% if pagination.prev %}<a rel="prev" href="{{ pagination.prev|url }}">{{ t('pager.previous') }}</a>{% endif %}
  <span>{{ t('pager.page_of', 'page', pagination.page, 'pages', pagination.pages) }}</span>
  {% if pagination.next %}<a rel="next" href="{{ pagination.next|url }}">{{ t('pager.next') }}</a>{% endif %}
</nav>
{% endif %}
{% endblock %}
VariableValue
itemsThe page of published documents of the type on this site in the request locale, in the type’s ordering (title order without one). Each is an entry with its relations resolved
pagination.pageThe current page, from ?page=, from 1
pagination.pagesHow many pages
pagination.totalHow many documents match, exactly
pagination.perThe page size, the route’s paginate:
pagination.next, pagination.prevThe path of the next and previous page, keeping the selected facets; empty at the ends
pagination.rangeThe page numbers from 1 to pages, as plain numbers with no address
facetsOne per field in the route’s facets: list
page.indextrue
page.titleThe type’s plural label, an admin word that also becomes the page’s <title>. Take the h1 from t() rather than printing this. An index has no title or description of its own
page.noindextrue on a filtered page
page.canonicalThe unfiltered index (or, with engine-dmo, the most specific landing page the filters match)
page.alternatesThe index in each locale

The response also carries Link headers with rel="prev" and rel="next".

Only pagination.prev and pagination.next are addresses. They keep the selected facets (/walks?difficulty=easy&page=2). There is no address for an arbitrary page number, and a link written as href="?page={{ n }}" drops every selected facet, which engine-dmo check does not report. Build the pager from prev and next, as above, or print numbered links only on an unfiltered index ({% if not page.noindex %}). The strings pager.label, pager.previous, pager.next and pager.page_of are your own, in config/strings/<locale>.yaml.

Each facet is field, label (the field’s label), selected (whether a value is chosen), clearUrl (the index without this facet) and values. Each value is value, label (an option’s or tag’s label in the request locale), count, selected and url (the index with the value chosen, or removed when it is the chosen one). A facet is selected by the query string ?<field>=<value>.

Facet values are links, not form controls, so draw them as lists of links under a heading inside one navigation landmark, not as a <fieldset> and not as one landmark per facet:

{% if facets %}
<nav aria-label="{{ t('facets.label') }}">
  {% for facet in facets %}
  <h2>{{ facet.label }}</h2>
  <ul>
    {% for v in facet.values %}<li><a href="{{ v.url|url }}"{% if v.selected %} aria-current="true"{% endif %}>{{ v.label }} ({{ v.count }})</a></li>{% endfor %}
  </ul>
  {% if facet.selected %}<p><a href="{{ facet.clearUrl|url }}">{{ t('facets.clear') }}</a></p>{% endif %}
  {% endfor %}
</nav>
{% endif %}

A select facet’s value labels are the option labels, which must be written as a map per locale (label: { en: Easy, cy: Hawdd }); a plain-string option label prints the value instead.

A landing page

engine-dmo

This feature is part of the DMO bundle (engine-dmo).

A landing page is an index filtered to a place and a category at a clean path. It renders <type>/landing.html where the chain has one, else <type>/index.html, with everything an index page has, plus landing (key, place, category, title, heading, description, content, faq, count, siblings, parents, breadcrumbs, jsonld and more) and page.landing set to true. See Landing pages.

The search page

Rendered with search.html at the site’s special.search route, for the request’s q, type, tag, sort, lat, lon, km and page parameters. The page is never cached.

VariableValue
resultsThe search results, as search() returns them: items, total, facets, degraded, backend, query, sort, page, pages, hasMore, pagination, didYouMean. See Search in templates
paginationresults.pagination
pageuri, canonical, title (Search), type (search), isPreview

Error pages

errors/404.html and errors/500.html receive the globals and the functions, with page set to uri (the requested path), title (Not found or Error), type (error) and isPreview. They receive no entry. Keep them simple: an error template that itself fails falls back to a plain-text response.

Block templates

{% blocks entry.body %} renders each item of a blocks field through blocks/<block key>.html on the site’s chain. The block template receives the page’s globals and functions, the page’s entry and page, and:

VariableValue
blockThe item: its _type (the block key), _key, and every field of the block by name, rendered as on an entry
blockIndexThe item’s position, from 0
blockCountHow many items the field holds
templates/_shared/blocks/callout.html
<aside class="callout callout--{{ block.tone.value }}">
  {% if block.title %}<p class="callout__title">{{ block.title }}</p>{% endif %}
  {{ block.content|ptext }}
</aside>

engine-dmo check refuses a project that declares a block with no blocks/<key>.html anywhere on a site’s chain. engine-dmo new block <key> scaffolds one.

Rich text serialisers

|ptext renders an object block inside rich text through ptext/<set>/<block key>.html, where the set is the filter’s argument (default when none: |ptext or |ptext:"compact"). The serialiser receives the page’s globals and functions, and:

VariableValue
nodeThe object block, with its fields by name
indexIts position in the rich text, from 0

An object block with no serialiser renders nothing on a public page; under serve --dev it shows a notice naming the template to add, and engine-dmo check warns about it. Naming a set that has no templates at all is an error at render.

Endpoint and module templates

Declared endpoints (forms and their responses) and some modules render templates with context of their own, such as form, values, errors, submission, signals and endpoint. Those are described with the features: Forms, and, with engine-dmo, the member portal.