// 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}2>
\\
, .{
.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}
\\
\\
\\
\\
\\
, .{
.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;
}
}
fn genIndexHtml(ctx: *Context) !void {
const srcpathIndex = try joinIn(ctx.allocator, &.{PAGE_PATH}, &.{"index.htm"});
defer ctx.allocator.free(srcpathIndex);
const f = try std.fs.cwd().createFile(srcpathIndex, .{});
defer f.close();
try f.writeAll(
\\
\\
);
// alphanum ascii sort
std.mem.sort(
[]const u8,
ctx.pages,
{},
struct {
fn lessthan(_: void, a: []const u8, b: []const u8) bool {
return std.ascii.lessThanIgnoreCase(a, b);
}
}.lessthan,
);
for (ctx.pages) |p| {
if (std.mem.startsWith(u8, p, "meta.")) continue;
const dstname = try stripExt(p);
const dstpath = try joinIn(ctx.allocator, &.{SITE_PATH}, &.{ dstname, ".html" });
defer ctx.allocator.free(dstpath);
try f.writer().print(
\\- {[NAME]s}
\\
, .{
.LINK = dstpath,
.NAME = dstname,
});
}
try f.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});
}