internal/web/web.go
140 lines · 3549 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 "strings"
12 "sync"
13 "time"
14)
15
16//go:embed templates/*.html
17var templateFS embed.FS
18
19//go:embed static/style.css
20var StyleCSS []byte
21
22//go:embed static/favicon.svg
23var FaviconSVG []byte
24
25//go:embed static/fonts/*.woff2
26var FontFS embed.FS
27
28// version returns the short VCS revision baked into the binary, or "" when
29// built outside a checkout. Used by the layout footer.
30var version = sync.OnceValue(func() string {
31 info, ok := debug.ReadBuildInfo()
32 if !ok {
33 return ""
34 }
35 for _, s := range info.Settings {
36 if s.Key == "vcs.revision" && len(s.Value) >= 10 {
37 return s.Value[:10]
38 }
39 }
40 return ""
41})
42
43// fullVersion is the complete VCS revision, for linking the footer hash
44// to the upstream commit page.
45var fullVersion = sync.OnceValue(func() string {
46 info, ok := debug.ReadBuildInfo()
47 if !ok {
48 return ""
49 }
50 for _, s := range info.Settings {
51 if s.Key == "vcs.revision" {
52 return s.Value
53 }
54 }
55 return ""
56})
57
58var funcs = template.FuncMap{
59 "gitbayVersion": func() string { return version() },
60 "gitbayCommit": func() string { return fullVersion() },
61 // paragraphs splits plain text on blank lines for safe rich display.
62 "paragraphs": func(s string) []string {
63 var out []string
64 for _, p := range strings.Split(s, "\n\n") {
65 if p = strings.TrimSpace(p); p != "" {
66 out = append(out, p)
67 }
68 }
69 return out
70 },
71 // short abbreviates a commit SHA for display.
72 "short": func(s string) string {
73 if len(s) > 10 {
74 return s[:10]
75 }
76 return s
77 },
78 "add": func(a, b int) int { return a + b },
79 "sub": func(a, b int) int { return a - b },
80 // initial is the avatar letter for a username.
81 "initial": func(s string) string {
82 for _, r := range s {
83 return strings.ToUpper(string(r))
84 }
85 return "?"
86 },
87 // str is field, narrowed to strings: missing or non-string fields
88 // yield "", which comparisons handle without erroring.
89 "str": func(v any, name string) string {
90 rv := reflect.ValueOf(v)
91 for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
92 rv = rv.Elem()
93 }
94 if rv.Kind() != reflect.Struct {
95 return ""
96 }
97 f := rv.FieldByName(name)
98 if !f.IsValid() || f.Kind() != reflect.String {
99 return ""
100 }
101 return f.String()
102 },
103 // field reads a field the page struct may not have, so the layout can
104 // render repo navigation without every page carrying repo context.
105 // Missing fields yield nil, which templates treat as absent.
106 "field": func(v any, name string) any {
107 rv := reflect.ValueOf(v)
108 for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
109 rv = rv.Elem()
110 }
111 if rv.Kind() != reflect.Struct {
112 return nil
113 }
114 f := rv.FieldByName(name)
115 if !f.IsValid() || !f.CanInterface() {
116 return nil
117 }
118 return f.Interface()
119 },
120 // when formats a stored RFC3339 timestamp for display; unparseable
121 // values pass through unchanged.
122 "when": func(s string) string {
123 t, err := time.Parse(time.RFC3339Nano, s)
124 if err != nil {
125 return s
126 }
127 return t.UTC().Format("2006-01-02 15:04")
128 },
129}
130
131// Render executes the named page template with the shared layout.
132func Render(w io.Writer, page string, data any) error {
133 t, err := template.Must(
134 template.New("layout.html").Funcs(funcs).ParseFS(templateFS, "templates/layout.html"),
135 ).ParseFS(templateFS, "templates/"+page)
136 if err != nil {
137 return err
138 }
139 return t.ExecuteTemplate(w, "layout", data)
140}