Skip to content

Commit

Permalink
Support for Strings Catalogs (Xcode 15) (#1421)
Browse files Browse the repository at this point in the history
* Support for xcode 15 string catalogs

* Add sample string catalog to Test Fixture and basic test to check that asset catalogs are added in the resources build phase

* Restore unintended changes

* Update Pull Request number for 'Support for Strings Catalogs' in changelog

* Update fixture yml generator

* Detect knownRegions based on locales in string catalogs
  • Loading branch information
nicolasbosi95 committed Feb 14, 2024
1 parent 2c15007 commit 2881fcc
Show file tree
Hide file tree
Showing 9 changed files with 209 additions and 1 deletion.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Expand Up @@ -5,6 +5,10 @@

- Support Artifact Bundle #1388 @freddi-kit

### Added

- Added support for String Catalogs (`.xcstrings`) #1421 @nicolasbosi95

## 2.38.0

### Added
Expand Down
1 change: 1 addition & 0 deletions Sources/ProjectSpec/FileType.swift
Expand Up @@ -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),
Expand Down
10 changes: 10 additions & 0 deletions Sources/XcodeGenKit/SourceGenerator.swift
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<String>(), { partialResult, stringCatalog in
partialResult.union(stringCatalog.includedLocales)
})
knownRegions.formUnion(stringCatalogsLocales)

// create variant groups of the base localisation first
var baseLocalisationVariantGroups: [PBXVariantGroup] = []
Expand Down
82 changes: 82 additions & 0 deletions 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<String>

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<String> {
strings.reduce(Set<String>(), { partialResult, catalogItem in
partialResult.union(catalogItem.locales)
})
}
}
2 changes: 2 additions & 0 deletions Sources/XcodeGenKit/XCProjExtensions.swift
Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj
Expand Up @@ -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, ); }; };
Expand Down Expand Up @@ -838,6 +839,7 @@
AAA49985DFFE797EE8416887 /* inputList.xcfilelist */ = {isa = PBXFileReference; lastKnownFileType = text.xcfilelist; path = inputList.xcfilelist; sourceTree = "<group>"; };
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 = "<group>"; };
AEDB7833B8AE2126630D6FCB /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
AEEFDE76B5FEC833403C0869 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
B17B8D9C9B391332CD176A35 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LocalizedStoryboard.storyboard; sourceTree = "<group>"; };
B198242976C3395E31FE000A /* MessagesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesViewController.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1139,6 +1141,7 @@
9DB22CB08CFAA455518700DB /* StandaloneFiles */,
BDA839814AF73F01F7710518 /* StaticLibrary_ObjC */,
CBDAC144248EE9D3838C6AAA /* StaticLibrary_Swift */,
6E0D17C5B4E6F01B89254309 /* String Catalogs */,
8CFD8AD4820FAB9265663F92 /* Tool */,
4C7F5EB7D6F3E0E9B426AB4A /* Utilities */,
3FEA12CF227D41EF50E5C2DB /* Vendor */,
Expand Down Expand Up @@ -1257,6 +1260,14 @@
path = App_macOS_Tests;
sourceTree = "<group>";
};
6E0D17C5B4E6F01B89254309 /* String Catalogs */ = {
isa = PBXGroup;
children = (
AEDB7833B8AE2126630D6FCB /* Localizable.xcstrings */,
);
path = "String Catalogs";
sourceTree = "<group>";
};
795B8D70B674C850B57DD39D /* App_watchOS Extension */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -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 */,
Expand Down
24 changes: 24 additions & 0 deletions 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"
}
1 change: 1 addition & 0 deletions Tests/Fixtures/TestProject/project.yml
Expand Up @@ -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
Expand Down
74 changes: 73 additions & 1 deletion 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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -561,6 +568,7 @@ class SourceGeneratorTests: XCTestCase {
- file.h
- GoogleService-Info.plist
- file.xcconfig
- Localizable.xcstrings
B:
- file.swift
- file.xcassets
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"]
}
}
}
}
Expand Down

0 comments on commit 2881fcc

Please sign in to comment.