~bigbes/sr-ht-dolt

ref: 1dc49e2ecffdcfd6e0503ef183771850451e5eab sr-ht-dolt/web/templates.go -rw-r--r-- 8.9 KiB
1dc49e2e — Eugene Blikh apk: ship dolt-git-hook as a -hook subpackage 11 days 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
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
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(
			`<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,
		"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.<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)))
}