Artifact 202fcfb8276277633b6b93a2a46bd72118b5e211d56f104ce814b285d00bedfd:
- File
internal/web/web.go
— part of check-in
[3253bba7bb]
at
2026-08-15 16:15:08
on branch trunk
— layout: persistent nav, code-block styling, single h1, a11y (010/100/110/120/140)
Header nav + search form in base; markdown headings demoted one level; pre gets --code-bg; tables scroll in .tw wrappers; th scope attrs. (user: dmestas, size: 18180) [annotate] [blame] [check-ins using]
// Package web is the HTML consumer of the registry (0001 §9). Pages are // server-rendered html/template over op results — the same ops the API // serves, so every page capability is API-visible by construction. This // first page exists to prove the seam end to end (0003 phase 0); real // pages land with the 0002 §4 extraction order. package web import ( "context" "encoding/json" "errors" "fmt" "html/template" "log" "net/http" "net/url" "reflect" "strings" "craftdesign.group/quarry/internal/highlight" "craftdesign.group/quarry/internal/ops" ) // base is the layout every page renders through. Both themes come from // prefers-color-scheme; no JavaScript anywhere (0001 Principle 3 — the // no-JS baseline is the baseline). var base = template.Must(template.New("base").Parse(`<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>{{.Title}} — quarry</title> <style> :root { --fg: #1a1a1a; --bg: #ffffff; --muted: #6a6a6a; --code-bg: #f4f5f7; } @media (prefers-color-scheme: dark) { :root { --fg: #e8e8e8; --bg: #14161a; --muted: #9a9a9a; --code-bg: #1c2026; } } body { color: var(--fg); background: var(--bg); font: 16px/1.5 system-ui, sans-serif; max-width: 60rem; margin: 0 auto; padding: 1rem; } footer { color: var(--muted); border-top: 1px solid var(--muted); margin-top: 2rem; padding-top: 0.5rem; font-size: 0.85rem; } table { border-collapse: collapse; width: 100%; } th, td { text-align: left; padding: 0.25rem 0.75rem 0.25rem 0; } th { border-bottom: 1px solid var(--muted); } a { color: inherit; } header { display: flex; gap: 1rem; align-items: baseline; flex-wrap: wrap; border-bottom: 1px solid var(--muted); padding-bottom: 0.5rem; } header .site { font-weight: bold; } header form { margin-left: auto; } .tw { overflow-x: auto; } pre { overflow-x: auto; background: var(--code-bg); padding: 0.5rem; border-radius: 4px; } pre.diff .add { color: #116329; } pre.diff .del { color: #a40e26; } pre.diff .hunk { color: var(--muted); } pre.diff .head { font-weight: bold; } mark { background: #fff3b8; color: inherit; } @media (prefers-color-scheme: dark) { pre.diff .add { color: #56d364; } pre.diff .del { color: #ff7b72; } mark { background: #5c4d00; } } </style> <style>` + highlight.CSS() + `</style> </head> <body> <header> <a class="site" href="/">quarry</a> <nav><a href="/timeline">timeline</a> <a href="/wiki">wiki</a> <a href="/ticket">tickets</a></nav> <form action="/search" method="get"><input type="search" name="q" placeholder="search" aria-label="search"></form> </header> <main> <h1>{{.Title}}</h1> {{block "content" .}}{{end}} </main> <footer>quarry {{.Version}}</footer> </body> </html> `)) var homePage = template.Must(template.Must(base.Clone()).Parse( `{{define "content"}}<p>A forge for zit repositories. See the <a href="/timeline">timeline</a>, the <a href="/wiki">wiki</a>, and the <a href="/ticket">tickets</a>; <a href="/search">search</a> everything.</p>{{end}}`)) var wikiListPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} {{if .WikiPages}}<ul>{{range .WikiPages}} <li><a href="/wiki/{{.Title}}">{{.Title}}</a> <small>{{.Date}} by {{.User}}</small></li> {{end}}</ul>{{else}}<p>No wiki pages.</p>{{end}} {{end}}`)) var checkinPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <p>{{.Checkin.Comment}}</p> <p><small>{{.Checkin.Date}} by {{.Checkin.User}} — <code>{{.Checkin.Hash}}</code></small></p> {{if .Checkin.Parents}}<p>parent{{if gt (len .Checkin.Parents) 1}}s{{end}}: {{range .Checkin.Parents}} <a href="/checkin/{{.}}"><code>{{printf "%.12s" .}}</code></a>{{end}}</p>{{end}} <p><a href="/checkin/{{.Checkin.Hash}}/diff">view diff</a></p> {{if .Checkin.Files}}<div class="tw"><table> <thead><tr><th scope="col">File</th><th scope="col">Blob</th></tr></thead> <tbody>{{range .Checkin.Files}} <tr><td><a href="/file/{{$.Checkin.Hash}}/{{.Name}}">{{.Name}}</a></td><td><code>{{printf "%.12s" .Blob}}</code></td></tr> {{end}}</tbody> </table></div>{{end}} {{end}}`)) var ticketListPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} {{if .Tickets}}<div class="tw"><table> <thead><tr><th scope="col">Ticket</th><th scope="col">Status</th><th scope="col">Title</th><th scope="col">Updated</th></tr></thead> <tbody>{{range .Tickets}} <tr><td><a href="/ticket/{{.ID}}"><code>{{printf "%.10s" .ID}}</code></a></td><td>{{.Status}}</td><td>{{.Title}}</td><td>{{.Updated}}</td></tr> {{end}}</tbody> </table></div>{{else}}<p>No tickets.</p>{{end}} {{end}}`)) var ticketPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <table> <tbody>{{range .Ticket.Fields}} <tr><th scope="row">{{.Name}}</th><td>{{.Value}}</td></tr> {{end}}</tbody> </table> <h2>History</h2> <ul>{{range .Ticket.Changes}} <li>{{.Date}} by {{.User}}: {{range .Fields}}{{.Name}}={{.Value}} {{end}}</li> {{end}}</ul> {{end}}`)) var filePage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <p><small>at <a href="/checkin/{{.File.Hash}}"><code>{{printf "%.12s" .File.Hash}}</code></a> — {{.File.Size}} bytes</small></p> {{if .File.Binary}}<p>Binary file.</p>{{else if .FileHTML}}{{.FileHTML}}{{else}}<pre>{{.File.Content}}</pre>{{end}} {{end}}`)) var notFoundPage = template.Must(template.Must(base.Clone()).Parse( `{{define "content"}}<p>Nothing lives at this address. Try the <a href="/timeline">timeline</a>, the <a href="/wiki">wiki</a>, the <a href="/ticket">tickets</a>, or <a href="/search">search</a>.</p>{{end}}`)) var errorPage = template.Must(template.Must(base.Clone()).Parse( `{{define "content"}}<p>Something went wrong serving this page. The details are in the server log.</p>{{end}}`)) var searchPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <form action="/search" method="get"> <input type="search" name="q" value="{{.Query}}" placeholder="search check-ins, wiki, tickets" aria-label="search"> <button type="submit">Search</button> </form> {{if .Results}}<ul>{{range .Results}} <li><code>{{.Kind}}</code> <a href="{{.URL}}">{{.Label}}</a> — <small>{{.Snippet}}</small></li> {{end}}</ul>{{else if .Query}}<p>No results.</p>{{end}} {{end}}`)) var diffPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <p><small>{{if .Diff.From}}<a href="/checkin/{{.Diff.From}}"><code>{{printf "%.12s" .Diff.From}}</code></a> →{{else}}root check-in →{{end}} <a href="/checkin/{{.Diff.To}}"><code>{{printf "%.12s" .Diff.To}}</code></a></small></p> {{if .Diff.Files}}<ul>{{range .Diff.Files}}<li><code>{{.Status}}</code> {{.Name}}</li>{{end}}</ul>{{end}} {{if .DiffLines}}<pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span> {{end}}</pre>{{end}} {{end}}`)) var wikiPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} <article>{{.WikiHTML}}</article> <footer><small>edited {{.Wiki.Date}} by {{.Wiki.User}}</small></footer> {{end}}`)) var timelinePage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} {{if .Entries}}<div class="tw"><table> <thead><tr><th scope="col">Check-in</th><th scope="col">Date</th><th scope="col">User</th><th scope="col">Comment</th></tr></thead> <tbody>{{range .Entries}} <tr><td><a href="/checkin/{{.Hash}}"><code>{{printf "%.12s" .Hash}}</code></a></td><td>{{.Date}}</td><td>{{.User}}</td><td>{{.Comment}}</td></tr> {{end}}</tbody> </table></div>{{else}}<p>No check-ins.</p>{{end}} {{end}}`)) type pageData struct { Title string Version string Entries []timelineEntry WikiPages []wikiTitle Wiki wikiDetail Checkin checkinDetail Tickets []ticketSummary Ticket ticketDetail File fileDetail Diff diffDetail DiffLines []diffLine Query string Results []searchHit // WikiHTML and FileHTML are op-rendered, sanitizer-gated markup — // the trusted HTML injection points (0002 §2: the sanitizer is the // gate). WikiHTML template.HTML FileHTML template.HTML } type timelineEntry struct { Hash string `json:"hash"` Date string `json:"date"` User string `json:"user"` Comment string `json:"comment"` } type wikiTitle struct { Title string `json:"title"` Date string `json:"date"` User string `json:"user"` } type wikiDetail struct { Title string `json:"title"` Date string `json:"date"` User string `json:"user"` HTML string `json:"html"` } type ticketSummary struct { ID string `json:"id"` Title string `json:"title"` Status string `json:"status"` Updated string `json:"updated"` } type ticketField struct { Name string `json:"name"` Value string `json:"value"` } type ticketDetail struct { ID string `json:"id"` Fields []ticketField `json:"fields"` Changes []struct { Date string `json:"date"` User string `json:"user"` Fields []ticketField `json:"fields"` } `json:"changes"` } type fileDetail struct { Hash string `json:"hash"` Path string `json:"path"` Size int `json:"size"` Binary bool `json:"binary"` Content string `json:"content"` HTML string `json:"html"` } type searchHit struct { Kind string Label string URL string Snippet template.HTML } // hitOf turns one search result into its page link. The label is the // human-readable name the op provides (ticket title, wiki name, // check-in comment) with the ref as fallback; the snippet's ⟦…⟧ match // markers become <mark> AFTER escaping, so content stays inert. func hitOf(kind, ref, label, snippet string) searchHit { h := searchHit{Kind: kind, Label: label} switch kind { case "checkin": h.URL = "/checkin/" + ref if h.Label == "" { h.Label = ref[:min(12, len(ref))] } case "wiki": h.URL = "/wiki/" + ref if h.Label == "" { h.Label = ref } case "ticket": h.URL = "/ticket/" + ref if h.Label == "" { h.Label = ref[:min(10, len(ref))] } } esc := template.HTMLEscapeString(snippet) esc = strings.ReplaceAll(esc, "⟦", "<mark>") esc = strings.ReplaceAll(esc, "⟧", "</mark>") h.Snippet = template.HTML(esc) return h } type diffDetail struct { From string `json:"from"` To string `json:"to"` Files []struct { Status string `json:"status"` Name string `json:"name"` } `json:"files"` Unified string `json:"unified"` } type diffLine struct { Class string Text string } // classifyDiff maps unified-diff lines to display classes; the template // escapes Text, so content stays inert. func classifyDiff(unified string) []diffLine { if unified == "" { return nil } var out []diffLine for _, line := range strings.Split(strings.TrimSuffix(unified, "\n"), "\n") { class := "ctx" switch { case strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"): class = "head" case strings.HasPrefix(line, "@@"): class = "hunk" case strings.HasPrefix(line, "+"): class = "add" case strings.HasPrefix(line, "-"): class = "del" } out = append(out, diffLine{Class: class, Text: line}) } return out } type checkinDetail struct { Hash string `json:"hash"` Date string `json:"date"` User string `json:"user"` Comment string `json:"comment"` Parents []string `json:"parents"` Files []struct { Blob string `json:"blob"` Name string `json:"name"` } `json:"files"` } // Handler mounts every HTML page over the registry. func Handler(r *ops.Registry) http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, req *http.Request) { render(r, w, req, homePage, pageData{Title: "quarry"}) }) // Everything unmatched gets the themed 404, never Go's bare // plain-text default. mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { fail(r, w, req, ops.ErrNotFound) }) mux.HandleFunc("GET /timeline", func(w http.ResponseWriter, req *http.Request) { out, err := invoke(req.Context(), r, "timeline.list", req.URL.Query()) if err != nil { fail(r, w, req, err) return } var tl struct { Entries []timelineEntry `json:"entries"` } remarshal(out, &tl) render(r, w, req, timelinePage, pageData{Title: "timeline", Entries: tl.Entries}) }) mux.HandleFunc("GET /wiki", func(w http.ResponseWriter, req *http.Request) { out, err := invoke(req.Context(), r, "wiki.list", nil) if err != nil { fail(r, w, req, err) return } var wl struct { Pages []wikiTitle `json:"pages"` } remarshal(out, &wl) render(r, w, req, wikiListPage, pageData{Title: "wiki", WikiPages: wl.Pages}) }) mux.HandleFunc("GET /ticket", func(w http.ResponseWriter, req *http.Request) { out, err := invoke(req.Context(), r, "ticket.list", nil) if err != nil { fail(r, w, req, err) return } var tl struct { Tickets []ticketSummary `json:"tickets"` } remarshal(out, &tl) render(r, w, req, ticketListPage, pageData{Title: "tickets", Tickets: tl.Tickets}) }) mux.HandleFunc("GET /ticket/{id}", func(w http.ResponseWriter, req *http.Request) { out, err := invoke(req.Context(), r, "ticket.get", url.Values{"id": {req.PathValue("id")}}) if err != nil { fail(r, w, req, err) return } var td struct { Ticket ticketDetail `json:"ticket"` } remarshal(out, &td) render(r, w, req, ticketPage, pageData{ Title: "ticket " + td.Ticket.ID[:10], Ticket: td.Ticket, }) }) mux.HandleFunc("GET /checkin/{hash}", func(w http.ResponseWriter, req *http.Request) { out, err := invoke(req.Context(), r, "checkin.get", url.Values{"hash": {req.PathValue("hash")}}) if err != nil { fail(r, w, req, err) return } var cd checkinDetail remarshal(out, &cd) render(r, w, req, checkinPage, pageData{ Title: "check-in " + cd.Hash[:12], Checkin: cd, }) }) mux.HandleFunc("GET /search", func(w http.ResponseWriter, req *http.Request) { q := req.URL.Query().Get("q") data := pageData{Title: "search", Query: q} if q != "" { out, err := invoke(req.Context(), r, "search.query", req.URL.Query()) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } var sr struct { Results []struct { Kind string `json:"kind"` Ref string `json:"ref"` Label string `json:"label"` Snippet string `json:"snippet"` } `json:"results"` } remarshal(out, &sr) for _, res := range sr.Results { data.Results = append(data.Results, hitOf(res.Kind, res.Ref, res.Label, res.Snippet)) } } render(r, w, req, searchPage, data) }) mux.HandleFunc("GET /checkin/{hash}/diff", func(w http.ResponseWriter, req *http.Request) { out, err := invoke(req.Context(), r, "checkin.diff", url.Values{"hash": {req.PathValue("hash")}}) if err != nil { fail(r, w, req, err) return } var dd diffDetail remarshal(out, &dd) render(r, w, req, diffPage, pageData{ Title: "diff " + dd.To[:12], Diff: dd, DiffLines: classifyDiff(dd.Unified), }) }) mux.HandleFunc("GET /file/{hash}/{path...}", func(w http.ResponseWriter, req *http.Request) { out, err := invoke(req.Context(), r, "file.get", url.Values{ "hash": {req.PathValue("hash")}, "path": {req.PathValue("path")}, }) if err != nil { fail(r, w, req, err) return } var fd fileDetail remarshal(out, &fd) render(r, w, req, filePage, pageData{ Title: fd.Path, File: fd, FileHTML: template.HTML(fd.HTML), }) }) mux.HandleFunc("GET /wiki/{name}", func(w http.ResponseWriter, req *http.Request) { out, err := invoke(req.Context(), r, "wiki.get", url.Values{"name": {req.PathValue("name")}}) if err != nil { fail(r, w, req, err) return } var wd wikiDetail remarshal(out, &wd) render(r, w, req, wikiPage, pageData{ Title: wd.Title, Wiki: wd, WikiHTML: template.HTML(wd.HTML), }) }) return mux } // render executes a page template with the version footer served by the // meta.version op — every page reports it (0001 Principle 1). func render(r *ops.Registry, w http.ResponseWriter, req *http.Request, page *template.Template, data pageData) { renderStatus(r, w, req, page, data, http.StatusOK) } func renderStatus(r *ops.Registry, w http.ResponseWriter, req *http.Request, page *template.Template, data pageData, status int) { out, err := invoke(req.Context(), r, "meta.version", nil) if err != nil { http.Error(w, "version op failed", http.StatusInternalServerError) return } var v struct { Version string `json:"version"` } remarshal(out, &v) data.Version = v.Version w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) page.Execute(w, data) } // fail renders the themed error page for an op failure: not-found gets // the 404 page, everything else logs and gets the 500 page — never // Go's bare plain-text defaults (every page keeps theme, nav, version). func fail(r *ops.Registry, w http.ResponseWriter, req *http.Request, err error) { if errors.Is(err, ops.ErrNotFound) { renderStatus(r, w, req, notFoundPage, pageData{Title: "not found"}, http.StatusNotFound) return } log.Printf("web: %s: %v", req.URL.Path, err) renderStatus(r, w, req, errorPage, pageData{Title: "error"}, http.StatusInternalServerError) } // invoke runs an op through its descriptor with form-shaped input — the // same decode path the API router uses, so HTML pages and API calls hit // identical op semantics. Results come back as opaque values; pages pick // the fields they render via remarshal. func invoke(ctx context.Context, r *ops.Registry, name string, form url.Values) (any, error) { d, ok := r.Lookup(name) if !ok { return nil, fmt.Errorf("web: unknown op %q", name) } inPtr := d.NewIn() if err := ops.DecodeValues(form, inPtr); err != nil { return nil, err } in := reflect.ValueOf(inPtr).Elem().Interface() if err := ops.Validate(in); err != nil { return nil, err } return r.Invoke(ctx, name, in) } // remarshal projects an op result onto the fields a page renders — the // JSON round-trip keeps web coupled to op output *shapes* (the API // contract) rather than to concrete Go types in other packages. func remarshal(from any, to any) { b, err := json.Marshal(from) if err == nil { json.Unmarshal(b, to) } }