Skip to content

Commit b63fee1

Browse files
authored
feat: Add page-level filtering for icons and images (#54)
* feat: add figma page filtering to config Enhance configurations to allow filtering by Figma page names. This change aids in distinguishing components across pages with identical frame names. Updates include: - Introduced `figmaPageName` in both `FrameSource` and platform-specific entries. - Updated relevant loading functions and configurations to acknowledge this filter. - Improved tests to validate various fallback scenarios for `figmaPageName`. * chore: add examples * fix: after review * chore: update CLAUDE.md
1 parent 5db072b commit b63fee1

50 files changed

Lines changed: 952 additions & 69 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,21 @@ Tests/ # Test targets mirror source structure
182182

183183
## Code Patterns
184184

185+
### PKL Consumer Config DRY Patterns
186+
187+
Consumer `exfig.pkl` configs can use `local` Mapping + `for`-generators to eliminate entry duplication:
188+
189+
```pkl
190+
local categories: Mapping<String, String> = new { ["FrameName"] = "folder" }
191+
icons = new Listing {
192+
for (frameName, folder in categories) {
193+
new iOS.IconsEntry { figmaFrameName = frameName; assetsFolder = folder; /* ... */ }
194+
}
195+
}
196+
```
197+
198+
`local` properties don't appear in JSON output. Verify refactoring with `pkl eval --format json` diff.
199+
185200
### Modifying Loader Configs (IconsLoaderConfig / ImagesLoaderConfig)
186201

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

208+
**EnumBridgingTests gotcha:** Entry constructions have TWO indentation levels — 16-space (inside `for` loop)
209+
and 12-space ("defaults to" tests outside loop). A single `replace_all` with fixed indent misses one level.
210+
193211
When adding fields to `FrameSource` (PKL) / `SourceInput` (ExFigCore), also update:
194212

195213
4. Entry bridge methods (`iconsSourceInput()`/`imagesSourceInput()`) in ALL `Sources/ExFig-*/Config/*Entry.swift`
196214
5. Inline `SourceInput(` constructions in exporters (`iOSImagesExporter.svgSourceInput`, `AndroidImagesExporter.loadAndProcessSVG`)
197215
6. "Through" tests in `IconsLoaderConfigTests` — use `source.field` not hardcoded `nil`
216+
7. Download command files: `DownloadOptions.swift` (CLI flag), `DownloadImageLoader.swift` (filter), `DownloadExportHelpers.swift`, `DownloadImages.swift`, `DownloadIcons.swift`
217+
8. `DownloadAll.swift` — pass filter value to both `exportIcons` and `exportImages`
218+
9. Error/warning types with context (`ExFigError`, `ExFigWarning`) — add associated values if needed
219+
220+
### Adding a New Filter Level (e.g., page filtering)
221+
222+
Filter predicate sites that ALL need updating:
223+
224+
1. `ImageLoaderBase.swift``fetchImageComponents` (icons + images)
225+
2. `DownloadImageLoader.swift``fetchImageComponents`
226+
3. `DownloadExportHelpers.swift``AssetExportHelper.fetchComponents`
227+
4. Inline `SourceInput()` constructions in platform exporters (iOS `svgSourceInput`, Android `loadAndProcessSVG`)
228+
5. `DownloadAll.swift` — pass filter value to both `exportIcons` and `exportImages`
198229

199230
### Moving/Renaming PKL Types Between Modules
200231

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

274+
### Destination.url Contract (FileContents.swift)
275+
276+
`Destination.url` uses `isFileURL` to choose path strategy:
277+
278+
- `URL(fileURLWithPath:)``lastPathComponent` (just filename) — iOS/Android/Web exporters
279+
- `URL(string:)``file.path` (preserves subdirectories like `"icons/actions.dart"`) — Flutter exporters
280+
281+
`FileWriter` creates intermediate directories from `destination.url.deletingLastPathComponent()`, not `destination.directory`.
282+
243283
### Modifying Generated Code
244284

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

303343
## Troubleshooting
304344

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

317361
## Additional Rules
318362

CONFIG.md

Lines changed: 109 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,8 @@ variablesColors = new Common.VariablesColors {
270270
icons = new Common.Icons {
271271
// [optional] Figma frame name. Default: "Icons"
272272
figmaFrameName = "Icons"
273+
// [optional] Figma page name to filter by (useful when multiple pages share the same frame name)
274+
// figmaPageName = "Outlined"
273275
// [optional] RegExp for icon name validation
274276
nameValidateRegexp = "^(ic)_(\\d\\d)_([a-z0-9_]+)$"
275277
// [optional] Replacement pattern
@@ -289,6 +291,8 @@ icons = new Common.Icons {
289291
images = new Common.Images {
290292
// [optional] Figma frame name. Default: "Illustrations"
291293
figmaFrameName = "Illustrations"
294+
// [optional] Figma page name to filter by (useful when multiple pages share the same frame name)
295+
// figmaPageName = "Marketing"
292296
// [optional] RegExp for image name validation
293297
nameValidateRegexp = "^(img)_([a-z0-9_]+)$"
294298
// [optional] Replacement pattern
@@ -318,6 +322,7 @@ All Icons and Images entries across platforms extend `Common.FrameSource`, which
318322
| Field | Type | Default | Description |
319323
| -------------------- | --------- | ------- | ------------------------------------------------------- |
320324
| `figmaFrameName` | `String?` || Override Figma frame name for this entry |
325+
| `figmaPageName` | `String?` || Filter by Figma page name for this entry |
321326
| `figmaFileId` | `String?` || Override Figma file ID for this entry |
322327
| `rtlProperty` | `String?` | `"RTL"` | Figma component property name for RTL variant detection |
323328
| `nameValidateRegexp` | `String?` || Regex pattern for name validation |
@@ -431,7 +436,7 @@ icons = new iOS.IconsEntry {
431436
}
432437
```
433438

434-
`iOS.IconsEntry` extends `Common.FrameSource`, inheriting `figmaFrameName`, `figmaFileId`, `rtlProperty`,
439+
`iOS.IconsEntry` extends `Common.FrameSource`, inheriting `figmaFrameName`, `figmaPageName`, `figmaFileId`, `rtlProperty`,
435440
`nameValidateRegexp`, and `nameReplaceRegexp`.
436441

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

451-
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.
456+
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaPageName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.
452457

453458
### iOS Images
454459

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

492-
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.
497+
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaPageName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.
493498

494499
**HEIC Options:**
495500

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

650-
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.
655+
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaPageName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.
651656

652657
### Android Images
653658

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

675-
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.
680+
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaPageName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.
676681

677682
**WebP Options:**
678683

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

762-
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.
767+
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaPageName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.
763768

764769
### Flutter Images
765770

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

793-
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.
798+
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaPageName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.
794799

795800
---
796801

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

865-
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.
870+
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaPageName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.
866871

867872
### Web Images
868873

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

883-
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.
888+
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaPageName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.
884889

885890
---
886891

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

985-
The same fallback applies to `nameValidateRegexp`, `nameReplaceRegexp`, and `nameStyle`.
990+
The same fallback applies to `figmaPageName`, `nameValidateRegexp`, `nameReplaceRegexp`, and `nameStyle`.
986991

987992
### Performance
988993

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

1112+
### DRY Configs with `for`-Generators
1113+
1114+
When you have many entries that share the same structure (e.g., 15+ icon categories with identical settings except
1115+
`figmaFrameName` and `assetsFolder`), use PKL `local` Mapping and `for`-generators to eliminate duplication.
1116+
1117+
**Define categories as `local` Mapping**`local` properties are not included in the output:
1118+
1119+
```pkl
1120+
// figmaFrameName → assetsFolder
1121+
local iconCategories: Mapping<String, String> = new {
1122+
["Actions"] = "Actions"
1123+
["Chart"] = "Chart"
1124+
["Communication, Media, Art"] = "CommunicationMediaArt"
1125+
["Text editor"] = "TextEditor"
1126+
// ... more categories
1127+
}
1128+
```
1129+
1130+
**Generate entries with `for`:**
1131+
1132+
```pkl
1133+
icons = new Listing {
1134+
for (frameName, folder in iconCategories) {
1135+
new iOS.IconsEntry {
1136+
figmaFrameName = frameName
1137+
format = "svg"
1138+
xcassetsPath = "./Resources/Icons.xcassets"
1139+
assetsFolder = folder
1140+
imageSwift = "./Generated/\(folder)Icons.generated.swift"
1141+
}
1142+
}
1143+
}
1144+
```
1145+
1146+
You can mix manual entries and `for`-generators in the same `Listing`:
1147+
1148+
```pkl
1149+
icons = new Listing {
1150+
// Manual entries for special cases
1151+
new iOS.IconsEntry {
1152+
figmaFrameName = "Colored Icons"
1153+
renderMode = "default"
1154+
// ...
1155+
}
1156+
1157+
// Generated entries for categories with identical settings
1158+
for (frameName, folder in iconCategories) {
1159+
new iOS.IconsEntry {
1160+
figmaFrameName = frameName
1161+
assetsFolder = folder
1162+
// ...
1163+
}
1164+
}
1165+
}
1166+
```
1167+
1168+
**String interpolation** — use `\(expr)` to build paths from category data:
1169+
1170+
```pkl
1171+
imageSwift = "./Generated/\(folder)Icons.generated.swift"
1172+
assetsFolder = "\(folder)Dc" // e.g., "ActionsDc"
1173+
```
1174+
1175+
**Multiple Mappings** for different groups — define separate Mappings when groups need different settings:
1176+
1177+
```pkl
1178+
local allCategories: Mapping<String, String> = new { /* 17 items */ }
1179+
local dcCategories: Mapping<String, String> = new { /* 15 items — all except Logo, Template */ }
1180+
1181+
icons = new Listing {
1182+
// Template icons from allCategories
1183+
for (frameName, folder in allCategories) {
1184+
new iOS.IconsEntry { /* template settings */ }
1185+
}
1186+
// Double Color icons from dcCategories
1187+
for (frameName, folder in dcCategories) {
1188+
new iOS.IconsEntry { figmaFileId = "other-file"; renderMode = "default"; /* ... */ }
1189+
}
1190+
}
1191+
```
1192+
1193+
**Verification** — always verify that the refactored config produces the same output:
1194+
1195+
```bash
1196+
# Save output before refactoring
1197+
pkl eval --format json exfig.pkl > before.json
1198+
1199+
# After refactoring
1200+
pkl eval --format json exfig.pkl > after.json
1201+
1202+
# Compare (order of entries within Listing is preserved)
1203+
diff before.json after.json
1204+
```
1205+
11071206
---
11081207

11091208
## Validation

MIGRATION.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,67 @@ ios = new iOS.iOSConfig {
671671

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

674+
## DRY Configs with `for`-Generators
675+
676+
PKL is a programmable language — you can use `for`-generators to eliminate entry duplication. This is especially
677+
useful when you have many icon or image categories with identical settings except for the Figma frame name and output
678+
folder.
679+
680+
**Before** — 34 nearly identical entries (~380 lines):
681+
682+
```pkl
683+
icons = new Listing {
684+
new iOS.IconsEntry {
685+
figmaFrameName = "Actions"
686+
format = "svg"
687+
xcassetsPath = "./Resources/Icons.xcassets"
688+
assetsFolder = "Actions"
689+
imageSwift = "./Generated/ActionsIcons.generated.swift"
690+
}
691+
new iOS.IconsEntry {
692+
figmaFrameName = "Chart"
693+
format = "svg"
694+
xcassetsPath = "./Resources/Icons.xcassets"
695+
assetsFolder = "Chart"
696+
imageSwift = "./Generated/ChartIcons.generated.swift"
697+
}
698+
// ... 32 more identical entries
699+
}
700+
```
701+
702+
**After**`local` Mapping + `for`-generator (~30 lines):
703+
704+
```pkl
705+
// local properties are NOT included in the output
706+
local iconCategories: Mapping<String, String> = new {
707+
["Actions"] = "Actions"
708+
["Chart"] = "Chart"
709+
["Communication, Media, Art"] = "CommunicationMediaArt"
710+
["Text editor"] = "TextEditor"
711+
// ...
712+
}
713+
714+
icons = new Listing {
715+
for (frameName, folder in iconCategories) {
716+
new iOS.IconsEntry {
717+
figmaFrameName = frameName
718+
format = "svg"
719+
xcassetsPath = "./Resources/Icons.xcassets"
720+
assetsFolder = folder
721+
imageSwift = "./Generated/\(folder)Icons.generated.swift"
722+
}
723+
}
724+
}
725+
```
726+
727+
Key points:
728+
729+
- `local` properties exist only during evaluation — they don't appear in the JSON output
730+
- `\(expr)` is PKL string interpolation for building paths from data
731+
- You can mix manual entries and `for`-generators in the same `Listing`
732+
- Use multiple Mappings for groups with different settings (e.g., template vs. colored icons)
733+
- Always verify with `pkl eval --format json` that the output is unchanged after refactoring
734+
674735
## Validation
675736

676737
Validate your PKL config at any time:

Sources/ExFig-Android/Config/AndroidIconsEntry.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public extension Android.IconsEntry {
1717
figmaFileId: figmaFileId,
1818
darkFileId: darkFileId,
1919
frameName: figmaFrameName ?? "Icons",
20+
pageName: figmaPageName,
2021
format: .svg,
2122
useSingleFile: darkFileId == nil,
2223
darkModeSuffix: "_dark",

Sources/ExFig-Android/Config/AndroidImagesEntry.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public extension Android.ImagesEntry {
3434
figmaFileId: figmaFileId,
3535
darkFileId: darkFileId,
3636
frameName: figmaFrameName ?? "Images",
37+
pageName: figmaPageName,
3738
sourceFormat: effectiveSourceFormat,
3839
scales: effectiveScales,
3940
useSingleFile: darkFileId == nil,

0 commit comments

Comments
 (0)