diff --git a/CHANGELOG.md b/CHANGELOG.md index d0e245f..58d81aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,24 @@ +# Code Connect v1.2.0 + +## Features + +### General +- The interactive setup now offers AI support for accurate prop mapping between Figma and code components. Users will now be given the option to use AI during the setup process, which if chosen will assist in creating Code Connect files and attempting to accurately map your code to Figma properties. + + Data is used only for mapping and is not stored or used for training. To learn more, visit https://help.figma.com/hc/en-us/articles/23920389749655-Code-Connect + +### React +- Added support for returning strings or React components from the `example` function, in addition to JSX +- Added `getProps` on `figma.instance()` which can be used to access props of a nested connected component +- Added `render` on `figma.instance()` which can be used to render a nested connected component dynamically +- Added support for including any custom props in the `props` object, that can be accessed with `getProps` in a parent component + +## Fixed + +### HTML +- Case of attribute names is now preserved to support Angular (fixes https://github.com/figma/code-connect/issues/172) +- Fixed a bug with `nestedProps` (fixes https://github.com/figma/code-connect/issues/176) + # Code Connect v1.1.4 (26th September 2024) ## Fixed diff --git a/cli/package.json b/cli/package.json index 4692b3d..8d6d04e 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@figma/code-connect", - "version": "1.1.4", + "version": "1.2.0", "description": "A tool for connecting your design system components in code with your design system in Figma", "keywords": [], "author": "Figma", @@ -38,7 +38,7 @@ "dev": "tsx src/cli.ts", "build": "rm -rf dist && npm run typecheck && tsc", "build:web": "pnpm build", - "build:webpack": "webpack --mode production", + "build:webpack": "cross-env NODE_OPTIONS=\"--max-old-space-size=4096\" webpack --mode production", "test": "npm run test:no-coverage -- --coverage", "test:no-coverage": "cross-env NODE_OPTIONS=--experimental-vm-modules npx jest", "test:fast": "npm run test -- --testPathIgnorePatterns=template_rendering.test.ts --testPathIgnorePatterns=e2e_parse_command_swift.test.ts --testPathIgnorePatterns=e2e_wizard_swift.test.ts", @@ -62,7 +62,7 @@ }, "devDependencies": { "@types/cross-spawn": "^6.0.6", - "@types/jest": "^29.5.5", + "@types/jest": "^29.5.13", "@types/jsdom": "^21.1.7", "@types/lodash": "^4.17.0", "@types/node": "^20.14.0", @@ -73,17 +73,17 @@ "jest": "^29.7.0", "pkg": "^5.8.1", "react": "18.2.0", - "ts-jest": "^29.1.1", + "ts-jest": "^29.2.5", "ts-loader": "^9.5.1", "tsx": "^4.11.0", "webpack": "^5.91.0", "webpack-cli": "^5.1.4" }, "dependencies": { - "@babel/core": "^7.24.7", - "@babel/generator": "^7.24.7", - "@babel/parser": "^7.24.7", - "@babel/types": "^7.24.7", + "@babel/core": "^7.25.2", + "@babel/generator": "^7.25.2", + "@babel/parser": "^7.25.2", + "@babel/types": "^7.25.2", "@storybook/csf-tools": "^7.6.7", "axios": "^1.7.4", @@ -104,6 +104,7 @@ "prettier": "^2.8.8", "prompts": "^2.4.2", "strip-ansi": "^6.0.0", + "ts-morph": "^23.0.0", "typescript": "5.4.2", "zod": "^3.23.6", "zod-validation-error": "^3.2.0" diff --git a/cli/scripts/README.md b/cli/scripts/README.md index dbf07bf..0b7794c 100644 --- a/cli/scripts/README.md +++ b/cli/scripts/README.md @@ -1,6 +1,6 @@ # Code Connect for icons -This folder includes example scripts for generating Code Connect files for your components. This is useful for icons, where you might have tons of icons that you don't want to manually connect one by one. +This folder includes example scripts for generating Code Connect files for your components. This is the recommended way of connecting icons, where you might have tons of icons that you don't want to manually connect one by one. ## Usage diff --git a/cli/scripts/import-icons-as-strings.ts b/cli/scripts/import-icons-as-strings.ts new file mode 100644 index 0000000..027cff0 --- /dev/null +++ b/cli/scripts/import-icons-as-strings.ts @@ -0,0 +1,38 @@ +// change to: "import { client } from '@figma/code-connect'" +import { client } from '../src/react/index_react' +import fs from 'fs' + +async function generateIcons() { + // fetch components from a figma file. If the `node-id` query parameter is used, + // only components within those frames will be included. This is useful if your + // file is very large, as this will speed up the query by a lot + let components = await client.getComponents( + 'https://figma.com/file/ABc123IjkLmnOPq?node-id=41-41', + ) + + // Finds all components starting with 'icon' (this assumes icons are named e.g: 'icon-list') + components = components.filter(({ name }) => { + return name.includes('icon') + }) + + // map each icon to a figma.connect call that looks like this: + // figma.connect('https://figma.com/file/ABc123IjkLmnOPq?node-id=41-41', { + // example: () => "icon-list" + // }) + fs.writeFileSync( + 'icons.figma.tsx', + `\ + import figma from '@figma/code-connect' + + ${components + .map( + (c) => `figma.connect('${c.figmaUrl}', { + example: () => "${c.name}" + })`, + ) + .join('\n')} + `, + ) +} + +generateIcons() diff --git a/cli/src/connect/__test__/e2e/e2e_parse_command/swift_package/Package.resolved b/cli/src/connect/__test__/e2e/e2e_parse_command/swift_package/Package.resolved index fea39ee..16cf882 100644 --- a/cli/src/connect/__test__/e2e/e2e_parse_command/swift_package/Package.resolved +++ b/cli/src/connect/__test__/e2e/e2e_parse_command/swift_package/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "6a346fc32d6af41ee9cbc74e95852610a9fad9416dbc94728135f4316e9fe8ca", + "originHash" : "9937304cb61aff6361579860c5a1d18afa62d808d7e1e39ca036ac6e6e6f72b7", "pins" : [ { "identity" : "swift-argument-parser", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-syntax", "state" : { - "revision" : "515f79b522918f83483068d99c68daeb5116342d", - "version" : "600.0.0-prerelease-2024-08-20" + "revision" : "cb53fa1bd3219b0b23ded7dfdd3b2baff266fd25", + "version" : "600.0.0" } }, { diff --git a/cli/src/connect/__test__/e2e/e2e_parse_command/swift_parser/swift_parser.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/cli/src/connect/__test__/e2e/e2e_parse_command/swift_parser/swift_parser.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2038cb6..26f194d 100644 --- a/cli/src/connect/__test__/e2e/e2e_parse_command/swift_parser/swift_parser.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/cli/src/connect/__test__/e2e/e2e_parse_command/swift_parser/swift_parser.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-syntax", "state" : { - "revision" : "515f79b522918f83483068d99c68daeb5116342d", - "version" : "600.0.0-prerelease-2024-08-20" + "revision" : "cb53fa1bd3219b0b23ded7dfdd3b2baff266fd25", + "version" : "600.0.0" } }, { diff --git a/cli/src/connect/__test__/e2e/e2e_parse_command/swift_wizard/swift_wizard.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/cli/src/connect/__test__/e2e/e2e_parse_command/swift_wizard/swift_wizard.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2038cb6..26f194d 100644 --- a/cli/src/connect/__test__/e2e/e2e_parse_command/swift_wizard/swift_wizard.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/cli/src/connect/__test__/e2e/e2e_parse_command/swift_wizard/swift_wizard.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-syntax", "state" : { - "revision" : "515f79b522918f83483068d99c68daeb5116342d", - "version" : "600.0.0-prerelease-2024-08-20" + "revision" : "cb53fa1bd3219b0b23ded7dfdd3b2baff266fd25", + "version" : "600.0.0" } }, { diff --git a/cli/src/connect/__test__/e2e/e2e_parse_command_html.test.ts b/cli/src/connect/__test__/e2e/e2e_parse_command_html.test.ts index ead7b8d..cbf9687 100644 --- a/cli/src/connect/__test__/e2e/e2e_parse_command_html.test.ts +++ b/cli/src/connect/__test__/e2e/e2e_parse_command_html.test.ts @@ -40,7 +40,7 @@ ${path.join(testPath, 'test-component.figma.ts')}${maybeLabelMessage}`, }, ]) - expect(json[0].template.startsWith('function _fcc_renderHtmlValue')).toBe(true) + expect(json[0].template.startsWith('function _fcc_templateString')).toBe(true) // We don't care about checking the contents of the function as this can change expect( json[0].template.endsWith('export default figma.html``\n'), diff --git a/cli/src/connect/api.ts b/cli/src/connect/api.ts index a03e050..a1bc8ec 100644 --- a/cli/src/connect/api.ts +++ b/cli/src/connect/api.ts @@ -8,6 +8,39 @@ export type EnumValue = | Function | Object +/** + * These types are intended to be returned by figma helper functions for exposing the + * supported output modifiers for that type. There's no implementation for these types, + * they are resolved to primitive types when the `props` object is passed to `example`. + */ +export interface ConnectedComponent { + /** + * Returns the resolved props of the connected component. This is useful for accessing + * the `props` object of a child in a parent context. For example: + * ```ts + * figma.connect("parent", { + * props: { + * iconProps: figma.instance("Icon").getProps(), + * }, + * example: (iconProps) => , + * } + */ + getProps(): T + /** + * Renders the instance with the provided render function. The function is passed the resolved + * `props` of the nested connected component. This is useful for dynamically rendering a child + * component depending on parent context. For example: + * ```ts + * figma.connect("parent", { + * props: { + * icon: figma.instance("Icon").render(({ iconId }) => ), + * }, + * example: ({ icon }) => ` + `, metadata: { __props } } diff --git a/cli/src/react/__test__/expected_templates/PropMappings_indented.expected_template b/cli/src/react/__test__/expected_templates/PropMappings_indented.expected_template index b684958..92d1126 100644 --- a/cli/src/react/__test__/expected_templates/PropMappings_indented.expected_template +++ b/cli/src/react/__test__/expected_templates/PropMappings_indented.expected_template @@ -12,12 +12,17 @@ const size = figma.currentLayer.__properties__.enum('👥 Size', { "Default": 'hug-contents', "Large": undefined, "Wide": 'fit-parent'}) +const state = figma.currentLayer.__properties__.enum('🐣 State', { +"Default": 'Default', +"Active": 'Active', +"Focused": 'Focused'}) const disabled = figma.currentLayer.__properties__.boolean('🎛️ Disabled') const iconLead = figma.currentLayer.__properties__.boolean('🎛️ Icon Lead', { "true": 'icon', "false": undefined}) const label = figma.currentLayer.__properties__.string('🎛️ Label') +const __props = { variant, size, state, disabled, iconLead, label } -export default figma.tsx` { }}${_fcc_renderReactProp('width', size)}${_fcc_renderReactProp('disabled', disabled)}${_fcc_renderReactProp('iconLead', iconLead)}> +export default { ...figma.tsx` { }}${_fcc_renderReactProp('width', size)}${_fcc_renderReactProp('disabled', disabled)}${_fcc_renderReactProp('iconLead', iconLead)}> ${_fcc_renderReactChildren(label)} - ` + `, metadata: { __props } } diff --git a/cli/src/react/__test__/expected_templates/PropsSpread.expected_template b/cli/src/react/__test__/expected_templates/PropsSpread.expected_template index 60d8c5f..bf70ce5 100644 --- a/cli/src/react/__test__/expected_templates/PropsSpread.expected_template +++ b/cli/src/react/__test__/expected_templates/PropsSpread.expected_template @@ -13,5 +13,6 @@ const width = figma.currentLayer.__properties__.enum('👥 Size', { "Large": undefined, "Wide": 'fit-parent'}) const disabled = figma.currentLayer.__properties__.boolean('🎛️ Disabled') +const __props = { variant, width, disabled } -export default figma.tsx`` +export default { ...figma.tsx``, metadata: { __props } } diff --git a/cli/src/react/__test__/expected_templates/PropsSpreadWithDestructuring.expected_template b/cli/src/react/__test__/expected_templates/PropsSpreadWithDestructuring.expected_template index 578c450..4a81800 100644 --- a/cli/src/react/__test__/expected_templates/PropsSpreadWithDestructuring.expected_template +++ b/cli/src/react/__test__/expected_templates/PropsSpreadWithDestructuring.expected_template @@ -13,5 +13,6 @@ const width = figma.currentLayer.__properties__.enum('Size', { "Large": undefined, "Wide": 'fit-parent'}) const disabled = figma.currentLayer.__properties__.boolean('Disabled') +const __props = { variant, width, disabled } -export default figma.tsx`` +export default { ...figma.tsx``, metadata: { __props } } diff --git a/cli/src/react/create.ts b/cli/src/react/create.ts index 11963ce..0eb0d55 100644 --- a/cli/src/react/create.ts +++ b/cli/src/react/create.ts @@ -151,7 +151,7 @@ function generatePropsFromMapping( } const usedFigmaPropsSet = getSetOfAllPropsReferencedInPropMapping(propMapping) - for (const [propName, propDef] of Object.entries(component.componentPropertyDefinitions)) { + for (const [propName, propDef] of Object.entries(component.componentPropertyDefinitions || {})) { if (!usedFigmaPropsSet.has(propName)) { const propMapping = generateSinglePropMappingFromFigmaProp(propName, propDef) if (propMapping) { @@ -161,8 +161,12 @@ function generatePropsFromMapping( } return `{ - // These props were automatically mapped based on your linked code: - ${mappedProps.join(',\n')}, +${ + mappedProps.length + ? `// These props were automatically mapped based on your linked code: + ${mappedProps.join(',\n')},` + : '' +} ${ unmappedProps.length ? `// No matching props could be found for these Figma properties: @@ -291,7 +295,7 @@ export async function createReactCodeConnect( normalizedName, }) - const hasPropMapping = propMapping && Object.keys(propMapping).length > 0 + const hasAnyMappedProps = propMapping && Object.keys(propMapping).length > 0 const importName = sourceFilepath && sourceExport @@ -300,30 +304,38 @@ export async function createReactCodeConnect( : sourceExport : normalizedName - const codeConnect = ` -import React from 'react' -import ${sourceExport === 'default' ? importName : `{ ${importName} }`} from '${importsPath}' -import figma from '@figma/code-connect' + let comment = '' -${ - hasPropMapping - ? `/** - * -- This file was auto-generated by Code Connect -- + if (propMapping && hasAnyMappedProps) { + comment = ` * \`props\` includes a mapping from your code props to Figma properties. * You should check this is correct, and update the \`example\` function - * to return the code example you'd like to see in Figma -*/` - : `/** - * -- This file was auto-generated by Code Connect -- + * to return the code example you'd like to see in Figma` + } else if (propMapping && !hasAnyMappedProps) { + comment = ` + * None of your props could be automatically mapped to Figma properties. + * You should update the \`props\` object to include a mapping from your + * code props to Figma properties, and update the \`example\` function to + * return the code example you'd like to see in Figma` + } else { + comment = ` * \`props\` includes a mapping from Figma properties and variants to * suggested values. You should update this to match the props of your * code component, and update the \`example\` function to return the - * code example you'd like to see in Figma -*/` -} + * code example you'd like to see in Figma` + } + + const codeConnect = ` +import React from 'react' +import ${sourceExport === 'default' ? importName : `{ ${importName} }`} from '${importsPath}' +import figma from '@figma/code-connect' + +/** + * -- This file was auto-generated by Code Connect --${comment} + */ figma.connect(${importName}, "${figmaNodeUrl}", { - props: ${hasPropMapping ? generatePropsFromMapping(component, propMapping) : generateProps(component)}, + props: ${propMapping ? generatePropsFromMapping(component, propMapping) : generateProps(component)}, example: (props) => ${generateExample(importName, payload.reactTypeSignature, propMapping)}, }) ` diff --git a/cli/src/react/external.ts b/cli/src/react/external.ts index e78d99e..00e6012 100644 --- a/cli/src/react/external.ts +++ b/cli/src/react/external.ts @@ -6,6 +6,7 @@ import { nestedPropsType, classNameType, textContentType, + instanceType, } from '../connect/external_types' import { ReactMeta } from './types' @@ -13,10 +14,6 @@ function connectType

(_figmaNodeUrl: string, _meta?: ReactMeta

): void function connectType

(_component: any, _figmaNodeUrl: string, _meta?: ReactMeta

): void function connectType(_component: unknown, _figmaNodeUrl: unknown, _meta?: unknown): void {} -function instanceType(_figmaPropName: string) { - return React.createElement('div') -} - function childrenType(_layers: string | string[]) { return React.createElement('div') } diff --git a/cli/src/react/index_react.ts b/cli/src/react/index_react.ts index c5f9083..f672a39 100644 --- a/cli/src/react/index_react.ts +++ b/cli/src/react/index_react.ts @@ -4,7 +4,13 @@ // conditionally required - see `client` for an example. Reach out in // #feat-code-connect if you're unsure. -import { EnumValue, FigmaConnectAPI, FigmaConnectMeta, ValueOf } from '../connect/api' +import { + EnumValue, + FigmaConnectAPI, + FigmaConnectMeta, + ValueOf, + ConnectedComponent, +} from '../connect/api' import * as figma from './external' import * as StorybookTypes from '../storybook/external' import { FigmaConnectClient } from '../client/figma_client' @@ -12,7 +18,7 @@ import { getClient } from '../connect/index_common' import { ReactMeta } from './types' const _client: FigmaConnectClient = getClient() -const _figma: FigmaConnectAPI & { +const _figma: FigmaConnectAPI & { /** * Defines a code snippet that displays in Figma when a component is selected. This function has two signatures: * - When called with a component reference as the first argument, it will infer metadata such as the import statement @@ -97,6 +103,30 @@ const _figma: FigmaConnectAPI & { * ``` */ nestedProps(layer: string, input: V): V + + /** + * Maps a Figma instance-swap property for the connected component. This prop is replaced with + * a nested connected component matching the Figma instance when viewed in Dev Mode. For example: + * ```ts + * props: { + * icon: figma.instance('Icon'), + * } + * ``` + * Would show the nested example for the component passed to the "Icon" property in Figma. + * + * If the nested connected component returns something other than JSX in its `example` function, + * you can pass a type parameter to `instance` to specify the return type. For example: + * ```ts + * props: { + * icon: figma.instance('Icon') + * } + * ``` + * + * @param figmaPropName The name of the property on the Figma component + * @returns {ConnectedComponent} The connected component for the instance. The return value + * can be modified with helper functions such as `getProps` and `render` + */ + instance(figmaPropName: string): T } = figma export { _figma as figma, _client as client } diff --git a/cli/src/react/parser.ts b/cli/src/react/parser.ts index 5d05e46..88c36ee 100644 --- a/cli/src/react/parser.ts +++ b/cli/src/react/parser.ts @@ -33,7 +33,7 @@ import { * @param node AST node * @returns */ -function findJSXElement( +export function findJSXElement( node: ts.Node, ): ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment | undefined { if (ts.isJsxElement(node) || ts.isJsxFragment(node) || ts.isJsxSelfClosingElement(node)) { @@ -189,228 +189,8 @@ function getSourceFilesOfImportedIdentifiers(parserContext: ParserContext, _iden return imports } -function findCallExpression(node: ts.Node): ts.CallExpression { - if (ts.isCallExpression(node)) { - return node - } else { - return ts.forEachChild(node, findCallExpression) as ts.CallExpression - } -} - -/** - * Traverses the AST and finds the first arrow function declaration - * @param node - * @returns - */ -function findDescendantArrowFunction(node: ts.Node): ts.ArrowFunction { - if (ts.isArrowFunction(node)) { - return node - } else { - return ts.forEachChild(node, findDescendantArrowFunction) as ts.ArrowFunction - } -} - -/** - * Traverses the AST and finds the first function expression - * (handles forwardRef:d component declarations) - * @param node - * @returns - */ -function findDescendantFunctionExpression(node: ts.Node): ts.FunctionExpression { - if (ts.isFunctionExpression(node)) { - return node - } else { - return ts.forEachChild(node, findDescendantFunctionExpression) as ts.FunctionExpression - } -} - -function getDeclarationFromSymbol(symbol: ts.Symbol) { - if (!symbol.declarations || !symbol.declarations.length) { - throw new Error(`No declarations found in symbol ${symbol.escapedName}`) - } - return symbol.declarations[0] -} - -function resolveIdentifierSymbol(identifier: ts.Identifier, checker: ts.TypeChecker) { - const componentSymbol = checker.getSymbolAtLocation(identifier) - - if (!componentSymbol) { - throw new Error(`Symbol not found at location ${identifier.getText()}`) - } - - const componentDeclaration = getDeclarationFromSymbol(componentSymbol) - - if (ts.isImportSpecifier(componentDeclaration) || ts.isImportClause(componentDeclaration)) { - let importDeclaration = findParentImportDeclaration(componentDeclaration) - - if (!importDeclaration) { - throw new Error('No import statement found for component') - } - - // The component should be imported from another file, we need to follow the - // aliased symbol to get the correct function definition - if (componentSymbol.flags & ts.SymbolFlags.Alias) { - return checker.getAliasedSymbol(componentSymbol) - } - } - - return componentSymbol -} - -/** - * This handles a number of cases for finding the function expression of an exported symbol: - * - A function that is exported directly (function declaration) - * - A function that is exported as a variable (arrow function) - * - A function expression wrapped in a forwardRef - * - A function expression wrapped in a memo call - * @param declaration an exported function declaration - * @param checker - */ -function findFunctionExpression( - declaration: ts.Declaration, - checker: ts.TypeChecker, - sourceFile: ts.SourceFile, -) { - let functionExpression: ts.FunctionExpression | ts.ArrowFunction - - // Example: export function Button() {} - if (ts.isFunctionDeclaration(declaration)) { - return declaration - } - - // Example: export const Button = forwardRef(function Button() {}) - if ((functionExpression = findDescendantFunctionExpression(declaration))) { - return functionExpression - } - - // Example: export const Button = () => {} - if ((functionExpression = findDescendantArrowFunction(declaration))) { - return functionExpression - } - - // Example: export const MemoButton = memo(Button) - // Example: export const ButtonWithRef = forwardRef(Button) - if (ts.isVariableDeclaration(declaration)) { - let componentSymbol: ts.Symbol | undefined = undefined - - // Example: export const SomeAlias = Button - if (declaration.initializer && ts.isIdentifier(declaration.initializer)) { - componentSymbol = resolveIdentifierSymbol(declaration.initializer, checker) - } else { - const callExpression = findCallExpression(declaration) - const component = callExpression.arguments[0] - // follow the symbol to its declaration, then try to find the function expression again - componentSymbol = checker.getSymbolAtLocation(component) - } - - if (!componentSymbol) { - throw new Error('No symbol found at location') - } - - const componentDeclaration = getDeclarationFromSymbol(componentSymbol) - return findFunctionExpression(componentDeclaration, checker, sourceFile) - } - - throw new ParserError('Failed to find function expression for component', { - sourceFile, - node: declaration, - }) -} - export type ComponentTypeSignature = Record -/** - * Extracts the type signature from the interface of a React component as a map of - * keys to strings representing the type of that property. Appends a '?' to the value - * if it's optional. Example: - * { - * name: string - * disabled: ?boolean - * } - * @param symbol the symbol of the function declaration of the component (in the source file) - * @param sourceFile the source file with the component definition - * @param checker - * @returns - */ -export function extractComponentTypeSignature( - symbol: ts.Symbol, - checker: ts.TypeChecker, - sourceFile: ts.SourceFile, -) { - const declaration = getDeclarationFromSymbol(symbol) - - const ReactInterfaceNames = ['HTMLAttributes', 'Attributes', 'AriaAttributes', 'DOMAttributes'] - - let propsType: ts.Type | null = null - - /** - * Special case for forwardRef as type is passed as generic arg - */ - if (ts.isVariableDeclaration(declaration)) { - const callExpression = findCallExpression(declaration) - - if ( - (callExpression?.expression.getText() === 'forwardRef' || - callExpression?.expression.getText() === 'React.forwardRef') && - callExpression.typeArguments && - callExpression.typeArguments.length === 2 - ) { - propsType = checker.getTypeAtLocation(callExpression.typeArguments[1]) - } - } - - if (!propsType) { - const functionExpression = findFunctionExpression(declaration, checker, sourceFile) - propsType = checker.getTypeAtLocation(functionExpression.parameters[0]) - } - - if (!propsType) { - throw new InternalError( - `Failed to extract props from component declaration: ${declaration.getText()}`, - ) - } - - const propsMap: ComponentTypeSignature = {} - const props = propsType.getProperties() - for (const prop of props) { - // Skip props that are inherited from React types - // NOTE: this is pretty naive, in the future we might want to - // actually traverse the AST to determine if the types are declared - // in the React namespace - - const parent = getDeclarationFromSymbol(prop).parent - if (ts.isInterfaceDeclaration(parent)) { - const parentInterfaceName = parent.name.getText() - if ( - ReactInterfaceNames.includes(parentInterfaceName) || - (parent.heritageClauses && parent.heritageClauses[0].getText().includes('HTMLAttributes')) - ) { - continue - } - } - - if (!prop.valueDeclaration) { - throw new Error(`No valueDeclaration for symbol ${prop.escapedName}`) - } - - const propType = checker.getTypeOfSymbolAtLocation(prop, prop.valueDeclaration) - let propTypeString = checker.typeToString(propType) - - if (propType.isUnion()) { - // Get the types of the union - const unionTypes = propType.types - - // Map each type to its string representation and join them with a comma - propTypeString = unionTypes.map((type) => checker.typeToString(type)).join(' | ') - } - - propsMap[prop.name] = - prop.flags & ts.SymbolFlags.Optional ? `?${propTypeString}` : propTypeString - } - - return propsMap -} - /** * Extract metadata about the referenced React component. Used by both the * Code Connect and Storybook commands. @@ -535,7 +315,7 @@ export async function parseComponentMetadata( } /** - * Parses the render function passed to `figma.connect()`, extracting the code and + * Parses a render function and ouputs a template string, extracting the code and * any import statements matching the JSX elements used in the function body * * @param exp A function or arrow function expression @@ -544,14 +324,14 @@ export async function parseComponentMetadata( * * @returns The code of the render function and a list of imports */ -export function parseRenderFunction( +export function parseRenderFunctionExpression( exp: ts.ArrowFunction | ts.FunctionExpression | ts.FunctionDeclaration, parserContext: ParserContext, propMappings?: PropMappings, ) { const { sourceFile } = parserContext - let exampleCode: string + let renderFunctionCode: string if (exp.parameters.length > 1) { throw new ParserError( @@ -562,8 +342,7 @@ export function parseRenderFunction( const propsParameter = exp.parameters[0] - // Keep track of any props which are referenced in the example so that we can - // insert the appropriate `figma.properties` call in the JS template + // Keep track of any props which are referenced in the example const referencedProps = new Set() const createPropPlaceholder = makeCreatePropPlaceholder({ @@ -687,7 +466,7 @@ export function parseRenderFunction( if (jsx && (!block || (block && block.statements.length <= 1))) { // The function body is a single JSX element - exampleCode = printer.printNode(ts.EmitHint.Unspecified, jsx, sourceFile) + renderFunctionCode = printer.printNode(ts.EmitHint.Unspecified, jsx, sourceFile) nestable = true } else if (block) { // The function body has more stuff in it, so we wrap the body in a function @@ -706,7 +485,7 @@ export function parseRenderFunction( block, ) const printer = ts.createPrinter() - exampleCode = printer.printNode(ts.EmitHint.Unspecified, functionExpression, sourceFile) + renderFunctionCode = printer.printNode(ts.EmitHint.Unspecified, functionExpression, sourceFile) } else { throw new ParserError( `Expected a single JSX element or a block statement in the render function, got ${exp.getText()}`, @@ -714,9 +493,54 @@ export function parseRenderFunction( ) } - let templateCode = '' + renderFunctionCode = replacePropPlaceholders(renderFunctionCode) + + // Escape backticks from the example code, as otherwise those would terminate the `figma.tsx` template literal + renderFunctionCode = renderFunctionCode.replace(/`/g, '\\`') + + // Finally, output the render function as a figma.tsx call + const figmaTsxCall = `figma.tsx\`${renderFunctionCode}\`` + + // Find all JSX elements in the function body and extract their import + // statements + const jsxTags = findDescendants( + exp, + (element) => ts.isJsxElement(element) || ts.isJsxSelfClosingElement(element), + ) as (ts.JsxElement | ts.JsxSelfClosingElement)[] + const imports = getSourceFilesOfImportedIdentifiers(parserContext, jsxTags.map(getTagName)) - exampleCode = replacePropPlaceholders(exampleCode) + return { + code: figmaTsxCall, + imports, + nestable, + referencedProps, + } +} + +/** + * Parses the render function passed to `figma.connect()`, extracting the code and + * any import statements matching the JSX elements used in the function body + * + * @param exp A function or arrow function expression + * @param parserContext Parser context + * @param propMappings Prop mappings object as returned by parseProps + * + * @returns The code of the render function and a list of imports + */ +export function parseJSXRenderFunction( + exp: ts.ArrowFunction | ts.FunctionExpression | ts.FunctionDeclaration, + parserContext: ParserContext, + propMappings?: PropMappings, +) { + const { sourceFile } = parserContext + + const { code, imports, nestable, referencedProps } = parseRenderFunctionExpression( + exp, + parserContext, + propMappings, + ) + + let templateCode = '' // Generate the template code // Inject React-specific template helper functions @@ -734,20 +558,81 @@ export function parseRenderFunction( sourceFile, }) - // Escape backticks from the example code, as otherwise those would terminate the `figma.tsx` template literal - exampleCode = exampleCode.replace(/`/g, '\\`') + const includeMetadata = propMappings && Object.keys(propMappings).length > 0 // Finally, output the example code - templateCode += `export default figma.tsx\`${exampleCode}\`\n` + templateCode += includeMetadata + ? `export default { ...${code}, metadata: { __props } }\n` + : `export default ${code}\n` - // Find all JSX elements in the function body and extract their import - // statements - const jsxTags = findDescendants( + return { + code: templateCode, + imports, + nestable, + } +} + +/** + * Parses the render function for a value (i.e. example which returns a string or React + * component reference, not JSX) passed to `figma.connect()` + */ +export function parseValueRenderFunction( + exp: ts.ArrowFunction | ts.FunctionExpression | ts.FunctionDeclaration, + parserContext: ParserContext, + propMappings?: PropMappings, +) { + const { sourceFile } = parserContext + const printer = ts.createPrinter() + if (!exp.body) { + throw new ParserError('Expected a function body', { + sourceFile: parserContext.sourceFile, + node: exp, + }) + } + + let exampleCode = printer.printNode(ts.EmitHint.Unspecified, exp.body, sourceFile) + const nestable = true + + let templateCode = '' + // Generate the template code + // Inject React-specific template helper functions + templateCode = getParsedTemplateHelpersString() + '\n\n' + + // Require the template API + templateCode += `const figma = require('figma')\n\n` + + // Then we output `const propName = figma.properties.('propName')` calls + // for each referenced prop, so these are accessible to the template code. + templateCode += getReferencedPropsForTemplate({ + propMappings, exp, - (element) => ts.isJsxElement(element) || ts.isJsxSelfClosingElement(element), - ) as (ts.JsxElement | ts.JsxSelfClosingElement)[] + sourceFile, + }) - const imports = getSourceFilesOfImportedIdentifiers(parserContext, jsxTags.map(getTagName)) + // Escape backticks from the example code + exampleCode = exampleCode.replace(/`/g, '\\`') + + let imports: { + statement: string + file: string + }[] = [] + + const includeMetadata = propMappings && Object.keys(propMappings).length > 0 + + if (ts.isStringLiteral(exp.body)) { + // The value is a string, which is already wrapped in quotes + templateCode += includeMetadata + ? `export default { ...figma.value(${exampleCode}), metadata: { __props } }\n` + : `export default figma.value(${exampleCode})\n` + } else if (ts.isIdentifier(exp.body)) { + // The value is an identifier, i.e. a React component reference + const value = `_fcc_reactComponent("${exp.body.getText()}")` + const preview = `_fcc_renderPropValue(${value})` + templateCode += includeMetadata + ? `export default { ...figma.value(${value}, ${preview}), metadata: { __props } }\n` + : `export default figma.value(${value}, ${preview})\n` + imports = getSourceFilesOfImportedIdentifiers(parserContext, [exp.body.getText()]) + } return { code: templateCode, @@ -1013,7 +898,11 @@ export async function parseReactDoc( : undefined const props = propsArg ? parsePropsObject(propsArg, parserContext) : undefined - const render = exampleArg ? parseRenderFunction(exampleArg, parserContext, props) : undefined + const render = exampleArg + ? findJSXElement(exampleArg) + ? parseJSXRenderFunction(exampleArg, parserContext, props) + : parseValueRenderFunction(exampleArg, parserContext, props) + : undefined const variant = variantArg ? parseVariant(variantArg, sourceFile, checker) : undefined const links = linksArg ? parseLinks(linksArg, parserContext) : undefined diff --git a/cli/src/react/parser_template_helpers.ts b/cli/src/react/parser_template_helpers.ts index 6ba05c1..9d95f6c 100644 --- a/cli/src/react/parser_template_helpers.ts +++ b/cli/src/react/parser_template_helpers.ts @@ -5,6 +5,9 @@ // `new Function()` declare const figma: { tsx: (template: TemplateStringsArray, ...args: any[]) => string } +declare type CodeSection = { type: 'CODE'; code: string } +declare type InstanceSection = { type: 'INSTANCE' } +declare type ErrorSection = { type: 'ERROR' } // This file contains helper functions which hare included in the React template // example - i.e. they are run on the client side, but are not part of the @@ -31,6 +34,7 @@ export type FCCValue = | typeof _fcc_identifier | typeof _fcc_object | typeof _fcc_templateString + | typeof _fcc_reactComponent > export function _fcc_jsxElement($value: string) { @@ -69,9 +73,16 @@ export function _fcc_templateString($value: string) { } as const } +export function _fcc_reactComponent($value: string) { + return { + $value, + $type: 'react-component', + } as const +} + // Render a prop value passed to an object literal based on its type. // for example: +}) + +// renders in Dev Mode + +``` + +### Icons as React Components +If your icons are passed as React components, you can return a React component instead of a JSX element in your icon Code Connect file. + +```tsx +// icon +figma.connect("my-icon-url", { + example: () => IconHeart +}) + +// parent +figma.connect("my-button-url, { + props: { + Icon: figma.instance("InstanceSwapPropName") + }, + example: ({ Icon }) =>