Leaf Templates
|
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 (released
2026-06-27) and LeafKit 1.14.3, 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; every template shown was rendered by that package, the output
shown is captured from curl against the running service and from a VaporTesting suite, and the statements about what
Leaf accepts and rejects were checked by rendering small probe templates, not only by reading the documentation. Leaf
has no release for Vapor 5 yet: at the time of writing the main branch of vapor/leaf still depends on Vapor 4.x, and
Vapor 5 is at 5.0.0-beta.4 and not released. The page extends the Bookshelf service of the earlier pages of this
section with its public catalogue: HTML pages for browsers next to the JSON API. HTML and CSS themselves are in the
site’s HTML & CSS section (see in particular HTML
structure and forms), and accessible markup in
Accessibility. Swift itself is covered by the
Swift Reference; the same job in Rust is done in
Server-side Templates with askama. The
Leaf Custom Tags and HTML DSLs page (custom Leaf tags and
type-safe HTML in Swift) and the Server-Rendered Web Apps and
Forms page (complete form-driven pages, sessions and CSRF) build on this page.
What Leaf is and where it runs
Leaf is Vapor’s templating language. A template is a text file (usually HTML) with tags that begin with #; Leaf
parses it once into a syntax tree, and rendering walks that tree with a context of values and produces bytes. Two
packages are involved, and the split explains several error messages:
-
LeafKit (
vapor/leaf-kit, 1.14.3) is the language: lexer, parser, serializer, the built-in tags, the cache and the template sources. It does not depend on Vapor. -
Leaf (
vapor/leaf, 4.5.2) is the integration: theapp.leafaccessor,.leafas aApplication.Views.Provider, the encoder that turns anEncodablecontext into Leaf data, thereq.leafrenderer, and the conformance ofLeafErrortoAbortErrorso that a failed render becomes a500response.
In the request lifecycle (middleware, router, handler, response encoding) rendering happens inside the handler:
-
The handler builds a context value, awaits
req.view.render(_:_:)and returns the resultingView.ViewisResponseEncodable; encoding it gives200 OKwithcontent-type: text/html; charset=utf-8(verified withcurl -I). -
req.viewasks the configuredViewRendererfor a renderer for this request. For Leaf that isreq.leaf, aLeafRendererbuilt per access from the application’s configuration, tags, cache and sources, with the request itself stored in itsuserInfo. A custom tag can therefore readctx.requestandctx.application. -
A
LeafError(missing template, lexer error, a tag throwing) conforms toAbortErrorwith status500, soErrorMiddlewareanswers with it. Section Errors you will meet shows the messages, and why you should not let them reach a browser in production. -
Because the view is rendered in the handler, middleware that wraps the route (security headers, compression, request logging) sees a finished HTML body.
Installing Leaf and configuring Vapor
Leaf is a separate package (the official vapor/template lists it and ships Resources/Views/index.leaf). Add the
package and the product, and, if you want to touch LeafConfiguration or LeafSources in your own code (see
Configuration, the cache and template sources), the LeafKit product as well, because Leaf re-exports only a few
LeafKit types (LeafTag, UnsafeUnescapedLeafTag, LeafData, LeafContext, Syntax):
// swift-tools-version:6.3
import PackageDescription
let package = Package(
name: "Bookshelf",
platforms: [.macOS(.v13)],
dependencies: [
.package(url: "https://github.com/vapor/vapor.git", from: "4.122.2"),
.package(url: "https://github.com/vapor/leaf.git", from: "4.5.2"),
.package(url: "https://github.com/vapor/leaf-kit.git", from: "1.14.3"),
],
targets: [
.executableTarget(name: "App", dependencies: [
.product(name: "Vapor", package: "vapor"),
.product(name: "Leaf", package: "leaf"),
.product(name: "LeafKit", package: "leaf-kit"),
]),
]
)
Source: Vapor docs: Leaf, getting started (the page shows from: "4.4.0"; the scratch package pins 4.5.2 exactly). The Fluent packages of the earlier pages are omitted from this abridged manifest.
One line in configure.swift switches the application’s view renderer to Leaf:
import Fluent
import FluentSQLiteDriver
import Leaf
import Vapor
func configure(_ app: Application) async throws {
app.databases.use(.sqlite(.memory), as: .sqlite)
app.migrations.add(CreateBookshelf())
try await app.autoMigrate()
// Leaf: make req.view render with Leaf. Templates come from Resources/Views.
app.views.use(.leaf)
try routes(app)
}
(The scratch package also runs the Bookshelf migration with in-memory SQLite, as in the Fluent pages, because the catalogue route below reads books.) The facts that matter:
-
app.views.use(.leaf)registers a provider;req.viewthen uses Leaf. Without it,req.viewfalls back to Vapor’sPlaintextRenderer(installed byApplicationat start-up), which reads the file and does not understand tags, so the page would come out as the raw template. Call it before the first request; it is part ofconfigure, not of a route. -
Templates are read from
Resources/Viewsrelative to the working directory (app.directory.viewsDirectory, which isapp.directory.resourcesDirectory + "Views/"). A name is a path below that directory without the.leafextension:books/indexisResources/Views/books/index.leaf. The file source is sandboxed to the views directory; a name such as../secretdid not resolve (No template found) in a probe. -
Static files (CSS, JavaScript, images) are not templates and do not go in
Resources/Views; they go inPublic/and are served byFileMiddleware, covered in the Static Files and File I/O page. -
When the app is started from Xcode the working directory is not the package root, so Leaf cannot find
Resources/Viewsand every render fails with a "No template found" error. Set a custom working directory in the scheme (Run, Options, "Use custom working directory"), or start the server withswift runfrom the package directory.
Rendering a view
The route is an ordinary async handler. The context is a value you build; Leaf reads it through its Encodable
conformance:
import Fluent
import Vapor
/// One row of the catalogue, shaped for the template: strings are already formatted.
struct BookRow: Content {
let title: String
let isbn: String
let year: Int
let price: String
let status: BookStatus
let tags: [String]
}
struct CatalogueContext: Encodable {
let title: String
let user: String?
let books: [BookRow]
let generatedAt: Date
}
struct BookDetailContext: Encodable {
let title: String
let book: BookRow
let summaryHTML: String
let reviews: [String]
}
extension BookRow {
/// Maps a Fluent model whose `author` is not needed here; `tags` must be eager loaded.
init(_ book: Book) throws {
self.init(
title: book.title,
isbn: book.isbn,
year: book.publishedYear,
price: String(format: "%d.%02d", book.priceCents / 100, book.priceCents % 100),
status: book.status,
tags: book.tags.map(\.name)
)
}
}
Source: Vapor docs: Leaf, overview (context); the Fluent part follows Vapor docs: Fluent relations.
import Fluent
import Leaf
import Vapor
func routes(_ app: Application) throws {
app.get("books") { req async throws -> View in
let books = try await Book.query(on: req.db)
.filter(\.$status == .published)
.sort(\.$title)
.with(\.$tags)
.all()
let context = CatalogueContext(
title: "Bookshelf catalogue",
user: req.query[String.self, at: "user"],
books: try books.map(BookRow.init),
generatedAt: Date()
)
return try await req.view.render("books/index", context)
}
// A fixed example so the output on the page is reproducible without a database.
app.get("books", "demo") { req async throws -> View in
let context = CatalogueContext(
title: "Bookshelf catalogue",
user: req.query[String.self, at: "user"],
books: [
BookRow(title: "Dune", isbn: "9780441013593", year: 1965, price: "9.99", status: .published, tags: ["sci-fi", "classic"]),
BookRow(title: "Tom & Jerry <Annual>", isbn: "9780000000002", year: 2001, price: "12.50", status: .draft, tags: []),
BookRow(title: "Emma", isbn: "9780141439587", year: 1815, price: "7.99", status: .archived, tags: ["classic"]),
],
generatedAt: Date(timeIntervalSince1970: 1_760_000_000)
)
return try await req.view.render("books/index", context)
}
app.get("books", "dune") { req async throws -> View in
let context = BookDetailContext(
title: "Dune",
book: BookRow(title: "dune", isbn: "9780441013593", year: 1965, price: "9.99", status: .published, tags: ["sci-fi"]),
summaryHTML: "<em>Trusted</em> editor-written summary",
reviews: ["Great", "<script>alert(1)</script>"]
)
return try await req.view.render("books/show", context)
}
}
What to take from this:
-
Context rules.
render(_:_:)takessome Encodable(the older[String: String]dictionary literal such asreq.view.render("hello", ["name": "Leaf"])is only the simplest case). A struct is preferred: its property names become the variable names in the template. A dictionary of mixed values ([String: Any]) does not work, and the top-level value must be a keyed container: to pass an array, wrap it in a struct, asCatalogueContext.booksdoes. -
What the encoder produces. Strings, numbers, booleans, arrays, dictionaries and nested structs map to the obvious Leaf values. An enum with a
Stringraw value (BookStatus) becomes its raw value, sobook.status == "published"works in a template. ADatebecomes a number of seconds since 1970, which is what#dateexpects.niloptionals leave the variable undefined, not an error (see Variables and expressions). -
Prefer a view-model over the Fluent model. A Fluent model is
Codable, and handing it to Leaf works, but the template would then see every field the model encodes (includingpasswordHashonUser) and would silently see nothing for a relation you forgot to load with.with(\.$tags).BookRowis the template’s contract: it holds already formatted strings (a price is formatted in Swift, not computed in the template), and the mapping is the one place that must eager-loadtags. -
Swift 6 concurrency. The context is created and consumed inside one handler task, so it does not need to be
Sendableforrenderto accept it;Contentstructs areSendableanyway. The canonical Fluent models are@unchecked Sendableclasses in these pages; copying them into value-type view-models before rendering avoids sharing mutable model instances with the renderer.req.viewis awaited, so the handler is suspended, not blocked, while the template file is read (throughNonBlockingFileIO) and the output is serialized. -
The result is a
View.View.datais the renderedByteBuffer. To get aString(for an e-mail body, say) useString(buffer: view.data), asrenderToStringdoes in Configuration, the cache and template sources.
The Bookshelf templates
A layout, two partials and two pages. The layout is the skeleton of every HTML page and declares three import points; a page extends it and exports content for those points:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>#import("title") - Bookshelf</title>
#import("head")
</head>
<body>
#extend("partials/nav")
<main>
#import("content")
</main>
#extend("partials/footer")
</body>
</html>
<nav>
<a href="/books">Catalogue</a>
#if(user):
<span>Signed in as #(user)</span>
#else:
<a href="/login">Sign in</a>
#endif
</nav>
<footer>Bookshelf demo</footer>
#extend("base"):
#export("title", title)
#export("content"):
<h1>#(title)</h1>
#comment("Rendered #count(books) books")
<p>#count(books) books, generated #date(generatedAt, "yyyy-MM-dd HH:mm", "UTC").</p>
#if(count(books) == 0):
<p>No books.</p>
#else:
<table>
<thead><tr><th>#</th><th>Title</th><th>ISBN</th><th>Price</th><th>Status</th><th>Tags</th></tr></thead>
<tbody>
#for(i, book in books):
<tr class="#if(isFirst):first#endif #if(isLast):last#endif #if(i % 2 == 0):even#else:odd#endif">
<td>#(i + 1)</td>
<td><a href="/books/#(book.isbn)">#(book.title)</a></td>
<td>#(book.isbn)</td>
<td>#(book.price)</td>
<td>#uppercased(book.status)</td>
<td>#if(count(book.tags) > 0):#for(tag in book.tags):#(tag)#if(!isLast):, #endif#endfor#else:-#endif</td>
</tr>
#endfor
</tbody>
</table>
#endif
#endexport
#endextend
Source: Vapor docs: Leaf, overview
The structure, as a diagram of which template provides what:
The result of rendering books/index with /books/demo?user=ana (blank lines trimmed; Leaf keeps the whitespace of the
lines that hold only tags):
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Bookshelf catalogue - Bookshelf</title>
</head>
<body>
<nav>
<a href="/books">Catalogue</a>
<span>Signed in as ana</span>
</nav>
<main>
<h1>Bookshelf catalogue</h1>
<p>3 books, generated 2025-10-09 08:53.</p>
<table>
<thead><tr><th>#</th><th>Title</th><th>ISBN</th><th>Price</th><th>Status</th><th>Tags</th></tr></thead>
<tbody>
<tr class="first even">
<td>1</td>
<td><a href="/books/9780441013593">Dune</a></td>
<td>9780441013593</td>
<td>9.99</td>
<td>PUBLISHED</td>
<td>sci-fi, classic</td>
</tr>
<tr class=" odd">
<td>2</td>
<td><a href="/books/9780000000002">Tom & Jerry <Annual></a></td>
<td>9780000000002</td>
<td>12.50</td>
<td>DRAFT</td>
<td>-</td>
</tr>
<tr class=" last even">
<td>3</td>
<td><a href="/books/9780141439587">Emma</a></td>
<td>9780141439587</td>
<td>7.99</td>
<td>ARCHIVED</td>
<td>classic</td>
</tr>
</tbody>
</table>
</main>
<footer>Bookshelf demo</footer>
</body>
</html>
Things to notice in that output: the title with & and < is escaped; #(i + 1) did arithmetic; isFirst and
isLast produced the first and last classes; the head import, which no page exported, rendered nothing and raised
no error; the partials saw the page’s context, so user is available in nav.leaf. The second page uses the tags that
deal with trust and debugging:
#extend("base"):
#export("title", title)
#export("head"):
<meta name="robots" content="noindex">
#endexport
#export("content"):
<h1>#capitalized(book.title)</h1>
<div class="summary">#unsafeHTML(summaryHTML)</div>
<div class="escaped">#(summaryHTML)</div>
<ul>
#for(review in reviews):
<li>#(review)</li>
#endfor
</ul>
<pre>#dumpContext</pre>
#endexport
#endextend
Output of /books/dune (the context holds title: "dune", a summary containing markup and a review containing a
<script> element; the #dumpContext output is shortened):
<h1>Dune</h1>
<div class="summary"><em>Trusted</em> editor-written summary</div>
<div class="escaped"><em>Trusted</em> editor-written summary</div>
<ul>
<li>Great</li>
<li><script>alert(1)</script></li>
</ul>
<pre>[book: "[isbn: "9780441013593", price: "9.99", status: "published", ...
The tag syntax
A tag has up to four parts: the tag indicator #, a name, a parameter list in parentheses, and an optional body that
starts after a colon and ends with an #end... tag. Everything outside a tag is copied to the output unchanged, which
is why the CSS braces and JavaScript braces of a page need no escaping in Leaf 4: bodies are introduced by :, not by
{. A # that does not start a tag (<a href="#top">, color: #fff, a CSS selector #main {) is also left as it
is; to write a literal tag name or to protect text that would otherwise parse as a tag, escape the indicator with a
backslash: \#42 renders as #42.
Variables and expressions
#(name) prints a variable; the dotted path #(book.title) reaches into nested structs and dictionaries. Inside
the parentheses of any tag you can write an expression with these operators, all verified by rendering:
| Operators | Notes |
|---|---|
|
Comparison; strings compare with strings ( |
|
Logical and, or, not; |
|
Arithmetic. |
|
Grouping. |
Behaviours worth knowing before you debug them:
-
An undefined variable (
#(nothing)), a missing nested key (#(book.nothing)) and aniloptional all print nothing; none is an error. A misspelled variable name is therefore visible only as a gap in the page. In a condition they are false, so#if(user):is the "is it present" test (but see the truthiness note under conditions). -
A
Boolprintstrueorfalse, aDoubleprints like2.5, an array prints as a quoted list (["a", "b", "c"], HTML-escaped), aDateprints as its number of seconds. -
Subscripts (
items[0]) and the nil-coalescing operator??are rejected by the lexer in LeafKit 1.14.3, and so is assignment:#let(x = 5)fails with "= is not yet supported as an operator". Leaf 4 has no local variables; do the computation in Swift. -
Inside the parentheses of another tag, a built-in tag is written without the
#:#if(count(books) > 0):,#if(contains(tags, "classic")):.
Conditions: if, elseif, else
#if(count(books) == 0):
<p>No books.</p>
#elseif(count(books) < 5):
<p>A few books.</p>
#else:
<p>Many books.</p>
#endif
Source: Vapor docs: Leaf, overview
Truthiness is narrower than in JavaScript or Python, and this is the most common surprise. Rendering showed that
#if(x): is false only for false, nil and an undefined variable; it is true for the number 0, the empty
string "" and an empty array. So #if(books): does not test for emptiness: write #if(count(books) > 0):,
#if(name != ""): for strings and #if(n != 0): for numbers. The keywords true, false, nil, yes, no and
self are available inside expressions.
Loops: for
<ul>
#for(book in books):
<li>#(book.title)</li>
#endfor
</ul>
#for(i, book in books):
<li>#(i + 1). #(book.title)#if(isLast): (last)#endif</li>
#endfor
Source: Vapor docs: Leaf, overview
Inside the body of a #for, three variables describe the iteration: isFirst and isLast (booleans) and index (the
zero-based position). In the first form the index is called index; in the second form you pick the name (i above)
and index is not set. The official overview documents only the indexed form; isFirst and isLast are set by the
serializer (LeafSerializer) for every loop. The loop variables of the inner loop shadow those of the outer one: in
the catalogue template the comma between tags uses #if(!isLast):, #endif inside the inner loop, which tests the
inner list, while the first/last row classes are tested in the outer loop body before the inner loop starts. The
array may be a dotted path (#for(x in nested.k):). Looping over something that is not an array fails with expected
array at key: n; the serializer requires an array, so a dictionary cannot be looped over directly.
Layouts: extend, export, import
#extend("base"):
#export("title", title)
#export("content"):
<h1>Hello</h1>
#endexport
#endextend
Source: Vapor docs: Leaf, overview
-
#extend("base"):…#endextendrendersResources/Views/base.leafwith the exports of its body available to its#importtags. The name has no extension (the renderer acceptedbase.leafas well, but the documentation says to omit it). A name with directories is a path below the views directory (partials/nav). -
#export("name"): ... #endexportstores a block;#export("title", title)is the short form that exports a single expression, convenient for a page title. -
#import("name")in the layout prints the block exported under that name. An import with no matching export prints nothing and is not an error: that is theignoreUnfoundImportssetting ofLeafConfiguration, which istrueby default (verified with#import("nothing")). -
#extend("footer")with no body is an include: it pastes the other template at that point, with the current context. This is whatpartials/navandpartials/footerare. -
Exports and includes share the page’s context:
user,booksandtitleare visible in the layout and in the partials. There is no syntax in 4.5 for passing different values to an include (#extend("x", ["k": v])does not lex); put what the partial needs in the context. -
A template that is its own ancestor fails with a "cyclically referenced" error (from the LeafKit source; not rendered here).
with
#with(book): ... #endwith runs its body with the keys of the dictionary book promoted to the top level, so
#with(book):#(title)/#(year)#endwith prints Dune/1965. It is implemented in LeafKit (Syntax.With) and verified
by rendering, but the official overview does not document it.
Built-in tags
| Tag | Meaning | Verified behaviour |
|---|---|---|
|
Number of elements |
Arrays and dictionaries; anything else throws. |
|
Case conversion ( |
|
|
True when the array holds the value |
|
|
Formats a number of seconds since 1970 with a |
With one parameter the pattern is |
|
True when a string is empty |
Throws "unable to check for empty value; cannot convert parameter to string" when given an array. Use |
|
Prints the string without HTML escaping |
See Escaping and XSS. |
|
Prints nothing; the text may span lines with a triple-quoted string |
The text is not parsed for tags. |
|
The whole context as one value |
Takes no parameters; its output is escaped like any other. Remove it before deploying: it prints every context value, including ones you did not mean to show. |
These ten tags, plus the control-flow tags (if, elseif, else, for, with, extend, export, import), are
all that exist; there is no #set/#get, no #embed, no #loop, no #raw, no #min/#max. Anything else is a
custom tag (the Leaf Custom Tags and HTML DSLs page) or Swift
code in the handler.
A probe template that exercises most of them, with its real output:
name=[#(name)] missing=[#(nothing)] nested=[#(book.nothing)]
not=[#if(!flag):yes#endif] and=[#if(n > 3 && !flag):yes#endif] or=[#if(flag || n == 7):yes#endif]
math=[#(n * 2 + 1)] mod=[#(n % 4)] price=[#(price)] int=[#(n)] bool=[#(flag)]
items=[#(items)] count=[#count(items)] contains=[#contains(items, "b")]
with:#with(book):[#(title)/#(year)]#endwith
nestedloop:#for(x in nested.k):#(x)#(isLast)#endfor
#for(i, item in items):#(i)#(item)#if(isFirst):F#endif#if(isLast):L#endif|#endfor
with the context name: nil, n: 7, items: ["a", "b", "c"], flag: false, price: 2.5 this prints:
name=[] missing=[] nested=[]
not=[yes] and=[yes] or=[yes]
math=[15] mod=[3] price=[2.5] int=[7] bool=[false]
items=[["a", "b", "c"]] count=[3] contains=[true]
with:[Dune/1965]
nestedloop:xfalseytrue
0aF|1b|2cL|
Escaping and XSS
#(variable) and the output of every tag are HTML-escaped by default: &, <, >, " and ' become entities.
That is the protection against stored and reflected cross-site scripting, and it is why the <script> review in
books/show.leaf came out inert. The escaping is applied to the result of the tag, not to your template text, so the
HTML you write yourself is copied as is. Rules that follow:
-
The only ways to emit unescaped text are
#unsafeHTML(value)and a custom tag that conforms toUnsafeUnescapedLeafTag. Use them only for markup your own code produced or sanitized (a Markdown-to-HTML result from a trusted editor, an SVG icon string). Never pass user input, a Markdown conversion of user input without a sanitizer, or a database field a user can set. -
Escaping is context-free: it is correct for text between tags and for double-quoted attribute values. It is not enough inside a
<script>block, in an inline event handler, in astyleattribute, or in an unquoted attribute. Do not interpolate user data into JavaScript; put it in adata-attribute (quoted) and read it from the script, or serialize it to JSON in Swift and use a dedicated, escaped,type="application/json"element. -
URLs need their own validation:
href="#(url)"escapes quotes but still allowsjavascript:. Validate the scheme in Swift before putting a user-supplied URL in the context. -
The
#dumpContextdemo above shows that the debug output is escaped as well, so it is safe to look at, but not safe to leave in. -
A
Content-Security-Policyheader, set by a middleware (see the Middleware page and the Security Hardening page), is the second layer: it limits what an injected script can do even when an escaping rule is missed.
Configuration, the cache and template sources
app.leaf is a small struct over per-application storage with these members (all settable before the first render):
| Member | Meaning |
|---|---|
|
A |
|
The |
|
The |
|
A |
|
Values handed to every tag through |
import Leaf
import LeafKit
import Vapor
/// Optional Leaf settings; call before the first view is rendered.
func tuneLeaf(_ app: Application) {
// Templates in "Templates/" instead of "Resources/Views/".
app.leaf.configuration = LeafConfiguration(rootDirectory: app.directory.workingDirectory + "Templates/")
// Compiled templates are cached unless the environment is .development, where Leaf already turns the
// cache off. Say so explicitly if you like, but only for development: testing, staging and production
// keep the cache on.
if app.environment == .development {
app.leaf.cache.isEnabled = false
}
}
/// Renders a template to a String, for example for an e-mail body.
func renderToString(_ req: Request, _ name: String, _ context: some Encodable) async throws -> String {
let view = try await req.view.render(name, context)
return String(buffer: view.data)
}
The cache has one rule that explains the most common "my change does not show" report: when the application’s
environment is .development, the renderer is created with the cache disabled (the 4.5.2 source of
Application.Leaf.renderer does cache.isEnabled = false for .development); in any other environment, including
.production and .testing, templates are parsed once and kept. Running the same binary both ways shows it:
in production a template that failed to render kept failing after the file was fixed, until the process was restarted;
in development the fix showed on the next request. Consequences:
-
Do not turn the cache off in production: every request would re-read and re-parse the files.
-
Deploy templates with the binary and restart on deploy; there is no hot reload in production.
-
A custom environment (for example
staging) caches, which is what you want for it. -
LeafConfigurationalso has process-wide, write-once static settings (tagIndicator,encoding, and formatters such asboolFormatteranddoubleFormatter) that must be set before the firstLeafRendererexists; changing them later is ignored (and trips anassertin debug builds). Changing the tag indicator is rarely worth it; if a template has to contain many literal#, escape them with\#.
Testing rendered pages
Rendering is deterministic, so a page is tested like any other route (see the Testing page), by asserting on the HTML. The two tests below ran with VaporTesting and Swift Testing against in-memory SQLite:
import Fluent
import Testing
import VaporTesting
@testable import App
@Suite struct LeafTests {
@Test func catalogueRendersEscapedTitles() async throws {
let app = try await Application.make(.testing)
do {
try await configure(app)
try await app.testing().test(.GET, "books/demo?user=ana") { res async in
#expect(res.status == .ok)
#expect(res.headers.contentType == .html)
let html = res.body.string
#expect(html.contains("Tom & Jerry <Annual>"))
#expect(!html.contains("<Annual>"))
#expect(html.contains("Signed in as ana"))
#expect(html.contains("<td>PUBLISHED</td>"))
}
} catch { try await app.asyncShutdown(); throw error }
try await app.asyncShutdown()
}
@Test func databaseBackedCatalogueListsOnlyPublished() async throws {
let app = try await Application.make(.testing)
do {
try await configure(app)
let author = Author(name: "Frank Herbert")
try await author.save(on: app.db)
let authorID = try author.requireID()
try await Book(title: "Dune", isbn: "9780441013593", publishedYear: 1965, priceCents: 999,
status: .published, authorID: authorID).save(on: app.db)
try await Book(title: "Unreleased", isbn: "9780000000019", publishedYear: 2030, priceCents: 100,
status: .draft, authorID: authorID).save(on: app.db)
try await app.testing().test(.GET, "books") { res async in
let html = res.body.string
#expect(html.contains("Dune"))
#expect(!html.contains("Unreleased"))
#expect(html.contains("<td>9.99</td>"))
}
} catch { try await app.asyncShutdown(); throw error }
try await app.asyncShutdown()
}
}
Source: Vapor docs: Testing
Output of swift test:
✔ Test catalogueRendersEscapedTitles() passed after 0.031 seconds.
✔ Test databaseBackedCatalogueListsOnlyPublished() passed after 0.031 seconds.
✔ Suite LeafTests passed after 0.031 seconds.
✔ Test run with 2 tests in 1 suite passed after 0.031 seconds.
Assert on a few stable fragments (the escaped title, a status cell, the absence of a draft), not on the whole document,
because whitespace around tags changes whenever someone reformats a template. The .testing environment is not
.development, so templates are cached there too. For end-to-end checks of layout and behaviour in a real browser, see
the site’s page on end-to-end testing in the browser.
Errors you will meet
Messages below were produced by the scratch package (the JSON the default error middleware sends is
{"error":true,"reason":"..."}):
| Message | Cause | Fix |
|---|---|---|
|
The name does not match a file below the views directory, or the working directory is wrong (Xcode). |
Check the path and the extension-less name; set the working directory. |
|
|
|
|
|
Pass an array or wrap the value. |
|
An unsupported token in a parameter list: |
Rewrite the expression; compute in Swift. |
|
Assignment such as |
There are no local variables; compute in Swift. |
|
|
Pass the |
|
Templates extend each other in a loop. |
Break the cycle. |
Two quieter failures produce no error: an unclosed body (a #if(n):x without #endif rendered an empty page in
the probe) and a typo in a variable name (it prints nothing). Both are caught by a test that asserts on a fragment of
the output. A LeafError always travels to the client with status 500 and its reason text, which for lexer errors
includes the template name and the tokens lexed so far. Put an error-rendering middleware in front for browsers (the
Error Handling page) so that visitors see an HTML error page, and the detail
goes to the log only.
Leaf 2/3 syntax to Leaf 4
The books listed in the section’s bibliography use Leaf 2 (Patel, on Vapor 2) and Leaf 3 (Yeung, and chapters 14 to
36 of Condon et al., on Vapor 3); their templates do not render on Leaf 4 as written. The Leaf 3 column follows the
archived Leaf 3 documentation (docs.vapor.codes/3.0/leaf). Leaf 2 shares most tags with Leaf 3 and additionally had a
#loop tag; both are replaced by the Leaf 4 forms on the right.
| Concern | Leaf 2/3 | Leaf 4 (this page) |
|---|---|---|
Register |
|
|
Render |
|
|
Condition |
|
|
Loop |
|
|
Loop metadata |
|
Same; the index variable can be named |
Layout and partials |
|
|
Comments |
|
|
Case tags |
|
|
|
format pattern as second parameter |
Same, plus an optional time zone as third parameter |
Body delimiters |
Braces |
Colon and |
Package |
|
|
Swift 6 and concurrency notes
-
Everything in the render path is
async; no.wait()and noEventLoopFutureappear in new code. The futures overload (render(_:_:) -> EventLoopFuture<View>) is still the requirement of the 4.xViewRendererprotocol and is what theasyncmethod calls. -
app.leaf.tagsandapp.leaf.userInfoare plain dictionaries in storage that is written during configuration; mutate them only inconfigure, never from a handler, or you introduce a data race that the compiler cannot see (the storage class is@unchecked Sendable). -
LeafKit’s
LeafConfigurationstatic settings anddefaultTagsarenonisolated(unsafe)globals in LeafKit 1.14.3; treat them as write-once, set before the server starts. -
Custom tags receive a
LeafContextwhoseuserInfoholds non-Sendablereferences to theRequestandApplication; read them synchronously insiderender, do not store them. -
A view-model is a plain
Sendablestruct: it can be built in one task (a Fluent query) and rendered in another with no warnings under complete strict concurrency.
Version notes
-
Leaf 2 and 3 (Vapor 2 and 3) are unmaintained; their brace syntax and
#set/#get/#embedare not accepted by Leaf 4. -
Leaf 4 (2020 onward, Vapor 4) is built on LeafKit 1.x: colon bodies,
#extend/#export/#import,ViewRendererintegration. The documentation still saysfrom: "4.4.0"; 4.5.2 is the release used here. -
Cache in development. The disabling of the cache for
.developmentis in the 4.5.2 source; the documentation says only that the cache is off during development. -
Vapor 5. There is no Leaf release that targets Vapor 5 at the time of writing (
vapor/leafmainstill depends on Vapor 4.x). In the 5.0.0-beta.4 sources theViewRendererprotocol has only anasync throwsrender(_:_:)requirement (in 4.122 the requirement returns anEventLoopFuture<View>and theasyncmethod is an extension), so a Leaf for Vapor 5 has to be a new release. Keep templates free of Swift specifics and they will carry over; theapp.views.use(.leaf)call andreq.view.renderare the surface that may be renamed.
Where the books are outdated
-
Condon et al. (Server-Side Swift with Vapor, Kodeco) renders the TIL web site with Leaf 3 in chapters 14 and 15: contexts as
Encodablestructs,#if/#for/#count, abase.leaflayout with#embed/#set/#get, and Bootstrap. The concepts (context structs, escaping, a layout and partials) are unchanged; the braces,#set/#get/#embed,req.view().renderand theLeafProviderregistration are replaced as in the table above. -
Yeung (Packt) uses Leaf 3 for partials and a Bootstrap grid, navbar and forms loaded from a CDN with Subresource Integrity. The partial idea maps to
#extend("partial"); the Bootstrap part is covered on the Server-Rendered Web Apps and Forms page. -
Patel (Packt) uses Leaf 2 layouts and one set of routes that answers HTML and JSON (content negotiation); Leaf 2 syntax does not apply to Leaf 4, and Vapor 2’s
drop.viewisreq.view.
References
-
Kodeco: Server-Side Swift with Vapor (Condon et al.)