diff --git a/Packages/Localization/README.md b/Packages/Localization/README.md index 3f79dae..7be3a52 100644 --- a/Packages/Localization/README.md +++ b/Packages/Localization/README.md @@ -5,7 +5,7 @@ The server-side localization toolkit the platform's services build on: locale-ex | Role | Types | | --- | --- | | Lookup | `Localize`, a bundle-bound localizer that resolves a catalog key for an explicit locale | -| Negotiation | `Negotiate`, which picks the best supported language from an `Accept-Language` header per RFC 9110 | +| Negotiation | `Negotiate`, which picks the best supported language from an explicit request or an `Accept-Language` header per RFC 9110 | | Languages | `LanguageList`, the supported and default languages a bundle's String Catalog defines | | Diagnostics | `CatalogState`, the outcome of reading the catalog (`loaded`, `missing`, or `undecodable`) | diff --git a/Packages/Localization/Sources/Public/Methods/Negotiate.swift b/Packages/Localization/Sources/Public/Methods/Negotiate.swift index ee41bed..c18694e 100644 --- a/Packages/Localization/Sources/Public/Methods/Negotiate.swift +++ b/Packages/Localization/Sources/Public/Methods/Negotiate.swift @@ -24,18 +24,32 @@ public struct Negotiate: Sendable { // MARK: Methods - /// Picks the best supported language for the given `Accept-Language` header value. + /// Picks the best supported language for the given explicit request and `Accept-Language` header value. /// /// Invoked by calling the instance directly, for example `negotiate(acceptLanguage: header)`. + /// A `requested` language — a deliberate choice, such as a language switcher's query parameter — wins over the header when it matches a + /// supported language, on the same terms a header tag matches; an unsupported value is ignored, so it cannot select a language the catalog + /// does not serve. + /// /// The header is parsed into its language ranges, which are ordered by descending `q` weight as RFC 9110 prescribes: an entry without a weight /// counts as 1, entries weighted 0 are "not acceptable" and dropped, and equal weights keep the header order. Each tag is then matched against the /// supported languages in turn — first by an exact match, then by its primary language subtag, so `de-AT` resolves to a supported `de` — while the /// `*` wildcard accepts the default language. When the header is absent or matches nothing, the default language is returned. - /// - Parameter acceptLanguage: the raw `Accept-Language` header value, if any. + /// - Parameters: + /// - requested: an explicitly requested language identifier, if any; it overrides the header when supported. + /// - acceptLanguage: the raw `Accept-Language` header value, if any. /// - Returns: the identifier of the supported language to serve. public func callAsFunction( + requested: String? = nil, acceptLanguage language: String? ) -> String { + if + let requested, + let match = match(requested, in: list.all) + { + return match + } + guard let language else { return list.default } diff --git a/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift b/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift index f7cb0bc..6adc16c 100644 --- a/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift +++ b/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift @@ -127,4 +127,44 @@ struct NegotiateTests { #expect(language == "en") } + @Test + func `honours a supported requested language over the header`() { + let language = negotiate( + requested: "de", + acceptLanguage: "en" + ) + + #expect(language == "de") + } + + @Test + func `matches a regional requested language by its primary subtag`() { + let language = negotiate( + requested: "de-AT", + acceptLanguage: "en" + ) + + #expect(language == "de") + } + + @Test + func `ignores an unsupported requested language`() { + let language = negotiate( + requested: "fr", + acceptLanguage: "de" + ) + + #expect(language == "de") + } + + @Test + func `ignores an empty requested language`() { + let language = negotiate( + requested: "", + acceptLanguage: "de" + ) + + #expect(language == "de") + } + }