internal/web/web.go

ba046389a8614e133b3fdd33d25fa3055bca7e7d
gitbay/internal/web/web.go history · blame · raw

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}