--- a/src/html.cpp Fri Feb 27 14:38:01 2026 +0100 +++ b/src/html.cpp Thu Mar 12 12:00:50 2026 +0100 @@ -27,18 +27,42 @@ #include <ranges> #include <cstdio> #include <cassert> +#include <iostream> using namespace std::chrono; namespace html { static constexpr const char* weekdays[] = {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}; + struct output_reference { + FILE *file; + explicit output_reference(FILE *f) : file{f} {} + output_reference(output_reference const &) = delete; + output_reference(output_reference && other) noexcept : file(other.file) { + other.file = nullptr; + } + output_reference &operator=(output_reference const &) = delete; + output_reference &operator=(output_reference && other) noexcept { + using std::swap; + swap(other.file, file); + return *this; + } + ~output_reference() { + if (file != nullptr && file != stdout) { + fclose(file); + file = nullptr; + } + } + }; + + static output_reference output{stdout}; + static unsigned indentation; static const char *tabs = " "; static void indent(int change = 0) { indentation += change; assert(indentation <= max_indentation); - fwrite(tabs, 4, indentation, stdout); + fwrite(tabs, 4, indentation, output.file); } static std::string encode(const std::string &data) { @@ -158,8 +182,21 @@ } } +int html::set_output(std::string const &path) { + if (path.empty()) { + output = output_reference{stdout}; + return 0; + } + FILE * f = fopen(path.c_str(), "w"); + if (f == nullptr) { + return -1; + } + output = output_reference{f}; + return 0; +} + void html::styles_and_script() { - puts(R"( <style> + fputs(R"( <style> table.heatmap { table-layout: fixed; border-collapse: separate; @@ -387,23 +424,27 @@ } }); }); - </script>)"); + </script> +)", output.file); } void html::open(bool fragment, unsigned char fragment_indent) { + indentation = 0; if (fragment) { indent(fragment_indent); - puts("<div class=\"heatmap-content\">"); + fputs("<div class=\"heatmap-content\">\n", output.file); indentation++; } else { - puts(R"(<!DOCTYPE html> + fputs(R"(<!DOCTYPE html> <html> <head> - <meta charset="UTF-8">)"); + <meta charset="UTF-8"> +)", output.file); styles_and_script(); - puts(R"( </head> + fputs(R"( </head> <body> - <div class="heatmap-content">)"); + <div class="heatmap-content"> +)", output.file); indentation = 3; } } @@ -411,32 +452,33 @@ void html::close(bool fragment) { if (fragment) { indent(-1); - puts("</div>"); + fputs("</div>\n", output.file); } else { - puts(" </div>\n </body>\n</html>"); + fputs(" </div>\n </body>\n</html>\n", output.file); } } void html::chart_begin(const std::string& repo, const std::string& author) { indent(); - printf("<div class=\"chart\" data-repo=\"%s\" data-author=\"%s\">\n", + fprintf(output.file, + "<div class=\"chart\" data-repo=\"%s\" data-author=\"%s\">\n", encode(repo).c_str(), encode(author).c_str()); indentation++; } void html::chart_end() { indent(-1); - puts("</div>"); + fputs("</div>\n", output.file); } void html::heading_repo(const std::string& repo) { indent(); - printf("<h1 data-repo=\"%1$s\">%1$s</h1>\n", encode(repo).c_str()); + fprintf(output.file, "<h1 data-repo=\"%1$s\">%1$s</h1>\n", encode(repo).c_str()); } void html::heading_author(const std::string& author, unsigned int total_commits) { indent(); - printf("<h2 title=\"Total commits: %u\">%s</h2>\n", + fprintf(output.file, "<h2 title=\"Total commits: %u\">%s</h2>\n", total_commits, encode(author).c_str()); } @@ -459,11 +501,11 @@ // now render the table heading indent(); - puts("<table class=\"heatmap\">"); + fputs("<table class=\"heatmap\">\n", output.file); indent(1); - puts("<tr>"); + fputs("<tr>\n", output.file); indent(1); - puts("<th></th>"); + fputs("<th></th>\n", output.file); for (unsigned i = 0 ; i < 12 ; i++) { indent(); const fm::commit_summary &summary = commits_per_month[i]; @@ -483,39 +525,41 @@ commit_summary = std::format("data-commits=\"{}\"", encode(commit_summary_json)); } std::string tags = build_tag_summary(summary.tags_with_date, hide_repo_names); - printf("<th scope=\"col\" title=\"Total commits: %u\" colspan=\"%d\" data-total=\"%u\" %s data-tags=\"%s\">%s</th>\n", + fprintf(output.file, + "<th scope=\"col\" title=\"Total commits: %u\" colspan=\"%d\" data-total=\"%u\" %s data-tags=\"%s\">%s</th>\n", total, colspans[i], total, commit_summary.c_str(), encode(tags).c_str(), months[i]); } else { - printf("<th scope=\"col\" class=\"zero-commits\" colspan=\"%d\">%s</th>\n", + fprintf(output.file, + "<th scope=\"col\" class=\"zero-commits\" colspan=\"%d\">%s</th>\n", colspans[i], months[i]); } } indent(-1); - puts("</tr>"); + fputs("</tr>\n", output.file); } void html::table_end() { indent(-1); - puts("</table>"); + fputs("</table>\n", output.file); } void html::row_begin(unsigned int row) { indent(); - puts("<tr>"); + fputs("<tr>\n", output.file); indent(1); - printf("<th scope=\"row\">%s</th>\n", weekdays[row]); + fprintf(output.file, "<th scope=\"row\">%s</th>\n", weekdays[row]); } void html::row_end() { indent(-1); - puts("</tr>"); + fputs("</tr>\n", output.file); } void html::cell_out_of_range() { indent(); - puts("<td class=\"out-of-range\"></td>"); + fputs("<td class=\"out-of-range\"></td>\n", output.file); } void html::cell(year_month_day ymd, bool hide_repo_names, const fm::commits &commits) { @@ -573,7 +617,8 @@ static_cast<unsigned>(ymd.day())); const unsigned total = commits.count(); indent(); - printf("<td class=\"%s\" title=\"%s: %u %s\" data-total=\"%u\" data-date=\"%s\" data-summaries=\"%s\" data-tags=\"%s\"></td>\n", + fprintf(output.file, + "<td class=\"%s\" title=\"%s: %u %s\" data-total=\"%u\" data-date=\"%s\" data-summaries=\"%s\" data-tags=\"%s\"></td>\n", color_class, date_str.c_str(), total,