Zig 0.17.0

Templates

A template is an HTML file with values in braces. c.view renders one with the data a handler gives it.

The code on this page is from Posts, the example app in examples/posts (source). This is the handler of its feed:

zig
/// The feed. With ?q= it only lists the posts that match.
pub fn index(c: *spider.Ctx) !Response {
    const q = std.mem.trim(u8, c.queryDecoded("q") orelse "", " ");
    const posts = if (q.len > 0)
        try repository.search(c.arena, q)
    else
        try repository.list(c.arena);

    return c.view("posts/index", .{
        .title = "Posts",
        .q = q,
        .searching = q.len > 0,
        .posts = try present.cards(c.arena, posts),
    }, .{});
}

The name is the feature and the file: "posts/index" is src/features/posts/views/index.html. The data is a struct: text, numbers, booleans, optionals, other structs and slices of structs.

Values, conditions and loops

And this is the template it renders:

html
extends "layout"

<main class="ui-feed-page">
    <section class="ui-intro">
        <h1 class="ui-display">Latest posts</h1>
        <p class="ui-lead">Short notes from people building with Spider.</p>
        <form class="ui-search" action="/posts" method="get" role="search">
            <label class="ui-label" for="q">Search posts</label>
            <div class="ui-search-row">
                <div class="ui-search-box">
                    <span class="hero-magnifying-glass-mini size-5" aria-hidden="true"></span>
                    <input id="q" name="q" type="search" value="{ q }" placeholder="Try &quot;router&quot;">
                </div>
                <button class="ui-btn ui-btn-outline ui-search-submit" type="submit">Search</button>
            </div>
        </form>
        <a class="ui-link" href="/posts/export.csv">Download all posts as CSV</a>
    </section>

    <section class="ui-feed" aria-label="Posts">
        if (searching) {
        <p class="ui-feed-note">Posts that mention “{ q }”. <a class="ui-link" href="/posts">Show all posts</a></p>
        }
        if (posts.len == 0) {
        <div class="ui-empty">
            if (searching) {
            <p>No post mentions “{ q }”.</p>
            } else {
            <p>Nothing here yet. <a class="ui-link" href="/posts/new">Write the first post</a></p>
            }
        </div>
        }
        for (posts) |post| {
        <PostCard id="{ post.id }" title="{ post.title }" author="{ post.author }"
                  initials="{ post.initials }" tone="{ post.tone }"
                  written="{ post.written }" excerpt="{ post.excerpt }" />
        }
    </section>
</main>
  • { q } puts a value in the page. It is escaped: a < in the data reaches the page as &lt;.
  • if (searching) { ... } else { ... } shows one part or the other. Conditions take ==, !=, <, >, and, or and !.
  • for (posts) |post| { ... } repeats a part for each item, and post.title reads a field of it.
  • posts.len is how many there are.

Layouts

A layout is a template with a { slot } where the page goes. A page names its layout on its first line, as extends "layout" does above. This is the layout of Posts, src/shared/templates/layout.html:

html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{ title ?? "Posts" }</title>
    <link rel="icon" type="image/png" href="/favicon.png">
    <link rel="shortcut icon" href="/favicon.ico">
    <link rel="stylesheet" href="/css/app.css">
</head>
<body class="ui-page">
    <header class="ui-topbar">
        <a class="ui-brand" href="/posts">
            <svg width="14" height="32" viewBox="0 0 14 32" aria-hidden="true"><line x1="7" y1="0" x2="7" y2="22" stroke="#FF5300" stroke-width="2"/><circle cx="7" cy="25" r="6" fill="#FF5300"/></svg>
            Posts
        </a>
        <nav class="ui-topbar-links">
            if (current_user) {
            <a class="ui-btn ui-btn-ghost ui-who" href="/auth/account">{ current_user.name }</a>
            <form action="/auth/logout" method="post">
                <button class="ui-btn ui-btn-ghost" type="submit">Sign out</button>
            </form>
            } else {
            <a class="ui-btn ui-btn-ghost" href="/auth/login">Sign in</a>
            }
            if (!composing) {
            <a class="ui-btn ui-btn-primary" href="/posts/new">New post</a>
            }
        </nav>
    </header>

    { slot }

    <footer class="ui-footer">
        <span>Posts is an example app for Spider.</span>
        <a class="ui-link" href="https://github.com/llllOllOOll/spider">Built with Spider</a>
    </footer>
</body>
</html>

The layout sees the same data as the page. { title ?? "Posts" } uses the title the handler sent, or "Posts" when it sent none: ?? gives a default for a value that is missing. In a condition a missing value counts as false, so if (!composing) shows the button on every page except the ones that send composing.

current_user is there on every page without any handler sending it: Spider fills it in when someone is signed in, with id, email and name. Nobody signed in, no current_user, and the layout offers to sign in. A component can read it too.

Components

A template can be used as a tag: PostCard.html is <PostCard>. The feed above writes one for each post. This is the component:

html
<article class="ui-post">
    <div class="ui-post-rail">
        <span class="ui-avatar ui-avatar-{ tone }" aria-hidden="true">{ initials }</span>
        <span class="ui-thread" aria-hidden="true"></span>
    </div>
    <div class="ui-post-body">
        <p class="ui-byline"><span class="ui-author">{ author }</span> <span class="ui-when">{ written }</span></p>
        <h2 class="ui-post-title"><a href="/posts/{ id }">{ title }</a></h2>
        <p class="ui-excerpt">{ excerpt }</p>
        <a class="ui-link ui-read" href="/posts/{ id }" aria-label="Read post: { title }">Read post</a>
    </div>
</article>

Components is the page about them: attributes, what goes inside the tag, components made of components, and sending one alone to htmx.

HTML from Zig

Text is always escaped. To put markup your own code built into a page, wrap it in spider.RawHtml. Posts does it for the body of a post, which is written in Markdown:

zig
pub fn show(c: *spider.Ctx) !Response {
    const post = (try policy.load(c)) orelse return error.NotFound;
    return c.view("posts/show", .{
        .title = post.title,
        .post = try present.card(c.arena, post),
        // The same rule the routes use decides whether the buttons show.
        .can_edit = try Posts.can(c, .update, &post),
        // The body is Markdown. Spider's renderer escapes what the author
        // typed, so its HTML can go into the page as it is.
        .body = spider.RawHtml{ .html = try spider.zmd.parse(c.arena, post.body, .{}) },
    }, .{});
}

spider.zmd.parse turns Markdown into HTML and escapes everything the author typed, so its result is safe to show as it is. Never wrap text a user typed that did not go through something that escapes it.

The template then uses { body } like any other value:

html
extends "layout"

<main class="ui-reading">
    <BackLink href="/posts" label="All posts" />

    <header class="ui-post-header">
        <h1 class="ui-headline">{ post.title }</h1>
        <div class="ui-author-row">
            <span class="ui-avatar ui-avatar-sm ui-avatar-{ post.tone }" aria-hidden="true">{ post.initials }</span>
            <p class="ui-author-meta">
                <span class="ui-author">{ post.author }</span>
                <span class="ui-when">{ post.written }</span>
            </p>
        </div>
    </header>

    <article class="ui-prose">
        { body }
    </article>

    if (can_edit) {
    <div class="ui-actions ui-actions-ruled">
        <a class="ui-btn ui-btn-outline" href="/posts/{ post.id }/edit">Edit post</a>
        <a class="ui-btn ui-btn-danger-quiet" href="/posts/{ post.id }/delete">Delete post</a>
    </div>
    }
</main>

Where templates are read from

In a debug build, which is what spider dev and zig build run make, templates are read from disk on every request: save the file and reload the page. In a release build they are compiled into the binary, so a deploy needs nothing but the binary. Either can be chosen for every build: see The build.