Go · 9203 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 "strings"
18 "time"
19
20 "github.com/tenseleyFlow/shithub/internal/web/middleware"
21 )
22
23 // Renderer holds parsed templates indexed by page name.
24 type Renderer struct {
25 pages map[string]*template.Template
26 octicon OcticonResolver
27 }
28
29 // OcticonResolver returns the inline SVG markup for a named octicon. The
30 // implementation is provided by the caller; for S02 we ship a tiny built-in
31 // set; later sprints can plug in the full Primer octicon catalog.
32 type OcticonResolver func(name string) (template.HTML, bool)
33
34 // Options configures a renderer.
35 type Options struct {
36 Octicons OcticonResolver
37 }
38
39 // New parses every page template under tmplFS. A "page template" is any file
40 // at the root of tmplFS that does NOT begin with an underscore. Files that
41 // begin with an underscore (e.g. "_layout.html") are partials, parsed once
42 // into every page.
43 func New(tmplFS fs.FS, opts Options) (*Renderer, error) {
44 entries, err := fs.ReadDir(tmplFS, ".")
45 if err != nil {
46 return nil, fmt.Errorf("read template root: %w", err)
47 }
48
49 var (
50 partialNames []string
51 pageNames []string
52 errorPages []string
53 )
54 for _, e := range entries {
55 if e.IsDir() {
56 continue
57 }
58 name := e.Name()
59 if !strings.HasSuffix(name, ".html") {
60 continue
61 }
62 if strings.HasPrefix(name, "_") {
63 partialNames = append(partialNames, name)
64 } else {
65 pageNames = append(pageNames, name)
66 }
67 }
68
69 // Recursively pick up files in subdirectories like errors/.
70 // Each subdirectory file is registered as `<dir>/<name>` (without
71 // suffix) for Render lookups.
72 if err := fs.WalkDir(tmplFS, ".", func(p string, d fs.DirEntry, walkErr error) error {
73 if walkErr != nil {
74 return walkErr
75 }
76 if d.IsDir() || !strings.HasSuffix(p, ".html") {
77 return nil
78 }
79 if !strings.Contains(p, "/") {
80 return nil
81 }
82 errorPages = append(errorPages, p)
83 return nil
84 }); err != nil {
85 return nil, fmt.Errorf("walk templates: %w", err)
86 }
87
88 r := &Renderer{
89 pages: make(map[string]*template.Template, len(pageNames)+len(errorPages)),
90 octicon: opts.Octicons,
91 }
92
93 parse := func(displayName string, primary string) error {
94 t := template.New(path.Base(primary)).Funcs(funcMap(r.octicon))
95 all := append([]string{}, partialNames...)
96 all = append(all, primary)
97 parsed, err := t.ParseFS(tmplFS, all...)
98 if err != nil {
99 return fmt.Errorf("parse %s: %w", displayName, err)
100 }
101 r.pages[displayName] = parsed
102 return nil
103 }
104
105 for _, page := range pageNames {
106 if err := parse(strings.TrimSuffix(page, ".html"), page); err != nil {
107 return nil, err
108 }
109 }
110 for _, page := range errorPages {
111 if err := parse(strings.TrimSuffix(page, ".html"), page); err != nil {
112 return nil, err
113 }
114 }
115 return r, nil
116 }
117
118 // Render writes the named page to w using data as the template root context.
119 //
120 // Prefer RenderPage when a *http.Request is in scope — it auto-injects the
121 // viewer (current logged-in user) into map data so partials like _nav.html
122 // can branch on .Viewer without every handler remembering to thread it.
123 func (r *Renderer) Render(w io.Writer, name string, data any) error {
124 t, ok := r.pages[name]
125 if !ok {
126 return fmt.Errorf("render: unknown page %q", name)
127 }
128 var buf bytes.Buffer
129 if err := t.ExecuteTemplate(&buf, "layout", data); err != nil {
130 return fmt.Errorf("execute %s: %w", name, err)
131 }
132 _, err := w.Write(buf.Bytes())
133 return err
134 }
135
136 // RenderPage is the request-aware Render: when data is a map[string]any, it
137 // injects "Viewer" (from middleware.CurrentUserFromContext) and "CSRFToken"
138 // (the per-request token) if the caller hasn't set them. The nav partial's
139 // sign-out form uses the token, so every layout-rendered page needs it.
140 // Typed-struct callers must include those fields themselves — we don't
141 // reflect-mutate to avoid surprising aliasing.
142 func (r *Renderer) RenderPage(w io.Writer, req *http.Request, name string, data any) error {
143 if m, ok := data.(map[string]any); ok {
144 if _, present := m["Viewer"]; !present {
145 m["Viewer"] = middleware.CurrentUserFromContext(req.Context())
146 }
147 if _, present := m["CSRFToken"]; !present {
148 m["CSRFToken"] = middleware.CSRFTokenForRequest(req)
149 }
150 data = m
151 }
152 return r.Render(w, name, data)
153 }
154
155 // HTTPError writes an error page with the appropriate status code. If the
156 // named error template doesn't exist a plain-text fallback is written.
157 func (r *Renderer) HTTPError(w http.ResponseWriter, req *http.Request, status int, message string) {
158 pageName := errorPageFor(status)
159 w.Header().Set("Content-Type", "text/html; charset=utf-8")
160 w.Header().Set("Cache-Control", "no-store")
161 w.WriteHeader(status)
162
163 data := struct {
164 Title string
165 Status int
166 StatusText string
167 Message string
168 RequestID string
169 }{
170 Title: fmt.Sprintf("%d %s", status, http.StatusText(status)),
171 Status: status,
172 StatusText: http.StatusText(status),
173 Message: message,
174 RequestID: middleware.RequestIDFromContext(req.Context()),
175 }
176 if err := r.Render(w, pageName, data); err != nil {
177 _, _ = fmt.Fprintf(w, "%d %s\n%s\n(request_id=%s)\n",
178 status, http.StatusText(status), message, data.RequestID)
179 }
180 }
181
182 func errorPageFor(status int) string {
183 switch status {
184 case http.StatusForbidden:
185 return "errors/403"
186 case http.StatusNotFound:
187 return "errors/404"
188 case http.StatusTooManyRequests:
189 return "errors/429"
190 default:
191 return "errors/500"
192 }
193 }
194
195 func funcMap(octicon OcticonResolver) template.FuncMap {
196 return template.FuncMap{
197 // safeHTML embeds trusted HTML directly. Callers MUST ensure the
198 // input is server-controlled — never user input. S25's markdown
199 // pipeline supplies the canonical helper for user content.
200 "safeHTML": func(s string) template.HTML {
201 return template.HTML(s) //nolint:gosec // trusted-input only
202 },
203 // relativeTime renders a "2 hours ago" / "yesterday" / "Mar 5"
204 // style label. Used wherever timestamps appear in UI.
205 "relativeTime": relativeTime,
206 // pluralize picks the singular or plural form based on count.
207 "pluralize": func(count int, one, many string) string {
208 if count == 1 {
209 return one
210 }
211 return many
212 },
213 // pathJoin builds URL paths with a single leading slash.
214 "pathJoin": func(parts ...string) string {
215 joined := path.Join(parts...)
216 if !strings.HasPrefix(joined, "/") {
217 return "/" + joined
218 }
219 return joined
220 },
221 // octicon resolves a named octicon to inline SVG. Returns empty
222 // HTML if the icon isn't registered (the caller's template stays
223 // valid but renders nothing — better than a build-time crash).
224 "octicon": func(name string) template.HTML {
225 if octicon == nil {
226 return ""
227 }
228 if html, ok := octicon(name); ok {
229 return html
230 }
231 return ""
232 },
233 // csrfToken pulls the per-request token from the request context.
234 // Templates use this in <input type="hidden" name="csrf_token">.
235 "csrfToken": middleware.CSRFTokenForRequest,
236 // dict builds a map for partial-template includes that need
237 // multiple named values (idiomatic Go template trick).
238 // add / sub are tiny integer helpers used by pagination
239 // templates (next/prev page links). Templates can't do
240 // arithmetic, so the helpers earn their keep here.
241 "add": func(a, b int) int { return a + b },
242 "sub": func(a, b int) int { return a - b },
243 "dict": func(values ...any) (map[string]any, error) {
244 if len(values)%2 != 0 {
245 return nil, fmt.Errorf("dict: odd number of args")
246 }
247 m := make(map[string]any, len(values)/2)
248 for i := 0; i < len(values); i += 2 {
249 key, ok := values[i].(string)
250 if !ok {
251 return nil, fmt.Errorf("dict: non-string key at %d", i)
252 }
253 m[key] = values[i+1]
254 }
255 return m, nil
256 },
257 }
258 }
259
260 // relativeTime returns a human-readable relative-time string. The intent is
261 // to read naturally; absolute precision below the level of "minutes" isn't
262 // useful for UI labels.
263 func relativeTime(t time.Time) string {
264 if t.IsZero() {
265 return ""
266 }
267 d := time.Since(t)
268 switch {
269 case d < 0:
270 // Future timestamps are uncommon; render as absolute.
271 return t.UTC().Format("Jan 2, 2006")
272 case d < time.Minute:
273 return "just now"
274 case d < time.Hour:
275 m := int(d / time.Minute)
276 return fmt.Sprintf("%d minute%s ago", m, plural(m))
277 case d < 24*time.Hour:
278 h := int(d / time.Hour)
279 return fmt.Sprintf("%d hour%s ago", h, plural(h))
280 case d < 7*24*time.Hour:
281 days := int(d / (24 * time.Hour))
282 if days == 1 {
283 return "yesterday"
284 }
285 return fmt.Sprintf("%d days ago", days)
286 case d < 30*24*time.Hour:
287 w := int(d / (7 * 24 * time.Hour))
288 return fmt.Sprintf("%d week%s ago", w, plural(w))
289 case d < 365*24*time.Hour:
290 mo := int(d / (30 * 24 * time.Hour))
291 return fmt.Sprintf("%d month%s ago", mo, plural(mo))
292 default:
293 return t.UTC().Format("Jan 2, 2006")
294 }
295 }
296
297 func plural(n int) string {
298 if n == 1 {
299 return ""
300 }
301 return "s"
302 }
303