Migrate from templates to Gomponents (#103)

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

773
README.md
View file

@ -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 **<ins>not required to use any of these</ins>**. 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
- Cache
- Database
- ORM
- Web
- Validator
- Authentication
- Mail
- Template renderer
- Tasks
- Cache
- Configuration
- Database
- Files
- Mail
- ORM
- Tasks
- 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,35 +497,290 @@ assert.Len(t, h1.Nodes, 1)
assert.Equal(t, "About", h1.Text())
```
## Pages
## User interface
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`.
### Why Gomponents?
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.
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.
As you develop your application, the `Page` can be easily extended to include whatever data or functions you want to provide to your templates.
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.
Initializing a new page is simple:
[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.
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.
### HTMX support
[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.
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.
All of this can be easily accomplished without writing any JavaScript at all.
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.
#### Header management
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_.
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.
From within your _ui_, the [Request](#request) object will automatically contain the request details on the `Htmx` field.
#### Conditional and partial rendering
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.
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).
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
func (c *home) Get(ctx echo.Context) error {
p := page.New(ctx)
if htmx.GetRequest(ctx).Target == "search" {
// This request is HTMX fetching content just for the #search element
}
```
Using the `echo.Context`, the `Page` will be initialized with the following fields populated:
#### CSRF token
- `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
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).
### Request
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.
`NewRequest()` will automatically populate the following fields using the `echo.Context` from the current request:
- `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
- `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)
- `Htmx`: Data from the HTMX headers, if HTMX made the request
- `Config`: The application configuration, if the middleware is being used
#### Title and metatags
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:
* `Title`: The page title
* `Metatags`:
* `Description`: The description of the page
* `Tags`: A slice of keyword tags
#### URL generation
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.
The methods both take a route name and optional variadic route parameters:
* `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.
**Example:**
```go
g.GET("/user/:uid", profilePage).Name = routenames.Profile
```
```go
func ProfileLink(r *ui.Request, userName string, userID int64) gomponents.Node {
return A(
Class("profile"),
Href(r.Path(routenames.Profile, userID)),
Text(userName),
)
}
```
### Components
The [components package](https://github.com/mikestefanello/pagoda/tree/templates/pkg/ui/components) is meant to be your library of reusable _gomponent_ components. Having this makes building your [layouts](#layouts), [pages](#pages), [forms](#forms), [models](#models) and the rest of your user interface much easier. Some of the examples provided include components for [flash messages](#flash-messaging), navigation menus, tabs, metatags, and form elements used to automatically provide [inline validation](#inline-form-validation).
Your components can also make using utility-based CSS libraries, such as [Tailwind CSS](https://tailwindcss.com/), much easier by avoiding excessive duplication of classes across elements.
### Layouts
_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.
### Pages
_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.
#### Rendering
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
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)
}
```
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`.
And from within your [route handler](#handlers), simply:
```go
func (e *ExampleHandler) Page(ctx echo.Context) error {
return pages.MyPage(ctx, "abcd")
}
```
### Forms
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).
Start by declaring the form within the [forms](https://github.com/mikestefanello/pagoda/tree/templates/pkg/ui/forms) package:
```go
type Guestbook struct {
Message string `form:"message" validate:"required"`
form.Submission
}
```
Embedding `form.Submission` satisfies the `form.Form` interface and handles submissions and validation for you.
Next, provide a method that renders the form:
```go
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),
)
}
```
Then, create a _page_ that includes your form:
```go
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)
}
```
And last, have your handler render the _page_ in a route, and provide a route for the submission.
```go
func (e *Example) Routes(g *echo.Group) {
g.GET("/guestbook", e.Page).Name = routenames.Guestbook
g.POST("/guestbook", c.PageSubmit).Name = routenames.GuestbookSubmit
}
func (e *Example) Page(ctx echo.Context) error {
return pages.UserGuestbook(ctx, form.Get[forms.Guestbook](ctx))
}
```
`form.Get` will either initialize a new form, or load one previously stored in the context (ie, if it was already submitted).
#### Submission processing
Using the example form above, this is all you would have to do within the _POST_ callback for your route:
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.
Then, evaluate the error returned, if one, and process the form values however you need to:
```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
}
msg.Success(fmt.Sprintf("Your message was: %s", input.Message))
return redirect.New(ctx).
Route(routenames.Home).
Go()
}
```
#### Inline validation
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.
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.
#### CSRF
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.
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.
### Models
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.
### Node caching
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.
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.
The cache functions are available in `pkg/ui/cache` and can most easily used like this:
```go
func SearchModal() gomponents.Node {
return cache.SetIfNotExists("searchModal", func() gomponents.Node {
return Div(...your entire nested node...)
})
}
```
`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()`.
`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.
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.
### Flash messaging
@ -545,380 +796,30 @@ There are four types of messages, and each can be created as follows:
- 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.
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.
A [component](#components), `FlashMessages()`, is provided to render flash messages within your UI.
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.
## Pager
### Pager
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.
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.
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.
Methods include:
- `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
- `GetOffset()`: Get the offset which can be useful in constructing a paged database query
There is currently no template (yet) to easily render a pager.
### CSRF
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.
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.
### Automatic template parsing
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:
- `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.
Specifying which templates to render for a given `Page` is as easy as:
```go
page.Name = "home"
page.Layout = "main"
```
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
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.
### Forms
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.
An example of this pattern is:
```go
type ContactForm struct {
Email string `form:"email" validate:"required,email"`
Message string `form:"message" validate:"required"`
form.Submission
}
```
Embedding `form.Submission` satisfies the `form.Form` interface and makes dealing with submissions and validation extremely easy.
Then in your page:
```go
p := page.New(ctx)
p.Form = form.Get[ContactForm](ctx)
```
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.
#### Submission processing
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`.
Using the example form above, this is all you would have to do within the _POST_ callback for your route:
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.
```go
var input ContactForm
err := form.Submit(ctx, &input)
```
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
}
```
And finally, your template:
```html
<form id="contact" method="post" hx-post="{{url "contact.post"}}">
<input id="email" name="email" type="email" class="input" value="{{.Form.Email}}">
<input id="message" name="message" type="text" class="input" value="{{.Form.Message}}">
</form
```
#### Inline validation
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 your templates can use to provide classes and extract the error messages.
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.
To provide the inline validation in your template, there are two things that need to be done.
First, include a status class on the element so it will highlight green or red based on the validation:
```html
<input id="email" name="email" type="email" class="input {{.Form.GetFieldStatusClass "Email"}}" value="{{.Form.Email}}">
```
Second, render the error messages, if there are any for a given field:
```go
{{template "field-errors" (.Form.GetFieldErrors "Email")}}
```
### Headers
HTTP headers can be set either via the `Page` or the _context_:
```go
p := page.New(ctx)
p.Headers["HeaderName"] = "header-value"
```
```go
ctx.Response().Header().Set("HeaderName", "header-value")
```
### Status code
The HTTP response status code can be set either via the `Page` or the _context_:
```go
p := page.New(ctx)
p.StatusCode = http.StatusTooManyRequests
```
```go
ctx.Response().Status = http.StatusTooManyRequests
```
### Metatags
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.
```go
p := page.New(ctx)
p.Metatags.Description = "The page description."
p.Metatags.Keywords = []string{"Go", "Software"}
```
A _component_ template is included to render metatags in `core.gohtml` which can be used by adding `{{template "metatags" .}}` to your _layout_.
### URL and link generation
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"
```
And you want to generate a URL in the template, you can:
```go
{{url "user_profile" 1}
```
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.
```go
{{link (url "user_profile" .AuthUser.ID) "Profile" .Path "extra-class"}}
```
Will generate:
```html
<a href="/user/profile/1" class="is-active extra-class">Profile</a>
```
Assuming the current _path_ is `/user/profile/1`; otherwise the `is-active` class will be excluded.
### HTMX support
[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.
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.
All of this can be easily accomplished without writing any JavaScript at all.
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.
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_.
If you need to set any HTMX headers in your `Page` response, this can be done by altering `Page.HTMX.Response`.
#### Layout template override
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.
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.
#### Conditional processing / rendering
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.
A simple example of this:
```go
if page.HTMX.Request.Target == "search" {
// You know this request HTMX is fetching content just for the #search element
}
```
```go
{{if eq .HTMX.Request.Target "search"}}
// Render content for the #search element
{{end}}
```
#### CSRF token
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).
### Rendering the page
Once your `Page` is fully built, rendering it via the embedded `TemplateRenderer` in your _handler_ can be done simply by calling `RenderPage()`:
```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)
}
```
## Template renderer
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`.
Here is an example of a complex rendering that uses multiple template files as well as an entire directory of template files:
```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()
```
```go
tpl, err := c.TemplateRenderer.Load("my-group", "my-key")
buf, err := tpl.Execute(data)
```
### Custom functions
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.
### Caching
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.
### Hot-reload for development
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.
### File configuration
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.
## Funcmap
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/).
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
<img src="{{file "picture.png"}}"/>
```go
return Img(Src(ui.File("picture.png")))
```
Which would result in:
```html
<img src="/files/picture.png?v=9fhe73kaf3"/>
<img src="/files/picture.png?v=1741053493"/>
```
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)