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,
};
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,
}
}
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.