Skip to content

Commit

Permalink
feat(scripts): allow import script to pull using Figma API
Browse files Browse the repository at this point in the history
- handle both downloaded file version and API version
- handle input and edge cases (e.g., missing config)
- separate and handle parsing of token contents into theme file
- guard against committing local edsrc config
  • Loading branch information
booc0mtaco committed Feb 12, 2025
1 parent 717938f commit dfec8fb
Show file tree
Hide file tree
Showing 6 changed files with 18,506 additions and 3 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ storybook-components
.env.production.local
.cache
.idea
.edsrc.json

npm-debug.log*
yarn-debug.log*
Expand Down
2 changes: 2 additions & 0 deletions bin/_util.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ module.exports = {
module.exports.getTokenPrefix(collectionName) +
module.exports.tokenNameToPath(variableName);

console.log(workingPath);

const found = at(localTheme, workingPath).filter(
(entries) => typeof entries !== 'undefined',
);
Expand Down
253 changes: 253 additions & 0 deletions bin/eds-import-from-figma-api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
#!/usr/bin/env node

class AbstractFigmaVariableReader {
constructor(jsonData) {
this._jsonData = jsonData;
}

getModes(variableCollectionId) {
throw new Error('Cannot Use Abstract class directly');
}

getVariableCollections() {
throw new Error('Cannot Use Abstract class directly');
}

getVariablesByCollectionId(variableCollectionId) {
throw new Error('Cannot Use Abstract class directly');
}

getVariableValue(variableId, mode) {
throw new Error('Cannot Use Abstract class directly');
}

parseResolvedValue(varType, figmaResolvedValue) {
if (varType === 'COLOR') {
const r = Math.floor(figmaResolvedValue.r * 255);
const g = Math.floor(figmaResolvedValue.g * 255);
const b = Math.floor(figmaResolvedValue.b * 255);
const a = figmaResolvedValue.a;
if (figmaResolvedValue.a > 0 && figmaResolvedValue.a < 1) {
return `rgba(${r}, ${g}, ${b}, ${a})`;
} else {
// print hex instead
return (
'#' +
[r, g, b]
.map((x) => x.toString(16))
.map((x) => (x.length === 1 ? '0' + x : x))
.join('')
.toUpperCase()
);
}
} else {
throw new TypeError('unknown resolved varType: ' + varType, {
details: figmaResolvedValue,
});
}
}
}

// https://www.figma.com/developers/api#get-published-variables-endpoint
class FigmaAPIReader extends AbstractFigmaVariableReader {
constructor(jsonData) {
super(jsonData.meta);
}

getModes(variableCollectionId) {
return this._jsonData.variableCollections[variableCollectionId].modes;
}

getVariableCollections() {
return Object.values(this._jsonData.variableCollections).map(
(collection) => {
return {
id: collection.id,
name: collection.name,
};
},
);
}

getVariablesByCollectionId(variableCollectionId) {
return Object.values(this._jsonData.variables).filter((variable) => {
return variable.variableCollectionId === variableCollectionId;
});
}
}

(async function () {
const jsonfile = require('jsonfile');
const chalk = require('chalk');
const { prompt } = require('enquirer');
const set = require('lodash/set');
const { default: ora } = await import('ora');

// eslint-disable-next-line import/extensions
const { hideBin } = require('yargs/helpers');
const yargs = require('yargs/yargs');

const { getConfig, parseResolvedValue, getWritePath } = require('./_util');

// Set up the command structure to output help if the file to load is not specified
const args = yargs(hideBin(process.argv))
.command(
'$0 file [file_id]',
'Fetch the variables from Figma using the API',
(yargs) => {
yargs.positional('file_id', {
type: 'string',
describe: 'the figma file where the published variables are defined',
});
},
)
.option('token', {
describe: 'Figma API token',
type: 'string',
})
.option('verbose', {
describe: 'Print additional details for debugging purposes',
type: 'boolean',
})
.option('dry-run', {
describe: 'Run the script without writing any changes',
type: 'boolean',
}).argv;

// read in the config from config file, package json "eds", etc.
const config = await getConfig();
const isVerbose = args.v;
const canWrite = !args.dryRun;

// Get the local variables from the defined file b/c published ones won't contain the modes
// https://www.figma.com/developers/api#get-local-variables-endpoint
const figmaFileResponse = await fetch(
`https://api.figma.com/v1/files/${args.file_id}/variables/local`,
{
headers: {
'X-FIGMA-TOKEN': args.token,
},
},
);

if (!figmaFileResponse.ok) {
throw new Error('cannot access figma file: ' + figmaFileResponse.status);
}

const fullData = await figmaFileResponse.json();
const figmaApiReader = new FigmaAPIReader(fullData);
const edsCollection = figmaApiReader
.getVariableCollections()
.find((collection) => collection.name === 'EDS tokens');

// Determine which of the modes in the file should be used
// TODO-AH: define naming convention for tier 1 collection and EDS token collection so that we can map
// the two together.
let response;
try {
response = await prompt({
name: 'modeId',
message: 'Please select the Figma mode for token import',
type: 'select',
choices: figmaApiReader.getModes(edsCollection.id).map((mode) => {
return {
name: mode.modeId,
message: `Use the ${mode.name} figma mode`,
value: mode.name,
};
}),
});
} catch (e) {
// e.g., someone hits ESC
console.error(chalk.red('Aborted.'), e);
process.exit(-1);
}

const stats = {
skipped: [],
updated: [],
errored: [],
total: [],
};

const spinner = ora('Parsing tokens').start();

// now, load in the local theme file. We want to look up keys in there
const localTheme = jsonfile.readFileSync(`${config.src}app-theme.json`);

figmaApiReader
.getVariablesByCollectionId(edsCollection.id)
.forEach((figmaVariable) => {
stats.total.push(figmaVariable.id);

// TODO-AH: all the write paths are invalid
const writePath = getWritePath(
localTheme,
'themes', // TODO-AH : hardcoded to only write semantic tokens b/c of naming convention
figmaVariable.name,
);
console.log(writePath);

if (writePath) {
try {
canWrite &&
set(
localTheme,
writePath,
parseResolvedValue(
figmaVariable.type,
figmaVariable.resolvedValuesByMode[response.modeId]
.resolvedValue,
),
);

// write the value using the calculated path and parsed value
if (isVerbose || !canWrite) {
spinner.succeed(
'Write: ' +
parseResolvedValue(
figmaVariable.type,
figmaVariable.resolvedValuesByMode[response.modeId]
.resolvedValue,
) +
' to ' +
writePath,
);
}

spinner.text = chalk.bold(figmaVariable.name) + ': Done!';
stats.updated.push(figmaVariable);
} catch (e) {
// We couldn't parse the resolved value, so skip and add to errors
spinner.fail(
chalk.bold(figmaVariable.name) +
': Skipped with error (' +
e.message +
')',
);
stats.errored.push(figmaVariable);
}
} else {
spinner.text = chalk.bold(figmaVariable.name) + ': Skipped';

if (!writePath) {
spinner.warn(
chalk.bold(figmaVariable.name) +
': Skipped with warning (no write path)',
);
}
stats.skipped.push(figmaVariable);
}
});

if (canWrite) {
jsonfile.writeFileSync(`${config.src}app-theme.json`, localTheme, {
spaces: 2,
finalEOL: false,
});
}

spinner.succeed(
`Done! updated: ${stats.updated.length}, skipped: ${stats.skipped.length}, errored: ${stats.errored.length}, total: ${stats.total.length}`,
);
})();
36 changes: 33 additions & 3 deletions bin/eds-import-from-figma.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@
});
},
)
.command(
'$0 file [file_id]',
'Fetch the variables from Figma using the API',
(yargs) => {
yargs.positional('file_id', {
type: 'string',
describe: 'the figma file where the published variables are defined',
});
},
)
.option('verbose', {
describe: 'Print additional details for debugging purposes',
type: 'boolean',
Expand All @@ -37,9 +47,29 @@
// read in the config from config file, package json "eds", etc.
const config = await getConfig();

// Read the figma import file, and parse out the modes to select from, prompting the user to pick one,
// and load in the local theme file. We want to look up keys in there
const figmaThemeData = jsonfile.readFileSync(args.figma_file);
let figmaThemeData;
// Decide file source (either a downloaded JSON file or via API)
if (args.file) {
const figmaFileResponse = await fetch(
`https://api.figma.com/v1/files/${args.file_id}/variables/published`,
{
headers: {
'X-FIGMA-TOKEN': config.token,
},
},
);

if (!figmaFileResponse.ok) {
throw new Error('cannot access figma file: ' + figmaFileResponse.status);
}

figmaThemeData = await figmaFileResponse.json();
} else {
// Read the figma import file, and parse out the modes to select from, prompting the user to pick one,
figmaThemeData = jsonfile.readFileSync(args.figma_file);
}

// now, load in the local theme file. We want to look up keys in there
const localTheme = jsonfile.readFileSync(`${config.src}app-theme.json`);

// Determine which of the modes in the file should be used
Expand Down
Loading

0 comments on commit dfec8fb

Please sign in to comment.