package web
import (
"embed"
"fmt"
"html/template"
"io/fs"
"net/http"
"net/url"
"os"
"sort"
"strings"
"time"
"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
)
//go:embed templates/*.html templates/icons/*.svg
var templateFS embed.FS
// pageTemplates lists every content page. Each is parsed together with the
// shared layout, nav and partials into its own *template.Template, so the
// per-page {{define "content"}} blocks never collide.
var pageTemplates = []string{
"index.html",
"create.html",
"user.html",
"overview.html",
"log.html",
"commit.html",
"tree.html",
"table.html",
"settings.html",
"keys.html",
"404.html",
"403.html",
}
// sharedTemplates are parsed into every page: the outer layout and this
// service's own reusable partials (badges, tab bars). The chrome partials —
// the brand, the switcher, the login block, the environment banner, the
// listing — are NOT here: they come from sr-ht-ecore and are attached to every
// set by chrome.Attach (loadTemplates), which is the copy every custom service
// on the instance draws from.
var sharedTemplates = []string{
"templates/layout.html",
"templates/partials.html",
}
// templateSet maps a page name to its fully-parsed template (execute "layout").
type templateSet map[string]*template.Template
// loadTemplates parses every page template with the shared chrome and the
// funcmap. It fails loudly (returns an error) on any parse problem so a broken
// template surfaces at startup, never as a blank page at request time.
func loadTemplates() (templateSet, error) {
icons, err := loadIcons()
if err != nil {
return nil, err
}
funcs := templateFuncs(icons)
set := make(templateSet, len(pageTemplates))
for _, page := range pageTemplates {
t, err := chrome.Attach(template.New("layout").Funcs(funcs))
if err != nil {
return nil, fmt.Errorf("web: attach the shared chrome partials for %s: %w", page, err)
}
files := append(append([]string{}, sharedTemplates...), "templates/"+page)
if _, err := t.ParseFS(templateFS, files...); err != nil {
return nil, fmt.Errorf("web: parse template %s: %w", page, err)
}
set[page] = t
}
// Parse the page template of every registered view the same way, so a
// concrete view (e.g. the future "beads" view) becomes renderable by adding
// only its own web/beads.go + templates/beads.html — the embed.FS glob picks
// the new file up at compile time and this loop parses it, with no edit to
// this loader. The registry is fully populated by the init()s that ran before
// Register called us. A view whose Template() is already a known page (which
// a test may deliberately reuse, e.g. "tree.html") is skipped rather than
// re-parsed, so registration never clobbers a page template.
for _, v := range registeredViews {
name := v.Template()
if _, ok := set[name]; ok {
continue
}
t, err := chrome.Attach(template.New("layout").Funcs(funcs))
if err != nil {
return nil, fmt.Errorf("web: attach the shared chrome partials for %s: %w", name, err)
}
files := append(append([]string{}, sharedTemplates...), "templates/"+name)
if _, err := t.ParseFS(templateFS, files...); err != nil {
return nil, fmt.Errorf("web: parse view template %s: %w", name, err)
}
set[name] = t
}
return set, nil
}
// 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 the funcmap available in every template.
//
// It starts from chrome.Funcs — "dict" and "shortsha", which the shared
// partials and half this family's pages were written against — and adds this
// service's own on top. Adding after is deliberate: a name may then be shadowed
// on purpose rather than by accident of map ordering. Nothing here re-defines a
// shared helper; the local copies of dict and the hash abbreviator are gone.
func templateFuncs(icons map[string]template.HTML) template.FuncMap {
m := chrome.Funcs()
// 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] }
// reltime renders a humanized relative time ("3 hours ago"), no deps.
m["reltime"] = humanizeTime
// abstime renders an absolute UTC timestamp for tooltips/detail.
m["abstime"] = func(t time.Time) string { return t.UTC().Format("2006-01-02 15:04:05 UTC") }
// 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
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"
}
// humanizeTime renders t as a coarse relative time in the past. It is a small
// self-contained helper (no new dependency) covering seconds→years.
func humanizeTime(t time.Time) string {
d := time.Since(t)
if d < 0 {
return "just now"
}
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return plural(int(d/time.Minute), "minute")
case d < 24*time.Hour:
return plural(int(d/time.Hour), "hour")
case d < 30*24*time.Hour:
return plural(int(d/(24*time.Hour)), "day")
case d < 365*24*time.Hour:
return plural(int(d/(30*24*time.Hour)), "month")
default:
return plural(int(d/(365*24*time.Hour)), "year")
}
}
func plural(n int, unit string) string {
if n == 1 {
return "1 " + unit + " ago"
}
return fmt.Sprintf("%d %ss ago", n, unit)
}
// 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])
}
// discoverStyleHref returns the stylesheet href for the layout: the hashed
// production asset if one is present in staticDir (main.min.<sha>.css, served
// under /static/), else the dev fallback /static/main.css. Globbing at startup
// keeps the cache-busting filename out of the templates.
func discoverStyleHref(staticDir string) string {
const fallback = "/static/main.css"
if staticDir == "" {
return fallback
}
matches, err := fs.Glob(os.DirFS(staticDir), "main.min.*.css")
if err != nil || len(matches) == 0 {
return fallback
}
sort.Strings(matches)
return "/static/" + matches[len(matches)-1]
}
// render executes the named page template with the layout, writing an HTML
// response with the given status. A template execution error is a programming
// error (bad template or view struct); it is logged and a 500 is written, but
// never a partially-flushed page — we render into a buffer first.
func (a *app) render(w http.ResponseWriter, status int, page string, data any) {
t, ok := a.templates[page]
if !ok {
http.Error(w, "template not found", http.StatusInternalServerError)
return
}
var buf strings.Builder
if err := t.ExecuteTemplate(&buf, "layout", data); err != nil {
http.Error(w, "template render error: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, _ = w.Write([]byte(buf.String()))
}
// httpStaticHandler serves files from staticDir under the /static/ prefix. It
// is mounted by the router; in dev (empty staticDir) it 404s every asset.
func httpStaticHandler(staticDir string) http.Handler {
return http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir)))
}