Compare commits

..

4 Commits

2 changed files with 385 additions and 56 deletions
+276 -48
View File
@@ -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
} }
) )
@@ -29,34 +31,108 @@ const (
ContentTextHTMLUTF8 string = "text/html; charset=utf-8" ContentTextHTMLUTF8 string = "text/html; charset=utf-8"
) )
// NewHTMLRender initializes a new Render instance. // NewHTMLRender initializes a new HRender instance, setting up the template engine.
// fileSystem: The file system containing the templates (usually embed.FS or os.DirFS). // It configures the file system to load templates from, enables or disables caching,
// enableCache: If true, templates will be compiled once and cached for future use. // 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.
func NewHTMLRender(fileSystem fs.FS, enableCache bool) *HRender { func NewHTMLRender(fileSystem fs.FS, enableCache bool) *HRender {
return &HRender{ r := &HRender{
fs: fileSystem, fs: fileSystem,
enableCache: enableCache, enableCache: enableCache,
cache: make(map[string]*template.Template), cache: make(map[string]*template.Template),
funcMap: template.FuncMap{
"dict": Dict,
},
} }
r.funcMap = template.FuncMap{
"dict": Dict,
"list": List,
"merge": Merge,
"include": r.Include,
"attrs": Attrs,
}
return r
} }
// AddFunc registers a custom function to the template's FuncMap. // AddFunc registers a custom function with the template's FuncMap.
// This must be called before rendering any templates if you want the function to be available. // 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.
func (r *HRender) AddFunc(name string, fn any) { func (r *HRender) AddFunc(name string, fn any) {
r.funcMap[name] = fn r.funcMap[name] = fn
} }
// Render executes the template for the given pageName and writes the result to w. // RenderW executes the specified template (pageName) with the provided data and writes the
// pageName: The path to the page template (e.g., "pages/login.html"). // resulting HTML to the `http.ResponseWriter`. It can optionally apply a layout template.
// data: The data map to be passed to the template.
// layoutName: (Optional) The path to the layout template (e.g., "layouts/base.html").
// //
// If caching is enabled, it checks the cache first. If not found (or cache disabled), // This function handles template compilation (with caching if enabled) and execution,
// it compiles the template (merging layout and page) and caches it if appropriate. // ensuring that any errors during execution do not result in partial responses.
func (r *HRender) Render(w http.ResponseWriter, pageName string, data H, layoutName ...string) error { //
// w: The `http.ResponseWriter` to which the rendered HTML will be written.
// status: The `http status` to write header in r.execute.
// 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, status int, pageName string, data any, layoutName ...string) error {
tmpl, err := r.getTemplateInstance(pageName, layoutName...)
if err != nil {
return err
}
return r.execute(w, status, 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 any, 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 !strings.HasSuffix(pageName, ".html") { if !strings.HasSuffix(pageName, ".html") {
pageName += ".html" pageName += ".html"
} }
@@ -82,54 +158,71 @@ func (r *HRender) Render(w http.ResponseWriter, pageName string, data H, layoutN
cachedTmpl, ok := r.cache[cacheKey] cachedTmpl, ok := r.cache[cacheKey]
r.cacheMu.RUnlock() r.cacheMu.RUnlock()
if ok { if ok {
return r.execute(w, cachedTmpl, data) return cachedTmpl, nil // Retorna si está en caché
} }
r.cacheMu.Lock() r.cacheMu.Lock()
defer r.cacheMu.Unlock()
if cachedTmpl, ok = r.cache[cacheKey]; ok { if cachedTmpl, ok = r.cache[cacheKey]; ok {
r.cacheMu.Unlock() return cachedTmpl, nil
return r.execute(w, cachedTmpl, data)
} }
tmpl, err = r.buildTemplate(pageName, layout) tmpl, err = r.buildTemplate(pageName, layout)
if err != nil { if err != nil {
r.cacheMu.Unlock() return nil, err
return err
} }
r.cache[cacheKey] = tmpl r.cache[cacheKey] = tmpl
slog.Debug("template compiled and cached", "key", cacheKey) slog.Debug("template compiled and cached", "key", cacheKey)
r.cacheMu.Unlock() return tmpl, nil
} else { } else {
tmpl, err = r.buildTemplate(pageName, layout) return r.buildTemplate(pageName, layout)
if err != nil {
return err
}
} }
return r.execute(w, tmpl, data)
} }
// execute runs the template into a buffer first to catch any execution errors // execute renders the provided template with data into an internal buffer.
// before writing to the response writer. This prevents partial writes on error. // If successful, it writes the buffer's content to the `http.ResponseWriter`.
func (r *HRender) execute(w http.ResponseWriter, tmpl *template.Template, data H) error { // This prevents partial HTTP responses in case of template execution errors.
//
// w: The `http.ResponseWriter` to write the rendered content to.
// status: The `http status` to to write headers 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.
func (r *HRender) execute(w http.ResponseWriter, status int, tmpl *template.Template, data any) error {
var buf bytes.Buffer var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil { if err := tmpl.Execute(&buf, data); err != nil {
return err return err // Si falla, NO hemos tocado 'w'. Podemos mandar un 500 arriba.
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, err := buf.WriteTo(w) _, err := buf.WriteTo(w)
return err return err
} }
// buildTemplate constructs a new template instance by: // parsePageAndLayoutIntoTemplate reads the page and optional layout content and parses them
// 1. Reading the page content. // into the provided template instance.
// 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. // This helper function abstracts the file reading and template parsing logic for pages and layouts.
func (r *HRender) buildTemplate(pageName, layoutName string) (*template.Template, error) { //
// tmpl: The target `*template.Template` instance where the content will be parsed.
// pageName: The filename of the page template.
// layoutName: The filename of the layout template (can be empty).
//
// Returns:
//
// An error if reading the files or parsing the content fails.
func (r *HRender) parsePageAndLayoutIntoTemplate(tmpl *template.Template, pageName, layoutName string) error {
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 fmt.Errorf("page not found: %w", err)
} }
var finalContent string var finalContent string
@@ -137,11 +230,10 @@ func (r *HRender) buildTemplate(pageName, layoutName string) (*template.Template
if layoutName != "" { if layoutName != "" {
layoutBytes, err := fs.ReadFile(r.fs, layoutName) layoutBytes, err := fs.ReadFile(r.fs, layoutName)
if err != nil { if err != nil {
return nil, fmt.Errorf("layout not found: %w", err) return fmt.Errorf("layout not found: %w", err)
} }
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))
@@ -150,13 +242,70 @@ 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 fmt.Errorf("error parsing main content: %w", err)
}
return nil
}
// buildTemplate constructs a new template instance for the specified page and layout.
//
// If caching is enabled (`enableCache` is true), it clones a pre-loaded base template
// (containing shared components) and parses the page/layout into the clone.
// If caching is disabled, it creates a fresh template instance, parses all shared components,
// and then parses the page/layout.
//
// pageName: The filename of the page template.
// layoutName: The filename of the layout template.
//
// Returns:
//
// A pointer to the constructed `*template.Template`.
// An error if any part of the template loading or parsing fails.
func (r *HRender) buildTemplate(pageName, layoutName string) (*template.Template, error) {
if !r.enableCache {
tmpl := template.New("root").Funcs(r.funcMap)
if err := r.parseSharedTemplatesInto(tmpl); err != nil {
return nil, fmt.Errorf("failed to parse shared templates for non-cached template: %w", err)
}
if err := r.parsePageAndLayoutIntoTemplate(tmpl, pageName, layoutName); err != nil {
return nil, err
}
return tmpl, nil
} }
err = fs.WalkDir(r.fs, ".", func(path string, d fs.DirEntry, err error) error { var initErr error
r.baseOnce.Do(func() {
initErr = r.loadBaseTemplate()
})
if initErr != nil {
return nil, fmt.Errorf("failed to load shared templates into base: %w", initErr)
}
tmpl, err := r.baseTmpl.Clone() // This is the line user wants to disable when enableCache is false
if err != nil {
return nil, fmt.Errorf("failed to clone base template: %w", err)
}
if err := r.parsePageAndLayoutIntoTemplate(tmpl, pageName, layoutName); err != nil {
return nil, err
}
return tmpl, nil
}
// parseSharedTemplatesInto scans the file system for shared templates and parses them.
//
// It looks for templates in "components/" and "fragments/" directories and parses them
// into the provided template instance.
//
// tmpl: The `*template.Template` instance to populate with shared templates.
//
// Returns:
//
// An error if walking the directory or parsing a template fails.
func (r *HRender) parseSharedTemplatesInto(tmpl *template.Template) error {
err := fs.WalkDir(r.fs, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil { if err != nil {
return err return err
} }
@@ -167,7 +316,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
} }
@@ -176,17 +326,85 @@ 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 = tmpl.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
}
// loadBaseTemplate initializes the base template with shared components.
//
// It creates a new "root" template, registers the function map, and populates it
// with all shared templates found in the file system. This is typically used
// to initialize the cached base template.
//
// Returns:
//
// An error if loading the shared templates fails.
func (r *HRender) loadBaseTemplate() error {
r.baseTmpl = template.New("root").Funcs(r.funcMap)
return r.parseSharedTemplatesInto(r.baseTmpl)
}
func (r *HRender) Include(name string, data any) (template.HTML, error) {
var buf bytes.Buffer
var err error
if r.enableCache {
r.baseOnce.Do(func() { _ = r.loadBaseTemplate() })
if r.baseTmpl == nil {
return "", fmt.Errorf("base template not initialized")
}
err = r.baseTmpl.ExecuteTemplate(&buf, name, data)
} else {
t := template.New("temp_include").Funcs(r.funcMap)
if parseErr := r.parseSharedTemplatesInto(t); parseErr != nil {
return "", fmt.Errorf("failed to parse shared templates for include: %w", parseErr)
}
err = t.ExecuteTemplate(&buf, name, data)
}
if err != nil {
return "", fmt.Errorf("include error (%s): %w", name, err)
}
return template.HTML(buf.String()), nil
}
func List(values ...any) []any {
return values
}
func Merge(base any, overlays ...map[string]any) map[string]any {
out := make(map[string]any)
if baseMap, ok := base.(map[string]any); ok {
for k, v := range baseMap {
out[k] = v
}
} else if baseH, ok := base.(H); ok { // Soporte para tu tipo H
for k, v := range baseH {
out[k] = v
}
}
for _, overlay := range overlays {
for k, v := range overlay {
out[k] = v
}
}
return out
} }
// 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.
@@ -221,3 +439,13 @@ func Dict(values ...any) map[string]any {
} }
return dict return dict
} }
func Attrs(m map[string]string) template.HTMLAttr {
var sb strings.Builder
for k, v := range m {
if v != "" {
sb.WriteString(fmt.Sprintf(` %s="%s"`, k, v))
}
}
return template.HTMLAttr(sb.String())
}
+109 -8
View File
@@ -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)
} }
@@ -214,7 +315,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)
} }