From 4b296ce8f1e8b83204799ac3fafab0b46f261885 Mon Sep 17 00:00:00 2001 From: Adam Wareing Date: Thu, 21 Dec 2023 09:24:01 +1000 Subject: [PATCH 1/3] Add support for exporting colors from variables definitions in a JSON file exported from Figma --- CONFIG.md | 6 +++ README.md | 35 +++++++++++++ Sources/FigmaExport/FigmaExportCommand.swift | 3 ++ Sources/FigmaExport/Input/JSONReader.swift | 15 ++++++ Sources/FigmaExport/Input/Params.swift | 3 ++ .../FigmaExport/Loaders/ColorsLoader.swift | 14 ++--- .../Loaders/JSONColorsLoader.swift | 46 ++++++++++++++++ .../Subcommands/ExportColors.swift | 46 +++++++++++++--- .../Extensions/Color+Extensions.swift | 52 +++++++++++++++++++ 9 files changed, 203 insertions(+), 17 deletions(-) create mode 100644 Sources/FigmaExport/Input/JSONReader.swift create mode 100644 Sources/FigmaExport/Loaders/JSONColorsLoader.swift create mode 100644 Sources/FigmaExportCore/Extensions/Color+Extensions.swift diff --git a/CONFIG.md b/CONFIG.md index 3cb035fa..f54b2e5a 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -38,6 +38,12 @@ common: lightHCModeSuffix: '_lightHC' # [optional] If useSingleFile is true, customize the suffix to denote a dark high contrast color. Defaults to '_darkHC' darkHCModeSuffix: '_darkHC' + # [optional] Set this to true and instead of loading Styles from Figma you can specify a variable json path `variableFilePath` to read from instead + useVariablesFromFileInstead: false + # [optional] The local path to read the variables from if `useVariablesFromFileInstead` is `true` + variableFilePath: 'variables.json' + # [optional] If `useVariablesFromFileInstead` is `true`, this should be set to specify the root group of JSON variables to decode + variableGroupName: 'colors' # [optional] icons: # [optional] Name of the Figma's frame where icons components are located diff --git a/README.md b/README.md index 43150b20..b4e2bbbc 100644 --- a/README.md +++ b/README.md @@ -490,6 +490,41 @@ Example | | | | | | + +**Figma Variables** + +Figma is moving away from styles towards variables. This allows designers to create a color variable e.g. `BackgroundDefault` and have various states of it e.g. `light` and `dark`. The API for this is still in beta and is for enterprise customers only. In the interm, you can use a Figma plugin [Variables to JSON](https://www.figma.com/community/plugin/1301567053264748331/variables-to-json) to download the variables as JSON and specify them in the config. Each variable should have a "light" state, but can also include "dark", "lightHC", "darkHC" too. + +If you want to provide the JSON yourself, the variables should be in the following format. It supports reading from hex and rgba along with traversing through the folder structures. + +``` +{ + "app-tokens": { + "light": { + "background-default": { + "value": "rgba(255, 255, 255, 1)" + }, + "background-secondary": { + "value": "#e7134b" + } + }, + "dark": { + "background-default": { + "value": "rgba(255, 255, 255, 1)" + }, + "background-secondary": { + "value": "#e7134b" + } + } + } +} +``` + +To use this should specify the following properties in the colors common config +`useVariablesFromFileInstead: true` +`variableFilePath: 'path-to-styles.json'` +`variableGroupName: 'app-tokens-or-token-group-name'` + For `figma-export icons` By default, your Figma file should contains a frame with `Icons` name which contains components for each icon. You may change a frame name in a [CONFIG.md](CONFIG.md) file by setting `common.icons.figmaFrameName` property. diff --git a/Sources/FigmaExport/FigmaExportCommand.swift b/Sources/FigmaExport/FigmaExportCommand.swift index 60a9a577..4fa81995 100644 --- a/Sources/FigmaExport/FigmaExportCommand.swift +++ b/Sources/FigmaExport/FigmaExportCommand.swift @@ -6,6 +6,7 @@ enum FigmaExportError: LocalizedError { case invalidFileName(String) case stylesNotFound + case stylesNotFoundLocally case componentsNotFound case accessTokenNotFound case colorsAssetsFolderNotSpecified @@ -13,6 +14,8 @@ enum FigmaExportError: LocalizedError { var errorDescription: String? { switch self { + case .stylesNotFoundLocally: + return "Color styles not found locally. Did you specify the correct path, or have the correct file structure?" case .invalidFileName(let name): return "File name is invalid: \(name)" case .stylesNotFound: diff --git a/Sources/FigmaExport/Input/JSONReader.swift b/Sources/FigmaExport/Input/JSONReader.swift new file mode 100644 index 00000000..9918a962 --- /dev/null +++ b/Sources/FigmaExport/Input/JSONReader.swift @@ -0,0 +1,15 @@ +import Foundation + +final class JSONReader { + + private let inputPath: String + + init(inputPath: String) { + self.inputPath = inputPath + } + + func read() throws -> Any { + let data = try Data(contentsOf: URL(fileURLWithPath: inputPath)) + return try JSONSerialization.jsonObject(with: data, options: []) + } +} diff --git a/Sources/FigmaExport/Input/Params.swift b/Sources/FigmaExport/Input/Params.swift index 9865e0b4..46f3f26e 100644 --- a/Sources/FigmaExport/Input/Params.swift +++ b/Sources/FigmaExport/Input/Params.swift @@ -21,6 +21,9 @@ struct Params: Decodable { let darkModeSuffix: String? let lightHCModeSuffix: String? let darkHCModeSuffix: String? + let useVariablesFromFileInstead: Bool? + let variableFilePath: String? + let variableGroupName: String? } struct Icons: Decodable { diff --git a/Sources/FigmaExport/Loaders/ColorsLoader.swift b/Sources/FigmaExport/Loaders/ColorsLoader.swift index 6ff18627..3a05bee3 100644 --- a/Sources/FigmaExport/Loaders/ColorsLoader.swift +++ b/Sources/FigmaExport/Loaders/ColorsLoader.swift @@ -4,6 +4,8 @@ import FigmaExportCore /// Loads colors from Figma final class ColorsLoader { + typealias Output = (light: [Color], dark: [Color]?, lightHC: [Color]?, darkHC: [Color]?) + private let client: Client private let figmaParams: Params.Figma private let colorParams: Params.Common.Colors? @@ -14,17 +16,14 @@ final class ColorsLoader { self.colorParams = colorParams } - func load(filter: String?) throws -> (light: [Color], dark: [Color]?, lightHC: [Color]?, darkHC: [Color]?) { + func load(filter: String?) throws -> Output { guard let useSingleFile = colorParams?.useSingleFile, useSingleFile else { return try loadColorsFromLightAndDarkFile(filter: filter) } return try loadColorsFromSingleFile(filter: filter) } - private func loadColorsFromLightAndDarkFile(filter: String?) throws -> (light: [Color], - dark: [Color]?, - lightHC: [Color]?, - darkHC: [Color]?) { + private func loadColorsFromLightAndDarkFile(filter: String?) throws -> Output { let lightColors = try loadColors(fileId: figmaParams.lightFileId, filter: filter) let darkColors = try figmaParams.darkFileId.map { try loadColors(fileId: $0, filter: filter) } let lightHighContrastColors = try figmaParams.lightHighContrastFileId.map { try loadColors(fileId: $0, filter: filter) } @@ -32,10 +31,7 @@ final class ColorsLoader { return (lightColors, darkColors, lightHighContrastColors, darkHighContrastColors) } - private func loadColorsFromSingleFile(filter: String?) throws -> (light: [Color], - dark: [Color]?, - lightHC: [Color]?, - darkHC: [Color]?) { + private func loadColorsFromSingleFile(filter: String?) throws -> Output { let colors = try loadColors(fileId: figmaParams.lightFileId, filter: filter) let darkSuffix = colorParams?.darkModeSuffix ?? "_dark" diff --git a/Sources/FigmaExport/Loaders/JSONColorsLoader.swift b/Sources/FigmaExport/Loaders/JSONColorsLoader.swift new file mode 100644 index 00000000..2bd7604d --- /dev/null +++ b/Sources/FigmaExport/Loaders/JSONColorsLoader.swift @@ -0,0 +1,46 @@ +import Foundation +import FigmaExportCore + +final class JSONColorsLoader { + + typealias Output = (light: [Color], dark: [Color]?, lightHC: [Color]?, darkHC: [Color]?) + + static func processColors(in node: Any, groupName: String) throws -> Output { + guard + let node = node as? [String: Any], + let subNode = node[groupName] as? [String: Any], + let light = subNode["light"] + else { + throw FigmaExportError.stylesNotFoundLocally + } + + return ( + light: processSchemeColors(in: light), + dark: subNode["dark"].map { processSchemeColors(in: $0) }, + lightHC: subNode["lightHC"].map { processSchemeColors(in: $0) }, + darkHC: subNode["darkHC"].map { processSchemeColors(in: $0) } + ) + } + + static func processSchemeColors(in node: Any, path: [String] = []) -> [Color] { + // Check if the node contains a color value + if let color = node as? [String: String], let value = color["value"] { + let name = path.joined(separator: "_") + if let def = Color(name: name, value: value) { + return [def] + } else { + return [] + } + } + + // Check if the node is a dictionary + else if let dictionary = node as? [String: Any] { + return dictionary.map { (key, value) in + processSchemeColors(in: value, path: path + [key]) + }.flatMap { $0 } + + } else { + return [] + } + } +} diff --git a/Sources/FigmaExport/Subcommands/ExportColors.swift b/Sources/FigmaExport/Subcommands/ExportColors.swift index ce7c3a5c..bacf2e10 100644 --- a/Sources/FigmaExport/Subcommands/ExportColors.swift +++ b/Sources/FigmaExport/Subcommands/ExportColors.swift @@ -25,14 +25,8 @@ extension FigmaExportCommand { var filter: String? func run() throws { - let client = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma.timeout) - - logger.info("Using FigmaExport \(FigmaExportCommand.version) to export colors.") - - logger.info("Fetching colors. Please wait...") - let loader = ColorsLoader(client: client, figmaParams: options.params.figma, colorParams: options.params.common?.colors) - let colors = try loader.load(filter: filter) - + let colors = try getColors() + if let ios = options.params.ios { logger.info("Processing colors...") let processor = ColorsProcessor( @@ -78,6 +72,42 @@ extension FigmaExportCommand { logger.info("Done!") } } + + private func getColors() throws -> ColorsLoader.Output { + logger.info("Using FigmaExport \(FigmaExportCommand.version) to export colors.") + + if options.params.common?.colors?.useVariablesFromFileInstead ?? false { + return try loadFromJsonFile() + } else { + return try loadFromFigma() + } + } + + private func loadFromFigma() throws -> ColorsLoader.Output { + logger.info("Fetching colors. Please wait...") + + let client = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma.timeout) + let loader = ColorsLoader( + client: client, + figmaParams: options.params.figma, + colorParams: options.params.common?.colors) + + return try loader.load(filter: filter) + } + + private func loadFromJsonFile() throws -> JSONColorsLoader.Output { + let colorParams = options.params.common?.colors + guard + let fileName = colorParams?.variableFilePath, + let groupName = colorParams?.variableGroupName + else { + throw FigmaExportError.componentsNotFound + } + + let dataReader = JSONReader(inputPath: fileName) + let styles = try dataReader.read() + return try JSONColorsLoader.processColors(in: styles, groupName: groupName) + } private func exportXcodeColors(colorPairs: [AssetPair], iosParams: Params.iOS) throws { guard let colorParams = iosParams.colors else { diff --git a/Sources/FigmaExportCore/Extensions/Color+Extensions.swift b/Sources/FigmaExportCore/Extensions/Color+Extensions.swift new file mode 100644 index 00000000..ddb6c648 --- /dev/null +++ b/Sources/FigmaExportCore/Extensions/Color+Extensions.swift @@ -0,0 +1,52 @@ +import Foundation + +extension Color { + + /// Creates a name from a hex or rgba value + /// - Parameters: + /// - name: + /// - value: + public init?(name: String, value: String) { + if value.hasPrefix("#") { + self.init(name: name, hex: value) + } else if value.hasPrefix("rgba") { + self.init(name: name, rgbaString: value) + } else { + return nil + } + } + + private init?(name: String, hex: String) { + let r, g, b: Double + let start = hex.index(hex.startIndex, offsetBy: hex.hasPrefix("#") ? 1 : 0) + let hexColor = String(hex[start...]) + + if hexColor.count == 6 { + let scanner = Scanner(string: hexColor) + var hexNumber: UInt64 = 0 + + if scanner.scanHexInt64(&hexNumber) { + r = Double((hexNumber & 0xff0000) >> 16) / 255 + g = Double((hexNumber & 0x00ff00) >> 8) / 255 + b = Double(hexNumber & 0x0000ff) / 255 + + self.init(name: name, red: r, green: g, blue: b, alpha: 1) + return + } + } + + return nil + } + + private init?(name: String, rgbaString: String) { + let components = rgbaString + .replacingOccurrences(of: "rgba(", with: "") + .replacingOccurrences(of: ")", with: "") + .split(separator: ",") + .compactMap { Double($0.trimmingCharacters(in: .whitespaces)) } + + guard components.count == 4 else { return nil } + + self.init(name: name, red: components[0] / 255, green: components[1] / 255, blue: components[2] / 255, alpha: components[3]) + } +} From 3e08c0ae15b7bc09a6a802288b01b1f2842fb7f0 Mon Sep 17 00:00:00 2001 From: Adam Wareing Date: Thu, 21 Dec 2023 11:47:46 +1000 Subject: [PATCH 2/3] Refactor to use smaller constructors --- .../Extensions/Color+Extensions.swift | 66 ++++++++++++------- 1 file changed, 41 insertions(+), 25 deletions(-) diff --git a/Sources/FigmaExportCore/Extensions/Color+Extensions.swift b/Sources/FigmaExportCore/Extensions/Color+Extensions.swift index ddb6c648..62dc9e00 100644 --- a/Sources/FigmaExportCore/Extensions/Color+Extensions.swift +++ b/Sources/FigmaExportCore/Extensions/Color+Extensions.swift @@ -7,39 +7,48 @@ extension Color { /// - name: /// - value: public init?(name: String, value: String) { - if value.hasPrefix("#") { - self.init(name: name, hex: value) - } else if value.hasPrefix("rgba") { - self.init(name: name, rgbaString: value) - } else { + guard let color = ColorDecoder.paintColor(fromString: value) else { return nil } + + self.init(name: name, + red: color.r, + green: color.g, + blue: color.b, + alpha: color.a) } +} - private init?(name: String, hex: String) { - let r, g, b: Double +struct ColorDecoder { + + static func paintColor(fromString string: String) -> PaintColor? { + if string.hasPrefix("#") { + return paintColor(fromHex: string) + } else if string.hasPrefix("rgba") { + return paintColor(fromRgba: string) + } + return nil + } + + private static func paintColor(fromHex hex: String) -> PaintColor? { let start = hex.index(hex.startIndex, offsetBy: hex.hasPrefix("#") ? 1 : 0) let hexColor = String(hex[start...]) - if hexColor.count == 6 { - let scanner = Scanner(string: hexColor) - var hexNumber: UInt64 = 0 - - if scanner.scanHexInt64(&hexNumber) { - r = Double((hexNumber & 0xff0000) >> 16) / 255 - g = Double((hexNumber & 0x00ff00) >> 8) / 255 - b = Double(hexNumber & 0x0000ff) / 255 - - self.init(name: name, red: r, green: g, blue: b, alpha: 1) - return - } + let scanner = Scanner(string: hexColor) + var hexNumber: UInt64 = 0 + + guard hexColor.count == 6, scanner.scanHexInt64(&hexNumber) else { + return nil } - - return nil + + return PaintColor(r: Double((hexNumber & 0xff0000) >> 16) / 255, + g: Double((hexNumber & 0x00ff00) >> 8) / 255, + b: Double(hexNumber & 0x0000ff) / 255, + a: 1) } - - private init?(name: String, rgbaString: String) { - let components = rgbaString + + private static func paintColor(fromRgba rgba: String) -> PaintColor? { + let components = rgba .replacingOccurrences(of: "rgba(", with: "") .replacingOccurrences(of: ")", with: "") .split(separator: ",") @@ -47,6 +56,13 @@ extension Color { guard components.count == 4 else { return nil } - self.init(name: name, red: components[0] / 255, green: components[1] / 255, blue: components[2] / 255, alpha: components[3]) + return PaintColor(r: components[0] / 255, + g: components[1] / 255, + b: components[2] / 255, + a: components[3]) + } + + public struct PaintColor: Decodable { + public let r, g, b, a: Double } } From 8c94a9de7c9933c03efec69272bf90d0d602adc8 Mon Sep 17 00:00:00 2001 From: Adam Wareing Date: Fri, 22 Dec 2023 14:34:08 +1000 Subject: [PATCH 3/3] Change to only use the variable name --- Sources/FigmaExport/Loaders/JSONColorsLoader.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/FigmaExport/Loaders/JSONColorsLoader.swift b/Sources/FigmaExport/Loaders/JSONColorsLoader.swift index 2bd7604d..4d7a9019 100644 --- a/Sources/FigmaExport/Loaders/JSONColorsLoader.swift +++ b/Sources/FigmaExport/Loaders/JSONColorsLoader.swift @@ -24,8 +24,7 @@ final class JSONColorsLoader { static func processSchemeColors(in node: Any, path: [String] = []) -> [Color] { // Check if the node contains a color value - if let color = node as? [String: String], let value = color["value"] { - let name = path.joined(separator: "_") + if let color = node as? [String: String], let value = color["value"], let name = path.last { if let def = Color(name: name, value: value) { return [def] } else {