Skip to content

Commit b96a51a

Browse files
committed
Move template lookup logic to Express.View class
Apparently Express has such a class internally, try to replicate that and move the logic there. The eventual goal is lookup caching and custom View subclasses (the reason why this is a class).
1 parent 9aea0c2 commit b96a51a

File tree

4 files changed

+177
-35
lines changed

4 files changed

+177
-35
lines changed

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ let package = Package(
1717

1818
dependencies: [
1919
.package(url: "https://github.com/Macro-swift/Macro.git",
20-
from: "1.0.4"),
20+
from: "1.0.10"),
2121
.package(url: "https://github.com/AlwaysRightInstitute/mustache.git",
2222
from: "1.0.2")
2323
],

Sources/express/Express.swift

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -243,19 +243,6 @@ open class Express: SettingsHolder, MountableMiddlewareObject, MiddlewareObject,
243243

244244
// MARK: - Extension Point for Subclasses
245245

246-
open func viewDirectory(for engine: String, response: ServerResponse)
247-
-> String
248-
{
249-
// Maybe that should be an array
250-
// This should allow 'views' as a relative path.
251-
// Also, in Apache it should be a configuration directive.
252-
let viewsPath = (get("views") as? String)
253-
?? process.env["EXPRESS_VIEWS"]
254-
// ?? apacheRequest.pathRelativeToServerRoot(filename: "views")
255-
?? process.cwd()
256-
return viewsPath
257-
}
258-
259246
/// The identifier used in the x-powered-by header
260247
open var productIdentifier : String {
261248
return "MacroExpress"

Sources/express/Render.swift

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,16 @@ public enum ExpressRenderingError: Swift.Error {
2020

2121
public extension ServerResponse {
2222

23-
// TODO: How do we get access to the application?? Need to attach to the
24-
// request? We need to retrieve values.
25-
2623
/**
2724
* Lookup a template with the given name, locate the rendering engine for it,
2825
* and render it with the options that are passed in.
2926
*
3027
* Example:
31-
*
32-
* app.get { _, res in
33-
* res.render('index', { "title": "Hello World!" })
34-
* }
28+
* ```swift
29+
* app.get { _, res in
30+
* res.render("index", [ "title": "Hello World!" ])
31+
* }
32+
* ```
3533
*
3634
* Assuming your 'views' directory contains an `index.mustache` file, this
3735
* would trigger the Mustache engine to render the template with the given
@@ -64,31 +62,35 @@ public extension Express {
6462
* Lookup a template with the given name, locate the rendering engine for it,
6563
* and render it with the options that are passed in.
6664
*
67-
* Refer to the `ServerResponse.render` method for details.
65+
* Refer to the ``ServerResponse/render`` method for details.
6866
*/
6967
func render(template: String, options: Any?, to res: ServerResponse) {
7068
let log = self.log
71-
let viewEngine = (get("view engine") as? String) ?? ".mustache"
72-
guard let engine = engines[viewEngine] else {
73-
log.error("Did not find view engine for extension: \(viewEngine)")
74-
res.emit(error: ExpressRenderingError.unsupportedViewEngine(viewEngine))
75-
res.finishRender500IfNecessary()
76-
return
77-
}
78-
79-
let viewsPath = viewDirectory(for: viewEngine, response: res)
69+
70+
let defaultEngine = self.defaultEngine
8071
let emptyOpts : [ String : Any ] = [:]
81-
let appViewOptions = get("view options") ?? emptyOpts
72+
let appViewOptions = get("view options") ?? emptyOpts // Any?
8273
let viewOptions = options ?? appViewOptions // TODO: merge if possible
83-
84-
lookupTemplatePath(template, in: viewsPath, preferredEngine: viewEngine) {
85-
pathOrNot in
74+
// not usually possible, because not guaranteed to be dicts!
75+
76+
let view = View(name: template, options: self)
77+
let name = path.basename(template, path.extname(template))
78+
view.lookup(name) { pathOrNot in
8679
guard let path = pathOrNot else {
8780
res.emit(error: ExpressRenderingError.didNotFindTemplate(template))
8881
res.finishRender500IfNecessary()
8982
return
9083
}
9184

85+
let ext = fs.path.extname(path)
86+
let viewEngine = ext.isEmpty ? defaultEngine : ext
87+
guard let engine = self.engines[viewEngine] else {
88+
log.error("Did not find view engine for extension: \(viewEngine)")
89+
res.emit(error: ExpressRenderingError.unsupportedViewEngine(viewEngine))
90+
res.finishRender500IfNecessary()
91+
return
92+
}
93+
9294
engine(path, viewOptions) { ( results: Any?... ) in
9395
let rc = results.count
9496
let v0 = rc > 0 ? results[0] : nil

Sources/express/View.swift

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
//
2+
// View.swift
3+
// MacroExpress
4+
//
5+
// Created by Helge Heß on 07.09.25.
6+
//
7+
8+
import Macro
9+
10+
public extension SettingsHolder {
11+
12+
/**
13+
* The default engine set by `app.set("view engine", "mustache")`.
14+
*
15+
* Note: The default engine has *no* leading dot!
16+
*/
17+
@inlinable
18+
var defaultEngine: String {
19+
settings["view engine"] as? String ?? ""
20+
}
21+
22+
/**
23+
* The views directory for templates set by `app.set("views", "views")`.
24+
*/
25+
@inlinable
26+
var views : [ String ] {
27+
switch settings["views"] {
28+
case .none:
29+
return [ process.env["EXPRESS_VIEWS"] ?? __dirname() + "/views" ]
30+
case let v as String: return [ v ]
31+
case let v as [ String ]: return v
32+
case .some(let v):
33+
assertionFailure("Unexpected value in 'views' option: \(v)")
34+
return [ String(describing: v) ]
35+
}
36+
}
37+
38+
}
39+
40+
extension Express {
41+
42+
open class View {
43+
44+
/**
45+
* The default engine set by `app.set("view engine", "mustache")`.
46+
*/
47+
public let defaultEngine : String
48+
49+
/// Raw view name passed to ``ServerResponse/render``, e.g. "index.html"
50+
public let name : String
51+
52+
/// Extension of the name, if there was one (e.g. ".html" for "index.html")
53+
public let ext : String
54+
55+
/**
56+
* The views directory for templates set by `app.set("views", "views")`.
57+
*/
58+
public let root : [ String ]
59+
60+
/// The render function for the extension specified in ``ext`` (i.e. passed
61+
/// explicitly to the render function, like `render("index.mustache")`.
62+
public let engine : ExpressEngine?
63+
64+
/// All known engines
65+
private let engines : [ String : ExpressEngine ]
66+
67+
/// The resolved path (for cached views)
68+
var path : String?
69+
70+
public init(name: String, options: Express) {
71+
self.name = name
72+
self.ext = Macro.path.extname(name)
73+
self.engines = options.engines
74+
self.root = options.views
75+
self.defaultEngine = options.defaultEngine
76+
self.engine = engines[self.ext]
77+
}
78+
79+
/**
80+
* Lookup the path of a template in the filesystem.
81+
*
82+
* Entry point that can be overridden in subclasses. The View class to use
83+
* is specified in the `view` setting (e.g. `set("view", MyView.self)`).
84+
*
85+
* - Parameters:
86+
* - name: The template name *without* the extension! Whether it was
87+
* originally provided or not.
88+
* - yield: The function to call once the lookup process has finished.
89+
*/
90+
open func lookup(_ name: String, yield: @escaping ( String? ) -> Void) {
91+
// This is synchronous in Express.js.
92+
93+
guard !root.isEmpty else { return yield(nil) }
94+
95+
let preferredEngine = self.ext.isEmpty
96+
? (defaultEngine.isEmpty ? nil : ".\(defaultEngine)")
97+
: self.ext
98+
99+
// All this is kinda expensive?! But we might want to cache it.
100+
var pathesToCheck = [ String ]()
101+
pathesToCheck.reserveCapacity(root.count * (engines.count + 1) + 1)
102+
if let ext = preferredEngine {
103+
for path in root {
104+
pathesToCheck.append("\(path)/\(name)\(ext)")
105+
}
106+
}
107+
if self.ext.isEmpty { // no explicit extension specified, search for all
108+
// Real Express.js doesn't do this by default.
109+
for extraKey in engines.keys.sorted() where extraKey != preferredEngine
110+
{
111+
for path in root {
112+
pathesToCheck.append("\(path)/\(name)\(extraKey)")
113+
}
114+
}
115+
}
116+
117+
lookupFilePath(pathesToCheck, yield: yield)
118+
}
119+
120+
/**
121+
* This asynchronously finds the first path in the given set of pathes that
122+
* exists.
123+
*/
124+
public func lookupFilePath(_ pathesToCheck: [ String ],
125+
yield: @escaping ( String? ) -> Void)
126+
{
127+
guard !pathesToCheck.isEmpty else { return yield(nil) }
128+
129+
final class State {
130+
var pending : ArraySlice<String>
131+
let yield : ( String? ) -> Void
132+
133+
init(_ pathesToCheck: [ String ], yield: @escaping ( String? ) -> Void) {
134+
pending = pathesToCheck[...]
135+
self.yield = yield
136+
}
137+
138+
func step() {
139+
guard let pathToCheck = pending.popFirst() else { return yield(nil) }
140+
fs.stat(pathToCheck) { error, stat in
141+
guard let stat = stat, stat.isFile() else {
142+
return self.step()
143+
}
144+
self.yield(pathToCheck)
145+
}
146+
}
147+
}
148+
149+
let state = State(pathesToCheck, yield: yield)
150+
state.step()
151+
}
152+
}
153+
}

0 commit comments

Comments
 (0)