commit
28a901e6ae
22 changed files with 284 additions and 85 deletions
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
@ -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}"
|
||||
|
|
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
|
@ -5,7 +5,7 @@ on:
|
|||
|
||||
jobs:
|
||||
BuildAndTest:
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-12
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
|
|
|
@ -1 +1 @@
|
|||
5.3
|
||||
5.7
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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"),
|
||||
]
|
||||
),
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
|
151
Sources/MakeColors/Importers/Figma/FigmaImporter.swift
Normal file
151
Sources/MakeColors/Importers/Figma/FigmaImporter.swift
Normal 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))
|
||||
}
|
||||
}
|
15
Sources/MakeColors/Importers/Importer.swift
Normal file
15
Sources/MakeColors/Importers/Importer.swift
Normal 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()
|
||||
}
|
||||
}
|
38
Sources/MakeColors/Importers/List/ListImporter.swift
Normal file
38
Sources/MakeColors/Importers/List/ListImporter.swift
Normal 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
|
||||
}
|
||||
}
|
|
@ -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)
|
|
@ -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`)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
import LibMakeColors
|
||||
|
||||
MakeColors.main()
|
|
@ -1,4 +1,4 @@
|
|||
@testable import LibMakeColors
|
||||
@testable import MakeColors
|
||||
import RBBJSON
|
||||
import XCTest
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
@testable import LibMakeColors
|
||||
@testable import MakeColors
|
||||
import XCTest
|
||||
|
||||
final class ColorHSVTest: XCTestCase {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
@testable import LibMakeColors
|
||||
@testable import MakeColors
|
||||
import XCTest
|
||||
|
||||
final class ColorParserTest: XCTestCase {
|
||||
|
|
Loading…
Add table
Reference in a new issue