https://github.com/russbaz/vaporhx
Vapor Extensions + HTMX
https://github.com/russbaz/vaporhx
htmx swift vapor
Last synced: about 1 year ago
JSON representation
Vapor Extensions + HTMX
- Host: GitHub
- URL: https://github.com/russbaz/vaporhx
- Owner: RussBaz
- License: mit
- Created: 2023-11-05T14:20:42.000Z (over 2 years ago)
- Default Branch: master
- Last Pushed: 2024-12-15T23:11:11.000Z (over 1 year ago)
- Last Synced: 2025-05-03T16:17:50.191Z (about 1 year ago)
- Topics: htmx, swift, vapor
- Language: Swift
- Homepage:
- Size: 188 KB
- Stars: 51
- Watchers: 4
- Forks: 4
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# VaporHX - Swift Vapor + Htmx + Extensions
**VaporHX** is a collection of Htmx and other extensions that I made when started working with htmx in a personal project (and thus they can be quite opinionated). In any case, please feel free to discuss any changes, as I am open to new and convincing ideas.
NOTE: All the HTMX parts are fully functional and they are actively used by me, even if the documentation is incomplete. I am sorry about this but it writing docs is incredibly tedious.
## Core Idea
The core idea is that you can combine your existing API endpoints with HTMX endpoints with minimal effort. The response will depend on the value of the request `Accept` header and the request method.
All you need to do is to call the `hx(template: String)` method on your `Content` struct and return its value. It will automatically pick the appropriate response, whether it is JSON encoded data, a full HTML page or an HTMX fragment. When HTML (HTMX) is returned, your content is injected into the specified template as a context. It uses the Leaf templating engine by default.
However, you do not have to use the Leaf engine if you do not want to. This package defines `HXTemplateable` protocol with a single render method that returns an html page as a string. For as long as your own templating engine implements it, you can pass its type into the `hx(template:)` method instead.
```swift
import VHX
// Do NOT forget to call 'configureHtmx' in your 'configure' method before trying this snippet in your project
// Also, do NOT create a folder called '--page' in your template root without changing the default VHX settings
// as it is used as a prefix for dynamically generated wrapper page templates
// and the custom template provider is the last one to be checked after the default ones are run
// Furthermore, this snippet assumes the default leaf naming conventions as they can be manually overriden
struct MyApi: Content {
let name: String
}
func routes(_ app: Application) throws {
// Combined API and HTMX endpoint
// 'api.leaf' template must exist
app.get("api") { req in
MyApi(name: "name").hx(template: "api")
// The return type of this function call is 'HX'
}
// HTMX only endpoint
// 'index.leaf' template must exist
// It will automatically select whether to return a full page or only a fragment if the generic page template was configured.
// Otherwise, it will simply render the 'index.leaf' template
app.get { req in
try await req.htmx.render("index")
}
// Or an even quicker definition of simple 'static' routes
app.get("static", use: staticRoute(template: "my-static-template"))
}
```
Basic configuration (`configure.swift`):
```swift
import Vapor
import VHX
// Basic config configures Leaf engine but you are not required to use it
// Basic config assumes 'index-base.leaf' template exists and that it contains '#import("body")' tag
// It will generate a dynamic template that wraps the content of the specified template for NON-htmx calls to 'htmx.render'
// It will simply plug the provided template into the 'body' slot of the base template
public func configure(_ app: Application) async throws {
let config = HtmxConfiguration.basic()
try configureHtmx(app, configuration: config)
}
```
## Table of Contents
- [What is HTMX?](#what-is-htmx)
- [HTMX](#htmx)
- [Installation](#installation)
- [Configuration](#configuration)
- [HX Request Extensions](#hx-request-extensions)
- [HX Extension Method and HX\](#hx-extension-method-and-hxmytype)
- [HX Templateable and Custom Templating Engines](#hx-templateable-and-custom-templating-engines)
- [Request Headers](#request-headers)
- [Response Headers](#response-headers)
- [Overview](#overview)
- [Location](#location)
- [Push Url](#push-url)
- [Redirect](#redirect)
- [Refresh](#refresh)
- [Replace Url](#htmx)
- [Reselect](#htmx)
- [Reswap](#htmx)
- [Retarget](#htmx)
- [Trigger, Trigger After Settle and Trigger After Swap](#htmx)
- [HXError, Abort and HXErrorMiddleware](#htmx)
- [HXRedirect](#htmx)
- [Simple Localisation](#htmx)
- [Configuration](#htmx)
- [HXLocalisable Protocol and HXLocalisation](#htmx)
- [HXRequestLocalisation](#htmx)
- [Custom HXTextTag Leaf Tag](#htmx)
- [Other Utilities](#htmx)
- [String + View](#htmx)
- [Date + Custom Interval](#htmx)
- [Request + Base Url](#htmx)
- [staticRoute Helper](#htmx)
- [Changelog](#htmx)
- [HTMX Demo](#htmxleaf-demo)
## What is HTMX?
Here is my hot take: Make your backend code the single source of truth for your project, and drop most of your front end bloat in favour of updating your HTML in-place and seamlessly. Without reloading the page and with only your server side HTML templates. Learn more at [htmx.org](https://htmx.org/).
And here is the official intro:
Lastly, here is a quick introduction to HTMX by `Fireship`: [htmx in 100 seconds](https://www.youtube.com/watch?v=r-GSGH2RxJs).
## HTMX
### Installation
SPM installation:
- Add the package to your package dependencies
```swift
.package(url: "https://github.com/RussBaz/VaporHX.git", from: "0.0.26"),
```
- Then add it to your target dependencies
```swift
.product(name: "VHX", package: "VaporHX"),
```
### Configuration
Assuming the standard use of `configure.swift' in all the following examples.
The simplest config usign the Leaf engine (without localisation helpers):
```swift
import Vapor
import VHX
// Basic config configures Leaf engine but you are not required to use it
// Basic config assumes 'index-base.leaf' template exists and that it contains '#import("body")' tag
// It will generate a dynamic template that wraps the content of the specified template for NON-htmx calls to 'htmx.render'
// It will simply plug the provided template into the 'body' slot of the base template
public func configure(_ app: Application) async throws {
// other configuration
let config = HtmxConfiguration.basic()
try configureHtmx(app, configuration: config)
// more configuration
}
```
Please note that the default wrapper allows dynamically changing the base template name and the slot name. Please refer to the `render` function.
Otherwise, if you want to specify your own htmx page wrapper (plugs the provided template name into a dynamically generated page on NON-HTMX requests):
```swift
// The most straightforward configuration
import Vapor
import VHX
// Defining the page dynamic template generator separately
// Check the 'HXBasicLeafSource' later in this section for further details
func pageTemplate(_ template: String) -> String {
"""
#extend("index-base"): #export("body"): #extend("\(template)") #endexport #endextend
"""
}
public func configure(_ app: Application) async throws {
// Other configuration
// HTMX configuration also enables leaf templating language
try configureHtmx(app, pageTemplate: pageTemplate)
// Later configuration and routes registration
}
```
Here are all the signatures:
```swift
func configureHtmx(_ app: Application, pageTemplate template: ((_ name: String) -> String)? = nil) throws
// or
func configureHtmx(_ app: Application, configuration: HtmxConfiguration) throws
// -------------------------------------------- //
// This struct stores globally available (through the Application) htmx configuration
struct HtmxConfiguration {
var pageSource: HXLeafSource
// A header name that will be copied back from the request when HXError is thrown
// The header type must be UInt, otherwise 0 is returned
// Should be used by the client when retrying
var errorAttemptCountHeaderName: String?
// Possible ways to init the configuration structure
init()
init(pagePrefix prefix: String)
init(pagePrefix prefix: String = "--page", pageTemplate template: @escaping (_ name: String) -> String)
init(pageSource: HXLeafSource, errorAttemptCountHeaderName: String? = nil)
// Default basic configuration
static func basic(pagePrefix prefix: String = "--page", baseTemplate: String = "index-base", slotName: String = "body") -> Self
}
```
`HXLeafSource` is used to generate a dynamic template that is used for wrapping HTMX fragments with the rest of the page content when it is accessed through a normal browser request.
It satisfies the following protocol:
```swift
protocol HXLeafSource: LeafSource {
var pagePrefix: String { get }
}
```
Where `LeafSource` is a special `Leaf` protocol designed for customising how leaf templates are discovered.
Then **VaporHX** implements its implementation of this specialised protocol.
```swift
struct HXBasicLeafSource: HXLeafSource {
let pagePrefix: String
// This is our custom template generator
let pageTemplate: (_ name: String) -> String
}
```
In order to manually initialise this struct, please use the following functions:
```swift
func hxPageLeafSource(prefix: String = "--page", template: ((_ name: String) -> String)?) -> HXLeafSource
func hxBasicPageLeafSource(prefix: String = "--page", baseTemplate: String = "index-base", slotName: String = "body") -> HXLeafSource
```
In our case the default `pagePrefix` value is `--page`. Therefore, everytime you ask `leaf` for a template prefixed with `--page/` (please do not miss `/` after the prefix, it is always required), the default `HXBasicLeafSource` will return a template generated by the `pageTemplate` closure. Everything after the prefix with `/` will be passed into the page template generator and the result of this function should be a valid `leaf` template as a string.
The value passed to the `pageTemplate` method must not be empty. If it is, then `HXBasicLeafSource` will return a 'not found' error.
Lastly, this `LeafSource` implementation is registered as a last leaf source, and this means that the default search path is fully preserved.
### HX Request Extensions
How to check if the incoming request is an HTMX request?
```swift
// Check this extensions property on the 'Request' object
req.htmx.prefered // Bool
// And if you want more more accuracy ...
req.htmx.prefers // Preference
// -------------------------------------------- //
// HTMX case implies an HTMX fragment
// HTML case implies standard browser request
// API case implies json api request
enum Preference {
case htmx, html, api
}
```
How to automatically decide if you need to render an HTMX fragment or a full page?
```swift
// Try this method on the 'req.htmx' extension
// This method tries to mimic the 'req.view.render' api
// but it also can accept optional HXResponseHeaders
// Setting the 'page' parameter to true will force the server to always return a full page or a page fragment only otherwise
func render(_ name: String, _ context: some Encodable, page: Bool? = nil, headers: HXResponseHeaders? = nil) async throws -> Response
func render(_ name: String, page: Bool? = nil, headers: HXResponseHeaders? = nil) async throws -> Response
// If you are using a custom templating engine, then you should use this method
func render(_ template: T.Type, _ context: T.Context, page: Bool? = nil, headers: HXResponseHeaderAddable? = nil) async throws -> Response
```
Furthermore, if you are using the default template generator, you can manually override the base template name and the slot name. Here is an example how it can be done:
```swift
// Use square brackets at the beginning of the name to override default base template and slot names
routes.get("template") { req in
// Just square brackets without colons to override base template name only
try await req.htmx.render("[index-custom]name")
}
routes.get("slot") { req in
// Use the following format to override a slot name: [template:slot]
// Using multiple colons will result in an error
try await req.htmx.render("[index-custom:extra]name")
}
```
To add an HTMX specific header to a response, you can update `response.headers` extension:
```swift
req.htmx.response.headers // HXResponseHeaders
```
Otherwise, you can provide a header to any `render` method. It will override any previously specified headers.
To learn more about the `HXResponseHeaders`, please refere to the [Response Headers](#response-headers) section.
How to redirect quickly with proper HTMX headers?
```swift
// The simplest type of redirect
func redirect(to location: String, htmx: HXRedirect.Kind = .redirect, html: Redirect = .normal, refresh: Bool = false) async throws -> Response
// A helper that looks for a query parameter by the 'key' value and redirects to it
func autoRedirect(key: String = "next", htmx: HXRedirect.Kind = .redirect, html: Redirect = .normal, refresh: Bool = false) async throws -> Response
// A helper that looks for a query parameter by the 'key' value
// And then redirects to a 'through location' while preserving the query parameter from the first step during the redirect
// e.g. it can redirect from '/redirect?next=/dashboard/' to '/login?next=/dashboard/'
// by making 'through' equal to '/login'
func autoRedirect(through location: String, key: String = "next", htmx: HXRedirect.Kind = .redirect, html: Redirect = .normal, refresh: Bool = false) async throws -> Response
// A helper that redirects to 'from location' while adding the current url as query parameter with a name specified by the 'key'
// It preserves query parameteres from the original url
// e.g from /dashboard/ to /login?next=/dashboard/
// by making 'from' equal to '/login'
func autoRedirectBack(from location: String, key: String = "next", htmx: HXRedirect.Kind = .redirect, html: Redirect = .normal, refresh: Bool = false) async throws -> Response
// -------------------------------------------- //
// HXRedirect.Kind
enum Kind {
case redirect
case redirectAndPush
case redirectAndReplace
}
// What is a 'Redirect' type?
// It is simply the default 'Vapor' type which you use with the 'req.redirect'
```
### HX Extension Method and HX\
The inbuilt `Content` type was extended with an `hx()` method. This is the secret ingredient that adds automatic HTMX support to the standard responses. In addition, some other types have been extended, such as `HTTPStatus` and `Abort`.
Here is how it works:
```swift
// This is a slightly simplified version of this extension method declaration
extension Content where Self: AsyncResponseEncodable & Encodable {
func hx(template name: String? = nil, page: Bool? = nil, headers: HXResponseHeaders? = nil) -> HX
// For custom templating engines
func hx(template: T.Type, page: Bool? = nil, headers: HXResponseHeaders? = nil) -> HX where T.Context == Self
}
// And this is how you would use it
app.get("api") { req in
// Where 'MyApi' is some Content
MyApi(name: "name").hx(template: "api")
// The return type of this function call is 'HX'
}
```
One should not normally deal with the `HX` struct directly but in case it is ever needed, here is its definition:
```swift
typealias TemplateRenderer = (_ req: Request, _ context: T, _ page: Bool?, _ headers: HXResponseHeaders?) async throws -> Response
struct HX {
let context: T
let template: TemplateRenderer?
let page: Bool?
let htmxHeaders: HXResponseHeaders?
}
```
### HX Templateable and Custom Templating Engines
If you would like to use your own templating engine, then each renderer should implement the following protocol:
```swift
protocol HXTemplateable {
associatedtype Context: AsyncResponseEncodable & Encodable
static func render(req: Request, isPage: Bool, context: Context) -> String
}
```
If this protocol is not enough for your use case, please open an issue and we can discuss it there.
### Request Headers
```swift
// 'HTMX' request header getter on every request
req.htmx.headers
// Request header structure
// For the meaning of value of each header, please refer to the 'HTMX' docs
struct HXRequestHeaders {
let boosted: Bool
let currentUrl: String?
let historyRestoreRequest: Bool
let prompt: Bool
let request: Bool
let target: String?
let triggerName: String?
let trigger: String?
}
```
### Response Headers
#### Overview
`HTMX` headers are defined as structs with a simple inbuilt validation. They can be added as individual headers to a `Response` object or as a whole collection by using `HXResponseHeaders` struct. The latter struct can be passed to `htmx` specific functions as an optional parameter, such as `.htmx.render` or `.hx`
```swift
// Cleaned up definition
struct HXResponseHeaders {
var location: HXLocationHeader?
var pushUrl: HXPushUrlHeader?
var redirect: HXRedirectHeader?
var refresh: HXRefreshHeader?
var replaceUrl: HXReplaceUrlHeader?
var reselect: HXReselectHeader?
var reswap: HXReswapHeader?
var retarget: HXRetargetHeader?
var trigger: HXTriggerHeader?
var triggerAfterSettle: HXTriggerAfterSettleHeader?
var triggerAfterSwap: HXTriggerAfterSwapHeader?
}
// Example usages
// With 'hx' extension
app.get("api") { req in
let headers = HXResponseHeaders(retarget: HXRetargetHeader("#content"))
return MyApi(name: "name").hx(template: "api", headers: headers)
}
// With 'htmx.render' function
app.get("example") { req in
let headers = HXResponseHeaders(retarget: HXRetargetHeader("#content"))
return req.htmx.render("example", headers: headers)
}
// With 'add' extension method on 'Response'
// Can be used with the whole container ('HXResponseHeaders') or with individual headers (such as 'HXRetargetHeader')
// Later header values will replace earlier headers
app.get("redirect") { req in
req.htmx.autoRedirect(through: "/login", html: .temporary).add(headers: HXResponseHeaders())
}
```
#### Location
`HXLocationHeader` is a type-safe constructor for a `HX-Location` response header. It is the most complicated response header in my opinion.
```swift
struct HXLocationHeader {
let location: HXLocationType
}
enum HXLocationType {
case simple(String) // Header value is just a string
case custom(HXCustomLocation) // Header value is json
}
struct HXCustomLocation: Encodable {
let path: String
let target: String?
let source: String?
let event: String?
let handler: String?
let swap: HXReswapHeader?
let values: [String]?
let headers: [String: String]?
}
```
Because of the sheer volume of possible options, there is a 'fluent' api to generate a new header struct with updated property values, such as:
```swift
func set(target newTarget: String) -> Self
func set(source newSource: String?) -> Self
func set(event newEvent: String?) -> Self
func set(handler newHandler: String?) -> Self
func set(swap newSwap: HXReswapHeader?) -> Self
func set(values newValues: [String]?) -> Self
func set(headers newHeaders: [String: String]?) -> Self
// Removes all other properties and sets the location property to simple
func makeSimple(_ path: String? = nil) -> Self
```
A few examples of how to create a simple and more complicated headers:
```swift
let header1 = HXLocationHeader("/test") // reuslts in "HX-Location: /test"
let header2 = HXLocationHeader("/test2", target: "#testdiv") // results in "HX-Location: {"path":"/test2", "target":"#testdiv"}"
// and so on
```
#### Push Url
`HXPushUrlHeader` is a type-safe constructor for a `HX-Push-Url` response header.
```swift
struct HXPushUrlHeader {
let url: HXPushType
}
enum HXPushType {
case disable
case enable(String) // where the string should contain the new value to be pushed onto the browser location history stack
}
```
A few examples:
```swift
let header1 = HXPushUrlHeader(url: .disable) // results in "HX-Push-Url: false"
let header2 = HXPushUrlHeader("/test") // results in "HX-Push-Url: /test"
```
#### Redirect
`HXRedirectHeader` is a type-safe constructor for a `HX-Redirect` response header. Please do not confuse this response header with the `HXRedirect` structure.
It is extremely simple:
```swift
struct HXRedirectHeader {
let location: String
}
// sample usage
let header = HXRedirectHeader("/test") // results in "HX-Redirect: /test"
```
#### Refresh
To be continued...
## HTMX+Leaf Demo
For those new to HTMX, this package comes bundled with an HTMX Demo that you can run locally. It consists of a few examples from the [HTMX.org](https://htmx.org/examples/) website.
This is a showcase of some HTMX features built with the Leaf templating engine and VaporHX. It is not a showcase of all the capabilities of this library, but a good introduction to HTMX. It is also not a production ready example.
#### To run the demo using Xcode
- Open this project in Xcode
- Switch the scheme from `VHX` to `Demo` (left hand side of the top url bar)
- Confirm that the correct Run Target is selected (likely 'My Mac')
- Set the custom working directory in the scheme editor to the root folder of this package as shown in the [Vapor docs](https://docs.vapor.codes/getting-started/xcode/). Otherwise, it will display a warning in the console and it will be unable to find leaf templates.
- Press the play / run button
- Then head to `http://localhost:8080`
#### To run the demo using the command line
- execute the following from the VaporHX projects root dir
```bash
swift run Demo
```