about summary refs log tree commit diff
path: root/src/Context.zig
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/Context.zig242
1 files changed, 242 insertions, 0 deletions
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);
+}