Artifact 9cc6e97802c5e41b4d4a041d4028622f4f705aaa38cc2cca1d81f8987712d8c1:
- File
internal/web/web.go
— part of check-in
[4681b2fbc4]
at
2026-08-15 18:13:50
on branch trunk
— timeline: unified events — wiki and ticket rows interleave (y= filter)
Union query over checkin/wiki_edit/ticket_change (ticket titles from the folded search doc); composite date~hash cursor; flat event rows with badges, rails passing through; Show selector in controls. (user: dmestas, size: 25182) [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. // Repo-scoped pages live under /r/{repo}/; the home page is the fleet. package web import ( "bytes" "context" "encoding/json" "errors" "fmt" "html/template" "log" "net/http" "net/url" "reflect" "strconv" "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). The header carries the fleet crumb // and, inside a repo, that repo's nav and search. 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; } /* Timeline card/detail metrics adapted from fossil src/default.css (BSD-2-Clause; see third_party/PROVENANCE.md). The graph tiles seamlessly: a fixed 36px head band (node + curves) and a stretching tail fill each row edge-to-edge at identical pixel x positions, and date rows carry the active rails through. */ table.tl td { border: 0; padding: 0; vertical-align: top; } table.tl td.tl-graph { position: relative; } svg.tl-head { position: absolute; top: 0; left: 0; display: block; } svg.tl-tail { position: absolute; top: 36px; left: 0; height: calc(100% - 36px); display: block; } tr.tl-date svg.tl-tail, tr.tl-flat svg.tl-tail { top: 0; height: 100%; } .tl-card-flat { padding: 0.45em 0.75em; font-size: 0.95em; } .tl-meta-inline { color: var(--muted); font-size: 85%; } table.tl td.tl-time { white-space: nowrap; width: 3.4rem; padding: 13px 0.5rem 0 0; text-align: right; } table.tl td.tl-time a { color: var(--muted); text-decoration: none; } table.tl td.tl-time a:hover { text-decoration: underline; } table.tl td.tl-slot { width: 100%; padding: 0 0 5px 0.6rem; } table.tl td.tl-datecell { padding: 6px 0 6px 0.6rem; } .tl-card { background: var(--code-bg); border-radius: 1em; padding: 0.75em; } .tl-title { font-weight: 550; } .tl-p { margin: 0.35em 0 0; color: color-mix(in srgb, var(--fg) 78%, var(--muted)); } .tl-meta { color: var(--muted); font-size: 80%; text-align: right; opacity: 0.85; margin-top: 0.5em; } .tl-meta a { text-decoration: none; } .tl-meta a:hover { text-decoration: underline; } @media (max-width: 640px) { body { padding: 0.5rem; } table.tl td.tl-time { width: 2.6rem; font-size: 0.8rem; padding-top: 15px; } .tl-card { padding: 0.75em 0.55em; border-radius: 0.6em; } .tl-meta { font-size: 72%; } } .tl-badge { border: 1px solid var(--muted); border-radius: 3px; padding: 0 0.25rem; font-size: 0.72rem; } .tl-pill { display: inline-block; border: 1px solid var(--muted); border-radius: 6px; padding: 0.05rem 0.5rem; font-weight: bold; margin: 0.55rem 0 0.25rem; } .tl-controls { color: var(--muted); font-size: 0.9rem; display: flex; gap: 0.8rem; align-items: baseline; margin-bottom: 0.4rem; } 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> {{if .Repo}}<a href="/r/{{.Repo}}/timeline">{{.Repo}}</a> <nav><a href="/r/{{.Repo}}/timeline">timeline</a> <a href="/r/{{.Repo}}/wiki">wiki</a> <a href="/r/{{.Repo}}/ticket">tickets</a></nav> <form action="/r/{{.Repo}}/search" method="get"><input type="search" name="q" placeholder="search" aria-label="search"></form> {{end}} </header> <main> <h1>{{.Title}}</h1> {{block "content" .}}{{end}} </main> <footer>quarry {{.Version}}</footer> </body> </html> `)) var fleetPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} {{if .Fleet}}<div class="tw"><table> <thead><tr><th scope="col">Repository</th><th scope="col">Check-ins</th><th scope="col">Tip</th></tr></thead> <tbody>{{range .Fleet}} <tr><td><a href="/r/{{.Name}}/timeline">{{.Name}}</a></td><td>{{.Checkins}}</td><td>{{.TipDate}} — {{.TipComment}}</td></tr> {{end}}</tbody> </table></div>{{else}}<p>No repositories are being served. Start with <code>quarry serve --repo name=path</code>.</p>{{end}} {{end}}`)) var wikiListPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} {{if .WikiPages}}<ul>{{range .WikiPages}} <li><a href="/r/{{$.Repo}}/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}} on <a href="/r/{{.Repo}}/timeline?branch={{.Checkin.Branch}}">{{.Checkin.Branch}}</a> — <code>{{.Checkin.Hash}}</code></small></p> {{if .Checkin.Parents}}<p>parent{{if gt (len .Checkin.Parents) 1}}s{{end}}: {{range .Checkin.Parents}} <a href="/r/{{$.Repo}}/checkin/{{.}}"><code>{{printf "%.12s" .}}</code></a>{{end}}</p>{{end}} <p><a href="/r/{{.Repo}}/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="/r/{{$.Repo}}/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="/r/{{$.Repo}}/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="/r/{{.Repo}}/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. Start from the <a href="/">repository list</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="/r/{{.Repo}}/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="/r/{{.Repo}}/checkin/{{.Diff.From}}"><code>{{printf "%.12s" .Diff.From}}</code></a> →{{else}}root check-in →{{end}} <a href="/r/{{.Repo}}/checkin/{{.Diff.To}}"><code>{{printf "%.12s" .Diff.To}}</code></a></small></p> {{if .Diff.Note}}<p><em>{{.Diff.Note}}</em></p>{{end}} {{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"}} <form class="tl-controls" action="/r/{{.Repo}}/timeline" method="get"> {{if .Branch}}<input type="hidden" name="branch" value="{{.Branch}}"> <span>branch <a href="/r/{{.Repo}}/timeline?n={{.Limit}}"><b>{{.Branch}}</b> ✕</a></span>{{end}} <label>Show <select name="y"> <option value="all"{{if or (eq .Y "") (eq .Y "all")}} selected{{end}}>everything</option> <option value="ci"{{if eq .Y "ci"}} selected{{end}}>check-ins</option> <option value="w"{{if eq .Y "w"}} selected{{end}}>wiki</option> <option value="t"{{if eq .Y "t"}} selected{{end}}>tickets</option> </select></label> <label>Max <select name="n">{{range .LimitChoices}}<option{{if eq . $.Limit}} selected{{end}}>{{.}}</option>{{end}}</select></label> <button type="submit">Apply</button> </form> {{if .Days}}<div class="tw"><table class="tl"> <tbody>{{range .Days}} <tr class="tl-date"><td></td><td class="tl-graph" style="min-width:{{.Width}}px">{{.Carry}}</td><td class="tl-datecell"><span class="tl-pill">{{.Date}}</span></td></tr> {{range .Rows}}<tr{{if ne .Kind "ci"}} class="tl-flat"{{end}}> <td class="tl-time"><a href="{{.URL}}">{{.Time}}</a></td> <td class="tl-graph" style="min-width:{{.Width}}px">{{.Head}}{{.Tail}}</td> <td class="tl-slot"><div class="tl-card{{if ne .Kind "ci"}} tl-card-flat{{end}}"{{with .Tint}} style="{{.}}"{{end}}> {{if eq .Kind "ci"}}<span class="tl-title">{{.Title}}</span> {{range .Paras}}<p class="tl-p">{{.}}</p>{{end}} <div class="tl-meta">{{if .IsMerge}}<span class="tl-badge">Merge</span> {{end}}{{if .IsLeaf}}<span class="tl-badge">Leaf</span> {{end}}check-in: <a href="{{.URL}}"><b>{{.Short}}</b></a> user: <b>{{.User}}</b> branch: <a href="/r/{{$.Repo}}/timeline?branch={{.Branch}}"><b>{{.Branch}}</b></a></div> {{else}}<span class="tl-badge">{{if eq .Kind "w"}}wiki{{else}}ticket{{end}}</span> <a href="{{.URL}}">{{.Title}}</a> <span class="tl-meta-inline">by <b>{{.User}}</b></span>{{end}} </div></td> </tr> {{end}}{{end}}</tbody> </table></div> {{if .Next}}<p><a href="/r/{{.Repo}}/timeline?before={{.Next}}{{if .Branch}}&branch={{.Branch}}{{end}}{{with .Y}}&y={{.}}{{end}}&n={{.Limit}}">older →</a></p>{{end}} {{else}}<p>No check-ins.</p>{{end}} {{end}}`)) type pageData struct { Title string Version string Repo string // repo context; empty on fleet-level pages Branch string // active timeline branch filter Limit int LimitChoices []int Y string Fleet []repoInfo Entries []timelineEntry Days []tlDay Next string 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 repoInfo struct { Name string `json:"name"` Checkins int `json:"checkins"` TipDate string `json:"tipDate"` TipComment string `json:"tipComment"` } type timelineEntry struct { Kind string `json:"kind"` Ref string `json:"ref"` Hash string `json:"hash"` Date string `json:"date"` User string `json:"user"` Comment string `json:"comment"` Branch string `json:"branch"` IsLeaf bool `json:"isLeaf"` IsMerge bool `json:"isMerge"` Parents []string `json:"parents"` } 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 checkinDetail struct { Hash string `json:"hash"` Branch string `json:"branch"` 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"` } 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 diffDetail struct { From string `json:"from"` To string `json:"to"` Note string `json:"note"` 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 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(repo, kind, ref, label, snippet string) searchHit { h := searchHit{Kind: kind, Label: label} prefix := "/r/" + repo switch kind { case "checkin": h.URL = prefix + "/checkin/" + ref if h.Label == "" { h.Label = ref[:min(12, len(ref))] } case "wiki": h.URL = prefix + "/wiki/" + ref if h.Label == "" { h.Label = ref } case "ticket": h.URL = prefix + "/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 } // 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) { out, err := invoke(req.Context(), r, "repo.list", nil) if err != nil { fail(r, w, req, err) return } var fl struct { Repos []repoInfo `json:"repos"` } remarshal(out, &fl) render(r, w, req, fleetPage, pageData{Title: "quarry", Fleet: fl.Repos}) }) // 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 /r/{repo}/timeline", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") vals := req.URL.Query() vals.Set("repo", repo) // The page's Max control uses fossil's n= name; the op's field // is limit. limit := 50 if n := vals.Get("n"); n != "" { vals.Set("limit", n) if v, err := strconv.Atoi(n); err == nil && v > 0 && v <= 200 { limit = v } } out, err := invoke(req.Context(), r, "timeline.list", vals) if err != nil { fail(r, w, req, err) return } var tl struct { Entries []timelineEntry `json:"entries"` Next string `json:"next"` } remarshal(out, &tl) render(r, w, req, timelinePage, pageData{ Title: "timeline", Repo: repo, Days: buildTimeline(repo, tl.Entries), Next: tl.Next, Branch: req.URL.Query().Get("branch"), Y: req.URL.Query().Get("y"), Limit: limit, LimitChoices: []int{20, 50, 100, 200}, }) }) mux.HandleFunc("GET /r/{repo}/wiki", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") out, err := invoke(req.Context(), r, "wiki.list", url.Values{"repo": {repo}}) 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", Repo: repo, WikiPages: wl.Pages}) }) mux.HandleFunc("GET /r/{repo}/ticket", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") out, err := invoke(req.Context(), r, "ticket.list", url.Values{"repo": {repo}}) 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", Repo: repo, Tickets: tl.Tickets}) }) mux.HandleFunc("GET /r/{repo}/ticket/{id}", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") out, err := invoke(req.Context(), r, "ticket.get", url.Values{"repo": {repo}, "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], Repo: repo, Ticket: td.Ticket, }) }) mux.HandleFunc("GET /r/{repo}/search", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") q := req.URL.Query().Get("q") data := pageData{Title: "search", Repo: repo, Query: q} if q != "" { vals := req.URL.Query() vals.Set("repo", repo) out, err := invoke(req.Context(), r, "search.query", vals) if err != nil { fail(r, w, req, err) 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(repo, res.Kind, res.Ref, res.Label, res.Snippet)) } } render(r, w, req, searchPage, data) }) mux.HandleFunc("GET /r/{repo}/checkin/{hash}/diff", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") out, err := invoke(req.Context(), r, "checkin.diff", url.Values{"repo": {repo}, "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], Repo: repo, Diff: dd, DiffLines: classifyDiff(dd.Unified), }) }) mux.HandleFunc("GET /r/{repo}/file/{hash}/{path...}", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") out, err := invoke(req.Context(), r, "file.get", url.Values{ "repo": {repo}, "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, Repo: repo, File: fd, FileHTML: template.HTML(fd.HTML), }) }) mux.HandleFunc("GET /r/{repo}/checkin/{hash}", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") out, err := invoke(req.Context(), r, "checkin.get", url.Values{"repo": {repo}, "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], Repo: repo, Checkin: cd, }) }) mux.HandleFunc("GET /r/{repo}/wiki/{name}", func(w http.ResponseWriter, req *http.Request) { repo := req.PathValue("repo") out, err := invoke(req.Context(), r, "wiki.get", url.Values{"repo": {repo}, "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, Repo: repo, 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 // Render to a buffer first: a template error mid-stream would // otherwise emit a silently truncated page (a bug class this exact // code once shipped) — buffered, it becomes a loud, logged 500. var buf bytes.Buffer if err := page.Execute(&buf, data); err != nil { log.Printf("web: template %s: %v", req.URL.Path, err) http.Error(w, "template error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) w.Write(buf.Bytes()) } // 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) } }