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/Locale.swift b/Sources/HTMLKit/Framework/Localization/Locale.swift index 61adb4ba..257182b3 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 { @@ -47,14 +47,14 @@ 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? { @@ -68,44 +68,44 @@ public struct Locale: Hashable, Sendable { return nil } - /// 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 +256,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 +303,63 @@ 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", ] } } + +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/Sources/HTMLKit/Framework/Localization/Localization.swift b/Sources/HTMLKit/Framework/Localization/Localization.swift index 49ca1ef3..0fab7355 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." @@ -56,10 +56,20 @@ 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 { - if self.tables != nil && self.locale != nil { + if self.catalogs != nil && self.locale != nil { return true } @@ -67,7 +77,7 @@ public struct Localization: Sendable { } /// The translations tables - internal var tables: [Locale: [TranslationTable]]? + internal var catalogs: [Locale: [TranslationTable]]? /// The default locale /// @@ -82,7 +92,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 +110,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 +120,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) { @@ -123,28 +133,73 @@ public struct Localization: Sendable { } else { - let locale = Locale(tag: path.deletingPathExtension().deletingLastPathComponent().lastPathComponent) - - if var translationTables = localizationTables[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 translations = try? PropertyListSerialization.propertyList(from: data, options: .mutableContainers, format: nil) as? [String: String] { - translationTables.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] { + tables.append(TranslationTable(name: path.deletingPathExtension().lastPathComponent, translations: translations)) + } + + catalogs[locale] = tables } - localizationTables[locale] = translationTables + } else { + + 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) { + + 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 { - localizationTables[Locale(tag: path.lastPathComponent)] = [TranslationTable]() } } } - return localizationTables + return catalogs } /// Replace the value with the placeholder @@ -207,50 +262,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 = getPossibleLanguage(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) } - if let interpolation = string.key.interpolation { - interpolate(arguments: interpolation, 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) { - if let interpolation = string.key.interpolation { - interpolate(arguments: interpolation, 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. @@ -276,8 +327,32 @@ public struct Localization: Sendable { return try recover(from: error, with: string) default: - return string.key.literal + return string.key.fallback } } } + + /// 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/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/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/Sources/HTMLKit/Framework/Rendering/Renderer.swift b/Sources/HTMLKit/Framework/Rendering/Renderer.swift index e765f5dd..0354e2de 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 { @@ -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 @@ -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/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/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/HTMLKitVaporTests/Localization/en-GB/web.strings b/Tests/HTMLKitTests/Localization/en/web.strings similarity index 98% rename from Tests/HTMLKitVaporTests/Localization/en-GB/web.strings rename to Tests/HTMLKitTests/Localization/en/web.strings index 4bed9159..2e66f69f 100644 --- a/Tests/HTMLKitVaporTests/Localization/en-GB/web.strings +++ b/Tests/HTMLKitTests/Localization/en/web.strings @@ -1,3 +1,2 @@ /* A string key with a namespace pattern */ "hello.world" = "Hello World"; - diff --git a/Tests/HTMLKitTests/LocalizationTests.swift b/Tests/HTMLKitTests/LocalizationTests.swift index 4780ffcc..ff70fbe1 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 { @@ -16,7 +16,8 @@ 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") + 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.world", table: "web")), "Hello World") + XCTAssertEqual(try localization!.localize(string: .init(key: "hello", table: "mobile")), "Hiya") } /// Tests the localization of string interpolation @@ -67,39 +68,165 @@ 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 { - 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 + 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, .unknownTable("unknown.table", "en-GB")) - XCTAssertEqual(localizationError.description, "Unable to find translation table 'unknown.table' for the locale 'en-GB'.") + XCTAssertEqual(localizationError, .missingCatalog("tlh-AA")) + XCTAssertEqual(localizationError.description, "Unable to find a language catalog for the locale 'tlh-AA'.") } } + + /// 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) + } + + /// Test a locale of a language + func testLocale() throws { + + 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") + 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") + } + + /// Test the correct comparison of two locales + 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/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
\ + \ + + """ + ) + } + + 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 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 +282,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": "Hiya 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, + """ + \ + \ + \ +\(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, + """ + \ + \ + \ +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, """ @@ -288,7 +416,7 @@ final class ProviderTests: XCTestCase {Hello World
\ +Hiya World
\ \