From daf275b121463bc11dfe129bf1f16e615cfdb258 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 19 Jul 2026 02:08:59 +0000 Subject: [PATCH] Improved the static assets definitions in the Website service (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the work done to overhaul the static asset definitions for the Website service. To provide further details about the work: * Website library * Overhauled the `StateFile` enumeration to reduce the number of cases to one case per logical file name, each exposing a `fileExtensions` list. * Wired everything into the pages with a consistent ordering convention: stylesheets load shared-first so the page sheet wins the CSS cascade; scripts load page-first with shared.js last. The error page also gained the shared stylesheet and its scripts; the index page gained its page CSS/JS and the new touch icon link. * Used the `StaticFile` enumeration as a single source of truth for every _href_/_src_ in the `IndexPage` and the `ErrorPage` pages, eliminating hardcoded asset paths. * Website service * Added new assets to the Resources folder: * `apple-touch-icon.png` * `css/index.css` * `js/index.js` * `js/error.js` * `sitemap.xml` * Renamed existing assets within the Resources folder: * `css/style.css` → `css/shared.css` * `js/app.js` → `js/shared.js` * Fixed the working-directory location for the scheme in the Xcode project. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/18 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- .../xcshareddata/xcschemes/Website.xcscheme | 3 +- .../Resources/Static/apple-touch-icon.png | Bin 0 -> 9066 bytes .../Static/{js/app.js => css/index.css} | 0 .../Static/css/{style.css => shared.css} | 0 Services/Website/Resources/Static/js/error.js | 0 Services/Website/Resources/Static/js/index.js | 0 .../Website/Resources/Static/js/shared.js | 0 Services/Website/Resources/Static/robots.txt | 2 + Services/Website/Resources/Static/sitemap.xml | 6 + .../Internal/Enumerations/StaticFile.swift | 189 ++++++++++-------- .../Library/Internal/Pages/ErrorPage.swift | 12 +- .../Library/Internal/Pages/IndexPage.swift | 19 +- Services/Website/Tests/App/AppTests.swift | 28 +-- .../Enumerations/StaticFileTests.swift | 181 +++++++++++------ .../Cases/Internal/Pages/ErrorPageTests.swift | 3 + .../Cases/Internal/Pages/IndexPageTests.swift | 8 +- 16 files changed, 279 insertions(+), 172 deletions(-) create mode 100644 Services/Website/Resources/Static/apple-touch-icon.png rename Services/Website/Resources/Static/{js/app.js => css/index.css} (100%) rename Services/Website/Resources/Static/css/{style.css => shared.css} (100%) create mode 100644 Services/Website/Resources/Static/js/error.js create mode 100644 Services/Website/Resources/Static/js/index.js create mode 100644 Services/Website/Resources/Static/js/shared.js create mode 100644 Services/Website/Resources/Static/sitemap.xml diff --git a/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme b/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme index fe904d8..5ce7121 100644 --- a/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme +++ b/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme @@ -50,7 +50,8 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle = "0" - useCustomWorkingDirectory = "NO" + useCustomWorkingDirectory = "YES" + customWorkingDirectory = "/Users/logan/Documents/Development/Platforms/Röck+Cöde/Loud/Services/Website" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" diff --git a/Services/Website/Resources/Static/apple-touch-icon.png b/Services/Website/Resources/Static/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..56d1ff21c4d3c8caf95f98823bb10fa6679fab1d GIT binary patch literal 9066 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 literal 0 HcmV?d00001 diff --git a/Services/Website/Resources/Static/js/app.js b/Services/Website/Resources/Static/css/index.css similarity index 100% rename from Services/Website/Resources/Static/js/app.js rename to Services/Website/Resources/Static/css/index.css diff --git a/Services/Website/Resources/Static/css/style.css b/Services/Website/Resources/Static/css/shared.css similarity index 100% rename from Services/Website/Resources/Static/css/style.css rename to Services/Website/Resources/Static/css/shared.css diff --git a/Services/Website/Resources/Static/js/error.js b/Services/Website/Resources/Static/js/error.js new file mode 100644 index 0000000..e69de29 diff --git a/Services/Website/Resources/Static/js/index.js b/Services/Website/Resources/Static/js/index.js new file mode 100644 index 0000000..e69de29 diff --git a/Services/Website/Resources/Static/js/shared.js b/Services/Website/Resources/Static/js/shared.js new file mode 100644 index 0000000..e69de29 diff --git a/Services/Website/Resources/Static/robots.txt b/Services/Website/Resources/Static/robots.txt index 51d2d2e..e7c9f38 100644 --- a/Services/Website/Resources/Static/robots.txt +++ b/Services/Website/Resources/Static/robots.txt @@ -3,3 +3,5 @@ # Allow crawling of all content User-agent: * Disallow: + +Sitemap: https://loud.amsterdam/sitemap.xml diff --git a/Services/Website/Resources/Static/sitemap.xml b/Services/Website/Resources/Static/sitemap.xml new file mode 100644 index 0000000..8bfaeae --- /dev/null +++ b/Services/Website/Resources/Static/sitemap.xml @@ -0,0 +1,6 @@ + + + + https://loud.amsterdam/ + + diff --git a/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift index ef81f21..aed99f7 100644 --- a/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift +++ b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift @@ -1,24 +1,27 @@ /// A static file shipped with the website service. /// -/// Each case identifies a file stored under the static files root (the `Resources/Static` -/// directory) and served by Hummingbird's `FileMiddleware` middleware. +/// Each case identifies a file name stored under the static files root (the `Resources/Static` +/// directory) and served by Hummingbird's `FileMiddleware` middleware. A name can be available +/// with more than one extension (see ``fileExtensions``), each resolving to its own file. enum StaticFile: CaseIterable, Sendable { - /// The `js/app.js` script. - case appJS - /// The `css/error.css` stylesheet for the not-found page. - case errorCSS + /// The `apple-touch-icon.png` icon. + case appleTouchIcon + /// The `css/error.css` stylesheet and `js/error.js` script for the not-found page. + case error /// The `favicon.ico` icon. - case faviconICO - /// The `icon.png` icon. - case iconPNG - /// The `icon.svg` icon. - case iconSVG + case favicon + /// The `icon.png` and `icon.svg` icons. + case icon + /// The `css/index.css` stylesheet and `js/index.js` script for the landing page. + case index /// The `robots.txt` crawler directives. - case robotsTXT + case robots + /// The `css/shared.css` stylesheet and `js/shared.js` script shared across pages. + case shared /// The `site.webmanifest` web application manifest. - case siteWebmanifest - /// The `css/style.css` stylesheet. - case styleCSS + case site + /// The `sitemap.xml` crawler sitemap. + case sitemap } // MARK: - Enumerations @@ -40,6 +43,8 @@ extension StaticFile { case txt /// A web application manifest file. case webmanifest + /// An Extensible Markup Language file. + case xml } } @@ -49,9 +54,91 @@ extension StaticFile { // MARK: Computed + /// The file extensions the file is available with. + var fileExtensions: [Extension] { + switch self { + case .appleTouchIcon: [.png] + case .error, + .index, + .shared: [.css, .js] + case .favicon: [.ico] + case .icon: [.png, .svg] + case .robots: [.txt] + case .site: [.webmanifest] + case .sitemap: [.xml] + } + } + + /// The file's name, without extension. + var fileName: String { + switch self { + case .appleTouchIcon: "apple-touch-icon" + case .error: "error" + case .favicon: "favicon" + case .icon: "icon" + case .index: "index" + case .robots: "robots" + case .shared: "shared" + case .site: "site" + case .sitemap: "sitemap" + } + } + + // MARK: Methods + + /// Resolves the file'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: Extension + ) -> String { + let relativePath = relativePath(for: fileExtension) + + guard !basePath.isEmpty else { + return relativePath + } + + return "\(basePath)/\(relativePath)" + } + + /// Resolves the file'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: Extension + ) -> String { + let file = "\(fileName).\(fileExtension.rawValue)" + + return fileExtension.subdirectory + .map { "\($0)/\(file)" } ?? file + } + + /// Resolves the absolute URL path the file is served at (e.g. `"/css/shared.css"`). + /// + /// - Parameter fileExtension: the extension of the file to resolve. + /// - Returns: the path to use in `href` and `src` attributes. + func urlPath( + for fileExtension: Extension + ) -> String { + "/\(relativePath(for: fileExtension))" + } + +} + +extension StaticFile.Extension { + + // MARK: Computed + /// The file's content type. var contentType: String { - switch fileExtension { + switch self { case .css: "text/css" case .js: "text/javascript" case .png: "image/png" @@ -59,77 +146,15 @@ extension StaticFile { case .svg: "image/svg+xml" case .txt: "text/plain" case .webmanifest: "application/manifest+json" + case .xml: "application/xml" } } - /// The file's extension. - var fileExtension: Extension { - switch self { - case .errorCSS, - .styleCSS: .css - case .appJS: .js - case .faviconICO: .ico - case .iconPNG: .png - case .iconSVG: .svg - case .robotsTXT: .txt - case .siteWebmanifest: .webmanifest - } - } - - /// The file's name, without extension. - var fileName: String { - switch self { - case .appJS: "app" - case .errorCSS: "error" - case .faviconICO: "favicon" - case .iconPNG, - .iconSVG: "icon" - case .robotsTXT: "robots" - case .siteWebmanifest: "site" - case .styleCSS: "style" - } - } - - /// The path relative to the static files root (e.g. `"css/style.css"`). - /// - /// This also matches the URL path the file is served at by `FileMiddleware`. - var relativePath: String { - let file = "\(fileName).\(fileExtension.rawValue)" - - return subdirectory - .map { "\($0)/\(file)" } ?? file - } - - // MARK: Methods - - /// Resolves the file's path against the given base directory. - /// - /// - Parameter basePath: the directory the static files are served from. - /// - Returns: the path to the file, relative to the `basePath` path. - func path( - relativeTo basePath: String - ) -> String { - guard !basePath.isEmpty else { - return relativePath - } - - return "\(basePath)/\(relativePath)" - } - -} - -// MARK: - Helpers - -private extension StaticFile { - - // MARK: Computed - - /// The sub-directory within the static root that holds the file, if any. + /// The sub-directory within the static root that holds files with this extension, if any. var subdirectory: String? { switch self { - case .appJS: "js" - case .errorCSS, - .styleCSS: "css" + case .css: "css" + case .js: "js" default: nil } } diff --git a/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift b/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift index 06f11b6..79a1f53 100644 --- a/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift @@ -26,7 +26,7 @@ struct ErrorPage: HTMLDocument, Sendable { // MARK: Document - /// The page's content: a localized heading and explanatory message. + /// The page's content: a localized heading and explanatory message, followed by the error and shared scripts. var body: some HTML { h1 { localize("error.heading", locale: locale) @@ -34,9 +34,11 @@ struct ErrorPage: HTMLDocument, Sendable { p { localize("error.message", locale: locale) } + script(.src(StaticFile.error.urlPath(for: .js))) {} + script(.src(StaticFile.shared.urlPath(for: .js))) {} } - /// The metadata and stylesheet link placed in the document head. + /// The metadata and stylesheet links placed in the document head. var head: some HTML { meta(.charset(.utf8)) meta( @@ -45,7 +47,11 @@ struct ErrorPage: HTMLDocument, Sendable { ) link( .rel(.stylesheet), - .href("/css/error.css") + .href(StaticFile.shared.urlPath(for: .css)) + ) + link( + .rel(.stylesheet), + .href(StaticFile.error.urlPath(for: .css)) ) } diff --git a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift index 8d6c0c2..7f8bf19 100644 --- a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift @@ -26,12 +26,13 @@ struct IndexPage: HTMLDocument, Sendable { // MARK: Document - /// The page's content: a localized greeting followed by the app script. + /// The page's content: a localized greeting followed by the shared and index scripts. var body: some HTML { p { localize("index.greeting", locale: locale) } - script(.src("/js/app.js")) {} + script(.src(StaticFile.index.urlPath(for: .js))) {} + script(.src(StaticFile.shared.urlPath(for: .js))) {} } /// The metadata, stylesheet, icon, and manifest links placed in the document head. @@ -43,11 +44,15 @@ struct IndexPage: HTMLDocument, Sendable { ) link( .rel(.stylesheet), - .href("/css/style.css") + .href(StaticFile.shared.urlPath(for: .css)) + ) + link( + .rel(.stylesheet), + .href(StaticFile.index.urlPath(for: .css)) ) link( .rel(.icon), - .href("/favicon.ico"), + .href(StaticFile.favicon.urlPath(for: .ico)), .custom( name: "sizes", value: "any" @@ -55,7 +60,7 @@ struct IndexPage: HTMLDocument, Sendable { ) link( .rel(.icon), - .href("/icon.svg"), + .href(StaticFile.icon.urlPath(for: .svg)), .custom( name: "type", value: "image/svg+xml" @@ -63,11 +68,11 @@ struct IndexPage: HTMLDocument, Sendable { ) link( .rel("apple-touch-icon"), - .href("/icon.png") + .href(StaticFile.appleTouchIcon.urlPath(for: .png)) ) link( .rel("manifest"), - .href("/site.webmanifest") + .href(StaticFile.site.urlPath(for: .webmanifest)) ) meta( .name("theme-color"), diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index a677a32..e665763 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -94,20 +94,22 @@ struct AppTests { try await app( staticFilesPath: staticFilesPath ).test(.router) { client in - try await client.execute( - uri: "/\(file.relativePath)", - method: .get - ) { response in - #expect(response.status == .ok) - #expect(response.headers[.contentType] == file.contentType) - - let cacheControl = try #require(response.headers[.cacheControl]) + for fileExtension in file.fileExtensions { + try await client.execute( + uri: "/\(file.relativePath(for: fileExtension))", + method: .get + ) { response in + #expect(response.status == .ok) + #expect(response.headers[.contentType] == fileExtension.contentType) - #expect(cacheControl.contains("public") == true) - #expect(cacheControl.contains("max-age=") == true) - - if textExtensions.contains(file.fileExtension) { - #expect(cacheControl.contains("must-revalidate") == true) + let cacheControl = try #require(response.headers[.cacheControl]) + + #expect(cacheControl.contains("public") == true) + #expect(cacheControl.contains("max-age=") == true) + + if textExtensions.contains(fileExtension) { + #expect(cacheControl.contains("must-revalidate") == true) + } } } } diff --git a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift index 110bca2..4cb579f 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift @@ -5,36 +5,25 @@ import Testing @Suite("StaticFile enumeration") struct StaticFileTests { - + // MARK: Type aliases - + typealias File = StaticFile typealias FileExtension = StaticFile.Extension - + // MARK: Computed tests - - @Test(arguments: zip( - File.allCases, - Self.contentTypes - )) - func `content type`( - for file: File, - expects contentType: String - ) { - #expect(file.contentType == contentType) - } - + @Test(arguments: zip( File.allCases, Self.fileExtensions )) - func `file extension`( + func `file extensions`( for file: File, - expects `extension`: FileExtension + expects extensions: [FileExtension] ) { - #expect(file.fileExtension == `extension`) + #expect(file.fileExtensions == extensions) } - + @Test(arguments: zip( File.allCases, Self.fileNames @@ -45,20 +34,57 @@ struct StaticFileTests { ) { #expect(file.fileName == fileName) } - + + @Test(arguments: zip( + Self.extensions, + Self.contentTypes + )) + func `content type`( + for fileExtension: FileExtension, + expects contentType: String + ) { + #expect(fileExtension.contentType == contentType) + } + + @Test(arguments: zip( + Self.extensions, + Self.subdirectories + )) + func `subdirectory`( + for fileExtension: FileExtension, + expects subdirectory: String? + ) { + #expect(fileExtension.subdirectory == subdirectory) + } + + // MARK: Method tests + @Test(arguments: zip( File.allCases, Self.relativePaths )) - func `relative path`( + func `relative path for`( for file: File, - expects relativePath: String + expects relativePaths: [String] ) { - #expect(file.relativePath == relativePath) + for (fileExtension, relativePath) in zip(file.fileExtensions, relativePaths) { + #expect(file.relativePath(for: fileExtension) == relativePath) + } } - - // MARK: Method tests - + + @Test(arguments: zip( + File.allCases, + Self.relativePaths + )) + func `url path for`( + for file: File, + expects relativePaths: [String] + ) { + for (fileExtension, relativePath) in zip(file.fileExtensions, relativePaths) { + #expect(file.urlPath(for: fileExtension) == "/\(relativePath)") + } + } + @Test(arguments: [ "", ".", @@ -68,70 +94,99 @@ struct StaticFileTests { _ basePath: String ) { for file in File.allCases { - let pathRelativeToBasePath = file.path(relativeTo: basePath) + for fileExtension in file.fileExtensions { + let pathRelativeToBasePath = file.path( + relativeTo: basePath, + for: fileExtension + ) + let relativePath = file.relativePath(for: fileExtension) - if basePath.isEmpty { - #expect(pathRelativeToBasePath == file.relativePath) - } else { - #expect(pathRelativeToBasePath == "\(basePath)/\(file.relativePath)") + if basePath.isEmpty { + #expect(pathRelativeToBasePath == relativePath) + } else { + #expect(pathRelativeToBasePath == "\(basePath)/\(relativePath)") + } } } } // MARK: CaseIterable tests - + @Test func `all cases`() { - #expect(File.allCases.count == 8) + #expect(File.allCases.count == 9) } - + } // MARK: - Helpers private extension StaticFileTests { - + // MARK: Constants - - static let contentTypes: [String] = [ - "text/javascript", - "text/css", - "image/vnd.microsoft.icon", - "image/png", - "image/svg+xml", - "text/plain", - "application/manifest+json", - "text/css" - ] - static let fileExtensions: [FileExtension] = [ - .js, + + static let extensions: [FileExtension] = [ .css, - .ico, + .js, .png, + .ico, .svg, .txt, .webmanifest, - .css + .xml + ] + static let contentTypes: [String] = [ + "text/css", + "text/javascript", + "image/png", + "image/vnd.microsoft.icon", + "image/svg+xml", + "text/plain", + "application/manifest+json", + "application/xml" + ] + static let subdirectories: [String?] = [ + "css", + "js", + nil, + nil, + nil, + nil, + nil, + nil + ] + static let fileExtensions: [[FileExtension]] = [ + [.png], + [.css, .js], + [.ico], + [.png, .svg], + [.css, .js], + [.txt], + [.css, .js], + [.webmanifest], + [.xml] ] static let fileNames: [String] = [ - "app", + "apple-touch-icon", "error", "favicon", "icon", - "icon", + "index", "robots", + "shared", "site", - "style" + "sitemap" ] - static let relativePaths: [String] = [ - "js/app.js", - "css/error.css", - "favicon.ico", - "icon.png", - "icon.svg", - "robots.txt", - "site.webmanifest", - "css/style.css" + static let relativePaths: [[String]] = [ + ["apple-touch-icon.png"], + ["css/error.css", "js/error.js"], + ["favicon.ico"], + ["icon.png", "icon.svg"], + ["css/index.css", "js/index.js"], + ["robots.txt"], + ["css/shared.css", "js/shared.js"], + ["site.webmanifest"], + ["sitemap.xml"] ] - + } diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift index 7e03844..6a148e9 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift @@ -19,7 +19,10 @@ struct ErrorPageTests { #expect(html.contains(#"lang="en""#)) #expect(html.contains("Page Not Found")) #expect(html.contains("Sorry, but the page you were trying to view does not exist.")) + #expect(html.contains("/css/shared.css")) #expect(html.contains("/css/error.css")) + #expect(html.contains("/js/error.js")) + #expect(html.contains("/js/shared.js")) } } diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift index 254b6e3..89dd952 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift @@ -17,13 +17,15 @@ struct IndexPageTests { #expect(html.contains("")) #expect(html.contains(#"lang="en""#)) - #expect(html.contains("/css/style.css")) + #expect(html.contains("/css/shared.css")) + #expect(html.contains("/css/index.css")) #expect(html.contains("/favicon.ico")) #expect(html.contains("/icon.svg")) - #expect(html.contains("/icon.png")) + #expect(html.contains("/apple-touch-icon.png")) #expect(html.contains("/site.webmanifest")) #expect(html.contains("Hello world!")) - #expect(html.contains("/js/app.js")) + #expect(html.contains("/js/shared.js")) + #expect(html.contains("/js/index.js")) } }