diff --git a/CLAUDE.md b/CLAUDE.md index a2417a9c..bb89b5b5 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: @@ -260,6 +267,22 @@ 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 +- 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 + +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. @@ -282,6 +305,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 @@ -339,33 +366,37 @@ 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 | +| CLI flag default vs absent | swift-argument-parser can't distinguish explicit `--flag default_value` from omitted. Use `Optional` + computed `effectiveX` property for flags that wizard may override | +| MCP `Client` ambiguous | `FigmaAPI.Client` vs `MCP.Client` — always use `FigmaAPI.Client` in MCP/ files | +| MCP `FigmaConfig` fields | No `colorsFileId` — use `config.getFileIds()` or `figma.lightFileId`/`darkFileId` | +| `distantFuture` on clock | `ContinuousClock.Instant` has no `distantFuture`; use `withCheckedContinuation { _ in }` for infinite suspend | +| MCP stderr duplication | `TerminalOutputManager.setStderrMode(true)` handles all output routing — don't duplicate in `ExFigLogHandler` | +| 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 diff --git a/README.md b/README.md index 969a229e..1f88bdf2 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,11 @@ brew install designpipe/tap/exfig # 2. Set Figma token export FIGMA_PERSONAL_TOKEN=your_token_here -# 3. Generate config and export -exfig init -p ios +# 3a. Quick one-off export (interactive wizard) +exfig fetch + +# 3b. Or generate config for full pipeline (interactive wizard) +exfig init exfig batch exfig.pkl ``` 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/Sources/ExFigCLI/CLAUDE.md b/Sources/ExFigCLI/CLAUDE.md index ca05db30..24a22b0f 100644 --- a/Sources/ExFigCLI/CLAUDE.md +++ b/Sources/ExFigCLI/CLAUDE.md @@ -197,6 +197,27 @@ 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 + +**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 +- **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/ExFig.docc/ExFig.md b/Sources/ExFigCLI/ExFig.docc/ExFig.md index 0a8c90d7..88dba8a1 100644 --- a/Sources/ExFigCLI/ExFig.docc/ExFig.md +++ b/Sources/ExFigCLI/ExFig.docc/ExFig.md @@ -35,7 +35,8 @@ 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. +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 014f14db..115eb664 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: @@ -141,16 +157,24 @@ 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, asset type, platform, frame selection, +format, output directory, and more: ```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 diff --git a/Sources/ExFigCLI/Input/DownloadOptions.swift b/Sources/ExFigCLI/Input/DownloadOptions.swift index 3f56076b..a9c4f1f4 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 @@ -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 @@ -186,8 +191,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..0e08cd46 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 == nil { + 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.effectiveFormat.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.effectiveFormat, + 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.effectiveFormat, dark: false, - darkModeSuffix: downloadOptions.darkModeSuffix + darkModeSuffix: resolvedOptions.darkModeSuffix ) if let darkPacks { allFiles += DownloadImageProcessor.createFileContents( from: darkPacks, outputURL: outputURL, - format: downloadOptions.format, + format: resolvedOptions.effectiveFormat, 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.effectiveFormat == .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..5143e690 --- /dev/null +++ b/Sources/ExFigCLI/Subcommands/FetchWizard.swift @@ -0,0 +1,224 @@ +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 + } + + /// 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. +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: - 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. +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 fileIdInput = NooraUI.textPrompt( + title: "Figma Export Wizard", + prompt: "Figma file ID or URL (figma.com/design//...):", + description: "Paste the file URL or just the ID from it", + validationRules: [NonEmptyValidationRule(error: "File ID cannot be empty.")] + ) + let fileId = extractFigmaFileId(from: fileIdInput) + + 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) + } + + private static func promptDetails( + assetType: WizardAssetType, + platform: WizardPlatform, + defaults: PlatformDefaults, + fileId: String + ) -> FetchWizardResult { + 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." + ).trimmingCharacters(in: .whitespacesAndNewlines) + 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." + ).trimmingCharacters(in: .whitespacesAndNewlines) + 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/Subcommands/GenerateConfigFile.swift b/Sources/ExFigCLI/Subcommands/GenerateConfigFile.swift index ac4f655a..dda6833b 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,54 @@ extension ExFigCommand { if !result { return } } - // Extract PKL schemas for local validation - let extractedSchemas = try SchemaExtractor.extract() + // Determine file contents: wizard or direct template + let rawContents: String + let wizardResult: InitWizardResult? + + if let platform { + // Direct flag — use full template + rawContents = templateForPlatform(platform) + wizardResult = nil + } else if TTYDetector.isTTY { + // Interactive wizard + let result = InitWizard.run() + let template = templateForPlatform(result.platform) + 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.") + } + + // 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) + try writeConfigFile( + contents: fileContents, + to: destination, + ui: ui, + 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 { + case .android: androidConfigFileContents + case .ios: iosConfigFileContents + case .flutter: flutterConfigFileContents + case .web: webConfigFileContents + } } /// Handles existing file: prompts for confirmation and removes if approved. @@ -66,17 +105,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 } @@ -97,7 +133,7 @@ extension ExFigCommand { contents: String, to destination: String, ui: TerminalUI, - extractedSchemas: [String] = [] + wizardResult: InitWizardResult? = nil ) throws { guard let fileData = contents.data(using: .utf8) else { throw ExFigError.custom(errorString: "Failed to encode config file contents") @@ -106,30 +142,36 @@ 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:") - 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..d3361093 --- /dev/null +++ b/Sources/ExFigCLI/Subcommands/InitWizard.swift @@ -0,0 +1,231 @@ +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: - 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. +struct InitWizardResult { + let platform: Platform + let selectedAssetTypes: [InitAssetType] + 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 { + // 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 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 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 or URL:" + ) + let darkFileId = darkFileIdRaw.map { extractFigmaFileId(from: $0) } + + // 5. Colors source (if colors selected) + let variablesConfig: InitVariablesConfig? = if selectedTypes.contains(.colors) { + promptColorsSource(lightFileId: lightFileId) + } else { + nil + } + + // 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 { + 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( + platform: platform, + selectedAssetTypes: selectedTypes, + lightFileId: lightFileId, + darkFileId: darkFileId, + iconsFrameName: iconsFrameName, + iconsPageName: iconsPageName, + imagesFrameName: imagesFrameName, + imagesPageName: imagesPageName, + variablesConfig: variablesConfig + ) + } + + // MARK: - Prompt 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." + ).trimmingCharacters(in: .whitespacesAndNewlines) + 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.")] + ) + } + + 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:" + ) + } + + 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" + ) + + guard source == .variables else { return nil } + + 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:", + description: "The name of the variable collection in Figma (e.g., 'Primitives', 'Base collection')", + validationRules: [NonEmptyValidationRule(error: "Collection name cannot be empty.")] + ) + + 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?", + description: "Column name for dark color values in the variables table", + inputPrompt: "Dark mode column name:" + ) + + return InitVariablesConfig( + 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 new file mode 100644 index 00000000..a1023242 --- /dev/null +++ b/Sources/ExFigCLI/Subcommands/InitWizardTransform.swift @@ -0,0 +1,375 @@ +// 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. + /// 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] = [] + 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 } + } + + // 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") + } + + // 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/Sources/ExFigCLI/TerminalUI/NooraUI.swift b/Sources/ExFigCLI/TerminalUI/NooraUI.swift index 35defb53..42902f70 100644 --- a/Sources/ExFigCLI/TerminalUI/NooraUI.swift +++ b/Sources/ExFigCLI/TerminalUI/NooraUI.swift @@ -135,6 +135,107 @@ 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 + ) + } + + /// 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/Input/DownloadOptionsTests.swift b/Tests/ExFigTests/Input/DownloadOptionsTests.swift index 0402cb80..8150039f 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 @@ -41,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 { @@ -51,6 +59,7 @@ final class DownloadOptionsTests: XCTestCase { "--format", format.rawValue, ]) XCTAssertEqual(options.format, format) + XCTAssertEqual(options.effectiveFormat, format) } } @@ -60,6 +69,7 @@ final class DownloadOptionsTests: XCTestCase { "--format", "png", ]) + XCTAssertEqual(options.format, .png) XCTAssertNil(options.scale) XCTAssertEqual(options.effectiveScale, 3.0) } @@ -272,6 +282,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..6070ef3b --- /dev/null +++ b/Tests/ExFigTests/Subcommands/FetchWizardTests.swift @@ -0,0 +1,195 @@ +@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") + } + + // 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 new file mode 100644 index 00000000..767f2f6f --- /dev/null +++ b/Tests/ExFigTests/Subcommands/InitWizardTests.swift @@ -0,0 +1,381 @@ +@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: 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") + 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 {")) + #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, + iconsPageName: nil, + imagesFrameName: nil, + imagesPageName: nil, + variablesConfig: 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 {")) + #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, + iconsPageName: String? = nil, + imagesFrameName: String? = nil, + imagesPageName: String? = nil, + variablesConfig: InitVariablesConfig? = nil + ) -> InitWizardResult { + InitWizardResult( + platform: platform, + selectedAssetTypes: selectedAssetTypes, + lightFileId: lightFileId, + darkFileId: darkFileId, + iconsFrameName: iconsFrameName, + iconsPageName: iconsPageName, + imagesFrameName: imagesFrameName, + imagesPageName: imagesPageName, + variablesConfig: variablesConfig + ) + } +} + +// 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) + } +} diff --git a/exfig.usage.kdl b/exfig.usage.kdl index 291d4ded..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" } } @@ -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" } 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 } // ============================================================================= diff --git a/llms-full.txt b/llms-full.txt index 89765446..64188aa5 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -43,8 +43,11 @@ brew install designpipe/tap/exfig # 2. Set Figma token export FIGMA_PERSONAL_TOKEN=your_token_here -# 3. Generate config and export -exfig init -p ios +# 3a. Quick one-off export (interactive wizard) +exfig fetch + +# 3b. Or generate config for full pipeline (interactive wizard) +exfig init exfig batch exfig.pkl ``` @@ -255,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: @@ -374,16 +393,24 @@ 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, asset type, platform, frame selection, +format, output directory, and more: ```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