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