/// An asset shipped with a website: a file stored under the static files root and served by /// Hummingbird's `FileMiddleware` middleware. /// /// A conforming asset supplies its file name and the extensions it is available with, each /// resolving to its own file; the protocol derives the paths from them: the file's path within /// the static files root and the URL path it is served at, optionally versioned to bust caches. public protocol Asset: Sendable { // MARK: Properties /// The file extensions the asset is available with. var fileExtensions: [AssetExtension] { get } /// The asset's file name, without extension. var fileName: String { get } } // MARK: - Implementations public extension Asset { // MARK: Methods /// Resolves the asset's path against the given base directory. /// /// - Parameters: /// - basePath: the directory the static files are served from. /// - fileExtension: the extension of the file to resolve. /// - Returns: the path to the file, relative to the `basePath` path. func path( relativeTo basePath: String, for fileExtension: AssetExtension ) -> String { let relativePath = relativePath(for: fileExtension) guard !basePath.isEmpty else { return relativePath } return "\(basePath)/\(relativePath)" } /// Resolves the asset's path relative to the static files root (e.g. `"css/shared.css"`). /// /// This also matches the URL path the file is served at by `FileMiddleware`. /// /// - Parameter fileExtension: the extension of the file to resolve. /// - Returns: the path to the file, relative to the static files root. func relativePath( for fileExtension: AssetExtension ) -> String { let file = "\(fileName).\(fileExtension.rawValue)" return fileExtension.subdirectory .map { "\($0)/\(file)" } ?? file } /// Resolves the absolute URL path the asset is served at (e.g. `"/css/shared.css"`). /// /// A version token appends as a `v` query parameter (e.g. `"/css/shared.css?v=abc123"`): /// `FileMiddleware` ignores the query when resolving the file, while caches key on the full /// URL, so a deploy that changes the assets busts every cached copy at once. /// - Parameters: /// - fileExtension: the extension of the file to resolve. /// - version: the version token to append, or `nil` to leave the URL unversioned. /// - Returns: the path to use in `href` and `src` attributes. func urlPath( for fileExtension: AssetExtension, version: String? = nil ) -> String { let path = "/\(relativePath(for: fileExtension))" guard let version, !version.isEmpty else { return path } return "\(path)?v=\(version)" } }