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 (21542B)
1 #include <cmath> 2 #include <cstring> 3 #include <filesystem> 4 #include <format> 5 #include <fstream> 6 #include <iostream> 7 #include <string> 8 9 #include <git2wrap/error.hpp> 10 #include <git2wrap/libgit2.hpp> 11 #include <hemplate/classes.hpp> 12 #include <poafloc/poafloc.hpp> 13 14 #include "arguments.hpp" 15 #include "common.hpp" 16 #include "html.hpp" 17 #include "repository.hpp" 18 #include "utils.hpp" 19 20 namespace startgit 21 { 22 23 void write_title(std::ostream& ost, 24 const repository& repo, 25 const branch& branch, 26 const std::string& relpath = "./") 27 { 28 using namespace hemplate; // NOLINT 29 30 const auto dropdown = [&]() 31 { 32 auto span = html::span(); 33 span.add(html::label("Branch: ").set("for", "branch")); 34 span.add(html::select( 35 {{"id", "branch"}, {"onChange", "switchPage(this.value)"}})); 36 37 for (const auto& c_branch : repo.get_branches()) { 38 auto option = html::option(c_branch.get_name()); 39 option.set("value", c_branch.get_name()); 40 if (c_branch.get_name() == branch.get_name()) { 41 option.set("selected", "true"); 42 } 43 span.add(option); 44 } 45 46 span.add(html::select()); 47 return span; 48 }(); 49 50 ost << html::table(); 51 ost << html::tr().add(html::td() 52 .add(html::h1(repo.get_name())) 53 .add(html::span(repo.get_description()))); 54 ost << html::tr().add( 55 html::td() 56 .add(html::text("git clone ")) 57 .add(html::a(repo.get_url()).set("href", repo.get_url()))); 58 59 ost << html::tr() << html::td(); 60 ost << html::a("Log").set("href", relpath + "log.html"); 61 ost << html::text(" | ") 62 << html::a("Files").set("href", relpath + "files.html"); 63 ost << html::text(" | ") 64 << html::a("Refs").set("href", relpath + "refs.html"); 65 66 for (const auto& file : branch.get_special()) { 67 const auto filename = file.get_path().replace_extension("html").string(); 68 const auto name = file.get_path().replace_extension().string(); 69 ost << html::text(" | ") << html::a(name).set("href", relpath + filename); 70 } 71 72 ost << html::text(" | ") << dropdown; 73 ost << html::td() << html::tr(); 74 75 ost << html::table(); 76 ost << html::hr(); 77 } 78 79 void write_commit_table(std::ostream& ost, const branch& branch) 80 { 81 using namespace hemplate; // NOLINT 82 83 ost << html::table(); 84 ost << html::thead(); 85 ost << html::tr() 86 .add(html::td("Date")) 87 .add(html::td("Commit message")) 88 .add(html::td("Author")) 89 .add(html::td("Files")) 90 .add(html::td("+")) 91 .add(html::td("-")); 92 ost << html::thead(); 93 ost << html::tbody(); 94 95 for (const auto& commit : branch.get_commits()) { 96 const auto url = std::format("./commit/{}.html", commit.get_id()); 97 98 ost << html::tr() 99 .add(html::td(commit.get_time())) 100 .add(html::td().add( 101 html::a(commit.get_summary()).set("href", url))) 102 .add(html::td(commit.get_author_name())) 103 .add(html::td(commit.get_diff().get_files_changed())) 104 .add(html::td(commit.get_diff().get_insertions())) 105 .add(html::td(commit.get_diff().get_deletions())); 106 } 107 108 ost << html::tbody(); 109 ost << html::table(); 110 } 111 112 void write_files_table(std::ostream& ost, const branch& branch) 113 { 114 using namespace hemplate; // NOLINT 115 116 ost << html::table(); 117 ost << html::thead(); 118 ost << html::tr() 119 .add(html::td("Mode")) 120 .add(html::td("Name")) 121 .add(html::td("Size")); 122 ost << html::thead(); 123 ost << html::tbody(); 124 125 for (const auto& file : branch.get_files()) { 126 const auto url = std::format("./file/{}.html", file.get_path().string()); 127 const auto size = file.is_binary() ? std::format("{}B", file.get_size()) 128 : std::format("{}L", file.get_lines()); 129 130 ost << html::tr() 131 .add(html::td(file.get_filemode())) 132 .add(html::td().add(html::a(file.get_path()).set("href", url))) 133 .add(html::td(size)); 134 } 135 136 ost << html::tbody(); 137 ost << html::table(); 138 } 139 140 void write_branch_table(std::ostream& ost, 141 const repository& repo, 142 const std::string& branch_name) 143 { 144 using namespace hemplate; // NOLINT 145 146 ost << html::h2("Branches"); 147 ost << html::table(); 148 ost << html::thead(); 149 ost << html::tr() 150 .add(html::td(" ")) 151 .add(html::td("Name")) 152 .add(html::td("Last commit date")) 153 .add(html::td("Author")); 154 ost << html::thead(); 155 ost << html::tbody(); 156 157 for (const auto& branch : repo.get_branches()) { 158 const auto& last = branch.get_last_commit(); 159 const auto url = branch.get_name() != branch_name 160 ? std::format("../{}/refs.html", branch.get_name()) 161 : ""; 162 163 ost << html::tr() 164 .add(html::td(branch.get_name() == branch_name ? "*" : " ")) 165 .add(html::td().add(html::a(branch.get_name()).set("href", url))) 166 .add(html::td(last.get_time())) 167 .add(html::td(last.get_author_name())); 168 } 169 170 ost << html::tbody(); 171 ost << html::table(); 172 } 173 174 void write_tag_table(std::ostream& ost, const repository& repo) 175 { 176 using namespace hemplate; // NOLINT 177 178 ost << html::h2("Tags"); 179 ost << html::table(); 180 ost << html::thead(); 181 ost << html::tr() 182 .add(html::td(" ")) 183 .add(html::td("Name")) 184 .add(html::td("Last commit date")) 185 .add(html::td("Author")); 186 ost << html::thead(); 187 ost << html::tbody(); 188 189 for (const auto& tag : repo.get_tags()) { 190 ost << html::tr() 191 .add(html::td(" ")) 192 .add(html::td(tag.get_name())) 193 .add(html::td(tag.get_time())) 194 .add(html::td(tag.get_author())); 195 } 196 197 ost << html::tbody(); 198 ost << html::table(); 199 } 200 201 void write_file_changes(std::ostream& ost, const diff& diff) 202 { 203 using namespace hemplate; // NOLINT 204 205 ost << html::b("Diffstat:"); 206 ost << html::table() << html::tbody(); 207 208 for (const auto& delta : diff.get_deltas()) { 209 static const char* marker = " ADMRC T "; 210 211 const std::string link = std::format("#{}", delta->new_file.path); 212 213 uint32_t add = delta.get_adds(); 214 uint32_t del = delta.get_dels(); 215 const uint32_t changed = add + del; 216 const uint32_t total = 80; 217 if (changed > total) { 218 const double percent = 1.0 * total / changed; 219 220 if (add > 0) { 221 add = static_cast<uint32_t>(std::lround(percent * add) + 1); 222 } 223 224 if (del > 0) { 225 del = static_cast<uint32_t>(std::lround(percent * del) + 1); 226 } 227 } 228 229 ost << html::tr() 230 .add(html::td(std::string(1, marker[delta->status]))) // NOLINT 231 .add(html::td().add( 232 html::a(delta->new_file.path).set("href", link))) 233 .add(html::td("|")) 234 .add(html::td() 235 .add(html::span() 236 .add(html::text(std::string(add, '+'))) 237 .set("class", "add")) 238 .add(html::span() 239 .add(html::text(std::string(del, '-'))) 240 .set("class", "del"))); 241 } 242 243 ost << html::tbody() << html::table(); 244 ost << html::p( 245 std::format("{} files changed, {} insertions(+), {} deletions(-)", 246 diff.get_files_changed(), 247 diff.get_insertions(), 248 diff.get_deletions())); 249 } 250 251 void write_file_diffs(std::ostream& ost, const diff& diff) 252 { 253 using namespace hemplate; // NOLINT 254 255 for (const auto& delta : diff.get_deltas()) { 256 const auto new_link = std::format("../file/{}.html", delta->new_file.path); 257 const auto old_link = std::format("../file/{}.html", delta->old_file.path); 258 259 ost << html::h3().set("id", delta->new_file.path); 260 ost << "diff --git"; 261 ost << " a/" << html::a(delta->new_file.path).set("href", new_link); 262 ost << " b/" << html::a(delta->old_file.path).set("href", old_link); 263 ost << html::h3(); 264 265 for (const auto& hunk : delta.get_hunks()) { 266 const std::string header(hunk->header); // NOLINT 267 268 ost << html::h4(); 269 ost << std::format("@@ -{},{} +{},{} @@ ", 270 hunk->old_start, 271 hunk->old_lines, 272 hunk->new_start, 273 hunk->new_lines); 274 275 xmlencode(ost, header.substr(header.rfind('@') + 2)); 276 ost << html::h4(); 277 278 ost << html::span().set("style", "white-space: pre"); 279 for (const auto& line : hunk.get_lines()) { 280 auto div = html::div(); 281 if (line.is_add()) { 282 div.set("class", "add"); 283 } else if (line.is_del()) { 284 div.set("class", "del"); 285 } 286 287 ost << div; 288 xmlencode(ost, line.get_content()); 289 ost << div; 290 } 291 ost << html::span(); 292 } 293 } 294 } 295 296 void write_commit_diff(std::ostream& ost, const commit& commit) 297 { 298 using namespace hemplate; // NOLINT 299 300 ost << html::table() << html::tbody(); 301 302 const auto url = std::format("../commit/{}.html", commit.get_id()); 303 ost << html::tr() 304 .add(html::td().add(html::b("commit"))) 305 .add(html::td().add(html::a(commit.get_id()).set("href", url))); 306 307 if (commit.get_parentcount() > 0) { 308 const auto purl = std::format("../commit/{}.html", commit.get_parent_id()); 309 ost << html::tr() 310 .add(html::td().add(html::b("parent"))) 311 .add(html::td().add( 312 html::a(commit.get_parent_id()).set("href", purl))); 313 } 314 315 const auto mailto = std::string("mailto:") + commit.get_author_email(); 316 ost << html::tr(); 317 ost << html::td().add(html::b("author")); 318 ost << html::td() << commit.get_author_name() << " <"; 319 ost << html::a(commit.get_author_email()).set("href", mailto); 320 ost << ">" << html::td(); 321 ost << html::tr(); 322 323 ost << html::tr() 324 .add(html::td().add(html::b("date"))) 325 .add(html::td(commit.get_time_long())); 326 ost << html::tbody() << html::table(); 327 328 ost << html::br() << html::p().set("style", "white-space: pre;"); 329 xmlencode(ost, commit.get_message()); 330 ost << html::p(); 331 332 write_file_changes(ost, commit.get_diff()); 333 ost << html::hr(); 334 write_file_diffs(ost, commit.get_diff()); 335 } 336 337 void write_file_title(std::ostream& ost, const file& file) 338 { 339 using namespace hemplate; // NOLINT 340 341 ost << html::h3(std::format( 342 "{} ({}B)", file.get_path().filename().string(), file.get_size())); 343 ost << html::hr(); 344 } 345 346 void write_file_content(std::ostream& ost, const file& file) 347 { 348 using namespace hemplate; // NOLINT 349 350 if (file.is_binary()) { 351 ost << html::h4("Binary file"); 352 return; 353 } 354 355 const std::string str(file.get_content(), file.get_size()); 356 std::stringstream sstr(str); 357 358 std::string line; 359 360 ost << html::span().set("style", "white-space: pre;"); 361 for (int count = 1; std::getline(sstr, line, '\n'); count++) { 362 ost << std::format( 363 R"(<a id="{}" href="#{}">{:5}</a>)", count, count, count); 364 ost << " "; 365 xmlencode(ost, line); 366 ost << '\n'; 367 } 368 ost << html::span(); 369 } 370 371 void write_html(std::ostream& ost, const file& file) 372 { 373 static const auto process_output = 374 +[](const MD_CHAR* str, MD_SIZE size, void* data) 375 { 376 std::ofstream& ofs = *static_cast<std::ofstream*>(data); 377 ofs << std::string(str, size); 378 }; 379 380 md_html(file.get_content(), 381 static_cast<MD_SIZE>(file.get_size()), 382 process_output, 383 &ost, 384 MD_DIALECT_GITHUB, 385 0); 386 } 387 388 void write_log(const std::filesystem::path& base, 389 const repository& repo, 390 const branch& branch) 391 { 392 std::ofstream ofs(base / "log.html"); 393 394 write_header(ofs, repo, branch, "Commit list"); 395 write_title(ofs, repo, branch); 396 write_commit_table(ofs, branch); 397 write_footer(ofs); 398 } 399 400 void write_file(const std::filesystem::path& base, 401 const repository& repo, 402 const branch& branch) 403 { 404 std::ofstream ofs(base / "files.html"); 405 406 write_header(ofs, repo, branch, "File list"); 407 write_title(ofs, repo, branch); 408 write_files_table(ofs, branch); 409 write_footer(ofs); 410 } 411 412 void write_refs(const std::filesystem::path& base, 413 const repository& repo, 414 const branch& branch) 415 { 416 std::ofstream ofs(base / "refs.html"); 417 418 write_header(ofs, repo, branch, "Refs list"); 419 write_title(ofs, repo, branch); 420 write_branch_table(ofs, repo, branch.get_name()); 421 write_tag_table(ofs, repo); 422 write_footer(ofs); 423 } 424 425 bool write_commits(const std::filesystem::path& base, 426 const repository& repo, 427 const branch& branch) 428 { 429 bool changed = false; 430 431 for (const auto& commit : branch.get_commits()) { 432 const std::string file = base / (commit.get_id() + ".html"); 433 if (!args.force && std::filesystem::exists(file)) { 434 break; 435 } 436 std::ofstream ofs(file); 437 438 write_header(ofs, repo, branch, commit.get_summary(), "../"); 439 write_title(ofs, repo, branch, "../"); 440 write_commit_diff(ofs, commit); 441 write_footer(ofs); 442 changed = true; 443 } 444 445 return changed; 446 } 447 448 void write_files(const std::filesystem::path& base, 449 const repository& repo, 450 const branch& branch) 451 { 452 for (const auto& file : branch.get_files()) { 453 const std::filesystem::path path = 454 base / (file.get_path().string() + ".html"); 455 std::filesystem::create_directories(path.parent_path()); 456 std::ofstream ofs(path); 457 458 std::string relpath = "../"; 459 for (const char chr : file.get_path().string()) { 460 if (chr == '/') { 461 relpath += "../"; 462 } 463 } 464 465 write_header(ofs, repo, branch, file.get_path(), relpath); 466 write_title(ofs, repo, branch, relpath); 467 write_file_title(ofs, file); 468 write_file_content(ofs, file); 469 write_footer(ofs); 470 } 471 } 472 473 void write_readme_licence(const std::filesystem::path& base, 474 const repository& repo, 475 const branch& branch) 476 { 477 for (const auto& file : branch.get_special()) { 478 std::ofstream ofs(base / file.get_path().replace_extension("html")); 479 write_header(ofs, repo, branch, file.get_path()); 480 write_title(ofs, repo, branch); 481 write_html(ofs, file); 482 write_footer(ofs); 483 } 484 } 485 486 void write_atom(std::ostream& ost, 487 const branch& branch, 488 const std::string& base_url) 489 { 490 using namespace hemplate; // NOLINT 491 492 ost << atom::feed(); 493 ost << atom::title(args.title); 494 ost << atom::subtitle(args.description); 495 496 ost << atom::id(base_url + '/'); 497 ost << atom::updated(atom::format_time_now()); 498 ost << atom::author().add(atom::name(args.author)); 499 ost << atom::link(" ", {{"rel", "self"}, {"href", base_url + "/atom.xml"}}); 500 ost << atom::link(" ", 501 {{"href", args.resource_url}, 502 {"rel", "alternate"}, 503 {"type", "text/html"}}); 504 505 for (const auto& commit : branch.get_commits()) { 506 const auto url = 507 std::format("{}/commit/{}.html", base_url, commit.get_id()); 508 509 ost << atom::entry() 510 .add(atom::id(url)) 511 .add(atom::updated(atom::format_time(commit.get_time_raw()))) 512 .add(atom::title(commit.get_summary())) 513 .add(atom::link(" ").set("href", url)) 514 .add(atom::author() 515 .add(atom::name(commit.get_author_name())) 516 .add(atom::email(commit.get_author_email()))) 517 .add(atom::content(commit.get_message())); 518 } 519 520 ost << atom::feed(); 521 } 522 523 void write_rss(std::ostream& ost, 524 const branch& branch, 525 const std::string& base_url) 526 { 527 using namespace hemplate; // NOLINT 528 529 ost << xml(); 530 ost << rss::rss(); 531 ost << rss::channel(); 532 533 ost << rss::title(args.title); 534 ost << rss::description(args.description); 535 ost << rss::link(base_url + '/'); 536 ost << rss::generator("startgit"); 537 ost << rss::language("en-us"); 538 ost << rss::atomLink().set("href", base_url + "/atom.xml"); 539 540 for (const auto& commit : branch.get_commits()) { 541 const auto url = 542 std::format("{}/commit/{}.html", base_url, commit.get_id()); 543 544 ost << rss::item() 545 .add(rss::title(commit.get_summary())) 546 .add(rss::link(url)) 547 .add(rss::guid(url)) 548 .add(rss::pubDate(rss::format_time(commit.get_time_raw()))) 549 .add(rss::author(std::format("{} ({})", 550 commit.get_author_email(), 551 commit.get_author_name()))); 552 } 553 554 ost << rss::channel(); 555 ost << rss::rss(); 556 } 557 558 } // namespace startgit 559 560 namespace 561 { 562 563 int parse_opt(int key, const char* arg, poafloc::Parser* parser) 564 { 565 auto* l_args = static_cast<startgit::arguments_t*>(parser->input()); 566 switch (key) { 567 case 'o': 568 l_args->output_dir = arg; 569 break; 570 case 'b': 571 l_args->base_url = arg; 572 if (l_args->base_url.back() == '/') { 573 l_args->base_url.pop_back(); 574 } 575 break; 576 case 'r': 577 l_args->resource_url = arg; 578 if (l_args->resource_url.back() == '/') { 579 l_args->resource_url.pop_back(); 580 } 581 break; 582 case 'a': 583 l_args->author = arg; 584 break; 585 case 'd': 586 l_args->description = arg; 587 break; 588 case 'g': 589 l_args->github = arg; 590 break; 591 case 'f': 592 l_args->force = true; 593 break; 594 case 's': { 595 std::stringstream sstream(arg); 596 std::string crnt; 597 598 l_args->special.clear(); 599 while (std::getline(sstream, crnt, ',')) { 600 l_args->special.emplace(crnt); 601 } 602 603 break; 604 } 605 case poafloc::ARG: 606 if (!l_args->repos.empty()) { 607 std::cerr << std::format("Error: only one repository required\n"); 608 return -1; 609 } 610 611 try { 612 l_args->repos.emplace_back(std::filesystem::canonical(arg)); 613 } catch (const std::filesystem::filesystem_error& arr) { 614 std::cerr << std::format("Error: {} doesn't exist\n", arg); 615 return -1; 616 } 617 break; 618 case poafloc::END: 619 if (l_args->repos.empty()) { 620 std::cerr << std::format("Error: no repository provided\n"); 621 return -1; 622 } 623 break; 624 default: 625 break; 626 } 627 return 0; 628 } 629 630 // NOLINTBEGIN 631 // clang-format off 632 static const poafloc::option_t options[] = { 633 {0, 0, 0, 0, "Output mode", 1}, 634 {"output", 'o', "DIR", 0, "Output directory"}, 635 {"force", 'f', 0, 0, "Force write even if file exists"}, 636 {"special", 's', "NAME", 0, "Comma separated files to be rendered to html"}, 637 {"github", 'g', "USERNAME", 0, "Github username for url translation"}, 638 {0, 0, 0, 0, "General information", 2}, 639 {"base", 'b', "URL", 0, "Absolute destination URL"}, 640 {"resource", 'r', "URL", 0, "URL that houses styles and scripts"}, 641 {"author", 'a', "NAME", 0, "Owner of the repository"}, 642 {"title", 't', "TITLE", 0, "Title for the index page"}, 643 {"description", 'd', "DESC", 0, "Description for the index page"}, 644 {0, 0, 0, 0, "Informational Options", -1}, 645 {0}, 646 }; 647 // clang-format on 648 649 static const poafloc::arg_t arg { 650 options, 651 parse_opt, 652 "repository", 653 "", 654 }; 655 // NOLINTEND 656 657 } // namespace 658 659 int main(int argc, char* argv[]) 660 { 661 using namespace startgit; // NOLINT 662 663 if (poafloc::parse(&arg, argc, argv, 0, &args) != 0) { 664 std::cerr << "There was an error while parsing arguments\n"; 665 return 1; 666 } 667 668 try { 669 const git2wrap::libgit2 libgit; 670 671 auto& output_dir = args.output_dir; 672 std::filesystem::create_directories(output_dir); 673 output_dir = std::filesystem::canonical(output_dir); 674 675 const repository repo(args.repos[0]); 676 const std::filesystem::path base = args.output_dir / repo.get_name(); 677 std::filesystem::create_directory(base); 678 679 for (const auto& branch : repo.get_branches()) { 680 const std::filesystem::path base_branch = base / branch.get_name(); 681 std::filesystem::create_directory(base_branch); 682 683 const std::filesystem::path commit = base_branch / "commit"; 684 std::filesystem::create_directory(commit); 685 686 const bool changed = write_commits(commit, repo, branch); 687 if (!args.force && !changed) { 688 continue; 689 }; 690 691 write_log(base_branch, repo, branch); 692 write_file(base_branch, repo, branch); 693 write_refs(base_branch, repo, branch); 694 write_readme_licence(base_branch, repo, branch); 695 696 const std::filesystem::path file = base_branch / "file"; 697 std::filesystem::create_directory(file); 698 699 write_files(file, repo, branch); 700 701 const std::string relative = 702 std::filesystem::relative(base_branch, args.output_dir); 703 const auto absolute = "https://git.dimitrijedobrota.com/" + relative; 704 705 std::ofstream atom(base_branch / "atom.xml"); 706 write_atom(atom, branch, absolute); 707 708 std::ofstream rss(base_branch / "rss.xml"); 709 write_rss(rss, branch, absolute); 710 } 711 } catch (const git2wrap::error<git2wrap::error_code_t::ENOTFOUND>& err) { 712 std::cerr << std::format("Warning: {} is not a repository\n", 713 args.repos[0].string()); 714 } catch (const git2wrap::runtime_error& err) { 715 std::cerr << std::format("Error (git2wrap): {}\n", err.what()); 716 } catch (const std::runtime_error& err) { 717 std::cerr << std::format("Error: {}\n", err.what()); 718 } catch (...) { 719 std::cerr << std::format("Unknown error\n"); 720 } 721 722 return 0; 723 }