// 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; }
table.tl td { border: 0; padding: 0; vertical-align: middle; }
td.tl-graph { position: relative; }
td.tl-graph svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.tl-node { position: absolute; top: 50%; width: 9px; height: 9px; margin: -4.5px 0 0 -4.5px;
border-radius: 50%; background: var(--nc); }
.tl-node.tl-tip { background: var(--bg); border: 2.5px solid var(--nc);
width: 10px; height: 10px; margin: -5px 0 0 -5px; }
td.tl-time { white-space: nowrap; width: 3.4rem; padding-right: 0.5rem; text-align: right; }
td.tl-time a { color: var(--muted); text-decoration: none; }
td.tl-time a:hover { text-decoration: underline; }
td.tl-slot { width: 100%; padding: 2px 0 2px 0.6rem; }
.tl-card { background: var(--code-bg); border-radius: 6px; padding: 0.35rem 0.6rem; }
.tl-title { font-weight: 500; }
.tl-body { color: var(--muted); font-size: 0.9rem; white-space: pre-line; margin-top: 0.15rem; }
.tl-meta { color: var(--muted); font-size: 0.8rem; text-align: right; margin-top: 0.2rem; }
.tl-meta a { text-decoration: none; }
.tl-meta a:hover { text-decoration: underline; }
.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>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 colspan="3"><span class="tl-pill">{{.Date}}</span></td></tr>
{{range .Rows}}<tr>
<td class="tl-time"><a href="/r/{{$.Repo}}/checkin/{{.Hash}}">{{.Time}}</a></td>
<td class="tl-graph" style="min-width:{{.Width}}px">{{.Rails}}<span class="tl-node{{if .IsLeaf}} tl-tip{{end}}" style="left:{{.NodeX}}px;--nc:{{.Color}}"></span></td>
<td class="tl-slot"><div class="tl-card"{{with .Tint}} style="{{.}}"{{end}}>
<span class="tl-title">{{.Title}}</span>
{{with .Body}}<div class="tl-body">{{.}}</div>{{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="/r/{{$.Repo}}/checkin/{{.Hash}}"><b>{{.Short}}</b></a>
user: <b>{{.User}}</b>
branch: <a href="/r/{{$.Repo}}/timeline?branch={{.Branch}}"><b>{{.Branch}}</b></a></div>
</div></td>
</tr>
{{end}}{{end}}</tbody>
</table></div>
{{if .Next}}<p><a href="/r/{{.Repo}}/timeline?before={{.Next}}{{if .Branch}}&branch={{.Branch}}{{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
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 {
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(tl.Entries), Next: tl.Next,
Branch: req.URL.Query().Get("branch"),
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)
}
}