internal/web/web.go
207 lines · 5436 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 "add": func(a, b int) int { return a + b },
80 "sub": func(a, b int) int { return a - b },
81 // topTab maps a page's Tab to the repo header tab that should read as
82 // current. Everything that browses the tree or its history sits under
83 // code, whose own toolbar carries the log, search, and refs links.
84 "topTab": func(tab string) string {
85 switch tab {
86 case "files", "log", "refs", "search":
87 return "code"
88 case "issues":
89 return "issues"
90 case "merge requests":
91 return "mrs"
92 }
93 return tab
94 },
95 // initial is the avatar letter for a username.
96 "initial": func(s string) string {
97 for _, r := range s {
98 return strings.ToUpper(string(r))
99 }
100 return "?"
101 },
102 // str is field, narrowed to strings: missing or non-string fields
103 // yield "", which comparisons handle without erroring.
104 "str": func(v any, name string) string {
105 rv := reflect.ValueOf(v)
106 for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
107 rv = rv.Elem()
108 }
109 if rv.Kind() != reflect.Struct {
110 return ""
111 }
112 f := rv.FieldByName(name)
113 if !f.IsValid() || f.Kind() != reflect.String {
114 return ""
115 }
116 return f.String()
117 },
118 // field reads a field the page struct may not have, so the layout can
119 // render repo navigation without every page carrying repo context.
120 // Missing fields yield nil, which templates treat as absent.
121 "field": func(v any, name string) any {
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 nil
128 }
129 f := rv.FieldByName(name)
130 if !f.IsValid() || !f.CanInterface() {
131 return nil
132 }
133 return f.Interface()
134 },
135 // sigLabel turns a stored signature state into words. The state names
136 // are the CLI's vocabulary and belong in its output; a reader of the
137 // web UI needs to know what is wrong, not what the column is called.
138 "sigLabel": func(state string) string {
139 switch state {
140 case "verified":
141 return "Verified"
142 case "signed_unknown_key":
143 return "Unregistered key"
144 case "signed_email_mismatch":
145 return "Email mismatch"
146 case "signed_key_expired":
147 return "Key expired"
148 case "signed_key_revoked":
149 return "Key revoked"
150 case "bad_signature":
151 return "Bad signature"
152 case "unsigned":
153 return "Unsigned"
154 }
155 return state
156 },
157 // ago renders a time as a coarse relative age, which is what a
158 // listing column is actually read for. The zero time yields "".
159 "ago": func(t time.Time) string {
160 if t.IsZero() {
161 return ""
162 }
163 d := time.Since(t)
164 if d < 0 {
165 d = 0
166 }
167 plural := func(n int, unit string) string {
168 if n == 1 {
169 return "1 " + unit + " ago"
170 }
171 return strconv.Itoa(n) + " " + unit + "s ago"
172 }
173 switch {
174 case d < time.Minute:
175 return "just now"
176 case d < time.Hour:
177 return plural(int(d/time.Minute), "minute")
178 case d < 24*time.Hour:
179 return plural(int(d/time.Hour), "hour")
180 case d < 30*24*time.Hour:
181 return plural(int(d/(24*time.Hour)), "day")
182 case d < 365*24*time.Hour:
183 return plural(int(d/(30*24*time.Hour)), "month")
184 }
185 return plural(int(d/(365*24*time.Hour)), "year")
186 },
187 // when formats a stored RFC3339 timestamp for display; unparseable
188 // values pass through unchanged.
189 "when": func(s string) string {
190 t, err := time.Parse(time.RFC3339Nano, s)
191 if err != nil {
192 return s
193 }
194 return t.UTC().Format("2006-01-02 15:04")
195 },
196}
197
198// Render executes the named page template with the shared layout.
199func Render(w io.Writer, page string, data any) error {
200 t, err := template.Must(
201 template.New("layout.html").Funcs(funcs).ParseFS(templateFS, "templates/layout.html"),
202 ).ParseFS(templateFS, "templates/"+page)
203 if err != nil {
204 return err
205 }
206 return t.ExecuteTemplate(w, "layout", data)
207}