├── .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 | Leaf 3 |
4 |
5 | Documentation 6 | Team Chat 7 | MIT License 8 | Continuous Integration 9 | 10 | Swift 5.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 | 2 | 3 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 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 | --------------------------------------------------------------------------------