Seamless HTML templates, zero magic.
A template engine in Zig for static sites and live servers. Templates are plain HTML with <t-*> elements for inheritance, slots, loops, conditionals, and transforms. No custom delimiters, no embedded language. Feed it templates and data, get HTML back.
<t-extend template="base.html">
<t-define name="content">
<h1><t-var name="salon.name" /></h1>
<t-for style in styles sort="name" limit="5" as loop>
<article>
<h2><a t-var:href="style.url"><t-var name="style.name" /></a></h2>
<p><t-var name="style.description" transform="truncate:120" /></p>
<t-for technique in style.techniques>
<span class="tag"><t-var name="technique" /></span>
</t-for>
</article>
<t-else />
<p>No styles listed yet.</p>
</t-for>
</t-define>
</t-extend>That's a real template. Inheritance, nested loops, attribute binding, transforms, for-else -- all valid HTML.
Template:
<t-for product in products>
<li><t-var name="product.name" /> (<t-var name="product.hold" transform="lower" />)</li>
</t-for>Zig:
const toupee = @import("toupee");
var ctx = toupee.Context.init(allocator);
defer ctx.deinit();
try ctx.put("products", .{ .list = &.{
.{ .map = /* { name: "Pomade", hold: "STRONG" } */ },
.{ .map = /* { name: "Mousse", hold: "LIGHT" } */ },
} });
var resolver: toupee.Resolver = .{};
const html = try toupee.render(allocator, template, &ctx, resolver.loader(), .{});Output:
<li>Pomade (strong)</li>
<li>Mousse (light)</li>-
Templates should look like HTML. If your template isn't valid HTML structure, your tooling can't help you. Toupee uses custom elements (
<t-var>,<t-for>,<t-if>) that sit naturally alongside real markup. -
Parsing and rendering are separate. Parse once into a flat IR, cache it, render many times with different data. The IR is a
[]Nodetagged union slice -- contiguous memory, no pointer chasing, no GC. -
No embedded language. Templates don't execute arbitrary code. Transforms handle formatting; conditionals handle branching; loops handle iteration. That's the whole language.
-
Errors should help. Source excerpts with line numbers, caret highlighting, typo suggestions via Levenshtein distance, and include stack traces. When something goes wrong, the error tells you where and why.
- Template inheritance --
<t-extend>with named<t-slot>/<t-define>pairs and defaults - Components --
<t-include>with attributes, body slots, and nested defines - Scope isolation --
<t-include isolated context="post, site">passes only named data to components - Variables --
<t-var>(escaped) and<t-raw>(unescaped) with dot-path resolution - Attribute binding --
<a t-var:href="post.url">binds variables to HTML attributes - Conditionals --
<t-if>withequals,contains,starts-with,ends-with,matches(glob),truthy/not-truthy - Loops --
<t-for>with sort, filter, limit/offset,loop.first/loop.last/loop.length, for-else - Transforms --
upper,slugify,truncate:N,escape,js_escape,url_encode,join,split,int,float,decimal:N,bool,date:format, and more (pipe-chained) - Auto-coercion -- integers, floats, and booleans render as strings automatically; no manual conversion needed
- Capture --
<t-let>renders content into a scoped variable - Strict mode -- errors on undefined variables (default on, opt out per render)
- Startup validation --
Engine.validate()catches missing templates before serving traffic - Thread-safe rendering -- immutable Engine for concurrent render calls
- Writer API -- render directly to any
std.io.Writerwith true top-level streaming - Loader abstraction --
Resolver(in-memory),FileSystemLoader,ChainLoader(try loaders in order) - Directory loading --
Engine.loadFromDirectory()scans a directory and caches all matching templates - Dot-path context building --
Context.putAt("page.meta.title", value)creates intermediate maps automatically - Cache management --
removeTemplate(),clearTemplates()for dev-mode hot-reload
const toupee = @import("toupee");
var engine = try toupee.Engine.init(allocator);
defer engine.deinit();
// Load all .html templates from a directory at once
try engine.loadFromDirectory("templates", ".html");
var ctx = toupee.Context.init(allocator);
defer ctx.deinit();
// Build nested context from dot-separated paths
try ctx.putAt("site.title", .{ .string = "The Toupee Room" });
try ctx.putAt("site.author", .{ .string = "QuiteClose" });
try ctx.put("year", .{ .string = "2026" });
var resolver: toupee.Resolver = .{};
const html = try engine.renderTemplate(allocator, "page.html", &ctx, resolver.loader(), .{});
defer allocator.free(html);// Setup phase (at server startup)
var engine = try toupee.Engine.init(allocator);
try engine.addTemplate("client-status.html",
\\<div id="status"><t-var name="client" /> is <t-var name="status" /></div>
);
// Validate all templates before serving
var resolver: toupee.Resolver = .{};
const diags = try engine.validate(allocator, resolver.loader());
defer allocator.free(diags);
// Serve phase (per-request, thread-safe)
var ctx = toupee.Context.init(allocator);
defer ctx.deinit();
try ctx.put("client", .{ .string = "Marcel" });
try ctx.put("status", .{ .string = "seated" });
try engine.renderTemplateToWriter(allocator, "client-status.html", &ctx, resolver.loader(), .{}, response.writer());Or skip the Engine for one-shot rendering:
var resolver: toupee.Resolver = .{};
const html = try toupee.render(allocator, source, &ctx, resolver.loader(), .{});zig build test # over 500 tests (integration + unit)
zig build bench # performance smoke test (ReleaseFast, silent on success)
zig build fuzz # fuzz testing for parser and renderer
- Template Author Guide -- getting started, variables, control flow, composition, transforms, patterns, tutorial
- Library API Reference -- Engine, Context, errors, integration
- Contributor Guide -- architecture, adding elements/transforms, testing, code style
- Element Reference -- complete element and transform reference