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}