download patch
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
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
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
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
{
"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
+<?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
+<?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
It'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
<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
<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
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
+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
+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
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);
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
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");
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;
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});
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();
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);
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
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 {
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>
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,
});
}