package web
import (
"embed"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"go.bigb.es/auxilia/scribe"
"sourcecraft.dev/bigbes/sr-ht-ecore/pages"
)
//go:embed templates/*.html templates/icons/*.svg
var templateFS embed.FS
// loadPages parses one template set per page in templates/, through
// sr-ht-ecore's pages: the layout, the shared chrome partials, our own
// _partials.html and that page's content.
//
// There is no list of pages here any more. Pages are discovered from the
// directory, so a new page — or a new View's beads.html — is registered by
// existing as a file, and the loader a registration used to have to remember is
// gone with it. A page that defines no "content" block, and a missing layout,
// are startup errors: the first would otherwise serve the chrome around a hole
// under a 200.
//
// The error page is not ours: pages ships one (registered as pages.ErrorPage)
// and parses its "srht-error" body into every set, so the 404 and 403 templates
// this service used to carry are gone rather than reworded.
func loadPages() (pages.Set, error) {
icons, err := loadIcons()
if err != nil {
return nil, err
}
return pages.Load(templateFS, pages.Options{Funcs: templateFuncs(icons)})
}
// pageName maps a template file name ("beads.html", what a View declares) to
// the name pages registers it under ("beads", what Render takes).
func pageName(file string) string {
return strings.TrimSuffix(file, ".html")
}
// loadIcons reads every embedded icon SVG into a name→markup map for the icon
// template func.
func loadIcons() (map[string]template.HTML, error) {
entries, err := fs.ReadDir(templateFS, "templates/icons")
if err != nil {
return nil, fmt.Errorf("web: read icons dir: %w", err)
}
icons := make(map[string]template.HTML, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".svg") {
continue
}
data, err := templateFS.ReadFile("templates/icons/" + e.Name())
if err != nil {
return nil, fmt.Errorf("web: read icon %s: %w", e.Name(), err)
}
name := strings.TrimSuffix(e.Name(), ".svg")
icons[name] = template.HTML(fmt.Sprintf(
`%s`, name, data))
}
return icons, nil
}
// templateFuncs is this service's own funcmap. pages merges it over
// chrome.Funcs — "dict", "shortsha", "reltime" and "abstime", which the shared
// partials and half this family's pages were written against — so only the
// helpers nobody else has are listed here. The local copies of the relative and
// absolute time formatters are gone with the rest; chrome's reltime also faces
// forward ("in 3 weeks"), where ours called every future instant "just now".
//
// "ago" is the one time helper that came back, and deliberately under its own
// name rather than as a shadow of reltime: the freshness line needs a
// past-facing phrase and a clock it can be tested against, and the listings
// that want chrome's forward-facing reltime keep it unchanged.
func templateFuncs(icons map[string]template.HTML) template.FuncMap {
m := template.FuncMap{}
// icon renders a named inline SVG (from templates/icons). An unknown name
// yields empty output rather than a hard error, so a missing icon never
// crashes a page.
m["icon"] = func(name string) template.HTML { return icons[name] }
// humansize renders a byte count as a human-readable size.
m["humansize"] = humanizeSize
// "upper" and "lower" used to be here for the environment banner and the
// database listing's visibility label. Both are the shared chrome's markup
// now, and it does its own casing, so nothing in this service's templates
// calls them any more.
//
// inc/dec support 1-based page arithmetic in pagination links.
m["inc"] = func(n int) int { return n + 1 }
m["dec"] = func(n int) int { return n - 1 }
// doltHost derives the host:port a `dolt login --auth-endpoint` expects
// from our origin URL (defaulting to :443 for https).
m["doltHost"] = doltHost
// withQuery rebuilds a request's query with one key replaced, for a link
// that switches one dimension of a page — the beads board/stream toggle —
// without re-listing the filters that are already set.
m["withQuery"] = withQuery
// ago is the freshness line's relative time: past-facing, coarse, and never
// negative. See the func for why it is not chrome's reltime.
m["ago"] = ago
// agoDays is ago with the ladder stopped at days, for the Memory view.
m["agoDays"] = agoDays
return m
}
// timeNow is the clock ago reads. It is a package variable so a test can pin it;
// production never assigns it. A relative time built on a hidden time.Now is
// untestable by construction, which is how a formatter's boundaries end up
// asserted only by eye.
var timeNow = time.Now
// ago renders how long ago t was, coarsely: "just now", "4 minutes ago",
// "3 hours ago", "2 days ago", "2 months ago", "1 year ago". The question it
// answers is "is this page stale", not "how long exactly" — the exact stamp
// belongs in the title attribute beside it (abstime).
//
// A future t — clock skew between whoever committed and this host — is "just
// now" rather than "in 3 minutes" or, worse, a negated count. The freshness
// line says how old the data is, and data cannot be younger than now; a
// forward-facing phrase there would read as a claim about a scheduled event.
// That is also why this is not chrome's reltime, which deliberately faces
// forward for the deadlines other services render.
//
// Units follow chrome's ladder (minute → hour → day → month → year, months of
// 30 days and years of 365), so the two spellings on one page cannot disagree
// about which unit a duration falls into.
func ago(t time.Time) string {
d := timeNow().Sub(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return plural(int(d/time.Minute), "minute") + " ago"
case d < 24*time.Hour:
return plural(int(d/time.Hour), "hour") + " ago"
case d < 30*24*time.Hour:
return plural(int(d/(24*time.Hour)), "day") + " ago"
case d < 365*24*time.Hour:
return plural(int(d/(30*24*time.Hour)), "month") + " ago"
default:
return plural(int(d/(365*24*time.Hour)), "year") + " ago"
}
}
// agoDays is ago with the unit ladder stopped at days: "just now", "4 minutes
// ago", "3 hours ago", "71 days ago" — never "2 months ago".
//
// It exists because the Memory view asks a different question of the same
// duration. There, the number is what a reader judges a memory by ("is this note
// about a service that has since been rewritten?"), and the informative unit for
// that is days: "2 months ago" and "71 days ago" are the same instant, and only
// the second one can be compared against the 60-day mark the page marks memories
// at. ago itself is left alone — the freshness line shares it, and a line about
// how fresh a page is wants the coarse spelling.
//
// It reads the same swappable clock, so it is testable to the boundary rather
// than by eye, and it is past-facing for the same reason ago is.
func agoDays(t time.Time) string {
d := timeNow().Sub(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return plural(int(d/time.Minute), "minute") + " ago"
case d < 24*time.Hour:
return plural(int(d/time.Hour), "hour") + " ago"
default:
return plural(int(d/(24*time.Hour)), "day") + " ago"
}
}
// plural names a count in a unit, singular at one.
func plural(n int, unit string) string {
if n == 1 {
return "1 " + unit
}
return fmt.Sprintf("%d %ss", n, unit)
}
// doltHost renders the host:port for `dolt login --auth-endpoint` from an origin
// URL. It appends the default TLS/plain port when the origin omits one.
func doltHost(origin string) string {
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
return origin
}
if u.Port() != "" {
return u.Host
}
if u.Scheme == "http" {
return u.Host + ":80"
}
return u.Host + ":443"
}
// withQuery renders q with key set to value — or removed, when value is empty —
// as a query string ready to append to a path: it carries its own leading "?"
// and is empty when nothing is left. q itself is not modified; it is the live
// request's query, and a template func that mutated it would change the page
// rendering it.
//
// The point is that everything else in q survives. A link that spelled out the
// keys it knows about would quietly drop ?ref= and any filter added later,
// which is how "switch to the stream" turns into "switch to the stream of
// something else".
func withQuery(q url.Values, key, value string) string {
next := make(url.Values, len(q)+1)
for k, vs := range q {
next[k] = append([]string(nil), vs...)
}
if value == "" {
next.Del(key)
} else {
next.Set(key, value)
}
enc := next.Encode()
if enc == "" {
return ""
}
return "?" + enc
}
// humanizeSize renders a byte count with binary (1024) units.
func humanizeSize(n uint64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := uint64(unit), 0
for m := n / unit; m >= unit; m /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}
// render is pages.Set.Render with this service's log line on the end.
//
// It is the whole of what is left of the old renderer, and the deleted half is
// the point: the previous one wrote "template render error: "+err.Error() into
// the response body, publishing template names, field paths and whatever the
// payload's String method produced to whoever asked for the page. pages answers
// a fixed sentence and hands the error back for the log, which is where a
// broken template belongs.
//
// A returned error means the response is already answered; there is nothing to
// do with it here but say so.
func (a *app) render(w http.ResponseWriter, status int, page string, data any) {
if err := a.pages.Render(w, status, page, data); err != nil {
slog.Error("rendering a page failed",
"component", "web", "page", page, "status", status, scribe.Err(err))
}
}