internal/web/web.go

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

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