From e6992c418bbc1d8be0e1c6a40c6e35fb4df336b5 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Wed, 10 Dec 2025 21:40:38 +0500 Subject: [PATCH 1/6] feat(batch): add nodes and components pre-fetch for granular cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. New `PreFetchedNodes` and `PreFetchedComponents` structs with TaskLocal storage for batch-mode sharing 2. Extended `FileVersionPreFetcher` with methods for pre-fetching nodes and components 3. Updated `Batch.swift` to coordinate the three-phase pre-fetch (metadata → components → nodes) 4. Added new warning types for pre-fetch partial failures 5. Updated loaders to use pre-fetched components 6. Updated `GranularCacheManager` to use pre-fetched nodes --- README.md | 2 +- .../ExFig/Batch/FileVersionPreFetcher.swift | 313 +++++++++++++++++- .../ExFig/Batch/PreFetchedComponents.swift | 76 +++++ .../ExFig/Batch/PreFetchedFileVersions.swift | 5 + Sources/ExFig/Batch/PreFetchedNodes.swift | 71 ++++ .../ExFig/Cache/GranularCacheManager.swift | 42 ++- Sources/ExFig/ExFigCommand.swift | 2 +- .../ExFig/Loaders/DownloadImageLoader.swift | 8 + Sources/ExFig/Loaders/ImageLoaderBase.swift | 9 + Sources/ExFig/Subcommands/Batch.swift | 168 ++++++++-- Sources/ExFig/TerminalUI/ExFigWarning.swift | 6 + .../TerminalUI/ExFigWarningFormatter.swift | 7 + mise.toml | 8 +- 13 files changed, 678 insertions(+), 39 deletions(-) create mode 100644 Sources/ExFig/Batch/PreFetchedComponents.swift create mode 100644 Sources/ExFig/Batch/PreFetchedNodes.swift diff --git a/README.md b/README.md index 85952b91..5fa3ebec 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![CI](https://github.com/alexey1312/ExFig/actions/workflows/ci.yml/badge.svg)](https://github.com/alexey1312/ExFig/actions/workflows/ci.yml) [![Release](https://github.com/alexey1312/ExFig/actions/workflows/release.yml/badge.svg)](https://github.com/alexey1312/ExFig/actions/workflows/release.yml) [![Docs](https://github.com/alexey1312/ExFig/actions/workflows/deploy-docc.yml/badge.svg)](https://alexey1312.github.io/ExFig/documentation/exfig) -![Coverage](https://img.shields.io/badge/coverage-54.40%25-yellow) +![Coverage](https://img.shields.io/badge/coverage-52.84%25-yellow) [![License](https://img.shields.io/github/license/alexey1312/ExFig.svg)](LICENSE) Command-line utility to export colors, typography, icons, and images from Figma to Xcode, Android Studio, and Flutter diff --git a/Sources/ExFig/Batch/FileVersionPreFetcher.swift b/Sources/ExFig/Batch/FileVersionPreFetcher.swift index e849a50b..39199255 100644 --- a/Sources/ExFig/Batch/FileVersionPreFetcher.swift +++ b/Sources/ExFig/Batch/FileVersionPreFetcher.swift @@ -1,3 +1,4 @@ +// swiftlint:disable file_length import FigmaAPI import Foundation @@ -9,11 +10,40 @@ struct PreFetchConfiguration { let verbose: Bool let rateLimiter: SharedRateLimiter let retryPolicy: RetryPolicy + + /// Optional cache for smart component pre-fetch optimization. + /// When provided, components are only fetched for files with changed versions. + let cache: ImageTrackingCache? + + init( + configs: [ConfigFile], + cacheEnabled: Bool, + noCacheFlag: Bool, + verbose: Bool, + rateLimiter: SharedRateLimiter, + retryPolicy: RetryPolicy, + cache: ImageTrackingCache? = nil + ) { + self.configs = configs + self.cacheEnabled = cacheEnabled + self.noCacheFlag = noCacheFlag + self.verbose = verbose + self.rateLimiter = rateLimiter + self.retryPolicy = retryPolicy + self.cache = cache + } +} + +/// Result of pre-fetching file versions and components. +struct PreFetchResult: Sendable { + let versions: PreFetchedFileVersions? + let components: PreFetchedComponents? + let nodes: PreFetchedNodes? } -/// Pre-fetches file metadata for multiple Figma files in parallel. +/// Pre-fetches file metadata and components for multiple Figma files in parallel. /// -/// Used by batch processing to fetch all unique file versions upfront, +/// Used by batch processing to fetch all unique file versions and components upfront, /// avoiding redundant API calls when multiple configs reference the same files. struct FileVersionPreFetcher: Sendable { let client: Client @@ -82,6 +112,147 @@ struct FileVersionPreFetcher: Sendable { } } + /// Pre-fetches file versions AND components for all unique file IDs if cache is enabled. + /// + /// Uses **smart two-phase pre-fetch**: + /// 1. Phase 1: Fetch only FileMetadata (fast, lightweight) + /// 2. Phase 2: Compare versions with cache — only fetch Components for changed files + /// + /// This avoids fetching heavy Components endpoint when file hasn't changed. + /// + /// - Parameters: + /// - configuration: Pre-fetch configuration (includes optional cache for version check). + /// - ui: Terminal UI for progress output. + /// - Returns: PreFetchResult containing versions and optionally components. + static func preFetchWithComponents( + configuration: PreFetchConfiguration, + ui: TerminalUI + ) async -> PreFetchResult { + // Only pre-fetch when cache is enabled + guard configuration.cacheEnabled, !configuration.noCacheFlag else { + return PreFetchResult(versions: nil, components: nil, nodes: nil) + } + + // Extract unique file IDs from all configs + let uniqueFileIds = extractUniqueFileIds(from: configuration) + guard !uniqueFileIds.isEmpty else { + return PreFetchResult(versions: nil, components: nil, nodes: nil) + } + + if configuration.verbose { + ui.info("Found \(uniqueFileIds.count) unique Figma file(s) to pre-fetch") + } + + // Create pre-fetcher with rate-limited client + guard let preFetcher = createPreFetcher(configuration: configuration, ui: ui) else { + return PreFetchResult(versions: nil, components: nil, nodes: nil) + } + + do { + return try await performSmartPreFetch( + preFetcher: preFetcher, + fileIds: uniqueFileIds, + cache: configuration.cache, + verbose: configuration.verbose, + ui: ui + ) + } catch { + if configuration.verbose { + ui.warning("Pre-fetch failed: \(error.localizedDescription)") + } + return PreFetchResult(versions: nil, components: nil, nodes: nil) + } + } + + /// Extracts unique file IDs from configuration. + private static func extractUniqueFileIds(from configuration: PreFetchConfiguration) -> Set { + let extractor = FileIdExtractor() + let configURLs = configuration.configs.map(\.url) + return extractor.extractUniqueFileIds(from: configURLs) + } + + /// Creates a rate-limited pre-fetcher. + static func createPreFetcher( + configuration: PreFetchConfiguration, + ui: TerminalUI + ) -> FileVersionPreFetcher? { + guard let token = ProcessInfo.processInfo.environment["FIGMA_PERSONAL_TOKEN"] else { + return nil + } + + let baseClient = FigmaClient(accessToken: token, timeout: nil) + let client = RateLimitedClient( + client: baseClient, + rateLimiter: configuration.rateLimiter, + configID: ConfigID("prefetch"), + retryPolicy: configuration.retryPolicy + ) + + return FileVersionPreFetcher(client: client, ui: ui) + } + + /// Performs smart two-phase pre-fetch. + private static func performSmartPreFetch( + preFetcher: FileVersionPreFetcher, + fileIds: Set, + cache: ImageTrackingCache?, + verbose: Bool, + ui: TerminalUI + ) async throws -> PreFetchResult { + // Phase 1: Fetch only metadata (fast) + let versions = try await preFetcher.preFetch(fileIds: fileIds) + + // Phase 2: Determine which files need components + let filesNeedingComponents = determineFilesNeedingComponents( + versions: versions, + cache: cache, + allFileIds: fileIds, + verbose: verbose, + ui: ui + ) + + // Phase 3: Fetch components only for changed files + guard !filesNeedingComponents.isEmpty else { + return PreFetchResult(versions: versions, components: nil, nodes: nil) + } + + let components = try await preFetcher.preFetchComponents(fileIds: filesNeedingComponents) + + // Note: Nodes are fetched separately in Batch.swift when granular cache is enabled + return PreFetchResult(versions: versions, components: components, nodes: nil) + } + + /// Determines which files need components based on cache state. + private static func determineFilesNeedingComponents( + versions: PreFetchedFileVersions, + cache: ImageTrackingCache?, + allFileIds: Set, + verbose: Bool, + ui: TerminalUI + ) -> Set { + guard let cache else { + // No cache = need all components (first run) + return allFileIds + } + + // Smart mode: only fetch components for changed files + let changedFiles = Set( + versions.allFileIds.filter { fileId in + guard let metadata = versions.metadata(for: fileId) else { return false } + return cache.needsExport(fileId: fileId, currentVersion: metadata.version) + } + ) + + if verbose { + let unchangedCount = allFileIds.count - changedFiles.count + if unchangedCount > 0 { + ui.info("\(unchangedCount) file(s) unchanged, skipping components fetch") + } + } + + return changedFiles + } + // MARK: - Instance Methods /// Pre-fetch file metadata for all unique file IDs. @@ -111,6 +282,36 @@ struct FileVersionPreFetcher: Sendable { return result } + /// Pre-fetch components ONLY for specified file IDs (without metadata). + /// + /// Used in phase 2 of smart pre-fetch when we already have metadata + /// and only need components for files with changed versions. + /// + /// - Parameter fileIds: Set of file IDs to fetch components for. + /// - Returns: PreFetchedComponents containing all successfully fetched components. + /// - Throws: Error if all fetches fail. Partial failures are handled gracefully. + func preFetchComponents(fileIds: Set) async throws -> PreFetchedComponents { + guard !fileIds.isEmpty else { + return PreFetchedComponents(components: [:]) + } + + let fileIdArray = Array(fileIds) + + let result = try await ui.withSpinner( + "Fetching components for \(fileIdArray.count) changed file(s)..." + ) { + try await fetchAllComponents(fileIds: fileIdArray) + } + + // Report partial failures + let failedCount = fileIdArray.count - result.count + if failedCount > 0 { + ui.warning(.preFetchComponentsPartialFailure(failed: failedCount, total: fileIdArray.count)) + } + + return result + } + /// Fetch metadata for all file IDs in parallel. private func fetchAllMetadata(fileIds: [String]) async throws -> PreFetchedFileVersions { try await withThrowingTaskGroup(of: (String, FileMetadata?).self) { group in @@ -143,6 +344,114 @@ struct FileVersionPreFetcher: Sendable { return PreFetchedFileVersions(versions: versions) } } + + /// Pre-fetch nodes for granular cache optimization. + /// + /// Collects ALL nodeIds from pre-fetched components and fetches node documents + /// in a single API call per file. This avoids redundant Nodes API calls when + /// multiple configs reference the same Figma file. + /// + /// - Parameters: + /// - components: Pre-fetched components containing nodeIds. + /// - changedFileIds: Set of file IDs that have changed versions. + /// - Returns: PreFetchedNodes containing all node documents. + func preFetchNodes( + components: PreFetchedComponents, + changedFileIds: Set + ) async throws -> PreFetchedNodes { + guard !changedFileIds.isEmpty else { + return PreFetchedNodes(nodes: [:]) + } + + // Collect all nodeIds per file from components + var nodeIdsByFile: [String: [NodeId]] = [:] + for fileId in changedFileIds { + if let fileComponents = components.components(for: fileId) { + nodeIdsByFile[fileId] = fileComponents.map(\.nodeId) + } + } + + let totalNodes = nodeIdsByFile.values.reduce(0) { $0 + $1.count } + guard totalNodes > 0 else { + return PreFetchedNodes(nodes: [:]) + } + + // Capture into let for concurrent access + let nodeIdsByFileSnapshot = nodeIdsByFile + + let result = try await ui.withSpinner( + "Pre-fetching nodes for granular cache (\(totalNodes) nodes)..." + ) { + try await fetchAllNodes(nodeIdsByFile: nodeIdsByFileSnapshot) + } + + return result + } + + /// Fetch node documents for all files in parallel. + private func fetchAllNodes( + nodeIdsByFile: [String: [NodeId]] + ) async throws -> PreFetchedNodes { + // Batch size for NodesEndpoint (Figma API limit) + let batchSize = 100 + + return try await withThrowingTaskGroup(of: (String, [NodeId: Node]).self) { group in + for (fileId, nodeIds) in nodeIdsByFile { + group.addTask { [client] in + // Split into batches and fetch in parallel + let batches = nodeIds.chunked(into: batchSize) + var allNodes: [NodeId: Node] = [:] + + for batch in batches { + let endpoint = NodesEndpoint(fileId: fileId, nodeIds: batch) + let nodes = try await client.request(endpoint) + allNodes.merge(nodes) { _, new in new } + } + + return (fileId, allNodes) + } + } + + var nodesByFile: [String: [NodeId: Node]] = [:] + for try await (fileId, nodes) in group { + nodesByFile[fileId] = nodes + } + + return PreFetchedNodes(nodes: nodesByFile) + } + } + + /// Fetch components ONLY for all file IDs in parallel (no metadata). + private func fetchAllComponents(fileIds: [String]) async throws -> PreFetchedComponents { + try await withThrowingTaskGroup(of: (String, [Component]?).self) { group in + for fileId in fileIds { + group.addTask { [client] in + do { + let endpoint = ComponentsEndpoint(fileId: fileId) + let components = try await client.request(endpoint) + return (fileId, components) + } catch { + // Individual file fetch failed, return nil + return (fileId, nil) + } + } + } + + var components: [String: [Component]] = [:] + for try await (fileId, comps) in group { + if let comps { + components[fileId] = comps + } + } + + // If all fetches failed, throw error + if components.isEmpty, !fileIds.isEmpty { + throw PreFetchError.allFetchesFailed(count: fileIds.count) + } + + return PreFetchedComponents(components: components) + } + } } // MARK: - Errors diff --git a/Sources/ExFig/Batch/PreFetchedComponents.swift b/Sources/ExFig/Batch/PreFetchedComponents.swift new file mode 100644 index 00000000..c472377f --- /dev/null +++ b/Sources/ExFig/Batch/PreFetchedComponents.swift @@ -0,0 +1,76 @@ +import FigmaAPI + +/// Pre-fetched components for batch processing optimization. +/// +/// When batch processing multiple configs that reference the same Figma files, +/// this storage allows sharing pre-fetched components across all configs, +/// avoiding redundant API calls. Each config then filters components locally +/// by its `figmaFrameName`. +struct PreFetchedComponents: Sendable { + /// Stored components keyed by fileId. + private let components: [String: [Component]] + + /// Creates a new storage with pre-fetched components. + /// - Parameter components: Dictionary mapping fileId to its components. + init(components: [String: [Component]]) { + self.components = components + } + + /// Get pre-fetched components for a fileId. + /// - Parameter fileId: The Figma file ID to look up. + /// - Returns: The components if pre-fetched, nil otherwise. + func components(for fileId: String) -> [Component]? { + components[fileId] + } + + /// Check if a fileId has pre-fetched components. + /// - Parameter fileId: The Figma file ID to check. + /// - Returns: True if components exist for this fileId. + func hasComponents(for fileId: String) -> Bool { + components[fileId] != nil + } + + /// Number of pre-fetched files. + var count: Int { + components.count + } + + /// Total component count across all files. + var totalComponentCount: Int { + components.values.reduce(0) { $0 + $1.count } + } + + /// Returns all file IDs that have pre-fetched components. + func allFileIds() -> [String] { + Array(components.keys) + } +} + +/// TaskLocal storage for pre-fetched components. +/// +/// This is used by batch processing to share pre-fetched components across +/// multiple config executions. When running individual commands (not in batch mode), +/// the storage is `nil` and commands fetch their own components. +/// +/// ## Usage in Batch Mode +/// +/// ```swift +/// let preFetched = try await preFetcher.preFetchWithComponents(...) +/// await PreFetchedComponentsStorage.$components.withValue(preFetched.components) { +/// // All configs executed here will use pre-fetched components +/// await executor.execute(configs: configs) { ... } +/// } +/// ``` +/// +/// ## Usage in ImageLoaderBase +/// +/// ```swift +/// if let preFetched = PreFetchedComponentsStorage.components, +/// let components = preFetched.components(for: fileId) { +/// return components // Use pre-fetched +/// } +/// // Fall back to API request +/// ``` +enum PreFetchedComponentsStorage { + @TaskLocal static var components: PreFetchedComponents? +} diff --git a/Sources/ExFig/Batch/PreFetchedFileVersions.swift b/Sources/ExFig/Batch/PreFetchedFileVersions.swift index c90d8d0d..e7589065 100644 --- a/Sources/ExFig/Batch/PreFetchedFileVersions.swift +++ b/Sources/ExFig/Batch/PreFetchedFileVersions.swift @@ -33,6 +33,11 @@ struct PreFetchedFileVersions: Sendable { var count: Int { versions.count } + + /// All file IDs that have been pre-fetched. + var allFileIds: [String] { + Array(versions.keys) + } } /// TaskLocal storage for pre-fetched file versions. diff --git a/Sources/ExFig/Batch/PreFetchedNodes.swift b/Sources/ExFig/Batch/PreFetchedNodes.swift new file mode 100644 index 00000000..2d3ad185 --- /dev/null +++ b/Sources/ExFig/Batch/PreFetchedNodes.swift @@ -0,0 +1,71 @@ +import FigmaAPI + +/// Pre-fetched node documents for granular cache optimization. +/// +/// When batch processing multiple configs that reference the same Figma files, +/// this storage allows sharing pre-fetched node documents across all configs, +/// avoiding redundant API calls to the Nodes endpoint. +struct PreFetchedNodes: Sendable { + /// Stored nodes keyed by fileId, then by nodeId. + private let nodes: [String: [NodeId: Node]] + + /// Creates a new storage with pre-fetched nodes. + /// - Parameter nodes: Dictionary mapping fileId to its node documents. + init(nodes: [String: [NodeId: Node]]) { + self.nodes = nodes + } + + /// Get pre-fetched node for a specific fileId and nodeId. + /// - Parameters: + /// - fileId: The Figma file ID. + /// - nodeId: The node ID to look up. + /// - Returns: The node if pre-fetched, nil otherwise. + func node(fileId: String, nodeId: NodeId) -> Node? { + nodes[fileId]?[nodeId] + } + + /// Get all pre-fetched nodes for a fileId. + /// - Parameter fileId: The Figma file ID to look up. + /// - Returns: All nodes for this file if pre-fetched, nil otherwise. + func nodes(for fileId: String) -> [NodeId: Node]? { + nodes[fileId] + } + + /// Number of files with pre-fetched nodes. + var fileCount: Int { + nodes.count + } + + /// Total number of pre-fetched nodes across all files. + var totalNodeCount: Int { + nodes.values.reduce(0) { $0 + $1.count } + } +} + +/// TaskLocal storage for pre-fetched node documents. +/// +/// This is used by batch processing to share pre-fetched nodes across +/// multiple config executions when granular cache is enabled. +/// +/// ## Usage in Batch Mode +/// +/// ```swift +/// let preFetchedNodes = try await preFetcher.preFetchNodes(...) +/// await PreFetchedNodesStorage.$nodes.withValue(preFetchedNodes) { +/// // All configs executed here will use pre-fetched nodes +/// await executor.execute(configs: configs) { ... } +/// } +/// ``` +/// +/// ## Usage in GranularCacheManager +/// +/// ```swift +/// if let preFetched = PreFetchedNodesStorage.nodes, +/// let nodes = preFetched.nodes(for: fileId) { +/// return nodes // Use pre-fetched +/// } +/// // Fall back to API request +/// ``` +enum PreFetchedNodesStorage { + @TaskLocal static var nodes: PreFetchedNodes? +} diff --git a/Sources/ExFig/Cache/GranularCacheManager.swift b/Sources/ExFig/Cache/GranularCacheManager.swift index 76ae4e22..2ee1b5d5 100644 --- a/Sources/ExFig/Cache/GranularCacheManager.swift +++ b/Sources/ExFig/Cache/GranularCacheManager.swift @@ -29,6 +29,10 @@ final class GranularCacheManager: @unchecked Sendable { /// Filters components to only those that have changed since last export. /// + /// In batch mode with `--cache` and `--experimental-granular-cache`, nodes are + /// pre-fetched before parallel config processing. This method checks the pre-fetched + /// storage first to avoid redundant API calls. + /// /// - Parameters: /// - fileId: The Figma file ID. /// - components: All components to potentially export. @@ -41,9 +45,9 @@ final class GranularCacheManager: @unchecked Sendable { return GranularCacheResult(changedComponents: [:], computedHashes: [:]) } - // Fetch node documents and compute hashes + // Fetch node documents - check pre-fetched storage first let nodeIds = Array(components.keys) - let nodes = try await fetchNodeDocuments(fileId: fileId, nodeIds: nodeIds) + let nodes = try await fetchNodeDocumentsWithPreFetchCheck(fileId: fileId, nodeIds: nodeIds) // Compute hashes for all nodes var computedHashes: [NodeId: String] = [:] @@ -70,6 +74,40 @@ final class GranularCacheManager: @unchecked Sendable { ) } + /// Fetches node documents, checking pre-fetched storage first. + /// + /// In batch mode, nodes are pre-fetched before parallel config processing. + /// This method uses pre-fetched nodes when available, falling back to API. + private func fetchNodeDocumentsWithPreFetchCheck( + fileId: String, + nodeIds: [NodeId] + ) async throws -> [NodeId: Node] { + // Check pre-fetched nodes first (batch optimization) + if let preFetched = PreFetchedNodesStorage.nodes, + let preFetchedNodes = preFetched.nodes(for: fileId) + { + // Filter to only requested nodeIds + let filteredNodes = preFetchedNodes.filter { nodeIds.contains($0.key) } + + // If we have all requested nodes, use pre-fetched + if filteredNodes.count == nodeIds.count { + return filteredNodes + } + + // If some nodes are missing, fetch only the missing ones + let missingNodeIds = nodeIds.filter { preFetchedNodes[$0] == nil } + if !missingNodeIds.isEmpty { + let fetchedNodes = try await fetchNodeDocuments(fileId: fileId, nodeIds: missingNodeIds) + return filteredNodes.merging(fetchedNodes) { _, new in new } + } + + return filteredNodes + } + + // Fall back to API request (standalone mode or missing pre-fetch) + return try await fetchNodeDocuments(fileId: fileId, nodeIds: nodeIds) + } + /// Fetches node documents from Figma API in batches. private func fetchNodeDocuments( fileId: String, diff --git a/Sources/ExFig/ExFigCommand.swift b/Sources/ExFig/ExFigCommand.swift index b4abd585..05237fd2 100644 --- a/Sources/ExFig/ExFigCommand.swift +++ b/Sources/ExFig/ExFigCommand.swift @@ -52,7 +52,7 @@ enum ExFigError: LocalizedError { @main struct ExFigCommand: AsyncParsableCommand { - static let version = "v1.1.2" + static let version = "v1.1.3-beta" static let svgFileConverter = NativeVectorDrawableConverter() static let fileWriter = FileWriter() diff --git a/Sources/ExFig/Loaders/DownloadImageLoader.swift b/Sources/ExFig/Loaders/DownloadImageLoader.swift index 99cec018..05f37df4 100644 --- a/Sources/ExFig/Loaders/DownloadImageLoader.swift +++ b/Sources/ExFig/Loaders/DownloadImageLoader.swift @@ -127,6 +127,14 @@ final class DownloadImageLoader: @unchecked Sendable { } private func loadComponents(fileId: String) async throws -> [Component] { + // Check pre-fetched components first (batch optimization) + if let preFetched = PreFetchedComponentsStorage.components, + let components = preFetched.components(for: fileId) + { + return components + } + + // Fall back to API request (standalone mode) let endpoint = ComponentsEndpoint(fileId: fileId) return try await client.request(endpoint) } diff --git a/Sources/ExFig/Loaders/ImageLoaderBase.swift b/Sources/ExFig/Loaders/ImageLoaderBase.swift index cd4ea4f2..fba40eae 100644 --- a/Sources/ExFig/Loaders/ImageLoaderBase.swift +++ b/Sources/ExFig/Loaders/ImageLoaderBase.swift @@ -682,6 +682,15 @@ class ImageLoaderBase: @unchecked Sendable { // MARK: - Private Figma API Methods private func loadComponents(fileId: String) async throws -> [Component] { + // Check pre-fetched components first (batch optimization) + if let preFetched = PreFetchedComponentsStorage.components, + let components = preFetched.components(for: fileId) + { + logger.debug("Using pre-fetched components for \(fileId) (\(components.count) components)") + return components + } + + // Fall back to API request (standalone mode or missing pre-fetch) let endpoint = ComponentsEndpoint(fileId: fileId) return try await client.request(endpoint) } diff --git a/Sources/ExFig/Subcommands/Batch.swift b/Sources/ExFig/Subcommands/Batch.swift index 663c1919..18943819 100644 --- a/Sources/ExFig/Subcommands/Batch.swift +++ b/Sources/ExFig/Subcommands/Batch.swift @@ -205,23 +205,42 @@ extension ExFigCommand { let rateLimiter = SharedRateLimiter(requestsPerMinute: Double(rateLimit)) let retryPolicy = RetryPolicy(maxRetries: maxRetries) - // Pre-fetch file versions if cache is enabled (optimization) + // Load cache for smart pre-fetch optimization (version checking) + // This allows skipping heavy Components API calls when file version is unchanged + let cacheForVersionCheck = loadCacheForVersionCheck() + + // Pre-fetch file versions and components if cache is enabled (optimization) + // Smart two-phase pre-fetch: + // 1. Fetch FileMetadata only (fast, lightweight) + // 2. Compare versions with cache + // 3. Only fetch Components for files with changed versions let preFetchConfig = PreFetchConfiguration( configs: configs, cacheEnabled: cache, noCacheFlag: noCache, verbose: globalOptions.verbose, rateLimiter: rateLimiter, - retryPolicy: retryPolicy + retryPolicy: retryPolicy, + cache: cacheForVersionCheck ) - let preFetchedVersions = await FileVersionPreFetcher.preFetchIfNeeded( + let preFetchResult = await FileVersionPreFetcher.preFetchWithComponents( configuration: preFetchConfig, ui: ui ) + let preFetchedVersions = preFetchResult.versions + let preFetchedComponents = preFetchResult.components - // Pre-load granular cache if enabled + // Pre-load granular cache if enabled (uses same cache data if already loaded) let sharedGranularCache: SharedGranularCache? = prepareSharedGranularCache() + // Phase 3: Pre-fetch nodes for granular cache if enabled + // This avoids redundant Nodes API calls when multiple configs reference same file + let preFetchedNodes = await preFetchNodesIfNeeded( + components: preFetchedComponents, + preFetchConfig: preFetchConfig, + ui: ui + ) + // Create shared download queue for cross-config pipelining let downloadQueue = SharedDownloadQueue( maxConcurrentDownloads: concurrentDownloads * parallel @@ -259,20 +278,24 @@ extension ExFigCommand { ) // Wrap execution with batch progress view and batch context injection + // Nodes are injected via separate TaskLocal (Phase 3 optimization) let result: BatchResult = await BatchProgressViewStorage.$progressView.withValue(progressView) { - await withBatchContext( - preFetchedVersions: preFetchedVersions, - sharedGranularCache: sharedGranularCache - ) { - await executeWithProgressUpdates( - executor: executor, - configs: configs, - checkpointManager: checkpointManager, - runnerFactory: runnerFactory, - progressView: progressView, - rateLimiter: rateLimiter, - ui: ui - ) + await PreFetchedNodesStorage.$nodes.withValue(preFetchedNodes) { + await withBatchContext( + preFetchedVersions: preFetchedVersions, + preFetchedComponents: preFetchedComponents, + sharedGranularCache: sharedGranularCache + ) { + await executeWithProgressUpdates( + executor: executor, + configs: configs, + checkpointManager: checkpointManager, + runnerFactory: runnerFactory, + progressView: progressView, + rateLimiter: rateLimiter, + ui: ui + ) + } } } @@ -395,32 +418,123 @@ extension ExFigCommand { return SharedGranularCache(cache: cacheData, cachePath: resolvedCachePath) } - /// Wraps execution with TaskLocal context for pre-fetched versions and shared granular cache. + /// Loads cache for smart pre-fetch optimization (version checking). + /// Returns cache data for version comparison, even when granular cache is disabled. + private func loadCacheForVersionCheck() -> ImageTrackingCache? { + guard cache, !noCache else { return nil } + + let resolvedCachePath = ImageTrackingCache.resolvePath(customPath: cachePath) + return ImageTrackingCache.load(from: resolvedCachePath) + } + + /// Pre-fetch nodes for granular cache optimization if enabled. + /// + /// Phase 3 of smart pre-fetch: collects ALL nodeIds from pre-fetched components + /// and fetches in 1 request per file. This avoids redundant Nodes API calls when + /// multiple configs reference the same Figma file. + private func preFetchNodesIfNeeded( + components: PreFetchedComponents?, + preFetchConfig: PreFetchConfiguration, + ui: TerminalUI + ) async -> PreFetchedNodes? { + // Only pre-fetch nodes when granular cache is enabled + guard experimentalGranularCache, + cache, + !noCache, + let components + else { + return nil + } + + // Get file IDs that have components (those that need nodes) + let changedFileIds = Set(components.allFileIds()) + + guard !changedFileIds.isEmpty else { + return nil + } + + guard let preFetcher = FileVersionPreFetcher.createPreFetcher( + configuration: preFetchConfig, + ui: ui + ) else { + return nil + } + + do { + return try await preFetcher.preFetchNodes( + components: components, + changedFileIds: changedFileIds + ) + } catch { + // Log warning and continue without pre-fetched nodes (fallback to per-config fetch) + ui.warning(.preFetchNodesPartialFailure(error: error.localizedDescription)) + return nil + } + } + + /// Wraps execution with TaskLocal context for pre-fetched data and shared granular cache. private func withBatchContext( preFetchedVersions: PreFetchedFileVersions?, + preFetchedComponents: PreFetchedComponents?, sharedGranularCache: SharedGranularCache?, operation: () async -> T ) async -> T { - switch (preFetchedVersions, sharedGranularCache) { - case let (versions?, cache?): - // Both contexts + // Nested TaskLocal injection based on what's available + switch (preFetchedVersions, preFetchedComponents, sharedGranularCache) { + // All three contexts + case let (versions?, components?, cache?): + await PreFetchedVersionsStorage.$versions.withValue(versions) { + await PreFetchedComponentsStorage.$components.withValue(components) { + await SharedGranularCacheStorage.$cache.withValue(cache) { + await operation() + } + } + } + + // Versions + components + case let (versions?, components?, nil): + await PreFetchedVersionsStorage.$versions.withValue(versions) { + await PreFetchedComponentsStorage.$components.withValue(components) { + await operation() + } + } + + // Versions + cache + case let (versions?, nil, cache?): await PreFetchedVersionsStorage.$versions.withValue(versions) { await SharedGranularCacheStorage.$cache.withValue(cache) { await operation() } } - case let (versions?, nil): - // Only pre-fetched versions + + // Components + cache + case let (nil, components?, cache?): + await PreFetchedComponentsStorage.$components.withValue(components) { + await SharedGranularCacheStorage.$cache.withValue(cache) { + await operation() + } + } + + // Only versions + case let (versions?, nil, nil): await PreFetchedVersionsStorage.$versions.withValue(versions) { await operation() } - case let (nil, cache?): - // Only granular cache + + // Only components + case let (nil, components?, nil): + await PreFetchedComponentsStorage.$components.withValue(components) { + await operation() + } + + // Only cache + case let (nil, nil, cache?): await SharedGranularCacheStorage.$cache.withValue(cache) { await operation() } - case (nil, nil): - // No context + + // None + case (nil, nil, nil): await operation() } } diff --git a/Sources/ExFig/TerminalUI/ExFigWarning.swift b/Sources/ExFig/TerminalUI/ExFigWarning.swift index f73f0d16..a2bc04aa 100644 --- a/Sources/ExFig/TerminalUI/ExFigWarning.swift +++ b/Sources/ExFig/TerminalUI/ExFigWarning.swift @@ -47,6 +47,12 @@ enum ExFigWarning: Sendable, Equatable { /// Pre-fetch failed for some files, falling back to per-config fetch. case preFetchPartialFailure(failed: Int, total: Int) + /// Pre-fetch components failed for some files, falling back to per-config fetch. + case preFetchComponentsPartialFailure(failed: Int, total: Int) + + /// Pre-fetch nodes failed, falling back to per-config fetch. + case preFetchNodesPartialFailure(error: String) + // MARK: - Granular Cache Warnings /// Granular cache flag used without --cache enabled. diff --git a/Sources/ExFig/TerminalUI/ExFigWarningFormatter.swift b/Sources/ExFig/TerminalUI/ExFigWarningFormatter.swift index cb25882a..314f9379 100644 --- a/Sources/ExFig/TerminalUI/ExFigWarningFormatter.swift +++ b/Sources/ExFig/TerminalUI/ExFigWarningFormatter.swift @@ -13,6 +13,7 @@ struct ExFigWarningFormatter { case .configMissing, .composeRequirementMissing, .noConfigsFound, .noValidConfigs, .xcodeProjectUpdateFailed, .checkpointExpired, .checkpointPathMismatch, .retrying, .preFetchPartialFailure, + .preFetchComponentsPartialFailure, .preFetchNodesPartialFailure, .granularCacheWithoutCache: formatCompact(warning) @@ -57,6 +58,12 @@ struct ExFigWarningFormatter { case let .preFetchPartialFailure(failed, total): "Pre-fetch partial failure: \(failed)/\(total) files failed, using fallback" + case let .preFetchComponentsPartialFailure(failed, total): + "Pre-fetch components partial failure: \(failed)/\(total) files failed, using fallback" + + case let .preFetchNodesPartialFailure(error): + "Pre-fetch nodes failed: \(error), using fallback" + case .granularCacheWithoutCache: "--experimental-granular-cache ignored: requires --cache flag" diff --git a/mise.toml b/mise.toml index 9ce80427..a7de781c 100644 --- a/mise.toml +++ b/mise.toml @@ -9,12 +9,8 @@ # NO global mise installation required - ./bin/mise is self-contained! # # To update mise version: -# Option 1 (Recommended): bin/mise self-update -# -# Option 2 (Manual regeneration): -# 1. Install new mise temporarily: curl https://mise.run | sh -# 2. Regenerate bootstrap: mise generate bootstrap > ./bin/mise -# 3. Make executable: chmod +x ./bin/mise +# 1. Regenerate bootstrap: mise generate bootstrap > ./bin/mise +# 2. Make executable: chmod +x ./bin/mise # ============================================================================= [settings] From 555e12ac760de094af857b2724288f0295b2a1cb Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Thu, 11 Dec 2025 08:06:09 +0500 Subject: [PATCH 2/6] feat(icons): support multiple icons configs with per-frame exports --- .claude/EXFIG.toon | 19 + CLAUDE.md | 29 + CONFIG.md | 123 ++++- README.md | 2 +- Sources/ExFig/ExFig.docc/Configuration.md | 44 +- Sources/ExFig/Input/Params.swift | 163 +++++- Sources/ExFig/Loaders/IconsLoader.swift | 88 ++- Sources/ExFig/Subcommands/ExportIcons.swift | 381 ++++++++++++- .../Input/IconsConfigurationTests.swift | 514 ++++++++++++++++++ .../Loaders/IconsLoaderConfigTests.swift | 267 +++++++++ 10 files changed, 1585 insertions(+), 45 deletions(-) create mode 100644 Tests/ExFigTests/Input/IconsConfigurationTests.swift create mode 100644 Tests/ExFigTests/Loaders/IconsLoaderConfigTests.swift diff --git a/.claude/EXFIG.toon b/.claude/EXFIG.toon index 8a8dfca4..8de3530a 100644 --- a/.claude/EXFIG.toon +++ b/.claude/EXFIG.toon @@ -128,6 +128,25 @@ keyDirectories: docc: Sources/ExFig/ExFig.docc/ tests: Tests/ +configTypes: + IconsConfiguration: + purpose: Enum for backward-compatible icons config parsing + cases: [single(Icons), multiple([IconsEntry])] + platforms: [iOS, Android, Flutter] + properties: + entries: "[IconsEntry] - unified access to all entries" + isMultiple: "Bool - true if array format" + decoding: "Try array first, fallback to single object" + IconsEntry: + purpose: Per-frame icons configuration + requiredFields: [format/output (platform-specific)] + optionalFields: [figmaFrameName] + fallback: "figmaFrameName defaults to common.icons.figmaFrameName or 'Icons'" + IconsLoaderConfig: + purpose: Sendable struct for IconsLoader frame settings + file: Sources/ExFig/Loaders/IconsLoader.swift + factoryMethods: [forIOS, forAndroid, forFlutter, defaultConfig] + keyFiles: cli: Sources/ExFig/ExFigCommand.swift config: Sources/ExFig/Input/Params.swift diff --git a/CLAUDE.md b/CLAUDE.md index decfa643..94cbed85 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,6 +110,35 @@ Tests/ # Test targets mirror source structure Templates are in `Sources/*/Resources/`. Use Stencil syntax. Update tests after changes. +### Multiple Icons Configuration + +Icons can be configured as a single object (legacy) or array (new format) in `Params.swift`: + +```swift +// IconsConfiguration enum handles both formats via custom Decodable +enum IconsConfiguration: Decodable { + case single(Icons) // Legacy: icons: { format: svg, ... } + case multiple([IconsEntry]) // New: icons: [{ figmaFrameName: "Actions", ... }] + + var entries: [IconsEntry] // Unified access to all entries + var isMultiple: Bool // Check format type +} + +// IconsLoaderConfig passes frame-specific settings to loader +let config = IconsLoaderConfig.forIOS(entry: entry, params: params) +let loader = IconsLoader(client: client, params: params, platform: .ios, logger: logger, config: config) +``` + +**Key types:** + +| Type | Purpose | +| -------------------- | -------------------------------------------------------- | +| `IconsConfiguration` | Enum with `.single`/`.multiple` for backward compat | +| `IconsEntry` | Per-frame config (figmaFrameName, format, assetsFolder) | +| `IconsLoaderConfig` | Sendable struct passed to IconsLoader for frame settings | + +**Frame name resolution:** `entry.figmaFrameName` → `params.common?.icons?.figmaFrameName` → `"Icons"` + ### TerminalUI Usage ```swift diff --git a/CONFIG.md b/CONFIG.md index 1145ecc7..43f50ba1 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -138,6 +138,8 @@ ios: groupUsingNamespace: true # [optional] Parameters for exporting icons + # Can be a single object (legacy format) or an array of objects (new format) + # Legacy format (single icons configuration): icons: # Image file format: pdf or svg format: pdf @@ -150,22 +152,37 @@ ios: - ic24TabBarMain - ic24TabBarEvents - ic24TabBarProfile - # [optional] Absolute or relative path to swift file where to export icons (SwiftUI’s Image) for accessing from the code (e.g. Image.illZeroNoInternet) + # [optional] Absolute or relative path to swift file where to export icons (SwiftUI's Image) for accessing from the code (e.g. Image.illZeroNoInternet) swiftUIImageSwift: "./Source/Image+extension_icons.swift" # [optional] Absolute or relative path to swift file where to generate extension for UIImage for accessing icons from the code (e.g. UIImage.ic24ArrowRight) imageSwift: "./Example/Source/UIImage+extension_icons.swift" # Asset render mode: "template", "original" or "default". Default value is "template". renderMode: default - # Configure the suffix for filtering Icons and to denote a asset render mode: "default". + # Configure the suffix for filtering Icons and to denote a asset render mode: "default". # It will work when renderMode value is "template". Defaults to nil. renderModeDefaultSuffix: '_default' - # Configure the suffix for filtering Icons and to denote a asset render mode: "original". + # Configure the suffix for filtering Icons and to denote a asset render mode: "original". # It will work when renderMode value is "template". Defaults to nil. renderModeOriginalSuffix: '_original' - # Configure the suffix for filtering Icons and to denote a asset render mode: "template". + # Configure the suffix for filtering Icons and to denote a asset render mode: "template". # It will work when renderMode value isn't "template". Defaults to nil. renderModeTemplateSuffix: '_template' + # New format (multiple icons configurations from different Figma frames): + # icons: + # - figmaFrameName: Actions # Export icons from "Actions" frame + # format: svg + # assetsFolder: Actions + # nameStyle: camelCase + # preservesVectorRepresentation: ["*"] + # imageSwift: "./Generated/ActionsIcons.swift" + # - figmaFrameName: Navigation # Export icons from "Navigation" frame + # format: svg + # assetsFolder: Navigation + # nameStyle: camelCase + # preservesVectorRepresentation: ["*"] + # imageSwift: "./Generated/NavigationIcons.swift" + # [optional] Parameters for exporting images images: # Name of the folder inside Assets.xcassets where to place images (.imageset directories) @@ -210,11 +227,29 @@ android: # [optional] The package to export the Jetpack Compose color code to. Note: To export Jetpack Compose code, also `mainSrc` and `resourcePackage` above must be set composePackageName: "com.example" # Parameters for exporting icons + # Can be a single object (legacy format) or an array of objects (new format) + # Legacy format (single icons configuration): icons: # Where to place icons relative to `mainRes`? ExFig clears this directory every time your execute `exfig icons` command output: "figma-import-icons" - # [optional] The package to export the Jetpack Compose icon code to. Note: To export Jetpack Compose code, also `mainSrc` and `resourcePackage` above must be set + # [optional] The package to export the Jetpack Compose icon code to. Note: To export Jetpack Compose code, also `mainSrc` and `resourcePackage` above must be set composePackageName: "com.example" + # [optional] Icon format: resourceReference (uses painterResource) or imageVector (generates ImageVector code). Default: resourceReference + composeFormat: resourceReference + # [optional] Extension target for ImageVector (e.g., "com.example.app.ui.AppIcons") + composeExtensionTarget: "com.example.app.ui.AppIcons" + + # New format (multiple icons configurations from different Figma frames): + # icons: + # - figmaFrameName: Actions # Export icons from "Actions" frame + # output: "drawable-actions" + # composePackageName: "com.example.icons.actions" + # - figmaFrameName: Navigation # Export icons from "Navigation" frame + # output: "drawable-nav" + # composePackageName: "com.example.icons.nav" + # composeFormat: imageVector + # composeExtensionTarget: "com.example.NavIcons" + # Parameters for exporting images images: # Image file format: svg, png, or webp @@ -251,6 +286,8 @@ flutter: className: "AppColors" # Parameters for exporting icons + # Can be a single object (legacy format) or an array of objects (new format) + # Legacy format (single icons configuration): icons: # Where to place SVG icon assets (relative path from project root) output: "assets/icons" @@ -259,6 +296,17 @@ flutter: # [optional] Class name for generated icon constants. Defaults to "AppIcons" className: "AppIcons" + # New format (multiple icons configurations from different Figma frames): + # icons: + # - figmaFrameName: Actions # Export icons from "Actions" frame + # output: "assets/icons/actions" + # dartFile: "action_icons.dart" + # className: "ActionIcons" + # - figmaFrameName: Navigation # Export icons from "Navigation" frame + # output: "assets/icons/nav" + # dartFile: "nav_icons.dart" + # className: "NavIcons" + # Parameters for exporting images images: # Where to place image assets (relative path from project root) @@ -279,6 +327,71 @@ flutter: quality: 90 ``` +## Multiple Icons Configuration + +ExFig supports exporting icons from multiple Figma frames in a single config file. This is useful when your design +system organizes icons into different categories (e.g., Actions, Navigation, Chart) each in its own Figma frame. + +### Benefits + +- **Single config file** instead of multiple separate configs +- **Optimized API calls** — Components are fetched once per Figma file, then filtered locally by frame name +- **Shared settings** — Common settings like `nameValidateRegexp` and `darkModeSuffix` from `common.icons` apply to all + entries +- **Backward compatible** — Existing single-object configs continue to work + +### Format + +The `icons` section can be either a single object (legacy) or an array of objects (new): + +```yaml +# Legacy format (single configuration) +ios: + icons: + format: svg + assetsFolder: Icons + nameStyle: camelCase + +# New format (multiple configurations) +ios: + icons: + - figmaFrameName: Actions + format: svg + assetsFolder: Actions + nameStyle: camelCase + imageSwift: "./Generated/ActionsIcons.swift" + - figmaFrameName: Navigation + format: svg + assetsFolder: Navigation + nameStyle: camelCase + imageSwift: "./Generated/NavigationIcons.swift" +``` + +### Per-Entry Fields + +Each entry in the array supports all the same fields as the legacy format, plus: + +| Field | Description | +| ---------------- | ------------------------------------------------------------------------------ | +| `figmaFrameName` | Figma frame name to export icons from. Overrides `common.icons.figmaFrameName` | + +### Fallback Behavior + +If `figmaFrameName` is not specified in an entry, it falls back to: + +1. `common.icons.figmaFrameName` (if defined) +2. `"Icons"` (default) + +### Performance + +When using multiple entries with the same Figma file: + +- **Batch mode**: Components are pre-fetched once per unique file ID across all configs +- **Standalone mode**: Components are fetched once and cached locally for all entries in the same config + +This means 17 icon entries with the same `lightFileId` result in only 1 Components API call (plus 1 Images API call per +unique frame), not 17 separate calls. + ## CLI Options for Version Tracking In addition to the YAML configuration, you can control version tracking via CLI flags. Version tracking works for all diff --git a/README.md b/README.md index 5fa3ebec..608b0259 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![CI](https://github.com/alexey1312/ExFig/actions/workflows/ci.yml/badge.svg)](https://github.com/alexey1312/ExFig/actions/workflows/ci.yml) [![Release](https://github.com/alexey1312/ExFig/actions/workflows/release.yml/badge.svg)](https://github.com/alexey1312/ExFig/actions/workflows/release.yml) [![Docs](https://github.com/alexey1312/ExFig/actions/workflows/deploy-docc.yml/badge.svg)](https://alexey1312.github.io/ExFig/documentation/exfig) -![Coverage](https://img.shields.io/badge/coverage-52.84%25-yellow) +![Coverage](https://img.shields.io/badge/coverage-52.35%25-yellow) [![License](https://img.shields.io/github/license/alexey1312/ExFig.svg)](LICENSE) Command-line utility to export colors, typography, icons, and images from Figma to Xcode, Android Studio, and Flutter diff --git a/Sources/ExFig/ExFig.docc/Configuration.md b/Sources/ExFig/ExFig.docc/Configuration.md index 970df888..32a11edc 100644 --- a/Sources/ExFig/ExFig.docc/Configuration.md +++ b/Sources/ExFig/ExFig.docc/Configuration.md @@ -70,7 +70,7 @@ common: ```yaml common: icons: - # Frame name containing icon components + # Default frame name for icon components (can be overridden per-entry) figmaFrameName: "Icons" # Regex to validate icon names @@ -78,6 +78,12 @@ common: # Regex replacement for icon names nameReplaceRegexp: "ic_$1" + + # Use single file for light/dark (default: false) + useSingleFile: false + + # Suffix for dark mode variants (when useSingleFile: true) + darkModeSuffix: "_dark" ``` ### Images @@ -156,6 +162,7 @@ ios: # SwiftUI extension output path swiftUIColorSwift: "./Sources/Generated/Color+Colors.swift" + # Icons - single object (legacy) or array format icons: # Folder in xcassets for icons assetsFolder: "Icons" @@ -178,6 +185,19 @@ ios: # SwiftUI extension output path swiftUIImageSwift: "./Sources/Generated/Image+Icons.swift" + # Icons - array format for multiple icon sets + # icons: + # - figmaFrameName: "Actions" + # format: svg + # assetsFolder: "Actions" + # nameStyle: camelCase + # imageSwift: "./Sources/Generated/ActionsIcons.swift" + # - figmaFrameName: "Navigation" + # format: pdf + # assetsFolder: "Navigation" + # nameStyle: camelCase + # imageSwift: "./Sources/Generated/NavIcons.swift" + images: # Folder in xcassets for images assetsFolder: "Images" @@ -234,6 +254,7 @@ android: # Jetpack Compose package name composePackageName: "com.example.app.ui.theme" + # Icons - single object (legacy) or array format icons: # Output directory (relative to mainRes) output: "exfig-icons" @@ -247,6 +268,15 @@ android: # Use native VectorDrawable generator useNativeVectorDrawable: false + # Icons - array format for multiple icon sets + # icons: + # - figmaFrameName: "Actions" + # output: "drawable-actions" + # composePackageName: "com.example.app.ui.actions" + # - figmaFrameName: "Navigation" + # output: "drawable-nav" + # composePackageName: "com.example.app.ui.nav" + images: # Output directory (relative to mainRes) output: "exfig-images" @@ -293,6 +323,7 @@ flutter: # Class name for colors className: "AppColors" + # Icons - single object (legacy) or array format icons: # Output directory for SVG files output: "assets/icons" @@ -303,6 +334,17 @@ flutter: # Class name for icons className: "AppIcons" + # Icons - array format for multiple icon sets + # icons: + # - figmaFrameName: "Actions" + # output: "assets/icons/actions" + # dartFile: "action_icons.dart" + # className: "ActionIcons" + # - figmaFrameName: "Navigation" + # output: "assets/icons/nav" + # dartFile: "nav_icons.dart" + # className: "NavIcons" + images: # Output directory for images output: "assets/images" diff --git a/Sources/ExFig/Input/Params.swift b/Sources/ExFig/Input/Params.swift index a5683db5..ca75a179 100644 --- a/Sources/ExFig/Input/Params.swift +++ b/Sources/ExFig/Input/Params.swift @@ -1,7 +1,7 @@ import ExFigCore import Foundation -// swiftlint:disable:this nesting type_name +// swiftlint:disable nesting type_name type_body_length struct Params: Decodable { struct Figma: Decodable { let lightFileId: String @@ -94,6 +94,7 @@ struct Params: Decodable { let swiftuiColorSwift: URL? } + /// Single icons configuration (legacy format). struct Icons: Decodable { let format: VectorFormat let assetsFolder: String @@ -109,6 +110,70 @@ struct Params: Decodable { let renderModeTemplateSuffix: String? } + /// Icons entry with figmaFrameName for multiple icons configuration. + struct IconsEntry: Decodable { + /// Figma frame name to export icons from. Overrides common.icons.figmaFrameName. + let figmaFrameName: String? + let format: VectorFormat + let assetsFolder: String + let preservesVectorRepresentation: [String]? + let nameStyle: NameStyle + + let imageSwift: URL? + let swiftUIImageSwift: URL? + + let renderMode: XcodeRenderMode? + let renderModeDefaultSuffix: String? + let renderModeOriginalSuffix: String? + let renderModeTemplateSuffix: String? + } + + /// Icons configuration supporting both single object and array formats. + enum IconsConfiguration: Decodable { + case single(Icons) + case multiple([IconsEntry]) + + init(from decoder: Decoder) throws { + // Try decoding as array first (new format) + if let array = try? [IconsEntry](from: decoder) { + self = .multiple(array) + return + } + // Fallback to single object (legacy format) + let single = try Icons(from: decoder) + self = .single(single) + } + + /// Returns all icon entries for iteration. + var entries: [IconsEntry] { + switch self { + case let .single(icons): + // Convert legacy format to entry + [IconsEntry( + figmaFrameName: nil, + format: icons.format, + assetsFolder: icons.assetsFolder, + preservesVectorRepresentation: icons.preservesVectorRepresentation, + nameStyle: icons.nameStyle, + imageSwift: icons.imageSwift, + swiftUIImageSwift: icons.swiftUIImageSwift, + renderMode: icons.renderMode, + renderModeDefaultSuffix: icons.renderModeDefaultSuffix, + renderModeOriginalSuffix: icons.renderModeOriginalSuffix, + renderModeTemplateSuffix: icons.renderModeTemplateSuffix + )] + case let .multiple(entries): + entries + } + } + + /// Returns true if using new multi-entry format. + var isMultiple: Bool { + if case .multiple = self { return true } + return false + } + } + struct Images: Decodable { let assetsFolder: String let nameStyle: NameStyle @@ -137,7 +202,7 @@ struct Params: Decodable { let templatesPath: URL? let colors: Colors? - let icons: Icons? + let icons: IconsConfiguration? let images: Images? let typography: Typography? } @@ -150,6 +215,7 @@ struct Params: Decodable { case imageVector } + /// Single icons configuration (legacy format). struct Icons: Decodable { let output: String let composePackageName: String? @@ -158,6 +224,51 @@ struct Params: Decodable { let composeExtensionTarget: String? } + /// Icons entry with figmaFrameName for multiple icons configuration. + struct IconsEntry: Decodable { + /// Figma frame name to export icons from. Overrides common.icons.figmaFrameName. + let figmaFrameName: String? + let output: String + let composePackageName: String? + let composeFormat: ComposeIconFormat? + let composeExtensionTarget: String? + } + + /// Icons configuration supporting both single object and array formats. + enum IconsConfiguration: Decodable { + case single(Icons) + case multiple([IconsEntry]) + + init(from decoder: Decoder) throws { + if let array = try? [IconsEntry](from: decoder) { + self = .multiple(array) + return + } + let single = try Icons(from: decoder) + self = .single(single) + } + + var entries: [IconsEntry] { + switch self { + case let .single(icons): + [IconsEntry( + figmaFrameName: nil, + output: icons.output, + composePackageName: icons.composePackageName, + composeFormat: icons.composeFormat, + composeExtensionTarget: icons.composeExtensionTarget + )] + case let .multiple(entries): + entries + } + } + + var isMultiple: Bool { + if case .multiple = self { return true } + return false + } + } + struct Colors: Decodable { let xmlOutputFileName: String? let composePackageName: String? @@ -195,7 +306,7 @@ struct Params: Decodable { let resourcePackage: String? let mainSrc: URL? let colors: Colors? - let icons: Icons? + let icons: IconsConfiguration? let images: Images? let typography: Typography? let templatesPath: URL? @@ -213,12 +324,56 @@ struct Params: Decodable { let className: String? } + /// Single icons configuration (legacy format). struct Icons: Decodable { let output: String let dartFile: String? let className: String? } + /// Icons entry with figmaFrameName for multiple icons configuration. + struct IconsEntry: Decodable { + /// Figma frame name to export icons from. Overrides common.icons.figmaFrameName. + let figmaFrameName: String? + let output: String + let dartFile: String? + let className: String? + } + + /// Icons configuration supporting both single object and array formats. + enum IconsConfiguration: Decodable { + case single(Icons) + case multiple([IconsEntry]) + + init(from decoder: Decoder) throws { + if let array = try? [IconsEntry](from: decoder) { + self = .multiple(array) + return + } + let single = try Icons(from: decoder) + self = .single(single) + } + + var entries: [IconsEntry] { + switch self { + case let .single(icons): + [IconsEntry( + figmaFrameName: nil, + output: icons.output, + dartFile: icons.dartFile, + className: icons.className + )] + case let .multiple(entries): + entries + } + } + + var isMultiple: Bool { + if case .multiple = self { return true } + return false + } + } + struct Images: Decodable { let output: String let dartFile: String? @@ -230,7 +385,7 @@ struct Params: Decodable { let output: URL let colors: Colors? - let icons: Icons? + let icons: IconsConfiguration? let images: Images? let templatesPath: URL? } diff --git a/Sources/ExFig/Loaders/IconsLoader.swift b/Sources/ExFig/Loaders/IconsLoader.swift index 8e995230..1eba7fe9 100644 --- a/Sources/ExFig/Loaders/IconsLoader.swift +++ b/Sources/ExFig/Loaders/IconsLoader.swift @@ -18,10 +18,86 @@ struct IconsLoaderResultWithHashes { let allNames: [String] } +/// Configuration for loading icons, supporting both single-entry and multi-entry modes. +struct IconsLoaderConfig: Sendable { + /// Figma frame name to load icons from. + let frameName: String + + /// Icon format for iOS (pdf or svg). Android always uses svg. + let format: Params.VectorFormat? + + /// Render mode for iOS icons. + let renderMode: XcodeRenderMode? + let renderModeDefaultSuffix: String? + let renderModeOriginalSuffix: String? + let renderModeTemplateSuffix: String? + + /// Creates config for a specific iOS icons entry. + static func forIOS(entry: Params.iOS.IconsEntry, params: Params) -> IconsLoaderConfig { + IconsLoaderConfig( + frameName: entry.figmaFrameName ?? params.common?.icons?.figmaFrameName ?? "Icons", + format: entry.format, + renderMode: entry.renderMode, + renderModeDefaultSuffix: entry.renderModeDefaultSuffix, + renderModeOriginalSuffix: entry.renderModeOriginalSuffix, + renderModeTemplateSuffix: entry.renderModeTemplateSuffix + ) + } + + /// Creates config for Android (no iOS-specific fields needed). + static func forAndroid(entry: Params.Android.IconsEntry, params: Params) -> IconsLoaderConfig { + IconsLoaderConfig( + frameName: entry.figmaFrameName ?? params.common?.icons?.figmaFrameName ?? "Icons", + format: nil, + renderMode: nil, + renderModeDefaultSuffix: nil, + renderModeOriginalSuffix: nil, + renderModeTemplateSuffix: nil + ) + } + + /// Creates config for Flutter (no iOS-specific fields needed). + static func forFlutter(entry: Params.Flutter.IconsEntry, params: Params) -> IconsLoaderConfig { + IconsLoaderConfig( + frameName: entry.figmaFrameName ?? params.common?.icons?.figmaFrameName ?? "Icons", + format: nil, + renderMode: nil, + renderModeDefaultSuffix: nil, + renderModeOriginalSuffix: nil, + renderModeTemplateSuffix: nil + ) + } + + /// Creates default config using common.icons.figmaFrameName or "Icons". + static func defaultConfig(params: Params) -> IconsLoaderConfig { + IconsLoaderConfig( + frameName: params.common?.icons?.figmaFrameName ?? "Icons", + format: nil, + renderMode: nil, + renderModeDefaultSuffix: nil, + renderModeOriginalSuffix: nil, + renderModeTemplateSuffix: nil + ) + } +} + /// Loads icons from Figma files. final class IconsLoader: ImageLoaderBase, @unchecked Sendable { + private let config: IconsLoaderConfig + + init( + client: Client, + params: Params, + platform: Platform, + logger: Logger, + config: IconsLoaderConfig? = nil + ) { + self.config = config ?? IconsLoaderConfig.defaultConfig(params: params) + super.init(client: client, params: params, platform: platform, logger: logger) + } + private var frameName: String { - params.common?.icons?.figmaFrameName ?? "Icons" + config.frameName } /// Loads icons from Figma, supporting both single-file and separate light/dark file modes. @@ -130,7 +206,7 @@ final class IconsLoader: ImageLoaderBase, @unchecked Sendable { // MARK: - Helpers private func makeFormatParams() -> FormatParams { - switch (platform, params.ios?.icons?.format) { + switch (platform, config.format) { case (.android, _), (.ios, .svg): SVGParams() case (.ios, _): @@ -140,10 +216,10 @@ final class IconsLoader: ImageLoaderBase, @unchecked Sendable { private func updateRenderMode(_ icon: ImagePack) -> ImagePack { // Filtering at suffixes - var renderMode = params.ios?.icons?.renderMode ?? .template - let defaultSuffix = renderMode == .template ? params.ios?.icons?.renderModeDefaultSuffix : nil - let originalSuffix = renderMode == .template ? params.ios?.icons?.renderModeOriginalSuffix : nil - let templateSuffix = renderMode != .template ? params.ios?.icons?.renderModeTemplateSuffix : nil + var renderMode = config.renderMode ?? .template + let defaultSuffix = renderMode == .template ? config.renderModeDefaultSuffix : nil + let originalSuffix = renderMode == .template ? config.renderModeOriginalSuffix : nil + let templateSuffix = renderMode != .template ? config.renderModeTemplateSuffix : nil var suffix: String? if let defaultSuffix, icon.name.hasSuffix(defaultSuffix) { diff --git a/Sources/ExFig/Subcommands/ExportIcons.swift b/Sources/ExFig/Subcommands/ExportIcons.swift index 3b306f4a..a72a2485 100644 --- a/Sources/ExFig/Subcommands/ExportIcons.swift +++ b/Sources/ExFig/Subcommands/ExportIcons.swift @@ -245,14 +245,123 @@ extension ExFigCommand { granularCacheManager: GranularCacheManager? ) async throws -> PlatformExportResult { guard let ios = params.ios, - let iconsParams = ios.icons + let iconsConfig = ios.icons else { ui.warning(.configMissing(platform: "ios", assetType: "icons")) return PlatformExportResult(count: 0, hashes: [:], skippedCount: 0) } + // Get all entries from config (supports both single and multiple formats) + let entries = iconsConfig.entries + + // Single entry - use direct processing (legacy behavior) + if entries.count == 1 { + return try await exportiOSIconsEntry( + entry: entries[0], + ios: ios, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + } + + // Multiple entries - pre-fetch Components once for all entries + // This avoids redundant API calls when processing multiple figmaFrameNames + // Check if we're already in batch mode with pre-fetched components + let needsLocalPreFetch = PreFetchedComponentsStorage.components == nil + + if needsLocalPreFetch { + // Pre-fetch Components for all file IDs + var componentsMap: [String: [Component]] = [:] + let fileIds = Set([params.figma.lightFileId] + (params.figma.darkFileId.map { [$0] } ?? [])) + + for fileId in fileIds { + let components = try await client.request(ComponentsEndpoint(fileId: fileId)) + componentsMap[fileId] = components + } + + let preFetched = PreFetchedComponents(components: componentsMap) + + // Inject via TaskLocal - IconsLoader will use pre-fetched components + return try await PreFetchedComponentsStorage.$components.withValue(preFetched) { + try await processIOSIconsEntries( + entries: entries, + ios: ios, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + } + } else { + // Already have pre-fetched components (batch mode) + return try await processIOSIconsEntries( + entries: entries, + ios: ios, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + } + } + + // Helper to process multiple iOS icon entries sequentially. + // swiftlint:disable:next function_parameter_count + private func processIOSIconsEntries( + entries: [Params.iOS.IconsEntry], + ios: Params.iOS, + client: Client, + params: Params, + ui: TerminalUI, + granularCacheManager: GranularCacheManager? + ) async throws -> PlatformExportResult { + var totalCount = 0 + var totalSkipped = 0 + var allHashes: [String: [NodeId: String]] = [:] + + for entry in entries { + let result = try await exportiOSIconsEntry( + entry: entry, + ios: ios, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + totalCount += result.count + totalSkipped += result.skippedCount + mergeHashes(&allHashes, result.hashes) + } + + return PlatformExportResult( + count: totalCount, + hashes: allHashes, + skippedCount: totalSkipped + ) + } + + // Exports icons for a single iOS icons entry. + // swiftlint:disable:next function_body_length cyclomatic_complexity function_parameter_count + private func exportiOSIconsEntry( + entry: Params.iOS.IconsEntry, + ios: Params.iOS, + client: Client, + params: Params, + ui: TerminalUI, + granularCacheManager: GranularCacheManager? + ) async throws -> PlatformExportResult { + let loaderConfig = IconsLoaderConfig.forIOS(entry: entry, params: params) + let loaderResult = try await ui.withSpinnerProgress("Fetching icons from Figma...") { onProgress in - let loader = IconsLoader(client: client, params: params, platform: .ios, logger: logger) + let loader = IconsLoader( + client: client, + params: params, + platform: .ios, + logger: logger, + config: loaderConfig + ) if let manager = granularCacheManager { loader.granularCacheManager = manager return try await loader.loadWithGranularCache(filter: filter, onBatchProgress: onProgress) @@ -284,7 +393,7 @@ extension ExFigCommand { platform: .ios, nameValidateRegexp: params.common?.icons?.nameValidateRegexp, nameReplaceRegexp: params.common?.icons?.nameReplaceRegexp, - nameStyle: iconsParams.nameStyle + nameStyle: entry.nameStyle ) let (icons, iconsWarning): ([AssetPair], AssetsValidatorWarning?) = @@ -296,7 +405,7 @@ extension ExFigCommand { ui.warning(iconsWarning) } - let assetsURL = ios.xcassetsPath.appendingPathComponent(iconsParams.assetsFolder) + let assetsURL = ios.xcassetsPath.appendingPathComponent(entry.assetsFolder) let output = XcodeImagesOutput( assetsFolderURL: assetsURL, @@ -304,9 +413,9 @@ extension ExFigCommand { assetsInSwiftPackage: ios.xcassetsInSwiftPackage, resourceBundleNames: ios.resourceBundleNames, addObjcAttribute: ios.addObjcAttribute, - preservesVectorRepresentation: iconsParams.preservesVectorRepresentation, - uiKitImageExtensionURL: iconsParams.imageSwift, - swiftUIImageExtensionURL: iconsParams.swiftUIImageSwift, + preservesVectorRepresentation: entry.preservesVectorRepresentation, + uiKitImageExtensionURL: entry.imageSwift, + swiftUIImageExtensionURL: entry.swiftUIImageSwift, templatesPath: ios.templatesPath ) @@ -397,16 +506,114 @@ extension ExFigCommand { ui: TerminalUI, granularCacheManager: GranularCacheManager? ) async throws -> PlatformExportResult { - guard let android = params.android, let androidIcons = android.icons else { + guard let android = params.android, let iconsConfig = android.icons else { ui.warning(.configMissing(platform: "android", assetType: "icons")) return PlatformExportResult(count: 0, hashes: [:]) } + // Get all entries from config (supports both single and multiple formats) + let entries = iconsConfig.entries + + // Single entry - use direct processing (legacy behavior) + if entries.count == 1 { + return try await exportAndroidIconsEntry( + entry: entries[0], + android: android, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + } + + // Multiple entries - pre-fetch Components once for all entries + let needsLocalPreFetch = PreFetchedComponentsStorage.components == nil + + if needsLocalPreFetch { + var componentsMap: [String: [Component]] = [:] + let fileIds = Set([params.figma.lightFileId] + (params.figma.darkFileId.map { [$0] } ?? [])) + + for fileId in fileIds { + let components = try await client.request(ComponentsEndpoint(fileId: fileId)) + componentsMap[fileId] = components + } + + let preFetched = PreFetchedComponents(components: componentsMap) + + return try await PreFetchedComponentsStorage.$components.withValue(preFetched) { + try await processAndroidIconsEntries( + entries: entries, + android: android, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + } + } else { + return try await processAndroidIconsEntries( + entries: entries, + android: android, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + } + } + + // Helper to process multiple Android icon entries sequentially. + // swiftlint:disable:next function_parameter_count + private func processAndroidIconsEntries( + entries: [Params.Android.IconsEntry], + android: Params.Android, + client: Client, + params: Params, + ui: TerminalUI, + granularCacheManager: GranularCacheManager? + ) async throws -> PlatformExportResult { + var totalCount = 0 + var totalSkipped = 0 + var allHashes: [String: [NodeId: String]] = [:] + + for entry in entries { + let result = try await exportAndroidIconsEntry( + entry: entry, + android: android, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + totalCount += result.count + totalSkipped += result.skippedCount + mergeHashes(&allHashes, result.hashes) + } + + return PlatformExportResult( + count: totalCount, + hashes: allHashes, + skippedCount: totalSkipped + ) + } + + // Exports icons for a single Android icons entry. + // swiftlint:disable:next function_body_length function_parameter_count + private func exportAndroidIconsEntry( + entry: Params.Android.IconsEntry, + android: Params.Android, + client: Client, + params: Params, + ui: TerminalUI, + granularCacheManager: GranularCacheManager? + ) async throws -> PlatformExportResult { // Check if ImageVector format is requested - let composeFormat = androidIcons.composeFormat ?? .resourceReference + let composeFormat = entry.composeFormat ?? .resourceReference if composeFormat == .imageVector { - return try await exportAndroidIconsAsImageVector( + return try await exportAndroidIconsAsImageVectorEntry( + entry: entry, + android: android, client: client, params: params, ui: ui, @@ -414,9 +621,17 @@ extension ExFigCommand { ) } + let loaderConfig = IconsLoaderConfig.forAndroid(entry: entry, params: params) + // 1. Get Icons info let loaderResult = try await ui.withSpinnerProgress("Fetching icons from Figma...") { onProgress in - let loader = IconsLoader(client: client, params: params, platform: .android, logger: logger) + let loader = IconsLoader( + client: client, + params: params, + platform: .android, + logger: logger, + config: loaderConfig + ) if let manager = granularCacheManager { loader.granularCacheManager = manager return try await loader.loadWithGranularCache(filter: filter, onBatchProgress: onProgress) @@ -514,11 +729,11 @@ extension ExFigCommand { // Create output directory main/res/custom-directory/drawable/ let lightDirectory = URL(fileURLWithPath: android.mainRes - .appendingPathComponent(androidIcons.output) + .appendingPathComponent(entry.output) .appendingPathComponent("drawable", isDirectory: true).path) let darkDirectory = URL(fileURLWithPath: android.mainRes - .appendingPathComponent(androidIcons.output) + .appendingPathComponent(entry.output) .appendingPathComponent("drawable-night", isDirectory: true).path) if filter == nil, granularCacheManager == nil { @@ -550,7 +765,7 @@ extension ExFigCommand { xmlOutputDirectory: android.mainRes, xmlResourcePackage: android.resourcePackage, srcDirectory: android.mainSrc, - packageName: android.icons?.composePackageName, + packageName: entry.composePackageName, templatesPath: android.templatesPath ) let composeExporter = AndroidComposeIconExporter(output: output) @@ -591,18 +806,16 @@ extension ExFigCommand { } // Exports Android icons as Jetpack Compose ImageVector Kotlin files - // swiftlint:disable:next function_body_length cyclomatic_complexity - private func exportAndroidIconsAsImageVector( + // swiftlint:disable:next function_body_length cyclomatic_complexity function_parameter_count + private func exportAndroidIconsAsImageVectorEntry( + entry: Params.Android.IconsEntry, + android: Params.Android, client: Client, params: Params, ui: TerminalUI, granularCacheManager: GranularCacheManager? ) async throws -> PlatformExportResult { - guard let android = params.android, let androidIcons = android.icons else { - return PlatformExportResult(count: 0, hashes: [:]) - } - - guard let packageName = androidIcons.composePackageName else { + guard let packageName = entry.composePackageName else { ui.warning(.composeRequirementMissing(requirement: "composePackageName")) return PlatformExportResult(count: 0, hashes: [:]) } @@ -612,9 +825,17 @@ extension ExFigCommand { return PlatformExportResult(count: 0, hashes: [:]) } + let loaderConfig = IconsLoaderConfig.forAndroid(entry: entry, params: params) + // 1. Get Icons info let loaderResult = try await ui.withSpinnerProgress("Fetching icons from Figma...") { onProgress in - let loader = IconsLoader(client: client, params: params, platform: .android, logger: logger) + let loader = IconsLoader( + client: client, + params: params, + platform: .android, + logger: logger, + config: loaderConfig + ) if let manager = granularCacheManager { loader.granularCacheManager = manager return try await loader.loadWithGranularCache(filter: filter, onBatchProgress: onProgress) @@ -697,7 +918,7 @@ extension ExFigCommand { outputDirectory: outputDirectory, config: .init( packageName: packageName, - extensionTarget: androidIcons.composeExtensionTarget, + extensionTarget: entry.composeExtensionTarget, generatePreview: true, colorMappings: [:] ) @@ -751,14 +972,118 @@ extension ExFigCommand { ui: TerminalUI, granularCacheManager: GranularCacheManager? ) async throws -> PlatformExportResult { - guard let flutter = params.flutter, let flutterIcons = flutter.icons else { + guard let flutter = params.flutter, let iconsConfig = flutter.icons else { ui.warning(.configMissing(platform: "flutter", assetType: "icons")) return PlatformExportResult(count: 0, hashes: [:]) } + // Get all entries from config (supports both single and multiple formats) + let entries = iconsConfig.entries + + // Single entry - use direct processing (legacy behavior) + if entries.count == 1 { + return try await exportFlutterIconsEntry( + entry: entries[0], + flutter: flutter, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + } + + // Multiple entries - pre-fetch Components once for all entries + let needsLocalPreFetch = PreFetchedComponentsStorage.components == nil + + if needsLocalPreFetch { + var componentsMap: [String: [Component]] = [:] + let fileIds = Set([params.figma.lightFileId] + (params.figma.darkFileId.map { [$0] } ?? [])) + + for fileId in fileIds { + let components = try await client.request(ComponentsEndpoint(fileId: fileId)) + componentsMap[fileId] = components + } + + let preFetched = PreFetchedComponents(components: componentsMap) + + return try await PreFetchedComponentsStorage.$components.withValue(preFetched) { + try await processFlutterIconsEntries( + entries: entries, + flutter: flutter, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + } + } else { + return try await processFlutterIconsEntries( + entries: entries, + flutter: flutter, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + } + } + + // Helper to process multiple Flutter icon entries sequentially. + // swiftlint:disable:next function_parameter_count + private func processFlutterIconsEntries( + entries: [Params.Flutter.IconsEntry], + flutter: Params.Flutter, + client: Client, + params: Params, + ui: TerminalUI, + granularCacheManager: GranularCacheManager? + ) async throws -> PlatformExportResult { + var totalCount = 0 + var totalSkipped = 0 + var allHashes: [String: [NodeId: String]] = [:] + + for entry in entries { + let result = try await exportFlutterIconsEntry( + entry: entry, + flutter: flutter, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + totalCount += result.count + totalSkipped += result.skippedCount + mergeHashes(&allHashes, result.hashes) + } + + return PlatformExportResult( + count: totalCount, + hashes: allHashes, + skippedCount: totalSkipped + ) + } + + // Exports icons for a single Flutter icons entry. + // swiftlint:disable:next function_body_length function_parameter_count + private func exportFlutterIconsEntry( + entry: Params.Flutter.IconsEntry, + flutter: Params.Flutter, + client: Client, + params: Params, + ui: TerminalUI, + granularCacheManager: GranularCacheManager? + ) async throws -> PlatformExportResult { + let loaderConfig = IconsLoaderConfig.forFlutter(entry: entry, params: params) + // 1. Get Icons info let loaderResult = try await ui.withSpinnerProgress("Fetching icons from Figma...") { onProgress in - let loader = IconsLoader(client: client, params: params, platform: .android, logger: logger) + let loader = IconsLoader( + client: client, + params: params, + platform: .android, + logger: logger, + config: loaderConfig + ) if let manager = granularCacheManager { loader.granularCacheManager = manager return try await loader.loadWithGranularCache(filter: filter, onBatchProgress: onProgress) @@ -804,15 +1129,15 @@ extension ExFigCommand { } // 3. Export icons - let assetsDirectory = URL(fileURLWithPath: flutterIcons.output) + let assetsDirectory = URL(fileURLWithPath: entry.output) let output = FlutterOutput( outputDirectory: flutter.output, iconsAssetsDirectory: assetsDirectory, templatesPath: flutter.templatesPath, - iconsClassName: flutterIcons.className + iconsClassName: entry.className ) - let exporter = FlutterIconsExporter(output: output, outputFileName: flutterIcons.dartFile) + let exporter = FlutterIconsExporter(output: output, outputFileName: entry.dartFile) // Process allNames with the same transformations applied to icons let allIconNames = granularCacheManager != nil ? processor.processNames(loaderResult.allNames) diff --git a/Tests/ExFigTests/Input/IconsConfigurationTests.swift b/Tests/ExFigTests/Input/IconsConfigurationTests.swift new file mode 100644 index 00000000..dbd0b414 --- /dev/null +++ b/Tests/ExFigTests/Input/IconsConfigurationTests.swift @@ -0,0 +1,514 @@ +// swiftlint:disable file_length type_body_length +@testable import ExFig +import XCTest + +final class IconsConfigurationTests: XCTestCase { + // MARK: - iOS IconsConfiguration + + func testIOSIconsConfigurationParsesLegacySingleObject() throws { + let json = """ + { + "format": "svg", + "assetsFolder": "Icons", + "nameStyle": "camelCase" + } + """ + + let config = try JSONDecoder().decode( + Params.iOS.IconsConfiguration.self, + from: Data(json.utf8) + ) + + guard case .single = config else { + XCTFail("Expected .single case") + return + } + + XCTAssertEqual(config.entries.count, 1) + XCTAssertNil(config.entries[0].figmaFrameName) + XCTAssertEqual(config.entries[0].format, .svg) + XCTAssertEqual(config.entries[0].assetsFolder, "Icons") + XCTAssertFalse(config.isMultiple) + } + + func testIOSIconsConfigurationParsesMultipleEntries() throws { + let json = """ + [ + { + "figmaFrameName": "Actions", + "format": "svg", + "assetsFolder": "Actions", + "nameStyle": "camelCase" + }, + { + "figmaFrameName": "Navigation", + "format": "pdf", + "assetsFolder": "Navigation", + "nameStyle": "snake_case" + } + ] + """ + + let config = try JSONDecoder().decode( + Params.iOS.IconsConfiguration.self, + from: Data(json.utf8) + ) + + guard case .multiple = config else { + XCTFail("Expected .multiple case") + return + } + + XCTAssertEqual(config.entries.count, 2) + XCTAssertTrue(config.isMultiple) + + XCTAssertEqual(config.entries[0].figmaFrameName, "Actions") + XCTAssertEqual(config.entries[0].format, .svg) + XCTAssertEqual(config.entries[0].assetsFolder, "Actions") + XCTAssertEqual(config.entries[0].nameStyle, .camelCase) + + XCTAssertEqual(config.entries[1].figmaFrameName, "Navigation") + XCTAssertEqual(config.entries[1].format, .pdf) + XCTAssertEqual(config.entries[1].assetsFolder, "Navigation") + XCTAssertEqual(config.entries[1].nameStyle, .snakeCase) + } + + func testIOSIconsEntryParsesAllFields() throws { + let json = """ + { + "figmaFrameName": "Icons", + "format": "svg", + "assetsFolder": "Assets", + "preservesVectorRepresentation": ["icon_*"], + "nameStyle": "camelCase", + "imageSwift": "./Generated/Icons.swift", + "swiftUIImageSwift": "./Generated/SwiftUIIcons.swift", + "renderMode": "template", + "renderModeDefaultSuffix": "_default", + "renderModeOriginalSuffix": "_original", + "renderModeTemplateSuffix": "_template" + } + """ + + let entry = try JSONDecoder().decode( + Params.iOS.IconsEntry.self, + from: Data(json.utf8) + ) + + XCTAssertEqual(entry.figmaFrameName, "Icons") + XCTAssertEqual(entry.format, .svg) + XCTAssertEqual(entry.assetsFolder, "Assets") + XCTAssertEqual(entry.preservesVectorRepresentation, ["icon_*"]) + XCTAssertEqual(entry.imageSwift?.lastPathComponent, "Icons.swift") + XCTAssertEqual(entry.swiftUIImageSwift?.lastPathComponent, "SwiftUIIcons.swift") + XCTAssertEqual(entry.renderMode, .template) + XCTAssertEqual(entry.renderModeDefaultSuffix, "_default") + XCTAssertEqual(entry.renderModeOriginalSuffix, "_original") + XCTAssertEqual(entry.renderModeTemplateSuffix, "_template") + } + + func testIOSIconsEntriesConversionFromLegacy() throws { + let json = """ + { + "format": "pdf", + "assetsFolder": "Legacy", + "nameStyle": "snake_case", + "renderMode": "original" + } + """ + + let config = try JSONDecoder().decode( + Params.iOS.IconsConfiguration.self, + from: Data(json.utf8) + ) + + let entries = config.entries + XCTAssertEqual(entries.count, 1) + XCTAssertNil(entries[0].figmaFrameName) // Legacy doesn't have this + XCTAssertEqual(entries[0].format, .pdf) + XCTAssertEqual(entries[0].assetsFolder, "Legacy") + XCTAssertEqual(entries[0].nameStyle, .snakeCase) + XCTAssertEqual(entries[0].renderMode, .original) + } + + // MARK: - Android IconsConfiguration + + func testAndroidIconsConfigurationParsesLegacySingleObject() throws { + let json = """ + { + "output": "drawable" + } + """ + + let config = try JSONDecoder().decode( + Params.Android.IconsConfiguration.self, + from: Data(json.utf8) + ) + + guard case .single = config else { + XCTFail("Expected .single case") + return + } + + XCTAssertEqual(config.entries.count, 1) + XCTAssertEqual(config.entries[0].output, "drawable") + XCTAssertFalse(config.isMultiple) + } + + func testAndroidIconsConfigurationParsesMultipleEntries() throws { + let json = """ + [ + { + "figmaFrameName": "Actions", + "output": "drawable-actions", + "composePackageName": "com.example.icons.actions" + }, + { + "figmaFrameName": "Navigation", + "output": "drawable-nav", + "composeFormat": "imageVector", + "composeExtensionTarget": "com.example.AppIcons" + } + ] + """ + + let config = try JSONDecoder().decode( + Params.Android.IconsConfiguration.self, + from: Data(json.utf8) + ) + + guard case .multiple = config else { + XCTFail("Expected .multiple case") + return + } + + XCTAssertEqual(config.entries.count, 2) + XCTAssertTrue(config.isMultiple) + + XCTAssertEqual(config.entries[0].figmaFrameName, "Actions") + XCTAssertEqual(config.entries[0].output, "drawable-actions") + XCTAssertEqual(config.entries[0].composePackageName, "com.example.icons.actions") + + XCTAssertEqual(config.entries[1].figmaFrameName, "Navigation") + XCTAssertEqual(config.entries[1].output, "drawable-nav") + XCTAssertEqual(config.entries[1].composeFormat, .imageVector) + XCTAssertEqual(config.entries[1].composeExtensionTarget, "com.example.AppIcons") + } + + func testAndroidIconsEntryParsesAllFields() throws { + let json = """ + { + "figmaFrameName": "Icons", + "output": "drawable", + "composePackageName": "com.example.icons", + "composeFormat": "resourceReference", + "composeExtensionTarget": "com.example.AppIcons" + } + """ + + let entry = try JSONDecoder().decode( + Params.Android.IconsEntry.self, + from: Data(json.utf8) + ) + + XCTAssertEqual(entry.figmaFrameName, "Icons") + XCTAssertEqual(entry.output, "drawable") + XCTAssertEqual(entry.composePackageName, "com.example.icons") + XCTAssertEqual(entry.composeFormat, .resourceReference) + XCTAssertEqual(entry.composeExtensionTarget, "com.example.AppIcons") + } + + // MARK: - Flutter IconsConfiguration + + func testFlutterIconsConfigurationParsesLegacySingleObject() throws { + let json = """ + { + "output": "assets/icons" + } + """ + + let config = try JSONDecoder().decode( + Params.Flutter.IconsConfiguration.self, + from: Data(json.utf8) + ) + + guard case .single = config else { + XCTFail("Expected .single case") + return + } + + XCTAssertEqual(config.entries.count, 1) + XCTAssertEqual(config.entries[0].output, "assets/icons") + XCTAssertFalse(config.isMultiple) + } + + func testFlutterIconsConfigurationParsesMultipleEntries() throws { + let json = """ + [ + { + "figmaFrameName": "Actions", + "output": "assets/icons/actions", + "dartFile": "lib/generated/action_icons.dart", + "className": "ActionIcons" + }, + { + "figmaFrameName": "Navigation", + "output": "assets/icons/nav", + "dartFile": "lib/generated/nav_icons.dart", + "className": "NavIcons" + } + ] + """ + + let config = try JSONDecoder().decode( + Params.Flutter.IconsConfiguration.self, + from: Data(json.utf8) + ) + + guard case .multiple = config else { + XCTFail("Expected .multiple case") + return + } + + XCTAssertEqual(config.entries.count, 2) + XCTAssertTrue(config.isMultiple) + + XCTAssertEqual(config.entries[0].figmaFrameName, "Actions") + XCTAssertEqual(config.entries[0].output, "assets/icons/actions") + XCTAssertEqual(config.entries[0].dartFile, "lib/generated/action_icons.dart") + XCTAssertEqual(config.entries[0].className, "ActionIcons") + + XCTAssertEqual(config.entries[1].figmaFrameName, "Navigation") + XCTAssertEqual(config.entries[1].output, "assets/icons/nav") + } + + func testFlutterIconsEntryParsesAllFields() throws { + let json = """ + { + "figmaFrameName": "Icons", + "output": "assets/icons", + "dartFile": "lib/icons.dart", + "className": "AppIcons" + } + """ + + let entry = try JSONDecoder().decode( + Params.Flutter.IconsEntry.self, + from: Data(json.utf8) + ) + + XCTAssertEqual(entry.figmaFrameName, "Icons") + XCTAssertEqual(entry.output, "assets/icons") + XCTAssertEqual(entry.dartFile, "lib/icons.dart") + XCTAssertEqual(entry.className, "AppIcons") + } + + // MARK: - Full Params Integration + + func testFullParamsWithIOSIconsArray() throws { + let json = """ + { + "figma": { + "lightFileId": "test-file" + }, + "ios": { + "xcodeprojPath": ".swiftpm/xcode/package.xcworkspace", + "target": "TestTarget", + "xcassetsPath": "./Resources/Icons.xcassets", + "xcassetsInMainBundle": true, + "icons": [ + { + "figmaFrameName": "Actions", + "format": "svg", + "assetsFolder": "Actions", + "nameStyle": "camelCase" + }, + { + "figmaFrameName": "Navigation", + "format": "pdf", + "assetsFolder": "Navigation", + "nameStyle": "snake_case" + } + ] + } + } + """ + + let params = try JSONDecoder().decode(Params.self, from: Data(json.utf8)) + + XCTAssertNotNil(params.ios?.icons) + XCTAssertEqual(params.ios?.icons?.entries.count, 2) + XCTAssertTrue(params.ios?.icons?.isMultiple ?? false) + } + + func testFullParamsWithIOSIconsLegacy() throws { + let json = """ + { + "figma": { + "lightFileId": "test-file" + }, + "ios": { + "xcodeprojPath": ".swiftpm/xcode/package.xcworkspace", + "target": "TestTarget", + "xcassetsPath": "./Resources/Icons.xcassets", + "xcassetsInMainBundle": true, + "icons": { + "format": "svg", + "assetsFolder": "Icons", + "nameStyle": "camelCase" + } + } + } + """ + + let params = try JSONDecoder().decode(Params.self, from: Data(json.utf8)) + + XCTAssertNotNil(params.ios?.icons) + XCTAssertEqual(params.ios?.icons?.entries.count, 1) + XCTAssertFalse(params.ios?.icons?.isMultiple ?? true) + } + + func testFullParamsWithAndroidIconsArray() throws { + let json = """ + { + "figma": { + "lightFileId": "test-file" + }, + "android": { + "mainRes": "./app/src/main/res", + "icons": [ + { + "figmaFrameName": "Actions", + "output": "drawable-actions" + }, + { + "figmaFrameName": "Navigation", + "output": "drawable-nav" + } + ] + } + } + """ + + let params = try JSONDecoder().decode(Params.self, from: Data(json.utf8)) + + XCTAssertNotNil(params.android?.icons) + XCTAssertEqual(params.android?.icons?.entries.count, 2) + XCTAssertTrue(params.android?.icons?.isMultiple ?? false) + } + + func testFullParamsWithFlutterIconsArray() throws { + let json = """ + { + "figma": { + "lightFileId": "test-file" + }, + "flutter": { + "output": "./lib/generated", + "icons": [ + { + "figmaFrameName": "Actions", + "output": "assets/actions" + }, + { + "figmaFrameName": "Navigation", + "output": "assets/nav" + } + ] + } + } + """ + + let params = try JSONDecoder().decode(Params.self, from: Data(json.utf8)) + + XCTAssertNotNil(params.flutter?.icons) + XCTAssertEqual(params.flutter?.icons?.entries.count, 2) + XCTAssertTrue(params.flutter?.icons?.isMultiple ?? false) + } + + // MARK: - Edge Cases + + func testIOSIconsConfigurationWithEmptyArray() throws { + let json = "[]" + + let config = try JSONDecoder().decode( + Params.iOS.IconsConfiguration.self, + from: Data(json.utf8) + ) + + guard case .multiple = config else { + XCTFail("Expected .multiple case for empty array") + return + } + + XCTAssertEqual(config.entries.count, 0) + XCTAssertTrue(config.isMultiple) + } + + func testAndroidIconsConfigurationWithEmptyArray() throws { + let json = "[]" + + let config = try JSONDecoder().decode( + Params.Android.IconsConfiguration.self, + from: Data(json.utf8) + ) + + guard case .multiple = config else { + XCTFail("Expected .multiple case for empty array") + return + } + + XCTAssertEqual(config.entries.count, 0) + XCTAssertTrue(config.isMultiple) + } + + func testFlutterIconsConfigurationWithEmptyArray() throws { + let json = "[]" + + let config = try JSONDecoder().decode( + Params.Flutter.IconsConfiguration.self, + from: Data(json.utf8) + ) + + guard case .multiple = config else { + XCTFail("Expected .multiple case for empty array") + return + } + + XCTAssertEqual(config.entries.count, 0) + XCTAssertTrue(config.isMultiple) + } + + func testIOSIconsConfigurationFailsWithInvalidType() throws { + let json = "\"not_an_object_or_array\"" + + XCTAssertThrowsError( + try JSONDecoder().decode( + Params.iOS.IconsConfiguration.self, + from: Data(json.utf8) + ) + ) + } + + func testAndroidIconsConfigurationFailsWithInvalidType() throws { + let json = "\"not_an_object_or_array\"" + + XCTAssertThrowsError( + try JSONDecoder().decode( + Params.Android.IconsConfiguration.self, + from: Data(json.utf8) + ) + ) + } + + func testFlutterIconsConfigurationFailsWithInvalidType() throws { + let json = "\"not_an_object_or_array\"" + + XCTAssertThrowsError( + try JSONDecoder().decode( + Params.Flutter.IconsConfiguration.self, + from: Data(json.utf8) + ) + ) + } +} diff --git a/Tests/ExFigTests/Loaders/IconsLoaderConfigTests.swift b/Tests/ExFigTests/Loaders/IconsLoaderConfigTests.swift new file mode 100644 index 00000000..8caf25da --- /dev/null +++ b/Tests/ExFigTests/Loaders/IconsLoaderConfigTests.swift @@ -0,0 +1,267 @@ +@testable import ExFig +import XCTest + +final class IconsLoaderConfigTests: XCTestCase { + // MARK: - iOS Frame Name Resolution + + func testForIOS_entryFrameNameOverridesCommon() throws { + let entry = try makeIOSEntry(figmaFrameName: "Actions") + let params = Params.make(lightFileId: "test", iconsFrameName: "CommonIcons") + + let config = IconsLoaderConfig.forIOS(entry: entry, params: params) + + XCTAssertEqual(config.frameName, "Actions") + } + + func testForIOS_fallbackToCommonFrameName() throws { + let entry = try makeIOSEntry(figmaFrameName: nil) + let params = Params.make(lightFileId: "test", iconsFrameName: "CommonIcons") + + let config = IconsLoaderConfig.forIOS(entry: entry, params: params) + + XCTAssertEqual(config.frameName, "CommonIcons") + } + + func testForIOS_fallbackToDefaultFrameName() throws { + let entry = try makeIOSEntry(figmaFrameName: nil) + let params = Params.make(lightFileId: "test") + + let config = IconsLoaderConfig.forIOS(entry: entry, params: params) + + XCTAssertEqual(config.frameName, "Icons") + } + + func testForIOS_passesFormatField() throws { + let entry = try makeIOSEntry(format: "svg") + let params = Params.make(lightFileId: "test") + + let config = IconsLoaderConfig.forIOS(entry: entry, params: params) + + XCTAssertEqual(config.format, .svg) + } + + func testForIOS_passesPDFFormat() throws { + let entry = try makeIOSEntry(format: "pdf") + let params = Params.make(lightFileId: "test") + + let config = IconsLoaderConfig.forIOS(entry: entry, params: params) + + XCTAssertEqual(config.format, .pdf) + } + + func testForIOS_passesRenderModeFields() throws { + let entry = try makeIOSEntry( + renderMode: "original", + renderModeDefaultSuffix: "_default", + renderModeOriginalSuffix: "_original", + renderModeTemplateSuffix: "_template" + ) + let params = Params.make(lightFileId: "test") + + let config = IconsLoaderConfig.forIOS(entry: entry, params: params) + + XCTAssertEqual(config.renderMode, .original) + XCTAssertEqual(config.renderModeDefaultSuffix, "_default") + XCTAssertEqual(config.renderModeOriginalSuffix, "_original") + XCTAssertEqual(config.renderModeTemplateSuffix, "_template") + } + + func testForIOS_nilRenderModeFieldsWhenNotProvided() throws { + let entry = try makeIOSEntry() + let params = Params.make(lightFileId: "test") + + let config = IconsLoaderConfig.forIOS(entry: entry, params: params) + + XCTAssertNil(config.renderMode) + XCTAssertNil(config.renderModeDefaultSuffix) + XCTAssertNil(config.renderModeOriginalSuffix) + XCTAssertNil(config.renderModeTemplateSuffix) + } + + // MARK: - Android Frame Name Resolution + + func testForAndroid_entryFrameNameOverridesCommon() throws { + let entry = try makeAndroidEntry(figmaFrameName: "Actions") + let params = Params.make(lightFileId: "test", iconsFrameName: "CommonIcons") + + let config = IconsLoaderConfig.forAndroid(entry: entry, params: params) + + XCTAssertEqual(config.frameName, "Actions") + } + + func testForAndroid_fallbackToCommonFrameName() throws { + let entry = try makeAndroidEntry(figmaFrameName: nil) + let params = Params.make(lightFileId: "test", iconsFrameName: "CommonIcons") + + let config = IconsLoaderConfig.forAndroid(entry: entry, params: params) + + XCTAssertEqual(config.frameName, "CommonIcons") + } + + func testForAndroid_fallbackToDefault() throws { + let entry = try makeAndroidEntry(figmaFrameName: nil) + let params = Params.make(lightFileId: "test") + + let config = IconsLoaderConfig.forAndroid(entry: entry, params: params) + + XCTAssertEqual(config.frameName, "Icons") + } + + func testForAndroid_hasNoIOSSpecificFields() throws { + let entry = try makeAndroidEntry() + let params = Params.make(lightFileId: "test") + + let config = IconsLoaderConfig.forAndroid(entry: entry, params: params) + + XCTAssertNil(config.format) + XCTAssertNil(config.renderMode) + XCTAssertNil(config.renderModeDefaultSuffix) + XCTAssertNil(config.renderModeOriginalSuffix) + XCTAssertNil(config.renderModeTemplateSuffix) + } + + // MARK: - Flutter Frame Name Resolution + + func testForFlutter_entryFrameNameOverridesCommon() throws { + let entry = try makeFlutterEntry(figmaFrameName: "Actions") + let params = Params.make(lightFileId: "test", iconsFrameName: "CommonIcons") + + let config = IconsLoaderConfig.forFlutter(entry: entry, params: params) + + XCTAssertEqual(config.frameName, "Actions") + } + + func testForFlutter_fallbackToCommonFrameName() throws { + let entry = try makeFlutterEntry(figmaFrameName: nil) + let params = Params.make(lightFileId: "test", iconsFrameName: "CommonIcons") + + let config = IconsLoaderConfig.forFlutter(entry: entry, params: params) + + XCTAssertEqual(config.frameName, "CommonIcons") + } + + func testForFlutter_fallbackToDefault() throws { + let entry = try makeFlutterEntry(figmaFrameName: nil) + let params = Params.make(lightFileId: "test") + + let config = IconsLoaderConfig.forFlutter(entry: entry, params: params) + + XCTAssertEqual(config.frameName, "Icons") + } + + func testForFlutter_hasNoIOSSpecificFields() throws { + let entry = try makeFlutterEntry() + let params = Params.make(lightFileId: "test") + + let config = IconsLoaderConfig.forFlutter(entry: entry, params: params) + + XCTAssertNil(config.format) + XCTAssertNil(config.renderMode) + XCTAssertNil(config.renderModeDefaultSuffix) + XCTAssertNil(config.renderModeOriginalSuffix) + XCTAssertNil(config.renderModeTemplateSuffix) + } + + // MARK: - Default Config + + func testDefaultConfig_usesCommonFrameName() { + let params = Params.make(lightFileId: "test", iconsFrameName: "CommonIcons") + + let config = IconsLoaderConfig.defaultConfig(params: params) + + XCTAssertEqual(config.frameName, "CommonIcons") + } + + func testDefaultConfig_fallbackToDefault() { + let params = Params.make(lightFileId: "test") + + let config = IconsLoaderConfig.defaultConfig(params: params) + + XCTAssertEqual(config.frameName, "Icons") + } + + func testDefaultConfig_hasNoIOSSpecificFields() { + let params = Params.make(lightFileId: "test") + + let config = IconsLoaderConfig.defaultConfig(params: params) + + XCTAssertNil(config.format) + XCTAssertNil(config.renderMode) + XCTAssertNil(config.renderModeDefaultSuffix) + XCTAssertNil(config.renderModeOriginalSuffix) + XCTAssertNil(config.renderModeTemplateSuffix) + } + + // MARK: - Helpers + + private func makeIOSEntry( + figmaFrameName: String? = nil, + format: String = "svg", + assetsFolder: String = "Icons", + nameStyle: String = "camelCase", + renderMode: String? = nil, + renderModeDefaultSuffix: String? = nil, + renderModeOriginalSuffix: String? = nil, + renderModeTemplateSuffix: String? = nil + ) throws -> Params.iOS.IconsEntry { + var json = """ + { + "format": "\(format)", + "assetsFolder": "\(assetsFolder)", + "nameStyle": "\(nameStyle)" + """ + + if let figmaFrameName { + json = json.replacingOccurrences(of: "{", with: "{ \"figmaFrameName\": \"\(figmaFrameName)\",") + } + if let renderMode { + json += ", \"renderMode\": \"\(renderMode)\"" + } + if let renderModeDefaultSuffix { + json += ", \"renderModeDefaultSuffix\": \"\(renderModeDefaultSuffix)\"" + } + if let renderModeOriginalSuffix { + json += ", \"renderModeOriginalSuffix\": \"\(renderModeOriginalSuffix)\"" + } + if let renderModeTemplateSuffix { + json += ", \"renderModeTemplateSuffix\": \"\(renderModeTemplateSuffix)\"" + } + json += "}" + + return try JSONDecoder().decode(Params.iOS.IconsEntry.self, from: Data(json.utf8)) + } + + private func makeAndroidEntry( + figmaFrameName: String? = nil, + output: String = "drawable" + ) throws -> Params.Android.IconsEntry { + var json = """ + { + "output": "\(output)" + """ + + if let figmaFrameName { + json = json.replacingOccurrences(of: "{", with: "{ \"figmaFrameName\": \"\(figmaFrameName)\",") + } + json += "}" + + return try JSONDecoder().decode(Params.Android.IconsEntry.self, from: Data(json.utf8)) + } + + private func makeFlutterEntry( + figmaFrameName: String? = nil, + output: String = "assets/icons" + ) throws -> Params.Flutter.IconsEntry { + var json = """ + { + "output": "\(output)" + """ + + if let figmaFrameName { + json = json.replacingOccurrences(of: "{", with: "{ \"figmaFrameName\": \"\(figmaFrameName)\",") + } + json += "}" + + return try JSONDecoder().decode(Params.Flutter.IconsEntry.self, from: Data(json.utf8)) + } +} From df6374f266b7aa77daa6c6a4138b1beb7648c36a Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Thu, 11 Dec 2025 19:03:33 +0500 Subject: [PATCH 3/6] feat(config): support multiple colors and images configs 1. New `ColorsConfiguration` and `ImagesConfiguration` enums in `Params.swift` for iOS, Android, and Flutter 2. New `ColorsEntry` and `ImagesEntry` structs with per-frame settings 3. `ImagesLoaderConfig` for passing frame-specific settings to the loader 4. Updated `ExportColors` and `ExportImages` commands to handle multiple entries 5. Comprehensive tests for the new configuration formats 6. Documentation updates in CLAUDE.md and CONFIG.md --- CLAUDE.md | 59 ++ CONFIG.md | 190 ++++++ README.md | 2 +- Sources/ExFig/Input/Params.swift | 355 ++++++++++- Sources/ExFig/Loaders/ImagesLoader.swift | 85 ++- Sources/ExFig/Subcommands/ExportColors.swift | 545 +++++++++++++---- Sources/ExFig/Subcommands/ExportImages.swift | 431 +++++++++++--- .../Input/ColorsConfigurationTests.swift | 559 ++++++++++++++++++ .../Input/ImagesConfigurationTests.swift | 528 +++++++++++++++++ .../Loaders/ImagesLoaderConfigTests.swift | 225 +++++++ 10 files changed, 2757 insertions(+), 222 deletions(-) create mode 100644 Tests/ExFigTests/Input/ColorsConfigurationTests.swift create mode 100644 Tests/ExFigTests/Input/ImagesConfigurationTests.swift create mode 100644 Tests/ExFigTests/Loaders/ImagesLoaderConfigTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index 94cbed85..65ec4247 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,6 +139,65 @@ let loader = IconsLoader(client: client, params: params, platform: .ios, logger: **Frame name resolution:** `entry.figmaFrameName` → `params.common?.icons?.figmaFrameName` → `"Icons"` +### Multiple Colors Configuration + +Colors can be configured as a single object (legacy) or array (new format) in `Params.swift`: + +```swift +// ColorsConfiguration enum handles both formats via custom Decodable +enum ColorsConfiguration: Decodable { + case single(Colors) // Legacy: colors: { useColorAssets: true, ... } + case multiple([ColorsEntry]) // New: colors: [{ tokensFileId: "...", ... }] + + var entries: [ColorsEntry] // Unified access to all entries + var isMultiple: Bool // Check format type +} + +// Each platform has its own ColorsEntry with platform-specific output fields +// iOS: useColorAssets, assetsFolder, colorSwift, swiftuiColorSwift +// Android: xmlOutputFileName, composePackageName +// Flutter: output, className +``` + +**Key types:** + +| Type | Purpose | +| --------------------- | ---------------------------------------------------------- | +| `ColorsConfiguration` | Enum with `.single`/`.multiple` for backward compat | +| `ColorsEntry` | Per-collection config (tokensFileId, tokensCollectionName) | + +**Note:** Colors array format is self-contained—each entry specifies its own Figma Variables source (`tokensFileId`, +`tokensCollectionName`, mode names) and output paths. Legacy format uses `common.variablesColors` for source. + +### Multiple Images Configuration + +Images can be configured as a single object (legacy) or array (new format) in `Params.swift`: + +```swift +// ImagesConfiguration enum handles both formats via custom Decodable +enum ImagesConfiguration: Decodable { + case single(Images) // Legacy: images: { assetsFolder: "Illustrations", ... } + case multiple([ImagesEntry]) // New: images: [{ figmaFrameName: "Promo", ... }] + + var entries: [ImagesEntry] // Unified access to all entries + var isMultiple: Bool // Check format type +} + +// ImagesLoaderConfig passes frame-specific settings to loader +let config = ImagesLoaderConfig.forIOS(entry: entry, params: params) +let loader = ImagesLoader(client: client, params: params, platform: .ios, logger: logger, config: config) +``` + +**Key types:** + +| Type | Purpose | +| --------------------- | --------------------------------------------------------- | +| `ImagesConfiguration` | Enum with `.single`/`.multiple` for backward compat | +| `ImagesEntry` | Per-frame config (figmaFrameName, scales, output paths) | +| `ImagesLoaderConfig` | Sendable struct passed to ImagesLoader for frame settings | + +**Frame name resolution:** `entry.figmaFrameName` → `params.common?.images?.figmaFrameName` → `"Illustrations"` + ### TerminalUI Usage ```swift diff --git a/CONFIG.md b/CONFIG.md index 43f50ba1..150744ca 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -392,6 +392,196 @@ When using multiple entries with the same Figma file: This means 17 icon entries with the same `lightFileId` result in only 1 Components API call (plus 1 Images API call per unique frame), not 17 separate calls. +## Multiple Colors Configuration + +ExFig supports exporting colors from multiple Figma Variable collections in a single config file. This is useful when +your design system has separate color collections (e.g., Base Palette, Theme Colors, Brand Colors). + +### Benefits + +- **Single config file** instead of multiple separate configs +- **Self-contained entries** — Each entry specifies its own Figma Variables source and output paths +- **Backward compatible** — Existing single-object configs with `common.variablesColors` continue to work + +### Format + +The `colors` section can be either a single object (legacy) or an array of objects (new): + +```yaml +# Legacy format (uses common.variablesColors for source) +ios: + colors: + useColorAssets: true + assetsFolder: Colors + nameStyle: camelCase + +# New format (multiple configurations with self-contained sources) +ios: + colors: + - tokensFileId: abc123 + tokensCollectionName: Base Palette + lightModeName: Light + darkModeName: Dark + useColorAssets: true + assetsFolder: BaseColors + nameStyle: camelCase + colorSwift: "./Generated/BaseColors.swift" + - tokensFileId: def456 + tokensCollectionName: Theme Colors + lightModeName: Light + darkModeName: Dark + useColorAssets: true + assetsFolder: ThemeColors + nameStyle: camelCase + colorSwift: "./Generated/ThemeColors.swift" +``` + +### Per-Entry Fields (iOS) + +Each entry in the array includes both source and output fields: + +| Field | Description | +| ---------------------- | ----------------------------------------------------- | +| `tokensFileId` | Figma file ID containing the Variables | +| `tokensCollectionName` | Name of the Variables collection | +| `lightModeName` | Column name for light mode values | +| `darkModeName` | Column name for dark mode values (optional) | +| `lightHCModeName` | Column name for light high contrast (optional) | +| `darkHCModeName` | Column name for dark high contrast (optional) | +| `primitivesModeName` | Column name for primitives (optional) | +| `nameValidateRegexp` | RegExp for name validation (optional) | +| `nameReplaceRegexp` | RegExp for name replacement (optional) | +| `useColorAssets` | Export to .xcassets (true) or Swift only (false) | +| `assetsFolder` | Folder name inside Assets.xcassets | +| `nameStyle` | camelCase, snake_case, PascalCase, etc. | +| `groupUsingNamespace` | Enable namespace grouping for "/" in names (optional) | +| `colorSwift` | Path to UIColor extension file (optional) | +| `swiftuiColorSwift` | Path to SwiftUI Color extension file (optional) | + +### Android Colors Array Format + +```yaml +android: + colors: + - tokensFileId: abc123 + tokensCollectionName: Base Palette + lightModeName: Light + xmlOutputFileName: base_colors.xml + - tokensFileId: def456 + tokensCollectionName: Theme Colors + lightModeName: Light + darkModeName: Dark + xmlOutputFileName: theme_colors.xml + composePackageName: com.example.theme +``` + +### Flutter Colors Array Format + +```yaml +flutter: + colors: + - tokensFileId: abc123 + tokensCollectionName: Base Palette + lightModeName: Light + output: base_colors.dart + className: BaseColors + - tokensFileId: def456 + tokensCollectionName: Theme Colors + lightModeName: Light + darkModeName: Dark + output: theme_colors.dart + className: ThemeColors +``` + +## Multiple Images Configuration + +ExFig supports exporting images from multiple Figma frames in a single config file. This is useful when your design +system organizes illustrations into categories (e.g., Onboarding, Promo, Empty States). + +### Benefits + +- **Single config file** instead of multiple separate configs +- **Per-frame scales** — Each entry can specify its own scale factors +- **Optimized API calls** — Components are fetched once per Figma file +- **Backward compatible** — Existing single-object configs continue to work + +### Format + +The `images` section can be either a single object (legacy) or an array of objects (new): + +```yaml +# Legacy format (single images configuration) +ios: + images: + assetsFolder: Illustrations + nameStyle: camelCase + +# New format (multiple images configurations from different Figma frames) +ios: + images: + - figmaFrameName: Onboarding + assetsFolder: Onboarding + nameStyle: camelCase + imageSwift: "./Generated/OnboardingImages.swift" + - figmaFrameName: Promo + assetsFolder: Promo + nameStyle: camelCase + scales: [1, 2, 3] + imageSwift: "./Generated/PromoImages.swift" +``` + +### Per-Entry Fields (iOS) + +| Field | Description | +| ------------------- | -------------------------------------------------------------------------------- | +| `figmaFrameName` | Figma frame name to export images from. Overrides `common.images.figmaFrameName` | +| `assetsFolder` | Folder name inside Assets.xcassets | +| `nameStyle` | camelCase, snake_case, PascalCase, etc. | +| `scales` | Array of scale factors [1, 2, 3] (optional) | +| `imageSwift` | Path to UIImage extension file (optional) | +| `swiftUIImageSwift` | Path to SwiftUI Image extension file (optional) | + +### Android Images Array Format + +```yaml +android: + images: + - figmaFrameName: Illustrations + output: drawable-illustrations + format: svg + - figmaFrameName: Photos + output: drawable-photos + format: webp + scales: [1, 1.5, 2, 3, 4] + webpOptions: + encoding: lossy + quality: 80 +``` + +### Flutter Images Array Format + +```yaml +flutter: + images: + - figmaFrameName: Illustrations + output: assets/images/illustrations + dartFile: illustrations.dart + className: Illustrations + - figmaFrameName: Promo + output: assets/images/promo + dartFile: promo_images.dart + className: PromoImages + format: webp + scales: [1, 2, 3] +``` + +### Fallback Behavior + +If `figmaFrameName` is not specified in an entry, it falls back to: + +1. `common.images.figmaFrameName` (if defined) +2. `"Illustrations"` (default) + ## CLI Options for Version Tracking In addition to the YAML configuration, you can control version tracking via CLI flags. Version tracking works for all diff --git a/README.md b/README.md index 608b0259..c387d287 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![CI](https://github.com/alexey1312/ExFig/actions/workflows/ci.yml/badge.svg)](https://github.com/alexey1312/ExFig/actions/workflows/ci.yml) [![Release](https://github.com/alexey1312/ExFig/actions/workflows/release.yml/badge.svg)](https://github.com/alexey1312/ExFig/actions/workflows/release.yml) [![Docs](https://github.com/alexey1312/ExFig/actions/workflows/deploy-docc.yml/badge.svg)](https://alexey1312.github.io/ExFig/documentation/exfig) -![Coverage](https://img.shields.io/badge/coverage-52.35%25-yellow) +![Coverage](https://img.shields.io/badge/coverage-51.06%25-yellow) [![License](https://img.shields.io/github/license/alexey1312/ExFig.svg)](LICENSE) Command-line utility to export colors, typography, icons, and images from Figma to Xcode, Android Studio, and Flutter diff --git a/Sources/ExFig/Input/Params.swift b/Sources/ExFig/Input/Params.swift index ca75a179..4774b817 100644 --- a/Sources/ExFig/Input/Params.swift +++ b/Sources/ExFig/Input/Params.swift @@ -1,7 +1,7 @@ import ExFigCore import Foundation -// swiftlint:disable nesting type_name type_body_length +// swiftlint:disable nesting type_name type_body_length file_length struct Params: Decodable { struct Figma: Decodable { let lightFileId: String @@ -84,6 +84,8 @@ struct Params: Decodable { } struct iOS: Decodable { + /// Single colors configuration (legacy format). + /// Uses common.variablesColors for Figma Variables source. struct Colors: Decodable { let useColorAssets: Bool let assetsFolder: String? @@ -94,6 +96,79 @@ struct Params: Decodable { let swiftuiColorSwift: URL? } + /// Colors entry with Figma Variables source for multiple colors configuration. + struct ColorsEntry: Decodable { + // Source (Figma Variables) + let tokensFileId: String + let tokensCollectionName: String + let lightModeName: String + let darkModeName: String? + let lightHCModeName: String? + let darkHCModeName: String? + let primitivesModeName: String? + let nameValidateRegexp: String? + let nameReplaceRegexp: String? + + // Output (iOS-specific) + let useColorAssets: Bool + let assetsFolder: String? + let nameStyle: NameStyle + let groupUsingNamespace: Bool? + let colorSwift: URL? + let swiftuiColorSwift: URL? + } + + /// Colors configuration supporting both single object and array formats. + enum ColorsConfiguration: Decodable { + case single(Colors) + case multiple([ColorsEntry]) + + init(from decoder: Decoder) throws { + // Try decoding as array first (new format) + if let array = try? [ColorsEntry](from: decoder) { + self = .multiple(array) + return + } + // Fallback to single object (legacy format) + let single = try Colors(from: decoder) + self = .single(single) + } + + /// Returns all color entries for iteration. + /// For legacy format, returns single entry with nil source fields (uses common.variablesColors). + var entries: [ColorsEntry] { + switch self { + case let .single(colors): + // Legacy format: source fields are nil, use common.variablesColors + [ColorsEntry( + tokensFileId: "", + tokensCollectionName: "", + lightModeName: "", + darkModeName: nil, + lightHCModeName: nil, + darkHCModeName: nil, + primitivesModeName: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil, + useColorAssets: colors.useColorAssets, + assetsFolder: colors.assetsFolder, + nameStyle: colors.nameStyle, + groupUsingNamespace: colors.groupUsingNamespace, + colorSwift: colors.colorSwift, + swiftuiColorSwift: colors.swiftuiColorSwift + )] + case let .multiple(entries): + entries + } + } + + /// Returns true if using new multi-entry format. + var isMultiple: Bool { + if case .multiple = self { return true } + return false + } + } + /// Single icons configuration (legacy format). struct Icons: Decodable { let format: VectorFormat @@ -174,6 +249,7 @@ struct Params: Decodable { } } + /// Single images configuration (legacy format). struct Images: Decodable { let assetsFolder: String let nameStyle: NameStyle @@ -183,6 +259,53 @@ struct Params: Decodable { let swiftUIImageSwift: URL? } + /// Images entry with figmaFrameName for multiple images configuration. + struct ImagesEntry: Decodable { + /// Figma frame name to export images from. Overrides common.images.figmaFrameName. + let figmaFrameName: String? + let assetsFolder: String + let nameStyle: NameStyle + let scales: [Double]? + let imageSwift: URL? + let swiftUIImageSwift: URL? + } + + /// Images configuration supporting both single object and array formats. + enum ImagesConfiguration: Decodable { + case single(Images) + case multiple([ImagesEntry]) + + init(from decoder: Decoder) throws { + if let array = try? [ImagesEntry](from: decoder) { + self = .multiple(array) + return + } + let single = try Images(from: decoder) + self = .single(single) + } + + var entries: [ImagesEntry] { + switch self { + case let .single(images): + [ImagesEntry( + figmaFrameName: nil, + assetsFolder: images.assetsFolder, + nameStyle: images.nameStyle, + scales: images.scales, + imageSwift: images.imageSwift, + swiftUIImageSwift: images.swiftUIImageSwift + )] + case let .multiple(entries): + entries + } + } + + var isMultiple: Bool { + if case .multiple = self { return true } + return false + } + } + struct Typography: Decodable { let fontSwift: URL? let labelStyleSwift: URL? @@ -201,9 +324,9 @@ struct Params: Decodable { let addObjcAttribute: Bool? let templatesPath: URL? - let colors: Colors? + let colors: ColorsConfiguration? let icons: IconsConfiguration? - let images: Images? + let images: ImagesConfiguration? let typography: Typography? } @@ -269,11 +392,73 @@ struct Params: Decodable { } } + /// Single colors configuration (legacy format). + /// Uses common.variablesColors for Figma Variables source. struct Colors: Decodable { let xmlOutputFileName: String? let composePackageName: String? } + /// Colors entry with Figma Variables source for multiple colors configuration. + struct ColorsEntry: Decodable { + // Source (Figma Variables) + let tokensFileId: String + let tokensCollectionName: String + let lightModeName: String + let darkModeName: String? + let lightHCModeName: String? + let darkHCModeName: String? + let primitivesModeName: String? + let nameValidateRegexp: String? + let nameReplaceRegexp: String? + + // Output (Android-specific) + let xmlOutputFileName: String? + let composePackageName: String? + } + + /// Colors configuration supporting both single object and array formats. + enum ColorsConfiguration: Decodable { + case single(Colors) + case multiple([ColorsEntry]) + + init(from decoder: Decoder) throws { + if let array = try? [ColorsEntry](from: decoder) { + self = .multiple(array) + return + } + let single = try Colors(from: decoder) + self = .single(single) + } + + var entries: [ColorsEntry] { + switch self { + case let .single(colors): + [ColorsEntry( + tokensFileId: "", + tokensCollectionName: "", + lightModeName: "", + darkModeName: nil, + lightHCModeName: nil, + darkHCModeName: nil, + primitivesModeName: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil, + xmlOutputFileName: colors.xmlOutputFileName, + composePackageName: colors.composePackageName + )] + case let .multiple(entries): + entries + } + } + + var isMultiple: Bool { + if case .multiple = self { return true } + return false + } + } + + /// Single images configuration (legacy format). struct Images: Decodable { enum Format: String, Decodable { case svg @@ -297,6 +482,51 @@ struct Params: Decodable { let webpOptions: FormatOptions? } + /// Images entry with figmaFrameName for multiple images configuration. + struct ImagesEntry: Decodable { + /// Figma frame name to export images from. Overrides common.images.figmaFrameName. + let figmaFrameName: String? + let scales: [Double]? + let output: String + let format: Images.Format + let webpOptions: Images.FormatOptions? + } + + /// Images configuration supporting both single object and array formats. + enum ImagesConfiguration: Decodable { + case single(Images) + case multiple([ImagesEntry]) + + init(from decoder: Decoder) throws { + if let array = try? [ImagesEntry](from: decoder) { + self = .multiple(array) + return + } + let single = try Images(from: decoder) + self = .single(single) + } + + var entries: [ImagesEntry] { + switch self { + case let .single(images): + [ImagesEntry( + figmaFrameName: nil, + scales: images.scales, + output: images.output, + format: images.format, + webpOptions: images.webpOptions + )] + case let .multiple(entries): + entries + } + } + + var isMultiple: Bool { + if case .multiple = self { return true } + return false + } + } + struct Typography: Decodable { let nameStyle: NameStyle let composePackageName: String? @@ -305,9 +535,9 @@ struct Params: Decodable { let mainRes: URL let resourcePackage: String? let mainSrc: URL? - let colors: Colors? + let colors: ColorsConfiguration? let icons: IconsConfiguration? - let images: Images? + let images: ImagesConfiguration? let typography: Typography? let templatesPath: URL? } @@ -319,11 +549,72 @@ struct Params: Decodable { case webp } + /// Single colors configuration (legacy format). + /// Uses common.variablesColors for Figma Variables source. struct Colors: Decodable { let output: String? let className: String? } + /// Colors entry with Figma Variables source for multiple colors configuration. + struct ColorsEntry: Decodable { + // Source (Figma Variables) + let tokensFileId: String + let tokensCollectionName: String + let lightModeName: String + let darkModeName: String? + let lightHCModeName: String? + let darkHCModeName: String? + let primitivesModeName: String? + let nameValidateRegexp: String? + let nameReplaceRegexp: String? + + // Output (Flutter-specific) + let output: String? + let className: String? + } + + /// Colors configuration supporting both single object and array formats. + enum ColorsConfiguration: Decodable { + case single(Colors) + case multiple([ColorsEntry]) + + init(from decoder: Decoder) throws { + if let array = try? [ColorsEntry](from: decoder) { + self = .multiple(array) + return + } + let single = try Colors(from: decoder) + self = .single(single) + } + + var entries: [ColorsEntry] { + switch self { + case let .single(colors): + [ColorsEntry( + tokensFileId: "", + tokensCollectionName: "", + lightModeName: "", + darkModeName: nil, + lightHCModeName: nil, + darkHCModeName: nil, + primitivesModeName: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil, + output: colors.output, + className: colors.className + )] + case let .multiple(entries): + entries + } + } + + var isMultiple: Bool { + if case .multiple = self { return true } + return false + } + } + /// Single icons configuration (legacy format). struct Icons: Decodable { let output: String @@ -374,6 +665,7 @@ struct Params: Decodable { } } + /// Single images configuration (legacy format). struct Images: Decodable { let output: String let dartFile: String? @@ -383,10 +675,59 @@ struct Params: Decodable { let webpOptions: Android.Images.FormatOptions? } + /// Images entry with figmaFrameName for multiple images configuration. + struct ImagesEntry: Decodable { + /// Figma frame name to export images from. Overrides common.images.figmaFrameName. + let figmaFrameName: String? + let output: String + let dartFile: String? + let className: String? + let scales: [Double]? + let format: ImageFormat? + let webpOptions: Android.Images.FormatOptions? + } + + /// Images configuration supporting both single object and array formats. + enum ImagesConfiguration: Decodable { + case single(Images) + case multiple([ImagesEntry]) + + init(from decoder: Decoder) throws { + if let array = try? [ImagesEntry](from: decoder) { + self = .multiple(array) + return + } + let single = try Images(from: decoder) + self = .single(single) + } + + var entries: [ImagesEntry] { + switch self { + case let .single(images): + [ImagesEntry( + figmaFrameName: nil, + output: images.output, + dartFile: images.dartFile, + className: images.className, + scales: images.scales, + format: images.format, + webpOptions: images.webpOptions + )] + case let .multiple(entries): + entries + } + } + + var isMultiple: Bool { + if case .multiple = self { return true } + return false + } + } + let output: URL - let colors: Colors? + let colors: ColorsConfiguration? let icons: IconsConfiguration? - let images: Images? + let images: ImagesConfiguration? let templatesPath: URL? } diff --git a/Sources/ExFig/Loaders/ImagesLoader.swift b/Sources/ExFig/Loaders/ImagesLoader.swift index 97baf03e..800661be 100644 --- a/Sources/ExFig/Loaders/ImagesLoader.swift +++ b/Sources/ExFig/Loaders/ImagesLoader.swift @@ -4,6 +4,47 @@ import FigmaAPI import Foundation import Logging +/// Configuration for loading images from a specific Figma frame. +struct ImagesLoaderConfig: Sendable { + /// Figma frame name to load images from. + let frameName: String + + /// Custom scales for raster images. + let scales: [Double]? + + /// Creates config for a specific iOS images entry. + static func forIOS(entry: Params.iOS.ImagesEntry, params: Params) -> ImagesLoaderConfig { + ImagesLoaderConfig( + frameName: entry.figmaFrameName ?? params.common?.images?.figmaFrameName ?? "Illustrations", + scales: entry.scales + ) + } + + /// Creates config for a specific Android images entry. + static func forAndroid(entry: Params.Android.ImagesEntry, params: Params) -> ImagesLoaderConfig { + ImagesLoaderConfig( + frameName: entry.figmaFrameName ?? params.common?.images?.figmaFrameName ?? "Illustrations", + scales: entry.scales + ) + } + + /// Creates config for a specific Flutter images entry. + static func forFlutter(entry: Params.Flutter.ImagesEntry, params: Params) -> ImagesLoaderConfig { + ImagesLoaderConfig( + frameName: entry.figmaFrameName ?? params.common?.images?.figmaFrameName ?? "Illustrations", + scales: entry.scales + ) + } + + /// Creates default config from params (for backward compatibility). + static func defaultConfig(params: Params) -> ImagesLoaderConfig { + ImagesLoaderConfig( + frameName: params.common?.images?.figmaFrameName ?? "Illustrations", + scales: nil + ) + } +} + /// Output type for images loading operations. typealias ImagesLoaderOutput = (light: [ImagePack], dark: [ImagePack]?) @@ -21,8 +62,26 @@ struct ImagesLoaderResultWithHashes { /// Loads images (illustrations) from Figma files. final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:disable:this type_body_length + private let config: ImagesLoaderConfig + + init( + client: Client, + params: Params, + platform: Platform, + logger: Logger, + config: ImagesLoaderConfig? = nil + ) { + self.config = config ?? ImagesLoaderConfig.defaultConfig(params: params) + super.init(client: client, params: params, platform: platform, logger: logger) + } + private var frameName: String { - params.common?.images?.figmaFrameName ?? "Illustrations" + config.frameName + } + + /// Custom scales from config, or nil to use defaults. + private var configScales: [Double]? { + config.scales } /// Loads images from Figma, supporting both single-file and separate light/dark file modes. @@ -69,11 +128,9 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di ) async throws -> ImagesLoaderOutput { let darkSuffix = params.common?.images?.darkModeSuffix ?? "_dark" - switch (platform, params.android?.images?.format) { + switch (platform, params.android?.images?.entries.first?.format) { case (.android, .png), (.android, .webp), (.ios, _): - let scales = getScales(customScales: platform == .android - ? params.android?.images?.scales - : params.ios?.images?.scales) + let scales = getScales(customScales: configScales) let images = try await loadPNGImages( fileId: params.figma.lightFileId, @@ -110,7 +167,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di filesToLoad.append(("dark", darkFileId)) } - switch (platform, params.android?.images?.format) { + switch (platform, params.android?.images?.entries.first?.format) { case (.android, .png), (.android, .webp), (.ios, _): return try await loadRasterImagesFromMultipleFiles( filesToLoad: filesToLoad, @@ -131,9 +188,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di filter: String?, onBatchProgress: @escaping BatchProgressCallback ) async throws -> ImagesLoaderOutput { - let scales = getScales(customScales: platform == .android - ? params.android?.images?.scales - : params.ios?.images?.scales) + let scales = getScales(customScales: configScales) // Load all files in parallel for PNG/WebP let results = try await withThrowingTaskGroup( @@ -211,12 +266,10 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di let fileId = params.figma.lightFileId let darkSuffix = params.common?.images?.darkModeSuffix ?? "_dark" - switch (platform, params.android?.images?.format) { + switch (platform, params.android?.images?.entries.first?.format) { case (.android, .png), (.android, .webp), (.ios, _): // Raster images (PNG/WebP) with granular cache - let scales = getScales(customScales: platform == .android - ? params.android?.images?.scales - : params.ios?.images?.scales) + let scales = getScales(customScales: configScales) let result = try await loadPNGImagesWithGranularCache( fileId: fileId, @@ -308,12 +361,10 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di let isRasterFormat: Bool let scales: [Double] - switch (platform, params.android?.images?.format) { + switch (platform, params.android?.images?.entries.first?.format) { case (.android, .png), (.android, .webp), (.ios, _): isRasterFormat = true - scales = getScales(customScales: platform == .android - ? params.android?.images?.scales - : params.ios?.images?.scales) + scales = getScales(customScales: configScales) default: isRasterFormat = false scales = [] diff --git a/Sources/ExFig/Subcommands/ExportColors.swift b/Sources/ExFig/Subcommands/ExportColors.swift index 4ac898ff..bb05c42e 100644 --- a/Sources/ExFig/Subcommands/ExportColors.swift +++ b/Sources/ExFig/Subcommands/ExportColors.swift @@ -6,6 +6,7 @@ import FlutterExport import Foundation import XcodeExport +// swiftlint:disable file_length type_body_length extension ExFigCommand { struct ExportColors: AsyncParsableCommand { static let configuration = CommandConfiguration( @@ -76,61 +77,140 @@ extension ExFigCommand { ui.info("Using ExFig \(ExFigCommand.version) to export colors.") } let commonParams = options.params.common - - if commonParams?.colors != nil, commonParams?.variablesColors != nil { - let errorMsg = - "In the configuration file, you can use " - + "either the common/colors or common/variablesColors parameter" - throw ExFigError.custom(errorString: errorMsg) - } - let figmaParams = options.params.figma - var colors: ColorsLoaderOutput? - var nameValidateRegexp: String? - var nameReplaceRegexp: String? - - // Fetch colors with spinner - colors = try await ui.withSpinner("Fetching colors from Figma...") { - if let variableParams = commonParams?.variablesColors { - let loader = ColorsVariablesLoader( + var totalCount = 0 + + // iOS export + if let ios = options.params.ios, let colorsConfig = ios.colors { + if colorsConfig.isMultiple { + // New format: multiple entries with self-contained source data + totalCount += try await exportiOSColorsMultiple( + entries: colorsConfig.entries, + ios: ios, client: client, - figmaParams: figmaParams, - variableParams: variableParams, - filter: filter + ui: ui ) - return try await loader.load() } else { - let loader = ColorsLoader( - client: client, + // Legacy format: use common.variablesColors or common.colors + let config = LegacyExportConfig( + commonParams: commonParams, figmaParams: figmaParams, - colorParams: commonParams?.colors, - filter: filter + client: client, + ui: ui + ) + totalCount += try await exportiOSColorsLegacy( + colorsConfig: colorsConfig, + ios: ios, + config: config ) - return try await loader.load() } } - if let variableParams = commonParams?.variablesColors { - nameValidateRegexp = variableParams.nameValidateRegexp - nameReplaceRegexp = variableParams.nameReplaceRegexp - } else { - nameValidateRegexp = commonParams?.colors?.nameValidateRegexp - nameReplaceRegexp = commonParams?.colors?.nameReplaceRegexp + // Android export + if let android = options.params.android, let colorsConfig = android.colors { + if colorsConfig.isMultiple { + totalCount += try await exportAndroidColorsMultiple( + entries: colorsConfig.entries, + android: android, + client: client, + ui: ui + ) + } else { + let config = LegacyExportConfig( + commonParams: commonParams, + figmaParams: figmaParams, + client: client, + ui: ui + ) + totalCount += try await exportAndroidColorsLegacy( + colorsConfig: colorsConfig, + android: android, + config: config + ) + } } - guard let colors else { - throw ExFigError.custom(errorString: "Failed to load colors from Figma") + // Flutter export + if let flutter = options.params.flutter, let colorsConfig = flutter.colors { + if colorsConfig.isMultiple { + totalCount += try await exportFlutterColorsMultiple( + entries: colorsConfig.entries, + flutter: flutter, + client: client, + ui: ui + ) + } else { + let config = LegacyExportConfig( + commonParams: commonParams, + figmaParams: figmaParams, + client: client, + ui: ui + ) + totalCount += try await exportFlutterColorsLegacy( + colorsConfig: colorsConfig, + flutter: flutter, + config: config + ) + } } - if let ios = options.params.ios { - let validateRegexp = nameValidateRegexp - let replaceRegexp = nameReplaceRegexp + // Update cache after successful export + try VersionTrackingHelper.updateCacheIfNeeded( + manager: trackingManager, versions: fileVersions + ) + + return totalCount + } + + // MARK: - Legacy Export Configuration + + /// Configuration for legacy colors export (using common.variablesColors or common.colors). + private struct LegacyExportConfig { + let commonParams: Params.Common? + let figmaParams: Params.Figma + let client: Client + let ui: TerminalUI + } + + // MARK: - iOS Colors Export + + private func exportiOSColorsMultiple( + entries: [Params.iOS.ColorsEntry], + ios: Params.iOS, + client: Client, + ui: TerminalUI + ) async throws -> Int { + var totalCount = 0 + + for entry in entries { + let colors = try await ui.withSpinner( + "Fetching colors from Figma (\(entry.tokensCollectionName))..." + ) { + let loader = ColorsVariablesLoader( + client: client, + figmaParams: options.params.figma, + variableParams: Params.Common.VariablesColors( + tokensFileId: entry.tokensFileId, + tokensCollectionName: entry.tokensCollectionName, + lightModeName: entry.lightModeName, + darkModeName: entry.darkModeName, + lightHCModeName: entry.lightHCModeName, + darkHCModeName: entry.darkHCModeName, + primitivesModeName: entry.primitivesModeName, + nameValidateRegexp: entry.nameValidateRegexp, + nameReplaceRegexp: entry.nameReplaceRegexp + ), + filter: filter + ) + return try await loader.load() + } + let colorPairs = try await ui.withSpinner("Processing colors for iOS...") { let processor = ColorsProcessor( platform: .ios, - nameValidateRegexp: validateRegexp, - nameReplaceRegexp: replaceRegexp, - nameStyle: options.params.ios?.colors?.nameStyle + nameValidateRegexp: entry.nameValidateRegexp, + nameReplaceRegexp: entry.nameReplaceRegexp, + nameStyle: entry.nameStyle ) let result = processor.process( light: colors.light, @@ -145,25 +225,108 @@ extension ExFigCommand { } try await ui.withSpinner("Exporting colors to Xcode project...") { - try exportXcodeColors(colorPairs: colorPairs, iosParams: ios, ui: ui) + try exportXcodeColorsEntry(colorPairs: colorPairs, entry: entry, ios: ios, ui: ui) } - // Suppress update check in batch mode (will be shown once at the end) - if BatchProgressViewStorage.progressView == nil { - await checkForUpdate(logger: logger) + totalCount += colorPairs.count + } + + if BatchProgressViewStorage.progressView == nil { + await checkForUpdate(logger: logger) + } + + ui.success("Done! Exported \(totalCount) colors to Xcode project.") + return totalCount + } + + // swiftlint:disable:next function_body_length + private func exportiOSColorsLegacy( + colorsConfig: Params.iOS.ColorsConfiguration, + ios: Params.iOS, + config: LegacyExportConfig + ) async throws -> Int { + try validateLegacyConfig(config.commonParams) + + let colors = try await loadLegacyColors(config: config) + + let (finalNameValidateRegexp, finalNameReplaceRegexp) = extractNameRegexps( + from: config.commonParams + ) + + // Get the first entry for legacy format + let entry = colorsConfig.entries[0] + + let colorPairs = try await config.ui.withSpinner("Processing colors for iOS...") { + let processor = ColorsProcessor( + platform: .ios, + nameValidateRegexp: finalNameValidateRegexp, + nameReplaceRegexp: finalNameReplaceRegexp, + nameStyle: entry.nameStyle + ) + let result = processor.process( + light: colors.light, + dark: colors.dark, + lightHC: colors.lightHC, + darkHC: colors.darkHC + ) + if let warning = result.warning { + config.ui.warning(warning) } + return try result.get() + } - ui.success("Done! Exported \(colorPairs.count) colors to Xcode project.") + try await config.ui.withSpinner("Exporting colors to Xcode project...") { + try exportXcodeColorsEntry( + colorPairs: colorPairs, entry: entry, ios: ios, ui: config.ui + ) + } + + if BatchProgressViewStorage.progressView == nil { + await checkForUpdate(logger: logger) } - if let android = options.params.android { - let validateRegexpAndroid = nameValidateRegexp - let replaceRegexpAndroid = nameReplaceRegexp + config.ui.success("Done! Exported \(colorPairs.count) colors to Xcode project.") + return colorPairs.count + } + + // MARK: - Android Colors Export + + private func exportAndroidColorsMultiple( + entries: [Params.Android.ColorsEntry], + android: Params.Android, + client: Client, + ui: TerminalUI + ) async throws -> Int { + var totalCount = 0 + + for entry in entries { + let colors = try await ui.withSpinner( + "Fetching colors from Figma (\(entry.tokensCollectionName))..." + ) { + let loader = ColorsVariablesLoader( + client: client, + figmaParams: options.params.figma, + variableParams: Params.Common.VariablesColors( + tokensFileId: entry.tokensFileId, + tokensCollectionName: entry.tokensCollectionName, + lightModeName: entry.lightModeName, + darkModeName: entry.darkModeName, + lightHCModeName: entry.lightHCModeName, + darkHCModeName: entry.darkHCModeName, + primitivesModeName: entry.primitivesModeName, + nameValidateRegexp: entry.nameValidateRegexp, + nameReplaceRegexp: entry.nameReplaceRegexp + ), + filter: filter + ) + return try await loader.load() + } + let colorPairs = try await ui.withSpinner("Processing colors for Android...") { let processor = ColorsProcessor( platform: .android, - nameValidateRegexp: validateRegexpAndroid, - nameReplaceRegexp: replaceRegexpAndroid, + nameValidateRegexp: entry.nameValidateRegexp, + nameReplaceRegexp: entry.nameReplaceRegexp, nameStyle: .snakeCase ) let result = processor.process(light: colors.light, dark: colors.dark) @@ -174,26 +337,100 @@ extension ExFigCommand { } try await ui.withSpinner("Exporting colors to Android Studio project...") { - try exportAndroidColors(colorPairs: colorPairs, androidParams: android) + try exportAndroidColorsEntry(colorPairs: colorPairs, entry: entry, android: android) } - // Suppress update check in batch mode (will be shown once at the end) - if BatchProgressViewStorage.progressView == nil { - await checkForUpdate(logger: logger) + totalCount += colorPairs.count + } + + if BatchProgressViewStorage.progressView == nil { + await checkForUpdate(logger: logger) + } + + ui.success("Done! Exported \(totalCount) colors to Android project.") + return totalCount + } + + private func exportAndroidColorsLegacy( + colorsConfig: Params.Android.ColorsConfiguration, + android: Params.Android, + config: LegacyExportConfig + ) async throws -> Int { + try validateLegacyConfig(config.commonParams) + + let colors = try await loadLegacyColors(config: config) + + let (finalNameValidateRegexp, finalNameReplaceRegexp) = extractNameRegexps( + from: config.commonParams + ) + + let entry = colorsConfig.entries[0] + + let colorPairs = try await config.ui.withSpinner("Processing colors for Android...") { + let processor = ColorsProcessor( + platform: .android, + nameValidateRegexp: finalNameValidateRegexp, + nameReplaceRegexp: finalNameReplaceRegexp, + nameStyle: .snakeCase + ) + let result = processor.process(light: colors.light, dark: colors.dark) + if let warning = result.warning { + config.ui.warning(warning) } + return try result.get() + } - ui.success("Done! Exported \(colorPairs.count) colors to Android project.") + try await config.ui.withSpinner("Exporting colors to Android Studio project...") { + try exportAndroidColorsEntry(colorPairs: colorPairs, entry: entry, android: android) } - if let flutter = options.params.flutter, flutter.colors != nil { - let validateRegexpFlutter = nameValidateRegexp - let replaceRegexpFlutter = nameReplaceRegexp + if BatchProgressViewStorage.progressView == nil { + await checkForUpdate(logger: logger) + } + + config.ui.success("Done! Exported \(colorPairs.count) colors to Android project.") + return colorPairs.count + } + + // MARK: - Flutter Colors Export + + private func exportFlutterColorsMultiple( + entries: [Params.Flutter.ColorsEntry], + flutter: Params.Flutter, + client: Client, + ui: TerminalUI + ) async throws -> Int { + var totalCount = 0 + + for entry in entries { + let colors = try await ui.withSpinner( + "Fetching colors from Figma (\(entry.tokensCollectionName))..." + ) { + let loader = ColorsVariablesLoader( + client: client, + figmaParams: options.params.figma, + variableParams: Params.Common.VariablesColors( + tokensFileId: entry.tokensFileId, + tokensCollectionName: entry.tokensCollectionName, + lightModeName: entry.lightModeName, + darkModeName: entry.darkModeName, + lightHCModeName: entry.lightHCModeName, + darkHCModeName: entry.darkHCModeName, + primitivesModeName: entry.primitivesModeName, + nameValidateRegexp: entry.nameValidateRegexp, + nameReplaceRegexp: entry.nameReplaceRegexp + ), + filter: filter + ) + return try await loader.load() + } + let colorPairs = try await ui.withSpinner("Processing colors for Flutter...") { let processor = ColorsProcessor( - platform: .android, // Flutter uses similar naming to Android - nameValidateRegexp: validateRegexpFlutter, - nameReplaceRegexp: replaceRegexpFlutter, - nameStyle: NameStyle.camelCase + platform: .android, + nameValidateRegexp: entry.nameValidateRegexp, + nameReplaceRegexp: entry.nameReplaceRegexp, + nameStyle: .camelCase ) let result = processor.process(light: colors.light, dark: colors.dark) if let warning = result.warning { @@ -203,39 +440,119 @@ extension ExFigCommand { } try await ui.withSpinner("Exporting colors to Flutter project...") { - try exportFlutterColors(colorPairs: colorPairs, flutterParams: flutter) + try exportFlutterColorsEntry(colorPairs: colorPairs, entry: entry, flutter: flutter) } - // Suppress update check in batch mode (will be shown once at the end) - if BatchProgressViewStorage.progressView == nil { - await checkForUpdate(logger: logger) - } + totalCount += colorPairs.count + } - ui.success("Done! Exported \(colorPairs.count) colors to Flutter project.") + if BatchProgressViewStorage.progressView == nil { + await checkForUpdate(logger: logger) } - // Update cache after successful export - try VersionTrackingHelper.updateCacheIfNeeded( - manager: trackingManager, versions: fileVersions + ui.success("Done! Exported \(totalCount) colors to Flutter project.") + return totalCount + } + + private func exportFlutterColorsLegacy( + colorsConfig: Params.Flutter.ColorsConfiguration, + flutter: Params.Flutter, + config: LegacyExportConfig + ) async throws -> Int { + try validateLegacyConfig(config.commonParams) + + let colors = try await loadLegacyColors(config: config) + + let (finalNameValidateRegexp, finalNameReplaceRegexp) = extractNameRegexps( + from: config.commonParams ) - return colors.light.count + let entry = colorsConfig.entries[0] + + let colorPairs = try await config.ui.withSpinner("Processing colors for Flutter...") { + let processor = ColorsProcessor( + platform: .android, + nameValidateRegexp: finalNameValidateRegexp, + nameReplaceRegexp: finalNameReplaceRegexp, + nameStyle: .camelCase + ) + let result = processor.process(light: colors.light, dark: colors.dark) + if let warning = result.warning { + config.ui.warning(warning) + } + return try result.get() + } + + try await config.ui.withSpinner("Exporting colors to Flutter project...") { + try exportFlutterColorsEntry(colorPairs: colorPairs, entry: entry, flutter: flutter) + } + + if BatchProgressViewStorage.progressView == nil { + await checkForUpdate(logger: logger) + } + + config.ui.success("Done! Exported \(colorPairs.count) colors to Flutter project.") + return colorPairs.count + } + + // MARK: - Legacy Helper Methods + + /// Validates that both common.colors and common.variablesColors are not set at the same time. + private func validateLegacyConfig(_ commonParams: Params.Common?) throws { + if commonParams?.colors != nil, commonParams?.variablesColors != nil { + throw ExFigError.custom( + errorString: + "In the configuration file, you can use " + + "either the common/colors or common/variablesColors parameter" + ) + } + } + + /// Loads colors from Figma using either Variables API or legacy Styles API. + private func loadLegacyColors(config: LegacyExportConfig) async throws -> ColorsLoaderOutput { + try await config.ui.withSpinner("Fetching colors from Figma...") { + if let variableParams = config.commonParams?.variablesColors { + let loader = ColorsVariablesLoader( + client: config.client, + figmaParams: config.figmaParams, + variableParams: variableParams, + filter: filter + ) + return try await loader.load() + } else { + let loader = ColorsLoader( + client: config.client, + figmaParams: config.figmaParams, + colorParams: config.commonParams?.colors, + filter: filter + ) + return try await loader.load() + } + } } - private func exportXcodeColors( + /// Extracts name validation and replacement regexps from common params. + private func extractNameRegexps( + from commonParams: Params.Common? + ) -> (validate: String?, replace: String?) { + if let variableParams = commonParams?.variablesColors { + return (variableParams.nameValidateRegexp, variableParams.nameReplaceRegexp) + } + return (commonParams?.colors?.nameValidateRegexp, commonParams?.colors?.nameReplaceRegexp) + } + + // MARK: - Entry-based Export Methods + + private func exportXcodeColorsEntry( colorPairs: [AssetPair], - iosParams: Params.iOS, + entry: Params.iOS.ColorsEntry, + ios: Params.iOS, ui: TerminalUI ) throws { - guard let colorParams = iosParams.colors else { - ui.warning(.configMissing(platform: "ios", assetType: "colors")) - return - } - var colorsURL: URL? - if colorParams.useColorAssets { - if let folder = colorParams.assetsFolder { - colorsURL = iosParams.xcassetsPath.appendingPathComponent(folder) + if entry.useColorAssets { + if let folder = entry.assetsFolder { + colorsURL = ios.xcassetsPath.appendingPathComponent(folder) } else { throw ExFigError.colorsAssetsFolderNotSpecified } @@ -243,33 +560,33 @@ extension ExFigCommand { let output = XcodeColorsOutput( assetsColorsURL: colorsURL, - assetsInMainBundle: iosParams.xcassetsInMainBundle, - assetsInSwiftPackage: iosParams.xcassetsInSwiftPackage, - resourceBundleNames: iosParams.resourceBundleNames, - addObjcAttribute: iosParams.addObjcAttribute, - colorSwiftURL: colorParams.colorSwift, - swiftuiColorSwiftURL: colorParams.swiftuiColorSwift, - groupUsingNamespace: colorParams.groupUsingNamespace, - templatesPath: iosParams.templatesPath + assetsInMainBundle: ios.xcassetsInMainBundle, + assetsInSwiftPackage: ios.xcassetsInSwiftPackage, + resourceBundleNames: ios.resourceBundleNames, + addObjcAttribute: ios.addObjcAttribute, + colorSwiftURL: entry.colorSwift, + swiftuiColorSwiftURL: entry.swiftuiColorSwift, + groupUsingNamespace: entry.groupUsingNamespace, + templatesPath: ios.templatesPath ) let exporter = XcodeColorExporter(output: output) let files = try exporter.export(colorPairs: colorPairs) - if colorParams.useColorAssets, let url = colorsURL { + if entry.useColorAssets, let url = colorsURL { try? FileManager.default.removeItem(atPath: url.path) } try fileWriter.write(files: files) - guard iosParams.xcassetsInSwiftPackage == false else { + guard ios.xcassetsInSwiftPackage == false else { return } do { let xcodeProject = try XcodeProjectWriter( - xcodeProjPath: iosParams.xcodeprojPath, - target: iosParams.target + xcodeProjPath: ios.xcodeprojPath, + target: ios.target ) try files.forEach { file in if file.destination.file.pathExtension == "swift" { @@ -282,27 +599,29 @@ extension ExFigCommand { } } - private func exportAndroidColors( - colorPairs: [AssetPair], androidParams: Params.Android + private func exportAndroidColorsEntry( + colorPairs: [AssetPair], + entry: Params.Android.ColorsEntry, + android: Params.Android ) throws { let output = AndroidOutput( - xmlOutputDirectory: androidParams.mainRes, - xmlResourcePackage: androidParams.resourcePackage, - srcDirectory: androidParams.mainSrc, - packageName: androidParams.colors?.composePackageName, - templatesPath: androidParams.templatesPath + xmlOutputDirectory: android.mainRes, + xmlResourcePackage: android.resourcePackage, + srcDirectory: android.mainSrc, + packageName: entry.composePackageName, + templatesPath: android.templatesPath ) let exporter = AndroidColorExporter( output: output, - xmlOutputFileName: androidParams.colors?.xmlOutputFileName + xmlOutputFileName: entry.xmlOutputFileName ) let files = try exporter.export(colorPairs: colorPairs) - let fileName = androidParams.colors?.xmlOutputFileName ?? "colors.xml" + let fileName = entry.xmlOutputFileName ?? "colors.xml" - let lightColorsFileURL = androidParams.mainRes.appendingPathComponent( + let lightColorsFileURL = android.mainRes.appendingPathComponent( "values/" + fileName) - let darkColorsFileURL = androidParams.mainRes.appendingPathComponent( + let darkColorsFileURL = android.mainRes.appendingPathComponent( "values-night/" + fileName) try? FileManager.default.removeItem(atPath: lightColorsFileURL.path) @@ -311,22 +630,24 @@ extension ExFigCommand { try fileWriter.write(files: files) } - private func exportFlutterColors( - colorPairs: [AssetPair], flutterParams: Params.Flutter + private func exportFlutterColorsEntry( + colorPairs: [AssetPair], + entry: Params.Flutter.ColorsEntry, + flutter: Params.Flutter ) throws { let output = FlutterOutput( - outputDirectory: flutterParams.output, - templatesPath: flutterParams.templatesPath, - colorsClassName: flutterParams.colors?.className + outputDirectory: flutter.output, + templatesPath: flutter.templatesPath, + colorsClassName: entry.className ) let exporter = FlutterColorExporter( output: output, - outputFileName: flutterParams.colors?.output + outputFileName: entry.output ) let files = try exporter.export(colorPairs: colorPairs) - let fileName = flutterParams.colors?.output ?? "colors.dart" - let colorsFileURL = flutterParams.output.appendingPathComponent(fileName) + let fileName = entry.output ?? "colors.dart" + let colorsFileURL = flutter.output.appendingPathComponent(fileName) try? FileManager.default.removeItem(atPath: colorsFileURL.path) diff --git a/Sources/ExFig/Subcommands/ExportImages.swift b/Sources/ExFig/Subcommands/ExportImages.swift index ab64c382..db82b53f 100644 --- a/Sources/ExFig/Subcommands/ExportImages.swift +++ b/Sources/ExFig/Subcommands/ExportImages.swift @@ -234,22 +234,122 @@ extension ExFigCommand { return result } - private func exportiOSImages( // swiftlint:disable:this function_body_length + private func exportiOSImages( client: Client, params: Params, granularCacheManager: GranularCacheManager?, ui: TerminalUI ) async throws -> PlatformExportResult { guard let ios = params.ios, - let imagesParams = ios.images + let imagesConfig = ios.images else { ui.warning(.configMissing(platform: "ios", assetType: "images")) return PlatformExportResult(count: 0, hashes: [:]) } - // iOS uses PNG/raster images - granular cache not applicable - // Just load normally (granular cache is handled inside loader for vector formats) - let loader = ImagesLoader(client: client, params: params, platform: .ios, logger: logger) + // Get all entries from config (supports both single and multiple formats) + let entries = imagesConfig.entries + + // Single entry - use direct processing (legacy behavior) + if entries.count == 1 { + return try await exportiOSImagesEntry( + entry: entries[0], + ios: ios, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + } + + // Multiple entries - pre-fetch Components once for all entries + let needsLocalPreFetch = PreFetchedComponentsStorage.components == nil + + if needsLocalPreFetch { + var componentsMap: [String: [Component]] = [:] + let fileIds = Set([params.figma.lightFileId] + (params.figma.darkFileId.map { [$0] } ?? [])) + + for fileId in fileIds { + let components = try await client.request(ComponentsEndpoint(fileId: fileId)) + componentsMap[fileId] = components + } + + let preFetched = PreFetchedComponents(components: componentsMap) + + return try await PreFetchedComponentsStorage.$components.withValue(preFetched) { + try await processIOSImagesEntries( + entries: entries, + ios: ios, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + } + } else { + return try await processIOSImagesEntries( + entries: entries, + ios: ios, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + } + } + + // Helper to process multiple iOS images entries sequentially. + // swiftlint:disable:next function_parameter_count + private func processIOSImagesEntries( + entries: [Params.iOS.ImagesEntry], + ios: Params.iOS, + client: Client, + params: Params, + ui: TerminalUI, + granularCacheManager: GranularCacheManager? + ) async throws -> PlatformExportResult { + var totalCount = 0 + var totalSkipped = 0 + var allHashes: [String: [NodeId: String]] = [:] + + for entry in entries { + let result = try await exportiOSImagesEntry( + entry: entry, + ios: ios, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + totalCount += result.count + totalSkipped += result.skippedCount + allHashes = mergeHashes(allHashes, result.hashes) + } + + return PlatformExportResult( + count: totalCount, + hashes: allHashes, + skippedCount: totalSkipped + ) + } + + // swiftlint:disable:next function_body_length cyclomatic_complexity function_parameter_count + private func exportiOSImagesEntry( + entry: Params.iOS.ImagesEntry, + ios: Params.iOS, + client: Client, + params: Params, + ui: TerminalUI, + granularCacheManager: GranularCacheManager? + ) async throws -> PlatformExportResult { + let loaderConfig = ImagesLoaderConfig.forIOS(entry: entry, params: params) + let loader = ImagesLoader( + client: client, + params: params, + platform: .ios, + logger: logger, + config: loaderConfig + ) loader.granularCacheManager = granularCacheManager let loaderResult = try await ui.withSpinnerProgress("Fetching images from Figma...") { onProgress in @@ -262,12 +362,11 @@ extension ExFigCommand { dark: result.dark, computedHashes: [:], allSkipped: false, - allNames: [] // Not needed when not using granular cache + allNames: [] ) } } - // Early return if all images skipped by granular cache if loaderResult.allSkipped { ui.success("All images unchanged (granular cache hit). Skipping iOS export.") return PlatformExportResult( @@ -283,7 +382,7 @@ extension ExFigCommand { platform: .ios, nameValidateRegexp: params.common?.images?.nameValidateRegexp, nameReplaceRegexp: params.common?.images?.nameReplaceRegexp, - nameStyle: imagesParams.nameStyle + nameStyle: entry.nameStyle ) let (images, imagesWarning): ([AssetPair], AssetsValidatorWarning?) = @@ -295,7 +394,7 @@ extension ExFigCommand { ui.warning(imagesWarning) } - let assetsURL = ios.xcassetsPath.appendingPathComponent(imagesParams.assetsFolder) + let assetsURL = ios.xcassetsPath.appendingPathComponent(entry.assetsFolder) let output = XcodeImagesOutput( assetsFolderURL: assetsURL, @@ -303,13 +402,12 @@ extension ExFigCommand { assetsInSwiftPackage: ios.xcassetsInSwiftPackage, resourceBundleNames: ios.resourceBundleNames, addObjcAttribute: ios.addObjcAttribute, - uiKitImageExtensionURL: imagesParams.imageSwift, - swiftUIImageExtensionURL: imagesParams.swiftUIImageSwift, + uiKitImageExtensionURL: entry.imageSwift, + swiftUIImageExtensionURL: entry.swiftUIImageSwift, templatesPath: ios.templatesPath ) let exporter = XcodeImagesExporter(output: output) - // Process allNames with the same transformations applied to images let allAssetNames = granularCacheManager != nil ? processor.processNames(loaderResult.allNames) : nil @@ -325,7 +423,6 @@ extension ExFigCommand { let remoteFilesCount = localAndRemoteFiles.filter { $0.sourceURL != nil }.count let fileDownloader = faultToleranceOptions.createFileDownloader() - // Download with progress bar (uses pipelined queue in batch mode) let localFiles: [FileContents] = if remoteFilesCount > 0 { try await ui.withProgress("Downloading images", total: remoteFilesCount) { progress in try await PipelinedDownloader.download( @@ -343,13 +440,11 @@ extension ExFigCommand { try fileWriter.write(files: localFiles) } - // Calculate skipped count for granular cache stats let skippedCount = granularCacheManager != nil ? loaderResult.allNames.count - images.count : 0 guard params.ios?.xcassetsInSwiftPackage == false else { - // Suppress update check in batch mode (will be shown once at the end) if BatchProgressViewStorage.progressView == nil { await checkForUpdate(logger: logger) } @@ -373,7 +468,6 @@ extension ExFigCommand { ui.warning(.xcodeProjectUpdateFailed) } - // Suppress update check in batch mode (will be shown once at the end) if BatchProgressViewStorage.progressView == nil { await checkForUpdate(logger: logger) } @@ -386,19 +480,118 @@ extension ExFigCommand { ) } - private func exportAndroidImages( // swiftlint:disable:this function_body_length + private func exportAndroidImages( client: Client, params: Params, granularCacheManager: GranularCacheManager?, ui: TerminalUI ) async throws -> PlatformExportResult { - guard let androidImages = params.android?.images else { + guard let android = params.android, + let imagesConfig = android.images + else { ui.warning(.configMissing(platform: "android", assetType: "images")) return PlatformExportResult(count: 0, hashes: [:]) } - // Android SVG format uses granular cache; PNG/WebP don't - let loader = ImagesLoader(client: client, params: params, platform: .android, logger: logger) + let entries = imagesConfig.entries + + if entries.count == 1 { + return try await exportAndroidImagesEntry( + entry: entries[0], + android: android, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + } + + let needsLocalPreFetch = PreFetchedComponentsStorage.components == nil + + if needsLocalPreFetch { + var componentsMap: [String: [Component]] = [:] + let fileIds = Set([params.figma.lightFileId] + (params.figma.darkFileId.map { [$0] } ?? [])) + + for fileId in fileIds { + let components = try await client.request(ComponentsEndpoint(fileId: fileId)) + componentsMap[fileId] = components + } + + let preFetched = PreFetchedComponents(components: componentsMap) + + return try await PreFetchedComponentsStorage.$components.withValue(preFetched) { + try await processAndroidImagesEntries( + entries: entries, + android: android, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + } + } else { + return try await processAndroidImagesEntries( + entries: entries, + android: android, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + } + } + + // swiftlint:disable:next function_parameter_count + private func processAndroidImagesEntries( + entries: [Params.Android.ImagesEntry], + android: Params.Android, + client: Client, + params: Params, + ui: TerminalUI, + granularCacheManager: GranularCacheManager? + ) async throws -> PlatformExportResult { + var totalCount = 0 + var totalSkipped = 0 + var allHashes: [String: [NodeId: String]] = [:] + + for entry in entries { + let result = try await exportAndroidImagesEntry( + entry: entry, + android: android, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + totalCount += result.count + totalSkipped += result.skippedCount + allHashes = mergeHashes(allHashes, result.hashes) + } + + return PlatformExportResult( + count: totalCount, + hashes: allHashes, + skippedCount: totalSkipped + ) + } + + // swiftlint:disable:next function_body_length cyclomatic_complexity function_parameter_count + private func exportAndroidImagesEntry( + entry: Params.Android.ImagesEntry, + android: Params.Android, + client: Client, + params: Params, + ui: TerminalUI, + granularCacheManager: GranularCacheManager? + ) async throws -> PlatformExportResult { + let loaderConfig = ImagesLoaderConfig.forAndroid(entry: entry, params: params) + let loader = ImagesLoader( + client: client, + params: params, + platform: .android, + logger: logger, + config: loaderConfig + ) loader.granularCacheManager = granularCacheManager let loaderResult = try await ui.withSpinnerProgress("Fetching images from Figma...") { onProgress in @@ -411,12 +604,11 @@ extension ExFigCommand { dark: result.dark, computedHashes: [:], allSkipped: false, - allNames: [] // Not needed when not using granular cache + allNames: [] ) } } - // Early return if all images skipped by granular cache if loaderResult.allSkipped { ui.success("All images unchanged (granular cache hit). Skipping Android export.") return PlatformExportResult( @@ -443,29 +635,30 @@ extension ExFigCommand { ui.warning(imagesWarning) } - switch androidImages.format { + switch entry.format { case .svg: - try await exportAndroidSVGImages( + try await exportAndroidSVGImagesEntry( images: images, - params: params, + entry: entry, + android: android, granularCacheManager: granularCacheManager, ui: ui ) case .png, .webp: - try await exportAndroidRasterImages( + try await exportAndroidRasterImagesEntry( images: images, + entry: entry, + android: android, params: params, granularCacheManager: granularCacheManager, ui: ui ) } - // Suppress update check in batch mode (will be shown once at the end) if BatchProgressViewStorage.progressView == nil { await checkForUpdate(logger: logger) } - // Calculate skipped count for granular cache stats let skippedCount = granularCacheManager != nil ? loaderResult.allNames.count - images.count : 0 @@ -478,23 +671,17 @@ extension ExFigCommand { ) } - // swiftlint:disable:next function_body_length - private func exportAndroidSVGImages( + // swiftlint:disable:next function_body_length function_parameter_count + private func exportAndroidSVGImagesEntry( images: [AssetPair], - params: Params, + entry: Params.Android.ImagesEntry, + android: Params.Android, granularCacheManager: GranularCacheManager?, ui: TerminalUI ) async throws { - guard let android = params.android, let androidImages = android.images else { - ui.warning(.configMissing(platform: "android", assetType: "images")) - return - } - - // Create empty temp directory let tempDirectoryLightURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tempDirectoryDarkURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - // Download SVG files to user's temp directory let remoteFiles = images.flatMap { asset -> [FileContents] in let lightFiles = asset.light.images.compactMap { image -> FileContents? in guard let fileURL = URL(string: "\(image.name).svg") else { return nil } @@ -523,10 +710,8 @@ extension ExFigCommand { [] } - // Move downloaded SVG files to new empty temp directory try fileWriter.write(files: localFiles) - // Convert all SVG to XML files try await ui.withSpinner("Converting SVGs to vector drawables...") { try svgFileConverter.convert(inputDirectoryUrl: tempDirectoryLightURL) if images.first?.dark != nil { @@ -534,22 +719,19 @@ extension ExFigCommand { } } - // Create output directory main/res/drawable/ let lightDirectory = URL(fileURLWithPath: android.mainRes - .appendingPathComponent(androidImages.output) + .appendingPathComponent(entry.output) .appendingPathComponent("drawable", isDirectory: true).path) let darkDirectory = URL(fileURLWithPath: android.mainRes - .appendingPathComponent(androidImages.output) + .appendingPathComponent(entry.output) .appendingPathComponent("drawable-night", isDirectory: true).path) if filter == nil, granularCacheManager == nil { - // Clear output directory try? FileManager.default.removeItem(atPath: lightDirectory.path) try? FileManager.default.removeItem(atPath: darkDirectory.path) } - // Move XML files to main/res/drawable/ localFiles = localFiles.map { fileContents -> FileContents in let source = fileContents.destination.url .deletingPathExtension() @@ -576,22 +758,17 @@ extension ExFigCommand { try? FileManager.default.removeItem(at: tempDirectoryDarkURL) } - // swiftlint:disable:next function_body_length - private func exportAndroidRasterImages( + // swiftlint:disable:next function_body_length function_parameter_count + private func exportAndroidRasterImagesEntry( images: [AssetPair], + entry: Params.Android.ImagesEntry, + android: Params.Android, params: Params, granularCacheManager: GranularCacheManager?, ui: TerminalUI ) async throws { - guard let android = params.android, let androidImages = android.images else { - ui.warning(.configMissing(platform: "android", assetType: "images")) - return - } - - // Create empty temp directory let tempDirectoryURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - // Download files to user's temp directory let remoteFiles = try images.flatMap { asset -> [FileContents] in let lightFiles = try makeRemoteFiles( images: asset.light.images, @@ -618,11 +795,9 @@ extension ExFigCommand { [] } - // Move downloaded files to new empty temp directory try fileWriter.write(files: localFiles) - // Convert to WebP - if androidImages.format == .webp, let options = androidImages.webpOptions { + if entry.format == .webp, let options = entry.webpOptions { let converter: WebpConverter switch (options.encoding, options.quality) { case (.lossless, _): @@ -644,21 +819,18 @@ extension ExFigCommand { } if filter == nil, granularCacheManager == nil { - // Clear output directory - let outputDirectory = URL(fileURLWithPath: android.mainRes.appendingPathComponent(androidImages.output) - .path) + let outputDirectory = URL(fileURLWithPath: android.mainRes.appendingPathComponent(entry.output).path) try? FileManager.default.removeItem(atPath: outputDirectory.path) } - // Move PNG/WebP files to main/res/exfig-images/drawable-XXXdpi/ - let isSingleScale = params.android?.images?.scales?.count == 1 + let isSingleScale = entry.scales?.count == 1 localFiles = localFiles.map { fileContents -> FileContents in let directoryName = Drawable.scaleToDrawableName( fileContents.scale, dark: fileContents.dark, singleScale: isSingleScale ) - let directory = URL(fileURLWithPath: android.mainRes.appendingPathComponent(androidImages.output).path) + let directory = URL(fileURLWithPath: android.mainRes.appendingPathComponent(entry.output).path) .appendingPathComponent(directoryName, isDirectory: true) return FileContents( destination: Destination(directory: directory, file: fileContents.destination.file), @@ -697,19 +869,111 @@ extension ExFigCommand { } } - private func exportFlutterImages( // swiftlint:disable:this function_body_length + private func exportFlutterImages( client: Client, params: Params, granularCacheManager: GranularCacheManager?, ui: TerminalUI ) async throws -> PlatformExportResult { - guard let flutter = params.flutter, let flutterImages = flutter.images else { + guard let flutter = params.flutter, + let imagesConfig = flutter.images + else { ui.warning(.configMissing(platform: "flutter", assetType: "images")) return PlatformExportResult(count: 0, hashes: [:]) } - // Determine format - let formatString = switch flutterImages.format { + let entries = imagesConfig.entries + + if entries.count == 1 { + return try await exportFlutterImagesEntry( + entry: entries[0], + flutter: flutter, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + } + + let needsLocalPreFetch = PreFetchedComponentsStorage.components == nil + + if needsLocalPreFetch { + var componentsMap: [String: [Component]] = [:] + let fileIds = Set([params.figma.lightFileId] + (params.figma.darkFileId.map { [$0] } ?? [])) + + for fileId in fileIds { + let components = try await client.request(ComponentsEndpoint(fileId: fileId)) + componentsMap[fileId] = components + } + + let preFetched = PreFetchedComponents(components: componentsMap) + + return try await PreFetchedComponentsStorage.$components.withValue(preFetched) { + try await processFlutterImagesEntries( + entries: entries, + flutter: flutter, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + } + } else { + return try await processFlutterImagesEntries( + entries: entries, + flutter: flutter, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + } + } + + // swiftlint:disable:next function_parameter_count + private func processFlutterImagesEntries( + entries: [Params.Flutter.ImagesEntry], + flutter: Params.Flutter, + client: Client, + params: Params, + ui: TerminalUI, + granularCacheManager: GranularCacheManager? + ) async throws -> PlatformExportResult { + var totalCount = 0 + var totalSkipped = 0 + var allHashes: [String: [NodeId: String]] = [:] + + for entry in entries { + let result = try await exportFlutterImagesEntry( + entry: entry, + flutter: flutter, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + totalCount += result.count + totalSkipped += result.skippedCount + allHashes = mergeHashes(allHashes, result.hashes) + } + + return PlatformExportResult( + count: totalCount, + hashes: allHashes, + skippedCount: totalSkipped + ) + } + + // swiftlint:disable:next function_body_length cyclomatic_complexity function_parameter_count + private func exportFlutterImagesEntry( + entry: Params.Flutter.ImagesEntry, + flutter: Params.Flutter, + client: Client, + params: Params, + ui: TerminalUI, + granularCacheManager: GranularCacheManager? + ) async throws -> PlatformExportResult { + let formatString = switch entry.format { case .png, .none: "png" case .svg: @@ -718,8 +982,14 @@ extension ExFigCommand { "webp" } - // 1. Get Images info (Flutter uses .android platform for similar loading behavior) - let loader = ImagesLoader(client: client, params: params, platform: .android, logger: logger) + let loaderConfig = ImagesLoaderConfig.forFlutter(entry: entry, params: params) + let loader = ImagesLoader( + client: client, + params: params, + platform: .android, + logger: logger, + config: loaderConfig + ) loader.granularCacheManager = granularCacheManager let loaderResult = try await ui.withSpinnerProgress("Fetching images from Figma...") { onProgress in @@ -732,12 +1002,11 @@ extension ExFigCommand { dark: result.dark, computedHashes: [:], allSkipped: false, - allNames: [] // Not needed when not using granular cache + allNames: [] ) } } - // Early return if all images skipped by granular cache if loaderResult.allSkipped { ui.success("All images unchanged (granular cache hit). Skipping Flutter export.") return PlatformExportResult( @@ -749,9 +1018,8 @@ extension ExFigCommand { let imagesTuple = (light: loaderResult.light, dark: loaderResult.dark) - // 2. Process images let processor = ImagesProcessor( - platform: .android, // Flutter uses similar naming to Android + platform: .android, nameValidateRegexp: params.common?.images?.nameValidateRegexp, nameReplaceRegexp: params.common?.images?.nameReplaceRegexp, nameStyle: .snakeCase @@ -766,28 +1034,25 @@ extension ExFigCommand { ui.warning(imagesWarning) } - // 3. Export images - let assetsDirectory = URL(fileURLWithPath: flutterImages.output) + let assetsDirectory = URL(fileURLWithPath: entry.output) let output = FlutterOutput( outputDirectory: flutter.output, imagesAssetsDirectory: assetsDirectory, templatesPath: flutter.templatesPath, - imagesClassName: flutterImages.className + imagesClassName: entry.className ) let exporter = FlutterImagesExporter( output: output, - outputFileName: flutterImages.dartFile, - scales: flutterImages.scales, + outputFileName: entry.dartFile, + scales: entry.scales, format: formatString ) - // Process allNames with the same transformations applied to images let allImageNames = granularCacheManager != nil ? processor.processNames(loaderResult.allNames) : nil let (dartFile, assetFiles) = try exporter.export(images: images, allImageNames: allImageNames) - // 4. Download image files (uses pipelined queue in batch mode) let remoteFiles = assetFiles.filter { $0.sourceURL != nil } let fileDownloader = faultToleranceOptions.createFileDownloader() @@ -804,8 +1069,7 @@ extension ExFigCommand { [] } - // Convert to WebP if needed - if flutterImages.format == .webp, let options = flutterImages.webpOptions { + if entry.format == .webp, let options = entry.webpOptions { let converter: WebpConverter switch (options.encoding, options.quality) { case (.lossless, _): @@ -826,12 +1090,10 @@ extension ExFigCommand { localFiles = localFiles.map { $0.changingExtension(newExtension: "webp") } } - // Clear output directory if not filtering if filter == nil, granularCacheManager == nil { try? FileManager.default.removeItem(atPath: assetsDirectory.path) } - // 5. Write files localFiles.append(dartFile) let filesToWrite = localFiles @@ -841,7 +1103,6 @@ extension ExFigCommand { await checkForUpdate(logger: logger) - // Calculate skipped count for granular cache stats let skippedCount = granularCacheManager != nil ? loaderResult.allNames.count - images.count : 0 diff --git a/Tests/ExFigTests/Input/ColorsConfigurationTests.swift b/Tests/ExFigTests/Input/ColorsConfigurationTests.swift new file mode 100644 index 00000000..752dc9d4 --- /dev/null +++ b/Tests/ExFigTests/Input/ColorsConfigurationTests.swift @@ -0,0 +1,559 @@ +// swiftlint:disable file_length type_body_length +@testable import ExFig +import XCTest + +final class ColorsConfigurationTests: XCTestCase { + // MARK: - iOS ColorsConfiguration + + func testIOSColorsConfigurationParsesLegacySingleObject() throws { + let json = """ + { + "useColorAssets": true, + "assetsFolder": "Colors", + "nameStyle": "camelCase" + } + """ + + let config = try JSONDecoder().decode( + Params.iOS.ColorsConfiguration.self, + from: Data(json.utf8) + ) + + guard case .single = config else { + XCTFail("Expected .single case") + return + } + + XCTAssertEqual(config.entries.count, 1) + XCTAssertTrue(config.entries[0].useColorAssets) + XCTAssertEqual(config.entries[0].assetsFolder, "Colors") + XCTAssertFalse(config.isMultiple) + } + + func testIOSColorsConfigurationParsesMultipleEntries() throws { + let json = """ + [ + { + "tokensFileId": "file1", + "tokensCollectionName": "Base palette", + "lightModeName": "Light", + "useColorAssets": true, + "assetsFolder": "BaseColors", + "nameStyle": "camelCase", + "colorSwift": "./Generated/BaseColors.swift" + }, + { + "tokensFileId": "file2", + "tokensCollectionName": "Statement palette", + "lightModeName": "Light", + "darkModeName": "Dark", + "useColorAssets": true, + "assetsFolder": "StatementColors", + "nameStyle": "snake_case", + "colorSwift": "./Generated/StatementColors.swift" + } + ] + """ + + let config = try JSONDecoder().decode( + Params.iOS.ColorsConfiguration.self, + from: Data(json.utf8) + ) + + guard case .multiple = config else { + XCTFail("Expected .multiple case") + return + } + + XCTAssertEqual(config.entries.count, 2) + XCTAssertTrue(config.isMultiple) + + XCTAssertEqual(config.entries[0].tokensFileId, "file1") + XCTAssertEqual(config.entries[0].tokensCollectionName, "Base palette") + XCTAssertEqual(config.entries[0].lightModeName, "Light") + XCTAssertNil(config.entries[0].darkModeName) + XCTAssertEqual(config.entries[0].assetsFolder, "BaseColors") + XCTAssertEqual(config.entries[0].nameStyle, .camelCase) + + XCTAssertEqual(config.entries[1].tokensFileId, "file2") + XCTAssertEqual(config.entries[1].tokensCollectionName, "Statement palette") + XCTAssertEqual(config.entries[1].darkModeName, "Dark") + XCTAssertEqual(config.entries[1].assetsFolder, "StatementColors") + XCTAssertEqual(config.entries[1].nameStyle, .snakeCase) + } + + func testIOSColorsEntryParsesAllFields() throws { + let json = """ + { + "tokensFileId": "abc123", + "tokensCollectionName": "Design Tokens", + "lightModeName": "Light", + "darkModeName": "Dark", + "lightHCModeName": "Light HC", + "darkHCModeName": "Dark HC", + "primitivesModeName": "Primitives", + "nameValidateRegexp": "^color_.*", + "nameReplaceRegexp": "color_", + "useColorAssets": true, + "assetsFolder": "DesignColors", + "nameStyle": "camelCase", + "groupUsingNamespace": true, + "colorSwift": "./Generated/Colors.swift", + "swiftuiColorSwift": "./Generated/SwiftUIColors.swift" + } + """ + + let entry = try JSONDecoder().decode( + Params.iOS.ColorsEntry.self, + from: Data(json.utf8) + ) + + XCTAssertEqual(entry.tokensFileId, "abc123") + XCTAssertEqual(entry.tokensCollectionName, "Design Tokens") + XCTAssertEqual(entry.lightModeName, "Light") + XCTAssertEqual(entry.darkModeName, "Dark") + XCTAssertEqual(entry.lightHCModeName, "Light HC") + XCTAssertEqual(entry.darkHCModeName, "Dark HC") + XCTAssertEqual(entry.primitivesModeName, "Primitives") + XCTAssertEqual(entry.nameValidateRegexp, "^color_.*") + XCTAssertEqual(entry.nameReplaceRegexp, "color_") + XCTAssertTrue(entry.useColorAssets) + XCTAssertEqual(entry.assetsFolder, "DesignColors") + XCTAssertEqual(entry.nameStyle, .camelCase) + XCTAssertEqual(entry.groupUsingNamespace, true) + XCTAssertEqual(entry.colorSwift?.lastPathComponent, "Colors.swift") + XCTAssertEqual(entry.swiftuiColorSwift?.lastPathComponent, "SwiftUIColors.swift") + } + + // MARK: - Android ColorsConfiguration + + func testAndroidColorsConfigurationParsesLegacySingleObject() throws { + let json = """ + { + "xmlOutputFileName": "custom_colors.xml" + } + """ + + let config = try JSONDecoder().decode( + Params.Android.ColorsConfiguration.self, + from: Data(json.utf8) + ) + + guard case .single = config else { + XCTFail("Expected .single case") + return + } + + XCTAssertEqual(config.entries.count, 1) + XCTAssertEqual(config.entries[0].xmlOutputFileName, "custom_colors.xml") + XCTAssertFalse(config.isMultiple) + } + + func testAndroidColorsConfigurationParsesMultipleEntries() throws { + let json = """ + [ + { + "tokensFileId": "file1", + "tokensCollectionName": "Base palette", + "lightModeName": "Light", + "xmlOutputFileName": "base_colors.xml" + }, + { + "tokensFileId": "file2", + "tokensCollectionName": "Theme palette", + "lightModeName": "Light", + "darkModeName": "Dark", + "xmlOutputFileName": "theme_colors.xml", + "composePackageName": "com.example.theme" + } + ] + """ + + let config = try JSONDecoder().decode( + Params.Android.ColorsConfiguration.self, + from: Data(json.utf8) + ) + + guard case .multiple = config else { + XCTFail("Expected .multiple case") + return + } + + XCTAssertEqual(config.entries.count, 2) + XCTAssertTrue(config.isMultiple) + + XCTAssertEqual(config.entries[0].tokensFileId, "file1") + XCTAssertEqual(config.entries[0].tokensCollectionName, "Base palette") + XCTAssertEqual(config.entries[0].xmlOutputFileName, "base_colors.xml") + XCTAssertNil(config.entries[0].composePackageName) + + XCTAssertEqual(config.entries[1].tokensFileId, "file2") + XCTAssertEqual(config.entries[1].darkModeName, "Dark") + XCTAssertEqual(config.entries[1].xmlOutputFileName, "theme_colors.xml") + XCTAssertEqual(config.entries[1].composePackageName, "com.example.theme") + } + + func testAndroidColorsEntryParsesAllFields() throws { + let json = """ + { + "tokensFileId": "abc123", + "tokensCollectionName": "Design Tokens", + "lightModeName": "Light", + "darkModeName": "Dark", + "lightHCModeName": "Light HC", + "darkHCModeName": "Dark HC", + "primitivesModeName": "Primitives", + "nameValidateRegexp": "^color_.*", + "nameReplaceRegexp": "color_", + "xmlOutputFileName": "design_colors.xml", + "composePackageName": "com.example.colors" + } + """ + + let entry = try JSONDecoder().decode( + Params.Android.ColorsEntry.self, + from: Data(json.utf8) + ) + + XCTAssertEqual(entry.tokensFileId, "abc123") + XCTAssertEqual(entry.tokensCollectionName, "Design Tokens") + XCTAssertEqual(entry.lightModeName, "Light") + XCTAssertEqual(entry.darkModeName, "Dark") + XCTAssertEqual(entry.lightHCModeName, "Light HC") + XCTAssertEqual(entry.darkHCModeName, "Dark HC") + XCTAssertEqual(entry.primitivesModeName, "Primitives") + XCTAssertEqual(entry.nameValidateRegexp, "^color_.*") + XCTAssertEqual(entry.nameReplaceRegexp, "color_") + XCTAssertEqual(entry.xmlOutputFileName, "design_colors.xml") + XCTAssertEqual(entry.composePackageName, "com.example.colors") + } + + // MARK: - Flutter ColorsConfiguration + + func testFlutterColorsConfigurationParsesLegacySingleObject() throws { + let json = """ + { + "output": "colors.dart", + "className": "AppColors" + } + """ + + let config = try JSONDecoder().decode( + Params.Flutter.ColorsConfiguration.self, + from: Data(json.utf8) + ) + + guard case .single = config else { + XCTFail("Expected .single case") + return + } + + XCTAssertEqual(config.entries.count, 1) + XCTAssertEqual(config.entries[0].output, "colors.dart") + XCTAssertEqual(config.entries[0].className, "AppColors") + XCTAssertFalse(config.isMultiple) + } + + func testFlutterColorsConfigurationParsesMultipleEntries() throws { + let json = """ + [ + { + "tokensFileId": "file1", + "tokensCollectionName": "Base palette", + "lightModeName": "Light", + "output": "base_colors.dart", + "className": "BaseColors" + }, + { + "tokensFileId": "file2", + "tokensCollectionName": "Theme palette", + "lightModeName": "Light", + "darkModeName": "Dark", + "output": "theme_colors.dart", + "className": "ThemeColors" + } + ] + """ + + let config = try JSONDecoder().decode( + Params.Flutter.ColorsConfiguration.self, + from: Data(json.utf8) + ) + + guard case .multiple = config else { + XCTFail("Expected .multiple case") + return + } + + XCTAssertEqual(config.entries.count, 2) + XCTAssertTrue(config.isMultiple) + + XCTAssertEqual(config.entries[0].tokensFileId, "file1") + XCTAssertEqual(config.entries[0].tokensCollectionName, "Base palette") + XCTAssertEqual(config.entries[0].output, "base_colors.dart") + XCTAssertEqual(config.entries[0].className, "BaseColors") + + XCTAssertEqual(config.entries[1].tokensFileId, "file2") + XCTAssertEqual(config.entries[1].darkModeName, "Dark") + XCTAssertEqual(config.entries[1].output, "theme_colors.dart") + XCTAssertEqual(config.entries[1].className, "ThemeColors") + } + + func testFlutterColorsEntryParsesAllFields() throws { + let json = """ + { + "tokensFileId": "abc123", + "tokensCollectionName": "Design Tokens", + "lightModeName": "Light", + "darkModeName": "Dark", + "lightHCModeName": "Light HC", + "darkHCModeName": "Dark HC", + "primitivesModeName": "Primitives", + "nameValidateRegexp": "^color_.*", + "nameReplaceRegexp": "color_", + "output": "design_colors.dart", + "className": "DesignColors" + } + """ + + let entry = try JSONDecoder().decode( + Params.Flutter.ColorsEntry.self, + from: Data(json.utf8) + ) + + XCTAssertEqual(entry.tokensFileId, "abc123") + XCTAssertEqual(entry.tokensCollectionName, "Design Tokens") + XCTAssertEqual(entry.lightModeName, "Light") + XCTAssertEqual(entry.darkModeName, "Dark") + XCTAssertEqual(entry.lightHCModeName, "Light HC") + XCTAssertEqual(entry.darkHCModeName, "Dark HC") + XCTAssertEqual(entry.primitivesModeName, "Primitives") + XCTAssertEqual(entry.nameValidateRegexp, "^color_.*") + XCTAssertEqual(entry.nameReplaceRegexp, "color_") + XCTAssertEqual(entry.output, "design_colors.dart") + XCTAssertEqual(entry.className, "DesignColors") + } + + // MARK: - Full Params Integration + + func testFullParamsWithIOSColorsArray() throws { + let json = """ + { + "figma": { + "lightFileId": "test-file" + }, + "ios": { + "xcodeprojPath": ".swiftpm/xcode/package.xcworkspace", + "target": "TestTarget", + "xcassetsPath": "./Resources/Colors.xcassets", + "xcassetsInMainBundle": true, + "colors": [ + { + "tokensFileId": "file1", + "tokensCollectionName": "Base", + "lightModeName": "Light", + "useColorAssets": true, + "assetsFolder": "Base", + "nameStyle": "camelCase" + }, + { + "tokensFileId": "file2", + "tokensCollectionName": "Theme", + "lightModeName": "Light", + "useColorAssets": true, + "assetsFolder": "Theme", + "nameStyle": "camelCase" + } + ] + } + } + """ + + let params = try JSONDecoder().decode(Params.self, from: Data(json.utf8)) + + XCTAssertNotNil(params.ios?.colors) + XCTAssertEqual(params.ios?.colors?.entries.count, 2) + XCTAssertTrue(params.ios?.colors?.isMultiple ?? false) + } + + func testFullParamsWithIOSColorsLegacy() throws { + let json = """ + { + "figma": { + "lightFileId": "test-file" + }, + "ios": { + "xcodeprojPath": ".swiftpm/xcode/package.xcworkspace", + "target": "TestTarget", + "xcassetsPath": "./Resources/Colors.xcassets", + "xcassetsInMainBundle": true, + "colors": { + "useColorAssets": true, + "assetsFolder": "Colors", + "nameStyle": "camelCase" + } + } + } + """ + + let params = try JSONDecoder().decode(Params.self, from: Data(json.utf8)) + + XCTAssertNotNil(params.ios?.colors) + XCTAssertEqual(params.ios?.colors?.entries.count, 1) + XCTAssertFalse(params.ios?.colors?.isMultiple ?? true) + } + + func testFullParamsWithAndroidColorsArray() throws { + let json = """ + { + "figma": { + "lightFileId": "test-file" + }, + "android": { + "mainRes": "./app/src/main/res", + "colors": [ + { + "tokensFileId": "file1", + "tokensCollectionName": "Base", + "lightModeName": "Light", + "xmlOutputFileName": "base_colors.xml" + }, + { + "tokensFileId": "file2", + "tokensCollectionName": "Theme", + "lightModeName": "Light", + "xmlOutputFileName": "theme_colors.xml" + } + ] + } + } + """ + + let params = try JSONDecoder().decode(Params.self, from: Data(json.utf8)) + + XCTAssertNotNil(params.android?.colors) + XCTAssertEqual(params.android?.colors?.entries.count, 2) + XCTAssertTrue(params.android?.colors?.isMultiple ?? false) + } + + func testFullParamsWithFlutterColorsArray() throws { + let json = """ + { + "figma": { + "lightFileId": "test-file" + }, + "flutter": { + "output": "./lib/generated", + "colors": [ + { + "tokensFileId": "file1", + "tokensCollectionName": "Base", + "lightModeName": "Light", + "output": "base_colors.dart", + "className": "BaseColors" + }, + { + "tokensFileId": "file2", + "tokensCollectionName": "Theme", + "lightModeName": "Light", + "output": "theme_colors.dart", + "className": "ThemeColors" + } + ] + } + } + """ + + let params = try JSONDecoder().decode(Params.self, from: Data(json.utf8)) + + XCTAssertNotNil(params.flutter?.colors) + XCTAssertEqual(params.flutter?.colors?.entries.count, 2) + XCTAssertTrue(params.flutter?.colors?.isMultiple ?? false) + } + + // MARK: - Edge Cases + + func testIOSColorsConfigurationWithEmptyArray() throws { + let json = "[]" + + let config = try JSONDecoder().decode( + Params.iOS.ColorsConfiguration.self, + from: Data(json.utf8) + ) + + guard case .multiple = config else { + XCTFail("Expected .multiple case for empty array") + return + } + + XCTAssertEqual(config.entries.count, 0) + XCTAssertTrue(config.isMultiple) + } + + func testAndroidColorsConfigurationWithEmptyArray() throws { + let json = "[]" + + let config = try JSONDecoder().decode( + Params.Android.ColorsConfiguration.self, + from: Data(json.utf8) + ) + + guard case .multiple = config else { + XCTFail("Expected .multiple case for empty array") + return + } + + XCTAssertEqual(config.entries.count, 0) + XCTAssertTrue(config.isMultiple) + } + + func testFlutterColorsConfigurationWithEmptyArray() throws { + let json = "[]" + + let config = try JSONDecoder().decode( + Params.Flutter.ColorsConfiguration.self, + from: Data(json.utf8) + ) + + guard case .multiple = config else { + XCTFail("Expected .multiple case for empty array") + return + } + + XCTAssertEqual(config.entries.count, 0) + XCTAssertTrue(config.isMultiple) + } + + func testIOSColorsConfigurationFailsWithInvalidType() throws { + let json = "\"not_an_object_or_array\"" + + XCTAssertThrowsError( + try JSONDecoder().decode( + Params.iOS.ColorsConfiguration.self, + from: Data(json.utf8) + ) + ) + } + + func testAndroidColorsConfigurationFailsWithInvalidType() throws { + let json = "\"not_an_object_or_array\"" + + XCTAssertThrowsError( + try JSONDecoder().decode( + Params.Android.ColorsConfiguration.self, + from: Data(json.utf8) + ) + ) + } + + func testFlutterColorsConfigurationFailsWithInvalidType() throws { + let json = "\"not_an_object_or_array\"" + + XCTAssertThrowsError( + try JSONDecoder().decode( + Params.Flutter.ColorsConfiguration.self, + from: Data(json.utf8) + ) + ) + } +} diff --git a/Tests/ExFigTests/Input/ImagesConfigurationTests.swift b/Tests/ExFigTests/Input/ImagesConfigurationTests.swift new file mode 100644 index 00000000..731d67e6 --- /dev/null +++ b/Tests/ExFigTests/Input/ImagesConfigurationTests.swift @@ -0,0 +1,528 @@ +// swiftlint:disable file_length type_body_length +@testable import ExFig +import XCTest + +final class ImagesConfigurationTests: XCTestCase { + // MARK: - iOS ImagesConfiguration + + func testIOSImagesConfigurationParsesLegacySingleObject() throws { + let json = """ + { + "assetsFolder": "Illustrations", + "nameStyle": "camelCase" + } + """ + + let config = try JSONDecoder().decode( + Params.iOS.ImagesConfiguration.self, + from: Data(json.utf8) + ) + + guard case .single = config else { + XCTFail("Expected .single case") + return + } + + XCTAssertEqual(config.entries.count, 1) + XCTAssertEqual(config.entries[0].assetsFolder, "Illustrations") + XCTAssertFalse(config.isMultiple) + } + + func testIOSImagesConfigurationParsesMultipleEntries() throws { + let json = """ + [ + { + "figmaFrameName": "InDrive", + "assetsFolder": "inDrive", + "nameStyle": "camelCase", + "imageSwift": "./Generated/InDrive.swift" + }, + { + "figmaFrameName": "Promo", + "assetsFolder": "promo", + "nameStyle": "snake_case", + "scales": [1.0, 2.0, 3.0], + "swiftUIImageSwift": "./Generated/SwiftUIPromo.swift" + } + ] + """ + + let config = try JSONDecoder().decode( + Params.iOS.ImagesConfiguration.self, + from: Data(json.utf8) + ) + + guard case .multiple = config else { + XCTFail("Expected .multiple case") + return + } + + XCTAssertEqual(config.entries.count, 2) + XCTAssertTrue(config.isMultiple) + + XCTAssertEqual(config.entries[0].figmaFrameName, "InDrive") + XCTAssertEqual(config.entries[0].assetsFolder, "inDrive") + XCTAssertEqual(config.entries[0].nameStyle, .camelCase) + XCTAssertEqual(config.entries[0].imageSwift?.lastPathComponent, "InDrive.swift") + XCTAssertNil(config.entries[0].scales) + + XCTAssertEqual(config.entries[1].figmaFrameName, "Promo") + XCTAssertEqual(config.entries[1].assetsFolder, "promo") + XCTAssertEqual(config.entries[1].nameStyle, .snakeCase) + XCTAssertEqual(config.entries[1].scales, [1.0, 2.0, 3.0]) + XCTAssertEqual(config.entries[1].swiftUIImageSwift?.lastPathComponent, "SwiftUIPromo.swift") + } + + func testIOSImagesEntryParsesAllFields() throws { + let json = """ + { + "figmaFrameName": "Illustrations", + "assetsFolder": "Assets", + "nameStyle": "camelCase", + "scales": [1.0, 2.0, 3.0], + "imageSwift": "./Generated/Images.swift", + "swiftUIImageSwift": "./Generated/SwiftUIImages.swift" + } + """ + + let entry = try JSONDecoder().decode( + Params.iOS.ImagesEntry.self, + from: Data(json.utf8) + ) + + XCTAssertEqual(entry.figmaFrameName, "Illustrations") + XCTAssertEqual(entry.assetsFolder, "Assets") + XCTAssertEqual(entry.nameStyle, .camelCase) + XCTAssertEqual(entry.scales, [1.0, 2.0, 3.0]) + XCTAssertEqual(entry.imageSwift?.lastPathComponent, "Images.swift") + XCTAssertEqual(entry.swiftUIImageSwift?.lastPathComponent, "SwiftUIImages.swift") + } + + func testIOSImagesEntriesConversionFromLegacy() throws { + let json = """ + { + "assetsFolder": "Legacy", + "nameStyle": "snake_case" + } + """ + + let config = try JSONDecoder().decode( + Params.iOS.ImagesConfiguration.self, + from: Data(json.utf8) + ) + + let entries = config.entries + XCTAssertEqual(entries.count, 1) + XCTAssertNil(entries[0].figmaFrameName) // Legacy doesn't have this + XCTAssertEqual(entries[0].assetsFolder, "Legacy") + XCTAssertEqual(entries[0].nameStyle, .snakeCase) + } + + // MARK: - Android ImagesConfiguration + + func testAndroidImagesConfigurationParsesLegacySingleObject() throws { + let json = """ + { + "output": "drawable", + "format": "svg" + } + """ + + let config = try JSONDecoder().decode( + Params.Android.ImagesConfiguration.self, + from: Data(json.utf8) + ) + + guard case .single = config else { + XCTFail("Expected .single case") + return + } + + XCTAssertEqual(config.entries.count, 1) + XCTAssertEqual(config.entries[0].output, "drawable") + XCTAssertEqual(config.entries[0].format, .svg) + XCTAssertFalse(config.isMultiple) + } + + func testAndroidImagesConfigurationParsesMultipleEntries() throws { + let json = """ + [ + { + "figmaFrameName": "Illustrations", + "output": "drawable-illustrations", + "format": "svg" + }, + { + "figmaFrameName": "Photos", + "output": "drawable-photos", + "format": "webp", + "scales": [1.0, 1.5, 2.0, 3.0, 4.0], + "webpOptions": { + "encoding": "lossy", + "quality": 80 + } + } + ] + """ + + let config = try JSONDecoder().decode( + Params.Android.ImagesConfiguration.self, + from: Data(json.utf8) + ) + + guard case .multiple = config else { + XCTFail("Expected .multiple case") + return + } + + XCTAssertEqual(config.entries.count, 2) + XCTAssertTrue(config.isMultiple) + + XCTAssertEqual(config.entries[0].figmaFrameName, "Illustrations") + XCTAssertEqual(config.entries[0].output, "drawable-illustrations") + XCTAssertEqual(config.entries[0].format, .svg) + XCTAssertNil(config.entries[0].scales) + + XCTAssertEqual(config.entries[1].figmaFrameName, "Photos") + XCTAssertEqual(config.entries[1].output, "drawable-photos") + XCTAssertEqual(config.entries[1].format, .webp) + XCTAssertEqual(config.entries[1].scales, [1.0, 1.5, 2.0, 3.0, 4.0]) + XCTAssertEqual(config.entries[1].webpOptions?.encoding, .lossy) + XCTAssertEqual(config.entries[1].webpOptions?.quality, 80) + } + + func testAndroidImagesEntryParsesAllFields() throws { + let json = """ + { + "figmaFrameName": "Images", + "output": "drawable", + "format": "png", + "scales": [1.0, 2.0, 3.0], + "webpOptions": { + "encoding": "lossless" + } + } + """ + + let entry = try JSONDecoder().decode( + Params.Android.ImagesEntry.self, + from: Data(json.utf8) + ) + + XCTAssertEqual(entry.figmaFrameName, "Images") + XCTAssertEqual(entry.output, "drawable") + XCTAssertEqual(entry.format, .png) + XCTAssertEqual(entry.scales, [1.0, 2.0, 3.0]) + XCTAssertEqual(entry.webpOptions?.encoding, .lossless) + } + + // MARK: - Flutter ImagesConfiguration + + func testFlutterImagesConfigurationParsesLegacySingleObject() throws { + let json = """ + { + "output": "assets/images" + } + """ + + let config = try JSONDecoder().decode( + Params.Flutter.ImagesConfiguration.self, + from: Data(json.utf8) + ) + + guard case .single = config else { + XCTFail("Expected .single case") + return + } + + XCTAssertEqual(config.entries.count, 1) + XCTAssertEqual(config.entries[0].output, "assets/images") + XCTAssertFalse(config.isMultiple) + } + + func testFlutterImagesConfigurationParsesMultipleEntries() throws { + let json = """ + [ + { + "figmaFrameName": "Illustrations", + "output": "assets/images/illustrations", + "dartFile": "lib/generated/illustrations.dart", + "className": "Illustrations" + }, + { + "figmaFrameName": "Promo", + "output": "assets/images/promo", + "dartFile": "lib/generated/promo.dart", + "className": "PromoImages", + "scales": [1.0, 2.0, 3.0], + "format": "webp" + } + ] + """ + + let config = try JSONDecoder().decode( + Params.Flutter.ImagesConfiguration.self, + from: Data(json.utf8) + ) + + guard case .multiple = config else { + XCTFail("Expected .multiple case") + return + } + + XCTAssertEqual(config.entries.count, 2) + XCTAssertTrue(config.isMultiple) + + XCTAssertEqual(config.entries[0].figmaFrameName, "Illustrations") + XCTAssertEqual(config.entries[0].output, "assets/images/illustrations") + XCTAssertEqual(config.entries[0].dartFile, "lib/generated/illustrations.dart") + XCTAssertEqual(config.entries[0].className, "Illustrations") + XCTAssertNil(config.entries[0].scales) + + XCTAssertEqual(config.entries[1].figmaFrameName, "Promo") + XCTAssertEqual(config.entries[1].output, "assets/images/promo") + XCTAssertEqual(config.entries[1].dartFile, "lib/generated/promo.dart") + XCTAssertEqual(config.entries[1].className, "PromoImages") + XCTAssertEqual(config.entries[1].scales, [1.0, 2.0, 3.0]) + XCTAssertEqual(config.entries[1].format, .webp) + } + + func testFlutterImagesEntryParsesAllFields() throws { + let json = """ + { + "figmaFrameName": "Images", + "output": "assets/images", + "dartFile": "lib/images.dart", + "className": "AppImages", + "scales": [1.0, 2.0, 3.0], + "format": "png", + "webpOptions": { + "encoding": "lossy", + "quality": 90 + } + } + """ + + let entry = try JSONDecoder().decode( + Params.Flutter.ImagesEntry.self, + from: Data(json.utf8) + ) + + XCTAssertEqual(entry.figmaFrameName, "Images") + XCTAssertEqual(entry.output, "assets/images") + XCTAssertEqual(entry.dartFile, "lib/images.dart") + XCTAssertEqual(entry.className, "AppImages") + XCTAssertEqual(entry.scales, [1.0, 2.0, 3.0]) + XCTAssertEqual(entry.format, .png) + XCTAssertEqual(entry.webpOptions?.encoding, .lossy) + XCTAssertEqual(entry.webpOptions?.quality, 90) + } + + // MARK: - Full Params Integration + + func testFullParamsWithIOSImagesArray() throws { + let json = """ + { + "figma": { + "lightFileId": "test-file" + }, + "ios": { + "xcodeprojPath": ".swiftpm/xcode/package.xcworkspace", + "target": "TestTarget", + "xcassetsPath": "./Resources/Images.xcassets", + "xcassetsInMainBundle": true, + "images": [ + { + "figmaFrameName": "InDrive", + "assetsFolder": "InDrive", + "nameStyle": "camelCase" + }, + { + "figmaFrameName": "Promo", + "assetsFolder": "Promo", + "nameStyle": "snake_case" + } + ] + } + } + """ + + let params = try JSONDecoder().decode(Params.self, from: Data(json.utf8)) + + XCTAssertNotNil(params.ios?.images) + XCTAssertEqual(params.ios?.images?.entries.count, 2) + XCTAssertTrue(params.ios?.images?.isMultiple ?? false) + } + + func testFullParamsWithIOSImagesLegacy() throws { + let json = """ + { + "figma": { + "lightFileId": "test-file" + }, + "ios": { + "xcodeprojPath": ".swiftpm/xcode/package.xcworkspace", + "target": "TestTarget", + "xcassetsPath": "./Resources/Images.xcassets", + "xcassetsInMainBundle": true, + "images": { + "assetsFolder": "Images", + "nameStyle": "camelCase" + } + } + } + """ + + let params = try JSONDecoder().decode(Params.self, from: Data(json.utf8)) + + XCTAssertNotNil(params.ios?.images) + XCTAssertEqual(params.ios?.images?.entries.count, 1) + XCTAssertFalse(params.ios?.images?.isMultiple ?? true) + } + + func testFullParamsWithAndroidImagesArray() throws { + let json = """ + { + "figma": { + "lightFileId": "test-file" + }, + "android": { + "mainRes": "./app/src/main/res", + "images": [ + { + "figmaFrameName": "Illustrations", + "output": "drawable-illustrations", + "format": "svg" + }, + { + "figmaFrameName": "Photos", + "output": "drawable-photos", + "format": "webp" + } + ] + } + } + """ + + let params = try JSONDecoder().decode(Params.self, from: Data(json.utf8)) + + XCTAssertNotNil(params.android?.images) + XCTAssertEqual(params.android?.images?.entries.count, 2) + XCTAssertTrue(params.android?.images?.isMultiple ?? false) + } + + func testFullParamsWithFlutterImagesArray() throws { + let json = """ + { + "figma": { + "lightFileId": "test-file" + }, + "flutter": { + "output": "./lib/generated", + "images": [ + { + "figmaFrameName": "Illustrations", + "output": "assets/illustrations" + }, + { + "figmaFrameName": "Photos", + "output": "assets/photos" + } + ] + } + } + """ + + let params = try JSONDecoder().decode(Params.self, from: Data(json.utf8)) + + XCTAssertNotNil(params.flutter?.images) + XCTAssertEqual(params.flutter?.images?.entries.count, 2) + XCTAssertTrue(params.flutter?.images?.isMultiple ?? false) + } + + // MARK: - Edge Cases + + func testIOSImagesConfigurationWithEmptyArray() throws { + let json = "[]" + + let config = try JSONDecoder().decode( + Params.iOS.ImagesConfiguration.self, + from: Data(json.utf8) + ) + + guard case .multiple = config else { + XCTFail("Expected .multiple case for empty array") + return + } + + XCTAssertEqual(config.entries.count, 0) + XCTAssertTrue(config.isMultiple) + } + + func testAndroidImagesConfigurationWithEmptyArray() throws { + let json = "[]" + + let config = try JSONDecoder().decode( + Params.Android.ImagesConfiguration.self, + from: Data(json.utf8) + ) + + guard case .multiple = config else { + XCTFail("Expected .multiple case for empty array") + return + } + + XCTAssertEqual(config.entries.count, 0) + XCTAssertTrue(config.isMultiple) + } + + func testFlutterImagesConfigurationWithEmptyArray() throws { + let json = "[]" + + let config = try JSONDecoder().decode( + Params.Flutter.ImagesConfiguration.self, + from: Data(json.utf8) + ) + + guard case .multiple = config else { + XCTFail("Expected .multiple case for empty array") + return + } + + XCTAssertEqual(config.entries.count, 0) + XCTAssertTrue(config.isMultiple) + } + + func testIOSImagesConfigurationFailsWithInvalidType() throws { + let json = "\"not_an_object_or_array\"" + + XCTAssertThrowsError( + try JSONDecoder().decode( + Params.iOS.ImagesConfiguration.self, + from: Data(json.utf8) + ) + ) + } + + func testAndroidImagesConfigurationFailsWithInvalidType() throws { + let json = "\"not_an_object_or_array\"" + + XCTAssertThrowsError( + try JSONDecoder().decode( + Params.Android.ImagesConfiguration.self, + from: Data(json.utf8) + ) + ) + } + + func testFlutterImagesConfigurationFailsWithInvalidType() throws { + let json = "\"not_an_object_or_array\"" + + XCTAssertThrowsError( + try JSONDecoder().decode( + Params.Flutter.ImagesConfiguration.self, + from: Data(json.utf8) + ) + ) + } +} diff --git a/Tests/ExFigTests/Loaders/ImagesLoaderConfigTests.swift b/Tests/ExFigTests/Loaders/ImagesLoaderConfigTests.swift new file mode 100644 index 00000000..f6ad350b --- /dev/null +++ b/Tests/ExFigTests/Loaders/ImagesLoaderConfigTests.swift @@ -0,0 +1,225 @@ +@testable import ExFig +import XCTest + +final class ImagesLoaderConfigTests: XCTestCase { + // MARK: - iOS Frame Name Resolution + + func testForIOS_entryFrameNameOverridesCommon() throws { + let entry = try makeIOSEntry(figmaFrameName: "Promo") + let params = Params.make(lightFileId: "test", imagesFrameName: "CommonImages") + + let config = ImagesLoaderConfig.forIOS(entry: entry, params: params) + + XCTAssertEqual(config.frameName, "Promo") + } + + func testForIOS_fallbackToCommonFrameName() throws { + let entry = try makeIOSEntry(figmaFrameName: nil) + let params = Params.make(lightFileId: "test", imagesFrameName: "CommonImages") + + let config = ImagesLoaderConfig.forIOS(entry: entry, params: params) + + XCTAssertEqual(config.frameName, "CommonImages") + } + + func testForIOS_fallbackToDefaultFrameName() throws { + let entry = try makeIOSEntry(figmaFrameName: nil) + let params = Params.make(lightFileId: "test") + + let config = ImagesLoaderConfig.forIOS(entry: entry, params: params) + + XCTAssertEqual(config.frameName, "Illustrations") + } + + func testForIOS_passesScalesField() throws { + let entry = try makeIOSEntry(scales: [1.0, 2.0, 3.0]) + let params = Params.make(lightFileId: "test") + + let config = ImagesLoaderConfig.forIOS(entry: entry, params: params) + + XCTAssertEqual(config.scales, [1.0, 2.0, 3.0]) + } + + func testForIOS_nilScalesWhenNotProvided() throws { + let entry = try makeIOSEntry() + let params = Params.make(lightFileId: "test") + + let config = ImagesLoaderConfig.forIOS(entry: entry, params: params) + + XCTAssertNil(config.scales) + } + + // MARK: - Android Frame Name Resolution + + func testForAndroid_entryFrameNameOverridesCommon() throws { + let entry = try makeAndroidEntry(figmaFrameName: "Photos") + let params = Params.make(lightFileId: "test", imagesFrameName: "CommonImages") + + let config = ImagesLoaderConfig.forAndroid(entry: entry, params: params) + + XCTAssertEqual(config.frameName, "Photos") + } + + func testForAndroid_fallbackToCommonFrameName() throws { + let entry = try makeAndroidEntry(figmaFrameName: nil) + let params = Params.make(lightFileId: "test", imagesFrameName: "CommonImages") + + let config = ImagesLoaderConfig.forAndroid(entry: entry, params: params) + + XCTAssertEqual(config.frameName, "CommonImages") + } + + func testForAndroid_fallbackToDefault() throws { + let entry = try makeAndroidEntry(figmaFrameName: nil) + let params = Params.make(lightFileId: "test") + + let config = ImagesLoaderConfig.forAndroid(entry: entry, params: params) + + XCTAssertEqual(config.frameName, "Illustrations") + } + + func testForAndroid_passesScalesField() throws { + let entry = try makeAndroidEntry(scales: [1.0, 1.5, 2.0, 3.0, 4.0]) + let params = Params.make(lightFileId: "test") + + let config = ImagesLoaderConfig.forAndroid(entry: entry, params: params) + + XCTAssertEqual(config.scales, [1.0, 1.5, 2.0, 3.0, 4.0]) + } + + // MARK: - Flutter Frame Name Resolution + + func testForFlutter_entryFrameNameOverridesCommon() throws { + let entry = try makeFlutterEntry(figmaFrameName: "Banners") + let params = Params.make(lightFileId: "test", imagesFrameName: "CommonImages") + + let config = ImagesLoaderConfig.forFlutter(entry: entry, params: params) + + XCTAssertEqual(config.frameName, "Banners") + } + + func testForFlutter_fallbackToCommonFrameName() throws { + let entry = try makeFlutterEntry(figmaFrameName: nil) + let params = Params.make(lightFileId: "test", imagesFrameName: "CommonImages") + + let config = ImagesLoaderConfig.forFlutter(entry: entry, params: params) + + XCTAssertEqual(config.frameName, "CommonImages") + } + + func testForFlutter_fallbackToDefault() throws { + let entry = try makeFlutterEntry(figmaFrameName: nil) + let params = Params.make(lightFileId: "test") + + let config = ImagesLoaderConfig.forFlutter(entry: entry, params: params) + + XCTAssertEqual(config.frameName, "Illustrations") + } + + func testForFlutter_passesScalesField() throws { + let entry = try makeFlutterEntry(scales: [1.0, 2.0, 3.0]) + let params = Params.make(lightFileId: "test") + + let config = ImagesLoaderConfig.forFlutter(entry: entry, params: params) + + XCTAssertEqual(config.scales, [1.0, 2.0, 3.0]) + } + + // MARK: - Default Config + + func testDefaultConfig_usesCommonFrameName() { + let params = Params.make(lightFileId: "test", imagesFrameName: "CommonImages") + + let config = ImagesLoaderConfig.defaultConfig(params: params) + + XCTAssertEqual(config.frameName, "CommonImages") + } + + func testDefaultConfig_fallbackToDefault() { + let params = Params.make(lightFileId: "test") + + let config = ImagesLoaderConfig.defaultConfig(params: params) + + XCTAssertEqual(config.frameName, "Illustrations") + } + + func testDefaultConfig_hasNilScales() { + let params = Params.make(lightFileId: "test") + + let config = ImagesLoaderConfig.defaultConfig(params: params) + + XCTAssertNil(config.scales) + } + + // MARK: - Helpers + + private func makeIOSEntry( + figmaFrameName: String? = nil, + assetsFolder: String = "Images", + nameStyle: String = "camelCase", + scales: [Double]? = nil + ) throws -> Params.iOS.ImagesEntry { + var json = """ + { + "assetsFolder": "\(assetsFolder)", + "nameStyle": "\(nameStyle)" + """ + + if let figmaFrameName { + json = json.replacingOccurrences(of: "{", with: "{ \"figmaFrameName\": \"\(figmaFrameName)\",") + } + if let scales { + let scalesJson = scales.map { String($0) }.joined(separator: ", ") + json += ", \"scales\": [\(scalesJson)]" + } + json += "}" + + return try JSONDecoder().decode(Params.iOS.ImagesEntry.self, from: Data(json.utf8)) + } + + private func makeAndroidEntry( + figmaFrameName: String? = nil, + output: String = "drawable", + format: String = "svg", + scales: [Double]? = nil + ) throws -> Params.Android.ImagesEntry { + var json = """ + { + "output": "\(output)", + "format": "\(format)" + """ + + if let figmaFrameName { + json = json.replacingOccurrences(of: "{", with: "{ \"figmaFrameName\": \"\(figmaFrameName)\",") + } + if let scales { + let scalesJson = scales.map { String($0) }.joined(separator: ", ") + json += ", \"scales\": [\(scalesJson)]" + } + json += "}" + + return try JSONDecoder().decode(Params.Android.ImagesEntry.self, from: Data(json.utf8)) + } + + private func makeFlutterEntry( + figmaFrameName: String? = nil, + output: String = "assets/images", + scales: [Double]? = nil + ) throws -> Params.Flutter.ImagesEntry { + var json = """ + { + "output": "\(output)" + """ + + if let figmaFrameName { + json = json.replacingOccurrences(of: "{", with: "{ \"figmaFrameName\": \"\(figmaFrameName)\",") + } + if let scales { + let scalesJson = scales.map { String($0) }.joined(separator: ", ") + json += ", \"scales\": [\(scalesJson)]" + } + json += "}" + + return try JSONDecoder().decode(Params.Flutter.ImagesEntry.self, from: Data(json.utf8)) + } +} From 6ca05c2180d94c9984fbcce76783c543b8021409 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Thu, 11 Dec 2025 20:01:35 +0500 Subject: [PATCH 4/6] feat(images): add granular cache support for raster images Add PNG/WebP granular cache tracking to ImagesLoader using the same pattern as vector images. This enables per-node change detection for raster exports, skipping unchanged assets even when Figma file version changes. Changes: - Add loadPNGImagesWithGranularCache method for raster images - Support granular cache in both single-file and multi-file modes - Update Configuration.md with images array format documentation - Add comprehensive tests for ImagesLoaderConfig --- Sources/ExFig/ExFig.docc/Configuration.md | 110 ++++++++++++++++-- Sources/ExFig/Loaders/ImagesLoader.swift | 93 ++++++++++----- .../Loaders/ImagesLoaderConfigTests.swift | 86 +++++++++++++- 3 files changed, 250 insertions(+), 39 deletions(-) diff --git a/Sources/ExFig/ExFig.docc/Configuration.md b/Sources/ExFig/ExFig.docc/Configuration.md index 32a11edc..58fbfe89 100644 --- a/Sources/ExFig/ExFig.docc/Configuration.md +++ b/Sources/ExFig/ExFig.docc/Configuration.md @@ -146,7 +146,11 @@ ios: # Path to Assets.xcassets xcassetsPath: "./Resources/Assets.xcassets" + # Colors - single object (legacy) or array format colors: + # Use color assets in xcassets + useColorAssets: true + # Folder in xcassets for colors assetsFolder: "Colors" @@ -154,13 +158,32 @@ ios: nameStyle: camelCase # Group colors in subfolders by prefix - groupByPrefix: true + groupUsingNamespace: true # UIKit extension output path colorSwift: "./Sources/Generated/UIColor+Colors.swift" # SwiftUI extension output path - swiftUIColorSwift: "./Sources/Generated/Color+Colors.swift" + swiftuiColorSwift: "./Sources/Generated/Color+Colors.swift" + + # Colors - array format for multiple color collections + # colors: + # - tokensFileId: "ABC123" + # tokensCollectionName: "Base Palette" + # lightModeName: "Light" + # darkModeName: "Dark" + # useColorAssets: true + # assetsFolder: "BaseColors" + # nameStyle: camelCase + # colorSwift: "./Sources/Generated/BaseColors.swift" + # - tokensFileId: "DEF456" + # tokensCollectionName: "Theme Colors" + # lightModeName: "Light" + # darkModeName: "Dark" + # useColorAssets: true + # assetsFolder: "ThemeColors" + # nameStyle: camelCase + # colorSwift: "./Sources/Generated/ThemeColors.swift" # Icons - single object (legacy) or array format icons: @@ -198,6 +221,7 @@ ios: # nameStyle: camelCase # imageSwift: "./Sources/Generated/NavIcons.swift" + # Images - single object (legacy) or array format images: # Folder in xcassets for images assetsFolder: "Images" @@ -214,6 +238,19 @@ ios: # SwiftUI extension output path swiftUIImageSwift: "./Sources/Generated/Image+Images.swift" + # Images - array format for multiple image sets + # images: + # - figmaFrameName: "Onboarding" + # assetsFolder: "Onboarding" + # nameStyle: camelCase + # scales: [1, 2, 3] + # imageSwift: "./Sources/Generated/OnboardingImages.swift" + # - figmaFrameName: "Promo" + # assetsFolder: "Promo" + # nameStyle: camelCase + # scales: [2, 3] + # imageSwift: "./Sources/Generated/PromoImages.swift" + typography: # Generate labels with predefined styles generateLabels: true @@ -244,16 +281,27 @@ android: # Path to main source directory (for Compose) mainSrc: "./app/src/main/java" + # Colors - single object (legacy) or array format colors: # Output filename - output: "colors.xml" - - # Naming style - nameStyle: snake_case + xmlOutputFileName: "colors.xml" # Jetpack Compose package name composePackageName: "com.example.app.ui.theme" + # Colors - array format for multiple color collections + # colors: + # - tokensFileId: "ABC123" + # tokensCollectionName: "Base Palette" + # lightModeName: "Light" + # xmlOutputFileName: "base_colors.xml" + # - tokensFileId: "DEF456" + # tokensCollectionName: "Theme Colors" + # lightModeName: "Light" + # darkModeName: "Dark" + # xmlOutputFileName: "theme_colors.xml" + # composePackageName: "com.example.theme" + # Icons - single object (legacy) or array format icons: # Output directory (relative to mainRes) @@ -276,15 +324,15 @@ android: # - figmaFrameName: "Navigation" # output: "drawable-nav" # composePackageName: "com.example.app.ui.nav" + # composeFormat: imageVector + # composeExtensionTarget: "com.example.NavIcons" + # Images - single object (legacy) or array format images: # Output directory (relative to mainRes) output: "exfig-images" - # Naming style - nameStyle: snake_case - - # Image format: png or webp + # Image format: svg, png, or webp format: webp # WebP encoding options @@ -295,6 +343,19 @@ android: # Density scales (default: [1, 1.5, 2, 3, 4]) scales: [1, 1.5, 2, 3, 4] + # Images - array format for multiple image sets + # images: + # - figmaFrameName: "Illustrations" + # output: "drawable-illustrations" + # format: svg + # - figmaFrameName: "Photos" + # output: "drawable-photos" + # format: webp + # scales: [1, 1.5, 2, 3, 4] + # webpOptions: + # encoding: lossy + # quality: 80 + typography: # Output filename output: "typography.xml" @@ -316,6 +377,7 @@ flutter: # Path to custom Stencil templates templatesPath: "./templates" + # Colors - single object (legacy) or array format colors: # Output filename output: "colors.dart" @@ -323,6 +385,20 @@ flutter: # Class name for colors className: "AppColors" + # Colors - array format for multiple color collections + # colors: + # - tokensFileId: "ABC123" + # tokensCollectionName: "Base Palette" + # lightModeName: "Light" + # output: "base_colors.dart" + # className: "BaseColors" + # - tokensFileId: "DEF456" + # tokensCollectionName: "Theme Colors" + # lightModeName: "Light" + # darkModeName: "Dark" + # output: "theme_colors.dart" + # className: "ThemeColors" + # Icons - single object (legacy) or array format icons: # Output directory for SVG files @@ -345,6 +421,7 @@ flutter: # dartFile: "nav_icons.dart" # className: "NavIcons" + # Images - single object (legacy) or array format images: # Output directory for images output: "assets/images" @@ -365,6 +442,19 @@ flutter: webpOptions: encoding: lossy quality: 90 + + # Images - array format for multiple image sets + # images: + # - figmaFrameName: "Illustrations" + # output: "assets/images/illustrations" + # dartFile: "illustrations.dart" + # className: "Illustrations" + # - figmaFrameName: "Promo" + # output: "assets/images/promo" + # dartFile: "promo_images.dart" + # className: "PromoImages" + # format: webp + # scales: [1, 2, 3] ``` ## Example Configurations diff --git a/Sources/ExFig/Loaders/ImagesLoader.swift b/Sources/ExFig/Loaders/ImagesLoader.swift index 800661be..a9c41943 100644 --- a/Sources/ExFig/Loaders/ImagesLoader.swift +++ b/Sources/ExFig/Loaders/ImagesLoader.swift @@ -4,6 +4,13 @@ import FigmaAPI import Foundation import Logging +/// Image format for loader configuration. +enum ImagesLoaderFormat: Sendable { + case svg + case png + case webp +} + /// Configuration for loading images from a specific Figma frame. struct ImagesLoaderConfig: Sendable { /// Figma frame name to load images from. @@ -12,11 +19,15 @@ struct ImagesLoaderConfig: Sendable { /// Custom scales for raster images. let scales: [Double]? + /// Image format (for Android/Flutter). iOS always uses PNG. + let format: ImagesLoaderFormat? + /// Creates config for a specific iOS images entry. static func forIOS(entry: Params.iOS.ImagesEntry, params: Params) -> ImagesLoaderConfig { ImagesLoaderConfig( frameName: entry.figmaFrameName ?? params.common?.images?.figmaFrameName ?? "Illustrations", - scales: entry.scales + scales: entry.scales, + format: nil // iOS always uses PNG ) } @@ -24,7 +35,8 @@ struct ImagesLoaderConfig: Sendable { static func forAndroid(entry: Params.Android.ImagesEntry, params: Params) -> ImagesLoaderConfig { ImagesLoaderConfig( frameName: entry.figmaFrameName ?? params.common?.images?.figmaFrameName ?? "Illustrations", - scales: entry.scales + scales: entry.scales, + format: convertAndroidFormat(entry.format) ) } @@ -32,7 +44,8 @@ struct ImagesLoaderConfig: Sendable { static func forFlutter(entry: Params.Flutter.ImagesEntry, params: Params) -> ImagesLoaderConfig { ImagesLoaderConfig( frameName: entry.figmaFrameName ?? params.common?.images?.figmaFrameName ?? "Illustrations", - scales: entry.scales + scales: entry.scales, + format: entry.format.flatMap { convertFlutterFormat($0) } ) } @@ -40,9 +53,26 @@ struct ImagesLoaderConfig: Sendable { static func defaultConfig(params: Params) -> ImagesLoaderConfig { ImagesLoaderConfig( frameName: params.common?.images?.figmaFrameName ?? "Illustrations", - scales: nil + scales: nil, + format: nil ) } + + private static func convertAndroidFormat(_ format: Params.Android.Images.Format) -> ImagesLoaderFormat { + switch format { + case .svg: .svg + case .png: .png + case .webp: .webp + } + } + + private static func convertFlutterFormat(_ format: Params.Flutter.ImageFormat) -> ImagesLoaderFormat { + switch format { + case .svg: .svg + case .png: .png + case .webp: .webp + } + } } /// Output type for images loading operations. @@ -84,6 +114,27 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di config.scales } + /// Image format from config, determines raster vs vector loading. + private var configFormat: ImagesLoaderFormat? { + config.format + } + + /// Whether the configured format is raster (PNG/WebP) or vector (SVG). + private var isRasterFormat: Bool { + switch (platform, configFormat) { + case (.ios, _): + // iOS always uses raster (PNG) + true + case (.android, .png), (.android, .webp): + true + case (.android, .svg): + false + case (.android, nil): + // Default to raster for backward compatibility + true + } + } + /// Loads images from Figma, supporting both single-file and separate light/dark file modes. func load( filter: String? = nil, @@ -128,8 +179,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di ) async throws -> ImagesLoaderOutput { let darkSuffix = params.common?.images?.darkModeSuffix ?? "_dark" - switch (platform, params.android?.images?.entries.first?.format) { - case (.android, .png), (.android, .webp), (.ios, _): + if isRasterFormat { let scales = getScales(customScales: configScales) let images = try await loadPNGImages( @@ -141,8 +191,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di ) let (lightImages, darkImages) = splitByDarkMode(images, darkSuffix: darkSuffix) return (lightImages, darkImages) - - default: + } else { let pack = try await loadVectorImages( fileId: params.figma.lightFileId, frameName: frameName, @@ -167,14 +216,13 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di filesToLoad.append(("dark", darkFileId)) } - switch (platform, params.android?.images?.entries.first?.format) { - case (.android, .png), (.android, .webp), (.ios, _): + if isRasterFormat { return try await loadRasterImagesFromMultipleFiles( filesToLoad: filesToLoad, filter: filter, onBatchProgress: onBatchProgress ) - default: + } else { return try await loadVectorImagesFromMultipleFiles( filesToLoad: filesToLoad, filter: filter, @@ -266,8 +314,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di let fileId = params.figma.lightFileId let darkSuffix = params.common?.images?.darkModeSuffix ?? "_dark" - switch (platform, params.android?.images?.entries.first?.format) { - case (.android, .png), (.android, .webp), (.ios, _): + if isRasterFormat { // Raster images (PNG/WebP) with granular cache let scales = getScales(customScales: configScales) @@ -300,8 +347,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di allSkipped: false, allNames: lightOnlyNames ) - - default: + } else { // Vector images (SVG) with granular cache let result = try await loadVectorImagesWithGranularCache( fileId: fileId, @@ -358,23 +404,14 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di } // Determine format and scales once (same for all files) - let isRasterFormat: Bool - let scales: [Double] - - switch (platform, params.android?.images?.entries.first?.format) { - case (.android, .png), (.android, .webp), (.ios, _): - isRasterFormat = true - scales = getScales(customScales: configScales) - default: - isRasterFormat = false - scales = [] - } + let useRasterFormat = isRasterFormat + let scales = useRasterFormat ? getScales(customScales: configScales) : [] // Load all files in parallel let results = try await withThrowingTaskGroup(of: FileGranularResult.self) { [self] group in for (key, fileId) in filesToLoad { - group.addTask { [key, fileId, filter, onBatchProgress, isRasterFormat, scales] in - if isRasterFormat { + group.addTask { [key, fileId, filter, onBatchProgress, useRasterFormat, scales] in + if useRasterFormat { // Raster images (PNG/WebP) let result = try await self.loadPNGImagesWithGranularCache( fileId: fileId, diff --git a/Tests/ExFigTests/Loaders/ImagesLoaderConfigTests.swift b/Tests/ExFigTests/Loaders/ImagesLoaderConfigTests.swift index f6ad350b..d952a854 100644 --- a/Tests/ExFigTests/Loaders/ImagesLoaderConfigTests.swift +++ b/Tests/ExFigTests/Loaders/ImagesLoaderConfigTests.swift @@ -49,6 +49,15 @@ final class ImagesLoaderConfigTests: XCTestCase { XCTAssertNil(config.scales) } + func testForIOS_formatIsAlwaysNil() throws { + let entry = try makeIOSEntry() + let params = Params.make(lightFileId: "test") + + let config = ImagesLoaderConfig.forIOS(entry: entry, params: params) + + XCTAssertNil(config.format, "iOS always uses PNG, so format should be nil") + } + // MARK: - Android Frame Name Resolution func testForAndroid_entryFrameNameOverridesCommon() throws { @@ -87,6 +96,33 @@ final class ImagesLoaderConfigTests: XCTestCase { XCTAssertEqual(config.scales, [1.0, 1.5, 2.0, 3.0, 4.0]) } + func testForAndroid_formatSVG() throws { + let entry = try makeAndroidEntry(format: "svg") + let params = Params.make(lightFileId: "test") + + let config = ImagesLoaderConfig.forAndroid(entry: entry, params: params) + + XCTAssertEqual(config.format, .svg) + } + + func testForAndroid_formatPNG() throws { + let entry = try makeAndroidEntry(format: "png") + let params = Params.make(lightFileId: "test") + + let config = ImagesLoaderConfig.forAndroid(entry: entry, params: params) + + XCTAssertEqual(config.format, .png) + } + + func testForAndroid_formatWebP() throws { + let entry = try makeAndroidEntry(format: "webp") + let params = Params.make(lightFileId: "test") + + let config = ImagesLoaderConfig.forAndroid(entry: entry, params: params) + + XCTAssertEqual(config.format, .webp) + } + // MARK: - Flutter Frame Name Resolution func testForFlutter_entryFrameNameOverridesCommon() throws { @@ -125,6 +161,42 @@ final class ImagesLoaderConfigTests: XCTestCase { XCTAssertEqual(config.scales, [1.0, 2.0, 3.0]) } + func testForFlutter_formatSVG() throws { + let entry = try makeFlutterEntry(format: "svg") + let params = Params.make(lightFileId: "test") + + let config = ImagesLoaderConfig.forFlutter(entry: entry, params: params) + + XCTAssertEqual(config.format, .svg) + } + + func testForFlutter_formatPNG() throws { + let entry = try makeFlutterEntry(format: "png") + let params = Params.make(lightFileId: "test") + + let config = ImagesLoaderConfig.forFlutter(entry: entry, params: params) + + XCTAssertEqual(config.format, .png) + } + + func testForFlutter_formatWebP() throws { + let entry = try makeFlutterEntry(format: "webp") + let params = Params.make(lightFileId: "test") + + let config = ImagesLoaderConfig.forFlutter(entry: entry, params: params) + + XCTAssertEqual(config.format, .webp) + } + + func testForFlutter_formatNilWhenNotProvided() throws { + let entry = try makeFlutterEntry() + let params = Params.make(lightFileId: "test") + + let config = ImagesLoaderConfig.forFlutter(entry: entry, params: params) + + XCTAssertNil(config.format) + } + // MARK: - Default Config func testDefaultConfig_usesCommonFrameName() { @@ -151,6 +223,14 @@ final class ImagesLoaderConfigTests: XCTestCase { XCTAssertNil(config.scales) } + func testDefaultConfig_hasNilFormat() { + let params = Params.make(lightFileId: "test") + + let config = ImagesLoaderConfig.defaultConfig(params: params) + + XCTAssertNil(config.format) + } + // MARK: - Helpers private func makeIOSEntry( @@ -204,7 +284,8 @@ final class ImagesLoaderConfigTests: XCTestCase { private func makeFlutterEntry( figmaFrameName: String? = nil, output: String = "assets/images", - scales: [Double]? = nil + scales: [Double]? = nil, + format: String? = nil ) throws -> Params.Flutter.ImagesEntry { var json = """ { @@ -218,6 +299,9 @@ final class ImagesLoaderConfigTests: XCTestCase { let scalesJson = scales.map { String($0) }.joined(separator: ", ") json += ", \"scales\": [\(scalesJson)]" } + if let format { + json += ", \"format\": \"\(format)\"" + } json += "}" return try JSONDecoder().decode(Params.Flutter.ImagesEntry.self, from: Data(json.utf8)) From d18e5d1149dfe2511770e457a8fb6d28145f6bce Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Thu, 11 Dec 2025 20:12:15 +0500 Subject: [PATCH 5/6] feat(flutter): use dedicated Platform.flutter for Flutter exports Previously Flutter used .android platform internally, which could cause incorrect behavior in platform-specific code paths. This change adds a proper .flutter case to the Platform enum and updates all Flutter export commands and loaders to use it. Also adds a Flutter config template for the init command. --- .swiftlint.yml | 1 + README.md | 2 +- Sources/ExFig/Loaders/IconsLoader.swift | 2 +- Sources/ExFig/Loaders/ImageLoaderBase.swift | 2 +- Sources/ExFig/Loaders/ImagesLoader.swift | 6 +- Sources/ExFig/Resources/flutterConfig.swift | 106 ++++++++++++++++++ Sources/ExFig/Subcommands/ExportColors.swift | 4 +- Sources/ExFig/Subcommands/ExportIcons.swift | 4 +- Sources/ExFig/Subcommands/ExportImages.swift | 4 +- .../Subcommands/GenerateConfigFile.swift | 2 + Sources/ExFigCore/Platform.swift | 4 + 11 files changed, 125 insertions(+), 12 deletions(-) create mode 100644 Sources/ExFig/Resources/flutterConfig.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 7d36730a..cdccb8c2 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -7,6 +7,7 @@ excluded: - Packages - Sources/ExFig/Resources/iOSConfig.swift - Sources/ExFig/Resources/androidConfig.swift + - Sources/ExFig/Resources/flutterConfig.swift - Tests/XcodeExportTests/XcodeIconsExporterTests.swift disabled_rules: diff --git a/README.md b/README.md index c387d287..a403a042 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![CI](https://github.com/alexey1312/ExFig/actions/workflows/ci.yml/badge.svg)](https://github.com/alexey1312/ExFig/actions/workflows/ci.yml) [![Release](https://github.com/alexey1312/ExFig/actions/workflows/release.yml/badge.svg)](https://github.com/alexey1312/ExFig/actions/workflows/release.yml) [![Docs](https://github.com/alexey1312/ExFig/actions/workflows/deploy-docc.yml/badge.svg)](https://alexey1312.github.io/ExFig/documentation/exfig) -![Coverage](https://img.shields.io/badge/coverage-51.06%25-yellow) +![Coverage](https://img.shields.io/badge/coverage-51.30%25-yellow) [![License](https://img.shields.io/github/license/alexey1312/ExFig.svg)](LICENSE) Command-line utility to export colors, typography, icons, and images from Figma to Xcode, Android Studio, and Flutter diff --git a/Sources/ExFig/Loaders/IconsLoader.swift b/Sources/ExFig/Loaders/IconsLoader.swift index 1eba7fe9..59fd50f5 100644 --- a/Sources/ExFig/Loaders/IconsLoader.swift +++ b/Sources/ExFig/Loaders/IconsLoader.swift @@ -207,7 +207,7 @@ final class IconsLoader: ImageLoaderBase, @unchecked Sendable { private func makeFormatParams() -> FormatParams { switch (platform, config.format) { - case (.android, _), (.ios, .svg): + case (.android, _), (.flutter, _), (.ios, .svg): SVGParams() case (.ios, _): PDFParams() diff --git a/Sources/ExFig/Loaders/ImageLoaderBase.swift b/Sources/ExFig/Loaders/ImageLoaderBase.swift index fba40eae..605dc03e 100644 --- a/Sources/ExFig/Loaders/ImageLoaderBase.swift +++ b/Sources/ExFig/Loaders/ImageLoaderBase.swift @@ -840,7 +840,7 @@ extension String { func parseNameAndIdiom(platform: Platform) -> (name: String, idiom: String) { switch platform { - case .android: + case .android, .flutter: return (self, "") case .ios: guard let regex = Self.idiomRegex, diff --git a/Sources/ExFig/Loaders/ImagesLoader.swift b/Sources/ExFig/Loaders/ImagesLoader.swift index a9c41943..443ab73e 100644 --- a/Sources/ExFig/Loaders/ImagesLoader.swift +++ b/Sources/ExFig/Loaders/ImagesLoader.swift @@ -125,11 +125,11 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di case (.ios, _): // iOS always uses raster (PNG) true - case (.android, .png), (.android, .webp): + case (.android, .png), (.android, .webp), (.flutter, .png), (.flutter, .webp): true - case (.android, .svg): + case (.android, .svg), (.flutter, .svg): false - case (.android, nil): + case (.android, nil), (.flutter, nil): // Default to raster for backward compatibility true } diff --git a/Sources/ExFig/Resources/flutterConfig.swift b/Sources/ExFig/Resources/flutterConfig.swift new file mode 100644 index 00000000..f14566b8 --- /dev/null +++ b/Sources/ExFig/Resources/flutterConfig.swift @@ -0,0 +1,106 @@ +let flutterConfigFileContents = #""" +--- +figma: + # Identifier of the file containing light color palette, icons and light images. To obtain a file id, open the file in the browser. The file id will be present in the URL after the word file and before the file name. + lightFileId: shPilWnVdJfo10YF12345 + # [optional] Identifier of the file containing dark color palette and dark images. + darkFileId: KfF6DnJTWHGZzC912345 + # [optional] Figma API request timeout. The default value of this property is 30 (seconds). If you have a lot of resources to export set this value to 60 or more to give Figma API more time to prepare resources for exporting. + # timeout: 30 + +# [optional] Common export parameters +common: + # [optional] + colors: + # [optional] RegExp pattern for color name validation before exporting. If a name contains "/" symbol it will be replaced by "_" before executing the RegExp + nameValidateRegexp: '^([a-zA-Z_]+)$' # RegExp pattern for: background, background_primary, widget_primary_background + # [optional] RegExp pattern for replacing. Supports only $n + nameReplaceRegexp: 'color_$1' + # [optional] Extract light and dark mode colors from the lightFileId specified in the figma params. Defaults to false + useSingleFile: false + # [optional] If useSingleFile is true, customize the suffix to denote a dark mode color. Defaults to '_dark' + darkModeSuffix: '_dark' + # [optional] Use variablesColors instead of colors to export colors from Figma Variables. Cannot be used together with colors. + # variablesColors: + # # [required] Identifier of the file containing variables + # tokensFileId: shPilWnVdJfo10YF12345 + # # [required] Variables collection name + # tokensCollectionName: Base collection + # # [required] Name of the column containing light color variables in the tokens table + # lightModeName: Light + # # [optional] Name of the column containing dark color variables in the tokens table + # darkModeName: Dark + # # [optional] Name of the column containing color variables in the primitive table. If a value is not specified, the default values will be taken + # primitivesModeName: Collection_1 + # # [optional] RegExp pattern for color name validation before exporting. + # nameValidateRegexp: '^([a-zA-Z_]+)$' + # # [optional] RegExp pattern for replacing. Supports only $n + # nameReplaceRegexp: 'color_$1' + # [optional] + icons: + # [optional] Name of the Figma's frame where icons components are located + figmaFrameName: Icons + # [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 + # [optional] RegExp pattern for replacing. Supports only $n + nameReplaceRegexp: 'icon_$2_$1' + # [optional] Extract light and dark mode icons from the lightFileId specified in the figma params. Defaults to false + useSingleFile: false + # [optional] If useSingleFile is true, customize the suffix to denote a dark mode icons. Defaults to '_dark' + darkModeSuffix: '_dark' + # [optional] + images: + # [optional] Name of the Figma's frame where image components are located + figmaFrameName: Illustrations + # [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 + # [optional] RegExp pattern for replacing. Supports only $n + nameReplaceRegexp: 'image_$2' + # [optional] Extract light and dark mode images from the lightFileId specified in the figma params. Defaults to false + useSingleFile: false + # [optional] If useSingleFile is true, customize the suffix to denote a dark mode images. Defaults to '_dark' + darkModeSuffix: '_dark' + +# Flutter export parameters +flutter: + # Output directory for generated Dart files + output: "./lib/generated" + # [optional] Path to the Stencil templates used to generate code + # templatesPath: "./Resources/Templates" + + # Parameters for exporting colors + colors: + # Output filename for colors Dart file + output: "colors.dart" + # Class name for colors + className: "AppColors" + + # Parameters for exporting icons + icons: + # Output directory for SVG icon files + output: "assets/icons" + # Dart file output + dartFile: "icons.dart" + # Class name for icons + className: "AppIcons" + + # Parameters for exporting images + images: + # Output directory for image files + output: "assets/images" + # Dart file output + dartFile: "images.dart" + # Class name for images + className: "AppImages" + # Image file format: svg, png, or webp + format: png + # [optional] An array of asset scales that should be downloaded. The valid values are 1, 2, 3. The default value is [1, 2, 3]. + scales: [1, 2, 3] + # [optional] Format options for webp format only + # webpOptions: + # # Encoding type: lossy or lossless + # encoding: lossy + # # Encoding quality in percents. Only for lossy encoding. + # quality: 90 + +"""# diff --git a/Sources/ExFig/Subcommands/ExportColors.swift b/Sources/ExFig/Subcommands/ExportColors.swift index bb05c42e..ed5a1c53 100644 --- a/Sources/ExFig/Subcommands/ExportColors.swift +++ b/Sources/ExFig/Subcommands/ExportColors.swift @@ -427,7 +427,7 @@ extension ExFigCommand { let colorPairs = try await ui.withSpinner("Processing colors for Flutter...") { let processor = ColorsProcessor( - platform: .android, + platform: .flutter, nameValidateRegexp: entry.nameValidateRegexp, nameReplaceRegexp: entry.nameReplaceRegexp, nameStyle: .camelCase @@ -471,7 +471,7 @@ extension ExFigCommand { let colorPairs = try await config.ui.withSpinner("Processing colors for Flutter...") { let processor = ColorsProcessor( - platform: .android, + platform: .flutter, nameValidateRegexp: finalNameValidateRegexp, nameReplaceRegexp: finalNameReplaceRegexp, nameStyle: .camelCase diff --git a/Sources/ExFig/Subcommands/ExportIcons.swift b/Sources/ExFig/Subcommands/ExportIcons.swift index a72a2485..2412318e 100644 --- a/Sources/ExFig/Subcommands/ExportIcons.swift +++ b/Sources/ExFig/Subcommands/ExportIcons.swift @@ -1080,7 +1080,7 @@ extension ExFigCommand { let loader = IconsLoader( client: client, params: params, - platform: .android, + platform: .flutter, logger: logger, config: loaderConfig ) @@ -1113,7 +1113,7 @@ extension ExFigCommand { // 2. Process images let processor = ImagesProcessor( - platform: .android, // Flutter uses similar naming to Android + platform: .flutter, nameValidateRegexp: params.common?.icons?.nameValidateRegexp, nameReplaceRegexp: params.common?.icons?.nameReplaceRegexp, nameStyle: .snakeCase diff --git a/Sources/ExFig/Subcommands/ExportImages.swift b/Sources/ExFig/Subcommands/ExportImages.swift index db82b53f..e916813d 100644 --- a/Sources/ExFig/Subcommands/ExportImages.swift +++ b/Sources/ExFig/Subcommands/ExportImages.swift @@ -986,7 +986,7 @@ extension ExFigCommand { let loader = ImagesLoader( client: client, params: params, - platform: .android, + platform: .flutter, logger: logger, config: loaderConfig ) @@ -1019,7 +1019,7 @@ extension ExFigCommand { let imagesTuple = (light: loaderResult.light, dark: loaderResult.dark) let processor = ImagesProcessor( - platform: .android, + platform: .flutter, nameValidateRegexp: params.common?.images?.nameValidateRegexp, nameReplaceRegexp: params.common?.images?.nameReplaceRegexp, nameStyle: .snakeCase diff --git a/Sources/ExFig/Subcommands/GenerateConfigFile.swift b/Sources/ExFig/Subcommands/GenerateConfigFile.swift index c33f5e3a..affd1cc2 100644 --- a/Sources/ExFig/Subcommands/GenerateConfigFile.swift +++ b/Sources/ExFig/Subcommands/GenerateConfigFile.swift @@ -33,6 +33,8 @@ extension ExFigCommand { androidConfigFileContents case .ios: iosConfigFileContents + case .flutter: + flutterConfigFileContents } let destination = FileManager.default.currentDirectoryPath + "/" + ExFigOptions.defaultConfigFilename diff --git a/Sources/ExFigCore/Platform.swift b/Sources/ExFigCore/Platform.swift index 0c02daf9..3d4e1de7 100644 --- a/Sources/ExFigCore/Platform.swift +++ b/Sources/ExFigCore/Platform.swift @@ -12,4 +12,8 @@ public enum Platform: String, Sendable { /// Android platform (Android Studio projects). /// Generates XML resources, vector drawables, and Kotlin code for Jetpack Compose. case android + + /// Flutter platform (Flutter projects). + /// Generates Dart code and SVG/PNG/WebP assets. + case flutter } From c5fafa691a875bbdf377e77abfa1b9976aa8f640 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Thu, 11 Dec 2025 20:50:36 +0500 Subject: [PATCH 6/6] chore(cli): add Claude Code slash commands Add project-specific slash commands for development workflow: - /local-review: comprehensive code review vs origin/develop - /plan: task breakdown with dependencies for parallel execution - /pr-summary: generate GitHub PR summary and copy to clipboard --- .claude/commands/local-review.md | 95 ++++++++++++++++++++ .claude/commands/plan.md | 144 +++++++++++++++++++++++++++++++ .claude/commands/pr-summary.md | 87 +++++++++++++++++++ 3 files changed, 326 insertions(+) create mode 100644 .claude/commands/local-review.md create mode 100644 .claude/commands/plan.md create mode 100644 .claude/commands/pr-summary.md diff --git a/.claude/commands/local-review.md b/.claude/commands/local-review.md new file mode 100644 index 00000000..59f45a0f --- /dev/null +++ b/.claude/commands/local-review.md @@ -0,0 +1,95 @@ +--- +description: Run a comprehensive code review of local changes +allowed-tools: Bash(git *), Grep, Glob, Read, TodoWrite, Task(Explore), mcp__context7__resolve-library-id, mcp__context7__get-library-docs, mcp__sosumi__searchAppleDocumentation, mcp__sosumi__fetchAppleDocumentation +argument-hint: '[optional: files/directories to review]' +version: 2.0 +--- + +# Local Development Review + +Comprehensive technical code review analyzing ALL commits in feature branch relative to `origin/develop`. + +## Review Scope Decision + +```toon +scope[3]{condition,action}: + "specific files/directories provided","review only those paths" + "no arguments","review all branch changes vs origin/develop" + "module path provided","review module + check cross-module impacts" +``` + +## Step 1: Fetch Latest & Identify Changes + +```bash +git fetch origin + +# Show all commits in feature branch +git log origin/develop..HEAD --oneline + +# Show all changed files +git diff origin/develop...HEAD --name-only +``` + +If specific files/directories provided via arguments: + +```bash +git diff origin/develop...HEAD --name-only -- [file-paths] +``` + +## Step 2: Read Review Guidelines + +Read recipe files before flagging issues: + +```toon +recipes[6]{category,path}: + "SwiftUI crashes","docs/agents/development/recipes/code-review/swiftui-crash-recipes.md" + "Memory management","docs/agents/development/recipes/code-review/memory-management-recipes.md" + "Security","docs/agents/development/recipes/code-review/security-recipes.md" + "Swift 6 concurrency","docs/agents/development/recipes/code-review/swift6-concurrency-recipes.md" + "Architecture violations","docs/agents/development/recipes/code-review/architecture-violation-recipes.md" + "Output format","docs/agents/development/recipes/code-review/code-review-output-recipes.md" +``` + +## Step 3: Perform Review + +Follow @docs/agents/development/code-review.md methodology: + +```toon +methodology[5]{step,action}: + 1,"Extract context: iOS version, UI framework, architectural layer" + 2,"Pattern match: verify ALL conditions from detection rules" + 3,"Assess confidence: >=80% flag, <80% skip" + 4,"Verify against recipes: read linked recipe files before flagging" + 5,"Prioritize: CRITICAL -> HIGH -> MEDIUM" +``` + +## Step 4: Generate Output + +Output format per @docs/agents/development/recipes/code-review/code-review-output-recipes.md: + +```toon +output[3]{section,format,required}: + "Code Review Complete","[one line summary]",true + "Review Findings","[grouped by priority]",true + "No issues","No critical, high, or medium priority issues detected",conditional +``` + +Priority format: `emoji + description + file:line + fix action` + +```toon +priorities[3]{level,emoji,examples}: + CRITICAL,"red circle","crashes, security, memory leaks" + HIGH,"yellow circle","architecture violations, missing error handling" + MEDIUM,"green circle","performance, code quality" +``` + +## Quality Gate + +```toon +checklist[5]{check,required}: + "All changed files reviewed",true + "Recipe files consulted for detected issues",true + "Confidence >= 80% for all flagged issues",true + "Output follows code-review-output-recipes.md format",true + "No false positives from partial pattern matches",true +``` diff --git a/.claude/commands/plan.md b/.claude/commands/plan.md new file mode 100644 index 00000000..562a64be --- /dev/null +++ b/.claude/commands/plan.md @@ -0,0 +1,144 @@ +--- +description: Break down a task into subtasks with dependencies for parallel execution +allowed-tools: Read, Glob, Grep, TodoWrite, Task(Explore), Task(Plan) +argument-hint: +version: 1.0 +--- + +# Task Planning with Dependencies + +**Purpose**: Analyze a task and break it down into subtasks with explicit dependencies, enabling parallel execution +where possible. + +## Output Format (TOON-like) + +Generate plan in this structured format: + +```yaml +command: + name: plan- + purpose: + version: 1.0 + +tasks[N]{id,title,description,depends_on,parallel_group,type}: + ,,<description>,[deps],[group],[type] + ... + +execution_order: + phase_1: + parallel: [task_ids that can run in parallel] + phase_2: + sequential: [task_id] # build/test tasks + phase_3: + parallel: [task_ids for fixes if needed] + ... + +dependency_graph: + <task_id>: [list of task_ids this depends on] + ... +``` + +## Task Types + +- `analysis` - Code analysis, research, reading +- `implementation` - Writing new code +- `modification` - Changing existing code +- `build` - Building project/module +- `test` - Running tests +- `fix` - Fixing issues found by build/tests +- `review` - Code review, validation + +## Dependency Rules + +### Critical Rules for Parallel Execution + +1. **Independent tasks** (no shared files/modules) can run in parallel +2. **Build tasks** MUST wait for ALL parallel implementation tasks to complete +3. **Test tasks** MUST wait for build to succeed +4. **Fix tasks** MUST wait for test results +5. **Tasks modifying same file** MUST be sequential + +### Dependency Detection + +- Same file modification → sequential +- Same module modification → sequential (unless different files) +- Different modules → parallel possible +- Build depends on → all implementation tasks +- Test depends on → successful build +- Fix depends on → test results + +## Instructions + +1. **Analyze the task**: + + - Read relevant code files mentioned in task + - Identify affected modules/files + - Detect potential conflicts + +2. **Break down into subtasks**: + + - Create atomic, independent subtasks where possible + - Identify dependencies between subtasks + - Group parallelizable tasks + +3. **Generate execution plan**: + + - Phase 1: Parallel analysis/implementation tasks + - Phase 2: Build (waits for Phase 1) + - Phase 3: Tests (waits for Phase 2) + - Phase 4: Fixes if needed (based on Phase 3 results) + - Phase 5: Final build/test validation + +4. **Output the plan** in TOON format above + +## Example Output + +```yaml +command: + name: plan-add-analytics-tracking + purpose: Add analytics tracking to user profile module + version: 1.0 + +tasks[6]{id,title,description,depends_on,parallel_group,type}: + T1,Create analytics service,Implement AnalyticsService protocol,[],G1,implementation + T2,Add tracking to ProfileView,Integrate analytics calls,[],G1,implementation + T3,Add tracking to SettingsView,Integrate analytics calls,[],G1,implementation + T4,Build module,Build UserProfile module,[T1,T2,T3],G2,build + T5,Run tests,Execute unit tests,[T4],G3,test + T6,Fix issues,Address any test failures,[T5],G4,fix + +execution_order: + phase_1: + parallel: [T1, T2, T3] # Can run simultaneously - different files + phase_2: + sequential: [T4] # Build waits for all implementations + phase_3: + sequential: [T5] # Tests wait for build + phase_4: + conditional: [T6] # Only if tests fail + +dependency_graph: + T1: [] + T2: [] + T3: [] + T4: [T1, T2, T3] + T5: [T4] + T6: [T5] + +notes: + - T1, T2, T3 modify different files, safe to parallelize + - T4 must wait for ALL implementations before building + - T6 is conditional - only execute if T5 finds failures +``` + +## Validation Checklist + +Before finalizing plan, verify: + +- [ ] No circular dependencies +- [ ] Build tasks depend on ALL related implementations +- [ ] Test tasks depend on successful build +- [ ] Fix tasks depend on test results +- [ ] Parallel tasks don't modify same files +- [ ] All task IDs are unique +- [ ] dependency_graph matches depends_on fields diff --git a/.claude/commands/pr-summary.md b/.claude/commands/pr-summary.md new file mode 100644 index 00000000..f1fcded9 --- /dev/null +++ b/.claude/commands/pr-summary.md @@ -0,0 +1,87 @@ +--- +description: Generate PR summary from branch changes for GitHub +allowed-tools: Bash(git *), Bash(pbcopy), Read, Glob, Grep, Write +version: 2.0 +--- + +# PR Summary Generator + +Generate a concise PR summary in English based on branch changes, copy to clipboard. + +## Step 1: Gather Context + +```bash +git branch --show-current +git fetch origin +git log origin/develop..HEAD --oneline +git diff origin/develop...HEAD --stat +``` + +## Step 2: Extract Task ID + +Extract from branch name using pattern `[A-Z]+-[0-9]+`: + +```toon +patterns[4]{branch_example,extracted_id}: + "feature/PL-19452-description","PL-19452" + "fix/COUR-8147-bug","COUR-8147" + "hotfix/PAX-123-critical","PAX-123" + "feature/some-description","[manual input required]" +``` + +## Step 3: Generate Summary + +Write to `/tmp/pr-summary.md`: + +```toon +template{section,format}: + Title: "imperative mood, max 50 chars (Add/Fix/Update/Remove)" + Task ID: "[{ID}](https://indriver.atlassian.net/browse/{ID})" + Description: "1-2 sentences: what changed and why" + Changes: "- bullet list of changes" +``` + +Template: + +```markdown +## Title +<imperative mood, max 50 chars> + +## Task ID +[{TASK_ID}](https://indriver.atlassian.net/browse/{TASK_ID}) + +## Description +<1-2 sentences> + +- <change 1> +- <change 2> +``` + +## Step 4: Copy to Clipboard + +```bash +cat /tmp/pr-summary.md | pbcopy +``` + +Inform user: "Copied to clipboard!" + +## Output Format + +```toon +sections[4]{name,format,required}: + Title,"imperative mood max 50 chars",true + "Task ID","markdown link to Jira",true + Description,"1-2 sentences explaining what/why",true + Changes,"bullet list of specific changes",true +``` + +## Quality Gate + +```toon +checklist[5]{check,required}: + "Title in imperative mood (not past tense)",true + "Task ID extracted and formatted as link",true + "English only, formal tone",true + "Summary written to /tmp/pr-summary.md",true + "Result copied to clipboard via pbcopy",true +```