From 6894db15f5cbe161c70970fb6146b9d9d7338e43 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 20 Mar 2026 13:38:03 +0500 Subject: [PATCH 01/12] feat(cli): add interactive wizard for `exfig fetch` When required options (--file-id, --frame, --output) are omitted in an interactive terminal, a guided wizard helps fill them in with smart platform-specific defaults. Changes: - NooraUI: add textPrompt, singleChoicePrompt, yesOrNoPrompt wrappers - FetchWizard: wizard flow with WizardPlatform, WizardAssetType, PlatformDefaults (format, scale, nameStyle per platform) - DownloadOptions: make fileId, frameName, outputPath optional - DownloadImages: wire wizard into run(), fallback to error in non-TTY - ImageFormat: add CustomStringConvertible for display in prompts - Usage spec: update fetch flags as optional with interactive docs - Tests: FetchWizardTests (16 tests), updated DownloadOptionsTests --- Sources/ExFigCLI/Input/DownloadOptions.swift | 27 ++- .../ExFigCLI/Subcommands/DownloadImages.swift | 152 +++++++++---- .../ExFigCLI/Subcommands/FetchWizard.swift | 199 ++++++++++++++++++ Sources/ExFigCLI/TerminalUI/NooraUI.swift | 74 +++++++ .../Input/DownloadOptionsTests.swift | 17 +- .../Subcommands/FetchWizardTests.swift | 134 ++++++++++++ exfig.usage.kdl | 8 +- 7 files changed, 558 insertions(+), 53 deletions(-) create mode 100644 Sources/ExFigCLI/Subcommands/FetchWizard.swift create mode 100644 Tests/ExFigTests/Subcommands/FetchWizardTests.swift diff --git a/Sources/ExFigCLI/Input/DownloadOptions.swift b/Sources/ExFigCLI/Input/DownloadOptions.swift index 3f56076b..e864c5b0 100644 --- a/Sources/ExFigCLI/Input/DownloadOptions.swift +++ b/Sources/ExFigCLI/Input/DownloadOptions.swift @@ -54,13 +54,13 @@ struct DownloadOptions: ParsableArguments { name: [.customLong("file-id"), .customShort("f")], help: "Figma file ID (from the URL: figma.com/file//...)" ) - var fileId: String + var fileId: String? @Option( name: [.customLong("frame"), .customShort("r")], help: "Name of the Figma frame containing images" ) - var frameName: String + var frameName: String? @Option(name: [.customLong("page"), .customShort("p")], help: "Filter by Figma page name.") var pageName: String? @@ -69,7 +69,7 @@ struct DownloadOptions: ParsableArguments { name: [.customLong("output"), .customShort("o")], help: "Output directory for downloaded images" ) - var outputPath: String + var outputPath: String? // MARK: - Format Options @@ -186,8 +186,23 @@ struct DownloadOptions: ParsableArguments { ProcessInfo.processInfo.environment["FIGMA_PERSONAL_TOKEN"] } - /// Returns the output directory URL - var outputURL: URL { - URL(fileURLWithPath: outputPath, isDirectory: true) + /// Returns the output directory URL (nil if outputPath not set) + var outputURL: URL? { + guard let outputPath else { return nil } + return URL(fileURLWithPath: outputPath, isDirectory: true) + } +} + +// MARK: - ImageFormat Display + +extension ImageFormat: CustomStringConvertible { + var description: String { + switch self { + case .png: "PNG" + case .svg: "SVG" + case .jpg: "JPG" + case .pdf: "PDF" + case .webp: "WebP" + } } } diff --git a/Sources/ExFigCLI/Subcommands/DownloadImages.swift b/Sources/ExFigCLI/Subcommands/DownloadImages.swift index 519031b7..155ac9ab 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadImages.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadImages.swift @@ -13,7 +13,13 @@ extension ExFigCommand { Downloads images from a specific Figma frame to a local directory. All parameters are passed via command-line arguments. + When required options (--file-id, --frame, --output) are omitted in an + interactive terminal, a guided wizard helps you fill them in. + Examples: + # Interactive wizard (TTY only) + exfig fetch + # Download PNGs at 3x scale (default) exfig fetch --file-id abc123 --frame "Illustrations" --output ./images @@ -46,32 +52,70 @@ extension ExFigCommand { @OptionGroup var faultToleranceOptions: HeavyFaultToleranceOptions - // swiftlint:disable:next function_body_length + // swiftlint:disable function_body_length cyclomatic_complexity + func run() async throws { // Initialize terminal UI ExFigCommand.initializeTerminalUI(verbose: globalOptions.verbose, quiet: globalOptions.quiet) let ui = ExFigCommand.terminalUI! + // Resolve required options via wizard if missing + var options = downloadOptions + if options.fileId == nil || options.frameName == nil || options.outputPath == nil { + guard TTYDetector.isTTY else { + throw ValidationError( + "Missing required options: --file-id, --frame, --output. " + + "Run in interactive terminal for guided setup." + ) + } + let result = FetchWizard.run() + options.fileId = options.fileId ?? result.fileId + options.frameName = options.frameName ?? result.frameName + options.outputPath = options.outputPath ?? result.outputPath + options.pageName = options.pageName ?? result.pageName + options.filter = options.filter ?? result.filter + if options.format == .png { + options.format = result.format + } + if options.nameStyle == nil { + options.nameStyle = result.nameStyle + } + if options.scale == nil { + options.scale = result.scale + } + } + + // Validate required fields are now populated + 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") + } + // Validate access token - guard let accessToken = downloadOptions.accessToken else { + guard let accessToken = options.accessToken else { throw ExFigError.accessTokenNotFound } // Create output directory if needed - let outputURL = downloadOptions.outputURL + let outputURL = URL(fileURLWithPath: outputPath, isDirectory: true) try FileManager.default.createDirectory(at: outputURL, withIntermediateDirectories: true) ui.info("Downloading images from Figma...") - ui.debug("File ID: \(downloadOptions.fileId)") - ui.debug("Frame: \(downloadOptions.frameName)") + ui.debug("File ID: \(fileId)") + ui.debug("Frame: \(frameName)") ui.debug("Output: \(outputURL.path)") - ui.debug("Format: \(downloadOptions.format.rawValue)") - if !downloadOptions.isVectorFormat { - ui.debug("Scale: \(downloadOptions.effectiveScale)x") + ui.debug("Format: \(options.format.rawValue)") + if !options.isVectorFormat { + ui.debug("Scale: \(options.effectiveScale)x") } // Create Figma client with fault tolerance - let baseClient = FigmaClient(accessToken: accessToken, timeout: TimeInterval(downloadOptions.timeout)) + let baseClient = FigmaClient(accessToken: accessToken, timeout: TimeInterval(options.timeout)) let rateLimiter = faultToleranceOptions.createRateLimiter() let maxRetries = faultToleranceOptions.maxRetries let client = faultToleranceOptions.createRateLimitedClient( @@ -94,16 +138,28 @@ extension ExFigCommand { logger: ExFigCommand.logger ) + // Snapshot options for use in @Sendable closures + let resolvedOptions = options + // Load images from Figma let imagePacks = try await ui.withSpinnerProgress("Fetching images from Figma...") { onProgress in - try await loadImages(using: loader, onBatchProgress: onProgress) + try await loadImages( + using: loader, + fileId: fileId, + frameName: frameName, + pageName: resolvedOptions.pageName, + format: resolvedOptions.format, + effectiveScale: resolvedOptions.effectiveScale, + filter: resolvedOptions.filter, + onBatchProgress: onProgress + ) } guard !imagePacks.isEmpty else { ui.warning(.noAssetsFound( assetType: "images", - frameName: downloadOptions.frameName, - pageName: downloadOptions.pageName + frameName: frameName, + pageName: resolvedOptions.pageName )) return } @@ -113,32 +169,32 @@ extension ExFigCommand { // Process names using extracted processor let processedPacks = DownloadImageProcessor.processNames( imagePacks, - validateRegexp: downloadOptions.nameValidateRegexp, - replaceRegexp: downloadOptions.nameReplaceRegexp, - nameStyle: downloadOptions.nameStyle + validateRegexp: resolvedOptions.nameValidateRegexp, + replaceRegexp: resolvedOptions.nameReplaceRegexp, + nameStyle: resolvedOptions.nameStyle ) // Handle dark mode if suffix is specified let (lightPacks, darkPacks) = DownloadImageProcessor.splitByDarkMode( processedPacks, - darkSuffix: downloadOptions.darkModeSuffix + darkSuffix: resolvedOptions.darkModeSuffix ) // Create file contents for download var allFiles = DownloadImageProcessor.createFileContents( from: lightPacks, outputURL: outputURL, - format: downloadOptions.format, + format: resolvedOptions.format, dark: false, - darkModeSuffix: downloadOptions.darkModeSuffix + darkModeSuffix: resolvedOptions.darkModeSuffix ) if let darkPacks { allFiles += DownloadImageProcessor.createFileContents( from: darkPacks, outputURL: outputURL, - format: downloadOptions.format, + format: resolvedOptions.format, dark: true, - darkModeSuffix: downloadOptions.darkModeSuffix + darkModeSuffix: resolvedOptions.darkModeSuffix ) } let filesToDownload = allFiles @@ -146,15 +202,18 @@ extension ExFigCommand { // Download files with progress ui.info("Downloading \(filesToDownload.count) files...") let fileDownloader = faultToleranceOptions.createFileDownloader() - let downloadedFiles = try await ui.withProgress("Downloading", total: filesToDownload.count) { progress in + let downloadedFiles = try await ui.withProgress( + "Downloading", + total: filesToDownload.count + ) { progress in try await fileDownloader.fetch(files: filesToDownload) { current, _ in progress.update(current: current) } } // Convert to WebP if needed - let finalFiles: [FileContents] = if downloadOptions.format == .webp { - try await convertToWebP(downloadedFiles, ui: ui) + let finalFiles: [FileContents] = if resolvedOptions.format == .webp { + try await convertToWebP(downloadedFiles, options: resolvedOptions, ui: ui) } else { downloadedFiles } @@ -167,39 +226,56 @@ extension ExFigCommand { ui.success("Downloaded \(finalFiles.count) images to \(outputURL.path)") } + // swiftlint:enable function_body_length cyclomatic_complexity + // MARK: - Private Methods + // swiftlint:disable function_parameter_count + private func loadImages( using loader: DownloadImageLoader, + fileId: String, + frameName: String, + pageName: String?, + format: ImageFormat, + effectiveScale: Double, + filter: String?, onBatchProgress: @escaping BatchProgressCallback = { _, _ in } ) async throws -> [ImagePack] { - if downloadOptions.isVectorFormat { - let params: FormatParams = downloadOptions.format == .svg ? SVGParams() : PDFParams() + let isVector = format == .svg || format == .pdf + if isVector { + let params: FormatParams = format == .svg ? SVGParams() : PDFParams() return try await loader.loadVectorImages( - fileId: downloadOptions.fileId, - frameName: downloadOptions.frameName, - pageName: downloadOptions.pageName, + fileId: fileId, + frameName: frameName, + pageName: pageName, params: params, - filter: downloadOptions.filter, + filter: filter, onBatchProgress: onBatchProgress ) } else { return try await loader.loadRasterImages( - fileId: downloadOptions.fileId, - frameName: downloadOptions.frameName, - pageName: downloadOptions.pageName, - scale: downloadOptions.effectiveScale, - format: downloadOptions.format.rawValue, - filter: downloadOptions.filter, + fileId: fileId, + frameName: frameName, + pageName: pageName, + scale: effectiveScale, + format: format.rawValue, + filter: filter, onBatchProgress: onBatchProgress ) } } - private func convertToWebP(_ files: [FileContents], ui: TerminalUI) async throws -> [FileContents] { - let encoding: WebpConverter.Encoding = switch downloadOptions.webpEncoding { + // swiftlint:enable function_parameter_count + + private func convertToWebP( + _ files: [FileContents], + options: DownloadOptions, + ui: TerminalUI + ) async throws -> [FileContents] { + let encoding: WebpConverter.Encoding = switch options.webpEncoding { case .lossy: - .lossy(quality: downloadOptions.webpQuality) + .lossy(quality: options.webpQuality) case .lossless: .lossless } diff --git a/Sources/ExFigCLI/Subcommands/FetchWizard.swift b/Sources/ExFigCLI/Subcommands/FetchWizard.swift new file mode 100644 index 00000000..89500a9c --- /dev/null +++ b/Sources/ExFigCLI/Subcommands/FetchWizard.swift @@ -0,0 +1,199 @@ +import ExFigCore +import Noora + +// MARK: - Wizard Display Types + +/// Platform choice for the wizard (display-friendly wrapper for Noora prompts). +enum WizardPlatform: String, CaseIterable, CustomStringConvertible, Equatable { + case ios = "iOS" + case android = "Android" + case flutter = "Flutter" + case web = "Web" + + var description: String { + rawValue + } +} + +/// Asset type choice for the wizard. +enum WizardAssetType: String, CaseIterable, CustomStringConvertible, Equatable { + case icons = "Icons" + case illustrations = "Illustrations / Images" + + var description: String { + rawValue + } + + /// Default Figma frame name for this asset type. + var defaultFrameName: String { + switch self { + case .icons: "Icons" + case .illustrations: "Illustrations" + } + } + + /// Default output directory for this asset type. + var defaultOutputPath: String { + switch self { + case .icons: "./icons" + case .illustrations: "./images" + } + } +} + +// MARK: - Platform Defaults + +/// Smart defaults per platform and asset type. +struct PlatformDefaults { + let format: ImageFormat + let scale: Double? + let nameStyle: NameStyle + + static func forPlatform(_ platform: WizardPlatform, assetType: WizardAssetType) -> PlatformDefaults { + switch (platform, assetType) { + case (.ios, .icons): + PlatformDefaults(format: .svg, scale: nil, nameStyle: .camelCase) + case (.ios, .illustrations): + PlatformDefaults(format: .png, scale: 3.0, nameStyle: .camelCase) + case (.android, .icons): + PlatformDefaults(format: .svg, scale: nil, nameStyle: .snakeCase) + case (.android, .illustrations): + PlatformDefaults(format: .webp, scale: 4.0, nameStyle: .snakeCase) + case (.flutter, .icons): + PlatformDefaults(format: .svg, scale: nil, nameStyle: .snakeCase) + case (.flutter, .illustrations): + PlatformDefaults(format: .png, scale: 3.0, nameStyle: .snakeCase) + case (.web, .icons): + PlatformDefaults(format: .svg, scale: nil, nameStyle: .kebabCase) + case (.web, .illustrations): + PlatformDefaults(format: .svg, scale: nil, nameStyle: .kebabCase) + } + } +} + +// MARK: - Wizard Result + +/// Result of the interactive wizard flow. +struct FetchWizardResult { + let fileId: String + let frameName: String + let pageName: String? + let outputPath: String + let format: ImageFormat + let scale: Double? + let nameStyle: NameStyle? + let filter: String? +} + +// MARK: - Wizard Flow + +/// Interactive wizard for `exfig fetch` when required options are missing. +enum FetchWizard { + /// Sorted format options with recommended format first. + static func sortedFormats(recommended: ImageFormat) -> [ImageFormat] { + var formats = ImageFormat.allCases + formats.removeAll { $0 == recommended } + formats.insert(recommended, at: 0) + return formats + } + + /// Run the interactive wizard and return populated options. + static func run() -> FetchWizardResult { + // 1–3: Core choices (file, asset type, platform) + let fileId = NooraUI.textPrompt( + title: "Figma Export Wizard", + prompt: "Figma file ID (from URL: figma.com/file//...):", + description: "You can find it in the Figma file URL", + validationRules: [NonEmptyValidationRule(error: "File ID cannot be empty.")] + ) + + let assetType: WizardAssetType = NooraUI.singleChoicePrompt( + question: "What are you exporting?", + options: WizardAssetType.allCases, + description: "Icons are typically vector, illustrations can be raster or vector" + ) + + let platform: WizardPlatform = NooraUI.singleChoicePrompt( + question: "Target platform:", + options: WizardPlatform.allCases, + description: "Affects default format, scale, and naming style" + ) + + 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) + } + + // swiftlint:disable function_parameter_count + private static func promptDetails( + assetType: WizardAssetType, + platform: WizardPlatform, + defaults: PlatformDefaults, + fileId: String + ) -> FetchWizardResult { + // swiftlint:enable function_parameter_count + + let pageName = promptOptionalText( + question: "Filter by Figma page name?", + description: "Useful when multiple pages have frames with the same name", + inputPrompt: "Page name:" + ) + + let defaultFrame = assetType.defaultFrameName + let frameInput = NooraUI.textPrompt( + prompt: "Figma frame name (default: \(defaultFrame)):", + description: "Name of the frame containing your assets. Press Enter for default." + ) + let frameName = frameInput.isEmpty ? defaultFrame : frameInput + + let sortedFormats = sortedFormats(recommended: defaults.format) + let format: ImageFormat = NooraUI.singleChoicePrompt( + question: "Output format:", + options: sortedFormats, + description: "\(defaults.format.description) is recommended for \(platform) \(assetType)" + ) + + let defaultOutput = assetType.defaultOutputPath + let outputInput = NooraUI.textPrompt( + prompt: "Output directory (default: \(defaultOutput)):", + description: "Where to save exported assets. Press Enter for default." + ) + let outputPath = outputInput.isEmpty ? defaultOutput : outputInput + + let filter = promptOptionalText( + question: "Add a name filter?", + description: "Filter assets by glob pattern (e.g., 'icon/*' or 'logo, banner')", + inputPrompt: "Filter pattern:" + ) + + return FetchWizardResult( + fileId: fileId, + frameName: frameName, + pageName: pageName, + outputPath: outputPath, + format: format, + scale: defaults.scale, + nameStyle: defaults.nameStyle, + filter: filter + ) + } + + /// Ask a yes/no question; if yes, prompt for text. Returns nil if user says no. + private static func promptOptionalText( + question: TerminalText, + description: TerminalText, + inputPrompt: TerminalText + ) -> String? { + guard NooraUI.yesOrNoPrompt( + question: question, + defaultAnswer: false, + description: description + ) else { return nil } + + return NooraUI.textPrompt( + prompt: inputPrompt, + validationRules: [NonEmptyValidationRule(error: "Value cannot be empty.")] + ) + } +} diff --git a/Sources/ExFigCLI/TerminalUI/NooraUI.swift b/Sources/ExFigCLI/TerminalUI/NooraUI.swift index 35defb53..f0622e7b 100644 --- a/Sources/ExFigCLI/TerminalUI/NooraUI.swift +++ b/Sources/ExFigCLI/TerminalUI/NooraUI.swift @@ -135,6 +135,80 @@ enum NooraUI { return "\u{001B}[4m\(format(.primary(text)))\u{001B}[24m" } + // MARK: - Interactive Prompts + + /// Prompt the user for text input with optional validation. + /// - Parameters: + /// - title: Optional title above the prompt + /// - prompt: The question text + /// - description: Optional description for context + /// - collapseOnAnswer: Whether to collapse after input (default: true) + /// - validationRules: Validation rules applied to input + /// - Returns: The user's text input + static func textPrompt( + title: TerminalText? = nil, + prompt: TerminalText, + description: TerminalText? = nil, + collapseOnAnswer: Bool = true, + validationRules: [any ValidatableRule] = [] + ) -> String { + shared.textPrompt( + title: title, + prompt: prompt, + description: description, + collapseOnAnswer: collapseOnAnswer, + validationRules: validationRules + ) + } + + /// Prompt the user to select a single option from a list. + /// - Parameters: + /// - title: Optional title above the prompt + /// - question: The question text + /// - options: Array of options to choose from + /// - description: Optional description for context + /// - collapseOnSelection: Whether to collapse after selection (default: true) + /// - Returns: The selected option + static func singleChoicePrompt( + title: TerminalText? = nil, + question: TerminalText, + options: [T], + description: TerminalText? = nil, + collapseOnSelection: Bool = true + ) -> T { + shared.singleChoicePrompt( + title: title, + question: question, + options: options, + description: description, + collapseOnSelection: collapseOnSelection + ) + } + + /// Prompt the user with a yes/no question. + /// - Parameters: + /// - title: Optional title above the prompt + /// - question: The question text + /// - defaultAnswer: Default answer (default: true) + /// - description: Optional description for context + /// - collapseOnSelection: Whether to collapse after selection (default: true) + /// - Returns: true for yes, false for no + static func yesOrNoPrompt( + title: TerminalText? = nil, + question: TerminalText, + defaultAnswer: Bool = true, + description: TerminalText? = nil, + collapseOnSelection: Bool = true + ) -> Bool { + shared.yesOrNoChoicePrompt( + title: title, + question: question, + defaultAnswer: defaultAnswer, + description: description, + collapseOnSelection: collapseOnSelection + ) + } + // MARK: - Progress Components /// Execute an async operation with a Noora progress bar (0-100%). diff --git a/Tests/ExFigTests/Input/DownloadOptionsTests.swift b/Tests/ExFigTests/Input/DownloadOptionsTests.swift index 0402cb80..d0c49843 100644 --- a/Tests/ExFigTests/Input/DownloadOptionsTests.swift +++ b/Tests/ExFigTests/Input/DownloadOptionsTests.swift @@ -28,10 +28,17 @@ final class DownloadOptionsTests: XCTestCase { XCTAssertEqual(options.outputPath, "/tmp/output") } - func testFailsWithoutRequiredOptions() { - XCTAssertThrowsError(try DownloadOptions.parse([])) - XCTAssertThrowsError(try DownloadOptions.parse(["--file-id", "abc"])) - XCTAssertThrowsError(try DownloadOptions.parse(["--file-id", "abc", "--frame", "Icons"])) + func testParsesWithoutRequiredOptions() throws { + // Fields are now optional to support interactive wizard mode + let empty = try DownloadOptions.parse([]) + XCTAssertNil(empty.fileId) + XCTAssertNil(empty.frameName) + XCTAssertNil(empty.outputPath) + + let partial = try DownloadOptions.parse(["--file-id", "abc"]) + XCTAssertEqual(partial.fileId, "abc") + XCTAssertNil(partial.frameName) + XCTAssertNil(partial.outputPath) } // MARK: - Format Options @@ -272,6 +279,6 @@ final class DownloadOptionsTests: XCTestCase { "-f", "abc", "-r", "Frame", "-o", "/tmp/images", ]) - XCTAssertEqual(options.outputURL.path, "/tmp/images") + XCTAssertEqual(options.outputURL?.path, "/tmp/images") } } diff --git a/Tests/ExFigTests/Subcommands/FetchWizardTests.swift b/Tests/ExFigTests/Subcommands/FetchWizardTests.swift new file mode 100644 index 00000000..f74264e6 --- /dev/null +++ b/Tests/ExFigTests/Subcommands/FetchWizardTests.swift @@ -0,0 +1,134 @@ +@testable import ExFigCLI +import ExFigCore +import Testing + +@Suite("FetchWizard") +struct FetchWizardTests { + // MARK: - WizardPlatform + + @Test("WizardPlatform descriptions match raw values") + func platformDescriptions() { + #expect(WizardPlatform.ios.description == "iOS") + #expect(WizardPlatform.android.description == "Android") + #expect(WizardPlatform.flutter.description == "Flutter") + #expect(WizardPlatform.web.description == "Web") + } + + @Test("WizardPlatform has all 4 cases") + func platformCases() { + #expect(WizardPlatform.allCases.count == 4) + } + + // MARK: - WizardAssetType + + @Test("WizardAssetType descriptions") + func assetTypeDescriptions() { + #expect(WizardAssetType.icons.description == "Icons") + #expect(WizardAssetType.illustrations.description == "Illustrations / Images") + } + + @Test("WizardAssetType default frame names") + func assetTypeDefaultFrameNames() { + #expect(WizardAssetType.icons.defaultFrameName == "Icons") + #expect(WizardAssetType.illustrations.defaultFrameName == "Illustrations") + } + + @Test("WizardAssetType default output paths") + func assetTypeDefaultOutputPaths() { + #expect(WizardAssetType.icons.defaultOutputPath == "./icons") + #expect(WizardAssetType.illustrations.defaultOutputPath == "./images") + } + + // MARK: - PlatformDefaults + + @Test("iOS icons defaults: SVG, no scale, camelCase") + func iOSIconsDefaults() { + let defaults = PlatformDefaults.forPlatform(.ios, assetType: .icons) + #expect(defaults.format == .svg) + #expect(defaults.scale == nil) + #expect(defaults.nameStyle == .camelCase) + } + + @Test("iOS illustrations defaults: PNG, 3x scale, camelCase") + func iOSIllustrationsDefaults() { + let defaults = PlatformDefaults.forPlatform(.ios, assetType: .illustrations) + #expect(defaults.format == .png) + #expect(defaults.scale == 3.0) + #expect(defaults.nameStyle == .camelCase) + } + + @Test("Android icons defaults: SVG, no scale, snake_case") + func androidIconsDefaults() { + let defaults = PlatformDefaults.forPlatform(.android, assetType: .icons) + #expect(defaults.format == .svg) + #expect(defaults.scale == nil) + #expect(defaults.nameStyle == .snakeCase) + } + + @Test("Android illustrations defaults: WebP, 4x scale, snake_case") + func androidIllustrationsDefaults() { + let defaults = PlatformDefaults.forPlatform(.android, assetType: .illustrations) + #expect(defaults.format == .webp) + #expect(defaults.scale == 4.0) + #expect(defaults.nameStyle == .snakeCase) + } + + @Test("Flutter icons defaults: SVG, no scale, snake_case") + func flutterIconsDefaults() { + let defaults = PlatformDefaults.forPlatform(.flutter, assetType: .icons) + #expect(defaults.format == .svg) + #expect(defaults.scale == nil) + #expect(defaults.nameStyle == .snakeCase) + } + + @Test("Flutter illustrations defaults: PNG, 3x scale, snake_case") + func flutterIllustrationsDefaults() { + let defaults = PlatformDefaults.forPlatform(.flutter, assetType: .illustrations) + #expect(defaults.format == .png) + #expect(defaults.scale == 3.0) + #expect(defaults.nameStyle == .snakeCase) + } + + @Test("Web icons defaults: SVG, no scale, kebab-case") + func webIconsDefaults() { + let defaults = PlatformDefaults.forPlatform(.web, assetType: .icons) + #expect(defaults.format == .svg) + #expect(defaults.scale == nil) + #expect(defaults.nameStyle == .kebabCase) + } + + @Test("Web illustrations defaults: SVG, no scale, kebab-case") + func webIllustrationsDefaults() { + let defaults = PlatformDefaults.forPlatform(.web, assetType: .illustrations) + #expect(defaults.format == .svg) + #expect(defaults.scale == nil) + #expect(defaults.nameStyle == .kebabCase) + } + + // MARK: - Sorted Formats + + @Test("sortedFormats puts recommended format first") + func sortedFormatsRecommendedFirst() { + let formats = FetchWizard.sortedFormats(recommended: .webp) + #expect(formats.first == .webp) + #expect(formats.count == ImageFormat.allCases.count) + } + + @Test("sortedFormats contains all formats exactly once") + func sortedFormatsComplete() { + let formats = FetchWizard.sortedFormats(recommended: .svg) + #expect(Set(formats) == Set(ImageFormat.allCases)) + #expect(formats.count == ImageFormat.allCases.count) + } + + // MARK: - ImageFormat CustomStringConvertible + + @Test("ImageFormat descriptions are user-friendly") + func imageFormatDescriptions() { + #expect(ImageFormat.png.description == "PNG") + #expect(ImageFormat.svg.description == "SVG") + #expect(ImageFormat.jpg.description == "JPG") + #expect(ImageFormat.pdf.description == "PDF") + #expect(ImageFormat.webp.description == "WebP") + } +} diff --git a/exfig.usage.kdl b/exfig.usage.kdl index 291d4ded..159c2549 100644 --- a/exfig.usage.kdl +++ b/exfig.usage.kdl @@ -130,12 +130,12 @@ cmd "schemas" help="Extracts PKL schemas to local directory" { // ============================================================================= cmd "fetch" help="Downloads images from Figma without config file" { - long_help "Downloads images from a specific Figma frame to a local directory. All parameters are passed via command-line arguments. Requires FIGMA_PERSONAL_TOKEN environment variable." + long_help "Downloads images from a specific Figma frame to a local directory. All parameters are passed via command-line arguments. When required options are omitted in an interactive terminal, a guided wizard helps fill them in. Requires FIGMA_PERSONAL_TOKEN environment variable." - flag "-f --file-id " help="Figma file ID (from the URL: figma.com/file//...)" required=#true - flag "-r --frame " help="Name of the Figma frame containing images" required=#true + flag "-f --file-id " help="Figma file ID (from the URL: figma.com/file//...). Prompted interactively if omitted in TTY." + flag "-r --frame " help="Name of the Figma frame containing images. Prompted interactively if omitted in TTY." flag "-p --page " help="Filter by Figma page name." - flag "-o --output " help="Output directory for downloaded images" required=#true + flag "-o --output " help="Output directory for downloaded images. Prompted interactively if omitted in TTY." flag "--format " help="Image format" default="png" { choices "png" "svg" "jpg" "pdf" "webp" } From 28c2fed426cda0783ead458502cf826d57e69d43 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 20 Mar 2026 15:52:04 +0500 Subject: [PATCH 02/12] docs: document interactive wizard in README and DocC articles Add `exfig fetch` wizard mentions to Quick Start (README), Quick Fetch section (Usage.md), and ExFig.md landing page capabilities. Update CLAUDE.md with documentation structure guidance for future sessions. --- CLAUDE.md | 4 ++++ README.md | 5 ++++- Sources/ExFigCLI/ExFig.docc/ExFig.md | 2 +- Sources/ExFigCLI/ExFig.docc/Usage.md | 11 +++++++++-- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a2417a9c..8610bd28 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -282,6 +282,10 @@ See `ExFigCore/CLAUDE.md` (Modification Checklist) and platform module CLAUDE.md | Terminal output | `TerminalUI` facade | Direct `print()` calls | | 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). + **JSONCodec usage:** ```swift diff --git a/README.md b/README.md index 969a229e..f8910352 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,10 @@ brew install designpipe/tap/exfig # 2. Set Figma token export FIGMA_PERSONAL_TOKEN=your_token_here -# 3. Generate config and export +# 3a. Quick one-off export (interactive wizard) +exfig fetch + +# 3b. Or generate config for full pipeline exfig init -p ios exfig batch exfig.pkl ``` diff --git a/Sources/ExFigCLI/ExFig.docc/ExFig.md b/Sources/ExFigCLI/ExFig.docc/ExFig.md index 0a8c90d7..71d01165 100644 --- a/Sources/ExFigCLI/ExFig.docc/ExFig.md +++ b/Sources/ExFigCLI/ExFig.docc/ExFig.md @@ -35,7 +35,7 @@ and Figma Variables integration. **Export Formats** PNG, SVG, PDF, JPEG, WebP, HEIC output formats with quality control. W3C Design Tokens (DTCG v2025) for token pipelines. -Quick fetch mode for one-off downloads without a config file. +Quick fetch mode with interactive wizard for one-off downloads without a config file. **Performance and Reliability** Parallel downloads and writes, batch processing with shared rate limiting, diff --git a/Sources/ExFigCLI/ExFig.docc/Usage.md b/Sources/ExFigCLI/ExFig.docc/Usage.md index 014f14db..6e175310 100644 --- a/Sources/ExFigCLI/ExFig.docc/Usage.md +++ b/Sources/ExFigCLI/ExFig.docc/Usage.md @@ -141,16 +141,23 @@ exfig icons --resume ## Quick Fetch -Download images without a configuration file: +Download images without a configuration file. Run `exfig fetch` with no arguments for an +interactive wizard that guides you through file ID, platform, format, and output options: ```bash -# Download PNG images at 3x scale +# Interactive wizard — asks platform, format, output step by step +exfig fetch + +# Or pass all options directly exfig fetch --file-id YOUR_FILE_ID --frame "Illustrations" --output ./images # Using short options exfig fetch -f YOUR_FILE_ID -r "Icons" -o ./icons ``` +The wizard provides smart defaults per platform (e.g., SVG + camelCase for iOS icons, +WebP + snake_case for Android illustrations) and only runs in interactive terminals. + ### Format Options ```bash From 715584ee924a374c494807c9de2ce6415150abf3 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 20 Mar 2026 16:10:31 +0500 Subject: [PATCH 03/12] chore: regenerate llms-full.txt with wizard docs --- llms-full.txt | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/llms-full.txt b/llms-full.txt index 89765446..72e4574a 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -43,7 +43,10 @@ brew install designpipe/tap/exfig # 2. Set Figma token export FIGMA_PERSONAL_TOKEN=your_token_here -# 3. Generate config and export +# 3a. Quick one-off export (interactive wizard) +exfig fetch + +# 3b. Or generate config for full pipeline exfig init -p ios exfig batch exfig.pkl ``` @@ -374,16 +377,23 @@ exfig icons --resume ## Quick Fetch -Download images without a configuration file: +Download images without a configuration file. Run `exfig fetch` with no arguments for an +interactive wizard that guides you through file ID, platform, format, and output options: ```bash -# Download PNG images at 3x scale +# Interactive wizard — asks platform, format, output step by step +exfig fetch + +# Or pass all options directly exfig fetch --file-id YOUR_FILE_ID --frame "Illustrations" --output ./images # Using short options exfig fetch -f YOUR_FILE_ID -r "Icons" -o ./icons ``` +The wizard provides smart defaults per platform (e.g., SVG + camelCase for iOS icons, +WebP + snake_case for Android illustrations) and only runs in interactive terminals. + ### Format Options ```bash From 5bf10c89cd2144c2251076f648da44e87718395f Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 20 Mar 2026 17:16:48 +0500 Subject: [PATCH 04/12] feat(cli): add interactive wizard for `exfig init` When --platform is omitted in a TTY, `exfig init` launches a guided wizard that collects platform, asset types, Figma file IDs, and frame names, then generates a ready-to-run config with unselected sections removed and IDs substituted. - Add InitWizard with run() and pure applyResult() for testability - Add multipleChoicePrompt wrapper to NooraUI - Add WizardPlatform.asPlatform mapping - Make --platform optional, gate wizard on TTYDetector.isTTY - Replace readLine() overwrite prompt with NooraUI.yesOrNoPrompt - Context-aware "next steps" output (skip "edit file IDs" after wizard) - 16 tests covering section removal, ID substitution, brace balance - Update exfig.usage.kdl, README, DocC articles, CLAUDE.md --- CLAUDE.md | 69 +-- README.md | 4 +- Sources/ExFigCLI/CLAUDE.md | 10 + Sources/ExFigCLI/ExFig.docc/ExFig.md | 1 + Sources/ExFigCLI/ExFig.docc/Usage.md | 16 + .../ExFigCLI/Subcommands/FetchWizard.swift | 10 + .../Subcommands/GenerateConfigFile.swift | 113 +++-- Sources/ExFigCLI/Subcommands/InitWizard.swift | 396 ++++++++++++++++++ Sources/ExFigCLI/TerminalUI/NooraUI.swift | 27 ++ .../Subcommands/InitWizardTests.swift | 201 +++++++++ exfig.usage.kdl | 4 +- 11 files changed, 786 insertions(+), 65 deletions(-) create mode 100644 Sources/ExFigCLI/Subcommands/InitWizard.swift create mode 100644 Tests/ExFigTests/Subcommands/InitWizardTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index 8610bd28..5c1f2db9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -260,6 +260,20 @@ See `ExFigCLI/CLAUDE.md` (Adding a New Subcommand). **Important:** When adding/changing CLI flags or subcommands, update `exfig.usage.kdl` (Usage spec) to keep shell completions and docs in sync. When bumping the app version in `ExFigCommand.swift`, also update the `version` field in `exfig.usage.kdl`. +### Adding an Interactive Wizard + +Follow `InitWizard.swift` / `FetchWizard.swift` pattern: + +- `enum` with `static func run()` for interactive flow (NooraUI prompts) +- Pure function for testable transformation logic (e.g., `applyResult(_:to:)`) +- Reuse `WizardPlatform` from `FetchWizard.swift` (has `asPlatform` property) +- Gate on `TTYDetector.isTTY`; throw `ValidationError` for non-TTY without required flags + +### Adding a NooraUI Prompt Wrapper + +Follow the existing pattern in `NooraUI.swift`: static method delegating to `shared` instance with matching parameter names. +Noora's `multipleChoicePrompt` uses `MultipleChoiceLimit` — `.unlimited` or `.limited(count:errorMessage:)`. + ### Adding a Figma API Endpoint FigmaAPI is now an external package (`swift-figma-api`). See its repository for endpoint patterns. @@ -343,33 +357,34 @@ 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 | -| 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` | +| 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 | +| 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` | +| 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 | ## Additional Rules diff --git a/README.md b/README.md index f8910352..1f88bdf2 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,8 @@ export FIGMA_PERSONAL_TOKEN=your_token_here # 3a. Quick one-off export (interactive wizard) exfig fetch -# 3b. Or generate config for full pipeline -exfig init -p ios +# 3b. Or generate config for full pipeline (interactive wizard) +exfig init exfig batch exfig.pkl ``` diff --git a/Sources/ExFigCLI/CLAUDE.md b/Sources/ExFigCLI/CLAUDE.md index ca05db30..c8417a7e 100644 --- a/Sources/ExFigCLI/CLAUDE.md +++ b/Sources/ExFigCLI/CLAUDE.md @@ -197,6 +197,16 @@ reserved for MCP JSON-RPC protocol. 3. Use `@OptionGroup` for shared options 4. Call `initializeTerminalUI()` + `checkSchemaVersionIfNeeded()` in `run()` +### Adding an Interactive Wizard + +Follow `InitWizard.swift` / `FetchWizard.swift` pattern: + +1. Create `Subcommands/NewWizard.swift` as `enum` with `static func run() -> Result` +2. Use NooraUI prompts (`textPrompt`, `singleChoicePrompt`, `multipleChoicePrompt`, `yesOrNoPrompt`) +3. Extract testable pure logic into a separate static method (e.g., `applyResult(_:to:)`) +4. Reuse `WizardPlatform` from `FetchWizard.swift` (has `asPlatform` → `Platform` mapping) +5. Gate on `TTYDetector.isTTY` in the calling command; throw `ValidationError` for non-TTY + ### Adding a New Platform Export 1. Create platform export orchestrator in `Subcommands/Export/` (e.g., `NewPlatformColorsExport.swift`) diff --git a/Sources/ExFigCLI/ExFig.docc/ExFig.md b/Sources/ExFigCLI/ExFig.docc/ExFig.md index 71d01165..88dba8a1 100644 --- a/Sources/ExFigCLI/ExFig.docc/ExFig.md +++ b/Sources/ExFigCLI/ExFig.docc/ExFig.md @@ -36,6 +36,7 @@ and Figma Variables integration. PNG, SVG, PDF, JPEG, WebP, HEIC output formats with quality control. W3C Design Tokens (DTCG v2025) for token pipelines. Quick fetch mode with interactive wizard for one-off downloads without a config file. +Interactive `exfig init` wizard for guided config setup with file IDs and asset selection. **Performance and Reliability** Parallel downloads and writes, batch processing with shared rate limiting, diff --git a/Sources/ExFigCLI/ExFig.docc/Usage.md b/Sources/ExFigCLI/ExFig.docc/Usage.md index 6e175310..5f2bb9e1 100644 --- a/Sources/ExFigCLI/ExFig.docc/Usage.md +++ b/Sources/ExFigCLI/ExFig.docc/Usage.md @@ -22,6 +22,22 @@ exfig images exfig typography ``` +## Getting Started + +Generate a config file with the interactive wizard: + +```bash +exfig init +``` + +The wizard guides you through platform selection, asset types, and Figma file IDs. +For non-interactive use, specify the platform directly: + +```bash +exfig init -p ios # Full iOS template +exfig init -p android # Full Android template +``` + ## Configuration File By default, ExFig looks for `exfig.pkl` in the current directory. Specify a different location: diff --git a/Sources/ExFigCLI/Subcommands/FetchWizard.swift b/Sources/ExFigCLI/Subcommands/FetchWizard.swift index 89500a9c..9f9609b7 100644 --- a/Sources/ExFigCLI/Subcommands/FetchWizard.swift +++ b/Sources/ExFigCLI/Subcommands/FetchWizard.swift @@ -13,6 +13,16 @@ enum WizardPlatform: String, CaseIterable, CustomStringConvertible, Equatable { var description: String { rawValue } + + /// Convert to ExFigCore `Platform`. + var asPlatform: Platform { + switch self { + case .ios: .ios + case .android: .android + case .flutter: .flutter + case .web: .web + } + } } /// Asset type choice for the wizard. diff --git a/Sources/ExFigCLI/Subcommands/GenerateConfigFile.swift b/Sources/ExFigCLI/Subcommands/GenerateConfigFile.swift index ac4f655a..d08fb474 100644 --- a/Sources/ExFigCLI/Subcommands/GenerateConfigFile.swift +++ b/Sources/ExFigCLI/Subcommands/GenerateConfigFile.swift @@ -12,33 +12,29 @@ extension ExFigCommand { discussion: """ Generates exfig.pkl config file in the current directory. + When --platform is omitted in an interactive terminal, a guided wizard + configures file IDs and asset types interactively. + Examples: - exfig init -p ios Generate iOS config - exfig init -p android Generate Android config + exfig init Interactive wizard (TTY only) + exfig init -p ios Generate iOS config template + exfig init -p android Generate Android config template """ ) @OptionGroup var globalOptions: GlobalOptions - @Option(name: .shortAndLong, help: "Platform: ios or android.") - var platform: Platform + @Option( + name: .shortAndLong, + help: "Platform: ios, android, flutter, or web. Prompted interactively if omitted in TTY." + ) + var platform: Platform? func run() async throws { ExFigCommand.initializeTerminalUI(verbose: globalOptions.verbose, quiet: globalOptions.quiet) let ui = ExFigCommand.terminalUI! - let fileContents: String = switch platform { - case .android: - androidConfigFileContents - case .ios: - iosConfigFileContents - case .flutter: - flutterConfigFileContents - case .web: - webConfigFileContents - } - let destination = FileManager.default.currentDirectoryPath + "/" + ExFigOptions.defaultConfigFilename // Check if file exists and ask for confirmation @@ -47,11 +43,46 @@ extension ExFigCommand { if !result { return } } + // Determine file contents: wizard or direct template + let fileContents: String + let wizardResult: InitWizardResult? + + if let platform { + // Direct flag — use full template + fileContents = templateForPlatform(platform) + wizardResult = nil + } else if TTYDetector.isTTY { + // Interactive wizard + let result = InitWizard.run() + let template = templateForPlatform(result.platform) + fileContents = InitWizard.applyResult(result, to: template) + wizardResult = result + } else { + // Non-TTY without --platform + throw ValidationError("Missing required option: --platform. Use -p ios|android|flutter|web.") + } + // Extract PKL schemas for local validation let extractedSchemas = try SchemaExtractor.extract() // Write new config file - try writeConfigFile(contents: fileContents, to: destination, ui: ui, extractedSchemas: extractedSchemas) + try writeConfigFile( + contents: fileContents, + to: destination, + ui: ui, + extractedSchemas: extractedSchemas, + wizardResult: wizardResult + ) + } + + /// Return the full PKL template for a given platform. + private func templateForPlatform(_ platform: Platform) -> String { + switch platform { + case .android: androidConfigFileContents + case .ios: iosConfigFileContents + case .flutter: flutterConfigFileContents + case .web: webConfigFileContents + } } /// Handles existing file: prompts for confirmation and removes if approved. @@ -66,17 +97,14 @@ extension ExFigCommand { } ui.warning("Config file already exists at: \(destination)") - TerminalOutputManager.shared.writeDirect("Overwrite? [y/N] ") - ANSICodes.flushStdout() - guard let input = readLine() else { - TerminalOutputManager.shared.writeDirect("\n") - ui.error("Operation cancelled.") - return false - } + let overwrite = NooraUI.yesOrNoPrompt( + question: "Overwrite existing config file?", + defaultAnswer: false, + description: "The current exfig.pkl will be replaced" + ) - let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if trimmed != "y", trimmed != "yes" { + if !overwrite { ui.info("Operation cancelled.") return false } @@ -93,12 +121,15 @@ extension ExFigCommand { return true } + // swiftlint:disable function_parameter_count private func writeConfigFile( contents: String, to destination: String, ui: TerminalUI, - extractedSchemas: [String] = [] + extractedSchemas: [String] = [], + wizardResult: InitWizardResult? = nil ) throws { + // swiftlint:enable function_parameter_count guard let fileData = contents.data(using: .utf8) else { throw ExFigError.custom(errorString: "Failed to encode config file contents") } @@ -116,20 +147,34 @@ extension ExFigCommand { ui.info("") ui.info("Next steps:") - ui.info("1. Edit \(ExFigOptions.defaultConfigFilename) with your Figma file IDs") + + // When wizard provided file IDs, skip "edit file IDs" step + let stepOffset: Int + if wizardResult != nil { + stepOffset = 1 + } else { + ui.info("1. Edit \(ExFigOptions.defaultConfigFilename) with your Figma file IDs") + stepOffset = 2 + } if ProcessInfo.processInfo.environment["FIGMA_PERSONAL_TOKEN"] == nil { - ui.info("2. Set your Figma token (missing):") + ui.info("\(stepOffset). Set your Figma token (missing):") ui.info(" export FIGMA_PERSONAL_TOKEN=your_token_here") } else { - ui.info("2. Figma token detected in environment ✅") + ui.info("\(stepOffset). Figma token detected in environment ✅") } - ui.info("3. Run export commands:") - ui.info(" exfig colors") - ui.info(" exfig icons") - ui.info(" exfig images") - ui.info(" exfig typography") + ui.info("\(stepOffset + 1). Run export commands:") + if let result = wizardResult { + for assetType in result.selectedAssetTypes { + ui.info(" exfig \(assetType.commandName)") + } + } else { + ui.info(" exfig colors") + ui.info(" exfig icons") + ui.info(" exfig images") + ui.info(" exfig typography") + } } else { throw ExFigError.custom(errorString: "Unable to create config file at: \(destination)") } diff --git a/Sources/ExFigCLI/Subcommands/InitWizard.swift b/Sources/ExFigCLI/Subcommands/InitWizard.swift new file mode 100644 index 00000000..dde35a02 --- /dev/null +++ b/Sources/ExFigCLI/Subcommands/InitWizard.swift @@ -0,0 +1,396 @@ +import ExFigCore +import Noora + +// MARK: - Init Asset Type + +/// Asset type choice for the init wizard multi-select. +enum InitAssetType: String, CaseIterable, CustomStringConvertible, Equatable { + case colors = "Colors" + case icons = "Icons" + case images = "Images" + case typography = "Typography" + + var description: String { + rawValue + } + + /// Asset types available for the given platform. + /// Typography is only available for iOS and Android. + static func availableTypes(for platform: WizardPlatform) -> [InitAssetType] { + switch platform { + case .ios, .android: + allCases + case .flutter, .web: + allCases.filter { $0 != .typography } + } + } + + /// CLI command name for this asset type. + var commandName: String { + switch self { + case .colors: "colors" + case .icons: "icons" + case .images: "images" + case .typography: "typography" + } + } +} + +// MARK: - Init Wizard Result + +/// Result of the interactive init wizard flow. +struct InitWizardResult { + let platform: Platform + let selectedAssetTypes: [InitAssetType] + let lightFileId: String + let darkFileId: String? + let iconsFrameName: String? + let imagesFrameName: String? +} + +// MARK: - Init Wizard Flow + +/// Interactive wizard for `exfig init` when `--platform` is not provided. +enum InitWizard { + /// Run the interactive wizard and return collected answers. + static func run() -> InitWizardResult { + // 1. Platform selection + let wizardPlatform: WizardPlatform = NooraUI.singleChoicePrompt( + title: "ExFig Config Wizard", + 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 + let availableTypes = InitAssetType.availableTypes(for: wizardPlatform) + let selectedTypes: [InitAssetType] = NooraUI.multipleChoicePrompt( + question: "What do you want to export?", + options: availableTypes, + description: "Use space to toggle, enter to confirm. At least one required.", + minLimit: .limited(count: 1, errorMessage: "Select at least one asset type.") + ) + + // 3. Figma file ID (light) + let lightFileId = NooraUI.textPrompt( + prompt: "Figma file ID (from URL: figma.com/design//...):", + description: "The file containing your design system assets", + validationRules: [NonEmptyValidationRule(error: "File ID cannot be empty.")] + ) + + // 4. Dark mode file ID (optional) + let darkFileId = promptOptionalText( + question: "Do you have a separate dark mode file?", + description: "If your dark colors/images are in a different Figma file", + inputPrompt: "Dark mode file ID:" + ) + + // 5. Icons frame name (if icons selected) + let iconsFrameName: String? = if selectedTypes.contains(.icons) { + promptFrameName(assetType: "icons", defaultName: "Icons") + } else { + nil + } + + // 6. Images frame name (if images selected) + let imagesFrameName: String? = if selectedTypes.contains(.images) { + promptFrameName(assetType: "images", defaultName: "Illustrations") + } else { + nil + } + + return InitWizardResult( + platform: platform, + selectedAssetTypes: selectedTypes, + lightFileId: lightFileId, + darkFileId: darkFileId, + iconsFrameName: iconsFrameName, + imagesFrameName: imagesFrameName + ) + } + + // MARK: - Template Transformation (Pure, Testable) + + /// Apply wizard result to a platform template, removing unselected sections and substituting values. + static func applyResult(_ result: InitWizardResult, to template: String) -> String { + var output = template + + // Substitute file IDs + output = output.replacingOccurrences(of: "shPilWnVdJfo10YF12345", with: result.lightFileId) + + if let darkId = result.darkFileId { + output = output.replacingOccurrences(of: "KfF6DnJTWHGZzC912345", with: darkId) + } else { + output = removeDarkFileIdLine(from: output) + } + + // Substitute frame names + if let iconsFrame = result.iconsFrameName { + output = substituteFrameName(in: output, section: "icons", name: iconsFrame) + } + if let imagesFrame = result.imagesFrameName { + output = substituteFrameName(in: output, section: "images", name: 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) + } + + // When colors removed, also remove commented variablesColors block + if !result.selectedAssetTypes.contains(.colors) { + output = removeCommentedVariablesColors(from: output) + } + + // Collapse 3+ consecutive blank lines to 2 + output = collapseBlankLines(output) + + return output + } + + // MARK: - Private Helpers + + private static func promptFrameName(assetType: String, defaultName: String) -> String { + let input = NooraUI.textPrompt( + prompt: "Figma frame name for \(assetType) (default: \(defaultName)):", + description: "Name of the frame containing your \(assetType). Press Enter for default." + ) + return input.isEmpty ? defaultName : input + } + + private static func promptOptionalText( + question: TerminalText, + description: TerminalText, + inputPrompt: TerminalText + ) -> String? { + guard NooraUI.yesOrNoPrompt( + question: question, + defaultAnswer: false, + description: description + ) else { return nil } + + return NooraUI.textPrompt( + prompt: inputPrompt, + validationRules: [NonEmptyValidationRule(error: "Value cannot be empty.")] + ) + } + + /// Remove the `darkFileId = "..."` line (and its comment) from the template. + private static func removeDarkFileIdLine(from template: String) -> String { + var lines = template.components(separatedBy: "\n") + lines.removeAll { line in + let trimmed = line.trimmingCharacters(in: .whitespaces) + return trimmed.hasPrefix("darkFileId = ") + || trimmed == "// [optional] Identifier of the file containing dark color palette and dark images." + || trimmed == "// [optional] Identifier of the file containing dark color palette." + } + return lines.joined(separator: "\n") + } + + /// Substitute the default frame name in the `figmaFrameName = "..."` line within a section. + private static func substituteFrameName(in template: String, section: String, name: String) -> String { + let defaultName = section == "icons" ? "Icons" : "Illustrations" + let lines = template.components(separatedBy: "\n") + var result: [String] = [] + var inSection = false + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed.contains("\(section) = new Common.") { + inSection = true + } + + if inSection, trimmed.hasPrefix("figmaFrameName = \"\(defaultName)\"") { + result.append(line.replacingOccurrences(of: "\"\(defaultName)\"", with: "\"\(name)\"")) + inSection = false + } else { + result.append(line) + } + + if inSection, trimmed == "}" { + inSection = false + } + } + + return result.joined(separator: "\n") + } + + /// Remove all sections (common + platform) for the given asset type. + static func removeAssetSections(from template: String, assetType: InitAssetType) -> String { + var output = template + + // Remove common section + let commonMarkers = commonSectionMarkers(for: assetType) + for marker in commonMarkers { + output = removeSection(from: output, matching: marker) + } + + // Remove platform-specific section + let platformMarkers = platformSectionMarkers(for: assetType) + for marker in platformMarkers { + output = removeSection(from: output, matching: marker) + } + + return output + } + + /// Remove a PKL section starting with a line matching the marker, counting braces to find the end. + static func removeSection(from template: String, matching marker: String) -> String { + let lines = template.components(separatedBy: "\n") + var result: [String] = [] + var braceDepth = 0 + var removing = false + + for line in lines { + if removing { + braceDepth += braceBalance(in: line) + if braceDepth <= 0 { removing = false } + continue + } + + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.contains(marker) else { + result.append(line) + continue + } + + // Start removing: strip preceding comments/blanks + removing = true + braceDepth = braceBalance(in: line) + stripTrailingCommentsAndBlanks(&result) + if braceDepth <= 0 { removing = false } + } + + return result.joined(separator: "\n") + } + + /// Count net brace balance ({ = +1, } = -1) in a line. + private static func braceBalance(in line: String) -> Int { + var balance = 0 + for char in line { + if char == "{" { balance += 1 } + if char == "}" { balance -= 1 } + } + return balance + } + + /// Remove trailing comment lines and blank lines from the result array. + private static func stripTrailingCommentsAndBlanks(_ lines: inout [String]) { + while let last = lines.last { + let trimmed = last.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("//") || trimmed.isEmpty { + lines.removeLast() + } else { + break + } + } + } + + /// Markers for common section blocks (in `common = new Common.CommonConfig`). + private static func commonSectionMarkers(for assetType: InitAssetType) -> [String] { + switch assetType { + case .colors: + ["colors = new Common.Colors {"] + case .icons: + ["icons = new Common.Icons {"] + case .images: + ["images = new Common.Images {"] + case .typography: + ["typography = new Common.Typography {"] + } + } + + /// Markers for platform-specific section blocks. + private static func platformSectionMarkers(for assetType: InitAssetType) -> [String] { + switch assetType { + case .colors: + [ + "colors = new iOS.ColorsEntry {", + "colors = new Android.ColorsEntry {", + "colors = new Flutter.ColorsEntry {", + "colors = new Web.ColorsEntry {", + ] + case .icons: + [ + "icons = new iOS.IconsEntry {", + "icons = new Android.IconsEntry {", + "icons = new Flutter.IconsEntry {", + "icons = new Web.IconsEntry {", + ] + case .images: + [ + "images = new iOS.ImagesEntry {", + "images = new Android.ImagesEntry {", + "images = new Flutter.ImagesEntry {", + "images = new Web.ImagesEntry {", + ] + case .typography: + [ + "typography = new iOS.Typography {", + "typography = new Android.Typography {", + ] + } + } + + /// Remove commented-out `variablesColors` block when colors are not selected. + private static func removeCommentedVariablesColors(from template: String) -> String { + let lines = template.components(separatedBy: "\n") + var result: [String] = [] + var removing = false + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if !removing { + if trimmed.hasPrefix("// variablesColors = new Common.VariablesColors {") + || trimmed + .hasPrefix( + "// [optional] Use variablesColors instead of colors to export colors from Figma Variables." + ) + || trimmed + .hasPrefix( + "// [optional] Use variablesColors to export colors from Figma Variables." + ) + { + removing = true + continue + } + result.append(line) + } else { + // Keep removing commented lines until we find a non-comment line + if trimmed.hasPrefix("//") { + continue + } else { + removing = false + result.append(line) + } + } + } + + return result.joined(separator: "\n") + } + + /// Collapse 3+ consecutive blank lines into 2. + private static func collapseBlankLines(_ text: String) -> String { + let lines = text.components(separatedBy: "\n") + var result: [String] = [] + var consecutiveBlanks = 0 + + for line in lines { + if line.trimmingCharacters(in: .whitespaces).isEmpty { + consecutiveBlanks += 1 + if consecutiveBlanks <= 2 { + result.append(line) + } + } else { + consecutiveBlanks = 0 + result.append(line) + } + } + + return result.joined(separator: "\n") + } +} diff --git a/Sources/ExFigCLI/TerminalUI/NooraUI.swift b/Sources/ExFigCLI/TerminalUI/NooraUI.swift index f0622e7b..42902f70 100644 --- a/Sources/ExFigCLI/TerminalUI/NooraUI.swift +++ b/Sources/ExFigCLI/TerminalUI/NooraUI.swift @@ -209,6 +209,33 @@ enum NooraUI { ) } + /// Prompt the user to select multiple options from a list. + /// - Parameters: + /// - title: Optional title above the prompt + /// - question: The question text + /// - options: Array of options to choose from + /// - description: Optional description for context + /// - collapseOnSelection: Whether to collapse after selection (default: true) + /// - minLimit: Minimum number of selections required (default: unlimited) + /// - Returns: Array of selected options + static func multipleChoicePrompt( + title: TerminalText? = nil, + question: TerminalText, + options: [T], + description: TerminalText? = nil, + collapseOnSelection: Bool = true, + minLimit: MultipleChoiceLimit = .unlimited + ) -> [T] { + shared.multipleChoicePrompt( + title: title, + question: question, + options: options, + description: description, + collapseOnSelection: collapseOnSelection, + minLimit: minLimit + ) + } + // MARK: - Progress Components /// Execute an async operation with a Noora progress bar (0-100%). diff --git a/Tests/ExFigTests/Subcommands/InitWizardTests.swift b/Tests/ExFigTests/Subcommands/InitWizardTests.swift new file mode 100644 index 00000000..ee5ae7d3 --- /dev/null +++ b/Tests/ExFigTests/Subcommands/InitWizardTests.swift @@ -0,0 +1,201 @@ +@testable import ExFigCLI +import ExFigCore +import Testing + +@Suite("InitWizard") +struct InitWizardTests { + // MARK: - WizardPlatform.asPlatform + + @Test("WizardPlatform.asPlatform maps all 4 cases correctly") + func wizardPlatformAsPlatform() { + #expect(WizardPlatform.ios.asPlatform == .ios) + #expect(WizardPlatform.android.asPlatform == .android) + #expect(WizardPlatform.flutter.asPlatform == .flutter) + #expect(WizardPlatform.web.asPlatform == .web) + } + + // MARK: - InitAssetType + + @Test("InitAssetType descriptions match raw values") + func assetTypeDescriptions() { + #expect(InitAssetType.colors.description == "Colors") + #expect(InitAssetType.icons.description == "Icons") + #expect(InitAssetType.images.description == "Images") + #expect(InitAssetType.typography.description == "Typography") + } + + @Test("availableTypes excludes typography for Flutter and Web") + func availableTypesPerPlatform() { + let iosTypes = InitAssetType.availableTypes(for: .ios) + #expect(iosTypes.contains(.typography)) + #expect(iosTypes.count == 4) + + let flutterTypes = InitAssetType.availableTypes(for: .flutter) + #expect(!flutterTypes.contains(.typography)) + #expect(flutterTypes.count == 3) + + let webTypes = InitAssetType.availableTypes(for: .web) + #expect(!webTypes.contains(.typography)) + #expect(webTypes.count == 3) + } + + // MARK: - applyResult: File ID substitution + + @Test("applyResult substitutes light file ID") + func substituteLightFileId() { + let result = makeResult(lightFileId: "ABC123") + let output = InitWizard.applyResult(result, to: iosTemplate) + #expect(output.contains("ABC123")) + #expect(!output.contains("shPilWnVdJfo10YF12345")) + } + + @Test("applyResult substitutes dark file ID when provided") + func substituteDarkFileId() { + let result = makeResult(darkFileId: "DARK456") + let output = InitWizard.applyResult(result, to: iosTemplate) + #expect(output.contains("DARK456")) + #expect(!output.contains("KfF6DnJTWHGZzC912345")) + } + + @Test("applyResult removes darkFileId line when nil") + func removeDarkFileIdWhenNil() { + let result = makeResult(darkFileId: nil) + let output = InitWizard.applyResult(result, to: iosTemplate) + #expect(!output.contains("darkFileId")) + } + + // MARK: - applyResult: Frame name substitution + + @Test("applyResult substitutes custom icons frame name") + func substituteIconsFrameName() { + let result = makeResult(iconsFrameName: "MyIcons") + let output = InitWizard.applyResult(result, to: iosTemplate) + #expect(output.contains("figmaFrameName = \"MyIcons\"")) + } + + @Test("applyResult substitutes custom images frame name") + func substituteImagesFrameName() { + let result = makeResult(imagesFrameName: "MyImages") + let output = InitWizard.applyResult(result, to: iosTemplate) + #expect(output.contains("figmaFrameName = \"MyImages\"")) + } + + // MARK: - applyResult: Section removal + + @Test("applyResult removes colors section when not selected") + func removeColorsSection() { + let result = makeResult(selectedAssetTypes: [.icons, .images, .typography]) + let output = InitWizard.applyResult(result, to: iosTemplate) + #expect(!output.contains("colors = new Common.Colors {")) + #expect(!output.contains("colors = new iOS.ColorsEntry {")) + // variablesColors commented block should also be removed + #expect(!output.contains("variablesColors = new Common.VariablesColors {")) + } + + @Test("applyResult removes icons section when not selected") + func removeIconsSection() { + let result = makeResult(selectedAssetTypes: [.colors, .images, .typography]) + let output = InitWizard.applyResult(result, to: iosTemplate) + #expect(!output.contains("icons = new Common.Icons {")) + #expect(!output.contains("icons = new iOS.IconsEntry {")) + } + + @Test("applyResult removes images section when not selected") + func removeImagesSection() { + let result = makeResult(selectedAssetTypes: [.colors, .icons, .typography]) + let output = InitWizard.applyResult(result, to: iosTemplate) + #expect(!output.contains("images = new Common.Images {")) + #expect(!output.contains("images = new iOS.ImagesEntry {")) + } + + @Test("applyResult removes typography section for iOS when not selected") + func removeTypographySection() { + let result = makeResult(selectedAssetTypes: [.colors, .icons, .images]) + let output = InitWizard.applyResult(result, to: iosTemplate) + #expect(!output.contains("typography = new Common.Typography {")) + #expect(!output.contains("typography = new iOS.Typography {")) + } + + @Test("applyResult removes multiple sections at once") + func removeMultipleSections() { + let result = makeResult(selectedAssetTypes: [.colors]) + let output = InitWizard.applyResult(result, to: iosTemplate) + #expect(output.contains("colors = new Common.Colors {")) + #expect(output.contains("colors = new iOS.ColorsEntry {")) + #expect(!output.contains("icons = new Common.Icons {")) + #expect(!output.contains("images = new Common.Images {")) + #expect(!output.contains("typography = new Common.Typography {")) + } + + // MARK: - applyResult: Flutter (no typography) + + @Test("applyResult with Flutter and all available types preserves all sections") + func flutterAllSelected() { + let result = InitWizardResult( + platform: .flutter, + selectedAssetTypes: [.colors, .icons, .images], + lightFileId: "FLUTTER_ID", + darkFileId: "FLUTTER_DARK", + iconsFrameName: nil, + imagesFrameName: nil + ) + let output = InitWizard.applyResult(result, to: flutterTemplate) + #expect(output.contains("FLUTTER_ID")) + #expect(output.contains("FLUTTER_DARK")) + #expect(output.contains("colors = new Common.Colors {")) + #expect(output.contains("colors = new Flutter.ColorsEntry {")) + #expect(output.contains("icons = new Flutter.IconsEntry {")) + #expect(output.contains("images = new Flutter.ImagesEntry {")) + // Flutter has no typography config sections + #expect(!output.contains("typography = new Common.Typography {")) + #expect(!output.contains("typography = new Flutter.")) + } + + // MARK: - Brace balance + + @Test("Result PKL has balanced braces") + func balancedBraces() { + let result = makeResult(selectedAssetTypes: [.colors, .icons]) + let output = InitWizard.applyResult(result, to: iosTemplate) + let openCount = output.filter { $0 == "{" }.count + let closeCount = output.filter { $0 == "}" }.count + #expect(openCount == closeCount, "Unbalanced braces: \(openCount) open vs \(closeCount) close") + } + + @Test("Brace balance after removing all optional sections") + func balancedBracesMinimal() { + let result = makeResult(selectedAssetTypes: [.colors], darkFileId: nil) + let output = InitWizard.applyResult(result, to: iosTemplate) + let openCount = output.filter { $0 == "{" }.count + let closeCount = output.filter { $0 == "}" }.count + #expect(openCount == closeCount, "Unbalanced braces: \(openCount) open vs \(closeCount) close") + } + + // MARK: - Helpers + + private var iosTemplate: String { + iosConfigFileContents + } + + private var flutterTemplate: String { + flutterConfigFileContents + } + + private func makeResult( + platform: Platform = .ios, + selectedAssetTypes: [InitAssetType] = [.colors, .icons, .images, .typography], + lightFileId: String = "LIGHT_FILE_ID", + darkFileId: String? = "DARK_FILE_ID", + iconsFrameName: String? = nil, + imagesFrameName: String? = nil + ) -> InitWizardResult { + InitWizardResult( + platform: platform, + selectedAssetTypes: selectedAssetTypes, + lightFileId: lightFileId, + darkFileId: darkFileId, + iconsFrameName: iconsFrameName, + imagesFrameName: imagesFrameName + ) + } +} diff --git a/exfig.usage.kdl b/exfig.usage.kdl index 159c2549..dc43afff 100644 --- a/exfig.usage.kdl +++ b/exfig.usage.kdl @@ -107,9 +107,9 @@ cmd "typography" help="Exports typography from Figma" { // ============================================================================= cmd "init" help="Generates config file" { - long_help "Generates exfig.pkl config file in the current directory." + long_help "Generates exfig.pkl config file. When --platform is omitted in an interactive terminal, a guided wizard configures file IDs and asset types." - flag "-p --platform " help="Platform: ios, android, flutter, or web" required=#true { + flag "-p --platform " help="Platform: ios, android, flutter, or web. Prompted interactively if omitted in TTY." { choices "ios" "android" "flutter" "web" } } From c75873e054e9715cbd4934e15e6c6b81e3fedfe5 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 20 Mar 2026 17:38:16 +0500 Subject: [PATCH 05/12] feat(cli): add variables colors, page filter to init wizard - Add colors source choice: Styles vs Figma Variables - When Variables selected: uncomment variablesColors block, populate tokensFileId, collectionName, lightModeName, darkModeName - Add optional page name filter for icons and images (figmaPageName) - Split transform logic into InitWizardTransform.swift (file_length) - 23 tests covering variables, page names, section removal, brace balance --- Sources/ExFigCLI/Subcommands/InitWizard.swift | 345 +++++----------- .../Subcommands/InitWizardTransform.swift | 368 ++++++++++++++++++ .../Subcommands/InitWizardTests.swift | 106 ++++- 3 files changed, 558 insertions(+), 261 deletions(-) create mode 100644 Sources/ExFigCLI/Subcommands/InitWizardTransform.swift diff --git a/Sources/ExFigCLI/Subcommands/InitWizard.swift b/Sources/ExFigCLI/Subcommands/InitWizard.swift index dde35a02..c034aa8a 100644 --- a/Sources/ExFigCLI/Subcommands/InitWizard.swift +++ b/Sources/ExFigCLI/Subcommands/InitWizard.swift @@ -36,6 +36,26 @@ enum InitAssetType: String, CaseIterable, CustomStringConvertible, Equatable { } } +// MARK: - Colors Source + +/// How colors are sourced from Figma. +enum InitColorsSource: String, CaseIterable, CustomStringConvertible, Equatable { + case styles = "Color Styles (from file)" + case variables = "Figma Variables" + + var description: String { + rawValue + } +} + +/// Configuration for colors sourced from Figma Variables. +struct InitVariablesConfig { + let tokensFileId: String + let collectionName: String + let lightModeName: String + let darkModeName: String? +} + // MARK: - Init Wizard Result /// Result of the interactive init wizard flow. @@ -45,12 +65,17 @@ struct InitWizardResult { let lightFileId: String let darkFileId: String? let iconsFrameName: String? + let iconsPageName: String? let imagesFrameName: String? + let imagesPageName: String? + let variablesConfig: InitVariablesConfig? } // MARK: - Init Wizard Flow /// Interactive wizard for `exfig init` when `--platform` is not provided. +/// +/// Template transformation logic lives in `InitWizardTransform.swift`. enum InitWizard { /// Run the interactive wizard and return collected answers. static func run() -> InitWizardResult { @@ -86,18 +111,33 @@ enum InitWizard { inputPrompt: "Dark mode file ID:" ) - // 5. Icons frame name (if icons selected) - let iconsFrameName: String? = if selectedTypes.contains(.icons) { - promptFrameName(assetType: "icons", defaultName: "Icons") + // 5. Colors source (if colors selected) + let variablesConfig: InitVariablesConfig? = if selectedTypes.contains(.colors) { + promptColorsSource(lightFileId: lightFileId) } else { nil } - // 6. Images frame name (if images selected) - let imagesFrameName: String? = if selectedTypes.contains(.images) { - promptFrameName(assetType: "images", defaultName: "Illustrations") + // 6. Icons details (if icons selected) + let iconsFrameName: String? + let iconsPageName: String? + if selectedTypes.contains(.icons) { + iconsFrameName = promptFrameName(assetType: "icons", defaultName: "Icons") + iconsPageName = promptPageName(assetType: "icons") } else { - nil + iconsFrameName = nil + iconsPageName = nil + } + + // 7. Images details (if images selected) + let imagesFrameName: String? + let imagesPageName: String? + if selectedTypes.contains(.images) { + imagesFrameName = promptFrameName(assetType: "images", defaultName: "Illustrations") + imagesPageName = promptPageName(assetType: "images") + } else { + imagesFrameName = nil + imagesPageName = nil } return InitWizardResult( @@ -106,51 +146,14 @@ enum InitWizard { lightFileId: lightFileId, darkFileId: darkFileId, iconsFrameName: iconsFrameName, - imagesFrameName: imagesFrameName + iconsPageName: iconsPageName, + imagesFrameName: imagesFrameName, + imagesPageName: imagesPageName, + variablesConfig: variablesConfig ) } - // MARK: - Template Transformation (Pure, Testable) - - /// Apply wizard result to a platform template, removing unselected sections and substituting values. - static func applyResult(_ result: InitWizardResult, to template: String) -> String { - var output = template - - // Substitute file IDs - output = output.replacingOccurrences(of: "shPilWnVdJfo10YF12345", with: result.lightFileId) - - if let darkId = result.darkFileId { - output = output.replacingOccurrences(of: "KfF6DnJTWHGZzC912345", with: darkId) - } else { - output = removeDarkFileIdLine(from: output) - } - - // Substitute frame names - if let iconsFrame = result.iconsFrameName { - output = substituteFrameName(in: output, section: "icons", name: iconsFrame) - } - if let imagesFrame = result.imagesFrameName { - output = substituteFrameName(in: output, section: "images", name: 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) - } - - // When colors removed, also remove commented variablesColors block - if !result.selectedAssetTypes.contains(.colors) { - output = removeCommentedVariablesColors(from: output) - } - - // Collapse 3+ consecutive blank lines to 2 - output = collapseBlankLines(output) - - return output - } - - // MARK: - Private Helpers + // MARK: - Prompt Helpers private static func promptFrameName(assetType: String, defaultName: String) -> String { let input = NooraUI.textPrompt( @@ -177,220 +180,50 @@ enum InitWizard { ) } - /// Remove the `darkFileId = "..."` line (and its comment) from the template. - private static func removeDarkFileIdLine(from template: String) -> String { - var lines = template.components(separatedBy: "\n") - lines.removeAll { line in - let trimmed = line.trimmingCharacters(in: .whitespaces) - return trimmed.hasPrefix("darkFileId = ") - || trimmed == "// [optional] Identifier of the file containing dark color palette and dark images." - || trimmed == "// [optional] Identifier of the file containing dark color palette." - } - return lines.joined(separator: "\n") - } - - /// Substitute the default frame name in the `figmaFrameName = "..."` line within a section. - private static func substituteFrameName(in template: String, section: String, name: String) -> String { - let defaultName = section == "icons" ? "Icons" : "Illustrations" - let lines = template.components(separatedBy: "\n") - var result: [String] = [] - var inSection = false - - for line in lines { - let trimmed = line.trimmingCharacters(in: .whitespaces) - - if trimmed.contains("\(section) = new Common.") { - inSection = true - } - - if inSection, trimmed.hasPrefix("figmaFrameName = \"\(defaultName)\"") { - result.append(line.replacingOccurrences(of: "\"\(defaultName)\"", with: "\"\(name)\"")) - inSection = false - } else { - result.append(line) - } - - if inSection, trimmed == "}" { - inSection = false - } - } - - return result.joined(separator: "\n") - } - - /// Remove all sections (common + platform) for the given asset type. - static func removeAssetSections(from template: String, assetType: InitAssetType) -> String { - var output = template - - // Remove common section - let commonMarkers = commonSectionMarkers(for: assetType) - for marker in commonMarkers { - output = removeSection(from: output, matching: marker) - } - - // Remove platform-specific section - let platformMarkers = platformSectionMarkers(for: assetType) - for marker in platformMarkers { - output = removeSection(from: output, matching: marker) - } - - return output - } - - /// Remove a PKL section starting with a line matching the marker, counting braces to find the end. - static func removeSection(from template: String, matching marker: String) -> String { - let lines = template.components(separatedBy: "\n") - var result: [String] = [] - var braceDepth = 0 - var removing = false - - for line in lines { - if removing { - braceDepth += braceBalance(in: line) - if braceDepth <= 0 { removing = false } - continue - } - - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard trimmed.contains(marker) else { - result.append(line) - continue - } - - // Start removing: strip preceding comments/blanks - removing = true - braceDepth = braceBalance(in: line) - stripTrailingCommentsAndBlanks(&result) - if braceDepth <= 0 { removing = false } - } - - return result.joined(separator: "\n") - } - - /// Count net brace balance ({ = +1, } = -1) in a line. - private static func braceBalance(in line: String) -> Int { - var balance = 0 - for char in line { - if char == "{" { balance += 1 } - if char == "}" { balance -= 1 } - } - return balance + private static func promptPageName(assetType: String) -> String? { + promptOptionalText( + question: "Filter \(assetType) by Figma page name?", + description: "Useful when multiple pages have frames with the same name", + inputPrompt: "Page name:" + ) } - /// Remove trailing comment lines and blank lines from the result array. - private static func stripTrailingCommentsAndBlanks(_ lines: inout [String]) { - while let last = lines.last { - let trimmed = last.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix("//") || trimmed.isEmpty { - lines.removeLast() - } else { - break - } - } - } + private static func promptColorsSource(lightFileId: String) -> InitVariablesConfig? { + let source: InitColorsSource = NooraUI.singleChoicePrompt( + question: "How are your colors defined in Figma?", + options: InitColorsSource.allCases, + description: "Variables is the modern approach; Styles is the classic one" + ) - /// Markers for common section blocks (in `common = new Common.CommonConfig`). - private static func commonSectionMarkers(for assetType: InitAssetType) -> [String] { - switch assetType { - case .colors: - ["colors = new Common.Colors {"] - case .icons: - ["icons = new Common.Icons {"] - case .images: - ["images = new Common.Images {"] - case .typography: - ["typography = new Common.Typography {"] - } - } + guard source == .variables else { return nil } - /// Markers for platform-specific section blocks. - private static func platformSectionMarkers(for assetType: InitAssetType) -> [String] { - switch assetType { - case .colors: - [ - "colors = new iOS.ColorsEntry {", - "colors = new Android.ColorsEntry {", - "colors = new Flutter.ColorsEntry {", - "colors = new Web.ColorsEntry {", - ] - case .icons: - [ - "icons = new iOS.IconsEntry {", - "icons = new Android.IconsEntry {", - "icons = new Flutter.IconsEntry {", - "icons = new Web.IconsEntry {", - ] - case .images: - [ - "images = new iOS.ImagesEntry {", - "images = new Android.ImagesEntry {", - "images = new Flutter.ImagesEntry {", - "images = new Web.ImagesEntry {", - ] - case .typography: - [ - "typography = new iOS.Typography {", - "typography = new Android.Typography {", - ] - } - } + let tokensFileId = NooraUI.textPrompt( + prompt: "Variables file ID (default: same as light file):", + description: "The Figma file containing your color variables. Press Enter to use the light file ID." + ) - /// Remove commented-out `variablesColors` block when colors are not selected. - private static func removeCommentedVariablesColors(from template: String) -> String { - let lines = template.components(separatedBy: "\n") - var result: [String] = [] - var removing = false - - for line in lines { - let trimmed = line.trimmingCharacters(in: .whitespaces) - - if !removing { - if trimmed.hasPrefix("// variablesColors = new Common.VariablesColors {") - || trimmed - .hasPrefix( - "// [optional] Use variablesColors instead of colors to export colors from Figma Variables." - ) - || trimmed - .hasPrefix( - "// [optional] Use variablesColors to export colors from Figma Variables." - ) - { - removing = true - continue - } - result.append(line) - } else { - // Keep removing commented lines until we find a non-comment line - if trimmed.hasPrefix("//") { - continue - } else { - removing = false - result.append(line) - } - } - } + let collectionName = NooraUI.textPrompt( + prompt: "Variables collection name:", + description: "The name of the variable collection in Figma (e.g., 'Primitives', 'Base collection')", + validationRules: [NonEmptyValidationRule(error: "Collection name cannot be empty.")] + ) - return result.joined(separator: "\n") - } + let lightModeName = NooraUI.textPrompt( + prompt: "Light mode column name (default: Light):", + description: "Column name for light color values. Press Enter for default." + ) - /// Collapse 3+ consecutive blank lines into 2. - private static func collapseBlankLines(_ text: String) -> String { - let lines = text.components(separatedBy: "\n") - var result: [String] = [] - var consecutiveBlanks = 0 - - for line in lines { - if line.trimmingCharacters(in: .whitespaces).isEmpty { - consecutiveBlanks += 1 - if consecutiveBlanks <= 2 { - result.append(line) - } - } else { - consecutiveBlanks = 0 - result.append(line) - } - } + let darkModeName = promptOptionalText( + question: "Do you have a dark mode column?", + description: "Column name for dark color values in the variables table", + inputPrompt: "Dark mode column name:" + ) - return result.joined(separator: "\n") + return InitVariablesConfig( + tokensFileId: tokensFileId.isEmpty ? lightFileId : tokensFileId, + collectionName: collectionName, + lightModeName: lightModeName.isEmpty ? "Light" : lightModeName, + darkModeName: darkModeName + ) } } diff --git a/Sources/ExFigCLI/Subcommands/InitWizardTransform.swift b/Sources/ExFigCLI/Subcommands/InitWizardTransform.swift new file mode 100644 index 00000000..06451060 --- /dev/null +++ b/Sources/ExFigCLI/Subcommands/InitWizardTransform.swift @@ -0,0 +1,368 @@ +// swiftlint:disable file_length + +// MARK: - Template Transformation (Pure, Testable) + +extension InitWizard { + /// Apply wizard result to a platform template, removing unselected sections and substituting values. + static func applyResult(_ result: InitWizardResult, to template: String) -> String { + var output = template + + // Substitute file IDs + output = output.replacingOccurrences(of: "shPilWnVdJfo10YF12345", with: result.lightFileId) + + if let darkId = result.darkFileId { + output = output.replacingOccurrences(of: "KfF6DnJTWHGZzC912345", with: darkId) + } else { + output = removeDarkFileIdLine(from: output) + } + + // Substitute frame names + if let iconsFrame = result.iconsFrameName { + output = substituteFrameName(in: output, section: "icons", name: iconsFrame) + } + if let imagesFrame = result.imagesFrameName { + output = substituteFrameName(in: output, section: "images", name: imagesFrame) + } + + // Substitute page names + if let iconsPage = result.iconsPageName { + output = uncommentPageName(in: output, section: "icons", name: iconsPage) + } + if let imagesPage = result.imagesPageName { + output = uncommentPageName(in: output, section: "images", name: imagesPage) + } + + // Handle colors source: variables vs styles + if let vars = result.variablesConfig { + // Remove regular colors section, uncomment and populate variablesColors + output = removeSection(from: output, matching: "colors = new Common.Colors {") + output = uncommentVariablesColors(in: output, config: vars) + } else if result.selectedAssetTypes.contains(.colors) { + // Regular styles: just remove the commented variablesColors block + output = removeCommentedVariablesColors(from: output) + } + + // 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) + } + + // When colors removed entirely, also remove commented variablesColors block + if !result.selectedAssetTypes.contains(.colors) { + output = removeCommentedVariablesColors(from: output) + } + + // Collapse 3+ consecutive blank lines to 2 + output = collapseBlankLines(output) + + return output + } + + // MARK: - Line-Level Operations + + /// Remove the `darkFileId = "..."` line (and its comment) from the template. + static func removeDarkFileIdLine(from template: String) -> String { + var lines = template.components(separatedBy: "\n") + lines.removeAll { line in + let trimmed = line.trimmingCharacters(in: .whitespaces) + return trimmed.hasPrefix("darkFileId = ") + || trimmed == "// [optional] Identifier of the file containing dark color palette and dark images." + || trimmed == "// [optional] Identifier of the file containing dark color palette." + } + return lines.joined(separator: "\n") + } + + /// Substitute the default frame name in the `figmaFrameName = "..."` line within a section. + static func substituteFrameName(in template: String, section: String, name: String) -> String { + let defaultName = section == "icons" ? "Icons" : "Illustrations" + let lines = template.components(separatedBy: "\n") + var result: [String] = [] + var inSection = false + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed.contains("\(section) = new Common.") { + inSection = true + } + + if inSection, trimmed.hasPrefix("figmaFrameName = \"\(defaultName)\"") { + result.append(line.replacingOccurrences(of: "\"\(defaultName)\"", with: "\"\(name)\"")) + inSection = false + } else { + result.append(line) + } + + if inSection, trimmed == "}" { + inSection = false + } + } + + return result.joined(separator: "\n") + } + + /// Uncomment `figmaPageName` within a common section and set the value. + static func uncommentPageName(in template: String, section: String, name: String) -> String { + let lines = template.components(separatedBy: "\n") + var result: [String] = [] + var inSection = false + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed.contains("\(section) = new Common.") { + inSection = true + } + + if inSection, trimmed.hasPrefix("// figmaPageName = ") { + let indent = String(line.prefix(while: { $0 == " " })) + result.append("\(indent)figmaPageName = \"\(name)\"") + inSection = false + } else { + result.append(line) + } + + if inSection, trimmed == "}" { + inSection = false + } + } + + return result.joined(separator: "\n") + } + + // MARK: - Section Removal + + /// Remove all sections (common + platform) for the given asset type. + static func removeAssetSections(from template: String, assetType: InitAssetType) -> String { + var output = template + + for marker in commonSectionMarkers(for: assetType) { + output = removeSection(from: output, matching: marker) + } + for marker in platformSectionMarkers(for: assetType) { + output = removeSection(from: output, matching: marker) + } + + return output + } + + /// Remove a PKL section starting with a line matching the marker, counting braces to find the end. + static func removeSection(from template: String, matching marker: String) -> String { + let lines = template.components(separatedBy: "\n") + var result: [String] = [] + var braceDepth = 0 + var removing = false + + for line in lines { + if removing { + braceDepth += braceBalance(in: line) + if braceDepth <= 0 { removing = false } + continue + } + + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.contains(marker) else { + result.append(line) + continue + } + + // Start removing: strip preceding comments/blanks + removing = true + braceDepth = braceBalance(in: line) + stripTrailingCommentsAndBlanks(&result) + if braceDepth <= 0 { removing = false } + } + + return result.joined(separator: "\n") + } + + // MARK: - Variables Colors + + /// Uncomment `variablesColors` block and substitute values from wizard config. + static func uncommentVariablesColors( + in template: String, + config: InitVariablesConfig + ) -> String { + let lines = template.components(separatedBy: "\n") + var result: [String] = [] + var inBlock = false + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if !inBlock { + if trimmed.hasPrefix("// [optional] Use variablesColors") + || trimmed.hasPrefix("// variablesColors = new Common.VariablesColors {") + { + if trimmed.hasPrefix("// variablesColors") { + inBlock = true + result.append(" variablesColors = new Common.VariablesColors {") + } + continue + } + result.append(line) + } else { + if trimmed == "// }" { + result.append(" }") + inBlock = false + } else if trimmed.hasPrefix("//") { + var uncommented = String(trimmed.dropFirst(2)) + if uncommented.hasPrefix(" ") { + uncommented = String(uncommented.dropFirst()) + } + uncommented = substituteVariableValue(uncommented, config: config) + result.append(" \(uncommented)") + } else { + inBlock = false + result.append(line) + } + } + } + + return result.joined(separator: "\n") + } + + /// Remove commented-out `variablesColors` block. + static func removeCommentedVariablesColors(from template: String) -> String { + let lines = template.components(separatedBy: "\n") + var result: [String] = [] + var removing = false + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if !removing { + if trimmed.hasPrefix("// variablesColors = new Common.VariablesColors {") + || trimmed + .hasPrefix( + "// [optional] Use variablesColors instead of colors to export colors from Figma Variables." + ) + || trimmed + .hasPrefix( + "// [optional] Use variablesColors to export colors from Figma Variables." + ) + { + removing = true + continue + } + result.append(line) + } else { + if trimmed.hasPrefix("//") { + continue + } else { + removing = false + result.append(line) + } + } + } + + return result.joined(separator: "\n") + } + + // MARK: - Section Markers + + private static func commonSectionMarkers(for assetType: InitAssetType) -> [String] { + switch assetType { + case .colors: + ["colors = new Common.Colors {"] + case .icons: + ["icons = new Common.Icons {"] + case .images: + ["images = new Common.Images {"] + case .typography: + ["typography = new Common.Typography {"] + } + } + + private static func platformSectionMarkers(for assetType: InitAssetType) -> [String] { + switch assetType { + case .colors: + [ + "colors = new iOS.ColorsEntry {", + "colors = new Android.ColorsEntry {", + "colors = new Flutter.ColorsEntry {", + "colors = new Web.ColorsEntry {", + ] + case .icons: + [ + "icons = new iOS.IconsEntry {", + "icons = new Android.IconsEntry {", + "icons = new Flutter.IconsEntry {", + "icons = new Web.IconsEntry {", + ] + case .images: + [ + "images = new iOS.ImagesEntry {", + "images = new Android.ImagesEntry {", + "images = new Flutter.ImagesEntry {", + "images = new Web.ImagesEntry {", + ] + case .typography: + [ + "typography = new iOS.Typography {", + "typography = new Android.Typography {", + ] + } + } + + // MARK: - Utilities + + private static func braceBalance(in line: String) -> Int { + var balance = 0 + for char in line { + if char == "{" { balance += 1 } + if char == "}" { balance -= 1 } + } + return balance + } + + private static func stripTrailingCommentsAndBlanks(_ lines: inout [String]) { + while let last = lines.last { + let trimmed = last.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("//") || trimmed.isEmpty { + lines.removeLast() + } else { + break + } + } + } + + private static func substituteVariableValue(_ line: String, config: InitVariablesConfig) -> String { + var result = line + if result.contains("tokensFileId = ") { + result = "tokensFileId = \"\(config.tokensFileId)\"" + } else if result.contains("tokensCollectionName = ") { + result = "tokensCollectionName = \"\(config.collectionName)\"" + } else if result.contains("lightModeName = ") { + result = "lightModeName = \"\(config.lightModeName)\"" + } else if result.contains("darkModeName = "), let darkMode = config.darkModeName { + result = "darkModeName = \"\(darkMode)\"" + } else if result.contains("darkModeName = "), config.darkModeName == nil { + result = "// \(result)" + } + return result + } + + static func collapseBlankLines(_ text: String) -> String { + let lines = text.components(separatedBy: "\n") + var result: [String] = [] + var consecutiveBlanks = 0 + + for line in lines { + if line.trimmingCharacters(in: .whitespaces).isEmpty { + consecutiveBlanks += 1 + if consecutiveBlanks <= 2 { + result.append(line) + } + } else { + consecutiveBlanks = 0 + result.append(line) + } + } + + return result.joined(separator: "\n") + } +} + +// swiftlint:enable file_length diff --git a/Tests/ExFigTests/Subcommands/InitWizardTests.swift b/Tests/ExFigTests/Subcommands/InitWizardTests.swift index ee5ae7d3..30456ac8 100644 --- a/Tests/ExFigTests/Subcommands/InitWizardTests.swift +++ b/Tests/ExFigTests/Subcommands/InitWizardTests.swift @@ -80,6 +80,95 @@ struct InitWizardTests { #expect(output.contains("figmaFrameName = \"MyImages\"")) } + // MARK: - applyResult: Page name substitution + + @Test("applyResult uncomments icons page name when provided") + func uncommentIconsPageName() { + let result = makeResult(iconsPageName: "Outlined") + let output = InitWizard.applyResult(result, to: iosTemplate) + #expect(output.contains("figmaPageName = \"Outlined\"")) + #expect(!output.contains("// figmaPageName = \"Outlined\"")) + } + + @Test("applyResult uncomments images page name when provided") + func uncommentImagesPageName() { + let result = makeResult(imagesPageName: "Marketing") + let output = InitWizard.applyResult(result, to: iosTemplate) + #expect(output.contains("figmaPageName = \"Marketing\"")) + } + + @Test("applyResult keeps page name commented when not provided") + func pageNameStaysCommentedWhenNil() { + let result = makeResult() + let output = InitWizard.applyResult(result, to: iosTemplate) + // Both page name lines should remain commented + #expect(output.contains("// figmaPageName = ")) + } + + // MARK: - applyResult: Variables colors + + @Test("applyResult replaces colors with variablesColors when variables config provided") + func variablesColorsReplacesStyles() { + let vars = InitVariablesConfig( + tokensFileId: "TOKENS_FILE", + collectionName: "My Tokens", + lightModeName: "Day", + darkModeName: "Night" + ) + let result = makeResult(variablesConfig: vars) + let output = InitWizard.applyResult(result, to: iosTemplate) + // Regular colors section removed + #expect(!output.contains("colors = new Common.Colors {")) + // variablesColors uncommented and populated + #expect(output.contains("variablesColors = new Common.VariablesColors {")) + #expect(output.contains("tokensFileId = \"TOKENS_FILE\"")) + #expect(output.contains("tokensCollectionName = \"My Tokens\"")) + #expect(output.contains("lightModeName = \"Day\"")) + #expect(output.contains("darkModeName = \"Night\"")) + } + + @Test("applyResult comments out darkModeName when variables config has no dark mode") + func variablesColorsNoDarkMode() { + let vars = InitVariablesConfig( + tokensFileId: "TOKENS_FILE", + collectionName: "Primitives", + lightModeName: "Light", + darkModeName: nil + ) + let result = makeResult(variablesConfig: vars) + let output = InitWizard.applyResult(result, to: iosTemplate) + #expect(output.contains("variablesColors = new Common.VariablesColors {")) + // darkModeName should be commented out + let darkModeLines = output.components(separatedBy: "\n") + .filter { $0.contains("darkModeName") } + for line in darkModeLines { + #expect( + line.trimmingCharacters(in: .whitespaces).hasPrefix("//"), + "darkModeName should be commented: \(line)" + ) + } + } + + @Test("applyResult with styles removes variablesColors comment block") + func stylesRemovesVariablesBlock() { + let result = makeResult(variablesConfig: nil) + let output = InitWizard.applyResult(result, to: iosTemplate) + #expect(output.contains("colors = new Common.Colors {")) + #expect(!output.contains("variablesColors = new Common.VariablesColors {")) + } + + @Test("Brace balance with variablesColors") + func balancedBracesWithVariables() { + let vars = InitVariablesConfig( + tokensFileId: "ID", collectionName: "C", lightModeName: "L", darkModeName: "D" + ) + let result = makeResult(variablesConfig: vars) + let output = InitWizard.applyResult(result, to: iosTemplate) + let openCount = output.filter { $0 == "{" }.count + let closeCount = output.filter { $0 == "}" }.count + #expect(openCount == closeCount, "Unbalanced braces: \(openCount) open vs \(closeCount) close") + } + // MARK: - applyResult: Section removal @Test("applyResult removes colors section when not selected") @@ -88,7 +177,6 @@ struct InitWizardTests { let output = InitWizard.applyResult(result, to: iosTemplate) #expect(!output.contains("colors = new Common.Colors {")) #expect(!output.contains("colors = new iOS.ColorsEntry {")) - // variablesColors commented block should also be removed #expect(!output.contains("variablesColors = new Common.VariablesColors {")) } @@ -137,7 +225,10 @@ struct InitWizardTests { lightFileId: "FLUTTER_ID", darkFileId: "FLUTTER_DARK", iconsFrameName: nil, - imagesFrameName: nil + iconsPageName: nil, + imagesFrameName: nil, + imagesPageName: nil, + variablesConfig: nil ) let output = InitWizard.applyResult(result, to: flutterTemplate) #expect(output.contains("FLUTTER_ID")) @@ -146,7 +237,6 @@ struct InitWizardTests { #expect(output.contains("colors = new Flutter.ColorsEntry {")) #expect(output.contains("icons = new Flutter.IconsEntry {")) #expect(output.contains("images = new Flutter.ImagesEntry {")) - // Flutter has no typography config sections #expect(!output.contains("typography = new Common.Typography {")) #expect(!output.contains("typography = new Flutter.")) } @@ -187,7 +277,10 @@ struct InitWizardTests { lightFileId: String = "LIGHT_FILE_ID", darkFileId: String? = "DARK_FILE_ID", iconsFrameName: String? = nil, - imagesFrameName: String? = nil + iconsPageName: String? = nil, + imagesFrameName: String? = nil, + imagesPageName: String? = nil, + variablesConfig: InitVariablesConfig? = nil ) -> InitWizardResult { InitWizardResult( platform: platform, @@ -195,7 +288,10 @@ struct InitWizardTests { lightFileId: lightFileId, darkFileId: darkFileId, iconsFrameName: iconsFrameName, - imagesFrameName: imagesFrameName + iconsPageName: iconsPageName, + imagesFrameName: imagesFrameName, + imagesPageName: imagesPageName, + variablesConfig: variablesConfig ) } } From c49878b613c0ac03becdd5def27b0703c8f2ef15 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 20 Mar 2026 18:08:06 +0500 Subject: [PATCH 06/12] fix(cli): use package:// URIs in generated configs, drop local schemas Generated exfig.pkl now references published PKL package URIs (package://github.com/DesignPipe/exfig/releases/download/v{VERSION}/...) instead of local .exfig/schemas/ paths. Version is taken from ExFigCommand.version at generation time. - Add substitutePackageURI() to replace .exfig/schemas/ with package:// - Remove SchemaExtractor.extract() call from `exfig init` - No more .exfig/schemas/ created on init --- CLAUDE.md | 1 + Sources/ExFigCLI/CLAUDE.md | 9 ++++++ .../Subcommands/GenerateConfigFile.swift | 31 +++++++++---------- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5c1f2db9..109946a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -385,6 +385,7 @@ NooraUI.formatLink("url", useColors: true) // underlined primary | `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` | | 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 | +| Release build .pcm warnings | Stale `ModuleCache` — clean with: `rm -r .build/*/release/ModuleCache` then rebuild | ## Additional Rules diff --git a/Sources/ExFigCLI/CLAUDE.md b/Sources/ExFigCLI/CLAUDE.md index c8417a7e..beceeebe 100644 --- a/Sources/ExFigCLI/CLAUDE.md +++ b/Sources/ExFigCLI/CLAUDE.md @@ -207,6 +207,15 @@ Follow `InitWizard.swift` / `FetchWizard.swift` pattern: 4. Reuse `WizardPlatform` from `FetchWizard.swift` (has `asPlatform` → `Platform` mapping) 5. Gate on `TTYDetector.isTTY` in the calling command; throw `ValidationError` for non-TTY +**File split pattern:** Keep types + interactive prompts in `*Wizard.swift` (<200 lines), extract pure +transformation logic into `*WizardTransform.swift` as `extension`. SwiftLint enforces 400-line file / 300-line type body limits. + +**Template transformations** (three operations on PKL templates): + +- **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) + ### Adding a New Platform Export 1. Create platform export orchestrator in `Subcommands/Export/` (e.g., `NewPlatformColorsExport.swift`) diff --git a/Sources/ExFigCLI/Subcommands/GenerateConfigFile.swift b/Sources/ExFigCLI/Subcommands/GenerateConfigFile.swift index d08fb474..dda6833b 100644 --- a/Sources/ExFigCLI/Subcommands/GenerateConfigFile.swift +++ b/Sources/ExFigCLI/Subcommands/GenerateConfigFile.swift @@ -44,37 +44,45 @@ extension ExFigCommand { } // Determine file contents: wizard or direct template - let fileContents: String + let rawContents: String let wizardResult: InitWizardResult? if let platform { // Direct flag — use full template - fileContents = templateForPlatform(platform) + rawContents = templateForPlatform(platform) wizardResult = nil } else if TTYDetector.isTTY { // Interactive wizard let result = InitWizard.run() let template = templateForPlatform(result.platform) - fileContents = InitWizard.applyResult(result, to: template) + rawContents = InitWizard.applyResult(result, to: template) wizardResult = result } else { // Non-TTY without --platform throw ValidationError("Missing required option: --platform. Use -p ios|android|flutter|web.") } - // Extract PKL schemas for local validation - let extractedSchemas = try SchemaExtractor.extract() + // Substitute local schema paths with published package URIs + let fileContents = Self.substitutePackageURI(in: rawContents) // Write new config file try writeConfigFile( contents: fileContents, to: destination, ui: ui, - extractedSchemas: extractedSchemas, wizardResult: wizardResult ) } + /// Replace `.exfig/schemas/` paths with published `package://` URIs using current CLI version. + static func substitutePackageURI(in template: String) -> String { + let version = ExFigCommand.version // e.g. "v2.8.1" + let semver = version.hasPrefix("v") ? String(version.dropFirst()) : version + let packagePrefix = + "package://github.com/DesignPipe/exfig/releases/download/\(version)/exfig@\(semver)#/" + return template.replacingOccurrences(of: ".exfig/schemas/", with: packagePrefix) + } + /// Return the full PKL template for a given platform. private func templateForPlatform(_ platform: Platform) -> String { switch platform { @@ -121,15 +129,12 @@ extension ExFigCommand { return true } - // swiftlint:disable function_parameter_count private func writeConfigFile( contents: String, to destination: String, ui: TerminalUI, - extractedSchemas: [String] = [], wizardResult: InitWizardResult? = nil ) throws { - // swiftlint:enable function_parameter_count guard let fileData = contents.data(using: .utf8) else { throw ExFigError.custom(errorString: "Failed to encode config file contents") } @@ -137,14 +142,6 @@ extension ExFigCommand { let success = FileManager.default.createFile(atPath: destination, contents: fileData, attributes: nil) if success { ui.success("Config file generated: \(destination)") - - if !extractedSchemas.isEmpty { - ui - .success( - "Extracted \(extractedSchemas.count) PKL schemas to \(SchemaExtractor.defaultOutputDir)/" - ) - } - ui.info("") ui.info("Next steps:") From 1f59509befe5daaabf2793353e7351b8e9924841 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 20 Mar 2026 18:10:47 +0500 Subject: [PATCH 07/12] docs: document generated PKL config URI pattern in CLAUDE.md --- CLAUDE.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 109946a5..a1f7a492 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -163,6 +163,13 @@ Tests/ # Test targets mirror source structure ## Code Patterns +### Generated PKL Config URIs + +Templates in `*Config.swift` use `.exfig/schemas/` as placeholder paths. `GenerateConfigFile.substitutePackageURI()` +replaces them with `package://github.com/DesignPipe/exfig/releases/download/v{VERSION}/exfig@{VERSION}#/` at generation +time. Version comes from `ExFigCommand.version`. `exfig init` does NOT extract local schemas — config references the +published PKL package directly. + ### PKL Consumer Config DRY Patterns Consumer `exfig.pkl` configs can use `local` Mapping + `for`-generators to eliminate entry duplication: From ab92fd0695335294f10cfd62fc8af933fb252826 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 20 Mar 2026 20:52:28 +0500 Subject: [PATCH 08/12] chore: regenerate llms-full.txt to fix lint CI --- llms-full.txt | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/llms-full.txt b/llms-full.txt index 72e4574a..79914ea4 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -46,8 +46,8 @@ export FIGMA_PERSONAL_TOKEN=your_token_here # 3a. Quick one-off export (interactive wizard) exfig fetch -# 3b. Or generate config for full pipeline -exfig init -p ios +# 3b. Or generate config for full pipeline (interactive wizard) +exfig init exfig batch exfig.pkl ``` @@ -258,6 +258,22 @@ exfig images exfig typography ``` +## Getting Started + +Generate a config file with the interactive wizard: + +```bash +exfig init +``` + +The wizard guides you through platform selection, asset types, and Figma file IDs. +For non-interactive use, specify the platform directly: + +```bash +exfig init -p ios # Full iOS template +exfig init -p android # Full Android template +``` + ## Configuration File By default, ExFig looks for `exfig.pkl` in the current directory. Specify a different location: From 749cb7aaf04a14d6d9c9a61d2d44ec7b9af348ed Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 20 Mar 2026 21:13:29 +0500 Subject: [PATCH 09/12] fix(cli): harden wizard input handling and expand test coverage - Make DownloadOptions.format optional with effectiveFormat computed property so wizard doesn't override explicit --format png - Add extractFigmaFileId() to auto-extract file IDs from full Figma URLs - Trim whitespace on text prompt inputs before empty-check defaults - Add defensive check in removeSection for unclosed brace-counted sections - Remove unnecessary swiftlint:disable function_parameter_count - Add tests for substitutePackageURI, extractFigmaFileId, Android/Web templates, removeSection edge cases, and collapseBlankLines - Update CLAUDE.md with wizard patterns and CLI flag gotcha --- CLAUDE.md | 3 + Sources/ExFigCLI/CLAUDE.md | 4 +- Sources/ExFigCLI/ExFig.docc/Usage.md | 3 +- Sources/ExFigCLI/Input/DownloadOptions.swift | 11 ++- .../ExFigCLI/Subcommands/DownloadImages.swift | 12 +-- .../ExFigCLI/Subcommands/FetchWizard.swift | 31 +++++-- Sources/ExFigCLI/Subcommands/InitWizard.swift | 24 +++--- .../Subcommands/InitWizardTransform.swift | 7 ++ .../Input/DownloadOptionsTests.swift | 5 +- .../Subcommands/FetchWizardTests.swift | 61 ++++++++++++++ .../Subcommands/InitWizardTests.swift | 84 +++++++++++++++++++ 11 files changed, 214 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a1f7a492..57b93e7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -275,6 +275,8 @@ Follow `InitWizard.swift` / `FetchWizard.swift` pattern: - Pure function for testable transformation logic (e.g., `applyResult(_:to:)`) - Reuse `WizardPlatform` from `FetchWizard.swift` (has `asPlatform` property) - Gate on `TTYDetector.isTTY`; throw `ValidationError` for non-TTY without required flags +- 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 ### Adding a NooraUI Prompt Wrapper @@ -387,6 +389,7 @@ NooraUI.formatLink("url", useColors: true) // underlined primary | 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 | diff --git a/Sources/ExFigCLI/CLAUDE.md b/Sources/ExFigCLI/CLAUDE.md index beceeebe..24a22b0f 100644 --- a/Sources/ExFigCLI/CLAUDE.md +++ b/Sources/ExFigCLI/CLAUDE.md @@ -207,9 +207,11 @@ Follow `InitWizard.swift` / `FetchWizard.swift` pattern: 4. Reuse `WizardPlatform` from `FetchWizard.swift` (has `asPlatform` → `Platform` mapping) 5. Gate on `TTYDetector.isTTY` in the calling command; throw `ValidationError` for non-TTY -**File split pattern:** Keep types + interactive prompts in `*Wizard.swift` (<200 lines), extract pure +**File split pattern:** Keep types + interactive prompts in `*Wizard.swift` (~230 lines), extract pure transformation logic into `*WizardTransform.swift` as `extension`. SwiftLint enforces 400-line file / 300-line type body limits. +**Test file split pattern:** When a `@Suite` struct exceeds 300 lines, extract groups of tests into separate `@Suite` structs in the same file (e.g., `InitWizardCrossPlatformTests`, `InitWizardTransformUtilityTests`). + **Template transformations** (three operations on PKL templates): - **Remove section** — brace-counting (`removeSection`), strips preceding comments diff --git a/Sources/ExFigCLI/ExFig.docc/Usage.md b/Sources/ExFigCLI/ExFig.docc/Usage.md index 5f2bb9e1..115eb664 100644 --- a/Sources/ExFigCLI/ExFig.docc/Usage.md +++ b/Sources/ExFigCLI/ExFig.docc/Usage.md @@ -158,7 +158,8 @@ exfig icons --resume ## Quick Fetch Download images without a configuration file. Run `exfig fetch` with no arguments for an -interactive wizard that guides you through file ID, platform, format, and output options: +interactive wizard that guides you through file ID, asset type, platform, frame selection, +format, output directory, and more: ```bash # Interactive wizard — asks platform, format, output step by step diff --git a/Sources/ExFigCLI/Input/DownloadOptions.swift b/Sources/ExFigCLI/Input/DownloadOptions.swift index e864c5b0..a9c4f1f4 100644 --- a/Sources/ExFigCLI/Input/DownloadOptions.swift +++ b/Sources/ExFigCLI/Input/DownloadOptions.swift @@ -77,7 +77,7 @@ struct DownloadOptions: ParsableArguments { name: .long, help: "Image format: \(ImageFormat.allValueStrings.joined(separator: ", ")). Default: png" ) - var format: ImageFormat = .png + var format: ImageFormat? @Option( name: .long, @@ -164,11 +164,16 @@ struct DownloadOptions: ParsableArguments { // MARK: - Computed Properties + /// Returns the effective format, defaulting to `.png` when not explicitly set. + var effectiveFormat: ImageFormat { + format ?? .png + } + /// Returns the effective scale based on format. /// For PNG: defaults to 3 if not specified. /// For vector formats (SVG, PDF): scale is ignored. var effectiveScale: Double { - switch format { + switch effectiveFormat { case .png, .jpg, .webp: scale ?? 3.0 case .svg, .pdf: @@ -178,7 +183,7 @@ struct DownloadOptions: ParsableArguments { /// Returns true if format is a vector format (scale is ignored) var isVectorFormat: Bool { - format == .svg || format == .pdf + effectiveFormat == .svg || effectiveFormat == .pdf } /// Returns the Figma access token from environment diff --git a/Sources/ExFigCLI/Subcommands/DownloadImages.swift b/Sources/ExFigCLI/Subcommands/DownloadImages.swift index 155ac9ab..0e08cd46 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadImages.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadImages.swift @@ -74,7 +74,7 @@ extension ExFigCommand { options.outputPath = options.outputPath ?? result.outputPath options.pageName = options.pageName ?? result.pageName options.filter = options.filter ?? result.filter - if options.format == .png { + if options.format == nil { options.format = result.format } if options.nameStyle == nil { @@ -109,7 +109,7 @@ extension ExFigCommand { ui.debug("File ID: \(fileId)") ui.debug("Frame: \(frameName)") ui.debug("Output: \(outputURL.path)") - ui.debug("Format: \(options.format.rawValue)") + ui.debug("Format: \(options.effectiveFormat.rawValue)") if !options.isVectorFormat { ui.debug("Scale: \(options.effectiveScale)x") } @@ -148,7 +148,7 @@ extension ExFigCommand { fileId: fileId, frameName: frameName, pageName: resolvedOptions.pageName, - format: resolvedOptions.format, + format: resolvedOptions.effectiveFormat, effectiveScale: resolvedOptions.effectiveScale, filter: resolvedOptions.filter, onBatchProgress: onProgress @@ -184,7 +184,7 @@ extension ExFigCommand { var allFiles = DownloadImageProcessor.createFileContents( from: lightPacks, outputURL: outputURL, - format: resolvedOptions.format, + format: resolvedOptions.effectiveFormat, dark: false, darkModeSuffix: resolvedOptions.darkModeSuffix ) @@ -192,7 +192,7 @@ extension ExFigCommand { allFiles += DownloadImageProcessor.createFileContents( from: darkPacks, outputURL: outputURL, - format: resolvedOptions.format, + format: resolvedOptions.effectiveFormat, dark: true, darkModeSuffix: resolvedOptions.darkModeSuffix ) @@ -212,7 +212,7 @@ extension ExFigCommand { } // Convert to WebP if needed - let finalFiles: [FileContents] = if resolvedOptions.format == .webp { + let finalFiles: [FileContents] = if resolvedOptions.effectiveFormat == .webp { try await convertToWebP(downloadedFiles, options: resolvedOptions, ui: ui) } else { downloadedFiles diff --git a/Sources/ExFigCLI/Subcommands/FetchWizard.swift b/Sources/ExFigCLI/Subcommands/FetchWizard.swift index 9f9609b7..5143e690 100644 --- a/Sources/ExFigCLI/Subcommands/FetchWizard.swift +++ b/Sources/ExFigCLI/Subcommands/FetchWizard.swift @@ -95,6 +95,23 @@ struct FetchWizardResult { let filter: String? } +// MARK: - Figma 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. +func extractFigmaFileId(from input: String) -> String { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + // Match figma.com/file/ or figma.com/design/ + if let range = trimmed.range(of: #"figma\.com/(?:file|design)/([A-Za-z0-9]+)"#, options: .regularExpression) { + let match = trimmed[range] + // Extract the ID part after the last / + if let lastSlash = match.lastIndex(of: "/") { + return String(match[match.index(after: lastSlash)...]) + } + } + return trimmed +} + // MARK: - Wizard Flow /// Interactive wizard for `exfig fetch` when required options are missing. @@ -110,12 +127,13 @@ enum FetchWizard { /// Run the interactive wizard and return populated options. static func run() -> FetchWizardResult { // 1–3: Core choices (file, asset type, platform) - let fileId = NooraUI.textPrompt( + let fileIdInput = NooraUI.textPrompt( title: "Figma Export Wizard", - prompt: "Figma file ID (from URL: figma.com/file//...):", - description: "You can find it in the Figma file URL", + 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.")] ) + let fileId = extractFigmaFileId(from: fileIdInput) let assetType: WizardAssetType = NooraUI.singleChoicePrompt( question: "What are you exporting?", @@ -135,15 +153,12 @@ enum FetchWizard { return promptDetails(assetType: assetType, platform: platform, defaults: defaults, fileId: fileId) } - // swiftlint:disable function_parameter_count private static func promptDetails( assetType: WizardAssetType, platform: WizardPlatform, defaults: PlatformDefaults, fileId: String ) -> FetchWizardResult { - // swiftlint:enable function_parameter_count - let pageName = promptOptionalText( question: "Filter by Figma page name?", description: "Useful when multiple pages have frames with the same name", @@ -154,7 +169,7 @@ enum FetchWizard { let frameInput = NooraUI.textPrompt( prompt: "Figma frame name (default: \(defaultFrame)):", description: "Name of the frame containing your assets. Press Enter for default." - ) + ).trimmingCharacters(in: .whitespacesAndNewlines) let frameName = frameInput.isEmpty ? defaultFrame : frameInput let sortedFormats = sortedFormats(recommended: defaults.format) @@ -168,7 +183,7 @@ enum FetchWizard { 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 let filter = promptOptionalText( diff --git a/Sources/ExFigCLI/Subcommands/InitWizard.swift b/Sources/ExFigCLI/Subcommands/InitWizard.swift index c034aa8a..d3361093 100644 --- a/Sources/ExFigCLI/Subcommands/InitWizard.swift +++ b/Sources/ExFigCLI/Subcommands/InitWizard.swift @@ -98,18 +98,20 @@ enum InitWizard { ) // 3. Figma file ID (light) - let lightFileId = NooraUI.textPrompt( - prompt: "Figma file ID (from URL: figma.com/design//...):", - description: "The file containing your design system assets", + let lightFileIdInput = NooraUI.textPrompt( + 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.")] ) + let lightFileId = extractFigmaFileId(from: lightFileIdInput) // 4. Dark mode file ID (optional) - let darkFileId = promptOptionalText( + let darkFileIdRaw = promptOptionalText( question: "Do you have a separate dark mode file?", description: "If your dark colors/images are in a different Figma file", - inputPrompt: "Dark mode file ID:" + inputPrompt: "Dark mode file ID or URL:" ) + let darkFileId = darkFileIdRaw.map { extractFigmaFileId(from: $0) } // 5. Colors source (if colors selected) let variablesConfig: InitVariablesConfig? = if selectedTypes.contains(.colors) { @@ -159,7 +161,7 @@ enum InitWizard { let input = NooraUI.textPrompt( prompt: "Figma frame name for \(assetType) (default: \(defaultName)):", description: "Name of the frame containing your \(assetType). Press Enter for default." - ) + ).trimmingCharacters(in: .whitespacesAndNewlines) return input.isEmpty ? defaultName : input } @@ -197,10 +199,10 @@ enum InitWizard { guard source == .variables else { return nil } - let tokensFileId = NooraUI.textPrompt( - prompt: "Variables file ID (default: same as light file):", + let tokensFileIdInput = NooraUI.textPrompt( + prompt: "Variables file ID or URL (default: same as light file):", description: "The Figma file containing your color variables. Press Enter to use the light file ID." - ) + ).trimmingCharacters(in: .whitespacesAndNewlines) let collectionName = NooraUI.textPrompt( prompt: "Variables collection name:", @@ -211,7 +213,7 @@ enum InitWizard { let lightModeName = NooraUI.textPrompt( prompt: "Light mode column name (default: Light):", description: "Column name for light color values. Press Enter for default." - ) + ).trimmingCharacters(in: .whitespacesAndNewlines) let darkModeName = promptOptionalText( question: "Do you have a dark mode column?", @@ -220,7 +222,7 @@ enum InitWizard { ) return InitVariablesConfig( - tokensFileId: tokensFileId.isEmpty ? lightFileId : tokensFileId, + tokensFileId: tokensFileIdInput.isEmpty ? lightFileId : extractFigmaFileId(from: tokensFileIdInput), collectionName: collectionName, lightModeName: lightModeName.isEmpty ? "Light" : lightModeName, darkModeName: darkModeName diff --git a/Sources/ExFigCLI/Subcommands/InitWizardTransform.swift b/Sources/ExFigCLI/Subcommands/InitWizardTransform.swift index 06451060..a1023242 100644 --- a/Sources/ExFigCLI/Subcommands/InitWizardTransform.swift +++ b/Sources/ExFigCLI/Subcommands/InitWizardTransform.swift @@ -148,6 +148,7 @@ extension InitWizard { } /// Remove a PKL section starting with a line matching the marker, counting braces to find the end. + /// Also strips preceding comment lines and blank lines. static func removeSection(from template: String, matching marker: String) -> String { let lines = template.components(separatedBy: "\n") var result: [String] = [] @@ -174,6 +175,12 @@ extension InitWizard { if braceDepth <= 0 { removing = false } } + // Safety: if still removing at EOF, the section was never closed — return template unchanged + if removing { + assertionFailure("removeSection: unclosed section for marker '\(marker)' — template may be malformed") + return template + } + return result.joined(separator: "\n") } diff --git a/Tests/ExFigTests/Input/DownloadOptionsTests.swift b/Tests/ExFigTests/Input/DownloadOptionsTests.swift index d0c49843..8150039f 100644 --- a/Tests/ExFigTests/Input/DownloadOptionsTests.swift +++ b/Tests/ExFigTests/Input/DownloadOptionsTests.swift @@ -48,7 +48,8 @@ final class DownloadOptionsTests: XCTestCase { "-f", "abc", "-r", "Frame", "-o", "./out", ]) - XCTAssertEqual(options.format, .png) + XCTAssertNil(options.format) + XCTAssertEqual(options.effectiveFormat, .png) } func testParsesAllFormats() throws { @@ -58,6 +59,7 @@ final class DownloadOptionsTests: XCTestCase { "--format", format.rawValue, ]) XCTAssertEqual(options.format, format) + XCTAssertEqual(options.effectiveFormat, format) } } @@ -67,6 +69,7 @@ final class DownloadOptionsTests: XCTestCase { "--format", "png", ]) + XCTAssertEqual(options.format, .png) XCTAssertNil(options.scale) XCTAssertEqual(options.effectiveScale, 3.0) } diff --git a/Tests/ExFigTests/Subcommands/FetchWizardTests.swift b/Tests/ExFigTests/Subcommands/FetchWizardTests.swift index f74264e6..6070ef3b 100644 --- a/Tests/ExFigTests/Subcommands/FetchWizardTests.swift +++ b/Tests/ExFigTests/Subcommands/FetchWizardTests.swift @@ -131,4 +131,65 @@ struct FetchWizardTests { #expect(ImageFormat.pdf.description == "PDF") #expect(ImageFormat.webp.description == "WebP") } + + // MARK: - extractFigmaFileId + + @Test("extractFigmaFileId returns bare ID as-is") + func extractBareId() { + #expect(extractFigmaFileId(from: "abc123XYZ") == "abc123XYZ") + } + + @Test("extractFigmaFileId extracts ID from /file/ URL") + func extractFromFileUrl() { + #expect(extractFigmaFileId(from: "https://www.figma.com/file/abc123/MyFile") == "abc123") + } + + @Test("extractFigmaFileId extracts ID from /design/ URL") + func extractFromDesignUrl() { + #expect(extractFigmaFileId(from: "https://www.figma.com/design/XYZ789/MyDesign?node-id=0") == "XYZ789") + } + + @Test("extractFigmaFileId trims whitespace") + func extractTrimsWhitespace() { + #expect(extractFigmaFileId(from: " abc123 ") == "abc123") + } + + @Test("extractFigmaFileId handles URL without https prefix") + func extractWithoutProtocol() { + #expect(extractFigmaFileId(from: "figma.com/design/FILEID/Title") == "FILEID") + } +} + +// MARK: - GenerateConfigFile Tests + +@Suite("GenerateConfigFile") +struct GenerateConfigFileTests { + @Test("substitutePackageURI replaces .exfig/schemas/ paths") + func substitutePackageURI() { + let template = """ + amends ".exfig/schemas/ExFig.pkl" + import ".exfig/schemas/iOS.pkl" + """ + let result = ExFigCommand.GenerateConfigFile.substitutePackageURI(in: template) + #expect(result.contains("package://github.com/DesignPipe/exfig/")) + #expect(!result.contains(".exfig/schemas/")) + } + + @Test("substitutePackageURI strips v prefix for semver") + func substitutePackageURIVersionFormat() { + let template = "amends \".exfig/schemas/ExFig.pkl\"" + let result = ExFigCommand.GenerateConfigFile.substitutePackageURI(in: template) + // Should contain both v-prefixed version and bare semver + // e.g. /download/v2.8.1/exfig@2.8.1#/ + let version = ExFigCommand.version + let semver = version.hasPrefix("v") ? String(version.dropFirst()) : version + #expect(result.contains("exfig@\(semver)#/")) + } + + @Test("substitutePackageURI preserves non-schema content") + func substitutePackageURIPreservesContent() { + let template = "// This is a comment\nsome other content" + let result = ExFigCommand.GenerateConfigFile.substitutePackageURI(in: template) + #expect(result == template) + } } diff --git a/Tests/ExFigTests/Subcommands/InitWizardTests.swift b/Tests/ExFigTests/Subcommands/InitWizardTests.swift index 30456ac8..767f2f6f 100644 --- a/Tests/ExFigTests/Subcommands/InitWizardTests.swift +++ b/Tests/ExFigTests/Subcommands/InitWizardTests.swift @@ -295,3 +295,87 @@ struct InitWizardTests { ) } } + +// MARK: - Cross-Platform Template Tests + +@Suite("InitWizard Cross-Platform") +struct InitWizardCrossPlatformTests { + @Test("applyResult works with Android template") + func androidAllSelected() { + let result = InitWizardResult( + platform: .android, + selectedAssetTypes: [.colors, .icons, .images, .typography], + lightFileId: "ANDROID_ID", + darkFileId: "ANDROID_DARK", + iconsFrameName: nil, + iconsPageName: nil, + imagesFrameName: nil, + imagesPageName: nil, + variablesConfig: nil + ) + let output = InitWizard.applyResult(result, to: androidConfigFileContents) + #expect(output.contains("ANDROID_ID")) + #expect(output.contains("ANDROID_DARK")) + #expect(output.contains("colors = new Android.ColorsEntry {")) + #expect(output.contains("icons = new Android.IconsEntry {")) + #expect(output.contains("images = new Android.ImagesEntry {")) + #expect(output.contains("typography = new Android.Typography {")) + let openCount = output.filter { $0 == "{" }.count + let closeCount = output.filter { $0 == "}" }.count + #expect(openCount == closeCount, "Unbalanced braces in Android: \(openCount) vs \(closeCount)") + } + + @Test("applyResult works with Web template (no typography)") + func webAllSelected() { + let result = InitWizardResult( + platform: .web, + selectedAssetTypes: [.colors, .icons, .images], + lightFileId: "WEB_ID", + darkFileId: nil, + iconsFrameName: "WebIcons", + iconsPageName: nil, + imagesFrameName: nil, + imagesPageName: nil, + variablesConfig: nil + ) + let output = InitWizard.applyResult(result, to: webConfigFileContents) + #expect(output.contains("WEB_ID")) + #expect(!output.contains("darkFileId")) + #expect(output.contains("colors = new Web.ColorsEntry {")) + #expect(output.contains("icons = new Web.IconsEntry {")) + #expect(output.contains("images = new Web.ImagesEntry {")) + #expect(output.contains("figmaFrameName = \"WebIcons\"")) + let openCount = output.filter { $0 == "{" }.count + let closeCount = output.filter { $0 == "}" }.count + #expect(openCount == closeCount, "Unbalanced braces in Web: \(openCount) vs \(closeCount)") + } +} + +// MARK: - Transform Utilities Tests + +@Suite("InitWizard Transform Utilities") +struct InitWizardTransformUtilityTests { + @Test("removeSection returns template unchanged when marker not found") + func removeSectionMissingMarker() { + let template = "line 1\nline 2\nline 3" + let result = InitWizard.removeSection(from: template, matching: "nonexistent marker") + #expect(result == template) + } + + @Test("collapseBlankLines collapses 3+ blank lines to 2") + func collapseBlankLines() { + let input = "a\n\n\n\n\nb" + let output = InitWizard.collapseBlankLines(input) + let blankCount = output.components(separatedBy: "\n").filter(\.isEmpty).count + #expect(blankCount <= 2) + #expect(output.contains("a")) + #expect(output.contains("b")) + } + + @Test("collapseBlankLines preserves 2 blank lines") + func collapseBlankLinesPreserves() { + let input = "a\n\n\nb" + let output = InitWizard.collapseBlankLines(input) + #expect(output == input) + } +} From 5aded4e1fcdec2a077c31c247f31e83df2f8f388 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 20 Mar 2026 21:15:39 +0500 Subject: [PATCH 10/12] chore: regenerate llms-full.txt after Usage.md update --- llms-full.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/llms-full.txt b/llms-full.txt index 79914ea4..64188aa5 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -394,7 +394,8 @@ exfig icons --resume ## Quick Fetch Download images without a configuration file. Run `exfig fetch` with no arguments for an -interactive wizard that guides you through file ID, platform, format, and output options: +interactive wizard that guides you through file ID, asset type, platform, frame selection, +format, output directory, and more: ```bash # Interactive wizard — asks platform, format, output step by step From e9a8f4b0eb6e1d9c5ab367728e732814d571695e Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 20 Mar 2026 21:16:41 +0500 Subject: [PATCH 11/12] docs: add CI llms-full.txt staleness gotcha to CLAUDE.md --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 57b93e7f..bb89b5b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -395,6 +395,7 @@ NooraUI.formatLink("url", useColors: true) // underlined primary | `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` | | PKL template word search | Template comments on `lightFileId` contain cross-refs (`variablesColors`, `typography`); test section removal by matching full markers (`colors = new Common.Colors {`) not bare words | +| CI llms-full.txt stale | `llms-full.txt` is generated from README + DocC articles; after editing `Usage.md`, `ExFig.md`, or `README.md`, run `./bin/mise run generate:llms` and commit the result | | Release build .pcm warnings | Stale `ModuleCache` — clean with: `rm -r .build/*/release/ModuleCache` then rebuild | ## Additional Rules From 62d6a04649e78099de1108cca79d4b4d9ad45597 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 20 Mar 2026 21:33:17 +0500 Subject: [PATCH 12/12] feat(hooks): add llms-check pre-commit hook for stale llms files Automatically regenerates and stages llms.txt / llms-full.txt when documentation sources (README, DocC articles, CONFIG.md) are modified. - check uses temp dir generation (parallel-safe for hk batching) - fix regenerates in-place and auto-stages via hk stage list - generate-llms.sh now accepts LLMS_TXT/LLMS_FULL env overrides --- Scripts/check-llms-freshness.sh | 16 ++++++++++++++++ Scripts/generate-llms.sh | 4 ++-- hk.pkl | 17 +++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100755 Scripts/check-llms-freshness.sh diff --git a/Scripts/check-llms-freshness.sh b/Scripts/check-llms-freshness.sh new file mode 100755 index 00000000..9181111f --- /dev/null +++ b/Scripts/check-llms-freshness.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Check if llms.txt / llms-full.txt are up to date with documentation sources. +# Generates to a temp directory (parallel-safe) and compares with repo versions. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +LLMS_TXT="$TMPDIR/llms.txt" LLMS_FULL="$TMPDIR/llms-full.txt" \ + bash Scripts/generate-llms.sh > /dev/null 2>&1 + +diff -q llms.txt "$TMPDIR/llms.txt" > /dev/null 2>&1 \ + && diff -q llms-full.txt "$TMPDIR/llms-full.txt" > /dev/null 2>&1 diff --git a/Scripts/generate-llms.sh b/Scripts/generate-llms.sh index 8f5fe439..1691319d 100755 --- a/Scripts/generate-llms.sh +++ b/Scripts/generate-llms.sh @@ -9,8 +9,8 @@ cd "$REPO_ROOT" GITHUB_BLOB="https://github.com/DesignPipe/exfig/blob/main" DOCC_BASE="https://designpipe.github.io/exfig/documentation/exfigcli" -LLMS_TXT="llms.txt" -LLMS_FULL="llms-full.txt" +LLMS_TXT="${LLMS_TXT:-llms.txt}" +LLMS_FULL="${LLMS_FULL:-llms-full.txt}" # ── Header (shared) ────────────────────────────────────────────────────────── diff --git a/hk.pkl b/hk.pkl index 9faa0ad4..981821a8 100644 --- a/hk.pkl +++ b/hk.pkl @@ -89,6 +89,22 @@ local builtin_checks = new Mapping { } } +// ============================================================================= +// Generated File Checks +// ============================================================================= + +local generated_checks = new Mapping { + // Regenerate llms.txt / llms-full.txt when documentation sources change. + // check generates to a temp dir (parallel-safe), fix writes to repo. + ["llms-check"] { + glob = List("README.md", "Sources/ExFigCLI/ExFig.docc/**/*.md", "CONFIG.md", "docs/*.md") + check = "bash Scripts/check-llms-freshness.sh" + fix = "bash Scripts/generate-llms.sh" + stage = List("llms.txt", "llms-full.txt") + hide = true + } +} + // ============================================================================= // Combined Linters // ============================================================================= @@ -97,6 +113,7 @@ local all_linters = new Mapping { ...swift_linters ...dprint_checks ...builtin_checks + ...generated_checks } // =============================================================================