Skip to content
quarry Artifact [afdb0fcbab]
Login

Artifact [afdb0fcbab]

Artifact afdb0fcbab5d6993704e0f87f62ce5a6962d262ae4c540c4ee9221cb345124b3:


// 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> and the <a href="/wiki">wiki</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 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><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
	WikiPages []wikiTitle
	Wiki      wikiDetail
	// 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"`
}

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