Skip to content

Commit

Permalink
maintenance: implement @packages/figma-to-css-variables
Browse files Browse the repository at this point in the history
  • Loading branch information
MH4GF committed Oct 15, 2024
1 parent 0f9d8a6 commit 75d1fb4
Show file tree
Hide file tree
Showing 9 changed files with 628 additions and 0 deletions.
1 change: 1 addition & 0 deletions frontend/packages/figma-to-css-variables/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
tmp
32 changes: 32 additions & 0 deletions frontend/packages/figma-to-css-variables/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Figma to CSS Variables

## Overview

This script is designed to handle various tasks related to Figma local variables and Style Dictionary. It can fetch Figma local variables, transform them for Style Dictionary, and run Style Dictionary with specified options.

## Environment Variables

The script requires the following environment variables:

- `FIGMA_FILE_KEY`: The key of the Figma file.
- `FIGMA_ACCESS_TOKEN`: The token to access the Figma API.

## Command Line Arguments

The script accepts the following command line arguments:

- `--fetch`: Fetches Figma local variables.
- `--transform`: Transforms variables for Style Dictionary.
- `--generate`: Runs Style Dictionary with the specified output path.
- `--output <path>`: Specifies the output path for the generated files. Defaults to 'build/css'.
- `--filter-modes <modes>`: Specifies filter modes for Style Dictionary as a comma-separated list.

If no specific argument is provided, the script defaults to running all steps in sequence.

## Usage

To run the script, use the following command:

```sh
FIGMA_FILE_KEY=FnK... FIGMA_ACCESS_TOKEN=figd_xxx pnpm --filter @packages/figma-to-css-variables gen --output '../../apps/service-site/src/styles' --filter-modes 'Dark,Mode 1'
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { promises as fs } from 'node:fs'
import { mkdir } from 'node:fs/promises'

/**
* Fetches local variables from the Figma API and saves them to a temporary file.
* @returns {Promise<void>}
*/
export async function fetchFigmaLocalVariables() {
const FIGMA_FILE_KEY = process.env.FIGMA_FILE_KEY
const FIGMA_ACCESS_TOKEN = process.env.FIGMA_ACCESS_TOKEN

if (!FIGMA_FILE_KEY || !FIGMA_ACCESS_TOKEN) {
throw new Error(
'FIGMA_FILE_KEY and FIGMA_ACCESS_TOKEN environment variables are required.',
)
}

const url = `https://api.figma.com/v1/files/${FIGMA_FILE_KEY}/variables/local`
const headers = {
'X-Figma-Token': FIGMA_ACCESS_TOKEN,
}

const response = await fetch(url, { headers })
if (!response.ok) {
throw new Error(`Failed to fetch variables: ${response.statusText}`)
}

const data = await response.json()

await mkdir('tmp', { recursive: true })
await fs.writeFile(
'tmp/local-variables.json',
JSON.stringify(data.meta, null, 2),
)
console.info('Local variables fetched and saved to tmp/local-variables.json')
}
55 changes: 55 additions & 0 deletions frontend/packages/figma-to-css-variables/bin/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { fetchFigmaLocalVariables } from './fetchFigmaLocalVariables.mjs'
import { runStyleDictionary } from './runStyleDictionary.mjs'
import { transformVariablesForStyleDictionary } from './transformVariablesForStyleDictionary.mjs'

/**
* Main function to handle command line arguments and execute corresponding tasks.
*
* Command line arguments:
* - `--fetch`: Fetches Figma local variables.
* - `--transform`: Transforms variables for Style Dictionary.
* - `--generate`: Runs Style Dictionary with the specified output path.
* - `--output <path>`: Specifies the output path for the generated files. Defaults to 'build/css'.
* - `--filter-modes <modes>`: Specifies filter modes for Style Dictionary as a comma-separated list.
*
* If no specific argument is provided, it defaults to running all steps in sequence.
*
* @returns {Promise<void>} A promise that resolves when all tasks are completed.
*/
async function main() {
const args = process.argv.slice(2)

let outputPath = null
const filterModes = []

if (args.includes('--output')) {
const outputIndex = args.indexOf('--output')
if (outputIndex !== -1 && args[outputIndex + 1]) {
outputPath = args[outputIndex + 1]
}
}

if (args.includes('--filter-modes')) {
const filterModesIndex = args.indexOf('--filter-modes')
if (filterModesIndex !== -1 && args[filterModesIndex + 1]) {
filterModes.push(...args[filterModesIndex + 1].split(','))
}
}

if (args.includes('--fetch')) {
await fetchFigmaLocalVariables()
} else if (args.includes('--transform')) {
await transformVariablesForStyleDictionary()
} else if (args.includes('--generate')) {
await runStyleDictionary(outputPath, filterModes)
} else {
// Default to running all steps
await fetchFigmaLocalVariables()
await transformVariablesForStyleDictionary()
await runStyleDictionary(outputPath, filterModes)
}
}

main().catch((err) => {
console.error('Error:', err)
})
109 changes: 109 additions & 0 deletions frontend/packages/figma-to-css-variables/bin/runStyleDictionary.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import fs from 'node:fs'
import path from 'node:path'
import StyleDictionary from 'style-dictionary'

function isNumber(token) {
const targets = [
'size',
'border-radius',
'border-width',
'spacing',
'font-size',
'line-height',
'sizing',
]

return (
targets.includes(token.attributes?.category ?? '') ||
targets.includes(token.attributes?.type ?? '')
)
}

StyleDictionary.registerTransform({
type: 'value',
transitive: true,
name: 'number/px',
filter: isNumber,
transform: (token) => {
const val = Number.parseFloat(token.value)
if (Number.isNaN(val))
throw `Invalid Number: '${token.name}: ${token.value}' is not a valid number, cannot transform to 'px' \n`
return `${val}px`
},
})

function source(input) {
return [`${input}/**/*.json`]
}

/**
* Runs the Style Dictionary build process for each sub-directory found in the input directory.
*
* @param {string[]} filterDirNames - An array of directory names to filter and process.
* @param {string} [outputPath='build/css'] - The output path where the CSS files will be generated.
* @returns {Promise<void>} - A promise that resolves when the build process is complete.
*/
export async function runStyleDictionary(
outputPath = 'build/css',
filterDirNames = [],
) {
try {
const inputDir = path.resolve('tmp/transformed-variables')
const subDirs = fs
.readdirSync(inputDir, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory())
.filter(
(dirent) =>
filterDirNames.length === 0 || filterDirNames.includes(dirent.name),
)
.map((dirent) => dirent.name)

for (const subDir of subDirs) {
const subDirInput = path.join(inputDir, subDir)
const subDirOutput = `${path.join(outputPath, subDir)}/`

const config = {
source: [source(subDirInput)],
platforms: {
css: {
transformGroup: 'css',
buildPath: subDirOutput,
files: [
{
destination: 'variables.css',
format: 'css/variables',
},
],
/**
* Resetting the configuration to display size-related numbers in px.
* The default settings are as follows:
* @see: https://styledictionary.com/reference/hooks/transform-groups/predefined/
*/
transforms: [
'attribute/cti',
'name/kebab',
'time/seconds',
'html/icon',
'color/css',
'number/px',
],
},
},
log: {
warnings: 'warn', // 'warn' | 'error' | 'disabled'
verbosity: 'verbose', // 'default' | 'silent' | 'verbose'
errors: {
brokenReferences: 'console', // 'throw' | 'console'
},
},
}

const styleDictionary = new StyleDictionary(config)
await styleDictionary.buildAllPlatforms()
}

console.info('Style Dictionary build complete.')
} catch (error) {
console.error(`Error during Style Dictionary generation: ${error.message}`)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { promises as fs } from 'node:fs'
import { mkdir } from 'node:fs/promises'
import path from 'node:path'

/**
* Retrieves the token value from the variable based on the mode.
* @param {Object} variable
* @param {string} modeId
* @param {Object[]} localVariables
* @returns {string|number}
*/
function tokenValueFromVariable(variable, modeId, localVariables) {
const value = variable.valuesByMode[modeId]
if (typeof value === 'object') {
if ('type' in value && value.type === 'VARIABLE_ALIAS') {
const aliasedVariable = localVariables[value.id]
// console.log(aliasedVariable, value.id)
return `{${aliasedVariable?.name.replace(/\//g, '.')}}`
}
if ('r' in value) {
return rgbToHex(value) // Assuming rgbToHex is defined elsewhere
}

throw new Error(`Format of variable value is invalid: ${value}`)
}

if (typeof value === 'number') {
return Number.isInteger(value) ? value : Math.floor(value * 100) / 100
}

return value
}

function rgbToHex({ r, g, b, a }) {
if (a === undefined) {
a = 1
}

const toHex = (value) => {
const hex = Math.round(value * 255).toString(16)
return hex.length === 1 ? `0${hex}` : hex
}

const hex = [toHex(r), toHex(g), toHex(b)].join('')
return `#${hex}${a !== 1 ? toHex(a) : ''}`
}

/**
* Infers token type from the variable.
* @param {Object} variable
* @returns {string}
*/
function tokenTypeFromVariable(variable) {
switch (variable.resolvedType) {
case 'BOOLEAN':
return 'boolean'
case 'COLOR':
return 'color'
case 'FLOAT':
return 'number'
case 'STRING':
return 'string'
}
}

/**
* Transforms Figma local variables into Style Dictionary format.
* @returns {Promise<void>}
*/
export async function transformVariablesForStyleDictionary() {
const localVariablesPath = 'tmp/local-variables.json'
const transformedDir = 'tmp/transformed-variables'

const data = JSON.parse(await fs.readFile(localVariablesPath, 'utf-8'))
const localVariables = data.variables
const localVariableCollections = data.variableCollections

await mkdir(transformedDir, { recursive: true })

const fileContents = {}

for (const variable of Object.values(localVariables)) {
if (variable.remote) continue // Skip remote variables

const collection = localVariableCollections[variable.variableCollectionId]
if (collection) {
for (const mode of collection.modes) {
const fileName = `${mode.name}/${collection.name}.json`
const filePath = path.join(transformedDir, fileName)

if (!fileContents[filePath]) {
fileContents[filePath] = {}
}

let obj = fileContents[filePath]
for (const groupName of variable.name.split('/')) {
obj[groupName] = obj[groupName] || {}
obj = obj[groupName]
}

const token = {
type: tokenTypeFromVariable(variable),
value: tokenValueFromVariable(variable, mode.modeId, localVariables),
description: variable.description || '',
extensions: {
'com.figma': {
hiddenFromPublishing: variable.hiddenFromPublishing,
scopes: variable.scopes,
codeSyntax: variable.codeSyntax,
},
},
}

Object.assign(obj, token)
}
}
}

for (const [filePath, content] of Object.entries(fileContents)) {
const dir = path.dirname(filePath)
await mkdir(dir, { recursive: true })
await fs.writeFile(filePath, JSON.stringify(content, null, 2))
}

console.info('Variables transformed and saved to tmp/transformed-variables/')
}
3 changes: 3 additions & 0 deletions frontend/packages/figma-to-css-variables/biome.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": ["../../packages/configs/biome.jsonc"]
}
18 changes: 18 additions & 0 deletions frontend/packages/figma-to-css-variables/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "@packages/figma-to-css-variables",
"main": "bin/index.mjs",
"scripts": {
"gen": "node bin/index.mjs",
"lint": "conc -c auto pnpm:lint:*",
"lint:biome": "biome check .",
"fmt": "conc -c auto pnpm:fmt:*",
"fmt:biome": "biome check --write --unsafe ."
},
"devDependencies": {
"@biomejs/biome": "1.9.3",
"@packages/configs": "workspace:*"
},
"dependencies": {
"style-dictionary": "^4.1.3"
}
}
Loading

0 comments on commit 75d1fb4

Please sign in to comment.