diff --git a/CHANGELOG.md b/CHANGELOG.md index 141a48c6a..44dfe38a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ - Support Artifact Bundle #1388 @freddi-kit +### Added + +- Added support for String Catalogs (`.xcstrings`) #1421 @nicolasbosi95 + ## 2.38.0 ### Added diff --git a/Sources/ProjectSpec/FileType.swift b/Sources/ProjectSpec/FileType.swift index b6af3a9fa..1629bca59 100644 --- a/Sources/ProjectSpec/FileType.swift +++ b/Sources/ProjectSpec/FileType.swift @@ -72,6 +72,7 @@ extension FileType { "bundle": FileType(buildPhase: .resources), "xcassets": FileType(buildPhase: .resources), "storekit": FileType(buildPhase: .resources), + "xcstrings": FileType(buildPhase: .resources), // sources "swift": FileType(buildPhase: .sources), diff --git a/Sources/XcodeGenKit/SourceGenerator.swift b/Sources/XcodeGenKit/SourceGenerator.swift index 21155156f..ba5cbd0cb 100644 --- a/Sources/XcodeGenKit/SourceGenerator.swift +++ b/Sources/XcodeGenKit/SourceGenerator.swift @@ -455,6 +455,7 @@ class SourceGenerator { let createIntermediateGroups = targetSource.createIntermediateGroups ?? project.options.createIntermediateGroups let nonLocalizedChildren = children.filter { $0.extension != "lproj" } + let stringCatalogChildren = children.filter { $0.extension == "xcstrings" } let directories = nonLocalizedChildren .filter { @@ -520,6 +521,15 @@ class SourceGenerator { }() knownRegions.formUnion(localisedDirectories.map { $0.lastComponentWithoutExtension }) + + // XCode 15 - Detect known regions from locales present in string catalogs + + let stringCatalogsLocales = stringCatalogChildren + .compactMap { StringCatalog(from: $0) } + .reduce(Set(), { partialResult, stringCatalog in + partialResult.union(stringCatalog.includedLocales) + }) + knownRegions.formUnion(stringCatalogsLocales) // create variant groups of the base localisation first var baseLocalisationVariantGroups: [PBXVariantGroup] = [] diff --git a/Sources/XcodeGenKit/StringCatalogDecoding.swift b/Sources/XcodeGenKit/StringCatalogDecoding.swift new file mode 100644 index 000000000..da66c2596 --- /dev/null +++ b/Sources/XcodeGenKit/StringCatalogDecoding.swift @@ -0,0 +1,82 @@ +import Foundation +import JSONUtilities +import PathKit + +struct StringCatalog { + +/** +* Sample string catalog: + +* { +* "sourceLanguage" : "en", +* "strings" : { +* "foo" : { +* "localizations" : { +* "en" : { +* ... +* }, +* "es" : { +* ... +* }, +* "it" : { +* ... +* } +* } +* } +* } +* } +*/ + + private struct CatalogItem { + private enum JSONKeys: String { + case localizations + } + + private let key: String + let locales: Set + + init?(key: String, from jsonDictionary: JSONDictionary) { + guard let localizations = jsonDictionary[JSONKeys.localizations.rawValue] as? JSONDictionary else { + return nil + } + + self.key = key + self.locales = Set(localizations.keys) + } + } + + private enum JSONKeys: String { + case strings + } + + private let strings: [CatalogItem] + + init?(from path: Path) { + guard let catalogDictionary = try? JSONDictionary.from(url: path.url), + let catalog = StringCatalog(from: catalogDictionary) else { + return nil + } + + self = catalog + } + + private init?(from jsonDictionary: JSONDictionary) { + guard let stringsDictionary = jsonDictionary[JSONKeys.strings.rawValue] as? JSONDictionary else { + return nil + } + + self.strings = stringsDictionary.compactMap { key, value -> CatalogItem? in + guard let stringDictionary = value as? JSONDictionary else { + return nil + } + + return CatalogItem(key: key, from: stringDictionary) + } + } + + var includedLocales: Set { + strings.reduce(Set(), { partialResult, catalogItem in + partialResult.union(catalogItem.locales) + }) + } +} diff --git a/Sources/XcodeGenKit/XCProjExtensions.swift b/Sources/XcodeGenKit/XCProjExtensions.swift index d6021668d..ec3f2763b 100644 --- a/Sources/XcodeGenKit/XCProjExtensions.swift +++ b/Sources/XcodeGenKit/XCProjExtensions.swift @@ -61,6 +61,8 @@ extension Xcode { return "wrapper.extensionkit-extension" case ("swiftcrossimport", _): return "wrapper.swiftcrossimport" + case ("xcstrings", _): + return "text.json.xcstrings" default: // fallback to XcodeProj defaults return Xcode.filetype(extension: fileExtension) diff --git a/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj b/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj index 5cdebf96f..7328f510f 100644 --- a/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj +++ b/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj @@ -85,6 +85,7 @@ 4B862F11762F6BB54E97E401 /* MyFramework.h in Headers */ = {isa = PBXBuildFile; fileRef = 576675973B56A96047CB4944 /* MyFramework.h */; settings = {ATTRIBUTES = (Public, ); }; }; 4C1504A05321046B3ED7A839 /* Framework2.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AB055761199DF36DB0C629A6 /* Framework2.framework */; }; 4CB673A7C0C11E04F8544BDB /* Contacts.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FDB2B6A77D39CD5602F2125F /* Contacts.framework */; }; + 4CCBDB0492AB3542B2AB6D94 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = AEDB7833B8AE2126630D6FCB /* Localizable.xcstrings */; }; 4DA7140FF84DBF39961F3409 /* NetworkSystemExtension.systemextension in Embed System Extensions */ = {isa = PBXBuildFile; fileRef = 2049B6DD2AFE85F9DC9F3EB3 /* NetworkSystemExtension.systemextension */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 4F6481557E2BEF8D749C37E3 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 187E665975BB5611AF0F27E1 /* main.m */; }; 5126CD91C2CB41C9B14B6232 /* DriverKitDriver.dext in Embed System Extensions */ = {isa = PBXBuildFile; fileRef = 83B5EC7EF81F7E4B6F426D4E /* DriverKitDriver.dext */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -838,6 +839,7 @@ AAA49985DFFE797EE8416887 /* inputList.xcfilelist */ = {isa = PBXFileReference; lastKnownFileType = text.xcfilelist; path = inputList.xcfilelist; sourceTree = ""; }; AB055761199DF36DB0C629A6 /* Framework2.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Framework2.framework; sourceTree = BUILT_PRODUCTS_DIR; }; AEBCA8CFF769189C0D52031E /* App_iOS.xctestplan */ = {isa = PBXFileReference; path = App_iOS.xctestplan; sourceTree = ""; }; + AEDB7833B8AE2126630D6FCB /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; AEEFDE76B5FEC833403C0869 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; B17B8D9C9B391332CD176A35 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LocalizedStoryboard.storyboard; sourceTree = ""; }; B198242976C3395E31FE000A /* MessagesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesViewController.swift; sourceTree = ""; }; @@ -1139,6 +1141,7 @@ 9DB22CB08CFAA455518700DB /* StandaloneFiles */, BDA839814AF73F01F7710518 /* StaticLibrary_ObjC */, CBDAC144248EE9D3838C6AAA /* StaticLibrary_Swift */, + 6E0D17C5B4E6F01B89254309 /* String Catalogs */, 8CFD8AD4820FAB9265663F92 /* Tool */, 4C7F5EB7D6F3E0E9B426AB4A /* Utilities */, 3FEA12CF227D41EF50E5C2DB /* Vendor */, @@ -1257,6 +1260,14 @@ path = App_macOS_Tests; sourceTree = ""; }; + 6E0D17C5B4E6F01B89254309 /* String Catalogs */ = { + isa = PBXGroup; + children = ( + AEDB7833B8AE2126630D6FCB /* Localizable.xcstrings */, + ); + path = "String Catalogs"; + sourceTree = ""; + }; 795B8D70B674C850B57DD39D /* App_watchOS Extension */ = { isa = PBXGroup; children = ( @@ -2594,6 +2605,7 @@ A9548E5DCFE92236494164DF /* LaunchScreen.storyboard in Resources */, 6E8F8303759824631C8D9DA3 /* Localizable.strings in Resources */, E5DD0AD6F7AE1DD4AF98B83E /* Localizable.stringsdict in Resources */, + 4CCBDB0492AB3542B2AB6D94 /* Localizable.xcstrings in Resources */, 2A7EB1A9A365A7EC5D49AFCF /* LocalizedStoryboard.storyboard in Resources */, 49A4B8937BB5520B36EA33F0 /* Main.storyboard in Resources */, 900CFAD929CAEE3861127627 /* MyBundle.bundle in Resources */, diff --git a/Tests/Fixtures/TestProject/String Catalogs/Localizable.xcstrings b/Tests/Fixtures/TestProject/String Catalogs/Localizable.xcstrings new file mode 100644 index 000000000..14c34efb9 --- /dev/null +++ b/Tests/Fixtures/TestProject/String Catalogs/Localizable.xcstrings @@ -0,0 +1,24 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "sampleText" : { + "comment" : "Sample string in an asset catalog", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a localized string" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esta es una cadena de texto localizable." + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Tests/Fixtures/TestProject/project.yml b/Tests/Fixtures/TestProject/project.yml index dcef8e1e0..54b33454f 100644 --- a/Tests/Fixtures/TestProject/project.yml +++ b/Tests/Fixtures/TestProject/project.yml @@ -163,6 +163,7 @@ targets: resourceTags: - tag1 - tag2 + - String Catalogs/Localizable.xcstrings settings: INFOPLIST_FILE: App_iOS/Info.plist PRODUCT_BUNDLE_IDENTIFIER: com.project.app diff --git a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift index 2075abf3b..89405629c 100644 --- a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift @@ -1,7 +1,7 @@ import PathKit import ProjectSpec import Spectre -import XcodeGenKit +@testable import XcodeGenKit import XcodeProj import XCTest import Yams @@ -41,6 +41,13 @@ class SourceGeneratorTests: XCTestCase { try file.write("") } } + + func createFile(at relativePath: Path, content: String) throws -> Path { + let filePath = directoryPath + relativePath + try filePath.parent().mkpath() + try filePath.write(content) + return filePath + } func removeDirectories() { try? directoryPath.delete() @@ -561,6 +568,7 @@ class SourceGeneratorTests: XCTestCase { - file.h - GoogleService-Info.plist - file.xcconfig + - Localizable.xcstrings B: - file.swift - file.xcassets @@ -617,6 +625,7 @@ class SourceGeneratorTests: XCTestCase { try pbxProj.expectFile(paths: ["A", "file.h"], buildPhase: .resources) try pbxProj.expectFile(paths: ["A", "GoogleService-Info.plist"], buildPhase: .resources) try pbxProj.expectFile(paths: ["A", "file.xcconfig"], buildPhase: .resources) + try pbxProj.expectFile(paths: ["A", "Localizable.xcstrings"], buildPhase: .resources) try pbxProj.expectFile(paths: ["B", "file.swift"], buildPhase: BuildPhaseSpec.none) try pbxProj.expectFile(paths: ["B", "file.xcassets"], buildPhase: BuildPhaseSpec.none) @@ -1238,6 +1247,69 @@ class SourceGeneratorTests: XCTestCase { try expect(pbxProj.rootObject!.attributes["knownAssetTags"] as? [String]) == ["tag1", "tag2", "tag3"] } + + $0.it("Detects all locales present in a String Catalog") { + /// This is a catalog with gaps: + /// - String "foo" is translated into English (en) and Spanish (es) + /// - String "bar" is translated into English (en) and Italian (it) + /// + /// It is aimed at representing real world scenarios where translators have not finished translating all strings into their respective languages. + /// The expectation in this kind of cases is that `includedLocales` returns all locales found at least once in the catalog. + /// In this example, `includedLocales` is expected to be a set only containing "en", "es" and "it". + let stringCatalogContent = """ + { + "sourceLanguage" : "en", + "strings" : { + "foo" : { + "comment" : "Sample string in an asset catalog", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Foo English" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Foo Spanish" + } + } + } + }, + "bar" : { + "comment" : "Another sample string in an asset catalog", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bar English" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bar Italian" + } + } + } + } + }, + "version" : "1.0" + } + """ + + let testStringCatalogRelativePath = Path("Localizable.xcstrings") + let testStringCatalogPath = try createFile(at: testStringCatalogRelativePath, content: stringCatalogContent) + + guard let stringCatalog = StringCatalog(from: testStringCatalogPath) else { + throw failure("Failed decoding string catalog from \(testStringCatalogPath)") + } + + try expect(stringCatalog.includedLocales.sorted(by: { $0 < $1 })) == ["en", "es", "it"] + } } } }