diff --git a/Packages/Localization/Package.swift b/Packages/Localization/Package.swift
index 277db4a..2323799 100644
--- a/Packages/Localization/Package.swift
+++ b/Packages/Localization/Package.swift
@@ -1,4 +1,4 @@
-// swift-tools-version:6.3
+// swift-tools-version: 6.3
import PackageDescription
diff --git a/Services/Website/Sources/Library/Internal/Methods/NegotiateLanguage.swift b/Packages/Localization/Sources/Methods/Negotiate.swift
similarity index 64%
rename from Services/Website/Sources/Library/Internal/Methods/NegotiateLanguage.swift
rename to Packages/Localization/Sources/Methods/Negotiate.swift
index c7e8422..06cfe99 100644
--- a/Services/Website/Sources/Library/Internal/Methods/NegotiateLanguage.swift
+++ b/Packages/Localization/Sources/Methods/Negotiate.swift
@@ -1,43 +1,45 @@
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
+/// Bound to a bundle's catalog languages via ``LanguageList``, an instance is invoked like a
+/// function — through ``callAsFunction(acceptLanguage:)`` — to resolve a header value to a
/// supported language identifier, falling back to the default language.
-struct NegotiateLanguage {
+public struct Negotiate: Sendable {
// MARK: Properties
- /// The supported languages and default language, derived from the module's String Catalog.
+ /// The supported languages and default language, derived from the bundle'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)
+ /// Creates a language negotiator backed by the given bundle's String Catalog.
+ /// - Parameter bundle: the bundle whose String Catalog defines the supported languages.
+ public init(
+ bundle: Bundle
+ ) {
+ self.list = .init(bundle: bundle)
}
// 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)`.
+ /// Invoked by calling the instance directly, for example `negotiate(acceptLanguage: 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?
+ public func callAsFunction(
+ acceptLanguage language: String?
) -> String {
- guard let acceptLanguage else {
+ guard let language else {
return list.default
}
- let tags = tags(from: acceptLanguage)
+ let tags = tags(from: language)
guard !tags.isEmpty else {
return list.default
@@ -56,7 +58,7 @@ struct NegotiateLanguage {
// MARK: - Helpers
-private extension NegotiateLanguage {
+private extension Negotiate {
// MARK: Methods
@@ -71,15 +73,29 @@ private extension NegotiateLanguage {
from acceptLanguage: String
) -> [String] {
acceptLanguage
- .split(separator: ",")
+ .split(separator: .Separator.comma)
.map { entry in
entry
- .split(separator: ";")
+ .split(separator: .Separator.semicolon)
.first
.map(String.init)?
- .trimmingCharacters(in: .whitespaces) ?? ""
+ .trimmingCharacters(in: .whitespaces)
+ ?? .empty
}
.filter { !$0.isEmpty }
}
}
+
+// MARK: - Constants
+
+private extension Character {
+ enum Separator {
+ static let comma: Character = ","
+ static let semicolon: Character = ";"
+ }
+}
+
+private extension String {
+ static let empty: String = ""
+}
diff --git a/Packages/Localization/Tests/Cases/Methods/NegotiateTests.swift b/Packages/Localization/Tests/Cases/Methods/NegotiateTests.swift
new file mode 100644
index 0000000..4ab3160
--- /dev/null
+++ b/Packages/Localization/Tests/Cases/Methods/NegotiateTests.swift
@@ -0,0 +1,78 @@
+import Foundation
+import Testing
+
+@testable import Localization
+
+@Suite("Negotiate method")
+struct NegotiateTests {
+
+ // MARK: Constants
+
+ private let negotiate = Negotiate(bundle: .module)
+
+ // MARK: Functional tests
+
+ @Test
+ func `matches an exact language tag`() {
+ let language = negotiate(acceptLanguage: "de")
+
+ #expect(language == "de")
+ }
+
+ @Test
+ func `matches a regional language tag`() {
+ let language = negotiate(acceptLanguage: "de-AT")
+
+ #expect(language == "de")
+ }
+
+ @Test
+ func `respects the header order of preference`() {
+ let language = negotiate(acceptLanguage: "de, en")
+
+ #expect(language == "de")
+ }
+
+ @Test
+ func `strips quality weights from the language tags`() {
+ let language = negotiate(acceptLanguage: "de;q=0.5, en;q=0.9")
+
+ #expect(language == "de")
+ }
+
+ @Test
+ func `trims whitespace around the language tags`() {
+ let language = negotiate(acceptLanguage: " de , en ")
+
+ #expect(language == "de")
+ }
+
+ @Test
+ func `falls back to the default for an unsupported language`() {
+ let language = negotiate(acceptLanguage: "fr")
+
+ #expect(language == "en")
+ }
+
+ @Test
+ func `falls back to the default for a missing header`() {
+ let language = negotiate(acceptLanguage: nil)
+
+ #expect(language == "en")
+ }
+
+ @Test
+ func `falls back to the default for an empty header`() {
+ let language = negotiate(acceptLanguage: "")
+
+ #expect(language == "en")
+ }
+
+ @Test
+ func `falls back to the default for a header without language tags`() {
+ let language = negotiate(acceptLanguage: " , ;q=0.5,")
+
+ #expect(language == "en")
+ }
+
+}
diff --git a/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme b/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme
index 24b3ac5..2700a30 100644
--- a/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme
+++ b/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme
@@ -26,13 +26,8 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
- shouldUseLaunchSchemeArgsEnv = "YES">
-
-
-
-
+ shouldUseLaunchSchemeArgsEnv = "YES"
+ shouldAutocreateTestPlan = "YES">
@@ -50,8 +45,7 @@
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
- useCustomWorkingDirectory = "YES"
- customWorkingDirectory = "/Users/logan/Documents/Development/Platforms/Röck+Cöde/Loud/Services/Website"
+ useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
diff --git a/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/WebsiteCoreTests.xcscheme b/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/WebsiteCoreTests.xcscheme
new file mode 100644
index 0000000..d5af719
--- /dev/null
+++ b/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/WebsiteCoreTests.xcscheme
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift
index 929c0d0..7e4b279 100644
--- a/Services/Website/Package.swift
+++ b/Services/Website/Package.swift
@@ -1,4 +1,4 @@
-// swift-tools-version:6.3
+// swift-tools-version: 6.3
import PackageDescription
@@ -21,7 +21,7 @@ let package = Package(
],
dependencies: [
.package(
- path: "../Packages/Localization"
+ path: "../../Packages/Localization"
),
.package(
url: "https://github.com/elementary-swift/elementary.git",
diff --git a/Services/Website/README.md b/Services/Website/README.md
index 0e0bacd..614ab5e 100644
--- a/Services/Website/README.md
+++ b/Services/Website/README.md
@@ -3,9 +3,11 @@ The **Loud** public website service — a [Hummingbird](https://github.com/hummi
## Overview
The service:
-- Serves the landing page at `GET /` (rendered once with [Elementary](https://github.com/elementary-swift/elementary) and cached).
+- Serves the landing page at `GET /` (rendered once per supported language with [Elementary](https://github.com/elementary-swift/elementary) and cached).
+- Negotiates each request's language from its `Accept-Language` header against the languages in the `WebsiteCore` String Catalog, falling back to the default (`en`); pages are served from the per-language cache with `Content-Language` and `Vary: Accept-Language` headers.
+- Answers health checks at `GET /health` with a static JSON payload.
- Serves static files (CSS, JS, icons, manifest, `robots.txt`) from `Resources/Static` via Hummingbird's `FileMiddleware`, tagged with media-type-specific `Cache-Control`.
-- Returns a custom HTML 404 page for any request that matches neither a route nor a static file.
+- Returns a custom HTML 404 page, localized like the landing page, for any request that matches neither a route nor a static file.
- Compresses responses (gzip/deflate) above a configurable size when the client advertises support.
- Stamps a hardened set of security headers on every response.
@@ -20,14 +22,18 @@ Two SwiftPM targets:
| `Website` | executable | `Sources/App` | Entry point: reads configuration, builds and runs the application. |
| `WebsiteCore` | library | `Sources/Library` | Controllers, middlewares, pages, cached responses, and configuration helpers. |
+`WebsiteCore` also depends on the local `Localization` package (`Packages/Localization`), which provides the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages.
+
Requests pass through the middleware chain in this order (outermost first), then reach the routes:
```
LogRequestsMiddleware
- → SecurityHeadersMiddleware (security headers on every response)
- → ResponseCompressionMiddleware (gzip/deflate above the size threshold)
- → NotFoundMiddleware (renders the 404 page on .notFound)
- → FileMiddleware (serves Resources/Static)
-RootController (GET / → landing page)
+ → SecurityHeadersMiddleware (security headers on every response)
+ → ResponseCompressionMiddleware (gzip/deflate above the size threshold)
+ → LocalizationMiddleware (negotiates the request's language)
+ → NotFoundMiddleware (renders the localized 404 page on .notFound)
+ → FileMiddleware (serves Resources/Static)
+RootController (GET / → landing page)
+HealthController (GET /health → health check)
```
## Configuration
@@ -40,7 +46,7 @@ the following sources, **highest precedence first**:
### Environment variable naming
A dotted config key maps to an environment variable by upper-casing, splitting camelCase, and replacing separators with `_`. For example `http.serverName` → `HTTP_SERVER_NAME`,
-`security.strictTransportSecurity` → `SECURITY_STRICT_TRANSPORT_SECURITY`, `cache.maxAge.text` →` CACHE_MAX_AGE_TEXT`.
+`security.strictTransportSecurity` → `SECURITY_STRICT_TRANSPORT_SECURITY`, `cache.maxAge.text` → `CACHE_MAX_AGE_TEXT`.
> To disable a header or override a value, leave the variable **unset** to fall back to the default. A
> variable that is set but **blank** is treated as an explicit empty value, not as "use the default".
@@ -92,7 +98,7 @@ swift run Website # binds to Hummingbird's default 127.0.0.1:8080
swift run Website --http-host 0.0.0.0 --http-port 9000 --log-level debug
```
-Or via the Makefile / Docker (uses `docker-compose.override.yml`, which builds from source and sets`LOG_LEVEL=debug`:
+Or via the Makefile / Docker (uses `docker-compose.override.yml`, which builds from source and sets `LOG_LEVEL=debug`):
```sh
make pkg-build # swift build
make img-mount # docker compose up --build --detach
@@ -100,16 +106,17 @@ make img-unmount # docker compose down + remove the local image
```
`make help` lists every available target.
+
## Testing
```sh
make pkg-test
# = swift test --disable-xctest --enable-code-coverage --enable-swift-testing --parallel
```
-Tests use the [Swift Testing](https://developer.apple.com/documentation/testing/) framework. The`Website.xctestplan` covers two targets: `WebsiteTests` (the executable/integration tests) and` WebsiteCoreTests` (the library unit tests).
+Tests use the [Swift Testing](https://developer.apple.com/documentation/testing/) framework. The `Website.xctestplan` covers two targets: `WebsiteTests` (the executable/integration tests) and `WebsiteCoreTests` (the library unit tests).
## Deployment
-The production image is built for `linux/amd64` in release mode with a statically linked Swift runtime and jemalloc, runs as a non-root `hummingbird` user, and exposes port `8080`(`ENTRYPOINT ./Website --http-host 0.0.0.0 --http-port 8080`).
+The production image is built for `linux/amd64` in release mode with a statically linked Swift runtime and jemalloc, runs as a non-root `hummingbird` user, and exposes port `8080` (`ENTRYPOINT ./Website --http-host 0.0.0.0 --http-port 8080`).
Build, tag, and push a release to the registry (an explicit version is required):
```sh
make img-release version=1.2.3
diff --git a/Services/Website/Sources/Library/Internal/Responses/CachedHTMLResponse.swift b/Services/Website/Sources/Library/Internal/Responses/CachedHTMLResponse.swift
index 8493813..f9da28a 100644
--- a/Services/Website/Sources/Library/Internal/Responses/CachedHTMLResponse.swift
+++ b/Services/Website/Sources/Library/Internal/Responses/CachedHTMLResponse.swift
@@ -10,7 +10,7 @@ import NIOCore
/// 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.
+/// ``LocalizedHTMLCollectionResponse`` 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.
diff --git a/Services/Website/Sources/Library/Public/Contexts/LocalizedRequestContext.swift b/Services/Website/Sources/Library/Public/Contexts/LocalizedRequestContext.swift
index 195196c..0172f98 100644
--- a/Services/Website/Sources/Library/Public/Contexts/LocalizedRequestContext.swift
+++ b/Services/Website/Sources/Library/Public/Contexts/LocalizedRequestContext.swift
@@ -1,5 +1,4 @@
import Hummingbird
-import Localization
/// A request context that carries the language negotiated for the request.
///
@@ -34,9 +33,17 @@ public struct WebsiteRequestContext: LocalizedRequestContext {
/// Creates a request context for the given source.
/// - Parameter source: the source the context is initialized from.
- public init(source: Source) {
+ public init(
+ source: Source,
+ ) {
self.coreContext = .init(source: source)
- self.language = LanguageList(bundle: .module).default
+ self.language = .empty
}
}
+
+// MARK: - Constants
+
+private extension String {
+ static let empty = ""
+}
diff --git a/Services/Website/Sources/Library/Internal/Extensions/HTTPFieldName+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/HTTPFieldName+Constants.swift
similarity index 61%
rename from Services/Website/Sources/Library/Internal/Extensions/HTTPFieldName+Constants.swift
rename to Services/Website/Sources/Library/Public/Extensions/HTTPFieldName+Constants.swift
index 4ebf3bb..b343f3c 100644
--- a/Services/Website/Sources/Library/Internal/Extensions/HTTPFieldName+Constants.swift
+++ b/Services/Website/Sources/Library/Public/Extensions/HTTPFieldName+Constants.swift
@@ -2,9 +2,9 @@ import HTTPTypes
extension HTTPField.Name {
/// The `Permissions-Policy` field name (not provided as a standard `HTTPField.Name`).
- static let permissionsPolicy = Self("Permissions-Policy")!
+ public static let permissionsPolicy = Self("Permissions-Policy")!
/// The `Referrer-Policy` field name (not provided as a standard `HTTPField.Name`).
- static let referrerPolicy = Self("Referrer-Policy")!
+ public static let referrerPolicy = Self("Referrer-Policy")!
/// The `X-Frame-Options` field name (not provided as a standard `HTTPField.Name`).
- static let frameOptions = Self("X-Frame-Options")!
+ public static let frameOptions = Self("X-Frame-Options")!
}
diff --git a/Services/Website/Sources/Library/Public/Middlewares/LocalizationMiddleware.swift b/Services/Website/Sources/Library/Public/Middlewares/LocalizationMiddleware.swift
index 99725f5..db18270 100644
--- a/Services/Website/Sources/Library/Public/Middlewares/LocalizationMiddleware.swift
+++ b/Services/Website/Sources/Library/Public/Middlewares/LocalizationMiddleware.swift
@@ -15,13 +15,13 @@ public struct LocalizationMiddleware {
// MARK: Properties
/// Negotiates the request's language from its `Accept-Language` header.
- private let negotiate: NegotiateLanguage
+ private let negotiate: Negotiate
// MARK: Initializers
- /// Creates a localization middleware.
+ /// Creates a localization middleware that negotiates against the module's String Catalog languages.
public init() {
- self.negotiate = .init()
+ self.negotiate = .init(bundle: .module)
}
}
@@ -47,7 +47,7 @@ extension LocalizationMiddleware: RouterMiddleware {
var context = context
context.language = negotiate(
- forAcceptLanguage: request.headers[.acceptLanguage]
+ acceptLanguage: request.headers[.acceptLanguage]
)
return try await next(request, context)