@figma/code-connect
Version:
A tool for connecting your design system components in code with your design system in Figma
324 lines • 13.7 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.InternalError = exports.ParserError = void 0;
exports.getPositionInSourceFile = getPositionInSourceFile;
exports.makeCreatePropPlaceholder = makeCreatePropPlaceholder;
exports.visitPropReferencingNode = visitPropReferencingNode;
exports.getReferencedPropsForTemplate = getReferencedPropsForTemplate;
exports.isFigmaConnectFile = isFigmaConnectFile;
exports.isFigmaConnectCall = isFigmaConnectCall;
exports.findDescendants = findDescendants;
exports.parseLinks = parseLinks;
exports.parseVariant = parseVariant;
exports.parseImports = parseImports;
exports.parseCodeConnect = parseCodeConnect;
const typescript_1 = __importDefault(require("typescript"));
const intrinsics_1 = require("./intrinsics");
const logging_1 = require("../common/logging");
const console_1 = require("console");
const compiler_1 = require("../typescript/compiler");
function getPositionInSourceFile(node, sourceFile) {
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
}
class ParserError extends Error {
constructor(message, context) {
super(message);
this.name = 'ParserError';
this.sourceFileName = context?.sourceFile.fileName || '';
this.sourceFilePosition =
context && context.node
? getPositionInSourceFile(context.node, context.sourceFile) || null
: null;
}
toString() {
let msg = `${(0, logging_1.highlight)((0, console_1.error)(this.name))}: ${this.message}\n`;
if (this.sourceFileName && this.sourceFilePosition) {
msg += ` -> ${(0, logging_1.reset)(this.sourceFileName)}:${this.sourceFilePosition.line}:${this.sourceFilePosition.character}\n`;
}
return msg;
}
toDebugString() {
return this.toString() + `\n ${this.stack}`;
}
}
exports.ParserError = ParserError;
class InternalError extends ParserError {
constructor(message) {
super(message);
this.name = 'InternalError';
}
}
exports.InternalError = InternalError;
/**
* Factory to create a function that is used to create `__PROP__(propName)`
* function nodes, which are used to replace prop references and ultimately
* replaced.
*
* @returns Function to create `__PROP__(propName)` function nodes
*/
function makeCreatePropPlaceholder({ propMappings, referencedProps, sourceFile, }) {
return function ({ name, node, wrapInJsxExpression = false, }) {
let propReferenceName = name;
// for nested prop references like `nested.prop`, we only want to look for
// the prop mapping of `nested`, but include the full `nested.prop` in the
// __PROP__ call
if (name.includes('.')) {
propReferenceName = name.split('.')[0];
}
// if prop mappings exist, check that the prop reference is in the mappings
if (propMappings) {
const mappedProp = propMappings[propReferenceName];
if (!mappedProp) {
throw new ParserError(`Could not find prop mapping for ${propReferenceName} in the props object`, {
sourceFile,
node,
});
}
}
referencedProps.add(propReferenceName);
const callExpression = typescript_1.default.factory.createCallExpression(typescript_1.default.factory.createIdentifier('__PROP__'), undefined, [typescript_1.default.factory.createStringLiteral(name)]);
if (wrapInJsxExpression) {
return typescript_1.default.factory.createJsxExpression(undefined, callExpression);
}
else {
return callExpression;
}
};
}
/**
* TS AST visitor function for use with example functions, which replaces
* references to the `props` argument in various forms in the example code with
* `__PROP__(propName)` placeholders (created with a createPropPlaceholder
* function).
*
* This is called when transforming the TS AST, and allows us to normalise the
* different forms of supported prop references into a single representation,
* which we can then handle consistently (currently we replace the placeholders,
* using a regex).
*
* @returns Placeholder node, or undefined if the node is not a supported prop
* reference (which results in no transformation)
*/
function visitPropReferencingNode({ propsParameter, node, createPropPlaceholder, useJsx, }) {
// `props.` notation
if (typescript_1.default.isIdentifier(propsParameter.name) &&
typescript_1.default.isPropertyAccessExpression(node) &&
node.expression.getText().startsWith(propsParameter.name.getText())) {
// nested notation e.g `props.nested.prop`
if (typescript_1.default.isPropertyAccessExpression(node.expression)) {
let current = node;
const parts = [];
// Build the property name by traversing up the chain
while (typescript_1.default.isPropertyAccessExpression(current)) {
parts.unshift(current.name.getText());
current = current.expression;
}
// Join the parts together to form the full property name
const name = parts.join('.');
return createPropPlaceholder({ name, node });
}
const name = node.name.getText();
return createPropPlaceholder({ name, node });
}
// `props[""]` notation
if (typescript_1.default.isIdentifier(propsParameter.name) &&
typescript_1.default.isElementAccessExpression(node) &&
node.expression.getText().startsWith(propsParameter.name.getText()) &&
typescript_1.default.isStringLiteral(node.argumentExpression)) {
const name = (0, compiler_1.stripQuotesFromNode)(node.argumentExpression);
return createPropPlaceholder({ name, node });
}
// object destructuring references
if (typescript_1.default.isObjectBindingPattern(propsParameter.name)) {
const isValidNode = useJsx
? typescript_1.default.isJsxExpression(node)
: typescript_1.default.isPropertyAccessExpression(node) || typescript_1.default.isIdentifier(node);
const target = useJsx ? node.expression : node;
if (isValidNode &&
target &&
propsParameter.name.elements.find((el) => target?.getText().startsWith(el.name.getText()))) {
const name = target.getText();
return createPropPlaceholder({ name, node, wrapInJsxExpression: useJsx });
}
}
return undefined;
}
/**
* Get template code to create variables referencing the props in the prop
* mappings. This converts the prop mappings into JS calls like `const propName
* = figma.properties.string('Prop Name')`, which can then be prepended to the
* template code.
*
* @returns Template code string
*/
function getReferencedPropsForTemplate({ propMappings = {}, }) {
let templateCode = '';
if (Object.keys(propMappings).length > 0) {
for (const prop in propMappings) {
const propMapping = propMappings[prop];
templateCode += `const ${prop} = ${(0, intrinsics_1.valueToString)(propMapping)}\n`;
}
templateCode += `const __props = {}\n`;
Object.keys(propMappings).forEach((prop) => {
// If trying to render prop resulted in an error (e.g. layer was not found
// because it was invisible), don't include it in the __props object as
// this will result in a runtime error.
//
// TODO Note that this can also happen if there is a typo in the prop name
// of a nested prop, because we don't validate these at publish time,
// which would be confusing. Perhaps we should have a way to show a
// warning but not an error to the user.
templateCode += `if (${prop} && ${prop}.type !== 'ERROR') {
__props["${prop}"] = ${prop}
}\n`;
});
templateCode += `\n`;
}
return templateCode;
}
/**
* Checks if a file contains Code Connect by looking for the `figma.connect()` function call
*/
function isFigmaConnectFile(program, file, extension) {
const allowedExtensions = Array.isArray(extension) ? extension : [extension];
const fileExtension = file.split('.').pop();
// If the file has no extension, we can't determine if it's a Code Connect file
if (!fileExtension) {
return false;
}
// If the file extension is not in the list of supported extensions, it's not a Code Connect file
if (!allowedExtensions.includes(fileExtension)) {
return false;
}
const sourceFile = program.getSourceFile(file);
if (!sourceFile) {
throw new InternalError(`Could not find source file for ${file}`);
}
return (findDescendants(sourceFile, (node) => {
if (isFigmaConnectCall(node, sourceFile)) {
return true;
}
return false;
}).length > 0);
}
/**
* Checks if an AST node is a `figma.connect()` call
*
* @param node AST node
* @param sourceFile Source file
* @returns True if the node is a `figma.connect()` call
*/
function isFigmaConnectCall(node, sourceFile) {
return (typescript_1.default.isCallExpression(node) && node.expression.getText(sourceFile).includes(intrinsics_1.FIGMA_CONNECT_CALL));
}
function findDescendants(node, cb) {
const matches = [];
function visit(node) {
if (cb(node)) {
matches.push(node);
}
typescript_1.default.forEachChild(node, visit);
}
visit(node);
return matches;
}
/**
* Parses the `links` field of a `figma.connect()` call
*
* @param linksArray an ArrayLiteralExpression
* @param parserContext Parser context
* @returns An array of link objects
*/
function parseLinks(linksArray, parserContext) {
const { sourceFile } = parserContext;
const links = [];
for (const element of linksArray.elements) {
(0, compiler_1.assertIsObjectLiteralExpression)(element, sourceFile, `'links' must be an array literal with objects of the format { name: string, url: string }`);
const name = (0, compiler_1.parsePropertyOfType)({
objectLiteralNode: element,
propertyName: 'name',
predicate: typescript_1.default.isStringLiteral,
parserContext,
required: true,
errorMessage: "The 'name' property must be a string literal",
});
const url = (0, compiler_1.parsePropertyOfType)({
objectLiteralNode: element,
propertyName: 'url',
predicate: typescript_1.default.isStringLiteral,
parserContext,
required: true,
errorMessage: "The 'url' property must be a string literal",
});
if (name && url) {
links.push({ name: (0, compiler_1.stripQuotesFromNode)(name), url: (0, compiler_1.stripQuotesFromNode)(url) });
}
}
return links;
}
function parseVariant(variantMap, sourceFile, checker) {
return (0, compiler_1.convertObjectLiteralToJs)(variantMap, sourceFile, checker, (valueNode) => {
if (!typescript_1.default.isObjectLiteralElement(valueNode) &&
!typescript_1.default.isStringLiteral(valueNode) &&
!typescript_1.default.isNumericLiteral(valueNode) &&
valueNode.kind !== typescript_1.default.SyntaxKind.TrueKeyword &&
valueNode.kind !== typescript_1.default.SyntaxKind.FalseKeyword) {
throw new ParserError(`Invalid value for variant, got: ${valueNode.getText()}`, {
node: valueNode,
sourceFile,
});
}
});
}
/**
* Parses the `imports` field of a `figma.connect()` call
*
* @param importsArray an ArrayLiteralExpression
* @param parserContext Parser context
* @returns An array of link objects
*/
function parseImports(importsArray, parserContext) {
const { sourceFile } = parserContext;
const imports = [];
for (const element of importsArray.elements) {
(0, compiler_1.assertIsStringLiteral)(element, sourceFile, `'imports' must be an array literal with strings`);
imports.push((0, compiler_1.stripQuotesFromNode)(element));
}
return imports;
}
async function parseCodeConnect({ program, file, config, absPath, parseFn, resolveImportsFn, parseOptions = {}, }) {
const sourceFile = program.getSourceFile(file);
if (!sourceFile) {
throw new InternalError(`Could not find source file for ${file}`);
}
const parserContext = {
checker: program.getTypeChecker(),
sourceFile,
resolvedImports: resolveImportsFn ? resolveImportsFn(program, sourceFile) : {},
config,
absPath,
};
const codeConnectObjects = [];
const nodes = [parserContext.sourceFile];
while (nodes.length > 0) {
const node = nodes.shift();
if (isFigmaConnectCall(node, parserContext.sourceFile)) {
const doc = await parseFn(node, parserContext, parseOptions);
if (doc) {
codeConnectObjects.push(doc);
}
}
nodes.push(...node.getChildren(parserContext.sourceFile));
}
if (codeConnectObjects.length === 0) {
throw new ParserError(`Didn't find any calls to figma.connect()`, {
sourceFile: parserContext.sourceFile,
node: parserContext.sourceFile,
});
}
return codeConnectObjects;
}
//# sourceMappingURL=parser_common.js.map