diff --git a/README.md b/README.md index 6fc3451..8df6bc7 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@

- Setup + Features + • SetupUsagePerformanceTests @@ -17,63 +18,63 @@ **Lingo** is a pure Swift localization library ready to be used in Server Side Swift project but not limited to those. -**Features**: +## Features -* Pluralization - including custom language specific pluralization rules (CLDR compatible) -* String interpolation -* Default locale - if the localization for a requested locale is not available, it will fallback to the default one -* Locale validation - the library will warn you for using invalid locale identifiers (`en_fr` instead of `en_FR` etc.) +* **Pluralization** - including custom language specific pluralization rules (CLDR compatible) +* **String interpolation** +* **Flexible data source** (read localizations from a JSON file, database or whatever suites your workflow the best) +* **Default locale** - if the localization for a requested locale is not available, it will fallback to the default one +* **Locale validation** - the library will warn you for using invalid locale identifiers (`en_fr` instead of `en_FR` etc.) ## Setup The supported method for using this library is trough the Swift Package Manager, like this: -``` +```swift import PackageDescription let package = Package( name: "MyCoolApp", - dependencies: [.Package(url: "https://github.com/miroslavkovac/Lingo.git", majorVersion: 1)] + dependencies: [.Package(url: "https://github.com/miroslavkovac/Lingo.git", majorVersion: 2)] ) ``` Optionally, if you are using Xcode, you can generate Xcode project by running: -``` +```swift swift package generate-xcodeproj ``` In your app create an instance of `Lingo` object passing the root directory path where the localization files are located: +```swift +let lingo = Lingo(rootPath: "/path/to/localizations", defaultLocale: "en") ``` -let lingo = try Lingo(rootURL: URL(fileURLWithPath: "/users/user.name/localizations"), defaultLocale: "en") -``` -> Note that the call can throw in case of an IO error or invalid JSON file. ### Vapor If you are using Vapor for you server side swift project, you can initialise `Lingo` alongside `Droplet` which will make it accessible everywhere in code: -``` +```swift import Vapor import Lingo let drop = try Droplet() -let lingo = try Lingo(rootURL: URL(fileURLWithPath: drop.config.workDir.appending("Localizations")), defaultLocale: "en") +let lingo = Lingo(rootPath: drop.config.workDir.appending("Localizations"), defaultLocale: "en") try drop.run() ``` -> Further versions of Vapor will hopefully provide some hooks for localization engines to be plugged in, and then we will be able to do something like `drop.localize(...)`. +> Future versions of Vapor might provide some hooks for localization engines to be plugged in, and then we might be able to do something like `drop.localize(...)`. ## Usage -Use the following syntax for defining localizations in the JSON file: +Use the following syntax for defining localizations in a JSON file: -``` +```swift { "title": "Hello Swift!", - "greeting.message": "Hi %{full-name}! How are your Swift skills today?", + "greeting.message": "Hi %{full-name}!", "unread.messages": { "one": "You have an unread message.", "other": "You have %{count} unread messages." @@ -81,26 +82,26 @@ Use the following syntax for defining localizations in the JSON file: } ``` -> Note that this syntax is compatible with `i18n-node-2`. This is useful in case you are using a 3rd party localization service which will export the localization files for you. +> Note that this syntax is compatible with `i18n-node-2`. This is can be useful in case you are using a 3rd party localization service which will export the localization files for you. ### Localization You can retrieve localized string like this: -``` -let localizedTitle = lingo.localized("title", locale: "en") +```swift +let localizedTitle = lingo.localize("title", locale: "en") print(localizedTitle) // will print: "Hello Swift!" ``` ### String interpolation -You can interpolate the strings like this: +You can interpolate the localized strings like this: -``` -let greeting = lingo.localized("greeting.message", locale: "en", interpolations: ["full-name": "John"]) +```swift +let greeting = lingo.localize("greeting.message", locale: "en", interpolations: ["full-name": "John"]) -print(greeting) // will print: "Hi John! How are your Swift skills today?" +print(greeting) // will print: "Hi John!" ``` ### Pluralization @@ -116,9 +117,9 @@ Lingo supports all Unicode plural categories as defined in [CLDR](http://cldr.un Example: -``` -let unread1 = lingo.localized("unread.messages", locale: "en", interpolations: ["count": 1]) -let unread24 = lingo.localized("unread.messages", locale: "en", interpolations: ["count": 24]) +```swift +let unread1 = lingo.localize("unread.messages", locale: "en", interpolations: ["count": 1]) +let unread24 = lingo.localize("unread.messages", locale: "en", interpolations: ["count": 24]) print(unread1) // Will print: "You have an unread message." print(unread24) // Will print: "You have 24 unread messages." @@ -136,11 +137,35 @@ In tests with a set of 1000 localization keys including plural forms, the librar > String interpolation uses regular expressions under the hood, which can explain the difference in performance. All tests were performed on i7 4GHz CPU. +## Custom localizations data source + +Although most of the time, the localizations will be defined in the JSON file, but if you prefer keeping them in a database, we've got you covered! + +To implement a custom data source, all you need is to have an object that conforms to the `LocalizationDataSource` protocol: + +```swift +public protocol LocalizationDataSource { + + func availableLocales() -> [LocaleIdentifier] + func localizations(`for` locale: LocaleIdentifier) -> [LocalizationKey: Localization] + +} +``` + +So, let's say you are using MongoDB to store your localizations, all you need to do is to create a data source and pass it to Lingo's designated initializer: + +```swift +let mongoDataSource = MongoLocalizationDataSource(...) +let lingo = Lingo(dataSource: mongoDataSource, defaultLocale: "en") +``` + +Lingo already includes `FileDataSource` conforming to this protocol, which, as you might guess, is wired up to the Longo's convenience initializer with `rootPath`. + ## Note on locale identifiers Although it is completely up to you how you name the locales, there is an easy way to get the list of all locales directly from `Locale` class: -``` +```swift #import Foundation print(Locale.availableIdentifiers) @@ -152,7 +177,7 @@ Just keep that in mind when adding a support for a new locale. To build and run tests from command line just run: -``` +```swift swift test ``` @@ -173,8 +198,8 @@ Currently the library doesn't support the case where different plural categories and passing numbers 1 and 7: -``` -print(lingo.localized("key", locale: "en", interpolations: ["apples-count": 1, "oranges-count": 7])) +```swift +print(lingo.localize("key", locale: "en", interpolations: ["apples-count": 1, "oranges-count": 7])) ``` @@ -185,12 +210,12 @@ You have 1 apple and 7 orange. ``` > Note the missing *s* in the printed message. -The reason for this was to keep the JSON file syntax simple and elegant (in comparison to iOS .stringsdict file), but if you still need to support this case, the workaround is to split the string in two and combine it later in code. +This was done on purpose, and the reason for this was to keep the JSON file syntax simple and elegant (in contrast with iOS .stringsdict file and similar). If you still need to support a case like this, a possible workaround would be to split that string in two and combine it later in code. ## Further work - Locale fallbacks, being RFC4647 compliant. -- Options for doubling the length of a localized string, which can be useful in debugging. +- Options for doubling the length of a localized string, which can be useful for debugging. - Implement debug mode for easier testing and finding missing localizations. - Support for non integer based pluralization rules diff --git a/Sources/Lingo/DataSources/FileDataSource.swift b/Sources/Lingo/DataSources/FileDataSource.swift index e33ba01..bc7be8c 100644 --- a/Sources/Lingo/DataSources/FileDataSource.swift +++ b/Sources/Lingo/DataSources/FileDataSource.swift @@ -1,29 +1,37 @@ import Foundation /// Class providing file backed data source for Lingo in case localizations are stored in JSON files. -public final class FileDataSource: DataSource { +public final class FileDataSource: LocalizationDataSource { public let rootPath: String /// `rootPath` should contain localization files in JSON format named based on relevant locale. For example: en.json, de.json etc. - public init(rootPath: String) throws { + public init(rootPath: String) { self.rootPath = rootPath } - // MARK: DataSource - public func availableLocales() throws -> [LocaleIdentifier] { - return try FileManager().contentsOfDirectory(atPath: self.rootPath).filter { - $0.hasSuffix(".json") - }.map { - $0.components(separatedBy: ".").first! // It is safe to use force unwrap here as $0 will always contain the "." + // MARK: LocalizationDataSource + public func availableLocales() -> [LocaleIdentifier] { + do { + let identifiers = try FileManager().contentsOfDirectory(atPath: self.rootPath).filter { + $0.hasSuffix(".json") + }.map { + $0.components(separatedBy: ".").first! // It is safe to use force unwrap here as $0 will always contain the "." + } + + return identifiers + + } catch let e { + assertionFailure("Failed retrieving contents of a directory: \(e.localizedDescription)") + return [] } } - public func localizations(for locale: LocaleIdentifier) throws -> [LocalizationKey : Localization] { + public func localizations(for locale: LocaleIdentifier) -> [LocalizationKey : Localization] { let jsonFilePath = "\(self.rootPath)/\(locale).json" // Try to read localizations file from disk - guard let localizationsData = try self.loadLocalizations(atPath: jsonFilePath) else { + guard let localizationsData = self.loadLocalizations(atPath: jsonFilePath) else { assertionFailure("Failed to load localizations at path: \(jsonFilePath)") return [:] } @@ -76,14 +84,17 @@ fileprivate extension FileDataSource { } /// Loads a localizations file from disk if it exists and parses it. - /// It can throw an exception in case JSON file is invalid. - func loadLocalizations(atPath path: String) throws -> [String: Any]? { - if !FileManager().fileExists(atPath: path) { - return nil + func loadLocalizations(atPath path: String) -> [String: Any]? { + precondition(FileManager().fileExists(atPath: path)) + + guard + let fileContent = try? Data(contentsOf: URL(fileURLWithPath: path)), + let jsonObject = try? JSONSerialization.jsonObject(with: fileContent, options: []) as? [String: Any] else { + assertionFailure("Failed reading localizations from file at path: \(path)") + return nil } - let fileContent = try Data(contentsOf: URL(fileURLWithPath: path)) - return try JSONSerialization.jsonObject(with: fileContent, options: []) as? [String: Any] + return jsonObject } } diff --git a/Sources/Lingo/DataSources/DataSource.swift b/Sources/Lingo/DataSources/LocalizationDataSource.swift similarity index 56% rename from Sources/Lingo/DataSources/DataSource.swift rename to Sources/Lingo/DataSources/LocalizationDataSource.swift index 1d17abc..f9453f8 100644 --- a/Sources/Lingo/DataSources/DataSource.swift +++ b/Sources/Lingo/DataSources/LocalizationDataSource.swift @@ -3,10 +3,10 @@ import Foundation /// Types conforming to this protocol can be used to initialize Lingo. /// /// Use it in case your localizations are not stored in JSON files, but rather in a database or other storage technology. -public protocol DataSource { +public protocol LocalizationDataSource { - func availableLocales() throws -> [LocaleIdentifier] + func availableLocales() -> [LocaleIdentifier] - func localizations(`for` locale: LocaleIdentifier) throws -> [LocalizationKey: Localization] + func localizations(`for` locale: LocaleIdentifier) -> [LocalizationKey: Localization] } diff --git a/Sources/Lingo/Lingo.swift b/Sources/Lingo/Lingo.swift index 129d179..38f0807 100644 --- a/Sources/Lingo/Lingo.swift +++ b/Sources/Lingo/Lingo.swift @@ -14,37 +14,37 @@ public final class Lingo { /// /// If the `defaultLocale` is specified, it will be used as a fallback when no localizations /// are available for given locale. - public convenience init(rootPath: String, defaultLocale: LocaleIdentifier?) throws { - let dataSource = try FileDataSource(rootPath: rootPath) - try self.init(dataSource: dataSource, defaultLocale: defaultLocale) + public convenience init(rootPath: String, defaultLocale: LocaleIdentifier?) { + let dataSource = FileDataSource(rootPath: rootPath) + self.init(dataSource: dataSource, defaultLocale: defaultLocale) } - /// Initializes Lingo. With a DataSource providing localization data + /// Initializes Lingo with a `LocalizationDataSource`. /// /// If the `defaultLocale` is specified, it will be used as a fallback when no localizations /// are available for given locale. - public init(dataSource: DataSource, defaultLocale: LocaleIdentifier?) throws { + public init(dataSource: LocalizationDataSource, defaultLocale: LocaleIdentifier?) { self.defaultLocale = defaultLocale self.model = LocalizationsModel() let validator = LocaleValidator() - for locale in try dataSource.availableLocales() { + for locale in dataSource.availableLocales() { // Check if locale is valid. Invalid locales will not cause any problems in the runtime, // so this validation should only warn about potential mistype in locale names. if !validator.validate(locale: locale) { print("WARNING: Invalid locale identifier: \(locale)") } - let localizations = try dataSource.localizations(for: locale) + let localizations = dataSource.localizations(for: locale) self.model.addLocalizations(localizations, for: locale) } } - /// Returns string localization of a given key in the given locale. - /// If string contains interpolations, they are replaced from the dictionary. - public func localized(_ key: LocalizationKey, locale: LocaleIdentifier, interpolations: [String: Any]? = nil) -> String { - let result = self.model.localized(key: key, locale: locale, interpolations: interpolations) + /// Returns localized string for given key in specified locale. + /// If string contains interpolations, they are replaced from the `interpolations` dictionary. + public func localize(_ key: LocalizationKey, locale: LocaleIdentifier, interpolations: [String: Any]? = nil) -> String { + let result = self.model.localize(key, locale: locale, interpolations: interpolations) switch result { case .missingKey: print("Missing localization for locale: \(locale)") @@ -52,7 +52,7 @@ public final class Lingo { case .missingLocale: if let defaultLocale = self.defaultLocale { - return self.localized(key, locale: defaultLocale, interpolations: interpolations) + return self.localize(key, locale: defaultLocale, interpolations: interpolations) } else { print("Missing \(locale) localization for key: \(key)") return key diff --git a/Sources/Lingo/Localization.swift b/Sources/Lingo/Localization.swift index 37021fb..64530c7 100644 --- a/Sources/Lingo/Localization.swift +++ b/Sources/Lingo/Localization.swift @@ -1,5 +1,10 @@ import Foundation +/// Object represent localization of a given key in a given language. +/// +/// It has 2 cases: +/// - `universal` - in case pluralization is not needed and one values is used for all plural categories +/// - `pluralized` - in case of different localizations are defined based on a PluralCategory public enum Localization { case universal(value: String) diff --git a/Sources/Lingo/LocalizationsModel.swift b/Sources/Lingo/LocalizationsModel.swift index 4767aec..a4430f0 100644 --- a/Sources/Lingo/LocalizationsModel.swift +++ b/Sources/Lingo/LocalizationsModel.swift @@ -23,8 +23,8 @@ class LocalizationsModel { func addLocalizations(_ localizations: [LocalizationKey: Localization], `for` locale: LocaleIdentifier) { // Find existing bucket for a given locale or create a new one if var existingLocaleBucket = self.data[locale] { - for (localiationKey, localization) in localizations { - existingLocaleBucket[localiationKey] = localization + for (localizationKey, localization) in localizations { + existingLocaleBucket[localizationKey] = localization self.data[locale] = existingLocaleBucket } } else { @@ -34,7 +34,7 @@ class LocalizationsModel { /// Returns localized string of a given key in the given locale. /// If string contains interpolations, they are replaced from the dictionary. - func localized(key: LocalizationKey, locale: LocaleIdentifier, interpolations: [String: Any]? = nil) -> LocalizationResult { + func localize(_ key: LocalizationKey, locale: LocaleIdentifier, interpolations: [String: Any]? = nil) -> LocalizationResult { guard let localeBucket = self.data[locale] else { return .missingLocale } diff --git a/Sources/Lingo/Pluralization/PluralizationRules+List.swift b/Sources/Lingo/Pluralization/PluralizationRuleStore+List.swift similarity index 100% rename from Sources/Lingo/Pluralization/PluralizationRules+List.swift rename to Sources/Lingo/Pluralization/PluralizationRuleStore+List.swift diff --git a/Tests/LingoTests/LingoTests.swift b/Tests/LingoTests/LingoTests.swift index 940dccd..ac7c808 100644 --- a/Tests/LingoTests/LingoTests.swift +++ b/Tests/LingoTests/LingoTests.swift @@ -11,27 +11,27 @@ class LingoTests: XCTestCase { } func testNonExistingKeyReturnsRawKeyAsLocalization() throws { - let lingo = try Lingo(rootPath: self.localizationsRootPath, defaultLocale: nil) - XCTAssertEqual(lingo.localized("non.existing.key", locale: "en"), "non.existing.key") + let lingo = Lingo(rootPath: self.localizationsRootPath, defaultLocale: nil) + XCTAssertEqual(lingo.localize("non.existing.key", locale: "en"), "non.existing.key") } func testNonExistingLocaleReturnsRawKeyAsLocalizationWhenDefaultLocaleIsNotSpecified() throws { - let lingo = try Lingo(rootPath: self.localizationsRootPath, defaultLocale: nil) - XCTAssertEqual(lingo.localized("hello.world", locale: "non-existing-locale"), "hello.world") + let lingo = Lingo(rootPath: self.localizationsRootPath, defaultLocale: nil) + XCTAssertEqual(lingo.localize("hello.world", locale: "non-existing-locale"), "hello.world") } func testFallbackToDefaultLocale() throws { - let lingo = try Lingo(rootPath: self.localizationsRootPath, defaultLocale: "en") - XCTAssertEqual(lingo.localized("hello.world", locale: "non-existing-locale"), "Hello World!") + let lingo = Lingo(rootPath: self.localizationsRootPath, defaultLocale: "en") + XCTAssertEqual(lingo.localize("hello.world", locale: "non-existing-locale"), "Hello World!") } func testLocalization() throws { - let lingo = try Lingo(rootPath: self.localizationsRootPath, defaultLocale: nil) + let lingo = Lingo(rootPath: self.localizationsRootPath, defaultLocale: nil) - XCTAssertEqual(lingo.localized("hello.world", locale: "en"), "Hello World!") - XCTAssertEqual(lingo.localized("hello.world", locale: "de"), "Hallo Welt!") - XCTAssertEqual(lingo.localized("unread.messages", locale: "en", interpolations: ["unread-messages-count": 1]), "You have an unread message.") - XCTAssertEqual(lingo.localized("unread.messages", locale: "en", interpolations: ["unread-messages-count": 24]), "You have 24 unread messages.") + XCTAssertEqual(lingo.localize("hello.world", locale: "en"), "Hello World!") + XCTAssertEqual(lingo.localize("hello.world", locale: "de"), "Hallo Welt!") + XCTAssertEqual(lingo.localize("unread.messages", locale: "en", interpolations: ["unread-messages-count": 1]), "You have an unread message.") + XCTAssertEqual(lingo.localize("unread.messages", locale: "en", interpolations: ["unread-messages-count": 24]), "You have 24 unread messages.") } }