Go · 15927 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 // stringField reads an optional string-ish field from map or
329 // struct template data. Shared document metadata uses this so
330 // typed page-data structs don't all have to define every optional
331 // SEO/social field.
332 "stringField": dataString,
333 // jsField returns only server-marked template.JS values. It is
334 // intentionally stricter than stringField so JSON-LD can be
335 // emitted from trusted marshaled data without giving templates a
336 // generic raw-JS escape hatch for arbitrary strings.
337 "jsField": dataJS,
338 // csrfToken pulls the per-request token from the request context.
339 // Templates use this in <input type="hidden" name="csrf_token">.
340 "csrfToken": middleware.CSRFTokenForRequest,
341 // dict builds a map for partial-template includes that need
342 // multiple named values (idiomatic Go template trick).
343 // add / sub are tiny integer helpers used by pagination
344 // templates (next/prev page links). Templates can't do
345 // arithmetic, so the helpers earn their keep here.
346 "add": func(a, b int) int { return a + b },
347 "sub": func(a, b int) int { return a - b },
348 "dict": func(values ...any) (map[string]any, error) {
349 if len(values)%2 != 0 {
350 return nil, fmt.Errorf("dict: odd number of args")
351 }
352 m := make(map[string]any, len(values)/2)
353 for i := 0; i < len(values); i += 2 {
354 key, ok := values[i].(string)
355 if !ok {
356 return nil, fmt.Errorf("dict: non-string key at %d", i)
357 }
358 m[key] = values[i+1]
359 }
360 return m, nil
361 },
362 }
363 }
364
365 func dataFlag(data any, name string) bool {
366 field, ok := dataField(data, name)
367 if !ok {
368 return false
369 }
370 return truthyValue(field)
371 }
372
373 func dataString(data any, name string) string {
374 field, ok := dataField(data, name)
375 if !ok {
376 return ""
377 }
378 for field.Kind() == reflect.Pointer || field.Kind() == reflect.Interface {
379 if field.IsNil() {
380 return ""
381 }
382 field = field.Elem()
383 }
384 switch field.Kind() {
385 case reflect.String:
386 return field.String()
387 default:
388 if field.CanInterface() {
389 return fmt.Sprint(field.Interface())
390 }
391 return ""
392 }
393 }
394
395 func dataJS(data any, name string) template.JS {
396 field, ok := dataField(data, name)
397 if !ok {
398 return ""
399 }
400 if field.CanInterface() {
401 switch v := field.Interface().(type) {
402 case template.JS:
403 return v
404 case *template.JS:
405 if v != nil {
406 return *v
407 }
408 }
409 }
410 for field.Kind() == reflect.Pointer || field.Kind() == reflect.Interface {
411 if field.IsNil() {
412 return ""
413 }
414 field = field.Elem()
415 if field.CanInterface() {
416 if v, ok := field.Interface().(template.JS); ok {
417 return v
418 }
419 }
420 }
421 return ""
422 }
423
424 func dataField(data any, name string) (reflect.Value, bool) {
425 v := reflect.ValueOf(data)
426 if !v.IsValid() {
427 return reflect.Value{}, false
428 }
429 for v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface {
430 if v.IsNil() {
431 return reflect.Value{}, false
432 }
433 v = v.Elem()
434 }
435 switch v.Kind() {
436 case reflect.Map:
437 if v.Type().Key().Kind() != reflect.String {
438 return reflect.Value{}, false
439 }
440 field := v.MapIndex(reflect.ValueOf(name))
441 if !field.IsValid() {
442 return reflect.Value{}, false
443 }
444 return field, true
445 case reflect.Struct:
446 field := v.FieldByName(name)
447 if !field.IsValid() || !field.CanInterface() {
448 return reflect.Value{}, false
449 }
450 return field, true
451 default:
452 return reflect.Value{}, false
453 }
454 }
455
456 func truthyValue(v reflect.Value) bool {
457 if !v.IsValid() {
458 return false
459 }
460 for v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface {
461 if v.IsNil() {
462 return false
463 }
464 v = v.Elem()
465 }
466 switch v.Kind() {
467 case reflect.Bool:
468 return v.Bool()
469 case reflect.String:
470 return v.String() != ""
471 case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
472 return v.Int() != 0
473 case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
474 return v.Uint() != 0
475 default:
476 return !v.IsZero()
477 }
478 }
479
480 // relativeTime returns a human-readable relative-time string. The intent is
481 // to read naturally; absolute precision below the level of "minutes" isn't
482 // useful for UI labels.
483 func relativeTime(t time.Time) string {
484 if t.IsZero() {
485 return ""
486 }
487 d := time.Since(t)
488 switch {
489 case d < 0:
490 // Future timestamps are uncommon; render as absolute.
491 return t.UTC().Format("Jan 2, 2006")
492 case d < time.Minute:
493 return "just now"
494 case d < time.Hour:
495 m := int(d / time.Minute)
496 return fmt.Sprintf("%d minute%s ago", m, plural(m))
497 case d < 24*time.Hour:
498 h := int(d / time.Hour)
499 return fmt.Sprintf("%d hour%s ago", h, plural(h))
500 case d < 7*24*time.Hour:
501 days := int(d / (24 * time.Hour))
502 if days == 1 {
503 return "yesterday"
504 }
505 return fmt.Sprintf("%d days ago", days)
506 case d < 30*24*time.Hour:
507 w := int(d / (7 * 24 * time.Hour))
508 return fmt.Sprintf("%d week%s ago", w, plural(w))
509 case d < 365*24*time.Hour:
510 mo := int(d / (30 * 24 * time.Hour))
511 return fmt.Sprintf("%d month%s ago", mo, plural(mo))
512 default:
513 return t.UTC().Format("Jan 2, 2006")
514 }
515 }
516
517 func plural(n int) string {
518 if n == 1 {
519 return ""
520 }
521 return "s"
522 }
523