Cache templates

This commit is contained in:
Jonathon Leight
2026-06-20 18:11:00 -04:00
parent 9cb3411726
commit 8fff48b818
2 changed files with 99 additions and 16 deletions
+45
View File
@@ -0,0 +1,45 @@
package web
import (
"io"
"strings"
"testing"
)
// TestBuildPagesComposeAndExecute verifies every content page is pre-built with
// the shared layouts and can execute against each root layout without a parse
// or template-resolution error. This guards the startup-time template
// composition (each page redefines content/title/header onto the base set).
func TestBuildPagesComposeAndExecute(t *testing.T) {
pages, err := buildPages()
if err != nil {
t.Fatalf("buildPages: %v", err)
}
if len(pages) == 0 {
t.Fatal("buildPages returned no pages")
}
// The shared partials are not pages and must not be rendered directly.
for _, name := range []string{"base.html", "icons.html"} {
if _, ok := pages[name]; ok {
t.Errorf("%s should not be registered as a page", name)
}
}
layouts := []string{"base", "authbase", "landingbase"}
for name, tmpl := range pages {
for _, layout := range layouts {
if tmpl.Lookup(layout) == nil {
t.Errorf("page %s missing layout %q", name, layout)
continue
}
// Execute with empty data: this catches references to undefined
// partials (e.g. {{template "missing"}}). Runtime errors driven by
// missing data (e.g. len of an absent map key) are expected here and
// don't indicate a composition problem.
err := tmpl.ExecuteTemplate(io.Discard, layout, map[string]any{})
if err != nil && strings.Contains(err.Error(), "no such template") {
t.Errorf("execute %s with layout %q: %v", name, layout, err)
}
}
}
}
+54 -16
View File
@@ -28,12 +28,15 @@ var staticFS embed.FS
// Server holds the dependencies shared by HTTP handlers.
type Server struct {
store *store.Store
auth *auth.Service
identity *identity.Service
cfg *config.Config
templates *template.Template
router chi.Router
store *store.Store
auth *auth.Service
identity *identity.Service
cfg *config.Config
// pages holds each content page pre-composed with the shared layouts and
// partials, keyed by file name (e.g. "dashboard.html"). Built once at
// startup since the templates are embedded and never change at runtime.
pages map[string]*template.Template
router chi.Router
// lookupTXT resolves DNS TXT records; injectable so domain verification is
// testable. Defaults to net.LookupTXT.
lookupTXT func(name string) ([]string, error)
@@ -41,15 +44,55 @@ type Server struct {
// NewServer constructs the HTTP server and its routes.
func NewServer(st *store.Store, authSvc *auth.Service, idSvc *identity.Service, cfg *config.Config) (*Server, error) {
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
pages, err := buildPages()
if err != nil {
return nil, err
}
s := &Server{store: st, auth: authSvc, identity: idSvc, cfg: cfg, templates: tmpl, lookupTXT: net.LookupTXT}
s := &Server{store: st, auth: authSvc, identity: idSvc, cfg: cfg, pages: pages, lookupTXT: net.LookupTXT}
s.routes()
return s, nil
}
// sharedTemplates are the layouts and partials shared by every page (the root
// layouts live in base.html; reusable snippets like the icon set in icons.html).
// They define no "content"/"title" blocks of their own, so they can be the
// common base each page is composed onto.
var sharedTemplates = []string{"templates/base.html", "templates/icons.html"}
// buildPages composes each content page with the shared layouts/partials once,
// returning a map keyed by the page's file name. Each page redefines the
// "content"/"title"/"header" blocks, so every page needs its own template set
// rather than one shared set (where the blocks would collide).
func buildPages() (map[string]*template.Template, error) {
base, err := template.New("").ParseFS(templatesFS, sharedTemplates...)
if err != nil {
return nil, err
}
all, err := fs.Glob(templatesFS, "templates/*.html")
if err != nil {
return nil, err
}
shared := map[string]bool{}
for _, p := range sharedTemplates {
shared[p] = true
}
pages := map[string]*template.Template{}
for _, p := range all {
if shared[p] {
continue
}
clone, err := base.Clone()
if err != nil {
return nil, err
}
if _, err := clone.ParseFS(templatesFS, p); err != nil {
return nil, err
}
pages[strings.TrimPrefix(p, "templates/")] = clone
}
return pages, nil
}
// Handler returns the root HTTP handler.
func (s *Server) Handler() http.Handler { return s.router }
@@ -188,14 +231,9 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, dat
data["CanAdmin"] = u.CapManageUsers || u.CapManageCatalog
}
}
// Clone so we can associate the page's blocks without mutating the shared set.
t, err := s.templates.Clone()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if _, err := t.ParseFS(templatesFS, "templates/"+page); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
t, ok := s.pages[page]
if !ok {
http.Error(w, "unknown page: "+page, http.StatusInternalServerError)
return
}
// Pages may opt into an alternate root layout (e.g. the centered "authbase"