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