From a8a93ab5ae11041466b7c58a38669bbd00453dd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20P=C3=A9rez?= Date: Wed, 3 Dec 2025 15:40:31 +0100 Subject: [PATCH] initial commit and release --- go.mod | 3 + go.sum | 0 hrender.go | 218 +++++++++++++++++++++++++++++++++++++++++++++ hrender_test.go | 229 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 450 insertions(+) create mode 100644 go.mod create mode 100644 go.sum create mode 100644 hrender.go create mode 100644 hrender_test.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bfb56b3 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/zepyrshut/hrender + +go 1.18 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/hrender.go b/hrender.go new file mode 100644 index 0000000..9307042 --- /dev/null +++ b/hrender.go @@ -0,0 +1,218 @@ +package main + +import ( + "bytes" + "fmt" + "html/template" + "io/fs" + "log/slog" + "net/http" + "path/filepath" + "strings" + "sync" +) + +type ( + H map[string]any + + HRender struct { + fs fs.FS + enableCache bool + funcMap template.FuncMap + cache map[string]*template.Template + cacheMu sync.RWMutex + } +) + +// 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, + enableCache: enableCache, + cache: make(map[string]*template.Template), + funcMap: template.FuncMap{ + "dict": Dict, + }, + } +} + +// 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 +} + +// 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"). +// +// 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" + } + + var layout string + if len(layoutName) > 0 && layoutName[0] != "" { + layout = layoutName[0] + if !strings.HasSuffix(layout, ".html") { + layout += ".html" + } + } + + cacheKey := pageName + if layout != "" { + cacheKey = layout + "::" + pageName + } + + var tmpl *template.Template + var err error + + if r.enableCache { + r.cacheMu.RLock() + cachedTmpl, ok := r.cache[cacheKey] + r.cacheMu.RUnlock() + if ok { + return r.execute(w, cachedTmpl, data) + } + + r.cacheMu.Lock() + if cachedTmpl, ok = r.cache[cacheKey]; ok { + r.cacheMu.Unlock() + return r.execute(w, cachedTmpl, data) + } + + tmpl, err = r.buildTemplate(pageName, layout) + if err != nil { + r.cacheMu.Unlock() + return err + } + + r.cache[cacheKey] = tmpl + slog.Debug("template compiled and cached", "key", cacheKey) + r.cacheMu.Unlock() + + } else { + tmpl, err = 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 +// 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 { + return err + } + _, err := buf.WriteTo(w) + return err +} + +// 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) { + pageContent, err := fs.ReadFile(r.fs, pageName) + if err != nil { + return nil, fmt.Errorf("page not found: %w", err) + } + + var finalContent string + + if layoutName != "" { + layoutBytes, err := fs.ReadFile(r.fs, layoutName) + if err != nil { + return nil, fmt.Errorf("layout not found: %w", err) + } + + layoutStr := string(layoutBytes) + + layoutStr = strings.ReplaceAll(layoutStr, "{{ embed }}", string(pageContent)) + layoutStr = strings.ReplaceAll(layoutStr, "{{embed}}", string(pageContent)) + + finalContent = layoutStr + } else { + 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 { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if !strings.HasSuffix(path, ".html") && !strings.HasSuffix(path, ".gohtml") { + return nil + } + + if path == pageName || path == layoutName { + return nil + } + + content, err := fs.ReadFile(r.fs, path) + if err != nil { + return err + } + + name := filepath.ToSlash(path) + name = strings.TrimSuffix(name, filepath.Ext(name)) + + _, err = tmpl.New(name).Parse(string(content)) + return err + }) + if err != nil { + return nil, err + } + + return tmpl, nil +} + +// 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 +// and the following argument is its value. +// +// This is particularly useful for passing multiple pieces of data to a sub-template +// or fragment, as Go templates normally only accept a single pipeline argument. +// +// Usage in template: +// +// {{ template "fragments/input" dict "Label" "Username" "Value" .User.Name "Type" "text" }} +// +// Returns: +// - A map[string]any containing the key-value pairs. +// - A map with an "error" key if the number of arguments is odd or a key is not a string. +func Dict(values ...any) map[string]any { + if len(values)%2 != 0 { + return map[string]any{ + "error": "invalid dict call", + } + } + dict := make(map[string]any, len(values)/2) + for i := 0; i < len(values); i += 2 { + key, ok := values[i].(string) + if !ok { + return map[string]any{ + "error": "dict keys must be strings", + } + } + dict[key] = values[i+1] + } + return dict +} diff --git a/hrender_test.go b/hrender_test.go new file mode 100644 index 0000000..3ad5105 --- /dev/null +++ b/hrender_test.go @@ -0,0 +1,229 @@ +package main + +import ( + "bytes" + "html/template" + "log/slog" + "net/http/httptest" + "reflect" + "strings" + "testing" + "testing/fstest" +) + +func Test_Render(t *testing.T) { + tests := []struct { + name string + mockFS fstest.MapFS + data H + page string + layout string + customFuncs template.FuncMap + expectedError bool + expectedBody string + }{ + { + name: "simple page", + mockFS: fstest.MapFS{ + "index.html": { + Data: []byte(`

hello world

`), + }, + }, + page: "index.html", + expectedBody: "

hello world

", + }, + { + name: "simple page with layout", + mockFS: fstest.MapFS{ + "index.html": { + Data: []byte(`

hello world

`), + }, + "main-layout.html": { + Data: []byte(`
{{ embed }}
`), + }, + }, + page: "index.html", + layout: "main-layout.html", + expectedBody: "

hello world

", + }, + { + name: "simple page with fragment", + mockFS: fstest.MapFS{ + "index.html": { + Data: []byte(`

{{ template "fragments/header" . }}

`), + }, + "fragments/header.html": { + Data: []byte(`Hello Header`), + }, + }, + page: "index.html", + expectedBody: "

Hello Header

", + }, + { + name: "simple page with layout and fragment", + mockFS: fstest.MapFS{ + "layout.html": { + Data: []byte(`{{ embed }}`), + }, + "index.html": { + Data: []byte(`
{{ template "fragments/footer" . }}
`), + }, + "fragments/footer.html": { + Data: []byte(`Footer Content`), + }, + }, + page: "index.html", + layout: "layout.html", + expectedBody: "
Footer Content
", + }, + { + name: "simple page with Dict function and fragment", + mockFS: fstest.MapFS{ + "index.html": { + Data: []byte(``), + }, + "fragments/button_attrs.html": { + Data: []byte(`id="{{.ID}}" class="{{.Class}}"`), + }, + }, + page: "index.html", + expectedBody: ``, + }, + { + name: "error page not found due to wrong extension", + mockFS: fstest.MapFS{ + "index.another": { + Data: []byte(`

Hello, wrong extension!

`), + }, + }, + page: "index.another", + expectedError: true, + expectedBody: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := NewHTMLRender(tt.mockFS, false) + + for name, fn := range tt.customFuncs { + r.AddFunc(name, fn) + } + + rec := httptest.NewRecorder() + + var layouts []string + if tt.layout != "" { + layouts = append(layouts, tt.layout) + } + + err := r.Render(rec, tt.page, tt.data, layouts...) + if tt.expectedError { + if err == nil { + t.Error("expected error, got nil") + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if got := strings.TrimSpace(rec.Body.String()); got != tt.expectedBody { + t.Errorf("expected body %q, got %q", tt.expectedBody, got) + } + } + }) + } +} + +func Test_Dict(t *testing.T) { + tests := []struct { + name string + given []any + expected map[string]any + }{ + { + name: "valid - all strings", + given: []any{"key", "value"}, + expected: map[string]any{ + "key": "value", + }, + }, + { + name: "valid - key string value int", + given: []any{"age", 15}, + expected: map[string]any{ + "age": 15, + }, + }, + { + name: "error invalid dict call", + given: []any{"error"}, + expected: map[string]any{ + "error": "invalid dict call", + }, + }, + { + name: "error dict keys must be strings", + given: []any{1, "error"}, + expected: map[string]any{ + "error": "dict keys must be strings", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Dict(tt.given...) + if !reflect.DeepEqual(got, tt.expected) { + t.Errorf("expected %v, got %v", tt.expected, got) + } + }) + } +} + +func Test_Cache(t *testing.T) { + mockFS := fstest.MapFS{ + "index.html": { + Data: []byte(`

Hello Cached World

`), + }, + "main-layout.html": { + Data: []byte(`
{{ embed }}
`), + }, + } + + var buf bytes.Buffer + originalLogger := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + defer slog.SetDefault(originalLogger) + + r := NewHTMLRender(mockFS, true) + + rec1 := httptest.NewRecorder() + err1 := r.Render(rec1, "index.html", nil, "main-layout.html") + if err1 != nil { + t.Errorf("unexpected error: %v", err1) + } + expected := "

Hello Cached World

" + if got := strings.TrimSpace(rec1.Body.String()); got != expected { + t.Errorf("expected body %q, got %q", expected, got) + } + + if !strings.Contains(buf.String(), "template compiled and cached") { + t.Error("expected 'template compiled and cached' message on first render") + } + + buf.Reset() + + rec2 := httptest.NewRecorder() + err2 := r.Render(rec2, "index.html", nil, "main-layout.html") + if err2 != nil { + t.Errorf("unexpected error: %v", err2) + } + if got := strings.TrimSpace(rec2.Body.String()); got != expected { + t.Errorf("expected body %q, got %q", expected, got) + } + + if strings.Contains(buf.String(), "template compiled and cached") { + t.Error("did not expect 'template compiled and cached' message on second render") + } +} +