GitHub Hot Board

GitHub Hot Board · Repository archive

lalinsky/zt

HTML template language that compiles to Zig (This description comes from the repository itself.)

Data at October 22lalinsky

Best

#181

Underappreciated Gems

100

Total stars

5

Total forks

1

Days on boards (30d)

README

Zig Templating

This is a young project. The syntax is mostly settled, but one breaking change is still under discussion: how block-level control flow is written (#27). Feedback is very welcome, especially there.

A small HTML templating language that compiles to Zig at build-time. Inspired by Templ, Zeix and JSX.

The idea is to invent as little syntax as possible, just enough to make it possible to interweave real Zig code with HTML elements. Using this approach, the template compiler can stay very simple and delegate Zig code analysis to the real Zig compiler.

Templates are transpiled into Zig source files as part of zig build, so you can just import them like any other source files in your application. Everything is fully type-checked by the Zig compiler, and there is no overhead at runtime. Output is directly written to a std.Io.Writer, so there is no state and no allocations.

Installation

zt supports Zig 0.16 and 0.17. Both versions are tested in CI.

zig fetch --save git+https://github.com/lalinsky/zt

Configure your build.zig:

const std = @import("std");
const zt = @import("zt");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const zt_dep = b.dependency("zt", .{
        .target = target,
        .optimize = optimize,
    });

    // Compile templates (.zt → .zig)
    const templates = zt.addTemplates(b, zt_dep, &.{
        b.path("src/templates/pages.zt"),
    });

    const root_module = b.createModule(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });
    root_module.addImport("zt", zt_dep.module("zt"));

    const exe = b.addExecutable(.{
        .name = "myapp",
        .root_module = root_module,
    });
    exe.step.dependOn(templates);

    b.installArtifact(exe);
}

The template compiler is built in debug mode by default. To build it optimized, pass tool_optimize:

const zt_dep = b.dependency("zt", .{
    .target = target,
    .optimize = optimize,
    .tool_optimize = .fast,
});

Usage

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

pub fn main() !void {
    var buf: [4096]u8 = undefined;
    var stdout = std.fs.File.stdout().writer(&buf);
    const w = &stdout.interface;

    try pages.Home.render(.{ "Welcome" }, w);
    try w.flush();
}

Example

const Post = @import("../models.zig").Post;
const User = @import("../models.zig").User;

fn formatDate(ts: i64) []const u8 {
    // ...
}

pub templ Layout(title: []const u8) {
    <!DOCTYPE html>
    <html>
        <head>
            <title>{title}</title>
            <link rel="stylesheet" href="/style.css" />
        </head>
        <body>
            @Nav()
            <main>
                @children
            </main>
        </body>
    </html>
}

pub templ Nav() {
    <nav>
        <a href="/">Home</a>
        <a href="/about">About</a>
    </nav>
}

pub templ PostCard(post: Post) {
    <article class="card">
        <h2><a href="/posts/{post.id}/{post.slug}">{post.title}</a></h2>
        <time>{formatDate(post.created_at)}</time>
        if (post.subtitle) |subtitle| {
            <p class="subtitle">{subtitle}</p>
        }
        <div class="tags">
            {for (post.tags) |tag| <span class="tag">{tag}</span>}
        </div>
    </article>
}

pub templ HomePage(user: ?User, posts: []const Post) {
    @Layout("Home") {
        if (user) |u| {
            <p>Welcome back, {u.name}!</p>
        } else {
            <p>Welcome, guest! <a href="/login">Log in</a></p>
        }
        <section class="posts">
            for (posts) |post| {
                @PostCard(post)
            }
        </section>
    }
}

Syntax

Templates

Templates are defined with templ and compile to structs with a render method:

pub templ Greeting(name: []const u8) {
    <h1>Hello, {name}!</h1>
}

Zig Code

Standard Zig code goes at the top of the file and is passed through unchanged:

const std = @import("std");
const Post = @import("../models.zig").Post;

fn formatDate(ts: i64) []const u8 {
    // ...
}

pub templ Article(post: Post) {
    <article>
        <time>{formatDate(post.created_at)}</time>
        <h1>{post.title}</h1>
    </article>
}

Elements

pub templ Document() {
    <!DOCTYPE html>
    <html>
        <head>
            <title>My Page</title>
            <link rel="stylesheet" href="/style.css" />
        </head>
        <body>
            <div>
                Content
            </div>
            <img src="avatar.png" />
        </body>
    </html>
}

All HTML elements must be explicitly closed, but void elements like <img> and <link> will be rendered correctly without the closing slash.


Attributes

Static - quoted string values:

<div class="container" id="main"></div>

Single-quoted values are always static, so { has no special meaning inside them. Use them for JSON or JavaScript object literals:

<meta name="htmx-config" content='{"noSwap": [204, 304, "5xx"]}' />
<div x-data='{ open: false }'></div>

Dynamic - Zig expressions in braces:

<div class={className} data-id={item.id}></div>

Interpolated - mix static and dynamic parts (double quotes only):

<a href="/posts/{post.id}/{post.slug}">Read more</a>

Boolean - presence means true:

<input type="checkbox" checked disabled />

Conditional - ?= takes a bool (or ?bool) and renders the bare attribute when it is true, nothing otherwise:

<input type="checkbox" checked?={item.done} />

A plain dynamic attribute rejects a bool at compile time, because checked="false" still means checked. For attributes that expect the strings "true"/"false" (aria-expanded, draggable, hx-boost, ...), pass a string:

<button aria-expanded={if (open) "true" else "false"}>Menu</button>

Optional - attribute omitted when value is null:

<a href={item.url}>Link</a>
<div class={if (isActive) "active" else null}></div>

Expressions

Escaped output (default) - safe for user content:

<span>{comment.text}</span>

Raw output - for trusted HTML only:

<div>{!article.html_content}</div>

Custom Formatting

A value with a format method is written through it, and the output is escaped like any other text (or written as is with {!...}):

const Money = struct {
    cents: u32,

    pub fn format(self: Money, writer: *std.Io.Writer) std.Io.Writer.Error!void {
        try writer.print("{d}.{d:0>2} EUR", .{ self.cents / 100, self.cents % 100 });
    }
};

pub templ Price(price: Money) {
    <span>{price}</span>
}

A value with a formatHtml method writes HTML that is not escaped, so it must produce safe markup itself:

const Icon = enum {
    home,
    search,

    pub fn formatHtml(self: Icon, writer: *std.Io.Writer) std.Io.Writer.Error!void {
        try writer.print("<svg class=\"icon\"><use href=\"#{t}\"></use></svg>", .{self});
    }
};

zt.fmt turns a function that writes to a writer into a value with a format method, called with the arguments followed by the writer:

fn dateTime(ts: i64, writer: *std.Io.Writer) std.Io.Writer.Error!void {
    // ...
}

pub templ RecipeInfo(recipe: Recipe) {
    <p>Created {zt.fmt(dateTime, .{recipe.created_at})}</p>
}

Control Flow

If/Else

Block-level:

if (user.is_admin) {
    <span class="badge">Admin</span>
}

if (post.subtitle) |subtitle| {
    <h2>{subtitle}</h2>
}

Inline:

<span>{if (user.premium) "Pro" else "Free"}</span>
{if (error) |msg| <div class="error">{msg}</div>}

For Loops

Block-level:

<ul>
    for (items) |item| {
        <li>{item.name}</li>
    }
</ul>

for (rows, 0..) |row, i| {
    <tr class={if (i % 2 == 0) "even" else "odd"}>...</tr>
}

Inline:

<nav>{for (links) |link| <a href={link.url}>{link.title}</a>}</nav>

Switch

Block-level:

switch (order.status) {
    .pending => {
        <span class="yellow">Processing</span>
    },
    .shipped => |tracking| {
        <a href={tracking}>Track package</a>
    },
    else => {
        <span>Unknown</span>
    },
}

Inline:

{switch (user.role) .admin => <b>Admin</b>, .mod => <i>Mod</i>, else => <span>User</span>}

Components

Call other templates with @:

pub templ Nav() {
    <nav>...</nav>
}

pub templ Page() {
    @Nav()
    <main>Content</main>
}

With imports:

const ui = @import("ui.zig");

pub templ Page() {
    @ui.Button("Click me")
}

Children

Parent templates use @children to render nested content:

pub templ Card(title: []const u8) {
    <div class="card">
        <h2>{title}</h2>
        <div class="body">
            @children
        </div>
    </div>
}

pub templ Page() {
    @Card("Welcome") {
        <p>This appears inside the card body.</p>
    }
}

Nesting works to any depth:

pub templ Page() {
    @Layout("Home") {
        @Card("News") {
            <p>Latest updates...</p>
        }
    }
}

Components as Parameters

Templates can accept zt.Component for dynamic composition:

pub templ Modal(title: []const u8, body: zt.Component) {
    <div class="modal">
        <h2>{title}</h2>
        @body
    </div>
}

Create components with bind:

const args: templates.Alert.Args = .{ "Something went wrong" };
const alert = templates.Alert.bind(&args);
try templates.Modal.render(.{ "Error", alert }, writer);

API

Each template generates:

  • render(args, writer) !void - render to a writer
  • bind(args) zt.Component - create a type-erased component
  • Args - the argument tuple type

See examples/ for a runnable project:

cd examples
zig build run

Current rankings

  • 181

    Underappreciated Gems

    100 stars · 5 fork · Total stars (ascending)

    Underrated →

Momentum, past 30 days

Cumulative stars

Rank movement

Racing nearby on the same board

← Back to boards