Tau language specification
This document specifies Tau 0.9. Tau templates are UTF-8 text and use the .tau extension. Tau 0.9
is a superset of Tau 0.8: every Tau 0.8 template still parses and renders identically (see
Compatibility).
If you're using Tau through a Steno theme rather than calling the render() API directly, head to
Themes and Tau instead - it covers the context a layout receives (site,
theme, assets, ...) and is the faster path to a working template. This document is the language
reference, for when you need the full picture.
Quick example
<ul>
{#each posts as post, index}
<li class="{post.featured ? 'featured' : ''}">
{index + 1}. <a href="{post.url | url}">{post.title | upper}</a>
{#if post.date}<time>{post.date | date}</time>{/if}
</li>
{:else}
<li>No posts yet.</li>
{/each}
</ul>
Given posts = [{ title: "Hi", url: "/hi", date: "2026-01-05", featured: true }], this renders one
<li class="featured"> with an uppercased title, a validated link, and a localized date. An empty
or missing posts renders the {:else} branch instead. Note that expressions always sit inside
quotes in an attribute (class="{...}"), even though {expression} and {expr} look identical
either way - see Escaping and output contexts for why. The rest of
this document covers each piece in detail: expressions,
filters, control flow, and components.
Grammar
The grammar uses an EBNF-like notation. expression is the restricted JavaScript-expression subset
described below. - markers on a tag are the optional whitespace-control
suffix/prefix.
template = { text | interpolation | raw_html | include | comment
| if_block | each_block | let_binding | component
| children_slot | named_slot } ;
interpolation = "{", expression, { "|", filter }, "}" ;
filter = identifier, [ "(", [ expression, { ",", expression } ], ")" ] ;
raw_html = "{@html ", expression, "}" ;
include = "{@include ", quoted_path, "}" ;
comment = "{#", { any character except "#}" }, "#}" ;
if_block = "{", ["-"], "#if ", expression, ["-"], "}", template,
{ "{", ["-"], ":else if ", expression, ["-"], "}", template },
[ "{", ["-"], ":else", ["-"], "}", template ],
"{", ["-"], "/if", ["-"], "}" ;
each_block = "{", ["-"], "#each ", expression, " as ", identifier,
[ ",", identifier ], ["-"], "}", template,
[ "{", ["-"], ":else", ["-"], "}", template ],
"{", ["-"], "/each", ["-"], "}" ;
let_binding = "{#let ", identifier, " = ", expression, "}" ;
children_slot = "{@children}" ;
named_slot = "{@slot ", identifier, "}" ;
slot_content = "{#slot ", identifier, "}", template, "{/slot}" ;
component = "<", upper_identifier, { whitespace, prop }, [ whitespace ],
( "/>" | ">", { template | slot_content }, "</", upper_identifier, ">" ) ;
prop = identifier
| identifier, "=", quoted_string
| identifier, "={", expression, "}"
| "{", identifier, "}" ;
quoted_path = '"', path_chars, '"' | "'", path_chars, "'" ;
identifier = ( letter | "_" | "$" ), { letter | digit | "_" | "$" } ;
upper_identifier = uppercase_letter, { letter | digit | "_" | "$" } ;
Control tags must be balanced. Components are self-closing (<Foo />) or carry children
(<Foo>...</Foo>). Includes use a literal path; dynamic include paths are not part of Tau.
Comments
{# any text #} is removed at parse time and produces no output. Comments cannot be nested and
cannot contain the literal #}. A comment does not start with {#if, {#each, or {#let, so
those tags are never mistaken for one.
{# TODO: replace with real navigation once the API ships #}
Expressions
Tau accepts side-effect-free JavaScript expressions for property access, indexing, comparisons, arithmetic, boolean logic, the ternary operator, optional chaining, nullish coalescing, literals, and calls to functions explicitly supplied in the render context.
Tau rejects assignment, increment/decrement, arrow and function expressions, classes, new,
await, yield, delete, template literals, and statement separators. It also rejects any
identifier in a fixed blocklist, regardless of whether it resolves to anything in context:
AsyncFunction, Deno, Function, WebAssembly, __proto__, __tauIterable, constructor,
context, eval, globalThis, helpers, html, import, module, process, prototype,
require, self, and window. The blocklist is enforced structurally against a parsed expression,
not scanned as text: it applies equally to a static property (value.constructor) and a dynamically
computed one (value["constructor"], value["cons" + "tructor"]), so building a blocked name at
runtime does not bypass it. A {#each} item or index binding, or a {#let} name, may still shadow
a blocklisted name for the duration of its scope, the same way a real for...of or const binding
would.
Tau hardening is defense in depth for trusted theme templates. The expression subset is not an isolation boundary for arbitrary hostile code.
Property access supports both dot and bracket form, and both can be made optional with ?.:
{user.name}
{user["name"]}
{assets['style.css']}
{user?.name}
{settings?.["theme"]}
?. (plain or bracket) short-circuits to undefined - without throwing - when the object it's
accessed on is null or undefined, the same as in JavaScript. Plain ./[] access on a
null/undefined object is a render error (see Values).
Async function calls
A call in an expression ({fn()}, {obj.method(arg)}) is always awaited, so a context-supplied
function may be sync or async without any special syntax - await itself remains rejected as
expression syntax. Filters registered on the filters export may also return a promise; it is
awaited the same way. render() is therefore always async and returns Promise<string>.
render({
template: "{fetchTitle()}",
context: { fetchTitle: () => fetch("/title").then((r) => r.text()) },
components: {},
});
Local bindings
{#let name = expression} computes expression once and binds it to name for the rest of the
enclosing block - the same each/if/component nesting a {#each} item variable would use. It does
not need a closing tag; name stops being visible at the end of the block it appears in (end of the
template, or the enclosing {#if}/{#each}/component-children block).
{#each posts as post}
{#let excerpt = post.body | truncate(120)}
<p>{excerpt}</p>
{/each}
name follows the same identifier rules as a component or filter name and cannot start with
__tau.
Control flow
{#each items as item}...{:else}...{/each} renders the {:else} branch when items is nullish,
non-iterable, or empty - the loop body never ran. This mirrors {#if}'s {:else} but keys off
iteration count instead of a boolean.
{#each comments as comment}
<li>{comment.body}</li>
{:else}
<li class="empty">No comments yet.</li>
{/each}
Components
A component tag is either self-closing (<Card title={title} />) or carries children
(<Card title={title}>{@html body}</Card>). Children are compiled in the caller's scope - they
can reference the surrounding {#each} item, {#let} bindings, and page context - and rendered
once, before the component template runs. The component template retrieves the rendered children
with {@children}, a zero-argument tag equivalent to {@html children}:
<!-- theme component: Card.tau -->
<div class="card"><h2>{title}</h2><div class="body">{@children}</div></div>
<!-- usage -->
<Card title={post.title}>
<p>{post.excerpt}</p>
</Card>
A component with no {@children} in its template silently ignores any children content passed to
it.
Declare named content with {#slot name}...{/slot} directly inside a component call. Render it with
{@slot name} in the component template:
<!-- component: Panel.tau -->
<section><header>{@slot header}</header>{@children}<footer>{@slot footer}</footer></section>
<!-- usage -->
<Panel>
{#slot header}<h2>{post.title}</h2>{/slot}
<p>{post.excerpt}</p>
{#slot footer}<a href="/posts">All posts</a>{/slot}
</Panel>
Named content is excluded from {@children}. Each slot is rendered once in the caller's scope,
before the component runs, and inserted without double escaping. Missing and empty slots produce an
empty string. Slot names must be identifiers and unique within the call; prototype-related names and
the __tau prefix are forbidden. Declarations must be direct children of the component, but their
contents can contain conditionals, loops, includes, and nested components. Bindings declared inside
one slot stay inside that slot. Named slots are available as slots.name in the component context
(for example, {#if slots?.footer}...{/if}); nested components receive their own slots.
Whitespace control
{#if}, {:else if}, {:else}, {/if}, {#each}, and {/each} accept an optional -
immediately inside the tag delimiter on either side:
{-#if cond}(dash after{) trims trailing whitespace from the text immediately before the tag.{#if cond-}(dash before}) trims leading whitespace from the text immediately after the tag (i.e., at the start of its block).- Both can be combined (
{-#if cond-}), and each closing/branch tag ({:else-},{-/each},{/if-}, ...) accepts the same markers independently.
Trimming removes all adjacent whitespace (spaces, tabs, newlines), not just up to the next newline.
Other tags ({expr}, {@html}, {@include}, component tags) do not support trim markers.
<ul>
{#each items as item-}
<li>{item}</li>
{-/each}
</ul>
renders as <ul>\n <li>a</li><li>b</li>\n</ul> instead of leaving a blank line per iteration.
Values
- Missing identifiers evaluate to
undefined. nullandundefinedinterpolate as an empty string.- Other interpolated values are converted with
String(value). - Missing values are false in conditions.
- A nullish or non-iterable value produces zero loop iterations (and runs
{:else}if present). - Invalid property access or a context function that throws produces
TAU_RENDER_FAILEDwith the original failure available ascause. - Component boolean props have the value
true. - A missing component, filter, or include resolver is an error.
- Filter-specific conversion rules are part of each filter's contract.
Built-in filters
dateformats a value withDate.prototype.toLocaleDateString()in the host's locale. A falsy input renders as an empty string; an input that does not parse to a valid date renders asString(value)unchanged.truncate(length)cuts a stringified value tolengthcharacters, appending...when it was longer.lengthdefaults to 100 and falls back to 100 if it does not parse as a number.null/undefinedrender as an empty string.upperandlowerstringify and change case; a falsy input renders as an empty string.urlvalidates a value for use in a URL attribute; see below.slugifynormalizes accents, lowercases, and joins runs of non-letter/non-digit characters with-, trimming leading and trailing hyphens. Unicode letters and digits are preserved;null/undefinedbecome empty strings. For example,"Crème & Tea"becomes"creme-tea". This formats a string; it does not change a page's output path.pluralize(singular, plural)selectssingularwhen the numeric input is exactly1, andpluralotherwise. Defaults are""and"s":{count} post{count | pluralize}. Supply complete words for irregular forms:{count | pluralize("person", "people")}.number_format(locale, digits)formats numeric values and numeric strings usingIntl.NumberFormat. Defaults are"en-US"and at most3fractional digits;digitsmust be an integer from 0 to 20. For example,{price | number_format("de-DE", 2)}renders1234.567as1.234,57. Nullish/empty inputs become empty strings; non-finite or non-numeric values are returned as strings. Invalid locales or precision produce a render error.markdown_inlinerenders inline Markdown with the same Marked dependency used by Steno, without paragraph wrappers or block headings. Use{@html label | markdown_inline}to emit the HTML; ordinary{label | markdown_inline}escapes it. Nullish inputs become empty strings. This filter does not sanitize HTML or link URLs; use raw output only with trusted Markdown.
Filters chain left to right: {value | truncate(20) | upper} truncates first, then uppercases the
result. A filter may be sync or async (see Async function calls).
Escaping and output contexts
{expression} performs HTML escaping for &, <, >, ", and '. This is the rule in both text
and quoted-attribute positions. Tau does not infer HTML parser state, so expressions must not be
placed into unquoted attributes, element names, attribute names, JavaScript, CSS, or HTML comments.
URLs must use the url filter:
<a href="{target | url}">Open</a>
The filter permits relative URLs, fragments, and the http:, https:, mailto:, and tel:
schemes. It rejects control characters and all other schemes. It is a validation step; normal
interpolation then HTML-escapes the result.
{@html expression} performs no escaping and is only for trusted, already-sanitized HTML. Tau does
not provide an HTML sanitizer. {@children} is sugar for {@html children} and carries the same
caveat: children content is inserted unescaped, since it was already rendered (and escaped where
appropriate) at the call site.
Component prop expressions pass values without stringification to the component context. Escaping occurs when the component interpolates those values.
Resource limits
Limits are shared by the complete render tree:
- template size: 1 MiB per template;
- render/include/component depth: 64;
- loop iterations: 100,000;
- generated output: 16 MiB.
API consumers may lower or raise these values through TauOptions.limits.
Errors
All parser, policy, and resource-limit failures throw TauError. Its stable code is intended for
automation; human-readable messages may improve between patch releases. Source-backed parse errors
also expose filePath, line, and column.
Compatibility
Tau follows Steno's compatibility policy. The executable fixtures under src/utils/fixtures/tau/
record output and error-code behavior for each released Tau language line. Tau 0.9 is purely
additive over 0.8 - every construct in v0.8.json still produces the same output or error code; new
behavior (comments, {#let}, each/{:else}, component children, whitespace control, async calls)
is covered separately in v0.9.json.
See also
- Themes and Tau for the context a theme layout or component receives, and
how
{@include}differs from<Component />. - API reference for calling
render()directly and registering custom filters on thefiltersexport.