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
11 changes: 11 additions & 0 deletions .claude/rules/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ When Figma API response structure differs from project models, check:
2. Figma API docs - actual response schema
3. Create/update `Decodable` structs to match API response

### Components Response Key Types

| Type | Fields | Purpose |
| ----------------------- | ------------------------ | ------------------------------------------------ |
| `Component` | key, nodeId, name, description, containingFrame | Figma component metadata |
| `ContainingFrame` | nodeId, name, pageId, pageName, backgroundColor, containingComponentSet | Parent frame info |
| `ContainingComponentSet`| nodeId, name | Parent COMPONENT_SET for variant components |

`containingComponentSet` is present when a component is a variant inside a component set (e.g., `RTL=Off` variant).
Used to get the real icon name from `componentSet.name` instead of the variant name.

## Rate Limits

**Official docs:** https://developers.figma.com/docs/rest-api/rate-limits/
Expand Down
49 changes: 39 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ and Flutter projects.
# Build & Test
./bin/mise run build # Debug build
./bin/mise run build:release # Release build
./bin/mise run test # All tests
./bin/mise run test # All tests (prefer over test:filter when 3+ files changed)
# Linux: swift build --build-tests && swift test --skip-build --parallel
./bin/mise run test:filter NAME # Filter by target/class/method
./bin/mise run test:file FILE # Run tests for specific file
Expand Down Expand Up @@ -182,6 +182,33 @@ Tests/ # Test targets mirror source structure

## Code Patterns

### Modifying Loader Configs (IconsLoaderConfig / ImagesLoaderConfig)

When adding fields to loader configs, update ALL construction sites:

1. Factory methods (`forIOS`, `forAndroid`, `forFlutter`, `forWeb`, `defaultConfig`)
2. Context implementations (`Sources/ExFigCLI/Context/*ExportContextImpl.swift`) — direct constructions in `loadIcons`/`loadImages`
3. Test files (`IconsLoaderConfigTests.swift`, `EnumBridgingTests.swift`) — direct init calls

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`

### Module Boundaries

ExFigCore does NOT import FigmaAPI. Constants on `Component` (FigmaAPI, extended in ExFigCLI) are
not accessible from ExFigCore types (`IconsSourceInput`, `ImagesSourceInput`). Keep default values
as string literals in ExFigCore inits; use shared constants only within ExFigCLI.

### RTL Detection Design

- `Component.iconName`: uses `containingComponentSet.name` for variants, own `name` otherwise
- `Component.defaultRTLProperty = "RTL"`: shared constant in ExFigCLI for the magic string
- PNG images intentionally do NOT carry `isRTL` — raster images skip mirroring by design
- `buildPairedComponents` must use `iconName` (not `name`) — variant `name` is `"RTL=Off"`, not the icon name

### Adding a CLI Command

1. Create `Sources/ExFigCLI/Subcommands/NewCommand.swift` implementing `AsyncParsableCommand`
Expand Down Expand Up @@ -264,15 +291,17 @@ NooraUI.formatLink("url", useColors: true) // underlined primary

## Troubleshooting

| Problem | Solution |
| --------------------- | ------------------------------------------------------------------------------------- |
| 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` |

## Additional Rules

Expand Down
41 changes: 31 additions & 10 deletions CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,27 @@ typography = new Common.Typography {
}
```

### FrameSource (Inherited Fields)

All Icons and Images entries across platforms extend `Common.FrameSource`, which provides:

| Field | Type | Default | Description |
| -------------------- | --------- | ------- | ------------------------------------------------------- |
| `figmaFrameName` | `String?` | — | Override Figma frame 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 |
| `nameReplaceRegexp` | `String?` | — | Replacement pattern using captured groups |

**RTL Detection:** When `rtlProperty` is set (default `"RTL"`), ExFig detects RTL support via Figma
COMPONENT_SET variant properties. Components with `RTL=On` variant are automatically skipped (iOS/Android
handle mirroring at runtime). Components with `RTL=Off` variant are marked as RTL-supported, and the icon
name is taken from the COMPONENT_SET name instead of the variant name.

Priority: variant property > description-based detection (legacy fallback: if description contains "rtl").

Set `rtlProperty = null` to disable variant-based detection and use only description-based fallback.

---

## iOS
Expand Down Expand Up @@ -410,8 +431,8 @@ icons = new iOS.IconsEntry {
}
```

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

| Field | Type | Required | Description |
| ------------------------------- | ------------------ | -------- | ------------------------------------------------------------ |
Expand All @@ -427,7 +448,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`, `nameValidateRegexp`, `nameReplaceRegexp`.
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.

### iOS Images

Expand Down Expand Up @@ -468,7 +489,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`, `nameValidateRegexp`, `nameReplaceRegexp`.
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.

**HEIC Options:**

Expand Down Expand Up @@ -626,7 +647,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`, `nameValidateRegexp`, `nameReplaceRegexp`.
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.

### Android Images

Expand All @@ -651,7 +672,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`, `nameValidateRegexp`, `nameReplaceRegexp`.
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.

**WebP Options:**

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

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

### Flutter Images

Expand Down Expand Up @@ -769,7 +790,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`, `nameValidateRegexp`, `nameReplaceRegexp`.
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.

---

Expand Down Expand Up @@ -841,7 +862,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`, `nameValidateRegexp`, `nameReplaceRegexp`.
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.

### Web Images

Expand All @@ -859,7 +880,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`, `nameValidateRegexp`, `nameReplaceRegexp`.
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.

---

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 @@ -20,6 +20,7 @@ public extension Android.IconsEntry {
format: .svg,
useSingleFile: darkFileId == nil,
darkModeSuffix: "_dark",
rtlProperty: rtlProperty,
nameValidateRegexp: nameValidateRegexp,
nameReplaceRegexp: nameReplaceRegexp
)
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 @@ -38,6 +38,7 @@ public extension Android.ImagesEntry {
scales: effectiveScales,
useSingleFile: darkFileId == nil,
darkModeSuffix: "_dark",
rtlProperty: rtlProperty,
nameValidateRegexp: nameValidateRegexp,
nameReplaceRegexp: nameReplaceRegexp
)
Expand Down
1 change: 1 addition & 0 deletions Sources/ExFig-Android/Export/AndroidImagesExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,7 @@ private extension AndroidImagesExporter {
scales: [1.0],
useSingleFile: true,
darkModeSuffix: "_dark",
rtlProperty: entry.rtlProperty,
nameValidateRegexp: entry.nameValidateRegexp,
nameReplaceRegexp: entry.nameReplaceRegexp
)
Expand Down
1 change: 1 addition & 0 deletions Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public extension Flutter.IconsEntry {
frameName: figmaFrameName ?? "Icons",
useSingleFile: darkFileId == nil,
darkModeSuffix: "_dark",
rtlProperty: rtlProperty,
nameValidateRegexp: nameValidateRegexp,
nameReplaceRegexp: nameReplaceRegexp
)
Expand Down
2 changes: 2 additions & 0 deletions Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public extension Flutter.ImagesEntry {
scales: effectiveScales,
useSingleFile: darkFileId == nil,
darkModeSuffix: "_dark",
rtlProperty: rtlProperty,
nameValidateRegexp: nameValidateRegexp,
nameReplaceRegexp: nameReplaceRegexp
)
Expand Down Expand Up @@ -48,6 +49,7 @@ public extension Flutter.ImagesEntry {
scales: [1.0],
useSingleFile: darkFileId == nil,
darkModeSuffix: "_dark",
rtlProperty: rtlProperty,
nameValidateRegexp: nameValidateRegexp,
nameReplaceRegexp: nameReplaceRegexp
)
Expand Down
1 change: 1 addition & 0 deletions Sources/ExFig-Web/Config/WebIconsEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public extension Web.IconsEntry {
frameName: figmaFrameName ?? "Icons",
useSingleFile: darkFileId == nil,
darkModeSuffix: "_dark",
rtlProperty: rtlProperty,
nameValidateRegexp: nameValidateRegexp,
nameReplaceRegexp: nameReplaceRegexp
)
Expand Down
1 change: 1 addition & 0 deletions Sources/ExFig-Web/Config/WebImagesEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public extension Web.ImagesEntry {
scales: [1.0],
useSingleFile: darkFileId == nil,
darkModeSuffix: "_dark",
rtlProperty: rtlProperty,
nameValidateRegexp: nameValidateRegexp,
nameReplaceRegexp: nameReplaceRegexp
)
Expand Down
1 change: 1 addition & 0 deletions Sources/ExFig-iOS/Config/iOSIconsEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public extension iOS.IconsEntry {
renderModeDefaultSuffix: renderModeDefaultSuffix,
renderModeOriginalSuffix: renderModeOriginalSuffix,
renderModeTemplateSuffix: renderModeTemplateSuffix,
rtlProperty: rtlProperty,
nameValidateRegexp: nameValidateRegexp,
nameReplaceRegexp: nameReplaceRegexp
)
Expand Down
1 change: 1 addition & 0 deletions Sources/ExFig-iOS/Config/iOSImagesEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public extension iOS.ImagesEntry {
scales: effectiveScales,
useSingleFile: darkFileId == nil,
darkModeSuffix: "_dark",
rtlProperty: rtlProperty,
nameValidateRegexp: nameValidateRegexp,
nameReplaceRegexp: nameReplaceRegexp
)
Expand Down
1 change: 1 addition & 0 deletions Sources/ExFig-iOS/Export/iOSImagesExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,7 @@ private extension iOSImagesEntry {
scales: [1.0],
useSingleFile: true,
darkModeSuffix: "_dark",
rtlProperty: rtlProperty,
nameValidateRegexp: nameValidateRegexp,
nameReplaceRegexp: nameReplaceRegexp
)
Expand Down
6 changes: 4 additions & 2 deletions Sources/ExFigCLI/Context/IconsExportContextImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache {
renderMode: source.renderMode,
renderModeDefaultSuffix: source.renderModeDefaultSuffix,
renderModeOriginalSuffix: source.renderModeOriginalSuffix,
renderModeTemplateSuffix: source.renderModeTemplateSuffix
renderModeTemplateSuffix: source.renderModeTemplateSuffix,
rtlProperty: source.rtlProperty
)

let loader = IconsLoader(
Expand Down Expand Up @@ -175,7 +176,8 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache {
renderMode: source.renderMode,
renderModeDefaultSuffix: source.renderModeDefaultSuffix,
renderModeOriginalSuffix: source.renderModeOriginalSuffix,
renderModeTemplateSuffix: source.renderModeTemplateSuffix
renderModeTemplateSuffix: source.renderModeTemplateSuffix,
rtlProperty: source.rtlProperty
)

let loader = IconsLoader(
Expand Down
6 changes: 4 additions & 2 deletions Sources/ExFigCLI/Context/ImagesExportContextImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ struct ImagesExportContextImpl: ImagesExportContextWithGranularCache {
frameName: source.frameName,
scales: source.scales,
format: nil, // Format is determined by platform exporter
sourceFormat: loaderSourceFormat
sourceFormat: loaderSourceFormat,
rtlProperty: source.rtlProperty
)

let loader = ImagesLoader(
Expand Down Expand Up @@ -367,7 +368,8 @@ struct ImagesExportContextImpl: ImagesExportContextWithGranularCache {
frameName: source.frameName,
scales: source.scales,
format: nil,
sourceFormat: loaderSourceFormat
sourceFormat: loaderSourceFormat,
rtlProperty: source.rtlProperty
)

let loader = ImagesLoader(
Expand Down
Loading
Loading