Skip to content
quarry Artifact [a4a60bea11]
Login

Artifact [a4a60bea11]

Artifact a4a60bea114cd71a4992ff1a4d1908a3ab99d3b4b682908879b834eec38f2cea:


// 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; }
@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; }
pre.diff .add { color: #116329; } pre.diff .del { color: #a40e26; }
pre.diff .hunk { color: var(--muted); } pre.diff .head { font-weight: bold; }
@media (prefers-color-scheme: dark) {
  pre.diff .add { color: #56d364; } pre.diff .del { color: #ff7b72; }
}
</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>; <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}}<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 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}}<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
	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 string
}

// hitOf turns one search result into its page link.
func hitOf(kind, ref, snippet string) searchHit {
	h := searchHit{Kind: kind, Label: ref, Snippet: snippet}
	switch kind {
	case "checkin":
		h.URL, h.Label = "/checkin/"+ref, ref[:min(12, len(ref))]
	case "wiki":
		h.URL = "/wiki/" + ref
	case "ticket":
		h.URL, h.Label = "/ticket/"+ref, ref[:min(10, len(ref))]
	}
	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"`
					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.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)
	}
}