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..4d7a9019
--- /dev/null
+++ b/Sources/FigmaExport/Loaders/JSONColorsLoader.swift
@@ -0,0 +1,45 @@
+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.last {
+ 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..62dc9e00
--- /dev/null
+++ b/Sources/FigmaExportCore/Extensions/Color+Extensions.swift
@@ -0,0 +1,68 @@
+import Foundation
+
+extension Color {
+
+ /// Creates a name from a hex or rgba value
+ /// - Parameters:
+ /// - name:
+ /// - value:
+ public init?(name: String, value: String) {
+ 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)
+ }
+}
+
+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...])
+
+ let scanner = Scanner(string: hexColor)
+ var hexNumber: UInt64 = 0
+
+ guard hexColor.count == 6, scanner.scanHexInt64(&hexNumber) else {
+ return nil
+ }
+
+ return PaintColor(r: Double((hexNumber & 0xff0000) >> 16) / 255,
+ g: Double((hexNumber & 0x00ff00) >> 8) / 255,
+ b: Double(hexNumber & 0x0000ff) / 255,
+ a: 1)
+ }
+
+ private static func paintColor(fromRgba rgba: String) -> PaintColor? {
+ let components = rgba
+ .replacingOccurrences(of: "rgba(", with: "")
+ .replacingOccurrences(of: ")", with: "")
+ .split(separator: ",")
+ .compactMap { Double($0.trimmingCharacters(in: .whitespaces)) }
+
+ guard components.count == 4 else { return nil }
+
+ 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
+ }
+}