├── .gitignore ├── LICENSE ├── Package.swift ├── README.md ├── Sources ├── LingoVapor │ ├── LingoProvider.swift │ ├── LocaleRedirectMiddleware.swift │ └── Request+Locale.swift └── LingoVaporLeaf │ ├── LocaleLinksTag.swift │ ├── LocaleTag.swift │ └── LocalizeTag.swift └── Tests ├── LingoVaporTests └── LingoVaporTests.swift └── LinuxMain.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /*.xcodeproj 4 | Package.resolved 5 | .swiftpm 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Miroslav Kovac 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.2 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "LingoVapor", 7 | platforms: [ 8 | .macOS(.v10_15) 9 | ], 10 | products: [ 11 | .library(name: "LingoVapor", targets: ["LingoVapor"]), 12 | .library(name: "LingoVaporLeaf", targets: ["LingoVaporLeaf"]) 13 | ], 14 | dependencies: [ 15 | .package(url: "https://github.com/vapor/vapor.git", from: "4.27.0"), 16 | .package(url: "https://github.com/vapor/leaf.git", from: "4.0.0"), 17 | .package(url: "https://github.com/miroslavkovac/Lingo.git", from: "4.0.0") 18 | ], 19 | targets: [ 20 | .target(name: "LingoVapor", dependencies: [ 21 | .product(name: "Vapor", package: "vapor"), 22 | .product(name: "Lingo", package: "Lingo") 23 | ]), 24 | .target(name: "LingoVaporLeaf", dependencies: [ 25 | .target(name: "LingoVapor"), 26 | .product(name: "Leaf", package: "leaf") 27 | ]), 28 | .testTarget(name: "LingoVaporTests", dependencies: [ 29 | .target(name: "LingoVapor") 30 | ]) 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lingo Provider 2 | 3 | [![Language](https://img.shields.io/badge/Swift-5-brightgreen.svg)](http://swift.org) 4 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/vapor-community/markdown-provider/master/LICENSE) 5 | 6 | A Vapor provider for [Lingo](https://github.com/miroslavkovac/Lingo) - a pure Swift localization library ready to be used in Server Side Swift projects. 7 | 8 | ## Setup 9 | 10 | ### Add a dependancy 11 | 12 | Add LingoProvider as a dependancy in your `Package.swift` file: 13 | 14 | ```swift 15 | dependencies: [ 16 | ..., 17 | .package(name: "LingoVapor", url: "https://github.com/vapor-community/Lingo-Vapor.git", from: "4.2.0")] 18 | ], 19 | targets: [ 20 | .target(name: "App", dependencies: [ 21 | .product(name: "LingoVapor", package: "Lingo-Vapor") 22 | ``` 23 | 24 | ### Upgrading from version 4.1.0 to version 4.2.0 25 | 26 | The version 4.1.0 uses the new version of [Lingo](https://github.com/miroslavkovac/Lingo) where the format of locale identifiers was changed to match [RFC 5646](https://datatracker.ietf.org/doc/html/rfc5646). Prior to 4.2.0 `_` was used to separate _language code_ and _country code_ in the locale identifier, and now the library uses `-` as per RFC. 27 | 28 | If you were using any locales which include a country code, you would need to rename related translation files to match the new format. 29 | 30 | ### Add the Provider 31 | 32 | In the `configure.swift` simply initialize the `LingoVapor` with a default locale: 33 | 34 | ```swift 35 | import LingoVapor 36 | ... 37 | public func configure(_ app: Application) throws { 38 | ... 39 | app.lingoVapor.configuration = .init(defaultLocale: "en", localizationsDir: "Localizations") 40 | } 41 | ``` 42 | 43 | > The `localizationsDir` can be omitted, as the _Localizations_ is also the default path. Note that this folder should exist under the _workDir_. 44 | 45 | ## Use 46 | 47 | After you have configured the provider, you can use `lingoVapor` service to create `Lingo`: 48 | 49 | ```swift 50 | let lingo = try app.lingoVapor.lingo() 51 | ... 52 | let localizedTitle = lingo.localize("welcome.title", locale: "en") 53 | ``` 54 | 55 | To get the locale of a user out of the request, you can use `request.locale`. This uses a language, which is in the HTTP header and which is in your available locales, if that exists. Otherwise it falls back to the default locale. Now you can use different locales dynamically: 56 | 57 | ```swift 58 | let localizedTitle = lingo.localize("welcome.title", locale: request.locale) 59 | ``` 60 | 61 | When overwriting the requested locale, just write the new locale into the session, e.g. like that: 62 | 63 | ```swift 64 | session.data["locale"] = locale 65 | ``` 66 | 67 | Use the following syntax for defining localizations in a JSON file: 68 | 69 | ```swift 70 | { 71 | "title": "Hello Swift!", 72 | "greeting.message": "Hi %{full-name}!", 73 | "unread.messages": { 74 | "one": "You have one unread message.", 75 | "other": "You have %{count} unread messages." 76 | } 77 | } 78 | ``` 79 | 80 | ### Locale redirection middleware 81 | 82 | In case you want to serv different locales on different subfolders, you can use the `LocaleRedirectMiddleware`. 83 | 84 | Add in `configure.swift`: 85 | ```swift 86 | import LingoVapor 87 | 88 | // Inside `configure(_ app: Application)`: 89 | app.middleware.use(LocaleRedirectMiddleware()) 90 | ``` 91 | 92 | Add in `routes.swift`: 93 | ```swift 94 | import LingoVapor 95 | 96 | // Inside `routes(_ app: Application)`: 97 | app.get("home") { /* ... */ } 98 | app.get(":locale", "home") { /* ... */ } // For each route, add the one prefixed by the `locale` parameter 99 | ``` 100 | 101 | That way, going to `/home/` will redirect you to `//home/` (with `` corresponding to your browser locale), and going to `/fr/home/` will display homepage in french whatever the browser locale is. 102 | 103 | ### Inside Leaf templates 104 | 105 | When using [Leaf](https://github.com/vapor/leaf) as templating engine, you can use `LocalizeTag`, `LocaleTag` and `LocaleLinksTag` from `LingoVaporLeaf` for localization inside the templates. 106 | 107 | Add in `configure.swift`: 108 | ```swift 109 | import LingoVaporLeaf 110 | 111 | // Inside `configure(_ app: Application)`: 112 | app.leaf.tags["localize"] = LocalizeTag() 113 | app.leaf.tags["locale"] = LocaleTag() 114 | app.leaf.tags["localeLinks"] = LocaleLinksTag() 115 | ``` 116 | 117 | Afterwards you can call them inside the Leaf templates: 118 | 119 | ```html 120 | 121 | #localize("thisisthelingokey") 122 | #localize("lingokeywithvariable", "{\"foo\":\"bar\"}") 123 | 124 | 125 | 126 | 127 | 128 | #localeLinks("http://example.com/", "/canonical/path/") 129 | ``` 130 | 131 | ## Learn more 132 | 133 | - [Lingo](https://github.com/miroslavkovac/Lingo) - learn more about the localization file format, pluralization support, and see how you can get the most out of the Lingo. 134 | -------------------------------------------------------------------------------- /Sources/LingoVapor/LingoProvider.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Lingo 3 | 4 | extension Application { 5 | public var lingoVapor: LingoProvider { 6 | .init(application: self) 7 | } 8 | } 9 | 10 | public struct LingoProvider { 11 | let application: Application 12 | 13 | public init(application: Application) { 14 | self.application = application 15 | } 16 | 17 | public func lingo() throws -> Lingo { 18 | let directory = application.directory.workingDirectory 19 | let workDir = directory.hasSuffix("/") ? directory : directory + "/" 20 | let rootPath = workDir + (configuration?.localizationsDir ?? "") 21 | return try Lingo(rootPath: rootPath, defaultLocale: (configuration?.defaultLocale ?? "")) 22 | } 23 | } 24 | 25 | extension LingoProvider { 26 | struct ConfigurationKey: StorageKey { 27 | typealias Value = LingoConfiguration 28 | } 29 | 30 | public var configuration: LingoConfiguration? { 31 | get { application.storage[ConfigurationKey.self] } 32 | nonmutating set { application.storage[ConfigurationKey.self] = newValue } 33 | } 34 | } 35 | 36 | public struct LingoConfiguration { 37 | let defaultLocale, localizationsDir: String 38 | 39 | public init(defaultLocale: String, localizationsDir: String = "Localizations") { 40 | self.defaultLocale = defaultLocale 41 | self.localizationsDir = localizationsDir 42 | } 43 | } 44 | 45 | 46 | -------------------------------------------------------------------------------- /Sources/LingoVapor/LocaleRedirectMiddleware.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | public final class LocaleRedirectMiddleware: Middleware { 4 | public init() {} 5 | public func respond(to request: Request, chainingTo next: Responder) -> EventLoopFuture { 6 | // Check if a language is passed in url 7 | guard let locale = request.parameters.get("locale") 8 | else { 9 | // Redirect corresponding to specified locale 10 | // For example, `/home/` will redirect to `//home/` 11 | return request.eventLoop.makeSucceededFuture( 12 | request.redirect(to: "/\(request.locale)\(request.url.path)") 13 | ) 14 | } 15 | 16 | // Get Lingo instance 17 | guard let lingo = try? request.application.lingoVapor.lingo(), 18 | let locales = try? lingo.dataSource.availableLocales() 19 | else { 20 | return request.eventLoop.makeFailedFuture(Abort( 21 | .internalServerError, reason: "Unable to get Lingo instance" 22 | )) 23 | } 24 | 25 | // Check that the locale is available, else it's a 404 26 | guard locales.contains(locale) 27 | else { 28 | return request.eventLoop.makeFailedFuture(Abort(.notFound)) 29 | } 30 | 31 | // Set request locale from url 32 | // For example, `/fr/home/` will display homepage in French 33 | request.session.data["locale"] = locale 34 | return next.respond(to: request) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/LingoVapor/Request+Locale.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Lingo 3 | 4 | extension Request { 5 | public var locale: String { 6 | if let sessionlocale = session.data["locale"] { 7 | return sessionlocale 8 | } 9 | // Parse locale, ordered from general case to special case 10 | // If nothing helps.. use "en" 11 | var locale: LocaleIdentifier = "en" 12 | 13 | // If Lingo has defaultLocale, use that 14 | if let lingo = try? application.lingoVapor.lingo() { 15 | locale = lingo.defaultLocale 16 | 17 | // If headers provide information, use those 18 | if var langHeader = headers.first(name: .acceptLanguage) { 19 | langHeader = langHeader.components(separatedBy: .whitespaces).joined() 20 | let langValues = langHeader.components(separatedBy: ",").map { h -> (String, String) in 21 | // Core is no longer available with Vapor 4, so we have no way to use `HeaderValue` anymore 22 | //HeaderValue.parse($0) ?? HeaderValue($0, parameters: ["q":"0"]) 23 | let parts = h.split(separator: ";", maxSplits: 1).map(String.init) 24 | return (parts[0], "1") 25 | } 26 | let availableLocales = (try? lingo.dataSource.availableLocales()) ?? [locale] 27 | var localeWeight = 0.0 28 | for lang in langValues { 29 | let q = Double(lang.1) ?? 1 30 | if localeWeight < q { 31 | if availableLocales.contains(lang.0) { 32 | localeWeight = q 33 | locale = lang.0 34 | } else if let baseLang = lang.0.split(separator: "-").first, availableLocales.contains(String(baseLang)) { 35 | localeWeight = q-0.01 36 | locale = String(baseLang) 37 | } 38 | } 39 | } 40 | } 41 | } 42 | 43 | return locale 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/LingoVaporLeaf/LocaleLinksTag.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Leaf 3 | import LingoVapor 4 | 5 | public final class LocaleLinksTag: UnsafeUnescapedLeafTag { 6 | public init() {} 7 | public func render(_ tag: LeafContext) throws -> LeafData { 8 | guard let lingo = try tag.application?.lingoVapor.lingo() 9 | else { 10 | throw Abort(.internalServerError, reason: "Unable to get Lingo instance") 11 | } 12 | let locale = tag.request?.locale ?? lingo.defaultLocale 13 | 14 | guard tag.parameters.count == 2, 15 | let prefix = tag.parameters[0].string, 16 | let suffix = tag.parameters[1].string 17 | else { 18 | throw Abort(.internalServerError, reason: "Wrong parameters count") 19 | } 20 | 21 | let canonical = "\n" 22 | 23 | let alternates = try lingo.dataSource.availableLocales().map { alternate in 24 | "\n" 25 | } 26 | 27 | return LeafData.string(canonical + alternates.joined()) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/LingoVaporLeaf/LocaleTag.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Leaf 3 | import LingoVapor 4 | 5 | public final class LocaleTag: LeafTag { 6 | public init() {} 7 | public func render(_ tag: LeafContext) throws -> LeafData { 8 | guard let lingo = try tag.application?.lingoVapor.lingo() else { 9 | throw Abort(.internalServerError, reason: "Unable to get Lingo instance") 10 | } 11 | return LeafData.string(tag.request?.locale ?? lingo.defaultLocale) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/LingoVaporLeaf/LocalizeTag.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Leaf 3 | import LingoVapor 4 | 5 | public final class LocalizeTag: LeafTag { 6 | public init() {} 7 | public func render(_ tag: LeafContext) throws -> LeafData { 8 | guard let lingo = try tag.application?.lingoVapor.lingo() else { 9 | throw Abort(.internalServerError, reason: "Unable to get Lingo instance") 10 | } 11 | let locale = tag.request?.locale ?? lingo.defaultLocale 12 | 13 | guard let key = tag.parameters.first?.string else { 14 | throw Abort(.internalServerError, reason: "First parameter for localize tag is no string") 15 | } 16 | 17 | if tag.parameters.count == 2 { 18 | guard let body = tag.parameters[1].string, 19 | let bodyData = body.data(using: .utf8), 20 | let interpolations = try? JSONSerialization.jsonObject(with: bodyData, options: []) as? [String: AnyObject] 21 | else { 22 | throw Abort(.internalServerError, reason: "Body of localize tag invalid") 23 | } 24 | return .string(lingo.localize(key, locale: locale, interpolations: interpolations)) 25 | } else { 26 | return .string(lingo.localize(key, locale: locale)) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tests/LingoVaporTests/LingoVaporTests.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import XCTest 3 | @testable import LingoVapor 4 | 5 | class LingoVaporTests: XCTestCase { 6 | 7 | private var application: Application! 8 | 9 | override func setUp() { 10 | super.setUp() 11 | self.application = Application() 12 | } 13 | 14 | override func tearDown() { 15 | self.application.shutdown() 16 | super.tearDown() 17 | } 18 | 19 | func testInitialization() throws { 20 | let lingoProvider = LingoProvider(application: self.application) 21 | lingoProvider.configuration = .init(defaultLocale: "en", localizationsDir: "Localizations") 22 | XCTAssertEqual(lingoProvider.configuration?.defaultLocale, "en") 23 | } 24 | 25 | static var allTests: [(String, (LingoVaporTests) -> () throws -> Void)] = [ 26 | ("testInitialization", testInitialization), 27 | ] 28 | 29 | } 30 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import LingoVaporTests 3 | 4 | XCTMain([ 5 | testCase(LingoVaporTests.allTests), 6 | ]) 7 | --------------------------------------------------------------------------------