Migrate from templates to Gomponents (#103)

This commit is contained in:
Mike Stefanello 2025-03-05 20:01:58 -05:00 committed by GitHub
parent 0bf9ab7189
commit 051d032038
104 changed files with 2768 additions and 2824 deletions

View file

@ -3,29 +3,54 @@ package context
import (
"context"
"errors"
"github.com/labstack/echo/v4"
)
const (
// AuthenticatedUserKey is the key value used to store the authenticated user in context
// AuthenticatedUserKey is the key used to store the authenticated user in context.
AuthenticatedUserKey = "auth_user"
// UserKey is the key value used to store a user in context
// UserKey is the key used to store a user in context.
UserKey = "user"
// FormKey is the key value used to store a form in context
// FormKey is the key used to store a form in context.
FormKey = "form"
// PasswordTokenKey is the key value used to store a password token in context
// PasswordTokenKey is the key used to store a password token in context.
PasswordTokenKey = "password_token"
// LoggerKey is the key value used to store a structured logger in context
// LoggerKey is the key used to store a structured logger in context.
LoggerKey = "logger"
// SessionKey is the key value used to store the session data in context
// SessionKey is the key used to store the session data in context.
SessionKey = "session"
// HTMXRequestKey is the key used to store the HTMX request data in context.
HTMXRequestKey = "htmx"
// CSRFKey is the key used to store the CSRF token in context.
CSRFKey = "csrf"
// ConfigKey is the key used to store the configuration in context.
ConfigKey = "config"
)
// IsCanceledError determines if an error is due to a context cancelation
// IsCanceledError determines if an error is due to a context cancellation.
func IsCanceledError(err error) bool {
return errors.Is(err, context.Canceled)
}
// Cache checks if a value of a given type exists in the Echo context for a given key and returns that, otherwise
// it will use a callback to generate a value, which is stored in the context then returned. This allows you to
// only generate items only once for a given request.
func Cache[T any](ctx echo.Context, key string, gen func(echo.Context) T) T {
if val := ctx.Get(key); val != nil {
if v, ok := val.(T); ok {
return v
}
}
val := gen(ctx)
ctx.Set(key, val)
return val
}

View file

@ -6,6 +6,7 @@ import (
"testing"
"time"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
)
@ -22,3 +23,25 @@ func TestIsCanceled(t *testing.T) {
assert.False(t, IsCanceledError(errors.New("test error")))
}
func TestCache(t *testing.T) {
ctx := echo.New().NewContext(nil, nil)
key := "testing"
value := "hello"
called := 0
callback := func(ctx echo.Context) string {
called++
return value
}
assert.Nil(t, ctx.Get(key))
got := Cache(ctx, key, callback)
assert.Equal(t, value, got)
assert.Equal(t, 1, called)
got = Cache(ctx, key, callback)
assert.Equal(t, value, got)
assert.Equal(t, 1, called)
}

View file

@ -5,7 +5,7 @@ import (
"github.com/mikestefanello/pagoda/pkg/context"
)
// Form represents a form that can be submitted and validated
// Form represents a form that can be submitted and validated.
type Form interface {
// Submit marks the form as submitted, stores a pointer to it in the context, binds the request
// values to the struct fields, and validates the input based on the struct tags.
@ -13,29 +13,26 @@ type Form interface {
// Returns an echo.HTTPError if the request failed to process.
Submit(c echo.Context, form any) error
// IsSubmitted returns true if the form was submitted
// IsSubmitted returns true if the form was submitted.
IsSubmitted() bool
// IsValid returns true if the form has no validation errors
// IsValid returns true if the form has no validation errors.
IsValid() bool
// IsDone returns true if the form was submitted and has no validation errors
// IsDone returns true if the form was submitted and has no validation errors.
IsDone() bool
// FieldHasErrors returns true if a given struct field has validation errors
// FieldHasErrors returns true if a given struct field has validation errors.
FieldHasErrors(fieldName string) bool
// SetFieldError sets a validation error message for a given struct field
// SetFieldError sets a validation error message for a given struct field.
SetFieldError(fieldName string, message string)
// GetFieldErrors returns the validation errors for a given struct field
// GetFieldErrors returns the validation errors for a given struct field.
GetFieldErrors(fieldName string) []string
// GetFieldStatusClass returns a CSS class to be used for a given struct field
GetFieldStatusClass(fieldName string) string
}
// Get gets a form from the context or initializes a new copy if one is not set
// Get gets a form from the context or initializes a new copy if one is not set.
func Get[T any](ctx echo.Context) *T {
if v := ctx.Get(context.FormKey); v != nil {
return v.(*T)
@ -44,13 +41,13 @@ func Get[T any](ctx echo.Context) *T {
return &v
}
// Clear removes the form set in the context
// Clear removes the form set in the context.
func Clear(ctx echo.Context) {
ctx.Set(context.FormKey, nil)
}
// Submit submits a form
// See Form.Submit()
// Submit submits a form.
// See Form.Submit().
func Submit(ctx echo.Context, form Form) error {
return form.Submit(ctx, form)
}

View file

@ -13,25 +13,25 @@ import (
// Submission represents the state of the submission of a form, not including the form itself.
// This satisfies the Form interface.
type Submission struct {
// isSubmitted indicates if the form has been submitted
// isSubmitted indicates if the form has been submitted.
isSubmitted bool
// errors stores a slice of error message strings keyed by form struct field name
// errors stores a slice of error message strings keyed by form struct field name.
errors map[string][]string
}
func (f *Submission) Submit(ctx echo.Context, form any) error {
f.isSubmitted = true
// Set in context so the form can later be retrieved
// Set in context so the form can later be retrieved.
ctx.Set(context.FormKey, form)
// Bind the values from the incoming request to the form struct
// Bind the values from the incoming request to the form struct.
if err := ctx.Bind(form); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("unable to bind form: %v", err))
}
// Validate the form
// Validate the form.
if err := ctx.Validate(form); err != nil {
f.setErrorMessages(err)
return err
@ -73,17 +73,7 @@ func (f *Submission) GetFieldErrors(fieldName string) []string {
return f.errors[fieldName]
}
func (f *Submission) GetFieldStatusClass(fieldName string) string {
if f.isSubmitted {
if f.FieldHasErrors(fieldName) {
return "is-danger"
}
return "is-success"
}
return ""
}
// setErrorMessages sets errors messages on the submission for all fields that failed validation
// setErrorMessages sets errors messages on the submission for all fields that failed validation.
func (f *Submission) setErrorMessages(err error) {
// Only this is supported right now
ves, ok := err.(validator.ValidationErrors)
@ -94,8 +84,8 @@ func (f *Submission) setErrorMessages(err error) {
for _, ve := range ves {
var message string
// Provide better error messages depending on the failed validation tag
// This should be expanded as you use additional tags in your validation
// Provide better error messages depending on the failed validation tag.
// This should be expanded as you use additional tags in your validation.
switch ve.Tag() {
case "required":
message = "This field is required."
@ -109,7 +99,7 @@ func (f *Submission) setErrorMessages(err error) {
message = "Invalid value."
}
// Add the error
// Add the error.
f.SetFieldError(ve.Field(), message)
}
}

View file

@ -40,8 +40,6 @@ func TestFormSubmission(t *testing.T) {
require.Len(t, form.GetFieldErrors("Name"), 1)
assert.Len(t, form.GetFieldErrors("Email"), 0)
assert.Equal(t, "This field is required.", form.GetFieldErrors("Name")[0])
assert.Equal(t, "is-danger", form.GetFieldStatusClass("Name"))
assert.Equal(t, "is-success", form.GetFieldStatusClass("Email"))
assert.False(t, form.IsDone())
formInCtx := Get[formTest](ctx)

View file

@ -1,56 +0,0 @@
package funcmap
import (
"fmt"
"html/template"
"strings"
"github.com/Masterminds/sprig"
"github.com/labstack/echo/v4"
"github.com/labstack/gommon/random"
"github.com/mikestefanello/pagoda/config"
)
var (
// CacheBuster stores a random string used as a cache buster for static files.
CacheBuster = random.String(10)
)
type funcMap struct {
web *echo.Echo
}
// NewFuncMap provides a template function map
func NewFuncMap(web *echo.Echo) template.FuncMap {
fm := &funcMap{web: web}
// See http://masterminds.github.io/sprig/ for all provided funcs
funcs := sprig.FuncMap()
// Include all the custom functions
funcs["file"] = fm.file
funcs["link"] = fm.link
funcs["url"] = fm.url
return funcs
}
// file appends a cache buster to a given filepath so it can remain cached until the app is restarted
func (fm *funcMap) file(filepath string) string {
return fmt.Sprintf("/%s/%s?v=%s", config.StaticPrefix, filepath, CacheBuster)
}
// link outputs HTML for a link element, providing the ability to dynamically set the active class
func (fm *funcMap) link(url, text, currentPath string, classes ...string) template.HTML {
if currentPath == url {
classes = append(classes, "is-active")
}
html := fmt.Sprintf(`<a class="%s" href="%s">%s</a>`, strings.Join(classes, " "), url, text)
return template.HTML(html)
}
// url generates a URL from a given route name and optional parameters
func (fm *funcMap) url(routeName string, params ...any) string {
return fm.web.Reverse(routeName, params...)
}

View file

@ -1,52 +0,0 @@
package funcmap
import (
"fmt"
"testing"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/config"
"github.com/stretchr/testify/assert"
)
func TestNewFuncMap(t *testing.T) {
f := NewFuncMap(echo.New())
assert.NotNil(t, f["link"])
assert.NotNil(t, f["file"])
assert.NotNil(t, f["url"])
}
func TestLink(t *testing.T) {
f := new(funcMap)
link := string(f.link("/abc", "Text", "/abc"))
expected := `<a class="is-active" href="/abc">Text</a>`
assert.Equal(t, expected, link)
link = string(f.link("/abc", "Text", "/abc", "first", "second"))
expected = `<a class="first second is-active" href="/abc">Text</a>`
assert.Equal(t, expected, link)
link = string(f.link("/abc", "Text", "/def"))
expected = `<a class="" href="/abc">Text</a>`
assert.Equal(t, expected, link)
}
func TestFile(t *testing.T) {
f := new(funcMap)
file := f.file("test.png")
expected := fmt.Sprintf("/%s/test.png?v=%s", config.StaticPrefix, CacheBuster)
assert.Equal(t, expected, file)
}
func TestUrl(t *testing.T) {
f := new(funcMap)
f.web = echo.New()
f.web.GET("/mypath/:id", func(c echo.Context) error {
return nil
}).Name = "test"
out := f.url("test", 5)
assert.Equal(t, "/mypath/5", out)
}

View file

@ -13,65 +13,25 @@ import (
"github.com/mikestefanello/pagoda/pkg/log"
"github.com/mikestefanello/pagoda/pkg/middleware"
"github.com/mikestefanello/pagoda/pkg/msg"
"github.com/mikestefanello/pagoda/pkg/page"
"github.com/mikestefanello/pagoda/pkg/redirect"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/services"
"github.com/mikestefanello/pagoda/templates"
"github.com/mikestefanello/pagoda/pkg/ui/emails"
"github.com/mikestefanello/pagoda/pkg/ui/forms"
"github.com/mikestefanello/pagoda/pkg/ui/pages"
)
const (
routeNameForgotPassword = "forgot_password"
routeNameForgotPasswordSubmit = "forgot_password.submit"
routeNameLogin = "login"
routeNameLoginSubmit = "login.submit"
routeNameLogout = "logout"
routeNameRegister = "register"
routeNameRegisterSubmit = "register.submit"
routeNameResetPassword = "reset_password"
routeNameResetPasswordSubmit = "reset_password.submit"
routeNameVerifyEmail = "verify_email"
)
type (
Auth struct {
auth *services.AuthClient
mail *services.MailClient
orm *ent.Client
*services.TemplateRenderer
}
forgotPasswordForm struct {
Email string `form:"email" validate:"required,email"`
form.Submission
}
loginForm struct {
Email string `form:"email" validate:"required,email"`
Password string `form:"password" validate:"required"`
form.Submission
}
registerForm struct {
Name string `form:"name" validate:"required"`
Email string `form:"email" validate:"required,email"`
Password string `form:"password" validate:"required"`
ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"`
form.Submission
}
resetPasswordForm struct {
Password string `form:"password" validate:"required"`
ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"`
form.Submission
}
)
type Auth struct {
auth *services.AuthClient
mail *services.MailClient
orm *ent.Client
}
func init() {
Register(new(Auth))
}
func (h *Auth) Init(c *services.Container) error {
h.TemplateRenderer = c.TemplateRenderer
h.orm = c.ORM
h.auth = c.Auth
h.mail = c.Mail
@ -79,37 +39,31 @@ func (h *Auth) Init(c *services.Container) error {
}
func (h *Auth) Routes(g *echo.Group) {
g.GET("/logout", h.Logout, middleware.RequireAuthentication()).Name = routeNameLogout
g.GET("/email/verify/:token", h.VerifyEmail).Name = routeNameVerifyEmail
g.GET("/logout", h.Logout, middleware.RequireAuthentication()).Name = routenames.Logout
g.GET("/email/verify/:token", h.VerifyEmail).Name = routenames.VerifyEmail
noAuth := g.Group("/user", middleware.RequireNoAuthentication())
noAuth.GET("/login", h.LoginPage).Name = routeNameLogin
noAuth.POST("/login", h.LoginSubmit).Name = routeNameLoginSubmit
noAuth.GET("/register", h.RegisterPage).Name = routeNameRegister
noAuth.POST("/register", h.RegisterSubmit).Name = routeNameRegisterSubmit
noAuth.GET("/password", h.ForgotPasswordPage).Name = routeNameForgotPassword
noAuth.POST("/password", h.ForgotPasswordSubmit).Name = routeNameForgotPasswordSubmit
noAuth.GET("/login", h.LoginPage).Name = routenames.Login
noAuth.POST("/login", h.LoginSubmit).Name = routenames.LoginSubmit
noAuth.GET("/register", h.RegisterPage).Name = routenames.Register
noAuth.POST("/register", h.RegisterSubmit).Name = routenames.RegisterSubmit
noAuth.GET("/password", h.ForgotPasswordPage).Name = routenames.ForgotPassword
noAuth.POST("/password", h.ForgotPasswordSubmit).Name = routenames.ForgotPasswordSubmit
resetGroup := noAuth.Group("/password/reset",
middleware.LoadUser(h.orm),
middleware.LoadValidPasswordToken(h.auth),
)
resetGroup.GET("/token/:user/:password_token/:token", h.ResetPasswordPage).Name = routeNameResetPassword
resetGroup.POST("/token/:user/:password_token/:token", h.ResetPasswordSubmit).Name = routeNameResetPasswordSubmit
resetGroup.GET("/token/:user/:password_token/:token", h.ResetPasswordPage).Name = routenames.ResetPassword
resetGroup.POST("/token/:user/:password_token/:token", h.ResetPasswordSubmit).Name = routenames.ResetPasswordSubmit
}
func (h *Auth) ForgotPasswordPage(ctx echo.Context) error {
p := page.New(ctx)
p.Layout = templates.LayoutAuth
p.Name = templates.PageForgotPassword
p.Title = "Forgot password"
p.Form = form.Get[forgotPasswordForm](ctx)
return h.RenderPage(ctx, p)
return pages.ForgotPassword(ctx, form.Get[forms.ForgotPassword](ctx))
}
func (h *Auth) ForgotPasswordSubmit(ctx echo.Context) error {
var input forgotPasswordForm
var input forms.ForgotPassword
succeed := func() error {
form.Clear(ctx)
@ -127,7 +81,7 @@ func (h *Auth) ForgotPasswordSubmit(ctx echo.Context) error {
return err
}
// Attempt to load the user
// Attempt to load the user.
u, err := h.orm.User.
Query().
Where(user.Email(strings.ToLower(input.Email))).
@ -141,7 +95,7 @@ func (h *Auth) ForgotPasswordSubmit(ctx echo.Context) error {
return fail(err, "error querying user during forgot password")
}
// Generate the token
// Generate the token.
token, pt, err := h.auth.GeneratePasswordResetToken(ctx, u.ID)
if err != nil {
return fail(err, "error generating password reset token")
@ -151,8 +105,8 @@ func (h *Auth) ForgotPasswordSubmit(ctx echo.Context) error {
"user_id", u.ID,
)
// Email the user
url := ctx.Echo().Reverse(routeNameResetPassword, u.ID, pt.ID, token)
// Email the user.
url := ctx.Echo().Reverse(routenames.ResetPassword, u.ID, pt.ID, token)
err = h.mail.
Compose().
To(u.Email).
@ -168,17 +122,11 @@ func (h *Auth) ForgotPasswordSubmit(ctx echo.Context) error {
}
func (h *Auth) LoginPage(ctx echo.Context) error {
p := page.New(ctx)
p.Layout = templates.LayoutAuth
p.Name = templates.PageLogin
p.Title = "Log in"
p.Form = form.Get[loginForm](ctx)
return h.RenderPage(ctx, p)
return pages.Login(ctx, form.Get[forms.Login](ctx))
}
func (h *Auth) LoginSubmit(ctx echo.Context) error {
var input loginForm
var input forms.Login
authFailed := func() error {
input.SetFieldError("Email", "")
@ -197,7 +145,7 @@ func (h *Auth) LoginSubmit(ctx echo.Context) error {
return err
}
// Attempt to load the user
// Attempt to load the user.
u, err := h.orm.User.
Query().
Where(user.Email(strings.ToLower(input.Email))).
@ -211,22 +159,22 @@ func (h *Auth) LoginSubmit(ctx echo.Context) error {
return fail(err, "error querying user during login")
}
// Check if the password is correct
// Check if the password is correct.
err = h.auth.CheckPassword(input.Password, u.Password)
if err != nil {
return authFailed()
}
// Log the user in
// Log the user in.
err = h.auth.Login(ctx, u.ID)
if err != nil {
return fail(err, "unable to log in user")
}
msg.Success(ctx, fmt.Sprintf("Welcome back, <strong>%s</strong>. You are now logged in.", u.Name))
msg.Success(ctx, fmt.Sprintf("Welcome back, %s. You are now logged in.", u.Name))
return redirect.New(ctx).
Route(routeNameHome).
Route(routenames.Home).
Go()
}
@ -237,22 +185,16 @@ func (h *Auth) Logout(ctx echo.Context) error {
msg.Danger(ctx, "An error occurred. Please try again.")
}
return redirect.New(ctx).
Route(routeNameHome).
Route(routenames.Home).
Go()
}
func (h *Auth) RegisterPage(ctx echo.Context) error {
p := page.New(ctx)
p.Layout = templates.LayoutAuth
p.Name = templates.PageRegister
p.Title = "Register"
p.Form = form.Get[registerForm](ctx)
return h.RenderPage(ctx, p)
return pages.Register(ctx, form.Get[forms.Register](ctx))
}
func (h *Auth) RegisterSubmit(ctx echo.Context) error {
var input registerForm
var input forms.Register
err := form.Submit(ctx, &input)
@ -264,13 +206,13 @@ func (h *Auth) RegisterSubmit(ctx echo.Context) error {
return err
}
// Hash the password
// Hash the password.
pwHash, err := h.auth.HashPassword(input.Password)
if err != nil {
return fail(err, "unable to hash password")
}
// Attempt creating the user
// Attempt creating the user.
u, err := h.orm.User.
Create().
SetName(input.Name).
@ -287,13 +229,13 @@ func (h *Auth) RegisterSubmit(ctx echo.Context) error {
case *ent.ConstraintError:
msg.Warning(ctx, "A user with this email address already exists. Please log in.")
return redirect.New(ctx).
Route(routeNameLogin).
Route(routenames.Login).
Go()
default:
return fail(err, "unable to create user")
}
// Log the user in
// Log the user in.
err = h.auth.Login(ctx, u.ID)
if err != nil {
log.Ctx(ctx).Error("unable to log user in",
@ -302,22 +244,22 @@ func (h *Auth) RegisterSubmit(ctx echo.Context) error {
)
msg.Info(ctx, "Your account has been created.")
return redirect.New(ctx).
Route(routeNameLogin).
Route(routenames.Login).
Go()
}
msg.Success(ctx, "Your account has been created. You are now logged in.")
// Send the verification email
// Send the verification email.
h.sendVerificationEmail(ctx, u)
return redirect.New(ctx).
Route(routeNameHome).
Route(routenames.Home).
Go()
}
func (h *Auth) sendVerificationEmail(ctx echo.Context, usr *ent.User) {
// Generate a token
// Generate a token.
token, err := h.auth.GenerateEmailVerificationToken(usr.Email)
if err != nil {
log.Ctx(ctx).Error("unable to generate email verification token",
@ -327,13 +269,12 @@ func (h *Auth) sendVerificationEmail(ctx echo.Context, usr *ent.User) {
return
}
// Send the email
url := ctx.Echo().Reverse(routeNameVerifyEmail, token)
// Send the email.
err = h.mail.
Compose().
To(usr.Email).
Subject("Confirm your email address").
Body(fmt.Sprintf("Click here to confirm your email address: %s", url)).
Component(emails.ConfirmEmailAddress(ctx, usr.Name, token)).
Send(ctx)
if err != nil {
@ -348,17 +289,11 @@ func (h *Auth) sendVerificationEmail(ctx echo.Context, usr *ent.User) {
}
func (h *Auth) ResetPasswordPage(ctx echo.Context) error {
p := page.New(ctx)
p.Layout = templates.LayoutAuth
p.Name = templates.PageResetPassword
p.Title = "Reset password"
p.Form = form.Get[resetPasswordForm](ctx)
return h.RenderPage(ctx, p)
return pages.ResetPassword(ctx, form.Get[forms.ResetPassword](ctx))
}
func (h *Auth) ResetPasswordSubmit(ctx echo.Context) error {
var input resetPasswordForm
var input forms.ResetPassword
err := form.Submit(ctx, &input)
@ -370,16 +305,16 @@ func (h *Auth) ResetPasswordSubmit(ctx echo.Context) error {
return err
}
// Hash the new password
// Hash the new password.
hash, err := h.auth.HashPassword(input.Password)
if err != nil {
return fail(err, "unable to hash password")
}
// Get the requesting user
// Get the requesting user.
usr := ctx.Get(context.UserKey).(*ent.User)
// Update the user
// Update the user.
_, err = usr.
Update().
SetPassword(hash).
@ -389,7 +324,7 @@ func (h *Auth) ResetPasswordSubmit(ctx echo.Context) error {
return fail(err, "unable to update password")
}
// Delete all password tokens for this user
// Delete all password tokens for this user.
err = h.auth.DeletePasswordTokens(ctx, usr.ID)
if err != nil {
return fail(err, "unable to delete password tokens")
@ -397,24 +332,24 @@ func (h *Auth) ResetPasswordSubmit(ctx echo.Context) error {
msg.Success(ctx, "Your password has been updated.")
return redirect.New(ctx).
Route(routeNameLogin).
Route(routenames.Login).
Go()
}
func (h *Auth) VerifyEmail(ctx echo.Context) error {
var usr *ent.User
// Validate the token
// Validate the token.
token := ctx.Param("token")
email, err := h.auth.ValidateEmailVerificationToken(token)
if err != nil {
msg.Warning(ctx, "The link is either invalid or has expired.")
return redirect.New(ctx).
Route(routeNameHome).
Route(routenames.Home).
Go()
}
// Check if it matches the authenticated user
// Check if it matches the authenticated user.
if u := ctx.Get(context.AuthenticatedUserKey); u != nil {
authUser := u.(*ent.User)
@ -423,7 +358,7 @@ func (h *Auth) VerifyEmail(ctx echo.Context) error {
}
}
// Query to find a matching user, if needed
// Query to find a matching user, if needed.
if usr == nil {
usr, err = h.orm.User.
Query().
@ -435,7 +370,7 @@ func (h *Auth) VerifyEmail(ctx echo.Context) error {
}
}
// Verify the user, if needed
// Verify the user, if needed.
if !usr.Verified {
usr, err = usr.
Update().
@ -449,6 +384,6 @@ func (h *Auth) VerifyEmail(ctx echo.Context) error {
msg.Success(ctx, "Your email has been successfully verified.")
return redirect.New(ctx).
Route(routeNameHome).
Route(routenames.Home).
Go()
}

View file

@ -2,79 +2,63 @@ package handlers
import (
"errors"
"time"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/form"
"github.com/mikestefanello/pagoda/pkg/page"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/services"
"github.com/mikestefanello/pagoda/templates"
"time"
"github.com/mikestefanello/pagoda/pkg/ui/forms"
"github.com/mikestefanello/pagoda/pkg/ui/pages"
)
const (
routeNameCache = "cache"
routeNameCacheSubmit = "cache.submit"
)
type (
Cache struct {
cache *services.CacheClient
*services.TemplateRenderer
}
cacheForm struct {
Value string `form:"value"`
form.Submission
}
)
type Cache struct {
cache *services.CacheClient
}
func init() {
Register(new(Cache))
}
func (h *Cache) Init(c *services.Container) error {
h.TemplateRenderer = c.TemplateRenderer
h.cache = c.Cache
return nil
}
func (h *Cache) Routes(g *echo.Group) {
g.GET("/cache", h.Page).Name = routeNameCache
g.POST("/cache", h.Submit).Name = routeNameCacheSubmit
g.GET("/cache", h.Page).Name = routenames.Cache
g.POST("/cache", h.Submit).Name = routenames.CacheSubmit
}
func (h *Cache) Page(ctx echo.Context) error {
p := page.New(ctx)
p.Layout = templates.LayoutMain
p.Name = templates.PageCache
p.Title = "Set a cache entry"
p.Form = form.Get[cacheForm](ctx)
f := form.Get[forms.Cache](ctx)
// Fetch the value from the cache
// Fetch the value from the cache.
value, err := h.cache.
Get().
Key("page_cache_example").
Fetch(ctx.Request().Context())
// Store the value in the page, so it can be rendered, if found
// Store the value in the form, so it can be rendered, if found.
switch {
case err == nil:
p.Data = value.(string)
f.CurrentValue = value.(string)
case errors.Is(err, services.ErrCacheMiss):
default:
return fail(err, "failed to fetch from cache")
}
return h.RenderPage(ctx, p)
return pages.UpdateCache(ctx, f)
}
func (h *Cache) Submit(ctx echo.Context) error {
var input cacheForm
var input forms.Cache
if err := form.Submit(ctx, &input); err != nil {
return err
}
// Set the cache
// Set the cache.
err := h.cache.
Set().
Key("page_cache_example").

View file

@ -2,60 +2,40 @@ package handlers
import (
"fmt"
"github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/form"
"github.com/mikestefanello/pagoda/pkg/page"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/services"
"github.com/mikestefanello/pagoda/templates"
"github.com/mikestefanello/pagoda/pkg/ui/forms"
"github.com/mikestefanello/pagoda/pkg/ui/pages"
)
const (
routeNameContact = "contact"
routeNameContactSubmit = "contact.submit"
)
type (
Contact struct {
mail *services.MailClient
*services.TemplateRenderer
}
contactForm struct {
Email string `form:"email" validate:"required,email"`
Department string `form:"department" validate:"required,oneof=sales marketing hr"`
Message string `form:"message" validate:"required"`
form.Submission
}
)
type Contact struct {
mail *services.MailClient
}
func init() {
Register(new(Contact))
}
func (h *Contact) Init(c *services.Container) error {
h.TemplateRenderer = c.TemplateRenderer
h.mail = c.Mail
return nil
}
func (h *Contact) Routes(g *echo.Group) {
g.GET("/contact", h.Page).Name = routeNameContact
g.POST("/contact", h.Submit).Name = routeNameContactSubmit
g.GET("/contact", h.Page).Name = routenames.Contact
g.POST("/contact", h.Submit).Name = routenames.ContactSubmit
}
func (h *Contact) Page(ctx echo.Context) error {
p := page.New(ctx)
p.Layout = templates.LayoutMain
p.Name = templates.PageContact
p.Title = "Contact us"
p.Form = form.Get[contactForm](ctx)
return h.RenderPage(ctx, p)
return pages.ContactUs(ctx, form.Get[forms.Contact](ctx))
}
func (h *Contact) Submit(ctx echo.Context) error {
var input contactForm
var input forms.Contact
err := form.Submit(ctx, &input)

View file

@ -6,27 +6,23 @@ import (
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/context"
"github.com/mikestefanello/pagoda/pkg/log"
"github.com/mikestefanello/pagoda/pkg/page"
"github.com/mikestefanello/pagoda/pkg/services"
"github.com/mikestefanello/pagoda/templates"
"github.com/mikestefanello/pagoda/pkg/ui/pages"
)
type Error struct {
*services.TemplateRenderer
}
type Error struct{}
func (e *Error) Page(err error, ctx echo.Context) {
if ctx.Response().Committed || context.IsCanceledError(err) {
return
}
// Determine the error status code
// Determine the error status code.
code := http.StatusInternalServerError
if he, ok := err.(*echo.HTTPError); ok {
code = he.Code
}
// Log the error
// Log the error.
logger := log.Ctx(ctx)
switch {
case code >= 500:
@ -35,15 +31,11 @@ func (e *Error) Page(err error, ctx echo.Context) {
logger.Warn(err.Error())
}
// Render the error page
p := page.New(ctx)
p.Layout = templates.LayoutMain
p.Name = templates.PageError
p.Title = http.StatusText(code)
p.StatusCode = code
p.HTMX.Request.Enabled = false
// Set the status code.
ctx.Response().Status = code
if err = e.RenderPage(ctx, p); err != nil {
// Render the error page.
if err = pages.Error(ctx, code); err != nil {
log.Ctx(ctx).Error("failed to render error page",
"error", err,
)

View file

@ -7,69 +7,48 @@ import (
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/msg"
"github.com/mikestefanello/pagoda/pkg/page"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/services"
"github.com/mikestefanello/pagoda/templates"
"github.com/mikestefanello/pagoda/pkg/ui/models"
"github.com/mikestefanello/pagoda/pkg/ui/pages"
"github.com/spf13/afero"
)
const (
routeNameFiles = "files"
routeNameFilesSubmit = "files.submit"
)
type (
Files struct {
files afero.Fs
*services.TemplateRenderer
}
File struct {
Name string
Size int64
Modified string
}
)
type Files struct {
files afero.Fs
}
func init() {
Register(new(Files))
}
func (h *Files) Init(c *services.Container) error {
h.TemplateRenderer = c.TemplateRenderer
h.files = c.Files
return nil
}
func (h *Files) Routes(g *echo.Group) {
g.GET("/files", h.Page).Name = routeNameFiles
g.POST("/files", h.Submit).Name = routeNameFilesSubmit
g.GET("/files", h.Page).Name = routenames.Files
g.POST("/files", h.Submit).Name = routenames.FilesSubmit
}
func (h *Files) Page(ctx echo.Context) error {
p := page.New(ctx)
p.Layout = templates.LayoutMain
p.Name = templates.PageFiles
p.Title = "Upload a file"
// Send a list of all uploaded files to the template to be rendered.
// Compile a list of all uploaded files to be rendered.
info, err := afero.ReadDir(h.files, "")
if err != nil {
return err
}
files := make([]File, 0)
files := make([]*models.File, 0)
for _, file := range info {
files = append(files, File{
files = append(files, &models.File{
Name: file.Name(),
Size: file.Size(),
Modified: file.ModTime().Format(time.DateTime),
})
}
p.Data = files
return h.RenderPage(ctx, p)
return pages.UploadFile(ctx, files)
}
func (h *Files) Submit(ctx echo.Context) error {

View file

@ -2,74 +2,46 @@ package handlers
import (
"fmt"
"html/template"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/page"
"github.com/mikestefanello/pagoda/pkg/pager"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/services"
"github.com/mikestefanello/pagoda/templates"
"github.com/mikestefanello/pagoda/pkg/ui/models"
"github.com/mikestefanello/pagoda/pkg/ui/pages"
)
const (
routeNameAbout = "about"
routeNameHome = "home"
)
type (
Pages struct {
*services.TemplateRenderer
}
post struct {
Title string
Body string
}
aboutData struct {
ShowCacheWarning bool
FrontendTabs []aboutTab
BackendTabs []aboutTab
}
aboutTab struct {
Title string
Body template.HTML
}
)
type Pages struct{}
func init() {
Register(new(Pages))
}
func (h *Pages) Init(c *services.Container) error {
h.TemplateRenderer = c.TemplateRenderer
return nil
}
func (h *Pages) Routes(g *echo.Group) {
g.GET("/", h.Home).Name = routeNameHome
g.GET("/about", h.About).Name = routeNameAbout
g.GET("/", h.Home).Name = routenames.Home
g.GET("/about", h.About).Name = routenames.About
}
func (h *Pages) Home(ctx echo.Context) error {
p := page.New(ctx)
p.Layout = templates.LayoutMain
p.Name = templates.PageHome
p.Metatags.Description = "Welcome to the homepage."
p.Metatags.Keywords = []string{"Go", "MVC", "Web", "Software"}
p.Pager = page.NewPager(ctx, 4)
p.Data = h.fetchPosts(&p.Pager)
pgr := pager.NewPager(ctx, 4)
return h.RenderPage(ctx, p)
return pages.Home(ctx, &models.Posts{
Posts: h.fetchPosts(&pgr),
Pager: pgr,
})
}
// fetchPosts is an mock example of fetching posts to illustrate how paging works
func (h *Pages) fetchPosts(pager *page.Pager) []post {
// fetchPosts is a mock example of fetching posts to illustrate how paging works.
func (h *Pages) fetchPosts(pager *pager.Pager) []models.Post {
pager.SetItems(20)
posts := make([]post, 20)
posts := make([]models.Post, 20)
for k := range posts {
posts[k] = post{
posts[k] = models.Post{
Title: fmt.Sprintf("Post example #%d", k+1),
Body: fmt.Sprintf("Lorem ipsum example #%d ddolor sit amet, consectetur adipiscing elit. Nam elementum vulputate tristique.", k+1),
}
@ -78,44 +50,5 @@ func (h *Pages) fetchPosts(pager *page.Pager) []post {
}
func (h *Pages) About(ctx echo.Context) error {
p := page.New(ctx)
p.Layout = templates.LayoutMain
p.Name = templates.PageAbout
p.Title = "About"
// This page will be cached!
p.Cache.Enabled = true
p.Cache.Tags = []string{"page_about", "page:list"}
// A simple example of how the Data field can contain anything you want to send to the templates
// even though you wouldn't normally send markup like this
p.Data = aboutData{
ShowCacheWarning: true,
FrontendTabs: []aboutTab{
{
Title: "HTMX",
Body: template.HTML(`Completes HTML as a hypertext by providing attributes to AJAXify anything and much more. Visit <a href="https://htmx.org/">htmx.org</a> to learn more.`),
},
{
Title: "Alpine.js",
Body: template.HTML(`Drop-in, Vue-like functionality written directly in your markup. Visit <a href="https://alpinejs.dev/">alpinejs.dev</a> to learn more.`),
},
{
Title: "Bulma",
Body: template.HTML(`Ready-to-use frontend components that you can easily combine to build responsive web interfaces with no JavaScript requirements. Visit <a href="https://bulma.io/">bulma.io</a> to learn more.`),
},
},
BackendTabs: []aboutTab{
{
Title: "Echo",
Body: template.HTML(`High performance, extensible, minimalist Go web framework. Visit <a href="https://echo.labstack.com/">echo.labstack.com</a> to learn more.`),
},
{
Title: "Ent",
Body: template.HTML(`Simple, yet powerful ORM for modeling and querying data. Visit <a href="https://entgo.io/">entgo.io</a> to learn more.`),
},
},
}
return h.RenderPage(ctx, p)
return pages.About(ctx)
}

View file

@ -4,6 +4,7 @@ import (
"net/http"
"testing"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/stretchr/testify/assert"
)
@ -11,7 +12,7 @@ import (
// this test package
func TestPages__About(t *testing.T) {
doc := request(t).
setRoute(routeNameAbout).
setRoute(routenames.About).
get().
assertStatusCode(http.StatusOK).
toDoc()

View file

@ -6,27 +6,28 @@ import (
"github.com/gorilla/sessions"
echomw "github.com/labstack/echo/v4/middleware"
"github.com/mikestefanello/pagoda/config"
"github.com/mikestefanello/pagoda/pkg/context"
"github.com/mikestefanello/pagoda/pkg/middleware"
"github.com/mikestefanello/pagoda/pkg/services"
)
// BuildRouter builds the router
// BuildRouter builds the router.
func BuildRouter(c *services.Container) error {
// Static files with proper cache control
// funcmap.File() should be used in templates to append a cache key to the URL in order to break cache
// after each server restart
// Static files with proper cache control.
// ui.File() should be used in ui components to append a cache key to the URL in order to break cache
// after each server restart.
c.Web.Group("", middleware.CacheControl(c.Config.Cache.Expiration.StaticFile)).
Static(config.StaticPrefix, config.StaticDir)
// Non-static file route group
// Non-static file route group.
g := c.Web.Group("")
// Force HTTPS, if enabled
// Force HTTPS, if enabled.
if c.Config.HTTP.TLS.Enabled {
g.Use(echomw.HTTPSRedirect())
}
// Create a cookie store for session data
// Create a cookie store for session data.
cookieStore := sessions.NewCookieStore([]byte(c.Config.App.EncryptionKey))
cookieStore.Options.HttpOnly = true
cookieStore.Options.Secure = true
@ -45,22 +46,22 @@ func BuildRouter(c *services.Container) error {
echomw.TimeoutWithConfig(echomw.TimeoutConfig{
Timeout: c.Config.App.Timeout,
}),
middleware.Config(c.Config),
middleware.Session(cookieStore),
middleware.LoadAuthenticatedUser(c.Auth),
middleware.ServeCachedPage(c.TemplateRenderer),
echomw.CSRFWithConfig(echomw.CSRFConfig{
TokenLookup: "form:csrf",
CookieHTTPOnly: true,
CookieSecure: true,
CookieSameSite: http.SameSiteStrictMode,
ContextKey: context.CSRFKey,
}),
)
// Error handler
err := Error{c.TemplateRenderer}
c.Web.HTTPErrorHandler = err.Page
// Error handler.
c.Web.HTTPErrorHandler = new(Error).Page
// Initialize and register all handlers
// Initialize and register all handlers.
for _, h := range GetHandlers() {
if err := h.Init(c); err != nil {
return err

View file

@ -5,56 +5,40 @@ import (
"math/rand"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/page"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/services"
"github.com/mikestefanello/pagoda/templates"
"github.com/mikestefanello/pagoda/pkg/ui/models"
"github.com/mikestefanello/pagoda/pkg/ui/pages"
)
const routeNameSearch = "search"
type (
Search struct {
*services.TemplateRenderer
}
searchResult struct {
Title string
URL string
}
)
type Search struct{}
func init() {
Register(new(Search))
}
func (h *Search) Init(c *services.Container) error {
h.TemplateRenderer = c.TemplateRenderer
return nil
}
func (h *Search) Routes(g *echo.Group) {
g.GET("/search", h.Page).Name = routeNameSearch
g.GET("/search", h.Page).Name = routenames.Search
}
func (h *Search) Page(ctx echo.Context) error {
p := page.New(ctx)
p.Layout = templates.LayoutMain
p.Name = templates.PageSearch
// Fake search results
var results []searchResult
// Fake search results.
results := make([]*models.SearchResult, 0, 5)
if search := ctx.QueryParam("query"); search != "" {
for i := 0; i < 5; i++ {
title := "Lorem ipsum example ddolor sit amet"
index := rand.Intn(len(title))
title = title[:index] + search + title[index:]
results = append(results, searchResult{
results = append(results, &models.SearchResult{
Title: title,
URL: fmt.Sprintf("https://www.%s.com", search),
})
}
}
p.Data = results
return h.RenderPage(ctx, p)
return pages.SearchResults(ctx, results)
}

View file

@ -2,64 +2,45 @@ package handlers
import (
"fmt"
"time"
"github.com/mikestefanello/backlite"
"github.com/mikestefanello/pagoda/pkg/msg"
"time"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/ui/forms"
"github.com/mikestefanello/pagoda/pkg/ui/pages"
"github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/form"
"github.com/mikestefanello/pagoda/pkg/page"
"github.com/mikestefanello/pagoda/pkg/services"
"github.com/mikestefanello/pagoda/pkg/tasks"
"github.com/mikestefanello/pagoda/templates"
)
const (
routeNameTask = "task"
routeNameTaskSubmit = "task.submit"
)
type (
Task struct {
tasks *backlite.Client
*services.TemplateRenderer
}
taskForm struct {
Delay int `form:"delay" validate:"gte=0"`
Message string `form:"message" validate:"required"`
form.Submission
}
)
type Task struct {
tasks *backlite.Client
}
func init() {
Register(new(Task))
}
func (h *Task) Init(c *services.Container) error {
h.TemplateRenderer = c.TemplateRenderer
h.tasks = c.Tasks
return nil
}
func (h *Task) Routes(g *echo.Group) {
g.GET("/task", h.Page).Name = routeNameTask
g.POST("/task", h.Submit).Name = routeNameTaskSubmit
g.GET("/task", h.Page).Name = routenames.Task
g.POST("/task", h.Submit).Name = routenames.TaskSubmit
}
func (h *Task) Page(ctx echo.Context) error {
p := page.New(ctx)
p.Layout = templates.LayoutMain
p.Name = templates.PageTask
p.Title = "Create a task"
p.Form = form.Get[taskForm](ctx)
return h.RenderPage(ctx, p)
return pages.AddTask(ctx, form.Get[forms.Task](ctx))
}
func (h *Task) Submit(ctx echo.Context) error {
var input taskForm
var input forms.Task
err := form.Submit(ctx, &input)

View file

@ -4,6 +4,7 @@ import (
"net/http"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/context"
)
// Request headers: https://htmx.org/docs/#request-headers
@ -28,7 +29,7 @@ const (
)
type (
// Request contains data that HTMX provides during requests
// Request contains data that HTMX provides during requests.
Request struct {
Enabled bool
Boosted bool
@ -39,7 +40,7 @@ type (
Prompt string
}
// Response contain data that the server can communicate back to HTMX
// Response contain data that the server can communicate back to HTMX.
Response struct {
PushURL string
Redirect string
@ -52,20 +53,22 @@ type (
}
)
// GetRequest extracts HTMX data from the request
func GetRequest(ctx echo.Context) Request {
return Request{
Enabled: ctx.Request().Header.Get(HeaderRequest) == "true",
Boosted: ctx.Request().Header.Get(HeaderBoosted) == "true",
Trigger: ctx.Request().Header.Get(HeaderTrigger),
TriggerName: ctx.Request().Header.Get(HeaderTriggerName),
Target: ctx.Request().Header.Get(HeaderTarget),
Prompt: ctx.Request().Header.Get(HeaderPrompt),
HistoryRestore: ctx.Request().Header.Get(HeaderHistoryRestoreRequest) == "true",
}
// GetRequest extracts HTMX data from the request,
func GetRequest(ctx echo.Context) *Request {
return context.Cache(ctx, context.HTMXRequestKey, func(ctx echo.Context) *Request {
return &Request{
Enabled: ctx.Request().Header.Get(HeaderRequest) == "true",
Boosted: ctx.Request().Header.Get(HeaderBoosted) == "true",
Trigger: ctx.Request().Header.Get(HeaderTrigger),
TriggerName: ctx.Request().Header.Get(HeaderTriggerName),
Target: ctx.Request().Header.Get(HeaderTarget),
Prompt: ctx.Request().Header.Get(HeaderPrompt),
HistoryRestore: ctx.Request().Header.Get(HeaderHistoryRestoreRequest) == "true",
}
})
}
// Apply applies data from a Response to a server response
// Apply applies data from a Response to a server response.
func (r Response) Apply(ctx echo.Context) {
if r.PushURL != "" {
ctx.Response().Header().Set(HeaderPushURL, r.PushURL)

View file

@ -4,6 +4,7 @@ import (
"net/http"
"testing"
"github.com/mikestefanello/pagoda/pkg/context"
"github.com/mikestefanello/pagoda/pkg/tests"
"github.com/stretchr/testify/assert"
@ -29,6 +30,11 @@ func TestSetRequest(t *testing.T) {
assert.Equal(t, "b", r.TriggerName)
assert.Equal(t, "c", r.Target)
assert.Equal(t, "d", r.Prompt)
cached := context.Cache(ctx, context.HTMXRequestKey, func(ctx echo.Context) *Request {
return nil
})
assert.Equal(t, r, cached)
}
func TestResponse_Apply(t *testing.T) {

View file

@ -7,12 +7,12 @@ import (
"github.com/mikestefanello/pagoda/pkg/context"
)
// Set sets a logger in the context
// Set sets a logger in the context.
func Set(ctx echo.Context, logger *slog.Logger) {
ctx.Set(context.LoggerKey, logger)
}
// Ctx returns the logger stored in context, or provides the default logger if one is not present
// Ctx returns the logger stored in context, or provides the default logger if one is not present.
func Ctx(ctx echo.Context) *slog.Logger {
if l, ok := ctx.Get(context.LoggerKey).(*slog.Logger); ok {
return l
@ -21,7 +21,7 @@ func Ctx(ctx echo.Context) *slog.Logger {
return Default()
}
// Default returns the default logger
// Default returns the default logger.
func Default() *slog.Logger {
return slog.Default()
}

View file

@ -1,66 +1,13 @@
package middleware
import (
"errors"
"fmt"
"net/http"
"time"
"github.com/mikestefanello/pagoda/pkg/context"
"github.com/mikestefanello/pagoda/pkg/log"
"github.com/mikestefanello/pagoda/pkg/services"
"github.com/labstack/echo/v4"
)
// ServeCachedPage attempts to load a page from the cache by matching on the complete request URL
// If a page is cached for the requested URL, it will be served here and the request terminated.
// Any request made by an authenticated user or that is not a GET will be skipped.
func ServeCachedPage(t *services.TemplateRenderer) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
// Skip non GET requests
if ctx.Request().Method != http.MethodGet {
return next(ctx)
}
// Skip if the user is authenticated
if ctx.Get(context.AuthenticatedUserKey) != nil {
return next(ctx)
}
// Attempt to load from cache
page, err := t.GetCachedPage(ctx, ctx.Request().URL.String())
if err != nil {
switch {
case errors.Is(err, services.ErrCacheMiss):
case context.IsCanceledError(err):
return nil
default:
log.Ctx(ctx).Error("failed getting cached page",
"error", err,
)
}
return next(ctx)
}
// Set any headers
if page.Headers != nil {
for k, v := range page.Headers {
ctx.Response().Header().Set(k, v)
}
}
log.Ctx(ctx).Debug("serving cached page")
return ctx.HTMLBlob(page.StatusCode, page.HTML)
}
}
}
// CacheControl sets a Cache-Control header with a given max age
// CacheControl sets a Cache-Control header with a given max age.
func CacheControl(maxAge time.Duration) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {

View file

@ -1,52 +1,14 @@
package middleware
import (
"net/http"
"testing"
"time"
"github.com/mikestefanello/pagoda/pkg/page"
"github.com/mikestefanello/pagoda/pkg/tests"
"github.com/mikestefanello/pagoda/templates"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/assert"
)
func TestServeCachedPage(t *testing.T) {
// Cache a page
ctx, rec := tests.NewContext(c.Web, "/cache")
p := page.New(ctx)
p.Layout = templates.LayoutHTMX
p.Name = templates.PageHome
p.Cache.Enabled = true
p.Cache.Expiration = time.Minute
p.StatusCode = http.StatusCreated
p.Headers["a"] = "b"
p.Headers["c"] = "d"
err := c.TemplateRenderer.RenderPage(ctx, p)
output := rec.Body.Bytes()
require.NoError(t, err)
// Request the URL of the cached page
ctx, rec = tests.NewContext(c.Web, "/cache")
err = tests.ExecuteMiddleware(ctx, ServeCachedPage(c.TemplateRenderer))
assert.NoError(t, err)
assert.Equal(t, p.StatusCode, ctx.Response().Status)
assert.Equal(t, p.Headers["a"], ctx.Response().Header().Get("a"))
assert.Equal(t, p.Headers["c"], ctx.Response().Header().Get("c"))
assert.Equal(t, output, rec.Body.Bytes())
// Login and try again
tests.InitSession(ctx)
err = c.Auth.Login(ctx, usr.ID)
require.NoError(t, err)
_ = tests.ExecuteMiddleware(ctx, LoadAuthenticatedUser(c.Auth))
err = tests.ExecuteMiddleware(ctx, ServeCachedPage(c.TemplateRenderer))
assert.Nil(t, err)
}
func TestCacheControl(t *testing.T) {
ctx, _ := tests.NewContext(c.Web, "/")
_ = tests.ExecuteMiddleware(ctx, CacheControl(time.Second*5))

17
pkg/middleware/config.go Normal file
View file

@ -0,0 +1,17 @@
package middleware
import (
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/config"
"github.com/mikestefanello/pagoda/pkg/context"
)
// Config stores the configuration in the request so it can be accessed by the ui.
func Config(cfg *config.Config) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
ctx.Set(context.ConfigKey, cfg)
return next(ctx)
}
}
}

View file

@ -0,0 +1,22 @@
package middleware
import (
"testing"
"github.com/mikestefanello/pagoda/config"
"github.com/mikestefanello/pagoda/pkg/context"
"github.com/mikestefanello/pagoda/pkg/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConfig(t *testing.T) {
ctx, _ := tests.NewContext(c.Web, "/")
cfg := &config.Config{}
err := tests.ExecuteMiddleware(ctx, Config(cfg))
require.NoError(t, err)
got, ok := ctx.Get(context.ConfigKey).(*config.Config)
require.True(t, ok)
assert.Same(t, got, cfg)
}

View file

@ -12,7 +12,7 @@ import (
"github.com/labstack/echo/v4"
)
// LoadUser loads the user based on the ID provided as a path parameter
// LoadUser loads the user based on the ID provided as a path parameter.
func LoadUser(orm *ent.Client) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {

View file

@ -7,44 +7,44 @@ import (
"github.com/mikestefanello/pagoda/pkg/session"
)
// Type is a message type
// Type is a message type.
type Type string
const (
// TypeSuccess represents a success message type
// TypeSuccess represents a success message type.
TypeSuccess Type = "success"
// TypeInfo represents a info message type
// TypeInfo represents a info message type.
TypeInfo Type = "info"
// TypeWarning represents a warning message type
// TypeWarning represents a warning message type.
TypeWarning Type = "warning"
// TypeDanger represents a danger message type
// TypeDanger represents a danger message type.
TypeDanger Type = "danger"
)
const (
// sessionName stores the name of the session which contains flash messages
// sessionName stores the name of the session which contains flash messages.
sessionName = "msg"
)
// Success sets a success flash message
// Success sets a success flash message.
func Success(ctx echo.Context, message string) {
Set(ctx, TypeSuccess, message)
}
// Info sets an info flash message
// Info sets an info flash message.
func Info(ctx echo.Context, message string) {
Set(ctx, TypeInfo, message)
}
// Warning sets a warning flash message
// Warning sets a warning flash message.
func Warning(ctx echo.Context, message string) {
Set(ctx, TypeWarning, message)
}
// Danger sets a danger flash message
// Danger sets a danger flash message.
func Danger(ctx echo.Context, message string) {
Set(ctx, TypeDanger, message)
}
@ -76,7 +76,7 @@ func Get(ctx echo.Context, typ Type) []string {
return msgs
}
// getSession gets the flash message session
// getSession gets the flash message session.
func getSession(ctx echo.Context) (*sessions.Session, error) {
sess, err := session.Get(ctx, sessionName)
if err != nil {
@ -87,7 +87,7 @@ func getSession(ctx echo.Context) (*sessions.Session, error) {
return sess, err
}
// save saves the flash message session
// save saves the flash message session.
func save(ctx echo.Context, sess *sessions.Session) {
if err := sess.Save(ctx.Request(), ctx.Response()); err != nil {
log.Ctx(ctx).Error("failed to set flash message",

View file

@ -1,162 +0,0 @@
package page
import (
"html/template"
"net/http"
"time"
"github.com/mikestefanello/pagoda/ent"
"github.com/mikestefanello/pagoda/pkg/context"
"github.com/mikestefanello/pagoda/pkg/htmx"
"github.com/mikestefanello/pagoda/pkg/msg"
"github.com/mikestefanello/pagoda/templates"
echomw "github.com/labstack/echo/v4/middleware"
"github.com/labstack/echo/v4"
)
// Page consists of all data that will be used to render a page response for a given route.
// While it's not required for a handler to render a Page on a route, this is the common data
// object that will be passed to the templates, making it easy for all handlers to share
// functionality both on the back and frontend. The Page can be expanded to include anything else
// your app wants to support.
// Methods on this page also then become available in the templates, which can be more useful than
// the funcmap if your methods require data stored in the page, such as the context.
type Page struct {
// AppName stores the name of the application.
// If omitted, the configuration value will be used.
AppName string
// Title stores the title of the page
Title string
// Context stores the request context
Context echo.Context
// Path stores the path of the current request
Path string
// URL stores the URL of the current request
URL string
// Data stores whatever additional data that needs to be passed to the templates.
// This is what the handler uses to pass the content of the page.
Data any
// Form stores a struct that represents a form on the page.
// This should be a struct with fields for each form field, using both "form" and "validate" tags
// It should also contain form.FormSubmission if you wish to have validation
// messages and markup presented to the user
Form any
// Layout stores the name of the layout base template file which will be used when the page is rendered.
// This should match a template file located within the layouts directory inside the templates directory.
// The template extension should not be included in this value.
Layout templates.Layout
// Name stores the name of the page as well as the name of the template file which will be used to render
// the content portion of the layout template.
// This should match a template file located within the pages directory inside the templates directory.
// The template extension should not be included in this value.
Name templates.Page
// IsHome stores whether the requested page is the home page or not
IsHome bool
// IsAuth stores whether the user is authenticated
IsAuth bool
// AuthUser stores the authenticated user
AuthUser *ent.User
// StatusCode stores the HTTP status code that will be returned
StatusCode int
// Metatags stores metatag values
Metatags struct {
// Description stores the description metatag value
Description string
// Keywords stores the keywords metatag values
Keywords []string
}
// Pager stores a pager which can be used to page lists of results
Pager Pager
// CSRF stores the CSRF token for the given request.
// This will only be populated if the CSRF middleware is in effect for the given request.
// If this is populated, all forms must include this value otherwise the requests will be rejected.
CSRF string
// Headers stores a list of HTTP headers and values to be set on the response
Headers map[string]string
// RequestID stores the ID of the given request.
// This will only be populated if the request ID middleware is in effect for the given request.
RequestID string
// HTMX provides the ability to interact with the HTMX library
HTMX struct {
// Request contains the information provided by HTMX about the current request
Request htmx.Request
// Response contains values to pass back to HTMX
Response *htmx.Response
}
// Cache stores values for caching the response of this page
Cache struct {
// Enabled dictates if the response of this page should be cached.
// Cached responses are served via middleware.
Enabled bool
// Expiration stores the amount of time that the cache entry should live for before expiring.
// If omitted, the configuration value will be used.
Expiration time.Duration
// Tags stores a list of tags to apply to the cache entry.
// These are useful when invalidating cache for dynamic events such as entity operations.
Tags []string
}
}
// New creates and initiatizes a new Page for a given request context
func New(ctx echo.Context) Page {
p := Page{
Context: ctx,
Path: ctx.Request().URL.Path,
URL: ctx.Request().URL.String(),
StatusCode: http.StatusOK,
Pager: NewPager(ctx, DefaultItemsPerPage),
Headers: make(map[string]string),
RequestID: ctx.Response().Header().Get(echo.HeaderXRequestID),
}
p.IsHome = p.Path == "/"
if csrf := ctx.Get(echomw.DefaultCSRFConfig.ContextKey); csrf != nil {
p.CSRF = csrf.(string)
}
if u := ctx.Get(context.AuthenticatedUserKey); u != nil {
p.IsAuth = true
p.AuthUser = u.(*ent.User)
}
p.HTMX.Request = htmx.GetRequest(ctx)
return p
}
// GetMessages gets all flash messages for a given type.
// This allows for easy access to flash messages from the templates.
func (p Page) GetMessages(typ msg.Type) []template.HTML {
strs := msg.Get(p.Context, typ)
ret := make([]template.HTML, len(strs))
for k, v := range strs {
ret[k] = template.HTML(v)
}
return ret
}

View file

@ -1,77 +0,0 @@
package page
import (
"net/http"
"testing"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/ent"
"github.com/mikestefanello/pagoda/pkg/context"
"github.com/mikestefanello/pagoda/pkg/msg"
"github.com/mikestefanello/pagoda/pkg/tests"
echomw "github.com/labstack/echo/v4/middleware"
"github.com/stretchr/testify/assert"
)
func TestNew(t *testing.T) {
e := echo.New()
ctx, _ := tests.NewContext(e, "/")
p := New(ctx)
assert.Same(t, ctx, p.Context)
assert.Equal(t, "/", p.Path)
assert.Equal(t, "/", p.URL)
assert.Equal(t, http.StatusOK, p.StatusCode)
assert.Equal(t, NewPager(ctx, DefaultItemsPerPage), p.Pager)
assert.Empty(t, p.Headers)
assert.True(t, p.IsHome)
assert.False(t, p.IsAuth)
assert.Empty(t, p.CSRF)
assert.Empty(t, p.RequestID)
assert.False(t, p.Cache.Enabled)
ctx, _ = tests.NewContext(e, "/abc?def=123")
usr := &ent.User{
ID: 1,
}
ctx.Set(context.AuthenticatedUserKey, usr)
ctx.Set(echomw.DefaultCSRFConfig.ContextKey, "csrf")
p = New(ctx)
assert.Equal(t, "/abc", p.Path)
assert.Equal(t, "/abc?def=123", p.URL)
assert.False(t, p.IsHome)
assert.True(t, p.IsAuth)
assert.Equal(t, usr, p.AuthUser)
assert.Equal(t, "csrf", p.CSRF)
}
func TestPage_GetMessages(t *testing.T) {
ctx, _ := tests.NewContext(echo.New(), "/")
tests.InitSession(ctx)
p := New(ctx)
// Set messages
msgTests := make(map[msg.Type][]string)
msgTests[msg.TypeWarning] = []string{
"abc",
"def",
}
msgTests[msg.TypeInfo] = []string{
"123",
"456",
}
for typ, values := range msgTests {
for _, value := range values {
msg.Set(ctx, typ, value)
}
}
// Get the messages
for typ, values := range msgTests {
msgs := p.GetMessages(typ)
for i, message := range msgs {
assert.Equal(t, values[i], string(message))
}
}
}

View file

@ -1,4 +1,4 @@
package page
package pager
import (
"math"
@ -7,30 +7,25 @@ import (
"github.com/labstack/echo/v4"
)
const (
// DefaultItemsPerPage stores the default amount of items per page
DefaultItemsPerPage = 20
// QueryKey stores the query key used to indicate the current page.
const QueryKey = "page"
// PageQueryKey stores the query key used to indicate the current page
PageQueryKey = "page"
)
// Pager provides a mechanism to allow a user to page results via a query parameter
// Pager provides a mechanism to allow a user to page results via a query parameter.
type Pager struct {
// Items stores the total amount of items in the result set
// Items stores the total amount of items in the result set.
Items int
// Page stores the current page number
// Page stores the current page number.
Page int
// ItemsPerPage stores the amount of items to display per page
// ItemsPerPage stores the amount of items to display per page.
ItemsPerPage int
// Pages stores the total amount of pages in the result set
// Pages stores the total amount of pages in the result set.
Pages int
}
// NewPager creates a new Pager
// NewPager creates a new Pager.
func NewPager(ctx echo.Context, itemsPerPage int) Pager {
p := Pager{
ItemsPerPage: itemsPerPage,
@ -38,7 +33,7 @@ func NewPager(ctx echo.Context, itemsPerPage int) Pager {
Page: 1,
}
if page := ctx.QueryParam(PageQueryKey); page != "" {
if page := ctx.QueryParam(QueryKey); page != "" {
if pageInt, err := strconv.Atoi(page); err == nil {
if pageInt > 0 {
p.Page = pageInt
@ -67,18 +62,18 @@ func (p *Pager) SetItems(items int) {
}
// IsBeginning determines if the pager is at the beginning of the pages
func (p Pager) IsBeginning() bool {
func (p *Pager) IsBeginning() bool {
return p.Page == 1
}
// IsEnd determines if the pager is at the end of the pages
func (p Pager) IsEnd() bool {
func (p *Pager) IsEnd() bool {
return p.Page >= p.Pages
}
// GetOffset determines the offset of the results in order to get the items for
// the current page
func (p Pager) GetOffset() int {
func (p *Pager) GetOffset() int {
if p.Page == 0 {
p.Page = 1
}

View file

@ -1,4 +1,4 @@
package page
package pager
import (
"fmt"
@ -19,11 +19,11 @@ func TestNewPager(t *testing.T) {
assert.Equal(t, 0, pgr.Items)
assert.Equal(t, 1, pgr.Pages)
ctx, _ = tests.NewContext(e, fmt.Sprintf("/abc?%s=%d", PageQueryKey, 2))
ctx, _ = tests.NewContext(e, fmt.Sprintf("/abc?%s=%d", QueryKey, 2))
pgr = NewPager(ctx, 10)
assert.Equal(t, 2, pgr.Page)
ctx, _ = tests.NewContext(e, fmt.Sprintf("/abc?%s=%d", PageQueryKey, -2))
ctx, _ = tests.NewContext(e, fmt.Sprintf("/abc?%s=%d", QueryKey, -2))
pgr = NewPager(ctx, 10)
assert.Equal(t, 1, pgr.Page)
}

View file

@ -24,7 +24,7 @@ type Redirect struct {
func New(ctx echo.Context) *Redirect {
return &Redirect{
ctx: ctx,
status: http.StatusFound,
status: http.StatusTemporaryRedirect,
}
}

25
pkg/routenames/names.go Normal file
View file

@ -0,0 +1,25 @@
package routenames
const (
Home = "home"
About = "about"
Contact = "contact"
ContactSubmit = "contact.submit"
Login = "login"
LoginSubmit = "login.submit"
Register = "register"
RegisterSubmit = "register.submit"
ForgotPassword = "forgot_password"
ForgotPasswordSubmit = "forgot_password.submit"
Logout = "logout"
VerifyEmail = "verify_email"
ResetPassword = "reset_password"
ResetPasswordSubmit = "reset_password.submit"
Search = "search"
Task = "task"
TaskSubmit = "task.submit"
Cache = "cache"
CacheSubmit = "cache.submit"
Files = "files"
FilesSubmit = "files.submit"
)

View file

@ -16,51 +16,47 @@ import (
_ "github.com/mattn/go-sqlite3"
"github.com/mikestefanello/pagoda/config"
"github.com/mikestefanello/pagoda/ent"
"github.com/mikestefanello/pagoda/pkg/funcmap"
"github.com/mikestefanello/pagoda/pkg/log"
// Require by ent
// Required by ent.
_ "github.com/mikestefanello/pagoda/ent/runtime"
)
// Container contains all services used by the application and provides an easy way to handle dependency
// injection including within tests
// injection including within tests.
type Container struct {
// Validator stores a validator
Validator *Validator
// Web stores the web framework
// Web stores the web framework.
Web *echo.Echo
// Config stores the application configuration
// Config stores the application configuration.
Config *config.Config
// Cache contains the cache client
// Cache contains the cache client.
Cache *CacheClient
// Database stores the connection to the database
// Database stores the connection to the database.
Database *sql.DB
// Files stores the file system.
Files afero.Fs
// ORM stores a client to the ORM
// ORM stores a client to the ORM.
ORM *ent.Client
// Mail stores an email sending client
// Mail stores an email sending client.
Mail *MailClient
// Auth stores an authentication client
// Auth stores an authentication client.
Auth *AuthClient
// TemplateRenderer stores a service to easily render and cache templates
TemplateRenderer *TemplateRenderer
// Tasks stores the task client
// Tasks stores the task client.
Tasks *backlite.Client
}
// NewContainer creates and initializes a new Container
// NewContainer creates and initializes a new Container.
func NewContainer() *Container {
c := new(Container)
c.initConfig()
@ -71,7 +67,6 @@ func NewContainer() *Container {
c.initFiles()
c.initORM()
c.initAuth()
c.initTemplateRenderer()
c.initMail()
c.initTasks()
return c
@ -107,7 +102,7 @@ func (c *Container) Shutdown() error {
return nil
}
// initConfig initializes configuration
// initConfig initializes configuration.
func (c *Container) initConfig() {
cfg, err := config.GetConfig()
if err != nil {
@ -115,7 +110,7 @@ func (c *Container) initConfig() {
}
c.Config = &cfg
// Configure logging
// Configure logging.
switch cfg.App.Environment {
case config.EnvProduction:
slog.SetLogLoggerLevel(slog.LevelInfo)
@ -124,19 +119,19 @@ func (c *Container) initConfig() {
}
}
// initValidator initializes the validator
// initValidator initializes the validator.
func (c *Container) initValidator() {
c.Validator = NewValidator()
}
// initWeb initializes the web framework
// initWeb initializes the web framework.
func (c *Container) initWeb() {
c.Web = echo.New()
c.Web.HideBanner = true
c.Web.Validator = c.Validator
}
// initCache initializes the cache
// initCache initializes the cache.
func (c *Container) initCache() {
store, err := newInMemoryCache(c.Config.Cache.Capacity)
if err != nil {
@ -146,7 +141,7 @@ func (c *Container) initCache() {
c.Cache = NewCacheClient(store)
}
// initDatabase initializes the database
// initDatabase initializes the database.
func (c *Container) initDatabase() {
var err error
var connection string
@ -180,7 +175,7 @@ func (c *Container) initFiles() {
c.Files = afero.NewBasePathFs(fs, c.Config.Files.Directory)
}
// initORM initializes the ORM
// initORM initializes the ORM.
func (c *Container) initORM() {
drv := entsql.OpenDB(c.Config.Database.Driver, c.Database)
c.ORM = ent.NewClient(ent.Driver(drv))
@ -191,30 +186,25 @@ func (c *Container) initORM() {
}
}
// initAuth initializes the authentication client
// initAuth initializes the authentication client.
func (c *Container) initAuth() {
c.Auth = NewAuthClient(c.Config, c.ORM)
}
// initTemplateRenderer initializes the template renderer
func (c *Container) initTemplateRenderer() {
c.TemplateRenderer = NewTemplateRenderer(c.Config, c.Cache, funcmap.NewFuncMap(c.Web))
}
// initMail initialize the mail client
// initMail initialize the mail client.
func (c *Container) initMail() {
var err error
c.Mail, err = NewMailClient(c.Config, c.TemplateRenderer)
c.Mail, err = NewMailClient(c.Config)
if err != nil {
panic(fmt.Sprintf("failed to create mail client: %v", err))
}
}
// initTasks initializes the task client
// initTasks initializes the task client.
func (c *Container) initTasks() {
var err error
// You could use a separate database for tasks, if you'd like. but using one
// makes transaction support easier
// makes transaction support easier.
c.Tasks, err = backlite.NewClient(backlite.ClientConfig{
DB: c.Database,
Logger: log.Default(),
@ -232,10 +222,10 @@ func (c *Container) initTasks() {
}
}
// openDB opens a database connection
// openDB opens a database connection.
func openDB(driver, connection string) (*sql.DB, error) {
// Helper to automatically create the directories that the specified sqlite file
// should reside in, if one
// should reside in, if one.
if driver == "sqlite3" {
d := strings.Split(connection, "/")

View file

@ -16,6 +16,5 @@ func TestNewContainer(t *testing.T) {
assert.NotNil(t, c.ORM)
assert.NotNil(t, c.Mail)
assert.NotNil(t, c.Auth)
assert.NotNil(t, c.TemplateRenderer)
assert.NotNil(t, c.Tasks)
}

View file

@ -1,11 +1,12 @@
package services
import (
"bytes"
"errors"
"fmt"
"github.com/mikestefanello/pagoda/config"
"github.com/mikestefanello/pagoda/pkg/log"
"maragu.dev/gomponents"
"github.com/labstack/echo/v4"
)
@ -14,36 +15,31 @@ type (
// MailClient provides a client for sending email
// This is purposely not completed because there are many different methods and services
// for sending email, many of which are very different. Choose what works best for you
// and populate the methods below
// and populate the methods below. For now, emails will just be logged.
MailClient struct {
// config stores application configuration
// config stores application configuration.
config *config.Config
// templates stores the template renderer
templates *TemplateRenderer
}
// mail represents an email to be sent
// mail represents an email to be sent.
mail struct {
client *MailClient
from string
to string
subject string
body string
template string
templateData any
client *MailClient
from string
to string
subject string
body string
component gomponents.Node
}
)
// NewMailClient creates a new MailClient
func NewMailClient(cfg *config.Config, templates *TemplateRenderer) (*MailClient, error) {
// NewMailClient creates a new MailClient.
func NewMailClient(cfg *config.Config) (*MailClient, error) {
return &MailClient{
config: cfg,
templates: templates,
config: cfg,
}, nil
}
// Compose creates a new email
// Compose creates a new email.
func (m *MailClient) Compose() *mail {
return &mail{
client: m,
@ -51,39 +47,33 @@ func (m *MailClient) Compose() *mail {
}
}
// skipSend determines if mail sending should be skipped
// skipSend determines if mail sending should be skipped.
func (m *MailClient) skipSend() bool {
return m.config.App.Environment != config.EnvProduction
}
// send attempts to send the email
// send attempts to send the email.
func (m *MailClient) send(email *mail, ctx echo.Context) error {
switch {
case email.to == "":
return errors.New("email cannot be sent without a to address")
case email.body == "" && email.template == "":
return errors.New("email cannot be sent without a body or template")
case email.body == "" && email.component == nil:
return errors.New("email cannot be sent without a body or component to render")
}
// Check if a template was supplied
if email.template != "" {
// Parse and execute template
buf, err := m.templates.
Parse().
Group("mail").
Key(email.template).
Base(email.template).
Files(fmt.Sprintf("emails/%s", email.template)).
Execute(email.templateData)
if err != nil {
// Check if a component was supplied.
if email.component != nil {
// Render the component and use as the body.
// TODO pool the buffers?
buf := bytes.NewBuffer(nil)
if err := email.component.Render(buf); err != nil {
return err
}
email.body = buf.String()
}
// Check if mail sending should be skipped
// Check if mail sending should be skipped.
if m.skipSend() {
log.Ctx(ctx).Debug("skipping email delivery",
"to", email.to,
@ -91,52 +81,47 @@ func (m *MailClient) send(email *mail, ctx echo.Context) error {
return nil
}
// TODO: Finish based on your mail sender of choice!
// TODO: Finish based on your mail sender of choice or stop logging below!
log.Ctx(ctx).Info("sending email",
"to", email.to,
"subject", email.subject,
"body", email.body,
)
return nil
}
// From sets the email from address
// From sets the email from address.
func (m *mail) From(from string) *mail {
m.from = from
return m
}
// To sets the email address this email will be sent to
// To sets the email address this email will be sent to.
func (m *mail) To(to string) *mail {
m.to = to
return m
}
// Subject sets the subject line of the email
// Subject sets the subject line of the email.
func (m *mail) Subject(subject string) *mail {
m.subject = subject
return m
}
// Body sets the body of the email
// This is not required and will be ignored if a template via Template()
// Body sets the body of the email.
// This is not required and will be ignored if a component is set via Component().
func (m *mail) Body(body string) *mail {
m.body = body
return m
}
// Template sets the template to be used to produce the body of the email
// The template name should only include the filename without the extension or directory.
// The template must reside within the emails sub-directory.
// The funcmap will be automatically added to the template.
// Use TemplateData() to supply the data that will be passed in to the template.
func (m *mail) Template(template string) *mail {
m.template = template
// Component sets a renderable component to use as the body of the email.
func (m *mail) Component(component gomponents.Node) *mail {
m.component = component
return m
}
// TemplateData sets the data that will be passed to the template specified when calling Template()
func (m *mail) TemplateData(data any) *mail {
m.templateData = data
return m
}
// Send attempts to send the email
// Send attempts to send the email.
func (m *mail) Send(ctx echo.Context) error {
return m.client.send(m, ctx)
}

View file

@ -1,376 +0,0 @@
package services
import (
"bytes"
"errors"
"fmt"
"html/template"
"io/fs"
"net/http"
"sync"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/config"
"github.com/mikestefanello/pagoda/pkg/context"
"github.com/mikestefanello/pagoda/pkg/log"
"github.com/mikestefanello/pagoda/pkg/page"
"github.com/mikestefanello/pagoda/templates"
)
// cachedPageGroup stores the cache group for cached pages
const cachedPageGroup = "page"
type (
// TemplateRenderer provides a flexible and easy to use method of rendering simple templates or complex sets of
// templates while also providing caching and/or hot-reloading depending on your current environment
TemplateRenderer struct {
// templateCache stores a cache of parsed page templates
templateCache sync.Map
// funcMap stores the template function map
funcMap template.FuncMap
// config stores application configuration
config *config.Config
// cache stores the cache client
cache *CacheClient
}
// TemplateParsed is a wrapper around parsed templates which are stored in the TemplateRenderer cache
TemplateParsed struct {
// Template is the parsed template
Template *template.Template
// build stores the build data used to parse the template
build *templateBuild
}
// templateBuild stores the build data used to parse a template
templateBuild struct {
group string
key string
base string
files []string
directories []string
}
// templateBuilder handles chaining a template parse operation
templateBuilder struct {
build *templateBuild
renderer *TemplateRenderer
}
// CachedPage is what is used to store a rendered Page in the cache
CachedPage struct {
// URL stores the URL of the requested page
URL string
// HTML stores the complete HTML of the rendered Page
HTML []byte
// StatusCode stores the HTTP status code
StatusCode int
// Headers stores the HTTP headers
Headers map[string]string
}
)
// NewTemplateRenderer creates a new TemplateRenderer
func NewTemplateRenderer(cfg *config.Config, cache *CacheClient, fm template.FuncMap) *TemplateRenderer {
return &TemplateRenderer{
templateCache: sync.Map{},
funcMap: fm,
config: cfg,
cache: cache,
}
}
// Parse creates a template build operation
func (t *TemplateRenderer) Parse() *templateBuilder {
return &templateBuilder{
renderer: t,
build: &templateBuild{},
}
}
// RenderPage renders a Page as an HTTP response
func (t *TemplateRenderer) RenderPage(ctx echo.Context, page page.Page) error {
var buf *bytes.Buffer
var err error
templateGroup := "page"
// Page name is required
if page.Name == "" {
return echo.NewHTTPError(http.StatusInternalServerError, "page render failed due to missing name")
}
// Use the app name in configuration if a value was not set
if page.AppName == "" {
page.AppName = t.config.App.Name
}
// Check if this is an HTMX non-boosted request which indicates that only partial
// content should be rendered
if page.HTMX.Request.Enabled && !page.HTMX.Request.Boosted {
// Switch the layout which will only render the page content
page.Layout = templates.LayoutHTMX
// Alter the template group so this is cached separately
templateGroup = "page:htmx"
}
// Parse and execute the templates for the Page
// As mentioned in the documentation for the Page struct, the templates used for the page will be:
// 1. The layout/base template specified in Page.Layout
// 2. The content template specified in Page.Name
// 3. All templates within the components directory
// Also included is the function map provided by the funcmap package
buf, err = t.
Parse().
Group(templateGroup).
Key(string(page.Name)).
Base(string(page.Layout)).
Files(
fmt.Sprintf("layouts/%s", page.Layout),
fmt.Sprintf("pages/%s", page.Name),
).
Directories("components").
Execute(page)
if err != nil {
return echo.NewHTTPError(
http.StatusInternalServerError,
fmt.Sprintf("failed to parse and execute templates: %s", err),
)
}
// Set the status code
ctx.Response().Status = page.StatusCode
// Set any headers
for k, v := range page.Headers {
ctx.Response().Header().Set(k, v)
}
// Apply the HTMX response, if one
if page.HTMX.Response != nil {
page.HTMX.Response.Apply(ctx)
}
// Cache this page, if caching was enabled
t.cachePage(ctx, page, buf)
return ctx.HTMLBlob(ctx.Response().Status, buf.Bytes())
}
// cachePage caches the HTML for a given Page if the Page has caching enabled
func (t *TemplateRenderer) cachePage(ctx echo.Context, page page.Page, html *bytes.Buffer) {
if !page.Cache.Enabled || page.IsAuth {
return
}
// If no expiration time was provided, default to the configuration value
if page.Cache.Expiration == 0 {
page.Cache.Expiration = t.config.Cache.Expiration.Page
}
// Extract the headers
headers := make(map[string]string)
for k, v := range ctx.Response().Header() {
headers[k] = v[0]
}
// The request URL is used as the cache key so the middleware can serve the
// cached page on matching requests
key := ctx.Request().URL.String()
cp := &CachedPage{
URL: key,
HTML: html.Bytes(),
Headers: headers,
StatusCode: ctx.Response().Status,
}
err := t.cache.
Set().
Group(cachedPageGroup).
Key(key).
Tags(page.Cache.Tags...).
Expiration(page.Cache.Expiration).
Data(cp).
Save(ctx.Request().Context())
switch {
case err == nil:
log.Ctx(ctx).Debug("cached page")
case !context.IsCanceledError(err):
log.Ctx(ctx).Error("failed to cache page",
"error", err,
)
}
}
// GetCachedPage attempts to fetch a cached page for a given URL
func (t *TemplateRenderer) GetCachedPage(ctx echo.Context, url string) (*CachedPage, error) {
p, err := t.cache.
Get().
Group(cachedPageGroup).
Key(url).
Fetch(ctx.Request().Context())
if err != nil {
return nil, err
}
return p.(*CachedPage), nil
}
// getCacheKey gets a cache key for a given group and ID
func (t *TemplateRenderer) getCacheKey(group, key string) string {
if group != "" {
return fmt.Sprintf("%s:%s", group, key)
}
return key
}
// parse parses a set of templates and caches them for quick execution
// If the application environment is set to local, the cache will be bypassed and templates will be
// parsed upon each request so hot-reloading is possible without restarts.
// Also included will be the function map provided by the funcmap package.
func (t *TemplateRenderer) parse(build *templateBuild) (*TemplateParsed, error) {
var tp *TemplateParsed
var err error
switch {
case build.key == "":
return nil, errors.New("cannot parse template without key")
case len(build.files) == 0 && len(build.directories) == 0:
return nil, errors.New("cannot parse template without files or directories")
case build.base == "":
return nil, errors.New("cannot parse template without base")
}
// Generate the cache key
cacheKey := t.getCacheKey(build.group, build.key)
// Check if the template has not yet been parsed or if the app environment is local, so that
// templates reflect changes without having the restart the server
if tp, err = t.Load(build.group, build.key); err != nil || t.config.App.Environment == config.EnvLocal {
// Initialize the parsed template with the function map
parsed := template.New(build.base + config.TemplateExt).
Funcs(t.funcMap)
// Format the requested files
for k, v := range build.files {
build.files[k] = fmt.Sprintf("%s%s", v, config.TemplateExt)
}
// Include all files within the requested directories
for k, v := range build.directories {
build.directories[k] = fmt.Sprintf("%s/*%s", v, config.TemplateExt)
}
// Get the templates
var tpl fs.FS
if t.config.App.Environment == config.EnvLocal {
tpl = templates.GetOS()
} else {
tpl = templates.Get()
}
// Parse the templates
parsed, err = parsed.ParseFS(tpl, append(build.files, build.directories...)...)
if err != nil {
return nil, err
}
// Store the template so this process only happens once
tp = &TemplateParsed{
Template: parsed,
build: build,
}
t.templateCache.Store(cacheKey, tp)
}
return tp, nil
}
// Load loads a template from the cache
func (t *TemplateRenderer) Load(group, key string) (*TemplateParsed, error) {
load, ok := t.templateCache.Load(t.getCacheKey(group, key))
if !ok {
return nil, errors.New("uncached page template requested")
}
tmpl, ok := load.(*TemplateParsed)
if !ok {
return nil, errors.New("unable to cast cached template")
}
return tmpl, nil
}
// Execute executes a template with the given data and provides the output
func (t *TemplateParsed) Execute(data any) (*bytes.Buffer, error) {
if t.Template == nil {
return nil, errors.New("cannot execute template: template not initialized")
}
buf := new(bytes.Buffer)
err := t.Template.ExecuteTemplate(buf, t.build.base+config.TemplateExt, data)
if err != nil {
return nil, err
}
return buf, nil
}
// Group sets the cache group for the template being built
func (t *templateBuilder) Group(group string) *templateBuilder {
t.build.group = group
return t
}
// Key sets the cache key for the template being built
func (t *templateBuilder) Key(key string) *templateBuilder {
t.build.key = key
return t
}
// Base sets the name of the base template to be used during template parsing and execution.
// This should be only the file name without a directory or extension.
func (t *templateBuilder) Base(base string) *templateBuilder {
t.build.base = base
return t
}
// Files sets a list of template files to include in the parse.
// This should not include the file extension and the paths should be relative to the templates directory.
func (t *templateBuilder) Files(files ...string) *templateBuilder {
t.build.files = files
return t
}
// Directories sets a list of directories that all template files within will be parsed.
// The paths should be relative to the templates directory.
func (t *templateBuilder) Directories(directories ...string) *templateBuilder {
t.build.directories = directories
return t
}
// Store parsed the templates and stores them in the cache
func (t *templateBuilder) Store() (*TemplateParsed, error) {
return t.renderer.parse(t.build)
}
// Execute executes the template with the given data.
// If the template has not already been cached, this will parse and cache the template
func (t *templateBuilder) Execute(data any) (*bytes.Buffer, error) {
tp, err := t.Store()
if err != nil {
return nil, err
}
return tp.Execute(data)
}

View file

@ -1,198 +0,0 @@
package services
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/config"
"github.com/mikestefanello/pagoda/pkg/htmx"
"github.com/mikestefanello/pagoda/pkg/page"
"github.com/mikestefanello/pagoda/pkg/tests"
"github.com/mikestefanello/pagoda/templates"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTemplateRenderer(t *testing.T) {
group := "test"
id := "parse"
// Should not exist yet
_, err := c.TemplateRenderer.Load(group, id)
assert.Error(t, err)
// Parse in to the cache
tpl, err := c.TemplateRenderer.
Parse().
Group(group).
Key(id).
Base("htmx").
Files("layouts/htmx", "pages/error").
Directories("components").
Store()
require.NoError(t, err)
// Should exist now
parsed, err := c.TemplateRenderer.Load(group, id)
require.NoError(t, err)
// Check that all expected templates are included
expectedTemplates := make(map[string]bool)
expectedTemplates["htmx"+config.TemplateExt] = true
expectedTemplates["error"+config.TemplateExt] = true
components, err := templates.Get().ReadDir("components")
require.NoError(t, err)
for _, f := range components {
expectedTemplates[f.Name()] = true
}
for _, v := range parsed.Template.Templates() {
delete(expectedTemplates, v.Name())
}
assert.Empty(t, expectedTemplates)
data := struct {
StatusCode int
}{
StatusCode: 500,
}
buf, err := tpl.Execute(data)
require.NoError(t, err)
require.NotNil(t, buf)
assert.Contains(t, buf.String(), "Please try again")
buf, err = c.TemplateRenderer.
Parse().
Group(group).
Key(id).
Base("htmx").
Files("htmx", "pages/error").
Directories("components").
Execute(data)
require.NoError(t, err)
require.NotNil(t, buf)
assert.Contains(t, buf.String(), "Please try again")
}
func TestTemplateRenderer_RenderPage(t *testing.T) {
setup := func() (echo.Context, *httptest.ResponseRecorder, page.Page) {
ctx, rec := tests.NewContext(c.Web, "/test/TestTemplateRenderer_RenderPage")
tests.InitSession(ctx)
p := page.New(ctx)
p.Name = "home"
p.Layout = "main"
p.Cache.Enabled = false
p.Headers["A"] = "b"
p.Headers["C"] = "d"
p.StatusCode = http.StatusCreated
return ctx, rec, p
}
t.Run("missing name", func(t *testing.T) {
// Rendering should fail if the Page has no name
ctx, _, p := setup()
p.Name = ""
err := c.TemplateRenderer.RenderPage(ctx, p)
assert.Error(t, err)
})
t.Run("no page cache", func(t *testing.T) {
ctx, _, p := setup()
err := c.TemplateRenderer.RenderPage(ctx, p)
require.NoError(t, err)
// Check status code and headers
assert.Equal(t, http.StatusCreated, ctx.Response().Status)
for k, v := range p.Headers {
assert.Equal(t, v, ctx.Response().Header().Get(k))
}
// Check the template cache
parsed, err := c.TemplateRenderer.Load("page", string(p.Name))
require.NoError(t, err)
// Check that all expected templates were parsed.
// This includes the name, layout and all components
expectedTemplates := make(map[string]bool)
expectedTemplates[fmt.Sprintf("%s%s", p.Name, config.TemplateExt)] = true
expectedTemplates[fmt.Sprintf("%s%s", p.Layout, config.TemplateExt)] = true
components, err := templates.Get().ReadDir("components")
require.NoError(t, err)
for _, f := range components {
expectedTemplates[f.Name()] = true
}
for _, v := range parsed.Template.Templates() {
delete(expectedTemplates, v.Name())
}
assert.Empty(t, expectedTemplates)
})
t.Run("htmx rendering", func(t *testing.T) {
ctx, _, p := setup()
p.HTMX.Request.Enabled = true
p.HTMX.Response = &htmx.Response{
Trigger: "trigger",
}
err := c.TemplateRenderer.RenderPage(ctx, p)
require.NoError(t, err)
// Check HTMX header
assert.Equal(t, "trigger", ctx.Response().Header().Get(htmx.HeaderTrigger))
// Check the template cache
parsed, err := c.TemplateRenderer.Load("page:htmx", string(p.Name))
require.NoError(t, err)
// Check that all expected templates were parsed.
// This includes the name, htmx and all components
expectedTemplates := make(map[string]bool)
expectedTemplates[fmt.Sprintf("%s%s", p.Name, config.TemplateExt)] = true
expectedTemplates["htmx"+config.TemplateExt] = true
components, err := templates.Get().ReadDir("components")
require.NoError(t, err)
for _, f := range components {
expectedTemplates[f.Name()] = true
}
for _, v := range parsed.Template.Templates() {
delete(expectedTemplates, v.Name())
}
assert.Empty(t, expectedTemplates)
})
t.Run("page cache", func(t *testing.T) {
ctx, rec, p := setup()
p.Cache.Enabled = true
p.Cache.Tags = []string{"tag1"}
err := c.TemplateRenderer.RenderPage(ctx, p)
require.NoError(t, err)
// Fetch from the cache
cp, err := c.TemplateRenderer.GetCachedPage(ctx, p.URL)
require.NoError(t, err)
// Compare the cached page
assert.Equal(t, p.URL, cp.URL)
assert.Equal(t, p.Headers, cp.Headers)
assert.Equal(t, p.StatusCode, cp.StatusCode)
assert.Equal(t, rec.Body.Bytes(), cp.HTML)
// Clear the tag
err = c.Cache.
Flush().
Tags(p.Cache.Tags[0]).
Execute(context.Background())
require.NoError(t, err)
// Refetch from the cache and expect no results
_, err = c.TemplateRenderer.GetCachedPage(ctx, p.URL)
assert.Error(t, err)
})
}

View file

@ -2,14 +2,16 @@ package tasks
import (
"context"
"github.com/mikestefanello/backlite"
"time"
"github.com/mikestefanello/backlite"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/log"
"github.com/mikestefanello/pagoda/pkg/services"
)
// ExampleTask is an example implementation of backlite.Task
// ExampleTask is an example implementation of backlite.Task.
// This represents the task that can be queued for execution via the task client and should contain everything
// that your queue processor needs to process the task.
type ExampleTask struct {
@ -34,7 +36,7 @@ func (t ExampleTask) Config() backlite.QueueConfig {
}
}
// NewExampleTaskQueue provides a Queue that can process ExampleTask tasks
// NewExampleTaskQueue provides a Queue that can process ExampleTask tasks.
// The service container is provided so the subscriber can have access to the app dependencies.
// All queues must be registered in the Register() function.
// Whenever an ExampleTask is added to the task client, it will be queued and eventually sent here for execution.
@ -44,7 +46,7 @@ func NewExampleTaskQueue(c *services.Container) backlite.Queue {
"message", task.Message,
)
log.Default().Info("This can access the container for dependencies",
"echo", c.Web.Reverse("home"),
"echo", c.Web.Reverse(routenames.Home),
)
return nil
})

View file

@ -4,7 +4,7 @@ import (
"github.com/mikestefanello/pagoda/pkg/services"
)
// Register registers all task queues with the task client
// Register registers all task queues with the task client.
func Register(c *services.Container) {
c.Tasks.Register(NewExampleTaskQueue(c))
}

64
pkg/ui/cache/cache.go vendored Normal file
View file

@ -0,0 +1,64 @@
package cache
import (
"bytes"
"sync"
"maragu.dev/gomponents"
)
var (
// cache stores a cache of assembled components by key.
cache = make(map[string]gomponents.Node)
// mu handles concurrent access to the cache.
mu sync.RWMutex
)
// Set sets a given renderable node in the cache with a given key.
// You should only cache nodes that are entirely static.
// This will panic if the node fails to render.
//
// To optimize performance, the node will be rendered and converted to a Raw component so the assembly and rendering
// of the entire, nested node only has to execute once. This can eliminate countless function calls and string building.
// It's worth noting that this performance optimization is, in most cases, entirely unnecessary, but it's easy to do
// and realize some performance gains. In my very limited testing, gomponents actually outperformed Go templates in
// many areas; but not all. The results were still very close and my limited testing is in no way definitive.
//
// In most applications, these slight differences in nanoseconds and bytes allocated will almost never matter or even
// be noticeable, but it's good to be aware of them; and it's fun to address them. In looking at the example layouts
// provided, I noticed that a lot of nested function calls and string building was happening on every single page load
// just to re-render static HTML such as the navbar and the search form/modal. Benchmarks quickly revealed that caching
// those high-level nodes made a significant difference in speed and memory allocations. Going further, I thought that
// with the entire node cached, you still have to render the entire nested structure each time it's used, so that is why
// this will render them upfront, then cache. If my few examples have a handful of static nodes, I assume most full
// applications will have many, so maybe this is useful.
func Set(key string, node gomponents.Node) {
buf := bytes.NewBuffer(nil)
if err := node.Render(buf); err != nil {
panic(err)
}
mu.Lock()
defer mu.Unlock()
cache[key] = gomponents.Raw(buf.String())
}
// Get returns the node cached under the provided key, if one exists.
func Get(key string) gomponents.Node {
mu.RLock()
defer mu.RUnlock()
return cache[key]
}
// SetIfNotExists will return the cached Node for the key, if it exists, otherwise it will use the provided callback
// function to generate the node and cache it.
func SetIfNotExists(key string, gen func() gomponents.Node) gomponents.Node {
if n := Get(key); n != nil {
return n
}
n := gen()
Set(key, n)
return n
}

57
pkg/ui/cache/cache_test.go vendored Normal file
View file

@ -0,0 +1,57 @@
package cache
import (
"bytes"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func TestCache_GetSet(t *testing.T) {
key := "test"
assert.Nil(t, Get(key))
node := Div(Text("hello"))
Set(key, node)
got := Get(key)
require.NotNil(t, got)
// Check it was converted to a Raw component.
_, ok := got.(NodeFunc)
require.True(t, ok)
// Both nodes should render the same string.
buf1, buf2 := bytes.NewBuffer(nil), bytes.NewBuffer(nil)
require.NoError(t, node.Render(buf1))
require.NoError(t, got.Render(buf2))
assert.Equal(t, buf1.String(), buf2.String())
}
func TestCache_SetIfNotExists(t *testing.T) {
key := "test2"
called := 0
callback := func() Node {
called++
return Div(Text("hello"))
}
assertRender := func(n Node) {
buf := bytes.NewBuffer(nil)
require.NoError(t, n.Render(buf))
assert.Equal(t, `<div>hello</div>`, buf.String())
}
got := SetIfNotExists(key, callback)
assert.Equal(t, 1, called)
require.NotNil(t, got)
assertRender(got)
got = SetIfNotExists(key, callback)
assert.Equal(t, 1, called)
require.NotNil(t, got)
assertRender(got)
}

View file

@ -0,0 +1,64 @@
package components
import (
"github.com/mikestefanello/pagoda/pkg/msg"
"github.com/mikestefanello/pagoda/pkg/ui"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func FlashMessages(r *ui.Request) Node {
var g Group
for _, typ := range []msg.Type{
msg.TypeSuccess,
msg.TypeInfo,
msg.TypeWarning,
msg.TypeDanger,
} {
for _, str := range msg.Get(r.Context, typ) {
g = append(g, Notification(typ, str))
}
}
return g
}
func Notification(typ msg.Type, text string) Node {
var class string
switch typ {
case msg.TypeSuccess:
class = "success"
case msg.TypeInfo:
class = "info"
case msg.TypeWarning:
class = "warning"
case msg.TypeDanger:
class = "danger"
}
return Div(
Class("notification is-"+class),
Attr("x-data", "{show: true}"),
Attr("x-show", "show"),
Button(
Class("delete"),
Attr("@click", "show = false"),
),
Text(text),
)
}
func Message(class, header string, body Node) Node {
return Article(
Class("message "+class),
If(header != "", Div(
Class("message-header"),
P(Text(header)),
)),
Div(
Class("message-body"),
body,
),
)
}

203
pkg/ui/components/form.go Normal file
View file

@ -0,0 +1,203 @@
package components
import (
"github.com/mikestefanello/pagoda/pkg/form"
"github.com/mikestefanello/pagoda/pkg/ui"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type (
InputFieldParams struct {
Form form.Form
FormField string
Name string
InputType string
Label string
Value string
Placeholder string
Help string
}
RadiosParams struct {
Form form.Form
FormField string
Name string
Label string
Value string
Options []Radio
}
Radio struct {
Value string
Label string
}
TextareaFieldParams struct {
Form form.Form
FormField string
Name string
Label string
Value string
Help string
}
)
func ControlGroup(controls ...Node) Node {
g := make(Group, len(controls))
for i, control := range controls {
g[i] = Div(
Class("control"),
control,
)
}
return Div(
Class("field is-grouped"),
g,
)
}
func TextareaField(el TextareaFieldParams) Node {
return Div(
Class("field"),
Label(
For("name"),
Class("label"),
Text(el.Label),
),
Div(
Class("control"),
Textarea(
ID(el.Name),
Name(el.Name),
Class("textarea "+formFieldStatusClass(el.Form, el.FormField)),
Text(el.Value),
),
),
If(el.Help != "", P(Class("help"), Text(el.Help))),
formFieldErrors(el.Form, el.FormField),
)
}
func Radios(el RadiosParams) Node {
buttons := make(Group, len(el.Options))
for i, opt := range el.Options {
buttons[i] = Label(
Class("radio"),
Input(
Type("radio"),
Name(el.Name),
Value(opt.Value),
If(el.Value == opt.Value, Checked()),
),
Text(" "+opt.Label),
)
}
return Div(
Class("control field"),
Label(Class("label"), Text(el.Label)),
Div(
Class("radios"),
buttons,
),
formFieldErrors(el.Form, el.FormField),
)
}
func InputField(el InputFieldParams) Node {
return Div(
Class("field"),
Label(
Class("label"),
For(el.Name),
Text(el.Label),
),
Div(
Class("control"),
Input(
ID(el.Name),
Name(el.Name),
Type(el.InputType),
If(el.Placeholder != "", Placeholder(el.Placeholder)),
Class("input "+formFieldStatusClass(el.Form, el.FormField)),
Value(el.Value),
),
),
If(el.Help != "", P(Class("help"), Text(el.Help))),
formFieldErrors(el.Form, el.FormField),
)
}
func FileField(name, label string) Node {
return Div(
Class("field file"),
Label(
Class("file-label"),
Input(
Class("file-input"),
Type("file"),
Name(name),
),
Span(
Class("file-cta"),
Span(
Class("file-label"),
Text(label),
),
),
),
)
}
func formFieldStatusClass(fm form.Form, formField string) string {
switch {
case !fm.IsSubmitted():
return ""
case fm.FieldHasErrors(formField):
return "is-danger"
default:
return "is-success"
}
}
func formFieldErrors(fm form.Form, field string) Node {
errs := fm.GetFieldErrors(field)
if len(errs) == 0 {
return nil
}
g := make(Group, len(errs))
for i, err := range errs {
g[i] = P(
Class("help is-danger"),
Text(err),
)
}
return g
}
func CSRF(r *ui.Request) Node {
return Input(
Type("hidden"),
Name("csrf"),
Value(r.CSRF),
)
}
func FormButton(class, label string) Node {
return Button(
Class("button "+class),
Text(label),
)
}
func ButtonLink(href, class, label string) Node {
return A(
Href(href),
Class("button "+class),
Text(label),
)
}

60
pkg/ui/components/head.go Normal file
View file

@ -0,0 +1,60 @@
package components
import (
"fmt"
"strings"
"github.com/mikestefanello/pagoda/pkg/ui"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func JS(r *ui.Request) Node {
const htmxErr = `
document.body.addEventListener('htmx:beforeSwap', function(evt) {
if (evt.detail.xhr.status >= 400){
evt.detail.shouldSwap = true;
evt.detail.target = htmx.find("body");
}
});
`
const htmxCSRF = `
document.body.addEventListener('htmx:configRequest', function(evt) {
if (evt.detail.verb !== "get") {
evt.detail.parameters['csrf'] = '%s';
}
})
`
var csrf Node
if len(r.CSRF) > 0 {
csrf = Script(Raw(fmt.Sprintf(htmxCSRF, r.CSRF)))
}
return Group{
Script(Src("https://unpkg.com/htmx.org@2.0.0/dist/htmx.min.js")),
Script(Src("https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"), Defer()),
Script(Raw(htmxErr)),
csrf,
}
}
func CSS() Node {
return Link(
Href("https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css"),
Rel("stylesheet"),
)
}
func Metatags(r *ui.Request) Node {
return Group{
Meta(Charset("utf-8")),
Meta(Name("viewport"), Content("width=device-width, initial-scale=1")),
Link(Rel("icon"), Href(ui.File("favicon.png"))),
TitleEl(Text(r.Config.App.Name), If(r.Title != "", Text(" | "+r.Title))),
If(r.Metatags.Description != "", Meta(Name("description"), Content(r.Metatags.Description))),
If(len(r.Metatags.Keywords) > 0, Meta(Name("keywords"), Content(strings.Join(r.Metatags.Keywords, ", ")))),
}
}

View file

@ -0,0 +1,9 @@
package components
import (
. "maragu.dev/gomponents"
)
func HxBoost() Node {
return Attr("hx-boost", "true")
}

19
pkg/ui/components/nav.go Normal file
View file

@ -0,0 +1,19 @@
package components
import (
"github.com/mikestefanello/pagoda/pkg/ui"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func MenuLink(r *ui.Request, title, routeName string, routeParams ...any) Node {
href := r.Path(routeName, routeParams...)
return Li(
A(
Href(href),
Text(title),
If(href == r.CurrentPath, Class("is-active")),
),
)
}

56
pkg/ui/components/tabs.go Normal file
View file

@ -0,0 +1,56 @@
package components
import (
"fmt"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type Tab struct {
Title, Body string
}
func Tabs(heading, description string, items []Tab) Node {
renderTitles := func() Node {
g := make(Group, len(items))
for i, item := range items {
g[i] = Li(
Attr(":class", fmt.Sprintf("{'is-active': tab === %d}", i)),
Attr("@click", fmt.Sprintf("tab = %d", i)),
A(Text(item.Title)),
)
}
return g
}
renderBodies := func() Node {
g := make(Group, len(items))
for i, item := range items {
g[i] = Div(
Attr("x-show", fmt.Sprintf("tab == %d", i)),
P(Raw(" "+item.Body)),
)
}
return g
}
return Div(
P(
Class("subtitle mt-5"),
Text(heading),
),
P(
Class("mb-4"),
Text(description),
),
Div(
Attr("x-data", "{tab: 0}"),
Div(
Class("tabs"),
Ul(renderTitles()),
),
renderBodies(),
),
)
}

22
pkg/ui/emails/auth.go Normal file
View file

@ -0,0 +1,22 @@
package emails
import (
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/ui"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func ConfirmEmailAddress(ctx echo.Context, username, token string) Node {
url := ui.NewRequest(ctx).
Url(routenames.VerifyEmail, token)
return Group{
Strong(Textf("Hello %s,", username)),
Br(),
P(Text("Please click on the following link to confirm your email address:")),
Br(),
A(Href(url), Text(url)),
}
}

53
pkg/ui/forms/cache.go Normal file
View file

@ -0,0 +1,53 @@
package forms
import (
"net/http"
"github.com/mikestefanello/pagoda/pkg/form"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/ui"
. "github.com/mikestefanello/pagoda/pkg/ui/components"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type Cache struct {
CurrentValue string
Value string `form:"value"`
form.Submission
}
func (f *Cache) Render(r *ui.Request) Node {
return Form(
ID("cache"),
Method(http.MethodPost),
Attr("hx-post", r.Path(routenames.CacheSubmit)),
Message(
"is-info",
"Test the cache",
Group{
P(Text("This route handler shows how the default in-memory cache works. Try updating the value using the form below and see how it persists after you reload the page.")),
P(Text("HTMX makes it easy to re-render the cached value after the form is submitted.")),
},
),
Label(
For("value"),
Class("value"),
Text("Value in cache: "),
),
If(f.CurrentValue != "", Span(Class("tag is-success"), Text(f.CurrentValue))),
If(f.CurrentValue == "", I(Text("(empty)"))),
InputField(InputFieldParams{
Form: f,
FormField: "Value",
Name: "value",
InputType: "text",
Label: "Value",
Value: f.Value,
}),
ControlGroup(
FormButton("is-link", "Update cache"),
),
CSRF(r),
)
}

58
pkg/ui/forms/contact.go Normal file
View file

@ -0,0 +1,58 @@
package forms
import (
"net/http"
"github.com/mikestefanello/pagoda/pkg/form"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/ui"
. "github.com/mikestefanello/pagoda/pkg/ui/components"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type Contact struct {
Email string `form:"email" validate:"required,email"`
Department string `form:"department" validate:"required,oneof=sales marketing hr"`
Message string `form:"message" validate:"required"`
form.Submission
}
func (f *Contact) Render(r *ui.Request) Node {
return Form(
ID("contact"),
Method(http.MethodPost),
Attr("hx-post", r.Path(routenames.ContactSubmit)),
InputField(InputFieldParams{
Form: f,
FormField: "Email",
Name: "email",
InputType: "email",
Label: "Email address",
Value: f.Email,
}),
Radios(RadiosParams{
Form: f,
FormField: "Department",
Name: "department",
Label: "Department",
Value: f.Department,
Options: []Radio{
{Value: "sales", Label: "Sales"},
{Value: "marketing", Label: "Marketing"},
{Value: "hr", Label: "HR"},
},
}),
TextareaField(TextareaFieldParams{
Form: f,
FormField: "Message",
Name: "message",
Label: "Message",
Value: f.Message,
}),
ControlGroup(
FormButton("is-link", "Submit"),
),
CSRF(r),
)
}

27
pkg/ui/forms/file.go Normal file
View file

@ -0,0 +1,27 @@
package forms
import (
"net/http"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/ui"
. "github.com/mikestefanello/pagoda/pkg/ui/components"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type File struct{}
func (f File) Render(r *ui.Request) Node {
return Form(
ID("files"),
Method(http.MethodPost),
Action(r.Path(routenames.FilesSubmit)),
EncType("multipart/form-data"),
FileField("file", "Choose a file.. "),
ControlGroup(
FormButton("is-link", "Upload"),
),
CSRF(r),
)
}

View file

@ -0,0 +1,39 @@
package forms
import (
"net/http"
"github.com/mikestefanello/pagoda/pkg/form"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/ui"
. "github.com/mikestefanello/pagoda/pkg/ui/components"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type ForgotPassword struct {
Email string `form:"email" validate:"required,email"`
form.Submission
}
func (f *ForgotPassword) Render(r *ui.Request) Node {
return Form(
ID("forgot-password"),
Method(http.MethodPost),
HxBoost(),
Action(r.Path(routenames.ForgotPasswordSubmit)),
InputField(InputFieldParams{
Form: f,
FormField: "Email",
Name: "email",
InputType: "email",
Label: "Email address",
Value: f.Email,
}),
ControlGroup(
FormButton("is-primary", "Reset password"),
ButtonLink(r.Path(routenames.Home), "is-light", "Cancel"),
),
CSRF(r),
)
}

49
pkg/ui/forms/login.go Normal file
View file

@ -0,0 +1,49 @@
package forms
import (
"net/http"
"github.com/mikestefanello/pagoda/pkg/form"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/ui"
. "github.com/mikestefanello/pagoda/pkg/ui/components"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type Login struct {
Email string `form:"email" validate:"required,email"`
Password string `form:"password" validate:"required"`
form.Submission
}
func (f *Login) Render(r *ui.Request) Node {
return Form(
ID("login"),
Method(http.MethodPost),
HxBoost(),
Action(r.Path(routenames.LoginSubmit)),
FlashMessages(r),
InputField(InputFieldParams{
Form: f,
FormField: "Email",
Name: "email",
InputType: "email",
Label: "Email address",
Value: f.Email,
}),
InputField(InputFieldParams{
Form: f,
FormField: "Password",
Name: "password",
InputType: "password",
Label: "Password",
Placeholder: "******",
}),
ControlGroup(
FormButton("is-link", "Login"),
ButtonLink(r.Path(routenames.Home), "is-light", "Cancel"),
),
CSRF(r),
)
}

66
pkg/ui/forms/register.go Normal file
View file

@ -0,0 +1,66 @@
package forms
import (
"net/http"
"github.com/mikestefanello/pagoda/pkg/form"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/ui"
. "github.com/mikestefanello/pagoda/pkg/ui/components"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type Register struct {
Name string `form:"name" validate:"required"`
Email string `form:"email" validate:"required,email"`
Password string `form:"password" validate:"required"`
ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"`
form.Submission
}
func (f *Register) Render(r *ui.Request) Node {
return Form(
ID("register"),
Method(http.MethodPost),
HxBoost(),
Action(r.Path(routenames.RegisterSubmit)),
InputField(InputFieldParams{
Form: f,
FormField: "Name",
Name: "name",
InputType: "text",
Label: "Name",
Value: f.Name,
}),
InputField(InputFieldParams{
Form: f,
FormField: "Email",
Name: "email",
InputType: "email",
Label: "Email address",
Value: f.Email,
}),
InputField(InputFieldParams{
Form: f,
FormField: "Password",
Name: "password",
InputType: "password",
Label: "Password",
Placeholder: "******",
}),
InputField(InputFieldParams{
Form: f,
FormField: "PasswordConfirm",
Name: "password-confirm",
InputType: "password",
Label: "Confirm password",
Placeholder: "******",
}),
ControlGroup(
FormButton("is-primary", "Register"),
ButtonLink(r.Path(routenames.Home), "is-light", "Cancel"),
),
CSRF(r),
)
}

View file

@ -0,0 +1,46 @@
package forms
import (
"net/http"
"github.com/mikestefanello/pagoda/pkg/form"
"github.com/mikestefanello/pagoda/pkg/ui"
. "github.com/mikestefanello/pagoda/pkg/ui/components"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type ResetPassword struct {
Password string `form:"password" validate:"required"`
ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"`
form.Submission
}
func (f *ResetPassword) Render(r *ui.Request) Node {
return Form(
ID("reset-password"),
Method(http.MethodPost),
HxBoost(),
Action(r.CurrentPath),
InputField(InputFieldParams{
Form: f,
FormField: "Password",
Name: "password",
InputType: "password",
Label: "Password",
Placeholder: "******",
}),
InputField(InputFieldParams{
Form: f,
FormField: "PasswordConfirm",
Name: "password-confirm",
InputType: "password",
Label: "Confirm password",
Placeholder: "******",
}),
ControlGroup(
FormButton("is-primary", "Update password"),
),
CSRF(r),
)
}

49
pkg/ui/forms/task.go Normal file
View file

@ -0,0 +1,49 @@
package forms
import (
"fmt"
"net/http"
"github.com/mikestefanello/pagoda/pkg/form"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/ui"
. "github.com/mikestefanello/pagoda/pkg/ui/components"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type Task struct {
Delay int `form:"delay" validate:"gte=0"`
Message string `form:"message" validate:"required"`
form.Submission
}
func (f *Task) Render(r *ui.Request) Node {
return Form(
ID("task"),
Method(http.MethodPost),
Attr("hx-post", r.Path(routenames.TaskSubmit)),
FlashMessages(r),
InputField(InputFieldParams{
Form: f,
FormField: "Delay",
Name: "delay",
InputType: "number",
Label: "Delay (in seconds)",
Help: "How long to wait until the task is executed",
Value: fmt.Sprint(f.Delay),
}),
TextareaField(TextareaFieldParams{
Form: f,
FormField: "Message",
Name: "message",
Label: "Message",
Value: f.Message,
Help: "The message the task will output to the log",
}),
ControlGroup(
FormButton("is-link", "Add task to queue"),
),
CSRF(r),
)
}

64
pkg/ui/layouts/auth.go Normal file
View file

@ -0,0 +1,64 @@
package layouts
import (
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/ui"
"github.com/mikestefanello/pagoda/pkg/ui/cache"
. "github.com/mikestefanello/pagoda/pkg/ui/components"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func Auth(r *ui.Request, content Node) Node {
return Doctype(
HTML(
Lang("en"),
Head(
Metatags(r),
CSS(),
JS(r),
),
Body(
Section(
Class("hero is-fullheight"),
Div(
Class("hero-body"),
Div(
Class("container"),
Div(
Class("columns is-centered"),
Div(
Class("column is-half"),
If(len(r.Title) > 0, H1(Class("title"), Text(r.Title))),
Div(
Class("notification"),
FlashMessages(r),
content,
authNavBar(r),
),
),
),
),
),
),
),
),
)
}
func authNavBar(r *ui.Request) Node {
return cache.SetIfNotExists("authNavBar", func() Node {
return Nav(
Class("navbar"),
Div(
Class("navbar-menu"),
Div(
Class("navbar-start"),
A(Class("navbar-item"), Href(r.Path(routenames.Login)), Text("Login")),
A(Class("navbar-item"), Href(r.Path(routenames.Register)), Text("Create an account")),
A(Class("navbar-item"), Href(r.Path(routenames.ForgotPassword)), Text("Forgot password")),
),
),
)
})
}

158
pkg/ui/layouts/primary.go Normal file
View file

@ -0,0 +1,158 @@
package layouts
import (
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/ui"
"github.com/mikestefanello/pagoda/pkg/ui/cache"
. "github.com/mikestefanello/pagoda/pkg/ui/components"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func Primary(r *ui.Request, content Node) Node {
return Doctype(
HTML(
Lang("en"),
Head(
Metatags(r),
CSS(),
JS(r),
),
Body(
headerNavBar(r),
Div(
Class("container mt-5"),
Div(
Class("columns"),
Div(
Class("column is-2"),
sidebarMenu(r),
),
Div(
Class("column is-10"),
If(len(r.Title) > 0, H1(Class("title"), Text(r.Title))),
FlashMessages(r),
content,
),
),
),
),
),
)
}
func headerNavBar(r *ui.Request) Node {
return cache.SetIfNotExists("layout.headerNavBar", func() Node {
return Nav(
Class("navbar is-dark"),
Div(
Class("container"),
Div(
Class("navbar-brand"),
HxBoost(),
A(
Href(r.Path(routenames.Home)),
Class("navbar-item"),
Text("Pagoda"),
),
),
Div(
ID("navbarMenu"),
Class("navbar-menu"),
Div(
Class("navbar-end"),
search(r),
),
),
),
)
})
}
func search(r *ui.Request) Node {
return cache.SetIfNotExists("layout.search", func() Node {
return Div(
Class("search mr-2 mt-1"),
Attr("x-data", "{modal:false}"),
Input(
Class("input"),
Type("search"),
Placeholder("Search..."),
Attr("@click", "modal = true; $nextTick(() => $refs.input.focus());"),
),
Div(
Class("modal"),
Attr(":class", "modal ? 'is-active' : ''"),
Attr("x-show", "modal == true"),
Div(
Class("modal-background"),
),
Div(
Class("modal-content"),
Attr("@click.outside", "modal = false;"),
Div(
Class("box"),
H2(
Class("subtitle"),
Text("Search"),
),
P(
Class("control"),
Input(
Attr("hx-get", r.Path(routenames.Search)),
Attr("hx-trigger", "keyup changed delay:500ms"),
Attr("hx-target", "#results"),
Name("query"),
Class("input"),
Type("search"),
Placeholder("Search..."),
Attr("x-ref", "input"),
),
),
Div(
Class("block"),
),
Div(
ID("results"),
),
),
),
Button(
Class("modal-close is-large"),
Aria("label", "close"),
),
),
)
})
}
func sidebarMenu(r *ui.Request) Node {
return Aside(
Class("menu"),
HxBoost(),
P(
Class("menu-label"),
Text("General"),
),
Ul(
Class("menu-list"),
MenuLink(r, "Dashboard", routenames.Home),
MenuLink(r, "About", routenames.About),
MenuLink(r, "Contact", routenames.Contact),
MenuLink(r, "Cache", routenames.Cache),
MenuLink(r, "Task", routenames.Task),
MenuLink(r, "Files", routenames.Files),
),
P(
Class("menu-label"),
Text("Account"),
),
Ul(
Class("menu-list"),
If(r.IsAuth, MenuLink(r, "Logout", routenames.Logout)),
If(!r.IsAuth, MenuLink(r, "Login", routenames.Login)),
If(!r.IsAuth, MenuLink(r, "Register", routenames.Register)),
If(!r.IsAuth, MenuLink(r, "Forgot password", routenames.ForgotPasswordSubmit)),
),
)
}

22
pkg/ui/models/file.go Normal file
View file

@ -0,0 +1,22 @@
package models
import (
"fmt"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type File struct {
Name string
Size int64
Modified string
}
func (f *File) Render() Node {
return Tr(
Td(Text(f.Name)),
Td(Text(fmt.Sprint(f.Size))),
Td(Text(f.Modified)),
)
}

85
pkg/ui/models/post.go Normal file
View file

@ -0,0 +1,85 @@
package models
import (
"fmt"
"github.com/mikestefanello/pagoda/pkg/pager"
"github.com/mikestefanello/pagoda/pkg/ui"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type (
Posts struct {
Posts []Post
Pager pager.Pager
}
Post struct {
Title, Body string
}
)
func (p *Posts) Render(path string) Node {
g := make(Group, len(p.Posts))
for i, post := range p.Posts {
g[i] = post.Render()
}
return Div(
ID("posts"),
g,
Div(
Class("field is-grouped is-grouped-centered"),
If(!p.Pager.IsBeginning(), P(
Class("control"),
Button(
Class("button is-primary"),
Attr("hx-swap", "outerHTML"),
Attr("hx-get", fmt.Sprintf("%s?%s=%d", path, pager.QueryKey, p.Pager.Page-1)),
Attr("hx-target", "#posts"),
Text("Previous page"),
),
)),
If(!p.Pager.IsEnd(), P(
Class("control"),
Button(
Class("button is-primary"),
Attr("hx-swap", "outerHTML"),
Attr("hx-get", fmt.Sprintf("%s?%s=%d", path, pager.QueryKey, p.Pager.Page+1)),
Attr("hx-target", "#posts"),
Text("Next page"),
),
)),
),
)
}
func (p *Post) Render() Node {
return Article(
Class("media"),
Figure(
Class("media-left"),
P(
Class("image is-64x64"),
Img(
Src(ui.File("gopher.png")),
Alt("Gopher"),
),
),
),
Div(
Class("media-content"),
Div(
Class("content"),
P(
Strong(
Text(p.Title),
),
Br(),
Text(p.Body),
),
),
),
)
}

View file

@ -0,0 +1,19 @@
package models
import (
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type SearchResult struct {
Title string
URL string
}
func (s *SearchResult) Render() Node {
return A(
Class("panel-block"),
Href(s.URL),
Text(s.Title),
)
}

58
pkg/ui/pages/about.go Normal file
View file

@ -0,0 +1,58 @@
package pages
import (
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/ui"
"github.com/mikestefanello/pagoda/pkg/ui/cache"
. "github.com/mikestefanello/pagoda/pkg/ui/components"
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func About(ctx echo.Context) error {
r := ui.NewRequest(ctx)
r.Title = "About"
r.Metatags.Description = "Learn a little about what's included in Pagoda."
// The tabs are static so we can render and cache them.
tabs := cache.SetIfNotExists("pages.about.Tabs", func() Node {
return Group{
Tabs(
"Frontend",
"The following incredible projects make developing advanced, modern frontends possible and simple without having to write a single line of JS or CSS. You can go extremely far without leaving the comfort of Go with server-side rendered HTML.",
[]Tab{
{
Title: "HTMX",
Body: "Completes HTML as a hypertext by providing attributes to AJAXify anything and much more. Visit <a href=\"https://htmx.org/\">htmx.org</a> to learn more.",
},
{
Title: "Alpine.js",
Body: "Drop-in, Vue-like functionality written directly in your markup. Visit <a href=\"https://alpinejs.dev/\">alpinejs.dev</a> to learn more.",
},
{
Title: "Bulma",
Body: "Ready-to-use frontend components that you can easily combine to build responsive web interfaces with no JavaScript requirements. Visit <a href=\"https://bulma.io/\">bulma.io</a> to learn more.",
},
},
),
Div(Class("mb-4")),
Tabs(
"Backend",
"The following incredible projects provide the foundation of the Go backend. See the repository for a complete list of included projects.",
[]Tab{
{
Title: "Echo",
Body: "High performance, extensible, minimalist Go web framework. Visit <a href=\"https://echo.labstack.com/\">echo.labstack.com</a> to learn more.",
},
{
Title: "Ent",
Body: "Simple, yet powerful ORM for modeling and querying data. Visit <a href=\"https://entgo.io/\">entgo.io</a> to learn more.",
},
},
),
}
})
return r.Render(layouts.Primary, tabs)
}

46
pkg/ui/pages/auth.go Normal file
View file

@ -0,0 +1,46 @@
package pages
import (
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/ui"
"github.com/mikestefanello/pagoda/pkg/ui/forms"
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func Login(ctx echo.Context, form *forms.Login) error {
r := ui.NewRequest(ctx)
r.Title = "Login"
return r.Render(layouts.Auth, form.Render(r))
}
func Register(ctx echo.Context, form *forms.Register) error {
r := ui.NewRequest(ctx)
r.Title = "Register"
return r.Render(layouts.Auth, form.Render(r))
}
func ForgotPassword(ctx echo.Context, form *forms.ForgotPassword) error {
r := ui.NewRequest(ctx)
r.Title = "Forgot password"
g := Group{
Div(
Class("content"),
P(Text("Enter your email address and we'll email you a link that allows you to reset your password.")),
),
form.Render(r),
}
return r.Render(layouts.Auth, g)
}
func ResetPassword(ctx echo.Context, form *forms.ResetPassword) error {
r := ui.NewRequest(ctx)
r.Title = "Reset your password"
return r.Render(layouts.Auth, form.Render(r))
}

15
pkg/ui/pages/cache.go Normal file
View file

@ -0,0 +1,15 @@
package pages
import (
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/ui"
"github.com/mikestefanello/pagoda/pkg/ui/forms"
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
)
func UpdateCache(ctx echo.Context, form *forms.Cache) error {
r := ui.NewRequest(ctx)
r.Title = "Set a cache entry"
return r.Render(layouts.Primary, form.Render(r))
}

41
pkg/ui/pages/contact.go Normal file
View file

@ -0,0 +1,41 @@
package pages
import (
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/ui"
"github.com/mikestefanello/pagoda/pkg/ui/components"
"github.com/mikestefanello/pagoda/pkg/ui/forms"
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func ContactUs(ctx echo.Context, form *forms.Contact) error {
r := ui.NewRequest(ctx)
r.Title = "Contact us"
r.Metatags.Description = "Get in touch with us."
g := make(Group, 0)
if r.Htmx.Target != "contact" {
g = append(g, components.Message(
"is-link",
"",
Group{
P(Text("This is an example of a form with inline, server-side validation and HTMX-powered AJAX submissions without writing a single line of JavaScript.")),
P(Text("Only the form below will update async upon submission.")),
}))
}
if form.IsDone() {
g = append(g, components.Message(
"is-large is-success",
"Thank you!",
Text("No email was actually sent but this entire operation was handled server-side and degrades without JavaScript enabled."),
))
} else {
g = append(g, form.Render(r))
}
return r.Render(layouts.Primary, g)
}

38
pkg/ui/pages/error.go Normal file
View file

@ -0,0 +1,38 @@
package pages
import (
"net/http"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/ui"
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func Error(ctx echo.Context, code int) error {
r := ui.NewRequest(ctx)
r.Title = http.StatusText(code)
var body Node
switch code {
case http.StatusInternalServerError:
body = Text("Please try again.")
case http.StatusForbidden, http.StatusUnauthorized:
body = Text("You are not authorized to view the requested page.")
case http.StatusNotFound:
body = Group{
Text("Click "),
A(
Href(r.Path(routenames.Home)),
Text("here"),
),
Text(" to go return home."),
}
default:
body = Text("Something went wrong.")
}
return r.Render(layouts.Primary, P(body))
}

53
pkg/ui/pages/file.go Normal file
View file

@ -0,0 +1,53 @@
package pages
import (
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/ui"
. "github.com/mikestefanello/pagoda/pkg/ui/components"
"github.com/mikestefanello/pagoda/pkg/ui/forms"
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
"github.com/mikestefanello/pagoda/pkg/ui/models"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func UploadFile(ctx echo.Context, files []*models.File) error {
r := ui.NewRequest(ctx)
r.Title = "Upload a file"
fileList := make(Group, len(files))
for i, file := range files {
fileList[i] = file.Render()
}
n := Group{
Message(
"is-link",
"",
P(Text("This is a very basic example of how to handle file uploads. Files uploaded will be saved to the directory specified in your configuration.")),
),
Hr(),
forms.File{}.Render(r),
Hr(),
H3(
Class("title"),
Text("Uploaded files"),
),
Message("is-warning", "", P(Text("Below are all files in the configured upload directory."))),
Table(
Class("table"),
THead(
Tr(
Th(Text("Filename")),
Th(Text("Size")),
Th(Text("Modified on")),
),
),
TBody(
fileList,
),
),
}
return r.Render(layouts.Primary, n)
}

69
pkg/ui/pages/home.go Normal file
View file

@ -0,0 +1,69 @@
package pages
import (
"fmt"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/ui"
. "github.com/mikestefanello/pagoda/pkg/ui/components"
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
"github.com/mikestefanello/pagoda/pkg/ui/models"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func Home(ctx echo.Context, posts *models.Posts) error {
r := ui.NewRequest(ctx)
r.Metatags.Description = "This is the home page."
r.Metatags.Keywords = []string{"Software", "Coding", "Go"}
g := make(Group, 0)
if r.Htmx.Target != "posts" {
var hello string
if r.IsAuth {
hello = fmt.Sprintf("Hello, %s", r.AuthUser.Name)
} else {
hello = "Hello"
}
g = append(g,
Section(
Class("hero is-info welcome is-small mb-5"),
Div(
Class("hero-body"),
Div(
Class("container"),
H1(
Class("title"),
Text(hello),
),
H2(
Class("subtitle"),
If(!r.IsAuth, Text("Please login in to your account.")),
If(r.IsAuth, Text("Welcome back!")),
),
),
),
),
H2(Class("title"), Text("Recent posts")),
H3(Class("subtitle"), Text("Below is an example of both paging and AJAX fetching using HTMX")),
)
}
g = append(g, posts.Render(r.Path(routenames.Home)))
if r.Htmx.Target != "posts" {
g = append(g, Message(
"is-small is-warning mt-5",
"Serving files",
Group{
Text("In the example posts above, check how the file URL contains a cache-buster query parameter which changes only when the app is restarted. "),
Text("Static files also contain cache-control headers which are configured via middleware."),
},
))
}
return r.Render(layouts.Primary, g)
}

20
pkg/ui/pages/search.go Normal file
View file

@ -0,0 +1,20 @@
package pages
import (
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/ui"
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
"github.com/mikestefanello/pagoda/pkg/ui/models"
. "maragu.dev/gomponents"
)
func SearchResults(ctx echo.Context, results []*models.SearchResult) error {
r := ui.NewRequest(ctx)
g := make(Group, len(results))
for i, result := range results {
g[i] = result.Render()
}
return r.Render(layouts.Primary, g)
}

33
pkg/ui/pages/task.go Normal file
View file

@ -0,0 +1,33 @@
package pages
import (
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/ui"
"github.com/mikestefanello/pagoda/pkg/ui/components"
"github.com/mikestefanello/pagoda/pkg/ui/forms"
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func AddTask(ctx echo.Context, form *forms.Task) error {
r := ui.NewRequest(ctx)
r.Title = "Create a task"
r.Metatags.Description = "Test creating a task to see how it works."
g := make(Group, 0)
if r.Htmx.Target != "task" {
g = append(g, components.Message(
"is-link",
"",
Group{
P(Raw("Submitting this form will create an <i>ExampleTask</i> in the task queue. After the specified delay, the message will be logged by the queue processor.")),
P(Text("See pkg/tasks and the README for more information.")),
}))
}
g = append(g, form.Render(r))
return r.Render(layouts.Primary, g)
}

110
pkg/ui/request.go Normal file
View file

@ -0,0 +1,110 @@
package ui
import (
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/config"
"github.com/mikestefanello/pagoda/ent"
"github.com/mikestefanello/pagoda/pkg/context"
"github.com/mikestefanello/pagoda/pkg/htmx"
"maragu.dev/gomponents"
)
type (
// Request encapsulates information about the incoming request in order to provide your ui with important and
// useful information needed for rendering.
Request struct {
// Title stores the title of the page.
Title string
// Context stores the request context.
Context echo.Context
// CurrentPath stores the path of the current request.
CurrentPath string
// IsHome stores whether the requested page is the home page.
IsHome bool
// IsAuth stores whether the user is authenticated.
IsAuth bool
// AuthUser stores the authenticated user.
AuthUser *ent.User
// Metatags stores metatag values.
Metatags struct {
// Description stores the description metatag value.
Description string
// Keywords stores the keywords metatag values.
Keywords []string
}
// CSRF stores the CSRF token for the given request.
// This will only be populated if the CSRF middleware is in effect for the given request.
// If this is populated, all forms must include this value otherwise the requests will be rejected.
CSRF string
// Htmx stores information provided by HTMX about this request.
Htmx *htmx.Request
// Config stores the application configuration.
// This will only be populated if the Config middleware is installed in the router.
Config *config.Config
}
// LayoutFunc is a callback function intended to render your page node within a given layout.
// This is handled as a callback in order to automatically support HTMX requests so that you can respond
// with only the page content and not the entire layout.
// See Request.Render().
LayoutFunc func(*Request, gomponents.Node) gomponents.Node
)
// NewRequest generates a new Request using the Echo context of a given HTTP request.
func NewRequest(ctx echo.Context) *Request {
p := &Request{
Context: ctx,
CurrentPath: ctx.Request().URL.Path,
Htmx: htmx.GetRequest(ctx),
}
p.IsHome = p.CurrentPath == "/"
if csrf := ctx.Get(context.CSRFKey); csrf != nil {
p.CSRF = csrf.(string)
}
if u := ctx.Get(context.AuthenticatedUserKey); u != nil {
p.IsAuth = true
p.AuthUser = u.(*ent.User)
}
if cfg := ctx.Get(context.ConfigKey); cfg != nil {
p.Config = cfg.(*config.Config)
}
return p
}
// Path generates a URL path for a given route name and optional route parameters.
// This will only work if you've supplied names for each of your routes. It's optional to use and helps avoids
// having duplicate, hard-coded paths and parameters all over your application.
func (r *Request) Path(routeName string, routeParams ...any) string {
return r.Context.Echo().Reverse(routeName, routeParams...)
}
// Url generates an absolute URL for a given route name and optional route parameters.
func (r *Request) Url(routeName string, routeParams ...any) string {
return r.Config.App.Host + r.Path(routeName, routeParams...)
}
// Render renders a given node, optionally within a given layout based on the HTMX request headers.
// If the request is being made by HTMX and is not boosted, this will automatically only render the node without
// the layout, to support partial rendering.
func (r *Request) Render(layout LayoutFunc, node gomponents.Node) error {
if r.Htmx.Enabled && !r.Htmx.Boosted {
return node.Render(r.Context.Response().Writer)
}
return layout(r, node).Render(r.Context.Response().Writer)
}

93
pkg/ui/request_test.go Normal file
View file

@ -0,0 +1,93 @@
package ui
import (
"testing"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/config"
"github.com/mikestefanello/pagoda/ent"
"github.com/mikestefanello/pagoda/pkg/context"
"github.com/mikestefanello/pagoda/pkg/htmx"
"github.com/mikestefanello/pagoda/pkg/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"maragu.dev/gomponents"
"maragu.dev/gomponents/html"
)
func TestNewRequest(t *testing.T) {
e := echo.New()
ctx, _ := tests.NewContext(e, "/")
r := NewRequest(ctx)
assert.Same(t, ctx, r.Context)
assert.Equal(t, "/", r.CurrentPath)
assert.True(t, r.IsHome)
assert.False(t, r.IsAuth)
assert.Nil(t, r.AuthUser)
assert.Empty(t, r.CSRF)
assert.Nil(t, r.Config)
assert.Same(t, htmx.GetRequest(ctx), r.Htmx)
ctx, _ = tests.NewContext(e, "/abc")
usr := &ent.User{
ID: 1,
}
ctx.Set(context.AuthenticatedUserKey, usr)
ctx.Set(context.CSRFKey, "12345")
ctx.Set(context.ConfigKey, &config.Config{
App: config.AppConfig{
Name: "testing",
},
})
r = NewRequest(ctx)
assert.Equal(t, "/abc", r.CurrentPath)
assert.False(t, r.IsHome)
assert.True(t, r.IsAuth)
assert.Equal(t, usr, r.AuthUser)
assert.Equal(t, "12345", r.CSRF)
assert.Equal(t, "testing", r.Config.App.Name)
}
func TestRequest_UrlPath(t *testing.T) {
e := echo.New()
e.GET("/abc/:id", func(c echo.Context) error { return nil }).Name = "test"
ctx, _ := tests.NewContext(e, "/")
r := NewRequest(ctx)
r.Config = &config.Config{
App: config.AppConfig{
Host: "http://localhost",
},
}
assert.Equal(t, "http://localhost/abc/123", r.Url("test", 123))
assert.Equal(t, "/abc/123", r.Path("test", 123))
}
func TestRequest_Render(t *testing.T) {
e := echo.New()
layout := func(r *Request, n gomponents.Node) gomponents.Node {
return html.Div(html.Class("test"), n)
}
node := html.P(gomponents.Text("hello"))
t.Run("no htmx", func(t *testing.T) {
ctx, rec := tests.NewContext(e, "/")
r := NewRequest(ctx)
r.Htmx = &htmx.Request{}
err := r.Render(layout, node)
require.NoError(t, err)
assert.Equal(t, `<div class="test"><p>hello</p></div>`, rec.Body.String())
})
t.Run("htmx", func(t *testing.T) {
ctx, rec := tests.NewContext(e, "/")
r := NewRequest(ctx)
r.Htmx = &htmx.Request{
Enabled: true,
Boosted: false,
}
err := r.Render(layout, node)
require.NoError(t, err)
assert.Equal(t, `<p>hello</p>`, rec.Body.String())
})
}

18
pkg/ui/ui.go Normal file
View file

@ -0,0 +1,18 @@
package ui
import (
"fmt"
"time"
"github.com/mikestefanello/pagoda/config"
)
var (
// cacheBuster stores the current time as a cache buster for static files.
cacheBuster = fmt.Sprint(time.Now().Unix())
)
// File generates a relative URL to a static file including a cache-buster query parameter.
func File(filepath string) string {
return fmt.Sprintf("/%s/%s?v=%s", config.StaticPrefix, filepath, cacheBuster)
}

16
pkg/ui/ui_test.go Normal file
View file

@ -0,0 +1,16 @@
package ui
import (
"fmt"
"testing"
"github.com/mikestefanello/pagoda/config"
"github.com/stretchr/testify/assert"
)
func TestFile(t *testing.T) {
path := "abc.txt"
got := File(path)
expected := fmt.Sprintf("/%s/%s?v=%s", config.StaticPrefix, path, cacheBuster)
assert.Equal(t, expected, got)
}