internal/web/web.go

32a583340a8db2b242ed452180dd239fd118bc26
gitbay/internal/web/web.go history · blame · raw

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}