Skip to content

Commit

Permalink
feat(tokens): add Figma sync script (#1532)
Browse files Browse the repository at this point in the history
* feat(tokens): add Figma sync script

* feat: support modes

* feat: use env var

* fix: use built-in

* fix: typecheck

* fix: remove root to avoid redundancy
  • Loading branch information
Niznikr authored Jan 30, 2025
1 parent eb7e2a8 commit 0d68d77
Show file tree
Hide file tree
Showing 12 changed files with 533 additions and 78 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"test:watch": "vitest",
"prepare": "husky",
"release": "nx affected --target=build --base=HEAD~1 --head=HEAD --exclude=launchpad-design-system && changeset publish",
"typecheck": "pnpm build:transform && tsc --noEmit --emitDeclarationOnly false --skipLibCheck --moduleResolution bundler"
"typecheck": "pnpm build:transform && tsc --noEmit --emitDeclarationOnly false --skipLibCheck --moduleResolution bundler --lib 'es2024, dom, dom.iterable'"
},
"repository": {
"type": "git",
Expand Down
6 changes: 4 additions & 2 deletions packages/tokens/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,12 @@
"build": "tsx src/build.ts && tsc --noEmit",
"clean": "rm -rf dist",
"lint": "exit 0",
"test": "vitest run"
"test": "vitest run",
"sync": "tsx src/sync.ts"
},
"devDependencies": {
"@figma/rest-api-spec": "^0.22.0",
"@figma/rest-api-spec": "^0.23.0",
"axios": "^1.7.9",
"json-to-ts": "^2.1.0",
"style-dictionary": "^4.3.0",
"tsx": "^4.19.0"
Expand Down
125 changes: 86 additions & 39 deletions packages/tokens/src/build.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,21 @@
import type { LocalVariable, RGBA, VariableValue } from '@figma/rest-api-spec';
import type { RGBA, VariableValue } from '@figma/rest-api-spec';
import type {
Config,
DesignToken,
DesignTokens,
PreprocessedTokens,
TransformedToken,
} from 'style-dictionary/types';
import type { Variable } from './types';

import JsonToTS from 'json-to-ts';
import StyleDictionary from 'style-dictionary';
import { formats, transformGroups, transforms } from 'style-dictionary/enums';
import { fileHeader, minifyDictionary } from 'style-dictionary/utils';
import { formats, transformGroups, transformTypes, transforms } from 'style-dictionary/enums';
import { fileHeader, minifyDictionary, usesReferences } from 'style-dictionary/utils';

import { css, themes } from './themes';

interface Variable extends Partial<LocalVariable> {
value: VariableValue;
}

const [light, dark] = themes;
const configs = themes.map(css);

const runSD = async (cfg: Config) => {
Expand Down Expand Up @@ -71,6 +69,21 @@ const removeExtensions = (slice: DesignTokens | DesignToken): PreprocessedTokens
return slice as PreprocessedTokens;
};

const getResolvedType = (value: DesignToken['$value']) => {
switch (typeof value) {
case 'object':
return 'COLOR';
case 'number':
return 'FLOAT';
case 'string':
return 'STRING';
case 'boolean':
return 'BOOLEAN';
default:
throw new Error(`Invalid token type: ${typeof value}`);
}
};

const sd = new StyleDictionary({
source: ['tokens/*.json'],
hooks: {
Expand Down Expand Up @@ -131,6 +144,9 @@ const sd = new StyleDictionary({
{
format: 'javascript/esm',
destination: 'index.es.js',
options: {
minify: true,
},
},
{
format: 'typescript/accurate-module-declarations',
Expand Down Expand Up @@ -168,7 +184,7 @@ const sd = new StyleDictionary({
},
files: [
{
destination: 'figma.json',
destination: `figma.${light}.json`,
format: 'json/figma',
filter: (token) => token.$extensions,
},
Expand All @@ -192,17 +208,38 @@ const sd = new StyleDictionary({
},
});

sd.registerFormat({
const modes = new StyleDictionary({
source: ['tokens/color-primitives.json', `tokens/*.${light}.json`, `tokens/*.${dark}.json`],
platforms: {
figma: {
buildPath: 'dist/',
transforms: [transforms.nameKebab, transforms.attributeColor],
options: {
outputReferences: true,
usesDtcg: true,
},
files: [
{
destination: `figma.${dark}.json`,
format: 'json/figma',
filter: (token) => !token.filePath.includes('primitives'),
},
],
},
},
});

StyleDictionary.registerFormat({
name: 'css/themes',
format: async () => {
const light = aliasTokens['default.css'];
const dark = aliasTokens['dark.css'];
const lightMode = aliasTokens[`${light}.css`];
const darkMode = aliasTokens[`${dark}.css`];

return `${light}\n${dark}`;
return `${lightMode}\n${darkMode}`;
},
});

sd.registerFormat({
StyleDictionary.registerFormat({
name: 'custom/font-face',
format: async ({ dictionary }) => {
return dictionary.allTokens
Expand All @@ -222,14 +259,14 @@ sd.registerFormat({
},
});

sd.registerFormat({
StyleDictionary.registerFormat({
name: 'custom/json',
format: async ({ dictionary, options }) => {
return `${JSON.stringify(minifyDictionary(dictionary.tokens, options.usesDtcg), null, 2)}\n`;
},
});

sd.registerFormat({
StyleDictionary.registerFormat({
name: 'custom/media-query',
format: async ({ dictionary }) => {
return dictionary.allTokens
Expand All @@ -242,19 +279,7 @@ sd.registerFormat({
},
});

sd.registerFormat({
name: 'javascript/esm',
format: async ({ dictionary, file, options }) => {
const header = await fileHeader({ file });
return `${header}export default ${JSON.stringify(
minifyDictionary(dictionary.tokens, options.usesDtcg),
null,
2,
)};\n`;
},
});

sd.registerFormat({
StyleDictionary.registerFormat({
name: 'javascript/commonJs',
format: async ({ dictionary, file, options }) => {
const header = await fileHeader({ file });
Expand All @@ -266,7 +291,7 @@ sd.registerFormat({
},
});

sd.registerFormat({
StyleDictionary.registerFormat({
name: 'typescript/accurate-module-declarations',
format: async ({ dictionary, options }) => {
return `declare const root: RootObject\nexport default root\n${JsonToTS(
Expand All @@ -275,7 +300,7 @@ sd.registerFormat({
},
});

sd.registerFormat({
StyleDictionary.registerFormat({
name: 'json/category',
format: async ({ dictionary }) => {
const groups = dictionary.allTokens.reduce((acc: TransformedToken, obj) => {
Expand All @@ -288,39 +313,60 @@ sd.registerFormat({
},
});

sd.registerFormat({
StyleDictionary.registerFormat({
name: 'json/figma',
format: async ({ dictionary }) => {
const tokens = dictionary.allTokens.map((token) => {
const { attributes, $description: description, $extensions } = token;
const { hiddenFromPublishing, scopes } = $extensions['com.figma'];
const { attributes, $description: description = '', $extensions } = token;
const { hiddenFromPublishing, scopes } = $extensions?.['com.figma'] || {};

const [collection] = token.filePath
.replace('tokens/', '')
.split('.')
.filter((path) => path !== 'json');

const { r, g, b, a } = { ...(attributes?.rgb as RGBA) };
const original: VariableValue =
token.$type === 'color' ? { r: r / 255, g: g / 255, b: b / 255, a } : token.$value;
const value: VariableValue = usesReferences(token.original.$value)
? {
type: 'VARIABLE_ALIAS',
id: token.original.$value
.trim()
.replace(/[\{\}]/g, '')
.split('.')
.join('/'),
}
: original;
const resolvedType = getResolvedType(original);

return {
name: token.name.replaceAll('-', '/'),
name: token.name.split('-').join('/'),
description,
value: token.$type === 'color' ? { r: r / 255, g: g / 255, b: b / 255, a } : token.$value,
value,
hiddenFromPublishing,
scopes,
codeSyntax: { WEB: `var(--lp-${token.name})` },
resolvedType,
collection,
} satisfies Variable;
});
return `${JSON.stringify(tokens, null, 2)}\n`;
},
});

sd.registerTransform({
StyleDictionary.registerTransform({
name: 'custom/value/name',
type: 'attribute',
type: transformTypes.attribute,
transform: (token) => {
token.$value = token.name;
return token;
},
});

sd.registerTransform({
StyleDictionary.registerTransform({
name: 'attribute/font',
type: 'attribute',
type: transformTypes.attribute,
filter: (token) => token.$type === 'file',
transform: (token) => ({
category: token.path[0],
Expand All @@ -332,3 +378,4 @@ sd.registerTransform({
});

await sd.buildAllPlatforms();
await modes.buildAllPlatforms();
23 changes: 23 additions & 0 deletions packages/tokens/src/color.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { RGB, RGBA } from '@figma/rest-api-spec';

/**
* Compares two colors for approximate equality since converting between Figma RGBA objects (from 0 -> 1) and
* hex colors can result in slight differences.
*/
const colorApproximatelyEqual = (colorA: RGB | RGBA, colorB: RGB | RGBA) => {
return rgbToHex(colorA) === rgbToHex(colorB);
};

const rgbToHex = ({ r, g, b, ...rest }: RGB | RGBA) => {
const a = 'a' in rest ? rest.a : 1;

const toHex = (value: number) => {
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) : ''}`;
};

export { colorApproximatelyEqual, rgbToHex };
44 changes: 44 additions & 0 deletions packages/tokens/src/figma.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type {
GetLocalVariablesResponse,
PostVariablesRequestBody,
PostVariablesResponse,
} from '@figma/rest-api-spec';

import axios from 'axios';

class FigmaApi {
private baseUrl = 'https://api.figma.com';
private token: string;

constructor(token: string) {
this.token = token;
}

async getLocalVariables(fileKey: string) {
const resp = await axios.request<GetLocalVariablesResponse>({
url: `${this.baseUrl}/v1/files/${fileKey}/variables/local`,
headers: {
Accept: '*/*',
'X-Figma-Token': this.token,
},
});

return resp.data;
}

async postVariables(fileKey: string, payload: PostVariablesRequestBody) {
const resp = await axios.request<PostVariablesResponse>({
url: `${this.baseUrl}/v1/files/${fileKey}/variables`,
method: 'POST',
headers: {
Accept: '*/*',
'X-Figma-Token': this.token,
},
data: payload,
});

return resp.data;
}
}

export { FigmaApi };
52 changes: 52 additions & 0 deletions packages/tokens/src/sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { Variable } from './types';

import darkTokens from '../dist/figma.dark.json';
import defaultTokens from '../dist/figma.default.json';
import { FigmaApi } from './figma';
import { generatePostVariablesPayload } from './variables';

// https://github.com/gerard-figma/figma-variables-to-styledictionary
const main = async () => {
const fileKey = process.env.FIGMA_FILE_KEY ?? '';
const api = new FigmaApi(process.env.FIGMA_TOKEN ?? '');

const localVariables = await api.getLocalVariables(fileKey);

const tokens = {
Default: Object.groupBy(defaultTokens, ({ collection }) => collection),
Dark: Object.groupBy(darkTokens, ({ collection }) => collection),
};
const postVariablesPayload = generatePostVariablesPayload(
tokens as unknown as Record<string, Record<string, Variable[]>>,
localVariables,
);

if (Object.values(postVariablesPayload).every((value) => value.length === 0)) {
console.log('%c ✅ Tokens are already up to date with the Figma file', 'color:green;');
return;
}

const apiResp = await api.postVariables(fileKey, postVariablesPayload);

console.log('POST variables API response:', apiResp);

if (postVariablesPayload.variableCollections?.length) {
console.log('Updated variable collections', postVariablesPayload.variableCollections);
}

if (postVariablesPayload.variableModes?.length) {
console.log('Updated variable modes', postVariablesPayload.variableModes);
}

if (postVariablesPayload.variables?.length) {
console.log('Updated variables', postVariablesPayload.variables);
}

if (postVariablesPayload.variableModeValues?.length) {
console.log('Updated variable mode values', postVariablesPayload.variableModeValues);
}

console.log('%c ✅ Figma file has been updated with the new tokens', 'color:green;');
};

main();
Loading

0 comments on commit 0d68d77

Please sign in to comment.