Go · 2650 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 will broaden this with helpers (relativeTime, urlFor, octicon, etc.);
5 // S00 ships only what the hello page needs.
6 package render
7
8 import (
9 "bytes"
10 "fmt"
11 "html/template"
12 "io"
13 "io/fs"
14 "path"
15 "strings"
16 )
17
18 // Renderer holds parsed templates indexed by page name.
19 type Renderer struct {
20 pages map[string]*template.Template
21 }
22
23 // New parses every page template under tmplFS. A "page template" is any file
24 // at the root of tmplFS that does NOT begin with an underscore. Files that
25 // begin with an underscore (e.g. "_layout.html") are partials, parsed once
26 // into every page.
27 func New(tmplFS fs.FS) (*Renderer, error) {
28 entries, err := fs.ReadDir(tmplFS, ".")
29 if err != nil {
30 return nil, fmt.Errorf("read template root: %w", err)
31 }
32
33 var (
34 partialNames []string
35 pageNames []string
36 )
37 for _, e := range entries {
38 if e.IsDir() {
39 continue
40 }
41 name := e.Name()
42 if !strings.HasSuffix(name, ".html") {
43 continue
44 }
45 if strings.HasPrefix(name, "_") {
46 partialNames = append(partialNames, name)
47 } else {
48 pageNames = append(pageNames, name)
49 }
50 }
51
52 r := &Renderer{pages: make(map[string]*template.Template, len(pageNames))}
53 for _, page := range pageNames {
54 t := template.New(page).Funcs(funcMap())
55 all := append([]string{}, partialNames...)
56 all = append(all, page)
57 // Convert to filesystem paths.
58 for i := range all {
59 all[i] = path.Clean(all[i])
60 }
61 parsed, err := t.ParseFS(tmplFS, all...)
62 if err != nil {
63 return nil, fmt.Errorf("parse %s: %w", page, err)
64 }
65 r.pages[strings.TrimSuffix(page, ".html")] = parsed
66 }
67 return r, nil
68 }
69
70 // Render writes the named page to w using data as the template root context.
71 // The page's templates execute the layout via {{ template "layout" . }}.
72 func (r *Renderer) Render(w io.Writer, name string, data any) error {
73 t, ok := r.pages[name]
74 if !ok {
75 return fmt.Errorf("render: unknown page %q", name)
76 }
77 // Pages declare a "page" block; the layout calls into it.
78 var buf bytes.Buffer
79 if err := t.ExecuteTemplate(&buf, "layout", data); err != nil {
80 return fmt.Errorf("execute %s: %w", name, err)
81 }
82 _, err := w.Write(buf.Bytes())
83 return err
84 }
85
86 func funcMap() template.FuncMap {
87 return template.FuncMap{
88 // safeHTML embeds trusted HTML directly. Callers MUST ensure the
89 // input is server-controlled — never user input. S25's markdown
90 // pipeline supplies the canonical helper for user content.
91 "safeHTML": func(s string) template.HTML {
92 return template.HTML(s) // #nosec G203 — trusted-input only
93 },
94 }
95 }
96