view.go
240 lines1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
package application
import (
"context"
"fmt"
"html/template"
"io/fs"
"log"
"maps"
"net/http"
"os"
"path/filepath"
"strings"
)
// ViewEngine owns Congo's template machinery: the embedded views filesystem, the
// base templates (layouts + partials) parsed once at boot, the registered
// template functions, and the per-request nonce source. App embeds a
// *ViewEngine, so all of this stays out of app.go and the application core stays
// thin — App is the router and controller registry; ViewEngine is the renderer.
//
// Rendering is pure memory work. Templates are parsed once at boot and held in
// memory, so a render never touches disk; combined with an embedded, in-memory
// LibSQL read path, a full page render — layout plus controller method calls
// plus their queries — is CPU and RAM only, with no IO. That is what makes the
// lazy "{{auth.CurrentUser}}" pattern cheap even when it fans out into several
// reads per page.
type ViewEngine struct {
fs fs.FS
base *template.Template
funcs template.FuncMap
// nonce returns the per-request CSP nonce from a context. It defaults to
// returning "" and is set via WithNonce (whose signature matches
// security.NonceFrom), so application does not import security to read it.
nonce func(context.Context) string
// ScriptFunc renders the frontend <script> tags for a nonce. The frontend
// package sets it; it stays nil when no bundler is wired.
ScriptFunc func(nonce string) template.HTML
// parseErrors collects templates that failed to parse at boot — for example a
// layout that references a controller which has since been deleted, so its
// {{gone.Method}} no longer resolves to a known function. Validate surfaces
// them alongside reference issues so a broken build fails at boot rather than
// silently dropping a template.
parseErrors []error
}
// newViewEngine creates an empty engine over a views filesystem. Base templates
// are parsed later, in New, once options have registered every func and
// controller, so the placeholder function set the parser sees is complete.
func newViewEngine(views fs.FS) *ViewEngine {
return &ViewEngine{
fs: views,
funcs: template.FuncMap{},
nonce: func(context.Context) string { return "" },
}
}
// templateFuncs returns the built-in funcs plus every user-registered func. It
// seeds the FuncMap the base templates are parsed with, so parsing never fails on
// an unknown function name.
func (ve *ViewEngine) templateFuncs() template.FuncMap {
funcs := template.FuncMap{
"dict": func(values ...any) map[string]any {
if len(values)%2 != 0 {
return nil
}
m := make(map[string]any, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
continue
}
m[key] = values[i+1]
}
return m
},
}
maps.Copy(funcs, ve.funcs)
return funcs
}
// parse loads layouts and partials from the filesystem into the base template.
// controllerNames seed placeholder accessor funcs so templates that call
// {{home.Method}} parse; the real, request-bound versions are injected per
// render. Called once, at boot.
func (ve *ViewEngine) parse(controllerNames []string) {
funcs := ve.templateFuncs()
for _, name := range controllerNames {
funcs[name] = func() Controller { return nil }
}
funcs["nonce"] = func() string { return "" }
funcs["frontend_script"] = func() template.HTML { return "" }
tmpl := template.New("").Funcs(funcs)
for _, dir := range []string{"views/layouts", "views/partials"} {
if err := fs.WalkDir(ve.fs, dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(path, ".html") {
return nil
}
content, err := fs.ReadFile(ve.fs, path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
name := filepath.Base(path)
if _, err := tmpl.New(name).Parse(string(content)); err != nil {
ve.parseErrors = append(ve.parseErrors, fmt.Errorf("failed to parse %q: %w", name, err))
}
return nil
}); err != nil {
log.Printf("Warning: could not load templates from %s: %v", dir, err)
}
}
ve.base = tmpl
}
// loadView clones the base templates (layouts + partials) and parses the named
// view file into the clone. Cloning is required because executing a template
// marks it used and it cannot then be re-parsed.
func (ve *ViewEngine) loadView(name string) (*template.Template, error) {
tmpl, err := ve.base.Clone()
if err != nil {
return nil, err
}
if tmpl.Lookup(name) != nil {
return tmpl, nil
}
content, err := fs.ReadFile(ve.fs, "views/"+name)
if err != nil {
return nil, fmt.Errorf("view not found: %s", name)
}
_, err = tmpl.New(name).Parse(string(content))
return tmpl, err
}
// RenderToString renders a template to a string with no request context, so it
// injects no controller accessors — use it for partials and SSE fragments that
// take their data explicitly. For request-bound rendering with {{ctrl.Method}}
// accessors, controllers call Render.
func (ve *ViewEngine) RenderToString(name string, data any) (string, error) {
tmpl, err := ve.base.Clone()
if err != nil {
return "", err
}
var buf strings.Builder
if t := tmpl.Lookup(name); t != nil {
if err := t.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
content, err := fs.ReadFile(ve.fs, "views/"+name)
if err != nil {
return "", fmt.Errorf("template not found: %s", name)
}
if _, err := tmpl.New(name).Parse(string(content)); err != nil {
return "", err
}
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
return "", err
}
return buf.String(), nil
}
// render executes a view for a request, injecting the request-bound controller
// accessors ({{auth.CurrentUser}}), the CSP nonce, and the frontend script tag,
// then writes the buffered HTML. Buffering means a template error never emits a
// half-written page. This lives on App because it needs the controller registry;
// the template mechanics it calls live on ViewEngine.
func (app *App) render(w http.ResponseWriter, r *http.Request, name string, data any) {
tmpl, err := app.loadView(name)
if err != nil {
log.Printf("Template load error: %v", err)
http.Error(w, "Template error", http.StatusInternalServerError)
return
}
funcs := template.FuncMap{}
for ctrlName := range app.controllers {
name := ctrlName
funcs[name] = func() Controller { return app.use(name, r) }
}
nonce := app.nonce(r.Context())
funcs["nonce"] = func() string { return nonce }
if app.ScriptFunc != nil {
funcs["frontend_script"] = func() template.HTML { return app.ScriptFunc(nonce) }
}
var buf strings.Builder
if err := tmpl.Funcs(funcs).ExecuteTemplate(&buf, name, data); err != nil {
log.Printf("Template error: %v", err)
http.Error(w, "Template error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(buf.String()))
}
// View is an http.Handler that renders a template, optionally gated by a Bouncer.
type View struct {
*App
template string
bouncer Bouncer
}
// ServeHTTP renders the view.
func (v *View) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if v.bouncer != nil && !v.bouncer(v.App, w, r) {
return
}
v.App.render(w, r, v.template, nil)
}
// Render executes a view template, writing HTML to the response.
func (c *BaseController) Render(w http.ResponseWriter, r *http.Request, templateName string, data any) {
c.App.render(w, r, templateName, data)
}
// RenderError renders an error message as an HTMX-friendly alert (status 200). In
// production the detail is logged and hidden so internal errors never leak.
func RenderError(w http.ResponseWriter, r *http.Request, err error) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
message := err.Error()
if os.Getenv("ENV") == "production" {
log.Printf("[error] %s %s: %v", r.Method, r.URL.Path, err)
message = "An error occurred. Please try again."
}
fmt.Fprintf(w, `<div class="alert alert-error">%s</div>`, template.HTMLEscapeString(message))
}
// RenderError delegates to the package-level RenderError.
func (c *BaseController) RenderError(w http.ResponseWriter, r *http.Request, err error) {
RenderError(w, r, err)
}