commit 05e15e75c0606608bd388e0370b5cd88b507de0f
Author: tri <tri@thac.loan>
Date:   Sat Oct 11 22:21:45 2025 +0700

    first MVP: generate home page and posts

diff --git a/.rgignore b/.rgignore
new file mode 100644
index 0000000..b0eac58
--- /dev/null
+++ b/.rgignore
@@ -0,0 +1 @@
+/sample/_loa/
diff --git a/sample/_loa/script.js b/sample/_loa/script.js
new file mode 100644
index 0000000..c998953
--- /dev/null
+++ b/sample/_loa/script.js
@@ -0,0 +1,64 @@
+window.addEventListener("load", () => {
+  enhanceTimeElements();
+});
+
+// Replace ISO datetime strings with momentjs-style relative text e.g. "20
+// minutes ago", "2 weeks ago"...
+function enhanceTimeElements() {
+  const millisecondsPerSecond = 1000;
+  const secondsPerMinute = 60;
+  const minutesPerHour = 60;
+  const hoursPerDay = 24;
+  const daysPerWeek = 7;
+  const daysPerMonth = 31;
+  const daysPerYear = 365; // getting imprecise here, but no big deal
+  const intervals = {
+    year:
+      millisecondsPerSecond *
+      secondsPerMinute *
+      minutesPerHour *
+      hoursPerDay *
+      daysPerYear,
+    month:
+      millisecondsPerSecond *
+      secondsPerMinute *
+      minutesPerHour *
+      hoursPerDay *
+      daysPerMonth,
+    week:
+      millisecondsPerSecond *
+      secondsPerMinute *
+      minutesPerHour *
+      hoursPerDay *
+      daysPerWeek,
+    day:
+      millisecondsPerSecond * secondsPerMinute * minutesPerHour * hoursPerDay,
+    hour: millisecondsPerSecond * secondsPerMinute * minutesPerHour,
+    minute: millisecondsPerSecond * secondsPerMinute,
+    second: millisecondsPerSecond,
+  };
+  const relativeDateFormat = new Intl.RelativeTimeFormat("en", {
+    style: "long",
+  });
+
+  // https://stackoverflow.com/a/78704662
+  function formatRelativeTime(isoStr) {
+    const diff = new Date(isoStr) - new Date();
+    for (const interval in intervals) {
+      if (intervals[interval] <= Math.abs(diff)) {
+        return relativeDateFormat.format(
+          Math.trunc(diff / intervals[interval]),
+          interval,
+        );
+      }
+    }
+    return relativeDateFormat.format(diff / 1000, "second");
+  }
+
+  document.querySelectorAll("time.relative").forEach((element) => {
+    const timeStr = element.getAttribute("datetime");
+    if (!timeStr) return;
+    const relativeTimeStr = formatRelativeTime(timeStr);
+    element.innerHTML = relativeTimeStr;
+  });
+}
diff --git a/sample/_loa/style.css b/sample/_loa/style.css
index f4bf9d7..27de5b0 100644
--- a/sample/_loa/style.css
+++ b/sample/_loa/style.css
@@ -9,6 +9,7 @@
   --bg: white;
   --fg: black;
   --pre-bg: #f2f2f2;
+  --pre-border: none;
   --code-fg: darkred;
   --base-padding: 0.7rem 1rem;
 
@@ -37,6 +38,7 @@ code {
 pre {
   padding: var(--base-padding);
   background-color: var(--pre-bg);
+  border: var(--pre-border);
   max-width: 100%;
   overflow-x: auto;
 }
@@ -63,6 +65,7 @@ p + ul {
     --bg: black;
     --fg: cornsilk;
     --pre-bg: #333;
+    --pre-border: 2px solid dimgrey;
     --code-fg: burlywood;
   }
 
@@ -79,3 +82,9 @@ p + ul {
     --base-padding: 0.5rem 0.7rem;
   }
 }
+
+.home--post-published-at {
+  opacity: 0.5;
+  margin-left: 0.5rem;
+  font-variant-caps: small-caps;
+}
diff --git a/sample/first/index.html b/sample/first/index.html
index fee8c0c..e423999 100644
--- a/sample/first/index.html
+++ b/sample/first/index.html
@@ -14,14 +14,13 @@
 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 rel="stylesheet" href="/_loa/style.css">
+    <script src="/_loa/script.js"></script>
   </head>
   <body>
-    <header>
-      <div id="breadcrumbs">
-        /<a href="/">home</a>/<span>First</span>      </div>
-    </header>
-
-    <main><p>khoe (Vietnamese, verb): to show something off</p>
+    <main>
+<a href="/">« back to home</a>
+<h1 style="margin-bottom:0">First</h1><time class="post--published-at relative" datetime="2025-10-09T21:13:03+07:00" title="2025-10-09T21:13:03+07:00">2025-10-09T21:13:03+07:00</time>
+<p>khoe (Vietnamese, verb): to show something off</p>
 <section id="What">
 <h2>What</h2>
 <p>It&rsquo;s a static site generator for your git repos - think <a href="https://codemadness.org/stagit.html">stagit</a>, but <code>git clone</code>-able. If you ever wanted to share your plain git repos without the security headaches of hosting a dynamic web service, you may like khoe.</p>
diff --git a/sample/index.html b/sample/index.html
new file mode 100644
index 0000000..5c5aa29
--- /dev/null
+++ b/sample/index.html
@@ -0,0 +1,32 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <title>loa</title>
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+
+    <meta property="og:site_name" content="loa" />
+    <meta property="og:title" content="loa" />
+    <meta property="og:description" content="shouting into the void" />
+
+    <link rel="stylesheet" href="/_loa/style.css">
+    <script src="/_loa/script.js"></script>
+  </head>
+  <body>
+    <main>
+<h1 style="margin-bottom:0">
+Loa Sample Site</h1>
+<div class="tagline">Who samples the samplers?</div>
+<ul class="home--post-list">
+<li>
+  <a href="/second/">Second post</a>
+  <time class="home--post-published-at relative" datetime="2025-10-11T09:04:40+07:00" title="2025-10-11T09:04:40+07:00">2025-10-11T09:04:40+07:00</time>
+</li>
+<li>
+  <a href="/first/">First</a>
+  <time class="home--post-published-at relative" datetime="2025-10-09T21:13:03+07:00" title="2025-10-09T21:13:03+07:00">2025-10-09T21:13:03+07:00</time>
+</li>
+</ul>
+    </main>
+  </body>
+</html>
diff --git a/sample/meta.json b/sample/meta.json
index f1b193f..397256f 100644
--- a/sample/meta.json
+++ b/sample/meta.json
@@ -1,4 +1,4 @@
 {
-  "site_name": "Sample Site",
+  "site_name": "Loa Sample Site",
   "tagline": "Who samples the samplers?"
 }
diff --git a/sample/second/index.html b/sample/second/index.html
index 00f7070..ead3b4f 100644
--- a/sample/second/index.html
+++ b/sample/second/index.html
@@ -11,14 +11,13 @@
 " />
 
     <link rel="stylesheet" href="/_loa/style.css">
+    <script src="/_loa/script.js"></script>
   </head>
   <body>
-    <header>
-      <div id="breadcrumbs">
-        /<a href="/">home</a>/<span>Second post</span>      </div>
-    </header>
-
-    <main><p>Look ma, second post!</p>
+    <main>
+<a href="/">« back to home</a>
+<h1 style="margin-bottom:0">Second post</h1><time class="post--published-at relative" datetime="2025-10-11T09:04:40+07:00" title="2025-10-11T09:04:40+07:00">2025-10-11T09:04:40+07:00</time>
+<p>Look ma, second post!</p>
     </main>
   </body>
 </html>
diff --git a/src/Meta.zig b/src/Meta.zig
new file mode 100644
index 0000000..1bead20
--- /dev/null
+++ b/src/Meta.zig
@@ -0,0 +1,17 @@
+const std = @import("std");
+const fs = std.fs;
+const json = std.json;
+const mem = std.mem;
+
+const Meta = @This();
+
+site_name: []const u8,
+tagline: []const u8,
+
+pub fn load(arena: mem.Allocator, dir: fs.Dir) !Meta {
+    const meta_text = try dir.readFileAlloc(arena, "meta.json", 1024 * 16);
+
+    return try json.parseFromSliceLeaky(Meta, arena, meta_text, .{
+        .allocate = .alloc_if_needed,
+    });
+}
diff --git a/src/Post.zig b/src/Post.zig
index ededf4c..748324e 100644
--- a/src/Post.zig
+++ b/src/Post.zig
@@ -1,17 +1,21 @@
 const std = @import("std");
 const mem = std.mem;
+const fs = std.fs;
 const println = @import("utils.zig").println;
 const Djot = @import("Djot.zig");
 
 pub const Post = @This();
 
+slug: []const u8,
 title: []const u8 = "",
 published_at: []const u8 = "",
 djot_text: []const u8 = "",
 
-pub fn init(gpa: mem.Allocator, input: []const u8) !Post {
+pub fn init(gpa: mem.Allocator, input: []const u8, file_path: []const u8) !Post {
     const fence = "```";
-    var post = Post{};
+    var post = Post{
+        .slug = try gpa.dupe(u8, fs.path.dirname(file_path).?),
+    };
 
     var lines = mem.splitScalar(u8, input, '\n');
     var idx: usize = 0;
@@ -41,7 +45,12 @@ pub fn init(gpa: mem.Allocator, input: []const u8) !Post {
 }
 
 pub fn deinit(self: Post, gpa: mem.Allocator) void {
+    gpa.free(self.slug);
     gpa.free(self.title);
     gpa.free(self.published_at);
     gpa.free(self.djot_text);
 }
+
+pub fn newestFirst(_: void, lhs: Post, rhs: Post) bool {
+    return mem.lessThan(u8, rhs.published_at, lhs.published_at);
+}
diff --git a/src/main.zig b/src/main.zig
index 723db91..9c6fdd3 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -7,6 +7,8 @@ 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");
 
 pub fn main() !u8 {
     var target_dir: [*:0]const u8 = undefined;
@@ -41,6 +43,10 @@ pub fn entrypoint(target_dir: [*:0]const u8) !void {
             .sub_path = "style.css",
             .data = @embedFile("static/style.css"),
         });
+        try static_dir.writeFile(.{
+            .sub_path = "script.js",
+            .data = @embedFile("static/script.js"),
+        });
     }
 
     const djot = try Djot.init(gpa);
@@ -61,6 +67,7 @@ pub fn entrypoint(target_dir: [*:0]const u8) !void {
     defer post_arena_state.deinit();
     const post_arena = post_arena_state.allocator();
 
+    // Collect posts and generate their respective pages one after another.
     var djot_buf: [1024 * 16]u8 = undefined;
     var writer_buf: [1024 * 4]u8 = undefined;
     while (try walker.next()) |entry| {
@@ -69,7 +76,7 @@ pub fn entrypoint(target_dir: [*:0]const u8) !void {
         if (entry.kind == .file and mem.eql(u8, entry.basename, "index.dj")) {
             const post_text = try entry.dir.readFile(entry.basename, &djot_buf);
 
-            const p = try Post.init(gpa, post_text);
+            const p = try Post.init(gpa, post_text, entry.path);
             try posts.append(gpa, p);
 
             var html_file = try entry.dir.createFile("index.html", .{});
@@ -84,19 +91,93 @@ pub fn entrypoint(target_dir: [*:0]const u8) !void {
                     .title = p.title,
                     .description = p.djot_text[0..@min(p.djot_text.len, 256)],
                 },
-                .breadcrumbs = &.{
-                    .{ .text = "home", .href = "/" },
-                    .{ .text = p.title },
-                },
             });
+
+            try writer.writeAll(
+                \\<a href="/">« back to home</a>
+                \\
+            );
+
+            try writer.writeAll("<h1 style=\"margin-bottom:0\">");
+            try html.escape(writer, p.title);
+            try writer.writeAll("</h1>");
+            try writer.print(
+                \\<time class="post--published-at relative" datetime="{0s}" title="{0s}">{0s}</time>
+                \\
+            , .{
+                try html.escapeAlloc(post_arena, p.published_at),
+            });
+
             try djot.writeHtml(&html_writer.interface, p.djot_text);
-            try templates.base_end(writer);
 
-            try html_writer.interface.flush();
+            try templates.base_end(writer);
+            try writer.flush();
         }
     }
-
     println("Found {d} posts", .{posts.items.len});
+
+    // Sort posts by latest first
+    mem.sortUnstable(Post, posts.items, {}, Post.newestFirst);
+
+    // Generate home page
+    {
+        defer _ = post_arena_state.reset(.retain_capacity);
+
+        const meta = try Meta.load(post_arena, dir);
+
+        var home_file = try dir.createFile("index.html", .{});
+        defer home_file.close();
+
+        var home_buf: [4096]u8 = undefined;
+        var file_writer = home_file.writer(&home_buf);
+        const writer = &file_writer.interface;
+
+        try templates.base_start(post_arena, writer, .{
+            .opengraph = .{
+                .title = "loa",
+                .description = "shouting into the void",
+            },
+        });
+
+        try writer.writeAll(
+            \\<h1 style="margin-bottom:0">
+            \\
+        );
+        try html.escape(writer, meta.site_name);
+        try writer.writeAll(
+            \\</h1>
+            \\
+        );
+
+        try writer.writeAll("<div class=\"tagline\">");
+        try html.escape(writer, meta.tagline);
+        try writer.writeAll("</div>\n");
+
+        try writer.writeAll(
+            \\<ul class="home--post-list">
+            \\
+        );
+        for (posts.items) |p| {
+            try writer.print(
+                \\<li>
+                \\  <a href="/{0s}/">{1s}</a>
+                \\  <time class="home--post-published-at relative" datetime="{2s}" title="{2s}">{2s}</time>
+                \\</li>
+                \\
+            , .{
+                try html.escapeAlloc(post_arena, p.slug),
+                try html.escapeAlloc(post_arena, p.title),
+                try html.escapeAlloc(post_arena, p.published_at),
+            });
+        }
+        try writer.writeAll(
+            \\</ul>
+            \\
+        );
+
+        try templates.base_end(writer);
+        try writer.flush();
+    }
 }
 
 test {
diff --git a/src/static/script.js b/src/static/script.js
new file mode 100644
index 0000000..c998953
--- /dev/null
+++ b/src/static/script.js
@@ -0,0 +1,64 @@
+window.addEventListener("load", () => {
+  enhanceTimeElements();
+});
+
+// Replace ISO datetime strings with momentjs-style relative text e.g. "20
+// minutes ago", "2 weeks ago"...
+function enhanceTimeElements() {
+  const millisecondsPerSecond = 1000;
+  const secondsPerMinute = 60;
+  const minutesPerHour = 60;
+  const hoursPerDay = 24;
+  const daysPerWeek = 7;
+  const daysPerMonth = 31;
+  const daysPerYear = 365; // getting imprecise here, but no big deal
+  const intervals = {
+    year:
+      millisecondsPerSecond *
+      secondsPerMinute *
+      minutesPerHour *
+      hoursPerDay *
+      daysPerYear,
+    month:
+      millisecondsPerSecond *
+      secondsPerMinute *
+      minutesPerHour *
+      hoursPerDay *
+      daysPerMonth,
+    week:
+      millisecondsPerSecond *
+      secondsPerMinute *
+      minutesPerHour *
+      hoursPerDay *
+      daysPerWeek,
+    day:
+      millisecondsPerSecond * secondsPerMinute * minutesPerHour * hoursPerDay,
+    hour: millisecondsPerSecond * secondsPerMinute * minutesPerHour,
+    minute: millisecondsPerSecond * secondsPerMinute,
+    second: millisecondsPerSecond,
+  };
+  const relativeDateFormat = new Intl.RelativeTimeFormat("en", {
+    style: "long",
+  });
+
+  // https://stackoverflow.com/a/78704662
+  function formatRelativeTime(isoStr) {
+    const diff = new Date(isoStr) - new Date();
+    for (const interval in intervals) {
+      if (intervals[interval] <= Math.abs(diff)) {
+        return relativeDateFormat.format(
+          Math.trunc(diff / intervals[interval]),
+          interval,
+        );
+      }
+    }
+    return relativeDateFormat.format(diff / 1000, "second");
+  }
+
+  document.querySelectorAll("time.relative").forEach((element) => {
+    const timeStr = element.getAttribute("datetime");
+    if (!timeStr) return;
+    const relativeTimeStr = formatRelativeTime(timeStr);
+    element.innerHTML = relativeTimeStr;
+  });
+}
diff --git a/src/static/style.css b/src/static/style.css
index f6a587c..27de5b0 100644
--- a/src/static/style.css
+++ b/src/static/style.css
@@ -82,3 +82,9 @@ p + ul {
     --base-padding: 0.5rem 0.7rem;
   }
 }
+
+.home--post-published-at {
+  opacity: 0.5;
+  margin-left: 0.5rem;
+  font-variant-caps: small-caps;
+}
diff --git a/src/templates.zig b/src/templates.zig
index 359666d..889e8a5 100644
--- a/src/templates.zig
+++ b/src/templates.zig
@@ -5,16 +5,10 @@ const fmt = std.fmt;
 
 const html = @import("html.zig");
 
-pub const Crumb = struct {
-    href: ?[]const u8 = null,
-    text: []const u8,
-};
-
 // Remember to close with base_end()
 pub fn base_start(arena: mem.Allocator, writer: *Io.Writer, args: struct {
     site_name: []const u8 = "loa",
     title: ?[]const u8 = null,
-    breadcrumbs: []const Crumb,
     opengraph: struct {
         title: []const u8,
         description: []const u8,
@@ -33,11 +27,11 @@ pub fn base_start(arena: mem.Allocator, writer: *Io.Writer, args: struct {
         \\    <meta property="og:description" content="{3s}" />
         \\
         \\    <link rel="stylesheet" href="/_loa/style.css">
+        \\    <script src="/_loa/script.js"></script>
         \\  </head>
         \\  <body>
-        \\    <header>
-        \\      <div id="breadcrumbs">
-        \\        /
+        \\    <main>
+        \\
     , .{
         if (args.title) |title|
             try html.escapeAlloc(
@@ -50,26 +44,6 @@ pub fn base_start(arena: mem.Allocator, writer: *Io.Writer, args: struct {
         try html.escapeAlloc(arena, args.opengraph.title),
         try html.escapeAlloc(arena, args.opengraph.description),
     });
-
-    for (args.breadcrumbs) |crumb| {
-        const escaped_text = try html.escapeAlloc(arena, crumb.text);
-        if (crumb.href) |href| {
-            try writer.print(
-                \\<a href="{0s}">{1s}</a>/
-            , .{ href, escaped_text });
-        } else {
-            try writer.print(
-                \\<span>{0s}</span>
-            , .{escaped_text});
-        }
-    }
-
-    try writer.writeAll(
-        \\      </div>
-        \\    </header>
-        \\
-        \\    <main>
-    );
 }
 
 pub fn base_end(writer: *Io.Writer) !void {