From 1e403e42aac7d515aad67a8bf92185d67e0b03c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20P=C3=A9rez?= Date: Mon, 8 Dec 2025 23:26:55 +0100 Subject: [PATCH] change to RenderW and RenderS and update tests --- hrender.go | 62 +++++++++++++++---------- hrender_test.go | 120 ++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 149 insertions(+), 33 deletions(-) diff --git a/hrender.go b/hrender.go index 671e63e..4951159 100644 --- a/hrender.go +++ b/hrender.go @@ -21,6 +21,8 @@ type ( funcMap template.FuncMap cache map[string]*template.Template cacheMu sync.RWMutex + baseTmpl *template.Template + baseOnce sync.Once } ) @@ -56,7 +58,7 @@ func (r *HRender) AddFunc(name string, fn any) { r.funcMap[name] = fn } -// Render executes the specified template (pageName) with the provided data and writes the +// RenderW executes the specified template (pageName) with the provided data and writes the // resulting HTML to the `http.ResponseWriter`. It can optionally apply a layout template. // // This function handles template compilation (with caching if enabled) and execution, @@ -68,7 +70,7 @@ func (r *HRender) AddFunc(name string, fn any) { // layoutName: (Optional) The name of the layout template to wrap the page content (e.g., "layouts/base.html"). // // The page content will be embedded where `{{ embed }}` or `{{embed}}` is found in the layout. -func (r *HRender) Render(w http.ResponseWriter, pageName string, data H, layoutName ...string) error { +func (r *HRender) RenderW(w http.ResponseWriter, pageName string, data H, layoutName ...string) error { tmpl, err := r.getTemplateInstance(pageName, layoutName...) if err != nil { return err @@ -77,7 +79,7 @@ func (r *HRender) Render(w http.ResponseWriter, pageName string, data H, layoutN return r.execute(w, tmpl, data) } -// RenderToString executes the specified template (pageName) with the provided data and +// RenderS executes the specified template (pageName) with the provided data and // returns the resulting HTML as a string. It can optionally apply a layout template. // // This function is useful for scenarios where the rendered HTML needs to be further processed, @@ -94,7 +96,7 @@ func (r *HRender) Render(w http.ResponseWriter, pageName string, data H, layoutN // // A string containing the rendered HTML. // An error if template compilation or execution fails. -func (r *HRender) RenderToString(pageName string, data H, layoutName ...string) (string, error) { +func (r *HRender) RenderS(pageName string, data H, layoutName ...string) (string, error) { tmpl, err := r.getTemplateInstance(pageName, layoutName...) if err != nil { return "", err @@ -191,19 +193,23 @@ func (r *HRender) execute(w http.ResponseWriter, tmpl *template.Template, data H return err } -// buildTemplate reads the specified page and layout (if provided) files from the file system, -// embeds the page content into the layout (if a layout is used), and then parses -// all other HTML files in the file system as named templates (fragments/components) -// to be available for inclusion within the main template. -// -// pageName: The name of the primary page template file. -// layoutName: The name of the layout template file, or an empty string if no layout is used. -// -// Returns: -// -// A fully parsed `*template.Template` instance configured with `FuncMap` and all discovered fragments. -// An error if template files are not found or parsing fails. +// buildTemplate constructs a new template instance by cloning a pre-loaded base template +// (containing shared components/fragments) and parsing the specific page and layout. +// It ensures shared templates are loaded only once. func (r *HRender) buildTemplate(pageName, layoutName string) (*template.Template, error) { + var initErr error + r.baseOnce.Do(func() { + initErr = r.loadSharedTemplates() + }) + if initErr != nil { + return nil, fmt.Errorf("failed to load shared templates: %w", initErr) + } + + tmpl, err := r.baseTmpl.Clone() + if err != nil { + return nil, fmt.Errorf("failed to clone base template: %w", err) + } + pageContent, err := fs.ReadFile(r.fs, pageName) if err != nil { return nil, fmt.Errorf("page not found: %w", err) @@ -218,7 +224,6 @@ func (r *HRender) buildTemplate(pageName, layoutName string) (*template.Template } layoutStr := string(layoutBytes) - layoutStr = strings.ReplaceAll(layoutStr, "{{ embed }}", string(pageContent)) layoutStr = strings.ReplaceAll(layoutStr, "{{embed}}", string(pageContent)) @@ -227,13 +232,19 @@ func (r *HRender) buildTemplate(pageName, layoutName string) (*template.Template finalContent = string(pageContent) } - tmpl := template.New("root").Funcs(r.funcMap) - if _, err := tmpl.Parse(finalContent); err != nil { return nil, fmt.Errorf("error parsing main content: %w", err) } - err = fs.WalkDir(r.fs, ".", func(path string, d fs.DirEntry, err error) error { + return tmpl, nil +} + +// loadSharedTemplates scans the file system for templates in "components/" and "fragments/" +// directories and parses them into a base template instance. +func (r *HRender) loadSharedTemplates() error { + r.baseTmpl = template.New("root").Funcs(r.funcMap) + + err := fs.WalkDir(r.fs, ".", func(path string, d fs.DirEntry, err error) error { if err != nil { return err } @@ -244,7 +255,8 @@ func (r *HRender) buildTemplate(pageName, layoutName string) (*template.Template return nil } - if path == pageName || path == layoutName { + pathSlash := filepath.ToSlash(path) + if !strings.HasPrefix(pathSlash, "components/") && !strings.HasPrefix(pathSlash, "fragments/") { return nil } @@ -253,17 +265,17 @@ func (r *HRender) buildTemplate(pageName, layoutName string) (*template.Template return err } - name := filepath.ToSlash(path) + name := pathSlash name = strings.TrimSuffix(name, filepath.Ext(name)) - _, err = tmpl.New(name).Parse(string(content)) + _, err = r.baseTmpl.New(name).Parse(string(content)) return err }) if err != nil { - return nil, err + return fmt.Errorf("error loading shared templates: %w", err) } - return tmpl, nil + return nil } // Dict creates a map[string]any from a list of key-value pairs. diff --git a/hrender_test.go b/hrender_test.go index 8403650..bba10a1 100644 --- a/hrender_test.go +++ b/hrender_test.go @@ -11,7 +11,7 @@ import ( "testing/fstest" ) -func Test_Render(t *testing.T) { +func Test_RenderW(t *testing.T) { tests := []struct { name string mockFS fstest.MapFS @@ -90,13 +90,37 @@ func Test_Render(t *testing.T) { expectedBody: ``, }, { - name: "error page not found due to wrong extension", + name: "auto-append .html to page", mockFS: fstest.MapFS{ - "index.another": { - Data: []byte(`

Hello, wrong extension!

`), + "about.html": { + Data: []byte(`

About

`), }, }, - page: "index.another", + page: "about", + expectedBody: "

About

", + }, + { + name: "auto-append .html to layout", + mockFS: fstest.MapFS{ + "contact.html": { + Data: []byte(`

Contact

`), + }, + "base.html": { + Data: []byte(`
{{ embed }}
`), + }, + }, + page: "contact.html", + layout: "base", + expectedBody: "

Contact

", + }, + { + name: "error page not found", + mockFS: fstest.MapFS{ + "index.html": { + Data: []byte(`

Exists

`), + }, + }, + page: "missing.html", expectedError: true, expectedBody: "", }, @@ -117,7 +141,7 @@ func Test_Render(t *testing.T) { layouts = append(layouts, tt.layout) } - err := r.Render(rec, tt.page, tt.data, layouts...) + err := r.RenderW(rec, tt.page, tt.data, layouts...) if tt.expectedError { if err == nil { t.Error("expected error, got nil") @@ -134,6 +158,83 @@ func Test_Render(t *testing.T) { } } +func Test_RenderS(t *testing.T) { + mockFS := fstest.MapFS{ + "index.html": { + Data: []byte(`

{{ .Title }}

`), + }, + } + + r := NewHTMLRender(mockFS, false) + data := H{"Title": "Render String"} + + got, err := r.RenderS("index.html", data) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + expected := "

Render String

" + if got != expected { + t.Errorf("expected %q, got %q", expected, got) + } +} + +func Test_SharedTemplatesIsolation(t *testing.T) { + // This test ensures that ONLY components/ and fragments/ are loaded into the base template + mockFS := fstest.MapFS{ + "pages/home.html": { + Data: []byte(` + {{ template "components/btn" . }} + {{ template "fragments/tbl" . }} + {{ template "other/ignored" . }} + `), + }, + "components/btn.html": { + Data: []byte(`[BUTTON]`), + }, + "fragments/tbl.html": { + Data: []byte(`[TABLE]`), + }, + "other/ignored.html": { + Data: []byte(`[IGNORED]`), + }, + } + + r := NewHTMLRender(mockFS, false) + + // Attempt to render home. It should fail because "other/ignored" is not available in the base template, + // and "pages/home.html" only tries to invoke it, it doesn't define it. + err := r.RenderW(httptest.NewRecorder(), "pages/home.html", nil) + if err == nil { + t.Fatal("expected error due to missing 'other/ignored' template, but got nil") + } + + // Now a valid test case checking correct loading of components and fragments + mockFSValid := fstest.MapFS{ + "pages/valid.html": { + Data: []byte(`{{ template "components/btn" . }} - {{ template "fragments/tbl" . }}`), + }, + "components/btn.html": { + Data: []byte(`[BUTTON]`), + }, + "fragments/tbl.html": { + Data: []byte(`[TABLE]`), + }, + } + + r2 := NewHTMLRender(mockFSValid, false) + rec := httptest.NewRecorder() + err = r2.RenderW(rec, "pages/valid.html", nil) + if err != nil { + t.Fatalf("unexpected error rendering valid templates: %v", err) + } + + expected := "[BUTTON] - [TABLE]" + if got := rec.Body.String(); got != expected { + t.Errorf("expected %q, got %q", expected, got) + } +} + func Test_Dict(t *testing.T) { tests := []struct { name string @@ -198,7 +299,7 @@ func Test_Cache(t *testing.T) { r := NewHTMLRender(mockFS, true) rec1 := httptest.NewRecorder() - err1 := r.Render(rec1, "index.html", nil, "main-layout.html") + err1 := r.RenderW(rec1, "index.html", nil, "main-layout.html") if err1 != nil { t.Errorf("unexpected error: %v", err1) } @@ -207,6 +308,7 @@ func Test_Cache(t *testing.T) { t.Errorf("expected body %q, got %q", expected, got) } + // Verify log contains "template compiled and cached" if !strings.Contains(buf.String(), "template compiled and cached") { t.Error("expected 'template compiled and cached' message on first render") } @@ -214,7 +316,7 @@ func Test_Cache(t *testing.T) { buf.Reset() rec2 := httptest.NewRecorder() - err2 := r.Render(rec2, "index.html", nil, "main-layout.html") + err2 := r.RenderW(rec2, "index.html", nil, "main-layout.html") if err2 != nil { t.Errorf("unexpected error: %v", err2) } @@ -222,7 +324,9 @@ func Test_Cache(t *testing.T) { t.Errorf("expected body %q, got %q", expected, got) } + // Verify log DOES NOT contain "template compiled and cached" (cache hit) if strings.Contains(buf.String(), "template compiled and cached") { t.Error("did not expect 'template compiled and cached' message on second render") } } +