From 4f3ca6fe2b20e3796acdd6081b2358c8e0fd9dcd Mon Sep 17 00:00:00 2001 From: Gani Georgiev Date: Sun, 23 Jul 2023 15:27:51 +0300 Subject: [PATCH] added helper html template rendering utils --- tools/template/registry.go | 76 +++++++++++++++++++++++++++++ tools/template/registry_test.go | 86 +++++++++++++++++++++++++++++++++ tools/template/renderer.go | 33 +++++++++++++ tools/template/renderer_test.go | 63 ++++++++++++++++++++++++ 4 files changed, 258 insertions(+) create mode 100644 tools/template/registry.go create mode 100644 tools/template/registry_test.go create mode 100644 tools/template/renderer.go create mode 100644 tools/template/renderer_test.go diff --git a/tools/template/registry.go b/tools/template/registry.go new file mode 100644 index 00000000..86ddcb82 --- /dev/null +++ b/tools/template/registry.go @@ -0,0 +1,76 @@ +// Package template is a thin wrapper arround the standard html/template +// and text/template packages that implements a convenient registry to +// load and cache templates on the fly concurrently. +// +// It was created to assist the JSVM plugin HTML rendering, but could be used in other Go code. +// +// Example: +// registry := template.NewRegistry() +// +// html1, err := registry.LoadFiles( +// // the files set wil be parsed only once and then cached +// "layout.html", +// "content.html", +// ).Render(map[string]any{"name": "John"}) +// +// html2, err := registry.LoadFiles( +// // reuse the already parsed and cached files set +// "layout.html", +// "content.html", +// ).Render(map[string]any{"name": "Jane"}) +package template + +import ( + "html/template" + "strings" + + "github.com/pocketbase/pocketbase/tools/store" +) + +// NewRegistry creates and initializes a new blank templates registry. +// +// Use the Registry.Load* methods to load templates into the registry. +func NewRegistry() *Registry { + return &Registry{ + cache: store.New[*Renderer](nil), + } +} + +// Registry defines a templates registry that is safe to be used by multiple goroutines. +// +// Use the Registry.Load* methods to load templates into the registry. +type Registry struct { + cache *store.Store[*Renderer] +} + +// LoadFiles caches (if not already) the specified files set as a +// single template and returns a ready to use Renderer instance. +func (r *Registry) LoadFiles(files ...string) *Renderer { + key := strings.Join(files, ",") + + found := r.cache.Get(key) + + if found == nil { + // parse and cache + tpl, err := template.ParseFiles(files...) + found = &Renderer{template: tpl, parseError: err} + r.cache.Set(key, found) + } + + return found +} + +// LoadString caches (if not already) the specified inline string as a +// single template and returns a ready to use Renderer instance. +func (r *Registry) LoadString(text string) *Renderer { + found := r.cache.Get(text) + + if found == nil { + // parse and cache (using the text as key) + tpl, err := template.New("").Parse(text) + found = &Renderer{template: tpl, parseError: err} + r.cache.Set(text, found) + } + + return found +} diff --git a/tools/template/registry_test.go b/tools/template/registry_test.go new file mode 100644 index 00000000..44f70870 --- /dev/null +++ b/tools/template/registry_test.go @@ -0,0 +1,86 @@ +package template + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNewRegistry(t *testing.T) { + r := NewRegistry() + + if r.cache == nil { + t.Fatalf("Expected cache store to be initialized, got nil") + } + + if v := r.cache.Length(); v != 0 { + t.Fatalf("Expected cache store length to be 0, got %d", v) + } +} + +func TestRegistryLoadFiles(t *testing.T) { + r := NewRegistry() + + t.Run("invalid or missing files", func(t *testing.T) { + r.LoadFiles("file1.missing", "file2.missing") + + key := "file1.missing,file2.missing" + renderer := r.cache.Get(key) + + if renderer == nil { + t.Fatal("Expected renderer to be initialized even if invalid, got nil") + } + + if renderer.template != nil { + t.Fatalf("Expected renderer template to be nil, got %v", renderer.template) + } + + if renderer.parseError == nil { + t.Fatalf("Expected renderer parseError to be set, got nil") + } + }) + + t.Run("valid files", func(t *testing.T) { + // create test templates + dir, err := os.MkdirTemp(os.TempDir(), "template_test") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "base.html"), []byte(`Base:{{template "content"}}`), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "content.html"), []byte(`{{define "content"}}Content:123{{end}}`), 0644); err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + files := []string{filepath.Join(dir, "base.html"), filepath.Join(dir, "content.html")} + + r.LoadFiles(files...) + + renderer := r.cache.Get(strings.Join(files, ",")) + + if renderer == nil { + t.Fatal("Expected renderer to be initialized even if invalid, got nil") + } + + if renderer.template == nil { + t.Fatal("Expected renderer template to be set, got nil") + } + + if renderer.parseError != nil { + t.Fatalf("Expected renderer parseError to be nil, got %v", renderer.parseError) + } + + result, err := renderer.Render(nil) + if err != nil { + t.Fatalf("Unexpected Render() error, got %v", err) + } + + expected := "Base:Content:123" + if result != expected { + t.Fatalf("Expected Render() result %q, got %q", expected, result) + } + }) +} diff --git a/tools/template/renderer.go b/tools/template/renderer.go new file mode 100644 index 00000000..7a2d85dc --- /dev/null +++ b/tools/template/renderer.go @@ -0,0 +1,33 @@ +package template + +import ( + "bytes" + "errors" + "html/template" +) + +// Renderer defines a single parsed template. +type Renderer struct { + template *template.Template + parseError error +} + +// Render executes the template with the specified data as the dot object +// and returns the result as plain string. +func (r *Renderer) Render(data any) (string, error) { + if r.parseError != nil { + return "", r.parseError + } + + if r.template == nil { + return "", errors.New("invalid or nil template") + } + + buf := new(bytes.Buffer) + + if err := r.template.Execute(buf, data); err != nil { + return "", err + } + + return buf.String(), nil +} diff --git a/tools/template/renderer_test.go b/tools/template/renderer_test.go new file mode 100644 index 00000000..7c751114 --- /dev/null +++ b/tools/template/renderer_test.go @@ -0,0 +1,63 @@ +package template + +import ( + "errors" + "html/template" + "testing" +) + +func TestRendererRender(t *testing.T) { + tpl, _ := template.New("").Parse("Hello {{.Name}}!") + tpl.Option("missingkey=error") // enforce execute errors + + scenarios := map[string]struct { + renderer *Renderer + data any + expectedHasErr bool + expectedResult string + }{ + "with nil template": { + &Renderer{}, + nil, + true, + "", + }, + "with parse error": { + &Renderer{ + template: tpl, + parseError: errors.New("test"), + }, + nil, + true, + "", + }, + "with execute error": { + &Renderer{template: tpl}, + nil, + true, + "", + }, + "no error": { + &Renderer{template: tpl}, + struct{ Name string }{"world"}, + false, + "Hello world!", + }, + } + + for name, s := range scenarios { + t.Run(name, func(t *testing.T) { + result, err := s.renderer.Render(s.data) + + hasErr := err != nil + + if s.expectedHasErr != hasErr { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectedHasErr, hasErr, err) + } + + if s.expectedResult != result { + t.Fatalf("Expected result %v, got %v", s.expectedResult, result) + } + }) + } +}