From f9990d37c005e229d475ff537f0fc67191c525eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20P=C3=A9rez?= Date: Tue, 12 Nov 2024 23:46:40 +0100 Subject: [PATCH] add HTML rendering capabilities and helper functions --- example/main.go | 18 +- example/templates/page/component.list.gohtml | 4 + example/templates/page/page.index.gohtml | 16 ++ ron.go | 16 +- template.go | 141 ++++++++++++++ template_test.go | 195 +++++++++++++++++++ testhelpers/testhelpers.go | 33 ++++ testhelpers/testhelpers_test.go | 70 +++++++ 8 files changed, 488 insertions(+), 5 deletions(-) create mode 100644 example/templates/page/component.list.gohtml create mode 100644 example/templates/page/page.index.gohtml create mode 100644 template.go create mode 100644 template_test.go create mode 100644 testhelpers/testhelpers.go create mode 100644 testhelpers/testhelpers_test.go diff --git a/example/main.go b/example/main.go index 03cf272..fa398e2 100644 --- a/example/main.go +++ b/example/main.go @@ -10,9 +10,14 @@ import ( func main() { r := ron.New() + htmlRender := ron.HTMLRender() + r.Renderer = htmlRender + r.GET("/", helloWorld) r.GET("/json", helloWorldJSON) r.POST("/another", anotherHelloWorld) + r.GET("/html", helloWorldHTML) + r.GET("/component", componentHTML) slog.Info("Server is running at http://localhost:8080") http.ListenAndServe(":8080", r) @@ -27,5 +32,16 @@ func anotherHelloWorld(c *ron.Context) { } func helloWorldJSON(c *ron.Context) { - c.JSON(200, ron.D{"message": "hello world"}) + c.JSON(200, ron.Data{"message": "hello world"}) +} + +func helloWorldHTML(c *ron.Context) { + c.HTML(200, "page.index.gohtml", ron.Data{ + "title": "hello world", + "message": "hello world from html", + }) +} + +func componentHTML(c *ron.Context) { + c.HTML(200, "component.list.gohtml", ron.Data{}) } diff --git a/example/templates/page/component.list.gohtml b/example/templates/page/component.list.gohtml new file mode 100644 index 0000000..342f35f --- /dev/null +++ b/example/templates/page/component.list.gohtml @@ -0,0 +1,4 @@ + diff --git a/example/templates/page/page.index.gohtml b/example/templates/page/page.index.gohtml new file mode 100644 index 0000000..b66954b --- /dev/null +++ b/example/templates/page/page.index.gohtml @@ -0,0 +1,16 @@ + + + + + + {{ .Data.title }} + + + + +{{ .Data.message }} + + + + + \ No newline at end of file diff --git a/ron.go b/ron.go index 65bb2df..e6be920 100644 --- a/ron.go +++ b/ron.go @@ -6,7 +6,7 @@ import ( "net/http" ) -type D map[string]interface{} +type Data map[string]any type Context struct { C context.Context @@ -15,7 +15,12 @@ type Context struct { E *Engine } -func (c *Context) JSON(code int, data interface{}) { +type Engine struct { + mux *http.ServeMux + Renderer *Render +} + +func (c *Context) JSON(code int, data Data) { c.W.WriteHeader(code) c.W.Header().Set("Content-Type", "application/json") encoder := json.NewEncoder(c.W) @@ -24,8 +29,11 @@ func (c *Context) JSON(code int, data interface{}) { } } -type Engine struct { - mux *http.ServeMux +func (c *Context) HTML(code int, name string, data Data) { + c.W.WriteHeader(code) + c.E.Renderer.Template(c.W, name, &TemplateData{ + Data: data, + }) } func New() *Engine { diff --git a/template.go b/template.go new file mode 100644 index 0000000..49bef61 --- /dev/null +++ b/template.go @@ -0,0 +1,141 @@ +package ron + +import ( + "bytes" + "errors" + "html/template" + "io/fs" + "net/http" + "path/filepath" + "strings" +) + +type ( + templateCache map[string]*template.Template + + TemplateData struct { + Data Data + } + + OptionFunc func(*Render) + Render struct { + EnableCache bool + TemplatesPath string + Functions template.FuncMap + templateCache templateCache + } +) + +func DefaultHTMLRender() *Render { + return &Render{ + EnableCache: false, + TemplatesPath: "templates", + Functions: make(template.FuncMap), + templateCache: make(templateCache), + } +} + +func HTMLRender(opts ...OptionFunc) *Render { + config := DefaultHTMLRender() + return config.apply(opts...) +} + +func (re *Render) apply(opts ...OptionFunc) *Render { + for _, opt := range opts { + if opt != nil { + opt(re) + } + } + + return re +} + +func (re *Render) Template(w http.ResponseWriter, tmpl string, td *TemplateData) error { + var tc templateCache + var err error + + if td == nil { + td = &TemplateData{} + } + + if re.EnableCache { + tc = re.templateCache + } else { + tc, err = re.createTemplateCache() + if err != nil { + return err + } + } + + t, ok := tc[tmpl] + if !ok { + return errors.New("can't get template from cache") + } + + buf := new(bytes.Buffer) + err = t.Execute(buf, td) + if err != nil { + return err + } + + _, err = buf.WriteTo(w) + if err != nil { + return err + } + + return nil +} + +func (re *Render) findHTMLFiles() ([]string, error) { + var files []string + + err := filepath.WalkDir(re.TemplatesPath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if !d.IsDir() && filepath.Ext(path) == ".gohtml" { + files = append(files, path) + } + + return nil + }) + + if err != nil { + return nil, err + } + + return files, nil +} + +func (re *Render) createTemplateCache() (templateCache, error) { + cache := templateCache{} + var baseTemplates []string + var renderTemplates []string + + templates, err := re.findHTMLFiles() + if err != nil { + return cache, err + } + + for _, file := range templates { + filePathBase := filepath.Base(file) + if strings.Contains(filePathBase, "layout") || strings.Contains(filePathBase, "fragment") { + baseTemplates = append(baseTemplates, file) + } + } + + for _, file := range templates { + filePathBase := filepath.Base(file) + if strings.Contains(filePathBase, "page") || strings.Contains(filePathBase, "component") { + renderTemplates = append(baseTemplates, file) + ts, err := template.New(filePathBase).Funcs(re.Functions).ParseFiles(append(baseTemplates, renderTemplates...)...) + if err != nil { + return cache, err + } + cache[filePathBase] = ts + } + } + + return cache, nil +} diff --git a/template_test.go b/template_test.go new file mode 100644 index 0000000..94744f7 --- /dev/null +++ b/template_test.go @@ -0,0 +1,195 @@ +package ron + +import ( + "html/template" + "net/http/httptest" + "os" + "reflect" + "ron/testhelpers" + "testing" +) + +func Test_DefaultHTMLRender(t *testing.T) { + expected := &Render{ + EnableCache: false, + TemplatesPath: "templates", + Functions: make(template.FuncMap), + templateCache: make(templateCache), + } + + actual := DefaultHTMLRender() + if reflect.DeepEqual(expected, actual) == false { + t.Errorf("Expected: %v, Actual: %v", expected, actual) + } +} + +func Test_HTMLRender(t *testing.T) { + expected := &Render{ + EnableCache: false, + TemplatesPath: "templates", + Functions: make(template.FuncMap), + templateCache: make(templateCache), + } + + tests := []struct { + name string + arg OptionFunc + }{ + {"Empty OptionFunc", OptionFunc(func(r *Render) {})}, + {"Nil", nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := HTMLRender(tt.arg) + if reflect.DeepEqual(expected, actual) == false { + t.Errorf("Expected: %v, Actual: %v", expected, actual) + } + }) + } +} + +func Test_apply(t *testing.T) { + tests := []struct { + name string + expected *Render + actual *Render + }{ + {"Empty OptionFunc", &Render{ + EnableCache: false, + TemplatesPath: "templates", + Functions: make(template.FuncMap), + templateCache: make(templateCache), + }, DefaultHTMLRender()}, + { + name: "Two OptionFunc", expected: &Render{ + EnableCache: true, + TemplatesPath: "foobar", + Functions: make(template.FuncMap), + templateCache: make(templateCache), + }, + actual: HTMLRender(func(r *Render) { + r.EnableCache = true + r.TemplatesPath = "foobar" + }), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if reflect.DeepEqual(tt.expected, tt.actual) == false { + t.Errorf("Expected: %v, Actual: %v", tt.expected, tt.actual) + } + }) + } + +} + +func createDummyFilesAndRender() *Render { + os.MkdirAll("templates", os.ModePerm) + + f, _ := os.Create("templates/layout.base.gohtml") + f.Write([]byte("{{ define \"layout/base\" }}

layout.base.gohtml

{{ .Data.foo }}

{{ block \"base/content\" . }}{{ end }}{{ end }}")) + f.Close() + f, _ = os.Create("templates/layout.another.gohtml") + f.Write([]byte("{{ define \"layout/another\" }}

layout.another.gohtml

{{ .Data.bar }}

{{ block \"base/content\" . }}{{ end }}{{ end }}")) + f.Close() + f, _ = os.Create("templates/fragment.button.gohtml") + f.Close() + f, _ = os.Create("templates/component.list.gohtml") + f.Close() + f, _ = os.Create("templates/page.index.gohtml") + f.Write([]byte("{{ template \"layout/base\" .}}{{ define \"base/content\" }}

page.index.gohtml

{{ .Data.bar }}

{{ end }}")) + f.Close() + f, _ = os.Create("templates/page.another.gohtml") + f.Write([]byte("{{ template \"layout/another\" .}}{{ define \"base/content\" }}

page.another.gohtml

{{ .Data.foo }}

{{ end }}")) + f.Close() + + render := DefaultHTMLRender() + return render +} + +func Test_findHTMLFiles(t *testing.T) { + render := createDummyFilesAndRender() + if render == nil { + t.Errorf("Error: %v", render) + return + } + defer os.RemoveAll("templates") + + expected := []string{ + "templates\\layout.base.gohtml", + "templates\\layout.another.gohtml", + "templates\\fragment.button.gohtml", + "templates\\component.list.gohtml", + "templates\\page.index.gohtml", + "templates\\page.another.gohtml", + } + actual, err := render.findHTMLFiles() + if err != nil { + t.Errorf("Error: %v", err) + } + + expectedAny := testhelpers.StringSliceToAnySlice(expected) + actualAny := testhelpers.StringSliceToAnySlice(actual) + + if testhelpers.CheckSlicesEquality(expectedAny, actualAny) == false { + t.Errorf("Expected: %v, Actual: %v", expected, actual) + } +} + +func Test_createTemplateCache(t *testing.T) { + render := createDummyFilesAndRender() + if render == nil { + t.Errorf("Error: %v", render) + } + defer os.RemoveAll("templates") + + tc, err := render.createTemplateCache() + if err != nil || len(tc) != 3 { + t.Errorf("Error: %v", err) + } + + templateNames := []string{ + "component.list.gohtml", + "page.index.gohtml", + "page.another.gohtml", + } + + for _, templateName := range templateNames { + if _, ok := tc[templateName]; ok == false { + t.Errorf("Error: %v", err) + } + } +} + +func Test_TemplateDefault(t *testing.T) { + tests := []struct { + name string + expected string + }{ + {"index", "

layout.base.gohtml

Foo

page.index.gohtml

Bar

"}, + {"another", "

layout.another.gohtml

Bar

page.another.gohtml

Foo

"}, + } + + render := createDummyFilesAndRender() + if render == nil { + t.Errorf("Error: %v", render) + } + defer os.RemoveAll("templates") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rr := httptest.NewRecorder() + render.Template(rr, "page."+tt.name+".gohtml", &TemplateData{ + Data: Data{ + "foo": "Foo", + "bar": "Bar", + }}) + + if rr.Body.String() != tt.expected { + t.Errorf("Expected: %v, Actual: %v", tt.expected, rr.Body.String()) + } + }) + } +} diff --git a/testhelpers/testhelpers.go b/testhelpers/testhelpers.go new file mode 100644 index 0000000..1fc8a9c --- /dev/null +++ b/testhelpers/testhelpers.go @@ -0,0 +1,33 @@ +package testhelpers + +func CheckSlicesEquality(a []any, b []any) bool { + if len(a) != len(b) { + return false + } + + aMap := make(map[any]int) + bMap := make(map[any]int) + + for _, v := range a { + aMap[v]++ + } + for _, v := range b { + bMap[v]++ + } + + for k, v := range aMap { + if bMap[k] != v { + return false + } + } + + return true +} + +func StringSliceToAnySlice(s []string) []any { + var result []any + for _, v := range s { + result = append(result, v) + } + return result +} diff --git a/testhelpers/testhelpers_test.go b/testhelpers/testhelpers_test.go new file mode 100644 index 0000000..bc9df4e --- /dev/null +++ b/testhelpers/testhelpers_test.go @@ -0,0 +1,70 @@ +package testhelpers + +import ( + "testing" +) + +func Test_CheckSlicesEquality(t *testing.T) { + tests := []struct { + name string + ok bool + sliceA []any + sliceB []any + }{ + { + name: "Integers", + ok: true, + sliceA: []any{2, 3, 1}, + sliceB: []any{1, 2, 3}, + }, + { + name: "Strings", + ok: true, + sliceA: []any{"x", "y", "z"}, + sliceB: []any{"z", "y", "x"}, + }, + { + name: "Integers 2", + ok: true, + sliceA: []any{1, 2, 3}, + sliceB: []any{1, 2, 3}, + }, + { + name: "Different lengths", + ok: false, + sliceA: []any{1, 2, 3}, + sliceB: []any{1, 2, 3, 4}, + }, + { + name: "Different lengths 2", + ok: false, + sliceA: []any{1, 2, 3, 4}, + sliceB: []any{1, 2, 3}, + }, + { + name: "Different types", + ok: false, + sliceA: []any{1, 2, 3}, + sliceB: []any{"1", "2", "3"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if result := CheckSlicesEquality(tt.sliceA, tt.sliceB); result != tt.ok { + t.Errorf("CheckSlicesEquality() = %v, want %v", result, tt.ok) + } + }) + } + +} + +func Test_StringSliceToAnySlice(t *testing.T) { + expected := []any{"a", "b", "c"} + actual := StringSliceToAnySlice([]string{"a", "b", "c"}) + + if !CheckSlicesEquality(expected, actual) { + t.Errorf("StringSliceToAnySlice() = %v, want %v", actual, expected) + } + +}