Merge pull request #4 from 5sw/figma

Import colors from Figma library
This commit is contained in:
Sven Weidauer 2022-10-08 13:14:23 +02:00 committed by GitHub
commit 28a901e6ae
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 284 additions and 85 deletions

View file

@ -24,7 +24,7 @@ jobs:
head "https://github.com/${{ github.repository }}.git"
license "MIT"
depends_on :xcode => ["12.0", :build]
depends_on :xcode => ["14.0", :build]
def install
system "make", "install", "prefix=#{prefix}"

View file

@ -5,7 +5,7 @@ on:
jobs:
BuildAndTest:
runs-on: macos-latest
runs-on: macos-12
steps:
- uses: actions/checkout@v1

View file

@ -1 +1 @@
5.3
5.7

View file

@ -1,25 +1,23 @@
{
"object": {
"pins": [
{
"package": "RBBJSON",
"repositoryURL": "https://github.com/robb/RBBJSON",
"state": {
"branch": "main",
"revision": "102c970283e105d7c5be2e29630db29c808c20eb",
"version": null
}
},
{
"package": "swift-argument-parser",
"repositoryURL": "https://github.com/apple/swift-argument-parser",
"state": {
"branch": null,
"revision": "92646c0cdbaca076c8d3d0207891785b3379cbff",
"version": "0.3.1"
}
"pins" : [
{
"identity" : "rbbjson",
"kind" : "remoteSourceControl",
"location" : "https://github.com/robb/RBBJSON",
"state" : {
"branch" : "main",
"revision" : "102c970283e105d7c5be2e29630db29c808c20eb"
}
]
},
"version": 1
},
{
"identity" : "swift-argument-parser",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser",
"state" : {
"revision" : "9f39744e025c7d377987f30b03770805dcb0bcd1",
"version" : "1.1.4"
}
}
],
"version" : 2
}

View file

@ -1,4 +1,4 @@
// swift-tools-version:5.3
// swift-tools-version:5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
@ -6,21 +6,15 @@ 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: "0.3.1")),
.package(url: "https://github.com/robb/RBBJSON", .branch("main")),
.package(url: "https://github.com/apple/swift-argument-parser", .upToNextMinor(from: "1.1.4")),
.package(url: "https://github.com/robb/RBBJSON", branch: "main"),
],
targets: [
.target(
.executableTarget(
name: "MakeColors",
dependencies: [
"LibMakeColors",
]
),
.target(
name: "LibMakeColors",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
]
@ -28,7 +22,7 @@ let package = Package(
.testTarget(
name: "MakeColorsTests",
dependencies: [
"LibMakeColors",
"MakeColors",
.product(name: "RBBJSON", package: "RBBJSON"),
]
),

View file

@ -1,6 +1,6 @@
import Foundation
protocol Generator: class {
protocol Generator: AnyObject {
static var defaultExtension: String { get }
static var option: String { get }
@ -9,7 +9,7 @@ protocol Generator: class {
func generate(data: [String: ColorDef]) throws -> FileWrapper
}
protocol Context: class {
protocol Context: AnyObject {
var prefix: String? { get }
}

View file

@ -0,0 +1,151 @@
import Foundation
enum FigmaErrors: Error {
case invalidUrl
case missingToken
case invalidResponse
case missingColor(String)
}
class FigmaImporter: Importer {
let key: String
let token: String
let outputName: 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]
outputName = url.pathComponents[3]
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: Decodable>(_: 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))
}
}

View file

@ -0,0 +1,15 @@
protocol Importer {
init(source: String) throws
func read() async throws -> [String: ColorDef]
var outputName: String { get }
static var option: String { get }
}
extension Importer {
static var option: String {
String(describing: self).droppingSuffix("Importer").lowercased()
}
}

View file

@ -0,0 +1,38 @@
import Foundation
struct ListImporter: Importer {
let input: String
var outputName: String
init(source: String) {
input = source
outputName = URL(fileURLWithPath: source).deletingPathExtension().lastPathComponent
}
func read() throws -> [String: ColorDef] {
let scanner = Scanner(string: try readInput())
scanner.charactersToBeSkipped = .whitespaces
return try scanner.colorList()
}
func readInput() throws -> String {
if input == "-" {
return try readStdin()
}
let url = URL(fileURLWithPath: input)
return try String(contentsOf: url)
}
func readStdin() throws -> String {
guard
let data = try FileHandle.standardInput.readToEnd(),
let input = String(data: data, encoding: .utf8)
else {
throw Errors.cannotReadStdin
}
return input
}
}

View file

@ -110,7 +110,7 @@ extension Scanner {
func colorLine() -> (String, ColorDef)? {
guard
let name = self.name(),
let name = name(),
let def = colorDef(),
endOfLine()
else {
@ -168,7 +168,7 @@ extension Scanner {
func commaSeparated() -> [UInt8]? {
var result: [UInt8] = []
repeat {
guard let component = self.component() else {
guard let component = component() else {
return nil
}
result.append(component)

View file

@ -19,6 +19,39 @@ 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)
let type: Importer.Type
init(type: Importer.Type) {
self.type = type
}
init?(argument: String) {
guard
let found = Self.allCases
.first(where: { $0.description.caseInsensitiveCompare(argument) == .orderedSame })
else {
return nil
}
self = found
}
var description: String {
type.option
}
static func == (lhs: ImporterOption, rhs: ImporterOption) -> Bool {
lhs.type == rhs.type
}
}
enum Errors: Error {
case syntaxError
case duplicateColor(String)
@ -47,13 +80,17 @@ enum HelpTexts {
)
}
public final class MakeColors: ParsableCommand, Context {
@main
public final class MakeColors: AsyncParsableCommand, Context {
@Argument(help: HelpTexts.input)
var input: String
@Flag(help: "The formatter to use.")
private var formatter = GeneratorOption.allCases[0]
@Option(help: "The importer to use.")
private var importer = ImporterOption.list
@Option(help: "Prefix for color names.")
var prefix: String?
@ -65,11 +102,9 @@ public final class MakeColors: ParsableCommand, Context {
public init() {}
public func run() throws {
let scanner = Scanner(string: try readInput())
scanner.charactersToBeSkipped = .whitespaces
let data = try scanner.colorList()
public func run() async throws {
let importer = try importer.type.init(source: input)
let data = try await importer.read()
if dump {
try dump(data: data)
@ -78,27 +113,7 @@ public final class MakeColors: ParsableCommand, Context {
let generator = formatter.type.init(context: self)
let fileWrapper = try generator.generate(data: data)
try writeOutput(fileWrapper)
}
func readInput() throws -> String {
if input == "-" {
return try readStdin()
}
let url = URL(fileURLWithPath: input)
return try String(contentsOf: url)
}
func readStdin() throws -> String {
guard
let data = try FileHandle.standardInput.readToEnd(),
let input = String(data: data, encoding: .utf8)
else {
throw Errors.cannotReadStdin
}
return input
try writeOutput(fileWrapper, name: output ?? "\(importer.outputName).\(formatter.type.defaultExtension)")
}
func dump(data: [String: ColorDef]) throws {
@ -118,7 +133,7 @@ public final class MakeColors: ParsableCommand, Context {
}
}
func writeOutput(_ wrapper: FileWrapper) throws {
func writeOutput(_ wrapper: FileWrapper, name: String) throws {
if shouldWriteToStdout {
guard wrapper.isRegularFile, let contents = wrapper.regularFileContents else {
throw Errors.cannotWriteWrapperToStdout
@ -126,19 +141,10 @@ public final class MakeColors: ParsableCommand, Context {
FileHandle.standardOutput.write(contents)
} else {
let writeURL = outputURL(extension: formatter.type.defaultExtension)
let writeURL = URL(fileURLWithPath: name)
try wrapper.write(to: writeURL, options: .atomic, originalContentsURL: nil)
}
}
var shouldWriteToStdout: Bool { output == "-" || (input == "-" && output == nil) }
func outputURL(extension: String) -> URL {
if let output = output {
return URL(fileURLWithPath: output)
} else {
let basename = URL(fileURLWithPath: input).deletingPathExtension().lastPathComponent
return URL(fileURLWithPath: basename).appendingPathExtension(`extension`)
}
}
}

View file

@ -1,3 +0,0 @@
import LibMakeColors
MakeColors.main()

View file

@ -1,4 +1,4 @@
@testable import LibMakeColors
@testable import MakeColors
import RBBJSON
import XCTest

View file

@ -1,4 +1,4 @@
@testable import LibMakeColors
@testable import MakeColors
import XCTest
final class ColorHSVTest: XCTestCase {

View file

@ -1,4 +1,4 @@
@testable import LibMakeColors
@testable import MakeColors
import XCTest
final class ColorParserTest: XCTestCase {