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 0000000..56d1ff2 Binary files /dev/null and b/Services/Website/Resources/Static/apple-touch-icon.png differ 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")) } }