Skip to content

Commit

Permalink
Figma tokens build process (#665)
Browse files Browse the repository at this point in the history
* created separate figma build file which compiles list of files to fetch

* fix issues with token validation
  • Loading branch information
lukasoppermann authored Jul 20, 2023
1 parent db9b814 commit 360a8b8
Show file tree
Hide file tree
Showing 25 changed files with 6,896 additions and 251 deletions.
5 changes: 5 additions & 0 deletions .changeset/sixty-teachers-hunt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/primitives': minor
---

Adding support for figma tokens
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,32 @@ A `mix` proprty must always have a `color` and a `weight` child. `color` can be
}
```

#### Extensions property

According to the [w3c design token specs](https://design-tokens.github.io/community-group/format/#design-token), the [`$extensions`](https://design-tokens.github.io/community-group/format/#extensions) property is used for additional meta data.

For our Figma export we use the following meta data:

- `collection` the collection that the token is added to within Figma
- `mode` the mode that the token is added to within the collection in Figma
- `scopes` the scopes that are assigned to the token in Figma, the actual Figma compatible `scopes` are retreive from an object in the [figmaAttributes transformer](./src/transformers/figmaAttributes.ts)

Code example

```js
bgColor: {
$value: '{borderColor.accent.muted}',
$type: 'color',
$extensions: {
'org.primer.figma': {
collection: 'pattern/mode',
mode: 'light',
scopes: ['bgColor'],
},
},
}
```

## License

[MIT](./LICENSE) © [GitHub](https://github.com/)
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
"build": "ts-node ./scripts/build.ts && tsc --project tsconfig.build.json",
"build:next": "ts-node -r tsconfig-paths/register ./scripts/buildTokens.ts && ts-node ./scripts/buildFallbacks.ts",
"build:tokens": "node -e \"require('./build')._init()\"",
"build:new-tokens": "npm run build:tokens && npm run build:next",
"build:figma": "ts-node scripts/buildPlatforms/buildFigma.ts",
"build:new-tokens": "npm run build:tokens && npm run build:next && npm run build:figma",
"tokenJson:check": "ts-node scripts/diffThemes.ts && ts-node scripts/diffTokenProps.ts",
"contrast:check": "ts-node -e \"require('./scripts/color-contrast').check()\"",
"format": "prettier --write '**/*.{js,jsx,ts,tsx,md,mdx,css}'",
Expand Down
111 changes: 111 additions & 0 deletions scripts/buildPlatforms/buildFigma.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import fs from 'fs'
import {PrimerStyleDictionary} from '~/src/PrimerStyleDictionary'
import {themes} from '../themes.config'
import {figma} from '~/src/platforms'
import type {ConfigGeneratorOptions} from '~/src/types/StyleDictionaryConfigGenerator'

export const buildFigma = (buildOptions: ConfigGeneratorOptions): void => {
/** -----------------------------------
* Colors
* ----------------------------------- */
// base colors
const baseScales = [
{
name: 'light',
source: [`src/tokens/base/color/light/light.json5`],
},
{
name: 'dark',
source: [`src/tokens/base/color/dark/dark.json5`],
},
// {
// name: 'dark-dimmed',
// source: [`src/tokens/base/color/dark/dark.json5`, `src/tokens/base/color/dark/dark.dimmed.json5`],
// },
]

for (const {name, source} of baseScales) {
PrimerStyleDictionary.extend({
source,
platforms: {
figma: figma(`figma/scales/${name}.json`, buildOptions.prefix, buildOptions.buildPath),
},
}).buildAllPlatforms()
}
//
for (const {filename, source, include} of themes) {
if (['light', 'dark' /*, 'dark-dimmed'*/].includes(filename)) {
// build functional scales
PrimerStyleDictionary.extend({
source,
include,
platforms: {
figma: figma(`figma/themes/${filename}.json`, buildOptions.prefix, buildOptions.buildPath, {
mode: filename,
}),
},
}).buildAllPlatforms()
}
}
/** -----------------------------------
* Size tokens
* ----------------------------------- */
const sizeFiles = [
'src/tokens/base/size/size.json',
'src/tokens/functional/size/breakpoints.json',
'src/tokens/functional/size/size.json',
'src/tokens/functional/size/border.json',
// 'src/tokens/functional/size/size-fine.json',
// 'src/tokens/functional/size/size-coarse.json',
]
//
PrimerStyleDictionary.extend({
source: sizeFiles,
include: sizeFiles,
platforms: {
figma: figma(`figma/dimension/dimension.json`, buildOptions.prefix, buildOptions.buildPath),
},
}).buildAllPlatforms()

/** -----------------------------------
* Create list of files
* ----------------------------------- */
const dirNames = fs
.readdirSync(`${buildOptions.buildPath}figma`, {withFileTypes: true})
.filter(dir => dir.isDirectory())
.map(dir => dir.name)

const files = dirNames.flatMap(dir => {
const localFiles = fs.readdirSync(`${buildOptions.buildPath}figma/${dir}`)
return localFiles.map(file => `${buildOptions.buildPath}figma/${dir}/${file}`)
})

const tokens: {
collection: string
mode: string
}[] = files.flatMap(filePath => JSON.parse(fs.readFileSync(filePath, 'utf8')))
const collections: Record<string, string[]> = {}

for (const {collection, mode} of tokens) {
if (!(collection in collections)) {
collections[collection] = []
}
if (!collections[collection].includes(mode)) {
collections[collection].push(mode)
}
}

// define the order of the modes
// we inverse it to deal with the -1 of the indexOf if item is not found in the array: basically anything that is not in the list should come last
const modeOrder = ['light', 'dark'].reverse()
// sort modes in the order defined above
for (const collection in collections) {
collections[collection].sort((a, b) => modeOrder.indexOf(b) - modeOrder.indexOf(a))
}
// write to file
fs.writeFileSync(`${buildOptions.buildPath}figma/figma.json`, JSON.stringify({collections, files}, null, 2))
}

buildFigma({
buildPath: 'tokens-next-private/',
})
2 changes: 2 additions & 0 deletions scripts/buildTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {ConfigGeneratorOptions, StyleDictionaryConfigGenerator} from '~/src
import type {TokenBuildInput} from '~/src/types/TokenBuildInput'
import glob from 'fast-glob'
import {themes} from './themes.config'
import {buildFigma} from './buildPlatforms/buildFigma'
/**
* getStyleDictionaryConfig
* @param filename output file name without extension
Expand Down Expand Up @@ -46,6 +47,7 @@ const getStyleDictionaryConfig: StyleDictionaryConfigGenerator = (
})

export const buildDesignTokens = (buildOptions: ConfigGeneratorOptions): void => {
buildFigma(buildOptions)
/** -----------------------------------
* Colors, shadows & borders
* ----------------------------------- */
Expand Down
6 changes: 6 additions & 0 deletions src/PrimerStyleDictionary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
jsonPostCssFallback,
cssWrapMediaQuery,
cssVariables,
jsonFigma,
} from './formats'

/**
Expand Down Expand Up @@ -99,6 +100,11 @@ StyleDictionary.registerFormat({
formatter: jsonPostCssFallback,
})

StyleDictionary.registerFormat({
name: 'json/figma',
formatter: jsonFigma,
})

/**
* Transformers
*
Expand Down
1 change: 1 addition & 0 deletions src/formats/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export {cssThemed} from './cssThemed'
export {cssCustomMedia} from './cssCustomMedia'
export {cssWrapMediaQuery} from './cssWrapMediaQuery'
export {cssVariables} from './cssVariables'
export {jsonFigma} from './jsonFigma'
export {javascriptCommonJs} from './javascriptCommonJs'
export {javascriptEsm} from './javascriptEsm'
export {jsonNestedPrefixed} from './jsonNestedPrefixed'
Expand Down
62 changes: 62 additions & 0 deletions src/formats/jsonFigma.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import StyleDictionary from 'style-dictionary'
import {format} from 'prettier'
import type {FormatterArguments} from 'style-dictionary/types/Format'
const {sortByReference} = StyleDictionary.formatHelpers

const replaceRegEx = /(?:{|})/g

const isReference = (string: string): boolean => /^\{([^\\]*)\}$/g.test(string)

const getReference = (dictionary: StyleDictionary.Dictionary, refString: string) => {
if (isReference(refString)) {
// retrieve reference token
const refToken = dictionary.getReferences(refString)[0]
// return full reference
return [refToken.attributes?.collection, ...refToken.path].filter(Boolean).join('/')
}
return undefined
}

const getFigmaType = (type: string): string => {
const validTypes = {
color: 'COLOR',
dimension: 'FLOAT',
}
if (type in validTypes) return validTypes[type as keyof typeof validTypes]
throw new Error(`Invalid type: ${type}`)
}

/**
* @description Converts `StyleDictionary.dictionary.tokens` to javascript esm (javascript export statement)
* @param arguments [FormatterArguments](https://github.com/amzn/style-dictionary/blob/main/types/Format.d.ts)
* @returns formatted `string`
*/
export const jsonFigma: StyleDictionary.Formatter = ({
dictionary,
file: _file,
platform: _platform,
}: FormatterArguments) => {
// sort tokens by reference
const tokens = dictionary.allTokens.sort(sortByReference(dictionary)).map(token => {
const {attributes, value, $type, comment: description, original, alpha, mix} = token
const {mode, collection, scopes} = attributes || {}
const tokenName = token.name.replace(replaceRegEx, '')
return {
name: tokenName,
value,
type: getFigmaType($type),
alpha,
isMix: mix ? true : undefined,
description,
refId: [collection, tokenName].filter(Boolean).join('/'),
reference: getReference(dictionary, original.value),
collection,
mode,
scopes,
}
})
// add file header and convert output
const output = JSON.stringify(tokens, null, 2)
// return prettified
return format(output, {parser: 'json', printWidth: 500})
}
46 changes: 46 additions & 0 deletions src/platforms/figma.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type StyleDictionary from 'style-dictionary'
import type {PlatformInitializer} from '~/src/types/PlatformInitializer'
import {isSource} from '~/src/filters'

const validFigmaToken = (token: StyleDictionary.TransformedToken) => {
const validTypes = ['color', 'dimension']
// is a siource token, not an included one
if (!isSource(token)) return false
// has a collection attribute
if (
!('$extensions' in token) ||
!('org.primer.figma' in token.$extensions) ||
!('collection' in token.$extensions['org.primer.figma'])
)
return false
// is a color or dimension type
return validTypes.includes(token.$type)
}

export const figma: PlatformInitializer = (outputFile, prefix, buildPath, options): StyleDictionary.Platform => ({
prefix,
buildPath,
transforms: [
'color/rgbaFloat',
'name/pathToSlashNotation',
'figma/attributes',
'dimension/pixelUnitless',
// 'border/figma',
// 'typography/figma',
'fontWeight/number',
],
options: {
basePxFontSize: 16,
...options,
},
files: [
{
destination: outputFile,
filter: validFigmaToken,
format: `json/figma`,
options: {
outputReferences: true,
},
},
],
})
1 change: 1 addition & 0 deletions src/platforms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export {css} from './css'
export {deprecatedJson} from './deprecatedJson'
export {docJson} from './docJson'
export {fallbacks} from './fallbacks'
export {figma} from './figma'
export {javascript} from './javascript'
export {json} from './json'
export {scss} from './scss'
Expand Down
Loading

0 comments on commit 360a8b8

Please sign in to comment.