add templating engine

This commit is contained in:
Pedro Pérez 2025-05-11 12:21:16 +02:00
parent 827cb66b4e
commit 2aa592252c
5 changed files with 425 additions and 73 deletions

83
app.go
View File

@ -21,53 +21,6 @@ import (
_ "github.com/jackc/pgx/v5/stdlib"
)
// TODO: review consts
const (
// Handlers keys
InvalidRequest = "invalid_request"
MalformedJSON = "malformed_json"
TokenBlacklisted = "token_blacklisted"
TokenInvalid = "token_invalid"
ValidationFailed = "validation_failed"
UntilBeforeTo = "until_before_to"
InternalError = "internal_error"
NotFound = "not_found"
Created = "created"
Updated = "updated"
Deleted = "deleted"
Enabled = "enabled"
Disabled = "disabled"
Retrieved = "retrieved"
ErrorCreating = "error_creating"
ErrorUpdating = "error_updating"
ErrorEnabling = "error_enabling"
ErrorDisabling = "error_disabling"
ErrorGetting = "error_getting"
ErrorGettingAll = "error_getting_all"
ErrorMailing = "error_mailing"
InvalidEntityID = "invalid_entity_id"
NotImplemented = "not_implemented"
NotPassValidation = "not_pass_validation"
NotEnoughBalance = "not_enough_balance"
InvalidIdentifier = "invalid_identifier"
// User keys (DB)
UserUsernameKey = "username_key"
UserEmailKey = "email_key"
UsernameAlreadyExists = "username_already_exists"
UserSessionKey = "user_session_key"
EmailAlreadyExists = "email_already_exists"
PhoneNumberKey = "phone_number_key"
PhoneAlreadyExists = "phone_already_exists"
NoRowsAffected = "no rows in result set"
// Auth
TokenPayload = "token_payload"
LoggedIn = "logged_in"
IncorrectPassword = "incorrect_password"
ErrorGeneratingToken = "error_generating_token"
)
var (
logFile *os.File
logLevel string
@ -90,10 +43,10 @@ type DatabaseConfig struct {
}
type Config struct {
// default ""
// default "no-name-defined"
Name string
// default ""
// default "v0.0.0"
Version string
// default "development"
@ -111,21 +64,21 @@ type Config struct {
// default map[string]DatabaseConfig{}
Databases map[string]DatabaseConfig
// default false
CreateTemplates bool
// default false
CreateSession bool
// default false
CreateMailer bool
// default false
CreateTemplates bool
}
type App struct {
config Config
Session *scs.SessionManager
Mailer Mailer
//Templates *Templates
config Config
Templates *Render
Session *scs.SessionManager
Mailer Mailer
}
type Paseto struct {
@ -248,12 +201,20 @@ func New(config ...Config) *App {
"paseto_public_key", cfg.Paseto.PublicKey.ExportHex(),
"paseto_duration", cfg.Paseto.Duration.String(),
"databases", cfg.Databases,
"create_templates", cfg.CreateTemplates,
"create_session", cfg.CreateSession,
"create_mailer", cfg.CreateMailer,
)
if cfg.EnvMode != EnvironmentProduction {
slog.Info("paseto_assymetric_key", "key", cfg.Paseto.AsymmetricKey.ExportHex())
}
if cfg.CreateTemplates {
slog.Debug("creating templates")
app.Templates = NewHTMLRender()
}
if cfg.CreateSession {
slog.Debug("creating session")
app.Session = scs.New()
@ -283,14 +244,14 @@ func (a *App) LogLevel() slog.Level {
return a.config.LogLevel
}
func (a *App) Paseto() *Paseto {
return a.config.Paseto
}
func (a *App) Timezone() string {
return a.config.Timezone
}
func (a *App) Paseto() *Paseto {
return a.config.Paseto
}
func (a *App) Datasource(name string) string {
config, exists := a.config.Databases[name]
if !exists {
@ -306,7 +267,7 @@ func (a *App) Datasource(name string) string {
// cmd/main.go
//
// cmd/database/migrations/*.sql
func (a *App) Migrate(database embed.FS, dbName string) {
func (a *App) Migrate(dbName string, database embed.FS) {
dbConfig, exists := a.config.Databases[dbName]
if !exists {
slog.Error("database configuration not found", "name", dbName)

48
consts.go Normal file
View File

@ -0,0 +1,48 @@
package goblocks
// TODO: review consts
const (
// Handlers keys
InvalidRequest = "invalid_request"
MalformedJSON = "malformed_json"
TokenBlacklisted = "token_blacklisted"
TokenInvalid = "token_invalid"
ValidationFailed = "validation_failed"
UntilBeforeTo = "until_before_to"
InternalError = "internal_error"
NotFound = "not_found"
Created = "created"
Updated = "updated"
Deleted = "deleted"
Enabled = "enabled"
Disabled = "disabled"
Retrieved = "retrieved"
ErrorCreating = "error_creating"
ErrorUpdating = "error_updating"
ErrorEnabling = "error_enabling"
ErrorDisabling = "error_disabling"
ErrorGetting = "error_getting"
ErrorGettingAll = "error_getting_all"
ErrorMailing = "error_mailing"
InvalidEntityID = "invalid_entity_id"
NotImplemented = "not_implemented"
NotPassValidation = "not_pass_validation"
NotEnoughBalance = "not_enough_balance"
InvalidIdentifier = "invalid_identifier"
// User keys (DB)
UserUsernameKey = "username_key"
UserEmailKey = "email_key"
UsernameAlreadyExists = "username_already_exists"
UserSessionKey = "user_session_key"
EmailAlreadyExists = "email_already_exists"
PhoneNumberKey = "phone_number_key"
PhoneAlreadyExists = "phone_already_exists"
NoRowsAffected = "no rows in result set"
// Auth
TokenPayload = "token_payload"
LoggedIn = "logged_in"
IncorrectPassword = "incorrect_password"
ErrorGeneratingToken = "error_generating_token"
)

View File

@ -1,12 +0,0 @@
package network
import (
"encoding/json"
"net/http"
)
func JSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}

24
render.go Normal file
View File

@ -0,0 +1,24 @@
package goblocks
import (
"encoding/json"
"log/slog"
"net/http"
)
func (a *App) JSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
func (a *App) HTML(w http.ResponseWriter, code int, name string, td *TemplateData) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
err := a.Templates.Template(w, name, td)
if err != nil {
slog.Error("error rendering template", "error", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(code)
}

331
templates.go Normal file
View File

@ -0,0 +1,331 @@
package goblocks
import (
"bytes"
"errors"
"html/template"
"io/fs"
"log/slog"
"net/http"
"path/filepath"
"reflect"
"strconv"
"strings"
)
type (
templateCache map[string]*template.Template
TemplateData struct {
Data map[string]any
Pages Pages
}
RenderOptions func(*Render)
Render struct {
EnableCache bool
TemplatesPath string
Functions template.FuncMap
TemplateData TemplateData
templateCache templateCache
}
)
func defaultHTMLRender() *Render {
return &Render{
EnableCache: false,
TemplatesPath: "templates",
TemplateData: TemplateData{},
Functions: template.FuncMap{},
templateCache: templateCache{},
}
}
func NewHTMLRender(opts ...RenderOptions) *Render {
config := defaultHTMLRender()
return config.apply(opts...)
}
func (re *Render) apply(opts ...RenderOptions) *Render {
for _, opt := range opts {
if opt != nil {
opt(re)
}
}
return re
}
func defaultIfEmpty(fallback, value string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
func (re *Render) Template(w http.ResponseWriter, tmpl string, td *TemplateData) error {
var tc templateCache
var err error
re.Functions["default"] = defaultIfEmpty
if td == nil {
td = &TemplateData{}
}
tc, err = re.getTemplateCache()
if err != nil {
return err
}
t, ok := tc[tmpl]
if !ok {
return errors.New("can't get template from cache")
}
buf := new(bytes.Buffer)
if err = t.Execute(buf, td); err != nil {
return err
}
if _, err = buf.WriteTo(w); err != nil {
return err
}
return nil
}
func (re *Render) getTemplateCache() (templateCache, error) {
if len(re.templateCache) == 0 {
cachedTemplates, err := re.createTemplateCache()
if err != nil {
return nil, err
}
re.templateCache = cachedTemplates
}
if re.EnableCache {
return re.templateCache, nil
}
return re.createTemplateCache()
}
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
}
slog.Debug("templates", "templates", templates)
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
}
// Pages contains pagination info.
type Pages struct {
// TotalElements indicates the total number of elements available for
// pagination.
TotalElements int
// ElementsPerPage defines the number of elements to display per page in
// pagination.
ElementsPerPage int
// ActualPage represents the current page number in pagination.
ActualPage int
}
func (p *Pages) PaginationParams(r *http.Request) {
limit := r.FormValue("limit")
page := r.FormValue("page")
if limit == "" {
if p.ElementsPerPage != 0 {
limit = strconv.Itoa(p.ElementsPerPage)
} else {
limit = "20"
}
}
if page == "" || page == "0" {
if p.ActualPage != 0 {
page = strconv.Itoa(p.ActualPage)
} else {
page = "1"
}
}
limitInt, _ := strconv.Atoi(limit)
pageInt, _ := strconv.Atoi(page)
offset := (pageInt - 1) * limitInt
currentPage := offset/limitInt + 1
p.ElementsPerPage = limitInt
p.ActualPage = currentPage
}
func (p Pages) PaginateArray(elements any) any {
itemsValue := reflect.ValueOf(elements)
if p.ActualPage < 1 {
p.ActualPage = 1
}
if p.ActualPage > p.TotalPages() {
p.ActualPage = p.TotalPages()
}
startIndex := (p.ActualPage - 1) * p.ElementsPerPage
endIndex := startIndex + p.ElementsPerPage
return itemsValue.Slice(startIndex, endIndex).Interface()
}
func (p Pages) CurrentPage() int {
return p.ActualPage
}
func (p Pages) TotalPages() int {
return (p.TotalElements + p.ElementsPerPage - 1) / p.ElementsPerPage
}
func (p Pages) IsFirst() bool {
return p.ActualPage == 1
}
func (p Pages) IsLast() bool {
return p.ActualPage == p.TotalPages()
}
func (p Pages) HasPrevious() bool {
return p.ActualPage > 1
}
func (p Pages) HasNext() bool {
return p.ActualPage < p.TotalPages()
}
func (p Pages) Previous() int {
if p.ActualPage > p.TotalPages() {
return p.TotalPages()
}
return p.ActualPage - 1
}
func (p Pages) Next() int {
if p.ActualPage < 1 {
return 1
}
return p.ActualPage + 1
}
func (p Pages) GoToPage(page int) int {
if page < 1 {
page = 1
} else if page > p.TotalPages() {
page = p.TotalPages()
}
return page
}
func (p Pages) First() int {
return p.GoToPage(1)
}
func (p Pages) Last() int {
return p.GoToPage(p.TotalPages())
}
// Page contiene la información de una página. Utilizado para la barra de
// paginación que suelen mostrarse en la parte inferior de una lista o tabla.
// Page represents a single page in pagination, including its number and active
// state. Useful for pagination bar.
type Page struct {
// Number is the numeric identifier of the page in pagination.
Number int
// Active indicates if the page is the currently selected page.
Active bool
}
func (p Page) NumberOfPage() int {
return p.Number
}
func (p Page) IsActive() bool {
return p.Active
}
// PageRange generates a slice of Page instances representing a range of pages
// to be displayed in a pagination bar.
func (p Pages) PageRange(maxPagesToShow int) []Page {
var pages []Page
totalPages := p.TotalPages()
startPage := p.ActualPage - (maxPagesToShow / 2)
endPage := p.ActualPage + (maxPagesToShow / 2)
if startPage < 1 {
startPage = 1
endPage = maxPagesToShow
}
if endPage > totalPages {
endPage = totalPages
startPage = totalPages - maxPagesToShow + 1
if startPage < 1 {
startPage = 1
}
}
for i := startPage; i <= endPage; i++ {
pages = append(pages, Page{
Number: i,
Active: i == p.ActualPage,
})
}
return pages
}