coverage~bigbes/sr-ht-ecore3bd158fbpages/pages.go

Coverage
85.0% 68/80 statements
Δ
Blob
c8b24d4
1 // Package pages is the shared page-template machinery for the custom services
2 // of a self-hosted SourceHut instance (compare, dolt, cover, bench, tokens).
3 //
4 // Every one of those services grew the same web/templates.go: discover the page
5 // templates next to a shared layout, parse one template set per page, refuse a
6 // page that defines no "content", execute into a buffer and only then write the
7 // response, and render one error page for every status the surface refuses
8 // with. The copies drifted in the ways copies do — one kept a hand-maintained
9 // list of page names instead of reading the directory, one wrote the template
10 // error into the response body, one skipped the content check entirely — and
11 // each drift is a different way to serve a viewer a page that is silently
12 // wrong. This package is the one copy, and it keeps the two refusals the
13 // donors argued for at length:
14 //
15 // - A page that defines no "content" is a startup error. Executed, it would
16 // render the chrome around an empty hole and answer 200, and a blank page
17 // is the one failure a viewer cannot report usefully. This is also why the
18 // layout must invoke the hole with {{template "content" .}} and never with
19 // {{block "content" .}}: `block` defines the name it invokes, which would
20 // hand every page an empty default at once and silently disarm the check.
21 //
22 // - A render goes into a buffer first. html/template writes as it evaluates,
23 // so executing straight into the ResponseWriter commits the status line and
24 // however many kilobytes of chrome were already produced before reaching
25 // the expression that fails. Buffering costs one page of memory and turns
26 // that into a clean 500.
27 //
28 // Usage, at startup:
29 //
30 // set, err := pages.Load(tmplFS, pages.Options{Funcs: myHelpers})
31 //
32 // and in a handler:
33 //
34 // if err := s.pages.Render(w, http.StatusOK, "index", vd); err != nil {
35 // slog.ErrorContext(r.Context(), "render failed",
36 // "method", r.Method, "path", r.URL.Path, scribe.Err(err))
37 // }
38 //
39 // Render answers the response itself in every case, so a returned error is for
40 // the log and never for a second answer — see Render.
41 //
42 // What stays in the services: the mapping from their own domain sentinels onto
43 // HTTP statuses (each service's `fail`). Two surfaces of one service must agree
44 // about which object exists, and that agreement is a property of that service's
45 // domain, not of this package.
46 package pages
47
48 import (
49 "bytes"
50 "errors"
51 "fmt"
52 "html/template"
53 "io/fs"
54 "maps"
55 "net/http"
56 "strings"
57
58 "sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
59 )
60
61 // Defaults for Options, and the file extension a page or partial is recognised
62 // by. The extension is not configurable: every service in this family writes
63 // .html, and a second spelling would only make the discovery rule harder to
64 // read than the list it replaces.
65 const (
66 DefaultDir = "templates"
67 DefaultLayout = "layout.html"
68 DefaultPartialPrefix = "_"
69 DefaultContentBlock = "content"
70
71 pageExt = ".html"
72 )
73
74 // ErrNoContent is returned by Load for a page template that defines no content
75 // block. It is a sentinel rather than a bare string because it is the one Load
76 // failure that is a template-authoring mistake and not a build or packaging
77 // one, and a service that wants to say so in its startup message needs to tell
78 // them apart.
79 var ErrNoContent = errors.New("pages: no content block")
80
81 // ErrUnknownPage is returned by Render when the name is not in the set. It is
82 // always a bug in the calling package — the set is built from the files that
83 // exist — so it is worth recognising in a log filter.
84 var ErrUnknownPage = errors.New("pages: unknown page")
85
86 // internalServerError is the body of every 500 this package writes itself. It
87 // says nothing, deliberately: the error it stands for names templates, fields
88 // and payload types, and the dolt donor published exactly that string to the
89 // browser.
90 const internalServerError = "internal server error"
91
92 // Options configures Load. The zero value is the layout of every service in
93 // this family: templates/layout.html, partials prefixed with '_', a "content"
94 // block, and the shared chrome helpers.
95 type Options struct {
96 // Dir is the directory inside the FS holding the layout, the partials and
97 // the pages. Defaults to DefaultDir.
98 Dir string
99
100 // Layout is the outer chrome every page is executed through, by file name.
101 // Defaults to DefaultLayout.
102 Layout string
103
104 // PartialPrefix marks the files in Dir that are fragments rather than
105 // pages. Defaults to DefaultPartialPrefix.
106 //
107 // A partial is parsed into *every* page's set, not only into the pages that
108 // invoke it today: a partial known only to the pages that used it on the
109 // day it was written is a lookup failure on the page that needs it next.
110 PartialPrefix string
111
112 // ContentBlock is the one block a page must define. Defaults to
113 // DefaultContentBlock.
114 ContentBlock string
115
116 // Funcs are the service's own template helpers. They are merged over
117 // chrome.Funcs, in that order, so the shared partials always find the
118 // helpers they were written against and a service can still shadow one
119 // deliberately rather than by accident of map ordering.
120 Funcs template.FuncMap
121 }
122
123 // withDefaults fills the unset fields. It works on a copy: Load must not
124 // rewrite the caller's struct, which is usually a literal at a call site that
125 // documents what the service actually chose.
126 13 func (o Options) withDefaults() Options {
127 13 if o.Dir == "" {
128 13 o.Dir = DefaultDir
129 13 }
130 13 if o.Layout == "" {
131 13 o.Layout = DefaultLayout
132 13 }
133 13 if o.PartialPrefix == "" {
134 13 o.PartialPrefix = DefaultPartialPrefix
135 13 }
136 13 if o.ContentBlock == "" {
137 13 o.ContentBlock = DefaultContentBlock
138 13 }
139 13 return o
140 }
141
142 // funcs is the merged helper map, chrome's first and the service's on top.
143 13 func (o Options) funcs() template.FuncMap {
144 13 m := chrome.Funcs()
145 13 maps.Copy(m, o.Funcs)
146 13 return m
147 13 }
148
149 // A Set maps a page name — the template file's name without its extension — to
150 // the template set that renders it: the layout, the shared chrome partials, the
151 // shipped error partial, every local partial, and that one page's content.
152 //
153 // Every page gets its own set rather than all of them sharing one, because each
154 // defines "content" and a shared set would let the last one parsed win.
155 type Set map[string]*template.Template
156
157 // Load parses one template set per content page found in the FS.
158 //
159 // Pages are discovered from the directory rather than listed in a slice, so
160 // adding templates/whatever.html is the whole registration of a page. The
161 // alternative — the static list the compare donor kept — is one more edit to
162 // forget, and forgetting it yields a page that 500s with "unknown template"
163 // while the file sits right there in the tree.
164 //
165 // A page that defines no content block is refused here, at startup, for the
166 // reason given in the package doc.
167 //
168 // The set always has an "error" page: the one this package ships (error.html),
169 // unless the FS carries an error.html of its own, which then wins whole. Either
170 // way the "srht-error" partial is parsed into every set, so a service that
171 // wants its own error page around the standard body can invoke it rather than
172 // copy it.
173 //
174 // It takes the FS rather than an embed.FS so a test can hand it a bad tree: the
175 // refusals above are the whole point of this function and none of them is
176 // reachable through a service's own embedded templates, which are exactly the
177 // files it ships and is expected to keep valid.
178 13 func Load(fsys fs.FS, opts Options) (Set, error) {
179 13 opts = opts.withDefaults()
180 13 funcs := opts.funcs()
181 13
182 13 entries, err := fs.ReadDir(fsys, opts.Dir)
183 13 if err != nil {
184 0 return nil, fmt.Errorf("pages: read %s: %w", opts.Dir, err)
185 0 }
186
187 13 var pageNames, partials []string
188 13 layoutFound := false
189 51 for _, e := range entries {
190 51 name := e.Name()
191 51 switch {
192 0 case e.IsDir() || !strings.HasSuffix(name, pageExt):
193 0 continue
194 12 case name == opts.Layout:
195 12 layoutFound = true
196 13 case strings.HasPrefix(name, opts.PartialPrefix):
197 13 partials = append(partials, opts.Dir+"/"+name)
198 26 default:
199 26 pageNames = append(pageNames, name)
200 }
201 }
202 13 if !layoutFound {
203 1 // Reported here rather than left to ParseFS, whose message for a pattern
204 1 // that matches nothing does not mention that the missing file is the
205 1 // layout every page is executed through.
206 1 return nil, fmt.Errorf("pages: no layout %s in %s", opts.Layout, opts.Dir)
207 1 }
208 12 if len(pageNames) == 0 {
209 1 // Only reachable if an embed pattern stops matching, which is a build
210 1 // change and not a runtime condition; it is checked because the symptom
211 1 // otherwise is a daemon that starts happily and 500s on every route.
212 1 return nil, fmt.Errorf("pages: no page templates in %s", opts.Dir)
213 1 }
214
215 11 set := make(Set, len(pageNames)+1)
216 22 for _, page := range pageNames {
217 22 defines, err := opts.definesContent(fsys, page, funcs)
218 22 if err != nil {
219 0 return nil, err
220 0 }
221 22 if !defines {
222 1 return nil, fmt.Errorf("pages: template %s defines no %q block: %w",
223 1 page, opts.ContentBlock, ErrNoContent)
224 1 }
225
226 21 t, err := opts.base(funcs)
227 21 if err != nil {
228 0 return nil, err
229 0 }
230 // The layout first and the page last, so that a page's content wins over
231 // any default the layout may carry for it: text/template keeps the last
232 // definition of a name it parses.
233 21 files := append([]string{opts.Dir + "/" + opts.Layout}, partials...)
234 21 files = append(files, opts.Dir+"/"+page)
235 21 if _, err := t.ParseFS(fsys, files...); err != nil {
236 0 return nil, fmt.Errorf("pages: parse template %s: %w", page, err)
237 0 }
238 21 set[strings.TrimSuffix(page, pageExt)] = t
239 }
240
241 10 if _, ok := set[ErrorPage]; !ok {
242 9 t, err := opts.base(funcs)
243 9 if err != nil {
244 0 return nil, err
245 0 }
246 9 files := append([]string{opts.Dir + "/" + opts.Layout}, partials...)
247 9 if _, err := t.ParseFS(fsys, files...); err != nil {
248 0 return nil, fmt.Errorf("pages: parse the layout for the error page: %w", err)
249 0 }
250 9 if _, err := t.ParseFS(sharedFS, sharedDir+"/"+errorPageFile); err != nil {
251 0 return nil, fmt.Errorf("pages: parse the shipped error page: %w", err)
252 0 }
253 9 set[ErrorPage] = t
254 }
255 10 return set, nil
256 }
257
258 // base is the empty set every page starts from: the funcs, the shared chrome
259 // partials, and this package's own.
260 //
261 // The chrome partials go in through chrome.Attach rather than
262 // chrome.MustAttach: a parse failure in somebody else's module is still a
263 // startup error the daemon should report with a sentence naming what it was
264 // doing, and Load already returns an error for everything else that can go
265 // wrong here.
266 30 func (o Options) base(funcs template.FuncMap) (*template.Template, error) {
267 30 // Attach first, the service's funcs second: Attach installs chrome's own
268 30 // helpers so its partials can parse, and layering the service's map on top
269 30 // afterwards is what lets a service shadow one of them deliberately.
270 30 t, err := chrome.Attach(template.New(o.Layout))
271 30 if err != nil {
272 0 return nil, fmt.Errorf("pages: attach the shared chrome partials: %w", err)
273 0 }
274 30 t = t.Funcs(funcs)
275 30 if _, err := t.ParseFS(sharedFS, sharedDir+"/"+errorPartialFile); err != nil {
276 0 return nil, fmt.Errorf("pages: parse the shared error partial: %w", err)
277 0 }
278 30 return t, nil
279 }
280
281 // definesContent reports whether a page's own file defines the content block.
282 //
283 // It parses the page alone, without the layout and without the partials, and
284 // that separate parse is the whole of what this function is for. The obvious
285 // check — looking "content" up in the assembled set — only works while
286 // layout.html spells its hole {{template "content" .}} and its neighbours are
287 // {{block "head" .}} and {{block "scripts" .}}: `block` *defines* the name it
288 // invokes, so the day somebody makes the three consistent — a tidying edit no
289 // reviewer would question — Lookup starts finding the layout's own empty
290 // default on every page and the guard silently stops guarding. What comes back
291 // then is the failure it exists to prevent: the chrome around an empty hole,
292 // answered 200. Parsed on its own a page has only what it defines itself, and
293 // no edit to the layout can reach that.
294 //
295 // The cost is one extra parse per page, once, at startup.
296 22 func (o Options) definesContent(fsys fs.FS, page string, funcs template.FuncMap) (bool, error) {
297 22 t := template.New(page).Funcs(funcs)
298 22 if _, err := t.ParseFS(fsys, o.Dir+"/"+page); err != nil {
299 0 return false, fmt.Errorf("pages: parse template %s: %w", page, err)
300 0 }
301 22 return t.Lookup(o.ContentBlock) != nil, nil
302 }
303
304 // Render executes a page and writes it.
305 //
306 // The execution goes into a buffer first, and that is the whole point of this
307 // function: a template that fails halfway has otherwise already written a
308 // partial page under a 200 that cannot be taken back, and a viewer cannot tell
309 // half a document from a page that is genuinely that short. On some of these
310 // surfaces the missing half is the one carrying a secret that will never be
311 // shown again.
312 //
313 // Render answers the response in every case, and the contract that follows from
314 // that is the important half of this comment: **a returned error means the
315 // response has already been answered**, so the caller must log it and nothing
316 // else. Handing it back to a `fail` that renders an error page would either
317 // write a second response over a committed one, or — when the failure is in the
318 // error page itself — recurse until the stack runs out. It is returned rather
319 // than logged here because this package has no opinion about the caller's
320 // logger, and the callers have three between them.
321 //
322 // What is answered on failure is a bare 500 carrying a fixed string. The dolt
323 // donor wrote "template render error: "+err.Error() into the body instead,
324 // which publishes template names, field paths and whatever the payload's
325 // String method produces to whoever asked for the page.
326 8 func (s Set) Render(w http.ResponseWriter, status int, name string, data any) error {
327 8 t, ok := s[name]
328 8 if !ok {
329 1 // A page name that is not a template is a bug in the calling package —
330 1 // the set is built from the files that exist — so it is answered as the
331 1 // 500 it is.
332 1 http.Error(w, internalServerError, http.StatusInternalServerError)
333 1 return fmt.Errorf("pages: page %q: %w", name, ErrUnknownPage)
334 1 }
335
336 // t.Execute and not ExecuteTemplate(layout): Load names every set after the
337 // layout it parses, so t *is* the layout, and naming it again here would be
338 // a second place for Options.Layout to be spelled.
339 7 var buf bytes.Buffer
340 7 if err := t.Execute(&buf, data); err != nil {
341 1 http.Error(w, internalServerError, http.StatusInternalServerError)
342 1 return fmt.Errorf("pages: execute template %q: %w", name, err)
343 1 }
344
345 6 w.Header().Set("Content-Type", "text/html; charset=utf-8")
346 6 w.WriteHeader(status)
347 6 if _, err := buf.WriteTo(w); err != nil {
348 0 // The viewer hung up mid-response. Nothing left to answer with and the
349 0 // read is already done, so this is a log line — which is what every
350 0 // error out of here is.
351 0 return fmt.Errorf("pages: write the %d page: %w", status, err)
352 0 }
353 6 return nil
354 }