internal/web/web.go
237 lines · 6292 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 "html/template"
8 "io"
9 "reflect"
10 "runtime/debug"
11 "strconv"
12 "strings"
13 "sync"
14 "time"
15)
16
17//go:embed templates/*.html
18var templateFS embed.FS
19
20//go:embed static/style.css
21var StyleCSS []byte
22
23//go:embed static/favicon.svg
24var FaviconSVG []byte
25
26//go:embed static/fonts/*.woff2
27var FontFS embed.FS
28
29// version returns the short VCS revision baked into the binary, or "" when
30// built outside a checkout. Used by the layout footer.
31var version = sync.OnceValue(func() string {
32 info, ok := debug.ReadBuildInfo()
33 if !ok {
34 return ""
35 }
36 for _, s := range info.Settings {
37 if s.Key == "vcs.revision" && len(s.Value) >= 10 {
38 return s.Value[:10]
39 }
40 }
41 return ""
42})
43
44// fullVersion is the complete VCS revision, for linking the footer hash
45// to the upstream commit page.
46var fullVersion = sync.OnceValue(func() string {
47 info, ok := debug.ReadBuildInfo()
48 if !ok {
49 return ""
50 }
51 for _, s := range info.Settings {
52 if s.Key == "vcs.revision" {
53 return s.Value
54 }
55 }
56 return ""
57})
58
59var funcs = template.FuncMap{
60 "gitbayVersion": func() string { return version() },
61 "gitbayCommit": func() string { return fullVersion() },
62 // paragraphs splits plain text on blank lines for safe rich display.
63 "paragraphs": func(s string) []string {
64 var out []string
65 for _, p := range strings.Split(s, "\n\n") {
66 if p = strings.TrimSpace(p); p != "" {
67 out = append(out, p)
68 }
69 }
70 return out
71 },
72 // short abbreviates a commit SHA for display.
73 "short": func(s string) string {
74 if len(s) > 10 {
75 return s[:10]
76 }
77 return s
78 },
79 // dict builds a map for {{template}} calls that need several values.
80 "dict": func(pairs ...any) map[string]any {
81 m := map[string]any{}
82 for i := 0; i+1 < len(pairs); i += 2 {
83 if k, ok := pairs[i].(string); ok {
84 m[k] = pairs[i+1]
85 }
86 }
87 return m
88 },
89 "add": func(a, b int) int { return a + b },
90 "sub": func(a, b int) int { return a - b },
91 // topTab maps a page's Tab to the repo header tab that should read as
92 // current. Everything that browses the tree or its history sits under
93 // code, whose own toolbar carries the log, search, and refs links.
94 "topTab": func(tab string) string {
95 switch tab {
96 case "files", "log", "refs", "search":
97 return "code"
98 case "issues":
99 return "issues"
100 case "merge requests":
101 return "mrs"
102 }
103 return tab
104 },
105 // initial is the avatar letter for a username.
106 "initial": func(s string) string {
107 for _, r := range s {
108 return strings.ToUpper(string(r))
109 }
110 return "?"
111 },
112 // str is field, narrowed to strings: missing or non-string fields
113 // yield "", which comparisons handle without erroring.
114 "str": func(v any, name string) string {
115 rv := reflect.ValueOf(v)
116 for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
117 rv = rv.Elem()
118 }
119 if rv.Kind() != reflect.Struct {
120 return ""
121 }
122 f := rv.FieldByName(name)
123 if !f.IsValid() || f.Kind() != reflect.String {
124 return ""
125 }
126 return f.String()
127 },
128 // field reads a field the page struct may not have, so the layout can
129 // render repo navigation without every page carrying repo context.
130 // Missing fields yield nil, which templates treat as absent.
131 "field": func(v any, name string) any {
132 rv := reflect.ValueOf(v)
133 for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
134 rv = rv.Elem()
135 }
136 if rv.Kind() != reflect.Struct {
137 return nil
138 }
139 f := rv.FieldByName(name)
140 if !f.IsValid() || !f.CanInterface() {
141 return nil
142 }
143 return f.Interface()
144 },
145 // sigLabel turns a stored signature state into words. The state names
146 // are the CLI's vocabulary and belong in its output; a reader of the
147 // web UI needs to know what is wrong, not what the column is called.
148 "sigLabel": func(state string) string {
149 switch state {
150 case "verified":
151 return "Verified"
152 case "signed_unknown_key":
153 return "Unregistered key"
154 case "signed_email_mismatch":
155 return "Email mismatch"
156 case "signed_key_expired":
157 return "Key expired"
158 case "signed_key_revoked":
159 return "Key revoked"
160 case "bad_signature":
161 return "Bad signature"
162 case "unsigned":
163 return "Unsigned"
164 }
165 return state
166 },
167 // ago renders a time as a coarse relative age, which is what a
168 // listing column is actually read for. The zero time yields "".
169 // slug turns a language name into a CSS class suffix, so the palette
170 // lives in the stylesheet rather than in inline styles the CSP would
171 // have to allow.
172 "slug": func(s string) string {
173 var b strings.Builder
174 for _, r := range strings.ToLower(s) {
175 switch {
176 case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
177 b.WriteRune(r)
178 default:
179 b.WriteByte('-')
180 }
181 }
182 return b.String()
183 },
184 // pct renders a share to one decimal, dropping a trailing ".0".
185 "pct": func(f float64) string {
186 s := strconv.FormatFloat(f, 'f', 1, 64)
187 return strings.TrimSuffix(s, ".0")
188 },
189 "ago": func(t time.Time) string {
190 if t.IsZero() {
191 return ""
192 }
193 d := time.Since(t)
194 if d < 0 {
195 d = 0
196 }
197 plural := func(n int, unit string) string {
198 if n == 1 {
199 return "1 " + unit + " ago"
200 }
201 return strconv.Itoa(n) + " " + unit + "s ago"
202 }
203 switch {
204 case d < time.Minute:
205 return "just now"
206 case d < time.Hour:
207 return plural(int(d/time.Minute), "minute")
208 case d < 24*time.Hour:
209 return plural(int(d/time.Hour), "hour")
210 case d < 30*24*time.Hour:
211 return plural(int(d/(24*time.Hour)), "day")
212 case d < 365*24*time.Hour:
213 return plural(int(d/(30*24*time.Hour)), "month")
214 }
215 return plural(int(d/(365*24*time.Hour)), "year")
216 },
217 // when formats a stored RFC3339 timestamp for display; unparseable
218 // values pass through unchanged.
219 "when": func(s string) string {
220 t, err := time.Parse(time.RFC3339Nano, s)
221 if err != nil {
222 return s
223 }
224 return t.UTC().Format("2006-01-02 15:04")
225 },
226}
227
228// Render executes the named page template with the shared layout.
229func Render(w io.Writer, page string, data any) error {
230 t, err := template.Must(
231 template.New("layout.html").Funcs(funcs).ParseFS(templateFS, "templates/layout.html"),
232 ).ParseFS(templateFS, "templates/"+page)
233 if err != nil {
234 return err
235 }
236 return t.ExecuteTemplate(w, "layout", data)
237}