@figma/code-connect
Version:
A tool for connecting your design system components in code with your design system in Figma
593 lines • 28 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.addConnectCommandToProgram = addConnectCommandToProgram;
exports.getAccessToken = getAccessToken;
exports.getAccessTokenOrExit = getAccessTokenOrExit;
exports.getDir = getDir;
exports.setupHandler = setupHandler;
exports.getCodeConnectObjects = getCodeConnectObjects;
const fs_1 = __importDefault(require("fs"));
const upload_1 = require("../connect/upload");
const validation_1 = require("../connect/validation");
const create_1 = require("../connect/create");
const project_1 = require("../connect/project");
const logging_1 = require("../common/logging");
const convert_1 = require("../storybook/convert");
const delete_docs_1 = require("../connect/delete_docs");
const run_wizard_1 = require("../connect/wizard/run_wizard");
const parser_executables_1 = require("../connect/parser_executables");
const zod_validation_error_1 = require("zod-validation-error");
const parser_executable_types_1 = require("../connect/parser_executable_types");
const path_1 = __importDefault(require("path"));
const updates_1 = require("../common/updates");
const helpers_1 = require("../connect/helpers");
const filter_project_info_1 = require("./filter_project_info");
const parser_1 = require("../html/parser");
const parser_common_1 = require("../connect/parser_common");
const parser_2 = require("../react/parser");
const label_language_mapping_1 = require("../connect/label_language_mapping");
const migration_helpers_1 = require("../connect/migration_helpers");
const raw_templates_1 = require("../connect/raw_templates");
const run_wizard_2 = require("../connect/wizard/run_wizard");
const project_2 = require("../connect/project");
function addBaseCommand(command, name, description) {
return command
.command(name)
.description(description)
.usage('[options]')
.option('-r --dir <dir>', 'directory to parse')
.option('-f --file <file>', 'path to a single Code Connect file to process')
.option('-t --token <token>', 'figma access token')
.option('-v --verbose', 'enable verbose logging for debugging')
.option('-o --outFile <file>', 'specify a file to output generated Code Connect')
.option('-o --outDir <dir>', 'specify a directory to output generated Code Connect')
.option('-c --config <path>', 'path to a figma config file')
.option('-a --api-url <url>', 'custom Figma API URL to use instead of https://api.figma.com/v1')
.option('--skip-update-check', 'skips checking for an updated version of the Figma CLI')
.option('--exit-on-unreadable-files', 'exit if any Code Connect files cannot be parsed. We recommend using this option for CI/CD.')
.option('--dry-run', 'tests publishing without actually publishing')
.addHelpText('before', 'For feedback or bugs, please raise an issue: https://github.com/figma/code-connect/issues');
}
function addConnectCommandToProgram(program) {
// Main command, invoked with `figma connect`
const connectCommand = addBaseCommand(program, 'connect', 'Figma Code Connect').action((0, updates_1.withUpdateCheck)(run_wizard_1.runWizard));
// Sub-commands, invoked with e.g. `figma connect publish`
addBaseCommand(connectCommand, 'publish', 'Run Code Connect locally to find any files that have figma connections and publish them to Figma. ' +
'By default this looks for a config file named "figma.config.json", and uses the `include` and `exclude` fields to determine which files to parse. ' +
'If no config file is found, this parses the current directory. An optional `--dir` flag can be used to specify a directory to parse.')
.option('--skip-validation', 'skip validation of Code Connect docs')
.option('-l --label <label>', 'label to apply to the published files')
.option('-b --batch-size <batch_size>', 'optional batch size (in number of documents) to use when uploading. Use this if you hit "request too large" errors. See README for more information.')
.option('--include-template-files', '(Deprecated) No longer needed - template files are included by default. Will be removed in a future version.')
.action((0, updates_1.withUpdateCheck)(handlePublish));
addBaseCommand(connectCommand, 'unpublish', 'Run to find any files that have figma connections and unpublish them from Figma. ' +
'By default this looks for a config file named "figma.config.json", and uses the `include` and `exclude` fields to determine which files to parse. ' +
'If no config file is found, this parses the current directory. An optional `--dir` flag can be used to specify a directory to parse.')
.option('--node <link_to_node>', 'specify the node to unpublish. This will unpublish for the specified label.')
.option('-l --label <label>', 'label to unpublish for')
.action((0, updates_1.withUpdateCheck)(handleUnpublish));
addBaseCommand(connectCommand, 'parse', 'Run Code Connect locally to find any files that have figma connections, then converts them to JSON and outputs to stdout.')
.option('-l --label <label>', 'label to apply to the parsed files')
.option('--include-template-files', '(Deprecated) No longer needed - template files are included by default. Will be removed in a future version.')
.action((0, updates_1.withUpdateCheck)(handleParse));
addBaseCommand(connectCommand, 'create', 'Generate a Code Connect file with boilerplate in the current directory for a Figma node URL')
.argument('<figma-node-url>', 'Figma node URL to create the Code Connect file from')
.action((0, updates_1.withUpdateCheck)(handleCreate));
addBaseCommand(connectCommand, 'migrate', 'Parse Code Connect files and migrate their templates into .figma.js files that can be published directly without parsing.')
.argument('[pattern]', 'glob pattern to match files (optional, uses config if not provided)')
.option('--include-props', 'preserve __props metadata blocks in migrated templates (removed by default). Use if your templates use the React .getProps() or .render() modifiers, or read executeTemplate().metadata.__props from the migrated components')
.option('--javascript', 'output migrated templates as JavaScript (.figma.js) instead of TypeScript (.figma.ts)')
.action((0, updates_1.withUpdateCheck)(handleMigrate));
}
function getAccessToken(cmd) {
return cmd.token ?? process.env.FIGMA_ACCESS_TOKEN;
}
function getAccessTokenOrExit(cmd) {
const token = getAccessToken(cmd);
if (!token) {
(0, logging_1.exitWithError)(`Couldn't find a Figma access token. Please provide one with \`--token <access_token>\` or set the FIGMA_ACCESS_TOKEN environment variable`);
}
return token;
}
function getDir(cmd) {
return cmd.dir ?? process.cwd();
}
function setupHandler(cmd) {
if (cmd.verbose) {
logging_1.logger.setLogLevel(logging_1.LogLevel.Debug);
}
}
function transformDocFromParser(doc, remoteUrl, config) {
let source = doc.source;
if (source) {
try {
const url = new URL(source);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('Invalid URL scheme');
}
}
catch (e) {
source = (0, project_1.getRemoteFileUrl)(source, remoteUrl, config.defaultBranch);
}
}
// TODO This logic is duplicated in parser.ts parseDoc due to some type issues
let figmaNode = doc.figmaNode;
if (config.documentUrlSubstitutions) {
figmaNode = (0, helpers_1.applyDocumentUrlSubstitutions)(figmaNode, config.documentUrlSubstitutions);
}
return {
...doc,
source,
figmaNode,
};
}
async function getCodeConnectObjects(cmd, projectInfo, silent = false, isForMigration = false) {
if (cmd.jsonFile) {
try {
const docsFromJson = JSON.parse(fs_1.default.readFileSync(cmd.jsonFile, 'utf8'));
// Strip internal fields from JSON input
return docsFromJson.map((doc) => {
const { _codeConnectFilePath, ...cleanDoc } = doc;
return cleanDoc;
});
}
catch (e) {
logging_1.logger.error('Failed to parse JSON file:', e);
}
}
let codeConnectObjects = [];
if (projectInfo.config.parser === 'react') {
codeConnectObjects = await getReactCodeConnectObjects(projectInfo, cmd, silent, isForMigration);
}
else if (projectInfo.config.parser === 'html') {
codeConnectObjects = await getCodeConnectObjectsFromParseFn({
parseFn: parser_1.parseHtmlDoc,
fileExtension: 'ts',
projectInfo,
cmd,
silent,
isForMigration,
});
}
else {
const payload = {
mode: 'PARSE',
paths: projectInfo.files,
config: { ...projectInfo.config, skipTemplateHelpers: isForMigration },
verbose: cmd.verbose,
};
try {
const stdout = await (0, parser_executables_1.callParser)(
// We use `as` because the React parser makes the types difficult
// TODO remove once React is an executable parser
projectInfo.config, payload, projectInfo.absPath);
const parsed = parser_executable_types_1.ParseResponsePayload.parse(stdout);
const { hasErrors } = (0, parser_executables_1.handleMessages)(parsed.messages);
if (hasErrors) {
(0, logging_1.exitWithError)('Errors encountered calling parser, exiting');
}
codeConnectObjects = parsed.docs.map((doc) => ({
...transformDocFromParser(doc, projectInfo.remoteUrl, projectInfo.config),
metadata: {
cliVersion: require('../../package.json').version,
},
}));
}
catch (e) {
if (cmd.verbose) {
console.trace(e);
// Don't say to enable verbose if the user has already enabled it.
(0, logging_1.exitWithError)(`Error calling parser: ${e}.`);
}
else {
(0, logging_1.exitWithError)(`Error returned from parser: ${(0, zod_validation_error_1.fromError)(e)}. Try re-running the command with --verbose for more information.`);
}
}
}
const rawTemplateFiles = projectInfo.files.filter((f) => {
// Suffix may be used by HTML / custom parsers
const isPotentialRawTemplate = f.endsWith('.figma.js') ||
f.endsWith('.figma.template.js') ||
f.endsWith('.figma.ts') ||
f.endsWith('.figma.template.ts');
if (!isPotentialRawTemplate)
return false;
try {
return (0, raw_templates_1.isRawTemplate)(fs_1.default.readFileSync(f, 'utf-8'));
}
catch {
return false;
}
});
if (rawTemplateFiles.length > 0) {
// Resolve the label before parsing so language inference works correctly
const resolvedLabel = cmd.label || projectInfo.config.label;
const rawTemplateDocs = rawTemplateFiles.map((file) => {
const doc = (0, raw_templates_1.parseRawFile)(file, resolvedLabel, projectInfo.config, projectInfo.absPath);
// Store the Code Connect file path for migration purposes
doc._codeConnectFilePath = file;
if (!silent || cmd.verbose) {
logging_1.logger.info((0, logging_1.success)(file));
}
return doc;
});
codeConnectObjects = codeConnectObjects.concat(rawTemplateDocs);
}
if (cmd.label || projectInfo.config.label) {
logging_1.logger.info(`Using label "${cmd.label || projectInfo.config.label}"`);
codeConnectObjects.forEach((doc) => {
doc.label = cmd.label || projectInfo.config.label || doc.label;
});
}
if (projectInfo.config.language) {
logging_1.logger.info(`Using language "${projectInfo.config.language}"`);
codeConnectObjects.forEach((doc) => {
doc.language = projectInfo.config.language || doc.language;
});
}
return codeConnectObjects;
}
// React/Storybook and HTML parsers are handled as special cases for now, they
// do not use the parser executable model but instead directly call a function
// in the code base. We may want to transition them to that model in future.
async function getCodeConnectObjectsFromParseFn({
/** The function used to parse a source file into a Code Connect object */
parseFn,
/** Optional function used to resolve imports in a source file */
resolveImportsFn,
/** The file extension to filter for when checking if files should be parsed */
fileExtension,
/** Project info */
projectInfo,
/** The commander command object */
cmd,
/** Silences console output */
silent = false,
/** Whether this parse is for the migrate command */
isForMigration = false, }) {
const codeConnectObjects = [];
const tsProgram = (0, project_1.getTsProgram)(projectInfo);
const { files, remoteUrl, config, absPath } = projectInfo;
for (const file of files.filter((f) => (0, parser_common_1.isFigmaConnectFile)(tsProgram, f, fileExtension))) {
try {
const docs = await (0, parser_common_1.parseCodeConnect)({
program: tsProgram,
file,
config,
parseFn,
resolveImportsFn,
absPath,
parseOptions: {
repoUrl: remoteUrl,
debug: cmd.verbose,
silent,
isForMigration,
},
});
codeConnectObjects.push(...docs);
if (!silent || cmd.verbose) {
logging_1.logger.info((0, logging_1.success)(file));
}
}
catch (e) {
if (!silent || cmd.verbose) {
logging_1.logger.error(`❌ ${file}`);
if (e instanceof parser_common_1.ParserError) {
if (cmd.verbose) {
console.trace(e);
}
else {
logging_1.logger.error(e.toString());
}
if (cmd.exitOnUnreadableFiles) {
logging_1.logger.info('Exiting due to unreadable files');
process.exit(1);
}
}
else {
if (cmd.verbose) {
console.trace(e);
}
else {
logging_1.logger.error(new parser_common_1.InternalError(String(e)).toString());
}
}
}
}
}
return codeConnectObjects;
}
async function getReactCodeConnectObjects(projectInfo, cmd, silent = false, isForMigration = false) {
const codeConnectObjects = await getCodeConnectObjectsFromParseFn({
parseFn: parser_2.parseReactDoc,
resolveImportsFn: parser_2.findAndResolveImports,
fileExtension: ['tsx', 'jsx'],
projectInfo,
cmd,
silent,
isForMigration,
});
const storybookCodeConnectObjects = await (0, convert_1.convertStorybookFiles)({
projectInfo: (0, project_1.getReactProjectInfo)(projectInfo),
isForMigration,
});
const allCodeConnectObjects = codeConnectObjects.concat(storybookCodeConnectObjects);
return allCodeConnectObjects;
}
async function handlePublish(cmd) {
setupHandler(cmd);
// Show deprecation warning if the flag is used
if (cmd.includeTemplateFiles !== undefined) {
logging_1.logger.warn('[Deprecated] The --include-template-files flag is no longer needed because template files are now included by default. ' +
"Please don't use this flag - it will be removed in a future version.");
}
let dir = getDir(cmd);
const projectInfo = (0, filter_project_info_1.filterProjectInfoByFile)(await (0, project_1.getProjectInfo)(dir, cmd.config), cmd.file);
const codeConnectObjects = await getCodeConnectObjects(cmd, projectInfo);
if (codeConnectObjects.length === 0) {
logging_1.logger.warn(`No Code Connect files found in ${dir} - Make sure you have configured \`include\` and \`exclude\` in your figma.config.json file correctly, or that you are running in a directory that contains Code Connect files.`);
process.exit(0);
}
if (cmd.dryRun) {
logging_1.logger.info(`Files that would be published:`);
logging_1.logger.info(codeConnectObjects.map((o) => `- ${o.component} (${o.figmaNode})`).join('\n'));
}
const accessToken = getAccessTokenOrExit(cmd);
if (cmd.skipValidation) {
logging_1.logger.info('Validation skipped');
}
else {
logging_1.logger.info('Validating Code Connect files...');
var start = new Date().getTime();
const valid = await (0, validation_1.validateDocs)(cmd, accessToken, codeConnectObjects, cmd.apiUrl || projectInfo.config.apiUrl);
if (!valid) {
(0, helpers_1.exitWithFeedbackMessage)(1);
}
else {
var end = new Date().getTime();
var time = end - start;
logging_1.logger.info(`All Code Connect files are valid (${time}ms)`);
}
}
if (cmd.dryRun) {
logging_1.logger.info(`Dry run complete`);
process.exit(0);
}
let batchSize;
if (cmd.batchSize) {
batchSize = parseInt(cmd.batchSize, 10);
if (isNaN(batchSize)) {
logging_1.logger.error('Error: failed to parse batch-size. batch-size passed must be a number');
(0, helpers_1.exitWithFeedbackMessage)(1);
}
}
(0, upload_1.upload)({
accessToken,
docs: codeConnectObjects,
batchSize: batchSize,
verbose: cmd.verbose,
apiUrl: cmd.apiUrl || projectInfo.config.apiUrl,
});
}
async function handleUnpublish(cmd) {
setupHandler(cmd);
let dir = getDir(cmd);
if (cmd.dryRun) {
logging_1.logger.info(`Files that would be unpublished:`);
}
let nodesToDeleteRelevantInfo;
const projectInfo = (0, filter_project_info_1.filterProjectInfoByFile)(await (0, project_1.getProjectInfo)(dir, cmd.config), cmd.file);
if (cmd.node) {
if (!cmd.label) {
(0, logging_1.exitWithError)('Label is required when specifying a node');
}
nodesToDeleteRelevantInfo = [{ figmaNode: cmd.node, label: cmd.label }];
}
else {
const codeConnectObjects = await getCodeConnectObjects(cmd, projectInfo);
nodesToDeleteRelevantInfo = codeConnectObjects.map((doc) => ({
figmaNode: doc.figmaNode,
label: cmd.label || projectInfo.config.label || doc.label,
}));
if (cmd.label || projectInfo.config.label) {
logging_1.logger.info(`Using label ${cmd.label || projectInfo.config.label}`);
}
if (cmd.dryRun) {
logging_1.logger.info(`Dry run complete`);
process.exit(0);
}
}
const accessToken = getAccessTokenOrExit(cmd);
(0, delete_docs_1.delete_docs)({
accessToken,
docs: nodesToDeleteRelevantInfo,
apiUrl: cmd.apiUrl || projectInfo.config.apiUrl,
});
}
async function handleParse(cmd) {
setupHandler(cmd);
// Show deprecation warning if the flag is used
if (cmd.includeTemplateFiles !== undefined) {
logging_1.logger.warn('[Deprecated] The --include-template-files flag is no longer needed because template files are now included by default. ' +
"Please don't use this flag - it will be removed in a future version.");
}
const dir = cmd.dir ?? process.cwd();
const projectInfo = (0, filter_project_info_1.filterProjectInfoByFile)(await (0, project_1.getProjectInfo)(dir, cmd.config), cmd.file);
const codeConnectObjects = await getCodeConnectObjects(cmd, projectInfo);
if (cmd.dryRun) {
logging_1.logger.info(`Dry run complete`);
process.exit(0);
}
if (cmd.outFile) {
fs_1.default.writeFileSync(cmd.outFile, JSON.stringify(codeConnectObjects, null, 2));
logging_1.logger.info(`Wrote Code Connect JSON to ${(0, logging_1.highlight)(cmd.outFile)}`);
}
else {
// don't format the output, so it can be piped to other commands
console.log(JSON.stringify(codeConnectObjects, undefined, 2));
}
}
async function handleCreate(nodeUrl, cmd) {
setupHandler(cmd);
const dir = cmd.dir ?? process.cwd();
const projectInfo = await (0, project_1.getProjectInfo)(dir, cmd.config);
if (cmd.dryRun) {
process.exit(0);
}
const accessToken = getAccessTokenOrExit(cmd);
return (0, create_1.createCodeConnectFromUrl)({
accessToken,
// We remove \s to allow users to paste URLs inside quotes - the terminal
// paste will add backslashes, which the quotes preserve, but expected user
// behaviour would be to strip the quotes
figmaNodeUrl: nodeUrl.replace(/\\/g, ''),
outFile: cmd.outFile,
outDir: cmd.outDir,
projectInfo,
cmd,
});
}
/**
* Migrate native parser Code Connect files to parserless. For each found doc:
* - Replace helpers with figma.* versions
* - Migrate v1 syntax to v2 (equivalent but more readable)
* - Format as valid parserless file w/ url="" comment
* - Name collisions are skipped, unless file is from migration (in which case add "_1")
*/
async function handleMigrate(pattern, cmd) {
setupHandler(cmd);
const dir = cmd.dir ?? process.cwd();
let projectInfo = await (0, project_1.getProjectInfo)(dir, cmd.config);
const documentUrlSubstitutions = projectInfo.config.documentUrlSubstitutions;
// Clear documentUrlSubstitutions so we preserve original URLs in migrated templates
projectInfo = {
...projectInfo,
config: {
...projectInfo.config,
documentUrlSubstitutions: {},
},
};
// If a glob pattern is provided, override the project files
if (pattern) {
const { globSync } = require('glob');
const files = globSync(pattern, {
cwd: dir,
absolute: true,
nodir: true,
});
if (files.length === 0) {
(0, logging_1.exitWithError)(`No files found matching pattern: ${pattern}`);
}
logging_1.logger.info(`Found ${files.length} file(s) matching pattern: ${pattern}`);
projectInfo = {
...projectInfo,
files,
};
}
// Parse the files to get Code Connect objects
const allCodeConnectObjects = await getCodeConnectObjects(cmd, projectInfo, false, true);
const codeConnectObjectsByFigmaUrl = (0, migration_helpers_1.groupCodeConnectObjectsByFigmaUrl)(allCodeConnectObjects);
const groupCount = Object.keys(codeConnectObjectsByFigmaUrl).length;
if (groupCount === 0) {
(0, logging_1.exitWithError)('No Code Connect objects found to migrate');
}
logging_1.logger.info(`Found ${groupCount} component(s) to migrate`);
let migratedCount = 0;
let skippedCount = 0;
const errors = [];
const filePathsCreated = new Set();
const gitRootPath = (0, project_2.getGitRepoAbsolutePath)(dir);
const useTypeScript = !cmd.javascript;
for (const [figmaUrl, group] of Object.entries(codeConnectObjectsByFigmaUrl)) {
try {
const hasVariants = group.variants.length > 0;
const representativeDoc = group.main ?? group.variants[0];
if (!representativeDoc?.template) {
logging_1.logger.warn(`Skipping ${figmaUrl}: no template found`);
skippedCount++;
continue;
}
// Determine local source path for output location
let localSourcePath;
if (representativeDoc._codeConnectFilePath) {
// Use the Code Connect file path directly (preferred)
localSourcePath = representativeDoc._codeConnectFilePath;
}
else if (representativeDoc?.source && gitRootPath) {
// Fallback: Convert remote file URL to local path
const relativePath = (0, run_wizard_2.convertRemoteFileUrlToRelativePath)({
remoteFileUrl: representativeDoc.source,
gitRootPath,
dir,
});
if (relativePath) {
localSourcePath = path_1.default.resolve(dir, relativePath);
}
}
const { outputPath, skipped } = hasVariants
? (0, migration_helpers_1.writeVariantTemplateFile)(group, figmaUrl, cmd.outDir, dir, {
localSourcePath,
filePathsCreated,
useTypeScript,
includeProps: cmd.includeProps,
})
: (0, migration_helpers_1.writeTemplateFile)(representativeDoc, cmd.outDir, dir, {
localSourcePath,
filePathsCreated,
includeProps: cmd.includeProps,
useTypeScript,
});
if (skipped) {
logging_1.logger.warn(`Skipping ${outputPath}: file already exists`);
skippedCount++;
}
else {
logging_1.logger.info(`${(0, logging_1.success)('✓')} Migrated${hasVariants ? ' (with variants)' : ''} to ${(0, logging_1.highlight)(outputPath)}`);
migratedCount++;
}
}
catch (error) {
const errorMsg = `Failed to migrate ${figmaUrl}: ${error}`;
logging_1.logger.error(errorMsg);
errors.push(errorMsg);
}
}
if (migratedCount > 0) {
if (cmd.outDir) {
const configFilePath = path_1.default.join(cmd.outDir, 'figma.config.json');
if (fs_1.default.existsSync(configFilePath)) {
logging_1.logger.warn(`Config file already exists at ${(0, logging_1.highlight)(configFilePath)}`);
}
else {
const { language: docLanguage, label } = allCodeConnectObjects[0];
const language = (0, label_language_mapping_1.getInferredLanguageForRaw)(label, docLanguage);
const config = {
include: [useTypeScript ? '**/*.figma.ts' : '**/*.figma.js'],
language,
label,
...(documentUrlSubstitutions && Object.keys(documentUrlSubstitutions).length > 0
? { documentUrlSubstitutions }
: {}),
};
fs_1.default.writeFileSync(configFilePath, JSON.stringify({ codeConnect: config }, null, 2));
logging_1.logger.info(`${(0, logging_1.success)('✓')} Wrote Figma config to ${(0, logging_1.highlight)(configFilePath)}`);
}
}
}
// Summary
console.log('');
logging_1.logger.info(`Migration complete: ${(0, logging_1.success)(`${migratedCount} migrated`)}, ${skippedCount} skipped`);
if (errors.length > 0) {
console.log('');
logging_1.logger.error(`Encountered ${errors.length} error(s):`);
errors.forEach((err) => logging_1.logger.error(` ${err}`));
process.exit(1);
}
if (migratedCount === 0) {
(0, logging_1.exitWithError)('No files were migrated');
}
}
//# sourceMappingURL=connect.js.map