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());
}