diff --git a/package-lock.json b/package-lock.json index 62a7460..bf1eee3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "typescript": "^5.1.6" }, "devDependencies": { + "@figma/rest-api-spec": "^0.10.0", "@types/jest": "^29.5.3", "@types/node": "^20.4.5", "jest": "^29.6.2", @@ -699,6 +700,12 @@ "node": ">=12" } }, + "node_modules/@figma/rest-api-spec": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@figma/rest-api-spec/-/rest-api-spec-0.10.0.tgz", + "integrity": "sha512-QwAZ5iW/QmvX+HDD0ItK9TaE/BfhJitQWmi9JFZswGhthFO1EJcPYbFNlknOG+ZvtIeK693b0hDrVE0xpsZomQ==", + "dev": true + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", diff --git a/package.json b/package.json index 5971e18..06e1f43 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "typescript": "^5.1.6" }, "devDependencies": { + "@figma/rest-api-spec": "^0.10.0", "@types/jest": "^29.5.3", "@types/node": "^20.4.5", "jest": "^29.6.2", diff --git a/src/color.ts b/src/color.ts index 172cadf..57e52b7 100644 --- a/src/color.ts +++ b/src/color.ts @@ -1,14 +1,14 @@ -import { Color } from './figma_api.js' +import { RGB, RGBA } from '@figma/rest-api-spec' /** * Compares two colors for approximate equality since converting between Figma RGBA objects (from 0 -> 1) and * hex colors can result in slight differences. */ -export function colorApproximatelyEqual(colorA: Color, colorB: Color) { +export function colorApproximatelyEqual(colorA: RGB | RGBA, colorB: RGB | RGBA) { return rgbToHex(colorA) === rgbToHex(colorB) } -export function parseColor(color: string): Color { +export function parseColor(color: string): RGB | RGBA { color = color.trim() const hexRegex = /^#([A-Fa-f0-9]{6})([A-Fa-f0-9]{2}){0,1}$/ const hexShorthandRegex = /^#([A-Fa-f0-9]{3})([A-Fa-f0-9]){0,1}$/ @@ -36,10 +36,8 @@ export function parseColor(color: string): Color { } } -export function rgbToHex({ r, g, b, a }: Color) { - if (a === undefined) { - a = 1 - } +export function rgbToHex({ r, g, b, ...rest }: RGB | RGBA) { + const a = 'a' in rest ? rest.a : 1 const toHex = (value: number) => { const hex = Math.round(value * 255).toString(16) diff --git a/src/figma_api.ts b/src/figma_api.ts index f81dae0..85b8149 100644 --- a/src/figma_api.ts +++ b/src/figma_api.ts @@ -1,110 +1,9 @@ import axios from 'axios' - -export interface VariableMode { - modeId: string - name: string -} - -export interface VariableModeChange { - action: 'CREATE' | 'UPDATE' | 'DELETE' - id?: string - name?: string - variableCollectionId: string -} - -export interface VariableCollection { - id: string - name: string - modes: VariableMode[] - defaultModeId: string - remote: boolean - hiddenFromPublishing: boolean -} - -export interface VariableCollectionChange - extends Partial> { - action: 'CREATE' | 'UPDATE' | 'DELETE' - initialModeId?: string -} - -export interface Color { - r: number - g: number - b: number - a?: number -} - -interface VariableAlias { - type: 'VARIABLE_ALIAS' - id: string -} - -export type VariableValue = boolean | number | string | Color | VariableAlias - -export type VariableScope = 'ALL_SCOPES' | VariableFloatScopes | VariableColorScopes -type VariableFloatScopes = 'TEXT_CONTENT' | 'WIDTH_HEIGHT' | 'GAP' -type VariableColorScopes = 'ALL_FILLS' | 'FRAME_FILL' | 'SHAPE_FILL' | 'TEXT_FILL' | 'STROKE_COLOR' - -export type VariableCodeSyntax = { WEB?: string; ANDROID?: string; iOS?: string } - -export interface Variable { - id: string - name: string - key: string - variableCollectionId: string - resolvedType: 'BOOLEAN' | 'FLOAT' | 'STRING' | 'COLOR' - valuesByMode: { [modeId: string]: VariableValue } - remote: boolean - description: string - hiddenFromPublishing: boolean - scopes: VariableScope[] - codeSyntax: VariableCodeSyntax -} - -export interface VariableChange - extends Partial< - Pick< - Variable, - | 'id' - | 'name' - | 'variableCollectionId' - | 'resolvedType' - | 'description' - | 'hiddenFromPublishing' - | 'scopes' - | 'codeSyntax' - > - > { - action: 'CREATE' | 'UPDATE' | 'DELETE' -} - -export interface VariableModeValue { - variableId: string - modeId: string - value: VariableValue -} - -export interface ApiGetLocalVariablesResponse { - status: number - error: boolean - meta: { - variableCollections: { [id: string]: VariableCollection } - variables: { [id: string]: Variable } - } -} - -export interface ApiPostVariablesPayload { - variableCollections?: VariableCollectionChange[] - variableModes?: VariableModeChange[] - variables?: VariableChange[] - variableModeValues?: VariableModeValue[] -} - -interface ApiPostVariablesResponse { - status: number - error: boolean - meta: { tempIdToRealId: { [tempId: string]: string } } -} +import { + GetLocalVariablesResponse, + PostVariablesRequestBody, + PostVariablesResponse, +} from '@figma/rest-api-spec' export default class FigmaApi { private baseUrl = 'https://api.figma.com' @@ -115,7 +14,7 @@ export default class FigmaApi { } async getLocalVariables(fileKey: string) { - const resp = await axios.request({ + const resp = await axios.request({ url: `${this.baseUrl}/v1/files/${fileKey}/variables/local`, headers: { Accept: '*/*', @@ -126,8 +25,8 @@ export default class FigmaApi { return resp.data } - async postVariables(fileKey: string, payload: ApiPostVariablesPayload) { - const resp = await axios.request({ + async postVariables(fileKey: string, payload: PostVariablesRequestBody) { + const resp = await axios.request({ url: `${this.baseUrl}/v1/files/${fileKey}/variables`, method: 'POST', headers: { diff --git a/src/token_export.test.ts b/src/token_export.test.ts index a642377..c0f4792 100644 --- a/src/token_export.test.ts +++ b/src/token_export.test.ts @@ -1,9 +1,9 @@ -import { ApiGetLocalVariablesResponse } from './figma_api.js' +import { GetLocalVariablesResponse } from '@figma/rest-api-spec' import { tokenFilesFromLocalVariables } from './token_export.js' describe('tokenFilesFromLocalVariables', () => { it('ignores remote variables', () => { - const localVariablesResponse: ApiGetLocalVariablesResponse = { + const localVariablesResponse: GetLocalVariablesResponse = { status: 200, error: false, meta: { @@ -14,7 +14,9 @@ describe('tokenFilesFromLocalVariables', () => { modes: [{ modeId: '1:0', name: 'mode1' }], defaultModeId: '1:0', remote: true, + key: 'variableKey', hiddenFromPublishing: false, + variableIds: ['VariableID:2:1'], }, }, variables: { @@ -42,7 +44,7 @@ describe('tokenFilesFromLocalVariables', () => { }) it('returns token files', () => { - const localVariablesResponse: ApiGetLocalVariablesResponse = { + const localVariablesResponse: GetLocalVariablesResponse = { status: 200, error: false, meta: { @@ -56,7 +58,9 @@ describe('tokenFilesFromLocalVariables', () => { ], defaultModeId: '1:0', remote: false, + key: 'variableKey', hiddenFromPublishing: false, + variableIds: ['VariableID:2:1', 'VariableID:2:2', 'VariableID:2:3', 'VariableID:2:4'], }, }, variables: { @@ -246,7 +250,7 @@ describe('tokenFilesFromLocalVariables', () => { }) it('handles aliases', () => { - const localVariablesResponse: ApiGetLocalVariablesResponse = { + const localVariablesResponse: GetLocalVariablesResponse = { status: 200, error: false, meta: { @@ -260,7 +264,9 @@ describe('tokenFilesFromLocalVariables', () => { ], defaultModeId: '1:0', remote: false, + key: 'variableKey', hiddenFromPublishing: false, + variableIds: ['VariableID:2:1', 'VariableID:2:2'], }, }, variables: { diff --git a/src/token_export.ts b/src/token_export.ts index 58ed3e0..998562a 100644 --- a/src/token_export.ts +++ b/src/token_export.ts @@ -1,8 +1,8 @@ +import { GetLocalVariablesResponse, LocalVariable } from '@figma/rest-api-spec' import { rgbToHex } from './color.js' -import { ApiGetLocalVariablesResponse, Variable } from './figma_api.js' import { Token, TokensFile } from './token_types.js' -function tokenTypeFromVariable(variable: Variable) { +function tokenTypeFromVariable(variable: LocalVariable) { switch (variable.resolvedType) { case 'BOOLEAN': return 'boolean' @@ -16,9 +16,9 @@ function tokenTypeFromVariable(variable: Variable) { } function tokenValueFromVariable( - variable: Variable, + variable: LocalVariable, modeId: string, - localVariables: { [id: string]: Variable }, + localVariables: { [id: string]: LocalVariable }, ) { const value = variable.valuesByMode[modeId] if (typeof value === 'object') { @@ -35,7 +35,7 @@ function tokenValueFromVariable( } } -export function tokenFilesFromLocalVariables(localVariablesResponse: ApiGetLocalVariablesResponse) { +export function tokenFilesFromLocalVariables(localVariablesResponse: GetLocalVariablesResponse) { const tokenFiles: { [fileName: string]: TokensFile } = {} const localVariableCollections = localVariablesResponse.meta.variableCollections const localVariables = localVariablesResponse.meta.variables diff --git a/src/token_import.test.ts b/src/token_import.test.ts index 8fc1e21..d1b2a87 100644 --- a/src/token_import.test.ts +++ b/src/token_import.test.ts @@ -1,4 +1,4 @@ -import { ApiGetLocalVariablesResponse } from './figma_api.js' +import { GetLocalVariablesResponse } from '@figma/rest-api-spec' import { FlattenedTokensByFile, generatePostVariablesPayload, @@ -124,7 +124,7 @@ describe('readJsonFiles', () => { describe('generatePostVariablesPayload', () => { it('does an initial sync', async () => { - const localVariablesResponse = { + const localVariablesResponse: GetLocalVariablesResponse = { status: 200, error: false, meta: { @@ -305,7 +305,7 @@ describe('generatePostVariablesPayload', () => { }) it('does an in-place update', async () => { - const localVariablesResponse: ApiGetLocalVariablesResponse = { + const localVariablesResponse: GetLocalVariablesResponse = { status: 200, error: false, meta: { @@ -316,7 +316,9 @@ describe('generatePostVariablesPayload', () => { modes: [{ modeId: '1:0', name: 'mode1' }], defaultModeId: '1:0', remote: false, + key: 'variableKey', hiddenFromPublishing: false, + variableIds: ['VariableID:2:1', 'VariableID:2:2', 'VariableID:2:3', 'VariableID:2:4'], }, }, variables: { @@ -537,7 +539,7 @@ describe('generatePostVariablesPayload', () => { }) it('noops when everything is already in sync (with aliases)', () => { - const localVariablesResponse: ApiGetLocalVariablesResponse = { + const localVariablesResponse: GetLocalVariablesResponse = { status: 200, error: false, meta: { @@ -548,7 +550,9 @@ describe('generatePostVariablesPayload', () => { modes: [{ modeId: '1:0', name: 'mode1' }], defaultModeId: '1:0', remote: false, + key: 'variableKey', hiddenFromPublishing: false, + variableIds: ['VariableID:2:1', 'VariableID:2:2'], }, }, variables: { @@ -626,7 +630,7 @@ describe('generatePostVariablesPayload', () => { }) it('ignores remote collections and variables', () => { - const localVariablesResponse: ApiGetLocalVariablesResponse = { + const localVariablesResponse: GetLocalVariablesResponse = { status: 200, error: false, meta: { @@ -637,7 +641,9 @@ describe('generatePostVariablesPayload', () => { modes: [{ modeId: '1:0', name: 'mode1' }], defaultModeId: '1:0', remote: true, + key: 'variableKey', hiddenFromPublishing: false, + variableIds: ['VariableID:2:1', 'VariableID:2:2'], }, }, variables: { @@ -720,7 +726,7 @@ describe('generatePostVariablesPayload', () => { }) it('throws on unsupported token types', async () => { - const localVariablesResponse = { + const localVariablesResponse: GetLocalVariablesResponse = { status: 200, error: false, meta: { @@ -741,7 +747,7 @@ describe('generatePostVariablesPayload', () => { }) it('throws on duplicate variable collections in the Figma file', () => { - const localVariablesResponse: ApiGetLocalVariablesResponse = { + const localVariablesResponse: GetLocalVariablesResponse = { status: 200, error: false, meta: { @@ -752,7 +758,9 @@ describe('generatePostVariablesPayload', () => { modes: [{ modeId: '1:0', name: 'mode1' }], defaultModeId: '1:0', remote: false, + key: 'variableCollectionKey1', hiddenFromPublishing: false, + variableIds: [], }, 'VariableCollectionId:1:2': { id: 'VariableCollectionId:1:2', @@ -760,7 +768,9 @@ describe('generatePostVariablesPayload', () => { modes: [{ modeId: '2:0', name: 'mode1' }], defaultModeId: '2:0', remote: false, + key: 'variableCollectionKey2', hiddenFromPublishing: false, + variableIds: [], }, }, variables: {}, diff --git a/src/token_import.ts b/src/token_import.ts index 2caf9e2..47d057f 100644 --- a/src/token_import.ts +++ b/src/token_import.ts @@ -1,18 +1,19 @@ import * as fs from 'fs' import * as path from 'path' -import { - VariableCollection, - Variable, - ApiPostVariablesPayload, - VariableValue, - ApiGetLocalVariablesResponse, - VariableChange, - VariableCodeSyntax, -} from './figma_api.js' import { colorApproximatelyEqual, parseColor } from './color.js' import { areSetsEqual } from './utils.js' import { Token, TokenOrTokenGroup, TokensFile } from './token_types.js' +import { + GetLocalVariablesResponse, + LocalVariable, + LocalVariableCollection, + PostVariablesRequestBody, + VariableCodeSyntax, + VariableCreate, + VariableUpdate, + VariableValue, +} from '@figma/rest-api-spec' export type FlattenedTokensByFile = { [fileName: string]: { @@ -120,7 +121,7 @@ function isAlias(value: string) { function variableValueFromToken( token: Token, localVariablesByCollectionAndName: { - [variableCollectionId: string]: { [variableName: string]: Variable } + [variableCollectionId: string]: { [variableName: string]: LocalVariable } }, ): VariableValue { if (typeof token.$value === 'string' && isAlias(token.$value)) { @@ -188,9 +189,12 @@ function isCodeSyntaxEqual(a: VariableCodeSyntax, b: VariableCodeSyntax) { * a particular property, we do not include it in the differences object to avoid * touching that property in Figma. */ -function tokenAndVariableDifferences(token: Token, variable: Variable | null) { +function tokenAndVariableDifferences(token: Token, variable: LocalVariable | null) { const differences: Partial< - Omit + Omit< + VariableCreate | VariableUpdate, + 'id' | 'name' | 'variableCollectionId' | 'resolvedType' | 'action' + > > = {} if ( @@ -230,11 +234,11 @@ function tokenAndVariableDifferences(token: Token, variable: Variable | null) { export function generatePostVariablesPayload( tokensByFile: FlattenedTokensByFile, - localVariables: ApiGetLocalVariablesResponse, + localVariables: GetLocalVariablesResponse, ) { - const localVariableCollectionsByName: { [name: string]: VariableCollection } = {} + const localVariableCollectionsByName: { [name: string]: LocalVariableCollection } = {} const localVariablesByCollectionAndName: { - [variableCollectionId: string]: { [variableName: string]: Variable } + [variableCollectionId: string]: { [variableName: string]: LocalVariable } } = {} Object.values(localVariables.meta.variableCollections).forEach((collection) => { @@ -268,7 +272,7 @@ export function generatePostVariablesPayload( Object.keys(localVariableCollectionsByName), ) - const postVariablesPayload: ApiPostVariablesPayload = { + const postVariablesPayload: PostVariablesRequestBody = { variableCollections: [], variableModes: [], variables: [], @@ -310,7 +314,7 @@ export function generatePostVariablesPayload( if ( !variableMode && !postVariablesPayload.variableCollections!.find( - (c) => c.id === variableCollectionId && c.initialModeId === modeId, + (c) => c.id === variableCollectionId && 'initialModeId' in c && c.initialModeId === modeId, ) ) { postVariablesPayload.variableModes!.push({ @@ -327,7 +331,10 @@ export function generatePostVariablesPayload( const variable = localVariablesByName[tokenName] const variableId = variable ? variable.id : tokenName const variableInPayload = postVariablesPayload.variables!.find( - (v) => v.id === variableId && v.variableCollectionId === variableCollectionId, + (v) => + v.id === variableId && + 'variableCollectionId' in v && + v.variableCollectionId === variableCollectionId, ) const differences = tokenAndVariableDifferences(token, variable) diff --git a/src/token_types.ts b/src/token_types.ts index d6f9973..a2a0b9c 100644 --- a/src/token_types.ts +++ b/src/token_types.ts @@ -1,5 +1,3 @@ -import { VariableCodeSyntax, VariableScope } from './figma_api.js' - /** * This file defines what design tokens and design token files look like in the codebase. * @@ -8,6 +6,8 @@ import { VariableCodeSyntax, VariableScope } from './figma_api.js' * one for each mode. */ +import { VariableCodeSyntax, VariableScope } from '@figma/rest-api-spec' + export interface Token { /** * The [type](https://tr.designtokens.org/format/#type-0) of the token.