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"))
}
}