const C = @cImport({ @cInclude("time.h"); }); 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); const BUFFER_SIZE = 1024; const DEBUG = (@import("builtin").mode == .Debug); // tune log options pub const std_options = std.Options{ .log_level = if (DEBUG) .debug else .warn, }; pub fn main(init: std.process.Init) !void { try Cli.run(init); } // TODO: make this not a global var FORCE: bool = false; var LINK_PREFIX: []const u8 = "/site"; const Cli = struct { const FlagSet = std.StringHashMapUnmanaged([]const u8); const Flag = struct { name: []const u8, help: []const u8 = "", takes_value: bool = false, }; const Command = struct { name: []const u8, help: []const u8 = "", flags: []const Flag = &.{}, subcommands: []const Command = &.{}, handler: *const fn (io: Io, gpa: Allocator, flags: FlagSet, args: [][]const u8) anyerror!void, fn run(self: Command, io: Io, gpa: Allocator, opts: *std.process.Args.Iterator) !void { var flags: FlagSet = .empty; defer flags.deinit(gpa); var args: std.ArrayList([]const u8) = .empty; defer args.deinit(gpa); optLoop: while (opts.next()) |o| { if (std.mem.eql(u8, o, "help")) return Cli.usage(); if (std.mem.eql(u8, o, "--help")) return Cli.usage(); if (std.mem.eql(u8, o, "-h")) return Cli.usage(); for (self.flags) |f| if (std.mem.eql(u8, o, f.name)) { if (f.takes_value) { const flagval = opts.next() orelse return error.MissingFlagValue; try flags.put(gpa, f.name, flagval); } else { try flags.put(gpa, f.name, ""); } continue :optLoop; }; for (self.subcommands) |c| if (std.mem.eql(u8, o, c.name)) { try c.run(io, gpa, opts); return; // terminate with subcommand }; try args.append(gpa, o); } try self.handler(io, gpa, flags, args.items); return; } }; const root = Command{ .name = "rbc", .subcommands = &.{ .{ .name = "build", .flags = &.{ .{ .name = "-f" }, .{ .name = "--force" } }, .handler = &Cli.build, }, .{ .name = "gen", .flags = &.{ .{ .name = "-f" }, .{ .name = "--force" }, .{ .name = "-l" }, .{ .name = "--list" }, }, .handler = &Cli.gen, }, .{ .name = "serve", .flags = &.{ .{ .name = "-l", .takes_value = true }, .{ .name = "--listen", .takes_value = true }, }, .handler = &Cli.serve, }, }, .handler = struct { fn inner(_: Io, _: Allocator, _: FlagSet, _: [][]const u8) anyerror!void { return Cli.usage(); } }.inner, }; fn usage() void { std.debug.print( \\usage: rbc [command] [options] \\ \\commands: \\ build [-f, --force] SOURCE-FILE TARGET-DIR \\ builds while resolving references \\ \\ gen [-f, --force] [-l, --list] ROOT-DIR TARGET-DIR \\ runs build for relevant files \\ \\ serve ROOT-DIR \\ serves the site (using caddy) \\ \\ , .{}); } fn run(init: std.process.Init) !void { if (init.environ_map.get("RBC_FORCE")) |_| FORCE = true; if (init.environ_map.get("RBC_LINK_PREFIX")) |e| LINK_PREFIX = e; // parse options var opts = init.minimal.args.iterate(); _ = opts.skip(); // skip arg0 return Cli.root.run(init.io, init.gpa, &opts); } fn build(io: Io, gpa: Allocator, flags: FlagSet, args: [][]const u8) !void { if (flags.contains("--force")) FORCE = true; if (flags.contains("-f")) FORCE = true; if (args.len < 2) return Cli.usage(); const source_path = args[0]; const target_path = args[1]; log.info("build : 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 Dir.init(io, Io.Dir.cwd(), root_path, .{}); defer root_dir.fd.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 try Io.Dir.cwd().createDirPath(io, target_path); const target_dir = try Dir.init(io, Io.Dir.cwd(), target_path, .{}); defer target_dir.fd.close(io); const b = try Builder.init(gpa, root_dir, source, target_dir, .{}); defer b.deinit(gpa); try b.build(io, gpa); } fn gen(io: Io, parent_gpa: Allocator, flags: FlagSet, args: [][]const u8) !void { var arena = std.heap.ArenaAllocator.init(parent_gpa); defer arena.deinit(); const gpa = arena.allocator(); if (flags.contains("--force")) FORCE = true; if (flags.contains("-f")) FORCE = true; var LIST = false; if (flags.contains("-l")) LIST = true; if (flags.contains("--list")) LIST = true; if (args.len < 2) return Cli.usage(); const root_path = args[0]; const target_path = args[1]; log.info("gen : target={s} root={s}", .{ target_path, root_path }); const root_dir = try Dir.init(io, Io.Dir.cwd(), root_path, .{ .iterate = true }); defer root_dir.fd.close(io); // open/create destination dir try Io.Dir.cwd().createDirPath(io, target_path); const target_dir = try Dir.init(io, Io.Dir.cwd(), target_path, .{}); defer target_dir.fd.close(io); var work: std.BufSet = .init(gpa); defer work.deinit(); var mdn: std.BufSet = .init(gpa); defer mdn.deinit(); var iter = root_dir.fd.iterateAssumeFirstIteration(); while (try iter.next(io)) |child| { if (child.kind != .file) continue; if (std.mem.endsWith(u8, child.name, "meta.baseof.htm")) continue; if (std.mem.endsWith(u8, child.name, "meta.nav.htm")) continue; if (std.mem.endsWith(u8, child.name, ".md")) { try mdn.insert(child.name); } else { try work.insert(child.name); } } var progress = if (!DEBUG) std.Progress.start(io, .{}); defer if (!DEBUG) progress.end(); var mdnProgress = if (!DEBUG) progress.start("Generating markdown", mdn.count()); defer if (!DEBUG) mdnProgress.end(); var htmProgress = if (!DEBUG) progress.start("Generating html", work.count()); defer if (!DEBUG) htmProgress.end(); var outgoingRefs: std.StringHashMapUnmanaged([][]const u8) = .empty; defer outgoingRefs.deinit(gpa); // TODO: parallelize? // process markdown first var mdnIter = mdn.iterator(); while (mdnIter.next()) |path| { const full_path = try std.fs.path.join(gpa, &.{ root_path, path.* }); defer gpa.free(full_path); if (flags.contains("-l") or flags.contains("--list")) { // only print try Io.File.stdout().writeStreamingAll(io, full_path); try Io.File.stdout().writeStreamingAll(io, "\n"); continue; } const mdnNode = if (!DEBUG) mdnProgress.start(path.*, 0); defer if (!DEBUG) mdnNode.end(); var source = try File.init(io, root_dir, path.*, null); defer source.close(io); const b = try Builder.init(gpa, root_dir, source, target_dir, .{}); defer b.deinit(gpa); try b.build(io, gpa); const htm_name = try std.fmt.allocPrint(gpa, "{s}.htm", .{std.fs.path.stem(path.*)}); try work.insert(htm_name); } var workIter = work.iterator(); while (workIter.next()) |path| { const full_path = try std.fs.path.join(gpa, &.{ root_path, path.* }); defer gpa.free(full_path); if (flags.contains("-l") or flags.contains("--list")) { // only print try Io.File.stdout().writeStreamingAll(io, full_path); try Io.File.stdout().writeStreamingAll(io, "\n"); continue; } const htmNode = if (!DEBUG) htmProgress.start(path.*, 0); defer if (!DEBUG) htmNode.end(); var source = try File.init(io, root_dir, path.*, null); defer source.close(io); const b = try Builder.init(gpa, root_dir, source, target_dir, .{}); defer b.deinit(gpa); try b.build(io, gpa); // make a deep copy of the refs list const refs = try b.refs.clone(gpa); for (refs.items, 0..) |r, i| refs.items[i] = try gpa.dupe(u8, r); // track the refs for this source file try outgoingRefs.put(gpa, std.fs.path.stem(source.path), refs.items); } if (LIST) return; // filter index for post processing var index: std.ArrayList([]const u8) = try .initCapacity(gpa, work.count()); var workIter2 = work.iterator(); while (workIter2.next()) |e| { if (std.mem.startsWith(u8, std.fs.path.stem(e.*), "meta.")) continue; index.appendAssumeCapacity(e.*); } { var pn = if (!DEBUG) progress.start("Generating index", 0); defer if (!DEBUG) pn.end(); try genIndex(io, gpa, target_dir, index); } { var pn = if (!DEBUG) progress.start("Generating backrefs", index.items.len); defer if (!DEBUG) pn.end(); try genBackrefs(io, gpa, target_dir, index, outgoingRefs); } } fn inject(io: Io, gpa: Allocator, target: File, needle: []const u8, buf: []const u8) !void { const offset = std.mem.indexOf(u8, target.mmap.memory, needle) orelse return; const remainder = try gpa.dupe(u8, target.mmap.memory[offset + needle.len ..]); defer gpa.free(remainder); // insert it into the target document try target.fd.writePositionalAll(io, buf, offset); try target.fd.writePositionalAll(io, remainder, offset + buf.len); } fn genBackrefs( io: Io, gpa: Allocator, target_dir: Dir, work: std.ArrayList([]const u8), outgoingRefs: std.StringHashMapUnmanaged([][]const u8), ) !void { log.info("Generating backrefs", .{}); // create incoming refs from outgoing refs var incomingRefs: std.StringHashMapUnmanaged([][]const u8) = .empty; try incomingRefs.ensureTotalCapacity(gpa, outgoingRefs.size); defer incomingRefs.deinit(gpa); var outIter = outgoingRefs.iterator(); while (outIter.next()) |entry| { for (entry.value_ptr.*) |ref| { const res = incomingRefs.getOrPutAssumeCapacity(ref); if (res.found_existing) { res.value_ptr.* = try gpa.realloc(res.value_ptr.*, res.value_ptr.len + 1); res.value_ptr.*[res.value_ptr.len - 1] = entry.key_ptr.*; } else { res.value_ptr.* = try gpa.alloc([]const u8, 1); res.value_ptr.*[0] = entry.key_ptr.*; } } } for (work.items) |path| { const stem = std.fs.path.stem(path); const back_refs = incomingRefs.get(stem); const target_name = try std.fmt.allocPrint(gpa, "{s}.html", .{stem}); defer gpa.free(target_name); log.debug("{s}: backrefs n={d}", .{ target_name, if (back_refs) |b| b.len else 0 }); var target = try File.init(io, target_dir, target_name, null); defer target.close(io); try inject(io, gpa, target, "{[builtin.backrefs]}", blk: { var buf = Io.Writer.Allocating.init(gpa); defer buf.deinit(); // if the file doesnt have backrefs, we still have to write here // so that the template string is resolved to empty if (back_refs == null) break :blk ""; try buf.writer.writeAll( \\incoming: \\ ); break :blk try buf.toOwnedSlice(); }); } } fn genIndex( io: Io, gpa: Allocator, target_dir: Dir, index: std.ArrayList([]const u8), ) !void { log.info("Generating index", .{}); var target = File.init(io, target_dir, "index.html", null) catch |err| switch (err) { error.FileNotFound => return, else => return err, }; defer target.close(io); log.debug("{s}: index n={d}", .{ target.path, index.items.len }); try inject(io, gpa, target, "{[builtin.index]}", blk: { var buf = Io.Writer.Allocating.init(gpa); defer buf.deinit(); // write a comment when we dont have pages if (index.items.len == 0) break :blk ""; try buf.writer.writeAll( \\ \\ ); break :blk try buf.toOwnedSlice(); }); } fn serve(io: Io, _: Allocator, _: FlagSet, args: [][]const u8) !void { if (args.len != 1) return Cli.usage(); const root_path = args[0]; const root_dir = try Io.Dir.cwd().openDir(io, root_path, .{}); defer root_dir.close(io); const command = blk: { root_dir.access(io, "Caddyfile", .{}) catch |err| switch (err) { error.FileNotFound => break :blk "caddy file-server -l :1414", else => return err, }; break :blk "caddy fmt --overwrite && caddy run --watch"; }; log.info("Serving root={s}", .{root_path}); try shell(io, command, .{ .cwd = .{ .dir = root_dir } }); } }; const Dir = struct { path: []const u8, fd: Io.Dir, fn init(io: Io, dir: Io.Dir, path: []const u8, options: Io.Dir.OpenOptions) !Dir { return .{ .path = path, .fd = try dir.openDir(io, path, options), }; } }; const File = struct { path: []const u8, fd: Io.File, dir: Dir, mmap: Io.File.MemoryMap, mtime: Io.Timestamp, fn init(io: Io, dir: Dir, path: []const u8, options: ?Io.Dir.OpenFileOptions) !File { const fd = try dir.fd.openFile(io, path, options orelse .{ .lock = .exclusive, .mode = .read_write, }); const stat = try fd.stat(io); var mmap = try fd.createMemoryMap(io, .{ .len = stat.size, }); try mmap.read(io); return .{ .path = path, .fd = fd, .dir = dir, .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: Dir, source: File, target_dir: Dir, options: Options, // track files which affect build output (paths relative to root_dir) parts: std.ArrayList([]const u8) = .empty, // track component digests which should affect digest check dgsts: std.ArrayList(Digest) = .empty, // track references refs: std.ArrayList([]const u8) = .empty, // used for post processing index: ?std.ArrayList([]const u8) = null, pub const Options = struct { link_prefix: []const u8 = "/", }; const DELIM_START: []const u8 = "{["; const DELIM_END: []const u8 = "]}"; fn init( gpa: Allocator, root_dir: Dir, source: File, target_dir: Dir, options: Options, ) !*Builder { const b = try gpa.create(Builder); b.* = .{ .root_dir = root_dir, .source = source, .target_dir = target_dir, .options = options, }; b.options.link_prefix = LINK_PREFIX; return b; } fn deinit(self: *Builder, gpa: Allocator) void { self.dgsts.deinit(gpa); for (self.parts.items) |p| gpa.free(p); self.parts.deinit(gpa); for (self.refs.items) |p| gpa.free(p); self.refs.deinit(gpa); gpa.destroy(self); } fn build(self: *Builder, io: Io, gpa: Allocator) !void { log.info("{s}: building", .{self.source.path}); const source_stem = std.fs.path.stem(self.source.path); const source_ext = std.fs.path.extension(self.source.path); // handle markdown files if (std.mem.eql(u8, source_ext, ".md")) return try self.markdown(io, gpa); // handle meta pages before even creating target_path if (std.mem.startsWith(u8, source_stem, "meta.tilde")) { return try self.metaTilde(io, gpa); } if (std.mem.startsWith(u8, source_stem, "meta.feed")) { return try self.metaFeed(io, gpa); } // target file const target_path = try std.fmt.allocPrint(gpa, "{s}.html", .{source_stem}); defer gpa.free(target_path); // check if we need to do any work if (try targetExists(io, gpa, self.target_dir, target_path, self.root_dir)) { defer log.debug("{s}: skipping", .{self.source.path}); return; // skip any further processing } log.debug("{s}: writing", .{self.source.path}); // atomic? errdefer log.err("path={s}", .{target_path}); const target_fd = try self.target_dir.fd.createFile(io, target_path, .{ .lock = .exclusive, }); defer target_fd.close(io); 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 self.inject(io, gpa, self.source, &writer.interface); } else { // load baseof fragment errdefer log.err("path={s}", .{"meta.baseof.htm"}); 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, self.source.fd.handle == source.fd.handle); // 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 template( self: *Builder, io: Io, gpa: Allocator, ref_name: []const u8, w: *Io.Writer, track_refs: bool, ) anyerror!void { log.debug("{s}: templating ref={s}", .{ self.source.path, 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; } if (std.mem.eql(u8, ref_name, "builtin.mtime")) { var buf: [BUFFER_SIZE]u8 = undefined; const tm: isize = @intCast(self.source.mtime.toSeconds()); const n = C.strftime(&buf, buf.len, "%F", C.localtime(&tm)); try w.writeAll(buf[0..n]); return; } if (std.mem.eql(u8, ref_name, "builtin.backrefs")) { log.debug("{s}: no backrefs yet", .{self.source.path}); try w.writeAll("{[builtin.backrefs]}"); // done in post processing return; } if (std.mem.eql(u8, ref_name, "builtin.index")) { log.debug("{s}: no index yet", .{self.source.path}); try w.writeAll("{[builtin.index]}"); // done in post processing return; } 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, track_refs), } } fn templatePortal( self: *Builder, io: Io, gpa: Allocator, ref_name: []const u8, w: *Io.Writer, ) !void { log.debug("{s}: portaling ref={s}", .{ self.source.path, 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, track_refs: bool, ) !void { const fragment = std.mem.indexOf(u8, ref_str, "#"); const ref = if (fragment) |i| ref_str[0..i] else ref_str; log.debug("{s}: referencing ref={s}", .{ self.source.path, 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.fd.access(io, p, .{}) catch break :blk false; break :blk true; }); if (!found) { log.err("{s} not found", .{ref}); return error.FileNotFound; } } const ref_ = try formatRef( gpa, ref, self.options.link_prefix, if (fragment) |i| ref_str[i..] else "", ); defer gpa.free(ref_); try w.writeAll(ref_); // TODO: $ and + refs should be handled too? if (track_refs) try self.refs.append(gpa, try gpa.dupe(u8, ref)); } fn templateLink( self: *Builder, gpa: Allocator, ref_name: []const u8, w: *std.Io.Writer, ) !void { const link = try formatLink(gpa, ref_name, self.options.link_prefix); defer gpa.free(link); try w.writeAll(link); } 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); } // 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." const content = std.mem.trim(u8, self.source.mmap.memory, "\n\t\r"); const target_path = try std.fmt.allocPrint(gpa, "~{s}", .{source_stem}); defer gpa.free(target_path); if (try targetExists(io, gpa, self.target_dir, target_path, self.root_dir)) return; // TODO: allow customizing tilde dir const target_fd = try self.target_dir.fd.createFile(io, target_path, .{ .lock = .exclusive, }); defer target_fd.close(io); var buffer: [BUFFER_SIZE]u8 = undefined; var writer = target_fd.writer(io, &buffer); try writer.interface.print( \\ \\ \\
\\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ , .{ .LINK = content, }); try writer.interface.flush(); // track source try self.parts.append(gpa, try gpa.dupe(u8, self.source.path)); try self.dgsts.append(gpa, try self.source.digest()); try self.finalize(gpa, target_fd); log.debug("{s}: done", .{self.source.path}); } // TODO: could also be a generic way to allow arbitrary templated files fn metaFeed(self: *Builder, io: Io, gpa: Allocator) !void { const source_stem = std.fs.path.basename(self.source.path)[5..]; // trim "meta." if (try targetExists(io, gpa, self.target_dir, source_stem, self.root_dir)) return; const target_fd = try self.target_dir.fd.createFile(io, source_stem, .{ .lock = .exclusive, }); defer target_fd.close(io); var buffer: [BUFFER_SIZE]u8 = undefined; var writer = target_fd.writer(io, &buffer); try self.inject(io, gpa, self.source, &writer.interface); try self.finalize(gpa, target_fd); log.debug("{s}: done", .{self.source.path}); } const prepocLua = @embedFile("preproc.lua"); fn markdown(self: *Builder, io: Io, gpa: Allocator) !void { log.info("mdn : {s}", .{self.source.path}); const source_stem = std.fs.path.stem(self.source.path); const target_path = try std.fmt.allocPrint(gpa, "{s}.htm", .{source_stem}); defer gpa.free(target_path); if (try targetExists(io, gpa, self.root_dir, target_path, self.root_dir)) return; const target_fd = try self.root_dir.fd.createFile(io, target_path, .{ .lock = .exclusive, .read = true, .truncate = true, }); defer target_fd.close(io); // embedded preproc.lua -> fd // which we pass to the pandoc subshell const fd = try memfd("preproc.lua", prepocLua); const pandoc_command = try std.fmt.allocPrint(gpa, \\pandoc --from=markdown+wikilinks_title_after_pipe+autolink_bare_uris --to=html5 --lua-filter=/dev/fd/{d} --strip-comments --output={s} {s} , .{ fd, target_path, self.source.path }); defer gpa.free(pandoc_command); try shell(io, pandoc_command, .{ .cwd = .{ .dir = self.root_dir.fd } }); // track source try self.parts.append(gpa, try gpa.dupe(u8, self.source.path)); try self.dgsts.append(gpa, try self.source.digest()); 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, prefix: []const u8) ![]u8 { const link_name = try std.fmt.allocPrint(gpa, "{s}.html", .{name}); defer gpa.free(link_name); return try std.fs.path.join(gpa, &.{ prefix, link_name }); } fn formatRef(gpa: Allocator, name: []const u8, link_prefix: []const u8, fragment: []const u8) ![]u8 { const link = try formatLink(gpa, name, link_prefix); defer gpa.free(link); const text = try formatName(gpa, name); defer gpa.free(text); return try std.fmt.allocPrint(gpa, \\{s} , .{ link, fragment, text }); } fn targetExists( io: Io, gpa: Allocator, target_dir: Dir, sub_path: []const u8, root_dir: Dir, ) !bool { if (FORCE) return false; const target_fd = target_dir.fd.openFile(io, sub_path, .{}) catch |err| switch (err) { error.FileNotFound => return false, else => return err, }; defer target_fd.close(io); const target_parts = try getXattrSourceComponents(gpa, target_fd) orelse return false; defer gpa.free(target_parts); defer for (target_parts) |p| gpa.free(p); const target_dgst = try getXattrSourceDigest(gpa, target_fd) orelse return false; // 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 const buf = source.mmap.memory[0..6]; return std.mem.eql(u8, buf, ""); } fn compositeDigest(parts: []Digest) Digest { // sort digests before computing composite 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, } } 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 null, 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) orelse return null; defer gpa.free(val); return val[0..16].*; } fn setXattrSourceComponents(gpa: Allocator, fd: Io.File, parts: [][]const u8) !void { if (parts.len < 1) return error.InvalidComponents; // sort parts before joining std.sort.block([]const u8, parts, {}, comptime struct { pub fn inner(_: void, a: []const u8, b: []const u8) bool { return std.mem.lessThan(u8, a, b); } }.inner); 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) orelse return null; 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); } // shell pub fn memfd(name: [*:0]const u8, data: []const u8) !usize { const fd = linux.memfd_create(name, 0); if (linux.errno(fd) != .SUCCESS) return error.MemfdCreate; const rc = linux.ftruncate(@intCast(fd), @intCast(data.len)); if (linux.errno(rc) != .SUCCESS) return error.Ftruncate; const page = linux.mmap( null, data.len, .{ .READ = true, .WRITE = true }, .{ .TYPE = .SHARED }, @intCast(fd), 0, ); if (linux.errno(page) != .SUCCESS) return error.Mmap; const ptr: [*]volatile u8 = @ptrFromInt(page); @memcpy(ptr, data); return fd; } 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; } // tests pub const Testing = if (@import("builtin").is_test) struct { 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].*; } }; 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: 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 Dir.init(io, std.Io.Dir.cwd(), 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: Dir) !void { std.debug.print("=== {s} =============================\n", .{tc.source.path}); defer tc.root_dir.fd.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.fd.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 shell(io, "tree", .{ .cwd = .{ .dir = dest.fd } }); const cmd = try std.fmt.allocPrint(gpa, "cat {s}", .{tc.expect_path}); defer gpa.free(cmd); try shell(io, cmd, .{ .cwd = .{ .dir = dest.fd } }); } }; var tmproot = std.testing.tmpDir(.{ .iterate = true }); defer tmproot.cleanup(); const tmp = Dir{ .fd = tmproot.dir, .path = "." }; { var tc = try Tc.init( "./testdata/only-copy.html", "only-copy.html", try Testing.parse_hash("be89dea05afb8f216e8ad977edad28ee"), ); try tc.run(tmp); } { var tc = try Tc.init( "./testdata/only-copy.html", "only-copy.html", try Testing.parse_hash("be89dea05afb8f216e8ad977edad28ee"), ); try tc.run(tmp); } { var tc = try Tc.init( "./testdata/fragment.htm", "fragment.html", try Testing.parse_hash("3d14d46139973e8ddf5bc6ebd2089a62"), ); try tc.run(tmp); } { var tc = try Tc.init( "./testdata/page.htm", "page.html", try Testing.parse_hash("091c13472ee74d2805765901b812266d"), ); try tc.run(tmp); } { var tc = try Tc.init( "./testdata/meta.tilde.foo.htm", "~foo", try Testing.parse_hash("fdb03f240780fb8a90e1d504366ee23b"), ); try tc.run(tmp); } }