Leaf Custom Tags and HTML DSLs
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Vapor 4.122.2 on Swift 6.4 (the package builds in the Swift 6 language mode with tools
version 6.3, as the official template does; Vapor 4.122.2 itself needs tools version 6.0), with Leaf 4.5.2 (LeafKit
1.14.3), Elementary 0.8.2, ElementaryHTMX 0.5.2, VaporElementary 0.2.2, Plot 0.14.0 and swift-html 0.5.0, checked on
2026-10-17. Every Swift listing on this page was compiled in the Swift 6 language mode with zero warnings in a scratch
package, and the behaviour described was verified with a VaporTesting suite and with curl against the running service;
the output shown is captured from those runs. Elementary and VaporElementary declare macOS 14 as their minimum platform,
so a package that uses them needs platforms: [.macOS(.v14)]. The Vapor 5 situation is in
Version notes. The page extends the Bookshelf service of the earlier pages of this section: it renders the
catalog as HTML in four ways. Leaf itself (setup, syntax, layouts, the built-in tags) is on the
Leaf Templates page; here only what goes beyond it is covered. Swift is covered
by the Swift Reference, and the builder syntax that the DSLs rely on by
Result Builders. The same topic in Rust is in
Server-Side Templates and htmx.
Where it sits
Rendering HTML is the last step of a handler, before Vapor encodes the value the handler returned into a Response. A
Leaf view is produced by req.view.render(_:_:), which returns a View (a ResponseEncodable); a DSL page is
produced by your own code and returned as a type that conforms to AsyncResponseEncodable. Custom Leaf tags run
inside the render call, once for every place a template uses them. Nothing here is middleware: sessions
(Sessions and Cookies), CSRF protection and security headers
(Security Hardening) are separate layers that wrap the handler.
Custom Leaf tags
A Leaf tag is the #name(parameters) syntax of a template. Everything built in (#count, #date, #lowercased,
#contains, #isEmpty, #unsafeHTML, #comment, #dumpContext) is a LeafTag registered in a dictionary, and you
extend Leaf by adding your own entries to that dictionary. The protocol is one method:
// LeafKit 1.14.3 (abridged)
public protocol LeafTag {
func render(_ ctx: LeafContext) throws -> LeafData
}
public protocol UnsafeUnescapedLeafTag: LeafTag {}
Source: Vapor docs: Leaf custom tags
The two types that carry the data are small:
| Type | What it gives a tag |
|---|---|
|
The resolved arguments, |
|
The complete template context at the point of the call, |
|
|
|
|
|
Helpers that throw a |
|
The value type of parameters and results. Read it with |
A tag returns one LeafData. Whatever it returns is serialised into the output at the tag’s position; returning
.trueNil prints nothing (the built-in #comment does that). A tag cannot write to the output more than once, set
variables for the rest of the template, or produce several values.
Registering a tag
A tag is registered under the name the template uses, usually in configure(_:):
app.leaf.tags["now"] = NowTag()
Source: Vapor docs: Leaf custom tags
app.leaf.tags is a [String: any LeafTag] that starts as a copy of LeafKit’s defaultTags. Because it is a
dictionary, registering with an existing name replaces the built-in tag with that name, which is occasionally useful
and occasionally an accident. The renderer is built from the dictionary each time req.view or app.leaf.renderer is
read, so register before the first request. A tag registered under a name that a template does not use costs nothing; a
template that uses a name nobody registered does not fail, as the output below shows.
A complete set of tags
The listing below has the official #now example, a formatter, a tag that emits markup, one that reads the whole
context and the application, the escaping helper that markup tags need, and a tag with a body. They are used by the
template that follows it.
import Foundation
import Leaf
import LeafKit
import Vapor
// MARK: - The official docs example: #now()
enum NowTagError: Error {
case invalidFormatParameter
case tooManyParameters
}
struct NowTag: LeafTag {
func render(_ ctx: LeafContext) throws -> LeafData {
let formatter = DateFormatter()
switch ctx.parameters.count {
case 0: formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
case 1:
guard let string = ctx.parameters[0].string else {
throw NowTagError.invalidFormatParameter
}
formatter.dateFormat = string
default:
throw NowTagError.tooManyParameters
}
return LeafData.string(formatter.string(from: Date()))
}
}
// MARK: - A value formatter: #price(cents) and #price(cents, "EUR")
struct PriceTag: LeafTag {
func render(_ ctx: LeafContext) throws -> LeafData {
guard (1...2).contains(ctx.parameters.count), let cents = ctx.parameters[0].int else {
throw LeafError(.unknownError("#price(cents, currency?) expects an integer number of cents"))
}
let currency = ctx.parameters.count == 2 ? (ctx.parameters[1].string ?? "USD") : "USD"
let symbol = ["USD": "$", "EUR": "€", "GBP": "£"][currency] ?? currency + " "
let whole = cents / 100
let fraction = cents % 100
return .string("\(symbol)\(whole).\(fraction < 10 ? "0" : "")\(fraction)")
}
}
// MARK: - A tag that emits HTML: #stars(rating)
/// Emits markup, so it conforms to `UnsafeUnescapedLeafTag`. Everything it interpolates is either a validated
/// integer or escaped here, because Leaf will not escape the result.
struct StarsTag: UnsafeUnescapedLeafTag {
func render(_ ctx: LeafContext) throws -> LeafData {
try ctx.requireParameterCount(1)
guard let rating = ctx.parameters[0].int, (0...5).contains(rating) else {
throw LeafError(.unknownError("#stars(rating) expects an integer between 0 and 5"))
}
let filled = String(repeating: "★", count: rating)
let empty = String(repeating: "☆", count: 5 - rating)
return .string(#"<span class="stars" role="img" aria-label="\#(rating) out of 5">\#(filled)\#(empty)</span>"#)
}
}
// MARK: - Escaping text that goes into an unescaped tag
extension String {
/// Escapes the five characters that matter in HTML text and quoted attribute values.
var htmlEscaped: String {
var result = ""
result.reserveCapacity(count)
for character in self {
switch character {
case "&": result += "&"
case "<": result += "<"
case ">": result += ">"
case "\"": result += """
case "'": result += "'"
default: result.append(character)
}
}
return result
}
}
/// #badge(status): an unescaped tag that takes user-controllable text, so it escapes it itself.
struct BadgeTag: UnsafeUnescapedLeafTag {
func render(_ ctx: LeafContext) throws -> LeafData {
try ctx.requireParameterCount(1)
let text = ctx.parameters[0].string ?? ""
return .string(#"<span class="badge">\#(text.htmlEscaped)</span>"#)
}
}
/// The same tag written the wrong way: unescaped output, unescaped input.
struct UnsafeBadgeTag: UnsafeUnescapedLeafTag {
func render(_ ctx: LeafContext) throws -> LeafData {
.string("<span class=\"badge\">\(ctx.parameters.first?.string ?? "")</span>")
}
}
// MARK: - Reading the context data and the application
/// #owned(): reads the whole template context through ctx.data instead of parameters.
struct GreetingTag: LeafTag {
func render(_ ctx: LeafContext) throws -> LeafData {
guard let name = ctx.data["viewer"]?.string else {
throw LeafError(.unknownError("#greeting(): the context has no 'viewer' string"))
}
let environment = ctx.application?.environment.name ?? "unknown"
return .string("Hello, \(name) (\(environment))")
}
}
func registerLeafTags(_ app: Application) {
app.leaf.tags["now"] = NowTag()
app.leaf.tags["panel"] = PanelTag()
app.leaf.tags["price"] = PriceTag()
app.leaf.tags["stars"] = StarsTag()
app.leaf.tags["badge"] = BadgeTag()
app.leaf.tags["unsafeBadge"] = UnsafeBadgeTag()
app.leaf.tags["greeting"] = GreetingTag()
}
// MARK: - A tag with a body: #panel("Title"): ... #endpanel
/// ctx.body is the parsed syntax tree of the block. Only literal text (.raw) can be turned back into a string with
/// the public API; anything that needs evaluating (#(x), #if, #for) is rejected.
struct PanelTag: UnsafeUnescapedLeafTag {
func render(_ ctx: LeafContext) throws -> LeafData {
try ctx.requireParameterCount(1)
let body = try ctx.requireBody()
var text = ""
for node in body {
guard case .raw(let buffer) = node else {
throw LeafError(.unknownError("#panel only supports literal text in its body"))
}
text += String(buffer: buffer)
}
let title = (ctx.parameters[0].string ?? "").htmlEscaped
return .string("<section class=\"panel\"><h2>\(title)</h2>\(text)</section>")
}
}
Source: Vapor docs: Leaf custom tags (NowTag); vapor/leaf-kit 1.14.3: LeafTag, LeafContext
The data every example on this page renders is a small read-only view of a book, with a search function (page-local
types, not the Fluent models of the earlier pages; the first book’s title contains an & on purpose):
import Vapor
/// A read-only view of a book for the rendering examples on this page (page-local; not the Fluent model).
struct BookSummary: Content, Equatable {
let id: Int
let title: String
let author: String
let publishedYear: Int
let priceCents: Int
let rating: Int
let status: String
}
enum Catalog {
static let books: [BookSummary] = [
BookSummary(id: 1, title: "Types & Programming Languages", author: "Benjamin Pierce", publishedYear: 2002, priceCents: 7450, rating: 5, status: "published"),
BookSummary(id: 2, title: "The Swift Programming Language", author: "Apple Inc.", publishedYear: 2014, priceCents: 0, rating: 4, status: "published"),
BookSummary(id: 3, title: "Designing Data-Intensive Applications", author: "Martin Kleppmann", publishedYear: 2017, priceCents: 4999, rating: 5, status: "published"),
BookSummary(id: 4, title: "Unfinished Manuscript", author: "Ada Lovelace", publishedYear: 1843, priceCents: 1250, rating: 3, status: "draft"),
]
static func search(_ query: String?) -> [BookSummary] {
guard let query = query?.trimmingCharacters(in: .whitespaces), !query.isEmpty else { return books }
return books.filter { $0.title.localizedCaseInsensitiveContains(query) }
}
}
The route (see the routes listing below) passes the catalog as the context, and registration is part of configure:
import Leaf
import Vapor
func configure(_ app: Application) async throws {
app.views.use(.leaf)
registerLeafTags(app)
app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
try routes(app)
}
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>#(title)</title></head>
<body>
<h1>#(title)</h1>
<p>#greeting() - rendered at #now("yyyy") (year only, to keep the output stable)</p>
<ul>
#for(book in books):
<li>#(book.title) by #(book.author), #price(book.priceCents) #stars(book.rating) #badge(book.status)</li>
#endfor
</ul>
<p>In euros: #price(1999, "EUR"). Unknown tag: #nosuchtag()</p>
<p>Escaped output: #unsafeBadge(note) vs #badge(note)</p>
</body>
</html>
Source: Vapor docs: Leaf getting started (app.views.use(.leaf) and
req.view.render); Vapor docs: Leaf overview (the template syntax)
The response from curl (blank lines are what the #for loop leaves where its tags were; trailing whitespace is
trimmed):
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Catalog</title></head>
<body>
<h1>Catalog</h1>
<p>Hello, ada (development) - rendered at 2026 (year only, to keep the output stable)</p>
<ul>
<li>Types & Programming Languages by Benjamin Pierce, $74.50 <span class="stars" role="img" aria-label="5 out of 5">★★★★★</span> <span class="badge">published</span></li>
<li>The Swift Programming Language by Apple Inc., $0.00 <span class="stars" role="img" aria-label="4 out of 5">★★★★☆</span> <span class="badge">published</span></li>
<li>Designing Data-Intensive Applications by Martin Kleppmann, $49.99 <span class="stars" role="img" aria-label="5 out of 5">★★★★★</span> <span class="badge">published</span></li>
<li>Unfinished Manuscript by Ada Lovelace, $12.50 <span class="stars" role="img" aria-label="3 out of 5">★★★☆☆</span> <span class="badge">draft</span></li>
</ul>
<p>In euros: €19.99. Unknown tag: #nosuchtag</p>
<p>Escaped output: <span class="badge"><b>hi</b></span> vs <span class="badge"><b>hi</b></span></p>
</body>
</html>
Five things in that output are worth reading closely:
-
$74.50,€19.99:#pricereturns a plain.string, which Leaf escapes like any other value, so a currency symbol that was&would appear as&. -
The stars and the badge are real markup:
#starsand#badgeconform toUnsafeUnescapedLeafTag, so Leaf leaves their results alone. -
Types & Programming Languagesis the title from the context, escaped by Leaf’s normal output rule. -
#nosuchtag()is printed as the text#nosuchtag: an unregistered tag name is not an error. A misspelled tag therefore shows up as stray text in the page. Add a rendered-page assertion to your tests for tags a page depends on. -
The last line is the escaping demonstration: with
?note=<script>alert(1)</script>the unsafe tag emits a live<script>element, the safe one emits<script>.
<p>Escaped output: <span class="badge"><script>alert(1)</script></span> vs <span class="badge"><script>alert(1)</script></span></p>
The escaping rule
The serializer decides escaping by type, not by content: if the tag conforms to UnsafeUnescapedLeafTag its result is
written as-is, otherwise the result goes through htmlEscaped() (which only affects string values). The consequences:
-
A tag that returns text (a date, a price, a translated string) conforms to
LeafTagand gets escaping for free. -
A tag that returns markup must conform to
UnsafeUnescapedLeafTagand becomes responsible for every character it interpolates. Validate numbers (#starsaccepts only an integer from 0 to 5), and escape text with a helper such ashtmlEscapedin the listing (&,<,>,"and').UnsafeBadgeTagin the listing is the same tag written wrongly, kept to show the failure. -
The official docs advise the same: tags that render HTML use
UnsafeUnescapedLeafTag, and user input is checked or sanitised. Markup built by string interpolation does not stay safe as it grows; if a tag needs more than a couple of elements, build it with a DSL (below) and render to a string inside the tag. -
The built-in
#unsafeHTML(x)is such a tag. Everything said here applies to using it on user data.
Tags with a body
A tag followed by a colon and closed with #endname receives the parsed block in ctx.body. PanelTag in the
listing reads it:
A: #panel("Notes & more"):
plain <em>text</em>
#endpanel
BODY-TAG: A: <section class="panel"><h2>Notes & more</h2>
plain <em>text</em>
</section>
The limit is in the type: body is [Syntax], LeafKit’s syntax tree. A literal run of text is .raw(ByteBuffer) and
can be turned back into a string, but .expression, .conditional, .loop and the rest need the serializer to
evaluate them, and that serializer is internal to LeafKit. With a body of #(viewer) the same tag throws
LeafError.500: #panel only supports literal text in its body. In practice a tag with a body is for wrapping static
text. For reusable chunks that contain variables use #extend/#export/#import
(Leaf Templates page), or move that component to a DSL. Body support itself is
not in the official custom-tags page; it was verified here against LeafKit 1.14.3 and may change.
Errors, threading and caching
-
A tag signals failure by throwing. A
LeafError(LeafError(.unknownError("..."))) surfaces fromreq.view.renderasLeafError.500: <message>(the output of the test that renders#stars(9)); any other error type is rethrown as it is (the output of#now("yyyy", "x")istooManyParameters, the official example’s own error). Vapor’sErrorMiddlewarerenders a non-AbortErroras500, so an author’s mistake in a template is a server error, not a400. Outside the release environment the JSONreasonof that response is the description of the error (in release it is "Something went wrong."), and the message is logged; do not put user-controlled text in it. -
Tags are plain, non-
Sendableinstances shared by every request and every event loop. Keep them stateless, or make their state thread-safe (an immutablelet, an actor is not usable becauserenderis synchronous and notasync).NowTagcreates its formatter insiderenderfor that reason; a hot tag can instead hold an immutable, thread-safe value created once. -
renderis synchronous and runs on the event loop that is serialising the template. Do not block in it (no file or network I/O, no.wait()), and do not call intoasynccode. Compute asynchronous data in the handler and pass it in the context. -
Leaf caches the parsed template, not the output. A tag therefore runs on every request, even with the cache enabled. In the
.developmentenvironment Leaf disables the cache (the renderer setscache.isEnabled = false), so template edits show up without restarting. -
A misspelled or unregistered tag is not a startup failure; it shows as text (the
#nosuchtagoutput above).
Type-safe HTML in Swift
A Leaf template is text in a second language that is checked when it is rendered. A DSL ("domain-specific language")
moves the HTML into Swift: elements are functions or types, attributes are typed values, and the compiler checks that a
<tr> is built from valid parts. Three Swift packages are worth knowing for Vapor, and they differ more than their
names suggest:
| Elementary + VaporElementary | Plot | swift-html (pointfreeco) | |
|---|---|---|---|
Style |
Result builders, SwiftUI-like |
A |
Enum |
Output |
Streams chunks to the response, or |
|
|
Also |
ElementaryHTMX: typed |
HTML, XML, RSS, podcast and sitemap documents |
Snapshot-testing helpers |
Vapor integration |
|
none: you wrap it |
none: you wrap it |
Version used here |
0.8.2 (tools 6.2) |
0.14.0, last commit July 2024 |
0.5.0, last commit October 2024 |
Elementary is the actively developed one (release 0.8.2 on 2026-09-23, a push on 2026-10-08) and the only one with a Vapor integration and htmx support, so it gets the most space. Plot and swift-html still work with Swift 6.4 in this package, but neither has had a commit in about two years, so a new project should treat them as stable but unmaintained. None of the three is part of Vapor.
Common to all of them: the HTML is ordinary Swift, so it is refactored with functions and types, tested with plain unit tests; template syntax errors become compile errors; but there is no designer-edit-the-file workflow (Leaf’s strength), and compile times grow with deeply nested builders.
Returning a page: AsyncResponseEncodable
Vapor turns a handler’s return value into a Response through ResponseEncodable, or through the async variant,
which is what a DSL page uses because it can be written without futures:
// Vapor 4.122.2
public protocol AsyncResponseEncodable: VaporSendableMetatype {
func encodeResponse(for request: Request) async throws -> Response
}
A route closure may return any ResponseEncodable or AsyncResponseEncodable (View, Content types, String and
Response are the common ones, and HTMLResponse is one). To return Plot or swift-html you therefore write a small
wrapper type (shown below) or return a Response directly. Retroactively conforming a library’s own type is possible
but the Swift 6 compiler asks for @retroactive and the library’s type is not guaranteed to be Sendable; a wrapper
that holds the rendered String has neither problem.
Elementary and htmx
Elementary describes a page as a type. A component conforms to HTML and has a body; a whole page conforms to
HTMLDocument, which adds title, head and renders the doctype and <html>. if/else, for and switch work in
body because it is a result builder. Attributes are written as leading arguments (.href(...), .class(...),
.id(...)), and ElementaryHTMX adds .hx.get(_:), .hx.post(_:), .hx.trigger(_:), .hx.target(_:),
.hx.swap(_:) and the rest of the htmx attributes in the same style. Text is a Swift string, and Elementary escapes
it.
The dependencies, pinned (the VaporElementary package declares Vapor from 4.102.0, so it resolves with 4.122.2):
// Package.swift
platforms: [.macOS(.v14)],
dependencies: [
.package(url: "https://github.com/vapor/vapor.git", from: "4.122.2"),
.package(url: "https://github.com/elementary-swift/elementary.git", from: "0.8.2"),
.package(url: "https://github.com/elementary-swift/elementary-htmx.git", from: "0.5.2"),
.package(url: "https://github.com/vapor-community/vapor-elementary.git", from: "0.2.2"),
],
// target dependencies:
// .product(name: "Elementary", package: "elementary"),
// .product(name: "ElementaryHTMX", package: "elementary-htmx"),
// .product(name: "VaporElementary", package: "vapor-elementary"),
The catalog page with a search box that asks the server for new table rows while the user types, the row fragment that both the page and the htmx request reuse, and a small component:
import Elementary
import ElementaryHTMX
struct CatalogPage: HTMLDocument {
let books: [BookSummary]
let query: String
var title: String { "Bookshelf catalog" }
var head: some HTML {
meta(.name(.viewport), .content("width=device-width, initial-scale=1"))
script(.src("/js/htmx.min.js")) {}
}
var body: some HTML {
main {
h1 { "Catalog" }
input(
.type(.search), .name("q"), .value(query), .id("q"),
.custom(name: "aria-label", value: "Search titles"),
.placeholder("Search titles"),
.hx.get("/elementary/books/rows"),
.hx.trigger(.event(.input).changed().delay("300ms")),
.hx.target("#rows"),
.hx.swap(.innerHTML)
)
table {
thead {
tr { th { "Title" }; th { "Author" }; th { "Price" } }
}
tbody(.id("rows")) {
BookRows(books: books)
}
}
}
}
}
/// A reusable fragment: rendered inside the page and returned on its own for htmx.
struct BookRows: HTML {
let books: [BookSummary]
var body: some HTML {
if books.isEmpty {
tr { td(.custom(name: "colspan", value: "3")) { "No books match." } }
} else {
for book in books {
BookRow(book: book)
}
}
}
}
struct BookRow: HTML {
let book: BookSummary
var body: some HTML {
tr(.data("book-id", value: String(book.id))) {
td { a(.href("/books/\(book.id)")) { book.title } }
td { book.author }
td { Money(cents: book.priceCents) }
}
}
}
struct Money: HTML {
let cents: Int
var body: some HTML {
span(.class("money")) { "$\(cents / 100).\(cents % 100 < 10 ? "0" : "")\(cents % 100)" }
}
}
struct NotFoundPage: HTMLDocument {
var title = "Not found"
var head: some HTML {
meta(.name(.viewport), .content("width=device-width, initial-scale=1"))
}
var body: some HTML {
main {
h1 { "No such book" }
}
}
}
Source: elementary-swift/elementary; elementary-swift/elementary-htmx; vapor-community/vapor-elementary
BookRows is used twice: inside CatalogPage for the initial load and, on its own, as the response of the htmx
request. That is the pattern behind htmx-style apps: the server returns fragments, and a component is the unit of
reuse. The routes:
import Elementary
import Vapor
import VaporElementary
func routes(_ app: Application) throws {
// Leaf with custom tags.
app.get("leaf", "books") { req async throws -> View in
struct Context: Encodable {
let title: String
let viewer: String
let books: [BookSummary]
let note: String
}
let note = req.query[String.self, at: "note"] ?? "<b>hi</b>"
return try await req.view.render("catalog", Context(title: "Catalog", viewer: "ada", books: Catalog.books, note: note))
}
// Elementary: a full page, and the htmx fragment for the same rows.
app.get("elementary", "books") { req async throws -> HTMLResponse in
let query = req.query[String.self, at: "q"] ?? ""
let books = Catalog.search(query)
return HTMLResponse { CatalogPage(books: books, query: query) }
}
app.get("elementary", "books", "rows") { req async throws -> HTMLResponse in
let books = Catalog.search(req.query[String.self, at: "q"])
return HTMLResponse { BookRows(books: books) }
}
app.get("elementary", "missing") { _ async throws -> Response in
htmlResponse(NotFoundPage(), status: .notFound)
}
// Plot: a String-rendering DSL wrapped in a response type.
app.get("plot", "books") { _ async throws -> PlotPage in
PlotPage(plotCatalog(Catalog.books))
}
app.get("swift-html", "books") { _ async throws -> Response in
swiftHtmlResponse(Catalog.books)
}
}
A helper for returning a status other than 200, discussed below (it is in its own file in the scratch package):
import Elementary
import Vapor
/// Renders to a string so that the status code can be chosen: HTMLResponse always answers 200 OK.
func htmlResponse(_ html: some HTML, status: HTTPResponseStatus = .ok) -> Response {
Response(
status: status,
headers: ["Content-Type": "text/html; charset=utf-8"],
body: .init(string: html.render())
)
}
Real output from curl localhost:8089/elementary/books?q=swift (the long line is broken before <main> and <tbody>
here), the fragment for q=zzz, and the 404 page:
$ curl -s "localhost:8089/elementary/books?q=swift"
<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Bookshelf catalog</title><meta name="viewport" content="width=device-width, initial-scale=1"><script src="/js/htmx.min.js"></script></head><body>
<main><h1>Catalog</h1><input type="search" name="q" value="swift" id="q" aria-label="Search titles" placeholder="Search titles" hx-get="/elementary/books/rows" hx-trigger="input changed delay:300ms" hx-target="#rows" hx-swap="innerHTML"><table><thead><tr><th>Title</th><th>Author</th><th>Price</th></tr></thead>
<tbody id="rows"><tr data-book-id="2"><td><a href="/books/2">The Swift Programming Language</a></td><td>Apple Inc.</td><td><span class="money">$0.00</span></td></tr></tbody></table></main></body></html>
$ curl -s "localhost:8089/elementary/books/rows?q=zzz"
<tr><td colspan="3">No books match.</td></tr>
$ curl -si localhost:8089/elementary/missing
HTTP/1.1 404 Not Found
Content-Type: text/html; charset=utf-8
content-length: 202
<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Not found</title><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><main><h1>No such book</h1></main></body></html>
Details that matter in practice:
-
HTMLResponsetakes a builder closure:HTMLResponse { CatalogPage(...) }. The page value must beSendable(the struct above is, because its stored properties are), or on macOS 15 and later (and Linux) thesendingoverload takes non-Sendablevalues that are used once. A non-Sendablepage can be written only once, and a second write is a runtime error (assertionFailurein a debug build). -
The response is streamed:
transfer-encoding: chunked, 1024-byte chunks by default (chunkSize), content typetext/html; charset=utf-8. The status line and headers are sent before the body is finished, so an error thrown while rendering the middle of a page cannot become a500page any more; the connection ends early. Do the database work in the handler and pass the results in, so thatbodyonly reads values. -
HTMLResponsealways answers200 OKand exposesheadersandchunkSizebut no status. To return another status, render to a string and build theResponseyourself, ashtmlResponse(_:status:)in the listing does (the404output above). Because that variant buffers the whole page, use it for small pages such as error pages. -
Text and attribute values are escaped. In the check below
<script>in text became<script>; in a double-quoted attribute the check above shows only"escaped ("), which is enough inside a quoted attribute.HTMLRawwrites a string unescaped and is the DSL’s equivalent of#unsafeHTML. -
Elementary does not know every attribute:
tdhas nocolspanhelper in 0.8.2, so the listing uses.custom(name:value:). -
htmx itself is a JavaScript file you serve (here
Public/js/htmx.min.js, a placeholder in the scratch package). Vendor a pinned version and load it from your own origin, or from a CDN with a Subresource Integrity hash. ElementaryHTMX only writes the attributes. -
A fragment endpoint is an ordinary route and needs the same authentication, authorisation and CSRF handling as the page (see Server-Rendered Web Apps and Forms). An htmx request carries an
HX-Request: trueheader; use it to return the fragment or the whole page from the same route, which keeps a link working when JavaScript is off.
Plot
Plot builds a tree of Node values whose type parameter is the context (HTML.BodyContext for the content of <body>,
for example), so a list item can only be used where a list is expected. It has no component protocol: you compose with
functions that return `Node`s.
import Plot
import Vapor
/// Plot renders to a String, so the response wrapper keeps the rendered text (a Sendable value).
struct PlotPage: AsyncResponseEncodable {
let html: String
init(_ document: Plot.HTML) {
self.html = document.render()
}
func encodeResponse(for request: Request) async throws -> Response {
Response(status: .ok, headers: ["Content-Type": "text/html; charset=utf-8"], body: .init(string: html))
}
}
func plotCatalog(_ books: [BookSummary]) -> Plot.HTML {
Plot.HTML(
.lang(.english),
.head(
.title("Bookshelf catalog"),
.meta(.charset(.utf8))
),
.body(
.h1("Catalog"),
.ul(
.forEach(books) { book in
.li(.a(.href("/books/\(book.id)"), .text(book.title)), .text(" by \(book.author)"))
}
)
)
)
}
Source: JohnSundell/Plot
The listing’s PlotPage is the wrapper described above. Plot.HTML is spelled with its module name because the
Elementary HTML protocol is in the same package; if your target imports both libraries, qualify every ambiguous name
or keep each library in its own file. Output of curl localhost:8089/plot/books:
<!DOCTYPE html><html lang="en"><head><title>Bookshelf catalog</title><meta name="twitter:title" content="Bookshelf catalog"/><meta property="og:title" content="Bookshelf catalog"/><meta charset="UTF-8"/></head><body><h1>Catalog</h1><ul><li><a href="/books/1">Types & Programming Languages</a> by Benjamin Pierce</li><li><a href="/books/2">The Swift Programming Language</a> by Apple Inc.</li><li><a href="/books/3">Designing Data-Intensive Applications</a> by Martin Kleppmann</li><li><a href="/books/4">Unfinished Manuscript</a> by Ada Lovelace</li></ul></body></html>
Plot added twitter:title and og:title meta elements by itself when given a .title.
swift-html
pointfreeco’s library models a document as an enum Node and renders it with render(_:). It is the smallest of the
three, and it does not conform to anything in Vapor.
import Html
import Vapor
func swiftHtmlCatalog(_ books: [BookSummary]) -> Node {
.document(
.html(
.head(.title("Bookshelf catalog")),
.body(
.h1("Catalog"),
.ul(
.fragment(books.map { book in
.li(.a(attributes: [.href("/books/\(book.id)")], .text(book.title)), " by \(book.author)")
})
)
)
)
)
}
func swiftHtmlResponse(_ books: [BookSummary]) -> Response {
let html = render(swiftHtmlCatalog(books))
return Response(status: .ok, headers: ["Content-Type": "text/html; charset=utf-8"], body: .init(string: html))
}
Source: pointfreeco/swift-html
<!DOCTYPE html><html><head><title>Bookshelf catalog</title></head><body><h1>Catalog</h1><ul><li><a href="/books/1">Types & Programming Languages</a> by Benjamin Pierce</li><li><a href="/books/2">The Swift Programming Language</a> by Apple Inc.</li><li><a href="/books/3">Designing Data-Intensive Applications</a> by Martin Kleppmann</li><li><a href="/books/4">Unfinished Manuscript</a> by Ada Lovelace</li></ul></body></html>
swift-html declares its own Application type, so a file that imports both Html and Vapor cannot write
Application without qualification (the compiler reports it as ambiguous). The listing keeps the Html import in a
file that only needs Response, and the routes.swift listing above does not import it.
Escaping: what each library does
The scratch package’s test renders the same hostile string through each library:
@Test func dslEscaping() {
let hostile = "<script>alert(\"x\")</script>"
print("ELEMENTARY text:", p { hostile }.render())
print("ELEMENTARY attr:", a(.href("/x?q=\(hostile)"), .title(hostile)) { "go" }.render())
print("ELEMENTARY raw:", p { HTMLRaw(hostile) }.render())
print("PLOT text:", Plot.Node<Plot.HTML.BodyContext>.p(.text(hostile)).render())
print("PLOT raw:", Plot.Node<Plot.HTML.BodyContext>.p(.raw(hostile)).render())
print("PLOT attr:", Plot.Node<Plot.HTML.BodyContext>.a(.href("/x\"onclick=\"y"), .text("go")).render())
}
ELEMENTARY text: <p><script>alert("x")</script></p>
ELEMENTARY attr: <a href="/x?q=<script>alert("x")</script>" title="<script>alert("x")</script>">go</a>
ELEMENTARY raw: <p><script>alert("x")</script></p>
PLOT text: <p><script>alert("x")</script></p>
PLOT raw: <p><script>alert("x")</script></p>
PLOT attr: <a href="/x"onclick="y">go</a>
-
Text: Elementary and Plot’s
.textboth escape<and>and leave"in text alone, which is correct for element content. Plot’s.rawand Elementary’sHTMLRawdo not escape. swift-html is not part of this comparison. -
Attributes: Elementary escapes
"in attribute values. Plot 0.14.0 did not: a value containing a double quote ended the attribute and injected another (href="/x"onclick="y"above). Never put request data into a Plot attribute without escaping the quote yourself or validating the value; this was observed on 0.14.0 and the library is no longer maintained, so do not expect a fix. Treat URLs from users (href,src) separately in every library: no DSL checks the scheme, andjavascript:URLs are still yours to reject. -
Because none of these is a sanitiser, user-supplied HTML (rich text, Markdown output) needs a sanitiser before it reaches
HTMLRaw,.rawor#unsafeHTML.
Choosing between Leaf, a DSL and a separate front end
| Approach | Strengths | Costs | Fits |
|---|---|---|---|
Leaf (plus custom tags) |
Plain HTML files, edited without compiling; hot reload in development; the best-documented option; layouts with |
Errors appear at render time; unknown tags print as text; logic belongs in handlers and tags |
Content sites, admin pages, anything a template author maintains |
A DSL (Elementary best supported) |
Compile-time checks, components, refactoring, unit-testable fragments, typed htmx attributes, streaming output |
No designer workflow; a recompile for every markup change; the smaller ecosystem and bus factor |
Server-rendered apps built by Swift developers, htmx fragments |
Separate front end (React, Vue, SwiftUI or UIKit apps, with the Vapor API in JSON) |
Rich interactivity, one API for web and mobile |
Two codebases, an API contract to maintain, CORS and token handling |
Applications with a mobile client or heavy client-side state |
Mixing is normal: Leaf for the page frame and layout, a custom tag or DSL component for the pieces where Leaf is awkward
(a form field with error text, a pagination bar). A tag can render a DSL component to a string and return it from an
UnsafeUnescapedLeafTag, because the DSL has already escaped its own output. Whichever you choose, the security
headers, sessions and CSRF protection are properties of the middleware stack and apply to all of them, and a
user-visible page must stay accessible: label inputs, keep a skip link and a logical heading order, as
Web accessibility describes.
Testing rendered output
Both Leaf and the DSLs are tested through the application: app.test(.GET, "path") returns the Response, and a body
assertion catches a misspelled tag or a changed attribute. Components that do not need a request are tested without one,
by calling .render() and comparing the string.
import Elementary
import Plot
import Testing
import Vapor
import VaporTesting
@testable import App
@Suite("Rendering")
struct RenderingTests {
private func withApp(_ body: (Application) async throws -> Void) async throws {
let app = try await Application.make(.testing)
do {
try await configure(app)
try await body(app)
} catch {
try await app.asyncShutdown()
throw error
}
try await app.asyncShutdown()
}
@Test func leafBadStarsFails() async throws {
try await withApp { app in
struct Empty: Encodable {}
do {
_ = try await app.view.render("bad-stars", Empty())
Issue.record("expected an error")
} catch {
print("BAD-STARS:", String(describing: error))
}
do {
_ = try await app.view.render("bad-now", Empty())
Issue.record("expected an error")
} catch {
print("BAD-NOW:", String(describing: error))
}
struct Ctx: Encodable { let viewer = "ada" }
do {
let view = try await app.view.render("body-tag", Ctx())
print("BODY-TAG:", String(buffer: view.data))
} catch {
print("BODY-TAG ERR:", String(describing: error))
}
}
}
@Test func notFoundStatus() async throws {
try await withApp { app in
try await app.test(.GET, "elementary/missing") { res async in
#expect(res.status == .notFound)
print("MISSING:", res.body.string)
}
}
}
}
Source: Vapor docs: Testing; Swift Testing
For Leaf, app.view.render("template", context) renders without a Request, as the scratch tests did to capture the
error messages above (the bad-stars.leaf template contains #stars(9), bad-now.leaf contains #now("yyyy",
"x"), and body-tag.leaf is the panel template shown earlier). Output of the test run:
BAD-STARS: LeafError.500: #stars(rating) expects an integer between 0 and 5
MISSING: <!DOCTYPE html><html><head><meta charset="UTF-8"><title>Not found</title><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><main><h1>No such book</h1></main></body></html>
BAD-NOW: tooManyParameters
BODY-TAG: A: <section class="panel"><h2>Notes & more</h2>
plain <em>text</em>
</section>
The DSL libraries also ship snapshot helpers (swift-html’s HtmlSnapshotTesting target is a separate product), which
are not covered here.
Common mistakes
| Symptom | Cause | Fix |
|---|---|---|
|
tag not registered, or registered under another name |
register before the first request; assert on the rendered body |
|
markup tag conforms to |
escape with a helper, or build with a DSL; or return a plain |
|
markup tag conforms to plain |
conform to |
Compile error |
the page holds a non- |
store |
|
a file imports |
keep |
|
a file imports Elementary and Plot |
qualify |
Status is always 200 with Elementary |
|
render to a string and build the |
Truncated page and a dropped connection |
error thrown while a streamed page was being rendered |
load data in the handler before building the page |
A tag with a body throws |
expressions inside the body are not evaluable by a custom tag |
keep bodies static or use |
Slow requests under load |
a tag performs I/O in |
compute in the handler, pass in the context |
Elementary or VaporElementary fails to resolve |
the package’s platform is below macOS 14 |
|
User-controlled URL in |
no DSL validates the scheme |
allow only |
Version notes
-
Vapor 3 and earlier / Leaf 1 to 3. The custom tag protocol was
TagRendererwithrender(tag: TagContext)returning a future ofTemplateData, registered through aLeafTagConfig. Leaf 4 replaced it byLeafTag.render(_:)returningLeafDatasynchronously, registered inapp.leaf.tags. -
Leaf 4.x (current, 4.5.2). The protocol shape described here has been stable for the 4.x line.
LeafContext.applicationcomes from Leaf, not LeafKit. -
Vapor 5 (5.0.0-beta.4, not generally available).
vapor/leaf-kithas av5branch (last commit 2026-04-28) whoseLeafTagandUnsafeUnescapedLeafTaghave the same declarations as 1.14.3, but noleafrelease targets Vapor 5 yet, so Leaf’s own Vapor 5 integration is still open. The DSL packages above target Vapor 4: VaporElementary’sPackage.swiftpins Vaporfrom: "4.102.0"and itsHTMLResponseis written againstAsyncResponseEncodable; check the packages' release notes before moving a DSL-based app to Vapor 5. -
Swift 6. LeafTag is not
Sendable, which is why registering instances in a dictionary compiles without annotations; stored state in a tag is a data race you must prevent yourself.
Where the books are outdated
-
Condon et al. (Server-Side Swift with Vapor, Kodeco): its Leaf chapters (14 and 15, per the section’s concept table) are still on Vapor 3, where views are rendered through futures and a custom tag is a
TagRenderer. In Vapor 4 and Leaf 4 the tag is aLeafTagregistered inapp.leaf.tags, rendering istry await req.view.render(...), and the layout directives are#extend/#export/#import. HTML DSLs are not part of the book. -
Yeung (Packt) builds its web UI on Leaf 3 partials; the Leaf 4 syntax and built-in tag set differ (see the Leaf Templates page).
-
Patel (Packt) is on Vapor 2 and its Leaf 2; Leaf of that generation registered tags through its own
TagAPI on the stem, and none of that exists in Leaf 4. -
None of the three books covers Elementary, Plot or swift-html, so the DSL half of this page rests on the libraries' own documentation and on the scratch-package runs. The books were not to hand while this page was written, so these remarks come from the frameworks' version history, not from rereading the chapters.
References
-
vapor/leaf 4.5.2 and vapor/leaf-kit 1.14.3 (
LeafTag.swift,LeafContext.swift,LeafSerializer.swift) -
elementary-swift/elementary, elementary-htmx and vapor-community/vapor-elementary
-
Vapor API documentation and vapor/vapor 4.122.2: Concurrency
-
Kodeco: Server-Side Swift with Vapor (Condon et al.)