internal/web/web.go
319 lines · 9401 bytes
15 symbols in this file
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 "sort"
13 "strconv"
14 "strings"
15 "time"
16)
17
18//go:embed templates/*.html
19var templateFS embed.FS
20
21//go:embed static/style.css
22var StyleCSS []byte
23
24//go:embed static/favicon.svg
25var FaviconSVG []byte
26
27//go:embed static/fonts/*.woff2
28var FontFS embed.FS
29
30//go:embed static/img/*.gif static/img/*.webm static/img/*.mp4
31var ImageFS embed.FS
32
33// StyleVersion identifies the bytes /static/style.css serves. The httpd
34// package sets it; the layout stamps it on the stylesheet URL so a deploy
35// changes that URL and a browser holding a cached copy cannot miss it.
36var StyleVersion string
37
38// railItem is one destination the rail's icon strip and the phone
39// "More" menu both render — from this one list, so a destination added
40// here reaches both instead of the two being hand-kept in step (#271).
41type railItem struct {
42 Href string
43 Icon string
44 Name string
45 Current bool
46 Count int64 // unused by railOptItems; present so "raillink" can read it uniformly
47 Show bool
48}
49
50// railField and railBool read a named field off the page value the
51// layout was given — the same reflection str/field already do for the
52// repo header, duplicated narrowly here rather than exported, since
53// railOptItems is their only other caller.
54func railField(v any, name string) string {
55 rv := reflect.ValueOf(v)
56 for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
57 rv = rv.Elem()
58 }
59 if rv.Kind() != reflect.Struct {
60 return ""
61 }
62 f := rv.FieldByName(name)
63 if !f.IsValid() || f.Kind() != reflect.String {
64 return ""
65 }
66 return f.String()
67}
68
69func railBool(v any, name string) bool {
70 rv := reflect.ValueOf(v)
71 for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
72 rv = rv.Elem()
73 }
74 if rv.Kind() != reflect.Struct {
75 return false
76 }
77 f := rv.FieldByName(name)
78 return f.IsValid() && f.Kind() == reflect.Bool && f.Bool()
79}
80
81// railOptItems is the rail's "New repository", "Settings", "Admin" and
82// "Log out" destinations, in the order the rail shows them. v is the
83// page value the layout renders (any page struct that embeds
84// basePage), read by field name since the layout has no single common
85// type for every page.
86func railOptItems(v any) []railItem {
87 tab := railField(v, "Tab")
88 admin := railBool(v, "Admin")
89 return []railItem{
90 {Href: "/new", Icon: "plus", Name: "New repository", Show: true},
91 {Href: "/settings", Icon: "gear", Name: "Settings", Current: tab == "account", Show: true},
92 {Href: "/admin", Icon: "shield", Name: "Admin", Current: tab == "admin", Show: admin},
93 {Href: "/logout", Icon: "signout", Name: "Log out", Show: true},
94 }
95}
96
97var funcs = template.FuncMap{
98 "styleVersion": func() string { return StyleVersion },
99 "join": strings.Join,
100 // paragraphs splits plain text on blank lines for safe rich display.
101 "paragraphs": func(s string) []string {
102 var out []string
103 for _, p := range strings.Split(s, "\n\n") {
104 if p = strings.TrimSpace(p); p != "" {
105 out = append(out, p)
106 }
107 }
108 return out
109 },
110 // short abbreviates a commit SHA for display.
111 "short": func(s string) string {
112 if len(s) > 10 {
113 return s[:10]
114 }
115 return s
116 },
117 // dict builds a map for {{template}} calls that need several values.
118 "dict": func(pairs ...any) map[string]any {
119 m := map[string]any{}
120 for i := 0; i+1 < len(pairs); i += 2 {
121 if k, ok := pairs[i].(string); ok {
122 m[k] = pairs[i+1]
123 }
124 }
125 return m
126 },
127 "add": func(a, b int) int { return a + b },
128 "sub": func(a, b int) int { return a - b },
129 // topTab maps a page's Tab to the repo header tab that should read as
130 // current. Everything that browses the tree or its history sits under
131 // code, whose own toolbar carries the log, search, and refs links.
132 "topTab": func(tab string) string {
133 switch tab {
134 case "files", "log", "refs", "search":
135 return "code"
136 case "issues":
137 return "issues"
138 case "merge requests":
139 return "mrs"
140 }
141 return tab
142 },
143 // initial is the avatar letter for a username.
144 "initial": func(s string) string {
145 for _, r := range s {
146 return strings.ToUpper(string(r))
147 }
148 return "?"
149 },
150 "railOptItems": railOptItems,
151 // str is field, narrowed to strings: missing or non-string fields
152 // yield "", which comparisons handle without erroring.
153 "str": func(v any, name string) string {
154 rv := reflect.ValueOf(v)
155 for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
156 rv = rv.Elem()
157 }
158 if rv.Kind() != reflect.Struct {
159 return ""
160 }
161 f := rv.FieldByName(name)
162 if !f.IsValid() || f.Kind() != reflect.String {
163 return ""
164 }
165 return f.String()
166 },
167 // field reads a field the page struct may not have, so the layout can
168 // render repo navigation without every page carrying repo context.
169 // Missing fields yield nil, which templates treat as absent.
170 "field": func(v any, name string) any {
171 rv := reflect.ValueOf(v)
172 for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
173 rv = rv.Elem()
174 }
175 if rv.Kind() != reflect.Struct {
176 return nil
177 }
178 f := rv.FieldByName(name)
179 if !f.IsValid() || !f.CanInterface() {
180 return nil
181 }
182 return f.Interface()
183 },
184 // sigLabel turns a stored signature state into words. The state names
185 // are the CLI's vocabulary and belong in its output; a reader of the
186 // web UI needs to know what is wrong, not what the column is called.
187 "sigLabel": func(state string) string {
188 switch state {
189 case "verified":
190 return "Verified"
191 case "signed_unknown_key":
192 return "Unregistered key"
193 case "signed_email_mismatch":
194 return "Email mismatch"
195 case "signed_key_expired":
196 return "Key expired"
197 case "signed_key_revoked":
198 return "Key revoked"
199 case "bad_signature":
200 return "Bad signature"
201 case "unsigned":
202 return "Unsigned"
203 }
204 return state
205 },
206 // ago renders a time as a coarse relative age, which is what a
207 // listing column is actually read for. The zero time yields "".
208 // slug turns a language name into a CSS class suffix, so the palette
209 // lives in the stylesheet rather than in inline styles the CSP would
210 // have to allow.
211 "slug": func(s string) string {
212 var b strings.Builder
213 for _, r := range strings.ToLower(s) {
214 switch {
215 case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
216 b.WriteRune(r)
217 default:
218 b.WriteByte('-')
219 }
220 }
221 return b.String()
222 },
223 // pct renders a share to one decimal, dropping a trailing ".0".
224 "pct": func(f float64) string {
225 s := strconv.FormatFloat(f, 'f', 1, 64)
226 return strings.TrimSuffix(s, ".0")
227 },
228 "ago": func(t time.Time) string {
229 if t.IsZero() {
230 return ""
231 }
232 d := time.Since(t)
233 if d < 0 {
234 d = 0
235 }
236 plural := func(n int, unit string) string {
237 if n == 1 {
238 return "1 " + unit + " ago"
239 }
240 return strconv.Itoa(n) + " " + unit + "s ago"
241 }
242 switch {
243 case d < time.Minute:
244 return "just now"
245 case d < time.Hour:
246 return plural(int(d/time.Minute), "minute")
247 case d < 24*time.Hour:
248 return plural(int(d/time.Hour), "hour")
249 case d < 30*24*time.Hour:
250 return plural(int(d/(24*time.Hour)), "day")
251 case d < 365*24*time.Hour:
252 return plural(int(d/(30*24*time.Hour)), "month")
253 }
254 return plural(int(d/(365*24*time.Hour)), "year")
255 },
256 // when formats a stored RFC3339 timestamp for display; unparseable
257 // values pass through unchanged.
258 "when": func(s string) string {
259 t, err := time.Parse(time.RFC3339Nano, s)
260 if err != nil {
261 return s
262 }
263 return t.UTC().Format("2006-01-02 15:04 UTC")
264 },
265 // whenT is when for a value that is already a time.Time rather than
266 // a stored string.
267 "whenT": func(t time.Time) string {
268 return t.UTC().Format("2006-01-02 15:04 UTC")
269 },
270}
271
272// pages holds every page template parsed once with the layout, at
273// start-up: a template that does not parse fails the process before it
274// serves anything, rather than the first visit to a rarely-hit page, and
275// a request no longer re-parses the layout (#116).
276var pages = func() map[string]*template.Template {
277 entries, err := fs.ReadDir(templateFS, "templates")
278 if err != nil {
279 panic(err)
280 }
281 m := map[string]*template.Template{}
282 for _, e := range entries {
283 name := e.Name()
284 if name == "layout.html" || !strings.HasSuffix(name, ".html") {
285 continue
286 }
287 layout := template.Must(template.New("layout.html").Funcs(funcs).ParseFS(templateFS, "templates/layout.html"))
288 m[name] = template.Must(layout.ParseFS(templateFS, "templates/"+name))
289 }
290 return m
291}()
292
293// TemplateSource returns a template's raw text, for tests that inspect the
294// markup rather than the rendered output — an accessibility rule about
295// labels and ids is a property of the source, and holds whatever data a
296// page is given.
297func TemplateSource(name string) (string, error) {
298 b, err := templateFS.ReadFile("templates/" + name)
299 return string(b), err
300}
301
302// Pages lists the page template names, for tests that render each one.
303func Pages() []string {
304 names := make([]string, 0, len(pages))
305 for name := range pages {
306 names = append(names, name)
307 }
308 sort.Strings(names)
309 return names
310}
311
312// Render executes the named page template with the shared layout.
313func Render(w io.Writer, page string, data any) error {
314 t, ok := pages[page]
315 if !ok {
316 return fmt.Errorf("no page template %q", page)
317 }
318 return t.ExecuteTemplate(w, "layout", data)
319}