From 58033d8e3717ed6cf1d94494c03050677d3420d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20P=C3=A9rez?= Date: Wed, 21 Aug 2024 22:39:00 +0200 Subject: [PATCH] feat: :tada: nace gorender --- README.md | 106 +++++++++++++++++++ example/go.mod | 20 ++++ example/go.sum | 30 ++++++ example/main.go | 37 +++++++ example/template/base.html | 22 ++++ example/template/fragment.html | 9 ++ example/template/pages/page.html | 7 ++ form.go | 95 +++++++++++++++++ functions.go | 71 +++++++++++++ go.mod | 19 ++++ go.sum | 30 ++++++ pages.go | 130 ++++++++++++++++++++++++ render.go | 168 +++++++++++++++++++++++++++++++ 13 files changed, 744 insertions(+) create mode 100644 README.md create mode 100644 example/go.mod create mode 100644 example/go.sum create mode 100644 example/main.go create mode 100644 example/template/base.html create mode 100644 example/template/fragment.html create mode 100644 example/template/pages/page.html create mode 100644 form.go create mode 100644 functions.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 pages.go create mode 100644 render.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..275c391 --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# gorender + +Simple y minimalista librería para procesar plantillas utilizando la librería +estándar de Go `html/template`. + +## Características + +- Procesamiento de plantillas utilizando `html/template`. +- Soporte para caché de plantillas. +- Posibilidad de añadir funciones personalizadas a las plantillas. +- Configuración sencilla con opciones por defecto que se pueden sobreescribir. +Inspirado en `Gin`. + +## Instalación + +```bash +go get github.com/zepyrshut/gorender +``` + +## Uso mínimo + +Las plantillas deben tener la siguiente estructura, observa que las páginas a +procesar están dentro de `pages`. Los demás componentes como bases y fragmentos +pueden estar en el directorio raíz o dentro de un directorio. + +Puedes cambiar el nombre del directorio `template` y `pages`. Ejemplo en la +siguiente sección. + +``` +template/ +├── pages/ +│ └── page.html +├── base.html +└── fragment.html +``` + +```go +import ( + "github.com/zepyrshut/gorender" +) + +func main() { + ren := gorender.New() + + // ... + + td := &gorender.TemplateData{} + ren.Template(w, r, "index.html", td) + + // ... +} +``` + +## Personalización + +> Recuerda que si habilitas el caché, no podrás ver los cambios que realices +> durante el desarrollo. + +```go +func dummyFunc() string { + return "dummy" +} + +func main() { + + customFuncs := template.FuncMap{ + "dummyFunc": dummyFunc, + } + + renderOpts := &gorender.Render{ + EnableCache: true, + TemplatesPath: "template/path", + PageTemplatesPath: "template/path/pages", + Functions: customFuncs, + } + + ren := gorender.New(gorender.WithRenderOptions(renderOpts)) + + // ... + + td := &gorender.TemplateData{} + ren.Template(w, r, "index.html", td) + + // ... +} +``` +## Agradecimientos + +- [Protección CSRF justinas/nosurf](https://github.com/justinas/nosurf) +- [Valicación go-playground/validator](https://github.com/go-playground/validator) + +## Descargo de responsabilidad + +Esta librería fue creada para usar las plantillas en mis proyectos privados, es +posible que también solucione su problema. Sin embargo, no ofrezco ninguna +garantía de que funcione para todos los casos de uso, tenga el máximo +rendimiento o esté libre de errores. + +Si decides integrarla en tu proyecto, te recomiendo que la pruebes para +asegurarte de que cumple con tus expectativas y requisitos. + +Si encuentras problemas o tienes sugerencias de mejora, puedes colocar tus +aportaciones a través de _issues_ o _pull requests_ en el repositorio. Estaré +encantado de ayudarte. + + diff --git a/example/go.mod b/example/go.mod new file mode 100644 index 0000000..441ffa6 --- /dev/null +++ b/example/go.mod @@ -0,0 +1,20 @@ +module gorender/example + +go 1.23.0 + +replace github.com/zepyrshut/gorender => ../ + +require github.com/zepyrshut/gorender v0.0.0-00010101000000-000000000000 + +require ( + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.22.0 // indirect + github.com/justinas/nosurf v1.1.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + golang.org/x/crypto v0.19.0 // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/sys v0.17.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/example/go.sum b/example/go.sum new file mode 100644 index 0000000..d6f9aad --- /dev/null +++ b/example/go.sum @@ -0,0 +1,30 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao= +github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/justinas/nosurf v1.1.1 h1:92Aw44hjSK4MxJeMSyDa7jwuI9GR2J/JCQiaKvXXSlk= +github.com/justinas/nosurf v1.1.1/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/example/main.go b/example/main.go new file mode 100644 index 0000000..14e6ce1 --- /dev/null +++ b/example/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "fmt" + "net/http" + "text/template" + + "github.com/zepyrshut/gorender" +) + +func dummyFunc() string { + return "dummy function" +} + +func main() { + newFuncs := template.FuncMap{ + "dummyFunc": dummyFunc, + } + + renderOpts := &gorender.Render{ + EnableCache: true, + TemplatesPath: "template", + PageTemplatesPath: "template/pages", + Functions: newFuncs, + } + + ren := gorender.New(gorender.WithRenderOptions(renderOpts)) + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + td := &gorender.TemplateData{} + + ren.Template(w, r, "page.html", td) + }) + + fmt.Println("Server running on port 8080") + http.ListenAndServe(":8080", nil) +} diff --git a/example/template/base.html b/example/template/base.html new file mode 100644 index 0000000..ea051d3 --- /dev/null +++ b/example/template/base.html @@ -0,0 +1,22 @@ +{{ define "base" }} + + + + + + + gorender example + + + + +

Base

+ +
+ {{ block "content" . }}{{ end }} +
+ + + + +{{ end }} \ No newline at end of file diff --git a/example/template/fragment.html b/example/template/fragment.html new file mode 100644 index 0000000..70e943e --- /dev/null +++ b/example/template/fragment.html @@ -0,0 +1,9 @@ +{{ define "fragment" }} + +

Fragment

+ + +{{ dummyFunc }} + + +{{ end }} \ No newline at end of file diff --git a/example/template/pages/page.html b/example/template/pages/page.html new file mode 100644 index 0000000..5145c05 --- /dev/null +++ b/example/template/pages/page.html @@ -0,0 +1,7 @@ +{{ template "base" . }} +{{ define "content" }} + +

Page with fragment

+ +{{ template "fragment" . }} +{{ end }} \ No newline at end of file diff --git a/form.go b/form.go new file mode 100644 index 0000000..0a2f46e --- /dev/null +++ b/form.go @@ -0,0 +1,95 @@ +package gorender + +import ( + "strings" + + spanish "github.com/go-playground/locales/es" + ut "github.com/go-playground/universal-translator" + "github.com/go-playground/validator/v10" + esTranslations "github.com/go-playground/validator/v10/translations/es" +) + +type FormData struct { + HasErrors bool + Errors map[string]string + Values map[string]string +} + +func NewForm() FormData { + return FormData{ + HasErrors: false, + Errors: map[string]string{}, + Values: map[string]string{}, + } +} + +// AddError añade errores a la estructura FormData, es un mapa cuya clave es una +// cadena de carecteres. Hay que tener en cuenta que cuando se hace una +// validación, se llama a esta función cuya clave es el nombre del campo con lo +// cual si hay más de un error de validación se sobreescriben el anterior y sólo +// se muestra el último error. +func (fd *FormData) AddError(field, message string) { + fd.HasErrors = true + fd.Errors[field] = message +} + +func (fd *FormData) AddValue(field, value string) { + fd.Values[field] = value +} + +type ValidationError struct { + Field string + Reason string +} + +func (fd *FormData) ValidateStruct(s interface{}) (map[string]string, error) { + spanishTranslator := spanish.New() + uni := ut.New(spanishTranslator, spanishTranslator) + trans, _ := uni.GetTranslator("es") + validate := validator.New() + _ = esTranslations.RegisterDefaultTranslations(validate, trans) + errors := make(map[string]string) + var validationErrors []ValidationError + + err := validate.Struct(s) + if err != nil { + if _, ok := err.(*validator.InvalidValidationError); ok { + fd.AddError("form-error", "Error de validación de datos.") + return errors, err + } + + for _, err := range err.(validator.ValidationErrors) { + fieldName, _ := trans.T(err.Field()) + message := strings.Replace(err.Translate(trans), err.Field(), fieldName, -1) + + validationErrors = append(validationErrors, ValidationError{ + Field: strings.ToLower(err.Field()), + Reason: correctMessage(message), + }) + } + + for _, err := range validationErrors { + errors[err.Field] = err.Reason + } + + if len(errors) > 0 { + fd.Errors = errors + fd.HasErrors = true + } + + return errors, err + } + + return errors, nil +} + +func correctMessage(s string) string { + s = strings.TrimSpace(s) + runes := []rune(s) + runes[0] = []rune(strings.ToUpper(string(runes[0])))[0] + if runes[len(runes)-1] != '.' { + runes = append(runes, '.') + } + + return string(runes) +} diff --git a/functions.go b/functions.go new file mode 100644 index 0000000..d00def8 --- /dev/null +++ b/functions.go @@ -0,0 +1,71 @@ +package gorender + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +func or(a, b string) bool { + if a == "" && b == "" { + return false + } + return true +} + +// containsErrors hace una función similar a "{{ with index ... }}" con el +// añadido de que puede pasarle más de un argumento y comprobar si alguno de +// ellos está en el mapa de errores. +// +// Ejemplo: +// +// {{ if containsErrors .FormData.Errors "name" "email" }} +// {{index .FormData.Errors "name" }} +// {{index .FormData.Errors "email" }} +// {{ end }} +func containsErrors(errors map[string]string, names ...string) bool { + for _, name := range names { + if _, ok := errors[name]; ok { + return true + } + } + return false +} + +func loadTranslations(language string) map[string]string { + translations := make(map[string]string) + filePath := fmt.Sprintf("%s.translate", language) + file, err := os.Open(filePath) + if err != nil { + fmt.Println("Error opening translation file:", err) + return translations + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + parts := strings.Split(line, "=") + if len(parts) == 2 { + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + translations[key] = value + } + } + + if err := scanner.Err(); err != nil { + fmt.Println("Error reading translation file:", err) + } + + return translations +} + +func translateKey(key string) string { + translations := loadTranslations("es_ES") + translated := translations[key] + if translated != "" { + return translated + } + return key +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ccdaa6b --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module github.com/zepyrshut/gorender + +go 1.23.0 + +require ( + github.com/go-playground/locales v0.14.1 + github.com/go-playground/universal-translator v0.18.1 + github.com/go-playground/validator/v10 v10.22.0 + github.com/justinas/nosurf v1.1.1 +) + +require ( + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + golang.org/x/crypto v0.19.0 // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/sys v0.17.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d6f9aad --- /dev/null +++ b/go.sum @@ -0,0 +1,30 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao= +github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/justinas/nosurf v1.1.1 h1:92Aw44hjSK4MxJeMSyDa7jwuI9GR2J/JCQiaKvXXSlk= +github.com/justinas/nosurf v1.1.1/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pages.go b/pages.go new file mode 100644 index 0000000..3f58218 --- /dev/null +++ b/pages.go @@ -0,0 +1,130 @@ +package gorender + +// Pages contiene la información de paginación. +type Pages struct { + // totalElements son la cantidad de elementos totales a paginar. Pueden ser + // total de filas o total de páginas de blog. + totalElements int + // showElements muestra la cantidad máxima de elementos a mostrar en una + // página. + showElements int + // currentPage es la página actual, utilizado como ayuda para mostrar la + // página activa. + currentPage int +} + +// Page contiene la información de una página. +type Page struct { + // number es el número de página. + number int + // active es un dato lógico que indica si la página es la actual. + active bool +} + +// NewPages crea un nuevo objeto para paginación. +func NewPages(totalElements, showElements, currentPage int) Pages { + if showElements <= 0 { + showElements = 1 + } + if currentPage <= 0 { + currentPage = 1 + } + p := Pages{totalElements, showElements, currentPage} + if p.currentPage > p.TotalPages() { + p.currentPage = p.TotalPages() + } + + return p +} + +// Limit devuelve la cantidad de elementos máximos a mostrar por página. +func (p *Pages) Limit() int { + return p.showElements +} + +// TotalPages devuelve la cantidad total de páginas. +func (p *Pages) TotalPages() int { + return (p.totalElements + p.showElements - 1) / p.showElements +} + +// IsFirst indica si la página actual es la primera. +func (p *Pages) IsFirst() bool { + return p.currentPage == 1 +} + +// IsLast indica si la página actual es la última. +func (p *Pages) IsLast() bool { + return p.currentPage == p.TotalPages() +} + +// HasPrevious indica si hay una página anterior. +func (p *Pages) HasPrevious() bool { + return p.currentPage > 1 +} + +// HasNext indica si hay una página siguiente. +func (p *Pages) HasNext() bool { + return p.currentPage < p.TotalPages() +} + +// Previous devuelve el número de la página anterior. +func (p *Pages) Previous() int { + return p.currentPage - 1 +} + +// Next devuelve el número de la página siguiente. +func (p *Pages) Next() int { + return p.currentPage + 1 +} + +func (p *Page) NumberOfPage() int { + return p.number +} + +// IsActive indica si la página es la actual. +func (p *Page) IsActive() bool { + return p.active +} + +// Pages devuelve un arreglo de páginas para mostrar en la paginación. El +// parametro pagesShow indica la cantidad de páginas a mostrar, asignable desde +// la plantilla. +func (p *Pages) Pages(pagesShow int) []*Page { + var pages []*Page + startPage := p.currentPage - (pagesShow / 2) + endPage := p.currentPage + (pagesShow/2 - 1) + + if startPage < 1 { + startPage = 1 + endPage = pagesShow + } + + if endPage > p.TotalPages() { + endPage = p.TotalPages() + startPage = p.TotalPages() - pagesShow + 1 + if startPage < 1 { + startPage = 1 + } + } + + for i := startPage; i <= endPage; i++ { + pages = append(pages, &Page{i, i == p.currentPage}) + } + return pages +} + +func PaginateArray[T any](items []T, currentPage, itemsPerPage int) []T { + totalItems := len(items) + + startIndex := (currentPage - 1) * itemsPerPage + endIndex := startIndex + itemsPerPage + + if startIndex > totalItems { + startIndex = totalItems + } + if endIndex > totalItems { + endIndex = totalItems + } + + return items[startIndex:endIndex] +} diff --git a/render.go b/render.go new file mode 100644 index 0000000..1213ecc --- /dev/null +++ b/render.go @@ -0,0 +1,168 @@ +package gorender + +import ( + "bytes" + "errors" + "fmt" + "html/template" + "io/fs" + "log" + "log/slog" + "net/http" + "path/filepath" + + "github.com/justinas/nosurf" +) + +type TemplateCache map[string]*template.Template + +type Render struct { + EnableCache bool + // TemplatesPath es la ruta donde se encuentran las plantillas de la + // aplicación, pueden ser bases, fragmentos o ambos. Lo que quieras. + TemplatesPath string + // PageTemplatesPath es la ruta donde se encuentran las plantillas de las + // páginas de la aplicación. Estas son las que van a ser llamadas para + // mostrar en pantalla. + PageTemplatesPath string + TemplateCache TemplateCache + Functions template.FuncMap +} + +type OptionFunc func(*Render) + +type TemplateData struct { + Data map[string]interface{} + // FeedbackData tiene como función mostrar los mensajes habituales de + // información, advertencia, éxito y error. No va implícitamente relacionado + // con los errores de validación de formularios pero pueden ser usados para + // ello. + FeedbackData map[string]string + // FormData es una estructura que contiene los errores de validación de los + // formularios además de los valores que se han introducido en los campos. + FormData FormData + CSRFToken string + Page Pages +} + +func WithRenderOptions(opts *Render) OptionFunc { + return func(re *Render) { + re.TemplatesPath = opts.TemplatesPath + re.PageTemplatesPath = opts.PageTemplatesPath + + if opts.Functions != nil { + for k, v := range opts.Functions { + re.Functions[k] = v + } + } + + if opts.EnableCache { + re.EnableCache = opts.EnableCache + re.TemplateCache, _ = re.CreateTemplateCache() + } + } +} + +func New(opts ...OptionFunc) *Render { + functions := template.FuncMap{ + "translateKey": translateKey, + "or": or, + "containsErrors": containsErrors, + } + + config := &Render{ + EnableCache: false, + TemplatesPath: "templates", + PageTemplatesPath: "templates/pages", + TemplateCache: TemplateCache{}, + Functions: functions, + } + + return config.Apply(opts...) +} + +func (re *Render) Apply(opts ...OptionFunc) *Render { + for _, opt := range opts { + opt(re) + } + + return re +} + +func AddDefaultData(td *TemplateData, r *http.Request) *TemplateData { + td.CSRFToken = nosurf.Token(r) + return td +} + +func (re *Render) Template(w http.ResponseWriter, r *http.Request, tmpl string, td *TemplateData) error { + var tc TemplateCache + var err error + + if re.EnableCache { + tc = re.TemplateCache + } else { + tc, err = re.CreateTemplateCache() + if err != nil { + log.Println("error creating template cache:", err) + return err + } + } + + t, ok := tc[tmpl] + if !ok { + return errors.New("can't get template from cache") + } + + buf := new(bytes.Buffer) + td = AddDefaultData(td, r) + err = t.Execute(buf, td) + if err != nil { + log.Println("error executing template:", err) + return err + } + + _, err = buf.WriteTo(w) + if err != nil { + log.Println("error writing template to browser:", err) + } + + return nil +} + +func (re *Render) CreateTemplateCache() (TemplateCache, error) { + myCache := TemplateCache{} + + pagesTemplates, err := filepath.Glob(fmt.Sprintf("%s/*.html", re.PageTemplatesPath)) + if err != nil { + return myCache, err + } + + files := []string{} + filepath.WalkDir(re.TemplatesPath, func(path string, file fs.DirEntry, err error) error { + if err != nil { + return err + } + if !file.IsDir() { + slog.Info("file found", "path", path) + files = append(files, path) + } + + return nil + }) + + for function, _ := range re.Functions { + slog.Info("function found", "function", function) + } + + for _, file := range pagesTemplates { + name := filepath.Base(file) + ts, err := template.New(name).Funcs(re.Functions).ParseFiles(append(files, file)...) + if err != nil { + return myCache, err + } + + myCache[name] = ts + } + + return myCache, nil +}