internal/web/web.go
217 lines · 5702 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 "ago": func(t time.Time) string {
170 if t.IsZero() {
171 return ""
172 }
173 d := time.Since(t)
174 if d < 0 {
175 d = 0
176 }
177 plural := func(n int, unit string) string {
178 if n == 1 {
179 return "1 " + unit + " ago"
180 }
181 return strconv.Itoa(n) + " " + unit + "s ago"
182 }
183 switch {
184 case d < time.Minute:
185 return "just now"
186 case d < time.Hour:
187 return plural(int(d/time.Minute), "minute")
188 case d < 24*time.Hour:
189 return plural(int(d/time.Hour), "hour")
190 case d < 30*24*time.Hour:
191 return plural(int(d/(24*time.Hour)), "day")
192 case d < 365*24*time.Hour:
193 return plural(int(d/(30*24*time.Hour)), "month")
194 }
195 return plural(int(d/(365*24*time.Hour)), "year")
196 },
197 // when formats a stored RFC3339 timestamp for display; unparseable
198 // values pass through unchanged.
199 "when": func(s string) string {
200 t, err := time.Parse(time.RFC3339Nano, s)
201 if err != nil {
202 return s
203 }
204 return t.UTC().Format("2006-01-02 15:04")
205 },
206}
207
208// Render executes the named page template with the shared layout.
209func Render(w io.Writer, page string, data any) error {
210 t, err := template.Must(
211 template.New("layout.html").Funcs(funcs).ParseFS(templateFS, "templates/layout.html"),
212 ).ParseFS(templateFS, "templates/"+page)
213 if err != nil {
214 return err
215 }
216 return t.ExecuteTemplate(w, "layout", data)
217}