coverage~bigbes/sr-ht-doltede3b0bbweb/templates.go

Coverage
80.3% 61/76 statements
Δ
Blob
73521c9
1 package web
2
3 import (
4 "embed"
5 "fmt"
6 "html/template"
7 "io/fs"
8 "log/slog"
9 "net/http"
10 "net/url"
11 "strings"
12 "time"
13
14 "go.bigb.es/auxilia/scribe"
15
16 "sourcecraft.dev/bigbes/sr-ht-ecore/pages"
17 )
18
19 //go:embed templates/*.html templates/icons/*.svg
20 var templateFS embed.FS
21
22 // loadPages parses one template set per page in templates/, through
23 // sr-ht-ecore's pages: the layout, the shared chrome partials, our own
24 // _partials.html and that page's content.
25 //
26 // There is no list of pages here any more. Pages are discovered from the
27 // directory, so a new page — or a new View's beads.html — is registered by
28 // existing as a file, and the loader a registration used to have to remember is
29 // gone with it. A page that defines no "content" block, and a missing layout,
30 // are startup errors: the first would otherwise serve the chrome around a hole
31 // under a 200.
32 //
33 // The error page is not ours: pages ships one (registered as pages.ErrorPage)
34 // and parses its "srht-error" body into every set, so the 404 and 403 templates
35 // this service used to carry are gone rather than reworded.
36 141 func loadPages() (pages.Set, error) {
37 141 icons, err := loadIcons()
38 141 if err != nil {
39 0 return nil, err
40 0 }
41 141 return pages.Load(templateFS, pages.Options{Funcs: templateFuncs(icons)})
42 }
43
44 // pageName maps a template file name ("beads.html", what a View declares) to
45 // the name pages registers it under ("beads", what Render takes).
46 65 func pageName(file string) string {
47 65 return strings.TrimSuffix(file, ".html")
48 65 }
49
50 // loadIcons reads every embedded icon SVG into a name→markup map for the icon
51 // template func.
52 141 func loadIcons() (map[string]template.HTML, error) {
53 141 entries, err := fs.ReadDir(templateFS, "templates/icons")
54 141 if err != nil {
55 0 return nil, fmt.Errorf("web: read icons dir: %w", err)
56 0 }
57 141 icons := make(map[string]template.HTML, len(entries))
58 987 for _, e := range entries {
59 987 if e.IsDir() || !strings.HasSuffix(e.Name(), ".svg") {
60 0 continue
61 }
62 987 data, err := templateFS.ReadFile("templates/icons/" + e.Name())
63 987 if err != nil {
64 0 return nil, fmt.Errorf("web: read icon %s: %w", e.Name(), err)
65 0 }
66 987 name := strings.TrimSuffix(e.Name(), ".svg")
67 987 icons[name] = template.HTML(fmt.Sprintf(
68 987 `<span class="icon icon-%s" aria-hidden="true">%s</span>`, name, data))
69 }
70 141 return icons, nil
71 }
72
73 // templateFuncs is this service's own funcmap. pages merges it over
74 // chrome.Funcs — "dict", "shortsha", "reltime" and "abstime", which the shared
75 // partials and half this family's pages were written against — so only the
76 // helpers nobody else has are listed here. The local copies of the relative and
77 // absolute time formatters are gone with the rest; chrome's reltime also faces
78 // forward ("in 3 weeks"), where ours called every future instant "just now".
79 //
80 // "ago" is the one time helper that came back, and deliberately under its own
81 // name rather than as a shadow of reltime: the freshness line needs a
82 // past-facing phrase and a clock it can be tested against, and the listings
83 // that want chrome's forward-facing reltime keep it unchanged.
84 141 func templateFuncs(icons map[string]template.HTML) template.FuncMap {
85 141 m := template.FuncMap{}
86 141
87 141 // icon renders a named inline SVG (from templates/icons). An unknown name
88 141 // yields empty output rather than a hard error, so a missing icon never
89 141 // crashes a page.
90 141 m["icon"] = func(name string) template.HTML { return icons[name] }
91 // humansize renders a byte count as a human-readable size.
92 141 m["humansize"] = humanizeSize
93 141 // "upper" and "lower" used to be here for the environment banner and the
94 141 // database listing's visibility label. Both are the shared chrome's markup
95 141 // now, and it does its own casing, so nothing in this service's templates
96 141 // calls them any more.
97 141 //
98 141 // inc/dec support 1-based page arithmetic in pagination links.
99 141 m["inc"] = func(n int) int { return n + 1 }
100 141 m["dec"] = func(n int) int { return n - 1 }
101 // doltHost derives the host:port a `dolt login --auth-endpoint` expects
102 // from our origin URL (defaulting to :443 for https).
103 141 m["doltHost"] = doltHost
104 141 // withQuery rebuilds a request's query with one key replaced, for a link
105 141 // that switches one dimension of a page — the beads board/stream toggle —
106 141 // without re-listing the filters that are already set.
107 141 m["withQuery"] = withQuery
108 141 // ago is the freshness line's relative time: past-facing, coarse, and never
109 141 // negative. See the func for why it is not chrome's reltime.
110 141 m["ago"] = ago
111 141 // agoDays is ago with the ladder stopped at days, for the Memory view.
112 141 m["agoDays"] = agoDays
113 141
114 141 return m
115 }
116
117 // timeNow is the clock ago reads. It is a package variable so a test can pin it;
118 // production never assigns it. A relative time built on a hidden time.Now is
119 // untestable by construction, which is how a formatter's boundaries end up
120 // asserted only by eye.
121 var timeNow = time.Now
122
123 // ago renders how long ago t was, coarsely: "just now", "4 minutes ago",
124 // "3 hours ago", "2 days ago", "2 months ago", "1 year ago". The question it
125 // answers is "is this page stale", not "how long exactly" — the exact stamp
126 // belongs in the title attribute beside it (abstime).
127 //
128 // A future t — clock skew between whoever committed and this host — is "just
129 // now" rather than "in 3 minutes" or, worse, a negated count. The freshness
130 // line says how old the data is, and data cannot be younger than now; a
131 // forward-facing phrase there would read as a claim about a scheduled event.
132 // That is also why this is not chrome's reltime, which deliberately faces
133 // forward for the deadlines other services render.
134 //
135 // Units follow chrome's ladder (minute → hour → day → month → year, months of
136 // 30 days and years of 365), so the two spellings on one page cannot disagree
137 // about which unit a duration falls into.
138 123 func ago(t time.Time) string {
139 123 d := timeNow().Sub(t)
140 123 switch {
141 6 case d < time.Minute:
142 6 return "just now"
143 106 case d < time.Hour:
144 106 return plural(int(d/time.Minute), "minute") + " ago"
145 3 case d < 24*time.Hour:
146 3 return plural(int(d/time.Hour), "hour") + " ago"
147 3 case d < 30*24*time.Hour:
148 3 return plural(int(d/(24*time.Hour)), "day") + " ago"
149 3 case d < 365*24*time.Hour:
150 3 return plural(int(d/(30*24*time.Hour)), "month") + " ago"
151 2 default:
152 2 return plural(int(d/(365*24*time.Hour)), "year") + " ago"
153 }
154 }
155
156 // agoDays is ago with the unit ladder stopped at days: "just now", "4 minutes
157 // ago", "3 hours ago", "71 days ago" — never "2 months ago".
158 //
159 // It exists because the Memory view asks a different question of the same
160 // duration. There, the number is what a reader judges a memory by ("is this note
161 // about a service that has since been rewritten?"), and the informative unit for
162 // that is days: "2 months ago" and "71 days ago" are the same instant, and only
163 // the second one can be compared against the 60-day mark the page marks memories
164 // at. ago itself is left alone — the freshness line shares it, and a line about
165 // how fresh a page is wants the coarse spelling.
166 //
167 // It reads the same swappable clock, so it is testable to the boundary rather
168 // than by eye, and it is past-facing for the same reason ago is.
169 24 func agoDays(t time.Time) string {
170 24 d := timeNow().Sub(t)
171 24 switch {
172 3 case d < time.Minute:
173 3 return "just now"
174 7 case d < time.Hour:
175 7 return plural(int(d/time.Minute), "minute") + " ago"
176 4 case d < 24*time.Hour:
177 4 return plural(int(d/time.Hour), "hour") + " ago"
178 10 default:
179 10 return plural(int(d/(24*time.Hour)), "day") + " ago"
180 }
181 }
182
183 // plural names a count in a unit, singular at one.
184 138 func plural(n int, unit string) string {
185 138 if n == 1 {
186 8 return "1 " + unit
187 8 }
188 130 return fmt.Sprintf("%d %ss", n, unit)
189 }
190
191 // doltHost renders the host:port for `dolt login --auth-endpoint` from an origin
192 // URL. It appends the default TLS/plain port when the origin omits one.
193 13 func doltHost(origin string) string {
194 13 u, err := url.Parse(origin)
195 13 if err != nil || u.Host == "" {
196 0 return origin
197 0 }
198 13 if u.Port() != "" {
199 0 return u.Host
200 0 }
201 13 if u.Scheme == "http" {
202 0 return u.Host + ":80"
203 0 }
204 13 return u.Host + ":443"
205 }
206
207 // withQuery renders q with key set to value — or removed, when value is empty —
208 // as a query string ready to append to a path: it carries its own leading "?"
209 // and is empty when nothing is left. q itself is not modified; it is the live
210 // request's query, and a template func that mutated it would change the page
211 // rendering it.
212 //
213 // The point is that everything else in q survives. A link that spelled out the
214 // keys it knows about would quietly drop ?ref= and any filter added later,
215 // which is how "switch to the stream" turns into "switch to the stream of
216 // something else".
217 123 func withQuery(q url.Values, key, value string) string {
218 123 next := make(url.Values, len(q)+1)
219 123 for k, vs := range q {
220 32 next[k] = append([]string(nil), vs...)
221 32 }
222 123 if value == "" {
223 4 next.Del(key)
224 119 } else {
225 119 next.Set(key, value)
226 119 }
227 123 enc := next.Encode()
228 123 if enc == "" {
229 3 return ""
230 3 }
231 120 return "?" + enc
232 }
233
234 // humanizeSize renders a byte count with binary (1024) units.
235 0 func humanizeSize(n uint64) string {
236 0 const unit = 1024
237 0 if n < unit {
238 0 return fmt.Sprintf("%d B", n)
239 0 }
240 0 div, exp := uint64(unit), 0
241 0 for m := n / unit; m >= unit; m /= unit {
242 0 div *= unit
243 0 exp++
244 0 }
245 0 return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
246 }
247
248 // render is pages.Set.Render with this service's log line on the end.
249 //
250 // It is the whole of what is left of the old renderer, and the deleted half is
251 // the point: the previous one wrote "template render error: "+err.Error() into
252 // the response body, publishing template names, field paths and whatever the
253 // payload's String method produced to whoever asked for the page. pages answers
254 // a fixed sentence and hands the error back for the log, which is where a
255 // broken template belongs.
256 //
257 // A returned error means the response is already answered; there is nothing to
258 // do with it here but say so.
259 158 func (a *app) render(w http.ResponseWriter, status int, page string, data any) {
260 158 if err := a.pages.Render(w, status, page, data); err != nil {
261 1 slog.Error("rendering a page failed",
262 1 "component", "web", "page", page, "status", status, scribe.Err(err))
263 1 }
264 }