// 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 std = @import("std"); const c = @cImport({ @cInclude("time.h"); }); const PAGE_PATH = "src/inc"; const SITE_PATH = "site"; const LINK_PATH = "links"; const SITE_NAME = "gzr.im"; const LICENSE_HOLDER = "robertgzr"; const LICENSE_YEAR = "2025"; const LICENSE_NAME = "BY-NC-SA 4.0"; const LICENSE_URL = "https://git.sr.ht/~robertgzr/site/blob/master/LICENSE.content"; const SOURCE_URL = "https://git.sr.ht/~robertgzr/site/tree/master"; const PUBLIC_INBOX = "~robertgzr/public-inbox@lists.sr.ht"; // tune std.log options pub const std_options = std.Options{ .log_level = .info, }; const Context = struct { allocator: std.mem.Allocator, pages: [][]const u8, generated: usize = 0, meta: usize = 0, tilde: usize = 0, }; // also trims off base paths fn stripExt(path: []const u8) ![]const u8 { const ext = std.fs.path.extension(path); const ext_idx = std.mem.indexOfPosLinear(u8, path, 0, ext); std.debug.assert(ext_idx != null); return path[0..ext_idx.?]; } fn joinIn(allocator: std.mem.Allocator, paths: []const []const u8, filename_parts: []const []const u8) ![]u8 { const filename = try std.mem.concat(allocator, u8, filename_parts); defer allocator.free(filename); var p = try std.ArrayList([]const u8).initCapacity(allocator, paths.len + 1); defer p.deinit(); p.appendSliceAssumeCapacity(paths); p.appendAssumeCapacity(filename); const paths2 = try p.toOwnedSlice(); defer allocator.free(paths2); std.debug.assert(paths.len + 1 == paths2.len); return try std.fs.path.join(allocator, paths2); } fn getMtime(allocator: std.mem.Allocator, srcpath: []const u8) ![]u8 { const attr = try allocator.create(std.os.linux.Stat); defer allocator.destroy(attr); const srcpathZ = try allocator.dupeZ(u8, srcpath); defer allocator.free(srcpathZ); _ = std.os.linux.stat(srcpathZ, attr); const mtime = attr.mtime(); const mtime_str = try allocator.alloc(u8, 512); const n = c.strftime( mtime_str.ptr, mtime_str.len, "%F", c.localtime(&mtime.sec), ); return try allocator.realloc(mtime_str, n); } fn portal(allocator: std.mem.Allocator, w: std.io.AnyWriter, refname: []const u8, insert_heading: bool) anyerror!void { const basename = try std.mem.replaceOwned(u8, allocator, refname, " ", "_"); defer allocator.free(basename); const refpathPage = try joinIn(allocator, &.{PAGE_PATH}, &.{ basename, ".htm" }); defer allocator.free(refpathPage); const refpathSite = try joinIn(allocator, &.{SITE_PATH}, &.{ basename, ".html" }); defer allocator.free(refpathSite); if (insert_heading) try w.print( \\

{[NAME]s} \\ , .{ .ID = basename, .NAME = refname, .PATH = refpathSite, }); try inject(allocator, w, refpathPage); // XXX: track ref count? } fn ref(allocator: std.mem.Allocator, w: std.io.AnyWriter, refname: []const u8) anyerror!void { const basename = try std.mem.replaceOwned(u8, allocator, refname, " ", "_"); defer allocator.free(basename); const refpathSite = try joinIn(allocator, &.{SITE_PATH}, &.{ basename, ".html" }); defer allocator.free(refpathSite); try w.print( \\{[NAME]s} , .{ .NAME = refname, .PATH = refpathSite, }); // XXX: track ref count? } fn ref_link(allocator: std.mem.Allocator, w: std.io.AnyWriter, refname: []const u8) anyerror!void { const basename = try std.mem.replaceOwned(u8, allocator, refname, " ", "_"); defer allocator.free(basename); const refpathSite = try joinIn(allocator, &.{SITE_PATH}, &.{ basename, ".html" }); defer allocator.free(refpathSite); try w.print("/{s}", .{refpathSite}); } fn ref_name(_: std.mem.Allocator, w: std.io.AnyWriter, refname: []const u8) anyerror!void { try w.print("{s}", .{refname}); } fn template(allocator: std.mem.Allocator, w: std.io.AnyWriter, refname: []const u8) anyerror!void { switch (refname[0]) { '/' => return try portal(allocator, w, refname[1..], false), '@' => return try ref_link(allocator, w, refname[1..]), '+' => return try ref_name(allocator, w, refname[1..]), else => return try ref(allocator, w, refname), } } // Parses srcpath, resolves refs and portals and writes everything to w fn inject(allocator: std.mem.Allocator, w: std.io.AnyWriter, srcpath: []const u8) !void { const inc = std.fs.cwd().openFile(srcpath, .{ .mode = .read_only }) catch |err| { if (err == error.FileNotFound) std.log.warn("Missing {s} include.", .{srcpath}); return err; }; defer inc.close(); var buf_writer = std.io.bufferedWriter(w); var out_stream = buf_writer.writer(); var buf_reader = std.io.bufferedReader(inc.reader()); var in_stream = buf_reader.reader(); var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); while (in_stream.readUntilDelimiterOrEofAlloc( arena.allocator(), '\n', buf_reader.buf.len, ) catch { @panic("Failed to read from stream"); }) |chunk_| { defer _ = arena.reset(.free_all); var chunk = chunk_; templating: while (true) { if (std.mem.indexOfPosLinear(u8, chunk, 0, "{")) |start| { if (std.mem.indexOfPosLinear(u8, chunk, start + 1, "}")) |end| { try out_stream.writeAll(chunk[0..start]); template(allocator, out_stream.any(), chunk[start + 1 .. end]) catch @panic("Failed to resolve template"); chunk = chunk[end + 1 ..]; } else break :templating; } else break :templating; } try out_stream.writeAll(chunk); try out_stream.writeByte('\n'); try buf_writer.flush(); } try buf_writer.flush(); } fn build(allocator: std.mem.Allocator, f: std.fs.File, page_name: []const u8, srcpath: []const u8) !void { const mtime = try getMtime(allocator, srcpath); defer allocator.free(mtime); // HEAD try f.writer().print( \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\{[SITE_NAME]s} — {[PAGE_NAME]s} \\ \\ \\
\\{[SITE_NAME]s} \\ , .{ .SOURCE = srcpath, .LICENSE_URL = LICENSE_URL, .PAGE_NAME = page_name, .SITE_NAME = SITE_NAME, }); // NAV try f.writer().writeAll( \\ \\
\\ ); // MAIN try f.writer().print( \\
\\ , .{}); try inject(allocator, f.writer().any(), srcpath); try f.writer().writeAll( \\
\\ ); // FOOTER try f.writer().print( \\ \\ , .{ .LICENSE_HOLDER = LICENSE_HOLDER, .LICENSE_NAME = LICENSE_NAME, .LICENSE_URL = LICENSE_URL, .LICENSE_YEAR = LICENSE_YEAR, .MTIME = mtime, .PATH = srcpath, .PUBLIC_INBOX = PUBLIC_INBOX, .SOURCE_URL = SOURCE_URL, }); try f.writer().writeAll( \\[⏶] \\ ); try f.writer().writeAll( \\ \\ \\ ); } fn meta(ctx: *Context, p: []const u8) !void { ctx.meta += 1; if (std.mem.startsWith(u8, p, "meta.nav.")) return; if (std.mem.startsWith(u8, p, "meta.tilde.")) { const dot = std.mem.indexOfPosLinear(u8, p, 11, "."); const dstname = p[11..dot.?]; const srcpath = try joinIn(ctx.allocator, &.{PAGE_PATH}, &.{p}); defer ctx.allocator.free(srcpath); const dstpath = try joinIn(ctx.allocator, &.{LINK_PATH}, &.{ "~", dstname }); defer ctx.allocator.free(dstpath); const link = blk: { const f = try std.fs.cwd().openFile(srcpath, .{ .mode = .read_only }); defer f.close(); const content = try f.readToEndAlloc(ctx.allocator, std.os.linux.PATH_MAX); defer ctx.allocator.free(content); const trimmed = std.mem.trim(u8, content, " \n\t\r"); break :blk try ctx.allocator.dupe(u8, trimmed); }; defer ctx.allocator.free(link); const f = try std.fs.cwd().createFile(dstpath, .{}); defer f.close(); try f.writer().print( \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ , .{ .LINK = link, }); ctx.tilde += 1; return; } return error.unknownMeta; } fn genSite(ctx: *Context) !void { for (ctx.pages) |p| { const pname = std.fs.path.basename(p); if (std.mem.startsWith(u8, pname, "meta.")) { try meta(ctx, p); continue; } const dstname = try stripExt(p); const srcpath = try joinIn(ctx.allocator, &.{PAGE_PATH}, &.{ dstname, ".htm" }); defer ctx.allocator.free(srcpath); const dstpath = try joinIn(ctx.allocator, &.{SITE_PATH}, &.{ dstname, ".html" }); defer ctx.allocator.free(dstpath); const rel_dstpath = try std.fs.path.relative(ctx.allocator, SITE_PATH, dstpath); defer ctx.allocator.free(rel_dstpath); std.log.debug("generating {s} at {s}", .{ p, rel_dstpath }); if (std.fs.path.dirname(rel_dstpath)) |d| { const dstdir = try joinIn(ctx.allocator, &.{SITE_PATH}, &.{d}); defer ctx.allocator.free(dstdir); try std.fs.cwd().makePath(dstdir); } const f = try std.fs.cwd().createFile(dstpath, .{}); const page_name = try std.mem.replaceOwned(u8, ctx.allocator, dstname, "_", " "); defer ctx.allocator.free(page_name); try build(ctx.allocator, f, page_name, srcpath); ctx.generated += 1; } } const FileSwapper = struct { allocator: std.mem.Allocator, target_path: []const u8, tmp_path: []const u8, tmp: std.fs.File, fn createAt(allocator: std.mem.Allocator, path: []const u8) !FileSwapper { const target_dir = std.fs.path.dirname(path) orelse "."; const target_name = std.fs.path.basename(path); const tmp_name = try std.fmt.allocPrint(allocator, ".{s}", .{target_name}); defer allocator.free(tmp_name); const tmp_path = try std.fs.path.join(allocator, &.{ target_dir, tmp_name }); const f = try std.fs.cwd().createFile(tmp_path, .{}); return .{ .allocator = allocator, .target_path = path, .tmp_path = tmp_path, .tmp = f, }; } fn writer(self: FileSwapper) std.fs.File.Writer { return self.tmp.writer(); } fn close(self: FileSwapper) void { defer self.allocator.free(self.tmp_path); defer self.tmp.close(); self.tmp.sync() catch @panic("sync tmp file"); std.debug.print("??? {s} => {s}\n", .{ self.tmp_path, self.target_path }); std.fs.rename(std.fs.cwd(), self.tmp_path, std.fs.cwd(), self.target_path) catch @panic("rename tmp file"); } }; fn genIndexHtml(ctx: *Context) !void { const srcpathIndex = try joinIn(ctx.allocator, &.{PAGE_PATH}, &.{"index.htm"}); defer ctx.allocator.free(srcpathIndex); const f = try FileSwapper.createAt(ctx.allocator, srcpathIndex); defer f.close(); try f.writer().writeAll( \\ \\ ); } fn index(allocator: std.mem.Allocator, rootpath: []const u8) ![][]const u8 { var root = std.fs.cwd().openDir(rootpath, .{ .iterate = true }) catch |err| switch (err) { error.FileNotFound => { std.log.err("Missing {s} folder.", .{PAGE_PATH}); std.os.linux.exit(1); }, else => |e| return e, }; defer root.close(); var pages = std.ArrayList([]const u8).init(allocator); defer pages.deinit(); var walk = try root.walk(allocator); defer walk.deinit(); while (try walk.next()) |e| { switch (e.kind) { .file => { if (std.mem.endsWith(u8, e.basename, ".htm")) { std.log.debug("Indexing {s}", .{e.path}); try pages.append(try allocator.dupe(u8, e.path)); } }, else => {}, } } return try pages.toOwnedSlice(); } pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; const allocator = gpa.allocator(); defer _ = gpa.deinit(); const pages = index(allocator, PAGE_PATH) catch { std.log.err("Failed to index pages.", .{}); std.os.linux.exit(1); }; defer { for (pages) |p| allocator.free(p); allocator.free(pages); } std.log.info("Indexed {d} pages.", .{pages.len}); var ctx = Context{ .allocator = allocator, .pages = pages, }; try genIndexHtml(&ctx); std.log.info("Generated index.", .{}); try genSite(&ctx); std.log.info("Generated {d} files.", .{ctx.generated}); std.log.info("Generated {d} tildes.", .{ctx.tilde}); }