diff options
| author | Robert Günzler <r@gnzler.io> | 2026-09-18 04:00:18 +0200 |
|---|---|---|
| committer | Robert Günzler <r@gnzler.io> | 2026-09-22 22:37:51 +0200 |
| commit | 5bc242742e1225c7c3b87a31c2c2a3998ac18685 (patch) | |
| tree | baae923037b49ba31bb0a15ece2eec4bea13ab25 /src | |
| parent | c677e8873e0eae790a1b8b07844a10efa9f63a1e (diff) | |
| download | site-5bc242742e1225c7c3b87a31c2c2a3998ac18685.tar.gz | |
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/gen.zig | 255 | ||||
| -rw-r--r-- | src/inc/2025.htm | 12 | ||||
| -rw-r--r-- | src/inc/2025.md | 12 | ||||
| -rw-r--r-- | src/inc/2026.htm | 2 | ||||
| -rw-r--r-- | src/inc/2026.md | 2 | ||||
| -rw-r--r-- | src/inc/japan.htm | 16 | ||||
| -rw-r--r-- | src/inc/meta.baseof.htm | 36 | ||||
| -rw-r--r-- | src/inc/meta.nav.htm | 12 | ||||
| -rw-r--r-- | src/inc/nikko.htm | 2 | ||||
| -rw-r--r-- | src/inc/nikko.md | 2 | ||||
| -rw-r--r-- | src/inc/notes.htm | 12 | ||||
| -rw-r--r-- | src/inc/notes.md | 12 | ||||
| -rw-r--r-- | src/main.zig | 782 | ||||
| -rw-r--r-- | src/os.zig | 84 |
17 files changed, 767 insertions, 1027 deletions
diff --git a/src/Content.zig b/src/Content.zig deleted file mode 100644 index 120d8b5..0000000 --- a/src/Content.zig +++ /dev/null @@ -1,210 +0,0 @@ -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 deleted file mode 100644 index f5a638e..0000000 --- a/src/Context.zig +++ /dev/null @@ -1,242 +0,0 @@ -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 deleted file mode 100644 index 13bafe4..0000000 --- a/src/FileSwapper.zig +++ /dev/null @@ -1,101 +0,0 @@ -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/gen.zig b/src/gen.zig deleted file mode 100644 index 8def5cf..0000000 --- a/src/gen.zig +++ /dev/null @@ -1,255 +0,0 @@ -const std = @import("std"); - -const os = @import("os.zig"); - -const log = std.log.scoped(.gen); - -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 -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( - \\<!-- Generated file, do not edit, you may edit and discard {[SOURCE]s} :) --> - \\<!DOCTYPE html> - \\<html lang="en"> - \\<head> - \\<meta charset="utf-8"> - \\<meta name="thumbnail" content="/media/services/thumbnail.jpg"> - \\<meta name="viewport" content="width=device-width,initial-scale=1"> - \\<link rel="preload" href="/media/interface/logo.svg" as="image"> - \\<link rel="alternate" type="application/rss+xml" title="RSS Feed" href="/links/rss.xml"> - \\<link rel="stylesheet" type="text/css" href="/links/main.css"> - \\<link rel="shortcut icon" type="image/png" href="/media/services/icon.png"> - \\<link rel="license" href="{[LICENSE_URL]s}"> - \\<title>{[SITE_NAME]s} — {[PAGE_NAME]s}</title> - \\</head> - \\<body> - \\<header> - \\<a href="/"><img src="/media/interface/logo.svg" alt="{[SITE_NAME]s}"></a> - \\ - , .{ - .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> - ); - template_portal(io, gpa, w, "meta.nav", opts) catch |err| { - if (err != error.FileNotFound) return err; - }; - try w.writeAll( - \\</nav> - \\</header> - \\ - ); - - // MAIN - try w.writeAll( - \\<main> - \\ - ); - try inject(io, gpa, w, opts); - try w.writeAll( - \\</main> - \\ - ); - - // FOOTER - try w.print( - \\<footer> - \\Edited on {[MTIME]s} <a href="{[SOURCE_URL]s}/{[PATH]s}" target="_blank">[src]</a> - \\<a href="mailto:{[PUBLIC_INBOX]s}">[comments]</a> - \\<br> - \\<b>{[LICENSE_HOLDER]s}</b> - \\© {[LICENSE_YEAR]s} - \\— - \\<a href="{[LICENSE_URL]s}" target="_blank">{[LICENSE_NAME]s}</a> - \\</footer> - \\ - , .{ - .MTIME = mtime_str, - .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( - \\<a href="#top">[⏶]</a> - \\ - ); - - try w.writeAll( - \\</body> - \\</html> - ); - - try w.flush(); -} - -// parses srcpath, resolves refs and portals and writes everything to w -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 = opts.root_dir.openFile(io, opts.page_path, .{ .mode = .read_only }) catch |err| { - if (err == error.FileNotFound) - log.warn("Missing {s} include.", .{opts.page_path}); - - return err; - }; - defer inc.close(io); - - 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(); - - while (true) { - defer chunkw.clearRetainingCapacity(); // reset chunk - - _ = r.interface.streamDelimiter(&chunkw.writer, '\n') catch |err| - if (err == error.EndOfStream) break else return err; - - _ = r.interface.toss(1); // skip newline delimiter - - var chunk = chunkw.written(); - templating: while (true) { - 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(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 - chunk = chunk[start..]; - break :templating; - }, - else => @panic("Failed to resolve template"), - }; - chunk = chunk[end + DELIM_END.len ..]; - } else break :templating; - } else break :templating; - } - - try w.writeAll(chunk); - try w.writeByte('\n'); - try w.flush(); - } - - try w.flush(); -} - -// implements various reference types that can be used in .htm templates -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), - } -} - -// 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}); - - const ref_path = try std.fmt.allocPrint(gpa, "{s}.html", .{ref_name}); - defer gpa.free(ref_path); - - _ = io; - _ = opts; - - try w.print( - \\<a href="{s}">{s}</a> - , .{ ref_path, ref_name }); -} - -// 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}); - - 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, .{}); - - var iopts = opts; - iopts.page_path = ref_path; - iopts.page_name = ref_name; - try inject(io, gpa, w, iopts); -} - -// 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; - - 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); - - 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(); - } - - return error.UnsupportedMeta; -} diff --git a/src/inc/2025.htm b/src/inc/2025.htm index e1f3933..f867484 100644 --- a/src/inc/2025.htm +++ b/src/inc/2025.htm @@ -86,7 +86,7 @@ target="_blank">all-systems-go</a></li> <li>saw parcels live</li> <li>visited amsterdam</li> <li>took the sleeper train</li> -<li>got really into the passage for two weeks ↪ {read#sep25}</li> +<li>got really into the passage for two weeks ↪ {[read#sep25]}</li> <li>had delicious coffee in linden</li> <li>played chunithm @ hi-score (again)</li> <li>went to hannover (again)</li> @@ -143,7 +143,7 @@ class="i size-m" alt="coffee" /></p> <p><img src="/media/content/2025/apr-train.jpg" class="i" alt="train" /></p> <ul> -<li>started {like}</li> +<li>started {[like]}</li> <li>visited the northern part of niedersachsen</li> <li>held our first “plancake”</li> <li>learned about unikernels 🐒</li> @@ -166,7 +166,7 @@ class="i size-m" alt="basilica-cistern-medusa" /></p> <li>went to <a href="https://en.wikipedia.org/wiki/Basilica_Cistern" target="_blank">basilica cistern</a> in istanbul</li> <li>had kokoreç again</li> -<li>published {unicode-range}</li> +<li>published {[unicode-range]}</li> <li>made custom anki cards for turkish</li> <li>went to marji’s place in kadiköy</li> <li>had lebanese-turkish breakfast</li> @@ -193,15 +193,15 @@ target="_blank">tuzla</a></li> <p><img src="/media/content/2025/jan-sky.jpg" class="i" alt="january sky" /></p> <ul> -<li>published {plan9-cursors}</li> +<li>published {[plan9-cursors]}</li> <li>ate at kaijin ramen and got inspired to make mapo tofu</li> <li><a href="https://github.com/containers/podman/pull/25029" target="_blank">implemented</a> gpu support for podman-kube</li> <li>reorganised anime folder on shimakaze</li> <li>watched one pace “whole cake island” arc</li> -<li>published {sony-wf1000xm3-battery}</li> +<li>published {[sony-wf1000xm3-battery]}</li> <li>played siedler von catan</li> -<li>published {pyonji}</li> +<li>published {[pyonji]}</li> <li>replaced my hugo page with this one</li> <li>watched season 1 of mr robot</li> <li>started a daily workout and anki (for japanese) routine</li> diff --git a/src/inc/2025.md b/src/inc/2025.md index 8d67310..978c4be 100644 --- a/src/inc/2025.md +++ b/src/inc/2025.md @@ -43,7 +43,7 @@ toc: true - saw parcels live - visited amsterdam - took the sleeper train -- got really into the passage for two weeks ↪ {read#sep25} +- got really into the passage for two weeks ↪ {[read#sep25]} - had delicious coffee in linden - played chunithm @ hi-score (again) - went to hannover (again) @@ -100,7 +100,7 @@ toc: true {alt=train class=i} -- started {like} +- started {[like]} - visited the northern part of niedersachsen - held our first "plancake" - learned about unikernels 🐒 @@ -120,7 +120,7 @@ toc: true - finished my japanese deck on anki - went to [basilica cistern](https://en.wikipedia.org/wiki/Basilica_Cistern) in istanbul - had kokoreç again -- published {unicode-range} +- published {[unicode-range]} - made custom anki cards for turkish - went to marji's place in kadiköy - had lebanese-turkish breakfast @@ -145,14 +145,14 @@ toc: true {alt="january sky" class=i} -- published {plan9-cursors} +- published {[plan9-cursors]} - ate at kaijin ramen and got inspired to make mapo tofu - [implemented](https://github.com/containers/podman/pull/25029) gpu support for podman-kube - reorganised anime folder on shimakaze - watched one pace "whole cake island" arc -- published {sony-wf1000xm3-battery} +- published {[sony-wf1000xm3-battery]} - played siedler von catan -- published {pyonji} +- published {[pyonji]} - replaced my hugo page with this one - watched season 1 of mr robot - started a daily workout and anki (for japanese) routine diff --git a/src/inc/2026.htm b/src/inc/2026.htm index 795ad41..a331b8a 100644 --- a/src/inc/2026.htm +++ b/src/inc/2026.htm @@ -48,4 +48,4 @@ target="_blank">raus.life</a></li> </ul> <hr /> -<p>↪ {2025}</p> +<p>↪ {[2025]}</p> diff --git a/src/inc/2026.md b/src/inc/2026.md index e26d0e7..35c0985 100644 --- a/src/inc/2026.md +++ b/src/inc/2026.md @@ -35,4 +35,4 @@ toc: true --- -↪ {2025} +↪ {[2025]} diff --git a/src/inc/japan.htm b/src/inc/japan.htm index a2ee56a..305d8b4 100644 --- a/src/inc/japan.htm +++ b/src/inc/japan.htm @@ -12,12 +12,12 @@ The linked pages document those trips: </p> <ul class="capital"> - <li>{kamakura}</li> - <li>{nikko}</li> - <li>{yamanashi}</li> - <li>{niigata}</li> - <li>{ibaraki}</li> - <li>{toyama}</li> - <li>{shikoku}</li> - <li>{hokkaido}</li> + <li>{[kamakura]}</li> + <li>{[nikko]}</li> + <li>{[yamanashi]}</li> + <li>{[niigata]}</li> + <li>{[ibaraki]}</li> + <li>{[toyama]}</li> + <li>{[shikoku]}</li> + <li>{[hokkaido]}</li> </ul> diff --git a/src/inc/meta.baseof.htm b/src/inc/meta.baseof.htm new file mode 100644 index 0000000..462d279 --- /dev/null +++ b/src/inc/meta.baseof.htm @@ -0,0 +1,36 @@ +<!-- Generated file, do not edit, you may edit and discard {[builtin.path]} :) --> +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <meta name="thumbnail" content="/media/services/thumbnail.jpg"> + <meta name="viewport" content="width=device-width,initial-scale=1"> + <link rel="preload" href="/media/interface/logo.svg" as="image"> + <link rel="alternate" type="application/rss+xml" title="RSS Feed" href="/links/rss.xml"> + <link rel="stylesheet" type="text/css" href="/links/main.css"> + <link rel="shortcut icon" type="image/png" href="/media/services/icon.png"> + <link rel="license" href="https://git.sr.ht/~robertgzr/site/blob/master/LICENSE.content"> + <title>gzr.im — {[builtin.title]}</title> + </head> + <body> + <header> + <a href="/"><img src="/media/interface/logo.svg" alt="gzr.im"></a> + <nav> + {[/meta.nav]} + </nav> + </header> + <main> + {[builtin.fragment]} + </main> + <footer> + Edited on 2026-04-08 <a href="https://git.sr.ht/~robertgzr/site/tree/master/src/inc/{[builtin.path]}" target="_blank">[src]</a> + <a href="mailto:~robertgzr/public-inbox@lists.sr.ht">[comments]</a> + <br> + <b>robertgzr</b> +© 2025 + — + <a href="https://git.sr.ht/~robertgzr/site/blob/master/LICENSE.content" target="_blank">BY-NC-SA 4.0</a> + </footer> + <a href="#top">[⏶]</a> + </body> +</html> diff --git a/src/inc/meta.nav.htm b/src/inc/meta.nav.htm index 8e7d70b..afb87c6 100644 --- a/src/inc/meta.nav.htm +++ b/src/inc/meta.nav.htm @@ -1,19 +1,19 @@ <section> <ul class="nobull capital"> - <li>{about}</li> - <li>{2026}</li> + <li>{[about]}</li> + <li>{[2026]}</li> </ul> </section> <section> <ul class="nobull capital"> - <li>{notes}</li> - <li>{like}</li> - <li>{read}</li> + <li>{[notes]}</li> + <li>{[like]}</li> + <li>{[read]}</li> </ul> </section> <section> <ul class="nobull capital"> - <li>{photos}</li> + <li>{[photos]}</li> <li><a href="/map/">map</a></li> </ul> </section> diff --git a/src/inc/nikko.htm b/src/inc/nikko.htm index e9ba14f..aa93bb5 100644 --- a/src/inc/nikko.htm +++ b/src/inc/nikko.htm @@ -194,7 +194,7 @@ href="#fnref3" class="footnote-back" role="doc-backlink">↩︎</a></p></li> class="footnote-back" role="doc-backlink">↩︎</a></p></li> <li id="fn5"><p>prior to leaving for Japan, I started experimenting with binaural audio recording as a way to capture memories. I have a page -with some notes around the setup and some recordings here: {audio}<a +with some notes around the setup and some recordings here: {[audio]}<a href="#fnref5" class="footnote-back" role="doc-backlink">↩︎</a></p></li> </ol> </section> diff --git a/src/inc/nikko.md b/src/inc/nikko.md index 69c6f38..70a1325 100644 --- a/src/inc/nikko.md +++ b/src/inc/nikko.md @@ -185,7 +185,7 @@ a good sense of how serious I need to take them. [^rec]: prior to leaving for Japan, I started experimenting with binaural audio recording as a way to capture memories. I have a page with some notes - around the setup and some recordings here: {audio} + around the setup and some recordings here: {[audio]} <aside class="notice"> I learn over time that the "bear problem" is taken maybe a little too serious. diff --git a/src/inc/notes.htm b/src/inc/notes.htm index fbf96f3..a86f6e4 100644 --- a/src/inc/notes.htm +++ b/src/inc/notes.htm @@ -1,9 +1,9 @@ <!-- Generated file, you may edit and discard notes.md :) --> <ul> -<li><code>2025-11-12</code> — {varlink}</li> -<li><code>2025-05-14</code> — {porta}</li> -<li><code>2025-03-07</code> — {unicode-range}</li> -<li><code>2025-01-24</code> — {plan9-cursors}</li> -<li><code>2025-01-10</code> — {sony-wf1000xm3-battery}</li> -<li><code>2025-01-08</code> — {pyonji}</li> +<li><code>2025-11-12</code> — {[varlink]}</li> +<li><code>2025-05-14</code> — {[porta]}</li> +<li><code>2025-03-07</code> — {[unicode-range]}</li> +<li><code>2025-01-24</code> — {[plan9-cursors]}</li> +<li><code>2025-01-10</code> — {[sony-wf1000xm3-battery]}</li> +<li><code>2025-01-08</code> — {[pyonji]}</li> </ul> diff --git a/src/inc/notes.md b/src/inc/notes.md index 0d6d298..0ca5e7f 100644 --- a/src/inc/notes.md +++ b/src/inc/notes.md @@ -1,6 +1,6 @@ -* `2025-11-12` — {varlink} -* `2025-05-14` — {porta} -* `2025-03-07` — {unicode-range} -* `2025-01-24` — {plan9-cursors} -* `2025-01-10` — {sony-wf1000xm3-battery} -* `2025-01-08` — {pyonji} +* `2025-11-12` — {[varlink]} +* `2025-05-14` — {[porta]} +* `2025-03-07` — {[unicode-range]} +* `2025-01-24` — {[plan9-cursors]} +* `2025-01-10` — {[sony-wf1000xm3-battery]} +* `2025-01-08` — {[pyonji]} diff --git a/src/main.zig b/src/main.zig index 354eed9..2134d45 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,127 +1,723 @@ -// TODO: just let compiler infer error set through recursive calls -// depends on: https://github.com/ziglang/zig/issues/2971 - -// This code is heavily insprired by Devine's implementation in C: -// https://github.com/hundredrabbits/100r.co/blob/main/src/main.c and his -// personal site https://github.com/XXIIVV/Oscean - -const Context = @import("Context.zig"); -const os = @import("os.zig"); - const std = @import("std"); +const Io = std.Io; +const Allocator = std.mem.Allocator; +const linux = std.os.linux; + +const Digester = std.crypto.hash.Md5; +pub const Digest = [16]u8; const log = std.log.scoped(.rbc); -// tune std.log options +const BUFFER_SIZE = 1024; + +// tune log options pub const std_options = std.Options{ .log_level = if (@import("builtin").mode == .Debug) .debug else .info, }; +pub fn main(init: std.process.Init) !void { + try Cli.run(init); +} + const Cli = struct { - const Defaults = struct { - const ROOT: []const u8 = "src/inc"; - const SITE: []const u8 = "site"; + fn usage() void { + std.debug.print("usage: rbc SOURCE TARGET-DIR\n\n", .{}); + std.debug.print("Writes SOURCE into TARGET-DIR, resolving references to other files in ROOT (= parent dir of SOURCE).\n", .{}); + std.debug.print("\n", .{}); + } + + fn run(init: std.process.Init) !void { + const io = init.io; + const gpa = init.gpa; + + const args = try init.minimal.args.toSlice(gpa); + defer gpa.free(args); + + // parse options + for (args) |arg| { + if (std.mem.eql(u8, arg, "help")) return Cli.usage(); + if (std.mem.eql(u8, arg, "--help")) return Cli.usage(); + if (std.mem.eql(u8, arg, "-h")) return Cli.usage(); + } + + if (args.len < 3) return Cli.usage(); + + // TODO: flag handling + + const source_path = args[1]; + const target_path = args[2]; + + log.info("target={s} source={s} ", .{ target_path, source_path }); + + // TODO: allow setting custom root path via flags + const root_path = std.fs.path.dirname(source_path) orelse return error.NoDirname; + const root_dir = try Io.Dir.cwd().openDir(io, root_path, .{}); + defer root_dir.close(io); + + var source = try File.init(io, root_dir, std.fs.path.basename(source_path), null); + defer source.close(io); + + // open/create destination dir + const target_dir = try Io.Dir.cwd().createDirPathOpen(io, target_path, .{}); + defer target_dir.close(io); + + const b = try Builder.init(gpa, root_dir, source, target_dir); + defer b.deinit(gpa); + try b.build(io, gpa); + } +}; + +const File = struct { + path: []const u8, + fd: Io.File, + mmap: Io.File.MemoryMap, + mtime: Io.Timestamp, + + fn init(io: Io, dir: Io.Dir, path: []const u8, options: ?Io.Dir.OpenFileOptions) !File { + const fd = try dir.openFile(io, path, options orelse .{ + .lock = .exclusive, + .mode = .read_write, + }); + var mmap = try fd.createMemoryMap(io, .{ + .len = try fd.length(io), + // .protection = .{ .read = true, .write = false }, + }); + try mmap.read(io); + const stat = try fd.stat(io); + return .{ + .path = path, + .fd = fd, + .mmap = mmap, + .mtime = stat.mtime, + }; + } + + fn digest(self: File) !Digest { + var buffer: [Digester.digest_length]u8 = undefined; + var d = Digester.init(.{}); + d.update(self.mmap.memory); + var buf2: [BUFFER_SIZE]u8 = undefined; + d.update(try std.fmt.bufPrint(&buf2, "{d}", .{self.mtime})); + d.final(&buffer); + return buffer; + } + + fn close(self: *File, io: Io) void { + self.mmap.destroy(io); + self.fd.close(io); + } +}; + +const Builder = struct { + root_dir: Io.Dir, + source: File, + target_dir: Io.Dir, + options: Options, + + // track files which affect build output (paths relative to root_dir) + parts: std.ArrayList([]const u8), + // track component digests which should affect digest check + dgsts: std.ArrayList(Digest), + + pub const Options = struct { + // TODO: this is a bit of a hack no? + offset_from_siteroot: ?[]const u8, }; - fn run(init: std.process.Init, ctx: *Context) !void { - var argv = init.minimal.args.iterate(); + const DELIM_START: []const u8 = "{["; + const DELIM_END: []const u8 = "]}"; + + fn init(gpa: Allocator, root_dir: Io.Dir, source: File, target_dir: Io.Dir) !*Builder { + const b = try gpa.create(Builder); + b.* = .{ + .root_dir = root_dir, + .source = source, + .target_dir = target_dir, + .options = .{ + .offset_from_siteroot = "/site", + }, + .parts = .empty, + .dgsts = .empty, + }; + return b; + } + + fn deinit(self: *Builder, gpa: Allocator) void { + for (self.parts.items) |p| gpa.free(p); + self.parts.deinit(gpa); + self.dgsts.deinit(gpa); + gpa.destroy(self); + } + + fn build(self: *Builder, io: Io, gpa: Allocator) !void { + log.debug("{s}: building", .{self.source.path}); + + // track source + try self.parts.append(gpa, try gpa.dupe(u8, self.source.path)); + try self.dgsts.append(gpa, try self.source.digest()); + + const source_stem = std.fs.path.stem(self.source.path); - _ = argv.skip(); // skip command name + // handle meta pages before even creating target_path + if (std.mem.startsWith(u8, source_stem, "meta.tilde")) { + return try self.metaTilde(io, gpa); + } + + // target file + const target_path = try std.fmt.allocPrint(gpa, "{s}.html", .{source_stem}); + defer gpa.free(target_path); + + // atomic? + const target_fd = try self.target_dir.createFile(io, target_path, .{ + .lock = .exclusive, + }); + defer target_fd.close(io); - if (argv.inner.remaining.len == 0) { - return Cli.helpCmd(); + // check if we need to do any work + if (try targetExists(io, gpa, target_fd, self.root_dir)) { + defer log.debug("{s}: done", .{self.source.path}); + return; // skip any further processing } - while (argv.next()) |arg| { - if (std.mem.eql(u8, arg, "help")) { - return Cli.helpCmd(); - } else if (std.mem.eql(u8, arg, "gen")) { - 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(init, &argv); - } else if (std.mem.eql(u8, arg, "serve")) { - try Cli.serveCmd(init, &argv); - } else { - return Cli.helpCmd(); + log.debug("{s}: writing", .{self.source.path}); + + var writer_buf: [BUFFER_SIZE]u8 = undefined; + var writer = target_fd.writer(io, &writer_buf); + + // determine if this is a fragment and exit if not + if (try isFullPage(self.source)) { + // only copy source to target + try writer.interface.writeAll(self.source.mmap.memory); + } else { + // load baseof fragment + var baseof = try File.init(io, self.root_dir, "meta.baseof.htm", null); + defer baseof.close(io); + + try self.inject(io, gpa, baseof, &writer.interface); + } + + try self.finalize(gpa, target_fd); + defer log.debug("{s}: done", .{self.source.path}); + } + + // finalize computes the digest and attaches it to the target file + fn finalize(self: *Builder, gpa: Allocator, target_fd: Io.File) !void { + log.debug("{s}: finalizing", .{self.source.path}); + + const composite_dgst = compositeDigest(self.dgsts.items); + try setXattrSourceDigest(target_fd, composite_dgst); + try setXattrSourceComponents(gpa, target_fd, self.parts.items); + } + + // inject interprets source and writes to w + fn inject(self: *Builder, io: Io, gpa: Allocator, source: File, w: *Io.Writer) !void { + var iter = std.mem.splitScalar(u8, source.mmap.memory, '\n'); + while (iter.next()) |line| { + var offset: usize = 0; + parseLn: while (true) { + if (std.mem.findPos(u8, line, offset, DELIM_START)) |start| { + if (std.mem.findPos(u8, line, offset + start + 1, DELIM_END)) |end| { + // write out beginning of chunk + try w.writeAll(line[offset..start]); + + // do templating + const ref_name = line[(offset + start + DELIM_START.len)..end]; + try self.template(io, gpa, ref_name, w); + + // advance offset + offset += end + DELIM_END.len; + } else break :parseLn; + } else break :parseLn; } + + // write out remaining chunk + try w.writeAll(line[offset..]); + try w.writeByte('\n'); } + try w.flush(); + + // track injected file + try self.parts.append(gpa, try gpa.dupe(u8, source.path)); + try self.dgsts.append(gpa, try source.digest()); } - 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 template(self: *Builder, io: Io, gpa: Allocator, ref_name: []const u8, w: *Io.Writer) anyerror!void { + log.debug("templating ref={s}", .{ref_name}); + + // special case for when we're injecting the source into the baseof + if (std.mem.eql(u8, ref_name, "builtin.fragment")) { + try w.writeByte('\n'); + try self.inject(io, gpa, self.source, w); + try w.writeByte('\n'); + return; + } + // builtin variables + if (std.mem.eql(u8, ref_name, "builtin.title")) { + const title = try formatName(gpa, std.fs.path.stem(self.source.path)); + defer gpa.free(title); + try w.writeAll(title); + return; + } + if (std.mem.eql(u8, ref_name, "builtin.path")) { + try w.writeAll(self.source.path); + return; + } + // TODO: other builtins? + + switch (ref_name[0]) { + '/' => try self.templatePortal(io, gpa, ref_name[1..], w), + '$' => return try self.templateLink(gpa, ref_name[1..], w), + '+' => return try self.templateName(gpa, ref_name[1..], w), + else => try self.templateRef(io, gpa, ref_name, w), + } } - 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; + fn templatePortal(self: *Builder, io: Io, gpa: Allocator, ref_name: []const u8, w: *Io.Writer) !void { + log.debug("portaling ref={s}", .{ref_name}); + + const ref_path = try std.fmt.allocPrint(gpa, "{s}.htm", .{ref_name}); + defer gpa.free(ref_path); + + var ref = try File.init(io, self.root_dir, ref_path, null); + defer ref.close(io); + + try w.writeByte('\n'); + try self.inject(io, gpa, ref, w); + try w.writeByte('\n'); + } + + fn templateRef(self: *Builder, io: Io, gpa: Allocator, ref_str: []const u8, w: *Io.Writer) !void { + const fragment = std.mem.indexOf(u8, ref_str, "#"); + const ref = if (fragment) |i| ref_str[0..i] else ref_str; + + log.debug("referencing ref={s}", .{ref}); + + const ref_path = try std.fmt.allocPrint(gpa, "{s}.html", .{ref}); + defer gpa.free(ref_path); + + // check ref'ed page can exist + { + const ref_htm = try std.fmt.allocPrint(gpa, "{s}.htm", .{ref}); + defer gpa.free(ref_htm); + + const possible_paths = [_][]const u8{ ref_path, ref_htm }; + var found = false; + for (possible_paths) |p| + found = found or (blk: { + self.root_dir.access(io, p, .{}) catch break :blk false; + break :blk true; + }); + if (!found) { + log.err("{s} not found", .{ref}); + return error.FileNotFound; } - }; - 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); + } + + const ref_name = try formatName(gpa, ref); + defer gpa.free(ref_name); + const ref_link = try formatLink(gpa, ref, self.options.offset_from_siteroot); + defer gpa.free(ref_link); + + try w.print( + \\<a href="{s}{s}">{s}</a> + , .{ + ref_link, + if (fragment) |i| ref_str[i..] else "", + ref_name, + }); } - 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}); + fn templateLink(self: *Builder, gpa: Allocator, ref_name: []const u8, w: *std.Io.Writer) !void { + const link = try formatLink(gpa, ref_name, self.options.offset_from_siteroot); + defer gpa.free(link); + try w.writeAll(link); + } - 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 templateName(_: *Builder, gpa: Allocator, ref_name: []const u8, w: *std.Io.Writer) !void { + const name = try formatName(gpa, ref_name); + defer gpa.free(name); + try w.writeAll(name); } - 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; + // meta_tilde implements + fn metaTilde(self: *Builder, io: Io, gpa: Allocator) !void { + const source_stem = std.fs.path.stem(self.source.path)[11..]; // trim "meta.tilde." + log.debug("meta.tilde ~{s}", .{source_stem}); + + const content = std.mem.trim(u8, self.source.mmap.memory, "\n\t\r"); + const target_name = try std.fmt.allocPrint(gpa, "~{s}", .{source_stem}); + defer gpa.free(target_name); - 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 target_fd = try self.target_dir.createFile(io, target_name, .{ .exclusive = true }); + if (try targetExists(io, gpa, target_fd, self.root_dir)) return; + + var buffer: [BUFFER_SIZE]u8 = undefined; + var writer = target_fd.writer(io, &buffer); + + try writer.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 = content, + }); + try writer.interface.flush(); + + try self.finalize(gpa, target_fd); + log.debug("{s}: done", .{self.source.path}); + } +}; + +// helpers + +fn formatName(gpa: Allocator, name: []const u8) ![]u8 { + return try std.mem.replaceOwned(u8, gpa, name, "_", " "); +} + +fn formatLink(gpa: Allocator, name: []const u8, offset: ?[]const u8) ![]u8 { + var link = try std.fmt.allocPrint(gpa, "{s}.html", .{name}); + if (offset) |_| { + const link2 = try std.fs.path.join(gpa, &.{ offset.?, link }); + gpa.free(link); + link = link2; + } + return link; +} + +fn targetExists(io: Io, gpa: Allocator, target_fd: Io.File, root_dir: Io.Dir) !bool { + const target_parts = getXattrSourceComponents(gpa, target_fd) catch |err| + switch (err) { + error.XattrNotFound => return false, + else => return err, + }; + defer gpa.free(target_parts); + defer for (target_parts) |p| gpa.free(p); + + const target_dgst = getXattrSourceDigest(gpa, target_fd) catch |err| + switch (err) { + error.XattrNotFound => return false, + else => return err, }; - 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(init: std.process.Init, argv: *std.process.Args.Iterator) !void { - const root = argv.next() orelse "."; - log.info("root = {s}", .{root}); - - 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"); - - 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 } }); + + // create digests for parts + var actual_dgsts: std.ArrayList(Digest) = .empty; + defer actual_dgsts.deinit(gpa); + for (target_parts) |p| { + var f = try File.init(io, root_dir, p, .{ .mode = .read_write }); + defer f.close(io); + try actual_dgsts.append(gpa, try f.digest()); + } + const actual_dgst = compositeDigest(actual_dgsts.items); + return std.mem.eql(u8, &actual_dgst, &target_dgst); +} + +fn isFullPage(source: File) !bool { + // exit early if extension is .html vs .htm + const ext = std.fs.path.extension(source.path); + if (std.mem.eql(u8, ext, ".html")) return true; + + // peek if file starts with <html> + const buf = source.mmap.memory[0..6]; + return std.mem.eql(u8, buf, "<html>"); +} + +fn compositeDigest(parts: []Digest) Digest { + std.sort.block(Digest, parts, {}, comptime struct { + pub fn inner(_: void, a: Digest, b: Digest) bool { + return std.mem.lessThan(u8, &a, &b); } + }.inner); + + var buffer: [Digester.digest_length]u8 = undefined; + var d = Digester.init(.{}); + for (parts) |dgst| d.update(&dgst); + d.final(&buffer); + return buffer; +} + +// xattr code +// TODO: fallback to cache dir when xattr isnt supported + +fn setXattr(fd: Io.File, key: [:0]const u8, val: []const u8) !void { + const rc = linux.fsetxattr(fd.handle, key.ptr, val.ptr, val.len, 0); + switch (linux.errno(rc)) { + .SUCCESS => {}, + .OPNOTSUPP => return error.XattrNotSupported, + .NODATA => return error.XattrNotFound, + else => return error.XattrError, } -}; +} -pub fn main(init: std.process.Init) !void { - var ctx = try Context.init(init.io, init.gpa); - defer ctx.deinit(); +fn getXattr(gpa: Allocator, fd: Io.File, key: [:0]const u8) ![]u8 { + var buffer: [BUFFER_SIZE]u8 = undefined; + const rc = linux.fgetxattr(fd.handle, key.ptr, &buffer, buffer.len); + switch (linux.errno(rc)) { + .SUCCESS => {}, + .OPNOTSUPP => return error.XattrNotSupported, + .NODATA => return error.XattrNotFound, + else => { + log.err("errno: {d}", .{linux.errno(rc)}); + return error.XattrError; + }, + } + return try gpa.dupe(u8, buffer[0..@intCast(rc)]); +} + +const XATTR_SOURCE_DIGEST = "user.rbc-source-digest"; +const XATTR_SOURCE_COMPONENTS = "user.rbc-source-components"; + +fn setXattrSourceDigest(fd: Io.File, dgst: Digest) !void { + return try setXattr(fd, XATTR_SOURCE_DIGEST, &dgst); +} + +fn getXattrSourceDigest(gpa: Allocator, fd: Io.File) !Digest { + const val = try getXattr(gpa, fd, XATTR_SOURCE_DIGEST); + defer gpa.free(val); + return val[0..16].*; +} + +fn setXattrSourceComponents(gpa: Allocator, fd: Io.File, parts: []const []const u8) !void { + const val = try std.mem.join(gpa, ";", parts); + defer gpa.free(val); + return try setXattr(fd, XATTR_SOURCE_COMPONENTS, val); +} + +fn getXattrSourceComponents(gpa: Allocator, fd: Io.File) ![][]const u8 { + const val = try getXattr(gpa, fd, XATTR_SOURCE_COMPONENTS); + defer gpa.free(val); + + var parts: std.ArrayList([]const u8) = .empty; + errdefer parts.deinit(gpa); + + var iter = std.mem.splitScalar(u8, val, ';'); + while (iter.next()) |p| try parts.append(gpa, try gpa.dupe(u8, p)); + return try parts.toOwnedSlice(gpa); +} + +// tests + +pub const Testing = if (@import("builtin").is_test) + struct { + 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; + } + + pub fn parse_hash(comptime str: []const u8) !Digest { + var buffer: [BUFFER_SIZE]u8 = undefined; + var out = try std.fmt.hexToBytes(&buffer, str); + return out[0..Digester.digest_length].*; + } + }; - try Cli.run(init, &ctx); +test "xattr" { + std.testing.log_level = .debug; + + const io = std.testing.io; + const gpa = std.testing.allocator; + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const fd = try tmp.dir.createFile(io, &tmp.sub_path, .{}); + + // test generic funcs + { + try setXattr(fd, "user.foo", "bar"); + const val = try getXattr(gpa, fd, "user.foo"); + defer gpa.free(val); + try std.testing.expectEqualStrings("bar", val); + } + + // test digest func + { + const dgst = try Testing.parse_hash("accd9547a71463f530ae12a8dd0d7696"); + try setXattrSourceDigest(fd, dgst); + const val = try getXattrSourceDigest(gpa, fd); + try std.testing.expectEqualStrings(&dgst, &val); + } + + // test paths func + { + const parts = &[_][]const u8{ "foo.htm", "bar.htm" }; + try setXattrSourceComponents(gpa, fd, parts); + const val = try getXattrSourceComponents(gpa, fd); + defer gpa.free(val); + defer for (val) |v| gpa.free(v); + try std.testing.expectEqual(parts.len, val.len); + for (parts, 0..) |p, i| try std.testing.expectEqualStrings(p, val[i]); + } +} + +test "targetExists" { + std.testing.log_level = .debug; + + const io = std.testing.io; + const gpa = std.testing.allocator; + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + var dgsts: std.ArrayList(Digest) = .empty; + defer dgsts.deinit(gpa); + + const source_fd = try tmp.dir.createFile(io, "source.htm", .{}); + try source_fd.writePositionalAll(io, "hello world", 0); + const source = try File.init(io, tmp.dir, "source.htm", null); + try dgsts.append(gpa, try source.digest()); + + const ref_fd = try tmp.dir.createFile(io, "ref.htm", .{}); + try ref_fd.writePositionalAll(io, "foobar", 0); + const ref = try File.init(io, tmp.dir, "ref.htm", null); + try dgsts.append(gpa, try ref.digest()); + + const dgst = compositeDigest(dgsts.items); + + const target_fd = try tmp.dir.createFile(io, "target.htm", .{}); + try setXattrSourceDigest(target_fd, dgst); + try setXattrSourceComponents(gpa, target_fd, &.{ + source.path, + ref.path, + }); + + const exists = try targetExists(io, gpa, target_fd, tmp.dir); + try std.testing.expectEqual(true, exists); +} + +test "e2e" { + std.testing.log_level = .debug; + + const io = std.testing.io; + const gpa = std.testing.allocator; + + const Tc = struct { + const Self = @This(); + + source: File, + root_dir: Io.Dir, + + expect_path: []const u8, + expect_dgst: Digest, + + fn init(path: []const u8, expect_path: []const u8, expect_dgst: Digest) !Self { + const root_dir = try std.Io.Dir.cwd().openDir(io, std.fs.path.dirname(path).?, .{}); + return .{ + .root_dir = root_dir, + .source = try File.init(io, root_dir, std.fs.path.basename(path), null), + .expect_path = expect_path, + .expect_dgst = expect_dgst, + }; + } + + fn run(tc: *Self, dest: Io.Dir) !void { + std.debug.print("=== {s} =============================\n", .{tc.source.path}); + + defer tc.root_dir.close(io); + defer tc.source.close(io); + + const b = try Builder.init(gpa, tc.root_dir, tc.source, dest); + defer b.deinit(gpa); + try b.build( + io, + gpa, + ); + + const fd = try dest.openFile(io, tc.expect_path, .{ .mode = .read_write, .lock = .exclusive }); + defer fd.close(io); + const xattr_dgst = try getXattrSourceDigest(gpa, fd); + + log.debug("{x} == {x}", .{ tc.expect_dgst, xattr_dgst }); + if (!std.mem.eql(u8, &tc.expect_dgst, &xattr_dgst)) { + std.debug.print("====== expected this digest: =========\n", .{}); + std.debug.print("{x}\n\n", .{tc.expect_dgst}); + std.debug.print("======== instead found this: =========\n", .{}); + std.debug.print("{x}\n\n", .{xattr_dgst}); + std.debug.print("======================================\n", .{}); + return error.TestExpectedEqual; + } + + try Testing.shell(io, "tree", .{ .cwd = .{ .dir = dest } }); + const cmd = try std.fmt.allocPrint(gpa, "cat {s}", .{tc.expect_path}); + defer gpa.free(cmd); + try Testing.shell(io, cmd, .{ .cwd = .{ .dir = dest } }); + } + }; + + var tmp = std.testing.tmpDir(.{ .iterate = true }); + defer tmp.cleanup(); + + { + var tc = try Tc.init( + "./testdata/only-copy.html", + "only-copy.html", + try Testing.parse_hash("eae5ff7df7c71f8bbeda89302f9fe721"), + ); + try tc.run(tmp.dir); + } + { + var tc = try Tc.init( + "./testdata/only-copy.html", + "only-copy.html", + try Testing.parse_hash("eae5ff7df7c71f8bbeda89302f9fe721"), + ); + try tc.run(tmp.dir); + } + { + var tc = try Tc.init( + "./testdata/fragment.htm", + "fragment.html", + try Testing.parse_hash("a5c099fac15ea9ea9928712fa6229702"), + ); + try tc.run(tmp.dir); + } + { + var tc = try Tc.init( + "./testdata/page.htm", + "page.html", + try Testing.parse_hash("5d810c74eb9ba4d03fa49ab920b85e45"), + ); + try tc.run(tmp.dir); + } + { + var tc = try Tc.init( + "./testdata/meta.tilde.foo.htm", + "~foo", + try Testing.parse_hash("f5bdd49b1a3d691113033ee606e0aa1d"), + ); + try tc.run(tmp.dir); + } } diff --git a/src/os.zig b/src/os.zig deleted file mode 100644 index ad9fe23..0000000 --- a/src/os.zig +++ /dev/null @@ -1,84 +0,0 @@ -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); -} |