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); }