Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Wire

Wire is Zuri’s built-in templating engine. Unlike most templating languages, Wire does not invent its own syntax on top of your markup. Every Wire feature is powered by attributes on ordinary HTML elements, which means anything a designer already knows about HTML transfers directly, and every valid HTML5 document is already a valid Wire template.

Under the hood, a Wire template is compiled once — not re-interpreted on every render — into a small instruction tree, using the exact same WHATWG-conformant parser that backs the html module. That has a consequence worth knowing up front: Wire understands your markup as structure, not as text. It knows that one interpolation sits inside a paragraph, another inside an href, and a third inside a <script> tag, and it escapes each one correctly for where it actually is. A value handed to a template can never turn into new markup by accident — that has to be requested explicitly, and Wire makes you say so out loud.

Introduction

Wire and Blade

If you have used Laravel’s Blade, PHP’s own answer to templating, a lot of Wire will feel familiar in spirit even though the syntax is different. Both engines compile templates rather than interpret them on every request, both let you extend a base layout and override named sections of it, and both escape everything by default so that printing a user’s name can never become a way for that user to run script in someone else’s browser.

Where Wire departs from Blade is in how it expresses control flow. Blade adds its own directive syntax on top of plain text (@if, @foreach, {{ }}) that a template author has to learn as a second language layered over HTML. Wire instead expresses everything as attributes on the HTML you were already going to write:

{{-- Blade --}}
@if ($user->isAdmin())
    <p>Welcome back, administrator.</p>
@endif
{{-- Wire --}}
<p x-if="user.is_admin">Welcome back, administrator.</p>

The practical benefit is that a Wire template can be handed to a designer who has never seen Zuri and they can still read it: it is HTML, with some attributes they can look up. It also means every Wire template validates as HTML5, can be opened directly in a browser to check its structure, and can be run through the html module’s own tools (html.format(), a linter, a selector query) without anything special-casing Wire’s own syntax.

Your First Template

Here is the smallest possible Wire template, rendered from a string:

import wire

echo wire.render_string('<p>Hello {{ name }}</p>', { name: 'Ada' })
# <p>Hello Ada</p>

{{ name }} is an interpolation: it evaluates the expression name against the variables you supplied and writes the result into the page, escaped for wherever it landed. Everything else in this guide is built out of that one idea, plus a handful of x- prefixed attributes that control whether and how many times an element is rendered.

Rendering Templates

Templates From Files

For anything beyond a one-off snippet, templates live in files. Build a Wire instance, point it at a directory, and render by path:

import wire

var view = wire.wire()
view.set_root('./views')

echo view.render('pages/home', { user, posts })

render()’s first argument is a path relative to the root. The .html extension is added automatically when the path as written names no file, so 'pages/home' finds pages/home.html; a path that already carries an extension ('pages/home.wire') is tried exactly as given first. set_extension() changes what gets tried when none is given.

Note A Wire instance is meant to be built once, configured, and reused for the life of your program — typically at startup, alongside however you already configure the rest of your application. Rendering does not mutate it, so it is safe to render from several places at once.

Templates From Strings

render_string() renders a template given directly as a string, without touching the filesystem. It behaves identically to render() in every other respect — the same directives, the same escaping, the same filters — and any x-include or x-extend inside the string still resolves against the configured root.

echo view.render_string('<p>{{ greeting }}</p>', { greeting: 'Hi there' })

The third argument names the source for error messages (it defaults to <source>), which is worth passing when the string came from somewhere with its own identity — a database row, a file you already had open for another reason:

view.render_string(row.body, { user }, 'cms:page:${row.id}')

render_string() is not cached, since there is no file path to key a cache entry on. Reach for render() for anything rendered more than once.

The Template Root

Every path — the one passed to render(), and the one written in every x-include and x-extend in every template — resolves inside the configured root directory, and nowhere else. This is not a convention; it is enforced by the loader on every single resolution, and it matters for security, not just organization.

view.set_root('./views')
view.root()
# '/home/you/project/views' — always the absolute path

The root does not have to exist yet. create_root() makes it, and reports whether it had to:

if view.create_root() {
  echo 'Created a fresh views/ directory.'
}

Wire never creates this directory on its own initiative — a typo in a root path should read as “template not found,” not silently produce an empty folder somewhere unexpected.

Displaying Data

You have already seen the basic form. {{ expression }} evaluates whatever is between the braces and writes the result into the page:

<h1>{{ post.title }}</h1>
<p>By {{ post.author.name }}</p>

expression is not limited to a bare variable name — it is a full expression, covered in its own section below — so all of the following are valid:

<p>{{ post.views > 1000 ? 'Popular' : 'New' }}</p>
<p>{{ post.tags|join(', ') }}</p>
<p>{{ user.nickname ?? user.name }}</p>

Escaping Data

Every interpolation is escaped for the specific place it lands, and this cannot be turned off from inside a template. If name holds <b>Ada</b>:

<p>Hello {{ name }}</p>

renders as:

<p>Hello &lt;b&gt;Ada&lt;/b&gt;</p>

which is exactly what you want when name came from a form field, a database column, or anywhere else outside your own control. Wire is not guessing at this — because it compiles a real parsed document rather than gluing strings together, it always knows precisely which kind of place an interpolation sits in, and it applies the escaping that place needs:

Where the interpolation isWhat happens
Text between tags&, <, > become entities
An ordinary attribute (title, alt, data-*, …)& and " become entities
href, src, action, and other URL attributesthe value’s URL scheme is checked too
<script>, or an on* event handler attributethe value is encoded as JSON
<style>anything outside a CSS-safe set is dropped

That is a materially stronger guarantee than “the special characters got replaced”: a value dropped into an href cannot smuggle in a javascript: scheme, and a value dropped into a <script> block cannot break out of its string literal, close the tag early, or open a comment — even though none of those things are <, >, or &.

Rendering Raw Markup

Escaping everything by default is right, but sometimes you genuinely have markup — the output of another render, a snippet you built and trust — and you want it written out as markup. The raw filter is how you say so:

<div class="article-body">{{ post.rendered_html|raw }}</div>

You can build the same value on the Zuri side and hand it to a template already marked as safe, with wire.safe():

import wire

view.render_string('<div>{{ body }}</div>', {
  body: wire.safe('<em>already-trusted markup</em>'),
})

Warning raw and wire.safe() are a promise that the value is safe to write unescaped at the place it lands. Applying either to anything a user submitted — a comment, a bio, a search query — reopens exactly the cross-site scripting hole the rest of Wire exists to close. Only reach for them on markup your own code produced.

If you need to render markup as text on purpose — showing someone a literal <script> tag in a code sample, say — that is what plain interpolation already does; there is nothing extra to opt into.

Wire and JavaScript Frameworks

Wire uses {{ }} for interpolation, the same delimiter many JavaScript templating libraries (Vue, Angular, Handlebars, Mustache) use for their own. If a Wire template also contains inline script that a browser-side framework is meant to interpret, escape the braces with a leading % so Wire leaves them alone:

<div id="app">
  <p>{{ user.name }}</p>          {{-- rendered by Wire, server-side --}}
  <p>%{{ message }}</p>           {{-- left as literal {{ message }}, for Vue --}}
</div>

%{{ renders as the literal text {{, and %{! does the same for template function calls. If you find yourself escaping braces constantly because most of a template belongs to a client-side framework, it may be worth keeping that section in its own file and serving it untouched rather than through Wire at all.

The Expression Language

Everything between {{ and }} — and everything given to x-if, x-for, x-attr, and the rest of the directives below — is written in Wire’s own small expression language. It is deliberately not a full embedded copy of Zuri: there is no assignment, no way to declare anything, and no way to reach a global variable. A template describes a page; the logic that decides what the page contains belongs in the code that calls render(), not in the template itself.

Literals

{{ 42 }}            {{-- a number --}}
{{ 3.14 }}           {{-- a decimal --}}
{{ 'a string' }}     {{-- single quotes --}}
{{ "a string" }}     {{-- or double, interchangeably --}}
{{ true }}           {{-- true / false / nil --}}
{{ [1, 2, 3] }}      {{-- a list literal --}}
{{ { id: 1, name } }} {{-- a dict literal; { name } is short for { name: name } --}}
{{ 0..pages }}       {{-- a range, exactly as Zuri writes one --}}

Looking Up Values

{{ user.name }}                 {{-- a dotted lookup --}}
{{ user.address.city }}         {{-- chains as deep as you like --}}
{{ items.0 }}                   {{-- a numeric key reads a list position --}}
{{ items[index] }}              {{-- a computed index --}}
{{ items[-1] }}                 {{-- negative counts from the end --}}
{{ items.length }}               {{-- a collection answers `length` by name --}}

Reading a variable that was never supplied gives nil rather than raising, and reading a key off nil gives nil too. That is what makes an optional value safe to reach through without a guard in front of it:

{{ user.profile.avatar_url }}

renders as nothing at all if user has no profile, instead of failing three levels down. Reach for x-if when the difference between “empty” and “genuinely missing” matters to what you show.

Note A name starting with an underscore can never be read from a template, the same way Zuri treats _field as private. If you find yourself wanting to read one, expose a public accessor from Zuri instead.

Operators

{{ price * quantity }}
{{ subtotal + tax }}
{{ stock - reserved }}
{{ total / count }}
{{ index % 2 }}

{{ a == b }}   {{ a != b }}
{{ a < b }}    {{ a <= b }}
{{ a > b }}    {{ a >= b }}

{{ 'admin' in user.roles }}
{{ 'admin' not in user.roles }}

{{ is_admin and is_active }}
{{ is_admin && is_active }}      {{-- && is the same as `and` --}}
{{ is_guest or is_banned }}
{{ is_guest || is_banned }}       {{-- || is the same as `or` --}}
{{ !is_active }}
{{ not is_active }}               {{-- ! is the same as `not` --}}

{{ stock > 0 ? 'In stock' : 'Sold out' }}
{{ nickname ?? name }}

A string on either side of + concatenates rather than raising:

{{ 'Hello, ' + user.name }}

?? and or look similar but answer different questions, and mixing them up is the single most common Wire mistake:

  • ?? asks “was this ever supplied?” and only falls back on nil.
  • or asks “is this worth showing?” using Wire’s own truthiness, and falls back on anything falsy — nil, false, an empty string, an empty collection, or the number 0.
{{ discount ?? 0 }}   {{-- a missing discount becomes 0 --}}
{{ discount or 0 }}   {{-- a discount that IS 0 also becomes 0, harmlessly here --}}

{{ stock_count ?? 'unknown' }}  {{-- 0 in stock still shows as 0 --}}
{{ stock_count or 'unknown' }}  {{-- 0 in stock is treated the same as never supplied --}}

Use ?? whenever zero is a legitimate value you want to keep, and or whenever you only want to show something when there is genuinely something to show.

Truthiness

x-if, x-not, and/or, !/not, and ? : all use Wire’s own notion of truthy and falsy, which differs from Zuri’s own rules in one place, deliberately, for template authoring:

ValueWire
nil, falsefalsy
0, NaNfalsy
any other number, including negative onestruthy
an empty stringfalsy
a non-empty stringtruthy
an empty list or dictfalsy
a non-empty list or dicttruthy
anything elsetruthy

The difference is the empty collection. Zuri treats [] and {} as truthy; Wire treats them as falsy, so x-if="results" correctly hides a section for a search that came back with nothing.

Calling Functions

A value registered as a global can be called directly:

<a href="{{ route('user.profile', user.id) }}">{{ user.name }}</a>

There is also an older, standalone spelling for calling a function that takes no arguments, kept from Wire’s very first version because it reads well on its own:

{! current_year !}

is exactly the same as writing:

{{ current_year() }}

Prefer {{ fn() }} for anything new; {! !} exists for templates that already use it and for the handful of cases — a page’s build stamp, a feature flag — where a function taking nothing at all reads a little cleaner without the parentheses.

Filters

A filter transforms the value on the left of a |. It is the same idea as a Unix pipe:

{{ name|upper }}
{{ price|round(2) }}
{{ post.body|truncate(150) }}

The value being filtered is always the filter’s first argument; anything written in parentheses follows it. truncate(150) above calls the truncate filter as truncate(post.body, 150).

Chaining Filters

Filters read left to right, each one’s output feeding the next:

{{ name|trim|title }}
{{ comment.body|strip_tags|truncate(200) }}

The = Argument Shorthand

For a filter that takes exactly one argument, name=value is shorthand for name(value):

{{ status|is='active' }}

is the same as:

{{ status|is('active') }}

This spelling exists for symmetry with Wire’s very first version and reads naturally for a short comparison; the parenthesised form is equally valid everywhere and is the only option once a filter needs more than one argument.

Available Filters

Escaping

FilterWhat it does
rawMarks the value as markup, skipping escaping entirely.
escape / eEscapes for a context other than the one the value is being written into. Takes 'text' (the default), 'attribute', 'url', 'script', or 'style'.

Text

FilterWhat it does
upperConverts to upper case.
lowerConverts to lower case.
titleTitle Cases Every Word.
capitalizeCapitalizes only the first letter, leaving the rest alone.
trimRemoves leading and trailing whitespace of every kind (space, tab, newline).
truncate(length, suffix?)Cuts to length characters, appending suffix (default '…') only if anything was actually cut.
replace(search, replacement?)Replaces every literal occurrence of search. Never a regular expression. replacement defaults to an empty string.
lpad(width, fill?)Pads on the left to width characters, with fill (default a space).
rpad(width, fill?)Pads on the right.
repeat(count)Repeats the value count times.
nl2brTurns line breaks into <br>, escaping the text first. Returns markup.
line_breaksAn alias for nl2br.
strip_tagsRemoves every HTML tag, keeping only the text — a real parse, not a pattern match.
slugLower-cased, hyphen-separated, safe for a URL segment.
url_encodePercent-encodes for use inside a URL.
jsonEncodes as JSON text.
json_script(id?)Wraps the value’s JSON encoding in a <script type="application/json">, optionally with an id, ready to be read back by a script on the page. Returns markup.

Numbers

FilterWhat it does
absRemoves the sign.
round(places?)Rounds to places decimal places (default 0), halves rounding away from zero.
floorRounds down to the nearest whole number.
ceilRounds up.
number_format(places?, point?, separator?)Groups thousands and fixes the decimal places, like 1,234,567.89. Pass point/separator to use another convention, e.g. 1.234.567,89.
filesize(binary?)A byte count written the way a person reads it — 1.4 MB by default, or 1.3 MiB with binary set to true.

Collections

FilterWhat it does
lengthHow many entries — works on a string, list, dict, or bytes. nil has length 0.
firstThe first entry, or nil if there is none.
lastThe last entry.
join(glue?)Joins entries into one string with glue (default '') between them.
sort(key?)Sorted ascending; key sorts a list of dicts or instances by one field.
reverseThe entries backwards, or a string reversed.
uniqueDuplicates removed, keeping the first of each.
keysA dict’s keys, in insertion order.
valuesA dict’s values, in insertion order.
slice(start, end?)The entries from start up to but not including end. Negative positions count from the end.
sum(key?)Adds the entries together; key sums one field of a list of dicts or instances.
split(separator?)Splits a string into a list. separator defaults to any run of whitespace.

Choice

FilterWhat it does
default(fallback) / altfallback when the value is falsy, otherwise the value.
emptyWhether the value has nothing in it. Unlike falsiness, a number is never empty — not even 0.
is(expected)Whether the value equals expected.
not(expected)Whether it differs.

Dates

FilterWhat it does
date(format?)Formats a date.Date, a Unix timestamp, or a parseable date string, using the same format directives as Date.format(). Defaults to 'Y-m-d H:i:s'.
<time datetime="{{ post.published_at|date('Y-m-d') }}">
  {{ post.published_at|date('jS F Y') }}
</time>

Writing Your Own Filter

See Custom Filters below.

Conditionals

x-if, x-elif, and x-else

x-if renders an element, and everything inside it, only when its expression is truthy:

<p x-if="user.is_admin">You have administrator access.</p>

If user.is_admin is falsy, the whole <p> — tag and contents — is left out of the page entirely. There is no empty element left behind.

Chain further conditions with x-elif, and close the chain with a plain x-else:

<p x-if="user.role == 'admin'">Administrator</p>
<p x-elif="user.role == 'staff'">Staff member</p>
<p x-elif="user.role == 'contributor'">Contributor</p>
<p x-else>Member</p>

Exactly one of these renders. Only whitespace and HTML comments are allowed between the elements in a chain — any real content in between ends it, and an x-elif or x-else with no x-if in front of it is rejected when the template compiles, not silently ignored:

{{-- this chain is broken by the text between the two elements --}}
<p x-if="a">A</p>
some text
<p x-else>not A</p> {{-- error: x-else has no x-if in front of it --}}

x-not

x-not is the plain inverse of x-if — it renders when its expression is falsy — and does not take part in a chain:

<div x-not="user.has_verified_email">
  <p>Please verify your email address.</p>
</div>

x-if="!condition" and x-not="condition" mean the same thing; x-not exists because it often reads more naturally for a guard clause.

Loops

x-for repeats an element once per entry of whatever its expression evaluates to — a list, a dict, a string, or a range:

<ul>
  <li x-for="posts" x-value="post">{{ post.title }}</li>
</ul>

Notice that the element itself repeats, not just its contents — the example above produces one whole <li> per post, not one <li> wrapping every post. x-value names the variable each iteration binds its current entry to; leave it out entirely if you do not need to refer to the entry by name.

An optional x-key binds the position (for a list) or the key (for a dict):

<tr x-for="users" x-key="id" x-value="user">
  <td>{{ id }}</td>
  <td>{{ user.name }}</td>
</tr>

Every kind of collection iterates naturally:

<li x-for="tags" x-value="tag">{{ tag }}</li>              {{-- a list --}}
<li x-for="scores" x-key="name" x-value="score">           {{-- a dict --}}
  {{ name }}: {{ score }}
</li>
<span x-for="word" x-value="letter">{{ letter }}</span>    {{-- a string, by character --}}
<option x-for="1..5" x-value="n">{{ n }}</option>          {{-- a range --}}

A missing or empty collection simply renders nothing — there is no need to guard a loop with an x-if first:

<li x-for="comments" x-value="comment">{{ comment.body }}</li>
{{-- renders nothing at all if `comments` is empty or was never supplied --}}

The loop Variable

Every iteration publishes a loop variable with the following fields:

FieldValue
loop.indexThe position, counting from 1.
loop.index0The position, counting from 0.
loop.firsttrue on the first pass.
loop.lasttrue on the last pass.
loop.lengthHow many entries there are in total.
loop.eventrue on the 2nd, 4th, 6th, … pass — even-numbered by loop.index.
loop.oddtrue on the 1st, 3rd, 5th, … pass.
loop.keyThe current key or index, whether or not x-key binds it too.
loop.valueThe current value, whether or not x-value binds it too.
loop.parentThe enclosing loop’s own loop, for a nested x-for.
<tr x-for="rows" x-value="row" x-attr="{ 'class': loop.odd ? 'zebra' : nil }">
  <td>{{ loop.index }}</td>
  <td>{{ row.name }}</td>
</tr>

Nesting Loops

x-loop renames the metadata variable a specific x-for publishes, which is what lets an inner loop’s own loop and an outer loop’s loop both be reached at once:

<table x-for="rows" x-loop="row" x-value="cells">
  <tr>
    <td x-for="cells" x-value="cell">
      {{ row.index }}, {{ loop.index }}: {{ cell }}
    </td>
  </tr>
</table>

Without x-loop, the inner loop’s own loop would simply shadow the outer one for the scope of the inner loop — reach for loop.parent instead if you would rather not rename anything:

<td x-for="cells" x-value="cell">
  outer position {{ loop.parent.index }}, inner position {{ loop.index }}
</td>

Looping Without a Wrapper Element

Sometimes you want to repeat several elements together without a real element wrapping them, or without introducing any element at all. Put the directive on a <template> instead of on the element you want repeated:

<select>
  <template x-for="countries" x-value="country">
    <option value="{{ country.code }}">{{ country.name }}</option>
  </template>
</select>

<template> is the one HTML element the parser lets through completely untouched, wherever it appears — inside a <head>, inside a <table>, inside a <select> — and Wire removes it from the output entirely, leaving only what was inside it, once per pass. This is also the way to apply x-if to a group of elements without picking one of them to carry the attribute:

<template x-if="user.is_admin">
  <a href="/admin">Dashboard</a>
  <a href="/admin/users">Users</a>
</template>

Content and Attributes

x-text

x-text replaces an element’s children with its expression, escaped as plain text — useful when the element already has other attributes and you would rather not write the value twice:

<p x-text="post.summary"></p>

is the same as:

<p>{{ post.summary }}</p>

Anything written inside the element in the source is discarded; it exists only to describe what would go there without JavaScript.

x-html

x-html is x-text’s unescaped counterpart — it replaces the element’s children with its expression’s value, written as markup rather than text:

<div x-html="post.rendered_body|raw"></div>

Note the |raw: x-html still expects a value marked safe, the same as an ordinary interpolation would. x-html only changes where the markup goes (replacing the whole element’s contents, rather than sitting inline in a text run) — it does not, on its own, turn escaping off. The same warning about untrusted input applies here just as much as it does to the raw filter.

x-attr

x-attr spreads a dictionary of names and values onto the element as attributes:

<input x-attr="{ type: 'text', name: field.name, required: field.is_required }">

Within that dictionary:

  • true produces a valueless attribute (required), exactly as writing required by hand would.
  • false and nil leave the attribute off entirely, rather than writing it with an empty or literal "false" value.
  • Anything else is written as the attribute’s value, escaped for whatever that attribute means (a URL attribute is checked as a URL, same as always).

This is what turns a boolean into a real HTML boolean attribute without a ternary in every place one is needed:

<button x-attr="{ disabled: !form.is_valid }">Submit</button>

A computed attribute takes over from one written directly on the same element, so you can set a sensible default and only override it when there is something to override:

<div class="card" x-attr="{ 'class': featured ? 'card card-featured' : nil }">

Comments

HTML’s own comment syntax is a server-side note in a Wire template. It never reaches the rendered page, and nothing inside one is evaluated — which is exactly what makes it safe to leave notes for other people maintaining the template, without those notes shipping to a browser:

<!-- TODO: replace this hard-coded banner once marketing sends the real copy -->
<div class="banner">Coming soon</div>

<!-- this variable is not evaluated: {{ some.internal.detail }} -->

If you genuinely want a comment to reach the browser — a conditional comment, a build stamp a deploy script checks for — turn comments back on for that Wire instance with set_comments(true).

Including Templates

<include path="..." /> renders another template in its place. It also has a directive spelling, <template x-include="...">, which means exactly the same thing — the pseudo-element forms in this guide (<include>, <extend>, <declare>, <define>) are sugar, rewritten into the directive form before the template is even parsed, which is what lets them work correctly inside a <head> or a <table> where an element the parser does not recognise would otherwise be moved or dropped.

<include path="partials/nav.html" />

is the same as:

<template x-include="partials/nav.html"></template>

The path is resolved inside the template root, the same as render()’s own path argument.

By default, an include sees every variable the page around it can see — it is not a separate scope:

{{-- page.html --}}
<include path="partials/greeting.html" />
{{-- partials/greeting.html --}}
<p>Hi, {{ user.name }}!</p>

renders correctly without user ever being passed explicitly to the partial.

Passing Data to an Include

x-with adds variables for the included template, alongside whatever it already inherits:

<include path="partials/badge.html" x-with="{ label: 'New', tone: 'green' }" />

only withholds the surrounding scope entirely, leaving the partial with only what x-with gave it — turning a plain partial into something closer to a real component with a defined interface:

<include path="components/price-tag.html" x-with="{ amount: item.price }" only />

Includes as Components

Anything written inside an <include> tag is handed to the included template as a named region called content, declared with <declare name="content">:

{{-- components/card.html --}}
<section class="card">
  <h3>{{ title }}</h3>
  <declare name="content"></declare>
</section>
{{-- the page --}}
<include path="components/card.html" x-with="{ title: 'Recent Orders' }">
  <p>{{ orders|length }} orders this week</p>
</include>

renders as:

<section class="card">
  <h3>Recent Orders</h3>
  <p>3 orders this week</p>
</section>

The content you write inside the <include> tag keeps the scope it was written in — the page’s, not the partial’s — which is exactly what lets {{ orders|length }} above read a variable from the page even though x-only was not used and the card component never mentions orders at all.

Computed Include Paths

A path is read as literal text, with {{ }} interpolation allowed inside it — not as an expression in its own right, so x-include="header" names a file called header, rather than reading a variable of that name:

<include path="themes/{{ current_theme }}/header.html" />

resolves a different file per render depending on current_theme, while everything after path="themes/" up to the next {{ is taken literally.

Template Inheritance

Includes are for small, reusable fragments — a navbar, a footer, a badge. For whole-page structure — the parts of a layout every page on your site shares — template inheritance is the better fit. Where an include composes fragments together, inheritance lets a base layout define the shape of a page once, and lets each page fill in only the parts that differ.

Defining a Layout

A base template marks the regions a page is allowed to fill in with <declare name="...">:

{{-- layouts/app.html --}}
<!DOCTYPE html>
<html>
  <head>
    <title>{{ title }}</title>
    <declare name="head"></declare>
  </head>
  <body>
    <nav><!-- shared navigation --></nav>
    <main>
      <declare name="content"></declare>
    </main>
    <footer>
      <declare name="footer">
        <p>&copy; {{ year }} My Company</p>
      </declare>
    </footer>
  </body>
</html>

Notice that footer has content already inside its <declare> tag. That becomes the default — what renders when a page does not define that region at all.

Extending a Layout

A page declares which layout it extends with <extend base="...">, and fills in regions with <define name="...">:

{{-- pages/dashboard.html --}}
<extend base="layouts/app.html">
  <define name="head">
    <meta name="description" content="Your account dashboard.">
  </define>
  <define name="content">
    <h1>Welcome back, {{ user.name }}</h1>
    <p>You have {{ notifications|length }} new notifications.</p>
  </define>
</extend>

Rendering pages/dashboard.html produces the layout’s full structure, with head and content replaced by what the page defined, and footer left exactly as the layout’s own default. Both the layout and the page are rendered against the same variables render() was given — there is no separate scope to pass anything through.

Everything inside an <extend> must be inside a <define>. The base template owns the page’s structure; a page’s job is only to fill in the regions it was offered, not to add markup of its own outside them. A template can extend exactly one base.

Default Slot Content

A region a page does not define keeps whatever the layout put inside its own <declare> tag:

{{-- pages/minimal.html --}}
<extend base="layouts/app.html">
  <define name="content">
    <p>Just this page's content — head and footer both use the layout's defaults.</p>
  </define>
</extend>

A <declare> tag with nothing inside it — like head in the layout above — simply renders empty when nothing defines it.

x-super

<super /> — or its directive spelling, <template x-super></template> — renders whatever the region it sits inside would have shown before this definition replaced it. That lets a page add to a section instead of fully restating it:

<extend base="layouts/app.html">
  <define name="footer">
    <super />
    <p><a href="/privacy">Privacy Policy</a></p>
  </define>
</extend>

renders the layout’s own copyright line, followed by the extra privacy link — without the page having to know or repeat what the layout’s default footer actually said.

Multi-Level Inheritance

A template that extends a base can itself declare regions of its own, letting a further template extend it. This is how a site with, say, a general layout and several page-type-specific layouts (a blog post, a product page) is usually structured:

{{-- layouts/app.html --}}
<html><body>
  <declare name="body"><p>default</p></declare>
</body></html>
{{-- layouts/article.html --}}
<extend base="layouts/app.html">
  <define name="body">
    <article>
      <declare name="article-content"></declare>
    </article>
  </define>
</extend>
{{-- pages/post.html --}}
<extend base="layouts/article.html">
  <define name="article-content">
    <h1>{{ post.title }}</h1>
    {{ post.body|raw }}
  </define>
</extend>

Rendering pages/post.html walks the whole chain: it extends layouts/article.html, which extends layouts/app.html, and the final page is assembled from all three.

Overriding a Definition

Defining the same region twice in one template is almost always an accident — two people editing the same file, a copy-paste that was never cleaned up — so Wire refuses it at compile time unless you say the replacement is deliberate with override:

<extend base="layouts/app.html">
  <define name="content"><p>First draft</p></define>
  <define name="content" override><p>Final version</p></define>
</extend>

Without override on the second one, this template fails to compile with a clear message naming the region and both locations, rather than silently keeping whichever definition happened to come last.

Security

Why Everything Is Escaped by Default

A templating engine’s job includes keeping a value that came from outside your program — a form submission, a query string, another user’s profile — from becoming markup, a script, or a link, unless you explicitly say it is safe to. Wire treats this as the default rather than an opt-in setting, for the same reason a seatbelt only works if putting it on is the ordinary thing to do rather than the thing you remember to do under pressure: the moment escaping is something you have to remember to turn on, the templates that skip it are the ones that get exploited.

Every interpolation is escaped for the exact place it lands — see the table earlier in this guide — and there is no template-level setting to disable that. The only way past it is the explicit, visible raw filter or x-html directive, which is exactly the friction you want between “displaying a value” and “trusting a value with unescaped markup.”

URLs Are Checked, Not Just Escaped

An attribute a browser reads as a URL — href, src, action, and several others — gets more than entity escaping. Its scheme is checked against an allowlist, and a disallowed scheme is replaced with about:blank rather than written through:

<a href="{{ profile_link }}">{{ user.name }}</a>

If profile_link were javascript:alert(document.cookie), the rendered href is about:blank, not the script URL. A URL with no scheme at all — every relative link, every absolute path, every protocol-relative URL — is always allowed, since none of those can name a handler in the first place.

The default allowlist covers http, https, mailto, tel, and a handful of others; it deliberately leaves out javascript, vbscript, data, and file. set_url_schemes() replaces the list for an application that genuinely needs another scheme — a custom app: handler, say.

srcset and ping, which each hold a list of URLs, have every entry in the list checked the same way, with descriptors (2x, 640w) and separators preserved exactly as written.

Scripts and Stylesheets

A value interpolated inside a <script> element, or inside an event handler attribute like onclick, is encoded as JSON rather than merely escaped — automatically, with no filter needed:

<script>
  var currentUser = {{ user }};
</script>

renders the whole user value as a JSON literal, with its own quotes included. That is worth internalising, because it changes how you write the surrounding script: do not wrap the interpolation in your own quotes.

{{-- correct: renders   var name = "Ada";   --}}
<script>var name = {{ user.name }};</script>

{{-- wrong: renders   var name = '"Ada"';   which is not what you meant --}}
<script>var name = '{{ user.name }}';</script>

A value inside a <style> block has anything outside a conservative, CSS-safe character set dropped rather than escaped — there is no escape sequence that would stop a < from being read by the HTML tokenizer looking for </style>, so the only sound answer is for the character not to be there at all.

The Template Root Is a Sandbox

Every path — whether it came from render()’s own argument, or from an x-include/x-extend written inside a template, even one built from an interpolated value — is resolved inside the configured root and refused if it resolves anywhere else, .. segments and symbolic links both included:

<include path="{{ theme }}/header.html" />

If theme ever came from something a request controls, an unbounded loader would turn this into a way to read arbitrary files the process can reach. Wire’s loader treats the root as a hard boundary instead: the worst a hostile value can do here is fail to find a template.

Extending Wire

Custom Filters

register_filter() adds a filter, or replaces one that already exists by that name. The value being filtered is always the first argument; anything the template passes after it follows:

view.register_filter('excerpt', @(value, words) {
  var count = words ?? 25
  return ' '.join(value.split('/\\s+/').take(count)) + '…'
})
<p>{{ post.body|excerpt(40) }}</p>

An argument the template leaves out arrives as nil, which is why words ?? 25 above supplies a sensible default rather than the filter insisting the template spell it out every time.

A filter returning a plain value has that value escaped like any other — the same as an ordinary interpolation. A filter that genuinely produces markup returns wire.safe(), and from that point on is responsible for what is inside it, exactly like raw and x-html are.

Globals

register_global() makes a value readable from every template without it being passed to render() explicitly:

view.register_global('site_name', 'Example Inc.')
view.register_global('route', @(name) {
  return '/' + name
})
<title>{{ page_title }} — {{ site_name }}</title>
<a href="{{ route('user.profile', user.id) }}">{{ user.name }}</a>

A variable of the same name passed to render() wins over a global — a global is a default, not a hard override, so a page can still shadow site_name for itself if it ever needs to.

Custom Elements

For the rare case the directives genuinely cannot express, register_element() claims an HTML tag name outright and hands every element of that name to a Zuri function instead of writing it out:

view.register_element('icon', @(view, element) {
  var name = element.attributes.get('name', 'dot')
  return wire.safe('<svg class="icon"><use href="#${name}"></use></svg>')
})
<icon name="star" />

The function receives the Wire instance and a dictionary describing the element — its tag name, its attributes (already rendered and escaped), its already-rendered children, and the scope it was written in. Return wire.safe(markup) to write markup, any other value to write it as escaped text, or nil to write nothing at all. Directives still work on a claimed element exactly as they read (<icon x-if="..." /> behaves the way it looks), and the whole mechanism exists for genuinely dynamic, code-driven markup that a partial and x-include cannot express — reach for an include first; it is easier for the next person to read and does not require them to go find the Zuri function behind it.

Configuration

Every setting can be passed to the Wire constructor at once, or set individually with its own method afterward:

var view = wire.wire({
  root: './views',
  extension: '.html',
  compact: true,
  comments: false,
  auto_reload: true,
  url_schemes: ['https', 'mailto'],
})
Option / methodDefaultWhat it controls
root / set_root(path)./templatesThe directory every template path resolves inside.
extension / set_extension(ext).htmlTried when a path names no file as written. Must start with ..
compact / set_compact(bool)falseDrops whitespace-only text between tags.
comments / set_comments(bool)falseWhether HTML comments reach the rendered output.
auto_reload / set_auto_reload(bool)trueWhether a cached template is checked against its file, and re-read if it changed, before each render.
url_schemes / set_url_schemes(list)see aboveWhich URL schemes an href/src/etc. is allowed to use.

root() reports the currently configured root as an absolute path, and create_root() makes the directory if it does not exist yet (returning whether it had to).

Compiling and Caching

render() compiles a template into its instruction tree the first time it is used, and keeps the compiled result for next time — parsing and directive resolution happen once per template, not once per request. Every subsequent render just walks that tree.

With auto_reload on (the default), each render checks the file’s modification time and size against what was cached, and recompiles if either changed. That is one filesystem stat per template per render — cheap, and exactly what you want while actively editing templates. Turn it off with set_auto_reload(false) once you are running under load and are not editing templates live; clear_cache() is then how a long-running process picks up a new deployment.

compile(path) compiles a template without rendering it, and returns the compiled result — or raises, if the template has a mistake in it. That makes it a good fit for a startup-time check across a whole directory of templates, so a broken one is caught before the first request that would have hit it:

for name in os.read_dir('./views', true) {
  view.compile(name)
}

Error Handling

Everything Wire raises is a wire.WireError, and every one of them carries the template path and the line/column of the tag responsible, so catching the base class is enough to handle any template failure the same way — turning it into a 500 page, logging it with context, whatever your application needs:

catch {
  echo view.render('pages/dashboard', { user })
} as e {
  if instance_of(e, wire.WireError) {
    log.error('template failed: ${e.reason} at ${e.location()}')
    echo view.render('errors/500')
  }
}

Three more specific errors, each a WireError, tell you what kind of thing went wrong:

ErrorRaised when
wire.TemplateSyntaxErrorA template does not compile: an unknown directive, a malformed expression, a broken inheritance chain. Always caught the first time a template is used, not on a later render.
wire.TemplateNotFoundErrorA path names no file, or resolves outside the template root.
wire.RenderErrorSomething that depends on the values a template was given: iterating something that cannot be iterated, a filter rejecting its input, an include chain that never bottoms out.

A variable that was simply never supplied is not one of these — that renders as empty and tests as falsy, by design, so an optional section can be written without a guard around every single field it touches.

Full Directive Reference

Every directive Wire understands. An x- prefixed attribute outside this list fails to compile — Wire owns that whole prefix, so a typo like x-fi is caught the moment the template is compiled rather than silently doing nothing.

DirectiveTakesMeaning
x-ifexpressionRenders only if truthy. Starts a chain.
x-elifexpressionContinues an x-if chain.
x-else(none)Closes an x-if chain.
x-notexpressionRenders only if falsy. Does not chain.
x-forexpressionRepeats the element once per entry.
x-valuenameBinds each iteration’s value.
x-keynameBinds each iteration’s key/index.
x-loopnameRenames the loop metadata variable.
x-textexpressionReplaces children with escaped text.
x-htmlexpressionReplaces children with unescaped markup.
x-attrexpression (dict)Spreads attributes onto the element.
x-includepathRenders another template in this element’s place.
x-withexpression (dict)Adds variables for an include.
x-only(none)Withholds the surrounding scope from an include.
x-extendpathThis template extends the named base.
x-slotnameDeclares a region an extending template may replace.
x-definenameReplaces a base template’s region.
x-override(none)Permits redefining a region already defined once.
x-super(none)Renders the definition this one replaces.

Five pseudo-elements are sugar for the directives above, rewritten before the template is parsed:

Pseudo-elementEquivalent to
<include path="p" /><template x-include="p"></template>
<extend base="p">…</extend><template x-extend="p">…</template>
<declare name="n">…</declare><template x-slot="n">…</template>
<define name="n">…</define><template x-define="n">…</template>
<super /><template x-super></template>

Cheat Sheet

{{-- Variables --}}
{{ name }}
{{ user.address.city }}
{{ items.0 }}
{{ items[index] }}

{{-- Filters --}}
{{ name|upper }}
{{ price|round(2) }}
{{ post.body|truncate(150) }}
{{ status|is='active' }}

{{-- Raw markup --}}
{{ trusted_html|raw }}

{{-- Conditionals --}}
<p x-if="condition">…</p>
<p x-elif="other">…</p>
<p x-else>…</p>
<p x-not="condition">…</p>

{{-- Loops --}}
<li x-for="items" x-value="item" x-key="i">{{ i }}: {{ item }}</li>
{{ loop.index }} {{ loop.first }} {{ loop.last }} {{ loop.length }}

{{-- Wrapper-free groups --}}
<template x-for="items" x-value="item">…</template>
<template x-if="condition">…</template>

{{-- Content and attributes --}}
<p x-text="value"></p>
<div x-html="trusted_html|raw"></div>
<input x-attr="{ type: 'text', disabled: !enabled }">

{{-- Comments (never rendered) --}}
<!-- a note for the next person -->

{{-- Includes --}}
<include path="partials/nav.html" />
<include path="components/card.html" x-with="{ title: 'X' }" only>
  content for the component's declared region
</include>

{{-- Inheritance --}}
<extend base="layouts/app.html">
  <define name="content">…</define>
  <define name="footer"><super />…</define>
</extend>

Further reading: the html module that Wire’s parser and serializer are built on, and the date module for the format directives the date filter accepts.