# gsg — a modular git browser cgit's weight, Hugo's composability, served dynamically. A **module** computes one piece of a view; a **route** binds a renderer to a URL pattern. Adding a feature means adding a module and calling it from a template — `internal/core` never changes. One dependency (`goldmark`, which itself has none) plus the `git` binary. Go 1.22+ (uses `net/http` pattern wildcards, so there is no router dependency either). ``` go build -o gsg . ./gsg -root /srv/git -addr :8080 -name "rschanz.org" ./gsg -root /srv/git -templates ./templates # re-parse the theme per request ./gsg -root /srv/git -notices /etc/gsg/notices.yaml -admin-addr 127.0.0.1:8081 ``` ## The idea ```go type Module func(c *Ctx, args ...string) (any, error) ``` Templates reach modules through one funcmap entry: ```gotmpl {{ with mod "meta" }}

{{ .Title }}

{{ end }} {{ $d := mod "diff" }} {{ with modOr "readme" }}{{ .HTML }}{{ end }} ``` - **Lazy.** A template that calls `mod "authors"` is what *causes* authors to be computed. Views that don't ask don't pay. - **Memoized twice.** Once per request (`Ctx.cache`), and once process-wide against the resolved commit SHA (`core.LRU`). - **No manifest.** Dependencies are declared by use. There is no module list to keep in sync with the templates. `internal/modules/example_authors.go` is a live demonstration: 30 lines, an `init()`, and one `{{ with mod "authors" }}` block. Nothing else moved. ## Pages and endpoints are the same mechanism A route is `{pattern, renderer}`. `core.View` renders a template, `core.JSON` serializes an endpoint. Both take a `*Ctx`, both call modules. ```go {Name: "commit", Immutable: true, Pattern: "GET /{repo}/commit/{rev}", Render: core.View{Template: "commit.html"}}, {Name: "api-commit", Immutable: true, Pattern: "GET /{repo}/api/commit/{rev}", Render: core.JSON{Build: api.CommitEndpoint}}, ``` ### Endpoints use DTOs, not module values `internal/api` defines explicit structs and never marshals a module's return value. Go marshals every exported field by default, so serializing a module type means the day someone adds `Body` to `gitx.Commit`, commit messages are public. The allowlist is one readable file. ``` GET /api/repos.json site index GET /{repo}/api/repo.json slug, title, description, owner, section, clone_url, default_branch, last_commit GET /{repo}/api/commit/{sha} sha, short, authored, parents, and file/line counts — no subject, no body ``` ## Routes | Route | URL | |---|---| | index | `/` | | repo | `/{repo}/` — default branch, last update, latest commit, README | | files | `/{repo}/tree/{ref}/{path}` | | file | `/{repo}/blob/{ref}/{path}` | | **raw bytes** | `/{repo}/raw/{ref}/{path}` | | log | `/{repo}/log/{ref}` | | refs | `/{repo}/refs/` | | commit | `/{repo}/commit/{sha}` | | **compare** | `/{repo}/compare/{base}..{head}` or `{base}...{head}` | | **branch graph** | `/{repo}/graph/` | ### Compare `a..b` is the literal endpoint difference. `a...b` diffs from the merge base, which is what you want when reviewing a branch — it excludes changes that landed on the base since the branch forked. Verified against `git merge-base` on a diverged fixture: `topic..main` lists `topic.txt` as removed, `topic...main` does not. ### Raw files A repository can contain an `.html` file with a `