add HTML rendering capabilities and helper functions

This commit is contained in:
Pedro Pérez 2024-11-12 23:46:40 +01:00
parent e44f56635b
commit f9990d37c0
8 changed files with 488 additions and 5 deletions

View File

@ -10,9 +10,14 @@ import (
func main() { func main() {
r := ron.New() r := ron.New()
htmlRender := ron.HTMLRender()
r.Renderer = htmlRender
r.GET("/", helloWorld) r.GET("/", helloWorld)
r.GET("/json", helloWorldJSON) r.GET("/json", helloWorldJSON)
r.POST("/another", anotherHelloWorld) r.POST("/another", anotherHelloWorld)
r.GET("/html", helloWorldHTML)
r.GET("/component", componentHTML)
slog.Info("Server is running at http://localhost:8080") slog.Info("Server is running at http://localhost:8080")
http.ListenAndServe(":8080", r) http.ListenAndServe(":8080", r)
@ -27,5 +32,16 @@ func anotherHelloWorld(c *ron.Context) {
} }
func helloWorldJSON(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{})
} }

View File

@ -0,0 +1,4 @@
<ul>
<li>elem1</li>
<li>elem2</li>
</ul>

View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .Data.title }}</title>
</head>
<body>
{{ .Data.message }}
</body>
</html>

16
ron.go
View File

@ -6,7 +6,7 @@ import (
"net/http" "net/http"
) )
type D map[string]interface{} type Data map[string]any
type Context struct { type Context struct {
C context.Context C context.Context
@ -15,7 +15,12 @@ type Context struct {
E *Engine 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.WriteHeader(code)
c.W.Header().Set("Content-Type", "application/json") c.W.Header().Set("Content-Type", "application/json")
encoder := json.NewEncoder(c.W) encoder := json.NewEncoder(c.W)
@ -24,8 +29,11 @@ func (c *Context) JSON(code int, data interface{}) {
} }
} }
type Engine struct { func (c *Context) HTML(code int, name string, data Data) {
mux *http.ServeMux c.W.WriteHeader(code)
c.E.Renderer.Template(c.W, name, &TemplateData{
Data: data,
})
} }
func New() *Engine { func New() *Engine {

141
template.go Normal file
View File

@ -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
}

195
template_test.go Normal file
View File

@ -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\" }}<p>layout.base.gohtml</p><p>{{ .Data.foo }}</p>{{ block \"base/content\" . }}{{ end }}{{ end }}"))
f.Close()
f, _ = os.Create("templates/layout.another.gohtml")
f.Write([]byte("{{ define \"layout/another\" }}<p>layout.another.gohtml</p><p>{{ .Data.bar }}</p>{{ 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\" }}<p>page.index.gohtml</p><p>{{ .Data.bar }}</p>{{ end }}"))
f.Close()
f, _ = os.Create("templates/page.another.gohtml")
f.Write([]byte("{{ template \"layout/another\" .}}{{ define \"base/content\" }}<p>page.another.gohtml</p><p>{{ .Data.foo }}</p>{{ 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", "<p>layout.base.gohtml</p><p>Foo</p><p>page.index.gohtml</p><p>Bar</p>"},
{"another", "<p>layout.another.gohtml</p><p>Bar</p><p>page.another.gohtml</p><p>Foo</p>"},
}
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())
}
})
}
}

View File

@ -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
}

View File

@ -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)
}
}