Skip to content
quarry Artifact [1e46262ad5]
Login

Artifact [1e46262ad5]

Artifact 1e46262ad5897b30614b61fc3df0921f01ccac110118567840b663363ff26e63:


// 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>.</p>{{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><code>{{printf "%.12s" .Hash}}</code></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
}

type timelineEntry struct {
	Hash    string `json:"hash"`
	Date    string `json:"date"`
	User    string `json:"user"`
	Comment string `json:"comment"`
}

// 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, "quarry", nil)
	})
	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, "timeline", tl.Entries)
	})
	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, title string, entries []timelineEntry) {
	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)
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	page.Execute(w, pageData{Title: title, Version: v.Version, Entries: entries})
}

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