diff --git a/Packages/Localization/.swiftpm/xcode/xcshareddata/xcschemes/Localization.xcscheme b/Packages/Localization/.swiftpm/xcode/xcshareddata/xcschemes/Localization.xcscheme new file mode 100644 index 0000000..c25c6ee --- /dev/null +++ b/Packages/Localization/.swiftpm/xcode/xcshareddata/xcschemes/Localization.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/Localization/Package.swift b/Packages/Localization/Package.swift new file mode 100644 index 0000000..277db4a --- /dev/null +++ b/Packages/Localization/Package.swift @@ -0,0 +1,37 @@ +// swift-tools-version:6.3 + +import PackageDescription + +let package = Package( + name: "Localization", + defaultLocalization: "en", + platforms: [ + .macOS(.v15), + .iOS(.v18), + .tvOS(.v18), + ], + products: [ + .library( + name: "Localization", + targets: [ + "Localization" + ] + ) + ], + targets: [ + .target( + name: "Localization", + path: "Sources", + ), + .testTarget( + name: "LocalizationTests", + dependencies: [ + .byName(name: "Localization") + ], + path: "Tests", + resources: [ + .process("Catalogs/Localizable.xcstrings") + ] + ), + ] +) diff --git a/Packages/Localization/Sources/Methods/Localize.swift b/Packages/Localization/Sources/Methods/Localize.swift new file mode 100644 index 0000000..2acb900 --- /dev/null +++ b/Packages/Localization/Sources/Methods/Localize.swift @@ -0,0 +1,45 @@ +import Foundation + +/// A reusable, bundle-bound localizer that resolves String Catalog entries for an explicit locale. +/// +/// A server has no single "current" locale, so each lookup must name the locale to use. An instance +/// is bound to the bundle whose catalog holds the strings, then invoked like a function to resolve a +/// key in a chosen locale. +public struct Localize: Sendable { + + // MARK: Properties + + /// The bundle whose compiled String Catalog the keys are resolved against. + private let bundle: Bundle + + // MARK: Initializers + + /// Creates a localizer backed by the given bundle. + /// - Parameter bundle: the bundle whose String Catalog contains the keys to resolve. + public init( + bundle: Bundle + ) { + self.bundle = bundle + } + + // MARK: Methods + + /// Resolves a catalog key in the given locale. + /// + /// Invoked by calling the instance directly, for example `localize("index.title", locale: locale)`. + /// - Parameters: + /// - key: the String Catalog key to look up. + /// - locale: the locale to resolve the key in. + /// - Returns: the localized string for the locale, or the key itself when the bundle's catalog has no entry for it. + public func callAsFunction( + _ key: String.LocalizationValue, + locale: Locale + ) -> String { + .init(localized: .init( + key, + locale: locale, + bundle: .atURL(bundle.bundleURL) + )) + } + +} diff --git a/Packages/Localization/Sources/Types/LanguageList.swift b/Packages/Localization/Sources/Types/LanguageList.swift new file mode 100644 index 0000000..0b7f476 --- /dev/null +++ b/Packages/Localization/Sources/Types/LanguageList.swift @@ -0,0 +1,46 @@ +import Foundation + +/// The list of languages an app can serve, derived from a bundle's String Catalog. +/// +/// The languages are read from the injected `bundle`'s compiled localizations, so the catalog that +/// ships in that bundle is the single source of truth: adding a language is a translation-only +/// change — once a locale exists in the catalog, it appears in ``all`` with no code change required. +public struct LanguageList: Sendable { + + // MARK: Properties + + /// The language served when none of the supported languages match a request. + /// + /// Should match the catalog bundle's development localization. + public let `default`: String + + /// The bundle whose compiled String Catalog defines the available languages. + private let bundle: Bundle + + // MARK: Initializers + + /// Creates a language list backed by the given bundle. + /// - Parameters: + /// - bundle: the bundle whose String Catalog defines the available languages. + /// - default: the language served when none of the supported languages match. Defaults to `"en"`. + public init( + bundle: Bundle, + `default`: String = "en" + ) { + self.bundle = bundle + self.`default` = `default` + } + + // MARK: Computed + + /// Every language the bundle's String Catalog provides a localization for. + /// + /// The `Base` internationalization is excluded, as it is a development placeholder rather than a + /// real language. + public var all: [String] { + bundle + .localizations + .filter { $0 != "Base" } + } + +} diff --git a/Packages/Localization/Tests/Cases/Methods/LocalizeTests.swift b/Packages/Localization/Tests/Cases/Methods/LocalizeTests.swift new file mode 100644 index 0000000..502d226 --- /dev/null +++ b/Packages/Localization/Tests/Cases/Methods/LocalizeTests.swift @@ -0,0 +1,45 @@ +import Foundation +import Testing + +@testable import Localization + +@Suite("Localize method") +struct LocalizeTests { + + // MARK: Constants + + private let localize = Localize(bundle: .module) + + // MARK: Functional tests + + @Test + func `resolves a key in the default locale`() { + let text = localize( + "test.greeting", + locale: Locale(identifier: "en") + ) + + #expect(text == "Hello") + } + + @Test + func `resolves a key in another locale`() { + let text = localize( + "test.greeting", + locale: Locale(identifier: "de"), + ) + + #expect(text == "Hallo") + } + + @Test + func `falls back to the key for an unknown entry`() { + let text = localize( + "unknown.key", + locale: Locale(identifier: "en"), + ) + + #expect(text == "unknown.key") + } + +} diff --git a/Packages/Localization/Tests/Cases/Types/LanguageListTests.swift b/Packages/Localization/Tests/Cases/Types/LanguageListTests.swift new file mode 100644 index 0000000..4e144e2 --- /dev/null +++ b/Packages/Localization/Tests/Cases/Types/LanguageListTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing + +@testable import Localization + +@Suite("LanguageList type") +struct LanguageListTests { + + // MARK: Properties tests + + @Suite("default") + struct Default { + @Test + func `defaults to english`() { + let list = LanguageList( + bundle: .module + ) + + #expect(list.default == "en") + } + + @Test + func `uses the provided default language`() { + let list = LanguageList( + bundle: .module, + default: "de" + ) + + #expect(list.default == "de") + } + } + + @Suite("all") + struct All { + @Test + func `lists the catalog languages`() { + let list = LanguageList( + bundle: .module + ) + + #expect(list.all.contains("en")) + #expect(list.all.contains("de")) + } + + @Test + func `excludes the base localization`() { + let list = LanguageList( + bundle: .module + ) + + #expect(!list.all.contains("Base")) + } + + @Test + func `lists each language only once`() { + let list = LanguageList( + bundle: .module + ) + let all = list.all + + #expect(all.count == Set(all).count) + } + } + +} diff --git a/Packages/Localization/Tests/Catalogs/Localizable.xcstrings b/Packages/Localization/Tests/Catalogs/Localizable.xcstrings new file mode 100644 index 0000000..509d868 --- /dev/null +++ b/Packages/Localization/Tests/Catalogs/Localizable.xcstrings @@ -0,0 +1,23 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "test.greeting" : { + "comment" : "Fixture string used by the Localization test suite.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hallo" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hello" + } + } + } + } + }, + "version" : "1.0" +} diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift index 937f6e0..929c0d0 100644 --- a/Services/Website/Package.swift +++ b/Services/Website/Package.swift @@ -4,6 +4,7 @@ import PackageDescription let package = Package( name: "Website", + defaultLocalization: "en", platforms: [ .macOS(.v15), .iOS(.v18), @@ -19,6 +20,9 @@ let package = Package( ) ], dependencies: [ + .package( + path: "../Packages/Localization" + ), .package( url: "https://github.com/elementary-swift/elementary.git", from: "0.6.0" @@ -67,6 +71,7 @@ let package = Package( .target( name: "WebsiteCore", dependencies: [ + .byName(name: "Localization"), .product( name: "Configuration", package: "swift-configuration" @@ -84,7 +89,10 @@ let package = Package( package: "hummingbird-elementary" ), ], - path: "Sources/Library" + path: "Sources/Library", + resources: [ + .process("Catalogs/Localizable.xcstrings") + ] ), .testTarget( name: "WebsiteTests", diff --git a/Services/Website/Sources/App/App+build.swift b/Services/Website/Sources/App/App+build.swift index c24d390..3898109 100644 --- a/Services/Website/Sources/App/App+build.swift +++ b/Services/Website/Sources/App/App+build.swift @@ -69,7 +69,7 @@ func application( // MARK: - Helpers // Request context used by application -private typealias AppRequestContext = BasicRequestContext +private typealias AppRequestContext = WebsiteRequestContext /// Builds the cache-control policy applied to the served static files. /// @@ -153,7 +153,8 @@ private func logger( /// Registers the request-logging middleware, the security-headers middleware that stamps the given /// `securityHeaders` onto every response, the response-compression middleware that compresses /// responses larger than `minimumResponseSizeToCompress` when the client advertises support, the -/// not-found middleware that serves the error page, and the static file middleware that serves the +/// localization middleware that negotiates the request's language from its `Accept-Language` header, +/// the not-found middleware that serves the error page, and the static file middleware that serves the /// contents of `staticFilesPath` (tagging responses with the given `cacheControl` directives), then /// adds the `RootController` routes that render the landing page and the `HealthController` routes /// that serve the health check. @@ -185,6 +186,7 @@ private func router( ResponseCompressionMiddleware( minimumResponseSizeToCompress: compressionMinResponseSize ) + LocalizationMiddleware() NotFoundMiddleware() FileMiddleware( staticFilesPath, diff --git a/Services/Website/Sources/Library/Catalogs/Localizable.xcstrings b/Services/Website/Sources/Library/Catalogs/Localizable.xcstrings new file mode 100644 index 0000000..9a6ccec --- /dev/null +++ b/Services/Website/Sources/Library/Catalogs/Localizable.xcstrings @@ -0,0 +1,61 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "error.heading" : { + "comment" : "The not-found page's main heading.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Page Not Found" + } + } + } + }, + "error.message" : { + "comment" : "The not-found page's body text.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sorry, but the page you were trying to view does not exist." + } + } + } + }, + "error.title" : { + "comment" : "The not-found page's document title.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Page Not Found" + } + } + } + }, + "index.greeting" : { + "comment" : "The landing page's greeting paragraph.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hello world! This is HTML5 Boilerplate." + } + } + } + }, + "index.title" : { + "comment" : "The landing page's document title.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Index page" + } + } + } + } + }, + "version" : "1.0" +} diff --git a/Services/Website/Sources/Library/Internal/Methods/NegotiateLanguage.swift b/Services/Website/Sources/Library/Internal/Methods/NegotiateLanguage.swift new file mode 100644 index 0000000..c7e8422 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Methods/NegotiateLanguage.swift @@ -0,0 +1,85 @@ +import Foundation +import Localization + +/// Negotiates the best supported language for a request from its `Accept-Language` header. +/// +/// Bound to the module's catalog languages via ``LanguageList``, an instance is invoked like a +/// function — through ``callAsFunction(forAcceptLanguage:)`` — to resolve a header value to a +/// supported language identifier, falling back to the default language. +struct NegotiateLanguage { + + // MARK: Properties + + /// The supported languages and default language, derived from the module's String Catalog. + private let list: LanguageList + + // MARK: Initializers + + /// Creates a language negotiator backed by the module's String Catalog. + init() { + self.list = .init(bundle: .module) + } + + // MARK: Methods + + /// Picks the best supported language for the given `Accept-Language` header value. + /// + /// Invoked by calling the instance directly, for example `negotiate(forAcceptLanguage: header)`. + /// The header is split into its language tags (dropping any `q` weights), then matched against + /// the supported languages with `Bundle.preferredLocalizations(from:forPreferences:)`. When the + /// header is absent or matches nothing, the default language is returned. + /// - Parameter acceptLanguage: the raw `Accept-Language` header value, if any. + /// - Returns: the identifier of the supported language to serve. + func callAsFunction( + forAcceptLanguage acceptLanguage: String? + ) -> String { + guard let acceptLanguage else { + return list.default + } + + let tags = tags(from: acceptLanguage) + + guard !tags.isEmpty else { + return list.default + } + + let preferred = Bundle.preferredLocalizations( + from: list.all, + forPreferences: tags + ) + + return preferred.first + ?? list.default + } + +} + +// MARK: - Helpers + +private extension NegotiateLanguage { + + // MARK: Methods + + /// Extracts the ordered language tags from an `Accept-Language` header value. + /// + /// Each comma-separated entry is reduced to its language tag by dropping the `;q=` weight, and + /// blank entries are removed. The original order is kept, which mirrors descending preference + /// closely enough for `preferredLocalizations(from:forPreferences:)` to resolve correctly. + /// - Parameter acceptLanguage: the raw `Accept-Language` header value. + /// - Returns: the ordered, weight-stripped language tags. + func tags( + from acceptLanguage: String + ) -> [String] { + acceptLanguage + .split(separator: ",") + .map { entry in + entry + .split(separator: ";") + .first + .map(String.init)? + .trimmingCharacters(in: .whitespaces) ?? "" + } + .filter { !$0.isEmpty } + } + +} diff --git a/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift b/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift index 733ae1d..06f11b6 100644 --- a/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift @@ -1,14 +1,39 @@ import Elementary +import Foundation +import Localization -/// The HTML page rendered for a not-found response. +/// The HTML page rendered for a not-found response, with its text localized to a given locale. struct ErrorPage: HTMLDocument, Sendable { + // MARK: Properties + + /// The locale the page content is localized to. + private let locale: Locale + + /// Resolves the page's text from the bundled String Catalog for the page's ``locale``. + private let localize: Localize + + // MARK: Initializers + + /// Creates a not-found page localized to the given locale. + /// - Parameter locale: the locale the page content is localized to. + init( + locale: Locale + ) { + self.locale = locale + self.localize = .init(bundle: .module) + } + // MARK: Document - /// The page's content. + /// The page's content: a localized heading and explanatory message. var body: some HTML { - h1 { "Page Not Found" } - p { "Sorry, but the page you were trying to view does not exist." } + h1 { + localize("error.heading", locale: locale) + } + p { + localize("error.message", locale: locale) + } } /// The metadata and stylesheet link placed in the document head. @@ -24,10 +49,15 @@ struct ErrorPage: HTMLDocument, Sendable { ) } - /// The document language. - var lang: String { "en" } + /// The document language, derived from the page's locale and falling back to the default language. + var lang: String { + locale.language.languageCode?.identifier + ?? LanguageList(bundle: .module).default + } - /// The document title. - var title: String { "Page Not Found" } + /// The localized document title. + var title: String { + localize("error.title", locale: locale) + } } diff --git a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift index 23630d2..8d6c0c2 100644 --- a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift @@ -1,13 +1,36 @@ import Elementary +import Foundation +import Localization -/// The website's landing page. +/// The website's landing page, with its text localized to a given locale. struct IndexPage: HTMLDocument, Sendable { + // MARK: Properties + + /// The locale the page content is localized to. + private let locale: Locale + + /// Resolves the page's text from the bundled String Catalog for the page's ``locale``. + private let localize: Localize + + // MARK: Initializers + + /// Creates a landing page localized to the given locale. + /// - Parameter locale: the locale the page content is localized to. + init( + locale: Locale + ) { + self.locale = locale + self.localize = .init(bundle: .module) + } + // MARK: Document - /// The page's content. + /// The page's content: a localized greeting followed by the app script. var body: some HTML { - p { "Hello world! This is HTML5 Boilerplate." } + p { + localize("index.greeting", locale: locale) + } script(.src("/js/app.js")) {} } @@ -52,14 +75,15 @@ struct IndexPage: HTMLDocument, Sendable { ) } - /// The document language. + /// The document language, derived from the page's locale and falling back to the default language. var lang: String { - "en" + locale.language.languageCode?.identifier + ?? LanguageList(bundle: .module).default } - /// The document title. + /// The localized document title. var title: String { - "Index page" + localize("index.title", locale: locale) } } diff --git a/Services/Website/Sources/Library/Internal/Responses/CachedHTMLResponse.swift b/Services/Website/Sources/Library/Internal/Responses/CachedHTMLResponse.swift index 6b1dcfb..8493813 100644 --- a/Services/Website/Sources/Library/Internal/Responses/CachedHTMLResponse.swift +++ b/Services/Website/Sources/Library/Internal/Responses/CachedHTMLResponse.swift @@ -1,12 +1,16 @@ import Elementary +import HTTPTypes import Hummingbird import NIOCore /// A pre-rendered HTTP response for a fully static HTML page. /// /// The document is rendered to bytes once, at initialization, and every ``response()`` reuses those -/// bytes instead of re-rendering. This suits pages whose markup never changes between requests — the -/// landing page and the not-found page — avoiding a per-request Elementary render on hot paths. +/// bytes — along with a fixed status and precomputed headers — instead of re-rendering. This suits +/// pages whose markup never changes between requests, such as the landing page and the not-found +/// page, avoiding a per-request Elementary render on hot paths. +/// +/// ``LocalizedHTMLResponses`` builds on this type, caching one instance per supported language. /// /// The body is written as an unsized stream (no `Content-Length`), mirroring `HTMLResponse`, so the /// response-compression middleware downstream treats it exactly as it would a freshly rendered page. @@ -14,23 +18,37 @@ struct CachedHTMLResponse: Sendable { // MARK: Properties - /// The status applied to every response. - private let status: HTTPResponse.Status /// The page rendered to bytes once. private let buffer: ByteBuffer + /// The headers applied to every response, precomputed once. + private let headers: HTTPFields + /// The status applied to every response. + private let status: HTTPResponse.Status // MARK: Initializers /// Renders the given document to bytes once. /// - Parameters: /// - status: the status applied to every response. Defaults to `.ok`. + /// - additionalHeaders: extra headers merged onto every response, alongside the content type. + /// Used to carry per-language signals such as `Content-Language` and `Vary`. /// - document: the static HTML document to render and cache. init( status: HTTPResponse.Status = .ok, - _ document: some HTMLDocument + additionalHeaders: HTTPFields = [:], + document: some HTMLDocument ) { + var headers: HTTPFields = [ + .contentType: "text/html; charset=utf-8" + ] + + for field in additionalHeaders { + headers[field.name] = field.value + } + self.status = status - self.buffer = ByteBuffer(string: document.render()) + self.headers = headers + self.buffer = .init(string: document.render()) } // MARK: Methods @@ -43,7 +61,7 @@ struct CachedHTMLResponse: Sendable { func response() -> Response { Response( status: status, - headers: [.contentType: "text/html; charset=utf-8"], + headers: headers, body: .init { [buffer] writer in try await writer.write(buffer) try await writer.finish(nil) diff --git a/Services/Website/Sources/Library/Internal/Responses/LocalizedHTMLCollectionResponse.swift b/Services/Website/Sources/Library/Internal/Responses/LocalizedHTMLCollectionResponse.swift new file mode 100644 index 0000000..e6772d1 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Responses/LocalizedHTMLCollectionResponse.swift @@ -0,0 +1,65 @@ +import Elementary +import Foundation +import HTTPTypes +import Hummingbird +import Localization + +/// A per-language collection of pre-rendered HTML responses. +/// +/// At initialization it renders the document once for each language the bundle's ``LanguageList`` +/// reports and caches the bytes, mirroring ``CachedHTMLResponse``'s render-once model but keyed by +/// language. Each cached response carries a `Content-Language` header and `Vary: Accept-Language`, so +/// shared caches key on the negotiated language instead of serving one language to everyone. +struct LocalizedHTMLCollectionResponse: Sendable { + + // MARK: Properties + + /// The supported languages and default language, derived from the module's String Catalog. + private let list: LanguageList + + /// The pre-rendered responses, keyed by language identifier. + private let responses: [String: CachedHTMLResponse] + + // MARK: Initializers + + /// Renders the document once per supported language. + /// - Parameters: + /// - status: the status applied to every response. Defaults to `.ok`. + /// - document: builds the document to render for a given locale. + init( + status: HTTPResponse.Status = .ok, + document: (Locale) -> Document + ) { + self.list = .init(bundle: .module) + self.responses = list.all + .reduce(into: [:]) { responses, language in + responses[language] = CachedHTMLResponse( + status: status, + additionalHeaders: [ + .contentLanguage: language, + .vary: "Accept-Language", + ], + document: document(.init(identifier: language)) + ) + } + } + + // MARK: Methods + + /// Builds the response for the given language, falling back to the default language. + /// - Parameter language: the negotiated language identifier. + /// - Returns: the cached response for the language, the default language's response when the + /// language is unavailable, or a `500 Internal Server Error` if neither is cached. + func response( + for language: String + ) -> Response { + guard + let response = responses[language] ?? responses[list.default] + else { + return .init(status: .internalServerError) + } + + return response.response() + } + +} diff --git a/Services/Website/Sources/Library/Public/Contexts/LocalizedRequestContext.swift b/Services/Website/Sources/Library/Public/Contexts/LocalizedRequestContext.swift new file mode 100644 index 0000000..195196c --- /dev/null +++ b/Services/Website/Sources/Library/Public/Contexts/LocalizedRequestContext.swift @@ -0,0 +1,42 @@ +import Hummingbird +import Localization + +/// A request context that carries the language negotiated for the request. +/// +/// ``LocalizationMiddleware`` resolves the visitor's preferred language from the `Accept-Language` +/// header and stores it here, so downstream controllers and middleware can serve the matching +/// localization without re-reading the header. +public protocol LocalizedRequestContext: RequestContext { + + // MARK: Properties + + /// The language identifier negotiated for the request. + var language: String { get set } + +} + +// MARK: - Context + +/// The website's request context. +/// +/// Extends the core request storage with the negotiated language, defaulting to the default +/// supported language until ``LocalizationMiddleware`` resolves it from the request. +public struct WebsiteRequestContext: LocalizedRequestContext { + + // MARK: Properties + + /// The core request context storage Hummingbird requires. + public var coreContext: CoreRequestContextStorage + /// The language identifier negotiated for the request. + public var language: String + + // MARK: Initializers + + /// Creates a request context for the given source. + /// - Parameter source: the source the context is initialized from. + public init(source: Source) { + self.coreContext = .init(source: source) + self.language = LanguageList(bundle: .module).default + } + +} diff --git a/Services/Website/Sources/Library/Public/Controllers/RootController.swift b/Services/Website/Sources/Library/Public/Controllers/RootController.swift index 811f696..d457c73 100644 --- a/Services/Website/Sources/Library/Public/Controllers/RootController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/RootController.swift @@ -11,25 +11,26 @@ import Hummingbird /// /// - Note: `Context` is the request context the routes are resolved against, and must match the /// context of the router the routes are added to. -public struct RootController: Sendable { +public struct RootController: Sendable { // MARK: Properties - /// The landing page, rendered once at initialization and reused for every request. - private let cache: CachedHTMLResponse + /// The landing page, rendered once per supported language and reused for every request. + private let responses: LocalizedHTMLCollectionResponse // MARK: Initializers /// Creates a root controller. public init() { - self.cache = .init(IndexPage()) + self.responses = .init { IndexPage(locale: $0) } } // MARK: Computed /// The routes served by the controller. /// - /// Serves a `GET` request for the root path (`/`) by rendering the ``IndexPage``. + /// Serves a `GET` request for the root path (`/`) by rendering the ``IndexPage`` in the + /// language negotiated for the request. public var routes: RouteCollection { let routes = RouteCollection(context: Context.self) @@ -50,16 +51,19 @@ private extension RootController { // MARK: Methods /// Handles a request for the landing page. + /// + /// Renders the ``IndexPage`` in the language stored on the context by ``LocalizationMiddleware``, + /// falling back to the default language. /// - Parameters: /// - request: the incoming request. /// - context: the context the request is resolved against. - /// - Returns: the cached ``IndexPage`` response. + /// - Returns: the cached ``IndexPage`` response for the context's language. @Sendable func index( request: Request, - context: some RequestContext + context: Context ) -> Response { - cache.response() + responses.response(for: context.language) } } diff --git a/Services/Website/Sources/Library/Public/Middlewares/LocalizationMiddleware.swift b/Services/Website/Sources/Library/Public/Middlewares/LocalizationMiddleware.swift new file mode 100644 index 0000000..99725f5 --- /dev/null +++ b/Services/Website/Sources/Library/Public/Middlewares/LocalizationMiddleware.swift @@ -0,0 +1,56 @@ +import HTTPTypes +import Hummingbird +import Localization + +/// Resolves the visitor's preferred language and records it on the request context. +/// +/// Placed ahead of the localized responders in the middleware chain, it reads the request's +/// `Accept-Language` header, negotiates the best supported match (falling back to the default +/// language), and stores it on the context's ``LocalizedRequestContext/language``. +/// +/// The request is otherwise passed through untouched — the URL and routing are not affected — so +/// each page is served at its existing path and varies its content by header. +public struct LocalizationMiddleware { + + // MARK: Properties + + /// Negotiates the request's language from its `Accept-Language` header. + private let negotiate: NegotiateLanguage + + // MARK: Initializers + + /// Creates a localization middleware. + public init() { + self.negotiate = .init() + } + +} + +// MARK: - RouterMiddleware + +extension LocalizationMiddleware: RouterMiddleware { + + // MARK: Functions + + /// Negotiates the request's language and records it on the context before passing it down. + /// - Parameters: + /// - request: the incoming request. + /// - context: the context the request is resolved against. + /// - next: the next responder in the middleware chain. + /// - Returns: the downstream response. + /// - Throws: any error thrown downstream. + public func handle( + _ request: Request, + context: Context, + next: (Request, Context) async throws -> Response + ) async throws -> Response { + var context = context + + context.language = negotiate( + forAcceptLanguage: request.headers[.acceptLanguage] + ) + + return try await next(request, context) + } + +} diff --git a/Services/Website/Sources/Library/Public/Middlewares/NotFoundMiddleware.swift b/Services/Website/Sources/Library/Public/Middlewares/NotFoundMiddleware.swift index abbbdfd..6d053aa 100644 --- a/Services/Website/Sources/Library/Public/Middlewares/NotFoundMiddleware.swift +++ b/Services/Website/Sources/Library/Public/Middlewares/NotFoundMiddleware.swift @@ -4,22 +4,24 @@ import Hummingbird /// /// Placed ahead of `FileMiddleware` in the middleware chain, it catches the `.notFound` error /// that bubbles up when no file exists for the requested path and responds with the rendered -/// ``ErrorPage`` and a `404 Not Found` status. -public struct NotFoundMiddleware { +/// ``ErrorPage`` and a `404 Not Found` status. The page is served in the language stored on the +/// context by ``LocalizationMiddleware``, falling back to the default language. +public struct NotFoundMiddleware { // MARK: Properties - /// The error page, rendered once at initialization and reused for every not-found response. - private let cache: CachedHTMLResponse + /// The error page, rendered once per supported language and reused for every not-found response. + private let responses: LocalizedHTMLCollectionResponse // MARK: Initializers /// Creates a not-found middleware. public init() { - self.cache = .init( - status: .notFound, - ErrorPage() - ) + self.responses = .init( + status: .notFound + ) { + ErrorPage(locale: $0) + } } } @@ -56,7 +58,9 @@ extension NotFoundMiddleware: RouterMiddleware { throw error } - return cache.response() + return responses.response( + for: context.language + ) } } diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift index c320657..463a357 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/ErrorPageTests.swift @@ -1,4 +1,5 @@ import Elementary +import Foundation import Testing @testable import WebsiteCore @@ -10,11 +11,14 @@ struct ErrorPageTests { @Test func `renders its markup`() { - let html = ErrorPage().render() + let html = ErrorPage( + locale: .init(identifier: "en") + ).render() #expect(html.contains("")) + #expect(html.contains(#"lang="en""#)) #expect(html.contains("Page Not Found")) - #expect(html.contains("does not exist")) + #expect(html.contains("Sorry, but the page you were trying to view does not exist.")) #expect(html.contains("/css/error.css")) } diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift index 1594721..be76e76 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift @@ -1,4 +1,5 @@ import Elementary +import Foundation import Testing @testable import WebsiteCore @@ -10,9 +11,12 @@ struct IndexPageTests { @Test func `renders its markup`() { - let html = IndexPage().render() + let html = IndexPage( + locale: .init(identifier: "en") + ).render() #expect(html.contains("")) + #expect(html.contains(#"lang="en""#)) #expect(html.contains("/css/style.css")) #expect(html.contains("/favicon.ico")) #expect(html.contains("/icon.svg")) diff --git a/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift b/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift index daa9e79..6f0cd7f 100644 --- a/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift +++ b/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift @@ -11,9 +11,13 @@ struct RootControllerTests { // MARK: Constants private let app: Application = .init(router: { - let router = Router() - - router.addRoutes(RootController().routes) + let router = Router(context: WebsiteRequestContext.self) + + router.addMiddleware { + LocalizationMiddleware() + } + + router.addRoutes(RootController().routes) return router }()) @@ -31,6 +35,8 @@ struct RootControllerTests { #expect(response.status == .ok) #expect(response.headers[.contentType] == "text/html; charset=utf-8") + #expect(response.headers[.contentLanguage] == "en") + #expect(response.headers[.vary] == "Accept-Language") #expect(body.contains("Hello world!")) } } diff --git a/Services/Website/Tests/Library/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift b/Services/Website/Tests/Library/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift new file mode 100644 index 0000000..09046af --- /dev/null +++ b/Services/Website/Tests/Library/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift @@ -0,0 +1,55 @@ +import HTTPTypes +import Hummingbird +import HummingbirdTesting +import NIOCore +import Testing + +@testable import WebsiteCore + +@Suite("LocalizationMiddleware middleware") +struct LocalizationMiddlewareTests { + + // MARK: Constants + + private let app: Application = .init(router: { + let router = Router(context: WebsiteRequestContext.self) + + router.addMiddleware { + LocalizationMiddleware() + } + + router.get("language") { _, context in + context.language + } + + return router + }()) + + // MARK: Functional tests + + @Test + func `negotiates a supported language from the header`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/language", + method: .get, + headers: [.acceptLanguage: "en-US,en;q=0.9"] + ) { response in + #expect(String(buffer: response.body) == "en") + } + } + } + + @Test + func `falls back to the default without a header`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/language", + method: .get + ) { response in + #expect(String(buffer: response.body) == "en") + } + } + } + +} diff --git a/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift b/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift index b4646e3..8ac9e09 100644 --- a/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift +++ b/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift @@ -11,9 +11,10 @@ struct NotFoundMiddlewareTests { // MARK: Constants private let app: Application = .init(router: { - let router = Router() + let router = Router(context: WebsiteRequestContext.self) router.addMiddleware { + LocalizationMiddleware() NotFoundMiddleware() } @@ -41,6 +42,8 @@ struct NotFoundMiddlewareTests { #expect(response.status == .notFound) #expect(response.headers[.contentType] == "text/html; charset=utf-8") + #expect(response.headers[.contentLanguage] == "en") + #expect(response.headers[.vary] == "Accept-Language") #expect(body.contains("Page Not Found")) } }