Zig 0.17.0

Components

A component is a template used as a tag. A page of any size ends up made of them: a card repeated in a list, a panel around a group of things, a badge that knows its own colors. And because a component is a piece of the page with a name, it is also what the server sends when only that piece changes.

The small ones on this page are from the example apps, Posts and Bookings. The shop that puts them together is from examples/guide (source): a small server kept as tests.

A file is a tag

The name of the file is the name of the tag, in PascalCase: StateBadge.html is <StateBadge>, and so is state-badge.html. This is the one of Bookings:

html
if (state == "held") {
<span class="ui-badge ui-badge-held"><span class="hero-clock size-4" aria-hidden="true"></span>Held</span>
} else if (state == "confirmed") {
<span class="ui-badge ui-badge-confirmed"><span class="hero-check size-4" aria-hidden="true"></span>Confirmed</span>
} else if (state == "cancelled") {
<span class="ui-badge ui-badge-cancelled">Cancelled</span>
} else {
<span class="ui-badge ui-badge-expired">Expired</span>
}
html
<StateBadge state="{ booking.state }" />

A component is a template like any other: it has if, for and other components. What it does not have is a handler. It shows what it is given.

Where the file goes: next to the views of the feature that uses it (src/features/shop/views), or in src/shared/templates when several features do. A tag finds its file in either place.

What a component sees

Two things: its attributes, and everything the page was given.

html
<div id="products">
    for (products) |product| {
    <ProductCard id="{ product.id }" name="{ product.name }" price="{ product.price }" on_sale="{ product.on_sale }" />
    }
    if (products.len == 0) {
    <p>Nothing matches "{ q }".</p>
    }
</div>

<ProductList /> in the page has no attribute. It reads products and q because the handler gave them to the page. That is the usual way to cut a long page into parts: each part is a file, and none of them needs its values passed down.

<ProductCard> is different: it is used once per product, so each use says which one, in attributes.

html
<article class="card" id="product-{ id }">
    <h3><a href="/shop/{ id }">{ name }</a></h3>
    <PriceTag amount="{ price }" on_sale="{ on_sale }" />
</article>

Attributes

An attribute is written in one of two ways, and they are not the same thing:

  • name="{ product.name }" gives the component a value. Whatever it is (text, a number, true or false), the component gets it as it is: on_sale arrives as a boolean and if (on_sale) works. The value is escaped where it is finally printed, once, however many components it went through.
  • heading="Products" gives it text, as written.

Do not mix the two in one attribute inside a loop. href="/shop/{ product.id }" reaches the component as that very text, braces included, because the loop's product does not exist inside the component. Pass the value and build the text where it is used, as ProductCard does with id: href="/shop/{ id }".

What goes inside the tag

A component prints what was written inside its tag where it says { slot }:

html
<section class="panel">
    <h2>{ heading }</h2>
    <div class="panel-body">{ slot }</div>
</section>
html
extends "layout"
<Panel heading="Products">
    <input type="search" name="q" value="{ q }" hx-get="/shop" hx-trigger="input changed delay:300ms" hx-target="#products" hx-swap="outerHTML">
    <ResultCount />
    <ProductList />
</Panel>

What is inside the tag belongs to the page, not to the panel: it sees the page's values, and may use other components. A layout works the same way, with the whole page as what goes inside (Templates).

Components made of components

ProductCard uses PriceTag, which decides how a price looks:

html
if (on_sale) {
<span class="price price-sale">{ amount }</span>
} else {
<span class="price">{ amount }</span>
}

There is a limit to how deep components may go inside each other, there to stop one that includes itself: such a page fails with an error instead of never finishing.

The whole page, with every piece in place:

zig
test "components: a page is its layout, a panel, and a card for each product" {
    const app = try spider.testing.start(run);
    var res = try app.get("/shop");
    defer res.deinit();
    try res.expectStatus(200);

    // The layout, with the page in its slot.
    try res.expectContains("<h1 id=\"page-title\">Shop</h1>");
    // <Panel heading="Products">: its attribute, and what was inside the tag.
    try res.expectContains("<h2>Products</h2>");
    try res.expectContains("<div class=\"panel-body\">");
    // <ResultCount />, inside the panel's tag, read `count` from the page too.
    try res.expectContains("3 found");
    // <ProductList /> took no attribute: it read `products` from the page.
    // <ProductCard> got one product on each turn of the loop.
    try res.expectContains("<article class=\"card\" id=\"product-1\">");
    try res.expectContains("<a href=\"/shop/3\">Desk lamp</a>");
    // What an attribute carries is escaped where it is printed, once.
    try res.expectContains("Pen &amp; ink set");
    // <PriceTag>, inside the card, chose its own markup.
    try res.expectContains("<span class=\"price price-sale\">$12.00</span>");
    try res.expectContains("<span class=\"price\">$4.50</span>");
}

Sending one component to htmx

When a visitor types in the search box, only the list changes. c.viewFragment(template, component, data, .{}) renders one component, without the layout and without the rest of the page:

zig
const Product = struct { id: i64, name: []const u8, price: []const u8, on_sale: bool };

const catalogue = [_]Product{
    .{ .id = 1, .name = "Notebook", .price = "$4.50", .on_sale = false },
    .{ .id = 2, .name = "Pen & ink set", .price = "$12.00", .on_sale = true },
    .{ .id = 3, .name = "Desk lamp", .price = "$39.90", .on_sale = false },
};

/// The shop, and its search. A browser gets the page; htmx, which already
/// has the page, gets the two parts that change.
fn shop(c: *spider.Ctx) !spider.Response {
    const q = c.queryDecoded("q") orelse "";
    var found: std.ArrayList(Product) = .empty;
    for (catalogue) |product| {
        if (q.len == 0 or std.ascii.findIgnoreCase(product.name, q) != null) try found.append(c.arena, product);
    }
    const data = .{ .title = "Shop", .q = q, .products = found.items, .count = found.items.len };

    if (!c.isHtmx()) return c.view("shop/index", data, .{});

    // The list goes where the request pointed (`hx-target`). The count is
    // somewhere else on the page: it says `hx-swap-oob`, and htmx puts it
    // where the element with its id is.
    const list = try c.viewFragment("shop/index", "ProductList", data, .{});
    const count = try c.viewFragment("shop/index", "ResultCount", data, .{});
    return c.html(try std.mem.concat(c.arena, u8, &.{ list.body orelse "", count.body orelse "" }), .{});
}

The page and the part come from the same files and the same data, so they cannot drift apart. The first argument is the page the component belongs to; the second, the component.

A request often changes two places of the page: here the list, and a count above it. The answer carries both. The first goes where the request pointed, and the second says where it goes itself:

html
<p id="result-count" hx-swap-oob="true">{ count } found</p>
zig
test "components: htmx gets the parts that change, each one a component" {
    const app = try spider.testing.start(run);
    var res = try app.with(&.{"HX-Request: true"}).get("/shop?q=lamp");
    defer res.deinit();
    try res.expectStatus(200);

    // No layout and no panel: the list, then the count.
    try std.testing.expect(std.mem.startsWith(u8, res.body, "<div id=\"products\">"));
    try res.expectContains("Desk lamp");
    try res.expectNotContains("Notebook");
    try res.expectContains("<p id=\"result-count\" hx-swap-oob=\"true\">1 found</p>");
    try res.expectNotContains("<html");
}

This is how a large app keeps its pages fast: the frame (a side menu, a top bar) is sent once, and every click after that asks for the component of the content, plus the few places of the frame that change with it, such as a title.

A component for one page

A component only one page uses can be written in that page, above where it is used. A tag with a capital letter that has other lines inside it, at the top of the file, is a definition:

html
<Line>
<tr><td>{ label }</td><td>{ amount }</td></tr>
</Line>
<table>
    <Line label="Notebook" amount="$4.50" />
    <Line label="Shipping" amount="$3.00" />
    <Line label="Total" amount="{ total }" />
</table>
zig
test "components: one defined in the page that uses it" {
    const app = try spider.testing.start(run);
    var res = try app.get("/shop/receipt");
    defer res.deinit();
    try res.expectContains("<tr><td>Notebook</td><td>$4.50</td></tr>");
    try res.expectContains("<tr><td>Total</td><td>$7.50</td></tr>");
    // The definition itself is not printed.
    try res.expectNotContains("<Line");
    try res.expectNotContains("{ label }");
}

c.viewFragment finds these too, by the name of the page and of the component.

A name that is wrong

A tag that no file answers to prints nothing, and nothing says so. When a part of a page is missing, check the spelling of the tag first:

html
<p>before</p>
<ProdctCard id="1" name="Notebook" price="$4.50" />
<p>after</p>
zig
test "components: a tag no file answers to prints nothing, and says nothing" {
    const app = try spider.testing.start(run);
    var res = try app.get("/shop/typo");
    defer res.deinit();
    try res.expectStatus(200);
    try res.expectContains("<p>before</p>");
    try res.expectContains("<p>after</p>");
    try res.expectNotContains("Notebook");
}