diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..52366af
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+.DS_Store
+/.build
+/Packages
+xcuserdata/
+
diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/MakeColors.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/MakeColors.xcscheme
new file mode 100644
index 0000000..34cf953
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/MakeColors.xcscheme
@@ -0,0 +1,131 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Package.resolved b/Package.resolved
new file mode 100644
index 0000000..228d9c6
--- /dev/null
+++ b/Package.resolved
@@ -0,0 +1,16 @@
+{
+ "object": {
+ "pins": [
+ {
+ "package": "swift-argument-parser",
+ "repositoryURL": "https://github.com/apple/swift-argument-parser",
+ "state": {
+ "branch": null,
+ "revision": "92646c0cdbaca076c8d3d0207891785b3379cbff",
+ "version": "0.3.1"
+ }
+ }
+ ]
+ },
+ "version": 1
+}
diff --git a/Package.swift b/Package.swift
new file mode 100644
index 0000000..475bbaf
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,29 @@
+// swift-tools-version:5.3
+// The swift-tools-version declares the minimum version of Swift required to build this package.
+
+import PackageDescription
+
+let package = Package(
+ name: "MakeColors",
+ platforms: [
+ .macOS(.v10_15),
+ ],
+ dependencies: [
+ .package(url: "https://github.com/apple/swift-argument-parser", .upToNextMinor(from: "0.3.1")),
+ ],
+ targets: [
+ .target(
+ name: "MakeColors",
+ dependencies: [
+ "LibMakeColors"
+ ]),
+ .target(
+ name: "LibMakeColors",
+ dependencies: [
+ .product(name: "ArgumentParser", package: "swift-argument-parser"),
+ ]),
+ .testTarget(
+ name: "MakeColorsTests",
+ dependencies: ["LibMakeColors"]),
+ ]
+)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6d8471c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,4 @@
+# MakeColors
+
+Converts a simple list of color definitions to asset catalogs for Xcode, resource XML for Android or an HTML preview.
+
diff --git a/Sources/LibMakeColors/Color.swift b/Sources/LibMakeColors/Color.swift
new file mode 100644
index 0000000..c96c8c2
--- /dev/null
+++ b/Sources/LibMakeColors/Color.swift
@@ -0,0 +1,27 @@
+struct Color: CustomStringConvertible, Equatable {
+ var description: String {
+ return a != 0xFF ? String(format: "#%02X%02X%02X%02X", r, g, b, a): String(format: "#%02X%02X%02X", r, g, b)
+ }
+
+ let r, g, b, a: UInt8
+
+ init(r: UInt8, g: UInt8, b: UInt8, a: UInt8 = 0xFF) {
+ self.r = r
+ self.g = g
+ self.b = b
+ self.a = a
+ }
+
+ init?(_ array: [UInt8]) {
+ guard array.count >= 3 else { return nil }
+ r = array[0]
+ g = array[1]
+ b = array[2]
+ a = array.count >= 4 ? array[3] : 0xFF
+ }
+}
+
+enum ColorDef {
+ case reference(String)
+ case color(Color)
+}
diff --git a/Sources/LibMakeColors/MakeColors.swift b/Sources/LibMakeColors/MakeColors.swift
new file mode 100644
index 0000000..89090b4
--- /dev/null
+++ b/Sources/LibMakeColors/MakeColors.swift
@@ -0,0 +1,328 @@
+import ArgumentParser
+import Foundation
+
+enum Formatter: String, EnumerableFlag {
+ case ios
+ case android
+ case html
+}
+
+enum Errors: Error {
+ case syntaxError
+ case duplicateColor(String)
+ case missingReference(String)
+ case cyclicReference(String)
+}
+
+public struct MakeColors: ParsableCommand {
+ @Argument(help: "The color list to proces")
+ var input: String
+
+ @Flag(help: "The formatter to use")
+ var formatter = Formatter.ios
+
+ @Option(help: "Prefix for color names")
+ var prefix: String?
+
+ @Option(help: "Output file")
+ var output: String?
+
+ public init() {}
+
+ public func run() throws {
+ let url = URL(fileURLWithPath: input)
+ let string = try String(contentsOf: url)
+
+ let scanner = Scanner(string: string)
+ scanner.charactersToBeSkipped = .whitespaces
+
+ let data = try scanner.colorList()
+
+ print(data)
+
+ switch formatter {
+ case .ios:
+ try writeAssetCatalog(data: data)
+
+ case .android:
+ try writeAndroidXML(data: data)
+
+ case .html:
+ try writeHtmlPreview(data: data)
+ }
+ }
+
+ func outputURL(extension: String) -> URL {
+ if let output = output {
+ return URL(fileURLWithPath: output)
+ } else {
+ return URL(fileURLWithPath: input).deletingPathExtension().appendingPathExtension(`extension`)
+ }
+ }
+
+ func mapColorName(_ name: String) -> String {
+ mapSpaceColorName(name, separator: "_")
+ .replacingOccurrences(of: "/", with: "_")
+ .lowercased()
+ }
+
+ func mapSpaceColorName(_ name: String, separator: String = " ") -> String {
+ name.replacingOccurrences(
+ of: "(?<=[a-z0-9])([A-Z])",
+ with: "\(separator)$1",
+ options: .regularExpression,
+ range: nil
+ )
+ }
+
+ func writeAndroidXML(data: [String: ColorDef]) throws {
+ var xml = """
+
+
+
+ """
+
+ let prefix = self.prefix.map { mapColorName($0) + "_" } ?? ""
+
+ for (key, color) in data.sorted(by: compare) {
+ _ = try data.resolve(key)
+
+ let value: String
+ switch color {
+ case .color(let colorValue): value = colorValue.description
+ case .reference(let ref): value = "@color/\(prefix)\(mapColorName(ref))"
+ }
+
+ xml += """
+ \(value)
+
+ """
+ }
+
+ xml += """
+
+
+
+ """
+
+ try xml.write(to: outputURL(extension: "xml"), atomically: true, encoding: .utf8)
+ }
+
+ func writeHtmlPreview(data: [String: ColorDef]) throws {
+ var html = """
+
+
+
+
+
+
+
+ |
+ Name |
+ Value |
+
+
+
+
+ """
+
+ for (key, color) in data.sorted(by: compare) {
+ let actualColor = try data.resolve(key)
+ let value: String
+
+ switch color {
+ case let .reference(name): value = """
+ \(mapSpaceColorName(name))
\(actualColor)
+ """
+ case .color: value = actualColor.description
+ }
+
+ html += """
+
+ |
+ \(mapSpaceColorName(key)) |
+ \(value) |
+
+
+ """
+ }
+
+ html += """
+
+
+
+
+ """
+
+ try html.write(to: outputURL(extension: "html"), atomically: true, encoding: .utf8)
+ }
+
+ func writeAssetCatalog(data: [String: ColorDef]) throws {
+ let root = FileWrapper(directoryWithFileWrappers: ["Contents.json" : FileWrapper(catalog)])
+ let colorRoot: FileWrapper
+
+ if let prefix = prefix {
+ let name = mapSpaceColorName(prefix)
+ colorRoot = FileWrapper(directoryWithFileWrappers: ["Contents.json": FileWrapper(group)])
+ colorRoot.filename = name
+ colorRoot.preferredFilename = name
+ root.addFileWrapper(colorRoot)
+ } else {
+ colorRoot = root
+ }
+
+ for key in data.keys {
+ var path = mapSpaceColorName(key).split(separator: "/").map(\.capitalizeFirst)
+ let colorSet = path.removeLast()
+
+ var current = colorRoot
+ for pathSegment in path {
+ if let next = current.fileWrappers?[pathSegment] {
+ current = next
+ } else {
+ let next = FileWrapper(directoryWithFileWrappers: ["Contents.json": FileWrapper(group)])
+ next.filename = pathSegment
+ next.preferredFilename = pathSegment
+ _ = current.addFileWrapper(next)
+ current = next
+ }
+ }
+
+ let colorWrapper = try data.resolve(key).fileWrapper()
+ colorWrapper.filename = "\(colorSet).colorset"
+ colorWrapper.preferredFilename = "\(colorSet).colorset"
+
+ current.addFileWrapper(colorWrapper)
+ }
+
+ let outputUrl = outputURL(extension: "xcassets")
+
+ try root.write(to: outputUrl, options: .atomic, originalContentsURL: nil)
+ }
+}
+
+extension StringProtocol {
+ var capitalizeFirst: String {
+ guard !isEmpty else {
+ return String(self)
+ }
+
+ return prefix(1).uppercased() + dropFirst()
+ }
+}
+
+func compareDef(_ a: ColorDef, _ b: ColorDef) -> ComparisonResult {
+ switch (a, b) {
+ case (.color, .reference): return .orderedAscending
+ case (.reference, .color): return .orderedDescending
+ case (.color, .color), (.reference, .reference): return .orderedSame
+ }
+}
+
+func compare(_ a: (String, ColorDef), b: (String, ColorDef)) -> Bool {
+ switch (compareDef(a.1, b.1)) {
+ case .orderedAscending: return true
+ case .orderedDescending: return false
+ case .orderedSame: return a.0.localizedStandardCompare(b.0) == .orderedAscending
+ }
+}
+
+
+
+
+extension Dictionary where Key == String, Value == ColorDef {
+ func resolve(_ name: String, visited: Set = []) throws -> Color {
+ var visited = visited
+ guard visited.insert(name).inserted else {
+ throw Errors.cyclicReference(name)
+ }
+
+ switch self[name] {
+ case nil:
+ throw Errors.missingReference(name)
+
+ case .color(let color):
+ return color
+
+ case .reference(let referenced):
+ return try resolve(referenced, visited: visited)
+ }
+ }
+
+}
+
+extension Color {
+ func json() -> String {
+ return """
+ {
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "\(Float(a) / 256)",
+ "blue" : "0x\(String(b, radix: 16))",
+ "green" : "0x\(String(g, radix: 16))",
+ "red" : "0x\(String(r, radix: 16))"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+ }
+ """
+ }
+
+ func fileWrapper() -> FileWrapper {
+ FileWrapper(directoryWithFileWrappers: [
+ "Contents.json": FileWrapper(json())
+ ])
+ }
+}
+
+extension FileWrapper {
+ convenience init(_ string: String) {
+ self.init(regularFileWithContents: Data(string.utf8))
+ }
+}
+
+let group = """
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "provides-namespace" : true
+ }
+}
+"""
+
+let catalog = """
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
+"""
+
diff --git a/Sources/LibMakeColors/Parser.swift b/Sources/LibMakeColors/Parser.swift
new file mode 100644
index 0000000..9cce424
--- /dev/null
+++ b/Sources/LibMakeColors/Parser.swift
@@ -0,0 +1,126 @@
+import Foundation
+
+
+extension CharacterSet {
+ static let hex = CharacterSet(charactersIn: "0123456789abcdef")
+ static let name = alphanumerics.union(CharacterSet.init(charactersIn: "_/"))
+}
+
+extension Collection {
+ func chunks(size: Int) -> UnfoldSequence {
+ sequence(state: startIndex) { state -> SubSequence? in
+ guard state != endIndex else { return nil }
+ let next = index(state, offsetBy: size, limitedBy: endIndex) ?? endIndex
+ defer { state = next }
+ return self[state.. Bool {
+ return scanString(s) != nil
+ }
+
+ func color() -> Color? {
+ if string("#"), let digits = scanCharacters(from: .hex) {
+ switch digits.count {
+ case 3, 4: //rgb(a)
+ let digits = digits.chunks(size: 1)
+ .compactMap { UInt8($0, radix: 16) }
+ .map { $0 << 4 | $0 }
+ return Color(digits)
+
+ case 6, 8: //rrggbb(aa)
+ let digits = digits.chunks(size: 2).compactMap { UInt8($0, radix: 16) }
+ return Color(digits)
+
+ default: return nil
+ }
+ }
+
+ if string("rgba"), string("("), let components = commaSeparated(), components.count == 4, string(")") {
+ return Color(components)
+ }
+
+ if string("rgb"), string("("), let components = commaSeparated(), components.count == 3, string(")") {
+ return Color(components)
+ }
+
+ return nil
+ }
+
+ func colorReference() -> String? {
+ guard string("@") else { return nil }
+ return name()
+ }
+
+ func name() -> String? {
+ guard let name = scanCharacters(from: .name), !name.isEmpty else {
+ return nil
+ }
+
+ return name
+ }
+
+ func colorDef() -> ColorDef? {
+ if let color = color() {
+ return .color(color)
+ }
+
+ if let ref = colorReference() {
+ return .reference(ref)
+ }
+
+ return nil
+ }
+
+ func colorLine() -> (String, ColorDef)? {
+ guard let name = self.name(),
+ let def = colorDef(),
+ endOfLine() else {
+ return nil
+ }
+ return (name, def)
+ }
+
+ func endOfLine() -> Bool {
+ guard isAtEnd || string("\n") else {
+ return false
+ }
+ _ = scanCharacters(from: .whitespacesAndNewlines)
+ return true
+ }
+
+
+ func colorList() throws -> [String: ColorDef] {
+ var result: [String: ColorDef] = [:]
+ while !isAtEnd {
+ guard let (name, def) = colorLine() else {
+ throw Errors.syntaxError
+ }
+
+ guard !result.keys.contains(name) else {
+ throw Errors.duplicateColor(name)
+ }
+
+ result[name] = def
+ }
+
+ return result
+ }
+
+ func commaSeparated() -> [UInt8]? {
+ var result: [UInt8] = []
+ repeat {
+ guard let int = scanInt(), let component = UInt8(exactly: int) else {
+ return nil
+ }
+ result.append(component)
+ } while string(",")
+ return result
+ }
+}
+
+
diff --git a/Sources/MakeColors/main.swift b/Sources/MakeColors/main.swift
new file mode 100644
index 0000000..db25b90
--- /dev/null
+++ b/Sources/MakeColors/main.swift
@@ -0,0 +1,3 @@
+import LibMakeColors
+
+MakeColors.main()
diff --git a/Tests/MakeColorsTests/ColorParserTest.swift b/Tests/MakeColorsTests/ColorParserTest.swift
new file mode 100644
index 0000000..2aa39f9
--- /dev/null
+++ b/Tests/MakeColorsTests/ColorParserTest.swift
@@ -0,0 +1,42 @@
+import XCTest
+@testable import LibMakeColors
+
+final class ColorParserTest: XCTestCase {
+ func testScanningThreeDigitColor() throws {
+ let scanner = Scanner(string: "#abc")
+ let color = scanner.color()
+ XCTAssertEqual(Color(r: 0xaa, g: 0xbb, b: 0xcc), color)
+ }
+
+ func testScanningFourDigitColor() throws {
+ let scanner = Scanner(string: "#abcd")
+ let color = scanner.color()
+ XCTAssertEqual(Color(r: 0xaa, g: 0xbb, b: 0xcc, a: 0xDD), color)
+ }
+
+
+ func testScanningSixDigitColor() throws {
+ let scanner = Scanner(string: "#abcdef")
+ let color = scanner.color()
+ XCTAssertEqual(Color(r: 0xab, g: 0xcd, b: 0xef), color)
+ }
+
+ func testScanningEightDigitColor() throws {
+ let scanner = Scanner(string: "#abcdef17")
+ let color = scanner.color()
+ XCTAssertEqual(Color(r: 0xab, g: 0xcd, b: 0xef, a: 0x17), color)
+ }
+
+ func testScanningRGBColor() throws {
+ let scanner = Scanner(string: "rgb(1,2,3)")
+ let color = scanner.color()
+ XCTAssertEqual(Color(r: 1, g: 2, b: 3), color)
+ }
+
+ func testScanningRGBAColor() throws {
+ let scanner = Scanner(string: "rgba(1,2,3,4)")
+ let color = scanner.color()
+ XCTAssertEqual(Color(r: 1, g: 2, b: 3, a: 4), color)
+ }
+
+}