diff --git a/Sources/LibMakeColors/Color.swift b/Sources/LibMakeColors/Color.swift deleted file mode 100644 index c96c8c2..0000000 --- a/Sources/LibMakeColors/Color.swift +++ /dev/null @@ -1,27 +0,0 @@ -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/Extensions/FileWrapper+Extensions.swift b/Sources/LibMakeColors/Extensions/FileWrapper+Extensions.swift new file mode 100644 index 0000000..1dbb70e --- /dev/null +++ b/Sources/LibMakeColors/Extensions/FileWrapper+Extensions.swift @@ -0,0 +1,7 @@ +import Foundation + +extension FileWrapper { + convenience init(_ string: String) { + self.init(regularFileWithContents: Data(string.utf8)) + } +} diff --git a/Sources/LibMakeColors/Extensions/String+Extensions.swift b/Sources/LibMakeColors/Extensions/String+Extensions.swift new file mode 100644 index 0000000..66c6ee0 --- /dev/null +++ b/Sources/LibMakeColors/Extensions/String+Extensions.swift @@ -0,0 +1,40 @@ +extension StringProtocol { + var capitalizeFirst: String { + guard !isEmpty else { + return String(self) + } + + return prefix(1).uppercased() + dropFirst() + } + + var lowercasedFirst: String { + guard !isEmpty else { + return String(self) + } + + return prefix(1).lowercased() + dropFirst() + } + + func droppingSuffix(_ suffix: String) -> SubSequence { + guard hasSuffix(suffix) else { + return self[...] + } + + return dropLast(suffix.count) + } + + func insertCamelCaseSeparators(separator: String = " ") -> String { + replacingOccurrences( + of: "(?<=[a-z0-9])([A-Z])", + with: "\(separator)$1", + options: .regularExpression, + range: nil + ) + } + + func camelCasePathToSnakeCase() -> String { + insertCamelCaseSeparators(separator: "_") + .replacingOccurrences(of: "/", with: "_") + .lowercased() + } +} diff --git a/Sources/LibMakeColors/Generators/AndroidGenerator.swift b/Sources/LibMakeColors/Generators/AndroidGenerator.swift new file mode 100644 index 0000000..ecd225d --- /dev/null +++ b/Sources/LibMakeColors/Generators/AndroidGenerator.swift @@ -0,0 +1,44 @@ +import Foundation + +final class AndroidGenerator: Generator { + static let defaultExtension = "xml" + + let context: Context + + init(context: Context) { + self.context = context + } + + func generate(data: [String : ColorDef]) throws -> FileWrapper { + var xml = """ + + + + """ + + let prefix = context.prefix.map { $0.camelCasePathToSnakeCase() + "_" } ?? "" + + for (key, color) in data.sorted() { + _ = try data.resolve(key) + + let value: String + switch color { + case .color(let colorValue): value = colorValue.description + case .reference(let ref): value = "@color/\(prefix)\(ref.camelCasePathToSnakeCase())" + } + + xml += """ + \(value) + + """ + } + + xml += """ + + + + """ + + return FileWrapper(xml) + } +} diff --git a/Sources/LibMakeColors/Generators/AssetCatalogGenerator.swift b/Sources/LibMakeColors/Generators/AssetCatalogGenerator.swift new file mode 100644 index 0000000..f17623b --- /dev/null +++ b/Sources/LibMakeColors/Generators/AssetCatalogGenerator.swift @@ -0,0 +1,107 @@ +import Foundation + +final class AssetCatalogGenerator: Generator { + static let defaultExtension = "xcasset" + static let option = "ios" + + let context: Context + + init(context: Context) { + self.context = context + } + + func generate(data: [String : ColorDef]) throws -> FileWrapper { + let root = FileWrapper(directoryWithFileWrappers: ["Contents.json" : FileWrapper(catalog)]) + let colorRoot: FileWrapper + + if let prefix = context.prefix?.insertCamelCaseSeparators() { + colorRoot = FileWrapper(directoryWithFileWrappers: ["Contents.json": FileWrapper(group)]) + colorRoot.filename = prefix + colorRoot.preferredFilename = prefix + root.addFileWrapper(colorRoot) + } else { + colorRoot = root + } + + for key in data.keys { + var path = key.insertCamelCaseSeparators().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) + } + + return root + } +} + +private 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()) + ]) + } +} + + +private let group = """ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} +""" + +private let catalog = """ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} +""" diff --git a/Sources/LibMakeColors/Generators/Generator.swift b/Sources/LibMakeColors/Generators/Generator.swift new file mode 100644 index 0000000..8561ac3 --- /dev/null +++ b/Sources/LibMakeColors/Generators/Generator.swift @@ -0,0 +1,20 @@ +import Foundation + +protocol Generator: class { + static var defaultExtension: String { get } + static var option: String { get } + + init(context: Context) + + func generate(data: [String: ColorDef]) throws -> FileWrapper +} + +protocol Context: class { + var prefix: String? { get } +} + +extension Generator { + static var option: String { + String(String(describing: self).droppingSuffix("Generator")) + } +} diff --git a/Sources/LibMakeColors/Generators/HTMLGenerator.swift b/Sources/LibMakeColors/Generators/HTMLGenerator.swift new file mode 100644 index 0000000..43d0d08 --- /dev/null +++ b/Sources/LibMakeColors/Generators/HTMLGenerator.swift @@ -0,0 +1,74 @@ +import Foundation + +final class HTMLGenerator: Generator { + static let defaultExtension = "html" + let context: Context + + init(context: Context) { + self.context = context + } + + func generate(data: [String : ColorDef]) throws -> FileWrapper { + var html = """ + + + + + + + + + + + + + + + """ + + for (key, color) in data.sorted() { + let actualColor = try data.resolve(key) + let value: String + + switch color { + case let .reference(name): value = """ + \(name.insertCamelCaseSeparators())
\(actualColor) + """ + case .color: value = actualColor.description + } + + html += """ + + + + + + + """ + } + + html += """ +
 NameValue
  \(key.insertCamelCaseSeparators())\(value)
+ + + + """ + + return FileWrapper(html) + } +} diff --git a/Sources/LibMakeColors/MakeColors.swift b/Sources/LibMakeColors/MakeColors.swift index 89090b4..79e68d3 100644 --- a/Sources/LibMakeColors/MakeColors.swift +++ b/Sources/LibMakeColors/MakeColors.swift @@ -1,10 +1,22 @@ import ArgumentParser import Foundation -enum Formatter: String, EnumerableFlag { - case ios - case android - case html +private struct GeneratorOption: EnumerableFlag, CustomStringConvertible { + let type: Generator.Type + + var description: String { + type.option + } + + static let allCases: [GeneratorOption] = [ + .init(type: AssetCatalogGenerator.self), + .init(type: AndroidGenerator.self), + .init(type: HTMLGenerator.self) + ] + + static func == (lhs: GeneratorOption, rhs: GeneratorOption) -> Bool { + lhs.type == rhs.type + } } enum Errors: Error { @@ -14,12 +26,12 @@ enum Errors: Error { case cyclicReference(String) } -public struct MakeColors: ParsableCommand { +public final class MakeColors: ParsableCommand, Context { @Argument(help: "The color list to proces") var input: String @Flag(help: "The formatter to use") - var formatter = Formatter.ios + private var formatter = GeneratorOption.allCases[0] @Option(help: "Prefix for color names") var prefix: String? @@ -38,18 +50,19 @@ public struct MakeColors: ParsableCommand { 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) + for (key, color) in data.sorted() { + let resolved = try data.resolve(key) + switch color { + case .color: print(key.insertCamelCaseSeparators(), resolved, separator: ": ") + case .reference(let r): print("\(key.insertCamelCaseSeparators()) (@\(r.insertCamelCaseSeparators()))", resolved, separator: ": ") + } } + + let generator = formatter.type.init(context: self) + let fileWrapper = try generator.generate(data: data) + + let writeURL = outputURL(extension: formatter.type.defaultExtension) + try fileWrapper.write(to: writeURL, options: .atomic, originalContentsURL: nil) } func outputURL(extension: String) -> URL { @@ -59,270 +72,5 @@ public struct MakeColors: ParsableCommand { 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 = """ - - - - - - - - - - - - - - - """ - - 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 += """ - - - - - - - """ - } - - html += """ -
 NameValue
  \(mapSpaceColorName(key))\(value)
- - - - """ - - 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/Model/Color.swift b/Sources/LibMakeColors/Model/Color.swift new file mode 100644 index 0000000..6c5c641 --- /dev/null +++ b/Sources/LibMakeColors/Model/Color.swift @@ -0,0 +1,60 @@ +struct Color: CustomStringConvertible, Equatable { + 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 + } + + var description: String { + return a != 0xFF ? String(format: "#%02X%02X%02X%02X", r, g, b, a): String(format: "#%02X%02X%02X", r, g, b) + } +} + +enum ColorDef { + case reference(String) + case color(Color) +} + +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) + } + } + + func sorted() -> [Element] { + sorted(by: Self.compare) + } + + static func compare(_ a: (String, ColorDef), _ b: (String, ColorDef)) -> Bool { + switch (a, b) { + case ((_, .color), (_, .reference)): return true + case ((_, .reference), (_, .color)): return false + case let ((left, _), (right, _)): return left.localizedStandardCompare(right) == .orderedAscending + } + } + +} diff --git a/Sources/LibMakeColors/Parser.swift b/Sources/LibMakeColors/Model/Parser.swift similarity index 98% rename from Sources/LibMakeColors/Parser.swift rename to Sources/LibMakeColors/Model/Parser.swift index 9cce424..390e88c 100644 --- a/Sources/LibMakeColors/Parser.swift +++ b/Sources/LibMakeColors/Model/Parser.swift @@ -1,12 +1,11 @@ import Foundation - -extension CharacterSet { +private extension CharacterSet { static let hex = CharacterSet(charactersIn: "0123456789abcdef") static let name = alphanumerics.union(CharacterSet.init(charactersIn: "_/")) } -extension Collection { +private extension Collection { func chunks(size: Int) -> UnfoldSequence { sequence(state: startIndex) { state -> SubSequence? in guard state != endIndex else { return nil }