From ddfaba54b52ab8943e80e2893d6f67d6a1008dde Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Wed, 11 Feb 2026 09:35:25 +0500 Subject: [PATCH 1/4] 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`. --- CLAUDE.md | 36 +++++--- CONFIG.md | 25 +++--- .../Config/AndroidIconsEntry.swift | 1 + .../Config/AndroidImagesEntry.swift | 1 + .../Config/FlutterIconsEntry.swift | 1 + .../Config/FlutterImagesEntry.swift | 2 + Sources/ExFig-Web/Config/WebIconsEntry.swift | 1 + Sources/ExFig-Web/Config/WebImagesEntry.swift | 1 + Sources/ExFig-iOS/Config/iOSIconsEntry.swift | 1 + Sources/ExFig-iOS/Config/iOSImagesEntry.swift | 1 + .../Context/IconsExportContextImpl.swift | 2 + .../Context/ImagesExportContextImpl.swift | 2 + Sources/ExFigCLI/Input/DownloadOptions.swift | 3 + .../Loaders/DownloadImageLoader.swift | 22 ++++- Sources/ExFigCLI/Loaders/IconsLoader.swift | 16 ++++ .../ExFigCLI/Loaders/ImageLoaderBase.swift | 24 +++-- Sources/ExFigCLI/Loaders/ImagesLoader.swift | 20 +++++ .../Output/DownloadExportHelpers.swift | 6 +- Sources/ExFigCLI/Output/FileWriter.swift | 4 +- Sources/ExFigCLI/Resources/Schemas/Common.pkl | 11 +++ .../ExFigCLI/Resources/androidConfig.swift | 4 + .../ExFigCLI/Resources/flutterConfig.swift | 4 + Sources/ExFigCLI/Resources/iOSConfig.swift | 4 + Sources/ExFigCLI/Resources/webConfig.swift | 4 + .../ExFigCLI/Subcommands/DownloadIcons.swift | 5 ++ .../ExFigCLI/Subcommands/DownloadImages.swift | 2 + .../ExFigConfig/Generated/Android.pkl.swift | 14 +++ .../ExFigConfig/Generated/Common.pkl.swift | 19 ++++ .../ExFigConfig/Generated/Flutter.pkl.swift | 14 +++ Sources/ExFigConfig/Generated/Web.pkl.swift | 14 +++ Sources/ExFigConfig/Generated/iOS.pkl.swift | 14 +++ Sources/ExFigCore/FileContents.swift | 9 +- .../Protocol/IconsExportContext.swift | 5 ++ .../Protocol/ImagesExportContext.swift | 5 ++ Tests/ExFigCoreTests/FileContentsTests.swift | 24 +++++ Tests/ExFigTests/Helpers/TestHelpers.swift | 12 ++- .../ExFigTests/Input/EnumBridgingTests.swift | 8 ++ .../Loaders/IconsLoaderConfigTests.swift | 89 +++++++++++++++++++ .../Loaders/ImagesLoaderConfigTests.swift | 70 +++++++++++++++ Tests/ExFigTests/Output/FileWriterTests.swift | 35 ++++++++ 40 files changed, 495 insertions(+), 40 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c3bf6cfe..7edae8dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,11 +190,15 @@ 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` ### Moving/Renaming PKL Types Between Modules @@ -240,6 +244,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. @@ -302,17 +315,18 @@ 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` | ## Additional Rules diff --git a/CONFIG.md b/CONFIG.md index 3c5b5928..170b4811 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -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 @@ -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 @@ -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 | @@ -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 | @@ -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 @@ -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:** @@ -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 @@ -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:** @@ -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 @@ -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`. --- @@ -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 @@ -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`. --- @@ -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 diff --git a/Sources/ExFig-Android/Config/AndroidIconsEntry.swift b/Sources/ExFig-Android/Config/AndroidIconsEntry.swift index a3c0aecb..c15c7c64 100644 --- a/Sources/ExFig-Android/Config/AndroidIconsEntry.swift +++ b/Sources/ExFig-Android/Config/AndroidIconsEntry.swift @@ -17,6 +17,7 @@ public extension Android.IconsEntry { figmaFileId: figmaFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Icons", + pageName: figmaPageName, format: .svg, useSingleFile: darkFileId == nil, darkModeSuffix: "_dark", diff --git a/Sources/ExFig-Android/Config/AndroidImagesEntry.swift b/Sources/ExFig-Android/Config/AndroidImagesEntry.swift index 4b2441c1..e77ed0a2 100644 --- a/Sources/ExFig-Android/Config/AndroidImagesEntry.swift +++ b/Sources/ExFig-Android/Config/AndroidImagesEntry.swift @@ -34,6 +34,7 @@ public extension Android.ImagesEntry { figmaFileId: figmaFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Images", + pageName: figmaPageName, sourceFormat: effectiveSourceFormat, scales: effectiveScales, useSingleFile: darkFileId == nil, diff --git a/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift b/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift index def455c4..25579860 100644 --- a/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift +++ b/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift @@ -14,6 +14,7 @@ public extension Flutter.IconsEntry { figmaFileId: figmaFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Icons", + pageName: figmaPageName, useSingleFile: darkFileId == nil, darkModeSuffix: "_dark", rtlProperty: rtlProperty, diff --git a/Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift b/Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift index 24ef8141..d98fbded 100644 --- a/Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift +++ b/Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift @@ -17,6 +17,7 @@ public extension Flutter.ImagesEntry { figmaFileId: figmaFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Images", + pageName: figmaPageName, sourceFormat: effectiveSourceFormat, scales: effectiveScales, useSingleFile: darkFileId == nil, @@ -45,6 +46,7 @@ public extension Flutter.ImagesEntry { figmaFileId: figmaFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Images", + pageName: figmaPageName, sourceFormat: .svg, scales: [1.0], useSingleFile: darkFileId == nil, diff --git a/Sources/ExFig-Web/Config/WebIconsEntry.swift b/Sources/ExFig-Web/Config/WebIconsEntry.swift index 3fcae601..e43d5c60 100644 --- a/Sources/ExFig-Web/Config/WebIconsEntry.swift +++ b/Sources/ExFig-Web/Config/WebIconsEntry.swift @@ -14,6 +14,7 @@ public extension Web.IconsEntry { figmaFileId: figmaFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Icons", + pageName: figmaPageName, useSingleFile: darkFileId == nil, darkModeSuffix: "_dark", rtlProperty: rtlProperty, diff --git a/Sources/ExFig-Web/Config/WebImagesEntry.swift b/Sources/ExFig-Web/Config/WebImagesEntry.swift index 7d06d73b..837c7639 100644 --- a/Sources/ExFig-Web/Config/WebImagesEntry.swift +++ b/Sources/ExFig-Web/Config/WebImagesEntry.swift @@ -14,6 +14,7 @@ public extension Web.ImagesEntry { figmaFileId: figmaFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Images", + pageName: figmaPageName, sourceFormat: .svg, scales: [1.0], useSingleFile: darkFileId == nil, diff --git a/Sources/ExFig-iOS/Config/iOSIconsEntry.swift b/Sources/ExFig-iOS/Config/iOSIconsEntry.swift index 2b5d2a5c..f5b8c614 100644 --- a/Sources/ExFig-iOS/Config/iOSIconsEntry.swift +++ b/Sources/ExFig-iOS/Config/iOSIconsEntry.swift @@ -16,6 +16,7 @@ public extension iOS.IconsEntry { figmaFileId: figmaFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Icons", + pageName: figmaPageName, format: coreVectorFormat, useSingleFile: darkFileId == nil, darkModeSuffix: "_dark", diff --git a/Sources/ExFig-iOS/Config/iOSImagesEntry.swift b/Sources/ExFig-iOS/Config/iOSImagesEntry.swift index 2223d2f9..2b269160 100644 --- a/Sources/ExFig-iOS/Config/iOSImagesEntry.swift +++ b/Sources/ExFig-iOS/Config/iOSImagesEntry.swift @@ -16,6 +16,7 @@ public extension iOS.ImagesEntry { figmaFileId: figmaFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Images", + pageName: figmaPageName, sourceFormat: effectiveSourceFormat, scales: effectiveScales, useSingleFile: darkFileId == nil, diff --git a/Sources/ExFigCLI/Context/IconsExportContextImpl.swift b/Sources/ExFigCLI/Context/IconsExportContextImpl.swift index 6a3d8476..7a2eeea0 100644 --- a/Sources/ExFigCLI/Context/IconsExportContextImpl.swift +++ b/Sources/ExFigCLI/Context/IconsExportContextImpl.swift @@ -81,6 +81,7 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache { let config = IconsLoaderConfig( entryFileId: source.figmaFileId, frameName: source.frameName, + pageName: source.pageName, format: source.format, renderMode: source.renderMode, renderModeDefaultSuffix: source.renderModeDefaultSuffix, @@ -172,6 +173,7 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache { let config = IconsLoaderConfig( entryFileId: source.figmaFileId, frameName: source.frameName, + pageName: source.pageName, format: source.format, renderMode: source.renderMode, renderModeDefaultSuffix: source.renderModeDefaultSuffix, diff --git a/Sources/ExFigCLI/Context/ImagesExportContextImpl.swift b/Sources/ExFigCLI/Context/ImagesExportContextImpl.swift index 8fe21cf3..bf2216d6 100644 --- a/Sources/ExFigCLI/Context/ImagesExportContextImpl.swift +++ b/Sources/ExFigCLI/Context/ImagesExportContextImpl.swift @@ -89,6 +89,7 @@ struct ImagesExportContextImpl: ImagesExportContextWithGranularCache { let config = ImagesLoaderConfig( entryFileId: source.figmaFileId, frameName: source.frameName, + pageName: source.pageName, scales: source.scales, format: nil, // Format is determined by platform exporter sourceFormat: loaderSourceFormat, @@ -366,6 +367,7 @@ struct ImagesExportContextImpl: ImagesExportContextWithGranularCache { let config = ImagesLoaderConfig( entryFileId: source.figmaFileId, frameName: source.frameName, + pageName: source.pageName, scales: source.scales, format: nil, sourceFormat: loaderSourceFormat, diff --git a/Sources/ExFigCLI/Input/DownloadOptions.swift b/Sources/ExFigCLI/Input/DownloadOptions.swift index 10066dba..3f56076b 100644 --- a/Sources/ExFigCLI/Input/DownloadOptions.swift +++ b/Sources/ExFigCLI/Input/DownloadOptions.swift @@ -62,6 +62,9 @@ struct DownloadOptions: ParsableArguments { ) var frameName: String + @Option(name: [.customLong("page"), .customShort("p")], help: "Filter by Figma page name.") + var pageName: String? + @Option( name: [.customLong("output"), .customShort("o")], help: "Output directory for downloaded images" diff --git a/Sources/ExFigCLI/Loaders/DownloadImageLoader.swift b/Sources/ExFigCLI/Loaders/DownloadImageLoader.swift index eb0d65ef..e902e4a5 100644 --- a/Sources/ExFigCLI/Loaders/DownloadImageLoader.swift +++ b/Sources/ExFigCLI/Loaders/DownloadImageLoader.swift @@ -18,11 +18,17 @@ final class DownloadImageLoader: Sendable { func loadVectorImages( fileId: String, frameName: String, + pageName: String? = nil, params: FormatParams, filter: String?, onBatchProgress: @escaping BatchProgressCallback = { _, _ in } ) async throws -> [ImagePack] { - var imagesDict = try await fetchImageComponents(fileId: fileId, frameName: frameName, filter: filter) + var imagesDict = try await fetchImageComponents( + fileId: fileId, + frameName: frameName, + pageName: pageName, + filter: filter + ) guard !imagesDict.isEmpty else { throw ExFigError.componentsNotFound @@ -68,12 +74,18 @@ final class DownloadImageLoader: Sendable { func loadRasterImages( fileId: String, frameName: String, + pageName: String? = nil, scale: Double, format: String, filter: String?, onBatchProgress: @escaping BatchProgressCallback = { _, _ in } ) async throws -> [ImagePack] { - let imagesDict = try await fetchImageComponents(fileId: fileId, frameName: frameName, filter: filter) + let imagesDict = try await fetchImageComponents( + fileId: fileId, + frameName: frameName, + pageName: pageName, + filter: filter + ) guard !imagesDict.isEmpty else { throw ExFigError.componentsNotFound @@ -111,10 +123,14 @@ final class DownloadImageLoader: Sendable { private func fetchImageComponents( fileId: String, frameName: String, + pageName: String? = nil, filter: String? ) async throws -> [NodeId: Component] { var components = try await loadComponents(fileId: fileId) - .filter { $0.containingFrame.name == frameName } + .filter { + $0.containingFrame.name == frameName + && (pageName == nil || $0.containingFrame.pageName == pageName) + } if let filter { let assetsFilter = AssetsFilter(filter: filter) diff --git a/Sources/ExFigCLI/Loaders/IconsLoader.swift b/Sources/ExFigCLI/Loaders/IconsLoader.swift index 9df79810..f50db4de 100644 --- a/Sources/ExFigCLI/Loaders/IconsLoader.swift +++ b/Sources/ExFigCLI/Loaders/IconsLoader.swift @@ -32,6 +32,9 @@ struct IconsLoaderConfig: Sendable { /// Figma frame name to load icons from. let frameName: String + /// Optional page name to filter icons by. + let pageName: String? + /// Icon format for iOS (pdf or svg). Android always uses svg. let format: VectorFormat? @@ -49,6 +52,7 @@ struct IconsLoaderConfig: Sendable { IconsLoaderConfig( entryFileId: entry.figmaFileId, frameName: entry.figmaFrameName ?? params.common?.icons?.figmaFrameName ?? "Icons", + pageName: entry.figmaPageName ?? params.common?.icons?.figmaPageName, format: VectorFormat(rawValue: entry.format.rawValue) ?? .svg, renderMode: entry.coreRenderMode, renderModeDefaultSuffix: entry.renderModeDefaultSuffix, @@ -63,6 +67,7 @@ struct IconsLoaderConfig: Sendable { IconsLoaderConfig( entryFileId: entry.figmaFileId, frameName: entry.figmaFrameName ?? params.common?.icons?.figmaFrameName ?? "Icons", + pageName: entry.figmaPageName ?? params.common?.icons?.figmaPageName, format: nil, renderMode: nil, renderModeDefaultSuffix: nil, @@ -77,6 +82,7 @@ struct IconsLoaderConfig: Sendable { IconsLoaderConfig( entryFileId: entry.figmaFileId, frameName: entry.figmaFrameName ?? params.common?.icons?.figmaFrameName ?? "Icons", + pageName: entry.figmaPageName ?? params.common?.icons?.figmaPageName, format: nil, renderMode: nil, renderModeDefaultSuffix: nil, @@ -91,6 +97,7 @@ struct IconsLoaderConfig: Sendable { IconsLoaderConfig( entryFileId: entry.figmaFileId, frameName: entry.figmaFrameName ?? params.common?.icons?.figmaFrameName ?? "Icons", + pageName: entry.figmaPageName ?? params.common?.icons?.figmaPageName, format: nil, renderMode: nil, renderModeDefaultSuffix: nil, @@ -105,6 +112,7 @@ struct IconsLoaderConfig: Sendable { IconsLoaderConfig( entryFileId: nil, frameName: params.common?.icons?.figmaFrameName ?? "Icons", + pageName: params.common?.icons?.figmaPageName, format: nil, renderMode: nil, renderModeDefaultSuffix: nil, @@ -134,6 +142,10 @@ final class IconsLoader: ImageLoaderBase, @unchecked Sendable { config.frameName } + private var pageName: String? { + config.pageName + } + /// Loads icons from Figma, supporting both single-file and separate light/dark file modes. func load( filter: String? = nil, @@ -178,6 +190,7 @@ final class IconsLoader: ImageLoaderBase, @unchecked Sendable { let icons = try await loadVectorImages( fileId: fileId, frameName: frameName, + pageName: pageName, params: formatParams, filter: filter, rtlProperty: config.rtlProperty, @@ -218,6 +231,7 @@ final class IconsLoader: ImageLoaderBase, @unchecked Sendable { let icons = try await self.loadVectorImages( fileId: fileId, frameName: self.frameName, + pageName: self.pageName, params: formatParams, filter: filter, rtlProperty: self.config.rtlProperty, @@ -290,6 +304,7 @@ final class IconsLoader: ImageLoaderBase, @unchecked Sendable { let result = try await loadVectorImagesWithGranularCacheAndPairing( fileId: fileId, frameName: frameName, + pageName: pageName, params: formatParams, filter: filter, darkModeSuffix: darkSuffix, @@ -354,6 +369,7 @@ final class IconsLoader: ImageLoaderBase, @unchecked Sendable { let result = try await self.loadVectorImagesWithGranularCache( fileId: fileId, frameName: self.frameName, + pageName: self.pageName, params: formatParams, filter: filter, rtlProperty: self.config.rtlProperty, diff --git a/Sources/ExFigCLI/Loaders/ImageLoaderBase.swift b/Sources/ExFigCLI/Loaders/ImageLoaderBase.swift index 3d98bfd4..e9f6ced8 100644 --- a/Sources/ExFigCLI/Loaders/ImageLoaderBase.swift +++ b/Sources/ExFigCLI/Loaders/ImageLoaderBase.swift @@ -73,12 +73,15 @@ class ImageLoaderBase: @unchecked Sendable { func fetchImageComponents( fileId: String, frameName: String, + pageName: String? = nil, filter: String? = nil, rtlProperty: String? = Component.defaultRTLProperty ) async throws -> [NodeId: Component] { var components = try await loadComponents(fileId: fileId) .filter { - $0.containingFrame.name == frameName && $0.useForPlatform(platform) + $0.containingFrame.name == frameName + && (pageName == nil || $0.containingFrame.pageName == pageName) + && $0.useForPlatform(platform) } // Skip RTL=On variants: the base (RTL=Off) icon is sufficient — @@ -120,12 +123,14 @@ class ImageLoaderBase: @unchecked Sendable { func fetchImageComponentsWithGranularCache( fileId: String, frameName: String, + pageName: String? = nil, filter: String? = nil, rtlProperty: String? = Component.defaultRTLProperty ) async throws -> GranularFilterResult { let allComponents = try await fetchImageComponents( fileId: fileId, frameName: frameName, + pageName: pageName, filter: filter, rtlProperty: rtlProperty ) @@ -191,12 +196,13 @@ class ImageLoaderBase: @unchecked Sendable { func fetchImageComponentsWithGranularCacheAndPairing( fileId: String, frameName: String, + pageName: String? = nil, filter: String? = nil, darkModeSuffix: String, rtlProperty: String? = Component.defaultRTLProperty ) async throws -> GranularFilterResult { let allComponents = try await fetchImageComponents( - fileId: fileId, frameName: frameName, filter: filter, rtlProperty: rtlProperty + fileId: fileId, frameName: frameName, pageName: pageName, filter: filter, rtlProperty: rtlProperty ) let allAssetMetadata = allComponents.map { nodeId, component in AssetMetadata(name: component.iconName, nodeId: nodeId, fileId: fileId) @@ -272,13 +278,14 @@ class ImageLoaderBase: @unchecked Sendable { func loadVectorImages( fileId: String, frameName: String, + pageName: String? = nil, params: FormatParams, filter: String? = nil, rtlProperty: String? = Component.defaultRTLProperty, onBatchProgress: @escaping BatchProgressCallback = { _, _ in } ) async throws -> [ImagePack] { let imagesDict = try await fetchImageComponents( - fileId: fileId, frameName: frameName, filter: filter, rtlProperty: rtlProperty + fileId: fileId, frameName: frameName, pageName: pageName, filter: filter, rtlProperty: rtlProperty ) return try await loadVectorImagesFromComponents( fileId: fileId, @@ -300,13 +307,14 @@ class ImageLoaderBase: @unchecked Sendable { func loadVectorImagesWithGranularCache( fileId: String, frameName: String, + pageName: String? = nil, params: FormatParams, filter: String? = nil, rtlProperty: String? = Component.defaultRTLProperty, onBatchProgress: @escaping BatchProgressCallback = { _, _ in } ) async throws -> ImagesWithHashesResult { let filterResult = try await fetchImageComponentsWithGranularCache( - fileId: fileId, frameName: frameName, filter: filter, rtlProperty: rtlProperty + fileId: fileId, frameName: frameName, pageName: pageName, filter: filter, rtlProperty: rtlProperty ) return try await loadVectorImagesFromGranularFilterResult( fileId: fileId, @@ -326,6 +334,7 @@ class ImageLoaderBase: @unchecked Sendable { func loadVectorImagesWithGranularCacheAndPairing( fileId: String, frameName: String, + pageName: String? = nil, params: FormatParams, filter: String? = nil, darkModeSuffix: String, @@ -335,6 +344,7 @@ class ImageLoaderBase: @unchecked Sendable { let filterResult = try await fetchImageComponentsWithGranularCacheAndPairing( fileId: fileId, frameName: frameName, + pageName: pageName, filter: filter, darkModeSuffix: darkModeSuffix, rtlProperty: rtlProperty @@ -498,13 +508,14 @@ class ImageLoaderBase: @unchecked Sendable { func loadPNGImages( fileId: String, frameName: String, + pageName: String? = nil, filter: String? = nil, scales: [Double], rtlProperty: String? = Component.defaultRTLProperty, onBatchProgress: @escaping BatchProgressCallback = { _, _ in } ) async throws -> [ImagePack] { let imagesDict = try await fetchImageComponents( - fileId: fileId, frameName: frameName, filter: filter, rtlProperty: rtlProperty + fileId: fileId, frameName: frameName, pageName: pageName, filter: filter, rtlProperty: rtlProperty ) return try await loadPNGImagesFromComponents( fileId: fileId, @@ -524,13 +535,14 @@ class ImageLoaderBase: @unchecked Sendable { func loadPNGImagesWithGranularCache( fileId: String, frameName: String, + pageName: String? = nil, filter: String? = nil, scales: [Double], rtlProperty: String? = Component.defaultRTLProperty, onBatchProgress: @escaping BatchProgressCallback = { _, _ in } ) async throws -> ImagesWithHashesResult { let filterResult = try await fetchImageComponentsWithGranularCache( - fileId: fileId, frameName: frameName, filter: filter, rtlProperty: rtlProperty + fileId: fileId, frameName: frameName, pageName: pageName, filter: filter, rtlProperty: rtlProperty ) if filterResult.allSkipped { diff --git a/Sources/ExFigCLI/Loaders/ImagesLoader.swift b/Sources/ExFigCLI/Loaders/ImagesLoader.swift index 3b056e68..167411df 100644 --- a/Sources/ExFigCLI/Loaders/ImagesLoader.swift +++ b/Sources/ExFigCLI/Loaders/ImagesLoader.swift @@ -31,6 +31,9 @@ struct ImagesLoaderConfig: Sendable { /// Figma frame name to load images from. let frameName: String + /// Optional page name to filter images by. + let pageName: String? + /// Custom scales for raster images. let scales: [Double]? @@ -49,6 +52,7 @@ struct ImagesLoaderConfig: Sendable { ImagesLoaderConfig( entryFileId: entry.figmaFileId, frameName: entry.figmaFrameName ?? params.common?.images?.figmaFrameName ?? "Illustrations", + pageName: entry.figmaPageName ?? params.common?.images?.figmaPageName, scales: entry.scales, format: nil, // iOS always uses PNG output sourceFormat: convertSourceFormat(entry.sourceFormat), @@ -61,6 +65,7 @@ struct ImagesLoaderConfig: Sendable { ImagesLoaderConfig( entryFileId: entry.figmaFileId, frameName: entry.figmaFrameName ?? params.common?.images?.figmaFrameName ?? "Illustrations", + pageName: entry.figmaPageName ?? params.common?.images?.figmaPageName, scales: entry.scales, format: convertAndroidFormat(entry.format), sourceFormat: convertSourceFormat(entry.sourceFormat), @@ -73,6 +78,7 @@ struct ImagesLoaderConfig: Sendable { ImagesLoaderConfig( entryFileId: entry.figmaFileId, frameName: entry.figmaFrameName ?? params.common?.images?.figmaFrameName ?? "Illustrations", + pageName: entry.figmaPageName ?? params.common?.images?.figmaPageName, scales: entry.scales, format: entry.format.flatMap { convertFlutterFormat($0) }, sourceFormat: convertSourceFormat(entry.sourceFormat), @@ -85,6 +91,7 @@ struct ImagesLoaderConfig: Sendable { ImagesLoaderConfig( entryFileId: entry.figmaFileId, frameName: entry.figmaFrameName ?? params.common?.images?.figmaFrameName ?? "Illustrations", + pageName: entry.figmaPageName ?? params.common?.images?.figmaPageName, scales: nil, format: .svg, // Web uses SVG by default sourceFormat: .svg, // Web always uses SVG source @@ -97,6 +104,7 @@ struct ImagesLoaderConfig: Sendable { ImagesLoaderConfig( entryFileId: nil, frameName: params.common?.images?.figmaFrameName ?? "Illustrations", + pageName: params.common?.images?.figmaPageName, scales: nil, format: nil, sourceFormat: .png, @@ -163,6 +171,10 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di config.frameName } + private var pageName: String? { + config.pageName + } + /// Custom scales from config, or nil to use defaults. private var configScales: [Double]? { config.scales @@ -257,6 +269,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di let images = try await loadPNGImages( fileId: fileId, frameName: frameName, + pageName: pageName, filter: filter, scales: scales, rtlProperty: config.rtlProperty, @@ -270,6 +283,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di let pack = try await loadVectorImages( fileId: fileId, frameName: frameName, + pageName: pageName, params: SVGParams(), filter: filter, rtlProperty: config.rtlProperty, @@ -326,6 +340,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di let images = try await self.loadPNGImages( fileId: fileId, frameName: self.frameName, + pageName: self.pageName, filter: filter, scales: scales, rtlProperty: self.config.rtlProperty, @@ -363,6 +378,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di let packs = try await self.loadVectorImages( fileId: fileId, frameName: self.frameName, + pageName: self.pageName, params: SVGParams(), filter: filter, rtlProperty: self.config.rtlProperty, @@ -402,6 +418,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di let result = try await loadPNGImagesWithGranularCache( fileId: fileId, frameName: frameName, + pageName: pageName, filter: filter, scales: scales, rtlProperty: config.rtlProperty, @@ -434,6 +451,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di let result = try await loadVectorImagesWithGranularCache( fileId: fileId, frameName: frameName, + pageName: pageName, params: SVGParams(), filter: filter, rtlProperty: config.rtlProperty, @@ -501,6 +519,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di let result = try await self.loadPNGImagesWithGranularCache( fileId: fileId, frameName: self.frameName, + pageName: self.pageName, filter: filter, scales: scales, rtlProperty: self.config.rtlProperty, @@ -519,6 +538,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di let result = try await self.loadVectorImagesWithGranularCache( fileId: fileId, frameName: self.frameName, + pageName: self.pageName, params: SVGParams(), filter: filter, rtlProperty: self.config.rtlProperty, diff --git a/Sources/ExFigCLI/Output/DownloadExportHelpers.swift b/Sources/ExFigCLI/Output/DownloadExportHelpers.swift index 7740c3ab..7d89f2e0 100644 --- a/Sources/ExFigCLI/Output/DownloadExportHelpers.swift +++ b/Sources/ExFigCLI/Output/DownloadExportHelpers.swift @@ -83,11 +83,15 @@ enum AssetExportHelper { client: Client, fileId: String, frameName: String, + pageName: String? = nil, filter: String? ) async throws -> [NodeId: Component] { let endpoint = ComponentsEndpoint(fileId: fileId) var comps = try await client.request(endpoint) - .filter { $0.containingFrame.name == frameName } + .filter { + $0.containingFrame.name == frameName + && (pageName == nil || $0.containingFrame.pageName == pageName) + } if let filter { let assetsFilter = AssetsFilter(filter: filter) diff --git a/Sources/ExFigCLI/Output/FileWriter.swift b/Sources/ExFigCLI/Output/FileWriter.swift index ae7fcfbc..fb0463aa 100644 --- a/Sources/ExFigCLI/Output/FileWriter.swift +++ b/Sources/ExFigCLI/Output/FileWriter.swift @@ -40,7 +40,7 @@ final class FileWriter: Sendable { // 1. Collect unique directories and create them (sequential, fast) var parentCache: ParentContentsCache = [:] - let directories = Set(files.map { URL(fileURLWithPath: $0.destination.directory.path) }) + let directories = Set(files.map { URL(fileURLWithPath: $0.destination.url.deletingLastPathComponent().path) }) for directory in directories { try fixCaseMismatchIfNeeded(for: directory, parentCache: &parentCache) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) @@ -92,7 +92,7 @@ final class FileWriter: Sendable { // MARK: - Private private func writeFile(_ file: FileContents, parentCache: inout ParentContentsCache) throws { - let directoryURL = URL(fileURLWithPath: file.destination.directory.path) + let directoryURL = URL(fileURLWithPath: file.destination.url.deletingLastPathComponent().path) try fixCaseMismatchIfNeeded(for: directoryURL, parentCache: &parentCache) try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) diff --git a/Sources/ExFigCLI/Resources/Schemas/Common.pkl b/Sources/ExFigCLI/Resources/Schemas/Common.pkl index 0816a677..96711a58 100644 --- a/Sources/ExFigCLI/Resources/Schemas/Common.pkl +++ b/Sources/ExFigCLI/Resources/Schemas/Common.pkl @@ -86,6 +86,11 @@ open class FrameSource extends NameProcessing { /// Figma frame name to export from. figmaFrameName: String? + /// Figma page name to filter components by. + /// When set, only components from this specific page are exported. + /// Useful when multiple pages have frames with the same name. + figmaPageName: String? + /// Override Figma file ID for this specific entry. /// When set, overrides the global `figma.lightFileId` for loading data. figmaFileId: String? @@ -121,6 +126,9 @@ class Icons extends NameProcessing { /// Figma frame name containing icons. figmaFrameName: String? + /// Figma page name to filter icons by. + figmaPageName: String? + /// Use single file for all icon modes. useSingleFile: Boolean? @@ -136,6 +144,9 @@ class Images extends NameProcessing { /// Figma frame name containing images. figmaFrameName: String? + /// Figma page name to filter images by. + figmaPageName: String? + /// Use single file for all image modes. useSingleFile: Boolean? diff --git a/Sources/ExFigCLI/Resources/androidConfig.swift b/Sources/ExFigCLI/Resources/androidConfig.swift index a0997588..e274eadb 100644 --- a/Sources/ExFigCLI/Resources/androidConfig.swift +++ b/Sources/ExFigCLI/Resources/androidConfig.swift @@ -61,6 +61,8 @@ common = new Common.CommonConfig { icons = new Common.Icons { // [optional] Name of the Figma's frame where icons components are located figmaFrameName = "Icons" + // [optional] Name of the Figma page to filter icons by (useful when multiple pages share the same frame name) + // figmaPageName = "Outlined" // [optional] RegExp pattern for icon name validation before exporting. // If a name contains "/" symbol it will be replaced by "_" before executing the RegExp. nameValidateRegexp = "^(ic)_(\\d\\d)_([a-z0-9_]+)$" // RegExp pattern for: ic_24_icon_name, ic_24_icon @@ -75,6 +77,8 @@ common = new Common.CommonConfig { images = new Common.Images { // [optional] Name of the Figma's frame where image components are located figmaFrameName = "Illustrations" + // [optional] Name of the Figma page to filter images by (useful when multiple pages share the same frame name) + // figmaPageName = "Marketing" // [optional] RegExp pattern for image name validation before exporting. // If a name contains "/" symbol it will be replaced by "_" before executing the RegExp. nameValidateRegexp = "^(img)_([a-z0-9_]+)$" // RegExp pattern for: img_image_name diff --git a/Sources/ExFigCLI/Resources/flutterConfig.swift b/Sources/ExFigCLI/Resources/flutterConfig.swift index 143310ec..f666c57a 100644 --- a/Sources/ExFigCLI/Resources/flutterConfig.swift +++ b/Sources/ExFigCLI/Resources/flutterConfig.swift @@ -53,6 +53,8 @@ common = new Common.CommonConfig { icons = new Common.Icons { // [optional] Name of the Figma's frame where icons components are located figmaFrameName = "Icons" + // [optional] Name of the Figma page to filter icons by (useful when multiple pages share the same frame name) + // figmaPageName = "Outlined" // [optional] RegExp pattern for icon name validation before exporting. // If a name contains "/" symbol it will be replaced by "_" before executing the RegExp. nameValidateRegexp = "^(ic)_(\\d\\d)_([a-z0-9_]+)$" // RegExp pattern for: ic_24_icon_name, ic_24_icon @@ -67,6 +69,8 @@ common = new Common.CommonConfig { images = new Common.Images { // [optional] Name of the Figma's frame where image components are located figmaFrameName = "Illustrations" + // [optional] Name of the Figma page to filter images by (useful when multiple pages share the same frame name) + // figmaPageName = "Marketing" // [optional] RegExp pattern for image name validation before exporting. // If a name contains "/" symbol it will be replaced by "_" before executing the RegExp. nameValidateRegexp = "^(img)_([a-z0-9_]+)$" // RegExp pattern for: img_image_name diff --git a/Sources/ExFigCLI/Resources/iOSConfig.swift b/Sources/ExFigCLI/Resources/iOSConfig.swift index 88889959..b25c8dc5 100644 --- a/Sources/ExFigCLI/Resources/iOSConfig.swift +++ b/Sources/ExFigCLI/Resources/iOSConfig.swift @@ -65,6 +65,8 @@ common = new Common.CommonConfig { icons = new Common.Icons { // [optional] Name of the Figma's frame where icons components are located figmaFrameName = "Icons" + // [optional] Name of the Figma page to filter icons by (useful when multiple pages share the same frame name) + // figmaPageName = "Outlined" // [optional] RegExp pattern for icon name validation before exporting. // If a name contains "/" symbol it will be replaced by "_" before executing the RegExp. nameValidateRegexp = "^(ic)_(\\d\\d)_([a-z0-9_]+)$" // RegExp pattern for: ic_24_icon_name, ic_24_icon @@ -79,6 +81,8 @@ common = new Common.CommonConfig { images = new Common.Images { // [optional] Name of the Figma's frame where image components are located figmaFrameName = "Illustrations" + // [optional] Name of the Figma page to filter images by (useful when multiple pages share the same frame name) + // figmaPageName = "Marketing" // [optional] RegExp pattern for image name validation before exporting. // If a name contains "/" symbol it will be replaced by "_" before executing the RegExp. nameValidateRegexp = "^(img)_([a-z0-9_]+)$" // RegExp pattern for: img_image_name diff --git a/Sources/ExFigCLI/Resources/webConfig.swift b/Sources/ExFigCLI/Resources/webConfig.swift index d628a683..0ce6db0f 100644 --- a/Sources/ExFigCLI/Resources/webConfig.swift +++ b/Sources/ExFigCLI/Resources/webConfig.swift @@ -54,6 +54,8 @@ common = new Common.CommonConfig { icons = new Common.Icons { // [optional] Name of the Figma's frame where icons components are located figmaFrameName = "Icons" + // [optional] Name of the Figma page to filter icons by (useful when multiple pages share the same frame name) + // figmaPageName = "Outlined" // [optional] RegExp pattern for icon name validation before exporting. // If a name contains "/" symbol it will be replaced by "_" before executing the RegExp. nameValidateRegexp = "^(ic)_(\\d\\d)_([a-z0-9_]+)$" @@ -68,6 +70,8 @@ common = new Common.CommonConfig { images = new Common.Images { // [optional] Name of the Figma's frame where image components are located figmaFrameName = "Illustrations" + // [optional] Name of the Figma page to filter images by (useful when multiple pages share the same frame name) + // figmaPageName = "Marketing" // [optional] RegExp pattern for image name validation before exporting. // If a name contains "/" symbol it will be replaced by "_" before executing the RegExp. nameValidateRegexp = "^(img)_([a-z0-9_]+)$" diff --git a/Sources/ExFigCLI/Subcommands/DownloadIcons.swift b/Sources/ExFigCLI/Subcommands/DownloadIcons.swift index f0e5127c..f2166937 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadIcons.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadIcons.swift @@ -52,6 +52,9 @@ extension ExFigCommand.Download { @Option(name: .long, help: "Figma frame name containing icons (default: from config or 'Icons')") var frameName: String? + @Option(name: [.customLong("page"), .customShort("p")], help: "Filter by Figma page name.") + var pageName: String? + @Argument(help: "Filter icons by name pattern (e.g., 'navigation/*')") var filter: String? @@ -80,6 +83,7 @@ extension ExFigCommand.Download { ?? "Icons" let filterValue = filter + let pageNameValue = pageName guard let fileId = options.params.figma?.lightFileId else { throw ExFigError.custom(errorString: "figma.lightFileId is required for icons download.") } @@ -91,6 +95,7 @@ extension ExFigCommand.Download { client: client, fileId: fileId, frameName: effectiveFrameName, + pageName: pageNameValue, filter: filterValue ) } diff --git a/Sources/ExFigCLI/Subcommands/DownloadImages.swift b/Sources/ExFigCLI/Subcommands/DownloadImages.swift index 0175cc07..0126721d 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadImages.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadImages.swift @@ -174,6 +174,7 @@ extension ExFigCommand { return try await loader.loadVectorImages( fileId: downloadOptions.fileId, frameName: downloadOptions.frameName, + pageName: downloadOptions.pageName, params: params, filter: downloadOptions.filter, onBatchProgress: onBatchProgress @@ -182,6 +183,7 @@ extension ExFigCommand { return try await loader.loadRasterImages( fileId: downloadOptions.fileId, frameName: downloadOptions.frameName, + pageName: downloadOptions.pageName, scale: downloadOptions.effectiveScale, format: downloadOptions.format.rawValue, filter: downloadOptions.filter, diff --git a/Sources/ExFigConfig/Generated/Android.pkl.swift b/Sources/ExFigConfig/Generated/Android.pkl.swift index 8d664b87..c8e60781 100644 --- a/Sources/ExFigConfig/Generated/Android.pkl.swift +++ b/Sources/ExFigConfig/Generated/Android.pkl.swift @@ -282,6 +282,11 @@ extension Android { /// Figma frame name to export from. public var figmaFrameName: String? + /// Figma page name to filter components by. + /// When set, only components from this specific page are exported. + /// Useful when multiple pages have frames with the same name. + public var figmaPageName: String? + /// Override Figma file ID for this specific entry. /// When set, overrides the global `figma.lightFileId` for loading data. public var figmaFileId: String? @@ -311,6 +316,7 @@ extension Android { pathPrecision: Int?, strictPathValidation: Bool?, figmaFrameName: String?, + figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, nameValidateRegexp: String?, @@ -326,6 +332,7 @@ extension Android { self.pathPrecision = pathPrecision self.strictPathValidation = strictPathValidation self.figmaFrameName = figmaFrameName + self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty self.nameValidateRegexp = nameValidateRegexp @@ -366,6 +373,11 @@ extension Android { /// Figma frame name to export from. public var figmaFrameName: String? + /// Figma page name to filter components by. + /// When set, only components from this specific page are exported. + /// Useful when multiple pages have frames with the same name. + public var figmaPageName: String? + /// Override Figma file ID for this specific entry. /// When set, overrides the global `figma.lightFileId` for loading data. public var figmaFileId: String? @@ -394,6 +406,7 @@ extension Android { sourceFormat: Common.SourceFormat?, nameStyle: Common.NameStyle?, figmaFrameName: String?, + figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, nameValidateRegexp: String?, @@ -408,6 +421,7 @@ extension Android { self.sourceFormat = sourceFormat self.nameStyle = nameStyle self.figmaFrameName = figmaFrameName + self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty self.nameValidateRegexp = nameValidateRegexp diff --git a/Sources/ExFigConfig/Generated/Common.pkl.swift b/Sources/ExFigConfig/Generated/Common.pkl.swift index 7b2d8790..b9a8fa34 100644 --- a/Sources/ExFigConfig/Generated/Common.pkl.swift +++ b/Sources/ExFigConfig/Generated/Common.pkl.swift @@ -28,6 +28,8 @@ public protocol Common_NameProcessing: PklRegisteredType, DynamicallyEquatable, public protocol Common_FrameSource: Common_NameProcessing { var figmaFrameName: String? { get } + var figmaPageName: String? { get } + var figmaFileId: String? { get } var rtlProperty: String? { get } @@ -189,6 +191,11 @@ extension Common { /// Figma frame name to export from. public var figmaFrameName: String? + /// Figma page name to filter components by. + /// When set, only components from this specific page are exported. + /// Useful when multiple pages have frames with the same name. + public var figmaPageName: String? + /// Override Figma file ID for this specific entry. /// When set, overrides the global `figma.lightFileId` for loading data. public var figmaFileId: String? @@ -209,12 +216,14 @@ extension Common { public init( figmaFrameName: String?, + figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { self.figmaFrameName = figmaFrameName + self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty self.nameValidateRegexp = nameValidateRegexp @@ -268,6 +277,9 @@ extension Common { /// Figma frame name containing icons. public var figmaFrameName: String? + /// Figma page name to filter icons by. + public var figmaPageName: String? + /// Use single file for all icon modes. public var useSingleFile: Bool? @@ -285,6 +297,7 @@ extension Common { public init( figmaFrameName: String?, + figmaPageName: String?, useSingleFile: Bool?, darkModeSuffix: String?, strictPathValidation: Bool?, @@ -292,6 +305,7 @@ extension Common { nameReplaceRegexp: String? ) { self.figmaFrameName = figmaFrameName + self.figmaPageName = figmaPageName self.useSingleFile = useSingleFile self.darkModeSuffix = darkModeSuffix self.strictPathValidation = strictPathValidation @@ -307,6 +321,9 @@ extension Common { /// Figma frame name containing images. public var figmaFrameName: String? + /// Figma page name to filter images by. + public var figmaPageName: String? + /// Use single file for all image modes. public var useSingleFile: Bool? @@ -321,12 +338,14 @@ extension Common { public init( figmaFrameName: String?, + figmaPageName: String?, useSingleFile: Bool?, darkModeSuffix: String?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { self.figmaFrameName = figmaFrameName + self.figmaPageName = figmaPageName self.useSingleFile = useSingleFile self.darkModeSuffix = darkModeSuffix self.nameValidateRegexp = nameValidateRegexp diff --git a/Sources/ExFigConfig/Generated/Flutter.pkl.swift b/Sources/ExFigConfig/Generated/Flutter.pkl.swift index 306f2dd5..5b061630 100644 --- a/Sources/ExFigConfig/Generated/Flutter.pkl.swift +++ b/Sources/ExFigConfig/Generated/Flutter.pkl.swift @@ -111,6 +111,11 @@ extension Flutter { /// Figma frame name to export from. public var figmaFrameName: String? + /// Figma page name to filter components by. + /// When set, only components from this specific page are exported. + /// Useful when multiple pages have frames with the same name. + public var figmaPageName: String? + /// Override Figma file ID for this specific entry. /// When set, overrides the global `figma.lightFileId` for loading data. public var figmaFileId: String? @@ -136,6 +141,7 @@ extension Flutter { className: String?, nameStyle: Common.NameStyle?, figmaFrameName: String?, + figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, nameValidateRegexp: String?, @@ -147,6 +153,7 @@ extension Flutter { self.className = className self.nameStyle = nameStyle self.figmaFrameName = figmaFrameName + self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty self.nameValidateRegexp = nameValidateRegexp @@ -189,6 +196,11 @@ extension Flutter { /// Figma frame name to export from. public var figmaFrameName: String? + /// Figma page name to filter components by. + /// When set, only components from this specific page are exported. + /// Useful when multiple pages have frames with the same name. + public var figmaPageName: String? + /// Override Figma file ID for this specific entry. /// When set, overrides the global `figma.lightFileId` for loading data. public var figmaFileId: String? @@ -218,6 +230,7 @@ extension Flutter { sourceFormat: Common.SourceFormat?, nameStyle: Common.NameStyle?, figmaFrameName: String?, + figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, nameValidateRegexp: String?, @@ -233,6 +246,7 @@ extension Flutter { self.sourceFormat = sourceFormat self.nameStyle = nameStyle self.figmaFrameName = figmaFrameName + self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty self.nameValidateRegexp = nameValidateRegexp diff --git a/Sources/ExFigConfig/Generated/Web.pkl.swift b/Sources/ExFigConfig/Generated/Web.pkl.swift index e2abafda..e0ecf346 100644 --- a/Sources/ExFigConfig/Generated/Web.pkl.swift +++ b/Sources/ExFigConfig/Generated/Web.pkl.swift @@ -123,6 +123,11 @@ extension Web { /// Figma frame name to export from. public var figmaFrameName: String? + /// Figma page name to filter components by. + /// When set, only components from this specific page are exported. + /// Useful when multiple pages have frames with the same name. + public var figmaPageName: String? + /// Override Figma file ID for this specific entry. /// When set, overrides the global `figma.lightFileId` for loading data. public var figmaFileId: String? @@ -149,6 +154,7 @@ extension Web { iconSize: Int?, nameStyle: Common.NameStyle?, figmaFrameName: String?, + figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, nameValidateRegexp: String?, @@ -161,6 +167,7 @@ extension Web { self.iconSize = iconSize self.nameStyle = nameStyle self.figmaFrameName = figmaFrameName + self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty self.nameValidateRegexp = nameValidateRegexp @@ -191,6 +198,11 @@ extension Web { /// Figma frame name to export from. public var figmaFrameName: String? + /// Figma page name to filter components by. + /// When set, only components from this specific page are exported. + /// Useful when multiple pages have frames with the same name. + public var figmaPageName: String? + /// Override Figma file ID for this specific entry. /// When set, overrides the global `figma.lightFileId` for loading data. public var figmaFileId: String? @@ -216,6 +228,7 @@ extension Web { generateReactComponents: Bool?, nameStyle: Common.NameStyle?, figmaFrameName: String?, + figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, nameValidateRegexp: String?, @@ -227,6 +240,7 @@ extension Web { self.generateReactComponents = generateReactComponents self.nameStyle = nameStyle self.figmaFrameName = figmaFrameName + self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty self.nameValidateRegexp = nameValidateRegexp diff --git a/Sources/ExFigConfig/Generated/iOS.pkl.swift b/Sources/ExFigConfig/Generated/iOS.pkl.swift index eecd20c9..8257f87b 100644 --- a/Sources/ExFigConfig/Generated/iOS.pkl.swift +++ b/Sources/ExFigConfig/Generated/iOS.pkl.swift @@ -204,6 +204,11 @@ extension iOS { /// Figma frame name to export from. public var figmaFrameName: String? + /// Figma page name to filter components by. + /// When set, only components from this specific page are exported. + /// Useful when multiple pages have frames with the same name. + public var figmaPageName: String? + /// Override Figma file ID for this specific entry. /// When set, overrides the global `figma.lightFileId` for loading data. public var figmaFileId: String? @@ -237,6 +242,7 @@ extension iOS { renderModeOriginalSuffix: String?, renderModeTemplateSuffix: String?, figmaFrameName: String?, + figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, nameValidateRegexp: String?, @@ -256,6 +262,7 @@ extension iOS { self.renderModeOriginalSuffix = renderModeOriginalSuffix self.renderModeTemplateSuffix = renderModeTemplateSuffix self.figmaFrameName = figmaFrameName + self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty self.nameValidateRegexp = nameValidateRegexp @@ -317,6 +324,11 @@ extension iOS { /// Figma frame name to export from. public var figmaFrameName: String? + /// Figma page name to filter components by. + /// When set, only components from this specific page are exported. + /// Useful when multiple pages have frames with the same name. + public var figmaPageName: String? + /// Override Figma file ID for this specific entry. /// When set, overrides the global `figma.lightFileId` for loading data. public var figmaFileId: String? @@ -352,6 +364,7 @@ extension iOS { renderModeOriginalSuffix: String?, renderModeTemplateSuffix: String?, figmaFrameName: String?, + figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, nameValidateRegexp: String?, @@ -373,6 +386,7 @@ extension iOS { self.renderModeOriginalSuffix = renderModeOriginalSuffix self.renderModeTemplateSuffix = renderModeTemplateSuffix self.figmaFrameName = figmaFrameName + self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty self.nameValidateRegexp = nameValidateRegexp diff --git a/Sources/ExFigCore/FileContents.swift b/Sources/ExFigCore/FileContents.swift index 732e18e1..62b0d032 100644 --- a/Sources/ExFigCore/FileContents.swift +++ b/Sources/ExFigCore/FileContents.swift @@ -5,11 +5,10 @@ public struct Destination: Equatable, Sendable { public let file: URL public var url: URL { - // Use lastPathComponent to handle both URL(string:) and URL(fileURLWithPath:) cases - // URL(fileURLWithPath: "file.ext") creates absolute path, .path returns full path - // URL(string: "file.ext") creates relative URL, .path may be empty or just filename - // lastPathComponent reliably extracts just the filename in both cases - directory.appendingPathComponent(file.lastPathComponent) + // URL(fileURLWithPath:) → absolute file URL, use lastPathComponent (just filename) + // URL(string:) → relative URL, preserve full path including subdirectories + let relativePath = file.isFileURL ? file.lastPathComponent : file.path + return directory.appendingPathComponent(relativePath) } public init(directory: URL, file: URL) { diff --git a/Sources/ExFigCore/Protocol/IconsExportContext.swift b/Sources/ExFigCore/Protocol/IconsExportContext.swift index 2a535a41..e40ee201 100644 --- a/Sources/ExFigCore/Protocol/IconsExportContext.swift +++ b/Sources/ExFigCore/Protocol/IconsExportContext.swift @@ -69,6 +69,9 @@ public struct IconsSourceInput: Sendable { /// The frame name containing icons. public let frameName: String + /// Optional page name to filter icons by. + public let pageName: String? + /// Icon format (svg or pdf, iOS only). public let format: VectorFormat @@ -98,6 +101,7 @@ public struct IconsSourceInput: Sendable { figmaFileId: String? = nil, darkFileId: String? = nil, frameName: String, + pageName: String? = nil, format: VectorFormat = .svg, useSingleFile: Bool = false, darkModeSuffix: String = "_dark", @@ -112,6 +116,7 @@ public struct IconsSourceInput: Sendable { self.figmaFileId = figmaFileId self.darkFileId = darkFileId self.frameName = frameName + self.pageName = pageName self.format = format self.useSingleFile = useSingleFile self.darkModeSuffix = darkModeSuffix diff --git a/Sources/ExFigCore/Protocol/ImagesExportContext.swift b/Sources/ExFigCore/Protocol/ImagesExportContext.swift index 201bd3b3..0ef9a182 100644 --- a/Sources/ExFigCore/Protocol/ImagesExportContext.swift +++ b/Sources/ExFigCore/Protocol/ImagesExportContext.swift @@ -171,6 +171,9 @@ public struct ImagesSourceInput: Sendable { /// The frame name containing images. public let frameName: String + /// Optional page name to filter images by. + public let pageName: String? + /// Source format for images (png or svg). public let sourceFormat: ImageSourceFormat @@ -197,6 +200,7 @@ public struct ImagesSourceInput: Sendable { figmaFileId: String? = nil, darkFileId: String? = nil, frameName: String, + pageName: String? = nil, sourceFormat: ImageSourceFormat = .png, scales: [Double] = [1.0, 2.0, 3.0], useSingleFile: Bool = false, @@ -208,6 +212,7 @@ public struct ImagesSourceInput: Sendable { self.figmaFileId = figmaFileId self.darkFileId = darkFileId self.frameName = frameName + self.pageName = pageName self.sourceFormat = sourceFormat self.scales = scales self.useSingleFile = useSingleFile diff --git a/Tests/ExFigCoreTests/FileContentsTests.swift b/Tests/ExFigCoreTests/FileContentsTests.swift index c9b90538..a723eab1 100644 --- a/Tests/ExFigCoreTests/FileContentsTests.swift +++ b/Tests/ExFigCoreTests/FileContentsTests.swift @@ -11,6 +11,30 @@ final class DestinationTests: XCTestCase { XCTAssertEqual(destination.url.path, "/output/images/icon.png") } + func testURLWithSubdirectoryPath() throws { + let directory = URL(fileURLWithPath: "/output/lib") + let file = try XCTUnwrap(URL(string: "icons/actions.dart")) + let destination = Destination(directory: directory, file: file) + + XCTAssertEqual(destination.url.path, "/output/lib/icons/actions.dart") + } + + func testURLWithDeepSubdirectoryPath() throws { + let directory = URL(fileURLWithPath: "/output/lib") + let file = try XCTUnwrap(URL(string: "src/icons/actions.dart")) + let destination = Destination(directory: directory, file: file) + + XCTAssertEqual(destination.url.path, "/output/lib/src/icons/actions.dart") + } + + func testURLPreservesSimpleFileURLPath() { + let directory = URL(fileURLWithPath: "/output/images") + let file = URL(fileURLWithPath: "icon.png") + let destination = Destination(directory: directory, file: file) + + XCTAssertEqual(destination.url.lastPathComponent, "icon.png") + } + func testEquality() { let dest1 = Destination( directory: URL(fileURLWithPath: "/output"), diff --git a/Tests/ExFigTests/Helpers/TestHelpers.swift b/Tests/ExFigTests/Helpers/TestHelpers.swift index 92b3517f..0e715269 100644 --- a/Tests/ExFigTests/Helpers/TestHelpers.swift +++ b/Tests/ExFigTests/Helpers/TestHelpers.swift @@ -188,18 +188,23 @@ extension PKLConfig { lightFileId: String, darkFileId: String? = nil, iconsFrameName: String? = nil, + iconsPageName: String? = nil, imagesFrameName: String? = nil, + imagesPageName: String? = nil, useSingleFileIcons: Bool? = nil, useSingleFileImages: Bool? = nil, iconsDarkModeSuffix: String? = nil ) -> PKLConfig { var commonComponents: [String] = [] - if iconsFrameName != nil || useSingleFileIcons != nil || iconsDarkModeSuffix != nil { + if iconsFrameName != nil || iconsPageName != nil || useSingleFileIcons != nil || iconsDarkModeSuffix != nil { var iconParts: [String] = [] if let frameName = iconsFrameName { iconParts.append("\"figmaFrameName\": \"\(frameName)\"") } + if let pageName = iconsPageName { + iconParts.append("\"figmaPageName\": \"\(pageName)\"") + } if let useSingle = useSingleFileIcons { iconParts.append("\"useSingleFile\": \(useSingle)") } @@ -209,11 +214,14 @@ extension PKLConfig { commonComponents.append("\"icons\": { \(iconParts.joined(separator: ", ")) }") } - if imagesFrameName != nil || useSingleFileImages != nil { + if imagesFrameName != nil || imagesPageName != nil || useSingleFileImages != nil { var imageParts: [String] = [] if let frameName = imagesFrameName { imageParts.append("\"figmaFrameName\": \"\(frameName)\"") } + if let pageName = imagesPageName { + imageParts.append("\"figmaPageName\": \"\(pageName)\"") + } if let useSingle = useSingleFileImages { imageParts.append("\"useSingleFile\": \(useSingle)") } diff --git a/Tests/ExFigTests/Input/EnumBridgingTests.swift b/Tests/ExFigTests/Input/EnumBridgingTests.swift index 6148e489..3c3ac5a2 100644 --- a/Tests/ExFigTests/Input/EnumBridgingTests.swift +++ b/Tests/ExFigTests/Input/EnumBridgingTests.swift @@ -111,6 +111,7 @@ final class EnumBridgingTests: XCTestCase { renderModeOriginalSuffix: nil, renderModeTemplateSuffix: nil, figmaFrameName: nil, + figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, nameValidateRegexp: nil, @@ -153,6 +154,7 @@ final class EnumBridgingTests: XCTestCase { renderModeOriginalSuffix: nil, renderModeTemplateSuffix: nil, figmaFrameName: nil, + figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, nameValidateRegexp: nil, @@ -189,6 +191,7 @@ final class EnumBridgingTests: XCTestCase { pathPrecision: nil, strictPathValidation: nil, figmaFrameName: nil, + figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, nameValidateRegexp: nil, @@ -213,6 +216,7 @@ final class EnumBridgingTests: XCTestCase { pathPrecision: nil, strictPathValidation: nil, figmaFrameName: nil, + figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, nameValidateRegexp: nil, @@ -244,6 +248,7 @@ final class EnumBridgingTests: XCTestCase { sourceFormat: nil, nameStyle: pklStyle, figmaFrameName: nil, + figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, nameValidateRegexp: nil, @@ -267,6 +272,7 @@ final class EnumBridgingTests: XCTestCase { sourceFormat: nil, nameStyle: nil, figmaFrameName: nil, + figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, nameValidateRegexp: nil, @@ -328,6 +334,7 @@ final class EnumBridgingTests: XCTestCase { renderModeOriginalSuffix: nil, renderModeTemplateSuffix: nil, figmaFrameName: nil, + figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, nameValidateRegexp: nil, @@ -359,6 +366,7 @@ final class EnumBridgingTests: XCTestCase { renderModeOriginalSuffix: nil, renderModeTemplateSuffix: nil, figmaFrameName: nil, + figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, nameValidateRegexp: nil, diff --git a/Tests/ExFigTests/Loaders/IconsLoaderConfigTests.swift b/Tests/ExFigTests/Loaders/IconsLoaderConfigTests.swift index b24e8ef4..4264f5dd 100644 --- a/Tests/ExFigTests/Loaders/IconsLoaderConfigTests.swift +++ b/Tests/ExFigTests/Loaders/IconsLoaderConfigTests.swift @@ -1,3 +1,5 @@ +// swiftlint:disable file_length type_body_length + import ExFig_Android import ExFig_Flutter import ExFig_iOS @@ -214,6 +216,7 @@ final class IconsLoaderConfigTests: XCTestCase { let config = IconsLoaderConfig( entryFileId: nil, frameName: "Icons", + pageName: nil, format: .svg, renderMode: nil, renderModeDefaultSuffix: nil, @@ -229,6 +232,7 @@ final class IconsLoaderConfigTests: XCTestCase { let config = IconsLoaderConfig( entryFileId: nil, frameName: "Icons", + pageName: nil, format: .pdf, renderMode: nil, renderModeDefaultSuffix: nil, @@ -253,6 +257,7 @@ final class IconsLoaderConfigTests: XCTestCase { let config = IconsLoaderConfig( entryFileId: source.figmaFileId, frameName: source.frameName, + pageName: source.pageName, format: source.format, renderMode: source.renderMode, renderModeDefaultSuffix: source.renderModeDefaultSuffix, @@ -272,6 +277,7 @@ final class IconsLoaderConfigTests: XCTestCase { let config = IconsLoaderConfig( entryFileId: source.figmaFileId, frameName: source.frameName, + pageName: source.pageName, format: source.format, renderMode: source.renderMode, renderModeDefaultSuffix: source.renderModeDefaultSuffix, @@ -295,6 +301,7 @@ final class IconsLoaderConfigTests: XCTestCase { let config = IconsLoaderConfig( entryFileId: source.figmaFileId, frameName: source.frameName, + pageName: source.pageName, format: source.format, renderMode: source.renderMode, renderModeDefaultSuffix: source.renderModeDefaultSuffix, @@ -319,10 +326,85 @@ final class IconsLoaderConfigTests: XCTestCase { XCTAssertEqual(source.rtlProperty, "IsRTL", "Custom rtlProperty name must be preserved") } + // MARK: - Page Name Resolution + + func testForIOS_entryPageNameOverridesCommon() throws { + let entry = try makeIOSEntry(figmaPageName: "Outlined") + let params = PKLConfig.make(lightFileId: "test", iconsPageName: "Filled") + + let config = IconsLoaderConfig.forIOS(entry: entry, params: params) + + XCTAssertEqual(config.pageName, "Outlined") + } + + func testForIOS_fallbackToCommonPageName() throws { + let entry = try makeIOSEntry() + let params = PKLConfig.make(lightFileId: "test", iconsPageName: "Filled") + + let config = IconsLoaderConfig.forIOS(entry: entry, params: params) + + XCTAssertEqual(config.pageName, "Filled") + } + + func testForIOS_pageNameNilByDefault() throws { + let entry = try makeIOSEntry() + let params = PKLConfig.make(lightFileId: "test") + + let config = IconsLoaderConfig.forIOS(entry: entry, params: params) + + XCTAssertNil(config.pageName) + } + + func testForAndroid_entryPageNameOverridesCommon() throws { + let entry = try makeAndroidEntry(figmaPageName: "Outlined") + let params = PKLConfig.make(lightFileId: "test", iconsPageName: "Filled") + + let config = IconsLoaderConfig.forAndroid(entry: entry, params: params) + + XCTAssertEqual(config.pageName, "Outlined") + } + + func testDefaultConfig_usesCommonPageName() { + let params = PKLConfig.make(lightFileId: "test", iconsPageName: "Filled") + + let config = IconsLoaderConfig.defaultConfig(params: params) + + XCTAssertEqual(config.pageName, "Filled") + } + + func testDefaultConfig_pageNameNilByDefault() { + let params = PKLConfig.make(lightFileId: "test") + + let config = IconsLoaderConfig.defaultConfig(params: params) + + XCTAssertNil(config.pageName) + } + + func testPageNamePreservedThroughEntryToSourceToConfig() throws { + let entry = try makeIOSEntry(figmaPageName: "Outlined") + + let source = entry.iconsSourceInput() + XCTAssertEqual(source.pageName, "Outlined", "pageName must survive entry → source conversion") + + let config = IconsLoaderConfig( + entryFileId: source.figmaFileId, + frameName: source.frameName, + pageName: source.pageName, + format: source.format, + renderMode: source.renderMode, + renderModeDefaultSuffix: source.renderModeDefaultSuffix, + renderModeOriginalSuffix: source.renderModeOriginalSuffix, + renderModeTemplateSuffix: source.renderModeTemplateSuffix, + rtlProperty: source.rtlProperty + ) + XCTAssertEqual(config.pageName, "Outlined", "pageName must survive source → config conversion") + } + // MARK: - Helpers private func makeIOSEntry( figmaFrameName: String? = nil, + figmaPageName: String? = nil, format: String = "svg", assetsFolder: String = "Icons", nameStyle: String = "camelCase", @@ -342,6 +424,9 @@ final class IconsLoaderConfigTests: XCTestCase { if let figmaFrameName { json = json.replacingOccurrences(of: "{", with: "{ \"figmaFrameName\": \"\(figmaFrameName)\",") } + if let figmaPageName { + json += ", \"figmaPageName\": \"\(figmaPageName)\"" + } if let renderMode { json += ", \"renderMode\": \"\(renderMode)\"" } @@ -364,6 +449,7 @@ final class IconsLoaderConfigTests: XCTestCase { private func makeAndroidEntry( figmaFrameName: String? = nil, + figmaPageName: String? = nil, output: String = "drawable" ) throws -> AndroidIconsEntry { var json = """ @@ -374,6 +460,9 @@ final class IconsLoaderConfigTests: XCTestCase { if let figmaFrameName { json = json.replacingOccurrences(of: "{", with: "{ \"figmaFrameName\": \"\(figmaFrameName)\",") } + if let figmaPageName { + json += ", \"figmaPageName\": \"\(figmaPageName)\"" + } json += "}" return try JSONDecoder().decode(AndroidIconsEntry.self, from: Data(json.utf8)) diff --git a/Tests/ExFigTests/Loaders/ImagesLoaderConfigTests.swift b/Tests/ExFigTests/Loaders/ImagesLoaderConfigTests.swift index 5568cde4..55f55ced 100644 --- a/Tests/ExFigTests/Loaders/ImagesLoaderConfigTests.swift +++ b/Tests/ExFigTests/Loaders/ImagesLoaderConfigTests.swift @@ -1,3 +1,5 @@ +// swiftlint:disable file_length + import ExFig_Android import ExFig_Flutter import ExFig_iOS @@ -259,6 +261,7 @@ final class ImagesLoaderConfigTests: XCTestCase { let config = ImagesLoaderConfig( entryFileId: source.figmaFileId, frameName: source.frameName, + pageName: source.pageName, scales: source.scales, format: nil, sourceFormat: .png, @@ -281,10 +284,74 @@ final class ImagesLoaderConfigTests: XCTestCase { XCTAssertEqual(source.rtlProperty, "IsRTL", "Custom rtlProperty name must be preserved") } + // MARK: - Page Name Resolution + + func testForIOS_entryPageNameOverridesCommon() throws { + let entry = try makeIOSEntry(figmaPageName: "Marketing") + let params = PKLConfig.make(lightFileId: "test", imagesPageName: "Promo") + + let config = ImagesLoaderConfig.forIOS(entry: entry, params: params) + + XCTAssertEqual(config.pageName, "Marketing") + } + + func testForIOS_fallbackToCommonPageName() throws { + let entry = try makeIOSEntry() + let params = PKLConfig.make(lightFileId: "test", imagesPageName: "Promo") + + let config = ImagesLoaderConfig.forIOS(entry: entry, params: params) + + XCTAssertEqual(config.pageName, "Promo") + } + + func testForIOS_pageNameNilByDefault() throws { + let entry = try makeIOSEntry() + let params = PKLConfig.make(lightFileId: "test") + + let config = ImagesLoaderConfig.forIOS(entry: entry, params: params) + + XCTAssertNil(config.pageName) + } + + func testDefaultConfig_usesCommonPageName() { + let params = PKLConfig.make(lightFileId: "test", imagesPageName: "Promo") + + let config = ImagesLoaderConfig.defaultConfig(params: params) + + XCTAssertEqual(config.pageName, "Promo") + } + + func testDefaultConfig_pageNameNilByDefault() { + let params = PKLConfig.make(lightFileId: "test") + + let config = ImagesLoaderConfig.defaultConfig(params: params) + + XCTAssertNil(config.pageName) + } + + func testPageNamePreservedThroughEntryToSourceToConfig() throws { + let entry = try makeIOSEntry(figmaPageName: "Marketing") + + let source = entry.imagesSourceInput() + XCTAssertEqual(source.pageName, "Marketing", "pageName must survive entry → source conversion") + + let config = ImagesLoaderConfig( + entryFileId: source.figmaFileId, + frameName: source.frameName, + pageName: source.pageName, + scales: source.scales, + format: nil, + sourceFormat: .png, + rtlProperty: source.rtlProperty + ) + XCTAssertEqual(config.pageName, "Marketing", "pageName must survive source → config conversion") + } + // MARK: - Helpers private func makeIOSEntry( figmaFrameName: String? = nil, + figmaPageName: String? = nil, assetsFolder: String = "Images", nameStyle: String = "camelCase", scales: [Double]? = nil, @@ -299,6 +366,9 @@ final class ImagesLoaderConfigTests: XCTestCase { if let figmaFrameName { json = json.replacingOccurrences(of: "{", with: "{ \"figmaFrameName\": \"\(figmaFrameName)\",") } + if let figmaPageName { + json += ", \"figmaPageName\": \"\(figmaPageName)\"" + } if let scales { let scalesJson = scales.map { String($0) }.joined(separator: ", ") json += ", \"scales\": [\(scalesJson)]" diff --git a/Tests/ExFigTests/Output/FileWriterTests.swift b/Tests/ExFigTests/Output/FileWriterTests.swift index 1f9f19db..090f69e6 100644 --- a/Tests/ExFigTests/Output/FileWriterTests.swift +++ b/Tests/ExFigTests/Output/FileWriterTests.swift @@ -277,6 +277,41 @@ final class FileWriterTests: XCTestCase { XCTAssertTrue(FileManager.default.fileExists(atPath: newDir.path)) } + // MARK: - Subdirectory Paths + + func testWriteCreatesSubdirectoriesFromFilePath() throws { + let writer = FileWriter() + let destination = try Destination( + directory: tempDirectory, + // swiftlint:disable:next force_unwrapping + file: XCTUnwrap(URL(string: "subdir/file.txt")) + ) + let file = FileContents(destination: destination, data: Data("subdirectory content".utf8)) + + try writer.write(files: [file]) + + let writtenURL = tempDirectory.appendingPathComponent("subdir/file.txt") + XCTAssertTrue(FileManager.default.fileExists(atPath: writtenURL.path)) + + let writtenData = try Data(contentsOf: writtenURL) + XCTAssertEqual(writtenData, Data("subdirectory content".utf8)) + } + + func testWriteParallelCreatesSubdirectoriesFromFilePath() async throws { + let writer = FileWriter() + let destination = try Destination( + directory: tempDirectory, + // swiftlint:disable:next force_unwrapping + file: XCTUnwrap(URL(string: "icons/actions.dart")) + ) + let file = FileContents(destination: destination, data: Data("dart content".utf8)) + + try await writer.writeParallel(files: [file]) + + let writtenURL = tempDirectory.appendingPathComponent("icons/actions.dart") + XCTAssertTrue(FileManager.default.fileExists(atPath: writtenURL.path)) + } + // MARK: - File from Disk func testWriteFromDataFile() throws { From ac82ffde71fb1d9997272d4b600e963351d8b1d4 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Wed, 11 Feb 2026 09:55:41 +0500 Subject: [PATCH 2/4] chore: add examples --- CLAUDE.md | 15 +++++++++ CONFIG.md | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++++ MIGRATION.md | 61 ++++++++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 7edae8dc..520e4541 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 = 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: diff --git a/CONFIG.md b/CONFIG.md index 170b4811..1a06eb30 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -1109,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 = 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 = new { /* 17 items */ } +local dcCategories: Mapping = 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 diff --git a/MIGRATION.md b/MIGRATION.md index 87208981..6fa2f20d 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -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 = 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: From 030d094201cf5337d9331bc2ce9dc4d0ff755e93 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Wed, 11 Feb 2026 10:01:21 +0500 Subject: [PATCH 3/4] fix: after review --- .../Export/AndroidImagesExporter.swift | 1 + .../ExFig-iOS/Export/iOSImagesExporter.swift | 1 + Sources/ExFigCLI/ExFigCommand.swift | 21 +++-- .../Loaders/DownloadImageLoader.swift | 18 +++- Sources/ExFigCLI/Loaders/IconsLoader.swift | 4 +- .../ExFigCLI/Loaders/ImageLoaderBase.swift | 18 +++- Sources/ExFigCLI/Loaders/ImagesLoader.swift | 6 +- .../Output/DownloadExportHelpers.swift | 14 +++- .../ExFigCLI/Subcommands/DownloadAll.swift | 16 +++- .../ExFigCLI/Subcommands/DownloadIcons.swift | 3 +- .../ExFigCLI/Subcommands/DownloadImages.swift | 6 +- .../Subcommands/DownloadImagesExport.swift | 2 +- .../ExFigCLI/TerminalUI/ExFigWarning.swift | 2 +- .../TerminalUI/ExFigWarningFormatter.swift | 13 ++- Sources/ExFigCore/FileContents.swift | 1 + Tests/ExFigTests/Helpers/TestHelpers.swift | 4 +- .../Loaders/DownloadImageLoaderTests.swift | 84 +++++++++++++++++++ .../Loaders/IconsLoaderConfigTests.swift | 43 ++++++++++ .../Loaders/ImagesLoaderConfigTests.swift | 28 ++++++- .../ExFigWarningFormatterTests.swift | 18 ++++ 20 files changed, 273 insertions(+), 30 deletions(-) diff --git a/Sources/ExFig-Android/Export/AndroidImagesExporter.swift b/Sources/ExFig-Android/Export/AndroidImagesExporter.swift index 51ddcaa1..32a00d89 100644 --- a/Sources/ExFig-Android/Export/AndroidImagesExporter.swift +++ b/Sources/ExFig-Android/Export/AndroidImagesExporter.swift @@ -369,6 +369,7 @@ private extension AndroidImagesExporter { let input = ImagesSourceInput( figmaFileId: entry.figmaFileId, frameName: entry.figmaFrameName ?? "Images", + pageName: entry.figmaPageName, sourceFormat: .svg, scales: [1.0], useSingleFile: true, diff --git a/Sources/ExFig-iOS/Export/iOSImagesExporter.swift b/Sources/ExFig-iOS/Export/iOSImagesExporter.swift index 871dc2c0..309678c9 100644 --- a/Sources/ExFig-iOS/Export/iOSImagesExporter.swift +++ b/Sources/ExFig-iOS/Export/iOSImagesExporter.swift @@ -472,6 +472,7 @@ private extension iOSImagesEntry { figmaFileId: figmaFileId, darkFileId: nil, frameName: figmaFrameName ?? "Images", + pageName: figmaPageName, sourceFormat: .svg, scales: [1.0], useSingleFile: true, diff --git a/Sources/ExFigCLI/ExFigCommand.swift b/Sources/ExFigCLI/ExFigCommand.swift index 0fabf431..06ae8011 100644 --- a/Sources/ExFigCLI/ExFigCommand.swift +++ b/Sources/ExFigCLI/ExFigCommand.swift @@ -6,7 +6,7 @@ import SVGKit enum ExFigError: LocalizedError { case invalidFileName(String) case stylesNotFound - case componentsNotFound + case componentsNotFound(frameName: String?, pageName: String?) case accessTokenNotFound case colorsAssetsFolderNotSpecified case configurationError(String) @@ -18,8 +18,14 @@ enum ExFigError: LocalizedError { "Invalid file name: \(name)" case .stylesNotFound: "Styles not found in Figma file" - case .componentsNotFound: - "Components not found in Figma file" + case let .componentsNotFound(frameName, pageName): + if let frameName, let pageName { + "No components found in frame '\(frameName)' on page '\(pageName)'" + } else if let frameName { + "No components found in frame '\(frameName)'" + } else { + "Components not found in Figma file" + } case .accessTokenNotFound: "FIGMA_PERSONAL_TOKEN not set" case .colorsAssetsFolderNotSpecified: @@ -37,8 +43,13 @@ enum ExFigError: LocalizedError { "Use alphanumeric characters, underscores, and hyphens only" case .stylesNotFound: "Publish Styles to the Team Library in Figma" - case .componentsNotFound: - "Publish Components to the Team Library in Figma" + case let .componentsNotFound(_, pageName): + if pageName != nil { + "Check that figmaPageName matches exactly (case-sensitive). " + + "Publish Components to the Team Library in Figma." + } else { + "Publish Components to the Team Library in Figma" + } case .accessTokenNotFound: "Run: export FIGMA_PERSONAL_TOKEN=your_token" case .colorsAssetsFolderNotSpecified: diff --git a/Sources/ExFigCLI/Loaders/DownloadImageLoader.swift b/Sources/ExFigCLI/Loaders/DownloadImageLoader.swift index e902e4a5..ec469337 100644 --- a/Sources/ExFigCLI/Loaders/DownloadImageLoader.swift +++ b/Sources/ExFigCLI/Loaders/DownloadImageLoader.swift @@ -31,7 +31,7 @@ final class DownloadImageLoader: Sendable { ) guard !imagesDict.isEmpty else { - throw ExFigError.componentsNotFound + throw ExFigError.componentsNotFound(frameName: frameName, pageName: pageName) } // Filter out empty names @@ -88,7 +88,7 @@ final class DownloadImageLoader: Sendable { ) guard !imagesDict.isEmpty else { - throw ExFigError.componentsNotFound + throw ExFigError.componentsNotFound(frameName: frameName, pageName: pageName) } logger.info("Fetching \(imagesDict.count) images from '\(frameName)' at \(scale)x...") @@ -126,12 +126,24 @@ final class DownloadImageLoader: Sendable { pageName: String? = nil, filter: String? ) async throws -> [NodeId: Component] { - var components = try await loadComponents(fileId: fileId) + let allComponents = try await loadComponents(fileId: fileId) + var components = allComponents .filter { $0.containingFrame.name == frameName && (pageName == nil || $0.containingFrame.pageName == pageName) } + if let pageName, components.isEmpty { + let frameComponents = allComponents.filter { $0.containingFrame.name == frameName } + if !frameComponents.isEmpty { + let availablePages = Set(frameComponents.compactMap(\.containingFrame.pageName)) + let pages = availablePages.sorted().joined(separator: ", ") + logger.info( + "Page filter '\(pageName)' matched no components in frame '\(frameName)'. Available pages: \(pages)" + ) + } + } + if let filter { let assetsFilter = AssetsFilter(filter: filter) components = components.filter { component -> Bool in diff --git a/Sources/ExFigCLI/Loaders/IconsLoader.swift b/Sources/ExFigCLI/Loaders/IconsLoader.swift index f50db4de..46d3b8a4 100644 --- a/Sources/ExFigCLI/Loaders/IconsLoader.swift +++ b/Sources/ExFigCLI/Loaders/IconsLoader.swift @@ -249,7 +249,7 @@ final class IconsLoader: ImageLoaderBase, @unchecked Sendable { } guard let lightIcons = results["light"] else { - throw ExFigError.componentsNotFound + throw ExFigError.componentsNotFound(frameName: frameName, pageName: pageName) } return (lightIcons, results["dark"]) @@ -419,7 +419,7 @@ final class IconsLoader: ImageLoaderBase, @unchecked Sendable { // Extract light and dark icons guard let lightResult else { - throw ExFigError.componentsNotFound + throw ExFigError.componentsNotFound(frameName: frameName, pageName: pageName) } let darkResult = results.first(where: { $0.key == "dark" }) diff --git a/Sources/ExFigCLI/Loaders/ImageLoaderBase.swift b/Sources/ExFigCLI/Loaders/ImageLoaderBase.swift index e9f6ced8..0dcbc70b 100644 --- a/Sources/ExFigCLI/Loaders/ImageLoaderBase.swift +++ b/Sources/ExFigCLI/Loaders/ImageLoaderBase.swift @@ -77,13 +77,25 @@ class ImageLoaderBase: @unchecked Sendable { filter: String? = nil, rtlProperty: String? = Component.defaultRTLProperty ) async throws -> [NodeId: Component] { - var components = try await loadComponents(fileId: fileId) + let allComponents = try await loadComponents(fileId: fileId) + var components = allComponents .filter { $0.containingFrame.name == frameName && (pageName == nil || $0.containingFrame.pageName == pageName) && $0.useForPlatform(platform) } + if let pageName, components.isEmpty { + let frameComponents = allComponents.filter { $0.containingFrame.name == frameName } + if !frameComponents.isEmpty { + let availablePages = Set(frameComponents.compactMap(\.containingFrame.pageName)) + let pages = availablePages.sorted().joined(separator: ", ") + logger.info( + "Page filter '\(pageName)' matched no components in frame '\(frameName)'. Available pages: \(pages)" + ) + } + } + // Skip RTL=On variants: the base (RTL=Off) icon is sufficient — // platforms mirror it at runtime (iOS languageDirection, Android autoMirrored). let beforeRTLFilter = components.count @@ -373,7 +385,7 @@ class ImageLoaderBase: @unchecked Sendable { var imagesDict = components guard !imagesDict.isEmpty else { - throw ExFigError.componentsNotFound + throw ExFigError.componentsNotFound(frameName: frameName, pageName: nil) } imagesDict = filterEmptyNameComponents(imagesDict) @@ -579,7 +591,7 @@ class ImageLoaderBase: @unchecked Sendable { onBatchProgress: @escaping BatchProgressCallback ) async throws -> [ImagePack] { guard !components.isEmpty else { - throw ExFigError.componentsNotFound + throw ExFigError.componentsNotFound(frameName: nil, pageName: nil) } let batchSize = 100 diff --git a/Sources/ExFigCLI/Loaders/ImagesLoader.swift b/Sources/ExFigCLI/Loaders/ImagesLoader.swift index 167411df..4a8bd921 100644 --- a/Sources/ExFigCLI/Loaders/ImagesLoader.swift +++ b/Sources/ExFigCLI/Loaders/ImagesLoader.swift @@ -358,7 +358,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di } guard let lightImages = results["light"] else { - throw ExFigError.componentsNotFound + throw ExFigError.componentsNotFound(frameName: frameName, pageName: pageName) } return (lightImages, results["dark"]) @@ -396,7 +396,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di } guard let lightPacks = results["light"] else { - throw ExFigError.componentsNotFound + throw ExFigError.componentsNotFound(frameName: frameName, pageName: pageName) } return (lightPacks, results["dark"]) @@ -587,7 +587,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di // Extract light and dark packs guard let lightResult else { - throw ExFigError.componentsNotFound + throw ExFigError.componentsNotFound(frameName: frameName, pageName: pageName) } let darkResult = results.first(where: { $0.key == "dark" }) diff --git a/Sources/ExFigCLI/Output/DownloadExportHelpers.swift b/Sources/ExFigCLI/Output/DownloadExportHelpers.swift index 7d89f2e0..42bbb7be 100644 --- a/Sources/ExFigCLI/Output/DownloadExportHelpers.swift +++ b/Sources/ExFigCLI/Output/DownloadExportHelpers.swift @@ -87,12 +87,24 @@ enum AssetExportHelper { filter: String? ) async throws -> [NodeId: Component] { let endpoint = ComponentsEndpoint(fileId: fileId) - var comps = try await client.request(endpoint) + let allComponents = try await client.request(endpoint) + var comps = allComponents .filter { $0.containingFrame.name == frameName && (pageName == nil || $0.containingFrame.pageName == pageName) } + if let pageName, comps.isEmpty { + let frameComponents = allComponents.filter { $0.containingFrame.name == frameName } + if !frameComponents.isEmpty { + let availablePages = Set(frameComponents.compactMap(\.containingFrame.pageName)) + let pages = availablePages.sorted().joined(separator: ", ") + ExFigCommand.logger.info( + "Page filter '\(pageName)' matched no components in frame '\(frameName)'. Available pages: \(pages)" + ) + } + } + if let filter { let assetsFilter = AssetsFilter(filter: filter) comps = comps.filter { assetsFilter.match(name: $0.name) } diff --git a/Sources/ExFigCLI/Subcommands/DownloadAll.swift b/Sources/ExFigCLI/Subcommands/DownloadAll.swift index b217b613..8b4ba768 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadAll.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadAll.swift @@ -158,6 +158,7 @@ extension ExFigCommand.Download { let effectiveFrameName = iconsFrameName ?? options.params.common?.icons?.figmaFrameName ?? "Icons" + let effectivePageName = options.params.common?.icons?.figmaPageName guard let fileId = figmaParams.lightFileId else { throw ExFigError.custom(errorString: "figma.lightFileId is required for icons download.") } @@ -169,12 +170,17 @@ extension ExFigCommand.Download { client: client, fileId: fileId, frameName: effectiveFrameName, + pageName: effectivePageName, filter: nil ) } guard !components.isEmpty else { - ui.warning(.noAssetsFound(assetType: "icons", frameName: effectiveFrameName)) + ui.warning(.noAssetsFound( + assetType: "icons", + frameName: effectiveFrameName, + pageName: effectivePageName + )) return } @@ -220,6 +226,7 @@ extension ExFigCommand.Download { let effectiveFrameName = imagesFrameName ?? options.params.common?.images?.figmaFrameName ?? "Illustrations" + let effectivePageName = options.params.common?.images?.figmaPageName guard let fileId = figmaParams.lightFileId else { throw ExFigError.custom(errorString: "figma.lightFileId is required for images download.") } @@ -231,12 +238,17 @@ extension ExFigCommand.Download { client: client, fileId: fileId, frameName: effectiveFrameName, + pageName: effectivePageName, filter: nil ) } guard !components.isEmpty else { - ui.warning(.noAssetsFound(assetType: "images", frameName: effectiveFrameName)) + ui.warning(.noAssetsFound( + assetType: "images", + frameName: effectiveFrameName, + pageName: effectivePageName + )) return } diff --git a/Sources/ExFigCLI/Subcommands/DownloadIcons.swift b/Sources/ExFigCLI/Subcommands/DownloadIcons.swift index f2166937..3e433049 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadIcons.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadIcons.swift @@ -84,6 +84,7 @@ extension ExFigCommand.Download { let filterValue = filter let pageNameValue = pageName + ?? options.params.common?.icons?.figmaPageName guard let fileId = options.params.figma?.lightFileId else { throw ExFigError.custom(errorString: "figma.lightFileId is required for icons download.") } @@ -101,7 +102,7 @@ extension ExFigCommand.Download { } guard !components.isEmpty else { - throw ExFigError.componentsNotFound + throw ExFigError.componentsNotFound(frameName: effectiveFrameName, pageName: pageNameValue) } let nodeIds = Array(components.keys) diff --git a/Sources/ExFigCLI/Subcommands/DownloadImages.swift b/Sources/ExFigCLI/Subcommands/DownloadImages.swift index 0126721d..519031b7 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadImages.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadImages.swift @@ -100,7 +100,11 @@ extension ExFigCommand { } guard !imagePacks.isEmpty else { - ui.warning(.noAssetsFound(assetType: "images", frameName: downloadOptions.frameName)) + ui.warning(.noAssetsFound( + assetType: "images", + frameName: downloadOptions.frameName, + pageName: downloadOptions.pageName + )) return } diff --git a/Sources/ExFigCLI/Subcommands/DownloadImagesExport.swift b/Sources/ExFigCLI/Subcommands/DownloadImagesExport.swift index 368165aa..b4de3a60 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadImagesExport.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadImagesExport.swift @@ -75,7 +75,7 @@ extension ExFigCommand.Download { } guard !components.isEmpty else { - throw ExFigError.componentsNotFound + throw ExFigError.componentsNotFound(frameName: effectiveFrameName, pageName: nil) } let nodeIds = Array(components.keys) diff --git a/Sources/ExFigCLI/TerminalUI/ExFigWarning.swift b/Sources/ExFigCLI/TerminalUI/ExFigWarning.swift index e047160e..b45774c8 100644 --- a/Sources/ExFigCLI/TerminalUI/ExFigWarning.swift +++ b/Sources/ExFigCLI/TerminalUI/ExFigWarning.swift @@ -15,7 +15,7 @@ enum ExFigWarning: Sendable, Equatable { // MARK: - Asset Discovery Warnings /// No assets found in the specified Figma frame. - case noAssetsFound(assetType: String, frameName: String) + case noAssetsFound(assetType: String, frameName: String, pageName: String? = nil) // MARK: - Xcode Project Warnings diff --git a/Sources/ExFigCLI/TerminalUI/ExFigWarningFormatter.swift b/Sources/ExFigCLI/TerminalUI/ExFigWarningFormatter.swift index 30f9cdcd..c97833a4 100644 --- a/Sources/ExFigCLI/TerminalUI/ExFigWarningFormatter.swift +++ b/Sources/ExFigCLI/TerminalUI/ExFigWarningFormatter.swift @@ -22,8 +22,8 @@ struct ExFigWarningFormatter { formatCompact(warning) // Multiline format warnings - case let .noAssetsFound(assetType, frameName): - formatNoAssetsFound(assetType: assetType, frameName: frameName) + case let .noAssetsFound(assetType, frameName, pageName): + formatNoAssetsFound(assetType: assetType, frameName: frameName, pageName: pageName) case let .invalidConfigsSkipped(count): formatInvalidConfigsSkipped(count: count) @@ -97,12 +97,17 @@ struct ExFigWarningFormatter { // MARK: - Multiline Formatters - private func formatNoAssetsFound(assetType: String, frameName: String) -> String { - """ + private func formatNoAssetsFound(assetType: String, frameName: String, pageName: String?) -> String { + var result = """ No assets found: type: \(assetType) frame: \(frameName) """ + if let pageName { + result += "\n page: \(pageName)" + result += "\n hint: Check that the page name matches exactly (case-sensitive)" + } + return result } private func formatInvalidConfigsSkipped(count: Int) -> String { diff --git a/Sources/ExFigCore/FileContents.swift b/Sources/ExFigCore/FileContents.swift index 62b0d032..d4d49cbf 100644 --- a/Sources/ExFigCore/FileContents.swift +++ b/Sources/ExFigCore/FileContents.swift @@ -12,6 +12,7 @@ public struct Destination: Equatable, Sendable { } public init(directory: URL, file: URL) { + precondition(!file.path.isEmpty, "Destination file URL must not have an empty path") self.directory = directory self.file = file } diff --git a/Tests/ExFigTests/Helpers/TestHelpers.swift b/Tests/ExFigTests/Helpers/TestHelpers.swift index 0e715269..d4e7d708 100644 --- a/Tests/ExFigTests/Helpers/TestHelpers.swift +++ b/Tests/ExFigTests/Helpers/TestHelpers.swift @@ -161,9 +161,9 @@ extension Component { "node_id": "\(nodeId)", "name": "\(name)", "containing_frame": { - "node_id": "\(nodeId)", + "nodeId": "\(nodeId)", "name": "\(frameName)", - "page_name": "\(pageName)" + "pageName": "\(pageName)" } """ if let description { diff --git a/Tests/ExFigTests/Loaders/DownloadImageLoaderTests.swift b/Tests/ExFigTests/Loaders/DownloadImageLoaderTests.swift index 35082ce1..40ab9ad3 100644 --- a/Tests/ExFigTests/Loaders/DownloadImageLoaderTests.swift +++ b/Tests/ExFigTests/Loaders/DownloadImageLoaderTests.swift @@ -405,4 +405,88 @@ final class DownloadImageLoaderTests: XCTestCase { XCTAssertEqual(result.count, 50) } + + // MARK: - Page Name Filtering + + func testLoadVectorImagesFiltersComponentsByPageName() async throws { + let components = [ + Component.make(nodeId: "1:1", name: "icon_home", frameName: "Icons", pageName: "Outlined"), + Component.make(nodeId: "1:2", name: "icon_settings", frameName: "Icons", pageName: "Outlined"), + Component.make(nodeId: "1:3", name: "icon_home", frameName: "Icons", pageName: "Filled"), + ] + let imageUrls: [NodeId: ImagePath?] = [ + "1:1": "https://figma.com/icon_home.svg", + "1:2": "https://figma.com/icon_settings.svg", + ] + + mockClient.setResponse(components, for: ComponentsEndpoint.self) + mockClient.setResponse(imageUrls, for: ImageEndpoint.self) + + let loader = DownloadImageLoader(client: mockClient, logger: logger) + + let result = try await loader.loadVectorImages( + fileId: "test-file", + frameName: "Icons", + pageName: "Outlined", + params: SVGParams(), + filter: nil + ) + + XCTAssertEqual(result.count, 2) + let names = result.map(\.name).sorted() + XCTAssertEqual(names, ["icon_home", "icon_settings"]) + } + + func testLoadVectorImagesNilPageNamePassesAllPages() async throws { + let components = [ + Component.make(nodeId: "1:1", name: "icon_home", frameName: "Icons", pageName: "Outlined"), + Component.make(nodeId: "1:2", name: "icon_settings", frameName: "Icons", pageName: "Filled"), + ] + let imageUrls: [NodeId: ImagePath?] = [ + "1:1": "https://figma.com/icon_home.svg", + "1:2": "https://figma.com/icon_settings.svg", + ] + + mockClient.setResponse(components, for: ComponentsEndpoint.self) + mockClient.setResponse(imageUrls, for: ImageEndpoint.self) + + let loader = DownloadImageLoader(client: mockClient, logger: logger) + + let result = try await loader.loadVectorImages( + fileId: "test-file", + frameName: "Icons", + params: SVGParams(), + filter: nil + ) + + XCTAssertEqual(result.count, 2, "nil pageName should allow components from all pages") + } + + func testLoadVectorImagesThrowsWhenPageFilterMatchesNothing() async throws { + let components = [ + Component.make(nodeId: "1:1", name: "icon_home", frameName: "Icons", pageName: "Outlined"), + ] + + mockClient.setResponse(components, for: ComponentsEndpoint.self) + + let loader = DownloadImageLoader(client: mockClient, logger: logger) + + do { + _ = try await loader.loadVectorImages( + fileId: "test-file", + frameName: "Icons", + pageName: "NonExistent", + params: SVGParams(), + filter: nil + ) + XCTFail("Expected componentsNotFound error") + } catch let error as ExFigError { + guard case let .componentsNotFound(frameName, pageName) = error else { + XCTFail("Expected componentsNotFound, got: \(error)") + return + } + XCTAssertEqual(frameName, "Icons") + XCTAssertEqual(pageName, "NonExistent") + } + } } diff --git a/Tests/ExFigTests/Loaders/IconsLoaderConfigTests.swift b/Tests/ExFigTests/Loaders/IconsLoaderConfigTests.swift index 4264f5dd..1ad3e580 100644 --- a/Tests/ExFigTests/Loaders/IconsLoaderConfigTests.swift +++ b/Tests/ExFigTests/Loaders/IconsLoaderConfigTests.swift @@ -400,6 +400,24 @@ final class IconsLoaderConfigTests: XCTestCase { XCTAssertEqual(config.pageName, "Outlined", "pageName must survive source → config conversion") } + func testForFlutter_entryPageNameOverridesCommon() throws { + let entry = try makeFlutterEntry(figmaPageName: "Outlined") + let params = PKLConfig.make(lightFileId: "test", iconsPageName: "Filled") + + let config = IconsLoaderConfig.forFlutter(entry: entry, params: params) + + XCTAssertEqual(config.pageName, "Outlined") + } + + func testForWeb_entryPageNameOverridesCommon() throws { + let entry = try makeWebEntry(figmaPageName: "Outlined") + let params = PKLConfig.make(lightFileId: "test", iconsPageName: "Filled") + + let config = IconsLoaderConfig.forWeb(entry: entry, params: params) + + XCTAssertEqual(config.pageName, "Outlined") + } + // MARK: - Helpers private func makeIOSEntry( @@ -470,6 +488,7 @@ final class IconsLoaderConfigTests: XCTestCase { private func makeFlutterEntry( figmaFrameName: String? = nil, + figmaPageName: String? = nil, output: String = "assets/icons" ) throws -> FlutterIconsEntry { var json = """ @@ -480,8 +499,32 @@ final class IconsLoaderConfigTests: XCTestCase { if let figmaFrameName { json = json.replacingOccurrences(of: "{", with: "{ \"figmaFrameName\": \"\(figmaFrameName)\",") } + if let figmaPageName { + json += ", \"figmaPageName\": \"\(figmaPageName)\"" + } json += "}" return try JSONDecoder().decode(FlutterIconsEntry.self, from: Data(json.utf8)) } + + private func makeWebEntry( + figmaFrameName: String? = nil, + figmaPageName: String? = nil, + outputDirectory: String = "assets/icons" + ) throws -> WebIconsEntry { + var json = """ + { + "outputDirectory": "\(outputDirectory)" + """ + + if let figmaFrameName { + json = json.replacingOccurrences(of: "{", with: "{ \"figmaFrameName\": \"\(figmaFrameName)\",") + } + if let figmaPageName { + json += ", \"figmaPageName\": \"\(figmaPageName)\"" + } + json += "}" + + return try JSONDecoder().decode(WebIconsEntry.self, from: Data(json.utf8)) + } } diff --git a/Tests/ExFigTests/Loaders/ImagesLoaderConfigTests.swift b/Tests/ExFigTests/Loaders/ImagesLoaderConfigTests.swift index 55f55ced..77611144 100644 --- a/Tests/ExFigTests/Loaders/ImagesLoaderConfigTests.swift +++ b/Tests/ExFigTests/Loaders/ImagesLoaderConfigTests.swift @@ -1,4 +1,4 @@ -// swiftlint:disable file_length +// swiftlint:disable file_length type_body_length import ExFig_Android import ExFig_Flutter @@ -347,6 +347,24 @@ final class ImagesLoaderConfigTests: XCTestCase { XCTAssertEqual(config.pageName, "Marketing", "pageName must survive source → config conversion") } + func testForAndroid_entryPageNameOverridesCommon() throws { + let entry = try makeAndroidEntry(figmaPageName: "Marketing") + let params = PKLConfig.make(lightFileId: "test", imagesPageName: "Promo") + + let config = ImagesLoaderConfig.forAndroid(entry: entry, params: params) + + XCTAssertEqual(config.pageName, "Marketing") + } + + func testForFlutter_entryPageNameOverridesCommon() throws { + let entry = try makeFlutterEntry(figmaPageName: "Marketing") + let params = PKLConfig.make(lightFileId: "test", imagesPageName: "Promo") + + let config = ImagesLoaderConfig.forFlutter(entry: entry, params: params) + + XCTAssertEqual(config.pageName, "Marketing") + } + // MARK: - Helpers private func makeIOSEntry( @@ -383,6 +401,7 @@ final class ImagesLoaderConfigTests: XCTestCase { private func makeAndroidEntry( figmaFrameName: String? = nil, + figmaPageName: String? = nil, output: String = "drawable", format: String = "svg", scales: [Double]? = nil @@ -396,6 +415,9 @@ final class ImagesLoaderConfigTests: XCTestCase { if let figmaFrameName { json = json.replacingOccurrences(of: "{", with: "{ \"figmaFrameName\": \"\(figmaFrameName)\",") } + if let figmaPageName { + json += ", \"figmaPageName\": \"\(figmaPageName)\"" + } if let scales { let scalesJson = scales.map { String($0) }.joined(separator: ", ") json += ", \"scales\": [\(scalesJson)]" @@ -407,6 +429,7 @@ final class ImagesLoaderConfigTests: XCTestCase { private func makeFlutterEntry( figmaFrameName: String? = nil, + figmaPageName: String? = nil, output: String = "assets/images", scales: [Double]? = nil, format: String? = nil @@ -419,6 +442,9 @@ final class ImagesLoaderConfigTests: XCTestCase { if let figmaFrameName { json = json.replacingOccurrences(of: "{", with: "{ \"figmaFrameName\": \"\(figmaFrameName)\",") } + if let figmaPageName { + json += ", \"figmaPageName\": \"\(figmaPageName)\"" + } if let scales { let scalesJson = scales.map { String($0) }.joined(separator: ", ") json += ", \"scales\": [\(scalesJson)]" diff --git a/Tests/ExFigTests/TerminalUI/ExFigWarningFormatterTests.swift b/Tests/ExFigTests/TerminalUI/ExFigWarningFormatterTests.swift index 79dc7f67..71ac2b9b 100644 --- a/Tests/ExFigTests/TerminalUI/ExFigWarningFormatterTests.swift +++ b/Tests/ExFigTests/TerminalUI/ExFigWarningFormatterTests.swift @@ -69,6 +69,24 @@ final class ExFigWarningFormatterTests: XCTestCase { XCTAssertEqual(indentedLines.count, 2, "Should have 2 indented lines (type and frame)") } + func testNoAssetsFoundWithPageNameShowsPageAndHint() { + let warning = ExFigWarning.noAssetsFound(assetType: "icons", frameName: "Icons", pageName: "Outlined") + + let result = formatter.format(warning) + + XCTAssertTrue(result.contains("page: Outlined")) + XCTAssertTrue(result.contains("hint: Check that the page name matches exactly")) + } + + func testNoAssetsFoundWithoutPageNameOmitsPageLine() { + let warning = ExFigWarning.noAssetsFound(assetType: "icons", frameName: "Icons") + + let result = formatter.format(warning) + + XCTAssertFalse(result.contains("page:")) + XCTAssertFalse(result.contains("hint:")) + } + // MARK: - Xcode Project Update Failed func testXcodeProjectUpdateFailedFormatsAsCompact() { From 202896baa06dbad05b9e0fcb9b507ae6663c92fb Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Wed, 11 Feb 2026 10:03:24 +0500 Subject: [PATCH 4/4] chore: update CLAUDE.md --- CLAUDE.md | 39 +++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 520e4541..66fb5465 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -214,6 +214,18 @@ When adding fields to `FrameSource` (PKL) / `SourceInput` (ExFigCore), also upda 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 @@ -330,18 +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` | -| Test target won't compile | Broken test files block entire target; use `swift test --filter Target.Class` after `build` | +| 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