VaporHX - Swift Vapor + Htmx + 扩展

VaporHX 是 Htmx 和其他扩展的集合,是我在个人项目中开始使用 htmx 时创建的(因此可能带有一些个人偏好)。无论如何,欢迎讨论任何更改,我对新的和有说服力的想法持开放态度。

注意:所有 HTMX 部分都完全可用,并且我正在积极使用它们,即使文档不完整。 对此我深感抱歉,但编写文档确实非常繁琐。

核心思想

核心思想是,您可以以最小的努力将现有的 API 端点与 HTMX 端点结合起来。 响应将取决于请求的 Accept 标头和请求方法的值。

您所需要做的就是在您的 Content 结构体上调用 hx(template: String) 方法并返回其值。 它将自动选择适当的响应,无论是 JSON 编码的数据、完整的 HTML 页面还是 HTMX 片段。 当返回 HTML (HTMX) 时,您的内容将作为上下文注入到指定的模板中。 默认情况下,它使用 Leaf 模板引擎。

但是,如果您不想使用 Leaf 引擎,则不必这样做。 这个包定义了 HXTemplateable 协议,它有一个单独的 render 方法,该方法将 HTML 页面作为字符串返回。 只要您自己的模板引擎实现了它,您就可以将其类型传递给 hx(template:) 方法。

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<MyApi>'
    }

    // 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"))
}

基本配置 (configure.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)
}

目录

什么是 HTMX?

这是我的个人看法:让您的后端代码成为项目的单一事实来源,放弃大部分前端膨胀,转而支持就地无缝更新您的 HTML。 无需重新加载页面,只需您的服务器端 HTML 模板。 在 htmx.org 了解更多信息。

这是官方介绍

通过删除这些任意约束,htmx 完成了 HTML 作为超文本

最后,这是 Fireship 对 HTMX 的快速介绍:htmx in 100 seconds

HTMX

安装

SPM 安装

.package(url: "https://github.com/RussBaz/VaporHX.git", from: "0.0.26"),
.product(name: "VHX", package: "VaporHX"),

配置

假设在以下所有示例中都标准使用了 `configure.swift`。

使用 Leaf 引擎的最简单配置(没有本地化助手)

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
}

请注意,默认包装器允许动态更改基本模板名称和槽名称。 请参阅 render 函数。

否则,如果您想指定自己的 htmx 页面包装器(在 NON-HTMX 请求上将提供的模板名称插入到动态生成的页面中)

// 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
}

这是所有签名

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 用于生成一个动态模板,当通过普通浏览器请求访问时,该模板用于将 HTMX 片段与页面的其余内容包装在一起。

它满足以下协议

protocol HXLeafSource: LeafSource {
  var pagePrefix: String { get }
}

其中 LeafSource 是一个特殊的 Leaf 协议,专用于自定义 Leaf 模板的发现方式。

然后 VaporHX 实现此专用协议的实现。

struct HXBasicLeafSource: HXLeafSource {
  let pagePrefix: String
  // This is our custom template generator
  let pageTemplate: (_ name: String) -> String
}

为了手动初始化此结构体,请使用以下函数

func hxPageLeafSource(prefix: String = "--page", template: ((_ name: String) -> String)?) -> HXLeafSource
func hxBasicPageLeafSource(prefix: String = "--page", baseTemplate: String = "index-base", slotName: String = "body") -> HXLeafSource

在我们的例子中,默认的 pagePrefix 值为 --page。 因此,每次您向 leaf 请求以 --page/ 为前缀的模板(请不要错过前缀后的 /,始终需要),默认的 HXBasicLeafSource 将返回由 pageTemplate 闭包生成的模板。 前缀后 / 的所有内容将传递到页面模板生成器中,并且此函数的结果应该是作为字符串的有效 leaf 模板。

传递给 pageTemplate 方法的值不能为空。 如果为空,则 HXBasicLeafSource 将返回“未找到”错误。

最后,此 LeafSource 实现注册为最后一个 Leaf 源,这意味着默认搜索路径已完全保留。

HX 请求扩展

如何检查传入的请求是否为 HTMX 请求?

// 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
}

如何自动决定是否需要渲染 HTMX 片段或完整页面?

// 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<T: HXTemplateable>(_ template: T.Type, _ context: T.Context, page: Bool? = nil, headers: HXResponseHeaderAddable? = nil) async throws -> Response

此外,如果您使用的是默认模板生成器,则可以手动覆盖基本模板名称和槽名称。 这是一个如何完成的例子

// 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")
}

要将 HTMX 特定标头添加到响应中,您可以更新 response.headers 扩展

req.htmx.response.headers // HXResponseHeaders

否则,您可以为任何 render 方法提供标头。 它将覆盖任何先前指定的标头。

要了解有关 HXResponseHeaders 的更多信息,请参阅 响应标头 部分。

如何使用正确的 HTMX 标头快速重定向?

// 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 扩展方法和 HX<MyType>

内置的 Content 类型已使用 hx() 方法进行了扩展。 这是为标准响应添加自动 HTMX 支持的秘诀。 此外,还扩展了一些其他类型,例如 HTTPStatusAbort

这是它的工作方式

// 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<Self>

  // For custom templating engines
  func hx<T: HXTemplateable>(template: T.Type, page: Bool? = nil, headers: HXResponseHeaders? = nil) -> HX<Self> 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<MyApi>'
}

通常不应直接处理 HX 结构体,但如果需要,这是其定义

typealias TemplateRenderer = (_ req: Request, _ context: T, _ page: Bool?, _ headers: HXResponseHeaders?) async throws -> Response

struct HX<T: AsyncResponseEncodable & Encodable> {
  let context: T
  let template: TemplateRenderer?
  let page: Bool?
  let htmxHeaders: HXResponseHeaders?
}

HX Templateable 和自定义模板引擎

如果您想使用自己的模板引擎,则每个渲染器都应实现以下协议

protocol HXTemplateable {
    associatedtype Context: AsyncResponseEncodable & Encodable

    static func render(req: Request, isPage: Bool, context: Context) -> String
}

如果此协议不足以满足您的用例,请打开一个 issue,我们可以在那里讨论。

请求标头

// '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?
}

响应标头

概述

HTMX 标头定义为具有简单内置验证的结构。 它们可以作为单独的标头添加到 Response 对象中,也可以通过使用 HXResponseHeaders 结构体作为整体集合添加。 后一个结构体可以作为可选参数传递给 htmx 特定函数,例如 .htmx.render.hx

// 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

HXLocationHeaderHX-Location 响应标头的类型安全构造函数。 我认为这是最复杂的响应标头。

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]?
}

由于可能选项的数量庞大,因此存在一个“流畅”的 API 来生成具有更新的属性值的新标头结构,例如

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

一些创建简单和更复杂的标头的示例

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

HXPushUrlHeaderHX-Push-Url 响应标头的类型安全构造函数。

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
}

一些例子

let header1 = HXPushUrlHeader(url: .disable) // results in "HX-Push-Url: false"
let header2 = HXPushUrlHeader("/test") // results in "HX-Push-Url: /test"

Redirect

HXRedirectHeaderHX-Redirect 响应标头的类型安全构造函数。 请不要将此响应标头与 HXRedirect 结构混淆。

它非常简单

struct HXRedirectHeader {
    let location: String
}

// sample usage
let header = HXRedirectHeader("/test") // results in "HX-Redirect: /test"

Refresh

未完待续...

HTMX+Leaf 演示

对于 HTMX 的新手,此包捆绑了一个 HTMX 演示,您可以在本地运行。 它包含来自 HTMX.org 网站的一些示例。

这是使用 Leaf 模板引擎和 VaporHX 构建的一些 HTMX 功能的展示。 它不是此库所有功能的展示,而是对 HTMX 的一个很好的介绍。 它也不是一个生产就绪的示例。

使用 Xcode 运行演示

使用命令行运行演示

swift run Demo