commit f3cb4233b71984c2a368f661adbbe038771ab18b
Author: tri <tri@thac.loan>
Date:   Mon Oct 13 18:26:41 2025 +0700

    add atom feed

diff --git a/readme.md b/readme.md
index 63cb63e..31b4011 100644
--- a/readme.md
+++ b/readme.md
@@ -12,7 +12,8 @@ cd mysite
 cat > _loa_meta.json << EOF
 {
   "site_name": "Loa Sample Site",
-  "tagline": "Who samples the samplers?"
+  "tagline": "Who samples the samplers?",
+  "domain": "https://blog.example.com",
 }
 EOF
 
@@ -48,6 +49,10 @@ zig build -Doptimize=ReleaseSafe -fsys=lua
 
 If you're curious, lua is used for running [djot.lua][1], which is vendored in src/vendor/djot.lua.
 
+## Runtime dependency
+
+Unfortunately loa shells out to [date][2] because zig doesn't come with a standard datetime library yet and I'm too lazy to implement one.
+
 ## License
 
 Copyright © 2025 tri@thac.loan
@@ -59,3 +64,4 @@ This program is distributed in the hope that it will be useful, but WITHOUT ANY
 You should have received a copy of the GNU Affero General Public License along with this program (agpl-3.0.txt). If not, see <https://www.gnu.org/licenses/>.
 
 [1]: https://github.com/jgm/djot.lua
+[2]: https://www.gnu.org/software/coreutils/manual/html_node/date-invocation.html#date-invocation
diff --git a/sample/_loa_meta.json b/sample/_loa_meta.json
index 397256f..8b9341f 100644
--- a/sample/_loa_meta.json
+++ b/sample/_loa_meta.json
@@ -1,4 +1,5 @@
 {
   "site_name": "Loa Sample Site",
-  "tagline": "Who samples the samplers?"
+  "tagline": "Who samples the samplers?",
+  "domain": "https://loa.thac.loan"
 }
diff --git a/sample/atom.xml b/sample/atom.xml
new file mode 100644
index 0000000..2a6cc5c
--- /dev/null
+++ b/sample/atom.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom">
+<title>Loa Sample Site</title>
+<subtitle>Who samples the samplers?</subtitle>
+<link href="https://loa.thac.loan/atom.xml" rel="self" />
+<id>https://loa.thac.loan/</id>
+<updated>2025-11-12T03:04:05+06:00</updated>
+<entry>
+  <id>https://loa.thac.loan/second/</id>
+  <title>Second post</title>
+  <updated>2025-10-11T09:04:40+07:00</updated>
+</entry>
+<entry>
+  <id>https://loa.thac.loan/first/</id>
+  <title>First</title>
+  <updated>2025-10-09T21:13:03+07:00</updated>
+</entry>
+</feed>
diff --git a/sample/feed.xml b/sample/feed.xml
new file mode 100644
index 0000000..69f7f13
--- /dev/null
+++ b/sample/feed.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom">
+<title>Loa Sample Site</title>
+<subtitle>Who samples the samplers?</subtitle>
+<link href="https://loa.thac.loan/feed.xml" rel="self" />
+<id>https://loa.thac.loan/</id>
+<updated>2025-11-12T03:04:05+06:00</updated>
+
+<entry>
+  <id>https://loa.thac.loan/second/</id>
+  <title>Second post</title>
+  <updated>2025-10-11T09:04:40+07:00</updated>
+</entry>
+
+<entry>
+  <id>https://loa.thac.loan/first/</id>
+  <title>First</title>
+  <updated>2025-10-09T21:13:03+07:00</updated>
+</entry>
+</feed>
diff --git a/sample/first/index.html b/sample/first/index.html
index ed6d697..fcbf4d3 100644
--- a/sample/first/index.html
+++ b/sample/first/index.html
@@ -13,6 +13,8 @@
 
 It&#39;s a static site generator for your git repos - think [stagit][1], but `git clone`-able. If you ever wanted to share your plain git repos without the security headaches of hosting a dynamic web ser" />
 
+    <link href="/atom.xml" type="application/atom+xml" rel="alternate" title="Sitewide Atom feed" />
+
     <link rel="stylesheet" href="/_loa/style.css">
     <script src="/_loa/script.js"></script>
   </head>
diff --git a/sample/index.html b/sample/index.html
index 5c5aa29..5ddf7dd 100644
--- a/sample/index.html
+++ b/sample/index.html
@@ -9,6 +9,8 @@
     <meta property="og:title" content="loa" />
     <meta property="og:description" content="shouting into the void" />
 
+    <link href="/atom.xml" type="application/atom+xml" rel="alternate" title="Sitewide Atom feed" />
+
     <link rel="stylesheet" href="/_loa/style.css">
     <script src="/_loa/script.js"></script>
   </head>
diff --git a/sample/second/index.html b/sample/second/index.html
index c23de93..a775e13 100644
--- a/sample/second/index.html
+++ b/sample/second/index.html
@@ -10,6 +10,8 @@
     <meta property="og:description" content="Look ma, second post!
 " />
 
+    <link href="/atom.xml" type="application/atom+xml" rel="alternate" title="Sitewide Atom feed" />
+
     <link rel="stylesheet" href="/_loa/style.css">
     <script src="/_loa/script.js"></script>
   </head>
diff --git a/src/Meta.zig b/src/Meta.zig
index d90b7fe..ac83333 100644
--- a/src/Meta.zig
+++ b/src/Meta.zig
@@ -9,6 +9,7 @@ const Meta = @This();
 
 site_name: []const u8,
 tagline: []const u8,
+domain: []const u8,
 
 pub fn load(arena: mem.Allocator, dir: fs.Dir) !Meta {
     const meta_text = try dir.readFileAlloc(arena, file_path, 1024 * 16);
diff --git a/src/datetime.zig b/src/datetime.zig
new file mode 100644
index 0000000..10dc604
--- /dev/null
+++ b/src/datetime.zig
@@ -0,0 +1,15 @@
+const std = @import("std");
+const mem = std.mem;
+
+/// Returns current time in ISO 8601 format e.g. 2025-10-13T12:42:43+07:00
+/// Unfortunately zig doesn't have a standard datetime library yet so we're
+/// shelling out to `date` here for convenience.
+pub fn now(arena: mem.Allocator) ![]u8 {
+    var proc = try std.process.Child.run(.{
+        .allocator = arena,
+        .argv = &.{ "date", "-Iseconds" },
+    });
+    std.debug.assert(proc.term.Exited == 0);
+    std.debug.assert(proc.stdout.len == 25 + 1); // with trailing \n
+    return proc.stdout[0..25];
+}
diff --git a/src/feed.zig b/src/feed.zig
new file mode 100644
index 0000000..7d24cda
--- /dev/null
+++ b/src/feed.zig
@@ -0,0 +1,59 @@
+const std = @import("std");
+const Io = std.Io;
+
+const Post = @import("Post.zig");
+const Meta = @import("Meta.zig");
+const html = @import("html.zig");
+
+pub const feed_path = "atom.xml";
+
+pub fn atom(args: struct {
+    arena: std.mem.Allocator,
+    writer: *Io.Writer,
+    meta: Meta,
+    posts: []Post,
+    now: []const u8,
+}) !void {
+    const w = args.writer;
+    const arena = args.arena;
+
+    try w.writeAll(
+        \\<?xml version="1.0" encoding="utf-8"?>
+        \\<feed xmlns="http://www.w3.org/2005/Atom">
+        \\
+    );
+
+    try html.tag(w, "title", args.meta.site_name);
+    try html.tag(w, "subtitle", args.meta.tagline);
+    try w.print(
+        \\<link href="{0s}/{1s}" rel="self" />
+        \\<id>{0s}/</id>
+        \\<updated>{2s}</updated>
+        \\
+    , .{
+        args.meta.domain,
+        feed_path,
+        args.now,
+    });
+
+    for (args.posts) |p| {
+        try w.print(
+            \\<entry>
+            \\  <id>{s}/{s}/</id>
+            \\  <title>{s}</title>
+            \\  <updated>{s}</updated>
+            \\</entry>
+            \\
+        , .{
+            args.meta.domain,
+            try html.escapeAlloc(arena, p.slug),
+            try html.escapeAlloc(arena, p.title),
+            try html.escapeAlloc(arena, p.published_at),
+        });
+    }
+
+    try w.writeAll(
+        \\</feed>
+        \\
+    );
+}
diff --git a/src/html.zig b/src/html.zig
index d0c18f2..c243708 100644
--- a/src/html.zig
+++ b/src/html.zig
@@ -1,5 +1,6 @@
 const std = @import("std");
 const t = std.testing;
+const Io = std.Io;
 
 pub fn escapeAlloc(arena: std.mem.Allocator, text: []const u8) ![]const u8 {
     var w = std.Io.Writer.Allocating.init(arena);
@@ -73,3 +74,9 @@ pub fn percentEncodeAlloc(arena: std.mem.Allocator, text: []const u8) ![]const u
     try percentEncode(&w.writer, text);
     return w.written();
 }
+
+pub fn tag(w: *Io.Writer, tag_name: []const u8, text: []const u8) !void {
+    try w.print("<{s}>", .{tag_name});
+    try escape(w, text);
+    try w.print("</{s}>\n", .{tag_name});
+}
diff --git a/src/main.zig b/src/main.zig
index 6ae8ae2..e0a80ab 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -2,6 +2,7 @@ const std = @import("std");
 const fs = std.fs;
 const os = std.os;
 const mem = std.mem;
+const t = std.testing;
 
 const println = @import("utils.zig").println;
 const Djot = @import("Djot.zig");
@@ -9,6 +10,8 @@ const Post = @import("Post.zig");
 const templates = @import("templates.zig");
 const html = @import("html.zig");
 const Meta = @import("Meta.zig");
+const feed = @import("feed.zig");
+const datetime = @import("datetime.zig");
 
 pub fn main() !u8 {
     var target_dir: [*:0]const u8 = undefined;
@@ -21,10 +24,10 @@ pub fn main() !u8 {
         },
     }
 
-    return try entrypoint(target_dir);
+    return try entrypoint(target_dir, null);
 }
 
-pub fn entrypoint(target_dir: [*:0]const u8) !u8 {
+pub fn entrypoint(target_dir: [*:0]const u8, custom_now: ?[]const u8) !u8 {
     var timer = try std.time.Timer.start();
     defer println("Took {d}ms", .{timer.read() / 1_000_000});
 
@@ -36,6 +39,12 @@ pub fn entrypoint(target_dir: [*:0]const u8) !u8 {
     defer global_arena_state.deinit();
     const global_arena = global_arena_state.allocator();
 
+    const now =
+        if (custom_now) |cnow|
+            cnow
+        else
+            try datetime.now(global_arena);
+
     var dir = try fs.cwd().openDirZ(target_dir, .{ .iterate = true });
     defer dir.close();
 
@@ -140,6 +149,28 @@ pub fn entrypoint(target_dir: [*:0]const u8) !u8 {
     // Sort posts by latest first
     mem.sortUnstable(Post, posts.items, {}, Post.newestFirst);
 
+    // Generate atom feed
+    {
+        defer _ = post_arena_state.reset(.retain_capacity);
+
+        var feed_file = try dir.createFile(feed.feed_path, .{});
+        defer feed_file.close();
+
+        var buf: [4096]u8 = undefined;
+        var feed_writer = feed_file.writer(&buf);
+        const writer = &feed_writer.interface;
+
+        try feed.atom(.{
+            .arena = post_arena,
+            .writer = writer,
+            .meta = meta,
+            .posts = posts.items,
+            .now = now,
+        });
+
+        try writer.flush();
+    }
+
     // Generate home page
     {
         defer _ = post_arena_state.reset(.retain_capacity);
@@ -205,5 +236,6 @@ test {
 }
 
 test entrypoint {
-    try entrypoint("sample");
+    const status = try entrypoint("sample", "2025-11-12T03:04:05+06:00");
+    try t.expectEqual(0, status);
 }
diff --git a/src/templates.zig b/src/templates.zig
index 889e8a5..38a5958 100644
--- a/src/templates.zig
+++ b/src/templates.zig
@@ -4,6 +4,7 @@ const Io = std.Io;
 const fmt = std.fmt;
 
 const html = @import("html.zig");
+const feed = @import("feed.zig");
 
 // Remember to close with base_end()
 pub fn base_start(arena: mem.Allocator, writer: *Io.Writer, args: struct {
@@ -26,6 +27,8 @@ pub fn base_start(arena: mem.Allocator, writer: *Io.Writer, args: struct {
         \\    <meta property="og:title" content="{2s}" />
         \\    <meta property="og:description" content="{3s}" />
         \\
+        \\    <link href="/{4s}" type="application/atom+xml" rel="alternate" title="Sitewide Atom feed" />
+        \\
         \\    <link rel="stylesheet" href="/_loa/style.css">
         \\    <script src="/_loa/script.js"></script>
         \\  </head>
@@ -43,6 +46,7 @@ pub fn base_start(arena: mem.Allocator, writer: *Io.Writer, args: struct {
         args.site_name,
         try html.escapeAlloc(arena, args.opengraph.title),
         try html.escapeAlloc(arena, args.opengraph.description),
+        feed.feed_path,
     });
 }