diff --git a/Package.swift b/Package.swift index 0c21422..05a09d5 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "MakeColors", platforms: [ - .macOS("10.15.4"), + .macOS("12.0"), ], dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser", .upToNextMinor(from: "1.1.4")), diff --git a/Sources/MakeColors/Importers/Figma/FigmaImporter.swift b/Sources/MakeColors/Importers/Figma/FigmaImporter.swift new file mode 100644 index 0000000..488f4d8 --- /dev/null +++ b/Sources/MakeColors/Importers/Figma/FigmaImporter.swift @@ -0,0 +1,149 @@ +import Foundation + +enum FigmaErrors: Error { + case invalidUrl + case missingToken + case invalidResponse + case missingColor(String) +} + +class FigmaImporter: Importer { + let key: String + let token: String + + required init(source: String) throws { + // https://www.figma.com/file/:key/:title + guard + let url = URL(string: source), + url.host == "www.figma.com", + url.pathComponents.count >= 4, + url.pathComponents[1] == "file" + else { + throw FigmaErrors.invalidUrl + } + + key = url.pathComponents[2] + + guard let token = ProcessInfo.processInfo.environment["FIGMA_TOKEN"] else { + throw FigmaErrors.missingToken + } + + self.token = token + } + + func read() async throws -> [String: ColorDef] { + let styles = try await request(StylesResponse.self, path: "/v1/files/\(key)/styles").meta.styles + .filter { $0.styleType == "FILL" } + + let ids = styles.map(\.nodeId).joined(separator: ",") + + let nodes = try await request( + NodesResponse.self, + path: "/v1/files/\(key)/nodes", + query: [URLQueryItem(name: "ids", value: ids)] + ) + .nodes + + var result: [String: ColorDef] = [:] + result.reserveCapacity(styles.count) + + for style in styles { + guard + let node = nodes[style.nodeId], + let fill = node.document.fills.first(where: { $0.type == "SOLID" }) + else { + throw FigmaErrors.missingColor(style.name) + } + + if node.document.fills.count > 1 { + print("Warning: Multiple fills defined for \(style.name)") + } + + if fill.blendMode != "NORMAL" { + print("Warning: Blend mode \(fill.blendMode) used for \(style.name)") + } + + guard !result.keys.contains(style.name) else { + throw Errors.duplicateColor(style.name) + } + + result[style.name] = .color(Color(fill.color)) + } + + return result + } + + func request(_: T.Type = T.self, path: String, query: [URLQueryItem]? = nil) async throws -> T { + var components = URLComponents() + components.scheme = "https" + components.host = "api.figma.com" + components.path = path + components.queryItems = query + + guard let url = components.url else { + fatalError("Cannot create url. Components: \(components)") + } + + var request = URLRequest(url: url) + request.setValue(token, forHTTPHeaderField: "X-Figma-Token") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let response = response as? HTTPURLResponse else { + fatalError("Non-HTTP-Response received: \(response)") + } + + guard response.statusCode == 200 else { + throw FigmaErrors.invalidResponse + } + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode(T.self, from: data) + } +} + +struct StylesResponse: Decodable { + var meta: Meta + struct Meta: Decodable { + var styles: [Style] + } + + struct Style: Decodable { + var nodeId: String + var styleType: String + var name: String + var description: String + } +} + +struct NodesResponse: Decodable { + var nodes: [String: Node] + + struct Node: Decodable { + var document: Document + } + + struct Document: Decodable { + var fills: [Fill] + } + + struct Fill: Decodable { + var blendMode: String + var type: String + var color: Color + } + + struct Color: Decodable { + var r, g, b, a: Float + } +} + +extension Color { + init(_ color: NodesResponse.Color) { + red = UInt8(truncatingIfNeeded: Int(color.r * 0xFF)) + green = UInt8(truncatingIfNeeded: Int(color.g * 0xFF)) + blue = UInt8(truncatingIfNeeded: Int(color.b * 0xFF)) + alpha = UInt8(truncatingIfNeeded: Int(color.a * 0xFF)) + } +} diff --git a/Sources/MakeColors/MakeColors.swift b/Sources/MakeColors/MakeColors.swift index c19563f..12a5ff3 100644 --- a/Sources/MakeColors/MakeColors.swift +++ b/Sources/MakeColors/MakeColors.swift @@ -22,6 +22,7 @@ private struct GeneratorOption: EnumerableFlag, CustomStringConvertible { private struct ImporterOption: CaseIterable, ExpressibleByArgument, CustomStringConvertible { static let allCases: [ImporterOption] = [ .list, + .init(type: FigmaImporter.self), ] static let list = ImporterOption(type: ListImporter.self)