|
| 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