internal/web/web.go

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