size: 26 KiB

1const std = @import("std");
2const fs = std.fs;
3const mem = std.mem;
4const ascii = std.ascii;
5const fmt = std.fmt;
6const utils = @import("utils.zig");
7const println = utils.println;
8const git = @import("git.zig");
9const html = @import("html.zig");
10const markdown = @import("markdown.zig");
11const templates = @import("templates.zig");
12const constants = @import("constants.zig");
13const config = @import("config.zig");
14
15pub fn main() !u8 {
16 if (std.os.argv.len != 3) {
17 println("Usage: khoe <dir> <site-url>", .{});
18 println(
19 \\For example:
20 \\ khoe /srv/git/repos https://khoe.thac.loan
21 , .{});
22 return 1;
23 }
24
25 const site_url = std.os.argv[2];
26
27 var dba_impl: std.heap.DebugAllocator(.{}) = .init;
28 defer _ = dba_impl.deinit();
29 const dba = dba_impl.allocator();
30
31 const target_dir_path = std.os.argv[1];
32 println("Targeting dir: {s}", .{target_dir_path});
33
34 var target_dir = try fs.cwd().openDirZ(target_dir_path, .{ .iterate = true });
35 defer target_dir.close();
36
37 // Global arena, mostly to avoid making tiny allocations. Freeing the arena
38 // at the very end is unnecessary, but it keeps DebugAllocator's leak
39 // checker happy, leaving it free to report other real problems.
40 var arena_impl: std.heap.ArenaAllocator = .init(dba);
41 defer arena_impl.deinit();
42 const arena = arena_impl.allocator();
43
44 const conf = config.fromEnv();
45
46 var repo_summaries: std.ArrayList(RepoSummary) = try .initCapacity(arena, 32);
47 defer repo_summaries.deinit(arena);
48
49 // This arena is reset after each repo's iteration
50 var repo_arena_impl: std.heap.ArenaAllocator = .init(dba);
51 defer repo_arena_impl.deinit();
52 const repo_arena = repo_arena_impl.allocator();
53
54 var dir_iter = target_dir.iterate();
55 while (try dir_iter.next()) |entry| {
56 defer _ = repo_arena_impl.reset(.retain_capacity);
57
58 if (entry.kind != .directory or mem.eql(u8, entry.name, constants.web_path)) {
59 continue;
60 }
61
62 var repo_dir = try target_dir.openDir(entry.name, .{});
63 defer repo_dir.close();
64
65 const git_dir_path = try git.findGitDir(repo_arena, repo_dir);
66 if (!(
67 // check if entry is actually a git repo
68 mem.eql(u8, git_dir_path, ".") // bare repo
69 or mem.eql(u8, git_dir_path, ".git") // normal repo
70 )) {
71 // Note: git dir could also return a full path, which means the
72 // current dir is NOT a git repo, but one of its ancestors is.
73 // We're not interested in such cases either.
74 println("* {s} is not a git repo", .{entry.name});
75 continue;
76 }
77
78 // update-server-info generates the necessary files to make our static
79 // dir cloneable: https://git-scm.com/docs/git-update-server-info
80 try git.updateServerInfo(repo_arena, repo_dir);
81
82 const commits = try git.getCommits(repo_arena, repo_dir);
83 println("Found repo {s}: {d} commits", .{ entry.name, commits.len });
84
85 var git_dir = try repo_dir.openDir(git_dir_path, .{ .iterate = true });
86 defer git_dir.close();
87
88 const repo_description = try git.getDescription(arena, git_dir);
89
90 try repo_summaries.append(arena, .{
91 .name = try arena.dupe(u8, entry.name),
92 .description = repo_description,
93 .commit_count = commits.len,
94 .last_commit_msg = if (commits.len == 0) "" else try arena.dupe(u8, commits[0].subject),
95 .last_commit_time = if (commits.len == 0) "" else try arena.dupe(u8, commits[0].time),
96 });
97
98 try processRepo(&.{
99 .arena = repo_arena,
100 .site_url = site_url,
101 .repo_name = entry.name,
102 .description = repo_description,
103 .target_dir = target_dir,
104 .in_repo_dir = repo_dir,
105 .commits = commits,
106 .git_dir_path = git_dir_path,
107 .git_dir = git_dir,
108 .conf = conf,
109 });
110 }
111
112 std.mem.sortUnstable(RepoSummary, repo_summaries.items, {}, RepoSummary.lessThan);
113
114 try writeHomePage(arena, target_dir, repo_summaries.items);
115
116 var assets_dir = try target_dir.makeOpenPath(constants.web_assets_path, .{});
117 inline for (.{ "style.css", "script.js" }) |asset_name| {
118 try assets_dir.writeFile(.{
119 .data = @embedFile("assets/" ++ asset_name),
120 .sub_path = asset_name,
121 });
122 }
123
124 return 0;
125}
126
127const RepoSummary = struct {
128 name: []const u8,
129 description: []const u8 = "",
130 commit_count: usize,
131 last_commit_time: []const u8,
132 last_commit_msg: []const u8,
133
134 // Sort by newest first using last_commit_time strings.
135 // Not accurate because it just compares ISO datetime strings, which may be
136 // in different timezones, in which case the sorting will be incorrect.
137 pub fn lessThan(_: void, lhs: RepoSummary, rhs: RepoSummary) bool {
138 return !std.mem.lessThan(u8, lhs.last_commit_time, rhs.last_commit_time);
139 }
140};
141
142// TODO: decide on some sort of templating system
143pub fn writeHomePage(arena: mem.Allocator, dir: fs.Dir, repos: []RepoSummary) !void {
144 var file = try dir.createFile("index.html", .{});
145 errdefer dir.deleteFile("index.html") catch {};
146 defer file.close();
147
148 var buf: [1024 * 16]u8 = undefined;
149 var file_writer = file.writer(&buf);
150 const writer = &file_writer.interface;
151 try templates.base_start(
152 arena,
153 writer,
154 .{
155 .breadcrumbs = &.{.{ .text = "repos" }},
156 .opengraph = .{
157 .title = "all repos",
158 .description = try fmt.allocPrint(arena, "repo count: {d}", .{repos.len}),
159 },
160 },
161 );
162
163 try writer.print(
164 \\ <p>listing <b>{d}</b> repos:</p>
165 \\ <div style="overflow-x:auto; padding-bottom:1rem">
166 \\ <table class="fancy">
167 \\ <thead>
168 \\ <tr>
169 \\ <th>name</th>
170 \\ <th>description</th>
171 \\ <th>commits</th>
172 \\ <th>last commit</th>
173 \\ <th>last modified</th>
174 \\ </tr>
175 \\ </thead>
176 \\ <tbody>
177 \\
178 , .{repos.len});
179
180 for (repos) |repo| {
181 try writer.print(
182 \\<tr>
183 \\ <td><a href="/{0s}/{1s}/">{1s}</a></td>
184 \\ <td>{2s}</td>
185 \\ <td>{3d}</td>
186 \\ <td>{4s}</td>
187 \\ <td><time class="relative" datetime="{5s}" title="{5s}">{5s}</time></td>
188 \\</tr>
189 \\
190 ,
191 .{
192 constants.web_path, // 0
193 try html.escapeAlloc(arena, repo.name), // 1
194 if (repo.description.len == 0)
195 "-"
196 else
197 try html.escapeAlloc(arena, repo.description), // 2
198 repo.commit_count, // 3
199 try html.escapeAlloc(arena, repo.last_commit_msg), // 4
200 repo.last_commit_time, // 5
201 },
202 );
203 }
204
205 try writer.writeAll(
206 \\ </tbody>
207 \\ </table>
208 \\ </div>
209 );
210
211 try templates.base_end(writer);
212 try writer.flush();
213}
214
215const RepoArgs = struct {
216 arena: std.mem.Allocator,
217 site_url: [*:0]const u8,
218 repo_name: []const u8,
219 description: []const u8,
220 target_dir: fs.Dir,
221 in_repo_dir: fs.Dir,
222 commits: []git.Commit,
223 git_dir_path: []const u8,
224 git_dir: fs.Dir,
225 conf: config.Conf,
226};
227
228pub fn processRepo(args: *const RepoArgs) !void {
229 const arena = args.arena;
230
231 var out_repo_dir = try args.target_dir.makeOpenPath(
232 try std.fmt.allocPrint(arena, "{s}/{s}", .{ constants.web_path, args.repo_name }),
233 .{},
234 );
235 defer out_repo_dir.close();
236
237 const maybe_readme_path = try git.findReadme(arena, args.in_repo_dir);
238
239 var processed_blob_hashes = std.StringHashMap(bool).init(arena);
240
241 try writeRepoPage(args, out_repo_dir, maybe_readme_path, &processed_blob_hashes);
242 try writeReadmePage(args, out_repo_dir, maybe_readme_path);
243 try writeCommitsPage(args, out_repo_dir, maybe_readme_path);
244 if (args.conf.historical_blobs) {
245 try writeHistoricalBlobPages(args, out_repo_dir, &processed_blob_hashes);
246 }
247}
248
249pub fn writeRepoPage(
250 args: *const RepoArgs,
251 out_repo_dir: fs.Dir,
252 maybe_readme_filename: ?[]const u8,
253 processed_blob_hashes: *std.StringHashMap(bool),
254) !void {
255 const arena = args.arena;
256
257 var file = try out_repo_dir.createFile("index.html", .{});
258 errdefer out_repo_dir.deleteFile("index.html") catch {};
259 defer file.close();
260
261 var buf2: [1024 * 16]u8 = undefined;
262 var file_writer = file.writer(&buf2);
263 const writer = &file_writer.interface;
264
265 try templates.base_start(arena, writer, .{
266 .title = args.repo_name,
267 .opengraph = .{
268 .title = try fmt.allocPrint(
269 arena,
270 "{s}: repo details",
271 .{args.repo_name},
272 ),
273 .description = args.description,
274 },
275 .breadcrumbs = &.{ .{
276 .href = "/",
277 .text = "repos",
278 }, .{
279 .text = args.repo_name,
280 } },
281 });
282
283 try templates.repo_metadata(arena, writer, .{
284 .description = args.description,
285 .repo_size = try utils.dirSize(arena, args.git_dir),
286 .commit_count = args.commits.len,
287 .site_url = args.site_url,
288 .repo_name = args.repo_name,
289 .git_dir_path = args.git_dir_path,
290 });
291
292 try templates.repo_nav(writer, .{
293 .repo_name = args.repo_name,
294 .readme_filename = maybe_readme_filename,
295 .active = .source,
296 });
297
298 try templates.repo_content_start(writer);
299
300 try writer.writeAll(
301 \\ <table class="fancy">
302 \\ <thead>
303 \\ <tr>
304 \\ <th>name</th>
305 \\ <th>size</th>
306 \\ </tr>
307 \\ </thead>
308 \\ <tbody>
309 \\
310 );
311
312 var objects_dir = try out_repo_dir.makeOpenPath(constants.web_objects_path, .{});
313 defer objects_dir.close();
314
315 var treeWalker = try git.walkTree(arena, args.in_repo_dir, "HEAD");
316 while (try treeWalker.next()) |src_file| {
317 try writer.print(
318 \\<tr>
319 \\ <td><a href="{0s}/{1s}/">{2s}</a></td>
320 \\ <td style="opacity:0.4">{3s}</td>
321 \\</tr>
322 \\
323 , .{
324 constants.web_objects_path,
325 src_file.hash,
326 try html.escapeAlloc(arena, src_file.path),
327 try utils.humanReadableSize(arena, src_file.size),
328 });
329
330 try writeBlobPage(args, objects_dir, src_file);
331
332 // Report that this blob has already been generated so we don't do
333 // duplicate work later:
334 try processed_blob_hashes.put(src_file.hash, true);
335 }
336
337 try writer.writeAll(
338 \\ </tbody>
339 \\ </table>
340 \\
341 );
342 try templates.repo_content_end(writer);
343 try templates.base_end(writer);
344 try writer.flush();
345}
346
347pub fn writeReadmePage(
348 args: *const RepoArgs,
349 out_repo_dir: fs.Dir,
350 maybe_readme_filename: ?[]const u8,
351) !void {
352 if (maybe_readme_filename == null) return;
353 const readme_filename = maybe_readme_filename.?;
354 const arena = args.arena;
355
356 var readme_dir = try out_repo_dir.makeOpenPath(constants.web_readme_path, .{});
357 defer readme_dir.close();
358
359 var readme_file = try readme_dir.createFile("index.html", .{});
360 errdefer readme_dir.deleteFile("index.html") catch {};
361 defer readme_file.close();
362
363 var writerBuf: [4096]u8 = undefined;
364 var file_writer = readme_file.writer(&writerBuf);
365 const writer = &file_writer.interface;
366
367 const readme_text = try git.readFileAlloc(arena, args.in_repo_dir, readme_filename);
368
369 try templates.base_start(arena, writer, .{
370 .title = args.repo_name,
371 .opengraph = .{
372 .title = try fmt.allocPrint(
373 arena,
374 "{s}/{s}",
375 .{ args.repo_name, readme_filename },
376 ),
377 .description = readme_text[0..@min(256, readme_text.len)],
378 },
379 .breadcrumbs = &.{ .{
380 .href = "/",
381 .text = "repos",
382 }, .{
383 .text = args.repo_name,
384 } },
385 });
386
387 try templates.repo_metadata(arena, writer, .{
388 .description = args.description,
389 .repo_size = try utils.dirSize(arena, args.git_dir),
390 .commit_count = args.commits.len,
391 .site_url = args.site_url,
392 .repo_name = args.repo_name,
393 .git_dir_path = args.git_dir_path,
394 });
395
396 try templates.repo_nav(writer, .{
397 .repo_name = args.repo_name,
398 .readme_filename = maybe_readme_filename,
399 .active = .readme,
400 });
401
402 try templates.repo_content_start(writer);
403
404 const is_markdown = (ascii.endsWithIgnoreCase(readme_filename, ".md") or
405 ascii.endsWithIgnoreCase(readme_filename, ".markdown"));
406
407 if (is_markdown) {
408 const readme_html = try markdown.toHtml(arena, readme_text);
409 try writer.writeAll(
410 \\<div class="readme-content">
411 );
412 try writer.writeAll(readme_html);
413 try writer.writeAll(
414 \\</div>
415 );
416 } else {
417 try writer.writeAll(
418 \\<pre class="readme-content">
419 );
420 try html.escape(
421 writer,
422 mem.trimEnd(u8, readme_text, "\n"),
423 );
424 try writer.writeAll(
425 \\</pre>
426 );
427 }
428
429 try templates.repo_content_end(writer);
430 try templates.base_end(writer);
431 try writer.flush();
432}
433
434pub fn writeCommitsPage(
435 args: *const RepoArgs,
436 out_repo_dir: fs.Dir,
437 maybe_readme_filename: ?[]const u8,
438) !void {
439 const arena = args.arena;
440
441 var commits_dir = try out_repo_dir.makeOpenPath(constants.web_commits_path, .{});
442 defer commits_dir.close();
443
444 var commits_file = try commits_dir.createFile("index.html", .{});
445 errdefer commits_dir.deleteFile("index.html") catch {};
446 defer commits_file.close();
447
448 var writerBuf: [4096]u8 = undefined;
449 var file_writer = commits_file.writer(&writerBuf);
450 const writer = &file_writer.interface;
451
452 try templates.base_start(arena, writer, .{
453 .title = args.repo_name,
454 .opengraph = .{
455 .title = try fmt.allocPrint(arena, "{s}/commits", .{args.repo_name}),
456 .description = try fmt.allocPrint(arena, "{d} commits", .{args.commits.len}),
457 },
458 .breadcrumbs = &.{ .{
459 .href = "/",
460 .text = "repos",
461 }, .{
462 .text = args.repo_name,
463 } },
464 });
465
466 try templates.repo_metadata(arena, writer, .{
467 .description = args.description,
468 .repo_size = try utils.dirSize(arena, args.git_dir),
469 .commit_count = args.commits.len,
470 .site_url = args.site_url,
471 .repo_name = args.repo_name,
472 .git_dir_path = args.git_dir_path,
473 });
474
475 try templates.repo_nav(writer, .{
476 .repo_name = args.repo_name,
477 .readme_filename = maybe_readme_filename,
478 .active = .commits,
479 });
480
481 try templates.repo_content_start(writer);
482
483 try writer.print(
484 \\ <table class="fancy">
485 \\ <thead>
486 \\ <tr>
487 \\ <th>hash</th>
488 \\ <th>subject</th>
489 \\ <th>author</th>
490 \\ <th>time</th>
491 \\ </tr>
492 \\ </thead>
493 \\ <tbody>
494 \\
495 , .{});
496
497 for (args.commits) |cmt| {
498 const escaped_subject = try html.escapeAlloc(
499 arena,
500 cmt.subject[0..@min(cmt.subject.len, 80)],
501 );
502 const escaped_author = try html.escapeAlloc(arena, cmt.author_name);
503
504 const cmt_web_path = try fmt.allocPrint(arena, "../{s}/{s}/", .{
505 constants.web_objects_path,
506 cmt.hash,
507 });
508
509 try writer.print(
510 \\<tr>
511 \\ <td class="monospace"><a href="{5s}">{0s}</a></td>
512 \\ <td>{1s}{2s}</td>
513 \\ <td>{4s}</td>
514 \\ <td><time class="relative" datetime="{3s}" title="{3s}">{3s}</time></td>
515 \\</tr>
516 \\
517 ,
518 .{
519 //cmt.hash,
520 cmt.hash[0..10],
521 escaped_subject,
522 if (cmt.subject.len > 80) "…" else "",
523 cmt.time,
524 escaped_author,
525 cmt_web_path,
526 },
527 );
528
529 try writeCommitPage(arena, args, out_repo_dir, cmt);
530 }
531
532 try writer.writeAll(
533 \\</div>
534 \\
535 );
536 try templates.repo_content_end(writer);
537 try templates.base_end(writer);
538 try writer.flush();
539}
540
541pub fn writeCommitPage(
542 arena: mem.Allocator,
543 args: *const RepoArgs,
544 out_repo_dir: fs.Dir,
545 commit: git.Commit,
546) !void {
547 var commit_dir = try out_repo_dir.makeOpenPath(
548 try fmt.allocPrint(
549 arena,
550 "{s}/{s}",
551 .{ constants.web_objects_path, commit.hash },
552 ),
553 .{},
554 );
555 defer commit_dir.close();
556
557 const index_html = "index.html";
558
559 var commit_file = commit_dir.createFile(
560 index_html,
561 .{ .exclusive = !args.conf.full_regen },
562 ) catch |err| {
563 switch (err) {
564 error.PathAlreadyExists => return,
565 else => return err,
566 }
567 };
568 errdefer commit_dir.deleteFile(index_html) catch {};
569 defer commit_file.close();
570
571 println(" writing {s} commit {s}", .{ args.repo_name, commit.hash[0..10] });
572
573 var buf: [4096]u8 = undefined;
574 var file_writer = commit_file.writer(&buf);
575 const writer = &file_writer.interface;
576
577 const commit_label = try fmt.allocPrint(arena, "{s} (commit)", .{commit.hash[0..10]});
578
579 try templates.base_start(arena, writer, .{
580 .title = commit_label,
581 .opengraph = .{
582 .title = try fmt.allocPrint(arena, "{s}/objects/{s}", .{ args.repo_name, commit_label }),
583 .description = "",
584 },
585 .breadcrumbs = &.{
586 .{ .href = "/", .text = "repos" },
587 .{
588 .text = args.repo_name,
589 .href = try fmt.allocPrint(arena, "/{s}/{s}/{s}/", .{
590 constants.web_path,
591 args.repo_name,
592 constants.web_commits_path,
593 }),
594 },
595 .{ .text = commit_label },
596 },
597 });
598
599 try writer.print(
600 \\<p class="patch-link"><a href="../{s}.patch">download patch</a></p>
601 , .{commit.hash});
602
603 try writer.writeAll(
604 \\<pre class="commit-content">
605 \\
606 );
607
608 const commit_text = try git.show(arena, args.in_repo_dir, commit.hash);
609 const escaped_text = try html.escapeAlloc(arena, commit_text);
610 var lines = mem.splitScalar(u8, escaped_text, '\n');
611 while (lines.next()) |line| {
612 if (mem.startsWith(u8, line, "diff") or
613 mem.startsWith(u8, line, "index") or
614 mem.startsWith(u8, line, "+++") or
615 mem.startsWith(u8, line, "---"))
616 {
617 try writer.writeAll("<span class='bold'>");
618 try writer.writeAll(line);
619 try writer.writeAll("</span>\n");
620 } else if (mem.startsWith(u8, line, "@@")) {
621 const hunk_header_end = mem.indexOf(u8, line[2..], "@@").? + 4;
622 const hunk_header = line[0..hunk_header_end];
623 const rest = line[hunk_header_end..];
624 try writer.writeAll("<span class='hunk-header'>");
625 try writer.writeAll(hunk_header);
626 try writer.writeAll("</span>");
627 try writer.writeAll(rest);
628 try writer.writeAll("\n");
629 } else if (mem.startsWith(u8, line, "+")) {
630 try writer.writeAll("<span class='added'>");
631 try writer.writeAll(line);
632 try writer.writeAll("</span>\n");
633 } else if (mem.startsWith(u8, line, "-")) {
634 try writer.writeAll("<span class='removed'>");
635 try writer.writeAll(line);
636 try writer.writeAll("</span>\n");
637 } else {
638 try writer.writeAll(line);
639 try writer.writeAll("\n");
640 }
641 }
642
643 try writer.writeAll(
644 \\</pre>
645 \\
646 );
647
648 try templates.base_end(writer);
649 try writer.flush();
650
651 var objects_dir = try out_repo_dir.openDir(constants.web_objects_path, .{});
652 defer objects_dir.close();
653
654 const patch = try git.formatPatch(arena, args.in_repo_dir, commit.hash);
655
656 try objects_dir.writeFile(.{
657 .sub_path = try fmt.allocPrint(arena, "{s}.patch", .{commit.hash}),
658 .data = patch,
659 .flags = .{},
660 });
661}
662
663/// For each historical commit, iterate its blobs and generate if necessary
664pub fn writeHistoricalBlobPages(
665 args: *const RepoArgs,
666 out_repo_dir: fs.Dir,
667 processed_blob_hashes: *std.StringHashMap(bool),
668) !void {
669 const arena = args.arena;
670
671 if (args.commits.len <= 1) return;
672 // skip latest commit because it has already been processed in
673 // writeRepoPage()
674 const commits = args.commits[1..];
675
676 var objects_dir = try out_repo_dir.makeOpenPath(constants.web_objects_path, .{});
677 defer objects_dir.close();
678
679 for (commits) |cmt| {
680 var treeWalker = try git.walkTree(arena, args.in_repo_dir, cmt.hash);
681 while (try treeWalker.next()) |src_file| {
682 if (processed_blob_hashes.get(src_file.hash) == null) {
683 try writeBlobPage(args, objects_dir, src_file);
684 try processed_blob_hashes.put(src_file.hash, true);
685 }
686 }
687 }
688}
689
690pub fn writeBlobPage(
691 args: *const RepoArgs,
692 objects_dir: fs.Dir,
693 src_file: git.File,
694) !void {
695 const arena = args.arena;
696
697 var out_dir = try objects_dir.makeOpenPath(src_file.hash, .{});
698 defer out_dir.close();
699
700 const object_type = try git.objectType(arena, args.in_repo_dir, src_file.hash);
701 if (object_type != .blob) {
702 return;
703 }
704
705 const index_html = "index.html";
706
707 var file = out_dir.createFile(
708 index_html,
709 .{ .exclusive = !args.conf.full_regen },
710 ) catch |err| {
711 switch (err) {
712 error.PathAlreadyExists => return,
713 else => return err,
714 }
715 };
716 errdefer out_dir.deleteFile(index_html) catch {};
717 defer file.close();
718
719 println(" writing {s} blob: {s}", .{ args.repo_name, src_file.hash });
720
721 var buf: [4096]u8 = undefined;
722 var file_writer = file.writer(&buf);
723 const writer = &file_writer.interface;
724
725 const file_label = try std.fmt.allocPrint(
726 arena,
727 "{s}: {s}",
728 .{ src_file.hash[0..10], src_file.path },
729 );
730 const human_readable_size = try utils.humanReadableSize(arena, src_file.size);
731
732 try templates.base_start(arena, writer, .{
733 .title = try std.fmt.allocPrint(
734 arena,
735 "{s} - {s}",
736 .{ file_label, args.repo_name },
737 ),
738 .opengraph = .{
739 .title = try fmt.allocPrint(
740 arena,
741 "{s}/objects/{s}",
742 .{ args.repo_name, file_label },
743 ),
744 .description = try fmt.allocPrint(arena, "size: {s}", .{human_readable_size}),
745 },
746 .breadcrumbs = &.{ .{
747 .href = "/",
748 .text = "repos",
749 }, .{
750 .href = "../../",
751 .text = args.repo_name,
752 }, .{
753 .text = file_label,
754 } },
755 });
756
757 try writer.print(
758 \\<p>size: {0s}</p>
759 , .{human_readable_size});
760
761 try writer.writeAll(
762 \\<pre class="blob-content">
763 \\
764 );
765
766 // This wastefully reads the whole file into memory.
767 // TODO: Try to implement a git.catFile() that only reads the
768 // first n bytes.
769 const file_content = try git.catFile(
770 arena,
771 args.in_repo_dir,
772 src_file.hash,
773 );
774 const blob_type = utils.blobType(src_file.path);
775
776 if (file_content.len <= 1024 * 1024 * 16 and
777 (blob_type == .image or blob_type == .video))
778 {
779 const src_file_name =
780 if (mem.lastIndexOfScalar(u8, src_file.path, '/')) |slash_idx|
781 src_file.path[slash_idx + 1 ..]
782 else
783 src_file.path;
784
785 // write the blob's actual content next to its html page
786 var blob_file = try out_dir.createFile(src_file_name, .{});
787 errdefer out_dir.deleteFile(src_file_name) catch {};
788 defer blob_file.close();
789 try blob_file.writeAll(file_content);
790
791 // write <img> markup in blob page
792 const escaped_file_name = try html.escapeAlloc(arena, src_file_name);
793
794 switch (blob_type) {
795 .image => {
796 try writer.print(
797 \\<img src="{0s}" alt="{0s}">
798 \\
799 , .{
800 escaped_file_name,
801 });
802 },
803 .video => {
804 try writer.print(
805 \\<video src="{0s}" controls></video>
806 \\
807 , .{
808 escaped_file_name,
809 });
810 },
811 else => return error.UnexpectedBlobType,
812 }
813 } else if (git.isBinary(file_content)) {
814 try writer.writeAll(
815 \\<span style="opacity: 0.5">(binary data)</span>
816 \\
817 );
818 } else {
819 try writer.writeAll(
820 \\<table class="text-blob-content">
821 \\ <tbody>
822 \\
823 );
824
825 const escaped = try html.escapeAlloc(arena, file_content);
826 var lines = mem.splitScalar(u8, escaped, '\n');
827 var line_number: usize = 0;
828 while (lines.next()) |line| {
829 if (line.len == 0 and lines.peek() == null) {
830 break;
831 }
832
833 line_number += 1;
834 try writer.writeAll(
835 \\<tr>
836 );
837
838 try writer.print(
839 \\<td class="line-number"><a href="#L{0d}" id="L{0d}">{0d}</a></td><td class="line-content">{1s}</td>
840 , .{ line_number, line });
841
842 try writer.writeAll(
843 \\</tr>
844 );
845 }
846
847 try writer.writeAll(
848 \\ </tbody>
849 \\</table>
850 );
851 }
852
853 try writer.writeAll(
854 \\</pre>
855 \\
856 );
857
858 try writer.flush();
859}
860
861test "all" {
862 _ = @import("html.zig");
863 _ = @import("markdown.zig");
864 std.testing.refAllDeclsRecursive(@This());
865}