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( ``, 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 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" } } // 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)) } }