diff --git a/.github/app-release-secrets.md b/.github/app-release-secrets.md new file mode 100755 index 00000000..20877304 --- /dev/null +++ b/.github/app-release-secrets.md @@ -0,0 +1,122 @@ +# ExFig Studio Release Secrets Configuration + +This document describes the GitHub secrets required for automated ExFig Studio releases. + +## Required Secrets + +| Secret | Description | Required For | +| ----------------------------- | --------------------------------------------------- | -------------------- | +| `APPLE_TEAM_ID` | Apple Developer Team ID (10-character alphanumeric) | Code signing | +| `APPLE_IDENTITY_NAME` | Code signing identity name (e.g., "Your Name") | Code signing | +| `APPLE_CERTIFICATE_BASE64` | Developer ID certificate (.p12) as base64 | Code signing | +| `APPLE_CERTIFICATE_PASSWORD` | Password for the .p12 certificate | Code signing | +| `APPLE_ID` | Apple ID email for notarization | Notarization | +| `APPLE_NOTARIZATION_PASSWORD` | App-specific password for notarization | Notarization | +| `HOMEBREW_TAP_TOKEN` | GitHub PAT with repo access to homebrew-exfig | Homebrew Cask update | + +## Setup Instructions + +### 1. Export Developer ID Certificate + +```bash +# Open Keychain Access, find "Developer ID Application" certificate +# Right-click → Export → Save as .p12 with password + +# Encode to base64 +base64 -i DeveloperID.p12 | pbcopy +# Paste into APPLE_CERTIFICATE_BASE64 secret +``` + +### 2. Create App-Specific Password + +1. Go to [appleid.apple.com](https://appleid.apple.com) +2. Sign in and navigate to Security → App-Specific Passwords +3. Generate a new password for "ExFig Studio Notarization" +4. Save as `APPLE_NOTARIZATION_PASSWORD` secret + +### 3. Create Homebrew Tap Token + +1. Go to [github.com/settings/tokens](https://github.com/settings/tokens) +2. Create a new PAT (classic) with `repo` scope +3. Save as `HOMEBREW_TAP_TOKEN` secret + +### 4. Find Your Team ID + +```bash +# If you have Xcode installed +security find-identity -v -p codesigning | grep "Developer ID Application" +# Team ID is in parentheses: "Developer ID Application: Name (TEAM_ID)" +``` + +## Release Process + +### Create a Release + +```bash +# Tag format: studio-v.. +git tag studio-v1.0.0 +git push origin studio-v1.0.0 +``` + +### Manual Build (for testing) + +```bash +# Local build without signing +./Scripts/build-app-release.sh + +# Local build with signing +APPLE_TEAM_ID=YOUR_TEAM_ID ./Scripts/build-app-release.sh +``` + +### Workflow Dispatch + +You can also trigger a release manually from the GitHub Actions tab: + +1. Go to Actions → Release App +2. Click "Run workflow" +3. Enter the version number +4. Optionally skip notarization for testing + +## Homebrew Cask + +After a successful release, the workflow automatically updates the Homebrew Cask formula at: +`alexey1312/homebrew-exfig/Casks/exfig-studio.rb` + +Users can install with: + +```bash +brew tap alexey1312/exfig +brew install --cask exfig-studio +``` + +## Troubleshooting + +### Certificate Issues + +```bash +# Verify certificate is in keychain +security find-identity -v -p codesigning + +# Verify certificate chain +codesign -vvv --deep "dist/ExFig Studio.app" +``` + +### Notarization Failures + +```bash +# Check notarization status +xcrun notarytool history --apple-id YOUR_APPLE_ID --team-id YOUR_TEAM_ID + +# Get detailed log for a submission +xcrun notarytool log SUBMISSION_ID --apple-id YOUR_APPLE_ID --team-id YOUR_TEAM_ID +``` + +### Gatekeeper Issues + +```bash +# Verify app is properly signed and notarized +spctl --assess --verbose "dist/ExFig Studio.app" + +# Check stapling +xcrun stapler validate "dist/ExFig Studio.app" +``` diff --git a/.github/workflows/deploy-docc.yml b/.github/workflows/deploy-docc.yml index 7f9fa9db..7b4235e2 100644 --- a/.github/workflows/deploy-docc.yml +++ b/.github/workflows/deploy-docc.yml @@ -1,8 +1,10 @@ name: Deploy DocC on: - release: - types: [published] + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + - 'v[0-9]+.[0-9]+.[0-9]+-*' workflow_dispatch: permissions: diff --git a/CLAUDE.md b/CLAUDE.md index 67921a00..a4655edb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,7 +98,7 @@ pkl eval --format json # Package URI requires published package ## Architecture -Fourteen modules in `Sources/`: +Twelve modules in `Sources/`: | Module | Purpose | | --------------- | --------------------------------------------------------- | @@ -117,6 +117,7 @@ Fourteen modules in `Sources/`: **Data flow:** CLI -> PKL config parsing -> FigmaAPI (external) fetch -> ExFigCore processing -> Platform plugin -> Export module -> File write **Alt data flow (tokens):** CLI -> local .tokens.json file -> TokensFileSource -> ExFigCore models -> W3C JSON export +**Alt data flow (penpot):** CLI -> PenpotAPI fetch -> Penpot*Source -> ExFigCore models -> Platform plugin -> Export module -> File write **MCP data flow:** `exfig mcp` → StdioTransport (JSON-RPC on stdin/stdout) → tool handlers → PKLEvaluator / TokensFileSource / FigmaAPI **MCP stdout safety:** `OutputMode.mcp` + `TerminalOutputManager.setStderrMode(true)` — all CLI output goes to stderr @@ -142,7 +143,7 @@ Sources/ExFigCLI/ ├── Sync/ # Figma sync functionality (state tracking, diff detection) ├── Plugin/ # Plugin registry ├── Context/ # Export context implementations (ColorsExportContextImpl, etc.) -├── Source/ # Design source implementations (FigmaColorsSource, SourceFactory, etc.) +├── Source/ # Design source implementations (SourceFactory, Figma*Source, Penpot*Source, TokensFile*Source) ├── MCP/ # Model Context Protocol server (tools, resources, prompts) └── Shared/ # Cross-cutting helpers (PlatformExportResult, HashMerger) @@ -226,7 +227,7 @@ When relocating a type (e.g., `Android.WebpOptions` → `Common.WebpOptions`), u 3. Swift bridging (`Sources/ExFig-*/Config/*Entry.swift`) — typealiases + extensions 4. Init-template configs (`Sources/ExFigCLI/Resources/*Config.swift`) — `new Type { }` refs 5. PKL examples (`Schemas/examples/*.pkl`) -6. DocC docs (`ExFig.docc/**/*.md`), CONFIG.md, MIGRATION.md +6. DocC docs (`ExFig.docc/**/*.md`), CONFIG.md ### Module Boundaries @@ -234,6 +235,19 @@ ExFigCore does NOT import FigmaAPI. Constants on `Component` (FigmaAPI, extended not accessible from ExFigCore types (`IconsSourceInput`, `ImagesSourceInput`). Keep default values as string literals in ExFigCore inits; use shared constants only within ExFigCLI. +ExFigConfig imports ExFigCore but NOT ExFigCLI — `ExFigError` is not available. Use `ColorsConfigError` (ExFigCore) for validation errors. + +### Modifying SourceFactory Signatures + +`createComponentsSource` has 8 call sites (4 in `PluginIconsExport` + 4 in `PluginImagesExport`) plus tests in `PenpotSourceTests.swift`. +`createTypographySource` call sites: only tests (not yet wired to production export flow). +Use `replace_all` on the trailing parameter pattern (e.g., `filter: filter\n )`) to update all sites at once. + +### Source-Aware File ID Resolution (SourceKindBridging) + +`resolvedFileId` must be source-kind-aware: when `resolvedSourceKind == .penpot`, return ONLY `penpotSource?.fileId` (not coalescent `?? figmaFileId`). +Passing a Figma file key to Penpot API causes cryptic UUID parse errors. Same principle applies to any future source-specific identifiers. + ### RTL Detection Design - `Component.iconName`: uses `containingComponentSet.name` for variants, own `name` otherwise @@ -280,6 +294,12 @@ Follow `InitWizard.swift` / `FetchWizard.swift` pattern: - Use `extractFigmaFileId(from:)` for file ID inputs (auto-extracts ID from full Figma URLs) - Trim text prompt results with `.trimmingCharacters(in: .whitespacesAndNewlines)` before `.isEmpty` default checks +#### Design Source Branching + +Both `InitWizard` and `FetchWizard` ask "Figma or Penpot?" first (`WizardDesignSource` enum in `FetchWizard.swift`). +`extractPenpotFileId(from:)` extracts UUID from Penpot workspace URLs (`file-id=UUID` query param). +`InitWizardTransform` has separate methods: `applyResult` (Figma) and `applyPenpotResult` (Penpot — removes figma section, inserts penpotSource blocks). + ### Adding a NooraUI Prompt Wrapper Follow the existing pattern in `NooraUI.swift`: static method delegating to `shared` instance with matching parameter names. @@ -295,6 +315,30 @@ FigmaAPI is now an external package (`swift-figma-api`). See its repository for This enables per-entry `sourceKind` — different entries in one config can use different sources. Do NOT inject `colorsSource` at context construction time — it breaks multi-source configs. +### Lazy Figma Client Pattern + +`resolveClient(accessToken:...)` accepts `String?`. When nil (no `FIGMA_PERSONAL_TOKEN`), returns `NoTokenFigmaClient()` — a fail-fast client that throws `accessTokenNotFound` on any request. Non-Figma sources never call it. `SourceFactory` also guards the `.figma` branch. This avoids making `Client?` cascade through 20+ type signatures. + +### Penpot Source Patterns + +- `PenpotClientFactory.makeClient(baseURL:)` — shared factory in `Source/PenpotClientFactory.swift`. Returns `any PenpotClient` (protocol, not `BasePenpotClient`) for testability. All Penpot sources use this (NOT a static on any single source). +- `PenpotShape.ShapeType` enum — `.path`, `.rect`, `.circle`, `.group`, `.frame`, `.bool`, `.unknown(String)`. Exhaustive switch in renderer (no `default` branch). +- `PenpotComponent.MainInstance` struct — pairs `id` + `page` (both or neither). Computed properties `mainInstanceId`/`mainInstancePage` for backward compat. +- `PenpotShapeRenderer.renderSVGResult()` — returns `Result` with `skippedShapeTypes` and typed failure reasons. `renderSVG()` is a convenience wrapper. +- Dictionary iteration from Penpot API (`colors`, `typographies`, `components`) must be sorted by key for deterministic export order: `.sorted(by: { $0.key < $1.key })`. +- `exfig fetch --source penpot` — `FetchSource` enum in `DownloadOptions.swift`. Route: `--source` flag > wizard result > default `.figma`. Also `--penpot-base-url` for self-hosted. +- Penpot fetch supports only `svg` and `png` formats — unsupported formats (pdf, webp, jpg) throw an error. +- Download commands (`download all/colors/icons/images/typography`) are **Figma-only** by design. Penpot export uses `exfig colors/icons/images` (via SourceFactory) and `exfig fetch --source penpot`. + +### Entry Bridge Source Kind Resolution + +Entry bridge methods (`iconsSourceInput()`, `imagesSourceInput()`) use `resolvedSourceKind` (computed property on `Common_FrameSource`) +instead of `sourceKind?.coreSourceKind ?? .figma`. This auto-detects Penpot when `penpotSource` is set. +`Common_VariablesSource` has its own `resolvedSourceKind` in `VariablesSourceValidation.swift` (includes tokensFile + penpot detection). + +Entry bridge methods also use `resolvedFileId` (`penpotSource?.fileId ?? figmaFileId`) and `resolvedPenpotBaseURL` +(`penpotSource?.baseUrl`) from `SourceKindBridging.swift` to pass source-specific values through flat SourceInput fields. + ### Adding a Platform Plugin Exporter See `ExFigCore/CLAUDE.md` (Modification Checklist) and platform module CLAUDE.md files. @@ -323,8 +367,9 @@ When changing fields on `ColorsSourceInput` / `IconsSourceInput` / `ImagesSource | README.md | Keep compact (~80 lines, pain-driven) | Detailed docs (use CONFIG.md / DocC) | **Documentation structure:** README is a short pain-driven intro (~80 lines). Detailed docs live in DocC articles -(`Sources/ExFigCLI/ExFig.docc/`). When adding new features, mention briefly in README Quick Start AND update -relevant DocC articles (`Usage.md` for CLI, `ExFig.md` landing page for capabilities). +(`Sources/ExFigCLI/ExFig.docc/`). Architecture, PKL Guide, and Migration are also DocC articles. +`docs/` is DocC OUTPUT (gitignored, for GitHub Pages) — never put source docs there. +When adding new features, mention briefly in README Quick Start AND update relevant DocC articles. **JSONCodec usage:** @@ -362,65 +407,80 @@ NooraUI.formatLink("url", useColors: true) // underlined primary ## Dependencies -| Package | Version | Purpose | -| --------------------- | ------- | -------------------------------------------------- | -| swift-argument-parser | 1.5.0+ | CLI framework | -| swift-collections | 1.2.x | Ordered collections | -| swift-jinja | 2.0.0+ | Jinja2 template engine | -| XcodeProj | 8.27.0+ | Xcode project manipulation | -| swift-log | 1.6.0+ | Logging | -| Rainbow | 4.2.0+ | Terminal colors | -| libwebp | 1.4.1+ | WebP encoding | -| libpng | 1.6.45+ | PNG decoding | -| swift-custom-dump | 1.3.0+ | Test assertions | -| Noora | 0.54.0+ | Terminal UI design system | -| swift-figma-api | 0.2.0+ | Figma REST API client (async/await, rate limiting) | -| swift-svgkit | 0.1.0+ | SVG parsing, ImageVector/VectorDrawable generation | -| swift-resvg | 0.45.1 | SVG parsing/rendering | -| swift-docc-plugin | 1.4.5+ | DocC documentation | -| swift-yyjson | 0.5.0+ | High-performance JSON codec | -| pkl-swift | 0.8.0+ | PKL config evaluation & codegen | +| Package | Version | Purpose | +| --------------------- | ------- | ------------------------------------------------------- | +| swift-argument-parser | 1.5.0+ | CLI framework | +| swift-collections | 1.2.x | Ordered collections | +| swift-jinja | 2.0.0+ | Jinja2 template engine | +| XcodeProj | 8.27.0+ | Xcode project manipulation | +| swift-log | 1.6.0+ | Logging | +| Rainbow | 4.2.0+ | Terminal colors | +| libwebp | 1.4.1+ | WebP encoding | +| libpng | 1.6.45+ | PNG decoding | +| swift-custom-dump | 1.3.0+ | Test assertions | +| Noora | 0.54.0+ | Terminal UI design system | +| swift-figma-api | 0.2.0+ | Figma REST API client (async/await, rate limiting) | +| swift-penpot-api | 0.1.0+ | Penpot RPC API client (async/await, SVG reconstruction) | +| swift-svgkit | 0.1.0+ | SVG parsing, ImageVector/VectorDrawable generation | +| swift-resvg | 0.45.1 | SVG parsing/rendering | +| swift-docc-plugin | 1.4.5+ | DocC documentation | +| swift-yyjson | 0.5.0+ | High-performance JSON codec | +| pkl-swift | 0.8.0+ | PKL config evaluation & codegen | ## Troubleshooting -| Problem | Solution | -| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| codegen:pkl gen.pkl error | gen.pkl `read?` bug: needs `--generator-settings` + `--project-dir` flags (see mise.toml) | -| xcsift "signal code 5" | False positive when piping `swift test` through xcsift; run `swift test` directly to verify | -| PKL tests need Pkl 0.31+ | Schemas use `isNotEmpty`; run tests via `./bin/mise exec -- swift test` to get correct Pkl in PATH | -| PKL FrameSource change | Update ALL entry init calls in tests (EnumBridgingTests, IconsLoaderConfigTests) | -| Build fails | `swift package clean && swift build` | -| Tests fail | Check `FIGMA_PERSONAL_TOKEN` is set | -| Formatting fails | Run `./bin/mise run setup` to install tools | -| test:filter no matches | SPM converts hyphens→underscores: use `ExFig_FlutterTests` not `ExFig-FlutterTests` | -| Template errors | Check Jinja2 syntax and context variables | -| Linux test hangs | Build first: `swift build --build-tests`, then `swift test --skip-build --parallel` | -| Android pathData long | Simplify in Figma or use `--strict-path-validation` | -| PKL parse error 1 | Check `PklError.message` — actual error is in `.message`, not `.localizedDescription` | -| Test target won't compile | Broken test files block entire target; use `swift test --filter Target.Class` after `build` | -| Test helper JSON decode | `ContainingFrame` uses default Codable (camelCase: `nodeId`, `pageName`), NOT snake_case | -| Web entry test fails | Web entry types use `outputDirectory` field, while Android/Flutter use `output` | -| Logger concatenation err | `Logger.Message` (swift-log) requires interpolation `"\(a) \(b)"`, not concatenation `a + b` | -| Deleted variables in output | Filter `VariableValue.deletedButReferenced != true` in variable loaders AND `CodeSyntaxSyncer` | -| mise "sources up-to-date" | mise caches tasks with `sources`/`outputs` — run script directly via `bash` when debugging | -| Jinja trailing `\n` | `{% if false %}...{% endif %}\n` renders `"\n"`, not `""` — strip whitespace-only partial template results | -| `Bundle.module` in tests | SPM test targets without declared resources don't have `Bundle.module` — use `Bundle.main` or temp bundle | -| SwiftLint trailing closure | When function takes 2+ closures, use explicit label for last closure (`export: { ... }`) not trailing syntax | -| CLI flag default vs absent | swift-argument-parser can't distinguish explicit `--flag default_value` from omitted. Use `Optional` + computed `effectiveX` property for flags that wizard may override | -| MCP `Client` ambiguous | `FigmaAPI.Client` vs `MCP.Client` — always use `FigmaAPI.Client` in MCP/ files | -| MCP `FigmaConfig` fields | No `colorsFileId` — use `config.getFileIds()` or `figma.lightFileId`/`darkFileId` | -| `distantFuture` on clock | `ContinuousClock.Instant` has no `distantFuture`; use `withCheckedContinuation { _ in }` for infinite suspend | -| MCP stderr duplication | `TerminalOutputManager.setStderrMode(true)` handles all output routing — don't duplicate in `ExFigLogHandler` | -| MCP `Process` race condition | Set `terminationHandler` BEFORE `process.run()` — process may exit before handler is installed, hanging the continuation forever | -| MCP pipe deadlock | Read stderr via concurrent `Task` BEFORE waiting for termination — pipe buffer (~64KB) can fill and block the subprocess | -| MCP `encodeJSON` errors | Use `throws` not `try?` — silently returning `"\(value)"` (Swift debug dump) breaks JSON consumers; top-level `do/catch` in `handle()` catches automatically | -| `VariablesColors` vs `Colors` | `ColorsVariablesLoader` takes `Common.VariablesColors?`, not `Common.Colors?` — different PKL types | -| PKL template word search | Template comments on `lightFileId` contain cross-refs (`variablesColors`, `typography`); test section removal by matching full markers (`colors = new Common.Colors {`) not bare words | -| CI llms-full.txt stale | `llms-full.txt` is generated from README + DocC articles; after editing `Usage.md`, `ExFig.md`, or `README.md`, run `./bin/mise run generate:llms` and commit the result | -| Release build .pcm warnings | Stale `ModuleCache` — clean with: `rm -r .build/*/release/ModuleCache` then rebuild | -| `nil` in switch expression | After adding enum case, `nil` in `String?` switch branch fails to compile | -| PKL↔Swift enum rawValue | PKL kebab `"tokens-file"` → `.tokensFile`, but Swift rawValue is `"tokensFile"` — rawValue round-trip fails | -| `unsupportedSourceKind` compile err | Changed to `.unsupportedSourceKind(kind, assetType:)` — add asset type string ("colors", "icons/images", "typography") | +| Problem | Solution | +| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| codegen:pkl gen.pkl error | gen.pkl `read?` bug: needs `--generator-settings` + `--project-dir` flags (see mise.toml) | +| xcsift "signal code 5" | False positive when piping `swift test` through xcsift; run `swift test` directly to verify | +| PKL tests need Pkl 0.31+ | Schemas use `isNotEmpty`; run tests via `./bin/mise exec -- swift test` to get correct Pkl in PATH | +| PKL FrameSource change | Update ALL entry init calls in tests (EnumBridgingTests, IconsLoaderConfigTests) | +| Build fails | `swift package clean && swift build` | +| Tests fail | Check `FIGMA_PERSONAL_TOKEN` is set | +| Formatting fails | Run `./bin/mise run setup` to install tools | +| test:filter no matches | SPM converts hyphens→underscores: use `ExFig_FlutterTests` not `ExFig-FlutterTests` | +| Template errors | Check Jinja2 syntax and context variables | +| Linux test hangs | Build first: `swift build --build-tests`, then `swift test --skip-build --parallel` | +| Android pathData long | Simplify in Figma or use `--strict-path-validation` | +| PKL parse error 1 | Check `PklError.message` — actual error is in `.message`, not `.localizedDescription` | +| Test target won't compile | Broken test files block entire target; use `swift test --filter Target.Class` after `build` | +| Test helper JSON decode | `ContainingFrame` uses default Codable (camelCase: `nodeId`, `pageName`), NOT snake_case | +| Web entry test fails | Web entry types use `outputDirectory` field, while Android/Flutter use `output` | +| Logger concatenation err | `Logger.Message` (swift-log) requires interpolation `"\(a) \(b)"`, not concatenation `a + b` | +| Deleted variables in output | Filter `VariableValue.deletedButReferenced != true` in variable loaders AND `CodeSyntaxSyncer` | +| mise "sources up-to-date" | mise caches tasks with `sources`/`outputs` — run script directly via `bash` when debugging | +| Jinja trailing `\n` | `{% if false %}...{% endif %}\n` renders `"\n"`, not `""` — strip whitespace-only partial template results | +| `Bundle.module` in tests | SPM test targets without declared resources don't have `Bundle.module` — use `Bundle.main` or temp bundle | +| SwiftLint trailing closure | When function takes 2+ closures, use explicit label for last closure (`export: { ... }`) not trailing syntax | +| CLI flag default vs absent | swift-argument-parser can't distinguish explicit `--flag default_value` from omitted. Use `Optional` + computed `effectiveX` property for flags that wizard may override | +| MCP `Client` ambiguous | `FigmaAPI.Client` vs `MCP.Client` — always use `FigmaAPI.Client` in MCP/ files | +| MCP `FigmaConfig` fields | No `colorsFileId` — use `config.getFileIds()` or `figma.lightFileId`/`darkFileId` | +| `distantFuture` on clock | `ContinuousClock.Instant` has no `distantFuture`; use `withCheckedContinuation { _ in }` for infinite suspend | +| MCP stderr duplication | `TerminalOutputManager.setStderrMode(true)` handles all output routing — don't duplicate in `ExFigLogHandler` | +| MCP `Process` race condition | Set `terminationHandler` BEFORE `process.run()` — process may exit before handler is installed, hanging the continuation forever | +| MCP pipe deadlock | Read stderr via concurrent `Task` BEFORE waiting for termination — pipe buffer (~64KB) can fill and block the subprocess | +| MCP `encodeJSON` errors | Use `throws` not `try?` — silently returning `"\(value)"` (Swift debug dump) breaks JSON consumers; top-level `do/catch` in `handle()` catches automatically | +| `VariablesColors` vs `Colors` | `ColorsVariablesLoader` takes `Common.VariablesColors?`, not `Common.Colors?` — different PKL types | +| PKL template word search | Template comments on `lightFileId` contain cross-refs (`variablesColors`, `typography`); test section removal by matching full markers (`colors = new Common.Colors {`) not bare words | +| CI llms-full.txt stale | `llms-full.txt` is generated from README + DocC articles; after editing `Usage.md`, `ExFig.md`, or `README.md`, run `./bin/mise run generate:llms` and commit the result | +| `docs/` source files lost | `docs/` is gitignored (DocC output). Source docs live in `ExFig.docc/`. Never `git add -f` to docs/ | +| Release build .pcm warnings | Stale `ModuleCache` — clean with: `rm -r .build/*/release/ModuleCache` then rebuild | +| `nil` in switch expression | After adding enum case, `nil` in `String?` switch branch fails to compile | +| `ColorsConfigError` new case | Has TWO switch blocks (`errorDescription` + `recoverySuggestion`) — adding a case to one without the other causes exhaustive switch error. Adding associated value breaks auto-`Equatable` — add explicit `: Equatable` (tests use `XCTAssertEqual`) | +| `ExFigConfig` → `ExFigError` | `ExFigError` lives in ExFigCLI, not ExFigConfig. Use `ColorsConfigError` (ExFigCore) for validation errors in `VariablesSourceValidation.swift` | +| PKL↔Swift enum rawValue | PKL kebab `"tokens-file"` → `.tokensFile`, but Swift rawValue is `"tokensFile"` — rawValue round-trip fails | +| `unsupportedSourceKind` compile err | Changed to `.unsupportedSourceKind(kind, assetType:)` — add asset type string ("colors", "icons/images", "typography") | +| `JSONCodec` in standalone module | `JSONCodec` lives in ExFigCore — external packages (swift-penpot-api) use `YYJSONEncoder()`/`YYJSONDecoder()` from YYJSON directly | +| `function_body_length` after branch | Split into private extension helper methods (e.g., `penpotColorsSourceInput()`, `tokensFileColorsSourceInput()`) | +| `ExFigCommand.terminalUI` in tests | Implicitly unwrapped — must init in `setUp()`: `ExFigCommand.terminalUI = TerminalUI(outputMode: .quiet)` before testing code that uses it (SourceFactory, Penpot sources) | +| `--timeout` duplicate in `fetch` | `FetchImages` uses both `DownloadOptions` and `HeavyFaultToleranceOptions` which both define `--timeout`. Fix: inline Heavy options + computed property | +| DocC articles not in Bundle.module | `.docc` articles aren't copied to SPM bundle — use `Resources/Guides/` with `.copy()` for MCP-served content | +| Penpot `update-file` changes format | Flat `changes[]` array, `type` dispatch, needs `vern` field. Shapes need `parentId`, `frameId`, `selrect`, `points`, `transform`. Undocumented — use validation errors | +| Switch expression + `return` | When any switch branch has side-effects before `return`, use explicit `return` on ALL branches — implicit return breaks type inference | +| Lazy Figma token validation | `ExFigOptions.validate()` reads token without throwing; `resolveClient()` returns placeholder if nil; `SourceFactory` guards `.figma` branch with `accessTokenNotFound` | +| PKL `swiftuiColorSwift` casing | PKL codegen lowercases: `swiftuiColorSwift`, not `swiftUIColorSwift` — check with `pkl eval` if unsure | +| Penpot `svgAttrs` decoding | `svgAttrs` contains mixed types (strings + nested dicts) — use `SVGAttributes` wrapper that extracts string values only, not `[String: String]` | +| iOS icons PKL: `xcassetsPath` | `xcassetsPath` and `target` are required in iOS PKL config even for Penpot; `assetsFolder` is folder name inside xcassets, not absolute path | ## Additional Rules diff --git a/MIGRATION_FROM_FIGMA_EXPORT.md b/MIGRATION_FROM_FIGMA_EXPORT.md index 68dbde38..d5c6da06 100644 --- a/MIGRATION_FROM_FIGMA_EXPORT.md +++ b/MIGRATION_FROM_FIGMA_EXPORT.md @@ -260,6 +260,6 @@ exfig icons --concurrent-downloads 50 # Increase CDN parallelism ## Getting Help - Configuration reference: [CONFIG.md](CONFIG.md) -- PKL guide: [docs/PKL.md](docs/PKL.md) -- Migration guide (YAML to PKL): [MIGRATION.md](MIGRATION.md) +- PKL guide: [PKLGuide](https://DesignPipe.github.io/exfig/documentation/exfigcli/pklguide) +- Migration guide (YAML to PKL): [Migration](https://DesignPipe.github.io/exfig/documentation/exfigcli/migration) - Issues: [GitHub Issues](https://github.com/DesignPipe/exfig/issues) diff --git a/Package.resolved b/Package.resolved index 64feef42..0f05d0ec 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "4a819b3065360bf98b89befa8fb35fa89db17992c9cce7813c9b1eb3c57b8221", + "originHash" : "2b3e6002adcb3e450ba06731dcbfc333d3f62894c0ca599c86c390156f0cf048", "pins" : [ { "identity" : "aexml", @@ -199,6 +199,15 @@ "version" : "2.96.0" } }, + { + "identity" : "swift-penpot-api", + "kind" : "remoteSourceControl", + "location" : "https://github.com/DesignPipe/swift-penpot-api.git", + "state" : { + "revision" : "3f0ed762b6130d32e96ae0624b2ad084756847b5", + "version" : "0.1.0" + } + }, { "identity" : "swift-resvg", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 198143f9..dd10ef1f 100644 --- a/Package.swift +++ b/Package.swift @@ -28,6 +28,7 @@ let package = Package( .package(url: "https://github.com/apple/pkl-swift", from: "0.8.0"), .package(url: "https://github.com/DesignPipe/swift-svgkit.git", from: "0.1.0"), .package(url: "https://github.com/DesignPipe/swift-figma-api.git", from: "0.2.0"), + .package(url: "https://github.com/DesignPipe/swift-penpot-api.git", from: "0.1.0"), .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.9.0"), ], targets: [ @@ -36,6 +37,7 @@ let package = Package( name: "ExFigCLI", dependencies: [ .product(name: "FigmaAPI", package: "swift-figma-api"), + .product(name: "PenpotAPI", package: "swift-penpot-api"), "ExFigCore", "ExFigConfig", "XcodeExport", @@ -60,6 +62,7 @@ let package = Package( exclude: ["CLAUDE.md", "AGENTS.md"], resources: [ .copy("Resources/Schemas/"), + .copy("Resources/Guides/"), ] ), diff --git a/README.md b/README.md index 90bdf7a2..9895bca5 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,15 @@ [![CI](https://github.com/DesignPipe/exfig/actions/workflows/ci.yml/badge.svg)](https://github.com/DesignPipe/exfig/actions/workflows/ci.yml) [![Release](https://github.com/DesignPipe/exfig/actions/workflows/release.yml/badge.svg)](https://github.com/DesignPipe/exfig/actions/workflows/release.yml) [![Docs](https://github.com/DesignPipe/exfig/actions/workflows/deploy-docc.yml/badge.svg)](https://DesignPipe.github.io/exfig/documentation/exfigcli) -![Coverage](https://img.shields.io/badge/coverage-50.65%25-yellow) +![Coverage](https://img.shields.io/badge/coverage-43.65%25-yellow) [![License](https://img.shields.io/github/license/DesignPipe/exfig.svg)](LICENSE) -Export colors, typography, icons, and images from Figma to Xcode, Android Studio, Flutter, and Web projects — automatically. +Export colors, typography, icons, and images from Figma and Penpot to Xcode, Android Studio, Flutter, and Web projects — automatically. ## The Problem - Figma has no "Export to Xcode" button. You copy hex codes by hand, one by one. +- Switching from Figma to Penpot? Your export pipeline shouldn't break. - Every color change means updating files across 3 platforms manually. - Dark mode variant? An afternoon spent on light/dark pairs and @1x/@2x/@3x PNGs. - Android gets XML. iOS gets xcassets. Flutter gets Dart. Someone maintains all three. @@ -24,7 +25,7 @@ Export colors, typography, icons, and images from Figma to Xcode, Android Studio **Flutter developer** — You need dark mode icon variants and `@2x`/`@3x` image scales. ExFig exports SVG icons with dark suffixes, raster images with scale directories, and Dart constants. -**Design Systems lead** — One Figma file feeds four platforms. ExFig's unified PKL config exports everything from a single `exfig batch` run. One CI pipeline, one source of truth. +**Design Systems lead** — One Figma or Penpot file feeds four platforms. ExFig's unified PKL config exports everything from a single `exfig batch` run. One CI pipeline, one source of truth. **CI/CD engineer** — Quiet mode, JSON reports, exit codes, version tracking, and checkpoint/resume. The [GitHub Action](https://github.com/DesignPipe/exfig-action) handles installation and caching. @@ -34,7 +35,7 @@ Export colors, typography, icons, and images from Figma to Xcode, Android Studio # 1. Install brew install designpipe/tap/exfig -# 2. Set Figma token +# 2. Set Figma token (or PENPOT_ACCESS_TOKEN for Penpot) export FIGMA_PERSONAL_TOKEN=your_token_here # 3a. Quick one-off export (interactive wizard) diff --git a/Sources/ExFig-Android/Config/AndroidIconsEntry.swift b/Sources/ExFig-Android/Config/AndroidIconsEntry.swift index dec15f5f..577638ae 100644 --- a/Sources/ExFig-Android/Config/AndroidIconsEntry.swift +++ b/Sources/ExFig-Android/Config/AndroidIconsEntry.swift @@ -14,8 +14,8 @@ public extension Android.IconsEntry { /// Returns an IconsSourceInput for use with IconsExportContext. func iconsSourceInput(darkFileId: String? = nil) -> IconsSourceInput { IconsSourceInput( - sourceKind: sourceKind?.coreSourceKind ?? .figma, - figmaFileId: figmaFileId, + sourceKind: resolvedSourceKind, + figmaFileId: resolvedFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Icons", pageName: figmaPageName, @@ -24,7 +24,8 @@ public extension Android.IconsEntry { darkModeSuffix: "_dark", rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, - nameReplaceRegexp: nameReplaceRegexp + nameReplaceRegexp: nameReplaceRegexp, + penpotBaseURL: resolvedPenpotBaseURL ) } diff --git a/Sources/ExFig-Android/Config/AndroidImagesEntry.swift b/Sources/ExFig-Android/Config/AndroidImagesEntry.swift index 5eeefc11..12a06347 100644 --- a/Sources/ExFig-Android/Config/AndroidImagesEntry.swift +++ b/Sources/ExFig-Android/Config/AndroidImagesEntry.swift @@ -31,8 +31,8 @@ public extension Android.ImagesEntry { /// Returns an ImagesSourceInput for use with ImagesExportContext. func imagesSourceInput(darkFileId: String? = nil) -> ImagesSourceInput { ImagesSourceInput( - sourceKind: sourceKind?.coreSourceKind ?? .figma, - figmaFileId: figmaFileId, + sourceKind: resolvedSourceKind, + figmaFileId: resolvedFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Images", pageName: figmaPageName, @@ -42,7 +42,8 @@ public extension Android.ImagesEntry { darkModeSuffix: "_dark", rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, - nameReplaceRegexp: nameReplaceRegexp + nameReplaceRegexp: nameReplaceRegexp, + penpotBaseURL: resolvedPenpotBaseURL ) } diff --git a/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift b/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift index 0eddc5fe..3c78d853 100644 --- a/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift +++ b/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift @@ -11,8 +11,8 @@ public extension Flutter.IconsEntry { /// Returns an IconsSourceInput for use with IconsExportContext. func iconsSourceInput(darkFileId: String? = nil) -> IconsSourceInput { IconsSourceInput( - sourceKind: sourceKind?.coreSourceKind ?? .figma, - figmaFileId: figmaFileId, + sourceKind: resolvedSourceKind, + figmaFileId: resolvedFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Icons", pageName: figmaPageName, @@ -20,7 +20,8 @@ public extension Flutter.IconsEntry { darkModeSuffix: "_dark", rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, - nameReplaceRegexp: nameReplaceRegexp + nameReplaceRegexp: nameReplaceRegexp, + penpotBaseURL: resolvedPenpotBaseURL ) } diff --git a/Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift b/Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift index f043b064..c5912a87 100644 --- a/Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift +++ b/Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift @@ -14,8 +14,8 @@ public extension Flutter.ImagesEntry { /// Returns an ImagesSourceInput for use with ImagesExportContext. func imagesSourceInput(darkFileId: String? = nil) -> ImagesSourceInput { ImagesSourceInput( - sourceKind: sourceKind?.coreSourceKind ?? .figma, - figmaFileId: figmaFileId, + sourceKind: resolvedSourceKind, + figmaFileId: resolvedFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Images", pageName: figmaPageName, @@ -25,7 +25,8 @@ public extension Flutter.ImagesEntry { darkModeSuffix: "_dark", rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, - nameReplaceRegexp: nameReplaceRegexp + nameReplaceRegexp: nameReplaceRegexp, + penpotBaseURL: resolvedPenpotBaseURL ) } @@ -44,8 +45,8 @@ public extension Flutter.ImagesEntry { /// Returns an ImagesSourceInput configured for SVG source. func svgSourceInput(darkFileId: String? = nil) -> ImagesSourceInput { ImagesSourceInput( - sourceKind: sourceKind?.coreSourceKind ?? .figma, - figmaFileId: figmaFileId, + sourceKind: resolvedSourceKind, + figmaFileId: resolvedFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Images", pageName: figmaPageName, @@ -55,7 +56,8 @@ public extension Flutter.ImagesEntry { darkModeSuffix: "_dark", rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, - nameReplaceRegexp: nameReplaceRegexp + nameReplaceRegexp: nameReplaceRegexp, + penpotBaseURL: resolvedPenpotBaseURL ) } diff --git a/Sources/ExFig-Web/Config/WebIconsEntry.swift b/Sources/ExFig-Web/Config/WebIconsEntry.swift index 12500a0b..02cbd0f2 100644 --- a/Sources/ExFig-Web/Config/WebIconsEntry.swift +++ b/Sources/ExFig-Web/Config/WebIconsEntry.swift @@ -11,8 +11,8 @@ public extension Web.IconsEntry { /// Returns an IconsSourceInput for use with IconsExportContext. func iconsSourceInput(darkFileId: String? = nil) -> IconsSourceInput { IconsSourceInput( - sourceKind: sourceKind?.coreSourceKind ?? .figma, - figmaFileId: figmaFileId, + sourceKind: resolvedSourceKind, + figmaFileId: resolvedFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Icons", pageName: figmaPageName, @@ -20,7 +20,8 @@ public extension Web.IconsEntry { darkModeSuffix: "_dark", rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, - nameReplaceRegexp: nameReplaceRegexp + nameReplaceRegexp: nameReplaceRegexp, + penpotBaseURL: resolvedPenpotBaseURL ) } diff --git a/Sources/ExFig-Web/Config/WebImagesEntry.swift b/Sources/ExFig-Web/Config/WebImagesEntry.swift index 21a0205d..d2f2dc1b 100644 --- a/Sources/ExFig-Web/Config/WebImagesEntry.swift +++ b/Sources/ExFig-Web/Config/WebImagesEntry.swift @@ -11,8 +11,8 @@ public extension Web.ImagesEntry { /// Returns an ImagesSourceInput for use with ImagesExportContext. func imagesSourceInput(darkFileId: String? = nil) -> ImagesSourceInput { ImagesSourceInput( - sourceKind: sourceKind?.coreSourceKind ?? .figma, - figmaFileId: figmaFileId, + sourceKind: resolvedSourceKind, + figmaFileId: resolvedFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Images", pageName: figmaPageName, @@ -22,7 +22,8 @@ public extension Web.ImagesEntry { darkModeSuffix: "_dark", rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, - nameReplaceRegexp: nameReplaceRegexp + nameReplaceRegexp: nameReplaceRegexp, + penpotBaseURL: resolvedPenpotBaseURL ) } diff --git a/Sources/ExFig-iOS/Config/iOSIconsEntry.swift b/Sources/ExFig-iOS/Config/iOSIconsEntry.swift index 3e7473e2..a7267494 100644 --- a/Sources/ExFig-iOS/Config/iOSIconsEntry.swift +++ b/Sources/ExFig-iOS/Config/iOSIconsEntry.swift @@ -13,8 +13,8 @@ public extension iOS.IconsEntry { /// Returns an IconsSourceInput for use with IconsExportContext. func iconsSourceInput(darkFileId: String? = nil) -> IconsSourceInput { IconsSourceInput( - sourceKind: sourceKind?.coreSourceKind ?? .figma, - figmaFileId: figmaFileId, + sourceKind: resolvedSourceKind, + figmaFileId: resolvedFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Icons", pageName: figmaPageName, @@ -27,7 +27,8 @@ public extension iOS.IconsEntry { renderModeTemplateSuffix: renderModeTemplateSuffix, rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, - nameReplaceRegexp: nameReplaceRegexp + nameReplaceRegexp: nameReplaceRegexp, + penpotBaseURL: resolvedPenpotBaseURL ) } diff --git a/Sources/ExFig-iOS/Config/iOSImagesEntry.swift b/Sources/ExFig-iOS/Config/iOSImagesEntry.swift index 1ae68e57..0ca2f263 100644 --- a/Sources/ExFig-iOS/Config/iOSImagesEntry.swift +++ b/Sources/ExFig-iOS/Config/iOSImagesEntry.swift @@ -13,8 +13,8 @@ public extension iOS.ImagesEntry { /// Returns an ImagesSourceInput for use with ImagesExportContext. func imagesSourceInput(darkFileId: String? = nil) -> ImagesSourceInput { ImagesSourceInput( - sourceKind: sourceKind?.coreSourceKind ?? .figma, - figmaFileId: figmaFileId, + sourceKind: resolvedSourceKind, + figmaFileId: resolvedFileId, darkFileId: darkFileId, frameName: figmaFrameName ?? "Images", pageName: figmaPageName, @@ -24,7 +24,8 @@ public extension iOS.ImagesEntry { darkModeSuffix: "_dark", rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, - nameReplaceRegexp: nameReplaceRegexp + nameReplaceRegexp: nameReplaceRegexp, + penpotBaseURL: resolvedPenpotBaseURL ) } diff --git a/Sources/ExFigCLI/Batch/BatchConfigRunner.swift b/Sources/ExFigCLI/Batch/BatchConfigRunner.swift index 6515116b..b5482772 100644 --- a/Sources/ExFigCLI/Batch/BatchConfigRunner.swift +++ b/Sources/ExFigCLI/Batch/BatchConfigRunner.swift @@ -307,8 +307,8 @@ struct BatchConfigRunner: Sendable { let effectiveTimeout: TimeInterval? = cliTimeout.map { TimeInterval($0) } ?? options.params.figma?.timeout - let baseClient = FigmaClient( - accessToken: options.accessToken, + let baseClient = try FigmaClient( + accessToken: options.requireFigmaToken(), timeout: effectiveTimeout ) diff --git a/Sources/ExFigCLI/CLAUDE.md b/Sources/ExFigCLI/CLAUDE.md index 36fdd703..1cf2ea25 100644 --- a/Sources/ExFigCLI/CLAUDE.md +++ b/Sources/ExFigCLI/CLAUDE.md @@ -30,7 +30,7 @@ exfig mcp → MCPServe Each subcommand composes options via `@OptionGroup`: - `GlobalOptions` — `--verbose/-v`, `--quiet/-q` -- `ExFigOptions` — `--input/-i`, validates PKL config + FIGMA_PERSONAL_TOKEN +- `ExFigOptions` — `--input/-i`, validates PKL config, reads FIGMA_PERSONAL_TOKEN (optional — `String?`, not required for non-Figma sources). Use `requireFigmaToken()` when creating `FigmaClient` directly in Download commands. - `CacheOptions` — `--cache`, `--no-cache`, `--force`, `--experimental-granular-cache` - `FaultToleranceOptions` — retry/timeout configuration @@ -174,6 +174,7 @@ Converter factories (`WebpConverterFactory`, `HeicConverterFactory`) handle plat | `MCP/MCPServerState.swift` | MCP server shared state | | `Source/SourceFactory.swift` | Centralized factory creating source instances by `DesignSourceKind` | | `Source/Figma*Source.swift` | Figma source implementations wrapping existing loaders | +| `Source/Penpot*Source.swift` | Penpot source implementations (colors, components, typography) | | `Source/TokensFileColorsSource.swift` | Local .tokens.json source (extracted from ColorsExportContextImpl) | ### MCP Server Architecture @@ -189,6 +190,9 @@ reserved for MCP JSON-RPC protocol. **Keepalive:** `withCheckedContinuation { _ in }` — suspends indefinitely without hacks (no `Task.sleep(365 days)`). +**Guide resources:** `exfig://guides/` serves markdown files from `Resources/Guides/` (copied from DocC articles). +DocC `.docc` articles are NOT accessible via `Bundle.module` at runtime — must be separately copied to `Resources/Guides/`. + **Tool handler order:** Validate input parameters BEFORE expensive operations (PKL eval, API client creation). ### Adding an MCP Tool Handler @@ -209,9 +213,17 @@ reserved for MCP JSON-RPC protocol. ### Source Dispatch Pattern `ColorsExportContextImpl.loadColors()` creates source via `SourceFactory.createColorsSource(for:...)` per call. -`IconsExportContextImpl` / `ImagesExportContextImpl` still use injected `componentsSource` (only Figma supported). +`IconsExportContextImpl` / `ImagesExportContextImpl` use injected `componentsSource` (Figma and Penpot supported via `SourceFactory`). `PluginColorsExport` does NOT create sources — context handles dispatch internally. +All platforms (iOS/Android/Flutter/Web) use `SourceFactory.createComponentsSource(for:)` in both `PluginIconsExport` and `PluginImagesExport`. When adding a new source kind: update `SourceFactory`, add source impl in `Source/`, update error `assetType`. +Penpot sources create `BasePenpotClient` internally from `PENPOT_ACCESS_TOKEN` env var (like TokensFileSource reads local files — no injected client). + +### Local File URLs (Penpot SVG) + +`PenpotComponentsSource` writes reconstructed SVGs to temp files and passes `file://` URLs in `Image.url`. +`FileDownloader.fetch()` treats `file://` URLs as local files (skips HTTP download). Do NOT weaken +`validateDownloadURL()` — it must remain HTTPS-only. Filter file URLs in `fetch()` before download loop. ### Adding a New Subcommand @@ -240,6 +252,13 @@ transformation logic into `*WizardTransform.swift` as `extension`. SwiftLint enf - **Remove section** — brace-counting (`removeSection`), strips preceding comments - **Substitute value** — simple `replacingOccurrences` for file IDs, frame names - **Uncomment block** — strip `//` prefix, substitute values (variablesColors, figmaPageName) +- **Insert block** (Penpot) — `applyPenpotResult` removes figma section, inserts `penpotSource` blocks into platform entries + +### Penpot Fetch Path + +`DownloadImages.runPenpotFetch()` uses `PenpotAPI` directly (not `DownloadImageLoader`). +Creates `BasePenpotClient`, fetches file, filters components by path, downloads thumbnails as PNG. +Triggered when `wizardResult?.designSource == .penpot` after wizard flow. ### Adding a New Platform Export diff --git a/Sources/ExFigCLI/ExFig.docc/Architecture.md b/Sources/ExFigCLI/ExFig.docc/Architecture.md new file mode 100755 index 00000000..3ace2db5 --- /dev/null +++ b/Sources/ExFigCLI/ExFig.docc/Architecture.md @@ -0,0 +1,488 @@ +# ExFig Architecture + +ExFig v2.0 uses a plugin-based architecture with twelve modules. This document explains the system design and how to extend it. + +## Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ExFig CLI │ +│ ┌─────────────┐ ┌─────────────┐ ┌───────────────────────┐ │ +│ │ Subcommands │ │ PluginReg. │ │ Context Impls │ │ +│ │ (colors, │──│ (routing) │──│ (ColorsExportContext │ │ +│ │ icons...) │ │ │ │ IconsExportContext) │ │ +│ └─────────────┘ └─────────────┘ └───────────────────────┘ │ +└────────────────────────────┬────────────────────────────────────┘ + │ + ┌────────────────────┼────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌───────────────┐ ┌────────────────┐ ┌────────────────────┐ +│ ExFig-iOS │ │ ExFig-Android │ │ ExFig-Flutter/Web │ +│ ┌─────────┐ │ │ ┌─────────┐ │ │ ┌─────────┐ │ +│ │iOSPlugin│ │ │ │Android │ │ │ │Flutter │ │ +│ └────┬────┘ │ │ │Plugin │ │ │ │Plugin │ │ +│ │ │ │ └────┬────┘ │ │ └────┬────┘ │ +│ ┌────▼────┐ │ │ ┌────▼────┐ │ │ ┌────▼────┐ │ +│ │Exporters│ │ │ │Exporters│ │ │ │Exporters│ │ +│ │ Colors │ │ │ │ Colors │ │ │ │ Colors │ │ +│ │ Icons │ │ │ │ Icons │ │ │ │ Icons │ │ +│ │ Images │ │ │ │ Images │ │ │ │ Images │ │ +│ └─────────┘ │ │ └─────────┘ │ │ └─────────┘ │ +└───────────────┘ └────────────────┘ └────────────────────┘ + │ │ │ + └────────────────────┼────────────────────┘ + │ + ▼ + ┌────────────────────────────────────────┐ + │ ExFigCore │ + │ ┌──────────────┐ ┌───────────────┐ │ + │ │ Protocols │ │ Domain Models │ │ + │ │PlatformPlugin│ │ Color, Image │ │ + │ │AssetExporter │ │ TextStyle │ │ + │ │ColorsExporter│ │ ColorPair │ │ + │ └──────────────┘ └───────────────┘ │ + └────────────────────────────────────────┘ +``` + +## Module Responsibilities + +### ExFigCLI + +Main executable. Handles: +- CLI commands (colors, icons, images, typography, batch) +- PKL config loading via ExFigConfig +- Plugin coordination via PluginRegistry +- Context implementations bridging plugins to services +- File I/O, TerminalUI, caching + +### ExFigCore + +Shared protocols and domain models: +- `PlatformPlugin` — platform registration +- `AssetExporter` — base export protocol +- `ColorsExporter` / `IconsExporter` / `ImagesExporter` — specialized protocols +- `ColorsExportContext` / `IconsExportContext` — dependency injection +- Domain models: `Color`, `ColorPair`, `Image`, `TextStyle` + +### ExFigConfig + +PKL configuration: +- `PKLLocator` — finds pkl CLI +- `PKLEvaluator` — runs pkl eval and decodes JSON +- Shared config types: `SourceConfig`, `AssetConfiguration` + +### ExFig-iOS / Android / Flutter / Web + +Platform plugins: +- `*Plugin` — platform registration, exporter factory +- `*Entry` types — configuration models +- `*Exporter` — export implementations + +### External Packages + +- **swift-figma-api** (`FigmaAPI`) — Figma REST API client with rate limiting, retry, and 46 endpoints +- **swift-penpot-api** (`PenpotAPI`) — Penpot RPC API client with SVG shape reconstruction +- **swift-svgkit** (`SVGKit`) — SVG parsing, ImageVector and VectorDrawable generation + +### XcodeExport / AndroidExport / FlutterExport / WebExport + +Platform-specific file generation: +- Asset catalog generation (xcassets, VectorDrawable) +- Code generation (Swift extensions, Kotlin, Dart, CSS) +- Template rendering via Jinja2 + +### JinjaSupport + +Shared Jinja2 template rendering utilities used across all Export modules. + +## Key Protocols + +### PlatformPlugin + +Represents a target platform: + +```swift +public protocol PlatformPlugin: Sendable { + var identifier: String { get } // "ios", "android", etc. + var platform: Platform { get } // .ios, .android, etc. + var configKeys: Set { get } // ["ios"] for PKL routing + + func exporters() -> [any AssetExporter] +} +``` + +### AssetExporter + +Base protocol for all exporters: + +```swift +public protocol AssetExporter: Sendable { + var assetType: AssetType { get } // .colors, .icons, .images, .typography +} +``` + +### ColorsExporter + +Specialized protocol for colors: + +```swift +public protocol ColorsExporter: AssetExporter { + associatedtype Entry: Sendable + associatedtype PlatformConfig: Sendable + + func exportColors( + entries: [Entry], + platformConfig: PlatformConfig, + context: some ColorsExportContext + ) async throws -> Int +} +``` + +### ColorsExportContext + +Dependency injection for exporters: + +```swift +public protocol ColorsExportContext: ExportContext { + func loadColors(from source: ColorsSourceInput) async throws -> ColorsLoadOutput + func processColors( + _ colors: ColorsLoadOutput, + platform: Platform, + nameValidateRegexp: String?, + nameReplaceRegexp: String?, + nameStyle: NameStyle + ) throws -> ColorsProcessResult +} +``` + +## Data Flow + +``` +┌──────────────┐ +│ exfig.pkl │ +└──────┬───────┘ + │ + ▼ PKLEvaluator (pkl eval --format json) + │ + ▼ JSON → PKLConfig (JSONDecoder) + │ + ▼ PluginRegistry.plugin(forConfigKey: "ios") + │ + ▼ iOSPlugin.exporters() + │ + ▼ [iOSColorsExporter, iOSIconsExporter, ...] + │ + ▼ exporter.exportColors(entries, platformConfig, context) + │ + ├──▶ context.loadColors(source) → Figma Variables API + │ + ├──▶ context.processColors(...) → ColorsProcessor + │ + └──▶ exporter → XcodeExport (xcassets, Swift) + │ + ▼ context.writeFiles([FileContents]) +``` + +## Plugin Registry + +Central coordination point: + +```swift +let registry = PluginRegistry.default // Contains all 4 plugins + +// Find plugin by config key +if let plugin = registry.plugin(forConfigKey: "ios") { + for exporter in plugin.exporters() { + if let colorsExporter = exporter as? iOSColorsExporter { + try await colorsExporter.exportColors(...) + } + } +} + +// Find plugin by platform +if let plugin = registry.plugin(for: .android) { + // Use Android plugin +} +``` + +## Adding a New Platform + +### 1. Create Module + +Create `Sources/ExFig-NewPlatform/`: + +``` +Sources/ExFig-NewPlatform/ +├── NewPlatformPlugin.swift +├── Config/ +│ ├── NewPlatformColorsEntry.swift +│ ├── NewPlatformIconsEntry.swift +│ └── NewPlatformImagesEntry.swift +└── Export/ + ├── NewPlatformColorsExporter.swift + ├── NewPlatformIconsExporter.swift + └── NewPlatformImagesExporter.swift +``` + +### 2. Define Plugin + +```swift +// NewPlatformPlugin.swift +import ExFigCore + +public struct NewPlatformPlugin: PlatformPlugin { + public let identifier = "newplatform" + public let platform: Platform = .custom("newplatform") + public let configKeys: Set = ["newplatform"] + + public init() {} + + public func exporters() -> [any AssetExporter] { + [ + NewPlatformColorsExporter(), + NewPlatformIconsExporter(), + NewPlatformImagesExporter(), + ] + } +} +``` + +### 3. Define Entry Types + +```swift +// Config/NewPlatformColorsEntry.swift +public struct NewPlatformColorsEntry: Codable, Sendable { + // Source fields (can use common.variablesColors) + public let tokensFileId: String? + public let tokensCollectionName: String? + public let lightModeName: String? + public let darkModeName: String? + + // Platform-specific fields + public let outputPath: String + public let format: OutputFormat +} +``` + +### 4. Implement Exporter + +```swift +// Export/NewPlatformColorsExporter.swift +import ExFigCore + +public struct NewPlatformColorsExporter: ColorsExporter { + public typealias Entry = NewPlatformColorsEntry + public typealias PlatformConfig = NewPlatformConfig + + public func exportColors( + entries: [Entry], + platformConfig: PlatformConfig, + context: some ColorsExportContext + ) async throws -> Int { + var totalCount = 0 + + for entry in entries { + // 1. Load from Figma + let source = ColorsSourceInput( + tokensFileId: entry.tokensFileId ?? context.commonSource?.tokensFileId, + // ... other fields + ) + let loaded = try await context.loadColors(from: source) + + // 2. Process + let processed = try context.processColors( + loaded, + platform: .custom("newplatform"), + nameValidateRegexp: entry.nameValidateRegexp, + nameReplaceRegexp: entry.nameReplaceRegexp, + nameStyle: entry.nameStyle + ) + + // 3. Generate output files + let files = try generateOutput(processed, entry: entry) + + // 4. Write files + try context.writeFiles(files) + + totalCount += processed.colorPairs.count + } + + return totalCount + } +} +``` + +### 5. Add PKL Schema + +```pkl +// Sources/ExFigCLI/Resources/Schemas/NewPlatform.pkl +module NewPlatform + +import "Common.pkl" + +class ColorsEntry extends Common.VariablesSource { + outputPath: String + format: "json"|"xml"|"yaml" +} + +class NewPlatformConfig { + basePath: String + colors: (ColorsEntry|Listing)? +} +``` + +### 6. Register in Package.swift + +```swift +.target( + name: "ExFig-NewPlatform", + dependencies: ["ExFigCore"], + path: "Sources/ExFig-NewPlatform" +), +``` + +### 7. Register in PluginRegistry + +```swift +// Sources/ExFigCLI/Plugin/PluginRegistry.swift +import ExFig_NewPlatform + +public static let `default` = PluginRegistry(plugins: [ + iOSPlugin(), + AndroidPlugin(), + FlutterPlugin(), + WebPlugin(), + NewPlatformPlugin(), // Add here +]) +``` + +## Context Implementation Pattern + +Exporters receive a context for dependencies. The CLI provides concrete implementations: + +```swift +// Plugin defines protocol +public protocol ColorsExportContext: ExportContext { + func loadColors(from: ColorsSourceInput) async throws -> ColorsLoadOutput + func processColors(...) throws -> ColorsProcessResult +} + +// CLI provides implementation +struct ColorsExportContextImpl: ColorsExportContext { + let client: FigmaClient + let ui: TerminalUI + + func loadColors(from source: ColorsSourceInput) async throws -> ColorsLoadOutput { + let loader = ColorsVariablesLoader(client: client, ...) + return try await loader.load() + } + + func processColors(...) throws -> ColorsProcessResult { + let processor = ColorsProcessor(...) + return processor.process(...) + } +} +``` + +This enables: +- Plugins are testable with mock contexts +- CLI controls batch optimizations (pipelining, caching) +- Exporters remain simple and focused + +## Batch Processing Integration + +Plugins don't know about batch mode. Context implementations check for batch state: + +```swift +struct IconsExportContextImpl: IconsExportContext { + func downloadFiles(_ files: [DownloadRequest]) async throws { + if BatchSharedState.current != nil { + // Use shared download queue for batch optimization + try await pipelinedDownloader.download(files) + } else { + // Standalone mode + try await fileDownloader.download(files) + } + } +} +``` + +## Testing + +### Plugin Tests + +```swift +// Tests/ExFig-iOSTests/iOSPluginTests.swift +func testIdentifier() { + let plugin = iOSPlugin() + XCTAssertEqual(plugin.identifier, "ios") +} + +func testExportersCount() { + let plugin = iOSPlugin() + XCTAssertEqual(plugin.exporters().count, 4) +} +``` + +### Exporter Tests with Mock Context + +```swift +struct MockColorsExportContext: ColorsExportContext { + var loadedColors: ColorsLoadOutput? + + func loadColors(from: ColorsSourceInput) async throws -> ColorsLoadOutput { + return loadedColors ?? ColorsLoadOutput(light: [], dark: [], lightHC: [], darkHC: []) + } +} + +func testColorsExporter() async throws { + let exporter = iOSColorsExporter() + let context = MockColorsExportContext(loadedColors: mockColors) + + let count = try await exporter.exportColors( + entries: [testEntry], + platformConfig: testConfig, + context: context + ) + + XCTAssertEqual(count, 5) +} +``` + +## File Structure Summary + +``` +Sources/ +├── ExFigCLI/ # CLI executable +│ ├── Subcommands/ # colors, icons, images commands +│ ├── Plugin/ # PluginRegistry +│ ├── Context/ # *ExportContextImpl +│ ├── Source/ # Design source implementations +│ ├── MCP/ # Model Context Protocol server +│ └── Resources/Schemas/ # PKL schemas +├── ExFigCore/ # Protocols, domain models +│ └── Protocol/ # PlatformPlugin, *Exporter +├── ExFigConfig/ # PKL infrastructure +│ └── PKL/ # PKLLocator, PKLEvaluator +├── ExFig-iOS/ # iOS plugin +│ ├── Config/ # Entry types +│ └── Export/ # Exporters +├── ExFig-Android/ # Android plugin +├── ExFig-Flutter/ # Flutter plugin +├── ExFig-Web/ # Web plugin +├── XcodeExport/ # iOS file generation +├── AndroidExport/ # Android file generation +├── FlutterExport/ # Flutter file generation +├── WebExport/ # Web file generation +└── JinjaSupport/ # Shared Jinja2 template rendering +``` + +## Design Principles + +1. **Plugin isolation**: Each platform is independent, can build/test separately +2. **Protocol-based**: Exporters depend on protocols, not concrete types +3. **Context injection**: Dependencies passed via context, enabling testing +4. **Batch transparency**: Plugins don't know about batch optimizations +5. **PKL-first**: Configuration is type-safe at parse time +6. **Sendable**: All protocols require Sendable for async safety diff --git a/Sources/ExFigCLI/ExFig.docc/Configuration.md b/Sources/ExFigCLI/ExFig.docc/Configuration.md index 058f2f4e..bbd371aa 100644 --- a/Sources/ExFigCLI/ExFig.docc/Configuration.md +++ b/Sources/ExFigCLI/ExFig.docc/Configuration.md @@ -103,6 +103,67 @@ common = new Common.CommonConfig { } ``` +### Penpot Source + +Use a Penpot project instead of Figma as the design source. For file preparation guidelines, +see . + +**Colors:** + +```pkl +import ".exfig/schemas/Common.pkl" +import ".exfig/schemas/iOS.pkl" + +ios = new iOS.iOSConfig { + colors = new iOS.ColorsEntry { + penpotSource = new Common.PenpotSource { + fileId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + // baseUrl = "https://penpot.mycompany.com/" // optional: self-hosted + pathFilter = "Brand" // optional: filter by path prefix + } + assetsFolder = "Colors" + nameStyle = "camelCase" + } +} +``` + +**Icons:** + +```pkl +ios = new iOS.iOSConfig { + icons = new Listing { + new iOS.IconsEntry { + penpotSource = new Common.PenpotSource { + fileId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + // pathFilter = "Icons / Actions" // optional: filter by path prefix + } + figmaFrameName = "Icons" // path prefix filter (same field as Figma) + format = "svg" // svg or pdf — SVG reconstructed from shape tree + assetsFolder = "Icons" + nameStyle = "camelCase" + } + } +} +``` + +**Typography:** + +```pkl +ios = new iOS.iOSConfig { + typography = new iOS.TypographyEntry { + penpotSource = new Common.PenpotSource { + fileId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + } + } +} +``` + +> When `penpotSource` is set, `sourceKind` auto-detects as `"penpot"`. ExFig reads from +> the Penpot API and does not require `FIGMA_PERSONAL_TOKEN`. Set `PENPOT_ACCESS_TOKEN` instead. +> +> Icons and images are exported via **SVG reconstruction** from Penpot's shape tree — +> no headless Chrome needed. Supported formats: SVG, PNG (any scale), PDF, WebP. + ### Tokens File Source Use a local W3C DTCG `.tokens.json` file instead of the Figma Variables API: diff --git a/Sources/ExFigCLI/ExFig.docc/DesignRequirements.md b/Sources/ExFigCLI/ExFig.docc/DesignRequirements.md index 61f71144..6f6c9499 100644 --- a/Sources/ExFigCLI/ExFig.docc/DesignRequirements.md +++ b/Sources/ExFigCLI/ExFig.docc/DesignRequirements.md @@ -1,13 +1,16 @@ -# Design Requirements +# Design File Structure -How to structure your Figma files for optimal export with ExFig. +How to structure your design files for optimal export with ExFig. ## Overview -ExFig extracts design resources from Figma files based on specific naming conventions and organizational structures. -This guide explains how to set up your Figma files for seamless export. +ExFig extracts design resources from **Figma** files and **Penpot** projects based on naming conventions +and organizational structures. This guide explains how to set up your design files for seamless export. -## General Principles +- **Figma**: Uses frames, components, color styles, and Variables +- **Penpot**: Uses shared library colors, components, and typographies + +## Figma ### Frame Organization @@ -46,9 +49,9 @@ common = new Common.CommonConfig { } ``` -## Colors +### Colors -### Using Color Styles +#### Using Color Styles Create color styles in Figma with descriptive names: @@ -63,7 +66,7 @@ Colors frame └── border/default ``` -### Using Figma Variables +#### Using Figma Variables For Figma Variables API support: @@ -94,15 +97,15 @@ Colors collection └── text: #FFFFFF ``` -### Naming Guidelines +#### Naming Guidelines - Use lowercase with optional separators: `/`, `-`, `_` - Group related colors with prefixes: `text/primary`, `background/card` - Avoid special characters except separators -## Icons +### Icons -### Component Structure +#### Component Structure Icons must be **components** (not plain frames): @@ -115,7 +118,7 @@ Icons frame └── ic/32/menu (component) ``` -### Size Conventions +#### Size Conventions Organize icons by size: @@ -127,7 +130,7 @@ Icons frame └── ic/48/... (48pt icons) ``` -### Vector Requirements +#### Vector Requirements For optimal vector export: @@ -136,7 +139,7 @@ For optimal vector export: 3. **Remove hidden layers**: Delete unused or hidden elements 4. **Use consistent viewBox**: Keep viewBox dimensions consistent within size groups -### Dark Mode Icons +#### Dark Mode Icons Two approaches for dark mode support: @@ -174,9 +177,9 @@ Icons frame └── ic/24/close_dark ``` -## Images +### Images -### Component Structure +#### Component Structure Images must be **components**: @@ -188,7 +191,7 @@ Illustrations frame └── img-hero-banner (component) ``` -### Size Recommendations +#### Size Recommendations Design at the largest needed scale: @@ -196,7 +199,7 @@ Design at the largest needed scale: - **Android**: Design at xxxhdpi (4x), ExFig generates all densities - **Flutter**: Design at 3x, ExFig generates 1x, 2x, 3x -### Multi-Idiom Support (iOS) +#### Multi-Idiom Support (iOS) Use suffixes for device-specific variants: @@ -208,7 +211,7 @@ Illustrations frame └── img-sidebar~ipad ``` -### Dark Mode Images +#### Dark Mode Images Same approaches as icons: @@ -222,9 +225,9 @@ Illustrations frame └── img-hero_dark ``` -## Typography +### Typography -### Text Style Structure +#### Text Style Structure Create text styles with hierarchical names: @@ -239,7 +242,7 @@ Typography frame └── caption/small ``` -### Required Properties +#### Required Properties Each text style should define: @@ -249,7 +252,7 @@ Each text style should define: - **Line height**: in pixels or percentage - **Letter spacing**: in pixels or percentage -### Font Mapping +#### Font Mapping Map Figma fonts to platform fonts in your config: @@ -270,9 +273,9 @@ android = new Android.AndroidConfig { } ``` -## Validation Regex Patterns +### Validation Regex Patterns -### Common Patterns +#### Common Patterns ```pkl import ".exfig/schemas/Common.pkl" @@ -295,7 +298,7 @@ common = new Common.CommonConfig { } ``` -### Transform Patterns +#### Transform Patterns Transform names during export: @@ -310,8 +313,6 @@ common = new Common.CommonConfig { } ``` -## File Organization Tips - ### Recommended Figma Structure ``` @@ -344,27 +345,180 @@ For complex theming, use separate files: Ensure component names match exactly between files. -## Troubleshooting +### Figma Troubleshooting -### Resources Not Found +#### Resources Not Found - Verify frame names match `figmaFrameName` in config - Check that resources are **components**, not plain frames - Ensure names pass validation regex -### Missing Dark Mode +#### Missing Dark Mode - Verify `darkFileId` is set correctly - Check component names match between light and dark files - For single-file mode, verify suffix is correct -### Export Quality Issues +#### Export Quality Issues - Design at highest needed resolution - Use vector graphics when possible - Avoid raster effects in vector icons - Flatten complex boolean operations +## Penpot + +ExFig reads Penpot library assets — colors, components, and typographies — from the shared library +of a Penpot file. All assets must be added to the **shared library** (Assets panel), not just placed +on the canvas. + +### Authentication + +Set the `PENPOT_ACCESS_TOKEN` environment variable: + +1. Open Penpot → Settings → Access Tokens +2. Create a new token (no expiration recommended for CI) +3. Export: + +```bash +export PENPOT_ACCESS_TOKEN="your-token-here" +``` + +No `FIGMA_PERSONAL_TOKEN` needed when using only Penpot sources. + +### Library Colors + +Colors must be in the shared **Library** (Assets panel → Local library → Colors): + +``` +Library Colors +├── Brand/Primary (#3B82F6) +├── Brand/Secondary (#8B5CF6) +├── Semantic/Success (#22C55E) +├── Semantic/Warning (#F59E0B) +├── Semantic/Error (#EF4444) +├── Neutral/Background (#1E1E2E) +├── Neutral/Text (#F8F8F2) +└── Neutral/Overlay (#000000, 50% opacity) +``` + +Key points: + +- Only **solid hex colors** are exported. Gradients and image fills are skipped in v1. +- The `path` field organizes colors into groups: `path: "Brand"`, `name: "Primary"` → `Brand/Primary` +- Use `pathFilter` in your config to select a specific group: `pathFilter = "Brand"` exports only Brand colors +- **Opacity** is preserved (0.0–1.0) + +Config example: + +```pkl +penpotSource = new Common.PenpotSource { + fileId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + pathFilter = "Brand" // optional: export only Brand/* colors +} +``` + +### Library Components (Icons and Images) + +Components must be in the shared **Library** (Assets panel → Local library → Components). +ExFig filters by the component `path` prefix (equivalent to Figma's frame name): + +``` +Library Components +├── Icons/Navigation/arrow-left +├── Icons/Navigation/arrow-right +├── Icons/Actions/close +├── Icons/Actions/check +├── Illustrations/Empty States/no-data +└── Illustrations/Onboarding/welcome +``` + +Config example: + +```pkl +penpotSource = new Common.PenpotSource { + fileId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" +} +// Use path prefix as the frame filter +figmaFrameName = "Icons/Navigation" // exports arrow-left, arrow-right +``` + +ExFig reconstructs SVG directly from Penpot's shape tree — no headless Chrome or CDN needed. +Supported output formats: **SVG** (native vector), **PNG** (via resvg at any scale), **PDF**, **WebP**. + +### Library Typography + +Typography styles must be in the shared **Library** (Assets panel → Local library → Typography): + +``` +Library Typography +├── Heading/H1 (Roboto Bold 32px) +├── Heading/H2 (Roboto Bold 24px) +├── Body/Regular (Roboto Regular 16px) +├── Body/Bold (Roboto Bold 16px) +└── Caption/Small (Roboto Regular 12px) +``` + +Required fields: + +- **fontFamily** — e.g., "Roboto", "DM Mono" +- **fontSize** — must be set (styles without a parseable font size are skipped) + +Supported fields: `fontWeight`, `lineHeight`, `letterSpacing`, `textTransform` (uppercase/lowercase). + +> Penpot may serialize numeric fields as strings (e.g., `"24"` instead of `24`). ExFig handles both formats automatically. + +### Recommended Penpot Structure + +``` +Design System (Penpot file) +├── Library Colors +│ ├── Brand/* (primary, secondary, accent) +│ ├── Semantic/* (success, warning, error, info) +│ └── Neutral/* (background, text, border, overlay) +├── Library Components +│ ├── Icons/Navigation/* (arrow, chevron, menu) +│ ├── Icons/Actions/* (close, check, edit, delete) +│ └── Illustrations/* (empty states, onboarding) +└── Library Typography + ├── Heading/* (H1, H2, H3) + ├── Body/* (regular, bold, italic) + └── Caption/* (regular, small) +``` + +### Known Limitations + +- **No dark mode support** — Penpot has no Variables/modes equivalent; colors export as light-only +- **No `exfig_inspect` for Penpot** — the MCP inspect tool works with Figma API only +- **Gradients skipped** — only solid hex colors are supported +- **No page filtering** — all library assets are global to the file, not page-scoped +- **SVG reconstruction scope** — supports path, rect, circle, bool, group shapes; complex effects (blur, shadow, gradients on shapes) are not yet rendered + +### Penpot Troubleshooting + +#### No Colors Exported + +- Verify colors are in the **shared library**, not just swatches on the canvas +- Check `pathFilter` — a too-specific prefix returns no results +- Gradient colors are skipped; use solid fills + +#### No Components Exported + +- Verify components are in the **shared library** (right-click shape → "Create component") +- Check the path prefix in `figmaFrameName` matches the component `path` +- Thumbnails may not be generated for programmatically created components + +#### Typography Styles Skipped + +- Ensure `fontSize` is set on the typography style +- Styles with unparseable font size values are silently skipped + +#### Authentication Errors + +- `PENPOT_ACCESS_TOKEN environment variable is required` — set the token +- Penpot 401 — token expired or invalid; regenerate in Settings → Access Tokens +- Self-hosted instances: set `baseUrl` in `penpotSource` + ## See Also - diff --git a/Sources/ExFigCLI/ExFig.docc/DesignTokens.md b/Sources/ExFigCLI/ExFig.docc/DesignTokens.md index 3edafd38..9f6e1bc2 100644 --- a/Sources/ExFigCLI/ExFig.docc/DesignTokens.md +++ b/Sources/ExFigCLI/ExFig.docc/DesignTokens.md @@ -1,6 +1,6 @@ # Design Tokens -Export Figma design data as W3C Design Tokens for token pipelines and cross-tool interoperability. +Export design data from Figma as W3C Design Tokens for token pipelines and cross-tool interoperability. ## Overview diff --git a/Sources/ExFigCLI/ExFig.docc/Development.md b/Sources/ExFigCLI/ExFig.docc/Development.md index 045aa816..26920766 100644 --- a/Sources/ExFigCLI/ExFig.docc/Development.md +++ b/Sources/ExFigCLI/ExFig.docc/Development.md @@ -57,29 +57,40 @@ swift build -c release ``` Sources/ -├── ExFig/ # CLI commands and main executable +├── ExFigCLI/ # CLI commands and main executable │ ├── Subcommands/ # CLI command implementations │ ├── Loaders/ # Figma data loaders │ ├── Input/ # Configuration parsing │ ├── Output/ # File writers │ ├── TerminalUI/ # Progress bars, spinners │ ├── Cache/ # Version tracking -│ └── Batch/ # Batch processing +│ ├── Batch/ # Batch processing +│ ├── Source/ # Design source implementations (Figma, Penpot, TokensFile) +│ └── MCP/ # Model Context Protocol server ├── ExFigCore/ # Domain models and processors -├── FigmaAPI/ # Figma REST API client +├── ExFigConfig/ # PKL config parsing, evaluation +├── ExFig-iOS/ # iOS platform plugin +├── ExFig-Android/ # Android platform plugin +├── ExFig-Flutter/ # Flutter platform plugin +├── ExFig-Web/ # Web platform plugin ├── XcodeExport/ # iOS export (xcassets, Swift) -├── AndroidExport/ # Android export (XML, Compose) +├── AndroidExport/ # Android export (XML, Compose, VectorDrawable) ├── FlutterExport/ # Flutter export (Dart, assets) -└── SVGKit/ # SVG parsing and code generation +├── WebExport/ # Web export (CSS, JSX icons) +└── JinjaSupport/ # Shared Jinja2 template rendering Tests/ ├── ExFigTests/ ├── ExFigCoreTests/ -├── FigmaAPITests/ ├── XcodeExportTests/ ├── AndroidExportTests/ ├── FlutterExportTests/ -└── SVGKitTests/ +├── WebExportTests/ +├── JinjaSupportTests/ +├── ExFig-iOSTests/ +├── ExFig-AndroidTests/ +├── ExFig-FlutterTests/ +└── ExFig-WebTests/ ``` ## Available Tasks @@ -167,35 +178,7 @@ static let configuration = CommandConfiguration( ## Adding a Figma API Endpoint -1. Create endpoint in `Sources/FigmaAPI/Endpoint/`: - -```swift -struct NewEndpoint: FigmaEndpoint { - typealias Response = NewResponse - - let fileId: String - - var path: String { - "files/\(fileId)/new-resource" - } -} -``` - -2. Create response model in `Sources/FigmaAPI/Model/`: - -```swift -struct NewResponse: Codable { - let data: [NewItem] -} -``` - -3. Add method to `FigmaClient`: - -```swift -func fetchNewResource(fileId: String) async throws -> NewResponse { - try await request(NewEndpoint(fileId: fileId)) -} -``` +FigmaAPI is an external package ([swift-figma-api](https://github.com/DesignPipe/swift-figma-api)). See its repository for endpoint patterns and contribution guidelines. ## Modifying Templates diff --git a/Sources/ExFigCLI/ExFig.docc/ExFig.md b/Sources/ExFigCLI/ExFig.docc/ExFig.md index b4836b46..3b02792e 100644 --- a/Sources/ExFigCLI/ExFig.docc/ExFig.md +++ b/Sources/ExFigCLI/ExFig.docc/ExFig.md @@ -1,22 +1,23 @@ # ``ExFigCLI`` -Export colors, typography, icons, and images from Figma to iOS, Android, Flutter, and Web projects. +Export colors, typography, icons, and images from Figma and Penpot to iOS, Android, Flutter, and Web projects. ## Overview -ExFig is a command-line tool that automates design-to-code handoff. Point it at a Figma file, -and it generates platform-native resources: Color Sets for Xcode, XML resources for Android, -Dart constants for Flutter, and CSS variables for React — all from one source of truth. +ExFig is a command-line tool that automates design-to-code handoff. Point it at a Figma file +or a Penpot project, and it generates platform-native resources: Color Sets for Xcode, XML +resources for Android, Dart constants for Flutter, and CSS variables for React — all from one +source of truth. ExFig handles the details that make manual export painful: light/dark mode variants, @1x/@2x/@3x image scales, high contrast colors, RTL icon mirroring, and Dynamic Type mappings. A single `exfig batch` command replaces hours of copy-paste work across platforms. -It's built for teams that maintain a Figma-based design system and need a reliable, automated -pipeline to keep code in sync with design. ExFig works locally for quick exports and in CI/CD -for fully automated workflows. +It's built for teams that maintain a Figma or Penpot-based design system and need a reliable, +automated pipeline to keep code in sync with design. ExFig works locally for quick exports and +in CI/CD for fully automated workflows. -> Tip: ExFig also works with local `.tokens.json` files — no Figma API access needed. +> Tip: ExFig also works with local `.tokens.json` files and Penpot projects — no Figma API access needed. ### Supported Platforms @@ -30,7 +31,7 @@ for fully automated workflows. **Design Assets** Colors with light/dark/high-contrast variants, vector icons (PDF, SVG, VectorDrawable), raster images with multi-scale support, typography with Dynamic Type, RTL layout support, -and Figma Variables integration. +Figma Variables integration, and Penpot library colors/components/typography. **Export Formats** PNG, SVG, PDF, JPEG, WebP, HEIC output formats with quality control. @@ -53,8 +54,8 @@ customizable Jinja2 code templates, and rich progress indicators with ETA. Type-safe Swift/Kotlin/Dart/TypeScript extensions, pre-configured UILabel subclasses, Compose color and icon objects, and Flutter path constants. -> Important: Exporting icons and images requires a Figma Professional or Organization plan -> (uses Shareable Team Libraries). +> Important: Exporting icons and images from Figma requires a Professional or Organization plan +> (uses Shareable Team Libraries). Penpot has no plan restrictions for API access. ## Topics @@ -87,7 +88,10 @@ Compose color and icon objects, and Flutter path constants. - - - +- ### Contributing - +- +- diff --git a/Sources/ExFigCLI/ExFig.docc/GettingStarted.md b/Sources/ExFigCLI/ExFig.docc/GettingStarted.md index 6f492bae..180ec13c 100644 --- a/Sources/ExFigCLI/ExFig.docc/GettingStarted.md +++ b/Sources/ExFigCLI/ExFig.docc/GettingStarted.md @@ -4,13 +4,13 @@ Install ExFig and configure your first export. ## Overview -ExFig is a command-line tool that exports design resources from Figma to iOS, Android, and Flutter projects. +ExFig is a command-line tool that exports design resources from Figma and Penpot to iOS, Android, Flutter, and Web projects. ## Requirements - macOS 13.0 or later (or Linux Ubuntu 22.04) -- Figma account with file access -- Figma Personal Access Token +- Figma account with file access, **or** Penpot account +- Figma Personal Access Token (for Figma sources) or Penpot Access Token (for Penpot sources) ## Installation @@ -45,7 +45,9 @@ cp .build/release/exfig /usr/local/bin/ Download the latest release from [GitHub Releases](https://github.com/DesignPipe/exfig/releases). -## Figma Access Token +## Authentication + +### Figma Access Token ExFig requires a Figma Personal Access Token to access the Figma API. @@ -72,6 +74,34 @@ Or pass it directly to commands: FIGMA_PERSONAL_TOKEN="your-token" exfig colors ``` +### Penpot Access Token + +For Penpot sources, set the `PENPOT_ACCESS_TOKEN` environment variable: + +1. Open your Penpot instance → Settings → Access Tokens +2. Create a new token +3. Set it: + +```bash +export PENPOT_ACCESS_TOKEN="your-penpot-token-here" +``` + +> Note: `PENPOT_ACCESS_TOKEN` is only required when using `penpotSource` in config. + +### Quick Penpot Icons Export (No Config) + +```bash +# Export Penpot icons as SVG +export PENPOT_ACCESS_TOKEN="your-token" +exfig fetch --source penpot -f "file-uuid" -r "Icons" -o ./icons --format svg + +# Export as PNG at 3x scale +exfig fetch --source penpot -f "file-uuid" -r "Icons" -o ./icons --format png --scale 3 +``` + +> File UUID is in the Penpot workspace URL: `?file-id=UUID`. +> For shared libraries, use the library's file ID from the Assets panel. + ## Quick Start ### 1. Initialize Configuration diff --git a/Sources/ExFigCLI/ExFig.docc/Migration.md b/Sources/ExFigCLI/ExFig.docc/Migration.md new file mode 100755 index 00000000..6ac390d7 --- /dev/null +++ b/Sources/ExFigCLI/ExFig.docc/Migration.md @@ -0,0 +1,518 @@ +# YAML to PKL Migration Guide + +ExFig v2.0 replaces YAML configuration with PKL. This guide helps migrate existing configurations. + +## Quick Migration + +### 1. Install PKL + +```bash +mise use pkl +``` + +### 2. Rename Config File + +```bash +mv exfig.yaml exfig.pkl +``` + +### 3. Convert Syntax + +Replace YAML syntax with PKL syntax (see mapping below). + +### 4. Test + +```bash +pkl eval exfig.pkl # Validate config +exfig colors -i exfig.pkl --dry-run # Test export +``` + +## Syntax Mapping + +### File Header + +**YAML:** +```yaml +# ExFig configuration +``` + +**PKL:** +```pkl +amends "package://github.com/DesignPipe/exfig@2.0.0#/ExFig.pkl" + +import "package://github.com/DesignPipe/exfig@2.0.0#/Common.pkl" +import "package://github.com/DesignPipe/exfig@2.0.0#/iOS.pkl" +``` + +### Objects + +**YAML:** +```yaml +figma: + lightFileId: "abc123" + timeout: 60 +``` + +**PKL:** +```pkl +figma = new Figma.FigmaConfig { + lightFileId = "abc123" + timeout = 60 +} +``` + +### Nested Objects + +**YAML:** +```yaml +common: + cache: + enabled: true + path: ".cache.json" + variablesColors: + tokensFileId: "file123" + tokensCollectionName: "Tokens" +``` + +**PKL:** +```pkl +common = new Common.CommonConfig { + cache = new Common.Cache { + enabled = true + path = ".cache.json" + } + variablesColors = new Common.VariablesColors { + tokensFileId = "file123" + tokensCollectionName = "Tokens" + } +} +``` + +### Arrays/Lists + +**YAML:** +```yaml +ios: + colors: + - tokensFileId: "file1" + useColorAssets: true + assetsFolder: "Colors1" + - tokensFileId: "file2" + useColorAssets: true + assetsFolder: "Colors2" +``` + +**PKL:** +```pkl +ios = new iOS.iOSConfig { + colors = new Listing { + new iOS.ColorsEntry { + tokensFileId = "file1" + useColorAssets = true + assetsFolder = "Colors1" + nameStyle = "camelCase" + } + new iOS.ColorsEntry { + tokensFileId = "file2" + useColorAssets = true + assetsFolder = "Colors2" + nameStyle = "camelCase" + } + } +} +``` + +### Simple Lists + +**YAML:** +```yaml +resourceBundleNames: + - "ModuleA" + - "ModuleB" +``` + +**PKL:** +```pkl +resourceBundleNames = new Listing { "ModuleA"; "ModuleB" } +``` + +### Number Lists + +**YAML:** +```yaml +scales: + - 1 + - 2 + - 3 +``` + +**PKL:** +```pkl +scales = new Listing { 1; 2; 3 } +``` + +### Booleans + +**YAML:** +```yaml +useColorAssets: true +xmlDisabled: false +``` + +**PKL:** +```pkl +useColorAssets = true +xmlDisabled = false +``` + +### Strings + +**YAML:** +```yaml +xcodeprojPath: "MyApp.xcodeproj" +nameStyle: camelCase +``` + +**PKL:** +```pkl +xcodeprojPath = "MyApp.xcodeproj" +nameStyle = "camelCase" +``` + +**Note:** Enum values must be quoted in PKL. + +### Null/Optional Values + +**YAML:** +```yaml +darkFileId: ~ +darkModeName: null +``` + +**PKL:** +Simply omit the field (PKL optional fields default to null): +```pkl +// darkFileId is not specified +// darkModeName is not specified +``` + +### Comments + +**YAML:** +```yaml +# This is a comment +ios: + xcodeprojPath: "App.xcodeproj" # Inline comment +``` + +**PKL:** +```pkl +/// Doc comment (shown in IDE) +// Regular comment +ios = new iOS.iOSConfig { + xcodeprojPath = "App.xcodeproj" // Inline comment +} +``` + +## Complete Examples + +### iOS Colors Only + +**YAML:** +```yaml +common: + variablesColors: + tokensFileId: "abc123" + tokensCollectionName: "Design Tokens" + lightModeName: "Light" + darkModeName: "Dark" + +ios: + xcodeprojPath: "MyApp.xcodeproj" + target: "MyApp" + xcassetsPath: "MyApp/Assets.xcassets" + xcassetsInMainBundle: true + colors: + useColorAssets: true + assetsFolder: "Colors" + nameStyle: camelCase + colorSwift: "Generated/UIColor+Colors.swift" + swiftuiColorSwift: "Generated/Color+Colors.swift" +``` + +**PKL:** +```pkl +amends "package://github.com/DesignPipe/exfig@2.0.0#/ExFig.pkl" + +import "package://github.com/DesignPipe/exfig@2.0.0#/Common.pkl" +import "package://github.com/DesignPipe/exfig@2.0.0#/iOS.pkl" + +common = new Common.CommonConfig { + variablesColors = new Common.VariablesColors { + tokensFileId = "abc123" + tokensCollectionName = "Design Tokens" + lightModeName = "Light" + darkModeName = "Dark" + } +} + +ios = new iOS.iOSConfig { + xcodeprojPath = "MyApp.xcodeproj" + target = "MyApp" + xcassetsPath = "MyApp/Assets.xcassets" + xcassetsInMainBundle = true + + colors = new iOS.ColorsEntry { + useColorAssets = true + assetsFolder = "Colors" + nameStyle = "camelCase" + colorSwift = "Generated/UIColor+Colors.swift" + swiftuiColorSwift = "Generated/Color+Colors.swift" + } +} +``` + +### Multi-Platform + +**YAML:** +```yaml +figma: + lightFileId: "light-file" + darkFileId: "dark-file" + +common: + variablesColors: + tokensFileId: "tokens" + tokensCollectionName: "Colors" + lightModeName: "Light" + darkModeName: "Dark" + icons: + figmaFrameName: "Icons/24" + +ios: + xcodeprojPath: "iOS/App.xcodeproj" + target: "App" + xcassetsPath: "iOS/Assets.xcassets" + xcassetsInMainBundle: true + colors: + useColorAssets: true + assetsFolder: "Colors" + nameStyle: camelCase + icons: + format: pdf + assetsFolder: "Icons" + nameStyle: camelCase + +android: + mainRes: "android/app/src/main/res" + colors: + colorKotlin: "android/app/src/main/kotlin/Colors.kt" + composePackageName: "com.example.app" + icons: + output: "android/app/src/main/res/drawable" + composeFormat: imageVector +``` + +**PKL:** +```pkl +amends "package://github.com/DesignPipe/exfig@2.0.0#/ExFig.pkl" + +import "package://github.com/DesignPipe/exfig@2.0.0#/Figma.pkl" +import "package://github.com/DesignPipe/exfig@2.0.0#/Common.pkl" +import "package://github.com/DesignPipe/exfig@2.0.0#/iOS.pkl" +import "package://github.com/DesignPipe/exfig@2.0.0#/Android.pkl" + +figma = new Figma.FigmaConfig { + lightFileId = "light-file" + darkFileId = "dark-file" +} + +common = new Common.CommonConfig { + variablesColors = new Common.VariablesColors { + tokensFileId = "tokens" + tokensCollectionName = "Colors" + lightModeName = "Light" + darkModeName = "Dark" + } + icons = new Common.Icons { + figmaFrameName = "Icons/24" + } +} + +ios = new iOS.iOSConfig { + xcodeprojPath = "iOS/App.xcodeproj" + target = "App" + xcassetsPath = "iOS/Assets.xcassets" + xcassetsInMainBundle = true + + colors = new iOS.ColorsEntry { + useColorAssets = true + assetsFolder = "Colors" + nameStyle = "camelCase" + } + + icons = new iOS.IconsEntry { + format = "pdf" + assetsFolder = "Icons" + nameStyle = "camelCase" + } +} + +android = new Android.AndroidConfig { + mainRes = "android/app/src/main/res" + + colors = new Android.ColorsEntry { + colorKotlin = "android/app/src/main/kotlin/Colors.kt" + composePackageName = "com.example.app" + } + + icons = new Android.IconsEntry { + output = "android/app/src/main/res/drawable" + composeFormat = "imageVector" + } +} +``` + +## Key Differences + +| Aspect | YAML | PKL | +|--------|------|-----| +| Assignment | `:` | `=` | +| Objects | Indentation | `new Type { }` | +| Lists | `- item` | `new Listing { item; item }` | +| Strings | Quotes optional | Quotes required for enums | +| Comments | `#` | `//` or `///` | +| Null | `~` or `null` | Omit field | +| Types | Runtime validation | Compile-time validation | +| Inheritance | Not supported | `amends "base.pkl"` | + +## Required Fields + +PKL schemas may require fields that were optional in YAML: + +### iOS Colors + +```pkl +// Required in PKL +colors = new iOS.ColorsEntry { + useColorAssets = true // Required + nameStyle = "camelCase" // Required + // assetsFolder required if useColorAssets = true + assetsFolder = "Colors" +} +``` + +### Android Icons + +```pkl +// Required in PKL +icons = new Android.IconsEntry { + output = "drawable" // Required +} +``` + +### Flutter + +```pkl +// Required in PKL +flutter = new Flutter.FlutterConfig { + output = "lib/generated" // Required +} +``` + +## Common Migration Errors + +### Missing Type Prefix + +**Error:** +``` +Cannot find member 'ColorsEntry' in module 'ios' +``` + +**Fix:** Import and use full type path: +```pkl +import "package://github.com/DesignPipe/exfig@2.0.0#/iOS.pkl" + +colors = new iOS.ColorsEntry { ... } +``` + +### Wrong String Syntax + +**Error:** +``` +Expected string literal +``` + +**Fix:** Quote enum values: +```pkl +// Wrong +nameStyle = camelCase + +// Correct +nameStyle = "camelCase" +``` + +### Missing Required Field + +**Error:** +``` +Field 'useColorAssets' is required but missing +``` + +**Fix:** Add required field: +```pkl +colors = new iOS.ColorsEntry { + useColorAssets = true // Add this + nameStyle = "camelCase" +} +``` + +### List Syntax Error + +**Error:** +``` +Expected '}' but found 'new' +``` + +**Fix:** Use semicolons in Listing: +```pkl +// Wrong +scales = new Listing { 1, 2, 3 } + +// Correct +scales = new Listing { 1; 2; 3 } +``` + +## Batch Migration + +For batch configs, rename all `.yaml` files to `.pkl`: + +```bash +# Rename all yaml to pkl +for f in configs/*.yaml; do mv "$f" "${f%.yaml}.pkl"; done + +# Convert each file +for f in configs/*.pkl; do + echo "Converting $f..." + # Manual conversion required +done + +# Test batch +exfig batch configs/ --parallel 2 --dry-run +``` + +## Getting Help + +- [PKL Documentation](https://pkl-lang.org/main/current/index.html) +- +- [ExFig Schema Reference](https://github.com/DesignPipe/exfig/tree/main/Sources/ExFigCLI/Resources/Schemas) + +## Validation Checklist + +Before committing migrated config: + +1. `pkl eval exfig.pkl` - No syntax/type errors +2. `exfig colors -i exfig.pkl --dry-run` - Export works +3. `exfig icons -i exfig.pkl --dry-run` - Icons export works +4. `exfig images -i exfig.pkl --dry-run` - Images export works +5. Compare output with previous YAML-based export diff --git a/Sources/ExFigCLI/ExFig.docc/PKLGuide.md b/Sources/ExFigCLI/ExFig.docc/PKLGuide.md new file mode 100755 index 00000000..302f6e44 --- /dev/null +++ b/Sources/ExFigCLI/ExFig.docc/PKLGuide.md @@ -0,0 +1,610 @@ +# PKL Configuration Guide + +PKL (Programmable, Scalable, Safe) is ExFig's configuration language, replacing YAML in v2.0. PKL provides native configuration inheritance via `amends`, type safety, and IDE support. + +## Installation + +PKL CLI is required to run ExFig. Install via mise: + +```bash +mise use pkl +``` + +Or manually from [pkl.dev](https://pkl.dev): + +```bash +# macOS (Apple Silicon) +curl -L https://github.com/apple/pkl/releases/download/0.30.2/pkl-macos-aarch64.gz | gunzip > /usr/local/bin/pkl +chmod +x /usr/local/bin/pkl + +# macOS (Intel) +curl -L https://github.com/apple/pkl/releases/download/0.30.2/pkl-macos-amd64.gz | gunzip > /usr/local/bin/pkl +chmod +x /usr/local/bin/pkl + +# Linux +curl -L https://github.com/apple/pkl/releases/download/0.30.2/pkl-linux-amd64.gz | gunzip > /usr/local/bin/pkl +chmod +x /usr/local/bin/pkl +``` + +Verify installation: + +```bash +pkl --version +``` + +## Basic Configuration + +Create `exfig.pkl` in your project root: + +```pkl +amends "package://github.com/DesignPipe/exfig@2.0.0#/ExFig.pkl" + +import "package://github.com/DesignPipe/exfig@2.0.0#/Common.pkl" +import "package://github.com/DesignPipe/exfig@2.0.0#/iOS.pkl" + +common = new Common.CommonConfig { + variablesColors = new Common.VariablesColors { + tokensFileId = "YOUR_FIGMA_FILE_ID" + tokensCollectionName = "Design Tokens" + lightModeName = "Light" + darkModeName = "Dark" + } +} + +ios = new iOS.iOSConfig { + xcodeprojPath = "MyApp.xcodeproj" + target = "MyApp" + xcassetsPath = "MyApp/Resources/Assets.xcassets" + xcassetsInMainBundle = true + + colors = new iOS.ColorsEntry { + useColorAssets = true + assetsFolder = "Colors" + nameStyle = "camelCase" + colorSwift = "MyApp/Generated/UIColor+Generated.swift" + swiftuiColorSwift = "MyApp/Generated/Color+Generated.swift" + } +} +``` + +Run ExFig: + +```bash +exfig colors -i exfig.pkl +``` + +## Configuration Inheritance + +PKL's `amends` keyword enables configuration inheritance. Create a base config that can be shared across projects: + +### Base Configuration (base.pkl) + +```pkl +amends "package://github.com/DesignPipe/exfig@2.0.0#/ExFig.pkl" + +import "package://github.com/DesignPipe/exfig@2.0.0#/Common.pkl" +import "package://github.com/DesignPipe/exfig@2.0.0#/Figma.pkl" + +figma = new Figma.FigmaConfig { + lightFileId = "YOUR_DESIGN_SYSTEM_FILE" + darkFileId = "YOUR_DESIGN_SYSTEM_DARK_FILE" + timeout = 60 +} + +common = new Common.CommonConfig { + cache = new Common.Cache { + enabled = true + } + variablesColors = new Common.VariablesColors { + tokensFileId = "YOUR_TOKENS_FILE" + tokensCollectionName = "Design System" + lightModeName = "Light" + darkModeName = "Dark" + lightHCModeName = "Light HC" + darkHCModeName = "Dark HC" + } + icons = new Common.Icons { + figmaFrameName = "Icons/24" + } + images = new Common.Images { + figmaFrameName = "Illustrations" + } +} +``` + +### Project Configuration (project-ios.pkl) + +```pkl +amends "base.pkl" + +import "package://github.com/DesignPipe/exfig@2.0.0#/iOS.pkl" + +ios = new iOS.iOSConfig { + xcodeprojPath = "ProjectA.xcodeproj" + target = "ProjectA" + xcassetsPath = "ProjectA/Assets.xcassets" + xcassetsInMainBundle = true + + colors = new iOS.ColorsEntry { + // Source comes from common.variablesColors (inherited from base.pkl) + useColorAssets = true + assetsFolder = "Colors" + nameStyle = "camelCase" + colorSwift = "ProjectA/Generated/UIColor+Colors.swift" + swiftuiColorSwift = "ProjectA/Generated/Color+Colors.swift" + } + + icons = new iOS.IconsEntry { + // figmaFrameName comes from common.icons (inherited from base.pkl) + format = "pdf" + assetsFolder = "Icons" + nameStyle = "camelCase" + renderMode = "template" + } +} +``` + +## Platform Configurations + +### iOS + +```pkl +ios = new iOS.iOSConfig { + // Required + xcodeprojPath = "MyApp.xcodeproj" // Path to Xcode project + target = "MyApp" // Xcode target name + xcassetsPath = "MyApp/Assets.xcassets" + xcassetsInMainBundle = true // true if assets in main bundle + + // Optional + xcassetsInSwiftPackage = false // true if assets in SPM package + resourceBundleNames = new Listing { "MyAppResources" } + addObjcAttribute = false // Add @objc to extensions + templatesPath = "Templates/" // Custom Stencil templates + + // Colors + colors = new iOS.ColorsEntry { + useColorAssets = true + assetsFolder = "Colors" + nameStyle = "camelCase" + groupUsingNamespace = false + colorSwift = "Generated/UIColor+Colors.swift" + swiftuiColorSwift = "Generated/Color+Colors.swift" + syncCodeSyntax = true + codeSyntaxTemplate = "Color.{name}" + } + + // Icons + icons = new iOS.IconsEntry { + figmaFrameName = "Icons" + format = "pdf" // "pdf" | "svg" + assetsFolder = "Icons" + nameStyle = "camelCase" + imageSwift = "Generated/UIImage+Icons.swift" + swiftUIImageSwift = "Generated/Image+Icons.swift" + codeConnectSwift = "Generated/Icons.figma.swift" + renderMode = "template" // "default" | "original" | "template" + preservesVectorRepresentation = new Listing { "icon-chevron" } + } + + // Images + images = new iOS.ImagesEntry { + figmaFrameName = "Illustrations" + assetsFolder = "Images" + nameStyle = "camelCase" + scales = new Listing { 1; 2; 3 } + sourceFormat = "svg" // "png" | "svg" + outputFormat = "heic" // "png" | "heic" + heicOptions = new iOS.HeicOptions { + encoding = "lossy" // "lossy" | "lossless" + quality = 90 + } + imageSwift = "Generated/UIImage+Images.swift" + swiftUIImageSwift = "Generated/Image+Images.swift" + } + + // Typography + typography = new iOS.Typography { + fontSwift = "Generated/UIFont+Styles.swift" + swiftUIFontSwift = "Generated/Font+Styles.swift" + labelStyleSwift = "Generated/LabelStyle.swift" + nameStyle = "camelCase" + generateLabels = true + labelsDirectory = "Generated/Labels/" + } +} +``` + +### Android + +```pkl +android = new Android.AndroidConfig { + // Required + mainRes = "app/src/main/res" + + // Optional + resourcePackage = "com.example.app" + mainSrc = "app/src/main/kotlin" + templatesPath = "Templates/" + + // Colors + colors = new Android.ColorsEntry { + xmlOutputFileName = "figma_colors.xml" + xmlDisabled = false // Skip XML for Compose-only + composePackageName = "com.example.app.ui.theme" + colorKotlin = "app/src/main/kotlin/ui/theme/Colors.kt" + themeAttributes = new Android.ThemeAttributes { + enabled = true + themeName = "Theme.MyApp" + attrsFile = "values/attrs.xml" + stylesFile = "values/styles.xml" + stylesNightFile = "values-night/styles.xml" + nameTransform = new Android.NameTransform { + style = "PascalCase" + prefix = "color" + stripPrefixes = new Listing { "bg"; "text" } + } + } + } + + // Icons + icons = new Android.IconsEntry { + figmaFrameName = "Icons" + output = "app/src/main/res/drawable" + composePackageName = "com.example.app.ui.icons" + composeFormat = "imageVector" // "resourceReference" | "imageVector" + composeExtensionTarget = "AppIcons" + nameStyle = "snake_case" + pathPrecision = 4 // 1-6, default 4 + strictPathValidation = true + } + + // Images + images = new Android.ImagesEntry { + figmaFrameName = "Illustrations" + output = "app/src/main/res/drawable" + format = "webp" // "svg" | "png" | "webp" + scales = new Listing { 1; 1.5; 2; 3; 4 } + sourceFormat = "svg" + webpOptions = new Android.WebpOptions { + encoding = "lossy" + quality = 85 + } + } + + // Typography + typography = new Android.Typography { + nameStyle = "camelCase" + composePackageName = "com.example.app.ui.theme" + } +} +``` + +### Flutter + +```pkl +flutter = new Flutter.FlutterConfig { + // Required + output = "lib/generated" + + // Optional + templatesPath = "Templates/" + + // Colors + colors = new Flutter.ColorsEntry { + output = "lib/generated/colors.dart" + className = "AppColors" + } + + // Icons + icons = new Flutter.IconsEntry { + figmaFrameName = "Icons" + output = "assets/icons" + dartFile = "lib/generated/icons.dart" + className = "AppIcons" + nameStyle = "camelCase" + } + + // Images + images = new Flutter.ImagesEntry { + figmaFrameName = "Illustrations" + output = "assets/images" + dartFile = "lib/generated/images.dart" + className = "AppImages" + scales = new Listing { 1; 2; 3 } + format = "webp" // "svg" | "png" | "webp" + sourceFormat = "svg" + nameStyle = "camelCase" + } +} +``` + +### Web + +```pkl +web = new Web.WebConfig { + // Required + output = "src/generated" + + // Optional + templatesPath = "Templates/" + + // Colors + colors = new Web.ColorsEntry { + outputDirectory = "src/generated/colors" + cssFileName = "colors.css" + tsFileName = "colors.ts" + jsonFileName = "colors.json" + } + + // Icons + icons = new Web.IconsEntry { + figmaFrameName = "Icons" + outputDirectory = "src/generated/icons" + svgDirectory = "public/icons" + generateReactComponents = true + iconSize = 24 + nameStyle = "PascalCase" + } + + // Images + images = new Web.ImagesEntry { + figmaFrameName = "Illustrations" + outputDirectory = "src/generated/images" + assetsDirectory = "public/images" + generateReactComponents = true + } +} +``` + +## Multiple Entries + +Each asset type supports multiple configurations for different sources or outputs: + +```pkl +ios = new iOS.iOSConfig { + xcodeprojPath = "MyApp.xcodeproj" + target = "MyApp" + xcassetsPath = "MyApp/Assets.xcassets" + xcassetsInMainBundle = true + + // Multiple color sources + colors = new Listing { + new iOS.ColorsEntry { + tokensFileId = "file1" + tokensCollectionName = "Brand Colors" + lightModeName = "Light" + useColorAssets = true + assetsFolder = "BrandColors" + nameStyle = "camelCase" + colorSwift = "Generated/UIColor+Brand.swift" + } + new iOS.ColorsEntry { + tokensFileId = "file2" + tokensCollectionName = "System Colors" + lightModeName = "Light" + useColorAssets = true + assetsFolder = "SystemColors" + nameStyle = "camelCase" + colorSwift = "Generated/UIColor+System.swift" + } + } + + // Multiple icon frames + icons = new Listing { + new iOS.IconsEntry { + figmaFrameName = "Icons/16" + format = "pdf" + assetsFolder = "Icons/Small" + nameStyle = "camelCase" + } + new iOS.IconsEntry { + figmaFrameName = "Icons/24" + format = "pdf" + assetsFolder = "Icons/Medium" + nameStyle = "camelCase" + } + } +} +``` + +## Entry-Level Overrides + +Each entry can override platform-level paths and even use a different Figma file. This is useful when different icon sets or image groups come from separate Figma files or need different output locations. + +Available override fields per platform: + +| Platform | Override Fields | +| -------- | ------------------------------------------------------- | +| iOS | `figmaFileId`, `xcassetsPath`, `templatesPath` | +| Android | `figmaFileId`, `mainRes`, `mainSrc`, `templatesPath` | +| Flutter | `figmaFileId`, `templatesPath` | +| Web | `figmaFileId`, `templatesPath` | + +When an override is set on an entry, it takes priority over the platform-level value. When not set, the platform config value is used as fallback. + +```pkl +ios = new iOS.iOSConfig { + xcodeprojPath = "MyApp.xcodeproj" + target = "MyApp" + xcassetsPath = "MyApp/Assets.xcassets" // Platform default + xcassetsInMainBundle = true + + icons = new Listing { + // Uses platform xcassetsPath ("MyApp/Assets.xcassets") + new iOS.IconsEntry { + format = "pdf" + assetsFolder = "Icons" + nameStyle = "camelCase" + } + // Overrides xcassetsPath and uses a separate Figma file + new iOS.IconsEntry { + figmaFileId = "brand-icons-figma-file" + figmaFrameName = "BrandIcons" + format = "svg" + assetsFolder = "BrandIcons" + nameStyle = "camelCase" + xcassetsPath = "BrandKit/Assets.xcassets" + templatesPath = "BrandKit/Templates" + } + } +} +``` + +## Common Settings + +Share settings across platforms using `common`: + +```pkl +common = new Common.CommonConfig { + // Version tracking cache + cache = new Common.Cache { + enabled = true + path = ".exfig-cache.json" + } + + // Shared color source for all platforms + variablesColors = new Common.VariablesColors { + tokensFileId = "YOUR_FILE_ID" + tokensCollectionName = "Design Tokens" + lightModeName = "Light" + darkModeName = "Dark" + lightHCModeName = "Light HC" + darkHCModeName = "Dark HC" + primitivesModeName = "Primitives" + } + + // Shared icons settings + icons = new Common.Icons { + figmaFrameName = "Icons/24" + useSingleFile = false + darkModeSuffix = "-dark" + strictPathValidation = true + } + + // Shared images settings + images = new Common.Images { + figmaFrameName = "Illustrations" + useSingleFile = false + darkModeSuffix = "-dark" + } + + // Name processing (applies to all) + colors = new Common.Colors { + nameValidateRegexp = "^[a-z][a-zA-Z0-9]*$" + nameReplaceRegexp = "color-" + } +} +``` + +## Figma Settings + +Configure Figma API access: + +```pkl +figma = new Figma.FigmaConfig { + lightFileId = "ABC123" // Light mode file + darkFileId = "DEF456" // Dark mode file (optional) + lightHighContrastFileId = "GHI789" + darkHighContrastFileId = "JKL012" + timeout = 60 // Request timeout in seconds +} +``` + +## Name Processing + +Control how Figma names are transformed: + +```pkl +colors = new iOS.ColorsEntry { + // Validate names match pattern + nameValidateRegexp = "^(bg|text|border)-.*$" + + // Replace parts of names + nameReplaceRegexp = "^(bg|text|border)-" // Strips prefix +} +``` + +### Name Styles + +- `camelCase`: backgroundPrimary +- `PascalCase`: BackgroundPrimary +- `snake_case`: background_primary +- `SCREAMING_SNAKE_CASE`: BACKGROUND_PRIMARY +- `flatCase`: backgroundprimary + +## Validation + +Validate your config without running export: + +```bash +pkl eval exfig.pkl +``` + +Check for type errors: + +```bash +pkl eval --format json exfig.pkl | jq . +``` + +## IDE Support + +### VS Code + +Install the [PKL extension](https://marketplace.visualstudio.com/items?itemName=apple.pkl-vscode) for: + +- Syntax highlighting +- Type checking +- Auto-completion +- Go to definition + +### IntelliJ IDEA + +Install the PKL plugin from JetBrains Marketplace. + +## Environment Variables + +Use PKL's read function for environment variables: + +```pkl +common = new Common.CommonConfig { + variablesColors = new Common.VariablesColors { + tokensFileId = read("env:FIGMA_TOKENS_FILE_ID") + // ... + } +} +``` + +**Note:** `FIGMA_PERSONAL_TOKEN` is read from environment by ExFig CLI, not from PKL config. + +## Troubleshooting + +### "pkl: command not found" + +Install PKL via mise: + +```bash +mise use pkl +``` + +### "Cannot find module" + +Ensure the package URL is correct and accessible: + +```pkl +// Correct +amends "package://github.com/DesignPipe/exfig@2.0.0#/ExFig.pkl" + +// Wrong (missing package://) +amends "github.com/DesignPipe/exfig@2.0.0#/ExFig.pkl" +``` + +### Type errors + +Check field names and types match the schema. Use IDE with PKL extension for real-time validation. + +## Resources + +- [PKL Documentation](https://pkl-lang.org/main/current/index.html) +- [PKL Language Reference](https://pkl-lang.org/main/current/language-reference/index.html) +- [ExFig Schema Reference](https://github.com/DesignPipe/exfig/tree/main/Sources/ExFigCLI/Resources/Schemas) diff --git a/Sources/ExFigCLI/ExFig.docc/Usage.md b/Sources/ExFigCLI/ExFig.docc/Usage.md index 115eb664..4f489607 100644 --- a/Sources/ExFigCLI/ExFig.docc/Usage.md +++ b/Sources/ExFigCLI/ExFig.docc/Usage.md @@ -4,7 +4,7 @@ Command-line interface reference and common usage patterns. ## Overview -ExFig provides commands for exporting colors, icons, images, and typography from Figma to native platform resources. +ExFig provides commands for exporting colors, icons, images, and typography from Figma and Penpot to native platform resources. ## Basic Commands @@ -194,6 +194,26 @@ exfig fetch -f abc123 -r "Images" -o ./images --format webp --webp-quality 90 exfig fetch -f abc123 -r "Images" -o ./images --format webp --webp-encoding lossless ``` +### Penpot Fetch + +```bash +# Fetch icons from Penpot as SVG +exfig fetch --source penpot -f "a1b2c3d4-..." -r "Icons / App" -o ./icons --format svg + +# Fetch as PNG at 3x scale (SVG reconstructed, then rasterized via resvg) +exfig fetch --source penpot -f "a1b2c3d4-..." -r "Icons" -o ./icons --format png --scale 3 + +# From a shared library (use library file ID) +exfig fetch --source penpot -f "library-uuid" -r "Icons / Actions" -o ./icons --format svg + +# Self-hosted Penpot instance +exfig fetch --source penpot --penpot-base-url https://penpot.mycompany.com/ \ + -f "uuid" -r "Icons" -o ./icons --format svg +``` + +> Set `PENPOT_ACCESS_TOKEN` environment variable (generate at Settings → Access Tokens). +> File ID is in the Penpot workspace URL: `?file-id=UUID`. + ### Scale Options ```bash diff --git a/Sources/ExFigCLI/ExFig.docc/WhyExFig.md b/Sources/ExFigCLI/ExFig.docc/WhyExFig.md index bfbc29f2..621a29a9 100644 --- a/Sources/ExFigCLI/ExFig.docc/WhyExFig.md +++ b/Sources/ExFigCLI/ExFig.docc/WhyExFig.md @@ -4,8 +4,9 @@ Understand the problems ExFig solves and how it fits into your workflow. ## Overview -Design-to-code handoff is broken. Every team that ships a mobile or web app with a Figma-based -design system eventually hits the same pain points — and ExFig was built to eliminate them. +Design-to-code handoff is broken. Every team that ships a mobile or web app with a Figma or +Penpot-based design system eventually hits the same pain points — and ExFig was built to +eliminate them. ## The Problem @@ -53,9 +54,11 @@ scale directories, plus Dart constants for type-safe access. ### Design Systems Lead -You own one Figma file that feeds four platforms. ExFig's unified PKL config lets you define -the source once and export to iOS, Android, Flutter, and Web from a single `exfig batch` run. -When a designer publishes a library update, one CI pipeline updates everything. +You own one Figma file — or a Penpot project — that feeds four platforms. ExFig's unified +PKL config lets you define the source once and export to iOS, Android, Flutter, and Web from +a single `exfig batch` run. When a designer publishes a library update, one CI pipeline +updates everything. Switching from Figma to Penpot? Change the source in config, keep +everything else. ### CI/CD Engineer diff --git a/Sources/ExFigCLI/ExFigCommand.swift b/Sources/ExFigCLI/ExFigCommand.swift index e8434e68..74e291c9 100644 --- a/Sources/ExFigCLI/ExFigCommand.swift +++ b/Sources/ExFigCLI/ExFigCommand.swift @@ -29,7 +29,7 @@ enum ExFigError: LocalizedError { "Components not found in Figma file" } case .accessTokenNotFound: - "FIGMA_PERSONAL_TOKEN not set" + "FIGMA_PERSONAL_TOKEN is required for Figma sources" case .colorsAssetsFolderNotSpecified: "Config missing: ios.colors.assetsFolder" case let .configurationError(message): @@ -55,7 +55,8 @@ enum ExFigError: LocalizedError { "Publish Components to the Team Library in Figma" } case .accessTokenNotFound: - "Run: export FIGMA_PERSONAL_TOKEN=your_token" + "Run: export FIGMA_PERSONAL_TOKEN=your_token\n" + + "Not needed if all entries use penpotSource or tokensFile." case .colorsAssetsFolderNotSpecified: "Add ios.colors.assetsFolder to your config file" case .configurationError, .custom: diff --git a/Sources/ExFigCLI/Input/DownloadOptions.swift b/Sources/ExFigCLI/Input/DownloadOptions.swift index a9c4f1f4..fab6293a 100644 --- a/Sources/ExFigCLI/Input/DownloadOptions.swift +++ b/Sources/ExFigCLI/Input/DownloadOptions.swift @@ -45,14 +45,31 @@ extension NameStyle: ExpressibleByArgument { } } +/// Design source for fetch command. +enum FetchSource: String, ExpressibleByArgument, CaseIterable, Sendable { + case figma + case penpot +} + /// CLI options for the download command. -/// All required parameters for downloading images from Figma without a config file. +/// All required parameters for downloading images from Figma or Penpot without a config file. struct DownloadOptions: ParsableArguments { + // MARK: - Source Options + + @Option(name: .long, help: "Design source: figma, penpot. Default: figma") + var source: FetchSource? + + @Option( + name: [.customLong("penpot-base-url")], + help: "Penpot instance base URL (default: design.penpot.app)" + ) + var penpotBaseURL: String? + // MARK: - Required Options @Option( name: [.customLong("file-id"), .customShort("f")], - help: "Figma file ID (from the URL: figma.com/file//...)" + help: "File ID (Figma file key or Penpot file UUID)" ) var fileId: String? diff --git a/Sources/ExFigCLI/Input/ExFigOptions.swift b/Sources/ExFigCLI/Input/ExFigOptions.swift index f43ff532..e9970165 100644 --- a/Sources/ExFigCLI/Input/ExFigOptions.swift +++ b/Sources/ExFigCLI/Input/ExFigOptions.swift @@ -25,8 +25,8 @@ struct ExFigOptions: ParsableArguments { // MARK: - Validated Properties /// Figma personal access token from FIGMA_PERSONAL_TOKEN environment variable. - /// Populated during `validate()`. - private(set) var accessToken: String! + /// Populated during `validate()`. May be `nil` if not set (validated lazily when needed). + private(set) var accessToken: String? /// Parsed configuration from the PKL input file. /// Populated during `validate()`. @@ -35,15 +35,21 @@ struct ExFigOptions: ParsableArguments { // MARK: - Validation mutating func validate() throws { - guard let token = ProcessInfo.processInfo.environment["FIGMA_PERSONAL_TOKEN"] else { - throw ExFigError.accessTokenNotFound - } - accessToken = token + accessToken = ProcessInfo.processInfo.environment["FIGMA_PERSONAL_TOKEN"] let configPath = try resolveConfigPath() params = try readParams(at: configPath) } + /// Returns the Figma access token, or throws if not set. + /// Call this only when the current operation requires Figma API access. + func requireFigmaToken() throws -> String { + guard let accessToken else { + throw ExFigError.accessTokenNotFound + } + return accessToken + } + // MARK: - Private Helpers private func resolveConfigPath() throws -> String { diff --git a/Sources/ExFigCLI/Input/FaultToleranceOptions.swift b/Sources/ExFigCLI/Input/FaultToleranceOptions.swift index 17c95b5e..8055cce4 100644 --- a/Sources/ExFigCLI/Input/FaultToleranceOptions.swift +++ b/Sources/ExFigCLI/Input/FaultToleranceOptions.swift @@ -253,7 +253,7 @@ struct HeavyFaultToleranceOptions: ParsableArguments { /// /// Timeout precedence: CLI `--timeout` > config > FigmaClient default (30s) func resolveClient( - accessToken: String, + accessToken: String?, timeout: TimeInterval?, options: FaultToleranceOptions, ui: TerminalUI @@ -261,6 +261,12 @@ func resolveClient( if let injectedClient = InjectedClientStorage.client { return injectedClient } + guard let accessToken else { + // No Figma token — return a client that throws on any call. + // Non-Figma sources (Penpot, tokens-file) never call it. + // SourceFactory also guards the .figma branch with accessTokenNotFound. + return NoTokenFigmaClient() + } // CLI timeout takes precedence over config timeout let effectiveTimeout: TimeInterval? = options.timeout.map { TimeInterval($0) } ?? timeout let baseClient = FigmaClient(accessToken: accessToken, timeout: effectiveTimeout) @@ -284,8 +290,12 @@ func resolveClient( /// Resolves a Figma API client for heavy commands, using injected client if available /// (batch mode) or creating a new rate-limited client (standalone command mode). /// +/// When `accessToken` is nil (no `FIGMA_PERSONAL_TOKEN`), returns ``NoTokenFigmaClient`` — +/// a fail-fast client that throws on any request. Non-Figma sources (Penpot, tokens-file) +/// never call it, so pure-Penpot workflows work without Figma credentials. +/// /// - Parameters: -/// - accessToken: Figma personal access token. +/// - accessToken: Figma personal access token (nil when using non-Figma sources only). /// - timeout: Request timeout interval from config (optional, uses FigmaClient default if nil). /// - options: Heavy fault tolerance options for creating new client (may contain CLI timeout override). /// - ui: Terminal UI for retry warnings. @@ -293,7 +303,7 @@ func resolveClient( /// /// Timeout precedence: CLI `--timeout` > config > FigmaClient default (30s) func resolveClient( - accessToken: String, + accessToken: String?, timeout: TimeInterval?, options: HeavyFaultToleranceOptions, ui: TerminalUI @@ -301,6 +311,11 @@ func resolveClient( if let injectedClient = InjectedClientStorage.client { return injectedClient } + guard let accessToken else { + // No Figma token — return a client that throws on any call. + // Non-Figma sources (Penpot, tokens-file) never call it. + return NoTokenFigmaClient() + } // CLI timeout takes precedence over config timeout let effectiveTimeout: TimeInterval? = options.timeout.map { TimeInterval($0) } ?? timeout let baseClient = FigmaClient(accessToken: accessToken, timeout: effectiveTimeout) @@ -320,3 +335,16 @@ func resolveClient( } ) } + +// MARK: - No-Token Client + +/// A Figma API client placeholder that throws `accessTokenNotFound` on any request. +/// +/// Used when `FIGMA_PERSONAL_TOKEN` is not set. Non-Figma sources (Penpot, tokens-file) +/// never call this client. If accidentally invoked, the error message clearly tells the user +/// to set the token — instead of making real HTTP requests with an invalid token. +final class NoTokenFigmaClient: Client, @unchecked Sendable { + func request(_: T) async throws -> T.Content { + throw ExFigError.accessTokenNotFound + } +} diff --git a/Sources/ExFigCLI/MCP/MCPPrompts.swift b/Sources/ExFigCLI/MCP/MCPPrompts.swift index 4854d37f..67eb3a77 100644 --- a/Sources/ExFigCLI/MCP/MCPPrompts.swift +++ b/Sources/ExFigCLI/MCP/MCPPrompts.swift @@ -14,6 +14,7 @@ enum MCPPrompts { description: "Guide through creating an exfig.pkl configuration file for a specific platform", arguments: [ .init(name: "platform", description: "Target platform: ios, android, flutter, or web", required: true), + .init(name: "source", description: "Design source: figma (default) or penpot"), .init( name: "project_path", description: "Path to the project directory (defaults to current directory)" @@ -53,6 +54,7 @@ enum MCPPrompts { throw MCPError.invalidParams("Missing required argument: platform") } + let source = arguments?["source"]?.stringValue ?? "figma" let projectPath = arguments?["project_path"]?.stringValue ?? "." let validPlatforms = ["ios", "android", "flutter", "web"] @@ -62,6 +64,21 @@ enum MCPPrompts { ) } + let validSources = ["figma", "penpot"] + guard validSources.contains(source) else { + throw MCPError.invalidParams( + "Invalid source '\(source)'. Must be one of: \(validSources.joined(separator: ", "))" + ) + } + + if source == "penpot" { + return try getSetupConfigPenpot(platform: platform, projectPath: projectPath) + } + + return try getSetupConfigFigma(platform: platform, projectPath: projectPath) + } + + private static func getSetupConfigFigma(platform: String, projectPath: String) throws -> GetPrompt.Result { let schemaName = platform == "ios" ? "iOS" : platform.capitalized let text = """ @@ -71,8 +88,9 @@ enum MCPPrompts { Please help me: 1. Read the ExFig \(schemaName) schema (use exfig://schemas/\(schemaName).pkl resource) 2. Read the starter template (use exfig://templates/\(platform) resource) - 3. Examine my project structure to determine correct output paths - 4. Create a properly configured exfig.pkl file + 3. Read the design file structure guide (use exfig://guides/DesignRequirements.md resource) + 4. Examine my project structure to determine correct output paths + 5. Create a properly configured exfig.pkl file I need to set: - Figma file ID(s) for my design files @@ -83,11 +101,50 @@ enum MCPPrompts { """ return .init( - description: "Setup ExFig \(platform) configuration at \(projectPath)", + description: "Setup ExFig \(platform) configuration with Figma at \(projectPath)", + messages: [.user(.text(text: text))] + ) + } + + // swiftlint:disable function_body_length + private static func getSetupConfigPenpot(platform: String, projectPath: String) throws -> GetPrompt.Result { + let schemaName = platform == "ios" ? "iOS" : platform.capitalized + + let text = """ + I need to create an exfig.pkl configuration file for the \(platform) platform \ + using **Penpot** as the design source in the project at \(projectPath). + + Please help me: + 1. Read the Common schema for PenpotSource (use exfig://schemas/Common.pkl resource) + 2. Read the \(schemaName) schema (use exfig://schemas/\(schemaName).pkl resource) + 3. Read the design file structure guide (use exfig://guides/DesignRequirements.md resource) \ + — focus on the Penpot section + 4. Read the starter template (use exfig://templates/\(platform) resource) + 5. Examine my project structure to determine correct output paths + 6. Create a properly configured exfig.pkl file with penpotSource entries + + I need to set: + - Penpot file UUID (from the Penpot workspace URL) + - Optional: custom Penpot instance URL (if self-hosted, default: design.penpot.app) + - Path filters matching my Penpot library structure + - Output paths matching my project structure + + Important notes: + - PENPOT_ACCESS_TOKEN must be set (not FIGMA_PERSONAL_TOKEN) + - No `figma` section needed when using only Penpot sources + - Icons/images: SVG reconstructed from shape tree (supports SVG, PNG, PDF output) + + First, validate the config with exfig_validate after creating it. + """ + + return .init( + description: "Setup ExFig \(platform) configuration with Penpot at \(projectPath)", messages: [.user(.text(text: text))] ) } + // swiftlint:enable function_body_length + // MARK: - Troubleshoot private static func getTroubleshoot(arguments: [String: Value]?) throws -> GetPrompt.Result { @@ -108,9 +165,14 @@ enum MCPPrompts { Please help me diagnose and fix this error: 1. First, validate the config with exfig_validate (config_path: "\(configPath)") - 2. Check if FIGMA_PERSONAL_TOKEN is set if the error is auth-related + 2. Check authentication: + - If the error mentions Figma or 401: check FIGMA_PERSONAL_TOKEN + - If the error mentions Penpot or PENPOT_ACCESS_TOKEN: check PENPOT_ACCESS_TOKEN + - For Penpot "malformed-json" errors: ensure ExFig is up to date 3. If it's a PKL error, read the relevant schema to understand the expected structure - 4. Suggest specific fixes with code examples + 4. Read the design file structure guide (exfig://guides/DesignRequirements.md) for \ + file preparation requirements + 5. Suggest specific fixes with code examples """ return .init( diff --git a/Sources/ExFigCLI/MCP/MCPResources.swift b/Sources/ExFigCLI/MCP/MCPResources.swift index bb7fb4f5..c44aa913 100644 --- a/Sources/ExFigCLI/MCP/MCPResources.swift +++ b/Sources/ExFigCLI/MCP/MCPResources.swift @@ -1,7 +1,7 @@ import Foundation import MCP -/// MCP resource definitions — PKL schemas and starter config templates. +/// MCP resource definitions — PKL schemas, starter config templates, and guides. enum MCPResources { // MARK: - Schema file names @@ -24,6 +24,16 @@ enum MCPResources { ("Web Starter Config", "web", webConfigFileContents), ] + // MARK: - Guide entries + + private static let guideFiles: [(name: String, file: String, description: String)] = [ + ( + "Design File Structure", + "DesignRequirements.md", + "How to prepare Figma and Penpot files for ExFig export — colors, components, typography, naming" + ), + ] + // MARK: - Public API static var allResources: [Resource] { @@ -50,6 +60,16 @@ enum MCPResources { )) } + // Guides + for guide in guideFiles { + resources.append(Resource( + name: guide.name, + uri: "exfig://guides/\(guide.file)", + description: guide.description, + mimeType: "text/markdown" + )) + } + return resources } @@ -75,6 +95,17 @@ enum MCPResources { return .init(contents: [Resource.Content.text(entry.content, uri: uri, mimeType: "text/plain")]) } + // Guide resources + if uri.hasPrefix("exfig://guides/") { + let fileName = String(uri.dropFirst("exfig://guides/".count)) + guard guideFiles.contains(where: { $0.file == fileName }) else { + throw MCPError.invalidParams("Unknown guide: \(fileName)") + } + + let content = try loadGuideContent(fileName: fileName) + return .init(contents: [Resource.Content.text(content, uri: uri, mimeType: "text/markdown")]) + } + throw MCPError.invalidParams("Unknown resource URI: \(uri)") } @@ -87,4 +118,13 @@ enum MCPResources { } return try String(contentsOf: url, encoding: .utf8) } + + // MARK: - Guide Loading + + private static func loadGuideContent(fileName: String) throws -> String { + guard let url = Bundle.module.url(forResource: fileName, withExtension: nil, subdirectory: "Guides") else { + throw MCPError.invalidParams("Guide file not found in bundle: \(fileName)") + } + return try String(contentsOf: url, encoding: .utf8) + } } diff --git a/Sources/ExFigCLI/Output/FileDownloader.swift b/Sources/ExFigCLI/Output/FileDownloader.swift index 07c38e29..2062530f 100644 --- a/Sources/ExFigCLI/Output/FileDownloader.swift +++ b/Sources/ExFigCLI/Output/FileDownloader.swift @@ -47,8 +47,19 @@ final class FileDownloader: Sendable { files: [FileContents], onProgress: DownloadProgressCallback? = nil ) async throws -> [FileContents] { - let remoteFiles = files.filter { $0.sourceURL != nil } - let localFiles = files.filter { $0.sourceURL == nil } + // file:// URLs (e.g., Penpot SVG from shape tree) are already on disk — treat as local + let remoteFiles = files.filter { $0.sourceURL != nil && !($0.sourceURL?.isFileURL ?? false) } + let localFileURLs = files.filter { $0.sourceURL?.isFileURL == true }.compactMap { file -> FileContents? in + guard let sourceURL = file.sourceURL else { return nil } + return FileContents( + destination: file.destination, + dataFile: sourceURL, + scale: file.scale, + dark: file.dark, + isRTL: file.isRTL + ) + } + let localFiles = files.filter { $0.sourceURL == nil } + localFileURLs let remoteFileCount = remoteFiles.count if remoteFiles.isEmpty { diff --git a/Sources/ExFigCLI/Resources/Guides/DesignRequirements.md b/Sources/ExFigCLI/Resources/Guides/DesignRequirements.md new file mode 100644 index 00000000..6f6c9499 --- /dev/null +++ b/Sources/ExFigCLI/Resources/Guides/DesignRequirements.md @@ -0,0 +1,527 @@ +# Design File Structure + +How to structure your design files for optimal export with ExFig. + +## Overview + +ExFig extracts design resources from **Figma** files and **Penpot** projects based on naming conventions +and organizational structures. This guide explains how to set up your design files for seamless export. + +- **Figma**: Uses frames, components, color styles, and Variables +- **Penpot**: Uses shared library colors, components, and typographies + +## Figma + +### Frame Organization + +ExFig looks for resources in specific frames. Configure frame names in your `exfig.pkl`: + +```pkl +import ".exfig/schemas/Common.pkl" + +common = new Common.CommonConfig { + colors = new Common.Colors { + figmaFrameName = "Colors" + } + icons = new Common.Icons { + figmaFrameName = "Icons" + } + images = new Common.Images { + figmaFrameName = "Illustrations" + } + typography = new Common.Typography { + figmaFrameName = "Typography" + } +} +``` + +### Naming Conventions + +Use consistent naming patterns for all resources. ExFig supports regex validation: + +```pkl +import ".exfig/schemas/Common.pkl" + +common = new Common.CommonConfig { + icons = new Common.Icons { + nameValidateRegexp = "^ic/[0-9]+/[a-z_]+$" // e.g., ic/24/arrow_right + } +} +``` + +### Colors + +#### Using Color Styles + +Create color styles in Figma with descriptive names: + +``` +Colors frame +├── primary +├── secondary +├── background/primary +├── background/secondary +├── text/primary +├── text/secondary +└── border/default +``` + +#### Using Figma Variables + +For Figma Variables API support: + +```pkl +import ".exfig/schemas/Common.pkl" + +common = new Common.CommonConfig { + variablesColors = new Common.VariablesColors { + tokensFileId = "ABC123xyz" + tokensCollectionName = "Colors" + lightModeName = "Light" + darkModeName = "Dark" + } +} +``` + +Variable structure in Figma: + +``` +Colors collection +├── Mode: Light +│ ├── primary: #007AFF +│ ├── background: #FFFFFF +│ └── text: #000000 +└── Mode: Dark + ├── primary: #0A84FF + ├── background: #000000 + └── text: #FFFFFF +``` + +#### Naming Guidelines + +- Use lowercase with optional separators: `/`, `-`, `_` +- Group related colors with prefixes: `text/primary`, `background/card` +- Avoid special characters except separators + +### Icons + +#### Component Structure + +Icons must be **components** (not plain frames): + +``` +Icons frame +├── ic/24/arrow-right (component) +├── ic/24/arrow-left (component) +├── ic/16/close (component) +├── ic/16/check (component) +└── ic/32/menu (component) +``` + +#### Size Conventions + +Organize icons by size: + +``` +Icons frame +├── ic/16/... (16pt icons) +├── ic/24/... (24pt icons) +├── ic/32/... (32pt icons) +└── ic/48/... (48pt icons) +``` + +#### Vector Requirements + +For optimal vector export: + +1. **Use strokes carefully**: Convert strokes to outlines for complex icons +2. **Flatten boolean operations**: Flatten complex boolean operations before export +3. **Remove hidden layers**: Delete unused or hidden elements +4. **Use consistent viewBox**: Keep viewBox dimensions consistent within size groups + +#### Dark Mode Icons + +Two approaches for dark mode support: + +**Separate files:** + +```pkl +import ".exfig/schemas/Figma.pkl" + +figma = new Figma.FigmaConfig { + lightFileId = "abc123" + darkFileId = "def456" +} +``` + +Create matching component names in both files. + +**Single file with suffix:** + +```pkl +import ".exfig/schemas/Common.pkl" + +common = new Common.CommonConfig { + icons = new Common.Icons { + useSingleFile = true + darkModeSuffix = "_dark" + } +} +``` + +``` +Icons frame +├── ic/24/arrow-right +├── ic/24/arrow-right_dark +├── ic/24/close +└── ic/24/close_dark +``` + +### Images + +#### Component Structure + +Images must be **components**: + +``` +Illustrations frame +├── img-empty-state (component) +├── img-onboarding-1 (component) +├── img-onboarding-2 (component) +└── img-hero-banner (component) +``` + +#### Size Recommendations + +Design at the largest needed scale: + +- **iOS**: Design at @3x, ExFig generates @1x, @2x, @3x +- **Android**: Design at xxxhdpi (4x), ExFig generates all densities +- **Flutter**: Design at 3x, ExFig generates 1x, 2x, 3x + +#### Multi-Idiom Support (iOS) + +Use suffixes for device-specific variants: + +``` +Illustrations frame +├── img-hero~iphone (iPhone variant) +├── img-hero~ipad (iPad variant) +├── img-hero~mac (Mac variant) +└── img-sidebar~ipad +``` + +#### Dark Mode Images + +Same approaches as icons: + +**Separate files** or **suffix-based**: + +``` +Illustrations frame +├── img-empty-state +├── img-empty-state_dark +├── img-hero +└── img-hero_dark +``` + +### Typography + +#### Text Style Structure + +Create text styles with hierarchical names: + +``` +Typography frame +├── heading/h1 +├── heading/h2 +├── heading/h3 +├── body/regular +├── body/bold +├── caption/regular +└── caption/small +``` + +#### Required Properties + +Each text style should define: + +- **Font family**: e.g., "SF Pro Text" +- **Font weight**: e.g., Regular, Bold, Semibold +- **Font size**: in pixels +- **Line height**: in pixels or percentage +- **Letter spacing**: in pixels or percentage + +#### Font Mapping + +Map Figma fonts to platform fonts in your config: + +```pkl +import ".exfig/schemas/iOS.pkl" +import ".exfig/schemas/Android.pkl" + +ios = new iOS.iOSConfig { + typography = new iOS.Typography { + // fontMapping configured via custom templates + } +} + +android = new Android.AndroidConfig { + typography = new Android.Typography { + // fontMapping configured via custom templates + } +} +``` + +### Validation Regex Patterns + +#### Common Patterns + +```pkl +import ".exfig/schemas/Common.pkl" + +common = new Common.CommonConfig { + colors = new Common.Colors { + // Allow: primary, text/primary, background_card + nameValidateRegexp = "^[a-z][a-z0-9_/]*$" + } + + icons = new Common.Icons { + // Require: ic/SIZE/name format + nameValidateRegexp = "^ic/[0-9]+/[a-z][a-z0-9_-]*$" + } + + images = new Common.Images { + // Require: img- prefix + nameValidateRegexp = "^img-[a-z][a-z0-9_-]*$" + } +} +``` + +#### Transform Patterns + +Transform names during export: + +```pkl +import ".exfig/schemas/Common.pkl" + +common = new Common.CommonConfig { + icons = new Common.Icons { + nameValidateRegexp = "^ic/([0-9]+)/(.+)$" + nameReplaceRegexp = "ic$1_$2" // ic/24/arrow -> ic24_arrow + } +} +``` + +### Recommended Figma Structure + +``` +Design System +├── Colors +│ ├── Primary palette +│ ├── Secondary palette +│ ├── Semantic colors +│ └── Dark mode colors +├── Icons +│ ├── 16pt icons +│ ├── 24pt icons +│ └── 32pt icons +├── Illustrations +│ ├── Empty states +│ ├── Onboarding +│ └── Marketing +└── Typography + ├── Headings + ├── Body text + └── Captions +``` + +### Light and Dark Mode Files + +For complex theming, use separate files: + +- `Design-System-Light.fig`: Light mode resources +- `Design-System-Dark.fig`: Dark mode resources + +Ensure component names match exactly between files. + +### Figma Troubleshooting + +#### Resources Not Found + +- Verify frame names match `figmaFrameName` in config +- Check that resources are **components**, not plain frames +- Ensure names pass validation regex + +#### Missing Dark Mode + +- Verify `darkFileId` is set correctly +- Check component names match between light and dark files +- For single-file mode, verify suffix is correct + +#### Export Quality Issues + +- Design at highest needed resolution +- Use vector graphics when possible +- Avoid raster effects in vector icons +- Flatten complex boolean operations + +## Penpot + +ExFig reads Penpot library assets — colors, components, and typographies — from the shared library +of a Penpot file. All assets must be added to the **shared library** (Assets panel), not just placed +on the canvas. + +### Authentication + +Set the `PENPOT_ACCESS_TOKEN` environment variable: + +1. Open Penpot → Settings → Access Tokens +2. Create a new token (no expiration recommended for CI) +3. Export: + +```bash +export PENPOT_ACCESS_TOKEN="your-token-here" +``` + +No `FIGMA_PERSONAL_TOKEN` needed when using only Penpot sources. + +### Library Colors + +Colors must be in the shared **Library** (Assets panel → Local library → Colors): + +``` +Library Colors +├── Brand/Primary (#3B82F6) +├── Brand/Secondary (#8B5CF6) +├── Semantic/Success (#22C55E) +├── Semantic/Warning (#F59E0B) +├── Semantic/Error (#EF4444) +├── Neutral/Background (#1E1E2E) +├── Neutral/Text (#F8F8F2) +└── Neutral/Overlay (#000000, 50% opacity) +``` + +Key points: + +- Only **solid hex colors** are exported. Gradients and image fills are skipped in v1. +- The `path` field organizes colors into groups: `path: "Brand"`, `name: "Primary"` → `Brand/Primary` +- Use `pathFilter` in your config to select a specific group: `pathFilter = "Brand"` exports only Brand colors +- **Opacity** is preserved (0.0–1.0) + +Config example: + +```pkl +penpotSource = new Common.PenpotSource { + fileId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + pathFilter = "Brand" // optional: export only Brand/* colors +} +``` + +### Library Components (Icons and Images) + +Components must be in the shared **Library** (Assets panel → Local library → Components). +ExFig filters by the component `path` prefix (equivalent to Figma's frame name): + +``` +Library Components +├── Icons/Navigation/arrow-left +├── Icons/Navigation/arrow-right +├── Icons/Actions/close +├── Icons/Actions/check +├── Illustrations/Empty States/no-data +└── Illustrations/Onboarding/welcome +``` + +Config example: + +```pkl +penpotSource = new Common.PenpotSource { + fileId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" +} +// Use path prefix as the frame filter +figmaFrameName = "Icons/Navigation" // exports arrow-left, arrow-right +``` + +ExFig reconstructs SVG directly from Penpot's shape tree — no headless Chrome or CDN needed. +Supported output formats: **SVG** (native vector), **PNG** (via resvg at any scale), **PDF**, **WebP**. + +### Library Typography + +Typography styles must be in the shared **Library** (Assets panel → Local library → Typography): + +``` +Library Typography +├── Heading/H1 (Roboto Bold 32px) +├── Heading/H2 (Roboto Bold 24px) +├── Body/Regular (Roboto Regular 16px) +├── Body/Bold (Roboto Bold 16px) +└── Caption/Small (Roboto Regular 12px) +``` + +Required fields: + +- **fontFamily** — e.g., "Roboto", "DM Mono" +- **fontSize** — must be set (styles without a parseable font size are skipped) + +Supported fields: `fontWeight`, `lineHeight`, `letterSpacing`, `textTransform` (uppercase/lowercase). + +> Penpot may serialize numeric fields as strings (e.g., `"24"` instead of `24`). ExFig handles both formats automatically. + +### Recommended Penpot Structure + +``` +Design System (Penpot file) +├── Library Colors +│ ├── Brand/* (primary, secondary, accent) +│ ├── Semantic/* (success, warning, error, info) +│ └── Neutral/* (background, text, border, overlay) +├── Library Components +│ ├── Icons/Navigation/* (arrow, chevron, menu) +│ ├── Icons/Actions/* (close, check, edit, delete) +│ └── Illustrations/* (empty states, onboarding) +└── Library Typography + ├── Heading/* (H1, H2, H3) + ├── Body/* (regular, bold, italic) + └── Caption/* (regular, small) +``` + +### Known Limitations + +- **No dark mode support** — Penpot has no Variables/modes equivalent; colors export as light-only +- **No `exfig_inspect` for Penpot** — the MCP inspect tool works with Figma API only +- **Gradients skipped** — only solid hex colors are supported +- **No page filtering** — all library assets are global to the file, not page-scoped +- **SVG reconstruction scope** — supports path, rect, circle, bool, group shapes; complex effects (blur, shadow, gradients on shapes) are not yet rendered + +### Penpot Troubleshooting + +#### No Colors Exported + +- Verify colors are in the **shared library**, not just swatches on the canvas +- Check `pathFilter` — a too-specific prefix returns no results +- Gradient colors are skipped; use solid fills + +#### No Components Exported + +- Verify components are in the **shared library** (right-click shape → "Create component") +- Check the path prefix in `figmaFrameName` matches the component `path` +- Thumbnails may not be generated for programmatically created components + +#### Typography Styles Skipped + +- Ensure `fontSize` is set on the typography style +- Styles with unparseable font size values are silently skipped + +#### Authentication Errors + +- `PENPOT_ACCESS_TOKEN environment variable is required` — set the token +- Penpot 401 — token expired or invalid; regenerate in Settings → Access Tokens +- Self-hosted instances: set `baseUrl` in `penpotSource` + +## See Also + +- +- +- +- diff --git a/Sources/ExFigCLI/Resources/Schemas/Common.pkl b/Sources/ExFigCLI/Resources/Schemas/Common.pkl index 32ea34f5..494d1628 100644 --- a/Sources/ExFigCLI/Resources/Schemas/Common.pkl +++ b/Sources/ExFigCLI/Resources/Schemas/Common.pkl @@ -45,6 +45,21 @@ class TokensFile { groupFilter: String? } +// MARK: - Penpot Source + +/// Penpot design source configuration. +/// When set on a colors/icons/images entry, loads assets from Penpot API instead of Figma. +class PenpotSource { + /// Penpot file UUID. + fileId: String(isNotEmpty) + + /// Penpot instance base URL (default: Penpot cloud). + baseUrl: String = "https://design.penpot.app/" + + /// Optional path prefix to filter assets (e.g., "Brand/Primary"). + pathFilter: String? +} + // MARK: - Cache /// Cache configuration for tracking Figma file versions. @@ -74,10 +89,14 @@ open class NameProcessing { /// All fields are optional to support legacy format where source comes from common.variablesColors. open class VariablesSource extends NameProcessing { /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" /// - If `tokensFile` is set → "tokens-file" /// - Otherwise → "figma" sourceKind: SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + penpotSource: PenpotSource? + /// Local .tokens.json file source (bypasses Figma API when set). tokensFile: TokensFile? @@ -106,9 +125,14 @@ open class VariablesSource extends NameProcessing { /// Figma Frame source configuration. /// Used for icons and images that come from Figma frames. open class FrameSource extends NameProcessing { - /// Design source kind override. When null, defaults to "figma". + /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" + /// - Otherwise → "figma" sourceKind: SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + penpotSource: PenpotSource? + /// Figma frame name to export from. figmaFrameName: String? diff --git a/Sources/ExFigCLI/Source/PenpotClientFactory.swift b/Sources/ExFigCLI/Source/PenpotClientFactory.swift new file mode 100644 index 00000000..b86e36a2 --- /dev/null +++ b/Sources/ExFigCLI/Source/PenpotClientFactory.swift @@ -0,0 +1,19 @@ +import Foundation +import PenpotAPI + +/// Shared factory for creating authenticated Penpot API clients. +enum PenpotClientFactory { + static func makeClient(baseURL: String) throws -> any PenpotClient { + guard URL(string: baseURL)?.host != nil else { + throw ExFigError.configurationError( + "Invalid Penpot base URL '\(baseURL)' — must be a valid URL (e.g., https://design.penpot.app/)" + ) + } + guard let token = ProcessInfo.processInfo.environment["PENPOT_ACCESS_TOKEN"], !token.isEmpty else { + throw ExFigError.configurationError( + "PENPOT_ACCESS_TOKEN environment variable is required for Penpot source" + ) + } + return BasePenpotClient(accessToken: token, baseURL: baseURL) + } +} diff --git a/Sources/ExFigCLI/Source/PenpotColorsSource.swift b/Sources/ExFigCLI/Source/PenpotColorsSource.swift new file mode 100644 index 00000000..a36b1cc3 --- /dev/null +++ b/Sources/ExFigCLI/Source/PenpotColorsSource.swift @@ -0,0 +1,97 @@ +import ExFigCore +import Foundation +import PenpotAPI + +struct PenpotColorsSource: ColorsSource { + let ui: TerminalUI + + func loadColors(from input: ColorsSourceInput) async throws -> ColorsLoadOutput { + guard let config = input.sourceConfig as? PenpotColorsConfig else { + throw ExFigError.configurationError( + "PenpotColorsSource requires PenpotColorsConfig, got \(type(of: input.sourceConfig))" + ) + } + + let client = try PenpotClientFactory.makeClient(baseURL: config.baseURL) + let fileResponse = try await client.request(GetFileEndpoint(fileId: config.fileId)) + + guard let penpotColors = fileResponse.data.colors else { + ui.warning("Penpot file '\(fileResponse.name)' has no library colors") + return ColorsLoadOutput(light: []) + } + + var colors: [Color] = [] + var skippedNonSolid = 0 + var skippedByFilter = 0 + + for (_, penpotColor) in penpotColors.sorted(by: { $0.key < $1.key }) { + // Skip gradient/image fills (no solid hex) + guard let hex = penpotColor.color else { + skippedNonSolid += 1 + continue + } + + // Apply path filter + if let pathFilter = config.pathFilter { + guard let path = penpotColor.path, path.hasPrefix(pathFilter) else { + skippedByFilter += 1 + continue + } + } + + guard let rgba = Self.hexToRGBA(hex: hex, opacity: penpotColor.opacity ?? 1.0) else { + ui.warning("Color '\(penpotColor.name)' has invalid hex value '\(hex)' — skipping") + continue + } + + let name = if let path = penpotColor.path { + path + "/" + penpotColor.name + } else { + penpotColor.name + } + + colors.append(Color( + name: name, + platform: nil, + red: rgba.red, + green: rgba.green, + blue: rgba.blue, + alpha: rgba.alpha + )) + } + + if skippedNonSolid > 0 { + ui.warning( + "Skipped \(skippedNonSolid) color(s) without solid hex values " + + "(gradients and image fills are not supported)" + ) + } + if skippedByFilter > 0 { + ui.warning("Skipped \(skippedByFilter) color(s) not matching path filter '\(config.pathFilter!)'") + } + + // Penpot has no mode-based variants — light only + return ColorsLoadOutput(light: colors) + } + + // MARK: - Internal + + static func hexToRGBA(hex: String, opacity: Double) + -> (red: Double, green: Double, blue: Double, alpha: Double)? + { + var hexString = hex.trimmingCharacters(in: .whitespacesAndNewlines) + if hexString.hasPrefix("#") { + hexString.removeFirst() + } + + guard hexString.count == 6, let hexValue = UInt64(hexString, radix: 16) else { + return nil + } + + let red = Double((hexValue >> 16) & 0xFF) / 255.0 + let green = Double((hexValue >> 8) & 0xFF) / 255.0 + let blue = Double(hexValue & 0xFF) / 255.0 + + return (red: red, green: green, blue: blue, alpha: opacity) + } +} diff --git a/Sources/ExFigCLI/Source/PenpotComponentsSource.swift b/Sources/ExFigCLI/Source/PenpotComponentsSource.swift new file mode 100644 index 00000000..ba37de8c --- /dev/null +++ b/Sources/ExFigCLI/Source/PenpotComponentsSource.swift @@ -0,0 +1,147 @@ +import ExFigCore +import Foundation +import PenpotAPI + +struct PenpotComponentsSource: ComponentsSource { + let ui: TerminalUI + + func loadIcons(from input: IconsSourceInput) async throws -> IconsLoadOutput { + let packs = try await loadComponents( + fileId: input.figmaFileId, + baseURL: input.penpotBaseURL, + pathFilter: input.frameName + ) + return IconsLoadOutput(light: packs) + } + + func loadImages(from input: ImagesSourceInput) async throws -> ImagesLoadOutput { + let packs = try await loadComponents( + fileId: input.figmaFileId, + baseURL: input.penpotBaseURL, + pathFilter: input.frameName + ) + return ImagesLoadOutput(light: packs) + } + + // MARK: - Private + + private func loadComponents( + fileId: String?, + baseURL: String?, + pathFilter: String + ) async throws -> [ImagePack] { + guard let fileId, !fileId.isEmpty else { + throw ExFigError.configurationError( + "Penpot file ID is required for components export — set penpotSource.fileId in your config" + ) + } + + let effectiveBaseURL = baseURL ?? BasePenpotClient.defaultBaseURL + let client = try PenpotClientFactory.makeClient(baseURL: effectiveBaseURL) + + let fileResponse = try await client.request(GetFileEndpoint(fileId: fileId)) + + guard let components = fileResponse.data.components else { + ui.warning("Penpot file '\(fileResponse.name)' has no library components") + return [] + } + + // Filter components by path + let matchedComponents = components.values.filter { component in + guard let path = component.path else { return false } + return path.hasPrefix(pathFilter) + } + + let sortedComponents = matchedComponents.sorted { $0.name < $1.name } + + guard !sortedComponents.isEmpty else { + let availablePaths = components.values + .compactMap(\.path) + .reduce(into: Set()) { $0.insert($1) } + .sorted() + .prefix(5) + let pathHint = availablePaths.isEmpty + ? "" + : " Available paths: \(availablePaths.joined(separator: ", "))" + ui.warning("No components matching path prefix '\(pathFilter)'.\(pathHint)") + return [] + } + + let packs = try reconstructSVGs( + components: sortedComponents, + fileResponse: fileResponse, + fileId: fileId + ) + + if packs.isEmpty, !sortedComponents.isEmpty { + ui.warning( + "Found \(sortedComponents.count) components but could not reconstruct SVG for any. " + + "Components may lack mainInstanceId (not opened in Penpot editor)." + ) + } + + return packs + } + + private func reconstructSVGs( + components: [PenpotComponent], + fileResponse: PenpotFileResponse, + fileId: String + ) throws -> [ImagePack] { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("exfig-penpot-\(ProcessInfo.processInfo.processIdentifier)") + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + + var packs: [ImagePack] = [] + + for component in components { + guard let pageId = component.mainInstancePage, + let instanceId = component.mainInstanceId, + let page = fileResponse.data.pagesIndex?[pageId], + let objects = page.objects + else { + ui.warning("Component '\(component.name)' has no shape data — skipping") + continue + } + + let renderResult = PenpotShapeRenderer.renderSVGResult( + objects: objects, rootId: instanceId + ) + let svgString: String + switch renderResult { + case let .success(result): + svgString = result.svg + if !result.skippedShapeTypes.isEmpty { + ui.warning( + "Component '\(component.name)' — unsupported shape types skipped: " + + result.skippedShapeTypes.sorted().joined(separator: ", ") + ) + } + case let .failure(reason): + switch reason { + case let .rootNotFound(id): + ui.warning("Component '\(component.name)' — root shape '\(id)' not found, skipping") + case let .missingSelrect(id): + ui.warning("Component '\(component.name)' — root shape '\(id)' has no bounds, skipping") + } + continue + } + + let svgData = Data(svgString.utf8) + let safeName = component.name + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: " ", with: "_") + let tempURL = tempDir.appendingPathComponent("\(safeName).svg") + try svgData.write(to: tempURL) + + packs.append(ImagePack( + name: component.name, + images: [Image(name: component.name, scale: .all, url: tempURL, format: "svg")], + nodeId: component.id, + fileId: fileId + )) + } + + return packs + } +} diff --git a/Sources/ExFigCLI/Source/PenpotTypographySource.swift b/Sources/ExFigCLI/Source/PenpotTypographySource.swift new file mode 100644 index 00000000..1ff92c91 --- /dev/null +++ b/Sources/ExFigCLI/Source/PenpotTypographySource.swift @@ -0,0 +1,68 @@ +import ExFigCore +import Foundation +import PenpotAPI + +struct PenpotTypographySource: TypographySource { + let ui: TerminalUI + + func loadTypography(from input: TypographySourceInput) async throws -> TypographyLoadOutput { + let effectiveBaseURL = input.penpotBaseURL ?? BasePenpotClient.defaultBaseURL + let client = try PenpotClientFactory.makeClient(baseURL: effectiveBaseURL) + + let fileResponse = try await client.request(GetFileEndpoint(fileId: input.fileId)) + + guard let typographies = fileResponse.data.typographies else { + ui.warning("Penpot file '\(fileResponse.name)' has no library typographies") + return TypographyLoadOutput(textStyles: []) + } + + var textStyles: [TextStyle] = [] + + for (_, typography) in typographies.sorted(by: { $0.key < $1.key }) { + guard let fontSize = typography.fontSize else { + ui.warning("Typography '\(typography.name)' has unparseable font-size — skipping") + continue + } + + let name = if let path = typography.path { + path + "/" + typography.name + } else { + typography.name + } + + let textCase = mapTextTransform(typography.textTransform) + + if typography.letterSpacing == nil { + ui.warning("Typography '\(typography.name)' has no letter-spacing — defaulting to 0") + } + + textStyles.append(TextStyle( + name: name, + fontName: typography.fontFamily, + fontSize: fontSize, + fontStyle: nil, + lineHeight: typography.lineHeight, + letterSpacing: typography.letterSpacing ?? 0, + textCase: textCase + )) + } + + return TypographyLoadOutput(textStyles: textStyles) + } + + // MARK: - Private + + private func mapTextTransform(_ transform: String?) -> TextStyle.TextCase { + switch transform { + case "uppercase": + return .uppercased + case "lowercase": + return .lowercased + case nil, "none": + return .original + default: + ui.warning("Unknown text transform '\(transform!)' — using original") + return .original + } + } +} diff --git a/Sources/ExFigCLI/Source/SourceFactory.swift b/Sources/ExFigCLI/Source/SourceFactory.swift index e5b31ab9..0032ba52 100644 --- a/Sources/ExFigCLI/Source/SourceFactory.swift +++ b/Sources/ExFigCLI/Source/SourceFactory.swift @@ -7,16 +7,19 @@ import Logging enum SourceFactory { static func createColorsSource( for input: ColorsSourceInput, - client: Client, + client: Client?, ui: TerminalUI, filter: String? ) throws -> any ColorsSource { switch input.sourceKind { case .figma: - FigmaColorsSource(client: client, ui: ui, filter: filter) + guard let client else { throw ExFigError.accessTokenNotFound } + return FigmaColorsSource(client: client, ui: ui, filter: filter) case .tokensFile: - TokensFileColorsSource(ui: ui) - case .penpot, .tokensStudio, .sketchFile: + return TokensFileColorsSource(ui: ui) + case .penpot: + return PenpotColorsSource(ui: ui) + case .tokensStudio, .sketchFile: throw ExFigError.unsupportedSourceKind(input.sourceKind, assetType: "colors") } } @@ -24,34 +27,42 @@ enum SourceFactory { // swiftlint:disable:next function_parameter_count static func createComponentsSource( for sourceKind: DesignSourceKind, - client: Client, + client: Client?, params: PKLConfig, platform: Platform, logger: Logger, - filter: String? + filter: String?, + ui: TerminalUI ) throws -> any ComponentsSource { switch sourceKind { case .figma: - FigmaComponentsSource( + guard let client else { throw ExFigError.accessTokenNotFound } + return FigmaComponentsSource( client: client, params: params, platform: platform, logger: logger, filter: filter ) - case .penpot, .tokensFile, .tokensStudio, .sketchFile: + case .penpot: + return PenpotComponentsSource(ui: ui) + case .tokensFile, .tokensStudio, .sketchFile: throw ExFigError.unsupportedSourceKind(sourceKind, assetType: "icons/images") } } static func createTypographySource( for sourceKind: DesignSourceKind, - client: Client + client: Client?, + ui: TerminalUI ) throws -> any TypographySource { switch sourceKind { case .figma: - FigmaTypographySource(client: client) - case .penpot, .tokensFile, .tokensStudio, .sketchFile: + guard let client else { throw ExFigError.accessTokenNotFound } + return FigmaTypographySource(client: client) + case .penpot: + return PenpotTypographySource(ui: ui) + case .tokensFile, .tokensStudio, .sketchFile: throw ExFigError.unsupportedSourceKind(sourceKind, assetType: "typography") } } diff --git a/Sources/ExFigCLI/Subcommands/Download.swift b/Sources/ExFigCLI/Subcommands/Download.swift index dc7dc050..02b2fa35 100644 --- a/Sources/ExFigCLI/Subcommands/Download.swift +++ b/Sources/ExFigCLI/Subcommands/Download.swift @@ -96,7 +96,10 @@ extension ExFigCommand.Download { ExFigCommand.initializeTerminalUI(verbose: globalOptions.verbose, quiet: globalOptions.quiet) let ui = ExFigCommand.terminalUI! - let baseClient = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma?.timeout) + let baseClient = try FigmaClient( + accessToken: options.requireFigmaToken(), + timeout: options.params.figma?.timeout + ) let rateLimiter = faultToleranceOptions.createRateLimiter() let client = faultToleranceOptions.createRateLimitedClient( wrapping: baseClient, diff --git a/Sources/ExFigCLI/Subcommands/DownloadAll.swift b/Sources/ExFigCLI/Subcommands/DownloadAll.swift index e12eaf36..e95d8dd8 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadAll.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadAll.swift @@ -59,8 +59,12 @@ extension ExFigCommand.Download { // MARK: - Export Methods + // swiftlint:disable:next function_body_length private func exportColors(outputDir: URL, ui: TerminalUI) async throws { - let client = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma?.timeout) + let client = try FigmaClient( + accessToken: options.requireFigmaToken(), + timeout: options.params.figma?.timeout + ) let figmaParams = options.params.figma let commonParams = options.params.common @@ -127,7 +131,10 @@ extension ExFigCommand.Download { } private func exportTypography(outputDir: URL, ui: TerminalUI) async throws { - let client = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma?.timeout) + let client = try FigmaClient( + accessToken: options.requireFigmaToken(), + timeout: options.params.figma?.timeout + ) guard let figmaParams = options.params.figma else { throw ExFigError.custom(errorString: "figma section is required for typography export.") } @@ -163,8 +170,12 @@ extension ExFigCommand.Download { } } + // swiftlint:disable:next function_body_length private func exportIcons(outputDir: URL, ui: TerminalUI) async throws { - let client = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma?.timeout) + let client = try FigmaClient( + accessToken: options.requireFigmaToken(), + timeout: options.params.figma?.timeout + ) guard let figmaParams = options.params.figma else { throw ExFigError.custom(errorString: "figma section is required for icons export.") } @@ -233,8 +244,12 @@ extension ExFigCommand.Download { } } + // swiftlint:disable:next function_body_length private func exportImages(outputDir: URL, ui: TerminalUI) async throws { - let client = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma?.timeout) + let client = try FigmaClient( + accessToken: options.requireFigmaToken(), + timeout: options.params.figma?.timeout + ) guard let figmaParams = options.params.figma else { throw ExFigError.custom(errorString: "figma section is required for images export.") } diff --git a/Sources/ExFigCLI/Subcommands/DownloadIcons.swift b/Sources/ExFigCLI/Subcommands/DownloadIcons.swift index 47c479ba..2aef4ff6 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadIcons.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadIcons.swift @@ -63,7 +63,10 @@ extension ExFigCommand.Download { ExFigCommand.initializeTerminalUI(verbose: globalOptions.verbose, quiet: globalOptions.quiet) let ui = ExFigCommand.terminalUI! - let baseClient = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma?.timeout) + let baseClient = try FigmaClient( + accessToken: options.requireFigmaToken(), + timeout: options.params.figma?.timeout + ) let rateLimiter = faultToleranceOptions.createRateLimiter() let client = faultToleranceOptions.createRateLimitedClient( wrapping: baseClient, diff --git a/Sources/ExFigCLI/Subcommands/DownloadImages.swift b/Sources/ExFigCLI/Subcommands/DownloadImages.swift index 0e08cd46..5e71c7c4 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadImages.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadImages.swift @@ -1,16 +1,19 @@ +// swiftlint:disable file_length import ArgumentParser import ExFigCore import FigmaAPI import Foundation import Logging +import PenpotAPI extension ExFigCommand { + // swiftlint:disable type_body_length struct FetchImages: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "fetch", - abstract: "Downloads images from Figma without config file", + abstract: "Downloads images from Figma or Penpot without config file", discussion: """ - Downloads images from a specific Figma frame to a local directory. + Downloads images from a specific frame to a local directory. All parameters are passed via command-line arguments. When required options (--file-id, --frame, --output) are omitted in an @@ -20,24 +23,22 @@ extension ExFigCommand { # Interactive wizard (TTY only) exfig fetch - # Download PNGs at 3x scale (default) - exfig fetch --file-id abc123 --frame "Illustrations" --output ./images + # Download PNGs from Figma at 3x scale (default) + exfig fetch -f abc123 -r "Illustrations" -o ./images # Download SVGs exfig fetch -f abc123 -r "Icons" -o ./icons --format svg - # Download PDFs - exfig fetch -f abc123 -r "Icons" -o ./icons --format pdf + # Download from Penpot + exfig fetch --source penpot -f -r "Icons" -o ./icons + + # Download from self-hosted Penpot + exfig fetch --source penpot --penpot-base-url https://penpot.mycompany.com/ \\ + -f -r "UI" -o ./components # Download with filtering exfig fetch -f abc123 -r "Images" -o ./images --filter "logo/*" - # Download PNG at 2x scale with camelCase naming - exfig fetch -f abc123 -r "Images" -o ./images --scale 2 --name-style camelCase - - # Download with dark mode variants - exfig fetch -f abc123 -r "Images" -o ./images --dark-mode-suffix "_dark" - # Download as WebP with quality settings exfig fetch -f abc123 -r "Images" -o ./images --format webp --webp-quality 90 """ @@ -49,8 +50,33 @@ extension ExFigCommand { @OptionGroup var downloadOptions: DownloadOptions - @OptionGroup - var faultToleranceOptions: HeavyFaultToleranceOptions + @Option(name: .long, help: "Maximum retry attempts for failed API requests") + var maxRetries: Int = 4 + + @Option(name: .long, help: "Maximum API requests per minute") + var rateLimit: Int = 10 + + @Flag(name: .long, help: "Stop on first error without retrying") + var failFast: Bool = false + + @Flag(name: .long, help: "Continue from checkpoint after interruption") + var resume: Bool = false + + @Option(name: .long, help: "Maximum concurrent CDN downloads") + var concurrentDownloads: Int = FileDownloader.defaultMaxConcurrentDownloads + + /// Constructs `HeavyFaultToleranceOptions` from locally declared options, + /// using `DownloadOptions.timeout` to avoid duplicate `--timeout` flags. + var faultToleranceOptions: HeavyFaultToleranceOptions { + var opts = HeavyFaultToleranceOptions() + opts.maxRetries = maxRetries + opts.rateLimit = rateLimit + opts.timeout = downloadOptions.timeout + opts.failFast = failFast + opts.resume = resume + opts.concurrentDownloads = concurrentDownloads + return opts + } // swiftlint:disable function_body_length cyclomatic_complexity @@ -61,6 +87,7 @@ extension ExFigCommand { // Resolve required options via wizard if missing var options = downloadOptions + var wizardResult: FetchWizardResult? if options.fileId == nil || options.frameName == nil || options.outputPath == nil { guard TTYDetector.isTTY else { throw ValidationError( @@ -69,6 +96,7 @@ extension ExFigCommand { ) } let result = FetchWizard.run() + wizardResult = result options.fileId = options.fileId ?? result.fileId options.frameName = options.frameName ?? result.frameName options.outputPath = options.outputPath ?? result.outputPath @@ -85,6 +113,18 @@ extension ExFigCommand { } } + // Determine design source: from wizard, from --source flag, or default figma + let designSource: WizardDesignSource = wizardResult?.designSource + ?? options.source.map { $0 == .penpot ? .penpot : .figma } + ?? .figma + + // Penpot path — use PenpotAPI directly + if designSource == .penpot { + let penpotBaseURL = wizardResult?.penpotBaseURL ?? options.penpotBaseURL + try await runPenpotFetch(options: options, penpotBaseURL: penpotBaseURL, ui: ui) + return + } + // Validate required fields are now populated guard let fileId = options.fileId else { throw ValidationError("--file-id is required") @@ -268,6 +308,142 @@ extension ExFigCommand { // swiftlint:enable function_parameter_count + // MARK: - Penpot Fetch + + // swiftlint:disable function_body_length cyclomatic_complexity + private func runPenpotFetch( + options: DownloadOptions, + penpotBaseURL: String?, + ui: TerminalUI + ) async throws { + guard let fileId = options.fileId else { + throw ValidationError("--file-id is required") + } + guard let frameName = options.frameName else { + throw ValidationError("--frame is required") + } + guard let outputPath = options.outputPath else { + throw ValidationError("--output is required") + } + + // Validate format early — Penpot supports svg and png (via SVG reconstruction) + let format = options.format ?? .svg + switch format { + case .svg, .png: + break // supported + default: + throw ExFigError.custom( + errorString: "Format '\(format.rawValue)' is not yet supported for Penpot export. " + + "Supported formats: svg, png" + ) + } + + let baseURL = penpotBaseURL ?? BasePenpotClient.defaultBaseURL + let client = try PenpotClientFactory.makeClient(baseURL: baseURL) + + let outputURL = URL(fileURLWithPath: outputPath, isDirectory: true) + try FileManager.default.createDirectory(at: outputURL, withIntermediateDirectories: true) + + ui.info("Downloading components from Penpot...") + + // Fetch file data + let fileResponse = try await ui.withSpinner("Fetching Penpot file...") { + try await client.request(GetFileEndpoint(fileId: fileId)) + } + + guard let components = fileResponse.data.components else { + ui.warning("No components found in Penpot file") + return + } + + // Filter by path prefix + let matched = components.values + .filter { comp in + guard let path = comp.path else { return false } + return path.hasPrefix(frameName) + } + .sorted { $0.name < $1.name } + + guard !matched.isEmpty else { + ui.warning("No components matching path '\(frameName)' found") + return + } + + ui.info("Found \(matched.count) components") + + // Reconstruct SVG from shape tree (format already validated above) + var exportedCount = 0 + + for component in matched { + guard let pageId = component.mainInstancePage, + let instanceId = component.mainInstanceId, + let page = fileResponse.data.pagesIndex?[pageId], + let objects = page.objects + else { + ui.warning("Component '\(component.name)' has no shape data — skipping") + continue + } + + let renderResult = PenpotShapeRenderer.renderSVGResult( + objects: objects, rootId: instanceId + ) + let svgString: String + switch renderResult { + case let .success(result): + svgString = result.svg + if !result.skippedShapeTypes.isEmpty { + ui.warning( + "Component '\(component.name)' — unsupported shape types skipped: " + + result.skippedShapeTypes.sorted().joined(separator: ", ") + ) + } + case let .failure(reason): + switch reason { + case let .rootNotFound(id): + ui.warning("Component '\(component.name)' — root shape '\(id)' not found, skipping") + case let .missingSelrect(id): + ui.warning("Component '\(component.name)' — root shape '\(id)' has no bounds, skipping") + } + continue + } + + let svgData = Data(svgString.utf8) + let safeName = component.name + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: " ", with: "_") + + switch format { + case .svg: + let fileURL = outputURL.appendingPathComponent("\(safeName).svg") + try svgData.write(to: fileURL) + case .png: + let scale = options.scale ?? 3.0 + let converter = SvgToPngConverter() + let pngData = try converter.convert(svgData: svgData, scale: scale, fileName: safeName) + let fileURL = outputURL.appendingPathComponent("\(safeName).png") + try pngData.write(to: fileURL) + default: + // Unreachable — format validated at method start + fatalError("Unsupported format '\(format.rawValue)' should have been caught earlier") + } + + exportedCount += 1 + } + + if exportedCount > 0 { + ui + .success( + "Exported \(exportedCount) components as \(format.rawValue.uppercased()) to \(outputURL.path)" + ) + } else { + ui.warning( + "No components could be exported. Components may lack mainInstanceId (not opened in Penpot editor)." + ) + } + } + + // swiftlint:enable function_body_length cyclomatic_complexity + private func convertToWebP( _ files: [FileContents], options: DownloadOptions, diff --git a/Sources/ExFigCLI/Subcommands/DownloadImagesExport.swift b/Sources/ExFigCLI/Subcommands/DownloadImagesExport.swift index 2b48f665..305fb5f6 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadImagesExport.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadImagesExport.swift @@ -39,7 +39,10 @@ extension ExFigCommand.Download { ExFigCommand.initializeTerminalUI(verbose: globalOptions.verbose, quiet: globalOptions.quiet) let ui = ExFigCommand.terminalUI! - let baseClient = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma?.timeout) + let baseClient = try FigmaClient( + accessToken: options.requireFigmaToken(), + timeout: options.params.figma?.timeout + ) let rateLimiter = faultToleranceOptions.createRateLimiter() let client = faultToleranceOptions.createRateLimitedClient( wrapping: baseClient, diff --git a/Sources/ExFigCLI/Subcommands/DownloadTokens.swift b/Sources/ExFigCLI/Subcommands/DownloadTokens.swift index 2d39bb02..73f1883a 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadTokens.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadTokens.swift @@ -29,7 +29,10 @@ extension ExFigCommand.Download { ExFigCommand.initializeTerminalUI(verbose: globalOptions.verbose, quiet: globalOptions.quiet) let ui = ExFigCommand.terminalUI! - let baseClient = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma?.timeout) + let baseClient = try FigmaClient( + accessToken: options.requireFigmaToken(), + timeout: options.params.figma?.timeout + ) let rateLimiter = faultToleranceOptions.createRateLimiter() let client = faultToleranceOptions.createRateLimitedClient( wrapping: baseClient, diff --git a/Sources/ExFigCLI/Subcommands/DownloadTypography.swift b/Sources/ExFigCLI/Subcommands/DownloadTypography.swift index 2dba1c13..e5646ffd 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadTypography.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadTypography.swift @@ -29,7 +29,10 @@ extension ExFigCommand.Download { ExFigCommand.initializeTerminalUI(verbose: globalOptions.verbose, quiet: globalOptions.quiet) let ui = ExFigCommand.terminalUI! - let baseClient = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma?.timeout) + let baseClient = try FigmaClient( + accessToken: options.requireFigmaToken(), + timeout: options.params.figma?.timeout + ) let rateLimiter = faultToleranceOptions.createRateLimiter() let client = faultToleranceOptions.createRateLimitedClient( wrapping: baseClient, diff --git a/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift b/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift index aa6cc588..b73dcfaf 100644 --- a/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift +++ b/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift @@ -39,12 +39,18 @@ extension ExFigCommand.ExportIcons { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() - let componentsSource = FigmaComponentsSource( + // All entries in a platform section share one source kind (mixed sources not yet supported) + guard let sourceKind = entries.first?.resolvedSourceKind else { + throw ExFigError.configurationError("No entries provided for icons export") + } + let componentsSource = try SourceFactory.createComponentsSource( + for: sourceKind, client: client, params: params, platform: .ios, logger: ExFigCommand.logger, - filter: filter + filter: filter, + ui: ui ) let context = IconsExportContextImpl( @@ -116,12 +122,18 @@ extension ExFigCommand.ExportIcons { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() - let componentsSource = FigmaComponentsSource( + // All entries in a platform section share one source kind (mixed sources not yet supported) + guard let sourceKind = entries.first?.resolvedSourceKind else { + throw ExFigError.configurationError("No entries provided for icons export") + } + let componentsSource = try SourceFactory.createComponentsSource( + for: sourceKind, client: client, params: params, platform: .android, logger: ExFigCommand.logger, - filter: filter + filter: filter, + ui: ui ) let context = IconsExportContextImpl( @@ -169,12 +181,18 @@ extension ExFigCommand.ExportIcons { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() - let componentsSource = FigmaComponentsSource( + // All entries in a platform section share one source kind (mixed sources not yet supported) + guard let sourceKind = entries.first?.resolvedSourceKind else { + throw ExFigError.configurationError("No entries provided for icons export") + } + let componentsSource = try SourceFactory.createComponentsSource( + for: sourceKind, client: client, params: params, platform: .flutter, logger: ExFigCommand.logger, - filter: filter + filter: filter, + ui: ui ) let context = IconsExportContextImpl( @@ -222,12 +240,18 @@ extension ExFigCommand.ExportIcons { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() - let componentsSource = FigmaComponentsSource( + // All entries in a platform section share one source kind (mixed sources not yet supported) + guard let sourceKind = entries.first?.resolvedSourceKind else { + throw ExFigError.configurationError("No entries provided for icons export") + } + let componentsSource = try SourceFactory.createComponentsSource( + for: sourceKind, client: client, params: params, platform: .web, logger: ExFigCommand.logger, - filter: filter + filter: filter, + ui: ui ) let context = IconsExportContextImpl( diff --git a/Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift b/Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift index d5451084..90cb4d54 100644 --- a/Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift +++ b/Sources/ExFigCLI/Subcommands/Export/PluginImagesExport.swift @@ -38,12 +38,18 @@ extension ExFigCommand.ExportImages { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() - let componentsSource = FigmaComponentsSource( + // All entries in a platform section share one source kind (mixed sources not yet supported) + guard let sourceKind = entries.first?.resolvedSourceKind else { + throw ExFigError.configurationError("No entries provided for images export") + } + let componentsSource = try SourceFactory.createComponentsSource( + for: sourceKind, client: client, params: params, platform: .ios, logger: ExFigCommand.logger, - filter: filter + filter: filter, + ui: ui ) let context = ImagesExportContextImpl( @@ -114,12 +120,18 @@ extension ExFigCommand.ExportImages { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() - let componentsSource = FigmaComponentsSource( + // All entries in a platform section share one source kind (mixed sources not yet supported) + guard let sourceKind = entries.first?.resolvedSourceKind else { + throw ExFigError.configurationError("No entries provided for images export") + } + let componentsSource = try SourceFactory.createComponentsSource( + for: sourceKind, client: client, params: params, platform: .android, logger: ExFigCommand.logger, - filter: filter + filter: filter, + ui: ui ) let context = ImagesExportContextImpl( @@ -167,12 +179,18 @@ extension ExFigCommand.ExportImages { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() - let componentsSource = FigmaComponentsSource( + // All entries in a platform section share one source kind (mixed sources not yet supported) + guard let sourceKind = entries.first?.resolvedSourceKind else { + throw ExFigError.configurationError("No entries provided for images export") + } + let componentsSource = try SourceFactory.createComponentsSource( + for: sourceKind, client: client, params: params, platform: .flutter, logger: ExFigCommand.logger, - filter: filter + filter: filter, + ui: ui ) let context = ImagesExportContextImpl( @@ -220,12 +238,18 @@ extension ExFigCommand.ExportImages { let batchMode = BatchSharedState.current?.isBatchMode ?? false let fileDownloader = faultToleranceOptions.createFileDownloader() - let componentsSource = FigmaComponentsSource( + // All entries in a platform section share one source kind (mixed sources not yet supported) + guard let sourceKind = entries.first?.resolvedSourceKind else { + throw ExFigError.configurationError("No entries provided for images export") + } + let componentsSource = try SourceFactory.createComponentsSource( + for: sourceKind, client: client, params: params, platform: .web, logger: ExFigCommand.logger, - filter: filter + filter: filter, + ui: ui ) let context = ImagesExportContextImpl( diff --git a/Sources/ExFigCLI/Subcommands/FetchWizard.swift b/Sources/ExFigCLI/Subcommands/FetchWizard.swift index 5143e690..cafb0acd 100644 --- a/Sources/ExFigCLI/Subcommands/FetchWizard.swift +++ b/Sources/ExFigCLI/Subcommands/FetchWizard.swift @@ -85,17 +85,31 @@ struct PlatformDefaults { /// Result of the interactive wizard flow. struct FetchWizardResult { + let designSource: WizardDesignSource let fileId: String let frameName: String let pageName: String? let outputPath: String - let format: ImageFormat + let format: ImageFormat? let scale: Double? let nameStyle: NameStyle? let filter: String? + let penpotBaseURL: String? } -// MARK: - Figma File ID Helpers +// MARK: - Design Source + +/// Design source choice for wizard prompts. +enum WizardDesignSource: String, CaseIterable, CustomStringConvertible, Equatable { + case figma = "Figma" + case penpot = "Penpot" + + var description: String { + rawValue + } +} + +// MARK: - File ID Helpers /// Extract Figma file ID from a full URL or return the input as-is if it looks like a bare ID. /// Supports: figma.com/file//..., figma.com/design//..., or bare alphanumeric IDs. @@ -112,6 +126,20 @@ func extractFigmaFileId(from input: String) -> String { return trimmed } +/// Extract Penpot file UUID from a workspace URL or return the input as-is. +/// Supports: design.penpot.app/#/workspace/...?file-id=UUID or bare UUIDs. +func extractPenpotFileId(from input: String) -> String { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + // Match file-id=UUID query parameter + if let range = trimmed.range(of: #"file-id=([0-9a-fA-F-]+)"#, options: .regularExpression) { + let match = trimmed[range] + if let eqSign = match.firstIndex(of: "=") { + return String(match[match.index(after: eqSign)...]) + } + } + return trimmed +} + // MARK: - Wizard Flow /// Interactive wizard for `exfig fetch` when required options are missing. @@ -126,9 +154,25 @@ enum FetchWizard { /// Run the interactive wizard and return populated options. static func run() -> FetchWizardResult { - // 1–3: Core choices (file, asset type, platform) + // 1: Design source + let source: WizardDesignSource = NooraUI.singleChoicePrompt( + title: "ExFig Export Wizard", + question: "Design source:", + options: WizardDesignSource.allCases, + description: "Where are your design assets stored?" + ) + + if source == .penpot { + return runPenpotFlow() + } + + return runFigmaFlow() + } + + // MARK: - Figma Flow + + private static func runFigmaFlow() -> FetchWizardResult { let fileIdInput = NooraUI.textPrompt( - title: "Figma Export Wizard", prompt: "Figma file ID or URL (figma.com/design//...):", description: "Paste the file URL or just the ID from it", validationRules: [NonEmptyValidationRule(error: "File ID cannot be empty.")] @@ -149,11 +193,61 @@ enum FetchWizard { let defaults = PlatformDefaults.forPlatform(platform, assetType: assetType) - // 4–8: Details (page, frame, format, output, filter) - return promptDetails(assetType: assetType, platform: platform, defaults: defaults, fileId: fileId) + return promptFigmaDetails(assetType: assetType, platform: platform, defaults: defaults, fileId: fileId) + } + + // MARK: - Penpot Flow + + private static func runPenpotFlow() -> FetchWizardResult { + let fileIdInput = NooraUI.textPrompt( + prompt: "Penpot file UUID or workspace URL:", + description: "Paste the workspace URL or just the file UUID", + validationRules: [NonEmptyValidationRule(error: "File ID cannot be empty.")] + ) + let fileId = extractPenpotFileId(from: fileIdInput) + + let baseURL: String? = if NooraUI.yesOrNoPrompt( + question: "Self-hosted Penpot instance?", + defaultAnswer: false, + description: "Select Yes if you're not using design.penpot.app" + ) { + NooraUI.textPrompt( + prompt: "Penpot base URL (e.g., https://penpot.mycompany.com/):", + validationRules: [NonEmptyValidationRule(error: "URL cannot be empty.")] + ) + } else { + nil + } + + let defaultPath = "Icons" + let pathInput = NooraUI.textPrompt( + prompt: "Component path filter (default: \(defaultPath)):", + description: "Library component path prefix to export. Press Enter for default." + ).trimmingCharacters(in: .whitespacesAndNewlines) + let pathFilter = pathInput.isEmpty ? defaultPath : pathInput + + let defaultOutput = "./output" + let outputInput = NooraUI.textPrompt( + prompt: "Output directory (default: \(defaultOutput)):", + description: "Where to save exported assets. Press Enter for default." + ).trimmingCharacters(in: .whitespacesAndNewlines) + let outputPath = outputInput.isEmpty ? defaultOutput : outputInput + + return FetchWizardResult( + designSource: .penpot, + fileId: fileId, + frameName: pathFilter, + pageName: nil, + outputPath: outputPath, + format: nil, + scale: nil, + nameStyle: nil, + filter: nil, + penpotBaseURL: baseURL + ) } - private static func promptDetails( + private static func promptFigmaDetails( assetType: WizardAssetType, platform: WizardPlatform, defaults: PlatformDefaults, @@ -193,6 +287,7 @@ enum FetchWizard { ) return FetchWizardResult( + designSource: .figma, fileId: fileId, frameName: frameName, pageName: pageName, @@ -200,7 +295,8 @@ enum FetchWizard { format: format, scale: defaults.scale, nameStyle: defaults.nameStyle, - filter: filter + filter: filter, + penpotBaseURL: nil ) } diff --git a/Sources/ExFigCLI/Subcommands/GenerateConfigFile.swift b/Sources/ExFigCLI/Subcommands/GenerateConfigFile.swift index dda6833b..6404e91e 100644 --- a/Sources/ExFigCLI/Subcommands/GenerateConfigFile.swift +++ b/Sources/ExFigCLI/Subcommands/GenerateConfigFile.swift @@ -55,7 +55,11 @@ extension ExFigCommand { // Interactive wizard let result = InitWizard.run() let template = templateForPlatform(result.platform) - rawContents = InitWizard.applyResult(result, to: template) + rawContents = if result.designSource == .penpot { + InitWizard.applyPenpotResult(result, to: template) + } else { + InitWizard.applyResult(result, to: template) + } wizardResult = result } else { // Non-TTY without --platform @@ -154,11 +158,14 @@ extension ExFigCommand { stepOffset = 2 } - if ProcessInfo.processInfo.environment["FIGMA_PERSONAL_TOKEN"] == nil { - ui.info("\(stepOffset). Set your Figma token (missing):") - ui.info(" export FIGMA_PERSONAL_TOKEN=your_token_here") + let isPenpot = wizardResult?.designSource == .penpot + let tokenName = isPenpot ? "PENPOT_ACCESS_TOKEN" : "FIGMA_PERSONAL_TOKEN" + let sourceName = isPenpot ? "Penpot" : "Figma" + if ProcessInfo.processInfo.environment[tokenName] == nil { + ui.info("\(stepOffset). Set your \(sourceName) token (missing):") + ui.info(" export \(tokenName)=your_token_here") } else { - ui.info("\(stepOffset). Figma token detected in environment ✅") + ui.info("\(stepOffset). \(sourceName) token detected in environment ✅") } ui.info("\(stepOffset + 1). Run export commands:") diff --git a/Sources/ExFigCLI/Subcommands/InitWizard.swift b/Sources/ExFigCLI/Subcommands/InitWizard.swift index d3361093..c3c330bd 100644 --- a/Sources/ExFigCLI/Subcommands/InitWizard.swift +++ b/Sources/ExFigCLI/Subcommands/InitWizard.swift @@ -60,6 +60,7 @@ struct InitVariablesConfig { /// Result of the interactive init wizard flow. struct InitWizardResult { + let designSource: WizardDesignSource let platform: Platform let selectedAssetTypes: [InitAssetType] let lightFileId: String @@ -69,6 +70,7 @@ struct InitWizardResult { let imagesFrameName: String? let imagesPageName: String? let variablesConfig: InitVariablesConfig? + let penpotBaseURL: String? } // MARK: - Init Wizard Flow @@ -79,16 +81,23 @@ struct InitWizardResult { enum InitWizard { /// Run the interactive wizard and return collected answers. static func run() -> InitWizardResult { - // 1. Platform selection - let wizardPlatform: WizardPlatform = NooraUI.singleChoicePrompt( + // 1. Design source + let source: WizardDesignSource = NooraUI.singleChoicePrompt( title: "ExFig Config Wizard", + question: "Design source:", + options: WizardDesignSource.allCases, + description: "Where are your design assets stored?" + ) + + // 2. Platform selection + let wizardPlatform: WizardPlatform = NooraUI.singleChoicePrompt( question: "Target platform:", options: WizardPlatform.allCases, description: "Select the platform you want to export assets for" ) let platform = wizardPlatform.asPlatform - // 2. Asset type multi-select + // 3. Asset type multi-select let availableTypes = InitAssetType.availableTypes(for: wizardPlatform) let selectedTypes: [InitAssetType] = NooraUI.multipleChoicePrompt( question: "What do you want to export?", @@ -97,7 +106,21 @@ enum InitWizard { minLimit: .limited(count: 1, errorMessage: "Select at least one asset type.") ) - // 3. Figma file ID (light) + if source == .penpot { + return runPenpotFlow(platform: platform, selectedTypes: selectedTypes) + } + + return runFigmaFlow(platform: platform, selectedTypes: selectedTypes) + } + + // MARK: - Figma Flow + + // swiftlint:disable function_body_length + private static func runFigmaFlow( + platform: Platform, + selectedTypes: [InitAssetType] + ) -> InitWizardResult { + // File ID (light) let lightFileIdInput = NooraUI.textPrompt( prompt: "Figma file ID or URL (figma.com/design//...):", description: "Paste the file URL or just the ID from it", @@ -105,7 +128,7 @@ enum InitWizard { ) let lightFileId = extractFigmaFileId(from: lightFileIdInput) - // 4. Dark mode file ID (optional) + // Dark mode file ID (optional) let darkFileIdRaw = promptOptionalText( question: "Do you have a separate dark mode file?", description: "If your dark colors/images are in a different Figma file", @@ -113,14 +136,14 @@ enum InitWizard { ) let darkFileId = darkFileIdRaw.map { extractFigmaFileId(from: $0) } - // 5. Colors source (if colors selected) + // Colors source (if colors selected) let variablesConfig: InitVariablesConfig? = if selectedTypes.contains(.colors) { promptColorsSource(lightFileId: lightFileId) } else { nil } - // 6. Icons details (if icons selected) + // Icons details (if icons selected) let iconsFrameName: String? let iconsPageName: String? if selectedTypes.contains(.icons) { @@ -131,7 +154,7 @@ enum InitWizard { iconsPageName = nil } - // 7. Images details (if images selected) + // Images details (if images selected) let imagesFrameName: String? let imagesPageName: String? if selectedTypes.contains(.images) { @@ -143,6 +166,7 @@ enum InitWizard { } return InitWizardResult( + designSource: .figma, platform: platform, selectedAssetTypes: selectedTypes, lightFileId: lightFileId, @@ -151,7 +175,67 @@ enum InitWizard { iconsPageName: iconsPageName, imagesFrameName: imagesFrameName, imagesPageName: imagesPageName, - variablesConfig: variablesConfig + variablesConfig: variablesConfig, + penpotBaseURL: nil + ) + } + + // swiftlint:enable function_body_length + + // MARK: - Penpot Flow + + private static func runPenpotFlow( + platform: Platform, + selectedTypes: [InitAssetType] + ) -> InitWizardResult { + // File UUID + let fileIdInput = NooraUI.textPrompt( + prompt: "Penpot file UUID or workspace URL:", + description: "Paste the workspace URL or just the file UUID", + validationRules: [NonEmptyValidationRule(error: "File ID cannot be empty.")] + ) + let fileId = extractPenpotFileId(from: fileIdInput) + + // Base URL (only for self-hosted) + let baseURL: String? = if NooraUI.yesOrNoPrompt( + question: "Self-hosted Penpot instance?", + defaultAnswer: false, + description: "Select Yes if you're not using design.penpot.app" + ) { + NooraUI.textPrompt( + prompt: "Penpot base URL (e.g., https://penpot.mycompany.com/):", + validationRules: [NonEmptyValidationRule(error: "URL cannot be empty.")] + ) + } else { + nil + } + + // Icons path filter (if icons selected) + let iconsFrameName: String? = if selectedTypes.contains(.icons) { + promptPathFilter(assetType: "icons", defaultPath: "Icons") + } else { + nil + } + + // Images path filter (if images selected) + let imagesFrameName: String? = if selectedTypes.contains(.images) { + promptPathFilter(assetType: "images", defaultPath: "Illustrations") + } else { + nil + } + + return InitWizardResult( + designSource: .penpot, + platform: platform, + selectedAssetTypes: selectedTypes, + lightFileId: fileId, + darkFileId: nil, + iconsFrameName: iconsFrameName, + iconsPageName: nil, + imagesFrameName: imagesFrameName, + imagesPageName: nil, + variablesConfig: nil, + penpotBaseURL: baseURL ) } @@ -165,6 +249,14 @@ enum InitWizard { return input.isEmpty ? defaultName : input } + private static func promptPathFilter(assetType: String, defaultPath: String) -> String { + let input = NooraUI.textPrompt( + prompt: "Penpot library path for \(assetType) (default: \(defaultPath)):", + description: "Path prefix to filter library components. Press Enter for default." + ).trimmingCharacters(in: .whitespacesAndNewlines) + return input.isEmpty ? defaultPath : input + } + private static func promptOptionalText( question: TerminalText, description: TerminalText, diff --git a/Sources/ExFigCLI/Subcommands/InitWizardTransform.swift b/Sources/ExFigCLI/Subcommands/InitWizardTransform.swift index a1023242..9acd85b1 100644 --- a/Sources/ExFigCLI/Subcommands/InitWizardTransform.swift +++ b/Sources/ExFigCLI/Subcommands/InitWizardTransform.swift @@ -351,6 +351,119 @@ extension InitWizard { return result } + // MARK: - Penpot Template Transformation + + /// Apply Penpot wizard result to a platform template. + /// Removes Figma-specific sections and inserts `penpotSource` blocks. + static func applyPenpotResult(_ result: InitWizardResult, to template: String) -> String { + var output = template + + // Remove figma section entirely + output = removeSection(from: output, matching: "figma = new Figma.FigmaConfig {") + // Remove Figma import + output = output.replacingOccurrences( + of: "import \".exfig/schemas/Figma.pkl\"\n", + with: "" + ) + + // Remove dark file ID lines + output = removeDarkFileIdLine(from: output) + + // Remove variablesColors block (not applicable for Penpot) + output = removeCommentedVariablesColors(from: output) + + // Remove common colors section (Penpot colors use penpotSource on platform entries) + output = removeSection(from: output, matching: "colors = new Common.Colors {") + + // Remove common icons/images sections (Penpot uses path filters, not frame names) + output = removeSection(from: output, matching: "icons = new Common.Icons {") + output = removeSection(from: output, matching: "images = new Common.Images {") + output = removeSection(from: output, matching: "typography = new Common.Typography {") + + // Build penpotSource block + let baseURLLine = if let baseURL = result.penpotBaseURL { + "\n baseUrl = \"\(baseURL)\"" + } else { + "" + } + + let penpotBlock = """ + penpotSource = new Common.PenpotSource { + fileId = "\(result.lightFileId)"\(baseURLLine) + } + """ + + // Insert penpotSource into each remaining platform entry + output = insertIntoPlatformEntries(output, block: penpotBlock) + + // Substitute icons/images path filters in platform entries + if let iconsFrame = result.iconsFrameName { + output = substitutePenpotPathFilter( + in: output, entryMarker: "icons", fieldName: "figmaFrameName", value: iconsFrame + ) + } + if let imagesFrame = result.imagesFrameName { + output = substitutePenpotPathFilter( + in: output, entryMarker: "images", fieldName: "figmaFrameName", value: imagesFrame + ) + } + + // Remove unselected asset sections + let allTypes: [InitAssetType] = [.colors, .icons, .images, .typography] + for assetType in allTypes where !result.selectedAssetTypes.contains(assetType) { + output = removeAssetSections(from: output, assetType: assetType) + } + + output = collapseBlankLines(output) + return output + } + + /// Insert a block of PKL text after the opening brace of each platform entry. + private static func insertIntoPlatformEntries(_ template: String, block: String) -> String { + let lines = template.components(separatedBy: "\n") + var result: [String] = [] + let entryPattern = #"= new (iOS|Android|Flutter|Web)\.\w+Entry \{"# + + for line in lines { + result.append(line) + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.range(of: entryPattern, options: .regularExpression) != nil { + result.append(block) + } + } + + return result.joined(separator: "\n") + } + + /// Replace `figmaFrameName` value in a platform entry for Penpot path filter. + private static func substitutePenpotPathFilter( + in template: String, + entryMarker: String, + fieldName: String, + value: String + ) -> String { + let lines = template.components(separatedBy: "\n") + var result: [String] = [] + var inEntry = false + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.contains("\(entryMarker) = new"), trimmed.contains("Entry {") { + inEntry = true + } + if inEntry, trimmed.hasPrefix("\(fieldName) = ") { + let indent = String(line.prefix(while: { $0 == " " })) + result.append("\(indent)\(fieldName) = \"\(value)\"") + inEntry = false + } else { + result.append(line) + } + if inEntry, trimmed == "}" { inEntry = false } + } + + return result.joined(separator: "\n") + } + static func collapseBlankLines(_ text: String) -> String { let lines = text.components(separatedBy: "\n") var result: [String] = [] diff --git a/Sources/ExFigConfig/CLAUDE.md b/Sources/ExFigConfig/CLAUDE.md index 8ef97f1f..a70120c0 100644 --- a/Sources/ExFigConfig/CLAUDE.md +++ b/Sources/ExFigConfig/CLAUDE.md @@ -48,14 +48,21 @@ ExFigCore domain types (NameStyle, ColorsSourceInput, etc.) ### Key Public API -| Symbol | Purpose | -| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `PKLEvaluator.evaluate(configPath:)` | Async evaluation of .pkl → `ExFig.ModuleImpl` | -| `PKLError.configNotFound` / `.evaluationDidNotComplete` | Error cases | -| `Common.NameStyle.coreNameStyle` | Bridge to `ExFigCore.NameStyle` via rawValue match | -| `Common.SourceKind.coreSourceKind` | Bridge to `ExFigCore.DesignSourceKind` via explicit switch (NOT rawValue — kebab vs camelCase mismatch) | -| `Common_VariablesSource.resolvedSourceKind` | Resolution priority: explicit `sourceKind` > auto-detect (tokensFile presence) > default `.figma` | -| `Common_VariablesSource.validatedColorsSourceInput()` | Validates required fields, returns `ColorsSourceInput`. Uses `resolvedSourceKind` for dispatch | +| Symbol | Purpose | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `PKLEvaluator.evaluate(configPath:)` | Async evaluation of .pkl → `ExFig.ModuleImpl` | +| `PKLError.configNotFound` / `.evaluationDidNotComplete` | Error cases | +| `Common.NameStyle.coreNameStyle` | Bridge to `ExFigCore.NameStyle` via rawValue match | +| `Common.SourceKind.coreSourceKind` | Bridge to `ExFigCore.DesignSourceKind` via explicit switch (NOT rawValue — kebab vs camelCase mismatch) | +| `Common_VariablesSource.resolvedSourceKind` | Resolution priority: explicit `sourceKind` > auto-detect (penpotSource > tokensFile) > default `.figma` | +| `Common_VariablesSource.validatedColorsSourceInput()` | Validates required fields, returns `ColorsSourceInput`. Uses `resolvedSourceKind` for dispatch | +| `Common_FrameSource.resolvedSourceKind` | Resolution priority: explicit `sourceKind` > auto-detect (penpotSource presence) > default `.figma`. Defined in `SourceKindBridging.swift` | +| `Common_FrameSource.resolvedFileId` | Source-kind-aware: Penpot → `penpotSource?.fileId` only, Figma → `figmaFileId`. Defined in `SourceKindBridging.swift` | +| `Common_FrameSource.resolvedPenpotBaseURL` | `penpotSource?.baseUrl` — passes Penpot base URL through entry bridges. Defined in `SourceKindBridging.swift` | + +### Generated Type Gotchas + +- `Common.PenpotSource.baseUrl` is non-optional `String` (has PKL default) — tests must pass a real URL, not `nil` ### PklError Workaround @@ -75,6 +82,11 @@ When adding new PKL types to schemas, regenerate with `codegen:pkl` and add the - New fields in generated inits require updating ALL test call sites with new `nil` parameters - Generated files are excluded from SwiftLint (`.swiftlint.yml`) +## Module Boundaries + +ExFigConfig imports ExFigCore but NOT ExFigCLI. Error types available: `ColorsConfigError` (ExFigCore), NOT `ExFigError` (ExFigCLI). +When adding validation errors in this module, extend `ColorsConfigError` or create a new error enum in ExFigCore. + ## Consumers All platform plugins (`ExFig-iOS`, `ExFig-Android`, `ExFig-Flutter`, `ExFig-Web`) and ExFigCLI import this module. Entry types from `Generated/` are extended in platform Config directories with computed properties that bridge to ExFigCore types. diff --git a/Sources/ExFigConfig/Generated/Android.pkl.swift b/Sources/ExFigConfig/Generated/Android.pkl.swift index 28c8a2d2..3baf3828 100644 --- a/Sources/ExFigConfig/Generated/Android.pkl.swift +++ b/Sources/ExFigConfig/Generated/Android.pkl.swift @@ -132,10 +132,14 @@ extension Android { public var themeAttributes: ThemeAttributes? /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" /// - If `tokensFile` is set → "tokens-file" /// - Otherwise → "figma" public var sourceKind: Common.SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + public var penpotSource: Common.PenpotSource? + /// Local .tokens.json file source (bypasses Figma API when set). public var tokensFile: Common.TokensFile? @@ -176,6 +180,7 @@ extension Android { colorKotlin: String?, themeAttributes: ThemeAttributes?, sourceKind: Common.SourceKind?, + penpotSource: Common.PenpotSource?, tokensFile: Common.TokensFile?, tokensFileId: String?, tokensCollectionName: String?, @@ -196,6 +201,7 @@ extension Android { self.colorKotlin = colorKotlin self.themeAttributes = themeAttributes self.sourceKind = sourceKind + self.penpotSource = penpotSource self.tokensFile = tokensFile self.tokensFileId = tokensFileId self.tokensCollectionName = tokensCollectionName @@ -250,9 +256,14 @@ extension Android { /// Path to generate Figma Code Connect Kotlin file for Jetpack Compose. public var codeConnectKotlin: String? - /// Design source kind override. When null, defaults to "figma". + /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" + /// - Otherwise → "figma" public var sourceKind: Common.SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + public var penpotSource: Common.PenpotSource? + /// Figma frame name to export from. public var figmaFrameName: String? @@ -292,6 +303,7 @@ extension Android { codeConnectPackageName: String?, codeConnectKotlin: String?, sourceKind: Common.SourceKind?, + penpotSource: Common.PenpotSource?, figmaFrameName: String?, figmaPageName: String?, figmaFileId: String?, @@ -311,6 +323,7 @@ extension Android { self.codeConnectPackageName = codeConnectPackageName self.codeConnectKotlin = codeConnectKotlin self.sourceKind = sourceKind + self.penpotSource = penpotSource self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId @@ -353,9 +366,14 @@ extension Android { /// Path to generate Figma Code Connect Kotlin file for Jetpack Compose. public var codeConnectKotlin: String? - /// Design source kind override. When null, defaults to "figma". + /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" + /// - Otherwise → "figma" public var sourceKind: Common.SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + public var penpotSource: Common.PenpotSource? + /// Figma frame name to export from. public var figmaFrameName: String? @@ -393,6 +411,7 @@ extension Android { nameStyle: Common.NameStyle?, codeConnectKotlin: String?, sourceKind: Common.SourceKind?, + penpotSource: Common.PenpotSource?, figmaFrameName: String?, figmaPageName: String?, figmaFileId: String?, @@ -410,6 +429,7 @@ extension Android { self.nameStyle = nameStyle self.codeConnectKotlin = codeConnectKotlin self.sourceKind = sourceKind + self.penpotSource = penpotSource self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId diff --git a/Sources/ExFigConfig/Generated/Common.pkl.swift b/Sources/ExFigConfig/Generated/Common.pkl.swift index be4b3b79..2297a715 100644 --- a/Sources/ExFigConfig/Generated/Common.pkl.swift +++ b/Sources/ExFigConfig/Generated/Common.pkl.swift @@ -6,6 +6,8 @@ public enum Common {} public protocol Common_VariablesSource: Common_NameProcessing { var sourceKind: Common.SourceKind? { get } + var penpotSource: Common.PenpotSource? { get } + var tokensFile: Common.TokensFile? { get } var tokensFileId: String? { get } @@ -32,6 +34,8 @@ public protocol Common_NameProcessing: PklRegisteredType, DynamicallyEquatable, public protocol Common_FrameSource: Common_NameProcessing { var sourceKind: Common.SourceKind? { get } + var penpotSource: Common.PenpotSource? { get } + var figmaFrameName: String? { get } var figmaPageName: String? { get } @@ -90,10 +94,14 @@ extension Common { public static let registeredIdentifier: String = "Common#VariablesSource" /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" /// - If `tokensFile` is set → "tokens-file" /// - Otherwise → "figma" public var sourceKind: SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + public var penpotSource: PenpotSource? + /// Local .tokens.json file source (bypasses Figma API when set). public var tokensFile: TokensFile? @@ -126,6 +134,7 @@ extension Common { public init( sourceKind: SourceKind?, + penpotSource: PenpotSource?, tokensFile: TokensFile?, tokensFileId: String?, tokensCollectionName: String?, @@ -138,6 +147,7 @@ extension Common { nameReplaceRegexp: String? ) { self.sourceKind = sourceKind + self.penpotSource = penpotSource self.tokensFile = tokensFile self.tokensFileId = tokensFileId self.tokensCollectionName = tokensCollectionName @@ -191,6 +201,27 @@ extension Common { } } + /// Penpot design source configuration. + /// When set on a colors/icons/images entry, loads assets from Penpot API instead of Figma. + public struct PenpotSource: PklRegisteredType, Decodable, Hashable, Sendable { + public static let registeredIdentifier: String = "Common#PenpotSource" + + /// Penpot file UUID. + public var fileId: String + + /// Penpot instance base URL (default: Penpot cloud). + public var baseUrl: String + + /// Optional path prefix to filter assets (e.g., "Brand/Primary"). + public var pathFilter: String? + + public init(fileId: String, baseUrl: String, pathFilter: String?) { + self.fileId = fileId + self.baseUrl = baseUrl + self.pathFilter = pathFilter + } + } + /// Cache configuration for tracking Figma file versions. public struct Cache: PklRegisteredType, Decodable, Hashable, Sendable { public static let registeredIdentifier: String = "Common#Cache" @@ -232,9 +263,14 @@ extension Common { public struct FrameSourceImpl: FrameSource { public static let registeredIdentifier: String = "Common#FrameSource" - /// Design source kind override. When null, defaults to "figma". + /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" + /// - Otherwise → "figma" public var sourceKind: SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + public var penpotSource: PenpotSource? + /// Figma frame name to export from. public var figmaFrameName: String? @@ -263,6 +299,7 @@ extension Common { public init( sourceKind: SourceKind?, + penpotSource: PenpotSource?, figmaFrameName: String?, figmaPageName: String?, figmaFileId: String?, @@ -271,6 +308,7 @@ extension Common { nameReplaceRegexp: String? ) { self.sourceKind = sourceKind + self.penpotSource = penpotSource self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId diff --git a/Sources/ExFigConfig/Generated/Flutter.pkl.swift b/Sources/ExFigConfig/Generated/Flutter.pkl.swift index 0f029fb5..ac91b0ae 100644 --- a/Sources/ExFigConfig/Generated/Flutter.pkl.swift +++ b/Sources/ExFigConfig/Generated/Flutter.pkl.swift @@ -33,10 +33,14 @@ extension Flutter { public var className: String? /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" /// - If `tokensFile` is set → "tokens-file" /// - Otherwise → "figma" public var sourceKind: Common.SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + public var penpotSource: Common.PenpotSource? + /// Local .tokens.json file source (bypasses Figma API when set). public var tokensFile: Common.TokensFile? @@ -72,6 +76,7 @@ extension Flutter { output: String?, className: String?, sourceKind: Common.SourceKind?, + penpotSource: Common.PenpotSource?, tokensFile: Common.TokensFile?, tokensFileId: String?, tokensCollectionName: String?, @@ -87,6 +92,7 @@ extension Flutter { self.output = output self.className = className self.sourceKind = sourceKind + self.penpotSource = penpotSource self.tokensFile = tokensFile self.tokensFileId = tokensFileId self.tokensCollectionName = tokensCollectionName @@ -120,9 +126,14 @@ extension Flutter { /// Naming style for icon names. public var nameStyle: Common.NameStyle? - /// Design source kind override. When null, defaults to "figma". + /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" + /// - Otherwise → "figma" public var sourceKind: Common.SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + public var penpotSource: Common.PenpotSource? + /// Figma frame name to export from. public var figmaFrameName: String? @@ -156,6 +167,7 @@ extension Flutter { className: String?, nameStyle: Common.NameStyle?, sourceKind: Common.SourceKind?, + penpotSource: Common.PenpotSource?, figmaFrameName: String?, figmaPageName: String?, figmaFileId: String?, @@ -169,6 +181,7 @@ extension Flutter { self.className = className self.nameStyle = nameStyle self.sourceKind = sourceKind + self.penpotSource = penpotSource self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId @@ -210,9 +223,14 @@ extension Flutter { /// Naming style for generated assets. public var nameStyle: Common.NameStyle? - /// Design source kind override. When null, defaults to "figma". + /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" + /// - Otherwise → "figma" public var sourceKind: Common.SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + public var penpotSource: Common.PenpotSource? + /// Figma frame name to export from. public var figmaFrameName: String? @@ -250,6 +268,7 @@ extension Flutter { sourceFormat: Common.SourceFormat?, nameStyle: Common.NameStyle?, sourceKind: Common.SourceKind?, + penpotSource: Common.PenpotSource?, figmaFrameName: String?, figmaPageName: String?, figmaFileId: String?, @@ -267,6 +286,7 @@ extension Flutter { self.sourceFormat = sourceFormat self.nameStyle = nameStyle self.sourceKind = sourceKind + self.penpotSource = penpotSource self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId diff --git a/Sources/ExFigConfig/Generated/Web.pkl.swift b/Sources/ExFigConfig/Generated/Web.pkl.swift index cab1e425..eb744d5e 100644 --- a/Sources/ExFigConfig/Generated/Web.pkl.swift +++ b/Sources/ExFigConfig/Generated/Web.pkl.swift @@ -36,10 +36,14 @@ extension Web { public var jsonFileName: String? /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" /// - If `tokensFile` is set → "tokens-file" /// - Otherwise → "figma" public var sourceKind: Common.SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + public var penpotSource: Common.PenpotSource? + /// Local .tokens.json file source (bypasses Figma API when set). public var tokensFile: Common.TokensFile? @@ -78,6 +82,7 @@ extension Web { tsFileName: String?, jsonFileName: String?, sourceKind: Common.SourceKind?, + penpotSource: Common.PenpotSource?, tokensFile: Common.TokensFile?, tokensFileId: String?, tokensCollectionName: String?, @@ -96,6 +101,7 @@ extension Web { self.tsFileName = tsFileName self.jsonFileName = jsonFileName self.sourceKind = sourceKind + self.penpotSource = penpotSource self.tokensFile = tokensFile self.tokensFileId = tokensFileId self.tokensCollectionName = tokensCollectionName @@ -132,9 +138,14 @@ extension Web { /// Naming style for icon names. public var nameStyle: Common.NameStyle? - /// Design source kind override. When null, defaults to "figma". + /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" + /// - Otherwise → "figma" public var sourceKind: Common.SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + public var penpotSource: Common.PenpotSource? + /// Figma frame name to export from. public var figmaFrameName: String? @@ -169,6 +180,7 @@ extension Web { iconSize: Int?, nameStyle: Common.NameStyle?, sourceKind: Common.SourceKind?, + penpotSource: Common.PenpotSource?, figmaFrameName: String?, figmaPageName: String?, figmaFileId: String?, @@ -183,6 +195,7 @@ extension Web { self.iconSize = iconSize self.nameStyle = nameStyle self.sourceKind = sourceKind + self.penpotSource = penpotSource self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId @@ -212,9 +225,14 @@ extension Web { /// Naming style for generated image names. public var nameStyle: Common.NameStyle? - /// Design source kind override. When null, defaults to "figma". + /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" + /// - Otherwise → "figma" public var sourceKind: Common.SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + public var penpotSource: Common.PenpotSource? + /// Figma frame name to export from. public var figmaFrameName: String? @@ -248,6 +266,7 @@ extension Web { generateReactComponents: Bool?, nameStyle: Common.NameStyle?, sourceKind: Common.SourceKind?, + penpotSource: Common.PenpotSource?, figmaFrameName: String?, figmaPageName: String?, figmaFileId: String?, @@ -261,6 +280,7 @@ extension Web { self.generateReactComponents = generateReactComponents self.nameStyle = nameStyle self.sourceKind = sourceKind + self.penpotSource = penpotSource self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId diff --git a/Sources/ExFigConfig/Generated/iOS.pkl.swift b/Sources/ExFigConfig/Generated/iOS.pkl.swift index d09241cd..c79edd63 100644 --- a/Sources/ExFigConfig/Generated/iOS.pkl.swift +++ b/Sources/ExFigConfig/Generated/iOS.pkl.swift @@ -91,10 +91,14 @@ extension iOS { public var codeSyntaxTemplate: String? /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" /// - If `tokensFile` is set → "tokens-file" /// - Otherwise → "figma" public var sourceKind: Common.SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + public var penpotSource: Common.PenpotSource? + /// Local .tokens.json file source (bypasses Figma API when set). public var tokensFile: Common.TokensFile? @@ -138,6 +142,7 @@ extension iOS { syncCodeSyntax: Bool?, codeSyntaxTemplate: String?, sourceKind: Common.SourceKind?, + penpotSource: Common.PenpotSource?, tokensFile: Common.TokensFile?, tokensFileId: String?, tokensCollectionName: String?, @@ -161,6 +166,7 @@ extension iOS { self.syncCodeSyntax = syncCodeSyntax self.codeSyntaxTemplate = codeSyntaxTemplate self.sourceKind = sourceKind + self.penpotSource = penpotSource self.tokensFile = tokensFile self.tokensFileId = tokensFileId self.tokensCollectionName = tokensCollectionName @@ -219,9 +225,14 @@ extension iOS { /// Suffix for assets using template render mode. public var renderModeTemplateSuffix: String? - /// Design source kind override. When null, defaults to "figma". + /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" + /// - Otherwise → "figma" public var sourceKind: Common.SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + public var penpotSource: Common.PenpotSource? + /// Figma frame name to export from. public var figmaFrameName: String? @@ -263,6 +274,7 @@ extension iOS { renderModeOriginalSuffix: String?, renderModeTemplateSuffix: String?, sourceKind: Common.SourceKind?, + penpotSource: Common.PenpotSource?, figmaFrameName: String?, figmaPageName: String?, figmaFileId: String?, @@ -284,6 +296,7 @@ extension iOS { self.renderModeOriginalSuffix = renderModeOriginalSuffix self.renderModeTemplateSuffix = renderModeTemplateSuffix self.sourceKind = sourceKind + self.penpotSource = penpotSource self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId @@ -344,9 +357,14 @@ extension iOS { /// Suffix for assets using template render mode. public var renderModeTemplateSuffix: String? - /// Design source kind override. When null, defaults to "figma". + /// Design source kind override. When null, auto-detected: + /// - If `penpotSource` is set → "penpot" + /// - Otherwise → "figma" public var sourceKind: Common.SourceKind? + /// Penpot source configuration (bypasses Figma API when set). + public var penpotSource: Common.PenpotSource? + /// Figma frame name to export from. public var figmaFrameName: String? @@ -390,6 +408,7 @@ extension iOS { renderModeOriginalSuffix: String?, renderModeTemplateSuffix: String?, sourceKind: Common.SourceKind?, + penpotSource: Common.PenpotSource?, figmaFrameName: String?, figmaPageName: String?, figmaFileId: String?, @@ -413,6 +432,7 @@ extension iOS { self.renderModeOriginalSuffix = renderModeOriginalSuffix self.renderModeTemplateSuffix = renderModeTemplateSuffix self.sourceKind = sourceKind + self.penpotSource = penpotSource self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId diff --git a/Sources/ExFigConfig/PKL/PKLEvaluator.swift b/Sources/ExFigConfig/PKL/PKLEvaluator.swift index 9a599eff..01788ad1 100644 --- a/Sources/ExFigConfig/PKL/PKLEvaluator.swift +++ b/Sources/ExFigConfig/PKL/PKLEvaluator.swift @@ -44,6 +44,7 @@ public enum PKLEvaluator { Common.NameProcessingImpl.self, Common.FrameSourceImpl.self, Common.TokensFile.self, + Common.PenpotSource.self, Common.WebpOptions.self, Common.Cache.self, Common.Colors.self, diff --git a/Sources/ExFigConfig/SourceKindBridging.swift b/Sources/ExFigConfig/SourceKindBridging.swift index 5ea26ff1..01275c89 100644 --- a/Sources/ExFigConfig/SourceKindBridging.swift +++ b/Sources/ExFigConfig/SourceKindBridging.swift @@ -15,3 +15,34 @@ public extension Common.SourceKind { } } } + +public extension Common_FrameSource { + /// Resolves the design source kind with priority: explicit > auto-detect > default (.figma). + /// + /// Auto-detection: `penpotSource` set → `.penpot`, otherwise `.figma`. + var resolvedSourceKind: DesignSourceKind { + if let explicit = sourceKind { + return explicit.coreSourceKind + } + if penpotSource != nil { + return .penpot + } + return .figma + } + + /// Resolves the file ID based on the resolved source kind. + /// + /// When source is Penpot, returns only the Penpot file ID (not Figma's) + /// to prevent passing a Figma file key to the Penpot API. + var resolvedFileId: String? { + if resolvedSourceKind == .penpot { + return penpotSource?.fileId + } + return figmaFileId + } + + /// Resolves the Penpot base URL from penpotSource config. + var resolvedPenpotBaseURL: String? { + penpotSource?.baseUrl + } +} diff --git a/Sources/ExFigConfig/VariablesSourceValidation.swift b/Sources/ExFigConfig/VariablesSourceValidation.swift index dec5b878..acbabd8a 100644 --- a/Sources/ExFigConfig/VariablesSourceValidation.swift +++ b/Sources/ExFigConfig/VariablesSourceValidation.swift @@ -1,49 +1,82 @@ import ExFigCore public extension Common_VariablesSource { - /// Returns a validated `ColorsSourceInput` for use with `ColorsExportContext`. - /// - /// When `tokensFile` is set, bypasses Figma API validation and returns a local-file source. - /// Otherwise, throws if required Figma fields (`tokensFileId`, `tokensCollectionName`, - /// `lightModeName`) are nil or empty. /// Resolves the design source kind with priority: explicit > auto-detect > default (.figma). var resolvedSourceKind: DesignSourceKind { if let explicit = sourceKind { return explicit.coreSourceKind } + if penpotSource != nil { + return .penpot + } if tokensFile != nil { return .tokensFile } return .figma } + /// Returns a validated `ColorsSourceInput` for use with `ColorsExportContext`. + /// + /// Dispatches by `resolvedSourceKind`: Penpot, tokens-file, or Figma Variables. func validatedColorsSourceInput() throws -> ColorsSourceInput { let kind = resolvedSourceKind - if kind == .tokensFile { - guard let tokensFile else { - throw ColorsConfigError.missingTokensFileId - } - // Collect Figma-specific mode fields that will be ignored by tokens-file source - var ignoredModes: [String] = [] - if darkModeName != nil { ignoredModes.append("darkModeName") } - if lightHCModeName != nil { ignoredModes.append("lightHCModeName") } - if darkHCModeName != nil { ignoredModes.append("darkHCModeName") } + switch kind { + case .penpot: + return try penpotColorsSourceInput() + case .tokensFile: + return try tokensFileColorsSourceInput() + case .figma: + return try figmaColorsSourceInput(kind: kind) + case .tokensStudio, .sketchFile: + throw ColorsConfigError.unsupportedSourceKind(kind) + } + } +} + +// MARK: - Private Helpers - let config = TokensFileColorsConfig( - filePath: tokensFile.path, - groupFilter: tokensFile.groupFilter, - ignoredModeNames: ignoredModes - ) - return ColorsSourceInput( - sourceKind: .tokensFile, - sourceConfig: config, - nameValidateRegexp: nameValidateRegexp, - nameReplaceRegexp: nameReplaceRegexp - ) +private extension Common_VariablesSource { + func penpotColorsSourceInput() throws -> ColorsSourceInput { + guard let penpotSource else { + throw ColorsConfigError.missingPenpotSource } + let config = PenpotColorsConfig( + fileId: penpotSource.fileId, + baseURL: penpotSource.baseUrl, + pathFilter: penpotSource.pathFilter + ) + return ColorsSourceInput( + sourceKind: .penpot, + sourceConfig: config, + nameValidateRegexp: nameValidateRegexp, + nameReplaceRegexp: nameReplaceRegexp + ) + } + + func tokensFileColorsSourceInput() throws -> ColorsSourceInput { + guard let tokensFile else { + throw ColorsConfigError.missingTokensFileId + } + var ignoredModes: [String] = [] + if darkModeName != nil { ignoredModes.append("darkModeName") } + if lightHCModeName != nil { ignoredModes.append("lightHCModeName") } + if darkHCModeName != nil { ignoredModes.append("darkHCModeName") } + + let config = TokensFileColorsConfig( + filePath: tokensFile.path, + groupFilter: tokensFile.groupFilter, + ignoredModeNames: ignoredModes + ) + return ColorsSourceInput( + sourceKind: .tokensFile, + sourceConfig: config, + nameValidateRegexp: nameValidateRegexp, + nameReplaceRegexp: nameReplaceRegexp + ) + } - // Figma Variables source — require all fields + func figmaColorsSourceInput(kind: DesignSourceKind) throws -> ColorsSourceInput { guard let tokensFileId, !tokensFileId.isEmpty else { throw ColorsConfigError.missingTokensFileId } diff --git a/Sources/ExFigCore/CLAUDE.md b/Sources/ExFigCore/CLAUDE.md index 9dcdd859..120ae25a 100644 --- a/Sources/ExFigCore/CLAUDE.md +++ b/Sources/ExFigCore/CLAUDE.md @@ -29,14 +29,15 @@ Exporter.export*(entries, platformConfig, context) - `ColorsSource`, `ComponentsSource`, `TypographySource` — no `sourceKind` in protocols (clean contract) - `DesignSourceKind` enum — dispatch discriminator (.figma, .penpot, .tokensFile, .tokensStudio, .sketchFile) -- `ColorsSourceConfig` protocol + `FigmaColorsConfig` / `TokensFileColorsConfig` — type-erased source-specific config +- `ColorsSourceConfig` protocol + `FigmaColorsConfig` / `TokensFileColorsConfig` / `PenpotColorsConfig` — type-erased source-specific config - `ColorsSourceInput` uses `sourceKind` + `sourceConfig: any ColorsSourceConfig` instead of flat fields - `ColorsSourceInput.spinnerLabel`: computed property for user-facing spinner messages (dispatches on `sourceConfig` type) - `TokensFileColorsConfig.ignoredModeNames`: carries Figma-specific mode field names set by user for warning - `IconsSourceInput`, `ImagesSourceInput`, `TypographySourceInput` have `sourceKind` field (default `.figma`) +- `IconsSourceInput`, `ImagesSourceInput`, `TypographySourceInput` have `penpotBaseURL: String?` field for Penpot base URL - When adding a new `ColorsSourceConfig` subtype: update `spinnerLabel` switch in `ExportContext.swift` -Implementations live in `Sources/ExFigCLI/Source/` — `FigmaColorsSource`, `TokensFileColorsSource`, `FigmaComponentsSource`, `FigmaTypographySource`, `SourceFactory`. +Implementations live in `Sources/ExFigCLI/Source/` — `FigmaColorsSource`, `TokensFileColorsSource`, `PenpotColorsSource`, `PenpotComponentsSource`, `PenpotTypographySource`, `FigmaComponentsSource`, `FigmaTypographySource`, `SourceFactory`. ### Domain Models diff --git a/Sources/ExFigCore/Protocol/DesignSource.swift b/Sources/ExFigCore/Protocol/DesignSource.swift index 1c41e291..c6044025 100644 --- a/Sources/ExFigCore/Protocol/DesignSource.swift +++ b/Sources/ExFigCore/Protocol/DesignSource.swift @@ -70,6 +70,23 @@ public struct FigmaColorsConfig: ColorsSourceConfig { } } +/// Penpot-specific colors configuration — file ID, base URL, and path filter. +public struct PenpotColorsConfig: ColorsSourceConfig { + public let fileId: String + public let baseURL: String + public let pathFilter: String? + + public init( + fileId: String, + baseURL: String = "https://design.penpot.app/", + pathFilter: String? = nil + ) { + self.fileId = fileId + self.baseURL = baseURL + self.pathFilter = pathFilter + } +} + /// Tokens-file-specific colors configuration — local .tokens.json path + optional group filter. public struct TokensFileColorsConfig: ColorsSourceConfig { public let filePath: String diff --git a/Sources/ExFigCore/Protocol/ExportContext.swift b/Sources/ExFigCore/Protocol/ExportContext.swift index ad5dfe0b..9d82f3b1 100644 --- a/Sources/ExFigCore/Protocol/ExportContext.swift +++ b/Sources/ExFigCore/Protocol/ExportContext.swift @@ -127,17 +127,25 @@ public struct ColorsSourceInput: Sendable { return URL(fileURLWithPath: config.filePath).lastPathComponent } return "tokens file" - case .penpot, .tokensStudio, .sketchFile: + case .penpot: + if let config = sourceConfig as? PenpotColorsConfig { + let shortId = String(config.fileId.prefix(8)) + return "Penpot colors (\(shortId)…)" + } + return "Penpot" + case .tokensStudio, .sketchFile: return sourceKind.rawValue } } } /// Error thrown when required colors configuration fields are missing. -public enum ColorsConfigError: LocalizedError { +public enum ColorsConfigError: LocalizedError, Equatable { case missingTokensFileId case missingTokensCollectionName case missingLightModeName + case missingPenpotSource + case unsupportedSourceKind(DesignSourceKind) public var errorDescription: String? { switch self { @@ -147,6 +155,10 @@ public enum ColorsConfigError: LocalizedError { "tokensCollectionName is required for colors export" case .missingLightModeName: "lightModeName is required for colors export" + case .missingPenpotSource: + "penpotSource configuration is required when sourceKind is 'penpot'" + case let .unsupportedSourceKind(kind): + "Source kind '\(kind.rawValue)' is not supported for colors export" } } @@ -158,6 +170,10 @@ public enum ColorsConfigError: LocalizedError { "Add 'tokensCollectionName' to your colors entry, or set common.variablesColors" case .missingLightModeName: "Add 'lightModeName' to your colors entry, or set common.variablesColors" + case .missingPenpotSource: + "Add 'penpotSource { fileId = \"...\" }' to your colors entry" + case let .unsupportedSourceKind(kind): + "Supported source kinds for colors: figma, penpot, tokensFile. Got: '\(kind.rawValue)'" } } } diff --git a/Sources/ExFigCore/Protocol/IconsExportContext.swift b/Sources/ExFigCore/Protocol/IconsExportContext.swift index 7bf81642..b080c7f3 100644 --- a/Sources/ExFigCore/Protocol/IconsExportContext.swift +++ b/Sources/ExFigCore/Protocol/IconsExportContext.swift @@ -100,6 +100,9 @@ public struct IconsSourceInput: Sendable { /// Name replacement regex. public let nameReplaceRegexp: String? + /// Penpot instance base URL (used when sourceKind == .penpot). + public let penpotBaseURL: String? + public init( sourceKind: DesignSourceKind = .figma, figmaFileId: String? = nil, @@ -115,7 +118,8 @@ public struct IconsSourceInput: Sendable { renderModeTemplateSuffix: String? = nil, rtlProperty: String? = "RTL", nameValidateRegexp: String? = nil, - nameReplaceRegexp: String? = nil + nameReplaceRegexp: String? = nil, + penpotBaseURL: String? = nil ) { self.sourceKind = sourceKind self.figmaFileId = figmaFileId @@ -132,6 +136,7 @@ public struct IconsSourceInput: Sendable { self.rtlProperty = rtlProperty self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp + self.penpotBaseURL = penpotBaseURL } } diff --git a/Sources/ExFigCore/Protocol/ImagesExportContext.swift b/Sources/ExFigCore/Protocol/ImagesExportContext.swift index b245667c..9e73bd40 100644 --- a/Sources/ExFigCore/Protocol/ImagesExportContext.swift +++ b/Sources/ExFigCore/Protocol/ImagesExportContext.swift @@ -199,6 +199,9 @@ public struct ImagesSourceInput: Sendable { /// Name replacement regex. public let nameReplaceRegexp: String? + /// Penpot instance base URL (used when sourceKind == .penpot). + public let penpotBaseURL: String? + public init( sourceKind: DesignSourceKind = .figma, figmaFileId: String? = nil, @@ -211,7 +214,8 @@ public struct ImagesSourceInput: Sendable { darkModeSuffix: String = "_dark", rtlProperty: String? = "RTL", nameValidateRegexp: String? = nil, - nameReplaceRegexp: String? = nil + nameReplaceRegexp: String? = nil, + penpotBaseURL: String? = nil ) { self.sourceKind = sourceKind self.figmaFileId = figmaFileId @@ -225,6 +229,7 @@ public struct ImagesSourceInput: Sendable { self.rtlProperty = rtlProperty self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp + self.penpotBaseURL = penpotBaseURL } } diff --git a/Sources/ExFigCore/Protocol/TypographyExportContext.swift b/Sources/ExFigCore/Protocol/TypographyExportContext.swift index c7771d90..a470e529 100644 --- a/Sources/ExFigCore/Protocol/TypographyExportContext.swift +++ b/Sources/ExFigCore/Protocol/TypographyExportContext.swift @@ -45,14 +45,19 @@ public struct TypographySourceInput: Sendable { /// Optional timeout for Figma API requests. public let timeout: TimeInterval? + /// Penpot instance base URL (used when sourceKind == .penpot). + public let penpotBaseURL: String? + public init( sourceKind: DesignSourceKind = .figma, fileId: String, - timeout: TimeInterval? = nil + timeout: TimeInterval? = nil, + penpotBaseURL: String? = nil ) { self.sourceKind = sourceKind self.fileId = fileId self.timeout = timeout + self.penpotBaseURL = penpotBaseURL } } diff --git a/Tests/ExFigTests/Input/DesignSourceTests.swift b/Tests/ExFigTests/Input/DesignSourceTests.swift index 4fd114b0..336adcfd 100644 --- a/Tests/ExFigTests/Input/DesignSourceTests.swift +++ b/Tests/ExFigTests/Input/DesignSourceTests.swift @@ -62,6 +62,7 @@ final class ExplicitSourceKindTests: XCTestCase { syncCodeSyntax: nil, codeSyntaxTemplate: nil, sourceKind: .figma, + penpotSource: nil, tokensFile: Common.TokensFile(path: "tokens.json", groupFilter: nil), tokensFileId: "file123", tokensCollectionName: "Collection", @@ -92,6 +93,7 @@ final class ExplicitSourceKindTests: XCTestCase { syncCodeSyntax: nil, codeSyntaxTemplate: nil, sourceKind: .tokensFile, + penpotSource: nil, tokensFile: Common.TokensFile(path: "design.json", groupFilter: "Brand"), tokensFileId: nil, tokensCollectionName: nil, @@ -125,6 +127,7 @@ final class ExplicitSourceKindTests: XCTestCase { syncCodeSyntax: nil, codeSyntaxTemplate: nil, sourceKind: .tokensFile, + penpotSource: nil, tokensFile: nil, tokensFileId: nil, tokensCollectionName: nil, @@ -159,6 +162,7 @@ final class IgnoredModeNamesTests: XCTestCase { syncCodeSyntax: nil, codeSyntaxTemplate: nil, sourceKind: nil, + penpotSource: nil, tokensFile: Common.TokensFile(path: "tokens.json", groupFilter: nil), tokensFileId: nil, tokensCollectionName: nil, @@ -189,6 +193,7 @@ final class IgnoredModeNamesTests: XCTestCase { syncCodeSyntax: nil, codeSyntaxTemplate: nil, sourceKind: nil, + penpotSource: nil, tokensFile: Common.TokensFile(path: "tokens.json", groupFilter: nil), tokensFileId: nil, tokensCollectionName: nil, @@ -219,6 +224,7 @@ final class IgnoredModeNamesTests: XCTestCase { syncCodeSyntax: nil, codeSyntaxTemplate: nil, sourceKind: nil, + penpotSource: nil, tokensFile: Common.TokensFile(path: "tokens.json", groupFilter: nil), tokensFileId: nil, tokensCollectionName: nil, @@ -259,14 +265,14 @@ final class SpinnerLabelTests: XCTestCase { XCTAssertEqual(input.spinnerLabel, "design-tokens.json") } - func testUnsupportedSourceKindSpinnerLabelShowsRawValue() { + func testPenpotSpinnerLabelShowsTruncatedFileId() { let input = ColorsSourceInput( sourceKind: .penpot, - sourceConfig: FigmaColorsConfig( - tokensFileId: "", tokensCollectionName: "", lightModeName: "" + sourceConfig: PenpotColorsConfig( + fileId: "abc12345-def6-7890", baseURL: "https://design.penpot.app", pathFilter: nil ) ) - XCTAssertEqual(input.spinnerLabel, "penpot") + XCTAssertEqual(input.spinnerLabel, "Penpot colors (abc12345…)") } } diff --git a/Tests/ExFigTests/Input/EnumBridgingTests.swift b/Tests/ExFigTests/Input/EnumBridgingTests.swift index eae4c5b3..851bcda3 100644 --- a/Tests/ExFigTests/Input/EnumBridgingTests.swift +++ b/Tests/ExFigTests/Input/EnumBridgingTests.swift @@ -68,6 +68,7 @@ final class EnumBridgingTests: XCTestCase { syncCodeSyntax: nil, codeSyntaxTemplate: nil, sourceKind: nil, + penpotSource: nil, tokensFile: nil, tokensFileId: nil, tokensCollectionName: nil, @@ -114,6 +115,7 @@ final class EnumBridgingTests: XCTestCase { renderModeOriginalSuffix: nil, renderModeTemplateSuffix: nil, sourceKind: nil, + penpotSource: nil, figmaFrameName: nil, figmaPageName: nil, figmaFileId: nil, @@ -158,6 +160,7 @@ final class EnumBridgingTests: XCTestCase { renderModeOriginalSuffix: nil, renderModeTemplateSuffix: nil, sourceKind: nil, + penpotSource: nil, figmaFrameName: nil, figmaPageName: nil, figmaFileId: nil, @@ -198,6 +201,7 @@ final class EnumBridgingTests: XCTestCase { codeConnectPackageName: nil, codeConnectKotlin: nil, sourceKind: nil, + penpotSource: nil, figmaFrameName: nil, figmaPageName: nil, figmaFileId: nil, @@ -226,6 +230,7 @@ final class EnumBridgingTests: XCTestCase { codeConnectPackageName: nil, codeConnectKotlin: nil, sourceKind: nil, + penpotSource: nil, figmaFrameName: nil, figmaPageName: nil, figmaFileId: nil, @@ -260,6 +265,7 @@ final class EnumBridgingTests: XCTestCase { nameStyle: pklStyle, codeConnectKotlin: nil, sourceKind: nil, + penpotSource: nil, figmaFrameName: nil, figmaPageName: nil, figmaFileId: nil, @@ -286,6 +292,7 @@ final class EnumBridgingTests: XCTestCase { nameStyle: nil, codeConnectKotlin: nil, sourceKind: nil, + penpotSource: nil, figmaFrameName: nil, figmaPageName: nil, figmaFileId: nil, @@ -349,6 +356,7 @@ final class EnumBridgingTests: XCTestCase { renderModeOriginalSuffix: nil, renderModeTemplateSuffix: nil, sourceKind: nil, + penpotSource: nil, figmaFrameName: nil, figmaPageName: nil, figmaFileId: nil, @@ -382,6 +390,7 @@ final class EnumBridgingTests: XCTestCase { renderModeOriginalSuffix: nil, renderModeTemplateSuffix: nil, sourceKind: nil, + penpotSource: nil, figmaFrameName: nil, figmaPageName: nil, figmaFileId: nil, @@ -445,6 +454,7 @@ final class EnumBridgingTests: XCTestCase { syncCodeSyntax: nil, codeSyntaxTemplate: nil, sourceKind: nil, + penpotSource: nil, tokensFile: nil, tokensFileId: nil, tokensCollectionName: "Collection", lightModeName: "Light", @@ -474,6 +484,7 @@ final class EnumBridgingTests: XCTestCase { syncCodeSyntax: nil, codeSyntaxTemplate: nil, sourceKind: nil, + penpotSource: nil, tokensFile: nil, tokensFileId: "", tokensCollectionName: "Collection", lightModeName: "Light", @@ -503,6 +514,7 @@ final class EnumBridgingTests: XCTestCase { syncCodeSyntax: nil, codeSyntaxTemplate: nil, sourceKind: nil, + penpotSource: nil, tokensFile: nil, tokensFileId: "file123", tokensCollectionName: "Collection", lightModeName: "Light", @@ -538,6 +550,7 @@ final class EnumBridgingTests: XCTestCase { syncCodeSyntax: nil, codeSyntaxTemplate: nil, sourceKind: nil, + penpotSource: nil, tokensFile: Common.TokensFile(path: "tokens.json", groupFilter: nil), tokensFileId: nil, tokensCollectionName: nil, @@ -570,6 +583,7 @@ final class EnumBridgingTests: XCTestCase { syncCodeSyntax: nil, codeSyntaxTemplate: nil, sourceKind: nil, + penpotSource: nil, tokensFile: Common.TokensFile(path: "design-tokens.json", groupFilter: "Brand.Colors"), tokensFileId: nil, tokensCollectionName: nil, @@ -602,6 +616,7 @@ final class EnumBridgingTests: XCTestCase { syncCodeSyntax: nil, codeSyntaxTemplate: nil, sourceKind: nil, + penpotSource: nil, tokensFile: nil, tokensFileId: nil, tokensCollectionName: nil, @@ -631,6 +646,7 @@ final class EnumBridgingTests: XCTestCase { colorKotlin: nil, themeAttributes: nil, sourceKind: nil, + penpotSource: nil, tokensFile: nil, tokensFileId: nil, tokensCollectionName: "Collection", lightModeName: "Light", @@ -657,6 +673,7 @@ final class EnumBridgingTests: XCTestCase { colorKotlin: nil, themeAttributes: nil, sourceKind: nil, + penpotSource: nil, tokensFile: nil, tokensFileId: "", tokensCollectionName: "Collection", lightModeName: "Light", @@ -683,6 +700,7 @@ final class EnumBridgingTests: XCTestCase { colorKotlin: nil, themeAttributes: nil, sourceKind: nil, + penpotSource: nil, tokensFile: nil, tokensFileId: "file456", tokensCollectionName: "Colors", lightModeName: "Light", @@ -710,7 +728,7 @@ final class EnumBridgingTests: XCTestCase { groupUsingNamespace: nil, assetsFolderProvidesNamespace: nil, colorSwift: nil, swiftuiColorSwift: nil, xcassetsPath: nil, templatesPath: nil, syncCodeSyntax: nil, codeSyntaxTemplate: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "file123", tokensCollectionName: nil, + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "file123", tokensCollectionName: nil, lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -726,7 +744,8 @@ final class EnumBridgingTests: XCTestCase { groupUsingNamespace: nil, assetsFolderProvidesNamespace: nil, colorSwift: nil, swiftuiColorSwift: nil, xcassetsPath: nil, templatesPath: nil, syncCodeSyntax: nil, codeSyntaxTemplate: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "file123", tokensCollectionName: "", lightModeName: "Light", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "file123", tokensCollectionName: "", + lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -741,7 +760,8 @@ final class EnumBridgingTests: XCTestCase { groupUsingNamespace: nil, assetsFolderProvidesNamespace: nil, colorSwift: nil, swiftuiColorSwift: nil, xcassetsPath: nil, templatesPath: nil, syncCodeSyntax: nil, codeSyntaxTemplate: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "file123", tokensCollectionName: "Collection", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "file123", + tokensCollectionName: "Collection", lightModeName: nil, darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -757,7 +777,8 @@ final class EnumBridgingTests: XCTestCase { groupUsingNamespace: nil, assetsFolderProvidesNamespace: nil, colorSwift: nil, swiftuiColorSwift: nil, xcassetsPath: nil, templatesPath: nil, syncCodeSyntax: nil, codeSyntaxTemplate: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "file123", tokensCollectionName: "Collection", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "file123", + tokensCollectionName: "Collection", lightModeName: "", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -774,7 +795,7 @@ final class EnumBridgingTests: XCTestCase { mainRes: nil, mainSrc: nil, templatesPath: nil, xmlOutputFileName: nil, xmlDisabled: nil, composePackageName: nil, colorKotlin: nil, themeAttributes: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "file456", tokensCollectionName: nil, + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "file456", tokensCollectionName: nil, lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -789,7 +810,8 @@ final class EnumBridgingTests: XCTestCase { mainRes: nil, mainSrc: nil, templatesPath: nil, xmlOutputFileName: nil, xmlDisabled: nil, composePackageName: nil, colorKotlin: nil, themeAttributes: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "file456", tokensCollectionName: "", lightModeName: "Light", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "file456", tokensCollectionName: "", + lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -803,7 +825,8 @@ final class EnumBridgingTests: XCTestCase { mainRes: nil, mainSrc: nil, templatesPath: nil, xmlOutputFileName: nil, xmlDisabled: nil, composePackageName: nil, colorKotlin: nil, themeAttributes: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "file456", tokensCollectionName: "Colors", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "file456", + tokensCollectionName: "Colors", lightModeName: nil, darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -818,7 +841,8 @@ final class EnumBridgingTests: XCTestCase { mainRes: nil, mainSrc: nil, templatesPath: nil, xmlOutputFileName: nil, xmlDisabled: nil, composePackageName: nil, colorKotlin: nil, themeAttributes: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "file456", tokensCollectionName: "Colors", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "file456", + tokensCollectionName: "Colors", lightModeName: "", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -833,7 +857,7 @@ final class EnumBridgingTests: XCTestCase { func testFlutterColorsEntryThrowsOnMissingTokensFileId() { let entry = Flutter.ColorsEntry( templatesPath: nil, output: nil, className: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: nil, tokensCollectionName: "Collection", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: nil, tokensCollectionName: "Collection", lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -846,7 +870,7 @@ final class EnumBridgingTests: XCTestCase { func testFlutterColorsEntryThrowsOnEmptyTokensFileId() { let entry = Flutter.ColorsEntry( templatesPath: nil, output: nil, className: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "", tokensCollectionName: "Collection", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "", tokensCollectionName: "Collection", lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -859,7 +883,7 @@ final class EnumBridgingTests: XCTestCase { func testFlutterColorsEntryThrowsOnMissingTokensCollectionName() { let entry = Flutter.ColorsEntry( templatesPath: nil, output: nil, className: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "file789", tokensCollectionName: nil, + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "file789", tokensCollectionName: nil, lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -872,7 +896,8 @@ final class EnumBridgingTests: XCTestCase { func testFlutterColorsEntryThrowsOnEmptyTokensCollectionName() { let entry = Flutter.ColorsEntry( templatesPath: nil, output: nil, className: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "file789", tokensCollectionName: "", lightModeName: "Light", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "file789", tokensCollectionName: "", + lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -884,7 +909,8 @@ final class EnumBridgingTests: XCTestCase { func testFlutterColorsEntryThrowsOnMissingLightModeName() { let entry = Flutter.ColorsEntry( templatesPath: nil, output: nil, className: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "file789", tokensCollectionName: "Collection", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "file789", + tokensCollectionName: "Collection", lightModeName: nil, darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -897,7 +923,8 @@ final class EnumBridgingTests: XCTestCase { func testFlutterColorsEntryThrowsOnEmptyLightModeName() { let entry = Flutter.ColorsEntry( templatesPath: nil, output: nil, className: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "file789", tokensCollectionName: "Collection", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "file789", + tokensCollectionName: "Collection", lightModeName: "", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -910,7 +937,8 @@ final class EnumBridgingTests: XCTestCase { func testFlutterColorsEntryValidatesSuccessfully() throws { let entry = Flutter.ColorsEntry( templatesPath: nil, output: nil, className: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "file789", tokensCollectionName: "Colors", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "file789", + tokensCollectionName: "Colors", lightModeName: "Light", darkModeName: "Dark", lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -930,7 +958,7 @@ final class EnumBridgingTests: XCTestCase { let entry = Web.ColorsEntry( output: nil, templatesPath: nil, outputDirectory: nil, cssFileName: nil, tsFileName: nil, jsonFileName: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: nil, tokensCollectionName: "Collection", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: nil, tokensCollectionName: "Collection", lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -944,7 +972,7 @@ final class EnumBridgingTests: XCTestCase { let entry = Web.ColorsEntry( output: nil, templatesPath: nil, outputDirectory: nil, cssFileName: nil, tsFileName: nil, jsonFileName: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "", tokensCollectionName: "Collection", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "", tokensCollectionName: "Collection", lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -958,7 +986,7 @@ final class EnumBridgingTests: XCTestCase { let entry = Web.ColorsEntry( output: nil, templatesPath: nil, outputDirectory: nil, cssFileName: nil, tsFileName: nil, jsonFileName: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "fileABC", tokensCollectionName: nil, + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "fileABC", tokensCollectionName: nil, lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -972,7 +1000,8 @@ final class EnumBridgingTests: XCTestCase { let entry = Web.ColorsEntry( output: nil, templatesPath: nil, outputDirectory: nil, cssFileName: nil, tsFileName: nil, jsonFileName: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "fileABC", tokensCollectionName: "", lightModeName: "Light", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "fileABC", tokensCollectionName: "", + lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -985,7 +1014,8 @@ final class EnumBridgingTests: XCTestCase { let entry = Web.ColorsEntry( output: nil, templatesPath: nil, outputDirectory: nil, cssFileName: nil, tsFileName: nil, jsonFileName: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "fileABC", tokensCollectionName: "Collection", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "fileABC", + tokensCollectionName: "Collection", lightModeName: nil, darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -999,7 +1029,8 @@ final class EnumBridgingTests: XCTestCase { let entry = Web.ColorsEntry( output: nil, templatesPath: nil, outputDirectory: nil, cssFileName: nil, tsFileName: nil, jsonFileName: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "fileABC", tokensCollectionName: "Collection", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "fileABC", + tokensCollectionName: "Collection", lightModeName: "", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -1013,7 +1044,8 @@ final class EnumBridgingTests: XCTestCase { let entry = Web.ColorsEntry( output: nil, templatesPath: nil, outputDirectory: nil, cssFileName: nil, tsFileName: nil, jsonFileName: nil, - sourceKind: nil, tokensFile: nil, tokensFileId: "fileABC", tokensCollectionName: "Colors", + sourceKind: nil, penpotSource: nil, tokensFile: nil, tokensFileId: "fileABC", + tokensCollectionName: "Colors", lightModeName: "Light", darkModeName: "Dark", lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil diff --git a/Tests/ExFigTests/Input/PenpotDesignSourceTests.swift b/Tests/ExFigTests/Input/PenpotDesignSourceTests.swift new file mode 100644 index 00000000..5a2e0081 --- /dev/null +++ b/Tests/ExFigTests/Input/PenpotDesignSourceTests.swift @@ -0,0 +1,281 @@ +import ExFig_iOS +@testable import ExFigCLI +import ExFigConfig +import ExFigCore +import XCTest + +// MARK: - FrameSource resolvedSourceKind Tests + +final class FrameSourceResolvedSourceKindTests: XCTestCase { + func testDefaultsToFigmaWhenNoPenpotSource() { + let entry = iOS.IconsEntry( + format: .svg, + assetsFolder: "Icons", + preservesVectorRepresentation: nil, + nameStyle: .camelCase, + imageSwift: nil, + swiftUIImageSwift: nil, + codeConnectSwift: nil, + xcassetsPath: nil, + templatesPath: nil, + renderMode: nil, + renderModeDefaultSuffix: nil, + renderModeOriginalSuffix: nil, + renderModeTemplateSuffix: nil, + sourceKind: nil, + penpotSource: nil, + figmaFrameName: nil, + figmaPageName: nil, + figmaFileId: "figma-file-id", + rtlProperty: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + XCTAssertEqual(entry.resolvedSourceKind, .figma) + } + + func testAutoDetectsPenpotFromPenpotSource() { + let entry = iOS.IconsEntry( + format: .svg, + assetsFolder: "Icons", + preservesVectorRepresentation: nil, + nameStyle: .camelCase, + imageSwift: nil, + swiftUIImageSwift: nil, + codeConnectSwift: nil, + xcassetsPath: nil, + templatesPath: nil, + renderMode: nil, + renderModeDefaultSuffix: nil, + renderModeOriginalSuffix: nil, + renderModeTemplateSuffix: nil, + sourceKind: nil, + penpotSource: Common.PenpotSource( + fileId: "penpot-uuid", baseUrl: "https://penpot.example.com/", pathFilter: nil + ), + figmaFrameName: nil, + figmaPageName: nil, + figmaFileId: nil, + rtlProperty: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + XCTAssertEqual(entry.resolvedSourceKind, .penpot) + } + + func testExplicitSourceKindOverridesPenpotAutoDetect() { + let entry = iOS.IconsEntry( + format: .svg, + assetsFolder: "Icons", + preservesVectorRepresentation: nil, + nameStyle: .camelCase, + imageSwift: nil, + swiftUIImageSwift: nil, + codeConnectSwift: nil, + xcassetsPath: nil, + templatesPath: nil, + renderMode: nil, + renderModeDefaultSuffix: nil, + renderModeOriginalSuffix: nil, + renderModeTemplateSuffix: nil, + sourceKind: .figma, + penpotSource: Common.PenpotSource( + fileId: "penpot-uuid", baseUrl: "https://penpot.example.com/", pathFilter: nil + ), + figmaFrameName: nil, + figmaPageName: nil, + figmaFileId: "figma-file-id", + rtlProperty: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + XCTAssertEqual(entry.resolvedSourceKind, .figma) + } + + func testResolvedFileIdPrefersPenpotSource() { + let entry = iOS.IconsEntry( + format: .svg, + assetsFolder: "Icons", + preservesVectorRepresentation: nil, + nameStyle: .camelCase, + imageSwift: nil, + swiftUIImageSwift: nil, + codeConnectSwift: nil, + xcassetsPath: nil, + templatesPath: nil, + renderMode: nil, + renderModeDefaultSuffix: nil, + renderModeOriginalSuffix: nil, + renderModeTemplateSuffix: nil, + sourceKind: nil, + penpotSource: Common.PenpotSource( + fileId: "penpot-uuid", baseUrl: "https://penpot.example.com/", pathFilter: nil + ), + figmaFrameName: nil, + figmaPageName: nil, + figmaFileId: "figma-file-id", + rtlProperty: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + XCTAssertEqual(entry.resolvedFileId, "penpot-uuid") + } + + func testResolvedFileIdFallsBackToFigmaFileId() { + let entry = iOS.IconsEntry( + format: .svg, + assetsFolder: "Icons", + preservesVectorRepresentation: nil, + nameStyle: .camelCase, + imageSwift: nil, + swiftUIImageSwift: nil, + codeConnectSwift: nil, + xcassetsPath: nil, + templatesPath: nil, + renderMode: nil, + renderModeDefaultSuffix: nil, + renderModeOriginalSuffix: nil, + renderModeTemplateSuffix: nil, + sourceKind: nil, + penpotSource: nil, + figmaFrameName: nil, + figmaPageName: nil, + figmaFileId: "figma-file-id", + rtlProperty: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + XCTAssertEqual(entry.resolvedFileId, "figma-file-id") + } + + func testResolvedPenpotBaseURLFromPenpotSource() { + let entry = iOS.IconsEntry( + format: .svg, + assetsFolder: "Icons", + preservesVectorRepresentation: nil, + nameStyle: .camelCase, + imageSwift: nil, + swiftUIImageSwift: nil, + codeConnectSwift: nil, + xcassetsPath: nil, + templatesPath: nil, + renderMode: nil, + renderModeDefaultSuffix: nil, + renderModeOriginalSuffix: nil, + renderModeTemplateSuffix: nil, + sourceKind: nil, + penpotSource: Common.PenpotSource( + fileId: "uuid", baseUrl: "https://my-penpot.example.com/", pathFilter: nil + ), + figmaFrameName: nil, + figmaPageName: nil, + figmaFileId: nil, + rtlProperty: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + XCTAssertEqual(entry.resolvedPenpotBaseURL, "https://my-penpot.example.com/") + } + + func testResolvedPenpotBaseURLNilWithoutPenpotSource() { + let entry = iOS.IconsEntry( + format: .svg, + assetsFolder: "Icons", + preservesVectorRepresentation: nil, + nameStyle: .camelCase, + imageSwift: nil, + swiftUIImageSwift: nil, + codeConnectSwift: nil, + xcassetsPath: nil, + templatesPath: nil, + renderMode: nil, + renderModeDefaultSuffix: nil, + renderModeOriginalSuffix: nil, + renderModeTemplateSuffix: nil, + sourceKind: nil, + penpotSource: nil, + figmaFrameName: nil, + figmaPageName: nil, + figmaFileId: nil, + rtlProperty: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + XCTAssertNil(entry.resolvedPenpotBaseURL) + } +} + +// MARK: - Penpot ColorsSourceInput Validation Tests + +final class PenpotColorsSourceInputTests: XCTestCase { + func testAutoDetectedPenpotProducesPenpotConfig() throws { + let entry = iOS.ColorsEntry( + useColorAssets: false, + assetsFolder: nil, + nameStyle: .camelCase, + groupUsingNamespace: nil, + assetsFolderProvidesNamespace: nil, + colorSwift: nil, + swiftuiColorSwift: nil, + xcassetsPath: nil, + templatesPath: nil, + syncCodeSyntax: nil, + codeSyntaxTemplate: nil, + sourceKind: nil, + penpotSource: Common.PenpotSource( + fileId: "penpot-uuid-123", baseUrl: "https://my-penpot.com/", pathFilter: "Brand" + ), + tokensFile: nil, + tokensFileId: nil, + tokensCollectionName: nil, + lightModeName: nil, + darkModeName: nil, + lightHCModeName: nil, + darkHCModeName: nil, + primitivesModeName: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + let sourceInput = try entry.validatedColorsSourceInput() + XCTAssertEqual(sourceInput.sourceKind, .penpot) + let config = try XCTUnwrap(sourceInput.sourceConfig as? PenpotColorsConfig) + XCTAssertEqual(config.fileId, "penpot-uuid-123") + XCTAssertEqual(config.baseURL, "https://my-penpot.com/") + XCTAssertEqual(config.pathFilter, "Brand") + } + + func testExplicitPenpotWithoutPenpotSourceThrowsMissingPenpotSource() { + let entry = iOS.ColorsEntry( + useColorAssets: false, + assetsFolder: nil, + nameStyle: .camelCase, + groupUsingNamespace: nil, + assetsFolderProvidesNamespace: nil, + colorSwift: nil, + swiftuiColorSwift: nil, + xcassetsPath: nil, + templatesPath: nil, + syncCodeSyntax: nil, + codeSyntaxTemplate: nil, + sourceKind: .penpot, + penpotSource: nil, + tokensFile: nil, + tokensFileId: nil, + tokensCollectionName: nil, + lightModeName: nil, + darkModeName: nil, + lightHCModeName: nil, + darkHCModeName: nil, + primitivesModeName: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + XCTAssertThrowsError(try entry.validatedColorsSourceInput()) { error in + guard let configError = error as? ColorsConfigError else { + XCTFail("Expected ColorsConfigError, got \(error)") + return + } + XCTAssertEqual(configError, .missingPenpotSource) + } + } +} diff --git a/Tests/ExFigTests/Input/VariablesSourceResolvedTests.swift b/Tests/ExFigTests/Input/VariablesSourceResolvedTests.swift new file mode 100644 index 00000000..d567d99a --- /dev/null +++ b/Tests/ExFigTests/Input/VariablesSourceResolvedTests.swift @@ -0,0 +1,101 @@ +import ExFigConfig +import ExFigCore +import Testing + +@Suite("VariablesSource resolvedSourceKind") +struct VariablesSourceResolvedSourceKindTests { + @Test("Defaults to figma when no overrides") + func defaultsFigma() { + let source = Common.VariablesSourceImpl( + sourceKind: nil, + penpotSource: nil, + tokensFile: nil, + tokensFileId: "file-id", + tokensCollectionName: "Collection", + lightModeName: "Light", + darkModeName: nil, + lightHCModeName: nil, + darkHCModeName: nil, + primitivesModeName: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + #expect(source.resolvedSourceKind == .figma) + } + + @Test("Auto-detects penpot from penpotSource") + func autoDetectsPenpot() { + let source = Common.VariablesSourceImpl( + sourceKind: nil, + penpotSource: Common.PenpotSource(fileId: "uuid", baseUrl: "https://design.penpot.app/", pathFilter: nil), + tokensFile: nil, + tokensFileId: nil, + tokensCollectionName: nil, + lightModeName: nil, + darkModeName: nil, + lightHCModeName: nil, + darkHCModeName: nil, + primitivesModeName: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + #expect(source.resolvedSourceKind == .penpot) + } + + @Test("Auto-detects tokensFile when set") + func autoDetectsTokensFile() { + let source = Common.VariablesSourceImpl( + sourceKind: nil, + penpotSource: nil, + tokensFile: Common.TokensFile(path: "./tokens.json", groupFilter: nil), + tokensFileId: nil, + tokensCollectionName: nil, + lightModeName: nil, + darkModeName: nil, + lightHCModeName: nil, + darkHCModeName: nil, + primitivesModeName: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + #expect(source.resolvedSourceKind == .tokensFile) + } + + @Test("Penpot takes priority over tokensFile in auto-detection") + func penpotPriorityOverTokensFile() { + let source = Common.VariablesSourceImpl( + sourceKind: nil, + penpotSource: Common.PenpotSource(fileId: "uuid", baseUrl: "https://design.penpot.app/", pathFilter: nil), + tokensFile: Common.TokensFile(path: "./tokens.json", groupFilter: nil), + tokensFileId: nil, + tokensCollectionName: nil, + lightModeName: nil, + darkModeName: nil, + lightHCModeName: nil, + darkHCModeName: nil, + primitivesModeName: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + #expect(source.resolvedSourceKind == .penpot) + } + + @Test("Explicit sourceKind overrides auto-detection") + func explicitOverridesAutoDetect() { + let source = Common.VariablesSourceImpl( + sourceKind: Common.SourceKind.figma, + penpotSource: Common.PenpotSource(fileId: "uuid", baseUrl: "https://design.penpot.app/", pathFilter: nil), + tokensFile: nil, + tokensFileId: nil, + tokensCollectionName: nil, + lightModeName: nil, + darkModeName: nil, + lightHCModeName: nil, + darkHCModeName: nil, + primitivesModeName: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + #expect(source.resolvedSourceKind == .figma) + } +} diff --git a/Tests/ExFigTests/Source/PenpotSourceTests.swift b/Tests/ExFigTests/Source/PenpotSourceTests.swift new file mode 100644 index 00000000..081236b5 --- /dev/null +++ b/Tests/ExFigTests/Source/PenpotSourceTests.swift @@ -0,0 +1,232 @@ +@testable import ExFigCLI +import ExFigCore +import FigmaAPI +import Logging +import PenpotAPI +import XCTest + +// MARK: - Helpers + +private func dummyClient() -> MockClient { + MockClient() +} + +private func dummyPKLConfig() -> PKLConfig { + // swiftlint:disable:next force_try + try! JSONCodec.decode( + PKLConfig.self, + from: Data(""" + { + "figma": { + "lightFileId": "test-file-id" + } + } + """.utf8) + ) +} + +// MARK: - HexToRGBA Tests + +final class HexToRGBATests: XCTestCase { + func testValidSixDigitHex() throws { + let result = try XCTUnwrap(PenpotColorsSource.hexToRGBA(hex: "#3366FF", opacity: 1.0)) + XCTAssertEqual(result.red, 0x33 / 255.0, accuracy: 0.001) + XCTAssertEqual(result.green, 0x66 / 255.0, accuracy: 0.001) + XCTAssertEqual(result.blue, 0xFF / 255.0, accuracy: 0.001) + XCTAssertEqual(result.alpha, 1.0) + } + + func testValidHexWithoutHashPrefix() throws { + let result = try XCTUnwrap(PenpotColorsSource.hexToRGBA(hex: "FF0000", opacity: 0.5)) + XCTAssertEqual(result.red, 1.0, accuracy: 0.001) + XCTAssertEqual(result.green, 0.0, accuracy: 0.001) + XCTAssertEqual(result.blue, 0.0, accuracy: 0.001) + XCTAssertEqual(result.alpha, 0.5) + } + + func testBlackHex() throws { + let result = try XCTUnwrap(PenpotColorsSource.hexToRGBA(hex: "#000000", opacity: 1.0)) + XCTAssertEqual(result.red, 0.0) + XCTAssertEqual(result.green, 0.0) + XCTAssertEqual(result.blue, 0.0) + } + + func testWhiteHex() throws { + let result = try XCTUnwrap(PenpotColorsSource.hexToRGBA(hex: "#FFFFFF", opacity: 1.0)) + XCTAssertEqual(result.red, 1.0, accuracy: 0.001) + XCTAssertEqual(result.green, 1.0, accuracy: 0.001) + XCTAssertEqual(result.blue, 1.0, accuracy: 0.001) + } + + func testOpacityPassthrough() throws { + let result = try XCTUnwrap(PenpotColorsSource.hexToRGBA(hex: "#000000", opacity: 0.75)) + XCTAssertEqual(result.alpha, 0.75) + } + + func testInvalidHexReturnsNil() { + XCTAssertNil(PenpotColorsSource.hexToRGBA(hex: "banana", opacity: 1.0)) + } + + func testThreeDigitHexReturnsNil() { + XCTAssertNil(PenpotColorsSource.hexToRGBA(hex: "#F00", opacity: 1.0)) + } + + func testEightDigitHexReturnsNil() { + XCTAssertNil(PenpotColorsSource.hexToRGBA(hex: "#3366FFCC", opacity: 1.0)) + } + + func testEmptyStringReturnsNil() { + XCTAssertNil(PenpotColorsSource.hexToRGBA(hex: "", opacity: 1.0)) + } + + func testHexWithWhitespace() throws { + let result = try XCTUnwrap(PenpotColorsSource.hexToRGBA(hex: " #3366FF ", opacity: 1.0)) + XCTAssertEqual(result.red, 0x33 / 255.0, accuracy: 0.001) + } + + func testLowercaseHex() throws { + let result = try XCTUnwrap(PenpotColorsSource.hexToRGBA(hex: "#aabbcc", opacity: 1.0)) + XCTAssertEqual(result.red, 0xAA / 255.0, accuracy: 0.001) + XCTAssertEqual(result.green, 0xBB / 255.0, accuracy: 0.001) + XCTAssertEqual(result.blue, 0xCC / 255.0, accuracy: 0.001) + } +} + +// MARK: - SourceFactory Penpot Dispatch Tests + +final class SourceFactoryPenpotTests: XCTestCase { + override func setUp() { + super.setUp() + // SourceFactory.createComponentsSource/.createTypographySource use ExFigCommand.terminalUI + ExFigCommand.terminalUI = TerminalUI(outputMode: .quiet) + } + + func testCreateColorsSourceForPenpot() throws { + let input = ColorsSourceInput( + sourceKind: .penpot, + sourceConfig: PenpotColorsConfig( + fileId: "uuid", baseURL: "https://penpot.example.com/", pathFilter: nil + ) + ) + let ui = TerminalUI(outputMode: .quiet) + // FigmaAPI.Client is required by the factory signature but not used for Penpot. + // We pass a dummy client — PenpotColorsSource creates its own PenpotClient internally. + let source = try SourceFactory.createColorsSource(for: input, client: dummyClient(), ui: ui, filter: nil) + XCTAssert(source is PenpotColorsSource) + } + + func testCreateComponentsSourceForPenpot() throws { + let ui = TerminalUI(outputMode: .quiet) + let source = try SourceFactory.createComponentsSource( + for: .penpot, + client: dummyClient(), + params: dummyPKLConfig(), + platform: .ios, + logger: .init(label: "test"), + filter: nil, + ui: ui + ) + XCTAssert(source is PenpotComponentsSource) + } + + func testCreateTypographySourceForPenpot() throws { + let ui = TerminalUI(outputMode: .quiet) + let source = try SourceFactory.createTypographySource( + for: .penpot, + client: dummyClient(), + ui: ui + ) + XCTAssert(source is PenpotTypographySource) + } + + func testUnsupportedSourceKindThrowsForColors() { + let input = ColorsSourceInput( + sourceKind: .tokensStudio, + sourceConfig: PenpotColorsConfig(fileId: "x", baseURL: "x") + ) + let ui = TerminalUI(outputMode: .quiet) + XCTAssertThrowsError( + try SourceFactory.createColorsSource(for: input, client: dummyClient(), ui: ui, filter: nil) + ) + } + + func testUnsupportedSourceKindThrowsForComponents() { + let ui = TerminalUI(outputMode: .quiet) + XCTAssertThrowsError( + try SourceFactory.createComponentsSource( + for: .sketchFile, + client: dummyClient(), + params: dummyPKLConfig(), + platform: .ios, + logger: .init(label: "test"), + filter: nil, + ui: ui + ) + ) + } + + func testUnsupportedSourceKindThrowsForTypography() { + let ui = TerminalUI(outputMode: .quiet) + XCTAssertThrowsError( + try SourceFactory.createTypographySource(for: .tokensStudio, client: dummyClient(), ui: ui) + ) + } +} + +// MARK: - PenpotComponentsSource FileId Validation Tests + +final class PenpotComponentsSourceValidationTests: XCTestCase { + func testLoadIconsThrowsWhenFileIdIsNil() async { + let source = PenpotComponentsSource(ui: TerminalUI(outputMode: .quiet)) + let input = IconsSourceInput( + sourceKind: .penpot, + figmaFileId: nil, + frameName: "Icons" + ) + do { + _ = try await source.loadIcons(from: input) + XCTFail("Expected error for nil fileId") + } catch { + XCTAssertTrue( + "\(error)".contains("file ID"), + "Error should mention file ID, got: \(error)" + ) + } + } + + func testLoadIconsThrowsWhenFileIdIsEmpty() async { + let source = PenpotComponentsSource(ui: TerminalUI(outputMode: .quiet)) + let input = IconsSourceInput( + sourceKind: .penpot, + figmaFileId: "", + frameName: "Icons" + ) + do { + _ = try await source.loadIcons(from: input) + XCTFail("Expected error for empty fileId") + } catch { + XCTAssertTrue( + "\(error)".contains("file ID"), + "Error should mention file ID, got: \(error)" + ) + } + } + + func testLoadImagesThrowsWhenFileIdIsNil() async { + let source = PenpotComponentsSource(ui: TerminalUI(outputMode: .quiet)) + let input = ImagesSourceInput( + sourceKind: .penpot, + figmaFileId: nil, + frameName: "Images" + ) + do { + _ = try await source.loadImages(from: input) + XCTFail("Expected error for nil fileId") + } catch { + XCTAssertTrue( + "\(error)".contains("file ID"), + "Error should mention file ID, got: \(error)" + ) + } + } +} diff --git a/Tests/ExFigTests/Subcommands/InitWizardTests.swift b/Tests/ExFigTests/Subcommands/InitWizardTests.swift index 767f2f6f..a0659e5d 100644 --- a/Tests/ExFigTests/Subcommands/InitWizardTests.swift +++ b/Tests/ExFigTests/Subcommands/InitWizardTests.swift @@ -220,6 +220,7 @@ struct InitWizardTests { @Test("applyResult with Flutter and all available types preserves all sections") func flutterAllSelected() { let result = InitWizardResult( + designSource: .figma, platform: .flutter, selectedAssetTypes: [.colors, .icons, .images], lightFileId: "FLUTTER_ID", @@ -228,7 +229,8 @@ struct InitWizardTests { iconsPageName: nil, imagesFrameName: nil, imagesPageName: nil, - variablesConfig: nil + variablesConfig: nil, + penpotBaseURL: nil ) let output = InitWizard.applyResult(result, to: flutterTemplate) #expect(output.contains("FLUTTER_ID")) @@ -283,6 +285,7 @@ struct InitWizardTests { variablesConfig: InitVariablesConfig? = nil ) -> InitWizardResult { InitWizardResult( + designSource: .figma, platform: platform, selectedAssetTypes: selectedAssetTypes, lightFileId: lightFileId, @@ -291,7 +294,8 @@ struct InitWizardTests { iconsPageName: iconsPageName, imagesFrameName: imagesFrameName, imagesPageName: imagesPageName, - variablesConfig: variablesConfig + variablesConfig: variablesConfig, + penpotBaseURL: nil ) } } @@ -303,6 +307,7 @@ struct InitWizardCrossPlatformTests { @Test("applyResult works with Android template") func androidAllSelected() { let result = InitWizardResult( + designSource: .figma, platform: .android, selectedAssetTypes: [.colors, .icons, .images, .typography], lightFileId: "ANDROID_ID", @@ -311,7 +316,8 @@ struct InitWizardCrossPlatformTests { iconsPageName: nil, imagesFrameName: nil, imagesPageName: nil, - variablesConfig: nil + variablesConfig: nil, + penpotBaseURL: nil ) let output = InitWizard.applyResult(result, to: androidConfigFileContents) #expect(output.contains("ANDROID_ID")) @@ -328,6 +334,7 @@ struct InitWizardCrossPlatformTests { @Test("applyResult works with Web template (no typography)") func webAllSelected() { let result = InitWizardResult( + designSource: .figma, platform: .web, selectedAssetTypes: [.colors, .icons, .images], lightFileId: "WEB_ID", @@ -336,7 +343,8 @@ struct InitWizardCrossPlatformTests { iconsPageName: nil, imagesFrameName: nil, imagesPageName: nil, - variablesConfig: nil + variablesConfig: nil, + penpotBaseURL: nil ) let output = InitWizard.applyResult(result, to: webConfigFileContents) #expect(output.contains("WEB_ID")) diff --git a/Tests/ExFigTests/Subcommands/PenpotWizardTests.swift b/Tests/ExFigTests/Subcommands/PenpotWizardTests.swift new file mode 100644 index 00000000..0cb2b74a --- /dev/null +++ b/Tests/ExFigTests/Subcommands/PenpotWizardTests.swift @@ -0,0 +1,185 @@ +@testable import ExFigCLI +import ExFigCore +import Testing + +// MARK: - extractPenpotFileId Tests + +@Suite("extractPenpotFileId") +struct ExtractPenpotFileIdTests { + @Test("Extracts UUID from full workspace URL") + func fullWorkspaceURL() { + let url = "https://design.penpot.app/#/workspace/team-123?file-id=abc-def-123&page-id=page-456" + #expect(extractPenpotFileId(from: url) == "abc-def-123") + } + + @Test("Extracts UUID when file-id is last query param") + func fileIdAtEnd() { + let url = "https://design.penpot.app/#/workspace/team?page-id=p1&file-id=a1b2c3d4-e5f6-7890-abcd-ef1234567890" + #expect(extractPenpotFileId(from: url) == "a1b2c3d4-e5f6-7890-abcd-ef1234567890") + } + + @Test("Returns bare UUID as-is") + func bareUUID() { + let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + #expect(extractPenpotFileId(from: uuid) == uuid) + } + + @Test("Returns input as-is when no file-id param") + func noFileIdParam() { + let url = "https://design.penpot.app/#/workspace/team-123?page-id=page-456" + #expect(extractPenpotFileId(from: url) == url) + } + + @Test("Trims whitespace") + func trimWhitespace() { + let input = " abc-def-123 " + #expect(extractPenpotFileId(from: input) == "abc-def-123") + } + + @Test("Self-hosted URL with valid UUID") + func selfHostedURL() { + let url = "https://penpot.mycompany.com/#/workspace/team?file-id=a1b2c3d4-e5f6-7890-abcd-ef1234567890" + #expect(extractPenpotFileId(from: url) == "a1b2c3d4-e5f6-7890-abcd-ef1234567890") + } +} + +// MARK: - applyPenpotResult Tests + +@Suite("InitWizard applyPenpotResult") +struct ApplyPenpotResultTests { + @Test("Removes Figma import and config section") + func removesFigmaSection() { + let result = makePenpotResult() + let output = InitWizard.applyPenpotResult(result, to: iosConfigFileContents) + #expect(!output.contains("import \".exfig/schemas/Figma.pkl\"")) + #expect(!output.contains("figma = new Figma.FigmaConfig {")) + } + + @Test("Inserts penpotSource block into platform entries") + func insertsPenpotSource() { + let result = makePenpotResult() + let output = InitWizard.applyPenpotResult(result, to: iosConfigFileContents) + #expect(output.contains("penpotSource = new Common.PenpotSource {")) + #expect(output.contains("fileId = \"PENPOT_FILE_UUID\"")) + } + + @Test("Includes custom base URL when provided") + func customBaseURL() { + let result = makePenpotResult(penpotBaseURL: "https://penpot.mycompany.com/") + let output = InitWizard.applyPenpotResult(result, to: iosConfigFileContents) + #expect(output.contains("baseUrl = \"https://penpot.mycompany.com/\"")) + } + + @Test("Omits base URL line when nil") + func noBaseURL() { + let result = makePenpotResult(penpotBaseURL: nil) + let output = InitWizard.applyPenpotResult(result, to: iosConfigFileContents) + #expect(!output.contains("baseUrl")) + } + + @Test("Removes unselected asset types") + func removesUnselected() { + let result = makePenpotResult(selectedAssetTypes: [.colors]) + let output = InitWizard.applyPenpotResult(result, to: iosConfigFileContents) + #expect(!output.contains("icons = new")) + #expect(!output.contains("images = new")) + #expect(!output.contains("typography = new")) + } + + @Test("Removes common colors/icons/images/typography sections") + func removesCommonSections() { + let result = makePenpotResult() + let output = InitWizard.applyPenpotResult(result, to: iosConfigFileContents) + #expect(!output.contains("colors = new Common.Colors {")) + #expect(!output.contains("icons = new Common.Icons {")) + #expect(!output.contains("images = new Common.Images {")) + #expect(!output.contains("typography = new Common.Typography {")) + } + + @Test("Output has balanced braces") + func balancedBraces() { + let result = makePenpotResult() + let output = InitWizard.applyPenpotResult(result, to: iosConfigFileContents) + let openCount = output.filter { $0 == "{" }.count + let closeCount = output.filter { $0 == "}" }.count + #expect(openCount == closeCount, "Unbalanced braces: \(openCount) open vs \(closeCount) close") + } + + @Test("Includes penpotSource in icons platform entry") + func iconsPlatformEntryHasPenpotSource() { + let result = makePenpotResult(iconsFrameName: "Icons/Navigation") + let output = InitWizard.applyPenpotResult(result, to: iosConfigFileContents) + // penpotSource block should be inserted into the icons platform entry + #expect(output.contains("penpotSource = new Common.PenpotSource {")) + #expect(output.contains("fileId = \"PENPOT_FILE_UUID\"")) + } + + @Test("Includes penpotSource in images platform entry") + func imagesPlatformEntryHasPenpotSource() { + let result = makePenpotResult(imagesFrameName: "Images/Hero") + let output = InitWizard.applyPenpotResult(result, to: iosConfigFileContents) + #expect(output.contains("penpotSource = new Common.PenpotSource {")) + } + + @Test("Works with Android template") + func androidTemplate() { + let result = makePenpotResult(platform: .android) + let output = InitWizard.applyPenpotResult(result, to: androidConfigFileContents) + #expect(output.contains("penpotSource = new Common.PenpotSource {")) + #expect(!output.contains("figma = new Figma.FigmaConfig {")) + let openCount = output.filter { $0 == "{" }.count + let closeCount = output.filter { $0 == "}" }.count + #expect(openCount == closeCount, "Unbalanced braces: \(openCount) vs \(closeCount)") + } + + @Test("Works with Flutter template") + func flutterTemplate() { + let result = makePenpotResult( + platform: .flutter, + selectedAssetTypes: [.colors, .icons, .images] + ) + let output = InitWizard.applyPenpotResult(result, to: flutterConfigFileContents) + #expect(output.contains("penpotSource = new Common.PenpotSource {")) + let openCount = output.filter { $0 == "{" }.count + let closeCount = output.filter { $0 == "}" }.count + #expect(openCount == closeCount, "Unbalanced braces: \(openCount) vs \(closeCount)") + } + + @Test("Works with Web template") + func webTemplate() { + let result = makePenpotResult( + platform: .web, + selectedAssetTypes: [.colors, .icons, .images] + ) + let output = InitWizard.applyPenpotResult(result, to: webConfigFileContents) + #expect(output.contains("penpotSource = new Common.PenpotSource {")) + let openCount = output.filter { $0 == "{" }.count + let closeCount = output.filter { $0 == "}" }.count + #expect(openCount == closeCount, "Unbalanced braces: \(openCount) vs \(closeCount)") + } + + // MARK: - Helpers + + private func makePenpotResult( + platform: Platform = .ios, + selectedAssetTypes: [InitAssetType] = [.colors, .icons, .images, .typography], + lightFileId: String = "PENPOT_FILE_UUID", + iconsFrameName: String? = nil, + imagesFrameName: String? = nil, + penpotBaseURL: String? = nil + ) -> InitWizardResult { + InitWizardResult( + designSource: .penpot, + platform: platform, + selectedAssetTypes: selectedAssetTypes, + lightFileId: lightFileId, + darkFileId: nil, + iconsFrameName: iconsFrameName, + iconsPageName: nil, + imagesFrameName: imagesFrameName, + imagesPageName: nil, + variablesConfig: nil, + penpotBaseURL: penpotBaseURL + ) + } +} diff --git a/Tests/ExFigTests/TerminalUI/ExFigErrorFormatterTests.swift b/Tests/ExFigTests/TerminalUI/ExFigErrorFormatterTests.swift index 3a4806e9..8a42d813 100644 --- a/Tests/ExFigTests/TerminalUI/ExFigErrorFormatterTests.swift +++ b/Tests/ExFigTests/TerminalUI/ExFigErrorFormatterTests.swift @@ -53,7 +53,7 @@ final class ExFigErrorFormatterTests: XCTestCase { let result = formatter.format(error) - XCTAssertTrue(result.contains("FIGMA_PERSONAL_TOKEN not set")) + XCTAssertTrue(result.contains("FIGMA_PERSONAL_TOKEN is required")) XCTAssertTrue(result.contains("→")) XCTAssertTrue(result.contains("export FIGMA_PERSONAL_TOKEN")) } diff --git a/hk.pkl b/hk.pkl index 981821a8..c2cf3946 100644 --- a/hk.pkl +++ b/hk.pkl @@ -125,7 +125,7 @@ hooks { // Using patch-file stash mode to properly handle untracked files ["pre-commit"] { fix = true - stash = "patch-file" + stash = "none" steps = all_linters } diff --git a/llms-full.txt b/llms-full.txt index f5c76c26..254b8daa 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -12,11 +12,12 @@ # ExFig -Export colors, typography, icons, and images from Figma to Xcode, Android Studio, Flutter, and Web projects — automatically. +Export colors, typography, icons, and images from Figma and Penpot to Xcode, Android Studio, Flutter, and Web projects — automatically. ## The Problem - Figma has no "Export to Xcode" button. You copy hex codes by hand, one by one. +- Switching from Figma to Penpot? Your export pipeline shouldn't break. - Every color change means updating files across 3 platforms manually. - Dark mode variant? An afternoon spent on light/dark pairs and @1x/@2x/@3x PNGs. - Android gets XML. iOS gets xcassets. Flutter gets Dart. Someone maintains all three. @@ -30,7 +31,7 @@ Export colors, typography, icons, and images from Figma to Xcode, Android Studio **Flutter developer** — You need dark mode icon variants and `@2x`/`@3x` image scales. ExFig exports SVG icons with dark suffixes, raster images with scale directories, and Dart constants. -**Design Systems lead** — One Figma file feeds four platforms. ExFig's unified PKL config exports everything from a single `exfig batch` run. One CI pipeline, one source of truth. +**Design Systems lead** — One Figma or Penpot file feeds four platforms. ExFig's unified PKL config exports everything from a single `exfig batch` run. One CI pipeline, one source of truth. **CI/CD engineer** — Quiet mode, JSON reports, exit codes, version tracking, and checkpoint/resume. The [GitHub Action](https://github.com/DesignPipe/exfig-action) handles installation and caching. @@ -40,7 +41,7 @@ Export colors, typography, icons, and images from Figma to Xcode, Android Studio # 1. Install brew install designpipe/tap/exfig -# 2. Set Figma token +# 2. Set Figma token (or PENPOT_ACCESS_TOKEN for Penpot) export FIGMA_PERSONAL_TOKEN=your_token_here # 3a. Quick one-off export (interactive wizard) @@ -99,13 +100,13 @@ Install ExFig and configure your first export. ## Overview -ExFig is a command-line tool that exports design resources from Figma to iOS, Android, and Flutter projects. +ExFig is a command-line tool that exports design resources from Figma and Penpot to iOS, Android, Flutter, and Web projects. ## Requirements - macOS 13.0 or later (or Linux Ubuntu 22.04) -- Figma account with file access -- Figma Personal Access Token +- Figma account with file access, **or** Penpot account +- Figma Personal Access Token (for Figma sources) or Penpot Access Token (for Penpot sources) ## Installation @@ -140,7 +141,9 @@ cp .build/release/exfig /usr/local/bin/ Download the latest release from [GitHub Releases](https://github.com/DesignPipe/exfig/releases). -## Figma Access Token +## Authentication + +### Figma Access Token ExFig requires a Figma Personal Access Token to access the Figma API. @@ -167,6 +170,34 @@ Or pass it directly to commands: FIGMA_PERSONAL_TOKEN="your-token" exfig colors ``` +### Penpot Access Token + +For Penpot sources, set the `PENPOT_ACCESS_TOKEN` environment variable: + +1. Open your Penpot instance → Settings → Access Tokens +2. Create a new token +3. Set it: + +```bash +export PENPOT_ACCESS_TOKEN="your-penpot-token-here" +``` + +> Note: `PENPOT_ACCESS_TOKEN` is only required when using `penpotSource` in config. + +### Quick Penpot Icons Export (No Config) + +```bash +# Export Penpot icons as SVG +export PENPOT_ACCESS_TOKEN="your-token" +exfig fetch --source penpot -f "file-uuid" -r "Icons" -o ./icons --format svg + +# Export as PNG at 3x scale +exfig fetch --source penpot -f "file-uuid" -r "Icons" -o ./icons --format png --scale 3 +``` + +> File UUID is in the Penpot workspace URL: `?file-id=UUID`. +> For shared libraries, use the library's file ID from the Assets panel. + ## Quick Start ### 1. Initialize Configuration @@ -250,7 +281,7 @@ Command-line interface reference and common usage patterns. ## Overview -ExFig provides commands for exporting colors, icons, images, and typography from Figma to native platform resources. +ExFig provides commands for exporting colors, icons, images, and typography from Figma and Penpot to native platform resources. ## Basic Commands @@ -440,6 +471,26 @@ exfig fetch -f abc123 -r "Images" -o ./images --format webp --webp-quality 90 exfig fetch -f abc123 -r "Images" -o ./images --format webp --webp-encoding lossless ``` +### Penpot Fetch + +```bash +# Fetch icons from Penpot as SVG +exfig fetch --source penpot -f "a1b2c3d4-..." -r "Icons / App" -o ./icons --format svg + +# Fetch as PNG at 3x scale (SVG reconstructed, then rasterized via resvg) +exfig fetch --source penpot -f "a1b2c3d4-..." -r "Icons" -o ./icons --format png --scale 3 + +# From a shared library (use library file ID) +exfig fetch --source penpot -f "library-uuid" -r "Icons / Actions" -o ./icons --format svg + +# Self-hosted Penpot instance +exfig fetch --source penpot --penpot-base-url https://penpot.mycompany.com/ \ + -f "uuid" -r "Icons" -o ./icons --format svg +``` + +> Set `PENPOT_ACCESS_TOKEN` environment variable (generate at Settings → Access Tokens). +> File ID is in the Penpot workspace URL: `?file-id=UUID`. + ### Scale Options ```bash @@ -608,6 +659,67 @@ common = new Common.CommonConfig { } ``` +### Penpot Source + +Use a Penpot project instead of Figma as the design source. For file preparation guidelines, +see DesignRequirements. + +**Colors:** + +```pkl +import ".exfig/schemas/Common.pkl" +import ".exfig/schemas/iOS.pkl" + +ios = new iOS.iOSConfig { + colors = new iOS.ColorsEntry { + penpotSource = new Common.PenpotSource { + fileId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + // baseUrl = "https://penpot.mycompany.com/" // optional: self-hosted + pathFilter = "Brand" // optional: filter by path prefix + } + assetsFolder = "Colors" + nameStyle = "camelCase" + } +} +``` + +**Icons:** + +```pkl +ios = new iOS.iOSConfig { + icons = new Listing { + new iOS.IconsEntry { + penpotSource = new Common.PenpotSource { + fileId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + // pathFilter = "Icons / Actions" // optional: filter by path prefix + } + figmaFrameName = "Icons" // path prefix filter (same field as Figma) + format = "svg" // svg or pdf — SVG reconstructed from shape tree + assetsFolder = "Icons" + nameStyle = "camelCase" + } + } +} +``` + +**Typography:** + +```pkl +ios = new iOS.iOSConfig { + typography = new iOS.TypographyEntry { + penpotSource = new Common.PenpotSource { + fileId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + } + } +} +``` + +> When `penpotSource` is set, `sourceKind` auto-detects as `"penpot"`. ExFig reads from +> the Penpot API and does not require `FIGMA_PERSONAL_TOKEN`. Set `PENPOT_ACCESS_TOKEN` instead. +> +> Icons and images are exported via **SVG reconstruction** from Penpot's shape tree — +> no headless Chrome needed. Supported formats: SVG, PNG (any scale), PDF, WebP. + ### Tokens File Source Use a local W3C DTCG `.tokens.json` file instead of the Figma Variables API: @@ -1049,16 +1161,19 @@ flutter = new Flutter.FlutterConfig { ### Design Requirements -# Design Requirements +# Design File Structure -How to structure your Figma files for optimal export with ExFig. +How to structure your design files for optimal export with ExFig. ## Overview -ExFig extracts design resources from Figma files based on specific naming conventions and organizational structures. -This guide explains how to set up your Figma files for seamless export. +ExFig extracts design resources from **Figma** files and **Penpot** projects based on naming conventions +and organizational structures. This guide explains how to set up your design files for seamless export. + +- **Figma**: Uses frames, components, color styles, and Variables +- **Penpot**: Uses shared library colors, components, and typographies -## General Principles +## Figma ### Frame Organization @@ -1097,9 +1212,9 @@ common = new Common.CommonConfig { } ``` -## Colors +### Colors -### Using Color Styles +#### Using Color Styles Create color styles in Figma with descriptive names: @@ -1114,7 +1229,7 @@ Colors frame └── border/default ``` -### Using Figma Variables +#### Using Figma Variables For Figma Variables API support: @@ -1145,15 +1260,15 @@ Colors collection └── text: #FFFFFF ``` -### Naming Guidelines +#### Naming Guidelines - Use lowercase with optional separators: `/`, `-`, `_` - Group related colors with prefixes: `text/primary`, `background/card` - Avoid special characters except separators -## Icons +### Icons -### Component Structure +#### Component Structure Icons must be **components** (not plain frames): @@ -1166,7 +1281,7 @@ Icons frame └── ic/32/menu (component) ``` -### Size Conventions +#### Size Conventions Organize icons by size: @@ -1178,7 +1293,7 @@ Icons frame └── ic/48/... (48pt icons) ``` -### Vector Requirements +#### Vector Requirements For optimal vector export: @@ -1187,7 +1302,7 @@ For optimal vector export: 3. **Remove hidden layers**: Delete unused or hidden elements 4. **Use consistent viewBox**: Keep viewBox dimensions consistent within size groups -### Dark Mode Icons +#### Dark Mode Icons Two approaches for dark mode support: @@ -1225,9 +1340,9 @@ Icons frame └── ic/24/close_dark ``` -## Images +### Images -### Component Structure +#### Component Structure Images must be **components**: @@ -1239,7 +1354,7 @@ Illustrations frame └── img-hero-banner (component) ``` -### Size Recommendations +#### Size Recommendations Design at the largest needed scale: @@ -1247,7 +1362,7 @@ Design at the largest needed scale: - **Android**: Design at xxxhdpi (4x), ExFig generates all densities - **Flutter**: Design at 3x, ExFig generates 1x, 2x, 3x -### Multi-Idiom Support (iOS) +#### Multi-Idiom Support (iOS) Use suffixes for device-specific variants: @@ -1259,7 +1374,7 @@ Illustrations frame └── img-sidebar~ipad ``` -### Dark Mode Images +#### Dark Mode Images Same approaches as icons: @@ -1273,9 +1388,9 @@ Illustrations frame └── img-hero_dark ``` -## Typography +### Typography -### Text Style Structure +#### Text Style Structure Create text styles with hierarchical names: @@ -1290,7 +1405,7 @@ Typography frame └── caption/small ``` -### Required Properties +#### Required Properties Each text style should define: @@ -1300,7 +1415,7 @@ Each text style should define: - **Line height**: in pixels or percentage - **Letter spacing**: in pixels or percentage -### Font Mapping +#### Font Mapping Map Figma fonts to platform fonts in your config: @@ -1321,9 +1436,9 @@ android = new Android.AndroidConfig { } ``` -## Validation Regex Patterns +### Validation Regex Patterns -### Common Patterns +#### Common Patterns ```pkl import ".exfig/schemas/Common.pkl" @@ -1346,7 +1461,7 @@ common = new Common.CommonConfig { } ``` -### Transform Patterns +#### Transform Patterns Transform names during export: @@ -1361,8 +1476,6 @@ common = new Common.CommonConfig { } ``` -## File Organization Tips - ### Recommended Figma Structure ``` @@ -1395,27 +1508,180 @@ For complex theming, use separate files: Ensure component names match exactly between files. -## Troubleshooting +### Figma Troubleshooting -### Resources Not Found +#### Resources Not Found - Verify frame names match `figmaFrameName` in config - Check that resources are **components**, not plain frames - Ensure names pass validation regex -### Missing Dark Mode +#### Missing Dark Mode - Verify `darkFileId` is set correctly - Check component names match between light and dark files - For single-file mode, verify suffix is correct -### Export Quality Issues +#### Export Quality Issues - Design at highest needed resolution - Use vector graphics when possible - Avoid raster effects in vector icons - Flatten complex boolean operations +## Penpot + +ExFig reads Penpot library assets — colors, components, and typographies — from the shared library +of a Penpot file. All assets must be added to the **shared library** (Assets panel), not just placed +on the canvas. + +### Authentication + +Set the `PENPOT_ACCESS_TOKEN` environment variable: + +1. Open Penpot → Settings → Access Tokens +2. Create a new token (no expiration recommended for CI) +3. Export: + +```bash +export PENPOT_ACCESS_TOKEN="your-token-here" +``` + +No `FIGMA_PERSONAL_TOKEN` needed when using only Penpot sources. + +### Library Colors + +Colors must be in the shared **Library** (Assets panel → Local library → Colors): + +``` +Library Colors +├── Brand/Primary (#3B82F6) +├── Brand/Secondary (#8B5CF6) +├── Semantic/Success (#22C55E) +├── Semantic/Warning (#F59E0B) +├── Semantic/Error (#EF4444) +├── Neutral/Background (#1E1E2E) +├── Neutral/Text (#F8F8F2) +└── Neutral/Overlay (#000000, 50% opacity) +``` + +Key points: + +- Only **solid hex colors** are exported. Gradients and image fills are skipped in v1. +- The `path` field organizes colors into groups: `path: "Brand"`, `name: "Primary"` → `Brand/Primary` +- Use `pathFilter` in your config to select a specific group: `pathFilter = "Brand"` exports only Brand colors +- **Opacity** is preserved (0.0–1.0) + +Config example: + +```pkl +penpotSource = new Common.PenpotSource { + fileId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + pathFilter = "Brand" // optional: export only Brand/* colors +} +``` + +### Library Components (Icons and Images) + +Components must be in the shared **Library** (Assets panel → Local library → Components). +ExFig filters by the component `path` prefix (equivalent to Figma's frame name): + +``` +Library Components +├── Icons/Navigation/arrow-left +├── Icons/Navigation/arrow-right +├── Icons/Actions/close +├── Icons/Actions/check +├── Illustrations/Empty States/no-data +└── Illustrations/Onboarding/welcome +``` + +Config example: + +```pkl +penpotSource = new Common.PenpotSource { + fileId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" +} +// Use path prefix as the frame filter +figmaFrameName = "Icons/Navigation" // exports arrow-left, arrow-right +``` + +ExFig reconstructs SVG directly from Penpot's shape tree — no headless Chrome or CDN needed. +Supported output formats: **SVG** (native vector), **PNG** (via resvg at any scale), **PDF**, **WebP**. + +### Library Typography + +Typography styles must be in the shared **Library** (Assets panel → Local library → Typography): + +``` +Library Typography +├── Heading/H1 (Roboto Bold 32px) +├── Heading/H2 (Roboto Bold 24px) +├── Body/Regular (Roboto Regular 16px) +├── Body/Bold (Roboto Bold 16px) +└── Caption/Small (Roboto Regular 12px) +``` + +Required fields: + +- **fontFamily** — e.g., "Roboto", "DM Mono" +- **fontSize** — must be set (styles without a parseable font size are skipped) + +Supported fields: `fontWeight`, `lineHeight`, `letterSpacing`, `textTransform` (uppercase/lowercase). + +> Penpot may serialize numeric fields as strings (e.g., `"24"` instead of `24`). ExFig handles both formats automatically. + +### Recommended Penpot Structure + +``` +Design System (Penpot file) +├── Library Colors +│ ├── Brand/* (primary, secondary, accent) +│ ├── Semantic/* (success, warning, error, info) +│ └── Neutral/* (background, text, border, overlay) +├── Library Components +│ ├── Icons/Navigation/* (arrow, chevron, menu) +│ ├── Icons/Actions/* (close, check, edit, delete) +│ └── Illustrations/* (empty states, onboarding) +└── Library Typography + ├── Heading/* (H1, H2, H3) + ├── Body/* (regular, bold, italic) + └── Caption/* (regular, small) +``` + +### Known Limitations + +- **No dark mode support** — Penpot has no Variables/modes equivalent; colors export as light-only +- **No `exfig_inspect` for Penpot** — the MCP inspect tool works with Figma API only +- **Gradients skipped** — only solid hex colors are supported +- **No page filtering** — all library assets are global to the file, not page-scoped +- **SVG reconstruction scope** — supports path, rect, circle, bool, group shapes; complex effects (blur, shadow, gradients on shapes) are not yet rendered + +### Penpot Troubleshooting + +#### No Colors Exported + +- Verify colors are in the **shared library**, not just swatches on the canvas +- Check `pathFilter` — a too-specific prefix returns no results +- Gradient colors are skipped; use solid fills + +#### No Components Exported + +- Verify components are in the **shared library** (right-click shape → "Create component") +- Check the path prefix in `figmaFrameName` matches the component `path` +- Thumbnails may not be generated for programmatically created components + +#### Typography Styles Skipped + +- Ensure `fontSize` is set on the typography style +- Styles with unparseable font size values are silently skipped + +#### Authentication Errors + +- `PENPOT_ACCESS_TOKEN environment variable is required` — set the token +- Penpot 401 — token expired or invalid; regenerate in Settings → Access Tokens +- Self-hosted instances: set `baseUrl` in `penpotSource` + ## See Also - Configuration diff --git a/openspec/changes/add-penpot-support/tasks.md b/openspec/changes/add-penpot-support/tasks.md index 8d599ac7..8dd58f0b 100644 --- a/openspec/changes/add-penpot-support/tasks.md +++ b/openspec/changes/add-penpot-support/tasks.md @@ -1,57 +1,57 @@ ## 1. PenpotAPI Module — Package Setup -- [ ] 1.1 Add `PenpotAPI` target and `PenpotAPITests` test target to `Package.swift`; add `"PenpotAPI"` to ExFigCLI dependencies -- [ ] 1.2 Create `Sources/PenpotAPI/CLAUDE.md` with module overview +- [x] 1.1 Add `PenpotAPI` target and `PenpotAPITests` test target to `Package.swift`; add `"PenpotAPI"` to ExFigCLI dependencies +- [x] 1.2 Create `Sources/PenpotAPI/CLAUDE.md` with module overview ## 2. PenpotAPI Module — Client -- [ ] 2.1 Define `PenpotEndpoint` protocol and `PenpotClient` protocol in `Sources/PenpotAPI/Client/` -- [ ] 2.2 Implement `BasePenpotClient` (URLSession, auth header, base URL, retry logic) -- [ ] 2.3 Implement `PenpotAPIError` with LocalizedError conformance and recovery suggestions +- [x] 2.1 Define `PenpotEndpoint` protocol and `PenpotClient` protocol in `Sources/PenpotAPI/Client/` +- [x] 2.2 Implement `BasePenpotClient` (URLSession, auth header, base URL, retry logic) +- [x] 2.3 Implement `PenpotAPIError` with LocalizedError conformance and recovery suggestions ## 3. PenpotAPI Module — Endpoints -- [ ] 3.1 Implement `GetFileEndpoint` (command: `get-file`, body: `{id}`, response: `PenpotFileResponse`) -- [ ] 3.2 Implement `GetProfileEndpoint` (command: `get-profile`, no body, response: `PenpotProfile`) -- [ ] 3.3 Implement `GetFileObjectThumbnailsEndpoint` (command: `get-file-object-thumbnails`, response: thumbnail map) -- [ ] 3.4 Implement asset download method (`GET /assets/by-file-media-id/`) +- [x] 3.1 Implement `GetFileEndpoint` (command: `get-file`, body: `{id}`, response: `PenpotFileResponse`) +- [x] 3.2 Implement `GetProfileEndpoint` (command: `get-profile`, no body, response: `PenpotProfile`) +- [x] 3.3 Implement `GetFileObjectThumbnailsEndpoint` (command: `get-file-object-thumbnails`, response: thumbnail map) +- [x] 3.4 Implement asset download method (`GET /assets/by-file-media-id/`) ## 4. PenpotAPI Module — Models -- [ ] 4.1 Define `PenpotFileResponse` and `PenpotFileData` with selective decoding (colors, typographies, components) -- [ ] 4.2 Define `PenpotColor` (id, name, path, color hex, opacity) — standard Codable, no CodingKeys (JSON uses camelCase) -- [ ] 4.3 Define `PenpotComponent` (id, name, path, mainInstanceId, mainInstancePage) — standard Codable, no CodingKeys -- [ ] 4.4 Define `PenpotTypography` with dual String/Double decoding via custom `init(from:)` — handles both `"24"` and `24` -- [ ] 4.5 Define `PenpotProfile` (id, fullname, email) +- [x] 4.1 Define `PenpotFileResponse` and `PenpotFileData` with selective decoding (colors, typographies, components) +- [x] 4.2 Define `PenpotColor` (id, name, path, color hex, opacity) — standard Codable, no CodingKeys (JSON uses camelCase) +- [x] 4.3 Define `PenpotComponent` (id, name, path, mainInstanceId, mainInstancePage) — standard Codable, no CodingKeys +- [x] 4.4 Define `PenpotTypography` with dual String/Double decoding via custom `init(from:)` — handles both `"24"` and `24` +- [x] 4.5 Define `PenpotProfile` (id, fullname, email) ## 5. PenpotAPI Module — Unit Tests -- [ ] 5.1 Create JSON fixtures in `Tests/PenpotAPITests/Fixtures/` (file response, colors, components, typographies) -- [ ] 5.2 Write `PenpotColorDecodingTests` — solid, gradient (nil hex), path grouping -- [ ] 5.3 Write `PenpotTypographyDecodingTests` — string→Double, number→Double, unparseable values, camelCase keys -- [ ] 5.4 Write `PenpotComponentDecodingTests` — camelCase keys, optional fields -- [ ] 5.5 Write `PenpotEndpointTests` — URL construction (`/api/main/methods/`), body serialization for RPC endpoints -- [ ] 5.6 Write `PenpotAPIErrorTests` — recovery suggestions for 401, 404, 429 +- [x] 5.1 Create JSON fixtures in `Tests/PenpotAPITests/Fixtures/` (file response, colors, components, typographies) +- [x] 5.2 Write `PenpotColorDecodingTests` — solid, gradient (nil hex), path grouping +- [x] 5.3 Write `PenpotTypographyDecodingTests` — string→Double, number→Double, unparseable values, camelCase keys +- [x] 5.4 Write `PenpotComponentDecodingTests` — camelCase keys, optional fields +- [x] 5.5 Write `PenpotEndpointTests` — URL construction (`/api/main/methods/`), body serialization for RPC endpoints +- [x] 5.6 Write `PenpotAPIErrorTests` — recovery suggestions for 401, 404, 429 ## 6. ExFigCore — Config Types -- [ ] 6.1 Add `PenpotColorsConfig: ColorsSourceConfig` to `DesignSource.swift` (fileId, baseURL, pathFilter) -- [ ] 6.2 Update `ColorsSourceInput.spinnerLabel` in `ExportContext.swift` for `.penpot` case +- [x] 6.1 Add `PenpotColorsConfig: ColorsSourceConfig` to `DesignSource.swift` (fileId, baseURL, pathFilter) +- [x] 6.2 Update `ColorsSourceInput.spinnerLabel` in `ExportContext.swift` for `.penpot` case ## 7. Integration Sources -- [ ] 7.1 Implement `PenpotColorsSource` in `ExFigCLI/Source/` — hex→RGBA, path filter, light-only output -- [ ] 7.2 Implement `PenpotComponentsSource` in `ExFigCLI/Source/` — component filter, thumbnails, SVG warning -- [ ] 7.3 Implement `PenpotTypographySource` in `ExFigCLI/Source/` — string→Double, textCase mapping -- [ ] 7.4 Update `SourceFactory.swift` — replace `throw unsupportedSourceKind(.penpot)` with real Penpot sources +- [x] 7.1 Implement `PenpotColorsSource` in `ExFigCLI/Source/` — hex→RGBA, path filter, light-only output +- [x] 7.2 Implement `PenpotComponentsSource` in `ExFigCLI/Source/` — component filter, thumbnails, SVG warning +- [x] 7.3 Implement `PenpotTypographySource` in `ExFigCLI/Source/` — string→Double, textCase mapping +- [x] 7.4 Update `SourceFactory.swift` — replace `throw unsupportedSourceKind(.penpot)` with real Penpot sources ## 8. PKL Schema + Codegen -- [ ] 8.1 Add `PenpotSource` class to `Common.pkl` (fileId, baseUrl, pathFilter) -- [ ] 8.2 Add `penpotSource: PenpotSource?` to `VariablesSource` and `FrameSource` in `Common.pkl` -- [ ] 8.3 Add sourceKind auto-detection logic in PKL (penpotSource → "penpot") -- [ ] 8.4 Run `./bin/mise run codegen:pkl` and verify generated types -- [ ] 8.5 Update entry bridge methods in `Sources/ExFig-*/Config/*Entry.swift` — map `penpotSource` → `PenpotColorsConfig` / SourceInput fields +- [x] 8.1 Add `PenpotSource` class to `Common.pkl` (fileId, baseUrl, pathFilter) +- [x] 8.2 Add `penpotSource: PenpotSource?` to `VariablesSource` and `FrameSource` in `Common.pkl` +- [x] 8.3 Add sourceKind auto-detection logic in PKL (penpotSource → "penpot") +- [x] 8.4 Run `./bin/mise run codegen:pkl` and verify generated types +- [x] 8.5 Update entry bridge methods in `Sources/ExFig-*/Config/*Entry.swift` — map `penpotSource` → `PenpotColorsConfig` / SourceInput fields ## 9. E2E Tests @@ -62,8 +62,8 @@ ## 10. Verification -- [ ] 10.1 `./bin/mise run build` — all modules compile -- [ ] 10.2 `./bin/mise run test` — unit tests pass -- [ ] 10.3 `./bin/mise run lint` — no SwiftLint violations -- [ ] 10.4 `./bin/mise run format-check` — formatting correct +- [x] 10.1 `./bin/mise run build` — all modules compile +- [x] 10.2 `./bin/mise run test` — unit tests pass +- [x] 10.3 `./bin/mise run lint` — no SwiftLint violations +- [x] 10.4 `./bin/mise run format-check` — formatting correct - [ ] 10.5 E2E tests pass with `PENPOT_ACCESS_TOKEN` and `PENPOT_TEST_FILE_ID`