internal/web/web.go

7ad3de789eb7388381be33637ec3854cc4c7885b
gitbay/internal/web/web.go history · blame · raw

207 lines · 5436 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	"add": func(a, b int) int { return a + b },
 80	"sub": func(a, b int) int { return a - b },
 81	// topTab maps a page's Tab to the repo header tab that should read as
 82	// current. Everything that browses the tree or its history sits under
 83	// code, whose own toolbar carries the log, search, and refs links.
 84	"topTab": func(tab string) string {
 85		switch tab {
 86		case "files", "log", "refs", "search":
 87			return "code"
 88		case "issues":
 89			return "issues"
 90		case "merge requests":
 91			return "mrs"
 92		}
 93		return tab
 94	},
 95	// initial is the avatar letter for a username.
 96	"initial": func(s string) string {
 97		for _, r := range s {
 98			return strings.ToUpper(string(r))
 99		}
100		return "?"
101	},
102	// str is field, narrowed to strings: missing or non-string fields
103	// yield "", which comparisons handle without erroring.
104	"str": func(v any, name string) string {
105		rv := reflect.ValueOf(v)
106		for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
107			rv = rv.Elem()
108		}
109		if rv.Kind() != reflect.Struct {
110			return ""
111		}
112		f := rv.FieldByName(name)
113		if !f.IsValid() || f.Kind() != reflect.String {
114			return ""
115		}
116		return f.String()
117	},
118	// field reads a field the page struct may not have, so the layout can
119	// render repo navigation without every page carrying repo context.
120	// Missing fields yield nil, which templates treat as absent.
121	"field": func(v any, name string) any {
122		rv := reflect.ValueOf(v)
123		for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
124			rv = rv.Elem()
125		}
126		if rv.Kind() != reflect.Struct {
127			return nil
128		}
129		f := rv.FieldByName(name)
130		if !f.IsValid() || !f.CanInterface() {
131			return nil
132		}
133		return f.Interface()
134	},
135	// sigLabel turns a stored signature state into words. The state names
136	// are the CLI's vocabulary and belong in its output; a reader of the
137	// web UI needs to know what is wrong, not what the column is called.
138	"sigLabel": func(state string) string {
139		switch state {
140		case "verified":
141			return "Verified"
142		case "signed_unknown_key":
143			return "Unregistered key"
144		case "signed_email_mismatch":
145			return "Email mismatch"
146		case "signed_key_expired":
147			return "Key expired"
148		case "signed_key_revoked":
149			return "Key revoked"
150		case "bad_signature":
151			return "Bad signature"
152		case "unsigned":
153			return "Unsigned"
154		}
155		return state
156	},
157	// ago renders a time as a coarse relative age, which is what a
158	// listing column is actually read for. The zero time yields "".
159	"ago": func(t time.Time) string {
160		if t.IsZero() {
161			return ""
162		}
163		d := time.Since(t)
164		if d < 0 {
165			d = 0
166		}
167		plural := func(n int, unit string) string {
168			if n == 1 {
169				return "1 " + unit + " ago"
170			}
171			return strconv.Itoa(n) + " " + unit + "s ago"
172		}
173		switch {
174		case d < time.Minute:
175			return "just now"
176		case d < time.Hour:
177			return plural(int(d/time.Minute), "minute")
178		case d < 24*time.Hour:
179			return plural(int(d/time.Hour), "hour")
180		case d < 30*24*time.Hour:
181			return plural(int(d/(24*time.Hour)), "day")
182		case d < 365*24*time.Hour:
183			return plural(int(d/(30*24*time.Hour)), "month")
184		}
185		return plural(int(d/(365*24*time.Hour)), "year")
186	},
187	// when formats a stored RFC3339 timestamp for display; unparseable
188	// values pass through unchanged.
189	"when": func(s string) string {
190		t, err := time.Parse(time.RFC3339Nano, s)
191		if err != nil {
192			return s
193		}
194		return t.UTC().Format("2006-01-02 15:04")
195	},
196}
197
198// Render executes the named page template with the shared layout.
199func Render(w io.Writer, page string, data any) error {
200	t, err := template.Must(
201		template.New("layout.html").Funcs(funcs).ParseFS(templateFS, "templates/layout.html"),
202	).ParseFS(templateFS, "templates/"+page)
203	if err != nil {
204		return err
205	}
206	return t.ExecuteTemplate(w, "layout", data)
207}