├── .github
├── CODEOWNERS
├── contributing.md
└── workflows
│ ├── api-docs.yml
│ └── test.yml
├── .gitignore
├── .spi.yml
├── LICENSE
├── Package.swift
├── README.md
├── Sources
└── Leaf
│ ├── Application+Leaf.swift
│ ├── Docs.docc
│ ├── Resources
│ │ └── vapor-leaf-logo.svg
│ ├── index.md
│ └── theme-settings.json
│ ├── Exports.swift
│ ├── LeafEncoder.swift
│ ├── LeafError+AbortError.swift
│ ├── LeafRenderer+ViewRenderer.swift
│ └── Request+Leaf.swift
└── Tests
└── LeafTests
├── LeafEncoderTests.swift
├── LeafTests.swift
└── Views
├── bar.leaf
├── base.leaf
├── foo.leaf
└── hello.leaf
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @0xTim @gwynne
2 |
--------------------------------------------------------------------------------
/.github/contributing.md:
--------------------------------------------------------------------------------
1 | # Contributing to Leaf
2 |
3 | 👋 Welcome to the Vapor team!
4 |
5 | ## Overview
6 |
7 | Leaf is a templating language built for Swift. You can read more about it at [docs.vapor.codes/4.0/leaf/getting-started/](https://docs.vapor.codes/4.0/leaf/getting-started/). The `leaf` package integrates the templating language provided by [leaf-kit](https://github.com/vapor/leaf-kit.git) with Vapor's `View` system.
8 |
9 | ## Feature Requests
10 |
11 | Please read these guidelines before submitting a feature request to Leaf. Due to the large volume of issues we receive, feature requests that do not follow these guidelines will be closed.
12 |
13 | ### Do:
14 |
15 | - ✅ Clearly define a problem currently affecting Leaf users.
16 |
17 | > Make it clear what you are currently _not able_ to do with Leaf and why you think this matters to most people using it.
18 |
19 | - ✅ Clearly define a solution, with plenty of examples.
20 |
21 | > Show what a solution to this problem looks like to use. Pretend you are writing documentation for it.
22 |
23 | - ✅ Propose more than one solution.
24 |
25 | > If you have lots of ideas, share them. They will help in the brainstorming process.
26 |
27 | - ✅ Relate the problem and solution to Leaf's existing APIs.
28 |
29 | > Explain clearly how your feature would fit in with the rest of what Leaf offers.
30 |
31 | - ✅ Show examples from other templating languages.
32 |
33 | > If the feature you are requesting exists in other templating languages, include examples. This will help everyone understand the idea better as well as provide additional sources of inspiration.
34 |
35 | ### Do not:
36 |
37 | - ❌ Submit large PRs without discussion first.
38 |
39 | > Give everyone a chance to understand your idea and get on the same page before submitting code. This can be through a GitHub issue or the Swift forums.
40 |
41 | ## SemVer
42 |
43 | Vapor follows [SemVer](https://semver.org). This means that any changes to the source code that can cause existing code to stop compiling _must_ wait until the next major version to be included.
44 |
45 | Code that is only additive and will not break any existing code can be included in the next minor release.
46 |
47 | ----------
48 |
49 | Join us in Chat if you have any questions: [http://vapor.team](http://vapor.team).
50 |
51 | — Thanks! 🙌
52 |
--------------------------------------------------------------------------------
/.github/workflows/api-docs.yml:
--------------------------------------------------------------------------------
1 | name: deploy-api-docs
2 | on:
3 | push:
4 | branches:
5 | - main
6 |
7 | jobs:
8 | build-and-deploy:
9 | uses: vapor/api-docs/.github/workflows/build-and-deploy-docs-workflow.yml@main
10 | secrets: inherit
11 | with:
12 | package_name: leaf
13 | modules: Leaf
14 | pathsToInvalidate: /leaf/*
15 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: test
2 | concurrency:
3 | group: ${{ github.workflow }}-${{ github.ref }}
4 | cancel-in-progress: true
5 | on:
6 | pull_request: { types: [opened, reopened, synchronize, ready_for_review] }
7 | push: { branches: [ main ] }
8 |
9 | jobs:
10 | unit-tests:
11 | uses: vapor/ci/.github/workflows/run-unit-tests.yml@main
12 | with:
13 | with_musl: true
14 | with_android: true
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | Package.resolved
6 | DerivedData
7 | .swiftpm
8 |
9 |
--------------------------------------------------------------------------------
/.spi.yml:
--------------------------------------------------------------------------------
1 | version: 1
2 | metadata:
3 | authors: "Maintained by the Vapor Core Team with hundreds of contributions from the Vapor Community."
4 | external_links:
5 | documentation: "https://docs.vapor.codes/leaf/getting-started/"
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2018 Qutheory, LLC
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.10
2 | import PackageDescription
3 |
4 | let package = Package(
5 | name: "leaf",
6 | platforms: [
7 | .macOS(.v10_15),
8 | .iOS(.v13),
9 | .tvOS(.v13),
10 | .watchOS(.v6)
11 | ],
12 | products: [
13 | .library(name: "Leaf", targets: ["Leaf"]),
14 | ],
15 | dependencies: [
16 | .package(url: "https://github.com/vapor/leaf-kit.git", from: "1.12.0"),
17 | .package(url: "https://github.com/vapor/vapor.git", from: "4.114.1"),
18 | .package(url: "https://github.com/apple/swift-algorithms.git", from: "1.2.1"),
19 | ],
20 | targets: [
21 | .target(
22 | name: "Leaf",
23 | dependencies: [
24 | .product(name: "LeafKit", package: "leaf-kit"),
25 | .product(name: "Vapor", package: "vapor"),
26 | .product(name: "Algorithms", package: "swift-algorithms")
27 | ],
28 | swiftSettings: swiftSettings
29 | ),
30 | .testTarget(
31 | name: "LeafTests",
32 | dependencies: [
33 | .target(name: "Leaf"),
34 | .product(name: "XCTVapor", package: "vapor"),
35 | ],
36 | exclude: [
37 | "Views",
38 | ],
39 | swiftSettings: swiftSettings
40 | ),
41 | ]
42 | )
43 |
44 | var swiftSettings: [SwiftSetting] { [
45 | .enableUpcomingFeature("ExistentialAny"),
46 | .enableUpcomingFeature("ConciseMagicFile"),
47 | .enableUpcomingFeature("ForwardTrailingClosures"),
48 | .enableUpcomingFeature("DisableOutwardActorInference"),
49 | .enableExperimentalFeature("StrictConcurrency=complete"),
50 | ] }
51 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | Leaf provides integrations between [LeafKit](https://github.com/vapor/leaf-kit) and [Vapor](https://github.com/vapor/vapor) to make it easy to use Leaf templates in your Vapor app. It provides extensions to make it easy to set up, configure and use Leaf as your renderer in Vapor. It also conforms ``LeafRenderer`` to ``ViewRenderer`` so it can be used to render generic Views in Vapor.
16 |
--------------------------------------------------------------------------------
/Sources/Leaf/Application+Leaf.swift:
--------------------------------------------------------------------------------
1 | import LeafKit
2 | import Vapor
3 |
4 | extension Application.Views.Provider {
5 | public static var leaf: Self {
6 | .init {
7 | $0.views.use {
8 | $0.leaf.renderer
9 | }
10 | }
11 | }
12 | }
13 |
14 | extension Application {
15 | public var leaf: Leaf {
16 | .init(application: self)
17 | }
18 |
19 | public struct Leaf {
20 | public let application: Application
21 |
22 | public var renderer: LeafRenderer {
23 | var userInfo = self.userInfo
24 | userInfo["application"] = self.application
25 |
26 | var cache = self.cache
27 | if self.application.environment == .development {
28 | cache.isEnabled = false
29 | }
30 | return .init(
31 | configuration: self.configuration,
32 | tags: self.tags,
33 | cache: cache,
34 | sources: self.sources,
35 | eventLoop: self.application.eventLoopGroup.next(),
36 | userInfo: userInfo
37 | )
38 | }
39 |
40 | public var configuration: LeafConfiguration {
41 | get {
42 | self.storage.configuration ??
43 | LeafConfiguration(rootDirectory: self.application.directory.viewsDirectory)
44 | }
45 | nonmutating set {
46 | self.storage.configuration = newValue
47 | }
48 | }
49 |
50 | public var tags: [String: any LeafTag] {
51 | get {
52 | self.storage.tags
53 | }
54 | nonmutating set {
55 | self.storage.tags = newValue
56 | }
57 | }
58 |
59 | public var sources: LeafSources {
60 | get {
61 | self.storage.sources ?? LeafSources.singleSource(NIOLeafFiles(
62 | fileio: self.application.fileio,
63 | limits: .default,
64 | sandboxDirectory: self.configuration.rootDirectory,
65 | viewDirectory: self.configuration.rootDirectory
66 | ))
67 | }
68 | nonmutating set {
69 | self.storage.sources = newValue
70 | }
71 | }
72 |
73 | public var cache: any LeafCache {
74 | get {
75 | self.storage.cache
76 | }
77 | nonmutating set {
78 | self.storage.cache = newValue
79 | }
80 | }
81 |
82 | public var userInfo: [AnyHashable: Any] {
83 | get {
84 | self.storage.userInfo
85 | }
86 | nonmutating set {
87 | self.storage.userInfo = newValue
88 | }
89 | }
90 |
91 | var storage: Storage {
92 | if let existing = self.application.storage[Key.self] {
93 | return existing
94 | } else {
95 | let new = Storage()
96 | self.application.storage[Key.self] = new
97 | return new
98 | }
99 | }
100 |
101 | struct Key: StorageKey {
102 | typealias Value = Storage
103 | }
104 |
105 | final class Storage: @unchecked Sendable {
106 | var cache: any LeafCache
107 | var configuration: LeafConfiguration?
108 | var sources: LeafSources?
109 | var tags: [String: any LeafTag]
110 | var userInfo: [AnyHashable: Any]
111 |
112 | init() {
113 | self.cache = DefaultLeafCache()
114 | self.tags = LeafKit.defaultTags
115 | self.userInfo = [:]
116 | }
117 | }
118 | }
119 | }
120 |
121 | extension LeafContext {
122 | public var application: Application? {
123 | self.userInfo["application"] as? Application
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/Sources/Leaf/Docs.docc/Resources/vapor-leaf-logo.svg:
--------------------------------------------------------------------------------
1 |
26 |
27 |
--------------------------------------------------------------------------------
/Sources/Leaf/Docs.docc/index.md:
--------------------------------------------------------------------------------
1 | # ``Leaf``
2 |
3 | Leaf provides integrations between [LeafKit](https://github.com/vapor/leaf-kit) and [Vapor](https://github.com/vapor/vapor) to make it easy to use Leaf templates in your Vapor app. It provides extensions to make it easy to set up, configure and use Leaf as your renderer in Vapor. It also conforms ``LeafRenderer`` to ``ViewRenderer`` so it can be used to render generic Views in Vapor.
--------------------------------------------------------------------------------
/Sources/Leaf/Docs.docc/theme-settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "theme": {
3 | "aside": { "border-radius": "16px", "border-style": "double", "border-width": "3px" },
4 | "border-radius": "0",
5 | "button": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" },
6 | "code": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" },
7 | "color": {
8 | "leaf": { "dark": "hsl(136, 43%, 53%)", "light": "hsl(136, 33%, 48%)" },
9 | "documentation-intro-fill": "radial-gradient(circle at top, var(--color-leaf) 30%, #000 100%)",
10 | "documentation-intro-accent": "var(--color-leaf)",
11 | "documentation-intro-eyebrow": "white",
12 | "documentation-intro-figure": "white",
13 | "documentation-intro-title": "white",
14 | "logo-base": { "dark": "#fff", "light": "#000" },
15 | "logo-shape": { "dark": "#000", "light": "#fff" },
16 | "fill": { "dark": "#000", "light": "#fff" }
17 | },
18 | "icons": { "technology": "/leaf/images/vapor-leaf-logo.svg" }
19 | },
20 | "features": {
21 | "quickNavigation": { "enable": true },
22 | "i18n": { "enable": true }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Sources/Leaf/Exports.swift:
--------------------------------------------------------------------------------
1 | @_documentation(visibility: internal) @_exported import protocol LeafKit.LeafTag
2 | @_documentation(visibility: internal) @_exported import protocol LeafKit.UnsafeUnescapedLeafTag
3 | @_documentation(visibility: internal) @_exported import struct LeafKit.LeafData
4 | @_documentation(visibility: internal) @_exported import struct LeafKit.LeafContext
5 | @_documentation(visibility: internal) @_exported import enum LeafKit.Syntax
6 |
--------------------------------------------------------------------------------
/Sources/Leaf/LeafEncoder.swift:
--------------------------------------------------------------------------------
1 | import Algorithms
2 | import LeafKit
3 |
4 | struct LeafEncoder {
5 | /// Use `Codable` to convert an (almost) arbitrary encodable type to a dictionary of key/``LeafData`` pairs
6 | /// for use as a rendering context. The type's encoded form must have a dictionary (keyed container) at its
7 | /// top level; it may not be an array or scalar value.
8 | static func encode(_ encodable: some Encodable) throws -> [String: LeafData] {
9 | let encoder = EncoderImpl(codingPath: [])
10 | try encodable.encode(to: encoder)
11 |
12 | // If the context encoded nothing at all, yield an empty dictionary.
13 | let data = encoder.storage?.resolvedData ?? .dictionary([:])
14 |
15 | // Unfortunately we have to delay this check until this point thanks to `Encoder` ever so helpfully not
16 | // declaring most of its methods as throwing.
17 | guard let dictionary = data.dictionary else {
18 | throw LeafError(.illegalAccess(
19 | "Leaf contexts must be dictionaries or structure types; arrays and scalar values are not permitted."
20 | ))
21 | }
22 |
23 | return dictionary
24 | }
25 | }
26 |
27 | // MARK: - Private
28 |
29 | /// One of these is always necessary when implementing an unkeyed container, and needed quite often for most
30 | /// other things in Codable. Sure would be nice if the stdlib had one instead of there being 1000-odd versions
31 | /// floating around various dependencies.
32 | private struct GenericCodingKey: CodingKey, Hashable {
33 | let stringValue: String
34 | let intValue: Int?
35 |
36 | init(stringValue: String) {
37 | self.stringValue = stringValue
38 | self.intValue = Int(stringValue)
39 | }
40 |
41 | init(intValue: Int) {
42 | self.stringValue = "\(intValue)"
43 | self.intValue = intValue
44 | }
45 |
46 | var description: String {
47 | "GenericCodingKey(\"\(self.stringValue)\"\(self.intValue.map { ", int: \($0)" } ?? ""))"
48 | }
49 | }
50 |
51 | /// Helper protocol allowing a single existential representation for all of the possible nested storage patterns
52 | /// that show up during encoding.
53 | private protocol LeafEncodingResolvable {
54 | var resolvedData: LeafData? {
55 | get
56 | }
57 | }
58 |
59 | /// A ``LeafData`` value always resolves to itself.
60 | extension LeafData: LeafEncodingResolvable {
61 | var resolvedData: LeafData? {
62 | self
63 | }
64 | }
65 |
66 | extension LeafEncoder {
67 | /// The ``Encoder`` conformer.
68 | private final class EncoderImpl: Encoder, LeafEncodingResolvable, SingleValueEncodingContainer {
69 | // See `Encoder.userinfo`.
70 | let userInfo: [CodingUserInfoKey: Any]
71 |
72 | // See `Encoder.codingPath`.
73 | let codingPath: [any CodingKey]
74 |
75 | /// This encoder's root stored value, if any has been encoded.
76 | var storage: (any LeafEncodingResolvable)?
77 |
78 | /// An encoder can be resolved to the resolved value of its storage. This ability is used to support the
79 | /// the use of `superEncoder()` and `superEncoder(forKey:)`.
80 | var resolvedData: LeafData? {
81 | self.storage?.resolvedData
82 | }
83 |
84 | init(userInfo: [CodingUserInfoKey: Any] = [:], codingPath: [any CodingKey]) {
85 | self.userInfo = userInfo
86 | self.codingPath = codingPath
87 | }
88 |
89 | convenience init(from encoder: EncoderImpl, withKey key: (any CodingKey)?) {
90 | self.init(userInfo: encoder.userInfo, codingPath: encoder.codingPath + [key].compacted())
91 | }
92 |
93 | /// Need to expose the ability to access unwrapped keyed container to enable use of nested
94 | /// keyed containers (see the keyed and unkeyed containers).
95 | func rawContainer(keyedBy type: Key.Type) -> KeyedContainerImpl {
96 | guard self.storage == nil else {
97 | fatalError("Can't encode to multiple containers at the same encoding level")
98 | }
99 |
100 | self.storage = KeyedContainerImpl(encoder: self)
101 | return self.storage as! KeyedContainerImpl
102 | }
103 |
104 | // See `Encoder.container(keyedBy:)`.
105 | func container(keyedBy type: Key.Type) -> KeyedEncodingContainer {
106 | .init(self.rawContainer(keyedBy: type))
107 | }
108 |
109 | // See `Encoder.unkeyedContainer()`.
110 | func unkeyedContainer() -> any UnkeyedEncodingContainer {
111 | guard self.storage == nil else {
112 | fatalError("Can't encode to multiple containers at the same encoding level")
113 | }
114 |
115 | self.storage = UnkeyedContainerImpl(encoder: self)
116 | return self.storage as! UnkeyedContainerImpl
117 | }
118 |
119 | // See `Encoder.singleValueContainer()`.
120 | func singleValueContainer() -> any SingleValueEncodingContainer {
121 | guard self.storage == nil else {
122 | fatalError("Can't encode to multiple containers at the same encoding level")
123 | }
124 |
125 | return self
126 | }
127 |
128 | // See `SingleValueEncodingContainer.encodeNil()`.
129 | func encodeNil() throws {}
130 |
131 | // See `SingleValueEncodingContainer.encode(_:)`.
132 | func encode(_ value: some Encodable) throws {
133 | self.storage = try self.encode(value, forKey: nil)
134 | }
135 |
136 | /// Encode an arbitrary encodable input, optionally deepening the current coding path with a
137 | /// given key during encoding, and return it as a resolvable item.
138 | func encode(_ value: some Encodable, forKey key: (any CodingKey)?) throws -> (any LeafEncodingResolvable)? {
139 | if let leafRepresentable = value as? any LeafDataRepresentable {
140 | /// Shortcut through ``LeafDataRepresentable`` if `value` conforms to it.
141 | return leafRepresentable.leafData
142 | } else {
143 | /// Otherwise, route encoding through a new subdecoder based on self, with an appropriate
144 | /// coding path. This is the central recursion point of the entire Codable setup.
145 | let subencoder = Self.init(from: self, withKey: key)
146 |
147 | try value.encode(to: subencoder)
148 | return subencoder.storage?.resolvedData
149 | }
150 | }
151 | }
152 |
153 | private final class KeyedContainerImpl: KeyedEncodingContainerProtocol, LeafEncodingResolvable where Key: CodingKey {
154 | private let encoder: EncoderImpl
155 | private var data: [String: any LeafEncodingResolvable] = [:]
156 | private var nestedEncoderCaptures: [AnyObject] = []
157 |
158 | // See `LeafEncodingResolvable.resolvedData`.
159 | var resolvedData: LeafData? {
160 | .dictionary(self.data.compactMapValues { $0.resolvedData })
161 | }
162 |
163 | init(encoder: EncoderImpl) {
164 | self.encoder = encoder
165 | }
166 |
167 | // See `KeyedEncodingContainerProtocol.codingPath`.
168 | var codingPath: [any CodingKey] {
169 | self.encoder.codingPath
170 | }
171 |
172 | // See `KeyedEncodingContainerProtocol.encodeNil()`.
173 | func encodeNil(forKey key: Key) throws {}
174 |
175 | // See `KeyedEncodingContainerProtocol.encode(_:forKey:)`.
176 | func encode(_ value: some Encodable, forKey key: Key) throws {
177 | guard let encodedValue = try self.encoder.encode(value, forKey: key) else {
178 | return
179 | }
180 |
181 | self.data[key.stringValue] = encodedValue
182 | }
183 |
184 | // See `KeyedEncodingContainerProtocol.nestedContainer(keyedBy:forKey:)`.
185 | func nestedContainer(keyedBy keyType: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer {
186 | let nestedEncoder = EncoderImpl(from: self.encoder, withKey: key)
187 |
188 | self.nestedEncoderCaptures.append(nestedEncoder)
189 |
190 | /// Use a subencoder to create a nested container so the coding paths are correctly maintained.
191 | /// Save the subcontainer in our data so it can be resolved later before returning it.
192 | return .init(self.insert(
193 | nestedEncoder.rawContainer(keyedBy: NestedKey.self),
194 | forKey: key,
195 | as: KeyedContainerImpl.self
196 | ))
197 | }
198 |
199 | // See `KeyedEncodingContainerProtocol.nestedUnkeyedContainer(forKey:)`.
200 | func nestedUnkeyedContainer(forKey key: Key) -> any UnkeyedEncodingContainer {
201 | let nestedEncoder = EncoderImpl(from: self.encoder, withKey: key)
202 |
203 | self.nestedEncoderCaptures.append(nestedEncoder)
204 |
205 | return self.insert(
206 | nestedEncoder.unkeyedContainer() as! UnkeyedContainerImpl,
207 | forKey: key
208 | )
209 | }
210 |
211 | /// A super encoder is, in fact, just a subdecoder with delusions of grandeur and some rather haughty
212 | /// pretensions. (It's mostly Codable's fault anyway.)
213 | func superEncoder() -> any Encoder {
214 | self.insert(
215 | EncoderImpl(from: self.encoder, withKey: GenericCodingKey(stringValue: "super")),
216 | forKey: GenericCodingKey(stringValue: "super")
217 | )
218 | }
219 |
220 | // See `KeyedEncodingContainerProtocol/superEncoder(forKey:)`.
221 | func superEncoder(forKey key: Key) -> any Encoder {
222 | self.insert(EncoderImpl(from: self.encoder, withKey: key), forKey: key)
223 | }
224 |
225 | /// Helper for the encoding methods.
226 | private func insert(_ value: any LeafEncodingResolvable, forKey key: any CodingKey, as: T.Type = T.self) -> T {
227 | self.data[key.stringValue] = value
228 | return value as! T
229 | }
230 | }
231 |
232 | private final class UnkeyedContainerImpl: UnkeyedEncodingContainer, LeafEncodingResolvable {
233 | private let encoder: EncoderImpl
234 | private var data: [any LeafEncodingResolvable] = []
235 | private var nestedEncoderCaptures: [AnyObject] = []
236 |
237 | // See `LeafEncodingResolvable.resolvedData`.
238 | var resolvedData: LeafData? {
239 | .array(data.compactMap(\.resolvedData))
240 | }
241 |
242 | // See `UnkeyedEncodingContainer.codingPath`.
243 | var codingPath: [any CodingKey] {
244 | self.encoder.codingPath
245 | }
246 |
247 | // See `UnkeyedEncodingContainer.count`.
248 | var count: Int {
249 | data.count
250 | }
251 |
252 | init(encoder: EncoderImpl) {
253 | self.encoder = encoder
254 | }
255 |
256 | // See `UnkeyedEncodingContainer.encodeNil()`.
257 | func encodeNil() throws {}
258 |
259 | // See `UnkeyedEncodingContainer.encode(_:)`.
260 | func encode(_ value: some Encodable) throws {
261 | guard let encodedValue = try self.encoder.encode(value, forKey: self.nextCodingKey) else {
262 | return
263 | }
264 |
265 | self.data.append(encodedValue)
266 | }
267 |
268 | // See `UnkeyedEncodingContainer.nestedContainer(keyedBy:)`.
269 | func nestedContainer(keyedBy keyType: NestedKey.Type) -> KeyedEncodingContainer {
270 | let nestedEncoder = EncoderImpl(from: self.encoder, withKey: self.nextCodingKey)
271 |
272 | self.nestedEncoderCaptures.append(nestedEncoder)
273 | return .init(self.add(
274 | nestedEncoder.rawContainer(keyedBy: NestedKey.self),
275 | as: KeyedContainerImpl.self
276 | ))
277 | }
278 |
279 | // See `UnkeyedEncodingContainer.nestedUnkeyedContainer()`.
280 | func nestedUnkeyedContainer() -> any UnkeyedEncodingContainer {
281 | let nestedEncoder = EncoderImpl(from: self.encoder, withKey: self.nextCodingKey)
282 |
283 | self.nestedEncoderCaptures.append(nestedEncoder)
284 | return self.add(nestedEncoder.unkeyedContainer() as! UnkeyedContainerImpl)
285 | }
286 |
287 | // See `UnkeyedEncodingContainer.superEncoder()`.
288 | func superEncoder() -> any Encoder {
289 | self.add(EncoderImpl(from: self.encoder, withKey: self.nextCodingKey))
290 | }
291 |
292 | /// A `CodingKey` corresponding to the index that will be given to the next value added to the array.
293 | private var nextCodingKey: any CodingKey {
294 | GenericCodingKey(intValue: self.count)
295 | }
296 |
297 | /// Helper for the encoding methods.
298 | private func add(_ value: any LeafEncodingResolvable, as: T.Type = T.self) -> T {
299 | self.data.append(value)
300 | return value as! T
301 | }
302 | }
303 | }
304 |
--------------------------------------------------------------------------------
/Sources/Leaf/LeafError+AbortError.swift:
--------------------------------------------------------------------------------
1 | import LeafKit
2 | import Vapor
3 |
4 | extension LeafError {
5 | /// This logic from `LeafError` must be duplicated here so we don't end up in infinite
6 | /// recursion trying to access it via the ``localizedDescription`` property.
7 | fileprivate var reasonString: String {
8 | switch self.reason as Reason {
9 | case .illegalAccess(let message):
10 | "\(message)"
11 | case .unknownError(let message):
12 | "\(message)"
13 | case .unsupportedFeature(let feature):
14 | "\(feature) is not implemented"
15 | case .cachingDisabled:
16 | "Caching is globally disabled"
17 | case .keyExists(let key):
18 | "Existing entry \(key); use insert with replace=true to overrride"
19 | case .noValueForKey(let key):
20 | "No cache entry exists for \(key)"
21 | case .unresolvedAST(let key, let dependencies):
22 | "Flat AST expected; \(key) has unresolved dependencies: \(dependencies)"
23 | case .noTemplateExists(let key):
24 | "No template found for \(key)"
25 | case .cyclicalReference(let key, let chain):
26 | "\(key) cyclically referenced in [\(chain.joined(separator: " -> "))]"
27 | case .lexerError(let e):
28 | "Lexing error - \(e.localizedDescription)"
29 | }
30 | }
31 | }
32 |
33 | /// Conforming ``LeafKit/LeafError`` to ``Vapor/AbortError`` significantly improves the quality of the
34 | /// output generated by the `ErrorMiddleware` should such an error be the outcome a request.
35 | extension LeafKit.LeafError: Vapor.AbortError {
36 | /// The use of `@_implements` here allows us to get away with the fact that ``Vapor/AbortError``
37 | /// requires a property named `reason` of type `String` while ``LeafKit/LeafError`` has an
38 | /// identically named property of an enum type.
39 | ///
40 | /// See ``Vapor/AbortError/reason``.
41 | @_implements(AbortError,reason)
42 | public var abortReason: String { self.reasonString }
43 |
44 | // See `Vapor.AbortError.status`.
45 | public var status: HTTPResponseStatus { .internalServerError }
46 | }
47 |
48 | /// Conforming `LeafError` to `DebuggableError` allows more and more useful information
49 | /// to be reported when the error is logged to a `Logger`.
50 | extension LeafKit.LeafError: Vapor.DebuggableError {
51 | /// Again, the underscored attribute gets around the inconvenient naming collision.
52 | ///
53 | /// See ``Vapor/DebuggableError/reason``.
54 | @_implements(DebuggableError,reason)
55 | public var debuggableReason: String { self.reasonString }
56 |
57 | // See `DebuggableError.source`.
58 | public var source: ErrorSource? {
59 | .init(file: self.file, function: self.function, line: self.line, column: self.column)
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/Sources/Leaf/LeafRenderer+ViewRenderer.swift:
--------------------------------------------------------------------------------
1 | import LeafKit
2 | import Vapor
3 |
4 | extension LeafKit.LeafRenderer: Vapor.ViewRenderer {
5 | public func `for`(_ request: Request) -> any ViewRenderer {
6 | request.leaf
7 | }
8 |
9 | public func render(_ name: String, _ context: some Encodable) -> EventLoopFuture {
10 | self.render(path: name, context: context).map { buffer in
11 | View(data: buffer)
12 | }
13 | }
14 | }
15 |
16 | extension LeafRenderer {
17 | /// Populate the template at `path` with the data from `context`.
18 | ///
19 | /// - Parameters:
20 | /// - path: The name of the template to render.
21 | /// - context: Contextual data to render the template with.
22 | /// - Returns: The serialized bytes of the rendered template.
23 | public func render(path: String, context: some Encodable) -> EventLoopFuture {
24 | let data: [String: LeafData]
25 |
26 | do {
27 | data = try LeafEncoder.encode(context)
28 | } catch {
29 | return self.eventLoop.makeFailedFuture(error)
30 | }
31 |
32 | return self.render(path: path, context: data)
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/Sources/Leaf/Request+Leaf.swift:
--------------------------------------------------------------------------------
1 | import LeafKit
2 | import Vapor
3 |
4 | extension Request {
5 | public var leaf: LeafRenderer {
6 | var userInfo = self.application.leaf.userInfo
7 | userInfo["request"] = self
8 | userInfo["application"] = self.application
9 |
10 | return .init(
11 | configuration: self.application.leaf.configuration,
12 | tags: self.application.leaf.tags,
13 | cache: self.application.leaf.cache,
14 | sources: self.application.leaf.sources,
15 | eventLoop: self.eventLoop,
16 | userInfo: userInfo
17 | )
18 | }
19 | }
20 |
21 | extension LeafContext {
22 | public var request: Request? {
23 | self.userInfo["request"] as? Request
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/Tests/LeafTests/LeafEncoderTests.swift:
--------------------------------------------------------------------------------
1 | import Leaf
2 | import LeafKit
3 | import XCTest
4 | import XCTVapor
5 |
6 | final class LeafEncoderTests: XCTestCase {
7 | private func testRender(
8 | of testLeaf: String,
9 | context: (some Encodable & Sendable)? = nil,
10 | expect expectedStatus: HTTPStatus = .ok,
11 | afterResponse: (TestingHTTPResponse) async throws -> (),
12 | file: StaticString = #filePath, line: UInt = #line
13 | ) async throws {
14 | var test = TestFiles()
15 | test.files["/foo.leaf"] = testLeaf
16 |
17 | try await withApp { app in
18 | app.views.use(.leaf)
19 | app.leaf.sources = .singleSource(test)
20 | if let context {
21 | app.get("foo") { try await $0.view.render("foo", context) }
22 | } else {
23 | app.get("foo") { try await $0.view.render("foo") }
24 | }
25 |
26 | try await app.testable().test(.GET, "foo") { res async throws in
27 | XCTAssertEqual(res.status, expectedStatus, file: file, line: line)
28 | try await afterResponse(res)
29 | }
30 | }
31 | }
32 |
33 | func testEmptyContext() async throws {
34 | try await testRender(of: "Hello!\n", context: Bool?.none) {
35 | XCTAssertEqual($0.body.string, "Hello!\n")
36 | }
37 | }
38 |
39 | func testSimpleScalarContext() async throws {
40 | struct Simple: Codable {
41 | let value: Int
42 | }
43 |
44 | try await testRender(of: "Value #(value)", context: Simple(value: 1)) {
45 | XCTAssertEqual($0.body.string, "Value 1")
46 | }
47 | }
48 |
49 | func testMultiValueContext() async throws {
50 | struct Multi: Codable {
51 | let value: Int
52 | let anotherValue: String
53 | }
54 |
55 | try await testRender(of: "Value #(value), string #(anotherValue)", context: Multi(value: 1, anotherValue: "one")) {
56 | XCTAssertEqual($0.body.string, "Value 1, string one")
57 | }
58 | }
59 |
60 | func testArrayContextFails() async throws {
61 | try await testRender(of: "[1, 2, 3, 4, 5]", context: [1, 2, 3, 4, 5], expect: .internalServerError) {
62 | struct Err: Content { let error: Bool, reason: String }
63 | let errInfo = try $0.content.decode(Err.self)
64 | XCTAssertEqual(errInfo.error, true)
65 | XCTAssert(errInfo.reason.contains("must be dictionaries"))
66 | }
67 | }
68 |
69 | func testNestedContainersContext() async throws {
70 | struct Nested: Codable { let deepSixRedOctober: [Int: MoreNested] }
71 | struct MoreNested: Codable { let things: [EvenMoreNested] }
72 | struct EvenMoreNested: Codable { let thing: [String: Double] }
73 |
74 | try await testRender(of: "Everything #(deepSixRedOctober)", context: Nested(deepSixRedOctober: [
75 | 1: .init(things: [
76 | .init(thing: ["a": 1.0, "b": 2.0]),
77 | .init(thing: ["c": 4.0, "d": 8.0]),
78 | ]),
79 | 2: .init(things: [
80 | .init(thing: ["z": 67_108_864.0]),
81 | ])
82 | ])) {
83 | XCTAssertEqual($0.body.string, """
84 | Everything [1: "[things: "["[thing: "[a: "1.0", b: "2.0"]"]", "[thing: "[c: "4.0", d: "8.0"]"]"]"]", 2: "[things: "["[thing: "[z: "67108864.0"]"]"]"]"]
85 | """)
86 | }
87 | }
88 |
89 | func testSuperEncoderContext() async throws {
90 | struct BetterCallSuperGoodman: Codable {
91 | let nestedId: Int
92 | let value: String?
93 | }
94 |
95 | struct BreakingCodable: Codable {
96 | let justTheId: Int
97 | let call: BetterCallSuperGoodman
98 | let called: BetterCallSuperGoodman
99 |
100 | private enum CodingKeys: String, CodingKey { case id, call }
101 | init(justTheId: Int, call: BetterCallSuperGoodman, called: BetterCallSuperGoodman) {
102 | (self.justTheId, self.call, self.called) = (justTheId, call, called)
103 | }
104 | init(from decoder: any Decoder) throws {
105 | let container = try decoder.container(keyedBy: Self.CodingKeys.self)
106 | self.justTheId = try container.decode(Int.self, forKey: .id)
107 | self.call = try .init(from: container.superDecoder(forKey: .call))
108 | self.called = try .init(from: container.superDecoder())
109 | }
110 | func encode(to encoder: any Encoder) throws {
111 | var container = encoder.container(keyedBy: Self.CodingKeys.self)
112 | try container.encode(self.justTheId, forKey: .id)
113 | try self.call.encode(to: container.superEncoder(forKey: .call))
114 | try self.called.encode(to: container.superEncoder())
115 | }
116 | }
117 |
118 | try await testRender(of: """
119 | #(id), or you'd better call:
120 | #(call)
121 | unless you called:
122 | #(super)
123 | """,
124 | context: BreakingCodable(
125 | justTheId: 8675309,
126 | call: .init(nestedId: 8008, value: "Who R U?"),
127 | called: .init(nestedId: 1337, value: "Super!")
128 | )
129 | ) {
130 | XCTAssertEqual($0.body.string, """
131 | 8675309, or you'd better call:
132 | [nestedId: "8008", value: "Who R U?"]
133 | unless you called:
134 | [nestedId: "1337", value: "Super!"]
135 | """)
136 | }
137 | }
138 |
139 | func testEncodeDoesntElideEmptyContainers() async throws {
140 | struct CodableContainersNeedBetterSemantics: Codable {
141 | let title: String
142 | let todoList: [String]
143 | let toundoList: [String: String]
144 | }
145 |
146 | try await testRender(of: "#count(todoList)\n#count(toundoList)", context: CodableContainersNeedBetterSemantics(title: "a", todoList: [], toundoList: [:])) {
147 | XCTAssertEqual($0.body.string, "0\n0")
148 | }
149 | }
150 | }
151 |
--------------------------------------------------------------------------------
/Tests/LeafTests/LeafTests.swift:
--------------------------------------------------------------------------------
1 | import Leaf
2 | import LeafKit
3 | import NIOConcurrencyHelpers
4 | import XCTest
5 | import XCTVapor
6 |
7 | public func withApp(_ block: (Application) async throws -> T) async throws -> T {
8 | let app = try await Application.make(.testing)
9 | let result: T
10 | do {
11 | result = try await block(app)
12 | } catch {
13 | try? await app.asyncShutdown()
14 | throw error
15 | }
16 | try await app.asyncShutdown()
17 | return result
18 | }
19 |
20 | final class LeafTests: XCTestCase {
21 | #if !os(Android)
22 | func testApplication() async throws {
23 | try await withApp { app in
24 | app.views.use(.leaf)
25 | app.leaf.configuration.rootDirectory = projectFolder
26 | app.leaf.sources = .singleSource(NIOLeafFiles(
27 | fileio: app.fileio,
28 | limits: .default,
29 | sandboxDirectory: projectFolder,
30 | viewDirectory: templateFolder
31 | ))
32 | app.get("test-file") { req in
33 | try await req.view.render("foo", ["foo": "bar"])
34 | }
35 |
36 | try await app.testable().test(.GET, "test-file") { res async throws in
37 | XCTAssertEqual(res.status, .ok)
38 | XCTAssertEqual(res.headers.contentType, .html)
39 | // test: #(foo)
40 | XCTAssertContains(res.body.string, "test: bar")
41 | }
42 | }
43 | }
44 |
45 | func testSandboxing() async throws {
46 | try await withApp { app in
47 | app.views.use(.leaf)
48 | app.leaf.configuration.rootDirectory = templateFolder
49 | app.leaf.sources = .singleSource(NIOLeafFiles(
50 | fileio: app.fileio,
51 | limits: .default,
52 | sandboxDirectory: projectFolder,
53 | viewDirectory: templateFolder
54 | ))
55 |
56 | app.get("hello") { req in
57 | try await req.view.render("hello")
58 | }
59 | app.get("allowed") { req in
60 | try await req.view.render("../hello")
61 | }
62 | app.get("sandboxed") { req in
63 | try await req.view.render("../../../../hello")
64 | }
65 |
66 | try await app.testable().test(.GET, "hello") { res async in
67 | XCTAssertEqual(res.status, .ok)
68 | XCTAssertEqual(res.headers.contentType, .html)
69 | XCTAssertEqual(res.body.string, "Hello, world!\n")
70 | }
71 |
72 | try await app.testable().test(.GET, "allowed") { res async in
73 | XCTAssertEqual(res.status, .internalServerError)
74 | XCTAssert(res.body.string.contains("No template found"))
75 | }
76 |
77 | try await app.testable().test(.GET, "sandboxed") { res async in
78 | XCTAssertEqual(res.status, .internalServerError)
79 | XCTAssert(res.body.string.contains("Attempted to escape sandbox"))
80 | }
81 | }
82 | }
83 | #endif
84 |
85 | func testContextRequest() async throws {
86 | var test = TestFiles()
87 | test.files["/foo.leaf"] = """
88 | Hello #(name) @ #source()
89 | """
90 |
91 | struct SourceTag: LeafTag {
92 | func render(_ ctx: LeafContext) throws -> LeafData {
93 | .string(ctx.request?.url.path ?? "application")
94 | }
95 | }
96 |
97 | try await withApp { app in
98 | app.views.use(.leaf)
99 | app.leaf.configuration.rootDirectory = "/"
100 | app.leaf.cache.isEnabled = false
101 | app.leaf.tags["source"] = SourceTag()
102 | app.leaf.sources = .singleSource(test)
103 |
104 | app.get("test-file") { req in
105 | try await req.view.render("foo", [
106 | "name": "vapor"
107 | ])
108 | }
109 | app.get("test-file-with-application-renderer") { req in
110 | try await req.application.leaf.renderer.render("foo", [
111 | "name": "World"
112 | ])
113 | }
114 |
115 | try await app.testable().test(.GET, "test-file") { res async in
116 | XCTAssertEqual(res.status, .ok)
117 | XCTAssertEqual(res.headers.contentType, .html)
118 | XCTAssertEqual(res.body.string, "Hello vapor @ /test-file")
119 | }
120 |
121 | try await app.testable().test(.GET, "test-file-with-application-renderer") { res async in
122 | XCTAssertEqual(res.status, .ok)
123 | XCTAssertEqual(res.headers.contentType, .html)
124 | XCTAssertEqual(res.body.string, "Hello World @ application")
125 | }
126 | }
127 | }
128 |
129 | func testContextUserInfo() async throws {
130 | var test = TestFiles()
131 | test.files["/foo.leaf"] = """
132 | Hello #custom()! @ #source() app nil? #application()
133 | """
134 |
135 | struct CustomTag: LeafTag {
136 | func render(_ ctx: LeafContext) throws -> LeafData {
137 | .string(ctx.userInfo["info"] as? String ?? "")
138 | }
139 | }
140 |
141 | struct SourceTag: LeafTag {
142 | func render(_ ctx: LeafContext) throws -> LeafData {
143 | .string(ctx.request?.url.path ?? "application")
144 | }
145 | }
146 |
147 | struct ApplicationTag: LeafTag {
148 | func render(_ ctx: LeafContext) throws -> LeafData {
149 | .string(ctx.application != nil ? "non-nil app" : "nil app")
150 | }
151 | }
152 |
153 | try await withApp { app in
154 | app.views.use(.leaf)
155 | app.leaf.configuration.rootDirectory = "/"
156 | app.leaf.cache.isEnabled = false
157 | app.leaf.tags["custom"] = CustomTag()
158 | app.leaf.tags["source"] = SourceTag()
159 | app.leaf.tags["application"] = ApplicationTag()
160 | app.leaf.sources = .singleSource(test)
161 | app.leaf.userInfo["info"] = "World"
162 |
163 | app.get("test-file") { req in
164 | try await req.view.render("foo")
165 | }
166 | app.get("test-file-with-application-renderer") { req in
167 | try await req.application.leaf.renderer.render("foo")
168 | }
169 |
170 | try await app.testable().test(.GET, "test-file") { res async in
171 | XCTAssertEqual(res.status, .ok)
172 | XCTAssertEqual(res.headers.contentType, .html)
173 | XCTAssertEqual(res.body.string, "Hello World! @ /test-file app nil? non-nil app")
174 | }
175 |
176 | try await app.testable().test(.GET, "test-file-with-application-renderer") { res async in
177 | XCTAssertEqual(res.status, .ok)
178 | XCTAssertEqual(res.headers.contentType, .html)
179 | XCTAssertEqual(res.body.string, "Hello World! @ application app nil? non-nil app")
180 | }
181 | }
182 | }
183 |
184 | func testLeafCacheDisabledInDevelopment() async throws {
185 | let app = try await Application.make(.development)
186 |
187 | app.views.use(.leaf)
188 |
189 | guard let renderer = app.view as? LeafRenderer else {
190 | try? await app.asyncShutdown()
191 | return XCTFail("app.view is not a LeafRenderer")
192 | }
193 |
194 | XCTAssertFalse(renderer.cache.isEnabled)
195 | try await app.asyncShutdown()
196 | }
197 |
198 | func testLeafCacheEnabledInProduction() async throws {
199 | let app = try await Application.make(.production)
200 |
201 | app.views.use(.leaf)
202 |
203 | guard let renderer = app.view as? LeafRenderer else {
204 | try? await app.asyncShutdown()
205 | return XCTFail("app.view is not a LeafRenderer")
206 | }
207 |
208 | XCTAssertTrue(renderer.cache.isEnabled)
209 | try await app.asyncShutdown()
210 | }
211 |
212 | func testLeafRendererWithEncodableContext() async throws {
213 | var test = TestFiles()
214 | test.files["/foo.leaf"] = """
215 | Hello #(name)!
216 | """
217 |
218 | try await withApp { app in
219 | app.views.use(.leaf)
220 | app.leaf.sources = .singleSource(test)
221 |
222 | struct NotHTML: AsyncResponseEncodable {
223 | var data: ByteBuffer
224 |
225 | func encodeResponse(for request: Request) async throws -> Response {
226 | .init(
227 | headers: ["content-type": "application/not-html"],
228 | body: .init(buffer: self.data)
229 | )
230 | }
231 | }
232 |
233 | struct Foo: Encodable {
234 | var name: String
235 | }
236 |
237 | app.get("foo") { req in
238 | let data = try await req.application.leaf.renderer.render(path: "foo", context: Foo(name: "World")).get()
239 |
240 | return NotHTML(data: data)
241 | }
242 |
243 | try await app.testable().test(.GET, "foo") { res async in
244 | XCTAssertEqual(res.status, .ok)
245 | XCTAssertEqual(res.headers.first(name: "content-type"), "application/not-html")
246 | XCTAssertEqual(res.body.string, "Hello World!")
247 | }
248 | }
249 | }
250 |
251 | func testNoFatalErrorWhenAttemptingToUseArrayAsContext() async throws {
252 | var test = TestFiles()
253 | test.files["/foo.leaf"] = """
254 | Hello #(name)!
255 | """
256 |
257 | try await withApp { app in
258 | app.views.use(.leaf)
259 | app.leaf.sources = .singleSource(test)
260 |
261 | struct MyModel: Content {
262 | let name: String
263 | }
264 |
265 | app.get("noCrash") { req -> View in
266 | let myModel1 = MyModel(name: "Alice")
267 | let myModel2 = MyModel(name: "Alice")
268 | let context = [myModel1, myModel2]
269 | return try await req.view.render("foo", context)
270 | }
271 |
272 | try await app.testable().test(.GET, "noCrash") { res async in
273 | XCTAssertEqual(res.status, .internalServerError)
274 | }
275 | }
276 | }
277 |
278 | // Test for GH Issue #197
279 | func testNoFatalErrorWhenAttemptingToUseArrayWithNil() async throws {
280 | var test = TestFiles()
281 | test.files["/foo.leaf"] = """
282 | #(value)
283 | """
284 |
285 | try await withApp { app in
286 | app.views.use(.leaf)
287 | app.leaf.sources = .singleSource(test)
288 |
289 | struct ArrayWithNils: Content {
290 | let value: [UUID?]
291 | }
292 |
293 | let id1 = UUID.init()
294 | let id2 = UUID.init()
295 |
296 |
297 | app.get("noCrash") { req -> View in
298 | let context = ArrayWithNils(value: [id1, nil, id2, nil])
299 |
300 | return try await req.view.render("foo", context)
301 | }
302 |
303 | try await app.testable().test(.GET, "noCrash") { res async in
304 | // Expected result .ok
305 | XCTAssertEqual(res.status, .ok)
306 |
307 | // Rendered result should match to all non-nil values
308 | XCTAssertEqual(res.body.string, "[\"\(id1)\", \"\(id2)\"]")
309 | }
310 | }
311 | }
312 | }
313 |
314 | /// Helper `LeafFiles` struct providing an in-memory thread-safe map of "file names" to "file data"
315 | struct TestFiles: LeafSource {
316 | var files: [String: String]
317 | var lock: NIOLock
318 |
319 | init() {
320 | files = [:]
321 | lock = .init()
322 | }
323 |
324 | public func file(template: String, escape: Bool = false, on eventLoop: any EventLoop) -> EventLoopFuture {
325 | var path = template
326 |
327 | if path.split(separator: "/").last?.split(separator: ".").count ?? 1 < 2,
328 | !path.hasSuffix(".leaf")
329 | {
330 | path += ".leaf"
331 | }
332 | if !path.starts(with: "/") {
333 | path = "/" + path
334 | }
335 |
336 | return self.lock.withLock {
337 | if let file = self.files[path] {
338 | var buffer = ByteBufferAllocator().buffer(capacity: file.count)
339 | buffer.writeString(file)
340 | return eventLoop.makeSucceededFuture(buffer)
341 | } else {
342 | return eventLoop.makeFailedFuture(LeafError(.noTemplateExists(template)))
343 | }
344 | }
345 | }
346 | }
347 |
348 | var templateFolder: String {
349 | URL(fileURLWithPath: #filePath, isDirectory: false)
350 | .deletingLastPathComponent()
351 | .appendingPathComponent("Views", isDirectory: true)
352 | .path
353 | }
354 |
355 | var projectFolder: String {
356 | URL(fileURLWithPath: #filePath, isDirectory: false) // .../leaf/Tests/LeafTests/LeafTests.swift
357 | .deletingLastPathComponent() // .../leaf/Tests/LeafTests
358 | .deletingLastPathComponent() // .../leaf/Tests
359 | .deletingLastPathComponent() // .../leaf
360 | .path
361 | }
362 |
--------------------------------------------------------------------------------
/Tests/LeafTests/Views/bar.leaf:
--------------------------------------------------------------------------------
1 | You have loaded bar.leaf!
2 |
--------------------------------------------------------------------------------
/Tests/LeafTests/Views/base.leaf:
--------------------------------------------------------------------------------
1 | #get(title)
2 | #get(body)
3 |
--------------------------------------------------------------------------------
/Tests/LeafTests/Views/foo.leaf:
--------------------------------------------------------------------------------
1 | test: #(foo)
2 |
--------------------------------------------------------------------------------
/Tests/LeafTests/Views/hello.leaf:
--------------------------------------------------------------------------------
1 | Hello, world!
2 |
--------------------------------------------------------------------------------