startgitStatic page generator for git repositories |
git clone git://git.dimitrijedobrota.com/startgit.git |
Log | Files | Refs | README | LICENSE | HACKING | CONTRIBUTING | CODE_OF_CONDUCT | BUILDING |
startgit.cpp (20898B)
0 #include <cmath>
1 #include <filesystem>
2 #include <format>
3 #include <fstream>
4 #include <iostream>
5 #include <string>
7 #include <git2wrap/error.hpp>
8 #include <git2wrap/libgit2.hpp>
9 #include <hemplate/atom.hpp>
10 #include <hemplate/html.hpp>
11 #include <hemplate/rss.hpp>
12 #include <poafloc/error.hpp>
13 #include <poafloc/poafloc.hpp>
15 #include "arguments.hpp"
16 #include "document.hpp"
17 #include "html.hpp"
18 #include "repository.hpp"
19 #include "utils.hpp"
21 using hemplate::element;
22 namespace
23 {
25 template<std::ranges::forward_range R>
26 element wtable(
27 std::initializer_list<std::string_view> head_content,
28 const R& range,
29 based::Procedure<element, std::ranges::range_value_t<R>> auto proc
30 )
31 {
32 using namespace hemplate::html; // NOLINT
34 return table {
35 thead {
36 tr {
37 transform(
38 head_content,
39 [](const auto& elem)
40 {
41 return td {
42 elem,
43 };
44 }
45 ),
46 },
47 },
48 tbody {
49 transform(range, proc),
50 },
51 };
52 }
54 } // namespace
56 namespace startgit
57 {
59 element page_title(
60 const repository& repo,
61 const branch& branch,
62 const std::string& relpath = "./"
63 )
64 {
65 using namespace hemplate::html; // NOLINT
67 return element {
68 table {
69 tr {
70 td {
71 h1 {repo.get_name()},
72 span {repo.get_description()},
73 },
74 },
75 tr {
76 td {
77 "git clone ",
78 aHref {repo.get_url(), repo.get_url()},
79 },
80 },
81 tr {
82 td {
83 aHref {relpath + "log.html", "Log"},
84 " | ",
85 aHref {relpath + "files.html", "Files"},
86 " | ",
87 aHref {relpath + "refs.html", "Refs"},
88 transform(
89 branch.get_special(),
90 [&](const auto& file)
91 {
92 auto path = file.get_path();
93 const auto filename =
94 path.replace_extension("html").string();
95 const auto name = path.replace_extension().string();
97 return element {
98 " | ",
99 aHref {relpath + filename, name},
100 };
101 }
102 ),
103 },
104 },
105 },
106 hr {},
107 };
108 }
110 element commit_table(const branch& branch)
111 {
112 using namespace hemplate::html; // NOLINT
114 return wtable(
115 {"Date", "Commit message", "Author", "Files", "+", "-"},
116 branch.get_commits(),
117 [&](const auto& commit)
118 {
119 const auto idd = commit.get_id();
120 const auto url = std::format("./commit/{}.html", idd);
122 return tr {
123 td {commit.get_time()},
124 td {aHref {url, commit.get_summary()}},
125 td {commit.get_author_name()},
126 td {commit.get_diff().get_files_changed()},
127 td {commit.get_diff().get_insertions()},
128 td {commit.get_diff().get_deletions()},
129 };
130 }
131 );
132 }
134 element files_table(const branch& branch)
135 {
136 using namespace hemplate::html; // NOLINT
138 return wtable(
139 {"Mode", "Name", "Size"},
140 branch.get_files(),
141 [&](const auto& file)
142 {
143 const auto path = file.get_path().string();
144 const auto url = std::format("./file/{}.html", path);
145 const auto size = file.is_binary()
146 ? std::format("{}B", file.get_size())
147 : std::format("{}L", file.get_lines());
149 return tr {
150 td {file.get_filemode()},
151 td {aHref {url, path}},
152 td {size},
153 };
154 }
155 );
156 }
158 element branch_table(const repository& repo, const std::string& branch_name)
159 {
160 using namespace hemplate::html; // NOLINT
162 return element {
163 h2 {"Branches"},
164 wtable(
165 {" ", "Name", "Last commit date", "Author"},
166 repo.get_branches(),
167 [&](const auto& branch)
168 {
169 const auto& last = branch.get_last_commit();
170 const auto url = branch.get_name() != branch_name
171 ? std::format("../{}/refs.html", branch.get_name())
172 : "";
173 const auto name = branch.get_name() == branch_name ? "*" : " ";
175 return tr {
176 td {name},
177 td {aHref {url, branch.get_name()}},
178 td {last.get_time()},
179 td {last.get_author_name()},
180 };
181 }
182 ),
183 };
184 }
186 element tag_table(const repository& repo)
187 {
188 using namespace hemplate::html; // NOLINT
190 return element {
191 h2 {"Tags"},
192 wtable(
193 {" ", "Name", "Last commit date", "Author"},
194 repo.get_tags(),
195 [&](const auto& tag)
196 {
197 return tr {
198 td {" "},
199 td {tag.get_name()},
200 td {tag.get_time()},
201 td {tag.get_author()},
202 };
203 }
204 ),
205 };
206 }
208 element file_changes(const diff& diff)
209 {
210 using namespace hemplate::html; // NOLINT
212 return element {
213 b {"Diffstat:"},
214 wtable(
215 {},
216 diff.get_deltas(),
217 [&](const auto& delta)
218 {
219 static const char* marker = " ADMRC T ";
221 const std::string link = std::format("#{}", delta->new_file.path);
223 uint32_t add = delta.get_adds();
224 uint32_t del = delta.get_dels();
225 const uint32_t changed = add + del;
226 const uint32_t total = 80;
227 if (changed > total) {
228 const double percent = 1.0 * total / changed;
230 if (add > 0) {
231 add = static_cast<uint32_t>(std::lround(percent * add) + 1);
232 }
234 if (del > 0) {
235 del = static_cast<uint32_t>(std::lround(percent * del) + 1);
236 }
237 }
239 return tr {
240 td {std::string(1, marker[delta->status])}, // NOLINT
241 td {aHref {link, delta->new_file.path}},
242 td {"|"},
243 td {
244 span {{{"class", "add"}}, std::string(add, '+')},
245 span {{{"class", "del"}}, std::string(del, '-')},
246 },
247 };
248 }
249 ),
251 p {
252 std::format(
253 "{} files changed, {} insertions(+), {} deletions(-)",
254 diff.get_files_changed(),
255 diff.get_insertions(),
256 diff.get_deletions()
257 ),
258 },
259 };
260 }
262 element diff_hunk(const hunk& hunk)
263 {
264 using namespace hemplate::html; // NOLINT
266 const std::string header(hunk->header); // NOLINT
267 return element {
268 h4 {
269 std::format(
270 "@@ -{},{} +{},{} @@ ",
271 hunk->old_start,
272 hunk->old_lines,
273 hunk->new_start,
274 hunk->new_lines
275 ),
276 xmlencode(header.substr(header.rfind('@') + 2)),
277 },
278 span {
279 transform(
280 hunk.get_lines(),
281 [](const auto& line) -> element
282 {
283 using hemplate::html::div;
285 if (line.is_add()) {
286 return div {
287 {{"class", "inline add"}},
288 xmlencode(line.get_content()),
289 };
290 }
292 if (line.is_del()) {
293 return div {
294 {{"class", "inline del"}},
295 xmlencode(line.get_content()),
296 };
297 }
299 return div {
300 {{"class", "inline"}},
301 xmlencode(line.get_content()),
302 };
303 }
304 ),
305 },
306 };
307 }
309 element file_diffs(const diff& diff)
310 {
311 using namespace hemplate::html; // NOLINT
313 return transform(
314 diff.get_deltas(),
315 [](const auto& delta)
316 {
317 const auto& new_file = delta->new_file.path;
318 const auto& old_file = delta->new_file.path;
319 const auto new_link = std::format("../file/{}.html", new_file);
320 const auto old_link = std::format("../file/{}.html", old_file);
322 return element {
323 h3 {
324 {{"id", delta->new_file.path}},
325 "diff --git",
326 "a/",
327 aHref {new_link, new_file},
328 "b/",
329 aHref {old_link, old_file},
330 },
331 transform(delta.get_hunks(), diff_hunk),
332 };
333 }
334 );
335 }
337 element commit_diff(const commit& commit)
338 {
339 using namespace hemplate::html; // NOLINT
341 const auto url = std::format("../commit/{}.html", commit.get_id());
342 const auto mailto = std::string("mailto:") + commit.get_author_email();
344 return element {
345 table {
346 tbody {
347 tr {
348 td {b {"commit"}},
349 td {aHref {url, commit.get_id()}},
350 },
351 commit.get_parentcount() == 0 ? element {} : [&]() -> element
352 {
353 const auto purl =
354 std::format("../commit/{}.html", commit.get_parent_id());
356 return tr {
357 td {b {"parent"}},
358 td {aHref {purl, commit.get_parent_id()}},
359 };
360 }(),
361 tr {
362 td {b {"author"}},
363 td {
364 commit.get_author_name(),
365 "<",
366 aHref {mailto, commit.get_author_email()},
367 ">",
368 },
369 },
370 tr {
371 td {b {"date"}},
372 td {commit.get_time_long()},
373 },
374 },
375 },
376 br {},
377 p {
378 {{"class", "inline"}},
379 xmlencode(commit.get_message()),
380 },
381 file_changes(commit.get_diff()),
382 hr {},
383 file_diffs(commit.get_diff()),
384 };
385 }
387 element write_file_title(const file& file)
388 {
389 using namespace hemplate::html; // NOLINT
391 const auto path = file.get_path().filename().string();
393 return element {
394 h3 {std::format("{} ({}B)", path, file.get_size())},
395 hr {},
396 };
397 }
399 element write_file_content(const file& file)
400 {
401 using namespace hemplate::html; // NOLINT
403 if (file.is_binary()) {
404 return h4("Binary file");
405 }
407 const std::string str(file.get_content(), file.get_size());
408 std::stringstream sstr(str);
410 std::vector<std::string> lines;
411 std::string tmp;
413 while (std::getline(sstr, tmp, '\n')) {
414 lines.emplace_back(std::move(tmp));
415 }
417 int count = 0;
418 return span {
419 transform(
420 lines,
421 [&](const auto& line)
422 {
423 return hemplate::html::div {
424 {{"class", "inline"}},
425 std::format(
426 R"(<a id="{0}" href="#{0}">{0:5}</a> {1})",
427 count++,
428 xmlencode(line)
429 )
430 };
431 }
432 ),
433 };
434 }
436 void write_log(
437 const std::filesystem::path& base,
438 const repository& repo,
439 const branch& branch
440 )
441 {
442 std::ofstream ofs(base / "log.html");
443 document(repo, branch, "Commit list")
444 .render(
445 ofs,
446 [&]()
447 {
448 return element {
449 page_title(repo, branch),
450 commit_table(branch),
451 };
452 }
453 );
454 }
456 void write_file(
457 const std::filesystem::path& base,
458 const repository& repo,
459 const branch& branch
460 )
461 {
462 std::ofstream ofs(base / "files.html");
463 document {repo, branch, "File list"}.render(
464 ofs,
465 [&]()
466 {
467 return element {
468 page_title(repo, branch),
469 files_table(branch),
470 };
471 }
472 );
473 }
475 void write_refs(
476 const std::filesystem::path& base,
477 const repository& repo,
478 const branch& branch
479 )
480 {
481 std::ofstream ofs(base / "refs.html");
482 document {repo, branch, "Refs list"}.render(
483 ofs,
484 [&]()
485 {
486 return element {
487 page_title(repo, branch),
488 branch_table(repo, branch.get_name()),
489 tag_table(repo),
490 };
491 }
492 );
493 }
495 bool write_commits(
496 const std::filesystem::path& base,
497 const repository& repo,
498 const branch& branch
499 )
500 {
501 bool changed = false;
503 for (const auto& commit : branch.get_commits()) {
504 const std::string file = base / (commit.get_id() + ".html");
505 if (!args.force && std::filesystem::exists(file)) {
506 break;
507 }
509 std::ofstream ofs(file);
510 document {repo, branch, commit.get_summary(), "../"}.render(
511 ofs,
512 [&]()
513 {
514 return element {
515 page_title(repo, branch, "../"),
516 commit_diff(commit),
517 };
518 }
519 );
520 changed = true;
521 }
523 return changed;
524 }
526 void write_files(
527 const std::filesystem::path& base,
528 const repository& repo,
529 const branch& branch
530 )
531 {
532 for (const auto& file : branch.get_files()) {
533 const std::filesystem::path path =
534 base / (file.get_path().string() + ".html");
535 std::filesystem::create_directories(path.parent_path());
536 std::ofstream ofs(path);
538 std::string relpath = "../";
539 for (const char chr : file.get_path().string()) {
540 if (chr == '/') {
541 relpath += "../";
542 }
543 }
545 document {repo, branch, file.get_path().string(), relpath}.render(
546 ofs,
547 [&]()
548 {
549 return element {
550 page_title(repo, branch, relpath),
551 write_file_title(file),
552 write_file_content(file),
553 };
554 }
555 );
556 }
557 }
559 void write_readme_licence(
560 const std::filesystem::path& base,
561 const repository& repo,
562 const branch& branch
563 )
564 {
565 for (const auto& file : branch.get_special()) {
566 std::ofstream ofs(base / file.get_path().replace_extension("html"));
567 document {repo, branch, file.get_path().string()}.render(
568 ofs,
569 [&]()
570 {
571 std::string html;
573 static const auto process_output =
574 +[](const MD_CHAR* str, MD_SIZE size, void* data)
575 {
576 auto buffer = *static_cast<std::string*>(data);
577 buffer += std::string(str, size);
578 };
580 md_html(
581 file.get_content(),
582 static_cast<MD_SIZE>(file.get_size()),
583 process_output,
584 &html,
585 MD_DIALECT_GITHUB,
586 0
587 );
588 return element {
589 page_title(repo, branch),
590 html,
591 };
592 }
593 );
594 }
595 }
597 void write_atom(
598 std::ostream& ost, const branch& branch, const std::string& base_url
599 )
600 {
601 using namespace hemplate::atom; // NOLINT
602 using hemplate::atom::link;
604 ost << feed {
605 title {args.title},
606 subtitle {args.description},
607 id {base_url + '/'},
608 updated {format_time_now()},
609 author {name {args.author}},
610 linkSelf {base_url + "/atom.xml"},
611 linkAlternate {args.resource_url},
612 transform(
613 branch.get_commits(),
614 [&](const auto& commit)
615 {
616 const auto url =
617 std::format("{}/commit/{}.html", base_url, commit.get_id());
619 return entry {
620 id {url},
621 updated {format_time(commit.get_time_raw())},
622 title {commit.get_summary()},
623 linkHref {url},
624 author {
625 name {commit.get_author_name()},
626 email {commit.get_author_email()},
627 },
628 content {commit.get_message()},
629 };
630 }
631 ),
632 };
633 }
635 void write_rss(
636 std::ostream& ost, const branch& branch, const std::string& base_url
637 )
638 {
639 using namespace hemplate::rss; // NOLINT
640 using hemplate::rss::link;
641 using hemplate::rss::rss;
643 ost << xml {};
644 ost << rss {
645 channel {
646 title {args.title},
647 description {args.description},
648 link {base_url + '/'},
649 generator {"startgit"},
650 language {"en-us"},
651 atomLink {base_url + "/atom.xml"},
652 transform(
653 branch.get_commits(),
654 [&](const auto& commit)
655 {
656 const auto url =
657 std::format("{}/commit/{}.html", base_url, commit.get_id());
659 return item {
660 title {commit.get_summary()},
661 link {url},
662 guid {url},
663 pubDate {format_time(commit.get_time_raw())},
664 author {std::format(
665 "{} ({})",
666 commit.get_author_email(),
667 commit.get_author_name()
668 )},
669 };
670 }
671 ),
672 },
673 };
674 }
676 } // namespace startgit
678 int main(int argc, const char* argv[])
679 {
680 using namespace startgit; // NOLINT
681 using namespace poafloc; // NOLINT
683 auto program = parser<arguments_t> {
684 positional {
685 argument {"repository", &arguments_t::set_repository},
686 },
687 group {
688 "Output mode",
689 direct {
690 "o output",
691 &arguments_t::output_dir,
692 "DIR Output directory",
693 },
694 boolean {
695 "f force",
696 &arguments_t::force,
697 "Force write even if file exists",
698 },
699 list {
700 "s special",
701 &arguments_t::add_special,
702 "FILE Files to be rendered to html",
703 },
704 direct {
705 "g github",
706 &arguments_t::github,
707 "USERNAME Github username for url translation",
708 },
709 },
710 group {
711 "General Information",
712 direct {
713 "b base",
714 &arguments_t::set_base,
715 "URL Absolute destination",
716 },
717 direct {
718 "r resources",
719 &arguments_t::set_resource,
720 "URL Location of styles and scripts",
721 },
722 direct {
723 "a author",
724 &arguments_t::author,
725 "NAME Owner of the repository",
726 },
727 direct {
728 "t title",
729 &arguments_t::title,
730 "TITLE Title for the index page",
731 },
732 direct {
733 "d description",
734 &arguments_t::description,
735 "DESC Description for the index page",
736 },
737 },
738 };
740 try {
741 program(args, argc, argv);
742 if (args.repos.empty()) {
743 return -1;
744 }
746 const git2wrap::libgit2 libgit;
748 auto& output_dir = args.output_dir;
749 std::filesystem::create_directories(output_dir);
750 output_dir = std::filesystem::canonical(output_dir);
752 const repository repo(args.repos.front());
753 const std::filesystem::path base = args.output_dir / repo.get_name();
754 std::filesystem::create_directory(base);
756 for (const auto& branch : repo.get_branches()) {
757 const std::filesystem::path base_branch = base / branch.get_name();
758 std::filesystem::create_directory(base_branch);
760 const std::filesystem::path commit = base_branch / "commit";
761 std::filesystem::create_directory(commit);
763 // always update refs in case of a new branch or tag
764 write_refs(base_branch, repo, branch);
766 const bool changed = write_commits(commit, repo, branch);
767 if (!args.force && !changed) {
768 continue;
769 };
771 write_log(base_branch, repo, branch);
772 write_file(base_branch, repo, branch);
773 write_readme_licence(base_branch, repo, branch);
775 const std::filesystem::path file = base_branch / "file";
776 std::filesystem::create_directory(file);
778 write_files(file, repo, branch);
780 const std::string relative =
781 std::filesystem::relative(base_branch, args.output_dir);
782 const auto absolute = "https://git.dimitrijedobrota.com/" + relative;
784 std::ofstream atom(base_branch / "atom.xml");
785 write_atom(atom, branch, absolute);
787 std::ofstream rss(base_branch / "rss.xml");
788 write_rss(rss, branch, absolute);
789 }
790 } catch (const git2wrap::error<git2wrap::error_code_t::enotfound>& err) {
791 std::cerr << std::format(
792 "Warning: {} is not a repository\n", args.repos.front().string()
793 );
794 } catch (const poafloc::runtime_error& err) {
795 std::cerr << std::format("Error (poafloc): {}\n", err.what());
796 return 1;
797 } catch (const git2wrap::runtime_error& err) {
798 std::cerr << std::format("Error (git2wrap): {}\n", err.what());
799 } catch (const std::runtime_error& err) {
800 std::cerr << std::format("Error: {}\n", err.what());
801 } catch (...) {
802 std::cerr << std::format("Unknown error\n");
803 }
805 return 0;
806 }