Skip to content

Commit 28ff4cc

Browse files
committed
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 8088504 commit 28ff4cc

5 files changed

Lines changed: 150 additions & 15 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/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/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/XcodeColorExporter.swift

Lines changed: 18 additions & 0 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

Tests/XcodeExportTests/XcodeColorExporterTests.swift

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,99 @@ final class XcodeColorExporterTests: XCTestCase {
435435
""")
436436
}
437437

438+
func testExport_withAssetsFolderProvidesNamespace_nilFolderName_throws() throws {
439+
let output = XcodeColorsOutput(
440+
assetsColorsURL: colorsAssetCatalog,
441+
assetsInMainBundle: true,
442+
colorSwiftURL: colorsFile,
443+
assetsFolderProvidesNamespace: true,
444+
assetsFolderName: nil
445+
)
446+
let exporter = XcodeColorExporter(output: output)
447+
448+
XCTAssertThrowsError(try exporter.export(colorPairs: [colorPair1]))
449+
}
450+
451+
func testExport_withAssetsFolderProvidesNamespace_emptyFolderName_throws() throws {
452+
let output = XcodeColorsOutput(
453+
assetsColorsURL: colorsAssetCatalog,
454+
assetsInMainBundle: true,
455+
colorSwiftURL: colorsFile,
456+
assetsFolderProvidesNamespace: true,
457+
assetsFolderName: ""
458+
)
459+
let exporter = XcodeColorExporter(output: output)
460+
461+
XCTAssertThrowsError(try exporter.export(colorPairs: [colorPair1]))
462+
}
463+
464+
func testExport_withAssetsFolderProvidesNamespace_false_usesFunction() throws {
465+
let output = XcodeColorsOutput(
466+
assetsColorsURL: colorsAssetCatalog,
467+
assetsInMainBundle: true,
468+
colorSwiftURL: colorsFile,
469+
assetsFolderProvidesNamespace: false,
470+
assetsFolderName: "MyColors"
471+
)
472+
let exporter = XcodeColorExporter(output: output)
473+
474+
let result = try exporter.export(colorPairs: [colorPair1])
475+
476+
let content = result[0].data
477+
478+
try assertCodeEquals(content, """
479+
\(header)
480+
481+
import UIKit
482+
483+
private class BundleProvider {
484+
static let bundle = Bundle(for: BundleProvider.self)
485+
}
486+
487+
public extension UIColor {
488+
static var colorPair1: UIColor { UIColor(named: #function)! }
489+
}
490+
491+
""")
492+
}
493+
494+
func testExport_withGroupUsingNamespace_andAssetsFolderProvidesNamespace() throws {
495+
let output = XcodeColorsOutput(
496+
assetsColorsURL: colorsAssetCatalog,
497+
assetsInMainBundle: true,
498+
colorSwiftURL: colorsFile,
499+
groupUsingNamespace: true,
500+
assetsFolderProvidesNamespace: true,
501+
assetsFolderName: "MyColors"
502+
)
503+
let exporter = XcodeColorExporter(output: output)
504+
505+
let result = try exporter.export(colorPairs: [colorPair3])
506+
507+
// Verify folder Contents.json has provides-namespace
508+
let folderContents = try XCTUnwrap(result[1].data)
509+
let folderJson = try XCTUnwrap(String(data: folderContents, encoding: .utf8))
510+
XCTAssertTrue(folderJson.contains("provides-namespace"))
511+
512+
// Verify Swift extension uses folder-prefixed originalName
513+
let content = result[0].data
514+
515+
try assertCodeEquals(content, """
516+
\(header)
517+
518+
import UIKit
519+
520+
private class BundleProvider {
521+
static let bundle = Bundle(for: BundleProvider.self)
522+
}
523+
524+
public extension UIColor {
525+
static var backgroundPrimary: UIColor { UIColor(named: "MyColors/background/primary")! }
526+
}
527+
528+
""")
529+
}
530+
438531
func testExportWhenNameIsSwiftKeyword() throws {
439532
let output = XcodeColorsOutput(assetsColorsURL: nil, assetsInMainBundle: true, colorSwiftURL: colorsFile)
440533
let exporter = XcodeColorExporter(output: output)

0 commit comments

Comments
 (0)