Skip to content
quarry Artifact [d037f2bec3]
Login

Artifact [d037f2bec3]

Artifact d037f2bec37ac5d1312cb0855e6f3d65c5782a0c3867bea12ed59eb4c044fb5b:


// 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/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; }
</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}}<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 is op-rendered, sanitizer-gated markup — the one trusted
	// HTML injection point (0002 §2: the sanitizer is the gate).
	WikiHTML 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"`
}

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})
	})
	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)
	}
}