internal/web/web.go
154 lines · 3970 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 // topTab maps a page's Tab to the repo header tab that should read as
81 // current. Everything that browses the tree or its history sits under
82 // code, whose own toolbar carries the log, search, and refs links.
83 "topTab": func(tab string) string {
84 switch tab {
85 case "files", "log", "refs", "search":
86 return "code"
87 case "issues":
88 return "issues"
89 case "merge requests":
90 return "mrs"
91 }
92 return tab
93 },
94 // initial is the avatar letter for a username.
95 "initial": func(s string) string {
96 for _, r := range s {
97 return strings.ToUpper(string(r))
98 }
99 return "?"
100 },
101 // str is field, narrowed to strings: missing or non-string fields
102 // yield "", which comparisons handle without erroring.
103 "str": func(v any, name string) string {
104 rv := reflect.ValueOf(v)
105 for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
106 rv = rv.Elem()
107 }
108 if rv.Kind() != reflect.Struct {
109 return ""
110 }
111 f := rv.FieldByName(name)
112 if !f.IsValid() || f.Kind() != reflect.String {
113 return ""
114 }
115 return f.String()
116 },
117 // field reads a field the page struct may not have, so the layout can
118 // render repo navigation without every page carrying repo context.
119 // Missing fields yield nil, which templates treat as absent.
120 "field": func(v any, name string) any {
121 rv := reflect.ValueOf(v)
122 for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
123 rv = rv.Elem()
124 }
125 if rv.Kind() != reflect.Struct {
126 return nil
127 }
128 f := rv.FieldByName(name)
129 if !f.IsValid() || !f.CanInterface() {
130 return nil
131 }
132 return f.Interface()
133 },
134 // when formats a stored RFC3339 timestamp for display; unparseable
135 // values pass through unchanged.
136 "when": func(s string) string {
137 t, err := time.Parse(time.RFC3339Nano, s)
138 if err != nil {
139 return s
140 }
141 return t.UTC().Format("2006-01-02 15:04")
142 },
143}
144
145// Render executes the named page template with the shared layout.
146func Render(w io.Writer, page string, data any) error {
147 t, err := template.Must(
148 template.New("layout.html").Funcs(funcs).ParseFS(templateFS, "templates/layout.html"),
149 ).ParseFS(templateFS, "templates/"+page)
150 if err != nil {
151 return err
152 }
153 return t.ExecuteTemplate(w, "layout", data)
154}