From 8fff48b81813eccc3ac1e5ff5ec8ba66e8a1cd20 Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Sat, 20 Jun 2026 18:11:00 -0400 Subject: [PATCH] Cache templates --- internal/web/render_test.go | 45 ++++++++++++++++++++++++ internal/web/web.go | 70 ++++++++++++++++++++++++++++--------- 2 files changed, 99 insertions(+), 16 deletions(-) create mode 100644 internal/web/render_test.go diff --git a/internal/web/render_test.go b/internal/web/render_test.go new file mode 100644 index 0000000..973b19e --- /dev/null +++ b/internal/web/render_test.go @@ -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) + } + } + } +} diff --git a/internal/web/web.go b/internal/web/web.go index 3e0dc69..ab50d23 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -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"