Skip to content

Themes and templates ​

For theme authors and plugin developers. Everything customer-facing is rendered with Twig: every PDF, every email, and the server-rendered public pages (the booking form). The portal, quote, pay and tracking pages are part of the web app; they wear the same theme through design tokens (see Customer pages).

Three layers decide how a document looks, top wins:

  1. A tenant template: made in the console, stored in the database. Data and sandboxed Twig, never code on disk. See Tenant templates and admin/document-templates.md.
  2. The theme: the organization's theme, then its parent, then the default theme.
  3. Core: what ships with Rangefront Waste.

A theme on disk ​

themes/harbour/            customer themes live in themes/, bundled ones in bundled-themes/
  theme.json
  views/
    documents/
      document.twig          the shell of every document (optional)
      invoice.twig           one document (optional)
      invoice-deposit.twig   one document, one variant (optional)
    emails/
      layout.twig            every email (optional)
      invoice_sent.twig      one message (optional)
    public/
      layout.twig            every server-rendered public page (optional)
      book.twig              one page (optional)
    parts/
      footer.twig            a template part (optional)
      totals-invoice.twig    a part for one document (optional)

You only ship what you change. Everything else comes from the parent, the default theme, or core.

theme.json:

json
{
  "name": "Harbour",
  "slug": "harbour",
  "version": "1.0.0",
  "parent": "slate",
  "description": "Optional.",
  "author": "Optional.",
  "tokens": { "brand": "#1f5f4a", "frame": "#14261f", "radius": "4px", "font": "archivo" },
  "provides": {
    "templates": ["documents/invoice-deposit", "emails/layout"],
    "parts": ["footer"]
  },
  "starters": {
    "invoice": { "style": "compact", "wording": { "footer": "Thank you for your business." } }
  }
}
  • slug is lower-case letters, digits and dashes, and matches the folder name.
  • tokens are the CSS variables in apps/web-aurelia/src/styles/tokens.css without the dashes. Colours are hex, radius and radius-panel are 0px to 24px, font is a bundled font (archivo, system). Anything else is rejected. Setting brand derives brand-hover, brand-tint, brand-soft and focus; setting frame derives the frame-* shades, unless you set them. Text and background pairs are checked against WCAG AA (4.5:1); failures are warnings in Admin > Branding.
  • provides is optional. When you list a template or a part, the file must exist or the theme is rejected. It documents what the theme changes and is shown in Admin.
  • starters are starting points for tenant templates ("start from the theme"): style, blocks, wording and CSS for a document type. They go through the same cleaning as anything a tenant saves.
  • A customer theme with the slug default replaces the bundled default.

Child themes ​

Add "parent": "<slug>". The chain is the theme, its parent, the parent's parent, then the default theme.

  • Tokens inherit: default, then each parent, then the child, then the organization's own overrides. The nearest one wins.
  • Templates and parts inherit the same way. A child ships only what it changes.
  • A parent that is missing or invalid, a theme that is its own parent, and a loop (a -> b -> a) reject the theme and every theme below it. The reasons are listed under ThemeManager::invalid() and in Admin.
  • slate-print in bundled-themes/ is a working example: a child of slate that sets black text, replaces one part (parts/footer.twig) and one template (documents/invoice-deposit.twig), and gets the slate banner and slate emails from its parent without copying them.

To extend the same-named template of your parent, name the parent explicitly:

twig
{% extends '@theme-slate/documents/invoice.twig' %}

The template hierarchy ​

For each document, email or page, a list of names is built from the most to the least specific. Each name is looked up in the selected theme, its parent, and so on up to the default theme. The first file found is used. If none is found, core's template is used.

Documents ​

OrderTemplateWhen it applies
1documents/invoice-{invoice_type}An invoice of that type, such as invoice-deposit
2documents/invoice-{status}An invoice with that status, such as invoice-sent
3documents/invoiceAny invoice
4documents/documentEvery document: the shell (see below)
5core@core/documents/invoice.twig
DocumentNames, most specific first
Invoiceinvoice-{invoice_type}, invoice-{status}, invoice, document
Quotequote-{status}, quote, document
Statementstatement, document
Service reportservice_report, document
Commissioning certificatecertificate, document

documents/document.twig is a shell, not a whole document. Core's documents/invoice.twig and the others {% extends 'documents/document.twig' %}, and that name resolves through your theme. A theme that ships documents/document.twig therefore restyles every document at once, and keeps core's content:

twig
{% extends '@core/documents/document.twig' %}

{% block banner %}
    <div class="tag">{{ theme.name }}</div>
    {{ parent() }}
{% endblock %}

A theme that ships documents/invoice.twig replaces the whole invoice. Extend core's to keep its content and change one block:

twig
{% extends '@core/documents/invoice.twig' %}

{% block totals %}
    <p class="lead">Amount due: {{ money(snapshot.invoice.total_cents) }}</p>
{% endblock %}

Emails ​

OrderTemplate
1emails/{message_event}, such as emails/invoice_sent or emails/technician_en_route
2emails/layout
3core @core/emails/layout.twig

Public pages ​

OrderTemplate
1public/{page}: book
2public/layout: the shell; core's public/book.twig extends it
3core @core/public/book.twig

Filters for plugins ​

  • theme.template_hierarchy: (candidates, surface, name, context). Add or reorder candidates. Plain names (documents/invoice-wholesale) or @plugin/name.twig templates are kept; anything else is dropped.
  • document.template: (view, documentType, context). Return a different template name to take over. Names that do not exist, or point outside the template folders, are ignored.

Template parts ​

A part is a small template a document is built from. A theme can replace one part without copying a document.

PartWhat it drawsVariants (core ships)
bannerArea above the letterhead. Empty in core
styleExtra CSS after core's
headerThe letterhead and the title
addressesWho it is for, site, datesinvoice, quote, statement
line-itemsThe lines or activityinvoice, quote, statement
totalsSubtotal, GST, total (or ageing)invoice, quote, statement
payment-detailsPay link and how to payinvoice, statement
open-itemsUnpaid invoices on a statementstatement
signatureAcceptance or signaturesquote, report
free-blockA tenant's own text, note or divider
footerThe small printinvoice, quote, statement, report
email-header, email-footerThe top and foot of an email
public-headDesign tokens as CSS variables

Files are parts/<name>.twig and parts/<name>-<variant>.twig. For part('totals', {}, 'invoice') the lookup is totals-invoice then totals, in every theme of the chain, and only then core. So a theme's generic parts/footer.twig beats core's footer-invoice.twig, and a variant file beats a generic one. Ship variant files unless your part handles every document it can be called for.

Draw a part with part(name, variables, variant). It returns HTML. The part sees the document's data (snapshot and so on) plus the variables you pass:

twig
{{ part('totals', {heading: 'Totals'}, 'invoice') }}

What a template can use ​

Variables in every document: snapshot (the frozen record), logo (a data address or null), template (version), document, title, page_title, body_class, template_blocks, words (merge fields), blocks (plugin blocks), theme. theme is plain data: slug, name, tokens, chain, font.

Functions: money(cents), money(cents, true) (signed, as statements print), day(value, format, fallback) (dates in the organization's time zone), fill(text) (merge fields), customised(token, fallback) (a token only when the theme or organization chose it; use it for neutral colours so untouched tenants keep their old look), document_font(fallback), theme_css(), asset(path), hash(value) and part(...).

Output is auto-escaped. Use |raw only on values you made yourself, such as colours.

Snapshots are frozen when a document is made. A template change does not alter documents that already exist, and the tenant template chosen for a document is stored in its snapshot.

Worked example: a child theme ​

bundled-themes/harbour/
  theme.json
  views/parts/footer.twig
  views/documents/invoice-deposit.twig
json
{
  "name": "Harbour",
  "slug": "harbour",
  "version": "1.0.0",
  "parent": "slate",
  "tokens": { "brand": "#1f5f4a" },
  "provides": { "templates": ["documents/invoice-deposit"], "parts": ["footer"] }
}

views/parts/footer.twig, every document's small print:

twig
<div class="meta">{{ snapshot.organization.name }} | {{ day(snapshot.rendered_at, 'j F Y') }}</div>

views/documents/invoice-deposit.twig, deposit invoices only:

twig
{% extends '@core/documents/invoice.twig' %}

{% block details %}
    <p class="lead"><strong>Deposit.</strong> This is credited to your final invoice.</p>
    {{ parent() }}
{% endblock %}

Select it in Admin > Branding. Colours come from slate except brand; the slate banner, style and emails are inherited; the footer and the deposit invoice are Harbour's.

Tenant templates ​

Tenants make templates in Admin > Templates. They are stored per organization (document_templates) with their rules (document_template_rules) and never touch the disk.

Which one is used, top wins:

  1. The template pinned on the record (invoices, quotes and compliance_reports have document_template_id).
  2. The first assignment rule that matches. Rules are ordered per document type and test invoice_type, job_type, status, customer_id or message_event. Every field in a rule must match one of its values.
  3. The tenant's default template for the document type.
  4. The theme: the hierarchy above.
  5. Core.

A template has a name, a document type, a style (classic, modern, compact), blocks, wording (title, footer, how to pay), optional CSS and optional Twig. GET /api/v1/document-templates returns the templates, rules and a catalogue of blocks, options, merge fields and sandbox limits for the editor. POST /api/v1/document-templates/explain returns the reasoning for a record or a set of values, step by step, with every theme file checked.

Blocks, and blocks as Twig ​

The block builder is the no-code way. Each document type has its own blocks (invoice: details, lines, totals, payment, footer_text, plus text, note, divider). Core's templates walk the block list and render each block with {% block <type> %}, so a tenant's Twig can override one block of the theme's template instead of copying it. "Start from my blocks" generates readable Twig from the list:

twig
{% extends "@document" %}

{% block content %}
    {{ part('addresses', {heading: 'Invoice'}, 'invoice') }}
    {{ part('line-items', {heading: 'Charges'}, 'invoice') }}
    {{ part('totals', {heading: 'Totals'}, 'invoice') }}
    {{ part('payment-details', {heading: 'Pay online'}, 'invoice') }}
    {{ part('footer', {}, 'invoice') }}
{% endblock %}

@document is the template the hierarchy chose for that document (a theme's, or core's), so a tenant template sits on top of the theme. If a tenant's Twig fails at render time, the document is made from the blocks instead; a PDF never fails because of a tenant template.

The sandbox ​

Tenant Twig runs in Twig's SandboxExtension, only for sources named tenant:*. Theme and core templates are trusted. Two layers check it. TenantSandbox refuses a template when it is compiled (so the console can show the error and the line when it is saved), and Twig's own SecurityPolicy checks again at render.

Allowed
Tagsif, for, block, extends (only @document)
Filtersescape, e, upper, lower, capitalize, title, trim, length, default, join, nl2br, number_format, first, last, slice, abs, round
Functionspart, money, day, block, parent
Testsdefined, empty, null, even, odd, iterable, same as, divisible by
Methods, properties of objectsNone. Data is plain arrays

Not available: include, embed, import, from, use, macro, set, source, range and .., constant, attribute, dump, random, raw, autoescape, sandbox, map, filter, sort, method calls, and any extends other than @document. part() accepts only the parts listed in TwigEngine::PARTS.

Limits: 20,000 characters, 20 levels of brackets, loops nested at most two deep, and lists handed to a template are cut to 500 items, so a loop is bounded. No set and no range means a template cannot build large values. The compile cache key includes the sandbox rules, so tightening the rules recompiles every template.

Tests in tests/Feature/TwigSandboxTest.php try to get out, one attempt per case.

Merge fields and CSS ​

Block text can hold {{ customer }} style merge fields. They are filled from a fixed list per document type, always printed escaped, and unknown ones are left as typed. See the lists in admin/document-templates.md.

Custom CSS is parsed, not filtered: selectors may name only elements and classes the documents use, declarations only an allowlist of properties, and values are checked. url(), image-set(), @ rules, escapes, var(), attr(), expressions and anything that could close the <style> tag are dropped. The result is written out again from the parsed parts. See CssSanitiser.

PDF safety ​

Chromium reads the HTML from a local file and must not fetch anything. PdfHtmlGuard adds a Content-Security-Policy (default-src 'none'; img-src data:; style-src 'unsafe-inline') and the renderer starts Chromium with --host-resolver-rules=MAP * ~NOTFOUND, so no host name resolves. Logos and signatures are inlined as data addresses.

Customer pages ​

The portal, quote, pay and tracking pages are part of the web app. Their API responses carry theme (variables, accent_color, logo_url) and the page applies the variables to itself. The booking form is rendered on the server from public/book.twig. The sync pull carries theme (slug, name, tokens, logo_url) for the field app.

The document accent colour (Admin > Documents) wins when it was changed from the default; otherwise the theme's brand is used.

Plugins ​

A plugin's Twig templates live in resources/views/ and are reached as @<slug>/<path>.twig:

php
$api->views();
$api->pdfBlock('compliance_report', $api->viewName('pdf/docket.twig'), fn (array $snapshot): array => [
    'docket_number' => $snapshot['extensions']['disposal-dockets']['docket_number'] ?? null,
]);

The template receives snapshot and data. Plugin templates are trusted code, like the plugin. They resolve through the same loader as themes, and cannot be reached by tenant templates.

Where the code is ​

app/Extensibility/Themes/ThemeManager, ResolvedTheme, TemplateResolver (the hierarchy)
app/Extensibility/Twig/TwigEngine, HierarchyLoader, TenantSandbox, PluginViewPaths
app/Services/Documents/DocumentRenderer, DocumentTemplates, BlockCatalogue, CssSanitiser, TwigGenerator
resources/views/theme/Core templates
tests/Fixtures/documents/Back-compat fixtures: an untouched tenant's documents render exactly as before

A$125 a month, or A$9,450 once with the source code. Contact us.