about summary refs log tree commit diff
diff options
context:
space:
mode:
authorRobert Günzler <r@gnzler.io>2026-06-24 11:57:02 +0900
committerRobert Günzler <r@gnzler.io>2026-09-22 18:01:30 +0200
commitc677e8873e0eae790a1b8b07844a10efa9f63a1e (patch)
tree95bc9709c374d27962a73712d24807a98cc69bb4
parentfcdf352af595759aa34e6ed037ee10810867a616 (diff)
downloadsite-c677e8873e0eae790a1b8b07844a10efa9f63a1e.tar.gz
wip design for v2
Signed-off-by: Robert Günzler <r@gnzler.io>
Diffstat (limited to '')
-rw-r--r--makefile21
-rw-r--r--src/Content.zig210
-rw-r--r--src/Context.zig242
-rw-r--r--src/FileSwapper.zig101
-rw-r--r--src/exe.zig216
-rw-r--r--src/fs.zig239
-rw-r--r--src/gen.zig404
-rw-r--r--src/main.zig332
-rw-r--r--src/os.zig84
9 files changed, 844 insertions, 1005 deletions
diff --git a/makefile b/makefile
index d293307..0348ce2 100644
--- a/makefile
+++ b/makefile
@@ -1,17 +1,30 @@
 all: gen
 
 # generator
-ZIG     ?= zig
-rbc_SRC := src/main.zig src/fs.zig src/gen.zig src/exe.zig
+ZIG     ?= /usr/bin/zig-bin-0.16.0
+rbc_SRC := src/main.zig $(wildcard src/*.zig)
 rbc_BIN := bin/rbc
 
+ifdef REL
+rbc_OPT := ReleaseFast
+else
+rbc_OPT := Debug
+endif
+
 $(rbc_BIN): $(rbc_SRC)
-	$(ZIG) build-exe -lc -O Debug -freference-trace -femit-bin=$@ $<
+	@ mkdir -p $(dir $@)
+	$(ZIG) build-exe -lc -O $(rbc_OPT) -freference-trace -femit-bin=$@ $<
 
 rbc: $(rbc_BIN)
 
+check:
+	$(ZIG) test -lc -O Debug -freference-trace --test-filter Context src/Context.zig
+
 clean:
-	@ rm -rf site/* $(rbc_BIN) $(md_SRC:%.md=%.htm)
+	@ rm -rf $(rbc_BIN)
+
+site-clean:
+	@ rm -rf site/* $(md_SRC:%.md=%.htm)
 
 gen: $(rbc_BIN)
 	# TODO: fix the panic
diff --git a/src/Content.zig b/src/Content.zig
new file mode 100644
index 0000000..120d8b5
--- /dev/null
+++ b/src/Content.zig
@@ -0,0 +1,210 @@
+const std = @import("std");
+const os = @import("os.zig");
+
+const Content = @This();
+const log = std.log.scoped(.Content);
+
+pub const Digest = [64]u8;
+
+io: std.Io,
+arena: *std.heap.ArenaAllocator,
+root: std.Io.Dir,
+
+pub fn init(io: std.Io, allocator: std.mem.Allocator, root_dir: std.Io.Dir) !Content {
+    const arena = try allocator.create(std.heap.ArenaAllocator);
+    errdefer allocator.destroy(arena);
+    arena.* = std.heap.ArenaAllocator.init(allocator);
+
+    return .{
+        .io = io,
+        .arena = arena,
+        .root = root_dir,
+    };
+}
+
+pub fn deinit(self: *Content) void {
+    self.root.close(self.io);
+    const allocator = self.arena.child_allocator;
+    self.arena.deinit();
+    allocator.destroy(self.arena);
+}
+
+pub fn digest(self: *Content, dir: std.Io.Dir, sub_path: []const u8) !Digest {
+    // generate sha256 digest
+    // TODO: use zig-based hashing?
+    const command = blk: {
+        var buf: [std.os.linux.PATH_MAX + 10]u8 = undefined;
+        @memcpy(buf[0..10], "sha256sum ");
+        @memcpy(buf[10 .. 10 + sub_path.len], sub_path);
+        break :blk buf[0 .. 10 + sub_path.len];
+    };
+    var buf: [std.os.linux.PATH_MAX + 64]u8 = undefined;
+    var w = std.Io.Writer.fixed(&buf);
+    try os.captureStdout(self.io, command, .{ .cwd = .{ .dir = dir } }, &w);
+
+    // take only the digest
+    return buf[0..64].*;
+}
+
+pub fn get(self: *Content, dgst: Digest) !?Digest {
+    const found = blk: {
+        self.root.access(self.io, &dgst, .{}) catch |err| switch (err) {
+            error.FileNotFound => break :blk false,
+            else => return err,
+        };
+        break :blk true;
+    };
+    if (found) {
+        return dgst;
+    } else {
+        return null;
+    }
+}
+
+pub fn add(self: *Content, dir: std.Io.Dir, sub_path: []const u8) !Digest {
+    const dgst = try self.digest(dir, sub_path);
+
+    return (try self.get(dgst)) orelse {
+        // create digest directory
+        try self.root.createDirPath(self.io, &dgst);
+
+        log.debug("Added {s}", .{dgst});
+        return dgst;
+    };
+}
+
+pub fn del(self: *Content, dgst: Digest) !void {
+    try self.root.deleteTree(self.io, &dgst);
+}
+
+const Enumerator = struct {
+    content: *Content,
+    walker: std.Io.Dir.SelectiveWalker,
+
+    pub fn deinit(self: *Enumerator) void {
+        self.walker.deinit();
+    }
+
+    pub fn next(self: *Enumerator) !?Digest {
+        if (try self.walker.next(self.content.io)) |e| {
+            switch (e.kind) {
+                // TODO: we should probably check that this is actually a digest
+                .directory => return e.basename[0..64].*,
+                else => return null,
+            }
+        } else {
+            return null;
+        }
+    }
+};
+
+pub fn enumerate(self: *Content, allocator: std.mem.Allocator) !Enumerator {
+    return .{
+        .walker = try self.root.walkSelectively(allocator),
+        .content = self,
+    };
+}
+
+pub fn propSet(self: *Content, dgst: Digest, key: []const u8, val: ?[]const u8) !void {
+    const digest_dir = try self.root.openDir(self.io, &dgst, .{});
+    defer digest_dir.close(self.io);
+
+    if (val == null) {
+        return try digest_dir.deleteFile(self.io, key);
+    }
+
+    return try digest_dir.writeFile(self.io, .{
+        .sub_path = key,
+        .data = val.?,
+    });
+}
+
+pub fn propGet(self: *Content, dgst: Digest, key: []const u8, allocator: std.mem.Allocator) !?[]const u8 {
+    const digest_dir = try self.root.openDir(self.io, &dgst, .{});
+    defer digest_dir.close(self.io);
+
+    if (digest_dir.readFileAlloc(self.io, key, allocator, .unlimited)) |val| {
+        return val;
+    } else |err| switch (err) {
+        error.FileNotFound => return null,
+        else => return err,
+    }
+}
+
+// fn checkLink(self: *Content, allocator: std.mem.Allocator, dgst: Digest) ![]const u8 {
+//     const link_target = try self.propGet(dgst, "link", allocator);
+//     // check target exists
+//     try std.Io.Dir.cwd().access(self.io, link_target, .{ .read = true });
+//     return link_target;
+// }
+
+test "e2e" {
+    std.testing.log_level = .debug;
+
+    const DEMO_DIR: []const u8 = "/home/robert/tmp/rbc-demo/";
+    const CONTENT_DIR: []const u8 = DEMO_DIR ++ ".content";
+
+    try std.Io.Dir.cwd().deleteTree(std.testing.io, CONTENT_DIR);
+
+    const demo_dir = try std.Io.Dir.cwd().openDir(std.testing.io, DEMO_DIR, .{ .iterate = true });
+    const content_dir = try std.Io.Dir.cwd().createDirPathOpen(std.testing.io, CONTENT_DIR, .{ .open_options = .{ .iterate = true } });
+
+    var cnt = try Content.init(std.testing.io, std.testing.allocator, content_dir);
+    defer cnt.deinit();
+
+    {
+        var walker = try demo_dir.walkSelectively(std.testing.allocator);
+        defer walker.deinit();
+
+        while (try walker.next(std.testing.io)) |e| {
+            switch (e.kind) {
+                .file => {
+                    log.info("+ {s}", .{e.path});
+                    const dgst = try cnt.add(demo_dir, e.path);
+                    try content_dir.access(std.testing.io, &dgst, .{});
+                    const rel = try std.fs.path.relativePosix(std.testing.allocator, DEMO_DIR, CONTENT_DIR, e.path);
+                    defer std.testing.allocator.free(rel);
+                    try cnt.propSet(dgst, "link", rel);
+                },
+                else => {},
+            }
+        }
+    }
+
+    var etor = try cnt.enumerate(std.testing.allocator);
+    defer etor.deinit();
+
+    {
+        const dgst = try etor.next();
+        try std.testing.expect(dgst != null);
+
+        try cnt.propSet(dgst.?, "foo", "bar");
+        const val1 = try cnt.propGet(dgst.?, "foo", std.testing.allocator);
+        try std.testing.expect(val1 != null);
+        defer std.testing.allocator.free(val1);
+        try std.testing.expect(std.mem.eql(u8, "bar", val1.?));
+
+        try cnt.propSet(dgst.?, "foo", null);
+        const val2 = try cnt.propGet(dgst.?, "foo", std.testing.allocator);
+        try std.testing.expect(val2 == null);
+    }
+
+    {
+        const dgst = try etor.next();
+        try std.testing.expect(dgst != null);
+
+        try cnt.del(dgst.?);
+        const err = content_dir.access(std.testing.io, &dgst.?, .{});
+        try std.testing.expectError(error.FileNotFound, err);
+    }
+
+    while (try etor.next()) |dgst| {
+        log.info("? {s}", .{dgst});
+        try content_dir.access(std.testing.io, &dgst, .{});
+        const link = try cnt.propGet(dgst, "link", std.testing.allocator);
+        try std.testing.expect(link != null);
+        defer std.testing.allocator.free(link.?);
+        try content_dir.access(std.testing.io, link.?, .{});
+        log.info("l {s}", .{link.?});
+    }
+}
diff --git a/src/Context.zig b/src/Context.zig
new file mode 100644
index 0000000..f5a638e
--- /dev/null
+++ b/src/Context.zig
@@ -0,0 +1,242 @@
+const std = @import("std");
+
+const Content = @import("Content.zig");
+const FileSwapper = @import("FileSwapper.zig");
+const gen = @import("gen.zig");
+
+const Context = @This();
+const log = std.log.scoped(.Context);
+
+pub const Defaults = struct {
+    // TODO: should this be next to the root_path of Context.index?
+    pub const CONTENT_PATH = ".rbc/content";
+    pub const CONTENT_REF = "../..";
+
+    pub const SITE_NAME = "gzr.im";
+    pub const LICENSE_HOLDER = "robertgzr";
+    pub const LICENSE_YEAR = "2025";
+    pub const LICENSE_NAME = "BY-NC-SA 4.0";
+    pub const LICENSE_URL = "https://git.sr.ht/~robertgzr/site/blob/master/LICENSE.content";
+    pub const SOURCE_URL = "https://git.sr.ht/~robertgzr/site/tree/master";
+    pub const PUBLIC_INBOX = "~robertgzr/public-inbox@lists.sr.ht";
+};
+
+io: std.Io,
+arena: std.heap.ArenaAllocator,
+content: ?Content = null,
+root: ?std.Io.Dir = null,
+
+pub fn init(io: std.Io, gpa: std.mem.Allocator) !Context {
+    return .{
+        .io = io,
+        .arena = std.heap.ArenaAllocator.init(gpa),
+    };
+}
+
+pub fn deinit(self: *Context) void {
+    if (self.content != null) {
+        self.content.?.deinit();
+    }
+    self.arena.deinit();
+}
+
+pub fn index(self: *Context, root_dir: std.Io.Dir) !void {
+    if (self.content == null) {
+        // setup content store
+        const content_dir = try root_dir.createDirPathOpen(
+            self.io,
+            Defaults.CONTENT_PATH,
+            .{ .open_options = .{ .iterate = true } },
+        );
+        self.content = try Content.init(self.io, self.arena.allocator(), content_dir);
+        self.root = root_dir;
+    } else {
+        try self.gc();
+    }
+
+    var walk = try root_dir.walkSelectively(self.arena.allocator());
+    defer walk.deinit();
+
+    var digests: std.ArrayList(Content.Digest) = .empty;
+    defer digests.deinit(self.arena.allocator());
+
+    while (try walk.next(self.io)) |e| {
+        switch (e.kind) {
+            .file => {
+                if (std.mem.endsWith(u8, e.basename, ".htm")) {
+                    const dgst = try self.content.?.add(root_dir, e.path);
+                    try self.content.?.propSet(dgst, "link", e.path);
+
+                    try digests.append(self.arena.allocator(), dgst);
+                    log.debug("Indexing page {s} (digest={s})", .{ e.path, dgst });
+                }
+            },
+            else => {},
+        }
+    }
+
+    log.info("Indexed {d} pages", .{digests.items.len});
+}
+
+// garbage collect
+fn gc(self: *Context) !void {
+    if (self.content == null) {
+        return error.NoIndex;
+    }
+
+    var etor = try self.content.?.enumerate(self.arena.allocator());
+    defer etor.deinit();
+
+    var gc_n: usize = 0;
+    while (try etor.next()) |dgst| {
+        if (try self.content.?.propGet(dgst, "link", self.arena.allocator())) |link| {
+            defer self.arena.allocator().free(link);
+
+            // cleanup entries with missing link
+            self.root.?.access(self.io, link, .{}) catch |err| switch (err) {
+                error.FileNotFound => {
+                    try self.content.?.del(dgst);
+                    gc_n += 1;
+                },
+                else => return err,
+            };
+        }
+    }
+
+    log.info("Garbage collected {d} pages", .{gc_n});
+}
+
+pub fn render(self: *Context, site_dir: std.Io.Dir, force: bool) !void {
+    if (self.content == null) {
+        return error.NoIndex;
+    }
+
+    log.info("Rendering site", .{});
+
+    var page_n: u32 = 0;
+
+    var etor = try self.content.?.enumerate(self.arena.allocator());
+    defer etor.deinit();
+
+    // render pages
+    while (try etor.next()) |dgst| {
+        if (!force and try self.hasRenderPage(dgst, site_dir)) {
+            continue; // no need to re-render
+        }
+
+        try self.renderPage(site_dir, dgst);
+        page_n += 1;
+    }
+
+    log.info("Rendered {d} pages", .{page_n});
+}
+
+fn hasRenderPage(self: *Context, dgst: Content.Digest, site_dir: std.Io.Dir) !bool {
+    const render_path = try self.content.?.propGet(dgst, "render", self.arena.allocator());
+    if (render_path == null) {
+        return false;
+    }
+    defer self.arena.allocator().free(render_path.?);
+
+    site_dir.access(self.io, render_path.?, .{}) catch |err| switch (err) {
+        error.FileNotFound => return false,
+        else => return err,
+    };
+
+    log.debug("Skipping {s}", .{render_path.?});
+    return true;
+}
+
+fn renderPage(self: *Context, site_dir: std.Io.Dir, dgst: Content.Digest) !void {
+    var arena = std.heap.ArenaAllocator.init(self.arena.allocator());
+    defer arena.deinit();
+    const gpa = arena.allocator();
+
+    const page_path = try self.content.?.propGet(dgst, "link", self.arena.allocator());
+    if (page_path == null) {
+        return error.NoLink;
+    }
+
+    const page_name = std.mem.cutSuffix(u8, std.fs.path.basename(page_path.?), ".htm").?;
+
+    if (std.mem.startsWith(u8, page_name, "meta.")) {
+        log.warn("skipping meta page => {s}", .{page_name});
+        return;
+    } else {
+        log.debug("Rendering page {s} (name={s})", .{ page_path.?, page_name });
+
+        const render_name = try std.mem.concat(gpa, u8, &.{ page_name, ".html" });
+        const pretty_name = try std.mem.replaceOwned(u8, gpa, page_name, "_", " ");
+
+        const sw = try FileSwapper.createAt(self.io, gpa, site_dir, render_name);
+        defer sw.close();
+        var w = sw.writer();
+
+        try gen.build_htm(self.io, gpa, &w.interface, .{
+            .root_dir = self.root.?,
+            .site_dir = site_dir,
+            .page_path = page_path.?,
+            .page_name = pretty_name,
+            .meta = .{
+                .site_name = Defaults.SITE_NAME,
+                .license_url = Defaults.LICENSE_URL,
+                .license_holder = Defaults.LICENSE_HOLDER,
+                .license_year = Defaults.LICENSE_YEAR,
+                .license_name = Defaults.LICENSE_NAME,
+                .public_inbox = Defaults.PUBLIC_INBOX,
+                .source_url = Defaults.SOURCE_URL,
+            },
+        });
+
+        try self.content.?.propSet(dgst, "render", render_name);
+        try self.content.?.propSet(dgst, "name", pretty_name);
+    }
+}
+
+// test "e2e" {
+//     std.testing.log_level = .debug;
+//
+//     const DEMO_DIR: []const u8 = "/home/robert/tmp/rbc-demo/";
+//     const SITE_DIR: []const u8 = DEMO_DIR ++ "site/";
+//
+//     const demo_dir = try std.Io.Dir.cwd().openDir(std.testing.io, DEMO_DIR, .{ .iterate = true });
+//     defer demo_dir.close(std.testing.io);
+//     const site_dir = try std.Io.Dir.cwd().createDirPathOpen(std.testing.io, SITE_DIR, .{});
+//     defer site_dir.close(std.testing.io);
+//
+//     var ctx = try Context.init(std.testing.io, std.testing.allocator);
+//     defer ctx.deinit();
+//
+//     try ctx.index(demo_dir);
+//     try ctx.render(site_dir);
+//
+//     // TODO:
+//     // index; build a list of input files
+//     // parse; detect backlinks, dependencies, etc.
+//     // render; write output
+// }
+
+test "gc" {
+    std.testing.log_level = .debug;
+
+    var tmp = std.testing.tmpDir(.{ .iterate = true });
+    defer tmp.cleanup();
+
+    const tmp_src = try tmp.dir.createDirPathOpen(std.testing.io, "src", .{ .open_options = .{ .iterate = true } });
+    // const tmp_dst = try tmp.dir.createDirPathOpen(std.testing.io, "dst", .{});
+
+    try tmp_src.writeFile(std.testing.io, .{ .sub_path = "foo.htm", .data = "hello world" });
+
+    var ctx = try Context.init(std.testing.io, std.testing.allocator);
+    defer ctx.deinit();
+
+    try ctx.index(tmp_src);
+    try tmp_src.deleteFile(std.testing.io, "foo.htm");
+
+    try ctx.index(tmp_src);
+    var etor = try ctx.content.?.enumerate(std.testing.allocator);
+    defer etor.deinit();
+    var seen: u32 = 0;
+    while (try etor.next()) |_| seen += 1;
+    try std.testing.expect(seen == 0);
+}
diff --git a/src/FileSwapper.zig b/src/FileSwapper.zig
new file mode 100644
index 0000000..13bafe4
--- /dev/null
+++ b/src/FileSwapper.zig
@@ -0,0 +1,101 @@
+const std = @import("std");
+
+const FileSwapper = @This();
+const log = std.log.scoped(.FileSwapper);
+
+io: std.Io,
+arena: *std.heap.ArenaAllocator,
+dir: std.Io.Dir,
+target_path: []const u8,
+tmp_path: []const u8,
+tmp: std.Io.File,
+
+pub fn createAt(io: std.Io, allocator: std.mem.Allocator, dir: std.Io.Dir, sub_path: []const u8) !FileSwapper {
+    const arena = try allocator.create(std.heap.ArenaAllocator);
+    errdefer allocator.destroy(arena);
+    arena.* = std.heap.ArenaAllocator.init(allocator);
+
+    const tmp_path = try std.fmt.allocPrint(
+        arena.allocator(),
+        ".{s}",
+        .{sub_path},
+    );
+
+    log.debug("Creating {s}", .{tmp_path});
+
+    const f = try dir.createFile(io, tmp_path, .{});
+
+    return .{
+        .io = io,
+        .arena = arena,
+        .dir = dir,
+        .target_path = try arena.allocator().dupe(u8, sub_path),
+        .tmp_path = tmp_path,
+        .tmp = f,
+    };
+}
+
+fn deinit(self: FileSwapper) void {
+    self.tmp.close(self.io);
+    const allocator = self.arena.child_allocator;
+    self.arena.deinit();
+    allocator.destroy(self.arena);
+}
+
+pub fn file(self: FileSwapper) std.fs.File {
+    return self.tmp;
+}
+
+pub fn writer(self: FileSwapper) std.Io.File.Writer {
+    const buf = self.arena.allocator().alloc(u8, 1024) catch
+        @panic("oom");
+    return self.tmp.writer(self.io, buf);
+}
+
+pub fn swap(self: FileSwapper) !void {
+    log.debug("Swapping {s} -> {s}", .{ self.tmp_path, self.target_path });
+
+    // flush writes
+    try self.tmp.sync(self.io);
+
+    // swap file into final location
+    // NOTE: the tmp fd is still valid after this
+    try std.Io.Dir.rename(
+        self.dir,
+        self.tmp_path,
+        self.dir,
+        self.target_path,
+        self.io,
+    );
+}
+
+pub fn close(self: FileSwapper) void {
+    self.swap() catch
+        @panic("failed to swap target file");
+
+    self.deinit();
+}
+
+test "smoke" {
+    std.testing.log_level = .debug;
+
+    const alloc = std.testing.allocator;
+
+    var tmp = std.testing.tmpDir(.{});
+    defer tmp.cleanup();
+
+    const fileswapper = try FileSwapper.createAt(std.testing.io, alloc, tmp.dir, "test.txt");
+    errdefer fileswapper.deinit();
+
+    var w = fileswapper.writer();
+    try w.interface.writeAll("hi");
+
+    try tmp.dir.access(std.testing.io, fileswapper.tmp_path, .{ .read = true, .write = true });
+
+    try fileswapper.swap();
+
+    const err = tmp.dir.access(std.testing.io, fileswapper.tmp_path, .{ .read = true, .write = true });
+    try std.testing.expectError(error.FileNotFound, err);
+
+    fileswapper.deinit();
+}
diff --git a/src/exe.zig b/src/exe.zig
deleted file mode 100644
index ad3a7aa..0000000
--- a/src/exe.zig
+++ /dev/null
@@ -1,216 +0,0 @@
-const std = @import("std");
-
-const fs = @import("fs.zig");
-const Context = @import("main.zig").Context;
-
-const log = std.log.scoped(.exe);
-
-pub fn WaitPool(
-    comptime T: type,
-    comptime worker: anytype,
-) type {
-    return struct {
-        const Self = @This();
-
-        pub fn work(ctx: *Context, items: []const T, args: anytype) !void {
-            var wg: std.Thread.WaitGroup = undefined;
-            wg.reset();
-
-            var pool: std.Thread.Pool = undefined;
-            try pool.init(.{
-                .n_jobs = (try std.Thread.getCpuCount()) - 1,
-                .allocator = ctx.allocator(),
-            });
-            defer pool.deinit();
-
-            for (items) |*item| {
-                // the inline struct trick is a workardoun for Thread.Pool::spawn not
-                // accepting functions that can error:
-                // https://github.com/ziglang/zig/issues/18810
-                try pool.spawn(struct {
-                    fn func(
-                        wwg: *std.Thread.WaitGroup,
-                        wctx: *Context,
-                        witem: *const T,
-                        wargs: anytype,
-                    ) void {
-                        wwg.start();
-                        defer wwg.finish();
-
-                        const result = @call(
-                            .auto,
-                            worker,
-                            .{ wctx, witem.* } ++ wargs,
-                        );
-
-                        result catch |err|
-                            std.log.err("worker failed with: {}", .{err});
-                    }
-                }.func, .{ &wg, ctx, item, args });
-            }
-
-            pool.waitAndWork(&wg);
-        }
-    };
-}
-
-pub fn runPandoc(ctx: *Context) !void {
-    ctx.generated.clearRetainingCapacity();
-
-    ctx.progress_start("Processing markdown files", true);
-    defer ctx.progress_end();
-
-    var md = try std.ArrayList([]const u8).initCapacity(ctx.allocator(), 1);
-    defer md.deinit(ctx.allocator());
-
-    // TODO: support more than one site
-    for (ctx.sites.getLast().pages.items) |p|
-        if (p.kind == .Markdown) try md.append(ctx.allocator(), p.path);
-
-    // TODO: check if pandoc is installed and exit gracefully
-    try WaitPool(
-        []const u8,
-        pandocWorker,
-    ).work(ctx, md.items, .{});
-
-    log.info("Processed {d} markdown files.", .{md.items.len});
-}
-
-fn pandocWorker(ctx: *Context, page: []const u8) !void {
-    ctx.progress_start(page, true);
-    defer ctx.progress_end();
-
-    const source_path = try std.fs.path.join(ctx.allocator(), &.{ Context.params.PAGE_PATH, page });
-    defer ctx.allocator().free(source_path);
-    const target_path = try fs.replaceExtension(ctx.allocator(), source_path, "htm");
-    defer ctx.allocator().free(target_path);
-
-    // get mtime of the source document
-    const mtime = try fs.getLastEdit(source_path);
-    const mtime_str = try fs.formatTimespec(ctx.allocator(), mtime, "%s");
-    defer ctx.allocator().free(mtime_str);
-
-    const f = try fs.FileSwapper.createAt(ctx.allocator(), target_path);
-
-    var buffer: [1024]u8 = undefined;
-    var w = f.writer(&buffer);
-
-    // open new scope to close the file swapper when we're done writing
-    try w.interface.print(
-        \\<!-- Generated file, you may edit and discard {s} :) -->
-        \\
-    ,
-        .{page},
-    );
-
-    // make pandoc runs reproducable
-    var env_map = std.process.EnvMap.init(ctx.allocator());
-    defer env_map.deinit();
-    try env_map.put("SOURCE_DATE_EPOCH", mtime_str);
-
-    try captureStdout(ctx, &.{
-        Context.params.PANDOC,
-        "--from",
-        "markdown+wikilinks_title_after_pipe+autolink_bare_uris",
-        "--to",
-        "html5",
-        "--lua-filter",
-        // TODO: embed the preproc filter
-        Context.params.PANDOC_PREPROC,
-        "--strip-comments",
-        "--output=-",
-        source_path,
-    }, &env_map, &w.interface);
-
-    f.close();
-
-    if (f.swapped.*) {
-        try ctx.generated.append(
-            ctx.allocator(),
-            try ctx.allocator().dupe(u8, target_path),
-        );
-    }
-}
-
-pub fn commit(ctx: *Context, paths: []const []const u8) !void {
-    // skip early if we have nothing to do
-    if (paths.len == 0) return;
-
-    // skip if staging is unclean
-    shell(ctx,
-        \\git diff --cached --exit-code
-    , null) catch |e| switch (e) {
-        error.NonZeroExitCode => return,
-        else => return e,
-    };
-
-    for (paths) |f| {
-        const cmd = try std.fmt.allocPrint(ctx.allocator(), "git add {s}", .{f});
-        defer ctx.allocator().free(cmd);
-
-        try shell(ctx, cmd, null);
-    }
-
-    try shell(ctx,
-        \\GIT_COMMITTER_NAME=rbc git commit --quiet --message=*
-    , null);
-
-    try shell(ctx,
-        \\git show --stat --pretty=oneline
-    , null);
-
-    log.info("Committed {d} paths", .{paths.len});
-}
-
-pub fn captureStdout(ctx: *Context, argv: []const []const u8, env_map: ?*std.process.EnvMap, w: *std.Io.Writer) !void {
-    const command = try std.mem.join(ctx.allocator(), " ", argv);
-    defer ctx.allocator().free(command);
-    const label = try std.fmt.allocPrint(ctx.allocator(), "running {s}", .{command});
-    defer ctx.allocator().free(label);
-
-    ctx.progress_start(label, true);
-    defer ctx.progress_end();
-
-    log.debug("running {s}", .{command});
-
-    var p = std.process.Child.init(argv, std.heap.page_allocator);
-    p.stdin_behavior = .Ignore;
-    p.stdout_behavior = .Pipe;
-    p.stderr_behavior = .Ignore;
-    p.progress_node = ctx.progress_nodes.getLast();
-    p.env_map = env_map;
-
-    try p.spawn();
-
-    var buffer: [1024]u8 = undefined;
-    var r = p.stdout.?.readerStreaming(&buffer);
-
-    _ = try w.sendFileAll(&r, .unlimited);
-    try w.flush();
-
-    const result = try p.wait();
-    if (result.Exited != 0) return error.NonZeroExitCode;
-}
-
-pub fn shell(ctx: *Context, command: []const u8, env_map: ?*std.process.EnvMap) !void {
-    const label = try std.fmt.allocPrint(ctx.allocator(), "running {s}", .{command});
-    defer ctx.allocator().free(label);
-
-    ctx.progress_start(label, true);
-    defer ctx.progress_end();
-
-    var p = std.process.Child.init(&.{
-        "sh",
-        "-c",
-        command,
-    }, std.heap.page_allocator);
-
-    p.stdout_behavior = .Inherit;
-    p.stderr_behavior = .Inherit;
-    p.progress_node = ctx.progress_nodes.getLast();
-    p.env_map = env_map;
-
-    try p.spawn();
-    const result = try p.wait();
-    if (result.Exited != 0) return error.NonZeroExitCode;
-}
diff --git a/src/fs.zig b/src/fs.zig
deleted file mode 100644
index f139653..0000000
--- a/src/fs.zig
+++ /dev/null
@@ -1,239 +0,0 @@
-const std = @import("std");
-const c = @cImport({
-    @cInclude("stdlib.h");
-    @cInclude("time.h");
-});
-
-const exe = @import("exe.zig");
-
-const log = std.log.scoped(.fs);
-
-pub const FileSwapper = struct {
-    allocator: std.mem.Allocator,
-    target_path: []const u8,
-    tmp_path: []const u8,
-    tmp: std.fs.File,
-    swapped: *bool,
-
-    pub fn createAt(alloc: std.mem.Allocator, path: []const u8) !FileSwapper {
-        const target_dir = std.fs.path.dirname(path) orelse ".";
-        const target_name = std.fs.path.basename(path);
-
-        const tmp_name = try std.fmt.allocPrint(
-            alloc,
-            ".{s}",
-            .{target_name},
-        );
-        defer alloc.free(tmp_name);
-
-        const tmp_path = try std.fs.path.join(
-            alloc,
-            &.{ target_dir, tmp_name },
-        );
-
-        const f = try std.fs.cwd().createFile(tmp_path, .{});
-
-        const swapped = try alloc.create(bool);
-        swapped.* = false;
-
-        return .{
-            .allocator = alloc,
-            .target_path = path,
-            .tmp_path = tmp_path,
-            .tmp = f,
-            .swapped = swapped,
-        };
-    }
-
-    pub fn file(self: FileSwapper) std.fs.File {
-        return self.tmp;
-    }
-
-    pub fn writer(self: FileSwapper, buffer: []u8) std.fs.File.Writer {
-        return self.tmp.writer(buffer);
-    }
-
-    pub fn swap(self: FileSwapper) !void {
-        std.log.debug("swapping {s} -> {s}", .{ self.tmp_path, self.target_path });
-
-        // flush writes
-        self.tmp.sync() catch
-            @panic("sync tmp file");
-
-        // swap file into final location
-        // NOTE: the tmp fd is still valid after this
-        try std.fs.rename(
-            std.fs.cwd(),
-            self.tmp_path,
-            std.fs.cwd(),
-            self.target_path,
-        );
-
-        self.swapped.* = true;
-    }
-
-    pub fn close(self: FileSwapper) void {
-        var found = true;
-        std.fs.cwd().access(self.tmp_path, .{}) catch |e| switch (e) {
-            error.FileNotFound => found = false,
-            else => @panic("access tmp path"),
-        };
-
-        defer self.allocator.free(self.tmp_path);
-        defer self.allocator.destroy(self.swapped);
-        defer self.tmp.close();
-
-        if (found) {
-            const changed = diff(self.tmp_path, self.target_path) catch
-                @panic("failed to diff tmp and target file");
-
-            if (changed) {
-                self.swap() catch
-                    @panic("failed to swap target file");
-            } else {
-                log.debug("{s} no changes, skipping", .{ self.target_path });
-                std.posix.unlink(self.tmp_path) catch
-                    @panic("failed to cleanup tmp file");
-            }
-        }
-    }
-};
-
-pub fn replaceExtension(alloc: std.mem.Allocator, p: []const u8, new: []const u8) ![]const u8 {
-    const dirname = std.fs.path.dirname(p) orelse ".";
-    const basename = try stripExtension(std.fs.path.basename(p));
-    return try joinPathAndFilename(
-        alloc,
-        &.{dirname},
-        &.{ basename, ".", new },
-    );
-}
-
-// also trims off base paths
-pub fn stripExtension(path: []const u8) ![]const u8 {
-    const ext = std.fs.path.extension(path);
-    const ext_idx = std.mem.indexOfPosLinear(u8, path, 0, ext);
-    std.debug.assert(ext_idx != null);
-    return path[0..ext_idx.?];
-}
-
-pub fn joinPathAndFilename(alloc: std.mem.Allocator, paths: []const []const u8, filename_parts: []const []const u8) ![]const u8 {
-    var buf: [std.os.linux.PATH_MAX]u8 = undefined;
-    var fba = std.heap.FixedBufferAllocator.init(&buf);
-
-    const filename = try std.mem.concat(fba.allocator(), u8, filename_parts);
-
-    var p = try std.ArrayList([]const u8).initCapacity(fba.allocator(), paths.len + 1);
-    p.appendSliceAssumeCapacity(paths);
-    p.appendAssumeCapacity(filename);
-
-    std.debug.assert(paths.len + 1 == p.items.len);
-    return try std.fs.path.join(alloc, p.items);
-}
-
-// pub fn getGitTime(path: []const u8) !std.posix.timespec {
-//     var buf = std.mem.zeroes([12]u8);
-//
-//     var p = std.process.Child.init(
-//         &.{ "git", "log", "-1", "--pretty=format:%at", "-z", "--", path },
-//         std.heap.page_allocator,
-//     );
-//     p.stdout_behavior = .Pipe;
-//     try p.spawn();
-//     _ = try p.stdout.?.readAll(&buf);
-//     const result = try p.wait();
-//
-//     std.debug.assert(result == .Exited and result.Exited == 0);
-//
-//     // parse result as ulong
-//     const gitAuthorDate: c_ulong = c.strtoul(&buf, null, 10);
-//     if (gitAuthorDate == 0) return error.NoGitTime;
-//
-//     // calculate timespec
-//     return .{
-//         .sec = std.math.cast(isize, gitAuthorDate) orelse std.math.maxInt(isize),
-//         .nsec = 0,
-//     };
-// }
-
-pub fn getMtime(path: []const u8) !std.posix.timespec {
-    const fd = try std.fs.cwd().openFile(path, .{ .mode = .read_only });
-    defer fd.close();
-
-    const meta = try fd.stat();
-    const mtime = meta.mtime;
-
-    return .{
-        .sec = std.math.cast(isize, @divFloor(mtime, std.time.ns_per_s)) orelse std.math.maxInt(isize),
-        .nsec = std.math.cast(isize, @mod(mtime, std.time.ns_per_s)) orelse std.math.maxInt(isize),
-    };
-}
-
-pub fn getLastEdit(path: []const u8) !std.posix.timespec {
-    std.fs.cwd().access(path, .{}) catch |e| switch (e) {
-        error.FileNotFound => return .{
-            .sec = 0,
-            .nsec = 0,
-        },
-        else => return e,
-    };
-    return getMtime(path);
-}
-
-pub fn setMtime(path: []const u8, ts: std.posix.timespec) !void {
-    const fd = try std.fs.cwd().openFile(path, .{ .mode = .write_only });
-    defer fd.close();
-
-    try std.posix.futimens(
-        fd.handle,
-        &[2]std.os.linux.timespec{ ts, ts },
-    );
-}
-
-// use libc strftime to format time string
-pub fn formatTimespec(alloc: std.mem.Allocator, timespec: std.posix.timespec, fmt: [*c]const u8) ![]const u8 {
-    const mtime_str = try alloc.alloc(u8, std.os.linux.PATH_MAX);
-    var tm: c.struct_tm = undefined;
-    _ = c.localtime_r(&timespec.sec, &tm);
-    const rc = c.strftime(
-        mtime_str.ptr,
-        mtime_str.len,
-        fmt,
-        &tm,
-    );
-    switch (std.posix.errno(rc)) {
-        .SUCCESS => {},
-        else => |err| return std.posix.unexpectedErrno(err),
-    }
-    return try alloc.realloc(mtime_str, rc);
-}
-
-pub fn diff(a: []const u8, b: []const u8) !bool {
-    std.fs.cwd().access(a, .{}) catch |e| switch (e) {
-        error.FileNotFound => return true,
-        else => return e,
-    };
-    std.fs.cwd().access(b, .{}) catch |e| switch (e) {
-        error.FileNotFound => return true,
-        else => return e,
-    };
-
-    var p = std.process.Child.init(&.{
-        "diff",
-        "-q",
-        a,
-        b,
-    }, std.heap.page_allocator);
-    p.stdin_behavior = .Close;
-    p.stderr_behavior = .Ignore;
-    p.stdout_behavior = .Ignore;
-    try p.spawn();
-    switch (try p.wait()) {
-        .Exited => |exit_code| switch (exit_code) {
-            0 => return false,
-            1 => return true,
-            else => return error.UnknownDiffError,
-        },
-        else => return error.UnknownDiffError,
-    }
-}
diff --git a/src/gen.zig b/src/gen.zig
index fd1dff7..8def5cf 100644
--- a/src/gen.zig
+++ b/src/gen.zig
@@ -1,131 +1,35 @@
 const std = @import("std");
 
-const fs = @import("fs.zig");
-const exe = @import("exe.zig");
-const Context = @import("main.zig").Context;
+const os = @import("os.zig");
 
 const log = std.log.scoped(.gen);
 
-pub fn generate(ctx: *Context) !void {
-    ctx.generated.clearRetainingCapacity();
-
-    ctx.progress_start("generate", true);
-    defer ctx.progress_end();
-
-    try index(ctx);
-
-    log.info("Generated index.", .{});
-
-    try pages(ctx);
-
-    log.info("Generated {d} files.", .{ctx.generated.items.len});
-    log.info("Generated {d} tildes.", .{ctx.tilde});
-}
-
-fn index(ctx: *Context) !void {
-    ctx.progress_start("index.htm", false);
-    defer ctx.progress_end();
-
-    const srcpathIndex = try fs.joinPathAndFilename(ctx.allocator(), &.{Context.params.PAGE_PATH}, &.{"index.htm"});
-    defer ctx.allocator().free(srcpathIndex);
-
-    const f = try fs.FileSwapper.createAt(ctx.allocator(), srcpathIndex);
-    defer f.close();
-
-    var buffer: [1024]u8 = undefined;
-    var w = f.writer(&buffer);
-
-    try w.interface.writeAll(
-        \\<ul class="col2 captial">
-        \\
-    );
-
-    for (ctx.sites.items) |s| {
-        for (s.pages.items) |p| {
-            if (p.kind == .Htm) {
-                const dstpath = try fs.joinPathAndFilename(ctx.allocator(), &.{Context.params.SITE_PATH}, &.{ p.name, ".html" });
-                defer ctx.allocator().free(dstpath);
-
-                try w.interface.print(
-                    \\<li><a href="/{[LINK]s}">{[NAME]s}</a></li>
-                    \\
-                , .{
-                    .LINK = dstpath,
-                    .NAME = p.name,
-                });
-            }
-        }
-    }
-
-    try w.interface.writeAll(
-        \\</ul>
-        \\
-    );
-
-    try w.interface.flush();
-}
-
-fn pages(ctx: *Context) !void {
-    for (ctx.sites.items) |s| {
-        for (s.pages.items) |p| {
-            if (p.kind == .Meta) {
-                try meta(ctx, p);
-            } else if (p.kind == .Htm) {
-                try page(ctx, p);
-            }
-        }
-    }
-}
-
-fn page(ctx: *Context, p: Context.Site.Page) !void {
-    ctx.progress_start(p.name, false);
-    defer ctx.progress_end();
-
-    const srcpath = try fs.joinPathAndFilename(ctx.allocator(), &.{Context.params.PAGE_PATH}, &.{ p.name, ".htm" });
-    defer ctx.allocator().free(srcpath);
-    const dstpath = try fs.joinPathAndFilename(ctx.allocator(), &.{Context.params.SITE_PATH}, &.{ p.name, ".html" });
-    defer ctx.allocator().free(dstpath);
-    const rel_dstpath = try std.fs.path.relative(ctx.allocator(), Context.params.SITE_PATH, dstpath);
-    defer ctx.allocator().free(rel_dstpath);
-
-    if (std.fs.path.dirname(rel_dstpath)) |d| {
-        const dstdir = try fs.joinPathAndFilename(ctx.allocator(), &.{Context.params.SITE_PATH}, &.{d});
-        defer ctx.allocator().free(dstdir);
-
-        try std.fs.cwd().makePath(dstdir);
-    }
-
-    const srcmtime = try fs.getLastEdit(srcpath);
-    const dstmtime = try fs.getLastEdit(dstpath);
-    if (!ctx.force and srcmtime.sec < dstmtime.sec) return;
-
-    log.debug("generating {s} at {s}", .{ p.path, rel_dstpath });
-
-    const f = try fs.FileSwapper.createAt(ctx.allocator(), dstpath);
-
-    var buffer: [1024]u8 = undefined;
-    var w = f.writer(&buffer);
-
-    const page_name = try std.mem.replaceOwned(u8, ctx.allocator(), p.name, "_", " ");
-    defer ctx.allocator().free(page_name);
-
-    try build(ctx.allocator(), &w.interface, page_name, srcpath);
-
-    f.close();
-
-    if (f.swapped.*) {
-        try ctx.generated.append(
-            ctx.allocator(),
-            try ctx.allocator().dupe(u8, dstpath),
-        );
-    }
-}
+const DELIM_START: []const u8 = "{[";
+const DELIM_END: []const u8 = "]}";
+
+pub const BuildOpts = struct {
+    root_dir: std.Io.Dir,
+    site_dir: std.Io.Dir,
+
+    page_path: []const u8,
+    page_name: []const u8,
+
+    meta: struct {
+        source_url: []const u8,
+        site_name: []const u8,
+        license_url: []const u8,
+        license_holder: []const u8,
+        license_name: []const u8,
+        license_year: []const u8,
+        public_inbox: []const u8,
+    },
+};
 
 // build a single html file
-fn build(allocator: std.mem.Allocator, w: *std.Io.Writer, page_name: []const u8, srcpath: []const u8) !void {
-    const mtime = try fs.getLastEdit(srcpath);
-    const mtime_str = try fs.formatTimespec(allocator, mtime, "%F");
-    defer allocator.free(mtime_str);
+pub fn build_htm(io: std.Io, gpa: std.mem.Allocator, w: *std.Io.Writer, opts: BuildOpts) !void {
+    const stat = try opts.root_dir.statFile(io, opts.page_path, .{});
+    const mtime_str = try os.formatTimestamp(gpa, stat.mtime, "%F");
+    defer gpa.free(mtime_str);
 
     // HEAD
     try w.print(
@@ -148,18 +52,19 @@ fn build(allocator: std.mem.Allocator, w: *std.Io.Writer, page_name: []const u8,
         \\<a href="/"><img src="/media/interface/logo.svg" alt="{[SITE_NAME]s}"></a>
         \\
     , .{
-        .SOURCE = srcpath,
-        .LICENSE_URL = Context.params.LICENSE_URL,
-        .PAGE_NAME = page_name,
-        .SITE_NAME = Context.params.SITE_NAME,
+        .SOURCE = opts.page_path,
+        .PAGE_NAME = opts.page_name,
+        .LICENSE_URL = opts.meta.license_url,
+        .SITE_NAME = opts.meta.site_name,
     });
 
     // NAV
     try w.writeAll(
         \\<nav>
-        \\
     );
-    portal(allocator, w, "meta.nav", false) catch {}; // ignore error
+    template_portal(io, gpa, w, "meta.nav", opts) catch |err| {
+        if (err != error.FileNotFound) return err;
+    };
     try w.writeAll(
         \\</nav>
         \\</header>
@@ -167,11 +72,11 @@ fn build(allocator: std.mem.Allocator, w: *std.Io.Writer, page_name: []const u8,
     );
 
     // MAIN
-    try w.print(
+    try w.writeAll(
         \\<main>
         \\
-    , .{});
-    try inject(allocator, w, srcpath);
+    );
+    try inject(io, gpa, w, opts);
     try w.writeAll(
         \\</main>
         \\
@@ -190,14 +95,14 @@ fn build(allocator: std.mem.Allocator, w: *std.Io.Writer, page_name: []const u8,
         \\</footer>
         \\
     , .{
-        .LICENSE_HOLDER = Context.params.LICENSE_HOLDER,
-        .LICENSE_NAME = Context.params.LICENSE_NAME,
-        .LICENSE_URL = Context.params.LICENSE_URL,
-        .LICENSE_YEAR = Context.params.LICENSE_YEAR,
         .MTIME = mtime_str,
-        .PATH = srcpath,
-        .PUBLIC_INBOX = Context.params.PUBLIC_INBOX,
-        .SOURCE_URL = Context.params.SOURCE_URL,
+        .PATH = opts.page_path,
+        .LICENSE_HOLDER = opts.meta.license_holder,
+        .LICENSE_NAME = opts.meta.license_name,
+        .LICENSE_URL = opts.meta.license_url,
+        .LICENSE_YEAR = opts.meta.license_year,
+        .PUBLIC_INBOX = opts.meta.public_inbox,
+        .SOURCE_URL = opts.meta.source_url,
     });
 
     try w.writeAll(
@@ -208,92 +113,26 @@ fn build(allocator: std.mem.Allocator, w: *std.Io.Writer, page_name: []const u8,
     try w.writeAll(
         \\</body>
         \\</html>
-        \\
     );
 
     try w.flush();
 }
 
-// build a virtual html file, currently either:
-// - meta.nav: used for populating the global navigation menu
-// - meta.tilde: used to create ~foobar files
-fn meta(ctx: *Context, p: Context.Site.Page) !void {
-    ctx.progress_start(p.name, false);
-    defer ctx.progress_end();
-
-    ctx.meta += 1;
-
-    if (std.mem.startsWith(u8, p.name, "meta.nav")) return;
-
-    if (std.mem.startsWith(u8, p.name, "meta.tilde")) {
-        const srcpath = try fs.joinPathAndFilename(ctx.allocator(), &.{Context.params.PAGE_PATH}, &.{p.path});
-        defer ctx.allocator().free(srcpath);
-        const dstname = p.name[11..]; // trim "meta.tilde." from the name
-        const dstpath = try fs.joinPathAndFilename(ctx.allocator(), &.{Context.params.LINK_PATH}, &.{ "~", dstname });
-        defer ctx.allocator().free(dstpath);
-
-        const link = blk: {
-            const f = try std.fs.cwd().openFile(srcpath, .{ .mode = .read_only });
-            defer f.close();
-            const content = try f.readToEndAlloc(ctx.allocator(), std.os.linux.PATH_MAX);
-            defer ctx.allocator().free(content);
-            const trimmed = std.mem.trim(u8, content, " \n\t\r");
-            break :blk try ctx.allocator().dupe(u8, trimmed);
-        };
-        defer ctx.allocator().free(link);
-
-        const f = try fs.FileSwapper.createAt(ctx.allocator(), dstpath);
-        defer f.close();
-
-        var buffer: [1024]u8 = undefined;
-        var w = f.writer(&buffer);
-
-        try w.interface.print(
-            \\<!DOCTYPE html>
-            \\<html lang="en">
-            \\<head>
-            \\<meta charset="utf-8">
-            \\<meta http-equiv="refresh" content="0; url={[LINK]s}">
-            \\<meta name="fediverse:creator" content="@robertgzr@toot.berlin">
-            \\<link rel="stylesheet" type="text/css" href="links/main.css">
-            \\</head>
-            \\<body>
-            \\<noscript>Continue to <a href="{[LINK]s}">{[LINK]s}</a>.</noscript>
-            \\<script>
-            \\'use strict'
-            \\window.location.replace("{[LINK]s}")
-            \\</script>
-            \\</body>
-            \\</html>
-            \\
-        , .{
-            .LINK = link,
-        });
-        try w.interface.flush();
-
-        ctx.tilde += 1;
-        return;
-    }
-
-    log.err("unknown meta page type: {s}", .{p.name});
-    return error.unknownMeta;
-}
-
 // parses srcpath, resolves refs and portals and writes everything to w
-fn inject(allocator: std.mem.Allocator, w: *std.Io.Writer, srcpath: []const u8) !void {
-    var arena = std.heap.ArenaAllocator.init(allocator);
+fn inject(io: std.Io, gpa: std.mem.Allocator, w: *std.Io.Writer, opts: BuildOpts) !void {
+    var arena = std.heap.ArenaAllocator.init(gpa);
     defer arena.deinit();
 
-    const inc = std.fs.cwd().openFile(srcpath, .{ .mode = .read_only }) catch |err| {
+    const inc = opts.root_dir.openFile(io, opts.page_path, .{ .mode = .read_only }) catch |err| {
         if (err == error.FileNotFound)
-            log.warn("Missing {s} include.", .{srcpath});
+            log.warn("Missing {s} include.", .{opts.page_path});
 
         return err;
     };
-    defer inc.close();
+    defer inc.close(io);
 
-    var buffer: [1024]u8 = undefined;
-    var r = inc.readerStreaming(&buffer);
+    const buf = try arena.allocator().alloc(u8, 1024);
+    var r = inc.readerStreaming(io, buf);
 
     var chunkw = std.Io.Writer.Allocating.init(arena.allocator());
     defer chunkw.deinit();
@@ -308,10 +147,10 @@ fn inject(allocator: std.mem.Allocator, w: *std.Io.Writer, srcpath: []const u8)
 
         var chunk = chunkw.written();
         templating: while (true) {
-            if (std.mem.indexOfPosLinear(u8, chunk, 0, "{")) |start| {
-                if (std.mem.indexOfPosLinear(u8, chunk, start + 1, "}")) |end| {
+            if (std.mem.indexOfPosLinear(u8, chunk, 0, DELIM_START)) |start| {
+                if (std.mem.indexOfPosLinear(u8, chunk, start + 1, DELIM_END)) |end| {
                     try w.writeAll(chunk[0..start]);
-                    template(allocator, w, chunk[start + 1 .. end]) catch |err| switch (err) {
+                    template(io, gpa, w, chunk[(start + DELIM_START.len)..end], opts) catch |err| switch (err) {
                         error.FileNotFound => {
                             // log.warn("ref {s} not found", .{chunk[start + 1 .. end]});
                             // trim already written chunk and exit templating
@@ -320,7 +159,7 @@ fn inject(allocator: std.mem.Allocator, w: *std.Io.Writer, srcpath: []const u8)
                         },
                         else => @panic("Failed to resolve template"),
                     };
-                    chunk = chunk[end + 1 ..];
+                    chunk = chunk[end + DELIM_END.len ..];
                 } else break :templating;
             } else break :templating;
         }
@@ -334,92 +173,83 @@ fn inject(allocator: std.mem.Allocator, w: *std.Io.Writer, srcpath: []const u8)
 }
 
 // implements various reference types that can be used in .htm templates
-fn template(allocator: std.mem.Allocator, w: *std.Io.Writer, refname: []const u8) anyerror!void {
-    switch (refname[0]) {
-        '/' => return try portal(allocator, w, refname[1..], false),
-        '$' => return try ref_link(allocator, w, refname[1..]),
-        '+' => return try ref_name(allocator, w, refname[1..]),
-        else => return try ref(allocator, w, refname),
+fn template(io: std.Io, gpa: std.mem.Allocator, w: *std.Io.Writer, ref_name: []const u8, opts: BuildOpts) anyerror!void {
+    switch (ref_name[0]) {
+        '/' => return try template_portal(io, gpa, w, ref_name[1..], opts),
+        //     '$' => return try ref_link(allocator, w, refname[1..]),
+        //     '+' => return try ref_name(allocator, w, refname[1..]),
+        else => return try template_ref(io, gpa, w, ref_name, opts),
     }
 }
 
-const Page = struct {
-    arena: std.heap.ArenaAllocator,
-    ref: []const u8,
-    name: []const u8,
-    html: []const u8,
-    htm: []const u8,
-    anchor: []const u8,
-
-    fn from_ref(allocator: std.mem.Allocator, refname: []const u8) !Page {
-        var arena = std.heap.ArenaAllocator.init(allocator);
-
-        const endofref = std.mem.lastIndexOf(u8, refname, "#") orelse refname.len;
-        const basename = try std.mem.replaceOwned(u8, arena.allocator(), refname[0..endofref], " ", "_");
-        const html = try fs.joinPathAndFilename(arena.allocator(), &.{Context.params.SITE_PATH}, &.{ basename, ".html" });
-        const htm = try fs.joinPathAndFilename(arena.allocator(), &.{Context.params.PAGE_PATH}, &.{ basename, ".htm" });
-
-        try std.fs.cwd().access(htm, .{ .mode = .read_only });
-
-        return .{
-            .arena = arena,
-            .ref = refname[0..endofref],
-            .name = basename,
-            .html = html,
-            .htm = htm,
-            .anchor = if (endofref < refname.len) refname[endofref..] else "",
-        };
-    }
+// insert link
+fn template_ref(io: std.Io, gpa: std.mem.Allocator, w: *std.Io.Writer, ref_name: []const u8, opts: BuildOpts) anyerror!void {
+    log.debug("template:ref (ref={s})", .{ref_name});
 
-    fn deinit(self: Page) void {
-        self.arena.deinit();
-    }
-};
+    const ref_path = try std.fmt.allocPrint(gpa, "{s}.html", .{ref_name});
+    defer gpa.free(ref_path);
 
-fn portal(allocator: std.mem.Allocator, w: *std.Io.Writer, refname: []const u8, insert_heading: bool) anyerror!void {
-    const p = try Page.from_ref(allocator, refname);
-    defer p.deinit();
+    _ = io;
+    _ = opts;
 
-    if (insert_heading)
-        try w.print(
-            \\<h2 id="{[ID]s}"><a href="/{[PATH]s}">{[NAME]s}</a></2>
-            \\
-        , .{
-            .ID = p.ref,
-            .NAME = p.name,
-            .PATH = p.html,
-        });
-
-    try inject(allocator, w, p.htm);
-
-    // XXX: track ref count?
+    try w.print(
+        \\<a href="{s}">{s}</a>
+    , .{ ref_path, ref_name });
 }
 
-fn ref(allocator: std.mem.Allocator, w: *std.Io.Writer, refname: []const u8) anyerror!void {
-    const p = try Page.from_ref(allocator, refname);
-    defer p.deinit();
+// insert contents
+fn template_portal(io: std.Io, gpa: std.mem.Allocator, w: *std.Io.Writer, ref_name: []const u8, opts: BuildOpts) anyerror!void {
+    log.debug("template:portal (ref={s})", .{ref_name});
 
-    try w.print(
-        \\<a href="/{[PATH]s}{[ANCHOR]s}">{[NAME]s}</a>
-    , .{
-        .NAME = p.name,
-        .PATH = p.html,
-        .ANCHOR = p.anchor,
-    });
+    const ref_path = try std.fmt.allocPrint(gpa, "{s}.htm", .{ref_name});
+    defer gpa.free(ref_path);
+
+    try opts.root_dir.access(io, ref_path, .{});
 
-    // XXX: track ref count?
+    var iopts = opts;
+    iopts.page_path = ref_path;
+    iopts.page_name = ref_name;
+    try inject(io, gpa, w, iopts);
 }
 
-fn ref_link(allocator: std.mem.Allocator, w: *std.Io.Writer, refname: []const u8) anyerror!void {
-    const p = try Page.from_ref(allocator, refname);
-    defer p.deinit();
+// build a meta file
+pub fn build_meta(io: std.Io, gpa: std.mem.Allocator, w: *std.Io.Writer, opts: BuildOpts) !void {
+    if (std.mem.startsWith(u8, opts.page_name, "meta.nav")) return;
 
-    try w.print("/{s}{s}", .{ p.html, p.anchor });
-}
+    if (std.mem.startsWith(u8, opts.page_name, "meta.tilde")) {
+        const name = std.mem.cutPrefix(u8, opts.page_name, "meta.tilde.").?;
+        const path = try std.fmt.allocPrint(gpa, "~{s}", .{name});
+        defer gpa.free(path);
+
+        const link = blk: {
+            const content = try opts.root_dir.readFileAlloc(io, opts.page_path, gpa, .unlimited);
+            break :blk std.mem.trim(u8, content, " \n\t\r");
+        };
+        defer gpa.free(link);
 
-fn ref_name(allocator: std.mem.Allocator, w: *std.Io.Writer, refname: []const u8) anyerror!void {
-    const p = try Page.from_ref(allocator, refname);
-    defer p.deinit();
+        try w.interface.print(
+            \\<!DOCTYPE html>
+            \\<html lang="en">
+            \\<head>
+            \\<meta charset="utf-8">
+            \\<meta http-equiv="refresh" content="0; url={[LINK]s}">
+            \\<meta name="fediverse:creator" content="@robertgzr@toot.berlin">
+            \\<link rel="stylesheet" type="text/css" href="links/main.css">
+            \\</head>
+            \\<body>
+            \\<noscript>Continue to <a href="{[LINK]s}">{[LINK]s}</a>.</noscript>
+            \\<script>
+            \\'use strict'
+            \\window.location.replace("{[LINK]s}")
+            \\</script>
+            \\</body>
+            \\</html>
+            \\
+        , .{
+            .LINK = link,
+        });
+        try w.interface.flush();
+    }
 
-    try w.print("{s}", .{p.ref});
+    return error.UnsupportedMeta;
 }
diff --git a/src/main.zig b/src/main.zig
index fb8e687..354eed9 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -5,9 +5,8 @@
 // https://github.com/hundredrabbits/100r.co/blob/main/src/main.c and his
 // personal site https://github.com/XXIIVV/Oscean
 
-const exe = @import("exe.zig");
-const gen = @import("gen.zig");
-const fs = @import("fs.zig");
+const Context = @import("Context.zig");
+const os = @import("os.zig");
 
 const std = @import("std");
 
@@ -15,299 +14,114 @@ const log = std.log.scoped(.rbc);
 
 // tune std.log options
 pub const std_options = std.Options{
-    .log_level = .debug,
+    .log_level = if (@import("builtin").mode == .Debug) .debug else .info,
 };
 
-pub const Context = struct {
-    pub const Site = struct {
-        pub const Page = struct {
-            kind: enum {
-                Meta,
-                Htm,
-                Markdown,
-            },
-            path: []const u8,
-            name: []const u8,
-        };
-
-        path: []const u8,
-        pages: std.ArrayListUnmanaged(Page) = .{},
-    };
-
-    arena: std.heap.ArenaAllocator,
-    arena_mx: std.Thread.Mutex = .{},
-    alloc: std.heap.ThreadSafeAllocator = undefined,
-
-    progress_nodes: std.ArrayListUnmanaged(std.Progress.Node) = .{},
-    progress_nodes_mx: std.Thread.Mutex = .{},
-    progress_nodes_tl: ?*const std.Progress.Node = null,
-
-    sites: std.ArrayListUnmanaged(Site) = .{},
-    generated: std.ArrayListUnmanaged([]const u8) = .{},
-    meta: usize = 0,
-    tilde: usize = 0,
-
-    // opts
-    force: bool = false,
-
-    pub const params = struct {
-        pub const PAGE_PATH = "src/inc";
-        pub const SITE_PATH = "site";
-        pub const LINK_PATH = "links";
-
-        pub const SITE_NAME = "gzr.im";
-        pub const LICENSE_HOLDER = "robertgzr";
-        pub const LICENSE_YEAR = "2025";
-        pub const LICENSE_NAME = "BY-NC-SA 4.0";
-        pub const LICENSE_URL = "https://git.sr.ht/~robertgzr/site/blob/master/LICENSE.content";
-        pub const SOURCE_URL = "https://git.sr.ht/~robertgzr/site/tree/master";
-        pub const PUBLIC_INBOX = "~robertgzr/public-inbox@lists.sr.ht";
-
-        pub const PANDOC = "pandoc";
-        pub const PANDOC_PREPROC = "preproc.lua";
-    };
-
-    fn create(parent_alloc: std.mem.Allocator) !*Context {
-        const self = try parent_alloc.create(Context);
-        self.* = .{
-            .arena = std.heap.ArenaAllocator.init(parent_alloc),
-        };
-        // create thread safe allocator
-        self.alloc = std.heap.ThreadSafeAllocator{
-            .child_allocator = self.arena.allocator(),
-            .mutex = self.arena_mx,
-        };
-        return self;
-    }
-
-    fn destroy(self: *Context) void {
-        self.progress_end();
-        self.progress_nodes.deinit(self.allocator());
-        self.arena.deinit();
-        self.arena.child_allocator.destroy(self);
-    }
-
-    pub fn allocator(self: *Context) std.mem.Allocator {
-        return self.alloc.allocator();
-    }
-
-    // progress handling
-    pub fn progress_start(self: *Context, name: []const u8, become_toplevel: bool) void {
-        const node = blk: {
-            if (self.progress_nodes_tl) |tl|
-                break :blk tl.start(name, 0);
-
-            break :blk std.Progress.start(.{ .root_name = name });
-        };
-
-        if (become_toplevel) self.progress_nodes_tl = &node;
-
-        self.progress_nodes_mx.lock();
-        self.progress_nodes.append(self.allocator(), node) catch @panic("insufficient memory");
-        self.progress_nodes_mx.unlock();
-    }
-    pub fn progress_end(self: *Context) void {
-        self.progress_nodes_mx.lock();
-        const node = self.progress_nodes.pop() orelse return;
-        self.progress_nodes_mx.unlock();
-        node.end();
-    }
-};
-
-// TODO: make index smarter, it should know which files reference each other.
-// with this information, we could make better decisions whether to generate
-// output.
-//
-fn index(ctx: *Context) !void {
-    // avoid reindexing
-    if (ctx.sites.items.len != 0) return;
-
-    ctx.progress_start("index", true);
-    defer ctx.progress_end();
-
-    var root = std.fs.cwd().openDir(Context.params.PAGE_PATH, .{ .iterate = true }) catch |err| switch (err) {
-        error.FileNotFound => {
-            log.err("Missing {s} folder.", .{Context.params.PAGE_PATH});
-            std.os.linux.exit(1);
-        },
-        else => |e| return e,
+const Cli = struct {
+    const Defaults = struct {
+        const ROOT: []const u8 = "src/inc";
+        const SITE: []const u8 = "site";
     };
-    defer root.close();
-
-    var walk = try root.walk(ctx.allocator());
-    defer walk.deinit();
-
-    var meta: u32 = 0;
-    var htm: u32 = 0;
-    var md: u32 = 0;
-
-    // root site
-    log.debug("Indexing site root", .{});
-    var site = Context.Site{ .path = "." };
-
-    while (try walk.next()) |e| {
-        switch (e.kind) {
-            .file => {
-                ctx.progress_start(e.basename, false);
-                defer ctx.progress_end();
-
-                if (std.mem.startsWith(u8, e.basename, "meta.")) {
-                    log.debug("Indexing meta {s}", .{e.path});
-                    try site.pages.append(ctx.allocator(), .{
-                        .kind = .Meta,
-                        .name = try ctx.allocator().dupe(u8, try fs.stripExtension(e.basename)),
-                        .path = try ctx.allocator().dupe(u8, e.path),
-                    });
-                    meta += 1;
-                } else if (std.mem.endsWith(u8, e.basename, ".htm")) {
-                    log.debug("--> {s}", .{std.mem.trimEnd(u8, e.basename, ".htm")});
-                    log.debug("Indexing page {s}", .{e.path});
-                    try site.pages.append(ctx.allocator(), .{
-                        .kind = .Htm,
-                        .name = try ctx.allocator().dupe(u8, try fs.stripExtension(e.basename)),
-                        .path = try ctx.allocator().dupe(u8, e.path),
-                    });
-                    htm += 1;
-                } else if (std.mem.endsWith(u8, e.basename, ".md")) {
-                    log.debug("Indexing page {s}", .{e.path});
-                    try site.pages.append(ctx.allocator(), .{
-                        .kind = .Markdown,
-                        .name = try ctx.allocator().dupe(u8, try fs.stripExtension(e.basename)),
-                        .path = try ctx.allocator().dupe(u8, e.path),
-                    });
-                    md += 1;
-                }
-
-                // XXX: scan for refs here?
-            },
-            else => {},
-        }
-    }
-
-    try ctx.sites.append(ctx.allocator(), site);
-
-    // sort pages by name
-    for (ctx.sites.items) |s| {
-        std.mem.sort(
-            Context.Site.Page,
-            s.pages.items,
-            {},
-            struct {
-                fn lessthan(_: void, a: Context.Site.Page, b: Context.Site.Page) bool {
-                    return std.ascii.lessThanIgnoreCase(a.name, b.name);
-                }
-            }.lessthan,
-        );
-    }
-
-    log.info("Indexed {d} sites.", .{ctx.sites.items.len});
-    log.info("Indexed {d} meta pages.", .{meta});
-    log.info("Indexed {d} htm pages.", .{htm});
-    log.info("Indexed {d} markdown pages.", .{md});
-
-    return;
-}
 
-const Cli = struct {
-    fn run(ctx: *Context) !void {
-        var argv = std.process.args();
+    fn run(init: std.process.Init, ctx: *Context) !void {
+        var argv = init.minimal.args.iterate();
 
-        _ = argv.skip(); // skip arg0
+        _ = argv.skip(); // skip command name
 
-        if (argv.inner.count == 1) {
-            try Cli.mdCmd(ctx, null);
-            try Cli.genCmd(ctx, null);
+        if (argv.inner.remaining.len == 0) {
+            return Cli.helpCmd();
         }
 
         while (argv.next()) |arg| {
             if (std.mem.eql(u8, arg, "help")) {
-                Cli.helpCmd(ctx);
+                return Cli.helpCmd();
             } else if (std.mem.eql(u8, arg, "gen")) {
-                try Cli.genCmd(ctx, &argv);
+                try Cli.genCmd(init, &argv, ctx);
             } else if (std.mem.eql(u8, arg, "md")) {
                 try Cli.mdCmd(ctx, &argv);
             } else if (std.mem.eql(u8, arg, "watch")) {
-                try Cli.watchCmd(ctx);
+                try Cli.watchCmd(init, &argv);
             } else if (std.mem.eql(u8, arg, "serve")) {
-                try Cli.serveCmd(ctx);
+                try Cli.serveCmd(init, &argv);
             } else {
-                Cli.helpCmd(ctx);
-            }
-        }
-    }
-
-    fn checkOpts(maybe_argv: ?*std.process.ArgIterator, opts: []const []const u8) bool {
-        if (maybe_argv) |argv| {
-            const arg = argv.next() orelse return false;
-            // check opts
-            for (opts) |opt| {
-                if (std.mem.eql(u8, arg, opt)) return true;
+                return Cli.helpCmd();
             }
-            // rewind argv
-            argv.*.inner.index -= 1;
-            return false;
-        } else {
-            return false;
         }
     }
 
-    fn helpCmd(_: *Context) void {
-        std.debug.print("usage: rbl [gen|md|watch|serve]\n\n", .{});
+    fn helpCmd() void {
+        std.debug.print("usage: rbc COMMAND\n\n", .{});
+        std.debug.print(" rbc gen ROOT SITE [force=true]\n", .{});
+        std.debug.print(" rbc watch\n", .{});
+        std.debug.print(" rbc serve\n", .{});
+        std.debug.print("\n", .{});
     }
 
-    fn genCmd(ctx: *Context, argv: ?*std.process.ArgIterator) !void {
-        try index(ctx);
-
-        if (checkOpts(argv, &.{ "-f", "--force" }))
-            ctx.force = true;
-
-        try gen.generate(ctx);
-
-        if (checkOpts(argv, &.{ "-c", "--commit" }))
-            try exe.commit(ctx, ctx.generated.items);
+    fn genCmd(init: std.process.Init, argv: *std.process.Args.Iterator, ctx: *Context) !void {
+        const root = argv.next() orelse Cli.Defaults.ROOT;
+        const site = argv.next() orelse Cli.Defaults.SITE;
+        const force = blk: {
+            const forcearg = argv.next();
+            if (forcearg != null and std.mem.eql(u8, forcearg.?, "force=true")) {
+                break :blk true;
+            } else {
+                break :blk false;
+            }
+        };
+        log.info("root = {s}", .{root});
+        log.info("site = {s}", .{site});
+        try ctx.index(try std.Io.Dir.cwd().openDir(init.io, root, .{ .iterate = true }));
+        try ctx.render(try std.Io.Dir.cwd().createDirPathOpen(init.io, site, .{}), force);
     }
 
-    fn mdCmd(ctx: *Context, argv: ?*std.process.ArgIterator) !void {
-            try index(ctx);
-            try exe.runPandoc(ctx);
+    fn mdCmd(init: std.process.Init, argv: *std.process.Args.Iterator, ctx: *Context) !void {
+        const root = argv.next() orelse Cli.Defaults.ROOT;
+        log.info("root = {s}", .{root});
 
-            if (checkOpts(argv, &.{ "-c", "--commit" }))
-                try exe.commit(ctx, ctx.generated.items);
+        try os.shell(init.io,
+            \\pandoc --from markdown+wikilinks_title_after_pipe+autolink_bare_uris --to html5 --lua-filter preproc.lua --strip-comments --output=-
+        , .{ .environ_map = &env_map, .cwd = .{ .path = root } });
     }
 
-    fn watchCmd(ctx: *Context) !void {
-        const selfExe = try std.fs.selfExePathAlloc(ctx.allocator());
-        const command = try std.fmt.allocPrint(ctx.allocator(),
-            \\find ./src/inc/ -type f -print | entr -nc -dd -- {s} md gen
-        , .{selfExe});
+    fn watchCmd(init: std.process.Init, argv: *std.process.Args.Iterator) !void {
+        const root = argv.next() orelse Cli.Defaults.ROOT;
+        const site = argv.next() orelse Cli.Defaults.SITE;
 
-        try exe.shell(ctx, command, null);
+        const exePath = blk: {
+            var buf: [std.os.linux.PATH_MAX]u8 = undefined;
+            const n = try std.process.executablePath(init.io, &buf);
+            break :blk buf[0..n];
+        };
+        const command = try std.fmt.allocPrint(init.gpa,
+            \\find "{s}" -path '*.htm' -type f -print | entr -nc -dd -- "{s}" gen "{s}" "{s}"
+        , .{ root, exePath, root, site });
+        try os.shell(init.io, command, .{});
     }
 
-    fn serveCmd(ctx: *Context) !void {
-        // try exe.shell(ctx,
-        //     \\python -m http.server 1414
-        // , null);
+    fn serveCmd(init: std.process.Init, argv: *std.process.Args.Iterator) !void {
+        const root = argv.next() orelse ".";
+        log.info("root = {s}", .{root});
 
-        var env_map = try std.process.getEnvMap(ctx.allocator());
+        var env_map = try init.minimal.environ.createMap(init.gpa);
         defer env_map.deinit();
-
         try env_map.put("CADDY_HTTP_PORT", "1414");
         try env_map.put("CADDY_HOST", "localhost");
 
-        try exe.shell(ctx,
-            \\caddy fmt --overwrite && caddy run --watch
-        , &env_map);
+        if ((try std.Io.Dir.cwd().openDir(init.io, root, .{})).access(init.io, "Caddyfile", .{})) {
+            try os.shell(init.io,
+                \\caddy fmt --overwrite && caddy run --watch
+            , .{ .environ_map = &env_map, .cwd = .{ .path = root } });
+        } else |_| {
+            try os.shell(init.io,
+                \\caddy file-server --listen=$CADDY_HOST:$CADDY_HTTP_PORT
+            , .{ .environ_map = &env_map, .cwd = .{ .path = root } });
+        }
     }
 };
 
-pub fn main() !void {
-    var gpa = std.heap.GeneralPurposeAllocator(.{}).init;
-    const allocator = gpa.allocator();
-    defer _ = gpa.deinit();
-
-    var ctx = try Context.create(allocator);
-    defer ctx.destroy();
+pub fn main(init: std.process.Init) !void {
+    var ctx = try Context.init(init.io, init.gpa);
+    defer ctx.deinit();
 
-    try Cli.run(ctx);
+    try Cli.run(init, &ctx);
 }
diff --git a/src/os.zig b/src/os.zig
new file mode 100644
index 0000000..ad9fe23
--- /dev/null
+++ b/src/os.zig
@@ -0,0 +1,84 @@
+const std = @import("std");
+const c = @cImport({
+    @cInclude("stdlib.h");
+    @cInclude("time.h");
+});
+
+const log = std.log.scoped(.os);
+
+pub fn captureStdout(
+    io: std.Io,
+    command: []const u8,
+    opts: struct {
+        cwd: std.process.Child.Cwd = .inherit,
+        environ_map: ?*const std.process.Environ.Map = null,
+        progress_node: std.Progress.Node = std.Progress.Node.none,
+    },
+    w: *std.Io.Writer,
+) !void {
+    log.debug("Running <<< {s}", .{command});
+
+    var p = try std.process.spawn(io, .{
+        .argv = &.{ "sh", "-c", command },
+        .cwd = opts.cwd,
+        .stdin = .ignore,
+        .stderr = .ignore,
+        .stdout = .pipe,
+        .environ_map = opts.environ_map,
+        .progress_node = opts.progress_node,
+    });
+
+    var buf: [1024]u8 = undefined;
+    var r = p.stdout.?.readerStreaming(io, &buf);
+
+    _ = try w.sendFileAll(&r, .unlimited);
+    try w.flush();
+
+    const result = try p.wait(io);
+    if (result.exited != 0) return error.NonZeroExitCode;
+}
+
+pub fn shell(
+    io: std.Io,
+    command: []const u8,
+    opts: struct {
+        cwd: std.process.Child.Cwd = .inherit,
+        environ_map: ?*const std.process.Environ.Map = null,
+        progress_node: std.Progress.Node = std.Progress.Node.none,
+    },
+) !void {
+    log.debug("Running Shell <<< {s}", .{command});
+
+    var p = try std.process.spawn(io, .{
+        .argv = &.{ "sh", "-c", command },
+        .cwd = opts.cwd,
+        .stdin = .ignore,
+        .stderr = .inherit,
+        .stdout = .inherit,
+        .environ_map = opts.environ_map,
+        .progress_node = opts.progress_node,
+    });
+
+    const result = try p.wait(io);
+    if (result.exited != 0) return error.NonZeroExitCode;
+}
+
+// use libc strftime to format time string
+pub fn formatTimestamp(alloc: std.mem.Allocator, ts: std.Io.Timestamp, fmt: [*c]const u8) ![]const u8 {
+    const mtime_str = try alloc.alloc(u8, std.os.linux.PATH_MAX);
+    var tm: c.struct_tm = undefined;
+
+    const sec = ts.toSeconds();
+    _ = c.localtime_r(&sec, &tm);
+    const rc = c.strftime(
+        mtime_str.ptr,
+        mtime_str.len,
+        fmt,
+        &tm,
+    );
+    switch (std.posix.errno(rc)) {
+        .SUCCESS => {},
+        else => |err| return std.posix.unexpectedErrno(err),
+    }
+    return try alloc.realloc(mtime_str, rc);
+}