Go · 14166 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 // Package render owns the html/template loading and rendering pipeline.
4 // S02 ships the helper set that the rest of the project will rely on
5 // (safeHTML, relativeTime, pluralize, pathJoin, octicon, csrfToken).
6 // S25 will broaden this with the markdown pipeline.
7 package render
8
9 import (
10 "bytes"
11 "fmt"
12 "html/template"
13 "io"
14 "io/fs"
15 "net/http"
16 "path"
17 "reflect"
18 "sort"
19 "strings"
20 "text/template/parse"
21 "time"
22
23 "github.com/tenseleyFlow/shithub/internal/web/middleware"
24 )
25
26 // Renderer holds parsed templates indexed by page name.
27 type Renderer struct {
28 pages map[string]*template.Template
29 octicon OcticonResolver
30 }
31
32 // OcticonResolver returns the inline SVG markup for a named octicon. The
33 // implementation is provided by the caller; for S02 we ship a tiny built-in
34 // set; later sprints can plug in the full Primer octicon catalog.
35 type OcticonResolver func(name string) (template.HTML, bool)
36
37 // Options configures a renderer.
38 type Options struct {
39 Octicons OcticonResolver
40 }
41
42 // New parses every page template under tmplFS.
43 //
44 // Naming contract — read this before adding files to internal/web/templates/:
45 //
46 // - **Pages** are .html files whose basename does NOT begin with an
47 // underscore. A page at `repo/tree.html` is registered under the
48 // lookup name `repo/tree`. Render that name from a handler.
49 // - **Partials** are .html files whose basename begins with an
50 // underscore (`_layout.html`, `profile/_tabs.html`). Partials are
51 // parsed into *every* page so the `{{ define "name" }}` blocks
52 // they declare are resolvable from any page template.
53 //
54 // Both pages and partials are picked up recursively. Earlier versions
55 // of this loader walked only the root for partials, which caused a
56 // page that referenced a subdir partial (`profile/_tabs.html`'s
57 // `{{ define "tabs" }}`) to render blank — html/template silently
58 // ignored the missing-template ref at exec time. We now also validate
59 // that every `{{ template "name" }}` action in every parsed page
60 // resolves; an undefined ref fails loud at startup with the offending
61 // page + the missing name.
62 func New(tmplFS fs.FS, opts Options) (*Renderer, error) {
63 var (
64 partialPaths []string
65 pagePaths []string
66 )
67 if err := fs.WalkDir(tmplFS, ".", func(p string, d fs.DirEntry, walkErr error) error {
68 if walkErr != nil {
69 return walkErr
70 }
71 if d.IsDir() || !strings.HasSuffix(p, ".html") {
72 return nil
73 }
74 if strings.HasPrefix(path.Base(p), "_") {
75 partialPaths = append(partialPaths, p)
76 } else {
77 pagePaths = append(pagePaths, p)
78 }
79 return nil
80 }); err != nil {
81 return nil, fmt.Errorf("walk templates: %w", err)
82 }
83 sort.Strings(partialPaths)
84 sort.Strings(pagePaths)
85
86 r := &Renderer{
87 pages: make(map[string]*template.Template, len(pagePaths)),
88 octicon: opts.Octicons,
89 }
90
91 parsePage := func(displayName, primary string) error {
92 t := template.New(path.Base(primary)).Funcs(funcMap(r.octicon))
93 all := append([]string{}, partialPaths...)
94 all = append(all, primary)
95 parsed, err := t.ParseFS(tmplFS, all...)
96 if err != nil {
97 return fmt.Errorf("parse %s: %w", displayName, err)
98 }
99 if missing := undefinedTemplateRefs(parsed); len(missing) > 0 {
100 return fmt.Errorf("page %q references undefined template(s): %s", displayName, strings.Join(missing, ", "))
101 }
102 r.pages[displayName] = parsed
103 return nil
104 }
105
106 for _, page := range pagePaths {
107 if err := parsePage(strings.TrimSuffix(page, ".html"), page); err != nil {
108 return nil, err
109 }
110 }
111 return r, nil
112 }
113
114 // undefinedTemplateRefs returns the names of every `{{ template "name" }}`
115 // action in any parsed sub-template that does not resolve to a defined
116 // template within `t`. Empty slice means every reference is satisfied.
117 //
118 // The standard library does not validate this at parse time — html/template
119 // happily parses a page with a dangling `{{ template "missing" }}` and
120 // silently emits nothing at exec time. This helper closes that hole.
121 func undefinedTemplateRefs(t *template.Template) []string {
122 defined := map[string]bool{}
123 for _, child := range t.Templates() {
124 defined[child.Name()] = true
125 }
126 seen := map[string]bool{}
127 var missing []string
128 for _, child := range t.Templates() {
129 if child.Tree == nil {
130 continue
131 }
132 walkTemplateRefs(child.Tree.Root, func(name string) {
133 if defined[name] || seen[name] {
134 return
135 }
136 seen[name] = true
137 missing = append(missing, name)
138 })
139 }
140 sort.Strings(missing)
141 return missing
142 }
143
144 func walkTemplateRefs(n parse.Node, visit func(name string)) {
145 if n == nil {
146 return
147 }
148 switch x := n.(type) {
149 case *parse.ListNode:
150 if x == nil {
151 return
152 }
153 for _, c := range x.Nodes {
154 walkTemplateRefs(c, visit)
155 }
156 case *parse.IfNode:
157 walkTemplateRefs(x.List, visit)
158 walkTemplateRefs(x.ElseList, visit)
159 case *parse.RangeNode:
160 walkTemplateRefs(x.List, visit)
161 walkTemplateRefs(x.ElseList, visit)
162 case *parse.WithNode:
163 walkTemplateRefs(x.List, visit)
164 walkTemplateRefs(x.ElseList, visit)
165 case *parse.TemplateNode:
166 visit(x.Name)
167 }
168 }
169
170 // Render writes the named page to w using data as the template root context.
171 //
172 // Prefer RenderPage when a *http.Request is in scope — it auto-injects the
173 // viewer (current logged-in user) into map data so partials like _nav.html
174 // can branch on .Viewer without every handler remembering to thread it.
175 //
176 // When w is an http.ResponseWriter, Render sets Content-Type to
177 // `text/html; charset=utf-8` *before* the first body byte. This is
178 // load-bearing: a handler that calls WriteHeader(non-200) without
179 // pre-setting Content-Type otherwise produces a 4xx/5xx response with
180 // no Content-Type, which the browser renders as raw text. Setting it
181 // here makes that class of bug structurally impossible.
182 func (r *Renderer) Render(w io.Writer, name string, data any) error {
183 t, ok := r.pages[name]
184 if !ok {
185 return fmt.Errorf("render: unknown page %q", name)
186 }
187 var buf bytes.Buffer
188 if err := t.ExecuteTemplate(&buf, "layout", data); err != nil {
189 return fmt.Errorf("execute %s: %w", name, err)
190 }
191 if rw, ok := w.(http.ResponseWriter); ok {
192 // Header().Set is a no-op once headers have been committed
193 // (e.g. an upstream WriteHeader call). That's the right
194 // behaviour: we don't try to retroactively fix a header
195 // stream that's already on the wire — the caller has to set
196 // Content-Type before WriteHeader in those cases.
197 if rw.Header().Get("Content-Type") == "" {
198 rw.Header().Set("Content-Type", "text/html; charset=utf-8")
199 }
200 }
201 _, err := w.Write(buf.Bytes())
202 return err
203 }
204
205 // RenderFragment executes only a page template's "page" definition. Use it
206 // for HTML fragments returned to JavaScript or htmx; full browser pages should
207 // continue to call Render/RenderPage so nav, footer, and document chrome stay
208 // consistent.
209 func (r *Renderer) RenderFragment(w io.Writer, name string, data any) error {
210 t, ok := r.pages[name]
211 if !ok {
212 return fmt.Errorf("render: unknown page %q", name)
213 }
214 var buf bytes.Buffer
215 if err := t.ExecuteTemplate(&buf, "page", data); err != nil {
216 return fmt.Errorf("execute fragment %s: %w", name, err)
217 }
218 if rw, ok := w.(http.ResponseWriter); ok {
219 if rw.Header().Get("Content-Type") == "" {
220 rw.Header().Set("Content-Type", "text/html; charset=utf-8")
221 }
222 }
223 _, err := w.Write(buf.Bytes())
224 return err
225 }
226
227 // RenderPage is the request-aware Render: when data is a map[string]any, it
228 // injects "Viewer" (from middleware.CurrentUserFromContext) and "CSRFToken"
229 // (the per-request token) if the caller hasn't set them. The nav partial's
230 // sign-out form uses the token, so every layout-rendered page needs it.
231 // Typed-struct callers must include those fields themselves — we don't
232 // reflect-mutate to avoid surprising aliasing.
233 func (r *Renderer) RenderPage(w io.Writer, req *http.Request, name string, data any) error {
234 if m, ok := data.(map[string]any); ok {
235 if _, present := m["Viewer"]; !present {
236 m["Viewer"] = middleware.CurrentUserFromContext(req.Context())
237 }
238 if _, present := m["CSRFToken"]; !present {
239 m["CSRFToken"] = middleware.CSRFTokenForRequest(req)
240 }
241 data = m
242 }
243 return r.Render(w, name, data)
244 }
245
246 // HTTPError writes an error page with the appropriate status code. If the
247 // named error template doesn't exist a plain-text fallback is written.
248 func (r *Renderer) HTTPError(w http.ResponseWriter, req *http.Request, status int, message string) {
249 pageName := errorPageFor(status)
250 w.Header().Set("Content-Type", "text/html; charset=utf-8")
251 w.Header().Set("Cache-Control", "no-store")
252 w.WriteHeader(status)
253
254 data := struct {
255 Title string
256 Status int
257 StatusText string
258 Message string
259 RequestID string
260 }{
261 Title: fmt.Sprintf("%d %s", status, http.StatusText(status)),
262 Status: status,
263 StatusText: http.StatusText(status),
264 Message: message,
265 RequestID: middleware.RequestIDFromContext(req.Context()),
266 }
267 if err := r.Render(w, pageName, data); err != nil {
268 _, _ = fmt.Fprintf(w, "%d %s\n%s\n(request_id=%s)\n",
269 status, http.StatusText(status), message, data.RequestID)
270 }
271 }
272
273 func errorPageFor(status int) string {
274 switch status {
275 case http.StatusForbidden:
276 return "errors/403"
277 case http.StatusNotFound:
278 return "errors/404"
279 case http.StatusTooManyRequests:
280 return "errors/429"
281 default:
282 return "errors/500"
283 }
284 }
285
286 func funcMap(octicon OcticonResolver) template.FuncMap {
287 return template.FuncMap{
288 // safeHTML embeds trusted HTML directly. Callers MUST ensure the
289 // input is server-controlled — never user input. S25's markdown
290 // pipeline supplies the canonical helper for user content.
291 "safeHTML": func(s string) template.HTML {
292 return template.HTML(s) //nolint:gosec // trusted-input only
293 },
294 // relativeTime renders a "2 hours ago" / "yesterday" / "Mar 5"
295 // style label. Used wherever timestamps appear in UI.
296 "relativeTime": relativeTime,
297 // pluralize picks the singular or plural form based on count.
298 "pluralize": func(count int, one, many string) string {
299 if count == 1 {
300 return one
301 }
302 return many
303 },
304 // pathJoin builds URL paths with a single leading slash.
305 "pathJoin": func(parts ...string) string {
306 joined := path.Join(parts...)
307 if !strings.HasPrefix(joined, "/") {
308 return "/" + joined
309 }
310 return joined
311 },
312 // octicon resolves a named octicon to inline SVG. Returns empty
313 // HTML if the icon isn't registered (the caller's template stays
314 // valid but renders nothing — better than a build-time crash).
315 "octicon": func(name string) template.HTML {
316 if octicon == nil {
317 return ""
318 }
319 if html, ok := octicon(name); ok {
320 return html
321 }
322 return ""
323 },
324 // flag reads an optional boolean-ish field from map or struct
325 // template data. Layout-level feature toggles use this so pages
326 // backed by typed structs don't fail when the toggle is absent.
327 "flag": dataFlag,
328 // csrfToken pulls the per-request token from the request context.
329 // Templates use this in <input type="hidden" name="csrf_token">.
330 "csrfToken": middleware.CSRFTokenForRequest,
331 // dict builds a map for partial-template includes that need
332 // multiple named values (idiomatic Go template trick).
333 // add / sub are tiny integer helpers used by pagination
334 // templates (next/prev page links). Templates can't do
335 // arithmetic, so the helpers earn their keep here.
336 "add": func(a, b int) int { return a + b },
337 "sub": func(a, b int) int { return a - b },
338 "dict": func(values ...any) (map[string]any, error) {
339 if len(values)%2 != 0 {
340 return nil, fmt.Errorf("dict: odd number of args")
341 }
342 m := make(map[string]any, len(values)/2)
343 for i := 0; i < len(values); i += 2 {
344 key, ok := values[i].(string)
345 if !ok {
346 return nil, fmt.Errorf("dict: non-string key at %d", i)
347 }
348 m[key] = values[i+1]
349 }
350 return m, nil
351 },
352 }
353 }
354
355 func dataFlag(data any, name string) bool {
356 v := reflect.ValueOf(data)
357 if !v.IsValid() {
358 return false
359 }
360 for v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface {
361 if v.IsNil() {
362 return false
363 }
364 v = v.Elem()
365 }
366 switch v.Kind() {
367 case reflect.Map:
368 if v.Type().Key().Kind() != reflect.String {
369 return false
370 }
371 field := v.MapIndex(reflect.ValueOf(name))
372 return truthyValue(field)
373 case reflect.Struct:
374 field := v.FieldByName(name)
375 if !field.IsValid() || !field.CanInterface() {
376 return false
377 }
378 return truthyValue(field)
379 default:
380 return false
381 }
382 }
383
384 func truthyValue(v reflect.Value) bool {
385 if !v.IsValid() {
386 return false
387 }
388 for v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface {
389 if v.IsNil() {
390 return false
391 }
392 v = v.Elem()
393 }
394 switch v.Kind() {
395 case reflect.Bool:
396 return v.Bool()
397 case reflect.String:
398 return v.String() != ""
399 case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
400 return v.Int() != 0
401 case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
402 return v.Uint() != 0
403 default:
404 return !v.IsZero()
405 }
406 }
407
408 // relativeTime returns a human-readable relative-time string. The intent is
409 // to read naturally; absolute precision below the level of "minutes" isn't
410 // useful for UI labels.
411 func relativeTime(t time.Time) string {
412 if t.IsZero() {
413 return ""
414 }
415 d := time.Since(t)
416 switch {
417 case d < 0:
418 // Future timestamps are uncommon; render as absolute.
419 return t.UTC().Format("Jan 2, 2006")
420 case d < time.Minute:
421 return "just now"
422 case d < time.Hour:
423 m := int(d / time.Minute)
424 return fmt.Sprintf("%d minute%s ago", m, plural(m))
425 case d < 24*time.Hour:
426 h := int(d / time.Hour)
427 return fmt.Sprintf("%d hour%s ago", h, plural(h))
428 case d < 7*24*time.Hour:
429 days := int(d / (24 * time.Hour))
430 if days == 1 {
431 return "yesterday"
432 }
433 return fmt.Sprintf("%d days ago", days)
434 case d < 30*24*time.Hour:
435 w := int(d / (7 * 24 * time.Hour))
436 return fmt.Sprintf("%d week%s ago", w, plural(w))
437 case d < 365*24*time.Hour:
438 mo := int(d / (30 * 24 * time.Hour))
439 return fmt.Sprintf("%d month%s ago", mo, plural(mo))
440 default:
441 return t.UTC().Format("Jan 2, 2006")
442 }
443 }
444
445 func plural(n int) string {
446 if n == 1 {
447 return ""
448 }
449 return "s"
450 }
451