Compare commits
No commits in common. "1e403e42aac7d515aad67a8bf92185d67e0b03c7" and "50fdd9cb5ff1a5a9bf545cd71ce85f8dda076b07" have entirely different histories.
1e403e42aa
...
50fdd9cb5f
167
hrender.go
167
hrender.go
@ -21,8 +21,6 @@ type (
|
||||
funcMap template.FuncMap
|
||||
cache map[string]*template.Template
|
||||
cacheMu sync.RWMutex
|
||||
baseTmpl *template.Template
|
||||
baseOnce sync.Once
|
||||
}
|
||||
)
|
||||
|
||||
@ -31,12 +29,9 @@ const (
|
||||
ContentTextHTMLUTF8 string = "text/html; charset=utf-8"
|
||||
)
|
||||
|
||||
// NewHTMLRender initializes a new HRender instance, setting up the template engine.
|
||||
// It configures the file system to load templates from, enables or disables caching,
|
||||
// and registers a default "dict" function for template use.
|
||||
//
|
||||
// fileSystem: The `fs.FS` implementation from which templates will be loaded (e.g., `embed.FS` or `os.DirFS`).
|
||||
// enableCache: If `true`, compiled templates will be stored in an in-memory cache for faster retrieval on subsequent renders.
|
||||
// NewHTMLRender initializes a new Render instance.
|
||||
// fileSystem: The file system containing the templates (usually embed.FS or os.DirFS).
|
||||
// enableCache: If true, templates will be compiled once and cached for future use.
|
||||
func NewHTMLRender(fileSystem fs.FS, enableCache bool) *HRender {
|
||||
return &HRender{
|
||||
fs: fileSystem,
|
||||
@ -48,83 +43,20 @@ func NewHTMLRender(fileSystem fs.FS, enableCache bool) *HRender {
|
||||
}
|
||||
}
|
||||
|
||||
// AddFunc registers a custom function with the template's FuncMap.
|
||||
// This method must be called before any templates are rendered for the function
|
||||
// to be available within the templates.
|
||||
//
|
||||
// name: The name by which the function will be called within the template.
|
||||
// fn: The Go function to be registered. Its signature must be compatible with `html/template` expectations.
|
||||
// AddFunc registers a custom function to the template's FuncMap.
|
||||
// This must be called before rendering any templates if you want the function to be available.
|
||||
func (r *HRender) AddFunc(name string, fn any) {
|
||||
r.funcMap[name] = fn
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Render executes the template for the given pageName and writes the result to w.
|
||||
// pageName: The path to the page template (e.g., "pages/login.html").
|
||||
// data: The data map to be passed to the template.
|
||||
// layoutName: (Optional) The path to the layout template (e.g., "layouts/base.html").
|
||||
//
|
||||
// This function handles template compilation (with caching if enabled) and execution,
|
||||
// ensuring that any errors during execution do not result in partial responses.
|
||||
//
|
||||
// w: The `http.ResponseWriter` to which the rendered HTML will be written.
|
||||
// pageName: The base name of the page template to render (e.g., "pages/login.html").
|
||||
// data: A map of data (`H`) to be passed into the template for dynamic content generation.
|
||||
// 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) RenderW(w http.ResponseWriter, pageName string, data H, layoutName ...string) error {
|
||||
tmpl, err := r.getTemplateInstance(pageName, layoutName...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.execute(w, tmpl, data)
|
||||
}
|
||||
|
||||
// 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,
|
||||
// sent as part of an SSE stream, or returned as a plain string rather than directly written
|
||||
// to an `http.ResponseWriter`.
|
||||
//
|
||||
// pageName: The base name of the page template to render (e.g., "pages/login.html").
|
||||
// data: A map of data (`H`) to be passed into the template for dynamic content generation.
|
||||
// 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.
|
||||
//
|
||||
// Returns:
|
||||
//
|
||||
// A string containing the rendered HTML.
|
||||
// An error if template compilation or execution fails.
|
||||
func (r *HRender) RenderS(pageName string, data H, layoutName ...string) (string, error) {
|
||||
tmpl, err := r.getTemplateInstance(pageName, layoutName...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// getTemplateInstance retrieves a compiled template. It first checks if the template
|
||||
// is available in the cache (if caching is enabled). If not found or caching is disabled,
|
||||
// it proceeds to build the template by combining the page and optional layout, and
|
||||
// parsing all associated fragments. If caching is enabled, the newly built template
|
||||
// is stored in the cache before being returned.
|
||||
//
|
||||
// pageName: The name of the main page template.
|
||||
// layoutName: (Optional) The name of the layout template.
|
||||
//
|
||||
// Returns:
|
||||
//
|
||||
// A pointer to `template.Template` instance ready for execution.
|
||||
// An error if template files cannot be read or parsing fails.
|
||||
func (r *HRender) getTemplateInstance(pageName string, layoutName ...string) (*template.Template, error) {
|
||||
// If caching is enabled, it checks the cache first. If not found (or cache disabled),
|
||||
// it compiles the template (merging layout and page) and caches it if appropriate.
|
||||
func (r *HRender) Render(w http.ResponseWriter, pageName string, data H, layoutName ...string) error {
|
||||
if !strings.HasSuffix(pageName, ".html") {
|
||||
pageName += ".html"
|
||||
}
|
||||
@ -150,40 +82,37 @@ func (r *HRender) getTemplateInstance(pageName string, layoutName ...string) (*t
|
||||
cachedTmpl, ok := r.cache[cacheKey]
|
||||
r.cacheMu.RUnlock()
|
||||
if ok {
|
||||
return cachedTmpl, nil // Retorna si está en caché
|
||||
return r.execute(w, cachedTmpl, data)
|
||||
}
|
||||
|
||||
r.cacheMu.Lock()
|
||||
defer r.cacheMu.Unlock()
|
||||
if cachedTmpl, ok = r.cache[cacheKey]; ok {
|
||||
return cachedTmpl, nil
|
||||
r.cacheMu.Unlock()
|
||||
return r.execute(w, cachedTmpl, data)
|
||||
}
|
||||
|
||||
tmpl, err = r.buildTemplate(pageName, layout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
r.cacheMu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
r.cache[cacheKey] = tmpl
|
||||
slog.Debug("template compiled and cached", "key", cacheKey)
|
||||
return tmpl, nil
|
||||
r.cacheMu.Unlock()
|
||||
|
||||
} else {
|
||||
return r.buildTemplate(pageName, layout)
|
||||
tmpl, err = r.buildTemplate(pageName, layout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return r.execute(w, tmpl, data)
|
||||
}
|
||||
|
||||
// execute renders the provided template with data into an internal buffer.
|
||||
// If successful, it writes the buffer's content to the `http.ResponseWriter`.
|
||||
// This prevents partial HTTP responses in case of template execution errors.
|
||||
//
|
||||
// w: The `http.ResponseWriter` to write the rendered content to.
|
||||
// tmpl: The `*template.Template` instance to execute.
|
||||
// data: The data map (`H`) to pass to the template.
|
||||
//
|
||||
// Returns:
|
||||
//
|
||||
// An error if template execution or writing to the response writer fails.
|
||||
// execute runs the template into a buffer first to catch any execution errors
|
||||
// before writing to the response writer. This prevents partial writes on error.
|
||||
func (r *HRender) execute(w http.ResponseWriter, tmpl *template.Template, data H) error {
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
@ -193,23 +122,11 @@ func (r *HRender) execute(w http.ResponseWriter, tmpl *template.Template, data H
|
||||
return err
|
||||
}
|
||||
|
||||
// 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.
|
||||
// buildTemplate constructs a new template instance by:
|
||||
// 1. Reading the page content.
|
||||
// 2. Reading the layout content (if provided) and embedding the page content into it using {{ embed }}.
|
||||
// 3. Walking the file system to parse all other available templates (fragments/components) so they can be invoked.
|
||||
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)
|
||||
@ -224,6 +141,7 @@ 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))
|
||||
|
||||
@ -232,19 +150,13 @@ 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)
|
||||
}
|
||||
|
||||
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 {
|
||||
err = fs.WalkDir(r.fs, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -255,8 +167,7 @@ func (r *HRender) loadSharedTemplates() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
pathSlash := filepath.ToSlash(path)
|
||||
if !strings.HasPrefix(pathSlash, "components/") && !strings.HasPrefix(pathSlash, "fragments/") {
|
||||
if path == pageName || path == layoutName {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -265,17 +176,17 @@ func (r *HRender) loadSharedTemplates() error {
|
||||
return err
|
||||
}
|
||||
|
||||
name := pathSlash
|
||||
name := filepath.ToSlash(path)
|
||||
name = strings.TrimSuffix(name, filepath.Ext(name))
|
||||
|
||||
_, err = r.baseTmpl.New(name).Parse(string(content))
|
||||
_, err = tmpl.New(name).Parse(string(content))
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("error loading shared templates: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
func Test_RenderW(t *testing.T) {
|
||||
func Test_Render(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mockFS fstest.MapFS
|
||||
@ -90,37 +90,13 @@ func Test_RenderW(t *testing.T) {
|
||||
expectedBody: `<button id="myButton" class="btn btn-primary">Click Me</button>`,
|
||||
},
|
||||
{
|
||||
name: "auto-append .html to page",
|
||||
name: "error page not found due to wrong extension",
|
||||
mockFS: fstest.MapFS{
|
||||
"about.html": {
|
||||
Data: []byte(`<h1>About</h1>`),
|
||||
"index.another": {
|
||||
Data: []byte(`<h1>Hello, wrong extension!</h1>`),
|
||||
},
|
||||
},
|
||||
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",
|
||||
page: "index.another",
|
||||
expectedError: true,
|
||||
expectedBody: "",
|
||||
},
|
||||
@ -141,7 +117,7 @@ func Test_RenderW(t *testing.T) {
|
||||
layouts = append(layouts, tt.layout)
|
||||
}
|
||||
|
||||
err := r.RenderW(rec, tt.page, tt.data, layouts...)
|
||||
err := r.Render(rec, tt.page, tt.data, layouts...)
|
||||
if tt.expectedError {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
@ -158,83 +134,6 @@ func Test_RenderW(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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@ -299,7 +198,7 @@ func Test_Cache(t *testing.T) {
|
||||
r := NewHTMLRender(mockFS, true)
|
||||
|
||||
rec1 := httptest.NewRecorder()
|
||||
err1 := r.RenderW(rec1, "index.html", nil, "main-layout.html")
|
||||
err1 := r.Render(rec1, "index.html", nil, "main-layout.html")
|
||||
if err1 != nil {
|
||||
t.Errorf("unexpected error: %v", err1)
|
||||
}
|
||||
@ -308,7 +207,6 @@ 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")
|
||||
}
|
||||
@ -316,7 +214,7 @@ func Test_Cache(t *testing.T) {
|
||||
buf.Reset()
|
||||
|
||||
rec2 := httptest.NewRecorder()
|
||||
err2 := r.RenderW(rec2, "index.html", nil, "main-layout.html")
|
||||
err2 := r.Render(rec2, "index.html", nil, "main-layout.html")
|
||||
if err2 != nil {
|
||||
t.Errorf("unexpected error: %v", err2)
|
||||
}
|
||||
@ -324,9 +222,7 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user