diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c6559b2f..df6677e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -319,4 +319,3 @@ jobs: git add Formula/exfig.rb git diff --staged --quiet || git commit -m "chore: bump to v${{ steps.version.outputs.version }}" git push - diff --git a/CLAUDE.md b/CLAUDE.md index 252e391b..8e78462c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,6 +122,9 @@ Twelve modules in `Sources/`: **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 **Claude Code plugins:** [exfig-plugins](https://github.com/DesignPipe/exfig-plugins) marketplace — MCP integration, setup wizard, export commands, config review, troubleshooting +**Plugin sync checklist:** When adding features visible to end users (new dark mode approach, new CLI flag, new MCP tool), update DesignPipe/exfig-plugins skills: `exfig-mcp-usage`, `exfig-config-review` (common-issues.md), `exfig-troubleshooting` (error-catalog.md), `exfig-setup`. Clone to `/tmp/exfig-plugins`, branch, commit, PR. + +**Variable-mode dark icons:** `FigmaComponentsSource.loadIcons()` → `VariableModeDarkGenerator` — fetches Variables API, resolves alias chains, replaces hex colors in SVG via `SVGColorReplacer`. Third dark mode approach alongside `darkFileId` and `suffixDarkMode`. **Batch mode:** Single `@TaskLocal` via `BatchSharedState` actor — see `ExFigCLI/CLAUDE.md`. @@ -229,6 +232,41 @@ When relocating a type (e.g., `Android.WebpOptions` → `Common.WebpOptions`), u 5. PKL examples (`Schemas/examples/*.pkl`) 6. DocC docs (`ExFig.docc/**/*.md`), CONFIG.md +### Variable-Mode Dark Icons (VariableModeDarkGenerator) + +Three dark mode approaches for icons (mutually exclusive): + +1. `darkFileId` — separate Figma file for dark icons (global `figma` section) +2. `suffixDarkMode` — `Common.SuffixDarkMode` on `Common.Icons`/`Images`/`Colors`, splits by name suffix +3. `variablesDarkMode` — `Common.VariablesDarkMode` on `FrameSource` (per-entry), resolves Figma Variable bindings + +Approach 3 is configured via nested `variablesDarkMode: VariablesDarkMode?` on `FrameSource`. +Integration point: `FigmaComponentsSource.loadIcons()`. + +**Algorithm:** fetch `VariablesMeta` → fetch icon nodes → walk children tree to find `Paint.boundVariables["color"]` +→ resolve dark value via alias chain (depth limit 10, same pattern as `ColorsVariablesLoader.handleColorMode()`) +→ build `lightHex → darkHex` map → `SVGColorReplacer.replaceColors()` → write dark SVG to temp file. + +Key files: `VariableModeDarkGenerator.swift`, `SVGColorReplacer.swift`, `FigmaComponentsSource.swift`. + +**Logging requirements:** Every `guard ... else { continue }` in the generation loop must log a warning — silent skips cause invisible data loss. `resolveDarkColor` must check `deletedButReferenced != true` (same as all other variable loaders). `SVGColorReplacer` uses separate regex replacement templates per pattern (attribute patterns have 3 capture groups, CSS patterns have 2 — never share a single template). + +**Config validation:** `Config.init` uses `precondition(!fileId.isEmpty)` — catches the documented empty-fileId bug at construction time instead of relying on call-site guards. + +**Alias resolution behaviour:** `resolveDarkColor` alias targets resolve using the target collection's `defaultModeId`, NOT the requested modeId — test expectations must account for this (e.g., alias to primitive resolves via "light" default mode, not "dark"). + +**pkl-swift decoding:** pkl-swift uses **keyed** decoding (by property name, not positional). TypeRegistry is only for `PklAny` polymorphic types and performance — concrete `Decodable` structs (like `VariablesDarkMode`) decode via synthesized `init(from:)` regardless of `registerPklTypes`. New types should still be added to `registerPklTypes()` for completeness, but missing entries do NOT cause silent nil for concrete typed fields. + +**Cross-file variable resolution:** Figma variable IDs are file-scoped — alias targets from the icons file don't exist in library files by ID. When `variablesFileId` is set, variables are loaded from BOTH files: icons file (semantic variables matching node boundVariables) + library file (primitives for alias resolution). Matching is by variable **name** across files and mode **name** across collections (not IDs). + +**VariablesCache pattern:** `VariablesCache` (Lock + Task dedup) caches Variables API responses by fileId across parallel entries. Created per platform section in `PluginIconsExport`, injected through `SourceFactory` → `FigmaComponentsSource` → `VariableModeDarkGenerator`. Same pattern applicable to `ColorsVariablesLoader` if needed. Failed tasks are evicted (`lock.withLock { tasks[fileId] = nil }` in catch) — transient Figma API errors (429) don't permanently poison the cache. + +**ComponentsCache pattern:** `ComponentsCache` (same Lock + Task dedup) caches Components API responses by fileId across parallel entries in standalone mode. Solves the problem that `ComponentPreFetcher` only works in batch mode (`BatchSharedState` is nil in standalone). Created per platform section in `PluginIconsExport`, injected through `SourceFactory` → `FigmaComponentsSource` → `ImageLoaderBase`. + +**Alpha handling:** `SVGColorReplacer` supports opacity via `ColorReplacement(hex, alpha)`. When `alpha < 1.0`, replacement adds `fill-opacity`/`stroke-opacity` attributes (SVG) or `;fill-opacity:N` (CSS). Same hex with different alpha IS a valid replacement (e.g., `#D6FB94` opaque → `#D6FB94` transparent). + +**Granular cache path:** `IconsExportContextImpl.loadIconsWithGranularCache()` creates its own `IconsLoader` and bypasses `FigmaComponentsSource` entirely. Variable-mode dark generation must be applied explicitly at the end of that method via `applyVariableModeDark(to:source:)`. + ### Module Boundaries ExFigCore does NOT import FigmaAPI. Constants on `Component` (FigmaAPI, extended in ExFigCLI) are @@ -239,7 +277,7 @@ ExFigConfig imports ExFigCore but NOT ExFigCLI — `ExFigError` is not available ### Modifying SourceFactory Signatures -`createComponentsSource` has 8 call sites (4 in `PluginIconsExport` + 4 in `PluginImagesExport`) plus tests in `PenpotSourceTests.swift`. +`createComponentsSource` has 8 call sites (4 in `PluginIconsExport` + 4 in `PluginImagesExport`) plus tests in `PenpotSourceTests.swift`. Icons sites pass `componentsCache:`, Images sites use default `nil`. `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. @@ -455,6 +493,13 @@ NooraUI.formatLink("url", useColors: true) // underlined primary | SwiftFormat `#if` indent | SwiftFormat 0.60.1 indents content inside `#if canImport()` — this is intentional project style, do not "fix" | | SPM `from:` too loose | When code uses APIs from version X, set `from: "X"` not older — SPM may resolve an incompatible earlier version | | Granular cache "Access denied" | `GranularCacheManager.filterChangedComponents` degrades gracefully — returns all components on node fetch error instead of failing config | +| Empty `fileId` in variable dark | `FigmaComponentsSource` must guard `fileId` not empty before calling `VariableModeDarkGenerator` — `?? ""` causes cryptic Figma API 404 | +| PKL field always `nil` | `registerPklTypes()` is a performance optimization, NOT a correctness requirement for concrete typed fields. For optional nested PKL objects returning `nil`, check: (1) `pkl eval --format json` confirms field present, (2) unit test with `PKLEvaluator.evaluate()` decodes correctly, (3) trace values at bridge layer (`iconsSourceInput()`) with diagnostic log | +| Granular cache skips dark gen | `loadIconsWithGranularCache()` in `IconsExportContextImpl` bypasses `FigmaComponentsSource` — must call `VariableModeDarkGenerator` explicitly via `applyVariableModeDark()` helper | +| Variable dark always empty maps | Alias targets are external library variables — set `variablesFileId` in `VariablesDarkMode` PKL config to the library file ID containing primitives | +| Figma variable IDs file-scoped | Variable IDs differ between files — alias targets from file A can't be found by ID in file B. Use name-based matching (`resolveViaLibrary`) + mode name matching (not modeId) for cross-file resolution | +| `assertionFailure` in release | `assertionFailure` is stripped in release builds — add `FileHandle.standardError.write()` as production fallback for truly-impossible-but-must-not-be-silent error paths | +| Components API called N times | `ComponentPreFetcher` only works in batch mode — use `ComponentsCache` via `SourceFactory(componentsCache:)` for standalone multi-entry dedup | ## Additional Rules diff --git a/CONFIG.md b/CONFIG.md index 18d4fc5b..a6d374a8 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -205,10 +205,8 @@ colors = new Common.Colors { nameValidateRegexp = "^([a-zA-Z_]+)$" // [optional] Replacement pattern using captured groups ($n). nameReplaceRegexp = "color_$1" - // [optional] Extract light/dark from a single file. Default: false - useSingleFile = false - // [optional] Suffix for dark mode. Default: "_dark" - darkModeSuffix = "_dark" + // [optional] Extract light/dark from a single file using name suffix splitting + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } // [optional] Suffix for light high contrast. Default: "_lightHC" // lightHCModeSuffix = "_lightHC" // [optional] Suffix for dark high contrast. Default: "_darkHC" @@ -216,14 +214,13 @@ colors = new Common.Colors { } ``` -| Field | Type | Description | -| -------------------- | ---------- | ------------------------------------------- | -| `nameValidateRegexp` | `String?` | RegExp for validating/capturing color names | -| `nameReplaceRegexp` | `String?` | Replacement pattern with captured groups | -| `useSingleFile` | `Boolean?` | Extract all modes from lightFileId | -| `darkModeSuffix` | `String?` | Dark mode name suffix (default: `_dark`) | -| `lightHCModeSuffix` | `String?` | Light HC suffix (default: `_lightHC`) | -| `darkHCModeSuffix` | `String?` | Dark HC suffix (default: `_darkHC`) | +| Field | Type | Description | +| -------------------- | ----------------- | ------------------------------------------- | +| `nameValidateRegexp` | `String?` | RegExp for validating/capturing color names | +| `nameReplaceRegexp` | `String?` | Replacement pattern with captured groups | +| `suffixDarkMode` | `SuffixDarkMode?` | Dark mode via name suffix splitting | +| `lightHCModeSuffix` | `String?` | Light HC suffix (default: `_lightHC`) | +| `darkHCModeSuffix` | `String?` | Dark HC suffix (default: `_darkHC`) | ### VariablesColors (Variables API) @@ -276,10 +273,8 @@ icons = new Common.Icons { nameValidateRegexp = "^(ic)_(\\d\\d)_([a-z0-9_]+)$" // [optional] Replacement pattern nameReplaceRegexp = "icon_$2_$1" - // [optional] Extract light/dark from a single file. Default: false - useSingleFile = false - // [optional] Suffix for dark mode icons. Default: "_dark" - darkModeSuffix = "_dark" + // [optional] Extract light/dark from a single file using name suffix splitting + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } // [optional] Exit with error when pathData exceeds 32,767 bytes (AAPT limit). Default: false // strictPathValidation = true } @@ -297,10 +292,8 @@ images = new Common.Images { nameValidateRegexp = "^(img)_([a-z0-9_]+)$" // [optional] Replacement pattern nameReplaceRegexp = "image_$2" - // [optional] Extract light/dark from a single file. Default: false - useSingleFile = false - // [optional] Suffix for dark mode images. Default: "_dark" - darkModeSuffix = "_dark" + // [optional] Extract light/dark from a single file using name suffix splitting + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } ``` @@ -319,14 +312,17 @@ typography = new Common.Typography { All Icons and Images entries across platforms extend `Common.FrameSource`, which provides: -| Field | Type | Default | Description | -| -------------------- | --------- | ------- | ------------------------------------------------------- | -| `figmaFrameName` | `String?` | — | Override Figma frame name for this entry | -| `figmaPageName` | `String?` | — | Filter by Figma page name for this entry | -| `figmaFileId` | `String?` | — | Override Figma file ID for this entry | -| `rtlProperty` | `String?` | `"RTL"` | Figma component property name for RTL variant detection | -| `nameValidateRegexp` | `String?` | — | Regex pattern for name validation | -| `nameReplaceRegexp` | `String?` | — | Replacement pattern using captured groups | +| Field | Type | Default | Description | +| -------------------- | -------------------- | ------- | ------------------------------------------------------- | +| `figmaFrameName` | `String?` | — | Override Figma frame name for this entry | +| `figmaPageName` | `String?` | — | Filter by Figma page name for this entry | +| `figmaFileId` | `String?` | — | Override Figma file ID for this entry | +| `rtlProperty` | `String?` | `"RTL"` | Figma component property name for RTL variant detection | +| `variablesDarkMode` | `VariablesDarkMode?` | — | Dark mode via Figma Variable bindings (see below) | +| `nameValidateRegexp` | `String?` | — | Regex pattern for name validation | +| `nameReplaceRegexp` | `String?` | — | Replacement pattern using captured groups | + +**VariablesDarkMode** fields: `collectionName` (required), `lightModeName` (required), `darkModeName` (required), `primitivesModeName` (optional), `variablesFileId` (optional — Figma file ID of the library containing the full variable chain including primitives; required when variables alias to an external library). **RTL Detection:** When `rtlProperty` is set (default `"RTL"`), ExFig detects RTL support via Figma COMPONENT_SET variant properties. Components with `RTL=On` variant are automatically skipped (iOS/Android diff --git a/Package.resolved b/Package.resolved index bbbb1b1b..06e95b13 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "31011d74ecc5927c563e211c66d6bb120f64c77db99e16e9faf7b2c17b959053", + "originHash" : "0174127c4f0a77235f54c6f12dac004075c2f8b505e4e338cfb70e60ba26609d", "pins" : [ { "identity" : "aexml", @@ -159,8 +159,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DesignPipe/swift-figma-api.git", "state" : { - "revision" : "028e774bc9b7ff23021a684852076dc2e48da316", - "version" : "0.2.0" + "revision" : "1dd19cf2e3aa38641552d7542bea5e68375d98ab", + "version" : "0.3.0" } }, { diff --git a/README.md b/README.md index 3b49b0b2..c04316d4 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![CI](https://github.com/DesignPipe/exfig/actions/workflows/ci.yml/badge.svg)](https://github.com/DesignPipe/exfig/actions/workflows/ci.yml) [![Release](https://github.com/DesignPipe/exfig/actions/workflows/release.yml/badge.svg)](https://github.com/DesignPipe/exfig/actions/workflows/release.yml) [![Docs](https://github.com/DesignPipe/exfig/actions/workflows/deploy-docc.yml/badge.svg)](https://DesignPipe.github.io/exfig/documentation/exfigcli) -![Coverage](https://img.shields.io/badge/coverage-43.65%25-yellow) +![Coverage](https://img.shields.io/badge/coverage-43.55%25-yellow) [![License](https://img.shields.io/github/license/DesignPipe/exfig.svg)](LICENSE) Export colors, typography, icons, and images from Figma and Penpot to Xcode, Android Studio, Flutter, and Web projects — automatically. Runs on macOS, Linux, and Windows. diff --git a/Scripts/generate-llms.sh b/Scripts/generate-llms.sh index 1691319d..c2d31189 100755 --- a/Scripts/generate-llms.sh +++ b/Scripts/generate-llms.sh @@ -127,8 +127,8 @@ generate_llms_full() { # ── Main ────────────────────────────────────────────────────────────────────── -generate_llms_txt > "$LLMS_TXT" -generate_llms_full > "$LLMS_FULL" +generate_llms_txt | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}' > "$LLMS_TXT" +generate_llms_full | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}' > "$LLMS_FULL" LINES_TXT=$(wc -l < "$LLMS_TXT" | tr -d ' ') LINES_FULL=$(wc -l < "$LLMS_FULL" | tr -d ' ') diff --git a/Sources/ExFig-Android/Config/AndroidIconsEntry.swift b/Sources/ExFig-Android/Config/AndroidIconsEntry.swift index 577638ae..a71223a9 100644 --- a/Sources/ExFig-Android/Config/AndroidIconsEntry.swift +++ b/Sources/ExFig-Android/Config/AndroidIconsEntry.swift @@ -20,12 +20,17 @@ public extension Android.IconsEntry { frameName: figmaFrameName ?? "Icons", pageName: figmaPageName, format: .svg, - useSingleFile: darkFileId == nil, + useSingleFile: darkFileId == nil && variablesDarkMode == nil, darkModeSuffix: "_dark", rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp, - penpotBaseURL: resolvedPenpotBaseURL + penpotBaseURL: resolvedPenpotBaseURL, + variablesCollectionName: variablesDarkMode?.collectionName, + variablesLightModeName: variablesDarkMode?.lightModeName, + variablesDarkModeName: variablesDarkMode?.darkModeName, + variablesPrimitivesModeName: variablesDarkMode?.primitivesModeName, + variablesFileId: variablesDarkMode?.variablesFileId ) } diff --git a/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift b/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift index 3c78d853..c97975a5 100644 --- a/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift +++ b/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift @@ -16,12 +16,17 @@ public extension Flutter.IconsEntry { darkFileId: darkFileId, frameName: figmaFrameName ?? "Icons", pageName: figmaPageName, - useSingleFile: darkFileId == nil, + useSingleFile: darkFileId == nil && variablesDarkMode == nil, darkModeSuffix: "_dark", rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp, - penpotBaseURL: resolvedPenpotBaseURL + penpotBaseURL: resolvedPenpotBaseURL, + variablesCollectionName: variablesDarkMode?.collectionName, + variablesLightModeName: variablesDarkMode?.lightModeName, + variablesDarkModeName: variablesDarkMode?.darkModeName, + variablesPrimitivesModeName: variablesDarkMode?.primitivesModeName, + variablesFileId: variablesDarkMode?.variablesFileId ) } diff --git a/Sources/ExFig-Web/Config/WebIconsEntry.swift b/Sources/ExFig-Web/Config/WebIconsEntry.swift index 02cbd0f2..0fd37917 100644 --- a/Sources/ExFig-Web/Config/WebIconsEntry.swift +++ b/Sources/ExFig-Web/Config/WebIconsEntry.swift @@ -16,12 +16,17 @@ public extension Web.IconsEntry { darkFileId: darkFileId, frameName: figmaFrameName ?? "Icons", pageName: figmaPageName, - useSingleFile: darkFileId == nil, + useSingleFile: darkFileId == nil && variablesDarkMode == nil, darkModeSuffix: "_dark", rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp, - penpotBaseURL: resolvedPenpotBaseURL + penpotBaseURL: resolvedPenpotBaseURL, + variablesCollectionName: variablesDarkMode?.collectionName, + variablesLightModeName: variablesDarkMode?.lightModeName, + variablesDarkModeName: variablesDarkMode?.darkModeName, + variablesPrimitivesModeName: variablesDarkMode?.primitivesModeName, + variablesFileId: variablesDarkMode?.variablesFileId ) } diff --git a/Sources/ExFig-iOS/CLAUDE.md b/Sources/ExFig-iOS/CLAUDE.md index 48408b65..4fef039e 100644 --- a/Sources/ExFig-iOS/CLAUDE.md +++ b/Sources/ExFig-iOS/CLAUDE.md @@ -85,6 +85,12 @@ All asset types support dark variants via `AssetPair`. Pattern: - Icons: dark variant filenames suffixed with `D` (e.g., `iconD.pdf`) - Images: dark variants in imageset with `appearances: [luminosity: dark]` +**Icons dark mode approaches** (mutually exclusive): + +1. `darkFileId` — separate Figma file for dark icons +2. `useSingleFile` + `darkModeSuffix` — dark icons in same file, split by name suffix +3. Variable modes — `variablesCollectionName` + mode names → SVG color replacement via `VariableModeDarkGenerator` + ### Output Cleanup Exporters remove old output dirs (`FileManager.removeItem`) before writing when: diff --git a/Sources/ExFig-iOS/Config/iOSIconsEntry.swift b/Sources/ExFig-iOS/Config/iOSIconsEntry.swift index a7267494..7cb04627 100644 --- a/Sources/ExFig-iOS/Config/iOSIconsEntry.swift +++ b/Sources/ExFig-iOS/Config/iOSIconsEntry.swift @@ -19,7 +19,7 @@ public extension iOS.IconsEntry { frameName: figmaFrameName ?? "Icons", pageName: figmaPageName, format: coreVectorFormat, - useSingleFile: darkFileId == nil, + useSingleFile: darkFileId == nil && variablesDarkMode == nil, darkModeSuffix: "_dark", renderMode: coreRenderMode, renderModeDefaultSuffix: renderModeDefaultSuffix, @@ -28,7 +28,12 @@ public extension iOS.IconsEntry { rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp, - penpotBaseURL: resolvedPenpotBaseURL + penpotBaseURL: resolvedPenpotBaseURL, + variablesCollectionName: variablesDarkMode?.collectionName, + variablesLightModeName: variablesDarkMode?.lightModeName, + variablesDarkModeName: variablesDarkMode?.darkModeName, + variablesPrimitivesModeName: variablesDarkMode?.primitivesModeName, + variablesFileId: variablesDarkMode?.variablesFileId ) } diff --git a/Sources/ExFig-iOS/Export/iOSIconsExporter.swift b/Sources/ExFig-iOS/Export/iOSIconsExporter.swift index 9b2ea374..5617175d 100644 --- a/Sources/ExFig-iOS/Export/iOSIconsExporter.swift +++ b/Sources/ExFig-iOS/Export/iOSIconsExporter.swift @@ -58,7 +58,8 @@ public struct iOSIconsExporter: IconsExporter { let merged = IconsExportResult.merge(results) if !context.isBatchMode { - context.success("Done! Exported \(merged.count) icons to Xcode project.") + let darkSuffix = merged.darkCount > 0 ? " (\(merged.darkCount) dark variants)" : "" + context.success("Done! Exported \(merged.count) icons\(darkSuffix) to Xcode project.") } return merged @@ -204,8 +205,11 @@ public struct iOSIconsExporter: IconsExporter { ? loadResult.allAssetMetadata.count - iconPairs.count : 0 + let darkCount = iconPairs.count { $0.dark != nil } + return IconsExportResult( count: iconPairs.count, + darkCount: darkCount, skippedCount: skippedCount, computedHashes: loadResult.computedHashes, allAssetMetadata: loadResult.allAssetMetadata diff --git a/Sources/ExFigCLI/CLAUDE.md b/Sources/ExFigCLI/CLAUDE.md index 1cf2ea25..7103f813 100644 --- a/Sources/ExFigCLI/CLAUDE.md +++ b/Sources/ExFigCLI/CLAUDE.md @@ -129,6 +129,8 @@ Granular cache flow: pre-fetch nodes → compute hashes → compare with cached - `warning()` / `error()` — NEVER suppressed; queued to `BatchProgressView.queueLogMessage()` for coordinated rendering - `TerminalOutputManager` singleton prevents race conditions between animations and log output via `hasActiveAnimation` flag - `withParallelEntries()` creates parent spinner suppressing all inner output (except warnings/errors) +- `OutputMode.useAnimations` is `true` only for `.normal` — `.verbose` and `.quiet` are non-animated +- `Spinner`/`ProgressBar` non-animated mode: `start()` emits NO output; only `stop()` prints the final `✓`/`✗` line. Do NOT call `startAnimation()` or `writeDirect()` in non-animated mode — causes duplicated output ### Image Conversion Pipeline @@ -148,34 +150,38 @@ Converter factories (`WebpConverterFactory`, `HeicConverterFactory`) handle plat ## Key Files -| File | Role | -| ---------------------------------------- | ------------------------------------------------------------------- | -| `ExFigCommand.swift` | Entry point, version, shared instances, subcommand registration | -| `Input/ExFigOptions.swift` | PKL config loading, token validation, auto-detection | -| `Batch/BatchContext.swift` | `BatchContext`, `ConfigExecutionContext`, `BatchSharedState` actor | -| `Batch/BatchExecutor.swift` | Parallel config execution with rate limiting | -| `Plugin/PluginRegistry.swift` | Platform plugin routing (config key → plugin → exporters) | -| `TerminalUI/TerminalUI.swift` | Output facade (info/success/warning/error, spinners, progress) | -| `TerminalUI/TerminalOutputManager.swift` | Thread-safe output synchronization, animation coordination | -| `TerminalUI/BatchProgressView.swift` | Multi-config progress display with log queuing | -| `Cache/GranularCacheManager.swift` | Per-node change detection with FNV-1a hashing | -| `Pipeline/SharedDownloadQueue.swift` | Cross-config download pipelining actor | -| `Output/FileWriter.swift` | Sequential and parallel file writing with directory creation | -| `Shared/ComponentPreFetcher.swift` | Pre-fetch components for multi-entry exports | -| `Input/TokensFileSource.swift` | W3C DTCG .tokens.json parser (local file → ExFigCore models) | -| `Output/W3CTokensExporter.swift` | W3C design token JSON exporter (v1/v2025 formats) | -| `Loaders/NumberVariablesLoader.swift` | Figma number variables → dimension/number tokens | -| `Subcommands/DownloadTokens.swift` | Unified `download tokens` subcommand | -| `MCP/ExFigMCPServer.swift` | MCP server setup and lifecycle (stdio transport) | -| `MCP/MCPToolDefinitions.swift` | MCP tool schemas (export colors, icons, images, etc.) | -| `MCP/MCPToolHandlers.swift` | MCP tool request handlers | -| `MCP/MCPResources.swift` | MCP resource providers (config, schemas) | -| `MCP/MCPPrompts.swift` | MCP prompt templates | -| `MCP/MCPServerState.swift` | MCP server shared state | -| `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) | +| File | Role | +| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `ExFigCommand.swift` | Entry point, version, shared instances, subcommand registration | +| `Input/ExFigOptions.swift` | PKL config loading, token validation, auto-detection | +| `Batch/BatchContext.swift` | `BatchContext`, `ConfigExecutionContext`, `BatchSharedState` actor | +| `Batch/BatchExecutor.swift` | Parallel config execution with rate limiting | +| `Plugin/PluginRegistry.swift` | Platform plugin routing (config key → plugin → exporters) | +| `TerminalUI/TerminalUI.swift` | Output facade (info/success/warning/error, spinners, progress) | +| `TerminalUI/TerminalOutputManager.swift` | Thread-safe output synchronization, animation coordination | +| `TerminalUI/BatchProgressView.swift` | Multi-config progress display with log queuing | +| `Cache/GranularCacheManager.swift` | Per-node change detection with FNV-1a hashing | +| `Pipeline/SharedDownloadQueue.swift` | Cross-config download pipelining actor | +| `Output/FileWriter.swift` | Sequential and parallel file writing with directory creation | +| `Shared/ComponentPreFetcher.swift` | Pre-fetch components for multi-entry exports | +| `Input/TokensFileSource.swift` | W3C DTCG .tokens.json parser (local file → ExFigCore models) | +| `Output/W3CTokensExporter.swift` | W3C design token JSON exporter (v1/v2025 formats) | +| `Loaders/NumberVariablesLoader.swift` | Figma number variables → dimension/number tokens | +| `Subcommands/DownloadTokens.swift` | Unified `download tokens` subcommand | +| `MCP/ExFigMCPServer.swift` | MCP server setup and lifecycle (stdio transport) | +| `MCP/MCPToolDefinitions.swift` | MCP tool schemas (export colors, icons, images, etc.) | +| `MCP/MCPToolHandlers.swift` | MCP tool request handlers | +| `MCP/MCPResources.swift` | MCP resource providers (config, schemas) | +| `MCP/MCPPrompts.swift` | MCP prompt templates | +| `MCP/MCPServerState.swift` | MCP server shared state | +| `Source/SourceFactory.swift` | Centralized factory creating source instances by `DesignSourceKind` | +| `Source/Figma*Source.swift` | Figma source implementations wrapping existing loaders | +| `Source/Penpot*Source.swift` | Penpot source implementations (colors, components, typography) | +| `Source/TokensFileColorsSource.swift` | Local .tokens.json source (extracted from ColorsExportContextImpl) | +| `Loaders/VariableModeDarkGenerator.swift` | Generates dark SVGs via Figma Variables; supports cross-file resolution (name-based) when `variablesFileId` set | +| `Loaders/VariablesCache.swift` | Lock+Task dedup cache for Variables API responses across parallel entries | +| `Loaders/ComponentsCache.swift` | Lock+Task dedup cache for Components API responses across parallel entries (standalone mode) | +| `Output/SVGColorReplacer.swift` | Hex color replacement in SVG content (fill, stroke, stop-color) | ### MCP Server Architecture @@ -192,6 +198,9 @@ reserved for MCP JSON-RPC protocol. **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/`. +**Guide sync:** `Resources/Guides/DesignRequirements.md` is served via MCP `exfig://guides/` resource. Must be updated alongside DocC articles when adding dark mode approaches or other user-facing features. + +**MCP validate dark_mode field:** `ValidateSummary.darkMode` reports active dark mode approaches (`darkFileId`, `suffixDarkMode`, `variablesDarkMode`). `buildDarkModeSummary()` checks `config.figma?.darkFileId`, `config.common?.icons?.suffixDarkMode`, and `Common_FrameSource.variablesDarkMode` across all platform icon entries. **Tool handler order:** Validate input parameters BEFORE expensive operations (PKL eval, API client creation). diff --git a/Sources/ExFigCLI/Context/ColorsExportContextImpl.swift b/Sources/ExFigCLI/Context/ColorsExportContextImpl.swift index 07358e4a..11b07183 100644 --- a/Sources/ExFigCLI/Context/ColorsExportContextImpl.swift +++ b/Sources/ExFigCLI/Context/ColorsExportContextImpl.swift @@ -82,9 +82,13 @@ struct ColorsExportContextImpl: ColorsExportContext { darkHC: colors.darkHC.isEmpty ? nil : colors.darkHC ) + if let warning = result.warning { + let formatted = WarningFormatter().format(warning, compact: isBatchMode) + ExFigCommand.logger.debug("\(formatted)") + } + return try ColorsProcessResult( - colorPairs: result.get(), - warning: result.warning.map { WarningFormatter().format($0, compact: isBatchMode) } + colorPairs: result.get() ) } } diff --git a/Sources/ExFigCLI/Context/IconsExportContextImpl.swift b/Sources/ExFigCLI/Context/IconsExportContextImpl.swift index ddfdcbe9..d0cf0f0a 100644 --- a/Sources/ExFigCLI/Context/IconsExportContextImpl.swift +++ b/Sources/ExFigCLI/Context/IconsExportContextImpl.swift @@ -23,6 +23,8 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache { let configExecutionContext: ConfigExecutionContext? let granularCacheManager: GranularCacheManager? let platform: Platform + let variablesCache: VariablesCache? + let componentsCache: ComponentsCache? init( client: Client, @@ -34,7 +36,9 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache { fileDownloader: FileDownloader = FileDownloader(), configExecutionContext: ConfigExecutionContext? = nil, granularCacheManager: GranularCacheManager? = nil, - platform: Platform + platform: Platform, + variablesCache: VariablesCache? = nil, + componentsCache: ComponentsCache? = nil ) { self.client = client self.componentsSource = componentsSource @@ -46,6 +50,8 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache { self.configExecutionContext = configExecutionContext self.granularCacheManager = granularCacheManager self.platform = platform + self.variablesCache = variablesCache + self.componentsCache = componentsCache } var isGranularCacheEnabled: Bool { @@ -102,9 +108,13 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache { dark: icons.dark.isEmpty ? nil : icons.dark ) + if let warning = result.warning { + let formatted = WarningFormatter().format(warning, compact: isBatchMode) + ExFigCommand.logger.debug("\(formatted)") + } + return try IconsProcessResult( - iconPairs: result.get(), - warning: result.warning.map { WarningFormatter().format($0, compact: isBatchMode) } + iconPairs: result.get() ) } @@ -166,14 +176,16 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache { logger: ExFigCommand.logger, config: config ) + loader.componentsCache = componentsCache + let output: IconsLoadOutputWithHashes if let manager = granularCacheManager { loader.granularCacheManager = manager let result = try await loader.loadWithGranularCache( filter: filter, onBatchProgress: onProgress ?? { _, _ in } ) - return IconsLoadOutputWithHashes( + output = IconsLoadOutputWithHashes( light: result.light, dark: result.dark ?? [], computedHashes: result.computedHashes, @@ -182,7 +194,7 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache { ) } else { let result = try await loader.load(filter: filter, onBatchProgress: onProgress ?? { _, _ in }) - return IconsLoadOutputWithHashes( + output = IconsLoadOutputWithHashes( light: result.light, dark: result.dark ?? [], computedHashes: [:], @@ -190,6 +202,44 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache { allAssetMetadata: [] ) } + + return try await applyVariableModeDark(to: output, source: source) + } + + /// Applies variable-mode dark generation to granular cache output. + private func applyVariableModeDark( + to output: IconsLoadOutputWithHashes, + source: IconsSourceInput + ) async throws -> IconsLoadOutputWithHashes { + guard let collectionName = source.variablesCollectionName, + let lightModeName = source.variablesLightModeName, + let darkModeName = source.variablesDarkModeName + else { return output } + + let logger = ExFigCommand.logger + guard let fileId = source.figmaFileId ?? params.figma?.lightFileId, !fileId.isEmpty else { + logger.warning("Variable-mode dark generation requires a Figma file ID, skipping") + return output + } + let generator = VariableModeDarkGenerator(client: client, logger: logger, variablesCache: variablesCache) + let darkPacks = try await generator.generateDarkVariants( + lightPacks: output.light, + config: .init( + fileId: fileId, + collectionName: collectionName, + lightModeName: lightModeName, + darkModeName: darkModeName, + primitivesModeName: source.variablesPrimitivesModeName, + variablesFileId: source.variablesFileId + ) + ) + return IconsLoadOutputWithHashes( + light: output.light, + dark: darkPacks, + computedHashes: output.computedHashes, + allSkipped: output.allSkipped, + allAssetMetadata: output.allAssetMetadata + ) } func processIconNames( diff --git a/Sources/ExFigCLI/Context/ImagesExportContextImpl.swift b/Sources/ExFigCLI/Context/ImagesExportContextImpl.swift index 1ab10df8..775f53db 100644 --- a/Sources/ExFigCLI/Context/ImagesExportContextImpl.swift +++ b/Sources/ExFigCLI/Context/ImagesExportContextImpl.swift @@ -107,9 +107,13 @@ struct ImagesExportContextImpl: ImagesExportContextWithGranularCache { dark: images.dark.isEmpty ? nil : images.dark ) + if let warning = result.warning { + let formatted = WarningFormatter().format(warning, compact: isBatchMode) + ExFigCommand.logger.debug("\(formatted)") + } + return try ImagesProcessResult( - imagePairs: result.get(), - warning: result.warning.map { WarningFormatter().format($0, compact: isBatchMode) } + imagePairs: result.get() ) } diff --git a/Sources/ExFigCLI/Context/TypographyExportContextImpl.swift b/Sources/ExFigCLI/Context/TypographyExportContextImpl.swift index cddc6fcd..9cc72380 100644 --- a/Sources/ExFigCLI/Context/TypographyExportContextImpl.swift +++ b/Sources/ExFigCLI/Context/TypographyExportContextImpl.swift @@ -77,9 +77,13 @@ struct TypographyExportContextImpl: TypographyExportContext { let result = processor.process(assets: textStyles.textStyles) + if let warning = result.warning { + let formatted = WarningFormatter().format(warning, compact: isBatchMode) + ExFigCommand.logger.debug("\(formatted)") + } + return try TypographyProcessResult( - textStyles: result.get(), - warning: result.warning.map { WarningFormatter().format($0, compact: isBatchMode) } + textStyles: result.get() ) } } diff --git a/Sources/ExFigCLI/ExFig.docc/Android/AndroidColors.md b/Sources/ExFigCLI/ExFig.docc/Android/AndroidColors.md index 8a156ff4..780ae8ba 100644 --- a/Sources/ExFigCLI/ExFig.docc/Android/AndroidColors.md +++ b/Sources/ExFigCLI/ExFig.docc/Android/AndroidColors.md @@ -207,8 +207,7 @@ import ".exfig/schemas/Common.pkl" common = new Common.CommonConfig { colors = new Common.Colors { - useSingleFile = true - darkModeSuffix = "_dark" + suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } ``` diff --git a/Sources/ExFigCLI/ExFig.docc/Android/AndroidIcons.md b/Sources/ExFigCLI/ExFig.docc/Android/AndroidIcons.md index 0972166f..b32901d8 100644 --- a/Sources/ExFigCLI/ExFig.docc/Android/AndroidIcons.md +++ b/Sources/ExFigCLI/ExFig.docc/Android/AndroidIcons.md @@ -260,8 +260,7 @@ import ".exfig/schemas/Common.pkl" common = new Common.CommonConfig { icons = new Common.Icons { - useSingleFile = true - darkModeSuffix = "_dark" + suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } ``` @@ -273,6 +272,28 @@ ic/24/logo ic/24/logo_dark ``` +### Variable Mode + +For icons using Figma Variable bindings (e.g., double-color icons), ExFig resolves dark colors automatically from the Variables API: + +```pkl +new Android.IconsEntry { + figmaFrameName = "DoubleColor" + format = "svg" + output = "./app/src/main/res" + variablesDarkMode = new Common.VariablesDarkMode { + collectionName = "DesignTokens" + lightModeName = "Light" + darkModeName = "Dark" + variablesFileId = "LIBRARY_FILE_ID" + } +} +``` + +ExFig fetches variable definitions, resolves alias chains to concrete colors, and replaces hex values in SVGs to produce dark variants. Light icons go to `drawable/`, dark to `drawable-night/`. + +`variablesFileId` is required when your variables alias to primitives in an external Figma library. + ## Tips 1. **Keep icons simple**: Complex paths may not convert well diff --git a/Sources/ExFigCLI/ExFig.docc/Android/AndroidImages.md b/Sources/ExFigCLI/ExFig.docc/Android/AndroidImages.md index b69b3c5f..e4149f3b 100644 --- a/Sources/ExFigCLI/ExFig.docc/Android/AndroidImages.md +++ b/Sources/ExFigCLI/ExFig.docc/Android/AndroidImages.md @@ -229,8 +229,7 @@ import ".exfig/schemas/Common.pkl" common = new Common.CommonConfig { images = new Common.Images { - useSingleFile = true - darkModeSuffix = "_dark" + suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } ``` diff --git a/Sources/ExFigCLI/ExFig.docc/Configuration.md b/Sources/ExFigCLI/ExFig.docc/Configuration.md index 452120df..bca76d4c 100644 --- a/Sources/ExFigCLI/ExFig.docc/Configuration.md +++ b/Sources/ExFigCLI/ExFig.docc/Configuration.md @@ -76,11 +76,7 @@ common = new Common.CommonConfig { // Regex replacement for color names nameReplaceRegexp = "$1" - // Extract light and dark mode colors from a single file - useSingleFile = false - - // Suffix for dark mode variants (when useSingleFile is true) - darkModeSuffix = "_dark" + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } ``` @@ -213,11 +209,7 @@ common = new Common.CommonConfig { // Regex replacement for icon names nameReplaceRegexp = "ic_$1" - // Use single file for light/dark (default: false) - useSingleFile = false - - // Suffix for dark mode variants (when useSingleFile is true) - darkModeSuffix = "_dark" + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } ``` @@ -238,11 +230,7 @@ common = new Common.CommonConfig { // Regex replacement for image names nameReplaceRegexp = "$1" - // Use single file for light/dark (default: false) - useSingleFile = false - - // Suffix for dark mode variants (when useSingleFile is true) - darkModeSuffix = "_dark" + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } ``` diff --git a/Sources/ExFigCLI/ExFig.docc/DesignRequirements.md b/Sources/ExFigCLI/ExFig.docc/DesignRequirements.md index c73c8b9c..40d5e936 100644 --- a/Sources/ExFigCLI/ExFig.docc/DesignRequirements.md +++ b/Sources/ExFigCLI/ExFig.docc/DesignRequirements.md @@ -169,8 +169,7 @@ import ".exfig/schemas/Common.pkl" common = new Common.CommonConfig { icons = new Common.Icons { - useSingleFile = true - darkModeSuffix = "_dark" + suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } ``` diff --git a/Sources/ExFigCLI/ExFig.docc/PKLGuide.md b/Sources/ExFigCLI/ExFig.docc/PKLGuide.md index 8afb2590..7efe4a62 100755 --- a/Sources/ExFigCLI/ExFig.docc/PKLGuide.md +++ b/Sources/ExFigCLI/ExFig.docc/PKLGuide.md @@ -483,16 +483,14 @@ common = new Common.CommonConfig { // Shared icons settings icons = new Common.Icons { figmaFrameName = "Icons/24" - useSingleFile = false - darkModeSuffix = "-dark" + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "-dark" } strictPathValidation = true } // Shared images settings images = new Common.Images { figmaFrameName = "Illustrations" - useSingleFile = false - darkModeSuffix = "-dark" + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "-dark" } } // Name processing (applies to all) diff --git a/Sources/ExFigCLI/ExFig.docc/iOS/iOSIcons.md b/Sources/ExFigCLI/ExFig.docc/iOS/iOSIcons.md index f75b09c1..400da5f1 100644 --- a/Sources/ExFigCLI/ExFig.docc/iOS/iOSIcons.md +++ b/Sources/ExFigCLI/ExFig.docc/iOS/iOSIcons.md @@ -291,8 +291,7 @@ import ".exfig/schemas/Common.pkl" common = new Common.CommonConfig { icons = new Common.Icons { - useSingleFile = true - darkModeSuffix = "_dark" + suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } ``` @@ -305,6 +304,28 @@ Icons frame └── ic/24/logo_dark ``` +### Variable Mode + +For icons using Figma Variable bindings (e.g., double-color icons), ExFig resolves dark colors automatically from the Variables API: + +```pkl +new iOS.IconsEntry { + figmaFrameName = "DoubleColor" + format = "svg" + assetsFolder = "DoubleColorIcons" + variablesDarkMode = new Common.VariablesDarkMode { + collectionName = "DesignTokens" + lightModeName = "Light" + darkModeName = "Dark" + variablesFileId = "LIBRARY_FILE_ID" + } +} +``` + +ExFig fetches variable definitions, resolves alias chains to concrete colors, and replaces hex values in SVGs to produce dark variants. + +`variablesFileId` is required when your variables alias to primitives in an external Figma library. It specifies the library file where the full variable chain (including primitives) can be resolved. If all variables are local to the icons file, omit this field. + ## See Also - diff --git a/Sources/ExFigCLI/ExFig.docc/iOS/iOSImages.md b/Sources/ExFigCLI/ExFig.docc/iOS/iOSImages.md index 6c8ebd8e..97223822 100644 --- a/Sources/ExFigCLI/ExFig.docc/iOS/iOSImages.md +++ b/Sources/ExFigCLI/ExFig.docc/iOS/iOSImages.md @@ -311,8 +311,7 @@ import ".exfig/schemas/Common.pkl" common = new Common.CommonConfig { images = new Common.Images { - useSingleFile = true - darkModeSuffix = "_dark" + suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } ``` diff --git a/Sources/ExFigCLI/Loaders/Colors/ColorsLoader.swift b/Sources/ExFigCLI/Loaders/Colors/ColorsLoader.swift index b9775613..a1242ded 100644 --- a/Sources/ExFigCLI/Loaders/Colors/ColorsLoader.swift +++ b/Sources/ExFigCLI/Loaders/Colors/ColorsLoader.swift @@ -30,7 +30,7 @@ final class ColorsLoader: Sendable { "Use common.variablesColors or multi-entry colors format instead." ) } - guard let useSingleFile = colorParams?.useSingleFile, useSingleFile else { + guard colorParams?.suffixDarkMode != nil else { return try await loadColorsFromLightAndDarkFile() } return try await loadColorsFromSingleFile() @@ -87,7 +87,7 @@ final class ColorsLoader: Sendable { // swiftlint:disable:next force_unwrapping let colors = try await loadColors(fileId: figmaParams.lightFileId!) - let darkSuffix = colorParams?.darkModeSuffix ?? "_dark" + let darkSuffix = colorParams?.suffixDarkMode?.suffix ?? "_dark" let lightHCSuffix = colorParams?.lightHCModeSuffix ?? "_lightHC" let darkHCSuffix = colorParams?.darkHCModeSuffix ?? "_darkHC" diff --git a/Sources/ExFigCLI/Loaders/ComponentsCache.swift b/Sources/ExFigCLI/Loaders/ComponentsCache.swift new file mode 100644 index 00000000..b02cfe46 --- /dev/null +++ b/Sources/ExFigCLI/Loaders/ComponentsCache.swift @@ -0,0 +1,34 @@ +import FigmaAPI +import Foundation + +/// Deduplicating cache for Figma Components API responses. +/// +/// Concurrent callers requesting the same `fileId` share a single in-flight `Task`. +/// First caller triggers the fetch; subsequent callers await the same result. +/// Failed tasks are evicted so subsequent callers can retry. +/// +/// Same pattern as ``VariablesCache``. Used in standalone (non-batch) mode +/// when multiple icon/image entries share the same Figma file. +final class ComponentsCache: @unchecked Sendable { + private let lock = NSLock() + private var tasks: [String: Task<[Component], Error>] = [:] + + func get( + fileId: String, + fetch: @escaping @Sendable () async throws -> [Component] + ) async throws -> [Component] { + let task: Task<[Component], Error> = lock.withLock { + if let existing = tasks[fileId] { return existing } + let newTask = Task { try await fetch() } + tasks[fileId] = newTask + return newTask + } + do { + return try await task.value + } catch { + // Evict failed tasks so subsequent callers can retry (e.g., transient 429 rate limit) + lock.withLock { tasks[fileId] = nil } + throw error + } + } +} diff --git a/Sources/ExFigCLI/Loaders/IconsLoader.swift b/Sources/ExFigCLI/Loaders/IconsLoader.swift index 3036f445..354d8a59 100644 --- a/Sources/ExFigCLI/Loaders/IconsLoader.swift +++ b/Sources/ExFigCLI/Loaders/IconsLoader.swift @@ -151,7 +151,7 @@ final class IconsLoader: ImageLoaderBase, @unchecked Sendable { filter: String? = nil, onBatchProgress: @escaping BatchProgressCallback = { _, _ in } ) async throws -> IconsLoaderOutput { - if let useSingleFile = params.common?.icons?.useSingleFile, useSingleFile { + if params.common?.icons?.suffixDarkMode != nil { try await loadFromSingleFile(filter: filter, onBatchProgress: onBatchProgress) } else { try await loadFromLightAndDarkFile(filter: filter, onBatchProgress: onBatchProgress) @@ -167,7 +167,7 @@ final class IconsLoader: ImageLoaderBase, @unchecked Sendable { filter: String? = nil, onBatchProgress: @escaping BatchProgressCallback = { _, _ in } ) async throws -> IconsLoaderResultWithHashes { - if let useSingleFile = params.common?.icons?.useSingleFile, useSingleFile { + if params.common?.icons?.suffixDarkMode != nil { try await loadFromSingleFileWithGranularCache( filter: filter, onBatchProgress: onBatchProgress ) @@ -197,7 +197,7 @@ final class IconsLoader: ImageLoaderBase, @unchecked Sendable { onBatchProgress: onBatchProgress ) - let darkSuffix = params.common?.icons?.darkModeSuffix ?? "_dark" + let darkSuffix = params.common?.icons?.suffixDarkMode?.suffix ?? "_dark" let (lightIcons, darkIcons) = splitByDarkMode(icons, darkSuffix: darkSuffix) return ( @@ -298,7 +298,7 @@ final class IconsLoader: ImageLoaderBase, @unchecked Sendable { ) async throws -> IconsLoaderResultWithHashes { let formatParams = makeFormatParams() let fileId = try requireLightFileId(entryFileId: config.entryFileId) - let darkSuffix = params.common?.icons?.darkModeSuffix ?? "_dark" + let darkSuffix = params.common?.icons?.suffixDarkMode?.suffix ?? "_dark" // Use pairing-aware method to ensure light/dark pairs are exported together let result = try await loadVectorImagesWithGranularCacheAndPairing( diff --git a/Sources/ExFigCLI/Loaders/ImageLoaderBase.swift b/Sources/ExFigCLI/Loaders/ImageLoaderBase.swift index b4a0b524..5aef264b 100644 --- a/Sources/ExFigCLI/Loaders/ImageLoaderBase.swift +++ b/Sources/ExFigCLI/Loaders/ImageLoaderBase.swift @@ -40,6 +40,9 @@ class ImageLoaderBase: @unchecked Sendable { /// Optional granular cache manager for per-node change detection. var granularCacheManager: GranularCacheManager? + /// Optional components cache for deduplicating Components API calls across entries. + var componentsCache: ComponentsCache? + init(client: Client, params: PKLConfig, platform: Platform, logger: Logger) { self.client = client self.params = params @@ -696,7 +699,14 @@ class ImageLoaderBase: @unchecked Sendable { return components } - // Fall back to API request (standalone mode or missing pre-fetch) + // Check components cache (standalone multi-entry dedup) + if let cache = componentsCache { + return try await cache.get(fileId: fileId) { + try await self.client.request(ComponentsEndpoint(fileId: fileId)) + } + } + + // Fall back to direct API request (single entry) let endpoint = ComponentsEndpoint(fileId: fileId) return try await client.request(endpoint) } diff --git a/Sources/ExFigCLI/Loaders/ImagesLoader.swift b/Sources/ExFigCLI/Loaders/ImagesLoader.swift index b52adb2f..53c83205 100644 --- a/Sources/ExFigCLI/Loaders/ImagesLoader.swift +++ b/Sources/ExFigCLI/Loaders/ImagesLoader.swift @@ -222,7 +222,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di filter: String? = nil, onBatchProgress: @escaping BatchProgressCallback = { _, _ in } ) async throws -> ImagesLoaderOutput { - if let useSingleFile = params.common?.images?.useSingleFile, useSingleFile { + if params.common?.images?.suffixDarkMode != nil { try await loadFromSingleFile(filter: filter, onBatchProgress: onBatchProgress) } else { try await loadFromLightAndDarkFile(filter: filter, onBatchProgress: onBatchProgress) @@ -242,7 +242,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di filter: String? = nil, onBatchProgress: @escaping BatchProgressCallback = { _, _ in } ) async throws -> ImagesLoaderResultWithHashes { - if let useSingleFile = params.common?.images?.useSingleFile, useSingleFile { + if params.common?.images?.suffixDarkMode != nil { try await loadFromSingleFileWithGranularCache( filter: filter, onBatchProgress: onBatchProgress ) @@ -259,7 +259,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di filter: String? = nil, onBatchProgress: @escaping BatchProgressCallback ) async throws -> ImagesLoaderOutput { - let darkSuffix = params.common?.images?.darkModeSuffix ?? "_dark" + let darkSuffix = params.common?.images?.suffixDarkMode?.suffix ?? "_dark" let fileId = try requireLightFileId(entryFileId: config.entryFileId) if isRasterFormat, !useSVGSource { @@ -409,7 +409,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di onBatchProgress: @escaping BatchProgressCallback ) async throws -> ImagesLoaderResultWithHashes { let fileId = try requireLightFileId(entryFileId: config.entryFileId) - let darkSuffix = params.common?.images?.darkModeSuffix ?? "_dark" + let darkSuffix = params.common?.images?.suffixDarkMode?.suffix ?? "_dark" if isRasterFormat, !useSVGSource { // PNG source: Raster images (PNG/WebP) with granular cache diff --git a/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift b/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift new file mode 100644 index 00000000..61d2d660 --- /dev/null +++ b/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift @@ -0,0 +1,506 @@ +// swiftlint:disable file_length +import ExFigCore +import FigmaAPI +import Foundation +import Logging + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +// swiftlint:disable type_body_length + +/// Generates dark SVG variants from light SVGs by resolving Figma Variable bindings. +/// +/// Given light icon packs and a variables collection with light/dark modes, this generator: +/// 1. Fetches variable definitions from the Figma Variables API +/// 2. Fetches icon nodes to discover `boundVariables` on fills/strokes +/// 3. Resolves each variable's dark mode value (following alias chains) +/// 4. Downloads light SVGs, replaces hex colors, and writes dark SVGs to temp files +struct VariableModeDarkGenerator { + /// Maximum depth for resolving variable alias chains (prevents infinite recursion). + private static let maxAliasDepth = 10 + + struct Config { + let fileId: String + let collectionName: String + let lightModeName: String + let darkModeName: String + let primitivesModeName: String? + /// Separate file ID for loading variables (when primitives are in a library file). + let variablesFileId: String? + + init( + fileId: String, + collectionName: String, + lightModeName: String, + darkModeName: String, + primitivesModeName: String? = nil, + variablesFileId: String? = nil + ) { + precondition(!fileId.isEmpty, "VariableModeDarkGenerator.Config.fileId must not be empty") + self.fileId = fileId + self.collectionName = collectionName + self.lightModeName = lightModeName + self.darkModeName = darkModeName + self.primitivesModeName = primitivesModeName + self.variablesFileId = variablesFileId + } + } + + /// Resolved mode IDs for variable resolution. + struct ModeContext { + let lightModeId: String + let darkModeId: String + let primitivesModeId: String? + } + + let client: Client + let logger: Logger + let variablesCache: VariablesCache? + + init(client: Client, logger: Logger, variablesCache: VariablesCache? = nil) { + self.client = client + self.logger = logger + self.variablesCache = variablesCache + } + + /// Generates dark SVG variants by resolving variable bindings and replacing colors. + /// + /// - Parameters: + /// - lightPacks: Light mode icon packs (must be SVG format with Figma URLs). + /// - config: Variables collection configuration. + /// - Returns: Dark mode icon packs with modified SVGs saved to temp files. + func generateDarkVariants( + lightPacks: [ImagePack], + config: Config + ) async throws -> [ImagePack] { + guard !lightPacks.isEmpty else { return [] } + + // 1. Fetch variable definitions + let localMeta = try await loadVariables(fileId: config.fileId) + logger.debug("Variable-mode dark: loaded \(localMeta.variables.count) local variables") + + // When library file specified, load its variables for name-based cross-file resolution. + // Variable IDs are file-scoped — alias targets from the icons file don't exist in the + // library file by ID. We resolve by matching variable NAME across files. + let libMeta: VariablesMeta? + if let libFileId = config.variablesFileId, libFileId != config.fileId { + let lib = try await loadVariables(fileId: libFileId) + logger.debug("Variable-mode dark: loaded \(lib.variables.count) library variables from \(libFileId)") + libMeta = lib + } else { + libMeta = nil + } + + // Use local meta for ID-based lookups (matches node boundVariables) + let variablesMeta = localMeta + + // 2. Find collection and extract mode IDs + guard let modes = findModeIds(in: variablesMeta, config: config) else { + let names = variablesMeta.variableCollections.values.map(\.name).sorted() + logger.debug("Variable-mode dark: available collections: \(names)") + logger.warning(""" + Variables dark mode: collection '\(config.collectionName)' not found or missing \ + modes '\(config.lightModeName)'/'\(config.darkModeName)'. \ + Available collections: \(variablesMeta.variableCollections.values.map(\.name).sorted() + .joined(separator: ", ")) + """) + return [] + } + + logger.debug("Variable-mode dark: modes light=\(modes.lightModeId) dark=\(modes.darkModeId)") + + // 3. Fetch nodes to discover boundVariables on paints + let nodeIds = lightPacks.compactMap(\.nodeId) + logger.debug("Variable-mode dark: \(nodeIds.count)/\(lightPacks.count) packs have nodeIds") + + guard !nodeIds.isEmpty else { + logger.warning("Variable-mode dark generation: none of the light packs have node IDs, skipping") + return [] + } + + let nodeMap = try await fetchNodesBatched(fileId: config.fileId, nodeIds: nodeIds) + logger.debug("Variable-mode dark: fetched \(nodeMap.count) nodes") + + // 4. For each icon, build light→dark color map from boundVariables + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("exfig-variable-dark-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + + var cleanupNeeded = true + defer { + if cleanupNeeded { + try? FileManager.default.removeItem(at: tempDir) + } + } + + let ctx = ResolutionContext( + variablesMeta: variablesMeta, + libMeta: libMeta, + libNameIndex: libMeta.map { buildLibNameIndex(from: $0) }, + modes: modes, + darkModeName: config.darkModeName + ) + let darkPacks = try processLightPacks( + lightPacks, nodeMap: nodeMap, ctx: ctx, tempDir: tempDir + ) + + // Keep temp dir alive — caller consumes the URLs during export, then OS cleans /tmp + cleanupNeeded = false + return darkPacks + } + + // MARK: - Internal (testable) + + // swiftlint:disable cyclomatic_complexity + + func processLightPacks( + _ lightPacks: [ImagePack], + nodeMap: [String: Node], + ctx: ResolutionContext, + tempDir: URL + ) throws -> [ImagePack] { + var darkPacks: [ImagePack] = [] + + for pack in lightPacks { + guard let nodeId = pack.nodeId else { + logger.warning("Icon '\(pack.name)' has no node ID, skipping dark generation") + continue + } + + guard let node = nodeMap[nodeId] else { + logger.warning( + "Node '\(nodeId)' for icon '\(pack.name)' not returned by Figma API, skipping dark generation" + ) + continue + } + + let colorMap = buildColorMap(node: node, ctx: ctx, iconName: pack.name) + + guard !colorMap.isEmpty else { + logger.debug("Icon '\(pack.name)' has no variable-bound colors, skipping dark generation") + continue + } + + guard let darkPack = try buildDarkPack(for: pack, colorMap: colorMap, tempDir: tempDir) else { + continue + } + darkPacks.append(darkPack) + } + + logger.debug("Variable-mode dark: generated \(darkPacks.count)/\(lightPacks.count) dark packs") + return darkPacks + } + + // swiftlint:enable cyclomatic_complexity + + // MARK: - Private + + private func buildDarkPack( + for pack: ImagePack, + colorMap: [String: ColorReplacement], + tempDir: URL + ) throws -> ImagePack? { + guard let svgImage = pack.images.first else { + logger.warning("Icon '\(pack.name)' has no images, skipping dark generation") + return nil + } + + let svgData: Data + do { + svgData = try Data(contentsOf: svgImage.url) + } catch { + logger.warning("Failed to read SVG for icon '\(pack.name)': \(error.localizedDescription)") + return nil + } + + guard let svgContent = String(data: svgData, encoding: .utf8) else { + logger.warning("Icon '\(pack.name)' SVG is not valid UTF-8, skipping dark generation") + return nil + } + + let darkSVG = SVGColorReplacer.replaceColors(in: svgContent, colorMap: colorMap) + + let safeName = pack.name + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: " ", with: "_") + let tempURL = tempDir.appendingPathComponent("\(safeName)_dark.svg") + try Data(darkSVG.utf8).write(to: tempURL) + + return ImagePack( + name: pack.name, + images: [Image( + name: pack.name, + scale: .all, + url: tempURL, + format: "svg" + )], + platform: pack.platform, + nodeId: pack.nodeId, + fileId: pack.fileId + ) + } + + /// Builds a name→variable index from library meta, warning on duplicate names. + private func buildLibNameIndex(from meta: VariablesMeta) -> [String: VariableValue] { + let grouped = Dictionary(grouping: meta.variables.values, by: \.name) + var index: [String: VariableValue] = [:] + for (name, vars) in grouped { + if vars.count > 1 { + logger.debug("Library file has \(vars.count) variables named '\(name)', using first match") + } + index[name] = vars[0] + } + return index + } + + private func loadVariables(fileId: String) async throws -> VariablesMeta { + if let cache = variablesCache { + return try await cache.get(fileId: fileId) { [client] in + try await client.request(VariablesEndpoint(fileId: fileId)) + } + } + return try await client.request(VariablesEndpoint(fileId: fileId)) + } + + func findModeIds(in meta: VariablesMeta, config: Config) -> ModeContext? { + for collection in meta.variableCollections.values { + guard collection.name == config.collectionName else { continue } + + var lightModeId: String? + var darkModeId: String? + var primitivesModeId: String? + + for mode in collection.modes { + if mode.name == config.lightModeName { + lightModeId = mode.modeId + } else if mode.name == config.darkModeName { + darkModeId = mode.modeId + } else if mode.name == config.primitivesModeName { + primitivesModeId = mode.modeId + } + } + + guard let light = lightModeId, let dark = darkModeId else { continue } + return ModeContext(lightModeId: light, darkModeId: dark, primitivesModeId: primitivesModeId) + } + return nil + } + + /// Fetches nodes in batches of 100 (Figma API limit). + private func fetchNodesBatched( + fileId: String, + nodeIds: [String] + ) async throws -> [String: Node] { + var allNodes: [String: Node] = [:] + let batchSize = 100 + + for batchStart in stride(from: 0, to: nodeIds.count, by: batchSize) { + let batchEnd = min(batchStart + batchSize, nodeIds.count) + let batch = Array(nodeIds[batchStart ..< batchEnd]) + let endpoint = NodesEndpoint(fileId: fileId, nodeIds: batch) + let nodes = try await client.request(endpoint) + for (key, value) in nodes { + allNodes[key] = value + } + } + + return allNodes + } + + /// Context for cross-file variable resolution. + struct ResolutionContext { + let variablesMeta: VariablesMeta + let libMeta: VariablesMeta? + let libNameIndex: [String: VariableValue]? + let modes: ModeContext + let darkModeName: String + } + + /// Walks a node tree and collects light→dark color mappings from boundVariables on paints. + func buildColorMap( + node: Node, + ctx: ResolutionContext, + iconName: String + ) -> [String: ColorReplacement] { + var colorMap: [String: ColorReplacement] = [:] + collectBoundColors(from: node.document, ctx: ctx, colorMap: &colorMap, iconName: iconName) + return colorMap + } + + func collectBoundColors( + from document: Document, + ctx: ResolutionContext, + colorMap: inout [String: ColorReplacement], + iconName: String + ) { + for paint in document.fills { + collectFromPaint(paint, ctx: ctx, colorMap: &colorMap, iconName: iconName) + } + if let strokes = document.strokes { + for paint in strokes { + collectFromPaint(paint, ctx: ctx, colorMap: &colorMap, iconName: iconName) + } + } + if let children = document.children { + for child in children { + collectBoundColors(from: child, ctx: ctx, colorMap: &colorMap, iconName: iconName) + } + } + } + + func collectFromPaint( + _ paint: Paint, + ctx: ResolutionContext, + colorMap: inout [String: ColorReplacement], + iconName: String + ) { + guard let boundVars = paint.boundVariables, + let colorAlias = boundVars["color"], + let lightColor = paint.color + else { return } + + let lightHex = SVGColorReplacer.normalizeColor( + r: lightColor.r, + g: lightColor.g, + b: lightColor.b + ) + + // Try local resolution first (same file) + var darkColor = resolveDarkColor( + variableId: colorAlias.id, + modeId: ctx.modes.darkModeId, + variablesMeta: ctx.variablesMeta, + primitivesModeId: ctx.modes.primitivesModeId + ) + + // Cross-file fallback: find variable by name in library, resolve there + if darkColor == nil, let libMeta = ctx.libMeta, let libNameIndex = ctx.libNameIndex { + if let localVar = ctx.variablesMeta.variables[colorAlias.id] { + darkColor = resolveViaLibrary( + variableName: localVar.name, + libMeta: libMeta, + libNameIndex: libNameIndex, + darkModeName: ctx.darkModeName + ) + } + } + + if let darkColor, lightHex != darkColor.hex || darkColor.changesOpacity { + if let existing = colorMap[lightHex], existing.hex != darkColor.hex { + let msg = "#\(lightHex) → multiple dark: #\(existing.hex), #\(darkColor.hex)" + logger.warning("Icon '\(iconName)': \(msg)") + } + colorMap[lightHex] = darkColor + } + } + + /// Resolves a variable's dark color by finding it by name in the library file. + func resolveViaLibrary( + variableName: String, + libMeta: VariablesMeta, + libNameIndex: [String: VariableValue], + darkModeName: String + ) -> ColorReplacement? { + guard let libVar = libNameIndex[variableName] else { + logger.debug("Variable-mode dark: library fallback miss — '\(variableName)' not found in library") + return nil + } + guard let libCollection = libMeta.variableCollections[libVar.variableCollectionId] else { + let collId = libVar.variableCollectionId + logger.debug("Variable-mode dark: library collection '\(collId)' not found for '\(variableName)'") + return nil + } + + // Match mode by NAME (mode IDs are file-scoped and differ between files) + guard let libDarkModeId = libCollection.modes.first(where: { $0.name == darkModeName })?.modeId else { + logger + .debug( + "Variable-mode dark: library mode '\(darkModeName)' not found in collection '\(libCollection.name)'" + ) + return nil + } + + let result = resolveDarkColor( + variableId: libVar.id, + modeId: libDarkModeId, + variablesMeta: libMeta, + primitivesModeId: nil + ) + if let result { + logger + .debug("Variable-mode dark: resolved '\(variableName)' via library → #\(result.hex) a=\(result.alpha)") + } + return result + } + + /// Resolves a variable to its concrete color value in the given mode, following alias chains. + func resolveDarkColor( + variableId: String, + modeId: String, + variablesMeta: VariablesMeta, + primitivesModeId: String?, + depth: Int = 0 + ) -> ColorReplacement? { + guard depth < Self.maxAliasDepth else { + logger.warning("Variable alias chain exceeded depth limit (variableId: \(variableId))") + return nil + } + + guard let variable = variablesMeta.variables[variableId] else { + logger.debug("Variable '\(variableId)' not found in variables meta during dark resolution") + return nil + } + guard variable.deletedButReferenced != true else { + logger.debug("Variable '\(variable.name)' (\(variableId)) is deleted but referenced, skipping") + return nil + } + + // Try the requested mode first, fall back to default mode of the collection + let value = variable.valuesByMode[modeId] + ?? variablesMeta.variableCollections[variable.variableCollectionId] + .flatMap { collection in + variable.valuesByMode[collection.defaultModeId] + } + + switch value { + case let .color(color): + return ColorReplacement( + hex: SVGColorReplacer.normalizeColor(r: color.r, g: color.g, b: color.b), + alpha: color.a + ) + + case let .variableAlias(alias): + // Resolve alias — use primitives mode if available, else use the same mode + let resolvedVariable = variablesMeta.variables[alias.id] + let resolveModeId: String = if let primId = primitivesModeId { + primId + } else if let resolvedVar = resolvedVariable, + let collection = variablesMeta.variableCollections[resolvedVar.variableCollectionId] + { + collection.defaultModeId + } else { + modeId + } + + return resolveDarkColor( + variableId: alias.id, + modeId: resolveModeId, + variablesMeta: variablesMeta, + primitivesModeId: primitivesModeId, + depth: depth + 1 + ) + + case .none: + logger.debug("Variable '\(variable.name)' has no value for mode '\(modeId)'") + return nil + + default: + logger.debug("Variable '\(variable.name)' has non-color type, cannot resolve dark color") + return nil + } + } +} + +// swiftlint:enable type_body_length diff --git a/Sources/ExFigCLI/Loaders/VariablesCache.swift b/Sources/ExFigCLI/Loaders/VariablesCache.swift new file mode 100644 index 00000000..d3f0a6c1 --- /dev/null +++ b/Sources/ExFigCLI/Loaders/VariablesCache.swift @@ -0,0 +1,31 @@ +import FigmaAPI +import Foundation + +/// Deduplicating cache for Figma Variables API responses. +/// +/// Concurrent callers requesting the same `fileId` share a single in-flight `Task`. +/// First caller triggers the fetch; subsequent callers await the same result. +/// Failed tasks are evicted so subsequent callers can retry. +final class VariablesCache: @unchecked Sendable { + private let lock = NSLock() + private var tasks: [String: Task] = [:] + + func get( + fileId: String, + fetch: @escaping @Sendable () async throws -> VariablesMeta + ) async throws -> VariablesMeta { + let task: Task = lock.withLock { + if let existing = tasks[fileId] { return existing } + let newTask = Task { try await fetch() } + tasks[fileId] = newTask + return newTask + } + do { + return try await task.value + } catch { + // Evict failed tasks so subsequent callers can retry (e.g., transient 429 rate limit) + lock.withLock { tasks[fileId] = nil } + throw error + } + } +} diff --git a/Sources/ExFigCLI/MCP/MCPPrompts.swift b/Sources/ExFigCLI/MCP/MCPPrompts.swift index a3fc4c96..99bde2a6 100644 --- a/Sources/ExFigCLI/MCP/MCPPrompts.swift +++ b/Sources/ExFigCLI/MCP/MCPPrompts.swift @@ -97,6 +97,10 @@ - Figma file ID(s) for my design files - Output paths matching my project structure - Entry configurations for colors, icons, and/or images + - Dark mode approach for icons (if applicable): + * `darkFileId` — separate Figma file for dark icons + * `suffixDarkMode` — name suffix splitting (e.g., "_dark") + * `variablesDarkMode` — Figma Variable Modes (recommended when icons use variable bindings) First, validate the config with exfig_validate after creating it. """ diff --git a/Sources/ExFigCLI/MCP/MCPToolHandlers.swift b/Sources/ExFigCLI/MCP/MCPToolHandlers.swift index 184c69b8..3c526145 100644 --- a/Sources/ExFigCLI/MCP/MCPToolHandlers.swift +++ b/Sources/ExFigCLI/MCP/MCPToolHandlers.swift @@ -54,12 +54,14 @@ let platforms = buildPlatformSummary(config: config) let fileIDs = Array(config.getFileIds()).sorted() + let darkModes = buildDarkModeSummary(config: config) let summary = ValidateSummary( configPath: configPath, valid: true, platforms: platforms.isEmpty ? nil : platforms, - figmaFileIds: fileIDs.isEmpty ? nil : fileIDs + figmaFileIds: fileIDs.isEmpty ? nil : fileIDs, + darkMode: darkModes.isEmpty ? nil : darkModes ) return try .init(content: [.text(text: encodeJSON(summary), annotations: nil, _meta: nil)]) @@ -96,6 +98,36 @@ return platforms } + private static func buildDarkModeSummary(config: PKLConfig) -> [String] { + var approaches: Set = [] + + if config.figma?.darkFileId != nil { + approaches.insert("darkFileId") + } + + if config.common?.icons?.suffixDarkMode != nil { + approaches.insert("suffixDarkMode (icons)") + } + if config.common?.images?.suffixDarkMode != nil { + approaches.insert("suffixDarkMode (images)") + } + + func checkIconEntries(_ entries: [any Common_FrameSource]?) { + guard let entries else { return } + for entry in entries where entry.variablesDarkMode != nil { + approaches.insert("variablesDarkMode") + return + } + } + + checkIconEntries(config.ios?.icons) + checkIconEntries(config.android?.icons) + checkIconEntries(config.flutter?.icons) + checkIconEntries(config.web?.icons) + + return approaches.sorted() + } + // MARK: - Tokens Info private static func handleTokensInfo(params: CallTool.Parameters) async throws -> CallTool.Result { @@ -748,12 +780,14 @@ let valid: Bool var platforms: [String: EntrySummary]? var figmaFileIds: [String]? + var darkMode: [String]? enum CodingKeys: String, CodingKey { case configPath = "config_path" case valid case platforms case figmaFileIds = "figma_file_ids" + case darkMode = "dark_mode" } } diff --git a/Sources/ExFigCLI/Output/SVGColorReplacer.swift b/Sources/ExFigCLI/Output/SVGColorReplacer.swift new file mode 100644 index 00000000..64759978 --- /dev/null +++ b/Sources/ExFigCLI/Output/SVGColorReplacer.swift @@ -0,0 +1,116 @@ +import Foundation + +/// A resolved dark color with optional alpha override. +struct ColorReplacement: Equatable { + let hex: String + let alpha: Double + + /// Whether this replacement changes opacity (not fully opaque). + var changesOpacity: Bool { + alpha < 0.999 + } +} + +/// Replaces hex colors in SVG content based on a light→dark color map. +/// +/// Used by ``VariableModeDarkGenerator`` to create dark SVG variants +/// by substituting resolved light colors with their dark counterparts. +enum SVGColorReplacer { + /// Replaces hex colors in SVG content using the provided color map. + /// + /// Handles both RGB hex replacement and opacity changes. When the dark color + /// has alpha < 1.0, adds `-opacity` attributes to the SVG elements. + /// + /// - Parameters: + /// - svgContent: The SVG string to process. + /// - colorMap: A mapping of normalized 6-digit lowercase hex (no `#`) to dark replacement. + /// - Returns: The SVG string with colors replaced. + static func replaceColors(in svgContent: String, colorMap: [String: ColorReplacement]) -> String { + guard !colorMap.isEmpty else { return svgContent } + + var result = svgContent + + for (lightHex, replacement) in colorMap { + result = replaceHex(in: result, lightHex: lightHex, replacement: replacement) + } + + return result + } + + // swiftlint:disable function_body_length + + private static func replaceHex(in svg: String, lightHex: String, replacement: ColorReplacement) -> String { + var result = svg + let darkHex = replacement.hex + + if replacement.changesOpacity { + let opacityStr = String(format: "%.2g", replacement.alpha) + + // Attribute style: fill="#aabbcc" → fill="#darkHex" fill-opacity="0" + result = regexReplace( + in: result, + pattern: "(fill|stroke)(\\s*=\\s*[\"'])#\(lightHex)([\"'])", + template: "$1$2#\(darkHex)$3 $1-opacity=\"\(opacityStr)\"" + ) + // stop-color in gradients: stop-color="#aabbcc" → stop-color="#darkHex" stop-opacity="0" + result = regexReplace( + in: result, + pattern: "(stop-color)(\\s*=\\s*[\"'])#\(lightHex)([\"'])", + template: "$1$2#\(darkHex)$3 stop-opacity=\"\(opacityStr)\"" + ) + // CSS property style: fill:#aabbcc → fill:#darkHex;fill-opacity:0 + // Lookahead prevents partial match on 8-digit hex (e.g., #aabbccdd) + result = regexReplace( + in: result, + pattern: "(fill|stroke)(\\s*:\\s*)#\(lightHex)(?=[;\"'\\s]|$)", + template: "$1$2#\(darkHex);$1-opacity:\(opacityStr)" + ) + result = regexReplace( + in: result, + pattern: "(stop-color)(\\s*:\\s*)#\(lightHex)(?=[;\"'\\s]|$)", + template: "$1$2#\(darkHex);stop-opacity:\(opacityStr)" + ) + } else { + // Simple hex-only replacement (no opacity change) + let replacements: [(pattern: String, template: String)] = [ + ( + "(fill|stroke|stop-color|flood-color|lighting-color)(\\s*=\\s*[\"'])#\(lightHex)([\"'])", + "$1$2#\(darkHex)$3" + ), + ( + "(fill|stroke|stop-color|flood-color|lighting-color)(\\s*:\\s*)#\(lightHex)(?=[;\"'\\s]|$)", + "$1$2#\(darkHex)" + ), + ] + for (pattern, template) in replacements { + result = regexReplace(in: result, pattern: pattern, template: template) + } + } + + return result + } + + // swiftlint:enable function_body_length + + private static func regexReplace(in string: String, pattern: String, template: String) -> String { + do { + let regex = try NSRegularExpression(pattern: pattern, options: .caseInsensitive) + let range = NSRange(string.startIndex..., in: string) + return regex.stringByReplacingMatches(in: string, range: range, withTemplate: template) + } catch { + assertionFailure("Invalid regex pattern: \(pattern), error: \(error)") + FileHandle.standardError.write( + Data("[SVGColorReplacer] Invalid regex pattern: \(pattern), error: \(error)\n".utf8) + ) + return string + } + } + + /// Normalizes a ``FigmaAPI.PaintColor`` (RGBA 0–1) to a 6-digit lowercase hex string without `#`. + static func normalizeColor(r: Double, g: Double, b: Double) -> String { + let ri = min(255, max(0, Int(round(r * 255)))) + let gi = min(255, max(0, Int(round(g * 255)))) + let bi = min(255, max(0, Int(round(b * 255)))) + return String(format: "%02x%02x%02x", ri, gi, bi) + } +} diff --git a/Sources/ExFigCLI/Resources/Guides/DesignRequirements.md b/Sources/ExFigCLI/Resources/Guides/DesignRequirements.md index 6f6c9499..a3ef4896 100644 --- a/Sources/ExFigCLI/Resources/Guides/DesignRequirements.md +++ b/Sources/ExFigCLI/Resources/Guides/DesignRequirements.md @@ -177,6 +177,40 @@ Icons frame └── ic/24/close_dark ``` +**Variable Modes (per-entry, recommended for Figma Variables):** + +When icons use Figma Variable bindings for colors (e.g., fill bound to a `DesignTokens` collection +with Light/Dark modes), ExFig can auto-generate dark SVGs by resolving variable values: + +```pkl +import ".exfig/schemas/Common.pkl" + +// Single-file mode: all variables in the same file as icons +new iOS.IconsEntry { + figmaFrameName = "Icons" + variablesDarkMode = new Common.VariablesDarkMode { + collectionName = "DesignTokens" // exact collection name (case-sensitive) + lightModeName = "Light" // exact mode name + darkModeName = "Dark" // exact mode name + } +} + +// Cross-file mode: icon variables reference an external library +new iOS.IconsEntry { + figmaFrameName = "Icons" + variablesDarkMode = new Common.VariablesDarkMode { + collectionName = "DesignTokens" + lightModeName = "Light" + darkModeName = "Dark" + variablesFileId = "LIB_FILE_ID" // library containing primitive values + primitivesModeName = "Value" // mode in primitives collection (optional) + } +} +``` + +No naming conventions required — ExFig reads variable bindings directly from Figma nodes. +Supports alpha/opacity in color replacements. + ### Images #### Component Structure diff --git a/Sources/ExFigCLI/Resources/Schemas/Common.pkl b/Sources/ExFigCLI/Resources/Schemas/Common.pkl index 494d1628..488a8017 100644 --- a/Sources/ExFigCLI/Resources/Schemas/Common.pkl +++ b/Sources/ExFigCLI/Resources/Schemas/Common.pkl @@ -60,6 +60,36 @@ class PenpotSource { pathFilter: String? } +// MARK: - Dark Mode + +/// Dark mode via Figma Variable bindings (per-entry). +/// Resolves variable values in dark mode and replaces colors in exported SVGs. +class VariablesDarkMode { + /// Variable collection name (e.g., "DesignTokens"). + collectionName: String(isNotEmpty) + + /// Light mode name in the collection (e.g., "Light"). + lightModeName: String(isNotEmpty) + + /// Dark mode name in the collection (e.g., "Dark"). + darkModeName: String(isNotEmpty) + + /// Primitives mode for resolving variable aliases (e.g., "Value"). + primitivesModeName: String? + + /// Figma file ID containing the full variable chain (including primitives). + /// When variables reference an external library, specify the library file ID here. + /// Defaults to the entry's figmaFileId if not set. + variablesFileId: String? +} + +/// Dark mode via name suffix splitting (global, on common.icons/images/colors). +/// Icons/images with the suffix are treated as dark variants. +class SuffixDarkMode { + /// Dark mode name suffix (e.g., "_dark"). + suffix: String(isNotEmpty) = "_dark" +} + // MARK: - Cache /// Cache configuration for tracking Figma file versions. @@ -152,17 +182,19 @@ open class FrameSource extends NameProcessing { /// mirrored at runtime by the platform (iOS languageDirection, Android autoMirrored). /// Set to null to disable variant-based RTL detection. rtlProperty: String? = "RTL" + + /// Dark mode generation via Figma Variable bindings. + /// When set, dark SVG variants are generated by resolving variable bindings + /// and replacing colors in the light SVG. + variablesDarkMode: VariablesDarkMode? } // MARK: - Common Settings /// Common colors settings shared across platforms. class Colors extends NameProcessing { - /// Use single file for all color modes. - useSingleFile: Boolean? - - /// Suffix for dark mode colors. - darkModeSuffix: String? + /// Dark mode via name suffix splitting. + suffixDarkMode: SuffixDarkMode? /// Suffix for light high contrast colors. lightHCModeSuffix: String? @@ -179,11 +211,8 @@ class Icons extends NameProcessing { /// Figma page name to filter icons by. figmaPageName: String? - /// Use single file for all icon modes. - useSingleFile: Boolean? - - /// Suffix for dark mode icons. - darkModeSuffix: String? + /// Dark mode via name suffix splitting. + suffixDarkMode: SuffixDarkMode? /// If true, exit with error when pathData exceeds 32,767 bytes (AAPT limit). strictPathValidation: Boolean? @@ -197,11 +226,8 @@ class Images extends NameProcessing { /// Figma page name to filter images by. figmaPageName: String? - /// Use single file for all image modes. - useSingleFile: Boolean? - - /// Suffix for dark mode images. - darkModeSuffix: String? + /// Dark mode via name suffix splitting. + suffixDarkMode: SuffixDarkMode? } /// Common typography settings shared across platforms. diff --git a/Sources/ExFigCLI/Resources/Schemas/examples/exfig-ios.pkl b/Sources/ExFigCLI/Resources/Schemas/examples/exfig-ios.pkl index ee0a4bc5..d1c8b6a4 100644 --- a/Sources/ExFigCLI/Resources/Schemas/examples/exfig-ios.pkl +++ b/Sources/ExFigCLI/Resources/Schemas/examples/exfig-ios.pkl @@ -49,5 +49,19 @@ ios = new iOS.iOSConfig { templatesPath = "BrandKit/Templates" // rtlProperty = "RTL" // default; set to null to disable variant-based RTL detection } + // Variable-mode dark: generate dark SVGs from Figma Variable bindings. + // variablesFileId is required when variables alias to primitives in an external library. + new iOS.IconsEntry { + figmaFrameName = "DoubleColor" + format = "svg" + assetsFolder = "DoubleColorIcons" + nameStyle = "camelCase" + variablesDarkMode = new Common.VariablesDarkMode { + collectionName = "DesignTokens" + lightModeName = "Light" + darkModeName = "Dark" + variablesFileId = "LIBRARY_FILE_ID" + } + } } } diff --git a/Sources/ExFigCLI/Resources/androidConfig.swift b/Sources/ExFigCLI/Resources/androidConfig.swift index d7a93b89..1e773157 100644 --- a/Sources/ExFigCLI/Resources/androidConfig.swift +++ b/Sources/ExFigCLI/Resources/androidConfig.swift @@ -31,10 +31,8 @@ common = new Common.CommonConfig { nameValidateRegexp = "^([a-zA-Z_]+)$" // RegExp pattern for: background, background_primary, widget_primary_background // [optional] RegExp pattern for replacing. Supports only $n nameReplaceRegexp = "color_$1" - // [optional] Extract light and dark mode colors from the lightFileId specified in the figma config. Defaults to false - useSingleFile = false - // [optional] If useSingleFile is true, customize the suffix to denote a dark mode color. Defaults to '_dark' - darkModeSuffix = "_dark" + // [optional] Extract light and dark mode from the lightFileId using name suffix splitting + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } // [optional] Use variablesColors instead of colors to export colors from Figma Variables. Cannot be used together with colors. // variablesColors = new Common.VariablesColors { @@ -68,10 +66,8 @@ common = new Common.CommonConfig { nameValidateRegexp = "^(ic)_(\\d\\d)_([a-z0-9_]+)$" // RegExp pattern for: ic_24_icon_name, ic_24_icon // [optional] RegExp pattern for replacing. Supports only $n nameReplaceRegexp = "icon_$2_$1" - // [optional] Extract light and dark mode icons from the lightFileId specified in the figma config. Defaults to false - useSingleFile = false - // [optional] If useSingleFile is true, customize the suffix to denote a dark mode icons. Defaults to '_dark' - darkModeSuffix = "_dark" + // [optional] Extract light and dark mode icons from the lightFileId using name suffix splitting + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } // [optional] images = new Common.Images { @@ -84,10 +80,8 @@ common = new Common.CommonConfig { nameValidateRegexp = "^(img)_([a-z0-9_]+)$" // RegExp pattern for: img_image_name // [optional] RegExp pattern for replacing. Supports only $n nameReplaceRegexp = "image_$2" - // [optional] Extract light and dark mode images from the lightFileId specified in the figma config. Defaults to false - useSingleFile = false - // [optional] If useSingleFile is true, customize the suffix to denote a dark mode images. Defaults to '_dark' - darkModeSuffix = "_dark" + // [optional] Extract light and dark mode images from the lightFileId using name suffix splitting + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } // [optional] typography = new Common.Typography { diff --git a/Sources/ExFigCLI/Resources/flutterConfig.swift b/Sources/ExFigCLI/Resources/flutterConfig.swift index 42c0a552..f86c455e 100644 --- a/Sources/ExFigCLI/Resources/flutterConfig.swift +++ b/Sources/ExFigCLI/Resources/flutterConfig.swift @@ -27,10 +27,8 @@ common = new Common.CommonConfig { nameValidateRegexp = "^([a-zA-Z_]+)$" // RegExp pattern for: background, background_primary, widget_primary_background // [optional] RegExp pattern for replacing. Supports only $n nameReplaceRegexp = "color_$1" - // [optional] Extract light and dark mode colors from the lightFileId specified in the figma config. Defaults to false - useSingleFile = false - // [optional] If useSingleFile is true, customize the suffix to denote a dark mode color. Defaults to '_dark' - darkModeSuffix = "_dark" + // [optional] Extract light and dark mode from the lightFileId using name suffix splitting + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } // [optional] Use variablesColors instead of colors to export colors from Figma Variables. Cannot be used together with colors. // variablesColors = new Common.VariablesColors { @@ -60,10 +58,8 @@ common = new Common.CommonConfig { nameValidateRegexp = "^(ic)_(\\d\\d)_([a-z0-9_]+)$" // RegExp pattern for: ic_24_icon_name, ic_24_icon // [optional] RegExp pattern for replacing. Supports only $n nameReplaceRegexp = "icon_$2_$1" - // [optional] Extract light and dark mode icons from the lightFileId specified in the figma config. Defaults to false - useSingleFile = false - // [optional] If useSingleFile is true, customize the suffix to denote a dark mode icons. Defaults to '_dark' - darkModeSuffix = "_dark" + // [optional] Extract light and dark mode icons from the lightFileId using name suffix splitting + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } // [optional] images = new Common.Images { @@ -76,10 +72,8 @@ common = new Common.CommonConfig { nameValidateRegexp = "^(img)_([a-z0-9_]+)$" // RegExp pattern for: img_image_name // [optional] RegExp pattern for replacing. Supports only $n nameReplaceRegexp = "image_$2" - // [optional] Extract light and dark mode images from the lightFileId specified in the figma config. Defaults to false - useSingleFile = false - // [optional] If useSingleFile is true, customize the suffix to denote a dark mode images. Defaults to '_dark' - darkModeSuffix = "_dark" + // [optional] Extract light and dark mode images from the lightFileId using name suffix splitting + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } diff --git a/Sources/ExFigCLI/Resources/iOSConfig.swift b/Sources/ExFigCLI/Resources/iOSConfig.swift index 6d28aeed..bf76bf71 100644 --- a/Sources/ExFigCLI/Resources/iOSConfig.swift +++ b/Sources/ExFigCLI/Resources/iOSConfig.swift @@ -31,13 +31,11 @@ common = new Common.CommonConfig { nameValidateRegexp = "^([a-zA-Z_]+)$" // RegExp pattern for: background, background_primary, widget_primary_background // [optional] RegExp pattern for replacing. Supports only $n nameReplaceRegexp = "color_$1" - // [optional] Extract light and dark mode colors from the lightFileId specified in the figma config. Defaults to false - useSingleFile = false - // [optional] If useSingleFile is true, customize the suffix to denote a dark mode color. Defaults to '_dark' - darkModeSuffix = "_dark" - // [optional] If useSingleFile is true, customize the suffix to denote a light high contrast color. Defaults to '_lightHC' + // [optional] Extract light and dark mode from the lightFileId using name suffix splitting + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } + // [optional] If suffixDarkMode is set, customize the suffix to denote a light high contrast color. Defaults to '_lightHC' // lightHCModeSuffix = "_lightHC" - // [optional] If useSingleFile is true, customize the suffix to denote a dark high contrast color. Defaults to '_darkHC' + // [optional] If suffixDarkMode is set, customize the suffix to denote a dark high contrast color. Defaults to '_darkHC' // darkHCModeSuffix = "_darkHC" } // [optional] Use variablesColors instead of colors to export colors from Figma Variables. Cannot be used together with colors. @@ -72,10 +70,8 @@ common = new Common.CommonConfig { nameValidateRegexp = "^(ic)_(\\d\\d)_([a-z0-9_]+)$" // RegExp pattern for: ic_24_icon_name, ic_24_icon // [optional] RegExp pattern for replacing. Supports only $n nameReplaceRegexp = "icon_$2_$1" - // [optional] Extract light and dark mode icons from the lightFileId specified in the figma config. Defaults to false - useSingleFile = false - // [optional] If useSingleFile is true, customize the suffix to denote a dark mode icons. Defaults to '_dark' - darkModeSuffix = "_dark" + // [optional] Extract light and dark mode icons from the lightFileId using name suffix splitting + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } // [optional] images = new Common.Images { @@ -88,10 +84,8 @@ common = new Common.CommonConfig { nameValidateRegexp = "^(img)_([a-z0-9_]+)$" // RegExp pattern for: img_image_name // [optional] RegExp pattern for replacing. Supports only $n nameReplaceRegexp = "image_$2" - // [optional] Extract light and dark mode images from the lightFileId specified in the figma config. Defaults to false - useSingleFile = false - // [optional] If useSingleFile is true, customize the suffix to denote a dark mode images. Defaults to '_dark' - darkModeSuffix = "_dark" + // [optional] Extract light and dark mode images from the lightFileId using name suffix splitting + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } // [optional] typography = new Common.Typography { diff --git a/Sources/ExFigCLI/Resources/webConfig.swift b/Sources/ExFigCLI/Resources/webConfig.swift index 6588a9cb..326950a1 100644 --- a/Sources/ExFigCLI/Resources/webConfig.swift +++ b/Sources/ExFigCLI/Resources/webConfig.swift @@ -28,10 +28,8 @@ common = new Common.CommonConfig { nameValidateRegexp = "^([a-zA-Z_]+)$" // [optional] RegExp pattern for replacing. Supports only $n nameReplaceRegexp = "color_$1" - // [optional] Extract light and dark mode colors from the lightFileId. Defaults to false - useSingleFile = false - // [optional] If useSingleFile is true, customize the suffix for dark mode. Defaults to '_dark' - darkModeSuffix = "_dark" + // [optional] Extract light and dark mode from the lightFileId using name suffix splitting + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } // [optional] Use variablesColors to export colors from Figma Variables. // variablesColors = new Common.VariablesColors { @@ -61,10 +59,8 @@ common = new Common.CommonConfig { nameValidateRegexp = "^(ic)_(\\d\\d)_([a-z0-9_]+)$" // [optional] RegExp pattern for replacing. Supports only $n nameReplaceRegexp = "icon_$2_$1" - // [optional] Extract light and dark mode icons from the lightFileId. Defaults to false - useSingleFile = false - // [optional] If useSingleFile is true, customize the suffix for dark mode. Defaults to '_dark' - darkModeSuffix = "_dark" + // [optional] Extract light and dark mode icons from the lightFileId using name suffix splitting + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } // [optional] images = new Common.Images { @@ -77,10 +73,8 @@ common = new Common.CommonConfig { nameValidateRegexp = "^(img)_([a-z0-9_]+)$" // [optional] RegExp pattern for replacing. Supports only $n nameReplaceRegexp = "image_$2" - // [optional] Extract light and dark mode images from the lightFileId. Defaults to false - useSingleFile = false - // [optional] If useSingleFile is true, customize the suffix for dark mode. Defaults to '_dark' - darkModeSuffix = "_dark" + // [optional] Extract light and dark mode images from the lightFileId using name suffix splitting + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } diff --git a/Sources/ExFigCLI/Source/FigmaComponentsSource.swift b/Sources/ExFigCLI/Source/FigmaComponentsSource.swift index 5c8db06a..7b1cfff5 100644 --- a/Sources/ExFigCLI/Source/FigmaComponentsSource.swift +++ b/Sources/ExFigCLI/Source/FigmaComponentsSource.swift @@ -10,6 +10,8 @@ struct FigmaComponentsSource: ComponentsSource { let platform: Platform let logger: Logger let filter: String? + let variablesCache: VariablesCache? + let componentsCache: ComponentsCache? func loadIcons(from input: IconsSourceInput) async throws -> IconsLoadOutput { let config = IconsLoaderConfig( @@ -31,9 +33,44 @@ struct FigmaComponentsSource: ComponentsSource { logger: logger, config: config ) + loader.componentsCache = componentsCache let result = try await loader.load(filter: filter) + // Variable-mode dark generation: resolve variable bindings and replace colors in SVGs + let hasPartialConfig = input.variablesCollectionName != nil + || input.variablesLightModeName != nil + || input.variablesDarkModeName != nil + if let collectionName = input.variablesCollectionName, + let lightModeName = input.variablesLightModeName, + let darkModeName = input.variablesDarkModeName + { + guard let fileId = input.figmaFileId ?? params.figma?.lightFileId, !fileId.isEmpty else { + logger.warning("Variable-mode dark generation requires a Figma file ID, skipping") + return IconsLoadOutput(light: result.light, dark: []) + } + let generator = VariableModeDarkGenerator(client: client, logger: logger, variablesCache: variablesCache) + let darkPacks = try await generator.generateDarkVariants( + lightPacks: result.light, + config: .init( + fileId: fileId, + collectionName: collectionName, + lightModeName: lightModeName, + darkModeName: darkModeName, + primitivesModeName: input.variablesPrimitivesModeName, + variablesFileId: input.variablesFileId + ) + ) + return IconsLoadOutput(light: result.light, dark: darkPacks) + } else if hasPartialConfig { + let col = input.variablesCollectionName ?? "nil" + let light = input.variablesLightModeName ?? "nil" + let dark = input.variablesDarkModeName ?? "nil" + logger.warning( + "Variable-mode dark: incomplete config — collection=\(col) light=\(light) dark=\(dark)" + ) + } + return IconsLoadOutput( light: result.light, dark: result.dark ?? [] @@ -60,6 +97,7 @@ struct FigmaComponentsSource: ComponentsSource { logger: logger, config: config ) + loader.componentsCache = componentsCache let result = try await loader.load(filter: filter) diff --git a/Sources/ExFigCLI/Source/SourceFactory.swift b/Sources/ExFigCLI/Source/SourceFactory.swift index 0032ba52..ff2a9df9 100644 --- a/Sources/ExFigCLI/Source/SourceFactory.swift +++ b/Sources/ExFigCLI/Source/SourceFactory.swift @@ -32,7 +32,9 @@ enum SourceFactory { platform: Platform, logger: Logger, filter: String?, - ui: TerminalUI + ui: TerminalUI, + variablesCache: VariablesCache? = nil, + componentsCache: ComponentsCache? = nil ) throws -> any ComponentsSource { switch sourceKind { case .figma: @@ -42,7 +44,9 @@ enum SourceFactory { params: params, platform: platform, logger: logger, - filter: filter + filter: filter, + variablesCache: variablesCache, + componentsCache: componentsCache ) case .penpot: return PenpotComponentsSource(ui: ui) diff --git a/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift b/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift index 3653d005..3b9bf5d4 100644 --- a/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift +++ b/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift @@ -45,6 +45,8 @@ extension ExFigCommand.ExportIcons { guard let sourceKind = entries.first?.resolvedSourceKind else { throw ExFigError.configurationError("No entries provided for icons export") } + let variablesCache = VariablesCache() + let componentsCache = ComponentsCache() let componentsSource = try SourceFactory.createComponentsSource( for: sourceKind, client: client, @@ -52,7 +54,9 @@ extension ExFigCommand.ExportIcons { platform: .ios, logger: ExFigCommand.logger, filter: filter, - ui: ui + ui: ui, + variablesCache: variablesCache, + componentsCache: componentsCache ) let context = IconsExportContextImpl( @@ -64,7 +68,9 @@ extension ExFigCommand.ExportIcons { isBatchMode: batchMode, fileDownloader: fileDownloader, granularCacheManager: granularCacheManager, - platform: .ios + platform: .ios, + variablesCache: variablesCache, + componentsCache: componentsCache ) // Export via plugin (returns IconsExportResult with hashes) @@ -130,6 +136,8 @@ extension ExFigCommand.ExportIcons { guard let sourceKind = entries.first?.resolvedSourceKind else { throw ExFigError.configurationError("No entries provided for icons export") } + let variablesCache = VariablesCache() + let componentsCache = ComponentsCache() let componentsSource = try SourceFactory.createComponentsSource( for: sourceKind, client: client, @@ -137,7 +145,9 @@ extension ExFigCommand.ExportIcons { platform: .android, logger: ExFigCommand.logger, filter: filter, - ui: ui + ui: ui, + variablesCache: variablesCache, + componentsCache: componentsCache ) let context = IconsExportContextImpl( @@ -149,7 +159,9 @@ extension ExFigCommand.ExportIcons { isBatchMode: batchMode, fileDownloader: fileDownloader, granularCacheManager: granularCacheManager, - platform: .android + platform: .android, + variablesCache: variablesCache, + componentsCache: componentsCache ) let exporter = AndroidIconsExporter() @@ -189,6 +201,8 @@ extension ExFigCommand.ExportIcons { guard let sourceKind = entries.first?.resolvedSourceKind else { throw ExFigError.configurationError("No entries provided for icons export") } + let variablesCache = VariablesCache() + let componentsCache = ComponentsCache() let componentsSource = try SourceFactory.createComponentsSource( for: sourceKind, client: client, @@ -196,7 +210,9 @@ extension ExFigCommand.ExportIcons { platform: .flutter, logger: ExFigCommand.logger, filter: filter, - ui: ui + ui: ui, + variablesCache: variablesCache, + componentsCache: componentsCache ) let context = IconsExportContextImpl( @@ -208,7 +224,9 @@ extension ExFigCommand.ExportIcons { isBatchMode: batchMode, fileDownloader: fileDownloader, granularCacheManager: granularCacheManager, - platform: .flutter + platform: .flutter, + variablesCache: variablesCache, + componentsCache: componentsCache ) let exporter = FlutterIconsExporter() @@ -248,6 +266,8 @@ extension ExFigCommand.ExportIcons { guard let sourceKind = entries.first?.resolvedSourceKind else { throw ExFigError.configurationError("No entries provided for icons export") } + let variablesCache = VariablesCache() + let componentsCache = ComponentsCache() let componentsSource = try SourceFactory.createComponentsSource( for: sourceKind, client: client, @@ -255,7 +275,9 @@ extension ExFigCommand.ExportIcons { platform: .web, logger: ExFigCommand.logger, filter: filter, - ui: ui + ui: ui, + variablesCache: variablesCache, + componentsCache: componentsCache ) let context = IconsExportContextImpl( @@ -267,7 +289,9 @@ extension ExFigCommand.ExportIcons { isBatchMode: batchMode, fileDownloader: fileDownloader, granularCacheManager: granularCacheManager, - platform: .web + platform: .web, + variablesCache: variablesCache, + componentsCache: componentsCache ) let exporter = WebIconsExporter() diff --git a/Sources/ExFigCLI/TerminalUI/ProgressBar.swift b/Sources/ExFigCLI/TerminalUI/ProgressBar.swift index b1975004..d26736bf 100644 --- a/Sources/ExFigCLI/TerminalUI/ProgressBar.swift +++ b/Sources/ExFigCLI/TerminalUI/ProgressBar.swift @@ -77,12 +77,12 @@ final class ProgressBar: @unchecked Sendable { // Build initial frame synchronously let initialFrame = buildFrame(currentValue: 0) - // Set animation flag and initial frame synchronously BEFORE dispatching - // This ensures log messages see the animation state immediately - TerminalOutputManager.shared.startAnimation(initialFrame: initialFrame) + if useAnimations { + // Set animation flag and initial frame synchronously BEFORE dispatching + // This ensures log messages see the animation state immediately + TerminalOutputManager.shared.startAnimation(initialFrame: initialFrame) - Self.renderQueue.async { [self] in - if useAnimations { + Self.renderQueue.async { [self] in TerminalOutputManager.shared.writeDirect(ANSICodes.hideCursor) // First frame already rendered by startAnimation(), start timer for next frames diff --git a/Sources/ExFigCLI/TerminalUI/Spinner.swift b/Sources/ExFigCLI/TerminalUI/Spinner.swift index 00e9c7bc..fd813dfb 100644 --- a/Sources/ExFigCLI/TerminalUI/Spinner.swift +++ b/Sources/ExFigCLI/TerminalUI/Spinner.swift @@ -56,10 +56,10 @@ final class Spinner: @unchecked Sendable { // Set animation flag and initial frame synchronously BEFORE dispatching // This ensures log messages see the animation state immediately - TerminalOutputManager.shared.startAnimation(initialFrame: "\(coloredFrame) \(initialMessage)") + if useAnimations { + TerminalOutputManager.shared.startAnimation(initialFrame: "\(coloredFrame) \(initialMessage)") - Self.renderQueue.async { [self] in - if useAnimations { + Self.renderQueue.async { [self] in TerminalOutputManager.shared.writeDirect(ANSICodes.hideCursor) // First frame already rendered by startAnimation(), start timer for next frames @@ -73,10 +73,9 @@ final class Spinner: @unchecked Sendable { } self.timer = timer timer.resume() - } else { - TerminalOutputManager.shared.writeDirect("\(initialMessage)\n") } } + // Non-animated mode: no start output — stop() prints the final result } /// Update the spinner message diff --git a/Sources/ExFigCLI/TerminalUI/TerminalUI.swift b/Sources/ExFigCLI/TerminalUI/TerminalUI.swift index f9c94e28..2c35ac42 100644 --- a/Sources/ExFigCLI/TerminalUI/TerminalUI.swift +++ b/Sources/ExFigCLI/TerminalUI/TerminalUI.swift @@ -77,16 +77,6 @@ final class TerminalUI: Sendable { } } - /// Print a formatted AssetsValidatorWarning - func warning(_ warning: AssetsValidatorWarning) { - let formatter = WarningFormatter() - let formattedMessage = formatter.format(warning) - - guard !formattedMessage.isEmpty else { return } - - self.warning(formattedMessage) - } - /// Print a formatted ExFigWarning func warning(_ warning: ExFigWarning) { let formatter = ExFigWarningFormatter() diff --git a/Sources/ExFigConfig/CLAUDE.md b/Sources/ExFigConfig/CLAUDE.md index a70120c0..92d1d7ce 100644 --- a/Sources/ExFigConfig/CLAUDE.md +++ b/Sources/ExFigConfig/CLAUDE.md @@ -75,6 +75,17 @@ Uses `static let _typeRegistration` for thread-safe dispatch_once semantics. When adding new PKL types to schemas, regenerate with `codegen:pkl` and add the new type to the registration list in `PKLEvaluator.swift`. `registerPklTypes` has a hard `precondition(_shared == nil)` — must be called before any `TypeRegistry.get()`. +**Scope:** `registerPklTypes` is a performance optimization that bypasses O(N) type scanning. It does NOT affect decoding of concrete `Decodable` types — pkl-swift decodes those via synthesized `init(from:)` directly. Missing entries only affect polymorphic `PklAny` decoding. Still list all types for completeness and the exhaustive count test in `PKLEvaluatorTests`. + +### Debugging PKL Deserialization + +When a PKL field appears as `nil` at runtime but `pkl eval --format json` shows it: + +1. Write a `PKLEvaluatorTests` test that evaluates a fixture with the field and asserts non-nil +2. If test passes: issue is downstream (entry bridge, source input, or runtime binary mismatch) +3. If test fails: issue is in pkl-swift decoding (check CodingKeys, property name mismatch) +4. Add diagnostic log in `FigmaComponentsSource` or bridge layer to trace values at runtime + ## Codegen Gotchas - PKL `"kebab-case"` raw values become `.kebabCase` in Swift (not `.kebab_case`) diff --git a/Sources/ExFigConfig/Generated/Android.pkl.swift b/Sources/ExFigConfig/Generated/Android.pkl.swift index 3baf3828..c676aefc 100644 --- a/Sources/ExFigConfig/Generated/Android.pkl.swift +++ b/Sources/ExFigConfig/Generated/Android.pkl.swift @@ -284,6 +284,11 @@ extension Android { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? + /// Dark mode generation via Figma Variable bindings. + /// When set, dark SVG variants are generated by resolving variable bindings + /// and replacing colors in the light SVG. + public var variablesDarkMode: Common.VariablesDarkMode? + /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -308,6 +313,7 @@ extension Android { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, + variablesDarkMode: Common.VariablesDarkMode?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -328,6 +334,7 @@ extension Android { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty + self.variablesDarkMode = variablesDarkMode self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } @@ -394,6 +401,11 @@ extension Android { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? + /// Dark mode generation via Figma Variable bindings. + /// When set, dark SVG variants are generated by resolving variable bindings + /// and replacing colors in the light SVG. + public var variablesDarkMode: Common.VariablesDarkMode? + /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -416,6 +428,7 @@ extension Android { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, + variablesDarkMode: Common.VariablesDarkMode?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -434,6 +447,7 @@ extension Android { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty + self.variablesDarkMode = variablesDarkMode self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } diff --git a/Sources/ExFigConfig/Generated/Common.pkl.swift b/Sources/ExFigConfig/Generated/Common.pkl.swift index 2297a715..9f2f875a 100644 --- a/Sources/ExFigConfig/Generated/Common.pkl.swift +++ b/Sources/ExFigConfig/Generated/Common.pkl.swift @@ -43,6 +43,8 @@ public protocol Common_FrameSource: Common_NameProcessing { var figmaFileId: String? { get } var rtlProperty: String? { get } + + var variablesDarkMode: Common.VariablesDarkMode? { get } } extension Common { @@ -222,6 +224,56 @@ extension Common { } } + /// Dark mode via Figma Variable bindings (per-entry). + /// Resolves variable values in dark mode and replaces colors in exported SVGs. + public struct VariablesDarkMode: PklRegisteredType, Decodable, Hashable, Sendable { + public static let registeredIdentifier: String = "Common#VariablesDarkMode" + + /// Variable collection name (e.g., "DesignTokens"). + public var collectionName: String + + /// Light mode name in the collection (e.g., "Light"). + public var lightModeName: String + + /// Dark mode name in the collection (e.g., "Dark"). + public var darkModeName: String + + /// Primitives mode for resolving variable aliases (e.g., "Value"). + public var primitivesModeName: String? + + /// Figma file ID containing the full variable chain (including primitives). + /// When variables reference an external library, specify the library file ID here. + /// Defaults to the entry's figmaFileId if not set. + public var variablesFileId: String? + + public init( + collectionName: String, + lightModeName: String, + darkModeName: String, + primitivesModeName: String?, + variablesFileId: String? + ) { + self.collectionName = collectionName + self.lightModeName = lightModeName + self.darkModeName = darkModeName + self.primitivesModeName = primitivesModeName + self.variablesFileId = variablesFileId + } + } + + /// Dark mode via name suffix splitting (global, on common.icons/images/colors). + /// Icons/images with the suffix are treated as dark variants. + public struct SuffixDarkMode: PklRegisteredType, Decodable, Hashable, Sendable { + public static let registeredIdentifier: String = "Common#SuffixDarkMode" + + /// Dark mode name suffix (e.g., "_dark"). + public var suffix: String + + public init(suffix: String) { + self.suffix = suffix + } + } + /// Cache configuration for tracking Figma file versions. public struct Cache: PklRegisteredType, Decodable, Hashable, Sendable { public static let registeredIdentifier: String = "Common#Cache" @@ -291,6 +343,11 @@ extension Common { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? + /// Dark mode generation via Figma Variable bindings. + /// When set, dark SVG variants are generated by resolving variable bindings + /// and replacing colors in the light SVG. + public var variablesDarkMode: VariablesDarkMode? + /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -304,6 +361,7 @@ extension Common { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, + variablesDarkMode: VariablesDarkMode?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -313,6 +371,7 @@ extension Common { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty + self.variablesDarkMode = variablesDarkMode self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } @@ -322,11 +381,8 @@ extension Common { public struct Colors: NameProcessing { public static let registeredIdentifier: String = "Common#Colors" - /// Use single file for all color modes. - public var useSingleFile: Bool? - - /// Suffix for dark mode colors. - public var darkModeSuffix: String? + /// Dark mode via name suffix splitting. + public var suffixDarkMode: SuffixDarkMode? /// Suffix for light high contrast colors. public var lightHCModeSuffix: String? @@ -341,15 +397,13 @@ extension Common { public var nameReplaceRegexp: String? public init( - useSingleFile: Bool?, - darkModeSuffix: String?, + suffixDarkMode: SuffixDarkMode?, lightHCModeSuffix: String?, darkHCModeSuffix: String?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { - self.useSingleFile = useSingleFile - self.darkModeSuffix = darkModeSuffix + self.suffixDarkMode = suffixDarkMode self.lightHCModeSuffix = lightHCModeSuffix self.darkHCModeSuffix = darkHCModeSuffix self.nameValidateRegexp = nameValidateRegexp @@ -367,11 +421,8 @@ extension Common { /// Figma page name to filter icons by. public var figmaPageName: String? - /// Use single file for all icon modes. - public var useSingleFile: Bool? - - /// Suffix for dark mode icons. - public var darkModeSuffix: String? + /// Dark mode via name suffix splitting. + public var suffixDarkMode: SuffixDarkMode? /// If true, exit with error when pathData exceeds 32,767 bytes (AAPT limit). public var strictPathValidation: Bool? @@ -385,16 +436,14 @@ extension Common { public init( figmaFrameName: String?, figmaPageName: String?, - useSingleFile: Bool?, - darkModeSuffix: String?, + suffixDarkMode: SuffixDarkMode?, strictPathValidation: Bool?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName - self.useSingleFile = useSingleFile - self.darkModeSuffix = darkModeSuffix + self.suffixDarkMode = suffixDarkMode self.strictPathValidation = strictPathValidation self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp @@ -411,11 +460,8 @@ extension Common { /// Figma page name to filter images by. public var figmaPageName: String? - /// Use single file for all image modes. - public var useSingleFile: Bool? - - /// Suffix for dark mode images. - public var darkModeSuffix: String? + /// Dark mode via name suffix splitting. + public var suffixDarkMode: SuffixDarkMode? /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -426,15 +472,13 @@ extension Common { public init( figmaFrameName: String?, figmaPageName: String?, - useSingleFile: Bool?, - darkModeSuffix: String?, + suffixDarkMode: SuffixDarkMode?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName - self.useSingleFile = useSingleFile - self.darkModeSuffix = darkModeSuffix + self.suffixDarkMode = suffixDarkMode self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } diff --git a/Sources/ExFigConfig/Generated/Flutter.pkl.swift b/Sources/ExFigConfig/Generated/Flutter.pkl.swift index ac91b0ae..871b6aa4 100644 --- a/Sources/ExFigConfig/Generated/Flutter.pkl.swift +++ b/Sources/ExFigConfig/Generated/Flutter.pkl.swift @@ -154,6 +154,11 @@ extension Flutter { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? + /// Dark mode generation via Figma Variable bindings. + /// When set, dark SVG variants are generated by resolving variable bindings + /// and replacing colors in the light SVG. + public var variablesDarkMode: Common.VariablesDarkMode? + /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -172,6 +177,7 @@ extension Flutter { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, + variablesDarkMode: Common.VariablesDarkMode?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -186,6 +192,7 @@ extension Flutter { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty + self.variablesDarkMode = variablesDarkMode self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } @@ -251,6 +258,11 @@ extension Flutter { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? + /// Dark mode generation via Figma Variable bindings. + /// When set, dark SVG variants are generated by resolving variable bindings + /// and replacing colors in the light SVG. + public var variablesDarkMode: Common.VariablesDarkMode? + /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -273,6 +285,7 @@ extension Flutter { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, + variablesDarkMode: Common.VariablesDarkMode?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -291,6 +304,7 @@ extension Flutter { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty + self.variablesDarkMode = variablesDarkMode self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } diff --git a/Sources/ExFigConfig/Generated/Web.pkl.swift b/Sources/ExFigConfig/Generated/Web.pkl.swift index eb744d5e..5ae9e0cf 100644 --- a/Sources/ExFigConfig/Generated/Web.pkl.swift +++ b/Sources/ExFigConfig/Generated/Web.pkl.swift @@ -166,6 +166,11 @@ extension Web { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? + /// Dark mode generation via Figma Variable bindings. + /// When set, dark SVG variants are generated by resolving variable bindings + /// and replacing colors in the light SVG. + public var variablesDarkMode: Common.VariablesDarkMode? + /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -185,6 +190,7 @@ extension Web { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, + variablesDarkMode: Common.VariablesDarkMode?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -200,6 +206,7 @@ extension Web { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty + self.variablesDarkMode = variablesDarkMode self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } @@ -253,6 +260,11 @@ extension Web { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? + /// Dark mode generation via Figma Variable bindings. + /// When set, dark SVG variants are generated by resolving variable bindings + /// and replacing colors in the light SVG. + public var variablesDarkMode: Common.VariablesDarkMode? + /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -271,6 +283,7 @@ extension Web { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, + variablesDarkMode: Common.VariablesDarkMode?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -285,6 +298,7 @@ extension Web { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty + self.variablesDarkMode = variablesDarkMode self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } diff --git a/Sources/ExFigConfig/Generated/iOS.pkl.swift b/Sources/ExFigConfig/Generated/iOS.pkl.swift index c79edd63..bfc3dbc8 100644 --- a/Sources/ExFigConfig/Generated/iOS.pkl.swift +++ b/Sources/ExFigConfig/Generated/iOS.pkl.swift @@ -253,6 +253,11 @@ extension iOS { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? + /// Dark mode generation via Figma Variable bindings. + /// When set, dark SVG variants are generated by resolving variable bindings + /// and replacing colors in the light SVG. + public var variablesDarkMode: Common.VariablesDarkMode? + /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -279,6 +284,7 @@ extension iOS { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, + variablesDarkMode: Common.VariablesDarkMode?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -301,6 +307,7 @@ extension iOS { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty + self.variablesDarkMode = variablesDarkMode self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } @@ -385,6 +392,11 @@ extension iOS { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? + /// Dark mode generation via Figma Variable bindings. + /// When set, dark SVG variants are generated by resolving variable bindings + /// and replacing colors in the light SVG. + public var variablesDarkMode: Common.VariablesDarkMode? + /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -413,6 +425,7 @@ extension iOS { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, + variablesDarkMode: Common.VariablesDarkMode?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -437,6 +450,7 @@ extension iOS { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty + self.variablesDarkMode = variablesDarkMode self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } diff --git a/Sources/ExFigConfig/PKL/PKLEvaluator.swift b/Sources/ExFigConfig/PKL/PKLEvaluator.swift index 01788ad1..e23243d7 100644 --- a/Sources/ExFigConfig/PKL/PKLEvaluator.swift +++ b/Sources/ExFigConfig/PKL/PKLEvaluator.swift @@ -46,6 +46,8 @@ public enum PKLEvaluator { Common.TokensFile.self, Common.PenpotSource.self, Common.WebpOptions.self, + Common.VariablesDarkMode.self, + Common.SuffixDarkMode.self, Common.Cache.self, Common.Colors.self, Common.Icons.self, diff --git a/Sources/ExFigCore/CLAUDE.md b/Sources/ExFigCore/CLAUDE.md index 120ae25a..a7fa9675 100644 --- a/Sources/ExFigCore/CLAUDE.md +++ b/Sources/ExFigCore/CLAUDE.md @@ -35,6 +35,7 @@ Exporter.export*(entries, platformConfig, context) - `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 +- `IconsSourceInput` has variable-mode dark fields: `variablesCollectionName`, `variablesLightModeName`, `variablesDarkModeName`, `variablesPrimitivesModeName` - When adding a new `ColorsSourceConfig` subtype: update `spinnerLabel` switch in `ExportContext.swift` Implementations live in `Sources/ExFigCLI/Source/` — `FigmaColorsSource`, `TokensFileColorsSource`, `PenpotColorsSource`, `PenpotComponentsSource`, `PenpotTypographySource`, `FigmaComponentsSource`, `FigmaTypographySource`, `SourceFactory`. diff --git a/Sources/ExFigCore/Protocol/IconsExportContext.swift b/Sources/ExFigCore/Protocol/IconsExportContext.swift index b080c7f3..740e24e4 100644 --- a/Sources/ExFigCore/Protocol/IconsExportContext.swift +++ b/Sources/ExFigCore/Protocol/IconsExportContext.swift @@ -103,6 +103,21 @@ public struct IconsSourceInput: Sendable { /// Penpot instance base URL (used when sourceKind == .penpot). public let penpotBaseURL: String? + /// Variable collection name for dark mode generation via Figma Variables. + public let variablesCollectionName: String? + + /// Light mode name in the variables collection. + public let variablesLightModeName: String? + + /// Dark mode name in the variables collection. + public let variablesDarkModeName: String? + + /// Primitives mode name for resolving variable aliases. + public let variablesPrimitivesModeName: String? + + /// Figma file ID for loading variables (when primitives are in a separate library file). + public let variablesFileId: String? + public init( sourceKind: DesignSourceKind = .figma, figmaFileId: String? = nil, @@ -119,7 +134,12 @@ public struct IconsSourceInput: Sendable { rtlProperty: String? = "RTL", nameValidateRegexp: String? = nil, nameReplaceRegexp: String? = nil, - penpotBaseURL: String? = nil + penpotBaseURL: String? = nil, + variablesCollectionName: String? = nil, + variablesLightModeName: String? = nil, + variablesDarkModeName: String? = nil, + variablesPrimitivesModeName: String? = nil, + variablesFileId: String? = nil ) { self.sourceKind = sourceKind self.figmaFileId = figmaFileId @@ -137,6 +157,11 @@ public struct IconsSourceInput: Sendable { self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp self.penpotBaseURL = penpotBaseURL + self.variablesCollectionName = variablesCollectionName + self.variablesLightModeName = variablesLightModeName + self.variablesDarkModeName = variablesDarkModeName + self.variablesPrimitivesModeName = variablesPrimitivesModeName + self.variablesFileId = variablesFileId } } @@ -273,6 +298,9 @@ public struct IconsExportResult: Sendable { /// Number of icons successfully exported. public let count: Int + /// Number of dark mode variants generated. + public let darkCount: Int + /// Number of icons skipped due to granular cache (unchanged). public let skippedCount: Int @@ -284,30 +312,34 @@ public struct IconsExportResult: Sendable { public init( count: Int, + darkCount: Int = 0, skippedCount: Int = 0, computedHashes: [String: [String: String]] = [:], allAssetMetadata: [AssetMetadata] = [] ) { self.count = count + self.darkCount = darkCount self.skippedCount = skippedCount self.computedHashes = computedHashes self.allAssetMetadata = allAssetMetadata } /// Creates a simple result with just count (no granular cache). - public static func simple(count: Int) -> IconsExportResult { - IconsExportResult(count: count) + public static func simple(count: Int, darkCount: Int = 0) -> IconsExportResult { + IconsExportResult(count: count, darkCount: darkCount) } /// Merges multiple results into one. public static func merge(_ results: [IconsExportResult]) -> IconsExportResult { var totalCount = 0 + var totalDark = 0 var totalSkipped = 0 var allHashes: [String: [String: String]] = [:] var allMetadata: [AssetMetadata] = [] for result in results { totalCount += result.count + totalDark += result.darkCount totalSkipped += result.skippedCount // Merge hashes @@ -324,6 +356,7 @@ public struct IconsExportResult: Sendable { return IconsExportResult( count: totalCount, + darkCount: totalDark, skippedCount: totalSkipped, computedHashes: allHashes, allAssetMetadata: allMetadata diff --git a/Tests/ExFigTests/Fixtures/PKL/valid-config.pkl b/Tests/ExFigTests/Fixtures/PKL/valid-config.pkl index a9ff2401..cf596e45 100644 --- a/Tests/ExFigTests/Fixtures/PKL/valid-config.pkl +++ b/Tests/ExFigTests/Fixtures/PKL/valid-config.pkl @@ -26,4 +26,18 @@ ios = new iOS.iOSConfig { nameStyle = "camelCase" } } + + icons = new Listing { + new iOS.IconsEntry { + figmaFrameName = "TestIcons" + format = "svg" + assetsFolder = "Icons" + variablesDarkMode = new Common.VariablesDarkMode { + collectionName = "TestCollection" + lightModeName = "Light" + darkModeName = "Dark" + variablesFileId = "lib-file-123" + } + } + } } diff --git a/Tests/ExFigTests/Helpers/TestHelpers.swift b/Tests/ExFigTests/Helpers/TestHelpers.swift index 84d63366..d58141be 100644 --- a/Tests/ExFigTests/Helpers/TestHelpers.swift +++ b/Tests/ExFigTests/Helpers/TestHelpers.swift @@ -33,17 +33,13 @@ extension PKLConfig.Figma { extension PKLConfig.Common.Colors { /// Creates a PKLConfig.Common.Colors for testing. static func make( - useSingleFile: Bool? = nil, - darkModeSuffix: String? = nil, + suffixDarkMode: String? = nil, lightHCModeSuffix: String? = nil, darkHCModeSuffix: String? = nil ) -> PKLConfig.Common.Colors { var components: [String] = [] - if let useSingleFile { - components.append("\"useSingleFile\": \(useSingleFile)") - } - if let darkModeSuffix { - components.append("\"darkModeSuffix\": \"\(darkModeSuffix)\"") + if let suffixDarkMode { + components.append("\"suffixDarkMode\": { \"suffix\": \"\(suffixDarkMode)\" }") } if let lightHCModeSuffix { components.append("\"lightHCModeSuffix\": \"\(lightHCModeSuffix)\"") @@ -191,13 +187,12 @@ extension PKLConfig { iconsPageName: String? = nil, imagesFrameName: String? = nil, imagesPageName: String? = nil, - useSingleFileIcons: Bool? = nil, - useSingleFileImages: Bool? = nil, - iconsDarkModeSuffix: String? = nil + iconsSuffixDarkMode: String? = nil, + imagesSuffixDarkMode: String? = nil ) -> PKLConfig { var commonComponents: [String] = [] - if iconsFrameName != nil || iconsPageName != nil || useSingleFileIcons != nil || iconsDarkModeSuffix != nil { + if iconsFrameName != nil || iconsPageName != nil || iconsSuffixDarkMode != nil { var iconParts: [String] = [] if let frameName = iconsFrameName { iconParts.append("\"figmaFrameName\": \"\(frameName)\"") @@ -205,16 +200,13 @@ extension PKLConfig { if let pageName = iconsPageName { iconParts.append("\"figmaPageName\": \"\(pageName)\"") } - if let useSingle = useSingleFileIcons { - iconParts.append("\"useSingleFile\": \(useSingle)") - } - if let darkSuffix = iconsDarkModeSuffix { - iconParts.append("\"darkModeSuffix\": \"\(darkSuffix)\"") + if let suffix = iconsSuffixDarkMode { + iconParts.append("\"suffixDarkMode\": { \"suffix\": \"\(suffix)\" }") } commonComponents.append("\"icons\": { \(iconParts.joined(separator: ", ")) }") } - if imagesFrameName != nil || imagesPageName != nil || useSingleFileImages != nil { + if imagesFrameName != nil || imagesPageName != nil || imagesSuffixDarkMode != nil { var imageParts: [String] = [] if let frameName = imagesFrameName { imageParts.append("\"figmaFrameName\": \"\(frameName)\"") @@ -222,8 +214,8 @@ extension PKLConfig { if let pageName = imagesPageName { imageParts.append("\"figmaPageName\": \"\(pageName)\"") } - if let useSingle = useSingleFileImages { - imageParts.append("\"useSingleFile\": \(useSingle)") + if let suffix = imagesSuffixDarkMode { + imageParts.append("\"suffixDarkMode\": { \"suffix\": \"\(suffix)\" }") } commonComponents.append("\"images\": { \(imageParts.joined(separator: ", ")) }") } diff --git a/Tests/ExFigTests/Input/EnumBridgingTests.swift b/Tests/ExFigTests/Input/EnumBridgingTests.swift index 851bcda3..3651a2b2 100644 --- a/Tests/ExFigTests/Input/EnumBridgingTests.swift +++ b/Tests/ExFigTests/Input/EnumBridgingTests.swift @@ -120,6 +120,7 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -165,6 +166,7 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -206,6 +208,7 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -235,6 +238,7 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -270,6 +274,7 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -297,6 +302,7 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -361,6 +367,7 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -395,6 +402,7 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) diff --git a/Tests/ExFigTests/Input/PenpotDesignSourceTests.swift b/Tests/ExFigTests/Input/PenpotDesignSourceTests.swift index 5a2e0081..a24184b6 100644 --- a/Tests/ExFigTests/Input/PenpotDesignSourceTests.swift +++ b/Tests/ExFigTests/Input/PenpotDesignSourceTests.swift @@ -28,6 +28,7 @@ final class FrameSourceResolvedSourceKindTests: XCTestCase { figmaPageName: nil, figmaFileId: "figma-file-id", rtlProperty: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -57,6 +58,7 @@ final class FrameSourceResolvedSourceKindTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -86,6 +88,7 @@ final class FrameSourceResolvedSourceKindTests: XCTestCase { figmaPageName: nil, figmaFileId: "figma-file-id", rtlProperty: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -115,6 +118,7 @@ final class FrameSourceResolvedSourceKindTests: XCTestCase { figmaPageName: nil, figmaFileId: "figma-file-id", rtlProperty: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -142,6 +146,7 @@ final class FrameSourceResolvedSourceKindTests: XCTestCase { figmaPageName: nil, figmaFileId: "figma-file-id", rtlProperty: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -171,6 +176,7 @@ final class FrameSourceResolvedSourceKindTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -198,6 +204,7 @@ final class FrameSourceResolvedSourceKindTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) diff --git a/Tests/ExFigTests/Loaders/ColorsLoaderTests.swift b/Tests/ExFigTests/Loaders/ColorsLoaderTests.swift index d694217c..cc1775fd 100644 --- a/Tests/ExFigTests/Loaders/ColorsLoaderTests.swift +++ b/Tests/ExFigTests/Loaders/ColorsLoaderTests.swift @@ -98,7 +98,7 @@ final class ColorsLoaderTests: XCTestCase { mockClient.setResponse(styles, for: StylesEndpoint.self) mockClient.setResponse(nodes, for: NodesEndpoint.self) - let colorParams = PKLConfig.Common.Colors.make(useSingleFile: true) + let colorParams = PKLConfig.Common.Colors.make(suffixDarkMode: "_dark") let loader = ColorsLoader( client: mockClient, figmaParams: .make(lightFileId: "single-file"), @@ -134,7 +134,7 @@ final class ColorsLoaderTests: XCTestCase { mockClient.setResponse(styles, for: StylesEndpoint.self) mockClient.setResponse(nodes, for: NodesEndpoint.self) - let colorParams = PKLConfig.Common.Colors.make(useSingleFile: true, darkModeSuffix: "-night") + let colorParams = PKLConfig.Common.Colors.make(suffixDarkMode: "-night") let loader = ColorsLoader( client: mockClient, figmaParams: .make(lightFileId: "single-file"), diff --git a/Tests/ExFigTests/Loaders/IconsLoaderGranularCachePairingTests.swift b/Tests/ExFigTests/Loaders/IconsLoaderGranularCachePairingTests.swift index 94cf4ee3..88143ed1 100644 --- a/Tests/ExFigTests/Loaders/IconsLoaderGranularCachePairingTests.swift +++ b/Tests/ExFigTests/Loaders/IconsLoaderGranularCachePairingTests.swift @@ -48,7 +48,7 @@ final class IconsLoaderGranularCachePairingTests: XCTestCase { let granularManager = GranularCacheManager(client: mockClient, cache: cache) let params = PKLConfig.make( lightFileId: "file123", iconsFrameName: "Icons", - useSingleFileIcons: true, iconsDarkModeSuffix: "-dark" + iconsSuffixDarkMode: "-dark" ) let loader = IconsLoader(client: mockClient, params: params, platform: .ios, logger: logger) loader.granularCacheManager = granularManager @@ -111,7 +111,7 @@ final class IconsLoaderGranularCachePairingTests: XCTestCase { let granularManager = GranularCacheManager(client: mockClient, cache: cache) let params = PKLConfig.make( lightFileId: "file123", iconsFrameName: "Icons", - useSingleFileIcons: true, iconsDarkModeSuffix: "-dark" + iconsSuffixDarkMode: "-dark" ) let loader = IconsLoader(client: mockClient, params: params, platform: .ios, logger: logger) loader.granularCacheManager = granularManager @@ -159,7 +159,7 @@ final class IconsLoaderGranularCachePairingTests: XCTestCase { let granularManager = GranularCacheManager(client: mockClient, cache: cache) let params = PKLConfig.make( lightFileId: "file123", iconsFrameName: "Icons", - useSingleFileIcons: true, iconsDarkModeSuffix: "-dark" + iconsSuffixDarkMode: "-dark" ) let loader = IconsLoader(client: mockClient, params: params, platform: .ios, logger: logger) loader.granularCacheManager = granularManager @@ -207,7 +207,7 @@ final class IconsLoaderGranularCachePairingTests: XCTestCase { let granularManager = GranularCacheManager(client: mockClient, cache: cache) let params = PKLConfig.make( lightFileId: "file123", iconsFrameName: "Icons", - useSingleFileIcons: true, iconsDarkModeSuffix: "-dark" + iconsSuffixDarkMode: "-dark" ) let loader = IconsLoader(client: mockClient, params: params, platform: .ios, logger: logger) loader.granularCacheManager = granularManager diff --git a/Tests/ExFigTests/Loaders/VariableModeDarkGeneratorTests.swift b/Tests/ExFigTests/Loaders/VariableModeDarkGeneratorTests.swift new file mode 100644 index 00000000..c534ed72 --- /dev/null +++ b/Tests/ExFigTests/Loaders/VariableModeDarkGeneratorTests.swift @@ -0,0 +1,630 @@ +// swiftlint:disable file_length +@testable import ExFigCLI +import ExFigCore +import FigmaAPI +import Logging +import XCTest + +// MARK: - Mock Client + +private final class MockFigmaClient: Client, @unchecked Sendable { + var requestCount = 0 + + func request(_: T) async throws -> T.Content { + requestCount += 1 + fatalError("MockFigmaClient should not make real requests in unit tests") + } +} + +private func makeGenerator() -> VariableModeDarkGenerator { + VariableModeDarkGenerator( + client: MockFigmaClient(), + logger: Logger(label: "test") + ) +} + +// MARK: - findModeIds & resolveDarkColor Tests + +final class VariableModeDarkGeneratorResolutionTests: XCTestCase { + private var generator: VariableModeDarkGenerator! + + override func setUp() { + super.setUp() + generator = makeGenerator() + } + + // MARK: - findModeIds + + func testFindModeIdsMatchesCollectionAndModes() { + let meta = VariablesMeta.make( + collectionName: "Theme", + modes: [("m1", "Light"), ("m2", "Dark")], + variables: [] + ) + let config = VariableModeDarkGenerator.Config( + fileId: "file1", + collectionName: "Theme", + lightModeName: "Light", + darkModeName: "Dark" + ) + + let result = generator.findModeIds(in: meta, config: config) + XCTAssertNotNil(result) + XCTAssertEqual(result?.lightModeId, "m1") + XCTAssertEqual(result?.darkModeId, "m2") + XCTAssertNil(result?.primitivesModeId) + } + + func testFindModeIdsReturnsNilWhenCollectionMissing() { + let meta = VariablesMeta.make( + collectionName: "Theme", + modes: [("m1", "Light"), ("m2", "Dark")], + variables: [] + ) + let config = VariableModeDarkGenerator.Config( + fileId: "file1", + collectionName: "NonExistent", + lightModeName: "Light", + darkModeName: "Dark" + ) + + XCTAssertNil(generator.findModeIds(in: meta, config: config)) + } + + func testFindModeIdsReturnsNilWhenModeMissing() { + let meta = VariablesMeta.make( + collectionName: "Theme", + modes: [("m1", "Light"), ("m2", "Dim")], + variables: [] + ) + let config = VariableModeDarkGenerator.Config( + fileId: "file1", + collectionName: "Theme", + lightModeName: "Light", + darkModeName: "Dark" + ) + + XCTAssertNil(generator.findModeIds(in: meta, config: config)) + } + + func testFindModeIdsWithPrimitivesMode() { + let meta = VariablesMeta.make( + collectionName: "Theme", + modes: [("m1", "Light"), ("m2", "Dark"), ("m3", "Primitives")], + variables: [] + ) + let config = VariableModeDarkGenerator.Config( + fileId: "file1", + collectionName: "Theme", + lightModeName: "Light", + darkModeName: "Dark", + primitivesModeName: "Primitives" + ) + + let result = generator.findModeIds(in: meta, config: config) + XCTAssertNotNil(result) + XCTAssertEqual(result?.primitivesModeId, "m3") + } + + // MARK: - resolveDarkColor: Direct color + + func testResolveDarkColorDirectColor() { + let meta = VariablesMeta.make( + collectionName: "Theme", + modes: [("light", "Light"), ("dark", "Dark")], + variables: [ + (id: "v1", name: "primary", valuesByMode: [ + "light": (r: 1.0, g: 0.0, b: 0.0, a: 1.0), + "dark": (r: 0.0, g: 0.0, b: 1.0, a: 1.0), + ]), + ] + ) + + let result = generator.resolveDarkColor( + variableId: "VariableID:v1", + modeId: "dark", + variablesMeta: meta, + primitivesModeId: nil + ) + + XCTAssertNotNil(result) + XCTAssertEqual(result?.hex, "0000ff") + XCTAssertEqual(result?.alpha, 1.0) + } + + // MARK: - resolveDarkColor: Alias chain + + func testResolveDarkColorFollowsAlias() { + let meta = VariablesMeta.makeWithAliases( + collectionName: "Theme", + modes: [("light", "Light"), ("dark", "Dark")], + variables: [ + (id: "semantic", name: "primary", collectionId: nil, valuesByMode: [ + "light": .color(r: 1.0, g: 0.0, b: 0.0, a: 1.0), + "dark": .alias("primitive"), + ]), + (id: "primitive", name: "blue-500", collectionId: nil, valuesByMode: [ + "light": .color(r: 0.0, g: 0.0, b: 1.0, a: 1.0), + "dark": .color(r: 0.0, g: 0.0, b: 0.8, a: 1.0), + ]), + ], + primitiveCollections: [] + ) + + let result = generator.resolveDarkColor( + variableId: "VariableID:semantic", + modeId: "dark", + variablesMeta: meta, + primitivesModeId: nil + ) + + // Alias target resolves using collection's defaultModeId ("light") since no primitivesModeId set + XCTAssertNotNil(result) + XCTAssertEqual(result?.hex, "0000ff") + } + + func testResolveDarkColorMultiHopAlias() { + let meta = VariablesMeta.makeWithAliases( + collectionName: "Theme", + modes: [("light", "Light"), ("dark", "Dark")], + variables: [ + (id: "a", name: "a", collectionId: nil, valuesByMode: ["dark": .alias("b")]), + (id: "b", name: "b", collectionId: nil, valuesByMode: [ + "light": .alias("c"), + "dark": .alias("c"), + ]), + (id: "c", name: "c", collectionId: nil, valuesByMode: [ + "light": .color(r: 0.0, g: 1.0, b: 0.0, a: 0.5), + ]), + ], + primitiveCollections: [] + ) + + let result = generator.resolveDarkColor( + variableId: "VariableID:a", + modeId: "dark", + variablesMeta: meta, + primitivesModeId: nil + ) + + XCTAssertNotNil(result) + XCTAssertEqual(result?.hex, "00ff00") + XCTAssertEqual(result?.alpha, 0.5) + } + + // MARK: - resolveDarkColor: Depth limit + + func testResolveDarkColorDepthLimitReturnsNil() { + var variables: [(id: String, name: String, collectionId: String?, valuesByMode: [String: TestVariableValue])] = + [] + for i in 0 ..< 12 { + let nextId = i < 11 ? "\(i + 1)" : "end" + variables.append((id: "\(i)", name: "v\(i)", collectionId: nil, valuesByMode: ["dark": .alias(nextId)])) + } + variables.append((id: "end", name: "end", collectionId: nil, valuesByMode: [ + "dark": .color(r: 1.0, g: 0.0, b: 0.0, a: 1.0), + ])) + + let meta = VariablesMeta.makeWithAliases( + collectionName: "Theme", + modes: [("light", "Light"), ("dark", "Dark")], + variables: variables, + primitiveCollections: [] + ) + + let result = generator.resolveDarkColor( + variableId: "VariableID:0", + modeId: "dark", + variablesMeta: meta, + primitivesModeId: nil + ) + + XCTAssertNil(result, "Should return nil when alias chain exceeds depth limit") + } + + // MARK: - resolveDarkColor: Deleted variable + + func testResolveDarkColorSkipsDeletedVariable() { + let meta = VariablesMeta.makeWithAliases( + collectionName: "Theme", + modes: [("light", "Light"), ("dark", "Dark")], + variables: [ + (id: "deleted", name: "old-color", collectionId: nil, valuesByMode: [ + "dark": .color(r: 1.0, g: 0.0, b: 0.0, a: 1.0), + ]), + ], + primitiveCollections: [], + deletedVariableIds: ["deleted"] + ) + + let result = generator.resolveDarkColor( + variableId: "VariableID:deleted", + modeId: "dark", + variablesMeta: meta, + primitivesModeId: nil + ) + + XCTAssertNil(result, "Should skip deleted variables") + } + + func testResolveDarkColorReturnsNilForUnknownVariable() { + let meta = VariablesMeta.make( + collectionName: "Theme", + modes: [("light", "Light"), ("dark", "Dark")], + variables: [] + ) + + let result = generator.resolveDarkColor( + variableId: "VariableID:nonexistent", + modeId: "dark", + variablesMeta: meta, + primitivesModeId: nil + ) + + XCTAssertNil(result) + } + + // MARK: - resolveDarkColor: Fallback to defaultModeId + + func testResolveDarkColorFallsBackToDefaultMode() { + let meta = VariablesMeta.make( + collectionName: "Theme", + modes: [("default", "Light"), ("dark", "Dark")], + variables: [ + (id: "v1", name: "bg", valuesByMode: [ + "default": (r: 0.5, g: 0.5, b: 0.5, a: 1.0), + ]), + ] + ) + + let result = generator.resolveDarkColor( + variableId: "VariableID:v1", + modeId: "dark", + variablesMeta: meta, + primitivesModeId: nil + ) + + XCTAssertNotNil(result) + XCTAssertEqual(result?.hex, "808080") + } + + // MARK: - resolveDarkColor: Alpha / opacity + + func testResolveDarkColorWithAlpha() { + let meta = VariablesMeta.make( + collectionName: "Theme", + modes: [("light", "Light"), ("dark", "Dark")], + variables: [ + (id: "v1", name: "transparent-bg", valuesByMode: [ + "dark": (r: 214.0 / 255.0, g: 251.0 / 255.0, b: 148.0 / 255.0, a: 0.0), + ]), + ] + ) + + let result = generator.resolveDarkColor( + variableId: "VariableID:v1", + modeId: "dark", + variablesMeta: meta, + primitivesModeId: nil + ) + + XCTAssertNotNil(result) + XCTAssertEqual(result?.hex, "d6fb94") + XCTAssertEqual(result?.alpha, 0.0) + XCTAssertTrue(result?.changesOpacity == true) + } +} + +// MARK: - buildColorMap & resolveViaLibrary Tests + +final class VariableModeDarkGeneratorColorMapTests: XCTestCase { + private var generator: VariableModeDarkGenerator! + + override func setUp() { + super.setUp() + generator = makeGenerator() + } + + func testBuildColorMapExtractsFromBoundVariables() throws { + let nodeJson = """ + { + "document": { + "id": "node1", + "name": "icon", + "fills": [ + { + "type": "SOLID", + "color": { "r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0 }, + "boundVariables": { + "color": { "id": "VariableID:v1", "type": "VARIABLE_ALIAS" } + } + } + ] + } + } + """ + let node = try JSONCodec.decode(Node.self, from: Data(nodeJson.utf8)) + + let meta = VariablesMeta.make( + collectionName: "Theme", + modes: [("light", "Light"), ("dark", "Dark")], + variables: [ + (id: "v1", name: "red", valuesByMode: [ + "light": (r: 1.0, g: 0.0, b: 0.0, a: 1.0), + "dark": (r: 0.0, g: 0.0, b: 1.0, a: 1.0), + ]), + ] + ) + + let ctx = VariableModeDarkGenerator.ResolutionContext( + variablesMeta: meta, + libMeta: nil, + libNameIndex: nil, + modes: .init(lightModeId: "light", darkModeId: "dark", primitivesModeId: nil), + darkModeName: "Dark" + ) + + let colorMap = generator.buildColorMap(node: node, ctx: ctx, iconName: "test-icon") + XCTAssertEqual(colorMap.count, 1) + XCTAssertEqual(colorMap["ff0000"]?.hex, "0000ff") + } + + func testBuildColorMapExtractsFromStrokes() throws { + let nodeJson = """ + { + "document": { + "id": "node1", + "name": "icon", + "fills": [], + "strokes": [ + { + "type": "SOLID", + "color": { "r": 0.0, "g": 1.0, "b": 0.0, "a": 1.0 }, + "boundVariables": { + "color": { "id": "VariableID:v1", "type": "VARIABLE_ALIAS" } + } + } + ] + } + } + """ + let node = try JSONCodec.decode(Node.self, from: Data(nodeJson.utf8)) + + let meta = VariablesMeta.make( + collectionName: "Theme", + modes: [("light", "Light"), ("dark", "Dark")], + variables: [ + (id: "v1", name: "green", valuesByMode: [ + "light": (r: 0.0, g: 1.0, b: 0.0, a: 1.0), + "dark": (r: 1.0, g: 1.0, b: 0.0, a: 1.0), + ]), + ] + ) + + let ctx = VariableModeDarkGenerator.ResolutionContext( + variablesMeta: meta, + libMeta: nil, + libNameIndex: nil, + modes: .init(lightModeId: "light", darkModeId: "dark", primitivesModeId: nil), + darkModeName: "Dark" + ) + + let colorMap = generator.buildColorMap(node: node, ctx: ctx, iconName: "test-icon") + XCTAssertEqual(colorMap["00ff00"]?.hex, "ffff00") + } + + func testBuildColorMapRecursesIntoChildren() throws { + let nodeJson = """ + { + "document": { + "id": "parent", + "name": "group", + "fills": [], + "children": [ + { + "id": "child1", + "name": "rect", + "fills": [ + { + "type": "SOLID", + "color": { "r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0 }, + "boundVariables": { + "color": { "id": "VariableID:v1", "type": "VARIABLE_ALIAS" } + } + } + ] + } + ] + } + } + """ + let node = try JSONCodec.decode(Node.self, from: Data(nodeJson.utf8)) + + let meta = VariablesMeta.make( + collectionName: "Theme", + modes: [("light", "Light"), ("dark", "Dark")], + variables: [ + (id: "v1", name: "red", valuesByMode: [ + "light": (r: 1.0, g: 0.0, b: 0.0, a: 1.0), + "dark": (r: 0.5, g: 0.5, b: 0.5, a: 1.0), + ]), + ] + ) + + let ctx = VariableModeDarkGenerator.ResolutionContext( + variablesMeta: meta, + libMeta: nil, + libNameIndex: nil, + modes: .init(lightModeId: "light", darkModeId: "dark", primitivesModeId: nil), + darkModeName: "Dark" + ) + + let colorMap = generator.buildColorMap(node: node, ctx: ctx, iconName: "test-icon") + XCTAssertEqual(colorMap.count, 1) + XCTAssertEqual(colorMap["ff0000"]?.hex, "808080") + } + + // MARK: - resolveViaLibrary + + func testResolveViaLibraryMatchesByName() { + let libMeta = VariablesMeta.make( + collectionName: "Primitives", + modes: [("plight", "Light"), ("pdark", "Dark")], + variables: [ + (id: "lib-v1", name: "brand/primary", valuesByMode: [ + "plight": (r: 1.0, g: 0.0, b: 0.0, a: 1.0), + "pdark": (r: 0.0, g: 1.0, b: 0.0, a: 1.0), + ]), + ] + ) + + let libNameIndex = Dictionary( + libMeta.variables.values.map { ($0.name, $0) }, + uniquingKeysWith: { first, _ in first } + ) + + let result = generator.resolveViaLibrary( + variableName: "brand/primary", + libMeta: libMeta, + libNameIndex: libNameIndex, + darkModeName: "Dark" + ) + + XCTAssertNotNil(result) + XCTAssertEqual(result?.hex, "00ff00") + } + + func testResolveViaLibraryReturnsNilWhenNameNotFound() { + let libMeta = VariablesMeta.make( + collectionName: "Primitives", + modes: [("plight", "Light"), ("pdark", "Dark")], + variables: [] + ) + + let result = generator.resolveViaLibrary( + variableName: "nonexistent", + libMeta: libMeta, + libNameIndex: [:], + darkModeName: "Dark" + ) + + XCTAssertNil(result) + } + + func testResolveViaLibraryReturnsNilWhenDarkModeNotFound() { + let libMeta = VariablesMeta.make( + collectionName: "Primitives", + modes: [("plight", "Light"), ("pdim", "Dim")], + variables: [ + (id: "lib-v1", name: "color", valuesByMode: [ + "plight": (r: 1.0, g: 0.0, b: 0.0, a: 1.0), + "pdim": (r: 0.5, g: 0.5, b: 0.5, a: 1.0), + ]), + ] + ) + + let libNameIndex = Dictionary( + libMeta.variables.values.map { ($0.name, $0) }, + uniquingKeysWith: { first, _ in first } + ) + + let result = generator.resolveViaLibrary( + variableName: "color", + libMeta: libMeta, + libNameIndex: libNameIndex, + darkModeName: "Dark" + ) + + XCTAssertNil(result, "Should return nil when dark mode name doesn't match") + } +} + +// MARK: - VariablesCache Tests + +final class VariablesCacheTests: XCTestCase { + func testDeduplicatesParallelRequests() async throws { + let cache = VariablesCache() + let fetchCount = Lock(0) + + let meta = VariablesMeta.make( + collectionName: "Test", + modes: [("m1", "Mode1")], + variables: [] + ) + + try await withThrowingTaskGroup(of: VariablesMeta.self) { group in + for _ in 0 ..< 5 { + group.addTask { + try await cache.get(fileId: "file1") { + fetchCount.withLock { $0 += 1 } + try await Task.sleep(nanoseconds: 10_000_000) // 10ms + return meta + } + } + } + for try await _ in group {} + } + + XCTAssertEqual(fetchCount.withLock { $0 }, 1, "Should only fetch once for the same fileId") + } + + func testDifferentFileIdsGetSeparateFetches() async throws { + let cache = VariablesCache() + let fetchCount = Lock(0) + + let meta = VariablesMeta.make( + collectionName: "Test", + modes: [("m1", "Mode1")], + variables: [] + ) + + try await withThrowingTaskGroup(of: VariablesMeta.self) { group in + for i in 0 ..< 3 { + group.addTask { + try await cache.get(fileId: "file\(i)") { + fetchCount.withLock { $0 += 1 } + return meta + } + } + } + for try await _ in group {} + } + + XCTAssertEqual(fetchCount.withLock { $0 }, 3, "Each unique fileId should trigger a separate fetch") + } + + func testFailedTaskIsEvictedForRetry() async throws { + let cache = VariablesCache() + let fetchCount = Lock(0) + + struct TestError: Error {} + + do { + _ = try await cache.get(fileId: "file1") { + fetchCount.withLock { $0 += 1 } + throw TestError() + } + XCTFail("Should have thrown") + } catch { + // Expected + } + + XCTAssertEqual(fetchCount.withLock { $0 }, 1) + + let meta = VariablesMeta.make( + collectionName: "Test", + modes: [("m1", "Mode1")], + variables: [] + ) + + let result = try await cache.get(fileId: "file1") { + fetchCount.withLock { $0 += 1 } + return meta + } + + XCTAssertEqual(fetchCount.withLock { $0 }, 2, "Failed task should be evicted, allowing retry") + XCTAssertEqual(result.variableCollections.count, 1) + } +} + +// swiftlint:enable file_length diff --git a/Tests/ExFigTests/Output/SVGColorReplacerTests.swift b/Tests/ExFigTests/Output/SVGColorReplacerTests.swift new file mode 100644 index 00000000..862971cd --- /dev/null +++ b/Tests/ExFigTests/Output/SVGColorReplacerTests.swift @@ -0,0 +1,204 @@ +@testable import ExFigCLI +import XCTest + +final class SVGColorReplacerTests: XCTestCase { + // MARK: - Helpers + + private func opaque(_ hex: String) -> ColorReplacement { + ColorReplacement(hex: hex, alpha: 1.0) + } + + private func transparent(_ hex: String, alpha: Double = 0.0) -> ColorReplacement { + ColorReplacement(hex: hex, alpha: alpha) + } + + // MARK: - Basic Replacement (opaque) + + func testReplacesHexInFillAttribute() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["ff0000": opaque("00ff00")]) + XCTAssertEqual(result, "") + } + + func testReplacesHexInStrokeAttribute() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["aabbcc": opaque("112233")]) + XCTAssertEqual(result, "") + } + + func testReplacesHexInStopColorAttribute() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["ff00ff": opaque("00ffff")]) + XCTAssertEqual(result, "") + } + + // MARK: - Case Insensitive + + func testCaseInsensitiveMatch() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["aabbcc": opaque("112233")]) + XCTAssertEqual(result, "") + } + + // MARK: - CSS Style Properties + + func testReplacesCSSFillProperty() { + let svg = "" + let result = SVGColorReplacer.replaceColors( + in: svg, + colorMap: ["ff0000": opaque("111111"), "00ff00": opaque("222222")] + ) + XCTAssertTrue(result.contains("fill:#111111")) + XCTAssertTrue(result.contains("stroke:#222222")) + } + + // MARK: - Multiple Colors + + func testReplacesMultipleColors() { + let svg = """ + + + + + + """ + let result = SVGColorReplacer.replaceColors( + in: svg, + colorMap: ["ff0000": opaque("111111"), "00ff00": opaque("222222"), "0000ff": opaque("333333")] + ) + XCTAssertTrue(result.contains("#111111")) + XCTAssertTrue(result.contains("#222222")) + XCTAssertTrue(result.contains("#333333")) + XCTAssertFalse(result.contains("#ff0000")) + XCTAssertFalse(result.contains("#00ff00")) + XCTAssertFalse(result.contains("#0000ff")) + } + + // MARK: - No Match + + func testEmptyColorMapReturnsOriginal() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: [:]) + XCTAssertEqual(result, svg) + } + + func testNoMatchingColorsReturnsOriginal() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["aabbcc": opaque("112233")]) + XCTAssertEqual(result, svg) + } + + // MARK: - Color Normalization + + func testNormalizeColorFromRGBA() { + XCTAssertEqual(SVGColorReplacer.normalizeColor(r: 1.0, g: 0.0, b: 0.0), "ff0000") + XCTAssertEqual(SVGColorReplacer.normalizeColor(r: 0.0, g: 1.0, b: 0.0), "00ff00") + XCTAssertEqual(SVGColorReplacer.normalizeColor(r: 0.0, g: 0.0, b: 1.0), "0000ff") + XCTAssertEqual(SVGColorReplacer.normalizeColor(r: 0.0, g: 0.0, b: 0.0), "000000") + XCTAssertEqual(SVGColorReplacer.normalizeColor(r: 1.0, g: 1.0, b: 1.0), "ffffff") + } + + func testNormalizeColorClampsValues() { + XCTAssertEqual(SVGColorReplacer.normalizeColor(r: 1.5, g: -0.1, b: 0.5), "ff0080") + } + + func testNormalizeColorFractionalValues() { + // 0.2 * 255 = 51 = 0x33 + XCTAssertEqual(SVGColorReplacer.normalizeColor(r: 0.2, g: 0.2, b: 0.2), "333333") + } + + // MARK: - Alpha / Opacity + + func testFillWithZeroAlphaAddsFillOpacity() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["d6fb94": transparent("d6fb94")]) + XCTAssertTrue(result.contains("fill-opacity=\"0\""), "Expected fill-opacity=\"0\" in: \(result)") + } + + func testStrokeWithZeroAlphaAddsStrokeOpacity() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["aabbcc": transparent("112233")]) + XCTAssertTrue(result.contains("stroke=\"#112233\""), "Hex should be replaced") + XCTAssertTrue(result.contains("stroke-opacity=\"0\""), "Expected stroke-opacity in: \(result)") + } + + func testPartialAlphaAddsFillOpacity() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["ff0000": transparent("00ff00", alpha: 0.5)]) + XCTAssertTrue(result.contains("fill=\"#00ff00\"")) + XCTAssertTrue(result.contains("fill-opacity=\"0.5\""), "Expected fill-opacity in: \(result)") + } + + func testStopColorWithZeroAlphaAddsStopOpacity() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["ff00ff": transparent("00ffff")]) + XCTAssertTrue(result.contains("stop-color=\"#00ffff\"")) + XCTAssertTrue(result.contains("stop-opacity=\"0\""), "Expected stop-opacity in: \(result)") + } + + func testCSSFillWithAlphaAddsFillOpacity() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["ff0000": transparent("00ff00")]) + XCTAssertTrue(result.contains("fill:#00ff00"), "Hex should be replaced") + XCTAssertTrue(result.contains("fill-opacity:0"), "Expected fill-opacity in CSS: \(result)") + } + + func testCSSStrokeWithAlphaAddsStrokeOpacity() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["aabbcc": transparent("112233")]) + XCTAssertTrue(result.contains("stroke:#112233")) + XCTAssertTrue(result.contains("stroke-opacity:0"), "Expected stroke-opacity in CSS: \(result)") + } + + func testOpaqueAlphaDoesNotAddOpacity() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["ff0000": opaque("00ff00")]) + XCTAssertEqual(result, "") + XCTAssertFalse(result.contains("opacity")) + } + + func testSameHexDifferentAlphaStillReplaces() { + // Same hex but dark has alpha=0: should add opacity even though hex matches + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["d6fb94": transparent("d6fb94")]) + XCTAssertTrue(result.contains("fill-opacity=\"0\""), "Should add opacity even when hex is the same: \(result)") + } + + // MARK: - CSS Partial Hex Match + + func testCSSDoesNotPartialMatch8DigitHex() { + // #aabbcc should NOT match inside #aabbccdd + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["aabbcc": opaque("112233")]) + XCTAssertEqual(result, svg, "Should not partially match 8-digit hex") + } + + func testCSSMatchesHexFollowedBySemicolon() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["aabbcc": opaque("112233")]) + XCTAssertTrue(result.contains("fill:#112233")) + } + + // MARK: - flood-color / lighting-color + + func testReplacesFloodColorAttribute() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["ff0000": opaque("00ff00")]) + XCTAssertEqual(result, "") + } + + func testReplacesLightingColorAttribute() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["aabbcc": opaque("112233")]) + XCTAssertEqual(result, "") + } + + // MARK: - ColorReplacement + + func testColorReplacementChangesOpacity() { + XCTAssertTrue(ColorReplacement(hex: "ff0000", alpha: 0.0).changesOpacity) + XCTAssertTrue(ColorReplacement(hex: "ff0000", alpha: 0.5).changesOpacity) + XCTAssertFalse(ColorReplacement(hex: "ff0000", alpha: 1.0).changesOpacity) + XCTAssertFalse(ColorReplacement(hex: "ff0000", alpha: 0.999).changesOpacity) + } +} diff --git a/Tests/ExFigTests/PKL/PKLEvaluatorTests.swift b/Tests/ExFigTests/PKL/PKLEvaluatorTests.swift index 5e8ef5f7..207e9d90 100644 --- a/Tests/ExFigTests/PKL/PKLEvaluatorTests.swift +++ b/Tests/ExFigTests/PKL/PKLEvaluatorTests.swift @@ -52,6 +52,30 @@ struct PKLEvaluatorTests { } } + @Test("Evaluates variablesDarkMode nested object") + func evaluatesVariablesDarkMode() async throws { + let configPath = Self.fixturesPath.appendingPathComponent("valid-config.pkl") + + let module = try await PKLEvaluator.evaluate(configPath: configPath) + + let icons = module.ios?.icons + #expect(icons?.count == 1) + + let entry = try #require(icons?.first) + #expect(entry.figmaFrameName == "TestIcons") + + // This is the critical assertion: variablesDarkMode must NOT be nil + let darkMode = try #require( + entry.variablesDarkMode, + "variablesDarkMode is nil — pkl-swift failed to deserialize nested object" + ) + #expect(darkMode.collectionName == "TestCollection") + #expect(darkMode.lightModeName == "Light") + #expect(darkMode.darkModeName == "Dark") + #expect(darkMode.primitivesModeName == nil) + #expect(darkMode.variablesFileId == "lib-file-123") + } + @Test("All generated PKL types are registered") func allGeneratedPklTypesRegistered() { // Every registeredIdentifier in Generated/*.pkl.swift must be listed here AND diff --git a/Tests/ExFigTests/TerminalUI/TerminalUITests.swift b/Tests/ExFigTests/TerminalUI/TerminalUITests.swift index 0a82b5f4..b6d3f976 100644 --- a/Tests/ExFigTests/TerminalUI/TerminalUITests.swift +++ b/Tests/ExFigTests/TerminalUI/TerminalUITests.swift @@ -327,37 +327,6 @@ final class TerminalUITests: XCTestCase { ui.warning("Line 1\nassets[3]: a,b,c\n item1\n item2") } - func testWarningWithAssetsValidatorWarning() { - let ui = TerminalUI(outputMode: .plain) - let warning = AssetsValidatorWarning.lightAssetsNotFoundInDarkPalette( - assets: ["icon-a", "icon-b"] - ) - - // Should not crash - will print multi-line formatted output - ui.warning(warning) - } - - func testWarningWithLargeAssetsValidatorWarning() { - let ui = TerminalUI(outputMode: .plain) - let assets = (1 ... 50).map { "asset-\($0)" } - let warning = AssetsValidatorWarning.lightAssetsNotFoundInDarkPalette( - assets: assets - ) - - // Should handle large lists without crashing - ui.warning(warning) - } - - func testWarningWithEmptyAssetsValidatorWarning() { - let ui = TerminalUI(outputMode: .plain) - let warning = AssetsValidatorWarning.lightAssetsNotFoundInDarkPalette( - assets: [] - ) - - // Should handle empty list gracefully - ui.warning(warning) - } - func testWarningWithColorsMultiline() { let ui = TerminalUI(outputMode: .normal) diff --git a/bin/mise b/bin/mise index e5595a6c..e15b52ca 100755 --- a/bin/mise +++ b/bin/mise @@ -3,7 +3,7 @@ set -eu __mise_bootstrap() { local cache_home="${XDG_CACHE_HOME:-$HOME/.cache}/mise" - export MISE_INSTALL_PATH="$cache_home/mise-2026.3.15" + export MISE_INSTALL_PATH="$cache_home/mise-2026.3.17" install() { local initial_working_dir="$PWD" #!/bin/sh @@ -118,28 +118,28 @@ __mise_bootstrap() { arch=$3 ext=$4 url="https://github.com/jdx/mise/releases/download/v${version}/SHASUMS256.txt" - current_version="v2026.3.15" + current_version="v2026.3.17" current_version="${current_version#v}" # For current version use static checksum otherwise # use checksum from releases if [ "$version" = "$current_version" ]; then - checksum_linux_x86_64="29b128db8b597103220b645560e544ccc4561f7f82cc0fc6e7ceff9f316e71a5 ./mise-v2026.3.15-linux-x64.tar.gz" - checksum_linux_x86_64_musl="4e70734eeef3e664f1616be83ed0d2ee6114ecd10539ca6abdb1f6d66c29559d ./mise-v2026.3.15-linux-x64-musl.tar.gz" - checksum_linux_arm64="5fe63efe1c57dadd1403e595e4de169e21ad38161f6ab7128461018f6b10eb86 ./mise-v2026.3.15-linux-arm64.tar.gz" - checksum_linux_arm64_musl="25d1f0d880e47f7478d93ee0e8344e25b7eb9cbd841ddb6231836c9ff86868bc ./mise-v2026.3.15-linux-arm64-musl.tar.gz" - checksum_linux_armv7="7e403628c73f90dd6f350321bcd5d905ddbf92b6a52b97b495180fbd107ee762 ./mise-v2026.3.15-linux-armv7.tar.gz" - checksum_linux_armv7_musl="e1c828424ad9449d410c36857cf1e2a8713e4d7ea7a8a45623aec63b234cc4be ./mise-v2026.3.15-linux-armv7-musl.tar.gz" - checksum_macos_x86_64="4dbc8750ce3833050321b0c0deb61db7fc76681aa958df6786b999b588e42d1d ./mise-v2026.3.15-macos-x64.tar.gz" - checksum_macos_arm64="e500c437e4b8679b4c65e91925f86c17e6be76d0e218012bd40ec695ae4cf78e ./mise-v2026.3.15-macos-arm64.tar.gz" - checksum_linux_x86_64_zstd="b22e759742d805e87e7c17c4fc4aa1eb5927ac3c989a38b6cbe64718dcf8dcc8 ./mise-v2026.3.15-linux-x64.tar.zst" - checksum_linux_x86_64_musl_zstd="766887aa6f08d209116e4ddf0f7c49aa2c08b7119d64c8485574ed0013b75164 ./mise-v2026.3.15-linux-x64-musl.tar.zst" - checksum_linux_arm64_zstd="d1f4275548c836f90c70822405847bbf912295937c78076e1f5d33eb1995f8cb ./mise-v2026.3.15-linux-arm64.tar.zst" - checksum_linux_arm64_musl_zstd="74fc0386cd28044bbb91e7d745642f4c834dbd0d844374b4bbc314aa8fca2aca ./mise-v2026.3.15-linux-arm64-musl.tar.zst" - checksum_linux_armv7_zstd="80993247d85fdb5ba5b3c129775a16a42e5d06889963e232113ff1817f1e4445 ./mise-v2026.3.15-linux-armv7.tar.zst" - checksum_linux_armv7_musl_zstd="53da25e2ba78aee29f12da96a63fec36b2cabdc5d8d23e771de18b663c0d3aa6 ./mise-v2026.3.15-linux-armv7-musl.tar.zst" - checksum_macos_x86_64_zstd="76b1ed712ea60582eab109b25915cc52e123efb2304a70c73a0a4c17c1596c91 ./mise-v2026.3.15-macos-x64.tar.zst" - checksum_macos_arm64_zstd="5e655ab772fde67faa2bf69af45737f5b9fec32bd62fbd4a60921c7195f13fd8 ./mise-v2026.3.15-macos-arm64.tar.zst" + checksum_linux_x86_64="2b40c42ac5653f6cb9526054fa37d56d6830f4fe9c14e242a6dad227d19ac4aa ./mise-v2026.3.17-linux-x64.tar.gz" + checksum_linux_x86_64_musl="f416a2d27f69173b22551429d2bc712c40fc3fb639112cf76ae817baae3772e2 ./mise-v2026.3.17-linux-x64-musl.tar.gz" + checksum_linux_arm64="47a549b15313f2115b9d74da08f72dae63ec8c93d0caceffc661026ec8cde35a ./mise-v2026.3.17-linux-arm64.tar.gz" + checksum_linux_arm64_musl="7ef20440c3e1d9f0db69e57e68e6833a9cade44fd1e97384a279660e48091f97 ./mise-v2026.3.17-linux-arm64-musl.tar.gz" + checksum_linux_armv7="c772c604030198087ddad4507eb6d8d5b9a8f8c437bd067c6c54271bc22dc63b ./mise-v2026.3.17-linux-armv7.tar.gz" + checksum_linux_armv7_musl="6f0bd634c82617dc6f9ded8ef31d5c54dc9c478438286d3324975f08c829392b ./mise-v2026.3.17-linux-armv7-musl.tar.gz" + checksum_macos_x86_64="bd120908c1476f6f27ae27f4214bdbc3fb0e81b9671aaba720b994a217d28e39 ./mise-v2026.3.17-macos-x64.tar.gz" + checksum_macos_arm64="6d98cedb9c92bd7a1694c9efedc035288da87f6e19c79d15bf91449474c37ce9 ./mise-v2026.3.17-macos-arm64.tar.gz" + checksum_linux_x86_64_zstd="3c5ee98309d98867a3c2b8d26c03e39730a2cae9130c86d455d2226bbd798788 ./mise-v2026.3.17-linux-x64.tar.zst" + checksum_linux_x86_64_musl_zstd="081545be54a1a0379439f3c0cb67023ac45692ec0abb27c9fa13daade144b7d0 ./mise-v2026.3.17-linux-x64-musl.tar.zst" + checksum_linux_arm64_zstd="6245c051d725becbc40b2ea4e6bd910aa9fb2467ac121121cd2260e47c67de94 ./mise-v2026.3.17-linux-arm64.tar.zst" + checksum_linux_arm64_musl_zstd="a7b404013efe773f8063f64d0bf365575131e0681120a5d6f608df1fc59aeb17 ./mise-v2026.3.17-linux-arm64-musl.tar.zst" + checksum_linux_armv7_zstd="ae0ff69470b4ef7b5ef1ec2da53d679743df869568d599c3bcba02290716318b ./mise-v2026.3.17-linux-armv7.tar.zst" + checksum_linux_armv7_musl_zstd="409593cc3db6e1519c619990694ebc1b047a085c85716bd8eb7eae8b94bc53a4 ./mise-v2026.3.17-linux-armv7-musl.tar.zst" + checksum_macos_x86_64_zstd="8822864bc2edef2ba7888f09be2a66e82474e08e68d94d426a1cda90b950af03 ./mise-v2026.3.17-macos-x64.tar.zst" + checksum_macos_arm64_zstd="c64e7e28db2eb5fe0492d940ad58e30214a1e21faa40faf91e4dfa120cf05f3a ./mise-v2026.3.17-macos-arm64.tar.zst" # TODO: refactor this, it's a bit messy if [ "$ext" = "tar.zst" ]; then @@ -250,9 +250,9 @@ __mise_bootstrap() { } install_mise() { - version="${MISE_VERSION:-v2026.3.15}" + version="${MISE_VERSION:-v2026.3.17}" version="${version#v}" - current_version="v2026.3.15" + current_version="v2026.3.17" current_version="${current_version#v}" os="${MISE_INSTALL_OS:-$(get_os)}" arch="${MISE_INSTALL_ARCH:-$(get_arch)}" @@ -335,4 +335,4 @@ __mise_bootstrap() { test -f "$MISE_INSTALL_PATH" || install } __mise_bootstrap -exec "$MISE_INSTALL_PATH" "$@" +exec -a "$0" "$MISE_INSTALL_PATH" "$@" diff --git a/hk.pkl b/hk.pkl index c2cf3946..a9aeee76 100644 --- a/hk.pkl +++ b/hk.pkl @@ -79,6 +79,7 @@ local builtin_checks = new Mapping { } ["newlines"] = (Builtins.newlines) { exclude = generated_excludes + template_excludes + depends = "llms-check" hide = true } ["actionlint"] { diff --git a/llms-full.txt b/llms-full.txt index f5330e04..70bc59e9 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -629,11 +629,7 @@ common = new Common.CommonConfig { // Regex replacement for color names nameReplaceRegexp = "$1" - // Extract light and dark mode colors from a single file - useSingleFile = false - - // Suffix for dark mode variants (when useSingleFile is true) - darkModeSuffix = "_dark" + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } ``` @@ -766,11 +762,7 @@ common = new Common.CommonConfig { // Regex replacement for icon names nameReplaceRegexp = "ic_$1" - // Use single file for light/dark (default: false) - useSingleFile = false - - // Suffix for dark mode variants (when useSingleFile is true) - darkModeSuffix = "_dark" + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } ``` @@ -791,11 +783,7 @@ common = new Common.CommonConfig { // Regex replacement for image names nameReplaceRegexp = "$1" - // Use single file for light/dark (default: false) - useSingleFile = false - - // Suffix for dark mode variants (when useSingleFile is true) - darkModeSuffix = "_dark" + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } ``` @@ -1330,8 +1318,7 @@ import ".exfig/schemas/Common.pkl" common = new Common.CommonConfig { icons = new Common.Icons { - useSingleFile = true - darkModeSuffix = "_dark" + suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } ``` @@ -2856,4 +2843,3 @@ For lossless encoding, quality is not required. - Configuration - DesignRequirements - CustomTemplates - diff --git a/mise.lock b/mise.lock index c3e08cf7..6e120ebf 100644 --- a/mise.lock +++ b/mise.lock @@ -1,56 +1,151 @@ +# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html + [[tools.actionlint]] -version = "1.7.9" +version = "1.7.11" backend = "aqua:rhysd/actionlint" -"platforms.linux-arm64" = { checksum = "sha256:6b82a3b8c808bf1bcd39a95aced22fc1a026eef08ede410f81e274af8deadbbc", url = "https://github.com/rhysd/actionlint/releases/download/v1.7.9/actionlint_1.7.9_linux_arm64.tar.gz"} -"platforms.linux-x64" = { checksum = "sha256:233b280d05e100837f4af1433c7b40a5dcb306e3aa68fb4f17f8a7f45a7df7b4", url = "https://github.com/rhysd/actionlint/releases/download/v1.7.9/actionlint_1.7.9_linux_amd64.tar.gz"} -"platforms.macos-arm64" = { checksum = "sha256:855e49e823fc68c6371fd6967e359cde11912d8d44fed343283c8e6e943bd789", url = "https://github.com/rhysd/actionlint/releases/download/v1.7.9/actionlint_1.7.9_darwin_arm64.tar.gz"} -"platforms.macos-x64" = { checksum = "sha256:f89a910e90e536f60df7c504160247db01dd67cab6f08c064c1c397b76c91a79", url = "https://github.com/rhysd/actionlint/releases/download/v1.7.9/actionlint_1.7.9_darwin_amd64.tar.gz"} -"platforms.windows-x64" = { checksum = "sha256:7c8b10a93723838bc3533f6e1886d868fdbb109b81606ebe6d1a533d11d8e978", url = "https://github.com/rhysd/actionlint/releases/download/v1.7.9/actionlint_1.7.9_windows_amd64.zip"} + +[tools.actionlint."platforms.linux-arm64"] +checksum = "sha256:21bc0dfb57a913fe175298c2a9e906ee630f747cb66d0a934d0d4b69f4ee1235" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.11/actionlint_1.7.11_linux_arm64.tar.gz" + +[tools.actionlint."platforms.linux-arm64-musl"] +checksum = "sha256:21bc0dfb57a913fe175298c2a9e906ee630f747cb66d0a934d0d4b69f4ee1235" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.11/actionlint_1.7.11_linux_arm64.tar.gz" + +[tools.actionlint."platforms.linux-x64"] +checksum = "sha256:900919a84f2229bac68ca9cd4103ea297abc35e9689ebb842c6e34a3d1b01b0a" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.11/actionlint_1.7.11_linux_amd64.tar.gz" + +[tools.actionlint."platforms.linux-x64-musl"] +checksum = "sha256:900919a84f2229bac68ca9cd4103ea297abc35e9689ebb842c6e34a3d1b01b0a" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.11/actionlint_1.7.11_linux_amd64.tar.gz" + +[tools.actionlint."platforms.macos-arm64"] +checksum = "sha256:a21ba7366d8329e7223faee0ed69eb13da27fe8acabb356bb7eb0b7f1e1cb6d8" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.11/actionlint_1.7.11_darwin_arm64.tar.gz" +provenance = "github-attestations" + +[tools.actionlint."platforms.macos-x64"] +checksum = "sha256:17ffc17fed8f0258ef6ad4aed932d3272464c7ef7d64e1cb0d65aa97c9752107" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.11/actionlint_1.7.11_darwin_amd64.tar.gz" + +[tools.actionlint."platforms.windows-x64"] +checksum = "sha256:5414b7124a91f4b5abee62e5c9d84802237734f8d15b9b7032732a32c3ebffa3" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.11/actionlint_1.7.11_windows_amd64.zip" [[tools.dprint]] -version = "0.50.2" +version = "0.53.1" backend = "aqua:dprint/dprint" -"platforms.linux-arm64" = { checksum = "sha256:a4982964a68aefc2720b4c79c51a57e49b32f8944c1641fd9e714503fcf01847", url = "https://github.com/dprint/dprint/releases/download/0.50.2/dprint-aarch64-unknown-linux-musl.zip"} -"platforms.linux-x64" = { checksum = "sha256:4b0e7911262049ccb8e1ac5968bf7a66dc490968fe1552a123bb2d6dadf2ad95", url = "https://github.com/dprint/dprint/releases/download/0.50.2/dprint-x86_64-unknown-linux-musl.zip"} -"platforms.macos-arm64" = { checksum = "sha256:f534bcc054947ab2a42c069b5f6027914d252729bd15c1109812313b35a662a5", url = "https://github.com/dprint/dprint/releases/download/0.50.2/dprint-aarch64-apple-darwin.zip"} -"platforms.macos-x64" = { checksum = "sha256:61becbf8d1b16540e364a4f00be704266ae322ee0ff3ba66a4a21033f66a8d55", url = "https://github.com/dprint/dprint/releases/download/0.50.2/dprint-x86_64-apple-darwin.zip"} -"platforms.windows-x64" = { checksum = "sha256:2dbdb57106818acd930a00bc0c2c33370bd4c7265f78a6cda000e3621f2d3c1c", url = "https://github.com/dprint/dprint/releases/download/0.50.2/dprint-x86_64-pc-windows-msvc.zip"} + +[tools.dprint."platforms.linux-arm64"] +checksum = "sha256:559cbd7aff707d461627c46f817cd06c28828458ef4a21eb18801f588bc94e89" +url = "https://github.com/dprint/dprint/releases/download/0.53.1/dprint-aarch64-unknown-linux-musl.zip" + +[tools.dprint."platforms.linux-arm64-musl"] +checksum = "sha256:559cbd7aff707d461627c46f817cd06c28828458ef4a21eb18801f588bc94e89" +url = "https://github.com/dprint/dprint/releases/download/0.53.1/dprint-aarch64-unknown-linux-musl.zip" + +[tools.dprint."platforms.linux-x64"] +checksum = "sha256:f2815a5c217bb63ff54356c4a6e1e5393a126b29c46116ae57a08ec97c29cb85" +url = "https://github.com/dprint/dprint/releases/download/0.53.1/dprint-x86_64-unknown-linux-musl.zip" + +[tools.dprint."platforms.linux-x64-musl"] +checksum = "sha256:f2815a5c217bb63ff54356c4a6e1e5393a126b29c46116ae57a08ec97c29cb85" +url = "https://github.com/dprint/dprint/releases/download/0.53.1/dprint-x86_64-unknown-linux-musl.zip" + +[tools.dprint."platforms.macos-arm64"] +checksum = "sha256:4c822f9d4c692b0f0cd53d14bc82057488e1c4db2df1122b1aceb0a660795ac8" +url = "https://github.com/dprint/dprint/releases/download/0.53.1/dprint-aarch64-apple-darwin.zip" + +[tools.dprint."platforms.macos-x64"] +checksum = "sha256:b35e91afe0f7f2217128b8edcebd63d789b00f9c1b0300e4907a115106d857fd" +url = "https://github.com/dprint/dprint/releases/download/0.53.1/dprint-x86_64-apple-darwin.zip" + +[tools.dprint."platforms.windows-x64"] +checksum = "sha256:db21e2f7a09dc9b3a2fbe2faac34a9f0733a4cc3cb8c78dd13dae6f081e13d4c" +url = "https://github.com/dprint/dprint/releases/download/0.53.1/dprint-x86_64-pc-windows-msvc.zip" [[tools.git-cliff]] -version = "2.10.1" +version = "2.12.0" backend = "aqua:orhun/git-cliff" -"platforms.linux-arm64" = { checksum = "sha256:218a25c728df98337541013218660eeb571a464daea7612b35bb4e97b22b97db", url = "https://github.com/orhun/git-cliff/releases/download/v2.10.1/git-cliff-2.10.1-aarch64-unknown-linux-musl.tar.gz"} -"platforms.linux-x64" = { checksum = "sha256:55ed8495e8c18e51e42182e17772013d6d2a7156a462d6b30f1adf17e54b465e", url = "https://github.com/orhun/git-cliff/releases/download/v2.10.1/git-cliff-2.10.1-x86_64-unknown-linux-musl.tar.gz"} -"platforms.macos-arm64" = { checksum = "sha256:98cf636ca6a66d84e0ba6202a990028ac45cf0dde331d18169397ae59cc6e41b", url = "https://github.com/orhun/git-cliff/releases/download/v2.10.1/git-cliff-2.10.1-aarch64-apple-darwin.tar.gz"} -"platforms.macos-x64" = { checksum = "sha256:c3111dddaf866a986085f22ff22fa3003645fc69a3b9302c4e1352c4676c398a", url = "https://github.com/orhun/git-cliff/releases/download/v2.10.1/git-cliff-2.10.1-x86_64-apple-darwin.tar.gz"} -"platforms.windows-x64" = { checksum = "sha256:073c8027da2e055ec83c3609c4195284bd10b2771fcbd806ff0f94e48c310c77", url = "https://github.com/orhun/git-cliff/releases/download/v2.10.1/git-cliff-2.10.1-x86_64-pc-windows-msvc.zip"} [[tools."github:alexey1312/swift-index"]] version = "0.3.0" backend = "github:alexey1312/swift-index" -"platforms.macos-arm64" = { checksum = "sha256:67dd926c16b1447d8314d0106332a3dd3bb66d05e897de9800e69cfa2597f081", url = "https://github.com/alexey1312/swift-index/releases/download/v0.3.0/swiftindex-macos.zip", url_api = "https://api.github.com/repos/alexey1312/swift-index/releases/assets/349689493"} -"platforms.macos-x64" = { checksum = "sha256:67dd926c16b1447d8314d0106332a3dd3bb66d05e897de9800e69cfa2597f081", url = "https://github.com/alexey1312/swift-index/releases/download/v0.3.0/swiftindex-macos.zip", url_api = "https://api.github.com/repos/alexey1312/swift-index/releases/assets/349689493"} + +[tools."github:alexey1312/swift-index"."platforms.macos-arm64"] +checksum = "sha256:67dd926c16b1447d8314d0106332a3dd3bb66d05e897de9800e69cfa2597f081" +url = "https://github.com/alexey1312/swift-index/releases/download/v0.3.0/swiftindex-macos.zip" +url_api = "https://api.github.com/repos/alexey1312/swift-index/releases/assets/349689493" + +[tools."github:alexey1312/swift-index"."platforms.macos-x64"] +checksum = "sha256:67dd926c16b1447d8314d0106332a3dd3bb66d05e897de9800e69cfa2597f081" +url = "https://github.com/alexey1312/swift-index/releases/download/v0.3.0/swiftindex-macos.zip" +url_api = "https://api.github.com/repos/alexey1312/swift-index/releases/assets/349689493" [[tools.hk]] -version = "1.36.0" +version = "1.39.0" backend = "aqua:jdx/hk" -"platforms.linux-arm64" = { checksum = "sha256:51cf51e2035038ee64d772e71f8daf4978380ef82faffee2b12c413f009341ab", url = "https://github.com/jdx/hk/releases/download/v1.36.0/hk-aarch64-unknown-linux-gnu.tar.gz"} -"platforms.linux-x64" = { checksum = "sha256:d20fa0be3f1135abc74471670306ae0353ad2336804595362293f0c8460952e7", url = "https://github.com/jdx/hk/releases/download/v1.36.0/hk-x86_64-unknown-linux-gnu.tar.gz"} -"platforms.macos-arm64" = { checksum = "sha256:553ff3c7c18d91f1c1dcdbae44315db0324b52336b5a02ed5b9c382f08499b3b", url = "https://github.com/jdx/hk/releases/download/v1.36.0/hk-aarch64-apple-darwin.tar.gz"} -"platforms.windows-x64" = { checksum = "sha256:1f12218ccad806a3f49bcf521f86b6825fbd89486e830c1c04f72e3796f84589", url = "https://github.com/jdx/hk/releases/download/v1.36.0/hk-x86_64-pc-windows-msvc.zip"} + +[tools.hk."platforms.linux-arm64"] +checksum = "sha256:209b73effe9f36fc5eee5a6ec0e833a501ed67f1041fbb3aeac9923ee252eb53" +url = "https://github.com/jdx/hk/releases/download/v1.39.0/hk-aarch64-unknown-linux-gnu.tar.gz" + +[tools.hk."platforms.linux-arm64-musl"] +checksum = "sha256:209b73effe9f36fc5eee5a6ec0e833a501ed67f1041fbb3aeac9923ee252eb53" +url = "https://github.com/jdx/hk/releases/download/v1.39.0/hk-aarch64-unknown-linux-gnu.tar.gz" + +[tools.hk."platforms.linux-x64"] +checksum = "sha256:559c72b512f7ec3df48f7a1e0f4d1c1082dd24a26f7d40865805dcc12dbf2a7c" +url = "https://github.com/jdx/hk/releases/download/v1.39.0/hk-x86_64-unknown-linux-gnu.tar.gz" + +[tools.hk."platforms.linux-x64-musl"] +checksum = "sha256:559c72b512f7ec3df48f7a1e0f4d1c1082dd24a26f7d40865805dcc12dbf2a7c" +url = "https://github.com/jdx/hk/releases/download/v1.39.0/hk-x86_64-unknown-linux-gnu.tar.gz" + +[tools.hk."platforms.macos-arm64"] +checksum = "sha256:0df1d19a8a7b052def3b1048a38681218ce4e04579c1805fa74457f8e634ee82" +url = "https://github.com/jdx/hk/releases/download/v1.39.0/hk-aarch64-apple-darwin.tar.gz" + +[tools.hk."platforms.windows-x64"] +checksum = "sha256:4fdbc4dfe17191fdc2157e2ddb9cf32d0e9fb4c6c0cddae2f8f2ea4b0b351fca" +url = "https://github.com/jdx/hk/releases/download/v1.39.0/hk-x86_64-pc-windows-msvc.zip" [[tools."npm:@mixedbread/mgrep"]] version = "0.1.8" backend = "npm:@mixedbread/mgrep" [[tools.pkl]] -version = "0.31.0" +version = "0.31.1" backend = "aqua:apple/pkl" -"platforms.linux-arm64" = { checksum = "sha256:471460cdd11e1cb9ac0a5401fdb05277ae3adb3a4573cc0a9c63ee087c1f93c8", url = "https://github.com/apple/pkl/releases/download/0.31.0/pkl-linux-aarch64"} -"platforms.linux-x64" = { checksum = "sha256:5a5c2a889b68ca92ff4258f9d277f92412b98dfef5057daef7564202a20870b6", url = "https://github.com/apple/pkl/releases/download/0.31.0/pkl-linux-amd64"} -"platforms.macos-arm64" = { checksum = "sha256:349402ae32c35382c034b0c0af744ffb0d53a213888c44deec94a7810e144889", url = "https://github.com/apple/pkl/releases/download/0.31.0/pkl-macos-aarch64"} -"platforms.macos-x64" = { checksum = "sha256:9f1cc8e3ac2327bc483b90d0c220da20eb785c3ba3fe92e021f47d3d56768282", url = "https://github.com/apple/pkl/releases/download/0.31.0/pkl-macos-amd64"} -"platforms.windows-x64" = { checksum = "sha256:37d35ce8a165766502fb13799071d4cefa84d39fb455c75c471b47b9b5d12b04", url = "https://github.com/apple/pkl/releases/download/0.31.0/pkl-windows-amd64.exe"} + +[tools.pkl."platforms.linux-arm64"] +checksum = "sha256:7ef10e743daa921fb94ae7bdb9ec6986f362bf250c55814b9ea2aeb13f2d083e" +url = "https://github.com/apple/pkl/releases/download/0.31.1/pkl-linux-aarch64" + +[tools.pkl."platforms.linux-arm64-musl"] +checksum = "sha256:7ef10e743daa921fb94ae7bdb9ec6986f362bf250c55814b9ea2aeb13f2d083e" +url = "https://github.com/apple/pkl/releases/download/0.31.1/pkl-linux-aarch64" + +[tools.pkl."platforms.linux-x64"] +checksum = "sha256:618f13955d755cafbfe8c9cba1d27635848cd49dbc6abffd398d2751db1231bf" +url = "https://github.com/apple/pkl/releases/download/0.31.1/pkl-linux-amd64" + +[tools.pkl."platforms.linux-x64-musl"] +checksum = "sha256:618f13955d755cafbfe8c9cba1d27635848cd49dbc6abffd398d2751db1231bf" +url = "https://github.com/apple/pkl/releases/download/0.31.1/pkl-linux-amd64" + +[tools.pkl."platforms.macos-arm64"] +checksum = "sha256:1b6a5438d9624cd2798a7530721bbbfa27ef72efe5c878a1b6c546c6e7ca0e8f" +url = "https://github.com/apple/pkl/releases/download/0.31.1/pkl-macos-aarch64" + +[tools.pkl."platforms.macos-x64"] +checksum = "sha256:22123ed4ae4c03afa8c54c69f77f0bec39b0fa0f67b09d6d148e0a376a2a471d" +url = "https://github.com/apple/pkl/releases/download/0.31.1/pkl-macos-amd64" + +[tools.pkl."platforms.windows-x64"] +checksum = "sha256:a8834481667325b44c539dbb758d7365a16389070f343c04b639bbe525ede013" +url = "https://github.com/apple/pkl/releases/download/0.31.1/pkl-windows-amd64.exe" [[tools.swift]] version = "6.2.3" @@ -63,17 +158,70 @@ backend = "asdf:swiftformat" [[tools.swiftlint]] version = "0.63.2" backend = "aqua:realm/SwiftLint" -"platforms.linux-arm64" = { checksum = "sha256:104dedff762157f5cff7752f1cc2a289b60f3ea677e72d651c6f3a3287fdd948", url = "https://github.com/realm/SwiftLint/releases/download/0.63.2/swiftlint_linux_arm64.zip"} -"platforms.linux-x64" = { checksum = "sha256:dd1017cfd20a1457f264590bcb5875a6ee06cd75b9a9d4f77cd43a552499143b", url = "https://github.com/realm/SwiftLint/releases/download/0.63.2/swiftlint_linux_amd64.zip"} -"platforms.macos-arm64" = { checksum = "sha256:c59a405c85f95b92ced677a500804e081596a4cae4a6a485af76065557d6ed29", url = "https://github.com/realm/SwiftLint/releases/download/0.63.2/portable_swiftlint.zip"} -"platforms.macos-x64" = { checksum = "sha256:c59a405c85f95b92ced677a500804e081596a4cae4a6a485af76065557d6ed29", url = "https://github.com/realm/SwiftLint/releases/download/0.63.2/portable_swiftlint.zip"} + +[tools.swiftlint."platforms.linux-arm64"] +checksum = "sha256:104dedff762157f5cff7752f1cc2a289b60f3ea677e72d651c6f3a3287fdd948" +url = "https://github.com/realm/SwiftLint/releases/download/0.63.2/swiftlint_linux_arm64.zip" + +[tools.swiftlint."platforms.linux-x64"] +checksum = "sha256:dd1017cfd20a1457f264590bcb5875a6ee06cd75b9a9d4f77cd43a552499143b" +url = "https://github.com/realm/SwiftLint/releases/download/0.63.2/swiftlint_linux_amd64.zip" + +[tools.swiftlint."platforms.macos-arm64"] +checksum = "sha256:c59a405c85f95b92ced677a500804e081596a4cae4a6a485af76065557d6ed29" +url = "https://github.com/realm/SwiftLint/releases/download/0.63.2/portable_swiftlint.zip" + +[tools.swiftlint."platforms.macos-x64"] +checksum = "sha256:c59a405c85f95b92ced677a500804e081596a4cae4a6a485af76065557d6ed29" +url = "https://github.com/realm/SwiftLint/releases/download/0.63.2/portable_swiftlint.zip" [[tools.usage]] -version = "3.0.0" +version = "3.2.0" backend = "aqua:jdx/usage" -"platforms.macos-arm64" = { checksum = "sha256:4de86e95923dd57d8171a5ae40ca65492ef84b69175fefd7ab14442ab3ea0fc2", url = "https://github.com/jdx/usage/releases/download/v3.0.0/usage-universal-apple-darwin.tar.gz"} + +[tools.usage."platforms.linux-arm64"] +checksum = "sha256:9cc22814763948582a31b054e6aa3adfc7a820a3cafb82b317dd4a8931277c0f" +url = "https://github.com/jdx/usage/releases/download/v3.2.0/usage-aarch64-unknown-linux-musl.tar.gz" + +[tools.usage."platforms.linux-arm64-musl"] +checksum = "sha256:9cc22814763948582a31b054e6aa3adfc7a820a3cafb82b317dd4a8931277c0f" +url = "https://github.com/jdx/usage/releases/download/v3.2.0/usage-aarch64-unknown-linux-musl.tar.gz" + +[tools.usage."platforms.linux-x64"] +checksum = "sha256:7f48ee228ae95b07c193ec61def326575682b37032473c5af4b3b93a114aec81" +url = "https://github.com/jdx/usage/releases/download/v3.2.0/usage-x86_64-unknown-linux-musl.tar.gz" + +[tools.usage."platforms.linux-x64-musl"] +checksum = "sha256:7f48ee228ae95b07c193ec61def326575682b37032473c5af4b3b93a114aec81" +url = "https://github.com/jdx/usage/releases/download/v3.2.0/usage-x86_64-unknown-linux-musl.tar.gz" + +[tools.usage."platforms.macos-arm64"] +checksum = "sha256:f0a566bc05fc485b23a339abddbaef04225e7730574dbb603fdc021e084d26f9" +url = "https://github.com/jdx/usage/releases/download/v3.2.0/usage-universal-apple-darwin.tar.gz" + +[tools.usage."platforms.macos-x64"] +checksum = "sha256:f0a566bc05fc485b23a339abddbaef04225e7730574dbb603fdc021e084d26f9" +url = "https://github.com/jdx/usage/releases/download/v3.2.0/usage-universal-apple-darwin.tar.gz" + +[tools.usage."platforms.windows-x64"] +checksum = "sha256:9aba23a5d549695cfb5f034849e143205ccf972e5b5d42c24acd32e084d7a165" +url = "https://github.com/jdx/usage/releases/download/v3.2.0/usage-x86_64-pc-windows-msvc.zip" [[tools.xcsift]] -version = "1.1.6" +version = "1.2.0" backend = "github:ldomaradzki/xcsift" -"platforms.macos-arm64" = { checksum = "sha256:120aedc672be232e7d58fbc600b01c06095e433f24d2212a414ae569f522371c", url = "https://github.com/ldomaradzki/xcsift/releases/download/v1.1.6/xcsift-v1.1.6-macos-arm64.tar.gz", url_api = "https://api.github.com/repos/ldomaradzki/xcsift/releases/assets/373660599"} + +[tools.xcsift."platforms.linux-x64"] +checksum = "sha256:ddb2ba80434bbe89889b0ab1d2c459b9f68e98a936d412ab4052a87488b8262a" +url = "https://github.com/ldomaradzki/xcsift/releases/download/v1.2.0/xcsift-v1.2.0-linux-x64.tar.gz" +url_api = "https://api.github.com/repos/ldomaradzki/xcsift/releases/assets/379050208" + +[tools.xcsift."platforms.linux-x64-musl"] +checksum = "sha256:ddb2ba80434bbe89889b0ab1d2c459b9f68e98a936d412ab4052a87488b8262a" +url = "https://github.com/ldomaradzki/xcsift/releases/download/v1.2.0/xcsift-v1.2.0-linux-x64.tar.gz" +url_api = "https://api.github.com/repos/ldomaradzki/xcsift/releases/assets/379050208" + +[tools.xcsift."platforms.macos-arm64"] +checksum = "sha256:11e93bdc74232a03d7676535f9e7f908d3d0a3c3991aa998af80de0e4a208e1b" +url = "https://github.com/ldomaradzki/xcsift/releases/download/v1.2.0/xcsift-v1.2.0-macos-arm64.tar.gz" +url_api = "https://api.github.com/repos/ldomaradzki/xcsift/releases/assets/379050209" diff --git a/mise.toml b/mise.toml index 7d008225..1c79f7c3 100644 --- a/mise.toml +++ b/mise.toml @@ -36,21 +36,21 @@ git config core.hooksPath .githooks 2>/dev/null || true # --- Swift Development --- swiftformat = "0.60.1" # Swift code formatting swiftlint = "0.63.2" # Swift linting -xcsift = "1.1.6" # xcodebuild output filtering +xcsift = "1.2.0" # xcodebuild output filtering # --- Documentation & Formatting --- -dprint = "0.50.2" # MD/JSON/YAML formatting (Rust, fast) +dprint = "0.53.1" # MD/JSON/YAML formatting (Rust, fast) # --- Git & CI --- -hk = "1.36.0" # Git hooks manager -actionlint = "1.7.9" # GitHub Actions linting -git-cliff = "2.10.1" # Changelog generation +hk = "1.39.0" # Git hooks manager +actionlint = "1.7.11" # GitHub Actions linting +git-cliff = "2.12.0" # Changelog generation # --- Configuration --- -pkl = "0.31.0" # Configuration language (for hk.pkl) +pkl = "0.31.1" # Configuration language (for hk.pkl) # --- CLI Spec --- -usage = "3.0.0" # CLI spec → shell completions, docs, man pages +usage = "3.2.0" # CLI spec → shell completions, docs, man pages # =============================================================================