internal/web/web.go
283 lines · 7772 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 UTC")
228 },
229 // whenT is when for a value that is already a time.Time rather than
230 // a stored string.
231 "whenT": func(t time.Time) string {
232 return t.UTC().Format("2006-01-02 15:04 UTC")
233 },
234}
235
236// pages holds every page template parsed once with the layout, at
237// start-up: a template that does not parse fails the process before it
238// serves anything, rather than the first visit to a rarely-hit page, and
239// a request no longer re-parses the layout (#116).
240var pages = func() map[string]*template.Template {
241 entries, err := fs.ReadDir(templateFS, "templates")
242 if err != nil {
243 panic(err)
244 }
245 m := map[string]*template.Template{}
246 for _, e := range entries {
247 name := e.Name()
248 if name == "layout.html" || !strings.HasSuffix(name, ".html") {
249 continue
250 }
251 layout := template.Must(template.New("layout.html").Funcs(funcs).ParseFS(templateFS, "templates/layout.html"))
252 m[name] = template.Must(layout.ParseFS(templateFS, "templates/"+name))
253 }
254 return m
255}()
256
257// TemplateSource returns a template's raw text, for tests that inspect the
258// markup rather than the rendered output — an accessibility rule about
259// labels and ids is a property of the source, and holds whatever data a
260// page is given.
261func TemplateSource(name string) (string, error) {
262 b, err := templateFS.ReadFile("templates/" + name)
263 return string(b), err
264}
265
266// Pages lists the page template names, for tests that render each one.
267func Pages() []string {
268 names := make([]string, 0, len(pages))
269 for name := range pages {
270 names = append(names, name)
271 }
272 sort.Strings(names)
273 return names
274}
275
276// Render executes the named page template with the shared layout.
277func Render(w io.Writer, page string, data any) error {
278 t, ok := pages[page]
279 if !ok {
280 return fmt.Errorf("no page template %q", page)
281 }
282 return t.ExecuteTemplate(w, "layout", data)
283}