Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 55 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,21 @@ Tests/ # Test targets mirror source structure

## Code Patterns

### PKL Consumer Config DRY Patterns

Consumer `exfig.pkl` configs can use `local` Mapping + `for`-generators to eliminate entry duplication:

```pkl
local categories: Mapping<String, String> = new { ["FrameName"] = "folder" }
icons = new Listing {
for (frameName, folder in categories) {
new iOS.IconsEntry { figmaFrameName = frameName; assetsFolder = folder; /* ... */ }
}
}
```

`local` properties don't appear in JSON output. Verify refactoring with `pkl eval --format json` diff.

### Modifying Loader Configs (IconsLoaderConfig / ImagesLoaderConfig)

When adding fields to loader configs, update ALL construction sites:
Expand All @@ -190,11 +205,27 @@ When adding fields to loader configs, update ALL construction sites:
2. Context implementations (`Sources/ExFigCLI/Context/*ExportContextImpl.swift`) — direct constructions in `loadIcons`/`loadImages`
3. Test files (`IconsLoaderConfigTests.swift`, `EnumBridgingTests.swift`) — direct init calls

**EnumBridgingTests gotcha:** Entry constructions have TWO indentation levels — 16-space (inside `for` loop)
and 12-space ("defaults to" tests outside loop). A single `replace_all` with fixed indent misses one level.

When adding fields to `FrameSource` (PKL) / `SourceInput` (ExFigCore), also update:

4. Entry bridge methods (`iconsSourceInput()`/`imagesSourceInput()`) in ALL `Sources/ExFig-*/Config/*Entry.swift`
5. Inline `SourceInput(` constructions in exporters (`iOSImagesExporter.svgSourceInput`, `AndroidImagesExporter.loadAndProcessSVG`)
6. "Through" tests in `IconsLoaderConfigTests` — use `source.field` not hardcoded `nil`
7. Download command files: `DownloadOptions.swift` (CLI flag), `DownloadImageLoader.swift` (filter), `DownloadExportHelpers.swift`, `DownloadImages.swift`, `DownloadIcons.swift`
8. `DownloadAll.swift` — pass filter value to both `exportIcons` and `exportImages`
9. Error/warning types with context (`ExFigError`, `ExFigWarning`) — add associated values if needed

### Adding a New Filter Level (e.g., page filtering)

Filter predicate sites that ALL need updating:

1. `ImageLoaderBase.swift` — `fetchImageComponents` (icons + images)
2. `DownloadImageLoader.swift` — `fetchImageComponents`
3. `DownloadExportHelpers.swift` — `AssetExportHelper.fetchComponents`
4. Inline `SourceInput()` constructions in platform exporters (iOS `svgSourceInput`, Android `loadAndProcessSVG`)
5. `DownloadAll.swift` — pass filter value to both `exportIcons` and `exportImages`

### Moving/Renaming PKL Types Between Modules

Expand Down Expand Up @@ -240,6 +271,15 @@ as string literals in ExFigCore inits; use shared constants only within ExFigCLI
3. Register exporter in plugin's `exporters()` method
4. Add export method in `Sources/ExFigCLI/Subcommands/Export/Plugin*Export.swift` (PKL config maps directly to entry types)

### Destination.url Contract (FileContents.swift)

`Destination.url` uses `isFileURL` to choose path strategy:

- `URL(fileURLWithPath:)` → `lastPathComponent` (just filename) — iOS/Android/Web exporters
- `URL(string:)` → `file.path` (preserves subdirectories like `"icons/actions.dart"`) — Flutter exporters

`FileWriter` creates intermediate directories from `destination.url.deletingLastPathComponent()`, not `destination.directory`.

### Modifying Generated Code

Templates are in `Sources/*/Resources/`. Use Stencil syntax. Update tests after changes.
Expand Down Expand Up @@ -302,17 +342,21 @@ NooraUI.formatLink("url", useColors: true) // underlined primary

## Troubleshooting

| Problem | Solution |
| ----------------------- | ---------------------------------------------------------------------------------------- |
| pkl-gen-swift not found | Build from SPM: `swift build --product pkl-gen-swift`, then `.build/debug/pkl-gen-swift` |
| PKL FrameSource change | Update ALL entry init calls in tests (EnumBridgingTests, IconsLoaderConfigTests) |
| Build fails | `swift package clean && swift build` |
| Tests fail | Check `FIGMA_PERSONAL_TOKEN` is set |
| Formatting fails | Run `./bin/mise run setup` to install tools |
| Template errors | Check Stencil syntax and context variables |
| Linux test hangs | Build first: `swift build --build-tests`, then `swift test --skip-build --parallel` |
| Android pathData long | Simplify in Figma or use `--strict-path-validation` |
| PKL parse error 1 | Check `PklError.message` — actual error is in `.message`, not `.localizedDescription` |
| Problem | Solution |
| ------------------------- | -------------------------------------------------------------------------------------------- |
| pkl-gen-swift not found | Build from SPM: `swift build --product pkl-gen-swift`, then `.build/debug/pkl-gen-swift` |
| PKL FrameSource change | Update ALL entry init calls in tests (EnumBridgingTests, IconsLoaderConfigTests) |
| Build fails | `swift package clean && swift build` |
| Tests fail | Check `FIGMA_PERSONAL_TOKEN` is set |
| Formatting fails | Run `./bin/mise run setup` to install tools |
| Template errors | Check Stencil syntax and context variables |
| Linux test hangs | Build first: `swift build --build-tests`, then `swift test --skip-build --parallel` |
| Android pathData long | Simplify in Figma or use `--strict-path-validation` |
| PKL parse error 1 | Check `PklError.message` — actual error is in `.message`, not `.localizedDescription` |
| Test target won't compile | Broken test files block entire target; use `swift test --filter Target.Class` after `build` |
| Test helper JSON decode | `ContainingFrame` uses default Codable (camelCase: `nodeId`, `pageName`), NOT snake_case |
| Web entry test fails | Web entry types use `outputDirectory` field, while Android/Flutter use `output` |
| Logger concatenation err | `Logger.Message` (swift-log) requires interpolation `"\(a) \(b)"`, not concatenation `a + b` |

## Additional Rules

Expand Down
119 changes: 109 additions & 10 deletions CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,8 @@ variablesColors = new Common.VariablesColors {
icons = new Common.Icons {
// [optional] Figma frame name. Default: "Icons"
figmaFrameName = "Icons"
// [optional] Figma page name to filter by (useful when multiple pages share the same frame name)
// figmaPageName = "Outlined"
// [optional] RegExp for icon name validation
nameValidateRegexp = "^(ic)_(\\d\\d)_([a-z0-9_]+)$"
// [optional] Replacement pattern
Expand All @@ -289,6 +291,8 @@ icons = new Common.Icons {
images = new Common.Images {
// [optional] Figma frame name. Default: "Illustrations"
figmaFrameName = "Illustrations"
// [optional] Figma page name to filter by (useful when multiple pages share the same frame name)
// figmaPageName = "Marketing"
// [optional] RegExp for image name validation
nameValidateRegexp = "^(img)_([a-z0-9_]+)$"
// [optional] Replacement pattern
Expand Down Expand Up @@ -318,6 +322,7 @@ All Icons and Images entries across platforms extend `Common.FrameSource`, which
| Field | Type | Default | Description |
| -------------------- | --------- | ------- | ------------------------------------------------------- |
| `figmaFrameName` | `String?` | — | Override Figma frame name for this entry |
| `figmaPageName` | `String?` | — | Filter by Figma page name for this entry |
| `figmaFileId` | `String?` | — | Override Figma file ID for this entry |
| `rtlProperty` | `String?` | `"RTL"` | Figma component property name for RTL variant detection |
| `nameValidateRegexp` | `String?` | — | Regex pattern for name validation |
Expand Down Expand Up @@ -431,7 +436,7 @@ icons = new iOS.IconsEntry {
}
```

`iOS.IconsEntry` extends `Common.FrameSource`, inheriting `figmaFrameName`, `figmaFileId`, `rtlProperty`,
`iOS.IconsEntry` extends `Common.FrameSource`, inheriting `figmaFrameName`, `figmaPageName`, `figmaFileId`, `rtlProperty`,
`nameValidateRegexp`, and `nameReplaceRegexp`.

| Field | Type | Required | Description |
Expand All @@ -448,7 +453,7 @@ icons = new iOS.IconsEntry {
| `renderModeOriginalSuffix` | `String?` | No | Suffix for original render mode |
| `renderModeTemplateSuffix` | `String?` | No | Suffix for template render mode |

**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaPageName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.

### iOS Images

Expand Down Expand Up @@ -489,7 +494,7 @@ images = new iOS.ImagesEntry {
| `renderModeOriginalSuffix` | `String?` | No | Suffix for original render mode |
| `renderModeTemplateSuffix` | `String?` | No | Suffix for template render mode |

**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaPageName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.

**HEIC Options:**

Expand Down Expand Up @@ -647,7 +652,7 @@ icons = new Android.IconsEntry {
| `pathPrecision` | `Int(1-6)?` | No | Coordinate precision for pathData (default: 4) |
| `strictPathValidation` | `Boolean?` | No | Error on pathData > 32,767 bytes (default: false) |

**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaPageName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.

### Android Images

Expand All @@ -672,7 +677,7 @@ images = new Android.ImagesEntry {
| `webpOptions` | `WebpOptions?` | No | WebP encoding options (when format is `"webp"`) |
| `sourceFormat` | `SourceFormat?` | No | Source from Figma: `"png"` (default) or `"svg"` |

**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaPageName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.

**WebP Options:**

Expand Down Expand Up @@ -759,7 +764,7 @@ icons = new Flutter.IconsEntry {
| `className` | `String?` | No | Class name (default: `AppIcons`) |
| `nameStyle` | `NameStyle?` | No | Name style for generated names |

**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaPageName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.

### Flutter Images

Expand Down Expand Up @@ -790,7 +795,7 @@ images = new Flutter.ImagesEntry {
| `sourceFormat` | `SourceFormat?` | No | Source from Figma: `"png"` or `"svg"` |
| `nameStyle` | `NameStyle?` | No | Name style for generated names |

**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaPageName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.

---

Expand Down Expand Up @@ -862,7 +867,7 @@ icons = new Web.IconsEntry {
| `iconSize` | `Int?` | No | Icon size in pixels for viewBox (default: 24) |
| `nameStyle` | `NameStyle?` | No | Name style for generated names |

**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaPageName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.

### Web Images

Expand All @@ -880,7 +885,7 @@ images = new Web.ImagesEntry {
| `assetsDirectory` | `String?` | No | Directory for raw image assets |
| `generateReactComponents` | `Boolean?` | No | Generate React TSX components (default: true) |

**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaPageName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.

---

Expand Down Expand Up @@ -982,7 +987,7 @@ For multi-entry icons and images, per-entry fields fall back to `common` setting
2. `common.icons.figmaFrameName` or `common.images.figmaFrameName`
3. Default: `"Icons"` for icons, `"Illustrations"` for images

The same fallback applies to `nameValidateRegexp`, `nameReplaceRegexp`, and `nameStyle`.
The same fallback applies to `figmaPageName`, `nameValidateRegexp`, `nameReplaceRegexp`, and `nameStyle`.

### Performance

Expand Down Expand Up @@ -1104,6 +1109,100 @@ Without the typed constructor (e.g., `new { ... }` inside a Listing), PKL will r
This pattern applies to all platforms: `iOS.ColorsEntry`, `iOS.IconsEntry`, `iOS.ImagesEntry`,
`Android.ColorsEntry`, `Android.IconsEntry`, `Android.ImagesEntry`, `Flutter.ColorsEntry`, etc.

### DRY Configs with `for`-Generators

When you have many entries that share the same structure (e.g., 15+ icon categories with identical settings except
`figmaFrameName` and `assetsFolder`), use PKL `local` Mapping and `for`-generators to eliminate duplication.

**Define categories as `local` Mapping** — `local` properties are not included in the output:

```pkl
// figmaFrameName → assetsFolder
local iconCategories: Mapping<String, String> = new {
["Actions"] = "Actions"
["Chart"] = "Chart"
["Communication, Media, Art"] = "CommunicationMediaArt"
["Text editor"] = "TextEditor"
// ... more categories
}
```

**Generate entries with `for`:**

```pkl
icons = new Listing {
for (frameName, folder in iconCategories) {
new iOS.IconsEntry {
figmaFrameName = frameName
format = "svg"
xcassetsPath = "./Resources/Icons.xcassets"
assetsFolder = folder
imageSwift = "./Generated/\(folder)Icons.generated.swift"
}
}
}
```

You can mix manual entries and `for`-generators in the same `Listing`:

```pkl
icons = new Listing {
// Manual entries for special cases
new iOS.IconsEntry {
figmaFrameName = "Colored Icons"
renderMode = "default"
// ...
}

// Generated entries for categories with identical settings
for (frameName, folder in iconCategories) {
new iOS.IconsEntry {
figmaFrameName = frameName
assetsFolder = folder
// ...
}
}
}
```

**String interpolation** — use `\(expr)` to build paths from category data:

```pkl
imageSwift = "./Generated/\(folder)Icons.generated.swift"
assetsFolder = "\(folder)Dc" // e.g., "ActionsDc"
```

**Multiple Mappings** for different groups — define separate Mappings when groups need different settings:

```pkl
local allCategories: Mapping<String, String> = new { /* 17 items */ }
local dcCategories: Mapping<String, String> = new { /* 15 items — all except Logo, Template */ }

icons = new Listing {
// Template icons from allCategories
for (frameName, folder in allCategories) {
new iOS.IconsEntry { /* template settings */ }
}
// Double Color icons from dcCategories
for (frameName, folder in dcCategories) {
new iOS.IconsEntry { figmaFileId = "other-file"; renderMode = "default"; /* ... */ }
}
}
```

**Verification** — always verify that the refactored config produces the same output:

```bash
# Save output before refactoring
pkl eval --format json exfig.pkl > before.json

# After refactoring
pkl eval --format json exfig.pkl > after.json

# Compare (order of entries within Listing is preserved)
diff before.json after.json
```

---

## Validation
Expand Down
61 changes: 61 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,67 @@ ios = new iOS.iOSConfig {

Run with: `exfig colors -i project-a.pkl`

## DRY Configs with `for`-Generators

PKL is a programmable language — you can use `for`-generators to eliminate entry duplication. This is especially
useful when you have many icon or image categories with identical settings except for the Figma frame name and output
folder.

**Before** — 34 nearly identical entries (~380 lines):

```pkl
icons = new Listing {
new iOS.IconsEntry {
figmaFrameName = "Actions"
format = "svg"
xcassetsPath = "./Resources/Icons.xcassets"
assetsFolder = "Actions"
imageSwift = "./Generated/ActionsIcons.generated.swift"
}
new iOS.IconsEntry {
figmaFrameName = "Chart"
format = "svg"
xcassetsPath = "./Resources/Icons.xcassets"
assetsFolder = "Chart"
imageSwift = "./Generated/ChartIcons.generated.swift"
}
// ... 32 more identical entries
}
```

**After** — `local` Mapping + `for`-generator (~30 lines):

```pkl
// local properties are NOT included in the output
local iconCategories: Mapping<String, String> = new {
["Actions"] = "Actions"
["Chart"] = "Chart"
["Communication, Media, Art"] = "CommunicationMediaArt"
["Text editor"] = "TextEditor"
// ...
}

icons = new Listing {
for (frameName, folder in iconCategories) {
new iOS.IconsEntry {
figmaFrameName = frameName
format = "svg"
xcassetsPath = "./Resources/Icons.xcassets"
assetsFolder = folder
imageSwift = "./Generated/\(folder)Icons.generated.swift"
}
}
}
```

Key points:

- `local` properties exist only during evaluation — they don't appear in the JSON output
- `\(expr)` is PKL string interpolation for building paths from data
- You can mix manual entries and `for`-generators in the same `Listing`
- Use multiple Mappings for groups with different settings (e.g., template vs. colored icons)
- Always verify with `pkl eval --format json` that the output is unchanged after refactoring

## Validation

Validate your PKL config at any time:
Expand Down
1 change: 1 addition & 0 deletions Sources/ExFig-Android/Config/AndroidIconsEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public extension Android.IconsEntry {
figmaFileId: figmaFileId,
darkFileId: darkFileId,
frameName: figmaFrameName ?? "Icons",
pageName: figmaPageName,
format: .svg,
useSingleFile: darkFileId == nil,
darkModeSuffix: "_dark",
Expand Down
1 change: 1 addition & 0 deletions Sources/ExFig-Android/Config/AndroidImagesEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public extension Android.ImagesEntry {
figmaFileId: figmaFileId,
darkFileId: darkFileId,
frameName: figmaFrameName ?? "Images",
pageName: figmaPageName,
sourceFormat: effectiveSourceFormat,
scales: effectiveScales,
useSingleFile: darkFileId == nil,
Expand Down
Loading
Loading