From 3c3dbff17f17e0297588da56a9006c2fae736dd5 Mon Sep 17 00:00:00 2001
From: tri <tri@thac.loan>
Date: Wed, 1 Oct 2025 22:12:49 +0700
Subject: [PATCH] generate preview pages for blob objects

Since we name each html file by their object's hash, the links are
stable. Object pages won't be overwritten in subsequent runs so that
performance doesn't suffer too much.

Also snuck in tiny updates:

- Zebra table rows
- Refactor repeated html into templates.zig
- Tidy up navigation links - use absolute paths instead of relative
---
 README.md            |   2 +-
 src/assets/style.css |  53 +++++-
 src/constants.zig    |   5 +
 src/git.zig          |  56 +++++++
 src/main.zig         | 388 +++++++++++++++++++++++--------------------
 src/templates.zig    | 177 ++++++++++++++++++++
 6 files changed, 493 insertions(+), 188 deletions(-)
 create mode 100644 src/constants.zig
 create mode 100644 src/templates.zig

diff --git a/README.md b/README.md
index 76ed89b..9658486 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,7 @@ Using [flock][4] ensures that only 1 instance of the script could be running at
 
 ## Quirks
 
-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.
+Nothing is cached. Every page is regenerated every time, except for those in /objects/. 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.
 
diff --git a/src/assets/style.css b/src/assets/style.css
index 085acf6..66979e7 100644
--- a/src/assets/style.css
+++ b/src/assets/style.css
@@ -6,10 +6,10 @@
   --bg: white;
   --fg: black;
 
-  --table-odd-row-bg: #eee;
   --table-border-color: #ddd;
   --table-row-hover-bg: papayawhip;
   --table-border-color: black;
+  --table-stripe-bg: #f5f5f5;
   --pre-bg: whitesmoke;
   --pre-border-color: gainsboro;
 
@@ -62,15 +62,30 @@ tr {
   border-top: 1px solid var(--table-border-color);
 }
 */
+tbody tr:nth-child(odd) {
+  background-color: var(--table-stripe-bg);
+}
 tbody tr:hover {
   background-color: var(--table-row-hover-bg);
 }
 td,
 th {
-  padding: 0.1rem 1rem;
+  padding-top: 0.1rem;
+  padding-bottom: 0.1rem;
+}
+td + td,
+th + th {
+  padding-left: 2rem;
+}
+td:first-child,
+th:first-child {
+  padding-left: 0.4rem;
+}
+td:last-child,
+th:last-child {
+  padding-right: 0.4rem;
 }
 table {
-  margin-left: -1rem;
   font-variant-numeric: tabular-nums;
 }
 
@@ -93,12 +108,9 @@ pre {
   margin: 0;
 }
 pre.readme-content {
-  max-width: 60rem;
-  padding: 1rem 0;
 }
 div.readme-content {
   max-width: 50rem;
-  padding-top: 0;
   background-color: transparent;
 
   h1,
@@ -173,9 +185,34 @@ div.readme-content {
 .repo-content {
   border: 1px solid var(--fg);
   margin-top: -1px; /* so nav item's bottom border overlaps its border */
-  padding: 0 1rem;
   width: fit-content;
   max-width: 100%;
+  overflow-x: auto;
+  padding: 1rem;
+
+  *:first-child {
+    margin-top: 0;
+  }
+  *:last-child {
+    margin-bottom: 0;
+  }
+}
+
+.blob-content {
+  padding: 1rem;
+  width: fit-content;
+  max-width: 100%;
+  overflow-x: auto;
+  border: 1px solid var(--table-border-color);
+}
+
+@media screen and (max-width: 600px) {
+  .repo-content {
+    padding: 0.5rem;
+  }
+  .blob-content {
+    padding: 0.5rem;
+  }
 }
 
 @media (prefers-color-scheme: dark) {
@@ -183,10 +220,10 @@ div.readme-content {
     --bg: #222;
     --fg: #ddd;
 
-    --table-odd-row-bg: #333;
     --table-border-color: #444;
     --table-row-hover-bg: #444;
     --table-border-color: #ddd;
+    --table-stripe-bg: #2a2a2a;
     --pre-bg: #444;
     --pre-border-color: #555;
 
diff --git a/src/constants.zig b/src/constants.zig
new file mode 100644
index 0000000..6869968
--- /dev/null
+++ b/src/constants.zig
@@ -0,0 +1,5 @@
+pub const web_path = "_";
+pub const web_assets_path = web_path ++ "/_khoe-hang";
+pub const web_readme_path = "readme";
+pub const web_commits_path = "commits";
+pub const web_objects_path = "objects";
diff --git a/src/git.zig b/src/git.zig
index a86f685..b8f227d 100644
--- a/src/git.zig
+++ b/src/git.zig
@@ -147,6 +147,7 @@ pub fn readFileAlloc(arena: mem.Allocator, dir: fs.Dir, file_name: []const u8) !
         },
     });
 
+    std.debug.assert(proc.term.Exited == 0);
     return proc.stdout;
 }
 
@@ -196,6 +197,8 @@ pub fn walkTree(arena: mem.Allocator, dir: fs.Dir) !Walker {
             "%(objectname) %(objectsize) %(path)",
         },
     });
+    //empty repo without any commits would exit with non-zero status...
+    //std.debug.assert(proc.term.Exited == 0);
     return Walker{ .remaining = proc.stdout };
 }
 
@@ -217,3 +220,56 @@ pub fn walkTree(arena: mem.Allocator, dir: fs.Dir) !Walker {
 //        (try walker.next()).?,
 //    );
 //}
+
+pub const ObjectType = enum { blob, commit, tree };
+
+pub fn objectType(arena: mem.Allocator, dir: fs.Dir, object_hash: []const u8) !ObjectType {
+    var proc = try std.process.Child.run(.{
+        .allocator = arena,
+        .cwd_dir = dir,
+        .max_output_bytes = 1024 * 1024 * 64,
+        .argv = &.{
+            "git",
+            "cat-file",
+            "-t",
+            object_hash,
+        },
+    });
+
+    const result = mem.trimEnd(u8, proc.stdout, "\n");
+    if (mem.eql(u8, result, "blob")) {
+        return .blob;
+    } else if (mem.eql(u8, result, "commit")) {
+        return .commit;
+    } else if (mem.eql(u8, result, "tree")) {
+        return .tree;
+    }
+
+    std.debug.panic("Unrecognized object type: \"{s}\" - {s}", .{ result, proc.stderr });
+}
+
+/// Replicates git's simple heuristic: if there's a null byte in the first 8k
+/// bytes, then consider the file binary.
+pub fn isBinary(content: []const u8) bool {
+    const first_few_bytes = 8000;
+    const haystack = content[0..@min(content.len, first_few_bytes)];
+    return mem.indexOfScalar(u8, haystack, '\x00') != null;
+}
+
+// TODO: instead of loading everything to memory, figure out how to stream
+// instead.
+pub fn catFile(arena: mem.Allocator, dir: fs.Dir, object_hash: []const u8) ![]const u8 {
+    var proc = try std.process.Child.run(.{
+        .allocator = arena,
+        .cwd_dir = dir,
+        .max_output_bytes = 1024 * 1024 * 64,
+        .argv = &.{
+            "git",
+            "cat-file",
+            "blob",
+            object_hash,
+        },
+    });
+    std.debug.assert(proc.term.Exited == 0);
+    return proc.stdout;
+}
diff --git a/src/main.zig b/src/main.zig
index 7ee1988..0a2965d 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -7,11 +7,8 @@ const println = utils.println;
 const git = @import("git.zig");
 const html = @import("html.zig");
 const markdown = @import("markdown.zig");
-
-const web_path = "_";
-const web_assets_path = web_path ++ "/_khoe-hang";
-const web_readme_path = "readme.html";
-const web_commits_path = "commits.html";
+const templates = @import("templates.zig");
+const constants = @import("constants.zig");
 
 const Mode = union(enum) {
     all: void,
@@ -68,7 +65,7 @@ pub fn main() !u8 {
     while (try dir_iter.next()) |entry| {
         defer _ = repo_arena_impl.reset(.retain_capacity);
 
-        if (entry.kind != .directory or mem.eql(u8, entry.name, web_path)) {
+        if (entry.kind != .directory or mem.eql(u8, entry.name, constants.web_path)) {
             continue;
         }
 
@@ -119,15 +116,13 @@ pub fn main() !u8 {
             .git_dir_path = git_dir_path,
             .git_dir = git_dir,
         });
-
-        // TODO: write repo's commits
     }
 
     std.mem.sortUnstable(RepoSummary, repo_summaries.items, {}, RepoSummary.lessThan);
 
     try writeHomePage(arena, target_dir, repo_summaries.items);
 
-    var assets_dir = try target_dir.makeOpenPath(web_assets_path, .{});
+    var assets_dir = try target_dir.makeOpenPath(constants.web_assets_path, .{});
     inline for (.{ "style.css", "script.js" }) |asset_name| {
         try assets_dir.writeFile(.{
             .data = @embedFile("assets/" ++ asset_name),
@@ -156,26 +151,19 @@ const RepoSummary = struct {
 // TODO: decide on some sort of templating system
 pub fn writeHomePage(arena: mem.Allocator, dir: fs.Dir, repos: []RepoSummary) !void {
     var file = try dir.createFile("index.html", .{});
+    errdefer dir.deleteFile("index.html") catch {};
     defer file.close();
 
     var buf: [1024 * 16]u8 = undefined;
-    var writer = file.writer(&buf);
-    try writer.interface.print(
-        \\<!doctype html>
-        \\<html lang="en">
-        \\  <head>
-        \\    <meta charset="utf-8" />
-        \\    <title>Khoe</title>
-        \\    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-        \\    <link rel="stylesheet" href="/_/_khoe-hang/style.css">
-        \\    <script src="/_/_khoe-hang/script.js"></script>
-        \\  </head>
-        \\  <body>
-        \\    <header>
-        \\      <div id="breadcrumbs">
-        \\        /<h1>repos</h1>/
-        \\      </div>
-        \\    </header>
+    var file_writer = file.writer(&buf);
+    const writer = &file_writer.interface;
+    try templates.base_start(
+        arena,
+        writer,
+        .{ .breadcrumbs = &.{.{ .text = "repos" }} },
+    );
+
+    try writer.print(
         \\    <p>listing <b>{d}</b> repos:</p>
         \\    <div style="overflow-x:auto; padding-bottom:1rem">
         \\      <table>
@@ -193,7 +181,7 @@ pub fn writeHomePage(arena: mem.Allocator, dir: fs.Dir, repos: []RepoSummary) !v
     , .{repos.len});
 
     for (repos) |repo| {
-        try writer.interface.print(
+        try writer.print(
             \\<tr>
             \\  <td><a href="/{0s}/{1s}/">{1s}</a></td>
             \\  <td>{2s}</td>
@@ -204,7 +192,7 @@ pub fn writeHomePage(arena: mem.Allocator, dir: fs.Dir, repos: []RepoSummary) !v
             \\
         ,
             .{
-                web_path, // 0
+                constants.web_path, // 0
                 try html.escapeAlloc(arena, repo.name), // 1
                 if (repo.description.len == 0)
                     "-"
@@ -217,15 +205,14 @@ pub fn writeHomePage(arena: mem.Allocator, dir: fs.Dir, repos: []RepoSummary) !v
         );
     }
 
-    try writer.interface.writeAll(
+    try writer.writeAll(
         \\        </tbody>
         \\      </table>
         \\    </div>
-        \\  </body>
-        \\</html>
     );
 
-    try writer.interface.flush();
+    try templates.base_end(writer);
+    try writer.flush();
 }
 
 const RepoArgs = struct {
@@ -244,7 +231,7 @@ pub fn processRepo(args: *const RepoArgs) !void {
     const arena = args.arena;
 
     var out_repo_dir = try args.target_dir.makeOpenPath(
-        try std.fmt.allocPrint(arena, "{s}/{s}", .{ web_path, args.repo_name }),
+        try std.fmt.allocPrint(arena, "{s}/{s}", .{ constants.web_path, args.repo_name }),
         .{},
     );
     defer out_repo_dir.close();
@@ -264,52 +251,42 @@ pub fn writeRepoPage(
     const arena = args.arena;
 
     var file = try out_repo_dir.createFile("index.html", .{});
+    errdefer out_repo_dir.deleteFile("index.html") catch {};
     defer file.close();
 
     var buf2: [1024 * 16]u8 = undefined;
     var file_writer = file.writer(&buf2);
     const writer = &file_writer.interface;
 
-    try writer.print(repo_html_start, .{
-        args.repo_name,
-        args.commits.len,
-        args.site_url,
-        if (mem.eql(u8, args.git_dir_path, "."))
-            args.repo_name
-        else
-            try std.fmt.allocPrint(arena, "{s}/{s}", .{ args.repo_name, args.git_dir_path }),
-        try html.escapeAlloc(arena, args.description),
-        try utils.humanReadableSize(arena, try utils.dirSize(arena, args.git_dir)),
+    try templates.base_start(arena, writer, .{
+        .title = args.repo_name,
+        .breadcrumbs = &.{ .{
+            .href = "/",
+            .text = "repos",
+        }, .{
+            .text = args.repo_name,
+        } },
     });
 
-    try writer.print(
-        \\<nav class="repo-nav">
-        \\  <ul>
-        \\    <li class="active"><a href="./">source</a></li>
-        \\    {0s}
-        \\    <li><a href="{1s}">commits</a></li>
-        \\  </ul>
-        \\</nav>
-        \\
-    ,
-        .{
-            if (maybe_readme_filename) |readme_filename|
-                try std.fmt.allocPrint(
-                    arena,
-                    "<li><a href=\"{s}\">{s}</a></li>",
-                    .{ web_readme_path, readme_filename },
-                )
-            else
-                "",
-            web_commits_path,
-        },
-    );
+    try templates.repo_metadata(arena, writer, .{
+        .description = args.description,
+        .repo_size = try utils.dirSize(arena, args.git_dir),
+        .commit_count = args.commits.len,
+        .site_url = args.site_url,
+        .repo_name = args.repo_name,
+        .git_dir_path = args.git_dir_path,
+    });
+
+    try templates.repo_nav(writer, .{
+        .repo_name = args.repo_name,
+        .readme_filename = maybe_readme_filename,
+        .active = .source,
+    });
+
+    try templates.repo_content_start(writer);
 
     try writer.writeAll(
-        \\<div class="repo-content">
-        \\
-        \\    <div style="overflow-x:auto; padding-bottom:1rem">
-        \\      <table style="margin-top: 1rem">
+        \\      <table>
         \\        <thead>
         \\          <tr>
         \\            <th>name</th>
@@ -317,30 +294,39 @@ pub fn writeRepoPage(
         \\          </tr>
         \\        </thead>
         \\        <tbody>
+        \\
     );
 
+    var objects_dir = try out_repo_dir.makeOpenPath(constants.web_objects_path, .{});
+    defer objects_dir.close();
+
     var treeWalker = try git.walkTree(arena, args.in_repo_dir);
     while (try treeWalker.next()) |src_file| {
+        const file_name = try std.fmt.allocPrint(arena, "{s}.html", .{src_file.hash});
+
         try writer.print(
             \\<tr>
-            \\  <td>{0s}</td>
+            \\  <td><a href="{2s}/{3s}">{0s}</a></td>
             \\  <td style="opacity:0.4">{1s}</td>
             \\</tr>
             \\
         , .{
             try html.escapeAlloc(arena, src_file.path),
             try utils.humanReadableSize(arena, src_file.size),
+            constants.web_objects_path,
+            file_name,
         });
+
+        try writeFilePage(args, objects_dir, src_file, file_name);
     }
 
     try writer.writeAll(
         \\        </tbody>
         \\      </table>
-        \\    </div>
-        \\</div>
         \\
     );
-    try writer.writeAll(repo_html_end);
+    try templates.repo_content_end(writer);
+    try templates.base_end(writer);
     try writer.flush();
 }
 
@@ -351,54 +337,50 @@ pub fn writeReadmePage(
 ) !void {
     if (maybe_readme_filename == null) return;
     const readme_filename = maybe_readme_filename.?;
-
     const arena = args.arena;
 
-    var readme_file = try out_repo_dir.createFile(web_readme_path, .{});
+    var readme_dir = try out_repo_dir.makeOpenPath(constants.web_readme_path, .{});
+    defer readme_dir.close();
+
+    var readme_file = try readme_dir.createFile("index.html", .{});
+    errdefer readme_dir.deleteFile("index.html") catch {};
     defer readme_file.close();
 
     var writerBuf: [4096]u8 = undefined;
     var file_writer = readme_file.writer(&writerBuf);
     const writer = &file_writer.interface;
 
-    try writer.print(repo_html_start, .{
-        args.repo_name,
-        args.commits.len,
-        args.site_url,
-        if (mem.eql(u8, args.git_dir_path, "."))
-            args.repo_name
-        else
-            try std.fmt.allocPrint(arena, "{s}/{s}", .{ args.repo_name, args.git_dir_path }),
-        try html.escapeAlloc(arena, args.description),
-        try utils.humanReadableSize(arena, try utils.dirSize(arena, args.git_dir)),
+    try templates.base_start(arena, writer, .{
+        .title = args.repo_name,
+        .breadcrumbs = &.{ .{
+            .href = "/",
+            .text = "repos",
+        }, .{
+            .text = args.repo_name,
+        } },
     });
 
-    try writer.print(
-        \\<nav class="repo-nav">
-        \\  <ul>
-        \\    <li><a href="./">source</a></li>
-        \\    <li class="active"><a href="{0s}">{2s}</a></li>
-        \\    <li><a href="{1s}">commits</a></li>
-        \\  </ul>
-        \\</nav>
-        \\
-    ,
-        .{
-            web_readme_path,
-            web_commits_path,
-            readme_filename,
-        },
-    );
+    try templates.repo_metadata(arena, writer, .{
+        .description = args.description,
+        .repo_size = try utils.dirSize(arena, args.git_dir),
+        .commit_count = args.commits.len,
+        .site_url = args.site_url,
+        .repo_name = args.repo_name,
+        .git_dir_path = args.git_dir_path,
+    });
+
+    try templates.repo_nav(writer, .{
+        .repo_name = args.repo_name,
+        .readme_filename = maybe_readme_filename,
+        .active = .readme,
+    });
+
+    try templates.repo_content_start(writer);
 
     const readme_text = try git.readFileAlloc(arena, args.in_repo_dir, readme_filename);
     const is_markdown = (ascii.endsWithIgnoreCase(readme_filename, ".md") or
         ascii.endsWithIgnoreCase(readme_filename, ".markdown"));
 
-    try writer.writeAll(
-        \\<div class="repo-content">
-        \\
-    );
-
     if (is_markdown) {
         const readme_html = try markdown.toHtml(arena, readme_text);
         try writer.writeAll(
@@ -410,7 +392,7 @@ pub fn writeReadmePage(
         );
     } else {
         try writer.writeAll(
-            \\<pre class="readme-content pre-wrap">
+            \\<pre class="readme-content">
         );
         try html.escape(
             writer,
@@ -420,10 +402,9 @@ pub fn writeReadmePage(
             \\</pre>
         );
     }
-    try writer.writeAll(
-        \\</div>
-        \\
-    );
+
+    try templates.repo_content_end(writer);
+    try templates.base_end(writer);
     try writer.flush();
 }
 
@@ -434,56 +415,45 @@ pub fn writeCommitsPage(
 ) !void {
     const arena = args.arena;
 
-    var commits_file = try out_repo_dir.createFile(web_commits_path, .{});
+    var commits_dir = try out_repo_dir.makeOpenPath(constants.web_commits_path, .{});
+    defer commits_dir.close();
+
+    var commits_file = try commits_dir.createFile("index.html", .{});
     defer commits_file.close();
 
     var writerBuf: [4096]u8 = undefined;
     var file_writer = commits_file.writer(&writerBuf);
     const writer = &file_writer.interface;
 
-    try writer.print(repo_html_start, .{
-        args.repo_name,
-        args.commits.len,
-        args.site_url,
-        if (mem.eql(u8, args.git_dir_path, "."))
-            args.repo_name
-        else
-            try std.fmt.allocPrint(arena, "{s}/{s}", .{ args.repo_name, args.git_dir_path }),
-        try html.escapeAlloc(arena, args.description),
-        try utils.humanReadableSize(arena, try utils.dirSize(arena, args.git_dir)),
+    try templates.base_start(arena, writer, .{
+        .title = args.repo_name,
+        .breadcrumbs = &.{ .{
+            .href = "/",
+            .text = "repos",
+        }, .{
+            .text = args.repo_name,
+        } },
     });
 
-    try writer.print(
-        \\<nav class="repo-nav">
-        \\  <ul>
-        \\    <li><a href="./">source</a></li>
-        \\    {0s}
-        \\    <li class="active"><a href="{1s}">commits</a></li>
-        \\  </ul>
-        \\</nav>
-        \\
-    ,
-        .{
-            if (maybe_readme_filename) |readme_filename|
-                try std.fmt.allocPrint(
-                    arena,
-                    "<li><a href=\"{s}\">{s}</a></li>",
-                    .{ web_readme_path, readme_filename },
-                )
-            else
-                "",
-            web_commits_path,
-        },
-    );
+    try templates.repo_metadata(arena, writer, .{
+        .description = args.description,
+        .repo_size = try utils.dirSize(arena, args.git_dir),
+        .commit_count = args.commits.len,
+        .site_url = args.site_url,
+        .repo_name = args.repo_name,
+        .git_dir_path = args.git_dir_path,
+    });
 
-    try writer.writeAll(
-        \\<div class="repo-content">
-        \\
-    );
+    try templates.repo_nav(writer, .{
+        .repo_name = args.repo_name,
+        .readme_filename = maybe_readme_filename,
+        .active = .commits,
+    });
+
+    try templates.repo_content_start(writer);
 
     try writer.print(
-        \\    <div style="overflow-x:auto; padding-bottom:1rem">
-        \\      <table style="margin-top: 1rem">
+        \\      <table>
         \\        <thead>
         \\          <tr>
         \\            <th>hash</th>
@@ -527,38 +497,98 @@ pub fn writeCommitsPage(
         \\</div>
         \\
     );
-    try writer.writeAll(repo_html_end);
+    try templates.repo_content_end(writer);
+    try templates.base_end(writer);
     try writer.flush();
 }
 
-const repo_html_start =
-    \\<!doctype html>
-    \\<html lang="en">
-    \\  <head>
-    \\    <meta charset="utf-8" />
-    \\    <title>{0s} | Khoe</title>
-    \\    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    \\    <link rel="stylesheet" href="/_/_khoe-hang/style.css">
-    \\    <script src="/_/_khoe-hang/script.js"></script>
-    \\  </head>
-    \\  <body>
-    \\    <header>
-    \\      <div id="breadcrumbs">
-    \\        /<a href="/">repos</a>/<h1>{0s}</h1>/
-    \\      </div>
-    \\    </header>
-    \\    <p style="margin:0; font-style:italic;">{4s}</p>
-    \\    <p style="margin:0">{5s} | {1d} commits</p>
-    \\
-    \\    <pre class="git-clone-command">git clone {2s}/{3s}</pre>
-    \\
-;
-
-const repo_html_end =
-    \\  </body>
-    \\</html>
-    \\
-;
+pub fn writeFilePage(
+    args: *const RepoArgs,
+    out_dir: fs.Dir,
+    src_file: git.File,
+    file_name: []const u8,
+) !void {
+    const arena = args.arena;
+
+    _ = out_dir.statFile(file_name) catch |err| {
+        switch (err) {
+            error.FileNotFound => {
+                println("    writing {s}/{s}", .{ constants.web_objects_path, file_name });
+                var file = try out_dir.createFile(file_name, .{});
+                errdefer out_dir.deleteFile(file_name) catch {};
+                defer file.close();
+
+                var buf: [4096]u8 = undefined;
+                var file_writer = file.writer(&buf);
+                const writer = &file_writer.interface;
+
+                const file_label = try std.fmt.allocPrint(
+                    arena,
+                    "{s}: {s}",
+                    .{ src_file.hash[0..10], src_file.path },
+                );
+
+                try templates.base_start(arena, writer, .{
+                    .title = try std.fmt.allocPrint(
+                        arena,
+                        "{s} - {s}",
+                        .{ file_label, args.repo_name },
+                    ),
+                    .breadcrumbs = &.{ .{
+                        .href = "/",
+                        .text = "repos",
+                    }, .{
+                        .href = "../",
+                        .text = args.repo_name,
+                    }, .{
+                        .text = file_label,
+                    } },
+                });
+
+                try writer.print(
+                    \\<p>size: {0s}</p>
+                , .{
+                    try utils.humanReadableSize(arena, src_file.size),
+                });
+
+                const object_type = try git.objectType(arena, args.in_repo_dir, src_file.hash);
+                if (object_type != .blob) {
+                    std.debug.panic("Not implemented: {any}\n", .{object_type});
+                }
+
+                try writer.writeAll(
+                    \\<pre class="blob-content">
+                    \\
+                );
+
+                // This wastefully reads the whole file into memory.
+                // TODO: Try to implement a git.catFile() that only reads the
+                // first n bytes.
+                const file_content = try git.catFile(
+                    arena,
+                    args.in_repo_dir,
+                    src_file.hash,
+                );
+                if (git.isBinary(file_content)) {
+                    try writer.writeAll(
+                        \\<span style="opacity: 0.5">(binary data)</span>
+                        \\
+                    );
+                } else {
+                    try html.escape(writer, file_content);
+                }
+
+                try writer.writeAll(
+                    \\</pre>
+                    \\
+                );
+
+                try writer.flush();
+            },
+            else => return err,
+        }
+    };
+}
 
 test "all" {
     _ = @import("html.zig");
diff --git a/src/templates.zig b/src/templates.zig
new file mode 100644
index 0000000..831957d
--- /dev/null
+++ b/src/templates.zig
@@ -0,0 +1,177 @@
+const std = @import("std");
+const mem = std.mem;
+const Io = std.Io;
+const fmt = std.fmt;
+const html = @import("html.zig");
+const utils = @import("utils.zig");
+const constants = @import("constants.zig");
+
+pub const Crumb = struct {
+    href: ?[]const u8 = null,
+    text: []const u8,
+};
+
+pub const BaseArgs = struct {
+    title: ?[]const u8 = null,
+    breadcrumbs: []const Crumb,
+};
+
+// Remember to close with base_end()
+pub fn base_start(arena: mem.Allocator, writer: *Io.Writer, args: BaseArgs) !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" />
+        \\    <link rel="stylesheet" href="/_/_khoe-hang/style.css">
+        \\    <script src="/_/_khoe-hang/script.js"></script>
+        \\  </head>
+        \\  <body>
+        \\    <header>
+        \\      <div id="breadcrumbs">
+        \\        /
+    , .{
+        if (args.title) |title|
+            try html.escapeAlloc(
+                arena,
+                try fmt.allocPrint(arena, "{s} | khoe", .{title}),
+            )
+        else
+            "khoe",
+    });
+
+    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>
+        \\
+    );
+}
+
+pub fn base_end(writer: *Io.Writer) !void {
+    try writer.writeAll(
+        \\  </body>
+        \\</html>
+        \\
+    );
+}
+
+pub fn repo_metadata(arena: mem.Allocator, writer: *Io.Writer, args: struct {
+    description: []const u8,
+    repo_size: u64,
+    commit_count: usize,
+    site_url: [*:0]const u8,
+    repo_name: []const u8,
+    git_dir_path: []const u8,
+}) !void {
+    const clone_path =
+        if (mem.eql(u8, args.git_dir_path, "."))
+            args.repo_name
+        else
+            try std.fmt.allocPrint(
+                arena,
+                "{s}/{s}",
+                .{ args.repo_name, args.git_dir_path },
+            );
+
+    try writer.print(
+        \\    <p style="margin:0; font-style:italic;">{0s}</p>
+        \\    <p style="margin:0">{1s} | {2d} commits</p>
+        \\
+        \\    <pre class="git-clone-command">git clone {3s}/{4s}</pre>
+        \\
+    ,
+        .{
+            try html.escapeAlloc(arena, args.description),
+            try utils.humanReadableSize(arena, args.repo_size),
+            args.commit_count,
+            args.site_url,
+            clone_path,
+        },
+    );
+}
+
+pub fn repo_nav(
+    writer: *Io.Writer,
+    args: struct {
+        repo_name: []const u8,
+        active: enum { source, readme, commits },
+        readme_filename: ?[]const u8,
+    },
+) !void {
+    try writer.writeAll(
+        \\<nav class="repo-nav">
+        \\  <ul>
+        \\
+    );
+
+    const source_class = if (args.active == .source) "active" else "";
+    const readme_class = if (args.active == .readme) "active" else "";
+    const commits_class = if (args.active == .commits) "active" else "";
+
+    try writer.print(
+        \\    <li class="{0s}"><a href="/{1s}/{2s}/">source</a></li>
+        \\
+    , .{
+        source_class,
+        constants.web_path,
+        args.repo_name,
+    });
+
+    if (args.readme_filename) |fname| {
+        try writer.print(
+            \\    <li class="{0s}"><a href="/{1s}/{2s}/{3s}/">{4s}</a></li>
+            \\
+        , .{
+            readme_class,
+            constants.web_path,
+            args.repo_name,
+            constants.web_readme_path,
+            fname,
+        });
+    }
+
+    try writer.print(
+        \\    <li class="{0s}"><a href="/{1s}/{2s}/{3s}">commits</a></li>
+        \\
+    , .{
+        commits_class,
+        constants.web_path,
+        args.repo_name,
+        constants.web_commits_path,
+    });
+
+    try writer.writeAll(
+        \\  </ul>
+        \\</nav>
+        \\
+    );
+}
+
+pub fn repo_content_start(writer: *Io.Writer) !void {
+    try writer.writeAll(
+        \\<div class="repo-content">
+        \\
+    );
+}
+
+pub fn repo_content_end(writer: *Io.Writer) !void {
+    try writer.writeAll(
+        \\</div>
+        \\
+    );
+}
-- 
2.47.3

