diff options
| author | Robert Günzler <r@gnzler.io> | 2026-06-24 11:57:02 +0900 |
|---|---|---|
| committer | Robert Günzler <r@gnzler.io> | 2026-09-22 18:01:30 +0200 |
| commit | c677e8873e0eae790a1b8b07844a10efa9f63a1e (patch) | |
| tree | 95bc9709c374d27962a73712d24807a98cc69bb4 /src | |
| parent | fcdf352af595759aa34e6ed037ee10810867a616 (diff) | |
| download | site-c677e8873e0eae790a1b8b07844a10efa9f63a1e.tar.gz | |
wip design for v2
Signed-off-by: Robert Günzler <r@gnzler.io>
Diffstat (limited to 'src')
| -rw-r--r-- | src/Content.zig | 210 | ||||
| -rw-r--r-- | src/Context.zig | 242 | ||||
| -rw-r--r-- | src/FileSwapper.zig | 101 | ||||
| -rw-r--r-- | src/exe.zig | 216 | ||||
| -rw-r--r-- | src/fs.zig | 239 | ||||
| -rw-r--r-- | src/gen.zig | 404 | ||||
| -rw-r--r-- | src/main.zig | 332 | ||||
| -rw-r--r-- | src/os.zig | 84 |
8 files changed, 827 insertions, 1001 deletions
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(×pec.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); +} |