~bigbes/sr-ht-dolt

ref: b4d6a3e641dc1e4417b69149ee707d8bd183465e sr-ht-dolt/web/templates.go -rw-r--r-- 7.0 KiB
b4d6a3e6 — Eugene Blikh docs: add design spec a month ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
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
	}
	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.
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,
		// 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,
	}
}

// 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.<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)))
}