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 the render() API
directly, see 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.
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 } ;
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}" ;
component = "<", upper_identifier, { whitespace, prop }, [ whitespace ],
( "/>" | ">", template, "</", 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. There is only one, unnamed slot per component; Tau 0.9
does not have named 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.
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.