Skip to content
Open
Show file tree
Hide file tree
Changes from 12 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 .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ module.exports = {
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
env: {
node: true,
},
globals: {
fetch: 'readonly',
},
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/ban-ts-ignore': 'off',
Expand Down
105 changes: 105 additions & 0 deletions .github/workflows/figma-sync.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
name: Tokens Sync automation

on:
workflow_dispatch:
inputs:
toBranch:
description: 'Branch to sync tokens to'
required: true
default: main
type: choice
options:
- main
- prerelease/minor
- prerelease/major

jobs:
sync-tokens:
runs-on: ubuntu-latest
Comment thread
Copilot marked this conversation as resolved.
permissions:
packages: write
pull-requests: write
contents: write
env:
NODE_VERSION: 24.x

steps:
- name: Set target branch name
id: target-branch
run: echo "name=sync-tokens-from-figma-${{ inputs.toBranch }}" >> $GITHUB_OUTPUT

- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ inputs.toBranch }}

- name: Set global user
run: |
git config --global user.name "${{ github.actor }}"
git config --global user.email "${{ github.actor }}@users.noreply.github.com"

- name: Create branch
run: |
git checkout -b ${{ steps.target-branch.outputs.name }}

- uses: actions/setup-node@v7.0.0
with:
node-version: ${{ env.NODE_VERSION }}

Comment thread
RayRedGoose marked this conversation as resolved.
- name: Cache node modules
id: yarn-cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # 6.1.0
with:
path: node_modules
key: ${{ runner.os }}-${{ env.NODE_VERSION }}-node-modules-hash-${{ hashFiles('yarn.lock') }}

- name: Install dev dependencies
if: steps.yarn-cache.outputs.cache-hit != 'true'
run: yarn install --production=false

- name: Fetch Figma variables
run: yarn fetch-tokens
env:
FIGMA_ACCESS_TOKEN: ${{ secrets.FIGMA_ACCESS_TOKEN }}
FIGMA_BASE_FILE_KEY: ${{ secrets.FIGMA_BASE_FILE_KEY }}
FIGMA_MAIN_FILE_KEY: ${{ secrets.FIGMA_MAIN_FILE_KEY }}

- name: Parse Figma variables
run: yarn parse-tokens

- name: Clear raw tokens
run: rm -rf figma-raw-tokens

- name: Check for token changes
id: changes
run: |
git add packages/canvas-tokens/dtcg/tokens
if git diff --cached --quiet; then
echo "No token changes found."
echo "has_changes=false" >> "$GITHUB_OUTPUT"
else
echo "Token changes found."
echo "has_changes=true" >> "$GITHUB_OUTPUT"
fi

- name: Commit changes
if: steps.changes.outputs.has_changes == 'true'
run: |
git commit -m "feat: Sync tokens from Figma"
git push origin ${{ steps.target-branch.outputs.name }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do we want to handle the case of this workflow being dispatched when a previous sync PR is still open - would we use --force to add to it?


- name: Create pull request
if: steps.changes.outputs.has_changes == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ secrets.GH_RW_TOKEN }}
script: |
github.rest.pulls.create({
Comment thread
RayRedGoose marked this conversation as resolved.
owner: 'Workday',
repo: 'canvas-tokens',
title: 'feat: Sync tokens from Figma',
head: '${{ steps.target-branch.outputs.name }}',
base: '${{ inputs.toBranch }}'
});


2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,5 @@ existing-tokens.json

# Storybook
build-storybook.log

figma-raw-tokens/
11 changes: 10 additions & 1 deletion jest.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
export default {
testMatch: ['**/spec/**.spec.ts'],
transform: {'^.+\\.ts?$': 'ts-jest'},
transform: {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to also modify the tsconfig to include the scripts dir in typechecking the JSDoc types?

We'd just need to add:

"allowJs": true,
"checkJs": true

And change the include to "include": ["packages/**/*.json", "scripts/**/*.js"],.

Alternatively, if we want full typechecking & the ability to use Figma's types, we could convert the scripts to TS files and then run them using tsx instead of node directly. I have used it before & it works well.

'^.+\\.tsx?$': [
'ts-jest',
{
tsconfig: {
allowJs: true,
},
},
],
},
testEnvironment: 'node',
roots: ['packages/canvas-tokens', 'packages/canvas-tokens-web'],
testTimeout: 200000,
Expand Down
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@
"build:tokens": "nx build @workday/canvas-tokens",
"clean:tokens": "nx clean @workday/canvas-tokens-web",
"lint": "eslint -c ./.eslintrc.js --ext=ts .",
"fetch-tokens": "node scripts/utils/fetch-figma-variables.js",
"parse-tokens": "node scripts/utils/generate-dtcg-tokens.js",
"precommit": "lint-staged",
"prepare": "husky install",
"prestorybook": "yarn build:tokens",
"storybook": "nx storybook @workday/canvas-tokens-docs",
"build-storybook": "nx build-storybook @workday/canvas-tokens-docs",
"serve-storybook": "npx http-server docs/storybook/@workday/canvas-tokens-docs",
"test": "jest -c jest.config.ts",
"test": "jest -c jest.config.ts && vitest run -c vitest.config.ts",

@sheelah sheelah Aug 31, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something for later: could add npm-run-all as a dependency and that would let us run both jest and vitest tests in parallel like:

"scripts": {
  "test:jest": "jest",
  "test:vitest": "vitest run",
  "test": "run-p test:jest test:vitest"
}

"test:parser": "vitest run -c vitest.config.ts",
"typecheck": "tsc -p . --noEmit",
"prerelease": "yarn clean:tokens && yarn build:tokens",
"release": "changeset publish"
Expand Down Expand Up @@ -58,7 +61,8 @@
"ts-jest": "^29.1.0",
"ts-node": "^10.9.2",
"typescript": "^5.0.4",
"vite": "~6.4.2"
"vite": "~6.4.2",
"vitest": "^3.0.5"
},
"workspaces": [
"packages/*"
Expand Down
206 changes: 206 additions & 0 deletions scripts/utils/fetch-figma-variables.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
#!/usr/bin/env node
import fs from 'fs';
import {resolve} from 'path';

Comment thread
RayRedGoose marked this conversation as resolved.
const FIGMA_API_BASE = 'https://api.figma.com/v1';
const DEFAULT_OUTPUT_DIR = 'figma-raw-tokens';
const SKIP_STYLE_TYPES = ['FILL', 'GRID'];

const {FIGMA_ACCESS_TOKEN, FIGMA_BASE_FILE_KEY, FIGMA_MAIN_FILE_KEY} = process.env;

function warn(message) {
console.warn(`Warning: ${message}`);
}

/**
* Makes a request to the Figma API.
* @param {string} path - The path to the Figma API.
* @param {string} token - The Figma access token.
* @returns {Promise<Object>} The response from the Figma API.
*/
async function figmaRequest(path, token) {
try {
const response = await fetch(`${FIGMA_API_BASE}${path}`, {
headers: {'X-Figma-Token': token},
});

const body = await response.json();

if (!response.ok || body.error) {
const message =
typeof body.message === 'string'
? body.message
: typeof body.err === 'string'
? body.err
: `Figma API request failed with status ${response.status}`;

throw new Error(message);
}

return body;
} catch (error) {
return {error: error.message};
}
}

/**
* Fetches the variables from the Figma API.
* @param {string} fileKey - The key of the file to fetch.
* @param {string} token - The Figma access token.
* @returns {Promise<Object>} The variables from the Figma API.
*/
async function fetchVariables(fileKey, token) {
try {
const response = await figmaRequest(`/files/${fileKey}/variables/local`, token);

if (response.error || !response.meta) {
warn(`Variables fetch failed: ${response.error || 'No metadata returned'}`);
return {meta: {}};
}

return {meta: response.meta};
} catch (error) {
console.error(`Error fetching variables: ${error.message}`);
return {meta: {}};
}
}

/**
* Fetches the published styles from the Figma API.
* @param {string} fileKey - The key of the file to fetch.
* @param {string} token - The Figma access token.
* @returns {Promise<Object>} The published styles from the Figma API.
*/
async function fetchPublishedStyles(fileKey, token) {
try {
const response = await figmaRequest(`/files/${fileKey}/styles`, token);

if (response.error || !Array.isArray(response.meta?.styles)) {
warn(`Published styles fetch failed: ${response.error || 'No styles metadata returned'}`);
return {styles: []};
}

const styles = response.meta.styles.filter(
style =>
!/more styles/i.test(style.name || '') && !SKIP_STYLE_TYPES.includes(style.style_type)
);

return {styles};
} catch (error) {
console.error(`Error fetching published styles: ${error.message}`);
return {styles: []};
}
}

/**
* Fetches the style nodes from the Figma API.
* @param {string} fileKey - The key of the file to fetch.
* @param {string} token - The Figma access token.
* @param {Array} styles - The styles to fetch.
* @returns {Promise<Object>} The style nodes from the Figma API.
*/
async function fetchStyleNodes(fileKey, token, styles) {
try {
const nodeIds = [...new Set(styles.map(style => style.node_id).filter(Boolean))];
const ids = encodeURIComponent(nodeIds.join(','));
const response = nodeIds.length
? await figmaRequest(`/files/${fileKey}/nodes?ids=${ids}`, token)
: {};

if (response.error) {
warn(`Style nodes fetch failed: ${response.error}`);
return {nodes: {}, file: {}};
}

const nodes = response.nodes || {};

return {
file: {
name: response.name,
lastModified: response.lastModified,
version: response.version,
},
nodes,
};
} catch (error) {
console.error(`Error fetching style nodes: ${error.message}`);
return {nodes: {}, file: {}};
}
}

/**
* Fetches the styles from the Figma API.
* @param {string} fileKey - The key of the file to fetch.
* @param {string} token - The Figma access token.
* @returns {Promise<Object>} The styles from the Figma API.
*/
async function fetchStyles(fileKey, token) {
try {
const published = await fetchPublishedStyles(fileKey, token);
const nodeResult = await fetchStyleNodes(fileKey, token, published.styles);

return {
published: published.styles,
nodes: nodeResult.nodes,
file: nodeResult.file,
};
} catch (error) {
console.error(`Error fetching styles: ${error.message}`);
return {};
}
}

/**
* Fetches the library from the Figma API.
* @param {string} fileKey - The key of the file to fetch.
* @param {string} token - The Figma access token.
* @returns {Promise<Object>} The library from the Figma API.
*/
async function fetchLibrary(fileKey, token) {
const variables = await fetchVariables(fileKey, token);
const styles = await fetchStyles(fileKey, token);
const name = fileKey === FIGMA_BASE_FILE_KEY ? 'Base' : 'Tokens';

const published = styles.published || [];
const nodes = styles.nodes || {};
const lastModified = styles.file?.lastModified || '';

return {
library: {
name,
lastModified,
fetchedAt: new Date().toISOString(),
},
meta: variables.meta,
styles: {published, nodes},
};
}

async function main() {
if (!FIGMA_ACCESS_TOKEN || !FIGMA_BASE_FILE_KEY || !FIGMA_MAIN_FILE_KEY) {
throw new Error(
'Missing Figma access token or file keys. Set FIGMA_ACCESS_TOKEN, FIGMA_BASE_FILE_KEY, and FIGMA_MAIN_FILE_KEY.'
);
}

const token = FIGMA_ACCESS_TOKEN || '';
const fileKeys = [FIGMA_BASE_FILE_KEY, FIGMA_MAIN_FILE_KEY];
const outputDirName = DEFAULT_OUTPUT_DIR;

const outputDir = resolve(process.cwd(), outputDirName);
fs.mkdirSync(outputDir, {recursive: true});

for (const fileKey of fileKeys) {
console.log(`Fetching variables and styles for library file key: ${fileKey}`);

const payload = await fetchLibrary(fileKey, token);
const fileName = `${payload.library.name.toLowerCase()}.json`;
const outputPath = resolve(outputDir, fileName);
fs.writeFileSync(outputPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
}
}

main().catch(error => {
console.error(`Error: ${error.message}`);
process.exitCode = 1;
});
Loading
Loading