├── 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 | --------------------------------------------------------------------------------