Appearance
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:
- 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.
- The theme: the organization's theme, then its parent, then the default theme.
- 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." } }
}
}slugis lower-case letters, digits and dashes, and matches the folder name.tokensare the CSS variables inapps/web-aurelia/src/styles/tokens.csswithout the dashes. Colours are hex,radiusandradius-panelare0pxto24px,fontis a bundled font (archivo,system). Anything else is rejected. Settingbrandderivesbrand-hover,brand-tint,brand-softandfocus; settingframederives theframe-*shades, unless you set them. Text and background pairs are checked against WCAG AA (4.5:1); failures are warnings in Admin > Branding.providesis 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.startersare 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
defaultreplaces 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 underThemeManager::invalid()and in Admin. slate-printinbundled-themes/is a working example: a child ofslatethat 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
| Order | Template | When it applies |
|---|---|---|
| 1 | documents/invoice-{invoice_type} | An invoice of that type, such as invoice-deposit |
| 2 | documents/invoice-{status} | An invoice with that status, such as invoice-sent |
| 3 | documents/invoice | Any invoice |
| 4 | documents/document | Every document: the shell (see below) |
| 5 | core | @core/documents/invoice.twig |
| Document | Names, most specific first |
|---|---|
| Invoice | invoice-{invoice_type}, invoice-{status}, invoice, document |
| Quote | quote-{status}, quote, document |
| Statement | statement, document |
| Service report | service_report, document |
| Commissioning certificate | certificate, 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
| Order | Template |
|---|---|
| 1 | emails/{message_event}, such as emails/invoice_sent or emails/technician_en_route |
| 2 | emails/layout |
| 3 | core @core/emails/layout.twig |
Public pages
| Order | Template |
|---|---|
| 1 | public/{page}: book |
| 2 | public/layout: the shell; core's public/book.twig extends it |
| 3 | core @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.twigtemplates 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.
| Part | What it draws | Variants (core ships) |
|---|---|---|
banner | Area above the letterhead. Empty in core | |
style | Extra CSS after core's | |
header | The letterhead and the title | |
addresses | Who it is for, site, dates | invoice, quote, statement |
line-items | The lines or activity | invoice, quote, statement |
totals | Subtotal, GST, total (or ageing) | invoice, quote, statement |
payment-details | Pay link and how to pay | invoice, statement |
open-items | Unpaid invoices on a statement | statement |
signature | Acceptance or signatures | quote, report |
free-block | A tenant's own text, note or divider | |
footer | The small print | invoice, quote, statement, report |
email-header, email-footer | The top and foot of an email | |
public-head | Design 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.twigjson
{
"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:
- The template pinned on the record (
invoices,quotesandcompliance_reportshavedocument_template_id). - The first assignment rule that matches. Rules are ordered per document type and test
invoice_type,job_type,status,customer_idormessage_event. Every field in a rule must match one of its values. - The tenant's default template for the document type.
- The theme: the hierarchy above.
- 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 | |
|---|---|
| Tags | if, for, block, extends (only @document) |
| Filters | escape, e, upper, lower, capitalize, title, trim, length, default, join, nl2br, number_format, first, last, slice, abs, round |
| Functions | part, money, day, block, parent |
| Tests | defined, empty, null, even, odd, iterable, same as, divisible by |
| Methods, properties of objects | None. 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 |