diff --git a/internal/server/server.go b/internal/server/server.go index bc3187b..8138ba9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -121,20 +121,12 @@ func New(cfg *config.Config, logger *slog.Logger) (*Server, error) { return nil, fmt.Errorf("verifying storage connectivity: %w", err) } - // Load templates - templates, err := NewTemplates() - if err != nil { - _ = store.Close() - _ = db.Close() - return nil, fmt.Errorf("loading templates: %w", err) - } - return &Server{ cfg: cfg, db: db, storage: store, logger: logger, - templates: templates, + templates: &Templates{}, }, nil } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 7e56f2c..02fa725 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -79,21 +79,13 @@ func newTestServer(t *testing.T) *testServer { r.Mount("/go", http.StripPrefix("/go", goHandler.Routes())) r.Mount("/pypi", http.StripPrefix("/pypi", pypiHandler.Routes())) - // Load templates - templates, err := NewTemplates() - if err != nil { - _ = db.Close() - _ = os.RemoveAll(tempDir) - t.Fatalf("failed to load templates: %v", err) - } - // Create a minimal server struct for the handlers s := &Server{ cfg: cfg, db: db, storage: store, logger: logger, - templates: templates, + templates: &Templates{}, } r.Get("/health", s.handleHealth) diff --git a/internal/server/templates.go b/internal/server/templates.go index 2d261a3..217da41 100644 --- a/internal/server/templates.go +++ b/internal/server/templates.go @@ -5,61 +5,70 @@ import ( "html/template" "net/http" "path/filepath" + "sync" ) //go:embed templates/**/*.html var templatesFS embed.FS -// Templates holds parsed templates for each page. +// Templates holds lazily-parsed templates for each page. type Templates struct { + once sync.Once pages map[string]*template.Template + err error } -// NewTemplates loads and parses all templates from the embedded filesystem. -func NewTemplates() (*Templates, error) { - pages := make(map[string]*template.Template) +// load parses all templates from the embedded filesystem on first call. +func (t *Templates) load() error { + t.once.Do(func() { + pages := make(map[string]*template.Template) - // Define custom template functions - funcMap := template.FuncMap{ - "add": func(a, b int) int { return a + b }, - "sub": func(a, b int) int { return a - b }, - "supportedEcosystems": supportedEcosystems, - "ecosystemBadgeClass": ecosystemBadgeClasses, - "ecosystemBadgeLabel": ecosystemBadgeLabel, - } - - // Get all page files - pageFiles, err := templatesFS.ReadDir("templates/pages") - if err != nil { - return nil, err - } - - for _, pageFile := range pageFiles { - if pageFile.IsDir() { - continue + funcMap := template.FuncMap{ + "add": func(a, b int) int { return a + b }, + "sub": func(a, b int) int { return a - b }, + "supportedEcosystems": supportedEcosystems, + "ecosystemBadgeClass": ecosystemBadgeClasses, + "ecosystemBadgeLabel": ecosystemBadgeLabel, } - pageName := pageFile.Name() - pageName = pageName[:len(pageName)-len(filepath.Ext(pageName))] - - // Parse all layout files + components + this page with custom functions - tmpl, err := template.New("").Funcs(funcMap).ParseFS(templatesFS, - "templates/layout/*.html", - "templates/components/*.html", - "templates/pages/"+pageFile.Name(), - ) + pageFiles, err := templatesFS.ReadDir("templates/pages") if err != nil { - return nil, err + t.err = err + return } - pages[pageName] = tmpl - } + for _, pageFile := range pageFiles { + if pageFile.IsDir() { + continue + } - return &Templates{pages: pages}, nil + pageName := pageFile.Name() + pageName = pageName[:len(pageName)-len(filepath.Ext(pageName))] + + tmpl, err := template.New("").Funcs(funcMap).ParseFS(templatesFS, + "templates/layout/*.html", + "templates/components/*.html", + "templates/pages/"+pageFile.Name(), + ) + if err != nil { + t.err = err + return + } + + pages[pageName] = tmpl + } + + t.pages = pages + }) + return t.err } // Render renders a page template with the given data. func (t *Templates) Render(w http.ResponseWriter, pageName string, data any) error { + if err := t.load(); err != nil { + return err + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") tmpl, ok := t.pages[pageName] diff --git a/internal/server/templates_test.go b/internal/server/templates_test.go index 9d26269..7fe5cf5 100644 --- a/internal/server/templates_test.go +++ b/internal/server/templates_test.go @@ -11,10 +11,7 @@ import ( ) func TestTemplatesRenderAllPages(t *testing.T) { - templates, err := NewTemplates() - if err != nil { - t.Fatalf("failed to load templates: %v", err) - } + templates := &Templates{} tests := []struct { page string @@ -156,14 +153,26 @@ func TestTemplatesRenderAllPages(t *testing.T) { } } -func TestTemplatesRenderUnknownPage(t *testing.T) { - templates, err := NewTemplates() - if err != nil { - t.Fatalf("failed to load templates: %v", err) +func TestTemplatesLazyLoading(t *testing.T) { + templates := &Templates{} + + if templates.pages != nil { + t.Fatal("expected pages to be nil before first Render call") } w := httptest.NewRecorder() - err = templates.Render(w, "nonexistent_page", nil) + _ = templates.Render(w, "dashboard", DashboardData{}) + + if templates.pages == nil { + t.Fatal("expected pages to be populated after first Render call") + } +} + +func TestTemplatesRenderUnknownPage(t *testing.T) { + templates := &Templates{} + + w := httptest.NewRecorder() + err := templates.Render(w, "nonexistent_page", nil) if err == nil { t.Error("expected error for unknown page") } @@ -424,3 +433,30 @@ func TestCategorizeLicense(t *testing.T) { } } } + +func BenchmarkTemplatesParse(b *testing.B) { + for b.Loop() { + t := &Templates{} + if err := t.load(); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkServerCreate(b *testing.B) { + for b.Loop() { + _ = &Server{ + templates: &Templates{}, + } + } +} + +func BenchmarkFirstRender(b *testing.B) { + for b.Loop() { + t := &Templates{} + w := httptest.NewRecorder() + if err := t.Render(w, "dashboard", DashboardData{}); err != nil { + b.Fatal(err) + } + } +}