download patch
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
+/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
+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
--bg: white;
--fg: black;
--pre-bg: #f2f2f2;
+ --pre-border: none;
--code-fg: darkred;
--base-padding: 0.7rem 1rem;
code {
pre {
padding: var(--base-padding);
background-color: var(--pre-bg);
+ border: var(--pre-border);
max-width: 100%;
overflow-x: auto;
}
p + ul {
--bg: black;
--fg: cornsilk;
--pre-bg: #333;
+ --pre-border: 2px solid dimgrey;
--code-fg: burlywood;
}
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
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 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’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
+<!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
{
- "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
" />
<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
+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
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;
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
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;
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);
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| {
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", .{});
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
+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
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
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,
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(
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 {