internal/web/web.go

185f434bf23123c61a922129270c2db4cfebd493
gitbay/internal/web/web.go history · blame · raw

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