├── Gemfile
├── Dangerfile
├── .gitignore
├── .swiftformat
├── Package.resolved
├── .swiftpm
├── configuration
│ └── Package.resolved
└── xcode
│ └── xcshareddata
│ └── xcschemes
│ ├── SwiftPolyglotCoreTests.xcscheme
│ └── SwiftPolyglot.xcscheme
├── .github
└── workflows
│ ├── test.yml
│ └── danger.yml
├── Sources
├── SwiftPolyglot
│ ├── RuntimeError.swift
│ └── SwiftPolyglot.swift
└── SwiftPolyglotCore
│ ├── SwiftPolyglotError.swift
│ ├── MissingTranslation.swift
│ └── SwiftPolyglotCore.swift
├── Tests
└── SwiftPolyglotCoreTests
│ ├── TestFiles
│ ├── WithDontTranslate.xcstrings
│ ├── WithMissingTranslations.xcstrings
│ ├── FullyTranslated.xcstrings
│ ├── VariationsWithMissingTranslations.xcstrings
│ └── VariationsFullyTranslated.xcstrings
│ ├── XCTest+AsyncThrowingExpression.swift
│ └── SwiftPolyglotCoreTests.swift
├── LICENSE.md
├── Package.swift
├── Gemfile.lock
└── README.md
/Gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 |
3 | gem 'danger'
4 | gem 'danger-swiftformat'
5 |
--------------------------------------------------------------------------------
/Dangerfile:
--------------------------------------------------------------------------------
1 | swiftformat.additional_args = "--lint"
2 | swiftformat.check_format(fail_on_error: true)
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
--------------------------------------------------------------------------------
/.swiftformat:
--------------------------------------------------------------------------------
1 | # file options
2 |
3 | # format options
4 |
5 | --commas always
6 | --indent 4
7 | --indentcase true
8 | --swiftversion 5.7
9 | --trimwhitespace always
10 | --ifdef no-indent
11 |
12 | # rules
13 |
14 | --disable preferForLoop
15 |
16 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "swift-argument-parser",
5 | "kind" : "remoteSourceControl",
6 | "location" : "https://github.com/apple/swift-argument-parser.git",
7 | "state" : {
8 | "revision" : "46989693916f56d1186bd59ac15124caef896560",
9 | "version" : "1.3.1"
10 | }
11 | }
12 | ],
13 | "version" : 2
14 | }
15 |
--------------------------------------------------------------------------------
/.swiftpm/configuration/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "swift-argument-parser",
5 | "kind" : "remoteSourceControl",
6 | "location" : "https://github.com/apple/swift-argument-parser.git",
7 | "state" : {
8 | "revision" : "41982a3656a71c768319979febd796c6fd111d5c",
9 | "version" : "1.5.0"
10 | }
11 | }
12 | ],
13 | "version" : 2
14 | }
15 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 |
3 | on:
4 | pull_request:
5 | types: [synchronize, opened, reopened, edited]
6 | workflow_dispatch:
7 |
8 | jobs:
9 | main:
10 | name: Build & Run Tests
11 | runs-on: macOS-latest
12 | steps:
13 | - name: git checkout
14 | uses: actions/checkout@v3
15 |
16 | - name: Run tests
17 | run: xcodebuild test -scheme SwiftPolyglotCoreTests -destination 'platform=macOS'
--------------------------------------------------------------------------------
/Sources/SwiftPolyglot/RuntimeError.swift:
--------------------------------------------------------------------------------
1 | enum RuntimeError: Error {
2 | case coreError(description: String)
3 | case fileListingNotPossible
4 | }
5 |
6 | extension RuntimeError: CustomStringConvertible {
7 | var description: String {
8 | switch self {
9 | case let .coreError(description):
10 | return description
11 | case .fileListingNotPossible:
12 | return "It was not possible to list all files to be checked"
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Sources/SwiftPolyglotCore/SwiftPolyglotError.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | enum SwiftPolyglotError: Error {
4 | case missingTranslations
5 | case unsupportedVariation(variation: String)
6 | }
7 |
8 | extension SwiftPolyglotError: Equatable {}
9 |
10 | extension SwiftPolyglotError: LocalizedError {
11 | public var errorDescription: String? {
12 | switch self {
13 | case .missingTranslations:
14 | return "Error: One or more translations are missing."
15 | case let .unsupportedVariation(variation):
16 | return "Variation type '\(variation)' is not supported. Please create an issue in GitHub"
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Tests/SwiftPolyglotCoreTests/TestFiles/WithDontTranslate.xcstrings:
--------------------------------------------------------------------------------
1 | {
2 | "sourceLanguage" : "en",
3 | "strings" : {
4 | "butterfly" : {
5 | "extractionState" : "manual",
6 | "localizations" : {
7 | "en" : {
8 | "stringUnit" : {
9 | "state" : "translated",
10 | "value" : "butterfly"
11 | }
12 | }
13 | },
14 | "shouldTranslate" : false
15 | },
16 | "football" : {
17 | "extractionState" : "manual",
18 | "localizations" : {
19 | "en" : {
20 | "stringUnit" : {
21 | "state" : "translated",
22 | "value" : "football"
23 | }
24 | }
25 | },
26 | "shouldTranslate" : false
27 | }
28 | },
29 | "version" : "1.0"
30 | }
--------------------------------------------------------------------------------
/Tests/SwiftPolyglotCoreTests/TestFiles/WithMissingTranslations.xcstrings:
--------------------------------------------------------------------------------
1 | {
2 | "sourceLanguage" : "en",
3 | "strings" : {
4 | "butterfly" : {
5 | "extractionState" : "manual",
6 | "localizations" : {
7 | "de" : {
8 | "stringUnit" : {
9 | "state" : "translated",
10 | "value" : "Schmetterling"
11 | }
12 | },
13 | "en" : {
14 | "stringUnit" : {
15 | "state" : "translated",
16 | "value" : "butterfly"
17 | }
18 | }
19 | }
20 | },
21 | "football" : {
22 | "extractionState" : "manual",
23 | "localizations" : {
24 | "en" : {
25 | "stringUnit" : {
26 | "state" : "translated",
27 | "value" : "football"
28 | }
29 | }
30 | }
31 | }
32 | },
33 | "version" : "1.0"
34 | }
--------------------------------------------------------------------------------
/Tests/SwiftPolyglotCoreTests/TestFiles/FullyTranslated.xcstrings:
--------------------------------------------------------------------------------
1 | {
2 | "sourceLanguage" : "en",
3 | "strings" : {
4 | "butterfly" : {
5 | "extractionState" : "manual",
6 | "localizations" : {
7 | "ca" : {
8 | "stringUnit" : {
9 | "state" : "translated",
10 | "value" : "papallona"
11 | }
12 | },
13 | "de" : {
14 | "stringUnit" : {
15 | "state" : "translated",
16 | "value" : "Schmetterling"
17 | }
18 | },
19 | "en" : {
20 | "stringUnit" : {
21 | "state" : "translated",
22 | "value" : "butterfly"
23 | }
24 | },
25 | "es" : {
26 | "stringUnit" : {
27 | "state" : "translated",
28 | "value" : "mariposa"
29 | }
30 | }
31 | }
32 | }
33 | },
34 | "version" : "1.0"
35 | }
--------------------------------------------------------------------------------
/.github/workflows/danger.yml:
--------------------------------------------------------------------------------
1 | name: Danger
2 |
3 | on:
4 | pull_request:
5 | types: [synchronize, opened, reopened, labeled, unlabeled, edited]
6 |
7 | jobs:
8 | main:
9 | name: Review, Lint, Verify
10 | runs-on: macOS-latest
11 | steps:
12 | - name: git checkout
13 | uses: actions/checkout@v3
14 |
15 | - name: ruby versions
16 | run: |
17 | ruby --version
18 | gem --version
19 | bundler --version
20 |
21 | - name: ruby setup
22 | uses: ruby/setup-ruby@v1
23 | with:
24 | ruby-version: 3.3
25 | bundler-cache: true
26 |
27 | # additional steps here, if needed
28 |
29 | - name: danger
30 | env:
31 | DANGER_GITHUB_API_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
32 | DANGER_GITHUB_BEARER_TOKEN: ${{ secrets.DANGER_GITHUB_BEARER_TOKEN }}
33 | run: bundle exec danger --verbose --fail-on-errors=true
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 App Deco Studio Inc.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.7
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "SwiftPolyglot",
8 | platforms: [
9 | .macOS(.v10_15),
10 | ],
11 | products: [
12 | .executable(name: "swiftpolyglot", targets: ["SwiftPolyglot"]),
13 | ],
14 | dependencies: [
15 | .package(url: "https://github.com/apple/swift-argument-parser.git", .upToNextMajor(from: "1.3.1")),
16 | ],
17 | targets: [
18 | .executableTarget(
19 | name: "SwiftPolyglot",
20 | dependencies: [
21 | "SwiftPolyglotCore",
22 | .product(name: "ArgumentParser", package: "swift-argument-parser"),
23 | ]
24 | ),
25 | .target(name: "SwiftPolyglotCore"),
26 | .testTarget(
27 | name: "SwiftPolyglotCoreTests",
28 | dependencies: ["SwiftPolyglotCore"],
29 | resources: [
30 | .copy("TestFiles"),
31 | ]
32 | ),
33 | ]
34 | )
35 |
--------------------------------------------------------------------------------
/Sources/SwiftPolyglot/SwiftPolyglot.swift:
--------------------------------------------------------------------------------
1 | import ArgumentParser
2 | import Foundation
3 | import SwiftPolyglotCore
4 |
5 | @main
6 | struct SwiftPolyglot: AsyncParsableCommand {
7 | static let configuration: CommandConfiguration = .init(commandName: "swiftpolyglot")
8 |
9 | @Flag(help: "Log errors instead of warnings for missing translations.")
10 | private var errorOnMissing = false
11 |
12 | @Argument(help: "Specify the language(s) to be checked.")
13 | private var languages: [String]
14 |
15 | func run() async throws {
16 | guard
17 | let enumerator = FileManager.default.enumerator(atPath: FileManager.default.currentDirectoryPath),
18 | let filePaths = enumerator.allObjects as? [String]
19 | else {
20 | throw RuntimeError.fileListingNotPossible
21 | }
22 |
23 | let swiftPolyglotCore: SwiftPolyglotCore = .init(
24 | filePaths: filePaths,
25 | languageCodes: languages,
26 | logsErrorOnMissingTranslation: errorOnMissing,
27 | isRunningInAGitHubAction: ProcessInfo.processInfo.environment["GITHUB_ACTIONS"] == "true"
28 | )
29 |
30 | do {
31 | try await swiftPolyglotCore.run()
32 | } catch {
33 | throw RuntimeError.coreError(description: error.localizedDescription)
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/Sources/SwiftPolyglotCore/MissingTranslation.swift:
--------------------------------------------------------------------------------
1 | struct MissingTranslation {
2 | enum Category {
3 | case deviceMissingOrNotTranslated(forDevice: String, inLanguage: String)
4 | case missingOrNotTranslated(inLanguage: String)
5 | case missingTranslation(forLanguage: String)
6 | case missingTranslationForAllLanguages
7 | case pluralMissingOrNotTranslated(forPluralForm: String, inLanguage: String)
8 | }
9 |
10 | let category: Category
11 | let filePath: String
12 | let originalString: String
13 | }
14 |
15 | extension MissingTranslation {
16 | var message: String {
17 | switch category {
18 | case let .deviceMissingOrNotTranslated(device, language):
19 | return "'\(originalString)' device '\(device)' is missing or not translated in '\(language)' in file: \(filePath)"
20 | case let .missingOrNotTranslated(language):
21 | return "'\(originalString)' is missing or not translated in '\(language)' in file: \(filePath)"
22 | case let .missingTranslation(language):
23 | return "'\(originalString)' is missing translations for language '\(language)' in file: \(filePath)"
24 | case .missingTranslationForAllLanguages:
25 | return "'\(originalString)' is not translated in any language in file: \(filePath)"
26 | case let .pluralMissingOrNotTranslated(pluralForm, language):
27 | return "'\(originalString)' plural form '\(pluralForm)' is missing or not translated in '\(language)' in file: \(filePath)"
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | GEM
2 | remote: https://rubygems.org/
3 | specs:
4 | addressable (2.8.6)
5 | public_suffix (>= 2.0.2, < 6.0)
6 | base64 (0.2.0)
7 | claide (1.1.0)
8 | claide-plugins (0.9.2)
9 | cork
10 | nap
11 | open4 (~> 1.3)
12 | colored2 (3.1.2)
13 | cork (0.3.0)
14 | colored2 (~> 3.1)
15 | danger (9.4.3)
16 | claide (~> 1.0)
17 | claide-plugins (>= 0.9.2)
18 | colored2 (~> 3.1)
19 | cork (~> 0.1)
20 | faraday (>= 0.9.0, < 3.0)
21 | faraday-http-cache (~> 2.0)
22 | git (~> 1.13)
23 | kramdown (~> 2.3)
24 | kramdown-parser-gfm (~> 1.0)
25 | no_proxy_fix
26 | octokit (>= 4.0)
27 | terminal-table (>= 1, < 4)
28 | danger-plugin-api (1.0.0)
29 | danger (> 2.0)
30 | danger-swiftformat (0.9.0)
31 | danger-plugin-api (~> 1.0)
32 | faraday (2.9.0)
33 | faraday-net_http (>= 2.0, < 3.2)
34 | faraday-http-cache (2.5.1)
35 | faraday (>= 0.8)
36 | faraday-net_http (3.1.0)
37 | net-http
38 | git (1.19.1)
39 | addressable (~> 2.8)
40 | rchardet (~> 1.8)
41 | kramdown (2.4.0)
42 | rexml
43 | kramdown-parser-gfm (1.1.0)
44 | kramdown (~> 2.0)
45 | nap (1.1.0)
46 | net-http (0.4.1)
47 | uri
48 | no_proxy_fix (0.1.2)
49 | octokit (8.1.0)
50 | base64
51 | faraday (>= 1, < 3)
52 | sawyer (~> 0.9)
53 | open4 (1.3.4)
54 | public_suffix (5.0.4)
55 | rchardet (1.8.0)
56 | rexml (3.2.6)
57 | sawyer (0.9.2)
58 | addressable (>= 2.3.5)
59 | faraday (>= 0.17.3, < 3)
60 | terminal-table (3.0.2)
61 | unicode-display_width (>= 1.1.1, < 3)
62 | unicode-display_width (2.5.0)
63 | uri (0.13.0)
64 |
65 | PLATFORMS
66 | arm64-darwin-23
67 | ruby
68 |
69 | DEPENDENCIES
70 | danger
71 | danger-swiftformat
72 |
73 | BUNDLED WITH
74 | 2.5.6
75 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/SwiftPolyglotCoreTests.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
16 |
18 |
24 |
25 |
26 |
27 |
28 |
38 |
39 |
45 |
46 |
48 |
49 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/Tests/SwiftPolyglotCoreTests/XCTest+AsyncThrowingExpression.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | /// Asserts that an asynchronous expression do not throw an error.
4 | /// (Intended to function as a drop-in asynchronous version of `XCTAssertNoThrow`.)
5 | ///
6 | /// Example usage:
7 | ///
8 | /// await assertNoThrowAsync(sut.function)
9 | ///
10 | /// - Parameters:
11 | /// - expression: An asynchronous expression that can throw an error.
12 | /// - failureMessage: An optional description of a failure.
13 | /// - file: The file where the failure occurs. The default is the file path of the test case where this function is being called.
14 | /// - line: The line number where the failure occurs. The default is the line number where this function is being called.
15 | public func XCTAssertNoThrowAsync(
16 | _ expression: () async throws -> some Any,
17 | failureMessage: String = "Asynchronous call did throw an error.",
18 | file: StaticString = #filePath,
19 | line: UInt = #line
20 | ) async {
21 | do {
22 | _ = try await expression()
23 | } catch {
24 | XCTFail(failureMessage, file: file, line: line)
25 | }
26 | }
27 |
28 | /// Asserts that an asynchronous expression throws an error.
29 | /// (Intended to function as a drop-in asynchronous version of `XCTAssertThrowsError`.)
30 | ///
31 | /// Example usage:
32 | ///
33 | /// await assertThrowsAsyncError(sut.function, MyError.specificError)
34 | ///
35 | /// - Parameters:
36 | /// - expression: An asynchronous expression that can throw an error.
37 | /// - errorThrown: The error type that should be thrown.
38 | /// - failureMessage: An optional description of a failure.
39 | /// - file: The file where the failure occurs. The default is the file path of the test case where this function is being called.
40 | /// - line: The line number where the failure occurs. The default is the line number where this function is being called.
41 | ///
42 | /// from: https://arturgruchala.com/testing-async-await-exceptions/
43 | func XCTAssertThrowsErrorAsync(
44 | _ expression: () async throws -> some Any,
45 | _ errorThrown: E,
46 | failureMessage: String = "Asynchronous call did not throw an error.",
47 | file: StaticString = #filePath,
48 | line: UInt = #line
49 | ) async where E: Equatable, E: Error {
50 | do {
51 | _ = try await expression()
52 | XCTFail(failureMessage, file: file, line: line)
53 | } catch {
54 | XCTAssertEqual(
55 | error as? E,
56 | errorThrown,
57 | "Asynchronous call did not throw the given error \"\(errorThrown)\".",
58 | file: file,
59 | line: line
60 | )
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## What is SwiftPolyglot?
2 |
3 | SwiftPolyglot is a handy script that checks that all of your `.xcstrings` include translations for the languages you specify. If you're working on an app that supports more than one language, there's a good chance you might forget to translate new strings.
4 |
5 | SwiftPolyglot will ensure that:
6 | - Translations are provided for every language that you specify
7 | - Translations are in a `translated` state
8 |
9 | **Note:** SwiftPolyglot was created to fulfil a requirement for my apps. As such, I have only added what is needed for my use case. I welcome any and all contributions here to make SwiftPolyglot more flexible and work for even more use cases.
10 |
11 | ## Installation
12 |
13 | Right now, SwiftPolyglot can only be used from the command line. This gives you the flexibility to run it manually or integrate it with another toolchain.
14 |
15 | To do this, you'll need to follow these steps:
16 |
17 | 1. Clone the repository and build the package locally:
18 |
19 | ```
20 | $ git clone https://github.com/appdecostudio/SwiftPolyglot
21 | $ cd SwiftPolyglot
22 | $ swift build -c release
23 | ```
24 |
25 | 2. Run against your project:
26 |
27 | ```
28 | $ cd ../path/to/your/project
29 | $ swift run --package-path ../path/to/SwiftPolyglot swiftpolyglot en es de
30 | ```
31 |
32 | ## Arguments
33 |
34 | You must specify at least one language code, and they must be separated by spaces. If you are not providing a translation for your language of origin, you do not need to specify that language. Otherwise, you will get errors due to missing translations.
35 |
36 | By default, SwiftPolyglot will not throw an error at the end of the script if there are translations missing. However, you can enable error throwing by adding the flag `--error-on-missing`
37 |
38 | ## Integrating with GitHub Actions
39 |
40 | Here is a sample GitHub action .yml file that you can use to automatically run SwiftPolyglot. Feel free to modify this for your needs.
41 |
42 | ```
43 | name: Run SwiftPolyglot
44 |
45 | on:
46 | pull_request:
47 | types: [synchronize, opened, reopened, labeled, unlabeled, edited]
48 |
49 | jobs:
50 | main:
51 | name: Validate Translations
52 | runs-on: macOS-latest
53 | steps:
54 | - name: git checkout
55 | uses: actions/checkout@v3
56 |
57 | - name: Clone SwiftPolyglot
58 | run: git clone https://github.com/appdecostudio/SwiftPolyglot.git --branch=0.3.1 ../SwiftPolyglot
59 |
60 | - name: validate translations
61 | run: |
62 | swift build --package-path ../SwiftPolyglot --configuration release
63 | swift run --package-path ../SwiftPolyglot swiftpolyglot es fr de it --error-on-missing
64 | ```
65 |
66 |
--------------------------------------------------------------------------------
/Tests/SwiftPolyglotCoreTests/TestFiles/VariationsWithMissingTranslations.xcstrings:
--------------------------------------------------------------------------------
1 | {
2 | "sourceLanguage" : "en",
3 | "strings" : {
4 | "%lld books" : {
5 | "extractionState" : "manual",
6 | "localizations" : {
7 | "de" : {
8 | "variations" : {
9 | "plural" : {
10 | "one" : {
11 | "stringUnit" : {
12 | "state" : "new",
13 | "value" : ""
14 | }
15 | },
16 | "other" : {
17 | "stringUnit" : {
18 | "state" : "translated",
19 | "value" : "%lld Bücher"
20 | }
21 | }
22 | }
23 | }
24 | },
25 | "en" : {
26 | "variations" : {
27 | "plural" : {
28 | "one" : {
29 | "stringUnit" : {
30 | "state" : "translated",
31 | "value" : "%lld book"
32 | }
33 | },
34 | "other" : {
35 | "stringUnit" : {
36 | "state" : "translated",
37 | "value" : "%lld books"
38 | }
39 | }
40 | }
41 | }
42 | }
43 | }
44 | },
45 | "Tap" : {
46 | "extractionState" : "manual",
47 | "localizations" : {
48 | "de" : {
49 | "variations" : {
50 | "device" : {
51 | "appletv" : {
52 | "stringUnit" : {
53 | "state" : "new",
54 | "value" : ""
55 | }
56 | },
57 | "mac" : {
58 | "stringUnit" : {
59 | "state" : "new",
60 | "value" : ""
61 | }
62 | },
63 | "other" : {
64 | "stringUnit" : {
65 | "state" : "translated",
66 | "value" : "Tap"
67 | }
68 | }
69 | }
70 | }
71 | },
72 | "en" : {
73 | "variations" : {
74 | "device" : {
75 | "appletv" : {
76 | "stringUnit" : {
77 | "state" : "translated",
78 | "value" : "Press"
79 | }
80 | },
81 | "mac" : {
82 | "stringUnit" : {
83 | "state" : "translated",
84 | "value" : "Click"
85 | }
86 | },
87 | "other" : {
88 | "stringUnit" : {
89 | "state" : "new",
90 | "value" : "Tap"
91 | }
92 | }
93 | }
94 | }
95 | }
96 | }
97 | }
98 | },
99 | "version" : "1.0"
100 | }
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/SwiftPolyglot.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
29 |
35 |
36 |
37 |
38 |
39 |
45 |
46 |
48 |
54 |
55 |
56 |
57 |
58 |
68 |
70 |
76 |
77 |
78 |
79 |
85 |
87 |
93 |
94 |
95 |
96 |
98 |
99 |
102 |
103 |
104 |
--------------------------------------------------------------------------------
/Tests/SwiftPolyglotCoreTests/SwiftPolyglotCoreTests.swift:
--------------------------------------------------------------------------------
1 | @testable import SwiftPolyglotCore
2 | import XCTest
3 |
4 | final class SwiftPolyglotCoreTests: XCTestCase {
5 | func testStringCatalogFullyTranslated() async throws {
6 | guard
7 | let stringCatalogFilePath = Bundle.module.path(
8 | forResource: "FullyTranslated",
9 | ofType: ".xcstrings",
10 | inDirectory: "TestFiles"
11 | )
12 | else {
13 | XCTFail("Fully translated string catalog for testing not found")
14 | return
15 | }
16 |
17 | let swiftPolyglotCore: SwiftPolyglotCore = .init(
18 | filePaths: [stringCatalogFilePath],
19 | languageCodes: ["ca", "de", "en", "es"],
20 | logsErrorOnMissingTranslation: false,
21 | isRunningInAGitHubAction: false
22 | )
23 |
24 | await XCTAssertNoThrowAsync(swiftPolyglotCore.run)
25 | }
26 |
27 | func testStringCatalogWithDontTranslate() async throws {
28 | guard
29 | let stringCatalogFilePath = Bundle.module.path(
30 | forResource: "WithDontTranslate",
31 | ofType: ".xcstrings",
32 | inDirectory: "TestFiles"
33 | )
34 | else {
35 | XCTFail("Dont translate string catalog for testing not found")
36 | return
37 | }
38 |
39 | let swiftPolyglotCore: SwiftPolyglotCore = .init(
40 | filePaths: [stringCatalogFilePath],
41 | languageCodes: ["de", "en"],
42 | logsErrorOnMissingTranslation: true,
43 | isRunningInAGitHubAction: false
44 | )
45 |
46 | await XCTAssertNoThrowAsync(swiftPolyglotCore.run)
47 | }
48 |
49 | func testStringCatalogVariationsFullyTranslated() async throws {
50 | guard
51 | let stringCatalogFilePath = Bundle.module.path(
52 | forResource: "VariationsFullyTranslated",
53 | ofType: ".xcstrings",
54 | inDirectory: "TestFiles"
55 | )
56 | else {
57 | XCTFail("Variations fully translated string catalog for testing not found")
58 | return
59 | }
60 |
61 | let swiftPolyglotCore: SwiftPolyglotCore = .init(
62 | filePaths: [stringCatalogFilePath],
63 | languageCodes: ["ca", "de", "en", "es"],
64 | logsErrorOnMissingTranslation: false,
65 | isRunningInAGitHubAction: false
66 | )
67 |
68 | await XCTAssertNoThrowAsync(swiftPolyglotCore.run)
69 | }
70 |
71 | func testStringCatalogWithMissingTranslations() async throws {
72 | guard
73 | let stringCatalogFilePath = Bundle.module.path(
74 | forResource: "WithMissingTranslations",
75 | ofType: ".xcstrings",
76 | inDirectory: "TestFiles"
77 | )
78 | else {
79 | XCTFail("String catalog with missing translations for testing not found")
80 | return
81 | }
82 |
83 | let swiftPolyglotCore: SwiftPolyglotCore = .init(
84 | filePaths: [stringCatalogFilePath],
85 | languageCodes: ["ca", "de", "en", "es"],
86 | logsErrorOnMissingTranslation: true,
87 | isRunningInAGitHubAction: false
88 | )
89 |
90 | await XCTAssertThrowsErrorAsync(swiftPolyglotCore.run, SwiftPolyglotError.missingTranslations)
91 | }
92 |
93 | func testStringCatalogWithMissingVariations() async throws {
94 | guard
95 | let stringCatalogFilePath = Bundle.module.path(
96 | forResource: "VariationsWithMissingTranslations",
97 | ofType: ".xcstrings",
98 | inDirectory: "TestFiles"
99 | )
100 | else {
101 | XCTFail("String catalog with missing variations translations for testing not found")
102 | return
103 | }
104 |
105 | let swiftPolyglotCore: SwiftPolyglotCore = .init(
106 | filePaths: [stringCatalogFilePath],
107 | languageCodes: ["de, en"],
108 | logsErrorOnMissingTranslation: true,
109 | isRunningInAGitHubAction: false
110 | )
111 |
112 | await XCTAssertThrowsErrorAsync(swiftPolyglotCore.run, SwiftPolyglotError.missingTranslations)
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/Tests/SwiftPolyglotCoreTests/TestFiles/VariationsFullyTranslated.xcstrings:
--------------------------------------------------------------------------------
1 | {
2 | "sourceLanguage" : "en",
3 | "strings" : {
4 | "%lld Books" : {
5 | "extractionState" : "manual",
6 | "localizations" : {
7 | "ca" : {
8 | "variations" : {
9 | "plural" : {
10 | "one" : {
11 | "stringUnit" : {
12 | "state" : "translated",
13 | "value" : "%lld llibre"
14 | }
15 | },
16 | "other" : {
17 | "stringUnit" : {
18 | "state" : "translated",
19 | "value" : "%lld Llibres"
20 | }
21 | },
22 | "zero" : {
23 | "stringUnit" : {
24 | "state" : "translated",
25 | "value" : "Sense Llibres"
26 | }
27 | }
28 | }
29 | }
30 | },
31 | "de" : {
32 | "variations" : {
33 | "plural" : {
34 | "one" : {
35 | "stringUnit" : {
36 | "state" : "translated",
37 | "value" : "%lld Buch"
38 | }
39 | },
40 | "other" : {
41 | "stringUnit" : {
42 | "state" : "translated",
43 | "value" : "%lld Bücher"
44 | }
45 | },
46 | "zero" : {
47 | "stringUnit" : {
48 | "state" : "translated",
49 | "value" : "Keine Bücher"
50 | }
51 | }
52 | }
53 | }
54 | },
55 | "en" : {
56 | "variations" : {
57 | "plural" : {
58 | "one" : {
59 | "stringUnit" : {
60 | "state" : "translated",
61 | "value" : "%lld Book"
62 | }
63 | },
64 | "other" : {
65 | "stringUnit" : {
66 | "state" : "new",
67 | "value" : "%lld Books"
68 | }
69 | },
70 | "zero" : {
71 | "stringUnit" : {
72 | "state" : "translated",
73 | "value" : "No Books"
74 | }
75 | }
76 | }
77 | }
78 | },
79 | "fr" : {
80 | "variations" : {
81 | "plural" : {
82 | "one" : {
83 | "stringUnit" : {
84 | "state" : "translated",
85 | "value" : "%lld Livre"
86 | }
87 | },
88 | "other" : {
89 | "stringUnit" : {
90 | "state" : "translated",
91 | "value" : "%lld Livres"
92 | }
93 | },
94 | "zero" : {
95 | "stringUnit" : {
96 | "state" : "translated",
97 | "value" : "Pas de livres"
98 | }
99 | }
100 | }
101 | }
102 | }
103 | }
104 | },
105 | "Tap" : {
106 | "extractionState" : "manual",
107 | "localizations" : {
108 | "ca" : {
109 | "variations" : {
110 | "device" : {
111 | "appletv" : {
112 | "stringUnit" : {
113 | "state" : "translated",
114 | "value" : "Premeu"
115 | }
116 | },
117 | "iphone" : {
118 | "stringUnit" : {
119 | "state" : "translated",
120 | "value" : "Tocar"
121 | }
122 | },
123 | "mac" : {
124 | "stringUnit" : {
125 | "state" : "translated",
126 | "value" : "Feu clic"
127 | }
128 | },
129 | "other" : {
130 | "stringUnit" : {
131 | "state" : "translated",
132 | "value" : "Tocar"
133 | }
134 | }
135 | }
136 | }
137 | },
138 | "de" : {
139 | "variations" : {
140 | "device" : {
141 | "appletv" : {
142 | "stringUnit" : {
143 | "state" : "translated",
144 | "value" : "Presse"
145 | }
146 | },
147 | "iphone" : {
148 | "stringUnit" : {
149 | "state" : "translated",
150 | "value" : "Tippen"
151 | }
152 | },
153 | "mac" : {
154 | "stringUnit" : {
155 | "state" : "translated",
156 | "value" : "Klick"
157 | }
158 | },
159 | "other" : {
160 | "stringUnit" : {
161 | "state" : "translated",
162 | "value" : "Tippen"
163 | }
164 | }
165 | }
166 | }
167 | },
168 | "en" : {
169 | "variations" : {
170 | "device" : {
171 | "appletv" : {
172 | "stringUnit" : {
173 | "state" : "translated",
174 | "value" : "Press"
175 | }
176 | },
177 | "iphone" : {
178 | "stringUnit" : {
179 | "state" : "translated",
180 | "value" : "Tap"
181 | }
182 | },
183 | "mac" : {
184 | "stringUnit" : {
185 | "state" : "translated",
186 | "value" : "Click"
187 | }
188 | },
189 | "other" : {
190 | "stringUnit" : {
191 | "state" : "new",
192 | "value" : "Tap"
193 | }
194 | }
195 | }
196 | }
197 | },
198 | "fr" : {
199 | "variations" : {
200 | "device" : {
201 | "appletv" : {
202 | "stringUnit" : {
203 | "state" : "translated",
204 | "value" : "Presse"
205 | }
206 | },
207 | "iphone" : {
208 | "stringUnit" : {
209 | "state" : "translated",
210 | "value" : "Toucher"
211 | }
212 | },
213 | "mac" : {
214 | "stringUnit" : {
215 | "state" : "translated",
216 | "value" : "Clique sur"
217 | }
218 | },
219 | "other" : {
220 | "stringUnit" : {
221 | "state" : "translated",
222 | "value" : "Toucher"
223 | }
224 | }
225 | }
226 | }
227 | }
228 | }
229 | }
230 | },
231 | "version" : "1.0"
232 | }
--------------------------------------------------------------------------------
/Sources/SwiftPolyglotCore/SwiftPolyglotCore.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public struct SwiftPolyglotCore {
4 | private let filePaths: [String]
5 | private let languageCodes: [String]
6 | private let logsErrorOnMissingTranslation: Bool
7 | private let isRunningInAGitHubAction: Bool
8 |
9 | public init(
10 | filePaths: [String],
11 | languageCodes: [String],
12 | logsErrorOnMissingTranslation: Bool,
13 | isRunningInAGitHubAction: Bool
14 | ) {
15 | self.filePaths = filePaths
16 | self.languageCodes = languageCodes
17 | self.logsErrorOnMissingTranslation = logsErrorOnMissingTranslation
18 | self.isRunningInAGitHubAction = isRunningInAGitHubAction
19 | }
20 |
21 | public func run() async throws {
22 | let stringCatalogFileURLs: [URL] = getStringCatalogURLs(from: filePaths)
23 |
24 | let missingTranslations: [MissingTranslation] = try await withThrowingTaskGroup(of: [MissingTranslation].self) { taskGroup in
25 | for fileURL in stringCatalogFileURLs {
26 | taskGroup.addTask {
27 | let strings: [String: [String: Any]] = extractStrings(
28 | from: fileURL,
29 | isRunningInAGitHubAction: isRunningInAGitHubAction
30 | )
31 |
32 | let missingTranslations: [MissingTranslation] = try await getMissingTranslations(from: strings, in: fileURL.path)
33 |
34 | let missingTranslationsLogs: [String] = missingTranslations.map { missingTranslation in
35 | if isRunningInAGitHubAction {
36 | return logForGitHubAction(
37 | missingTranslation: missingTranslation,
38 | logWithError: logsErrorOnMissingTranslation
39 | )
40 | } else {
41 | return missingTranslation.message
42 | }
43 | }
44 |
45 | missingTranslationsLogs.forEach { print($0) }
46 |
47 | return missingTranslations
48 | }
49 | }
50 |
51 | return try await taskGroup.reduce(into: [MissingTranslation]()) { partialResult, missingTranslations in
52 | partialResult.append(contentsOf: missingTranslations)
53 | }
54 | }
55 |
56 | if !missingTranslations.isEmpty, logsErrorOnMissingTranslation {
57 | throw SwiftPolyglotError.missingTranslations
58 | } else if !missingTranslations.isEmpty {
59 | print("Completed with missing translations.")
60 | } else {
61 | print("All translations are present.")
62 | }
63 | }
64 |
65 | private func extractStrings(from fileURL: URL, isRunningInAGitHubAction: Bool) -> [String: [String: Any]] {
66 | guard
67 | let data = try? Data(contentsOf: fileURL),
68 | let jsonObject = try? JSONSerialization.jsonObject(with: data),
69 | let jsonDict = jsonObject as? [String: Any],
70 | let strings = jsonDict["strings"] as? [String: [String: Any]]
71 | else {
72 | if isRunningInAGitHubAction {
73 | print("::warning file=\(fileURL.path)::Could not process file at path: \(fileURL.path)")
74 | } else {
75 | print("Could not process file at path: \(fileURL.path)")
76 | }
77 |
78 | return [:]
79 | }
80 |
81 | return strings
82 | }
83 |
84 | private func getMissingTranslations(
85 | from strings: [String: [String: Any]],
86 | in filePath: String
87 | ) async throws -> [MissingTranslation] {
88 | var missingTranslations: [MissingTranslation] = []
89 |
90 | for (originalString, translations) in strings {
91 | if let shouldTranslate = translations["shouldTranslate"] as? Bool, shouldTranslate == false {
92 | continue
93 | }
94 |
95 | guard let localizations = translations["localizations"] as? [String: [String: Any]] else {
96 | missingTranslations.append(
97 | MissingTranslation(
98 | category: .missingTranslationForAllLanguages,
99 | filePath: filePath,
100 | originalString: originalString
101 | )
102 | )
103 |
104 | continue
105 | }
106 |
107 | for lang in languageCodes {
108 | guard let languageDict = localizations[lang] else {
109 | missingTranslations.append(
110 | MissingTranslation(
111 | category: .missingTranslation(forLanguage: lang),
112 | filePath: filePath,
113 | originalString: originalString
114 | )
115 | )
116 |
117 | continue
118 | }
119 |
120 | if let variations = languageDict["variations"] as? [String: [String: [String: Any]]] {
121 | try missingTranslations.append(
122 | contentsOf:
123 | getMissingTranslationsFromVariations(
124 | variations,
125 | originalString: originalString,
126 | lang: lang,
127 | filePath: filePath
128 | )
129 | )
130 | } else if
131 | let stringUnit = languageDict["stringUnit"] as? [String: Any],
132 | let state = stringUnit["state"] as? String,
133 | state != "translated"
134 | {
135 | missingTranslations.append(
136 | MissingTranslation(
137 | category: .missingOrNotTranslated(inLanguage: lang),
138 | filePath: filePath,
139 | originalString: originalString
140 | )
141 | )
142 | }
143 | }
144 | }
145 |
146 | return missingTranslations
147 | }
148 |
149 | private func getMissingTranslationsFromVariations(
150 | _ variations: [String: [String: [String: Any]]],
151 | originalString: String,
152 | lang: String,
153 | filePath: String
154 | ) throws -> [MissingTranslation] {
155 | var missingTranslations: [MissingTranslation] = []
156 |
157 | for (variationKey, variationDict) in variations {
158 | if variationKey == "plural" {
159 | for (pluralForm, value) in variationDict {
160 | guard
161 | let stringUnit = value["stringUnit"] as? [String: Any],
162 | let state = stringUnit["state"] as? String,
163 | state == "translated"
164 | else {
165 | missingTranslations.append(
166 | MissingTranslation(
167 | category: .pluralMissingOrNotTranslated(forPluralForm: pluralForm, inLanguage: lang),
168 | filePath: filePath,
169 | originalString: originalString
170 | )
171 | )
172 |
173 | continue
174 | }
175 | }
176 | } else if variationKey == "device" {
177 | for (device, value) in variationDict {
178 | guard
179 | let stringUnit = value["stringUnit"] as? [String: Any],
180 | let state = stringUnit["state"] as? String,
181 | state == "translated"
182 | else {
183 | missingTranslations.append(
184 | MissingTranslation(
185 | category: .deviceMissingOrNotTranslated(forDevice: device, inLanguage: lang),
186 | filePath: filePath,
187 | originalString: originalString
188 | )
189 | )
190 |
191 | continue
192 | }
193 | }
194 | } else {
195 | throw SwiftPolyglotError.unsupportedVariation(variation: variationKey)
196 | }
197 | }
198 |
199 | return missingTranslations
200 | }
201 |
202 | private func getStringCatalogURLs(from filePaths: [String]) -> [URL] {
203 | filePaths.compactMap { filePath in
204 | guard filePath.hasSuffix(".xcstrings") else { return nil }
205 |
206 | return URL(fileURLWithPath: filePath)
207 | }
208 | }
209 |
210 | private func logForGitHubAction(missingTranslation: MissingTranslation, logWithError: Bool) -> String {
211 | if logWithError {
212 | return "::error file=\(missingTranslation.filePath)::\(missingTranslation.message)"
213 | } else {
214 | return "::warning file=\(missingTranslation.filePath)::\(missingTranslation.message)"
215 | }
216 | }
217 | }
218 |
--------------------------------------------------------------------------------