Compare commits

...

2 Commits

Author SHA1 Message Date
pedro 37daf0ed49 add merge, include and attrs functions 2026-02-02 03:01:55 +01:00
pedro 2789623ab5 remove error return and improve headers 2025-12-21 17:20:03 +01:00
2 changed files with 174 additions and 38 deletions
+174 -35
View File
@@ -38,14 +38,21 @@ const (
// fileSystem: The `fs.FS` implementation from which templates will be loaded (e.g., `embed.FS` or `os.DirFS`). // 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. // 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 with the template's FuncMap. // AddFunc registers a custom function with the template's FuncMap.
@@ -65,18 +72,19 @@ func (r *HRender) AddFunc(name string, fn any) {
// ensuring that any errors during execution do not result in partial responses. // ensuring that any errors during execution do not result in partial responses.
// //
// w: The `http.ResponseWriter` to which the rendered HTML will be written. // 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"). // 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. // 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"). // 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) RenderW(w http.ResponseWriter, pageName string, data H, layoutName ...string) error { func (r *HRender) RenderW(w http.ResponseWriter, status int, pageName string, data any, 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
} }
return r.execute(w, tmpl, data) return r.execute(w, status, tmpl, data)
} }
// RenderS executes the specified template (pageName) with the provided data and // RenderS executes the specified template (pageName) with the provided data and
@@ -96,7 +104,7 @@ func (r *HRender) RenderW(w http.ResponseWriter, pageName string, data H, layout
// //
// 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) RenderS(pageName string, data H, layoutName ...string) (string, error) { func (r *HRender) RenderS(pageName string, data any, 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
@@ -178,41 +186,43 @@ func (r *HRender) getTemplateInstance(pageName string, layoutName ...string) (*t
// This prevents partial HTTP responses in case of template execution errors. // This prevents partial HTTP responses in case of template execution errors.
// //
// w: The `http.ResponseWriter` to write the rendered content to. // 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. // tmpl: The `*template.Template` instance to execute.
// data: The data map (`H`) to pass to the template. // data: The data map (`H`) to pass to the template.
// //
// Returns: // Returns:
// //
// An error if template execution or writing to the response writer fails. // An error if template execution or writing to the response writer fails.
func (r *HRender) execute(w http.ResponseWriter, tmpl *template.Template, data H) error { 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 cloning a pre-loaded base template // parsePageAndLayoutIntoTemplate reads the page and optional layout content and parses them
// (containing shared components/fragments) and parsing the specific page and layout. // into the provided template instance.
// It ensures shared templates are loaded only once. //
func (r *HRender) buildTemplate(pageName, layoutName string) (*template.Template, error) { // This helper function abstracts the file reading and template parsing logic for pages and layouts.
var initErr error //
r.baseOnce.Do(func() { // tmpl: The target `*template.Template` instance where the content will be parsed.
initErr = r.loadSharedTemplates() // pageName: The filename of the page template.
}) // layoutName: The filename of the layout template (can be empty).
if initErr != nil { //
return nil, fmt.Errorf("failed to load shared templates: %w", initErr) // Returns:
} //
// An error if reading the files or parsing the content fails.
tmpl, err := r.baseTmpl.Clone() func (r *HRender) parsePageAndLayoutIntoTemplate(tmpl *template.Template, pageName, layoutName string) error {
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 fmt.Errorf("page not found: %w", err)
} }
var finalContent string var finalContent string
@@ -220,7 +230,7 @@ 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)
@@ -233,17 +243,68 @@ func (r *HRender) buildTemplate(pageName, layoutName string) (*template.Template
} }
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
}
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 return tmpl, nil
} }
// loadSharedTemplates scans the file system for templates in "components/" and "fragments/" // parseSharedTemplatesInto scans the file system for shared templates and parses them.
// directories and parses them into a base template instance. //
func (r *HRender) loadSharedTemplates() error { // It looks for templates in "components/" and "fragments/" directories and parses them
r.baseTmpl = template.New("root").Funcs(r.funcMap) // 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 { err := fs.WalkDir(r.fs, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil { if err != nil {
return err return err
@@ -268,7 +329,7 @@ func (r *HRender) loadSharedTemplates() error {
name := pathSlash name := pathSlash
name = strings.TrimSuffix(name, filepath.Ext(name)) name = strings.TrimSuffix(name, filepath.Ext(name))
_, err = r.baseTmpl.New(name).Parse(string(content)) _, err = tmpl.New(name).Parse(string(content))
return err return err
}) })
if err != nil { if err != nil {
@@ -278,6 +339,74 @@ func (r *HRender) loadSharedTemplates() error {
return 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.
// It expects an even number of arguments, where every odd argument is a string key // It expects an even number of arguments, where every odd argument is a string key
// and the following argument is its value. // and the following argument is its value.
@@ -310,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())
}
-3
View File
@@ -308,7 +308,6 @@ 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")
} }
@@ -324,9 +323,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 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")
} }
} }