gout

A static git page generator
git clone https://git.bracken.jp/gout.git
Log | Files | Refs | README | LICENSE

commit 9b28e0a9c18e8d9817376f48a8772269e75b2c98
parent f265eea34d1984d43bd51b2fa50e7222fb7a35e8
Author: Chris Bracken <chris@bracken.jp>
Date:   Sat, 21 Feb 2026 01:07:27 +0900

writer: implement a gemini writer

Diffstat:
MBUILD.gn | 8++++++++
MREADME.md | 11+++++------
Msrc/git/git_tests.c | 2+-
Msrc/gout.c | 5++++-
Msrc/gout.h | 1+
Msrc/gout_index.c | 5++++-
Msrc/gout_index.h | 1+
Msrc/gout_index_main.c | 3++-
Msrc/gout_main.c | 3++-
Msrc/writer/BUILD.gn | 2++
Asrc/writer/gemini/BUILD.gn | 41+++++++++++++++++++++++++++++++++++++++++
Asrc/writer/gemini/commit.c | 262+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/writer/gemini/commit.h | 24++++++++++++++++++++++++
Asrc/writer/gemini/commit_tests.c | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/writer/gemini/fileblob.c | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/writer/gemini/fileblob.h | 18++++++++++++++++++
Asrc/writer/gemini/fileblob_tests.c | 47+++++++++++++++++++++++++++++++++++++++++++++++
Asrc/writer/gemini/files.c | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/writer/gemini/files.h | 16++++++++++++++++
Asrc/writer/gemini/files_tests.c | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/writer/gemini/index_writer.c | 46++++++++++++++++++++++++++++++++++++++++++++++
Asrc/writer/gemini/index_writer.h | 17+++++++++++++++++
Asrc/writer/gemini/log.c | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/writer/gemini/log.h | 22++++++++++++++++++++++
Asrc/writer/gemini/log_tests.c | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/writer/gemini/page.c | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/writer/gemini/page.h | 20++++++++++++++++++++
Asrc/writer/gemini/page_tests.c | 48++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/writer/gemini/refs.c | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/writer/gemini/refs.h | 16++++++++++++++++
Asrc/writer/gemini/refs_tests.c | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/writer/gemini/repo_index.c | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/writer/gemini/repo_index.h | 16++++++++++++++++
Asrc/writer/gemini/repo_index_tests.c | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/writer/gemini/repo_writer.c | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/writer/gemini/repo_writer.h | 31+++++++++++++++++++++++++++++++
Asrc/writer/gemini/repo_writer_tests.c | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/writer/index_writer.c | 29+++++++++++++++++++++++++++++
Msrc/writer/index_writer_tests.c | 24++++++++++++++++++++++++
Msrc/writer/repo_writer.c | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
40 files changed, 1868 insertions(+), 11 deletions(-)

diff --git a/BUILD.gn b/BUILD.gn @@ -81,6 +81,14 @@ executable("gout_tests") { "src/utils_tests.c", "src/writer/atom/atom_tests.c", "src/writer/cache/cache_tests.c", + "src/writer/gemini/log_tests.c", + "src/writer/gemini/refs_tests.c", + "src/writer/gemini/files_tests.c", + "src/writer/gemini/fileblob_tests.c", + "src/writer/gemini/commit_tests.c", + "src/writer/gemini/repo_index_tests.c", + "src/writer/gemini/repo_writer_tests.c", + "src/writer/gemini/page_tests.c", "src/writer/gopher/log_tests.c", "src/writer/gopher/refs_tests.c", "src/writer/gopher/files_tests.c", diff --git a/README.md b/README.md @@ -3,9 +3,7 @@ gout `gout` is a static git repository builder designed to be output-compatible with the excellent `stagit` tool, but adds support for multiple output formats in the -same binary: html (-H) and gopher (-G). - -Early support for Gemini is available on the `gemini` branch. +same binary: html (-H), gopher (-G), and gemini (-M). Prerequisites ------------- @@ -69,9 +67,10 @@ gopher support into the same tool, and eventually add gemini support. Things got a bit out of hand and I'm embarrassed to say the code now reads a bit more like Enterprise Stagit. -In my defence, the final compiled binary size is still small; under 46 kB on -FreeBSD. It would be worthwhile trying to simplify the code a bit, now that -things are separated out and the formatters are now effectively pluggable. +In my defence, the final compiled binary is still small; under 56 kB on FreeBSD, +yet it includes support for HTML, gopher, and gemini output. It would be +worthwhile trying to simplify the code a bit, now that things are separated out +and the formatters are now effectively pluggable. Patches welcome diff --git a/src/git/git_tests.c b/src/git/git_tests.c @@ -1,5 +1,5 @@ -#include <sys/stat.h> #include <stdlib.h> +#include <sys/stat.h> #include "git/internal.h" #include "utest.h" diff --git a/src/gout.c b/src/gout.c @@ -32,7 +32,7 @@ GoutOptions* gout_options_create(int argc, const char* argv[]) { int ch; optind = 1; opterr = 0; - while ((ch = getopt(argc, (char* const*)argv, "c:l:u:HG")) != -1) { + while ((ch = getopt(argc, (char* const*)argv, "c:l:u:HGM")) != -1) { switch (ch) { case 'c': // Mutually exclusive with -l. @@ -62,6 +62,9 @@ GoutOptions* gout_options_create(int argc, const char* argv[]) { case 'G': options.writer_type = kRepoWriterTypeGopher; break; + case 'M': + options.writer_type = kRepoWriterTypeGemini; + break; default: return NULL; } diff --git a/src/gout.h b/src/gout.h @@ -6,6 +6,7 @@ typedef enum { kRepoWriterTypeHtml, kRepoWriterTypeGopher, + kRepoWriterTypeGemini, } RepoWriterType; typedef struct GoutOptions { diff --git a/src/gout_index.c b/src/gout_index.c @@ -26,7 +26,7 @@ GoutIndexOptions* gout_index_options_create(int argc, const char* argv[]) { int ch; optind = 1; opterr = 0; - while ((ch = getopt(argc, (char* const*)argv, "m:HG")) != -1) { + while ((ch = getopt(argc, (char* const*)argv, "m:HGM")) != -1) { switch (ch) { case 'm': options.me_url = optarg; @@ -37,6 +37,9 @@ GoutIndexOptions* gout_index_options_create(int argc, const char* argv[]) { case 'G': options.writer_type = kIndexWriterTypeGopher; break; + case 'M': + options.writer_type = kIndexWriterTypeGemini; + break; default: return NULL; } diff --git a/src/gout_index.h b/src/gout_index.h @@ -6,6 +6,7 @@ typedef enum { kIndexWriterTypeHtml, kIndexWriterTypeGopher, + kIndexWriterTypeGemini, } IndexWriterType; typedef struct GoutIndexOptions { diff --git a/src/gout_index_main.c b/src/gout_index_main.c @@ -7,7 +7,8 @@ #include "fs_posix.h" static void gout_index_usage(const char* program_name) { - fprintf(stderr, "usage: %s [repodir...]\n", program_name); + fprintf(stderr, "usage: %s [-H | -G | -M] [-m me_url] [repodir...]\n", + program_name); } int main(int argc, const char* argv[]) { diff --git a/src/gout_main.c b/src/gout_main.c @@ -8,7 +8,8 @@ static void gout_usage(const char* program_name) { fprintf(stderr, - "usage: %s [-c cachefile | -l commits] [-u baseurl] repodir\n", + "usage: %s [-H | -G | -M] [-c cachefile | -l commits] [-u baseurl] " + "repodir\n", program_name); } diff --git a/src/writer/BUILD.gn b/src/writer/BUILD.gn @@ -5,6 +5,7 @@ source_set("index_writer") { ] configs += [ "//:gout_config" ] deps = [ + "//src/writer/gemini:index_writer", "//src/writer/gopher:index_writer", "//src/writer/html:index_writer", ] @@ -18,6 +19,7 @@ source_set("repo_writer") { ] configs += [ "//:gout_config" ] deps = [ + "//src/writer/gemini:repo_writer", "//src/writer/gopher:repo_writer", "//src/writer/html:repo_writer", ] diff --git a/src/writer/gemini/BUILD.gn b/src/writer/gemini/BUILD.gn @@ -0,0 +1,41 @@ +source_set("index_writer") { + sources = [ + "index_writer.c", + "index_writer.h", + "repo_index.c", + "repo_index.h", + ] + configs += [ "//:gout_config" ] + deps = [ + "//src:format", + "//src:utils", + ] + public_deps = [ "//src/git" ] +} + +source_set("repo_writer") { + sources = [ + "commit.c", + "commit.h", + "fileblob.c", + "fileblob.h", + "files.c", + "files.h", + "log.c", + "log.h", + "page.c", + "page.h", + "refs.c", + "refs.h", + "repo_writer.c", + "repo_writer.h", + ] + configs += [ "//:gout_config" ] + deps = [ + "//src:format", + "//src:utils", + "//src/writer/atom", + "//src/writer/cache", + ] + public_deps = [ "//src/git" ] +} diff --git a/src/writer/gemini/commit.c b/src/writer/gemini/commit.c @@ -0,0 +1,262 @@ +#include "writer/gemini/commit.h" + +#include <assert.h> +#include <err.h> +#include <limits.h> +#include <stdbool.h> +#include <stdint.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "format.h" +#include "git/delta.h" +#include "utils.h" +#include "writer/gemini/page.h" + +struct GeminiCommit { + FILE* out; + const FileSystem* fs; + GeminiPage* page; + DiffLimits limits; +}; + +static void gemini_commit_write_summary(GeminiCommit* commit, + const GitCommit* git_commit); +static void gemini_commit_write_diffstat(GeminiCommit* commit, + const GitCommit* git_commit); +static void gemini_commit_write_diffstat_row(GeminiCommit* commit, + const GitDelta* delta); +static void gemini_commit_write_diff_content(GeminiCommit* commit, + const GitCommit* git_commit); +static void gemini_commit_write_diff_delta(GeminiCommit* commit, + const GitDelta* delta); +static void gemini_commit_write_diff_hunk(GeminiCommit* commit, + const GitHunk* hunk); + +GeminiCommit* gemini_commit_create(const GitRepo* repo, + const FileSystem* fs, + const char* oid, + const char* title) { + assert(repo != NULL); + assert(fs != NULL); + assert(oid != NULL); + GeminiCommit* commit = ecalloc(1, sizeof(GeminiCommit)); + commit->fs = fs; + char filename[PATH_MAX]; + int r = snprintf(filename, sizeof(filename), "%s.gmi", oid); + if (r < 0 || (size_t)r >= sizeof(filename)) { + errx(1, "snprintf: filename truncated or error"); + } + char* path = path_concat("commit", filename); + commit->out = fs->fopen(path, "w"); + if (!commit->out) { + err(1, "fopen: %s", path); + } + free(path); + commit->page = gemini_page_create(commit->out, repo, fs, title, "../"); + commit->limits.max_files = 1000; + commit->limits.max_deltas = 1000; + commit->limits.max_delta_lines = 100000; + return commit; +} + +void gemini_commit_free(GeminiCommit* commit) { + if (!commit) { + return; + } + commit->fs->fclose(commit->out); + commit->out = NULL; + gemini_page_free(commit->page); + commit->page = NULL; + free(commit); +} + +void gemini_commit_begin(GeminiCommit* commit) { + assert(commit != NULL); + gemini_page_begin(commit->page); +} + +void gemini_commit_add_commit(GeminiCommit* commit, + const GitCommit* git_commit) { + assert(commit != NULL); + assert(git_commit != NULL); + FILE* out = commit->out; + + gemini_commit_write_summary(commit, git_commit); + + size_t deltas_len = git_commit->deltas_len; + if (deltas_len == 0) { + return; + } + size_t addcount = git_commit->addcount; + size_t delcount = git_commit->delcount; + size_t filecount = git_commit->filecount; + if (filecount > commit->limits.max_files || + deltas_len > commit->limits.max_deltas || + addcount > commit->limits.max_delta_lines || + delcount > commit->limits.max_delta_lines) { + fprintf(out, "\nDiff is too large, output suppressed.\n"); + return; + } + + gemini_commit_write_diffstat(commit, git_commit); + gemini_commit_write_diff_content(commit, git_commit); +} + +void gemini_commit_set_diff_limits(GeminiCommit* commit, + const DiffLimits* limits) { + assert(commit != NULL); + assert(limits != NULL); + commit->limits = *limits; +} + +void gemini_commit_end(GeminiCommit* commit) { + assert(commit != NULL); + gemini_page_end(commit->page); +} + +static void gemini_commit_write_summary(GeminiCommit* commit, + const GitCommit* git_commit) { + FILE* out = commit->out; + const char* oid = git_commit->oid; + fprintf(out, "=> ../commit/"); + print_percent_encoded(out, oid); + fprintf(out, ".gmi commit %s\n", oid); + + const char* parentoid = git_commit->parentoid; + if (parentoid && parentoid[0] != '\0') { + fprintf(out, "=> ../commit/"); + print_percent_encoded(out, parentoid); + fprintf(out, ".gmi parent %s\n", parentoid); + } + + fprintf(out, "\n```\n"); + fprintf(out, "Author: %s <%s>\n", git_commit->author_name, + git_commit->author_email); + fprintf(out, "Date: "); + print_time(out, git_commit->author_time, git_commit->author_timezone_offset); + fprintf(out, "\n"); + + const char* message = git_commit->message; + if (message) { + fprintf(out, "\n%s\n", message); + } + fprintf(out, "```\n\n"); +} + +static void gemini_commit_write_diffstat(GeminiCommit* commit, + const GitCommit* git_commit) { + fprintf(commit->out, "### Diffstat\n\n```\n"); + size_t delta_count = git_commit->deltas_len; + for (size_t i = 0; i < delta_count; i++) { + gemini_commit_write_diffstat_row(commit, git_commit->deltas[i]); + } + fprintf(commit->out, "```\n\n"); +} + +static void gemini_commit_write_diffstat_row(GeminiCommit* commit, + const GitDelta* delta) { + static const size_t kGraphWidth = 30; + FILE* out = commit->out; + + fprintf(out, " %c ", delta->status); + char filename[PATH_MAX]; + const char* old_file_path = delta->old_file_path; + const char* new_file_path = delta->new_file_path; + int r; + if (strcmp(old_file_path, new_file_path) == 0) { + r = snprintf(filename, sizeof(filename), "%s", old_file_path); + } else { + r = snprintf(filename, sizeof(filename), "%s -> %s", old_file_path, + new_file_path); + } + if (r < 0 || (size_t)r >= sizeof(filename)) { + errx(1, "snprintf: filename truncated or error"); + } + fprintf(out, "%-35.35s", filename); + + size_t changed = delta->addcount + delta->delcount; + fprintf(out, " | "); + fprintf(out, "%7zu ", changed); + char* added_graph = gitdelta_added_graph(delta, kGraphWidth); + fprintf(out, "%s", added_graph); + free(added_graph); + char* deleted_graph = gitdelta_deleted_graph(delta, kGraphWidth); + fprintf(out, "%s", deleted_graph); + free(deleted_graph); + fprintf(out, "\n"); +} + +static void gemini_commit_write_diff_content(GeminiCommit* commit, + const GitCommit* git_commit) { + FILE* out = commit->out; + size_t addcount = git_commit->addcount; + size_t delcount = git_commit->delcount; + size_t filecount = git_commit->filecount; + fprintf(out, "%zu file%s changed, %zu insertion%s(+), %zu deletion%s(-)\n\n", + filecount, filecount == 1 ? "" : "s", // + addcount, addcount == 1 ? "" : "s", // + delcount, delcount == 1 ? "" : "s"); + + size_t delta_count = git_commit->deltas_len; + for (size_t i = 0; i < delta_count; i++) { + gemini_commit_write_diff_delta(commit, git_commit->deltas[i]); + } +} + +static void gemini_commit_write_diff_delta(GeminiCommit* commit, + const GitDelta* delta) { + FILE* out = commit->out; + fprintf(out, "=> ../file/"); + print_percent_encoded(out, delta->new_file_path); + fprintf(out, ".gmi diff --git a/%s b/%s\n", delta->old_file_path, + delta->new_file_path); + + if (delta->is_binary) { + fprintf(out, "Binary files differ.\n\n"); + } else { + fprintf(out, "```\n"); + size_t hunk_count = delta->hunks_len; + for (size_t i = 0; i < hunk_count; i++) { + gemini_commit_write_diff_hunk(commit, delta->hunks[i]); + } + fprintf(out, "```\n\n"); + } +} + +static void gemini_commit_write_diff_hunk(GeminiCommit* commit, + const GitHunk* hunk) { + FILE* out = commit->out; + + // Output header. e.g. @@ -0,0 +1,3 @@ + fprintf(out, "%s\n", hunk->header); + + // Iterate over lines in hunk. + size_t line_count = hunk->lines_len; + for (size_t i = 0; i < line_count; i++) { + const GitHunkLine* line = hunk->lines[i]; + + const char* content = line->content; + size_t content_len = line->content_len; + + // Strip trailing newline/CR from content. + while (content_len > 0 && (content[content_len - 1] == '\n' || + content[content_len - 1] == '\r')) { + content_len--; + } + + int old_lineno = line->old_lineno; + int new_lineno = line->new_lineno; + if (old_lineno == -1) { + // Added line. Prefix with +. + fprintf(out, "+%.*s\n", (int)content_len, content); + } else if (new_lineno == -1) { + // Removed line. Prefix with -. + fprintf(out, "-%.*s\n", (int)content_len, content); + } else { + // Unchanged line. Prefix with ' '. + fprintf(out, " %.*s\n", (int)content_len, content); + } + } +} diff --git a/src/writer/gemini/commit.h b/src/writer/gemini/commit.h @@ -0,0 +1,24 @@ +#ifndef GOUT_WRITER_GEMINI_COMMIT_H_ +#define GOUT_WRITER_GEMINI_COMMIT_H_ + +#include <stdint.h> + +#include "git/commit.h" +#include "git/repo.h" +#include "utils.h" + +typedef struct GeminiCommit GeminiCommit; + +GeminiCommit* gemini_commit_create(const GitRepo* repo, + const FileSystem* fs, + const char* oid, + const char* title); +void gemini_commit_free(GeminiCommit* commit); +void gemini_commit_begin(GeminiCommit* commit); +void gemini_commit_add_commit(GeminiCommit* commit, + const GitCommit* git_commit); +void gemini_commit_set_diff_limits(GeminiCommit* commit, + const DiffLimits* limits); +void gemini_commit_end(GeminiCommit* commit); + +#endif // GOUT_WRITER_GEMINI_COMMIT_H_ diff --git a/src/writer/gemini/commit_tests.c b/src/writer/gemini/commit_tests.c @@ -0,0 +1,52 @@ +#include "writer/gemini/commit.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "fs_inmemory.h" +#include "git/commit.h" +#include "git/delta.h" +#include "git/repo.h" +#include "test_utils.h" +#include "utest.h" + +struct gemini_commit { + int dummy; +}; + +UTEST_F_SETUP(gemini_commit) { + inmemory_fs_clear(); +} + +UTEST_F_TEARDOWN(gemini_commit) {} + +UTEST_F(gemini_commit, basic) { + GitRepo repo = {.short_name = "test-repo"}; + GeminiCommit* commit_writer = + gemini_commit_create(&repo, g_fs_inmemory, "sha123", "Commit Title"); + ASSERT_NE(NULL, commit_writer); + + GitCommit commit = { + .oid = "sha123", + .parentoid = "parent456", + .summary = "Fix a bug", + .message = "Detailed description.", + .author_name = "Author Name", + .author_email = "author@example.com", + .author_time = 1702031400, + .author_timezone_offset = 0, + }; + + gemini_commit_begin(commit_writer); + gemini_commit_add_commit(commit_writer, &commit); + gemini_commit_end(commit_writer); + gemini_commit_free(commit_writer); + + const char* buf = inmemory_fs_get_buffer("commit/sha123.gmi"); + ASSERT_NE(NULL, buf); + + EXPECT_STR_SEQUENCE(buf, "Commit Title", + "=> ../commit/sha123.gmi commit sha123", "Author Name", + "Detailed description."); +} diff --git a/src/writer/gemini/fileblob.c b/src/writer/gemini/fileblob.c @@ -0,0 +1,140 @@ +#include "writer/gemini/fileblob.h" + +#include <assert.h> +#include <err.h> +#include <libgen.h> +#include <limits.h> +#include <stdbool.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "format.h" +#include "utils.h" +#include "writer/gemini/page.h" + +struct GeminiFileBlob { + const GitRepo* repo; + const FileSystem* fs; + FILE* out; + GeminiPage* page; +}; + +GeminiFileBlob* gemini_fileblob_create(const GitRepo* repo, + const FileSystem* fs, + const char* path) { + assert(repo != NULL); + assert(fs != NULL); + assert(path != NULL); + if (!is_safe_repo_path(path)) { + errx(1, "unsafe path: %s", path); + } + GeminiFileBlob* blob = ecalloc(1, sizeof(GeminiFileBlob)); + blob->repo = repo; + blob->fs = fs; + + // Create directories. + char filename_buffer[PATH_MAX]; + int r = snprintf(filename_buffer, sizeof(filename_buffer), "%s.gmi", path); + if (r < 0 || (size_t)r >= sizeof(filename_buffer)) { + errx(1, "snprintf: filename truncated or error"); + } + + char* out_path = path_concat("file", filename_buffer); + char* dir_copy = estrdup(out_path); + const char* d = dirname(dir_copy); + if (!d) { + err(1, "dirname"); + } + fs->mkdirp(d); + blob->out = fs->fopen(out_path, "w"); + if (!blob->out) { + err(1, "fopen: %s", out_path); + } + + // Compute the relative path. + char* relpath = relpath_from_dir(d); + free(dir_copy); + + char* path_copy = estrdup(path); + const char* title = basename(path_copy); + if (!title) { + err(1, "basename"); + } + blob->page = gemini_page_create(blob->out, repo, fs, title, relpath); + + free(out_path); + free(path_copy); + free(relpath); + + return blob; +} + +void gemini_fileblob_free(GeminiFileBlob* blob) { + if (!blob) { + return; + } + blob->fs->fclose(blob->out); + blob->out = NULL; + gemini_page_free(blob->page); + blob->page = NULL; + free(blob); +} + +void gemini_fileblob_begin(GeminiFileBlob* blob) { + assert(blob != NULL); + gemini_page_begin(blob->page); +} + +void gemini_fileblob_add_file(GeminiFileBlob* blob, const GitFile* file) { + assert(blob != NULL); + assert(file != NULL); + FILE* out = blob->out; + + char path[PATH_MAX]; + estrlcpy(path, file->repo_path, sizeof(path)); + const char* filename = basename(path); + if (!filename) { + err(1, "basename"); + } + fprintf(out, "File: %s (%zdB)\n\n", filename, file->size_bytes); + + ssize_t size_lines = file->size_lines; + if (size_lines == -1) { + fprintf(out, "Binary file.\n"); + return; + } + if (size_lines == -2) { + fprintf(out, "File too large to display.\n"); + return; + } + + assert(file->content != NULL); + fprintf(out, "```\n"); + size_t i = 0; + const char* end = file->content + file->size_bytes; + const char* cur_line = file->content; + while (cur_line < end) { + const char* next_line = memchr(cur_line, '\n', end - cur_line); + size_t len = (next_line ? next_line : end) - cur_line; + + i++; + size_t display_len = len; + if (display_len > 0 && cur_line[display_len - 1] == '\r') { + display_len--; + } + fprintf(out, "%6zu %.*s\n", i, (int)display_len, cur_line); + + if (next_line) { + cur_line = next_line + 1; + } else { + break; + } + } + fprintf(out, "```\n"); +} + +void gemini_fileblob_end(GeminiFileBlob* blob) { + assert(blob != NULL); + gemini_page_end(blob->page); +} diff --git a/src/writer/gemini/fileblob.h b/src/writer/gemini/fileblob.h @@ -0,0 +1,18 @@ +#ifndef GOUT_WRITER_GEMINI_FILEBLOB_H_ +#define GOUT_WRITER_GEMINI_FILEBLOB_H_ + +#include "git/file.h" +#include "git/repo.h" +#include "utils.h" + +typedef struct GeminiFileBlob GeminiFileBlob; + +GeminiFileBlob* gemini_fileblob_create(const GitRepo* repo, + const FileSystem* fs, + const char* path); +void gemini_fileblob_free(GeminiFileBlob* blob); +void gemini_fileblob_begin(GeminiFileBlob* blob); +void gemini_fileblob_add_file(GeminiFileBlob* blob, const GitFile* file); +void gemini_fileblob_end(GeminiFileBlob* blob); + +#endif // GOUT_WRITER_GEMINI_FILEBLOB_H_ diff --git a/src/writer/gemini/fileblob_tests.c b/src/writer/gemini/fileblob_tests.c @@ -0,0 +1,47 @@ +#include "writer/gemini/fileblob.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "fs_inmemory.h" +#include "git/file.h" +#include "git/repo.h" +#include "test_utils.h" +#include "utest.h" + +struct gemini_fileblob { + int dummy; +}; + +UTEST_F_SETUP(gemini_fileblob) { + inmemory_fs_clear(); +} + +UTEST_F_TEARDOWN(gemini_fileblob) {} + +UTEST_F(gemini_fileblob, basic) { + GitRepo repo = {.short_name = "test-repo"}; + GeminiFileBlob* blob_writer = + gemini_fileblob_create(&repo, g_fs_inmemory, "src/main.c"); + ASSERT_NE(NULL, blob_writer); + + const char* content = "int main() {\n return 0;\n}\n"; + GitFile file = { + .repo_path = "src/main.c", + .content = (char*)content, + .size_bytes = strlen(content), + .size_lines = 3, + }; + + gemini_fileblob_begin(blob_writer); + gemini_fileblob_add_file(blob_writer, &file); + gemini_fileblob_end(blob_writer); + gemini_fileblob_free(blob_writer); + + const char* buf = inmemory_fs_get_buffer("file/src/main.c.gmi"); + ASSERT_NE(NULL, buf); + + EXPECT_STR_SEQUENCE(buf, "File: main.c", "```", " 1 int main()", + " 2 return 0;", " 3 }", "```"); +} diff --git a/src/writer/gemini/files.c b/src/writer/gemini/files.c @@ -0,0 +1,75 @@ +#include "writer/gemini/files.h" + +#include <assert.h> +#include <err.h> +#include <stdio.h> +#include <stdlib.h> +#include <sys/types.h> + +#include "format.h" +#include "utils.h" +#include "writer/gemini/page.h" + +struct GeminiFiles { + const GitRepo* repo; + const FileSystem* fs; + FILE* out; + GeminiPage* page; +}; + +GeminiFiles* gemini_files_create(const GitRepo* repo, const FileSystem* fs) { + assert(repo != NULL); + assert(fs != NULL); + GeminiFiles* files = ecalloc(1, sizeof(GeminiFiles)); + files->repo = repo; + files->fs = fs; + files->out = fs->fopen("files.gmi", "w"); + if (!files->out) { + err(1, "fopen: files.gmi"); + } + files->page = gemini_page_create(files->out, repo, fs, "Files", ""); + return files; +} + +void gemini_files_free(GeminiFiles* files) { + if (!files) { + return; + } + files->fs->fclose(files->out); + files->out = NULL; + gemini_page_free(files->page); + files->page = NULL; + free(files); +} + +void gemini_files_begin(GeminiFiles* files) { + assert(files != NULL); + gemini_page_begin(files->page); +} + +void gemini_files_add_file(GeminiFiles* files, const GitFile* file) { + assert(files != NULL); + assert(file != NULL); + FILE* out = files->out; + fprintf(out, "=> file/"); + print_percent_encoded(out, file->repo_path); + fprintf(out, ".gmi %s %s", file->mode, file->display_path); + + ssize_t size_lines = file->size_lines; + ssize_t size_bytes = file->size_bytes; + if (size_lines >= 0) { + fprintf(out, " (%zdL)", size_lines); + } else if (size_bytes >= 0) { + fprintf(out, " (%zdB)", size_bytes); + } + const char* oid = file->commit_oid; + if (oid[0] != '\0') { + fprintf(out, " @ %s", oid); + } + fprintf(out, "\n"); +} + +void gemini_files_end(GeminiFiles* files) { + assert(files != NULL); + gemini_page_end(files->page); +} diff --git a/src/writer/gemini/files.h b/src/writer/gemini/files.h @@ -0,0 +1,16 @@ +#ifndef GOUT_WRITER_GEMINI_FILES_H_ +#define GOUT_WRITER_GEMINI_FILES_H_ + +#include "git/file.h" +#include "git/repo.h" +#include "utils.h" + +typedef struct GeminiFiles GeminiFiles; + +GeminiFiles* gemini_files_create(const GitRepo* repo, const FileSystem* fs); +void gemini_files_free(GeminiFiles* files); +void gemini_files_begin(GeminiFiles* files); +void gemini_files_add_file(GeminiFiles* files, const GitFile* file); +void gemini_files_end(GeminiFiles* files); + +#endif // GOUT_WRITER_GEMINI_FILES_H_ diff --git a/src/writer/gemini/files_tests.c b/src/writer/gemini/files_tests.c @@ -0,0 +1,71 @@ +#include "writer/gemini/files.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "fs_inmemory.h" +#include "git/file.h" +#include "git/repo.h" +#include "test_utils.h" +#include "utest.h" + +struct gemini_files { + int dummy; +}; + +UTEST_F_SETUP(gemini_files) { + inmemory_fs_clear(); +} + +UTEST_F_TEARDOWN(gemini_files) {} + +UTEST_F(gemini_files, basic) { + GitRepo repo = {.short_name = "test-repo"}; + GeminiFiles* files = gemini_files_create(&repo, g_fs_inmemory); + ASSERT_NE(NULL, files); + + GitFile f1 = { + .type = kFileTypeFile, + .mode = "-rw-r--r--", + .display_path = "README.md", + .repo_path = "README.md", + .size_bytes = 100, + .size_lines = 5, + }; + + gemini_files_begin(files); + gemini_files_add_file(files, &f1); + gemini_files_end(files); + gemini_files_free(files); + + const char* buf = inmemory_fs_get_buffer("files.gmi"); + ASSERT_NE(NULL, buf); + EXPECT_STR_SEQUENCE(buf, "=> file/README.md.gmi", "-rw-r--r--", "README.md", + "(5L)"); +} + +UTEST_F(gemini_files, percent_encoding) { + GitRepo repo = {.short_name = "test-repo"}; + GeminiFiles* files = gemini_files_create(&repo, g_fs_inmemory); + + GitFile f1 = { + .type = kFileTypeFile, + .mode = "-rw-r--r--", + .display_path = "path with spaces.txt", + .repo_path = "path with spaces.txt", + .size_bytes = 10, + .size_lines = 1, + }; + + gemini_files_begin(files); + gemini_files_add_file(files, &f1); + gemini_files_end(files); + gemini_files_free(files); + + const char* buf = inmemory_fs_get_buffer("files.gmi"); + ASSERT_NE(NULL, buf); + /* "path with spaces.txt" -> "path%20with%20spaces.txt" */ + EXPECT_STR_SEQUENCE(buf, "=> file/path%20with%20spaces.txt.gmi", + "path with spaces.txt"); +} diff --git a/src/writer/gemini/index_writer.c b/src/writer/gemini/index_writer.c @@ -0,0 +1,46 @@ +#include "writer/gemini/index_writer.h" + +#include <assert.h> +#include <stdio.h> +#include <stdlib.h> + +#include "git/repo.h" +#include "utils.h" +#include "writer/gemini/repo_index.h" + +struct GeminiIndexWriter { + GeminiRepoIndex* index; +}; + +GeminiIndexWriter* gemini_indexwriter_create(FILE* out) { + assert(out != NULL); + GeminiIndexWriter* writer = ecalloc(1, sizeof(GeminiIndexWriter)); + writer->index = gemini_repoindex_create(out); + return writer; +} + +void gemini_indexwriter_free(GeminiIndexWriter* writer) { + if (!writer) { + return; + } + gemini_repoindex_free(writer->index); + writer->index = NULL; + free(writer); +} + +void gemini_indexwriter_begin(GeminiIndexWriter* writer) { + assert(writer != NULL); + gemini_repoindex_begin(writer->index); +} + +void gemini_indexwriter_add_repo(GeminiIndexWriter* writer, + const GitRepo* repo) { + assert(writer != NULL); + assert(repo != NULL); + gemini_repoindex_add_repo(writer->index, repo); +} + +void gemini_indexwriter_end(GeminiIndexWriter* writer) { + assert(writer != NULL); + gemini_repoindex_end(writer->index); +} diff --git a/src/writer/gemini/index_writer.h b/src/writer/gemini/index_writer.h @@ -0,0 +1,17 @@ +#ifndef GOUT_WRITER_GEMINI_INDEX_WRITER_H_ +#define GOUT_WRITER_GEMINI_INDEX_WRITER_H_ + +#include "git/repo.h" + +#include <stdio.h> + +typedef struct GeminiIndexWriter GeminiIndexWriter; + +GeminiIndexWriter* gemini_indexwriter_create(FILE* out); +void gemini_indexwriter_free(GeminiIndexWriter* writer); +void gemini_indexwriter_begin(GeminiIndexWriter* writer); +void gemini_indexwriter_add_repo(GeminiIndexWriter* writer, + const GitRepo* repo); +void gemini_indexwriter_end(GeminiIndexWriter* writer); + +#endif // GOUT_WRITER_GEMINI_INDEX_WRITER_H_ diff --git a/src/writer/gemini/log.c b/src/writer/gemini/log.c @@ -0,0 +1,118 @@ +#include "writer/gemini/log.h" + +#include <assert.h> +#include <err.h> +#include <stdint.h> +#include <stdlib.h> + +#include "format.h" +#include "utils.h" +#include "writer/cache/cache.h" +#include "writer/gemini/page.h" + +#include <sys/stat.h> +#include <unistd.h> + +struct GeminiLog { + const GitRepo* repo; + const FileSystem* fs; + FILE* out; + Cache* cache; + GeminiPage* page; + size_t remaining_commits; + size_t unlogged_commits; +}; + +static void write_commit_row(FILE* out, const GitCommit* commit); + +GeminiLog* gemini_log_create(const GitRepo* repo, const FileSystem* fs) { + assert(repo != NULL); + assert(fs != NULL); + GeminiLog* log = ecalloc(1, sizeof(GeminiLog)); + log->repo = repo; + log->fs = fs; + log->out = fs->fopen("log.gmi", "w"); + if (!log->out) { + err(1, "fopen: log.gmi"); + } + log->page = gemini_page_create(log->out, repo, fs, "Log", ""); + log->remaining_commits = SIZE_MAX; + log->unlogged_commits = 0; + return log; +} + +void gemini_log_free(GeminiLog* log) { + if (!log) { + return; + } + log->fs->fclose(log->out); + log->out = NULL; + cache_free(log->cache); + log->cache = NULL; + gemini_page_free(log->page); + log->page = NULL; + free(log); +} + +void gemini_log_set_cachefile(GeminiLog* log, const char* cachefile) { + assert(log != NULL); + assert(cachefile != NULL); + log->cache = cache_open(log->fs, cachefile, write_commit_row); +} + +void gemini_log_set_commit_limit(GeminiLog* log, size_t count) { + assert(log != NULL); + log->remaining_commits = count; +} + +bool gemini_log_can_add_commits(const GeminiLog* log) { + assert(log != NULL); + return !log->cache || cache_can_add_commits(log->cache); +} + +void gemini_log_begin(GeminiLog* log) { + assert(log != NULL); + gemini_page_begin(log->page); +} + +void gemini_log_add_commit(GeminiLog* log, const GitCommit* commit) { + assert(log != NULL); + assert(commit != NULL); + if (log->cache) { + cache_add_commit_row(log->cache, commit); + } else if (log->remaining_commits > 0) { + write_commit_row(log->out, commit); + log->remaining_commits--; + } else { + log->unlogged_commits++; + } +} + +void gemini_log_end(GeminiLog* log) { + assert(log != NULL); + if (log->cache) { + cache_close_and_replace(log->cache, log->out); + } else if (log->unlogged_commits > 0) { + size_t count = log->unlogged_commits; + fprintf(log->out, "\n%zu more commits remaining, fetch the repository\n", + count); + } + fprintf(log->out, "\n=> atom.xml Atom feed\n"); + fprintf(log->out, "=> tags.xml Atom feed (tags)\n"); + gemini_page_end(log->page); +} + +static void write_commit_row(FILE* out, const GitCommit* commit) { + assert(out != NULL); + assert(commit != NULL); + fprintf(out, "=> commit/"); + print_percent_encoded(out, commit->oid); + fprintf(out, ".gmi "); + print_time_short(out, commit->author_time); + fprintf(out, " "); + const char* summary = commit->summary; + if (summary) { + fprintf(out, "%s", summary); + } + fprintf(out, " [%s]\n", commit->author_name); +} diff --git a/src/writer/gemini/log.h b/src/writer/gemini/log.h @@ -0,0 +1,22 @@ +#ifndef GOUT_WRITER_GEMINI_LOG_H_ +#define GOUT_WRITER_GEMINI_LOG_H_ + +#include <stdbool.h> +#include <stdio.h> + +#include "git/commit.h" +#include "git/repo.h" +#include "utils.h" + +typedef struct GeminiLog GeminiLog; + +GeminiLog* gemini_log_create(const GitRepo* repo, const FileSystem* fs); +void gemini_log_free(GeminiLog* log); +void gemini_log_set_cachefile(GeminiLog* log, const char* cachefile); +void gemini_log_set_commit_limit(GeminiLog* log, size_t count); +bool gemini_log_can_add_commits(const GeminiLog* log); +void gemini_log_begin(GeminiLog* log); +void gemini_log_add_commit(GeminiLog* log, const GitCommit* commit); +void gemini_log_end(GeminiLog* log); + +#endif // GOUT_WRITER_GEMINI_LOG_H_ diff --git a/src/writer/gemini/log_tests.c b/src/writer/gemini/log_tests.c @@ -0,0 +1,57 @@ +#include "writer/gemini/log.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "fs_inmemory.h" +#include "git/commit.h" +#include "git/repo.h" +#include "test_utils.h" +#include "utest.h" + +struct gemini_log { + int dummy; +}; + +UTEST_F_SETUP(gemini_log) { + inmemory_fs_clear(); +} + +UTEST_F_TEARDOWN(gemini_log) {} + +UTEST_F(gemini_log, begin) { + GitRepo repo = {.short_name = "test-repo"}; + GeminiLog* log = gemini_log_create(&repo, g_fs_inmemory); + ASSERT_NE(NULL, log); + gemini_log_begin(log); + gemini_log_free(log); + + const char* buf = inmemory_fs_get_buffer("log.gmi"); + ASSERT_NE(NULL, buf); + EXPECT_STR_SEQUENCE(buf, "# Log - test-repo"); +} + +UTEST_F(gemini_log, add_commit) { + GitRepo repo = {.short_name = "test-repo"}; + GeminiLog* log = gemini_log_create(&repo, g_fs_inmemory); + ASSERT_NE(NULL, log); + + GitCommit commit = { + .oid = "abc1234", + .summary = "Fix bug", + .author_name = "User", + .author_time = 1702031400, + }; + + gemini_log_begin(log); + gemini_log_add_commit(log, &commit); + gemini_log_end(log); + gemini_log_free(log); + + const char* buf = inmemory_fs_get_buffer("log.gmi"); + ASSERT_NE(NULL, buf); + /* Gemini format: => commit/abc1234.gmi 2023-12-08 10:30 Fix bug [User] */ + EXPECT_STR_SEQUENCE(buf, "=> commit/abc1234.gmi", "2023-12-08 10:30", + "Fix bug", "[User]"); +} diff --git a/src/writer/gemini/page.c b/src/writer/gemini/page.c @@ -0,0 +1,89 @@ +#include "writer/gemini/page.h" + +#include <assert.h> +#include <stdbool.h> +#include <stdlib.h> + +#include "format.h" +#include "utils.h" + +struct GeminiPage { + FILE* out; + const GitRepo* repo; + const FileSystem* fs; + char* title; + char* relpath; +}; + +GeminiPage* gemini_page_create(FILE* out, + const GitRepo* repo, + const FileSystem* fs, + const char* title, + const char* relpath) { + assert(out != NULL); + assert(repo != NULL); + assert(fs != NULL); + assert(title != NULL); + assert(relpath != NULL); + GeminiPage* page = ecalloc(1, sizeof(GeminiPage)); + page->out = out; + page->repo = repo; + page->fs = fs; + page->title = estrdup(title); + page->relpath = estrdup(relpath); + return page; +} + +void gemini_page_free(GeminiPage* page) { + if (!page) { + return; + } + free(page->title); + page->title = NULL; + free(page->relpath); + page->relpath = NULL; + free(page); +} + +void gemini_page_begin(GeminiPage* page) { + assert(page != NULL); + FILE* out = page->out; + fprintf(out, "# "); + if (page->title[0] != '\0') { + fprintf(out, "%s", page->title); + const char* short_name = page->repo->short_name; + if (short_name && short_name[0] != '\0') { + fprintf(out, " - %s", short_name); + } + } else if (page->repo->short_name) { + fprintf(out, "%s", page->repo->short_name); + } + fprintf(out, "\n\n"); + + const char* description = page->repo->description; + if (description && description[0] != '\0') { + fprintf(out, "> %s\n\n", description); + } + + const char* clone_url = page->repo->clone_url; + if (clone_url && clone_url[0] != '\0') { + fprintf(out, "git clone %s\n\n", clone_url); + } + + fprintf(out, "=> %slog.gmi Log\n", page->relpath); + fprintf(out, "=> %sfiles.gmi Files\n", page->relpath); + fprintf(out, "=> %srefs.gmi Refs\n", page->relpath); + + for (size_t i = 0; i < page->repo->special_files_len; i++) { + RepoSpecialFile* sf = &page->repo->special_files[i]; + fprintf(out, "=> %sfile/", page->relpath); + print_percent_encoded(out, sf->path); + fprintf(out, ".gmi %s\n", sf->label); + } + fprintf(out, "\n---\n\n"); +} + +void gemini_page_end(GeminiPage* page) { + assert(page != NULL); + (void)page; +} diff --git a/src/writer/gemini/page.h b/src/writer/gemini/page.h @@ -0,0 +1,20 @@ +#ifndef GOUT_WRITER_GEMINI_PAGE_H_ +#define GOUT_WRITER_GEMINI_PAGE_H_ + +#include <stdio.h> + +#include "git/repo.h" +#include "utils.h" + +typedef struct GeminiPage GeminiPage; + +GeminiPage* gemini_page_create(FILE* out, + const GitRepo* repo, + const FileSystem* fs, + const char* title, + const char* relpath); +void gemini_page_free(GeminiPage* page); +void gemini_page_begin(GeminiPage* page); +void gemini_page_end(GeminiPage* page); + +#endif // GOUT_WRITER_GEMINI_PAGE_H_ diff --git a/src/writer/gemini/page_tests.c b/src/writer/gemini/page_tests.c @@ -0,0 +1,48 @@ +#include "writer/gemini/page.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "fs_inmemory.h" +#include "git/repo.h" +#include "test_utils.h" +#include "utest.h" + +struct gemini_page { + int dummy; +}; + +UTEST_F_SETUP(gemini_page) { + inmemory_fs_clear(); +} + +UTEST_F_TEARDOWN(gemini_page) {} + +UTEST_F(gemini_page, basic) { + RepoSpecialFile sf = {.label = "LICENSE", .path = "LICENSE"}; + GitRepo repo = { + .short_name = "test-repo", + .description = "Repo description", + .clone_url = "git://example.com/repo.git", + .special_files = &sf, + .special_files_len = 1, + }; + + FILE* out = g_fs_inmemory->fopen("test.gmi", "w"); + GeminiPage* page = + gemini_page_create(out, &repo, g_fs_inmemory, "Page Title", ""); + ASSERT_NE(NULL, page); + + gemini_page_begin(page); + gemini_page_end(page); + g_fs_inmemory->fclose(out); + gemini_page_free(page); + + const char* buf = inmemory_fs_get_buffer("test.gmi"); + ASSERT_NE(NULL, buf); + + EXPECT_STR_SEQUENCE(buf, "# Page Title - test-repo", "> Repo description", + "git clone", "=> log.gmi Log", "=> files.gmi Files", + "=> refs.gmi Refs", "=> file/LICENSE.gmi LICENSE"); +} diff --git a/src/writer/gemini/refs.c b/src/writer/gemini/refs.c @@ -0,0 +1,99 @@ +#include "writer/gemini/refs.h" + +#include <assert.h> +#include <err.h> +#include <stdio.h> +#include <stdlib.h> + +#include "format.h" +#include "git/commit.h" +#include "utils.h" +#include "writer/gemini/page.h" + +struct GeminiRefs { + const GitRepo* repo; + const FileSystem* fs; + FILE* out; + GeminiPage* page; + bool has_branches; + bool has_tags; + bool in_block; +}; + +GeminiRefs* gemini_refs_create(const GitRepo* repo, const FileSystem* fs) { + assert(repo != NULL); + assert(fs != NULL); + GeminiRefs* refs = ecalloc(1, sizeof(GeminiRefs)); + refs->repo = repo; + refs->fs = fs; + refs->out = fs->fopen("refs.gmi", "w"); + if (!refs->out) { + err(1, "fopen: refs.gmi"); + } + refs->page = gemini_page_create(refs->out, repo, fs, "Refs", ""); + return refs; +} + +void gemini_refs_free(GeminiRefs* refs) { + if (!refs) { + return; + } + refs->fs->fclose(refs->out); + refs->out = NULL; + gemini_page_free(refs->page); + refs->page = NULL; + free(refs); +} + +void gemini_refs_begin(GeminiRefs* refs) { + assert(refs != NULL); + gemini_page_begin(refs->page); +} + +static void ensure_block_closed(GeminiRefs* refs) { + if (refs->in_block) { + fprintf(refs->out, "```\n"); + refs->in_block = false; + } +} + +static void ensure_block_open(GeminiRefs* refs) { + if (!refs->in_block) { + fprintf(refs->out, "```\n"); + fprintf(refs->out, "%-32.32s %-16.16s %s\n", "Name", "Last commit date", + "Author"); + refs->in_block = true; + } +} + +void gemini_refs_add_ref(GeminiRefs* refs, const GitReference* ref) { + assert(refs != NULL); + assert(ref != NULL); + FILE* out = refs->out; + + if (ref->type == kReftypeBranch && !refs->has_branches) { + ensure_block_closed(refs); + fprintf(out, "## Branches\n\n"); + refs->has_branches = true; + ensure_block_open(refs); + } else if (ref->type == kReftypeTag && !refs->has_tags) { + ensure_block_closed(refs); + if (refs->has_branches) { + fprintf(out, "\n"); + } + fprintf(out, "## Tags\n\n"); + refs->has_tags = true; + ensure_block_open(refs); + } + + GitCommit* commit = ref->commit; + fprintf(out, "%-32.32s ", ref->shorthand); + print_time_short(out, commit->author_time); + fprintf(out, " %s\n", commit->author_name); +} + +void gemini_refs_end(GeminiRefs* refs) { + assert(refs != NULL); + ensure_block_closed(refs); + gemini_page_end(refs->page); +} diff --git a/src/writer/gemini/refs.h b/src/writer/gemini/refs.h @@ -0,0 +1,16 @@ +#ifndef GOUT_WRITER_GEMINI_REFS_H_ +#define GOUT_WRITER_GEMINI_REFS_H_ + +#include "git/reference.h" +#include "git/repo.h" +#include "utils.h" + +typedef struct GeminiRefs GeminiRefs; + +GeminiRefs* gemini_refs_create(const GitRepo* repo, const FileSystem* fs); +void gemini_refs_free(GeminiRefs* refs); +void gemini_refs_begin(GeminiRefs* refs); +void gemini_refs_add_ref(GeminiRefs* refs, const GitReference* ref); +void gemini_refs_end(GeminiRefs* refs); + +#endif // GOUT_WRITER_GEMINI_REFS_H_ diff --git a/src/writer/gemini/refs_tests.c b/src/writer/gemini/refs_tests.c @@ -0,0 +1,49 @@ +#include "writer/gemini/refs.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "fs_inmemory.h" +#include "git/commit.h" +#include "git/reference.h" +#include "git/repo.h" +#include "test_utils.h" +#include "utest.h" + +struct gemini_refs { + int dummy; +}; + +UTEST_F_SETUP(gemini_refs) { + inmemory_fs_clear(); +} + +UTEST_F_TEARDOWN(gemini_refs) {} + +UTEST_F(gemini_refs, branches) { + GitRepo repo = {.short_name = "test-repo"}; + GeminiRefs* refs = gemini_refs_create(&repo, g_fs_inmemory); + ASSERT_NE(NULL, refs); + + GitCommit commit = { + .author_name = "User A", + .author_time = 1702031400, + }; + GitReference ref = { + .type = kReftypeBranch, + .shorthand = "main", + .commit = &commit, + }; + + gemini_refs_begin(refs); + gemini_refs_add_ref(refs, &ref); + gemini_refs_end(refs); + gemini_refs_free(refs); + + const char* buf = inmemory_fs_get_buffer("refs.gmi"); + ASSERT_NE(NULL, buf); + EXPECT_STR_SEQUENCE(buf, "## Branches", "```", "Name", "Last commit date", + "Author", "main", "2023-12-08 10:30", "User A", "```"); + EXPECT_EQ(NULL, strstr(buf, "=> commit/")); +} diff --git a/src/writer/gemini/repo_index.c b/src/writer/gemini/repo_index.c @@ -0,0 +1,55 @@ +#include "writer/gemini/repo_index.h" + +#include <assert.h> +#include <stdlib.h> + +#include "format.h" +#include "utils.h" + +struct GeminiRepoIndex { + FILE* out; +}; + +GeminiRepoIndex* gemini_repoindex_create(FILE* out) { + assert(out != NULL); + GeminiRepoIndex* index = ecalloc(1, sizeof(GeminiRepoIndex)); + index->out = out; + return index; +} + +void gemini_repoindex_free(GeminiRepoIndex* index) { + if (!index) { + return; + } + index->out = NULL; + free(index); +} + +void gemini_repoindex_begin(GeminiRepoIndex* index) { + assert(index != NULL); + FILE* out = index->out; + fprintf(out, "# Repositories\n\n"); +} + +void gemini_repoindex_add_repo(GeminiRepoIndex* index, const GitRepo* repo) { + assert(index != NULL); + assert(repo != NULL); + FILE* out = index->out; + fprintf(out, "=> "); + print_percent_encoded(out, repo->short_name); + fprintf(out, "/log.gmi %s", repo->short_name); + if (repo->description && repo->description[0] != '\0') { + fprintf(out, " - %s", repo->description); + } + if (repo->last_commit_time > 0) { + fprintf(out, " (last commit: "); + print_time_short(out, repo->last_commit_time); + fprintf(out, ")"); + } + fprintf(out, "\n"); +} + +void gemini_repoindex_end(GeminiRepoIndex* index) { + assert(index != NULL); + (void)index; +} diff --git a/src/writer/gemini/repo_index.h b/src/writer/gemini/repo_index.h @@ -0,0 +1,16 @@ +#ifndef GOUT_WRITER_GEMINI_REPOINDEX_H_ +#define GOUT_WRITER_GEMINI_REPOINDEX_H_ + +#include "git/repo.h" + +#include <stdio.h> + +typedef struct GeminiRepoIndex GeminiRepoIndex; + +GeminiRepoIndex* gemini_repoindex_create(FILE* out); +void gemini_repoindex_free(GeminiRepoIndex* index); +void gemini_repoindex_begin(GeminiRepoIndex* index); +void gemini_repoindex_add_repo(GeminiRepoIndex* index, const GitRepo* repo); +void gemini_repoindex_end(GeminiRepoIndex* index); + +#endif // GOUT_WRITER_GEMINI_REPOINDEX_H_ diff --git a/src/writer/gemini/repo_index_tests.c b/src/writer/gemini/repo_index_tests.c @@ -0,0 +1,67 @@ +#include "writer/gemini/repo_index.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "git/repo.h" +#include "test_utils.h" +#include "utest.h" + +UTEST(gemini_repo_index, basic) { + char* buf = NULL; + size_t size = 0; + FILE* out = open_memstream(&buf, &size); + + GeminiRepoIndex* index = gemini_repoindex_create(out); + ASSERT_NE(NULL, index); + + GitRepo r1 = { + .short_name = "repo1", + .description = "Desc 1", + .owner = "Owner 1", + .last_commit_time = 1702031400, + }; + + gemini_repoindex_begin(index); + gemini_repoindex_add_repo(index, &r1); + gemini_repoindex_end(index); + gemini_repoindex_free(index); + fclose(out); + + ASSERT_NE(NULL, buf); + + /* Verify Header */ + EXPECT_STR_SEQUENCE(buf, "# Repositories"); + + /* Verify Repo Row */ + EXPECT_STR_SEQUENCE(buf, "=> repo1/log.gmi repo1", "Desc 1", + "2023-12-08 10:30"); + + free(buf); +} + +UTEST(gemini_repo_index, percent_encoding) { + char* buf = NULL; + size_t size = 0; + FILE* out = open_memstream(&buf, &size); + + GeminiRepoIndex* index = gemini_repoindex_create(out); + + GitRepo r1 = { + .short_name = "repo with spaces", + .description = "Desc", + }; + + gemini_repoindex_begin(index); + gemini_repoindex_add_repo(index, &r1); + gemini_repoindex_end(index); + gemini_repoindex_free(index); + fclose(out); + + ASSERT_NE(NULL, buf); + /* "repo with spaces" -> "repo%20with%20spaces" */ + EXPECT_STR_SEQUENCE(buf, "=> repo%20with%20spaces/log.gmi repo with spaces"); + + free(buf); +} diff --git a/src/writer/gemini/repo_writer.c b/src/writer/gemini/repo_writer.c @@ -0,0 +1,177 @@ +#include "writer/gemini/repo_writer.h" + +#include <assert.h> +#include <err.h> +#include <limits.h> +#include <stdio.h> +#include <stdlib.h> +#include <sys/stat.h> +#include <sys/types.h> + +#include "git/commit.h" +#include "git/file.h" +#include "git/reference.h" +#include "utils.h" +#include "writer/atom/atom.h" +#include "writer/gemini/commit.h" +#include "writer/gemini/fileblob.h" +#include "writer/gemini/files.h" +#include "writer/gemini/log.h" +#include "writer/gemini/refs.h" + +struct GeminiRepoWriter { + const GitRepo* repo; + const FileSystem* fs; + GeminiRefs* refs; + GeminiLog* log; + Atom* atom; + FILE* atom_out; + Atom* tags; + FILE* tags_out; + GeminiFiles* files; +}; + +GeminiRepoWriter* gemini_repowriter_create(const GitRepo* repo, + const FileSystem* fs) { + assert(repo != NULL); + assert(fs != NULL); + GeminiRepoWriter* writer = ecalloc(1, sizeof(GeminiRepoWriter)); + writer->repo = repo; + writer->fs = fs; + writer->refs = gemini_refs_create(repo, fs); + writer->log = gemini_log_create(repo, fs); + writer->atom_out = fs->fopen("atom.xml", "w"); + if (!writer->atom_out) { + err(1, "fopen: atom.xml"); + } + writer->atom = atom_create(repo, writer->atom_out); + writer->tags_out = fs->fopen("tags.xml", "w"); + if (!writer->tags_out) { + err(1, "fopen: tags.xml"); + } + writer->tags = atom_create(repo, writer->tags_out); + writer->files = gemini_files_create(repo, fs); + return writer; +} + +void gemini_repowriter_free(GeminiRepoWriter* writer) { + if (!writer) { + return; + } + gemini_refs_free(writer->refs); + writer->refs = NULL; + gemini_log_free(writer->log); + writer->log = NULL; + atom_free(writer->atom); + writer->atom = NULL; + if (writer->atom_out) { + writer->fs->fclose(writer->atom_out); + writer->atom_out = NULL; + } + atom_free(writer->tags); + writer->tags = NULL; + if (writer->tags_out) { + writer->fs->fclose(writer->tags_out); + writer->tags_out = NULL; + } + gemini_files_free(writer->files); + writer->files = NULL; + free(writer); +} + +void gemini_repowriter_set_log_cachefile(GeminiRepoWriter* writer, + const char* cachefile) { + assert(writer != NULL); + assert(cachefile != NULL); + gemini_log_set_cachefile(writer->log, cachefile); +} + +void gemini_repowriter_set_log_commit_limit(GeminiRepoWriter* writer, + size_t count) { + assert(writer != NULL); + gemini_log_set_commit_limit(writer->log, count); +} + +void gemini_repowriter_set_baseurl(GeminiRepoWriter* writer, + const char* baseurl) { + assert(writer != NULL); + assert(baseurl != NULL); + atom_set_baseurl(writer->atom, baseurl); + atom_set_baseurl(writer->tags, baseurl); +} + +void gemini_repowriter_begin(GeminiRepoWriter* writer) { + assert(writer != NULL); + writer->fs->mkdir("commit", S_IRWXU | S_IRWXG | S_IRWXO); + writer->fs->mkdir("file", S_IRWXU | S_IRWXG | S_IRWXO); + + gemini_refs_begin(writer->refs); + gemini_log_begin(writer->log); + atom_begin(writer->atom); + atom_begin(writer->tags); + gemini_files_begin(writer->files); +} + +void gemini_repowriter_add_commit(GeminiRepoWriter* writer, + const GitCommit* git_commit) { + assert(writer != NULL); + assert(git_commit != NULL); + char filename[PATH_MAX]; + int r = snprintf(filename, sizeof(filename), "%s.gmi", git_commit->oid); + if (r < 0 || (size_t)r >= sizeof(filename)) { + errx(1, "snprintf: filename truncated or error"); + } + char* path = path_concat("commit", filename); + atom_add_commit(writer->atom, git_commit, path, "", ""); + free(path); + + if (gemini_log_can_add_commits(writer->log)) { + gemini_log_add_commit(writer->log, git_commit); + GeminiCommit* commit = gemini_commit_create( + writer->repo, writer->fs, git_commit->oid, git_commit->summary); + gemini_commit_begin(commit); + gemini_commit_add_commit(commit, git_commit); + gemini_commit_end(commit); + gemini_commit_free(commit); + } +} + +void gemini_repowriter_add_reference(GeminiRepoWriter* writer, + const GitReference* ref) { + assert(writer != NULL); + assert(ref != NULL); + gemini_refs_add_ref(writer->refs, ref); + if (ref->type == kReftypeTag) { + GitCommit* commit = ref->commit; + char filename[PATH_MAX]; + int r = snprintf(filename, sizeof(filename), "%s.gmi", commit->oid); + if (r < 0 || (size_t)r >= sizeof(filename)) { + errx(1, "snprintf: filename truncated or error"); + } + char* path = path_concat("commit", filename); + atom_add_commit(writer->tags, commit, path, "", ref->shorthand); + free(path); + } +} + +void gemini_repowriter_add_file(GeminiRepoWriter* writer, const GitFile* file) { + assert(writer != NULL); + assert(file != NULL); + gemini_files_add_file(writer->files, file); + + GeminiFileBlob* blob = + gemini_fileblob_create(writer->repo, writer->fs, file->repo_path); + gemini_fileblob_begin(blob); + gemini_fileblob_add_file(blob, file); + gemini_fileblob_end(blob); + gemini_fileblob_free(blob); +} + +void gemini_repowriter_end(GeminiRepoWriter* writer) { + assert(writer != NULL); + gemini_refs_end(writer->refs); + gemini_log_end(writer->log); + atom_end(writer->atom); + atom_end(writer->tags); + gemini_files_end(writer->files); +} diff --git a/src/writer/gemini/repo_writer.h b/src/writer/gemini/repo_writer.h @@ -0,0 +1,31 @@ +#ifndef GOUT_WRITER_GEMINI_REPO_WRITER_H_ +#define GOUT_WRITER_GEMINI_REPO_WRITER_H_ + +#include <stddef.h> + +#include "git/commit.h" +#include "git/file.h" +#include "git/reference.h" +#include "git/repo.h" +#include "utils.h" + +typedef struct GeminiRepoWriter GeminiRepoWriter; + +GeminiRepoWriter* gemini_repowriter_create(const GitRepo* repo, + const FileSystem* fs); +void gemini_repowriter_free(GeminiRepoWriter* writer); +void gemini_repowriter_set_log_cachefile(GeminiRepoWriter* writer, + const char* cachefile); +void gemini_repowriter_set_log_commit_limit(GeminiRepoWriter* writer, + size_t count); +void gemini_repowriter_set_baseurl(GeminiRepoWriter* writer, + const char* baseurl); +void gemini_repowriter_begin(GeminiRepoWriter* writer); +void gemini_repowriter_add_commit(GeminiRepoWriter* writer, + const GitCommit* commit); +void gemini_repowriter_add_reference(GeminiRepoWriter* writer, + const GitReference* ref); +void gemini_repowriter_add_file(GeminiRepoWriter* writer, const GitFile* file); +void gemini_repowriter_end(GeminiRepoWriter* writer); + +#endif // GOUT_WRITER_GEMINI_REPO_WRITER_H_ diff --git a/src/writer/gemini/repo_writer_tests.c b/src/writer/gemini/repo_writer_tests.c @@ -0,0 +1,59 @@ +#include "writer/gemini/repo_writer.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "fs_inmemory.h" +#include "git/commit.h" +#include "git/repo.h" +#include "utest.h" + +struct gemini_repo_writer { + int dummy; +}; + +UTEST_F_SETUP(gemini_repo_writer) { + inmemory_fs_clear(); +} + +UTEST_F_TEARDOWN(gemini_repo_writer) {} + +UTEST_F(gemini_repo_writer, orchestration) { + GitRepo repo = { + .name = "test-repo.git", + .short_name = "test-repo", + .description = "A test repository", + .owner = "Test Owner", + .clone_url = "git://example.com/test-repo.git", + }; + GeminiRepoWriter* writer = gemini_repowriter_create(&repo, g_fs_inmemory); + ASSERT_NE(NULL, writer); + + GitCommit commit = { + .oid = "sha123", + .parentoid = "", + .summary = "Test commit", + .message = "Detailed message.", + .author_name = "User", + .author_email = "user@example.com", + }; + + gemini_repowriter_begin(writer); + gemini_repowriter_add_commit(writer, &commit); + gemini_repowriter_end(writer); + gemini_repowriter_free(writer); + + /* Verify orchestration by checking multiple output files. */ + + /* 1. Log file exists and contains the commit message. */ + const char* log_buf = inmemory_fs_get_buffer("log.gmi"); + ASSERT_NE(NULL, log_buf); + EXPECT_NE(NULL, strstr(log_buf, "Test commit")); + + /* 2. Individual commit page exists. */ + const char* commit_buf = inmemory_fs_get_buffer("commit/sha123.gmi"); + ASSERT_NE(NULL, commit_buf); + EXPECT_NE(NULL, strstr(commit_buf, "Test commit")); + EXPECT_NE(NULL, strstr(commit_buf, "sha123")); +} diff --git a/src/writer/index_writer.c b/src/writer/index_writer.c @@ -5,6 +5,7 @@ #include <stdlib.h> #include "utils.h" +#include "writer/gemini/index_writer.h" #include "writer/gopher/index_writer.h" #include "writer/html/index_writer.h" @@ -73,6 +74,30 @@ static const IndexWriterOps kGopherIndexWriterOps = { .free = gopher_idx_free, }; +static void gemini_idx_begin(void* impl) { + gemini_indexwriter_begin(impl); +} + +static void gemini_idx_add_repo(void* impl, const GitRepo* repo) { + gemini_indexwriter_add_repo(impl, repo); +} + +static void gemini_idx_end(void* impl) { + gemini_indexwriter_end(impl); +} + +static void gemini_idx_free(void* impl) { + gemini_indexwriter_free(impl); +} + +static const IndexWriterOps kGeminiIndexWriterOps = { + .set_me_url = NULL, + .begin = gemini_idx_begin, + .add_repo = gemini_idx_add_repo, + .end = gemini_idx_end, + .free = gemini_idx_free, +}; + IndexWriter* indexwriter_create(IndexWriterType type, FILE* out) { assert(out != NULL); IndexWriter* writer = ecalloc(1, sizeof(IndexWriter)); @@ -85,6 +110,10 @@ IndexWriter* indexwriter_create(IndexWriterType type, FILE* out) { writer->ops = &kGopherIndexWriterOps; writer->impl = gopher_indexwriter_create(out); return writer; + case kIndexWriterTypeGemini: + writer->ops = &kGeminiIndexWriterOps; + writer->impl = gemini_indexwriter_create(out); + return writer; } free(writer); errx(1, "unknown IndexWriterType %d", type); diff --git a/src/writer/index_writer_tests.c b/src/writer/index_writer_tests.c @@ -58,3 +58,27 @@ UTEST(index_writer, gopher) { EXPECT_STR_SEQUENCE(buf, "Repositories", "test-repo", "test-desc"); free(buf); } + +UTEST(index_writer, gemini) { + char* buf = NULL; + size_t size = 0; + FILE* out = open_memstream(&buf, &size); + + IndexWriter* writer = indexwriter_create(kIndexWriterTypeGemini, out); + ASSERT_NE(NULL, writer); + indexwriter_set_me_url(writer, "https://me.example.com"); + + GitRepo repo = {.short_name = "test-repo", + .description = "test-desc", + .owner = "test-owner"}; + + indexwriter_begin(writer); + indexwriter_add_repo(writer, &repo); + indexwriter_end(writer); + indexwriter_free(writer); + fclose(out); + + ASSERT_NE(NULL, buf); + EXPECT_STR_SEQUENCE(buf, "Repositories", "test-repo", "test-desc"); + free(buf); +} diff --git a/src/writer/repo_writer.c b/src/writer/repo_writer.c @@ -5,6 +5,7 @@ #include <stdlib.h> #include "utils.h" +#include "writer/gemini/repo_writer.h" #include "writer/gopher/repo_writer.h" #include "writer/html/repo_writer.h" @@ -121,6 +122,54 @@ static const RepoWriterOps kGopherRepoWriterOps = { .free = gopher_repo_free, }; +static void gemini_repo_set_log_cachefile(void* impl, const char* cachefile) { + gemini_repowriter_set_log_cachefile(impl, cachefile); +} + +static void gemini_repo_set_log_commit_limit(void* impl, size_t count) { + gemini_repowriter_set_log_commit_limit(impl, count); +} + +static void gemini_repo_set_baseurl(void* impl, const char* baseurl) { + gemini_repowriter_set_baseurl(impl, baseurl); +} + +static void gemini_repo_begin(void* impl) { + gemini_repowriter_begin(impl); +} + +static void gemini_repo_add_commit(void* impl, const GitCommit* commit) { + gemini_repowriter_add_commit(impl, commit); +} + +static void gemini_repo_add_reference(void* impl, const GitReference* ref) { + gemini_repowriter_add_reference(impl, ref); +} + +static void gemini_repo_add_file(void* impl, const GitFile* file) { + gemini_repowriter_add_file(impl, file); +} + +static void gemini_repo_end(void* impl) { + gemini_repowriter_end(impl); +} + +static void gemini_repo_free(void* impl) { + gemini_repowriter_free(impl); +} + +static const RepoWriterOps kGeminiRepoWriterOps = { + .set_log_cachefile = gemini_repo_set_log_cachefile, + .set_log_commit_limit = gemini_repo_set_log_commit_limit, + .set_baseurl = gemini_repo_set_baseurl, + .begin = gemini_repo_begin, + .add_commit = gemini_repo_add_commit, + .add_reference = gemini_repo_add_reference, + .add_file = gemini_repo_add_file, + .end = gemini_repo_end, + .free = gemini_repo_free, +}; + RepoWriter* repowriter_create(RepoWriterType type, GitRepo* repo, const FileSystem* fs) { @@ -136,6 +185,10 @@ RepoWriter* repowriter_create(RepoWriterType type, writer->ops = &kGopherRepoWriterOps; writer->impl = gopher_repowriter_create(repo, fs); return writer; + case kRepoWriterTypeGemini: + writer->ops = &kGeminiRepoWriterOps; + writer->impl = gemini_repowriter_create(repo, fs); + return writer; } free(writer); errx(1, "unknown RepoWriterType %d", type);