From 7d0ddb521152bce020c6ffb68bca94c86fab5747 Mon Sep 17 00:00:00 2001
From: tri <tri@thac.loan>
Date: Mon, 29 Sep 2025 14:46:59 +0700
Subject: [PATCH] render markdown readmes using cmark

---
 README.md            | 33 ++++++++++++++++++++-----------
 src/assets/style.css | 47 ++++++++++++++++++++++++++++++++++++++++----
 src/main.zig         | 43 ++++++++++++++++++++++++++--------------
 src/markdown.zig     | 36 +++++++++++++++++++++++++++++++++
 4 files changed, 128 insertions(+), 31 deletions(-)
 create mode 100644 src/markdown.zig

diff --git a/README.md b/README.md
index 704ad82..1faff7e 100644
--- a/README.md
+++ b/README.md
@@ -1,43 +1,52 @@
-# Khoe
-
-is Vietnamese for "showing off", or "boasting".
+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 code without the security headaches of hosting a dynamic web service, you may like `khoe`.
+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.
+We use zig master, which is 0.16.0-dev.393+dd4be26f5 at the time of writing:
 
 ```sh
 zig build -Doptimize=ReleaseSafe
 ```
 
-Runtime dependencies:
+Runtime dependencies (khoe shells out to these commands at runtime):
 
-- `git`
+- git
+- cmark
 
 ## Use
 
-Assuming all of your repos (both normal and bare repos work fine) reside in `/srv/git/repos`, run
+Assuming all of your repos (both normal and bare repos work fine) reside in `/srv/git/repos`, run:
 
 ```sh
-khoe /srv/git/repos
+# replace 2nd arg with the domain you'll host your site on
+khoe /srv/git/repos http://localhost:8000
 ```
 
-Said 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.
+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.
+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.
 
 ## Quirks
 
-Nothing is cached. Everything page is regenerated every time. 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.
+Nothing is cached. Every page is regenerated every time. 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.
 
 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.
 
+## Contribute
+
+See <https://www.chiark.greenend.org.uk/~sgtatham/quasiblog/git-no-forge/#preferences>. I generally agree with his preferences.
+
 ## License
 
 Copyright © 2025 tri@thac.loan
diff --git a/src/assets/style.css b/src/assets/style.css
index ecf6899..b76ae75 100644
--- a/src/assets/style.css
+++ b/src/assets/style.css
@@ -22,7 +22,7 @@ pre {
   font-size: 1.2rem;
 }
 
-h1 {
+header h1 {
   font-size: inherit;
   font-weight: normal;
   display: inline-block;
@@ -73,11 +73,50 @@ pre {
   overflow-x: auto;
   max-width: 60rem;
 }
-
-.readme-container {
-}
 .readme-content {
+}
+pre.readme-content {
+  max-width: 60rem;
+}
+div.readme-content {
   margin: 0;
+  max-width: 50rem;
+  padding-top: 0;
+  background-color: transparent;
+
+  h1,
+  h2,
+  h3,
+  h4,
+  h5,
+  h6 {
+    line-height: 1;
+    margin-top: 2rem;
+  }
+
+  p {
+    line-height: 1.4;
+    margin-top: 1.5rem;
+  }
+
+  ul p {
+    margin-top: 0.5rem;
+    margin-bottom: 0.2rem;
+  }
+
+  code {
+    border: 1px solid var(--pre-border-color);
+    border-radius: 4px;
+    padding: 0 5px;
+  }
+  pre {
+    background-color: var(--pre-bg);
+    padding: 1rem;
+    code {
+      border: none;
+      padding: 0;
+    }
+  }
 }
 .readme-content,
 summary {
diff --git a/src/main.zig b/src/main.zig
index f92340d..9300a94 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -1,9 +1,11 @@
 const std = @import("std");
 const fs = std.fs;
 const mem = std.mem;
+const ascii = std.ascii;
 const println = @import("utils.zig").println;
 const git = @import("git.zig");
 const html = @import("html.zig");
+const markdown = @import("markdown.zig");
 
 const web_path = "_";
 const assets_path = web_path ++ "/_khoe-hang";
@@ -265,30 +267,40 @@ pub fn writeRepoPage(args: RepoArgs) !void {
     // If there's a readme file, include it in repo index:
     if (try git.findReadme(arena, in_repo_dir)) |readme_path| {
         //println("   {s}", .{readme_path});
-        const readme_content = try git.readFileAlloc(arena, in_repo_dir, readme_path);
+        const readme_text = try git.readFileAlloc(arena, in_repo_dir, readme_path);
+        const is_markdown = (ascii.endsWithIgnoreCase(readme_path, ".md") or
+            ascii.endsWithIgnoreCase(readme_path, ".markdown"));
 
         try writer.interface.print(
             \\<details class="readme-container">
             \\  <summary>{0s}</summary>
-            \\  <pre class="readme-content {1s}">
         ,
-            .{
-                readme_path,
-                if (std.ascii.endsWithIgnoreCase(readme_path, ".md") or
-                    std.ascii.endsWithIgnoreCase(readme_path, ".markdown"))
-                    "pre-wrap"
-                else
-                    "",
-            },
+            .{readme_path},
         );
 
-        try html.escape(
-            &writer.interface,
-            mem.trimEnd(u8, readme_content, "\n"),
-        );
+        if (is_markdown) {
+            const readme_html = try markdown.toHtml(arena, readme_text);
+            try writer.interface.writeAll(
+                \\  <div class="readme-content">
+            );
+            try writer.interface.writeAll(readme_html);
+            try writer.interface.writeAll(
+                \\  </div>
+            );
+        } else {
+            try writer.interface.writeAll(
+                \\  <pre class="readme-content pre-wrap">
+            );
+            try html.escape(
+                &writer.interface,
+                mem.trimEnd(u8, readme_text, "\n"),
+            );
+            try writer.interface.writeAll(
+                \\  </pre>
+            );
+        }
 
         try writer.interface.print(
-            \\  </pre>
             \\</details>
         , .{});
     }
@@ -348,5 +360,6 @@ pub fn writeRepoPage(args: RepoArgs) !void {
 
 test "all" {
     _ = @import("html.zig");
+    _ = @import("markdown.zig");
     std.testing.refAllDeclsRecursive(@This());
 }
diff --git a/src/markdown.zig b/src/markdown.zig
new file mode 100644
index 0000000..070c140
--- /dev/null
+++ b/src/markdown.zig
@@ -0,0 +1,36 @@
+const std = @import("std");
+const mem = std.mem;
+const t = std.testing;
+
+pub fn toHtml(arena: mem.Allocator, text: []const u8) ![]const u8 {
+    var child = std.process.Child.init(&.{"cmark"}, arena);
+    child.stdin_behavior = .Pipe;
+    child.stdout_behavior = .Pipe;
+    child.stderr_behavior = .Pipe;
+
+    var stdout: std.ArrayList(u8) = .empty;
+    var stderr: std.ArrayList(u8) = .empty;
+
+    try child.spawn();
+    errdefer {
+        _ = child.kill() catch {};
+    }
+    try child.stdin.?.writeAll(text);
+    child.stdin.?.close();
+    child.stdin = null;
+    try child.collectOutput(arena, &stdout, &stderr, 64 * 1024);
+    _ = try child.wait();
+
+    return stdout.items;
+}
+
+test "toHtml" {
+    var arena_impl = std.heap.ArenaAllocator.init(t.allocator_instance.allocator());
+    defer arena_impl.deinit();
+    const arena = arena_impl.allocator();
+
+    try t.expectEqualStrings(
+        "<h1>foo</h1>\n",
+        try toHtml(arena, "# foo"),
+    );
+}
-- 
2.47.3

