diff --git a/README.md b/README.md
index 7758ed9..1d94c39 100644
--- a/README.md
+++ b/README.md
@@ -1,503 +1,52 @@
-# 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" }}<h1>{{ .Title }}</h1>{{ 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 `<script>` tag. Serving that
-as `text/html` on your own origin turns any push into stored XSS against the
-whole site — which is why GitHub puts raw content on
-`raw.githubusercontent.com`, a separate origin. Same-host serving is only
-safe if the response cannot be interpreted as a document, so `/raw/` sends
-`text/plain` (or `application/octet-stream` for binaries, plus
-`Content-Disposition: attachment`), with `X-Content-Type-Options: nosniff`
-and `Content-Security-Policy: default-src \'none\'; sandbox` as a backstop.
-
-The exception is a validated raster image (PNG, JPEG, GIF, WebP, BMP, ICO,
-AVIF): when the extension and the magic bytes agree, `/raw/` declares the
-real `image/*` type inline so the blob page can preview it. An extension by
-itself is not enough — an `.png` whose contents are HTML still downloads as
-`application/octet-stream`. SVG is deliberately excluded: it is an XML
-document that can execute script, so it stays `text/plain`.
-
-If you would rather have real isolation, serve `/raw/` from a second
-hostname and the CSP becomes belt-and-braces rather than the only line.
-
-### Images and structured config files
-
-An image blob shows an inline preview with a `download` link to the same
-`/raw/` URL; images over 10 MiB skip the preview and offer the download only.
-
-JSON and YAML blobs get per-level indent guides, fold controls, key/value
-search, and YAML anchor/alias links. The search box also accepts a value
-path: typing `obj1.property3.items[4].val` resolves it against the parsed
-document and highlights that line and, for a container, its subtree. A
-query containing `.` or `[` is tried as a path first and falls back to
-plain substring search when it does not resolve, so dotted text stays
-searchable. Paths quote keys that need it (`obj1["a.b"]`) and attach indices
-directly; SVG is never previewed, and files past 512 KiB skip the parsing
-tooling entirely.
-
-### Branch graph
-
-`modules/graph.go` lays out the DAG in lanes in one pass over
-`git log --all --topo-order`, which guarantees a child is emitted before its
-parents. `active` holds, per lane, the SHA that lane is waiting to draw; a
-commit claims the lane expecting it (or a fresh one), hands that lane to its
-first parent, and opens a new lane per additional parent — which is what
-makes a merge fork visually.
-
-The module emits the per-row SVG because the geometry *is* the data, but
-every stroke refers to a CSS custom property (`--lane-0` … `--lane-5`), so
-the theme still owns the palette. Row height in the SVG must stay in sync
-with `table.graph td { height }` in the stylesheet.
-
-## Caching, which is the whole reason this works dynamically
-
-Every module result is a pure function of `(repo, commit SHA, path, args)`.
-Refs move; SHAs do not. So the process-wide cache is keyed on the resolved
-SHA and needs **no invalidation logic at all** — a stale entry is impossible
-by construction.
-
-`Ctx.Vars` are part of the cache key, not just the commit. They were not
-originally, and the bug was exactly the kind this cache should be immune to:
-`/compare/a..b` and `/compare/a...b` resolve to the same head commit, so the
-second request was served the first one\'s diff. Anything that changes what a
-module computes has to be in the key.
-
-ETags are derived from the resolved SHA plus a digest of every template
-file, *not* from the response body. A conditional request costs one ref
-lookup instead of a render, and restyling the theme invalidates every ETag
-at once (without the template digest, a restyled page keeps serving 304s).
-
-Two URL classes, two policies:
-
-| URL shape | Example | Cache-Control |
-|---|---|---|
-| SHA-addressed | `/demo/commit/64b90fff…` | `max-age=31536000, immutable` |
-| ref-addressed | `/demo/tree/main` | `no-cache` (revalidate via ETag) |
-
-## Ref-vs-path ambiguity
-
-`/{repo}/tree/{rest...}` fuses them, and `gitx.SplitRefPath` separates them
-by **longest matching prefix against the repo's actual ref list**, so
-`feature/x/src/main.go` resolves correctly. Splitting at the first `/` is
-wrong because branch names contain slashes; cgit dodges this with `?h=`
-query params, GitHub does what this does.
-
-## Serving is an attack surface; static generation was not
-
-- **Ref input never reaches `git rev-parse`.** rev-parse accepts an
- expression language — `:/regex` searches history, `@{...}` reads the
- reflog — and git subcommands treat a leading `-` as an option.
- `gitx.Resolve` accepts either a hex SHA that already exists or an exact
- match against the repo's own ref list. Verified: `--upload-pack=…`,
- `:/secret`, `HEAD@{0}` and `-n` all return 404 with no side effects.
-- **Every git call is `exec.CommandContext`** with a per-request deadline
- (`-timeout`, default 10s) and a scrubbed environment
- (`GIT_CONFIG_NOSYSTEM=1`, no `HOME`, no terminal prompt).
-- **Output is capped** at `MaxGit` (8 MiB) per command; a diff that exceeds
- it renders truncated rather than eating the heap.
-
-## Layout
-
-```
-main.go flags, repo discovery, embedded assets
-internal/gitx/ the ONLY package that knows git exists
-internal/core/core.go Ctx, RepoInfo, module registry, Store
-internal/core/route.go Route, View, JSON, route request parsing
-internal/core/server.go request -> Ctx, ETags, cache policy
-internal/core/cache.go LRU keyed on commit SHAs
-internal/core/funcs.go template funcmap (mod, modOr, dict, date, bytes)
-internal/access/ visibility and share-grant policy (imports no core)
-internal/authn/ login/logout endpoints
-internal/httpx/ shared admin/share HTTP security helpers
-internal/modules/ meta refs log tree readme repos commit diff blob notices
-internal/notices/ notices YAML store + admin UI (imports no core)
-internal/api/ JSON DTOs — the serialization allowlist
-internal/site/routes.go the route table (the file you edit most)
-internal/site/request.go route-specific path parsers (compare ranges)
-templates/ layouts/ + views/
-```
-
-`gitx` shells out to plumbing, one process per query, `git log` in a single
-pass. Swapping in go-git means rewriting that one package.
-
-## Sections
-
-`gsg.section` groups repositories on the index. The `sections` module builds
-on `repos` rather than re-walking the repo set, so the index pays for its log
-lookups once. When no repo sets a section, it returns a single unnamed group
-and the template renders a flat list — no config, no headings.
-
-```sh
-git --git-dir=/srv/git/demo.git config gsg.section "Infrastructure"
-```
-
-## Notices
-
-Operator-authored banners at the top of every page, site-wide and per
-repository. They live in one YAML file that is watched for changes, so
-editing it never needs a restart or a rebuild:
-
-```yaml
-global:
- - type: critical # critical | warning | info | fix
- title: Maintenance window
- body: |
- Saturday 02:00-04:00 UTC.
- The site is read-only during the window.
- link: https://status.example.org
- link_text: status page
-repos:
- demo:
- - type: warning
- title: Migrating to a new remote
-```
-
-```sh
-./gsg -root /srv/git -notices /etc/gsg/notices.yaml
-```
-
-Global notices render above every repository's; on the site index only the
-global list appears. The file is re-read on write (atomic-rename saves from
-an editor included); a parse error keeps the last good content rather than
-blanking the banners.
-
-### Editing from the web
-
-`-admin-addr` starts a second listener with a small HTML form to add, edit,
-and delete notices. It is deliberately separate from the read-only server:
-the admin routes are not reachable on the public port even if the reverse
-proxy is misconfigured.
-
-```sh
-./gsg -root /srv/git -notices /etc/gsg/notices.yaml -admin-addr 127.0.0.1:8081
-```
-
-Protect it with Caddy `forward_auth` and proxy it to the admin listener. gsg
-trusts the network in front of it, so the admin port must not be published
-directly. In Compose, put 8081 on the internal network only and pass
-`-admin-allow-remote`, because a container address is not loopback. Note
-that gsg performs no authentication itself -- the `forward_auth` block below
-is the only thing between the internet and the admin form.
-
-```caddyfile
-example.com {
- # Admin: authenticate first, then proxy to the separate admin listener.
- # handle, not handle_path: the admin mux matches the full /admin/...
- # path, so stripping the prefix would 404 every request.
- handle /admin/* {
- forward_auth authentik:9000 {
- uri /outpost.goauthentik.io/auth/caddy
- copy_headers X-Authentik-Username
- }
- reverse_proxy gsg:8081
- }
-
- # Public, read-only.
- handle {
- reverse_proxy gsg:8080
- }
-}
-```
-
-The `X-Authentik-Username` header is used only to attribute the mutation in
-the log; it is never consulted for authorization. By default Authentik
-decides who may reach the handler at all -- anyone forward_auth lets through
-`/admin/*` can edit notices.
-
-Mutations are POST-only, CSRF-protected (double-submit cookie plus
-`Origin`/`Sec-Fetch-Site`), and logged with the `X-Authentik-Username`
-header, which is used for audit only, never for authorization.
-
-### Gating notices editing to a group
-
-`/admin/*` is also where [Login](#login) mints the `gsg_user` cookie, so
-forward_auth for the whole prefix is usually left open to everyone you want
-able to log in (for share links, hidden-repo visibility, etc). To let more
-people log in without also handing them notices editing, add `-admin-group`:
-
-```sh
-./gsg -root /srv/git -notices /etc/gsg/notices.yaml \
- -admin-addr 127.0.0.1:8081 -admin-group gsg-admins
-```
-
-This checks the `X-Authentik-Groups` header (override with
-`-admin-group-header`) for `gsg-admins` among its `|`-delimited entries,
-Authentik's own format for that header, and answers `403` to any request
-under `/admin/notices` from a caller that isn't a member -- before the
-handler does anything else, including setting the CSRF cookie. Add the group
-header to the same `forward_auth` block used above:
-
-```caddyfile
- forward_auth authentik:9000 {
- uri /outpost.goauthentik.io/auth/caddy
- copy_headers X-Authentik-Username X-Authentik-Groups
- }
-```
-
-Leaving `-admin-group` unset keeps today's behavior: anyone forward_auth
-admits to `/admin/*` can edit notices.
-
-**Immutable commit pages carry no notices.** `/{repo}/commit/{rev}` is
-served `Cache-Control: public, max-age=31536000, immutable`, so a banner
-rendered into it would stay frozen in the reader's browser. Notices are
-suppressed there by design; every other page picks them up on the next
-request.
-
-## Login
-
-Logging in reveals hidden repos whose `gsg.visible-to` list includes your
-username; everyone else, logged in or not, sees exactly what an anonymous
-visitor sees. It reuses the same Authentik `forward_auth` setup as the
-notices admin UI above, and needs nothing extra beyond `-admin-addr`
-(notices editing is not required — `-admin-addr` alone is enough to enable
-login).
-
-```sh
-./gsg -root /srv/git -admin-addr 127.0.0.1:8081
-```
-
-```caddyfile
-example.com {
- # Login: same trust boundary as /admin/*, one hop mints the cookie
- # and redirects back. This must be the exact path, not /auth/*: gsg
- # also serves /auth/logout, but on the *public* listener, since
- # logging out only clears gsg's own cookie and needs no Authentik
- # round trip at all. Routing /auth/* here would send logout to the
- # admin listener too, where no such handler exists, and gate it
- # behind an Authentik check it doesn't need.
- handle /auth/login {
- forward_auth authentik:9000 {
- uri /outpost.goauthentik.io/auth/caddy
- copy_headers X-Authentik-Username
- }
- reverse_proxy gsg:8081
- }
-
- # Public, read-only (also handles /auth/logout, /share/new, /share/view/*).
- handle {
- reverse_proxy gsg:8080
- }
-}
-```
-
-Visiting `/auth/login` (through Caddy, so `X-Authentik-Username` is set and
-trustworthy by the time gsg sees the request) mints a signed, stateless
-`gsg_user` cookie and redirects back to `/`. The cookie is verified on the
-public listener with an in-memory key — nothing is written to disk, and a
-process restart signs everyone out. `/auth/logout` clears it and needs no
-round trip through Authentik, since it only has to forget gsg's own cookie.
-
-**There is no "log in" link anywhere in the UI.** `/auth/login` is not
-discoverable from the site; people who need it are told the URL out of
-band. Once logged in, the nav bar shows who you are and a log-out link.
-
-## Share links
-
-Once logged in, `/share/new` lets you pick from your own hidden repos and
-mint a link that grants view access to exactly those repos to anyone who
-has it — no login, no Authentik involvement. It's entirely stateless: the
-link is a signed, self-contained token, and gsg keeps no record of what's
-been shared. An optional password can gate it.
-
-```
-git --git-dir=/srv/git/demo.git config gsg.share-max-age "720h"
-```
-
-`gsg.share-max-age` (Go duration syntax, default 30 days if unset) is both
-the ceiling no link's expiry may exceed and the default when the minting
-form doesn't specify one — you can always ask for less, never more. Sharing
-several repos at once uses the smallest ceiling among them.
-
-**Passwords are never stored, not even hashed.** The password is folded
-directly into the link's signature instead of being embedded in it: nobody
-holding just the URL has anything to brute-force offline, and checking a
-guess requires asking the server, which rate-limits attempts. This is a
-soft gate appropriate for a small self-hosted tool, not a cryptographic
-secret protecting high-value data.
-
-**Revocation.** A stateless token can't be individually revoked — there's
-no list of what's been issued. The one lever:
-
-```
-git --git-dir=/srv/git/demo.git config gsg.share-revoked-after "$(date +%s)"
-```
-
-Any share grant for that repo issued at or before this timestamp stops
-working immediately, including in a browser that already redeemed the
-link — not just future redemptions. Two things worth knowing before you
-rely on this:
-
-- There's no per-recipient state, so revoking means invalidating *every*
- outstanding share of that repo, then re-sharing with whoever should still
- have it.
-- Removing a username from `gsg.visible-to` does **not** revoke a share
- link that user already minted for that repo. The two mechanisms are
- independent; only `gsg.share-revoked-after` invalidates outstanding
- shares.
-
-## README rendering
-
-`modules/markdown.go` uses goldmark with GFM, Typographer, and automatic
-heading IDs — Hugo\'s own goldmark defaults, so a README renders identically
-to the same file on your blog.
-
-`WithUnsafe` is deliberately not set, which is also Hugo\'s default. Anyone
-who can push can write a README, so raw HTML in markdown is untrusted input;
-goldmark drops it. A `<script>` committed to a README comes out as
-`<!-- raw HTML omitted -->`.
-
-Relative links are rewritten against the file\'s own directory: links become
-the blob view, images become `/raw/` (an `<img>` needs bytes, not an HTML
-page). Absolute URLs, site-absolute paths, fragments and `mailto:` are left
-alone, and anything resolving above the repo root is not rewritten.
-
-## Per-repository config
-
-Read from each repo's own git config, cgit-style, with `gitweb.*` and the
-legacy `description` file as fallbacks, so an existing cgit tree works as-is:
-
-```sh
-git --git-dir=/srv/git/demo.git config gsg.title "Demo Project"
-git --git-dir=/srv/git/demo.git config gsg.description "A demo repository"
-git --git-dir=/srv/git/demo.git config gsg.clone "git://rschanz.org/demo"
-git --git-dir=/srv/git/demo.git config gsg.hide true
-git --git-dir=/srv/git/demo.git config gsg.visible-to "alice,bob"
-git --git-dir=/srv/git/demo.git config gsg.share-max-age "720h"
-git --git-dir=/srv/git/demo.git config gsg.share-revoked-after "$(date +%s)"
-```
-
-Add a key by adding a field to `core.RepoInfo` and a line in `LoadRepoInfo`.
-
-A repo's slug is its directory name (minus `.git`), and slugs share the
-public listener's URL namespace with a few reserved top-level paths:
-`static`, `auth`, and `share`. A repo named e.g. `share.git` would collide
-with the share-link endpoints below and is not supported.
-
-`gsg.visible-to` is a comma-separated allowlist of usernames (matched
-case-insensitively against a logged-in session, see "Login" below) who may
-see this repo even though `gsg.hide` is set. It's a separate key from
-`gsg.owner` on purpose: `Owner` is a free-text display field that falls
-back to `gitweb.owner` for cgit-migrated repos, so treating it as an access
-list could silently grant visibility nobody intended.
-
-## Theme
-
-Monospace-first: every value on these pages is an identifier, hash, path or
-count, so the interface type is the same face as the code, and the sans face
-appears only inside rendered README prose. A cool slate ramp, one indigo
-accent, and a six-colour lane ramp shared by the commit graph and its branch
-chips. Light and dark both defined via `prefers-color-scheme`.
-
-Diffs mark the gutter rather than flooding whole rows with green and red —
-the signal stays readable when a hunk is mostly additions.
-
-No webfonts; system stacks only, so there is no third-party request. All
-colour lives in `:root` custom properties in `static/style.css`.
-
-## Known gaps
-
-- **No syntax highlighting.** `blob` returns numbered plain lines; Chroma
- drops into the same module.
-- **No pagination on `log` or `graph`.** They cap at 200 and 150 commits.
-- **The graph loads all refs into memory** before laying out. Fine to a few
- thousand commits; beyond that it needs windowing.
-- **The LRU is bounded by entry count, not bytes.** A giant diff and a ref
- list cost the same slot. The `Store` interface is the only thing a
- size-aware replacement has to satisfy.
-- **Repo discovery is one level deep** and happens once at startup; a repo
- created later needs a restart.
-- **Module errors surface at render time**, not load time. Add a CI pass
- that renders every view against a fixture repo.
+# My git viewer
+
+### Why I did this
+
+So, the premise behind this project was a few different things:
+
+- I wanted a git viewer stack that, while modular, remained light.
+- I liked the idea of having reusable "templates" rather than generating the HTML dynamically in the code. This is much closer to how things like Hugo operate.
+- It needs to be easy to extend (modularity!). A core is responsible for loading a git repo's context and other request state, but all "real" functionality is implemented via modules. I could add/remove things without needing to worry (too much...) about breaking unrelated things.
+- I wanted something that was modern, but didn't require javascript to operate. All features on this site work without javascript, though your experience is improved with it.
+- I wanted to have a way to view private git repositories using systems I already have.
+ - I don't want to/have the capability to SSH clone my repos to show something off.
+ - Could I have done this in cgit? Perhaps, but it wasn't built for it.
+- And, probably most visibly, this was a trial in "vibe coding". While the idea around the design of this software was mine, I wrote very little of the code. Especially the UI, and this is for the better. I am not a graphics designer and whatever slop the LLM pumps out (with my input here and there) will be leagues better than whatever I do; for better or worse.
+
+The name "gsg" doesn't mean much (I don't name things). It're pretty much "git static generator". It was the placeholder when I was thinking of a system that was "kind of a combination of cgit's lightness and hugo's composability and templating".
+
+### The design
+
+First and foremost, the AI writeup is in ./llm_readme.md. Read that if you want all of the verbose details.
+
+Also, as alluded to above, no javascript is strictly required. The few places it is used it is just used to make the experience nicer, and a fallback always exists. For example, the JSON/YAML codepath has a JS module that allows you to search for values/keys, perform folding, etc. This is a core design tenant: JS always hydrates what exists already.
+
+(Not) Broadly speaking, as stated above, this project is split up into:
+
+- The core
+ - gitx - the module that actually interfaces with git. This transforms a context into a re-usable struct (should I ever want to use a real implementation)
+ - access - Consolidated checks for whether someone can see/not see a repo
+ - session - Owns all privileged access logic. It generates/verifies tokens that are used across the site. Everything is stateless once authenticated.
+ - authn - Route owner that defines login/logout flows. Uses session internally
+ - httpx - Shared helpers used when generating responses/evaling a req
+ - notices - Logic for me to post notices to the site or repos
+ - share - Logic for authenticated users to mint links to private repos they are allowed to. Uses same auth logic as users under the hood.
+ - api - Exposes repo details as a JSON endpoint, will be used by my main site later on
+ - core/site - All the main things needed to make this tick. Build req context, define routes and where they live, define module registration structs.
+- The modules
+ - blob - Responsible for returning files (raw or templated)
+ - commit - Render a commit
+ - compare - Render a diff between two commits/refs
+ - graph - Render commit log graph
+ - image - Image preview, defines compatible file types and max size
+ - markdown - Render markdown
+ - readme - Inherits markdown to render a readme (what you see here!)
+ - notices - Handles cache invalidation for repos that need one rendered
+ - log - View the git log
+ - repos - Aggregates the repos you may view based on session
+ - structured - Whether a file is a YAML or JSON. If so, it prepares data for JS in the frontend to perform folding and suchA
+ - tree - the directory-structure viewer
+
+As time goes on, I am sure I will find more things to add to this. This is really meant to be a project for me, not necessarily building "the next git viewer for everyone".
+
+I figured I would upload this here in case my tokens are useful for others. Enjoy!
diff --git a/llm_readme.md b/llm_readme.md
new file mode 100644
index 0000000..7758ed9
--- /dev/null
+++ b/llm_readme.md
@@ -0,0 +1,503 @@
+# 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" }}<h1>{{ .Title }}</h1>{{ 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 `<script>` tag. Serving that
+as `text/html` on your own origin turns any push into stored XSS against the
+whole site — which is why GitHub puts raw content on
+`raw.githubusercontent.com`, a separate origin. Same-host serving is only
+safe if the response cannot be interpreted as a document, so `/raw/` sends
+`text/plain` (or `application/octet-stream` for binaries, plus
+`Content-Disposition: attachment`), with `X-Content-Type-Options: nosniff`
+and `Content-Security-Policy: default-src \'none\'; sandbox` as a backstop.
+
+The exception is a validated raster image (PNG, JPEG, GIF, WebP, BMP, ICO,
+AVIF): when the extension and the magic bytes agree, `/raw/` declares the
+real `image/*` type inline so the blob page can preview it. An extension by
+itself is not enough — an `.png` whose contents are HTML still downloads as
+`application/octet-stream`. SVG is deliberately excluded: it is an XML
+document that can execute script, so it stays `text/plain`.
+
+If you would rather have real isolation, serve `/raw/` from a second
+hostname and the CSP becomes belt-and-braces rather than the only line.
+
+### Images and structured config files
+
+An image blob shows an inline preview with a `download` link to the same
+`/raw/` URL; images over 10 MiB skip the preview and offer the download only.
+
+JSON and YAML blobs get per-level indent guides, fold controls, key/value
+search, and YAML anchor/alias links. The search box also accepts a value
+path: typing `obj1.property3.items[4].val` resolves it against the parsed
+document and highlights that line and, for a container, its subtree. A
+query containing `.` or `[` is tried as a path first and falls back to
+plain substring search when it does not resolve, so dotted text stays
+searchable. Paths quote keys that need it (`obj1["a.b"]`) and attach indices
+directly; SVG is never previewed, and files past 512 KiB skip the parsing
+tooling entirely.
+
+### Branch graph
+
+`modules/graph.go` lays out the DAG in lanes in one pass over
+`git log --all --topo-order`, which guarantees a child is emitted before its
+parents. `active` holds, per lane, the SHA that lane is waiting to draw; a
+commit claims the lane expecting it (or a fresh one), hands that lane to its
+first parent, and opens a new lane per additional parent — which is what
+makes a merge fork visually.
+
+The module emits the per-row SVG because the geometry *is* the data, but
+every stroke refers to a CSS custom property (`--lane-0` … `--lane-5`), so
+the theme still owns the palette. Row height in the SVG must stay in sync
+with `table.graph td { height }` in the stylesheet.
+
+## Caching, which is the whole reason this works dynamically
+
+Every module result is a pure function of `(repo, commit SHA, path, args)`.
+Refs move; SHAs do not. So the process-wide cache is keyed on the resolved
+SHA and needs **no invalidation logic at all** — a stale entry is impossible
+by construction.
+
+`Ctx.Vars` are part of the cache key, not just the commit. They were not
+originally, and the bug was exactly the kind this cache should be immune to:
+`/compare/a..b` and `/compare/a...b` resolve to the same head commit, so the
+second request was served the first one\'s diff. Anything that changes what a
+module computes has to be in the key.
+
+ETags are derived from the resolved SHA plus a digest of every template
+file, *not* from the response body. A conditional request costs one ref
+lookup instead of a render, and restyling the theme invalidates every ETag
+at once (without the template digest, a restyled page keeps serving 304s).
+
+Two URL classes, two policies:
+
+| URL shape | Example | Cache-Control |
+|---|---|---|
+| SHA-addressed | `/demo/commit/64b90fff…` | `max-age=31536000, immutable` |
+| ref-addressed | `/demo/tree/main` | `no-cache` (revalidate via ETag) |
+
+## Ref-vs-path ambiguity
+
+`/{repo}/tree/{rest...}` fuses them, and `gitx.SplitRefPath` separates them
+by **longest matching prefix against the repo's actual ref list**, so
+`feature/x/src/main.go` resolves correctly. Splitting at the first `/` is
+wrong because branch names contain slashes; cgit dodges this with `?h=`
+query params, GitHub does what this does.
+
+## Serving is an attack surface; static generation was not
+
+- **Ref input never reaches `git rev-parse`.** rev-parse accepts an
+ expression language — `:/regex` searches history, `@{...}` reads the
+ reflog — and git subcommands treat a leading `-` as an option.
+ `gitx.Resolve` accepts either a hex SHA that already exists or an exact
+ match against the repo's own ref list. Verified: `--upload-pack=…`,
+ `:/secret`, `HEAD@{0}` and `-n` all return 404 with no side effects.
+- **Every git call is `exec.CommandContext`** with a per-request deadline
+ (`-timeout`, default 10s) and a scrubbed environment
+ (`GIT_CONFIG_NOSYSTEM=1`, no `HOME`, no terminal prompt).
+- **Output is capped** at `MaxGit` (8 MiB) per command; a diff that exceeds
+ it renders truncated rather than eating the heap.
+
+## Layout
+
+```
+main.go flags, repo discovery, embedded assets
+internal/gitx/ the ONLY package that knows git exists
+internal/core/core.go Ctx, RepoInfo, module registry, Store
+internal/core/route.go Route, View, JSON, route request parsing
+internal/core/server.go request -> Ctx, ETags, cache policy
+internal/core/cache.go LRU keyed on commit SHAs
+internal/core/funcs.go template funcmap (mod, modOr, dict, date, bytes)
+internal/access/ visibility and share-grant policy (imports no core)
+internal/authn/ login/logout endpoints
+internal/httpx/ shared admin/share HTTP security helpers
+internal/modules/ meta refs log tree readme repos commit diff blob notices
+internal/notices/ notices YAML store + admin UI (imports no core)
+internal/api/ JSON DTOs — the serialization allowlist
+internal/site/routes.go the route table (the file you edit most)
+internal/site/request.go route-specific path parsers (compare ranges)
+templates/ layouts/ + views/
+```
+
+`gitx` shells out to plumbing, one process per query, `git log` in a single
+pass. Swapping in go-git means rewriting that one package.
+
+## Sections
+
+`gsg.section` groups repositories on the index. The `sections` module builds
+on `repos` rather than re-walking the repo set, so the index pays for its log
+lookups once. When no repo sets a section, it returns a single unnamed group
+and the template renders a flat list — no config, no headings.
+
+```sh
+git --git-dir=/srv/git/demo.git config gsg.section "Infrastructure"
+```
+
+## Notices
+
+Operator-authored banners at the top of every page, site-wide and per
+repository. They live in one YAML file that is watched for changes, so
+editing it never needs a restart or a rebuild:
+
+```yaml
+global:
+ - type: critical # critical | warning | info | fix
+ title: Maintenance window
+ body: |
+ Saturday 02:00-04:00 UTC.
+ The site is read-only during the window.
+ link: https://status.example.org
+ link_text: status page
+repos:
+ demo:
+ - type: warning
+ title: Migrating to a new remote
+```
+
+```sh
+./gsg -root /srv/git -notices /etc/gsg/notices.yaml
+```
+
+Global notices render above every repository's; on the site index only the
+global list appears. The file is re-read on write (atomic-rename saves from
+an editor included); a parse error keeps the last good content rather than
+blanking the banners.
+
+### Editing from the web
+
+`-admin-addr` starts a second listener with a small HTML form to add, edit,
+and delete notices. It is deliberately separate from the read-only server:
+the admin routes are not reachable on the public port even if the reverse
+proxy is misconfigured.
+
+```sh
+./gsg -root /srv/git -notices /etc/gsg/notices.yaml -admin-addr 127.0.0.1:8081
+```
+
+Protect it with Caddy `forward_auth` and proxy it to the admin listener. gsg
+trusts the network in front of it, so the admin port must not be published
+directly. In Compose, put 8081 on the internal network only and pass
+`-admin-allow-remote`, because a container address is not loopback. Note
+that gsg performs no authentication itself -- the `forward_auth` block below
+is the only thing between the internet and the admin form.
+
+```caddyfile
+example.com {
+ # Admin: authenticate first, then proxy to the separate admin listener.
+ # handle, not handle_path: the admin mux matches the full /admin/...
+ # path, so stripping the prefix would 404 every request.
+ handle /admin/* {
+ forward_auth authentik:9000 {
+ uri /outpost.goauthentik.io/auth/caddy
+ copy_headers X-Authentik-Username
+ }
+ reverse_proxy gsg:8081
+ }
+
+ # Public, read-only.
+ handle {
+ reverse_proxy gsg:8080
+ }
+}
+```
+
+The `X-Authentik-Username` header is used only to attribute the mutation in
+the log; it is never consulted for authorization. By default Authentik
+decides who may reach the handler at all -- anyone forward_auth lets through
+`/admin/*` can edit notices.
+
+Mutations are POST-only, CSRF-protected (double-submit cookie plus
+`Origin`/`Sec-Fetch-Site`), and logged with the `X-Authentik-Username`
+header, which is used for audit only, never for authorization.
+
+### Gating notices editing to a group
+
+`/admin/*` is also where [Login](#login) mints the `gsg_user` cookie, so
+forward_auth for the whole prefix is usually left open to everyone you want
+able to log in (for share links, hidden-repo visibility, etc). To let more
+people log in without also handing them notices editing, add `-admin-group`:
+
+```sh
+./gsg -root /srv/git -notices /etc/gsg/notices.yaml \
+ -admin-addr 127.0.0.1:8081 -admin-group gsg-admins
+```
+
+This checks the `X-Authentik-Groups` header (override with
+`-admin-group-header`) for `gsg-admins` among its `|`-delimited entries,
+Authentik's own format for that header, and answers `403` to any request
+under `/admin/notices` from a caller that isn't a member -- before the
+handler does anything else, including setting the CSRF cookie. Add the group
+header to the same `forward_auth` block used above:
+
+```caddyfile
+ forward_auth authentik:9000 {
+ uri /outpost.goauthentik.io/auth/caddy
+ copy_headers X-Authentik-Username X-Authentik-Groups
+ }
+```
+
+Leaving `-admin-group` unset keeps today's behavior: anyone forward_auth
+admits to `/admin/*` can edit notices.
+
+**Immutable commit pages carry no notices.** `/{repo}/commit/{rev}` is
+served `Cache-Control: public, max-age=31536000, immutable`, so a banner
+rendered into it would stay frozen in the reader's browser. Notices are
+suppressed there by design; every other page picks them up on the next
+request.
+
+## Login
+
+Logging in reveals hidden repos whose `gsg.visible-to` list includes your
+username; everyone else, logged in or not, sees exactly what an anonymous
+visitor sees. It reuses the same Authentik `forward_auth` setup as the
+notices admin UI above, and needs nothing extra beyond `-admin-addr`
+(notices editing is not required — `-admin-addr` alone is enough to enable
+login).
+
+```sh
+./gsg -root /srv/git -admin-addr 127.0.0.1:8081
+```
+
+```caddyfile
+example.com {
+ # Login: same trust boundary as /admin/*, one hop mints the cookie
+ # and redirects back. This must be the exact path, not /auth/*: gsg
+ # also serves /auth/logout, but on the *public* listener, since
+ # logging out only clears gsg's own cookie and needs no Authentik
+ # round trip at all. Routing /auth/* here would send logout to the
+ # admin listener too, where no such handler exists, and gate it
+ # behind an Authentik check it doesn't need.
+ handle /auth/login {
+ forward_auth authentik:9000 {
+ uri /outpost.goauthentik.io/auth/caddy
+ copy_headers X-Authentik-Username
+ }
+ reverse_proxy gsg:8081
+ }
+
+ # Public, read-only (also handles /auth/logout, /share/new, /share/view/*).
+ handle {
+ reverse_proxy gsg:8080
+ }
+}
+```
+
+Visiting `/auth/login` (through Caddy, so `X-Authentik-Username` is set and
+trustworthy by the time gsg sees the request) mints a signed, stateless
+`gsg_user` cookie and redirects back to `/`. The cookie is verified on the
+public listener with an in-memory key — nothing is written to disk, and a
+process restart signs everyone out. `/auth/logout` clears it and needs no
+round trip through Authentik, since it only has to forget gsg's own cookie.
+
+**There is no "log in" link anywhere in the UI.** `/auth/login` is not
+discoverable from the site; people who need it are told the URL out of
+band. Once logged in, the nav bar shows who you are and a log-out link.
+
+## Share links
+
+Once logged in, `/share/new` lets you pick from your own hidden repos and
+mint a link that grants view access to exactly those repos to anyone who
+has it — no login, no Authentik involvement. It's entirely stateless: the
+link is a signed, self-contained token, and gsg keeps no record of what's
+been shared. An optional password can gate it.
+
+```
+git --git-dir=/srv/git/demo.git config gsg.share-max-age "720h"
+```
+
+`gsg.share-max-age` (Go duration syntax, default 30 days if unset) is both
+the ceiling no link's expiry may exceed and the default when the minting
+form doesn't specify one — you can always ask for less, never more. Sharing
+several repos at once uses the smallest ceiling among them.
+
+**Passwords are never stored, not even hashed.** The password is folded
+directly into the link's signature instead of being embedded in it: nobody
+holding just the URL has anything to brute-force offline, and checking a
+guess requires asking the server, which rate-limits attempts. This is a
+soft gate appropriate for a small self-hosted tool, not a cryptographic
+secret protecting high-value data.
+
+**Revocation.** A stateless token can't be individually revoked — there's
+no list of what's been issued. The one lever:
+
+```
+git --git-dir=/srv/git/demo.git config gsg.share-revoked-after "$(date +%s)"
+```
+
+Any share grant for that repo issued at or before this timestamp stops
+working immediately, including in a browser that already redeemed the
+link — not just future redemptions. Two things worth knowing before you
+rely on this:
+
+- There's no per-recipient state, so revoking means invalidating *every*
+ outstanding share of that repo, then re-sharing with whoever should still
+ have it.
+- Removing a username from `gsg.visible-to` does **not** revoke a share
+ link that user already minted for that repo. The two mechanisms are
+ independent; only `gsg.share-revoked-after` invalidates outstanding
+ shares.
+
+## README rendering
+
+`modules/markdown.go` uses goldmark with GFM, Typographer, and automatic
+heading IDs — Hugo\'s own goldmark defaults, so a README renders identically
+to the same file on your blog.
+
+`WithUnsafe` is deliberately not set, which is also Hugo\'s default. Anyone
+who can push can write a README, so raw HTML in markdown is untrusted input;
+goldmark drops it. A `<script>` committed to a README comes out as
+`<!-- raw HTML omitted -->`.
+
+Relative links are rewritten against the file\'s own directory: links become
+the blob view, images become `/raw/` (an `<img>` needs bytes, not an HTML
+page). Absolute URLs, site-absolute paths, fragments and `mailto:` are left
+alone, and anything resolving above the repo root is not rewritten.
+
+## Per-repository config
+
+Read from each repo's own git config, cgit-style, with `gitweb.*` and the
+legacy `description` file as fallbacks, so an existing cgit tree works as-is:
+
+```sh
+git --git-dir=/srv/git/demo.git config gsg.title "Demo Project"
+git --git-dir=/srv/git/demo.git config gsg.description "A demo repository"
+git --git-dir=/srv/git/demo.git config gsg.clone "git://rschanz.org/demo"
+git --git-dir=/srv/git/demo.git config gsg.hide true
+git --git-dir=/srv/git/demo.git config gsg.visible-to "alice,bob"
+git --git-dir=/srv/git/demo.git config gsg.share-max-age "720h"
+git --git-dir=/srv/git/demo.git config gsg.share-revoked-after "$(date +%s)"
+```
+
+Add a key by adding a field to `core.RepoInfo` and a line in `LoadRepoInfo`.
+
+A repo's slug is its directory name (minus `.git`), and slugs share the
+public listener's URL namespace with a few reserved top-level paths:
+`static`, `auth`, and `share`. A repo named e.g. `share.git` would collide
+with the share-link endpoints below and is not supported.
+
+`gsg.visible-to` is a comma-separated allowlist of usernames (matched
+case-insensitively against a logged-in session, see "Login" below) who may
+see this repo even though `gsg.hide` is set. It's a separate key from
+`gsg.owner` on purpose: `Owner` is a free-text display field that falls
+back to `gitweb.owner` for cgit-migrated repos, so treating it as an access
+list could silently grant visibility nobody intended.
+
+## Theme
+
+Monospace-first: every value on these pages is an identifier, hash, path or
+count, so the interface type is the same face as the code, and the sans face
+appears only inside rendered README prose. A cool slate ramp, one indigo
+accent, and a six-colour lane ramp shared by the commit graph and its branch
+chips. Light and dark both defined via `prefers-color-scheme`.
+
+Diffs mark the gutter rather than flooding whole rows with green and red —
+the signal stays readable when a hunk is mostly additions.
+
+No webfonts; system stacks only, so there is no third-party request. All
+colour lives in `:root` custom properties in `static/style.css`.
+
+## Known gaps
+
+- **No syntax highlighting.** `blob` returns numbered plain lines; Chroma
+ drops into the same module.
+- **No pagination on `log` or `graph`.** They cap at 200 and 150 commits.
+- **The graph loads all refs into memory** before laying out. Fine to a few
+ thousand commits; beyond that it needs windowing.
+- **The LRU is bounded by entry count, not bytes.** A giant diff and a ref
+ list cost the same slot. The `Store` interface is the only thing a
+ size-aware replacement has to satisfy.
+- **Repo discovery is one level deep** and happens once at startup; a repo
+ created later needs a restart.
+- **Module errors surface at render time**, not load time. Add a CI pass
+ that renders every view against a fixture repo.