Skip to content

Commit d67c418

Browse files
committed
feat(ffe-core): sync figma og kode, Figma Variables API
1 parent 3466a90 commit d67c418

21 files changed

+18996
-3
lines changed

packages/ffe-core/.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
css/
22
gen-src/
3+
less/colors-semantic.less
4+
less/colors-semantic-storybook.less

packages/ffe-core/less/ffe.less

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
// less variables
55
@import 'breakpoints';
66
@import 'colors';
7+
@import 'colors-semantic';
78
@import 'dimensions';
89
@import 'motion';
910
@import 'spacing';

packages/ffe-core/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
"lint": "ffe-buildtool stylelint less/*.less",
2424
"test": "npm run lint",
2525
"clean": "rm -rf css/ gen-src/ lib/",
26-
"build": "./scripts/build.js tokens.config.js && ./scripts/build-custom-mq.js less/breakpoints.less css/custom-media-queries.css && ffe-buildtool tsc && lessc less/ffe.less css/ffe.css --autoprefix"
26+
"generate-semantic-colors": "./scripts/generate-semantic-colors.js",
27+
"build": "npm run generate-semantic-colors && ./scripts/build.js tokens.config.js && ./scripts/build-custom-mq.js less/breakpoints.less css/custom-media-queries.css && ffe-buildtool tsc && lessc less/ffe.less css/ffe.css --autoprefix"
2728
},
2829
"devDependencies": {
2930
"@sb1/ffe-buildtool": "^0.9.0",

packages/ffe-core/scripts/build.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
#!/usr/bin/env node
22
const path = require('path');
3-
43
const writeToFile = require('./lib/writeToFile');
54
const renderLessVarsToCSSProps = require('./lib/renderLessVarsToCSSProps');
65
const extractCustomProps = require('./lib/extractCustomProps');
7-
const { genTSSource, genTSModIndex } = require('./lib/genTypeScript');
86

97
const configFilePath = process.argv[2];
108

@@ -13,7 +11,9 @@ if (!configFilePath) {
1311
process.exit(1);
1412
}
1513

14+
const { genTSSource, genTSModIndex } = require('./lib/genTypeScript');
1615
const config = require(path.resolve(configFilePath));
16+
1717
const lessFiles = config.sources.map(p => path.resolve(p));
1818

1919
const basename = fname => path.basename(fname, '.less');
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { colorApproximatelyEqual, parseColor, rgbToHex } from './color';
2+
3+
describe('colorApproximatelyEqual', () => {
4+
it('compares by hex value', () => {
5+
expect(
6+
colorApproximatelyEqual({ r: 0, g: 0, b: 0 }, { r: 0, g: 0, b: 0 }),
7+
).toBe(true);
8+
expect(
9+
colorApproximatelyEqual(
10+
{ r: 0, g: 0, b: 0 },
11+
{ r: 0, g: 0, b: 0, a: 1 },
12+
),
13+
).toBe(true);
14+
expect(
15+
colorApproximatelyEqual(
16+
{ r: 0, g: 0, b: 0, a: 0.5 },
17+
{ r: 0, g: 0, b: 0, a: 0.5 },
18+
),
19+
).toBe(true);
20+
expect(
21+
colorApproximatelyEqual(
22+
{ r: 0, g: 0, b: 0 },
23+
{ r: 0, g: 0, b: 0, a: 0 },
24+
),
25+
).toBe(false);
26+
27+
expect(
28+
colorApproximatelyEqual(
29+
{ r: 0, g: 0, b: 0 },
30+
{ r: 0.001, g: 0, b: 0 },
31+
),
32+
).toBe(true);
33+
expect(
34+
colorApproximatelyEqual(
35+
{ r: 0, g: 0, b: 0 },
36+
{ r: 0.0028, g: 0, b: 0 },
37+
),
38+
).toBe(false);
39+
});
40+
});
41+
42+
describe('parseColor', () => {
43+
it('parses hex values', () => {
44+
// 3-value syntax
45+
expect(parseColor('#000')).toEqual({ r: 0, g: 0, b: 0 });
46+
expect(parseColor('#fff')).toEqual({ r: 1, g: 1, b: 1 });
47+
expect(parseColor('#FFF')).toEqual({ r: 1, g: 1, b: 1 });
48+
expect(parseColor('#f09')).toEqual({ r: 1, g: 0, b: 153 / 255 });
49+
expect(parseColor('#F09')).toEqual({ r: 1, g: 0, b: 153 / 255 });
50+
51+
// 4-value syntax
52+
expect(parseColor('#0000')).toEqual({ r: 0, g: 0, b: 0, a: 0 });
53+
expect(parseColor('#000F')).toEqual({ r: 0, g: 0, b: 0, a: 1 });
54+
expect(parseColor('#f09a')).toEqual({
55+
r: 1,
56+
g: 0,
57+
b: 153 / 255,
58+
a: 170 / 255,
59+
});
60+
61+
// 6-value syntax
62+
expect(parseColor('#000000')).toEqual({ r: 0, g: 0, b: 0 });
63+
expect(parseColor('#ffffff')).toEqual({ r: 1, g: 1, b: 1 });
64+
expect(parseColor('#FFFFFF')).toEqual({ r: 1, g: 1, b: 1 });
65+
expect(parseColor('#ff0099')).toEqual({ r: 1, g: 0, b: 153 / 255 });
66+
expect(parseColor('#FF0099')).toEqual({ r: 1, g: 0, b: 153 / 255 });
67+
68+
// 8-value syntax
69+
expect(parseColor('#00000000')).toEqual({ r: 0, g: 0, b: 0, a: 0 });
70+
expect(parseColor('#00000080')).toEqual({
71+
r: 0,
72+
g: 0,
73+
b: 0,
74+
a: 128 / 255,
75+
});
76+
expect(parseColor('#000000ff')).toEqual({ r: 0, g: 0, b: 0, a: 1 });
77+
expect(parseColor('#5EE0DCAB')).toEqual({
78+
r: 0.3686274509803922,
79+
g: 0.8784313725490196,
80+
b: 0.8627450980392157,
81+
a: 0.6705882352941176,
82+
});
83+
});
84+
85+
it('handles invalid hex values', () => {
86+
expect(() => parseColor('#')).toThrowError('Invalid color format');
87+
expect(() => parseColor('#0')).toThrowError('Invalid color format');
88+
expect(() => parseColor('#00')).toThrowError('Invalid color format');
89+
expect(() => parseColor('#0000000')).toThrowError(
90+
'Invalid color format',
91+
);
92+
expect(() => parseColor('#000000000')).toThrowError(
93+
'Invalid color format',
94+
);
95+
expect(() => parseColor('#hhh')).toThrowError('Invalid color format');
96+
});
97+
});
98+
99+
describe('rgbToHex', () => {
100+
it('should convert rgb to hex', () => {
101+
expect(rgbToHex({ r: 1, g: 1, b: 1 })).toBe('#ffffff');
102+
expect(rgbToHex({ r: 0, g: 0, b: 0 })).toBe('#000000');
103+
expect(rgbToHex({ r: 0.5, g: 0.5, b: 0.5 })).toBe('#808080');
104+
expect(
105+
rgbToHex({
106+
r: 0.3686274509803922,
107+
g: 0.8784313725490196,
108+
b: 0.8627450980392157,
109+
}),
110+
).toBe('#5ee0dc');
111+
});
112+
113+
it('should convert rgba to hex', () => {
114+
expect(rgbToHex({ r: 1, g: 1, b: 1, a: 1 })).toBe('#ffffff');
115+
expect(rgbToHex({ r: 0, g: 0, b: 0, a: 0.5 })).toBe('#00000080');
116+
expect(rgbToHex({ r: 0.5, g: 0.5, b: 0.5, a: 0.5 })).toBe('#80808080');
117+
expect(
118+
rgbToHex({
119+
r: 0.3686274509803922,
120+
g: 0.8784313725490196,
121+
b: 0.8627450980392157,
122+
a: 0,
123+
}),
124+
).toBe('#5ee0dc00');
125+
});
126+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { RGB, RGBA } from '@figma/rest-api-spec';
2+
3+
export function rgbToHex({ r, g, b, ...rest }: RGB | RGBA) {
4+
const a = 'a' in rest ? rest.a : 1;
5+
6+
const toHex = (value: number) => {
7+
const hex = Math.round(value * 255).toString(16);
8+
return hex.length === 1 ? `0${hex}` : hex;
9+
};
10+
11+
const hex = [toHex(r), toHex(g), toHex(b)].join('');
12+
return `#${hex}${a !== 1 ? toHex(a) : ''}`.trim();
13+
}
14+
15+
/**
16+
* Compares two colors for approximate equality since converting between Figma RGBA objects (from 0 -> 1) and
17+
* hex colors can result in slight differences.
18+
*/
19+
export function colorApproximatelyEqual(
20+
colorA: RGB | RGBA,
21+
colorB: RGB | RGBA,
22+
) {
23+
return rgbToHex(colorA) === rgbToHex(colorB);
24+
}
25+
26+
export function parseColor(color: string): RGB | RGBA {
27+
const trimmedColor = color.trim();
28+
const hexRegex = /^#([A-Fa-f0-9]{6})([A-Fa-f0-9]{2}){0,1}$/;
29+
const hexShorthandRegex = /^#([A-Fa-f0-9]{3})([A-Fa-f0-9]){0,1}$/;
30+
31+
if (hexRegex.test(trimmedColor) || hexShorthandRegex.test(color)) {
32+
const hexValue = trimmedColor.substring(1);
33+
const expandedHex =
34+
hexValue.length === 3 || hexValue.length === 4
35+
? hexValue
36+
.split('')
37+
.map(char => char + char)
38+
.join('')
39+
: hexValue;
40+
41+
const alphaValue =
42+
expandedHex.length === 8 ? expandedHex.slice(6, 8) : undefined;
43+
44+
return {
45+
r: parseInt(expandedHex.slice(0, 2), 16) / 255,
46+
g: parseInt(expandedHex.slice(2, 4), 16) / 255,
47+
b: parseInt(expandedHex.slice(4, 6), 16) / 255,
48+
...(alphaValue ? { a: parseInt(alphaValue, 16) / 255 } : {}),
49+
};
50+
}
51+
throw new Error('Invalid color format');
52+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import axios from 'axios';
2+
import {
3+
GetLocalVariablesResponse,
4+
PostVariablesRequestBody,
5+
PostVariablesResponse,
6+
} from '@figma/rest-api-spec';
7+
8+
export default class FigmaApi {
9+
private baseUrl = 'https://api.figma.com';
10+
private token: string;
11+
12+
constructor(token: string) {
13+
this.token = token;
14+
}
15+
16+
async getLocalVariables(fileKey: string) {
17+
const resp = await axios.request<GetLocalVariablesResponse>({
18+
url: `${this.baseUrl}/v1/files/${fileKey}/variables/local`,
19+
headers: {
20+
Accept: '*/*',
21+
'X-Figma-Token': this.token,
22+
},
23+
});
24+
25+
return resp.data;
26+
}
27+
28+
async postVariables(fileKey: string, payload: PostVariablesRequestBody) {
29+
const resp = await axios.request<PostVariablesResponse>({
30+
url: `${this.baseUrl}/v1/files/${fileKey}/variables`,
31+
method: 'POST',
32+
headers: {
33+
Accept: '*/*',
34+
'X-Figma-Token': this.token,
35+
},
36+
data: payload,
37+
});
38+
39+
return resp.data;
40+
}
41+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import 'dotenv/config';
2+
import * as fs from 'fs';
3+
4+
import FigmaApi from './figma_api';
5+
6+
import { green } from './utils';
7+
import { tokenFilesFromLocalVariables } from './token_export';
8+
9+
/**
10+
* Usage:
11+
*
12+
* // Defaults to writing to the tokens_new directory
13+
* npm run sync-figma-to-tokens
14+
*
15+
* // Writes to the specified directory
16+
* npm run sync-figma-to-tokens -- --output directory_name
17+
*/
18+
19+
async function main() {
20+
if (!process.env.PERSONAL_ACCESS_TOKEN || !process.env.FILE_KEY) {
21+
throw new Error(
22+
'PERSONAL_ACCESS_TOKEN and FILE_KEY environemnt variables are required',
23+
);
24+
}
25+
const fileKey = process.env.FILE_KEY;
26+
27+
const api = new FigmaApi(process.env.PERSONAL_ACCESS_TOKEN);
28+
const localVariables = await api.getLocalVariables(fileKey);
29+
30+
const tokensFiles = tokenFilesFromLocalVariables(localVariables);
31+
32+
let outputDir = './packages/ffe-core/tokens';
33+
const outputArgIdx = process.argv.indexOf('--output');
34+
if (outputArgIdx !== -1) {
35+
outputDir = process.argv[outputArgIdx + 1];
36+
}
37+
38+
if (!fs.existsSync(outputDir)) {
39+
fs.mkdirSync(outputDir);
40+
}
41+
42+
Object.entries(tokensFiles).forEach(([fileName, fileContent]) => {
43+
fs.writeFileSync(
44+
`${outputDir}/${fileName}`,
45+
JSON.stringify(fileContent, null, 2),
46+
);
47+
console.log(`Wrote ${fileName}`);
48+
});
49+
50+
console.log(
51+
green(
52+
`✅ Tokens files have been written to the ${outputDir} directory`,
53+
),
54+
);
55+
}
56+
57+
main();

0 commit comments

Comments
 (0)