change to RenderW and RenderS and update tests
This commit is contained in:
parent
cfe597108b
commit
1e403e42aa
62
hrender.go
62
hrender.go
@ -21,6 +21,8 @@ type (
|
|||||||
funcMap template.FuncMap
|
funcMap template.FuncMap
|
||||||
cache map[string]*template.Template
|
cache map[string]*template.Template
|
||||||
cacheMu sync.RWMutex
|
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
|
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.
|
// resulting HTML to the `http.ResponseWriter`. It can optionally apply a layout template.
|
||||||
//
|
//
|
||||||
// This function handles template compilation (with caching if enabled) and execution,
|
// 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").
|
// 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.
|
// 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...)
|
tmpl, err := r.getTemplateInstance(pageName, layoutName...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -77,7 +79,7 @@ func (r *HRender) Render(w http.ResponseWriter, pageName string, data H, layoutN
|
|||||||
return r.execute(w, tmpl, data)
|
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.
|
// 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,
|
// 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.
|
// A string containing the rendered HTML.
|
||||||
// An error if template compilation or execution fails.
|
// 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...)
|
tmpl, err := r.getTemplateInstance(pageName, layoutName...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@ -191,19 +193,23 @@ func (r *HRender) execute(w http.ResponseWriter, tmpl *template.Template, data H
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildTemplate reads the specified page and layout (if provided) files from the file system,
|
// buildTemplate constructs a new template instance by cloning a pre-loaded base template
|
||||||
// embeds the page content into the layout (if a layout is used), and then parses
|
// (containing shared components/fragments) and parsing the specific page and layout.
|
||||||
// all other HTML files in the file system as named templates (fragments/components)
|
// It ensures shared templates are loaded only once.
|
||||||
// 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.
|
|
||||||
func (r *HRender) buildTemplate(pageName, layoutName string) (*template.Template, error) {
|
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)
|
pageContent, err := fs.ReadFile(r.fs, pageName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("page not found: %w", err)
|
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 := string(layoutBytes)
|
||||||
|
|
||||||
layoutStr = strings.ReplaceAll(layoutStr, "{{ embed }}", string(pageContent))
|
layoutStr = strings.ReplaceAll(layoutStr, "{{ embed }}", string(pageContent))
|
||||||
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)
|
finalContent = string(pageContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpl := template.New("root").Funcs(r.funcMap)
|
|
||||||
|
|
||||||
if _, err := tmpl.Parse(finalContent); err != nil {
|
if _, err := tmpl.Parse(finalContent); err != nil {
|
||||||
return nil, fmt.Errorf("error parsing main content: %w", err)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -244,7 +255,8 @@ func (r *HRender) buildTemplate(pageName, layoutName string) (*template.Template
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if path == pageName || path == layoutName {
|
pathSlash := filepath.ToSlash(path)
|
||||||
|
if !strings.HasPrefix(pathSlash, "components/") && !strings.HasPrefix(pathSlash, "fragments/") {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -253,17 +265,17 @@ func (r *HRender) buildTemplate(pageName, layoutName string) (*template.Template
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
name := filepath.ToSlash(path)
|
name := pathSlash
|
||||||
name = strings.TrimSuffix(name, filepath.Ext(name))
|
name = strings.TrimSuffix(name, filepath.Ext(name))
|
||||||
|
|
||||||
_, err = tmpl.New(name).Parse(string(content))
|
_, err = r.baseTmpl.New(name).Parse(string(content))
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
if err != nil {
|
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.
|
// Dict creates a map[string]any from a list of key-value pairs.
|
||||||
|
|||||||
120
hrender_test.go
120
hrender_test.go
@ -11,7 +11,7 @@ import (
|
|||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Test_Render(t *testing.T) {
|
func Test_RenderW(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
mockFS fstest.MapFS
|
mockFS fstest.MapFS
|
||||||
@ -90,13 +90,37 @@ func Test_Render(t *testing.T) {
|
|||||||
expectedBody: `<button id="myButton" class="btn btn-primary">Click Me</button>`,
|
expectedBody: `<button id="myButton" class="btn btn-primary">Click Me</button>`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "error page not found due to wrong extension",
|
name: "auto-append .html to page",
|
||||||
mockFS: fstest.MapFS{
|
mockFS: fstest.MapFS{
|
||||||
"index.another": {
|
"about.html": {
|
||||||
Data: []byte(`<h1>Hello, wrong extension!</h1>`),
|
Data: []byte(`<h1>About</h1>`),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
page: "index.another",
|
page: "about",
|
||||||
|
expectedBody: "<h1>About</h1>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "auto-append .html to layout",
|
||||||
|
mockFS: fstest.MapFS{
|
||||||
|
"contact.html": {
|
||||||
|
Data: []byte(`<p>Contact</p>`),
|
||||||
|
},
|
||||||
|
"base.html": {
|
||||||
|
Data: []byte(`<div>{{ embed }}</div>`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
page: "contact.html",
|
||||||
|
layout: "base",
|
||||||
|
expectedBody: "<div><p>Contact</p></div>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error page not found",
|
||||||
|
mockFS: fstest.MapFS{
|
||||||
|
"index.html": {
|
||||||
|
Data: []byte(`<h1>Exists</h1>`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
page: "missing.html",
|
||||||
expectedError: true,
|
expectedError: true,
|
||||||
expectedBody: "",
|
expectedBody: "",
|
||||||
},
|
},
|
||||||
@ -117,7 +141,7 @@ func Test_Render(t *testing.T) {
|
|||||||
layouts = append(layouts, tt.layout)
|
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 tt.expectedError {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error, got 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(`<h1>{{ .Title }}</h1>`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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 := "<h1>Render String</h1>"
|
||||||
|
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) {
|
func Test_Dict(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@ -198,7 +299,7 @@ func Test_Cache(t *testing.T) {
|
|||||||
r := NewHTMLRender(mockFS, true)
|
r := NewHTMLRender(mockFS, true)
|
||||||
|
|
||||||
rec1 := httptest.NewRecorder()
|
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 {
|
if err1 != nil {
|
||||||
t.Errorf("unexpected error: %v", err1)
|
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)
|
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") {
|
if !strings.Contains(buf.String(), "template compiled and cached") {
|
||||||
t.Error("expected 'template compiled and cached' message on first render")
|
t.Error("expected 'template compiled and cached' message on first render")
|
||||||
}
|
}
|
||||||
@ -214,7 +316,7 @@ func Test_Cache(t *testing.T) {
|
|||||||
buf.Reset()
|
buf.Reset()
|
||||||
|
|
||||||
rec2 := httptest.NewRecorder()
|
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 {
|
if err2 != nil {
|
||||||
t.Errorf("unexpected error: %v", err2)
|
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)
|
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") {
|
if strings.Contains(buf.String(), "template compiled and cached") {
|
||||||
t.Error("did not expect 'template compiled and cached' message on second render")
|
t.Error("did not expect 'template compiled and cached' message on second render")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user