Skip to content

Templating

Dusty has no templating language of its own. res.render takes a zt template or any function that writes to a *std.Io.Writer. We recommend zt, which compiles templates to Zig at build time.

The examples on this page come from examples/todo, a to-do list app built with zt, pg.zig and htmx.

Setup

Add zt to the project:

$ zig fetch --save "git+https://github.com/lalinsky/zt#v0.2.1"

Then generate the templates in build.zig, and give the executable the zt module:

const zt = @import("zt");

const zt_dep = b.dependency("zt", .{
    .target = target,
    .optimize = optimize,
});
const templates = zt.addTemplates(b, zt_dep, &.{
    b.path("src/templates/todo.zt"),
});

exe.root_module.addImport("zt", zt_dep.module("zt"));
exe.step.dependOn(templates);

zt turns each .zt file into a .zig file next to it, here src/templates/todo.zig.

Templates

A template is a templ declaration with parameters and an HTML body. Values go in with {...}. for and if work like they do in Zig. @TodoItem(todo) includes another template:

pub templ TodoItems(todos: []const Todo) {
    for (todos) |todo| {
        @TodoItem(todo)
    }
}

pub templ TodoItem(todo: Todo) {
    <li id="todo-{todo.id}" class={if (todo.done) "done" else null}>
        <input type="checkbox" checked={if (todo.done) "checked" else null} hx-patch="/todos/{todo.id}/toggle" hx-target="closest li" hx-swap="outerHTML" />
        <label hx-get="/todos/{todo.id}/edit" hx-trigger="dblclick" hx-target="closest li" hx-swap="outerHTML">{todo.title}</label>
        <button class="destroy" hx-delete="/todos/{todo.id}" hx-target="closest li" hx-swap="delete" aria-label="Delete">×</button>
    </li>
}

Text from {...} is HTML-escaped, so a to-do titled <script> shows up as text. An attribute set to null, like class on a to-do that isn't done, is left out.

A layout puts other content inside it with @children:

pub templ Layout(title: []const u8) {
    <!DOCTYPE html>
    <html lang="en">
        <head>
            <meta charset="utf-8" />
            <meta name="viewport" content="width=device-width, initial-scale=1" />
            <meta name="htmx-config" content={htmx_config} />
            <title>{title}</title>
            <link rel="icon" href={assets.url("favicon.svg")} type="image/svg+xml" />
            <link rel="stylesheet" href={assets.url("app.css")} />
            <script src="https://unpkg.com/htmx.org@4.0.0/dist/htmx.min.js" integrity="sha384-BvJpBiO8Kh31EqtJe5DRIeWrHWnCGkwytKs9NKFi86Hhw96dEqdEMzZDeK9iEGTc" crossorigin="anonymous"></script>
            <script src="https://unpkg.com/htmx.org@4.0.0/dist/ext/hx-sse.min.js" integrity="sha384-VZD0TLKqhJ26ayBUgQg3ud6DsOLMJvtcz0ANpNc9WSbgIuQTnlXI2IfsF5jhBjT6" crossorigin="anonymous"></script>
        </head>
        <body hx-boost:inherited="true">
            @children
            {http.dev.script(.{})}
        </body>
    </html>
}

A page wraps its content in @Layout("Todos") { ... }. What's in the braces goes where @children is. See the zt documentation for the rest of the language.

Rendering

The handler imports the generated file:

const templates = @import("templates/todo.zig");

Then it passes a template and its arguments, as a tuple, to res.render:

fn edit(app: *App, id: http.Param("id", i32), req: *http.Request, res: *http.Response) !void {
    const todo = try app.queryTodo(req.arena, "SELECT id, title, done FROM todos WHERE id = $1", .{id.value}) orelse return error.NotFound;
    try res.render(.html, templates.TodoEdit, .{todo});
}

app.queryTodo loads the to-do from the database. The first argument to res.render is the content type. If the template returns an error, its output is thrown away. The handler can still send a different response.

res.render works with any function whose last parameter is a *std.Io.Writer. It calls the function with the arguments from the tuple, followed by the writer. A type with a render(args, writer) function works too. So you can also write templates by hand, or use another template library that writes to a std.Io.Writer.

Next Steps