[we]blog

Taxonomies in Zine

Zine ships without a taxonomy system: there is no built-in way to say “collect every page tagged zig onto a /tags/zig/ page.” I added one in a fork, and the surface area is small enough to read in a sitting.

A taxonomy is declared in the site config. The whole contract is three strings: the field to read, and the two layouts used to render the listing and the per-term pages.

pub const TaxonomyConfig = struct {
    /// The name of the taxonomy. For "tags", reads from page.tags.
    /// For anything else, reads from page.custom.{name} (must be an array of strings).
    name: []const u8,
    /// Layout for the taxonomy list page (e.g., /tags/)
    layout: []const u8,
    /// Layout for individual term pages (e.g., /tags/zig/)
    term_layout: []const u8,
};
root.zig:26-34

The name does double duty. tags is special-cased to the built-in page.tags field; any other name reads from page.custom.{name}, which is how the same machinery powers a series taxonomy without a single line of extra Zig. The value must be an array of strings, so .custom = { .series = ["demo"] } works while a bare .series = "demo" is silently ignored.¹

That branch lives in one function. Given a page and a taxonomy name, it returns the list of terms that page belongs to, or null when the field is absent or malformed.

pub fn extractTermsFromPage(
    gpa: Allocator,
    page: *const Page,
    taxonomy_name: []const u8,
) !?[]const []const u8 {
    if (std.mem.eql(u8, taxonomy_name, "tags")) {
        return if (page.tags.len > 0) page.tags else null;
    }

    // Read from page.custom
    switch (page.custom) {
        .kv => |kv| {
            const val = kv.fields.get(taxonomy_name) orelse return null;
            switch (val) {
                .array => |arr| {
                    if (arr.len == 0) return null;
                    const terms = try gpa.alloc([]const u8, arr.len);
                    for (arr, 0..) |elem, i| {
                        switch (elem) {
                            .bytes => |b| terms[i] = b,
                            else => return null,
                        }
                    }
                    return terms;
                },
                else => return null,
            }
        },
        else => return null,
    }
}
utils.zig:38-68

Everything downstream, slugifying each term, bucketing pages, emitting synthetic /series/ and /series/demo/ pages, hangs off this list. The strictness is deliberate: a non-array or a non-string element returns null rather than guessing, so a typo in frontmatter drops the page out of the taxonomy instead of crashing the build.

Tax Test Two