~bigbes/sr-ht-dolt

ref: 4038ec2b4469113613ecf777e40a097bd8f09da2 sr-ht-dolt/web/templates.go -rw-r--r-- 9.9 KiB
4038ec2b — Eugene Blikh mcpsrv: answer what is ready across every tracker 5 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
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(
			`<span class="icon icon-%s" aria-hidden="true">%s</span>`, 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
	// agoDays is ago with the ladder stopped at days, for the Memory view.
	m["agoDays"] = agoDays

	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"
	}
}

// agoDays is ago with the unit ladder stopped at days: "just now", "4 minutes
// ago", "3 hours ago", "71 days ago" — never "2 months ago".
//
// It exists because the Memory view asks a different question of the same
// duration. There, the number is what a reader judges a memory by ("is this note
// about a service that has since been rewritten?"), and the informative unit for
// that is days: "2 months ago" and "71 days ago" are the same instant, and only
// the second one can be compared against the 60-day mark the page marks memories
// at. ago itself is left alone — the freshness line shares it, and a line about
// how fresh a page is wants the coarse spelling.
//
// It reads the same swappable clock, so it is testable to the boundary rather
// than by eye, and it is past-facing for the same reason ago is.
func agoDays(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"
	default:
		return plural(int(d/(24*time.Hour)), "day") + " 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))
	}
}