diff --git a/.air.toml b/.air.toml
new file mode 100644
index 00000000..aa66c5e9
--- /dev/null
+++ b/.air.toml
@@ -0,0 +1,52 @@
+root = "."
+testdata_dir = "testdata"
+tmp_dir = "tmp"
+
+[build]
+ args_bin = []
+ bin = "./tmp/main"
+ cmd = "go build -o ./tmp/main ./cmd/web"
+ delay = 1000
+ exclude_dir = ["assets", "tmp", "vendor", "testdata", "uploads", "dbs"]
+ exclude_file = []
+ exclude_regex = ["_test.go"]
+ exclude_unchanged = false
+ follow_symlink = false
+ full_bin = ""
+ include_dir = []
+ include_ext = ["go", "tpl", "tmpl", "html"]
+ include_file = []
+ kill_delay = "0s"
+ log = "build-errors.log"
+ poll = false
+ poll_interval = 0
+ post_cmd = []
+ pre_cmd = []
+ rerun = false
+ rerun_delay = 500
+ send_interrupt = false
+ stop_on_error = true
+
+[color]
+ app = ""
+ build = "yellow"
+ main = "magenta"
+ runner = "green"
+ watcher = "cyan"
+
+[log]
+ main_only = false
+ silent = false
+ time = false
+
+[misc]
+ clean_on_exit = false
+
+[proxy]
+ app_port = 0
+ enabled = false
+ proxy_port = 0
+
+[screen]
+ clear_on_rebuild = false
+ keep_scroll = true
diff --git a/.gitignore b/.gitignore
index a8c4bda9..747fcd5a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
.idea
dbs
-uploads
\ No newline at end of file
+uploads
+tmp
\ No newline at end of file
diff --git a/Makefile b/Makefile
index 7434d38f..a40014c6 100644
--- a/Makefile
+++ b/Makefile
@@ -1,30 +1,37 @@
-# Install Ent code-generation module
+.PHONY: help
+help: ## Print make targets
+ @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
+
.PHONY: ent-install
-ent-install:
+ent-install: ## Install Ent code-generation module
go get entgo.io/ent/cmd/ent
-# Generate Ent code
+.PHONY: air-install
+air-install: ## Install air
+ go install github.com/air-verse/air@latest
+
.PHONY: ent-gen
-ent-gen:
+ent-gen: ## Generate Ent code
go generate ./ent
-# Create a new Ent entity
.PHONY: ent-new
-ent-new:
+ent-new: ## Create a new Ent entity (ie, make ent-new NAME=MyEntity)
go run entgo.io/ent/cmd/ent new $(name)
-# Run the application
.PHONY: run
-run:
+run: ## Run the application
clear
go run cmd/web/main.go
-# Run all tests
+.PHONY: watch
+watch: ## Run the application and watch for changes with air to automatically rebuild
+ clear
+ air
+
.PHONY: test
-test:
- go test -count=1 -p 1 ./...
+test: ## Run all tests
+ go test ./...
-# Check for direct dependency updates
.PHONY: check-updates
-check-updates:
+check-updates: ## Check for direct dependency updates
go list -u -m -f '{{if not .Indirect}}{{.}}{{end}}' all | grep "\["
diff --git a/README.md b/README.md
index c2f0813d..94362e4f 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@
* [Getting started](#getting-started)
* [Dependencies](#dependencies)
* [Start the application](#start-the-application)
- * [Running tests](#running-tests)
+ * [Live reloading](#live-reloading)
* [Service container](#service-container)
* [Dependency injection](#dependency-injection)
* [Test dependencies](#test-dependencies)
@@ -46,34 +46,32 @@
* [Custom middleware](#custom-middleware)
* [Handlers](#handlers)
* [Errors](#errors)
+ * [Redirects](#redirects)
* [Testing](#testing)
* [HTTP server](#http-server)
* [Request / Request helpers](#request--response-helpers)
* [Goquery](#goquery)
-* [Pages](#pages)
- * [Flash messaging](#flash-messaging)
- * [Pager](#pager)
- * [CSRF](#csrf)
- * [Automatic template parsing](#automatic-template-parsing)
- * [Cached responses](#cached-responses)
- * [Cache tags](#cache-tags)
- * [Cache middleware](#cache-middleware)
- * [Data](#data)
+* [User interface](#user-interface)
+ * [Why Gomponents?](#why-gomponents)
+ * [HTMX support](#htmx-support)
+ * [Header management](#header-management)
+ * [Conditional and partial rendering](#conditional-and-partial-rendering)
+ * [CSRF token](#csrf-token)
+ * [Request](#request)
+ * [Title and metatags](#title-and-metatags)
+ * [URL generation](#url-generation)
+ * [Components](#components)
+ * [Layouts](#layouts)
+ * [Pages](#pages)
+ * [Rendering](#rendering)
* [Forms](#forms)
* [Submission processing](#submission-processing)
* [Inline validation](#inline-validation)
- * [Headers](#headers)
- * [Status code](#status-code)
- * [Metatags](#metatags)
- * [URL and link generation](#url-and-link-generation)
- * [HTMX support](#htmx-support)
- * [Rendering the page](#rendering-the-page)
-* [Template renderer](#template-renderer)
- * [Custom functions](#custom-functions)
- * [Caching](#caching)
- * [Hot-reload for development](#hot-reload-for-development)
- * [File configuration](#file-configuration)
-* [Funcmap](#funcmap)
+ * [CSRF](#csrf)
+ * [Models](#models)
+ * [Node caching](#node-caching)
+ * [Flash messaging](#flash-messaging)
+* [Pager](#pager)
* [Cache](#cache)
* [Set data](#set-data)
* [Get data](#get-data)
@@ -101,16 +99,17 @@ _Pagoda_ is not a framework but rather a base starter-kit for rapid, easy full-s
Built on a solid [foundation](#foundation) of well-established frameworks and modules, _Pagoda_ aims to be a starting point for any web application with the benefit over a mega-framework in that you have full control over all of the code, the ability to easily swap any frameworks or modules in or out, no strict patterns or interfaces to follow, and no fear of lock-in.
-While separate JavaScript frontends have surged in popularity, many prefer the reliability, simplicity and speed of a full-stack approach with server-side rendered HTML. Even the popular JS frameworks all have SSR options. This project aims to highlight that _Go_ templates can be powerful and easy to work with, and interesting [frontend](#frontend) libraries can provide the same modern functionality and behavior without having to write any JS at all.
+While separate JavaScript frontends have surged in popularity, many prefer the reliability, simplicity and speed of a full-stack approach with server-side rendered HTML. Even the popular JS frameworks all have SSR options. This project aims to highlight that _Go_ alone can be powerful and easy to work with as a full-stack solution, and interesting [frontend](#frontend) libraries can provide the same modern functionality and behavior without having to write any JS or CSS at all. In fact, you can even avoid writing HTML as well.
### Foundation
-While many great projects were used to build this, all of which are listed in the [credits](#credits) section, the following provide the foundation of the back and frontend. It's important to note that you are **not required to use any of these**. Swapping any of them out will be relatively easy.
+While many great projects were used to build this, all of which are listed in the [credits](#credits) section, the following provide the foundation of the back and frontend. It's important to note that you are **not required to use any of these**. Swapping any of them out will be relatively easy.
#### Backend
- [Echo](https://echo.labstack.com/): High performance, extensible, minimalist Go web framework.
- [Ent](https://entgo.io/): Simple, yet powerful ORM for modeling and querying data.
+- [Gomponents](https://github.com/maragudk/gomponents): HTML components written in pure Go. They render to HTML 5, and make it easy for you to build reusable components.
#### Frontend
@@ -158,31 +157,30 @@ make run
Since this repository is a _template_ and not a Go _library_, you **do not** use `go get`.
-By default, you should be able to access the application in your browser at `localhost:8000`. This can be changed via the [configuration](#configuration).
+By default, you should be able to access the application in your browser at `localhost:8000`. Your data will be stored within the `dbs` directory. If you ever want to quickly delete all data, just remove this directory.
-By default, your data will be stored within the `dbs` directory. If you ever want to quickly delete all data just remove this directory.
+These settings, and many others, can be changed via the [configuration](#configuration).
-### Running tests
+### Live reloading
-To run all tests in the application, execute `make test`. This ensures that the tests from each package are not run in parallel. This is required since many packages contain tests that connect to the test database which is stored in memory and reset automatically for each package.
+Rather than using `make run`, if you prefer live reloading so your app automatically rebuilds and runs whenever you save code changes, start by installing [air](https://github.com/air-verse/air) by running `make air-install`, then use `make watch` to start the application with automatic live reloading.
## Service container
The container is located at `pkg/services/container.go` and is meant to house all of your application's services and/or dependencies. It is easily extensible and can be created and initialized in a single call. The services currently included in the container are:
-- Configuration
+- Authentication
- Cache
+- Configuration
- Database
-- ORM
-- Web
-- Validator
-- Authentication
+- Files
- Mail
-- Template renderer
+- ORM
- Tasks
-- Files
+- Validator
+- Web
-A new container can be created and initialized via `services.NewContainer()`. It can be later shutdown via `Shutdown()`.
+A new container can be created and initialized via `services.NewContainer()`. It can be later shutdown via `Shutdown()`, which will attempt to gracefully shutdown all services.
### Dependency injection
@@ -196,7 +194,7 @@ It is common that your tests will require access to dependencies, like the datab
The `config` package provides a flexible, extensible way to store all configuration for the application. Configuration is added to the `Container` as a _Service_, making it accessible across most of the application.
-Be sure to review and adjust all of the default configuration values provided in `config/config.yaml`.
+Be sure to review and adjust all the default configuration values provided in `config/config.yaml`.
### Environment overrides
@@ -394,7 +392,6 @@ For this example, we'll create a new handler which includes a GET and POST route
```go
type Example struct {
orm *ent.Client
- *services.TemplateRenderer
}
```
@@ -410,7 +407,6 @@ func init() {
```go
func (e *Example) Init(c *services.Container) error {
- e.TemplateRenderer = c.TemplateRenderer
e.orm = c.ORM
return nil
}
@@ -418,12 +414,12 @@ func (e *Example) Init(c *services.Container) error {
4) Declare the routes
-**It is highly recommended** that you provide a `Name` for your routes. Most methods on the back and frontend leverage the route name and parameters in order to generate URLs.
+**It is highly recommended** that you provide a `Name` for your routes. Most methods on the back and frontend leverage the route name and parameters in order to generate URLs. All route names are currently stored as consts in the `routenames` package so they are accessible from within the `ui` layer.
```go
func (e *Example) Routes(g *echo.Group) {
- g.GET("/example", e.Page).Name = "example"
- g.POST("/example", c.PageSubmit).Name = "example.submit"
+ g.GET("/example", e.Page).Name = routenames.Example
+ g.POST("/example", c.PageSubmit).Name = routenames.ExampleSubmit
}
```
@@ -441,9 +437,9 @@ func (e *Example) PageSubmit(ctx echo.Context) error {
### Errors
-Routes can return errors to indicate that something wrong happened. Ideally, the error is of type `*echo.HTTPError` to indicate the intended HTTP response code. You can use `return echo.NewHTTPError(http.StatusInternalServerError)`, for example. If an error of a different type is returned, an _Internal Server Error_ is assumed.
+Routes can return errors to indicate that something wrong happened and an error page should be rendered for the request. Ideally, the error is of type `*echo.HTTPError` to indicate the intended HTTP response code, and optionally a message that will be logged. You can use `return echo.NewHTTPError(http.StatusInternalServerError, "optional message")`, for example. If an error of a different type is returned, an _Internal Server Error_ is assumed.
-The [error handler](https://echo.labstack.com/guide/error-handling/) is set to the provided `Handler` in `pkg/handlers/error.go` in the `BuildRouter()` function. That means that if any middleware or route return an error, the request gets routed there. This route conveniently constructs and renders a `Page` which uses the template `templates/pages/error.gohtml`. The status code is passed to the template so you can easily alter the markup depending on the error type.
+The [error handler](https://echo.labstack.com/guide/error-handling/) is set to the provided `Handler` in `pkg/handlers/error.go` in the `BuildRouter()` function. That means that if any middleware or route return an error, the request gets routed there. This route passes the status code to the `pages.Error` UI component page, allowing you to easily adjust the markup depending on the error type.
### Redirects
@@ -469,7 +465,7 @@ Only a brief example of route tests were provided in order to highlight what is
#### HTTP server
-When the route tests initialize, a new `Container` is created which provides full access to all of the _Services_ that will be available during normal application execution. Also provided is a test HTTP server with the router added. This means your tests can make requests and expect responses exactly as the application would behave outside of tests. You do not need to mock the requests and responses.
+When the route tests initialize, a new `Container` is created which provides full access to all the _Services_ that will be available during normal application execution. Also provided is a test HTTP server with the router added. This means your tests can make requests and expect responses exactly as the application would behave outside of tests. You do not need to mock the requests and responses.
#### Request / Response helpers
@@ -501,424 +497,329 @@ assert.Len(t, h1.Nodes, 1)
assert.Equal(t, "About", h1.Text())
```
-## Pages
-
-The `Page` is the major building block of your `Handler` responses. It is a _struct_ type located at `pkg/page/page.go`. The concept of the `Page` is that it provides a consistent structure for building responses and transmitting data and functionality to the templates. Pages are rendered with the `TemplateRenderer`.
-
-All example routes provided construct and _render_ a `Page`. It's recommended that you review both the `Page` and the example routes as they try to illustrate all included functionality.
+## User interface
-As you develop your application, the `Page` can be easily extended to include whatever data or functions you want to provide to your templates.
-
-Initializing a new page is simple:
-
-```go
-func (c *home) Get(ctx echo.Context) error {
- p := page.New(ctx)
-}
-```
+### Why Gomponents?
-Using the `echo.Context`, the `Page` will be initialized with the following fields populated:
+Originally, standard Go templates were chosen for this project and a lot of code was written to build tools to make using them as easy and flexible as possible. That code remains archived in [this branch](https://github.com/mikestefanello/pagoda/tree/templates) but is no longer maintained. Despite providing tools such as a powerful _template renderer_, which did things like automatically compile nested templates to separate layouts from pages, automatically include component templates, support HTMX partial rendering, provide _funcmap_ function helpers, and more, the end result left a lot to be desired. Templates provide no type-safety, child templates are difficult to call when you have multiple arguments, templates are not flexible enough to easily provide reusable components and elements, the _funcmap_ and form submission code often had to return HTML or CSS classes, and more.
-- `Context`: The passed in _context_
-- `Path`: The requested URL path
-- `URL`: The requested URL
-- `StatusCode`: Defaults to 200
-- `Pager`: Initialized `Pager` (see below)
-- `RequestID`: The request ID, if the middleware is being used
-- `IsHome`: If the request was for the homepage
-- `IsAuth`: If the user is authenticated
-- `AuthUser`: The logged in user entity, if one
-- `CSRF`: The CSRF token, if the middleware is being used
-- `HTMX.Request`: Data from the HTMX headers, if HTMX made the request (see below)
-
-### Flash messaging
-
-Flash messaging functionality is provided within the `msg` package. It is used to provide one-time status messages to users.
-
-Flash messaging requires that [sessions](#sessions) and the session middleware are in place since that is where the messages are stored.
-
-#### Creating messages
-
-There are four types of messages, and each can be created as follows:
-- Success: `msg.Success(ctx echo.Context, message string)`
-- Info: `msg.Info(ctx echo.Context, message string)`
-- Warning: `msg.Warning(ctx echo.Context, message string)`
-- Danger: `msg.Danger(ctx echo.Context, message string)`
-
-The _message_ string can contain HTML.
-
-#### Rendering messages
-
-When a flash message is retrieved from storage in order to be rendered, it is deleted from storage so that it cannot be rendered again.
+While I was extremely hesitant to adopt a rendering option outside the standard library, if an option exists that I personally feel is far superior, that is what I'm going to go with. [Templ](https://github.com/a-h/templ) was also a consideration as that project has made massive progress, seen an explosion in adoption, and aims to solve all the problems previously mentioned. I did not feel that it was a good fit for this project though as it requires you to know and understand their templating language, to install a CLI and an IDE plugin (which does not work with all IDEs; especially GoLand), and separately compile template code.
-The `Page` has a method that can be used to fetch messages for a given type from within the template: `Page.GetMessages(typ msg.Type)`. This is used rather than the _funcmap_ because the `Page` contains the request context which is required in order to access the session data. Since the `Page` is the data destined for the templates, you can use: `{{.GetMessages "success"}}` for example.
+[Gomponents](https://github.com/maragudk/gomponents) allows you to build HTML using nothing except pure, type-safe Go; whether that's entire documents or dynamic, reusable components. [Here](https://www.gomponents.com/) are some basic examples to give you an idea of how it works and [this tool](https://gomponents.morehart.dev/) is incredibly useful for quickly converting HTML to _gomponent_ Go code. When I first came across this library, I was very much against it, and couldn't imagine writing tons of nested function calls just to produce some HTML; especially for complex markup. But after actually spending some time using it to replicate the UI of this project, and feeling the downsides of Go templates, I quickly became a big fan and supporter of this approach. Between this and the chosen JS/CSS libraries, you can literally write your entire frontend without leaving Go.
-To make things easier, a template _component_ is already provided, located at `templates/components/messages.gohtml`. This will render all messages of all types simply by using `{{template "messages" .}}` either within your page or layout template.
+Before making any quick judgements of your own, I ask that you deeply consider what you've used in the past, review what previously existed in this project, and compare to the current solution and code presented here. I believe I've laid out the `ui` package in a way that makes building your frontend with _gomponents_ very easy and enjoyable.
-### Pager
+### HTMX support
-A very basic mechanism is provided to handle and facilitate paging located in `pkg/page/pager.go`. When a `Page` is initialized, so is a `Pager` at `Page.Pager`. If the requested URL contains a `page` query parameter with a numeric value, that will be set as the page number in the pager.
+[HTMX](https://htmx.org/) is an awesome JavaScript library allows you to access AJAX, CSS Transitions, WebSockets and Server Sent Events directly in HTML, using attributes, so you can build modern user interfaces with the simplicity and power of hypertext.
-During initialization, the _items per page_ amount will be set to the default, controlled via constant, which has a value of 20. It can be overridden by changing `Pager.ItemsPerPage` but should be done before other values are set in order to not provide incorrect calculations.
+Many examples of its usage are available in the included examples:
+- All navigation links use [boost](https://htmx.org/docs/#boosting) which dynamically replaces the page content with an AJAX request, providing a SPA-like experience.
+- All forms use either [boost](https://htmx.org/docs/#boosting) or [hx-post](https://htmx.org/docs/#triggers) to submit via AJAX.
+- The mock search autocomplete modal uses [hx-get](https://htmx.org/docs/#targets) to fetch search results from the server via AJAX and update the UI.
+- The mock posts on the homepage/dashboard use [hx-get](https://htmx.org/docs/#targets) to fetch and page posts via AJAX.
-Methods include:
+All of this can be easily accomplished without writing any JavaScript at all.
-- `SetItems(items int)`: Set the total amount of items in the entire result-set
-- `IsBeginning()`: Determine if the pager is at the beginning of the pages
-- `IsEnd()`: Determine if the pager is at the end of the pages
-- `GetOffset()`: Get the offset which can be useful is constructing a paged database query
+Another benefit of [HTMX](https://htmx.org/) is that it's completely backend-agnostic and does not require any special tools or integrations on the backend, though many things are provided here to make it simple.
-There is currently no template (yet) to easily render a pager.
+#### Header management
-### CSRF
+Included is an [htmx package](https://github.com/mikestefanello/pagoda/blob/main/pkg/htmx/htmx.go) to read and write [HTTP headers](https://htmx.org/docs/#requests) that HTMX uses to communicate additional information and commands for both the request and response. This allows you, for example, to determine if HTMX is making the given request and what exactly it is doing, which could be useful both in your _route_ and your _ui_.
-By default, all non GET requests will require a CSRF token be provided as a form value. This is provided by middleware and can be adjusted or removed in the router.
+From within your _route_, you can fetch HTMX request details by calling `htmx.GetRequest(ctx)`, and you can send commands back to HTMX by calling `htmx.Response{...}.Apply(ctx)`, and populating any fields on the `htmx.Response` struct.
-The `Page` will contain the CSRF token for the given request. There is a CSRF helper component template which can be used to easily render a hidden form element in your form which will contain the CSRF token and the proper element name. Simply include `{{template "csrf" .}}` within your form.
+From within your _ui_, the [Request](#request) object will automatically contain the request details on the `Htmx` field.
-### Automatic template parsing
+#### Conditional and partial rendering
-Dealing with templates can be quite tedious and annoying so the `Page` aims to make it as simple as possible with the help of the [template renderer](#template-renderer). To start, templates for _pages_ are grouped in the following directories within the `templates` directory:
+Since HTMX communicates what it is doing with the server, you can use the request headers to conditionally process in your _route_ or render in your _ui_, if needed.
-- `layouts`: Base templates that provide the entire HTML wrapper/layout. This template should include a call to `{{template "content" .}}` to render the content of the `Page`.
-- `pages`: Templates that are specific for a given route/page. These must contain `{{define "content"}}{{end}}` which will be injected in to the _layout_ template.
-- `components`: A shared library of common components that the layout and base template can leverage.
+The most important case to support is _partial_ rendering. If HTMX is making a request, unless it is [boosted](https://htmx.org/docs/#boosting), you only want to render the _content_ of your _route_, and not the entire [layout](#layouts). This is automatically handled by the `Render()` method on the [Request](#request) type. More can be read about that [here](#rendering).
-Specifying which templates to render for a given `Page` is as easy as:
+If your routes aren't doing multiple things, you may not need _conditional_ rendering, but it's worth knowing how flexible you can be. A simple example of this:
```go
-page.Name = "home"
-page.Layout = "main"
+if htmx.GetRequest(ctx).Target == "search" {
+ // This request is HTMX fetching content just for the #search element
+}
```
-That alone will result in the following templates being parsed and executed when the `Page` is rendered:
-
-1) `layouts/main.gohtml` as the base template
-2) `pages/home.gohtml` to provide the `content` template for the layout
-3) All template files located within the `components` directory
-4) The entire [funcmap](#funcmap)
-
-The [template renderer](#template-renderer) also provides caching and local hot-reloading.
-
-### Cached responses
-
-A `Page` can have cached enabled just by setting `Page.Cache.Enabled` to `true`. The `TemplateRenderer` will automatically handle caching the HTML output, headers and status code. Cached pages are stored using a key that matches the full request URL and [middleware](#cache-middleware) is used to serve it on matching requests.
-
-By default, the cache expiration time will be set according to the configuration value located at `Config.Cache.Expiration.Page` but it can be set per-page at `Page.Cache.Expiration`.
-
-#### Cache tags
-
-You can optionally specify cache tags for the `Page` by setting a slice of strings on `Page.Cache.Tags`. This provides the ability to build in cache invalidation logic in your application driven by events such as entity operations, for example.
-
-You can use the [cache client](#cache) on the `Container` to easily [flush cache tags](#flush-tags), if needed.
-
-#### Cache middleware
-
-Cached pages are served via the middleware `ServeCachedPage()` in the `middleware` package.
-
-The cache is bypassed if the requests meet any of the following criteria:
-1) Is not a GET request
-2) Is made by an authenticated user
-
-Cached pages are looked up for a key that matches the exact, full URL of the given request.
-
-### Data
+#### CSRF token
-The `Data` field on the `Page` is of type `any` and is what allows your route to pass whatever it requires to the templates, alongside the `Page` itself.
+If [CSRF](#csrf) protection is enabled, the token value will automatically be passed to HTMX to be included in all non-GET requests. This is done in the `JS()` [component](#components) by leveraging HTMX [events](https://htmx.org/reference/#events).
-### Forms
+### Request
-The `Form` field on the `Page` is similar to the `Data` field, but it's meant to store a struct that represents a form being rendered on the page.
+The `Request` type in the `ui` package is a foundational helper that provides useful data from the current request as well as resources and methods that make rendering UI components much easier. Using the `echo.Context`, a `Request` can be generated by executing `ui.NewRequest(ctx)`. As you develop and expand your application, you will likely want to expand this type to include additional data and methods that your frontend requires.
-An example of this pattern is:
+`NewRequest()` will automatically populate the following fields using the `echo.Context` from the current request:
-```go
-type ContactForm struct {
- Email string `form:"email" validate:"required,email"`
- Message string `form:"message" validate:"required"`
- form.Submission
-}
-```
+- `Context`: The provided _echo.Context_
+- `CurrentPath`: The requested URL path
+- `IsHome`: If the request was for the homepage
+- `IsAuth`: If the user is authenticated
+- `AuthUser`: The logged-in user entity, if one
+- `CSRF`: The CSRF token, if the middleware is being used
+- `Htmx`: Data from the HTMX headers, if HTMX made the request
+- `Config`: The application configuration, if the middleware is being used
-Embedding `form.Submission` satisfies the `form.Form` interface and makes dealing with submissions and validation extremely easy.
+#### Title and metatags
-Then in your page:
+The `Request` type has additional fields to make it easy to set static values within components being rendered on a given page. While the _title_ is always important, the others are provided as an example:
-```go
-p := page.New(ctx)
-p.Form = form.Get[ContactForm](ctx)
-```
+* `Title`: The page title
+* `Metatags`:
+ * `Description`: The description of the page
+ * `Tags`: A slice of keyword tags
-This will either initialize a new form to be rendered, or load one previously stored in the context (ie, if it was already submitted). How the _form_ gets populated with values so that your template can render them is covered in the next section.
+#### URL generation
-#### Submission processing
+As mentioned in the [Routes](#routes) section, it is recommended, though not required, to provide names for each of your routes. These are currently defined as consts in the `routenames` package. If you use names for your routes, you can leverage the URL generation methods on the `Request`. This allows you to prevent hard-coding your route paths and parameters in multiple places.
-Form submission processing is made extremely simple by leveraging functionality provided by [Echo binding](https://echo.labstack.com/guide/binding/), [validator](https://github.com/go-playground/validator) and the `Submission` struct located in `pkg/form/submission.go`.
+The methods both take a route name and optional variadic route parameters:
-Using the example form above, this is all you would have to do within the _POST_ callback for your route:
+* `Path()`: Generates a relative path for a given route.
+* `Url()`: Generates an absolute URL for a given route. This uses the `App.Host` field in your [configuration](#configuration) to determine the host of the URL.
-Start by submitting the form along with the request context. This will:
-1. Store a pointer to the form so that your _GET_ callback can access the form values (shown previously). That allows the form to easily be re-rendered with any validation errors it may have as well as the values that were provided.
-2. Parse the input in the _POST_ data to map to the struct so the fields becomes populated. This uses the `form` struct tags to map form input values to the struct fields.
-3. Validate the values in the struct fields according to the rules provided in the optional `validate` struct tags.
+**Example:**
```go
-var input ContactForm
-
-err := form.Submit(ctx, &input)
+g.GET("/user/:uid", profilePage).Name = routenames.Profile
```
-Check the error returned, and act accordingly. For example:
```go
-switch err.(type) {
-case nil:
- // All good!
-case validator.ValidationErrors:
- // The form input was not valid, so re-render the form
- return c.Page(ctx)
-default:
- // Request failed, show the error page
- return err
+func ProfileLink(r *ui.Request, userName string, userID int64) gomponents.Node {
+ return A(
+ Class("profile"),
+ Href(r.Path(routenames.Profile, userID)),
+ Text(userName),
+ )
}
```
-And finally, your template:
-```html
-
-```
+_Layouts_ are full HTML templates that are used by [pages](#pages) to inject themselves in to, allowing you to easily have multiple pages that all use the same layout, and to easily switch layouts between different pages. [Included](https://github.com/mikestefanello/pagoda/tree/templates/pkg/ui/layouts) is a _primary_ and _auth_ layout as an example, which you can see in action by navigating between the links on the _General_ and _Account_ sidebar menus.
-Second, render the error messages, if there are any for a given field:
-```go
-{{template "field-errors" (.Form.GetFieldErrors "Email")}}
-```
+### Pages
-### Headers
+_Pages_ are what [route handlers](#handlers) ultimately assemble and render. They may accept primitives, [models](#models), [forms](#forms), or nothing at all, and they embed themselves in a [layout](#layouts) of their choice. Each _page_ represents a different page of your web application and many [examples](https://github.com/mikestefanello/pagoda/tree/templates/pkg/ui/pages) are provided for reference. See below for a minimal example.
-HTTP headers can be set either via the `Page` or the _context_:
+#### Rendering
-```go
-p := page.New(ctx)
-p.Headers["HeaderName"] = "header-value"
-```
+The `Request` type contains a `Render()` method which makes rendering your page within a given layout simple. It automatically handles partial rendering, omitting the [layout](#layouts) and only rendering the [page](#pages) if the request is made by HTMX and is not boosted. Using HTMX is completely optional. This is accomplished by passing in your layout and _page_ separately, for example:
```go
-ctx.Response().Header().Set("HeaderName", "header-value")
+func MyPage(ctx echo.Context, username string) error {
+ r := ui.NewRequest(ctx)
+ r.Title = "My page"
+ node := Div(Textf("Hello, %s!", username))
+ return r.Render(layouts.Primary, node)
+}
```
-### Status code
+Using `Render()`, in this example, only `node` will render if HTMX made the request in a non-boosted fashion, otherwise `node` will render within `layouts.Primary`.
-The HTTP response status code can be set either via the `Page` or the _context_:
+And from within your [route handler](#handlers), simply:
```go
-p := page.New(ctx)
-p.StatusCode = http.StatusTooManyRequests
+func (e *ExampleHandler) Page(ctx echo.Context) error {
+ return pages.MyPage(ctx, "abcd")
+}
```
-```go
-ctx.Response().Status = http.StatusTooManyRequests
-```
+### Forms
-### Metatags
+Building, rendering, validating and processing forms is made extremely easy with [Echo binding](https://echo.labstack.com/guide/binding/), [validator](https://github.com/go-playground/validator), [form.Submission](https://github.com/mikestefanello/pagoda/blob/templates/pkg/form/submission.go), and the provided _gomponent_ [components](#components).
-The `Page` provides the ability to set basic HTML metatags which can be especially useful if your web application is publicly accessible. Only fields for the _description_ and _keywords_ are provided but adding additional fields is very easy.
+Start by declaring the form within the [forms](https://github.com/mikestefanello/pagoda/tree/templates/pkg/ui/forms) package:
```go
-p := page.New(ctx)
-p.Metatags.Description = "The page description."
-p.Metatags.Keywords = []string{"Go", "Software"}
+type Guestbook struct {
+ Message string `form:"message" validate:"required"`
+ form.Submission
+}
```
-A _component_ template is included to render metatags in `core.gohtml` which can be used by adding `{{template "metatags" .}}` to your _layout_.
+Embedding `form.Submission` satisfies the `form.Form` interface and handles submissions and validation for you.
-### URL and link generation
+Next, provide a method that renders the form:
-Generating URLs in the templates is made easy if you follow the [routing patterns](#patterns) and provide names for your routes. Echo provides a `Reverse` function to generate a route URL with a given route name and optional parameters. This function is made accessible to the templates via _funcmap_ function `url`.
-
-As an example, if you have route such as:
```go
-e.GET("/user/profile/:user", handler.Get).Name = "user_profile"
+func (f *Guestbook) Render(r *ui.Request) Node {
+ return Form(
+ ID("guestbook"),
+ Method(http.MethodPost),
+ Attr("hx-post", r.Path(routenames.GuestbookSubmit)),
+ TextareaField(TextareaFieldParams{
+ Form: f,
+ FormField: "Message",
+ Name: "message",
+ Label: "Message",
+ Value: f.Message,
+ }),
+ ControlGroup(
+ FormButton("is-link", "Submit"),
+ ),
+ CSRF(r),
+ )
+}
```
-And you want to generate a URL in the template, you can:
+Then, create a _page_ that includes your form:
+
```go
-{{url "user_profile" 1}
+func UserGuestbook(ctx echo.Context, form *forms.Guestbook) error {
+ r := ui.NewRequest(ctx)
+ r.Title = "User page"
+
+ content := Div(
+ Class("guestbook"),
+ H2(Text("My guestbook")),
+ P(Text("Hi, please sign my guestbook!")),
+ form.Render(r)
+ )
+
+ return r.Render(layouts.Primary, content)
+}
```
-Which will generate: `/user/profile/1`
-
-There is also a helper function provided in the [funcmap](#funcmap) to generate links which has the benefit of adding an _active_ class if the link URL matches the current path. This is especially useful for navigation menus.
+And last, have your handler render the _page_ in a route, and provide a route for the submission.
```go
-{{link (url "user_profile" .AuthUser.ID) "Profile" .Path "extra-class"}}
-```
+func (e *Example) Routes(g *echo.Group) {
+ g.GET("/guestbook", e.Page).Name = routenames.Guestbook
+ g.POST("/guestbook", c.PageSubmit).Name = routenames.GuestbookSubmit
+}
-Will generate:
-```html
-Profile
+func (e *Example) Page(ctx echo.Context) error {
+ return pages.UserGuestbook(ctx, form.Get[forms.Guestbook](ctx))
+}
```
-Assuming the current _path_ is `/user/profile/1`; otherwise the `is-active` class will be excluded.
-### HTMX support
+`form.Get` will either initialize a new form, or load one previously stored in the context (ie, if it was already submitted).
-[HTMX](https://htmx.org/) is an awesome JavaScript library allows you to access AJAX, CSS Transitions, WebSockets and Server Sent Events directly in HTML, using attributes, so you can build modern user interfaces with the simplicity and power of hypertext.
+#### Submission processing
-Many examples of its usage are available in the included examples:
-- All navigation links use [boost](https://htmx.org/docs/#boosting) which dynamically replaces the page content with an AJAX request, providing a SPA-like experience.
-- All forms use either [boost](https://htmx.org/docs/#boosting) or [hx-post](https://htmx.org/docs/#triggers) to submit via AJAX.
-- The mock search autocomplete modal uses [hx-get](https://htmx.org/docs/#targets) to fetch search results from the server via AJAX and update the UI.
-- The mock posts on the homepage/dashboard use [hx-get](https://htmx.org/docs/#targets) to fetch and page posts via AJAX.
+Using the example form above, this is all you would have to do within the _POST_ callback for your route:
-All of this can be easily accomplished without writing any JavaScript at all.
+Start by submitting the form via `form.Submit()`, along with the request context. This will:
+1. Store a pointer to the form in the _context_ so that your _GET_ callback can access the form values (shown previously). That allows the form to easily be re-rendered with any validation errors it may have as well as the values that were provided.
+2. Parse the input in the _POST_ data to map to the struct so the fields becomes populated. This uses the `form` struct tags to map form input values to the struct fields.
+3. Validate the values in the struct fields according to the rules provided in the optional `validate` struct tags.
-Another benefit of [HTMX](https://htmx.org/) is that it's completely backend-agnostic and does not require any special tools or integrations on the backend. But to make things easier, included is a small package to read and write [HTTP headers](https://htmx.org/docs/#requests) that HTMX uses to communicate additional information and commands.
+Then, evaluate the error returned, if one, and process the form values however you need to:
-The `htmx` package contains the headers for the _request_ and _response_. When a `Page` is initialized, `Page.HTMX.Request` will also be initialized and populated with the headers that HTMX provides, if HTMX made the request. This allows you to determine if HTMX is making the given request and what exactly it is doing, which could be useful both in your _route_ as well as your _templates_.
+```go
+func (e *Example) Submit(ctx echo.Context) error {
+ var input forms.Guestbook
+
+ // Submit the form.
+ err := form.Submit(ctx, &input)
+
+ // Check the error returned, and act accordingly.
+ switch err.(type) {
+ case nil:
+ // All good!
+ case validator.ValidationErrors:
+ // The form input was not valid, so re-render the form with the errors included.
+ return e.Page(ctx)
+ default:
+ // Request failed, show the error page.
+ return err
+ }
-If you need to set any HTMX headers in your `Page` response, this can be done by altering `Page.HTMX.Response`.
+ msg.Success(fmt.Sprintf("Your message was: %s", input.Message))
+
+ return redirect.New(ctx).
+ Route(routenames.Home).
+ Go()
+}
+```
-#### Layout template override
+#### Inline validation
-To facilitate easy partial rendering for HTMX requests, the `Page` will automatically change your _Layout_ template to use `htmx.gohtml`, which currently only renders `{{template "content" .}}`. This allows you to use an HTMX request to only update the content portion of the page, rather than the entire HTML.
+The `Submission` makes inline validation easier because it will store all validation errors in a map, keyed by the form struct field name. It also contains helper methods that the provided form [components](#components), such as `TextareaField` shown in the example above, use to automatically provide classes and error messages. The example form above will have inline validation without requiring anything other than what is shown above.
-This override only happens if the HTMX request being made is **not a boost** request because **boost** requests replace the entire `body` element so there is no need to do a partial render.
+While [validator](https://github.com/go-playground/validator) is a great package that is used to validate based on struct tags, the downside is that the messaging, by default, is not very human-readable or easy to override. Within `Submission.setErrorMessages()` the validation errors are converted to more readable messages based on the tag that failed validation. Only a few tags are provided as an example, so be sure to expand on that as needed.
-#### Conditional processing / rendering
+#### CSRF
-Since HTMX communicates what it is doing with the server, you can use the request headers to conditionally process in your _route_ or render in your _template_, if needed. If your routes aren't doing multiple things, you may not need this, but it's worth knowing how flexible you can be.
+By default, all non `GET` requests will require a CSRF token be provided as a form value. This is provided by middleware and can be adjusted or removed in the router.
-A simple example of this:
+The `Request` automatically extracts the CSRF token from the context, but you must include it in your forms by using the provided `CSRF()` [component](#components) as shown in the example above.
-```go
-if page.HTMX.Request.Target == "search" {
- // You know this request HTMX is fetching content just for the #search element
-}
-```
+### Models
-```go
-{{if eq .HTMX.Request.Target "search"}}
- // Render content for the #search element
-{{end}}
-```
+Models are objects built and provided by your _routes_ that can be rendered by your _ui_. Though not required, they reside in the [models package](https://github.com/mikestefanello/pagoda/tree/main/pkg/ui/models) and each has a `Render()` method, making them easy to render within your [pages](#pages). Please see example routes such as the homepage and search for an example.
-#### CSRF token
+### Node caching
-If [CSRF](#csrf) protection is enabled, the token value will automatically be passed to HTMX to be included in all non-GET requests. This is done in the `footer` template by leveraging HTMX [events](https://htmx.org/reference/#events).
+While most likely unnecessary for most applications, but because optimizing software is fun, a simple `gomponents.Node` cache is provided. This is not because _gomponents_ is inefficient, in fact my basic benchmarks put it as either similar or slightly better than Go templates, but rather because there are _some_ performance gains to be seen by caching static nodes and it may seem wasteful to build and render static HTML on every single page load. It is important to note, you can only cache nodes that are static and will never change.
-### Rendering the page
+A good example of this, and one included, is the entire upper navigation bar, search form, and search modal in the _Primary_ layout. It contains a large amount of nested _gomponent_ function calls and a lot of rendering is required. There is no reason to do this more than once.
-Once your `Page` is fully built, rendering it via the embedded `TemplateRenderer` in your _handler_ can be done simply by calling `RenderPage()`:
+The cache functions are available in `pkg/ui/cache` and can most easily used like this:
```go
-func (c *home) Get(ctx echo.Context) error {
- p := page.New(ctx)
- p.Layout = templates.LayoutMain
- p.Name = templates.PageHome
- return c.RenderPage(ctx, p)
+func SearchModal() gomponents.Node {
+ return cache.SetIfNotExists("searchModal", func() gomponents.Node {
+ return Div(...your entire nested node...)
+ })
}
```
-## Template renderer
+`cache.SetIfNotExists()`is a helper function that uses `cache.Get()` to check if the `Node` is already cached under the provided _key_, and if not, executes the _func_ to generate the `Node`, and caches that via `cache.Set()`.
-The _template renderer_ is a _Service_ on the `Container` that aims to make template parsing and rendering easy and flexible. It is the mechanism that allows the `Page` to do [automatic template parsing](#automatic-template-parsing). The standard `html/template` is still the engine used behind the scenes. The code can be found in `pkg/services/template_renderer.go`.
+`cache.Set()` does more than just cache the `Node` in-memory. It renders the entire `Node` into a `bytes.Buffer`, then stores a `Raw()` `Node` using the rendered content. This means that everytime the `Node` is taken from the cache and rendered, the pre-rendered `string` is used rather than having to iterate through the nested component, executing all of the element functions and rendering and building the entire HTML output.
-Here is an example of a complex rendering that uses multiple template files as well as an entire directory of template files:
+It's worth noting that my benchmarking was very limited and cannot be considered anything definitive. In my tests, gomponents was faster, allocated less overall, but had more allocations in total. If you're able to cache static nodes, gomponents can perform significantly better. Reiterating, for most applications, these differences in nanoseconds and bytes will most likely be completely insignificant and unnoticed; but it's worth being aware of.
-```go
-buf, err = c.TemplateRenderer.
- Parse().
- Group("page").
- Key("home").
- Base("main").
- Files("layouts/main", "pages/home").
- Directories("components").
- Execute(data)
-```
-
-This will do the following:
-- [Cache](#caching) the parsed template with a _group_ of `page` and _key_ of `home` so this parse only happens once
-- Set the _base template file_ as `main`
-- Include the templates `templates/layout/main.gohtml` and `templates/pages/home.gohtml`
-- Include all templates located within the directory `templates/components`
-- Include the [funcmap](#funcmap)
-- Execute the parsed template with `data` being passed in to the templates
-
-Using the example from the [page rendering](#rendering-the-page), this is will execute:
-
-```go
-buf, err = c.TemplateRenderer.
- Parse().
- Group("page").
- Key(page.Name).
- Base(page.Layout).
- Files(
- fmt.Sprintf("layouts/%s", page.Layout),
- fmt.Sprintf("pages/%s", page.Name),
- ).
- Directories("components").
- Execute(page)
-```
-
-If you have a need to _separately_ parse and cache the templates then later execute, you can separate the operations:
-
-```go
-_, err := c.TemplateRenderer.
- Parse().
- Group("my-group").
- Key("my-key").
- Base("auth").
- Files("layouts/auth", "pages/login").
- Directories("components").
- Store()
-```
+### Flash messaging
-```go
-tpl, err := c.TemplateRenderer.Load("my-group", "my-key")
-buf, err := tpl.Execute(data)
-```
+Flash messaging functionality is provided within the `msg` package. It is used to provide one-time status messages to users.
-### Custom functions
+Flash messaging requires that [sessions](#sessions) and the session middleware are in place since that is where the messages are stored.
-All templates will be parsed with the [funcmap](#funcmap) so all of your custom functions as well as the functions provided by [sprig](https://github.com/Masterminds/sprig) will be available.
+#### Creating messages
-### Caching
+There are four types of messages, and each can be created as follows:
+- Success: `msg.Success(ctx echo.Context, message string)`
+- Info: `msg.Info(ctx echo.Context, message string)`
+- Warning: `msg.Warning(ctx echo.Context, message string)`
+- Danger: `msg.Danger(ctx echo.Context, message string)`
-Parsed templates will be cached within a `sync.Map` so the operation will only happen once per cache _group_ and _ID_. Be careful with your cache _group_ and _ID_ parameters to avoid collisions.
+#### Rendering messages
-### Hot-reload for development
+When a flash message is retrieved from storage in order to be rendered, it is deleted from storage so that it cannot be rendered again.
-If the current [environment](#environments) is set to `config.EnvLocal`, which is the default, the cache will be bypassed and templates will be parsed every time they are requested. This allows you to have hot-reloading without having to restart the application so you can see your HTML changes in the browser immediately.
+A [component](#components), `FlashMessages()`, is provided to render flash messages within your UI.
-### File configuration
+## Pager
-To make things easier and less repetitive, parameters given to the _template renderer_ must not include the `templates` directory or the template file extensions. The file extension is stored as a constant (`TemplateExt`) within the `config` package.
+A very basic mechanism is provided to handle and facilitate paging located in `pkg/pager` and can be initialized via `pager.NewPager()`. If the requested URL contains a `page` query parameter with a numeric value, that will be set as the page number in the pager. This query key can be controlled via the `QueryKey` constant.
-## Funcmap
+Methods include:
-The `funcmap` package provides a _function map_ (`template.FuncMap`) which will be included for all templates rendered with the [template renderer](#template-renderer). Aside from a few custom functions, [sprig](https://github.com/Masterminds/sprig) is included which provides over 100 commonly used template functions. The full list is available [here](http://masterminds.github.io/sprig/).
+- `SetItems(items int)`: Set the total amount of items in the entire result-set
+- `IsBeginning()`: Determine if the pager is at the beginning of the pages
+- `IsEnd()`: Determine if the pager is at the end of the pages
+- `GetOffset()`: Get the offset which can be useful in constructing a paged database query
-To include additional custom functions, add to the map in `NewFuncMap()` and define the function in the package. It will then become automatically available in all templates.
+There is currently no generic component to easily render a pager, but the homepage does have an example.
## Cache
-As previously mentioned, the default cache implementation is a simple in-memory store, backed by [otter](https://github.com/maypok86/otter), a lockless cache that uses [S3-FIFO](https://s3fifo.com/) eviction. The `Container` houses a `CacheClient` which is a useful, wrapper to interact with the cache (see examples below). Within the `CacheClient` is the underlying store interface `CacheStore`. If you wish to use a different store, such as Redis, and want to keep using the `CacheClient`, simply implement the `CacheStore` interface with a Redis library and adjust the `Container` initialization to use that.
+As previously mentioned, the default cache implementation is a simple in-memory store, backed by [otter](https://github.com/maypok86/otter), a lockless cache that uses [S3-FIFO](https://s3fifo.com/) eviction. The `Container` houses a `CacheClient` which is a useful wrapper to interact with the cache (see examples below). Within the `CacheClient` is the underlying store interface `CacheStore`. If you wish to use a different store, such as Redis, and want to keep using the `CacheClient`, simply implement the `CacheStore` interface with a Redis library and adjust the `Container` initialization to use that.
-The built-in usage of the cache is currently only for optional [page caching](#cached-responses) and a simple example route located at `/cache` where you can set and view the value of a given cache entry.
+The built-in usage of the cache is currently only used for a simple example route located at `/cache` where you can set and view the value of a given cache entry.
Since the current cache is in-memory, there's no need to adjust the `Container` during tests. When this project used Redis, the configuration had a separate database that would be used strictly for tests to avoid writing to your primary database. If you need that functionality, it is easy to add back in.
@@ -996,7 +897,7 @@ As shown in the previous examples, cache tags were provided because they can be
## Tasks
-Tasks are queued operations to be executed in the background, either immediately, at a specfic time, or after a given amount of time has passed. Some examples of tasks could be long-running operations, bulk processing, cleanup, notifications, etc.
+Tasks are queued operations to be executed in the background, either immediately, at a specific time, or after a given amount of time has passed. Some examples of tasks could be long-running operations, bulk processing, cleanup, notifications, etc.
Since we're already using [SQLite](https://sqlite.org/) for our database, it's available to act as a persistent store for queued tasks so that tasks are never lost, can be retried until successful, and their concurrent execution can be managed. [Backlite](https://github.com/mikestefanello/backlite) is the library chosen to interface with [SQLite](https://sqlite.org/) and handle queueing tasks and processing them asynchronously. I wrote that specifically to address the requirements I wanted to satisfy for this project.
@@ -1044,25 +945,27 @@ The cache max-life is controlled by the configuration at `Config.Cache.Expiratio
### Cache-buster
-While it's ideal to use cache control headers on your static files so browsers cache the files, you need a way to bust the cache in case the files are changed. In order to do this, a function is provided in the [funcmap](#funcmap) to generate a static file URL for a given file that appends a cache-buster query. This query string is randomly generated and persisted until the application restarts.
+While it's ideal to use cache control headers on your static files so browsers cache the files, you need a way to bust the cache in case the files are changed. In order to do this, a function, `File()`, is provided in the `ui` package to generate a static file URL for a given file that appends a cache-buster query. This query string is generated using the timestamp of when the app started and persists until the application restarts.
For example, to render a file located in `static/picture.png`, you would use:
-```html
-
+```go
+return Img(Src(ui.File("picture.png")))
```
Which would result in:
```html
-
+
```
-Where `9fhe73kaf3` is the randomly-generated cache-buster.
+Where `1741053493` is the cache-buster.
## Email
-An email client was added as a _Service_ to the `Container` but it is just a skeleton without any actual email-sending functionality. The reason is because there are a lot of ways to send email and most prefer using a SaaS solution for that. That makes it difficult to provide a generic solution that will work for most applications.
+An email client was added as a _Service_ to the `Container` but it is just a skeleton without any actual email-sending functionality. The reason is that there are a lot of ways to send email and most prefer using a SaaS solution for that. That makes it difficult to provide a generic solution that will work for most applications.
-The structure in the client (`MailClient`) makes composing emails very easy and you have the option to construct the body using either a simple string or with a template by leveraging the [template renderer](#template-renderer). The standard library can be used if you wish to send email via SMTP and most SaaS providers have a Go package that can be used if you choose to go that direction. **You must** finish the implementation of `MailClient.send`.
+The structure in the client (`MailClient`) makes composing emails very easy, and you have the option to construct the body using either a simple string or with a renderable _gomponent_, as explained in the [user interface](#user-interface), in order to produce HTML emails. A simple example is provided in `pkg/ui/emails`.
+
+The standard library can be used if you wish to send email via SMTP and most SaaS providers have a Go package that can be used if you choose to go that direction. **You must** finish the implementation of `MailClient.send`.
The _from_ address will default to the configuration value at `Config.Mail.FromAddress`. This can be overridden per-email by calling `From()` on the email and passing in the desired address.
@@ -1079,19 +982,18 @@ err = c.Mail.
Send(ctx)
```
-**Sending with a template body**:
+**Sending an HTML body using a gomponent**:
```go
err = c.Mail.
Compose().
To("hello@example.com").
- Subject("Welcome!").
- Template("welcome").
- TemplateData(templateData).
+ Subject("Confirm your email address").
+ Component(emails.ConfirmEmailAddress(ctx, username, token)).
Send(ctx)
```
-This will use the template located at `templates/emails/welcome.gohtml` and pass `templateData` to it.
+This will use the HTML provided when rendering the _gomponent_ as the email body.
## HTTPS
@@ -1159,9 +1061,10 @@ Future work includes but is not limited to:
## Credits
-Thank you to all of the following amazing projects for making this possible.
+Thank you to all the following amazing projects for making this possible.
- [afero](https://github.com/spf13/afero)
+- [air](https://github.com/air-verse/air)
- [alpinejs](https://github.com/alpinejs/alpine)
- [backlite](https://github.com/mikestefanello/backlite)
- [bulma](https://github.com/jgthms/bulma)
@@ -1169,12 +1072,12 @@ Thank you to all of the following amazing projects for making this possible.
- [ent](https://github.com/ent/ent)
- [go](https://go.dev/)
- [go-sqlite3](https://github.com/mattn/go-sqlite3)
+- [gomponents](https://github.com/maragudk/gomponents)
- [goquery](https://github.com/PuerkitoBio/goquery)
- [htmx](https://github.com/bigskysoftware/htmx)
- [jwt](https://github.com/golang-jwt/jwt)
- [otter](https://github.com/maypok86/otter)
- [sessions](https://github.com/gorilla/sessions)
-- [sprig](https://github.com/Masterminds/sprig)
- [sqlite](https://sqlite.org/)
- [testify](https://github.com/stretchr/testify)
- [validator](https://github.com/go-playground/validator)
diff --git a/config/config.go b/config/config.go
index dcc3fdf9..7c8853e4 100644
--- a/config/config.go
+++ b/config/config.go
@@ -9,35 +9,32 @@ import (
)
const (
- // TemplateExt stores the extension used for the template files
- TemplateExt = ".gohtml"
-
- // StaticDir stores the name of the directory that will serve static files
+ // StaticDir stores the name of the directory that will serve static files.
StaticDir = "static"
- // StaticPrefix stores the URL prefix used when serving static files
+ // StaticPrefix stores the URL prefix used when serving static files.
StaticPrefix = "files"
)
type environment string
const (
- // EnvLocal represents the local environment
+ // EnvLocal represents the local environment.
EnvLocal environment = "local"
- // EnvTest represents the test environment
+ // EnvTest represents the test environment.
EnvTest environment = "test"
- // EnvDevelop represents the development environment
+ // EnvDevelop represents the development environment.
EnvDevelop environment = "dev"
- // EnvStaging represents the staging environment
+ // EnvStaging represents the staging environment.
EnvStaging environment = "staging"
- // EnvQA represents the qa environment
+ // EnvQA represents the qa environment.
EnvQA environment = "qa"
- // EnvProduction represents the production environment
+ // EnvProduction represents the production environment.
EnvProduction environment = "prod"
)
@@ -51,7 +48,7 @@ func SwitchEnvironment(env environment) {
}
type (
- // Config stores complete configuration
+ // Config stores complete configuration.
Config struct {
HTTP HTTPConfig
App AppConfig
@@ -62,7 +59,7 @@ type (
Mail MailConfig
}
- // HTTPConfig stores HTTP configuration
+ // HTTPConfig stores HTTP configuration.
HTTPConfig struct {
Hostname string
Port uint16
@@ -77,9 +74,10 @@ type (
}
}
- // AppConfig stores application configuration
+ // AppConfig stores application configuration.
AppConfig struct {
Name string
+ Host string
Environment environment
EncryptionKey string
Timeout time.Duration
@@ -90,7 +88,7 @@ type (
EmailVerificationTokenExpiration time.Duration
}
- // CacheConfig stores the cache configuration
+ // CacheConfig stores the cache configuration.
CacheConfig struct {
Capacity int
Expiration struct {
@@ -99,19 +97,19 @@ type (
}
}
- // DatabaseConfig stores the database configuration
+ // DatabaseConfig stores the database configuration.
DatabaseConfig struct {
Driver string
Connection string
TestConnection string
}
- // FilesConfig stores the file system configuration
+ // FilesConfig stores the file system configuration.
FilesConfig struct {
Directory string
}
- // TasksConfig stores the tasks configuration
+ // TasksConfig stores the tasks configuration.
TasksConfig struct {
Goroutines int
ReleaseAfter time.Duration
@@ -119,7 +117,7 @@ type (
ShutdownTimeout time.Duration
}
- // MailConfig stores the mail configuration
+ // MailConfig stores the mail configuration.
MailConfig struct {
Hostname string
Port uint16
@@ -129,11 +127,11 @@ type (
}
)
-// GetConfig loads and returns configuration
+// GetConfig loads and returns configuration.
func GetConfig() (Config, error) {
var c Config
- // Load the config file
+ // Load the config file.
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath(".")
@@ -141,7 +139,7 @@ func GetConfig() (Config, error) {
viper.AddConfigPath("../config")
viper.AddConfigPath("../../config")
- // Load env variables
+ // Load env variables.
viper.SetEnvPrefix("pagoda")
viper.AutomaticEnv()
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
diff --git a/config/config.yaml b/config/config.yaml
index 5193bd46..963ecbc6 100644
--- a/config/config.yaml
+++ b/config/config.yaml
@@ -12,8 +12,11 @@ http:
app:
name: "Pagoda"
+ # We manually set this rather than using the HTTP settings in order to build absolute URLs for users
+ # since it's likely your app's HTTP settings are not identical to what is exposed by your server.
+ host: "http://localhost:8000"
environment: "local"
- # Change this on any live environments
+ # Change this on any live environments.
encryptionKey: "?E(G+KbPeShVmYq3t6w9z$C&F)J@McQf"
timeout: "20s"
passwordToken:
diff --git a/go.mod b/go.mod
index e30558fe..37b190f5 100644
--- a/go.mod
+++ b/go.mod
@@ -4,14 +4,12 @@ go 1.23.0
require (
entgo.io/ent v0.14.2
- github.com/Masterminds/sprig v2.22.0+incompatible
github.com/PuerkitoBio/goquery v1.10.1
github.com/go-playground/validator/v10 v10.24.0
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/gorilla/context v1.1.2
github.com/gorilla/sessions v1.4.0
github.com/labstack/echo/v4 v4.13.3
- github.com/labstack/gommon v0.4.2
github.com/mattn/go-sqlite3 v1.14.24
github.com/maypok86/otter v1.2.4
github.com/mikestefanello/backlite v0.2.0
@@ -19,12 +17,11 @@ require (
github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.33.0
+ maragu.dev/gomponents v1.1.0
)
require (
ariga.io/atlas v0.31.1-0.20250212144724-069be8033e83 // indirect
- github.com/Masterminds/goutils v1.1.1 // indirect
- github.com/Masterminds/semver v1.5.0 // indirect
github.com/agext/levenshtein v1.2.3 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
@@ -42,16 +39,13 @@ require (
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hashicorp/hcl/v2 v2.20.1 // indirect
- github.com/huandu/xstrings v1.4.0 // indirect
- github.com/imdario/mergo v0.3.16 // indirect
+ github.com/labstack/gommon v0.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
- github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
- github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
diff --git a/go.sum b/go.sum
index 5426abac..09565b03 100644
--- a/go.sum
+++ b/go.sum
@@ -4,12 +4,6 @@ entgo.io/ent v0.14.2 h1:ywld/j2Rx4EmnIKs8eZ29cbFA1zpB+DA9TLL5l3rlq0=
entgo.io/ent v0.14.2/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
-github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
-github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
-github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
-github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
-github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60=
-github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o=
github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo=
@@ -64,10 +58,6 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/hcl/v2 v2.20.1 h1:M6hgdyz7HYt1UN9e61j+qKJBqR3orTWbI1HKBJEdxtc=
github.com/hashicorp/hcl/v2 v2.20.1/go.mod h1:TZDqQ4kNKCbh1iJp99FdPiUaVDDUPivbqxZulxDYqL4=
-github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU=
-github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
-github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
-github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -88,24 +78,16 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
-github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/maypok86/otter v1.2.4 h1:HhW1Pq6VdJkmWwcZZq19BlEQkHtI8xgsQzBVXJU0nfc=
github.com/maypok86/otter v1.2.4/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4=
github.com/mikestefanello/backlite v0.2.0 h1:nX+QUy/z5FEV+ApMqvZ1jGqyRPGbuBI2mJR0BvirT9s=
github.com/mikestefanello/backlite v0.2.0/go.mod h1:/vj8LPZWG/xqK/3uHaqOtu5JRLDEWqeyJKWTAlADTV0=
-github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
-github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
-github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
-github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
-github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
-github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -123,8 +105,6 @@ github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
-github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
-github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
@@ -245,3 +225,5 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+maragu.dev/gomponents v1.1.0 h1:iCybZZChHr1eSlvkWp/JP3CrZGzctLudQ/JI3sBcO4U=
+maragu.dev/gomponents v1.1.0/go.mod h1:oEDahza2gZoXDoDHhw8jBNgH+3UR5ni7Ur648HORydM=
diff --git a/pkg/context/context.go b/pkg/context/context.go
index 95e18ad9..d433949b 100644
--- a/pkg/context/context.go
+++ b/pkg/context/context.go
@@ -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
+}
diff --git a/pkg/context/context_test.go b/pkg/context/context_test.go
index cee5bc7c..536716af 100644
--- a/pkg/context/context_test.go
+++ b/pkg/context/context_test.go
@@ -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)
+}
diff --git a/pkg/form/form.go b/pkg/form/form.go
index 6832d155..16177e15 100644
--- a/pkg/form/form.go
+++ b/pkg/form/form.go
@@ -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)
}
diff --git a/pkg/form/submission.go b/pkg/form/submission.go
index 88d898fe..48fdc8a1 100644
--- a/pkg/form/submission.go
+++ b/pkg/form/submission.go
@@ -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)
}
}
diff --git a/pkg/form/submission_test.go b/pkg/form/submission_test.go
index 3b3a9dcb..e6b4f9bc 100644
--- a/pkg/form/submission_test.go
+++ b/pkg/form/submission_test.go
@@ -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)
diff --git a/pkg/funcmap/funcmap.go b/pkg/funcmap/funcmap.go
deleted file mode 100644
index 2b5dc83e..00000000
--- a/pkg/funcmap/funcmap.go
+++ /dev/null
@@ -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(`%s`, 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...)
-}
diff --git a/pkg/funcmap/funcmap_test.go b/pkg/funcmap/funcmap_test.go
deleted file mode 100644
index b3b3b2bb..00000000
--- a/pkg/funcmap/funcmap_test.go
+++ /dev/null
@@ -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 := `Text`
- assert.Equal(t, expected, link)
-
- link = string(f.link("/abc", "Text", "/abc", "first", "second"))
- expected = `Text`
- assert.Equal(t, expected, link)
-
- link = string(f.link("/abc", "Text", "/def"))
- expected = `Text`
- 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)
-}
diff --git a/pkg/handlers/auth.go b/pkg/handlers/auth.go
index b7f9edd2..4638dca0 100644
--- a/pkg/handlers/auth.go
+++ b/pkg/handlers/auth.go
@@ -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, %s. 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()
}
diff --git a/pkg/handlers/cache.go b/pkg/handlers/cache.go
index a0600f40..2b6d3b23 100644
--- a/pkg/handlers/cache.go
+++ b/pkg/handlers/cache.go
@@ -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"
-)
-
-const (
- routeNameCache = "cache"
- routeNameCacheSubmit = "cache.submit"
+ "github.com/mikestefanello/pagoda/pkg/ui/forms"
+ "github.com/mikestefanello/pagoda/pkg/ui/pages"
)
-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").
diff --git a/pkg/handlers/contact.go b/pkg/handlers/contact.go
index 4c5f264c..28fa0ae2 100644
--- a/pkg/handlers/contact.go
+++ b/pkg/handlers/contact.go
@@ -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"
-)
-
-const (
- routeNameContact = "contact"
- routeNameContactSubmit = "contact.submit"
+ "github.com/mikestefanello/pagoda/pkg/ui/forms"
+ "github.com/mikestefanello/pagoda/pkg/ui/pages"
)
-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)
diff --git a/pkg/handlers/error.go b/pkg/handlers/error.go
index fd5502b4..a35ab09f 100644
--- a/pkg/handlers/error.go
+++ b/pkg/handlers/error.go
@@ -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,
)
diff --git a/pkg/handlers/files.go b/pkg/handlers/files.go
index 1dfe9c55..5c77e848 100644
--- a/pkg/handlers/files.go
+++ b/pkg/handlers/files.go
@@ -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 {
diff --git a/pkg/handlers/pages.go b/pkg/handlers/pages.go
index 481ec4a9..b5937df0 100644
--- a/pkg/handlers/pages.go
+++ b/pkg/handlers/pages.go
@@ -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 htmx.org to learn more.`),
- },
- {
- Title: "Alpine.js",
- Body: template.HTML(`Drop-in, Vue-like functionality written directly in your markup. Visit alpinejs.dev 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 bulma.io to learn more.`),
- },
- },
- BackendTabs: []aboutTab{
- {
- Title: "Echo",
- Body: template.HTML(`High performance, extensible, minimalist Go web framework. Visit echo.labstack.com to learn more.`),
- },
- {
- Title: "Ent",
- Body: template.HTML(`Simple, yet powerful ORM for modeling and querying data. Visit entgo.io to learn more.`),
- },
- },
- }
-
- return h.RenderPage(ctx, p)
+ return pages.About(ctx)
}
diff --git a/pkg/handlers/pages_test.go b/pkg/handlers/pages_test.go
index 85c591be..0cd8ff3e 100644
--- a/pkg/handlers/pages_test.go
+++ b/pkg/handlers/pages_test.go
@@ -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()
diff --git a/pkg/handlers/router.go b/pkg/handlers/router.go
index e72e82b8..57d31def 100644
--- a/pkg/handlers/router.go
+++ b/pkg/handlers/router.go
@@ -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
diff --git a/pkg/handlers/search.go b/pkg/handlers/search.go
index 06ebd307..f0e92caf 100644
--- a/pkg/handlers/search.go
+++ b/pkg/handlers/search.go
@@ -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)
}
diff --git a/pkg/handlers/task.go b/pkg/handlers/task.go
index aae95641..93139274 100644
--- a/pkg/handlers/task.go
+++ b/pkg/handlers/task.go
@@ -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)
diff --git a/pkg/htmx/htmx.go b/pkg/htmx/htmx.go
index cabc9188..e897c42d 100644
--- a/pkg/htmx/htmx.go
+++ b/pkg/htmx/htmx.go
@@ -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)
diff --git a/pkg/htmx/htmx_test.go b/pkg/htmx/htmx_test.go
index 93fa06ef..4963197f 100644
--- a/pkg/htmx/htmx_test.go
+++ b/pkg/htmx/htmx_test.go
@@ -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) {
diff --git a/pkg/log/log.go b/pkg/log/log.go
index ebafd287..51c6448f 100644
--- a/pkg/log/log.go
+++ b/pkg/log/log.go
@@ -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()
}
diff --git a/pkg/middleware/cache.go b/pkg/middleware/cache.go
index 1701dc21..adfb8ebe 100644
--- a/pkg/middleware/cache.go
+++ b/pkg/middleware/cache.go
@@ -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 {
diff --git a/pkg/middleware/cache_test.go b/pkg/middleware/cache_test.go
index fcd7fb48..040777de 100644
--- a/pkg/middleware/cache_test.go
+++ b/pkg/middleware/cache_test.go
@@ -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))
diff --git a/pkg/middleware/config.go b/pkg/middleware/config.go
new file mode 100644
index 00000000..e3c89c18
--- /dev/null
+++ b/pkg/middleware/config.go
@@ -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)
+ }
+ }
+}
diff --git a/pkg/middleware/config_test.go b/pkg/middleware/config_test.go
new file mode 100644
index 00000000..22c0283b
--- /dev/null
+++ b/pkg/middleware/config_test.go
@@ -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)
+}
diff --git a/pkg/middleware/entity.go b/pkg/middleware/entity.go
index c13bee74..d6a4c012 100644
--- a/pkg/middleware/entity.go
+++ b/pkg/middleware/entity.go
@@ -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 {
diff --git a/pkg/msg/msg.go b/pkg/msg/msg.go
index e8274f54..724ecdbc 100644
--- a/pkg/msg/msg.go
+++ b/pkg/msg/msg.go
@@ -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",
diff --git a/pkg/page/page.go b/pkg/page/page.go
deleted file mode 100644
index e5bbd8ee..00000000
--- a/pkg/page/page.go
+++ /dev/null
@@ -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
-}
diff --git a/pkg/page/page_test.go b/pkg/page/page_test.go
deleted file mode 100644
index 1ab10cbc..00000000
--- a/pkg/page/page_test.go
+++ /dev/null
@@ -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))
- }
- }
-}
diff --git a/pkg/page/pager.go b/pkg/pager/pager.go
similarity index 66%
rename from pkg/page/pager.go
rename to pkg/pager/pager.go
index 85f530fb..ae4672e0 100644
--- a/pkg/page/pager.go
+++ b/pkg/pager/pager.go
@@ -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
}
diff --git a/pkg/page/pager_test.go b/pkg/pager/pager_test.go
similarity index 90%
rename from pkg/page/pager_test.go
rename to pkg/pager/pager_test.go
index bfb58806..44cace91 100644
--- a/pkg/page/pager_test.go
+++ b/pkg/pager/pager_test.go
@@ -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)
}
diff --git a/pkg/redirect/redirect.go b/pkg/redirect/redirect.go
index 3e971799..3f08b436 100644
--- a/pkg/redirect/redirect.go
+++ b/pkg/redirect/redirect.go
@@ -24,7 +24,7 @@ type Redirect struct {
func New(ctx echo.Context) *Redirect {
return &Redirect{
ctx: ctx,
- status: http.StatusFound,
+ status: http.StatusTemporaryRedirect,
}
}
diff --git a/pkg/routenames/names.go b/pkg/routenames/names.go
new file mode 100644
index 00000000..a3c98aa1
--- /dev/null
+++ b/pkg/routenames/names.go
@@ -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"
+)
diff --git a/pkg/services/container.go b/pkg/services/container.go
index 05c9cae7..a3f3ce80 100644
--- a/pkg/services/container.go
+++ b/pkg/services/container.go
@@ -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, "/")
diff --git a/pkg/services/container_test.go b/pkg/services/container_test.go
index 877578bc..7f4a5d41 100644
--- a/pkg/services/container_test.go
+++ b/pkg/services/container_test.go
@@ -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)
}
diff --git a/pkg/services/mail.go b/pkg/services/mail.go
index b4b21c38..22e7d3d8 100644
--- a/pkg/services/mail.go
+++ b/pkg/services/mail.go
@@ -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
- 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
+// 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
}
-// 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)
}
diff --git a/pkg/services/template_renderer.go b/pkg/services/template_renderer.go
deleted file mode 100644
index 92cb172c..00000000
--- a/pkg/services/template_renderer.go
+++ /dev/null
@@ -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)
-}
diff --git a/pkg/services/template_renderer_test.go b/pkg/services/template_renderer_test.go
deleted file mode 100644
index d9c6633a..00000000
--- a/pkg/services/template_renderer_test.go
+++ /dev/null
@@ -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)
- })
-}
diff --git a/pkg/tasks/example.go b/pkg/tasks/example.go
index 48e1839b..4c7a86db 100644
--- a/pkg/tasks/example.go
+++ b/pkg/tasks/example.go
@@ -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
})
diff --git a/pkg/tasks/register.go b/pkg/tasks/register.go
index 895b974a..fc934dcd 100644
--- a/pkg/tasks/register.go
+++ b/pkg/tasks/register.go
@@ -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))
}
diff --git a/pkg/ui/cache/cache.go b/pkg/ui/cache/cache.go
new file mode 100644
index 00000000..79badc61
--- /dev/null
+++ b/pkg/ui/cache/cache.go
@@ -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
+}
diff --git a/pkg/ui/cache/cache_test.go b/pkg/ui/cache/cache_test.go
new file mode 100644
index 00000000..06b0c940
--- /dev/null
+++ b/pkg/ui/cache/cache_test.go
@@ -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, `
hello
`, 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)
+}
diff --git a/pkg/ui/components/alerts.go b/pkg/ui/components/alerts.go
new file mode 100644
index 00000000..3e2f8210
--- /dev/null
+++ b/pkg/ui/components/alerts.go
@@ -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,
+ ),
+ )
+}
diff --git a/pkg/ui/components/form.go b/pkg/ui/components/form.go
new file mode 100644
index 00000000..7dee6cfe
--- /dev/null
+++ b/pkg/ui/components/form.go
@@ -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),
+ )
+}
diff --git a/pkg/ui/components/head.go b/pkg/ui/components/head.go
new file mode 100644
index 00000000..56536b43
--- /dev/null
+++ b/pkg/ui/components/head.go
@@ -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, ", ")))),
+ }
+}
diff --git a/pkg/ui/components/htmx.go b/pkg/ui/components/htmx.go
new file mode 100644
index 00000000..d62cb587
--- /dev/null
+++ b/pkg/ui/components/htmx.go
@@ -0,0 +1,9 @@
+package components
+
+import (
+ . "maragu.dev/gomponents"
+)
+
+func HxBoost() Node {
+ return Attr("hx-boost", "true")
+}
diff --git a/pkg/ui/components/nav.go b/pkg/ui/components/nav.go
new file mode 100644
index 00000000..385069fc
--- /dev/null
+++ b/pkg/ui/components/nav.go
@@ -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")),
+ ),
+ )
+}
diff --git a/pkg/ui/components/tabs.go b/pkg/ui/components/tabs.go
new file mode 100644
index 00000000..5c8857bc
--- /dev/null
+++ b/pkg/ui/components/tabs.go
@@ -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(),
+ ),
+ )
+}
diff --git a/pkg/ui/emails/auth.go b/pkg/ui/emails/auth.go
new file mode 100644
index 00000000..771f8543
--- /dev/null
+++ b/pkg/ui/emails/auth.go
@@ -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)),
+ }
+}
diff --git a/pkg/ui/forms/cache.go b/pkg/ui/forms/cache.go
new file mode 100644
index 00000000..f8432139
--- /dev/null
+++ b/pkg/ui/forms/cache.go
@@ -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),
+ )
+}
diff --git a/pkg/ui/forms/contact.go b/pkg/ui/forms/contact.go
new file mode 100644
index 00000000..3d8bd3cf
--- /dev/null
+++ b/pkg/ui/forms/contact.go
@@ -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),
+ )
+}
diff --git a/pkg/ui/forms/file.go b/pkg/ui/forms/file.go
new file mode 100644
index 00000000..ad447b75
--- /dev/null
+++ b/pkg/ui/forms/file.go
@@ -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),
+ )
+}
diff --git a/pkg/ui/forms/forgot_password.go b/pkg/ui/forms/forgot_password.go
new file mode 100644
index 00000000..511e36b1
--- /dev/null
+++ b/pkg/ui/forms/forgot_password.go
@@ -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),
+ )
+}
diff --git a/pkg/ui/forms/login.go b/pkg/ui/forms/login.go
new file mode 100644
index 00000000..b3024f75
--- /dev/null
+++ b/pkg/ui/forms/login.go
@@ -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),
+ )
+}
diff --git a/pkg/ui/forms/register.go b/pkg/ui/forms/register.go
new file mode 100644
index 00000000..2d583258
--- /dev/null
+++ b/pkg/ui/forms/register.go
@@ -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),
+ )
+}
diff --git a/pkg/ui/forms/reset_password.go b/pkg/ui/forms/reset_password.go
new file mode 100644
index 00000000..03abcad9
--- /dev/null
+++ b/pkg/ui/forms/reset_password.go
@@ -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),
+ )
+}
diff --git a/pkg/ui/forms/task.go b/pkg/ui/forms/task.go
new file mode 100644
index 00000000..2c4c2b76
--- /dev/null
+++ b/pkg/ui/forms/task.go
@@ -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),
+ )
+}
diff --git a/pkg/ui/layouts/auth.go b/pkg/ui/layouts/auth.go
new file mode 100644
index 00000000..719d3ff9
--- /dev/null
+++ b/pkg/ui/layouts/auth.go
@@ -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")),
+ ),
+ ),
+ )
+ })
+}
diff --git a/pkg/ui/layouts/primary.go b/pkg/ui/layouts/primary.go
new file mode 100644
index 00000000..e92e562a
--- /dev/null
+++ b/pkg/ui/layouts/primary.go
@@ -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)),
+ ),
+ )
+}
diff --git a/pkg/ui/models/file.go b/pkg/ui/models/file.go
new file mode 100644
index 00000000..257efcdb
--- /dev/null
+++ b/pkg/ui/models/file.go
@@ -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)),
+ )
+}
diff --git a/pkg/ui/models/post.go b/pkg/ui/models/post.go
new file mode 100644
index 00000000..b66ef672
--- /dev/null
+++ b/pkg/ui/models/post.go
@@ -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),
+ ),
+ ),
+ ),
+ )
+}
diff --git a/pkg/ui/models/search_result.go b/pkg/ui/models/search_result.go
new file mode 100644
index 00000000..f1a07af4
--- /dev/null
+++ b/pkg/ui/models/search_result.go
@@ -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),
+ )
+}
diff --git a/pkg/ui/pages/about.go b/pkg/ui/pages/about.go
new file mode 100644
index 00000000..5c68be8b
--- /dev/null
+++ b/pkg/ui/pages/about.go
@@ -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 htmx.org to learn more.",
+ },
+ {
+ Title: "Alpine.js",
+ Body: "Drop-in, Vue-like functionality written directly in your markup. Visit alpinejs.dev 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 bulma.io 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 echo.labstack.com to learn more.",
+ },
+ {
+ Title: "Ent",
+ Body: "Simple, yet powerful ORM for modeling and querying data. Visit entgo.io to learn more.",
+ },
+ },
+ ),
+ }
+ })
+
+ return r.Render(layouts.Primary, tabs)
+}
diff --git a/pkg/ui/pages/auth.go b/pkg/ui/pages/auth.go
new file mode 100644
index 00000000..aa277893
--- /dev/null
+++ b/pkg/ui/pages/auth.go
@@ -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))
+}
diff --git a/pkg/ui/pages/cache.go b/pkg/ui/pages/cache.go
new file mode 100644
index 00000000..ed572961
--- /dev/null
+++ b/pkg/ui/pages/cache.go
@@ -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))
+}
diff --git a/pkg/ui/pages/contact.go b/pkg/ui/pages/contact.go
new file mode 100644
index 00000000..cef7f326
--- /dev/null
+++ b/pkg/ui/pages/contact.go
@@ -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)
+}
diff --git a/pkg/ui/pages/error.go b/pkg/ui/pages/error.go
new file mode 100644
index 00000000..21d42cf9
--- /dev/null
+++ b/pkg/ui/pages/error.go
@@ -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))
+}
diff --git a/pkg/ui/pages/file.go b/pkg/ui/pages/file.go
new file mode 100644
index 00000000..506946d0
--- /dev/null
+++ b/pkg/ui/pages/file.go
@@ -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)
+}
diff --git a/pkg/ui/pages/home.go b/pkg/ui/pages/home.go
new file mode 100644
index 00000000..51bab861
--- /dev/null
+++ b/pkg/ui/pages/home.go
@@ -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)
+}
diff --git a/pkg/ui/pages/search.go b/pkg/ui/pages/search.go
new file mode 100644
index 00000000..0af5cee7
--- /dev/null
+++ b/pkg/ui/pages/search.go
@@ -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)
+}
diff --git a/pkg/ui/pages/task.go b/pkg/ui/pages/task.go
new file mode 100644
index 00000000..aed83ce3
--- /dev/null
+++ b/pkg/ui/pages/task.go
@@ -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 ExampleTask 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)
+}
diff --git a/pkg/ui/request.go b/pkg/ui/request.go
new file mode 100644
index 00000000..c2c6ab9a
--- /dev/null
+++ b/pkg/ui/request.go
@@ -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)
+}
diff --git a/pkg/ui/request_test.go b/pkg/ui/request_test.go
new file mode 100644
index 00000000..e8a49875
--- /dev/null
+++ b/pkg/ui/request_test.go
@@ -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, `
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.
{{if .IsAuth}}Welcome back!{{else}}Please login in to your account.{{end}}
-
-
-
-
-
-
Recent posts
-
- Below is an example of both paging and AJAX fetching using HTMX
-
-
-{{end}}
-
-{{define "posts"}}
-
- {{- range .Data}}
-
-
-
-
-
-
-
-
-
- {{.Title}}
-
- {{.Body}}
-
-
-
-
- {{- end}}
-
-
- {{- if not $.Pager.IsBeginning}}
-
-
-
- {{- end}}
- {{- if not $.Pager.IsEnd}}
-
-
-
- {{- end}}
-
-
-{{end}}
-
-{{define "file-msg"}}
-
-
-
-
Serving files
-
-
-
- In the example posts above, check how the file URL contains a cache-buster query parameter which changes only when the app is restarted.
- Static files also contain cache-control headers which are configured via middleware.
- You can also use AlpineJS to dismiss this message.
-
-
-{{end}}
\ No newline at end of file
diff --git a/templates/pages/login.gohtml b/templates/pages/login.gohtml
deleted file mode 100644
index a2613bcc..00000000
--- a/templates/pages/login.gohtml
+++ /dev/null
@@ -1,28 +0,0 @@
-{{define "content"}}
-
-{{end}}
\ No newline at end of file
diff --git a/templates/pages/register.gohtml b/templates/pages/register.gohtml
deleted file mode 100644
index e55781fe..00000000
--- a/templates/pages/register.gohtml
+++ /dev/null
@@ -1,41 +0,0 @@
-{{define "content"}}
-
-{{end}}
\ No newline at end of file
diff --git a/templates/pages/reset-password.gohtml b/templates/pages/reset-password.gohtml
deleted file mode 100644
index 54445142..00000000
--- a/templates/pages/reset-password.gohtml
+++ /dev/null
@@ -1,24 +0,0 @@
-{{define "content"}}
-
-{{end}}
\ No newline at end of file
diff --git a/templates/pages/search.gohtml b/templates/pages/search.gohtml
deleted file mode 100644
index dc32fb61..00000000
--- a/templates/pages/search.gohtml
+++ /dev/null
@@ -1,5 +0,0 @@
-{{define "content"}}
- {{- range .Data}}
- {{.Title}}
- {{- end}}
-{{end}}
\ No newline at end of file
diff --git a/templates/pages/task.gohtml b/templates/pages/task.gohtml
deleted file mode 100644
index 6436c48e..00000000
--- a/templates/pages/task.gohtml
+++ /dev/null
@@ -1,43 +0,0 @@
-{{define "content"}}
- {{- if not (eq .HTMX.Request.Target "task")}}
-
-
-
Submitting this form will create an ExampleTask in the task queue. After the specified delay, the message will be logged by the queue processor.
-
See pkg/tasks and the README for more information.
-
-
- {{- end}}
-
- {{template "form" .}}
-{{end}}
-
-{{define "form"}}
-
-{{end}}
\ No newline at end of file
diff --git a/templates/templates.go b/templates/templates.go
deleted file mode 100644
index 2162a81d..00000000
--- a/templates/templates.go
+++ /dev/null
@@ -1,55 +0,0 @@
-package templates
-
-import (
- "embed"
- "io/fs"
- "os"
- "path"
- "path/filepath"
- "runtime"
-)
-
-type (
- Layout string
- Page string
-)
-
-const (
- LayoutMain Layout = "main"
- LayoutAuth Layout = "auth"
- LayoutHTMX Layout = "htmx"
-)
-
-const (
- PageAbout Page = "about"
- PageCache Page = "cache"
- PageContact Page = "contact"
- PageError Page = "error"
- PageFiles Page = "files"
- PageForgotPassword Page = "forgot-password"
- PageHome Page = "home"
- PageLogin Page = "login"
- PageRegister Page = "register"
- PageResetPassword Page = "reset-password"
- PageSearch Page = "search"
- PageTask Page = "task"
-)
-
-//go:embed *
-var templates embed.FS
-
-// Get returns a file system containing all templates via embed.FS
-func Get() embed.FS {
- return templates
-}
-
-// GetOS returns a file system containing all templates which will load the files directly from the operating system.
-// This should only be used for local development in order to facilitate live reloading.
-func GetOS() fs.FS {
- // Gets the complete templates directory path
- // This is needed in case this is called from a package outside of main, such as within tests
- _, b, _, _ := runtime.Caller(0)
- d := path.Join(path.Dir(b))
- p := filepath.Join(filepath.Dir(d), "templates")
- return os.DirFS(p)
-}
diff --git a/templates/templates_test.go b/templates/templates_test.go
deleted file mode 100644
index 9b28e348..00000000
--- a/templates/templates_test.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package templates
-
-import (
- "fmt"
- "testing"
-
- "github.com/stretchr/testify/require"
-)
-
-func TestGet(t *testing.T) {
- _, err := Get().Open(fmt.Sprintf("pages/%s.gohtml", PageHome))
- require.NoError(t, err)
-}
-
-func TestGetOS(t *testing.T) {
- _, err := GetOS().Open(fmt.Sprintf("pages/%s.gohtml", PageHome))
- require.NoError(t, err)
-}