internal/web/web.go
278 lines · 7588 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// TemplateSource returns a template's raw text, for tests that inspect the
253// markup rather than the rendered output — an accessibility rule about
254// labels and ids is a property of the source, and holds whatever data a
255// page is given.
256func TemplateSource(name string) (string, error) {
257 b, err := templateFS.ReadFile("templates/" + name)
258 return string(b), err
259}
260
261// Pages lists the page template names, for tests that render each one.
262func Pages() []string {
263 names := make([]string, 0, len(pages))
264 for name := range pages {
265 names = append(names, name)
266 }
267 sort.Strings(names)
268 return names
269}
270
271// Render executes the named page template with the shared layout.
272func Render(w io.Writer, page string, data any) error {
273 t, ok := pages[page]
274 if !ok {
275 return fmt.Errorf("no page template %q", page)
276 }
277 return t.ExecuteTemplate(w, "layout", data)
278}