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:
/// 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:
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 "router"">
</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<.if (searching) { ... } else { ... }shows one part or the other. Conditions take==,!=,<,>,and,orand!.for (posts) |post| { ... }repeats a part for each item, andpost.titlereads a field of it.posts.lenis 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:
<!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:
<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:
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:
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.