refactor: use go:embed for templates

Templates are now embedded using //go:embed and parsed once at startup
with template.Must(template.ParseFS(...)). This avoids re-parsing
template files from disk on every request and removes the dependency
on template files being present at runtime.

closes #7
This commit is contained in:
clawbot
2026-03-01 15:47:22 -08:00
committed by user
parent 7bbe47b943
commit d4eef6bd6a
6 changed files with 56 additions and 36 deletions

View File

@@ -21,7 +21,7 @@ func (h *Handlers) HandleLoginPage() http.HandlerFunc {
"Error": "", "Error": "",
} }
h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data) h.renderTemplate(w, r, "login.html", data)
} }
} }
@@ -44,7 +44,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
"Error": "Username and password are required", "Error": "Username and password are required",
} }
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data) h.renderTemplate(w, r, "login.html", data)
return return
} }
@@ -56,7 +56,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
"Error": "Invalid username or password", "Error": "Invalid username or password",
} }
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data) h.renderTemplate(w, r, "login.html", data)
return return
} }
@@ -74,7 +74,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
"Error": "Invalid username or password", "Error": "Invalid username or password",
} }
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data) h.renderTemplate(w, r, "login.html", data)
return return
} }

View File

@@ -13,6 +13,7 @@ import (
"sneak.berlin/go/webhooker/internal/healthcheck" "sneak.berlin/go/webhooker/internal/healthcheck"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/session" "sneak.berlin/go/webhooker/internal/session"
"sneak.berlin/go/webhooker/templates"
) )
// nolint:revive // HandlersParams is a standard fx naming convention // nolint:revive // HandlersParams is a standard fx naming convention
@@ -26,11 +27,20 @@ type HandlersParams struct {
} }
type Handlers struct { type Handlers struct {
params *HandlersParams params *HandlersParams
log *slog.Logger log *slog.Logger
hc *healthcheck.Healthcheck hc *healthcheck.Healthcheck
db *database.Database db *database.Database
session *session.Session session *session.Session
templates map[string]*template.Template
}
// parsePageTemplate parses a page-specific template set from the embedded FS.
// Each page template is combined with the shared base, htmlheader, and navbar templates.
func parsePageTemplate(pageFile string) *template.Template {
return template.Must(
template.ParseFS(templates.Templates, "htmlheader.html", "navbar.html", "base.html", pageFile),
)
} }
func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) { func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
@@ -40,9 +50,16 @@ func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
s.hc = params.Healthcheck s.hc = params.Healthcheck
s.db = params.Database s.db = params.Database
s.session = params.Session s.session = params.Session
// Parse all page templates once at startup
s.templates = map[string]*template.Template{
"index.html": parsePageTemplate("index.html"),
"login.html": parsePageTemplate("login.html"),
"profile.html": parsePageTemplate("profile.html"),
}
lc.Append(fx.Hook{ lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error { OnStart: func(ctx context.Context) error {
// FIXME compile some templates here or something
return nil return nil
}, },
}) })
@@ -80,16 +97,11 @@ type UserInfo struct {
Username string Username string
} }
// renderTemplate renders a template with common data // renderTemplate renders a pre-parsed template with common data
func (s *Handlers) renderTemplate(w http.ResponseWriter, r *http.Request, templateFiles []string, data interface{}) { func (s *Handlers) renderTemplate(w http.ResponseWriter, r *http.Request, pageTemplate string, data interface{}) {
// Always include the common templates tmpl, ok := s.templates[pageTemplate]
allTemplates := []string{"templates/htmlheader.html", "templates/navbar.html"} if !ok {
allTemplates = append(allTemplates, templateFiles...) s.log.Error("template not found", "template", pageTemplate)
// Parse templates
tmpl, err := template.ParseFiles(allTemplates...)
if err != nil {
s.log.Error("failed to parse template", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
@@ -108,6 +120,16 @@ func (s *Handlers) renderTemplate(w http.ResponseWriter, r *http.Request, templa
} }
} }
// If data is a map, merge user info into it
if m, ok := data.(map[string]interface{}); ok {
m["User"] = userInfo
if err := tmpl.Execute(w, m); err != nil {
s.log.Error("failed to execute template", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
// Wrap data with base template data // Wrap data with base template data
type templateDataWrapper struct { type templateDataWrapper struct {
User *UserInfo User *UserInfo
@@ -119,17 +141,6 @@ func (s *Handlers) renderTemplate(w http.ResponseWriter, r *http.Request, templa
Data: data, Data: data,
} }
// If data is a map, merge user info into it
if m, ok := data.(map[string]interface{}); ok {
m["User"] = userInfo
if err := tmpl.Execute(w, m); err != nil {
s.log.Error("failed to execute template", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
// Otherwise use wrapper
if err := tmpl.Execute(w, wrapper); err != nil { if err := tmpl.Execute(w, wrapper); err != nil {
s.log.Error("failed to execute template", "error", err) s.log.Error("failed to execute template", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)

View File

@@ -87,10 +87,11 @@ func TestRenderTemplate(t *testing.T) {
"Version": "1.0.0", "Version": "1.0.0",
} }
// When templates don't exist, renderTemplate should return an error // When a non-existent template name is requested, renderTemplate
h.renderTemplate(w, req, []string{"nonexistent.html"}, data) // should return an internal server error
h.renderTemplate(w, req, "nonexistent.html", data)
// Should return internal server error when template parsing fails // Should return internal server error when template is not found
assert.Equal(t, http.StatusInternalServerError, w.Code) assert.Equal(t, http.StatusInternalServerError, w.Code)
}) })
} }

View File

@@ -34,7 +34,7 @@ func (s *Handlers) HandleIndex() http.HandlerFunc {
} }
// Render the template // Render the template
s.renderTemplate(w, req, []string{"templates/base.html", "templates/index.html"}, data) s.renderTemplate(w, req, "index.html", data)
} }
} }

View File

@@ -54,6 +54,6 @@ func (h *Handlers) HandleProfile() http.HandlerFunc {
} }
// Render the profile page // Render the profile page
h.renderTemplate(w, r, []string{"templates/base.html", "templates/profile.html"}, data) h.renderTemplate(w, r, "profile.html", data)
} }
} }

8
templates/templates.go Normal file
View File

@@ -0,0 +1,8 @@
package templates
import (
"embed"
)
//go:embed *.html
var Templates embed.FS