From 2aa592252c3c64bb7af18d4e7eeaea9e6da05e4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20P=C3=A9rez?= Date: Sun, 11 May 2025 12:21:16 +0200 Subject: [PATCH] add templating engine --- app.go | 83 +++--------- consts.go | 48 +++++++ network/network.go | 12 -- render.go | 24 ++++ templates.go | 331 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 425 insertions(+), 73 deletions(-) create mode 100644 consts.go delete mode 100644 network/network.go create mode 100644 render.go create mode 100644 templates.go diff --git a/app.go b/app.go index bc68eaf..b3d8979 100644 --- a/app.go +++ b/app.go @@ -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) diff --git a/consts.go b/consts.go new file mode 100644 index 0000000..20e731f --- /dev/null +++ b/consts.go @@ -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" +) diff --git a/network/network.go b/network/network.go deleted file mode 100644 index dea60b2..0000000 --- a/network/network.go +++ /dev/null @@ -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) -} diff --git a/render.go b/render.go new file mode 100644 index 0000000..0847a5e --- /dev/null +++ b/render.go @@ -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) +} diff --git a/templates.go b/templates.go new file mode 100644 index 0000000..0f03137 --- /dev/null +++ b/templates.go @@ -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 +}