Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for exporting colors from variables definitions in a JSON #228

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,41 @@ Example
| <img src="images/dark.png" width="352" /> | <img src="images/dark_c.png" width="200" /> |
| <img src="images/light.png" width="352" /> | <img src="images/light_c.png" width="200" /> |


**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.
Expand Down
3 changes: 3 additions & 0 deletions Sources/FigmaExport/FigmaExportCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ enum FigmaExportError: LocalizedError {

case invalidFileName(String)
case stylesNotFound
case stylesNotFoundLocally
case componentsNotFound
case accessTokenNotFound
case colorsAssetsFolderNotSpecified
case custom(errorString: String)

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:
Expand Down
15 changes: 15 additions & 0 deletions Sources/FigmaExport/Input/JSONReader.swift
Original file line number Diff line number Diff line change
@@ -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: [])
}
}
3 changes: 3 additions & 0 deletions Sources/FigmaExport/Input/Params.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
14 changes: 5 additions & 9 deletions Sources/FigmaExport/Loaders/ColorsLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -14,28 +16,22 @@ 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) }
let darkHighContrastColors = try figmaParams.darkHighContrastFileId.map { try loadColors(fileId: $0, filter: filter) }
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"
Expand Down
45 changes: 45 additions & 0 deletions Sources/FigmaExport/Loaders/JSONColorsLoader.swift
Original file line number Diff line number Diff line change
@@ -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 []
}
}
}
46 changes: 38 additions & 8 deletions Sources/FigmaExport/Subcommands/ExportColors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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<Color>], iosParams: Params.iOS) throws {
guard let colorParams = iosParams.colors else {
Expand Down
68 changes: 68 additions & 0 deletions Sources/FigmaExportCore/Extensions/Color+Extensions.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading