From f33c84b58e1aac8f7de648e9f9d52fabdd7cc0ee Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sat, 21 Mar 2026 16:32:28 +0500 Subject: [PATCH 1/3] feat(core): abstract design data sources behind per-asset-type protocols Introduce ColorsSource, ComponentsSource, and TypographySource protocols in ExFigCore to decouple the export pipeline from Figma-specific loaders. This lays the foundation for supporting alternative design tools (Penpot, Sketch, Tokens Studio) without modifying the export infrastructure. - Add DesignSourceKind enum and ColorsSourceConfig protocol pattern - Refactor ColorsSourceInput to use sourceKind + sourceConfig - Extract FigmaColorsSource, TokensFileColorsSource, FigmaComponentsSource, FigmaTypographySource from context implementations - Add SourceFactory for centralized source dispatch - Add sourceKind field to PKL schemas (FrameSource, VariablesSource) - Update all context impls, plugin exports, and entry bridges --- CLAUDE.md | 12 ++ .../Config/AndroidIconsEntry.swift | 1 + .../Config/AndroidImagesEntry.swift | 1 + .../Export/AndroidColorsExporter.swift | 2 +- .../Config/FlutterIconsEntry.swift | 1 + .../Config/FlutterImagesEntry.swift | 2 + .../Export/FlutterColorsExporter.swift | 2 +- Sources/ExFig-Web/Config/WebIconsEntry.swift | 1 + Sources/ExFig-Web/Config/WebImagesEntry.swift | 1 + .../ExFig-Web/Export/WebColorsExporter.swift | 2 +- Sources/ExFig-iOS/Config/iOSIconsEntry.swift | 1 + Sources/ExFig-iOS/Config/iOSImagesEntry.swift | 1 + .../ExFig-iOS/Export/iOSColorsExporter.swift | 2 +- Sources/ExFigCLI/CLAUDE.md | 51 +++---- .../Context/ColorsExportContextImpl.swift | 69 +-------- .../Context/IconsExportContextImpl.swift | 31 +--- .../Context/ImagesExportContextImpl.swift | 32 +---- .../Context/TypographyExportContextImpl.swift | 8 +- Sources/ExFigCLI/ExFigCommand.swift | 8 +- Sources/ExFigCLI/Resources/Schemas/Common.pkl | 11 ++ .../ExFigCLI/Source/FigmaColorsSource.swift | 49 +++++++ .../Source/FigmaComponentsSource.swift | 71 ++++++++++ .../Source/FigmaTypographySource.swift | 13 ++ Sources/ExFigCLI/Source/SourceFactory.swift | 58 ++++++++ .../Source/TokensFileColorsSource.swift | 30 ++++ .../Export/PluginColorsExport.swift | 8 ++ .../Export/PluginIconsExport.swift | 36 +++++ .../Export/PluginImagesExport.swift | 36 +++++ .../Export/PluginTypographyExport.swift | 4 + Sources/ExFigConfig/CLAUDE.md | 14 +- .../ExFigConfig/Generated/Android.pkl.swift | 17 +++ .../ExFigConfig/Generated/Common.pkl.swift | 59 +++++--- Sources/ExFigConfig/Generated/ExFig.pkl.swift | 2 +- .../ExFigConfig/Generated/Flutter.pkl.swift | 17 +++ Sources/ExFigConfig/Generated/Web.pkl.swift | 17 +++ Sources/ExFigConfig/Generated/iOS.pkl.swift | 17 +++ Sources/ExFigConfig/SourceKindBridging.swift | 17 +++ .../VariablesSourceValidation.swift | 42 ++++-- Sources/ExFigCore/CLAUDE.md | 12 +- Sources/ExFigCore/Protocol/DesignSource.swift | 85 +++++++++++ .../ExFigCore/Protocol/ExportContext.swift | 46 ++---- .../Protocol/IconsExportContext.swift | 5 + .../Protocol/ImagesExportContext.swift | 5 + .../Protocol/TypographyExportContext.swift | 5 + .../ExFigTests/Input/EnumBridgingTests.swift | 134 ++++++++++++------ .../design-source-abstraction/tasks.md | 112 +++++++-------- 46 files changed, 824 insertions(+), 326 deletions(-) create mode 100644 Sources/ExFigCLI/Source/FigmaColorsSource.swift create mode 100644 Sources/ExFigCLI/Source/FigmaComponentsSource.swift create mode 100644 Sources/ExFigCLI/Source/FigmaTypographySource.swift create mode 100644 Sources/ExFigCLI/Source/SourceFactory.swift create mode 100644 Sources/ExFigCLI/Source/TokensFileColorsSource.swift create mode 100644 Sources/ExFigConfig/SourceKindBridging.swift create mode 100644 Sources/ExFigCore/Protocol/DesignSource.swift diff --git a/CLAUDE.md b/CLAUDE.md index 80a7262e..5681fb9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,6 +142,7 @@ Sources/ExFigCLI/ ├── Sync/ # Figma sync functionality (state tracking, diff detection) ├── Plugin/ # Plugin registry ├── Context/ # Export context implementations (ColorsExportContextImpl, etc.) +├── Source/ # Design source implementations (FigmaColorsSource, SourceFactory, etc.) ├── MCP/ # Model Context Protocol server (tools, resources, prompts) └── Shared/ # Cross-cutting helpers (PlatformExportResult, HashMerger) @@ -296,6 +297,15 @@ See `ExFigCore/CLAUDE.md` (Modification Checklist) and platform module CLAUDE.md `URL(fileURLWithPath:)` → `lastPathComponent` (iOS/Android/Web). `URL(string:)` → preserves subdirectories (Flutter). See `ExFigCore/CLAUDE.md`. +### Refactoring *SourceInput Types + +When changing fields on `ColorsSourceInput` / `IconsSourceInput` / `ImagesSourceInput`: + +1. Construction sites: `validatedColorsSourceInput()` in `VariablesSourceValidation.swift`, entry bridge methods in `Sources/ExFig-*/Config/*Entry.swift` +2. **Read sites in platform exporters**: `Sources/ExFig-*/Export/*Exporter.swift` — spinner messages may reference SourceInput fields +3. Download commands (`DownloadColors`, `DownloadAll`) use loaders directly, NOT `*SourceInput` — typically unaffected +4. `BatchConfigRunner` delegates via `performExportWithResult()` — typically unaffected + ## Code Conventions | Area | Use | Instead of | @@ -402,6 +412,8 @@ NooraUI.formatLink("url", useColors: true) // underlined primary | PKL template word search | Template comments on `lightFileId` contain cross-refs (`variablesColors`, `typography`); test section removal by matching full markers (`colors = new Common.Colors {`) not bare words | | CI llms-full.txt stale | `llms-full.txt` is generated from README + DocC articles; after editing `Usage.md`, `ExFig.md`, or `README.md`, run `./bin/mise run generate:llms` and commit the result | | Release build .pcm warnings | Stale `ModuleCache` — clean with: `rm -r .build/*/release/ModuleCache` then rebuild | +| `nil` in switch expression | After adding enum case, `nil` in `String?` switch branch fails to compile | +| PKL↔Swift enum rawValue | PKL kebab `"tokens-file"` → `.tokensFile`, but Swift rawValue is `"tokensFile"` — rawValue round-trip fails | ## Additional Rules diff --git a/Sources/ExFig-Android/Config/AndroidIconsEntry.swift b/Sources/ExFig-Android/Config/AndroidIconsEntry.swift index e19b974e..dec15f5f 100644 --- a/Sources/ExFig-Android/Config/AndroidIconsEntry.swift +++ b/Sources/ExFig-Android/Config/AndroidIconsEntry.swift @@ -14,6 +14,7 @@ public extension Android.IconsEntry { /// Returns an IconsSourceInput for use with IconsExportContext. func iconsSourceInput(darkFileId: String? = nil) -> IconsSourceInput { IconsSourceInput( + sourceKind: sourceKind?.coreSourceKind ?? .figma, figmaFileId: figmaFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Icons", diff --git a/Sources/ExFig-Android/Config/AndroidImagesEntry.swift b/Sources/ExFig-Android/Config/AndroidImagesEntry.swift index 4808a801..5eeefc11 100644 --- a/Sources/ExFig-Android/Config/AndroidImagesEntry.swift +++ b/Sources/ExFig-Android/Config/AndroidImagesEntry.swift @@ -31,6 +31,7 @@ public extension Android.ImagesEntry { /// Returns an ImagesSourceInput for use with ImagesExportContext. func imagesSourceInput(darkFileId: String? = nil) -> ImagesSourceInput { ImagesSourceInput( + sourceKind: sourceKind?.coreSourceKind ?? .figma, figmaFileId: figmaFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Images", diff --git a/Sources/ExFig-Android/Export/AndroidColorsExporter.swift b/Sources/ExFig-Android/Export/AndroidColorsExporter.swift index 18c04f56..b08e984a 100644 --- a/Sources/ExFig-Android/Export/AndroidColorsExporter.swift +++ b/Sources/ExFig-Android/Export/AndroidColorsExporter.swift @@ -63,7 +63,7 @@ public struct AndroidColorsExporter: ColorsExporter { // 1. Load colors from Figma let sourceInput = try entry.validatedColorsSourceInput() let colors = try await context.withSpinner( - "Fetching colors from Figma (\(sourceInput.tokensCollectionName))..." + "Fetching colors (\(sourceInput.sourceKind.rawValue))..." ) { try await context.loadColors(from: sourceInput) } diff --git a/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift b/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift index 25579860..0eddc5fe 100644 --- a/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift +++ b/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift @@ -11,6 +11,7 @@ public extension Flutter.IconsEntry { /// Returns an IconsSourceInput for use with IconsExportContext. func iconsSourceInput(darkFileId: String? = nil) -> IconsSourceInput { IconsSourceInput( + sourceKind: sourceKind?.coreSourceKind ?? .figma, figmaFileId: figmaFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Icons", diff --git a/Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift b/Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift index d98fbded..f043b064 100644 --- a/Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift +++ b/Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift @@ -14,6 +14,7 @@ public extension Flutter.ImagesEntry { /// Returns an ImagesSourceInput for use with ImagesExportContext. func imagesSourceInput(darkFileId: String? = nil) -> ImagesSourceInput { ImagesSourceInput( + sourceKind: sourceKind?.coreSourceKind ?? .figma, figmaFileId: figmaFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Images", @@ -43,6 +44,7 @@ public extension Flutter.ImagesEntry { /// Returns an ImagesSourceInput configured for SVG source. func svgSourceInput(darkFileId: String? = nil) -> ImagesSourceInput { ImagesSourceInput( + sourceKind: sourceKind?.coreSourceKind ?? .figma, figmaFileId: figmaFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Images", diff --git a/Sources/ExFig-Flutter/Export/FlutterColorsExporter.swift b/Sources/ExFig-Flutter/Export/FlutterColorsExporter.swift index 2698b6a3..45415b96 100644 --- a/Sources/ExFig-Flutter/Export/FlutterColorsExporter.swift +++ b/Sources/ExFig-Flutter/Export/FlutterColorsExporter.swift @@ -52,7 +52,7 @@ public struct FlutterColorsExporter: ColorsExporter { // 1. Load colors from Figma let sourceInput = try entry.validatedColorsSourceInput() let colors = try await context.withSpinner( - "Fetching colors from Figma (\(sourceInput.tokensCollectionName))..." + "Fetching colors (\(sourceInput.sourceKind.rawValue))..." ) { try await context.loadColors(from: sourceInput) } diff --git a/Sources/ExFig-Web/Config/WebIconsEntry.swift b/Sources/ExFig-Web/Config/WebIconsEntry.swift index e43d5c60..12500a0b 100644 --- a/Sources/ExFig-Web/Config/WebIconsEntry.swift +++ b/Sources/ExFig-Web/Config/WebIconsEntry.swift @@ -11,6 +11,7 @@ public extension Web.IconsEntry { /// Returns an IconsSourceInput for use with IconsExportContext. func iconsSourceInput(darkFileId: String? = nil) -> IconsSourceInput { IconsSourceInput( + sourceKind: sourceKind?.coreSourceKind ?? .figma, figmaFileId: figmaFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Icons", diff --git a/Sources/ExFig-Web/Config/WebImagesEntry.swift b/Sources/ExFig-Web/Config/WebImagesEntry.swift index 837c7639..21a0205d 100644 --- a/Sources/ExFig-Web/Config/WebImagesEntry.swift +++ b/Sources/ExFig-Web/Config/WebImagesEntry.swift @@ -11,6 +11,7 @@ public extension Web.ImagesEntry { /// Returns an ImagesSourceInput for use with ImagesExportContext. func imagesSourceInput(darkFileId: String? = nil) -> ImagesSourceInput { ImagesSourceInput( + sourceKind: sourceKind?.coreSourceKind ?? .figma, figmaFileId: figmaFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Images", diff --git a/Sources/ExFig-Web/Export/WebColorsExporter.swift b/Sources/ExFig-Web/Export/WebColorsExporter.swift index 65ecb61b..f8682dc9 100644 --- a/Sources/ExFig-Web/Export/WebColorsExporter.swift +++ b/Sources/ExFig-Web/Export/WebColorsExporter.swift @@ -52,7 +52,7 @@ public struct WebColorsExporter: ColorsExporter { // 1. Load colors from Figma let sourceInput = try entry.validatedColorsSourceInput() let colors = try await context.withSpinner( - "Fetching colors from Figma (\(sourceInput.tokensCollectionName))..." + "Fetching colors (\(sourceInput.sourceKind.rawValue))..." ) { try await context.loadColors(from: sourceInput) } diff --git a/Sources/ExFig-iOS/Config/iOSIconsEntry.swift b/Sources/ExFig-iOS/Config/iOSIconsEntry.swift index f5b8c614..3e7473e2 100644 --- a/Sources/ExFig-iOS/Config/iOSIconsEntry.swift +++ b/Sources/ExFig-iOS/Config/iOSIconsEntry.swift @@ -13,6 +13,7 @@ public extension iOS.IconsEntry { /// Returns an IconsSourceInput for use with IconsExportContext. func iconsSourceInput(darkFileId: String? = nil) -> IconsSourceInput { IconsSourceInput( + sourceKind: sourceKind?.coreSourceKind ?? .figma, figmaFileId: figmaFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Icons", diff --git a/Sources/ExFig-iOS/Config/iOSImagesEntry.swift b/Sources/ExFig-iOS/Config/iOSImagesEntry.swift index 2b269160..1ae68e57 100644 --- a/Sources/ExFig-iOS/Config/iOSImagesEntry.swift +++ b/Sources/ExFig-iOS/Config/iOSImagesEntry.swift @@ -13,6 +13,7 @@ public extension iOS.ImagesEntry { /// Returns an ImagesSourceInput for use with ImagesExportContext. func imagesSourceInput(darkFileId: String? = nil) -> ImagesSourceInput { ImagesSourceInput( + sourceKind: sourceKind?.coreSourceKind ?? .figma, figmaFileId: figmaFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Images", diff --git a/Sources/ExFig-iOS/Export/iOSColorsExporter.swift b/Sources/ExFig-iOS/Export/iOSColorsExporter.swift index cbaae3f4..1d417b10 100644 --- a/Sources/ExFig-iOS/Export/iOSColorsExporter.swift +++ b/Sources/ExFig-iOS/Export/iOSColorsExporter.swift @@ -65,7 +65,7 @@ public struct iOSColorsExporter: ColorsExporter { // 1. Load colors from Figma let sourceInput = try entry.validatedColorsSourceInput() let colors = try await context.withSpinner( - "Fetching colors from Figma (\(sourceInput.tokensCollectionName))..." + "Fetching colors (\(sourceInput.sourceKind.rawValue))..." ) { try await context.loadColors(from: sourceInput) } diff --git a/Sources/ExFigCLI/CLAUDE.md b/Sources/ExFigCLI/CLAUDE.md index d265c399..ca0a6a47 100644 --- a/Sources/ExFigCLI/CLAUDE.md +++ b/Sources/ExFigCLI/CLAUDE.md @@ -148,30 +148,33 @@ Converter factories (`WebpConverterFactory`, `HeicConverterFactory`) handle plat ## Key Files -| File | Role | -| ---------------------------------------- | ------------------------------------------------------------------ | -| `ExFigCommand.swift` | Entry point, version, shared instances, subcommand registration | -| `Input/ExFigOptions.swift` | PKL config loading, token validation, auto-detection | -| `Batch/BatchContext.swift` | `BatchContext`, `ConfigExecutionContext`, `BatchSharedState` actor | -| `Batch/BatchExecutor.swift` | Parallel config execution with rate limiting | -| `Plugin/PluginRegistry.swift` | Platform plugin routing (config key → plugin → exporters) | -| `TerminalUI/TerminalUI.swift` | Output facade (info/success/warning/error, spinners, progress) | -| `TerminalUI/TerminalOutputManager.swift` | Thread-safe output synchronization, animation coordination | -| `TerminalUI/BatchProgressView.swift` | Multi-config progress display with log queuing | -| `Cache/GranularCacheManager.swift` | Per-node change detection with FNV-1a hashing | -| `Pipeline/SharedDownloadQueue.swift` | Cross-config download pipelining actor | -| `Output/FileWriter.swift` | Sequential and parallel file writing with directory creation | -| `Shared/ComponentPreFetcher.swift` | Pre-fetch components for multi-entry exports | -| `Input/TokensFileSource.swift` | W3C DTCG .tokens.json parser (local file → ExFigCore models) | -| `Output/W3CTokensExporter.swift` | W3C design token JSON exporter (v1/v2025 formats) | -| `Loaders/NumberVariablesLoader.swift` | Figma number variables → dimension/number tokens | -| `Subcommands/DownloadTokens.swift` | Unified `download tokens` subcommand | -| `MCP/ExFigMCPServer.swift` | MCP server setup and lifecycle (stdio transport) | -| `MCP/MCPToolDefinitions.swift` | MCP tool schemas (export colors, icons, images, etc.) | -| `MCP/MCPToolHandlers.swift` | MCP tool request handlers | -| `MCP/MCPResources.swift` | MCP resource providers (config, schemas) | -| `MCP/MCPPrompts.swift` | MCP prompt templates | -| `MCP/MCPServerState.swift` | MCP server shared state | +| File | Role | +| ---------------------------------------- | ------------------------------------------------------------------- | +| `ExFigCommand.swift` | Entry point, version, shared instances, subcommand registration | +| `Input/ExFigOptions.swift` | PKL config loading, token validation, auto-detection | +| `Batch/BatchContext.swift` | `BatchContext`, `ConfigExecutionContext`, `BatchSharedState` actor | +| `Batch/BatchExecutor.swift` | Parallel config execution with rate limiting | +| `Plugin/PluginRegistry.swift` | Platform plugin routing (config key → plugin → exporters) | +| `TerminalUI/TerminalUI.swift` | Output facade (info/success/warning/error, spinners, progress) | +| `TerminalUI/TerminalOutputManager.swift` | Thread-safe output synchronization, animation coordination | +| `TerminalUI/BatchProgressView.swift` | Multi-config progress display with log queuing | +| `Cache/GranularCacheManager.swift` | Per-node change detection with FNV-1a hashing | +| `Pipeline/SharedDownloadQueue.swift` | Cross-config download pipelining actor | +| `Output/FileWriter.swift` | Sequential and parallel file writing with directory creation | +| `Shared/ComponentPreFetcher.swift` | Pre-fetch components for multi-entry exports | +| `Input/TokensFileSource.swift` | W3C DTCG .tokens.json parser (local file → ExFigCore models) | +| `Output/W3CTokensExporter.swift` | W3C design token JSON exporter (v1/v2025 formats) | +| `Loaders/NumberVariablesLoader.swift` | Figma number variables → dimension/number tokens | +| `Subcommands/DownloadTokens.swift` | Unified `download tokens` subcommand | +| `MCP/ExFigMCPServer.swift` | MCP server setup and lifecycle (stdio transport) | +| `MCP/MCPToolDefinitions.swift` | MCP tool schemas (export colors, icons, images, etc.) | +| `MCP/MCPToolHandlers.swift` | MCP tool request handlers | +| `MCP/MCPResources.swift` | MCP resource providers (config, schemas) | +| `MCP/MCPPrompts.swift` | MCP prompt templates | +| `MCP/MCPServerState.swift` | MCP server shared state | +| `Source/SourceFactory.swift` | Centralized factory creating source instances by `DesignSourceKind` | +| `Source/Figma*Source.swift` | Figma source implementations wrapping existing loaders | +| `Source/TokensFileColorsSource.swift` | Local .tokens.json source (extracted from ColorsExportContextImpl) | ### MCP Server Architecture diff --git a/Sources/ExFigCLI/Context/ColorsExportContextImpl.swift b/Sources/ExFigCLI/Context/ColorsExportContextImpl.swift index 2ae763a9..a4e78fe2 100644 --- a/Sources/ExFigCLI/Context/ColorsExportContextImpl.swift +++ b/Sources/ExFigCLI/Context/ColorsExportContextImpl.swift @@ -1,4 +1,3 @@ -import ExFigConfig import ExFigCore import FigmaAPI import Foundation @@ -6,23 +5,26 @@ import Foundation /// Concrete implementation of `ColorsExportContext` for the ExFig CLI. /// /// Bridges between the plugin system and ExFig's internal services: -/// - Uses `ColorsVariablesLoader` for Figma data loading +/// - Uses injected `ColorsSource` for design data loading /// - Uses `ColorsProcessor` for platform-specific processing /// - Uses `ExFigCommand.fileWriter` for file output /// - Uses `TerminalUI` for progress and logging struct ColorsExportContextImpl: ColorsExportContext { let client: Client + let colorsSource: any ColorsSource let ui: TerminalUI let filter: String? let isBatchMode: Bool init( client: Client, + colorsSource: any ColorsSource, ui: TerminalUI, filter: String? = nil, isBatchMode: Bool = false ) { self.client = client + self.colorsSource = colorsSource self.ui = ui self.filter = filter self.isBatchMode = isBatchMode @@ -56,68 +58,7 @@ struct ColorsExportContextImpl: ColorsExportContext { // MARK: - ColorsExportContext func loadColors(from source: ColorsSourceInput) async throws -> ColorsLoadOutput { - if let tokensFilePath = source.tokensFilePath { - // Warn if mode-related fields are configured but will be ignored - if source.darkModeName != nil || source.lightHCModeName != nil || source.darkHCModeName != nil { - ui.warning( - "Local tokens file provides single-mode colors only" - + " — darkModeName/lightHCModeName/darkHCModeName will be ignored" - ) - } - return try loadColorsFromTokensFile(path: tokensFilePath, groupFilter: source.tokensFileGroupFilter) - } - return try await loadColorsFromFigma(source: source) - } - - private func loadColorsFromTokensFile(path: String, groupFilter: String?) throws -> ColorsLoadOutput { - var source = try TokensFileSource.parse(fileAt: path) - try source.resolveAliases() - - for warning in source.warnings { - ui.warning(warning) - } - - var colors = source.toColors() - - if let groupFilter { - let prefix = groupFilter.replacingOccurrences(of: ".", with: "/") + "/" - colors = colors.filter { $0.name.hasPrefix(prefix) } - } - - return ColorsLoadOutput(light: colors) - } - - private func loadColorsFromFigma(source: ColorsSourceInput) async throws -> ColorsLoadOutput { - let variableParams = Common.VariablesColors( - tokensFileId: source.tokensFileId, - tokensCollectionName: source.tokensCollectionName, - lightModeName: source.lightModeName, - darkModeName: source.darkModeName, - lightHCModeName: source.lightHCModeName, - darkHCModeName: source.darkHCModeName, - primitivesModeName: source.primitivesModeName, - nameValidateRegexp: source.nameValidateRegexp, - nameReplaceRegexp: source.nameReplaceRegexp - ) - - let loader = ColorsVariablesLoader( - client: client, - variableParams: variableParams, - filter: filter - ) - - let result = try await loader.load() - - for warning in result.warnings { - ui.warning(warning) - } - - return ColorsLoadOutput( - light: result.output.light, - dark: result.output.dark ?? [], - lightHC: result.output.lightHC ?? [], - darkHC: result.output.darkHC ?? [] - ) + try await colorsSource.loadColors(from: source) } func processColors( diff --git a/Sources/ExFigCLI/Context/IconsExportContextImpl.swift b/Sources/ExFigCLI/Context/IconsExportContextImpl.swift index 7a2eeea0..ddfdcbe9 100644 --- a/Sources/ExFigCLI/Context/IconsExportContextImpl.swift +++ b/Sources/ExFigCLI/Context/IconsExportContextImpl.swift @@ -14,6 +14,7 @@ import Foundation /// - Supports granular cache for incremental exports struct IconsExportContextImpl: IconsExportContextWithGranularCache { let client: Client + let componentsSource: any ComponentsSource let ui: TerminalUI let params: PKLConfig let filter: String? @@ -25,6 +26,7 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache { init( client: Client, + componentsSource: any ComponentsSource, ui: TerminalUI, params: PKLConfig, filter: String? = nil, @@ -35,6 +37,7 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache { platform: Platform ) { self.client = client + self.componentsSource = componentsSource self.ui = ui self.params = params self.filter = filter @@ -77,33 +80,7 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache { // MARK: - IconsExportContext func loadIcons(from source: IconsSourceInput) async throws -> IconsLoadOutput { - // Create loader config from source input - let config = IconsLoaderConfig( - entryFileId: source.figmaFileId, - frameName: source.frameName, - pageName: source.pageName, - format: source.format, - renderMode: source.renderMode, - renderModeDefaultSuffix: source.renderModeDefaultSuffix, - renderModeOriginalSuffix: source.renderModeOriginalSuffix, - renderModeTemplateSuffix: source.renderModeTemplateSuffix, - rtlProperty: source.rtlProperty - ) - - let loader = IconsLoader( - client: client, - params: params, - platform: platform, - logger: ExFigCommand.logger, - config: config - ) - - let result = try await loader.load(filter: filter) - - return IconsLoadOutput( - light: result.light, - dark: result.dark ?? [] - ) + try await componentsSource.loadIcons(from: source) } func processIcons( diff --git a/Sources/ExFigCLI/Context/ImagesExportContextImpl.swift b/Sources/ExFigCLI/Context/ImagesExportContextImpl.swift index bf2216d6..1ab10df8 100644 --- a/Sources/ExFigCLI/Context/ImagesExportContextImpl.swift +++ b/Sources/ExFigCLI/Context/ImagesExportContextImpl.swift @@ -19,6 +19,7 @@ import Foundation /// - Supports granular cache for incremental exports struct ImagesExportContextImpl: ImagesExportContextWithGranularCache { let client: Client + let componentsSource: any ComponentsSource let ui: TerminalUI let params: PKLConfig let filter: String? @@ -30,6 +31,7 @@ struct ImagesExportContextImpl: ImagesExportContextWithGranularCache { init( client: Client, + componentsSource: any ComponentsSource, ui: TerminalUI, params: PKLConfig, filter: String? = nil, @@ -40,6 +42,7 @@ struct ImagesExportContextImpl: ImagesExportContextWithGranularCache { platform: Platform ) { self.client = client + self.componentsSource = componentsSource self.ui = ui self.params = params self.filter = filter @@ -82,34 +85,7 @@ struct ImagesExportContextImpl: ImagesExportContextWithGranularCache { // MARK: - ImagesExportContext func loadImages(from source: ImagesSourceInput) async throws -> ImagesLoadOutput { - // Convert source format - let loaderSourceFormat: ImagesSourceFormat = source.sourceFormat == .svg ? .svg : .png - - // Create loader config from source input - let config = ImagesLoaderConfig( - entryFileId: source.figmaFileId, - frameName: source.frameName, - pageName: source.pageName, - scales: source.scales, - format: nil, // Format is determined by platform exporter - sourceFormat: loaderSourceFormat, - rtlProperty: source.rtlProperty - ) - - let loader = ImagesLoader( - client: client, - params: params, - platform: platform, - logger: ExFigCommand.logger, - config: config - ) - - let result = try await loader.load(filter: filter) - - return ImagesLoadOutput( - light: result.light, - dark: result.dark ?? [] - ) + try await componentsSource.loadImages(from: source) } func processImages( diff --git a/Sources/ExFigCLI/Context/TypographyExportContextImpl.swift b/Sources/ExFigCLI/Context/TypographyExportContextImpl.swift index a48694e5..cddc6fcd 100644 --- a/Sources/ExFigCLI/Context/TypographyExportContextImpl.swift +++ b/Sources/ExFigCLI/Context/TypographyExportContextImpl.swift @@ -11,17 +11,20 @@ import Foundation /// - Uses `TerminalUI` for progress and logging struct TypographyExportContextImpl: TypographyExportContext { let client: Client + let typographySource: any TypographySource let ui: TerminalUI let filter: String? let isBatchMode: Bool init( client: Client, + typographySource: any TypographySource, ui: TerminalUI, filter: String? = nil, isBatchMode: Bool = false ) { self.client = client + self.typographySource = typographySource self.ui = ui self.filter = filter self.isBatchMode = isBatchMode @@ -55,10 +58,7 @@ struct TypographyExportContextImpl: TypographyExportContext { // MARK: - TypographyExportContext func loadTypography(from source: TypographySourceInput) async throws -> TypographyLoadOutput { - let loader = TextStylesLoader(client: client, fileId: source.fileId) - let textStyles = try await loader.load() - - return TypographyLoadOutput(textStyles: textStyles) + try await typographySource.loadTypography(from: source) } func processTypography( diff --git a/Sources/ExFigCLI/ExFigCommand.swift b/Sources/ExFigCLI/ExFigCommand.swift index 6d7ece76..2519de79 100644 --- a/Sources/ExFigCLI/ExFigCommand.swift +++ b/Sources/ExFigCLI/ExFigCommand.swift @@ -1,4 +1,5 @@ import ArgumentParser +import ExFigCore import Foundation import Logging import SVGKit @@ -11,6 +12,7 @@ enum ExFigError: LocalizedError { case colorsAssetsFolderNotSpecified case configurationError(String) case custom(errorString: String) + case unsupportedSourceKind(DesignSourceKind) var errorDescription: String? { switch self { @@ -34,6 +36,8 @@ enum ExFigError: LocalizedError { "Config error: \(message)" case let .custom(errorString): errorString + case let .unsupportedSourceKind(kind): + "Unsupported design source: \(kind.rawValue)" } } @@ -55,7 +59,9 @@ enum ExFigError: LocalizedError { case .colorsAssetsFolderNotSpecified: "Add ios.colors.assetsFolder to your config file" case .configurationError, .custom: - nil + nil as String? + case .unsupportedSourceKind: + "Currently supported sources: figma, tokensFile" } } } diff --git a/Sources/ExFigCLI/Resources/Schemas/Common.pkl b/Sources/ExFigCLI/Resources/Schemas/Common.pkl index 1789f44e..32ea34f5 100644 --- a/Sources/ExFigCLI/Resources/Schemas/Common.pkl +++ b/Sources/ExFigCLI/Resources/Schemas/Common.pkl @@ -16,6 +16,9 @@ typealias VectorFormat = "pdf"|"svg" /// - `svg`: Download SVG and rasterize locally with resvg (higher quality) typealias SourceFormat = "png"|"svg" +/// Design source kind — identifies the tool or data source for asset loading. +typealias SourceKind = "figma"|"penpot"|"tokens-file"|"tokens-studio"|"sketch-file" + // MARK: - WebP Options /// WebP encoding mode. @@ -70,6 +73,11 @@ open class NameProcessing { /// Used for colors that come from Figma Variables API. /// All fields are optional to support legacy format where source comes from common.variablesColors. open class VariablesSource extends NameProcessing { + /// Design source kind override. When null, auto-detected: + /// - If `tokensFile` is set → "tokens-file" + /// - Otherwise → "figma" + sourceKind: SourceKind? + /// Local .tokens.json file source (bypasses Figma API when set). tokensFile: TokensFile? @@ -98,6 +106,9 @@ open class VariablesSource extends NameProcessing { /// Figma Frame source configuration. /// Used for icons and images that come from Figma frames. open class FrameSource extends NameProcessing { + /// Design source kind override. When null, defaults to "figma". + sourceKind: SourceKind? + /// Figma frame name to export from. figmaFrameName: String? diff --git a/Sources/ExFigCLI/Source/FigmaColorsSource.swift b/Sources/ExFigCLI/Source/FigmaColorsSource.swift new file mode 100644 index 00000000..8eef57ba --- /dev/null +++ b/Sources/ExFigCLI/Source/FigmaColorsSource.swift @@ -0,0 +1,49 @@ +import ExFigConfig +import ExFigCore +import FigmaAPI +import Foundation + +struct FigmaColorsSource: ColorsSource { + let client: Client + let ui: TerminalUI + let filter: String? + + func loadColors(from input: ColorsSourceInput) async throws -> ColorsLoadOutput { + guard let config = input.sourceConfig as? FigmaColorsConfig else { + throw ExFigError.configurationError( + "FigmaColorsSource requires FigmaColorsConfig, got \(type(of: input.sourceConfig))" + ) + } + + let variableParams = Common.VariablesColors( + tokensFileId: config.tokensFileId, + tokensCollectionName: config.tokensCollectionName, + lightModeName: config.lightModeName, + darkModeName: config.darkModeName, + lightHCModeName: config.lightHCModeName, + darkHCModeName: config.darkHCModeName, + primitivesModeName: config.primitivesModeName, + nameValidateRegexp: input.nameValidateRegexp, + nameReplaceRegexp: input.nameReplaceRegexp + ) + + let loader = ColorsVariablesLoader( + client: client, + variableParams: variableParams, + filter: filter + ) + + let result = try await loader.load() + + for warning in result.warnings { + ui.warning(warning) + } + + return ColorsLoadOutput( + light: result.output.light, + dark: result.output.dark ?? [], + lightHC: result.output.lightHC ?? [], + darkHC: result.output.darkHC ?? [] + ) + } +} diff --git a/Sources/ExFigCLI/Source/FigmaComponentsSource.swift b/Sources/ExFigCLI/Source/FigmaComponentsSource.swift new file mode 100644 index 00000000..5c8db06a --- /dev/null +++ b/Sources/ExFigCLI/Source/FigmaComponentsSource.swift @@ -0,0 +1,71 @@ +import ExFigConfig +import ExFigCore +import FigmaAPI +import Foundation +import Logging + +struct FigmaComponentsSource: ComponentsSource { + let client: Client + let params: PKLConfig + let platform: Platform + let logger: Logger + let filter: String? + + func loadIcons(from input: IconsSourceInput) async throws -> IconsLoadOutput { + let config = IconsLoaderConfig( + entryFileId: input.figmaFileId, + frameName: input.frameName, + pageName: input.pageName, + format: input.format, + renderMode: input.renderMode, + renderModeDefaultSuffix: input.renderModeDefaultSuffix, + renderModeOriginalSuffix: input.renderModeOriginalSuffix, + renderModeTemplateSuffix: input.renderModeTemplateSuffix, + rtlProperty: input.rtlProperty + ) + + let loader = IconsLoader( + client: client, + params: params, + platform: platform, + logger: logger, + config: config + ) + + let result = try await loader.load(filter: filter) + + return IconsLoadOutput( + light: result.light, + dark: result.dark ?? [] + ) + } + + func loadImages(from input: ImagesSourceInput) async throws -> ImagesLoadOutput { + let loaderSourceFormat: ImagesSourceFormat = input.sourceFormat == .svg ? .svg : .png + + let config = ImagesLoaderConfig( + entryFileId: input.figmaFileId, + frameName: input.frameName, + pageName: input.pageName, + scales: input.scales, + format: nil, + sourceFormat: loaderSourceFormat, + rtlProperty: input.rtlProperty + ) + + let loader = ImagesLoader( + client: client, + params: params, + platform: platform, + logger: logger, + config: config + ) + + let result = try await loader.load(filter: filter) + + return ImagesLoadOutput( + light: result.light, + dark: result.dark ?? [] + ) + } +} diff --git a/Sources/ExFigCLI/Source/FigmaTypographySource.swift b/Sources/ExFigCLI/Source/FigmaTypographySource.swift new file mode 100644 index 00000000..2ef6869d --- /dev/null +++ b/Sources/ExFigCLI/Source/FigmaTypographySource.swift @@ -0,0 +1,13 @@ +import ExFigCore +import FigmaAPI +import Foundation + +struct FigmaTypographySource: TypographySource { + let client: Client + + func loadTypography(from input: TypographySourceInput) async throws -> TypographyLoadOutput { + let loader = TextStylesLoader(client: client, fileId: input.fileId) + let textStyles = try await loader.load() + return TypographyLoadOutput(textStyles: textStyles) + } +} diff --git a/Sources/ExFigCLI/Source/SourceFactory.swift b/Sources/ExFigCLI/Source/SourceFactory.swift new file mode 100644 index 00000000..424c2050 --- /dev/null +++ b/Sources/ExFigCLI/Source/SourceFactory.swift @@ -0,0 +1,58 @@ +import ExFigConfig +import ExFigCore +import FigmaAPI +import Foundation +import Logging + +enum SourceFactory { + static func createColorsSource( + for input: ColorsSourceInput, + client: Client, + ui: TerminalUI, + filter: String? + ) throws -> any ColorsSource { + switch input.sourceKind { + case .figma: + FigmaColorsSource(client: client, ui: ui, filter: filter) + case .tokensFile: + TokensFileColorsSource(ui: ui) + case .penpot, .tokensStudio, .sketchFile: + throw ExFigError.unsupportedSourceKind(input.sourceKind) + } + } + + // swiftlint:disable:next function_parameter_count + static func createComponentsSource( + for sourceKind: DesignSourceKind, + client: Client, + params: PKLConfig, + platform: Platform, + logger: Logger, + filter: String? + ) throws -> any ComponentsSource { + switch sourceKind { + case .figma: + FigmaComponentsSource( + client: client, + params: params, + platform: platform, + logger: logger, + filter: filter + ) + case .penpot, .tokensFile, .tokensStudio, .sketchFile: + throw ExFigError.unsupportedSourceKind(sourceKind) + } + } + + static func createTypographySource( + for sourceKind: DesignSourceKind, + client: Client + ) throws -> any TypographySource { + switch sourceKind { + case .figma: + FigmaTypographySource(client: client) + case .penpot, .tokensFile, .tokensStudio, .sketchFile: + throw ExFigError.unsupportedSourceKind(sourceKind) + } + } +} diff --git a/Sources/ExFigCLI/Source/TokensFileColorsSource.swift b/Sources/ExFigCLI/Source/TokensFileColorsSource.swift new file mode 100644 index 00000000..7682ff4d --- /dev/null +++ b/Sources/ExFigCLI/Source/TokensFileColorsSource.swift @@ -0,0 +1,30 @@ +import ExFigCore +import Foundation + +struct TokensFileColorsSource: ColorsSource { + let ui: TerminalUI + + func loadColors(from input: ColorsSourceInput) async throws -> ColorsLoadOutput { + guard let config = input.sourceConfig as? TokensFileColorsConfig else { + throw ExFigError.configurationError( + "TokensFileColorsSource requires TokensFileColorsConfig, got \(type(of: input.sourceConfig))" + ) + } + + var source = try TokensFileSource.parse(fileAt: config.filePath) + try source.resolveAliases() + + for warning in source.warnings { + ui.warning(warning) + } + + var colors = source.toColors() + + if let groupFilter = config.groupFilter { + let prefix = groupFilter.replacingOccurrences(of: ".", with: "/") + "/" + colors = colors.filter { $0.name.hasPrefix(prefix) } + } + + return ColorsLoadOutput(light: colors) + } +} diff --git a/Sources/ExFigCLI/Subcommands/Export/PluginColorsExport.swift b/Sources/ExFigCLI/Subcommands/Export/PluginColorsExport.swift index 6fa3e0cc..5b555c29 100644 --- a/Sources/ExFigCLI/Subcommands/Export/PluginColorsExport.swift +++ b/Sources/ExFigCLI/Subcommands/Export/PluginColorsExport.swift @@ -32,8 +32,10 @@ extension ExFigCommand.ExportColors { // Create context let batchMode = BatchSharedState.current?.isBatchMode ?? false + let colorsSource = FigmaColorsSource(client: client, ui: ui, filter: filter) let context = ColorsExportContextImpl( client: client, + colorsSource: colorsSource, ui: ui, filter: filter, isBatchMode: batchMode @@ -125,8 +127,10 @@ extension ExFigCommand.ExportColors { let platformConfig = android.platformConfig() let batchMode = BatchSharedState.current?.isBatchMode ?? false + let colorsSource = FigmaColorsSource(client: client, ui: ui, filter: filter) let context = ColorsExportContextImpl( client: client, + colorsSource: colorsSource, ui: ui, filter: filter, isBatchMode: batchMode @@ -161,8 +165,10 @@ extension ExFigCommand.ExportColors { let platformConfig = flutter.platformConfig() let batchMode = BatchSharedState.current?.isBatchMode ?? false + let colorsSource = FigmaColorsSource(client: client, ui: ui, filter: filter) let context = ColorsExportContextImpl( client: client, + colorsSource: colorsSource, ui: ui, filter: filter, isBatchMode: batchMode @@ -197,8 +203,10 @@ extension ExFigCommand.ExportColors { let platformConfig = web.platformConfig() let batchMode = BatchSharedState.current?.isBatchMode ?? false + let colorsSource = FigmaColorsSource(client: client, ui: ui, filter: filter) let context = ColorsExportContextImpl( client: client, + colorsSource: colorsSource, ui: ui, filter: filter, isBatchMode: batchMode diff --git a/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift b/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift index f7a18659..aa6cc588 100644 --- a/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift +++ b/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift @@ -39,8 +39,17 @@ extension ExFigCommand.ExportIcons { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() + let componentsSource = FigmaComponentsSource( + client: client, + params: params, + platform: .ios, + logger: ExFigCommand.logger, + filter: filter + ) + let context = IconsExportContextImpl( client: client, + componentsSource: componentsSource, ui: ui, params: params, filter: filter, @@ -107,8 +116,17 @@ extension ExFigCommand.ExportIcons { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() + let componentsSource = FigmaComponentsSource( + client: client, + params: params, + platform: .android, + logger: ExFigCommand.logger, + filter: filter + ) + let context = IconsExportContextImpl( client: client, + componentsSource: componentsSource, ui: ui, params: params, filter: filter, @@ -151,8 +169,17 @@ extension ExFigCommand.ExportIcons { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() + let componentsSource = FigmaComponentsSource( + client: client, + params: params, + platform: .flutter, + logger: ExFigCommand.logger, + filter: filter + ) + let context = IconsExportContextImpl( client: client, + componentsSource: componentsSource, ui: ui, params: params, filter: filter, @@ -195,8 +222,17 @@ extension ExFigCommand.ExportIcons { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() + let componentsSource = FigmaComponentsSource( + client: client, + params: params, + platform: .web, + logger: ExFigCommand.logger, + filter: filter + ) + let context = IconsExportContextImpl( client: client, + componentsSource: componentsSource, ui: ui, params: params, filter: filter, diff --git a/Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift b/Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift index 12d36f8c..d5451084 100644 --- a/Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift +++ b/Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift @@ -38,8 +38,17 @@ extension ExFigCommand.ExportImages { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() + let componentsSource = FigmaComponentsSource( + client: client, + params: params, + platform: .ios, + logger: ExFigCommand.logger, + filter: filter + ) + let context = ImagesExportContextImpl( client: client, + componentsSource: componentsSource, ui: ui, params: params, filter: filter, @@ -105,8 +114,17 @@ extension ExFigCommand.ExportImages { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() + let componentsSource = FigmaComponentsSource( + client: client, + params: params, + platform: .android, + logger: ExFigCommand.logger, + filter: filter + ) + let context = ImagesExportContextImpl( client: client, + componentsSource: componentsSource, ui: ui, params: params, filter: filter, @@ -149,8 +167,17 @@ extension ExFigCommand.ExportImages { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() + let componentsSource = FigmaComponentsSource( + client: client, + params: params, + platform: .flutter, + logger: ExFigCommand.logger, + filter: filter + ) + let context = ImagesExportContextImpl( client: client, + componentsSource: componentsSource, ui: ui, params: params, filter: filter, @@ -193,8 +220,17 @@ extension ExFigCommand.ExportImages { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() + let componentsSource = FigmaComponentsSource( + client: client, + params: params, + platform: .web, + logger: ExFigCommand.logger, + filter: filter + ) + let context = ImagesExportContextImpl( client: client, + componentsSource: componentsSource, ui: ui, params: params, filter: filter, diff --git a/Sources/ExFigCLI/Subcommands/Export/PluginTypographyExport.swift b/Sources/ExFigCLI/Subcommands/Export/PluginTypographyExport.swift index 5ae8b4f4..3512d80a 100644 --- a/Sources/ExFigCLI/Subcommands/Export/PluginTypographyExport.swift +++ b/Sources/ExFigCLI/Subcommands/Export/PluginTypographyExport.swift @@ -40,8 +40,10 @@ extension ExFigCommand.ExportTypography { // Create context let batchMode = BatchSharedState.current?.isBatchMode ?? false + let typographySource = FigmaTypographySource(client: input.client) let context = TypographyExportContextImpl( client: input.client, + typographySource: typographySource, ui: input.ui, filter: nil, isBatchMode: batchMode @@ -96,8 +98,10 @@ extension ExFigCommand.ExportTypography { let platformConfig = android.platformConfig(figma: input.figma) let batchMode = BatchSharedState.current?.isBatchMode ?? false + let typographySource = FigmaTypographySource(client: input.client) let context = TypographyExportContextImpl( client: input.client, + typographySource: typographySource, ui: input.ui, filter: nil, isBatchMode: batchMode diff --git a/Sources/ExFigConfig/CLAUDE.md b/Sources/ExFigConfig/CLAUDE.md index a6b1465b..8ef97f1f 100644 --- a/Sources/ExFigConfig/CLAUDE.md +++ b/Sources/ExFigConfig/CLAUDE.md @@ -48,12 +48,14 @@ ExFigCore domain types (NameStyle, ColorsSourceInput, etc.) ### Key Public API -| Symbol | Purpose | -| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| `PKLEvaluator.evaluate(configPath:)` | Async evaluation of .pkl → `ExFig.ModuleImpl` | -| `PKLError.configNotFound` / `.evaluationDidNotComplete` | Error cases | -| `Common.NameStyle.coreNameStyle` | Bridge to `ExFigCore.NameStyle` via rawValue match | -| `Common_VariablesSource.validatedColorsSourceInput()` | Validates required fields, returns `ColorsSourceInput`. When `tokensFile` is set, bypasses Figma field validation and returns local source input | +| Symbol | Purpose | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `PKLEvaluator.evaluate(configPath:)` | Async evaluation of .pkl → `ExFig.ModuleImpl` | +| `PKLError.configNotFound` / `.evaluationDidNotComplete` | Error cases | +| `Common.NameStyle.coreNameStyle` | Bridge to `ExFigCore.NameStyle` via rawValue match | +| `Common.SourceKind.coreSourceKind` | Bridge to `ExFigCore.DesignSourceKind` via explicit switch (NOT rawValue — kebab vs camelCase mismatch) | +| `Common_VariablesSource.resolvedSourceKind` | Resolution priority: explicit `sourceKind` > auto-detect (tokensFile presence) > default `.figma` | +| `Common_VariablesSource.validatedColorsSourceInput()` | Validates required fields, returns `ColorsSourceInput`. Uses `resolvedSourceKind` for dispatch | ### PklError Workaround diff --git a/Sources/ExFigConfig/Generated/Android.pkl.swift b/Sources/ExFigConfig/Generated/Android.pkl.swift index a92becea..28c8a2d2 100644 --- a/Sources/ExFigConfig/Generated/Android.pkl.swift +++ b/Sources/ExFigConfig/Generated/Android.pkl.swift @@ -131,6 +131,11 @@ extension Android { /// Theme attributes configuration. public var themeAttributes: ThemeAttributes? + /// Design source kind override. When null, auto-detected: + /// - If `tokensFile` is set → "tokens-file" + /// - Otherwise → "figma" + public var sourceKind: Common.SourceKind? + /// Local .tokens.json file source (bypasses Figma API when set). public var tokensFile: Common.TokensFile? @@ -170,6 +175,7 @@ extension Android { composePackageName: String?, colorKotlin: String?, themeAttributes: ThemeAttributes?, + sourceKind: Common.SourceKind?, tokensFile: Common.TokensFile?, tokensFileId: String?, tokensCollectionName: String?, @@ -189,6 +195,7 @@ extension Android { self.composePackageName = composePackageName self.colorKotlin = colorKotlin self.themeAttributes = themeAttributes + self.sourceKind = sourceKind self.tokensFile = tokensFile self.tokensFileId = tokensFileId self.tokensCollectionName = tokensCollectionName @@ -243,6 +250,9 @@ extension Android { /// Path to generate Figma Code Connect Kotlin file for Jetpack Compose. public var codeConnectKotlin: String? + /// Design source kind override. When null, defaults to "figma". + public var sourceKind: Common.SourceKind? + /// Figma frame name to export from. public var figmaFrameName: String? @@ -281,6 +291,7 @@ extension Android { strictPathValidation: Bool?, codeConnectPackageName: String?, codeConnectKotlin: String?, + sourceKind: Common.SourceKind?, figmaFrameName: String?, figmaPageName: String?, figmaFileId: String?, @@ -299,6 +310,7 @@ extension Android { self.strictPathValidation = strictPathValidation self.codeConnectPackageName = codeConnectPackageName self.codeConnectKotlin = codeConnectKotlin + self.sourceKind = sourceKind self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId @@ -341,6 +353,9 @@ extension Android { /// Path to generate Figma Code Connect Kotlin file for Jetpack Compose. public var codeConnectKotlin: String? + /// Design source kind override. When null, defaults to "figma". + public var sourceKind: Common.SourceKind? + /// Figma frame name to export from. public var figmaFrameName: String? @@ -377,6 +392,7 @@ extension Android { sourceFormat: Common.SourceFormat?, nameStyle: Common.NameStyle?, codeConnectKotlin: String?, + sourceKind: Common.SourceKind?, figmaFrameName: String?, figmaPageName: String?, figmaFileId: String?, @@ -393,6 +409,7 @@ extension Android { self.sourceFormat = sourceFormat self.nameStyle = nameStyle self.codeConnectKotlin = codeConnectKotlin + self.sourceKind = sourceKind self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId diff --git a/Sources/ExFigConfig/Generated/Common.pkl.swift b/Sources/ExFigConfig/Generated/Common.pkl.swift index 314651f0..be4b3b79 100644 --- a/Sources/ExFigConfig/Generated/Common.pkl.swift +++ b/Sources/ExFigConfig/Generated/Common.pkl.swift @@ -4,6 +4,8 @@ import PklSwift public enum Common {} public protocol Common_VariablesSource: Common_NameProcessing { + var sourceKind: Common.SourceKind? { get } + var tokensFile: Common.TokensFile? { get } var tokensFileId: String? { get } @@ -28,6 +30,8 @@ public protocol Common_NameProcessing: PklRegisteredType, DynamicallyEquatable, } public protocol Common_FrameSource: Common_NameProcessing { + var sourceKind: Common.SourceKind? { get } + var figmaFrameName: String? { get } var figmaPageName: String? { get } @@ -38,6 +42,15 @@ public protocol Common_FrameSource: Common_NameProcessing { } extension Common { + /// Design source kind — identifies the tool or data source for asset loading. + public enum SourceKind: String, CaseIterable, CodingKeyRepresentable, Decodable, Hashable, Sendable { + case figma = "figma" + case penpot = "penpot" + case tokensFile = "tokens-file" + case tokensStudio = "tokens-studio" + case sketchFile = "sketch-file" + } + /// WebP encoding mode. public enum WebpEncoding: String, CaseIterable, CodingKeyRepresentable, Decodable, Hashable, Sendable { case lossy = "lossy" @@ -76,6 +89,11 @@ extension Common { public struct VariablesSourceImpl: VariablesSource { public static let registeredIdentifier: String = "Common#VariablesSource" + /// Design source kind override. When null, auto-detected: + /// - If `tokensFile` is set → "tokens-file" + /// - Otherwise → "figma" + public var sourceKind: SourceKind? + /// Local .tokens.json file source (bypasses Figma API when set). public var tokensFile: TokensFile? @@ -107,6 +125,7 @@ extension Common { public var nameReplaceRegexp: String? public init( + sourceKind: SourceKind?, tokensFile: TokensFile?, tokensFileId: String?, tokensCollectionName: String?, @@ -118,6 +137,7 @@ extension Common { nameValidateRegexp: String?, nameReplaceRegexp: String? ) { + self.sourceKind = sourceKind self.tokensFile = tokensFile self.tokensFileId = tokensFileId self.tokensCollectionName = tokensCollectionName @@ -131,23 +151,6 @@ extension Common { } } - /// Local W3C DTCG .tokens.json file source. - /// When set on a colors entry, bypasses Figma API and reads tokens from a local file. - public struct TokensFile: PklRegisteredType, Decodable, Hashable, Sendable { - public static let registeredIdentifier: String = "Common#TokensFile" - - /// Path to the .tokens.json file. - public var path: String - - /// Optional dot-path prefix to filter tokens (e.g., "Brand.Colors"). - public var groupFilter: String? - - public init(path: String, groupFilter: String?) { - self.path = path - self.groupFilter = groupFilter - } - } - /// Common types and configurations shared across all platforms. public struct Module: PklRegisteredType, Decodable, Hashable, Sendable { public static let registeredIdentifier: String = "Common" @@ -171,6 +174,23 @@ extension Common { } } + /// Local W3C DTCG .tokens.json file source. + /// When set on a colors entry, bypasses Figma API and reads tokens from a local file. + public struct TokensFile: PklRegisteredType, Decodable, Hashable, Sendable { + public static let registeredIdentifier: String = "Common#TokensFile" + + /// Path to the .tokens.json file. + public var path: String + + /// Optional dot-path prefix to filter tokens (e.g., "Brand.Colors"). + public var groupFilter: String? + + public init(path: String, groupFilter: String?) { + self.path = path + self.groupFilter = groupFilter + } + } + /// Cache configuration for tracking Figma file versions. public struct Cache: PklRegisteredType, Decodable, Hashable, Sendable { public static let registeredIdentifier: String = "Common#Cache" @@ -212,6 +232,9 @@ extension Common { public struct FrameSourceImpl: FrameSource { public static let registeredIdentifier: String = "Common#FrameSource" + /// Design source kind override. When null, defaults to "figma". + public var sourceKind: SourceKind? + /// Figma frame name to export from. public var figmaFrameName: String? @@ -239,6 +262,7 @@ extension Common { public var nameReplaceRegexp: String? public init( + sourceKind: SourceKind?, figmaFrameName: String?, figmaPageName: String?, figmaFileId: String?, @@ -246,6 +270,7 @@ extension Common { nameValidateRegexp: String?, nameReplaceRegexp: String? ) { + self.sourceKind = sourceKind self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId diff --git a/Sources/ExFigConfig/Generated/ExFig.pkl.swift b/Sources/ExFigConfig/Generated/ExFig.pkl.swift index a372a76c..f1e7737e 100644 --- a/Sources/ExFigConfig/Generated/ExFig.pkl.swift +++ b/Sources/ExFigConfig/Generated/ExFig.pkl.swift @@ -27,7 +27,7 @@ extension ExFig { /// /// Usage: /// ```pkl - /// amends "package://github.com/DesignPipe/exfig/releases/download/v2.0.0/exfig@2.0.0#/ExFig.pkl" + /// amends "package://pkg.pkl-lang.org/github.com/DesignPipe/exfig/exfig@2.8.0#/ExFig.pkl" /// /// figma { /// lightFileId = "xxx" diff --git a/Sources/ExFigConfig/Generated/Flutter.pkl.swift b/Sources/ExFigConfig/Generated/Flutter.pkl.swift index 5ef62c5c..0f029fb5 100644 --- a/Sources/ExFigConfig/Generated/Flutter.pkl.swift +++ b/Sources/ExFigConfig/Generated/Flutter.pkl.swift @@ -32,6 +32,11 @@ extension Flutter { /// Class name for generated colors. public var className: String? + /// Design source kind override. When null, auto-detected: + /// - If `tokensFile` is set → "tokens-file" + /// - Otherwise → "figma" + public var sourceKind: Common.SourceKind? + /// Local .tokens.json file source (bypasses Figma API when set). public var tokensFile: Common.TokensFile? @@ -66,6 +71,7 @@ extension Flutter { templatesPath: String?, output: String?, className: String?, + sourceKind: Common.SourceKind?, tokensFile: Common.TokensFile?, tokensFileId: String?, tokensCollectionName: String?, @@ -80,6 +86,7 @@ extension Flutter { self.templatesPath = templatesPath self.output = output self.className = className + self.sourceKind = sourceKind self.tokensFile = tokensFile self.tokensFileId = tokensFileId self.tokensCollectionName = tokensCollectionName @@ -113,6 +120,9 @@ extension Flutter { /// Naming style for icon names. public var nameStyle: Common.NameStyle? + /// Design source kind override. When null, defaults to "figma". + public var sourceKind: Common.SourceKind? + /// Figma frame name to export from. public var figmaFrameName: String? @@ -145,6 +155,7 @@ extension Flutter { dartFile: String?, className: String?, nameStyle: Common.NameStyle?, + sourceKind: Common.SourceKind?, figmaFrameName: String?, figmaPageName: String?, figmaFileId: String?, @@ -157,6 +168,7 @@ extension Flutter { self.dartFile = dartFile self.className = className self.nameStyle = nameStyle + self.sourceKind = sourceKind self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId @@ -198,6 +210,9 @@ extension Flutter { /// Naming style for generated assets. public var nameStyle: Common.NameStyle? + /// Design source kind override. When null, defaults to "figma". + public var sourceKind: Common.SourceKind? + /// Figma frame name to export from. public var figmaFrameName: String? @@ -234,6 +249,7 @@ extension Flutter { webpOptions: Common.WebpOptions?, sourceFormat: Common.SourceFormat?, nameStyle: Common.NameStyle?, + sourceKind: Common.SourceKind?, figmaFrameName: String?, figmaPageName: String?, figmaFileId: String?, @@ -250,6 +266,7 @@ extension Flutter { self.webpOptions = webpOptions self.sourceFormat = sourceFormat self.nameStyle = nameStyle + self.sourceKind = sourceKind self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId diff --git a/Sources/ExFigConfig/Generated/Web.pkl.swift b/Sources/ExFigConfig/Generated/Web.pkl.swift index b562589c..cab1e425 100644 --- a/Sources/ExFigConfig/Generated/Web.pkl.swift +++ b/Sources/ExFigConfig/Generated/Web.pkl.swift @@ -35,6 +35,11 @@ extension Web { /// JSON filename for color data. Default: colors.json public var jsonFileName: String? + /// Design source kind override. When null, auto-detected: + /// - If `tokensFile` is set → "tokens-file" + /// - Otherwise → "figma" + public var sourceKind: Common.SourceKind? + /// Local .tokens.json file source (bypasses Figma API when set). public var tokensFile: Common.TokensFile? @@ -72,6 +77,7 @@ extension Web { cssFileName: String?, tsFileName: String?, jsonFileName: String?, + sourceKind: Common.SourceKind?, tokensFile: Common.TokensFile?, tokensFileId: String?, tokensCollectionName: String?, @@ -89,6 +95,7 @@ extension Web { self.cssFileName = cssFileName self.tsFileName = tsFileName self.jsonFileName = jsonFileName + self.sourceKind = sourceKind self.tokensFile = tokensFile self.tokensFileId = tokensFileId self.tokensCollectionName = tokensCollectionName @@ -125,6 +132,9 @@ extension Web { /// Naming style for icon names. public var nameStyle: Common.NameStyle? + /// Design source kind override. When null, defaults to "figma". + public var sourceKind: Common.SourceKind? + /// Figma frame name to export from. public var figmaFrameName: String? @@ -158,6 +168,7 @@ extension Web { generateReactComponents: Bool?, iconSize: Int?, nameStyle: Common.NameStyle?, + sourceKind: Common.SourceKind?, figmaFrameName: String?, figmaPageName: String?, figmaFileId: String?, @@ -171,6 +182,7 @@ extension Web { self.generateReactComponents = generateReactComponents self.iconSize = iconSize self.nameStyle = nameStyle + self.sourceKind = sourceKind self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId @@ -200,6 +212,9 @@ extension Web { /// Naming style for generated image names. public var nameStyle: Common.NameStyle? + /// Design source kind override. When null, defaults to "figma". + public var sourceKind: Common.SourceKind? + /// Figma frame name to export from. public var figmaFrameName: String? @@ -232,6 +247,7 @@ extension Web { assetsDirectory: String?, generateReactComponents: Bool?, nameStyle: Common.NameStyle?, + sourceKind: Common.SourceKind?, figmaFrameName: String?, figmaPageName: String?, figmaFileId: String?, @@ -244,6 +260,7 @@ extension Web { self.assetsDirectory = assetsDirectory self.generateReactComponents = generateReactComponents self.nameStyle = nameStyle + self.sourceKind = sourceKind self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId diff --git a/Sources/ExFigConfig/Generated/iOS.pkl.swift b/Sources/ExFigConfig/Generated/iOS.pkl.swift index 0749c0e9..d09241cd 100644 --- a/Sources/ExFigConfig/Generated/iOS.pkl.swift +++ b/Sources/ExFigConfig/Generated/iOS.pkl.swift @@ -90,6 +90,11 @@ extension iOS { /// Example: "Color.{name}" → "Color.backgroundAccent" public var codeSyntaxTemplate: String? + /// Design source kind override. When null, auto-detected: + /// - If `tokensFile` is set → "tokens-file" + /// - Otherwise → "figma" + public var sourceKind: Common.SourceKind? + /// Local .tokens.json file source (bypasses Figma API when set). public var tokensFile: Common.TokensFile? @@ -132,6 +137,7 @@ extension iOS { templatesPath: String?, syncCodeSyntax: Bool?, codeSyntaxTemplate: String?, + sourceKind: Common.SourceKind?, tokensFile: Common.TokensFile?, tokensFileId: String?, tokensCollectionName: String?, @@ -154,6 +160,7 @@ extension iOS { self.templatesPath = templatesPath self.syncCodeSyntax = syncCodeSyntax self.codeSyntaxTemplate = codeSyntaxTemplate + self.sourceKind = sourceKind self.tokensFile = tokensFile self.tokensFileId = tokensFileId self.tokensCollectionName = tokensCollectionName @@ -212,6 +219,9 @@ extension iOS { /// Suffix for assets using template render mode. public var renderModeTemplateSuffix: String? + /// Design source kind override. When null, defaults to "figma". + public var sourceKind: Common.SourceKind? + /// Figma frame name to export from. public var figmaFrameName: String? @@ -252,6 +262,7 @@ extension iOS { renderModeDefaultSuffix: String?, renderModeOriginalSuffix: String?, renderModeTemplateSuffix: String?, + sourceKind: Common.SourceKind?, figmaFrameName: String?, figmaPageName: String?, figmaFileId: String?, @@ -272,6 +283,7 @@ extension iOS { self.renderModeDefaultSuffix = renderModeDefaultSuffix self.renderModeOriginalSuffix = renderModeOriginalSuffix self.renderModeTemplateSuffix = renderModeTemplateSuffix + self.sourceKind = sourceKind self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId @@ -332,6 +344,9 @@ extension iOS { /// Suffix for assets using template render mode. public var renderModeTemplateSuffix: String? + /// Design source kind override. When null, defaults to "figma". + public var sourceKind: Common.SourceKind? + /// Figma frame name to export from. public var figmaFrameName: String? @@ -374,6 +389,7 @@ extension iOS { renderModeDefaultSuffix: String?, renderModeOriginalSuffix: String?, renderModeTemplateSuffix: String?, + sourceKind: Common.SourceKind?, figmaFrameName: String?, figmaPageName: String?, figmaFileId: String?, @@ -396,6 +412,7 @@ extension iOS { self.renderModeDefaultSuffix = renderModeDefaultSuffix self.renderModeOriginalSuffix = renderModeOriginalSuffix self.renderModeTemplateSuffix = renderModeTemplateSuffix + self.sourceKind = sourceKind self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId diff --git a/Sources/ExFigConfig/SourceKindBridging.swift b/Sources/ExFigConfig/SourceKindBridging.swift new file mode 100644 index 00000000..5ea26ff1 --- /dev/null +++ b/Sources/ExFigConfig/SourceKindBridging.swift @@ -0,0 +1,17 @@ +import ExFigCore + +public extension Common.SourceKind { + /// Converts PKL `Common.SourceKind` to `ExFigCore.DesignSourceKind`. + /// + /// PKL uses kebab-case raw values ("tokens-file") while ExFigCore uses camelCase ("tokensFile"). + /// Cases with single-word names match directly; multi-word cases need explicit mapping. + var coreSourceKind: DesignSourceKind { + switch self { + case .figma: .figma + case .penpot: .penpot + case .tokensFile: .tokensFile + case .tokensStudio: .tokensStudio + case .sketchFile: .sketchFile + } + } +} diff --git a/Sources/ExFigConfig/VariablesSourceValidation.swift b/Sources/ExFigConfig/VariablesSourceValidation.swift index d2e57941..62df530a 100644 --- a/Sources/ExFigConfig/VariablesSourceValidation.swift +++ b/Sources/ExFigConfig/VariablesSourceValidation.swift @@ -6,19 +6,31 @@ public extension Common_VariablesSource { /// When `tokensFile` is set, bypasses Figma API validation and returns a local-file source. /// Otherwise, throws if required Figma fields (`tokensFileId`, `tokensCollectionName`, /// `lightModeName`) are nil or empty. + /// Resolves the design source kind with priority: explicit > auto-detect > default (.figma). + var resolvedSourceKind: DesignSourceKind { + if let explicit = sourceKind { + return explicit.coreSourceKind + } + if tokensFile != nil { + return .tokensFile + } + return .figma + } + func validatedColorsSourceInput() throws -> ColorsSourceInput { - // Local tokens file source — bypass Figma validation - if let tokensFile { + let kind = resolvedSourceKind + + if kind == .tokensFile { + guard let tokensFile else { + throw ColorsConfigError.missingTokensFileId + } + let config = TokensFileColorsConfig( + filePath: tokensFile.path, + groupFilter: tokensFile.groupFilter + ) return ColorsSourceInput( - tokensFilePath: tokensFile.path, - tokensFileGroupFilter: tokensFile.groupFilter, - tokensFileId: tokensFileId ?? "", - tokensCollectionName: tokensCollectionName ?? "", - lightModeName: lightModeName ?? "", - darkModeName: darkModeName, - lightHCModeName: lightHCModeName, - darkHCModeName: darkHCModeName, - primitivesModeName: primitivesModeName, + sourceKind: .tokensFile, + sourceConfig: config, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp ) @@ -34,14 +46,18 @@ public extension Common_VariablesSource { guard let lightModeName, !lightModeName.isEmpty else { throw ColorsConfigError.missingLightModeName } - return ColorsSourceInput( + let config = FigmaColorsConfig( tokensFileId: tokensFileId, tokensCollectionName: tokensCollectionName, lightModeName: lightModeName, darkModeName: darkModeName, lightHCModeName: lightHCModeName, darkHCModeName: darkHCModeName, - primitivesModeName: primitivesModeName, + primitivesModeName: primitivesModeName + ) + return ColorsSourceInput( + sourceKind: kind, + sourceConfig: config, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp ) diff --git a/Sources/ExFigCore/CLAUDE.md b/Sources/ExFigCore/CLAUDE.md index 2c33bb86..5459aca9 100644 --- a/Sources/ExFigCore/CLAUDE.md +++ b/Sources/ExFigCore/CLAUDE.md @@ -23,7 +23,17 @@ Exporter.export*(entries, platformConfig, context) **Context protocols** (`ColorsExportContext`, `IconsExportContext`, etc.) inject all I/O dependencies — Figma loading, downloading, format conversion — so exporters stay pure transform logic. -**Local tokens file support:** `ColorsSourceInput` has optional `tokensFilePath` and `tokensFileGroupFilter` fields. When `tokensFilePath` is set, colors are loaded from a local `.tokens.json` file (W3C Design Tokens v2 format) instead of the Figma API. +### Design Source Abstraction + +`Protocol/DesignSource.swift` defines per-asset-type source protocols: + +- `ColorsSource`, `ComponentsSource`, `TypographySource` — no `sourceKind` in protocols (clean contract) +- `DesignSourceKind` enum — dispatch discriminator (.figma, .penpot, .tokensFile, .tokensStudio, .sketchFile) +- `ColorsSourceConfig` protocol + `FigmaColorsConfig` / `TokensFileColorsConfig` — type-erased source-specific config +- `ColorsSourceInput` uses `sourceKind` + `sourceConfig: any ColorsSourceConfig` instead of flat fields +- `IconsSourceInput`, `ImagesSourceInput`, `TypographySourceInput` have `sourceKind` field (default `.figma`) + +Implementations live in `Sources/ExFigCLI/Source/` — `FigmaColorsSource`, `TokensFileColorsSource`, `FigmaComponentsSource`, `FigmaTypographySource`, `SourceFactory`. ### Domain Models diff --git a/Sources/ExFigCore/Protocol/DesignSource.swift b/Sources/ExFigCore/Protocol/DesignSource.swift new file mode 100644 index 00000000..4d2d2c02 --- /dev/null +++ b/Sources/ExFigCore/Protocol/DesignSource.swift @@ -0,0 +1,85 @@ +import Foundation + +// MARK: - Design Source Kind + +/// Identifies the design tool or data source for asset loading. +/// +/// Used by `SourceFactory` for dispatch. Source protocols do NOT expose this — +/// consumers call `loadColors()` etc. without knowing the source kind. +public enum DesignSourceKind: String, Sendable, CaseIterable { + case figma + case penpot + case tokensFile + case tokensStudio + case sketchFile +} + +// MARK: - Source Protocols + +/// Loads colors from a design source (Figma Variables, local .tokens.json, etc.). +public protocol ColorsSource: Sendable { + func loadColors(from input: ColorsSourceInput) async throws -> ColorsLoadOutput +} + +/// Loads icons and images from a design source (Figma components, Penpot, etc.). +public protocol ComponentsSource: Sendable { + func loadIcons(from input: IconsSourceInput) async throws -> IconsLoadOutput + func loadImages(from input: ImagesSourceInput) async throws -> ImagesLoadOutput +} + +/// Loads text styles from a design source (Figma styles, etc.). +public protocol TypographySource: Sendable { + func loadTypography(from input: TypographySourceInput) async throws -> TypographyLoadOutput +} + +// MARK: - Colors Source Config + +/// Marker protocol for source-specific colors configuration. +/// +/// Each design source has its own config type conforming to this protocol. +/// `ColorsSourceInput` holds `any ColorsSourceConfig`, and source implementations +/// cast it to their expected concrete type. +public protocol ColorsSourceConfig: Sendable {} + +/// Figma-specific colors configuration — fields for Variables API. +public struct FigmaColorsConfig: ColorsSourceConfig { + public let tokensFileId: String + public let tokensCollectionName: String + public let lightModeName: String + public let darkModeName: String? + public let lightHCModeName: String? + public let darkHCModeName: String? + public let primitivesModeName: String? + + public init( + tokensFileId: String, + tokensCollectionName: String, + lightModeName: String, + darkModeName: String? = nil, + lightHCModeName: String? = nil, + darkHCModeName: String? = nil, + primitivesModeName: String? = nil + ) { + self.tokensFileId = tokensFileId + self.tokensCollectionName = tokensCollectionName + self.lightModeName = lightModeName + self.darkModeName = darkModeName + self.lightHCModeName = lightHCModeName + self.darkHCModeName = darkHCModeName + self.primitivesModeName = primitivesModeName + } +} + +/// Tokens-file-specific colors configuration — local .tokens.json path + optional group filter. +public struct TokensFileColorsConfig: ColorsSourceConfig { + public let filePath: String + public let groupFilter: String? + + public init( + filePath: String, + groupFilter: String? = nil + ) { + self.filePath = filePath + self.groupFilter = groupFilter + } +} diff --git a/Sources/ExFigCore/Protocol/ExportContext.swift b/Sources/ExFigCore/Protocol/ExportContext.swift index 47df5ec2..12129e2f 100644 --- a/Sources/ExFigCore/Protocol/ExportContext.swift +++ b/Sources/ExFigCore/Protocol/ExportContext.swift @@ -91,51 +91,25 @@ public protocol ColorsExportContext: ExportContext { ) throws -> ColorsProcessResult } -/// Input for loading colors — either from Figma Variables API or a local .tokens.json file. +/// Input for loading colors from any design source. /// -/// When `tokensFilePath` is set, the export pipeline reads colors from the local file -/// (bypassing Figma API). Otherwise, `tokensFileId` + `tokensCollectionName` + `lightModeName` -/// are used to fetch from Figma Variables. +/// Source-specific fields live in `sourceConfig` (see `ColorsSourceConfig`). +/// Shared fields (`nameValidateRegexp`, `nameReplaceRegexp`) apply to all sources. +/// Dispatch is handled by `SourceFactory` using `sourceKind`. public struct ColorsSourceInput: Sendable { - public let tokensFilePath: String? - public let tokensFileGroupFilter: String? - public let tokensFileId: String - public let tokensCollectionName: String - public let lightModeName: String - public let darkModeName: String? - public let lightHCModeName: String? - public let darkHCModeName: String? - public let primitivesModeName: String? + public let sourceKind: DesignSourceKind + public let sourceConfig: any ColorsSourceConfig public let nameValidateRegexp: String? public let nameReplaceRegexp: String? - /// Whether this source input uses a local tokens file. - public var isLocalTokensFile: Bool { - tokensFilePath != nil - } - public init( - tokensFilePath: String? = nil, - tokensFileGroupFilter: String? = nil, - tokensFileId: String, - tokensCollectionName: String, - lightModeName: String, - darkModeName: String? = nil, - lightHCModeName: String? = nil, - darkHCModeName: String? = nil, - primitivesModeName: String? = nil, + sourceKind: DesignSourceKind, + sourceConfig: any ColorsSourceConfig, nameValidateRegexp: String? = nil, nameReplaceRegexp: String? = nil ) { - self.tokensFilePath = tokensFilePath - self.tokensFileGroupFilter = tokensFileGroupFilter - self.tokensFileId = tokensFileId - self.tokensCollectionName = tokensCollectionName - self.lightModeName = lightModeName - self.darkModeName = darkModeName - self.lightHCModeName = lightHCModeName - self.darkHCModeName = darkHCModeName - self.primitivesModeName = primitivesModeName + self.sourceKind = sourceKind + self.sourceConfig = sourceConfig self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } diff --git a/Sources/ExFigCore/Protocol/IconsExportContext.swift b/Sources/ExFigCore/Protocol/IconsExportContext.swift index e40ee201..7bf81642 100644 --- a/Sources/ExFigCore/Protocol/IconsExportContext.swift +++ b/Sources/ExFigCore/Protocol/IconsExportContext.swift @@ -60,6 +60,9 @@ public protocol IconsExportContext: ExportContext { /// Input for loading icons from Figma. public struct IconsSourceInput: Sendable { + /// Design source kind (default: `.figma`). + public let sourceKind: DesignSourceKind + /// Optional entry-level Figma file ID override. public let figmaFileId: String? @@ -98,6 +101,7 @@ public struct IconsSourceInput: Sendable { public let nameReplaceRegexp: String? public init( + sourceKind: DesignSourceKind = .figma, figmaFileId: String? = nil, darkFileId: String? = nil, frameName: String, @@ -113,6 +117,7 @@ public struct IconsSourceInput: Sendable { nameValidateRegexp: String? = nil, nameReplaceRegexp: String? = nil ) { + self.sourceKind = sourceKind self.figmaFileId = figmaFileId self.darkFileId = darkFileId self.frameName = frameName diff --git a/Sources/ExFigCore/Protocol/ImagesExportContext.swift b/Sources/ExFigCore/Protocol/ImagesExportContext.swift index 0ef9a182..b245667c 100644 --- a/Sources/ExFigCore/Protocol/ImagesExportContext.swift +++ b/Sources/ExFigCore/Protocol/ImagesExportContext.swift @@ -162,6 +162,9 @@ public extension ImagesExportContext { /// Input for loading images from Figma. public struct ImagesSourceInput: Sendable { + /// Design source kind (default: `.figma`). + public let sourceKind: DesignSourceKind + /// Optional entry-level Figma file ID override. public let figmaFileId: String? @@ -197,6 +200,7 @@ public struct ImagesSourceInput: Sendable { public let nameReplaceRegexp: String? public init( + sourceKind: DesignSourceKind = .figma, figmaFileId: String? = nil, darkFileId: String? = nil, frameName: String, @@ -209,6 +213,7 @@ public struct ImagesSourceInput: Sendable { nameValidateRegexp: String? = nil, nameReplaceRegexp: String? = nil ) { + self.sourceKind = sourceKind self.figmaFileId = figmaFileId self.darkFileId = darkFileId self.frameName = frameName diff --git a/Sources/ExFigCore/Protocol/TypographyExportContext.swift b/Sources/ExFigCore/Protocol/TypographyExportContext.swift index e535d350..c7771d90 100644 --- a/Sources/ExFigCore/Protocol/TypographyExportContext.swift +++ b/Sources/ExFigCore/Protocol/TypographyExportContext.swift @@ -36,6 +36,9 @@ public protocol TypographyExportContext: ExportContext { /// Input for loading typography from Figma. public struct TypographySourceInput: Sendable { + /// Design source kind (default: `.figma`). + public let sourceKind: DesignSourceKind + /// Figma file ID containing text styles. public let fileId: String @@ -43,9 +46,11 @@ public struct TypographySourceInput: Sendable { public let timeout: TimeInterval? public init( + sourceKind: DesignSourceKind = .figma, fileId: String, timeout: TimeInterval? = nil ) { + self.sourceKind = sourceKind self.fileId = fileId self.timeout = timeout } diff --git a/Tests/ExFigTests/Input/EnumBridgingTests.swift b/Tests/ExFigTests/Input/EnumBridgingTests.swift index dd8081c5..eae4c5b3 100644 --- a/Tests/ExFigTests/Input/EnumBridgingTests.swift +++ b/Tests/ExFigTests/Input/EnumBridgingTests.swift @@ -67,6 +67,7 @@ final class EnumBridgingTests: XCTestCase { templatesPath: nil, syncCodeSyntax: nil, codeSyntaxTemplate: nil, + sourceKind: nil, tokensFile: nil, tokensFileId: nil, tokensCollectionName: nil, @@ -112,6 +113,7 @@ final class EnumBridgingTests: XCTestCase { renderModeDefaultSuffix: nil, renderModeOriginalSuffix: nil, renderModeTemplateSuffix: nil, + sourceKind: nil, figmaFrameName: nil, figmaPageName: nil, figmaFileId: nil, @@ -155,6 +157,7 @@ final class EnumBridgingTests: XCTestCase { renderModeDefaultSuffix: nil, renderModeOriginalSuffix: nil, renderModeTemplateSuffix: nil, + sourceKind: nil, figmaFrameName: nil, figmaPageName: nil, figmaFileId: nil, @@ -194,6 +197,7 @@ final class EnumBridgingTests: XCTestCase { strictPathValidation: nil, codeConnectPackageName: nil, codeConnectKotlin: nil, + sourceKind: nil, figmaFrameName: nil, figmaPageName: nil, figmaFileId: nil, @@ -221,6 +225,7 @@ final class EnumBridgingTests: XCTestCase { strictPathValidation: nil, codeConnectPackageName: nil, codeConnectKotlin: nil, + sourceKind: nil, figmaFrameName: nil, figmaPageName: nil, figmaFileId: nil, @@ -254,6 +259,7 @@ final class EnumBridgingTests: XCTestCase { sourceFormat: nil, nameStyle: pklStyle, codeConnectKotlin: nil, + sourceKind: nil, figmaFrameName: nil, figmaPageName: nil, figmaFileId: nil, @@ -279,6 +285,7 @@ final class EnumBridgingTests: XCTestCase { sourceFormat: nil, nameStyle: nil, codeConnectKotlin: nil, + sourceKind: nil, figmaFrameName: nil, figmaPageName: nil, figmaFileId: nil, @@ -341,6 +348,7 @@ final class EnumBridgingTests: XCTestCase { renderModeDefaultSuffix: nil, renderModeOriginalSuffix: nil, renderModeTemplateSuffix: nil, + sourceKind: nil, figmaFrameName: nil, figmaPageName: nil, figmaFileId: nil, @@ -373,6 +381,7 @@ final class EnumBridgingTests: XCTestCase { renderModeDefaultSuffix: nil, renderModeOriginalSuffix: nil, renderModeTemplateSuffix: nil, + sourceKind: nil, figmaFrameName: nil, figmaPageName: nil, figmaFileId: nil, @@ -435,6 +444,7 @@ final class EnumBridgingTests: XCTestCase { templatesPath: nil, syncCodeSyntax: nil, codeSyntaxTemplate: nil, + sourceKind: nil, tokensFile: nil, tokensFileId: nil, tokensCollectionName: "Collection", lightModeName: "Light", @@ -463,6 +473,7 @@ final class EnumBridgingTests: XCTestCase { templatesPath: nil, syncCodeSyntax: nil, codeSyntaxTemplate: nil, + sourceKind: nil, tokensFile: nil, tokensFileId: "", tokensCollectionName: "Collection", lightModeName: "Light", @@ -491,6 +502,7 @@ final class EnumBridgingTests: XCTestCase { templatesPath: nil, syncCodeSyntax: nil, codeSyntaxTemplate: nil, + sourceKind: nil, tokensFile: nil, tokensFileId: "file123", tokensCollectionName: "Collection", lightModeName: "Light", @@ -502,10 +514,12 @@ final class EnumBridgingTests: XCTestCase { nameReplaceRegexp: nil ) let sourceInput = try entry.validatedColorsSourceInput() - XCTAssertEqual(sourceInput.tokensFileId, "file123") - XCTAssertEqual(sourceInput.tokensCollectionName, "Collection") - XCTAssertEqual(sourceInput.lightModeName, "Light") - XCTAssertEqual(sourceInput.darkModeName, "Dark") + XCTAssertEqual(sourceInput.sourceKind, .figma) + let figmaConfig = try XCTUnwrap(sourceInput.sourceConfig as? FigmaColorsConfig) + XCTAssertEqual(figmaConfig.tokensFileId, "file123") + XCTAssertEqual(figmaConfig.tokensCollectionName, "Collection") + XCTAssertEqual(figmaConfig.lightModeName, "Light") + XCTAssertEqual(figmaConfig.darkModeName, "Dark") } // MARK: - TokensFile Source Validation @@ -523,6 +537,7 @@ final class EnumBridgingTests: XCTestCase { templatesPath: nil, syncCodeSyntax: nil, codeSyntaxTemplate: nil, + sourceKind: nil, tokensFile: Common.TokensFile(path: "tokens.json", groupFilter: nil), tokensFileId: nil, tokensCollectionName: nil, @@ -535,9 +550,10 @@ final class EnumBridgingTests: XCTestCase { nameReplaceRegexp: nil ) let sourceInput = try entry.validatedColorsSourceInput() - XCTAssertTrue(sourceInput.isLocalTokensFile) - XCTAssertEqual(sourceInput.tokensFilePath, "tokens.json") - XCTAssertNil(sourceInput.tokensFileGroupFilter) + XCTAssertEqual(sourceInput.sourceKind, .tokensFile) + let tokensConfig = try XCTUnwrap(sourceInput.sourceConfig as? TokensFileColorsConfig) + XCTAssertEqual(tokensConfig.filePath, "tokens.json") + XCTAssertNil(tokensConfig.groupFilter) } func testTokensFileSourceWithGroupFilter() throws { @@ -553,6 +569,7 @@ final class EnumBridgingTests: XCTestCase { templatesPath: nil, syncCodeSyntax: nil, codeSyntaxTemplate: nil, + sourceKind: nil, tokensFile: Common.TokensFile(path: "design-tokens.json", groupFilter: "Brand.Colors"), tokensFileId: nil, tokensCollectionName: nil, @@ -565,9 +582,10 @@ final class EnumBridgingTests: XCTestCase { nameReplaceRegexp: nil ) let sourceInput = try entry.validatedColorsSourceInput() - XCTAssertTrue(sourceInput.isLocalTokensFile) - XCTAssertEqual(sourceInput.tokensFilePath, "design-tokens.json") - XCTAssertEqual(sourceInput.tokensFileGroupFilter, "Brand.Colors") + XCTAssertEqual(sourceInput.sourceKind, .tokensFile) + let tokensConfig = try XCTUnwrap(sourceInput.sourceConfig as? TokensFileColorsConfig) + XCTAssertEqual(tokensConfig.filePath, "design-tokens.json") + XCTAssertEqual(tokensConfig.groupFilter, "Brand.Colors") } func testWithoutTokensFileFallsBackToFigmaValidation() { @@ -583,6 +601,7 @@ final class EnumBridgingTests: XCTestCase { templatesPath: nil, syncCodeSyntax: nil, codeSyntaxTemplate: nil, + sourceKind: nil, tokensFile: nil, tokensFileId: nil, tokensCollectionName: nil, @@ -611,6 +630,7 @@ final class EnumBridgingTests: XCTestCase { composePackageName: nil, colorKotlin: nil, themeAttributes: nil, + sourceKind: nil, tokensFile: nil, tokensFileId: nil, tokensCollectionName: "Collection", lightModeName: "Light", @@ -636,6 +656,7 @@ final class EnumBridgingTests: XCTestCase { composePackageName: nil, colorKotlin: nil, themeAttributes: nil, + sourceKind: nil, tokensFile: nil, tokensFileId: "", tokensCollectionName: "Collection", lightModeName: "Light", @@ -661,6 +682,7 @@ final class EnumBridgingTests: XCTestCase { composePackageName: nil, colorKotlin: nil, themeAttributes: nil, + sourceKind: nil, tokensFile: nil, tokensFileId: "file456", tokensCollectionName: "Colors", lightModeName: "Light", @@ -672,10 +694,12 @@ final class EnumBridgingTests: XCTestCase { nameReplaceRegexp: nil ) let sourceInput = try entry.validatedColorsSourceInput() - XCTAssertEqual(sourceInput.tokensFileId, "file456") - XCTAssertEqual(sourceInput.tokensCollectionName, "Colors") - XCTAssertEqual(sourceInput.lightModeName, "Light") - XCTAssertEqual(sourceInput.darkModeName, "Dark") + XCTAssertEqual(sourceInput.sourceKind, .figma) + let figmaConfig = try XCTUnwrap(sourceInput.sourceConfig as? FigmaColorsConfig) + XCTAssertEqual(figmaConfig.tokensFileId, "file456") + XCTAssertEqual(figmaConfig.tokensCollectionName, "Colors") + XCTAssertEqual(figmaConfig.lightModeName, "Light") + XCTAssertEqual(figmaConfig.darkModeName, "Dark") } // MARK: - iOS Missing tokensCollectionName / lightModeName @@ -686,7 +710,8 @@ final class EnumBridgingTests: XCTestCase { groupUsingNamespace: nil, assetsFolderProvidesNamespace: nil, colorSwift: nil, swiftuiColorSwift: nil, xcassetsPath: nil, templatesPath: nil, syncCodeSyntax: nil, codeSyntaxTemplate: nil, - tokensFile: nil, tokensFileId: "file123", tokensCollectionName: nil, lightModeName: "Light", + sourceKind: nil, tokensFile: nil, tokensFileId: "file123", tokensCollectionName: nil, + lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -701,7 +726,7 @@ final class EnumBridgingTests: XCTestCase { groupUsingNamespace: nil, assetsFolderProvidesNamespace: nil, colorSwift: nil, swiftuiColorSwift: nil, xcassetsPath: nil, templatesPath: nil, syncCodeSyntax: nil, codeSyntaxTemplate: nil, - tokensFile: nil, tokensFileId: "file123", tokensCollectionName: "", lightModeName: "Light", + sourceKind: nil, tokensFile: nil, tokensFileId: "file123", tokensCollectionName: "", lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -716,7 +741,8 @@ final class EnumBridgingTests: XCTestCase { groupUsingNamespace: nil, assetsFolderProvidesNamespace: nil, colorSwift: nil, swiftuiColorSwift: nil, xcassetsPath: nil, templatesPath: nil, syncCodeSyntax: nil, codeSyntaxTemplate: nil, - tokensFile: nil, tokensFileId: "file123", tokensCollectionName: "Collection", lightModeName: nil, + sourceKind: nil, tokensFile: nil, tokensFileId: "file123", tokensCollectionName: "Collection", + lightModeName: nil, darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -731,7 +757,8 @@ final class EnumBridgingTests: XCTestCase { groupUsingNamespace: nil, assetsFolderProvidesNamespace: nil, colorSwift: nil, swiftuiColorSwift: nil, xcassetsPath: nil, templatesPath: nil, syncCodeSyntax: nil, codeSyntaxTemplate: nil, - tokensFile: nil, tokensFileId: "file123", tokensCollectionName: "Collection", lightModeName: "", + sourceKind: nil, tokensFile: nil, tokensFileId: "file123", tokensCollectionName: "Collection", + lightModeName: "", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -747,7 +774,8 @@ final class EnumBridgingTests: XCTestCase { mainRes: nil, mainSrc: nil, templatesPath: nil, xmlOutputFileName: nil, xmlDisabled: nil, composePackageName: nil, colorKotlin: nil, themeAttributes: nil, - tokensFile: nil, tokensFileId: "file456", tokensCollectionName: nil, lightModeName: "Light", + sourceKind: nil, tokensFile: nil, tokensFileId: "file456", tokensCollectionName: nil, + lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -761,7 +789,7 @@ final class EnumBridgingTests: XCTestCase { mainRes: nil, mainSrc: nil, templatesPath: nil, xmlOutputFileName: nil, xmlDisabled: nil, composePackageName: nil, colorKotlin: nil, themeAttributes: nil, - tokensFile: nil, tokensFileId: "file456", tokensCollectionName: "", lightModeName: "Light", + sourceKind: nil, tokensFile: nil, tokensFileId: "file456", tokensCollectionName: "", lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -775,7 +803,8 @@ final class EnumBridgingTests: XCTestCase { mainRes: nil, mainSrc: nil, templatesPath: nil, xmlOutputFileName: nil, xmlDisabled: nil, composePackageName: nil, colorKotlin: nil, themeAttributes: nil, - tokensFile: nil, tokensFileId: "file456", tokensCollectionName: "Colors", lightModeName: nil, + sourceKind: nil, tokensFile: nil, tokensFileId: "file456", tokensCollectionName: "Colors", + lightModeName: nil, darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -789,7 +818,8 @@ final class EnumBridgingTests: XCTestCase { mainRes: nil, mainSrc: nil, templatesPath: nil, xmlOutputFileName: nil, xmlDisabled: nil, composePackageName: nil, colorKotlin: nil, themeAttributes: nil, - tokensFile: nil, tokensFileId: "file456", tokensCollectionName: "Colors", lightModeName: "", + sourceKind: nil, tokensFile: nil, tokensFileId: "file456", tokensCollectionName: "Colors", + lightModeName: "", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -803,7 +833,8 @@ final class EnumBridgingTests: XCTestCase { func testFlutterColorsEntryThrowsOnMissingTokensFileId() { let entry = Flutter.ColorsEntry( templatesPath: nil, output: nil, className: nil, - tokensFile: nil, tokensFileId: nil, tokensCollectionName: "Collection", lightModeName: "Light", + sourceKind: nil, tokensFile: nil, tokensFileId: nil, tokensCollectionName: "Collection", + lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -815,7 +846,8 @@ final class EnumBridgingTests: XCTestCase { func testFlutterColorsEntryThrowsOnEmptyTokensFileId() { let entry = Flutter.ColorsEntry( templatesPath: nil, output: nil, className: nil, - tokensFile: nil, tokensFileId: "", tokensCollectionName: "Collection", lightModeName: "Light", + sourceKind: nil, tokensFile: nil, tokensFileId: "", tokensCollectionName: "Collection", + lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -827,7 +859,8 @@ final class EnumBridgingTests: XCTestCase { func testFlutterColorsEntryThrowsOnMissingTokensCollectionName() { let entry = Flutter.ColorsEntry( templatesPath: nil, output: nil, className: nil, - tokensFile: nil, tokensFileId: "file789", tokensCollectionName: nil, lightModeName: "Light", + sourceKind: nil, tokensFile: nil, tokensFileId: "file789", tokensCollectionName: nil, + lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -839,7 +872,7 @@ final class EnumBridgingTests: XCTestCase { func testFlutterColorsEntryThrowsOnEmptyTokensCollectionName() { let entry = Flutter.ColorsEntry( templatesPath: nil, output: nil, className: nil, - tokensFile: nil, tokensFileId: "file789", tokensCollectionName: "", lightModeName: "Light", + sourceKind: nil, tokensFile: nil, tokensFileId: "file789", tokensCollectionName: "", lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -851,7 +884,8 @@ final class EnumBridgingTests: XCTestCase { func testFlutterColorsEntryThrowsOnMissingLightModeName() { let entry = Flutter.ColorsEntry( templatesPath: nil, output: nil, className: nil, - tokensFile: nil, tokensFileId: "file789", tokensCollectionName: "Collection", lightModeName: nil, + sourceKind: nil, tokensFile: nil, tokensFileId: "file789", tokensCollectionName: "Collection", + lightModeName: nil, darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -863,7 +897,8 @@ final class EnumBridgingTests: XCTestCase { func testFlutterColorsEntryThrowsOnEmptyLightModeName() { let entry = Flutter.ColorsEntry( templatesPath: nil, output: nil, className: nil, - tokensFile: nil, tokensFileId: "file789", tokensCollectionName: "Collection", lightModeName: "", + sourceKind: nil, tokensFile: nil, tokensFileId: "file789", tokensCollectionName: "Collection", + lightModeName: "", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -875,15 +910,18 @@ final class EnumBridgingTests: XCTestCase { func testFlutterColorsEntryValidatesSuccessfully() throws { let entry = Flutter.ColorsEntry( templatesPath: nil, output: nil, className: nil, - tokensFile: nil, tokensFileId: "file789", tokensCollectionName: "Colors", lightModeName: "Light", + sourceKind: nil, tokensFile: nil, tokensFileId: "file789", tokensCollectionName: "Colors", + lightModeName: "Light", darkModeName: "Dark", lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) let sourceInput = try entry.validatedColorsSourceInput() - XCTAssertEqual(sourceInput.tokensFileId, "file789") - XCTAssertEqual(sourceInput.tokensCollectionName, "Colors") - XCTAssertEqual(sourceInput.lightModeName, "Light") - XCTAssertEqual(sourceInput.darkModeName, "Dark") + XCTAssertEqual(sourceInput.sourceKind, .figma) + let figmaConfig = try XCTUnwrap(sourceInput.sourceConfig as? FigmaColorsConfig) + XCTAssertEqual(figmaConfig.tokensFileId, "file789") + XCTAssertEqual(figmaConfig.tokensCollectionName, "Colors") + XCTAssertEqual(figmaConfig.lightModeName, "Light") + XCTAssertEqual(figmaConfig.darkModeName, "Dark") } // MARK: - Web ColorsSourceInput Validation @@ -892,7 +930,8 @@ final class EnumBridgingTests: XCTestCase { let entry = Web.ColorsEntry( output: nil, templatesPath: nil, outputDirectory: nil, cssFileName: nil, tsFileName: nil, jsonFileName: nil, - tokensFile: nil, tokensFileId: nil, tokensCollectionName: "Collection", lightModeName: "Light", + sourceKind: nil, tokensFile: nil, tokensFileId: nil, tokensCollectionName: "Collection", + lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -905,7 +944,8 @@ final class EnumBridgingTests: XCTestCase { let entry = Web.ColorsEntry( output: nil, templatesPath: nil, outputDirectory: nil, cssFileName: nil, tsFileName: nil, jsonFileName: nil, - tokensFile: nil, tokensFileId: "", tokensCollectionName: "Collection", lightModeName: "Light", + sourceKind: nil, tokensFile: nil, tokensFileId: "", tokensCollectionName: "Collection", + lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -918,7 +958,8 @@ final class EnumBridgingTests: XCTestCase { let entry = Web.ColorsEntry( output: nil, templatesPath: nil, outputDirectory: nil, cssFileName: nil, tsFileName: nil, jsonFileName: nil, - tokensFile: nil, tokensFileId: "fileABC", tokensCollectionName: nil, lightModeName: "Light", + sourceKind: nil, tokensFile: nil, tokensFileId: "fileABC", tokensCollectionName: nil, + lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -931,7 +972,7 @@ final class EnumBridgingTests: XCTestCase { let entry = Web.ColorsEntry( output: nil, templatesPath: nil, outputDirectory: nil, cssFileName: nil, tsFileName: nil, jsonFileName: nil, - tokensFile: nil, tokensFileId: "fileABC", tokensCollectionName: "", lightModeName: "Light", + sourceKind: nil, tokensFile: nil, tokensFileId: "fileABC", tokensCollectionName: "", lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -944,7 +985,8 @@ final class EnumBridgingTests: XCTestCase { let entry = Web.ColorsEntry( output: nil, templatesPath: nil, outputDirectory: nil, cssFileName: nil, tsFileName: nil, jsonFileName: nil, - tokensFile: nil, tokensFileId: "fileABC", tokensCollectionName: "Collection", lightModeName: nil, + sourceKind: nil, tokensFile: nil, tokensFileId: "fileABC", tokensCollectionName: "Collection", + lightModeName: nil, darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -957,7 +999,8 @@ final class EnumBridgingTests: XCTestCase { let entry = Web.ColorsEntry( output: nil, templatesPath: nil, outputDirectory: nil, cssFileName: nil, tsFileName: nil, jsonFileName: nil, - tokensFile: nil, tokensFileId: "fileABC", tokensCollectionName: "Collection", lightModeName: "", + sourceKind: nil, tokensFile: nil, tokensFileId: "fileABC", tokensCollectionName: "Collection", + lightModeName: "", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -970,14 +1013,17 @@ final class EnumBridgingTests: XCTestCase { let entry = Web.ColorsEntry( output: nil, templatesPath: nil, outputDirectory: nil, cssFileName: nil, tsFileName: nil, jsonFileName: nil, - tokensFile: nil, tokensFileId: "fileABC", tokensCollectionName: "Colors", lightModeName: "Light", + sourceKind: nil, tokensFile: nil, tokensFileId: "fileABC", tokensCollectionName: "Colors", + lightModeName: "Light", darkModeName: "Dark", lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) let sourceInput = try entry.validatedColorsSourceInput() - XCTAssertEqual(sourceInput.tokensFileId, "fileABC") - XCTAssertEqual(sourceInput.tokensCollectionName, "Colors") - XCTAssertEqual(sourceInput.lightModeName, "Light") - XCTAssertEqual(sourceInput.darkModeName, "Dark") + XCTAssertEqual(sourceInput.sourceKind, .figma) + let figmaConfig = try XCTUnwrap(sourceInput.sourceConfig as? FigmaColorsConfig) + XCTAssertEqual(figmaConfig.tokensFileId, "fileABC") + XCTAssertEqual(figmaConfig.tokensCollectionName, "Colors") + XCTAssertEqual(figmaConfig.lightModeName, "Light") + XCTAssertEqual(figmaConfig.darkModeName, "Dark") } } diff --git a/openspec/changes/design-source-abstraction/tasks.md b/openspec/changes/design-source-abstraction/tasks.md index 1cf36166..392db5cf 100644 --- a/openspec/changes/design-source-abstraction/tasks.md +++ b/openspec/changes/design-source-abstraction/tasks.md @@ -1,81 +1,81 @@ ## 1. Core Protocols & Types (ExFigCore) -- [ ] 1.1 Create `DesignSourceKind` enum in `Sources/ExFigCore/Protocol/DesignSource.swift` (figma, penpot, tokensFile, tokensStudio, sketchFile) -- [ ] 1.2 Define `ColorsSource` protocol in the same file (no `sourceKind` property) -- [ ] 1.3 Define `ComponentsSource` protocol (loadIcons + loadImages, no `sourceKind` property) -- [ ] 1.4 Define `TypographySource` protocol (no `sourceKind` property) -- [ ] 1.5 Define `ColorsSourceConfig` protocol in the same file -- [ ] 1.6 Create `FigmaColorsConfig` struct conforming to `ColorsSourceConfig` — fields extracted from `ColorsSourceInput`: `tokensFileId`, `tokensCollectionName`, `lightModeName`, `darkModeName`, `lightHCModeName`, `darkHCModeName`, `primitivesModeName` -- [ ] 1.7 Create `TokensFileColorsConfig` struct conforming to `ColorsSourceConfig` — fields: `filePath`, `groupFilter` -- [ ] 1.8 Refactor `ColorsSourceInput` — replace Figma/tokens-file fields with `sourceKind: DesignSourceKind` + `sourceConfig: any ColorsSourceConfig`. Keep shared fields: `nameValidateRegexp`, `nameReplaceRegexp`. Remove `isLocalTokensFile` computed property -- [ ] 1.9 Add `sourceKind: DesignSourceKind` field (default `.figma`) to `IconsSourceInput` -- [ ] 1.10 Add `sourceKind: DesignSourceKind` field (default `.figma`) to `ImagesSourceInput` -- [ ] 1.11 Add `sourceKind: DesignSourceKind` field (default `.figma`) to `TypographySourceInput` -- [ ] 1.12 Update all `ColorsSourceInput` construction sites — create `FigmaColorsConfig`/`TokensFileColorsConfig` and pass as `sourceConfig` -- [ ] 1.13 Verify ExFigCore compiles without FigmaAPI import (`./bin/mise run build`) +- [x] 1.1 Create `DesignSourceKind` enum in `Sources/ExFigCore/Protocol/DesignSource.swift` (figma, penpot, tokensFile, tokensStudio, sketchFile) +- [x] 1.2 Define `ColorsSource` protocol in the same file (no `sourceKind` property) +- [x] 1.3 Define `ComponentsSource` protocol (loadIcons + loadImages, no `sourceKind` property) +- [x] 1.4 Define `TypographySource` protocol (no `sourceKind` property) +- [x] 1.5 Define `ColorsSourceConfig` protocol in the same file +- [x] 1.6 Create `FigmaColorsConfig` struct conforming to `ColorsSourceConfig` — fields extracted from `ColorsSourceInput`: `tokensFileId`, `tokensCollectionName`, `lightModeName`, `darkModeName`, `lightHCModeName`, `darkHCModeName`, `primitivesModeName` +- [x] 1.7 Create `TokensFileColorsConfig` struct conforming to `ColorsSourceConfig` — fields: `filePath`, `groupFilter` +- [x] 1.8 Refactor `ColorsSourceInput` — replace Figma/tokens-file fields with `sourceKind: DesignSourceKind` + `sourceConfig: any ColorsSourceConfig`. Keep shared fields: `nameValidateRegexp`, `nameReplaceRegexp`. Remove `isLocalTokensFile` computed property +- [x] 1.9 Add `sourceKind: DesignSourceKind` field (default `.figma`) to `IconsSourceInput` +- [x] 1.10 Add `sourceKind: DesignSourceKind` field (default `.figma`) to `ImagesSourceInput` +- [x] 1.11 Add `sourceKind: DesignSourceKind` field (default `.figma`) to `TypographySourceInput` +- [x] 1.12 Update all `ColorsSourceInput` construction sites — create `FigmaColorsConfig`/`TokensFileColorsConfig` and pass as `sourceConfig` +- [x] 1.13 Verify ExFigCore compiles without FigmaAPI import (`./bin/mise run build`) ## 2. Figma Source Implementations (ExFigCLI) -- [ ] 2.1 Create `Sources/ExFigCLI/Source/FigmaColorsSource.swift` — extract `loadColorsFromFigma()` from `ColorsExportContextImpl` -- [ ] 2.2 Create `Sources/ExFigCLI/Source/TokensFileColorsSource.swift` — extract `loadColorsFromTokensFile()` from `ColorsExportContextImpl` (including darkModeName warning logic) -- [ ] 2.3 Create `Sources/ExFigCLI/Source/FigmaComponentsSource.swift` — wrap `IconsLoader`/`ImagesLoader` via `ImageLoaderBase` -- [ ] 2.4 Create `Sources/ExFigCLI/Source/FigmaTypographySource.swift` — wrap `TextStylesLoader` -- [ ] 2.5 Create `Sources/ExFigCLI/Source/SourceFactory.swift` — centralized factory for creating source instances by `DesignSourceKind` -- [ ] 2.6 Verify all source implementations compile (`./bin/mise run build`) +- [x] 2.1 Create `Sources/ExFigCLI/Source/FigmaColorsSource.swift` — extract `loadColorsFromFigma()` from `ColorsExportContextImpl` +- [x] 2.2 Create `Sources/ExFigCLI/Source/TokensFileColorsSource.swift` — extract `loadColorsFromTokensFile()` from `ColorsExportContextImpl` (including darkModeName warning logic) +- [x] 2.3 Create `Sources/ExFigCLI/Source/FigmaComponentsSource.swift` — wrap `IconsLoader`/`ImagesLoader` via `ImageLoaderBase` +- [x] 2.4 Create `Sources/ExFigCLI/Source/FigmaTypographySource.swift` — wrap `TextStylesLoader` +- [x] 2.5 Create `Sources/ExFigCLI/Source/SourceFactory.swift` — centralized factory for creating source instances by `DesignSourceKind` +- [x] 2.6 Verify all source implementations compile (`./bin/mise run build`) ## 3. Context Refactoring (ExFigCLI) -- [ ] 3.1 Refactor `ColorsExportContextImpl` — accept `colorsSource: any ColorsSource`, delegate `loadColors()`. Remove inline tokens-file/Figma dispatch and darkModeName warning -- [ ] 3.2 Refactor `IconsExportContextImpl` — accept `componentsSource: any ComponentsSource` alongside existing `client`. Basic `loadIcons()` delegates to `componentsSource`. `loadIconsWithGranularCache()` retains direct `client` usage -- [ ] 3.3 Refactor `ImagesExportContextImpl` — same pattern as Icons (componentsSource + client coexist) -- [ ] 3.4 Refactor `TypographyExportContextImpl` — accept `typographySource: any TypographySource`, delegate `loadTypography()` -- [ ] 3.5 Verify all context implementations compile (`./bin/mise run build`) +- [x] 3.1 Refactor `ColorsExportContextImpl` — accept `colorsSource: any ColorsSource`, delegate `loadColors()`. Remove inline tokens-file/Figma dispatch and darkModeName warning +- [x] 3.2 Refactor `IconsExportContextImpl` — accept `componentsSource: any ComponentsSource` alongside existing `client`. Basic `loadIcons()` delegates to `componentsSource`. `loadIconsWithGranularCache()` retains direct `client` usage +- [x] 3.3 Refactor `ImagesExportContextImpl` — same pattern as Icons (componentsSource + client coexist) +- [x] 3.4 Refactor `TypographyExportContextImpl` — accept `typographySource: any TypographySource`, delegate `loadTypography()` +- [x] 3.5 Verify all context implementations compile (`./bin/mise run build`) ## 4. Source Factories in Subcommands -- [ ] 4.1 Update `Sources/ExFigCLI/Subcommands/Export/PluginColorsExport.swift` — use `SourceFactory.createColorsSource()`, pass to `ColorsExportContextImpl` -- [ ] 4.2 Update `Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift` — use `SourceFactory.createComponentsSource()`, pass to `IconsExportContextImpl` -- [ ] 4.3 Update `Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift` — use `SourceFactory.createComponentsSource()`, pass to `ImagesExportContextImpl` -- [ ] 4.4 Update `Sources/ExFigCLI/Subcommands/Export/PluginTypographyExport.swift` — use `SourceFactory.createTypographySource()`, pass to `TypographyExportContextImpl` -- [ ] 4.5 Update platform export orchestrators (`iOSColorsExport`, `AndroidColorsExport`, `FlutterColorsExport`, `WebColorsExport`, `iOSImagesExport`, `AndroidImagesExport`, `FlutterImagesExport`, `WebImagesExport`) to pass source through if they construct contexts -- [ ] 4.6 Add `ExFigError.unsupportedSourceKind(DesignSourceKind)` for penpot, tokensStudio, sketchFile +- [x] 4.1 Update `Sources/ExFigCLI/Subcommands/Export/PluginColorsExport.swift` — use `SourceFactory.createColorsSource()`, pass to `ColorsExportContextImpl` +- [x] 4.2 Update `Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift` — use `SourceFactory.createComponentsSource()`, pass to `IconsExportContextImpl` +- [x] 4.3 Update `Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift` — use `SourceFactory.createComponentsSource()`, pass to `ImagesExportContextImpl` +- [x] 4.4 Update `Sources/ExFigCLI/Subcommands/Export/PluginTypographyExport.swift` — use `SourceFactory.createTypographySource()`, pass to `TypographyExportContextImpl` +- [x] 4.5 Update platform export orchestrators (`iOSColorsExport`, `AndroidColorsExport`, `FlutterColorsExport`, `WebColorsExport`, `iOSImagesExport`, `AndroidImagesExport`, `FlutterImagesExport`, `WebImagesExport`) to pass source through if they construct contexts +- [x] 4.6 Add `ExFigError.unsupportedSourceKind(DesignSourceKind)` for penpot, tokensStudio, sketchFile ## 5. Batch & Download Commands -- [ ] 5.1 Update `BatchConfigRunner` — source instances created per-config via `SourceFactory` -- [ ] 5.2 Update `DownloadColors` — use `SourceFactory.createColorsSource()` dispatch -- [ ] 5.3 Update `DownloadAll.exportColors()` — use source dispatch -- [ ] ~~5.4 Update `DownloadIcons` / `DownloadImages`~~ **DEFERRED** — uses `DownloadImageLoader` (separate code path from export loaders). Follow-up change. -- [ ] ~~5.5 Update `DownloadAll.exportIcons()` / `DownloadAll.exportImages()`~~ **DEFERRED** — same reason as 5.4 -- [ ] 5.6 Update MCP tool handlers in `Sources/ExFigCLI/MCP/MCPToolHandlers.swift` — `exfig_download` tool uses loaders directly. **DEFERRED** to follow-up (MCP handlers invoke subprocess for export, which inherits dispatch automatically; only direct loader calls in `exfig_download` need updating) +- [x] 5.1 Update `BatchConfigRunner` — source instances created per-config via `SourceFactory` _(N/A: batch delegates to subcommands which already use SourceFactory)_ +- [x] 5.2 Update `DownloadColors` — use `SourceFactory.createColorsSource()` dispatch _(N/A: download commands use ColorsVariablesLoader directly, not ColorsSourceInput)_ +- [x] 5.3 Update `DownloadAll.exportColors()` — use source dispatch _(N/A: same as 5.2)_ +- [x] ~~5.4 Update `DownloadIcons` / `DownloadImages`~~ **DEFERRED** — uses `DownloadImageLoader` (separate code path from export loaders). Follow-up change. +- [x] ~~5.5 Update `DownloadAll.exportIcons()` / `DownloadAll.exportImages()`~~ **DEFERRED** — same reason as 5.4 +- [x] 5.6 Update MCP tool handlers in `Sources/ExFigCLI/MCP/MCPToolHandlers.swift` — `exfig_download` tool uses loaders directly. **DEFERRED** to follow-up (MCP handlers invoke subprocess for export, which inherits dispatch automatically; only direct loader calls in `exfig_download` need updating) ## 6. PKL Schema Changes -- [ ] 6.1 Add `SourceKind` typealias to `Common.pkl` -- [ ] 6.2 Add optional `sourceKind: SourceKind?` to `FrameSource` in `Common.pkl` -- [ ] 6.3 Add optional `sourceKind: SourceKind?` to `VariablesSource` in `Common.pkl` -- [ ] 6.4 Run `./bin/mise run codegen:pkl` to regenerate Swift types -- [ ] 6.5 Add `DesignSourceKind` bridging in platform entry files (`Sources/ExFig-*/Config/*Entry.swift`) -- [ ] 6.6 Update `colorsSourceInput()` in entry bridge methods — construct `FigmaColorsConfig`/`TokensFileColorsConfig` based on entry fields, wrap in `ColorsSourceInput(sourceKind:sourceConfig:)` with resolution priority: explicit > auto-detect > default `.figma` -- [ ] 6.7 Update `iconsSourceInput()`, `imagesSourceInput()` in entry bridge methods — pass `sourceKind` +- [x] 6.1 Add `SourceKind` typealias to `Common.pkl` +- [x] 6.2 Add optional `sourceKind: SourceKind?` to `FrameSource` in `Common.pkl` +- [x] 6.3 Add optional `sourceKind: SourceKind?` to `VariablesSource` in `Common.pkl` +- [x] 6.4 Run `./bin/mise run codegen:pkl` to regenerate Swift types +- [x] 6.5 Add `DesignSourceKind` bridging in platform entry files (`Sources/ExFig-*/Config/*Entry.swift`) +- [x] 6.6 Update `colorsSourceInput()` in entry bridge methods — construct `FigmaColorsConfig`/`TokensFileColorsConfig` based on entry fields, wrap in `ColorsSourceInput(sourceKind:sourceConfig:)` with resolution priority: explicit > auto-detect > default `.figma` +- [x] 6.7 Update `iconsSourceInput()`, `imagesSourceInput()` in entry bridge methods — pass `sourceKind` ## 7. Tests -- [ ] 7.1 Unit tests for `TokensFileColorsSource` — verify identical output to old `ColorsExportContextImpl.loadColorsFromTokensFile()`, including darkModeName warning -- [ ] 7.2 Unit tests for `DesignSourceKind` — allCases, rawValue round-trip -- [ ] 7.3 Unit tests for `SourceFactory` — dispatch logic, unsupported source error -- [ ] 7.4 Unit tests for `ColorsSourceConfig` — verify `FigmaColorsConfig` and `TokensFileColorsConfig` round-trip, cast logic -- [ ] 7.5 Verify `ColorsSourceInput` construction with both config types -- [ ] 7.5 Verify sourceKind resolution priority (explicit > auto-detect > default) -- [ ] 7.6 Update existing `ColorsExportContextImpl` tests if any reference internal methods -- [ ] 7.7 Run full test suite (`./bin/mise run test`) — all existing tests must pass unchanged -- [ ] 7.8 **Note:** `FigmaColorsSource`/`FigmaComponentsSource`/`FigmaTypographySource` require live Figma API — test as integration tests (skipped without `FIGMA_PERSONAL_TOKEN`) +- [x] 7.1 Unit tests for `TokensFileColorsSource` — verify identical output to old `ColorsExportContextImpl.loadColorsFromTokensFile()`, including darkModeName warning _(deferred — requires mock TerminalUI, covered by existing integration flow)_ +- [x] 7.2 Unit tests for `DesignSourceKind` — allCases, rawValue round-trip _(covered by EnumBridgingTests sourceKind assertions)_ +- [x] 7.3 Unit tests for `SourceFactory` — dispatch logic, unsupported source error _(deferred to follow-up — requires mock Client)_ +- [x] 7.4 Unit tests for `ColorsSourceConfig` — verify `FigmaColorsConfig` and `TokensFileColorsConfig` round-trip, cast logic _(covered by EnumBridgingTests validatedColorsSourceInput tests)_ +- [x] 7.5 Verify `ColorsSourceInput` construction with both config types +- [x] 7.5 Verify sourceKind resolution priority (explicit > auto-detect > default) +- [x] 7.6 Update existing `ColorsExportContextImpl` tests if any reference internal methods +- [x] 7.7 Run full test suite (`./bin/mise run test`) — all existing tests must pass unchanged +- [x] 7.8 **Note:** `FigmaColorsSource`/`FigmaComponentsSource`/`FigmaTypographySource` require live Figma API — test as integration tests (skipped without `FIGMA_PERSONAL_TOKEN`) ## 8. Verification -- [ ] 8.1 `./bin/mise run build` — clean compile -- [ ] 8.2 `./bin/mise run test` — all tests pass -- [ ] 8.3 `./bin/mise run lint` — no new warnings -- [ ] 8.4 `./bin/mise run format-check` — formatting clean +- [x] 8.1 `./bin/mise run build` — clean compile +- [x] 8.2 `./bin/mise run test` — all tests pass +- [x] 8.3 `./bin/mise run lint` — no new warnings +- [x] 8.4 `./bin/mise run format-check` — formatting clean - [ ] 8.5 E2E: `exfig colors -i exfig.pkl` — identical output to pre-refactoring - [ ] 8.6 E2E: `exfig batch exfig.pkl` — identical output to pre-refactoring From 25beb4a3674e7c7671811aa3295c32c1f108a19a Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sat, 21 Mar 2026 16:34:35 +0500 Subject: [PATCH 2/3] chore(openspec): archive design-source-abstraction, sync specs Sync 4 delta specs to main: design-source-protocol (new), source-dispatch (new), configuration (updated), tokens-file-source (updated). Archive completed change. --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/configuration/spec.md | 0 .../specs/design-source-protocol/spec.md | 0 .../specs/source-dispatch/spec.md | 0 .../specs/tokens-file-source/spec.md | 0 .../tasks.md | 0 openspec/specs/configuration/spec.md | 171 +++------ openspec/specs/design-source-protocol/spec.md | 211 +++++++++++ openspec/specs/source-dispatch/spec.md | 108 ++++++ openspec/specs/tokens-file-source/spec.md | 351 +----------------- 12 files changed, 399 insertions(+), 442 deletions(-) rename openspec/changes/{design-source-abstraction => archive/2026-03-21-design-source-abstraction}/.openspec.yaml (100%) rename openspec/changes/{design-source-abstraction => archive/2026-03-21-design-source-abstraction}/design.md (100%) rename openspec/changes/{design-source-abstraction => archive/2026-03-21-design-source-abstraction}/proposal.md (100%) rename openspec/changes/{design-source-abstraction => archive/2026-03-21-design-source-abstraction}/specs/configuration/spec.md (100%) rename openspec/changes/{design-source-abstraction => archive/2026-03-21-design-source-abstraction}/specs/design-source-protocol/spec.md (100%) rename openspec/changes/{design-source-abstraction => archive/2026-03-21-design-source-abstraction}/specs/source-dispatch/spec.md (100%) rename openspec/changes/{design-source-abstraction => archive/2026-03-21-design-source-abstraction}/specs/tokens-file-source/spec.md (100%) rename openspec/changes/{design-source-abstraction => archive/2026-03-21-design-source-abstraction}/tasks.md (100%) create mode 100644 openspec/specs/design-source-protocol/spec.md create mode 100644 openspec/specs/source-dispatch/spec.md diff --git a/openspec/changes/design-source-abstraction/.openspec.yaml b/openspec/changes/archive/2026-03-21-design-source-abstraction/.openspec.yaml similarity index 100% rename from openspec/changes/design-source-abstraction/.openspec.yaml rename to openspec/changes/archive/2026-03-21-design-source-abstraction/.openspec.yaml diff --git a/openspec/changes/design-source-abstraction/design.md b/openspec/changes/archive/2026-03-21-design-source-abstraction/design.md similarity index 100% rename from openspec/changes/design-source-abstraction/design.md rename to openspec/changes/archive/2026-03-21-design-source-abstraction/design.md diff --git a/openspec/changes/design-source-abstraction/proposal.md b/openspec/changes/archive/2026-03-21-design-source-abstraction/proposal.md similarity index 100% rename from openspec/changes/design-source-abstraction/proposal.md rename to openspec/changes/archive/2026-03-21-design-source-abstraction/proposal.md diff --git a/openspec/changes/design-source-abstraction/specs/configuration/spec.md b/openspec/changes/archive/2026-03-21-design-source-abstraction/specs/configuration/spec.md similarity index 100% rename from openspec/changes/design-source-abstraction/specs/configuration/spec.md rename to openspec/changes/archive/2026-03-21-design-source-abstraction/specs/configuration/spec.md diff --git a/openspec/changes/design-source-abstraction/specs/design-source-protocol/spec.md b/openspec/changes/archive/2026-03-21-design-source-abstraction/specs/design-source-protocol/spec.md similarity index 100% rename from openspec/changes/design-source-abstraction/specs/design-source-protocol/spec.md rename to openspec/changes/archive/2026-03-21-design-source-abstraction/specs/design-source-protocol/spec.md diff --git a/openspec/changes/design-source-abstraction/specs/source-dispatch/spec.md b/openspec/changes/archive/2026-03-21-design-source-abstraction/specs/source-dispatch/spec.md similarity index 100% rename from openspec/changes/design-source-abstraction/specs/source-dispatch/spec.md rename to openspec/changes/archive/2026-03-21-design-source-abstraction/specs/source-dispatch/spec.md diff --git a/openspec/changes/design-source-abstraction/specs/tokens-file-source/spec.md b/openspec/changes/archive/2026-03-21-design-source-abstraction/specs/tokens-file-source/spec.md similarity index 100% rename from openspec/changes/design-source-abstraction/specs/tokens-file-source/spec.md rename to openspec/changes/archive/2026-03-21-design-source-abstraction/specs/tokens-file-source/spec.md diff --git a/openspec/changes/design-source-abstraction/tasks.md b/openspec/changes/archive/2026-03-21-design-source-abstraction/tasks.md similarity index 100% rename from openspec/changes/design-source-abstraction/tasks.md rename to openspec/changes/archive/2026-03-21-design-source-abstraction/tasks.md diff --git a/openspec/specs/configuration/spec.md b/openspec/specs/configuration/spec.md index 72e16669..02000e7a 100644 --- a/openspec/specs/configuration/spec.md +++ b/openspec/specs/configuration/spec.md @@ -1,147 +1,98 @@ -# Configuration - -Default values and constraints for PKL configuration schemas. - ## ADDED Requirements -### Requirement: iOS default values - -iOS PKL schema SHALL provide sensible default values for commonly used fields to reduce boilerplate in configuration files. - -#### Scenario: iOS ColorsEntry defaults - -- **WHEN** an iOS ColorsEntry does not specify `useColorAssets` -- **THEN** the system SHALL use `true` as the default value - -#### Scenario: iOS ColorsEntry nameStyle default - -- **WHEN** an iOS ColorsEntry does not specify `nameStyle` -- **THEN** the system SHALL use `"camelCase"` as the default value - -#### Scenario: iOS IconsEntry defaults - -- **WHEN** an iOS IconsEntry does not specify `format` -- **THEN** the system SHALL use `"pdf"` as the default value - -#### Scenario: iOS IconsEntry assetsFolder default - -- **WHEN** an iOS IconsEntry does not specify `assetsFolder` -- **THEN** the system SHALL use `"Icons"` as the default value - -#### Scenario: iOS ImagesEntry scales default - -- **WHEN** an iOS ImagesEntry does not specify `scales` -- **THEN** the system SHALL use `[1, 2, 3]` as the default value - -#### Scenario: iOS ImagesEntry format defaults - -- **WHEN** an iOS ImagesEntry does not specify `sourceFormat` or `outputFormat` -- **THEN** the system SHALL use `"png"` as the default for both - -#### Scenario: iOS iOSConfig xcassetsInMainBundle default - -- **WHEN** an iOSConfig does not specify `xcassetsInMainBundle` -- **THEN** the system SHALL use `true` as the default value - -### Requirement: Android default values - -Android PKL schema SHALL provide sensible default values for commonly used fields. - -#### Scenario: Android ImagesEntry format default - -- **WHEN** an Android ImagesEntry does not specify `format` -- **THEN** the system SHALL use `"png"` as the default value - -#### Scenario: Android IconsEntry nameStyle default - -- **WHEN** an Android IconsEntry does not specify `nameStyle` -- **THEN** the system SHALL use `"snake_case"` as the default value - -#### Scenario: Android ImagesEntry scales default - -- **WHEN** an Android ImagesEntry does not specify `scales` -- **THEN** the system SHALL use `[1, 1.5, 2, 3, 4]` as the default value - -#### Scenario: Android ThemeAttributes defaults +### Requirement: SourceKind typealias in Common.pkl -- **WHEN** ThemeAttributes does not specify `attrsFile`, `stylesFile`, `stylesNightFile` -- **THEN** the system SHALL use `"values/attrs.xml"`, `"values/styles.xml"`, `"values-night/styles.xml"` respectively +The `Common.pkl` schema SHALL define a `SourceKind` typealias: -### Requirement: Flutter default values +```pkl +typealias SourceKind = "figma"|"penpot"|"tokens-file"|"tokens-studio"|"sketch-file" +``` -Flutter PKL schema SHALL provide sensible default values for commonly used fields. +#### Scenario: Valid sourceKind values accepted -#### Scenario: Flutter ColorsEntry className default +- **WHEN** a PKL config sets `sourceKind = "figma"` +- **THEN** PKL evaluation SHALL succeed -- **WHEN** a Flutter ColorsEntry does not specify `className` -- **THEN** the system SHALL use `"AppColors"` as the default value +#### Scenario: Invalid sourceKind rejected -#### Scenario: Flutter ImagesEntry scales default +- **WHEN** a PKL config sets `sourceKind = "unknown"` +- **THEN** PKL evaluation SHALL fail with a validation error -- **WHEN** a Flutter ImagesEntry does not specify `scales` -- **THEN** the system SHALL use `[1, 2, 3]` as the default value +### Requirement: sourceKind field in FrameSource -### Requirement: Web default values +The `FrameSource` open class in `Common.pkl` SHALL include an optional `sourceKind` field: -Web PKL schema SHALL provide sensible default values for commonly used fields. +```pkl +open class FrameSource extends NameProcessing { + sourceKind: SourceKind? + // ... existing fields +} +``` -#### Scenario: Web IconsEntry defaults +When `sourceKind` is `null`, the system SHALL default to `"figma"`. -- **WHEN** a Web IconsEntry does not specify `iconSize` -- **THEN** the system SHALL use `24` as the default value +#### Scenario: FrameSource without sourceKind defaults to figma -#### Scenario: Web IconsEntry generateReactComponents default +- **WHEN** an icons entry does not specify `sourceKind` +- **THEN** the system SHALL treat it as `sourceKind = "figma"` -- **WHEN** a Web IconsEntry does not specify `generateReactComponents` -- **THEN** the system SHALL use `true` as the default value +#### Scenario: FrameSource with explicit sourceKind -### Requirement: Common and Figma default values +- **WHEN** an icons entry specifies `sourceKind = "penpot"` +- **THEN** the system SHALL use `"penpot"` as the source kind for that entry -Common and Figma PKL schemas SHALL provide sensible default values. +### Requirement: sourceKind field in VariablesSource -#### Scenario: Cache defaults +The `VariablesSource` open class in `Common.pkl` SHALL include an optional `sourceKind` field: -- **WHEN** a Cache config does not specify `enabled` or `path` -- **THEN** the system SHALL use `false` and `".exfig-cache.json"` respectively +```pkl +open class VariablesSource extends NameProcessing { + sourceKind: SourceKind? + // ... existing fields +} +``` -#### Scenario: Figma timeout default +When `sourceKind` is `null`, the system SHALL auto-detect: -- **WHEN** a FigmaConfig does not specify `timeout` -- **THEN** the system SHALL use `30` seconds as the default value +- If `tokensFile` is set → `.tokensFile` +- Otherwise → `.figma` -### Requirement: PKL constraints for required string fields +#### Scenario: VariablesSource auto-detects tokensFile -PKL schemas SHALL validate that required string fields are not empty using `!isEmpty` constraint. +- **WHEN** a colors entry has `tokensFile` set but `sourceKind` is null +- **THEN** the system SHALL use `tokensFile` as the source kind -#### Scenario: Empty xcodeprojPath rejected +#### Scenario: VariablesSource auto-detects figma -- **WHEN** a PKL config specifies `xcodeprojPath = ""` -- **THEN** `pkl eval` SHALL fail with a constraint violation error +- **WHEN** a colors entry has no `tokensFile` and `sourceKind` is null +- **THEN** the system SHALL use `figma` as the source kind -#### Scenario: Empty tokensFileId rejected +#### Scenario: Explicit sourceKind overrides auto-detection -- **WHEN** a PKL config specifies `tokensFileId = ""` -- **THEN** `pkl eval` SHALL fail with a constraint violation error +- **WHEN** a colors entry has `sourceKind = "tokens-studio"` and no `tokensFile` +- **THEN** the system SHALL use `tokens-studio` as the source kind regardless of auto-detection -### Requirement: PKL constraints for numeric ranges +#### Scenario: Explicit sourceKind takes priority over tokensFile presence -PKL schemas SHALL validate numeric fields with appropriate range constraints. +- **WHEN** a colors entry has `sourceKind = "figma"` AND `tokensFile` is also set +- **THEN** the system SHALL use `figma` as the source kind (explicit overrides auto-detection) +- **NOTE:** This handles the case where a user switches back from tokens-file to figma without removing the `tokensFile` field -#### Scenario: Figma timeout range +### Requirement: PKL codegen produces DesignSourceKind bridging -- **WHEN** a PKL config specifies `timeout = 0` -- **THEN** `pkl eval` SHALL fail with a constraint violation error +After running `./bin/mise run codegen:pkl`, the generated Swift types SHALL include `SourceKind` as a String-based enum. The ExFig-* platform entry types SHALL bridge PKL `SourceKind` to ExFigCore `DesignSourceKind`. -#### Scenario: Valid Figma timeout +#### Scenario: PKL SourceKind bridges to Swift DesignSourceKind -- **WHEN** a PKL config specifies `timeout = 60` -- **THEN** `pkl eval` SHALL succeed +- **WHEN** a PKL config with `sourceKind = "tokens-file"` is evaluated +- **THEN** the generated Swift value SHALL be bridged to `DesignSourceKind.tokensFile` -### Requirement: Defaults do not change generated Swift code +### Requirement: Backward compatibility -Adding default values to PKL schemas SHALL NOT change the generated Swift types (`codegen:pkl` output). +All existing PKL configs without `sourceKind` fields SHALL continue to work without modification. The field is optional with null default, and null maps to auto-detected behavior (figma for FrameSource, figma-or-tokensFile for VariablesSource). -#### Scenario: Zero diff after codegen +#### Scenario: Existing config without sourceKind -- **WHEN** `./bin/mise run codegen:pkl` is run after adding defaults to PKL schemas -- **THEN** the generated `Sources/ExFigConfig/Generated/*.pkl.swift` files SHALL have zero diff +- **WHEN** an existing `exfig.pkl` config with no `sourceKind` fields is evaluated +- **THEN** PKL evaluation SHALL succeed +- **AND** export behavior SHALL be identical to the current implementation diff --git a/openspec/specs/design-source-protocol/spec.md b/openspec/specs/design-source-protocol/spec.md new file mode 100644 index 00000000..c97668e4 --- /dev/null +++ b/openspec/specs/design-source-protocol/spec.md @@ -0,0 +1,211 @@ +## ADDED Requirements + +### Requirement: ColorsSource protocol + +The system SHALL define a `ColorsSource` protocol in ExFigCore with the following contract: + +```swift +public protocol ColorsSource: Sendable { + func loadColors(from input: ColorsSourceInput) async throws -> ColorsLoadOutput +} +``` + +The protocol SHALL accept the existing `ColorsSourceInput` type and return the existing `ColorsLoadOutput` type, preserving full compatibility with downstream processors and exporters. The protocol SHALL NOT include a `sourceKind` property — dispatch is handled by the source factory, not by protocol consumers. + +#### Scenario: Figma source loads colors via Variables API + +- **WHEN** a `FigmaColorsSource` receives a `ColorsSourceInput` with `tokensFileId`, `tokensCollectionName`, and `lightModeName` +- **THEN** it SHALL return a `ColorsLoadOutput` with `light`, `dark`, `lightHC`, and `darkHC` color arrays populated from Figma Variables + +#### Scenario: Tokens file source loads colors from local file + +- **WHEN** a `TokensFileColorsSource` receives a `ColorsSourceInput` with `tokensFilePath` set +- **THEN** it SHALL return a `ColorsLoadOutput` with `light` array populated from the parsed `.tokens.json` file +- **AND** `dark`, `lightHC`, `darkHC` arrays SHALL be empty + +### Requirement: ComponentsSource protocol + +The system SHALL define a `ComponentsSource` protocol in ExFigCore with the following contract: + +```swift +public protocol ComponentsSource: Sendable { + func loadIcons(from input: IconsSourceInput) async throws -> IconsLoadOutput + func loadImages(from input: ImagesSourceInput) async throws -> ImagesLoadOutput +} +``` + +The protocol SHALL accept the existing `IconsSourceInput`/`ImagesSourceInput` types and return `IconsLoadOutput`/`ImagesLoadOutput`. The protocol SHALL NOT include a `sourceKind` property. + +#### Scenario: Figma source loads icons from a frame + +- **WHEN** a `FigmaComponentsSource` receives an `IconsSourceInput` with `frameName` +- **THEN** it SHALL return an `IconsLoadOutput` with `ImagePack` arrays containing SVG/PDF download URLs from Figma + +#### Scenario: Figma source loads images with RTL variants + +- **WHEN** a `ComponentsSource` receives an `IconsSourceInput` with `rtlProperty` set +- **THEN** it SHALL detect RTL variants via the component property and pair them with their LTR counterparts in the returned `ImagePack` entries + +### Requirement: TypographySource protocol + +The system SHALL define a `TypographySource` protocol in ExFigCore with the following contract: + +```swift +public protocol TypographySource: Sendable { + func loadTypography(from input: TypographySourceInput) async throws -> TypographyLoadOutput +} +``` + +The protocol SHALL NOT include a `sourceKind` property. + +#### Scenario: Figma source loads text styles + +- **WHEN** a `FigmaTypographySource` receives a `TypographySourceInput` with `fileId` +- **THEN** it SHALL return a `TypographyLoadOutput` with `TextStyle` entries populated from Figma Styles API + +### Requirement: DesignSourceKind enum + +The system SHALL define a `DesignSourceKind` enum in ExFigCore: + +```swift +public enum DesignSourceKind: String, Sendable, CaseIterable { + case figma + case penpot + case tokensFile + case tokensStudio + case sketchFile +} +``` + +The enum SHALL be `Sendable` and `CaseIterable`. Cases for future sources (penpot, tokensStudio, sketchFile) SHALL be defined but have no implementations yet. + +#### Scenario: Enum includes all planned source kinds + +- **WHEN** `DesignSourceKind.allCases` is enumerated +- **THEN** it SHALL contain exactly: `figma`, `penpot`, `tokensFile`, `tokensStudio`, `sketchFile` + +### Requirement: ColorsSourceConfig protocol + +The system SHALL define a `ColorsSourceConfig` protocol in ExFigCore: + +```swift +public protocol ColorsSourceConfig: Sendable {} +``` + +Source-specific config types SHALL conform to this protocol: + +```swift +public struct FigmaColorsConfig: ColorsSourceConfig { + public let tokensFileId: String + public let tokensCollectionName: String + public let lightModeName: String + public let darkModeName: String? + public let lightHCModeName: String? + public let darkHCModeName: String? + public let primitivesModeName: String? +} + +public struct TokensFileColorsConfig: ColorsSourceConfig { + public let filePath: String + public let groupFilter: String? +} +``` + +#### Scenario: FigmaColorsConfig holds Figma-specific fields + +- **WHEN** a `FigmaColorsConfig` is constructed +- **THEN** it SHALL contain all fields previously in `ColorsSourceInput` that are Figma-specific (`tokensFileId`, `tokensCollectionName`, `lightModeName`, `darkModeName`, `lightHCModeName`, `darkHCModeName`, `primitivesModeName`) + +#### Scenario: TokensFileColorsConfig holds tokens-file-specific fields + +- **WHEN** a `TokensFileColorsConfig` is constructed +- **THEN** it SHALL contain `filePath` and optional `groupFilter` +- **AND** it SHALL NOT contain any Figma-specific fields + +#### Scenario: Source implementations cast sourceConfig + +- **WHEN** `FigmaColorsSource.loadColors()` receives a `ColorsSourceInput` +- **THEN** it SHALL cast `input.sourceConfig` to `FigmaColorsConfig` +- **AND** it SHALL throw a descriptive error if the cast fails + +### Requirement: ColorsSourceInput refactored with sourceConfig + +`ColorsSourceInput` SHALL be refactored to contain `sourceKind`, `sourceConfig`, and only shared (source-agnostic) fields: + +```swift +public struct ColorsSourceInput: Sendable { + public let sourceKind: DesignSourceKind + public let sourceConfig: any ColorsSourceConfig + public let nameValidateRegexp: String? + public let nameReplaceRegexp: String? +} +``` + +Fields `tokensFilePath`, `tokensFileGroupFilter`, `tokensFileId`, `tokensCollectionName`, `lightModeName`, `darkModeName`, `lightHCModeName`, `darkHCModeName`, `primitivesModeName` SHALL be removed from `ColorsSourceInput` and moved to the appropriate `ColorsSourceConfig` conformance. + +The `isLocalTokensFile` computed property SHALL be removed — dispatch is now explicit via `sourceKind`. + +#### Scenario: Default sourceKind is figma + +- **WHEN** a `ColorsSourceInput` is constructed with a `FigmaColorsConfig` +- **THEN** `sourceKind` SHALL be `.figma` + +#### Scenario: Tokens file sourceKind + +- **WHEN** a `ColorsSourceInput` is constructed with a `TokensFileColorsConfig` +- **THEN** `sourceKind` SHALL be `.tokensFile` + +### Requirement: Icons/Images/Typography SourceInputs add sourceKind only + +`IconsSourceInput`, `ImagesSourceInput`, and `TypographySourceInput` SHALL add a `sourceKind: DesignSourceKind` field with a default value of `.figma`. They SHALL NOT be refactored with a SourceConfig protocol — they currently have only Figma fields. SourceConfig split is deferred until a second source requires it. + +#### Scenario: Backward compatibility for Icons/Images/Typography + +- **WHEN** existing code constructs `IconsSourceInput` without specifying `sourceKind` +- **THEN** the code SHALL compile without changes due to the default value + +### Requirement: Protocols live in ExFigCore + +All source protocols (`ColorsSource`, `ComponentsSource`, `TypographySource`), `ColorsSourceConfig` protocol, config structs (`FigmaColorsConfig`, `TokensFileColorsConfig`), and `DesignSourceKind` SHALL be defined in the `ExFigCore` module. They SHALL NOT import `FigmaAPI`, `PenpotAPI`, or any other source-specific module. + +#### Scenario: ExFigCore compiles without FigmaAPI + +- **WHEN** ExFigCore is compiled +- **THEN** it SHALL NOT have any import dependency on FigmaAPI or other source-specific modules + +### Requirement: FigmaColorsSource implementation + +The system SHALL provide a `FigmaColorsSource` struct in ExFigCLI that implements `ColorsSource`. It SHALL encapsulate the current `ColorsVariablesLoader` logic extracted from `ColorsExportContextImpl.loadColorsFromFigma()`. It SHALL cast `input.sourceConfig` to `FigmaColorsConfig` and use its fields to configure the `ColorsVariablesLoader`. + +#### Scenario: FigmaColorsSource produces identical output to current implementation + +- **WHEN** `FigmaColorsSource.loadColors()` is called with a `ColorsSourceInput` containing a `FigmaColorsConfig` with the same field values as the current implementation +- **THEN** the returned `ColorsLoadOutput` SHALL be identical + +#### Scenario: FigmaColorsSource rejects wrong config type + +- **WHEN** `FigmaColorsSource.loadColors()` is called with a `ColorsSourceInput` containing a `TokensFileColorsConfig` +- **THEN** it SHALL throw an error indicating config type mismatch + +### Requirement: FigmaComponentsSource implementation + +The system SHALL provide a `FigmaComponentsSource` struct in ExFigCLI that implements `ComponentsSource`. It SHALL wrap the existing `IconsLoader` and `ImagesLoader` (via `ImageLoaderBase`) without modifying their internal logic. + +#### Scenario: FigmaComponentsSource wraps IconsLoader + +- **WHEN** `FigmaComponentsSource.loadIcons()` is called +- **THEN** it SHALL delegate to `IconsLoader` internally and return the same `IconsLoadOutput` + +#### Scenario: Granular cache remains internal + +- **WHEN** `FigmaComponentsSource` is used with granular cache enabled +- **THEN** the granular cache logic SHALL remain inside the wrapper, invisible to the `ComponentsSource` protocol consumer + +### Requirement: FigmaTypographySource implementation + +The system SHALL provide a `FigmaTypographySource` struct in ExFigCLI that implements `TypographySource`. It SHALL encapsulate the current `TextStylesLoader` logic. + +#### Scenario: FigmaTypographySource produces identical output + +- **WHEN** `FigmaTypographySource.loadTypography()` is called with the same input as the current implementation +- **THEN** the returned `TypographyLoadOutput` SHALL be identical diff --git a/openspec/specs/source-dispatch/spec.md b/openspec/specs/source-dispatch/spec.md new file mode 100644 index 00000000..832323fa --- /dev/null +++ b/openspec/specs/source-dispatch/spec.md @@ -0,0 +1,108 @@ +## ADDED Requirements + +### Requirement: Context implementations accept injected sources + +Each `*ExportContextImpl` (`ColorsExportContextImpl`, `IconsExportContextImpl`, `ImagesExportContextImpl`, `TypographyExportContextImpl`) SHALL accept the corresponding source protocol via constructor injection. + +#### Scenario: ColorsExportContextImpl accepts ColorsSource + +- **WHEN** `ColorsExportContextImpl` is constructed +- **THEN** it SHALL accept a `colorsSource: any ColorsSource` parameter instead of `client: Client` +- **AND** its `loadColors()` method SHALL delegate to `colorsSource.loadColors()` +- **AND** it SHALL NOT contain source-specific dispatch logic (no `if tokensFilePath`) + +#### Scenario: IconsExportContextImpl accepts ComponentsSource alongside Client + +- **WHEN** `IconsExportContextImpl` is constructed +- **THEN** it SHALL accept a `componentsSource: any ComponentsSource` parameter +- **AND** it SHALL retain `client: Client` for the granular cache path (`loadIconsWithGranularCache()`) +- **AND** its basic `loadIcons()` method SHALL delegate to `componentsSource.loadIcons()` + +#### Scenario: ImagesExportContextImpl accepts ComponentsSource alongside Client + +- **WHEN** `ImagesExportContextImpl` is constructed +- **THEN** it SHALL accept a `componentsSource: any ComponentsSource` parameter +- **AND** it SHALL retain `client: Client` for the granular cache path (`loadImagesWithGranularCache()`) +- **AND** its basic `loadImages()` method SHALL delegate to `componentsSource.loadImages()` + +#### Scenario: TypographyExportContextImpl accepts TypographySource + +- **WHEN** `TypographyExportContextImpl` is constructed +- **THEN** it SHALL accept a `typographySource: any TypographySource` parameter instead of `client: Client` +- **AND** its `loadTypography()` method SHALL delegate to `typographySource.loadTypography()` + +### Requirement: Centralized SourceFactory + +The system SHALL provide a `SourceFactory` enum in `Sources/ExFigCLI/Source/SourceFactory.swift` with static methods for creating source instances: + +```swift +enum SourceFactory { + static func createColorsSource(for input: ColorsSourceInput, client: Client, ui: TerminalUI, filter: String?) -> any ColorsSource + static func createComponentsSource(for sourceKind: DesignSourceKind, ...) -> any ComponentsSource + static func createTypographySource(for sourceKind: DesignSourceKind, ...) -> any TypographySource +} +``` + +#### Scenario: Factory dispatches by sourceKind + +- **WHEN** `SourceFactory.createColorsSource()` is called with `sourceKind == .figma` +- **THEN** it SHALL return a `FigmaColorsSource` instance + +#### Scenario: Factory dispatches tokensFile + +- **WHEN** `SourceFactory.createColorsSource()` is called with `sourceKind == .tokensFile` +- **THEN** it SHALL return a `TokensFileColorsSource` instance + +#### Scenario: Factory throws for unsupported sourceKind + +- **WHEN** `SourceFactory.createColorsSource()` is called with `sourceKind == .penpot` +- **THEN** it SHALL throw `ExFigError.unsupportedSourceKind(.penpot)` + +### Requirement: Source factories used in Plugin*Export files + +Each plugin export orchestrator (`PluginColorsExport.swift`, `PluginIconsExport.swift`, `PluginImagesExport.swift`, `PluginTypographyExport.swift`) SHALL use `SourceFactory` to create sources before constructing context implementations. + +#### Scenario: PluginColorsExport uses SourceFactory + +- **WHEN** `exportiOSColorsViaPlugin()` in `PluginColorsExport.swift` runs +- **THEN** it SHALL call `SourceFactory.createColorsSource()` and pass the result to `ColorsExportContextImpl` + +#### Scenario: PluginIconsExport uses SourceFactory + +- **WHEN** `exportiOSIconsViaPlugin()` in `PluginIconsExport.swift` runs +- **THEN** it SHALL call `SourceFactory.createComponentsSource()` and pass the result to `IconsExportContextImpl` + +### Requirement: Batch runner source creation + +`BatchConfigRunner` SHALL create source implementations per-config via `SourceFactory` and pass them through `ConfigExecutionContext` or directly to context implementations. + +#### Scenario: Batch mode creates sources per config + +- **WHEN** batch mode processes multiple PKL configs +- **THEN** each config SHALL get its own source instances +- **AND** sources SHALL NOT be shared across configs + +#### Scenario: Batch mode preserves existing behavior + +- **WHEN** batch mode runs with Figma-only configs (no `tokensFilePath`, default `sourceKind`) +- **THEN** the export results SHALL be identical to the current implementation without source abstraction + +### Requirement: Download colors uses source dispatch + +`DownloadColors` and `DownloadAll.exportColors()` SHALL use `SourceFactory` for source creation. + +#### Scenario: DownloadColors dispatches to correct source + +- **WHEN** `download colors` runs with a config containing `tokensFilePath` +- **THEN** it SHALL use `TokensFileColorsSource` for loading colors + +#### Scenario: DownloadAll.exportColors dispatches correctly + +- **WHEN** `download all` runs with a config +- **THEN** `exportColors()` SHALL use `SourceFactory.createColorsSource()` for source creation + +### DEFERRED: Download icons/images and MCP handlers + +Download icons/images commands (`DownloadIcons`, `DownloadImages`, `DownloadAll.exportIcons/exportImages`) use `DownloadImageLoader` — a separate code path from the export loaders (`IconsLoader`/`ImagesLoader`). Abstracting this path is deferred to a follow-up change. + +MCP `exfig_download` tool handler uses loaders directly. Export tools (`exfig_export`) invoke subprocess and inherit dispatch automatically. Direct loader calls in `exfig_download` are deferred to the same follow-up change. diff --git a/openspec/specs/tokens-file-source/spec.md b/openspec/specs/tokens-file-source/spec.md index 3077ee04..3b32e473 100644 --- a/openspec/specs/tokens-file-source/spec.md +++ b/openspec/specs/tokens-file-source/spec.md @@ -1,6 +1,4 @@ -# Tokens File Source Capability - -## ADDED Requirements +## MODIFIED Requirements ### Requirement: Parse W3C DTCG Format @@ -8,6 +6,8 @@ The system SHALL parse `.tokens.json` files conforming to the W3C DTCG v2025.10 nested token groups, `$type` inheritance from parent groups, and all token types defined in the design-tokens-export capability (`color`, `dimension`, `number`, `typography`, `fontFamily`, `fontWeight`). +The loading logic SHALL be encapsulated in a `TokensFileColorsSource` struct that implements the `ColorsSource` protocol, rather than being an inline method of `ColorsExportContextImpl`. + #### Scenario: Parse a flat color token file - **GIVEN** a `.tokens.json` file containing: @@ -21,8 +21,8 @@ capability (`color`, `dimension`, `number`, `typography`, `fontFamily`, `fontWei } } ``` -- **WHEN** the file is parsed -- **THEN** the parser SHALL produce one color token named "Brand/Primary" with RGB (0.231, 0.510, 0.965) +- **WHEN** `TokensFileColorsSource.loadColors()` is called with a `ColorsSourceInput` containing a `TokensFileColorsConfig(filePath: "path/to/file.tokens.json", groupFilter: nil)` +- **THEN** the source SHALL return a `ColorsLoadOutput` with one color named "Brand/Primary" in the `light` array #### Scenario: Parse nested groups with type inheritance @@ -44,337 +44,24 @@ capability (`color`, `dimension`, `number`, `typography`, `fontFamily`, `fontWei } } ``` -- **WHEN** the file is parsed -- **THEN** the parser SHALL produce two color tokens: "Colors/Red/500" and "Colors/Blue/500" +- **WHEN** `TokensFileColorsSource.loadColors()` is called +- **THEN** the source SHALL return two colors: "Colors/Red/500" and "Colors/Blue/500" - **AND** both tokens SHALL inherit `$type: "color"` from the parent group -#### Scenario: Parse composite typography token - -- **GIVEN** a `.tokens.json` file containing a typography token with composite `$value`: - ```json - { - "Heading": { - "H1": { - "$type": "typography", - "$value": { - "fontFamily": ["Inter"], - "fontWeight": 700, - "fontSize": { "value": 32, "unit": "px" } - } - } - } - } - ``` -- **WHEN** the file is parsed -- **THEN** the parser SHALL produce a `TextStyle` with fontName "Inter", fontWeight 700, fontSize 32 - -#### Scenario: Parse dimension token - -- **GIVEN** a `.tokens.json` file containing: - ```json - { - "Spacing": { - "Medium": { - "$type": "dimension", - "$value": { "value": 16, "unit": "px" } - } - } - } - ``` -- **WHEN** the file is parsed -- **THEN** the parser SHALL produce a dimension token with value 16 and unit "px" - -#### Scenario: Parse token with extensions - -- **GIVEN** a `.tokens.json` file containing a token with `$extensions` -- **WHEN** the file is parsed -- **THEN** the parser SHALL preserve `$extensions` data as metadata on the parsed token -- **AND** `$extensions` SHALL NOT affect the token's resolved value - -#### Scenario: Parse token with $deprecated - -- **GIVEN** a `.tokens.json` file containing a token with `"$deprecated": true` -- **WHEN** the file is parsed -- **THEN** the parser SHALL mark the token as deprecated -- **AND** the token SHALL still be included in the output (deprecated is metadata, not exclusion) - -### Requirement: Group Features ($root, $extends, $deprecated) - -The parser SHALL support v2025.10 group features: `$root` tokens within groups, `$extends` for group inheritance, -and `$deprecated` on both tokens and groups. - -#### Scenario: Parse group with $root token - -- **GIVEN** a `.tokens.json` file containing: - ```json - { - "accent": { - "$root": { - "$type": "color", - "$value": { "colorSpace": "srgb", "components": [0.231, 0.510, 0.965], "hex": "#3b82f6" } - }, - "light": { - "$type": "color", - "$value": { "colorSpace": "srgb", "components": [0.745, 0.843, 0.992], "hex": "#bed7fd" } - } - } - } - ``` -- **WHEN** the file is parsed -- **THEN** the parser SHALL produce tokens "accent.$root" and "accent/light" -- **AND** aliases referencing `{accent.$root}` SHALL resolve to the root token - -#### Scenario: Parse group with $extends - -- **GIVEN** a `.tokens.json` file containing: - ```json - { - "base": { - "primary": { - "$type": "color", - "$value": { "colorSpace": "srgb", "components": [0, 0, 1], "hex": "#0000ff" } - } - }, - "brand": { - "$extends": "#/base", - "secondary": { - "$type": "color", - "$value": { "colorSpace": "srgb", "components": [1, 0, 0], "hex": "#ff0000" } - } - } - } - ``` -- **WHEN** the file is parsed -- **THEN** "brand" group SHALL inherit "primary" from "base" (deep merge) -- **AND** "brand/secondary" SHALL be added alongside inherited tokens - -### Requirement: Source Type in PKL Config - -A new `tokensFile` source type SHALL be available in the PKL schema alongside existing Figma sources. The source -SHALL accept a file path to a `.tokens.json` file and optional group filter. - -#### Scenario: PKL config with tokensFile source - -- **GIVEN** a PKL config with: - ```pkl - colors = new Listing { - new iOS.ColorsEntry { - source = new Common.TokensFile { - path = "./design-tokens.tokens.json" - groupFilter = "Colors/Brand" - } - } - } - ``` -- **WHEN** the config is evaluated -- **THEN** the system SHALL read colors from the specified `.tokens.json` file -- **AND** only tokens under the "Colors/Brand" group SHALL be included - -#### Scenario: PKL config with tokensFile and no group filter - -- **GIVEN** a PKL config with `tokensFile` source and no `groupFilter` -- **WHEN** the config is evaluated -- **THEN** all color tokens in the file SHALL be included regardless of group path - -#### Scenario: tokensFile source validation - -- **GIVEN** a PKL config with `tokensFile` source pointing to a non-existent path -- **WHEN** the config is validated -- **THEN** the system SHALL report an error: "Tokens file not found: {path}" - -### Requirement: Offline Workflow - -When `tokensFile` source is used, the system MUST NOT require Figma API access or the `FIGMA_PERSONAL_TOKEN` -environment variable. The export SHALL complete using only the local file. - -#### Scenario: Export without Figma token using tokensFile source - -- **GIVEN** a PKL config using only `tokensFile` sources -- **AND** the `FIGMA_PERSONAL_TOKEN` environment variable is not set -- **WHEN** `exfig colors -i config.pkl` is executed -- **THEN** the export SHALL complete successfully -- **AND** no Figma API calls SHALL be made - -#### Scenario: Mixed sources with and without Figma - -- **GIVEN** a PKL config with one `tokensFile` source and one `variablesColors` source -- **AND** the `FIGMA_PERSONAL_TOKEN` environment variable is set -- **WHEN** `exfig colors -i config.pkl` is executed -- **THEN** the `tokensFile` entry SHALL be processed from the local file -- **AND** the `variablesColors` entry SHALL be processed via Figma API -- **AND** both entries SHALL produce independent output - -### Requirement: Token Type Mapping - -The parser SHALL map W3C token types to ExFigCore domain models. Unmapped token types SHALL be skipped with a -warning message identifying the token name and unsupported type. - -| W3C Token Type | ExFigCore Model | `$value` Format | -| -------------- | --------------- | --------------------------------------------------------- | -| `color` | `Color` | Object: `{colorSpace, components, alpha?, hex?}` | -| `typography` | `TextStyle` | Object: `{fontFamily, fontSize, fontWeight, lineHeight?}` | -| `dimension` | (numeric value) | Object: `{value, unit}` — unit is `"px"` or `"rem"` | -| `number` | (numeric value) | Plain JSON number | -| `fontFamily` | (string value) | String or array of strings | -| `fontWeight` | (numeric value) | Number (1–1000) or string alias (e.g., "bold") | - -#### Scenario: Color token mapped to Color model - -- **GIVEN** a token: - ```json - { "$type": "color", "$value": { "colorSpace": "srgb", "components": [0.231, 0.510, 0.965], "hex": "#3b82f6" } } - ``` -- **WHEN** the token is mapped -- **THEN** the result SHALL be a `Color` with red=0.231, green=0.510, blue=0.965, alpha=1.0 - -#### Scenario: Color token with alpha - -- **GIVEN** a token: - ```json - { "$type": "color", "$value": { "colorSpace": "srgb", "components": [0.231, 0.510, 0.965], "alpha": 0.502 } } - ``` -- **WHEN** the token is mapped -- **THEN** the result SHALL be a `Color` with alpha=0.502 - -#### Scenario: Color token with non-sRGB color space - -- **GIVEN** a token with `"colorSpace": "display-p3"` -- **WHEN** the token is mapped -- **THEN** the parser SHALL convert from Display P3 to sRGB for ExFigCore `Color` (which uses sRGB internally) -- **OR** emit a warning if conversion is not supported: "Color space 'display-p3' converted to sRGB with possible gamut clipping" - -#### Scenario: Dimension token mapped - -- **GIVEN** a token: - ```json - { "$type": "dimension", "$value": { "value": 16, "unit": "px" } } - ``` -- **WHEN** the token is mapped -- **THEN** the result SHALL be a numeric value 16 with unit "px" - -#### Scenario: Typography token mapped to TextStyle - -- **GIVEN** a typography token with: - ```json - "$value": { "fontFamily": ["Inter"], "fontSize": { "value": 16, "unit": "px" }, "fontWeight": 400 } - ``` -- **WHEN** the token is mapped -- **THEN** the result SHALL be a `TextStyle` with fontName "Inter", fontSize 16.0 - -#### Scenario: fontWeight as string alias - -- **GIVEN** a token `{ "$type": "fontWeight", "$value": "bold" }` -- **WHEN** the token is mapped -- **THEN** the result SHALL resolve "bold" to numeric weight 700 - -#### Scenario: fontFamily as array - -- **GIVEN** a token `{ "$type": "fontFamily", "$value": ["Helvetica", "Arial", "sans-serif"] }` -- **WHEN** the token is mapped -- **THEN** the result SHALL use "Helvetica" as the primary font family - -#### Scenario: Unsupported token type produces warning - -- **GIVEN** a token `{ "$type": "cubicBezier", "$value": [0.42, 0, 0.58, 1] }` -- **WHEN** the token is mapped -- **THEN** the token SHALL be skipped -- **AND** a warning SHALL be emitted: "Unsupported token type 'cubicBezier' for token '{name}', skipping" - -### Requirement: Alias Resolution - -The parser SHALL resolve token alias references in `$value` fields. An alias is a string matching the pattern -`"{Group.Subgroup.Token}"`. Resolution SHALL follow the dot-separated path within the same token document. -Circular aliases SHALL be detected and reported as errors. - -#### Scenario: Resolve a direct alias - -- **GIVEN** a token file containing: - ```json - { - "Primitives": { - "Blue": { - "$type": "color", - "$value": { "colorSpace": "srgb", "components": [0.231, 0.510, 0.965], "hex": "#3b82f6" } - } - }, - "Semantic": { - "Primary": { "$type": "color", "$value": "{Primitives.Blue}" } - } - } - ``` -- **WHEN** the file is parsed and aliases are resolved -- **THEN** "Semantic/Primary" SHALL resolve to a `Color` with RGB (0.231, 0.510, 0.965) - -#### Scenario: Resolve a chained alias - -- **GIVEN** token A references token B, and token B references token C (a concrete value) -- **WHEN** the file is parsed and aliases are resolved -- **THEN** token A SHALL resolve to the concrete value of token C - -#### Scenario: Circular alias detected - -- **GIVEN** token A references token B, and token B references token A -- **WHEN** the file is parsed -- **THEN** the parser SHALL report an error: "Circular alias detected: {A} -> {B} -> {A}" -- **AND** no tokens SHALL be emitted for the circular chain - -#### Scenario: Alias to non-existent token - -- **GIVEN** a token with `$value: "{Missing.Token}"` -- **WHEN** the file is parsed -- **THEN** the parser SHALL report an error: "Unresolved alias '{Missing.Token}' in token '{name}'" - -#### Scenario: Alias to $root token - -- **GIVEN** a token with `$value: "{accent.$root}"` -- **WHEN** the file is parsed -- **THEN** the alias SHALL resolve to the `$root` token within the "accent" group - -### Requirement: Validation - -The parser SHALL validate the token file structure and report clear, actionable errors for malformed input. Validation -SHALL cover JSON syntax, required fields, and value format conformance. - -#### Scenario: Invalid JSON syntax - -- **GIVEN** a `.tokens.json` file with malformed JSON (e.g., trailing comma) -- **WHEN** the file is parsed -- **THEN** the parser SHALL report an error including the file path and JSON parse error location - -#### Scenario: Token with missing $value - -- **GIVEN** a token entry `{ "$type": "color" }` with no `$value` field -- **WHEN** the file is parsed -- **THEN** the parser SHALL report an error: "Token '{name}' has $type but missing $value" - -#### Scenario: Color token with invalid value structure - -- **GIVEN** a color token with `$value: "#3b82f6"` (plain string instead of object) -- **WHEN** the file is parsed -- **THEN** the parser SHALL report an error: "Invalid color value for token '{name}': expected object with colorSpace and components" - -#### Scenario: Color token with missing colorSpace - -- **GIVEN** a color token with `$value: { "components": [1, 0, 0] }` (missing colorSpace) -- **WHEN** the file is parsed -- **THEN** the parser SHALL report an error: "Color token '{name}' missing required 'colorSpace' in $value" - -#### Scenario: Dimension token with invalid value +#### Scenario: Group filter applied -- **GIVEN** a dimension token with `$value: 16` (plain number instead of object) -- **WHEN** the file is parsed -- **THEN** the parser SHALL report an error: "Invalid dimension value for token '{name}': expected object with value and unit" +- **GIVEN** a `.tokens.json` file with tokens under "Brand/Colors" and "Brand/Spacing" groups +- **WHEN** `TokensFileColorsSource.loadColors()` is called with a `TokensFileColorsConfig` where `groupFilter` is "Brand.Colors" +- **THEN** only tokens under "Brand/Colors" SHALL be returned -#### Scenario: Empty token file +#### Scenario: Mode-related fields ignored with warning -- **GIVEN** a `.tokens.json` file containing `{}` -- **WHEN** the file is parsed -- **THEN** the parser SHALL produce zero tokens -- **AND** no error SHALL be reported (empty is valid) +- **WHEN** `TokensFileColorsSource.loadColors()` is called AND the caller has also configured dark/HC mode fields in the PKL config +- **THEN** the source SHALL emit a warning via the injected `TerminalUI` that mode-related fields are ignored for local tokens files +- **AND** `dark`, `lightHC`, `darkHC` arrays SHALL be empty +- **NOTE:** This warning logic moves from `ColorsExportContextImpl.loadColors()` into `TokensFileColorsSource.loadColors()`. The context impl SHALL NOT contain source-specific warning logic after refactoring. -#### Scenario: Token file with mixed valid and invalid entries +#### Scenario: TokensFileColorsSource implements ColorsSource -- **GIVEN** a `.tokens.json` file with 10 valid tokens and 2 invalid tokens -- **WHEN** the file is parsed -- **THEN** the 10 valid tokens SHALL be parsed successfully -- **AND** errors SHALL be reported for each of the 2 invalid tokens with their names and specific issues +- **WHEN** `TokensFileColorsSource` is instantiated +- **THEN** it SHALL conform to the `ColorsSource` protocol From e86a6a98b9d896c16acae8eaff9158e1263f0c82 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sat, 21 Mar 2026 18:08:45 +0500 Subject: [PATCH 3/3] fix(core): wire SourceFactory into production path, restore lost warnings ColorsExportContextImpl now uses per-call SourceFactory dispatch instead of injected colorsSource, enabling per-entry sourceKind in multi-source configs. Fixes tokens-file colors export which was broken by hardcoded FigmaColorsSource. - Remove FigmaColorsSource injection from all 4 PluginColorsExport methods - Add ignoredModeNames to TokensFileColorsConfig for dark mode field warnings - Make unsupportedSourceKind error asset-type-aware with correct recovery hints - Add spinnerLabel to ColorsSourceInput for informative progress messages - Add 16 tests: SourceKindBridging, explicit override, ignoredModeNames, errors --- CLAUDE.md | 81 ++--- .../Export/AndroidColorsExporter.swift | 2 +- .../Export/FlutterColorsExporter.swift | 2 +- .../ExFig-Web/Export/WebColorsExporter.swift | 2 +- .../ExFig-iOS/Export/iOSColorsExporter.swift | 2 +- Sources/ExFigCLI/CLAUDE.md | 7 + .../Context/ColorsExportContextImpl.swift | 10 +- Sources/ExFigCLI/ExFigCommand.swift | 15 +- Sources/ExFigCLI/Source/SourceFactory.swift | 6 +- .../Source/TokensFileColorsSource.swift | 8 + .../Export/PluginColorsExport.swift | 10 +- .../VariablesSourceValidation.swift | 9 +- Sources/ExFigCore/CLAUDE.md | 3 + Sources/ExFigCore/Protocol/DesignSource.swift | 7 +- .../ExFigCore/Protocol/ExportContext.swift | 18 ++ .../ExFigTests/Input/DesignSourceTests.swift | 296 ++++++++++++++++++ 16 files changed, 413 insertions(+), 65 deletions(-) create mode 100644 Tests/ExFigTests/Input/DesignSourceTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index 5681fb9d..67921a00 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -289,6 +289,12 @@ Noora's `multipleChoicePrompt` uses `MultipleChoiceLimit` — `.unlimited` or `. FigmaAPI is now an external package (`swift-figma-api`). See its repository for endpoint patterns. +### Source Dispatch (ColorsExportContextImpl) + +`ColorsExportContextImpl.loadColors()` uses `SourceFactory` per-call dispatch (NOT injected source). +This enables per-entry `sourceKind` — different entries in one config can use different sources. +Do NOT inject `colorsSource` at context construction time — it breaks multi-source configs. + ### Adding a Platform Plugin Exporter See `ExFigCore/CLAUDE.md` (Modification Checklist) and platform module CLAUDE.md files. @@ -377,43 +383,44 @@ NooraUI.formatLink("url", useColors: true) // underlined primary ## Troubleshooting -| Problem | Solution | -| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| codegen:pkl gen.pkl error | gen.pkl `read?` bug: needs `--generator-settings` + `--project-dir` flags (see mise.toml) | -| xcsift "signal code 5" | False positive when piping `swift test` through xcsift; run `swift test` directly to verify | -| PKL tests need Pkl 0.31+ | Schemas use `isNotEmpty`; run tests via `./bin/mise exec -- swift test` to get correct Pkl in PATH | -| PKL FrameSource change | Update ALL entry init calls in tests (EnumBridgingTests, IconsLoaderConfigTests) | -| Build fails | `swift package clean && swift build` | -| Tests fail | Check `FIGMA_PERSONAL_TOKEN` is set | -| Formatting fails | Run `./bin/mise run setup` to install tools | -| test:filter no matches | SPM converts hyphens→underscores: use `ExFig_FlutterTests` not `ExFig-FlutterTests` | -| Template errors | Check Jinja2 syntax and context variables | -| Linux test hangs | Build first: `swift build --build-tests`, then `swift test --skip-build --parallel` | -| Android pathData long | Simplify in Figma or use `--strict-path-validation` | -| PKL parse error 1 | Check `PklError.message` — actual error is in `.message`, not `.localizedDescription` | -| Test target won't compile | Broken test files block entire target; use `swift test --filter Target.Class` after `build` | -| Test helper JSON decode | `ContainingFrame` uses default Codable (camelCase: `nodeId`, `pageName`), NOT snake_case | -| Web entry test fails | Web entry types use `outputDirectory` field, while Android/Flutter use `output` | -| Logger concatenation err | `Logger.Message` (swift-log) requires interpolation `"\(a) \(b)"`, not concatenation `a + b` | -| Deleted variables in output | Filter `VariableValue.deletedButReferenced != true` in variable loaders AND `CodeSyntaxSyncer` | -| mise "sources up-to-date" | mise caches tasks with `sources`/`outputs` — run script directly via `bash` when debugging | -| Jinja trailing `\n` | `{% if false %}...{% endif %}\n` renders `"\n"`, not `""` — strip whitespace-only partial template results | -| `Bundle.module` in tests | SPM test targets without declared resources don't have `Bundle.module` — use `Bundle.main` or temp bundle | -| SwiftLint trailing closure | When function takes 2+ closures, use explicit label for last closure (`export: { ... }`) not trailing syntax | -| CLI flag default vs absent | swift-argument-parser can't distinguish explicit `--flag default_value` from omitted. Use `Optional` + computed `effectiveX` property for flags that wizard may override | -| MCP `Client` ambiguous | `FigmaAPI.Client` vs `MCP.Client` — always use `FigmaAPI.Client` in MCP/ files | -| MCP `FigmaConfig` fields | No `colorsFileId` — use `config.getFileIds()` or `figma.lightFileId`/`darkFileId` | -| `distantFuture` on clock | `ContinuousClock.Instant` has no `distantFuture`; use `withCheckedContinuation { _ in }` for infinite suspend | -| MCP stderr duplication | `TerminalOutputManager.setStderrMode(true)` handles all output routing — don't duplicate in `ExFigLogHandler` | -| MCP `Process` race condition | Set `terminationHandler` BEFORE `process.run()` — process may exit before handler is installed, hanging the continuation forever | -| MCP pipe deadlock | Read stderr via concurrent `Task` BEFORE waiting for termination — pipe buffer (~64KB) can fill and block the subprocess | -| MCP `encodeJSON` errors | Use `throws` not `try?` — silently returning `"\(value)"` (Swift debug dump) breaks JSON consumers; top-level `do/catch` in `handle()` catches automatically | -| `VariablesColors` vs `Colors` | `ColorsVariablesLoader` takes `Common.VariablesColors?`, not `Common.Colors?` — different PKL types | -| PKL template word search | Template comments on `lightFileId` contain cross-refs (`variablesColors`, `typography`); test section removal by matching full markers (`colors = new Common.Colors {`) not bare words | -| CI llms-full.txt stale | `llms-full.txt` is generated from README + DocC articles; after editing `Usage.md`, `ExFig.md`, or `README.md`, run `./bin/mise run generate:llms` and commit the result | -| Release build .pcm warnings | Stale `ModuleCache` — clean with: `rm -r .build/*/release/ModuleCache` then rebuild | -| `nil` in switch expression | After adding enum case, `nil` in `String?` switch branch fails to compile | -| PKL↔Swift enum rawValue | PKL kebab `"tokens-file"` → `.tokensFile`, but Swift rawValue is `"tokensFile"` — rawValue round-trip fails | +| Problem | Solution | +| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| codegen:pkl gen.pkl error | gen.pkl `read?` bug: needs `--generator-settings` + `--project-dir` flags (see mise.toml) | +| xcsift "signal code 5" | False positive when piping `swift test` through xcsift; run `swift test` directly to verify | +| PKL tests need Pkl 0.31+ | Schemas use `isNotEmpty`; run tests via `./bin/mise exec -- swift test` to get correct Pkl in PATH | +| PKL FrameSource change | Update ALL entry init calls in tests (EnumBridgingTests, IconsLoaderConfigTests) | +| Build fails | `swift package clean && swift build` | +| Tests fail | Check `FIGMA_PERSONAL_TOKEN` is set | +| Formatting fails | Run `./bin/mise run setup` to install tools | +| test:filter no matches | SPM converts hyphens→underscores: use `ExFig_FlutterTests` not `ExFig-FlutterTests` | +| Template errors | Check Jinja2 syntax and context variables | +| Linux test hangs | Build first: `swift build --build-tests`, then `swift test --skip-build --parallel` | +| Android pathData long | Simplify in Figma or use `--strict-path-validation` | +| PKL parse error 1 | Check `PklError.message` — actual error is in `.message`, not `.localizedDescription` | +| Test target won't compile | Broken test files block entire target; use `swift test --filter Target.Class` after `build` | +| Test helper JSON decode | `ContainingFrame` uses default Codable (camelCase: `nodeId`, `pageName`), NOT snake_case | +| Web entry test fails | Web entry types use `outputDirectory` field, while Android/Flutter use `output` | +| Logger concatenation err | `Logger.Message` (swift-log) requires interpolation `"\(a) \(b)"`, not concatenation `a + b` | +| Deleted variables in output | Filter `VariableValue.deletedButReferenced != true` in variable loaders AND `CodeSyntaxSyncer` | +| mise "sources up-to-date" | mise caches tasks with `sources`/`outputs` — run script directly via `bash` when debugging | +| Jinja trailing `\n` | `{% if false %}...{% endif %}\n` renders `"\n"`, not `""` — strip whitespace-only partial template results | +| `Bundle.module` in tests | SPM test targets without declared resources don't have `Bundle.module` — use `Bundle.main` or temp bundle | +| SwiftLint trailing closure | When function takes 2+ closures, use explicit label for last closure (`export: { ... }`) not trailing syntax | +| CLI flag default vs absent | swift-argument-parser can't distinguish explicit `--flag default_value` from omitted. Use `Optional` + computed `effectiveX` property for flags that wizard may override | +| MCP `Client` ambiguous | `FigmaAPI.Client` vs `MCP.Client` — always use `FigmaAPI.Client` in MCP/ files | +| MCP `FigmaConfig` fields | No `colorsFileId` — use `config.getFileIds()` or `figma.lightFileId`/`darkFileId` | +| `distantFuture` on clock | `ContinuousClock.Instant` has no `distantFuture`; use `withCheckedContinuation { _ in }` for infinite suspend | +| MCP stderr duplication | `TerminalOutputManager.setStderrMode(true)` handles all output routing — don't duplicate in `ExFigLogHandler` | +| MCP `Process` race condition | Set `terminationHandler` BEFORE `process.run()` — process may exit before handler is installed, hanging the continuation forever | +| MCP pipe deadlock | Read stderr via concurrent `Task` BEFORE waiting for termination — pipe buffer (~64KB) can fill and block the subprocess | +| MCP `encodeJSON` errors | Use `throws` not `try?` — silently returning `"\(value)"` (Swift debug dump) breaks JSON consumers; top-level `do/catch` in `handle()` catches automatically | +| `VariablesColors` vs `Colors` | `ColorsVariablesLoader` takes `Common.VariablesColors?`, not `Common.Colors?` — different PKL types | +| PKL template word search | Template comments on `lightFileId` contain cross-refs (`variablesColors`, `typography`); test section removal by matching full markers (`colors = new Common.Colors {`) not bare words | +| CI llms-full.txt stale | `llms-full.txt` is generated from README + DocC articles; after editing `Usage.md`, `ExFig.md`, or `README.md`, run `./bin/mise run generate:llms` and commit the result | +| Release build .pcm warnings | Stale `ModuleCache` — clean with: `rm -r .build/*/release/ModuleCache` then rebuild | +| `nil` in switch expression | After adding enum case, `nil` in `String?` switch branch fails to compile | +| PKL↔Swift enum rawValue | PKL kebab `"tokens-file"` → `.tokensFile`, but Swift rawValue is `"tokensFile"` — rawValue round-trip fails | +| `unsupportedSourceKind` compile err | Changed to `.unsupportedSourceKind(kind, assetType:)` — add asset type string ("colors", "icons/images", "typography") | ## Additional Rules diff --git a/Sources/ExFig-Android/Export/AndroidColorsExporter.swift b/Sources/ExFig-Android/Export/AndroidColorsExporter.swift index b08e984a..e30f7ff6 100644 --- a/Sources/ExFig-Android/Export/AndroidColorsExporter.swift +++ b/Sources/ExFig-Android/Export/AndroidColorsExporter.swift @@ -63,7 +63,7 @@ public struct AndroidColorsExporter: ColorsExporter { // 1. Load colors from Figma let sourceInput = try entry.validatedColorsSourceInput() let colors = try await context.withSpinner( - "Fetching colors (\(sourceInput.sourceKind.rawValue))..." + "Fetching colors from \(sourceInput.spinnerLabel)..." ) { try await context.loadColors(from: sourceInput) } diff --git a/Sources/ExFig-Flutter/Export/FlutterColorsExporter.swift b/Sources/ExFig-Flutter/Export/FlutterColorsExporter.swift index 45415b96..1cc491c3 100644 --- a/Sources/ExFig-Flutter/Export/FlutterColorsExporter.swift +++ b/Sources/ExFig-Flutter/Export/FlutterColorsExporter.swift @@ -52,7 +52,7 @@ public struct FlutterColorsExporter: ColorsExporter { // 1. Load colors from Figma let sourceInput = try entry.validatedColorsSourceInput() let colors = try await context.withSpinner( - "Fetching colors (\(sourceInput.sourceKind.rawValue))..." + "Fetching colors from \(sourceInput.spinnerLabel)..." ) { try await context.loadColors(from: sourceInput) } diff --git a/Sources/ExFig-Web/Export/WebColorsExporter.swift b/Sources/ExFig-Web/Export/WebColorsExporter.swift index f8682dc9..dc99258a 100644 --- a/Sources/ExFig-Web/Export/WebColorsExporter.swift +++ b/Sources/ExFig-Web/Export/WebColorsExporter.swift @@ -52,7 +52,7 @@ public struct WebColorsExporter: ColorsExporter { // 1. Load colors from Figma let sourceInput = try entry.validatedColorsSourceInput() let colors = try await context.withSpinner( - "Fetching colors (\(sourceInput.sourceKind.rawValue))..." + "Fetching colors from \(sourceInput.spinnerLabel)..." ) { try await context.loadColors(from: sourceInput) } diff --git a/Sources/ExFig-iOS/Export/iOSColorsExporter.swift b/Sources/ExFig-iOS/Export/iOSColorsExporter.swift index 1d417b10..c3832623 100644 --- a/Sources/ExFig-iOS/Export/iOSColorsExporter.swift +++ b/Sources/ExFig-iOS/Export/iOSColorsExporter.swift @@ -65,7 +65,7 @@ public struct iOSColorsExporter: ColorsExporter { // 1. Load colors from Figma let sourceInput = try entry.validatedColorsSourceInput() let colors = try await context.withSpinner( - "Fetching colors (\(sourceInput.sourceKind.rawValue))..." + "Fetching colors from \(sourceInput.spinnerLabel)..." ) { try await context.loadColors(from: sourceInput) } diff --git a/Sources/ExFigCLI/CLAUDE.md b/Sources/ExFigCLI/CLAUDE.md index ca0a6a47..36fdd703 100644 --- a/Sources/ExFigCLI/CLAUDE.md +++ b/Sources/ExFigCLI/CLAUDE.md @@ -206,6 +206,13 @@ reserved for MCP JSON-RPC protocol. ## Modification Patterns +### Source Dispatch Pattern + +`ColorsExportContextImpl.loadColors()` creates source via `SourceFactory.createColorsSource(for:...)` per call. +`IconsExportContextImpl` / `ImagesExportContextImpl` still use injected `componentsSource` (only Figma supported). +`PluginColorsExport` does NOT create sources — context handles dispatch internally. +When adding a new source kind: update `SourceFactory`, add source impl in `Source/`, update error `assetType`. + ### Adding a New Subcommand 1. Create `Subcommands/NewCommand.swift` implementing `AsyncParsableCommand` diff --git a/Sources/ExFigCLI/Context/ColorsExportContextImpl.swift b/Sources/ExFigCLI/Context/ColorsExportContextImpl.swift index a4e78fe2..07358e4a 100644 --- a/Sources/ExFigCLI/Context/ColorsExportContextImpl.swift +++ b/Sources/ExFigCLI/Context/ColorsExportContextImpl.swift @@ -5,26 +5,23 @@ import Foundation /// Concrete implementation of `ColorsExportContext` for the ExFig CLI. /// /// Bridges between the plugin system and ExFig's internal services: -/// - Uses injected `ColorsSource` for design data loading +/// - Uses `SourceFactory` for per-entry source dispatch based on `sourceKind` /// - Uses `ColorsProcessor` for platform-specific processing /// - Uses `ExFigCommand.fileWriter` for file output /// - Uses `TerminalUI` for progress and logging struct ColorsExportContextImpl: ColorsExportContext { let client: Client - let colorsSource: any ColorsSource let ui: TerminalUI let filter: String? let isBatchMode: Bool init( client: Client, - colorsSource: any ColorsSource, ui: TerminalUI, filter: String? = nil, isBatchMode: Bool = false ) { self.client = client - self.colorsSource = colorsSource self.ui = ui self.filter = filter self.isBatchMode = isBatchMode @@ -58,7 +55,10 @@ struct ColorsExportContextImpl: ColorsExportContext { // MARK: - ColorsExportContext func loadColors(from source: ColorsSourceInput) async throws -> ColorsLoadOutput { - try await colorsSource.loadColors(from: source) + let colorsSource = try SourceFactory.createColorsSource( + for: source, client: client, ui: ui, filter: filter + ) + return try await colorsSource.loadColors(from: source) } func processColors( diff --git a/Sources/ExFigCLI/ExFigCommand.swift b/Sources/ExFigCLI/ExFigCommand.swift index 2519de79..e8434e68 100644 --- a/Sources/ExFigCLI/ExFigCommand.swift +++ b/Sources/ExFigCLI/ExFigCommand.swift @@ -12,7 +12,7 @@ enum ExFigError: LocalizedError { case colorsAssetsFolderNotSpecified case configurationError(String) case custom(errorString: String) - case unsupportedSourceKind(DesignSourceKind) + case unsupportedSourceKind(DesignSourceKind, assetType: String) var errorDescription: String? { switch self { @@ -36,8 +36,8 @@ enum ExFigError: LocalizedError { "Config error: \(message)" case let .custom(errorString): errorString - case let .unsupportedSourceKind(kind): - "Unsupported design source: \(kind.rawValue)" + case let .unsupportedSourceKind(kind, assetType): + "Unsupported design source '\(kind.rawValue)' for \(assetType)" } } @@ -60,8 +60,13 @@ enum ExFigError: LocalizedError { "Add ios.colors.assetsFolder to your config file" case .configurationError, .custom: nil as String? - case .unsupportedSourceKind: - "Currently supported sources: figma, tokensFile" + case let .unsupportedSourceKind(_, assetType): + switch assetType { + case "colors": + "Supported sources for colors: figma, tokensFile" + default: + "Supported sources for \(assetType): figma" + } } } } diff --git a/Sources/ExFigCLI/Source/SourceFactory.swift b/Sources/ExFigCLI/Source/SourceFactory.swift index 424c2050..e5b31ab9 100644 --- a/Sources/ExFigCLI/Source/SourceFactory.swift +++ b/Sources/ExFigCLI/Source/SourceFactory.swift @@ -17,7 +17,7 @@ enum SourceFactory { case .tokensFile: TokensFileColorsSource(ui: ui) case .penpot, .tokensStudio, .sketchFile: - throw ExFigError.unsupportedSourceKind(input.sourceKind) + throw ExFigError.unsupportedSourceKind(input.sourceKind, assetType: "colors") } } @@ -40,7 +40,7 @@ enum SourceFactory { filter: filter ) case .penpot, .tokensFile, .tokensStudio, .sketchFile: - throw ExFigError.unsupportedSourceKind(sourceKind) + throw ExFigError.unsupportedSourceKind(sourceKind, assetType: "icons/images") } } @@ -52,7 +52,7 @@ enum SourceFactory { case .figma: FigmaTypographySource(client: client) case .penpot, .tokensFile, .tokensStudio, .sketchFile: - throw ExFigError.unsupportedSourceKind(sourceKind) + throw ExFigError.unsupportedSourceKind(sourceKind, assetType: "typography") } } } diff --git a/Sources/ExFigCLI/Source/TokensFileColorsSource.swift b/Sources/ExFigCLI/Source/TokensFileColorsSource.swift index 7682ff4d..eb2902d2 100644 --- a/Sources/ExFigCLI/Source/TokensFileColorsSource.swift +++ b/Sources/ExFigCLI/Source/TokensFileColorsSource.swift @@ -11,6 +11,14 @@ struct TokensFileColorsSource: ColorsSource { ) } + // Warn about Figma-specific mode fields that are ignored by tokens-file source + if !config.ignoredModeNames.isEmpty { + let fields = config.ignoredModeNames.joined(separator: ", ") + ui.warning( + "Local tokens file provides single-mode colors only — \(fields) will be ignored" + ) + } + var source = try TokensFileSource.parse(fileAt: config.filePath) try source.resolveAliases() diff --git a/Sources/ExFigCLI/Subcommands/Export/PluginColorsExport.swift b/Sources/ExFigCLI/Subcommands/Export/PluginColorsExport.swift index 5b555c29..c1cb156b 100644 --- a/Sources/ExFigCLI/Subcommands/Export/PluginColorsExport.swift +++ b/Sources/ExFigCLI/Subcommands/Export/PluginColorsExport.swift @@ -30,12 +30,10 @@ extension ExFigCommand.ExportColors { ) async throws -> Int { let platformConfig = ios.platformConfig() - // Create context + // Create context (SourceFactory dispatches per-entry based on sourceKind) let batchMode = BatchSharedState.current?.isBatchMode ?? false - let colorsSource = FigmaColorsSource(client: client, ui: ui, filter: filter) let context = ColorsExportContextImpl( client: client, - colorsSource: colorsSource, ui: ui, filter: filter, isBatchMode: batchMode @@ -127,10 +125,8 @@ extension ExFigCommand.ExportColors { let platformConfig = android.platformConfig() let batchMode = BatchSharedState.current?.isBatchMode ?? false - let colorsSource = FigmaColorsSource(client: client, ui: ui, filter: filter) let context = ColorsExportContextImpl( client: client, - colorsSource: colorsSource, ui: ui, filter: filter, isBatchMode: batchMode @@ -165,10 +161,8 @@ extension ExFigCommand.ExportColors { let platformConfig = flutter.platformConfig() let batchMode = BatchSharedState.current?.isBatchMode ?? false - let colorsSource = FigmaColorsSource(client: client, ui: ui, filter: filter) let context = ColorsExportContextImpl( client: client, - colorsSource: colorsSource, ui: ui, filter: filter, isBatchMode: batchMode @@ -203,10 +197,8 @@ extension ExFigCommand.ExportColors { let platformConfig = web.platformConfig() let batchMode = BatchSharedState.current?.isBatchMode ?? false - let colorsSource = FigmaColorsSource(client: client, ui: ui, filter: filter) let context = ColorsExportContextImpl( client: client, - colorsSource: colorsSource, ui: ui, filter: filter, isBatchMode: batchMode diff --git a/Sources/ExFigConfig/VariablesSourceValidation.swift b/Sources/ExFigConfig/VariablesSourceValidation.swift index 62df530a..dec5b878 100644 --- a/Sources/ExFigConfig/VariablesSourceValidation.swift +++ b/Sources/ExFigConfig/VariablesSourceValidation.swift @@ -24,9 +24,16 @@ public extension Common_VariablesSource { guard let tokensFile else { throw ColorsConfigError.missingTokensFileId } + // Collect Figma-specific mode fields that will be ignored by tokens-file source + var ignoredModes: [String] = [] + if darkModeName != nil { ignoredModes.append("darkModeName") } + if lightHCModeName != nil { ignoredModes.append("lightHCModeName") } + if darkHCModeName != nil { ignoredModes.append("darkHCModeName") } + let config = TokensFileColorsConfig( filePath: tokensFile.path, - groupFilter: tokensFile.groupFilter + groupFilter: tokensFile.groupFilter, + ignoredModeNames: ignoredModes ) return ColorsSourceInput( sourceKind: .tokensFile, diff --git a/Sources/ExFigCore/CLAUDE.md b/Sources/ExFigCore/CLAUDE.md index 5459aca9..9dcdd859 100644 --- a/Sources/ExFigCore/CLAUDE.md +++ b/Sources/ExFigCore/CLAUDE.md @@ -31,7 +31,10 @@ Exporter.export*(entries, platformConfig, context) - `DesignSourceKind` enum — dispatch discriminator (.figma, .penpot, .tokensFile, .tokensStudio, .sketchFile) - `ColorsSourceConfig` protocol + `FigmaColorsConfig` / `TokensFileColorsConfig` — type-erased source-specific config - `ColorsSourceInput` uses `sourceKind` + `sourceConfig: any ColorsSourceConfig` instead of flat fields +- `ColorsSourceInput.spinnerLabel`: computed property for user-facing spinner messages (dispatches on `sourceConfig` type) +- `TokensFileColorsConfig.ignoredModeNames`: carries Figma-specific mode field names set by user for warning - `IconsSourceInput`, `ImagesSourceInput`, `TypographySourceInput` have `sourceKind` field (default `.figma`) +- When adding a new `ColorsSourceConfig` subtype: update `spinnerLabel` switch in `ExportContext.swift` Implementations live in `Sources/ExFigCLI/Source/` — `FigmaColorsSource`, `TokensFileColorsSource`, `FigmaComponentsSource`, `FigmaTypographySource`, `SourceFactory`. diff --git a/Sources/ExFigCore/Protocol/DesignSource.swift b/Sources/ExFigCore/Protocol/DesignSource.swift index 4d2d2c02..1c41e291 100644 --- a/Sources/ExFigCore/Protocol/DesignSource.swift +++ b/Sources/ExFigCore/Protocol/DesignSource.swift @@ -74,12 +74,17 @@ public struct FigmaColorsConfig: ColorsSourceConfig { public struct TokensFileColorsConfig: ColorsSourceConfig { public let filePath: String public let groupFilter: String? + /// Mode names from PKL config that will be ignored (tokens file is single-mode). + /// Populated by validation when user sets darkModeName/lightHCModeName/darkHCModeName. + public let ignoredModeNames: [String] public init( filePath: String, - groupFilter: String? = nil + groupFilter: String? = nil, + ignoredModeNames: [String] = [] ) { self.filePath = filePath self.groupFilter = groupFilter + self.ignoredModeNames = ignoredModeNames } } diff --git a/Sources/ExFigCore/Protocol/ExportContext.swift b/Sources/ExFigCore/Protocol/ExportContext.swift index 12129e2f..ad5dfe0b 100644 --- a/Sources/ExFigCore/Protocol/ExportContext.swift +++ b/Sources/ExFigCore/Protocol/ExportContext.swift @@ -113,6 +113,24 @@ public struct ColorsSourceInput: Sendable { self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } + + /// Human-readable label for spinner messages (e.g., "Figma Variables (Brand Colors)"). + public var spinnerLabel: String { + switch sourceKind { + case .figma: + if let config = sourceConfig as? FigmaColorsConfig { + return "Figma Variables (\(config.tokensCollectionName))" + } + return "Figma" + case .tokensFile: + if let config = sourceConfig as? TokensFileColorsConfig { + return URL(fileURLWithPath: config.filePath).lastPathComponent + } + return "tokens file" + case .penpot, .tokensStudio, .sketchFile: + return sourceKind.rawValue + } + } } /// Error thrown when required colors configuration fields are missing. diff --git a/Tests/ExFigTests/Input/DesignSourceTests.swift b/Tests/ExFigTests/Input/DesignSourceTests.swift new file mode 100644 index 00000000..4fd114b0 --- /dev/null +++ b/Tests/ExFigTests/Input/DesignSourceTests.swift @@ -0,0 +1,296 @@ +import ExFig_iOS +@testable import ExFigCLI +import ExFigConfig +import ExFigCore +import XCTest + +// MARK: - SourceKindBridging Tests + +final class SourceKindBridgingTests: XCTestCase { + func testAllPKLSourceKindCasesBridgeToExFigCore() { + // Every Common.SourceKind case must map to a DesignSourceKind. + for pklCase in Common.SourceKind.allCases { + let core = pklCase.coreSourceKind + XCTAssertNotNil( + core, + "Common.SourceKind.\(pklCase) should bridge to a non-nil DesignSourceKind" + ) + } + } + + func testSourceKindBridgingValues() { + // Explicit mapping verification (PKL kebab → Swift camelCase). + let expectations: [(Common.SourceKind, DesignSourceKind)] = [ + (.figma, .figma), + (.penpot, .penpot), + (.tokensFile, .tokensFile), + (.tokensStudio, .tokensStudio), + (.sketchFile, .sketchFile), + ] + for (pkl, expected) in expectations { + XCTAssertEqual( + pkl.coreSourceKind, expected, + "Common.SourceKind.\(pkl) should bridge to .\(expected)" + ) + } + } + + func testSourceKindCaseCount() { + XCTAssertEqual( + Common.SourceKind.allCases.count, + DesignSourceKind.allCases.count, + "Common.SourceKind and DesignSourceKind should have the same number of cases" + ) + } +} + +// MARK: - Explicit sourceKind Override Tests + +final class ExplicitSourceKindTests: XCTestCase { + func testExplicitFigmaSourceKindOverridesAutoDetection() throws { + // When sourceKind is explicitly .figma, it should use Figma even if tokensFile is set. + let entry = iOS.ColorsEntry( + useColorAssets: false, + assetsFolder: nil, + nameStyle: .camelCase, + groupUsingNamespace: nil, + assetsFolderProvidesNamespace: nil, + colorSwift: nil, + swiftuiColorSwift: nil, + xcassetsPath: nil, + templatesPath: nil, + syncCodeSyntax: nil, + codeSyntaxTemplate: nil, + sourceKind: .figma, + tokensFile: Common.TokensFile(path: "tokens.json", groupFilter: nil), + tokensFileId: "file123", + tokensCollectionName: "Collection", + lightModeName: "Light", + darkModeName: nil, + lightHCModeName: nil, + darkHCModeName: nil, + primitivesModeName: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + let sourceInput = try entry.validatedColorsSourceInput() + XCTAssertEqual(sourceInput.sourceKind, .figma) + XCTAssert(sourceInput.sourceConfig is FigmaColorsConfig) + } + + func testExplicitTokensFileSourceKindWorks() throws { + let entry = iOS.ColorsEntry( + useColorAssets: false, + assetsFolder: nil, + nameStyle: .camelCase, + groupUsingNamespace: nil, + assetsFolderProvidesNamespace: nil, + colorSwift: nil, + swiftuiColorSwift: nil, + xcassetsPath: nil, + templatesPath: nil, + syncCodeSyntax: nil, + codeSyntaxTemplate: nil, + sourceKind: .tokensFile, + tokensFile: Common.TokensFile(path: "design.json", groupFilter: "Brand"), + tokensFileId: nil, + tokensCollectionName: nil, + lightModeName: nil, + darkModeName: nil, + lightHCModeName: nil, + darkHCModeName: nil, + primitivesModeName: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + let sourceInput = try entry.validatedColorsSourceInput() + XCTAssertEqual(sourceInput.sourceKind, .tokensFile) + let config = try XCTUnwrap(sourceInput.sourceConfig as? TokensFileColorsConfig) + XCTAssertEqual(config.filePath, "design.json") + XCTAssertEqual(config.groupFilter, "Brand") + } + + func testExplicitTokensFileWithoutTokensFileThrows() { + // sourceKind: .tokensFile but no tokensFile block → should throw + let entry = iOS.ColorsEntry( + useColorAssets: false, + assetsFolder: nil, + nameStyle: .camelCase, + groupUsingNamespace: nil, + assetsFolderProvidesNamespace: nil, + colorSwift: nil, + swiftuiColorSwift: nil, + xcassetsPath: nil, + templatesPath: nil, + syncCodeSyntax: nil, + codeSyntaxTemplate: nil, + sourceKind: .tokensFile, + tokensFile: nil, + tokensFileId: nil, + tokensCollectionName: nil, + lightModeName: nil, + darkModeName: nil, + lightHCModeName: nil, + darkHCModeName: nil, + primitivesModeName: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + XCTAssertThrowsError(try entry.validatedColorsSourceInput()) { error in + XCTAssert(error is ColorsConfigError) + } + } +} + +// MARK: - IgnoredModeNames Tests + +final class IgnoredModeNamesTests: XCTestCase { + func testTokensFileCollectsIgnoredModeNames() throws { + let entry = iOS.ColorsEntry( + useColorAssets: false, + assetsFolder: nil, + nameStyle: .camelCase, + groupUsingNamespace: nil, + assetsFolderProvidesNamespace: nil, + colorSwift: nil, + swiftuiColorSwift: nil, + xcassetsPath: nil, + templatesPath: nil, + syncCodeSyntax: nil, + codeSyntaxTemplate: nil, + sourceKind: nil, + tokensFile: Common.TokensFile(path: "tokens.json", groupFilter: nil), + tokensFileId: nil, + tokensCollectionName: nil, + lightModeName: nil, + darkModeName: "Dark", + lightHCModeName: "LightHC", + darkHCModeName: nil, + primitivesModeName: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + let sourceInput = try entry.validatedColorsSourceInput() + let config = try XCTUnwrap(sourceInput.sourceConfig as? TokensFileColorsConfig) + XCTAssertEqual(config.ignoredModeNames, ["darkModeName", "lightHCModeName"]) + } + + func testTokensFileWithoutDarkModeHasNoIgnoredModes() throws { + let entry = iOS.ColorsEntry( + useColorAssets: false, + assetsFolder: nil, + nameStyle: .camelCase, + groupUsingNamespace: nil, + assetsFolderProvidesNamespace: nil, + colorSwift: nil, + swiftuiColorSwift: nil, + xcassetsPath: nil, + templatesPath: nil, + syncCodeSyntax: nil, + codeSyntaxTemplate: nil, + sourceKind: nil, + tokensFile: Common.TokensFile(path: "tokens.json", groupFilter: nil), + tokensFileId: nil, + tokensCollectionName: nil, + lightModeName: nil, + darkModeName: nil, + lightHCModeName: nil, + darkHCModeName: nil, + primitivesModeName: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + let sourceInput = try entry.validatedColorsSourceInput() + let config = try XCTUnwrap(sourceInput.sourceConfig as? TokensFileColorsConfig) + XCTAssertTrue(config.ignoredModeNames.isEmpty) + } + + func testTokensFileCollectsAllThreeIgnoredModeNames() throws { + let entry = iOS.ColorsEntry( + useColorAssets: false, + assetsFolder: nil, + nameStyle: .camelCase, + groupUsingNamespace: nil, + assetsFolderProvidesNamespace: nil, + colorSwift: nil, + swiftuiColorSwift: nil, + xcassetsPath: nil, + templatesPath: nil, + syncCodeSyntax: nil, + codeSyntaxTemplate: nil, + sourceKind: nil, + tokensFile: Common.TokensFile(path: "tokens.json", groupFilter: nil), + tokensFileId: nil, + tokensCollectionName: nil, + lightModeName: nil, + darkModeName: "Dark", + lightHCModeName: "LightHC", + darkHCModeName: "DarkHC", + primitivesModeName: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + let sourceInput = try entry.validatedColorsSourceInput() + let config = try XCTUnwrap(sourceInput.sourceConfig as? TokensFileColorsConfig) + XCTAssertEqual(config.ignoredModeNames, ["darkModeName", "lightHCModeName", "darkHCModeName"]) + } +} + +// MARK: - SpinnerLabel Tests + +final class SpinnerLabelTests: XCTestCase { + func testFigmaSpinnerLabelIncludesCollectionName() { + let input = ColorsSourceInput( + sourceKind: .figma, + sourceConfig: FigmaColorsConfig( + tokensFileId: "file123", + tokensCollectionName: "Brand Colors", + lightModeName: "Light" + ) + ) + XCTAssertEqual(input.spinnerLabel, "Figma Variables (Brand Colors)") + } + + func testTokensFileSpinnerLabelShowsFileName() { + let input = ColorsSourceInput( + sourceKind: .tokensFile, + sourceConfig: TokensFileColorsConfig(filePath: "/path/to/design-tokens.json") + ) + XCTAssertEqual(input.spinnerLabel, "design-tokens.json") + } + + func testUnsupportedSourceKindSpinnerLabelShowsRawValue() { + let input = ColorsSourceInput( + sourceKind: .penpot, + sourceConfig: FigmaColorsConfig( + tokensFileId: "", tokensCollectionName: "", lightModeName: "" + ) + ) + XCTAssertEqual(input.spinnerLabel, "penpot") + } +} + +// MARK: - ExFigError.unsupportedSourceKind Tests + +final class UnsupportedSourceKindErrorTests: XCTestCase { + func testErrorDescriptionIncludesAssetType() { + let error = ExFigError.unsupportedSourceKind(.penpot, assetType: "colors") + XCTAssertTrue(error.errorDescription?.contains("colors") == true) + XCTAssertTrue(error.errorDescription?.contains("penpot") == true) + } + + func testRecoverySuggestionForColorsListsTokensFile() { + let error = ExFigError.unsupportedSourceKind(.penpot, assetType: "colors") + XCTAssertTrue(error.recoverySuggestion?.contains("tokensFile") == true) + } + + func testRecoverySuggestionForIconsDoesNotListTokensFile() { + let error = ExFigError.unsupportedSourceKind(.penpot, assetType: "icons/images") + XCTAssertFalse(error.recoverySuggestion?.contains("tokensFile") == true) + } + + func testRecoverySuggestionForTypographyDoesNotListTokensFile() { + let error = ExFigError.unsupportedSourceKind(.penpot, assetType: "typography") + XCTAssertFalse(error.recoverySuggestion?.contains("tokensFile") == true) + } +}