├── .gitignore ├── .prettierignore ├── .vscode └── settings.json ├── .prettierrc ├── jest.config.ts ├── .github └── workflows │ ├── test.yml │ ├── sync-tokens-to-figma.yml │ └── sync-figma-to-tokens.yml ├── src ├── utils.ts ├── figma_api.ts ├── sync_figma_to_tokens.ts ├── color.ts ├── sync_tokens_to_figma.ts ├── token_types.ts ├── token_export.ts ├── color.test.ts ├── token_export.test.ts ├── token_import.ts └── token_import.test.ts ├── tsconfig.json ├── package.json ├── LICENSE.md ├── tokens ├── Product interactions — Completed.Default.json ├── Tokens — Completed.Dark.json ├── Tokens — Completed.Light.json ├── Primitives — Completed.Brutal Theme.json └── Primitives — Completed.Modern Theme.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore tokens json files 2 | tokens/* -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true, 6 | "printWidth": 100, 7 | "arrowParens": "always" 8 | } -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest' 2 | 3 | const config: Config = { 4 | verbose: true, 5 | //transform: {}, 6 | preset: 'ts-jest', 7 | testEnvironment: 'node', 8 | moduleNameMapper: { 9 | '^(\\.{1,2}/.*)\\.js$': '$1', 10 | }, 11 | } 12 | 13 | export default config 14 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Check out repository code 8 | uses: actions/checkout@v3 9 | - run: npm install 10 | - run: npm run prettier:check 11 | - run: npm run test 12 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export function green(msg: string) { 2 | return `\x1b[32m${msg}\x1b[0m` 3 | } 4 | 5 | export function brightRed(msg: string) { 6 | return `\x1b[1;31m${msg}\x1b[0m` 7 | } 8 | 9 | export function areSetsEqual(a: Set, b: Set) { 10 | return a.size === b.size && [...a].every((item) => b.has(item)) 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "target": "es2015", 5 | "sourceMap": true, 6 | "moduleResolution": "node16", 7 | "forceConsistentCasingInFileNames": true, 8 | "noImplicitReturns": true, 9 | "noUnusedLocals": true, 10 | "strict": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "allowSyntheticDefaultImports": true, 13 | "esModuleInterop": true, 14 | "noUnusedParameters": true, 15 | "noEmitOnError": true, 16 | "removeComments": true, 17 | "skipLibCheck": true 18 | }, 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "variables-github-action-example", 3 | "type": "module", 4 | "scripts": { 5 | "prettier:check": "prettier --check src/", 6 | "sync-tokens-to-figma": "tsx src/sync_tokens_to_figma.ts", 7 | "sync-figma-to-tokens": "tsx src/sync_figma_to_tokens.ts", 8 | "test": "jest" 9 | }, 10 | "dependencies": { 11 | "axios": "^1.6.0", 12 | "dotenv": "^16.3.1", 13 | "tsx": "^4.16.2", 14 | "typescript": "^5.1.6" 15 | }, 16 | "devDependencies": { 17 | "@figma/rest-api-spec": "^0.10.0", 18 | "@types/jest": "^29.5.3", 19 | "@types/node": "^20.4.5", 20 | "jest": "^29.6.2", 21 | "prettier": "3.0.0", 22 | "ts-jest": "^29.1.1" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/sync-tokens-to-figma.yml: -------------------------------------------------------------------------------- 1 | name: Sync tokens to Figma 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | file_key: 6 | description: 'The file key of the Figma file to be updated' 7 | required: true 8 | 9 | jobs: 10 | sync-tokens-to-figma: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Install Node.js 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: '18.16.0' 17 | 18 | - name: Set NPM version 19 | run: npm install -g npm@9.5.1 20 | 21 | - name: Clone repo 22 | uses: actions/checkout@v3 23 | 24 | - name: Install dependencies 25 | run: npm install 26 | 27 | - name: Sync tokens to Figma file 28 | run: npm run sync-tokens-to-figma 29 | env: 30 | FILE_KEY: ${{ github.event.inputs.file_key }} 31 | PERSONAL_ACCESS_TOKEN: ${{ secrets.GH_ACTION_VARIABLES_SYNC_FIGMA_TOKEN }} 32 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Figma 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/figma_api.ts: -------------------------------------------------------------------------------- 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({ 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({ 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 | } 42 | -------------------------------------------------------------------------------- /tokens/Product interactions — Completed.Default.json: -------------------------------------------------------------------------------- 1 | { 2 | "Is available": { 3 | "$type": "boolean", 4 | "$value": true, 5 | "$description": "", 6 | "$extensions": { 7 | "com.figma": { 8 | "hiddenFromPublishing": false, 9 | "scopes": [ 10 | "ALL_SCOPES" 11 | ], 12 | "codeSyntax": {} 13 | } 14 | } 15 | }, 16 | "Cart button text": { 17 | "$type": "string", 18 | "$value": "Available", 19 | "$description": "", 20 | "$extensions": { 21 | "com.figma": { 22 | "hiddenFromPublishing": false, 23 | "scopes": [ 24 | "ALL_SCOPES" 25 | ], 26 | "codeSyntax": {} 27 | } 28 | } 29 | }, 30 | "Amount available": { 31 | "$type": "number", 32 | "$value": 4, 33 | "$description": "", 34 | "$extensions": { 35 | "com.figma": { 36 | "hiddenFromPublishing": false, 37 | "scopes": [ 38 | "ALL_SCOPES" 39 | ], 40 | "codeSyntax": {} 41 | } 42 | } 43 | }, 44 | "Has cart": { 45 | "$type": "boolean", 46 | "$value": false, 47 | "$description": "", 48 | "$extensions": { 49 | "com.figma": { 50 | "hiddenFromPublishing": false, 51 | "scopes": [ 52 | "ALL_SCOPES" 53 | ], 54 | "codeSyntax": {} 55 | } 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /.github/workflows/sync-figma-to-tokens.yml: -------------------------------------------------------------------------------- 1 | name: Sync Figma variables to tokens 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | file_key: 6 | description: 'The file key of the Figma file to be updated' 7 | required: true 8 | 9 | jobs: 10 | sync-figma-to-tokens: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | # Need to be able to create new branches and commits 14 | contents: write 15 | pull-requests: write 16 | steps: 17 | - name: Install Node.js 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: '18.16.0' 21 | 22 | - name: Set NPM version 23 | run: npm install -g npm@9.5.1 24 | 25 | - name: Clone repo 26 | uses: actions/checkout@v3 27 | 28 | - name: Install dependencies 29 | run: npm install 30 | 31 | - name: Sync variables in Figma file to tokens 32 | run: npm run sync-figma-to-tokens -- --output tokens 33 | env: 34 | FILE_KEY: ${{ github.event.inputs.file_key }} 35 | PERSONAL_ACCESS_TOKEN: ${{ secrets.GH_ACTION_VARIABLES_SYNC_FIGMA_TOKEN }} 36 | 37 | - name: Create Pull Request 38 | uses: peter-evans/create-pull-request@v5 39 | with: 40 | commit-message: Update tokens from Figma 41 | title: Update tokens from Figma 42 | body: 'Update tokens from Figma from file: https://www.figma.com/file/${{ github.event.inputs.file_key }}' 43 | branch: update-tokens 44 | -------------------------------------------------------------------------------- /src/sync_figma_to_tokens.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config' 2 | import * as fs from 'fs' 3 | 4 | import FigmaApi from './figma_api.js' 5 | 6 | import { green } from './utils.js' 7 | import { tokenFilesFromLocalVariables } from './token_export.js' 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('PERSONAL_ACCESS_TOKEN and FILE_KEY environemnt variables are required') 22 | } 23 | const fileKey = process.env.FILE_KEY 24 | 25 | const api = new FigmaApi(process.env.PERSONAL_ACCESS_TOKEN) 26 | const localVariables = await api.getLocalVariables(fileKey) 27 | 28 | const tokensFiles = tokenFilesFromLocalVariables(localVariables) 29 | 30 | let outputDir = 'tokens_new' 31 | const outputArgIdx = process.argv.indexOf('--output') 32 | if (outputArgIdx !== -1) { 33 | outputDir = process.argv[outputArgIdx + 1] 34 | } 35 | 36 | if (!fs.existsSync(outputDir)) { 37 | fs.mkdirSync(outputDir) 38 | } 39 | 40 | Object.entries(tokensFiles).forEach(([fileName, fileContent]) => { 41 | fs.writeFileSync(`${outputDir}/${fileName}`, JSON.stringify(fileContent, null, 2)) 42 | console.log(`Wrote ${fileName}`) 43 | }) 44 | 45 | console.log(green(`✅ Tokens files have been written to the ${outputDir} directory`)) 46 | } 47 | 48 | main() 49 | -------------------------------------------------------------------------------- /src/color.ts: -------------------------------------------------------------------------------- 1 | import { RGB, RGBA } from '@figma/rest-api-spec' 2 | 3 | /** 4 | * Compares two colors for approximate equality since converting between Figma RGBA objects (from 0 -> 1) and 5 | * hex colors can result in slight differences. 6 | */ 7 | export function colorApproximatelyEqual(colorA: RGB | RGBA, colorB: RGB | RGBA) { 8 | return rgbToHex(colorA) === rgbToHex(colorB) 9 | } 10 | 11 | export function parseColor(color: string): RGB | RGBA { 12 | color = color.trim() 13 | const hexRegex = /^#([A-Fa-f0-9]{6})([A-Fa-f0-9]{2}){0,1}$/ 14 | const hexShorthandRegex = /^#([A-Fa-f0-9]{3})([A-Fa-f0-9]){0,1}$/ 15 | 16 | if (hexRegex.test(color) || hexShorthandRegex.test(color)) { 17 | const hexValue = color.substring(1) 18 | const expandedHex = 19 | hexValue.length === 3 || hexValue.length === 4 20 | ? hexValue 21 | .split('') 22 | .map((char) => char + char) 23 | .join('') 24 | : hexValue 25 | 26 | const alphaValue = expandedHex.length === 8 ? expandedHex.slice(6, 8) : undefined 27 | 28 | return { 29 | r: parseInt(expandedHex.slice(0, 2), 16) / 255, 30 | g: parseInt(expandedHex.slice(2, 4), 16) / 255, 31 | b: parseInt(expandedHex.slice(4, 6), 16) / 255, 32 | ...(alphaValue ? { a: parseInt(alphaValue, 16) / 255 } : {}), 33 | } 34 | } else { 35 | throw new Error('Invalid color format') 36 | } 37 | } 38 | 39 | export function rgbToHex({ r, g, b, ...rest }: RGB | RGBA) { 40 | const a = 'a' in rest ? rest.a : 1 41 | 42 | const toHex = (value: number) => { 43 | const hex = Math.round(value * 255).toString(16) 44 | return hex.length === 1 ? '0' + hex : hex 45 | } 46 | 47 | const hex = [toHex(r), toHex(g), toHex(b)].join('') 48 | return `#${hex}` + (a !== 1 ? toHex(a) : '') 49 | } 50 | -------------------------------------------------------------------------------- /src/sync_tokens_to_figma.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config' 2 | import * as fs from 'fs' 3 | 4 | import FigmaApi from './figma_api.js' 5 | 6 | import { green } from './utils.js' 7 | import { generatePostVariablesPayload, readJsonFiles } from './token_import.js' 8 | 9 | async function main() { 10 | if (!process.env.PERSONAL_ACCESS_TOKEN || !process.env.FILE_KEY) { 11 | throw new Error('PERSONAL_ACCESS_TOKEN and FILE_KEY environemnt variables are required') 12 | } 13 | const fileKey = process.env.FILE_KEY 14 | 15 | const TOKENS_DIR = 'tokens' 16 | const tokensFiles = fs.readdirSync(TOKENS_DIR).map((file: string) => `${TOKENS_DIR}/${file}`) 17 | 18 | const tokensByFile = readJsonFiles(tokensFiles) 19 | 20 | console.log('Read tokens files:', Object.keys(tokensByFile)) 21 | 22 | const api = new FigmaApi(process.env.PERSONAL_ACCESS_TOKEN) 23 | const localVariables = await api.getLocalVariables(fileKey) 24 | 25 | const postVariablesPayload = generatePostVariablesPayload(tokensByFile, localVariables) 26 | 27 | if (Object.values(postVariablesPayload).every((value) => value.length === 0)) { 28 | console.log(green('✅ Tokens are already up to date with the Figma file')) 29 | return 30 | } 31 | 32 | const apiResp = await api.postVariables(fileKey, postVariablesPayload) 33 | 34 | console.log('POST variables API response:', apiResp) 35 | 36 | if (postVariablesPayload.variableCollections && postVariablesPayload.variableCollections.length) { 37 | console.log('Updated variable collections', postVariablesPayload.variableCollections) 38 | } 39 | 40 | if (postVariablesPayload.variableModes && postVariablesPayload.variableModes.length) { 41 | console.log('Updated variable modes', postVariablesPayload.variableModes) 42 | } 43 | 44 | if (postVariablesPayload.variables && postVariablesPayload.variables.length) { 45 | console.log('Updated variables', postVariablesPayload.variables) 46 | } 47 | 48 | if (postVariablesPayload.variableModeValues && postVariablesPayload.variableModeValues.length) { 49 | console.log('Updated variable mode values', postVariablesPayload.variableModeValues) 50 | } 51 | 52 | console.log(green('✅ Figma file has been updated with the new tokens')) 53 | } 54 | 55 | main() 56 | -------------------------------------------------------------------------------- /src/token_types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file defines what design tokens and design token files look like in the codebase. 3 | * 4 | * Tokens are distinct from variables, in that a [token](https://tr.designtokens.org/format/#design-token) 5 | * is a name/value pair (with other properties), while a variable in Figma stores multiple values, 6 | * one for each mode. 7 | */ 8 | 9 | import { VariableCodeSyntax, VariableScope } from '@figma/rest-api-spec' 10 | 11 | export interface Token { 12 | /** 13 | * The [type](https://tr.designtokens.org/format/#type-0) of the token. 14 | * 15 | * We allow `string` and `boolean` types in addition to the draft W3C spec's `color` and `number` types 16 | * to align with the resolved types for Figma variables. 17 | */ 18 | $type: 'color' | 'number' | 'string' | 'boolean' 19 | $value: string | number | boolean 20 | $description?: string 21 | $extensions?: { 22 | /** 23 | * The `com.figma` namespace stores Figma-specific variable properties 24 | */ 25 | 'com.figma'?: { 26 | hiddenFromPublishing?: boolean 27 | scopes?: VariableScope[] 28 | codeSyntax?: VariableCodeSyntax 29 | } 30 | } 31 | } 32 | 33 | export type TokenOrTokenGroup = 34 | | Token 35 | | ({ 36 | [tokenName: string]: Token 37 | } & { $type?: never; $value?: never }) 38 | 39 | /** 40 | * Defines what we expect a Design Tokens file to look like in the codebase. 41 | * 42 | * This format mostly adheres to the [draft W3C spec for Design Tokens](https://tr.designtokens.org/format/) 43 | * as of its most recent 24 July 2023 revision except for the $type property, for which 44 | * we allow `string` and `boolean` values in addition to the spec's `color` and `number` values. 45 | * We need to support `string` and `boolean` types to align with the resolved types for Figma variables. 46 | * 47 | * Additionally, we expect each tokens file to define tokens for a single variable collection and mode. 48 | * There currently isn't a way to represent modes or themes in the W3C community group design token specification. 49 | * Once the spec resolves how it wants to treat/handle modes, this code will be updated to reflect the new standard. 50 | * 51 | * Follow this discussion for updates: https://github.com/design-tokens/community-group/issues/210 52 | */ 53 | export type TokensFile = { 54 | [key: string]: TokenOrTokenGroup 55 | } 56 | -------------------------------------------------------------------------------- /src/token_export.ts: -------------------------------------------------------------------------------- 1 | import { GetLocalVariablesResponse, LocalVariable } from '@figma/rest-api-spec' 2 | import { rgbToHex } from './color.js' 3 | import { Token, TokensFile } from './token_types.js' 4 | 5 | function tokenTypeFromVariable(variable: LocalVariable) { 6 | switch (variable.resolvedType) { 7 | case 'BOOLEAN': 8 | return 'boolean' 9 | case 'COLOR': 10 | return 'color' 11 | case 'FLOAT': 12 | return 'number' 13 | case 'STRING': 14 | return 'string' 15 | } 16 | } 17 | 18 | function tokenValueFromVariable( 19 | variable: LocalVariable, 20 | modeId: string, 21 | localVariables: { [id: string]: LocalVariable }, 22 | ) { 23 | const value = variable.valuesByMode[modeId] 24 | if (typeof value === 'object') { 25 | if ('type' in value && value.type === 'VARIABLE_ALIAS') { 26 | const aliasedVariable = localVariables[value.id] 27 | return `{${aliasedVariable.name.replace(/\//g, '.')}}` 28 | } else if ('r' in value) { 29 | return rgbToHex(value) 30 | } 31 | 32 | throw new Error(`Format of variable value is invalid: ${value}`) 33 | } else { 34 | return value 35 | } 36 | } 37 | 38 | export function tokenFilesFromLocalVariables(localVariablesResponse: GetLocalVariablesResponse) { 39 | const tokenFiles: { [fileName: string]: TokensFile } = {} 40 | const localVariableCollections = localVariablesResponse.meta.variableCollections 41 | const localVariables = localVariablesResponse.meta.variables 42 | 43 | Object.values(localVariables).forEach((variable) => { 44 | // Skip remote variables because we only want to generate tokens for local variables 45 | if (variable.remote) { 46 | return 47 | } 48 | 49 | const collection = localVariableCollections[variable.variableCollectionId] 50 | 51 | collection.modes.forEach((mode) => { 52 | const fileName = `${collection.name}.${mode.name}.json` 53 | 54 | if (!tokenFiles[fileName]) { 55 | tokenFiles[fileName] = {} 56 | } 57 | 58 | let obj: any = tokenFiles[fileName] 59 | 60 | variable.name.split('/').forEach((groupName) => { 61 | obj[groupName] = obj[groupName] || {} 62 | obj = obj[groupName] 63 | }) 64 | 65 | const token: Token = { 66 | $type: tokenTypeFromVariable(variable), 67 | $value: tokenValueFromVariable(variable, mode.modeId, localVariables), 68 | $description: variable.description, 69 | $extensions: { 70 | 'com.figma': { 71 | hiddenFromPublishing: variable.hiddenFromPublishing, 72 | scopes: variable.scopes, 73 | codeSyntax: variable.codeSyntax, 74 | }, 75 | }, 76 | } 77 | 78 | Object.assign(obj, token) 79 | }) 80 | }) 81 | 82 | return tokenFiles 83 | } 84 | -------------------------------------------------------------------------------- /src/color.test.ts: -------------------------------------------------------------------------------- 1 | import { colorApproximatelyEqual, parseColor, rgbToHex } from './color.js' 2 | 3 | describe('colorApproximatelyEqual', () => { 4 | it('compares by hex value', () => { 5 | expect(colorApproximatelyEqual({ r: 0, g: 0, b: 0 }, { r: 0, g: 0, b: 0 })).toBe(true) 6 | expect(colorApproximatelyEqual({ r: 0, g: 0, b: 0 }, { r: 0, g: 0, b: 0, a: 1 })).toBe(true) 7 | expect( 8 | colorApproximatelyEqual({ r: 0, g: 0, b: 0, a: 0.5 }, { r: 0, g: 0, b: 0, a: 0.5 }), 9 | ).toBe(true) 10 | expect(colorApproximatelyEqual({ r: 0, g: 0, b: 0 }, { r: 0, g: 0, b: 0, a: 0 })).toBe(false) 11 | 12 | expect(colorApproximatelyEqual({ r: 0, g: 0, b: 0 }, { r: 0.001, g: 0, b: 0 })).toBe(true) 13 | expect(colorApproximatelyEqual({ r: 0, g: 0, b: 0 }, { r: 0.0028, g: 0, b: 0 })).toBe(false) 14 | }) 15 | }) 16 | 17 | describe('parseColor', () => { 18 | it('parses hex values', () => { 19 | // 3-value syntax 20 | expect(parseColor('#000')).toEqual({ r: 0, g: 0, b: 0 }) 21 | expect(parseColor('#fff')).toEqual({ r: 1, g: 1, b: 1 }) 22 | expect(parseColor('#FFF')).toEqual({ r: 1, g: 1, b: 1 }) 23 | expect(parseColor('#f09')).toEqual({ r: 1, g: 0, b: 153 / 255 }) 24 | expect(parseColor('#F09')).toEqual({ r: 1, g: 0, b: 153 / 255 }) 25 | 26 | // 4-value syntax 27 | expect(parseColor('#0000')).toEqual({ r: 0, g: 0, b: 0, a: 0 }) 28 | expect(parseColor('#000F')).toEqual({ r: 0, g: 0, b: 0, a: 1 }) 29 | expect(parseColor('#f09a')).toEqual({ r: 1, g: 0, b: 153 / 255, a: 170 / 255 }) 30 | 31 | // 6-value syntax 32 | expect(parseColor('#000000')).toEqual({ r: 0, g: 0, b: 0 }) 33 | expect(parseColor('#ffffff')).toEqual({ r: 1, g: 1, b: 1 }) 34 | expect(parseColor('#FFFFFF')).toEqual({ r: 1, g: 1, b: 1 }) 35 | expect(parseColor('#ff0099')).toEqual({ r: 1, g: 0, b: 153 / 255 }) 36 | expect(parseColor('#FF0099')).toEqual({ r: 1, g: 0, b: 153 / 255 }) 37 | 38 | // 8-value syntax 39 | expect(parseColor('#00000000')).toEqual({ r: 0, g: 0, b: 0, a: 0 }) 40 | expect(parseColor('#00000080')).toEqual({ r: 0, g: 0, b: 0, a: 128 / 255 }) 41 | expect(parseColor('#000000ff')).toEqual({ r: 0, g: 0, b: 0, a: 1 }) 42 | expect(parseColor('#5EE0DCAB')).toEqual({ 43 | r: 0.3686274509803922, 44 | g: 0.8784313725490196, 45 | b: 0.8627450980392157, 46 | a: 0.6705882352941176, 47 | }) 48 | }) 49 | 50 | it('handles invalid hex values', () => { 51 | expect(() => parseColor('#')).toThrowError('Invalid color format') 52 | expect(() => parseColor('#0')).toThrowError('Invalid color format') 53 | expect(() => parseColor('#00')).toThrowError('Invalid color format') 54 | expect(() => parseColor('#0000000')).toThrowError('Invalid color format') 55 | expect(() => parseColor('#000000000')).toThrowError('Invalid color format') 56 | expect(() => parseColor('#hhh')).toThrowError('Invalid color format') 57 | }) 58 | }) 59 | 60 | describe('rgbToHex', () => { 61 | it('should convert rgb to hex', () => { 62 | expect(rgbToHex({ r: 1, g: 1, b: 1 })).toBe('#ffffff') 63 | expect(rgbToHex({ r: 0, g: 0, b: 0 })).toBe('#000000') 64 | expect(rgbToHex({ r: 0.5, g: 0.5, b: 0.5 })).toBe('#808080') 65 | expect(rgbToHex({ r: 0.3686274509803922, g: 0.8784313725490196, b: 0.8627450980392157 })).toBe( 66 | '#5ee0dc', 67 | ) 68 | }) 69 | 70 | it('should convert rgba to hex', () => { 71 | expect(rgbToHex({ r: 1, g: 1, b: 1, a: 1 })).toBe('#ffffff') 72 | expect(rgbToHex({ r: 0, g: 0, b: 0, a: 0.5 })).toBe('#00000080') 73 | expect(rgbToHex({ r: 0.5, g: 0.5, b: 0.5, a: 0.5 })).toBe('#80808080') 74 | expect( 75 | rgbToHex({ r: 0.3686274509803922, g: 0.8784313725490196, b: 0.8627450980392157, a: 0 }), 76 | ).toBe('#5ee0dc00') 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # variables-github-action-example 2 | 3 | This repository contains a couple of GitHub Actions workflows: 4 | 5 | - Sync tokens to Figma 6 | - Sync Figma variables to tokens 7 | 8 | These workflows demonstrate bi-directional syncing between variables in Figma and design tokens in a codebase using Figma's [Variables REST API](https://www.figma.com/developers/api#variables). For more background and a graphical representation of what these workflows do, see our [Syncing design systems using Variables REST API](https://www.figma.com/community/file/1270821372236564565) FigJam file. 9 | 10 | To use these workflows, you should copy the code in this repository into your organization and modify it to suit the needs of your design processes. 11 | 12 | ## Prerequisites 13 | 14 | To use the "Sync Figma variables to tokens" workflow, you must be a full member of an Enterprise org in Figma. To use the "Sync tokens to Figma" workflow, you must also have an editor seat. 15 | 16 | Both workflows assume that you have a single Figma file with local variable collections, along with one or more tokens json files in the `tokens/` directory that adhere\* to the [draft W3C spec for Design Tokens](https://tr.designtokens.org/format/). For demonstration purposes, this directory contains the tokens exported from the [Get started with variables](https://www.figma.com/community/file/1253086684245880517/Get-started-with-variables) Community file. Have a copy of this file ready if you want to try out the workflow with these existing tokens. 17 | 18 | > \*See `src/token_types.ts` for more details on the format of the expected tokens json files, including the deviations from the draft design tokens spec we've had to make. **We expect there to be one tokens file per variable collection and mode.** 19 | 20 | In addition, you must also have a [personal access token](https://www.figma.com/developers/api#access-tokens) for the Figma API to allow these workflows to authenticate with the API. For the "Sync Figma variables to tokens" workflow, the token must have at least the Read-only Variables scope selected. For the "Sync tokens to Figma" workflow, the token must have the Read and write Variables scope selected. 21 | 22 | ## Usage 23 | 24 | Before running either of these workflows, you'll need to create an [encrypted secret](https://docs.github.com/en/actions/security-guides/encrypted-secrets#creating-encrypted-secrets-for-a-repository) in your repository named `GH_ACTION_VARIABLES_SYNC_FIGMA_TOKEN` containing your personal access token. 25 | 26 | Both workflows are configured to [run manually](https://docs.github.com/en/actions/using-workflows/manually-running-a-workflow) for demonstration purposes, and are designed to be as conservative as possible in their functionality (see details below). 27 | 28 | ### Sync Figma variables to tokens 29 | 30 | To run the "Sync Figma variables to tokens" workflow: 31 | 32 | - Open the workflow under the **Actions** tab in your repository and click **Run workflow** 33 | - You will be asked to provide the file key of the Figma file. The file key can be obtained from any Figma file URL: `https://www.figma.com/file/{file_key}/{title}`. 34 | - After the workflow finishes, you should see a new pull request if there are changes to be made to the tokens files in the `tokens/` directory. If there are no changes to be made, then a pull request will not be created. 35 | 36 | This workflow has some key behaviors to note: 37 | 38 | - After generating the new tokens json files, this workflow creates a pull request for someone on the team to review. If you prefer, you can modify the workflow to commit directly to a designated branch without creating a pull request. 39 | - If a variable collection or mode is removed from the Figma file, the corresponding tokens file will not be removed from the codebase. 40 | 41 | ### Sync tokens to Figma 42 | 43 | To run the "Sync tokens to Figma" workflow: 44 | 45 | - Open the workflow under the **Actions** tab in your repository and click **Run workflow** 46 | - You will be asked to provide the file key of the Figma file. The file key can be obtained from any Figma file URL: `https://www.figma.com/file/{file_key}/{title}`. Note: if you are trying out this workflow for the first time, use a file that is separate from your design system to avoid any unintended changes. 47 | - After the workflow finishes, open the file in Figma and observe that the variables should be updated to reflect the tokens in your tokens files. 48 | 49 | This workflow has some key behaviors to note: 50 | 51 | - Though this workflow is configured to run manually, you're free to modify it to run on code push to a specified branch once you are comfortable with its behavior. 52 | - When syncing to a Figma file that does not have any variable collections, this workflow will add brand-new collections and variables. When syncing to a Figma file that has existing variable collections, this workflow will modify collections and variables **in-place** using name matching. That is, it will look for existing collections and variables by name, modify their properties and values if names match, and create new variables if names do not match. 53 | - The workflow will not remove variables or variable collections that have been removed in your tokens files. 54 | - When mapping aliases to existing local variables, we assume that variable names are unique _across all collections_ in the Figma file. Figma allows duplicate variable names across collections, so you should make sure that aliases don't have naming conflicts in your file. 55 | - For optional Figma variable properties like scopes and code syntax, the workflow will not modify these properties in Figma if the tokens json files do not contain those properties. 56 | - If a string variable is bound to a text node content in the same file, and the text node uses a [shared font in the organization](https://help.figma.com/hc/en-us/articles/360039956774), that variable cannot be updated and will result in a 400 response. 57 | 58 | ## Local development 59 | 60 | You can run the GitHub actions locally by running `npm install` and creating a `.env` file with the following contents: 61 | 62 | ``` 63 | FILE_KEY="your_file_key" 64 | PERSONAL_ACCESS_TOKEN="your_personal_access_token" 65 | ``` 66 | 67 | and then running: 68 | 69 | ```sh 70 | npm run sync-tokens-to-figma 71 | 72 | # or 73 | 74 | # Defaults to writing to the tokens_new directory 75 | npm run sync-figma-to-tokens 76 | 77 | # Writes to the specified directory 78 | npm run sync-figma-to-tokens -- --output directory_name 79 | ``` 80 | 81 | ### Testing 82 | 83 | Run the Jest tests: 84 | 85 | ```sh 86 | npm run test 87 | ``` 88 | -------------------------------------------------------------------------------- /tokens/Tokens — Completed.Dark.json: -------------------------------------------------------------------------------- 1 | { 2 | "border": { 3 | "border-primary": { 4 | "$type": "color", 5 | "$value": "{color.gray.50}", 6 | "$description": "", 7 | "$extensions": { 8 | "com.figma": { 9 | "hiddenFromPublishing": false, 10 | "scopes": [ 11 | "ALL_SCOPES" 12 | ], 13 | "codeSyntax": {} 14 | } 15 | } 16 | } 17 | }, 18 | "text": { 19 | "text-invert": { 20 | "$type": "color", 21 | "$value": "{color.gray.900}", 22 | "$description": "", 23 | "$extensions": { 24 | "com.figma": { 25 | "hiddenFromPublishing": false, 26 | "scopes": [ 27 | "ALL_SCOPES" 28 | ], 29 | "codeSyntax": {} 30 | } 31 | } 32 | }, 33 | "text-brand": { 34 | "$type": "color", 35 | "$value": "{color.gray.900}", 36 | "$description": "", 37 | "$extensions": { 38 | "com.figma": { 39 | "hiddenFromPublishing": false, 40 | "scopes": [ 41 | "ALL_SCOPES" 42 | ], 43 | "codeSyntax": {} 44 | } 45 | } 46 | }, 47 | "text-primary": { 48 | "$type": "color", 49 | "$value": "{color.gray.50}", 50 | "$description": "", 51 | "$extensions": { 52 | "com.figma": { 53 | "hiddenFromPublishing": false, 54 | "scopes": [ 55 | "ALL_SCOPES" 56 | ], 57 | "codeSyntax": {} 58 | } 59 | } 60 | }, 61 | "text-secondary": { 62 | "$type": "color", 63 | "$value": "{color.gray.200}", 64 | "$description": "", 65 | "$extensions": { 66 | "com.figma": { 67 | "hiddenFromPublishing": false, 68 | "scopes": [ 69 | "ALL_SCOPES" 70 | ], 71 | "codeSyntax": {} 72 | } 73 | } 74 | } 75 | }, 76 | "spacing": { 77 | "spacing-none": { 78 | "$type": "number", 79 | "$value": 0, 80 | "$description": "", 81 | "$extensions": { 82 | "com.figma": { 83 | "hiddenFromPublishing": false, 84 | "scopes": [ 85 | "ALL_SCOPES" 86 | ], 87 | "codeSyntax": {} 88 | } 89 | } 90 | }, 91 | "spacing-2xl": { 92 | "$type": "number", 93 | "$value": "{spacing.13}", 94 | "$description": "", 95 | "$extensions": { 96 | "com.figma": { 97 | "hiddenFromPublishing": false, 98 | "scopes": [ 99 | "ALL_SCOPES" 100 | ], 101 | "codeSyntax": {} 102 | } 103 | } 104 | }, 105 | "spacing-lg": { 106 | "$type": "number", 107 | "$value": "{spacing.4}", 108 | "$description": "", 109 | "$extensions": { 110 | "com.figma": { 111 | "hiddenFromPublishing": false, 112 | "scopes": [ 113 | "ALL_SCOPES" 114 | ], 115 | "codeSyntax": {} 116 | } 117 | } 118 | }, 119 | "spacing-sm": { 120 | "$type": "number", 121 | "$value": "{spacing.1}", 122 | "$description": "", 123 | "$extensions": { 124 | "com.figma": { 125 | "hiddenFromPublishing": false, 126 | "scopes": [ 127 | "ALL_SCOPES" 128 | ], 129 | "codeSyntax": {} 130 | } 131 | } 132 | }, 133 | "spacing-xs": { 134 | "$type": "number", 135 | "$value": "{spacing.half}", 136 | "$description": "", 137 | "$extensions": { 138 | "com.figma": { 139 | "hiddenFromPublishing": false, 140 | "scopes": [ 141 | "ALL_SCOPES" 142 | ], 143 | "codeSyntax": {} 144 | } 145 | } 146 | }, 147 | "spacing-md": { 148 | "$type": "number", 149 | "$value": "{spacing.3}", 150 | "$description": "", 151 | "$extensions": { 152 | "com.figma": { 153 | "hiddenFromPublishing": false, 154 | "scopes": [ 155 | "ALL_SCOPES" 156 | ], 157 | "codeSyntax": {} 158 | } 159 | } 160 | }, 161 | "spacing-xl": { 162 | "$type": "number", 163 | "$value": "{spacing.6}", 164 | "$description": "", 165 | "$extensions": { 166 | "com.figma": { 167 | "hiddenFromPublishing": false, 168 | "scopes": [ 169 | "ALL_SCOPES" 170 | ], 171 | "codeSyntax": {} 172 | } 173 | } 174 | } 175 | }, 176 | "surface": { 177 | "surface-secodary": { 178 | "$type": "color", 179 | "$value": "{color.gray.400}", 180 | "$description": "", 181 | "$extensions": { 182 | "com.figma": { 183 | "hiddenFromPublishing": false, 184 | "scopes": [ 185 | "ALL_SCOPES" 186 | ], 187 | "codeSyntax": {} 188 | } 189 | } 190 | }, 191 | "surface-brand": { 192 | "$type": "color", 193 | "$value": "{color.brand.watermelon}", 194 | "$description": "", 195 | "$extensions": { 196 | "com.figma": { 197 | "hiddenFromPublishing": false, 198 | "scopes": [ 199 | "ALL_SCOPES" 200 | ], 201 | "codeSyntax": {} 202 | } 203 | } 204 | }, 205 | "surface-invert": { 206 | "$type": "color", 207 | "$value": "{color.gray.50}", 208 | "$description": "", 209 | "$extensions": { 210 | "com.figma": { 211 | "hiddenFromPublishing": false, 212 | "scopes": [ 213 | "ALL_SCOPES" 214 | ], 215 | "codeSyntax": {} 216 | } 217 | } 218 | }, 219 | "surface-primary": { 220 | "$type": "color", 221 | "$value": "{color.gray.900}", 222 | "$description": "", 223 | "$extensions": { 224 | "com.figma": { 225 | "hiddenFromPublishing": false, 226 | "scopes": [ 227 | "ALL_SCOPES" 228 | ], 229 | "codeSyntax": {} 230 | } 231 | } 232 | } 233 | }, 234 | "radius": { 235 | "radius-rounded": { 236 | "$type": "number", 237 | "$value": "{radius.md}", 238 | "$description": "", 239 | "$extensions": { 240 | "com.figma": { 241 | "hiddenFromPublishing": false, 242 | "scopes": [ 243 | "ALL_SCOPES" 244 | ], 245 | "codeSyntax": {} 246 | } 247 | } 248 | }, 249 | "radius-minimal": { 250 | "$type": "number", 251 | "$value": "{radius.sm}", 252 | "$description": "", 253 | "$extensions": { 254 | "com.figma": { 255 | "hiddenFromPublishing": false, 256 | "scopes": [ 257 | "ALL_SCOPES" 258 | ], 259 | "codeSyntax": {} 260 | } 261 | } 262 | }, 263 | "radius-full": { 264 | "$type": "number", 265 | "$value": "{radius.3xl}", 266 | "$description": "", 267 | "$extensions": { 268 | "com.figma": { 269 | "hiddenFromPublishing": false, 270 | "scopes": [ 271 | "ALL_SCOPES" 272 | ], 273 | "codeSyntax": {} 274 | } 275 | } 276 | } 277 | } 278 | } -------------------------------------------------------------------------------- /tokens/Tokens — Completed.Light.json: -------------------------------------------------------------------------------- 1 | { 2 | "border": { 3 | "border-primary": { 4 | "$type": "color", 5 | "$value": "{color.gray.900}", 6 | "$description": "", 7 | "$extensions": { 8 | "com.figma": { 9 | "hiddenFromPublishing": false, 10 | "scopes": [ 11 | "ALL_SCOPES" 12 | ], 13 | "codeSyntax": {} 14 | } 15 | } 16 | } 17 | }, 18 | "text": { 19 | "text-invert": { 20 | "$type": "color", 21 | "$value": "{color.gray.50}", 22 | "$description": "", 23 | "$extensions": { 24 | "com.figma": { 25 | "hiddenFromPublishing": false, 26 | "scopes": [ 27 | "ALL_SCOPES" 28 | ], 29 | "codeSyntax": {} 30 | } 31 | } 32 | }, 33 | "text-brand": { 34 | "$type": "color", 35 | "$value": "{color.gray.900}", 36 | "$description": "", 37 | "$extensions": { 38 | "com.figma": { 39 | "hiddenFromPublishing": false, 40 | "scopes": [ 41 | "ALL_SCOPES" 42 | ], 43 | "codeSyntax": {} 44 | } 45 | } 46 | }, 47 | "text-primary": { 48 | "$type": "color", 49 | "$value": "{color.gray.900}", 50 | "$description": "", 51 | "$extensions": { 52 | "com.figma": { 53 | "hiddenFromPublishing": false, 54 | "scopes": [ 55 | "ALL_SCOPES" 56 | ], 57 | "codeSyntax": {} 58 | } 59 | } 60 | }, 61 | "text-secondary": { 62 | "$type": "color", 63 | "$value": "{color.gray.600}", 64 | "$description": "", 65 | "$extensions": { 66 | "com.figma": { 67 | "hiddenFromPublishing": false, 68 | "scopes": [ 69 | "ALL_SCOPES" 70 | ], 71 | "codeSyntax": {} 72 | } 73 | } 74 | } 75 | }, 76 | "spacing": { 77 | "spacing-none": { 78 | "$type": "number", 79 | "$value": 0, 80 | "$description": "", 81 | "$extensions": { 82 | "com.figma": { 83 | "hiddenFromPublishing": false, 84 | "scopes": [ 85 | "ALL_SCOPES" 86 | ], 87 | "codeSyntax": {} 88 | } 89 | } 90 | }, 91 | "spacing-2xl": { 92 | "$type": "number", 93 | "$value": "{spacing.13}", 94 | "$description": "", 95 | "$extensions": { 96 | "com.figma": { 97 | "hiddenFromPublishing": false, 98 | "scopes": [ 99 | "ALL_SCOPES" 100 | ], 101 | "codeSyntax": {} 102 | } 103 | } 104 | }, 105 | "spacing-lg": { 106 | "$type": "number", 107 | "$value": "{spacing.4}", 108 | "$description": "", 109 | "$extensions": { 110 | "com.figma": { 111 | "hiddenFromPublishing": false, 112 | "scopes": [ 113 | "ALL_SCOPES" 114 | ], 115 | "codeSyntax": {} 116 | } 117 | } 118 | }, 119 | "spacing-sm": { 120 | "$type": "number", 121 | "$value": "{spacing.1}", 122 | "$description": "", 123 | "$extensions": { 124 | "com.figma": { 125 | "hiddenFromPublishing": false, 126 | "scopes": [ 127 | "ALL_SCOPES" 128 | ], 129 | "codeSyntax": {} 130 | } 131 | } 132 | }, 133 | "spacing-xs": { 134 | "$type": "number", 135 | "$value": "{spacing.half}", 136 | "$description": "", 137 | "$extensions": { 138 | "com.figma": { 139 | "hiddenFromPublishing": false, 140 | "scopes": [ 141 | "ALL_SCOPES" 142 | ], 143 | "codeSyntax": {} 144 | } 145 | } 146 | }, 147 | "spacing-md": { 148 | "$type": "number", 149 | "$value": "{spacing.3}", 150 | "$description": "", 151 | "$extensions": { 152 | "com.figma": { 153 | "hiddenFromPublishing": false, 154 | "scopes": [ 155 | "ALL_SCOPES" 156 | ], 157 | "codeSyntax": {} 158 | } 159 | } 160 | }, 161 | "spacing-xl": { 162 | "$type": "number", 163 | "$value": "{spacing.6}", 164 | "$description": "", 165 | "$extensions": { 166 | "com.figma": { 167 | "hiddenFromPublishing": false, 168 | "scopes": [ 169 | "ALL_SCOPES" 170 | ], 171 | "codeSyntax": {} 172 | } 173 | } 174 | } 175 | }, 176 | "surface": { 177 | "surface-secodary": { 178 | "$type": "color", 179 | "$value": "{color.gray.800}", 180 | "$description": "", 181 | "$extensions": { 182 | "com.figma": { 183 | "hiddenFromPublishing": false, 184 | "scopes": [ 185 | "ALL_SCOPES" 186 | ], 187 | "codeSyntax": {} 188 | } 189 | } 190 | }, 191 | "surface-brand": { 192 | "$type": "color", 193 | "$value": "{color.brand.watermelon}", 194 | "$description": "", 195 | "$extensions": { 196 | "com.figma": { 197 | "hiddenFromPublishing": false, 198 | "scopes": [ 199 | "ALL_SCOPES" 200 | ], 201 | "codeSyntax": {} 202 | } 203 | } 204 | }, 205 | "surface-invert": { 206 | "$type": "color", 207 | "$value": "{color.gray.900}", 208 | "$description": "", 209 | "$extensions": { 210 | "com.figma": { 211 | "hiddenFromPublishing": false, 212 | "scopes": [ 213 | "ALL_SCOPES" 214 | ], 215 | "codeSyntax": {} 216 | } 217 | } 218 | }, 219 | "surface-primary": { 220 | "$type": "color", 221 | "$value": "{color.gray.50}", 222 | "$description": "", 223 | "$extensions": { 224 | "com.figma": { 225 | "hiddenFromPublishing": false, 226 | "scopes": [ 227 | "ALL_SCOPES" 228 | ], 229 | "codeSyntax": {} 230 | } 231 | } 232 | } 233 | }, 234 | "radius": { 235 | "radius-rounded": { 236 | "$type": "number", 237 | "$value": "{radius.md}", 238 | "$description": "", 239 | "$extensions": { 240 | "com.figma": { 241 | "hiddenFromPublishing": false, 242 | "scopes": [ 243 | "ALL_SCOPES" 244 | ], 245 | "codeSyntax": {} 246 | } 247 | } 248 | }, 249 | "radius-minimal": { 250 | "$type": "number", 251 | "$value": "{radius.sm}", 252 | "$description": "", 253 | "$extensions": { 254 | "com.figma": { 255 | "hiddenFromPublishing": false, 256 | "scopes": [ 257 | "ALL_SCOPES" 258 | ], 259 | "codeSyntax": {} 260 | } 261 | } 262 | }, 263 | "radius-full": { 264 | "$type": "number", 265 | "$value": "{radius.3xl}", 266 | "$description": "", 267 | "$extensions": { 268 | "com.figma": { 269 | "hiddenFromPublishing": false, 270 | "scopes": [ 271 | "ALL_SCOPES" 272 | ], 273 | "codeSyntax": {} 274 | } 275 | } 276 | } 277 | } 278 | } -------------------------------------------------------------------------------- /src/token_export.test.ts: -------------------------------------------------------------------------------- 1 | import { GetLocalVariablesResponse } from '@figma/rest-api-spec' 2 | import { tokenFilesFromLocalVariables } from './token_export.js' 3 | 4 | describe('tokenFilesFromLocalVariables', () => { 5 | it('ignores remote variables', () => { 6 | const localVariablesResponse: GetLocalVariablesResponse = { 7 | status: 200, 8 | error: false, 9 | meta: { 10 | variableCollections: { 11 | 'VariableCollectionId:1:1': { 12 | id: 'VariableCollectionId:1:1', 13 | name: 'primitives', 14 | modes: [{ modeId: '1:0', name: 'mode1' }], 15 | defaultModeId: '1:0', 16 | remote: true, 17 | key: 'variableKey', 18 | hiddenFromPublishing: false, 19 | variableIds: ['VariableID:2:1'], 20 | }, 21 | }, 22 | variables: { 23 | 'VariableID:2:1': { 24 | id: 'VariableID:2:1', 25 | name: 'spacing/1', 26 | key: 'variable_key', 27 | variableCollectionId: 'VariableCollectionId:1:1', 28 | resolvedType: 'FLOAT', 29 | valuesByMode: { 30 | '1:0': 8, 31 | }, 32 | remote: true, 33 | description: '', 34 | hiddenFromPublishing: false, 35 | scopes: ['ALL_SCOPES'], 36 | codeSyntax: {}, 37 | }, 38 | }, 39 | }, 40 | } 41 | 42 | const tokenFiles = tokenFilesFromLocalVariables(localVariablesResponse) 43 | expect(tokenFiles).toEqual({}) 44 | }) 45 | 46 | it('returns token files', () => { 47 | const localVariablesResponse: GetLocalVariablesResponse = { 48 | status: 200, 49 | error: false, 50 | meta: { 51 | variableCollections: { 52 | 'VariableCollectionId:1:1': { 53 | id: 'VariableCollectionId:1:1', 54 | name: 'primitives', 55 | modes: [ 56 | { modeId: '1:0', name: 'mode1' }, 57 | { modeId: '1:1', name: 'mode2' }, 58 | ], 59 | defaultModeId: '1:0', 60 | remote: false, 61 | key: 'variableKey', 62 | hiddenFromPublishing: false, 63 | variableIds: ['VariableID:2:1', 'VariableID:2:2', 'VariableID:2:3', 'VariableID:2:4'], 64 | }, 65 | }, 66 | variables: { 67 | 'VariableID:2:1': { 68 | id: 'VariableID:2:1', 69 | name: 'spacing/1', 70 | key: 'variable_key', 71 | variableCollectionId: 'VariableCollectionId:1:1', 72 | resolvedType: 'FLOAT', 73 | valuesByMode: { 74 | '1:0': 8, 75 | '1:1': 8, 76 | }, 77 | remote: false, 78 | description: '8px spacing', 79 | hiddenFromPublishing: true, 80 | scopes: ['TEXT_CONTENT'], 81 | codeSyntax: { WEB: 'web', ANDROID: 'android' }, 82 | }, 83 | 'VariableID:2:2': { 84 | id: 'VariableID:2:2', 85 | name: 'spacing/2', 86 | key: 'variable_key2', 87 | variableCollectionId: 'VariableCollectionId:1:1', 88 | resolvedType: 'FLOAT', 89 | valuesByMode: { 90 | '1:0': 16, 91 | '1:1': 16, 92 | }, 93 | remote: false, 94 | description: '16px spacing', 95 | hiddenFromPublishing: false, 96 | scopes: ['ALL_SCOPES'], 97 | codeSyntax: {}, 98 | }, 99 | 'VariableID:2:3': { 100 | id: 'VariableID:2:3', 101 | name: 'color/brand/radish', 102 | key: 'variable_key3', 103 | variableCollectionId: 'VariableCollectionId:1:1', 104 | resolvedType: 'COLOR', 105 | valuesByMode: { 106 | '1:0': { r: 1, g: 0.7450980392156863, b: 0.08627450980392157, a: 1 }, 107 | '1:1': { r: 1, g: 0.796078431372549, b: 0.7176470588235294, a: 1 }, 108 | }, 109 | remote: false, 110 | description: 'Radish color', 111 | hiddenFromPublishing: false, 112 | scopes: ['ALL_SCOPES'], 113 | codeSyntax: {}, 114 | }, 115 | 'VariableID:2:4': { 116 | id: 'VariableID:2:4', 117 | name: 'color/brand/pear', 118 | key: 'variable_key4', 119 | variableCollectionId: 'VariableCollectionId:1:1', 120 | resolvedType: 'COLOR', 121 | valuesByMode: { 122 | '1:0': { r: 1, g: 0, b: 0.08627450980392157, a: 1 }, 123 | '1:1': { r: 0.8705882352941177, g: 0.9529411764705882, b: 0.34509803921568627, a: 1 }, 124 | }, 125 | remote: false, 126 | description: 'Pear color', 127 | hiddenFromPublishing: false, 128 | scopes: ['ALL_SCOPES'], 129 | codeSyntax: {}, 130 | }, 131 | }, 132 | }, 133 | } 134 | 135 | const tokenFiles = tokenFilesFromLocalVariables(localVariablesResponse) 136 | 137 | expect(tokenFiles['primitives.mode1.json']).toEqual({ 138 | spacing: { 139 | '1': { 140 | $type: 'number', 141 | $value: 8, 142 | $description: '8px spacing', 143 | $extensions: { 144 | 'com.figma': { 145 | hiddenFromPublishing: true, 146 | scopes: ['TEXT_CONTENT'], 147 | codeSyntax: { WEB: 'web', ANDROID: 'android' }, 148 | }, 149 | }, 150 | }, 151 | '2': { 152 | $type: 'number', 153 | $value: 16, 154 | $description: '16px spacing', 155 | $extensions: { 156 | 'com.figma': { 157 | hiddenFromPublishing: false, 158 | scopes: ['ALL_SCOPES'], 159 | codeSyntax: {}, 160 | }, 161 | }, 162 | }, 163 | }, 164 | color: { 165 | brand: { 166 | radish: { 167 | $type: 'color', 168 | $value: '#ffbe16', 169 | $description: 'Radish color', 170 | $extensions: { 171 | 'com.figma': { 172 | hiddenFromPublishing: false, 173 | scopes: ['ALL_SCOPES'], 174 | codeSyntax: {}, 175 | }, 176 | }, 177 | }, 178 | pear: { 179 | $type: 'color', 180 | $value: '#ff0016', 181 | $description: 'Pear color', 182 | $extensions: { 183 | 'com.figma': { 184 | hiddenFromPublishing: false, 185 | scopes: ['ALL_SCOPES'], 186 | codeSyntax: {}, 187 | }, 188 | }, 189 | }, 190 | }, 191 | }, 192 | }) 193 | 194 | expect(tokenFiles['primitives.mode2.json']).toEqual({ 195 | spacing: { 196 | '1': { 197 | $type: 'number', 198 | $value: 8, 199 | $description: '8px spacing', 200 | $extensions: { 201 | 'com.figma': { 202 | hiddenFromPublishing: true, 203 | scopes: ['TEXT_CONTENT'], 204 | codeSyntax: { WEB: 'web', ANDROID: 'android' }, 205 | }, 206 | }, 207 | }, 208 | '2': { 209 | $type: 'number', 210 | $value: 16, 211 | $description: '16px spacing', 212 | $extensions: { 213 | 'com.figma': { 214 | hiddenFromPublishing: false, 215 | scopes: ['ALL_SCOPES'], 216 | codeSyntax: {}, 217 | }, 218 | }, 219 | }, 220 | }, 221 | color: { 222 | brand: { 223 | radish: { 224 | $type: 'color', 225 | $value: '#ffcbb7', 226 | $description: 'Radish color', 227 | $extensions: { 228 | 'com.figma': { 229 | hiddenFromPublishing: false, 230 | scopes: ['ALL_SCOPES'], 231 | codeSyntax: {}, 232 | }, 233 | }, 234 | }, 235 | pear: { 236 | $type: 'color', 237 | $value: '#def358', 238 | $description: 'Pear color', 239 | $extensions: { 240 | 'com.figma': { 241 | hiddenFromPublishing: false, 242 | scopes: ['ALL_SCOPES'], 243 | codeSyntax: {}, 244 | }, 245 | }, 246 | }, 247 | }, 248 | }, 249 | }) 250 | }) 251 | 252 | it('handles aliases', () => { 253 | const localVariablesResponse: GetLocalVariablesResponse = { 254 | status: 200, 255 | error: false, 256 | meta: { 257 | variableCollections: { 258 | 'VariableCollectionId:1:1': { 259 | id: 'VariableCollectionId:1:1', 260 | name: 'collection1', 261 | modes: [ 262 | { modeId: '1:0', name: 'mode1' }, 263 | { modeId: '1:1', name: 'mode2' }, 264 | ], 265 | defaultModeId: '1:0', 266 | remote: false, 267 | key: 'variableKey', 268 | hiddenFromPublishing: false, 269 | variableIds: ['VariableID:2:1', 'VariableID:2:2'], 270 | }, 271 | }, 272 | variables: { 273 | 'VariableID:2:1': { 274 | id: 'VariableID:2:1', 275 | name: 'var1', 276 | key: 'variable_key1', 277 | variableCollectionId: 'VariableCollectionId:1:1', 278 | resolvedType: 'FLOAT', 279 | valuesByMode: { 280 | '1:0': 1, 281 | }, 282 | remote: false, 283 | description: 'var1 description', 284 | hiddenFromPublishing: false, 285 | scopes: ['ALL_SCOPES'], 286 | codeSyntax: {}, 287 | }, 288 | 'VariableID:2:2': { 289 | id: 'VariableID:2:2', 290 | name: 'var2', 291 | key: 'variable_key2', 292 | variableCollectionId: 'VariableCollectionId:1:1', 293 | resolvedType: 'FLOAT', 294 | valuesByMode: { 295 | '1:0': { type: 'VARIABLE_ALIAS', id: 'VariableID:2:1' }, 296 | }, 297 | remote: false, 298 | description: 'var2 description', 299 | hiddenFromPublishing: false, 300 | scopes: ['ALL_SCOPES'], 301 | codeSyntax: {}, 302 | }, 303 | }, 304 | }, 305 | } 306 | 307 | const tokenFiles = tokenFilesFromLocalVariables(localVariablesResponse) 308 | 309 | expect(tokenFiles['collection1.mode1.json']).toEqual({ 310 | var1: { 311 | $type: 'number', 312 | $value: 1, 313 | $description: 'var1 description', 314 | $extensions: { 315 | 'com.figma': { 316 | hiddenFromPublishing: false, 317 | scopes: ['ALL_SCOPES'], 318 | codeSyntax: {}, 319 | }, 320 | }, 321 | }, 322 | var2: { 323 | $type: 'number', 324 | $value: '{var1}', 325 | $description: 'var2 description', 326 | $extensions: { 327 | 'com.figma': { 328 | hiddenFromPublishing: false, 329 | scopes: ['ALL_SCOPES'], 330 | codeSyntax: {}, 331 | }, 332 | }, 333 | }, 334 | }) 335 | }) 336 | }) 337 | -------------------------------------------------------------------------------- /tokens/Primitives — Completed.Brutal Theme.json: -------------------------------------------------------------------------------- 1 | { 2 | "spacing": { 3 | "1": { 4 | "$type": "number", 5 | "$value": 8, 6 | "$description": "", 7 | "$extensions": { 8 | "com.figma": { 9 | "hiddenFromPublishing": false, 10 | "scopes": [ 11 | "ALL_SCOPES" 12 | ], 13 | "codeSyntax": {} 14 | } 15 | } 16 | }, 17 | "2": { 18 | "$type": "number", 19 | "$value": 16, 20 | "$description": "", 21 | "$extensions": { 22 | "com.figma": { 23 | "hiddenFromPublishing": false, 24 | "scopes": [ 25 | "ALL_SCOPES" 26 | ], 27 | "codeSyntax": {} 28 | } 29 | } 30 | }, 31 | "3": { 32 | "$type": "number", 33 | "$value": 24, 34 | "$description": "", 35 | "$extensions": { 36 | "com.figma": { 37 | "hiddenFromPublishing": false, 38 | "scopes": [ 39 | "ALL_SCOPES" 40 | ], 41 | "codeSyntax": {} 42 | } 43 | } 44 | }, 45 | "4": { 46 | "$type": "number", 47 | "$value": 32, 48 | "$description": "", 49 | "$extensions": { 50 | "com.figma": { 51 | "hiddenFromPublishing": false, 52 | "scopes": [ 53 | "ALL_SCOPES" 54 | ], 55 | "codeSyntax": {} 56 | } 57 | } 58 | }, 59 | "5": { 60 | "$type": "number", 61 | "$value": 40, 62 | "$description": "", 63 | "$extensions": { 64 | "com.figma": { 65 | "hiddenFromPublishing": false, 66 | "scopes": [ 67 | "ALL_SCOPES" 68 | ], 69 | "codeSyntax": {} 70 | } 71 | } 72 | }, 73 | "6": { 74 | "$type": "number", 75 | "$value": 48, 76 | "$description": "", 77 | "$extensions": { 78 | "com.figma": { 79 | "hiddenFromPublishing": false, 80 | "scopes": [ 81 | "ALL_SCOPES" 82 | ], 83 | "codeSyntax": {} 84 | } 85 | } 86 | }, 87 | "7": { 88 | "$type": "number", 89 | "$value": 56, 90 | "$description": "", 91 | "$extensions": { 92 | "com.figma": { 93 | "hiddenFromPublishing": false, 94 | "scopes": [ 95 | "ALL_SCOPES" 96 | ], 97 | "codeSyntax": {} 98 | } 99 | } 100 | }, 101 | "8": { 102 | "$type": "number", 103 | "$value": 64, 104 | "$description": "", 105 | "$extensions": { 106 | "com.figma": { 107 | "hiddenFromPublishing": false, 108 | "scopes": [ 109 | "ALL_SCOPES" 110 | ], 111 | "codeSyntax": {} 112 | } 113 | } 114 | }, 115 | "9": { 116 | "$type": "number", 117 | "$value": 72, 118 | "$description": "", 119 | "$extensions": { 120 | "com.figma": { 121 | "hiddenFromPublishing": false, 122 | "scopes": [ 123 | "ALL_SCOPES" 124 | ], 125 | "codeSyntax": {} 126 | } 127 | } 128 | }, 129 | "10": { 130 | "$type": "number", 131 | "$value": 80, 132 | "$description": "", 133 | "$extensions": { 134 | "com.figma": { 135 | "hiddenFromPublishing": false, 136 | "scopes": [ 137 | "ALL_SCOPES" 138 | ], 139 | "codeSyntax": {} 140 | } 141 | } 142 | }, 143 | "11": { 144 | "$type": "number", 145 | "$value": 88, 146 | "$description": "", 147 | "$extensions": { 148 | "com.figma": { 149 | "hiddenFromPublishing": false, 150 | "scopes": [ 151 | "ALL_SCOPES" 152 | ], 153 | "codeSyntax": {} 154 | } 155 | } 156 | }, 157 | "12": { 158 | "$type": "number", 159 | "$value": 96, 160 | "$description": "", 161 | "$extensions": { 162 | "com.figma": { 163 | "hiddenFromPublishing": false, 164 | "scopes": [ 165 | "ALL_SCOPES" 166 | ], 167 | "codeSyntax": {} 168 | } 169 | } 170 | }, 171 | "13": { 172 | "$type": "number", 173 | "$value": 104, 174 | "$description": "", 175 | "$extensions": { 176 | "com.figma": { 177 | "hiddenFromPublishing": false, 178 | "scopes": [ 179 | "ALL_SCOPES" 180 | ], 181 | "codeSyntax": {} 182 | } 183 | } 184 | }, 185 | "half": { 186 | "$type": "number", 187 | "$value": 4, 188 | "$description": "", 189 | "$extensions": { 190 | "com.figma": { 191 | "hiddenFromPublishing": true, 192 | "scopes": [ 193 | "ALL_SCOPES" 194 | ], 195 | "codeSyntax": {} 196 | } 197 | } 198 | } 199 | }, 200 | "radius": { 201 | "3xl": { 202 | "$type": "number", 203 | "$value": 0, 204 | "$description": "", 205 | "$extensions": { 206 | "com.figma": { 207 | "hiddenFromPublishing": false, 208 | "scopes": [ 209 | "ALL_SCOPES" 210 | ], 211 | "codeSyntax": {} 212 | } 213 | } 214 | }, 215 | "2xl": { 216 | "$type": "number", 217 | "$value": 0, 218 | "$description": "", 219 | "$extensions": { 220 | "com.figma": { 221 | "hiddenFromPublishing": false, 222 | "scopes": [ 223 | "ALL_SCOPES" 224 | ], 225 | "codeSyntax": {} 226 | } 227 | } 228 | }, 229 | "lg": { 230 | "$type": "number", 231 | "$value": 0, 232 | "$description": "", 233 | "$extensions": { 234 | "com.figma": { 235 | "hiddenFromPublishing": false, 236 | "scopes": [ 237 | "ALL_SCOPES" 238 | ], 239 | "codeSyntax": {} 240 | } 241 | } 242 | }, 243 | "xl": { 244 | "$type": "number", 245 | "$value": 0, 246 | "$description": "", 247 | "$extensions": { 248 | "com.figma": { 249 | "hiddenFromPublishing": false, 250 | "scopes": [ 251 | "ALL_SCOPES" 252 | ], 253 | "codeSyntax": {} 254 | } 255 | } 256 | }, 257 | "md": { 258 | "$type": "number", 259 | "$value": 0, 260 | "$description": "", 261 | "$extensions": { 262 | "com.figma": { 263 | "hiddenFromPublishing": false, 264 | "scopes": [ 265 | "ALL_SCOPES" 266 | ], 267 | "codeSyntax": {} 268 | } 269 | } 270 | }, 271 | "sm": { 272 | "$type": "number", 273 | "$value": 0, 274 | "$description": "", 275 | "$extensions": { 276 | "com.figma": { 277 | "hiddenFromPublishing": false, 278 | "scopes": [ 279 | "ALL_SCOPES" 280 | ], 281 | "codeSyntax": {} 282 | } 283 | } 284 | } 285 | }, 286 | "color": { 287 | "brand": { 288 | "radish": { 289 | "$type": "color", 290 | "$value": "#ffbe16", 291 | "$description": "", 292 | "$extensions": { 293 | "com.figma": { 294 | "hiddenFromPublishing": false, 295 | "scopes": [ 296 | "ALL_SCOPES" 297 | ], 298 | "codeSyntax": {} 299 | } 300 | } 301 | }, 302 | "pear": { 303 | "$type": "color", 304 | "$value": "#ffbe16", 305 | "$description": "", 306 | "$extensions": { 307 | "com.figma": { 308 | "hiddenFromPublishing": false, 309 | "scopes": [ 310 | "ALL_SCOPES" 311 | ], 312 | "codeSyntax": {} 313 | } 314 | } 315 | }, 316 | "watermelon": { 317 | "$type": "color", 318 | "$value": "#ffbe16", 319 | "$description": "", 320 | "$extensions": { 321 | "com.figma": { 322 | "hiddenFromPublishing": false, 323 | "scopes": [ 324 | "ALL_SCOPES" 325 | ], 326 | "codeSyntax": {} 327 | } 328 | } 329 | }, 330 | "mushroom": { 331 | "$type": "color", 332 | "$value": "#ffbe16", 333 | "$description": "", 334 | "$extensions": { 335 | "com.figma": { 336 | "hiddenFromPublishing": false, 337 | "scopes": [ 338 | "ALL_SCOPES" 339 | ], 340 | "codeSyntax": {} 341 | } 342 | } 343 | }, 344 | "cherry": { 345 | "$type": "color", 346 | "$value": "#ffbe16", 347 | "$description": "", 348 | "$extensions": { 349 | "com.figma": { 350 | "hiddenFromPublishing": false, 351 | "scopes": [ 352 | "ALL_SCOPES" 353 | ], 354 | "codeSyntax": {} 355 | } 356 | } 357 | } 358 | }, 359 | "gray": { 360 | "50": { 361 | "$type": "color", 362 | "$value": "#eedeff", 363 | "$description": "", 364 | "$extensions": { 365 | "com.figma": { 366 | "hiddenFromPublishing": false, 367 | "scopes": [ 368 | "ALL_SCOPES" 369 | ], 370 | "codeSyntax": {} 371 | } 372 | } 373 | }, 374 | "200": { 375 | "$type": "color", 376 | "$value": "#cfa0ff", 377 | "$description": "", 378 | "$extensions": { 379 | "com.figma": { 380 | "hiddenFromPublishing": false, 381 | "scopes": [ 382 | "ALL_SCOPES" 383 | ], 384 | "codeSyntax": {} 385 | } 386 | } 387 | }, 388 | "400": { 389 | "$type": "color", 390 | "$value": "#b874ff", 391 | "$description": "", 392 | "$extensions": { 393 | "com.figma": { 394 | "hiddenFromPublishing": false, 395 | "scopes": [ 396 | "ALL_SCOPES" 397 | ], 398 | "codeSyntax": {} 399 | } 400 | } 401 | }, 402 | "600": { 403 | "$type": "color", 404 | "$value": "#bc7cff", 405 | "$description": "", 406 | "$extensions": { 407 | "com.figma": { 408 | "hiddenFromPublishing": false, 409 | "scopes": [ 410 | "ALL_SCOPES" 411 | ], 412 | "codeSyntax": {} 413 | } 414 | } 415 | }, 416 | "800": { 417 | "$type": "color", 418 | "$value": "#a854ff", 419 | "$description": "", 420 | "$extensions": { 421 | "com.figma": { 422 | "hiddenFromPublishing": false, 423 | "scopes": [ 424 | "ALL_SCOPES" 425 | ], 426 | "codeSyntax": {} 427 | } 428 | } 429 | }, 430 | "900": { 431 | "$type": "color", 432 | "$value": "#33057e", 433 | "$description": "", 434 | "$extensions": { 435 | "com.figma": { 436 | "hiddenFromPublishing": false, 437 | "scopes": [ 438 | "ALL_SCOPES" 439 | ], 440 | "codeSyntax": {} 441 | } 442 | } 443 | } 444 | } 445 | } 446 | } -------------------------------------------------------------------------------- /tokens/Primitives — Completed.Modern Theme.json: -------------------------------------------------------------------------------- 1 | { 2 | "spacing": { 3 | "1": { 4 | "$type": "number", 5 | "$value": 8, 6 | "$description": "", 7 | "$extensions": { 8 | "com.figma": { 9 | "hiddenFromPublishing": false, 10 | "scopes": [ 11 | "ALL_SCOPES" 12 | ], 13 | "codeSyntax": {} 14 | } 15 | } 16 | }, 17 | "2": { 18 | "$type": "number", 19 | "$value": 16, 20 | "$description": "", 21 | "$extensions": { 22 | "com.figma": { 23 | "hiddenFromPublishing": false, 24 | "scopes": [ 25 | "ALL_SCOPES" 26 | ], 27 | "codeSyntax": {} 28 | } 29 | } 30 | }, 31 | "3": { 32 | "$type": "number", 33 | "$value": 24, 34 | "$description": "", 35 | "$extensions": { 36 | "com.figma": { 37 | "hiddenFromPublishing": false, 38 | "scopes": [ 39 | "ALL_SCOPES" 40 | ], 41 | "codeSyntax": {} 42 | } 43 | } 44 | }, 45 | "4": { 46 | "$type": "number", 47 | "$value": 32, 48 | "$description": "", 49 | "$extensions": { 50 | "com.figma": { 51 | "hiddenFromPublishing": false, 52 | "scopes": [ 53 | "ALL_SCOPES" 54 | ], 55 | "codeSyntax": {} 56 | } 57 | } 58 | }, 59 | "5": { 60 | "$type": "number", 61 | "$value": 40, 62 | "$description": "", 63 | "$extensions": { 64 | "com.figma": { 65 | "hiddenFromPublishing": false, 66 | "scopes": [ 67 | "ALL_SCOPES" 68 | ], 69 | "codeSyntax": {} 70 | } 71 | } 72 | }, 73 | "6": { 74 | "$type": "number", 75 | "$value": 48, 76 | "$description": "", 77 | "$extensions": { 78 | "com.figma": { 79 | "hiddenFromPublishing": false, 80 | "scopes": [ 81 | "ALL_SCOPES" 82 | ], 83 | "codeSyntax": {} 84 | } 85 | } 86 | }, 87 | "7": { 88 | "$type": "number", 89 | "$value": 56, 90 | "$description": "", 91 | "$extensions": { 92 | "com.figma": { 93 | "hiddenFromPublishing": false, 94 | "scopes": [ 95 | "ALL_SCOPES" 96 | ], 97 | "codeSyntax": {} 98 | } 99 | } 100 | }, 101 | "8": { 102 | "$type": "number", 103 | "$value": 64, 104 | "$description": "", 105 | "$extensions": { 106 | "com.figma": { 107 | "hiddenFromPublishing": false, 108 | "scopes": [ 109 | "ALL_SCOPES" 110 | ], 111 | "codeSyntax": {} 112 | } 113 | } 114 | }, 115 | "9": { 116 | "$type": "number", 117 | "$value": 72, 118 | "$description": "", 119 | "$extensions": { 120 | "com.figma": { 121 | "hiddenFromPublishing": false, 122 | "scopes": [ 123 | "ALL_SCOPES" 124 | ], 125 | "codeSyntax": {} 126 | } 127 | } 128 | }, 129 | "10": { 130 | "$type": "number", 131 | "$value": 80, 132 | "$description": "", 133 | "$extensions": { 134 | "com.figma": { 135 | "hiddenFromPublishing": false, 136 | "scopes": [ 137 | "ALL_SCOPES" 138 | ], 139 | "codeSyntax": {} 140 | } 141 | } 142 | }, 143 | "11": { 144 | "$type": "number", 145 | "$value": 88, 146 | "$description": "", 147 | "$extensions": { 148 | "com.figma": { 149 | "hiddenFromPublishing": false, 150 | "scopes": [ 151 | "ALL_SCOPES" 152 | ], 153 | "codeSyntax": {} 154 | } 155 | } 156 | }, 157 | "12": { 158 | "$type": "number", 159 | "$value": 96, 160 | "$description": "", 161 | "$extensions": { 162 | "com.figma": { 163 | "hiddenFromPublishing": false, 164 | "scopes": [ 165 | "ALL_SCOPES" 166 | ], 167 | "codeSyntax": {} 168 | } 169 | } 170 | }, 171 | "13": { 172 | "$type": "number", 173 | "$value": 104, 174 | "$description": "", 175 | "$extensions": { 176 | "com.figma": { 177 | "hiddenFromPublishing": false, 178 | "scopes": [ 179 | "ALL_SCOPES" 180 | ], 181 | "codeSyntax": {} 182 | } 183 | } 184 | }, 185 | "half": { 186 | "$type": "number", 187 | "$value": 4, 188 | "$description": "", 189 | "$extensions": { 190 | "com.figma": { 191 | "hiddenFromPublishing": true, 192 | "scopes": [ 193 | "ALL_SCOPES" 194 | ], 195 | "codeSyntax": {} 196 | } 197 | } 198 | } 199 | }, 200 | "radius": { 201 | "3xl": { 202 | "$type": "number", 203 | "$value": 360, 204 | "$description": "", 205 | "$extensions": { 206 | "com.figma": { 207 | "hiddenFromPublishing": false, 208 | "scopes": [ 209 | "ALL_SCOPES" 210 | ], 211 | "codeSyntax": {} 212 | } 213 | } 214 | }, 215 | "2xl": { 216 | "$type": "number", 217 | "$value": 128, 218 | "$description": "", 219 | "$extensions": { 220 | "com.figma": { 221 | "hiddenFromPublishing": false, 222 | "scopes": [ 223 | "ALL_SCOPES" 224 | ], 225 | "codeSyntax": {} 226 | } 227 | } 228 | }, 229 | "lg": { 230 | "$type": "number", 231 | "$value": 16, 232 | "$description": "", 233 | "$extensions": { 234 | "com.figma": { 235 | "hiddenFromPublishing": false, 236 | "scopes": [ 237 | "ALL_SCOPES" 238 | ], 239 | "codeSyntax": {} 240 | } 241 | } 242 | }, 243 | "xl": { 244 | "$type": "number", 245 | "$value": 32, 246 | "$description": "", 247 | "$extensions": { 248 | "com.figma": { 249 | "hiddenFromPublishing": false, 250 | "scopes": [ 251 | "ALL_SCOPES" 252 | ], 253 | "codeSyntax": {} 254 | } 255 | } 256 | }, 257 | "md": { 258 | "$type": "number", 259 | "$value": 8, 260 | "$description": "", 261 | "$extensions": { 262 | "com.figma": { 263 | "hiddenFromPublishing": false, 264 | "scopes": [ 265 | "ALL_SCOPES" 266 | ], 267 | "codeSyntax": {} 268 | } 269 | } 270 | }, 271 | "sm": { 272 | "$type": "number", 273 | "$value": 4, 274 | "$description": "", 275 | "$extensions": { 276 | "com.figma": { 277 | "hiddenFromPublishing": false, 278 | "scopes": [ 279 | "ALL_SCOPES" 280 | ], 281 | "codeSyntax": {} 282 | } 283 | } 284 | } 285 | }, 286 | "color": { 287 | "brand": { 288 | "radish": { 289 | "$type": "color", 290 | "$value": "#ffcbb7", 291 | "$description": "", 292 | "$extensions": { 293 | "com.figma": { 294 | "hiddenFromPublishing": false, 295 | "scopes": [ 296 | "ALL_SCOPES" 297 | ], 298 | "codeSyntax": {} 299 | } 300 | } 301 | }, 302 | "pear": { 303 | "$type": "color", 304 | "$value": "#def358", 305 | "$description": "", 306 | "$extensions": { 307 | "com.figma": { 308 | "hiddenFromPublishing": false, 309 | "scopes": [ 310 | "ALL_SCOPES" 311 | ], 312 | "codeSyntax": {} 313 | } 314 | } 315 | }, 316 | "watermelon": { 317 | "$type": "color", 318 | "$value": "#7db8a9", 319 | "$description": "", 320 | "$extensions": { 321 | "com.figma": { 322 | "hiddenFromPublishing": false, 323 | "scopes": [ 324 | "ALL_SCOPES" 325 | ], 326 | "codeSyntax": {} 327 | } 328 | } 329 | }, 330 | "mushroom": { 331 | "$type": "color", 332 | "$value": "#f1d367", 333 | "$description": "", 334 | "$extensions": { 335 | "com.figma": { 336 | "hiddenFromPublishing": false, 337 | "scopes": [ 338 | "ALL_SCOPES" 339 | ], 340 | "codeSyntax": {} 341 | } 342 | } 343 | }, 344 | "cherry": { 345 | "$type": "color", 346 | "$value": "#ff9eab", 347 | "$description": "", 348 | "$extensions": { 349 | "com.figma": { 350 | "hiddenFromPublishing": false, 351 | "scopes": [ 352 | "ALL_SCOPES" 353 | ], 354 | "codeSyntax": {} 355 | } 356 | } 357 | } 358 | }, 359 | "gray": { 360 | "50": { 361 | "$type": "color", 362 | "$value": "#f1f1f1", 363 | "$description": "", 364 | "$extensions": { 365 | "com.figma": { 366 | "hiddenFromPublishing": false, 367 | "scopes": [ 368 | "ALL_SCOPES" 369 | ], 370 | "codeSyntax": {} 371 | } 372 | } 373 | }, 374 | "200": { 375 | "$type": "color", 376 | "$value": "#d3d3d3", 377 | "$description": "", 378 | "$extensions": { 379 | "com.figma": { 380 | "hiddenFromPublishing": false, 381 | "scopes": [ 382 | "ALL_SCOPES" 383 | ], 384 | "codeSyntax": {} 385 | } 386 | } 387 | }, 388 | "400": { 389 | "$type": "color", 390 | "$value": "#afafaf", 391 | "$description": "", 392 | "$extensions": { 393 | "com.figma": { 394 | "hiddenFromPublishing": false, 395 | "scopes": [ 396 | "ALL_SCOPES" 397 | ], 398 | "codeSyntax": {} 399 | } 400 | } 401 | }, 402 | "600": { 403 | "$type": "color", 404 | "$value": "#7c7c7c", 405 | "$description": "", 406 | "$extensions": { 407 | "com.figma": { 408 | "hiddenFromPublishing": false, 409 | "scopes": [ 410 | "ALL_SCOPES" 411 | ], 412 | "codeSyntax": {} 413 | } 414 | } 415 | }, 416 | "800": { 417 | "$type": "color", 418 | "$value": "#343434", 419 | "$description": "", 420 | "$extensions": { 421 | "com.figma": { 422 | "hiddenFromPublishing": false, 423 | "scopes": [ 424 | "ALL_SCOPES" 425 | ], 426 | "codeSyntax": {} 427 | } 428 | } 429 | }, 430 | "900": { 431 | "$type": "color", 432 | "$value": "#202020", 433 | "$description": "", 434 | "$extensions": { 435 | "com.figma": { 436 | "hiddenFromPublishing": false, 437 | "scopes": [ 438 | "ALL_SCOPES" 439 | ], 440 | "codeSyntax": {} 441 | } 442 | } 443 | } 444 | } 445 | } 446 | } -------------------------------------------------------------------------------- /src/token_import.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as path from 'path' 3 | 4 | import { colorApproximatelyEqual, parseColor } from './color.js' 5 | import { areSetsEqual } from './utils.js' 6 | import { Token, TokenOrTokenGroup, TokensFile } from './token_types.js' 7 | import { 8 | GetLocalVariablesResponse, 9 | LocalVariable, 10 | LocalVariableCollection, 11 | PostVariablesRequestBody, 12 | VariableCodeSyntax, 13 | VariableCreate, 14 | VariableUpdate, 15 | VariableValue, 16 | } from '@figma/rest-api-spec' 17 | 18 | export type FlattenedTokensByFile = { 19 | [fileName: string]: { 20 | [tokenName: string]: Token 21 | } 22 | } 23 | 24 | export function readJsonFiles(files: string[]) { 25 | const tokensJsonByFile: FlattenedTokensByFile = {} 26 | 27 | const seenCollectionsAndModes = new Set() 28 | 29 | files.forEach((file) => { 30 | const baseFileName = path.basename(file) 31 | const { collectionName, modeName } = collectionAndModeFromFileName(baseFileName) 32 | 33 | if (seenCollectionsAndModes.has(`${collectionName}.${modeName}`)) { 34 | throw new Error(`Duplicate collection and mode in file: ${file}`) 35 | } 36 | 37 | seenCollectionsAndModes.add(`${collectionName}.${modeName}`) 38 | 39 | const fileContent = fs.readFileSync(file, { encoding: 'utf-8' }) 40 | 41 | if (!fileContent) { 42 | throw new Error(`Invalid tokens file: ${file}. File is empty.`) 43 | } 44 | const tokensFile: TokensFile = JSON.parse(fileContent) 45 | 46 | tokensJsonByFile[baseFileName] = flattenTokensFile(tokensFile) 47 | }) 48 | 49 | return tokensJsonByFile 50 | } 51 | 52 | function flattenTokensFile(tokensFile: TokensFile) { 53 | const flattenedTokens: { [tokenName: string]: Token } = {} 54 | 55 | Object.entries(tokensFile).forEach(([tokenGroup, groupValues]) => { 56 | traverseCollection({ key: tokenGroup, object: groupValues, tokens: flattenedTokens }) 57 | }) 58 | 59 | return flattenedTokens 60 | } 61 | 62 | function traverseCollection({ 63 | key, 64 | object, 65 | tokens, 66 | }: { 67 | key: string 68 | object: TokenOrTokenGroup 69 | tokens: { [tokenName: string]: Token } 70 | }) { 71 | // if key is a meta field, move on 72 | if (key.charAt(0) === '$') { 73 | return 74 | } 75 | 76 | if (object.$value !== undefined) { 77 | tokens[key] = object 78 | } else { 79 | Object.entries(object).forEach(([key2, object2]) => { 80 | if (key2.charAt(0) !== '$' && typeof object2 === 'object') { 81 | traverseCollection({ 82 | key: `${key}/${key2}`, 83 | object: object2, 84 | tokens, 85 | }) 86 | } 87 | }) 88 | } 89 | } 90 | 91 | function collectionAndModeFromFileName(fileName: string) { 92 | const fileNameParts = fileName.split('.') 93 | if (fileNameParts.length < 3) { 94 | throw new Error( 95 | `Invalid tokens file name: ${fileName}. File names must be in the format: {collectionName}.{modeName}.json`, 96 | ) 97 | } 98 | const [collectionName, modeName] = fileNameParts 99 | return { collectionName, modeName } 100 | } 101 | 102 | function variableResolvedTypeFromToken(token: Token) { 103 | switch (token.$type) { 104 | case 'color': 105 | return 'COLOR' 106 | case 'number': 107 | return 'FLOAT' 108 | case 'string': 109 | return 'STRING' 110 | case 'boolean': 111 | return 'BOOLEAN' 112 | default: 113 | throw new Error(`Invalid token $type: ${token.$type}`) 114 | } 115 | } 116 | 117 | function isAlias(value: string) { 118 | return value.toString().trim().charAt(0) === '{' 119 | } 120 | 121 | function variableValueFromToken( 122 | token: Token, 123 | localVariablesByCollectionAndName: { 124 | [variableCollectionId: string]: { [variableName: string]: LocalVariable } 125 | }, 126 | ): VariableValue { 127 | if (typeof token.$value === 'string' && isAlias(token.$value)) { 128 | // Assume aliases are in the format {group.subgroup.token} with any number of optional groups/subgroups 129 | // The Figma syntax for variable names is: group/subgroup/token 130 | const value = token.$value 131 | .trim() 132 | .replace(/\./g, '/') 133 | .replace(/[\{\}]/g, '') 134 | 135 | // When mapping aliases to existing local variables, we assume that variable names 136 | // are unique *across all collections* in the Figma file 137 | for (const localVariablesByName of Object.values(localVariablesByCollectionAndName)) { 138 | if (localVariablesByName[value]) { 139 | return { 140 | type: 'VARIABLE_ALIAS', 141 | id: localVariablesByName[value].id, 142 | } 143 | } 144 | } 145 | 146 | // If we don't find a local variable matching the alias, we assume it's a variable 147 | // that we're going to create elsewhere in the payload. 148 | // If the file has an invalid alias, we rely on the Figma API to return a 400 error 149 | return { 150 | type: 'VARIABLE_ALIAS', 151 | id: value, 152 | } 153 | } else if (typeof token.$value === 'string' && token.$type === 'color') { 154 | return parseColor(token.$value) 155 | } else { 156 | return token.$value 157 | } 158 | } 159 | 160 | function compareVariableValues(a: VariableValue, b: VariableValue) { 161 | if (typeof a === 'object' && typeof b === 'object') { 162 | if ('type' in a && 'type' in b && a.type === 'VARIABLE_ALIAS' && b.type === 'VARIABLE_ALIAS') { 163 | return a.id === b.id 164 | } else if ('r' in a && 'r' in b) { 165 | return colorApproximatelyEqual(a, b) 166 | } 167 | } else { 168 | return a === b 169 | } 170 | 171 | return false 172 | } 173 | 174 | function isCodeSyntaxEqual(a: VariableCodeSyntax, b: VariableCodeSyntax) { 175 | return ( 176 | Object.keys(a).length === Object.keys(b).length && 177 | Object.keys(a).every( 178 | (key) => a[key as keyof VariableCodeSyntax] === b[key as keyof VariableCodeSyntax], 179 | ) 180 | ) 181 | } 182 | 183 | /** 184 | * Get writable token properties that are different from the variable. 185 | * If the variable does not exist, all writable properties are returned. 186 | * 187 | * This function is being used to decide what properties to include in the 188 | * POST variables call to update variables in Figma. If a token does not have 189 | * a particular property, we do not include it in the differences object to avoid 190 | * touching that property in Figma. 191 | */ 192 | function tokenAndVariableDifferences(token: Token, variable: LocalVariable | null) { 193 | const differences: Partial< 194 | Omit< 195 | VariableCreate | VariableUpdate, 196 | 'id' | 'name' | 'variableCollectionId' | 'resolvedType' | 'action' 197 | > 198 | > = {} 199 | 200 | if ( 201 | token.$description !== undefined && 202 | (!variable || token.$description !== variable.description) 203 | ) { 204 | differences.description = token.$description 205 | } 206 | 207 | if (token.$extensions && token.$extensions['com.figma']) { 208 | const figmaExtensions = token.$extensions['com.figma'] 209 | 210 | if ( 211 | figmaExtensions.hiddenFromPublishing !== undefined && 212 | (!variable || figmaExtensions.hiddenFromPublishing !== variable.hiddenFromPublishing) 213 | ) { 214 | differences.hiddenFromPublishing = figmaExtensions.hiddenFromPublishing 215 | } 216 | 217 | if ( 218 | figmaExtensions.scopes && 219 | (!variable || !areSetsEqual(new Set(figmaExtensions.scopes), new Set(variable.scopes))) 220 | ) { 221 | differences.scopes = figmaExtensions.scopes 222 | } 223 | 224 | if ( 225 | figmaExtensions.codeSyntax && 226 | (!variable || !isCodeSyntaxEqual(figmaExtensions.codeSyntax, variable.codeSyntax)) 227 | ) { 228 | differences.codeSyntax = figmaExtensions.codeSyntax 229 | } 230 | } 231 | 232 | return differences 233 | } 234 | 235 | export function generatePostVariablesPayload( 236 | tokensByFile: FlattenedTokensByFile, 237 | localVariables: GetLocalVariablesResponse, 238 | ) { 239 | const localVariableCollectionsByName: { [name: string]: LocalVariableCollection } = {} 240 | const localVariablesByCollectionAndName: { 241 | [variableCollectionId: string]: { [variableName: string]: LocalVariable } 242 | } = {} 243 | 244 | Object.values(localVariables.meta.variableCollections).forEach((collection) => { 245 | if (localVariableCollectionsByName[collection.name]) { 246 | throw new Error(`Duplicate variable collection in file: ${collection.name}`) 247 | } 248 | 249 | localVariableCollectionsByName[collection.name] = collection 250 | }) 251 | 252 | Object.values(localVariables.meta.variables).forEach((variable) => { 253 | if (!localVariablesByCollectionAndName[variable.variableCollectionId]) { 254 | localVariablesByCollectionAndName[variable.variableCollectionId] = {} 255 | } 256 | 257 | localVariablesByCollectionAndName[variable.variableCollectionId][variable.name] = variable 258 | }) 259 | 260 | console.log( 261 | 'Local variable collections in Figma file:', 262 | Object.keys(localVariableCollectionsByName), 263 | ) 264 | 265 | const postVariablesPayload: PostVariablesRequestBody = { 266 | variableCollections: [], 267 | variableModes: [], 268 | variables: [], 269 | variableModeValues: [], 270 | } 271 | 272 | Object.entries(tokensByFile).forEach(([fileName, tokens]) => { 273 | const { collectionName, modeName } = collectionAndModeFromFileName(fileName) 274 | 275 | const variableCollection = localVariableCollectionsByName[collectionName] 276 | // Use the actual variable collection id or use the name as the temporary id 277 | const variableCollectionId = variableCollection ? variableCollection.id : collectionName 278 | const variableMode = variableCollection?.modes.find((mode) => mode.name === modeName) 279 | // Use the actual mode id or use the name as the temporary id 280 | const modeId = variableMode ? variableMode.modeId : modeName 281 | 282 | if ( 283 | !variableCollection && 284 | !postVariablesPayload.variableCollections!.find((c) => c.id === variableCollectionId) 285 | ) { 286 | postVariablesPayload.variableCollections!.push({ 287 | action: 'CREATE', 288 | id: variableCollectionId, 289 | name: variableCollectionId, 290 | initialModeId: modeId, // Use the initial mode as the first mode 291 | }) 292 | 293 | // Rename the initial mode, since we're using it as our first mode in the collection 294 | postVariablesPayload.variableModes!.push({ 295 | action: 'UPDATE', 296 | id: modeId, 297 | name: modeId, 298 | variableCollectionId, 299 | }) 300 | } 301 | 302 | // Add a new mode if it doesn't exist in the Figma file 303 | // and it's not the initial mode in the collection 304 | if ( 305 | !variableMode && 306 | !postVariablesPayload.variableCollections!.find( 307 | (c) => c.id === variableCollectionId && 'initialModeId' in c && c.initialModeId === modeId, 308 | ) 309 | ) { 310 | postVariablesPayload.variableModes!.push({ 311 | action: 'CREATE', 312 | id: modeId, 313 | name: modeId, 314 | variableCollectionId, 315 | }) 316 | } 317 | 318 | const localVariablesByName = localVariablesByCollectionAndName[variableCollection?.id] || {} 319 | 320 | Object.entries(tokens).forEach(([tokenName, token]) => { 321 | const variable = localVariablesByName[tokenName] 322 | const variableId = variable ? variable.id : tokenName 323 | const variableInPayload = postVariablesPayload.variables!.find( 324 | (v) => 325 | v.id === variableId && 326 | 'variableCollectionId' in v && 327 | v.variableCollectionId === variableCollectionId, 328 | ) 329 | const differences = tokenAndVariableDifferences(token, variable) 330 | 331 | // Add a new variable if it doesn't exist in the Figma file, 332 | // and we haven't added it already in another mode 333 | if (!variable && !variableInPayload) { 334 | postVariablesPayload.variables!.push({ 335 | action: 'CREATE', 336 | id: variableId, 337 | name: tokenName, 338 | variableCollectionId, 339 | resolvedType: variableResolvedTypeFromToken(token), 340 | ...differences, 341 | }) 342 | } else if (variable && Object.keys(differences).length > 0) { 343 | if (variable.remote) { 344 | throw new Error( 345 | `Cannot update remote variable "${variable.name}" in collection "${collectionName}"`, 346 | ) 347 | } 348 | 349 | postVariablesPayload.variables!.push({ 350 | action: 'UPDATE', 351 | id: variableId, 352 | ...differences, 353 | }) 354 | } 355 | 356 | const existingVariableValue = variable && variableMode ? variable.valuesByMode[modeId] : null 357 | const newVariableValue = variableValueFromToken(token, localVariablesByCollectionAndName) 358 | 359 | // Only include the variable mode value in the payload if it's different from the existing value 360 | if ( 361 | existingVariableValue === null || 362 | !compareVariableValues(existingVariableValue, newVariableValue) 363 | ) { 364 | postVariablesPayload.variableModeValues!.push({ 365 | variableId, 366 | modeId, 367 | value: newVariableValue, 368 | }) 369 | } 370 | }) 371 | }) 372 | 373 | return postVariablesPayload 374 | } 375 | -------------------------------------------------------------------------------- /src/token_import.test.ts: -------------------------------------------------------------------------------- 1 | import { GetLocalVariablesResponse } from '@figma/rest-api-spec' 2 | import { 3 | FlattenedTokensByFile, 4 | generatePostVariablesPayload, 5 | readJsonFiles, 6 | } from './token_import.js' 7 | 8 | jest.mock('fs', () => { 9 | const MOCK_FILE_INFO: { [fileName: string]: string } = { 10 | 'tokens/collection1.mode1.json': JSON.stringify({ 11 | spacing: { 12 | '1': { 13 | $type: 'number', 14 | $value: 8, 15 | $description: '8px spacing', 16 | }, 17 | '2': { 18 | $type: 'number', 19 | $value: 16, 20 | $description: '16px spacing', 21 | }, 22 | }, 23 | }), 24 | 'tokens/collection2.mode1.json': JSON.stringify({ 25 | color: { 26 | brand: { 27 | radish: { 28 | $type: 'color', 29 | $value: '#ffbe16', 30 | $description: 'Radish color', 31 | }, 32 | pear: { 33 | $type: 'color', 34 | $value: '#ffbe16', 35 | $description: 'Pear color', 36 | }, 37 | }, 38 | }, 39 | }), 40 | 'tokens/collection3.mode1.json': JSON.stringify({ 41 | token1: { $type: 'string', $value: 'value1' }, 42 | token2: { $type: 'string', $value: 'value2' }, 43 | }), 44 | 'no_tokens.mode1.json': JSON.stringify({ 45 | foo: 'bar', 46 | }), 47 | 'empty_file.mode1.json': '', 48 | 'file_with_$_keys.mode1.json': JSON.stringify({ 49 | $foo: 'bar', 50 | token1: { $type: 'string', $value: 'value1' }, 51 | }), 52 | } 53 | 54 | return { 55 | readFileSync: (fpath: string) => { 56 | if (fpath in MOCK_FILE_INFO) { 57 | return MOCK_FILE_INFO[fpath] 58 | } else { 59 | return '{}' 60 | } 61 | }, 62 | } 63 | }) 64 | 65 | describe('readJsonFiles', () => { 66 | it('reads all files and flattens tokens inside', () => { 67 | const result = readJsonFiles([ 68 | 'tokens/collection1.mode1.json', 69 | 'tokens/collection2.mode1.json', 70 | 'tokens/collection3.mode1.json', 71 | ]) 72 | expect(result).toEqual({ 73 | 'collection1.mode1.json': { 74 | 'spacing/1': { $type: 'number', $value: 8, $description: '8px spacing' }, 75 | 'spacing/2': { $type: 'number', $value: 16, $description: '16px spacing' }, 76 | }, 77 | 'collection2.mode1.json': { 78 | 'color/brand/radish': { $type: 'color', $value: '#ffbe16', $description: 'Radish color' }, 79 | 'color/brand/pear': { $type: 'color', $value: '#ffbe16', $description: 'Pear color' }, 80 | }, 81 | 'collection3.mode1.json': { 82 | token1: { $type: 'string', $value: 'value1' }, 83 | token2: { $type: 'string', $value: 'value2' }, 84 | }, 85 | }) 86 | }) 87 | 88 | it('handles files that do not have any tokens', () => { 89 | const result = readJsonFiles(['no_tokens.mode1.json']) 90 | expect(result).toEqual({ 'no_tokens.mode1.json': {} }) 91 | }) 92 | 93 | it('handles duplicate collections and modes', () => { 94 | expect(() => { 95 | readJsonFiles([ 96 | 'tokens/collection1.mode1.1.json', 97 | 'tokens/collection1.mode1.2.json', 98 | 'tokens/collection1.mode1.3.json', 99 | ]) 100 | }).toThrowError('Duplicate collection and mode in file: tokens/collection1.mode1.2.json') 101 | }) 102 | 103 | it('handles file names that do not match the expected format', () => { 104 | expect(() => { 105 | readJsonFiles(['tokens/collection1.mode1.json', 'tokens/collection2.mode1.json', 'foo.json']) 106 | }).toThrowError( 107 | 'Invalid tokens file name: foo.json. File names must be in the format: {collectionName}.{modeName}.json', 108 | ) 109 | }) 110 | 111 | it('ignores keys that start with $', () => { 112 | const result = readJsonFiles(['file_with_$_keys.mode1.json']) 113 | expect(result).toEqual({ 114 | 'file_with_$_keys.mode1.json': { token1: { $type: 'string', $value: 'value1' } }, 115 | }) 116 | }) 117 | 118 | it('handles empty files', () => { 119 | expect(() => { 120 | readJsonFiles(['empty_file.mode1.json']) 121 | }).toThrowError('Invalid tokens file: empty_file.mode1.json. File is empty.') 122 | }) 123 | }) 124 | 125 | describe('generatePostVariablesPayload', () => { 126 | it('does an initial sync', async () => { 127 | const localVariablesResponse: GetLocalVariablesResponse = { 128 | status: 200, 129 | error: false, 130 | meta: { 131 | variableCollections: {}, 132 | variables: {}, 133 | }, 134 | } 135 | 136 | const tokensByFile: FlattenedTokensByFile = { 137 | 'primitives.mode1.json': { 138 | 'spacing/1': { $type: 'number', $value: 8, $description: '8px spacing' }, 139 | 'spacing/2': { $type: 'number', $value: 16 }, 140 | 'color/brand/radish': { $type: 'color', $value: '#ffbe16', $description: 'Radish color' }, 141 | 'color/brand/pear': { $type: 'color', $value: '#ffbe16' }, 142 | }, 143 | 'primitives.mode2.json': { 144 | 'spacing/1': { $type: 'number', $value: 8 }, 145 | 'spacing/2': { $type: 'number', $value: 16 }, 146 | 'color/brand/radish': { $type: 'color', $value: '#010101' }, 147 | 'color/brand/pear': { $type: 'color', $value: '#010101' }, 148 | }, 149 | 'tokens.mode1.json': { 150 | 'spacing/spacing-sm': { $type: 'number', $value: '{spacing.1}' }, 151 | 'surface/surface-brand': { $type: 'color', $value: '{color.brand.radish}' }, 152 | }, 153 | 'tokens.mode2.json': { 154 | 'spacing/spacing-sm': { $type: 'number', $value: '{spacing.1}' }, 155 | 'surface/surface-brand': { $type: 'color', $value: '{color.brand.pear}' }, 156 | }, 157 | } 158 | 159 | const result = generatePostVariablesPayload(tokensByFile, localVariablesResponse) 160 | expect(result.variableCollections).toEqual([ 161 | { 162 | action: 'CREATE', 163 | id: 'primitives', 164 | name: 'primitives', 165 | initialModeId: 'mode1', 166 | }, 167 | { 168 | action: 'CREATE', 169 | id: 'tokens', 170 | name: 'tokens', 171 | initialModeId: 'mode1', 172 | }, 173 | ]) 174 | 175 | expect(result.variableModes).toEqual([ 176 | { 177 | action: 'UPDATE', 178 | id: 'mode1', 179 | name: 'mode1', 180 | variableCollectionId: 'primitives', 181 | }, 182 | { 183 | action: 'CREATE', 184 | id: 'mode2', 185 | name: 'mode2', 186 | variableCollectionId: 'primitives', 187 | }, 188 | { 189 | action: 'UPDATE', 190 | id: 'mode1', 191 | name: 'mode1', 192 | variableCollectionId: 'tokens', 193 | }, 194 | { 195 | action: 'CREATE', 196 | id: 'mode2', 197 | name: 'mode2', 198 | variableCollectionId: 'tokens', 199 | }, 200 | ]) 201 | 202 | expect(result.variables).toEqual([ 203 | // variables for the primitives collection 204 | { 205 | action: 'CREATE', 206 | id: 'spacing/1', 207 | name: 'spacing/1', 208 | variableCollectionId: 'primitives', 209 | resolvedType: 'FLOAT', 210 | description: '8px spacing', 211 | }, 212 | { 213 | action: 'CREATE', 214 | id: 'spacing/2', 215 | name: 'spacing/2', 216 | variableCollectionId: 'primitives', 217 | resolvedType: 'FLOAT', 218 | }, 219 | { 220 | action: 'CREATE', 221 | id: 'color/brand/radish', 222 | name: 'color/brand/radish', 223 | variableCollectionId: 'primitives', 224 | resolvedType: 'COLOR', 225 | description: 'Radish color', 226 | }, 227 | { 228 | action: 'CREATE', 229 | id: 'color/brand/pear', 230 | name: 'color/brand/pear', 231 | variableCollectionId: 'primitives', 232 | resolvedType: 'COLOR', 233 | }, 234 | 235 | // variables for the tokens collection 236 | { 237 | action: 'CREATE', 238 | id: 'spacing/spacing-sm', 239 | name: 'spacing/spacing-sm', 240 | variableCollectionId: 'tokens', 241 | resolvedType: 'FLOAT', 242 | }, 243 | { 244 | action: 'CREATE', 245 | id: 'surface/surface-brand', 246 | name: 'surface/surface-brand', 247 | variableCollectionId: 'tokens', 248 | resolvedType: 'COLOR', 249 | }, 250 | ]) 251 | 252 | expect(result.variableModeValues).toEqual([ 253 | // primitives, mode1 254 | { variableId: 'spacing/1', modeId: 'mode1', value: 8 }, 255 | { variableId: 'spacing/2', modeId: 'mode1', value: 16 }, 256 | { 257 | variableId: 'color/brand/radish', 258 | modeId: 'mode1', 259 | value: { r: 1, g: 0.7450980392156863, b: 0.08627450980392157 }, 260 | }, 261 | { 262 | variableId: 'color/brand/pear', 263 | modeId: 'mode1', 264 | value: { r: 1, g: 0.7450980392156863, b: 0.08627450980392157 }, 265 | }, 266 | 267 | // primitives, mode2 268 | { variableId: 'spacing/1', modeId: 'mode2', value: 8 }, 269 | { variableId: 'spacing/2', modeId: 'mode2', value: 16 }, 270 | { 271 | variableId: 'color/brand/radish', 272 | modeId: 'mode2', 273 | value: { r: 0.00392156862745098, g: 0.00392156862745098, b: 0.00392156862745098 }, 274 | }, 275 | { 276 | variableId: 'color/brand/pear', 277 | modeId: 'mode2', 278 | value: { r: 0.00392156862745098, g: 0.00392156862745098, b: 0.00392156862745098 }, 279 | }, 280 | 281 | // tokens, mode1 282 | { 283 | variableId: 'spacing/spacing-sm', 284 | modeId: 'mode1', 285 | value: { type: 'VARIABLE_ALIAS', id: 'spacing/1' }, 286 | }, 287 | { 288 | variableId: 'surface/surface-brand', 289 | modeId: 'mode1', 290 | value: { type: 'VARIABLE_ALIAS', id: 'color/brand/radish' }, 291 | }, 292 | 293 | // tokens, mode2 294 | { 295 | variableId: 'spacing/spacing-sm', 296 | modeId: 'mode2', 297 | value: { type: 'VARIABLE_ALIAS', id: 'spacing/1' }, 298 | }, 299 | { 300 | variableId: 'surface/surface-brand', 301 | modeId: 'mode2', 302 | value: { type: 'VARIABLE_ALIAS', id: 'color/brand/pear' }, 303 | }, 304 | ]) 305 | }) 306 | 307 | it('does an in-place update', async () => { 308 | const localVariablesResponse: GetLocalVariablesResponse = { 309 | status: 200, 310 | error: false, 311 | meta: { 312 | variableCollections: { 313 | 'VariableCollectionId:1:1': { 314 | id: 'VariableCollectionId:1:1', 315 | name: 'primitives', 316 | modes: [{ modeId: '1:0', name: 'mode1' }], 317 | defaultModeId: '1:0', 318 | remote: false, 319 | key: 'variableKey', 320 | hiddenFromPublishing: false, 321 | variableIds: ['VariableID:2:1', 'VariableID:2:2', 'VariableID:2:3', 'VariableID:2:4'], 322 | }, 323 | }, 324 | variables: { 325 | 'VariableID:2:1': { 326 | id: 'VariableID:2:1', 327 | name: 'spacing/1', 328 | key: 'variable_key', 329 | variableCollectionId: 'VariableCollectionId:1:1', 330 | resolvedType: 'FLOAT', 331 | valuesByMode: { 332 | '1:0': 8, 333 | }, 334 | remote: false, 335 | description: '8px spacing', 336 | hiddenFromPublishing: false, 337 | scopes: ['ALL_SCOPES'], 338 | codeSyntax: { WEB: 'web', ANDROID: 'android' }, 339 | }, 340 | 'VariableID:2:2': { 341 | id: 'VariableID:2:2', 342 | name: 'spacing/2', 343 | key: 'variable_key2', 344 | variableCollectionId: 'VariableCollectionId:1:1', 345 | resolvedType: 'FLOAT', 346 | valuesByMode: { 347 | '1:0': 15, // Different from token value 348 | }, 349 | remote: false, 350 | description: 'Another spacing', 351 | hiddenFromPublishing: false, 352 | scopes: ['ALL_SCOPES'], 353 | codeSyntax: { WEB: 'web', ANDROID: 'android' }, 354 | }, 355 | 'VariableID:2:3': { 356 | id: 'VariableID:2:3', 357 | name: 'color/brand/radish', 358 | key: 'variable_key3', 359 | variableCollectionId: 'VariableCollectionId:1:1', 360 | resolvedType: 'COLOR', 361 | valuesByMode: { 362 | '1:0': { r: 1, g: 0.7450980392156863, b: 0.08627450980392157, a: 1 }, 363 | }, 364 | remote: false, 365 | description: 'Radish color', 366 | hiddenFromPublishing: false, 367 | scopes: ['ALL_SCOPES'], 368 | codeSyntax: {}, 369 | }, 370 | 'VariableID:2:4': { 371 | id: 'VariableID:2:4', 372 | name: 'color/brand/pear', 373 | key: 'variable_key4', 374 | variableCollectionId: 'VariableCollectionId:1:1', 375 | resolvedType: 'COLOR', 376 | valuesByMode: { 377 | // Different from token value 378 | '1:0': { r: 1, g: 0, b: 0.08627450980392157, a: 1 }, 379 | }, 380 | remote: false, 381 | description: 'Pear color', 382 | hiddenFromPublishing: false, 383 | scopes: ['ALL_SCOPES'], 384 | codeSyntax: {}, 385 | }, 386 | }, 387 | }, 388 | } 389 | 390 | const tokensByFile: FlattenedTokensByFile = { 391 | 'primitives.mode1.json': { 392 | 'spacing/1': { 393 | $type: 'number', 394 | $value: 8, 395 | $description: '8px spacing', 396 | $extensions: { 397 | 'com.figma': { 398 | hiddenFromPublishing: false, 399 | scopes: ['ALL_SCOPES'], 400 | codeSyntax: { WEB: 'web', ANDROID: 'android' }, 401 | }, 402 | }, 403 | }, 404 | 'spacing/2': { 405 | $type: 'number', 406 | $value: 16, 407 | $description: 'changed description', 408 | $extensions: { 409 | 'com.figma': { 410 | hiddenFromPublishing: true, 411 | scopes: ['TEXT_CONTENT'], 412 | codeSyntax: { WEB: 'web', ANDROID: 'android_new' }, 413 | }, 414 | }, 415 | }, 416 | 'color/brand/radish': { $type: 'color', $value: '#ffbe16' }, 417 | 'color/brand/pear': { $type: 'color', $value: '#ffbe16' }, 418 | }, 419 | 'primitives.mode2.json': { 420 | 'spacing/1': { $type: 'number', $value: 8 }, 421 | 'spacing/2': { $type: 'number', $value: 16 }, 422 | 'color/brand/radish': { $type: 'color', $value: '#010101' }, 423 | 'color/brand/pear': { $type: 'color', $value: '#010101' }, 424 | }, 425 | 'tokens.mode1.json': { 426 | 'spacing/spacing-sm': { $type: 'number', $value: '{spacing.1}' }, 427 | 'surface/surface-brand': { $type: 'color', $value: '{color.brand.radish}' }, 428 | }, 429 | 'tokens.mode2.json': { 430 | 'spacing/spacing-sm': { $type: 'number', $value: '{spacing.1}' }, 431 | 'surface/surface-brand': { $type: 'color', $value: '{color.brand.pear}' }, 432 | }, 433 | } 434 | 435 | const result = generatePostVariablesPayload(tokensByFile, localVariablesResponse) 436 | expect(result.variableCollections).toEqual([ 437 | { 438 | action: 'CREATE', 439 | id: 'tokens', 440 | name: 'tokens', 441 | initialModeId: 'mode1', 442 | }, 443 | ]) 444 | 445 | expect(result.variableModes).toEqual([ 446 | { 447 | action: 'CREATE', 448 | id: 'mode2', 449 | name: 'mode2', 450 | variableCollectionId: 'VariableCollectionId:1:1', 451 | }, 452 | { 453 | action: 'UPDATE', 454 | id: 'mode1', 455 | name: 'mode1', 456 | variableCollectionId: 'tokens', 457 | }, 458 | { 459 | action: 'CREATE', 460 | id: 'mode2', 461 | name: 'mode2', 462 | variableCollectionId: 'tokens', 463 | }, 464 | ]) 465 | 466 | expect(result.variables).toEqual([ 467 | { 468 | action: 'UPDATE', 469 | id: 'VariableID:2:2', 470 | description: 'changed description', 471 | hiddenFromPublishing: true, 472 | scopes: ['TEXT_CONTENT'], 473 | codeSyntax: { WEB: 'web', ANDROID: 'android_new' }, 474 | }, 475 | // new variables for the tokens collection 476 | { 477 | action: 'CREATE', 478 | id: 'spacing/spacing-sm', 479 | name: 'spacing/spacing-sm', 480 | variableCollectionId: 'tokens', 481 | resolvedType: 'FLOAT', 482 | }, 483 | { 484 | action: 'CREATE', 485 | id: 'surface/surface-brand', 486 | name: 'surface/surface-brand', 487 | variableCollectionId: 'tokens', 488 | resolvedType: 'COLOR', 489 | }, 490 | ]) 491 | 492 | expect(result.variableModeValues).toEqual([ 493 | // primitives, mode1 494 | { variableId: 'VariableID:2:2', modeId: '1:0', value: 16 }, 495 | { 496 | variableId: 'VariableID:2:4', 497 | modeId: '1:0', 498 | value: { r: 1, g: 0.7450980392156863, b: 0.08627450980392157 }, 499 | }, 500 | 501 | // primitives, mode2 502 | { variableId: 'VariableID:2:1', modeId: 'mode2', value: 8 }, 503 | { variableId: 'VariableID:2:2', modeId: 'mode2', value: 16 }, 504 | { 505 | variableId: 'VariableID:2:3', 506 | modeId: 'mode2', 507 | value: { r: 0.00392156862745098, g: 0.00392156862745098, b: 0.00392156862745098 }, 508 | }, 509 | { 510 | variableId: 'VariableID:2:4', 511 | modeId: 'mode2', 512 | value: { r: 0.00392156862745098, g: 0.00392156862745098, b: 0.00392156862745098 }, 513 | }, 514 | 515 | // tokens, mode1 516 | { 517 | variableId: 'spacing/spacing-sm', 518 | modeId: 'mode1', 519 | value: { type: 'VARIABLE_ALIAS', id: 'VariableID:2:1' }, 520 | }, 521 | { 522 | variableId: 'surface/surface-brand', 523 | modeId: 'mode1', 524 | value: { type: 'VARIABLE_ALIAS', id: 'VariableID:2:3' }, 525 | }, 526 | 527 | // tokens, mode2 528 | { 529 | variableId: 'spacing/spacing-sm', 530 | modeId: 'mode2', 531 | value: { type: 'VARIABLE_ALIAS', id: 'VariableID:2:1' }, 532 | }, 533 | { 534 | variableId: 'surface/surface-brand', 535 | modeId: 'mode2', 536 | value: { type: 'VARIABLE_ALIAS', id: 'VariableID:2:4' }, 537 | }, 538 | ]) 539 | }) 540 | 541 | it('noops when everything is already in sync (with aliases)', () => { 542 | const localVariablesResponse: GetLocalVariablesResponse = { 543 | status: 200, 544 | error: false, 545 | meta: { 546 | variableCollections: { 547 | 'VariableCollectionId:1:1': { 548 | id: 'VariableCollectionId:1:1', 549 | name: 'collection', 550 | modes: [{ modeId: '1:0', name: 'mode1' }], 551 | defaultModeId: '1:0', 552 | remote: false, 553 | key: 'variableKey', 554 | hiddenFromPublishing: false, 555 | variableIds: ['VariableID:2:1', 'VariableID:2:2'], 556 | }, 557 | }, 558 | variables: { 559 | 'VariableID:2:1': { 560 | id: 'VariableID:2:1', 561 | name: 'var1', 562 | key: 'variable_key', 563 | variableCollectionId: 'VariableCollectionId:1:1', 564 | resolvedType: 'STRING', 565 | valuesByMode: { 566 | '1:0': 'hello world!', 567 | }, 568 | remote: false, 569 | description: '', 570 | hiddenFromPublishing: false, 571 | scopes: ['ALL_SCOPES'], 572 | codeSyntax: {}, 573 | }, 574 | 'VariableID:2:2': { 575 | id: 'VariableID:2:2', 576 | name: 'var2', 577 | key: 'variable_key2', 578 | variableCollectionId: 'VariableCollectionId:1:1', 579 | resolvedType: 'STRING', 580 | valuesByMode: { 581 | '1:0': { type: 'VARIABLE_ALIAS', id: 'VariableID:2:1' }, 582 | }, 583 | remote: false, 584 | description: '', 585 | hiddenFromPublishing: false, 586 | scopes: ['ALL_SCOPES'], 587 | codeSyntax: {}, 588 | }, 589 | }, 590 | }, 591 | } 592 | 593 | const tokensByFile: FlattenedTokensByFile = { 594 | 'collection.mode1.json': { 595 | var1: { 596 | $type: 'string', 597 | $value: 'hello world!', 598 | $description: '', 599 | $extensions: { 600 | 'com.figma': { 601 | hiddenFromPublishing: false, 602 | scopes: ['ALL_SCOPES'], 603 | codeSyntax: {}, 604 | }, 605 | }, 606 | }, 607 | var2: { 608 | $type: 'string', 609 | $value: '{var1}', 610 | $description: '', 611 | $extensions: { 612 | 'com.figma': { 613 | hiddenFromPublishing: false, 614 | scopes: ['ALL_SCOPES'], 615 | codeSyntax: {}, 616 | }, 617 | }, 618 | }, 619 | }, 620 | } 621 | 622 | const result = generatePostVariablesPayload(tokensByFile, localVariablesResponse) 623 | 624 | expect(result).toEqual({ 625 | variableCollections: [], 626 | variableModes: [], 627 | variables: [], 628 | variableModeValues: [], 629 | }) 630 | }) 631 | 632 | it('noops if tokens happen to match remote collections and variables', () => { 633 | const localVariablesResponse: GetLocalVariablesResponse = { 634 | status: 200, 635 | error: false, 636 | meta: { 637 | variableCollections: { 638 | 'VariableCollectionId:1:1': { 639 | id: 'VariableCollectionId:1:1', 640 | name: 'collection', 641 | modes: [{ modeId: '1:0', name: 'mode1' }], 642 | defaultModeId: '1:0', 643 | remote: true, 644 | key: 'variableKey', 645 | hiddenFromPublishing: false, 646 | variableIds: ['VariableID:2:1', 'VariableID:2:2'], 647 | }, 648 | }, 649 | variables: { 650 | 'VariableID:2:1': { 651 | id: 'VariableID:2:1', 652 | name: 'var1', 653 | key: 'variable_key', 654 | variableCollectionId: 'VariableCollectionId:1:1', 655 | resolvedType: 'STRING', 656 | valuesByMode: { 657 | '1:0': 'hello world!', 658 | }, 659 | remote: true, 660 | description: '', 661 | hiddenFromPublishing: false, 662 | scopes: ['ALL_SCOPES'], 663 | codeSyntax: {}, 664 | }, 665 | 'VariableID:2:2': { 666 | id: 'VariableID:2:2', 667 | name: 'var2', 668 | key: 'variable_key2', 669 | variableCollectionId: 'VariableCollectionId:1:1', 670 | resolvedType: 'STRING', 671 | valuesByMode: { 672 | '1:0': { type: 'VARIABLE_ALIAS', id: 'VariableID:2:1' }, 673 | }, 674 | remote: true, 675 | description: '', 676 | hiddenFromPublishing: false, 677 | scopes: ['ALL_SCOPES'], 678 | codeSyntax: {}, 679 | }, 680 | }, 681 | }, 682 | } 683 | 684 | const tokensByFile: FlattenedTokensByFile = { 685 | 'collection.mode1.json': { 686 | var1: { 687 | $type: 'string', 688 | $value: 'hello world!', 689 | $description: '', 690 | $extensions: { 691 | 'com.figma': { 692 | hiddenFromPublishing: false, 693 | scopes: ['ALL_SCOPES'], 694 | codeSyntax: {}, 695 | }, 696 | }, 697 | }, 698 | var2: { 699 | $type: 'string', 700 | $value: '{var1}', 701 | $description: '', 702 | $extensions: { 703 | 'com.figma': { 704 | hiddenFromPublishing: false, 705 | scopes: ['ALL_SCOPES'], 706 | codeSyntax: {}, 707 | }, 708 | }, 709 | }, 710 | }, 711 | } 712 | 713 | const result = generatePostVariablesPayload(tokensByFile, localVariablesResponse) 714 | 715 | expect(result).toEqual({ 716 | variableCollections: [], 717 | variableModes: [], 718 | variables: [], 719 | variableModeValues: [], 720 | }) 721 | }) 722 | 723 | it('throws on attempted modifications to remote variables', () => { 724 | const localVariablesResponse: GetLocalVariablesResponse = { 725 | status: 200, 726 | error: false, 727 | meta: { 728 | variableCollections: { 729 | 'VariableCollectionId:1:1': { 730 | id: 'VariableCollectionId:1:1', 731 | name: 'collection', 732 | modes: [{ modeId: '1:0', name: 'mode1' }], 733 | defaultModeId: '1:0', 734 | remote: true, 735 | key: 'variableKey', 736 | hiddenFromPublishing: false, 737 | variableIds: ['VariableID:2:1', 'VariableID:2:2'], 738 | }, 739 | }, 740 | variables: { 741 | 'VariableID:2:1': { 742 | id: 'VariableID:2:1', 743 | name: 'var1', 744 | key: 'variable_key', 745 | variableCollectionId: 'VariableCollectionId:1:1', 746 | resolvedType: 'STRING', 747 | valuesByMode: { 748 | '1:0': 'hello world!', 749 | }, 750 | remote: true, 751 | description: '', 752 | hiddenFromPublishing: false, 753 | scopes: ['ALL_SCOPES'], 754 | codeSyntax: {}, 755 | }, 756 | 'VariableID:2:2': { 757 | id: 'VariableID:2:2', 758 | name: 'var2', 759 | key: 'variable_key2', 760 | variableCollectionId: 'VariableCollectionId:1:1', 761 | resolvedType: 'STRING', 762 | valuesByMode: { 763 | '1:0': { type: 'VARIABLE_ALIAS', id: 'VariableID:2:1' }, 764 | }, 765 | remote: true, 766 | description: '', 767 | hiddenFromPublishing: false, 768 | scopes: ['ALL_SCOPES'], 769 | codeSyntax: {}, 770 | }, 771 | }, 772 | }, 773 | } 774 | 775 | const tokensByFile: FlattenedTokensByFile = { 776 | 'collection.mode1.json': { 777 | var1: { 778 | $type: 'string', 779 | $value: 'hello world!', 780 | $description: '', 781 | $extensions: { 782 | 'com.figma': { 783 | hiddenFromPublishing: true, // modification 784 | scopes: ['ALL_SCOPES'], 785 | codeSyntax: {}, 786 | }, 787 | }, 788 | }, 789 | var2: { 790 | $type: 'string', 791 | $value: '{var1}', 792 | $description: '', 793 | $extensions: { 794 | 'com.figma': { 795 | hiddenFromPublishing: false, 796 | scopes: ['ALL_SCOPES'], 797 | codeSyntax: {}, 798 | }, 799 | }, 800 | }, 801 | }, 802 | } 803 | 804 | expect(() => { 805 | generatePostVariablesPayload(tokensByFile, localVariablesResponse) 806 | }).toThrowError(`Cannot update remote variable "var1" in collection "collection"`) 807 | }) 808 | 809 | it('updates aliases to remote variables', () => { 810 | const localVariablesResponse: GetLocalVariablesResponse = { 811 | status: 200, 812 | error: false, 813 | meta: { 814 | variableCollections: { 815 | 'VariableCollectionId:1:1': { 816 | id: 'VariableCollectionId:1:1', 817 | name: 'primitives', 818 | modes: [{ modeId: '1:0', name: 'mode1' }], 819 | defaultModeId: '1:0', 820 | remote: true, 821 | key: 'variableCollectionKey1', 822 | hiddenFromPublishing: false, 823 | variableIds: ['VariableID:1:2', 'VariableID:1:3'], 824 | }, 825 | 'VariableCollectionId:2:1': { 826 | id: 'VariableCollectionId:2:1', 827 | name: 'tokens', 828 | modes: [{ modeId: '2:0', name: 'mode1' }], 829 | defaultModeId: '2:0', 830 | remote: false, 831 | key: 'variableCollectionKey2', 832 | hiddenFromPublishing: false, 833 | variableIds: ['VariableID:2:1'], 834 | }, 835 | }, 836 | variables: { 837 | // 2 color variables in the primitives collection 838 | 'VariableID:1:2': { 839 | id: 'VariableID:1:2', 840 | name: 'gray/100', 841 | key: 'variableKey1', 842 | variableCollectionId: 'VariableCollectionId:1:1', 843 | resolvedType: 'COLOR', 844 | valuesByMode: { 845 | '1:0': { r: 0.98, g: 0.98, b: 0.98, a: 1 }, 846 | }, 847 | remote: true, 848 | description: 'light gray', 849 | hiddenFromPublishing: false, 850 | scopes: ['ALL_SCOPES'], 851 | codeSyntax: {}, 852 | }, 853 | 'VariableID:1:3': { 854 | id: 'VariableID:1:3', 855 | name: 'gray/200', 856 | key: 'variableKey2', 857 | variableCollectionId: 'VariableCollectionId:1:1', 858 | resolvedType: 'COLOR', 859 | valuesByMode: { 860 | '1:0': { r: 0.96, g: 0.96, b: 0.96, a: 1 }, 861 | }, 862 | remote: true, 863 | description: 'light gray', 864 | hiddenFromPublishing: false, 865 | scopes: ['ALL_SCOPES'], 866 | codeSyntax: {}, 867 | }, 868 | // 1 color variable in the tokens collection 869 | 'VariableID:2:1': { 870 | id: 'VariableID:2:1', 871 | name: 'surface/surface-brand', 872 | key: 'variableKey3', 873 | variableCollectionId: 'VariableCollectionId:2:1', 874 | resolvedType: 'COLOR', 875 | valuesByMode: { 876 | '2:0': { type: 'VARIABLE_ALIAS', id: 'VariableID:1:2' }, 877 | }, 878 | remote: false, 879 | description: 'light gray', 880 | hiddenFromPublishing: false, 881 | scopes: ['ALL_SCOPES'], 882 | codeSyntax: {}, 883 | }, 884 | }, 885 | }, 886 | } 887 | 888 | const tokensByFile: FlattenedTokensByFile = { 889 | 'tokens.mode1.json': { 890 | // Change the alias to point to the other variable in the primitives collection 891 | 'surface/surface-brand': { $type: 'color', $value: '{gray.200}' }, 892 | }, 893 | } 894 | 895 | const result = generatePostVariablesPayload(tokensByFile, localVariablesResponse) 896 | 897 | expect(result.variableCollections).toEqual([]) 898 | expect(result.variableModes).toEqual([]) 899 | expect(result.variables).toEqual([]) 900 | expect(result.variableModeValues).toEqual([ 901 | { 902 | variableId: 'VariableID:2:1', 903 | modeId: '2:0', 904 | value: { type: 'VARIABLE_ALIAS', id: 'VariableID:1:3' }, 905 | }, 906 | ]) 907 | }) 908 | 909 | it('throws on unsupported token types', async () => { 910 | const localVariablesResponse: GetLocalVariablesResponse = { 911 | status: 200, 912 | error: false, 913 | meta: { 914 | variableCollections: {}, 915 | variables: {}, 916 | }, 917 | } 918 | 919 | const tokensByFile: any = { 920 | 'primitives.mode1.json': { 921 | 'font-weight-default': { $type: 'fontWeight', $value: 400 }, 922 | }, 923 | } 924 | 925 | expect(() => { 926 | generatePostVariablesPayload(tokensByFile, localVariablesResponse) 927 | }).toThrowError('Invalid token $type: fontWeight') 928 | }) 929 | 930 | it('throws on duplicate variable collections in the Figma file', () => { 931 | const localVariablesResponse: GetLocalVariablesResponse = { 932 | status: 200, 933 | error: false, 934 | meta: { 935 | variableCollections: { 936 | 'VariableCollectionId:1:1': { 937 | id: 'VariableCollectionId:1:1', 938 | name: 'collection', 939 | modes: [{ modeId: '1:0', name: 'mode1' }], 940 | defaultModeId: '1:0', 941 | remote: false, 942 | key: 'variableCollectionKey1', 943 | hiddenFromPublishing: false, 944 | variableIds: [], 945 | }, 946 | 'VariableCollectionId:1:2': { 947 | id: 'VariableCollectionId:1:2', 948 | name: 'collection', 949 | modes: [{ modeId: '2:0', name: 'mode1' }], 950 | defaultModeId: '2:0', 951 | remote: false, 952 | key: 'variableCollectionKey2', 953 | hiddenFromPublishing: false, 954 | variableIds: [], 955 | }, 956 | }, 957 | variables: {}, 958 | }, 959 | } 960 | 961 | const tokensByFile: FlattenedTokensByFile = { 962 | 'collection.mode1.json': { 963 | var1: { 964 | $type: 'string', 965 | $value: 'hello world!', 966 | $description: '', 967 | $extensions: { 968 | 'com.figma': { 969 | hiddenFromPublishing: false, 970 | scopes: ['ALL_SCOPES'], 971 | codeSyntax: {}, 972 | }, 973 | }, 974 | }, 975 | }, 976 | } 977 | 978 | expect(() => { 979 | generatePostVariablesPayload(tokensByFile, localVariablesResponse) 980 | }).toThrowError('Duplicate variable collection in file: collection') 981 | }) 982 | }) 983 | --------------------------------------------------------------------------------