Skip to content

Commit

Permalink
API improvements, added documentation and code cleanup.
Browse files Browse the repository at this point in the history
  • Loading branch information
miroslavkovac committed Aug 21, 2017
1 parent 1aafbd3 commit 44d8cf1
Show file tree
Hide file tree
Showing 8 changed files with 120 additions and 79 deletions.
93 changes: 59 additions & 34 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
</p>

<p align="center">
<a href="#setup">Setup</a>
<a href="#features">Features</a>
• <a href="#setup">Setup</a>
• <a href="#usage">Usage</a>
• <a href="#performance">Performance</a>
• <a href="#tests">Tests</a>
Expand All @@ -17,90 +18,90 @@

**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."
}
}
```

> 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
Expand All @@ -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."
Expand All @@ -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)
Expand All @@ -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
```

Expand All @@ -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]))

```

Expand All @@ -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

Expand Down
43 changes: 27 additions & 16 deletions Sources/Lingo/DataSources/FileDataSource.swift
Original file line number Diff line number Diff line change
@@ -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 [:]
}
Expand Down Expand Up @@ -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
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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]

}
24 changes: 12 additions & 12 deletions Sources/Lingo/Lingo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,45 +14,45 @@ 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)")
return key

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
Expand Down
5 changes: 5 additions & 0 deletions Sources/Lingo/Localization.swift
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
6 changes: 3 additions & 3 deletions Sources/Lingo/LocalizationsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down
Loading

0 comments on commit 44d8cf1

Please sign in to comment.