Skip to content

Commit 217673a

Browse files
committed
feat(ios): add assetsFolderProvidesNamespace for iOS colors
Enable "provides-namespace" on the assetsFolder directory in xcassets, so colors are accessed with folder prefix (e.g. "FolderName/colorName") to avoid naming conflicts between multiple asset catalogs. fix(ios): validate assetsFolderProvidesNamespace requires non-empty assetsFolder Add config validation to XcodeColorExporter that throws a descriptive error when assetsFolderProvidesNamespace is true but assetsFolderName is nil or empty — preventing silent runtime crashes from mismatched namespace Contents.json and unprefixed Swift color references. - Add Error enum with missingAssetsFolderName case - Add 4 tests: nil/empty folder throws, explicit false, both namespace flags - Update CONFIG.md, DocC iOSColors.md, XcodeExport CLAUDE.md
1 parent 633b1bf commit 217673a

10 files changed

Lines changed: 278 additions & 24 deletions

File tree

CONFIG.md

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,7 @@ colors = new iOS.ColorsEntry {
389389
colorSwift = "./Sources/UIColor+extension.swift"
390390
swiftuiColorSwift = "./Source/Color+extension.swift"
391391
// groupUsingNamespace = false
392+
// assetsFolderProvidesNamespace = false
392393
// syncCodeSyntax = true
393394
// codeSyntaxTemplate = "Color.{name}"
394395
}
@@ -398,16 +399,17 @@ colors = new iOS.ColorsEntry {
398399
(`tokensFileId`, `tokensCollectionName`, `lightModeName`, etc.) for multi-entry configs where each entry specifies its
399400
own source. When using a single entry, the source comes from `common.variablesColors`.
400401

401-
| Field | Type | Required | Description |
402-
| --------------------- | ----------- | -------- | --------------------------------------------------------------------------- |
403-
| `useColorAssets` | `Boolean` | Yes | Export to `.xcassets` (true) or code-only (false) |
404-
| `assetsFolder` | `String?` | No* | Folder inside Assets.xcassets for `.colorset` files |
405-
| `nameStyle` | `NameStyle` | Yes | Name style: `camelCase`, `snake_case`, `PascalCase`, `SCREAMING_SNAKE_CASE` |
406-
| `colorSwift` | `String?` | No | Path to generate UIColor extension file |
407-
| `swiftuiColorSwift` | `String?` | No | Path to generate SwiftUI Color extension file |
408-
| `groupUsingNamespace` | `Boolean?` | No | Group by "/" using Xcode namespaces (default: false) |
409-
| `syncCodeSyntax` | `Boolean?` | No | Sync names to Figma codeSyntax.iOS field |
410-
| `codeSyntaxTemplate` | `String?` | No | Template for codeSyntax, e.g. `"Color.{name}"` |
402+
| Field | Type | Required | Description |
403+
| ------------------------------- | ----------- | -------- | --------------------------------------------------------------------------- |
404+
| `useColorAssets` | `Boolean` | Yes | Export to `.xcassets` (true) or code-only (false) |
405+
| `assetsFolder` | `String?` | No* | Folder inside Assets.xcassets for `.colorset` files |
406+
| `nameStyle` | `NameStyle` | Yes | Name style: `camelCase`, `snake_case`, `PascalCase`, `SCREAMING_SNAKE_CASE` |
407+
| `colorSwift` | `String?` | No | Path to generate UIColor extension file |
408+
| `swiftuiColorSwift` | `String?` | No | Path to generate SwiftUI Color extension file |
409+
| `groupUsingNamespace` | `Boolean?` | No | Group by "/" using Xcode namespaces (default: false) |
410+
| `assetsFolderProvidesNamespace` | `Boolean?` | No | Set `provides-namespace` on assetsFolder (default: false) |
411+
| `syncCodeSyntax` | `Boolean?` | No | Sync names to Figma codeSyntax.iOS field |
412+
| `codeSyntaxTemplate` | `String?` | No | Template for codeSyntax, e.g. `"Color.{name}"` |
411413

412414
*Required when `useColorAssets = true`.
413415

Sources/ExFig-iOS/Export/iOSColorsExporter.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ public struct iOSColorsExporter: ColorsExporter {
128128
colorSwiftURL: entry.colorSwiftURL,
129129
swiftuiColorSwiftURL: entry.swiftuiColorSwiftURL,
130130
groupUsingNamespace: entry.groupUsingNamespace,
131+
assetsFolderProvidesNamespace: entry.assetsFolderProvidesNamespace,
132+
assetsFolderName: entry.assetsFolder,
131133
templatesPath: entry.resolvedTemplatesPath(fallback: platformConfig.templatesPath)
132134
)
133135

Sources/ExFigCLI/ExFig.docc/iOS/iOSColors.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,28 @@ When `groupUsingNamespace = true`, colors are organized in folders:
245245
| `text/primary` | `Colors/text/primary.colorset` | `textPrimary` |
246246
| `background/card` | `Colors/background/card.colorset` | `backgroundCard` |
247247

248+
## Assets Folder Namespace
249+
250+
When `assetsFolderProvidesNamespace = true`, the assets folder itself gets `provides-namespace` in its
251+
`Contents.json`. Colors are then accessed with the folder prefix:
252+
253+
```pkl
254+
colors = new iOS.ColorsEntry {
255+
assetsFolder = "MyColors"
256+
assetsFolderProvidesNamespace = true
257+
// ...
258+
}
259+
```
260+
261+
| Figma Name | Asset Path | Swift Access |
262+
| ---------- | ------------------------------------ | ------------------------------------- |
263+
| `primary` | `MyColors/primary.colorset` | `UIColor(named: "MyColors/primary")` |
264+
| `accent` | `MyColors/accent.colorset` | `Color("MyColors/accent")` |
265+
266+
This is useful when multiple `.xcassets` share color names — the folder prefix disambiguates them.
267+
268+
> Note: `assetsFolder` is required when `assetsFolderProvidesNamespace = true`.
269+
248270
## See Also
249271

250272
- <doc:iOS>

Sources/ExFigCLI/Resources/Schemas/iOS.pkl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ class ColorsEntry extends Common.VariablesSource {
4242
/// Group colors using namespace in asset catalog.
4343
groupUsingNamespace: Boolean?
4444

45+
/// Set "provides-namespace" on the assetsFolder in asset catalog.
46+
/// When true, colors are accessed with folder prefix: "FolderName/colorName".
47+
assetsFolderProvidesNamespace: Boolean?
48+
4549
/// Path to generate UIColor extension Swift file.
4650
colorSwift: String?
4751

Sources/ExFigConfig/Generated/iOS.pkl.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ extension iOS {
6565
/// Group colors using namespace in asset catalog.
6666
public var groupUsingNamespace: Bool?
6767

68+
/// Set "provides-namespace" on the assetsFolder in asset catalog.
69+
/// When true, colors are accessed with folder prefix: "FolderName/colorName".
70+
public var assetsFolderProvidesNamespace: Bool?
71+
6872
/// Path to generate UIColor extension Swift file.
6973
public var colorSwift: String?
7074

@@ -121,6 +125,7 @@ extension iOS {
121125
assetsFolder: String?,
122126
nameStyle: Common.NameStyle,
123127
groupUsingNamespace: Bool?,
128+
assetsFolderProvidesNamespace: Bool?,
124129
colorSwift: String?,
125130
swiftuiColorSwift: String?,
126131
xcassetsPath: String?,
@@ -142,6 +147,7 @@ extension iOS {
142147
self.assetsFolder = assetsFolder
143148
self.nameStyle = nameStyle
144149
self.groupUsingNamespace = groupUsingNamespace
150+
self.assetsFolderProvidesNamespace = assetsFolderProvidesNamespace
145151
self.colorSwift = colorSwift
146152
self.swiftuiColorSwift = swiftuiColorSwift
147153
self.xcassetsPath = xcassetsPath

Sources/XcodeExport/CLAUDE.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,11 @@ XcodeExporterBase # Shared: Jinja template loading, Swift keywo
3232

3333
Each exporter is initialized with an Output struct that declares **what to generate** via optional URLs:
3434

35-
| Output Type | Controls |
36-
| ----------------------- | ------------------------------------------------------------------------------------------------------------ |
37-
| `XcodeColorsOutput` | `assetsColorsURL`, `colorSwiftURL`, `swiftuiColorSwiftURL`, namespace |
38-
| `XcodeImagesOutput` | `assetsFolderURL`, `uiKitImageExtensionURL`, `swiftUIImageExtensionURL`, `codeConnectSwiftURL`, `renderMode` |
39-
| `XcodeTypographyOutput` | Font extension URLs, label directory, label style extension URL |
35+
| Output Type | Controls |
36+
| ----------------------- | ------------------------------------------------------------------------------------------------------------------ |
37+
| `XcodeColorsOutput` | `assetsColorsURL`, `colorSwiftURL`, `swiftuiColorSwiftURL`, `groupUsingNamespace`, `assetsFolderProvidesNamespace` |
38+
| `XcodeImagesOutput` | `assetsFolderURL`, `uiKitImageExtensionURL`, `swiftUIImageExtensionURL`, `codeConnectSwiftURL`, `renderMode` |
39+
| `XcodeTypographyOutput` | Font extension URLs, label directory, label style extension URL |
4040

4141
A `nil` URL means "skip generating that file". This is how callers control which outputs are produced.
4242

Sources/XcodeExport/Model/XcodeColorsOutput.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ public struct XcodeColorsOutput {
1010
public let colorSwiftURL: URL?
1111
public let swiftuiColorSwiftURL: URL?
1212
public let groupUsingNamespace: Bool
13+
public let assetsFolderProvidesNamespace: Bool
14+
public let assetsFolderName: String?
1315
public let templatesPath: URL?
1416

1517
public init(
@@ -21,6 +23,8 @@ public struct XcodeColorsOutput {
2123
colorSwiftURL: URL? = nil,
2224
swiftuiColorSwiftURL: URL? = nil,
2325
groupUsingNamespace: Bool? = nil,
26+
assetsFolderProvidesNamespace: Bool? = nil,
27+
assetsFolderName: String? = nil,
2428
templatesPath: URL? = nil
2529
) {
2630
self.assetsColorsURL = assetsColorsURL
@@ -31,6 +35,8 @@ public struct XcodeColorsOutput {
3135
self.colorSwiftURL = colorSwiftURL
3236
self.swiftuiColorSwiftURL = swiftuiColorSwiftURL
3337
self.groupUsingNamespace = groupUsingNamespace ?? false
38+
self.assetsFolderProvidesNamespace = assetsFolderProvidesNamespace ?? false
39+
self.assetsFolderName = assetsFolderName
3440
self.templatesPath = templatesPath
3541
}
3642
}

Sources/XcodeExport/XcodeColorExporter.swift

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,31 @@ import ExFigCore
22
import Foundation
33

44
public final class XcodeColorExporter: XcodeExporterBase {
5+
enum Error: LocalizedError {
6+
case missingAssetsFolderName
7+
8+
var errorDescription: String? {
9+
"assetsFolderProvidesNamespace requires a non-empty assetsFolder name"
10+
}
11+
12+
var recoverySuggestion: String? {
13+
"Set assetsFolder in your iOS colors entry when using assetsFolderProvidesNamespace"
14+
}
15+
}
16+
517
private let output: XcodeColorsOutput
618

719
public init(output: XcodeColorsOutput) {
820
self.output = output
921
}
1022

1123
public func export(colorPairs: [AssetPair<Color>]) throws -> [FileContents] {
24+
if output.assetsFolderProvidesNamespace {
25+
guard let name = output.assetsFolderName, !name.isEmpty else {
26+
throw Error.missingAssetsFolderName
27+
}
28+
}
29+
1230
var files: [FileContents] = []
1331

1432
// UIKit UIColor extension
@@ -24,7 +42,11 @@ public final class XcodeColorExporter: XcodeExporterBase {
2442
guard let assetCatalogURL = output.assetsColorsURL else { return files }
2543

2644
// Assets.xcassets/Colors/Contents.json
27-
files.append(makeXcodeEmptyFileContents(directoryURL: assetCatalogURL))
45+
if output.assetsFolderProvidesNamespace {
46+
files.append(makeXcodeNamespaceFileContents(directoryURL: assetCatalogURL))
47+
} else {
48+
files.append(makeXcodeEmptyFileContents(directoryURL: assetCatalogURL))
49+
}
2850

2951
// Assets.xcassets/Colors/***.colorset/Contents.json
3052
try files.append(contentsOf: makeAssets(for: colorPairs, assetCatalogURL: assetCatalogURL))
@@ -53,7 +75,7 @@ public final class XcodeColorExporter: XcodeExporterBase {
5375

5476
let name = normalizeName(colorPair.light.name)
5577
obj["name"] = name
56-
obj["originalName"] = colorPair.light.originalName
78+
obj["originalName"] = assetFolderPrefixed(colorPair.light.originalName)
5779

5880
if !useAssets {
5981
let lightComponents = colorPair.light.toRgbComponents()
@@ -73,7 +95,7 @@ public final class XcodeColorExporter: XcodeExporterBase {
7395
"resourceBundleNames": output.resourceBundleNames ?? [],
7496
"colorFromAssetCatalog": useAssets,
7597
"assetsInMainBundle": output.assetsInMainBundle,
76-
"useNamespace": output.groupUsingNamespace,
98+
"useNamespace": output.groupUsingNamespace || output.assetsFolderProvidesNamespace,
7799
"colors": colors,
78100
]
79101

@@ -92,7 +114,7 @@ public final class XcodeColorExporter: XcodeExporterBase {
92114

93115
var obj: [String: Any] = [:]
94116
obj["name"] = name
95-
obj["originalName"] = colorPair.light.originalName
117+
obj["originalName"] = assetFolderPrefixed(colorPair.light.originalName)
96118

97119
if !useAssets {
98120
let lightComponents = colorPair.light.toRgbComponents()
@@ -130,7 +152,7 @@ public final class XcodeColorExporter: XcodeExporterBase {
130152
"addObjcPrefix": output.addObjcAttribute,
131153
"colorFromAssetCatalog": useAssets,
132154
"assetsInMainBundle": output.assetsInMainBundle,
133-
"useNamespace": output.groupUsingNamespace,
155+
"useNamespace": output.groupUsingNamespace || output.assetsFolderProvidesNamespace,
134156
"colors": colors,
135157
]
136158

@@ -142,6 +164,21 @@ public final class XcodeColorExporter: XcodeExporterBase {
142164
)
143165
}
144166

167+
private func assetFolderPrefixed(_ originalName: String) -> String {
168+
if output.assetsFolderProvidesNamespace, let prefix = output.assetsFolderName {
169+
return "\(prefix)/\(originalName)"
170+
}
171+
return originalName
172+
}
173+
174+
private func makeXcodeNamespaceFileContents(directoryURL: URL) -> FileContents {
175+
let contentsJson = XcodeFolderNamespaceContents()
176+
return FileContents(
177+
destination: Destination(directory: directoryURL, file: contentsJson.fileURL),
178+
data: contentsJson.data
179+
)
180+
}
181+
145182
private func makeXcodeEmptyFileContents(directoryURL: URL) -> FileContents {
146183
let contentsJson = XcodeEmptyContents()
147184
return FileContents(

Tests/ExFigTests/Input/EnumBridgingTests.swift

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ final class EnumBridgingTests: XCTestCase {
6060
assetsFolder: nil,
6161
nameStyle: pklStyle,
6262
groupUsingNamespace: nil,
63+
assetsFolderProvidesNamespace: nil,
6364
colorSwift: nil,
6465
swiftuiColorSwift: nil,
6566
xcassetsPath: nil,
@@ -427,6 +428,7 @@ final class EnumBridgingTests: XCTestCase {
427428
assetsFolder: nil,
428429
nameStyle: .camelCase,
429430
groupUsingNamespace: nil,
431+
assetsFolderProvidesNamespace: nil,
430432
colorSwift: nil,
431433
swiftuiColorSwift: nil,
432434
xcassetsPath: nil,
@@ -454,6 +456,7 @@ final class EnumBridgingTests: XCTestCase {
454456
assetsFolder: nil,
455457
nameStyle: .camelCase,
456458
groupUsingNamespace: nil,
459+
assetsFolderProvidesNamespace: nil,
457460
colorSwift: nil,
458461
swiftuiColorSwift: nil,
459462
xcassetsPath: nil,
@@ -481,6 +484,7 @@ final class EnumBridgingTests: XCTestCase {
481484
assetsFolder: nil,
482485
nameStyle: .camelCase,
483486
groupUsingNamespace: nil,
487+
assetsFolderProvidesNamespace: nil,
484488
colorSwift: nil,
485489
swiftuiColorSwift: nil,
486490
xcassetsPath: nil,
@@ -512,6 +516,7 @@ final class EnumBridgingTests: XCTestCase {
512516
assetsFolder: nil,
513517
nameStyle: .camelCase,
514518
groupUsingNamespace: nil,
519+
assetsFolderProvidesNamespace: nil,
515520
colorSwift: nil,
516521
swiftuiColorSwift: nil,
517522
xcassetsPath: nil,
@@ -541,6 +546,7 @@ final class EnumBridgingTests: XCTestCase {
541546
assetsFolder: nil,
542547
nameStyle: .camelCase,
543548
groupUsingNamespace: nil,
549+
assetsFolderProvidesNamespace: nil,
544550
colorSwift: nil,
545551
swiftuiColorSwift: nil,
546552
xcassetsPath: nil,
@@ -570,6 +576,7 @@ final class EnumBridgingTests: XCTestCase {
570576
assetsFolder: nil,
571577
nameStyle: .camelCase,
572578
groupUsingNamespace: nil,
579+
assetsFolderProvidesNamespace: nil,
573580
colorSwift: nil,
574581
swiftuiColorSwift: nil,
575582
xcassetsPath: nil,
@@ -676,7 +683,7 @@ final class EnumBridgingTests: XCTestCase {
676683
func testiOSColorsEntryThrowsOnMissingTokensCollectionName() {
677684
let entry = iOS.ColorsEntry(
678685
useColorAssets: false, assetsFolder: nil, nameStyle: .camelCase,
679-
groupUsingNamespace: nil, colorSwift: nil, swiftuiColorSwift: nil,
686+
groupUsingNamespace: nil, assetsFolderProvidesNamespace: nil, colorSwift: nil, swiftuiColorSwift: nil,
680687
xcassetsPath: nil, templatesPath: nil,
681688
syncCodeSyntax: nil, codeSyntaxTemplate: nil,
682689
tokensFile: nil, tokensFileId: "file123", tokensCollectionName: nil, lightModeName: "Light",
@@ -691,7 +698,7 @@ final class EnumBridgingTests: XCTestCase {
691698
func testiOSColorsEntryThrowsOnEmptyTokensCollectionName() {
692699
let entry = iOS.ColorsEntry(
693700
useColorAssets: false, assetsFolder: nil, nameStyle: .camelCase,
694-
groupUsingNamespace: nil, colorSwift: nil, swiftuiColorSwift: nil,
701+
groupUsingNamespace: nil, assetsFolderProvidesNamespace: nil, colorSwift: nil, swiftuiColorSwift: nil,
695702
xcassetsPath: nil, templatesPath: nil,
696703
syncCodeSyntax: nil, codeSyntaxTemplate: nil,
697704
tokensFile: nil, tokensFileId: "file123", tokensCollectionName: "", lightModeName: "Light",
@@ -706,7 +713,7 @@ final class EnumBridgingTests: XCTestCase {
706713
func testiOSColorsEntryThrowsOnMissingLightModeName() {
707714
let entry = iOS.ColorsEntry(
708715
useColorAssets: false, assetsFolder: nil, nameStyle: .camelCase,
709-
groupUsingNamespace: nil, colorSwift: nil, swiftuiColorSwift: nil,
716+
groupUsingNamespace: nil, assetsFolderProvidesNamespace: nil, colorSwift: nil, swiftuiColorSwift: nil,
710717
xcassetsPath: nil, templatesPath: nil,
711718
syncCodeSyntax: nil, codeSyntaxTemplate: nil,
712719
tokensFile: nil, tokensFileId: "file123", tokensCollectionName: "Collection", lightModeName: nil,
@@ -721,7 +728,7 @@ final class EnumBridgingTests: XCTestCase {
721728
func testiOSColorsEntryThrowsOnEmptyLightModeName() {
722729
let entry = iOS.ColorsEntry(
723730
useColorAssets: false, assetsFolder: nil, nameStyle: .camelCase,
724-
groupUsingNamespace: nil, colorSwift: nil, swiftuiColorSwift: nil,
731+
groupUsingNamespace: nil, assetsFolderProvidesNamespace: nil, colorSwift: nil, swiftuiColorSwift: nil,
725732
xcassetsPath: nil, templatesPath: nil,
726733
syncCodeSyntax: nil, codeSyntaxTemplate: nil,
727734
tokensFile: nil, tokensFileId: "file123", tokensCollectionName: "Collection", lightModeName: "",

0 commit comments

Comments
 (0)