From 8a2a9dd489f21cd31fe51f8a13aad115bfc44e87 Mon Sep 17 00:00:00 2001 From: Mattes Mohr Date: Thu, 20 Aug 2026 19:31:28 +0200 Subject: [PATCH 01/14] Stop infer the language preference --- .../HTMLKitVapor/Extensions/Vapor+HTMLKit.swift | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) 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) } } From afe5497e542cd24130d894c7ba7c16535dbd19cc Mon Sep 17 00:00:00 2001 From: Mattes Mohr Date: Thu, 20 Aug 2026 19:46:00 +0200 Subject: [PATCH 02/14] Test more localization scenarios --- .../Localization/de-DE/web.strings | 2 + Tests/HTMLKitVaporTests/ProviderTests.swift | 92 ++++++++++++++++++- 2 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 Tests/HTMLKitVaporTests/Localization/de-DE/web.strings 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, """ From fb5d326a97c4f30e9ba38f9714a8cd85982dbfef Mon Sep 17 00:00:00 2001 From: Mattes Mohr Date: Sat, 22 Aug 2026 16:14:40 +0200 Subject: [PATCH 03/14] Simplify the localizedstringkey --- .../Framework/Localization/Localization.swift | 10 +- .../Localization/LocalizedStringKey.swift | 194 +++++++++--------- .../Framework/Rendering/Renderer.swift | 6 +- Tests/HTMLKitTests/LocalizationTests.swift | 24 ++- 4 files changed, 129 insertions(+), 105 deletions(-) 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..ae0d6f9b 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,104 @@ 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 init(stringInterpolation: StringInterpolation) { - self.init(value: stringInterpolation.key, - literal: stringInterpolation.literal, - interpolation: stringInterpolation.arguments) + public mutating func appendInterpolation(_ value: Double) { + + let argument = InterpolationArgument.double(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: Float) { + + let argument = InterpolationArgument.float(value) + + self.value += argument.placeholder + + self.fallback += String(value) + + self.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) } } 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/Tests/HTMLKitTests/LocalizationTests.swift b/Tests/HTMLKitTests/LocalizationTests.swift index 4780ffcc..197b4ad6 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,28 @@ 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) + } } extension LocalizationTests { From fb0e0e029c05d0d9aee58125be9382112bd7f857 Mon Sep 17 00:00:00 2001 From: Mattes Mohr Date: Sat, 22 Aug 2026 16:21:31 +0200 Subject: [PATCH 04/14] Adopt common protocols for localizedstringkey --- .../Localization/InterpolationArgument.swift | 2 +- .../Localization/LocalizedStringKey.swift | 15 +++++++++++++++ Tests/HTMLKitTests/LocalizationTests.swift | 13 +++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) 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/LocalizedStringKey.swift b/Sources/HTMLKit/Framework/Localization/LocalizedStringKey.swift index ae0d6f9b..14b49ded 100644 --- a/Sources/HTMLKit/Framework/Localization/LocalizedStringKey.swift +++ b/Sources/HTMLKit/Framework/Localization/LocalizedStringKey.swift @@ -131,3 +131,18 @@ extension LocalizedStringKey: StringInterpolationProtocol { 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/Tests/HTMLKitTests/LocalizationTests.swift b/Tests/HTMLKitTests/LocalizationTests.swift index 197b4ad6..68d7add4 100644 --- a/Tests/HTMLKitTests/LocalizationTests.swift +++ b/Tests/HTMLKitTests/LocalizationTests.swift @@ -122,6 +122,19 @@ final class LocalizationTests: XCTestCase { 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 { From 3669a0beeb11c4c5e612fb37bd2779c10bc39484 Mon Sep 17 00:00:00 2001 From: Mattes Mohr Date: Tue, 25 Aug 2026 22:51:09 +0200 Subject: [PATCH 05/14] Test the locale --- .../Framework/Localization/Locale.swift | 79 +++++++++++++------ Tests/HTMLKitTests/LocalizationTests.swift | 36 +++++++++ 2 files changed, 90 insertions(+), 25 deletions(-) diff --git a/Sources/HTMLKit/Framework/Localization/Locale.swift b/Sources/HTMLKit/Framework/Localization/Locale.swift index 61adb4ba..73e9c91f 100644 --- a/Sources/HTMLKit/Framework/Localization/Locale.swift +++ b/Sources/HTMLKit/Framework/Localization/Locale.swift @@ -47,65 +47,58 @@ public struct Locale: Hashable, Sendable { case chinese = "zh" } - /// The language code of the language + /// The language code of the language. /// /// The language code represents the generic language. public var language: String? { return tag.components(separatedBy: "-").first } - /// The region code of the language + /// The region code of the language. /// /// The region code refers to the regional dialect of a language. public var region: String? { - - let components = tag.components(separatedBy: "-") - - if components.count > 1 { - return components.last - } - - return nil + return tag.components(separatedBy: "-").last } - /// The currency code of the language + /// The currency code of the language. public var currencyCode: String? { return currencyCodes[tag] } - /// The currency symbol of the language + /// The currency symbol of the language. public var currencySymbol: String? { return currencySymbols[tag] } - /// The decimal seperator of the language + /// The decimal seperator of the language. public var decimalSeparator: String? { return decimalSeparators[tag] } - /// The date format of the language + /// The date format of the language. public var dateFormat: String? { return dateFormats[tag] } - /// The time format of the language + /// The time format of the language. public var timeFormat: String? { return timeFormats[tag] } - /// The locale identifier - public let tag: String + /// The locale identifier. + internal let tag: String - /// Initializes a locale + /// Create a locale. /// - /// - Parameter tag: A locale tag e.g. en-US + /// - Parameter tag: A locale tag e.g. en-US. public init(tag: String) { self.tag = tag } - /// Initializes a locale with a predefined tag + /// Create a locale with a predefined tag. /// - /// - Parameter tag: A locale tag e.g. en-US + /// - Parameter tag: A locale tag e.g. en-US. public init(tag: Tag) { self.tag = tag.rawValue } @@ -256,8 +249,7 @@ extension Locale { } internal var dateFormats: [String: String] { - return [ - + return [ "ar-AE": "dd‏/MM‏/yyyy", "ar_QA": "dd‏/MM‏/yyyy", "be-BY": "dd.MM.yyyy", @@ -304,12 +296,49 @@ extension Locale { } internal var timeFormats: [String: String] { - return [ + return [ + "ar-AE": "h:mm:ss tt", + "ar_QA": "h:mm:ss tt", + "be-BY": "H:mm:ss", + "bg-BG": "H:mm:ss", + "ca-ES": "H:mm:ss", + "cs-CZ": "H:mm:ss", + "da-DK": "H.mm.ss", + "de-DE": "HH:mm:ss", + "el-GR": "H:mm:ss", "en-GB": "HH:mm:ss", "en-US": "h:mm:ss tt", - "de-DE": "HH:mm:ss", + "es-ES": "H:mm:ss", + "fi-FI": "H.mm.ss", "fr-FR": "HH:mm:ss", + "is-IS": "H:mm:ss", + "it-IT": "H:mm:ss", + "ja-JP": "H:mm:ss", + "he-IL": "H:mm:ss", + "hi-IN": "h:mm:ss a", + "hr-HR": "H:mm:ss", + "hu-HU": "H:mm:ss", + "ko-KP": "H:mm:ss", + "ko-KR": "a h:mm:ss", + "lt-LT": "HH:mm:ss", + "lv-LV": "HH:mm:ss", + "mk-MK": "H:mm:ss", + "nl-NL": "HH:mm:ss", + "nb-NO": "HH:mm:ss", + "pl-PL": "HH:mm:ss", + "pt-PT": "HH:mm:ss", + "ro-RO": "HH:mm:ss", "ru-RU": "H:mm:ss", + "sr_RS": "H:mm:ss", + "sk-SK": "H:mm:ss", + "sl-SI": "H:mm:ss", + "sq-AL": "HH:mm:ss", + "sv-SE": "HH:mm:ss", + "th-TH": "H:mm:ss", + "tr-TR": "HH:mm:ss", + "uk-UA": "HH:mm:ss", + "zh-CN": "ah:mm:ss", + "zh-HK": "ah:mm:ss", ] } } diff --git a/Tests/HTMLKitTests/LocalizationTests.swift b/Tests/HTMLKitTests/LocalizationTests.swift index 68d7add4..eedd5ad1 100644 --- a/Tests/HTMLKitTests/LocalizationTests.swift +++ b/Tests/HTMLKitTests/LocalizationTests.swift @@ -135,6 +135,42 @@ final class LocalizationTests: XCTestCase { XCTAssertNotEqual(lhs, rhs) } + + /// Test a locale of a language + func testLocale() throws { + + let formatter = DateFormatter() + + let british = Locale(tag: "en-GB") + + XCTAssertEqual(british.tag, "en-GB") + XCTAssertEqual(british.language, "en") + XCTAssertEqual(british.region, "GB") + XCTAssertEqual(british.currencyCode, "GBP") + XCTAssertEqual(british.currencySymbol, "£") + XCTAssertEqual(british.decimalSeparator, ".") + XCTAssertEqual(british.dateFormat, "dd/MM/yyyy") + XCTAssertEqual(british.timeFormat, "HH:mm:ss") + + formatter.dateFormat = "\(british.dateFormat!) \(british.timeFormat!)" + + XCTAssertEqual(formatter.string(from: Date(timeIntervalSince1970: 1)), "01/01/1970 01:00:01") + + let german = Locale(tag: "de-DE") + + XCTAssertEqual(german.tag, "de-DE") + XCTAssertEqual(german.language, "de") + XCTAssertEqual(german.region, "DE") + XCTAssertEqual(german.currencyCode, "EUR") + XCTAssertEqual(german.currencySymbol, "€") + XCTAssertEqual(german.decimalSeparator, ",") + XCTAssertEqual(german.dateFormat, "dd.MM.yyyy") + XCTAssertEqual(german.timeFormat, "HH:mm:ss") + + formatter.dateFormat = "\(german.dateFormat!) \(german.timeFormat!)" + + XCTAssertEqual(formatter.string(from: Date(timeIntervalSince1970: 1)), "01.01.1970 01:00:01") + } } extension LocalizationTests { From 6ee16c6723e4cd3652061625d5901eeb77bc0405 Mon Sep 17 00:00:00 2001 From: Mattes Mohr Date: Tue, 25 Aug 2026 22:55:24 +0200 Subject: [PATCH 06/14] Adopt common protocols for locale --- .../HTMLKit/Framework/Localization/Locale.swift | 16 +++++++++++++++- Tests/HTMLKitTests/LocalizationTests.swift | 5 +++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/Sources/HTMLKit/Framework/Localization/Locale.swift b/Sources/HTMLKit/Framework/Localization/Locale.swift index 73e9c91f..65cb3181 100644 --- a/Sources/HTMLKit/Framework/Localization/Locale.swift +++ b/Sources/HTMLKit/Framework/Localization/Locale.swift @@ -2,7 +2,7 @@ /// /// A locale holds information about language, region and cultural preferences. @_documentation(visibility: internal) -public struct Locale: Hashable, Sendable { +public struct Locale: Sendable { /// An enumeration of potential language tags. public enum Tag: String, Sendable { @@ -342,3 +342,17 @@ extension Locale { ] } } + +extension Locale: Hashable { + + public static func == (lhs: Locale, rhs: Locale) -> Bool { + return lhs.tag == rhs.tag + } +} + +extension Locale: CustomStringConvertible { + + public var description: String { + return tag + } +} diff --git a/Tests/HTMLKitTests/LocalizationTests.swift b/Tests/HTMLKitTests/LocalizationTests.swift index eedd5ad1..9c05f3a8 100644 --- a/Tests/HTMLKitTests/LocalizationTests.swift +++ b/Tests/HTMLKitTests/LocalizationTests.swift @@ -171,6 +171,11 @@ final class LocalizationTests: XCTestCase { XCTAssertEqual(formatter.string(from: Date(timeIntervalSince1970: 1)), "01.01.1970 01:00:01") } + + /// Test the correct comparison of two locales + func testLocaleComparsion() throws { + XCTAssertNotEqual(Locale(tag: .english), Locale(tag: .german)) + } } extension LocalizationTests { From c29ff2a6c49dddab1f28806366a8a1bc81362de2 Mon Sep 17 00:00:00 2001 From: Mattes Mohr Date: Fri, 28 Aug 2026 10:39:51 +0200 Subject: [PATCH 07/14] Fix locale regression --- Sources/HTMLKit/Framework/Localization/Locale.swift | 9 ++++++++- Tests/HTMLKitTests/LocalizationTests.swift | 11 +++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/Sources/HTMLKit/Framework/Localization/Locale.swift b/Sources/HTMLKit/Framework/Localization/Locale.swift index 65cb3181..257182b3 100644 --- a/Sources/HTMLKit/Framework/Localization/Locale.swift +++ b/Sources/HTMLKit/Framework/Localization/Locale.swift @@ -58,7 +58,14 @@ public struct Locale: Sendable { /// /// The region code refers to the regional dialect of a language. public var region: String? { - return tag.components(separatedBy: "-").last + + let components = tag.components(separatedBy: "-") + + if components.count > 1 { + return components.last + } + + return nil } /// The currency code of the language. diff --git a/Tests/HTMLKitTests/LocalizationTests.swift b/Tests/HTMLKitTests/LocalizationTests.swift index 9c05f3a8..ed5bf86d 100644 --- a/Tests/HTMLKitTests/LocalizationTests.swift +++ b/Tests/HTMLKitTests/LocalizationTests.swift @@ -141,6 +141,17 @@ final class LocalizationTests: XCTestCase { let formatter = DateFormatter() + let english = Locale(tag: "en") + + XCTAssertEqual(english.tag, "en") + XCTAssertEqual(english.language, "en") + XCTAssertEqual(english.region, nil) + XCTAssertEqual(english.currencyCode, nil) + XCTAssertEqual(english.currencySymbol, nil) + XCTAssertEqual(english.decimalSeparator, nil) + XCTAssertEqual(english.dateFormat, nil) + XCTAssertEqual(english.timeFormat, nil) + let british = Locale(tag: "en-GB") XCTAssertEqual(british.tag, "en-GB") From 66dc1740fd5c72049dc0d041740fd203ec1abf65 Mon Sep 17 00:00:00 2001 From: Mattes Mohr Date: Fri, 28 Aug 2026 12:31:55 +0200 Subject: [PATCH 08/14] Refactor the localization --- .../Framework/Localization/Localization.swift | 88 +++++++++---------- .../Framework/Rendering/Renderer.swift | 4 +- Tests/HTMLKitTests/LocalizationTests.swift | 24 ++--- 3 files changed, 58 insertions(+), 58 deletions(-) diff --git a/Sources/HTMLKit/Framework/Localization/Localization.swift b/Sources/HTMLKit/Framework/Localization/Localization.swift index ff3a2b5b..3129d321 100644 --- a/Sources/HTMLKit/Framework/Localization/Localization.swift +++ b/Sources/HTMLKit/Framework/Localization/Localization.swift @@ -7,26 +7,26 @@ public struct Localization: Sendable { /// An enumeration of errors regarding the localization rendering. public enum Error: Swift.Error, Equatable { - /// Indicates a missing key + /// Indicates a missing key. /// /// A key is considered as missing if it cannot be found in the translation table. case missingKey(String, String) - /// Indicates a missing table + /// Indicates a missing table. /// - /// A table is considered as missing if there is no translation table for the given locale. - case missingTable(String) + /// A table is considered as missing if it cannot be found in the language catalog. + case missingTable(String, String) - /// Indicates missing tables - case missingTables - - /// Indicates a unknown table + /// Indicates a missing catalog. /// - /// A table is considered as unknown if it cannot be found by the given table name. - case unknownTable(String, String) + /// A catalog is considered as missing if it cannot be found in the localization folder. + case missingCatalog(String) + + /// Indicates missing language catalogs. + case missingCatalogs /// Indicates there is no fallback configuration set up. - case noFallback + case missingFallback /// Indicates a loading failure case loadingDataFailed @@ -38,17 +38,17 @@ public struct Localization: Sendable { case .missingKey(let key, let tag): return "Unable to find translation key '\(key)' for the locale '\(tag)'." - case .missingTable(let tag): - return "Unable to find a translation table for the locale '\(tag)'." + case .missingTable(let table, let tag): + return "Unable to find translation table '\(table)' for the locale '\(tag)'." - case .missingTables: - return "Unable to find any translation tables." + case .missingCatalog(let tag): + return "Unable to find a language catalog for the locale '\(tag)'." - case .unknownTable(let table, let tag): - return "Unable to find translation table '\(table)' for the locale '\(tag)'." + case .missingCatalogs: + return "Unable to find any language catalog." - case .noFallback: - return "The fallback needs to be set up first." + case .missingFallback: + return "The fallback locale is not set up." case .loadingDataFailed: return "Unable to load data." @@ -59,7 +59,7 @@ public struct Localization: Sendable { /// Indicates whether the localization is properly configured internal var isConfigured: Bool { - if self.tables != nil && self.locale != nil { + if self.catalogs != nil && self.locale != nil { return true } @@ -67,7 +67,7 @@ public struct Localization: Sendable { } /// The translations tables - internal var tables: [Locale: [TranslationTable]]? + internal var catalogs: [Locale: [TranslationTable]]? /// The default locale /// @@ -82,7 +82,7 @@ public struct Localization: Sendable { /// /// - Parameter source: The directory where the translations should be loaded from. public mutating func set(source: URL) { - self.tables = load(source: source) + self.catalogs = load(source: source) } /// Sets the default locale @@ -100,7 +100,7 @@ public struct Localization: Sendable { public init(source: URL, locale: Locale) { self.locale = locale - self.tables = load(source: source) + self.catalogs = load(source: source) } /// Loads the translation tables from a given directory @@ -110,7 +110,7 @@ public struct Localization: Sendable { /// - Returns: The translation tables mapped to their locale private func load(source: URL) -> [Locale: [TranslationTable]] { - var localizationTables = [Locale: [TranslationTable]]() + var catalogs = [Locale: [TranslationTable]]() if let enumerator = FileManager.default.enumerator(at: source, includingPropertiesForKeys: nil) { @@ -125,26 +125,26 @@ public struct Localization: Sendable { let locale = Locale(tag: path.deletingPathExtension().deletingLastPathComponent().lastPathComponent) - if var translationTables = localizationTables[locale] { + if var tables = catalogs[locale] { if let data = try? Foundation.Data(contentsOf: path) { if let translations = try? PropertyListSerialization.propertyList(from: data, options: .mutableContainers, format: nil) as? [String: String] { - translationTables.append(TranslationTable(name: path.deletingPathExtension().lastPathComponent, translations: translations)) + tables.append(TranslationTable(name: path.deletingPathExtension().lastPathComponent, translations: translations)) } - localizationTables[locale] = translationTables + catalogs[locale] = tables } } } } else { - localizationTables[Locale(tag: path.lastPathComponent)] = [TranslationTable]() + catalogs[Locale(tag: path.lastPathComponent)] = [TranslationTable]() } } } - return localizationTables + return catalogs } /// Replace the value with the placeholder @@ -207,46 +207,46 @@ public struct Localization: Sendable { public func localize(string: LocalizedString, for locale: Locale? = nil) throws -> String { guard let fallback = self.locale else { - throw Error.noFallback + throw Error.missingFallback } - guard let localizationTables = self.tables else { - throw Error.missingTables + guard let catalogs = self.catalogs else { + throw Error.missingCatalogs } - let currentLocale = locale ?? fallback + let candidate = locale ?? fallback - guard let translationTables = localizationTables[currentLocale] else { - throw Error.missingTable(currentLocale.tag) + guard let tables = catalogs[candidate] else { + throw Error.missingCatalog(candidate.tag) } if let table = string.table { - guard let translationTable = translationTables.first(where: { $0.name == table }) else { - throw Error.unknownTable(table, currentLocale.tag) + guard let match = tables.first(where: { $0.name == table }) else { + throw Error.missingTable(table, candidate.tag) } - guard var translation = translationTable.retrieve(for: string.key.value) else { - throw Error.missingKey(string.key.value, currentLocale.tag) + guard var translation = match.retrieve(for: string.key.value) else { + throw Error.missingKey(string.key.value, candidate.tag) } - interpolate(arguments: string.key.arguments, to: &translation, for: currentLocale) + interpolate(arguments: string.key.arguments, to: &translation, for: candidate) return translation } - for translationTable in translationTables { + for table in tables { - if var translation = translationTable.retrieve(for: string.key.value) { + if var translation = table.retrieve(for: string.key.value) { - interpolate(arguments: string.key.arguments, to: &translation, for: currentLocale) + interpolate(arguments: string.key.arguments, to: &translation, for: candidate) return translation } } - throw Error.missingKey(string.key.value, currentLocale.tag) + throw Error.missingKey(string.key.value, candidate.tag) } /// Recovers from an error. diff --git a/Sources/HTMLKit/Framework/Rendering/Renderer.swift b/Sources/HTMLKit/Framework/Rendering/Renderer.swift index f6c882d1..0354e2de 100644 --- a/Sources/HTMLKit/Framework/Rendering/Renderer.swift +++ b/Sources/HTMLKit/Framework/Rendering/Renderer.swift @@ -337,9 +337,9 @@ public struct Renderer: Sendable { fallthrough - case .missingTable: + case .missingCatalog: - logger.debug("Trying to recover from missing table") + logger.debug("Trying to recover from missing catalog") // Clear the locale on the environment, since it cannot be used for the remainder of the rendering, // otherwise it will throw an error each time diff --git a/Tests/HTMLKitTests/LocalizationTests.swift b/Tests/HTMLKitTests/LocalizationTests.swift index ed5bf86d..daee1529 100644 --- a/Tests/HTMLKitTests/LocalizationTests.swift +++ b/Tests/HTMLKitTests/LocalizationTests.swift @@ -67,37 +67,37 @@ final class LocalizationTests: XCTestCase { } } - /// Tests the behavior when a translation table is missing. + /// Tests the behavior when a translation table is unknown. /// - /// A table is considered as missing if there is no translation table for the given locale. In this case, + /// A table is considered as unknown if it cannot be found by the given table name. In this case, /// the localization is expected to throw an error. - func testMissingTable() throws { + func testUnknownTable() throws { - XCTAssertThrowsError(try localization!.localize(string: .init(key: "hello.world"), for: .init(tag: "unknown.tag"))) { error in + XCTAssertThrowsError(try localization!.localize(string: .init(key: "hello.world", table: "unknown.table"))) { error in guard let localizationError = error as? Localization.Error else { return XCTFail("Unexpected error type: \(error)") } - XCTAssertEqual(localizationError, .missingTable("unknown.tag")) - XCTAssertEqual(localizationError.description, "Unable to find a translation table for the locale 'unknown.tag'.") + XCTAssertEqual(localizationError, .missingTable("unknown.table", "en-GB")) + XCTAssertEqual(localizationError.description, "Unable to find translation table 'unknown.table' for the locale 'en-GB'.") } } - /// Tests the behavior when a translation table is unknown. + /// Tests the behavior when a translation table is missing. /// - /// A table is considered as unknown if it cannot be found by the given table name. In this case, + /// A table is considered as missing if there is no translation table for the given locale. In this case, /// the localization is expected to throw an error. - func testUnknownTable() throws { + func testMissingCatalog() throws { - XCTAssertThrowsError(try localization!.localize(string: .init(key: "hello.world", table: "unknown.table"))) { error in + XCTAssertThrowsError(try localization!.localize(string: .init(key: "hello.world"), for: .init(tag: "unknown.tag"))) { error in guard let localizationError = error as? Localization.Error else { return XCTFail("Unexpected error type: \(error)") } - XCTAssertEqual(localizationError, .unknownTable("unknown.table", "en-GB")) - XCTAssertEqual(localizationError.description, "Unable to find translation table 'unknown.table' for the locale 'en-GB'.") + XCTAssertEqual(localizationError, .missingCatalog("unknown.tag")) + XCTAssertEqual(localizationError.description, "Unable to find a language catalog for the locale 'unknown.tag'.") } } From c19db23e30b4f193bf10292b737c2282722c971c Mon Sep 17 00:00:00 2001 From: Mattes Mohr Date: Fri, 28 Aug 2026 12:44:33 +0200 Subject: [PATCH 09/14] Support locale chaining --- .../Framework/Localization/Localization.swift | 36 ++++++++++++++- .../HTMLKitTests/Localization/en/web.strings | 2 + Tests/HTMLKitTests/LocalizationTests.swift | 45 +++++++++++++++++-- .../Localization/en/web.strings | 2 + Tests/HTMLKitVaporTests/ProviderTests.swift | 43 ++++++++++++++++++ 5 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 Tests/HTMLKitTests/Localization/en/web.strings create mode 100644 Tests/HTMLKitVaporTests/Localization/en/web.strings diff --git a/Sources/HTMLKit/Framework/Localization/Localization.swift b/Sources/HTMLKit/Framework/Localization/Localization.swift index 3129d321..0549d247 100644 --- a/Sources/HTMLKit/Framework/Localization/Localization.swift +++ b/Sources/HTMLKit/Framework/Localization/Localization.swift @@ -56,6 +56,16 @@ public struct Localization: Sendable { } } + /// The available languages. + internal var availableLanguages: [Locale] { + + guard let catalogs = self.catalogs else { + return [] + } + + return catalogs.map(\.key) + } + /// Indicates whether the localization is properly configured internal var isConfigured: Bool { @@ -214,7 +224,7 @@ public struct Localization: Sendable { throw Error.missingCatalogs } - let candidate = locale ?? fallback + let candidate = getPossibleLanguage(locale, fallback) guard let tables = catalogs[candidate] else { throw Error.missingCatalog(candidate.tag) @@ -276,4 +286,28 @@ public struct Localization: Sendable { } } } + + /// Returns the possible language. + /// + /// - Parameter current: The current language. + /// + /// - Returns: The possible language. + internal func getPossibleLanguage(_ current: Locale?, _ other: Locale) -> Locale { + + guard let current = current else { + return other + } + + if self.availableLanguages.contains(current) { + return current + } + + let next = Locale(tag: current.language!) + + if self.availableLanguages.contains(next) { + return next + } + + return other + } } diff --git a/Tests/HTMLKitTests/Localization/en/web.strings b/Tests/HTMLKitTests/Localization/en/web.strings new file mode 100644 index 00000000..9a421319 --- /dev/null +++ b/Tests/HTMLKitTests/Localization/en/web.strings @@ -0,0 +1,2 @@ +/* A string key with a namespace pattern */ +"hello.english" = "Hello English"; diff --git a/Tests/HTMLKitTests/LocalizationTests.swift b/Tests/HTMLKitTests/LocalizationTests.swift index daee1529..4eb4dbb5 100644 --- a/Tests/HTMLKitTests/LocalizationTests.swift +++ b/Tests/HTMLKitTests/LocalizationTests.swift @@ -90,14 +90,16 @@ final class LocalizationTests: XCTestCase { /// the localization is expected to throw an error. func testMissingCatalog() throws { - XCTAssertThrowsError(try localization!.localize(string: .init(key: "hello.world"), for: .init(tag: "unknown.tag"))) { error in + localization!.set(locale: "tlh-AA") + + XCTAssertThrowsError(try localization!.localize(string: .init(key: "hello.world"))) { error in guard let localizationError = error as? Localization.Error else { return XCTFail("Unexpected error type: \(error)") } - XCTAssertEqual(localizationError, .missingCatalog("unknown.tag")) - XCTAssertEqual(localizationError.description, "Unable to find a language catalog for the locale 'unknown.tag'.") + XCTAssertEqual(localizationError, .missingCatalog("tlh-AA")) + XCTAssertEqual(localizationError.description, "Unable to find a language catalog for the locale 'tlh-AA'.") } } @@ -187,6 +189,43 @@ final class LocalizationTests: XCTestCase { func testLocaleComparsion() throws { XCTAssertNotEqual(Locale(tag: .english), Locale(tag: .german)) } + + /// Test the correct available languages + func testAvailableLanguage() throws { + + XCTAssertEqual(localization!.availableLanguages.count, 3) + XCTAssertEqual(localization!.availableLanguages.contains(Locale(tag: "en")), true) + XCTAssertEqual(localization!.availableLanguages.contains(Locale(tag: "en-GB")), true) + XCTAssertEqual(localization!.availableLanguages.contains(Locale(tag: "fr")), true) + } + + /// Tests the correct locale chain + func testLocaleChain() throws { + + let american = Locale(tag: "en-US") + + let missingRegion = localization!.getPossibleLanguage(american, localization!.locale!) + + XCTAssertEqual(missingRegion.tag, "en") + XCTAssertEqual(missingRegion.language, "en") + XCTAssertEqual(missingRegion.region, nil) + + let french = Locale(tag: "fr") + + let existingLanguage = localization!.getPossibleLanguage(french, localization!.locale!) + + XCTAssertEqual(existingLanguage.tag, "fr") + XCTAssertEqual(existingLanguage.language, "fr") + XCTAssertEqual(existingLanguage.region, nil) + + let german = Locale(tag: "de-DE") + + let missingLanguage = localization!.getPossibleLanguage(german, localization!.locale!) + + XCTAssertEqual(missingLanguage.tag, "en-GB") + XCTAssertEqual(missingLanguage.language, "en") + XCTAssertEqual(missingLanguage.region, "GB") + } } extension LocalizationTests { diff --git a/Tests/HTMLKitVaporTests/Localization/en/web.strings b/Tests/HTMLKitVaporTests/Localization/en/web.strings new file mode 100644 index 00000000..9a421319 --- /dev/null +++ b/Tests/HTMLKitVaporTests/Localization/en/web.strings @@ -0,0 +1,2 @@ +/* A string key with a namespace pattern */ +"hello.english" = "Hello English"; diff --git a/Tests/HTMLKitVaporTests/ProviderTests.swift b/Tests/HTMLKitVaporTests/ProviderTests.swift index 78844f03..fb5f55a3 100644 --- a/Tests/HTMLKitVaporTests/ProviderTests.swift +++ b/Tests/HTMLKitVaporTests/ProviderTests.swift @@ -220,6 +220,49 @@ final class ProviderTests: XCTestCase { try await app.asyncShutdown() } + func testLocaleChaining() 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") { 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": "en-US"]) { response async in + XCTAssertEqual(response.status, .ok) + XCTAssertEqual(response.body.string, + """ + \ + \ + \ + TestPage\ + \ + \ +

Hello Moin

\ + \ + + """ + ) + } + + try await app.asyncShutdown() + } + /// Tests the localization behavior based on the accept languages of the client. /// /// The environment locale is expected to be changed according to the language. The renderer From f1246984546880c85f0c7d1bf2b8c3a0f78f4489 Mon Sep 17 00:00:00 2001 From: Mattes Mohr Date: Wed, 2 Sep 2026 21:51:31 +0200 Subject: [PATCH 10/14] Fix failing tests --- .../en-GB/{web.strings => desktop.strings} | 2 +- .../Localization/en-GB/mobile.strings | 2 +- Tests/HTMLKitTests/Localization/en/web.strings | 2 +- Tests/HTMLKitTests/LocalizationTests.swift | 6 +++--- Tests/HTMLKitTests/RenderingTests.swift | 18 +++++++++--------- .../en-GB/{web.strings => desktop.strings} | 2 +- .../Localization/en-GB/mobile.strings | 2 +- .../Localization/en/web.strings | 2 +- Tests/HTMLKitVaporTests/ProviderTests.swift | 9 ++++++--- 9 files changed, 24 insertions(+), 21 deletions(-) rename Tests/HTMLKitTests/Localization/en-GB/{web.strings => desktop.strings} (95%) rename Tests/HTMLKitVaporTests/Localization/en-GB/{web.strings => desktop.strings} (59%) diff --git a/Tests/HTMLKitTests/Localization/en-GB/web.strings b/Tests/HTMLKitTests/Localization/en-GB/desktop.strings similarity index 95% rename from Tests/HTMLKitTests/Localization/en-GB/web.strings rename to Tests/HTMLKitTests/Localization/en-GB/desktop.strings index a32cbf1d..19855c5d 100644 --- a/Tests/HTMLKitTests/Localization/en-GB/web.strings +++ b/Tests/HTMLKitTests/Localization/en-GB/desktop.strings @@ -1,5 +1,5 @@ /* String key with namespace pattern */ -"hello.world" = "Hello World"; +"hello.world" = "Hiya World"; /* String key with namespace pattern and string interpolation */ "cheers.person %@" = "Cheers %@"; diff --git a/Tests/HTMLKitTests/Localization/en-GB/mobile.strings b/Tests/HTMLKitTests/Localization/en-GB/mobile.strings index 2e66f69f..ae199068 100644 --- a/Tests/HTMLKitTests/Localization/en-GB/mobile.strings +++ b/Tests/HTMLKitTests/Localization/en-GB/mobile.strings @@ -1,2 +1,2 @@ /* A string key with a namespace pattern */ -"hello.world" = "Hello World"; +"hello" = "Hiya"; diff --git a/Tests/HTMLKitTests/Localization/en/web.strings b/Tests/HTMLKitTests/Localization/en/web.strings index 9a421319..2e66f69f 100644 --- a/Tests/HTMLKitTests/Localization/en/web.strings +++ b/Tests/HTMLKitTests/Localization/en/web.strings @@ -1,2 +1,2 @@ /* A string key with a namespace pattern */ -"hello.english" = "Hello English"; +"hello.world" = "Hello World"; diff --git a/Tests/HTMLKitTests/LocalizationTests.swift b/Tests/HTMLKitTests/LocalizationTests.swift index 4eb4dbb5..3d8538d7 100644 --- a/Tests/HTMLKitTests/LocalizationTests.swift +++ b/Tests/HTMLKitTests/LocalizationTests.swift @@ -16,7 +16,7 @@ final class LocalizationTests: XCTestCase { /// The test expects the key to exist in the default translation table and to be rendered correctly. func testLocalization() throws { - XCTAssertEqual(try localization!.localize(string: .init(key: "hello.world")), "Hello World") + XCTAssertEqual(try localization!.localize(string: .init(key: "hello.world")), "Hiya World") } /// Tests the localization of a translation key in a specified translation table @@ -24,7 +24,7 @@ final class LocalizationTests: XCTestCase { /// The test expects the key to exist in the specified translation table and to be rendered accurately. func testLocalizationWithTable() throws { - XCTAssertEqual(try localization!.localize(string: .init(key: "hello.world", table: "web")), "Hello World") + XCTAssertEqual(try localization!.localize(string: .init(key: "hello", table: "mobile")), "Hiya") } /// Tests the localization of string interpolation @@ -71,7 +71,7 @@ final class LocalizationTests: XCTestCase { /// /// A table is considered as unknown if it cannot be found by the given table name. In this case, /// the localization is expected to throw an error. - func testUnknownTable() throws { + func testMissingTable() throws { XCTAssertThrowsError(try localization!.localize(string: .init(key: "hello.world", table: "unknown.table"))) { error in diff --git a/Tests/HTMLKitTests/RenderingTests.swift b/Tests/HTMLKitTests/RenderingTests.swift index 4140536c..7cdb356b 100644 --- a/Tests/HTMLKitTests/RenderingTests.swift +++ b/Tests/HTMLKitTests/RenderingTests.swift @@ -488,12 +488,12 @@ final class RenderingTests: XCTestCase { XCTAssertEqual(try renderer!.render(view: MainView()), """ -

Hello World

+

Hiya World

""" ) } - /// Tests the localization of a attribute + /// Tests the localization of a attribute. /// /// The test expects the key to exist in the default translation table and to be rendered correctly. func testLocalizationAttribute() throws { @@ -506,15 +506,15 @@ final class RenderingTests: XCTestCase { Input() .placeholder("hello.world", tableName: nil) .alternate(LocalizedStringKey("hello.world")) - .value(LocalizedStringKey("hello.world"), tableName: "web") - .title("hello.world", tableName: "mobile") + .value(LocalizedStringKey("hello.world"), tableName: "desktop") + .title("hello", tableName: "mobile") Meta() .content("hello.world") Input() .placeholder(verbatim: "hello.world") .alternate(verbatim: "hello.world") .value(verbatim: placeholder) - .title(verbatim: "hello.world") + .title(verbatim: "hello") TextArea {} .placeholder(placeholder) } @@ -522,9 +522,9 @@ final class RenderingTests: XCTestCase { XCTAssertEqual(try renderer!.render(view: TestView()), """ - \ - \ - \ + \ + \ + \ """ ) @@ -630,7 +630,7 @@ final class RenderingTests: XCTestCase { XCTAssertEqual(try renderer!.render(view: TestView()), """
\ -

Hello World

\ +

Hiya World

\
""" ) diff --git a/Tests/HTMLKitVaporTests/Localization/en-GB/web.strings b/Tests/HTMLKitVaporTests/Localization/en-GB/desktop.strings similarity index 59% rename from Tests/HTMLKitVaporTests/Localization/en-GB/web.strings rename to Tests/HTMLKitVaporTests/Localization/en-GB/desktop.strings index 4bed9159..93dee739 100644 --- a/Tests/HTMLKitVaporTests/Localization/en-GB/web.strings +++ b/Tests/HTMLKitVaporTests/Localization/en-GB/desktop.strings @@ -1,3 +1,3 @@ /* A string key with a namespace pattern */ -"hello.world" = "Hello World"; +"hello.world" = "Hiya World"; diff --git a/Tests/HTMLKitVaporTests/Localization/en-GB/mobile.strings b/Tests/HTMLKitVaporTests/Localization/en-GB/mobile.strings index 2e66f69f..ae199068 100644 --- a/Tests/HTMLKitVaporTests/Localization/en-GB/mobile.strings +++ b/Tests/HTMLKitVaporTests/Localization/en-GB/mobile.strings @@ -1,2 +1,2 @@ /* A string key with a namespace pattern */ -"hello.world" = "Hello World"; +"hello" = "Hiya"; diff --git a/Tests/HTMLKitVaporTests/Localization/en/web.strings b/Tests/HTMLKitVaporTests/Localization/en/web.strings index 9a421319..2e66f69f 100644 --- a/Tests/HTMLKitVaporTests/Localization/en/web.strings +++ b/Tests/HTMLKitVaporTests/Localization/en/web.strings @@ -1,2 +1,2 @@ /* A string key with a namespace pattern */ -"hello.english" = "Hello English"; +"hello.world" = "Hello World"; diff --git a/Tests/HTMLKitVaporTests/ProviderTests.swift b/Tests/HTMLKitVaporTests/ProviderTests.swift index fb5f55a3..5452d4b1 100644 --- a/Tests/HTMLKitVaporTests/ProviderTests.swift +++ b/Tests/HTMLKitVaporTests/ProviderTests.swift @@ -220,6 +220,9 @@ final class ProviderTests: XCTestCase { try await app.asyncShutdown() } + /// Tests the locale chaining. + /// + /// Before the fallback on the default locale, the localization should look up first whether the base language for the locale exists. func testLocaleChaining() async throws { guard let source = Bundle.module.url(forResource: "Localization", withExtension: nil) else { @@ -253,7 +256,7 @@ final class ProviderTests: XCTestCase { TestPage\ \ \ -

Hello Moin

\ +

Hello World

\ \ """ @@ -291,7 +294,7 @@ final class ProviderTests: XCTestCase { return try await request.htmlkit.render(TestPage.ChildView()) } - let languages = ["fr": "Bonjour le monde", "en-GB": "Hello World", "de-DE": "Hallo Welt"] + let languages = ["fr": "Bonjour le monde", "en-GB": "Hiya World", "de-DE": "Hallo Welt"] for language in languages { @@ -413,7 +416,7 @@ final class ProviderTests: XCTestCase { TestPage\ \ \ -

Hello World

\ +

Hiya World

\ \ """ From de96d3699d9d65dd883d771ec21015cb72304b98 Mon Sep 17 00:00:00 2001 From: Mattes Mohr Date: Wed, 2 Sep 2026 22:37:08 +0200 Subject: [PATCH 11/14] Support string catalogs --- .../Framework/Localization/Localization.swift | 65 ++++++++++++++++--- .../Localization/StringCatalog.swift | 31 +++++++++ .../Localization/TranslationTable.swift | 15 ++++- .../Localization/Localizable.xcstrings | 23 +++++++ Tests/HTMLKitTests/LocalizationTests.swift | 3 +- 5 files changed, 123 insertions(+), 14 deletions(-) create mode 100644 Sources/HTMLKit/Framework/Localization/StringCatalog.swift create mode 100644 Tests/HTMLKitTests/Localization/Localizable.xcstrings diff --git a/Sources/HTMLKit/Framework/Localization/Localization.swift b/Sources/HTMLKit/Framework/Localization/Localization.swift index 0549d247..0fab7355 100644 --- a/Sources/HTMLKit/Framework/Localization/Localization.swift +++ b/Sources/HTMLKit/Framework/Localization/Localization.swift @@ -133,23 +133,68 @@ public struct Localization: Sendable { } else { - let locale = Locale(tag: path.deletingPathExtension().deletingLastPathComponent().lastPathComponent) - - if var tables = catalogs[locale] { + if path.pathExtension == "strings" { - if let data = try? Foundation.Data(contentsOf: path) { + let locale = Locale(tag: path.deletingPathExtension().deletingLastPathComponent().lastPathComponent) + + if var tables = catalogs[locale] { + + if let data = try? Foundation.Data(contentsOf: path) { + + if let translations = try? PropertyListSerialization.propertyList(from: data, options: .mutableContainers, format: nil) as? [String: String] { + tables.append(TranslationTable(name: path.deletingPathExtension().lastPathComponent, translations: translations)) + } + + catalogs[locale] = tables + } + + } else { - if let translations = try? PropertyListSerialization.propertyList(from: data, options: .mutableContainers, format: nil) as? [String: String] { - tables.append(TranslationTable(name: path.deletingPathExtension().lastPathComponent, translations: translations)) + if let data = try? Foundation.Data(contentsOf: path) { + + if let translations = try? PropertyListSerialization.propertyList(from: data, options: .mutableContainers, format: nil) as? [String: String] { + catalogs[locale] = [TranslationTable(name: path.deletingPathExtension().lastPathComponent, translations: translations)] + } } + } + } + + if path.pathExtension == "xcstrings" { + + if let data = try? Foundation.Data(contentsOf: path) { - catalogs[locale] = tables + if let catalog = try? JSONDecoder().decode(StringCatalog.self, from: data) { + + for (key, entry) in catalog.entries { + + for (tag, localization) in entry.localizations { + + if let unit = localization.unit { + + let locale = Locale(tag: tag) + + if let tables = catalogs[locale] { + + for var table in tables { + + if table.name == path.deletingPathExtension().lastPathComponent { + table.upsert(unit.value, for: key) + } + } + + catalogs[locale] = tables + + } else { + + catalogs[locale] = [TranslationTable(name: path.deletingPathExtension().lastPathComponent, translations: [key: unit.value])] + } + } + } + } + } } } } - - } else { - catalogs[Locale(tag: path.lastPathComponent)] = [TranslationTable]() } } } diff --git a/Sources/HTMLKit/Framework/Localization/StringCatalog.swift b/Sources/HTMLKit/Framework/Localization/StringCatalog.swift new file mode 100644 index 00000000..1185cc7a --- /dev/null +++ b/Sources/HTMLKit/Framework/Localization/StringCatalog.swift @@ -0,0 +1,31 @@ +/// A type that represents a string catalog. +internal struct StringCatalog: Codable { + + enum CodingKeys: String, CodingKey { + + case entries = "strings" + } + + let entries: [String: StringCatalog.Entry] + + internal struct Entry: Codable { + + let localizations: [String: StringCatalog.Localization] + } + + internal struct Localization: Codable { + + enum CodingKeys: String, CodingKey { + + case unit = "stringUnit" + } + + let unit: StringCatalog.Unit? + } + + internal struct Unit: Codable { + + let value: String + } +} + diff --git a/Sources/HTMLKit/Framework/Localization/TranslationTable.swift b/Sources/HTMLKit/Framework/Localization/TranslationTable.swift index b47f6caf..9686bf5f 100644 --- a/Sources/HTMLKit/Framework/Localization/TranslationTable.swift +++ b/Sources/HTMLKit/Framework/Localization/TranslationTable.swift @@ -7,7 +7,7 @@ internal struct TranslationTable: Sendable { internal let name: String /// The translations in the table - private let translations: [String: String] + private var translations: [String: String] /// Initializes a translation table /// @@ -20,12 +20,21 @@ internal struct TranslationTable: Sendable { self.translations = translations } - /// Retrieves the translation for the specified key + /// Retrieves the translation for the given key. /// - /// - Parameter key: The string key + /// - Parameter key: The string key to look up. /// /// - Returns: The translation internal func retrieve(for key: String) -> String? { return translations[key] } + + /// Inserts or updates a value in the table for the given key + /// + /// - Parameters: + /// - value: The value to be stored or updated. + /// - key: The key to store at. + internal mutating func upsert(_ value: String, for key: String) { + return translations[key] = value + } } diff --git a/Tests/HTMLKitTests/Localization/Localizable.xcstrings b/Tests/HTMLKitTests/Localization/Localizable.xcstrings new file mode 100644 index 00000000..38b0f458 --- /dev/null +++ b/Tests/HTMLKitTests/Localization/Localizable.xcstrings @@ -0,0 +1,23 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "hello.xcstrings" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hello String Catalog" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiya String Catalog" + } + } + } + } + }, + "version" : "1.2" +} \ No newline at end of file diff --git a/Tests/HTMLKitTests/LocalizationTests.swift b/Tests/HTMLKitTests/LocalizationTests.swift index 3d8538d7..ff70fbe1 100644 --- a/Tests/HTMLKitTests/LocalizationTests.swift +++ b/Tests/HTMLKitTests/LocalizationTests.swift @@ -17,6 +17,7 @@ final class LocalizationTests: XCTestCase { func testLocalization() throws { XCTAssertEqual(try localization!.localize(string: .init(key: "hello.world")), "Hiya World") + XCTAssertEqual(try localization!.localize(string: .init(key: "hello.xcstrings")), "Hiya String Catalog") } /// Tests the localization of a translation key in a specified translation table @@ -24,7 +25,7 @@ final class LocalizationTests: XCTestCase { /// The test expects the key to exist in the specified translation table and to be rendered accurately. func testLocalizationWithTable() throws { - XCTAssertEqual(try localization!.localize(string: .init(key: "hello", table: "mobile")), "Hiya") + XCTAssertEqual(try localization!.localize(string: .init(key: "hello", table: "mobile")), "Hiya") } /// Tests the localization of string interpolation From d38ffbf396b3959072504f63ab7256077765b189 Mon Sep 17 00:00:00 2001 From: Mattes Mohr Date: Thu, 3 Sep 2026 20:53:12 +0200 Subject: [PATCH 12/14] Fix failing tests --- .../HTMLKit/Framework/Localization/Localization.swift | 9 +++------ Tests/HTMLKitTests/LocalizationTests.swift | 9 +++++---- Tests/HTMLKitVaporTests/ProviderTests.swift | 3 +-- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/Sources/HTMLKit/Framework/Localization/Localization.swift b/Sources/HTMLKit/Framework/Localization/Localization.swift index 0fab7355..8142e17f 100644 --- a/Sources/HTMLKit/Framework/Localization/Localization.swift +++ b/Sources/HTMLKit/Framework/Localization/Localization.swift @@ -173,13 +173,10 @@ public struct Localization: Sendable { let locale = Locale(tag: tag) - if let tables = catalogs[locale] { + if var tables = catalogs[locale] { - for var table in tables { - - if table.name == path.deletingPathExtension().lastPathComponent { - table.upsert(unit.value, for: key) - } + if let index = tables.firstIndex(where: { $0.name == path.deletingPathExtension().lastPathComponent }) { + tables[index].upsert(unit.value, for: key) } catalogs[locale] = tables diff --git a/Tests/HTMLKitTests/LocalizationTests.swift b/Tests/HTMLKitTests/LocalizationTests.swift index ff70fbe1..35b6bd1f 100644 --- a/Tests/HTMLKitTests/LocalizationTests.swift +++ b/Tests/HTMLKitTests/LocalizationTests.swift @@ -37,7 +37,7 @@ final class LocalizationTests: XCTestCase { XCTAssertEqual(try localization!.localize(string: .init(key: "String: \("John Doe")")), "String: John Doe") XCTAssertEqual(try localization!.localize(string: .init(key: "Integer: \(31)")), "Integer: 31") XCTAssertEqual(try localization!.localize(string: .init(key: "Double: \(12.5)")), "Double: 12.5") - XCTAssertEqual(try localization!.localize(string: .init(key: "Date: \(Date(timeIntervalSince1970: 50000))")), "Date: 01/01/1970") + XCTAssertEqual(try localization!.localize(string: .init(key: "Date: \(Date(timeIntervalSince1970: 0))")), "Date: 01/01/1970") } /// Tests the localization of string interpolation with multiple arguments and various data types @@ -47,7 +47,7 @@ final class LocalizationTests: XCTestCase { func testStringInterpolationWithMultipleArguments() throws { XCTAssertEqual(try localization!.localize(string: .init(key: "Hello \("Jane") and \("John Doe")")), "Hello Jane and John Doe") - XCTAssertEqual(try localization!.localize(string: .init(key: "Do you \(2) have time at \(Date(timeIntervalSince1970: 50000))?")), "Do you 2 have time at 01/01/1970?") + XCTAssertEqual(try localization!.localize(string: .init(key: "Do you \(2) have time at \(Date(timeIntervalSince1970: 0))?")), "Do you 2 have time at 01/01/1970?") XCTAssertEqual(try localization!.localize(string: .init(key: "cheers.person \("Jean")")), "Cheers Jean") } @@ -143,6 +143,7 @@ final class LocalizationTests: XCTestCase { func testLocale() throws { let formatter = DateFormatter() + formatter.timeZone = TimeZone(secondsFromGMT: 0) let english = Locale(tag: "en") @@ -168,7 +169,7 @@ final class LocalizationTests: XCTestCase { formatter.dateFormat = "\(british.dateFormat!) \(british.timeFormat!)" - XCTAssertEqual(formatter.string(from: Date(timeIntervalSince1970: 1)), "01/01/1970 01:00:01") + XCTAssertEqual(formatter.string(from: Date(timeIntervalSince1970: 0)), "01/01/1970 00:00:00") let german = Locale(tag: "de-DE") @@ -183,7 +184,7 @@ final class LocalizationTests: XCTestCase { formatter.dateFormat = "\(german.dateFormat!) \(german.timeFormat!)" - XCTAssertEqual(formatter.string(from: Date(timeIntervalSince1970: 1)), "01.01.1970 01:00:01") + XCTAssertEqual(formatter.string(from: Date(timeIntervalSince1970: 0)), "01.01.1970 00:00:00") } /// Test the correct comparison of two locales diff --git a/Tests/HTMLKitVaporTests/ProviderTests.swift b/Tests/HTMLKitVaporTests/ProviderTests.swift index 5452d4b1..35b38e45 100644 --- a/Tests/HTMLKitVaporTests/ProviderTests.swift +++ b/Tests/HTMLKitVaporTests/ProviderTests.swift @@ -283,11 +283,10 @@ final class ProviderTests: XCTestCase { 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) + request.application.htmlkit.environment.upsert(HTMLKit.Locale(tag: language), for: \EnvironmentKeys.locale) } } From ba129da6fe157a3d21b62a2d7d0006df86c931dc Mon Sep 17 00:00:00 2001 From: Mattes Mohr Date: Thu, 24 Sep 2026 18:31:35 +0200 Subject: [PATCH 13/14] Load associated catalog comments --- .../Framework/Localization/Localization.swift | 43 ++++++++++++++----- .../Localization/StringCatalog.swift | 2 + .../Framework/Localization/Translation.swift | 20 +++++++++ .../Localization/TranslationTable.swift | 8 ++-- .../Localization/Localizable.xcstrings | 3 +- Tests/HTMLKitTests/LocalizationTests.swift | 22 ++++++++++ 6 files changed, 83 insertions(+), 15 deletions(-) create mode 100644 Sources/HTMLKit/Framework/Localization/Translation.swift diff --git a/Sources/HTMLKit/Framework/Localization/Localization.swift b/Sources/HTMLKit/Framework/Localization/Localization.swift index 8142e17f..c5959c5e 100644 --- a/Sources/HTMLKit/Framework/Localization/Localization.swift +++ b/Sources/HTMLKit/Framework/Localization/Localization.swift @@ -142,7 +142,14 @@ public struct Localization: Sendable { if let data = try? Foundation.Data(contentsOf: path) { if let translations = try? PropertyListSerialization.propertyList(from: data, options: .mutableContainers, format: nil) as? [String: String] { - tables.append(TranslationTable(name: path.deletingPathExtension().lastPathComponent, translations: translations)) + + var entries = [String: Translation]() + + for (key, value) in translations { + entries[key] = Translation(value: value) + } + + tables.append(TranslationTable(name: path.deletingPathExtension().lastPathComponent, translations: entries)) } catalogs[locale] = tables @@ -153,7 +160,14 @@ public struct Localization: Sendable { if let data = try? Foundation.Data(contentsOf: path) { if let translations = try? PropertyListSerialization.propertyList(from: data, options: .mutableContainers, format: nil) as? [String: String] { - catalogs[locale] = [TranslationTable(name: path.deletingPathExtension().lastPathComponent, translations: translations)] + + var entries = [String: Translation]() + + for (key, value) in translations { + entries[key] = Translation(value: value) + } + + catalogs[locale] = [TranslationTable(name: path.deletingPathExtension().lastPathComponent, translations: entries)] } } } @@ -176,14 +190,19 @@ public struct Localization: Sendable { if var tables = catalogs[locale] { if let index = tables.firstIndex(where: { $0.name == path.deletingPathExtension().lastPathComponent }) { - tables[index].upsert(unit.value, for: key) + + let translation = Translation(value: unit.value, comment: entry.comment) + + tables[index].upsert(translation, for: key) } catalogs[locale] = tables } else { + + let translation = Translation(value: unit.value, comment: entry.comment) - catalogs[locale] = [TranslationTable(name: path.deletingPathExtension().lastPathComponent, translations: [key: unit.value])] + catalogs[locale] = [TranslationTable(name: path.deletingPathExtension().lastPathComponent, translations: [key: translation])] } } } @@ -278,23 +297,27 @@ public struct Localization: Sendable { throw Error.missingTable(table, candidate.tag) } - guard var translation = match.retrieve(for: string.key.value) else { + guard let translation = match.retrieve(for: string.key.value) else { throw Error.missingKey(string.key.value, candidate.tag) } + + var value = translation.value - interpolate(arguments: string.key.arguments, to: &translation, for: candidate) + interpolate(arguments: string.key.arguments, to: &value, for: candidate) - return translation + return value } for table in tables { - if var translation = table.retrieve(for: string.key.value) { + if let translation = table.retrieve(for: string.key.value) { + + var value = translation.value - interpolate(arguments: string.key.arguments, to: &translation, for: candidate) + interpolate(arguments: string.key.arguments, to: &value, for: candidate) - return translation + return value } } diff --git a/Sources/HTMLKit/Framework/Localization/StringCatalog.swift b/Sources/HTMLKit/Framework/Localization/StringCatalog.swift index 1185cc7a..e103474d 100644 --- a/Sources/HTMLKit/Framework/Localization/StringCatalog.swift +++ b/Sources/HTMLKit/Framework/Localization/StringCatalog.swift @@ -11,6 +11,8 @@ internal struct StringCatalog: Codable { internal struct Entry: Codable { let localizations: [String: StringCatalog.Localization] + + let comment: String? } internal struct Localization: Codable { diff --git a/Sources/HTMLKit/Framework/Localization/Translation.swift b/Sources/HTMLKit/Framework/Localization/Translation.swift new file mode 100644 index 00000000..4a1c47c9 --- /dev/null +++ b/Sources/HTMLKit/Framework/Localization/Translation.swift @@ -0,0 +1,20 @@ +/// A type that represents the translation within the translation table. +internal struct Translation { + + /// The value of the translation. + internal let value: String + + /// The comment describing the context + internal let comment: String? + + /// Creates a translation. + /// + /// - Parameters: + /// - value: The value for the translation. + /// - comment: The comment for some context. + internal init(value: String, comment: String? = nil) { + + self.value = value + self.comment = comment + } +} diff --git a/Sources/HTMLKit/Framework/Localization/TranslationTable.swift b/Sources/HTMLKit/Framework/Localization/TranslationTable.swift index 9686bf5f..e5c33033 100644 --- a/Sources/HTMLKit/Framework/Localization/TranslationTable.swift +++ b/Sources/HTMLKit/Framework/Localization/TranslationTable.swift @@ -7,14 +7,14 @@ internal struct TranslationTable: Sendable { internal let name: String /// The translations in the table - private var translations: [String: String] + private var translations: [String: Translation] /// Initializes a translation table /// /// - Parameters: /// - name: The name of the translation table /// - translations: The translations - internal init(name: String, translations: [String: String]) { + internal init(name: String, translations: [String: Translation]) { self.name = name self.translations = translations @@ -25,7 +25,7 @@ internal struct TranslationTable: Sendable { /// - Parameter key: The string key to look up. /// /// - Returns: The translation - internal func retrieve(for key: String) -> String? { + internal func retrieve(for key: String) -> Translation? { return translations[key] } @@ -34,7 +34,7 @@ internal struct TranslationTable: Sendable { /// - Parameters: /// - value: The value to be stored or updated. /// - key: The key to store at. - internal mutating func upsert(_ value: String, for key: String) { + internal mutating func upsert(_ value: Translation, for key: String) { return translations[key] = value } } diff --git a/Tests/HTMLKitTests/Localization/Localizable.xcstrings b/Tests/HTMLKitTests/Localization/Localizable.xcstrings index 38b0f458..138d0abe 100644 --- a/Tests/HTMLKitTests/Localization/Localizable.xcstrings +++ b/Tests/HTMLKitTests/Localization/Localizable.xcstrings @@ -2,6 +2,7 @@ "sourceLanguage" : "en", "strings" : { "hello.xcstrings" : { + "comment" : "One more thing", "extractionState" : "manual", "localizations" : { "en" : { @@ -20,4 +21,4 @@ } }, "version" : "1.2" -} \ No newline at end of file +} diff --git a/Tests/HTMLKitTests/LocalizationTests.swift b/Tests/HTMLKitTests/LocalizationTests.swift index 35b6bd1f..e2439ee1 100644 --- a/Tests/HTMLKitTests/LocalizationTests.swift +++ b/Tests/HTMLKitTests/LocalizationTests.swift @@ -228,6 +228,28 @@ final class LocalizationTests: XCTestCase { XCTAssertEqual(missingLanguage.language, "en") XCTAssertEqual(missingLanguage.region, "GB") } + + /// Tests the correct loading of the associated comments from the string catalog. + func testLoadingAssociatedComment() throws { + + guard let catalogs = localization!.catalogs else { + return XCTFail("No catalogs available") + } + + guard let tables = catalogs[Locale(tag: "en-GB")] else { + return XCTFail("No tables available") + } + + guard let table = tables.first(where: { $0.name == "Localizable" }) else { + return XCTFail("No table available") + } + + guard let translation = table.retrieve(for: "hello.xcstrings") else { + return XCTFail("No translation available") + } + + XCTAssertEqual(translation.comment, "One more thing") + } } extension LocalizationTests { From 114ca11e81744ab1f8fa97b6deefab585cdd7725 Mon Sep 17 00:00:00 2001 From: Mattes Mohr Date: Mon, 28 Sep 2026 21:00:08 +0200 Subject: [PATCH 14/14] Tidy up a bit --- .../Localization/InterpolationArgument.swift | 12 ++--- .../Framework/Localization/Localizable.swift | 8 +-- .../Framework/Localization/Localization.swift | 50 +++++++++++-------- .../Localization/LocalizedString.swift | 10 ++-- .../Localization/LocalizedStringKey.swift | 16 +++--- .../Localization/StringCatalog.swift | 23 ++++++--- .../Framework/Localization/Translation.swift | 4 +- .../Localization/TranslationTable.swift | 22 ++++---- Tests/HTMLKitTests/LocalizationTests.swift | 8 +-- 9 files changed, 86 insertions(+), 67 deletions(-) diff --git a/Sources/HTMLKit/Framework/Localization/InterpolationArgument.swift b/Sources/HTMLKit/Framework/Localization/InterpolationArgument.swift index 674a665d..6e867b1d 100644 --- a/Sources/HTMLKit/Framework/Localization/InterpolationArgument.swift +++ b/Sources/HTMLKit/Framework/Localization/InterpolationArgument.swift @@ -5,21 +5,21 @@ 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: Hashable { +public enum InterpolationArgument: Hashable, Sendable { - /// Holds an integer value + /// Holds an integer value. case int(Int) - /// Holds a string value + /// Holds a string value. case string(String) - /// Holds a double value + /// Holds a double value. case double(Double) - /// Holds a float value + /// Holds a float value. case float(Float) - /// Holds a date value + /// Holds a date value. case date(Date) /// The placeholder used for string interpolation diff --git a/Sources/HTMLKit/Framework/Localization/Localizable.swift b/Sources/HTMLKit/Framework/Localization/Localizable.swift index f1e551d5..99eac543 100644 --- a/Sources/HTMLKit/Framework/Localization/Localizable.swift +++ b/Sources/HTMLKit/Framework/Localization/Localizable.swift @@ -1,11 +1,11 @@ -/// A protocol that defines a type capable of being localized +/// A protocol that defines a type capable of being localized. @_documentation(visibility: internal) public protocol Localizable { - /// Initializes a phrasing element intended for localization + /// Initializes a phrasing element intended for localization. /// /// - Parameters: - /// - localizedKey: The string key to be translated - /// - tableName: The name of the translation table + /// - localizedKey: The string key to be translated. + /// - tableName: The name of the translation table. init(_ localizedKey: LocalizedStringKey, tableName: String?) } diff --git a/Sources/HTMLKit/Framework/Localization/Localization.swift b/Sources/HTMLKit/Framework/Localization/Localization.swift index c5959c5e..104e6173 100644 --- a/Sources/HTMLKit/Framework/Localization/Localization.swift +++ b/Sources/HTMLKit/Framework/Localization/Localization.swift @@ -1,6 +1,6 @@ import Foundation -/// A type that represents the localization +/// A type that represents the localization. @_documentation(visibility: internal) public struct Localization: Sendable { @@ -10,17 +10,27 @@ public struct Localization: Sendable { /// Indicates a missing key. /// /// A key is considered as missing if it cannot be found in the translation table. - case missingKey(String, String) + /// + /// - Parameters: + /// - identifier: The identifier of the involved key. + /// - locale: The involved locale. + case missingKey(identifier: String, locale: Locale) /// Indicates a missing table. /// /// A table is considered as missing if it cannot be found in the language catalog. - case missingTable(String, String) + /// + /// - Parameters: + /// - name: The name of the involved table. + /// - locale: The involved locale. + case missingTable(name: String, locale: Locale) /// Indicates a missing catalog. /// /// A catalog is considered as missing if it cannot be found in the localization folder. - case missingCatalog(String) + /// + /// - Parameter locale: The involved locale. + case missingCatalog(locale: Locale) /// Indicates missing language catalogs. case missingCatalogs @@ -28,21 +38,21 @@ public struct Localization: Sendable { /// Indicates there is no fallback configuration set up. case missingFallback - /// Indicates a loading failure + /// Indicates a loading failure. case loadingDataFailed - /// Returns a description about the failure reason + /// Returns a description about the failure reason. public var description: String { switch self { - case .missingKey(let key, let tag): - return "Unable to find translation key '\(key)' for the locale '\(tag)'." + case .missingKey(let key, let locale): + return "Unable to find translation key '\(key)' for the locale '\(locale.tag)'." - case .missingTable(let table, let tag): - return "Unable to find translation table '\(table)' for the locale '\(tag)'." + case .missingTable(let table, let locale): + return "Unable to find translation table '\(table)' for the locale '\(locale.tag)'." - case .missingCatalog(let tag): - return "Unable to find a language catalog for the locale '\(tag)'." + case .missingCatalog(let locale): + return "Unable to find a language catalog for the locale '\(locale.tag)'." case .missingCatalogs: return "Unable to find any language catalog." @@ -242,14 +252,14 @@ public struct Localization: Sendable { for argument in arguments { switch argument { - case .int(let int): - - replace(placeholder: argument.placeholder, with: String(int), on: &translation) - case .string(let string): replace(placeholder: argument.placeholder, with: string, on: &translation) + case .int(let int): + + replace(placeholder: argument.placeholder, with: String(int), on: &translation) + case .double(let double): replace(placeholder: argument.placeholder, with: String(double), on: &translation) @@ -288,17 +298,17 @@ public struct Localization: Sendable { let candidate = getPossibleLanguage(locale, fallback) guard let tables = catalogs[candidate] else { - throw Error.missingCatalog(candidate.tag) + throw Error.missingCatalog(locale: candidate) } if let table = string.table { guard let match = tables.first(where: { $0.name == table }) else { - throw Error.missingTable(table, candidate.tag) + throw Error.missingTable(name: table, locale: candidate) } guard let translation = match.retrieve(for: string.key.value) else { - throw Error.missingKey(string.key.value, candidate.tag) + throw Error.missingKey(identifier: string.key.value, locale: candidate) } var value = translation.value @@ -321,7 +331,7 @@ public struct Localization: Sendable { } } - throw Error.missingKey(string.key.value, candidate.tag) + throw Error.missingKey(identifier: string.key.value, locale: candidate) } /// Recovers from an error. diff --git a/Sources/HTMLKit/Framework/Localization/LocalizedString.swift b/Sources/HTMLKit/Framework/Localization/LocalizedString.swift index fa00f711..c1b18871 100644 --- a/Sources/HTMLKit/Framework/Localization/LocalizedString.swift +++ b/Sources/HTMLKit/Framework/Localization/LocalizedString.swift @@ -1,19 +1,19 @@ import Foundation -/// A type thats holds the information for the localization +/// A type thats holds the information for the localization. @_documentation(visibility: internal) public struct LocalizedString: Content { - /// The key of the translation value + /// The key of the translation value. internal let key: LocalizedStringKey - /// The name of the translation table + /// The name of the translation table. internal let table: String? - /// Initializes a localized string with context + /// Initializes a localized string with context. /// /// - Parameters: - /// - key: The string key to be translated + /// - key: The string key to be translated. /// - table: The table where the string key should be looked up. Default is nil. public init(key: LocalizedStringKey, table: String? = nil) { diff --git a/Sources/HTMLKit/Framework/Localization/LocalizedStringKey.swift b/Sources/HTMLKit/Framework/Localization/LocalizedStringKey.swift index 14b49ded..517ed0e4 100644 --- a/Sources/HTMLKit/Framework/Localization/LocalizedStringKey.swift +++ b/Sources/HTMLKit/Framework/Localization/LocalizedStringKey.swift @@ -1,17 +1,17 @@ import Foundation -/// A string key for the localization +/// A string key for the localization. @_documentation(visibility: internal) -public struct LocalizedStringKey { +public struct LocalizedStringKey: Sendable { - /// The key value + /// The key value. /// /// ``` /// Hello %@ /// ``` internal var value: String - /// A fallback literal string + /// A fallback literal string. /// /// ``` /// Hello World @@ -24,12 +24,12 @@ public struct LocalizedStringKey { /// The arguments for the interpolation internal var arguments: [InterpolationArgument] - /// Initializes a string key for localization + /// Initializes a string key for localization. /// /// - Parameters: - /// - value: The key value - /// - literal: The default value - /// - interpolation: The arguments toreplace placeholders within the translation string + /// - value: The key value. + /// - fallback: The default value. + /// - arguments: The arguments toreplace placeholders within the translation string. public init(value: String, fallback: String, arguments: [InterpolationArgument] = []) { self.value = value diff --git a/Sources/HTMLKit/Framework/Localization/StringCatalog.swift b/Sources/HTMLKit/Framework/Localization/StringCatalog.swift index e103474d..054ad3dd 100644 --- a/Sources/HTMLKit/Framework/Localization/StringCatalog.swift +++ b/Sources/HTMLKit/Framework/Localization/StringCatalog.swift @@ -1,33 +1,42 @@ /// A type that represents a string catalog. internal struct StringCatalog: Codable { - enum CodingKeys: String, CodingKey { + internal enum CodingKeys: String, CodingKey { case entries = "strings" } - let entries: [String: StringCatalog.Entry] + /// The entries within the catalog. + internal let entries: [String: StringCatalog.Entry] +} + +extension StringCatalog { + /// A type that represents a catalog entry. internal struct Entry: Codable { - let localizations: [String: StringCatalog.Localization] + /// The associated values. + internal let localizations: [String: StringCatalog.Localization] - let comment: String? + /// The associated comment. + internal let comment: String? } + /// A type that represents a entry value. internal struct Localization: Codable { - enum CodingKeys: String, CodingKey { + internal enum CodingKeys: String, CodingKey { case unit = "stringUnit" } - let unit: StringCatalog.Unit? + internal let unit: StringCatalog.Unit? } + /// A type that represents a value representation. internal struct Unit: Codable { - let value: String + internal let value: String } } diff --git a/Sources/HTMLKit/Framework/Localization/Translation.swift b/Sources/HTMLKit/Framework/Localization/Translation.swift index 4a1c47c9..602ff84f 100644 --- a/Sources/HTMLKit/Framework/Localization/Translation.swift +++ b/Sources/HTMLKit/Framework/Localization/Translation.swift @@ -1,10 +1,10 @@ /// A type that represents the translation within the translation table. -internal struct Translation { +internal struct Translation: Sendable { /// The value of the translation. internal let value: String - /// The comment describing the context + /// The comment describing the context. internal let comment: String? /// Creates a translation. diff --git a/Sources/HTMLKit/Framework/Localization/TranslationTable.swift b/Sources/HTMLKit/Framework/Localization/TranslationTable.swift index e5c33033..5c082671 100644 --- a/Sources/HTMLKit/Framework/Localization/TranslationTable.swift +++ b/Sources/HTMLKit/Framework/Localization/TranslationTable.swift @@ -1,19 +1,19 @@ -/// A type that represents a translation table +/// A type that represents a translation table. /// -/// A translation table stores multiple localized strings, mapping unique string keys to their corresponding translations +/// A translation table stores multiple localized strings, mapping unique string keys to their corresponding translations. internal struct TranslationTable: Sendable { - /// The name of the table + /// The name of the table. internal let name: String - /// The translations in the table + /// The translations in the table. private var translations: [String: Translation] - /// Initializes a translation table + /// Initializes a translation table. /// /// - Parameters: - /// - name: The name of the translation table - /// - translations: The translations + /// - name: The name of the translation table. + /// - translations: The translations. internal init(name: String, translations: [String: Translation]) { self.name = name @@ -29,12 +29,12 @@ internal struct TranslationTable: Sendable { return translations[key] } - /// Inserts or updates a value in the table for the given key + /// Inserts or updates a value in the table for the given key. /// /// - Parameters: - /// - value: The value to be stored or updated. + /// - translation: The value to be stored or updated. /// - key: The key to store at. - internal mutating func upsert(_ value: Translation, for key: String) { - return translations[key] = value + internal mutating func upsert(_ translation: Translation, for key: String) { + return translations[key] = translation } } diff --git a/Tests/HTMLKitTests/LocalizationTests.swift b/Tests/HTMLKitTests/LocalizationTests.swift index e2439ee1..6e7d895c 100644 --- a/Tests/HTMLKitTests/LocalizationTests.swift +++ b/Tests/HTMLKitTests/LocalizationTests.swift @@ -57,13 +57,13 @@ final class LocalizationTests: XCTestCase { /// the localization is expected to throw an error. func testMissingKey() throws { - XCTAssertThrowsError(try localization!.localize(string: .init(key: "unknown.key")), "unknown.key") { error in + XCTAssertThrowsError(try localization!.localize(string: .init(key: "unknown.key")), "unknown.table") { error in guard let localizationError = error as? Localization.Error else { return XCTFail("Unexpected error type: \(error)") } - XCTAssertEqual(localizationError, .missingKey("unknown.key", "en-GB")) + XCTAssertEqual(localizationError, .missingKey(identifier: "unknown.key", locale: Locale(tag: "en-GB"))) XCTAssertEqual(localizationError.description, "Unable to find translation key 'unknown.key' for the locale 'en-GB'.") } } @@ -80,7 +80,7 @@ final class LocalizationTests: XCTestCase { return XCTFail("Unexpected error type: \(error)") } - XCTAssertEqual(localizationError, .missingTable("unknown.table", "en-GB")) + XCTAssertEqual(localizationError, .missingTable(name: "unknown.table", locale: Locale(tag: "en-GB"))) XCTAssertEqual(localizationError.description, "Unable to find translation table 'unknown.table' for the locale 'en-GB'.") } } @@ -99,7 +99,7 @@ final class LocalizationTests: XCTestCase { return XCTFail("Unexpected error type: \(error)") } - XCTAssertEqual(localizationError, .missingCatalog("tlh-AA")) + XCTAssertEqual(localizationError, .missingCatalog(locale: Locale(tag: "tlh-AA"))) XCTAssertEqual(localizationError.description, "Unable to find a language catalog for the locale 'tlh-AA'.") } }