internal/web/web.go

e6cd75b5f28bacf51620bb531320c30fd4e66bfd
gitbay/internal/web/web.go history · blame · raw

319 lines · 9401 bytes

15 symbols in this file
  1// Package web holds the server-rendered templates and static assets for the
  2// read-only UI. No JavaScript, no build step.
  3package web
  4
  5import (
  6	"embed"
  7	"fmt"
  8	"html/template"
  9	"io"
 10	"io/fs"
 11	"reflect"
 12	"sort"
 13	"strconv"
 14	"strings"
 15	"time"
 16)
 17
 18//go:embed templates/*.html
 19var templateFS embed.FS
 20
 21//go:embed static/style.css
 22var StyleCSS []byte
 23
 24//go:embed static/favicon.svg
 25var FaviconSVG []byte
 26
 27//go:embed static/fonts/*.woff2
 28var FontFS embed.FS
 29
 30//go:embed static/img/*.gif static/img/*.webm static/img/*.mp4
 31var ImageFS embed.FS
 32
 33// StyleVersion identifies the bytes /static/style.css serves. The httpd
 34// package sets it; the layout stamps it on the stylesheet URL so a deploy
 35// changes that URL and a browser holding a cached copy cannot miss it.
 36var StyleVersion string
 37
 38// railItem is one destination the rail's icon strip and the phone
 39// "More" menu both render — from this one list, so a destination added
 40// here reaches both instead of the two being hand-kept in step (#271).
 41type railItem struct {
 42	Href    string
 43	Icon    string
 44	Name    string
 45	Current bool
 46	Count   int64 // unused by railOptItems; present so "raillink" can read it uniformly
 47	Show    bool
 48}
 49
 50// railField and railBool read a named field off the page value the
 51// layout was given — the same reflection str/field already do for the
 52// repo header, duplicated narrowly here rather than exported, since
 53// railOptItems is their only other caller.
 54func railField(v any, name string) string {
 55	rv := reflect.ValueOf(v)
 56	for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
 57		rv = rv.Elem()
 58	}
 59	if rv.Kind() != reflect.Struct {
 60		return ""
 61	}
 62	f := rv.FieldByName(name)
 63	if !f.IsValid() || f.Kind() != reflect.String {
 64		return ""
 65	}
 66	return f.String()
 67}
 68
 69func railBool(v any, name string) bool {
 70	rv := reflect.ValueOf(v)
 71	for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
 72		rv = rv.Elem()
 73	}
 74	if rv.Kind() != reflect.Struct {
 75		return false
 76	}
 77	f := rv.FieldByName(name)
 78	return f.IsValid() && f.Kind() == reflect.Bool && f.Bool()
 79}
 80
 81// railOptItems is the rail's "New repository", "Settings", "Admin" and
 82// "Log out" destinations, in the order the rail shows them. v is the
 83// page value the layout renders (any page struct that embeds
 84// basePage), read by field name since the layout has no single common
 85// type for every page.
 86func railOptItems(v any) []railItem {
 87	tab := railField(v, "Tab")
 88	admin := railBool(v, "Admin")
 89	return []railItem{
 90		{Href: "/new", Icon: "plus", Name: "New repository", Show: true},
 91		{Href: "/settings", Icon: "gear", Name: "Settings", Current: tab == "account", Show: true},
 92		{Href: "/admin", Icon: "shield", Name: "Admin", Current: tab == "admin", Show: admin},
 93		{Href: "/logout", Icon: "signout", Name: "Log out", Show: true},
 94	}
 95}
 96
 97var funcs = template.FuncMap{
 98	"styleVersion": func() string { return StyleVersion },
 99	"join":         strings.Join,
100	// paragraphs splits plain text on blank lines for safe rich display.
101	"paragraphs": func(s string) []string {
102		var out []string
103		for _, p := range strings.Split(s, "\n\n") {
104			if p = strings.TrimSpace(p); p != "" {
105				out = append(out, p)
106			}
107		}
108		return out
109	},
110	// short abbreviates a commit SHA for display.
111	"short": func(s string) string {
112		if len(s) > 10 {
113			return s[:10]
114		}
115		return s
116	},
117	// dict builds a map for {{template}} calls that need several values.
118	"dict": func(pairs ...any) map[string]any {
119		m := map[string]any{}
120		for i := 0; i+1 < len(pairs); i += 2 {
121			if k, ok := pairs[i].(string); ok {
122				m[k] = pairs[i+1]
123			}
124		}
125		return m
126	},
127	"add": func(a, b int) int { return a + b },
128	"sub": func(a, b int) int { return a - b },
129	// topTab maps a page's Tab to the repo header tab that should read as
130	// current. Everything that browses the tree or its history sits under
131	// code, whose own toolbar carries the log, search, and refs links.
132	"topTab": func(tab string) string {
133		switch tab {
134		case "files", "log", "refs", "search":
135			return "code"
136		case "issues":
137			return "issues"
138		case "merge requests":
139			return "mrs"
140		}
141		return tab
142	},
143	// initial is the avatar letter for a username.
144	"initial": func(s string) string {
145		for _, r := range s {
146			return strings.ToUpper(string(r))
147		}
148		return "?"
149	},
150	"railOptItems": railOptItems,
151	// str is field, narrowed to strings: missing or non-string fields
152	// yield "", which comparisons handle without erroring.
153	"str": func(v any, name string) string {
154		rv := reflect.ValueOf(v)
155		for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
156			rv = rv.Elem()
157		}
158		if rv.Kind() != reflect.Struct {
159			return ""
160		}
161		f := rv.FieldByName(name)
162		if !f.IsValid() || f.Kind() != reflect.String {
163			return ""
164		}
165		return f.String()
166	},
167	// field reads a field the page struct may not have, so the layout can
168	// render repo navigation without every page carrying repo context.
169	// Missing fields yield nil, which templates treat as absent.
170	"field": func(v any, name string) any {
171		rv := reflect.ValueOf(v)
172		for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
173			rv = rv.Elem()
174		}
175		if rv.Kind() != reflect.Struct {
176			return nil
177		}
178		f := rv.FieldByName(name)
179		if !f.IsValid() || !f.CanInterface() {
180			return nil
181		}
182		return f.Interface()
183	},
184	// sigLabel turns a stored signature state into words. The state names
185	// are the CLI's vocabulary and belong in its output; a reader of the
186	// web UI needs to know what is wrong, not what the column is called.
187	"sigLabel": func(state string) string {
188		switch state {
189		case "verified":
190			return "Verified"
191		case "signed_unknown_key":
192			return "Unregistered key"
193		case "signed_email_mismatch":
194			return "Email mismatch"
195		case "signed_key_expired":
196			return "Key expired"
197		case "signed_key_revoked":
198			return "Key revoked"
199		case "bad_signature":
200			return "Bad signature"
201		case "unsigned":
202			return "Unsigned"
203		}
204		return state
205	},
206	// ago renders a time as a coarse relative age, which is what a
207	// listing column is actually read for. The zero time yields "".
208	// slug turns a language name into a CSS class suffix, so the palette
209	// lives in the stylesheet rather than in inline styles the CSP would
210	// have to allow.
211	"slug": func(s string) string {
212		var b strings.Builder
213		for _, r := range strings.ToLower(s) {
214			switch {
215			case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
216				b.WriteRune(r)
217			default:
218				b.WriteByte('-')
219			}
220		}
221		return b.String()
222	},
223	// pct renders a share to one decimal, dropping a trailing ".0".
224	"pct": func(f float64) string {
225		s := strconv.FormatFloat(f, 'f', 1, 64)
226		return strings.TrimSuffix(s, ".0")
227	},
228	"ago": func(t time.Time) string {
229		if t.IsZero() {
230			return ""
231		}
232		d := time.Since(t)
233		if d < 0 {
234			d = 0
235		}
236		plural := func(n int, unit string) string {
237			if n == 1 {
238				return "1 " + unit + " ago"
239			}
240			return strconv.Itoa(n) + " " + unit + "s ago"
241		}
242		switch {
243		case d < time.Minute:
244			return "just now"
245		case d < time.Hour:
246			return plural(int(d/time.Minute), "minute")
247		case d < 24*time.Hour:
248			return plural(int(d/time.Hour), "hour")
249		case d < 30*24*time.Hour:
250			return plural(int(d/(24*time.Hour)), "day")
251		case d < 365*24*time.Hour:
252			return plural(int(d/(30*24*time.Hour)), "month")
253		}
254		return plural(int(d/(365*24*time.Hour)), "year")
255	},
256	// when formats a stored RFC3339 timestamp for display; unparseable
257	// values pass through unchanged.
258	"when": func(s string) string {
259		t, err := time.Parse(time.RFC3339Nano, s)
260		if err != nil {
261			return s
262		}
263		return t.UTC().Format("2006-01-02 15:04 UTC")
264	},
265	// whenT is when for a value that is already a time.Time rather than
266	// a stored string.
267	"whenT": func(t time.Time) string {
268		return t.UTC().Format("2006-01-02 15:04 UTC")
269	},
270}
271
272// pages holds every page template parsed once with the layout, at
273// start-up: a template that does not parse fails the process before it
274// serves anything, rather than the first visit to a rarely-hit page, and
275// a request no longer re-parses the layout (#116).
276var pages = func() map[string]*template.Template {
277	entries, err := fs.ReadDir(templateFS, "templates")
278	if err != nil {
279		panic(err)
280	}
281	m := map[string]*template.Template{}
282	for _, e := range entries {
283		name := e.Name()
284		if name == "layout.html" || !strings.HasSuffix(name, ".html") {
285			continue
286		}
287		layout := template.Must(template.New("layout.html").Funcs(funcs).ParseFS(templateFS, "templates/layout.html"))
288		m[name] = template.Must(layout.ParseFS(templateFS, "templates/"+name))
289	}
290	return m
291}()
292
293// TemplateSource returns a template's raw text, for tests that inspect the
294// markup rather than the rendered output — an accessibility rule about
295// labels and ids is a property of the source, and holds whatever data a
296// page is given.
297func TemplateSource(name string) (string, error) {
298	b, err := templateFS.ReadFile("templates/" + name)
299	return string(b), err
300}
301
302// Pages lists the page template names, for tests that render each one.
303func Pages() []string {
304	names := make([]string, 0, len(pages))
305	for name := range pages {
306		names = append(names, name)
307	}
308	sort.Strings(names)
309	return names
310}
311
312// Render executes the named page template with the shared layout.
313func Render(w io.Writer, page string, data any) error {
314	t, ok := pages[page]
315	if !ok {
316		return fmt.Errorf("no page template %q", page)
317	}
318	return t.ExecuteTemplate(w, "layout", data)
319}