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