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 (21755B)
0 #include <cmath> 1 #include <cstring> 2 #include <filesystem> 3 #include <format> 4 #include <fstream> 5 #include <iostream> 6 #include <string> 7 8 #include <git2wrap/error.hpp> 9 #include <git2wrap/libgit2.hpp> 10 #include <hemplate/atom.hpp> 11 #include <hemplate/html.hpp> 12 #include <hemplate/rss.hpp> 13 #include <poafloc/poafloc.hpp> 14 15 #include "arguments.hpp" 16 #include "document.hpp" 17 #include "html.hpp" 18 #include "repository.hpp" 19 #include "utils.hpp" 20 21 using hemplate::element; 22 namespace 23 { 24 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 33 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 } 53 54 } // namespace 55 56 namespace startgit 57 { 58 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 66 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(); 96 97 return element { 98 " | ", 99 aHref {relpath + filename, name}, 100 }; 101 } 102 ), 103 }, 104 }, 105 }, 106 hr {}, 107 }; 108 } 109 110 element commit_table(const branch& branch) 111 { 112 using namespace hemplate::html; // NOLINT 113 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); 121 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 } 133 134 element files_table(const branch& branch) 135 { 136 using namespace hemplate::html; // NOLINT 137 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()); 148 149 return tr { 150 td {file.get_filemode()}, 151 td {aHref {url, path}}, 152 td {size}, 153 }; 154 } 155 ); 156 } 157 158 element branch_table(const repository& repo, const std::string& branch_name) 159 { 160 using namespace hemplate::html; // NOLINT 161 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 ? "*" : " "; 174 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 } 185 186 element tag_table(const repository& repo) 187 { 188 using namespace hemplate::html; // NOLINT 189 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 } 207 208 element file_changes(const diff& diff) 209 { 210 using namespace hemplate::html; // NOLINT 211 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 "; 220 221 const std::string link = std::format("#{}", delta->new_file.path); 222 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; 229 230 if (add > 0) { 231 add = static_cast<uint32_t>(std::lround(percent * add) + 1); 232 } 233 234 if (del > 0) { 235 del = static_cast<uint32_t>(std::lround(percent * del) + 1); 236 } 237 } 238 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 ), 250 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 } 261 262 element diff_hunk(const hunk& hunk) 263 { 264 using namespace hemplate::html; // NOLINT 265 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; 284 285 if (line.is_add()) { 286 return div { 287 {{"class", "inline add"}}, 288 xmlencode(line.get_content()), 289 }; 290 } 291 292 if (line.is_del()) { 293 return div { 294 {{"class", "inline del"}}, 295 xmlencode(line.get_content()), 296 }; 297 } 298 299 return div { 300 {{"class", "inline"}}, 301 xmlencode(line.get_content()), 302 }; 303 } 304 ), 305 }, 306 }; 307 } 308 309 element file_diffs(const diff& diff) 310 { 311 using namespace hemplate::html; // NOLINT 312 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); 321 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 } 336 337 element commit_diff(const commit& commit) 338 { 339 using namespace hemplate::html; // NOLINT 340 341 const auto url = std::format("../commit/{}.html", commit.get_id()); 342 const auto mailto = std::string("mailto:") + commit.get_author_email(); 343 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()); 355 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 } 386 387 element write_file_title(const file& file) 388 { 389 using namespace hemplate::html; // NOLINT 390 391 const auto path = file.get_path().filename().string(); 392 393 return element { 394 h3 {std::format("{} ({}B)", path, file.get_size())}, 395 hr {}, 396 }; 397 } 398 399 element write_file_content(const file& file) 400 { 401 using namespace hemplate::html; // NOLINT 402 403 if (file.is_binary()) { 404 return h4("Binary file"); 405 } 406 407 const std::string str(file.get_content(), file.get_size()); 408 std::stringstream sstr(str); 409 410 std::vector<std::string> lines; 411 std::string tmp; 412 413 while (std::getline(sstr, tmp, '\n')) { 414 lines.emplace_back(std::move(tmp)); 415 } 416 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 } 435 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 } 455 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 } 474 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 } 494 495 bool write_commits( 496 const std::filesystem::path& base, 497 const repository& repo, 498 const branch& branch 499 ) 500 { 501 bool changed = false; 502 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 } 508 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 } 522 523 return changed; 524 } 525 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); 537 538 std::string relpath = "../"; 539 for (const char chr : file.get_path().string()) { 540 if (chr == '/') { 541 relpath += "../"; 542 } 543 } 544 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 } 558 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; 572 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 }; 579 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 } 596 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; 603 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()); 618 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 } 634 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; 642 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()); 658 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 } 675 676 } // namespace startgit 677 678 namespace 679 { 680 681 int parse_opt(int key, const char* arg, poafloc::Parser* parser) 682 { 683 auto* l_args = static_cast<startgit::arguments_t*>(parser->input()); 684 switch (key) { 685 case 'o': 686 l_args->output_dir = arg; 687 break; 688 case 'b': 689 l_args->base_url = arg; 690 if (l_args->base_url.back() == '/') { 691 l_args->base_url.pop_back(); 692 } 693 break; 694 case 'r': 695 l_args->resource_url = arg; 696 if (l_args->resource_url.back() == '/') { 697 l_args->resource_url.pop_back(); 698 } 699 break; 700 case 'a': 701 l_args->author = arg; 702 break; 703 case 'd': 704 l_args->description = arg; 705 break; 706 case 'g': 707 l_args->github = arg; 708 break; 709 case 'f': 710 l_args->force = true; 711 break; 712 case 's': { 713 std::stringstream str(arg); 714 std::string crnt; 715 716 l_args->special.clear(); 717 while (std::getline(str, crnt, ',')) { 718 l_args->special.emplace(crnt); 719 } 720 721 break; 722 } 723 case poafloc::ARG: 724 if (!l_args->repos.empty()) { 725 std::cerr << std::format("Error: only one repository required\n"); 726 return -1; 727 } 728 729 try { 730 l_args->repos.emplace_back(std::filesystem::canonical(arg)); 731 } catch (const std::filesystem::filesystem_error& arr) { 732 std::cerr << std::format("Error: {} doesn't exist\n", arg); 733 return -1; 734 } 735 break; 736 case poafloc::END: 737 if (l_args->repos.empty()) { 738 std::cerr << std::format("Error: no repository provided\n"); 739 return -1; 740 } 741 break; 742 default: 743 break; 744 } 745 return 0; 746 } 747 748 // NOLINTBEGIN 749 // clang-format off 750 static const poafloc::option_t options[] = { 751 {0, 0, 0, 0, "Output mode", 1}, 752 {"output", 'o', "DIR", 0, "Output directory"}, 753 {"force", 'f', 0, 0, "Force write even if file exists"}, 754 {"special", 's', "NAME", 0, "Comma separated files to be rendered to html"}, 755 {"github", 'g', "USERNAME", 0, "Github username for url translation"}, 756 {0, 0, 0, 0, "General information", 2}, 757 {"base", 'b', "URL", 0, "Absolute destination URL"}, 758 {"resource", 'r', "URL", 0, "URL that houses styles and scripts"}, 759 {"author", 'a', "NAME", 0, "Owner of the repository"}, 760 {"title", 't', "TITLE", 0, "Title for the index page"}, 761 {"description", 'd', "DESC", 0, "Description for the index page"}, 762 {0, 0, 0, 0, "Informational Options", -1}, 763 {0}, 764 }; 765 // clang-format on 766 767 static const poafloc::arg_t arg { 768 options, 769 parse_opt, 770 "repository", 771 "", 772 }; 773 // NOLINTEND 774 775 } // namespace 776 777 int main(int argc, char* argv[]) 778 { 779 using namespace startgit; // NOLINT 780 781 if (poafloc::parse(&arg, argc, argv, 0, &args) != 0) { 782 std::cerr << "There was an error while parsing arguments\n"; 783 return 1; 784 } 785 786 try { 787 const git2wrap::libgit2 libgit; 788 789 auto& output_dir = args.output_dir; 790 std::filesystem::create_directories(output_dir); 791 output_dir = std::filesystem::canonical(output_dir); 792 793 const repository repo(args.repos[0]); 794 const std::filesystem::path base = args.output_dir / repo.get_name(); 795 std::filesystem::create_directory(base); 796 797 for (const auto& branch : repo.get_branches()) { 798 const std::filesystem::path base_branch = base / branch.get_name(); 799 std::filesystem::create_directory(base_branch); 800 801 const std::filesystem::path commit = base_branch / "commit"; 802 std::filesystem::create_directory(commit); 803 804 // always update refs in case of a new branch or tag 805 write_refs(base_branch, repo, branch); 806 807 const bool changed = write_commits(commit, repo, branch); 808 if (!args.force && !changed) { 809 continue; 810 }; 811 812 write_log(base_branch, repo, branch); 813 write_file(base_branch, repo, branch); 814 write_readme_licence(base_branch, repo, branch); 815 816 const std::filesystem::path file = base_branch / "file"; 817 std::filesystem::create_directory(file); 818 819 write_files(file, repo, branch); 820 821 const std::string relative = 822 std::filesystem::relative(base_branch, args.output_dir); 823 const auto absolute = "https://git.dimitrijedobrota.com/" + relative; 824 825 std::ofstream atom(base_branch / "atom.xml"); 826 write_atom(atom, branch, absolute); 827 828 std::ofstream rss(base_branch / "rss.xml"); 829 write_rss(rss, branch, absolute); 830 } 831 } catch (const git2wrap::error<git2wrap::error_code_t::ENOTFOUND>& err) { 832 std::cerr << std::format( 833 "Warning: {} is not a repository\n", args.repos[0].string() 834 ); 835 } catch (const git2wrap::runtime_error& err) { 836 std::cerr << std::format("Error (git2wrap): {}\n", err.what()); 837 } catch (const std::runtime_error& err) { 838 std::cerr << std::format("Error: {}\n", err.what()); 839 } catch (...) { 840 std::cerr << std::format("Unknown error\n"); 841 } 842 843 return 0; 844 }