package web import ( "embed" "fmt" "html/template" "io/fs" "net/http" "net/url" "os" "sort" "strings" "time" ) //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, the nav // fragment, and reusable partials (badges, pagination, etc.). var sharedTemplates = []string{ "templates/layout.html", "templates/nav.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 := template.New("layout").Funcs(funcs) 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 := template.New("layout").Funcs(funcs) 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( ``, name, data)) } return icons, nil } // templateFuncs is the funcmap available in every template. func templateFuncs(icons map[string]template.HTML) template.FuncMap { return 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. "icon": func(name string) template.HTML { return icons[name] }, // shorthash abbreviates a dolt/NBS hash to its first 8 characters, the // convention used everywhere commits are listed. "shorthash": shortHash, // reltime renders a humanized relative time ("3 hours ago"), no deps. "reltime": humanizeTime, // abstime renders an absolute UTC timestamp for tooltips/detail. "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. "humansize": humanizeSize, "upper": strings.ToUpper, "lower": strings.ToLower, // inc/dec support 1-based page arithmetic in pagination links. "inc": func(n int) int { return n + 1 }, "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). "doltHost": doltHost, // dict builds a map from alternating key/value args, so a partial that // needs several fields (e.g. the "viewtabs" tab bar) can be invoked with // an inline context: {{template "viewtabs" (dict "Repo" .Repo ...)}}. "dict": dict, } } // dict builds a map[string]any from alternating key/value arguments. It powers // multi-field partial invocations from templates, which otherwise can pass only // a single pipeline value. An odd argument count or a non-string key is a // template authoring error and surfaces as a render error. func dict(kv ...any) (map[string]any, error) { if len(kv)%2 != 0 { return nil, fmt.Errorf("dict: expected an even number of arguments, got %d", len(kv)) } m := make(map[string]any, len(kv)/2) for i := 0; i < len(kv); i += 2 { k, ok := kv[i].(string) if !ok { return nil, fmt.Errorf("dict: key %d is not a string", i) } m[k] = kv[i+1] } return m, nil } // 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" } // shortHash returns the first 8 characters of h (or h itself if shorter). func shortHash(h string) string { if len(h) <= 8 { return h } return h[:8] } // 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..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))) }