download patch
commit 952254de772ae5a290da71f869e93173a5071d80
Author: tri <tri@thac.loan>
Date: Sat Oct 11 12:59:49 2025 +0700
basic dark/light styling
diff --git a/Makefile b/Makefile
index 5e36e86..a359cf5 100644
--- a/Makefile
+++ b/Makefile
test:
zig build test --watch
+serve:
+ python -m http.server -b 127.0.0.1 -d sample 8000
+
clean:
rm -rf .zig-cache zig-out
diff --git a/sample/_loa/style.css b/sample/_loa/style.css
new file mode 100644
index 0000000..f4bf9d7
--- /dev/null
+++ b/sample/_loa/style.css
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ --font-family: "Noto Serif", serif;
+ --font-weight: 400;
+ --font-family-mono: "Noto Sans Mono", monospace;
+ --bg: white;
+ --fg: black;
+ --pre-bg: #f2f2f2;
+ --code-fg: darkred;
+ --base-padding: 0.7rem 1rem;
+
+ font-family: var(--font-family);
+ font-size: 100%;
+ font-weight: var(--font-weight);
+}
+
+body {
+ max-width: 50rem;
+ margin: auto;
+ padding: var(--base-padding);
+}
+
+body {
+ background-color: var(--bg);
+ color: var(--fg);
+}
+
+pre,
+code {
+ font-family: var(--font-family-mono);
+ font-size: 0.95rem;
+}
+
+pre {
+ padding: var(--base-padding);
+ background-color: var(--pre-bg);
+ max-width: 100%;
+ overflow-x: auto;
+}
+
+p code,
+ul code {
+ color: var(--code-fg);
+}
+
+p,
+ul {
+ line-height: 1.5;
+ margin: 1.5rem 0;
+}
+
+p + ul {
+ margin-top: -1rem;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --font-family: "Noto Sans", sans-serif;
+ --font-weight: 400;
+ --bg: black;
+ --fg: cornsilk;
+ --pre-bg: #333;
+ --code-fg: burlywood;
+ }
+
+ a {
+ color: cornflowerblue;
+ }
+ a:visited {
+ color: violet;
+ }
+}
+
+@media screen and (max-width: 600px) {
+ :root {
+ --base-padding: 0.5rem 0.7rem;
+ }
+}
diff --git a/sample/first/index.dj b/sample/first/index.dj
index 0e7f140..14b48e7 100644
--- a/sample/first/index.dj
+++ b/sample/first/index.dj
title: First
published_at: 2025-10-09T21:13:03+07:00
```
-## First!
+khoe (Vietnamese, verb): to show something off
-Hello _friend_.
+## What
+
+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 service, you may like khoe.
+
+## Build
+
+We use zig master, which is 0.16.0-dev.393+dd4be26f5 at the time of writing:
+
+```sh
+# khoe is hosted on khoe
+git clone https://khoe.thac.loan/khoe
+cd khoe
+zig build -Doptimize=ReleaseSafe
+```
+
+Runtime dependencies (khoe shells out to these commands at runtime):
+
+- git
+- [cmark][6]
+
+## Use
+
+Assuming all of your repos (both normal and bare repos work fine) reside in `/srv/git/repos`, run:
+
+```sh
+# replace 2nd arg with the domain you'll host your site on
+khoe /srv/git/repos http://localhost:8000
+```
+
+Khoe will create 2 things inside this dir:
+
+- `index.html`: the home page
+- `_` (underscore): the directory that stores the rest of the generated html
+
+This `repos` dir is now serveable as a static website. For a quick preview, try `python3 -m http.server -b localhost -d /srv/git/repos`. People can `git clone` repos from this site too.
+
+Please *make sure there's nothing sensitive in your repos dir* before exposing it to the unwashed public. Git hooks, config, etc. are the usual suspects.
+
+You can now either serve the dir as-is using caddy/nginx/etc., or rsync it to a remote server, or serve it on s3 if you like burning your (employer's) money, or even, get this, make it a git repo itself to host on GitHub Pages for free! I'm not saying you should, but you could. Static web hosting is cheap, often free even. The world's your oyster.
+
+## Use as git hook
+
+Simplest way is to setup a global post-update hook like this:
+
+```sh
+flock /tmp/khoe khoe .. https://your.site
+```
+
+Using [flock][4] ensures that only 1 instance of the script could be running at a time, avoiding race conditions when multiple people could be pushing.
+
+## Quirks
+
+Nothing is cached. Every page is regenerated every time, except for those in /objects/ (which can also be forced to regenerate with `KHOE_FULL_REGEN=1`). Computers are fast though so performance hasn't been a problem for me. If it is for you, let me know and we can optimize it.
+
+Worse still, every repo is regenerated every time. This one I'll fix... sometime. Khoe should allow choosing a specific repo to regenerate so that it can run efficiently as a post-update git hook. See Roadmap.
+
+Outdated pages, if not overwritten, are left as-is. Since git itself is an append-only paradigm, deletion is rarely necessary. Security-wise, if you've accidentally pushed a secret on a public repo, you must consider it compromised forever and perform appropriate credential rotation and whatnot. Deleting the offending pages means nothing in this context. If it bothers you still, feel free to write a script to delete-before-generate, or generate-and-swap if you have lots of nines to maintain - you do you.
+
+## Roadmap
+
+- [ ] Add CLI arg to choose which repo to regen
+- [x] Browse tree at HEAD
+- [x] Browse commits from main history
+
+## Contribute
+
+See [How to interact with a bare git repo][5]. I generally agree with his preferences.
+
+## License
+
+Copyright © 2025 tri@thac.loan
+
+This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License version 3 as published by the Free Software Foundation.
+
+This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+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://codemadness.org/stagit.html
+[2]: https://mitchellh.com/writing/libghostty-is-coming
+[3]: https://github.com/theZiz/aha
+[4]: https://manpages.debian.org/trixie/util-linux/flock.1.en.html
+[5]: https://www.chiark.greenend.org.uk/~sgtatham/quasiblog/git-no-forge/#submissions
+[6]: https://github.com/commonmark/cmark
diff --git a/sample/first/index.html b/sample/first/index.html
index af32d2e..fee8c0c 100644
--- a/sample/first/index.html
+++ b/sample/first/index.html
-<section id="First">
-<h2>First!</h2>
-<p>Hello <em>friend</em>.</p>
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <title>First | 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="First" />
+ <meta property="og:description" content="khoe (Vietnamese, verb): to show something off
+
+## What
+
+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">
+ </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>
+<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>
</section>
+<section id="Build">
+<h2>Build</h2>
+<p>We use zig master, which is 0.16.0-dev.393+dd4be26f5 at the time of writing:</p>
+<pre><code class="language-sh"># khoe is hosted on khoe
+git clone https://khoe.thac.loan/khoe
+cd khoe
+zig build -Doptimize=ReleaseSafe
+</code></pre>
+<p>Runtime dependencies (khoe shells out to these commands at runtime):</p>
+<ul>
+<li>
+git
+</li>
+<li>
+<a href="https://github.com/commonmark/cmark">cmark</a>
+</li>
+</ul>
+</section>
+<section id="Use">
+<h2>Use</h2>
+<p>Assuming all of your repos (both normal and bare repos work fine) reside in <code>/srv/git/repos</code>, run:</p>
+<pre><code class="language-sh"># replace 2nd arg with the domain you'll host your site on
+khoe /srv/git/repos http://localhost:8000
+</code></pre>
+<p>Khoe will create 2 things inside this dir:</p>
+<ul>
+<li>
+<code>index.html</code>: the home page
+</li>
+<li>
+<code>_</code> (underscore): the directory that stores the rest of the generated html
+</li>
+</ul>
+<p>This <code>repos</code> dir is now serveable as a static website. For a quick preview, try <code>python3 -m http.server -b localhost -d /srv/git/repos</code>. People can <code>git clone</code> repos from this site too.</p>
+<p>Please <strong>make sure there’s nothing sensitive in your repos dir</strong> before exposing it to the unwashed public. Git hooks, config, etc. are the usual suspects.</p>
+<p>You can now either serve the dir as-is using caddy/nginx/etc., or rsync it to a remote server, or serve it on s3 if you like burning your (employer’s) money, or even, get this, make it a git repo itself to host on GitHub Pages for free! I’m not saying you should, but you could. Static web hosting is cheap, often free even. The world’s your oyster.</p>
+</section>
+<section id="Use-as-git-hook">
+<h2>Use as git hook</h2>
+<p>Simplest way is to setup a global post-update hook like this:</p>
+<pre><code class="language-sh">flock /tmp/khoe khoe .. https://your.site
+</code></pre>
+<p>Using <a href="https://manpages.debian.org/trixie/util-linux/flock.1.en.html">flock</a> ensures that only 1 instance of the script could be running at a time, avoiding race conditions when multiple people could be pushing.</p>
+</section>
+<section id="Quirks">
+<h2>Quirks</h2>
+<p>Nothing is cached. Every page is regenerated every time, except for those in /objects/ (which can also be forced to regenerate with <code>KHOE_FULL_REGEN=1</code>). Computers are fast though so performance hasn’t been a problem for me. If it is for you, let me know and we can optimize it.</p>
+<p>Worse still, every repo is regenerated every time. This one I’ll fix… sometime. Khoe should allow choosing a specific repo to regenerate so that it can run efficiently as a post-update git hook. See Roadmap.</p>
+<p>Outdated pages, if not overwritten, are left as-is. Since git itself is an append-only paradigm, deletion is rarely necessary. Security-wise, if you’ve accidentally pushed a secret on a public repo, you must consider it compromised forever and perform appropriate credential rotation and whatnot. Deleting the offending pages means nothing in this context. If it bothers you still, feel free to write a script to delete-before-generate, or generate-and-swap if you have lots of nines to maintain - you do you.</p>
+</section>
+<section id="Roadmap">
+<h2>Roadmap</h2>
+<ul class="task-list">
+<li class="unchecked">
+Add CLI arg to choose which repo to regen
+</li>
+<li class="checked">
+Browse tree at HEAD
+</li>
+<li class="checked">
+Browse commits from main history
+</li>
+</ul>
+</section>
+<section id="Contribute">
+<h2>Contribute</h2>
+<p>See <a href="https://www.chiark.greenend.org.uk/~sgtatham/quasiblog/git-no-forge/#submissions">How to interact with a bare git repo</a>. I generally agree with his preferences.</p>
+</section>
+<section id="License">
+<h2>License</h2>
+<p>Copyright © 2025 tri@thac.loan</p>
+<p>This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License version 3 as published by the Free Software Foundation.</p>
+<p>This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.</p>
+<p>You should have received a copy of the GNU Affero General Public License along with this program (agpl-3.0.txt). If not, see <a href="https://www.gnu.org/licenses/">https://www.gnu.org/licenses/</a>.</p>
+</section>
+ </main>
+ </body>
+</html>
diff --git a/sample/second/index.html b/sample/second/index.html
index 21750bd..00f7070 100644
--- a/sample/second/index.html
+++ b/sample/second/index.html
-<p>Look ma, second post!</p>
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <title>Second post | 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="Second post" />
+ <meta property="og:description" content="Look ma, second post!
+" />
+
+ <link rel="stylesheet" href="/_loa/style.css">
+ </head>
+ <body>
+ <header>
+ <div id="breadcrumbs">
+ /<a href="/">home</a>/<span>Second post</span> </div>
+ </header>
+
+ <main><p>Look ma, second post!</p>
+ </main>
+ </body>
+</html>
diff --git a/src/Post.zig b/src/Post.zig
index 09bca06..ededf4c 100644
--- a/src/Post.zig
+++ b/src/Post.zig
pub fn init(gpa: mem.Allocator, input: []const u8) !Post {
}
}
- post.djot_text = try gpa.dupe(u8, input[idx..]);
+ post.djot_text = try gpa.dupe(u8, mem.trimLeft(u8, input[idx..], "\n\t "));
return post;
}
diff --git a/src/html.zig b/src/html.zig
new file mode 100644
index 0000000..d0c18f2
--- /dev/null
+++ b/src/html.zig
+const std = @import("std");
+const t = std.testing;
+
+pub fn escapeAlloc(arena: std.mem.Allocator, text: []const u8) ![]const u8 {
+ var w = std.Io.Writer.Allocating.init(arena);
+ try escape(&w.writer, text);
+ return w.written();
+}
+
+pub fn escape(out: *std.Io.Writer, text: []const u8) !void {
+ for (text) |char| {
+ switch (char) {
+ '&' => try out.writeAll("&"),
+ '"' => try out.writeAll("""),
+ '\'' => try out.writeAll("'"),
+ '<' => try out.writeAll("<"),
+ '>' => try out.writeAll(">"),
+ else => try out.writeByte(char),
+ }
+ }
+}
+
+test "escape" {
+ var buf: [4096]u8 = undefined;
+ inline for (.{
+ .{
+ "<script>alert(\"jello\")</script>",
+ "<script>alert("jello")</script>",
+ },
+ .{
+ "&\"'<>",
+ "&"'<>",
+ },
+ }) |case| {
+ const input = case[0];
+ const expected = case[1];
+ var w = std.Io.Writer.fixed(&buf);
+ try escape(&w, input);
+ try t.expectEqualStrings(expected, w.buffered());
+ }
+}
+
+pub fn percentEncode(out: *std.Io.Writer, text: []const u8) !void {
+ for (text) |char| {
+ switch (char) {
+ ':' => try out.writeAll("%3A"),
+ '/' => try out.writeAll("%2F"),
+ '?' => try out.writeAll("%3F"),
+ '#' => try out.writeAll("%23"),
+ '[' => try out.writeAll("%5B"),
+ ']' => try out.writeAll("%5D"),
+ '@' => try out.writeAll("%40"),
+ '!' => try out.writeAll("%21"),
+ '$' => try out.writeAll("%24"),
+ '&' => try out.writeAll("%26"),
+ '\'' => try out.writeAll("%27"),
+ '(' => try out.writeAll("%28"),
+ ')' => try out.writeAll("%29"),
+ '*' => try out.writeAll("%2A"),
+ '+' => try out.writeAll("%2B"),
+ ',' => try out.writeAll("%2C"),
+ ';' => try out.writeAll("%3B"),
+ '=' => try out.writeAll("%3D"),
+ '%' => try out.writeAll("%25"),
+ ' ' => try out.writeAll("%20"),
+ else => try out.writeByte(char),
+ }
+ }
+}
+
+pub fn percentEncodeAlloc(arena: std.mem.Allocator, text: []const u8) ![]const u8 {
+ var w = std.Io.Writer.Allocating.init(arena);
+ try percentEncode(&w.writer, text);
+ return w.written();
+}
diff --git a/src/main.zig b/src/main.zig
index 1347cf5..723db91 100644
--- a/src/main.zig
+++ b/src/main.zig
const mem = std.mem;
const println = @import("utils.zig").println;
const Djot = @import("Djot.zig");
const Post = @import("Post.zig");
+const templates = @import("templates.zig");
pub fn main() !u8 {
var target_dir: [*:0]const u8 = undefined;
pub fn entrypoint(target_dir: [*:0]const u8) !void {
const gpa = gpa_state.allocator();
defer _ = gpa_state.deinit();
- const djot = try Djot.init(gpa);
- defer djot.deinit();
-
var dir = try fs.cwd().openDirZ(target_dir, .{ .iterate = true });
defer dir.close();
+ // Write static file(s)
+ {
+ var static_dir = try dir.makeOpenPath("_loa", .{});
+ defer static_dir.close();
+
+ try static_dir.writeFile(.{
+ .sub_path = "style.css",
+ .data = @embedFile("static/style.css"),
+ });
+ }
+
+ const djot = try Djot.init(gpa);
+ defer djot.deinit();
+
var walker = try dir.walk(gpa);
defer walker.deinit();
pub fn entrypoint(target_dir: [*:0]const u8) !void {
posts.deinit(gpa);
}
+ var post_arena_state = std.heap.ArenaAllocator.init(gpa);
+ defer post_arena_state.deinit();
+ const post_arena = post_arena_state.allocator();
+
var djot_buf: [1024 * 16]u8 = undefined;
var writer_buf: [1024 * 4]u8 = undefined;
while (try walker.next()) |entry| {
+ defer _ = post_arena_state.reset(.retain_capacity);
+
if (entry.kind == .file and mem.eql(u8, entry.basename, "index.dj")) {
- const djot_text = try entry.dir.readFile(entry.basename, &djot_buf);
+ const post_text = try entry.dir.readFile(entry.basename, &djot_buf);
- const p = try Post.init(gpa, djot_text);
+ const p = try Post.init(gpa, post_text);
try posts.append(gpa, p);
var html_file = try entry.dir.createFile("index.html", .{});
defer html_file.close();
var html_writer = html_file.writer(&writer_buf);
+ const writer = &html_writer.interface;
+
+ try templates.base_start(post_arena, writer, .{
+ .title = p.title,
+ .opengraph = .{
+ .title = p.title,
+ .description = p.djot_text[0..@min(p.djot_text.len, 256)],
+ },
+ .breadcrumbs = &.{
+ .{ .text = "home", .href = "/" },
+ .{ .text = p.title },
+ },
+ });
try djot.writeHtml(&html_writer.interface, p.djot_text);
+ try templates.base_end(writer);
+
try html_writer.interface.flush();
}
}
diff --git a/src/static/style.css b/src/static/style.css
new file mode 100644
index 0000000..f6a587c
--- /dev/null
+++ b/src/static/style.css
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ --font-family: "Noto Serif", serif;
+ --font-weight: 400;
+ --font-family-mono: "Noto Sans Mono", monospace;
+ --bg: white;
+ --fg: black;
+ --pre-bg: #f2f2f2;
+ --pre-border: none;
+ --code-fg: darkred;
+ --base-padding: 0.7rem 1rem;
+
+ font-family: var(--font-family);
+ font-size: 100%;
+ font-weight: var(--font-weight);
+}
+
+body {
+ max-width: 50rem;
+ margin: auto;
+ padding: var(--base-padding);
+}
+
+body {
+ background-color: var(--bg);
+ color: var(--fg);
+}
+
+pre,
+code {
+ font-family: var(--font-family-mono);
+ font-size: 0.95rem;
+}
+
+pre {
+ padding: var(--base-padding);
+ background-color: var(--pre-bg);
+ border: var(--pre-border);
+ max-width: 100%;
+ overflow-x: auto;
+}
+
+p code,
+ul code {
+ color: var(--code-fg);
+}
+
+p,
+ul {
+ line-height: 1.5;
+ margin: 1.5rem 0;
+}
+
+p + ul {
+ margin-top: -1rem;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --font-family: "Noto Sans", sans-serif;
+ --font-weight: 400;
+ --bg: black;
+ --fg: cornsilk;
+ --pre-bg: #333;
+ --pre-border: 2px solid dimgrey;
+ --code-fg: burlywood;
+ }
+
+ a {
+ color: cornflowerblue;
+ }
+ a:visited {
+ color: violet;
+ }
+}
+
+@media screen and (max-width: 600px) {
+ :root {
+ --base-padding: 0.5rem 0.7rem;
+ }
+}
diff --git a/src/templates.zig b/src/templates.zig
new file mode 100644
index 0000000..359666d
--- /dev/null
+++ b/src/templates.zig
+const std = @import("std");
+const mem = std.mem;
+const Io = std.Io;
+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,
+ },
+}) !void {
+ try writer.print(
+ \\<!doctype html>
+ \\<html lang="en">
+ \\ <head>
+ \\ <meta charset="utf-8" />
+ \\ <title>{0s}</title>
+ \\ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ \\
+ \\ <meta property="og:site_name" content="{1s}" />
+ \\ <meta property="og:title" content="{2s}" />
+ \\ <meta property="og:description" content="{3s}" />
+ \\
+ \\ <link rel="stylesheet" href="/_loa/style.css">
+ \\ </head>
+ \\ <body>
+ \\ <header>
+ \\ <div id="breadcrumbs">
+ \\ /
+ , .{
+ if (args.title) |title|
+ try html.escapeAlloc(
+ arena,
+ try fmt.allocPrint(arena, "{s} | {s}", .{ title, args.site_name }),
+ )
+ else
+ args.site_name,
+ args.site_name,
+ 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 {
+ try writer.writeAll(
+ \\ </main>
+ \\ </body>
+ \\</html>
+ \\
+ );
+}