Rendering & Context
Every route handler receives a *verve.Context — the per-request hub for building the page. It owns an arena allocator (wiped at end of request), the matched path params, the URL, the head accumulator, and the element factories.
The node builder
Element factories return *Node so chains read like the HTML they produce. .build() finalizes a chain and surfaces any allocation error accumulated along the way:
return ctx.div().class("card")
.children(.{
ctx.h1("Title"),
ctx.p().text("Body copy."),
ctx.a("/docs", "A link"),
})
.build();Dedicated factories: div span p h1–h4 a button input form ul ol li
img br hr label code pre nav main_ section header footer article aside
strong em small textarea select option link meta title style script. Anything else goes through the escape hatch ctx.el("dialog").
Common chain methods
| Method | Emits |
|---|---|
.class("a b") | class="a b" |
.attr("k", "v") | arbitrary attribute |
.attrFmt("k", "{d}", .{n}) | formatted attribute (arena-allocated) |
.text("…") / .textInt(n) / .textFmt(...) | escaped text content |
.children(.{ a, b, c }) | append children (tuple of *Node) |
.bind("name") | z-bind="name" — reactive text binding |
.onClick("export_name") | z-on-click — wasm export dispatch |
.id("x") / .href(...) / .src(...) / .type_(...) / .name(...) / .value(...) | the usual suspects |
.raw(bytes) | verbatim bytes — caller guarantees safety |
Text set via .text/textFmt is HTML-escaped by the renderer; URLs in markdown are sanitized. .raw and ctx.scriptInline are the explicit unsafe escape hatches.
Context essentials
ctx.alloc() // request arena — no manual frees
ctx.param("slug") // captured :slug path segment
ctx.location // ?*Location — path, query, fragment
ctx.assetHref("a.css") // cache-busted /public URL from the manifest
ctx.activeClass(href, "active") // nav highlighting
ctx.outlet() // nested-route child placeholder (layouts)
ctx.redirect("/login") // sentinel node → 303 response
ctx.errorBoundary(inner, fallback) // swap subtree on builder errorSpecial node forms
ctx.raw(bytes) // fragment, verbatim bytes (sitemap.xml, OG SVG…)
ctx.textNode("plain") // bare escaped text, no wrapper element
ctx.template("name", inner) // <template data-vt="name"> for client cloningPair ctx.raw with .contentType("application/xml") for non-HTML routes.
The page shell
One component owns the <html> skeleton. It drains the head accumulator (see Head management), then emits the body and the bridge script:
pub fn page(ctx: *const verve.Context, body: *verve.Node) !*verve.Node {
try ctx.setTitleIfUnset("My app");
var aw: std.Io.Writer.Allocating = .init(ctx.alloc());
try ctx.head.?.render(&aw.writer);
return ctx.el("html").children(.{
ctx.el("head").children(.{
ctx.raw(aw.written()),
ctx.link("stylesheet", try ctx.assetHref("site.css")),
}),
ctx.el("body").children(.{ body, ctx.script("/verve.js") }),
}).build();
}The server calls components.page for its own 404/error responses, so keep that export.
Errors
Builder chains never try mid-chain: allocation failure poisons the node and the error surfaces at .build(). ctx.errorBoundary(inner,
fallback) substitutes a fallback subtree when inner accumulated an error.
Next: Routing.