repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

6.2 KB · 275 lines · Go Raw History
  1package main
  2
  3import (
  4	"encoding/json"
  5	"fmt"
  6	"html/template"
  7	"math"
  8	"strconv"
  9	"strings"
 10	"time"
 11)
 12
 13var templateFuncs = template.FuncMap{
 14	"dict":            dict,
 15	"json":            jsonBlock,
 16	"naturaltime":     naturalTime,
 17	"num":             formatNum,
 18	"msSavings":       msSavings,
 19	"urlPath":         urlPath,
 20	"pct":             pct,
 21	"pct1":            pct1,
 22	"metricClass":     metricClass,
 23	"scoreClass":      scoreClass,
 24	"uptimeClass":     uptimeClass,
 25	"lighthouseClass": lighthouseClass,
 26	"countClass":      countClass,
 27	"seq":             seq,
 28	"add":             func(a, b int) int { return a + b },
 29	"upper":           strings.ToUpper,
 30	"hasPrefix":       strings.HasPrefix,
 31}
 32
 33// dict lets a partial take more than one value; a template has a single dot.
 34func dict(pairs ...any) (map[string]any, error) {
 35	if len(pairs)%2 != 0 {
 36		return nil, fmt.Errorf("dict: odd number of arguments (%d)", len(pairs))
 37	}
 38	out := make(map[string]any, len(pairs)/2)
 39	for i := 0; i < len(pairs); i += 2 {
 40		key, ok := pairs[i].(string)
 41		if !ok {
 42			return nil, fmt.Errorf("dict: key %d is %T, want string", i, pairs[i])
 43		}
 44		out[key] = pairs[i+1]
 45	}
 46	return out, nil
 47}
 48
 49// jsonBlock renders a value for a <script type="application/json"> block.
 50// HTML escaping stays on so a value containing "</script>" cannot close the
 51// block. html/template cannot work that out inside a non-JavaScript script
 52// type, and template.JS turns its escaping off anyway, so this is the only
 53// thing standing between a stored string and the end of the element.
 54func jsonBlock(v any) (template.JS, error) {
 55	buf, err := json.Marshal(v)
 56	if err != nil {
 57		return "", err
 58	}
 59	return template.JS(buf), nil
 60}
 61
 62// naturalTime renders a timestamp as a relative phrase. A nil time is "never",
 63// which reads differently from a field that failed to load.
 64func naturalTime(t *time.Time) string {
 65	if t == nil {
 66		return "never"
 67	}
 68
 69	d := time.Since(*t)
 70	// Future timestamps are normal here: next_run_at is one.
 71	suffix := "ago"
 72	if d < 0 {
 73		d = -d
 74		suffix = "from now"
 75	}
 76
 77	// Rounded, not truncated: 47h59m59.9s truncates to "1 day from now".
 78	switch secs := int64(math.Round(d.Seconds())); {
 79	case secs < 60:
 80		if suffix == "ago" {
 81			return "just now"
 82		}
 83		return "in a moment"
 84	case secs < 3600:
 85		return plural(secs/60, "minute", suffix)
 86	case secs < 86_400:
 87		return plural(secs/3600, "hour", suffix)
 88	case secs < 86_400*30:
 89		return plural(secs/86_400, "day", suffix)
 90	case secs < 86_400*365:
 91		return plural(secs/(86_400*30), "month", suffix)
 92	default:
 93		return plural(secs/(86_400*365), "year", suffix)
 94	}
 95}
 96
 97func plural(n int64, unit, suffix string) string {
 98	if n == 1 {
 99		return fmt.Sprintf("1 %s %s", unit, suffix)
100	}
101	return fmt.Sprintf("%d %ss %s", n, unit, suffix)
102}
103
104// formatNum groups thousands with commas.
105func formatNum(v any) string {
106	var n int64
107	switch t := v.(type) {
108	case int:
109		n = int64(t)
110	case int64:
111		n = t
112	case *int64:
113		if t == nil {
114			return "0"
115		}
116		n = *t
117	case float64:
118		n = int64(t)
119	default:
120		return fmt.Sprint(v)
121	}
122
123	s := strconv.FormatInt(n, 10)
124	negative := strings.HasPrefix(s, "-")
125	s = strings.TrimPrefix(s, "-")
126
127	var b strings.Builder
128	for i, digit := range s {
129		if i > 0 && (len(s)-i)%3 == 0 {
130			b.WriteByte(',')
131		}
132		b.WriteRune(digit)
133	}
134	if negative {
135		return "-" + b.String()
136	}
137	return b.String()
138}
139
140// msSavings renders a Lighthouse saving as "1.2 s" or "420 ms". Zero renders as
141// the empty string, so the template can show a placeholder instead.
142func msSavings(v any) string {
143	var ms float64
144	switch t := v.(type) {
145	case float64:
146		ms = t
147	case int64:
148		ms = float64(t)
149	case int:
150		ms = float64(t)
151	default:
152		return ""
153	}
154
155	switch {
156	case ms <= 0:
157		return ""
158	case ms >= 1000:
159		return fmt.Sprintf("%.1f s", ms/1000)
160	default:
161		return fmt.Sprintf("%.0f ms", ms)
162	}
163}
164
165// urlPath reduces an absolute URL to its path and query.
166func urlPath(raw string) string {
167	u, err := parseHTTPURL(raw)
168	if err != nil {
169		return raw
170	}
171	path := u.EscapedPath()
172	if path == "" {
173		path = "/"
174	}
175	if u.RawQuery != "" {
176		path += "?" + u.RawQuery
177	}
178	return path
179}
180
181// pct renders count as a whole-number percentage of total.
182func pct(count, total int64) int64 {
183	if total <= 0 {
184		return 0
185	}
186	return count * 100 / total
187}
188
189// pct1 renders a nullable percentage with one decimal, keeping the column aligned.
190func pct1(v *float64) string {
191	if v == nil {
192		return "—"
193	}
194	return strconv.FormatFloat(*v, 'f', 1, 64)
195}
196
197// seq is a counted loop, which templates otherwise cannot express: range needs
198// something to range over.
199func seq(n int) []struct{} { return make([]struct{}, n) }
200
201// uptimeClass bands a recent-uptime percentage; higher is better. It takes a
202// pointer because the value is nullable and a template cannot rebind the dot
203// inside an {{if}}.
204func uptimeClass(pct *float64) string {
205	if pct == nil {
206		return "muted"
207	}
208	switch {
209	case *pct >= 99:
210		return "ok"
211	case *pct < 95:
212		return "down"
213	default:
214		return "warn"
215	}
216}
217
218// lighthouseClass bands the average of the four Lighthouse scores. 90 and 80,
219// not Lighthouse's 90 and 50, because an average of 60 across four categories is
220// worse than 60 in one; scoreClass keeps Lighthouse's bands.
221func lighthouseClass(score *int64) string {
222	if score == nil {
223		return "muted"
224	}
225	switch {
226	case *score >= 90:
227		return "ok"
228	case *score >= 80:
229		return "warn"
230	default:
231		return "down"
232	}
233}
234
235// countClass bands a finding count. Lower is better, so the comparisons run the
236// opposite way to uptimeClass.
237func countClass(n, warnAbove, downAbove int) string {
238	switch {
239	case n > downAbove:
240		return "down"
241	case n > warnAbove:
242		return "warn"
243	default:
244		return "ok"
245	}
246}
247
248// metricClass bands one weighted metric's 0-to-1 score. A nil score means
249// Lighthouse could not measure it, which is not the same as measuring it bad.
250func metricClass(score *float64) string {
251	switch {
252	case score == nil:
253		return "muted"
254	case *score >= 0.9:
255		return "green"
256	case *score >= 0.5:
257		return "amber"
258	default:
259		return "danger"
260	}
261}
262
263// scoreClass maps a Lighthouse score to Lighthouse's own three bands, so the
264// dashboard colours match the tool the numbers came from.
265func scoreClass(score int64) string {
266	switch {
267	case score >= 90:
268		return "good"
269	case score >= 50:
270		return "average"
271	default:
272		return "poor"
273	}
274}