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
34 changes: 18 additions & 16 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ and Flutter projects.
./bin/mise run format # Format all (Swift + Markdown)
./bin/mise run format:swift # Format Swift only
./bin/mise run format:md # Format Markdown only
./bin/mise run format-check # Check formatting (CI) - fix with format:swift
./bin/mise run lint # SwiftLint + actionlint

# Docs & Coverage
Expand Down Expand Up @@ -190,22 +191,23 @@ Templates are in `Sources/*/Resources/`. Use Stencil syntax. Update tests after

## Dependencies

| Package | Version | Purpose |
| --------------------- | ------- | -------------------------- |
| swift-argument-parser | 1.5.0+ | CLI framework |
| swift-collections | 1.2.x | Ordered collections |
| Yams | 5.3.0+ | YAML parsing |
| Stencil | 0.15.1+ | Template engine |
| StencilSwiftKit | 2.10.1+ | Swift Stencil extensions |
| XcodeProj | 8.27.0+ | Xcode project manipulation |
| swift-log | 1.6.0+ | Logging |
| Rainbow | 4.2.0+ | Terminal colors |
| libwebp | 1.4.1+ | WebP encoding |
| libpng | 1.6.45+ | PNG decoding |
| swift-custom-dump | 1.3.0+ | Test assertions |
| Noora | 0.54.0+ | Terminal UI design system |
| swift-resvg | 0.45.1 | SVG parsing/rendering |
| swift-docc-plugin | 1.4.5+ | DocC documentation |
| Package | Version | Purpose |
| --------------------- | ------- | --------------------------- |
| swift-argument-parser | 1.5.0+ | CLI framework |
| swift-collections | 1.2.x | Ordered collections |
| Yams | 5.3.0+ | YAML parsing |
| Stencil | 0.15.1+ | Template engine |
| StencilSwiftKit | 2.10.1+ | Swift Stencil extensions |
| XcodeProj | 8.27.0+ | Xcode project manipulation |
| swift-log | 1.6.0+ | Logging |
| Rainbow | 4.2.0+ | Terminal colors |
| libwebp | 1.4.1+ | WebP encoding |
| libpng | 1.6.45+ | PNG decoding |
| swift-custom-dump | 1.3.0+ | Test assertions |
| Noora | 0.54.0+ | Terminal UI design system |
| swift-resvg | 0.45.1 | SVG parsing/rendering |
| swift-docc-plugin | 1.4.5+ | DocC documentation |
| swift-yyjson | 0.4.0+ | High-performance JSON codec |

## Troubleshooting

Expand Down
19 changes: 14 additions & 5 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 9 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ let package = Package(
.package(url: "https://github.com/tuist/Noora", from: "0.54.0"),
.package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.4.5"),
.package(url: "https://github.com/alexey1312/swift-resvg.git", exact: "0.45.1-swift.3"),
.package(url: "https://github.com/mattt/swift-yyjson", from: "0.4.0"),
],
targets: [
// Main target
Expand Down Expand Up @@ -53,12 +54,18 @@ let package = Package(

// Shared target
.target(
name: "ExFigCore"
name: "ExFigCore",
dependencies: [
.product(name: "YYJSON", package: "swift-yyjson"),
]
),

// Loads data via Figma REST API
.target(
name: "FigmaAPI"
name: "FigmaAPI",
dependencies: [
"ExFigCore",
]
),

// Exports resources to Xcode project
Expand Down
8 changes: 6 additions & 2 deletions Sources/AndroidExport/AndroidThemeAttributesExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,14 @@ public struct ThemeAttributesExportResult: Sendable {
public let collisions: [ThemeAttributeCollision]

/// Number of attributes generated.
public var count: Int { attributeMap.count }
public var count: Int {
attributeMap.count
}

/// Whether any collisions were detected.
public var hasCollisions: Bool { !collisions.isEmpty }
public var hasCollisions: Bool {
!collisions.isEmpty
}

public init(
attrsContent: String,
Expand Down
2 changes: 1 addition & 1 deletion Sources/ExFig/Batch/BatchExecutor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ actor BatchExecutor {
var running = 0
var configIterator = configs.makeIterator()

// Helper to create rate-limited client and process config
/// Helper to create rate-limited client and process config
func processConfig(_ config: ConfigFile) async -> ConfigResult {
let rateLimitedClient = Self.makeRateLimitedClient(
for: config,
Expand Down
4 changes: 3 additions & 1 deletion Sources/ExFig/Batch/BatchResult.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,9 @@ struct GranularCacheStats: Sendable {
let exported: Int

/// Total nodes processed.
var total: Int { skipped + exported }
var total: Int {
skipped + exported
}

/// Merges two stats, returning nil if both are nil.
static func merge(_ lhs: GranularCacheStats?, _ rhs: GranularCacheStats?) -> GranularCacheStats? {
Expand Down
12 changes: 5 additions & 7 deletions Sources/ExFig/Batch/FileVersionPreFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ struct FileVersionPreFetcher: Sendable {
// Pre-fetch failed, proceed without optimization
// Individual configs will fetch their own metadata
if configuration.verbose {
ui.warning("Pre-fetch failed: \(error.localizedDescription)")
ui.warning("Pre-fetch failed: \(error.bestDescription)")
}
return nil
}
Expand Down Expand Up @@ -161,7 +161,7 @@ struct FileVersionPreFetcher: Sendable {
)
} catch {
if configuration.verbose {
ui.warning("Pre-fetch failed: \(error.localizedDescription)")
ui.warning("Pre-fetch failed: \(error.bestDescription)")
}
return PreFetchResult(versions: nil, components: nil, nodes: nil)
}
Expand Down Expand Up @@ -325,7 +325,7 @@ struct FileVersionPreFetcher: Sendable {
let metadata = try await client.request(endpoint)
return (fileId, metadata)
} catch {
logger.warning("Pre-fetch metadata failed for file \(fileId): \(error.localizedDescription)")
logger.warning("Pre-fetch metadata failed for file \(fileId): \(error.bestDescription)")
return (fileId, nil)
}
}
Expand Down Expand Up @@ -381,13 +381,11 @@ struct FileVersionPreFetcher: Sendable {
// Capture into let for concurrent access
let nodeIdsByFileSnapshot = nodeIdsByFile

let result = try await ui.withSpinner(
return try await ui.withSpinner(
"Pre-fetching nodes for granular cache (\(totalNodes) nodes)..."
) {
try await fetchAllNodes(nodeIdsByFile: nodeIdsByFileSnapshot)
}

return result
}

/// Fetch node documents for all files in parallel.
Expand Down Expand Up @@ -433,7 +431,7 @@ struct FileVersionPreFetcher: Sendable {
let components = try await client.request(endpoint)
return (fileId, components)
} catch {
logger.warning("Pre-fetch components failed for file \(fileId): \(error.localizedDescription)")
logger.warning("Pre-fetch components failed for file \(fileId): \(error.bestDescription)")
return (fileId, nil)
}
}
Expand Down
7 changes: 4 additions & 3 deletions Sources/ExFig/Cache/BatchCheckpoint.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import ExFigCore
import Foundation

/// Checkpoint for resuming batch processing of multiple configs.
Expand Down Expand Up @@ -97,7 +98,7 @@ public extension BatchCheckpoint {
}

let data = try Data(contentsOf: fileURL)
let decoder = JSONDecoder()
var decoder = JSONCodec.makeDecoder()
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode(BatchCheckpoint.self, from: data)
}
Expand All @@ -107,9 +108,9 @@ public extension BatchCheckpoint {
func save(to directory: URL) throws {
let fileURL = directory.appendingPathComponent(Self.fileName)

let encoder = JSONEncoder()
var encoder = JSONCodec.makeEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.writeOptions = [.prettyPrinted]

let data = try encoder.encode(self)
try data.write(to: fileURL, options: .atomic)
Comment thread
alexey1312 marked this conversation as resolved.
Expand Down
3 changes: 1 addition & 2 deletions Sources/ExFig/Cache/CheckpointTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,7 @@ actor CheckpointTracker {
return nil
}

let tracker = CheckpointTracker(existing: existing, directory: directory, assetType: assetType)
return tracker
return CheckpointTracker(existing: existing, directory: directory, assetType: assetType)
}

/// Private init for loading existing checkpoint.
Expand Down
7 changes: 4 additions & 3 deletions Sources/ExFig/Cache/ExportCheckpoint.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import ExFigCore
import Foundation

/// Checkpoint for resuming interrupted exports.
Expand Down Expand Up @@ -146,7 +147,7 @@ public extension ExportCheckpoint {
}

let data = try Data(contentsOf: fileURL)
let decoder = JSONDecoder()
var decoder = JSONCodec.makeDecoder()
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode(ExportCheckpoint.self, from: data)
}
Expand All @@ -156,9 +157,9 @@ public extension ExportCheckpoint {
func save(to directory: URL) throws {
let fileURL = directory.appendingPathComponent(Self.fileName)

let encoder = JSONEncoder()
var encoder = JSONCodec.makeEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.writeOptions = [.prettyPrinted]

let data = try encoder.encode(self)
try data.write(to: fileURL, options: .atomic)
Comment thread
alexey1312 marked this conversation as resolved.
Expand Down
4 changes: 1 addition & 3 deletions Sources/ExFig/Cache/GranularCacheManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ final class GranularCacheManager: Sendable {
let batches = nodeIds.chunked(into: batchSize)

// Fetch all batches in parallel with limited concurrency
let allNodes = try await withThrowingTaskGroup(
return try await withThrowingTaskGroup(
of: [NodeId: Node].self
) { [self] group in
for batch in batches {
Expand All @@ -150,7 +150,5 @@ final class GranularCacheManager: Sendable {
}
return results
}

return allNodes
}
}
20 changes: 15 additions & 5 deletions Sources/ExFig/Cache/ImageTrackingCache.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import ExFigCore
import Foundation
import Logging

/// Cache model for tracking Figma file versions and node hashes.
/// Used to skip exports when files haven't changed since last export.
Expand Down Expand Up @@ -69,6 +71,8 @@ struct CachedFileInfo: Codable, Sendable {
// MARK: - Cache Persistence

extension ImageTrackingCache {
private static let logger = Logger(label: "com.alexey1312.exfig.image-tracking-cache")

/// Loads cache from a file at the specified path.
/// Returns an empty cache if file doesn't exist or is invalid.
/// Migrates from older schema versions automatically.
Expand All @@ -79,8 +83,7 @@ extension ImageTrackingCache {

do {
let data = try Data(contentsOf: path)
let decoder = JSONDecoder()
let cache = try decoder.decode(ImageTrackingCache.self, from: data)
let cache = try JSONCodec.decode(ImageTrackingCache.self, from: data)

// Migration: v1 → v2 (nodeHashes field added, backward compatible)
if cache.schemaVersion < currentSchemaVersion {
Expand All @@ -94,15 +97,22 @@ extension ImageTrackingCache {

return cache
} catch {
// Invalid cache file, return empty cache
// Log the error - helps diagnose cache compatibility issues after migrations
logger.warning(
"Cache file corrupted or incompatible, starting with empty cache",
metadata: [
"path": "\(path.path)",
"error": "\(error.localizedDescription)",
]
)
return ImageTrackingCache()
}
}

/// Saves cache to a file at the specified path.
func save(to path: URL) throws {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
var encoder = JSONCodec.makeEncoder()
encoder.writeOptions = [.prettyPrinted]
let data = try encoder.encode(self)
try data.write(to: path, options: .atomic)
Comment thread
alexey1312 marked this conversation as resolved.
}
Expand Down
20 changes: 14 additions & 6 deletions Sources/ExFig/Cache/NodeHasher.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import ExFigCore
import FigmaAPI
import Foundation
import Logging

/// Computes stable hashes for Figma node visual properties.
///
Expand All @@ -12,6 +14,8 @@ import Foundation
/// - Recursive hashing includes all children
/// - Float normalization handled at property creation time
enum NodeHasher {
private static let logger = Logger(label: "com.alexey1312.exfig.node-hasher")

/// Computes a stable hash for the given node properties.
///
/// The hash is computed from canonical JSON (sorted keys) of the
Expand All @@ -21,15 +25,19 @@ enum NodeHasher {
/// - Parameter properties: The hashable visual properties of a node.
/// - Returns: 16-character lowercase hex string (e.g., "a1b2c3d4e5f67890").
static func computeHash(_ properties: NodeHashableProperties) -> String {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]

do {
let data = try encoder.encode(properties)
let data = try JSONCodec.encodeSorted(properties)
return FNV1aHasher.hashToHex(data)
} catch {
// Encoding should never fail for Encodable types
// If it does, return a unique error hash to force re-export
// Log the error - this should never happen but needs visibility for debugging
logger.warning(
"NodeHasher encoding failed, returning zero hash to force re-export",
metadata: [
"nodeType": "\(properties.type)",
"error": "\(error.localizedDescription)",
]
)
// Return error hash to force re-export rather than silently skip
return "0000000000000000"
}
}
Expand Down
3 changes: 1 addition & 2 deletions Sources/ExFig/Input/FaultToleranceOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -193,13 +193,12 @@ struct HeavyFaultToleranceOptions: ParsableArguments {
}

// Create new checkpoint
let tracker = try CheckpointTracker(
return try CheckpointTracker(
configPath: configPath,
directory: workingDirectory,
assetType: assetType,
assetNames: assetNames
)
return tracker
}

/// Filter files to download based on checkpoint state.
Expand Down
Loading
Loading