internal/web/web.go

419f6dfdc5489a0c6374e36dd1ebbfca68040056
gitbay/internal/web/web.go history · blame · raw

293 lines · 8156 bytes

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