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:
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:
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
- Static Files and Assets covers
assets.urland serving the files. - Server-Sent Events covers pushing updates to the page, which the todo example uses to keep several tabs in sync.