From 6da3de3712358f59650f47e85786fd922bf5504d Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sat, 21 Mar 2026 21:14:30 +0500 Subject: [PATCH 01/26] feat(core): add Penpot API client and design source integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement Penpot as a second design data source alongside Figma: - New PenpotAPI module: HTTP client for Penpot RPC API with endpoint protocol, retry logic, and response models (colors, components, typographies) supporting dual String/Double decoding - PenpotColorsSource, PenpotComponentsSource, PenpotTypographySource wired into SourceFactory for seamless dispatch via sourceKind - PenpotColorsConfig added to ColorsSourceConfig protocol family - PenpotSource class in Common.pkl with auto-detection (penpotSource set → sourceKind "penpot") and codegen - Entry bridge methods updated to use resolvedSourceKind across all four platform plugins - 21 unit tests for model decoding, endpoint construction, and error recovery suggestions --- Package.swift | 21 +++ .../Config/AndroidIconsEntry.swift | 2 +- .../Config/AndroidImagesEntry.swift | 2 +- .../Config/FlutterIconsEntry.swift | 2 +- .../Config/FlutterImagesEntry.swift | 4 +- Sources/ExFig-Web/Config/WebIconsEntry.swift | 2 +- Sources/ExFig-Web/Config/WebImagesEntry.swift | 2 +- Sources/ExFig-iOS/Config/iOSIconsEntry.swift | 2 +- Sources/ExFig-iOS/Config/iOSImagesEntry.swift | 2 +- Sources/ExFigCLI/Resources/Schemas/Common.pkl | 26 ++- .../ExFigCLI/Source/PenpotColorsSource.swift | 84 ++++++++++ .../Source/PenpotComponentsSource.swift | 104 ++++++++++++ .../Source/PenpotTypographySource.swift | 61 +++++++ Sources/ExFigCLI/Source/SourceFactory.swift | 12 +- .../ExFigConfig/Generated/Android.pkl.swift | 24 ++- .../ExFigConfig/Generated/Common.pkl.swift | 40 ++++- .../ExFigConfig/Generated/Flutter.pkl.swift | 24 ++- Sources/ExFigConfig/Generated/Web.pkl.swift | 24 ++- Sources/ExFigConfig/Generated/iOS.pkl.swift | 24 ++- Sources/ExFigConfig/PKL/PKLEvaluator.swift | 1 + Sources/ExFigConfig/SourceKindBridging.swift | 15 ++ .../VariablesSourceValidation.swift | 83 +++++++--- Sources/ExFigCore/Protocol/DesignSource.swift | 17 ++ .../ExFigCore/Protocol/ExportContext.swift | 8 +- Sources/PenpotAPI/CLAUDE.md | 19 +++ Sources/PenpotAPI/Client/PenpotAPIError.swift | 43 +++++ Sources/PenpotAPI/Client/PenpotClient.swift | 154 ++++++++++++++++++ Sources/PenpotAPI/Client/PenpotEndpoint.swift | 19 +++ .../PenpotAPI/Endpoints/GetFileEndpoint.swift | 25 +++ .../GetFileObjectThumbnailsEndpoint.swift | 34 ++++ .../Endpoints/GetProfileEndpoint.swift | 22 +++ Sources/PenpotAPI/Models/PenpotColor.swift | 30 ++++ .../PenpotAPI/Models/PenpotComponent.swift | 33 ++++ .../PenpotAPI/Models/PenpotFileResponse.swift | 25 +++ Sources/PenpotAPI/Models/PenpotProfile.swift | 13 ++ .../PenpotAPI/Models/PenpotTypography.swift | 100 ++++++++++++ .../ExFigTests/Input/DesignSourceTests.swift | 14 +- .../ExFigTests/Input/EnumBridgingTests.swift | 76 ++++++--- .../Fixtures/file-response.json | 70 ++++++++ .../PenpotAPITests/PenpotAPIErrorTests.swift | 40 +++++ .../PenpotColorDecodingTests.swift | 67 ++++++++ .../PenpotComponentDecodingTests.swift | 55 +++++++ .../PenpotAPITests/PenpotEndpointTests.swift | 43 +++++ .../PenpotTypographyDecodingTests.swift | 83 ++++++++++ openspec/changes/add-penpot-support/tasks.md | 70 ++++---- 45 files changed, 1511 insertions(+), 110 deletions(-) create mode 100644 Sources/ExFigCLI/Source/PenpotColorsSource.swift create mode 100644 Sources/ExFigCLI/Source/PenpotComponentsSource.swift create mode 100644 Sources/ExFigCLI/Source/PenpotTypographySource.swift create mode 100644 Sources/PenpotAPI/CLAUDE.md create mode 100644 Sources/PenpotAPI/Client/PenpotAPIError.swift create mode 100644 Sources/PenpotAPI/Client/PenpotClient.swift create mode 100644 Sources/PenpotAPI/Client/PenpotEndpoint.swift create mode 100644 Sources/PenpotAPI/Endpoints/GetFileEndpoint.swift create mode 100644 Sources/PenpotAPI/Endpoints/GetFileObjectThumbnailsEndpoint.swift create mode 100644 Sources/PenpotAPI/Endpoints/GetProfileEndpoint.swift create mode 100644 Sources/PenpotAPI/Models/PenpotColor.swift create mode 100644 Sources/PenpotAPI/Models/PenpotComponent.swift create mode 100644 Sources/PenpotAPI/Models/PenpotFileResponse.swift create mode 100644 Sources/PenpotAPI/Models/PenpotProfile.swift create mode 100644 Sources/PenpotAPI/Models/PenpotTypography.swift create mode 100644 Tests/PenpotAPITests/Fixtures/file-response.json create mode 100644 Tests/PenpotAPITests/PenpotAPIErrorTests.swift create mode 100644 Tests/PenpotAPITests/PenpotColorDecodingTests.swift create mode 100644 Tests/PenpotAPITests/PenpotComponentDecodingTests.swift create mode 100644 Tests/PenpotAPITests/PenpotEndpointTests.swift create mode 100644 Tests/PenpotAPITests/PenpotTypographyDecodingTests.swift diff --git a/Package.swift b/Package.swift index 198143f9..f6136da9 100644 --- a/Package.swift +++ b/Package.swift @@ -36,6 +36,7 @@ let package = Package( name: "ExFigCLI", dependencies: [ .product(name: "FigmaAPI", package: "swift-figma-api"), + "PenpotAPI", "ExFigCore", "ExFigConfig", "XcodeExport", @@ -145,6 +146,15 @@ let package = Package( ] ), + // Penpot API client + .target( + name: "PenpotAPI", + dependencies: [ + .product(name: "YYJSON", package: "swift-yyjson"), + ], + exclude: ["CLAUDE.md"] + ), + // MARK: - Platform Plugins // iOS platform plugin @@ -247,6 +257,17 @@ let package = Package( ] ), + .testTarget( + name: "PenpotAPITests", + dependencies: [ + "PenpotAPI", + .product(name: "CustomDump", package: "swift-custom-dump"), + ], + resources: [ + .copy("Fixtures/"), + ] + ), + // MARK: - Plugin Tests .testTarget( diff --git a/Sources/ExFig-Android/Config/AndroidIconsEntry.swift b/Sources/ExFig-Android/Config/AndroidIconsEntry.swift index dec15f5f..ad2364e3 100644 --- a/Sources/ExFig-Android/Config/AndroidIconsEntry.swift +++ b/Sources/ExFig-Android/Config/AndroidIconsEntry.swift @@ -14,7 +14,7 @@ public extension Android.IconsEntry { /// Returns an IconsSourceInput for use with IconsExportContext. func iconsSourceInput(darkFileId: String? = nil) -> IconsSourceInput { IconsSourceInput( - sourceKind: sourceKind?.coreSourceKind ?? .figma, + sourceKind: resolvedSourceKind, figmaFileId: figmaFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Icons", diff --git a/Sources/ExFig-Android/Config/AndroidImagesEntry.swift b/Sources/ExFig-Android/Config/AndroidImagesEntry.swift index 5eeefc11..4e4e6020 100644 --- a/Sources/ExFig-Android/Config/AndroidImagesEntry.swift +++ b/Sources/ExFig-Android/Config/AndroidImagesEntry.swift @@ -31,7 +31,7 @@ public extension Android.ImagesEntry { /// Returns an ImagesSourceInput for use with ImagesExportContext. func imagesSourceInput(darkFileId: String? = nil) -> ImagesSourceInput { ImagesSourceInput( - sourceKind: sourceKind?.coreSourceKind ?? .figma, + sourceKind: resolvedSourceKind, figmaFileId: figmaFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Images", diff --git a/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift b/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift index 0eddc5fe..913ec5d3 100644 --- a/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift +++ b/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift @@ -11,7 +11,7 @@ public extension Flutter.IconsEntry { /// Returns an IconsSourceInput for use with IconsExportContext. func iconsSourceInput(darkFileId: String? = nil) -> IconsSourceInput { IconsSourceInput( - sourceKind: sourceKind?.coreSourceKind ?? .figma, + sourceKind: resolvedSourceKind, figmaFileId: figmaFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Icons", diff --git a/Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift b/Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift index f043b064..50fc0fd0 100644 --- a/Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift +++ b/Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift @@ -14,7 +14,7 @@ public extension Flutter.ImagesEntry { /// Returns an ImagesSourceInput for use with ImagesExportContext. func imagesSourceInput(darkFileId: String? = nil) -> ImagesSourceInput { ImagesSourceInput( - sourceKind: sourceKind?.coreSourceKind ?? .figma, + sourceKind: resolvedSourceKind, figmaFileId: figmaFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Images", @@ -44,7 +44,7 @@ public extension Flutter.ImagesEntry { /// Returns an ImagesSourceInput configured for SVG source. func svgSourceInput(darkFileId: String? = nil) -> ImagesSourceInput { ImagesSourceInput( - sourceKind: sourceKind?.coreSourceKind ?? .figma, + sourceKind: resolvedSourceKind, figmaFileId: figmaFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Images", diff --git a/Sources/ExFig-Web/Config/WebIconsEntry.swift b/Sources/ExFig-Web/Config/WebIconsEntry.swift index 12500a0b..4a085c16 100644 --- a/Sources/ExFig-Web/Config/WebIconsEntry.swift +++ b/Sources/ExFig-Web/Config/WebIconsEntry.swift @@ -11,7 +11,7 @@ public extension Web.IconsEntry { /// Returns an IconsSourceInput for use with IconsExportContext. func iconsSourceInput(darkFileId: String? = nil) -> IconsSourceInput { IconsSourceInput( - sourceKind: sourceKind?.coreSourceKind ?? .figma, + sourceKind: resolvedSourceKind, figmaFileId: figmaFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Icons", diff --git a/Sources/ExFig-Web/Config/WebImagesEntry.swift b/Sources/ExFig-Web/Config/WebImagesEntry.swift index 21a0205d..58dd32fe 100644 --- a/Sources/ExFig-Web/Config/WebImagesEntry.swift +++ b/Sources/ExFig-Web/Config/WebImagesEntry.swift @@ -11,7 +11,7 @@ public extension Web.ImagesEntry { /// Returns an ImagesSourceInput for use with ImagesExportContext. func imagesSourceInput(darkFileId: String? = nil) -> ImagesSourceInput { ImagesSourceInput( - sourceKind: sourceKind?.coreSourceKind ?? .figma, + sourceKind: resolvedSourceKind, figmaFileId: figmaFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Images", diff --git a/Sources/ExFig-iOS/Config/iOSIconsEntry.swift b/Sources/ExFig-iOS/Config/iOSIconsEntry.swift index 3e7473e2..bc75ddc6 100644 --- a/Sources/ExFig-iOS/Config/iOSIconsEntry.swift +++ b/Sources/ExFig-iOS/Config/iOSIconsEntry.swift @@ -13,7 +13,7 @@ public extension iOS.IconsEntry { /// Returns an IconsSourceInput for use with IconsExportContext. func iconsSourceInput(darkFileId: String? = nil) -> IconsSourceInput { IconsSourceInput( - sourceKind: sourceKind?.coreSourceKind ?? .figma, + sourceKind: resolvedSourceKind, figmaFileId: figmaFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Icons", diff --git a/Sources/ExFig-iOS/Config/iOSImagesEntry.swift b/Sources/ExFig-iOS/Config/iOSImagesEntry.swift index 1ae68e57..4da0ed3a 100644 --- a/Sources/ExFig-iOS/Config/iOSImagesEntry.swift +++ b/Sources/ExFig-iOS/Config/iOSImagesEntry.swift @@ -13,7 +13,7 @@ public extension iOS.ImagesEntry { /// Returns an ImagesSourceInput for use with ImagesExportContext. func imagesSourceInput(darkFileId: String? = nil) -> ImagesSourceInput { ImagesSourceInput( - sourceKind: sourceKind?.coreSourceKind ?? .figma, + sourceKind: resolvedSourceKind, figmaFileId: figmaFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Images", diff --git a/Sources/ExFigCLI/Resources/Schemas/Common.pkl b/Sources/ExFigCLI/Resources/Schemas/Common.pkl index 32ea34f5..494d1628 100644 --- a/Sources/ExFigCLI/Resources/Schemas/Common.pkl +++ b/Sources/ExFigCLI/Resources/Schemas/Common.pkl @@ -45,6 +45,21 @@ class TokensFile { groupFilter: String? } +// MARK: - Penpot Source + +/// Penpot design source configuration. +/// When set on a colors/icons/images entry, loads assets from Penpot API instead of Figma. +class PenpotSource { + /// Penpot file UUID. + fileId: String(isNotEmpty) + + /// Penpot instance base URL (default: Penpot cloud). + baseUrl: String = "https://design.penpot.app/" + + /// Optional path prefix to filter assets (e.g., "Brand/Primary"). + pathFilter: String? +} + // MARK: - Cache /// Cache configuration for tracking Figma file versions. @@ -74,10 +89,14 @@ open class NameProcessing { /// 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 `penpotSource` is set → "penpot" /// - If `tokensFile` is set → "tokens-file" /// - Otherwise → "figma" sourceKind: SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + penpotSource: PenpotSource? + /// Local .tokens.json file source (bypasses Figma API when set). tokensFile: TokensFile? @@ -106,9 +125,14 @@ 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". + /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" + /// - Otherwise → "figma" sourceKind: SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + penpotSource: PenpotSource? + /// Figma frame name to export from. figmaFrameName: String? diff --git a/Sources/ExFigCLI/Source/PenpotColorsSource.swift b/Sources/ExFigCLI/Source/PenpotColorsSource.swift new file mode 100644 index 00000000..33200d73 --- /dev/null +++ b/Sources/ExFigCLI/Source/PenpotColorsSource.swift @@ -0,0 +1,84 @@ +import ExFigCore +import Foundation +import PenpotAPI + +struct PenpotColorsSource: ColorsSource { + let ui: TerminalUI + + func loadColors(from input: ColorsSourceInput) async throws -> ColorsLoadOutput { + guard let config = input.sourceConfig as? PenpotColorsConfig else { + throw ExFigError.configurationError( + "PenpotColorsSource requires PenpotColorsConfig, got \(type(of: input.sourceConfig))" + ) + } + + let client = try Self.makeClient(baseURL: config.baseURL) + let fileResponse = try await client.request(GetFileEndpoint(fileId: config.fileId)) + + guard let penpotColors = fileResponse.data.colors else { + return ColorsLoadOutput(light: []) + } + + var colors: [Color] = [] + + for (_, penpotColor) in penpotColors { + // Skip gradient/image fills (no solid hex) + guard let hex = penpotColor.color else { continue } + + // Apply path filter + if let pathFilter = config.pathFilter { + guard let path = penpotColor.path, path.hasPrefix(pathFilter) else { + continue + } + } + + let rgba = Self.hexToRGBA(hex: hex, opacity: penpotColor.opacity ?? 1.0) + + let name = if let path = penpotColor.path { + path + "/" + penpotColor.name + } else { + penpotColor.name + } + + colors.append(Color( + name: name, + platform: nil, + red: rgba.red, + green: rgba.green, + blue: rgba.blue, + alpha: rgba.alpha + )) + } + + // Penpot has no mode-based variants — light only + return ColorsLoadOutput(light: colors) + } + + // MARK: - Private + + static func makeClient(baseURL: String) throws -> BasePenpotClient { + guard let token = ProcessInfo.processInfo.environment["PENPOT_ACCESS_TOKEN"], !token.isEmpty else { + throw ExFigError.configurationError( + "PENPOT_ACCESS_TOKEN environment variable is required for Penpot source" + ) + } + return BasePenpotClient(accessToken: token, baseURL: baseURL) + } + + static func hexToRGBA(hex: String, opacity: Double) -> (red: Double, green: Double, blue: Double, alpha: Double) { + var hexString = hex.trimmingCharacters(in: .whitespacesAndNewlines) + if hexString.hasPrefix("#") { + hexString.removeFirst() + } + + guard hexString.count == 6, let hexValue = UInt64(hexString, radix: 16) else { + return (red: 0, green: 0, blue: 0, alpha: opacity) + } + + let red = Double((hexValue >> 16) & 0xFF) / 255.0 + let green = Double((hexValue >> 8) & 0xFF) / 255.0 + let blue = Double(hexValue & 0xFF) / 255.0 + + return (red: red, green: green, blue: blue, alpha: opacity) + } +} diff --git a/Sources/ExFigCLI/Source/PenpotComponentsSource.swift b/Sources/ExFigCLI/Source/PenpotComponentsSource.swift new file mode 100644 index 00000000..a8d24786 --- /dev/null +++ b/Sources/ExFigCLI/Source/PenpotComponentsSource.swift @@ -0,0 +1,104 @@ +import ExFigCore +import Foundation +import PenpotAPI + +struct PenpotComponentsSource: ComponentsSource { + let ui: TerminalUI + + func loadIcons(from input: IconsSourceInput) async throws -> IconsLoadOutput { + // Warn about raster-only limitation when SVG requested + if input.format == .svg { + ui.warning( + "Penpot API provides raster thumbnails only — SVG format is not available. " + + "Icons will be exported as PNG thumbnails." + ) + } + + let packs = try await loadComponents( + fileId: input.figmaFileId ?? "", + pathFilter: input.frameName, + sourceKind: input.sourceKind + ) + + return IconsLoadOutput(light: packs) + } + + func loadImages(from input: ImagesSourceInput) async throws -> ImagesLoadOutput { + let packs = try await loadComponents( + fileId: input.figmaFileId ?? "", + pathFilter: input.frameName, + sourceKind: input.sourceKind + ) + + return ImagesLoadOutput(light: packs) + } + + // MARK: - Private + + private func loadComponents( + fileId: String, + pathFilter: String, + sourceKind: DesignSourceKind + ) async throws -> [ImagePack] { + let client = try PenpotColorsSource.makeClient( + baseURL: BasePenpotClient.defaultBaseURL + ) + + let fileResponse = try await client.request(GetFileEndpoint(fileId: fileId)) + + guard let components = fileResponse.data.components else { + return [] + } + + // Filter components by path + let matchedComponents = components.values.filter { component in + guard let path = component.path else { return false } + return path.hasPrefix(pathFilter) + } + + guard !matchedComponents.isEmpty else { + return [] + } + + // Get thumbnails for matched components + let objectIds = matchedComponents.map(\.id) + let thumbnails = try await client.request( + GetFileObjectThumbnailsEndpoint(fileId: fileId, objectIds: objectIds) + ) + + var packs: [ImagePack] = [] + + for component in matchedComponents { + guard let thumbnailRef = thumbnails[component.id] else { + ui.warning("Component '\(component.name)' has no thumbnail — skipping") + continue + } + + // Build download URL for the thumbnail + let downloadPath = thumbnailRef.hasPrefix("http") ? thumbnailRef : "assets/by-file-media-id/\(thumbnailRef)" + + guard let url = URL(string: downloadPath + .hasPrefix("http") ? downloadPath : "https://design.penpot.app/\(downloadPath)") + else { + ui.warning("Component '\(component.name)' has invalid thumbnail URL — skipping") + continue + } + + let image = Image( + name: component.name, + scale: .individual(1.0), + url: url, + format: "png" + ) + + packs.append(ImagePack( + name: component.name, + images: [image], + nodeId: component.id, + fileId: fileId + )) + } + + return packs + } +} diff --git a/Sources/ExFigCLI/Source/PenpotTypographySource.swift b/Sources/ExFigCLI/Source/PenpotTypographySource.swift new file mode 100644 index 00000000..4cbfd9b3 --- /dev/null +++ b/Sources/ExFigCLI/Source/PenpotTypographySource.swift @@ -0,0 +1,61 @@ +import ExFigCore +import Foundation +import PenpotAPI + +struct PenpotTypographySource: TypographySource { + let ui: TerminalUI + + func loadTypography(from input: TypographySourceInput) async throws -> TypographyLoadOutput { + let client = try PenpotColorsSource.makeClient( + baseURL: BasePenpotClient.defaultBaseURL + ) + + let fileResponse = try await client.request(GetFileEndpoint(fileId: input.fileId)) + + guard let typographies = fileResponse.data.typographies else { + return TypographyLoadOutput(textStyles: []) + } + + var textStyles: [TextStyle] = [] + + for (_, typography) in typographies { + guard let fontSize = typography.fontSize else { + ui.warning("Typography '\(typography.name)' has unparseable font-size — skipping") + continue + } + + let name = if let path = typography.path { + path + "/" + typography.name + } else { + typography.name + } + + let textCase = mapTextTransform(typography.textTransform) + + textStyles.append(TextStyle( + name: name, + fontName: typography.fontFamily, + fontSize: fontSize, + fontStyle: nil, + lineHeight: typography.lineHeight, + letterSpacing: typography.letterSpacing ?? 0, + textCase: textCase + )) + } + + return TypographyLoadOutput(textStyles: textStyles) + } + + // MARK: - Private + + private func mapTextTransform(_ transform: String?) -> TextStyle.TextCase { + switch transform { + case "uppercase": + .uppercased + case "lowercase": + .lowercased + default: + .original + } + } +} diff --git a/Sources/ExFigCLI/Source/SourceFactory.swift b/Sources/ExFigCLI/Source/SourceFactory.swift index e5b31ab9..21e97639 100644 --- a/Sources/ExFigCLI/Source/SourceFactory.swift +++ b/Sources/ExFigCLI/Source/SourceFactory.swift @@ -16,7 +16,9 @@ enum SourceFactory { FigmaColorsSource(client: client, ui: ui, filter: filter) case .tokensFile: TokensFileColorsSource(ui: ui) - case .penpot, .tokensStudio, .sketchFile: + case .penpot: + PenpotColorsSource(ui: ui) + case .tokensStudio, .sketchFile: throw ExFigError.unsupportedSourceKind(input.sourceKind, assetType: "colors") } } @@ -39,7 +41,9 @@ enum SourceFactory { logger: logger, filter: filter ) - case .penpot, .tokensFile, .tokensStudio, .sketchFile: + case .penpot: + PenpotComponentsSource(ui: ExFigCommand.terminalUI) + case .tokensFile, .tokensStudio, .sketchFile: throw ExFigError.unsupportedSourceKind(sourceKind, assetType: "icons/images") } } @@ -51,7 +55,9 @@ enum SourceFactory { switch sourceKind { case .figma: FigmaTypographySource(client: client) - case .penpot, .tokensFile, .tokensStudio, .sketchFile: + case .penpot: + PenpotTypographySource(ui: ExFigCommand.terminalUI) + case .tokensFile, .tokensStudio, .sketchFile: throw ExFigError.unsupportedSourceKind(sourceKind, assetType: "typography") } } diff --git a/Sources/ExFigConfig/Generated/Android.pkl.swift b/Sources/ExFigConfig/Generated/Android.pkl.swift index 28c8a2d2..3baf3828 100644 --- a/Sources/ExFigConfig/Generated/Android.pkl.swift +++ b/Sources/ExFigConfig/Generated/Android.pkl.swift @@ -132,10 +132,14 @@ extension Android { public var themeAttributes: ThemeAttributes? /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" /// - If `tokensFile` is set → "tokens-file" /// - Otherwise → "figma" public var sourceKind: Common.SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + public var penpotSource: Common.PenpotSource? + /// Local .tokens.json file source (bypasses Figma API when set). public var tokensFile: Common.TokensFile? @@ -176,6 +180,7 @@ extension Android { colorKotlin: String?, themeAttributes: ThemeAttributes?, sourceKind: Common.SourceKind?, + penpotSource: Common.PenpotSource?, tokensFile: Common.TokensFile?, tokensFileId: String?, tokensCollectionName: String?, @@ -196,6 +201,7 @@ extension Android { self.colorKotlin = colorKotlin self.themeAttributes = themeAttributes self.sourceKind = sourceKind + self.penpotSource = penpotSource self.tokensFile = tokensFile self.tokensFileId = tokensFileId self.tokensCollectionName = tokensCollectionName @@ -250,9 +256,14 @@ 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". + /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" + /// - Otherwise → "figma" public var sourceKind: Common.SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + public var penpotSource: Common.PenpotSource? + /// Figma frame name to export from. public var figmaFrameName: String? @@ -292,6 +303,7 @@ extension Android { codeConnectPackageName: String?, codeConnectKotlin: String?, sourceKind: Common.SourceKind?, + penpotSource: Common.PenpotSource?, figmaFrameName: String?, figmaPageName: String?, figmaFileId: String?, @@ -311,6 +323,7 @@ extension Android { self.codeConnectPackageName = codeConnectPackageName self.codeConnectKotlin = codeConnectKotlin self.sourceKind = sourceKind + self.penpotSource = penpotSource self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId @@ -353,9 +366,14 @@ 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". + /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" + /// - Otherwise → "figma" public var sourceKind: Common.SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + public var penpotSource: Common.PenpotSource? + /// Figma frame name to export from. public var figmaFrameName: String? @@ -393,6 +411,7 @@ extension Android { nameStyle: Common.NameStyle?, codeConnectKotlin: String?, sourceKind: Common.SourceKind?, + penpotSource: Common.PenpotSource?, figmaFrameName: String?, figmaPageName: String?, figmaFileId: String?, @@ -410,6 +429,7 @@ extension Android { self.nameStyle = nameStyle self.codeConnectKotlin = codeConnectKotlin self.sourceKind = sourceKind + self.penpotSource = penpotSource 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 be4b3b79..2297a715 100644 --- a/Sources/ExFigConfig/Generated/Common.pkl.swift +++ b/Sources/ExFigConfig/Generated/Common.pkl.swift @@ -6,6 +6,8 @@ public enum Common {} public protocol Common_VariablesSource: Common_NameProcessing { var sourceKind: Common.SourceKind? { get } + var penpotSource: Common.PenpotSource? { get } + var tokensFile: Common.TokensFile? { get } var tokensFileId: String? { get } @@ -32,6 +34,8 @@ public protocol Common_NameProcessing: PklRegisteredType, DynamicallyEquatable, public protocol Common_FrameSource: Common_NameProcessing { var sourceKind: Common.SourceKind? { get } + var penpotSource: Common.PenpotSource? { get } + var figmaFrameName: String? { get } var figmaPageName: String? { get } @@ -90,10 +94,14 @@ extension Common { public static let registeredIdentifier: String = "Common#VariablesSource" /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" /// - If `tokensFile` is set → "tokens-file" /// - Otherwise → "figma" public var sourceKind: SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + public var penpotSource: PenpotSource? + /// Local .tokens.json file source (bypasses Figma API when set). public var tokensFile: TokensFile? @@ -126,6 +134,7 @@ extension Common { public init( sourceKind: SourceKind?, + penpotSource: PenpotSource?, tokensFile: TokensFile?, tokensFileId: String?, tokensCollectionName: String?, @@ -138,6 +147,7 @@ extension Common { nameReplaceRegexp: String? ) { self.sourceKind = sourceKind + self.penpotSource = penpotSource self.tokensFile = tokensFile self.tokensFileId = tokensFileId self.tokensCollectionName = tokensCollectionName @@ -191,6 +201,27 @@ extension Common { } } + /// Penpot design source configuration. + /// When set on a colors/icons/images entry, loads assets from Penpot API instead of Figma. + public struct PenpotSource: PklRegisteredType, Decodable, Hashable, Sendable { + public static let registeredIdentifier: String = "Common#PenpotSource" + + /// Penpot file UUID. + public var fileId: String + + /// Penpot instance base URL (default: Penpot cloud). + public var baseUrl: String + + /// Optional path prefix to filter assets (e.g., "Brand/Primary"). + public var pathFilter: String? + + public init(fileId: String, baseUrl: String, pathFilter: String?) { + self.fileId = fileId + self.baseUrl = baseUrl + self.pathFilter = pathFilter + } + } + /// Cache configuration for tracking Figma file versions. public struct Cache: PklRegisteredType, Decodable, Hashable, Sendable { public static let registeredIdentifier: String = "Common#Cache" @@ -232,9 +263,14 @@ extension Common { public struct FrameSourceImpl: FrameSource { public static let registeredIdentifier: String = "Common#FrameSource" - /// Design source kind override. When null, defaults to "figma". + /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" + /// - Otherwise → "figma" public var sourceKind: SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + public var penpotSource: PenpotSource? + /// Figma frame name to export from. public var figmaFrameName: String? @@ -263,6 +299,7 @@ extension Common { public init( sourceKind: SourceKind?, + penpotSource: PenpotSource?, figmaFrameName: String?, figmaPageName: String?, figmaFileId: String?, @@ -271,6 +308,7 @@ extension Common { nameReplaceRegexp: String? ) { self.sourceKind = sourceKind + self.penpotSource = penpotSource self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId diff --git a/Sources/ExFigConfig/Generated/Flutter.pkl.swift b/Sources/ExFigConfig/Generated/Flutter.pkl.swift index 0f029fb5..ac91b0ae 100644 --- a/Sources/ExFigConfig/Generated/Flutter.pkl.swift +++ b/Sources/ExFigConfig/Generated/Flutter.pkl.swift @@ -33,10 +33,14 @@ extension Flutter { public var className: String? /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" /// - If `tokensFile` is set → "tokens-file" /// - Otherwise → "figma" public var sourceKind: Common.SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + public var penpotSource: Common.PenpotSource? + /// Local .tokens.json file source (bypasses Figma API when set). public var tokensFile: Common.TokensFile? @@ -72,6 +76,7 @@ extension Flutter { output: String?, className: String?, sourceKind: Common.SourceKind?, + penpotSource: Common.PenpotSource?, tokensFile: Common.TokensFile?, tokensFileId: String?, tokensCollectionName: String?, @@ -87,6 +92,7 @@ extension Flutter { self.output = output self.className = className self.sourceKind = sourceKind + self.penpotSource = penpotSource self.tokensFile = tokensFile self.tokensFileId = tokensFileId self.tokensCollectionName = tokensCollectionName @@ -120,9 +126,14 @@ extension Flutter { /// Naming style for icon names. public var nameStyle: Common.NameStyle? - /// Design source kind override. When null, defaults to "figma". + /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" + /// - Otherwise → "figma" public var sourceKind: Common.SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + public var penpotSource: Common.PenpotSource? + /// Figma frame name to export from. public var figmaFrameName: String? @@ -156,6 +167,7 @@ extension Flutter { className: String?, nameStyle: Common.NameStyle?, sourceKind: Common.SourceKind?, + penpotSource: Common.PenpotSource?, figmaFrameName: String?, figmaPageName: String?, figmaFileId: String?, @@ -169,6 +181,7 @@ extension Flutter { self.className = className self.nameStyle = nameStyle self.sourceKind = sourceKind + self.penpotSource = penpotSource self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId @@ -210,9 +223,14 @@ extension Flutter { /// Naming style for generated assets. public var nameStyle: Common.NameStyle? - /// Design source kind override. When null, defaults to "figma". + /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" + /// - Otherwise → "figma" public var sourceKind: Common.SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + public var penpotSource: Common.PenpotSource? + /// Figma frame name to export from. public var figmaFrameName: String? @@ -250,6 +268,7 @@ extension Flutter { sourceFormat: Common.SourceFormat?, nameStyle: Common.NameStyle?, sourceKind: Common.SourceKind?, + penpotSource: Common.PenpotSource?, figmaFrameName: String?, figmaPageName: String?, figmaFileId: String?, @@ -267,6 +286,7 @@ extension Flutter { self.sourceFormat = sourceFormat self.nameStyle = nameStyle self.sourceKind = sourceKind + self.penpotSource = penpotSource 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 cab1e425..eb744d5e 100644 --- a/Sources/ExFigConfig/Generated/Web.pkl.swift +++ b/Sources/ExFigConfig/Generated/Web.pkl.swift @@ -36,10 +36,14 @@ extension Web { public var jsonFileName: String? /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" /// - If `tokensFile` is set → "tokens-file" /// - Otherwise → "figma" public var sourceKind: Common.SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + public var penpotSource: Common.PenpotSource? + /// Local .tokens.json file source (bypasses Figma API when set). public var tokensFile: Common.TokensFile? @@ -78,6 +82,7 @@ extension Web { tsFileName: String?, jsonFileName: String?, sourceKind: Common.SourceKind?, + penpotSource: Common.PenpotSource?, tokensFile: Common.TokensFile?, tokensFileId: String?, tokensCollectionName: String?, @@ -96,6 +101,7 @@ extension Web { self.tsFileName = tsFileName self.jsonFileName = jsonFileName self.sourceKind = sourceKind + self.penpotSource = penpotSource self.tokensFile = tokensFile self.tokensFileId = tokensFileId self.tokensCollectionName = tokensCollectionName @@ -132,9 +138,14 @@ extension Web { /// Naming style for icon names. public var nameStyle: Common.NameStyle? - /// Design source kind override. When null, defaults to "figma". + /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" + /// - Otherwise → "figma" public var sourceKind: Common.SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + public var penpotSource: Common.PenpotSource? + /// Figma frame name to export from. public var figmaFrameName: String? @@ -169,6 +180,7 @@ extension Web { iconSize: Int?, nameStyle: Common.NameStyle?, sourceKind: Common.SourceKind?, + penpotSource: Common.PenpotSource?, figmaFrameName: String?, figmaPageName: String?, figmaFileId: String?, @@ -183,6 +195,7 @@ extension Web { self.iconSize = iconSize self.nameStyle = nameStyle self.sourceKind = sourceKind + self.penpotSource = penpotSource self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId @@ -212,9 +225,14 @@ extension Web { /// Naming style for generated image names. public var nameStyle: Common.NameStyle? - /// Design source kind override. When null, defaults to "figma". + /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" + /// - Otherwise → "figma" public var sourceKind: Common.SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + public var penpotSource: Common.PenpotSource? + /// Figma frame name to export from. public var figmaFrameName: String? @@ -248,6 +266,7 @@ extension Web { generateReactComponents: Bool?, nameStyle: Common.NameStyle?, sourceKind: Common.SourceKind?, + penpotSource: Common.PenpotSource?, figmaFrameName: String?, figmaPageName: String?, figmaFileId: String?, @@ -261,6 +280,7 @@ extension Web { self.generateReactComponents = generateReactComponents self.nameStyle = nameStyle self.sourceKind = sourceKind + self.penpotSource = penpotSource 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 d09241cd..c79edd63 100644 --- a/Sources/ExFigConfig/Generated/iOS.pkl.swift +++ b/Sources/ExFigConfig/Generated/iOS.pkl.swift @@ -91,10 +91,14 @@ extension iOS { public var codeSyntaxTemplate: String? /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" /// - If `tokensFile` is set → "tokens-file" /// - Otherwise → "figma" public var sourceKind: Common.SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + public var penpotSource: Common.PenpotSource? + /// Local .tokens.json file source (bypasses Figma API when set). public var tokensFile: Common.TokensFile? @@ -138,6 +142,7 @@ extension iOS { syncCodeSyntax: Bool?, codeSyntaxTemplate: String?, sourceKind: Common.SourceKind?, + penpotSource: Common.PenpotSource?, tokensFile: Common.TokensFile?, tokensFileId: String?, tokensCollectionName: String?, @@ -161,6 +166,7 @@ extension iOS { self.syncCodeSyntax = syncCodeSyntax self.codeSyntaxTemplate = codeSyntaxTemplate self.sourceKind = sourceKind + self.penpotSource = penpotSource self.tokensFile = tokensFile self.tokensFileId = tokensFileId self.tokensCollectionName = tokensCollectionName @@ -219,9 +225,14 @@ extension iOS { /// Suffix for assets using template render mode. public var renderModeTemplateSuffix: String? - /// Design source kind override. When null, defaults to "figma". + /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" + /// - Otherwise → "figma" public var sourceKind: Common.SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + public var penpotSource: Common.PenpotSource? + /// Figma frame name to export from. public var figmaFrameName: String? @@ -263,6 +274,7 @@ extension iOS { renderModeOriginalSuffix: String?, renderModeTemplateSuffix: String?, sourceKind: Common.SourceKind?, + penpotSource: Common.PenpotSource?, figmaFrameName: String?, figmaPageName: String?, figmaFileId: String?, @@ -284,6 +296,7 @@ extension iOS { self.renderModeOriginalSuffix = renderModeOriginalSuffix self.renderModeTemplateSuffix = renderModeTemplateSuffix self.sourceKind = sourceKind + self.penpotSource = penpotSource self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId @@ -344,9 +357,14 @@ extension iOS { /// Suffix for assets using template render mode. public var renderModeTemplateSuffix: String? - /// Design source kind override. When null, defaults to "figma". + /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" + /// - Otherwise → "figma" public var sourceKind: Common.SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + public var penpotSource: Common.PenpotSource? + /// Figma frame name to export from. public var figmaFrameName: String? @@ -390,6 +408,7 @@ extension iOS { renderModeOriginalSuffix: String?, renderModeTemplateSuffix: String?, sourceKind: Common.SourceKind?, + penpotSource: Common.PenpotSource?, figmaFrameName: String?, figmaPageName: String?, figmaFileId: String?, @@ -413,6 +432,7 @@ extension iOS { self.renderModeOriginalSuffix = renderModeOriginalSuffix self.renderModeTemplateSuffix = renderModeTemplateSuffix self.sourceKind = sourceKind + self.penpotSource = penpotSource self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId diff --git a/Sources/ExFigConfig/PKL/PKLEvaluator.swift b/Sources/ExFigConfig/PKL/PKLEvaluator.swift index 9a599eff..01788ad1 100644 --- a/Sources/ExFigConfig/PKL/PKLEvaluator.swift +++ b/Sources/ExFigConfig/PKL/PKLEvaluator.swift @@ -44,6 +44,7 @@ public enum PKLEvaluator { Common.NameProcessingImpl.self, Common.FrameSourceImpl.self, Common.TokensFile.self, + Common.PenpotSource.self, Common.WebpOptions.self, Common.Cache.self, Common.Colors.self, diff --git a/Sources/ExFigConfig/SourceKindBridging.swift b/Sources/ExFigConfig/SourceKindBridging.swift index 5ea26ff1..6c60c043 100644 --- a/Sources/ExFigConfig/SourceKindBridging.swift +++ b/Sources/ExFigConfig/SourceKindBridging.swift @@ -15,3 +15,18 @@ public extension Common.SourceKind { } } } + +public extension Common_FrameSource { + /// Resolves the design source kind with priority: explicit > auto-detect > default (.figma). + /// + /// Auto-detection: `penpotSource` set → `.penpot`, otherwise `.figma`. + var resolvedSourceKind: DesignSourceKind { + if let explicit = sourceKind { + return explicit.coreSourceKind + } + if penpotSource != nil { + return .penpot + } + return .figma + } +} diff --git a/Sources/ExFigConfig/VariablesSourceValidation.swift b/Sources/ExFigConfig/VariablesSourceValidation.swift index dec5b878..422514aa 100644 --- a/Sources/ExFigConfig/VariablesSourceValidation.swift +++ b/Sources/ExFigConfig/VariablesSourceValidation.swift @@ -1,49 +1,80 @@ import ExFigCore public extension Common_VariablesSource { - /// Returns a validated `ColorsSourceInput` for use with `ColorsExportContext`. - /// - /// 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 penpotSource != nil { + return .penpot + } if tokensFile != nil { return .tokensFile } return .figma } + /// Returns a validated `ColorsSourceInput` for use with `ColorsExportContext`. + /// + /// Dispatches by `resolvedSourceKind`: Penpot, tokens-file, or Figma Variables. func validatedColorsSourceInput() throws -> ColorsSourceInput { let kind = resolvedSourceKind - if kind == .tokensFile { - 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") } + switch kind { + case .penpot: + return try penpotColorsSourceInput() + case .tokensFile: + return try tokensFileColorsSourceInput() + default: + return try figmaColorsSourceInput(kind: kind) + } + } +} + +// MARK: - Private Helpers + +private extension Common_VariablesSource { + func penpotColorsSourceInput() throws -> ColorsSourceInput { + guard let penpotSource else { + throw ColorsConfigError.missingTokensFileId + } + let config = PenpotColorsConfig( + fileId: penpotSource.fileId, + baseURL: penpotSource.baseUrl, + pathFilter: penpotSource.pathFilter + ) + return ColorsSourceInput( + sourceKind: .penpot, + sourceConfig: config, + nameValidateRegexp: nameValidateRegexp, + nameReplaceRegexp: nameReplaceRegexp + ) + } - let config = TokensFileColorsConfig( - filePath: tokensFile.path, - groupFilter: tokensFile.groupFilter, - ignoredModeNames: ignoredModes - ) - return ColorsSourceInput( - sourceKind: .tokensFile, - sourceConfig: config, - nameValidateRegexp: nameValidateRegexp, - nameReplaceRegexp: nameReplaceRegexp - ) + func tokensFileColorsSourceInput() throws -> ColorsSourceInput { + guard let tokensFile else { + throw ColorsConfigError.missingTokensFileId } + 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, + ignoredModeNames: ignoredModes + ) + return ColorsSourceInput( + sourceKind: .tokensFile, + sourceConfig: config, + nameValidateRegexp: nameValidateRegexp, + nameReplaceRegexp: nameReplaceRegexp + ) + } - // Figma Variables source — require all fields + func figmaColorsSourceInput(kind: DesignSourceKind) throws -> ColorsSourceInput { guard let tokensFileId, !tokensFileId.isEmpty else { throw ColorsConfigError.missingTokensFileId } diff --git a/Sources/ExFigCore/Protocol/DesignSource.swift b/Sources/ExFigCore/Protocol/DesignSource.swift index 1c41e291..c6044025 100644 --- a/Sources/ExFigCore/Protocol/DesignSource.swift +++ b/Sources/ExFigCore/Protocol/DesignSource.swift @@ -70,6 +70,23 @@ public struct FigmaColorsConfig: ColorsSourceConfig { } } +/// Penpot-specific colors configuration — file ID, base URL, and path filter. +public struct PenpotColorsConfig: ColorsSourceConfig { + public let fileId: String + public let baseURL: String + public let pathFilter: String? + + public init( + fileId: String, + baseURL: String = "https://design.penpot.app/", + pathFilter: String? = nil + ) { + self.fileId = fileId + self.baseURL = baseURL + self.pathFilter = pathFilter + } +} + /// Tokens-file-specific colors configuration — local .tokens.json path + optional group filter. public struct TokensFileColorsConfig: ColorsSourceConfig { public let filePath: String diff --git a/Sources/ExFigCore/Protocol/ExportContext.swift b/Sources/ExFigCore/Protocol/ExportContext.swift index ad5dfe0b..f281d17e 100644 --- a/Sources/ExFigCore/Protocol/ExportContext.swift +++ b/Sources/ExFigCore/Protocol/ExportContext.swift @@ -127,7 +127,13 @@ public struct ColorsSourceInput: Sendable { return URL(fileURLWithPath: config.filePath).lastPathComponent } return "tokens file" - case .penpot, .tokensStudio, .sketchFile: + case .penpot: + if let config = sourceConfig as? PenpotColorsConfig { + let shortId = String(config.fileId.prefix(8)) + return "Penpot colors (\(shortId)…)" + } + return "Penpot" + case .tokensStudio, .sketchFile: return sourceKind.rawValue } } diff --git a/Sources/PenpotAPI/CLAUDE.md b/Sources/PenpotAPI/CLAUDE.md new file mode 100644 index 00000000..53470cd8 --- /dev/null +++ b/Sources/PenpotAPI/CLAUDE.md @@ -0,0 +1,19 @@ +# PenpotAPI Module + +Standalone HTTP client for Penpot RPC API. Zero dependencies on ExFigCore/ExFigCLI/FigmaAPI. +Only external dependency: swift-yyjson for JSON parsing. + +## Architecture + +- `PenpotEndpoint` protocol — RPC-style: `POST /api/main/methods/` +- `PenpotClient` protocol + `BasePenpotClient` — URLSession, auth, retry +- `PenpotAPIError` — LocalizedError with recovery suggestions +- Models use standard Codable (Penpot JSON is camelCase via `json/write-camel-key` middleware) + +## Key Patterns + +- All endpoints are POST to `/api/main/methods/` with JSON body +- `Accept: application/json` header (NOT transit+json) — ensures camelCase keys +- `Authorization: Token ` header +- Simple retry (3 attempts, exponential backoff) for 429/5xx +- Typography numeric fields may be String OR Number — custom init(from:) handles both diff --git a/Sources/PenpotAPI/Client/PenpotAPIError.swift b/Sources/PenpotAPI/Client/PenpotAPIError.swift new file mode 100644 index 00000000..ff1158d8 --- /dev/null +++ b/Sources/PenpotAPI/Client/PenpotAPIError.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Error type for Penpot API failures. +public struct PenpotAPIError: LocalizedError, Sendable { + /// HTTP status code (0 for non-HTTP errors). + public let statusCode: Int + + /// Error message from the API or client. + public let message: String? + + /// The endpoint command name that failed. + public let endpoint: String + + public init(statusCode: Int, message: String?, endpoint: String) { + self.statusCode = statusCode + self.message = message + self.endpoint = endpoint + } + + public var errorDescription: String? { + if let message { + "Penpot API error (\(endpoint)): \(statusCode) — \(message)" + } else { + "Penpot API error (\(endpoint)): HTTP \(statusCode)" + } + } + + public var recoverySuggestion: String? { + switch statusCode { + case 401: + "Check that PENPOT_ACCESS_TOKEN environment variable is set with a valid access token. " + + "Generate one at Settings → Access Tokens in your Penpot instance." + case 403: + "You don't have permission to access this resource. Check file sharing settings." + case 404: + "The requested resource was not found. Verify the file UUID is correct." + case 429: + "Rate limited by Penpot API. The request was retried but still failed. Try again later." + default: + nil + } + } +} diff --git a/Sources/PenpotAPI/Client/PenpotClient.swift b/Sources/PenpotAPI/Client/PenpotClient.swift new file mode 100644 index 00000000..678a465b --- /dev/null +++ b/Sources/PenpotAPI/Client/PenpotClient.swift @@ -0,0 +1,154 @@ +import Foundation +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif +import YYJSON + +/// Protocol for Penpot API clients. +public protocol PenpotClient: Sendable { + /// Executes a Penpot RPC endpoint and returns the decoded content. + func request(_ endpoint: T) async throws -> T.Content + + /// Downloads raw binary data from a URL path (e.g., asset downloads). + func download(path: String) async throws -> Data +} + +/// Default Penpot API client with authentication and retry logic. +public struct BasePenpotClient: PenpotClient { + /// Default Penpot cloud base URL. + public static let defaultBaseURL = "https://design.penpot.app/" + + private let accessToken: String + private let baseURL: String + private let session: URLSession + private let maxRetries: Int + + public init( + accessToken: String, + baseURL: String = Self.defaultBaseURL, + timeout: TimeInterval = 60, + maxRetries: Int = 3 + ) { + self.accessToken = accessToken + self.baseURL = baseURL.hasSuffix("/") ? baseURL : baseURL + "/" + self.maxRetries = maxRetries + + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = timeout + session = URLSession(configuration: config) + } + + public func request(_ endpoint: T) async throws -> T.Content { + let url = try buildURL(for: endpoint) + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Token \(accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + if let body = try endpoint.body() { + request.httpBody = body + } + + let (data, response) = try await performWithRetry(request: request, endpoint: endpoint.commandName) + + guard let httpResponse = response as? HTTPURLResponse else { + throw PenpotAPIError(statusCode: 0, message: "Invalid response type", endpoint: endpoint.commandName) + } + + guard (200 ..< 300).contains(httpResponse.statusCode) else { + let message = String(data: data, encoding: .utf8) + throw PenpotAPIError( + statusCode: httpResponse.statusCode, + message: message, + endpoint: endpoint.commandName + ) + } + + return try endpoint.content(from: data) + } + + public func download(path: String) async throws -> Data { + guard let url = URL(string: baseURL + path) else { + throw PenpotAPIError(statusCode: 0, message: "Invalid download URL: \(path)", endpoint: "download") + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Token \(accessToken)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await performWithRetry(request: request, endpoint: "download") + + guard let httpResponse = response as? HTTPURLResponse, + (200 ..< 300).contains(httpResponse.statusCode) + else { + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + throw PenpotAPIError(statusCode: statusCode, message: "Download failed", endpoint: "download") + } + + return data + } + + // MARK: - Private + + private func buildURL(for endpoint: some PenpotEndpoint) throws -> URL { + guard let url = URL(string: "\(baseURL)api/main/methods/\(endpoint.commandName)") else { + throw PenpotAPIError( + statusCode: 0, + message: "Failed to construct URL for command: \(endpoint.commandName)", + endpoint: endpoint.commandName + ) + } + return url + } + + private func performWithRetry( + request: URLRequest, + endpoint: String + ) async throws -> (Data, URLResponse) { + var lastError: Error? + + for attempt in 0 ..< maxRetries { + do { + let (data, response) = try await session.data(for: request) + + if let httpResponse = response as? HTTPURLResponse { + let statusCode = httpResponse.statusCode + + // Retry on 429 or 5xx + if statusCode == 429 || (500 ..< 600).contains(statusCode) { + lastError = PenpotAPIError( + statusCode: statusCode, + message: String(data: data, encoding: .utf8), + endpoint: endpoint + ) + + if attempt < maxRetries - 1 { + let delay = pow(2.0, Double(attempt)) // 1s, 2s, 4s + try await Task.sleep(for: .seconds(delay)) + continue + } + } + } + + return (data, response) + } catch let error as PenpotAPIError { + throw error + } catch { + lastError = error + + if attempt < maxRetries - 1 { + let delay = pow(2.0, Double(attempt)) + try await Task.sleep(for: .seconds(delay)) + continue + } + } + } + + throw lastError ?? PenpotAPIError( + statusCode: 0, + message: "Request failed after \(maxRetries) retries", + endpoint: endpoint + ) + } +} diff --git a/Sources/PenpotAPI/Client/PenpotEndpoint.swift b/Sources/PenpotAPI/Client/PenpotEndpoint.swift new file mode 100644 index 00000000..b1b191e7 --- /dev/null +++ b/Sources/PenpotAPI/Client/PenpotEndpoint.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Protocol for Penpot RPC API endpoints. +/// +/// All Penpot API calls are `POST /api/main/methods/` +/// with a JSON body. The legacy path `/api/rpc/command/` +/// is preserved for backward compatibility. +public protocol PenpotEndpoint: Sendable { + associatedtype Content: Sendable + + /// The RPC command name (e.g., "get-file", "get-profile"). + var commandName: String { get } + + /// Serializes the request body. Returns `nil` for body-less commands. + func body() throws -> Data? + + /// Deserializes the response data into the expected content type. + func content(from data: Data) throws -> Content +} diff --git a/Sources/PenpotAPI/Endpoints/GetFileEndpoint.swift b/Sources/PenpotAPI/Endpoints/GetFileEndpoint.swift new file mode 100644 index 00000000..548be430 --- /dev/null +++ b/Sources/PenpotAPI/Endpoints/GetFileEndpoint.swift @@ -0,0 +1,25 @@ +import Foundation +import YYJSON + +/// Retrieves a complete Penpot file with library assets. +/// +/// Command: `get-file` +/// Body: `{"id": ""}` +public struct GetFileEndpoint: PenpotEndpoint { + public typealias Content = PenpotFileResponse + + public let commandName = "get-file" + private let fileId: String + + public init(fileId: String) { + self.fileId = fileId + } + + public func body() throws -> Data? { + try YYJSONEncoder().encode(["id": fileId]) + } + + public func content(from data: Data) throws -> PenpotFileResponse { + try YYJSONDecoder().decode(PenpotFileResponse.self, from: data) + } +} diff --git a/Sources/PenpotAPI/Endpoints/GetFileObjectThumbnailsEndpoint.swift b/Sources/PenpotAPI/Endpoints/GetFileObjectThumbnailsEndpoint.swift new file mode 100644 index 00000000..5787cac2 --- /dev/null +++ b/Sources/PenpotAPI/Endpoints/GetFileObjectThumbnailsEndpoint.swift @@ -0,0 +1,34 @@ +import Foundation +import YYJSON + +/// Retrieves thumbnail media IDs for file objects (components). +/// +/// Command: `get-file-object-thumbnails` +/// Body: `{"file-id": "", "object-ids": ["", ...]}` +/// Response: Dictionary mapping object UUIDs to thumbnail URLs/media IDs. +public struct GetFileObjectThumbnailsEndpoint: PenpotEndpoint { + public typealias Content = [String: String] + + public let commandName = "get-file-object-thumbnails" + private let fileId: String + private let objectIds: [String] + + public init(fileId: String, objectIds: [String]) { + self.fileId = fileId + self.objectIds = objectIds + } + + public func body() throws -> Data? { + // Penpot RPC uses kebab-case keys in request bodies + let bodyDict: [String: Any] = [ + "file-id": fileId, + "object-ids": objectIds, + ] + return try JSONSerialization.data(withJSONObject: bodyDict) + } + + public func content(from data: Data) throws -> [String: String] { + // Response is a flat object: { "": "" } + try YYJSONDecoder().decode([String: String].self, from: data) + } +} diff --git a/Sources/PenpotAPI/Endpoints/GetProfileEndpoint.swift b/Sources/PenpotAPI/Endpoints/GetProfileEndpoint.swift new file mode 100644 index 00000000..46bf9659 --- /dev/null +++ b/Sources/PenpotAPI/Endpoints/GetProfileEndpoint.swift @@ -0,0 +1,22 @@ +import Foundation +import YYJSON + +/// Retrieves the authenticated user's profile. +/// +/// Command: `get-profile` +/// Body: none +public struct GetProfileEndpoint: PenpotEndpoint { + public typealias Content = PenpotProfile + + public let commandName = "get-profile" + + public init() {} + + public func body() throws -> Data? { + nil + } + + public func content(from data: Data) throws -> PenpotProfile { + try YYJSONDecoder().decode(PenpotProfile.self, from: data) + } +} diff --git a/Sources/PenpotAPI/Models/PenpotColor.swift b/Sources/PenpotAPI/Models/PenpotColor.swift new file mode 100644 index 00000000..db7b0103 --- /dev/null +++ b/Sources/PenpotAPI/Models/PenpotColor.swift @@ -0,0 +1,30 @@ +import Foundation + +/// A library color from a Penpot file. +/// +/// Solid colors have a non-nil `color` hex string. Gradient colors +/// have `nil` color and should be filtered out in v1. +public struct PenpotColor: Decodable, Sendable { + /// Unique identifier. + public let id: String + + /// Display name. + public let name: String + + /// Slash-separated group path (e.g., "Brand/Primary"). + public let path: String? + + /// Hex color value (e.g., "#3366FF"). Nil for gradient fills. + public let color: String? + + /// Opacity (0.0–1.0). Defaults to 1.0 if absent. + public let opacity: Double? + + public init(id: String, name: String, path: String? = nil, color: String? = nil, opacity: Double? = nil) { + self.id = id + self.name = name + self.path = path + self.color = color + self.opacity = opacity + } +} diff --git a/Sources/PenpotAPI/Models/PenpotComponent.swift b/Sources/PenpotAPI/Models/PenpotComponent.swift new file mode 100644 index 00000000..dbe3e732 --- /dev/null +++ b/Sources/PenpotAPI/Models/PenpotComponent.swift @@ -0,0 +1,33 @@ +import Foundation + +/// A library component from a Penpot file. +public struct PenpotComponent: Decodable, Sendable { + /// Unique identifier. + public let id: String + + /// Display name. + public let name: String + + /// Slash-separated group path (e.g., "Icons/Navigation"). + public let path: String? + + /// ID of the main instance on the canvas. + public let mainInstanceId: String? + + /// Page UUID where the main instance lives. + public let mainInstancePage: String? + + public init( + id: String, + name: String, + path: String? = nil, + mainInstanceId: String? = nil, + mainInstancePage: String? = nil + ) { + self.id = id + self.name = name + self.path = path + self.mainInstanceId = mainInstanceId + self.mainInstancePage = mainInstancePage + } +} diff --git a/Sources/PenpotAPI/Models/PenpotFileResponse.swift b/Sources/PenpotAPI/Models/PenpotFileResponse.swift new file mode 100644 index 00000000..a46c0124 --- /dev/null +++ b/Sources/PenpotAPI/Models/PenpotFileResponse.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Top-level response from the `get-file` endpoint. +public struct PenpotFileResponse: Decodable, Sendable { + /// The file data containing library assets. + public let data: PenpotFileData + + /// File ID. + public let id: String + + /// File name. + public let name: String +} + +/// File data with selective decoding of library assets. +public struct PenpotFileData: Decodable, Sendable { + /// Library colors keyed by UUID. + public let colors: [String: PenpotColor]? + + /// Library typographies keyed by UUID. + public let typographies: [String: PenpotTypography]? + + /// Library components keyed by UUID. + public let components: [String: PenpotComponent]? +} diff --git a/Sources/PenpotAPI/Models/PenpotProfile.swift b/Sources/PenpotAPI/Models/PenpotProfile.swift new file mode 100644 index 00000000..42f2ea9b --- /dev/null +++ b/Sources/PenpotAPI/Models/PenpotProfile.swift @@ -0,0 +1,13 @@ +import Foundation + +/// User profile returned by the `get-profile` endpoint. +public struct PenpotProfile: Decodable, Sendable { + /// User ID. + public let id: String + + /// Full display name. + public let fullname: String + + /// Email address. + public let email: String +} diff --git a/Sources/PenpotAPI/Models/PenpotTypography.swift b/Sources/PenpotAPI/Models/PenpotTypography.swift new file mode 100644 index 00000000..e88e0a02 --- /dev/null +++ b/Sources/PenpotAPI/Models/PenpotTypography.swift @@ -0,0 +1,100 @@ +import Foundation + +/// A library typography style from a Penpot file. +/// +/// Numeric fields (`fontSize`, `fontWeight`, `lineHeight`, `letterSpacing`) +/// may arrive as either JSON strings (e.g., `"24"`) or JSON numbers (e.g., `24`) +/// due to Penpot's Clojure→JSON serialization. Custom `init(from:)` handles both. +public struct PenpotTypography: Sendable { + /// Unique identifier. + public let id: String + + /// Display name. + public let name: String + + /// Slash-separated group path. + public let path: String? + + /// Font family name (e.g., "Roboto"). + public let fontFamily: String + + /// Font style (e.g., "italic", "normal"). + public let fontStyle: String? + + /// Text transform (e.g., "uppercase", "lowercase", "none"). + public let textTransform: String? + + /// Font size in points. + public var fontSize: Double? + + /// Font weight (e.g., 400, 700). + public var fontWeight: Double? + + /// Line height multiplier. + public var lineHeight: Double? + + /// Letter spacing in em. + public var letterSpacing: Double? + + public init( + id: String, + name: String, + path: String? = nil, + fontFamily: String, + fontStyle: String? = nil, + textTransform: String? = nil, + fontSize: Double? = nil, + fontWeight: Double? = nil, + lineHeight: Double? = nil, + letterSpacing: Double? = nil + ) { + self.id = id + self.name = name + self.path = path + self.fontFamily = fontFamily + self.fontStyle = fontStyle + self.textTransform = textTransform + self.fontSize = fontSize + self.fontWeight = fontWeight + self.lineHeight = lineHeight + self.letterSpacing = letterSpacing + } +} + +extension PenpotTypography: Decodable { + enum CodingKeys: String, CodingKey { + case id, name, path, fontFamily, fontStyle, textTransform + case fontSize, fontWeight, lineHeight, letterSpacing + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + path = try container.decodeIfPresent(String.self, forKey: .path) + fontFamily = try container.decode(String.self, forKey: .fontFamily) + fontStyle = try container.decodeIfPresent(String.self, forKey: .fontStyle) + textTransform = try container.decodeIfPresent(String.self, forKey: .textTransform) + + fontSize = Self.decodeFlexibleDouble(from: container, forKey: .fontSize) + fontWeight = Self.decodeFlexibleDouble(from: container, forKey: .fontWeight) + lineHeight = Self.decodeFlexibleDouble(from: container, forKey: .lineHeight) + letterSpacing = Self.decodeFlexibleDouble(from: container, forKey: .letterSpacing) + } + + /// Decodes a value that may be a JSON number or a JSON string containing a number. + private static func decodeFlexibleDouble( + from container: KeyedDecodingContainer, + forKey key: CodingKeys + ) -> Double? { + // Try as number first + if let value = try? container.decodeIfPresent(Double.self, forKey: key) { + return value + } + // Try as string → Double + if let stringValue = try? container.decodeIfPresent(String.self, forKey: key) { + return Double(stringValue) + } + return nil + } +} diff --git a/Tests/ExFigTests/Input/DesignSourceTests.swift b/Tests/ExFigTests/Input/DesignSourceTests.swift index 4fd114b0..336adcfd 100644 --- a/Tests/ExFigTests/Input/DesignSourceTests.swift +++ b/Tests/ExFigTests/Input/DesignSourceTests.swift @@ -62,6 +62,7 @@ final class ExplicitSourceKindTests: XCTestCase { syncCodeSyntax: nil, codeSyntaxTemplate: nil, sourceKind: .figma, + penpotSource: nil, tokensFile: Common.TokensFile(path: "tokens.json", groupFilter: nil), tokensFileId: "file123", tokensCollectionName: "Collection", @@ -92,6 +93,7 @@ final class ExplicitSourceKindTests: XCTestCase { syncCodeSyntax: nil, codeSyntaxTemplate: nil, sourceKind: .tokensFile, + penpotSource: nil, tokensFile: Common.TokensFile(path: "design.json", groupFilter: "Brand"), tokensFileId: nil, tokensCollectionName: nil, @@ -125,6 +127,7 @@ final class ExplicitSourceKindTests: XCTestCase { syncCodeSyntax: nil, codeSyntaxTemplate: nil, sourceKind: .tokensFile, + penpotSource: nil, tokensFile: nil, tokensFileId: nil, tokensCollectionName: nil, @@ -159,6 +162,7 @@ final class IgnoredModeNamesTests: XCTestCase { syncCodeSyntax: nil, codeSyntaxTemplate: nil, sourceKind: nil, + penpotSource: nil, tokensFile: Common.TokensFile(path: "tokens.json", groupFilter: nil), tokensFileId: nil, tokensCollectionName: nil, @@ -189,6 +193,7 @@ final class IgnoredModeNamesTests: XCTestCase { syncCodeSyntax: nil, codeSyntaxTemplate: nil, sourceKind: nil, + penpotSource: nil, tokensFile: Common.TokensFile(path: "tokens.json", groupFilter: nil), tokensFileId: nil, tokensCollectionName: nil, @@ -219,6 +224,7 @@ final class IgnoredModeNamesTests: XCTestCase { syncCodeSyntax: nil, codeSyntaxTemplate: nil, sourceKind: nil, + penpotSource: nil, tokensFile: Common.TokensFile(path: "tokens.json", groupFilter: nil), tokensFileId: nil, tokensCollectionName: nil, @@ -259,14 +265,14 @@ final class SpinnerLabelTests: XCTestCase { XCTAssertEqual(input.spinnerLabel, "design-tokens.json") } - func testUnsupportedSourceKindSpinnerLabelShowsRawValue() { + func testPenpotSpinnerLabelShowsTruncatedFileId() { let input = ColorsSourceInput( sourceKind: .penpot, - sourceConfig: FigmaColorsConfig( - tokensFileId: "", tokensCollectionName: "", lightModeName: "" + sourceConfig: PenpotColorsConfig( + fileId: "abc12345-def6-7890", baseURL: "https://design.penpot.app", pathFilter: nil ) ) - XCTAssertEqual(input.spinnerLabel, "penpot") + XCTAssertEqual(input.spinnerLabel, "Penpot colors (abc12345…)") } } diff --git a/Tests/ExFigTests/Input/EnumBridgingTests.swift b/Tests/ExFigTests/Input/EnumBridgingTests.swift index eae4c5b3..851bcda3 100644 --- a/Tests/ExFigTests/Input/EnumBridgingTests.swift +++ b/Tests/ExFigTests/Input/EnumBridgingTests.swift @@ -68,6 +68,7 @@ final class EnumBridgingTests: XCTestCase { syncCodeSyntax: nil, codeSyntaxTemplate: nil, sourceKind: nil, + penpotSource: nil, tokensFile: nil, tokensFileId: nil, tokensCollectionName: nil, @@ -114,6 +115,7 @@ final class EnumBridgingTests: XCTestCase { renderModeOriginalSuffix: nil, renderModeTemplateSuffix: nil, sourceKind: nil, + penpotSource: nil, figmaFrameName: nil, figmaPageName: nil, figmaFileId: nil, @@ -158,6 +160,7 @@ final class EnumBridgingTests: XCTestCase { renderModeOriginalSuffix: nil, renderModeTemplateSuffix: nil, sourceKind: nil, + penpotSource: nil, figmaFrameName: nil, figmaPageName: nil, figmaFileId: nil, @@ -198,6 +201,7 @@ final class EnumBridgingTests: XCTestCase { codeConnectPackageName: nil, codeConnectKotlin: nil, sourceKind: nil, + penpotSource: nil, figmaFrameName: nil, figmaPageName: nil, figmaFileId: nil, @@ -226,6 +230,7 @@ final class EnumBridgingTests: XCTestCase { codeConnectPackageName: nil, codeConnectKotlin: nil, sourceKind: nil, + penpotSource: nil, figmaFrameName: nil, figmaPageName: nil, figmaFileId: nil, @@ -260,6 +265,7 @@ final class EnumBridgingTests: XCTestCase { nameStyle: pklStyle, codeConnectKotlin: nil, sourceKind: nil, + penpotSource: nil, figmaFrameName: nil, figmaPageName: nil, figmaFileId: nil, @@ -286,6 +292,7 @@ final class EnumBridgingTests: XCTestCase { nameStyle: nil, codeConnectKotlin: nil, sourceKind: nil, + penpotSource: nil, figmaFrameName: nil, figmaPageName: nil, figmaFileId: nil, @@ -349,6 +356,7 @@ final class EnumBridgingTests: XCTestCase { renderModeOriginalSuffix: nil, renderModeTemplateSuffix: nil, sourceKind: nil, + penpotSource: nil, figmaFrameName: nil, figmaPageName: nil, figmaFileId: nil, @@ -382,6 +390,7 @@ final class EnumBridgingTests: XCTestCase { renderModeOriginalSuffix: nil, renderModeTemplateSuffix: nil, sourceKind: nil, + penpotSource: nil, figmaFrameName: nil, figmaPageName: nil, figmaFileId: nil, @@ -445,6 +454,7 @@ final class EnumBridgingTests: XCTestCase { syncCodeSyntax: nil, codeSyntaxTemplate: nil, sourceKind: nil, + penpotSource: nil, tokensFile: nil, tokensFileId: nil, tokensCollectionName: "Collection", lightModeName: "Light", @@ -474,6 +484,7 @@ final class EnumBridgingTests: XCTestCase { syncCodeSyntax: nil, codeSyntaxTemplate: nil, sourceKind: nil, + penpotSource: nil, tokensFile: nil, tokensFileId: "", tokensCollectionName: "Collection", lightModeName: "Light", @@ -503,6 +514,7 @@ final class EnumBridgingTests: XCTestCase { syncCodeSyntax: nil, codeSyntaxTemplate: nil, sourceKind: nil, + penpotSource: nil, tokensFile: nil, tokensFileId: "file123", tokensCollectionName: "Collection", lightModeName: "Light", @@ -538,6 +550,7 @@ final class EnumBridgingTests: XCTestCase { syncCodeSyntax: nil, codeSyntaxTemplate: nil, sourceKind: nil, + penpotSource: nil, tokensFile: Common.TokensFile(path: "tokens.json", groupFilter: nil), tokensFileId: nil, tokensCollectionName: nil, @@ -570,6 +583,7 @@ final class EnumBridgingTests: XCTestCase { syncCodeSyntax: nil, codeSyntaxTemplate: nil, sourceKind: nil, + penpotSource: nil, tokensFile: Common.TokensFile(path: "design-tokens.json", groupFilter: "Brand.Colors"), tokensFileId: nil, tokensCollectionName: nil, @@ -602,6 +616,7 @@ final class EnumBridgingTests: XCTestCase { syncCodeSyntax: nil, codeSyntaxTemplate: nil, sourceKind: nil, + penpotSource: nil, tokensFile: nil, tokensFileId: nil, tokensCollectionName: nil, @@ -631,6 +646,7 @@ final class EnumBridgingTests: XCTestCase { colorKotlin: nil, themeAttributes: nil, sourceKind: nil, + penpotSource: nil, tokensFile: nil, tokensFileId: nil, tokensCollectionName: "Collection", lightModeName: "Light", @@ -657,6 +673,7 @@ final class EnumBridgingTests: XCTestCase { colorKotlin: nil, themeAttributes: nil, sourceKind: nil, + penpotSource: nil, tokensFile: nil, tokensFileId: "", tokensCollectionName: "Collection", lightModeName: "Light", @@ -683,6 +700,7 @@ final class EnumBridgingTests: XCTestCase { colorKotlin: nil, themeAttributes: nil, sourceKind: nil, + penpotSource: nil, tokensFile: nil, tokensFileId: "file456", tokensCollectionName: "Colors", lightModeName: "Light", @@ -710,7 +728,7 @@ final class EnumBridgingTests: XCTestCase { groupUsingNamespace: nil, assetsFolderProvidesNamespace: nil, colorSwift: nil, swiftuiColorSwift: nil, xcassetsPath: nil, templatesPath: nil, syncCodeSyntax: nil, codeSyntaxTemplate: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "file123", tokensCollectionName: nil, + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "file123", tokensCollectionName: nil, lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -726,7 +744,8 @@ final class EnumBridgingTests: XCTestCase { groupUsingNamespace: nil, assetsFolderProvidesNamespace: nil, colorSwift: nil, swiftuiColorSwift: nil, xcassetsPath: nil, templatesPath: nil, syncCodeSyntax: nil, codeSyntaxTemplate: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "file123", tokensCollectionName: "", lightModeName: "Light", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "file123", tokensCollectionName: "", + lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -741,7 +760,8 @@ final class EnumBridgingTests: XCTestCase { groupUsingNamespace: nil, assetsFolderProvidesNamespace: nil, colorSwift: nil, swiftuiColorSwift: nil, xcassetsPath: nil, templatesPath: nil, syncCodeSyntax: nil, codeSyntaxTemplate: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "file123", tokensCollectionName: "Collection", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "file123", + tokensCollectionName: "Collection", lightModeName: nil, darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -757,7 +777,8 @@ final class EnumBridgingTests: XCTestCase { groupUsingNamespace: nil, assetsFolderProvidesNamespace: nil, colorSwift: nil, swiftuiColorSwift: nil, xcassetsPath: nil, templatesPath: nil, syncCodeSyntax: nil, codeSyntaxTemplate: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "file123", tokensCollectionName: "Collection", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "file123", + tokensCollectionName: "Collection", lightModeName: "", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -774,7 +795,7 @@ final class EnumBridgingTests: XCTestCase { mainRes: nil, mainSrc: nil, templatesPath: nil, xmlOutputFileName: nil, xmlDisabled: nil, composePackageName: nil, colorKotlin: nil, themeAttributes: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "file456", tokensCollectionName: nil, + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "file456", tokensCollectionName: nil, lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -789,7 +810,8 @@ final class EnumBridgingTests: XCTestCase { mainRes: nil, mainSrc: nil, templatesPath: nil, xmlOutputFileName: nil, xmlDisabled: nil, composePackageName: nil, colorKotlin: nil, themeAttributes: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "file456", tokensCollectionName: "", lightModeName: "Light", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "file456", tokensCollectionName: "", + lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -803,7 +825,8 @@ final class EnumBridgingTests: XCTestCase { mainRes: nil, mainSrc: nil, templatesPath: nil, xmlOutputFileName: nil, xmlDisabled: nil, composePackageName: nil, colorKotlin: nil, themeAttributes: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "file456", tokensCollectionName: "Colors", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "file456", + tokensCollectionName: "Colors", lightModeName: nil, darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -818,7 +841,8 @@ final class EnumBridgingTests: XCTestCase { mainRes: nil, mainSrc: nil, templatesPath: nil, xmlOutputFileName: nil, xmlDisabled: nil, composePackageName: nil, colorKotlin: nil, themeAttributes: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "file456", tokensCollectionName: "Colors", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "file456", + tokensCollectionName: "Colors", lightModeName: "", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -833,7 +857,7 @@ final class EnumBridgingTests: XCTestCase { func testFlutterColorsEntryThrowsOnMissingTokensFileId() { let entry = Flutter.ColorsEntry( templatesPath: nil, output: nil, className: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: nil, tokensCollectionName: "Collection", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: nil, tokensCollectionName: "Collection", lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -846,7 +870,7 @@ final class EnumBridgingTests: XCTestCase { func testFlutterColorsEntryThrowsOnEmptyTokensFileId() { let entry = Flutter.ColorsEntry( templatesPath: nil, output: nil, className: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "", tokensCollectionName: "Collection", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "", tokensCollectionName: "Collection", lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -859,7 +883,7 @@ final class EnumBridgingTests: XCTestCase { func testFlutterColorsEntryThrowsOnMissingTokensCollectionName() { let entry = Flutter.ColorsEntry( templatesPath: nil, output: nil, className: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "file789", tokensCollectionName: nil, + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "file789", tokensCollectionName: nil, lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -872,7 +896,8 @@ final class EnumBridgingTests: XCTestCase { func testFlutterColorsEntryThrowsOnEmptyTokensCollectionName() { let entry = Flutter.ColorsEntry( templatesPath: nil, output: nil, className: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "file789", tokensCollectionName: "", lightModeName: "Light", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "file789", tokensCollectionName: "", + lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -884,7 +909,8 @@ final class EnumBridgingTests: XCTestCase { func testFlutterColorsEntryThrowsOnMissingLightModeName() { let entry = Flutter.ColorsEntry( templatesPath: nil, output: nil, className: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "file789", tokensCollectionName: "Collection", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "file789", + tokensCollectionName: "Collection", lightModeName: nil, darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -897,7 +923,8 @@ final class EnumBridgingTests: XCTestCase { func testFlutterColorsEntryThrowsOnEmptyLightModeName() { let entry = Flutter.ColorsEntry( templatesPath: nil, output: nil, className: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "file789", tokensCollectionName: "Collection", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "file789", + tokensCollectionName: "Collection", lightModeName: "", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -910,7 +937,8 @@ final class EnumBridgingTests: XCTestCase { func testFlutterColorsEntryValidatesSuccessfully() throws { let entry = Flutter.ColorsEntry( templatesPath: nil, output: nil, className: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "file789", tokensCollectionName: "Colors", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "file789", + tokensCollectionName: "Colors", lightModeName: "Light", darkModeName: "Dark", lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -930,7 +958,7 @@ final class EnumBridgingTests: XCTestCase { let entry = Web.ColorsEntry( output: nil, templatesPath: nil, outputDirectory: nil, cssFileName: nil, tsFileName: nil, jsonFileName: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: nil, tokensCollectionName: "Collection", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: nil, tokensCollectionName: "Collection", lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -944,7 +972,7 @@ final class EnumBridgingTests: XCTestCase { let entry = Web.ColorsEntry( output: nil, templatesPath: nil, outputDirectory: nil, cssFileName: nil, tsFileName: nil, jsonFileName: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "", tokensCollectionName: "Collection", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "", tokensCollectionName: "Collection", lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -958,7 +986,7 @@ final class EnumBridgingTests: XCTestCase { let entry = Web.ColorsEntry( output: nil, templatesPath: nil, outputDirectory: nil, cssFileName: nil, tsFileName: nil, jsonFileName: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "fileABC", tokensCollectionName: nil, + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "fileABC", tokensCollectionName: nil, lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -972,7 +1000,8 @@ final class EnumBridgingTests: XCTestCase { let entry = Web.ColorsEntry( output: nil, templatesPath: nil, outputDirectory: nil, cssFileName: nil, tsFileName: nil, jsonFileName: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "fileABC", tokensCollectionName: "", lightModeName: "Light", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "fileABC", tokensCollectionName: "", + lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -985,7 +1014,8 @@ final class EnumBridgingTests: XCTestCase { let entry = Web.ColorsEntry( output: nil, templatesPath: nil, outputDirectory: nil, cssFileName: nil, tsFileName: nil, jsonFileName: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "fileABC", tokensCollectionName: "Collection", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "fileABC", + tokensCollectionName: "Collection", lightModeName: nil, darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -999,7 +1029,8 @@ final class EnumBridgingTests: XCTestCase { let entry = Web.ColorsEntry( output: nil, templatesPath: nil, outputDirectory: nil, cssFileName: nil, tsFileName: nil, jsonFileName: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "fileABC", tokensCollectionName: "Collection", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "fileABC", + tokensCollectionName: "Collection", lightModeName: "", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -1013,7 +1044,8 @@ final class EnumBridgingTests: XCTestCase { let entry = Web.ColorsEntry( output: nil, templatesPath: nil, outputDirectory: nil, cssFileName: nil, tsFileName: nil, jsonFileName: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "fileABC", tokensCollectionName: "Colors", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "fileABC", + tokensCollectionName: "Colors", lightModeName: "Light", darkModeName: "Dark", lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil diff --git a/Tests/PenpotAPITests/Fixtures/file-response.json b/Tests/PenpotAPITests/Fixtures/file-response.json new file mode 100644 index 00000000..b430e7a6 --- /dev/null +++ b/Tests/PenpotAPITests/Fixtures/file-response.json @@ -0,0 +1,70 @@ +{ + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "Test Design System", + "data": { + "colors": { + "color-uuid-1": { + "id": "color-uuid-1", + "name": "Blue", + "path": "Brand/Primary", + "color": "#3366FF", + "opacity": 1.0 + }, + "color-uuid-2": { + "id": "color-uuid-2", + "name": "Red", + "path": "Brand/Secondary", + "color": "#FF3366", + "opacity": 0.8 + }, + "color-uuid-3": { + "id": "color-uuid-3", + "name": "Gradient", + "path": "Effects", + "opacity": 1.0 + } + }, + "typographies": { + "typo-uuid-1": { + "id": "typo-uuid-1", + "name": "Heading", + "path": "Styles", + "fontFamily": "Roboto", + "fontStyle": "normal", + "textTransform": "uppercase", + "fontSize": "24", + "fontWeight": "700", + "lineHeight": "1.5", + "letterSpacing": "0.02" + }, + "typo-uuid-2": { + "id": "typo-uuid-2", + "name": "Body", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": 400, + "lineHeight": 1.6, + "letterSpacing": 0 + } + }, + "components": { + "comp-uuid-1": { + "id": "comp-uuid-1", + "name": "arrow-right", + "path": "Icons/Navigation", + "mainInstanceId": "instance-123", + "mainInstancePage": "page-456" + }, + "comp-uuid-2": { + "id": "comp-uuid-2", + "name": "star", + "path": "Icons/Actions" + }, + "comp-uuid-3": { + "id": "comp-uuid-3", + "name": "hero-banner", + "path": "Illustrations" + } + } + } +} diff --git a/Tests/PenpotAPITests/PenpotAPIErrorTests.swift b/Tests/PenpotAPITests/PenpotAPIErrorTests.swift new file mode 100644 index 00000000..3d85ca7b --- /dev/null +++ b/Tests/PenpotAPITests/PenpotAPIErrorTests.swift @@ -0,0 +1,40 @@ +import Foundation +@testable import PenpotAPI +import Testing + +@Suite("PenpotAPIError") +struct PenpotAPIErrorTests { + @Test("401 error suggests checking PENPOT_ACCESS_TOKEN") + func authError() { + let error = PenpotAPIError(statusCode: 401, message: "Unauthorized", endpoint: "get-profile") + #expect(error.recoverySuggestion?.contains("PENPOT_ACCESS_TOKEN") == true) + #expect(error.errorDescription?.contains("get-profile") == true) + } + + @Test("404 error suggests checking file UUID") + func notFoundError() { + let error = PenpotAPIError(statusCode: 404, message: "Not found", endpoint: "get-file") + #expect(error.recoverySuggestion?.contains("UUID") == true) + } + + @Test("429 error mentions rate limiting") + func rateLimitError() { + let error = PenpotAPIError(statusCode: 429, message: nil, endpoint: "get-file") + #expect(error.recoverySuggestion?.contains("Rate") == true) + } + + @Test("500 error has no recovery suggestion") + func serverError() { + let error = PenpotAPIError(statusCode: 500, message: "Internal error", endpoint: "get-file") + #expect(error.recoverySuggestion == nil) + } + + @Test("Error description includes endpoint and status code") + func errorDescription() { + let error = PenpotAPIError(statusCode: 403, message: "Forbidden", endpoint: "get-file") + let desc = error.errorDescription ?? "" + #expect(desc.contains("403")) + #expect(desc.contains("get-file")) + #expect(desc.contains("Forbidden")) + } +} diff --git a/Tests/PenpotAPITests/PenpotColorDecodingTests.swift b/Tests/PenpotAPITests/PenpotColorDecodingTests.swift new file mode 100644 index 00000000..566831ba --- /dev/null +++ b/Tests/PenpotAPITests/PenpotColorDecodingTests.swift @@ -0,0 +1,67 @@ +import Foundation +@testable import PenpotAPI +import Testing +import YYJSON + +@Suite("PenpotColor Decoding") +struct PenpotColorDecodingTests { + @Test("Decodes solid color with hex and opacity") + func decodeSolidColor() throws { + let json = Data(""" + {"id":"uuid-1","name":"Blue","path":"Brand/Primary","color":"#3366FF","opacity":1.0} + """.utf8) + + let color = try YYJSONDecoder().decode(PenpotColor.self, from: json) + #expect(color.id == "uuid-1") + #expect(color.name == "Blue") + #expect(color.path == "Brand/Primary") + #expect(color.color == "#3366FF") + #expect(color.opacity == 1.0) + } + + @Test("Gradient color has nil hex") + func decodeGradientColor() throws { + let json = Data(""" + {"id":"uuid-2","name":"Gradient","path":"Effects","opacity":1.0} + """.utf8) + + let color = try YYJSONDecoder().decode(PenpotColor.self, from: json) + #expect(color.id == "uuid-2") + #expect(color.name == "Gradient") + #expect(color.color == nil) + } + + @Test("Color without path") + func decodeColorWithoutPath() throws { + let json = Data(""" + {"id":"uuid-3","name":"Plain","color":"#000000"} + """.utf8) + + let color = try YYJSONDecoder().decode(PenpotColor.self, from: json) + #expect(color.path == nil) + #expect(color.opacity == nil) + } + + @Test("Color map from file response") + func decodeColorMap() throws { + let url = try #require(Bundle.module.url( + forResource: "file-response", + withExtension: "json", + subdirectory: "Fixtures" + )) + let data = try Data(contentsOf: url) + let response = try YYJSONDecoder().decode(PenpotFileResponse.self, from: data) + + let colors = response.data.colors + #expect(colors != nil) + #expect(colors?.count == 3) + + let blue = colors?["color-uuid-1"] + #expect(blue?.name == "Blue") + #expect(blue?.color == "#3366FF") + + // Gradient has no solid color + let gradient = colors?["color-uuid-3"] + #expect(gradient?.color == nil) + } +} diff --git a/Tests/PenpotAPITests/PenpotComponentDecodingTests.swift b/Tests/PenpotAPITests/PenpotComponentDecodingTests.swift new file mode 100644 index 00000000..b8772f2d --- /dev/null +++ b/Tests/PenpotAPITests/PenpotComponentDecodingTests.swift @@ -0,0 +1,55 @@ +import Foundation +@testable import PenpotAPI +import Testing +import YYJSON + +@Suite("PenpotComponent Decoding") +struct PenpotComponentDecodingTests { + @Test("Decodes component with camelCase keys") + func decodeCamelCase() throws { + let json = Data(( + #"{"id":"c1","name":"arrow-right","path":"Icons/Navigation","# + + #""mainInstanceId":"inst-123","mainInstancePage":"page-456"}"# + ).utf8) + + let comp = try YYJSONDecoder().decode(PenpotComponent.self, from: json) + #expect(comp.id == "c1") + #expect(comp.name == "arrow-right") + #expect(comp.path == "Icons/Navigation") + #expect(comp.mainInstanceId == "inst-123") + #expect(comp.mainInstancePage == "page-456") + } + + @Test("Component with optional fields nil") + func decodeMinimal() throws { + let json = Data(""" + {"id":"c2","name":"star"} + """.utf8) + + let comp = try YYJSONDecoder().decode(PenpotComponent.self, from: json) + #expect(comp.id == "c2") + #expect(comp.name == "star") + #expect(comp.path == nil) + #expect(comp.mainInstanceId == nil) + #expect(comp.mainInstancePage == nil) + } + + @Test("Components map from file response") + func decodeFromFixture() throws { + let url = try #require(Bundle.module.url( + forResource: "file-response", + withExtension: "json", + subdirectory: "Fixtures" + )) + let data = try Data(contentsOf: url) + let response = try YYJSONDecoder().decode(PenpotFileResponse.self, from: data) + + let comps = response.data.components + #expect(comps != nil) + #expect(comps?.count == 3) + + let arrow = comps?["comp-uuid-1"] + #expect(arrow?.name == "arrow-right") + #expect(arrow?.mainInstanceId == "instance-123") + } +} diff --git a/Tests/PenpotAPITests/PenpotEndpointTests.swift b/Tests/PenpotAPITests/PenpotEndpointTests.swift new file mode 100644 index 00000000..7a22da30 --- /dev/null +++ b/Tests/PenpotAPITests/PenpotEndpointTests.swift @@ -0,0 +1,43 @@ +import Foundation +@testable import PenpotAPI +import Testing +import YYJSON + +@Suite("Penpot Endpoint") +struct PenpotEndpointTests { + @Test("GetFileEndpoint produces correct command name") + func getFileCommandName() { + let endpoint = GetFileEndpoint(fileId: "test-uuid") + #expect(endpoint.commandName == "get-file") + } + + @Test("GetFileEndpoint body contains file ID") + func getFileBody() throws { + let endpoint = GetFileEndpoint(fileId: "abc-123") + let body = try #require(try endpoint.body()) + let json = try JSONSerialization.jsonObject(with: body) as? [String: String] + #expect(json?["id"] == "abc-123") + } + + @Test("GetProfileEndpoint has no body") + func getProfileNoBody() throws { + let endpoint = GetProfileEndpoint() + #expect(endpoint.commandName == "get-profile") + let body = try endpoint.body() + #expect(body == nil) + } + + @Test("GetFileObjectThumbnailsEndpoint body has kebab-case keys") + func thumbnailsBody() throws { + let endpoint = GetFileObjectThumbnailsEndpoint( + fileId: "file-uuid", + objectIds: ["obj-1", "obj-2"] + ) + #expect(endpoint.commandName == "get-file-object-thumbnails") + + let body = try #require(try endpoint.body()) + let json = try JSONSerialization.jsonObject(with: body) as? [String: Any] + #expect(json?["file-id"] as? String == "file-uuid") + #expect((json?["object-ids"] as? [String])?.count == 2) + } +} diff --git a/Tests/PenpotAPITests/PenpotTypographyDecodingTests.swift b/Tests/PenpotAPITests/PenpotTypographyDecodingTests.swift new file mode 100644 index 00000000..ab808664 --- /dev/null +++ b/Tests/PenpotAPITests/PenpotTypographyDecodingTests.swift @@ -0,0 +1,83 @@ +import Foundation +@testable import PenpotAPI +import Testing +import YYJSON + +@Suite("PenpotTypography Decoding") +struct PenpotTypographyDecodingTests { + @Test("String numeric values are parsed as Double") + func decodeStringNumerics() throws { + let json = Data(( + #"{"id":"t1","name":"Heading","fontFamily":"Roboto","# + + #""fontSize":"24","fontWeight":"700","lineHeight":"1.5","letterSpacing":"0.02"}"# + ).utf8) + + let typo = try YYJSONDecoder().decode(PenpotTypography.self, from: json) + #expect(typo.fontSize == 24.0) + #expect(typo.fontWeight == 700.0) + #expect(typo.lineHeight == 1.5) + #expect(typo.letterSpacing == 0.02) + } + + @Test("JSON number values are parsed as Double") + func decodeNumberValues() throws { + let json = Data(""" + {"id":"t2","name":"Body","fontFamily":"Inter","fontSize":16,"fontWeight":400,"lineHeight":1.6,"letterSpacing":0} + """.utf8) + + let typo = try YYJSONDecoder().decode(PenpotTypography.self, from: json) + #expect(typo.fontSize == 16.0) + #expect(typo.fontWeight == 400.0) + #expect(typo.lineHeight == 1.6) + #expect(typo.letterSpacing == 0.0) + } + + @Test("Unparseable string values become nil") + func decodeUnparseableValues() throws { + let json = Data(""" + {"id":"t3","name":"Auto","fontFamily":"System","fontSize":"auto","fontWeight":"bold"} + """.utf8) + + let typo = try YYJSONDecoder().decode(PenpotTypography.self, from: json) + #expect(typo.fontSize == nil) + #expect(typo.fontWeight == nil) + } + + @Test("camelCase keys decode without CodingKeys") + func decodeCamelCaseKeys() throws { + let json = Data(""" + {"id":"t4","name":"Styled","fontFamily":"Roboto","fontStyle":"italic","textTransform":"uppercase","fontSize":14} + """.utf8) + + let typo = try YYJSONDecoder().decode(PenpotTypography.self, from: json) + #expect(typo.fontFamily == "Roboto") + #expect(typo.fontStyle == "italic") + #expect(typo.textTransform == "uppercase") + } + + @Test("Typography map from file response") + func decodeFromFixture() throws { + let url = try #require(Bundle.module.url( + forResource: "file-response", + withExtension: "json", + subdirectory: "Fixtures" + )) + let data = try Data(contentsOf: url) + let response = try YYJSONDecoder().decode(PenpotFileResponse.self, from: data) + + let typos = response.data.typographies + #expect(typos != nil) + #expect(typos?.count == 2) + + // String numerics + let heading = typos?["typo-uuid-1"] + #expect(heading?.fontSize == 24.0) + #expect(heading?.fontWeight == 700.0) + #expect(heading?.textTransform == "uppercase") + + // Number values + let body = typos?["typo-uuid-2"] + #expect(body?.fontSize == 16.0) + #expect(body?.fontWeight == 400.0) + } +} diff --git a/openspec/changes/add-penpot-support/tasks.md b/openspec/changes/add-penpot-support/tasks.md index 8d599ac7..8dd58f0b 100644 --- a/openspec/changes/add-penpot-support/tasks.md +++ b/openspec/changes/add-penpot-support/tasks.md @@ -1,57 +1,57 @@ ## 1. PenpotAPI Module — Package Setup -- [ ] 1.1 Add `PenpotAPI` target and `PenpotAPITests` test target to `Package.swift`; add `"PenpotAPI"` to ExFigCLI dependencies -- [ ] 1.2 Create `Sources/PenpotAPI/CLAUDE.md` with module overview +- [x] 1.1 Add `PenpotAPI` target and `PenpotAPITests` test target to `Package.swift`; add `"PenpotAPI"` to ExFigCLI dependencies +- [x] 1.2 Create `Sources/PenpotAPI/CLAUDE.md` with module overview ## 2. PenpotAPI Module — Client -- [ ] 2.1 Define `PenpotEndpoint` protocol and `PenpotClient` protocol in `Sources/PenpotAPI/Client/` -- [ ] 2.2 Implement `BasePenpotClient` (URLSession, auth header, base URL, retry logic) -- [ ] 2.3 Implement `PenpotAPIError` with LocalizedError conformance and recovery suggestions +- [x] 2.1 Define `PenpotEndpoint` protocol and `PenpotClient` protocol in `Sources/PenpotAPI/Client/` +- [x] 2.2 Implement `BasePenpotClient` (URLSession, auth header, base URL, retry logic) +- [x] 2.3 Implement `PenpotAPIError` with LocalizedError conformance and recovery suggestions ## 3. PenpotAPI Module — Endpoints -- [ ] 3.1 Implement `GetFileEndpoint` (command: `get-file`, body: `{id}`, response: `PenpotFileResponse`) -- [ ] 3.2 Implement `GetProfileEndpoint` (command: `get-profile`, no body, response: `PenpotProfile`) -- [ ] 3.3 Implement `GetFileObjectThumbnailsEndpoint` (command: `get-file-object-thumbnails`, response: thumbnail map) -- [ ] 3.4 Implement asset download method (`GET /assets/by-file-media-id/`) +- [x] 3.1 Implement `GetFileEndpoint` (command: `get-file`, body: `{id}`, response: `PenpotFileResponse`) +- [x] 3.2 Implement `GetProfileEndpoint` (command: `get-profile`, no body, response: `PenpotProfile`) +- [x] 3.3 Implement `GetFileObjectThumbnailsEndpoint` (command: `get-file-object-thumbnails`, response: thumbnail map) +- [x] 3.4 Implement asset download method (`GET /assets/by-file-media-id/`) ## 4. PenpotAPI Module — Models -- [ ] 4.1 Define `PenpotFileResponse` and `PenpotFileData` with selective decoding (colors, typographies, components) -- [ ] 4.2 Define `PenpotColor` (id, name, path, color hex, opacity) — standard Codable, no CodingKeys (JSON uses camelCase) -- [ ] 4.3 Define `PenpotComponent` (id, name, path, mainInstanceId, mainInstancePage) — standard Codable, no CodingKeys -- [ ] 4.4 Define `PenpotTypography` with dual String/Double decoding via custom `init(from:)` — handles both `"24"` and `24` -- [ ] 4.5 Define `PenpotProfile` (id, fullname, email) +- [x] 4.1 Define `PenpotFileResponse` and `PenpotFileData` with selective decoding (colors, typographies, components) +- [x] 4.2 Define `PenpotColor` (id, name, path, color hex, opacity) — standard Codable, no CodingKeys (JSON uses camelCase) +- [x] 4.3 Define `PenpotComponent` (id, name, path, mainInstanceId, mainInstancePage) — standard Codable, no CodingKeys +- [x] 4.4 Define `PenpotTypography` with dual String/Double decoding via custom `init(from:)` — handles both `"24"` and `24` +- [x] 4.5 Define `PenpotProfile` (id, fullname, email) ## 5. PenpotAPI Module — Unit Tests -- [ ] 5.1 Create JSON fixtures in `Tests/PenpotAPITests/Fixtures/` (file response, colors, components, typographies) -- [ ] 5.2 Write `PenpotColorDecodingTests` — solid, gradient (nil hex), path grouping -- [ ] 5.3 Write `PenpotTypographyDecodingTests` — string→Double, number→Double, unparseable values, camelCase keys -- [ ] 5.4 Write `PenpotComponentDecodingTests` — camelCase keys, optional fields -- [ ] 5.5 Write `PenpotEndpointTests` — URL construction (`/api/main/methods/`), body serialization for RPC endpoints -- [ ] 5.6 Write `PenpotAPIErrorTests` — recovery suggestions for 401, 404, 429 +- [x] 5.1 Create JSON fixtures in `Tests/PenpotAPITests/Fixtures/` (file response, colors, components, typographies) +- [x] 5.2 Write `PenpotColorDecodingTests` — solid, gradient (nil hex), path grouping +- [x] 5.3 Write `PenpotTypographyDecodingTests` — string→Double, number→Double, unparseable values, camelCase keys +- [x] 5.4 Write `PenpotComponentDecodingTests` — camelCase keys, optional fields +- [x] 5.5 Write `PenpotEndpointTests` — URL construction (`/api/main/methods/`), body serialization for RPC endpoints +- [x] 5.6 Write `PenpotAPIErrorTests` — recovery suggestions for 401, 404, 429 ## 6. ExFigCore — Config Types -- [ ] 6.1 Add `PenpotColorsConfig: ColorsSourceConfig` to `DesignSource.swift` (fileId, baseURL, pathFilter) -- [ ] 6.2 Update `ColorsSourceInput.spinnerLabel` in `ExportContext.swift` for `.penpot` case +- [x] 6.1 Add `PenpotColorsConfig: ColorsSourceConfig` to `DesignSource.swift` (fileId, baseURL, pathFilter) +- [x] 6.2 Update `ColorsSourceInput.spinnerLabel` in `ExportContext.swift` for `.penpot` case ## 7. Integration Sources -- [ ] 7.1 Implement `PenpotColorsSource` in `ExFigCLI/Source/` — hex→RGBA, path filter, light-only output -- [ ] 7.2 Implement `PenpotComponentsSource` in `ExFigCLI/Source/` — component filter, thumbnails, SVG warning -- [ ] 7.3 Implement `PenpotTypographySource` in `ExFigCLI/Source/` — string→Double, textCase mapping -- [ ] 7.4 Update `SourceFactory.swift` — replace `throw unsupportedSourceKind(.penpot)` with real Penpot sources +- [x] 7.1 Implement `PenpotColorsSource` in `ExFigCLI/Source/` — hex→RGBA, path filter, light-only output +- [x] 7.2 Implement `PenpotComponentsSource` in `ExFigCLI/Source/` — component filter, thumbnails, SVG warning +- [x] 7.3 Implement `PenpotTypographySource` in `ExFigCLI/Source/` — string→Double, textCase mapping +- [x] 7.4 Update `SourceFactory.swift` — replace `throw unsupportedSourceKind(.penpot)` with real Penpot sources ## 8. PKL Schema + Codegen -- [ ] 8.1 Add `PenpotSource` class to `Common.pkl` (fileId, baseUrl, pathFilter) -- [ ] 8.2 Add `penpotSource: PenpotSource?` to `VariablesSource` and `FrameSource` in `Common.pkl` -- [ ] 8.3 Add sourceKind auto-detection logic in PKL (penpotSource → "penpot") -- [ ] 8.4 Run `./bin/mise run codegen:pkl` and verify generated types -- [ ] 8.5 Update entry bridge methods in `Sources/ExFig-*/Config/*Entry.swift` — map `penpotSource` → `PenpotColorsConfig` / SourceInput fields +- [x] 8.1 Add `PenpotSource` class to `Common.pkl` (fileId, baseUrl, pathFilter) +- [x] 8.2 Add `penpotSource: PenpotSource?` to `VariablesSource` and `FrameSource` in `Common.pkl` +- [x] 8.3 Add sourceKind auto-detection logic in PKL (penpotSource → "penpot") +- [x] 8.4 Run `./bin/mise run codegen:pkl` and verify generated types +- [x] 8.5 Update entry bridge methods in `Sources/ExFig-*/Config/*Entry.swift` — map `penpotSource` → `PenpotColorsConfig` / SourceInput fields ## 9. E2E Tests @@ -62,8 +62,8 @@ ## 10. Verification -- [ ] 10.1 `./bin/mise run build` — all modules compile -- [ ] 10.2 `./bin/mise run test` — unit tests pass -- [ ] 10.3 `./bin/mise run lint` — no SwiftLint violations -- [ ] 10.4 `./bin/mise run format-check` — formatting correct +- [x] 10.1 `./bin/mise run build` — all modules compile +- [x] 10.2 `./bin/mise run test` — unit tests pass +- [x] 10.3 `./bin/mise run lint` — no SwiftLint violations +- [x] 10.4 `./bin/mise run format-check` — formatting correct - [ ] 10.5 E2E tests pass with `PENPOT_ACCESS_TOKEN` and `PENPOT_TEST_FILE_ID` From 897c5acdbf6b9ab3e2bc4ef1efa8cc2c7e8bea6b Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sat, 21 Mar 2026 21:19:51 +0500 Subject: [PATCH 02/26] docs: update CLAUDE.md with PenpotAPI module and source kind patterns --- CLAUDE.md | 38 ++++++++++++++++++++++------------- Sources/ExFigConfig/CLAUDE.md | 17 ++++++++-------- 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 67921a00..919632f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,23 +100,25 @@ pkl eval --format json # Package URI requires published package Fourteen modules in `Sources/`: -| Module | Purpose | -| --------------- | --------------------------------------------------------- | -| `ExFigCLI` | CLI commands, loaders, file I/O, terminal UI | -| `ExFigCore` | Domain models (Color, Image, TextStyle), processors | -| `ExFigConfig` | PKL config parsing, evaluation, type bridging | -| `ExFig-iOS` | iOS platform plugin (ColorsExporter, IconsExporter, etc.) | -| `ExFig-Android` | Android platform plugin | -| `ExFig-Flutter` | Flutter platform plugin | -| `ExFig-Web` | Web platform plugin | -| `XcodeExport` | iOS export (.xcassets, Swift extensions) | -| `AndroidExport` | Android export (XML resources, Compose, Vector Drawables) | -| `FlutterExport` | Flutter export (Dart code, SVG/PNG assets) | -| `WebExport` | Web/React export (CSS variables, JSX icons) | -| `JinjaSupport` | Shared Jinja2 template rendering across Export modules | +| Module | Purpose | +| --------------- | ----------------------------------------------------------- | +| `ExFigCLI` | CLI commands, loaders, file I/O, terminal UI | +| `ExFigCore` | Domain models (Color, Image, TextStyle), processors | +| `ExFigConfig` | PKL config parsing, evaluation, type bridging | +| `ExFig-iOS` | iOS platform plugin (ColorsExporter, IconsExporter, etc.) | +| `ExFig-Android` | Android platform plugin | +| `ExFig-Flutter` | Flutter platform plugin | +| `ExFig-Web` | Web platform plugin | +| `XcodeExport` | iOS export (.xcassets, Swift extensions) | +| `AndroidExport` | Android export (XML resources, Compose, Vector Drawables) | +| `FlutterExport` | Flutter export (Dart code, SVG/PNG assets) | +| `WebExport` | Web/React export (CSS variables, JSX icons) | +| `JinjaSupport` | Shared Jinja2 template rendering across Export modules | +| `PenpotAPI` | Penpot RPC API client (standalone, no ExFigCore dependency) | **Data flow:** CLI -> PKL config parsing -> FigmaAPI (external) fetch -> ExFigCore processing -> Platform plugin -> Export module -> File write **Alt data flow (tokens):** CLI -> local .tokens.json file -> TokensFileSource -> ExFigCore models -> W3C JSON export +**Alt data flow (penpot):** CLI -> PenpotAPI fetch -> Penpot*Source -> ExFigCore models -> Platform plugin -> Export module -> File write **MCP data flow:** `exfig mcp` → StdioTransport (JSON-RPC on stdin/stdout) → tool handlers → PKLEvaluator / TokensFileSource / FigmaAPI **MCP stdout safety:** `OutputMode.mcp` + `TerminalOutputManager.setStderrMode(true)` — all CLI output goes to stderr @@ -295,6 +297,12 @@ FigmaAPI is now an external package (`swift-figma-api`). See its repository for 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. +### Entry Bridge Source Kind Resolution + +Entry bridge methods (`iconsSourceInput()`, `imagesSourceInput()`) use `resolvedSourceKind` (computed property on `Common_FrameSource`) +instead of `sourceKind?.coreSourceKind ?? .figma`. This auto-detects Penpot when `penpotSource` is set. +`Common_VariablesSource` has its own `resolvedSourceKind` in `VariablesSourceValidation.swift` (includes tokensFile + penpot detection). + ### Adding a Platform Plugin Exporter See `ExFigCore/CLAUDE.md` (Modification Checklist) and platform module CLAUDE.md files. @@ -421,6 +429,8 @@ NooraUI.formatLink("url", useColors: true) // underlined primary | `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") | +| `JSONCodec` in standalone module | `JSONCodec` lives in ExFigCore — standalone modules (PenpotAPI) use `YYJSONEncoder()`/`YYJSONDecoder()` from YYJSON directly | +| `function_body_length` after branch | Split into private extension helper methods (e.g., `penpotColorsSourceInput()`, `tokensFileColorsSourceInput()`) | ## Additional Rules diff --git a/Sources/ExFigConfig/CLAUDE.md b/Sources/ExFigConfig/CLAUDE.md index 8ef97f1f..79545808 100644 --- a/Sources/ExFigConfig/CLAUDE.md +++ b/Sources/ExFigConfig/CLAUDE.md @@ -48,14 +48,15 @@ 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.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 | +| 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 (penpotSource > tokensFile) > default `.figma` | +| `Common_VariablesSource.validatedColorsSourceInput()` | Validates required fields, returns `ColorsSourceInput`. Uses `resolvedSourceKind` for dispatch | +| `Common_FrameSource.resolvedSourceKind` | Resolution priority: explicit `sourceKind` > auto-detect (penpotSource presence) > default `.figma`. Defined in `SourceKindBridging.swift` | ### PklError Workaround From 1192a2c769aa79b025153f56b1cbba1c3c20f50f Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sat, 21 Mar 2026 21:21:41 +0500 Subject: [PATCH 03/26] docs: update module docs for PenpotAPI (15 modules, source dispatch) --- CLAUDE.md | 2 +- Sources/ExFigCLI/CLAUDE.md | 4 +++- Sources/ExFigCore/CLAUDE.md | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 919632f9..add8bf49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,7 +98,7 @@ pkl eval --format json # Package URI requires published package ## Architecture -Fourteen modules in `Sources/`: +Fifteen modules in `Sources/`: | Module | Purpose | | --------------- | ----------------------------------------------------------- | diff --git a/Sources/ExFigCLI/CLAUDE.md b/Sources/ExFigCLI/CLAUDE.md index 36fdd703..7973747c 100644 --- a/Sources/ExFigCLI/CLAUDE.md +++ b/Sources/ExFigCLI/CLAUDE.md @@ -174,6 +174,7 @@ Converter factories (`WebpConverterFactory`, `HeicConverterFactory`) handle plat | `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/Penpot*Source.swift` | Penpot source implementations (colors, components, typography) | | `Source/TokensFileColorsSource.swift` | Local .tokens.json source (extracted from ColorsExportContextImpl) | ### MCP Server Architecture @@ -209,9 +210,10 @@ reserved for MCP JSON-RPC protocol. ### Source Dispatch Pattern `ColorsExportContextImpl.loadColors()` creates source via `SourceFactory.createColorsSource(for:...)` per call. -`IconsExportContextImpl` / `ImagesExportContextImpl` still use injected `componentsSource` (only Figma supported). +`IconsExportContextImpl` / `ImagesExportContextImpl` use injected `componentsSource` (Figma and Penpot supported via `SourceFactory`). `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`. +Penpot sources create `BasePenpotClient` internally from `PENPOT_ACCESS_TOKEN` env var (like TokensFileSource reads local files — no injected client). ### Adding a New Subcommand diff --git a/Sources/ExFigCore/CLAUDE.md b/Sources/ExFigCore/CLAUDE.md index 9dcdd859..f82c1e01 100644 --- a/Sources/ExFigCore/CLAUDE.md +++ b/Sources/ExFigCore/CLAUDE.md @@ -29,14 +29,14 @@ Exporter.export*(entries, platformConfig, context) - `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 +- `ColorsSourceConfig` protocol + `FigmaColorsConfig` / `TokensFileColorsConfig` / `PenpotColorsConfig` — 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`. +Implementations live in `Sources/ExFigCLI/Source/` — `FigmaColorsSource`, `TokensFileColorsSource`, `PenpotColorsSource`, `PenpotComponentsSource`, `PenpotTypographySource`, `FigmaComponentsSource`, `FigmaTypographySource`, `SourceFactory`. ### Domain Models From 4672f8f8b4b1163db9a301b2eab2070089019ac4 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sat, 21 Mar 2026 21:26:20 +0500 Subject: [PATCH 04/26] docs: add Penpot support to README, DocC articles, and llms-full.txt --- README.md | 7 +- Sources/ExFigCLI/ExFig.docc/Configuration.md | 35 ++++++++++ Sources/ExFigCLI/ExFig.docc/DesignTokens.md | 2 +- Sources/ExFigCLI/ExFig.docc/ExFig.md | 23 ++++--- Sources/ExFigCLI/ExFig.docc/GettingStarted.md | 24 +++++-- Sources/ExFigCLI/ExFig.docc/Usage.md | 2 +- Sources/ExFigCLI/ExFig.docc/WhyExFig.md | 13 ++-- llms-full.txt | 68 ++++++++++++++++--- 8 files changed, 141 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 90bdf7a2..5bdb07ab 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,12 @@ ![Coverage](https://img.shields.io/badge/coverage-50.65%25-yellow) [![License](https://img.shields.io/github/license/DesignPipe/exfig.svg)](LICENSE) -Export colors, typography, icons, and images from Figma to Xcode, Android Studio, Flutter, and Web projects — automatically. +Export colors, typography, icons, and images from Figma and Penpot to Xcode, Android Studio, Flutter, and Web projects — automatically. ## The Problem - Figma has no "Export to Xcode" button. You copy hex codes by hand, one by one. +- Switching from Figma to Penpot? Your export pipeline shouldn't break. - Every color change means updating files across 3 platforms manually. - Dark mode variant? An afternoon spent on light/dark pairs and @1x/@2x/@3x PNGs. - Android gets XML. iOS gets xcassets. Flutter gets Dart. Someone maintains all three. @@ -24,7 +25,7 @@ Export colors, typography, icons, and images from Figma to Xcode, Android Studio **Flutter developer** — You need dark mode icon variants and `@2x`/`@3x` image scales. ExFig exports SVG icons with dark suffixes, raster images with scale directories, and Dart constants. -**Design Systems lead** — One Figma file feeds four platforms. ExFig's unified PKL config exports everything from a single `exfig batch` run. One CI pipeline, one source of truth. +**Design Systems lead** — One Figma or Penpot file feeds four platforms. ExFig's unified PKL config exports everything from a single `exfig batch` run. One CI pipeline, one source of truth. **CI/CD engineer** — Quiet mode, JSON reports, exit codes, version tracking, and checkpoint/resume. The [GitHub Action](https://github.com/DesignPipe/exfig-action) handles installation and caching. @@ -34,7 +35,7 @@ Export colors, typography, icons, and images from Figma to Xcode, Android Studio # 1. Install brew install designpipe/tap/exfig -# 2. Set Figma token +# 2. Set Figma token (or PENPOT_ACCESS_TOKEN for Penpot) export FIGMA_PERSONAL_TOKEN=your_token_here # 3a. Quick one-off export (interactive wizard) diff --git a/Sources/ExFigCLI/ExFig.docc/Configuration.md b/Sources/ExFigCLI/ExFig.docc/Configuration.md index 058f2f4e..069d52b8 100644 --- a/Sources/ExFigCLI/ExFig.docc/Configuration.md +++ b/Sources/ExFigCLI/ExFig.docc/Configuration.md @@ -103,6 +103,41 @@ common = new Common.CommonConfig { } ``` +### Penpot Source + +Use a Penpot project instead of Figma as the design source: + +```pkl +import ".exfig/schemas/Common.pkl" +import ".exfig/schemas/iOS.pkl" + +ios = new iOS.iOSConfig { + colors = new iOS.ColorsEntry { + // Load colors from a Penpot file + penpotSource = new Common.PenpotSource { + // Penpot file UUID + fileId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + + // Optional: custom Penpot instance URL (default: https://design.penpot.app/) + baseUrl = "https://penpot.mycompany.com/" + + // Optional: filter by path prefix + pathFilter = "Brand" + } + + assetsFolder = "Colors" + nameStyle = "camelCase" + } +} +``` + +> When `penpotSource` is set, `sourceKind` auto-detects as `"penpot"`. ExFig reads colors from +> the Penpot API and does not require `FIGMA_PERSONAL_TOKEN`. Set `PENPOT_ACCESS_TOKEN` instead. +> +> **v1 limitation:** Penpot icons/images are exported as raster thumbnails (the Penpot API has +> no public SVG/PNG render endpoint). SVG reconstruction from the shape tree is planned for a +> future version. + ### Tokens File Source Use a local W3C DTCG `.tokens.json` file instead of the Figma Variables API: diff --git a/Sources/ExFigCLI/ExFig.docc/DesignTokens.md b/Sources/ExFigCLI/ExFig.docc/DesignTokens.md index 3edafd38..b9afae8c 100644 --- a/Sources/ExFigCLI/ExFig.docc/DesignTokens.md +++ b/Sources/ExFigCLI/ExFig.docc/DesignTokens.md @@ -1,6 +1,6 @@ # Design Tokens -Export Figma design data as W3C Design Tokens for token pipelines and cross-tool interoperability. +Export design data from Figma or Penpot as W3C Design Tokens for token pipelines and cross-tool interoperability. ## Overview diff --git a/Sources/ExFigCLI/ExFig.docc/ExFig.md b/Sources/ExFigCLI/ExFig.docc/ExFig.md index b4836b46..1c000192 100644 --- a/Sources/ExFigCLI/ExFig.docc/ExFig.md +++ b/Sources/ExFigCLI/ExFig.docc/ExFig.md @@ -1,22 +1,23 @@ # ``ExFigCLI`` -Export colors, typography, icons, and images from Figma to iOS, Android, Flutter, and Web projects. +Export colors, typography, icons, and images from Figma and Penpot to iOS, Android, Flutter, and Web projects. ## Overview -ExFig is a command-line tool that automates design-to-code handoff. Point it at a Figma file, -and it generates platform-native resources: Color Sets for Xcode, XML resources for Android, -Dart constants for Flutter, and CSS variables for React — all from one source of truth. +ExFig is a command-line tool that automates design-to-code handoff. Point it at a Figma file +or a Penpot project, and it generates platform-native resources: Color Sets for Xcode, XML +resources for Android, Dart constants for Flutter, and CSS variables for React — all from one +source of truth. ExFig handles the details that make manual export painful: light/dark mode variants, @1x/@2x/@3x image scales, high contrast colors, RTL icon mirroring, and Dynamic Type mappings. A single `exfig batch` command replaces hours of copy-paste work across platforms. -It's built for teams that maintain a Figma-based design system and need a reliable, automated -pipeline to keep code in sync with design. ExFig works locally for quick exports and in CI/CD -for fully automated workflows. +It's built for teams that maintain a Figma or Penpot-based design system and need a reliable, +automated pipeline to keep code in sync with design. ExFig works locally for quick exports and +in CI/CD for fully automated workflows. -> Tip: ExFig also works with local `.tokens.json` files — no Figma API access needed. +> Tip: ExFig also works with local `.tokens.json` files and Penpot projects — no Figma API access needed. ### Supported Platforms @@ -30,7 +31,7 @@ for fully automated workflows. **Design Assets** Colors with light/dark/high-contrast variants, vector icons (PDF, SVG, VectorDrawable), raster images with multi-scale support, typography with Dynamic Type, RTL layout support, -and Figma Variables integration. +Figma Variables integration, and Penpot library colors/components/typography. **Export Formats** PNG, SVG, PDF, JPEG, WebP, HEIC output formats with quality control. @@ -53,8 +54,8 @@ customizable Jinja2 code templates, and rich progress indicators with ETA. Type-safe Swift/Kotlin/Dart/TypeScript extensions, pre-configured UILabel subclasses, Compose color and icon objects, and Flutter path constants. -> Important: Exporting icons and images requires a Figma Professional or Organization plan -> (uses Shareable Team Libraries). +> Important: Exporting icons and images from Figma requires a Professional or Organization plan +> (uses Shareable Team Libraries). Penpot has no plan restrictions for API access. ## Topics diff --git a/Sources/ExFigCLI/ExFig.docc/GettingStarted.md b/Sources/ExFigCLI/ExFig.docc/GettingStarted.md index 6f492bae..5b99237f 100644 --- a/Sources/ExFigCLI/ExFig.docc/GettingStarted.md +++ b/Sources/ExFigCLI/ExFig.docc/GettingStarted.md @@ -4,13 +4,13 @@ Install ExFig and configure your first export. ## Overview -ExFig is a command-line tool that exports design resources from Figma to iOS, Android, and Flutter projects. +ExFig is a command-line tool that exports design resources from Figma and Penpot to iOS, Android, Flutter, and Web projects. ## Requirements - macOS 13.0 or later (or Linux Ubuntu 22.04) -- Figma account with file access -- Figma Personal Access Token +- Figma account with file access, **or** Penpot account +- Figma Personal Access Token (for Figma sources) or Penpot Access Token (for Penpot sources) ## Installation @@ -45,7 +45,9 @@ cp .build/release/exfig /usr/local/bin/ Download the latest release from [GitHub Releases](https://github.com/DesignPipe/exfig/releases). -## Figma Access Token +## Authentication + +### Figma Access Token ExFig requires a Figma Personal Access Token to access the Figma API. @@ -72,6 +74,20 @@ Or pass it directly to commands: FIGMA_PERSONAL_TOKEN="your-token" exfig colors ``` +### Penpot Access Token + +For Penpot sources, set the `PENPOT_ACCESS_TOKEN` environment variable: + +1. Open your Penpot instance → Settings → Access Tokens +2. Create a new token +3. Set it: + +```bash +export PENPOT_ACCESS_TOKEN="your-penpot-token-here" +``` + +> Note: `PENPOT_ACCESS_TOKEN` is only required when using `sourceKind: "penpot"` or `penpotSource` in config. + ## Quick Start ### 1. Initialize Configuration diff --git a/Sources/ExFigCLI/ExFig.docc/Usage.md b/Sources/ExFigCLI/ExFig.docc/Usage.md index 115eb664..e92caacf 100644 --- a/Sources/ExFigCLI/ExFig.docc/Usage.md +++ b/Sources/ExFigCLI/ExFig.docc/Usage.md @@ -4,7 +4,7 @@ Command-line interface reference and common usage patterns. ## Overview -ExFig provides commands for exporting colors, icons, images, and typography from Figma to native platform resources. +ExFig provides commands for exporting colors, icons, images, and typography from Figma and Penpot to native platform resources. ## Basic Commands diff --git a/Sources/ExFigCLI/ExFig.docc/WhyExFig.md b/Sources/ExFigCLI/ExFig.docc/WhyExFig.md index bfbc29f2..621a29a9 100644 --- a/Sources/ExFigCLI/ExFig.docc/WhyExFig.md +++ b/Sources/ExFigCLI/ExFig.docc/WhyExFig.md @@ -4,8 +4,9 @@ Understand the problems ExFig solves and how it fits into your workflow. ## Overview -Design-to-code handoff is broken. Every team that ships a mobile or web app with a Figma-based -design system eventually hits the same pain points — and ExFig was built to eliminate them. +Design-to-code handoff is broken. Every team that ships a mobile or web app with a Figma or +Penpot-based design system eventually hits the same pain points — and ExFig was built to +eliminate them. ## The Problem @@ -53,9 +54,11 @@ scale directories, plus Dart constants for type-safe access. ### Design Systems Lead -You own one Figma file that feeds four platforms. ExFig's unified PKL config lets you define -the source once and export to iOS, Android, Flutter, and Web from a single `exfig batch` run. -When a designer publishes a library update, one CI pipeline updates everything. +You own one Figma file — or a Penpot project — that feeds four platforms. ExFig's unified +PKL config lets you define the source once and export to iOS, Android, Flutter, and Web from +a single `exfig batch` run. When a designer publishes a library update, one CI pipeline +updates everything. Switching from Figma to Penpot? Change the source in config, keep +everything else. ### CI/CD Engineer diff --git a/llms-full.txt b/llms-full.txt index f5c76c26..e744004f 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -12,11 +12,12 @@ # ExFig -Export colors, typography, icons, and images from Figma to Xcode, Android Studio, Flutter, and Web projects — automatically. +Export colors, typography, icons, and images from Figma and Penpot to Xcode, Android Studio, Flutter, and Web projects — automatically. ## The Problem - Figma has no "Export to Xcode" button. You copy hex codes by hand, one by one. +- Switching from Figma to Penpot? Your export pipeline shouldn't break. - Every color change means updating files across 3 platforms manually. - Dark mode variant? An afternoon spent on light/dark pairs and @1x/@2x/@3x PNGs. - Android gets XML. iOS gets xcassets. Flutter gets Dart. Someone maintains all three. @@ -30,7 +31,7 @@ Export colors, typography, icons, and images from Figma to Xcode, Android Studio **Flutter developer** — You need dark mode icon variants and `@2x`/`@3x` image scales. ExFig exports SVG icons with dark suffixes, raster images with scale directories, and Dart constants. -**Design Systems lead** — One Figma file feeds four platforms. ExFig's unified PKL config exports everything from a single `exfig batch` run. One CI pipeline, one source of truth. +**Design Systems lead** — One Figma or Penpot file feeds four platforms. ExFig's unified PKL config exports everything from a single `exfig batch` run. One CI pipeline, one source of truth. **CI/CD engineer** — Quiet mode, JSON reports, exit codes, version tracking, and checkpoint/resume. The [GitHub Action](https://github.com/DesignPipe/exfig-action) handles installation and caching. @@ -40,7 +41,7 @@ Export colors, typography, icons, and images from Figma to Xcode, Android Studio # 1. Install brew install designpipe/tap/exfig -# 2. Set Figma token +# 2. Set Figma token (or PENPOT_ACCESS_TOKEN for Penpot) export FIGMA_PERSONAL_TOKEN=your_token_here # 3a. Quick one-off export (interactive wizard) @@ -99,13 +100,13 @@ Install ExFig and configure your first export. ## Overview -ExFig is a command-line tool that exports design resources from Figma to iOS, Android, and Flutter projects. +ExFig is a command-line tool that exports design resources from Figma and Penpot to iOS, Android, Flutter, and Web projects. ## Requirements - macOS 13.0 or later (or Linux Ubuntu 22.04) -- Figma account with file access -- Figma Personal Access Token +- Figma account with file access, **or** Penpot account +- Figma Personal Access Token (for Figma sources) or Penpot Access Token (for Penpot sources) ## Installation @@ -140,7 +141,9 @@ cp .build/release/exfig /usr/local/bin/ Download the latest release from [GitHub Releases](https://github.com/DesignPipe/exfig/releases). -## Figma Access Token +## Authentication + +### Figma Access Token ExFig requires a Figma Personal Access Token to access the Figma API. @@ -167,6 +170,20 @@ Or pass it directly to commands: FIGMA_PERSONAL_TOKEN="your-token" exfig colors ``` +### Penpot Access Token + +For Penpot sources, set the `PENPOT_ACCESS_TOKEN` environment variable: + +1. Open your Penpot instance → Settings → Access Tokens +2. Create a new token +3. Set it: + +```bash +export PENPOT_ACCESS_TOKEN="your-penpot-token-here" +``` + +> Note: `PENPOT_ACCESS_TOKEN` is only required when using `sourceKind: "penpot"` or `penpotSource` in config. + ## Quick Start ### 1. Initialize Configuration @@ -250,7 +267,7 @@ Command-line interface reference and common usage patterns. ## Overview -ExFig provides commands for exporting colors, icons, images, and typography from Figma to native platform resources. +ExFig provides commands for exporting colors, icons, images, and typography from Figma and Penpot to native platform resources. ## Basic Commands @@ -608,6 +625,41 @@ common = new Common.CommonConfig { } ``` +### Penpot Source + +Use a Penpot project instead of Figma as the design source: + +```pkl +import ".exfig/schemas/Common.pkl" +import ".exfig/schemas/iOS.pkl" + +ios = new iOS.iOSConfig { + colors = new iOS.ColorsEntry { + // Load colors from a Penpot file + penpotSource = new Common.PenpotSource { + // Penpot file UUID + fileId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + + // Optional: custom Penpot instance URL (default: https://design.penpot.app/) + baseUrl = "https://penpot.mycompany.com/" + + // Optional: filter by path prefix + pathFilter = "Brand" + } + + assetsFolder = "Colors" + nameStyle = "camelCase" + } +} +``` + +> When `penpotSource` is set, `sourceKind` auto-detects as `"penpot"`. ExFig reads colors from +> the Penpot API and does not require `FIGMA_PERSONAL_TOKEN`. Set `PENPOT_ACCESS_TOKEN` instead. +> +> **v1 limitation:** Penpot icons/images are exported as raster thumbnails (the Penpot API has +> no public SVG/PNG render endpoint). SVG reconstruction from the shape tree is planned for a +> future version. + ### Tokens File Source Use a local W3C DTCG `.tokens.json` file instead of the Figma Variables API: From ad88f064bae003c78f285df734c4b0aac9d864d9 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sat, 21 Mar 2026 22:16:18 +0500 Subject: [PATCH 05/26] fix(penpot): resolve URL construction, base URL, error handling, and type safety issues - Fix operator precedence bug in PenpotComponentsSource URL construction - Use configurable base URL from penpotSource instead of hardcoded design.penpot.app - Add resolvedFileId/resolvedPenpotBaseURL computed properties to Common_FrameSource - Add penpotBaseURL field to IconsSourceInput, ImagesSourceInput, TypographySourceInput - Replace JSONSerialization with Codable+CodingKeys+YYJSONEncoder in thumbnails endpoint - Validate fileId before API calls instead of falling back to empty string - Add ColorsConfigError.missingPenpotSource for correct error messaging - Return nil from hexToRGBA on invalid hex with warning instead of silent black - Handle CancellationError in retry loop to respect cooperative cancellation - Include response body in download error messages for diagnostics - Add preconditions for maxRetries and accessToken in BasePenpotClient - Change PenpotTypography numeric fields from var to let (immutable) --- CLAUDE.md | 4 +++ .../Config/AndroidIconsEntry.swift | 5 ++-- .../Config/AndroidImagesEntry.swift | 5 ++-- .../Config/FlutterIconsEntry.swift | 5 ++-- .../Config/FlutterImagesEntry.swift | 10 ++++--- Sources/ExFig-Web/Config/WebIconsEntry.swift | 5 ++-- Sources/ExFig-Web/Config/WebImagesEntry.swift | 5 ++-- Sources/ExFig-iOS/Config/iOSIconsEntry.swift | 5 ++-- Sources/ExFig-iOS/Config/iOSImagesEntry.swift | 5 ++-- .../ExFigCLI/Source/PenpotColorsSource.swift | 13 +++++--- .../Source/PenpotComponentsSource.swift | 30 ++++++++++++------- .../Source/PenpotTypographySource.swift | 5 ++-- Sources/ExFigConfig/CLAUDE.md | 2 ++ Sources/ExFigConfig/SourceKindBridging.swift | 10 +++++++ .../VariablesSourceValidation.swift | 2 +- Sources/ExFigCore/CLAUDE.md | 1 + .../ExFigCore/Protocol/ExportContext.swift | 5 ++++ .../Protocol/IconsExportContext.swift | 7 ++++- .../Protocol/ImagesExportContext.swift | 7 ++++- .../Protocol/TypographyExportContext.swift | 7 ++++- Sources/PenpotAPI/CLAUDE.md | 8 +++++ Sources/PenpotAPI/Client/PenpotClient.swift | 10 ++++++- .../GetFileObjectThumbnailsEndpoint.swift | 18 +++++++---- .../PenpotAPI/Models/PenpotTypography.swift | 8 ++--- 24 files changed, 132 insertions(+), 50 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index add8bf49..d8797c8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -303,6 +303,9 @@ Entry bridge methods (`iconsSourceInput()`, `imagesSourceInput()`) use `resolved instead of `sourceKind?.coreSourceKind ?? .figma`. This auto-detects Penpot when `penpotSource` is set. `Common_VariablesSource` has its own `resolvedSourceKind` in `VariablesSourceValidation.swift` (includes tokensFile + penpot detection). +Entry bridge methods also use `resolvedFileId` (`penpotSource?.fileId ?? figmaFileId`) and `resolvedPenpotBaseURL` +(`penpotSource?.baseUrl`) from `SourceKindBridging.swift` to pass source-specific values through flat SourceInput fields. + ### Adding a Platform Plugin Exporter See `ExFigCore/CLAUDE.md` (Modification Checklist) and platform module CLAUDE.md files. @@ -427,6 +430,7 @@ NooraUI.formatLink("url", useColors: true) // underlined primary | 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 | +| `ColorsConfigError` new case | Has TWO switch blocks (`errorDescription` + `recoverySuggestion`) — adding a case to one without the other causes exhaustive switch error | | 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") | | `JSONCodec` in standalone module | `JSONCodec` lives in ExFigCore — standalone modules (PenpotAPI) use `YYJSONEncoder()`/`YYJSONDecoder()` from YYJSON directly | diff --git a/Sources/ExFig-Android/Config/AndroidIconsEntry.swift b/Sources/ExFig-Android/Config/AndroidIconsEntry.swift index ad2364e3..577638ae 100644 --- a/Sources/ExFig-Android/Config/AndroidIconsEntry.swift +++ b/Sources/ExFig-Android/Config/AndroidIconsEntry.swift @@ -15,7 +15,7 @@ public extension Android.IconsEntry { func iconsSourceInput(darkFileId: String? = nil) -> IconsSourceInput { IconsSourceInput( sourceKind: resolvedSourceKind, - figmaFileId: figmaFileId, + figmaFileId: resolvedFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Icons", pageName: figmaPageName, @@ -24,7 +24,8 @@ public extension Android.IconsEntry { darkModeSuffix: "_dark", rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, - nameReplaceRegexp: nameReplaceRegexp + nameReplaceRegexp: nameReplaceRegexp, + penpotBaseURL: resolvedPenpotBaseURL ) } diff --git a/Sources/ExFig-Android/Config/AndroidImagesEntry.swift b/Sources/ExFig-Android/Config/AndroidImagesEntry.swift index 4e4e6020..12a06347 100644 --- a/Sources/ExFig-Android/Config/AndroidImagesEntry.swift +++ b/Sources/ExFig-Android/Config/AndroidImagesEntry.swift @@ -32,7 +32,7 @@ public extension Android.ImagesEntry { func imagesSourceInput(darkFileId: String? = nil) -> ImagesSourceInput { ImagesSourceInput( sourceKind: resolvedSourceKind, - figmaFileId: figmaFileId, + figmaFileId: resolvedFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Images", pageName: figmaPageName, @@ -42,7 +42,8 @@ public extension Android.ImagesEntry { darkModeSuffix: "_dark", rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, - nameReplaceRegexp: nameReplaceRegexp + nameReplaceRegexp: nameReplaceRegexp, + penpotBaseURL: resolvedPenpotBaseURL ) } diff --git a/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift b/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift index 913ec5d3..3c78d853 100644 --- a/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift +++ b/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift @@ -12,7 +12,7 @@ public extension Flutter.IconsEntry { func iconsSourceInput(darkFileId: String? = nil) -> IconsSourceInput { IconsSourceInput( sourceKind: resolvedSourceKind, - figmaFileId: figmaFileId, + figmaFileId: resolvedFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Icons", pageName: figmaPageName, @@ -20,7 +20,8 @@ public extension Flutter.IconsEntry { darkModeSuffix: "_dark", rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, - nameReplaceRegexp: nameReplaceRegexp + nameReplaceRegexp: nameReplaceRegexp, + penpotBaseURL: resolvedPenpotBaseURL ) } diff --git a/Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift b/Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift index 50fc0fd0..c5912a87 100644 --- a/Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift +++ b/Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift @@ -15,7 +15,7 @@ public extension Flutter.ImagesEntry { func imagesSourceInput(darkFileId: String? = nil) -> ImagesSourceInput { ImagesSourceInput( sourceKind: resolvedSourceKind, - figmaFileId: figmaFileId, + figmaFileId: resolvedFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Images", pageName: figmaPageName, @@ -25,7 +25,8 @@ public extension Flutter.ImagesEntry { darkModeSuffix: "_dark", rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, - nameReplaceRegexp: nameReplaceRegexp + nameReplaceRegexp: nameReplaceRegexp, + penpotBaseURL: resolvedPenpotBaseURL ) } @@ -45,7 +46,7 @@ public extension Flutter.ImagesEntry { func svgSourceInput(darkFileId: String? = nil) -> ImagesSourceInput { ImagesSourceInput( sourceKind: resolvedSourceKind, - figmaFileId: figmaFileId, + figmaFileId: resolvedFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Images", pageName: figmaPageName, @@ -55,7 +56,8 @@ public extension Flutter.ImagesEntry { darkModeSuffix: "_dark", rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, - nameReplaceRegexp: nameReplaceRegexp + nameReplaceRegexp: nameReplaceRegexp, + penpotBaseURL: resolvedPenpotBaseURL ) } diff --git a/Sources/ExFig-Web/Config/WebIconsEntry.swift b/Sources/ExFig-Web/Config/WebIconsEntry.swift index 4a085c16..02cbd0f2 100644 --- a/Sources/ExFig-Web/Config/WebIconsEntry.swift +++ b/Sources/ExFig-Web/Config/WebIconsEntry.swift @@ -12,7 +12,7 @@ public extension Web.IconsEntry { func iconsSourceInput(darkFileId: String? = nil) -> IconsSourceInput { IconsSourceInput( sourceKind: resolvedSourceKind, - figmaFileId: figmaFileId, + figmaFileId: resolvedFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Icons", pageName: figmaPageName, @@ -20,7 +20,8 @@ public extension Web.IconsEntry { darkModeSuffix: "_dark", rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, - nameReplaceRegexp: nameReplaceRegexp + nameReplaceRegexp: nameReplaceRegexp, + penpotBaseURL: resolvedPenpotBaseURL ) } diff --git a/Sources/ExFig-Web/Config/WebImagesEntry.swift b/Sources/ExFig-Web/Config/WebImagesEntry.swift index 58dd32fe..d2f2dc1b 100644 --- a/Sources/ExFig-Web/Config/WebImagesEntry.swift +++ b/Sources/ExFig-Web/Config/WebImagesEntry.swift @@ -12,7 +12,7 @@ public extension Web.ImagesEntry { func imagesSourceInput(darkFileId: String? = nil) -> ImagesSourceInput { ImagesSourceInput( sourceKind: resolvedSourceKind, - figmaFileId: figmaFileId, + figmaFileId: resolvedFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Images", pageName: figmaPageName, @@ -22,7 +22,8 @@ public extension Web.ImagesEntry { darkModeSuffix: "_dark", rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, - nameReplaceRegexp: nameReplaceRegexp + nameReplaceRegexp: nameReplaceRegexp, + penpotBaseURL: resolvedPenpotBaseURL ) } diff --git a/Sources/ExFig-iOS/Config/iOSIconsEntry.swift b/Sources/ExFig-iOS/Config/iOSIconsEntry.swift index bc75ddc6..a7267494 100644 --- a/Sources/ExFig-iOS/Config/iOSIconsEntry.swift +++ b/Sources/ExFig-iOS/Config/iOSIconsEntry.swift @@ -14,7 +14,7 @@ public extension iOS.IconsEntry { func iconsSourceInput(darkFileId: String? = nil) -> IconsSourceInput { IconsSourceInput( sourceKind: resolvedSourceKind, - figmaFileId: figmaFileId, + figmaFileId: resolvedFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Icons", pageName: figmaPageName, @@ -27,7 +27,8 @@ public extension iOS.IconsEntry { renderModeTemplateSuffix: renderModeTemplateSuffix, rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, - nameReplaceRegexp: nameReplaceRegexp + nameReplaceRegexp: nameReplaceRegexp, + penpotBaseURL: resolvedPenpotBaseURL ) } diff --git a/Sources/ExFig-iOS/Config/iOSImagesEntry.swift b/Sources/ExFig-iOS/Config/iOSImagesEntry.swift index 4da0ed3a..0ca2f263 100644 --- a/Sources/ExFig-iOS/Config/iOSImagesEntry.swift +++ b/Sources/ExFig-iOS/Config/iOSImagesEntry.swift @@ -14,7 +14,7 @@ public extension iOS.ImagesEntry { func imagesSourceInput(darkFileId: String? = nil) -> ImagesSourceInput { ImagesSourceInput( sourceKind: resolvedSourceKind, - figmaFileId: figmaFileId, + figmaFileId: resolvedFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Images", pageName: figmaPageName, @@ -24,7 +24,8 @@ public extension iOS.ImagesEntry { darkModeSuffix: "_dark", rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, - nameReplaceRegexp: nameReplaceRegexp + nameReplaceRegexp: nameReplaceRegexp, + penpotBaseURL: resolvedPenpotBaseURL ) } diff --git a/Sources/ExFigCLI/Source/PenpotColorsSource.swift b/Sources/ExFigCLI/Source/PenpotColorsSource.swift index 33200d73..73db790a 100644 --- a/Sources/ExFigCLI/Source/PenpotColorsSource.swift +++ b/Sources/ExFigCLI/Source/PenpotColorsSource.swift @@ -32,7 +32,10 @@ struct PenpotColorsSource: ColorsSource { } } - let rgba = Self.hexToRGBA(hex: hex, opacity: penpotColor.opacity ?? 1.0) + guard let rgba = Self.hexToRGBA(hex: hex, opacity: penpotColor.opacity ?? 1.0) else { + ui.warning("Color '\(penpotColor.name)' has invalid hex value '\(hex)' — skipping") + continue + } let name = if let path = penpotColor.path { path + "/" + penpotColor.name @@ -54,7 +57,7 @@ struct PenpotColorsSource: ColorsSource { return ColorsLoadOutput(light: colors) } - // MARK: - Private + // MARK: - Internal static func makeClient(baseURL: String) throws -> BasePenpotClient { guard let token = ProcessInfo.processInfo.environment["PENPOT_ACCESS_TOKEN"], !token.isEmpty else { @@ -65,14 +68,16 @@ struct PenpotColorsSource: ColorsSource { return BasePenpotClient(accessToken: token, baseURL: baseURL) } - static func hexToRGBA(hex: String, opacity: Double) -> (red: Double, green: Double, blue: Double, alpha: Double) { + static func hexToRGBA(hex: String, opacity: Double) + -> (red: Double, green: Double, blue: Double, alpha: Double)? + { var hexString = hex.trimmingCharacters(in: .whitespacesAndNewlines) if hexString.hasPrefix("#") { hexString.removeFirst() } guard hexString.count == 6, let hexValue = UInt64(hexString, radix: 16) else { - return (red: 0, green: 0, blue: 0, alpha: opacity) + return nil } let red = Double((hexValue >> 16) & 0xFF) / 255.0 diff --git a/Sources/ExFigCLI/Source/PenpotComponentsSource.swift b/Sources/ExFigCLI/Source/PenpotComponentsSource.swift index a8d24786..533c14b5 100644 --- a/Sources/ExFigCLI/Source/PenpotComponentsSource.swift +++ b/Sources/ExFigCLI/Source/PenpotComponentsSource.swift @@ -15,7 +15,8 @@ struct PenpotComponentsSource: ComponentsSource { } let packs = try await loadComponents( - fileId: input.figmaFileId ?? "", + fileId: input.figmaFileId, + baseURL: input.penpotBaseURL, pathFilter: input.frameName, sourceKind: input.sourceKind ) @@ -25,7 +26,8 @@ struct PenpotComponentsSource: ComponentsSource { func loadImages(from input: ImagesSourceInput) async throws -> ImagesLoadOutput { let packs = try await loadComponents( - fileId: input.figmaFileId ?? "", + fileId: input.figmaFileId, + baseURL: input.penpotBaseURL, pathFilter: input.frameName, sourceKind: input.sourceKind ) @@ -36,13 +38,19 @@ struct PenpotComponentsSource: ComponentsSource { // MARK: - Private private func loadComponents( - fileId: String, + fileId: String?, + baseURL: String?, pathFilter: String, sourceKind: DesignSourceKind ) async throws -> [ImagePack] { - let client = try PenpotColorsSource.makeClient( - baseURL: BasePenpotClient.defaultBaseURL - ) + guard let fileId, !fileId.isEmpty else { + throw ExFigError.configurationError( + "Penpot file ID is required for components export — set penpotSource.fileId in your config" + ) + } + + let effectiveBaseURL = baseURL ?? BasePenpotClient.defaultBaseURL + let client = try PenpotColorsSource.makeClient(baseURL: effectiveBaseURL) let fileResponse = try await client.request(GetFileEndpoint(fileId: fileId)) @@ -75,11 +83,13 @@ struct PenpotComponentsSource: ComponentsSource { } // Build download URL for the thumbnail - let downloadPath = thumbnailRef.hasPrefix("http") ? thumbnailRef : "assets/by-file-media-id/\(thumbnailRef)" + let fullURL: String = if thumbnailRef.hasPrefix("http") { + thumbnailRef + } else { + "\(effectiveBaseURL)assets/by-file-media-id/\(thumbnailRef)" + } - guard let url = URL(string: downloadPath - .hasPrefix("http") ? downloadPath : "https://design.penpot.app/\(downloadPath)") - else { + guard let url = URL(string: fullURL) else { ui.warning("Component '\(component.name)' has invalid thumbnail URL — skipping") continue } diff --git a/Sources/ExFigCLI/Source/PenpotTypographySource.swift b/Sources/ExFigCLI/Source/PenpotTypographySource.swift index 4cbfd9b3..cae5920f 100644 --- a/Sources/ExFigCLI/Source/PenpotTypographySource.swift +++ b/Sources/ExFigCLI/Source/PenpotTypographySource.swift @@ -6,9 +6,8 @@ struct PenpotTypographySource: TypographySource { let ui: TerminalUI func loadTypography(from input: TypographySourceInput) async throws -> TypographyLoadOutput { - let client = try PenpotColorsSource.makeClient( - baseURL: BasePenpotClient.defaultBaseURL - ) + let effectiveBaseURL = input.penpotBaseURL ?? BasePenpotClient.defaultBaseURL + let client = try PenpotColorsSource.makeClient(baseURL: effectiveBaseURL) let fileResponse = try await client.request(GetFileEndpoint(fileId: input.fileId)) diff --git a/Sources/ExFigConfig/CLAUDE.md b/Sources/ExFigConfig/CLAUDE.md index 79545808..5f8d00cd 100644 --- a/Sources/ExFigConfig/CLAUDE.md +++ b/Sources/ExFigConfig/CLAUDE.md @@ -57,6 +57,8 @@ ExFigCore domain types (NameStyle, ColorsSourceInput, etc.) | `Common_VariablesSource.resolvedSourceKind` | Resolution priority: explicit `sourceKind` > auto-detect (penpotSource > tokensFile) > default `.figma` | | `Common_VariablesSource.validatedColorsSourceInput()` | Validates required fields, returns `ColorsSourceInput`. Uses `resolvedSourceKind` for dispatch | | `Common_FrameSource.resolvedSourceKind` | Resolution priority: explicit `sourceKind` > auto-detect (penpotSource presence) > default `.figma`. Defined in `SourceKindBridging.swift` | +| `Common_FrameSource.resolvedFileId` | `penpotSource?.fileId ?? figmaFileId` — auto-resolves file ID for any source. Defined in `SourceKindBridging.swift` | +| `Common_FrameSource.resolvedPenpotBaseURL` | `penpotSource?.baseUrl` — passes Penpot base URL through entry bridges. Defined in `SourceKindBridging.swift` | ### PklError Workaround diff --git a/Sources/ExFigConfig/SourceKindBridging.swift b/Sources/ExFigConfig/SourceKindBridging.swift index 6c60c043..7ed60172 100644 --- a/Sources/ExFigConfig/SourceKindBridging.swift +++ b/Sources/ExFigConfig/SourceKindBridging.swift @@ -29,4 +29,14 @@ public extension Common_FrameSource { } return .figma } + + /// Resolves the file ID: Penpot source takes priority, then Figma file ID. + var resolvedFileId: String? { + penpotSource?.fileId ?? figmaFileId + } + + /// Resolves the Penpot base URL from penpotSource config. + var resolvedPenpotBaseURL: String? { + penpotSource?.baseUrl + } } diff --git a/Sources/ExFigConfig/VariablesSourceValidation.swift b/Sources/ExFigConfig/VariablesSourceValidation.swift index 422514aa..e3f41946 100644 --- a/Sources/ExFigConfig/VariablesSourceValidation.swift +++ b/Sources/ExFigConfig/VariablesSourceValidation.swift @@ -37,7 +37,7 @@ public extension Common_VariablesSource { private extension Common_VariablesSource { func penpotColorsSourceInput() throws -> ColorsSourceInput { guard let penpotSource else { - throw ColorsConfigError.missingTokensFileId + throw ColorsConfigError.missingPenpotSource } let config = PenpotColorsConfig( fileId: penpotSource.fileId, diff --git a/Sources/ExFigCore/CLAUDE.md b/Sources/ExFigCore/CLAUDE.md index f82c1e01..120ae25a 100644 --- a/Sources/ExFigCore/CLAUDE.md +++ b/Sources/ExFigCore/CLAUDE.md @@ -34,6 +34,7 @@ Exporter.export*(entries, platformConfig, context) - `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`) +- `IconsSourceInput`, `ImagesSourceInput`, `TypographySourceInput` have `penpotBaseURL: String?` field for Penpot base URL - When adding a new `ColorsSourceConfig` subtype: update `spinnerLabel` switch in `ExportContext.swift` Implementations live in `Sources/ExFigCLI/Source/` — `FigmaColorsSource`, `TokensFileColorsSource`, `PenpotColorsSource`, `PenpotComponentsSource`, `PenpotTypographySource`, `FigmaComponentsSource`, `FigmaTypographySource`, `SourceFactory`. diff --git a/Sources/ExFigCore/Protocol/ExportContext.swift b/Sources/ExFigCore/Protocol/ExportContext.swift index f281d17e..74b7f252 100644 --- a/Sources/ExFigCore/Protocol/ExportContext.swift +++ b/Sources/ExFigCore/Protocol/ExportContext.swift @@ -144,6 +144,7 @@ public enum ColorsConfigError: LocalizedError { case missingTokensFileId case missingTokensCollectionName case missingLightModeName + case missingPenpotSource public var errorDescription: String? { switch self { @@ -153,6 +154,8 @@ public enum ColorsConfigError: LocalizedError { "tokensCollectionName is required for colors export" case .missingLightModeName: "lightModeName is required for colors export" + case .missingPenpotSource: + "penpotSource configuration is required when sourceKind is 'penpot'" } } @@ -164,6 +167,8 @@ public enum ColorsConfigError: LocalizedError { "Add 'tokensCollectionName' to your colors entry, or set common.variablesColors" case .missingLightModeName: "Add 'lightModeName' to your colors entry, or set common.variablesColors" + case .missingPenpotSource: + "Add 'penpotSource { fileId = \"...\" }' to your colors entry" } } } diff --git a/Sources/ExFigCore/Protocol/IconsExportContext.swift b/Sources/ExFigCore/Protocol/IconsExportContext.swift index 7bf81642..b080c7f3 100644 --- a/Sources/ExFigCore/Protocol/IconsExportContext.swift +++ b/Sources/ExFigCore/Protocol/IconsExportContext.swift @@ -100,6 +100,9 @@ public struct IconsSourceInput: Sendable { /// Name replacement regex. public let nameReplaceRegexp: String? + /// Penpot instance base URL (used when sourceKind == .penpot). + public let penpotBaseURL: String? + public init( sourceKind: DesignSourceKind = .figma, figmaFileId: String? = nil, @@ -115,7 +118,8 @@ public struct IconsSourceInput: Sendable { renderModeTemplateSuffix: String? = nil, rtlProperty: String? = "RTL", nameValidateRegexp: String? = nil, - nameReplaceRegexp: String? = nil + nameReplaceRegexp: String? = nil, + penpotBaseURL: String? = nil ) { self.sourceKind = sourceKind self.figmaFileId = figmaFileId @@ -132,6 +136,7 @@ public struct IconsSourceInput: Sendable { self.rtlProperty = rtlProperty self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp + self.penpotBaseURL = penpotBaseURL } } diff --git a/Sources/ExFigCore/Protocol/ImagesExportContext.swift b/Sources/ExFigCore/Protocol/ImagesExportContext.swift index b245667c..9e73bd40 100644 --- a/Sources/ExFigCore/Protocol/ImagesExportContext.swift +++ b/Sources/ExFigCore/Protocol/ImagesExportContext.swift @@ -199,6 +199,9 @@ public struct ImagesSourceInput: Sendable { /// Name replacement regex. public let nameReplaceRegexp: String? + /// Penpot instance base URL (used when sourceKind == .penpot). + public let penpotBaseURL: String? + public init( sourceKind: DesignSourceKind = .figma, figmaFileId: String? = nil, @@ -211,7 +214,8 @@ public struct ImagesSourceInput: Sendable { darkModeSuffix: String = "_dark", rtlProperty: String? = "RTL", nameValidateRegexp: String? = nil, - nameReplaceRegexp: String? = nil + nameReplaceRegexp: String? = nil, + penpotBaseURL: String? = nil ) { self.sourceKind = sourceKind self.figmaFileId = figmaFileId @@ -225,6 +229,7 @@ public struct ImagesSourceInput: Sendable { self.rtlProperty = rtlProperty self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp + self.penpotBaseURL = penpotBaseURL } } diff --git a/Sources/ExFigCore/Protocol/TypographyExportContext.swift b/Sources/ExFigCore/Protocol/TypographyExportContext.swift index c7771d90..a470e529 100644 --- a/Sources/ExFigCore/Protocol/TypographyExportContext.swift +++ b/Sources/ExFigCore/Protocol/TypographyExportContext.swift @@ -45,14 +45,19 @@ public struct TypographySourceInput: Sendable { /// Optional timeout for Figma API requests. public let timeout: TimeInterval? + /// Penpot instance base URL (used when sourceKind == .penpot). + public let penpotBaseURL: String? + public init( sourceKind: DesignSourceKind = .figma, fileId: String, - timeout: TimeInterval? = nil + timeout: TimeInterval? = nil, + penpotBaseURL: String? = nil ) { self.sourceKind = sourceKind self.fileId = fileId self.timeout = timeout + self.penpotBaseURL = penpotBaseURL } } diff --git a/Sources/PenpotAPI/CLAUDE.md b/Sources/PenpotAPI/CLAUDE.md index 53470cd8..83c3d174 100644 --- a/Sources/PenpotAPI/CLAUDE.md +++ b/Sources/PenpotAPI/CLAUDE.md @@ -17,3 +17,11 @@ Only external dependency: swift-yyjson for JSON parsing. - `Authorization: Token ` header - Simple retry (3 attempts, exponential backoff) for 429/5xx - Typography numeric fields may be String OR Number — custom init(from:) handles both + +## Conventions + +- All model fields are `let` (immutable) — no post-construction mutation needed +- Kebab-case request keys: use `Codable` struct with `CodingKeys` + `YYJSONEncoder`, NOT `JSONSerialization` +- `BasePenpotClient` validates `maxRetries >= 1` and `!accessToken.isEmpty` via preconditions +- Retry loop respects `CancellationError` — rethrows immediately instead of retrying +- `download()` includes response body in error message for diagnostics diff --git a/Sources/PenpotAPI/Client/PenpotClient.swift b/Sources/PenpotAPI/Client/PenpotClient.swift index 678a465b..7fcf3fc9 100644 --- a/Sources/PenpotAPI/Client/PenpotClient.swift +++ b/Sources/PenpotAPI/Client/PenpotClient.swift @@ -29,6 +29,9 @@ public struct BasePenpotClient: PenpotClient { timeout: TimeInterval = 60, maxRetries: Int = 3 ) { + precondition(maxRetries >= 1, "maxRetries must be at least 1") + precondition(!accessToken.isEmpty, "accessToken must not be empty") + self.accessToken = accessToken self.baseURL = baseURL.hasSuffix("/") ? baseURL : baseURL + "/" self.maxRetries = maxRetries @@ -83,7 +86,8 @@ public struct BasePenpotClient: PenpotClient { (200 ..< 300).contains(httpResponse.statusCode) else { let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 - throw PenpotAPIError(statusCode: statusCode, message: "Download failed", endpoint: "download") + let message = String(data: data, encoding: .utf8) ?? "Download failed" + throw PenpotAPIError(statusCode: statusCode, message: message, endpoint: "download") } return data @@ -110,6 +114,8 @@ public struct BasePenpotClient: PenpotClient { for attempt in 0 ..< maxRetries { do { + try Task.checkCancellation() + let (data, response) = try await session.data(for: request) if let httpResponse = response as? HTTPURLResponse { @@ -132,6 +138,8 @@ public struct BasePenpotClient: PenpotClient { } return (data, response) + } catch is CancellationError { + throw CancellationError() } catch let error as PenpotAPIError { throw error } catch { diff --git a/Sources/PenpotAPI/Endpoints/GetFileObjectThumbnailsEndpoint.swift b/Sources/PenpotAPI/Endpoints/GetFileObjectThumbnailsEndpoint.swift index 5787cac2..464bac6d 100644 --- a/Sources/PenpotAPI/Endpoints/GetFileObjectThumbnailsEndpoint.swift +++ b/Sources/PenpotAPI/Endpoints/GetFileObjectThumbnailsEndpoint.swift @@ -18,13 +18,19 @@ public struct GetFileObjectThumbnailsEndpoint: PenpotEndpoint { self.objectIds = objectIds } + /// Penpot RPC uses kebab-case keys — CodingKeys map camelCase to kebab-case. + private struct Body: Encodable { + let fileId: String + let objectIds: [String] + + enum CodingKeys: String, CodingKey { + case fileId = "file-id" + case objectIds = "object-ids" + } + } + public func body() throws -> Data? { - // Penpot RPC uses kebab-case keys in request bodies - let bodyDict: [String: Any] = [ - "file-id": fileId, - "object-ids": objectIds, - ] - return try JSONSerialization.data(withJSONObject: bodyDict) + try YYJSONEncoder().encode(Body(fileId: fileId, objectIds: objectIds)) } public func content(from data: Data) throws -> [String: String] { diff --git a/Sources/PenpotAPI/Models/PenpotTypography.swift b/Sources/PenpotAPI/Models/PenpotTypography.swift index e88e0a02..ee8da7f7 100644 --- a/Sources/PenpotAPI/Models/PenpotTypography.swift +++ b/Sources/PenpotAPI/Models/PenpotTypography.swift @@ -25,16 +25,16 @@ public struct PenpotTypography: Sendable { public let textTransform: String? /// Font size in points. - public var fontSize: Double? + public let fontSize: Double? /// Font weight (e.g., 400, 700). - public var fontWeight: Double? + public let fontWeight: Double? /// Line height multiplier. - public var lineHeight: Double? + public let lineHeight: Double? /// Letter spacing in em. - public var letterSpacing: Double? + public let letterSpacing: Double? public init( id: String, From a7ac06bad7bd6323bc834e214b35f46efd467554 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sat, 21 Mar 2026 22:35:58 +0500 Subject: [PATCH 06/26] test(penpot): add tests for resolvedSourceKind, hexToRGBA, SourceFactory dispatch, and fileId validation - Add FrameSourceResolvedSourceKindTests: auto-detect penpot, explicit override, resolvedFileId priority, resolvedPenpotBaseURL - Add PenpotColorsSourceInputTests: auto-detected penpot config, missingPenpotSource error - Add HexToRGBATests: valid/invalid hex, opacity, whitespace, lowercase - Add SourceFactoryPenpotTests: .penpot dispatch for all 3 source types + unsupported throws - Add PenpotComponentsSourceValidationTests: nil/empty fileId throws descriptive error - Extract Penpot tests from DesignSourceTests.swift to stay under file_length limit --- .../Input/PenpotDesignSourceTests.swift | 281 ++++++++++++++++++ .../ExFigTests/Source/PenpotSourceTests.swift | 225 ++++++++++++++ 2 files changed, 506 insertions(+) create mode 100644 Tests/ExFigTests/Input/PenpotDesignSourceTests.swift create mode 100644 Tests/ExFigTests/Source/PenpotSourceTests.swift diff --git a/Tests/ExFigTests/Input/PenpotDesignSourceTests.swift b/Tests/ExFigTests/Input/PenpotDesignSourceTests.swift new file mode 100644 index 00000000..5a2e0081 --- /dev/null +++ b/Tests/ExFigTests/Input/PenpotDesignSourceTests.swift @@ -0,0 +1,281 @@ +import ExFig_iOS +@testable import ExFigCLI +import ExFigConfig +import ExFigCore +import XCTest + +// MARK: - FrameSource resolvedSourceKind Tests + +final class FrameSourceResolvedSourceKindTests: XCTestCase { + func testDefaultsToFigmaWhenNoPenpotSource() { + let entry = iOS.IconsEntry( + format: .svg, + assetsFolder: "Icons", + preservesVectorRepresentation: nil, + nameStyle: .camelCase, + imageSwift: nil, + swiftUIImageSwift: nil, + codeConnectSwift: nil, + xcassetsPath: nil, + templatesPath: nil, + renderMode: nil, + renderModeDefaultSuffix: nil, + renderModeOriginalSuffix: nil, + renderModeTemplateSuffix: nil, + sourceKind: nil, + penpotSource: nil, + figmaFrameName: nil, + figmaPageName: nil, + figmaFileId: "figma-file-id", + rtlProperty: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + XCTAssertEqual(entry.resolvedSourceKind, .figma) + } + + func testAutoDetectsPenpotFromPenpotSource() { + let entry = iOS.IconsEntry( + format: .svg, + assetsFolder: "Icons", + preservesVectorRepresentation: nil, + nameStyle: .camelCase, + imageSwift: nil, + swiftUIImageSwift: nil, + codeConnectSwift: nil, + xcassetsPath: nil, + templatesPath: nil, + renderMode: nil, + renderModeDefaultSuffix: nil, + renderModeOriginalSuffix: nil, + renderModeTemplateSuffix: nil, + sourceKind: nil, + penpotSource: Common.PenpotSource( + fileId: "penpot-uuid", baseUrl: "https://penpot.example.com/", pathFilter: nil + ), + figmaFrameName: nil, + figmaPageName: nil, + figmaFileId: nil, + rtlProperty: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + XCTAssertEqual(entry.resolvedSourceKind, .penpot) + } + + func testExplicitSourceKindOverridesPenpotAutoDetect() { + let entry = iOS.IconsEntry( + format: .svg, + assetsFolder: "Icons", + preservesVectorRepresentation: nil, + nameStyle: .camelCase, + imageSwift: nil, + swiftUIImageSwift: nil, + codeConnectSwift: nil, + xcassetsPath: nil, + templatesPath: nil, + renderMode: nil, + renderModeDefaultSuffix: nil, + renderModeOriginalSuffix: nil, + renderModeTemplateSuffix: nil, + sourceKind: .figma, + penpotSource: Common.PenpotSource( + fileId: "penpot-uuid", baseUrl: "https://penpot.example.com/", pathFilter: nil + ), + figmaFrameName: nil, + figmaPageName: nil, + figmaFileId: "figma-file-id", + rtlProperty: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + XCTAssertEqual(entry.resolvedSourceKind, .figma) + } + + func testResolvedFileIdPrefersPenpotSource() { + let entry = iOS.IconsEntry( + format: .svg, + assetsFolder: "Icons", + preservesVectorRepresentation: nil, + nameStyle: .camelCase, + imageSwift: nil, + swiftUIImageSwift: nil, + codeConnectSwift: nil, + xcassetsPath: nil, + templatesPath: nil, + renderMode: nil, + renderModeDefaultSuffix: nil, + renderModeOriginalSuffix: nil, + renderModeTemplateSuffix: nil, + sourceKind: nil, + penpotSource: Common.PenpotSource( + fileId: "penpot-uuid", baseUrl: "https://penpot.example.com/", pathFilter: nil + ), + figmaFrameName: nil, + figmaPageName: nil, + figmaFileId: "figma-file-id", + rtlProperty: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + XCTAssertEqual(entry.resolvedFileId, "penpot-uuid") + } + + func testResolvedFileIdFallsBackToFigmaFileId() { + let entry = iOS.IconsEntry( + format: .svg, + assetsFolder: "Icons", + preservesVectorRepresentation: nil, + nameStyle: .camelCase, + imageSwift: nil, + swiftUIImageSwift: nil, + codeConnectSwift: nil, + xcassetsPath: nil, + templatesPath: nil, + renderMode: nil, + renderModeDefaultSuffix: nil, + renderModeOriginalSuffix: nil, + renderModeTemplateSuffix: nil, + sourceKind: nil, + penpotSource: nil, + figmaFrameName: nil, + figmaPageName: nil, + figmaFileId: "figma-file-id", + rtlProperty: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + XCTAssertEqual(entry.resolvedFileId, "figma-file-id") + } + + func testResolvedPenpotBaseURLFromPenpotSource() { + let entry = iOS.IconsEntry( + format: .svg, + assetsFolder: "Icons", + preservesVectorRepresentation: nil, + nameStyle: .camelCase, + imageSwift: nil, + swiftUIImageSwift: nil, + codeConnectSwift: nil, + xcassetsPath: nil, + templatesPath: nil, + renderMode: nil, + renderModeDefaultSuffix: nil, + renderModeOriginalSuffix: nil, + renderModeTemplateSuffix: nil, + sourceKind: nil, + penpotSource: Common.PenpotSource( + fileId: "uuid", baseUrl: "https://my-penpot.example.com/", pathFilter: nil + ), + figmaFrameName: nil, + figmaPageName: nil, + figmaFileId: nil, + rtlProperty: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + XCTAssertEqual(entry.resolvedPenpotBaseURL, "https://my-penpot.example.com/") + } + + func testResolvedPenpotBaseURLNilWithoutPenpotSource() { + let entry = iOS.IconsEntry( + format: .svg, + assetsFolder: "Icons", + preservesVectorRepresentation: nil, + nameStyle: .camelCase, + imageSwift: nil, + swiftUIImageSwift: nil, + codeConnectSwift: nil, + xcassetsPath: nil, + templatesPath: nil, + renderMode: nil, + renderModeDefaultSuffix: nil, + renderModeOriginalSuffix: nil, + renderModeTemplateSuffix: nil, + sourceKind: nil, + penpotSource: nil, + figmaFrameName: nil, + figmaPageName: nil, + figmaFileId: nil, + rtlProperty: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + XCTAssertNil(entry.resolvedPenpotBaseURL) + } +} + +// MARK: - Penpot ColorsSourceInput Validation Tests + +final class PenpotColorsSourceInputTests: XCTestCase { + func testAutoDetectedPenpotProducesPenpotConfig() 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, + penpotSource: Common.PenpotSource( + fileId: "penpot-uuid-123", baseUrl: "https://my-penpot.com/", pathFilter: "Brand" + ), + tokensFile: nil, + 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, .penpot) + let config = try XCTUnwrap(sourceInput.sourceConfig as? PenpotColorsConfig) + XCTAssertEqual(config.fileId, "penpot-uuid-123") + XCTAssertEqual(config.baseURL, "https://my-penpot.com/") + XCTAssertEqual(config.pathFilter, "Brand") + } + + func testExplicitPenpotWithoutPenpotSourceThrowsMissingPenpotSource() { + 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: .penpot, + penpotSource: nil, + 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 + guard let configError = error as? ColorsConfigError else { + XCTFail("Expected ColorsConfigError, got \(error)") + return + } + XCTAssertEqual(configError, .missingPenpotSource) + } + } +} diff --git a/Tests/ExFigTests/Source/PenpotSourceTests.swift b/Tests/ExFigTests/Source/PenpotSourceTests.swift new file mode 100644 index 00000000..fc9f37d5 --- /dev/null +++ b/Tests/ExFigTests/Source/PenpotSourceTests.swift @@ -0,0 +1,225 @@ +@testable import ExFigCLI +import ExFigCore +import FigmaAPI +import Logging +import PenpotAPI +import XCTest + +// MARK: - Helpers + +private func dummyClient() -> MockClient { + MockClient() +} + +private func dummyPKLConfig() -> PKLConfig { + // swiftlint:disable:next force_try + try! JSONCodec.decode( + PKLConfig.self, + from: Data(""" + { + "figma": { + "lightFileId": "test-file-id" + } + } + """.utf8) + ) +} + +// MARK: - HexToRGBA Tests + +final class HexToRGBATests: XCTestCase { + func testValidSixDigitHex() throws { + let result = try XCTUnwrap(PenpotColorsSource.hexToRGBA(hex: "#3366FF", opacity: 1.0)) + XCTAssertEqual(result.red, 0x33 / 255.0, accuracy: 0.001) + XCTAssertEqual(result.green, 0x66 / 255.0, accuracy: 0.001) + XCTAssertEqual(result.blue, 0xFF / 255.0, accuracy: 0.001) + XCTAssertEqual(result.alpha, 1.0) + } + + func testValidHexWithoutHashPrefix() throws { + let result = try XCTUnwrap(PenpotColorsSource.hexToRGBA(hex: "FF0000", opacity: 0.5)) + XCTAssertEqual(result.red, 1.0, accuracy: 0.001) + XCTAssertEqual(result.green, 0.0, accuracy: 0.001) + XCTAssertEqual(result.blue, 0.0, accuracy: 0.001) + XCTAssertEqual(result.alpha, 0.5) + } + + func testBlackHex() throws { + let result = try XCTUnwrap(PenpotColorsSource.hexToRGBA(hex: "#000000", opacity: 1.0)) + XCTAssertEqual(result.red, 0.0) + XCTAssertEqual(result.green, 0.0) + XCTAssertEqual(result.blue, 0.0) + } + + func testWhiteHex() throws { + let result = try XCTUnwrap(PenpotColorsSource.hexToRGBA(hex: "#FFFFFF", opacity: 1.0)) + XCTAssertEqual(result.red, 1.0, accuracy: 0.001) + XCTAssertEqual(result.green, 1.0, accuracy: 0.001) + XCTAssertEqual(result.blue, 1.0, accuracy: 0.001) + } + + func testOpacityPassthrough() throws { + let result = try XCTUnwrap(PenpotColorsSource.hexToRGBA(hex: "#000000", opacity: 0.75)) + XCTAssertEqual(result.alpha, 0.75) + } + + func testInvalidHexReturnsNil() { + XCTAssertNil(PenpotColorsSource.hexToRGBA(hex: "banana", opacity: 1.0)) + } + + func testThreeDigitHexReturnsNil() { + XCTAssertNil(PenpotColorsSource.hexToRGBA(hex: "#F00", opacity: 1.0)) + } + + func testEightDigitHexReturnsNil() { + XCTAssertNil(PenpotColorsSource.hexToRGBA(hex: "#3366FFCC", opacity: 1.0)) + } + + func testEmptyStringReturnsNil() { + XCTAssertNil(PenpotColorsSource.hexToRGBA(hex: "", opacity: 1.0)) + } + + func testHexWithWhitespace() throws { + let result = try XCTUnwrap(PenpotColorsSource.hexToRGBA(hex: " #3366FF ", opacity: 1.0)) + XCTAssertEqual(result.red, 0x33 / 255.0, accuracy: 0.001) + } + + func testLowercaseHex() throws { + let result = try XCTUnwrap(PenpotColorsSource.hexToRGBA(hex: "#aabbcc", opacity: 1.0)) + XCTAssertEqual(result.red, 0xAA / 255.0, accuracy: 0.001) + XCTAssertEqual(result.green, 0xBB / 255.0, accuracy: 0.001) + XCTAssertEqual(result.blue, 0xCC / 255.0, accuracy: 0.001) + } +} + +// MARK: - SourceFactory Penpot Dispatch Tests + +final class SourceFactoryPenpotTests: XCTestCase { + override func setUp() { + super.setUp() + // SourceFactory.createComponentsSource/.createTypographySource use ExFigCommand.terminalUI + ExFigCommand.terminalUI = TerminalUI(outputMode: .quiet) + } + + func testCreateColorsSourceForPenpot() throws { + let input = ColorsSourceInput( + sourceKind: .penpot, + sourceConfig: PenpotColorsConfig( + fileId: "uuid", baseURL: "https://penpot.example.com/", pathFilter: nil + ) + ) + let ui = TerminalUI(outputMode: .quiet) + // FigmaAPI.Client is required by the factory signature but not used for Penpot. + // We pass a dummy client — PenpotColorsSource creates its own PenpotClient internally. + let source = try SourceFactory.createColorsSource(for: input, client: dummyClient(), ui: ui, filter: nil) + XCTAssert(source is PenpotColorsSource) + } + + func testCreateComponentsSourceForPenpot() throws { + let source = try SourceFactory.createComponentsSource( + for: .penpot, + client: dummyClient(), + params: dummyPKLConfig(), + platform: .ios, + logger: .init(label: "test"), + filter: nil + ) + XCTAssert(source is PenpotComponentsSource) + } + + func testCreateTypographySourceForPenpot() throws { + let source = try SourceFactory.createTypographySource( + for: .penpot, + client: dummyClient() + ) + XCTAssert(source is PenpotTypographySource) + } + + func testUnsupportedSourceKindThrowsForColors() { + let input = ColorsSourceInput( + sourceKind: .tokensStudio, + sourceConfig: PenpotColorsConfig(fileId: "x", baseURL: "x") + ) + let ui = TerminalUI(outputMode: .quiet) + XCTAssertThrowsError( + try SourceFactory.createColorsSource(for: input, client: dummyClient(), ui: ui, filter: nil) + ) + } + + func testUnsupportedSourceKindThrowsForComponents() { + XCTAssertThrowsError( + try SourceFactory.createComponentsSource( + for: .sketchFile, + client: dummyClient(), + params: dummyPKLConfig(), + platform: .ios, + logger: .init(label: "test"), + filter: nil + ) + ) + } + + func testUnsupportedSourceKindThrowsForTypography() { + XCTAssertThrowsError( + try SourceFactory.createTypographySource(for: .tokensStudio, client: dummyClient()) + ) + } +} + +// MARK: - PenpotComponentsSource FileId Validation Tests + +final class PenpotComponentsSourceValidationTests: XCTestCase { + func testLoadIconsThrowsWhenFileIdIsNil() async { + let source = PenpotComponentsSource(ui: TerminalUI(outputMode: .quiet)) + let input = IconsSourceInput( + sourceKind: .penpot, + figmaFileId: nil, + frameName: "Icons" + ) + do { + _ = try await source.loadIcons(from: input) + XCTFail("Expected error for nil fileId") + } catch { + XCTAssertTrue( + "\(error)".contains("file ID"), + "Error should mention file ID, got: \(error)" + ) + } + } + + func testLoadIconsThrowsWhenFileIdIsEmpty() async { + let source = PenpotComponentsSource(ui: TerminalUI(outputMode: .quiet)) + let input = IconsSourceInput( + sourceKind: .penpot, + figmaFileId: "", + frameName: "Icons" + ) + do { + _ = try await source.loadIcons(from: input) + XCTFail("Expected error for empty fileId") + } catch { + XCTAssertTrue( + "\(error)".contains("file ID"), + "Error should mention file ID, got: \(error)" + ) + } + } + + func testLoadImagesThrowsWhenFileIdIsNil() async { + let source = PenpotComponentsSource(ui: TerminalUI(outputMode: .quiet)) + let input = ImagesSourceInput( + sourceKind: .penpot, + figmaFileId: nil, + frameName: "Images" + ) + do { + _ = try await source.loadImages(from: input) + XCTFail("Expected error for nil fileId") + } catch { + XCTAssertTrue( + "\(error)".contains("file ID"), + "Error should mention file ID, got: \(error)" + ) + } + } +} From f4ec909c97aba1eda8a87987afb118b2111aa35c Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sat, 21 Mar 2026 23:38:33 +0500 Subject: [PATCH 07/26] fix(penpot): fix GetProfile empty body and duplicate --timeout in fetch command - GetProfileEndpoint now sends `{}` body instead of nil (Penpot returns 400 "malformed-json" for empty body) - Resolve duplicate --timeout flag conflict in `exfig fetch` by inlining HeavyFaultToleranceOptions fields and constructing via computed property - Document API path difference (/api/main/methods/ vs /api/rpc/command/) and Cloudflare behavior in PenpotAPI CLAUDE.md - Add E2E test data documentation (README.md) for Penpot test file - Exclude README.md from PenpotAPITests SPM resources --- CLAUDE.md | 1 + Package.swift | 1 + .../ExFigCLI/Subcommands/DownloadImages.swift | 29 ++++++++- Sources/PenpotAPI/CLAUDE.md | 11 ++++ .../Endpoints/GetProfileEndpoint.swift | 2 +- .../PenpotAPITests/PenpotEndpointTests.swift | 9 +-- Tests/PenpotAPITests/README.md | 59 +++++++++++++++++++ 7 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 Tests/PenpotAPITests/README.md diff --git a/CLAUDE.md b/CLAUDE.md index d8797c8d..4b459192 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -435,6 +435,7 @@ NooraUI.formatLink("url", useColors: true) // underlined primary | `unsupportedSourceKind` compile err | Changed to `.unsupportedSourceKind(kind, assetType:)` — add asset type string ("colors", "icons/images", "typography") | | `JSONCodec` in standalone module | `JSONCodec` lives in ExFigCore — standalone modules (PenpotAPI) use `YYJSONEncoder()`/`YYJSONDecoder()` from YYJSON directly | | `function_body_length` after branch | Split into private extension helper methods (e.g., `penpotColorsSourceInput()`, `tokensFileColorsSourceInput()`) | +| `ExFigCommand.terminalUI` in tests | Implicitly unwrapped — must init in `setUp()`: `ExFigCommand.terminalUI = TerminalUI(outputMode: .quiet)` before testing code that uses it (SourceFactory, Penpot sources) | ## Additional Rules diff --git a/Package.swift b/Package.swift index f6136da9..821ffb36 100644 --- a/Package.swift +++ b/Package.swift @@ -263,6 +263,7 @@ let package = Package( "PenpotAPI", .product(name: "CustomDump", package: "swift-custom-dump"), ], + exclude: ["README.md"], resources: [ .copy("Fixtures/"), ] diff --git a/Sources/ExFigCLI/Subcommands/DownloadImages.swift b/Sources/ExFigCLI/Subcommands/DownloadImages.swift index 0e08cd46..6544fbfa 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadImages.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadImages.swift @@ -49,8 +49,33 @@ extension ExFigCommand { @OptionGroup var downloadOptions: DownloadOptions - @OptionGroup - var faultToleranceOptions: HeavyFaultToleranceOptions + @Option(name: .long, help: "Maximum retry attempts for failed API requests") + var maxRetries: Int = 4 + + @Option(name: .long, help: "Maximum API requests per minute") + var rateLimit: Int = 10 + + @Flag(name: .long, help: "Stop on first error without retrying") + var failFast: Bool = false + + @Flag(name: .long, help: "Continue from checkpoint after interruption") + var resume: Bool = false + + @Option(name: .long, help: "Maximum concurrent CDN downloads") + var concurrentDownloads: Int = FileDownloader.defaultMaxConcurrentDownloads + + /// Constructs `HeavyFaultToleranceOptions` from locally declared options, + /// using `DownloadOptions.timeout` to avoid duplicate `--timeout` flags. + var faultToleranceOptions: HeavyFaultToleranceOptions { + var opts = HeavyFaultToleranceOptions() + opts.maxRetries = maxRetries + opts.rateLimit = rateLimit + opts.timeout = downloadOptions.timeout + opts.failFast = failFast + opts.resume = resume + opts.concurrentDownloads = concurrentDownloads + return opts + } // swiftlint:disable function_body_length cyclomatic_complexity diff --git a/Sources/PenpotAPI/CLAUDE.md b/Sources/PenpotAPI/CLAUDE.md index 83c3d174..7fcd5dd9 100644 --- a/Sources/PenpotAPI/CLAUDE.md +++ b/Sources/PenpotAPI/CLAUDE.md @@ -17,6 +17,17 @@ Only external dependency: swift-yyjson for JSON parsing. - `Authorization: Token ` header - Simple retry (3 attempts, exponential backoff) for 429/5xx - Typography numeric fields may be String OR Number — custom init(from:) handles both +- `GetProfileEndpoint` sends `{}` body (not nil) — Penpot returns 400 "malformed-json" for empty body + +## API Path + +Two equivalent paths exist: + +- `/api/main/methods/` — **used by this module**; works with URLSession against design.penpot.app +- `/api/rpc/command/` — official docs path; blocked by Cloudflare JS challenge on design.penpot.app for programmatic clients + +Self-hosted Penpot instances (without Cloudflare) accept both paths. +If switching to `/api/rpc/command/`, update `BasePenpotClient.buildURL(for:)`. ## Conventions diff --git a/Sources/PenpotAPI/Endpoints/GetProfileEndpoint.swift b/Sources/PenpotAPI/Endpoints/GetProfileEndpoint.swift index 46bf9659..a2125c6a 100644 --- a/Sources/PenpotAPI/Endpoints/GetProfileEndpoint.swift +++ b/Sources/PenpotAPI/Endpoints/GetProfileEndpoint.swift @@ -13,7 +13,7 @@ public struct GetProfileEndpoint: PenpotEndpoint { public init() {} public func body() throws -> Data? { - nil + Data("{}".utf8) } public func content(from data: Data) throws -> PenpotProfile { diff --git a/Tests/PenpotAPITests/PenpotEndpointTests.swift b/Tests/PenpotAPITests/PenpotEndpointTests.swift index 7a22da30..f0b15ac5 100644 --- a/Tests/PenpotAPITests/PenpotEndpointTests.swift +++ b/Tests/PenpotAPITests/PenpotEndpointTests.swift @@ -19,12 +19,13 @@ struct PenpotEndpointTests { #expect(json?["id"] == "abc-123") } - @Test("GetProfileEndpoint has no body") - func getProfileNoBody() throws { + @Test("GetProfileEndpoint sends empty JSON body") + func getProfileBody() throws { let endpoint = GetProfileEndpoint() #expect(endpoint.commandName == "get-profile") - let body = try endpoint.body() - #expect(body == nil) + let body = try #require(try endpoint.body()) + let json = try JSONSerialization.jsonObject(with: body) as? [String: Any] + #expect(json?.isEmpty == true) } @Test("GetFileObjectThumbnailsEndpoint body has kebab-case keys") diff --git a/Tests/PenpotAPITests/README.md b/Tests/PenpotAPITests/README.md new file mode 100644 index 00000000..9aaa70b7 --- /dev/null +++ b/Tests/PenpotAPITests/README.md @@ -0,0 +1,59 @@ +# PenpotAPI Tests + +## E2E Test Data + +E2E tests run against a real Penpot instance at `design.penpot.app`. +They are skipped when `PENPOT_ACCESS_TOKEN` is not set. + +### Test File + +| Field | Value | +| ------- | ----------------------------------------- | +| Name | Tokens starter kit | +| File ID | `9afc49c1-9c44-8036-8007-bf9fc737a656` | +| Page ID | `5e5872fb-0776-80fd-8006-154b5dfd6ec7` | +| Owner | Aleksei Kakoulin (alexey1312ru@gmail.com) | + +### Colors (8) + +| Name | Hex | Opacity | Path | +| ------------ | --------- | ------- | -------- | +| Primary | `#3B82F6` | 1.0 | Brand | +| Secondary | `#8B5CF6` | 1.0 | Brand | +| Success | `#22C55E` | 1.0 | Semantic | +| Warning | `#F59E0B` | 1.0 | Semantic | +| Error | `#EF4444` | 1.0 | Semantic | +| Background | `#1E1E2E` | 1.0 | Neutral | +| Text Primary | `#F8F8F2` | 1.0 | Neutral | +| Overlay | `#000000` | 0.5 | Neutral | + +### Typographies (4) + +| Name | Font Family | Size | Weight | +| ----------- | ----------- | ---- | ------ | +| Title | DM Mono | 30 | 500 | +| Subtitle | DM Mono | 24 | 500 | +| Label | DM Mono | 16 | 400 | +| Description | DM Mono | 16 | 400 | + +### Components (4) + +| Name | Path | +| ---------- | --------- | +| IconButton | UI | +| Avatar | UI | +| Badge | UI/Status | +| Divider | Layout | + +## Environment Variables + +| Variable | Required | Description | +| --------------------- | -------- | ----------------------------- | +| `PENPOT_ACCESS_TOKEN` | Yes | Penpot personal access token | +| `PENPOT_TEST_FILE_ID` | No | Override default test file ID | + +## API Path + +Tests use `/api/main/methods/` (not `/api/rpc/command/`). +The `rpc` path is blocked by Cloudflare on `design.penpot.app`; +`main/methods` works with `URLSession` and `Accept: application/json`. From 091ea83ad30856e8adde3c929701b4f09a049c1c Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sun, 22 Mar 2026 00:52:37 +0500 Subject: [PATCH 08/26] docs(penpot): add Penpot file structure guide, MCP resources, and prompt enhancements - Expand DesignRequirements.md with Penpot section: library colors, components, typography, file organization, limitations, troubleshooting - Add MCP guide resource (exfig://guides/DesignRequirements.md) served from Resources/Guides/ since DocC articles aren't in Bundle.module - Add `source` argument to setup-config MCP prompt (figma/penpot branches) - Update troubleshoot-export prompt to check both auth tokens - Add Penpot typography config example to Configuration.md - Regenerate llms-full.txt with updated DocC content - Update CLAUDE.md with timeout, DocC bundle, and Penpot API gotchas --- CLAUDE.md | 3 + Package.swift | 1 + Sources/ExFigCLI/CLAUDE.md | 3 + Sources/ExFigCLI/ExFig.docc/Configuration.md | 30 +- .../ExFigCLI/ExFig.docc/DesignRequirements.md | 220 ++++++-- Sources/ExFigCLI/MCP/MCPPrompts.swift | 72 ++- Sources/ExFigCLI/MCP/MCPResources.swift | 42 +- .../Resources/Guides/DesignRequirements.md | 529 ++++++++++++++++++ llms-full.txt | 250 +++++++-- 9 files changed, 1058 insertions(+), 92 deletions(-) create mode 100644 Sources/ExFigCLI/Resources/Guides/DesignRequirements.md diff --git a/CLAUDE.md b/CLAUDE.md index 4b459192..f30b8ba5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -436,6 +436,9 @@ NooraUI.formatLink("url", useColors: true) // underlined primary | `JSONCodec` in standalone module | `JSONCodec` lives in ExFigCore — standalone modules (PenpotAPI) use `YYJSONEncoder()`/`YYJSONDecoder()` from YYJSON directly | | `function_body_length` after branch | Split into private extension helper methods (e.g., `penpotColorsSourceInput()`, `tokensFileColorsSourceInput()`) | | `ExFigCommand.terminalUI` in tests | Implicitly unwrapped — must init in `setUp()`: `ExFigCommand.terminalUI = TerminalUI(outputMode: .quiet)` before testing code that uses it (SourceFactory, Penpot sources) | +| `--timeout` duplicate in `fetch` | `FetchImages` uses both `DownloadOptions` and `HeavyFaultToleranceOptions` which both define `--timeout`. Fix: inline Heavy options + computed property | +| DocC articles not in Bundle.module | `.docc` articles aren't copied to SPM bundle — use `Resources/Guides/` with `.copy()` for MCP-served content | +| Penpot `update-file` changes format | Flat `changes[]` array, `type` dispatch, needs `vern` field. Shapes need `parentId`, `frameId`, `selrect`, `points`, `transform`. Undocumented — use validation errors | ## Additional Rules diff --git a/Package.swift b/Package.swift index 821ffb36..052ce43a 100644 --- a/Package.swift +++ b/Package.swift @@ -61,6 +61,7 @@ let package = Package( exclude: ["CLAUDE.md", "AGENTS.md"], resources: [ .copy("Resources/Schemas/"), + .copy("Resources/Guides/"), ] ), diff --git a/Sources/ExFigCLI/CLAUDE.md b/Sources/ExFigCLI/CLAUDE.md index 7973747c..97db3103 100644 --- a/Sources/ExFigCLI/CLAUDE.md +++ b/Sources/ExFigCLI/CLAUDE.md @@ -190,6 +190,9 @@ reserved for MCP JSON-RPC protocol. **Keepalive:** `withCheckedContinuation { _ in }` — suspends indefinitely without hacks (no `Task.sleep(365 days)`). +**Guide resources:** `exfig://guides/` serves markdown files from `Resources/Guides/` (copied from DocC articles). +DocC `.docc` articles are NOT accessible via `Bundle.module` at runtime — must be separately copied to `Resources/Guides/`. + **Tool handler order:** Validate input parameters BEFORE expensive operations (PKL eval, API client creation). ### Adding an MCP Tool Handler diff --git a/Sources/ExFigCLI/ExFig.docc/Configuration.md b/Sources/ExFigCLI/ExFig.docc/Configuration.md index 069d52b8..468ea060 100644 --- a/Sources/ExFigCLI/ExFig.docc/Configuration.md +++ b/Sources/ExFigCLI/ExFig.docc/Configuration.md @@ -105,7 +105,10 @@ common = new Common.CommonConfig { ### Penpot Source -Use a Penpot project instead of Figma as the design source: +Use a Penpot project instead of Figma as the design source. For file preparation guidelines, +see . + +**Colors:** ```pkl import ".exfig/schemas/Common.pkl" @@ -113,25 +116,30 @@ import ".exfig/schemas/iOS.pkl" ios = new iOS.iOSConfig { colors = new iOS.ColorsEntry { - // Load colors from a Penpot file penpotSource = new Common.PenpotSource { - // Penpot file UUID fileId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" - - // Optional: custom Penpot instance URL (default: https://design.penpot.app/) - baseUrl = "https://penpot.mycompany.com/" - - // Optional: filter by path prefix - pathFilter = "Brand" + // baseUrl = "https://penpot.mycompany.com/" // optional: self-hosted + pathFilter = "Brand" // optional: filter by path prefix } - assetsFolder = "Colors" nameStyle = "camelCase" } } ``` -> When `penpotSource` is set, `sourceKind` auto-detects as `"penpot"`. ExFig reads colors from +**Typography:** + +```pkl +ios = new iOS.iOSConfig { + typography = new iOS.TypographyEntry { + penpotSource = new Common.PenpotSource { + fileId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + } + } +} +``` + +> When `penpotSource` is set, `sourceKind` auto-detects as `"penpot"`. ExFig reads from > the Penpot API and does not require `FIGMA_PERSONAL_TOKEN`. Set `PENPOT_ACCESS_TOKEN` instead. > > **v1 limitation:** Penpot icons/images are exported as raster thumbnails (the Penpot API has diff --git a/Sources/ExFigCLI/ExFig.docc/DesignRequirements.md b/Sources/ExFigCLI/ExFig.docc/DesignRequirements.md index 61f71144..765b8179 100644 --- a/Sources/ExFigCLI/ExFig.docc/DesignRequirements.md +++ b/Sources/ExFigCLI/ExFig.docc/DesignRequirements.md @@ -1,13 +1,16 @@ -# Design Requirements +# Design File Structure -How to structure your Figma files for optimal export with ExFig. +How to structure your design files for optimal export with ExFig. ## Overview -ExFig extracts design resources from Figma files based on specific naming conventions and organizational structures. -This guide explains how to set up your Figma files for seamless export. +ExFig extracts design resources from **Figma** files and **Penpot** projects based on naming conventions +and organizational structures. This guide explains how to set up your design files for seamless export. -## General Principles +- **Figma**: Uses frames, components, color styles, and Variables +- **Penpot**: Uses shared library colors, components, and typographies + +## Figma ### Frame Organization @@ -46,9 +49,9 @@ common = new Common.CommonConfig { } ``` -## Colors +### Colors -### Using Color Styles +#### Using Color Styles Create color styles in Figma with descriptive names: @@ -63,7 +66,7 @@ Colors frame └── border/default ``` -### Using Figma Variables +#### Using Figma Variables For Figma Variables API support: @@ -94,15 +97,15 @@ Colors collection └── text: #FFFFFF ``` -### Naming Guidelines +#### Naming Guidelines - Use lowercase with optional separators: `/`, `-`, `_` - Group related colors with prefixes: `text/primary`, `background/card` - Avoid special characters except separators -## Icons +### Icons -### Component Structure +#### Component Structure Icons must be **components** (not plain frames): @@ -115,7 +118,7 @@ Icons frame └── ic/32/menu (component) ``` -### Size Conventions +#### Size Conventions Organize icons by size: @@ -127,7 +130,7 @@ Icons frame └── ic/48/... (48pt icons) ``` -### Vector Requirements +#### Vector Requirements For optimal vector export: @@ -136,7 +139,7 @@ For optimal vector export: 3. **Remove hidden layers**: Delete unused or hidden elements 4. **Use consistent viewBox**: Keep viewBox dimensions consistent within size groups -### Dark Mode Icons +#### Dark Mode Icons Two approaches for dark mode support: @@ -174,9 +177,9 @@ Icons frame └── ic/24/close_dark ``` -## Images +### Images -### Component Structure +#### Component Structure Images must be **components**: @@ -188,7 +191,7 @@ Illustrations frame └── img-hero-banner (component) ``` -### Size Recommendations +#### Size Recommendations Design at the largest needed scale: @@ -196,7 +199,7 @@ Design at the largest needed scale: - **Android**: Design at xxxhdpi (4x), ExFig generates all densities - **Flutter**: Design at 3x, ExFig generates 1x, 2x, 3x -### Multi-Idiom Support (iOS) +#### Multi-Idiom Support (iOS) Use suffixes for device-specific variants: @@ -208,7 +211,7 @@ Illustrations frame └── img-sidebar~ipad ``` -### Dark Mode Images +#### Dark Mode Images Same approaches as icons: @@ -222,9 +225,9 @@ Illustrations frame └── img-hero_dark ``` -## Typography +### Typography -### Text Style Structure +#### Text Style Structure Create text styles with hierarchical names: @@ -239,7 +242,7 @@ Typography frame └── caption/small ``` -### Required Properties +#### Required Properties Each text style should define: @@ -249,7 +252,7 @@ Each text style should define: - **Line height**: in pixels or percentage - **Letter spacing**: in pixels or percentage -### Font Mapping +#### Font Mapping Map Figma fonts to platform fonts in your config: @@ -270,9 +273,9 @@ android = new Android.AndroidConfig { } ``` -## Validation Regex Patterns +### Validation Regex Patterns -### Common Patterns +#### Common Patterns ```pkl import ".exfig/schemas/Common.pkl" @@ -295,7 +298,7 @@ common = new Common.CommonConfig { } ``` -### Transform Patterns +#### Transform Patterns Transform names during export: @@ -310,8 +313,6 @@ common = new Common.CommonConfig { } ``` -## File Organization Tips - ### Recommended Figma Structure ``` @@ -344,27 +345,182 @@ For complex theming, use separate files: Ensure component names match exactly between files. -## Troubleshooting +### Figma Troubleshooting -### Resources Not Found +#### Resources Not Found - Verify frame names match `figmaFrameName` in config - Check that resources are **components**, not plain frames - Ensure names pass validation regex -### Missing Dark Mode +#### Missing Dark Mode - Verify `darkFileId` is set correctly - Check component names match between light and dark files - For single-file mode, verify suffix is correct -### Export Quality Issues +#### Export Quality Issues - Design at highest needed resolution - Use vector graphics when possible - Avoid raster effects in vector icons - Flatten complex boolean operations +## Penpot + +ExFig reads Penpot library assets — colors, components, and typographies — from the shared library +of a Penpot file. All assets must be added to the **shared library** (Assets panel), not just placed +on the canvas. + +### Authentication + +Set the `PENPOT_ACCESS_TOKEN` environment variable: + +1. Open Penpot → Settings → Access Tokens +2. Create a new token (no expiration recommended for CI) +3. Export: + +```bash +export PENPOT_ACCESS_TOKEN="your-token-here" +``` + +No `FIGMA_PERSONAL_TOKEN` needed when using only Penpot sources. + +### Library Colors + +Colors must be in the shared **Library** (Assets panel → Local library → Colors): + +``` +Library Colors +├── Brand/Primary (#3B82F6) +├── Brand/Secondary (#8B5CF6) +├── Semantic/Success (#22C55E) +├── Semantic/Warning (#F59E0B) +├── Semantic/Error (#EF4444) +├── Neutral/Background (#1E1E2E) +├── Neutral/Text (#F8F8F2) +└── Neutral/Overlay (#000000, 50% opacity) +``` + +Key points: + +- Only **solid hex colors** are exported. Gradients and image fills are skipped in v1. +- The `path` field organizes colors into groups: `path: "Brand"`, `name: "Primary"` → `Brand/Primary` +- Use `pathFilter` in your config to select a specific group: `pathFilter = "Brand"` exports only Brand colors +- **Opacity** is preserved (0.0–1.0) + +Config example: + +```pkl +penpotSource = new Common.PenpotSource { + fileId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + pathFilter = "Brand" // optional: export only Brand/* colors +} +``` + +### Library Components (Icons and Images) + +Components must be in the shared **Library** (Assets panel → Local library → Components). +ExFig filters by the component `path` prefix (equivalent to Figma's frame name): + +``` +Library Components +├── Icons/Navigation/arrow-left +├── Icons/Navigation/arrow-right +├── Icons/Actions/close +├── Icons/Actions/check +├── Illustrations/Empty States/no-data +└── Illustrations/Onboarding/welcome +``` + +Config example: + +```pkl +penpotSource = new Common.PenpotSource { + fileId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" +} +// Use path prefix as the frame filter +figmaFrameName = "Icons/Navigation" // exports arrow-left, arrow-right +``` + +> **v1 limitation:** Penpot components are exported as **raster thumbnails** (PNG only). +> The Penpot API has no public SVG/PNG render endpoint. SVG reconstruction from the +> shape tree is planned for a future version. For best quality, design components at the +> largest needed scale. + +### Library Typography + +Typography styles must be in the shared **Library** (Assets panel → Local library → Typography): + +``` +Library Typography +├── Heading/H1 (Roboto Bold 32px) +├── Heading/H2 (Roboto Bold 24px) +├── Body/Regular (Roboto Regular 16px) +├── Body/Bold (Roboto Bold 16px) +└── Caption/Small (Roboto Regular 12px) +``` + +Required fields: + +- **fontFamily** — e.g., "Roboto", "DM Mono" +- **fontSize** — must be set (styles without a parseable font size are skipped) + +Supported fields: `fontWeight`, `lineHeight`, `letterSpacing`, `textTransform` (uppercase/lowercase). + +> Penpot may serialize numeric fields as strings (e.g., `"24"` instead of `24`). ExFig handles both formats automatically. + +### Recommended Penpot Structure + +``` +Design System (Penpot file) +├── Library Colors +│ ├── Brand/* (primary, secondary, accent) +│ ├── Semantic/* (success, warning, error, info) +│ └── Neutral/* (background, text, border, overlay) +├── Library Components +│ ├── Icons/Navigation/* (arrow, chevron, menu) +│ ├── Icons/Actions/* (close, check, edit, delete) +│ └── Illustrations/* (empty states, onboarding) +└── Library Typography + ├── Heading/* (H1, H2, H3) + ├── Body/* (regular, bold, italic) + └── Caption/* (regular, small) +``` + +### Known Limitations (v1) + +- **No dark mode support** — Penpot has no Variables/modes equivalent; colors export as light-only +- **Raster-only components** — icons and images export as PNG thumbnails, not SVG +- **No `exfig_inspect` for Penpot** — the MCP inspect tool works with Figma API only +- **Gradients skipped** — only solid hex colors are supported +- **No page filtering** — all library assets are global to the file, not page-scoped + +### Penpot Troubleshooting + +#### No Colors Exported + +- Verify colors are in the **shared library**, not just swatches on the canvas +- Check `pathFilter` — a too-specific prefix returns no results +- Gradient colors are skipped; use solid fills + +#### No Components Exported + +- Verify components are in the **shared library** (right-click shape → "Create component") +- Check the path prefix in `figmaFrameName` matches the component `path` +- Thumbnails may not be generated for programmatically created components + +#### Typography Styles Skipped + +- Ensure `fontSize` is set on the typography style +- Styles with unparseable font size values are silently skipped + +#### Authentication Errors + +- `PENPOT_ACCESS_TOKEN environment variable is required` — set the token +- Penpot 401 — token expired or invalid; regenerate in Settings → Access Tokens +- Self-hosted instances: set `baseUrl` in `penpotSource` + ## See Also - diff --git a/Sources/ExFigCLI/MCP/MCPPrompts.swift b/Sources/ExFigCLI/MCP/MCPPrompts.swift index 4854d37f..7f082cba 100644 --- a/Sources/ExFigCLI/MCP/MCPPrompts.swift +++ b/Sources/ExFigCLI/MCP/MCPPrompts.swift @@ -14,6 +14,7 @@ enum MCPPrompts { description: "Guide through creating an exfig.pkl configuration file for a specific platform", arguments: [ .init(name: "platform", description: "Target platform: ios, android, flutter, or web", required: true), + .init(name: "source", description: "Design source: figma (default) or penpot"), .init( name: "project_path", description: "Path to the project directory (defaults to current directory)" @@ -53,6 +54,7 @@ enum MCPPrompts { throw MCPError.invalidParams("Missing required argument: platform") } + let source = arguments?["source"]?.stringValue ?? "figma" let projectPath = arguments?["project_path"]?.stringValue ?? "." let validPlatforms = ["ios", "android", "flutter", "web"] @@ -62,6 +64,21 @@ enum MCPPrompts { ) } + let validSources = ["figma", "penpot"] + guard validSources.contains(source) else { + throw MCPError.invalidParams( + "Invalid source '\(source)'. Must be one of: \(validSources.joined(separator: ", "))" + ) + } + + if source == "penpot" { + return try getSetupConfigPenpot(platform: platform, projectPath: projectPath) + } + + return try getSetupConfigFigma(platform: platform, projectPath: projectPath) + } + + private static func getSetupConfigFigma(platform: String, projectPath: String) throws -> GetPrompt.Result { let schemaName = platform == "ios" ? "iOS" : platform.capitalized let text = """ @@ -71,8 +88,9 @@ enum MCPPrompts { Please help me: 1. Read the ExFig \(schemaName) schema (use exfig://schemas/\(schemaName).pkl resource) 2. Read the starter template (use exfig://templates/\(platform) resource) - 3. Examine my project structure to determine correct output paths - 4. Create a properly configured exfig.pkl file + 3. Read the design file structure guide (use exfig://guides/DesignRequirements.md resource) + 4. Examine my project structure to determine correct output paths + 5. Create a properly configured exfig.pkl file I need to set: - Figma file ID(s) for my design files @@ -83,11 +101,50 @@ enum MCPPrompts { """ return .init( - description: "Setup ExFig \(platform) configuration at \(projectPath)", + description: "Setup ExFig \(platform) configuration with Figma at \(projectPath)", + messages: [.user(.text(text: text))] + ) + } + + // swiftlint:disable function_body_length + private static func getSetupConfigPenpot(platform: String, projectPath: String) throws -> GetPrompt.Result { + let schemaName = platform == "ios" ? "iOS" : platform.capitalized + + let text = """ + I need to create an exfig.pkl configuration file for the \(platform) platform \ + using **Penpot** as the design source in the project at \(projectPath). + + Please help me: + 1. Read the Common schema for PenpotSource (use exfig://schemas/Common.pkl resource) + 2. Read the \(schemaName) schema (use exfig://schemas/\(schemaName).pkl resource) + 3. Read the design file structure guide (use exfig://guides/DesignRequirements.md resource) \ + — focus on the Penpot section + 4. Read the starter template (use exfig://templates/\(platform) resource) + 5. Examine my project structure to determine correct output paths + 6. Create a properly configured exfig.pkl file with penpotSource entries + + I need to set: + - Penpot file UUID (from the Penpot workspace URL) + - Optional: custom Penpot instance URL (if self-hosted, default: design.penpot.app) + - Path filters matching my Penpot library structure + - Output paths matching my project structure + + Important notes: + - PENPOT_ACCESS_TOKEN must be set (not FIGMA_PERSONAL_TOKEN) + - No `figma` section needed when using only Penpot sources + - v1 limitation: icons/images export as raster thumbnails only + + First, validate the config with exfig_validate after creating it. + """ + + return .init( + description: "Setup ExFig \(platform) configuration with Penpot at \(projectPath)", messages: [.user(.text(text: text))] ) } + // swiftlint:enable function_body_length + // MARK: - Troubleshoot private static func getTroubleshoot(arguments: [String: Value]?) throws -> GetPrompt.Result { @@ -108,9 +165,14 @@ enum MCPPrompts { Please help me diagnose and fix this error: 1. First, validate the config with exfig_validate (config_path: "\(configPath)") - 2. Check if FIGMA_PERSONAL_TOKEN is set if the error is auth-related + 2. Check authentication: + - If the error mentions Figma or 401: check FIGMA_PERSONAL_TOKEN + - If the error mentions Penpot or PENPOT_ACCESS_TOKEN: check PENPOT_ACCESS_TOKEN + - For Penpot "malformed-json" errors: ensure ExFig is up to date 3. If it's a PKL error, read the relevant schema to understand the expected structure - 4. Suggest specific fixes with code examples + 4. Read the design file structure guide (exfig://guides/DesignRequirements.md) for \ + file preparation requirements + 5. Suggest specific fixes with code examples """ return .init( diff --git a/Sources/ExFigCLI/MCP/MCPResources.swift b/Sources/ExFigCLI/MCP/MCPResources.swift index bb7fb4f5..c44aa913 100644 --- a/Sources/ExFigCLI/MCP/MCPResources.swift +++ b/Sources/ExFigCLI/MCP/MCPResources.swift @@ -1,7 +1,7 @@ import Foundation import MCP -/// MCP resource definitions — PKL schemas and starter config templates. +/// MCP resource definitions — PKL schemas, starter config templates, and guides. enum MCPResources { // MARK: - Schema file names @@ -24,6 +24,16 @@ enum MCPResources { ("Web Starter Config", "web", webConfigFileContents), ] + // MARK: - Guide entries + + private static let guideFiles: [(name: String, file: String, description: String)] = [ + ( + "Design File Structure", + "DesignRequirements.md", + "How to prepare Figma and Penpot files for ExFig export — colors, components, typography, naming" + ), + ] + // MARK: - Public API static var allResources: [Resource] { @@ -50,6 +60,16 @@ enum MCPResources { )) } + // Guides + for guide in guideFiles { + resources.append(Resource( + name: guide.name, + uri: "exfig://guides/\(guide.file)", + description: guide.description, + mimeType: "text/markdown" + )) + } + return resources } @@ -75,6 +95,17 @@ enum MCPResources { return .init(contents: [Resource.Content.text(entry.content, uri: uri, mimeType: "text/plain")]) } + // Guide resources + if uri.hasPrefix("exfig://guides/") { + let fileName = String(uri.dropFirst("exfig://guides/".count)) + guard guideFiles.contains(where: { $0.file == fileName }) else { + throw MCPError.invalidParams("Unknown guide: \(fileName)") + } + + let content = try loadGuideContent(fileName: fileName) + return .init(contents: [Resource.Content.text(content, uri: uri, mimeType: "text/markdown")]) + } + throw MCPError.invalidParams("Unknown resource URI: \(uri)") } @@ -87,4 +118,13 @@ enum MCPResources { } return try String(contentsOf: url, encoding: .utf8) } + + // MARK: - Guide Loading + + private static func loadGuideContent(fileName: String) throws -> String { + guard let url = Bundle.module.url(forResource: fileName, withExtension: nil, subdirectory: "Guides") else { + throw MCPError.invalidParams("Guide file not found in bundle: \(fileName)") + } + return try String(contentsOf: url, encoding: .utf8) + } } diff --git a/Sources/ExFigCLI/Resources/Guides/DesignRequirements.md b/Sources/ExFigCLI/Resources/Guides/DesignRequirements.md new file mode 100644 index 00000000..765b8179 --- /dev/null +++ b/Sources/ExFigCLI/Resources/Guides/DesignRequirements.md @@ -0,0 +1,529 @@ +# Design File Structure + +How to structure your design files for optimal export with ExFig. + +## Overview + +ExFig extracts design resources from **Figma** files and **Penpot** projects based on naming conventions +and organizational structures. This guide explains how to set up your design files for seamless export. + +- **Figma**: Uses frames, components, color styles, and Variables +- **Penpot**: Uses shared library colors, components, and typographies + +## Figma + +### Frame Organization + +ExFig looks for resources in specific frames. Configure frame names in your `exfig.pkl`: + +```pkl +import ".exfig/schemas/Common.pkl" + +common = new Common.CommonConfig { + colors = new Common.Colors { + figmaFrameName = "Colors" + } + icons = new Common.Icons { + figmaFrameName = "Icons" + } + images = new Common.Images { + figmaFrameName = "Illustrations" + } + typography = new Common.Typography { + figmaFrameName = "Typography" + } +} +``` + +### Naming Conventions + +Use consistent naming patterns for all resources. ExFig supports regex validation: + +```pkl +import ".exfig/schemas/Common.pkl" + +common = new Common.CommonConfig { + icons = new Common.Icons { + nameValidateRegexp = "^ic/[0-9]+/[a-z_]+$" // e.g., ic/24/arrow_right + } +} +``` + +### Colors + +#### Using Color Styles + +Create color styles in Figma with descriptive names: + +``` +Colors frame +├── primary +├── secondary +├── background/primary +├── background/secondary +├── text/primary +├── text/secondary +└── border/default +``` + +#### Using Figma Variables + +For Figma Variables API support: + +```pkl +import ".exfig/schemas/Common.pkl" + +common = new Common.CommonConfig { + variablesColors = new Common.VariablesColors { + tokensFileId = "ABC123xyz" + tokensCollectionName = "Colors" + lightModeName = "Light" + darkModeName = "Dark" + } +} +``` + +Variable structure in Figma: + +``` +Colors collection +├── Mode: Light +│ ├── primary: #007AFF +│ ├── background: #FFFFFF +│ └── text: #000000 +└── Mode: Dark + ├── primary: #0A84FF + ├── background: #000000 + └── text: #FFFFFF +``` + +#### Naming Guidelines + +- Use lowercase with optional separators: `/`, `-`, `_` +- Group related colors with prefixes: `text/primary`, `background/card` +- Avoid special characters except separators + +### Icons + +#### Component Structure + +Icons must be **components** (not plain frames): + +``` +Icons frame +├── ic/24/arrow-right (component) +├── ic/24/arrow-left (component) +├── ic/16/close (component) +├── ic/16/check (component) +└── ic/32/menu (component) +``` + +#### Size Conventions + +Organize icons by size: + +``` +Icons frame +├── ic/16/... (16pt icons) +├── ic/24/... (24pt icons) +├── ic/32/... (32pt icons) +└── ic/48/... (48pt icons) +``` + +#### Vector Requirements + +For optimal vector export: + +1. **Use strokes carefully**: Convert strokes to outlines for complex icons +2. **Flatten boolean operations**: Flatten complex boolean operations before export +3. **Remove hidden layers**: Delete unused or hidden elements +4. **Use consistent viewBox**: Keep viewBox dimensions consistent within size groups + +#### Dark Mode Icons + +Two approaches for dark mode support: + +**Separate files:** + +```pkl +import ".exfig/schemas/Figma.pkl" + +figma = new Figma.FigmaConfig { + lightFileId = "abc123" + darkFileId = "def456" +} +``` + +Create matching component names in both files. + +**Single file with suffix:** + +```pkl +import ".exfig/schemas/Common.pkl" + +common = new Common.CommonConfig { + icons = new Common.Icons { + useSingleFile = true + darkModeSuffix = "_dark" + } +} +``` + +``` +Icons frame +├── ic/24/arrow-right +├── ic/24/arrow-right_dark +├── ic/24/close +└── ic/24/close_dark +``` + +### Images + +#### Component Structure + +Images must be **components**: + +``` +Illustrations frame +├── img-empty-state (component) +├── img-onboarding-1 (component) +├── img-onboarding-2 (component) +└── img-hero-banner (component) +``` + +#### Size Recommendations + +Design at the largest needed scale: + +- **iOS**: Design at @3x, ExFig generates @1x, @2x, @3x +- **Android**: Design at xxxhdpi (4x), ExFig generates all densities +- **Flutter**: Design at 3x, ExFig generates 1x, 2x, 3x + +#### Multi-Idiom Support (iOS) + +Use suffixes for device-specific variants: + +``` +Illustrations frame +├── img-hero~iphone (iPhone variant) +├── img-hero~ipad (iPad variant) +├── img-hero~mac (Mac variant) +└── img-sidebar~ipad +``` + +#### Dark Mode Images + +Same approaches as icons: + +**Separate files** or **suffix-based**: + +``` +Illustrations frame +├── img-empty-state +├── img-empty-state_dark +├── img-hero +└── img-hero_dark +``` + +### Typography + +#### Text Style Structure + +Create text styles with hierarchical names: + +``` +Typography frame +├── heading/h1 +├── heading/h2 +├── heading/h3 +├── body/regular +├── body/bold +├── caption/regular +└── caption/small +``` + +#### Required Properties + +Each text style should define: + +- **Font family**: e.g., "SF Pro Text" +- **Font weight**: e.g., Regular, Bold, Semibold +- **Font size**: in pixels +- **Line height**: in pixels or percentage +- **Letter spacing**: in pixels or percentage + +#### Font Mapping + +Map Figma fonts to platform fonts in your config: + +```pkl +import ".exfig/schemas/iOS.pkl" +import ".exfig/schemas/Android.pkl" + +ios = new iOS.iOSConfig { + typography = new iOS.Typography { + // fontMapping configured via custom templates + } +} + +android = new Android.AndroidConfig { + typography = new Android.Typography { + // fontMapping configured via custom templates + } +} +``` + +### Validation Regex Patterns + +#### Common Patterns + +```pkl +import ".exfig/schemas/Common.pkl" + +common = new Common.CommonConfig { + colors = new Common.Colors { + // Allow: primary, text/primary, background_card + nameValidateRegexp = "^[a-z][a-z0-9_/]*$" + } + + icons = new Common.Icons { + // Require: ic/SIZE/name format + nameValidateRegexp = "^ic/[0-9]+/[a-z][a-z0-9_-]*$" + } + + images = new Common.Images { + // Require: img- prefix + nameValidateRegexp = "^img-[a-z][a-z0-9_-]*$" + } +} +``` + +#### Transform Patterns + +Transform names during export: + +```pkl +import ".exfig/schemas/Common.pkl" + +common = new Common.CommonConfig { + icons = new Common.Icons { + nameValidateRegexp = "^ic/([0-9]+)/(.+)$" + nameReplaceRegexp = "ic$1_$2" // ic/24/arrow -> ic24_arrow + } +} +``` + +### Recommended Figma Structure + +``` +Design System +├── Colors +│ ├── Primary palette +│ ├── Secondary palette +│ ├── Semantic colors +│ └── Dark mode colors +├── Icons +│ ├── 16pt icons +│ ├── 24pt icons +│ └── 32pt icons +├── Illustrations +│ ├── Empty states +│ ├── Onboarding +│ └── Marketing +└── Typography + ├── Headings + ├── Body text + └── Captions +``` + +### Light and Dark Mode Files + +For complex theming, use separate files: + +- `Design-System-Light.fig`: Light mode resources +- `Design-System-Dark.fig`: Dark mode resources + +Ensure component names match exactly between files. + +### Figma Troubleshooting + +#### Resources Not Found + +- Verify frame names match `figmaFrameName` in config +- Check that resources are **components**, not plain frames +- Ensure names pass validation regex + +#### Missing Dark Mode + +- Verify `darkFileId` is set correctly +- Check component names match between light and dark files +- For single-file mode, verify suffix is correct + +#### Export Quality Issues + +- Design at highest needed resolution +- Use vector graphics when possible +- Avoid raster effects in vector icons +- Flatten complex boolean operations + +## Penpot + +ExFig reads Penpot library assets — colors, components, and typographies — from the shared library +of a Penpot file. All assets must be added to the **shared library** (Assets panel), not just placed +on the canvas. + +### Authentication + +Set the `PENPOT_ACCESS_TOKEN` environment variable: + +1. Open Penpot → Settings → Access Tokens +2. Create a new token (no expiration recommended for CI) +3. Export: + +```bash +export PENPOT_ACCESS_TOKEN="your-token-here" +``` + +No `FIGMA_PERSONAL_TOKEN` needed when using only Penpot sources. + +### Library Colors + +Colors must be in the shared **Library** (Assets panel → Local library → Colors): + +``` +Library Colors +├── Brand/Primary (#3B82F6) +├── Brand/Secondary (#8B5CF6) +├── Semantic/Success (#22C55E) +├── Semantic/Warning (#F59E0B) +├── Semantic/Error (#EF4444) +├── Neutral/Background (#1E1E2E) +├── Neutral/Text (#F8F8F2) +└── Neutral/Overlay (#000000, 50% opacity) +``` + +Key points: + +- Only **solid hex colors** are exported. Gradients and image fills are skipped in v1. +- The `path` field organizes colors into groups: `path: "Brand"`, `name: "Primary"` → `Brand/Primary` +- Use `pathFilter` in your config to select a specific group: `pathFilter = "Brand"` exports only Brand colors +- **Opacity** is preserved (0.0–1.0) + +Config example: + +```pkl +penpotSource = new Common.PenpotSource { + fileId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + pathFilter = "Brand" // optional: export only Brand/* colors +} +``` + +### Library Components (Icons and Images) + +Components must be in the shared **Library** (Assets panel → Local library → Components). +ExFig filters by the component `path` prefix (equivalent to Figma's frame name): + +``` +Library Components +├── Icons/Navigation/arrow-left +├── Icons/Navigation/arrow-right +├── Icons/Actions/close +├── Icons/Actions/check +├── Illustrations/Empty States/no-data +└── Illustrations/Onboarding/welcome +``` + +Config example: + +```pkl +penpotSource = new Common.PenpotSource { + fileId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" +} +// Use path prefix as the frame filter +figmaFrameName = "Icons/Navigation" // exports arrow-left, arrow-right +``` + +> **v1 limitation:** Penpot components are exported as **raster thumbnails** (PNG only). +> The Penpot API has no public SVG/PNG render endpoint. SVG reconstruction from the +> shape tree is planned for a future version. For best quality, design components at the +> largest needed scale. + +### Library Typography + +Typography styles must be in the shared **Library** (Assets panel → Local library → Typography): + +``` +Library Typography +├── Heading/H1 (Roboto Bold 32px) +├── Heading/H2 (Roboto Bold 24px) +├── Body/Regular (Roboto Regular 16px) +├── Body/Bold (Roboto Bold 16px) +└── Caption/Small (Roboto Regular 12px) +``` + +Required fields: + +- **fontFamily** — e.g., "Roboto", "DM Mono" +- **fontSize** — must be set (styles without a parseable font size are skipped) + +Supported fields: `fontWeight`, `lineHeight`, `letterSpacing`, `textTransform` (uppercase/lowercase). + +> Penpot may serialize numeric fields as strings (e.g., `"24"` instead of `24`). ExFig handles both formats automatically. + +### Recommended Penpot Structure + +``` +Design System (Penpot file) +├── Library Colors +│ ├── Brand/* (primary, secondary, accent) +│ ├── Semantic/* (success, warning, error, info) +│ └── Neutral/* (background, text, border, overlay) +├── Library Components +│ ├── Icons/Navigation/* (arrow, chevron, menu) +│ ├── Icons/Actions/* (close, check, edit, delete) +│ └── Illustrations/* (empty states, onboarding) +└── Library Typography + ├── Heading/* (H1, H2, H3) + ├── Body/* (regular, bold, italic) + └── Caption/* (regular, small) +``` + +### Known Limitations (v1) + +- **No dark mode support** — Penpot has no Variables/modes equivalent; colors export as light-only +- **Raster-only components** — icons and images export as PNG thumbnails, not SVG +- **No `exfig_inspect` for Penpot** — the MCP inspect tool works with Figma API only +- **Gradients skipped** — only solid hex colors are supported +- **No page filtering** — all library assets are global to the file, not page-scoped + +### Penpot Troubleshooting + +#### No Colors Exported + +- Verify colors are in the **shared library**, not just swatches on the canvas +- Check `pathFilter` — a too-specific prefix returns no results +- Gradient colors are skipped; use solid fills + +#### No Components Exported + +- Verify components are in the **shared library** (right-click shape → "Create component") +- Check the path prefix in `figmaFrameName` matches the component `path` +- Thumbnails may not be generated for programmatically created components + +#### Typography Styles Skipped + +- Ensure `fontSize` is set on the typography style +- Styles with unparseable font size values are silently skipped + +#### Authentication Errors + +- `PENPOT_ACCESS_TOKEN environment variable is required` — set the token +- Penpot 401 — token expired or invalid; regenerate in Settings → Access Tokens +- Self-hosted instances: set `baseUrl` in `penpotSource` + +## See Also + +- +- +- +- diff --git a/llms-full.txt b/llms-full.txt index e744004f..3dd460a9 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -627,7 +627,10 @@ common = new Common.CommonConfig { ### Penpot Source -Use a Penpot project instead of Figma as the design source: +Use a Penpot project instead of Figma as the design source. For file preparation guidelines, +see DesignRequirements. + +**Colors:** ```pkl import ".exfig/schemas/Common.pkl" @@ -635,25 +638,30 @@ import ".exfig/schemas/iOS.pkl" ios = new iOS.iOSConfig { colors = new iOS.ColorsEntry { - // Load colors from a Penpot file penpotSource = new Common.PenpotSource { - // Penpot file UUID fileId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" - - // Optional: custom Penpot instance URL (default: https://design.penpot.app/) - baseUrl = "https://penpot.mycompany.com/" - - // Optional: filter by path prefix - pathFilter = "Brand" + // baseUrl = "https://penpot.mycompany.com/" // optional: self-hosted + pathFilter = "Brand" // optional: filter by path prefix } - assetsFolder = "Colors" nameStyle = "camelCase" } } ``` -> When `penpotSource` is set, `sourceKind` auto-detects as `"penpot"`. ExFig reads colors from +**Typography:** + +```pkl +ios = new iOS.iOSConfig { + typography = new iOS.TypographyEntry { + penpotSource = new Common.PenpotSource { + fileId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + } + } +} +``` + +> When `penpotSource` is set, `sourceKind` auto-detects as `"penpot"`. ExFig reads from > the Penpot API and does not require `FIGMA_PERSONAL_TOKEN`. Set `PENPOT_ACCESS_TOKEN` instead. > > **v1 limitation:** Penpot icons/images are exported as raster thumbnails (the Penpot API has @@ -1101,16 +1109,19 @@ flutter = new Flutter.FlutterConfig { ### Design Requirements -# Design Requirements +# Design File Structure -How to structure your Figma files for optimal export with ExFig. +How to structure your design files for optimal export with ExFig. ## Overview -ExFig extracts design resources from Figma files based on specific naming conventions and organizational structures. -This guide explains how to set up your Figma files for seamless export. +ExFig extracts design resources from **Figma** files and **Penpot** projects based on naming conventions +and organizational structures. This guide explains how to set up your design files for seamless export. + +- **Figma**: Uses frames, components, color styles, and Variables +- **Penpot**: Uses shared library colors, components, and typographies -## General Principles +## Figma ### Frame Organization @@ -1149,9 +1160,9 @@ common = new Common.CommonConfig { } ``` -## Colors +### Colors -### Using Color Styles +#### Using Color Styles Create color styles in Figma with descriptive names: @@ -1166,7 +1177,7 @@ Colors frame └── border/default ``` -### Using Figma Variables +#### Using Figma Variables For Figma Variables API support: @@ -1197,15 +1208,15 @@ Colors collection └── text: #FFFFFF ``` -### Naming Guidelines +#### Naming Guidelines - Use lowercase with optional separators: `/`, `-`, `_` - Group related colors with prefixes: `text/primary`, `background/card` - Avoid special characters except separators -## Icons +### Icons -### Component Structure +#### Component Structure Icons must be **components** (not plain frames): @@ -1218,7 +1229,7 @@ Icons frame └── ic/32/menu (component) ``` -### Size Conventions +#### Size Conventions Organize icons by size: @@ -1230,7 +1241,7 @@ Icons frame └── ic/48/... (48pt icons) ``` -### Vector Requirements +#### Vector Requirements For optimal vector export: @@ -1239,7 +1250,7 @@ For optimal vector export: 3. **Remove hidden layers**: Delete unused or hidden elements 4. **Use consistent viewBox**: Keep viewBox dimensions consistent within size groups -### Dark Mode Icons +#### Dark Mode Icons Two approaches for dark mode support: @@ -1277,9 +1288,9 @@ Icons frame └── ic/24/close_dark ``` -## Images +### Images -### Component Structure +#### Component Structure Images must be **components**: @@ -1291,7 +1302,7 @@ Illustrations frame └── img-hero-banner (component) ``` -### Size Recommendations +#### Size Recommendations Design at the largest needed scale: @@ -1299,7 +1310,7 @@ Design at the largest needed scale: - **Android**: Design at xxxhdpi (4x), ExFig generates all densities - **Flutter**: Design at 3x, ExFig generates 1x, 2x, 3x -### Multi-Idiom Support (iOS) +#### Multi-Idiom Support (iOS) Use suffixes for device-specific variants: @@ -1311,7 +1322,7 @@ Illustrations frame └── img-sidebar~ipad ``` -### Dark Mode Images +#### Dark Mode Images Same approaches as icons: @@ -1325,9 +1336,9 @@ Illustrations frame └── img-hero_dark ``` -## Typography +### Typography -### Text Style Structure +#### Text Style Structure Create text styles with hierarchical names: @@ -1342,7 +1353,7 @@ Typography frame └── caption/small ``` -### Required Properties +#### Required Properties Each text style should define: @@ -1352,7 +1363,7 @@ Each text style should define: - **Line height**: in pixels or percentage - **Letter spacing**: in pixels or percentage -### Font Mapping +#### Font Mapping Map Figma fonts to platform fonts in your config: @@ -1373,9 +1384,9 @@ android = new Android.AndroidConfig { } ``` -## Validation Regex Patterns +### Validation Regex Patterns -### Common Patterns +#### Common Patterns ```pkl import ".exfig/schemas/Common.pkl" @@ -1398,7 +1409,7 @@ common = new Common.CommonConfig { } ``` -### Transform Patterns +#### Transform Patterns Transform names during export: @@ -1413,8 +1424,6 @@ common = new Common.CommonConfig { } ``` -## File Organization Tips - ### Recommended Figma Structure ``` @@ -1447,27 +1456,182 @@ For complex theming, use separate files: Ensure component names match exactly between files. -## Troubleshooting +### Figma Troubleshooting -### Resources Not Found +#### Resources Not Found - Verify frame names match `figmaFrameName` in config - Check that resources are **components**, not plain frames - Ensure names pass validation regex -### Missing Dark Mode +#### Missing Dark Mode - Verify `darkFileId` is set correctly - Check component names match between light and dark files - For single-file mode, verify suffix is correct -### Export Quality Issues +#### Export Quality Issues - Design at highest needed resolution - Use vector graphics when possible - Avoid raster effects in vector icons - Flatten complex boolean operations +## Penpot + +ExFig reads Penpot library assets — colors, components, and typographies — from the shared library +of a Penpot file. All assets must be added to the **shared library** (Assets panel), not just placed +on the canvas. + +### Authentication + +Set the `PENPOT_ACCESS_TOKEN` environment variable: + +1. Open Penpot → Settings → Access Tokens +2. Create a new token (no expiration recommended for CI) +3. Export: + +```bash +export PENPOT_ACCESS_TOKEN="your-token-here" +``` + +No `FIGMA_PERSONAL_TOKEN` needed when using only Penpot sources. + +### Library Colors + +Colors must be in the shared **Library** (Assets panel → Local library → Colors): + +``` +Library Colors +├── Brand/Primary (#3B82F6) +├── Brand/Secondary (#8B5CF6) +├── Semantic/Success (#22C55E) +├── Semantic/Warning (#F59E0B) +├── Semantic/Error (#EF4444) +├── Neutral/Background (#1E1E2E) +├── Neutral/Text (#F8F8F2) +└── Neutral/Overlay (#000000, 50% opacity) +``` + +Key points: + +- Only **solid hex colors** are exported. Gradients and image fills are skipped in v1. +- The `path` field organizes colors into groups: `path: "Brand"`, `name: "Primary"` → `Brand/Primary` +- Use `pathFilter` in your config to select a specific group: `pathFilter = "Brand"` exports only Brand colors +- **Opacity** is preserved (0.0–1.0) + +Config example: + +```pkl +penpotSource = new Common.PenpotSource { + fileId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + pathFilter = "Brand" // optional: export only Brand/* colors +} +``` + +### Library Components (Icons and Images) + +Components must be in the shared **Library** (Assets panel → Local library → Components). +ExFig filters by the component `path` prefix (equivalent to Figma's frame name): + +``` +Library Components +├── Icons/Navigation/arrow-left +├── Icons/Navigation/arrow-right +├── Icons/Actions/close +├── Icons/Actions/check +├── Illustrations/Empty States/no-data +└── Illustrations/Onboarding/welcome +``` + +Config example: + +```pkl +penpotSource = new Common.PenpotSource { + fileId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" +} +// Use path prefix as the frame filter +figmaFrameName = "Icons/Navigation" // exports arrow-left, arrow-right +``` + +> **v1 limitation:** Penpot components are exported as **raster thumbnails** (PNG only). +> The Penpot API has no public SVG/PNG render endpoint. SVG reconstruction from the +> shape tree is planned for a future version. For best quality, design components at the +> largest needed scale. + +### Library Typography + +Typography styles must be in the shared **Library** (Assets panel → Local library → Typography): + +``` +Library Typography +├── Heading/H1 (Roboto Bold 32px) +├── Heading/H2 (Roboto Bold 24px) +├── Body/Regular (Roboto Regular 16px) +├── Body/Bold (Roboto Bold 16px) +└── Caption/Small (Roboto Regular 12px) +``` + +Required fields: + +- **fontFamily** — e.g., "Roboto", "DM Mono" +- **fontSize** — must be set (styles without a parseable font size are skipped) + +Supported fields: `fontWeight`, `lineHeight`, `letterSpacing`, `textTransform` (uppercase/lowercase). + +> Penpot may serialize numeric fields as strings (e.g., `"24"` instead of `24`). ExFig handles both formats automatically. + +### Recommended Penpot Structure + +``` +Design System (Penpot file) +├── Library Colors +│ ├── Brand/* (primary, secondary, accent) +│ ├── Semantic/* (success, warning, error, info) +│ └── Neutral/* (background, text, border, overlay) +├── Library Components +│ ├── Icons/Navigation/* (arrow, chevron, menu) +│ ├── Icons/Actions/* (close, check, edit, delete) +│ └── Illustrations/* (empty states, onboarding) +└── Library Typography + ├── Heading/* (H1, H2, H3) + ├── Body/* (regular, bold, italic) + └── Caption/* (regular, small) +``` + +### Known Limitations (v1) + +- **No dark mode support** — Penpot has no Variables/modes equivalent; colors export as light-only +- **Raster-only components** — icons and images export as PNG thumbnails, not SVG +- **No `exfig_inspect` for Penpot** — the MCP inspect tool works with Figma API only +- **Gradients skipped** — only solid hex colors are supported +- **No page filtering** — all library assets are global to the file, not page-scoped + +### Penpot Troubleshooting + +#### No Colors Exported + +- Verify colors are in the **shared library**, not just swatches on the canvas +- Check `pathFilter` — a too-specific prefix returns no results +- Gradient colors are skipped; use solid fills + +#### No Components Exported + +- Verify components are in the **shared library** (right-click shape → "Create component") +- Check the path prefix in `figmaFrameName` matches the component `path` +- Thumbnails may not be generated for programmatically created components + +#### Typography Styles Skipped + +- Ensure `fontSize` is set on the typography style +- Styles with unparseable font size values are silently skipped + +#### Authentication Errors + +- `PENPOT_ACCESS_TOKEN environment variable is required` — set the token +- Penpot 401 — token expired or invalid; regenerate in Settings → Access Tokens +- Self-hosted instances: set `baseUrl` in `penpotSource` + ## See Also - Configuration From b47744800f7ce4900503cd238065c523feefa9ca Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sun, 22 Mar 2026 12:43:31 +0500 Subject: [PATCH 09/26] feat(cli): add Penpot support to init and fetch wizards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add WizardDesignSource enum — first wizard question is now "Figma or Penpot?" - InitWizard Penpot flow: file UUID, base URL, path filters (no dark mode/variables) - FetchWizard Penpot flow: file UUID, path filter, output directory - Add extractPenpotFileId() for Penpot workspace URL parsing (file-id= query param) - Add applyPenpotResult() in InitWizardTransform — removes figma section, inserts penpotSource blocks into platform entries - Add runPenpotFetch() in DownloadImages — downloads component thumbnails via PenpotAPI - Update GenerateConfigFile to show PENPOT_ACCESS_TOKEN in next-steps for Penpot source - Update test helpers with new InitWizardResult fields (designSource, penpotBaseURL) --- CLAUDE.md | 6 + Sources/ExFigCLI/CLAUDE.md | 7 ++ .../ExFigCLI/Subcommands/DownloadImages.swift | 102 ++++++++++++++++ .../ExFigCLI/Subcommands/FetchWizard.swift | 112 +++++++++++++++-- .../Subcommands/GenerateConfigFile.swift | 17 ++- Sources/ExFigCLI/Subcommands/InitWizard.swift | 110 +++++++++++++++-- .../Subcommands/InitWizardTransform.swift | 113 ++++++++++++++++++ .../Subcommands/InitWizardTests.swift | 16 ++- 8 files changed, 457 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f30b8ba5..940af13c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -282,6 +282,12 @@ Follow `InitWizard.swift` / `FetchWizard.swift` pattern: - Use `extractFigmaFileId(from:)` for file ID inputs (auto-extracts ID from full Figma URLs) - Trim text prompt results with `.trimmingCharacters(in: .whitespacesAndNewlines)` before `.isEmpty` default checks +#### Design Source Branching + +Both `InitWizard` and `FetchWizard` ask "Figma or Penpot?" first (`WizardDesignSource` enum in `FetchWizard.swift`). +`extractPenpotFileId(from:)` extracts UUID from Penpot workspace URLs (`file-id=UUID` query param). +`InitWizardTransform` has separate methods: `applyResult` (Figma) and `applyPenpotResult` (Penpot — removes figma section, inserts penpotSource blocks). + ### Adding a NooraUI Prompt Wrapper Follow the existing pattern in `NooraUI.swift`: static method delegating to `shared` instance with matching parameter names. diff --git a/Sources/ExFigCLI/CLAUDE.md b/Sources/ExFigCLI/CLAUDE.md index 97db3103..cd860ac2 100644 --- a/Sources/ExFigCLI/CLAUDE.md +++ b/Sources/ExFigCLI/CLAUDE.md @@ -245,6 +245,13 @@ transformation logic into `*WizardTransform.swift` as `extension`. SwiftLint enf - **Remove section** — brace-counting (`removeSection`), strips preceding comments - **Substitute value** — simple `replacingOccurrences` for file IDs, frame names - **Uncomment block** — strip `//` prefix, substitute values (variablesColors, figmaPageName) +- **Insert block** (Penpot) — `applyPenpotResult` removes figma section, inserts `penpotSource` blocks into platform entries + +### Penpot Fetch Path + +`DownloadImages.runPenpotFetch()` uses `PenpotAPI` directly (not `DownloadImageLoader`). +Creates `BasePenpotClient`, fetches file, filters components by path, downloads thumbnails as PNG. +Triggered when `wizardResult?.designSource == .penpot` after wizard flow. ### Adding a New Platform Export diff --git a/Sources/ExFigCLI/Subcommands/DownloadImages.swift b/Sources/ExFigCLI/Subcommands/DownloadImages.swift index 6544fbfa..0f01c977 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadImages.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadImages.swift @@ -1,10 +1,13 @@ +// swiftlint:disable file_length import ArgumentParser import ExFigCore import FigmaAPI import Foundation import Logging +import PenpotAPI extension ExFigCommand { + // swiftlint:disable type_body_length struct FetchImages: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "fetch", @@ -86,6 +89,7 @@ extension ExFigCommand { // Resolve required options via wizard if missing var options = downloadOptions + var wizardResult: FetchWizardResult? if options.fileId == nil || options.frameName == nil || options.outputPath == nil { guard TTYDetector.isTTY else { throw ValidationError( @@ -94,6 +98,7 @@ extension ExFigCommand { ) } let result = FetchWizard.run() + wizardResult = result options.fileId = options.fileId ?? result.fileId options.frameName = options.frameName ?? result.frameName options.outputPath = options.outputPath ?? result.outputPath @@ -110,6 +115,12 @@ extension ExFigCommand { } } + // Penpot path — use PenpotAPI directly + if wizardResult?.designSource == .penpot { + try await runPenpotFetch(options: options, wizardResult: wizardResult!, ui: ui) + return + } + // Validate required fields are now populated guard let fileId = options.fileId else { throw ValidationError("--file-id is required") @@ -293,6 +304,97 @@ extension ExFigCommand { // swiftlint:enable function_parameter_count + // MARK: - Penpot Fetch + + // swiftlint:disable function_body_length + private func runPenpotFetch( + options: DownloadOptions, + wizardResult: FetchWizardResult, + ui: TerminalUI + ) async throws { + guard let fileId = options.fileId else { + throw ValidationError("--file-id is required") + } + guard let frameName = options.frameName else { + throw ValidationError("--frame is required") + } + guard let outputPath = options.outputPath else { + throw ValidationError("--output is required") + } + + let baseURL = wizardResult.penpotBaseURL ?? BasePenpotClient.defaultBaseURL + let client = try PenpotColorsSource.makeClient(baseURL: baseURL) + + let outputURL = URL(fileURLWithPath: outputPath, isDirectory: true) + try FileManager.default.createDirectory(at: outputURL, withIntermediateDirectories: true) + + ui.info("Downloading components from Penpot...") + + // Fetch file data + let fileResponse = try await ui.withSpinner("Fetching Penpot file...") { + try await client.request(GetFileEndpoint(fileId: fileId)) + } + + guard let components = fileResponse.data.components else { + ui.warning("No components found in Penpot file") + return + } + + // Filter by path prefix + let matched = components.values.filter { comp in + guard let path = comp.path else { return false } + return path.hasPrefix(frameName) + } + + guard !matched.isEmpty else { + ui.warning("No components matching path '\(frameName)' found") + return + } + + ui.info("Found \(matched.count) components") + + // Get thumbnails + let objectIds = matched.map(\.id) + let thumbnails = try await ui.withSpinner("Fetching thumbnails...") { + try await client.request( + GetFileObjectThumbnailsEndpoint(fileId: fileId, objectIds: objectIds) + ) + } + + // Download each thumbnail + var downloadedCount = 0 + for component in matched { + guard let thumbnailRef = thumbnails[component.id] else { + ui.warning("Component '\(component.name)' has no thumbnail — skipping") + continue + } + + let fullURL: String = if thumbnailRef.hasPrefix("http") { + thumbnailRef + } else { + "\(baseURL)assets/by-file-media-id/\(thumbnailRef)" + } + + let data = try await client + .download(path: fullURL.hasPrefix("http") ? fullURL : "assets/by-file-media-id/\(thumbnailRef)") + let fileName = "\(component.name).png" + let fileURL = outputURL.appendingPathComponent(fileName) + try data.write(to: fileURL) + downloadedCount += 1 + } + + if downloadedCount > 0 { + ui.success("Downloaded \(downloadedCount) components to \(outputURL.path)") + } else { + ui + .warning( + "No thumbnails available for download (components may need to be opened in Penpot editor first)" + ) + } + } + + // swiftlint:enable function_body_length + private func convertToWebP( _ files: [FileContents], options: DownloadOptions, diff --git a/Sources/ExFigCLI/Subcommands/FetchWizard.swift b/Sources/ExFigCLI/Subcommands/FetchWizard.swift index 5143e690..cafb0acd 100644 --- a/Sources/ExFigCLI/Subcommands/FetchWizard.swift +++ b/Sources/ExFigCLI/Subcommands/FetchWizard.swift @@ -85,17 +85,31 @@ struct PlatformDefaults { /// Result of the interactive wizard flow. struct FetchWizardResult { + let designSource: WizardDesignSource let fileId: String let frameName: String let pageName: String? let outputPath: String - let format: ImageFormat + let format: ImageFormat? let scale: Double? let nameStyle: NameStyle? let filter: String? + let penpotBaseURL: String? } -// MARK: - Figma File ID Helpers +// MARK: - Design Source + +/// Design source choice for wizard prompts. +enum WizardDesignSource: String, CaseIterable, CustomStringConvertible, Equatable { + case figma = "Figma" + case penpot = "Penpot" + + var description: String { + rawValue + } +} + +// MARK: - File ID Helpers /// Extract Figma file ID from a full URL or return the input as-is if it looks like a bare ID. /// Supports: figma.com/file//..., figma.com/design//..., or bare alphanumeric IDs. @@ -112,6 +126,20 @@ func extractFigmaFileId(from input: String) -> String { return trimmed } +/// Extract Penpot file UUID from a workspace URL or return the input as-is. +/// Supports: design.penpot.app/#/workspace/...?file-id=UUID or bare UUIDs. +func extractPenpotFileId(from input: String) -> String { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + // Match file-id=UUID query parameter + if let range = trimmed.range(of: #"file-id=([0-9a-fA-F-]+)"#, options: .regularExpression) { + let match = trimmed[range] + if let eqSign = match.firstIndex(of: "=") { + return String(match[match.index(after: eqSign)...]) + } + } + return trimmed +} + // MARK: - Wizard Flow /// Interactive wizard for `exfig fetch` when required options are missing. @@ -126,9 +154,25 @@ enum FetchWizard { /// Run the interactive wizard and return populated options. static func run() -> FetchWizardResult { - // 1–3: Core choices (file, asset type, platform) + // 1: Design source + let source: WizardDesignSource = NooraUI.singleChoicePrompt( + title: "ExFig Export Wizard", + question: "Design source:", + options: WizardDesignSource.allCases, + description: "Where are your design assets stored?" + ) + + if source == .penpot { + return runPenpotFlow() + } + + return runFigmaFlow() + } + + // MARK: - Figma Flow + + private static func runFigmaFlow() -> FetchWizardResult { let fileIdInput = NooraUI.textPrompt( - title: "Figma Export Wizard", prompt: "Figma file ID or URL (figma.com/design//...):", description: "Paste the file URL or just the ID from it", validationRules: [NonEmptyValidationRule(error: "File ID cannot be empty.")] @@ -149,11 +193,61 @@ enum FetchWizard { let defaults = PlatformDefaults.forPlatform(platform, assetType: assetType) - // 4–8: Details (page, frame, format, output, filter) - return promptDetails(assetType: assetType, platform: platform, defaults: defaults, fileId: fileId) + return promptFigmaDetails(assetType: assetType, platform: platform, defaults: defaults, fileId: fileId) + } + + // MARK: - Penpot Flow + + private static func runPenpotFlow() -> FetchWizardResult { + let fileIdInput = NooraUI.textPrompt( + prompt: "Penpot file UUID or workspace URL:", + description: "Paste the workspace URL or just the file UUID", + validationRules: [NonEmptyValidationRule(error: "File ID cannot be empty.")] + ) + let fileId = extractPenpotFileId(from: fileIdInput) + + let baseURL: String? = if NooraUI.yesOrNoPrompt( + question: "Self-hosted Penpot instance?", + defaultAnswer: false, + description: "Select Yes if you're not using design.penpot.app" + ) { + NooraUI.textPrompt( + prompt: "Penpot base URL (e.g., https://penpot.mycompany.com/):", + validationRules: [NonEmptyValidationRule(error: "URL cannot be empty.")] + ) + } else { + nil + } + + let defaultPath = "Icons" + let pathInput = NooraUI.textPrompt( + prompt: "Component path filter (default: \(defaultPath)):", + description: "Library component path prefix to export. Press Enter for default." + ).trimmingCharacters(in: .whitespacesAndNewlines) + let pathFilter = pathInput.isEmpty ? defaultPath : pathInput + + let defaultOutput = "./output" + let outputInput = NooraUI.textPrompt( + prompt: "Output directory (default: \(defaultOutput)):", + description: "Where to save exported assets. Press Enter for default." + ).trimmingCharacters(in: .whitespacesAndNewlines) + let outputPath = outputInput.isEmpty ? defaultOutput : outputInput + + return FetchWizardResult( + designSource: .penpot, + fileId: fileId, + frameName: pathFilter, + pageName: nil, + outputPath: outputPath, + format: nil, + scale: nil, + nameStyle: nil, + filter: nil, + penpotBaseURL: baseURL + ) } - private static func promptDetails( + private static func promptFigmaDetails( assetType: WizardAssetType, platform: WizardPlatform, defaults: PlatformDefaults, @@ -193,6 +287,7 @@ enum FetchWizard { ) return FetchWizardResult( + designSource: .figma, fileId: fileId, frameName: frameName, pageName: pageName, @@ -200,7 +295,8 @@ enum FetchWizard { format: format, scale: defaults.scale, nameStyle: defaults.nameStyle, - filter: filter + filter: filter, + penpotBaseURL: nil ) } diff --git a/Sources/ExFigCLI/Subcommands/GenerateConfigFile.swift b/Sources/ExFigCLI/Subcommands/GenerateConfigFile.swift index dda6833b..6404e91e 100644 --- a/Sources/ExFigCLI/Subcommands/GenerateConfigFile.swift +++ b/Sources/ExFigCLI/Subcommands/GenerateConfigFile.swift @@ -55,7 +55,11 @@ extension ExFigCommand { // Interactive wizard let result = InitWizard.run() let template = templateForPlatform(result.platform) - rawContents = InitWizard.applyResult(result, to: template) + rawContents = if result.designSource == .penpot { + InitWizard.applyPenpotResult(result, to: template) + } else { + InitWizard.applyResult(result, to: template) + } wizardResult = result } else { // Non-TTY without --platform @@ -154,11 +158,14 @@ extension ExFigCommand { stepOffset = 2 } - if ProcessInfo.processInfo.environment["FIGMA_PERSONAL_TOKEN"] == nil { - ui.info("\(stepOffset). Set your Figma token (missing):") - ui.info(" export FIGMA_PERSONAL_TOKEN=your_token_here") + let isPenpot = wizardResult?.designSource == .penpot + let tokenName = isPenpot ? "PENPOT_ACCESS_TOKEN" : "FIGMA_PERSONAL_TOKEN" + let sourceName = isPenpot ? "Penpot" : "Figma" + if ProcessInfo.processInfo.environment[tokenName] == nil { + ui.info("\(stepOffset). Set your \(sourceName) token (missing):") + ui.info(" export \(tokenName)=your_token_here") } else { - ui.info("\(stepOffset). Figma token detected in environment ✅") + ui.info("\(stepOffset). \(sourceName) token detected in environment ✅") } ui.info("\(stepOffset + 1). Run export commands:") diff --git a/Sources/ExFigCLI/Subcommands/InitWizard.swift b/Sources/ExFigCLI/Subcommands/InitWizard.swift index d3361093..c3c330bd 100644 --- a/Sources/ExFigCLI/Subcommands/InitWizard.swift +++ b/Sources/ExFigCLI/Subcommands/InitWizard.swift @@ -60,6 +60,7 @@ struct InitVariablesConfig { /// Result of the interactive init wizard flow. struct InitWizardResult { + let designSource: WizardDesignSource let platform: Platform let selectedAssetTypes: [InitAssetType] let lightFileId: String @@ -69,6 +70,7 @@ struct InitWizardResult { let imagesFrameName: String? let imagesPageName: String? let variablesConfig: InitVariablesConfig? + let penpotBaseURL: String? } // MARK: - Init Wizard Flow @@ -79,16 +81,23 @@ struct InitWizardResult { enum InitWizard { /// Run the interactive wizard and return collected answers. static func run() -> InitWizardResult { - // 1. Platform selection - let wizardPlatform: WizardPlatform = NooraUI.singleChoicePrompt( + // 1. Design source + let source: WizardDesignSource = NooraUI.singleChoicePrompt( title: "ExFig Config Wizard", + question: "Design source:", + options: WizardDesignSource.allCases, + description: "Where are your design assets stored?" + ) + + // 2. Platform selection + let wizardPlatform: WizardPlatform = NooraUI.singleChoicePrompt( question: "Target platform:", options: WizardPlatform.allCases, description: "Select the platform you want to export assets for" ) let platform = wizardPlatform.asPlatform - // 2. Asset type multi-select + // 3. Asset type multi-select let availableTypes = InitAssetType.availableTypes(for: wizardPlatform) let selectedTypes: [InitAssetType] = NooraUI.multipleChoicePrompt( question: "What do you want to export?", @@ -97,7 +106,21 @@ enum InitWizard { minLimit: .limited(count: 1, errorMessage: "Select at least one asset type.") ) - // 3. Figma file ID (light) + if source == .penpot { + return runPenpotFlow(platform: platform, selectedTypes: selectedTypes) + } + + return runFigmaFlow(platform: platform, selectedTypes: selectedTypes) + } + + // MARK: - Figma Flow + + // swiftlint:disable function_body_length + private static func runFigmaFlow( + platform: Platform, + selectedTypes: [InitAssetType] + ) -> InitWizardResult { + // File ID (light) let lightFileIdInput = NooraUI.textPrompt( prompt: "Figma file ID or URL (figma.com/design//...):", description: "Paste the file URL or just the ID from it", @@ -105,7 +128,7 @@ enum InitWizard { ) let lightFileId = extractFigmaFileId(from: lightFileIdInput) - // 4. Dark mode file ID (optional) + // Dark mode file ID (optional) let darkFileIdRaw = promptOptionalText( question: "Do you have a separate dark mode file?", description: "If your dark colors/images are in a different Figma file", @@ -113,14 +136,14 @@ enum InitWizard { ) let darkFileId = darkFileIdRaw.map { extractFigmaFileId(from: $0) } - // 5. Colors source (if colors selected) + // Colors source (if colors selected) let variablesConfig: InitVariablesConfig? = if selectedTypes.contains(.colors) { promptColorsSource(lightFileId: lightFileId) } else { nil } - // 6. Icons details (if icons selected) + // Icons details (if icons selected) let iconsFrameName: String? let iconsPageName: String? if selectedTypes.contains(.icons) { @@ -131,7 +154,7 @@ enum InitWizard { iconsPageName = nil } - // 7. Images details (if images selected) + // Images details (if images selected) let imagesFrameName: String? let imagesPageName: String? if selectedTypes.contains(.images) { @@ -143,6 +166,7 @@ enum InitWizard { } return InitWizardResult( + designSource: .figma, platform: platform, selectedAssetTypes: selectedTypes, lightFileId: lightFileId, @@ -151,7 +175,67 @@ enum InitWizard { iconsPageName: iconsPageName, imagesFrameName: imagesFrameName, imagesPageName: imagesPageName, - variablesConfig: variablesConfig + variablesConfig: variablesConfig, + penpotBaseURL: nil + ) + } + + // swiftlint:enable function_body_length + + // MARK: - Penpot Flow + + private static func runPenpotFlow( + platform: Platform, + selectedTypes: [InitAssetType] + ) -> InitWizardResult { + // File UUID + let fileIdInput = NooraUI.textPrompt( + prompt: "Penpot file UUID or workspace URL:", + description: "Paste the workspace URL or just the file UUID", + validationRules: [NonEmptyValidationRule(error: "File ID cannot be empty.")] + ) + let fileId = extractPenpotFileId(from: fileIdInput) + + // Base URL (only for self-hosted) + let baseURL: String? = if NooraUI.yesOrNoPrompt( + question: "Self-hosted Penpot instance?", + defaultAnswer: false, + description: "Select Yes if you're not using design.penpot.app" + ) { + NooraUI.textPrompt( + prompt: "Penpot base URL (e.g., https://penpot.mycompany.com/):", + validationRules: [NonEmptyValidationRule(error: "URL cannot be empty.")] + ) + } else { + nil + } + + // Icons path filter (if icons selected) + let iconsFrameName: String? = if selectedTypes.contains(.icons) { + promptPathFilter(assetType: "icons", defaultPath: "Icons") + } else { + nil + } + + // Images path filter (if images selected) + let imagesFrameName: String? = if selectedTypes.contains(.images) { + promptPathFilter(assetType: "images", defaultPath: "Illustrations") + } else { + nil + } + + return InitWizardResult( + designSource: .penpot, + platform: platform, + selectedAssetTypes: selectedTypes, + lightFileId: fileId, + darkFileId: nil, + iconsFrameName: iconsFrameName, + iconsPageName: nil, + imagesFrameName: imagesFrameName, + imagesPageName: nil, + variablesConfig: nil, + penpotBaseURL: baseURL ) } @@ -165,6 +249,14 @@ enum InitWizard { return input.isEmpty ? defaultName : input } + private static func promptPathFilter(assetType: String, defaultPath: String) -> String { + let input = NooraUI.textPrompt( + prompt: "Penpot library path for \(assetType) (default: \(defaultPath)):", + description: "Path prefix to filter library components. Press Enter for default." + ).trimmingCharacters(in: .whitespacesAndNewlines) + return input.isEmpty ? defaultPath : input + } + private static func promptOptionalText( question: TerminalText, description: TerminalText, diff --git a/Sources/ExFigCLI/Subcommands/InitWizardTransform.swift b/Sources/ExFigCLI/Subcommands/InitWizardTransform.swift index a1023242..9acd85b1 100644 --- a/Sources/ExFigCLI/Subcommands/InitWizardTransform.swift +++ b/Sources/ExFigCLI/Subcommands/InitWizardTransform.swift @@ -351,6 +351,119 @@ extension InitWizard { return result } + // MARK: - Penpot Template Transformation + + /// Apply Penpot wizard result to a platform template. + /// Removes Figma-specific sections and inserts `penpotSource` blocks. + static func applyPenpotResult(_ result: InitWizardResult, to template: String) -> String { + var output = template + + // Remove figma section entirely + output = removeSection(from: output, matching: "figma = new Figma.FigmaConfig {") + // Remove Figma import + output = output.replacingOccurrences( + of: "import \".exfig/schemas/Figma.pkl\"\n", + with: "" + ) + + // Remove dark file ID lines + output = removeDarkFileIdLine(from: output) + + // Remove variablesColors block (not applicable for Penpot) + output = removeCommentedVariablesColors(from: output) + + // Remove common colors section (Penpot colors use penpotSource on platform entries) + output = removeSection(from: output, matching: "colors = new Common.Colors {") + + // Remove common icons/images sections (Penpot uses path filters, not frame names) + output = removeSection(from: output, matching: "icons = new Common.Icons {") + output = removeSection(from: output, matching: "images = new Common.Images {") + output = removeSection(from: output, matching: "typography = new Common.Typography {") + + // Build penpotSource block + let baseURLLine = if let baseURL = result.penpotBaseURL { + "\n baseUrl = \"\(baseURL)\"" + } else { + "" + } + + let penpotBlock = """ + penpotSource = new Common.PenpotSource { + fileId = "\(result.lightFileId)"\(baseURLLine) + } + """ + + // Insert penpotSource into each remaining platform entry + output = insertIntoPlatformEntries(output, block: penpotBlock) + + // Substitute icons/images path filters in platform entries + if let iconsFrame = result.iconsFrameName { + output = substitutePenpotPathFilter( + in: output, entryMarker: "icons", fieldName: "figmaFrameName", value: iconsFrame + ) + } + if let imagesFrame = result.imagesFrameName { + output = substitutePenpotPathFilter( + in: output, entryMarker: "images", fieldName: "figmaFrameName", value: imagesFrame + ) + } + + // Remove unselected asset sections + let allTypes: [InitAssetType] = [.colors, .icons, .images, .typography] + for assetType in allTypes where !result.selectedAssetTypes.contains(assetType) { + output = removeAssetSections(from: output, assetType: assetType) + } + + output = collapseBlankLines(output) + return output + } + + /// Insert a block of PKL text after the opening brace of each platform entry. + private static func insertIntoPlatformEntries(_ template: String, block: String) -> String { + let lines = template.components(separatedBy: "\n") + var result: [String] = [] + let entryPattern = #"= new (iOS|Android|Flutter|Web)\.\w+Entry \{"# + + for line in lines { + result.append(line) + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.range(of: entryPattern, options: .regularExpression) != nil { + result.append(block) + } + } + + return result.joined(separator: "\n") + } + + /// Replace `figmaFrameName` value in a platform entry for Penpot path filter. + private static func substitutePenpotPathFilter( + in template: String, + entryMarker: String, + fieldName: String, + value: String + ) -> String { + let lines = template.components(separatedBy: "\n") + var result: [String] = [] + var inEntry = false + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.contains("\(entryMarker) = new"), trimmed.contains("Entry {") { + inEntry = true + } + if inEntry, trimmed.hasPrefix("\(fieldName) = ") { + let indent = String(line.prefix(while: { $0 == " " })) + result.append("\(indent)\(fieldName) = \"\(value)\"") + inEntry = false + } else { + result.append(line) + } + if inEntry, trimmed == "}" { inEntry = false } + } + + return result.joined(separator: "\n") + } + static func collapseBlankLines(_ text: String) -> String { let lines = text.components(separatedBy: "\n") var result: [String] = [] diff --git a/Tests/ExFigTests/Subcommands/InitWizardTests.swift b/Tests/ExFigTests/Subcommands/InitWizardTests.swift index 767f2f6f..a0659e5d 100644 --- a/Tests/ExFigTests/Subcommands/InitWizardTests.swift +++ b/Tests/ExFigTests/Subcommands/InitWizardTests.swift @@ -220,6 +220,7 @@ struct InitWizardTests { @Test("applyResult with Flutter and all available types preserves all sections") func flutterAllSelected() { let result = InitWizardResult( + designSource: .figma, platform: .flutter, selectedAssetTypes: [.colors, .icons, .images], lightFileId: "FLUTTER_ID", @@ -228,7 +229,8 @@ struct InitWizardTests { iconsPageName: nil, imagesFrameName: nil, imagesPageName: nil, - variablesConfig: nil + variablesConfig: nil, + penpotBaseURL: nil ) let output = InitWizard.applyResult(result, to: flutterTemplate) #expect(output.contains("FLUTTER_ID")) @@ -283,6 +285,7 @@ struct InitWizardTests { variablesConfig: InitVariablesConfig? = nil ) -> InitWizardResult { InitWizardResult( + designSource: .figma, platform: platform, selectedAssetTypes: selectedAssetTypes, lightFileId: lightFileId, @@ -291,7 +294,8 @@ struct InitWizardTests { iconsPageName: iconsPageName, imagesFrameName: imagesFrameName, imagesPageName: imagesPageName, - variablesConfig: variablesConfig + variablesConfig: variablesConfig, + penpotBaseURL: nil ) } } @@ -303,6 +307,7 @@ struct InitWizardCrossPlatformTests { @Test("applyResult works with Android template") func androidAllSelected() { let result = InitWizardResult( + designSource: .figma, platform: .android, selectedAssetTypes: [.colors, .icons, .images, .typography], lightFileId: "ANDROID_ID", @@ -311,7 +316,8 @@ struct InitWizardCrossPlatformTests { iconsPageName: nil, imagesFrameName: nil, imagesPageName: nil, - variablesConfig: nil + variablesConfig: nil, + penpotBaseURL: nil ) let output = InitWizard.applyResult(result, to: androidConfigFileContents) #expect(output.contains("ANDROID_ID")) @@ -328,6 +334,7 @@ struct InitWizardCrossPlatformTests { @Test("applyResult works with Web template (no typography)") func webAllSelected() { let result = InitWizardResult( + designSource: .figma, platform: .web, selectedAssetTypes: [.colors, .icons, .images], lightFileId: "WEB_ID", @@ -336,7 +343,8 @@ struct InitWizardCrossPlatformTests { iconsPageName: nil, imagesFrameName: nil, imagesPageName: nil, - variablesConfig: nil + variablesConfig: nil, + penpotBaseURL: nil ) let output = InitWizard.applyResult(result, to: webConfigFileContents) #expect(output.contains("WEB_ID")) From 2c4b1eb2f4b624074a08ce7c4ebf3dc28485f692 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sun, 22 Mar 2026 13:53:11 +0500 Subject: [PATCH 10/26] fix(penpot): fix API client bugs, improve error handling, and add missing tests - Fix download() URL mangling by detecting absolute URLs - Fix retry loop returning error response on final attempt (now throws) - Fix asset download auth conflict with S3 presigned URLs (no Authorization header) - Fix asset path: assets/by-id/ instead of assets/by-file-media-id/ - Extract PenpotClientFactory from PenpotColorsSource for shared usage - Sort dictionary iteration for deterministic export order - Replace force-unwrap wizardResult! with safe binding - Wire iOS PluginIconsExport through SourceFactory for Penpot dispatch - Add recovery suggestions for 500-level and network errors - Wrap decoding errors with HTTP context (endpoint, status code) - Add baseURL validation precondition in BasePenpotClient.init - Add precondition(!fileId.isEmpty) to GetFileEndpoint - Add warnings for empty API responses, unknown text transforms - Remove unused mainInstanceId/mainInstancePage from PenpotComponent - Fix doc comments (PenpotEndpoint, GetProfileEndpoint, PenpotTypography) - Fix DesignTokens.md false Penpot claim, CLAUDE.md module count - Add tests: applyPenpotResult, extractPenpotFileId, VariablesSource.resolvedSourceKind --- CLAUDE.md | 10 +- Sources/ExFigCLI/CLAUDE.md | 1 + Sources/ExFigCLI/ExFig.docc/DesignTokens.md | 2 +- .../ExFigCLI/Source/PenpotClientFactory.swift | 14 ++ .../ExFigCLI/Source/PenpotColorsSource.swift | 14 +- .../Source/PenpotComponentsSource.swift | 13 +- .../Source/PenpotTypographySource.swift | 18 +- .../ExFigCLI/Subcommands/DownloadImages.swift | 13 +- .../Export/PluginIconsExport.swift | 4 +- Sources/ExFigConfig/CLAUDE.md | 4 + Sources/PenpotAPI/CLAUDE.md | 6 + Sources/PenpotAPI/Client/PenpotAPIError.swift | 5 + Sources/PenpotAPI/Client/PenpotClient.swift | 28 ++- Sources/PenpotAPI/Client/PenpotEndpoint.swift | 8 +- .../PenpotAPI/Endpoints/GetFileEndpoint.swift | 1 + .../Endpoints/GetProfileEndpoint.swift | 2 +- .../PenpotAPI/Models/PenpotComponent.swift | 12 +- .../PenpotAPI/Models/PenpotTypography.swift | 4 +- .../Input/VariablesSourceResolvedTests.swift | 101 ++++++++++ .../Subcommands/PenpotWizardTests.swift | 185 ++++++++++++++++++ .../PenpotAPITests/PenpotAPIErrorTests.swift | 10 +- .../PenpotComponentDecodingTests.swift | 12 +- 22 files changed, 402 insertions(+), 65 deletions(-) create mode 100644 Sources/ExFigCLI/Source/PenpotClientFactory.swift create mode 100644 Tests/ExFigTests/Input/VariablesSourceResolvedTests.swift create mode 100644 Tests/ExFigTests/Subcommands/PenpotWizardTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index 940af13c..faca994b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,7 +98,7 @@ pkl eval --format json # Package URI requires published package ## Architecture -Fifteen modules in `Sources/`: +Thirteen modules in `Sources/`: | Module | Purpose | | --------------- | ----------------------------------------------------------- | @@ -303,6 +303,11 @@ FigmaAPI is now an external package (`swift-figma-api`). See its repository for 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. +### Penpot Source Patterns + +- `PenpotClientFactory.makeClient(baseURL:)` — shared factory in `Source/PenpotClientFactory.swift`. All Penpot sources use this (NOT a static on any single source). +- Dictionary iteration from Penpot API (`colors`, `typographies`, `components`) must be sorted by key for deterministic export order: `.sorted(by: { $0.key < $1.key })`. + ### Entry Bridge Source Kind Resolution Entry bridge methods (`iconsSourceInput()`, `imagesSourceInput()`) use `resolvedSourceKind` (computed property on `Common_FrameSource`) @@ -445,6 +450,9 @@ NooraUI.formatLink("url", useColors: true) // underlined primary | `--timeout` duplicate in `fetch` | `FetchImages` uses both `DownloadOptions` and `HeavyFaultToleranceOptions` which both define `--timeout`. Fix: inline Heavy options + computed property | | DocC articles not in Bundle.module | `.docc` articles aren't copied to SPM bundle — use `Resources/Guides/` with `.copy()` for MCP-served content | | Penpot `update-file` changes format | Flat `changes[]` array, `type` dispatch, needs `vern` field. Shapes need `parentId`, `frameId`, `selrect`, `points`, `transform`. Undocumented — use validation errors | +| Switch expression + `return` | When any switch branch has side-effects before `return`, use explicit `return` on ALL branches — implicit return breaks type inference | +| `FIGMA_PERSONAL_TOKEN` for Penpot | `ExFigOptions.validate()` requires it even for Penpot-only configs — pass dummy value for testing | +| PKL `swiftuiColorSwift` casing | PKL codegen lowercases: `swiftuiColorSwift`, not `swiftUIColorSwift` — check with `pkl eval` if unsure | ## Additional Rules diff --git a/Sources/ExFigCLI/CLAUDE.md b/Sources/ExFigCLI/CLAUDE.md index cd860ac2..87feb87d 100644 --- a/Sources/ExFigCLI/CLAUDE.md +++ b/Sources/ExFigCLI/CLAUDE.md @@ -215,6 +215,7 @@ DocC `.docc` articles are NOT accessible via `Bundle.module` at runtime — must `ColorsExportContextImpl.loadColors()` creates source via `SourceFactory.createColorsSource(for:...)` per call. `IconsExportContextImpl` / `ImagesExportContextImpl` use injected `componentsSource` (Figma and Penpot supported via `SourceFactory`). `PluginColorsExport` does NOT create sources — context handles dispatch internally. +**Source dispatch gap:** `PluginIconsExport` iOS uses `SourceFactory`; Android/Flutter/Web still hardcode `FigmaComponentsSource`. `PluginImagesExport` all platforms hardcoded. When adding a new source kind: update `SourceFactory`, add source impl in `Source/`, update error `assetType`. Penpot sources create `BasePenpotClient` internally from `PENPOT_ACCESS_TOKEN` env var (like TokensFileSource reads local files — no injected client). diff --git a/Sources/ExFigCLI/ExFig.docc/DesignTokens.md b/Sources/ExFigCLI/ExFig.docc/DesignTokens.md index b9afae8c..9f6e1bc2 100644 --- a/Sources/ExFigCLI/ExFig.docc/DesignTokens.md +++ b/Sources/ExFigCLI/ExFig.docc/DesignTokens.md @@ -1,6 +1,6 @@ # Design Tokens -Export design data from Figma or Penpot as W3C Design Tokens for token pipelines and cross-tool interoperability. +Export design data from Figma as W3C Design Tokens for token pipelines and cross-tool interoperability. ## Overview diff --git a/Sources/ExFigCLI/Source/PenpotClientFactory.swift b/Sources/ExFigCLI/Source/PenpotClientFactory.swift new file mode 100644 index 00000000..c627716b --- /dev/null +++ b/Sources/ExFigCLI/Source/PenpotClientFactory.swift @@ -0,0 +1,14 @@ +import Foundation +import PenpotAPI + +/// Shared factory for creating authenticated Penpot API clients. +enum PenpotClientFactory { + static func makeClient(baseURL: String) throws -> BasePenpotClient { + guard let token = ProcessInfo.processInfo.environment["PENPOT_ACCESS_TOKEN"], !token.isEmpty else { + throw ExFigError.configurationError( + "PENPOT_ACCESS_TOKEN environment variable is required for Penpot source" + ) + } + return BasePenpotClient(accessToken: token, baseURL: baseURL) + } +} diff --git a/Sources/ExFigCLI/Source/PenpotColorsSource.swift b/Sources/ExFigCLI/Source/PenpotColorsSource.swift index 73db790a..f45e4003 100644 --- a/Sources/ExFigCLI/Source/PenpotColorsSource.swift +++ b/Sources/ExFigCLI/Source/PenpotColorsSource.swift @@ -12,16 +12,17 @@ struct PenpotColorsSource: ColorsSource { ) } - let client = try Self.makeClient(baseURL: config.baseURL) + let client = try PenpotClientFactory.makeClient(baseURL: config.baseURL) let fileResponse = try await client.request(GetFileEndpoint(fileId: config.fileId)) guard let penpotColors = fileResponse.data.colors else { + ui.warning("Penpot file '\(fileResponse.name)' has no library colors") return ColorsLoadOutput(light: []) } var colors: [Color] = [] - for (_, penpotColor) in penpotColors { + for (_, penpotColor) in penpotColors.sorted(by: { $0.key < $1.key }) { // Skip gradient/image fills (no solid hex) guard let hex = penpotColor.color else { continue } @@ -59,15 +60,6 @@ struct PenpotColorsSource: ColorsSource { // MARK: - Internal - static func makeClient(baseURL: String) throws -> BasePenpotClient { - guard let token = ProcessInfo.processInfo.environment["PENPOT_ACCESS_TOKEN"], !token.isEmpty else { - throw ExFigError.configurationError( - "PENPOT_ACCESS_TOKEN environment variable is required for Penpot source" - ) - } - return BasePenpotClient(accessToken: token, baseURL: baseURL) - } - static func hexToRGBA(hex: String, opacity: Double) -> (red: Double, green: Double, blue: Double, alpha: Double)? { diff --git a/Sources/ExFigCLI/Source/PenpotComponentsSource.swift b/Sources/ExFigCLI/Source/PenpotComponentsSource.swift index 533c14b5..ad8f4d81 100644 --- a/Sources/ExFigCLI/Source/PenpotComponentsSource.swift +++ b/Sources/ExFigCLI/Source/PenpotComponentsSource.swift @@ -50,11 +50,12 @@ struct PenpotComponentsSource: ComponentsSource { } let effectiveBaseURL = baseURL ?? BasePenpotClient.defaultBaseURL - let client = try PenpotColorsSource.makeClient(baseURL: effectiveBaseURL) + let client = try PenpotClientFactory.makeClient(baseURL: effectiveBaseURL) let fileResponse = try await client.request(GetFileEndpoint(fileId: fileId)) guard let components = fileResponse.data.components else { + ui.warning("Penpot file '\(fileResponse.name)' has no library components") return [] } @@ -64,19 +65,21 @@ struct PenpotComponentsSource: ComponentsSource { return path.hasPrefix(pathFilter) } - guard !matchedComponents.isEmpty else { + let sortedComponents = matchedComponents.sorted { $0.name < $1.name } + + guard !sortedComponents.isEmpty else { return [] } // Get thumbnails for matched components - let objectIds = matchedComponents.map(\.id) + let objectIds = sortedComponents.map(\.id) let thumbnails = try await client.request( GetFileObjectThumbnailsEndpoint(fileId: fileId, objectIds: objectIds) ) var packs: [ImagePack] = [] - for component in matchedComponents { + for component in sortedComponents { guard let thumbnailRef = thumbnails[component.id] else { ui.warning("Component '\(component.name)' has no thumbnail — skipping") continue @@ -86,7 +89,7 @@ struct PenpotComponentsSource: ComponentsSource { let fullURL: String = if thumbnailRef.hasPrefix("http") { thumbnailRef } else { - "\(effectiveBaseURL)assets/by-file-media-id/\(thumbnailRef)" + "\(effectiveBaseURL)assets/by-id/\(thumbnailRef)" } guard let url = URL(string: fullURL) else { diff --git a/Sources/ExFigCLI/Source/PenpotTypographySource.swift b/Sources/ExFigCLI/Source/PenpotTypographySource.swift index cae5920f..fe954a26 100644 --- a/Sources/ExFigCLI/Source/PenpotTypographySource.swift +++ b/Sources/ExFigCLI/Source/PenpotTypographySource.swift @@ -7,17 +7,18 @@ struct PenpotTypographySource: TypographySource { func loadTypography(from input: TypographySourceInput) async throws -> TypographyLoadOutput { let effectiveBaseURL = input.penpotBaseURL ?? BasePenpotClient.defaultBaseURL - let client = try PenpotColorsSource.makeClient(baseURL: effectiveBaseURL) + let client = try PenpotClientFactory.makeClient(baseURL: effectiveBaseURL) let fileResponse = try await client.request(GetFileEndpoint(fileId: input.fileId)) guard let typographies = fileResponse.data.typographies else { + ui.warning("Penpot file '\(fileResponse.name)' has no library typographies") return TypographyLoadOutput(textStyles: []) } var textStyles: [TextStyle] = [] - for (_, typography) in typographies { + for (_, typography) in typographies.sorted(by: { $0.key < $1.key }) { guard let fontSize = typography.fontSize else { ui.warning("Typography '\(typography.name)' has unparseable font-size — skipping") continue @@ -31,6 +32,10 @@ struct PenpotTypographySource: TypographySource { let textCase = mapTextTransform(typography.textTransform) + if typography.letterSpacing == nil, typography.lineHeight != nil { + ui.warning("Typography '\(typography.name)' has unparseable letter-spacing — defaulting to 0") + } + textStyles.append(TextStyle( name: name, fontName: typography.fontFamily, @@ -50,11 +55,14 @@ struct PenpotTypographySource: TypographySource { private func mapTextTransform(_ transform: String?) -> TextStyle.TextCase { switch transform { case "uppercase": - .uppercased + return .uppercased case "lowercase": - .lowercased + return .lowercased + case nil, "none": + return .original default: - .original + ui.warning("Unknown text transform '\(transform!)' — using original") + return .original } } } diff --git a/Sources/ExFigCLI/Subcommands/DownloadImages.swift b/Sources/ExFigCLI/Subcommands/DownloadImages.swift index 0f01c977..e3e4391a 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadImages.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadImages.swift @@ -116,8 +116,8 @@ extension ExFigCommand { } // Penpot path — use PenpotAPI directly - if wizardResult?.designSource == .penpot { - try await runPenpotFetch(options: options, wizardResult: wizardResult!, ui: ui) + if let result = wizardResult, result.designSource == .penpot { + try await runPenpotFetch(options: options, wizardResult: result, ui: ui) return } @@ -323,7 +323,7 @@ extension ExFigCommand { } let baseURL = wizardResult.penpotBaseURL ?? BasePenpotClient.defaultBaseURL - let client = try PenpotColorsSource.makeClient(baseURL: baseURL) + let client = try PenpotClientFactory.makeClient(baseURL: baseURL) let outputURL = URL(fileURLWithPath: outputPath, isDirectory: true) try FileManager.default.createDirectory(at: outputURL, withIntermediateDirectories: true) @@ -369,14 +369,13 @@ extension ExFigCommand { continue } - let fullURL: String = if thumbnailRef.hasPrefix("http") { + let downloadPath: String = if thumbnailRef.hasPrefix("http") { thumbnailRef } else { - "\(baseURL)assets/by-file-media-id/\(thumbnailRef)" + "assets/by-id/\(thumbnailRef)" } - let data = try await client - .download(path: fullURL.hasPrefix("http") ? fullURL : "assets/by-file-media-id/\(thumbnailRef)") + let data = try await client.download(path: downloadPath) let fileName = "\(component.name).png" let fileURL = outputURL.appendingPathComponent(fileName) try data.write(to: fileURL) diff --git a/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift b/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift index aa6cc588..3ea810a8 100644 --- a/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift +++ b/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift @@ -39,7 +39,9 @@ extension ExFigCommand.ExportIcons { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() - let componentsSource = FigmaComponentsSource( + let sourceKind = entries.first?.resolvedSourceKind ?? .figma + let componentsSource = try SourceFactory.createComponentsSource( + for: sourceKind, client: client, params: params, platform: .ios, diff --git a/Sources/ExFigConfig/CLAUDE.md b/Sources/ExFigConfig/CLAUDE.md index 5f8d00cd..1697ab4c 100644 --- a/Sources/ExFigConfig/CLAUDE.md +++ b/Sources/ExFigConfig/CLAUDE.md @@ -60,6 +60,10 @@ ExFigCore domain types (NameStyle, ColorsSourceInput, etc.) | `Common_FrameSource.resolvedFileId` | `penpotSource?.fileId ?? figmaFileId` — auto-resolves file ID for any source. Defined in `SourceKindBridging.swift` | | `Common_FrameSource.resolvedPenpotBaseURL` | `penpotSource?.baseUrl` — passes Penpot base URL through entry bridges. Defined in `SourceKindBridging.swift` | +### Generated Type Gotchas + +- `Common.PenpotSource.baseUrl` is non-optional `String` (has PKL default) — tests must pass a real URL, not `nil` + ### PklError Workaround `PklSwift.PklError` doesn't conform to `LocalizedError`. The `@retroactive` extension in `PKLEvaluator.swift` exposes `.message` — without it, `.localizedDescription` returns a useless generic string. diff --git a/Sources/PenpotAPI/CLAUDE.md b/Sources/PenpotAPI/CLAUDE.md index 7fcd5dd9..8cddfa93 100644 --- a/Sources/PenpotAPI/CLAUDE.md +++ b/Sources/PenpotAPI/CLAUDE.md @@ -36,3 +36,9 @@ If switching to `/api/rpc/command/`, update `BasePenpotClient.buildURL(for:)`. - `BasePenpotClient` validates `maxRetries >= 1` and `!accessToken.isEmpty` via preconditions - Retry loop respects `CancellationError` — rethrows immediately instead of retrying - `download()` includes response body in error message for diagnostics +- `performWithRetry` must `throw` on the final attempt for retryable errors — falling through to `return` would return the error response as success data +- `BasePenpotClient.init` validates `baseURL` via precondition — invalid URLs fail at construction, not at first request +- `download(path:)` handles both absolute URLs (`http://...`) and relative paths — does NOT blindly prepend `baseURL` +- `download()` must NOT send `Authorization` header — Penpot assets are served via S3/MinIO presigned URLs that conflict with extra auth +- Thumbnail download path is `assets/by-id/`, NOT `assets/by-file-media-id/` +- `get-file-object-thumbnails` returns compound keys (`fileId/pageId/objectId/type`), not simple component IDs — v1 limitation diff --git a/Sources/PenpotAPI/Client/PenpotAPIError.swift b/Sources/PenpotAPI/Client/PenpotAPIError.swift index ff1158d8..bedc7118 100644 --- a/Sources/PenpotAPI/Client/PenpotAPIError.swift +++ b/Sources/PenpotAPI/Client/PenpotAPIError.swift @@ -36,6 +36,11 @@ public struct PenpotAPIError: LocalizedError, Sendable { "The requested resource was not found. Verify the file UUID is correct." case 429: "Rate limited by Penpot API. The request was retried but still failed. Try again later." + case 500 ... 599: + "Penpot server error. This may be temporary — try again in a few minutes. " + + "If the problem persists, check your Penpot instance status." + case 0: + "A network error occurred. Check your internet connection and verify the Penpot base URL is correct." default: nil } diff --git a/Sources/PenpotAPI/Client/PenpotClient.swift b/Sources/PenpotAPI/Client/PenpotClient.swift index 7fcf3fc9..58256945 100644 --- a/Sources/PenpotAPI/Client/PenpotClient.swift +++ b/Sources/PenpotAPI/Client/PenpotClient.swift @@ -32,8 +32,11 @@ public struct BasePenpotClient: PenpotClient { precondition(maxRetries >= 1, "maxRetries must be at least 1") precondition(!accessToken.isEmpty, "accessToken must not be empty") + let normalizedURL = baseURL.hasSuffix("/") ? baseURL : baseURL + "/" + precondition(URL(string: normalizedURL) != nil, "baseURL must be a valid URL: \(baseURL)") + self.accessToken = accessToken - self.baseURL = baseURL.hasSuffix("/") ? baseURL : baseURL + "/" + self.baseURL = normalizedURL self.maxRetries = maxRetries let config = URLSessionConfiguration.ephemeral @@ -68,17 +71,30 @@ public struct BasePenpotClient: PenpotClient { ) } - return try endpoint.content(from: data) + do { + return try endpoint.content(from: data) + } catch let error as PenpotAPIError { + throw error + } catch { + throw PenpotAPIError( + statusCode: httpResponse.statusCode, + message: "Failed to decode response for '\(endpoint.commandName)': \(error.localizedDescription)", + endpoint: endpoint.commandName + ) + } } public func download(path: String) async throws -> Data { - guard let url = URL(string: baseURL + path) else { + let isAbsolute = path.hasPrefix("http://") || path.hasPrefix("https://") + let urlString = isAbsolute ? path : baseURL + path + guard let url = URL(string: urlString) else { throw PenpotAPIError(statusCode: 0, message: "Invalid download URL: \(path)", endpoint: "download") } var request = URLRequest(url: url) request.httpMethod = "GET" - request.setValue("Token \(accessToken)", forHTTPHeaderField: "Authorization") + // Penpot asset storage (S3/MinIO) uses presigned URLs that conflict + // with an Authorization header. Do not send auth for asset downloads. let (data, response) = try await performWithRetry(request: request, endpoint: "download") @@ -123,17 +139,19 @@ public struct BasePenpotClient: PenpotClient { // Retry on 429 or 5xx if statusCode == 429 || (500 ..< 600).contains(statusCode) { - lastError = PenpotAPIError( + let error = PenpotAPIError( statusCode: statusCode, message: String(data: data, encoding: .utf8), endpoint: endpoint ) if attempt < maxRetries - 1 { + lastError = error let delay = pow(2.0, Double(attempt)) // 1s, 2s, 4s try await Task.sleep(for: .seconds(delay)) continue } + throw error } } diff --git a/Sources/PenpotAPI/Client/PenpotEndpoint.swift b/Sources/PenpotAPI/Client/PenpotEndpoint.swift index b1b191e7..5047a456 100644 --- a/Sources/PenpotAPI/Client/PenpotEndpoint.swift +++ b/Sources/PenpotAPI/Client/PenpotEndpoint.swift @@ -2,16 +2,16 @@ import Foundation /// Protocol for Penpot RPC API endpoints. /// -/// All Penpot API calls are `POST /api/main/methods/` -/// with a JSON body. The legacy path `/api/rpc/command/` -/// is preserved for backward compatibility. +/// All Penpot API calls are `POST /api/main/methods/` with a JSON body. +/// The official docs path `/api/rpc/command/` is equivalent but blocked +/// by Cloudflare on design.penpot.app for programmatic clients. public protocol PenpotEndpoint: Sendable { associatedtype Content: Sendable /// The RPC command name (e.g., "get-file", "get-profile"). var commandName: String { get } - /// Serializes the request body. Returns `nil` for body-less commands. + /// Serializes the request body. Penpot requires at minimum an empty JSON object `{}`. func body() throws -> Data? /// Deserializes the response data into the expected content type. diff --git a/Sources/PenpotAPI/Endpoints/GetFileEndpoint.swift b/Sources/PenpotAPI/Endpoints/GetFileEndpoint.swift index 548be430..c05cc44f 100644 --- a/Sources/PenpotAPI/Endpoints/GetFileEndpoint.swift +++ b/Sources/PenpotAPI/Endpoints/GetFileEndpoint.swift @@ -12,6 +12,7 @@ public struct GetFileEndpoint: PenpotEndpoint { private let fileId: String public init(fileId: String) { + precondition(!fileId.isEmpty, "fileId must not be empty") self.fileId = fileId } diff --git a/Sources/PenpotAPI/Endpoints/GetProfileEndpoint.swift b/Sources/PenpotAPI/Endpoints/GetProfileEndpoint.swift index a2125c6a..ceec423f 100644 --- a/Sources/PenpotAPI/Endpoints/GetProfileEndpoint.swift +++ b/Sources/PenpotAPI/Endpoints/GetProfileEndpoint.swift @@ -4,7 +4,7 @@ import YYJSON /// Retrieves the authenticated user's profile. /// /// Command: `get-profile` -/// Body: none +/// Body: `{}` (empty JSON object — Penpot requires non-nil body) public struct GetProfileEndpoint: PenpotEndpoint { public typealias Content = PenpotProfile diff --git a/Sources/PenpotAPI/Models/PenpotComponent.swift b/Sources/PenpotAPI/Models/PenpotComponent.swift index dbe3e732..68b5b7e8 100644 --- a/Sources/PenpotAPI/Models/PenpotComponent.swift +++ b/Sources/PenpotAPI/Models/PenpotComponent.swift @@ -11,23 +11,13 @@ public struct PenpotComponent: Decodable, Sendable { /// Slash-separated group path (e.g., "Icons/Navigation"). public let path: String? - /// ID of the main instance on the canvas. - public let mainInstanceId: String? - - /// Page UUID where the main instance lives. - public let mainInstancePage: String? - public init( id: String, name: String, - path: String? = nil, - mainInstanceId: String? = nil, - mainInstancePage: String? = nil + path: String? = nil ) { self.id = id self.name = name self.path = path - self.mainInstanceId = mainInstanceId - self.mainInstancePage = mainInstancePage } } diff --git a/Sources/PenpotAPI/Models/PenpotTypography.swift b/Sources/PenpotAPI/Models/PenpotTypography.swift index ee8da7f7..b582bf8a 100644 --- a/Sources/PenpotAPI/Models/PenpotTypography.swift +++ b/Sources/PenpotAPI/Models/PenpotTypography.swift @@ -30,10 +30,10 @@ public struct PenpotTypography: Sendable { /// Font weight (e.g., 400, 700). public let fontWeight: Double? - /// Line height multiplier. + /// Line height value. public let lineHeight: Double? - /// Letter spacing in em. + /// Letter spacing value. public let letterSpacing: Double? public init( diff --git a/Tests/ExFigTests/Input/VariablesSourceResolvedTests.swift b/Tests/ExFigTests/Input/VariablesSourceResolvedTests.swift new file mode 100644 index 00000000..d567d99a --- /dev/null +++ b/Tests/ExFigTests/Input/VariablesSourceResolvedTests.swift @@ -0,0 +1,101 @@ +import ExFigConfig +import ExFigCore +import Testing + +@Suite("VariablesSource resolvedSourceKind") +struct VariablesSourceResolvedSourceKindTests { + @Test("Defaults to figma when no overrides") + func defaultsFigma() { + let source = Common.VariablesSourceImpl( + sourceKind: nil, + penpotSource: nil, + tokensFile: nil, + tokensFileId: "file-id", + tokensCollectionName: "Collection", + lightModeName: "Light", + darkModeName: nil, + lightHCModeName: nil, + darkHCModeName: nil, + primitivesModeName: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + #expect(source.resolvedSourceKind == .figma) + } + + @Test("Auto-detects penpot from penpotSource") + func autoDetectsPenpot() { + let source = Common.VariablesSourceImpl( + sourceKind: nil, + penpotSource: Common.PenpotSource(fileId: "uuid", baseUrl: "https://design.penpot.app/", pathFilter: nil), + tokensFile: nil, + tokensFileId: nil, + tokensCollectionName: nil, + lightModeName: nil, + darkModeName: nil, + lightHCModeName: nil, + darkHCModeName: nil, + primitivesModeName: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + #expect(source.resolvedSourceKind == .penpot) + } + + @Test("Auto-detects tokensFile when set") + func autoDetectsTokensFile() { + let source = Common.VariablesSourceImpl( + sourceKind: nil, + penpotSource: 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 + ) + #expect(source.resolvedSourceKind == .tokensFile) + } + + @Test("Penpot takes priority over tokensFile in auto-detection") + func penpotPriorityOverTokensFile() { + let source = Common.VariablesSourceImpl( + sourceKind: nil, + penpotSource: Common.PenpotSource(fileId: "uuid", baseUrl: "https://design.penpot.app/", pathFilter: 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 + ) + #expect(source.resolvedSourceKind == .penpot) + } + + @Test("Explicit sourceKind overrides auto-detection") + func explicitOverridesAutoDetect() { + let source = Common.VariablesSourceImpl( + sourceKind: Common.SourceKind.figma, + penpotSource: Common.PenpotSource(fileId: "uuid", baseUrl: "https://design.penpot.app/", pathFilter: nil), + tokensFile: nil, + tokensFileId: nil, + tokensCollectionName: nil, + lightModeName: nil, + darkModeName: nil, + lightHCModeName: nil, + darkHCModeName: nil, + primitivesModeName: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + #expect(source.resolvedSourceKind == .figma) + } +} diff --git a/Tests/ExFigTests/Subcommands/PenpotWizardTests.swift b/Tests/ExFigTests/Subcommands/PenpotWizardTests.swift new file mode 100644 index 00000000..0cb2b74a --- /dev/null +++ b/Tests/ExFigTests/Subcommands/PenpotWizardTests.swift @@ -0,0 +1,185 @@ +@testable import ExFigCLI +import ExFigCore +import Testing + +// MARK: - extractPenpotFileId Tests + +@Suite("extractPenpotFileId") +struct ExtractPenpotFileIdTests { + @Test("Extracts UUID from full workspace URL") + func fullWorkspaceURL() { + let url = "https://design.penpot.app/#/workspace/team-123?file-id=abc-def-123&page-id=page-456" + #expect(extractPenpotFileId(from: url) == "abc-def-123") + } + + @Test("Extracts UUID when file-id is last query param") + func fileIdAtEnd() { + let url = "https://design.penpot.app/#/workspace/team?page-id=p1&file-id=a1b2c3d4-e5f6-7890-abcd-ef1234567890" + #expect(extractPenpotFileId(from: url) == "a1b2c3d4-e5f6-7890-abcd-ef1234567890") + } + + @Test("Returns bare UUID as-is") + func bareUUID() { + let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + #expect(extractPenpotFileId(from: uuid) == uuid) + } + + @Test("Returns input as-is when no file-id param") + func noFileIdParam() { + let url = "https://design.penpot.app/#/workspace/team-123?page-id=page-456" + #expect(extractPenpotFileId(from: url) == url) + } + + @Test("Trims whitespace") + func trimWhitespace() { + let input = " abc-def-123 " + #expect(extractPenpotFileId(from: input) == "abc-def-123") + } + + @Test("Self-hosted URL with valid UUID") + func selfHostedURL() { + let url = "https://penpot.mycompany.com/#/workspace/team?file-id=a1b2c3d4-e5f6-7890-abcd-ef1234567890" + #expect(extractPenpotFileId(from: url) == "a1b2c3d4-e5f6-7890-abcd-ef1234567890") + } +} + +// MARK: - applyPenpotResult Tests + +@Suite("InitWizard applyPenpotResult") +struct ApplyPenpotResultTests { + @Test("Removes Figma import and config section") + func removesFigmaSection() { + let result = makePenpotResult() + let output = InitWizard.applyPenpotResult(result, to: iosConfigFileContents) + #expect(!output.contains("import \".exfig/schemas/Figma.pkl\"")) + #expect(!output.contains("figma = new Figma.FigmaConfig {")) + } + + @Test("Inserts penpotSource block into platform entries") + func insertsPenpotSource() { + let result = makePenpotResult() + let output = InitWizard.applyPenpotResult(result, to: iosConfigFileContents) + #expect(output.contains("penpotSource = new Common.PenpotSource {")) + #expect(output.contains("fileId = \"PENPOT_FILE_UUID\"")) + } + + @Test("Includes custom base URL when provided") + func customBaseURL() { + let result = makePenpotResult(penpotBaseURL: "https://penpot.mycompany.com/") + let output = InitWizard.applyPenpotResult(result, to: iosConfigFileContents) + #expect(output.contains("baseUrl = \"https://penpot.mycompany.com/\"")) + } + + @Test("Omits base URL line when nil") + func noBaseURL() { + let result = makePenpotResult(penpotBaseURL: nil) + let output = InitWizard.applyPenpotResult(result, to: iosConfigFileContents) + #expect(!output.contains("baseUrl")) + } + + @Test("Removes unselected asset types") + func removesUnselected() { + let result = makePenpotResult(selectedAssetTypes: [.colors]) + let output = InitWizard.applyPenpotResult(result, to: iosConfigFileContents) + #expect(!output.contains("icons = new")) + #expect(!output.contains("images = new")) + #expect(!output.contains("typography = new")) + } + + @Test("Removes common colors/icons/images/typography sections") + func removesCommonSections() { + let result = makePenpotResult() + let output = InitWizard.applyPenpotResult(result, to: iosConfigFileContents) + #expect(!output.contains("colors = new Common.Colors {")) + #expect(!output.contains("icons = new Common.Icons {")) + #expect(!output.contains("images = new Common.Images {")) + #expect(!output.contains("typography = new Common.Typography {")) + } + + @Test("Output has balanced braces") + func balancedBraces() { + let result = makePenpotResult() + let output = InitWizard.applyPenpotResult(result, to: iosConfigFileContents) + let openCount = output.filter { $0 == "{" }.count + let closeCount = output.filter { $0 == "}" }.count + #expect(openCount == closeCount, "Unbalanced braces: \(openCount) open vs \(closeCount) close") + } + + @Test("Includes penpotSource in icons platform entry") + func iconsPlatformEntryHasPenpotSource() { + let result = makePenpotResult(iconsFrameName: "Icons/Navigation") + let output = InitWizard.applyPenpotResult(result, to: iosConfigFileContents) + // penpotSource block should be inserted into the icons platform entry + #expect(output.contains("penpotSource = new Common.PenpotSource {")) + #expect(output.contains("fileId = \"PENPOT_FILE_UUID\"")) + } + + @Test("Includes penpotSource in images platform entry") + func imagesPlatformEntryHasPenpotSource() { + let result = makePenpotResult(imagesFrameName: "Images/Hero") + let output = InitWizard.applyPenpotResult(result, to: iosConfigFileContents) + #expect(output.contains("penpotSource = new Common.PenpotSource {")) + } + + @Test("Works with Android template") + func androidTemplate() { + let result = makePenpotResult(platform: .android) + let output = InitWizard.applyPenpotResult(result, to: androidConfigFileContents) + #expect(output.contains("penpotSource = new Common.PenpotSource {")) + #expect(!output.contains("figma = new Figma.FigmaConfig {")) + let openCount = output.filter { $0 == "{" }.count + let closeCount = output.filter { $0 == "}" }.count + #expect(openCount == closeCount, "Unbalanced braces: \(openCount) vs \(closeCount)") + } + + @Test("Works with Flutter template") + func flutterTemplate() { + let result = makePenpotResult( + platform: .flutter, + selectedAssetTypes: [.colors, .icons, .images] + ) + let output = InitWizard.applyPenpotResult(result, to: flutterConfigFileContents) + #expect(output.contains("penpotSource = new Common.PenpotSource {")) + let openCount = output.filter { $0 == "{" }.count + let closeCount = output.filter { $0 == "}" }.count + #expect(openCount == closeCount, "Unbalanced braces: \(openCount) vs \(closeCount)") + } + + @Test("Works with Web template") + func webTemplate() { + let result = makePenpotResult( + platform: .web, + selectedAssetTypes: [.colors, .icons, .images] + ) + let output = InitWizard.applyPenpotResult(result, to: webConfigFileContents) + #expect(output.contains("penpotSource = new Common.PenpotSource {")) + let openCount = output.filter { $0 == "{" }.count + let closeCount = output.filter { $0 == "}" }.count + #expect(openCount == closeCount, "Unbalanced braces: \(openCount) vs \(closeCount)") + } + + // MARK: - Helpers + + private func makePenpotResult( + platform: Platform = .ios, + selectedAssetTypes: [InitAssetType] = [.colors, .icons, .images, .typography], + lightFileId: String = "PENPOT_FILE_UUID", + iconsFrameName: String? = nil, + imagesFrameName: String? = nil, + penpotBaseURL: String? = nil + ) -> InitWizardResult { + InitWizardResult( + designSource: .penpot, + platform: platform, + selectedAssetTypes: selectedAssetTypes, + lightFileId: lightFileId, + darkFileId: nil, + iconsFrameName: iconsFrameName, + iconsPageName: nil, + imagesFrameName: imagesFrameName, + imagesPageName: nil, + variablesConfig: nil, + penpotBaseURL: penpotBaseURL + ) + } +} diff --git a/Tests/PenpotAPITests/PenpotAPIErrorTests.swift b/Tests/PenpotAPITests/PenpotAPIErrorTests.swift index 3d85ca7b..6d7c4c14 100644 --- a/Tests/PenpotAPITests/PenpotAPIErrorTests.swift +++ b/Tests/PenpotAPITests/PenpotAPIErrorTests.swift @@ -23,10 +23,16 @@ struct PenpotAPIErrorTests { #expect(error.recoverySuggestion?.contains("Rate") == true) } - @Test("500 error has no recovery suggestion") + @Test("500 error suggests server-side issue") func serverError() { let error = PenpotAPIError(statusCode: 500, message: "Internal error", endpoint: "get-file") - #expect(error.recoverySuggestion == nil) + #expect(error.recoverySuggestion?.contains("server error") == true) + } + + @Test("0 status code suggests network error") + func networkError() { + let error = PenpotAPIError(statusCode: 0, message: nil, endpoint: "get-file") + #expect(error.recoverySuggestion?.contains("network") == true) } @Test("Error description includes endpoint and status code") diff --git a/Tests/PenpotAPITests/PenpotComponentDecodingTests.swift b/Tests/PenpotAPITests/PenpotComponentDecodingTests.swift index b8772f2d..c20ea22d 100644 --- a/Tests/PenpotAPITests/PenpotComponentDecodingTests.swift +++ b/Tests/PenpotAPITests/PenpotComponentDecodingTests.swift @@ -7,17 +7,14 @@ import YYJSON struct PenpotComponentDecodingTests { @Test("Decodes component with camelCase keys") func decodeCamelCase() throws { - let json = Data(( - #"{"id":"c1","name":"arrow-right","path":"Icons/Navigation","# + - #""mainInstanceId":"inst-123","mainInstancePage":"page-456"}"# - ).utf8) + let json = Data( + #"{"id":"c1","name":"arrow-right","path":"Icons/Navigation"}"#.utf8 + ) let comp = try YYJSONDecoder().decode(PenpotComponent.self, from: json) #expect(comp.id == "c1") #expect(comp.name == "arrow-right") #expect(comp.path == "Icons/Navigation") - #expect(comp.mainInstanceId == "inst-123") - #expect(comp.mainInstancePage == "page-456") } @Test("Component with optional fields nil") @@ -30,8 +27,6 @@ struct PenpotComponentDecodingTests { #expect(comp.id == "c2") #expect(comp.name == "star") #expect(comp.path == nil) - #expect(comp.mainInstanceId == nil) - #expect(comp.mainInstancePage == nil) } @Test("Components map from file response") @@ -50,6 +45,5 @@ struct PenpotComponentDecodingTests { let arrow = comps?["comp-uuid-1"] #expect(arrow?.name == "arrow-right") - #expect(arrow?.mainInstanceId == "instance-123") } } From 26f54b879030f67bfcfc69a8a4f7ad8d06fcd2c9 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sun, 22 Mar 2026 14:18:56 +0500 Subject: [PATCH 11/26] feat(cli): make FIGMA_PERSONAL_TOKEN optional and add --source penpot to fetch ExFigOptions.validate() no longer throws when FIGMA_PERSONAL_TOKEN is missing. Token is read lazily and only required when a Figma source is actually used. SourceFactory guards the .figma branch and throws accessTokenNotFound there. Added --source penpot/figma flag and --penpot-base-url to exfig fetch for non-interactive Penpot usage without the wizard. --- CLAUDE.md | 7 ++- .../ExFigCLI/Batch/BatchConfigRunner.swift | 4 +- Sources/ExFigCLI/CLAUDE.md | 2 +- Sources/ExFigCLI/ExFigCommand.swift | 5 ++- Sources/ExFigCLI/Input/DownloadOptions.swift | 21 ++++++++- Sources/ExFigCLI/Input/ExFigOptions.swift | 18 +++++--- .../Input/FaultToleranceOptions.swift | 13 +++++- Sources/ExFigCLI/Source/SourceFactory.swift | 23 +++++----- Sources/ExFigCLI/Subcommands/Download.swift | 5 ++- .../ExFigCLI/Subcommands/DownloadAll.swift | 23 ++++++++-- .../ExFigCLI/Subcommands/DownloadIcons.swift | 5 ++- .../ExFigCLI/Subcommands/DownloadImages.swift | 43 +++++++++++++------ .../Subcommands/DownloadImagesExport.swift | 5 ++- .../ExFigCLI/Subcommands/DownloadTokens.swift | 5 ++- .../Subcommands/DownloadTypography.swift | 5 ++- 15 files changed, 135 insertions(+), 49 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index faca994b..d8347fd4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -303,10 +303,15 @@ FigmaAPI is now an external package (`swift-figma-api`). See its repository for 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. +### Lazy Figma Client Pattern + +`resolveClient(accessToken:...)` accepts `String?`. When nil (no `FIGMA_PERSONAL_TOKEN`), returns a placeholder `FigmaClient(accessToken: "no-token")` that is never called by non-Figma sources. `SourceFactory` guards the `.figma` branch: `guard let client else { throw ExFigError.accessTokenNotFound }`. This avoids making `Client?` cascade through 20+ type signatures. + ### Penpot Source Patterns - `PenpotClientFactory.makeClient(baseURL:)` — shared factory in `Source/PenpotClientFactory.swift`. All Penpot sources use this (NOT a static on any single source). - Dictionary iteration from Penpot API (`colors`, `typographies`, `components`) must be sorted by key for deterministic export order: `.sorted(by: { $0.key < $1.key })`. +- `exfig fetch --source penpot` — `FetchSource` enum in `DownloadOptions.swift`. Route: `--source` flag > wizard result > default `.figma`. Also `--penpot-base-url` for self-hosted. ### Entry Bridge Source Kind Resolution @@ -451,7 +456,7 @@ NooraUI.formatLink("url", useColors: true) // underlined primary | DocC articles not in Bundle.module | `.docc` articles aren't copied to SPM bundle — use `Resources/Guides/` with `.copy()` for MCP-served content | | Penpot `update-file` changes format | Flat `changes[]` array, `type` dispatch, needs `vern` field. Shapes need `parentId`, `frameId`, `selrect`, `points`, `transform`. Undocumented — use validation errors | | Switch expression + `return` | When any switch branch has side-effects before `return`, use explicit `return` on ALL branches — implicit return breaks type inference | -| `FIGMA_PERSONAL_TOKEN` for Penpot | `ExFigOptions.validate()` requires it even for Penpot-only configs — pass dummy value for testing | +| Lazy Figma token validation | `ExFigOptions.validate()` reads token without throwing; `resolveClient()` returns placeholder if nil; `SourceFactory` guards `.figma` branch with `accessTokenNotFound` | | PKL `swiftuiColorSwift` casing | PKL codegen lowercases: `swiftuiColorSwift`, not `swiftUIColorSwift` — check with `pkl eval` if unsure | ## Additional Rules diff --git a/Sources/ExFigCLI/Batch/BatchConfigRunner.swift b/Sources/ExFigCLI/Batch/BatchConfigRunner.swift index 6515116b..b5482772 100644 --- a/Sources/ExFigCLI/Batch/BatchConfigRunner.swift +++ b/Sources/ExFigCLI/Batch/BatchConfigRunner.swift @@ -307,8 +307,8 @@ struct BatchConfigRunner: Sendable { let effectiveTimeout: TimeInterval? = cliTimeout.map { TimeInterval($0) } ?? options.params.figma?.timeout - let baseClient = FigmaClient( - accessToken: options.accessToken, + let baseClient = try FigmaClient( + accessToken: options.requireFigmaToken(), timeout: effectiveTimeout ) diff --git a/Sources/ExFigCLI/CLAUDE.md b/Sources/ExFigCLI/CLAUDE.md index 87feb87d..9d2f1f4f 100644 --- a/Sources/ExFigCLI/CLAUDE.md +++ b/Sources/ExFigCLI/CLAUDE.md @@ -30,7 +30,7 @@ exfig mcp → MCPServe Each subcommand composes options via `@OptionGroup`: - `GlobalOptions` — `--verbose/-v`, `--quiet/-q` -- `ExFigOptions` — `--input/-i`, validates PKL config + FIGMA_PERSONAL_TOKEN +- `ExFigOptions` — `--input/-i`, validates PKL config, reads FIGMA_PERSONAL_TOKEN (optional — `String?`, not required for non-Figma sources). Use `requireFigmaToken()` when creating `FigmaClient` directly in Download commands. - `CacheOptions` — `--cache`, `--no-cache`, `--force`, `--experimental-granular-cache` - `FaultToleranceOptions` — retry/timeout configuration diff --git a/Sources/ExFigCLI/ExFigCommand.swift b/Sources/ExFigCLI/ExFigCommand.swift index e8434e68..74e291c9 100644 --- a/Sources/ExFigCLI/ExFigCommand.swift +++ b/Sources/ExFigCLI/ExFigCommand.swift @@ -29,7 +29,7 @@ enum ExFigError: LocalizedError { "Components not found in Figma file" } case .accessTokenNotFound: - "FIGMA_PERSONAL_TOKEN not set" + "FIGMA_PERSONAL_TOKEN is required for Figma sources" case .colorsAssetsFolderNotSpecified: "Config missing: ios.colors.assetsFolder" case let .configurationError(message): @@ -55,7 +55,8 @@ enum ExFigError: LocalizedError { "Publish Components to the Team Library in Figma" } case .accessTokenNotFound: - "Run: export FIGMA_PERSONAL_TOKEN=your_token" + "Run: export FIGMA_PERSONAL_TOKEN=your_token\n" + + "Not needed if all entries use penpotSource or tokensFile." case .colorsAssetsFolderNotSpecified: "Add ios.colors.assetsFolder to your config file" case .configurationError, .custom: diff --git a/Sources/ExFigCLI/Input/DownloadOptions.swift b/Sources/ExFigCLI/Input/DownloadOptions.swift index a9c4f1f4..fab6293a 100644 --- a/Sources/ExFigCLI/Input/DownloadOptions.swift +++ b/Sources/ExFigCLI/Input/DownloadOptions.swift @@ -45,14 +45,31 @@ extension NameStyle: ExpressibleByArgument { } } +/// Design source for fetch command. +enum FetchSource: String, ExpressibleByArgument, CaseIterable, Sendable { + case figma + case penpot +} + /// CLI options for the download command. -/// All required parameters for downloading images from Figma without a config file. +/// All required parameters for downloading images from Figma or Penpot without a config file. struct DownloadOptions: ParsableArguments { + // MARK: - Source Options + + @Option(name: .long, help: "Design source: figma, penpot. Default: figma") + var source: FetchSource? + + @Option( + name: [.customLong("penpot-base-url")], + help: "Penpot instance base URL (default: design.penpot.app)" + ) + var penpotBaseURL: String? + // MARK: - Required Options @Option( name: [.customLong("file-id"), .customShort("f")], - help: "Figma file ID (from the URL: figma.com/file//...)" + help: "File ID (Figma file key or Penpot file UUID)" ) var fileId: String? diff --git a/Sources/ExFigCLI/Input/ExFigOptions.swift b/Sources/ExFigCLI/Input/ExFigOptions.swift index f43ff532..e9970165 100644 --- a/Sources/ExFigCLI/Input/ExFigOptions.swift +++ b/Sources/ExFigCLI/Input/ExFigOptions.swift @@ -25,8 +25,8 @@ struct ExFigOptions: ParsableArguments { // MARK: - Validated Properties /// Figma personal access token from FIGMA_PERSONAL_TOKEN environment variable. - /// Populated during `validate()`. - private(set) var accessToken: String! + /// Populated during `validate()`. May be `nil` if not set (validated lazily when needed). + private(set) var accessToken: String? /// Parsed configuration from the PKL input file. /// Populated during `validate()`. @@ -35,15 +35,21 @@ struct ExFigOptions: ParsableArguments { // MARK: - Validation mutating func validate() throws { - guard let token = ProcessInfo.processInfo.environment["FIGMA_PERSONAL_TOKEN"] else { - throw ExFigError.accessTokenNotFound - } - accessToken = token + accessToken = ProcessInfo.processInfo.environment["FIGMA_PERSONAL_TOKEN"] let configPath = try resolveConfigPath() params = try readParams(at: configPath) } + /// Returns the Figma access token, or throws if not set. + /// Call this only when the current operation requires Figma API access. + func requireFigmaToken() throws -> String { + guard let accessToken else { + throw ExFigError.accessTokenNotFound + } + return accessToken + } + // MARK: - Private Helpers private func resolveConfigPath() throws -> String { diff --git a/Sources/ExFigCLI/Input/FaultToleranceOptions.swift b/Sources/ExFigCLI/Input/FaultToleranceOptions.swift index 17c95b5e..1e9d64ab 100644 --- a/Sources/ExFigCLI/Input/FaultToleranceOptions.swift +++ b/Sources/ExFigCLI/Input/FaultToleranceOptions.swift @@ -253,7 +253,7 @@ struct HeavyFaultToleranceOptions: ParsableArguments { /// /// Timeout precedence: CLI `--timeout` > config > FigmaClient default (30s) func resolveClient( - accessToken: String, + accessToken: String?, timeout: TimeInterval?, options: FaultToleranceOptions, ui: TerminalUI @@ -261,6 +261,12 @@ func resolveClient( if let injectedClient = InjectedClientStorage.client { return injectedClient } + guard let accessToken else { + // No Figma token — return a placeholder client. + // Non-Figma sources (Penpot, tokens-file) never call it. + // SourceFactory guards the .figma branch and throws accessTokenNotFound. + return FigmaClient(accessToken: "no-token", timeout: nil) + } // CLI timeout takes precedence over config timeout let effectiveTimeout: TimeInterval? = options.timeout.map { TimeInterval($0) } ?? timeout let baseClient = FigmaClient(accessToken: accessToken, timeout: effectiveTimeout) @@ -293,7 +299,7 @@ func resolveClient( /// /// Timeout precedence: CLI `--timeout` > config > FigmaClient default (30s) func resolveClient( - accessToken: String, + accessToken: String?, timeout: TimeInterval?, options: HeavyFaultToleranceOptions, ui: TerminalUI @@ -301,6 +307,9 @@ func resolveClient( if let injectedClient = InjectedClientStorage.client { return injectedClient } + guard let accessToken else { + return FigmaClient(accessToken: "no-token", timeout: nil) + } // CLI timeout takes precedence over config timeout let effectiveTimeout: TimeInterval? = options.timeout.map { TimeInterval($0) } ?? timeout let baseClient = FigmaClient(accessToken: accessToken, timeout: effectiveTimeout) diff --git a/Sources/ExFigCLI/Source/SourceFactory.swift b/Sources/ExFigCLI/Source/SourceFactory.swift index 21e97639..201f74e1 100644 --- a/Sources/ExFigCLI/Source/SourceFactory.swift +++ b/Sources/ExFigCLI/Source/SourceFactory.swift @@ -7,17 +7,18 @@ import Logging enum SourceFactory { static func createColorsSource( for input: ColorsSourceInput, - client: Client, + client: Client?, ui: TerminalUI, filter: String? ) throws -> any ColorsSource { switch input.sourceKind { case .figma: - FigmaColorsSource(client: client, ui: ui, filter: filter) + guard let client else { throw ExFigError.accessTokenNotFound } + return FigmaColorsSource(client: client, ui: ui, filter: filter) case .tokensFile: - TokensFileColorsSource(ui: ui) + return TokensFileColorsSource(ui: ui) case .penpot: - PenpotColorsSource(ui: ui) + return PenpotColorsSource(ui: ui) case .tokensStudio, .sketchFile: throw ExFigError.unsupportedSourceKind(input.sourceKind, assetType: "colors") } @@ -26,7 +27,7 @@ enum SourceFactory { // swiftlint:disable:next function_parameter_count static func createComponentsSource( for sourceKind: DesignSourceKind, - client: Client, + client: Client?, params: PKLConfig, platform: Platform, logger: Logger, @@ -34,7 +35,8 @@ enum SourceFactory { ) throws -> any ComponentsSource { switch sourceKind { case .figma: - FigmaComponentsSource( + guard let client else { throw ExFigError.accessTokenNotFound } + return FigmaComponentsSource( client: client, params: params, platform: platform, @@ -42,7 +44,7 @@ enum SourceFactory { filter: filter ) case .penpot: - PenpotComponentsSource(ui: ExFigCommand.terminalUI) + return PenpotComponentsSource(ui: ExFigCommand.terminalUI) case .tokensFile, .tokensStudio, .sketchFile: throw ExFigError.unsupportedSourceKind(sourceKind, assetType: "icons/images") } @@ -50,13 +52,14 @@ enum SourceFactory { static func createTypographySource( for sourceKind: DesignSourceKind, - client: Client + client: Client? ) throws -> any TypographySource { switch sourceKind { case .figma: - FigmaTypographySource(client: client) + guard let client else { throw ExFigError.accessTokenNotFound } + return FigmaTypographySource(client: client) case .penpot: - PenpotTypographySource(ui: ExFigCommand.terminalUI) + return PenpotTypographySource(ui: ExFigCommand.terminalUI) case .tokensFile, .tokensStudio, .sketchFile: throw ExFigError.unsupportedSourceKind(sourceKind, assetType: "typography") } diff --git a/Sources/ExFigCLI/Subcommands/Download.swift b/Sources/ExFigCLI/Subcommands/Download.swift index dc7dc050..02b2fa35 100644 --- a/Sources/ExFigCLI/Subcommands/Download.swift +++ b/Sources/ExFigCLI/Subcommands/Download.swift @@ -96,7 +96,10 @@ extension ExFigCommand.Download { ExFigCommand.initializeTerminalUI(verbose: globalOptions.verbose, quiet: globalOptions.quiet) let ui = ExFigCommand.terminalUI! - let baseClient = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma?.timeout) + let baseClient = try FigmaClient( + accessToken: options.requireFigmaToken(), + timeout: options.params.figma?.timeout + ) let rateLimiter = faultToleranceOptions.createRateLimiter() let client = faultToleranceOptions.createRateLimitedClient( wrapping: baseClient, diff --git a/Sources/ExFigCLI/Subcommands/DownloadAll.swift b/Sources/ExFigCLI/Subcommands/DownloadAll.swift index e12eaf36..e95d8dd8 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadAll.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadAll.swift @@ -59,8 +59,12 @@ extension ExFigCommand.Download { // MARK: - Export Methods + // swiftlint:disable:next function_body_length private func exportColors(outputDir: URL, ui: TerminalUI) async throws { - let client = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma?.timeout) + let client = try FigmaClient( + accessToken: options.requireFigmaToken(), + timeout: options.params.figma?.timeout + ) let figmaParams = options.params.figma let commonParams = options.params.common @@ -127,7 +131,10 @@ extension ExFigCommand.Download { } private func exportTypography(outputDir: URL, ui: TerminalUI) async throws { - let client = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma?.timeout) + let client = try FigmaClient( + accessToken: options.requireFigmaToken(), + timeout: options.params.figma?.timeout + ) guard let figmaParams = options.params.figma else { throw ExFigError.custom(errorString: "figma section is required for typography export.") } @@ -163,8 +170,12 @@ extension ExFigCommand.Download { } } + // swiftlint:disable:next function_body_length private func exportIcons(outputDir: URL, ui: TerminalUI) async throws { - let client = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma?.timeout) + let client = try FigmaClient( + accessToken: options.requireFigmaToken(), + timeout: options.params.figma?.timeout + ) guard let figmaParams = options.params.figma else { throw ExFigError.custom(errorString: "figma section is required for icons export.") } @@ -233,8 +244,12 @@ extension ExFigCommand.Download { } } + // swiftlint:disable:next function_body_length private func exportImages(outputDir: URL, ui: TerminalUI) async throws { - let client = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma?.timeout) + let client = try FigmaClient( + accessToken: options.requireFigmaToken(), + timeout: options.params.figma?.timeout + ) guard let figmaParams = options.params.figma else { throw ExFigError.custom(errorString: "figma section is required for images export.") } diff --git a/Sources/ExFigCLI/Subcommands/DownloadIcons.swift b/Sources/ExFigCLI/Subcommands/DownloadIcons.swift index 47c479ba..2aef4ff6 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadIcons.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadIcons.swift @@ -63,7 +63,10 @@ extension ExFigCommand.Download { ExFigCommand.initializeTerminalUI(verbose: globalOptions.verbose, quiet: globalOptions.quiet) let ui = ExFigCommand.terminalUI! - let baseClient = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma?.timeout) + let baseClient = try FigmaClient( + accessToken: options.requireFigmaToken(), + timeout: options.params.figma?.timeout + ) let rateLimiter = faultToleranceOptions.createRateLimiter() let client = faultToleranceOptions.createRateLimitedClient( wrapping: baseClient, diff --git a/Sources/ExFigCLI/Subcommands/DownloadImages.swift b/Sources/ExFigCLI/Subcommands/DownloadImages.swift index e3e4391a..56f596ae 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadImages.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadImages.swift @@ -11,9 +11,9 @@ extension ExFigCommand { struct FetchImages: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "fetch", - abstract: "Downloads images from Figma without config file", + abstract: "Downloads images from Figma or Penpot without config file", discussion: """ - Downloads images from a specific Figma frame to a local directory. + Downloads images from a specific frame to a local directory. All parameters are passed via command-line arguments. When required options (--file-id, --frame, --output) are omitted in an @@ -23,24 +23,22 @@ extension ExFigCommand { # Interactive wizard (TTY only) exfig fetch - # Download PNGs at 3x scale (default) - exfig fetch --file-id abc123 --frame "Illustrations" --output ./images + # Download PNGs from Figma at 3x scale (default) + exfig fetch -f abc123 -r "Illustrations" -o ./images # Download SVGs exfig fetch -f abc123 -r "Icons" -o ./icons --format svg - # Download PDFs - exfig fetch -f abc123 -r "Icons" -o ./icons --format pdf + # Download from Penpot + exfig fetch --source penpot -f -r "Icons" -o ./icons + + # Download from self-hosted Penpot + exfig fetch --source penpot --penpot-base-url https://penpot.mycompany.com/ \\ + -f -r "UI" -o ./components # Download with filtering exfig fetch -f abc123 -r "Images" -o ./images --filter "logo/*" - # Download PNG at 2x scale with camelCase naming - exfig fetch -f abc123 -r "Images" -o ./images --scale 2 --name-style camelCase - - # Download with dark mode variants - exfig fetch -f abc123 -r "Images" -o ./images --dark-mode-suffix "_dark" - # Download as WebP with quality settings exfig fetch -f abc123 -r "Images" -o ./images --format webp --webp-quality 90 """ @@ -115,9 +113,26 @@ extension ExFigCommand { } } + // Determine design source: from wizard, from --source flag, or default figma + let designSource: WizardDesignSource = wizardResult?.designSource + ?? options.source.map { $0 == .penpot ? .penpot : .figma } + ?? .figma + // Penpot path — use PenpotAPI directly - if let result = wizardResult, result.designSource == .penpot { - try await runPenpotFetch(options: options, wizardResult: result, ui: ui) + if designSource == .penpot { + let penpotResult = wizardResult ?? FetchWizardResult( + designSource: .penpot, + fileId: options.fileId ?? "", + frameName: options.frameName ?? "", + pageName: options.pageName, + outputPath: options.outputPath ?? "", + format: options.format, + scale: options.scale, + nameStyle: options.nameStyle, + filter: options.filter, + penpotBaseURL: options.penpotBaseURL + ) + try await runPenpotFetch(options: options, wizardResult: penpotResult, ui: ui) return } diff --git a/Sources/ExFigCLI/Subcommands/DownloadImagesExport.swift b/Sources/ExFigCLI/Subcommands/DownloadImagesExport.swift index 2b48f665..305fb5f6 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadImagesExport.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadImagesExport.swift @@ -39,7 +39,10 @@ extension ExFigCommand.Download { ExFigCommand.initializeTerminalUI(verbose: globalOptions.verbose, quiet: globalOptions.quiet) let ui = ExFigCommand.terminalUI! - let baseClient = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma?.timeout) + let baseClient = try FigmaClient( + accessToken: options.requireFigmaToken(), + timeout: options.params.figma?.timeout + ) let rateLimiter = faultToleranceOptions.createRateLimiter() let client = faultToleranceOptions.createRateLimitedClient( wrapping: baseClient, diff --git a/Sources/ExFigCLI/Subcommands/DownloadTokens.swift b/Sources/ExFigCLI/Subcommands/DownloadTokens.swift index 2d39bb02..73f1883a 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadTokens.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadTokens.swift @@ -29,7 +29,10 @@ extension ExFigCommand.Download { ExFigCommand.initializeTerminalUI(verbose: globalOptions.verbose, quiet: globalOptions.quiet) let ui = ExFigCommand.terminalUI! - let baseClient = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma?.timeout) + let baseClient = try FigmaClient( + accessToken: options.requireFigmaToken(), + timeout: options.params.figma?.timeout + ) let rateLimiter = faultToleranceOptions.createRateLimiter() let client = faultToleranceOptions.createRateLimitedClient( wrapping: baseClient, diff --git a/Sources/ExFigCLI/Subcommands/DownloadTypography.swift b/Sources/ExFigCLI/Subcommands/DownloadTypography.swift index 2dba1c13..e5646ffd 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadTypography.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadTypography.swift @@ -29,7 +29,10 @@ extension ExFigCommand.Download { ExFigCommand.initializeTerminalUI(verbose: globalOptions.verbose, quiet: globalOptions.quiet) let ui = ExFigCommand.terminalUI! - let baseClient = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma?.timeout) + let baseClient = try FigmaClient( + accessToken: options.requireFigmaToken(), + timeout: options.params.figma?.timeout + ) let rateLimiter = faultToleranceOptions.createRateLimiter() let client = faultToleranceOptions.createRateLimitedClient( wrapping: baseClient, From de3ef65f4adffc79abe42f2e7beb95db93e507fa Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sun, 22 Mar 2026 17:15:45 +0500 Subject: [PATCH 12/26] feat(penpot): add SVG reconstruction from shape tree for icon/image export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace thumbnail-based component export with SVG reconstruction from Penpot's shape tree data. This enables vector export (SVG/PNG/PDF) at any scale without headless Chrome or CDN — shapes are parsed directly from the get-file API response. - Add PenpotShape model for shape tree decoding (path, rect, circle, bool, group, frame) - Add PenpotShapeRenderer — pure function that converts shape tree → SVG string - Add PenpotPage/SVGAttributes models, extend PenpotFileData with pagesIndex - Rewrite PenpotComponentsSource to use SVG reconstruction instead of thumbnails - Update runPenpotFetch to support --format svg/png with SVG→PNG via resvg - Wire SourceFactory through all remaining platform exports (Android/Flutter/Web icons + all images) - Restore mainInstanceId/mainInstancePage on PenpotComponent for shape tree lookup - Handle mixed-type svgAttrs (strings + nested style dicts) via SVGAttributes wrapper --- CLAUDE.md | 1 + Sources/ExFigCLI/CLAUDE.md | 2 +- .../Source/PenpotComponentsSource.swift | 74 +++-- .../ExFigCLI/Subcommands/DownloadImages.swift | 80 +++-- .../Export/PluginIconsExport.swift | 12 +- .../Export/PluginImagesExport.swift | 16 +- Sources/PenpotAPI/CLAUDE.md | 11 + .../PenpotAPI/Models/PenpotComponent.swift | 12 +- .../PenpotAPI/Models/PenpotFileResponse.swift | 4 + Sources/PenpotAPI/Models/PenpotShape.swift | 150 +++++++++ .../Renderer/PenpotShapeRenderer.swift | 314 ++++++++++++++++++ .../PenpotComponentDecodingTests.swift | 11 +- .../PenpotShapeRendererTests.swift | 234 +++++++++++++ 13 files changed, 846 insertions(+), 75 deletions(-) create mode 100644 Sources/PenpotAPI/Models/PenpotShape.swift create mode 100644 Sources/PenpotAPI/Renderer/PenpotShapeRenderer.swift create mode 100644 Tests/PenpotAPITests/PenpotShapeRendererTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index d8347fd4..62d8cbda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -458,6 +458,7 @@ NooraUI.formatLink("url", useColors: true) // underlined primary | Switch expression + `return` | When any switch branch has side-effects before `return`, use explicit `return` on ALL branches — implicit return breaks type inference | | Lazy Figma token validation | `ExFigOptions.validate()` reads token without throwing; `resolveClient()` returns placeholder if nil; `SourceFactory` guards `.figma` branch with `accessTokenNotFound` | | PKL `swiftuiColorSwift` casing | PKL codegen lowercases: `swiftuiColorSwift`, not `swiftUIColorSwift` — check with `pkl eval` if unsure | +| Penpot `svgAttrs` decoding | `svgAttrs` contains mixed types (strings + nested dicts) — use `SVGAttributes` wrapper that extracts string values only, not `[String: String]` | ## Additional Rules diff --git a/Sources/ExFigCLI/CLAUDE.md b/Sources/ExFigCLI/CLAUDE.md index 9d2f1f4f..158b5303 100644 --- a/Sources/ExFigCLI/CLAUDE.md +++ b/Sources/ExFigCLI/CLAUDE.md @@ -215,7 +215,7 @@ DocC `.docc` articles are NOT accessible via `Bundle.module` at runtime — must `ColorsExportContextImpl.loadColors()` creates source via `SourceFactory.createColorsSource(for:...)` per call. `IconsExportContextImpl` / `ImagesExportContextImpl` use injected `componentsSource` (Figma and Penpot supported via `SourceFactory`). `PluginColorsExport` does NOT create sources — context handles dispatch internally. -**Source dispatch gap:** `PluginIconsExport` iOS uses `SourceFactory`; Android/Flutter/Web still hardcode `FigmaComponentsSource`. `PluginImagesExport` all platforms hardcoded. +All platforms (iOS/Android/Flutter/Web) use `SourceFactory.createComponentsSource(for:)` in both `PluginIconsExport` and `PluginImagesExport`. When adding a new source kind: update `SourceFactory`, add source impl in `Source/`, update error `assetType`. Penpot sources create `BasePenpotClient` internally from `PENPOT_ACCESS_TOKEN` env var (like TokensFileSource reads local files — no injected client). diff --git a/Sources/ExFigCLI/Source/PenpotComponentsSource.swift b/Sources/ExFigCLI/Source/PenpotComponentsSource.swift index ad8f4d81..e10c4691 100644 --- a/Sources/ExFigCLI/Source/PenpotComponentsSource.swift +++ b/Sources/ExFigCLI/Source/PenpotComponentsSource.swift @@ -6,21 +6,12 @@ struct PenpotComponentsSource: ComponentsSource { let ui: TerminalUI func loadIcons(from input: IconsSourceInput) async throws -> IconsLoadOutput { - // Warn about raster-only limitation when SVG requested - if input.format == .svg { - ui.warning( - "Penpot API provides raster thumbnails only — SVG format is not available. " + - "Icons will be exported as PNG thumbnails." - ) - } - let packs = try await loadComponents( fileId: input.figmaFileId, baseURL: input.penpotBaseURL, pathFilter: input.frameName, sourceKind: input.sourceKind ) - return IconsLoadOutput(light: packs) } @@ -31,7 +22,6 @@ struct PenpotComponentsSource: ComponentsSource { pathFilter: input.frameName, sourceKind: input.sourceKind ) - return ImagesLoadOutput(light: packs) } @@ -71,42 +61,60 @@ struct PenpotComponentsSource: ComponentsSource { return [] } - // Get thumbnails for matched components - let objectIds = sortedComponents.map(\.id) - let thumbnails = try await client.request( - GetFileObjectThumbnailsEndpoint(fileId: fileId, objectIds: objectIds) + let packs = try reconstructSVGs( + components: sortedComponents, + fileResponse: fileResponse, + fileId: fileId ) + if packs.isEmpty, !sortedComponents.isEmpty { + ui.warning( + "Found \(sortedComponents.count) components but could not reconstruct SVG for any. " + + "Components may lack mainInstanceId (not opened in Penpot editor)." + ) + } + + return packs + } + + private func reconstructSVGs( + components: [PenpotComponent], + fileResponse: PenpotFileResponse, + fileId: String + ) throws -> [ImagePack] { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("exfig-penpot-\(ProcessInfo.processInfo.processIdentifier)") + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + var packs: [ImagePack] = [] - for component in sortedComponents { - guard let thumbnailRef = thumbnails[component.id] else { - ui.warning("Component '\(component.name)' has no thumbnail — skipping") + for component in components { + guard let pageId = component.mainInstancePage, + let instanceId = component.mainInstanceId, + let page = fileResponse.data.pagesIndex?[pageId], + let objects = page.objects + else { + ui.warning("Component '\(component.name)' has no shape data — skipping") continue } - // Build download URL for the thumbnail - let fullURL: String = if thumbnailRef.hasPrefix("http") { - thumbnailRef - } else { - "\(effectiveBaseURL)assets/by-id/\(thumbnailRef)" - } - - guard let url = URL(string: fullURL) else { - ui.warning("Component '\(component.name)' has invalid thumbnail URL — skipping") + guard let svgString = PenpotShapeRenderer.renderSVG( + objects: objects, rootId: instanceId + ) else { + ui.warning("Component '\(component.name)' — failed to reconstruct SVG, skipping") continue } - let image = Image( - name: component.name, - scale: .individual(1.0), - url: url, - format: "png" - ) + let svgData = Data(svgString.utf8) + let safeName = component.name + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: " ", with: "_") + let tempURL = tempDir.appendingPathComponent("\(safeName).svg") + try svgData.write(to: tempURL) packs.append(ImagePack( name: component.name, - images: [image], + images: [Image(name: component.name, scale: .all, url: tempURL, format: "svg")], nodeId: component.id, fileId: fileId )) diff --git a/Sources/ExFigCLI/Subcommands/DownloadImages.swift b/Sources/ExFigCLI/Subcommands/DownloadImages.swift index 56f596ae..b1ba2e09 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadImages.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadImages.swift @@ -321,7 +321,7 @@ extension ExFigCommand { // MARK: - Penpot Fetch - // swiftlint:disable function_body_length + // swiftlint:disable function_body_length cyclomatic_complexity private func runPenpotFetch( options: DownloadOptions, wizardResult: FetchWizardResult, @@ -356,10 +356,12 @@ extension ExFigCommand { } // Filter by path prefix - let matched = components.values.filter { comp in - guard let path = comp.path else { return false } - return path.hasPrefix(frameName) - } + let matched = components.values + .filter { comp in + guard let path = comp.path else { return false } + return path.hasPrefix(frameName) + } + .sorted { $0.name < $1.name } guard !matched.isEmpty else { ui.warning("No components matching path '\(frameName)' found") @@ -368,42 +370,60 @@ extension ExFigCommand { ui.info("Found \(matched.count) components") - // Get thumbnails - let objectIds = matched.map(\.id) - let thumbnails = try await ui.withSpinner("Fetching thumbnails...") { - try await client.request( - GetFileObjectThumbnailsEndpoint(fileId: fileId, objectIds: objectIds) - ) - } + // Reconstruct SVG from shape tree + let format = options.format ?? .svg + var exportedCount = 0 - // Download each thumbnail - var downloadedCount = 0 for component in matched { - guard let thumbnailRef = thumbnails[component.id] else { - ui.warning("Component '\(component.name)' has no thumbnail — skipping") + guard let pageId = component.mainInstancePage, + let instanceId = component.mainInstanceId, + let page = fileResponse.data.pagesIndex?[pageId], + let objects = page.objects + else { + ui.warning("Component '\(component.name)' has no shape data — skipping") continue } - let downloadPath: String = if thumbnailRef.hasPrefix("http") { - thumbnailRef - } else { - "assets/by-id/\(thumbnailRef)" + guard let svgString = PenpotShapeRenderer.renderSVG( + objects: objects, rootId: instanceId + ) else { + ui.warning("Component '\(component.name)' — SVG reconstruction failed, skipping") + continue } - let data = try await client.download(path: downloadPath) - let fileName = "\(component.name).png" - let fileURL = outputURL.appendingPathComponent(fileName) - try data.write(to: fileURL) - downloadedCount += 1 + let svgData = Data(svgString.utf8) + let safeName = component.name + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: " ", with: "_") + + switch format { + case .svg: + let fileURL = outputURL.appendingPathComponent("\(safeName).svg") + try svgData.write(to: fileURL) + case .png: + let scale = options.scale ?? 3.0 + let converter = SvgToPngConverter() + let pngData = try converter.convert(svgData: svgData, scale: scale, fileName: safeName) + let fileURL = outputURL.appendingPathComponent("\(safeName).png") + try pngData.write(to: fileURL) + default: + // For other formats (pdf, webp, jpg), save as SVG + let fileURL = outputURL.appendingPathComponent("\(safeName).svg") + try svgData.write(to: fileURL) + } + + exportedCount += 1 } - if downloadedCount > 0 { - ui.success("Downloaded \(downloadedCount) components to \(outputURL.path)") - } else { + if exportedCount > 0 { ui - .warning( - "No thumbnails available for download (components may need to be opened in Penpot editor first)" + .success( + "Exported \(exportedCount) components as \(format.rawValue.uppercased()) to \(outputURL.path)" ) + } else { + ui.warning( + "No components could be exported. Components may lack mainInstanceId (not opened in Penpot editor)." + ) } } diff --git a/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift b/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift index 3ea810a8..89468fff 100644 --- a/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift +++ b/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift @@ -118,7 +118,9 @@ extension ExFigCommand.ExportIcons { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() - let componentsSource = FigmaComponentsSource( + let sourceKind = entries.first?.resolvedSourceKind ?? .figma + let componentsSource = try SourceFactory.createComponentsSource( + for: sourceKind, client: client, params: params, platform: .android, @@ -171,7 +173,9 @@ extension ExFigCommand.ExportIcons { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() - let componentsSource = FigmaComponentsSource( + let sourceKind = entries.first?.resolvedSourceKind ?? .figma + let componentsSource = try SourceFactory.createComponentsSource( + for: sourceKind, client: client, params: params, platform: .flutter, @@ -224,7 +228,9 @@ extension ExFigCommand.ExportIcons { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() - let componentsSource = FigmaComponentsSource( + let sourceKind = entries.first?.resolvedSourceKind ?? .figma + let componentsSource = try SourceFactory.createComponentsSource( + for: sourceKind, client: client, params: params, platform: .web, diff --git a/Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift b/Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift index d5451084..acd5ac22 100644 --- a/Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift +++ b/Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift @@ -38,7 +38,9 @@ extension ExFigCommand.ExportImages { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() - let componentsSource = FigmaComponentsSource( + let sourceKind = entries.first?.resolvedSourceKind ?? .figma + let componentsSource = try SourceFactory.createComponentsSource( + for: sourceKind, client: client, params: params, platform: .ios, @@ -114,7 +116,9 @@ extension ExFigCommand.ExportImages { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() - let componentsSource = FigmaComponentsSource( + let sourceKind = entries.first?.resolvedSourceKind ?? .figma + let componentsSource = try SourceFactory.createComponentsSource( + for: sourceKind, client: client, params: params, platform: .android, @@ -167,7 +171,9 @@ extension ExFigCommand.ExportImages { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() - let componentsSource = FigmaComponentsSource( + let sourceKind = entries.first?.resolvedSourceKind ?? .figma + let componentsSource = try SourceFactory.createComponentsSource( + for: sourceKind, client: client, params: params, platform: .flutter, @@ -220,7 +226,9 @@ extension ExFigCommand.ExportImages { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() - let componentsSource = FigmaComponentsSource( + let sourceKind = entries.first?.resolvedSourceKind ?? .figma + let componentsSource = try SourceFactory.createComponentsSource( + for: sourceKind, client: client, params: params, platform: .web, diff --git a/Sources/PenpotAPI/CLAUDE.md b/Sources/PenpotAPI/CLAUDE.md index 8cddfa93..4918606e 100644 --- a/Sources/PenpotAPI/CLAUDE.md +++ b/Sources/PenpotAPI/CLAUDE.md @@ -42,3 +42,14 @@ If switching to `/api/rpc/command/`, update `BasePenpotClient.buildURL(for:)`. - `download()` must NOT send `Authorization` header — Penpot assets are served via S3/MinIO presigned URLs that conflict with extra auth - Thumbnail download path is `assets/by-id/`, NOT `assets/by-file-media-id/` - `get-file-object-thumbnails` returns compound keys (`fileId/pageId/objectId/type`), not simple component IDs — v1 limitation + +## SVG Reconstruction + +Icons/images exported via shape tree → SVG (no CDN, no headless Chrome): + +- `PenpotShapeRenderer.renderSVG(objects:rootId:)` — pure function, shape tree → SVG string +- Shapes are in canvas-space — subtract root frame's `selrect.x/y` to normalize +- `svgAttrs` has mixed types (string values + nested `style` dict) — `SVGAttributes` type extracts strings only +- Supported shape types: path, rect, circle, bool (compound path), group, frame +- Components need `mainInstanceId` + `mainInstancePage` for shape tree lookup +- Linked libraries: use `get-file` with library file ID (from `get-file-libraries`) diff --git a/Sources/PenpotAPI/Models/PenpotComponent.swift b/Sources/PenpotAPI/Models/PenpotComponent.swift index 68b5b7e8..2aafa41e 100644 --- a/Sources/PenpotAPI/Models/PenpotComponent.swift +++ b/Sources/PenpotAPI/Models/PenpotComponent.swift @@ -11,13 +11,23 @@ public struct PenpotComponent: Decodable, Sendable { /// Slash-separated group path (e.g., "Icons/Navigation"). public let path: String? + /// ID of the main instance shape on the canvas (needed for SVG reconstruction). + public let mainInstanceId: String? + + /// Page UUID where the main instance lives. + public let mainInstancePage: String? + public init( id: String, name: String, - path: String? = nil + path: String? = nil, + mainInstanceId: String? = nil, + mainInstancePage: String? = nil ) { self.id = id self.name = name self.path = path + self.mainInstanceId = mainInstanceId + self.mainInstancePage = mainInstancePage } } diff --git a/Sources/PenpotAPI/Models/PenpotFileResponse.swift b/Sources/PenpotAPI/Models/PenpotFileResponse.swift index a46c0124..22380958 100644 --- a/Sources/PenpotAPI/Models/PenpotFileResponse.swift +++ b/Sources/PenpotAPI/Models/PenpotFileResponse.swift @@ -22,4 +22,8 @@ public struct PenpotFileData: Decodable, Sendable { /// Library components keyed by UUID. public let components: [String: PenpotComponent]? + + /// Pages keyed by page UUID, each containing a flat object tree. + /// Used for SVG reconstruction of component shapes. + public let pagesIndex: [String: PenpotPage]? } diff --git a/Sources/PenpotAPI/Models/PenpotShape.swift b/Sources/PenpotAPI/Models/PenpotShape.swift new file mode 100644 index 00000000..15fd1f9a --- /dev/null +++ b/Sources/PenpotAPI/Models/PenpotShape.swift @@ -0,0 +1,150 @@ +import Foundation + +/// A shape object from a Penpot page's object tree. +/// +/// Shapes represent SVG-compatible design elements (rect, circle, path, group, frame). +/// Coordinates are in canvas-space — subtract the root frame's origin to normalize. +public struct PenpotShape: Decodable, Sendable { + public let id: String + public let name: String? + public let type: String + + // Geometry + public let x: Double? + public let y: Double? + public let width: Double? + public let height: Double? + public let rotation: Double? + public let selrect: Selrect? + + /// SVG path data (for path/bool types) + public let content: ShapeContent? + + // Styling + public let fills: [Fill]? + public let strokes: [Stroke]? + public let svgAttrs: SVGAttributes? + public let hideFillOnExport: Bool? + + /// Tree structure + public let shapes: [String]? + + /// Boolean operations + public let boolType: String? + + // Border radius (for rect type) + public let r1: Double? + public let r2: Double? + public let r3: Double? + public let r4: Double? + + /// Transform matrix + public let transform: Transform? + + public let opacity: Double? + public let hidden: Bool? +} + +// MARK: - Supporting Types + +public extension PenpotShape { + /// Content can be a String (SVG path data) or a structured object (text content). + /// We only care about String paths for SVG reconstruction. + enum ShapeContent: Decodable, Sendable { + case path(String) + case other + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let string = try? container.decode(String.self) { + self = .path(string) + } else { + self = .other + } + } + + public var pathData: String? { + if case let .path(data) = self { return data } + return nil + } + } + + struct Selrect: Decodable, Sendable { + public let x: Double + public let y: Double + public let width: Double + public let height: Double + } + + struct Fill: Decodable, Sendable { + public let fillColor: String? + public let fillOpacity: Double? + } + + struct Stroke: Decodable, Sendable { + public let strokeColor: String? + public let strokeOpacity: Double? + public let strokeWidth: Double? + public let strokeStyle: String? + public let strokeAlignment: String? + public let strokeCapStart: String? + public let strokeCapEnd: String? + } + + struct Transform: Decodable, Sendable { + public let a: Double + public let b: Double + public let c: Double + public let d: Double + public let e: Double + public let f: Double + + /// Whether this is an identity transform. + public var isIdentity: Bool { + a == 1 && b == 0 && c == 0 && d == 1 && e == 0 && f == 0 + } + } + + /// SVG attributes — values can be strings or nested dictionaries. + /// We extract only string values for SVG reconstruction. + struct SVGAttributes: Decodable, Sendable { + public let values: [String: String] + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let raw = try container.decode([String: AnyCodable].self) + var result: [String: String] = [:] + for (key, value) in raw { + if case let .string(s) = value { + result[key] = s + } + } + values = result + } + + public subscript(key: String) -> String? { + values[key] + } + + /// Flexible JSON value that handles strings, numbers, bools, and nested structures. + private enum AnyCodable: Decodable, Sendable { + case string(String) + case other + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let s = try? container.decode(String.self) { + self = .string(s) + } else { + self = .other + } + } + } + } +} + +/// A page from a Penpot file containing a flat object tree. +public struct PenpotPage: Decodable, Sendable { + public let name: String? + public let objects: [String: PenpotShape]? +} diff --git a/Sources/PenpotAPI/Renderer/PenpotShapeRenderer.swift b/Sources/PenpotAPI/Renderer/PenpotShapeRenderer.swift new file mode 100644 index 00000000..6f1ad891 --- /dev/null +++ b/Sources/PenpotAPI/Renderer/PenpotShapeRenderer.swift @@ -0,0 +1,314 @@ +import Foundation + +/// Reconstructs SVG from Penpot shape tree data. +/// +/// Penpot shapes are SVG-compatible objects stored in a flat dictionary keyed by ID. +/// Each shape has canvas-space coordinates that must be normalized relative to the +/// root frame's origin. +public enum PenpotShapeRenderer { + /// Renders a component's shape tree as an SVG string. + /// + /// - Parameters: + /// - objects: Flat dictionary of all shapes on the page (from `PenpotPage.objects`) + /// - rootId: The component's `mainInstanceId` — the root frame shape + /// - Returns: SVG string with coordinates normalized to (0,0), or nil if root not found + public static func renderSVG( + objects: [String: PenpotShape], + rootId: String + ) -> String? { + guard let root = objects[rootId] else { return nil } + guard let selrect = root.selrect else { return nil } + + let originX = selrect.x + let originY = selrect.y + let width = selrect.width + let height = selrect.height + + var svg = """ + + """ + + for childId in root.shapes ?? [] { + svg += renderShape( + id: childId, + objects: objects, + originX: originX, + originY: originY + ) + } + + svg += "\n" + return svg + } + + // MARK: - Private + + private static func renderShape( + id: String, + objects: [String: PenpotShape], + originX: Double, + originY: Double + ) -> String { + guard let shape = objects[id] else { return "" } + if shape.hidden == true { return "" } + + let rotation = shape.rotation ?? 0 + let needsTransform = rotation != 0 + + var result = "" + + // Wrap in if rotated + if needsTransform, let cx = shape.x, let cy = shape.y, + let w = shape.width, let h = shape.height + { + let rcx = formatNumber(cx - originX + w / 2) + let rcy = formatNumber(cy - originY + h / 2) + result += "\n" + } + + switch shape.type { + case "path", "bool": + result += renderPath(shape, originX: originX, originY: originY) + case "rect": + result += renderRect(shape, originX: originX, originY: originY) + case "circle": + result += renderEllipse(shape, originX: originX, originY: originY) + case "group", "frame": + result += renderGroup(shape, objects: objects, originX: originX, originY: originY) + default: + break + } + + if needsTransform { + result += "\n" + } + + return result + } + + private static func renderPath( + _ shape: PenpotShape, + originX: Double, + originY: Double + ) -> String { + guard let pathData = shape.content?.pathData, !pathData.isEmpty else { return "" } + + let normalized = normalizePathCoordinates(pathData, originX: originX, originY: originY) + let attrs = styleAttributes(fills: shape.fills, strokes: shape.strokes, svgAttrs: shape.svgAttrs) + + return "\n" + } + + private static func renderRect( + _ shape: PenpotShape, + originX: Double, + originY: Double + ) -> String { + guard let x = shape.x, let y = shape.y, + let w = shape.width, let h = shape.height else { return "" } + + let nx = formatNumber(x - originX) + let ny = formatNumber(y - originY) + let attrs = styleAttributes(fills: shape.fills, strokes: shape.strokes, svgAttrs: shape.svgAttrs) + + // Border radius — use r1 if all corners are equal, otherwise rx + let rx = shape.r1 ?? 0 + let rxAttr = rx > 0 ? " rx=\"\(formatNumber(rx))\"" : "" + + let size = "width=\"\(formatNumber(w))\" height=\"\(formatNumber(h))\"" + return "\n" + } + + private static func renderEllipse( + _ shape: PenpotShape, + originX: Double, + originY: Double + ) -> String { + guard let x = shape.x, let y = shape.y, + let w = shape.width, let h = shape.height else { return "" } + + let cx = formatNumber(x - originX + w / 2) + let cy = formatNumber(y - originY + h / 2) + let rx = formatNumber(w / 2) + let ry = formatNumber(h / 2) + let attrs = styleAttributes(fills: shape.fills, strokes: shape.strokes, svgAttrs: shape.svgAttrs) + + return "\n" + } + + private static func renderGroup( + _ shape: PenpotShape, + objects: [String: PenpotShape], + originX: Double, + originY: Double + ) -> String { + guard let children = shape.shapes, !children.isEmpty else { return "" } + + let attrs = styleAttributes(fills: shape.fills, strokes: shape.strokes, svgAttrs: shape.svgAttrs) + var result = "\n" + + for childId in children { + result += renderShape(id: childId, objects: objects, originX: originX, originY: originY) + } + + result += "\n" + return result + } + + // MARK: - Style Attributes + + private static func styleAttributes( + fills: [PenpotShape.Fill]?, + strokes: [PenpotShape.Stroke]?, + svgAttrs: PenpotShape.SVGAttributes? + ) -> String { + var attrs: [String] = [] + + // Fill from svgAttrs takes priority (e.g., fill="none" for stroke-only icons) + if let svgFill = svgAttrs?["fill"] { + attrs.append("fill=\"\(svgFill)\"") + } else if let fill = fills?.first, let color = fill.fillColor { + let opacity = fill.fillOpacity ?? 1.0 + attrs.append("fill=\"\(color)\"") + if opacity < 1.0 { + attrs.append("fill-opacity=\"\(formatNumber(opacity))\"") + } + } else if fills?.isEmpty == true { + attrs.append("fill=\"none\"") + } + + // Stroke + if let stroke = strokes?.first, let color = stroke.strokeColor { + attrs.append("stroke=\"\(color)\"") + if let width = stroke.strokeWidth { + attrs.append("stroke-width=\"\(formatNumber(width))\"") + } + if let opacity = stroke.strokeOpacity, opacity < 1.0 { + attrs.append("stroke-opacity=\"\(formatNumber(opacity))\"") + } + if let cap = stroke.strokeCapStart, cap != "butt" { + let svgCap = mapStrokeCap(cap) + attrs.append("stroke-linecap=\"\(svgCap)\"") + } + } + + if attrs.isEmpty { return "" } + return " " + attrs.joined(separator: " ") + } + + private static func mapStrokeCap(_ penpotCap: String) -> String { + switch penpotCap { + case "round", "circle-marker": "round" + case "square": "square" + default: "butt" + } + } + + // MARK: - Coordinate Normalization + + /// Normalizes SVG path data by subtracting the origin offset from all coordinate values. + static func normalizePathCoordinates( + _ pathData: String, + originX: Double, + originY: Double + ) -> String { + // SVG path commands: M, L, C, S, Q, T, A, Z (uppercase = absolute, lowercase = relative) + // Only absolute commands need normalization + var result = "" + var i = pathData.startIndex + var isX = true // alternate X/Y for coordinate pairs + + while i < pathData.endIndex { + let ch = pathData[i] + + if ch.isLetter { + result.append(ch) + // Reset coordinate tracking for new command + isX = true + // Relative commands (lowercase) don't need offset + // Z/z has no coordinates + i = pathData.index(after: i) + continue + } + + if ch == "," || ch == " " { + result.append(ch) + i = pathData.index(after: i) + continue + } + + if ch == "-" || ch == "." || ch.isNumber { + // Parse number + var numStr = "" + var j = i + // Handle negative sign + if pathData[j] == "-" { + numStr.append("-") + j = pathData.index(after: j) + } + // Parse digits and decimal + var hasDot = false + while j < pathData.endIndex { + let c = pathData[j] + if c.isNumber { + numStr.append(c) + } else if c == ".", !hasDot { + hasDot = true + numStr.append(c) + } else { + break + } + j = pathData.index(after: j) + } + + if let value = Double(numStr) { + // Find the last command to check if absolute + let lastCmd = findLastCommand(in: pathData, before: i) + if let cmd = lastCmd, cmd.isUppercase, cmd != "Z", cmd != "A" { + // For A (arc) command, only coordinates 6&7 of each 7-param set need offset + // Simplified: offset all X/Y pairs for non-arc absolute commands + let offset = isX ? originX : originY + result.append(formatNumber(value - offset)) + } else { + result.append(formatNumber(value)) + } + isX.toggle() + } else { + result.append(numStr) + } + i = j + continue + } + + result.append(ch) + i = pathData.index(after: i) + } + + return result + } + + private static func findLastCommand(in path: String, before index: String.Index) -> Character? { + var j = index + while j > path.startIndex { + j = path.index(before: j) + if path[j].isLetter { return path[j] } + } + return nil + } + + // MARK: - Formatting + + private static func formatNumber(_ value: Double) -> String { + if value == value.rounded(), abs(value) < 1_000_000 { + return String(Int(value)) + } + // Round to 2 decimal places to keep SVG compact + let rounded = (value * 100).rounded() / 100 + if rounded == rounded.rounded() { + return String(Int(rounded)) + } + return String(rounded) + } +} diff --git a/Tests/PenpotAPITests/PenpotComponentDecodingTests.swift b/Tests/PenpotAPITests/PenpotComponentDecodingTests.swift index c20ea22d..8e6e8a9d 100644 --- a/Tests/PenpotAPITests/PenpotComponentDecodingTests.swift +++ b/Tests/PenpotAPITests/PenpotComponentDecodingTests.swift @@ -7,14 +7,17 @@ import YYJSON struct PenpotComponentDecodingTests { @Test("Decodes component with camelCase keys") func decodeCamelCase() throws { - let json = Data( - #"{"id":"c1","name":"arrow-right","path":"Icons/Navigation"}"#.utf8 - ) + let json = Data(( + #"{"id":"c1","name":"arrow-right","path":"Icons/Navigation","# + + #""mainInstanceId":"inst-123","mainInstancePage":"page-456"}"# + ).utf8) let comp = try YYJSONDecoder().decode(PenpotComponent.self, from: json) #expect(comp.id == "c1") #expect(comp.name == "arrow-right") #expect(comp.path == "Icons/Navigation") + #expect(comp.mainInstanceId == "inst-123") + #expect(comp.mainInstancePage == "page-456") } @Test("Component with optional fields nil") @@ -27,6 +30,7 @@ struct PenpotComponentDecodingTests { #expect(comp.id == "c2") #expect(comp.name == "star") #expect(comp.path == nil) + #expect(comp.mainInstanceId == nil) } @Test("Components map from file response") @@ -45,5 +49,6 @@ struct PenpotComponentDecodingTests { let arrow = comps?["comp-uuid-1"] #expect(arrow?.name == "arrow-right") + #expect(arrow?.mainInstanceId == "instance-123") } } diff --git a/Tests/PenpotAPITests/PenpotShapeRendererTests.swift b/Tests/PenpotAPITests/PenpotShapeRendererTests.swift new file mode 100644 index 00000000..33177c9e --- /dev/null +++ b/Tests/PenpotAPITests/PenpotShapeRendererTests.swift @@ -0,0 +1,234 @@ +import Foundation +@testable import PenpotAPI +import Testing + +@Suite("PenpotShapeRenderer") +struct PenpotShapeRendererTests { + // MARK: - Basic Rendering + + @Test("Renders simple path icon") + func simplePath() throws { + let objects = makeObjects( + root: makeFrame(id: "root", x: 100, y: 200, width: 16, height: 16, children: ["path1"]), + children: [ + "path1": makePathShape( + content: "M106.0,204.0L108.0,208.0L110.0,204.0", + strokeColor: "#333333" + ), + ] + ) + + let svg = PenpotShapeRenderer.renderSVG(objects: objects, rootId: "root") + let result = try #require(svg) + + #expect(result.contains("viewBox=\"0 0 16 16\"")) + #expect(result.contains(" PenpotShape { + PenpotShape( + id: id, name: "frame", type: "frame", + x: x, y: y, width: width, height: height, rotation: nil, + selrect: .init(x: x, y: y, width: width, height: height), + content: nil, fills: nil, strokes: nil, svgAttrs: nil, hideFillOnExport: nil, + shapes: children, boolType: nil, r1: nil, r2: nil, r3: nil, r4: nil, + transform: nil, opacity: nil, hidden: nil + ) + } + + private func makePathShape(content: String, strokeColor: String) -> PenpotShape { + PenpotShape( + id: UUID().uuidString, name: "path", type: "path", + x: nil, y: nil, width: nil, height: nil, rotation: nil, selrect: nil, + content: .path(content), + fills: [], + strokes: [.init( + strokeColor: strokeColor, + strokeOpacity: 1, + strokeWidth: 1, + strokeStyle: "solid", + strokeAlignment: "center", + strokeCapStart: "round", + strokeCapEnd: "round" + )], + svgAttrs: nil, + hideFillOnExport: nil, + shapes: nil, boolType: nil, r1: nil, r2: nil, r3: nil, r4: nil, + transform: nil, opacity: nil, hidden: nil + ) + } + + private func makeObjects(root: PenpotShape, children: [String: PenpotShape]) -> [String: PenpotShape] { + var objects = children + objects[root.id] = root + return objects + } +} From 975134160caafaf2fc1b3cfc629310872cafd60f Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sun, 22 Mar 2026 17:21:42 +0500 Subject: [PATCH 13/26] =?UTF-8?q?docs(penpot):=20update=20docs=20for=20SVG?= =?UTF-8?q?=20reconstruction=20=E2=80=94=20remove=20raster-only=20limitati?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove "v1 limitation: raster thumbnails only" from DesignRequirements, Configuration, MCP prompts - Add Penpot icons PKL config example to Configuration.md - Add "Penpot Fetch" section to Usage.md with --source penpot examples - Add "Quick Penpot Icons Export" to GettingStarted.md - Update MCP prompt: "SVG reconstructed from shape tree" replaces raster limitation - Update Known Limitations: add SVG reconstruction scope (no blur/shadow/gradients) - Regenerate llms-full.txt --- Sources/ExFigCLI/ExFig.docc/Configuration.md | 24 ++++++- .../ExFigCLI/ExFig.docc/DesignRequirements.md | 10 ++- Sources/ExFigCLI/ExFig.docc/GettingStarted.md | 16 ++++- Sources/ExFigCLI/ExFig.docc/Usage.md | 20 ++++++ Sources/ExFigCLI/MCP/MCPPrompts.swift | 2 +- .../Resources/Guides/DesignRequirements.md | 10 ++- llms-full.txt | 70 ++++++++++++++++--- 7 files changed, 125 insertions(+), 27 deletions(-) diff --git a/Sources/ExFigCLI/ExFig.docc/Configuration.md b/Sources/ExFigCLI/ExFig.docc/Configuration.md index 468ea060..bbd371aa 100644 --- a/Sources/ExFigCLI/ExFig.docc/Configuration.md +++ b/Sources/ExFigCLI/ExFig.docc/Configuration.md @@ -127,6 +127,25 @@ ios = new iOS.iOSConfig { } ``` +**Icons:** + +```pkl +ios = new iOS.iOSConfig { + icons = new Listing { + new iOS.IconsEntry { + penpotSource = new Common.PenpotSource { + fileId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + // pathFilter = "Icons / Actions" // optional: filter by path prefix + } + figmaFrameName = "Icons" // path prefix filter (same field as Figma) + format = "svg" // svg or pdf — SVG reconstructed from shape tree + assetsFolder = "Icons" + nameStyle = "camelCase" + } + } +} +``` + **Typography:** ```pkl @@ -142,9 +161,8 @@ ios = new iOS.iOSConfig { > When `penpotSource` is set, `sourceKind` auto-detects as `"penpot"`. ExFig reads from > the Penpot API and does not require `FIGMA_PERSONAL_TOKEN`. Set `PENPOT_ACCESS_TOKEN` instead. > -> **v1 limitation:** Penpot icons/images are exported as raster thumbnails (the Penpot API has -> no public SVG/PNG render endpoint). SVG reconstruction from the shape tree is planned for a -> future version. +> Icons and images are exported via **SVG reconstruction** from Penpot's shape tree — +> no headless Chrome needed. Supported formats: SVG, PNG (any scale), PDF, WebP. ### Tokens File Source diff --git a/Sources/ExFigCLI/ExFig.docc/DesignRequirements.md b/Sources/ExFigCLI/ExFig.docc/DesignRequirements.md index 765b8179..6f6c9499 100644 --- a/Sources/ExFigCLI/ExFig.docc/DesignRequirements.md +++ b/Sources/ExFigCLI/ExFig.docc/DesignRequirements.md @@ -443,10 +443,8 @@ penpotSource = new Common.PenpotSource { figmaFrameName = "Icons/Navigation" // exports arrow-left, arrow-right ``` -> **v1 limitation:** Penpot components are exported as **raster thumbnails** (PNG only). -> The Penpot API has no public SVG/PNG render endpoint. SVG reconstruction from the -> shape tree is planned for a future version. For best quality, design components at the -> largest needed scale. +ExFig reconstructs SVG directly from Penpot's shape tree — no headless Chrome or CDN needed. +Supported output formats: **SVG** (native vector), **PNG** (via resvg at any scale), **PDF**, **WebP**. ### Library Typography @@ -488,13 +486,13 @@ Design System (Penpot file) └── Caption/* (regular, small) ``` -### Known Limitations (v1) +### Known Limitations - **No dark mode support** — Penpot has no Variables/modes equivalent; colors export as light-only -- **Raster-only components** — icons and images export as PNG thumbnails, not SVG - **No `exfig_inspect` for Penpot** — the MCP inspect tool works with Figma API only - **Gradients skipped** — only solid hex colors are supported - **No page filtering** — all library assets are global to the file, not page-scoped +- **SVG reconstruction scope** — supports path, rect, circle, bool, group shapes; complex effects (blur, shadow, gradients on shapes) are not yet rendered ### Penpot Troubleshooting diff --git a/Sources/ExFigCLI/ExFig.docc/GettingStarted.md b/Sources/ExFigCLI/ExFig.docc/GettingStarted.md index 5b99237f..180ec13c 100644 --- a/Sources/ExFigCLI/ExFig.docc/GettingStarted.md +++ b/Sources/ExFigCLI/ExFig.docc/GettingStarted.md @@ -86,7 +86,21 @@ For Penpot sources, set the `PENPOT_ACCESS_TOKEN` environment variable: export PENPOT_ACCESS_TOKEN="your-penpot-token-here" ``` -> Note: `PENPOT_ACCESS_TOKEN` is only required when using `sourceKind: "penpot"` or `penpotSource` in config. +> Note: `PENPOT_ACCESS_TOKEN` is only required when using `penpotSource` in config. + +### Quick Penpot Icons Export (No Config) + +```bash +# Export Penpot icons as SVG +export PENPOT_ACCESS_TOKEN="your-token" +exfig fetch --source penpot -f "file-uuid" -r "Icons" -o ./icons --format svg + +# Export as PNG at 3x scale +exfig fetch --source penpot -f "file-uuid" -r "Icons" -o ./icons --format png --scale 3 +``` + +> File UUID is in the Penpot workspace URL: `?file-id=UUID`. +> For shared libraries, use the library's file ID from the Assets panel. ## Quick Start diff --git a/Sources/ExFigCLI/ExFig.docc/Usage.md b/Sources/ExFigCLI/ExFig.docc/Usage.md index e92caacf..4f489607 100644 --- a/Sources/ExFigCLI/ExFig.docc/Usage.md +++ b/Sources/ExFigCLI/ExFig.docc/Usage.md @@ -194,6 +194,26 @@ exfig fetch -f abc123 -r "Images" -o ./images --format webp --webp-quality 90 exfig fetch -f abc123 -r "Images" -o ./images --format webp --webp-encoding lossless ``` +### Penpot Fetch + +```bash +# Fetch icons from Penpot as SVG +exfig fetch --source penpot -f "a1b2c3d4-..." -r "Icons / App" -o ./icons --format svg + +# Fetch as PNG at 3x scale (SVG reconstructed, then rasterized via resvg) +exfig fetch --source penpot -f "a1b2c3d4-..." -r "Icons" -o ./icons --format png --scale 3 + +# From a shared library (use library file ID) +exfig fetch --source penpot -f "library-uuid" -r "Icons / Actions" -o ./icons --format svg + +# Self-hosted Penpot instance +exfig fetch --source penpot --penpot-base-url https://penpot.mycompany.com/ \ + -f "uuid" -r "Icons" -o ./icons --format svg +``` + +> Set `PENPOT_ACCESS_TOKEN` environment variable (generate at Settings → Access Tokens). +> File ID is in the Penpot workspace URL: `?file-id=UUID`. + ### Scale Options ```bash diff --git a/Sources/ExFigCLI/MCP/MCPPrompts.swift b/Sources/ExFigCLI/MCP/MCPPrompts.swift index 7f082cba..67eb3a77 100644 --- a/Sources/ExFigCLI/MCP/MCPPrompts.swift +++ b/Sources/ExFigCLI/MCP/MCPPrompts.swift @@ -132,7 +132,7 @@ enum MCPPrompts { Important notes: - PENPOT_ACCESS_TOKEN must be set (not FIGMA_PERSONAL_TOKEN) - No `figma` section needed when using only Penpot sources - - v1 limitation: icons/images export as raster thumbnails only + - Icons/images: SVG reconstructed from shape tree (supports SVG, PNG, PDF output) First, validate the config with exfig_validate after creating it. """ diff --git a/Sources/ExFigCLI/Resources/Guides/DesignRequirements.md b/Sources/ExFigCLI/Resources/Guides/DesignRequirements.md index 765b8179..6f6c9499 100644 --- a/Sources/ExFigCLI/Resources/Guides/DesignRequirements.md +++ b/Sources/ExFigCLI/Resources/Guides/DesignRequirements.md @@ -443,10 +443,8 @@ penpotSource = new Common.PenpotSource { figmaFrameName = "Icons/Navigation" // exports arrow-left, arrow-right ``` -> **v1 limitation:** Penpot components are exported as **raster thumbnails** (PNG only). -> The Penpot API has no public SVG/PNG render endpoint. SVG reconstruction from the -> shape tree is planned for a future version. For best quality, design components at the -> largest needed scale. +ExFig reconstructs SVG directly from Penpot's shape tree — no headless Chrome or CDN needed. +Supported output formats: **SVG** (native vector), **PNG** (via resvg at any scale), **PDF**, **WebP**. ### Library Typography @@ -488,13 +486,13 @@ Design System (Penpot file) └── Caption/* (regular, small) ``` -### Known Limitations (v1) +### Known Limitations - **No dark mode support** — Penpot has no Variables/modes equivalent; colors export as light-only -- **Raster-only components** — icons and images export as PNG thumbnails, not SVG - **No `exfig_inspect` for Penpot** — the MCP inspect tool works with Figma API only - **Gradients skipped** — only solid hex colors are supported - **No page filtering** — all library assets are global to the file, not page-scoped +- **SVG reconstruction scope** — supports path, rect, circle, bool, group shapes; complex effects (blur, shadow, gradients on shapes) are not yet rendered ### Penpot Troubleshooting diff --git a/llms-full.txt b/llms-full.txt index 3dd460a9..254b8daa 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -182,7 +182,21 @@ For Penpot sources, set the `PENPOT_ACCESS_TOKEN` environment variable: export PENPOT_ACCESS_TOKEN="your-penpot-token-here" ``` -> Note: `PENPOT_ACCESS_TOKEN` is only required when using `sourceKind: "penpot"` or `penpotSource` in config. +> Note: `PENPOT_ACCESS_TOKEN` is only required when using `penpotSource` in config. + +### Quick Penpot Icons Export (No Config) + +```bash +# Export Penpot icons as SVG +export PENPOT_ACCESS_TOKEN="your-token" +exfig fetch --source penpot -f "file-uuid" -r "Icons" -o ./icons --format svg + +# Export as PNG at 3x scale +exfig fetch --source penpot -f "file-uuid" -r "Icons" -o ./icons --format png --scale 3 +``` + +> File UUID is in the Penpot workspace URL: `?file-id=UUID`. +> For shared libraries, use the library's file ID from the Assets panel. ## Quick Start @@ -457,6 +471,26 @@ exfig fetch -f abc123 -r "Images" -o ./images --format webp --webp-quality 90 exfig fetch -f abc123 -r "Images" -o ./images --format webp --webp-encoding lossless ``` +### Penpot Fetch + +```bash +# Fetch icons from Penpot as SVG +exfig fetch --source penpot -f "a1b2c3d4-..." -r "Icons / App" -o ./icons --format svg + +# Fetch as PNG at 3x scale (SVG reconstructed, then rasterized via resvg) +exfig fetch --source penpot -f "a1b2c3d4-..." -r "Icons" -o ./icons --format png --scale 3 + +# From a shared library (use library file ID) +exfig fetch --source penpot -f "library-uuid" -r "Icons / Actions" -o ./icons --format svg + +# Self-hosted Penpot instance +exfig fetch --source penpot --penpot-base-url https://penpot.mycompany.com/ \ + -f "uuid" -r "Icons" -o ./icons --format svg +``` + +> Set `PENPOT_ACCESS_TOKEN` environment variable (generate at Settings → Access Tokens). +> File ID is in the Penpot workspace URL: `?file-id=UUID`. + ### Scale Options ```bash @@ -649,6 +683,25 @@ ios = new iOS.iOSConfig { } ``` +**Icons:** + +```pkl +ios = new iOS.iOSConfig { + icons = new Listing { + new iOS.IconsEntry { + penpotSource = new Common.PenpotSource { + fileId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + // pathFilter = "Icons / Actions" // optional: filter by path prefix + } + figmaFrameName = "Icons" // path prefix filter (same field as Figma) + format = "svg" // svg or pdf — SVG reconstructed from shape tree + assetsFolder = "Icons" + nameStyle = "camelCase" + } + } +} +``` + **Typography:** ```pkl @@ -664,9 +717,8 @@ ios = new iOS.iOSConfig { > When `penpotSource` is set, `sourceKind` auto-detects as `"penpot"`. ExFig reads from > the Penpot API and does not require `FIGMA_PERSONAL_TOKEN`. Set `PENPOT_ACCESS_TOKEN` instead. > -> **v1 limitation:** Penpot icons/images are exported as raster thumbnails (the Penpot API has -> no public SVG/PNG render endpoint). SVG reconstruction from the shape tree is planned for a -> future version. +> Icons and images are exported via **SVG reconstruction** from Penpot's shape tree — +> no headless Chrome needed. Supported formats: SVG, PNG (any scale), PDF, WebP. ### Tokens File Source @@ -1554,10 +1606,8 @@ penpotSource = new Common.PenpotSource { figmaFrameName = "Icons/Navigation" // exports arrow-left, arrow-right ``` -> **v1 limitation:** Penpot components are exported as **raster thumbnails** (PNG only). -> The Penpot API has no public SVG/PNG render endpoint. SVG reconstruction from the -> shape tree is planned for a future version. For best quality, design components at the -> largest needed scale. +ExFig reconstructs SVG directly from Penpot's shape tree — no headless Chrome or CDN needed. +Supported output formats: **SVG** (native vector), **PNG** (via resvg at any scale), **PDF**, **WebP**. ### Library Typography @@ -1599,13 +1649,13 @@ Design System (Penpot file) └── Caption/* (regular, small) ``` -### Known Limitations (v1) +### Known Limitations - **No dark mode support** — Penpot has no Variables/modes equivalent; colors export as light-only -- **Raster-only components** — icons and images export as PNG thumbnails, not SVG - **No `exfig_inspect` for Penpot** — the MCP inspect tool works with Figma API only - **Gradients skipped** — only solid hex colors are supported - **No page filtering** — all library assets are global to the file, not page-scoped +- **SVG reconstruction scope** — supports path, rect, circle, bool, group shapes; complex effects (blur, shadow, gradients on shapes) are not yet rendered ### Penpot Troubleshooting From f30febec1e8dbf9582c22bce3e4a1a8b0c8e1f47 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sun, 22 Mar 2026 17:28:52 +0500 Subject: [PATCH 14/26] fix(penpot): allow file:// URLs in FileDownloader for local SVG assets Penpot SVG reconstruction writes SVGs to temp files and passes file:// URLs through the download pipeline. FileDownloader now skips HTTP download for file:// URLs and returns the local path directly. --- Sources/ExFigCLI/Output/FileDownloader.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Sources/ExFigCLI/Output/FileDownloader.swift b/Sources/ExFigCLI/Output/FileDownloader.swift index 07c38e29..c2f0f85e 100644 --- a/Sources/ExFigCLI/Output/FileDownloader.swift +++ b/Sources/ExFigCLI/Output/FileDownloader.swift @@ -11,6 +11,9 @@ typealias DownloadProgressCallback = @Sendable (Int, Int) async -> Void /// Validates that a download URL uses HTTPS and has a valid host. /// Shared by both `FileDownloader` and `SharedDownloadQueue`. func validateDownloadURL(_ url: URL) throws { + // Allow file:// URLs for locally reconstructed assets (e.g., Penpot SVG from shape tree) + if url.isFileURL { return } + guard url.scheme?.lowercased() == "https" else { throw URLError( .badURL, @@ -105,6 +108,18 @@ final class FileDownloader: Sendable { } try validateDownloadURL(remoteURL) + + // Local files (e.g., Penpot SVG reconstructed from shape tree) — already on disk + if remoteURL.isFileURL { + return FileContents( + destination: file.destination, + dataFile: remoteURL, + scale: file.scale, + dark: file.dark, + isRTL: file.isRTL + ) + } + let (localURL, _) = try await session.download(from: remoteURL) return FileContents( From 9266b119bda5ab0fb4e1efea1bf5ff1d9979f1bd Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sun, 22 Mar 2026 17:35:02 +0500 Subject: [PATCH 15/26] fix(penpot): handle file:// URLs before download phase instead of weakening validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move file:// URL handling from validateDownloadURL (security gate) to fetch() method — file:// URLs are filtered as local files before the download loop, keeping validateDownloadURL strictly HTTPS-only. --- Sources/ExFigCLI/Output/FileDownloader.swift | 29 ++++++++------------ 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/Sources/ExFigCLI/Output/FileDownloader.swift b/Sources/ExFigCLI/Output/FileDownloader.swift index c2f0f85e..c5d3edf6 100644 --- a/Sources/ExFigCLI/Output/FileDownloader.swift +++ b/Sources/ExFigCLI/Output/FileDownloader.swift @@ -11,9 +11,6 @@ typealias DownloadProgressCallback = @Sendable (Int, Int) async -> Void /// Validates that a download URL uses HTTPS and has a valid host. /// Shared by both `FileDownloader` and `SharedDownloadQueue`. func validateDownloadURL(_ url: URL) throws { - // Allow file:// URLs for locally reconstructed assets (e.g., Penpot SVG from shape tree) - if url.isFileURL { return } - guard url.scheme?.lowercased() == "https" else { throw URLError( .badURL, @@ -50,8 +47,18 @@ final class FileDownloader: Sendable { files: [FileContents], onProgress: DownloadProgressCallback? = nil ) async throws -> [FileContents] { - let remoteFiles = files.filter { $0.sourceURL != nil } - let localFiles = files.filter { $0.sourceURL == nil } + // file:// URLs (e.g., Penpot SVG from shape tree) are already on disk — treat as local + let remoteFiles = files.filter { $0.sourceURL != nil && !($0.sourceURL?.isFileURL ?? false) } + let localFileURLs = files.filter { $0.sourceURL?.isFileURL == true }.map { file in + FileContents( + destination: file.destination, + dataFile: file.sourceURL!, + scale: file.scale, + dark: file.dark, + isRTL: file.isRTL + ) + } + let localFiles = files.filter { $0.sourceURL == nil } + localFileURLs let remoteFileCount = remoteFiles.count if remoteFiles.isEmpty { @@ -108,18 +115,6 @@ final class FileDownloader: Sendable { } try validateDownloadURL(remoteURL) - - // Local files (e.g., Penpot SVG reconstructed from shape tree) — already on disk - if remoteURL.isFileURL { - return FileContents( - destination: file.destination, - dataFile: remoteURL, - scale: file.scale, - dark: file.dark, - isRTL: file.isRTL - ) - } - let (localURL, _) = try await session.download(from: remoteURL) return FileContents( From 9c2b4870e2d745d8e1769f6f43f5990a750d4369 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sun, 22 Mar 2026 17:38:28 +0500 Subject: [PATCH 16/26] docs: add FileDownloader file:// pattern and iOS PKL xcassetsPath gotcha --- CLAUDE.md | 1 + Sources/ExFigCLI/CLAUDE.md | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 62d8cbda..5eee98f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -459,6 +459,7 @@ NooraUI.formatLink("url", useColors: true) // underlined primary | Lazy Figma token validation | `ExFigOptions.validate()` reads token without throwing; `resolveClient()` returns placeholder if nil; `SourceFactory` guards `.figma` branch with `accessTokenNotFound` | | PKL `swiftuiColorSwift` casing | PKL codegen lowercases: `swiftuiColorSwift`, not `swiftUIColorSwift` — check with `pkl eval` if unsure | | Penpot `svgAttrs` decoding | `svgAttrs` contains mixed types (strings + nested dicts) — use `SVGAttributes` wrapper that extracts string values only, not `[String: String]` | +| iOS icons PKL: `xcassetsPath` | `xcassetsPath` and `target` are required in iOS PKL config even for Penpot; `assetsFolder` is folder name inside xcassets, not absolute path | ## Additional Rules diff --git a/Sources/ExFigCLI/CLAUDE.md b/Sources/ExFigCLI/CLAUDE.md index 158b5303..1cf2ea25 100644 --- a/Sources/ExFigCLI/CLAUDE.md +++ b/Sources/ExFigCLI/CLAUDE.md @@ -219,6 +219,12 @@ All platforms (iOS/Android/Flutter/Web) use `SourceFactory.createComponentsSourc When adding a new source kind: update `SourceFactory`, add source impl in `Source/`, update error `assetType`. Penpot sources create `BasePenpotClient` internally from `PENPOT_ACCESS_TOKEN` env var (like TokensFileSource reads local files — no injected client). +### Local File URLs (Penpot SVG) + +`PenpotComponentsSource` writes reconstructed SVGs to temp files and passes `file://` URLs in `Image.url`. +`FileDownloader.fetch()` treats `file://` URLs as local files (skips HTTP download). Do NOT weaken +`validateDownloadURL()` — it must remain HTTPS-only. Filter file URLs in `fetch()` before download loop. + ### Adding a New Subcommand 1. Create `Subcommands/NewCommand.swift` implementing `AsyncParsableCommand` From ed1cdfc4f34694a3efa31c87064ffd57d06720af Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sun, 22 Mar 2026 18:23:47 +0500 Subject: [PATCH 17/26] fix(penpot): improve type safety, error handling, and diagnostics across Penpot support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace placeholder FigmaClient("no-token") with NoTokenFigmaClient that throws accessTokenNotFound on any call — prevents silent HTTP requests with invalid credentials. Add ShapeType enum replacing stringly-typed shape dispatch, MainInstance struct enforcing paired id+page invariant, and structured RenderResult/RenderFailure for SVG reconstruction diagnostics. Fix SVG arc command normalization to correctly offset only endpoint coordinates (params 5-6 of 7-param groups). Add warnings for skipped gradient colors, empty path filter results, and unknown shape types. Remove force unwrap in FileDownloader, fix inverted letterSpacing warning condition, and throw error for unsupported Penpot export formats instead of silently saving SVG. Includes 9 new tests covering nested groups, rotation transforms, arc normalization, relative path commands, ShapeType decoding, and RenderFailure diagnostics. --- CLAUDE.md | 9 +- .../Input/FaultToleranceOptions.swift | 21 +- Sources/ExFigCLI/Output/FileDownloader.swift | 7 +- .../ExFigCLI/Source/PenpotClientFactory.swift | 2 +- .../ExFigCLI/Source/PenpotColorsSource.swift | 18 +- .../Source/PenpotComponentsSource.swift | 31 ++- .../Source/PenpotTypographySource.swift | 4 +- .../ExFigCLI/Subcommands/DownloadImages.swift | 29 ++- .../Export/PluginIconsExport.swift | 4 + .../Export/PluginImagesExport.swift | 4 + Sources/PenpotAPI/CLAUDE.md | 9 +- .../PenpotAPI/Models/PenpotComponent.swift | 51 ++++- Sources/PenpotAPI/Models/PenpotShape.swift | 45 +++- .../Renderer/PenpotShapeRenderer.swift | 195 +++++++++++------- .../PenpotShapeRendererTests.swift | 194 ++++++++++++++++- 15 files changed, 514 insertions(+), 109 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5eee98f5..b4b6c9bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -305,13 +305,18 @@ Do NOT inject `colorsSource` at context construction time — it breaks multi-so ### Lazy Figma Client Pattern -`resolveClient(accessToken:...)` accepts `String?`. When nil (no `FIGMA_PERSONAL_TOKEN`), returns a placeholder `FigmaClient(accessToken: "no-token")` that is never called by non-Figma sources. `SourceFactory` guards the `.figma` branch: `guard let client else { throw ExFigError.accessTokenNotFound }`. This avoids making `Client?` cascade through 20+ type signatures. +`resolveClient(accessToken:...)` accepts `String?`. When nil (no `FIGMA_PERSONAL_TOKEN`), returns `NoTokenFigmaClient()` — a fail-fast client that throws `accessTokenNotFound` on any request. Non-Figma sources never call it. `SourceFactory` also guards the `.figma` branch. This avoids making `Client?` cascade through 20+ type signatures. ### Penpot Source Patterns -- `PenpotClientFactory.makeClient(baseURL:)` — shared factory in `Source/PenpotClientFactory.swift`. All Penpot sources use this (NOT a static on any single source). +- `PenpotClientFactory.makeClient(baseURL:)` — shared factory in `Source/PenpotClientFactory.swift`. Returns `any PenpotClient` (protocol, not `BasePenpotClient`) for testability. All Penpot sources use this (NOT a static on any single source). +- `PenpotShape.ShapeType` enum — `.path`, `.rect`, `.circle`, `.group`, `.frame`, `.bool`, `.unknown(String)`. Exhaustive switch in renderer (no `default` branch). +- `PenpotComponent.MainInstance` struct — pairs `id` + `page` (both or neither). Computed properties `mainInstanceId`/`mainInstancePage` for backward compat. +- `PenpotShapeRenderer.renderSVGResult()` — returns `Result` with `skippedShapeTypes` and typed failure reasons. `renderSVG()` is a convenience wrapper. - Dictionary iteration from Penpot API (`colors`, `typographies`, `components`) must be sorted by key for deterministic export order: `.sorted(by: { $0.key < $1.key })`. - `exfig fetch --source penpot` — `FetchSource` enum in `DownloadOptions.swift`. Route: `--source` flag > wizard result > default `.figma`. Also `--penpot-base-url` for self-hosted. +- Penpot fetch supports only `svg` and `png` formats — unsupported formats (pdf, webp, jpg) throw an error. +- Download commands (`download all/colors/icons/images/typography`) are **Figma-only** by design. Penpot export uses `exfig colors/icons/images` (via SourceFactory) and `exfig fetch --source penpot`. ### Entry Bridge Source Kind Resolution diff --git a/Sources/ExFigCLI/Input/FaultToleranceOptions.swift b/Sources/ExFigCLI/Input/FaultToleranceOptions.swift index 1e9d64ab..ebe3a58f 100644 --- a/Sources/ExFigCLI/Input/FaultToleranceOptions.swift +++ b/Sources/ExFigCLI/Input/FaultToleranceOptions.swift @@ -262,10 +262,10 @@ func resolveClient( return injectedClient } guard let accessToken else { - // No Figma token — return a placeholder client. + // No Figma token — return a client that throws on any call. // Non-Figma sources (Penpot, tokens-file) never call it. - // SourceFactory guards the .figma branch and throws accessTokenNotFound. - return FigmaClient(accessToken: "no-token", timeout: nil) + // SourceFactory also guards the .figma branch with accessTokenNotFound. + return NoTokenFigmaClient() } // CLI timeout takes precedence over config timeout let effectiveTimeout: TimeInterval? = options.timeout.map { TimeInterval($0) } ?? timeout @@ -308,7 +308,7 @@ func resolveClient( return injectedClient } guard let accessToken else { - return FigmaClient(accessToken: "no-token", timeout: nil) + return NoTokenFigmaClient() } // CLI timeout takes precedence over config timeout let effectiveTimeout: TimeInterval? = options.timeout.map { TimeInterval($0) } ?? timeout @@ -329,3 +329,16 @@ func resolveClient( } ) } + +// MARK: - No-Token Client + +/// A Figma API client placeholder that throws `accessTokenNotFound` on any request. +/// +/// Used when `FIGMA_PERSONAL_TOKEN` is not set. Non-Figma sources (Penpot, tokens-file) +/// never call this client. If accidentally invoked, the error message clearly tells the user +/// to set the token — instead of making real HTTP requests with an invalid token. +final class NoTokenFigmaClient: Client, @unchecked Sendable { + func request(_: T) async throws -> T.Content { + throw ExFigError.accessTokenNotFound + } +} diff --git a/Sources/ExFigCLI/Output/FileDownloader.swift b/Sources/ExFigCLI/Output/FileDownloader.swift index c5d3edf6..2062530f 100644 --- a/Sources/ExFigCLI/Output/FileDownloader.swift +++ b/Sources/ExFigCLI/Output/FileDownloader.swift @@ -49,10 +49,11 @@ final class FileDownloader: Sendable { ) async throws -> [FileContents] { // file:// URLs (e.g., Penpot SVG from shape tree) are already on disk — treat as local let remoteFiles = files.filter { $0.sourceURL != nil && !($0.sourceURL?.isFileURL ?? false) } - let localFileURLs = files.filter { $0.sourceURL?.isFileURL == true }.map { file in - FileContents( + let localFileURLs = files.filter { $0.sourceURL?.isFileURL == true }.compactMap { file -> FileContents? in + guard let sourceURL = file.sourceURL else { return nil } + return FileContents( destination: file.destination, - dataFile: file.sourceURL!, + dataFile: sourceURL, scale: file.scale, dark: file.dark, isRTL: file.isRTL diff --git a/Sources/ExFigCLI/Source/PenpotClientFactory.swift b/Sources/ExFigCLI/Source/PenpotClientFactory.swift index c627716b..3a5f9c7a 100644 --- a/Sources/ExFigCLI/Source/PenpotClientFactory.swift +++ b/Sources/ExFigCLI/Source/PenpotClientFactory.swift @@ -3,7 +3,7 @@ import PenpotAPI /// Shared factory for creating authenticated Penpot API clients. enum PenpotClientFactory { - static func makeClient(baseURL: String) throws -> BasePenpotClient { + static func makeClient(baseURL: String) throws -> any PenpotClient { guard let token = ProcessInfo.processInfo.environment["PENPOT_ACCESS_TOKEN"], !token.isEmpty else { throw ExFigError.configurationError( "PENPOT_ACCESS_TOKEN environment variable is required for Penpot source" diff --git a/Sources/ExFigCLI/Source/PenpotColorsSource.swift b/Sources/ExFigCLI/Source/PenpotColorsSource.swift index f45e4003..a36b1cc3 100644 --- a/Sources/ExFigCLI/Source/PenpotColorsSource.swift +++ b/Sources/ExFigCLI/Source/PenpotColorsSource.swift @@ -21,14 +21,20 @@ struct PenpotColorsSource: ColorsSource { } var colors: [Color] = [] + var skippedNonSolid = 0 + var skippedByFilter = 0 for (_, penpotColor) in penpotColors.sorted(by: { $0.key < $1.key }) { // Skip gradient/image fills (no solid hex) - guard let hex = penpotColor.color else { continue } + guard let hex = penpotColor.color else { + skippedNonSolid += 1 + continue + } // Apply path filter if let pathFilter = config.pathFilter { guard let path = penpotColor.path, path.hasPrefix(pathFilter) else { + skippedByFilter += 1 continue } } @@ -54,6 +60,16 @@ struct PenpotColorsSource: ColorsSource { )) } + if skippedNonSolid > 0 { + ui.warning( + "Skipped \(skippedNonSolid) color(s) without solid hex values " + + "(gradients and image fills are not supported)" + ) + } + if skippedByFilter > 0 { + ui.warning("Skipped \(skippedByFilter) color(s) not matching path filter '\(config.pathFilter!)'") + } + // Penpot has no mode-based variants — light only return ColorsLoadOutput(light: colors) } diff --git a/Sources/ExFigCLI/Source/PenpotComponentsSource.swift b/Sources/ExFigCLI/Source/PenpotComponentsSource.swift index e10c4691..60ad553b 100644 --- a/Sources/ExFigCLI/Source/PenpotComponentsSource.swift +++ b/Sources/ExFigCLI/Source/PenpotComponentsSource.swift @@ -58,6 +58,15 @@ struct PenpotComponentsSource: ComponentsSource { let sortedComponents = matchedComponents.sorted { $0.name < $1.name } guard !sortedComponents.isEmpty else { + let availablePaths = components.values + .compactMap(\.path) + .reduce(into: Set()) { $0.insert($1) } + .sorted() + .prefix(5) + let pathHint = availablePaths.isEmpty + ? "" + : " Available paths: \(availablePaths.joined(separator: ", "))" + ui.warning("No components matching path prefix '\(pathFilter)'.\(pathHint)") return [] } @@ -98,10 +107,26 @@ struct PenpotComponentsSource: ComponentsSource { continue } - guard let svgString = PenpotShapeRenderer.renderSVG( + let renderResult = PenpotShapeRenderer.renderSVGResult( objects: objects, rootId: instanceId - ) else { - ui.warning("Component '\(component.name)' — failed to reconstruct SVG, skipping") + ) + let svgString: String + switch renderResult { + case let .success(result): + svgString = result.svg + if !result.skippedShapeTypes.isEmpty { + ui.warning( + "Component '\(component.name)' — unsupported shape types skipped: " + + result.skippedShapeTypes.sorted().joined(separator: ", ") + ) + } + case let .failure(reason): + switch reason { + case let .rootNotFound(id): + ui.warning("Component '\(component.name)' — root shape '\(id)' not found, skipping") + case let .missingSelrect(id): + ui.warning("Component '\(component.name)' — root shape '\(id)' has no bounds, skipping") + } continue } diff --git a/Sources/ExFigCLI/Source/PenpotTypographySource.swift b/Sources/ExFigCLI/Source/PenpotTypographySource.swift index fe954a26..1ff92c91 100644 --- a/Sources/ExFigCLI/Source/PenpotTypographySource.swift +++ b/Sources/ExFigCLI/Source/PenpotTypographySource.swift @@ -32,8 +32,8 @@ struct PenpotTypographySource: TypographySource { let textCase = mapTextTransform(typography.textTransform) - if typography.letterSpacing == nil, typography.lineHeight != nil { - ui.warning("Typography '\(typography.name)' has unparseable letter-spacing — defaulting to 0") + if typography.letterSpacing == nil { + ui.warning("Typography '\(typography.name)' has no letter-spacing — defaulting to 0") } textStyles.append(TextStyle( diff --git a/Sources/ExFigCLI/Subcommands/DownloadImages.swift b/Sources/ExFigCLI/Subcommands/DownloadImages.swift index b1ba2e09..037d6195 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadImages.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadImages.swift @@ -384,10 +384,26 @@ extension ExFigCommand { continue } - guard let svgString = PenpotShapeRenderer.renderSVG( + let renderResult = PenpotShapeRenderer.renderSVGResult( objects: objects, rootId: instanceId - ) else { - ui.warning("Component '\(component.name)' — SVG reconstruction failed, skipping") + ) + let svgString: String + switch renderResult { + case let .success(result): + svgString = result.svg + if !result.skippedShapeTypes.isEmpty { + ui.warning( + "Component '\(component.name)' — unsupported shape types skipped: " + + result.skippedShapeTypes.sorted().joined(separator: ", ") + ) + } + case let .failure(reason): + switch reason { + case let .rootNotFound(id): + ui.warning("Component '\(component.name)' — root shape '\(id)' not found, skipping") + case let .missingSelrect(id): + ui.warning("Component '\(component.name)' — root shape '\(id)' has no bounds, skipping") + } continue } @@ -407,9 +423,10 @@ extension ExFigCommand { let fileURL = outputURL.appendingPathComponent("\(safeName).png") try pngData.write(to: fileURL) default: - // For other formats (pdf, webp, jpg), save as SVG - let fileURL = outputURL.appendingPathComponent("\(safeName).svg") - try svgData.write(to: fileURL) + throw ExFigError.custom( + errorString: "Format '\(format.rawValue)' is not yet supported for Penpot export. " + + "Supported formats: svg, png" + ) } exportedCount += 1 diff --git a/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift b/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift index 89468fff..589c0718 100644 --- a/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift +++ b/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift @@ -39,6 +39,7 @@ extension ExFigCommand.ExportIcons { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() + // All entries in a platform section share one source kind (mixed sources not yet supported) let sourceKind = entries.first?.resolvedSourceKind ?? .figma let componentsSource = try SourceFactory.createComponentsSource( for: sourceKind, @@ -118,6 +119,7 @@ extension ExFigCommand.ExportIcons { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() + // All entries in a platform section share one source kind (mixed sources not yet supported) let sourceKind = entries.first?.resolvedSourceKind ?? .figma let componentsSource = try SourceFactory.createComponentsSource( for: sourceKind, @@ -173,6 +175,7 @@ extension ExFigCommand.ExportIcons { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() + // All entries in a platform section share one source kind (mixed sources not yet supported) let sourceKind = entries.first?.resolvedSourceKind ?? .figma let componentsSource = try SourceFactory.createComponentsSource( for: sourceKind, @@ -228,6 +231,7 @@ extension ExFigCommand.ExportIcons { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() + // All entries in a platform section share one source kind (mixed sources not yet supported) let sourceKind = entries.first?.resolvedSourceKind ?? .figma let componentsSource = try SourceFactory.createComponentsSource( for: sourceKind, diff --git a/Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift b/Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift index acd5ac22..0213284a 100644 --- a/Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift +++ b/Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift @@ -38,6 +38,7 @@ extension ExFigCommand.ExportImages { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() + // All entries in a platform section share one source kind (mixed sources not yet supported) let sourceKind = entries.first?.resolvedSourceKind ?? .figma let componentsSource = try SourceFactory.createComponentsSource( for: sourceKind, @@ -116,6 +117,7 @@ extension ExFigCommand.ExportImages { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() + // All entries in a platform section share one source kind (mixed sources not yet supported) let sourceKind = entries.first?.resolvedSourceKind ?? .figma let componentsSource = try SourceFactory.createComponentsSource( for: sourceKind, @@ -171,6 +173,7 @@ extension ExFigCommand.ExportImages { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() + // All entries in a platform section share one source kind (mixed sources not yet supported) let sourceKind = entries.first?.resolvedSourceKind ?? .figma let componentsSource = try SourceFactory.createComponentsSource( for: sourceKind, @@ -226,6 +229,7 @@ extension ExFigCommand.ExportImages { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() + // All entries in a platform section share one source kind (mixed sources not yet supported) let sourceKind = entries.first?.resolvedSourceKind ?? .figma let componentsSource = try SourceFactory.createComponentsSource( for: sourceKind, diff --git a/Sources/PenpotAPI/CLAUDE.md b/Sources/PenpotAPI/CLAUDE.md index 4918606e..1b5f8a41 100644 --- a/Sources/PenpotAPI/CLAUDE.md +++ b/Sources/PenpotAPI/CLAUDE.md @@ -47,9 +47,12 @@ If switching to `/api/rpc/command/`, update `BasePenpotClient.buildURL(for:)`. Icons/images exported via shape tree → SVG (no CDN, no headless Chrome): -- `PenpotShapeRenderer.renderSVG(objects:rootId:)` — pure function, shape tree → SVG string +- `PenpotShape.ShapeType` enum (not String) — `.path`, `.rect`, `.circle`, `.group`, `.frame`, `.bool`, `.unknown(String)`. Exhaustive switch in renderer. +- `PenpotComponent.MainInstance` struct pairs `id` + `page`. Backward-compat computed properties `mainInstanceId`/`mainInstancePage`. +- `renderSVGResult()` returns `Result` — includes `skippedShapeTypes: Set` and typed failure reasons (`.rootNotFound`, `.missingSelrect`). `renderSVG()` is a convenience wrapper returning `String?`. - Shapes are in canvas-space — subtract root frame's `selrect.x/y` to normalize +- Arc (`A`) command normalization: 7 params per segment, only params 5 (x) and 6 (y) get origin offset. Tracked via `PathNormState.arcParamIndex`. +- `normalizePathCoordinates` helpers extracted into `PathNormState`, `parseNumber`, `offsetValue` to stay under SwiftLint cyclomatic_complexity/function_body_length limits. - `svgAttrs` has mixed types (string values + nested `style` dict) — `SVGAttributes` type extracts strings only -- Supported shape types: path, rect, circle, bool (compound path), group, frame -- Components need `mainInstanceId` + `mainInstancePage` for shape tree lookup +- Components need `mainInstanceId` + `mainInstancePage` (via `MainInstance` struct) for shape tree lookup - Linked libraries: use `get-file` with library file ID (from `get-file-libraries`) diff --git a/Sources/PenpotAPI/Models/PenpotComponent.swift b/Sources/PenpotAPI/Models/PenpotComponent.swift index 2aafa41e..209f084f 100644 --- a/Sources/PenpotAPI/Models/PenpotComponent.swift +++ b/Sources/PenpotAPI/Models/PenpotComponent.swift @@ -11,11 +11,47 @@ public struct PenpotComponent: Decodable, Sendable { /// Slash-separated group path (e.g., "Icons/Navigation"). public let path: String? - /// ID of the main instance shape on the canvas (needed for SVG reconstruction). - public let mainInstanceId: String? + /// Main instance location on the canvas (both or neither present). + /// Needed for SVG reconstruction from the shape tree. + public let mainInstance: MainInstance? - /// Page UUID where the main instance lives. - public let mainInstancePage: String? + /// Convenience accessors for backward compatibility. + public var mainInstanceId: String? { + mainInstance?.id + } + + public var mainInstancePage: String? { + mainInstance?.page + } + + /// Paired instance ID and page UUID for shape tree lookup. + public struct MainInstance: Sendable, Equatable { + public let id: String + public let page: String + + public init(id: String, page: String) { + self.id = id + self.page = page + } + } + + enum CodingKeys: String, CodingKey { + case id, name, path, mainInstanceId, mainInstancePage + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + path = try container.decodeIfPresent(String.self, forKey: .path) + let instanceId = try container.decodeIfPresent(String.self, forKey: .mainInstanceId) + let instancePage = try container.decodeIfPresent(String.self, forKey: .mainInstancePage) + if let instanceId, let instancePage { + mainInstance = MainInstance(id: instanceId, page: instancePage) + } else { + mainInstance = nil + } + } public init( id: String, @@ -27,7 +63,10 @@ public struct PenpotComponent: Decodable, Sendable { self.id = id self.name = name self.path = path - self.mainInstanceId = mainInstanceId - self.mainInstancePage = mainInstancePage + if let mainInstanceId, let mainInstancePage { + mainInstance = MainInstance(id: mainInstanceId, page: mainInstancePage) + } else { + mainInstance = nil + } } } diff --git a/Sources/PenpotAPI/Models/PenpotShape.swift b/Sources/PenpotAPI/Models/PenpotShape.swift index 15fd1f9a..0e092f74 100644 --- a/Sources/PenpotAPI/Models/PenpotShape.swift +++ b/Sources/PenpotAPI/Models/PenpotShape.swift @@ -7,7 +7,7 @@ import Foundation public struct PenpotShape: Decodable, Sendable { public let id: String public let name: String? - public let type: String + public let type: ShapeType // Geometry public let x: Double? @@ -45,6 +45,49 @@ public struct PenpotShape: Decodable, Sendable { public let hidden: Bool? } +// MARK: - Shape Type + +public extension PenpotShape { + /// Known shape types from Penpot's shape tree. + enum ShapeType: Sendable, Equatable, CustomStringConvertible { + case path + case rect + case circle + case group + case frame + case bool + case unknown(String) + + public var description: String { + switch self { + case .path: "path" + case .rect: "rect" + case .circle: "circle" + case .group: "group" + case .frame: "frame" + case .bool: "bool" + case let .unknown(value): value + } + } + } +} + +extension PenpotShape.ShapeType: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + switch rawValue { + case "path": self = .path + case "rect": self = .rect + case "circle": self = .circle + case "group": self = .group + case "frame": self = .frame + case "bool": self = .bool + default: self = .unknown(rawValue) + } + } +} + // MARK: - Supporting Types public extension PenpotShape { diff --git a/Sources/PenpotAPI/Renderer/PenpotShapeRenderer.swift b/Sources/PenpotAPI/Renderer/PenpotShapeRenderer.swift index 6f1ad891..5df31005 100644 --- a/Sources/PenpotAPI/Renderer/PenpotShapeRenderer.swift +++ b/Sources/PenpotAPI/Renderer/PenpotShapeRenderer.swift @@ -12,18 +12,37 @@ public enum PenpotShapeRenderer { /// - objects: Flat dictionary of all shapes on the page (from `PenpotPage.objects`) /// - rootId: The component's `mainInstanceId` — the root frame shape /// - Returns: SVG string with coordinates normalized to (0,0), or nil if root not found - public static func renderSVG( + /// Describes why SVG rendering failed. + public enum RenderFailure: Error, Sendable { + case rootNotFound(id: String) + case missingSelrect(id: String) + } + + /// Result of SVG rendering including any warnings about skipped shapes. + public struct RenderResult: Sendable { + public let svg: String + public let skippedShapeTypes: Set + } + + /// Renders a component's shape tree as an SVG string with diagnostics. + public static func renderSVGResult( objects: [String: PenpotShape], rootId: String - ) -> String? { - guard let root = objects[rootId] else { return nil } - guard let selrect = root.selrect else { return nil } + ) -> Result { + guard let root = objects[rootId] else { + return .failure(.rootNotFound(id: rootId)) + } + guard let selrect = root.selrect else { + return .failure(.missingSelrect(id: rootId)) + } let originX = selrect.x let originY = selrect.y let width = selrect.width let height = selrect.height + var skippedTypes: Set = [] + var svg = """ String? { + switch renderSVGResult(objects: objects, rootId: rootId) { + case let .success(result): result.svg + case .failure: nil + } } // MARK: - Private @@ -49,7 +80,8 @@ public enum PenpotShapeRenderer { id: String, objects: [String: PenpotShape], originX: Double, - originY: Double + originY: Double, + skippedTypes: inout Set ) -> String { guard let shape = objects[id] else { return "" } if shape.hidden == true { return "" } @@ -69,16 +101,19 @@ public enum PenpotShapeRenderer { } switch shape.type { - case "path", "bool": + case .path, .bool: result += renderPath(shape, originX: originX, originY: originY) - case "rect": + case .rect: result += renderRect(shape, originX: originX, originY: originY) - case "circle": + case .circle: result += renderEllipse(shape, originX: originX, originY: originY) - case "group", "frame": - result += renderGroup(shape, objects: objects, originX: originX, originY: originY) - default: - break + case .group, .frame: + result += renderGroup( + shape, objects: objects, originX: originX, originY: originY, + skippedTypes: &skippedTypes + ) + case let .unknown(typeName): + skippedTypes.insert(typeName) } if needsTransform { @@ -142,7 +177,8 @@ public enum PenpotShapeRenderer { _ shape: PenpotShape, objects: [String: PenpotShape], originX: Double, - originY: Double + originY: Double, + skippedTypes: inout Set ) -> String { guard let children = shape.shapes, !children.isEmpty else { return "" } @@ -150,7 +186,10 @@ public enum PenpotShapeRenderer { var result = "\n" for childId in children { - result += renderShape(id: childId, objects: objects, originX: originX, originY: originY) + result += renderShape( + id: childId, objects: objects, originX: originX, originY: originY, + skippedTypes: &skippedTypes + ) } result += "\n" @@ -214,81 +253,97 @@ public enum PenpotShapeRenderer { originX: Double, originY: Double ) -> String { - // SVG path commands: M, L, C, S, Q, T, A, Z (uppercase = absolute, lowercase = relative) - // Only absolute commands need normalization var result = "" var i = pathData.startIndex - var isX = true // alternate X/Y for coordinate pairs + var state = PathNormState() while i < pathData.endIndex { let ch = pathData[i] if ch.isLetter { result.append(ch) - // Reset coordinate tracking for new command - isX = true - // Relative commands (lowercase) don't need offset - // Z/z has no coordinates + state.setCommand(ch) i = pathData.index(after: i) - continue - } - - if ch == "," || ch == " " { + } else if ch == "," || ch == " " { result.append(ch) i = pathData.index(after: i) - continue - } - - if ch == "-" || ch == "." || ch.isNumber { - // Parse number - var numStr = "" - var j = i - // Handle negative sign - if pathData[j] == "-" { - numStr.append("-") - j = pathData.index(after: j) - } - // Parse digits and decimal - var hasDot = false - while j < pathData.endIndex { - let c = pathData[j] - if c.isNumber { - numStr.append(c) - } else if c == ".", !hasDot { - hasDot = true - numStr.append(c) - } else { - break - } - j = pathData.index(after: j) - } - + } else if ch == "-" || ch == "." || ch.isNumber { + let (numStr, nextIndex) = parseNumber(in: pathData, from: i) if let value = Double(numStr) { - // Find the last command to check if absolute - let lastCmd = findLastCommand(in: pathData, before: i) - if let cmd = lastCmd, cmd.isUppercase, cmd != "Z", cmd != "A" { - // For A (arc) command, only coordinates 6&7 of each 7-param set need offset - // Simplified: offset all X/Y pairs for non-arc absolute commands - let offset = isX ? originX : originY - result.append(formatNumber(value - offset)) - } else { - result.append(formatNumber(value)) - } - isX.toggle() + let cmd = state.currentCmd ?? findLastCommand(in: pathData, before: i) + result.append(offsetValue(value, cmd: cmd, state: &state, originX: originX, originY: originY)) } else { result.append(numStr) } - i = j - continue + i = nextIndex + } else { + result.append(ch) + i = pathData.index(after: i) } - - result.append(ch) - i = pathData.index(after: i) } return result } + /// Mutable state for path coordinate normalization. + private struct PathNormState { + var isX = true + var currentCmd: Character? + var arcParamIndex = 0 + + mutating func setCommand(_ ch: Character) { + currentCmd = ch + isX = true + arcParamIndex = 0 + } + } + + private static func parseNumber(in path: String, from start: String.Index) -> (String, String.Index) { + var numStr = "" + var j = start + if path[j] == "-" { + numStr.append("-") + j = path.index(after: j) + } + var hasDot = false + while j < path.endIndex { + let c = path[j] + if c.isNumber { + numStr.append(c) + } else if c == ".", !hasDot { + hasDot = true + numStr.append(c) + } else { + break + } + j = path.index(after: j) + } + return (numStr, j) + } + + private static func offsetValue( + _ value: Double, cmd: Character?, state: inout PathNormState, + originX: Double, originY: Double + ) -> String { + guard let cmd, cmd.isUppercase, cmd != "Z" else { + return formatNumber(value) + } + + if cmd == "A" { + // Arc: 7 params per segment (rx, ry, x-rotation, large-arc, sweep, x, y) + // Only params 5 (x) and 6 (y) are endpoint coordinates + let paramPos = state.arcParamIndex % 7 + state.arcParamIndex += 1 + if paramPos == 5 { return formatNumber(value - originX) } + if paramPos == 6 { return formatNumber(value - originY) } + return formatNumber(value) + } + + let offset = state.isX ? originX : originY + state.isX.toggle() + return formatNumber(value - offset) + } + private static func findLastCommand(in path: String, before index: String.Index) -> Character? { var j = index while j > path.startIndex { diff --git a/Tests/PenpotAPITests/PenpotShapeRendererTests.swift b/Tests/PenpotAPITests/PenpotShapeRendererTests.swift index 33177c9e..d042e963 100644 --- a/Tests/PenpotAPITests/PenpotShapeRendererTests.swift +++ b/Tests/PenpotAPITests/PenpotShapeRendererTests.swift @@ -1,7 +1,9 @@ +// swiftlint:disable file_length import Foundation @testable import PenpotAPI import Testing +// swiftlint:disable type_body_length @Suite("PenpotShapeRenderer") struct PenpotShapeRendererTests { // MARK: - Basic Rendering @@ -35,7 +37,7 @@ struct PenpotShapeRendererTests { root: makeFrame(id: "root", x: 0, y: 0, width: 24, height: 24, children: ["rect1"]), children: [ "rect1": PenpotShape( - id: "rect1", name: "bg", type: "rect", + id: "rect1", name: "bg", type: .rect, x: 2, y: 2, width: 20, height: 20, rotation: nil, selrect: nil, content: nil, fills: [.init(fillColor: "#FF0000", fillOpacity: 0.5)], @@ -65,7 +67,7 @@ struct PenpotShapeRendererTests { root: makeFrame(id: "root", x: 0, y: 0, width: 16, height: 16, children: ["circle1"]), children: [ "circle1": PenpotShape( - id: "circle1", name: "dot", type: "circle", + id: "circle1", name: "dot", type: .circle, x: 4, y: 4, width: 8, height: 8, rotation: nil, selrect: nil, content: nil, fills: [.init(fillColor: "#00FF00", fillOpacity: 1.0)], @@ -92,7 +94,7 @@ struct PenpotShapeRendererTests { root: makeFrame(id: "root", x: 0, y: 0, width: 16, height: 16, children: ["bool1"]), children: [ "bool1": PenpotShape( - id: "bool1", name: "Union", type: "bool", + id: "bool1", name: "Union", type: .bool, x: nil, y: nil, width: nil, height: nil, rotation: nil, selrect: nil, content: .path("M2,4L8,2L14,4L14,12L8,14L2,12Z"), fills: [.init(fillColor: "#0000FF", fillOpacity: 1.0)], @@ -118,7 +120,7 @@ struct PenpotShapeRendererTests { children: [ "visible": makePathShape(content: "M0,0L16,16", strokeColor: "#000"), "hidden": PenpotShape( - id: "hidden", name: "hidden", type: "path", + id: "hidden", name: "hidden", type: .path, x: nil, y: nil, width: nil, height: nil, rotation: nil, selrect: nil, content: .path("M0,16L16,0"), fills: nil, strokes: nil, svgAttrs: nil, hideFillOnExport: nil, @@ -147,7 +149,7 @@ struct PenpotShapeRendererTests { root: makeFrame(id: "root", x: 0, y: 0, width: 16, height: 16, children: ["p"]), children: [ "p": PenpotShape( - id: "p", name: "line", type: "path", + id: "p", name: "line", type: .path, x: nil, y: nil, width: nil, height: nil, rotation: nil, selrect: nil, content: .path("M0,0L16,16"), fills: [], @@ -188,6 +190,184 @@ struct PenpotShapeRendererTests { #expect(result.contains("-5")) } + // MARK: - Nested Groups + + @Test("Renders nested group containing paths") + func nestedGroup() throws { + let innerPath = PenpotShape( + id: "inner-path", name: "inner", type: .path, + x: nil, y: nil, width: nil, height: nil, rotation: nil, selrect: nil, + content: .path("M2,2L14,14"), + fills: [.init(fillColor: "#FF0000", fillOpacity: 1.0)], + strokes: nil, svgAttrs: nil, hideFillOnExport: nil, + shapes: nil, boolType: nil, r1: nil, r2: nil, r3: nil, r4: nil, + transform: nil, opacity: nil, hidden: nil + ) + let group = PenpotShape( + id: "group1", name: "group", type: .group, + x: nil, y: nil, width: nil, height: nil, rotation: nil, selrect: nil, + content: nil, fills: nil, strokes: nil, svgAttrs: nil, hideFillOnExport: nil, + shapes: ["inner-path"], boolType: nil, r1: nil, r2: nil, r3: nil, r4: nil, + transform: nil, opacity: nil, hidden: nil + ) + let root = makeFrame(id: "root", x: 0, y: 0, width: 16, height: 16, children: ["group1"]) + var objects: [String: PenpotShape] = [:] + objects["root"] = root + objects["group1"] = group + objects["inner-path"] = innerPath + + let svg = try #require(PenpotShapeRenderer.renderSVG(objects: objects, rootId: "root")) + #expect(svg.contains("")) + #expect(svg.contains("")) + } + + @Test("Renders deeply nested frame inside group") + func deepNesting() throws { + let leaf = PenpotShape( + id: "leaf", name: "leaf", type: .rect, + x: 5, y: 5, width: 6, height: 6, rotation: nil, selrect: nil, + content: nil, fills: [.init(fillColor: "#00FF00", fillOpacity: 1.0)], + strokes: nil, svgAttrs: nil, hideFillOnExport: nil, + shapes: nil, boolType: nil, r1: nil, r2: nil, r3: nil, r4: nil, + transform: nil, opacity: nil, hidden: nil + ) + let innerFrame = PenpotShape( + id: "inner-frame", name: "inner", type: .frame, + x: nil, y: nil, width: nil, height: nil, rotation: nil, selrect: nil, + content: nil, fills: nil, strokes: nil, svgAttrs: nil, hideFillOnExport: nil, + shapes: ["leaf"], boolType: nil, r1: nil, r2: nil, r3: nil, r4: nil, + transform: nil, opacity: nil, hidden: nil + ) + let group = PenpotShape( + id: "g1", name: "wrapper", type: .group, + x: nil, y: nil, width: nil, height: nil, rotation: nil, selrect: nil, + content: nil, fills: nil, strokes: nil, svgAttrs: nil, hideFillOnExport: nil, + shapes: ["inner-frame"], boolType: nil, r1: nil, r2: nil, r3: nil, r4: nil, + transform: nil, opacity: nil, hidden: nil + ) + let root = makeFrame(id: "root", x: 0, y: 0, width: 16, height: 16, children: ["g1"]) + let objects: [String: PenpotShape] = [ + "root": root, "g1": group, "inner-frame": innerFrame, "leaf": leaf, + ] + + let svg = try #require(PenpotShapeRenderer.renderSVG(objects: objects, rootId: "root")) + #expect(svg.contains(" PenpotShape { PenpotShape( - id: id, name: "frame", type: "frame", + id: id, name: "frame", type: .frame, x: x, y: y, width: width, height: height, rotation: nil, selrect: .init(x: x, y: y, width: width, height: height), content: nil, fills: nil, strokes: nil, svgAttrs: nil, hideFillOnExport: nil, @@ -206,7 +386,7 @@ struct PenpotShapeRendererTests { private func makePathShape(content: String, strokeColor: String) -> PenpotShape { PenpotShape( - id: UUID().uuidString, name: "path", type: "path", + id: UUID().uuidString, name: "path", type: .path, x: nil, y: nil, width: nil, height: nil, rotation: nil, selrect: nil, content: .path(content), fills: [], From a70914cf7765c0cb7d1d36a25da4b68efe3ec6e3 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sun, 22 Mar 2026 18:36:05 +0500 Subject: [PATCH 18/26] ci: trigger DocC deploy on tag push instead of release event Aligns deploy-docc.yml trigger with release.yml so both workflows run in parallel when a version tag is pushed. --- .github/workflows/deploy-docc.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-docc.yml b/.github/workflows/deploy-docc.yml index 7f9fa9db..7b4235e2 100644 --- a/.github/workflows/deploy-docc.yml +++ b/.github/workflows/deploy-docc.yml @@ -1,8 +1,10 @@ name: Deploy DocC on: - release: - types: [published] + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + - 'v[0-9]+.[0-9]+.[0-9]+-*' workflow_dispatch: permissions: From 257cfeeab8635f844df54727dc880ceb9b9c000a Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sun, 22 Mar 2026 18:38:06 +0500 Subject: [PATCH 19/26] chore(hk): disable stash in pre-commit hook Switch from patch-file to none to avoid stash overhead during commits. --- hk.pkl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hk.pkl b/hk.pkl index 981821a8..c2cf3946 100644 --- a/hk.pkl +++ b/hk.pkl @@ -125,7 +125,7 @@ hooks { // Using patch-file stash mode to properly handle untracked files ["pre-commit"] { fix = true - stash = "patch-file" + stash = "none" steps = all_linters } From d3920851874f24eec15afd088597b5dd990d63c1 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sun, 22 Mar 2026 18:49:31 +0500 Subject: [PATCH 20/26] refactor(penpot): extract PenpotAPI into external package swift-penpot-api MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the standalone PenpotAPI module to DesignPipe/swift-penpot-api (v0.1.0), mirroring the swift-figma-api extraction pattern. No code changes in consumer files — `import PenpotAPI` continues to work via the external SPM dependency. Also fix pre-existing test failure in ExFigErrorFormatterTests where the assertion text didn't match the updated accessTokenNotFound error message. --- CLAUDE.md | 72 +-- Package.resolved | 11 +- Package.swift | 24 +- Sources/PenpotAPI/CLAUDE.md | 58 --- Sources/PenpotAPI/Client/PenpotAPIError.swift | 48 -- Sources/PenpotAPI/Client/PenpotClient.swift | 180 -------- Sources/PenpotAPI/Client/PenpotEndpoint.swift | 19 - .../PenpotAPI/Endpoints/GetFileEndpoint.swift | 26 -- .../GetFileObjectThumbnailsEndpoint.swift | 40 -- .../Endpoints/GetProfileEndpoint.swift | 22 - Sources/PenpotAPI/Models/PenpotColor.swift | 30 -- .../PenpotAPI/Models/PenpotComponent.swift | 72 --- .../PenpotAPI/Models/PenpotFileResponse.swift | 29 -- Sources/PenpotAPI/Models/PenpotProfile.swift | 13 - Sources/PenpotAPI/Models/PenpotShape.swift | 193 -------- .../PenpotAPI/Models/PenpotTypography.swift | 100 ----- .../Renderer/PenpotShapeRenderer.swift | 369 ---------------- .../TerminalUI/ExFigErrorFormatterTests.swift | 2 +- .../Fixtures/file-response.json | 70 --- .../PenpotAPITests/PenpotAPIErrorTests.swift | 46 -- .../PenpotColorDecodingTests.swift | 67 --- .../PenpotComponentDecodingTests.swift | 54 --- .../PenpotAPITests/PenpotEndpointTests.swift | 44 -- .../PenpotShapeRendererTests.swift | 414 ------------------ .../PenpotTypographyDecodingTests.swift | 83 ---- Tests/PenpotAPITests/README.md | 59 --- 26 files changed, 49 insertions(+), 2096 deletions(-) delete mode 100644 Sources/PenpotAPI/CLAUDE.md delete mode 100644 Sources/PenpotAPI/Client/PenpotAPIError.swift delete mode 100644 Sources/PenpotAPI/Client/PenpotClient.swift delete mode 100644 Sources/PenpotAPI/Client/PenpotEndpoint.swift delete mode 100644 Sources/PenpotAPI/Endpoints/GetFileEndpoint.swift delete mode 100644 Sources/PenpotAPI/Endpoints/GetFileObjectThumbnailsEndpoint.swift delete mode 100644 Sources/PenpotAPI/Endpoints/GetProfileEndpoint.swift delete mode 100644 Sources/PenpotAPI/Models/PenpotColor.swift delete mode 100644 Sources/PenpotAPI/Models/PenpotComponent.swift delete mode 100644 Sources/PenpotAPI/Models/PenpotFileResponse.swift delete mode 100644 Sources/PenpotAPI/Models/PenpotProfile.swift delete mode 100644 Sources/PenpotAPI/Models/PenpotShape.swift delete mode 100644 Sources/PenpotAPI/Models/PenpotTypography.swift delete mode 100644 Sources/PenpotAPI/Renderer/PenpotShapeRenderer.swift delete mode 100644 Tests/PenpotAPITests/Fixtures/file-response.json delete mode 100644 Tests/PenpotAPITests/PenpotAPIErrorTests.swift delete mode 100644 Tests/PenpotAPITests/PenpotColorDecodingTests.swift delete mode 100644 Tests/PenpotAPITests/PenpotComponentDecodingTests.swift delete mode 100644 Tests/PenpotAPITests/PenpotEndpointTests.swift delete mode 100644 Tests/PenpotAPITests/PenpotShapeRendererTests.swift delete mode 100644 Tests/PenpotAPITests/PenpotTypographyDecodingTests.swift delete mode 100644 Tests/PenpotAPITests/README.md diff --git a/CLAUDE.md b/CLAUDE.md index b4b6c9bd..8e1a4634 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,23 +98,22 @@ pkl eval --format json # Package URI requires published package ## Architecture -Thirteen modules in `Sources/`: - -| Module | Purpose | -| --------------- | ----------------------------------------------------------- | -| `ExFigCLI` | CLI commands, loaders, file I/O, terminal UI | -| `ExFigCore` | Domain models (Color, Image, TextStyle), processors | -| `ExFigConfig` | PKL config parsing, evaluation, type bridging | -| `ExFig-iOS` | iOS platform plugin (ColorsExporter, IconsExporter, etc.) | -| `ExFig-Android` | Android platform plugin | -| `ExFig-Flutter` | Flutter platform plugin | -| `ExFig-Web` | Web platform plugin | -| `XcodeExport` | iOS export (.xcassets, Swift extensions) | -| `AndroidExport` | Android export (XML resources, Compose, Vector Drawables) | -| `FlutterExport` | Flutter export (Dart code, SVG/PNG assets) | -| `WebExport` | Web/React export (CSS variables, JSX icons) | -| `JinjaSupport` | Shared Jinja2 template rendering across Export modules | -| `PenpotAPI` | Penpot RPC API client (standalone, no ExFigCore dependency) | +Twelve modules in `Sources/`: + +| Module | Purpose | +| --------------- | --------------------------------------------------------- | +| `ExFigCLI` | CLI commands, loaders, file I/O, terminal UI | +| `ExFigCore` | Domain models (Color, Image, TextStyle), processors | +| `ExFigConfig` | PKL config parsing, evaluation, type bridging | +| `ExFig-iOS` | iOS platform plugin (ColorsExporter, IconsExporter, etc.) | +| `ExFig-Android` | Android platform plugin | +| `ExFig-Flutter` | Flutter platform plugin | +| `ExFig-Web` | Web platform plugin | +| `XcodeExport` | iOS export (.xcassets, Swift extensions) | +| `AndroidExport` | Android export (XML resources, Compose, Vector Drawables) | +| `FlutterExport` | Flutter export (Dart code, SVG/PNG assets) | +| `WebExport` | Web/React export (CSS variables, JSX icons) | +| `JinjaSupport` | Shared Jinja2 template rendering across Export modules | **Data flow:** CLI -> PKL config parsing -> FigmaAPI (external) fetch -> ExFigCore processing -> Platform plugin -> Export module -> File write **Alt data flow (tokens):** CLI -> local .tokens.json file -> TokensFileSource -> ExFigCore models -> W3C JSON export @@ -394,24 +393,25 @@ NooraUI.formatLink("url", useColors: true) // underlined primary ## Dependencies -| Package | Version | Purpose | -| --------------------- | ------- | -------------------------------------------------- | -| swift-argument-parser | 1.5.0+ | CLI framework | -| swift-collections | 1.2.x | Ordered collections | -| swift-jinja | 2.0.0+ | Jinja2 template engine | -| XcodeProj | 8.27.0+ | Xcode project manipulation | -| swift-log | 1.6.0+ | Logging | -| Rainbow | 4.2.0+ | Terminal colors | -| libwebp | 1.4.1+ | WebP encoding | -| libpng | 1.6.45+ | PNG decoding | -| swift-custom-dump | 1.3.0+ | Test assertions | -| Noora | 0.54.0+ | Terminal UI design system | -| swift-figma-api | 0.2.0+ | Figma REST API client (async/await, rate limiting) | -| swift-svgkit | 0.1.0+ | SVG parsing, ImageVector/VectorDrawable generation | -| swift-resvg | 0.45.1 | SVG parsing/rendering | -| swift-docc-plugin | 1.4.5+ | DocC documentation | -| swift-yyjson | 0.5.0+ | High-performance JSON codec | -| pkl-swift | 0.8.0+ | PKL config evaluation & codegen | +| Package | Version | Purpose | +| --------------------- | ------- | ------------------------------------------------------- | +| swift-argument-parser | 1.5.0+ | CLI framework | +| swift-collections | 1.2.x | Ordered collections | +| swift-jinja | 2.0.0+ | Jinja2 template engine | +| XcodeProj | 8.27.0+ | Xcode project manipulation | +| swift-log | 1.6.0+ | Logging | +| Rainbow | 4.2.0+ | Terminal colors | +| libwebp | 1.4.1+ | WebP encoding | +| libpng | 1.6.45+ | PNG decoding | +| swift-custom-dump | 1.3.0+ | Test assertions | +| Noora | 0.54.0+ | Terminal UI design system | +| swift-figma-api | 0.2.0+ | Figma REST API client (async/await, rate limiting) | +| swift-penpot-api | 0.1.0+ | Penpot RPC API client (async/await, SVG reconstruction) | +| swift-svgkit | 0.1.0+ | SVG parsing, ImageVector/VectorDrawable generation | +| swift-resvg | 0.45.1 | SVG parsing/rendering | +| swift-docc-plugin | 1.4.5+ | DocC documentation | +| swift-yyjson | 0.5.0+ | High-performance JSON codec | +| pkl-swift | 0.8.0+ | PKL config evaluation & codegen | ## Troubleshooting @@ -454,7 +454,7 @@ NooraUI.formatLink("url", useColors: true) // underlined primary | `ColorsConfigError` new case | Has TWO switch blocks (`errorDescription` + `recoverySuggestion`) — adding a case to one without the other causes exhaustive switch error | | 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") | -| `JSONCodec` in standalone module | `JSONCodec` lives in ExFigCore — standalone modules (PenpotAPI) use `YYJSONEncoder()`/`YYJSONDecoder()` from YYJSON directly | +| `JSONCodec` in standalone module | `JSONCodec` lives in ExFigCore — external packages (swift-penpot-api) use `YYJSONEncoder()`/`YYJSONDecoder()` from YYJSON directly | | `function_body_length` after branch | Split into private extension helper methods (e.g., `penpotColorsSourceInput()`, `tokensFileColorsSourceInput()`) | | `ExFigCommand.terminalUI` in tests | Implicitly unwrapped — must init in `setUp()`: `ExFigCommand.terminalUI = TerminalUI(outputMode: .quiet)` before testing code that uses it (SourceFactory, Penpot sources) | | `--timeout` duplicate in `fetch` | `FetchImages` uses both `DownloadOptions` and `HeavyFaultToleranceOptions` which both define `--timeout`. Fix: inline Heavy options + computed property | diff --git a/Package.resolved b/Package.resolved index 64feef42..0f05d0ec 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "4a819b3065360bf98b89befa8fb35fa89db17992c9cce7813c9b1eb3c57b8221", + "originHash" : "2b3e6002adcb3e450ba06731dcbfc333d3f62894c0ca599c86c390156f0cf048", "pins" : [ { "identity" : "aexml", @@ -199,6 +199,15 @@ "version" : "2.96.0" } }, + { + "identity" : "swift-penpot-api", + "kind" : "remoteSourceControl", + "location" : "https://github.com/DesignPipe/swift-penpot-api.git", + "state" : { + "revision" : "3f0ed762b6130d32e96ae0624b2ad084756847b5", + "version" : "0.1.0" + } + }, { "identity" : "swift-resvg", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 052ce43a..dd10ef1f 100644 --- a/Package.swift +++ b/Package.swift @@ -28,6 +28,7 @@ let package = Package( .package(url: "https://github.com/apple/pkl-swift", from: "0.8.0"), .package(url: "https://github.com/DesignPipe/swift-svgkit.git", from: "0.1.0"), .package(url: "https://github.com/DesignPipe/swift-figma-api.git", from: "0.2.0"), + .package(url: "https://github.com/DesignPipe/swift-penpot-api.git", from: "0.1.0"), .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.9.0"), ], targets: [ @@ -36,7 +37,7 @@ let package = Package( name: "ExFigCLI", dependencies: [ .product(name: "FigmaAPI", package: "swift-figma-api"), - "PenpotAPI", + .product(name: "PenpotAPI", package: "swift-penpot-api"), "ExFigCore", "ExFigConfig", "XcodeExport", @@ -147,15 +148,6 @@ let package = Package( ] ), - // Penpot API client - .target( - name: "PenpotAPI", - dependencies: [ - .product(name: "YYJSON", package: "swift-yyjson"), - ], - exclude: ["CLAUDE.md"] - ), - // MARK: - Platform Plugins // iOS platform plugin @@ -258,18 +250,6 @@ let package = Package( ] ), - .testTarget( - name: "PenpotAPITests", - dependencies: [ - "PenpotAPI", - .product(name: "CustomDump", package: "swift-custom-dump"), - ], - exclude: ["README.md"], - resources: [ - .copy("Fixtures/"), - ] - ), - // MARK: - Plugin Tests .testTarget( diff --git a/Sources/PenpotAPI/CLAUDE.md b/Sources/PenpotAPI/CLAUDE.md deleted file mode 100644 index 1b5f8a41..00000000 --- a/Sources/PenpotAPI/CLAUDE.md +++ /dev/null @@ -1,58 +0,0 @@ -# PenpotAPI Module - -Standalone HTTP client for Penpot RPC API. Zero dependencies on ExFigCore/ExFigCLI/FigmaAPI. -Only external dependency: swift-yyjson for JSON parsing. - -## Architecture - -- `PenpotEndpoint` protocol — RPC-style: `POST /api/main/methods/` -- `PenpotClient` protocol + `BasePenpotClient` — URLSession, auth, retry -- `PenpotAPIError` — LocalizedError with recovery suggestions -- Models use standard Codable (Penpot JSON is camelCase via `json/write-camel-key` middleware) - -## Key Patterns - -- All endpoints are POST to `/api/main/methods/` with JSON body -- `Accept: application/json` header (NOT transit+json) — ensures camelCase keys -- `Authorization: Token ` header -- Simple retry (3 attempts, exponential backoff) for 429/5xx -- Typography numeric fields may be String OR Number — custom init(from:) handles both -- `GetProfileEndpoint` sends `{}` body (not nil) — Penpot returns 400 "malformed-json" for empty body - -## API Path - -Two equivalent paths exist: - -- `/api/main/methods/` — **used by this module**; works with URLSession against design.penpot.app -- `/api/rpc/command/` — official docs path; blocked by Cloudflare JS challenge on design.penpot.app for programmatic clients - -Self-hosted Penpot instances (without Cloudflare) accept both paths. -If switching to `/api/rpc/command/`, update `BasePenpotClient.buildURL(for:)`. - -## Conventions - -- All model fields are `let` (immutable) — no post-construction mutation needed -- Kebab-case request keys: use `Codable` struct with `CodingKeys` + `YYJSONEncoder`, NOT `JSONSerialization` -- `BasePenpotClient` validates `maxRetries >= 1` and `!accessToken.isEmpty` via preconditions -- Retry loop respects `CancellationError` — rethrows immediately instead of retrying -- `download()` includes response body in error message for diagnostics -- `performWithRetry` must `throw` on the final attempt for retryable errors — falling through to `return` would return the error response as success data -- `BasePenpotClient.init` validates `baseURL` via precondition — invalid URLs fail at construction, not at first request -- `download(path:)` handles both absolute URLs (`http://...`) and relative paths — does NOT blindly prepend `baseURL` -- `download()` must NOT send `Authorization` header — Penpot assets are served via S3/MinIO presigned URLs that conflict with extra auth -- Thumbnail download path is `assets/by-id/`, NOT `assets/by-file-media-id/` -- `get-file-object-thumbnails` returns compound keys (`fileId/pageId/objectId/type`), not simple component IDs — v1 limitation - -## SVG Reconstruction - -Icons/images exported via shape tree → SVG (no CDN, no headless Chrome): - -- `PenpotShape.ShapeType` enum (not String) — `.path`, `.rect`, `.circle`, `.group`, `.frame`, `.bool`, `.unknown(String)`. Exhaustive switch in renderer. -- `PenpotComponent.MainInstance` struct pairs `id` + `page`. Backward-compat computed properties `mainInstanceId`/`mainInstancePage`. -- `renderSVGResult()` returns `Result` — includes `skippedShapeTypes: Set` and typed failure reasons (`.rootNotFound`, `.missingSelrect`). `renderSVG()` is a convenience wrapper returning `String?`. -- Shapes are in canvas-space — subtract root frame's `selrect.x/y` to normalize -- Arc (`A`) command normalization: 7 params per segment, only params 5 (x) and 6 (y) get origin offset. Tracked via `PathNormState.arcParamIndex`. -- `normalizePathCoordinates` helpers extracted into `PathNormState`, `parseNumber`, `offsetValue` to stay under SwiftLint cyclomatic_complexity/function_body_length limits. -- `svgAttrs` has mixed types (string values + nested `style` dict) — `SVGAttributes` type extracts strings only -- Components need `mainInstanceId` + `mainInstancePage` (via `MainInstance` struct) for shape tree lookup -- Linked libraries: use `get-file` with library file ID (from `get-file-libraries`) diff --git a/Sources/PenpotAPI/Client/PenpotAPIError.swift b/Sources/PenpotAPI/Client/PenpotAPIError.swift deleted file mode 100644 index bedc7118..00000000 --- a/Sources/PenpotAPI/Client/PenpotAPIError.swift +++ /dev/null @@ -1,48 +0,0 @@ -import Foundation - -/// Error type for Penpot API failures. -public struct PenpotAPIError: LocalizedError, Sendable { - /// HTTP status code (0 for non-HTTP errors). - public let statusCode: Int - - /// Error message from the API or client. - public let message: String? - - /// The endpoint command name that failed. - public let endpoint: String - - public init(statusCode: Int, message: String?, endpoint: String) { - self.statusCode = statusCode - self.message = message - self.endpoint = endpoint - } - - public var errorDescription: String? { - if let message { - "Penpot API error (\(endpoint)): \(statusCode) — \(message)" - } else { - "Penpot API error (\(endpoint)): HTTP \(statusCode)" - } - } - - public var recoverySuggestion: String? { - switch statusCode { - case 401: - "Check that PENPOT_ACCESS_TOKEN environment variable is set with a valid access token. " + - "Generate one at Settings → Access Tokens in your Penpot instance." - case 403: - "You don't have permission to access this resource. Check file sharing settings." - case 404: - "The requested resource was not found. Verify the file UUID is correct." - case 429: - "Rate limited by Penpot API. The request was retried but still failed. Try again later." - case 500 ... 599: - "Penpot server error. This may be temporary — try again in a few minutes. " + - "If the problem persists, check your Penpot instance status." - case 0: - "A network error occurred. Check your internet connection and verify the Penpot base URL is correct." - default: - nil - } - } -} diff --git a/Sources/PenpotAPI/Client/PenpotClient.swift b/Sources/PenpotAPI/Client/PenpotClient.swift deleted file mode 100644 index 58256945..00000000 --- a/Sources/PenpotAPI/Client/PenpotClient.swift +++ /dev/null @@ -1,180 +0,0 @@ -import Foundation -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif -import YYJSON - -/// Protocol for Penpot API clients. -public protocol PenpotClient: Sendable { - /// Executes a Penpot RPC endpoint and returns the decoded content. - func request(_ endpoint: T) async throws -> T.Content - - /// Downloads raw binary data from a URL path (e.g., asset downloads). - func download(path: String) async throws -> Data -} - -/// Default Penpot API client with authentication and retry logic. -public struct BasePenpotClient: PenpotClient { - /// Default Penpot cloud base URL. - public static let defaultBaseURL = "https://design.penpot.app/" - - private let accessToken: String - private let baseURL: String - private let session: URLSession - private let maxRetries: Int - - public init( - accessToken: String, - baseURL: String = Self.defaultBaseURL, - timeout: TimeInterval = 60, - maxRetries: Int = 3 - ) { - precondition(maxRetries >= 1, "maxRetries must be at least 1") - precondition(!accessToken.isEmpty, "accessToken must not be empty") - - let normalizedURL = baseURL.hasSuffix("/") ? baseURL : baseURL + "/" - precondition(URL(string: normalizedURL) != nil, "baseURL must be a valid URL: \(baseURL)") - - self.accessToken = accessToken - self.baseURL = normalizedURL - self.maxRetries = maxRetries - - let config = URLSessionConfiguration.ephemeral - config.timeoutIntervalForRequest = timeout - session = URLSession(configuration: config) - } - - public func request(_ endpoint: T) async throws -> T.Content { - let url = try buildURL(for: endpoint) - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("Token \(accessToken)", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - if let body = try endpoint.body() { - request.httpBody = body - } - - let (data, response) = try await performWithRetry(request: request, endpoint: endpoint.commandName) - - guard let httpResponse = response as? HTTPURLResponse else { - throw PenpotAPIError(statusCode: 0, message: "Invalid response type", endpoint: endpoint.commandName) - } - - guard (200 ..< 300).contains(httpResponse.statusCode) else { - let message = String(data: data, encoding: .utf8) - throw PenpotAPIError( - statusCode: httpResponse.statusCode, - message: message, - endpoint: endpoint.commandName - ) - } - - do { - return try endpoint.content(from: data) - } catch let error as PenpotAPIError { - throw error - } catch { - throw PenpotAPIError( - statusCode: httpResponse.statusCode, - message: "Failed to decode response for '\(endpoint.commandName)': \(error.localizedDescription)", - endpoint: endpoint.commandName - ) - } - } - - public func download(path: String) async throws -> Data { - let isAbsolute = path.hasPrefix("http://") || path.hasPrefix("https://") - let urlString = isAbsolute ? path : baseURL + path - guard let url = URL(string: urlString) else { - throw PenpotAPIError(statusCode: 0, message: "Invalid download URL: \(path)", endpoint: "download") - } - - var request = URLRequest(url: url) - request.httpMethod = "GET" - // Penpot asset storage (S3/MinIO) uses presigned URLs that conflict - // with an Authorization header. Do not send auth for asset downloads. - - let (data, response) = try await performWithRetry(request: request, endpoint: "download") - - guard let httpResponse = response as? HTTPURLResponse, - (200 ..< 300).contains(httpResponse.statusCode) - else { - let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 - let message = String(data: data, encoding: .utf8) ?? "Download failed" - throw PenpotAPIError(statusCode: statusCode, message: message, endpoint: "download") - } - - return data - } - - // MARK: - Private - - private func buildURL(for endpoint: some PenpotEndpoint) throws -> URL { - guard let url = URL(string: "\(baseURL)api/main/methods/\(endpoint.commandName)") else { - throw PenpotAPIError( - statusCode: 0, - message: "Failed to construct URL for command: \(endpoint.commandName)", - endpoint: endpoint.commandName - ) - } - return url - } - - private func performWithRetry( - request: URLRequest, - endpoint: String - ) async throws -> (Data, URLResponse) { - var lastError: Error? - - for attempt in 0 ..< maxRetries { - do { - try Task.checkCancellation() - - let (data, response) = try await session.data(for: request) - - if let httpResponse = response as? HTTPURLResponse { - let statusCode = httpResponse.statusCode - - // Retry on 429 or 5xx - if statusCode == 429 || (500 ..< 600).contains(statusCode) { - let error = PenpotAPIError( - statusCode: statusCode, - message: String(data: data, encoding: .utf8), - endpoint: endpoint - ) - - if attempt < maxRetries - 1 { - lastError = error - let delay = pow(2.0, Double(attempt)) // 1s, 2s, 4s - try await Task.sleep(for: .seconds(delay)) - continue - } - throw error - } - } - - return (data, response) - } catch is CancellationError { - throw CancellationError() - } catch let error as PenpotAPIError { - throw error - } catch { - lastError = error - - if attempt < maxRetries - 1 { - let delay = pow(2.0, Double(attempt)) - try await Task.sleep(for: .seconds(delay)) - continue - } - } - } - - throw lastError ?? PenpotAPIError( - statusCode: 0, - message: "Request failed after \(maxRetries) retries", - endpoint: endpoint - ) - } -} diff --git a/Sources/PenpotAPI/Client/PenpotEndpoint.swift b/Sources/PenpotAPI/Client/PenpotEndpoint.swift deleted file mode 100644 index 5047a456..00000000 --- a/Sources/PenpotAPI/Client/PenpotEndpoint.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Foundation - -/// Protocol for Penpot RPC API endpoints. -/// -/// All Penpot API calls are `POST /api/main/methods/` with a JSON body. -/// The official docs path `/api/rpc/command/` is equivalent but blocked -/// by Cloudflare on design.penpot.app for programmatic clients. -public protocol PenpotEndpoint: Sendable { - associatedtype Content: Sendable - - /// The RPC command name (e.g., "get-file", "get-profile"). - var commandName: String { get } - - /// Serializes the request body. Penpot requires at minimum an empty JSON object `{}`. - func body() throws -> Data? - - /// Deserializes the response data into the expected content type. - func content(from data: Data) throws -> Content -} diff --git a/Sources/PenpotAPI/Endpoints/GetFileEndpoint.swift b/Sources/PenpotAPI/Endpoints/GetFileEndpoint.swift deleted file mode 100644 index c05cc44f..00000000 --- a/Sources/PenpotAPI/Endpoints/GetFileEndpoint.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Foundation -import YYJSON - -/// Retrieves a complete Penpot file with library assets. -/// -/// Command: `get-file` -/// Body: `{"id": ""}` -public struct GetFileEndpoint: PenpotEndpoint { - public typealias Content = PenpotFileResponse - - public let commandName = "get-file" - private let fileId: String - - public init(fileId: String) { - precondition(!fileId.isEmpty, "fileId must not be empty") - self.fileId = fileId - } - - public func body() throws -> Data? { - try YYJSONEncoder().encode(["id": fileId]) - } - - public func content(from data: Data) throws -> PenpotFileResponse { - try YYJSONDecoder().decode(PenpotFileResponse.self, from: data) - } -} diff --git a/Sources/PenpotAPI/Endpoints/GetFileObjectThumbnailsEndpoint.swift b/Sources/PenpotAPI/Endpoints/GetFileObjectThumbnailsEndpoint.swift deleted file mode 100644 index 464bac6d..00000000 --- a/Sources/PenpotAPI/Endpoints/GetFileObjectThumbnailsEndpoint.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation -import YYJSON - -/// Retrieves thumbnail media IDs for file objects (components). -/// -/// Command: `get-file-object-thumbnails` -/// Body: `{"file-id": "", "object-ids": ["", ...]}` -/// Response: Dictionary mapping object UUIDs to thumbnail URLs/media IDs. -public struct GetFileObjectThumbnailsEndpoint: PenpotEndpoint { - public typealias Content = [String: String] - - public let commandName = "get-file-object-thumbnails" - private let fileId: String - private let objectIds: [String] - - public init(fileId: String, objectIds: [String]) { - self.fileId = fileId - self.objectIds = objectIds - } - - /// Penpot RPC uses kebab-case keys — CodingKeys map camelCase to kebab-case. - private struct Body: Encodable { - let fileId: String - let objectIds: [String] - - enum CodingKeys: String, CodingKey { - case fileId = "file-id" - case objectIds = "object-ids" - } - } - - public func body() throws -> Data? { - try YYJSONEncoder().encode(Body(fileId: fileId, objectIds: objectIds)) - } - - public func content(from data: Data) throws -> [String: String] { - // Response is a flat object: { "": "" } - try YYJSONDecoder().decode([String: String].self, from: data) - } -} diff --git a/Sources/PenpotAPI/Endpoints/GetProfileEndpoint.swift b/Sources/PenpotAPI/Endpoints/GetProfileEndpoint.swift deleted file mode 100644 index ceec423f..00000000 --- a/Sources/PenpotAPI/Endpoints/GetProfileEndpoint.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Foundation -import YYJSON - -/// Retrieves the authenticated user's profile. -/// -/// Command: `get-profile` -/// Body: `{}` (empty JSON object — Penpot requires non-nil body) -public struct GetProfileEndpoint: PenpotEndpoint { - public typealias Content = PenpotProfile - - public let commandName = "get-profile" - - public init() {} - - public func body() throws -> Data? { - Data("{}".utf8) - } - - public func content(from data: Data) throws -> PenpotProfile { - try YYJSONDecoder().decode(PenpotProfile.self, from: data) - } -} diff --git a/Sources/PenpotAPI/Models/PenpotColor.swift b/Sources/PenpotAPI/Models/PenpotColor.swift deleted file mode 100644 index db7b0103..00000000 --- a/Sources/PenpotAPI/Models/PenpotColor.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation - -/// A library color from a Penpot file. -/// -/// Solid colors have a non-nil `color` hex string. Gradient colors -/// have `nil` color and should be filtered out in v1. -public struct PenpotColor: Decodable, Sendable { - /// Unique identifier. - public let id: String - - /// Display name. - public let name: String - - /// Slash-separated group path (e.g., "Brand/Primary"). - public let path: String? - - /// Hex color value (e.g., "#3366FF"). Nil for gradient fills. - public let color: String? - - /// Opacity (0.0–1.0). Defaults to 1.0 if absent. - public let opacity: Double? - - public init(id: String, name: String, path: String? = nil, color: String? = nil, opacity: Double? = nil) { - self.id = id - self.name = name - self.path = path - self.color = color - self.opacity = opacity - } -} diff --git a/Sources/PenpotAPI/Models/PenpotComponent.swift b/Sources/PenpotAPI/Models/PenpotComponent.swift deleted file mode 100644 index 209f084f..00000000 --- a/Sources/PenpotAPI/Models/PenpotComponent.swift +++ /dev/null @@ -1,72 +0,0 @@ -import Foundation - -/// A library component from a Penpot file. -public struct PenpotComponent: Decodable, Sendable { - /// Unique identifier. - public let id: String - - /// Display name. - public let name: String - - /// Slash-separated group path (e.g., "Icons/Navigation"). - public let path: String? - - /// Main instance location on the canvas (both or neither present). - /// Needed for SVG reconstruction from the shape tree. - public let mainInstance: MainInstance? - - /// Convenience accessors for backward compatibility. - public var mainInstanceId: String? { - mainInstance?.id - } - - public var mainInstancePage: String? { - mainInstance?.page - } - - /// Paired instance ID and page UUID for shape tree lookup. - public struct MainInstance: Sendable, Equatable { - public let id: String - public let page: String - - public init(id: String, page: String) { - self.id = id - self.page = page - } - } - - enum CodingKeys: String, CodingKey { - case id, name, path, mainInstanceId, mainInstancePage - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(String.self, forKey: .id) - name = try container.decode(String.self, forKey: .name) - path = try container.decodeIfPresent(String.self, forKey: .path) - let instanceId = try container.decodeIfPresent(String.self, forKey: .mainInstanceId) - let instancePage = try container.decodeIfPresent(String.self, forKey: .mainInstancePage) - if let instanceId, let instancePage { - mainInstance = MainInstance(id: instanceId, page: instancePage) - } else { - mainInstance = nil - } - } - - public init( - id: String, - name: String, - path: String? = nil, - mainInstanceId: String? = nil, - mainInstancePage: String? = nil - ) { - self.id = id - self.name = name - self.path = path - if let mainInstanceId, let mainInstancePage { - mainInstance = MainInstance(id: mainInstanceId, page: mainInstancePage) - } else { - mainInstance = nil - } - } -} diff --git a/Sources/PenpotAPI/Models/PenpotFileResponse.swift b/Sources/PenpotAPI/Models/PenpotFileResponse.swift deleted file mode 100644 index 22380958..00000000 --- a/Sources/PenpotAPI/Models/PenpotFileResponse.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Foundation - -/// Top-level response from the `get-file` endpoint. -public struct PenpotFileResponse: Decodable, Sendable { - /// The file data containing library assets. - public let data: PenpotFileData - - /// File ID. - public let id: String - - /// File name. - public let name: String -} - -/// File data with selective decoding of library assets. -public struct PenpotFileData: Decodable, Sendable { - /// Library colors keyed by UUID. - public let colors: [String: PenpotColor]? - - /// Library typographies keyed by UUID. - public let typographies: [String: PenpotTypography]? - - /// Library components keyed by UUID. - public let components: [String: PenpotComponent]? - - /// Pages keyed by page UUID, each containing a flat object tree. - /// Used for SVG reconstruction of component shapes. - public let pagesIndex: [String: PenpotPage]? -} diff --git a/Sources/PenpotAPI/Models/PenpotProfile.swift b/Sources/PenpotAPI/Models/PenpotProfile.swift deleted file mode 100644 index 42f2ea9b..00000000 --- a/Sources/PenpotAPI/Models/PenpotProfile.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Foundation - -/// User profile returned by the `get-profile` endpoint. -public struct PenpotProfile: Decodable, Sendable { - /// User ID. - public let id: String - - /// Full display name. - public let fullname: String - - /// Email address. - public let email: String -} diff --git a/Sources/PenpotAPI/Models/PenpotShape.swift b/Sources/PenpotAPI/Models/PenpotShape.swift deleted file mode 100644 index 0e092f74..00000000 --- a/Sources/PenpotAPI/Models/PenpotShape.swift +++ /dev/null @@ -1,193 +0,0 @@ -import Foundation - -/// A shape object from a Penpot page's object tree. -/// -/// Shapes represent SVG-compatible design elements (rect, circle, path, group, frame). -/// Coordinates are in canvas-space — subtract the root frame's origin to normalize. -public struct PenpotShape: Decodable, Sendable { - public let id: String - public let name: String? - public let type: ShapeType - - // Geometry - public let x: Double? - public let y: Double? - public let width: Double? - public let height: Double? - public let rotation: Double? - public let selrect: Selrect? - - /// SVG path data (for path/bool types) - public let content: ShapeContent? - - // Styling - public let fills: [Fill]? - public let strokes: [Stroke]? - public let svgAttrs: SVGAttributes? - public let hideFillOnExport: Bool? - - /// Tree structure - public let shapes: [String]? - - /// Boolean operations - public let boolType: String? - - // Border radius (for rect type) - public let r1: Double? - public let r2: Double? - public let r3: Double? - public let r4: Double? - - /// Transform matrix - public let transform: Transform? - - public let opacity: Double? - public let hidden: Bool? -} - -// MARK: - Shape Type - -public extension PenpotShape { - /// Known shape types from Penpot's shape tree. - enum ShapeType: Sendable, Equatable, CustomStringConvertible { - case path - case rect - case circle - case group - case frame - case bool - case unknown(String) - - public var description: String { - switch self { - case .path: "path" - case .rect: "rect" - case .circle: "circle" - case .group: "group" - case .frame: "frame" - case .bool: "bool" - case let .unknown(value): value - } - } - } -} - -extension PenpotShape.ShapeType: Decodable { - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - let rawValue = try container.decode(String.self) - switch rawValue { - case "path": self = .path - case "rect": self = .rect - case "circle": self = .circle - case "group": self = .group - case "frame": self = .frame - case "bool": self = .bool - default: self = .unknown(rawValue) - } - } -} - -// MARK: - Supporting Types - -public extension PenpotShape { - /// Content can be a String (SVG path data) or a structured object (text content). - /// We only care about String paths for SVG reconstruction. - enum ShapeContent: Decodable, Sendable { - case path(String) - case other - - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let string = try? container.decode(String.self) { - self = .path(string) - } else { - self = .other - } - } - - public var pathData: String? { - if case let .path(data) = self { return data } - return nil - } - } - - struct Selrect: Decodable, Sendable { - public let x: Double - public let y: Double - public let width: Double - public let height: Double - } - - struct Fill: Decodable, Sendable { - public let fillColor: String? - public let fillOpacity: Double? - } - - struct Stroke: Decodable, Sendable { - public let strokeColor: String? - public let strokeOpacity: Double? - public let strokeWidth: Double? - public let strokeStyle: String? - public let strokeAlignment: String? - public let strokeCapStart: String? - public let strokeCapEnd: String? - } - - struct Transform: Decodable, Sendable { - public let a: Double - public let b: Double - public let c: Double - public let d: Double - public let e: Double - public let f: Double - - /// Whether this is an identity transform. - public var isIdentity: Bool { - a == 1 && b == 0 && c == 0 && d == 1 && e == 0 && f == 0 - } - } - - /// SVG attributes — values can be strings or nested dictionaries. - /// We extract only string values for SVG reconstruction. - struct SVGAttributes: Decodable, Sendable { - public let values: [String: String] - - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - let raw = try container.decode([String: AnyCodable].self) - var result: [String: String] = [:] - for (key, value) in raw { - if case let .string(s) = value { - result[key] = s - } - } - values = result - } - - public subscript(key: String) -> String? { - values[key] - } - - /// Flexible JSON value that handles strings, numbers, bools, and nested structures. - private enum AnyCodable: Decodable, Sendable { - case string(String) - case other - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let s = try? container.decode(String.self) { - self = .string(s) - } else { - self = .other - } - } - } - } -} - -/// A page from a Penpot file containing a flat object tree. -public struct PenpotPage: Decodable, Sendable { - public let name: String? - public let objects: [String: PenpotShape]? -} diff --git a/Sources/PenpotAPI/Models/PenpotTypography.swift b/Sources/PenpotAPI/Models/PenpotTypography.swift deleted file mode 100644 index b582bf8a..00000000 --- a/Sources/PenpotAPI/Models/PenpotTypography.swift +++ /dev/null @@ -1,100 +0,0 @@ -import Foundation - -/// A library typography style from a Penpot file. -/// -/// Numeric fields (`fontSize`, `fontWeight`, `lineHeight`, `letterSpacing`) -/// may arrive as either JSON strings (e.g., `"24"`) or JSON numbers (e.g., `24`) -/// due to Penpot's Clojure→JSON serialization. Custom `init(from:)` handles both. -public struct PenpotTypography: Sendable { - /// Unique identifier. - public let id: String - - /// Display name. - public let name: String - - /// Slash-separated group path. - public let path: String? - - /// Font family name (e.g., "Roboto"). - public let fontFamily: String - - /// Font style (e.g., "italic", "normal"). - public let fontStyle: String? - - /// Text transform (e.g., "uppercase", "lowercase", "none"). - public let textTransform: String? - - /// Font size in points. - public let fontSize: Double? - - /// Font weight (e.g., 400, 700). - public let fontWeight: Double? - - /// Line height value. - public let lineHeight: Double? - - /// Letter spacing value. - public let letterSpacing: Double? - - public init( - id: String, - name: String, - path: String? = nil, - fontFamily: String, - fontStyle: String? = nil, - textTransform: String? = nil, - fontSize: Double? = nil, - fontWeight: Double? = nil, - lineHeight: Double? = nil, - letterSpacing: Double? = nil - ) { - self.id = id - self.name = name - self.path = path - self.fontFamily = fontFamily - self.fontStyle = fontStyle - self.textTransform = textTransform - self.fontSize = fontSize - self.fontWeight = fontWeight - self.lineHeight = lineHeight - self.letterSpacing = letterSpacing - } -} - -extension PenpotTypography: Decodable { - enum CodingKeys: String, CodingKey { - case id, name, path, fontFamily, fontStyle, textTransform - case fontSize, fontWeight, lineHeight, letterSpacing - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(String.self, forKey: .id) - name = try container.decode(String.self, forKey: .name) - path = try container.decodeIfPresent(String.self, forKey: .path) - fontFamily = try container.decode(String.self, forKey: .fontFamily) - fontStyle = try container.decodeIfPresent(String.self, forKey: .fontStyle) - textTransform = try container.decodeIfPresent(String.self, forKey: .textTransform) - - fontSize = Self.decodeFlexibleDouble(from: container, forKey: .fontSize) - fontWeight = Self.decodeFlexibleDouble(from: container, forKey: .fontWeight) - lineHeight = Self.decodeFlexibleDouble(from: container, forKey: .lineHeight) - letterSpacing = Self.decodeFlexibleDouble(from: container, forKey: .letterSpacing) - } - - /// Decodes a value that may be a JSON number or a JSON string containing a number. - private static func decodeFlexibleDouble( - from container: KeyedDecodingContainer, - forKey key: CodingKeys - ) -> Double? { - // Try as number first - if let value = try? container.decodeIfPresent(Double.self, forKey: key) { - return value - } - // Try as string → Double - if let stringValue = try? container.decodeIfPresent(String.self, forKey: key) { - return Double(stringValue) - } - return nil - } -} diff --git a/Sources/PenpotAPI/Renderer/PenpotShapeRenderer.swift b/Sources/PenpotAPI/Renderer/PenpotShapeRenderer.swift deleted file mode 100644 index 5df31005..00000000 --- a/Sources/PenpotAPI/Renderer/PenpotShapeRenderer.swift +++ /dev/null @@ -1,369 +0,0 @@ -import Foundation - -/// Reconstructs SVG from Penpot shape tree data. -/// -/// Penpot shapes are SVG-compatible objects stored in a flat dictionary keyed by ID. -/// Each shape has canvas-space coordinates that must be normalized relative to the -/// root frame's origin. -public enum PenpotShapeRenderer { - /// Renders a component's shape tree as an SVG string. - /// - /// - Parameters: - /// - objects: Flat dictionary of all shapes on the page (from `PenpotPage.objects`) - /// - rootId: The component's `mainInstanceId` — the root frame shape - /// - Returns: SVG string with coordinates normalized to (0,0), or nil if root not found - /// Describes why SVG rendering failed. - public enum RenderFailure: Error, Sendable { - case rootNotFound(id: String) - case missingSelrect(id: String) - } - - /// Result of SVG rendering including any warnings about skipped shapes. - public struct RenderResult: Sendable { - public let svg: String - public let skippedShapeTypes: Set - } - - /// Renders a component's shape tree as an SVG string with diagnostics. - public static func renderSVGResult( - objects: [String: PenpotShape], - rootId: String - ) -> Result { - guard let root = objects[rootId] else { - return .failure(.rootNotFound(id: rootId)) - } - guard let selrect = root.selrect else { - return .failure(.missingSelrect(id: rootId)) - } - - let originX = selrect.x - let originY = selrect.y - let width = selrect.width - let height = selrect.height - - var skippedTypes: Set = [] - - var svg = """ - - """ - - for childId in root.shapes ?? [] { - svg += renderShape( - id: childId, - objects: objects, - originX: originX, - originY: originY, - skippedTypes: &skippedTypes - ) - } - - svg += "\n" - return .success(RenderResult(svg: svg, skippedShapeTypes: skippedTypes)) - } - - /// Convenience wrapper that returns just the SVG string, or nil on failure. - public static func renderSVG( - objects: [String: PenpotShape], - rootId: String - ) -> String? { - switch renderSVGResult(objects: objects, rootId: rootId) { - case let .success(result): result.svg - case .failure: nil - } - } - - // MARK: - Private - - private static func renderShape( - id: String, - objects: [String: PenpotShape], - originX: Double, - originY: Double, - skippedTypes: inout Set - ) -> String { - guard let shape = objects[id] else { return "" } - if shape.hidden == true { return "" } - - let rotation = shape.rotation ?? 0 - let needsTransform = rotation != 0 - - var result = "" - - // Wrap in if rotated - if needsTransform, let cx = shape.x, let cy = shape.y, - let w = shape.width, let h = shape.height - { - let rcx = formatNumber(cx - originX + w / 2) - let rcy = formatNumber(cy - originY + h / 2) - result += "\n" - } - - switch shape.type { - case .path, .bool: - result += renderPath(shape, originX: originX, originY: originY) - case .rect: - result += renderRect(shape, originX: originX, originY: originY) - case .circle: - result += renderEllipse(shape, originX: originX, originY: originY) - case .group, .frame: - result += renderGroup( - shape, objects: objects, originX: originX, originY: originY, - skippedTypes: &skippedTypes - ) - case let .unknown(typeName): - skippedTypes.insert(typeName) - } - - if needsTransform { - result += "\n" - } - - return result - } - - private static func renderPath( - _ shape: PenpotShape, - originX: Double, - originY: Double - ) -> String { - guard let pathData = shape.content?.pathData, !pathData.isEmpty else { return "" } - - let normalized = normalizePathCoordinates(pathData, originX: originX, originY: originY) - let attrs = styleAttributes(fills: shape.fills, strokes: shape.strokes, svgAttrs: shape.svgAttrs) - - return "\n" - } - - private static func renderRect( - _ shape: PenpotShape, - originX: Double, - originY: Double - ) -> String { - guard let x = shape.x, let y = shape.y, - let w = shape.width, let h = shape.height else { return "" } - - let nx = formatNumber(x - originX) - let ny = formatNumber(y - originY) - let attrs = styleAttributes(fills: shape.fills, strokes: shape.strokes, svgAttrs: shape.svgAttrs) - - // Border radius — use r1 if all corners are equal, otherwise rx - let rx = shape.r1 ?? 0 - let rxAttr = rx > 0 ? " rx=\"\(formatNumber(rx))\"" : "" - - let size = "width=\"\(formatNumber(w))\" height=\"\(formatNumber(h))\"" - return "\n" - } - - private static func renderEllipse( - _ shape: PenpotShape, - originX: Double, - originY: Double - ) -> String { - guard let x = shape.x, let y = shape.y, - let w = shape.width, let h = shape.height else { return "" } - - let cx = formatNumber(x - originX + w / 2) - let cy = formatNumber(y - originY + h / 2) - let rx = formatNumber(w / 2) - let ry = formatNumber(h / 2) - let attrs = styleAttributes(fills: shape.fills, strokes: shape.strokes, svgAttrs: shape.svgAttrs) - - return "\n" - } - - private static func renderGroup( - _ shape: PenpotShape, - objects: [String: PenpotShape], - originX: Double, - originY: Double, - skippedTypes: inout Set - ) -> String { - guard let children = shape.shapes, !children.isEmpty else { return "" } - - let attrs = styleAttributes(fills: shape.fills, strokes: shape.strokes, svgAttrs: shape.svgAttrs) - var result = "\n" - - for childId in children { - result += renderShape( - id: childId, objects: objects, originX: originX, originY: originY, - skippedTypes: &skippedTypes - ) - } - - result += "\n" - return result - } - - // MARK: - Style Attributes - - private static func styleAttributes( - fills: [PenpotShape.Fill]?, - strokes: [PenpotShape.Stroke]?, - svgAttrs: PenpotShape.SVGAttributes? - ) -> String { - var attrs: [String] = [] - - // Fill from svgAttrs takes priority (e.g., fill="none" for stroke-only icons) - if let svgFill = svgAttrs?["fill"] { - attrs.append("fill=\"\(svgFill)\"") - } else if let fill = fills?.first, let color = fill.fillColor { - let opacity = fill.fillOpacity ?? 1.0 - attrs.append("fill=\"\(color)\"") - if opacity < 1.0 { - attrs.append("fill-opacity=\"\(formatNumber(opacity))\"") - } - } else if fills?.isEmpty == true { - attrs.append("fill=\"none\"") - } - - // Stroke - if let stroke = strokes?.first, let color = stroke.strokeColor { - attrs.append("stroke=\"\(color)\"") - if let width = stroke.strokeWidth { - attrs.append("stroke-width=\"\(formatNumber(width))\"") - } - if let opacity = stroke.strokeOpacity, opacity < 1.0 { - attrs.append("stroke-opacity=\"\(formatNumber(opacity))\"") - } - if let cap = stroke.strokeCapStart, cap != "butt" { - let svgCap = mapStrokeCap(cap) - attrs.append("stroke-linecap=\"\(svgCap)\"") - } - } - - if attrs.isEmpty { return "" } - return " " + attrs.joined(separator: " ") - } - - private static func mapStrokeCap(_ penpotCap: String) -> String { - switch penpotCap { - case "round", "circle-marker": "round" - case "square": "square" - default: "butt" - } - } - - // MARK: - Coordinate Normalization - - /// Normalizes SVG path data by subtracting the origin offset from all coordinate values. - static func normalizePathCoordinates( - _ pathData: String, - originX: Double, - originY: Double - ) -> String { - var result = "" - var i = pathData.startIndex - var state = PathNormState() - - while i < pathData.endIndex { - let ch = pathData[i] - - if ch.isLetter { - result.append(ch) - state.setCommand(ch) - i = pathData.index(after: i) - } else if ch == "," || ch == " " { - result.append(ch) - i = pathData.index(after: i) - } else if ch == "-" || ch == "." || ch.isNumber { - let (numStr, nextIndex) = parseNumber(in: pathData, from: i) - if let value = Double(numStr) { - let cmd = state.currentCmd ?? findLastCommand(in: pathData, before: i) - result.append(offsetValue(value, cmd: cmd, state: &state, originX: originX, originY: originY)) - } else { - result.append(numStr) - } - i = nextIndex - } else { - result.append(ch) - i = pathData.index(after: i) - } - } - - return result - } - - /// Mutable state for path coordinate normalization. - private struct PathNormState { - var isX = true - var currentCmd: Character? - var arcParamIndex = 0 - - mutating func setCommand(_ ch: Character) { - currentCmd = ch - isX = true - arcParamIndex = 0 - } - } - - private static func parseNumber(in path: String, from start: String.Index) -> (String, String.Index) { - var numStr = "" - var j = start - if path[j] == "-" { - numStr.append("-") - j = path.index(after: j) - } - var hasDot = false - while j < path.endIndex { - let c = path[j] - if c.isNumber { - numStr.append(c) - } else if c == ".", !hasDot { - hasDot = true - numStr.append(c) - } else { - break - } - j = path.index(after: j) - } - return (numStr, j) - } - - private static func offsetValue( - _ value: Double, cmd: Character?, state: inout PathNormState, - originX: Double, originY: Double - ) -> String { - guard let cmd, cmd.isUppercase, cmd != "Z" else { - return formatNumber(value) - } - - if cmd == "A" { - // Arc: 7 params per segment (rx, ry, x-rotation, large-arc, sweep, x, y) - // Only params 5 (x) and 6 (y) are endpoint coordinates - let paramPos = state.arcParamIndex % 7 - state.arcParamIndex += 1 - if paramPos == 5 { return formatNumber(value - originX) } - if paramPos == 6 { return formatNumber(value - originY) } - return formatNumber(value) - } - - let offset = state.isX ? originX : originY - state.isX.toggle() - return formatNumber(value - offset) - } - - private static func findLastCommand(in path: String, before index: String.Index) -> Character? { - var j = index - while j > path.startIndex { - j = path.index(before: j) - if path[j].isLetter { return path[j] } - } - return nil - } - - // MARK: - Formatting - - private static func formatNumber(_ value: Double) -> String { - if value == value.rounded(), abs(value) < 1_000_000 { - return String(Int(value)) - } - // Round to 2 decimal places to keep SVG compact - let rounded = (value * 100).rounded() / 100 - if rounded == rounded.rounded() { - return String(Int(rounded)) - } - return String(rounded) - } -} diff --git a/Tests/ExFigTests/TerminalUI/ExFigErrorFormatterTests.swift b/Tests/ExFigTests/TerminalUI/ExFigErrorFormatterTests.swift index 3a4806e9..8a42d813 100644 --- a/Tests/ExFigTests/TerminalUI/ExFigErrorFormatterTests.swift +++ b/Tests/ExFigTests/TerminalUI/ExFigErrorFormatterTests.swift @@ -53,7 +53,7 @@ final class ExFigErrorFormatterTests: XCTestCase { let result = formatter.format(error) - XCTAssertTrue(result.contains("FIGMA_PERSONAL_TOKEN not set")) + XCTAssertTrue(result.contains("FIGMA_PERSONAL_TOKEN is required")) XCTAssertTrue(result.contains("→")) XCTAssertTrue(result.contains("export FIGMA_PERSONAL_TOKEN")) } diff --git a/Tests/PenpotAPITests/Fixtures/file-response.json b/Tests/PenpotAPITests/Fixtures/file-response.json deleted file mode 100644 index b430e7a6..00000000 --- a/Tests/PenpotAPITests/Fixtures/file-response.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "name": "Test Design System", - "data": { - "colors": { - "color-uuid-1": { - "id": "color-uuid-1", - "name": "Blue", - "path": "Brand/Primary", - "color": "#3366FF", - "opacity": 1.0 - }, - "color-uuid-2": { - "id": "color-uuid-2", - "name": "Red", - "path": "Brand/Secondary", - "color": "#FF3366", - "opacity": 0.8 - }, - "color-uuid-3": { - "id": "color-uuid-3", - "name": "Gradient", - "path": "Effects", - "opacity": 1.0 - } - }, - "typographies": { - "typo-uuid-1": { - "id": "typo-uuid-1", - "name": "Heading", - "path": "Styles", - "fontFamily": "Roboto", - "fontStyle": "normal", - "textTransform": "uppercase", - "fontSize": "24", - "fontWeight": "700", - "lineHeight": "1.5", - "letterSpacing": "0.02" - }, - "typo-uuid-2": { - "id": "typo-uuid-2", - "name": "Body", - "fontFamily": "Inter", - "fontSize": 16, - "fontWeight": 400, - "lineHeight": 1.6, - "letterSpacing": 0 - } - }, - "components": { - "comp-uuid-1": { - "id": "comp-uuid-1", - "name": "arrow-right", - "path": "Icons/Navigation", - "mainInstanceId": "instance-123", - "mainInstancePage": "page-456" - }, - "comp-uuid-2": { - "id": "comp-uuid-2", - "name": "star", - "path": "Icons/Actions" - }, - "comp-uuid-3": { - "id": "comp-uuid-3", - "name": "hero-banner", - "path": "Illustrations" - } - } - } -} diff --git a/Tests/PenpotAPITests/PenpotAPIErrorTests.swift b/Tests/PenpotAPITests/PenpotAPIErrorTests.swift deleted file mode 100644 index 6d7c4c14..00000000 --- a/Tests/PenpotAPITests/PenpotAPIErrorTests.swift +++ /dev/null @@ -1,46 +0,0 @@ -import Foundation -@testable import PenpotAPI -import Testing - -@Suite("PenpotAPIError") -struct PenpotAPIErrorTests { - @Test("401 error suggests checking PENPOT_ACCESS_TOKEN") - func authError() { - let error = PenpotAPIError(statusCode: 401, message: "Unauthorized", endpoint: "get-profile") - #expect(error.recoverySuggestion?.contains("PENPOT_ACCESS_TOKEN") == true) - #expect(error.errorDescription?.contains("get-profile") == true) - } - - @Test("404 error suggests checking file UUID") - func notFoundError() { - let error = PenpotAPIError(statusCode: 404, message: "Not found", endpoint: "get-file") - #expect(error.recoverySuggestion?.contains("UUID") == true) - } - - @Test("429 error mentions rate limiting") - func rateLimitError() { - let error = PenpotAPIError(statusCode: 429, message: nil, endpoint: "get-file") - #expect(error.recoverySuggestion?.contains("Rate") == true) - } - - @Test("500 error suggests server-side issue") - func serverError() { - let error = PenpotAPIError(statusCode: 500, message: "Internal error", endpoint: "get-file") - #expect(error.recoverySuggestion?.contains("server error") == true) - } - - @Test("0 status code suggests network error") - func networkError() { - let error = PenpotAPIError(statusCode: 0, message: nil, endpoint: "get-file") - #expect(error.recoverySuggestion?.contains("network") == true) - } - - @Test("Error description includes endpoint and status code") - func errorDescription() { - let error = PenpotAPIError(statusCode: 403, message: "Forbidden", endpoint: "get-file") - let desc = error.errorDescription ?? "" - #expect(desc.contains("403")) - #expect(desc.contains("get-file")) - #expect(desc.contains("Forbidden")) - } -} diff --git a/Tests/PenpotAPITests/PenpotColorDecodingTests.swift b/Tests/PenpotAPITests/PenpotColorDecodingTests.swift deleted file mode 100644 index 566831ba..00000000 --- a/Tests/PenpotAPITests/PenpotColorDecodingTests.swift +++ /dev/null @@ -1,67 +0,0 @@ -import Foundation -@testable import PenpotAPI -import Testing -import YYJSON - -@Suite("PenpotColor Decoding") -struct PenpotColorDecodingTests { - @Test("Decodes solid color with hex and opacity") - func decodeSolidColor() throws { - let json = Data(""" - {"id":"uuid-1","name":"Blue","path":"Brand/Primary","color":"#3366FF","opacity":1.0} - """.utf8) - - let color = try YYJSONDecoder().decode(PenpotColor.self, from: json) - #expect(color.id == "uuid-1") - #expect(color.name == "Blue") - #expect(color.path == "Brand/Primary") - #expect(color.color == "#3366FF") - #expect(color.opacity == 1.0) - } - - @Test("Gradient color has nil hex") - func decodeGradientColor() throws { - let json = Data(""" - {"id":"uuid-2","name":"Gradient","path":"Effects","opacity":1.0} - """.utf8) - - let color = try YYJSONDecoder().decode(PenpotColor.self, from: json) - #expect(color.id == "uuid-2") - #expect(color.name == "Gradient") - #expect(color.color == nil) - } - - @Test("Color without path") - func decodeColorWithoutPath() throws { - let json = Data(""" - {"id":"uuid-3","name":"Plain","color":"#000000"} - """.utf8) - - let color = try YYJSONDecoder().decode(PenpotColor.self, from: json) - #expect(color.path == nil) - #expect(color.opacity == nil) - } - - @Test("Color map from file response") - func decodeColorMap() throws { - let url = try #require(Bundle.module.url( - forResource: "file-response", - withExtension: "json", - subdirectory: "Fixtures" - )) - let data = try Data(contentsOf: url) - let response = try YYJSONDecoder().decode(PenpotFileResponse.self, from: data) - - let colors = response.data.colors - #expect(colors != nil) - #expect(colors?.count == 3) - - let blue = colors?["color-uuid-1"] - #expect(blue?.name == "Blue") - #expect(blue?.color == "#3366FF") - - // Gradient has no solid color - let gradient = colors?["color-uuid-3"] - #expect(gradient?.color == nil) - } -} diff --git a/Tests/PenpotAPITests/PenpotComponentDecodingTests.swift b/Tests/PenpotAPITests/PenpotComponentDecodingTests.swift deleted file mode 100644 index 8e6e8a9d..00000000 --- a/Tests/PenpotAPITests/PenpotComponentDecodingTests.swift +++ /dev/null @@ -1,54 +0,0 @@ -import Foundation -@testable import PenpotAPI -import Testing -import YYJSON - -@Suite("PenpotComponent Decoding") -struct PenpotComponentDecodingTests { - @Test("Decodes component with camelCase keys") - func decodeCamelCase() throws { - let json = Data(( - #"{"id":"c1","name":"arrow-right","path":"Icons/Navigation","# + - #""mainInstanceId":"inst-123","mainInstancePage":"page-456"}"# - ).utf8) - - let comp = try YYJSONDecoder().decode(PenpotComponent.self, from: json) - #expect(comp.id == "c1") - #expect(comp.name == "arrow-right") - #expect(comp.path == "Icons/Navigation") - #expect(comp.mainInstanceId == "inst-123") - #expect(comp.mainInstancePage == "page-456") - } - - @Test("Component with optional fields nil") - func decodeMinimal() throws { - let json = Data(""" - {"id":"c2","name":"star"} - """.utf8) - - let comp = try YYJSONDecoder().decode(PenpotComponent.self, from: json) - #expect(comp.id == "c2") - #expect(comp.name == "star") - #expect(comp.path == nil) - #expect(comp.mainInstanceId == nil) - } - - @Test("Components map from file response") - func decodeFromFixture() throws { - let url = try #require(Bundle.module.url( - forResource: "file-response", - withExtension: "json", - subdirectory: "Fixtures" - )) - let data = try Data(contentsOf: url) - let response = try YYJSONDecoder().decode(PenpotFileResponse.self, from: data) - - let comps = response.data.components - #expect(comps != nil) - #expect(comps?.count == 3) - - let arrow = comps?["comp-uuid-1"] - #expect(arrow?.name == "arrow-right") - #expect(arrow?.mainInstanceId == "instance-123") - } -} diff --git a/Tests/PenpotAPITests/PenpotEndpointTests.swift b/Tests/PenpotAPITests/PenpotEndpointTests.swift deleted file mode 100644 index f0b15ac5..00000000 --- a/Tests/PenpotAPITests/PenpotEndpointTests.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Foundation -@testable import PenpotAPI -import Testing -import YYJSON - -@Suite("Penpot Endpoint") -struct PenpotEndpointTests { - @Test("GetFileEndpoint produces correct command name") - func getFileCommandName() { - let endpoint = GetFileEndpoint(fileId: "test-uuid") - #expect(endpoint.commandName == "get-file") - } - - @Test("GetFileEndpoint body contains file ID") - func getFileBody() throws { - let endpoint = GetFileEndpoint(fileId: "abc-123") - let body = try #require(try endpoint.body()) - let json = try JSONSerialization.jsonObject(with: body) as? [String: String] - #expect(json?["id"] == "abc-123") - } - - @Test("GetProfileEndpoint sends empty JSON body") - func getProfileBody() throws { - let endpoint = GetProfileEndpoint() - #expect(endpoint.commandName == "get-profile") - let body = try #require(try endpoint.body()) - let json = try JSONSerialization.jsonObject(with: body) as? [String: Any] - #expect(json?.isEmpty == true) - } - - @Test("GetFileObjectThumbnailsEndpoint body has kebab-case keys") - func thumbnailsBody() throws { - let endpoint = GetFileObjectThumbnailsEndpoint( - fileId: "file-uuid", - objectIds: ["obj-1", "obj-2"] - ) - #expect(endpoint.commandName == "get-file-object-thumbnails") - - let body = try #require(try endpoint.body()) - let json = try JSONSerialization.jsonObject(with: body) as? [String: Any] - #expect(json?["file-id"] as? String == "file-uuid") - #expect((json?["object-ids"] as? [String])?.count == 2) - } -} diff --git a/Tests/PenpotAPITests/PenpotShapeRendererTests.swift b/Tests/PenpotAPITests/PenpotShapeRendererTests.swift deleted file mode 100644 index d042e963..00000000 --- a/Tests/PenpotAPITests/PenpotShapeRendererTests.swift +++ /dev/null @@ -1,414 +0,0 @@ -// swiftlint:disable file_length -import Foundation -@testable import PenpotAPI -import Testing - -// swiftlint:disable type_body_length -@Suite("PenpotShapeRenderer") -struct PenpotShapeRendererTests { - // MARK: - Basic Rendering - - @Test("Renders simple path icon") - func simplePath() throws { - let objects = makeObjects( - root: makeFrame(id: "root", x: 100, y: 200, width: 16, height: 16, children: ["path1"]), - children: [ - "path1": makePathShape( - content: "M106.0,204.0L108.0,208.0L110.0,204.0", - strokeColor: "#333333" - ), - ] - ) - - let svg = PenpotShapeRenderer.renderSVG(objects: objects, rootId: "root") - let result = try #require(svg) - - #expect(result.contains("viewBox=\"0 0 16 16\"")) - #expect(result.contains("")) - #expect(svg.contains("")) - } - - @Test("Renders deeply nested frame inside group") - func deepNesting() throws { - let leaf = PenpotShape( - id: "leaf", name: "leaf", type: .rect, - x: 5, y: 5, width: 6, height: 6, rotation: nil, selrect: nil, - content: nil, fills: [.init(fillColor: "#00FF00", fillOpacity: 1.0)], - strokes: nil, svgAttrs: nil, hideFillOnExport: nil, - shapes: nil, boolType: nil, r1: nil, r2: nil, r3: nil, r4: nil, - transform: nil, opacity: nil, hidden: nil - ) - let innerFrame = PenpotShape( - id: "inner-frame", name: "inner", type: .frame, - x: nil, y: nil, width: nil, height: nil, rotation: nil, selrect: nil, - content: nil, fills: nil, strokes: nil, svgAttrs: nil, hideFillOnExport: nil, - shapes: ["leaf"], boolType: nil, r1: nil, r2: nil, r3: nil, r4: nil, - transform: nil, opacity: nil, hidden: nil - ) - let group = PenpotShape( - id: "g1", name: "wrapper", type: .group, - x: nil, y: nil, width: nil, height: nil, rotation: nil, selrect: nil, - content: nil, fills: nil, strokes: nil, svgAttrs: nil, hideFillOnExport: nil, - shapes: ["inner-frame"], boolType: nil, r1: nil, r2: nil, r3: nil, r4: nil, - transform: nil, opacity: nil, hidden: nil - ) - let root = makeFrame(id: "root", x: 0, y: 0, width: 16, height: 16, children: ["g1"]) - let objects: [String: PenpotShape] = [ - "root": root, "g1": group, "inner-frame": innerFrame, "leaf": leaf, - ] - - let svg = try #require(PenpotShapeRenderer.renderSVG(objects: objects, rootId: "root")) - #expect(svg.contains(" PenpotShape { - PenpotShape( - id: id, name: "frame", type: .frame, - x: x, y: y, width: width, height: height, rotation: nil, - selrect: .init(x: x, y: y, width: width, height: height), - content: nil, fills: nil, strokes: nil, svgAttrs: nil, hideFillOnExport: nil, - shapes: children, boolType: nil, r1: nil, r2: nil, r3: nil, r4: nil, - transform: nil, opacity: nil, hidden: nil - ) - } - - private func makePathShape(content: String, strokeColor: String) -> PenpotShape { - PenpotShape( - id: UUID().uuidString, name: "path", type: .path, - x: nil, y: nil, width: nil, height: nil, rotation: nil, selrect: nil, - content: .path(content), - fills: [], - strokes: [.init( - strokeColor: strokeColor, - strokeOpacity: 1, - strokeWidth: 1, - strokeStyle: "solid", - strokeAlignment: "center", - strokeCapStart: "round", - strokeCapEnd: "round" - )], - svgAttrs: nil, - hideFillOnExport: nil, - shapes: nil, boolType: nil, r1: nil, r2: nil, r3: nil, r4: nil, - transform: nil, opacity: nil, hidden: nil - ) - } - - private func makeObjects(root: PenpotShape, children: [String: PenpotShape]) -> [String: PenpotShape] { - var objects = children - objects[root.id] = root - return objects - } -} diff --git a/Tests/PenpotAPITests/PenpotTypographyDecodingTests.swift b/Tests/PenpotAPITests/PenpotTypographyDecodingTests.swift deleted file mode 100644 index ab808664..00000000 --- a/Tests/PenpotAPITests/PenpotTypographyDecodingTests.swift +++ /dev/null @@ -1,83 +0,0 @@ -import Foundation -@testable import PenpotAPI -import Testing -import YYJSON - -@Suite("PenpotTypography Decoding") -struct PenpotTypographyDecodingTests { - @Test("String numeric values are parsed as Double") - func decodeStringNumerics() throws { - let json = Data(( - #"{"id":"t1","name":"Heading","fontFamily":"Roboto","# + - #""fontSize":"24","fontWeight":"700","lineHeight":"1.5","letterSpacing":"0.02"}"# - ).utf8) - - let typo = try YYJSONDecoder().decode(PenpotTypography.self, from: json) - #expect(typo.fontSize == 24.0) - #expect(typo.fontWeight == 700.0) - #expect(typo.lineHeight == 1.5) - #expect(typo.letterSpacing == 0.02) - } - - @Test("JSON number values are parsed as Double") - func decodeNumberValues() throws { - let json = Data(""" - {"id":"t2","name":"Body","fontFamily":"Inter","fontSize":16,"fontWeight":400,"lineHeight":1.6,"letterSpacing":0} - """.utf8) - - let typo = try YYJSONDecoder().decode(PenpotTypography.self, from: json) - #expect(typo.fontSize == 16.0) - #expect(typo.fontWeight == 400.0) - #expect(typo.lineHeight == 1.6) - #expect(typo.letterSpacing == 0.0) - } - - @Test("Unparseable string values become nil") - func decodeUnparseableValues() throws { - let json = Data(""" - {"id":"t3","name":"Auto","fontFamily":"System","fontSize":"auto","fontWeight":"bold"} - """.utf8) - - let typo = try YYJSONDecoder().decode(PenpotTypography.self, from: json) - #expect(typo.fontSize == nil) - #expect(typo.fontWeight == nil) - } - - @Test("camelCase keys decode without CodingKeys") - func decodeCamelCaseKeys() throws { - let json = Data(""" - {"id":"t4","name":"Styled","fontFamily":"Roboto","fontStyle":"italic","textTransform":"uppercase","fontSize":14} - """.utf8) - - let typo = try YYJSONDecoder().decode(PenpotTypography.self, from: json) - #expect(typo.fontFamily == "Roboto") - #expect(typo.fontStyle == "italic") - #expect(typo.textTransform == "uppercase") - } - - @Test("Typography map from file response") - func decodeFromFixture() throws { - let url = try #require(Bundle.module.url( - forResource: "file-response", - withExtension: "json", - subdirectory: "Fixtures" - )) - let data = try Data(contentsOf: url) - let response = try YYJSONDecoder().decode(PenpotFileResponse.self, from: data) - - let typos = response.data.typographies - #expect(typos != nil) - #expect(typos?.count == 2) - - // String numerics - let heading = typos?["typo-uuid-1"] - #expect(heading?.fontSize == 24.0) - #expect(heading?.fontWeight == 700.0) - #expect(heading?.textTransform == "uppercase") - - // Number values - let body = typos?["typo-uuid-2"] - #expect(body?.fontSize == 16.0) - #expect(body?.fontWeight == 400.0) - } -} diff --git a/Tests/PenpotAPITests/README.md b/Tests/PenpotAPITests/README.md deleted file mode 100644 index 9aaa70b7..00000000 --- a/Tests/PenpotAPITests/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# PenpotAPI Tests - -## E2E Test Data - -E2E tests run against a real Penpot instance at `design.penpot.app`. -They are skipped when `PENPOT_ACCESS_TOKEN` is not set. - -### Test File - -| Field | Value | -| ------- | ----------------------------------------- | -| Name | Tokens starter kit | -| File ID | `9afc49c1-9c44-8036-8007-bf9fc737a656` | -| Page ID | `5e5872fb-0776-80fd-8006-154b5dfd6ec7` | -| Owner | Aleksei Kakoulin (alexey1312ru@gmail.com) | - -### Colors (8) - -| Name | Hex | Opacity | Path | -| ------------ | --------- | ------- | -------- | -| Primary | `#3B82F6` | 1.0 | Brand | -| Secondary | `#8B5CF6` | 1.0 | Brand | -| Success | `#22C55E` | 1.0 | Semantic | -| Warning | `#F59E0B` | 1.0 | Semantic | -| Error | `#EF4444` | 1.0 | Semantic | -| Background | `#1E1E2E` | 1.0 | Neutral | -| Text Primary | `#F8F8F2` | 1.0 | Neutral | -| Overlay | `#000000` | 0.5 | Neutral | - -### Typographies (4) - -| Name | Font Family | Size | Weight | -| ----------- | ----------- | ---- | ------ | -| Title | DM Mono | 30 | 500 | -| Subtitle | DM Mono | 24 | 500 | -| Label | DM Mono | 16 | 400 | -| Description | DM Mono | 16 | 400 | - -### Components (4) - -| Name | Path | -| ---------- | --------- | -| IconButton | UI | -| Avatar | UI | -| Badge | UI/Status | -| Divider | Layout | - -## Environment Variables - -| Variable | Required | Description | -| --------------------- | -------- | ----------------------------- | -| `PENPOT_ACCESS_TOKEN` | Yes | Penpot personal access token | -| `PENPOT_TEST_FILE_ID` | No | Override default test file ID | - -## API Path - -Tests use `/api/main/methods/` (not `/api/rpc/command/`). -The `rpc` path is blocked by Cloudflare on `design.penpot.app`; -`main/methods` works with `URLSession` and `Accept: application/json`. From 69f8cbfb3b691608b5a288f9b98144faebd9bea3 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sun, 22 Mar 2026 19:00:44 +0500 Subject: [PATCH 21/26] docs: update module structure, org name, and external package references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Development.md: ExFig/ → ExFigCLI/, expand module list from 7 to 12, add Source/, MCP/, JinjaSupport, WebExport, update test targets, replace inline FigmaAPI guide with link to external package - ARCHITECTURE.md: ExFig (CLI) → ExFigCLI, add External Packages section (FigmaAPI, PenpotAPI, SVGKit), Stencil → Jinja2, update file structure - PKL.md, MIGRATION.md: niceplaces → DesignPipe, fix Schemas path Sources/ExFig/ → Sources/ExFigCLI/ --- Sources/ExFigCLI/ExFig.docc/Development.md | 55 +- docs/ARCHITECTURE.md | 488 +++++++++++++++++ docs/MIGRATION.md | 518 +++++++++++++++++ docs/PKL.md | 610 +++++++++++++++++++++ 4 files changed, 1635 insertions(+), 36 deletions(-) create mode 100755 docs/ARCHITECTURE.md create mode 100755 docs/MIGRATION.md create mode 100755 docs/PKL.md diff --git a/Sources/ExFigCLI/ExFig.docc/Development.md b/Sources/ExFigCLI/ExFig.docc/Development.md index 045aa816..26920766 100644 --- a/Sources/ExFigCLI/ExFig.docc/Development.md +++ b/Sources/ExFigCLI/ExFig.docc/Development.md @@ -57,29 +57,40 @@ swift build -c release ``` Sources/ -├── ExFig/ # CLI commands and main executable +├── ExFigCLI/ # CLI commands and main executable │ ├── Subcommands/ # CLI command implementations │ ├── Loaders/ # Figma data loaders │ ├── Input/ # Configuration parsing │ ├── Output/ # File writers │ ├── TerminalUI/ # Progress bars, spinners │ ├── Cache/ # Version tracking -│ └── Batch/ # Batch processing +│ ├── Batch/ # Batch processing +│ ├── Source/ # Design source implementations (Figma, Penpot, TokensFile) +│ └── MCP/ # Model Context Protocol server ├── ExFigCore/ # Domain models and processors -├── FigmaAPI/ # Figma REST API client +├── ExFigConfig/ # PKL config parsing, evaluation +├── ExFig-iOS/ # iOS platform plugin +├── ExFig-Android/ # Android platform plugin +├── ExFig-Flutter/ # Flutter platform plugin +├── ExFig-Web/ # Web platform plugin ├── XcodeExport/ # iOS export (xcassets, Swift) -├── AndroidExport/ # Android export (XML, Compose) +├── AndroidExport/ # Android export (XML, Compose, VectorDrawable) ├── FlutterExport/ # Flutter export (Dart, assets) -└── SVGKit/ # SVG parsing and code generation +├── WebExport/ # Web export (CSS, JSX icons) +└── JinjaSupport/ # Shared Jinja2 template rendering Tests/ ├── ExFigTests/ ├── ExFigCoreTests/ -├── FigmaAPITests/ ├── XcodeExportTests/ ├── AndroidExportTests/ ├── FlutterExportTests/ -└── SVGKitTests/ +├── WebExportTests/ +├── JinjaSupportTests/ +├── ExFig-iOSTests/ +├── ExFig-AndroidTests/ +├── ExFig-FlutterTests/ +└── ExFig-WebTests/ ``` ## Available Tasks @@ -167,35 +178,7 @@ static let configuration = CommandConfiguration( ## Adding a Figma API Endpoint -1. Create endpoint in `Sources/FigmaAPI/Endpoint/`: - -```swift -struct NewEndpoint: FigmaEndpoint { - typealias Response = NewResponse - - let fileId: String - - var path: String { - "files/\(fileId)/new-resource" - } -} -``` - -2. Create response model in `Sources/FigmaAPI/Model/`: - -```swift -struct NewResponse: Codable { - let data: [NewItem] -} -``` - -3. Add method to `FigmaClient`: - -```swift -func fetchNewResource(fileId: String) async throws -> NewResponse { - try await request(NewEndpoint(fileId: fileId)) -} -``` +FigmaAPI is an external package ([swift-figma-api](https://github.com/DesignPipe/swift-figma-api)). See its repository for endpoint patterns and contribution guidelines. ## Modifying Templates diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100755 index 00000000..3ace2db5 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,488 @@ +# ExFig Architecture + +ExFig v2.0 uses a plugin-based architecture with twelve modules. This document explains the system design and how to extend it. + +## Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ExFig CLI │ +│ ┌─────────────┐ ┌─────────────┐ ┌───────────────────────┐ │ +│ │ Subcommands │ │ PluginReg. │ │ Context Impls │ │ +│ │ (colors, │──│ (routing) │──│ (ColorsExportContext │ │ +│ │ icons...) │ │ │ │ IconsExportContext) │ │ +│ └─────────────┘ └─────────────┘ └───────────────────────┘ │ +└────────────────────────────┬────────────────────────────────────┘ + │ + ┌────────────────────┼────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌───────────────┐ ┌────────────────┐ ┌────────────────────┐ +│ ExFig-iOS │ │ ExFig-Android │ │ ExFig-Flutter/Web │ +│ ┌─────────┐ │ │ ┌─────────┐ │ │ ┌─────────┐ │ +│ │iOSPlugin│ │ │ │Android │ │ │ │Flutter │ │ +│ └────┬────┘ │ │ │Plugin │ │ │ │Plugin │ │ +│ │ │ │ └────┬────┘ │ │ └────┬────┘ │ +│ ┌────▼────┐ │ │ ┌────▼────┐ │ │ ┌────▼────┐ │ +│ │Exporters│ │ │ │Exporters│ │ │ │Exporters│ │ +│ │ Colors │ │ │ │ Colors │ │ │ │ Colors │ │ +│ │ Icons │ │ │ │ Icons │ │ │ │ Icons │ │ +│ │ Images │ │ │ │ Images │ │ │ │ Images │ │ +│ └─────────┘ │ │ └─────────┘ │ │ └─────────┘ │ +└───────────────┘ └────────────────┘ └────────────────────┘ + │ │ │ + └────────────────────┼────────────────────┘ + │ + ▼ + ┌────────────────────────────────────────┐ + │ ExFigCore │ + │ ┌──────────────┐ ┌───────────────┐ │ + │ │ Protocols │ │ Domain Models │ │ + │ │PlatformPlugin│ │ Color, Image │ │ + │ │AssetExporter │ │ TextStyle │ │ + │ │ColorsExporter│ │ ColorPair │ │ + │ └──────────────┘ └───────────────┘ │ + └────────────────────────────────────────┘ +``` + +## Module Responsibilities + +### ExFigCLI + +Main executable. Handles: +- CLI commands (colors, icons, images, typography, batch) +- PKL config loading via ExFigConfig +- Plugin coordination via PluginRegistry +- Context implementations bridging plugins to services +- File I/O, TerminalUI, caching + +### ExFigCore + +Shared protocols and domain models: +- `PlatformPlugin` — platform registration +- `AssetExporter` — base export protocol +- `ColorsExporter` / `IconsExporter` / `ImagesExporter` — specialized protocols +- `ColorsExportContext` / `IconsExportContext` — dependency injection +- Domain models: `Color`, `ColorPair`, `Image`, `TextStyle` + +### ExFigConfig + +PKL configuration: +- `PKLLocator` — finds pkl CLI +- `PKLEvaluator` — runs pkl eval and decodes JSON +- Shared config types: `SourceConfig`, `AssetConfiguration` + +### ExFig-iOS / Android / Flutter / Web + +Platform plugins: +- `*Plugin` — platform registration, exporter factory +- `*Entry` types — configuration models +- `*Exporter` — export implementations + +### External Packages + +- **swift-figma-api** (`FigmaAPI`) — Figma REST API client with rate limiting, retry, and 46 endpoints +- **swift-penpot-api** (`PenpotAPI`) — Penpot RPC API client with SVG shape reconstruction +- **swift-svgkit** (`SVGKit`) — SVG parsing, ImageVector and VectorDrawable generation + +### XcodeExport / AndroidExport / FlutterExport / WebExport + +Platform-specific file generation: +- Asset catalog generation (xcassets, VectorDrawable) +- Code generation (Swift extensions, Kotlin, Dart, CSS) +- Template rendering via Jinja2 + +### JinjaSupport + +Shared Jinja2 template rendering utilities used across all Export modules. + +## Key Protocols + +### PlatformPlugin + +Represents a target platform: + +```swift +public protocol PlatformPlugin: Sendable { + var identifier: String { get } // "ios", "android", etc. + var platform: Platform { get } // .ios, .android, etc. + var configKeys: Set { get } // ["ios"] for PKL routing + + func exporters() -> [any AssetExporter] +} +``` + +### AssetExporter + +Base protocol for all exporters: + +```swift +public protocol AssetExporter: Sendable { + var assetType: AssetType { get } // .colors, .icons, .images, .typography +} +``` + +### ColorsExporter + +Specialized protocol for colors: + +```swift +public protocol ColorsExporter: AssetExporter { + associatedtype Entry: Sendable + associatedtype PlatformConfig: Sendable + + func exportColors( + entries: [Entry], + platformConfig: PlatformConfig, + context: some ColorsExportContext + ) async throws -> Int +} +``` + +### ColorsExportContext + +Dependency injection for exporters: + +```swift +public protocol ColorsExportContext: ExportContext { + func loadColors(from source: ColorsSourceInput) async throws -> ColorsLoadOutput + func processColors( + _ colors: ColorsLoadOutput, + platform: Platform, + nameValidateRegexp: String?, + nameReplaceRegexp: String?, + nameStyle: NameStyle + ) throws -> ColorsProcessResult +} +``` + +## Data Flow + +``` +┌──────────────┐ +│ exfig.pkl │ +└──────┬───────┘ + │ + ▼ PKLEvaluator (pkl eval --format json) + │ + ▼ JSON → PKLConfig (JSONDecoder) + │ + ▼ PluginRegistry.plugin(forConfigKey: "ios") + │ + ▼ iOSPlugin.exporters() + │ + ▼ [iOSColorsExporter, iOSIconsExporter, ...] + │ + ▼ exporter.exportColors(entries, platformConfig, context) + │ + ├──▶ context.loadColors(source) → Figma Variables API + │ + ├──▶ context.processColors(...) → ColorsProcessor + │ + └──▶ exporter → XcodeExport (xcassets, Swift) + │ + ▼ context.writeFiles([FileContents]) +``` + +## Plugin Registry + +Central coordination point: + +```swift +let registry = PluginRegistry.default // Contains all 4 plugins + +// Find plugin by config key +if let plugin = registry.plugin(forConfigKey: "ios") { + for exporter in plugin.exporters() { + if let colorsExporter = exporter as? iOSColorsExporter { + try await colorsExporter.exportColors(...) + } + } +} + +// Find plugin by platform +if let plugin = registry.plugin(for: .android) { + // Use Android plugin +} +``` + +## Adding a New Platform + +### 1. Create Module + +Create `Sources/ExFig-NewPlatform/`: + +``` +Sources/ExFig-NewPlatform/ +├── NewPlatformPlugin.swift +├── Config/ +│ ├── NewPlatformColorsEntry.swift +│ ├── NewPlatformIconsEntry.swift +│ └── NewPlatformImagesEntry.swift +└── Export/ + ├── NewPlatformColorsExporter.swift + ├── NewPlatformIconsExporter.swift + └── NewPlatformImagesExporter.swift +``` + +### 2. Define Plugin + +```swift +// NewPlatformPlugin.swift +import ExFigCore + +public struct NewPlatformPlugin: PlatformPlugin { + public let identifier = "newplatform" + public let platform: Platform = .custom("newplatform") + public let configKeys: Set = ["newplatform"] + + public init() {} + + public func exporters() -> [any AssetExporter] { + [ + NewPlatformColorsExporter(), + NewPlatformIconsExporter(), + NewPlatformImagesExporter(), + ] + } +} +``` + +### 3. Define Entry Types + +```swift +// Config/NewPlatformColorsEntry.swift +public struct NewPlatformColorsEntry: Codable, Sendable { + // Source fields (can use common.variablesColors) + public let tokensFileId: String? + public let tokensCollectionName: String? + public let lightModeName: String? + public let darkModeName: String? + + // Platform-specific fields + public let outputPath: String + public let format: OutputFormat +} +``` + +### 4. Implement Exporter + +```swift +// Export/NewPlatformColorsExporter.swift +import ExFigCore + +public struct NewPlatformColorsExporter: ColorsExporter { + public typealias Entry = NewPlatformColorsEntry + public typealias PlatformConfig = NewPlatformConfig + + public func exportColors( + entries: [Entry], + platformConfig: PlatformConfig, + context: some ColorsExportContext + ) async throws -> Int { + var totalCount = 0 + + for entry in entries { + // 1. Load from Figma + let source = ColorsSourceInput( + tokensFileId: entry.tokensFileId ?? context.commonSource?.tokensFileId, + // ... other fields + ) + let loaded = try await context.loadColors(from: source) + + // 2. Process + let processed = try context.processColors( + loaded, + platform: .custom("newplatform"), + nameValidateRegexp: entry.nameValidateRegexp, + nameReplaceRegexp: entry.nameReplaceRegexp, + nameStyle: entry.nameStyle + ) + + // 3. Generate output files + let files = try generateOutput(processed, entry: entry) + + // 4. Write files + try context.writeFiles(files) + + totalCount += processed.colorPairs.count + } + + return totalCount + } +} +``` + +### 5. Add PKL Schema + +```pkl +// Sources/ExFigCLI/Resources/Schemas/NewPlatform.pkl +module NewPlatform + +import "Common.pkl" + +class ColorsEntry extends Common.VariablesSource { + outputPath: String + format: "json"|"xml"|"yaml" +} + +class NewPlatformConfig { + basePath: String + colors: (ColorsEntry|Listing)? +} +``` + +### 6. Register in Package.swift + +```swift +.target( + name: "ExFig-NewPlatform", + dependencies: ["ExFigCore"], + path: "Sources/ExFig-NewPlatform" +), +``` + +### 7. Register in PluginRegistry + +```swift +// Sources/ExFigCLI/Plugin/PluginRegistry.swift +import ExFig_NewPlatform + +public static let `default` = PluginRegistry(plugins: [ + iOSPlugin(), + AndroidPlugin(), + FlutterPlugin(), + WebPlugin(), + NewPlatformPlugin(), // Add here +]) +``` + +## Context Implementation Pattern + +Exporters receive a context for dependencies. The CLI provides concrete implementations: + +```swift +// Plugin defines protocol +public protocol ColorsExportContext: ExportContext { + func loadColors(from: ColorsSourceInput) async throws -> ColorsLoadOutput + func processColors(...) throws -> ColorsProcessResult +} + +// CLI provides implementation +struct ColorsExportContextImpl: ColorsExportContext { + let client: FigmaClient + let ui: TerminalUI + + func loadColors(from source: ColorsSourceInput) async throws -> ColorsLoadOutput { + let loader = ColorsVariablesLoader(client: client, ...) + return try await loader.load() + } + + func processColors(...) throws -> ColorsProcessResult { + let processor = ColorsProcessor(...) + return processor.process(...) + } +} +``` + +This enables: +- Plugins are testable with mock contexts +- CLI controls batch optimizations (pipelining, caching) +- Exporters remain simple and focused + +## Batch Processing Integration + +Plugins don't know about batch mode. Context implementations check for batch state: + +```swift +struct IconsExportContextImpl: IconsExportContext { + func downloadFiles(_ files: [DownloadRequest]) async throws { + if BatchSharedState.current != nil { + // Use shared download queue for batch optimization + try await pipelinedDownloader.download(files) + } else { + // Standalone mode + try await fileDownloader.download(files) + } + } +} +``` + +## Testing + +### Plugin Tests + +```swift +// Tests/ExFig-iOSTests/iOSPluginTests.swift +func testIdentifier() { + let plugin = iOSPlugin() + XCTAssertEqual(plugin.identifier, "ios") +} + +func testExportersCount() { + let plugin = iOSPlugin() + XCTAssertEqual(plugin.exporters().count, 4) +} +``` + +### Exporter Tests with Mock Context + +```swift +struct MockColorsExportContext: ColorsExportContext { + var loadedColors: ColorsLoadOutput? + + func loadColors(from: ColorsSourceInput) async throws -> ColorsLoadOutput { + return loadedColors ?? ColorsLoadOutput(light: [], dark: [], lightHC: [], darkHC: []) + } +} + +func testColorsExporter() async throws { + let exporter = iOSColorsExporter() + let context = MockColorsExportContext(loadedColors: mockColors) + + let count = try await exporter.exportColors( + entries: [testEntry], + platformConfig: testConfig, + context: context + ) + + XCTAssertEqual(count, 5) +} +``` + +## File Structure Summary + +``` +Sources/ +├── ExFigCLI/ # CLI executable +│ ├── Subcommands/ # colors, icons, images commands +│ ├── Plugin/ # PluginRegistry +│ ├── Context/ # *ExportContextImpl +│ ├── Source/ # Design source implementations +│ ├── MCP/ # Model Context Protocol server +│ └── Resources/Schemas/ # PKL schemas +├── ExFigCore/ # Protocols, domain models +│ └── Protocol/ # PlatformPlugin, *Exporter +├── ExFigConfig/ # PKL infrastructure +│ └── PKL/ # PKLLocator, PKLEvaluator +├── ExFig-iOS/ # iOS plugin +│ ├── Config/ # Entry types +│ └── Export/ # Exporters +├── ExFig-Android/ # Android plugin +├── ExFig-Flutter/ # Flutter plugin +├── ExFig-Web/ # Web plugin +├── XcodeExport/ # iOS file generation +├── AndroidExport/ # Android file generation +├── FlutterExport/ # Flutter file generation +├── WebExport/ # Web file generation +└── JinjaSupport/ # Shared Jinja2 template rendering +``` + +## Design Principles + +1. **Plugin isolation**: Each platform is independent, can build/test separately +2. **Protocol-based**: Exporters depend on protocols, not concrete types +3. **Context injection**: Dependencies passed via context, enabling testing +4. **Batch transparency**: Plugins don't know about batch optimizations +5. **PKL-first**: Configuration is type-safe at parse time +6. **Sendable**: All protocols require Sendable for async safety diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md new file mode 100755 index 00000000..0cf3e770 --- /dev/null +++ b/docs/MIGRATION.md @@ -0,0 +1,518 @@ +# YAML to PKL Migration Guide + +ExFig v2.0 replaces YAML configuration with PKL. This guide helps migrate existing configurations. + +## Quick Migration + +### 1. Install PKL + +```bash +mise use pkl +``` + +### 2. Rename Config File + +```bash +mv exfig.yaml exfig.pkl +``` + +### 3. Convert Syntax + +Replace YAML syntax with PKL syntax (see mapping below). + +### 4. Test + +```bash +pkl eval exfig.pkl # Validate config +exfig colors -i exfig.pkl --dry-run # Test export +``` + +## Syntax Mapping + +### File Header + +**YAML:** +```yaml +# ExFig configuration +``` + +**PKL:** +```pkl +amends "package://github.com/DesignPipe/exfig@2.0.0#/ExFig.pkl" + +import "package://github.com/DesignPipe/exfig@2.0.0#/Common.pkl" +import "package://github.com/DesignPipe/exfig@2.0.0#/iOS.pkl" +``` + +### Objects + +**YAML:** +```yaml +figma: + lightFileId: "abc123" + timeout: 60 +``` + +**PKL:** +```pkl +figma = new Figma.FigmaConfig { + lightFileId = "abc123" + timeout = 60 +} +``` + +### Nested Objects + +**YAML:** +```yaml +common: + cache: + enabled: true + path: ".cache.json" + variablesColors: + tokensFileId: "file123" + tokensCollectionName: "Tokens" +``` + +**PKL:** +```pkl +common = new Common.CommonConfig { + cache = new Common.Cache { + enabled = true + path = ".cache.json" + } + variablesColors = new Common.VariablesColors { + tokensFileId = "file123" + tokensCollectionName = "Tokens" + } +} +``` + +### Arrays/Lists + +**YAML:** +```yaml +ios: + colors: + - tokensFileId: "file1" + useColorAssets: true + assetsFolder: "Colors1" + - tokensFileId: "file2" + useColorAssets: true + assetsFolder: "Colors2" +``` + +**PKL:** +```pkl +ios = new iOS.iOSConfig { + colors = new Listing { + new iOS.ColorsEntry { + tokensFileId = "file1" + useColorAssets = true + assetsFolder = "Colors1" + nameStyle = "camelCase" + } + new iOS.ColorsEntry { + tokensFileId = "file2" + useColorAssets = true + assetsFolder = "Colors2" + nameStyle = "camelCase" + } + } +} +``` + +### Simple Lists + +**YAML:** +```yaml +resourceBundleNames: + - "ModuleA" + - "ModuleB" +``` + +**PKL:** +```pkl +resourceBundleNames = new Listing { "ModuleA"; "ModuleB" } +``` + +### Number Lists + +**YAML:** +```yaml +scales: + - 1 + - 2 + - 3 +``` + +**PKL:** +```pkl +scales = new Listing { 1; 2; 3 } +``` + +### Booleans + +**YAML:** +```yaml +useColorAssets: true +xmlDisabled: false +``` + +**PKL:** +```pkl +useColorAssets = true +xmlDisabled = false +``` + +### Strings + +**YAML:** +```yaml +xcodeprojPath: "MyApp.xcodeproj" +nameStyle: camelCase +``` + +**PKL:** +```pkl +xcodeprojPath = "MyApp.xcodeproj" +nameStyle = "camelCase" +``` + +**Note:** Enum values must be quoted in PKL. + +### Null/Optional Values + +**YAML:** +```yaml +darkFileId: ~ +darkModeName: null +``` + +**PKL:** +Simply omit the field (PKL optional fields default to null): +```pkl +// darkFileId is not specified +// darkModeName is not specified +``` + +### Comments + +**YAML:** +```yaml +# This is a comment +ios: + xcodeprojPath: "App.xcodeproj" # Inline comment +``` + +**PKL:** +```pkl +/// Doc comment (shown in IDE) +// Regular comment +ios = new iOS.iOSConfig { + xcodeprojPath = "App.xcodeproj" // Inline comment +} +``` + +## Complete Examples + +### iOS Colors Only + +**YAML:** +```yaml +common: + variablesColors: + tokensFileId: "abc123" + tokensCollectionName: "Design Tokens" + lightModeName: "Light" + darkModeName: "Dark" + +ios: + xcodeprojPath: "MyApp.xcodeproj" + target: "MyApp" + xcassetsPath: "MyApp/Assets.xcassets" + xcassetsInMainBundle: true + colors: + useColorAssets: true + assetsFolder: "Colors" + nameStyle: camelCase + colorSwift: "Generated/UIColor+Colors.swift" + swiftuiColorSwift: "Generated/Color+Colors.swift" +``` + +**PKL:** +```pkl +amends "package://github.com/DesignPipe/exfig@2.0.0#/ExFig.pkl" + +import "package://github.com/DesignPipe/exfig@2.0.0#/Common.pkl" +import "package://github.com/DesignPipe/exfig@2.0.0#/iOS.pkl" + +common = new Common.CommonConfig { + variablesColors = new Common.VariablesColors { + tokensFileId = "abc123" + tokensCollectionName = "Design Tokens" + lightModeName = "Light" + darkModeName = "Dark" + } +} + +ios = new iOS.iOSConfig { + xcodeprojPath = "MyApp.xcodeproj" + target = "MyApp" + xcassetsPath = "MyApp/Assets.xcassets" + xcassetsInMainBundle = true + + colors = new iOS.ColorsEntry { + useColorAssets = true + assetsFolder = "Colors" + nameStyle = "camelCase" + colorSwift = "Generated/UIColor+Colors.swift" + swiftuiColorSwift = "Generated/Color+Colors.swift" + } +} +``` + +### Multi-Platform + +**YAML:** +```yaml +figma: + lightFileId: "light-file" + darkFileId: "dark-file" + +common: + variablesColors: + tokensFileId: "tokens" + tokensCollectionName: "Colors" + lightModeName: "Light" + darkModeName: "Dark" + icons: + figmaFrameName: "Icons/24" + +ios: + xcodeprojPath: "iOS/App.xcodeproj" + target: "App" + xcassetsPath: "iOS/Assets.xcassets" + xcassetsInMainBundle: true + colors: + useColorAssets: true + assetsFolder: "Colors" + nameStyle: camelCase + icons: + format: pdf + assetsFolder: "Icons" + nameStyle: camelCase + +android: + mainRes: "android/app/src/main/res" + colors: + colorKotlin: "android/app/src/main/kotlin/Colors.kt" + composePackageName: "com.example.app" + icons: + output: "android/app/src/main/res/drawable" + composeFormat: imageVector +``` + +**PKL:** +```pkl +amends "package://github.com/DesignPipe/exfig@2.0.0#/ExFig.pkl" + +import "package://github.com/DesignPipe/exfig@2.0.0#/Figma.pkl" +import "package://github.com/DesignPipe/exfig@2.0.0#/Common.pkl" +import "package://github.com/DesignPipe/exfig@2.0.0#/iOS.pkl" +import "package://github.com/DesignPipe/exfig@2.0.0#/Android.pkl" + +figma = new Figma.FigmaConfig { + lightFileId = "light-file" + darkFileId = "dark-file" +} + +common = new Common.CommonConfig { + variablesColors = new Common.VariablesColors { + tokensFileId = "tokens" + tokensCollectionName = "Colors" + lightModeName = "Light" + darkModeName = "Dark" + } + icons = new Common.Icons { + figmaFrameName = "Icons/24" + } +} + +ios = new iOS.iOSConfig { + xcodeprojPath = "iOS/App.xcodeproj" + target = "App" + xcassetsPath = "iOS/Assets.xcassets" + xcassetsInMainBundle = true + + colors = new iOS.ColorsEntry { + useColorAssets = true + assetsFolder = "Colors" + nameStyle = "camelCase" + } + + icons = new iOS.IconsEntry { + format = "pdf" + assetsFolder = "Icons" + nameStyle = "camelCase" + } +} + +android = new Android.AndroidConfig { + mainRes = "android/app/src/main/res" + + colors = new Android.ColorsEntry { + colorKotlin = "android/app/src/main/kotlin/Colors.kt" + composePackageName = "com.example.app" + } + + icons = new Android.IconsEntry { + output = "android/app/src/main/res/drawable" + composeFormat = "imageVector" + } +} +``` + +## Key Differences + +| Aspect | YAML | PKL | +|--------|------|-----| +| Assignment | `:` | `=` | +| Objects | Indentation | `new Type { }` | +| Lists | `- item` | `new Listing { item; item }` | +| Strings | Quotes optional | Quotes required for enums | +| Comments | `#` | `//` or `///` | +| Null | `~` or `null` | Omit field | +| Types | Runtime validation | Compile-time validation | +| Inheritance | Not supported | `amends "base.pkl"` | + +## Required Fields + +PKL schemas may require fields that were optional in YAML: + +### iOS Colors + +```pkl +// Required in PKL +colors = new iOS.ColorsEntry { + useColorAssets = true // Required + nameStyle = "camelCase" // Required + // assetsFolder required if useColorAssets = true + assetsFolder = "Colors" +} +``` + +### Android Icons + +```pkl +// Required in PKL +icons = new Android.IconsEntry { + output = "drawable" // Required +} +``` + +### Flutter + +```pkl +// Required in PKL +flutter = new Flutter.FlutterConfig { + output = "lib/generated" // Required +} +``` + +## Common Migration Errors + +### Missing Type Prefix + +**Error:** +``` +Cannot find member 'ColorsEntry' in module 'ios' +``` + +**Fix:** Import and use full type path: +```pkl +import "package://github.com/DesignPipe/exfig@2.0.0#/iOS.pkl" + +colors = new iOS.ColorsEntry { ... } +``` + +### Wrong String Syntax + +**Error:** +``` +Expected string literal +``` + +**Fix:** Quote enum values: +```pkl +// Wrong +nameStyle = camelCase + +// Correct +nameStyle = "camelCase" +``` + +### Missing Required Field + +**Error:** +``` +Field 'useColorAssets' is required but missing +``` + +**Fix:** Add required field: +```pkl +colors = new iOS.ColorsEntry { + useColorAssets = true // Add this + nameStyle = "camelCase" +} +``` + +### List Syntax Error + +**Error:** +``` +Expected '}' but found 'new' +``` + +**Fix:** Use semicolons in Listing: +```pkl +// Wrong +scales = new Listing { 1, 2, 3 } + +// Correct +scales = new Listing { 1; 2; 3 } +``` + +## Batch Migration + +For batch configs, rename all `.yaml` files to `.pkl`: + +```bash +# Rename all yaml to pkl +for f in configs/*.yaml; do mv "$f" "${f%.yaml}.pkl"; done + +# Convert each file +for f in configs/*.pkl; do + echo "Converting $f..." + # Manual conversion required +done + +# Test batch +exfig batch configs/ --parallel 2 --dry-run +``` + +## Getting Help + +- [PKL Documentation](https://pkl-lang.org/main/current/index.html) +- [ExFig PKL Guide](./PKL.md) +- [ExFig Schema Reference](https://github.com/DesignPipe/exfig/tree/main/Sources/ExFigCLI/Resources/Schemas) + +## Validation Checklist + +Before committing migrated config: + +1. `pkl eval exfig.pkl` - No syntax/type errors +2. `exfig colors -i exfig.pkl --dry-run` - Export works +3. `exfig icons -i exfig.pkl --dry-run` - Icons export works +4. `exfig images -i exfig.pkl --dry-run` - Images export works +5. Compare output with previous YAML-based export diff --git a/docs/PKL.md b/docs/PKL.md new file mode 100755 index 00000000..302f6e44 --- /dev/null +++ b/docs/PKL.md @@ -0,0 +1,610 @@ +# PKL Configuration Guide + +PKL (Programmable, Scalable, Safe) is ExFig's configuration language, replacing YAML in v2.0. PKL provides native configuration inheritance via `amends`, type safety, and IDE support. + +## Installation + +PKL CLI is required to run ExFig. Install via mise: + +```bash +mise use pkl +``` + +Or manually from [pkl.dev](https://pkl.dev): + +```bash +# macOS (Apple Silicon) +curl -L https://github.com/apple/pkl/releases/download/0.30.2/pkl-macos-aarch64.gz | gunzip > /usr/local/bin/pkl +chmod +x /usr/local/bin/pkl + +# macOS (Intel) +curl -L https://github.com/apple/pkl/releases/download/0.30.2/pkl-macos-amd64.gz | gunzip > /usr/local/bin/pkl +chmod +x /usr/local/bin/pkl + +# Linux +curl -L https://github.com/apple/pkl/releases/download/0.30.2/pkl-linux-amd64.gz | gunzip > /usr/local/bin/pkl +chmod +x /usr/local/bin/pkl +``` + +Verify installation: + +```bash +pkl --version +``` + +## Basic Configuration + +Create `exfig.pkl` in your project root: + +```pkl +amends "package://github.com/DesignPipe/exfig@2.0.0#/ExFig.pkl" + +import "package://github.com/DesignPipe/exfig@2.0.0#/Common.pkl" +import "package://github.com/DesignPipe/exfig@2.0.0#/iOS.pkl" + +common = new Common.CommonConfig { + variablesColors = new Common.VariablesColors { + tokensFileId = "YOUR_FIGMA_FILE_ID" + tokensCollectionName = "Design Tokens" + lightModeName = "Light" + darkModeName = "Dark" + } +} + +ios = new iOS.iOSConfig { + xcodeprojPath = "MyApp.xcodeproj" + target = "MyApp" + xcassetsPath = "MyApp/Resources/Assets.xcassets" + xcassetsInMainBundle = true + + colors = new iOS.ColorsEntry { + useColorAssets = true + assetsFolder = "Colors" + nameStyle = "camelCase" + colorSwift = "MyApp/Generated/UIColor+Generated.swift" + swiftuiColorSwift = "MyApp/Generated/Color+Generated.swift" + } +} +``` + +Run ExFig: + +```bash +exfig colors -i exfig.pkl +``` + +## Configuration Inheritance + +PKL's `amends` keyword enables configuration inheritance. Create a base config that can be shared across projects: + +### Base Configuration (base.pkl) + +```pkl +amends "package://github.com/DesignPipe/exfig@2.0.0#/ExFig.pkl" + +import "package://github.com/DesignPipe/exfig@2.0.0#/Common.pkl" +import "package://github.com/DesignPipe/exfig@2.0.0#/Figma.pkl" + +figma = new Figma.FigmaConfig { + lightFileId = "YOUR_DESIGN_SYSTEM_FILE" + darkFileId = "YOUR_DESIGN_SYSTEM_DARK_FILE" + timeout = 60 +} + +common = new Common.CommonConfig { + cache = new Common.Cache { + enabled = true + } + variablesColors = new Common.VariablesColors { + tokensFileId = "YOUR_TOKENS_FILE" + tokensCollectionName = "Design System" + lightModeName = "Light" + darkModeName = "Dark" + lightHCModeName = "Light HC" + darkHCModeName = "Dark HC" + } + icons = new Common.Icons { + figmaFrameName = "Icons/24" + } + images = new Common.Images { + figmaFrameName = "Illustrations" + } +} +``` + +### Project Configuration (project-ios.pkl) + +```pkl +amends "base.pkl" + +import "package://github.com/DesignPipe/exfig@2.0.0#/iOS.pkl" + +ios = new iOS.iOSConfig { + xcodeprojPath = "ProjectA.xcodeproj" + target = "ProjectA" + xcassetsPath = "ProjectA/Assets.xcassets" + xcassetsInMainBundle = true + + colors = new iOS.ColorsEntry { + // Source comes from common.variablesColors (inherited from base.pkl) + useColorAssets = true + assetsFolder = "Colors" + nameStyle = "camelCase" + colorSwift = "ProjectA/Generated/UIColor+Colors.swift" + swiftuiColorSwift = "ProjectA/Generated/Color+Colors.swift" + } + + icons = new iOS.IconsEntry { + // figmaFrameName comes from common.icons (inherited from base.pkl) + format = "pdf" + assetsFolder = "Icons" + nameStyle = "camelCase" + renderMode = "template" + } +} +``` + +## Platform Configurations + +### iOS + +```pkl +ios = new iOS.iOSConfig { + // Required + xcodeprojPath = "MyApp.xcodeproj" // Path to Xcode project + target = "MyApp" // Xcode target name + xcassetsPath = "MyApp/Assets.xcassets" + xcassetsInMainBundle = true // true if assets in main bundle + + // Optional + xcassetsInSwiftPackage = false // true if assets in SPM package + resourceBundleNames = new Listing { "MyAppResources" } + addObjcAttribute = false // Add @objc to extensions + templatesPath = "Templates/" // Custom Stencil templates + + // Colors + colors = new iOS.ColorsEntry { + useColorAssets = true + assetsFolder = "Colors" + nameStyle = "camelCase" + groupUsingNamespace = false + colorSwift = "Generated/UIColor+Colors.swift" + swiftuiColorSwift = "Generated/Color+Colors.swift" + syncCodeSyntax = true + codeSyntaxTemplate = "Color.{name}" + } + + // Icons + icons = new iOS.IconsEntry { + figmaFrameName = "Icons" + format = "pdf" // "pdf" | "svg" + assetsFolder = "Icons" + nameStyle = "camelCase" + imageSwift = "Generated/UIImage+Icons.swift" + swiftUIImageSwift = "Generated/Image+Icons.swift" + codeConnectSwift = "Generated/Icons.figma.swift" + renderMode = "template" // "default" | "original" | "template" + preservesVectorRepresentation = new Listing { "icon-chevron" } + } + + // Images + images = new iOS.ImagesEntry { + figmaFrameName = "Illustrations" + assetsFolder = "Images" + nameStyle = "camelCase" + scales = new Listing { 1; 2; 3 } + sourceFormat = "svg" // "png" | "svg" + outputFormat = "heic" // "png" | "heic" + heicOptions = new iOS.HeicOptions { + encoding = "lossy" // "lossy" | "lossless" + quality = 90 + } + imageSwift = "Generated/UIImage+Images.swift" + swiftUIImageSwift = "Generated/Image+Images.swift" + } + + // Typography + typography = new iOS.Typography { + fontSwift = "Generated/UIFont+Styles.swift" + swiftUIFontSwift = "Generated/Font+Styles.swift" + labelStyleSwift = "Generated/LabelStyle.swift" + nameStyle = "camelCase" + generateLabels = true + labelsDirectory = "Generated/Labels/" + } +} +``` + +### Android + +```pkl +android = new Android.AndroidConfig { + // Required + mainRes = "app/src/main/res" + + // Optional + resourcePackage = "com.example.app" + mainSrc = "app/src/main/kotlin" + templatesPath = "Templates/" + + // Colors + colors = new Android.ColorsEntry { + xmlOutputFileName = "figma_colors.xml" + xmlDisabled = false // Skip XML for Compose-only + composePackageName = "com.example.app.ui.theme" + colorKotlin = "app/src/main/kotlin/ui/theme/Colors.kt" + themeAttributes = new Android.ThemeAttributes { + enabled = true + themeName = "Theme.MyApp" + attrsFile = "values/attrs.xml" + stylesFile = "values/styles.xml" + stylesNightFile = "values-night/styles.xml" + nameTransform = new Android.NameTransform { + style = "PascalCase" + prefix = "color" + stripPrefixes = new Listing { "bg"; "text" } + } + } + } + + // Icons + icons = new Android.IconsEntry { + figmaFrameName = "Icons" + output = "app/src/main/res/drawable" + composePackageName = "com.example.app.ui.icons" + composeFormat = "imageVector" // "resourceReference" | "imageVector" + composeExtensionTarget = "AppIcons" + nameStyle = "snake_case" + pathPrecision = 4 // 1-6, default 4 + strictPathValidation = true + } + + // Images + images = new Android.ImagesEntry { + figmaFrameName = "Illustrations" + output = "app/src/main/res/drawable" + format = "webp" // "svg" | "png" | "webp" + scales = new Listing { 1; 1.5; 2; 3; 4 } + sourceFormat = "svg" + webpOptions = new Android.WebpOptions { + encoding = "lossy" + quality = 85 + } + } + + // Typography + typography = new Android.Typography { + nameStyle = "camelCase" + composePackageName = "com.example.app.ui.theme" + } +} +``` + +### Flutter + +```pkl +flutter = new Flutter.FlutterConfig { + // Required + output = "lib/generated" + + // Optional + templatesPath = "Templates/" + + // Colors + colors = new Flutter.ColorsEntry { + output = "lib/generated/colors.dart" + className = "AppColors" + } + + // Icons + icons = new Flutter.IconsEntry { + figmaFrameName = "Icons" + output = "assets/icons" + dartFile = "lib/generated/icons.dart" + className = "AppIcons" + nameStyle = "camelCase" + } + + // Images + images = new Flutter.ImagesEntry { + figmaFrameName = "Illustrations" + output = "assets/images" + dartFile = "lib/generated/images.dart" + className = "AppImages" + scales = new Listing { 1; 2; 3 } + format = "webp" // "svg" | "png" | "webp" + sourceFormat = "svg" + nameStyle = "camelCase" + } +} +``` + +### Web + +```pkl +web = new Web.WebConfig { + // Required + output = "src/generated" + + // Optional + templatesPath = "Templates/" + + // Colors + colors = new Web.ColorsEntry { + outputDirectory = "src/generated/colors" + cssFileName = "colors.css" + tsFileName = "colors.ts" + jsonFileName = "colors.json" + } + + // Icons + icons = new Web.IconsEntry { + figmaFrameName = "Icons" + outputDirectory = "src/generated/icons" + svgDirectory = "public/icons" + generateReactComponents = true + iconSize = 24 + nameStyle = "PascalCase" + } + + // Images + images = new Web.ImagesEntry { + figmaFrameName = "Illustrations" + outputDirectory = "src/generated/images" + assetsDirectory = "public/images" + generateReactComponents = true + } +} +``` + +## Multiple Entries + +Each asset type supports multiple configurations for different sources or outputs: + +```pkl +ios = new iOS.iOSConfig { + xcodeprojPath = "MyApp.xcodeproj" + target = "MyApp" + xcassetsPath = "MyApp/Assets.xcassets" + xcassetsInMainBundle = true + + // Multiple color sources + colors = new Listing { + new iOS.ColorsEntry { + tokensFileId = "file1" + tokensCollectionName = "Brand Colors" + lightModeName = "Light" + useColorAssets = true + assetsFolder = "BrandColors" + nameStyle = "camelCase" + colorSwift = "Generated/UIColor+Brand.swift" + } + new iOS.ColorsEntry { + tokensFileId = "file2" + tokensCollectionName = "System Colors" + lightModeName = "Light" + useColorAssets = true + assetsFolder = "SystemColors" + nameStyle = "camelCase" + colorSwift = "Generated/UIColor+System.swift" + } + } + + // Multiple icon frames + icons = new Listing { + new iOS.IconsEntry { + figmaFrameName = "Icons/16" + format = "pdf" + assetsFolder = "Icons/Small" + nameStyle = "camelCase" + } + new iOS.IconsEntry { + figmaFrameName = "Icons/24" + format = "pdf" + assetsFolder = "Icons/Medium" + nameStyle = "camelCase" + } + } +} +``` + +## Entry-Level Overrides + +Each entry can override platform-level paths and even use a different Figma file. This is useful when different icon sets or image groups come from separate Figma files or need different output locations. + +Available override fields per platform: + +| Platform | Override Fields | +| -------- | ------------------------------------------------------- | +| iOS | `figmaFileId`, `xcassetsPath`, `templatesPath` | +| Android | `figmaFileId`, `mainRes`, `mainSrc`, `templatesPath` | +| Flutter | `figmaFileId`, `templatesPath` | +| Web | `figmaFileId`, `templatesPath` | + +When an override is set on an entry, it takes priority over the platform-level value. When not set, the platform config value is used as fallback. + +```pkl +ios = new iOS.iOSConfig { + xcodeprojPath = "MyApp.xcodeproj" + target = "MyApp" + xcassetsPath = "MyApp/Assets.xcassets" // Platform default + xcassetsInMainBundle = true + + icons = new Listing { + // Uses platform xcassetsPath ("MyApp/Assets.xcassets") + new iOS.IconsEntry { + format = "pdf" + assetsFolder = "Icons" + nameStyle = "camelCase" + } + // Overrides xcassetsPath and uses a separate Figma file + new iOS.IconsEntry { + figmaFileId = "brand-icons-figma-file" + figmaFrameName = "BrandIcons" + format = "svg" + assetsFolder = "BrandIcons" + nameStyle = "camelCase" + xcassetsPath = "BrandKit/Assets.xcassets" + templatesPath = "BrandKit/Templates" + } + } +} +``` + +## Common Settings + +Share settings across platforms using `common`: + +```pkl +common = new Common.CommonConfig { + // Version tracking cache + cache = new Common.Cache { + enabled = true + path = ".exfig-cache.json" + } + + // Shared color source for all platforms + variablesColors = new Common.VariablesColors { + tokensFileId = "YOUR_FILE_ID" + tokensCollectionName = "Design Tokens" + lightModeName = "Light" + darkModeName = "Dark" + lightHCModeName = "Light HC" + darkHCModeName = "Dark HC" + primitivesModeName = "Primitives" + } + + // Shared icons settings + icons = new Common.Icons { + figmaFrameName = "Icons/24" + useSingleFile = false + darkModeSuffix = "-dark" + strictPathValidation = true + } + + // Shared images settings + images = new Common.Images { + figmaFrameName = "Illustrations" + useSingleFile = false + darkModeSuffix = "-dark" + } + + // Name processing (applies to all) + colors = new Common.Colors { + nameValidateRegexp = "^[a-z][a-zA-Z0-9]*$" + nameReplaceRegexp = "color-" + } +} +``` + +## Figma Settings + +Configure Figma API access: + +```pkl +figma = new Figma.FigmaConfig { + lightFileId = "ABC123" // Light mode file + darkFileId = "DEF456" // Dark mode file (optional) + lightHighContrastFileId = "GHI789" + darkHighContrastFileId = "JKL012" + timeout = 60 // Request timeout in seconds +} +``` + +## Name Processing + +Control how Figma names are transformed: + +```pkl +colors = new iOS.ColorsEntry { + // Validate names match pattern + nameValidateRegexp = "^(bg|text|border)-.*$" + + // Replace parts of names + nameReplaceRegexp = "^(bg|text|border)-" // Strips prefix +} +``` + +### Name Styles + +- `camelCase`: backgroundPrimary +- `PascalCase`: BackgroundPrimary +- `snake_case`: background_primary +- `SCREAMING_SNAKE_CASE`: BACKGROUND_PRIMARY +- `flatCase`: backgroundprimary + +## Validation + +Validate your config without running export: + +```bash +pkl eval exfig.pkl +``` + +Check for type errors: + +```bash +pkl eval --format json exfig.pkl | jq . +``` + +## IDE Support + +### VS Code + +Install the [PKL extension](https://marketplace.visualstudio.com/items?itemName=apple.pkl-vscode) for: + +- Syntax highlighting +- Type checking +- Auto-completion +- Go to definition + +### IntelliJ IDEA + +Install the PKL plugin from JetBrains Marketplace. + +## Environment Variables + +Use PKL's read function for environment variables: + +```pkl +common = new Common.CommonConfig { + variablesColors = new Common.VariablesColors { + tokensFileId = read("env:FIGMA_TOKENS_FILE_ID") + // ... + } +} +``` + +**Note:** `FIGMA_PERSONAL_TOKEN` is read from environment by ExFig CLI, not from PKL config. + +## Troubleshooting + +### "pkl: command not found" + +Install PKL via mise: + +```bash +mise use pkl +``` + +### "Cannot find module" + +Ensure the package URL is correct and accessible: + +```pkl +// Correct +amends "package://github.com/DesignPipe/exfig@2.0.0#/ExFig.pkl" + +// Wrong (missing package://) +amends "github.com/DesignPipe/exfig@2.0.0#/ExFig.pkl" +``` + +### Type errors + +Check field names and types match the schema. Use IDE with PKL extension for real-time validation. + +## Resources + +- [PKL Documentation](https://pkl-lang.org/main/current/index.html) +- [PKL Language Reference](https://pkl-lang.org/main/current/language-reference/index.html) +- [ExFig Schema Reference](https://github.com/DesignPipe/exfig/tree/main/Sources/ExFigCLI/Resources/Schemas) From ee276c274f316a2c34636d849609d6c3b4756b64 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sun, 22 Mar 2026 19:11:27 +0500 Subject: [PATCH 22/26] docs: consolidate docs/ into ExFig.docc, remove duplicate directory Move ARCHITECTURE.md, PKL.md, MIGRATION.md from docs/ (which conflicts with DocC output directory) into Sources/ExFigCLI/ExFig.docc/. Move app-release-secrets.md to .github/. Update cross-references to use DocC links and hosted documentation URLs. --- .github/app-release-secrets.md | 122 ++++++++++++++++++ CLAUDE.md | 2 +- MIGRATION_FROM_FIGMA_EXPORT.md | 4 +- .../ExFigCLI/ExFig.docc/Architecture.md | 0 Sources/ExFigCLI/ExFig.docc/ExFig.md | 3 + .../ExFigCLI/ExFig.docc/Migration.md | 2 +- .../ExFigCLI/ExFig.docc/PKLGuide.md | 0 7 files changed, 129 insertions(+), 4 deletions(-) create mode 100755 .github/app-release-secrets.md rename docs/ARCHITECTURE.md => Sources/ExFigCLI/ExFig.docc/Architecture.md (100%) rename docs/MIGRATION.md => Sources/ExFigCLI/ExFig.docc/Migration.md (99%) rename docs/PKL.md => Sources/ExFigCLI/ExFig.docc/PKLGuide.md (100%) diff --git a/.github/app-release-secrets.md b/.github/app-release-secrets.md new file mode 100755 index 00000000..20877304 --- /dev/null +++ b/.github/app-release-secrets.md @@ -0,0 +1,122 @@ +# ExFig Studio Release Secrets Configuration + +This document describes the GitHub secrets required for automated ExFig Studio releases. + +## Required Secrets + +| Secret | Description | Required For | +| ----------------------------- | --------------------------------------------------- | -------------------- | +| `APPLE_TEAM_ID` | Apple Developer Team ID (10-character alphanumeric) | Code signing | +| `APPLE_IDENTITY_NAME` | Code signing identity name (e.g., "Your Name") | Code signing | +| `APPLE_CERTIFICATE_BASE64` | Developer ID certificate (.p12) as base64 | Code signing | +| `APPLE_CERTIFICATE_PASSWORD` | Password for the .p12 certificate | Code signing | +| `APPLE_ID` | Apple ID email for notarization | Notarization | +| `APPLE_NOTARIZATION_PASSWORD` | App-specific password for notarization | Notarization | +| `HOMEBREW_TAP_TOKEN` | GitHub PAT with repo access to homebrew-exfig | Homebrew Cask update | + +## Setup Instructions + +### 1. Export Developer ID Certificate + +```bash +# Open Keychain Access, find "Developer ID Application" certificate +# Right-click → Export → Save as .p12 with password + +# Encode to base64 +base64 -i DeveloperID.p12 | pbcopy +# Paste into APPLE_CERTIFICATE_BASE64 secret +``` + +### 2. Create App-Specific Password + +1. Go to [appleid.apple.com](https://appleid.apple.com) +2. Sign in and navigate to Security → App-Specific Passwords +3. Generate a new password for "ExFig Studio Notarization" +4. Save as `APPLE_NOTARIZATION_PASSWORD` secret + +### 3. Create Homebrew Tap Token + +1. Go to [github.com/settings/tokens](https://github.com/settings/tokens) +2. Create a new PAT (classic) with `repo` scope +3. Save as `HOMEBREW_TAP_TOKEN` secret + +### 4. Find Your Team ID + +```bash +# If you have Xcode installed +security find-identity -v -p codesigning | grep "Developer ID Application" +# Team ID is in parentheses: "Developer ID Application: Name (TEAM_ID)" +``` + +## Release Process + +### Create a Release + +```bash +# Tag format: studio-v.. +git tag studio-v1.0.0 +git push origin studio-v1.0.0 +``` + +### Manual Build (for testing) + +```bash +# Local build without signing +./Scripts/build-app-release.sh + +# Local build with signing +APPLE_TEAM_ID=YOUR_TEAM_ID ./Scripts/build-app-release.sh +``` + +### Workflow Dispatch + +You can also trigger a release manually from the GitHub Actions tab: + +1. Go to Actions → Release App +2. Click "Run workflow" +3. Enter the version number +4. Optionally skip notarization for testing + +## Homebrew Cask + +After a successful release, the workflow automatically updates the Homebrew Cask formula at: +`alexey1312/homebrew-exfig/Casks/exfig-studio.rb` + +Users can install with: + +```bash +brew tap alexey1312/exfig +brew install --cask exfig-studio +``` + +## Troubleshooting + +### Certificate Issues + +```bash +# Verify certificate is in keychain +security find-identity -v -p codesigning + +# Verify certificate chain +codesign -vvv --deep "dist/ExFig Studio.app" +``` + +### Notarization Failures + +```bash +# Check notarization status +xcrun notarytool history --apple-id YOUR_APPLE_ID --team-id YOUR_TEAM_ID + +# Get detailed log for a submission +xcrun notarytool log SUBMISSION_ID --apple-id YOUR_APPLE_ID --team-id YOUR_TEAM_ID +``` + +### Gatekeeper Issues + +```bash +# Verify app is properly signed and notarized +spctl --assess --verbose "dist/ExFig Studio.app" + +# Check stapling +xcrun stapler validate "dist/ExFig Studio.app" +``` diff --git a/CLAUDE.md b/CLAUDE.md index 8e1a4634..ba028d22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -227,7 +227,7 @@ When relocating a type (e.g., `Android.WebpOptions` → `Common.WebpOptions`), u 3. Swift bridging (`Sources/ExFig-*/Config/*Entry.swift`) — typealiases + extensions 4. Init-template configs (`Sources/ExFigCLI/Resources/*Config.swift`) — `new Type { }` refs 5. PKL examples (`Schemas/examples/*.pkl`) -6. DocC docs (`ExFig.docc/**/*.md`), CONFIG.md, MIGRATION.md +6. DocC docs (`ExFig.docc/**/*.md`), CONFIG.md ### Module Boundaries diff --git a/MIGRATION_FROM_FIGMA_EXPORT.md b/MIGRATION_FROM_FIGMA_EXPORT.md index 68dbde38..d5c6da06 100644 --- a/MIGRATION_FROM_FIGMA_EXPORT.md +++ b/MIGRATION_FROM_FIGMA_EXPORT.md @@ -260,6 +260,6 @@ exfig icons --concurrent-downloads 50 # Increase CDN parallelism ## Getting Help - Configuration reference: [CONFIG.md](CONFIG.md) -- PKL guide: [docs/PKL.md](docs/PKL.md) -- Migration guide (YAML to PKL): [MIGRATION.md](MIGRATION.md) +- PKL guide: [PKLGuide](https://DesignPipe.github.io/exfig/documentation/exfigcli/pklguide) +- Migration guide (YAML to PKL): [Migration](https://DesignPipe.github.io/exfig/documentation/exfigcli/migration) - Issues: [GitHub Issues](https://github.com/DesignPipe/exfig/issues) diff --git a/docs/ARCHITECTURE.md b/Sources/ExFigCLI/ExFig.docc/Architecture.md similarity index 100% rename from docs/ARCHITECTURE.md rename to Sources/ExFigCLI/ExFig.docc/Architecture.md diff --git a/Sources/ExFigCLI/ExFig.docc/ExFig.md b/Sources/ExFigCLI/ExFig.docc/ExFig.md index 1c000192..3b02792e 100644 --- a/Sources/ExFigCLI/ExFig.docc/ExFig.md +++ b/Sources/ExFigCLI/ExFig.docc/ExFig.md @@ -88,7 +88,10 @@ Compose color and icon objects, and Flutter path constants. - - - +- ### Contributing - +- +- diff --git a/docs/MIGRATION.md b/Sources/ExFigCLI/ExFig.docc/Migration.md similarity index 99% rename from docs/MIGRATION.md rename to Sources/ExFigCLI/ExFig.docc/Migration.md index 0cf3e770..6ac390d7 100755 --- a/docs/MIGRATION.md +++ b/Sources/ExFigCLI/ExFig.docc/Migration.md @@ -504,7 +504,7 @@ exfig batch configs/ --parallel 2 --dry-run ## Getting Help - [PKL Documentation](https://pkl-lang.org/main/current/index.html) -- [ExFig PKL Guide](./PKL.md) +- - [ExFig Schema Reference](https://github.com/DesignPipe/exfig/tree/main/Sources/ExFigCLI/Resources/Schemas) ## Validation Checklist diff --git a/docs/PKL.md b/Sources/ExFigCLI/ExFig.docc/PKLGuide.md similarity index 100% rename from docs/PKL.md rename to Sources/ExFigCLI/ExFig.docc/PKLGuide.md From da9e8152be6543387e67b0704e5cc9d43f50c765 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sun, 22 Mar 2026 19:12:46 +0500 Subject: [PATCH 23/26] docs: clarify that docs/ is DocC output, source docs live in ExFig.docc --- CLAUDE.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ba028d22..d13fc428 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -354,8 +354,9 @@ When changing fields on `ColorsSourceInput` / `IconsSourceInput` / `ImagesSource | README.md | Keep compact (~80 lines, pain-driven) | Detailed docs (use CONFIG.md / DocC) | **Documentation structure:** README is a short pain-driven intro (~80 lines). Detailed docs live in DocC articles -(`Sources/ExFigCLI/ExFig.docc/`). When adding new features, mention briefly in README Quick Start AND update -relevant DocC articles (`Usage.md` for CLI, `ExFig.md` landing page for capabilities). +(`Sources/ExFigCLI/ExFig.docc/`). Architecture, PKL Guide, and Migration are also DocC articles. +`docs/` is DocC OUTPUT (gitignored, for GitHub Pages) — never put source docs there. +When adding new features, mention briefly in README Quick Start AND update relevant DocC articles. **JSONCodec usage:** @@ -449,6 +450,7 @@ NooraUI.formatLink("url", useColors: true) // underlined primary | `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 | +| `docs/` source files lost | `docs/` is gitignored (DocC output). Source docs live in `ExFig.docc/`. Never `git add -f` to docs/ | | 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 | | `ColorsConfigError` new case | Has TWO switch blocks (`errorDescription` + `recoverySuggestion`) — adding a case to one without the other causes exhaustive switch error | From d2990b946608bf7f653ff5174583ea5e9e692340 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sun, 22 Mar 2026 19:34:02 +0500 Subject: [PATCH 24/26] docs: document nil-token behavior in resolveClient and list all source families Update doc-comment for the HeavyFaultToleranceOptions resolveClient overload to describe NoTokenFigmaClient fallback when accessToken is nil, matching the existing FaultToleranceOptions overload. Update Source/ directory description in CLAUDE.md to list all three source families (Figma, Penpot, TokensFile). --- CLAUDE.md | 2 +- Sources/ExFigCLI/Input/FaultToleranceOptions.swift | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d13fc428..53177c20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -143,7 +143,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.) +├── Source/ # Design source implementations (SourceFactory, Figma*Source, Penpot*Source, TokensFile*Source) ├── MCP/ # Model Context Protocol server (tools, resources, prompts) └── Shared/ # Cross-cutting helpers (PlatformExportResult, HashMerger) diff --git a/Sources/ExFigCLI/Input/FaultToleranceOptions.swift b/Sources/ExFigCLI/Input/FaultToleranceOptions.swift index ebe3a58f..8055cce4 100644 --- a/Sources/ExFigCLI/Input/FaultToleranceOptions.swift +++ b/Sources/ExFigCLI/Input/FaultToleranceOptions.swift @@ -290,8 +290,12 @@ func resolveClient( /// Resolves a Figma API client for heavy commands, using injected client if available /// (batch mode) or creating a new rate-limited client (standalone command mode). /// +/// When `accessToken` is nil (no `FIGMA_PERSONAL_TOKEN`), returns ``NoTokenFigmaClient`` — +/// a fail-fast client that throws on any request. Non-Figma sources (Penpot, tokens-file) +/// never call it, so pure-Penpot workflows work without Figma credentials. +/// /// - Parameters: -/// - accessToken: Figma personal access token. +/// - accessToken: Figma personal access token (nil when using non-Figma sources only). /// - timeout: Request timeout interval from config (optional, uses FigmaClient default if nil). /// - options: Heavy fault tolerance options for creating new client (may contain CLI timeout override). /// - ui: Terminal UI for retry warnings. @@ -308,6 +312,8 @@ func resolveClient( return injectedClient } guard let accessToken else { + // No Figma token — return a client that throws on any call. + // Non-Figma sources (Penpot, tokens-file) never call it. return NoTokenFigmaClient() } // CLI timeout takes precedence over config timeout From 1db05266aa7c24f82b71b5dfacc31faa765f4b2c Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sun, 22 Mar 2026 20:01:02 +0500 Subject: [PATCH 25/26] fix(penpot): improve error handling, validation, and DI across Penpot support - Validate Penpot format (svg/png) before API call instead of inside component loop - Make resolvedFileId source-kind-aware to prevent passing Figma keys to Penpot API - Replace silent .figma fallback on empty entries with explicit guard + error - Add URL validation in PenpotClientFactory before client creation - Remove synthetic FetchWizardResult with empty strings, pass penpotBaseURL directly - Replace default branch with explicit cases in VariablesSourceValidation - Add ColorsConfigError.unsupportedSourceKind for proper error at config layer - Inject TerminalUI explicitly into SourceFactory instead of global static access - Remove unused sourceKind parameter from PenpotComponentsSource.loadComponents - Fix leaked swiftlint:disable cyclomatic_complexity in DownloadImages --- CLAUDE.md | 116 ++++++++++-------- .../ExFigCLI/Source/PenpotClientFactory.swift | 5 + .../Source/PenpotComponentsSource.swift | 9 +- Sources/ExFigCLI/Source/SourceFactory.swift | 10 +- .../ExFigCLI/Subcommands/DownloadImages.swift | 42 +++---- .../Export/PluginIconsExport.swift | 28 +++-- .../Export/PluginImagesExport.swift | 28 +++-- Sources/ExFigConfig/CLAUDE.md | 7 +- Sources/ExFigConfig/SourceKindBridging.swift | 10 +- .../VariablesSourceValidation.swift | 4 +- .../ExFigCore/Protocol/ExportContext.swift | 7 +- .../ExFigTests/Source/PenpotSourceTests.swift | 15 ++- 12 files changed, 173 insertions(+), 108 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 53177c20..a4655edb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -235,6 +235,19 @@ ExFigCore does NOT import FigmaAPI. Constants on `Component` (FigmaAPI, extended not accessible from ExFigCore types (`IconsSourceInput`, `ImagesSourceInput`). Keep default values as string literals in ExFigCore inits; use shared constants only within ExFigCLI. +ExFigConfig imports ExFigCore but NOT ExFigCLI — `ExFigError` is not available. Use `ColorsConfigError` (ExFigCore) for validation errors. + +### Modifying SourceFactory Signatures + +`createComponentsSource` has 8 call sites (4 in `PluginIconsExport` + 4 in `PluginImagesExport`) plus tests in `PenpotSourceTests.swift`. +`createTypographySource` call sites: only tests (not yet wired to production export flow). +Use `replace_all` on the trailing parameter pattern (e.g., `filter: filter\n )`) to update all sites at once. + +### Source-Aware File ID Resolution (SourceKindBridging) + +`resolvedFileId` must be source-kind-aware: when `resolvedSourceKind == .penpot`, return ONLY `penpotSource?.fileId` (not coalescent `?? figmaFileId`). +Passing a Figma file key to Penpot API causes cryptic UUID parse errors. Same principle applies to any future source-specific identifiers. + ### RTL Detection Design - `Component.iconName`: uses `containingComponentSet.name` for variants, own `name` otherwise @@ -416,57 +429,58 @@ 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 | -| `docs/` source files lost | `docs/` is gitignored (DocC output). Source docs live in `ExFig.docc/`. Never `git add -f` to docs/ | -| 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 | -| `ColorsConfigError` new case | Has TWO switch blocks (`errorDescription` + `recoverySuggestion`) — adding a case to one without the other causes exhaustive switch error | -| 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") | -| `JSONCodec` in standalone module | `JSONCodec` lives in ExFigCore — external packages (swift-penpot-api) use `YYJSONEncoder()`/`YYJSONDecoder()` from YYJSON directly | -| `function_body_length` after branch | Split into private extension helper methods (e.g., `penpotColorsSourceInput()`, `tokensFileColorsSourceInput()`) | -| `ExFigCommand.terminalUI` in tests | Implicitly unwrapped — must init in `setUp()`: `ExFigCommand.terminalUI = TerminalUI(outputMode: .quiet)` before testing code that uses it (SourceFactory, Penpot sources) | -| `--timeout` duplicate in `fetch` | `FetchImages` uses both `DownloadOptions` and `HeavyFaultToleranceOptions` which both define `--timeout`. Fix: inline Heavy options + computed property | -| DocC articles not in Bundle.module | `.docc` articles aren't copied to SPM bundle — use `Resources/Guides/` with `.copy()` for MCP-served content | -| Penpot `update-file` changes format | Flat `changes[]` array, `type` dispatch, needs `vern` field. Shapes need `parentId`, `frameId`, `selrect`, `points`, `transform`. Undocumented — use validation errors | -| Switch expression + `return` | When any switch branch has side-effects before `return`, use explicit `return` on ALL branches — implicit return breaks type inference | -| Lazy Figma token validation | `ExFigOptions.validate()` reads token without throwing; `resolveClient()` returns placeholder if nil; `SourceFactory` guards `.figma` branch with `accessTokenNotFound` | -| PKL `swiftuiColorSwift` casing | PKL codegen lowercases: `swiftuiColorSwift`, not `swiftUIColorSwift` — check with `pkl eval` if unsure | -| Penpot `svgAttrs` decoding | `svgAttrs` contains mixed types (strings + nested dicts) — use `SVGAttributes` wrapper that extracts string values only, not `[String: String]` | -| iOS icons PKL: `xcassetsPath` | `xcassetsPath` and `target` are required in iOS PKL config even for Penpot; `assetsFolder` is folder name inside xcassets, not absolute path | +| 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 | +| `docs/` source files lost | `docs/` is gitignored (DocC output). Source docs live in `ExFig.docc/`. Never `git add -f` to docs/ | +| 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 | +| `ColorsConfigError` new case | Has TWO switch blocks (`errorDescription` + `recoverySuggestion`) — adding a case to one without the other causes exhaustive switch error. Adding associated value breaks auto-`Equatable` — add explicit `: Equatable` (tests use `XCTAssertEqual`) | +| `ExFigConfig` → `ExFigError` | `ExFigError` lives in ExFigCLI, not ExFigConfig. Use `ColorsConfigError` (ExFigCore) for validation errors in `VariablesSourceValidation.swift` | +| 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") | +| `JSONCodec` in standalone module | `JSONCodec` lives in ExFigCore — external packages (swift-penpot-api) use `YYJSONEncoder()`/`YYJSONDecoder()` from YYJSON directly | +| `function_body_length` after branch | Split into private extension helper methods (e.g., `penpotColorsSourceInput()`, `tokensFileColorsSourceInput()`) | +| `ExFigCommand.terminalUI` in tests | Implicitly unwrapped — must init in `setUp()`: `ExFigCommand.terminalUI = TerminalUI(outputMode: .quiet)` before testing code that uses it (SourceFactory, Penpot sources) | +| `--timeout` duplicate in `fetch` | `FetchImages` uses both `DownloadOptions` and `HeavyFaultToleranceOptions` which both define `--timeout`. Fix: inline Heavy options + computed property | +| DocC articles not in Bundle.module | `.docc` articles aren't copied to SPM bundle — use `Resources/Guides/` with `.copy()` for MCP-served content | +| Penpot `update-file` changes format | Flat `changes[]` array, `type` dispatch, needs `vern` field. Shapes need `parentId`, `frameId`, `selrect`, `points`, `transform`. Undocumented — use validation errors | +| Switch expression + `return` | When any switch branch has side-effects before `return`, use explicit `return` on ALL branches — implicit return breaks type inference | +| Lazy Figma token validation | `ExFigOptions.validate()` reads token without throwing; `resolveClient()` returns placeholder if nil; `SourceFactory` guards `.figma` branch with `accessTokenNotFound` | +| PKL `swiftuiColorSwift` casing | PKL codegen lowercases: `swiftuiColorSwift`, not `swiftUIColorSwift` — check with `pkl eval` if unsure | +| Penpot `svgAttrs` decoding | `svgAttrs` contains mixed types (strings + nested dicts) — use `SVGAttributes` wrapper that extracts string values only, not `[String: String]` | +| iOS icons PKL: `xcassetsPath` | `xcassetsPath` and `target` are required in iOS PKL config even for Penpot; `assetsFolder` is folder name inside xcassets, not absolute path | ## Additional Rules diff --git a/Sources/ExFigCLI/Source/PenpotClientFactory.swift b/Sources/ExFigCLI/Source/PenpotClientFactory.swift index 3a5f9c7a..b86e36a2 100644 --- a/Sources/ExFigCLI/Source/PenpotClientFactory.swift +++ b/Sources/ExFigCLI/Source/PenpotClientFactory.swift @@ -4,6 +4,11 @@ import PenpotAPI /// Shared factory for creating authenticated Penpot API clients. enum PenpotClientFactory { static func makeClient(baseURL: String) throws -> any PenpotClient { + guard URL(string: baseURL)?.host != nil else { + throw ExFigError.configurationError( + "Invalid Penpot base URL '\(baseURL)' — must be a valid URL (e.g., https://design.penpot.app/)" + ) + } guard let token = ProcessInfo.processInfo.environment["PENPOT_ACCESS_TOKEN"], !token.isEmpty else { throw ExFigError.configurationError( "PENPOT_ACCESS_TOKEN environment variable is required for Penpot source" diff --git a/Sources/ExFigCLI/Source/PenpotComponentsSource.swift b/Sources/ExFigCLI/Source/PenpotComponentsSource.swift index 60ad553b..ba37de8c 100644 --- a/Sources/ExFigCLI/Source/PenpotComponentsSource.swift +++ b/Sources/ExFigCLI/Source/PenpotComponentsSource.swift @@ -9,8 +9,7 @@ struct PenpotComponentsSource: ComponentsSource { let packs = try await loadComponents( fileId: input.figmaFileId, baseURL: input.penpotBaseURL, - pathFilter: input.frameName, - sourceKind: input.sourceKind + pathFilter: input.frameName ) return IconsLoadOutput(light: packs) } @@ -19,8 +18,7 @@ struct PenpotComponentsSource: ComponentsSource { let packs = try await loadComponents( fileId: input.figmaFileId, baseURL: input.penpotBaseURL, - pathFilter: input.frameName, - sourceKind: input.sourceKind + pathFilter: input.frameName ) return ImagesLoadOutput(light: packs) } @@ -30,8 +28,7 @@ struct PenpotComponentsSource: ComponentsSource { private func loadComponents( fileId: String?, baseURL: String?, - pathFilter: String, - sourceKind: DesignSourceKind + pathFilter: String ) async throws -> [ImagePack] { guard let fileId, !fileId.isEmpty else { throw ExFigError.configurationError( diff --git a/Sources/ExFigCLI/Source/SourceFactory.swift b/Sources/ExFigCLI/Source/SourceFactory.swift index 201f74e1..0032ba52 100644 --- a/Sources/ExFigCLI/Source/SourceFactory.swift +++ b/Sources/ExFigCLI/Source/SourceFactory.swift @@ -31,7 +31,8 @@ enum SourceFactory { params: PKLConfig, platform: Platform, logger: Logger, - filter: String? + filter: String?, + ui: TerminalUI ) throws -> any ComponentsSource { switch sourceKind { case .figma: @@ -44,7 +45,7 @@ enum SourceFactory { filter: filter ) case .penpot: - return PenpotComponentsSource(ui: ExFigCommand.terminalUI) + return PenpotComponentsSource(ui: ui) case .tokensFile, .tokensStudio, .sketchFile: throw ExFigError.unsupportedSourceKind(sourceKind, assetType: "icons/images") } @@ -52,14 +53,15 @@ enum SourceFactory { static func createTypographySource( for sourceKind: DesignSourceKind, - client: Client? + client: Client?, + ui: TerminalUI ) throws -> any TypographySource { switch sourceKind { case .figma: guard let client else { throw ExFigError.accessTokenNotFound } return FigmaTypographySource(client: client) case .penpot: - return PenpotTypographySource(ui: ExFigCommand.terminalUI) + return PenpotTypographySource(ui: ui) case .tokensFile, .tokensStudio, .sketchFile: throw ExFigError.unsupportedSourceKind(sourceKind, assetType: "typography") } diff --git a/Sources/ExFigCLI/Subcommands/DownloadImages.swift b/Sources/ExFigCLI/Subcommands/DownloadImages.swift index 037d6195..5e71c7c4 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadImages.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadImages.swift @@ -120,19 +120,8 @@ extension ExFigCommand { // Penpot path — use PenpotAPI directly if designSource == .penpot { - let penpotResult = wizardResult ?? FetchWizardResult( - designSource: .penpot, - fileId: options.fileId ?? "", - frameName: options.frameName ?? "", - pageName: options.pageName, - outputPath: options.outputPath ?? "", - format: options.format, - scale: options.scale, - nameStyle: options.nameStyle, - filter: options.filter, - penpotBaseURL: options.penpotBaseURL - ) - try await runPenpotFetch(options: options, wizardResult: penpotResult, ui: ui) + let penpotBaseURL = wizardResult?.penpotBaseURL ?? options.penpotBaseURL + try await runPenpotFetch(options: options, penpotBaseURL: penpotBaseURL, ui: ui) return } @@ -324,7 +313,7 @@ extension ExFigCommand { // swiftlint:disable function_body_length cyclomatic_complexity private func runPenpotFetch( options: DownloadOptions, - wizardResult: FetchWizardResult, + penpotBaseURL: String?, ui: TerminalUI ) async throws { guard let fileId = options.fileId else { @@ -337,7 +326,19 @@ extension ExFigCommand { throw ValidationError("--output is required") } - let baseURL = wizardResult.penpotBaseURL ?? BasePenpotClient.defaultBaseURL + // Validate format early — Penpot supports svg and png (via SVG reconstruction) + let format = options.format ?? .svg + switch format { + case .svg, .png: + break // supported + default: + throw ExFigError.custom( + errorString: "Format '\(format.rawValue)' is not yet supported for Penpot export. " + + "Supported formats: svg, png" + ) + } + + let baseURL = penpotBaseURL ?? BasePenpotClient.defaultBaseURL let client = try PenpotClientFactory.makeClient(baseURL: baseURL) let outputURL = URL(fileURLWithPath: outputPath, isDirectory: true) @@ -370,8 +371,7 @@ extension ExFigCommand { ui.info("Found \(matched.count) components") - // Reconstruct SVG from shape tree - let format = options.format ?? .svg + // Reconstruct SVG from shape tree (format already validated above) var exportedCount = 0 for component in matched { @@ -423,10 +423,8 @@ extension ExFigCommand { let fileURL = outputURL.appendingPathComponent("\(safeName).png") try pngData.write(to: fileURL) default: - throw ExFigError.custom( - errorString: "Format '\(format.rawValue)' is not yet supported for Penpot export. " + - "Supported formats: svg, png" - ) + // Unreachable — format validated at method start + fatalError("Unsupported format '\(format.rawValue)' should have been caught earlier") } exportedCount += 1 @@ -444,7 +442,7 @@ extension ExFigCommand { } } - // swiftlint:enable function_body_length + // swiftlint:enable function_body_length cyclomatic_complexity private func convertToWebP( _ files: [FileContents], diff --git a/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift b/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift index 589c0718..b73dcfaf 100644 --- a/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift +++ b/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift @@ -40,14 +40,17 @@ extension ExFigCommand.ExportIcons { let fileDownloader = faultToleranceOptions.createFileDownloader() // All entries in a platform section share one source kind (mixed sources not yet supported) - let sourceKind = entries.first?.resolvedSourceKind ?? .figma + guard let sourceKind = entries.first?.resolvedSourceKind else { + throw ExFigError.configurationError("No entries provided for icons export") + } let componentsSource = try SourceFactory.createComponentsSource( for: sourceKind, client: client, params: params, platform: .ios, logger: ExFigCommand.logger, - filter: filter + filter: filter, + ui: ui ) let context = IconsExportContextImpl( @@ -120,14 +123,17 @@ extension ExFigCommand.ExportIcons { let fileDownloader = faultToleranceOptions.createFileDownloader() // All entries in a platform section share one source kind (mixed sources not yet supported) - let sourceKind = entries.first?.resolvedSourceKind ?? .figma + guard let sourceKind = entries.first?.resolvedSourceKind else { + throw ExFigError.configurationError("No entries provided for icons export") + } let componentsSource = try SourceFactory.createComponentsSource( for: sourceKind, client: client, params: params, platform: .android, logger: ExFigCommand.logger, - filter: filter + filter: filter, + ui: ui ) let context = IconsExportContextImpl( @@ -176,14 +182,17 @@ extension ExFigCommand.ExportIcons { let fileDownloader = faultToleranceOptions.createFileDownloader() // All entries in a platform section share one source kind (mixed sources not yet supported) - let sourceKind = entries.first?.resolvedSourceKind ?? .figma + guard let sourceKind = entries.first?.resolvedSourceKind else { + throw ExFigError.configurationError("No entries provided for icons export") + } let componentsSource = try SourceFactory.createComponentsSource( for: sourceKind, client: client, params: params, platform: .flutter, logger: ExFigCommand.logger, - filter: filter + filter: filter, + ui: ui ) let context = IconsExportContextImpl( @@ -232,14 +241,17 @@ extension ExFigCommand.ExportIcons { let fileDownloader = faultToleranceOptions.createFileDownloader() // All entries in a platform section share one source kind (mixed sources not yet supported) - let sourceKind = entries.first?.resolvedSourceKind ?? .figma + guard let sourceKind = entries.first?.resolvedSourceKind else { + throw ExFigError.configurationError("No entries provided for icons export") + } let componentsSource = try SourceFactory.createComponentsSource( for: sourceKind, client: client, params: params, platform: .web, logger: ExFigCommand.logger, - filter: filter + filter: filter, + ui: ui ) let context = IconsExportContextImpl( diff --git a/Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift b/Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift index 0213284a..90cb4d54 100644 --- a/Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift +++ b/Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift @@ -39,14 +39,17 @@ extension ExFigCommand.ExportImages { let fileDownloader = faultToleranceOptions.createFileDownloader() // All entries in a platform section share one source kind (mixed sources not yet supported) - let sourceKind = entries.first?.resolvedSourceKind ?? .figma + guard let sourceKind = entries.first?.resolvedSourceKind else { + throw ExFigError.configurationError("No entries provided for images export") + } let componentsSource = try SourceFactory.createComponentsSource( for: sourceKind, client: client, params: params, platform: .ios, logger: ExFigCommand.logger, - filter: filter + filter: filter, + ui: ui ) let context = ImagesExportContextImpl( @@ -118,14 +121,17 @@ extension ExFigCommand.ExportImages { let fileDownloader = faultToleranceOptions.createFileDownloader() // All entries in a platform section share one source kind (mixed sources not yet supported) - let sourceKind = entries.first?.resolvedSourceKind ?? .figma + guard let sourceKind = entries.first?.resolvedSourceKind else { + throw ExFigError.configurationError("No entries provided for images export") + } let componentsSource = try SourceFactory.createComponentsSource( for: sourceKind, client: client, params: params, platform: .android, logger: ExFigCommand.logger, - filter: filter + filter: filter, + ui: ui ) let context = ImagesExportContextImpl( @@ -174,14 +180,17 @@ extension ExFigCommand.ExportImages { let fileDownloader = faultToleranceOptions.createFileDownloader() // All entries in a platform section share one source kind (mixed sources not yet supported) - let sourceKind = entries.first?.resolvedSourceKind ?? .figma + guard let sourceKind = entries.first?.resolvedSourceKind else { + throw ExFigError.configurationError("No entries provided for images export") + } let componentsSource = try SourceFactory.createComponentsSource( for: sourceKind, client: client, params: params, platform: .flutter, logger: ExFigCommand.logger, - filter: filter + filter: filter, + ui: ui ) let context = ImagesExportContextImpl( @@ -230,14 +239,17 @@ extension ExFigCommand.ExportImages { let fileDownloader = faultToleranceOptions.createFileDownloader() // All entries in a platform section share one source kind (mixed sources not yet supported) - let sourceKind = entries.first?.resolvedSourceKind ?? .figma + guard let sourceKind = entries.first?.resolvedSourceKind else { + throw ExFigError.configurationError("No entries provided for images export") + } let componentsSource = try SourceFactory.createComponentsSource( for: sourceKind, client: client, params: params, platform: .web, logger: ExFigCommand.logger, - filter: filter + filter: filter, + ui: ui ) let context = ImagesExportContextImpl( diff --git a/Sources/ExFigConfig/CLAUDE.md b/Sources/ExFigConfig/CLAUDE.md index 1697ab4c..a70120c0 100644 --- a/Sources/ExFigConfig/CLAUDE.md +++ b/Sources/ExFigConfig/CLAUDE.md @@ -57,7 +57,7 @@ ExFigCore domain types (NameStyle, ColorsSourceInput, etc.) | `Common_VariablesSource.resolvedSourceKind` | Resolution priority: explicit `sourceKind` > auto-detect (penpotSource > tokensFile) > default `.figma` | | `Common_VariablesSource.validatedColorsSourceInput()` | Validates required fields, returns `ColorsSourceInput`. Uses `resolvedSourceKind` for dispatch | | `Common_FrameSource.resolvedSourceKind` | Resolution priority: explicit `sourceKind` > auto-detect (penpotSource presence) > default `.figma`. Defined in `SourceKindBridging.swift` | -| `Common_FrameSource.resolvedFileId` | `penpotSource?.fileId ?? figmaFileId` — auto-resolves file ID for any source. Defined in `SourceKindBridging.swift` | +| `Common_FrameSource.resolvedFileId` | Source-kind-aware: Penpot → `penpotSource?.fileId` only, Figma → `figmaFileId`. Defined in `SourceKindBridging.swift` | | `Common_FrameSource.resolvedPenpotBaseURL` | `penpotSource?.baseUrl` — passes Penpot base URL through entry bridges. Defined in `SourceKindBridging.swift` | ### Generated Type Gotchas @@ -82,6 +82,11 @@ When adding new PKL types to schemas, regenerate with `codegen:pkl` and add the - New fields in generated inits require updating ALL test call sites with new `nil` parameters - Generated files are excluded from SwiftLint (`.swiftlint.yml`) +## Module Boundaries + +ExFigConfig imports ExFigCore but NOT ExFigCLI. Error types available: `ColorsConfigError` (ExFigCore), NOT `ExFigError` (ExFigCLI). +When adding validation errors in this module, extend `ColorsConfigError` or create a new error enum in ExFigCore. + ## Consumers All platform plugins (`ExFig-iOS`, `ExFig-Android`, `ExFig-Flutter`, `ExFig-Web`) and ExFigCLI import this module. Entry types from `Generated/` are extended in platform Config directories with computed properties that bridge to ExFigCore types. diff --git a/Sources/ExFigConfig/SourceKindBridging.swift b/Sources/ExFigConfig/SourceKindBridging.swift index 7ed60172..01275c89 100644 --- a/Sources/ExFigConfig/SourceKindBridging.swift +++ b/Sources/ExFigConfig/SourceKindBridging.swift @@ -30,9 +30,15 @@ public extension Common_FrameSource { return .figma } - /// Resolves the file ID: Penpot source takes priority, then Figma file ID. + /// Resolves the file ID based on the resolved source kind. + /// + /// When source is Penpot, returns only the Penpot file ID (not Figma's) + /// to prevent passing a Figma file key to the Penpot API. var resolvedFileId: String? { - penpotSource?.fileId ?? figmaFileId + if resolvedSourceKind == .penpot { + return penpotSource?.fileId + } + return figmaFileId } /// Resolves the Penpot base URL from penpotSource config. diff --git a/Sources/ExFigConfig/VariablesSourceValidation.swift b/Sources/ExFigConfig/VariablesSourceValidation.swift index e3f41946..acbabd8a 100644 --- a/Sources/ExFigConfig/VariablesSourceValidation.swift +++ b/Sources/ExFigConfig/VariablesSourceValidation.swift @@ -26,8 +26,10 @@ public extension Common_VariablesSource { return try penpotColorsSourceInput() case .tokensFile: return try tokensFileColorsSourceInput() - default: + case .figma: return try figmaColorsSourceInput(kind: kind) + case .tokensStudio, .sketchFile: + throw ColorsConfigError.unsupportedSourceKind(kind) } } } diff --git a/Sources/ExFigCore/Protocol/ExportContext.swift b/Sources/ExFigCore/Protocol/ExportContext.swift index 74b7f252..9d82f3b1 100644 --- a/Sources/ExFigCore/Protocol/ExportContext.swift +++ b/Sources/ExFigCore/Protocol/ExportContext.swift @@ -140,11 +140,12 @@ public struct ColorsSourceInput: Sendable { } /// Error thrown when required colors configuration fields are missing. -public enum ColorsConfigError: LocalizedError { +public enum ColorsConfigError: LocalizedError, Equatable { case missingTokensFileId case missingTokensCollectionName case missingLightModeName case missingPenpotSource + case unsupportedSourceKind(DesignSourceKind) public var errorDescription: String? { switch self { @@ -156,6 +157,8 @@ public enum ColorsConfigError: LocalizedError { "lightModeName is required for colors export" case .missingPenpotSource: "penpotSource configuration is required when sourceKind is 'penpot'" + case let .unsupportedSourceKind(kind): + "Source kind '\(kind.rawValue)' is not supported for colors export" } } @@ -169,6 +172,8 @@ public enum ColorsConfigError: LocalizedError { "Add 'lightModeName' to your colors entry, or set common.variablesColors" case .missingPenpotSource: "Add 'penpotSource { fileId = \"...\" }' to your colors entry" + case let .unsupportedSourceKind(kind): + "Supported source kinds for colors: figma, penpot, tokensFile. Got: '\(kind.rawValue)'" } } } diff --git a/Tests/ExFigTests/Source/PenpotSourceTests.swift b/Tests/ExFigTests/Source/PenpotSourceTests.swift index fc9f37d5..081236b5 100644 --- a/Tests/ExFigTests/Source/PenpotSourceTests.swift +++ b/Tests/ExFigTests/Source/PenpotSourceTests.swift @@ -116,21 +116,25 @@ final class SourceFactoryPenpotTests: XCTestCase { } func testCreateComponentsSourceForPenpot() throws { + let ui = TerminalUI(outputMode: .quiet) let source = try SourceFactory.createComponentsSource( for: .penpot, client: dummyClient(), params: dummyPKLConfig(), platform: .ios, logger: .init(label: "test"), - filter: nil + filter: nil, + ui: ui ) XCTAssert(source is PenpotComponentsSource) } func testCreateTypographySourceForPenpot() throws { + let ui = TerminalUI(outputMode: .quiet) let source = try SourceFactory.createTypographySource( for: .penpot, - client: dummyClient() + client: dummyClient(), + ui: ui ) XCTAssert(source is PenpotTypographySource) } @@ -147,6 +151,7 @@ final class SourceFactoryPenpotTests: XCTestCase { } func testUnsupportedSourceKindThrowsForComponents() { + let ui = TerminalUI(outputMode: .quiet) XCTAssertThrowsError( try SourceFactory.createComponentsSource( for: .sketchFile, @@ -154,14 +159,16 @@ final class SourceFactoryPenpotTests: XCTestCase { params: dummyPKLConfig(), platform: .ios, logger: .init(label: "test"), - filter: nil + filter: nil, + ui: ui ) ) } func testUnsupportedSourceKindThrowsForTypography() { + let ui = TerminalUI(outputMode: .quiet) XCTAssertThrowsError( - try SourceFactory.createTypographySource(for: .tokensStudio, client: dummyClient()) + try SourceFactory.createTypographySource(for: .tokensStudio, client: dummyClient(), ui: ui) ) } } From b8e968bd60388921f236506e69c251082f86496c Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sun, 22 Mar 2026 20:02:08 +0500 Subject: [PATCH 26/26] chore: update cover --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5bdb07ab..9895bca5 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![CI](https://github.com/DesignPipe/exfig/actions/workflows/ci.yml/badge.svg)](https://github.com/DesignPipe/exfig/actions/workflows/ci.yml) [![Release](https://github.com/DesignPipe/exfig/actions/workflows/release.yml/badge.svg)](https://github.com/DesignPipe/exfig/actions/workflows/release.yml) [![Docs](https://github.com/DesignPipe/exfig/actions/workflows/deploy-docc.yml/badge.svg)](https://DesignPipe.github.io/exfig/documentation/exfigcli) -![Coverage](https://img.shields.io/badge/coverage-50.65%25-yellow) +![Coverage](https://img.shields.io/badge/coverage-43.65%25-yellow) [![License](https://img.shields.io/github/license/DesignPipe/exfig.svg)](LICENSE) Export colors, typography, icons, and images from Figma and Penpot to Xcode, Android Studio, Flutter, and Web projects — automatically.