package web
import (
"embed"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net/http"
"net/url"
"strings"
"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(
`<span class="icon icon-%s" aria-hidden="true">%s</span>`, 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".
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
return m
}
// 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))
}
}