size: 20 KiB
const std = @import("std"); const fs = std.fs; const mem = std.mem; const ascii = std.ascii; const utils = @import("utils.zig"); const println = utils.println; const git = @import("git.zig"); const html = @import("html.zig"); const markdown = @import("markdown.zig"); const templates = @import("templates.zig"); const constants = @import("constants.zig"); const Mode = union(enum) { all: void, single_repo: [*:0]const u8, }; pub fn main() !u8 { if (std.os.argv.len != 3 and std.os.argv.len != 4) { println("Usage: khoe <dir> <site-url> [repo-name]", .{}); println( \\For example: \\ khoe /srv/git/repos https://khoe.thac.loan \\ khoe /srv/git/repos https://khoe.thac.loan khoe.git \\When repo-name is present, only the homepage and that repo are \\regenerated. , .{}); return 1; } const site_url = std.os.argv[2]; const mode: Mode = if (std.os.argv.len == 3) .all else .{ .single_repo = std.os.argv[3] }; _ = mode; // TODO var dba_impl: std.heap.DebugAllocator(.{}) = .init; defer _ = dba_impl.deinit(); const dba = dba_impl.allocator(); const target_dir_path = std.os.argv[1]; println("Targeting dir: {s}", .{target_dir_path}); var target_dir = try fs.cwd().openDirZ(target_dir_path, .{ .iterate = true }); defer target_dir.close(); // Global arena, mostly to avoid making tiny allocations. Freeing the arena // at the very end is unnecessary, but it keeps DebugAllocator's leak // checker happy, leaving it free to report other real problems. var arena_impl: std.heap.ArenaAllocator = .init(dba); defer arena_impl.deinit(); const arena = arena_impl.allocator(); var repo_summaries: std.ArrayList(RepoSummary) = try .initCapacity(arena, 32); defer repo_summaries.deinit(arena); // This arena is reset after each repo's iteration var repo_arena_impl: std.heap.ArenaAllocator = .init(dba); defer repo_arena_impl.deinit(); const repo_arena = repo_arena_impl.allocator(); var dir_iter = target_dir.iterate(); while (try dir_iter.next()) |entry| { defer _ = repo_arena_impl.reset(.retain_capacity); if (entry.kind != .directory or mem.eql(u8, entry.name, constants.web_path)) { continue; } var repo_dir = try target_dir.openDir(entry.name, .{}); defer repo_dir.close(); const git_dir_path = try git.findGitDir(repo_arena, repo_dir); if (!( // check if entry is actually a git repo mem.eql(u8, git_dir_path, ".") // bare repo or mem.eql(u8, git_dir_path, ".git") // normal repo )) { // Note: git dir could also return a full path, which means the // current dir is NOT a git repo, but one of its ancestors is. // We're not interested in such cases either. println("* {s} is not a git repo", .{entry.name}); continue; } // update-server-info generates the necessary files to make our static // dir cloneable: https://git-scm.com/docs/git-update-server-info try git.updateServerInfo(repo_arena, repo_dir); const commits = try git.getCommits(repo_arena, repo_dir); println("Found repo {s}: {d} commits", .{ entry.name, commits.len }); var git_dir = try repo_dir.openDir(git_dir_path, .{ .iterate = true }); defer git_dir.close(); const repo_description = try git.getDescription(arena, git_dir); try repo_summaries.append(arena, .{ .name = try arena.dupe(u8, entry.name), .description = repo_description, .commit_count = commits.len, .last_commit_msg = if (commits.len == 0) "" else try arena.dupe(u8, commits[0].subject), .last_commit_time = if (commits.len == 0) "" else try arena.dupe(u8, commits[0].time), }); try processRepo(&.{ .arena = repo_arena, .site_url = site_url, .repo_name = entry.name, .description = repo_description, .target_dir = target_dir, .in_repo_dir = repo_dir, .commits = commits, .git_dir_path = git_dir_path, .git_dir = git_dir, }); } std.mem.sortUnstable(RepoSummary, repo_summaries.items, {}, RepoSummary.lessThan); try writeHomePage(arena, target_dir, repo_summaries.items); 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), .sub_path = asset_name, }); } return 0; } const RepoSummary = struct { name: []const u8, description: []const u8 = "", commit_count: usize, last_commit_time: []const u8, last_commit_msg: []const u8, // Sort by newest first using last_commit_time strings. // Not accurate because it just compares ISO datetime strings, which may be // in different timezones, in which case the sorting will be incorrect. pub fn lessThan(_: void, lhs: RepoSummary, rhs: RepoSummary) bool { return !std.mem.lessThan(u8, lhs.last_commit_time, rhs.last_commit_time); } }; // 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 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> \\ <thead> \\ <tr> \\ <th>name</th> \\ <th>description</th> \\ <th>commits</th> \\ <th>last commit</th> \\ <th>last modified</th> \\ </tr> \\ </thead> \\ <tbody> \\ , .{repos.len}); for (repos) |repo| { try writer.print( \\<tr> \\ <td><a href="/{0s}/{1s}/">{1s}</a></td> \\ <td>{2s}</td> \\ <td>{3d}</td> \\ <td>{4s}</td> \\ <td><time class="relative" datetime="{5s}" title="{5s}">{5s}</time></td> \\</tr> \\ , .{ constants.web_path, // 0 try html.escapeAlloc(arena, repo.name), // 1 if (repo.description.len == 0) "-" else try html.escapeAlloc(arena, repo.description), // 2 repo.commit_count, // 3 repo.last_commit_msg, // 4 repo.last_commit_time, // 5 }, ); } try writer.writeAll( \\ </tbody> \\ </table> \\ </div> ); try templates.base_end(writer); try writer.flush(); } const RepoArgs = struct { arena: std.mem.Allocator, site_url: [*:0]const u8, repo_name: []const u8, description: []const u8, target_dir: fs.Dir, in_repo_dir: fs.Dir, commits: []git.Commit, git_dir_path: []const u8, git_dir: fs.Dir, }; 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}", .{ constants.web_path, args.repo_name }), .{}, ); defer out_repo_dir.close(); const maybe_readme_path = try git.findReadme(arena, args.in_repo_dir); try writeRepoPage(args, out_repo_dir, maybe_readme_path); try writeReadmePage(args, out_repo_dir, maybe_readme_path); try writeCommitsPage(args, out_repo_dir, maybe_readme_path); } pub fn writeRepoPage( args: *const RepoArgs, out_repo_dir: fs.Dir, maybe_readme_filename: ?[]const u8, ) !void { 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 templates.base_start(arena, writer, .{ .title = args.repo_name, .breadcrumbs = &.{ .{ .href = "/", .text = "repos", }, .{ .text = args.repo_name, } }, }); 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( \\ <table> \\ <thead> \\ <tr> \\ <th>name</th> \\ <th>size</th> \\ </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><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> \\ ); try templates.repo_content_end(writer); try templates.base_end(writer); try writer.flush(); } pub fn writeReadmePage( args: *const RepoArgs, out_repo_dir: fs.Dir, maybe_readme_filename: ?[]const u8, ) !void { if (maybe_readme_filename == null) return; const readme_filename = maybe_readme_filename.?; const arena = args.arena; 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 templates.base_start(arena, writer, .{ .title = args.repo_name, .breadcrumbs = &.{ .{ .href = "/", .text = "repos", }, .{ .text = args.repo_name, } }, }); 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")); if (is_markdown) { const readme_html = try markdown.toHtml(arena, readme_text); try writer.writeAll( \\<div class="readme-content"> ); try writer.writeAll(readme_html); try writer.writeAll( \\</div> ); } else { try writer.writeAll( \\<pre class="readme-content"> ); try html.escape( writer, mem.trimEnd(u8, readme_text, "\n"), ); try writer.writeAll( \\</pre> ); } try templates.repo_content_end(writer); try templates.base_end(writer); try writer.flush(); } pub fn writeCommitsPage( args: *const RepoArgs, out_repo_dir: fs.Dir, maybe_readme_filename: ?[]const u8, ) !void { const arena = args.arena; 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", .{}); errdefer commits_dir.deleteFile("index.html") catch {}; defer commits_file.close(); var writerBuf: [4096]u8 = undefined; var file_writer = commits_file.writer(&writerBuf); const writer = &file_writer.interface; try templates.base_start(arena, writer, .{ .title = args.repo_name, .breadcrumbs = &.{ .{ .href = "/", .text = "repos", }, .{ .text = args.repo_name, } }, }); 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 = .commits, }); try templates.repo_content_start(writer); try writer.print( \\ <table> \\ <thead> \\ <tr> \\ <th>hash</th> \\ <th>subject</th> \\ <th>author</th> \\ <th>time</th> \\ </tr> \\ </thead> \\ <tbody> \\ , .{}); for (args.commits) |cmt| { const escaped_subject = try html.escapeAlloc( arena, cmt.subject[0..@min(cmt.subject.len, 80)], ); const escaped_author = try html.escapeAlloc(arena, cmt.author_name); try writer.print( \\<tr> \\ <td class="monospace" title="commit diff coming Soon™">{0s}</td> \\ <td>{1s}{2s}</td> \\ <td>{4s}</td> \\ <td><time class="relative" datetime="{3s}" title="{3s}">{3s}</time></td> \\</tr> \\ , .{ //cmt.hash, cmt.hash[0..10], escaped_subject, if (cmt.subject.len > 80) "…" else "", cmt.time, escaped_author, }, ); } try writer.writeAll( \\</div> \\ ); try templates.repo_content_end(writer); try templates.base_end(writer); try writer.flush(); } 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 => { const object_type = try git.objectType(arena, args.in_repo_dir, src_file.hash); if (object_type != .blob) { return; } println(" writing {s}: {s}/{s}", .{ args.repo_name, 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), }); 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, ); const blob_type = utils.blobType(src_file.path); if (file_content.len <= 1024 * 1024 * 16 and (blob_type == .image or blob_type == .video)) { const src_file_name = if (mem.lastIndexOfScalar(u8, src_file.path, '/')) |slash_idx| src_file.path[slash_idx + 1 ..] else src_file.path; const blob_file_name = try std.fmt.allocPrint( arena, "{s}.{s}", .{ src_file.hash, src_file_name }, ); // write the blob's actual content next to its html page var blob_file = try out_dir.createFile(blob_file_name, .{}); errdefer out_dir.deleteFile(blob_file_name) catch {}; defer blob_file.close(); try blob_file.writeAll(file_content); // write <img> markup in blob page const escaped_file_name = try html.escapeAlloc(arena, src_file_name); switch (blob_type) { .image => { try writer.print( \\<img src="{0s}.{1s}" alt="{1s}"> \\ , .{ src_file.hash, escaped_file_name, }); }, .video => { try writer.print( \\<video src="{0s}.{1s}" controls></video> \\ , .{ src_file.hash, escaped_file_name, }); }, else => return error.UnexpectedBlobType, } } else 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"); _ = @import("markdown.zig"); std.testing.refAllDeclsRecursive(@This()); }