internal/web/web.go

027899f05d625769c881aeac38b7ef6bbfe4fa4f
gitbay/internal/web/web.go history · blame · raw

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}