diff --git a/Sources/HTMLKit/Framework/Localization/InterpolationArgument.swift b/Sources/HTMLKit/Framework/Localization/InterpolationArgument.swift index 03102e9a..674a665d 100644 --- a/Sources/HTMLKit/Framework/Localization/InterpolationArgument.swift +++ b/Sources/HTMLKit/Framework/Localization/InterpolationArgument.swift @@ -5,7 +5,7 @@ import Foundation /// Each case corresponds to a specific data type and provides a placeholder /// that can be used for replacing values in the localized string. @_documentation(visibility: internal) -public enum InterpolationArgument { +public enum InterpolationArgument: Hashable { /// Holds an integer value case int(Int) diff --git a/Sources/HTMLKit/Framework/Localization/Localization.swift b/Sources/HTMLKit/Framework/Localization/Localization.swift index 49ca1ef3..ff3a2b5b 100644 --- a/Sources/HTMLKit/Framework/Localization/Localization.swift +++ b/Sources/HTMLKit/Framework/Localization/Localization.swift @@ -230,9 +230,7 @@ public struct Localization: Sendable { throw Error.missingKey(string.key.value, currentLocale.tag) } - if let interpolation = string.key.interpolation { - interpolate(arguments: interpolation, to: &translation, for: currentLocale) - } + interpolate(arguments: string.key.arguments, to: &translation, for: currentLocale) return translation @@ -242,9 +240,7 @@ public struct Localization: Sendable { if var translation = translationTable.retrieve(for: string.key.value) { - if let interpolation = string.key.interpolation { - interpolate(arguments: interpolation, to: &translation, for: currentLocale) - } + interpolate(arguments: string.key.arguments, to: &translation, for: currentLocale) return translation } @@ -276,7 +272,7 @@ public struct Localization: Sendable { return try recover(from: error, with: string) default: - return string.key.literal + return string.key.fallback } } } diff --git a/Sources/HTMLKit/Framework/Localization/LocalizedStringKey.swift b/Sources/HTMLKit/Framework/Localization/LocalizedStringKey.swift index 0e138f5e..14b49ded 100644 --- a/Sources/HTMLKit/Framework/Localization/LocalizedStringKey.swift +++ b/Sources/HTMLKit/Framework/Localization/LocalizedStringKey.swift @@ -5,16 +5,24 @@ import Foundation public struct LocalizedStringKey { /// The key value - internal let value: String + /// + /// ``` + /// Hello %@ + /// ``` + internal var value: String /// A fallback literal string + /// + /// ``` + /// Hello World + /// ``` /// /// > Note: This literal is not intended for lookup in the translation table. Instead, it serves as /// > a default value if localization is not set up or if the key is not found at all. - internal let literal: String + internal var fallback: String /// The arguments for the interpolation - internal var interpolation: [InterpolationArgument]? + internal var arguments: [InterpolationArgument] /// Initializes a string key for localization /// @@ -22,106 +30,119 @@ public struct LocalizedStringKey { /// - value: The key value /// - literal: The default value /// - interpolation: The arguments toreplace placeholders within the translation string - public init(value: String, literal: String, interpolation: [InterpolationArgument]? = nil) { + public init(value: String, fallback: String, arguments: [InterpolationArgument] = []) { self.value = value - self.literal = literal - self.interpolation = interpolation + self.fallback = fallback + self.arguments = arguments } } -extension LocalizedStringKey: ExpressibleByStringLiteral, ExpressibleByStringInterpolation { - +extension LocalizedStringKey: ExpressibleByStringLiteral { + public init(stringLiteral: String) { - self.init(value: stringLiteral, literal: stringLiteral) + + self.value = stringLiteral + self.fallback = stringLiteral + self.arguments = [] + } +} + +extension LocalizedStringKey: ExpressibleByStringInterpolation { + + public init(stringInterpolation: LocalizedStringKey) { + + self.value = stringInterpolation.value + self.fallback = stringInterpolation.fallback + self.arguments = stringInterpolation.arguments + } +} + +extension LocalizedStringKey: StringInterpolationProtocol { + + public init(literalCapacity: Int, interpolationCount: Int) { + + self.value = "" + self.fallback = "" + self.arguments = [] + } + + public mutating func appendLiteral(_ literal: String) { + + self.value += literal + + self.fallback += literal + } + + public mutating func appendInterpolation(_ value: String) { + + let argument = InterpolationArgument.string(value) + + self.value += argument.placeholder + + self.fallback += value + + self.arguments.append(argument) + } + + public mutating func appendInterpolation(_ value: Int) { + + let argument = InterpolationArgument.int(value) + + self.value += argument.placeholder + + self.fallback += String(value) + + self.arguments.append(argument) + } + + public mutating func appendInterpolation(_ value: Double) { + + let argument = InterpolationArgument.double(value) + + self.value += argument.placeholder + + self.fallback += String(value) + + self.arguments.append(argument) } - public init(stringInterpolation: StringInterpolation) { - self.init(value: stringInterpolation.key, - literal: stringInterpolation.literal, - interpolation: stringInterpolation.arguments) + public mutating func appendInterpolation(_ value: Float) { + + let argument = InterpolationArgument.float(value) + + self.value += argument.placeholder + + self.fallback += String(value) + + self.arguments.append(argument) } - public struct StringInterpolation: StringInterpolationProtocol { - - /// The key to be localized - var key = "" - - /// The arguments for the interpolation - var arguments: [InterpolationArgument] = [] - - /// The string literal - var literal = "" - - public init(literalCapacity: Int, interpolationCount: Int) { - - key.reserveCapacity(literalCapacity + interpolationCount * 2) - - arguments.reserveCapacity(interpolationCount) - } - - public mutating func appendLiteral(_ literal: String) { - - self.literal += literal - - key.append(literal) - } - - public mutating func appendInterpolation(_ value: String) { - - literal += value - - let argument = InterpolationArgument.string(value) - - key += argument.placeholder - - arguments.append(argument) - } - - public mutating func appendInterpolation(_ value: Int) { - - literal += String(value) - - let argument = InterpolationArgument.int(value) - - key += argument.placeholder - - arguments.append(argument) - } - - public mutating func appendInterpolation(_ value: Double) { - - literal += String(value) - - let argument = InterpolationArgument.double(value) - - key += argument.placeholder - - arguments.append(argument) - } - - public mutating func appendInterpolation(_ value: Float) { - - literal += String(value) - - let argument = InterpolationArgument.float(value) - - key += argument.placeholder - - arguments.append(.float(value)) - } - - public mutating func appendInterpolation(_ value: Date) { - - let formatter = DateFormatter() - - literal += formatter.string(from: value) - - let argument = InterpolationArgument.date(value) - - key += argument.placeholder - - arguments.append(argument) - } + public mutating func appendInterpolation(_ value: Date) { + + let argument = InterpolationArgument.date(value) + + self.value += argument.placeholder + + let formatter = DateFormatter() + + self.fallback += formatter.string(from: value) + + self.arguments.append(argument) + } +} + +extension LocalizedStringKey: Hashable { + + /// Compare two string keys. + public static func == (lhs: LocalizedStringKey, rhs: LocalizedStringKey) -> Bool { + return lhs.fallback == rhs.fallback + } +} + +extension LocalizedStringKey: CustomStringConvertible { + + public var description: String { + return self.value } } diff --git a/Sources/HTMLKit/Framework/Rendering/Renderer.swift b/Sources/HTMLKit/Framework/Rendering/Renderer.swift index e765f5dd..f6c882d1 100644 --- a/Sources/HTMLKit/Framework/Rendering/Renderer.swift +++ b/Sources/HTMLKit/Framework/Rendering/Renderer.swift @@ -309,12 +309,12 @@ public struct Renderer: Sendable { guard let localization = localization else { // Bail early with the fallback since the localization is not in use - return string.key.literal + return string.key.fallback } if !localization.isConfigured { // Bail early, since the localization is not properly configured - return string.key.literal + return string.key.fallback } do { @@ -348,7 +348,7 @@ public struct Renderer: Sendable { return try localization.recover(from: error, with: string) default: - return string.key.literal + return string.key.fallback } } } diff --git a/Sources/HTMLKitVapor/Extensions/Vapor+HTMLKit.swift b/Sources/HTMLKitVapor/Extensions/Vapor+HTMLKit.swift index edbcabc6..2e93ecec 100644 --- a/Sources/HTMLKitVapor/Extensions/Vapor+HTMLKit.swift +++ b/Sources/HTMLKitVapor/Extensions/Vapor+HTMLKit.swift @@ -82,23 +82,8 @@ extension Application { extension Request { - /// The accept language header of the request - private var acceptLanguage: String? { - - if let languageHeader = headers.first(name: .acceptLanguage) { - return languageHeader.components(separatedBy: ",").first - } - - return nil - } - /// Access to the view renderer - public var htmlkit: ViewRenderer { - - if let acceptLanguage = acceptLanguage { - application.htmlkit.environment.upsert(HTMLKit.Locale(tag: acceptLanguage), for: \HTMLKit.EnvironmentKeys.locale) - } - + public var htmlkit: ViewRenderer { return .init(eventLoop: eventLoop, configuration: application.htmlkit.configuration, logger: logger) } } diff --git a/Tests/HTMLKitTests/LocalizationTests.swift b/Tests/HTMLKitTests/LocalizationTests.swift index 4780ffcc..68d7add4 100644 --- a/Tests/HTMLKitTests/LocalizationTests.swift +++ b/Tests/HTMLKitTests/LocalizationTests.swift @@ -1,4 +1,4 @@ -import HTMLKit +@testable import HTMLKit import XCTest final class LocalizationTests: XCTestCase { @@ -100,6 +100,41 @@ final class LocalizationTests: XCTestCase { XCTAssertEqual(localizationError.description, "Unable to find translation table 'unknown.table' for the locale 'en-GB'.") } } + + /// Test the correct string interpolation of a localized string key + func testLocalizedStringKeyInterplation() throws { + + let string: LocalizedStringKey = "Hallo \("World")" + + XCTAssertEqual(string.value, "Hallo %@") + XCTAssertEqual(string.fallback, "Hallo World") + XCTAssertEqual(string.arguments.count, 1) + + let integer: LocalizedStringKey = "Hallo \(941)" + + XCTAssertEqual(integer.value, "Hallo %lld") + XCTAssertEqual(integer.fallback, "Hallo 941") + XCTAssertEqual(integer.arguments.count, 1) + + let float: LocalizedStringKey = "Hallo \(9.41)" + + XCTAssertEqual(float.value, "Hallo %f") + XCTAssertEqual(float.fallback, "Hallo 9.41") + XCTAssertEqual(float.arguments.count, 1) + } + + /// Test the correct camparsion of the localized string key + func testLocalizedStringKeyComparison() throws { + + let lhs: LocalizedStringKey = "Hallo \("Universe")" + let rhs: LocalizedStringKey = "Hallo \("World")" + + XCTAssertEqual(lhs.value, rhs.value) + XCTAssertNotEqual(lhs.fallback, rhs.fallback) + XCTAssertEqual(lhs.arguments.count, rhs.arguments.count) + + XCTAssertNotEqual(lhs, rhs) + } } extension LocalizationTests { diff --git a/Tests/HTMLKitVaporTests/Localization/de-DE/web.strings b/Tests/HTMLKitVaporTests/Localization/de-DE/web.strings new file mode 100644 index 00000000..6766e300 --- /dev/null +++ b/Tests/HTMLKitVaporTests/Localization/de-DE/web.strings @@ -0,0 +1,2 @@ +/* A string key with a namespace pattern */ +"hello.world" = "Hallo Welt"; diff --git a/Tests/HTMLKitVaporTests/ProviderTests.swift b/Tests/HTMLKitVaporTests/ProviderTests.swift index 804f397a..78844f03 100644 --- a/Tests/HTMLKitVaporTests/ProviderTests.swift +++ b/Tests/HTMLKitVaporTests/ProviderTests.swift @@ -220,11 +220,11 @@ final class ProviderTests: XCTestCase { try await app.asyncShutdown() } - /// Tests the localization behavior based on the accept language of the client + /// Tests the localization behavior based on the accept languages of the client. /// - /// The environment locale is expected to be changed according to the language given by the provider. - /// The renderer is expected to localize correctly the content based on the updated environment locale. - func testLocalizationByAcceptingHeaders() async throws { + /// The environment locale is expected to be changed according to the language. The renderer + /// is expected to localize correctly the view based on the updated environment locale. + func testHeaderBasedLocalization() async throws { guard let source = Bundle.module.url(forResource: "Localization", withExtension: nil) else { return @@ -236,10 +236,92 @@ final class ProviderTests: XCTestCase { app.htmlkit.localization.set(locale: "en-GB") app.get("test") { request async throws -> Vapor.View in + + + if let languages = request.headers.first(name: .acceptLanguage) { + + if let language = languages.components(separatedBy: ",").first { + app.htmlkit.environment.upsert(HTMLKit.Locale(tag: language), for: \EnvironmentKeys.locale) + } + } + return try await request.htmlkit.render(TestPage.ChildView()) } - try await app.test(.GET, "test", headers: ["accept-language": "fr"]) { response async in + let languages = ["fr": "Bonjour le monde", "en-GB": "Hello World", "de-DE": "Hallo Welt"] + + for language in languages { + + try await app.test(.GET, "test", headers: ["accept-language": language.key]) { response async in + XCTAssertEqual(response.status, .ok) + XCTAssertEqual(response.body.string, + """ + \ + \ + \ + TestPage\ + \ + \ +

\(language.value)

\ + \ + + """ + ) + } + } + + try await app.asyncShutdown() + } + + /// Tests the localization behavior based on the called route endpoint. + /// + /// The environment locale is expected to be changed according to the language. The renderer + /// is expected to localize correctly the view based on the updated environment locale. + func testRoutingBasedLocalization() async throws { + + guard let source = Bundle.module.url(forResource: "Localization", withExtension: nil) else { + return + } + + let app = try await Application.make(.testing) + + app.htmlkit.localization.set(source: source) + app.htmlkit.localization.set(locale: "en-GB") + + app.get("test", "de") { request async throws -> Vapor.View in + + request.application.htmlkit.environment.upsert(HTMLKit.Locale(tag: "de-DE"), for: \EnvironmentKeys.locale) + + return try await request.htmlkit.render(TestPage.ChildView()) + } + + app.get("test", "fr") { request async throws -> Vapor.View in + + request.application.htmlkit.environment.upsert(HTMLKit.Locale(tag: "fr"), for: \EnvironmentKeys.locale) + + return try await request.htmlkit.render(TestPage.ChildView()) + } + + try await app.test(.GET, "test/de", headers: ["accept-language": "en-GB"]) { response async in + + XCTAssertEqual(response.status, .ok) + XCTAssertEqual(response.body.string, + """ + \ + \ + \ + TestPage\ + \ + \ +

Hallo Welt

\ + \ + + """ + ) + } + + try await app.test(.GET, "test/fr", headers: ["accept-language": "en-GB"]) { response async in + XCTAssertEqual(response.status, .ok) XCTAssertEqual(response.body.string, """