Artifact 7e8f09a88b9e26d2c959bef9f44a069112fd17b23e3c0b988b9a894362604773:
- File
internal/web/web.go
— part of check-in
[689d0692d7]
at
2026-08-15 12:16:01
on branch trunk
— highlight: chroma wrapper, fenced-code + file-view highlighting
Cleanroom (their wrapper is settings-coupled); classes only, both themes in one stylesheet; sanitizer learns chroma token classes. (user: dmestas, size: 11355) [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" "fmt" "html/template" "net/http" "net/url" "reflect" "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; } @media (prefers-color-scheme: dark) { :root { --fg: #e8e8e8; --bg: #14161a; --muted: #9a9a9a; } } 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; } pre { overflow-x: auto; } </style> <style>` + highlight.CSS() + `</style> </head> <body> <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>.</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}} {{if .Checkin.Files}}<table> <thead><tr><th>File</th><th>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>{{end}} {{end}}`)) var ticketListPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}} {{if .Tickets}}<table> <thead><tr><th>Ticket</th><th>Status</th><th>Title</th><th>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>{{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>{{.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 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}}<table> <thead><tr><th>Check-in</th><th>Date</th><th>User</th><th>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>{{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 // 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 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"}) }) 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 { http.Error(w, err.Error(), http.StatusBadRequest) 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 { http.Error(w, err.Error(), http.StatusBadRequest) 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 { http.Error(w, err.Error(), http.StatusBadRequest) 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 { http.NotFound(w, req) 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 { http.NotFound(w, req) return } var cd checkinDetail remarshal(out, &cd) render(r, w, req, checkinPage, pageData{ Title: "check-in " + cd.Hash[:12], Checkin: cd, }) }) 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 { http.NotFound(w, req) 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 { http.NotFound(w, req) 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) { 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") page.Execute(w, data) } // 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) } }