Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 54 additions & 35 deletions CLAUDE.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Sources/ExFig-Android/Config/AndroidIconsEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public extension Android.IconsEntry {
/// Returns an IconsSourceInput for use with IconsExportContext.
func iconsSourceInput(darkFileId: String? = nil) -> IconsSourceInput {
IconsSourceInput(
sourceKind: sourceKind?.coreSourceKind ?? .figma,
figmaFileId: figmaFileId,
darkFileId: darkFileId,
frameName: figmaFrameName ?? "Icons",
Expand Down
1 change: 1 addition & 0 deletions Sources/ExFig-Android/Config/AndroidImagesEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public extension Android.ImagesEntry {
/// Returns an ImagesSourceInput for use with ImagesExportContext.
func imagesSourceInput(darkFileId: String? = nil) -> ImagesSourceInput {
ImagesSourceInput(
sourceKind: sourceKind?.coreSourceKind ?? .figma,
figmaFileId: figmaFileId,
darkFileId: darkFileId,
frameName: figmaFrameName ?? "Images",
Expand Down
2 changes: 1 addition & 1 deletion Sources/ExFig-Android/Export/AndroidColorsExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ public struct AndroidColorsExporter: ColorsExporter {
// 1. Load colors from Figma
let sourceInput = try entry.validatedColorsSourceInput()
let colors = try await context.withSpinner(
"Fetching colors from Figma (\(sourceInput.tokensCollectionName))..."
"Fetching colors from \(sourceInput.spinnerLabel)..."
) {
try await context.loadColors(from: sourceInput)
}
Expand Down
1 change: 1 addition & 0 deletions Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public extension Flutter.IconsEntry {
/// Returns an IconsSourceInput for use with IconsExportContext.
func iconsSourceInput(darkFileId: String? = nil) -> IconsSourceInput {
IconsSourceInput(
sourceKind: sourceKind?.coreSourceKind ?? .figma,
figmaFileId: figmaFileId,
darkFileId: darkFileId,
frameName: figmaFrameName ?? "Icons",
Expand Down
2 changes: 2 additions & 0 deletions Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public extension Flutter.ImagesEntry {
/// Returns an ImagesSourceInput for use with ImagesExportContext.
func imagesSourceInput(darkFileId: String? = nil) -> ImagesSourceInput {
ImagesSourceInput(
sourceKind: sourceKind?.coreSourceKind ?? .figma,
figmaFileId: figmaFileId,
darkFileId: darkFileId,
frameName: figmaFrameName ?? "Images",
Expand Down Expand Up @@ -43,6 +44,7 @@ public extension Flutter.ImagesEntry {
/// Returns an ImagesSourceInput configured for SVG source.
func svgSourceInput(darkFileId: String? = nil) -> ImagesSourceInput {
ImagesSourceInput(
sourceKind: sourceKind?.coreSourceKind ?? .figma,
figmaFileId: figmaFileId,
darkFileId: darkFileId,
frameName: figmaFrameName ?? "Images",
Expand Down
2 changes: 1 addition & 1 deletion Sources/ExFig-Flutter/Export/FlutterColorsExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public struct FlutterColorsExporter: ColorsExporter {
// 1. Load colors from Figma
let sourceInput = try entry.validatedColorsSourceInput()
let colors = try await context.withSpinner(
"Fetching colors from Figma (\(sourceInput.tokensCollectionName))..."
"Fetching colors from \(sourceInput.spinnerLabel)..."
) {
try await context.loadColors(from: sourceInput)
}
Expand Down
1 change: 1 addition & 0 deletions Sources/ExFig-Web/Config/WebIconsEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public extension Web.IconsEntry {
/// Returns an IconsSourceInput for use with IconsExportContext.
func iconsSourceInput(darkFileId: String? = nil) -> IconsSourceInput {
IconsSourceInput(
sourceKind: sourceKind?.coreSourceKind ?? .figma,
figmaFileId: figmaFileId,
darkFileId: darkFileId,
frameName: figmaFrameName ?? "Icons",
Expand Down
1 change: 1 addition & 0 deletions Sources/ExFig-Web/Config/WebImagesEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public extension Web.ImagesEntry {
/// Returns an ImagesSourceInput for use with ImagesExportContext.
func imagesSourceInput(darkFileId: String? = nil) -> ImagesSourceInput {
ImagesSourceInput(
sourceKind: sourceKind?.coreSourceKind ?? .figma,
figmaFileId: figmaFileId,
darkFileId: darkFileId,
frameName: figmaFrameName ?? "Images",
Expand Down
2 changes: 1 addition & 1 deletion Sources/ExFig-Web/Export/WebColorsExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public struct WebColorsExporter: ColorsExporter {
// 1. Load colors from Figma
let sourceInput = try entry.validatedColorsSourceInput()
let colors = try await context.withSpinner(
"Fetching colors from Figma (\(sourceInput.tokensCollectionName))..."
"Fetching colors from \(sourceInput.spinnerLabel)..."
) {
try await context.loadColors(from: sourceInput)
}
Expand Down
1 change: 1 addition & 0 deletions Sources/ExFig-iOS/Config/iOSIconsEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public extension iOS.IconsEntry {
/// Returns an IconsSourceInput for use with IconsExportContext.
func iconsSourceInput(darkFileId: String? = nil) -> IconsSourceInput {
IconsSourceInput(
sourceKind: sourceKind?.coreSourceKind ?? .figma,
figmaFileId: figmaFileId,
darkFileId: darkFileId,
frameName: figmaFrameName ?? "Icons",
Expand Down
1 change: 1 addition & 0 deletions Sources/ExFig-iOS/Config/iOSImagesEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public extension iOS.ImagesEntry {
/// Returns an ImagesSourceInput for use with ImagesExportContext.
func imagesSourceInput(darkFileId: String? = nil) -> ImagesSourceInput {
ImagesSourceInput(
sourceKind: sourceKind?.coreSourceKind ?? .figma,
figmaFileId: figmaFileId,
darkFileId: darkFileId,
frameName: figmaFrameName ?? "Images",
Expand Down
2 changes: 1 addition & 1 deletion Sources/ExFig-iOS/Export/iOSColorsExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public struct iOSColorsExporter: ColorsExporter {
// 1. Load colors from Figma
let sourceInput = try entry.validatedColorsSourceInput()
let colors = try await context.withSpinner(
"Fetching colors from Figma (\(sourceInput.tokensCollectionName))..."
"Fetching colors from \(sourceInput.spinnerLabel)..."
) {
try await context.loadColors(from: sourceInput)
}
Expand Down
58 changes: 34 additions & 24 deletions Sources/ExFigCLI/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,30 +148,33 @@ 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 |
| 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/TokensFileColorsSource.swift` | Local .tokens.json source (extracted from ColorsExportContextImpl) |

### MCP Server Architecture

Expand Down Expand Up @@ -203,6 +206,13 @@ reserved for MCP JSON-RPC protocol.

## Modification Patterns

### Source Dispatch Pattern

`ColorsExportContextImpl.loadColors()` creates source via `SourceFactory.createColorsSource(for:...)` per call.
`IconsExportContextImpl` / `ImagesExportContextImpl` still use injected `componentsSource` (only Figma supported).
`PluginColorsExport` does NOT create sources — context handles dispatch internally.
When adding a new source kind: update `SourceFactory`, add source impl in `Source/`, update error `assetType`.

### Adding a New Subcommand

1. Create `Subcommands/NewCommand.swift` implementing `AsyncParsableCommand`
Expand Down
67 changes: 4 additions & 63 deletions Sources/ExFigCLI/Context/ColorsExportContextImpl.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import ExFigConfig
import ExFigCore
import FigmaAPI
import Foundation

/// Concrete implementation of `ColorsExportContext` for the ExFig CLI.
///
/// Bridges between the plugin system and ExFig's internal services:
/// - Uses `ColorsVariablesLoader` for Figma data loading
/// - Uses `SourceFactory` for per-entry source dispatch based on `sourceKind`
/// - Uses `ColorsProcessor` for platform-specific processing
/// - Uses `ExFigCommand.fileWriter` for file output
/// - Uses `TerminalUI` for progress and logging
Expand Down Expand Up @@ -56,68 +55,10 @@ struct ColorsExportContextImpl: ColorsExportContext {
// MARK: - ColorsExportContext

func loadColors(from source: ColorsSourceInput) async throws -> ColorsLoadOutput {
if let tokensFilePath = source.tokensFilePath {
// Warn if mode-related fields are configured but will be ignored
if source.darkModeName != nil || source.lightHCModeName != nil || source.darkHCModeName != nil {
ui.warning(
"Local tokens file provides single-mode colors only"
+ " — darkModeName/lightHCModeName/darkHCModeName will be ignored"
)
}
return try loadColorsFromTokensFile(path: tokensFilePath, groupFilter: source.tokensFileGroupFilter)
}
return try await loadColorsFromFigma(source: source)
}

private func loadColorsFromTokensFile(path: String, groupFilter: String?) throws -> ColorsLoadOutput {
var source = try TokensFileSource.parse(fileAt: path)
try source.resolveAliases()

for warning in source.warnings {
ui.warning(warning)
}

var colors = source.toColors()

if let groupFilter {
let prefix = groupFilter.replacingOccurrences(of: ".", with: "/") + "/"
colors = colors.filter { $0.name.hasPrefix(prefix) }
}

return ColorsLoadOutput(light: colors)
}

private func loadColorsFromFigma(source: ColorsSourceInput) async throws -> ColorsLoadOutput {
let variableParams = Common.VariablesColors(
tokensFileId: source.tokensFileId,
tokensCollectionName: source.tokensCollectionName,
lightModeName: source.lightModeName,
darkModeName: source.darkModeName,
lightHCModeName: source.lightHCModeName,
darkHCModeName: source.darkHCModeName,
primitivesModeName: source.primitivesModeName,
nameValidateRegexp: source.nameValidateRegexp,
nameReplaceRegexp: source.nameReplaceRegexp
)

let loader = ColorsVariablesLoader(
client: client,
variableParams: variableParams,
filter: filter
)

let result = try await loader.load()

for warning in result.warnings {
ui.warning(warning)
}

return ColorsLoadOutput(
light: result.output.light,
dark: result.output.dark ?? [],
lightHC: result.output.lightHC ?? [],
darkHC: result.output.darkHC ?? []
let colorsSource = try SourceFactory.createColorsSource(
for: source, client: client, ui: ui, filter: filter
)
return try await colorsSource.loadColors(from: source)
}

func processColors(
Expand Down
31 changes: 4 additions & 27 deletions Sources/ExFigCLI/Context/IconsExportContextImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import Foundation
/// - Supports granular cache for incremental exports
struct IconsExportContextImpl: IconsExportContextWithGranularCache {
let client: Client
let componentsSource: any ComponentsSource
let ui: TerminalUI
let params: PKLConfig
let filter: String?
Expand All @@ -25,6 +26,7 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache {

init(
client: Client,
componentsSource: any ComponentsSource,
ui: TerminalUI,
params: PKLConfig,
filter: String? = nil,
Expand All @@ -35,6 +37,7 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache {
platform: Platform
) {
self.client = client
self.componentsSource = componentsSource
self.ui = ui
self.params = params
self.filter = filter
Expand Down Expand Up @@ -77,33 +80,7 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache {
// MARK: - IconsExportContext

func loadIcons(from source: IconsSourceInput) async throws -> IconsLoadOutput {
// Create loader config from source input
let config = IconsLoaderConfig(
entryFileId: source.figmaFileId,
frameName: source.frameName,
pageName: source.pageName,
format: source.format,
renderMode: source.renderMode,
renderModeDefaultSuffix: source.renderModeDefaultSuffix,
renderModeOriginalSuffix: source.renderModeOriginalSuffix,
renderModeTemplateSuffix: source.renderModeTemplateSuffix,
rtlProperty: source.rtlProperty
)

let loader = IconsLoader(
client: client,
params: params,
platform: platform,
logger: ExFigCommand.logger,
config: config
)

let result = try await loader.load(filter: filter)

return IconsLoadOutput(
light: result.light,
dark: result.dark ?? []
)
try await componentsSource.loadIcons(from: source)
}

func processIcons(
Expand Down
32 changes: 4 additions & 28 deletions Sources/ExFigCLI/Context/ImagesExportContextImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import Foundation
/// - Supports granular cache for incremental exports
struct ImagesExportContextImpl: ImagesExportContextWithGranularCache {
let client: Client
let componentsSource: any ComponentsSource
let ui: TerminalUI
let params: PKLConfig
let filter: String?
Expand All @@ -30,6 +31,7 @@ struct ImagesExportContextImpl: ImagesExportContextWithGranularCache {

init(
client: Client,
componentsSource: any ComponentsSource,
ui: TerminalUI,
params: PKLConfig,
filter: String? = nil,
Expand All @@ -40,6 +42,7 @@ struct ImagesExportContextImpl: ImagesExportContextWithGranularCache {
platform: Platform
) {
self.client = client
self.componentsSource = componentsSource
self.ui = ui
self.params = params
self.filter = filter
Expand Down Expand Up @@ -82,34 +85,7 @@ struct ImagesExportContextImpl: ImagesExportContextWithGranularCache {
// MARK: - ImagesExportContext

func loadImages(from source: ImagesSourceInput) async throws -> ImagesLoadOutput {
// Convert source format
let loaderSourceFormat: ImagesSourceFormat = source.sourceFormat == .svg ? .svg : .png

// Create loader config from source input
let config = ImagesLoaderConfig(
entryFileId: source.figmaFileId,
frameName: source.frameName,
pageName: source.pageName,
scales: source.scales,
format: nil, // Format is determined by platform exporter
sourceFormat: loaderSourceFormat,
rtlProperty: source.rtlProperty
)

let loader = ImagesLoader(
client: client,
params: params,
platform: platform,
logger: ExFigCommand.logger,
config: config
)

let result = try await loader.load(filter: filter)

return ImagesLoadOutput(
light: result.light,
dark: result.dark ?? []
)
try await componentsSource.loadImages(from: source)
}

func processImages(
Expand Down
Loading
Loading