= {}
+
+ for (const prop of props) {
+ const propString = getPropString(prop, signatureSourceFile)
+ if (propString) {
+ result[prop.getName()] = propString
+ }
}
- if (!statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) {
- continue
+ tsMorphProject.removeSourceFile(signatureSourceFile)
+ return result
+ }
+}
+
+function getPropString(prop: Symbol, sourceFile: SourceFile) {
+ if (IGNORE_REACT_PROPS.includes(prop.getName())) {
+ return null
+ }
+
+ const [declaration] = prop.getDeclarations()
+
+ const parent = declaration.getParentOrThrow().compilerNode
+
+ if (ts.isInterfaceDeclaration(parent)) {
+ const parentInterfaceName = parent.name.getText()
+
+ // Skip props that are inherited from React types
+ if (REACT_INTERFACE_NAMES.includes(parentInterfaceName)) {
+ return null
}
- const name = ts.isFunctionDeclaration(statement)
- ? statement.name?.text
- : statement.declarationList.declarations?.[0].name.getText(sourceFile)
-
- if (
- name === nameToFind ||
- (nameToFind === 'default' &&
- statement.modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword))
- ) {
- const symbol = ts.isFunctionDeclaration(statement)
- ? statement.name && checker.getSymbolAtLocation(statement.name)
- : checker.getSymbolAtLocation(statement.declarationList.declarations[0].name)
- if (!symbol) {
- throw new Error(`Could not find symbol for ${name}`)
+ if (parent.heritageClauses && parent.heritageClauses[0].getText().includes('HTMLAttributes')) {
+ // If interface extends HTMLAttributes, only allow props defined in interface
+ // TODO will fail if extends multiple interfaces and prop defined in one of them - could recursively check
+ if (!parent.members.some((m) => m.name?.getText() === prop.getName())) {
+ return null
}
+ }
+ }
- const signature = extractComponentTypeSignature(symbol, checker, sourceFile)
- if (!signature) {
- throw new Error(`Could not find signature for ${name}`)
- }
+ const compilerProp = prop.compilerSymbol
- return signature
- }
+ const checker = tsMorphProject.getTypeChecker().compilerObject
+
+ const propType = prop.getTypeAtLocation(sourceFile).compilerType
+ 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 |
+ propTypeString = unionTypes.map((type) => checker.typeToString(type)).join(' | ')
}
- throw new Error('No function or variable signatures found')
+ return compilerProp.flags & ts.SymbolFlags.Optional
+ ? `?${propTypeString.replace(/undefined \| /g, '')}`
+ : propTypeString
+}
+
+function findTsConfigPath(dir: string): string | undefined {
+ let tsConfigPath: string | undefined = undefined
+
+ findUp.sync(
+ (currentDir) => {
+ const pathToTry = path.join(currentDir, 'tsconfig.json')
+
+ if (fs.existsSync(pathToTry)) {
+ tsConfigPath = pathToTry
+ return findUp.stop
+ }
+ },
+ { cwd: dir },
+ )
+
+ return tsConfigPath
}
diff --git a/cli/src/html/external.ts b/cli/src/html/external.ts
index 57b3021..17ea82d 100644
--- a/cli/src/html/external.ts
+++ b/cli/src/html/external.ts
@@ -11,16 +11,16 @@ import { HtmlMeta } from './types'
function connectType(_figmaNodeUrl: string, _meta?: HtmlMeta
): void {}
-function instanceType(_figmaPropName: string): HtmlTemplateString {
+function childrenType(_layers: string | string[]): HtmlTemplateString {
return {
__tag: 'HtmlTemplateString',
}
}
-function childrenType(_layers: string | string[]): HtmlTemplateString {
+export function instanceType(_figmaPropName: string): T {
return {
__tag: 'HtmlTemplateString',
- }
+ } as T
}
export {
diff --git a/cli/src/html/index_html.ts b/cli/src/html/index_html.ts
index 0509ac1..60f5dcf 100644
--- a/cli/src/html/index_html.ts
+++ b/cli/src/html/index_html.ts
@@ -12,7 +12,7 @@ import { getClient } from '../connect/index_common'
import { HtmlMeta } 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.
*
diff --git a/cli/src/html/parser.ts b/cli/src/html/parser.ts
index 3444c20..8c57d74 100644
--- a/cli/src/html/parser.ts
+++ b/cli/src/html/parser.ts
@@ -1,5 +1,4 @@
import ts, { isTemplateExpression, SyntaxKind } from 'typescript'
-import { getRemoteFileUrl } from '../connect/project'
import {
stripQuotesFromNode,
parsePropertyOfType,
@@ -49,9 +48,9 @@ function getHtmlTaggedTemplateNode(node: ts.Node): ts.TaggedTemplateExpression |
* extract information which is used in generating the template:
* 1. A dictionary of template placeholders which correspond to HTML attribute
* values. The key is the placeholder index, and the value is the attribute
- * name. We use this to render attribute placeholders appropriately (either
- * in quotes for strings, or as either the attribute name or nothing for
- * booleans).
+ * name. The attribute name is unused (it used to be used in the output, but
+ * we need to preserve case to support Angular, which JSDOM can't do unless
+ * you use XHTML mode, and that doesn't support attributes without a value).
* 2. Whether the template is "nestable" or not. A template is considered
* nestable if it has only one top level element.
*
@@ -62,9 +61,9 @@ function getHtmlTaggedTemplateNode(node: ts.Node): ts.TaggedTemplateExpression |
* detect.
* 2. Use JSDOM to turn this into a DOM.
* 3. Iterate over every node in the DOM, and if the node has any attributes
- * starting `__FIGMA_PLACEHOLDER`, store the info of these attributes.
- * This allows us to know which template literal placeholders correspond to
- * HTML attributes when we construct the template.
+ * starting `__FIGMA_PLACEHOLDER`, store the info of these attributes. This
+ * allows us to know which template literal placeholders correspond to HTML
+ * attributes when we construct the template.
*/
function getInfoFromDom(
templateExp: ts.TemplateExpression | ts.TaggedTemplateExpression,
@@ -307,7 +306,7 @@ export function parseExampleTemplate(
// If the next placeholder is an attribute, then match the start of the
// attribute (`attribute=`) at the end of this chunk, so that we can
- // remove it from the example code
+ // remove it from the example code and store the attribute name
const attributeMatches = html.match(/(.*\s)([^\s]+)="?$/s)
if (nextPlaceholderIsAttribute && attributeMatches) {
// attributeMatches should always have matched here, but we check it
@@ -321,6 +320,12 @@ export function parseExampleTemplate(
// If we are in this block, we know that we've matched an attribute, so
// store whether it ends with a quote
insideAttributeWithQuotes = html.endsWith('"')
+
+ // Return the attribute name so we can use it to construct the attribute
+ // in the output. We do this rather than extract it from the HTML with
+ // JSDOM because we want to preserve case, but do not want to parse the
+ // doc as XHTML, so there's no way to do it otherwise.
+ return attributeMatches[2]
} else {
// No attribute to remove, just add the code
exampleCode += escapeTemplateString(html)
@@ -329,14 +334,17 @@ export function parseExampleTemplate(
}
// Process the first chunk, which is a special case as it is not in templateSpans
- handleHtmlChunk(transformedTemplate.head.text, attributePlaceholders[0] !== undefined)
+ let maybeAttributeName = handleHtmlChunk(
+ transformedTemplate.head.text,
+ attributePlaceholders[0] !== undefined,
+ )
// For each section of the template string, check that the expression is a
// prop placeholder, then add the appropriate template function call
transformedTemplate.templateSpans.forEach((part, index) => {
if (!ts.isCallExpression(part.expression)) {
throw new ParserError(
- `Expected an call expression as a placeholder in the template, got ${SyntaxKind[part.expression.kind]}`,
+ `Expected a call expression as a placeholder in the template, got ${SyntaxKind[part.expression.kind]}`,
{ sourceFile, node: part.expression },
)
}
@@ -350,16 +358,18 @@ export function parseExampleTemplate(
}
const propVariableName = propNameArg.text
- referencedProps.add(propVariableName)
if (attributePlaceholders[index]) {
- exampleCode += `\${_fcc_renderHtmlAttribute('${attributePlaceholders[index]}', ${propVariableName})}`
+ exampleCode += `\${_fcc_renderHtmlAttribute('${maybeAttributeName}', ${propVariableName})}`
} else {
exampleCode += `\${_fcc_renderHtmlValue(${propVariableName})}`
}
// Process the next chunk
- handleHtmlChunk(part.literal.text, attributePlaceholders[index + 1] !== undefined)
+ maybeAttributeName = handleHtmlChunk(
+ part.literal.text,
+ attributePlaceholders[index + 1] !== undefined,
+ )
})
} else if (templateNode.template.kind === ts.SyntaxKind.FirstTemplateToken) {
// Template string with no placeholders
diff --git a/cli/src/html/parser_template_helpers.ts b/cli/src/html/parser_template_helpers.ts
index 5a75e9e..9cdac6a 100644
--- a/cli/src/html/parser_template_helpers.ts
+++ b/cli/src/html/parser_template_helpers.ts
@@ -6,6 +6,21 @@
declare const figma: { html: (template: TemplateStringsArray, ...args: any[]) => string }
+export function _fcc_templateString($value: string) {
+ return {
+ $value,
+ $type: 'template-string',
+ } as const
+}
+
+export function _fcc_object($value: Record) {
+ return {
+ $value,
+ $type: 'object',
+ ...$value,
+ } as const
+}
+
/**
* Render a value to HTML, following sensible rules according to the type.
*
@@ -50,7 +65,6 @@ function _fcc_renderHtmlAttribute(name: string, value: any) {
return ''
}
} else if (typeof value === 'string' || typeof value === 'number' || typeof value === 'bigint') {
- // Some types might not make sense here but we'll allow anything to be stringified
return `${name}="${value.toString().replaceAll('\n', '\\n').replaceAll('"', '\\"')}"`
} else {
// TODO make this show a proper error in the UI
@@ -59,5 +73,7 @@ function _fcc_renderHtmlAttribute(name: string, value: any) {
}
export function getParsedTemplateHelpersString() {
- return [_fcc_renderHtmlValue, _fcc_renderHtmlAttribute].map((fn) => fn.toString()).join('\n')
+ return [_fcc_templateString, _fcc_object, _fcc_renderHtmlValue, _fcc_renderHtmlAttribute]
+ .map((fn) => fn.toString())
+ .join('\n')
}
diff --git a/cli/src/html/types.ts b/cli/src/html/types.ts
index 1ac4523..23185cd 100644
--- a/cli/src/html/types.ts
+++ b/cli/src/html/types.ts
@@ -1,8 +1,8 @@
import { FigmaConnectMeta } from '../connect/api'
import { HtmlTemplateString } from './template_literal'
-export type HtmlMeta = Required, 'example'>> &
- FigmaConnectMeta & {
+export type HtmlMeta
= Required, 'example'>> &
+ FigmaConnectMeta & {
/**
* A list of import statements that will render in the Code Snippet in Figma.
*/
diff --git a/cli/src/react/__test__/create.test.ts b/cli/src/react/__test__/create.test.ts
index 798e272..b4062f0 100644
--- a/cli/src/react/__test__/create.test.ts
+++ b/cli/src/react/__test__/create.test.ts
@@ -101,6 +101,65 @@ figma.connect(Test, "fake-url", {
expect(fs.writeFileSync).toHaveBeenCalledWith('test.figma.tsx', expected)
})
+ it('Should show the no-props message if no props could be mapped', async () => {
+ fs.existsSync.mockReturnValue(false)
+
+ await createReactCodeConnect({
+ destinationDir: 'test',
+ destinationFile: 'test.figma.tsx',
+ config: { parser: 'react' },
+ mode: 'CREATE',
+ propMapping: {},
+ reactTypeSignature: {
+ someBool: 'false | true',
+ },
+ component: {
+ id: '1:1',
+ figmaNodeUrl: 'fake-url',
+ name: 'Test',
+ normalizedName: 'Test',
+ type: 'COMPONENT_SET',
+ componentPropertyDefinitions: {
+ Label: {
+ type: 'TEXT',
+ defaultValue: 'Some label',
+ },
+ },
+ },
+ })
+
+ const expected = await prettier.format(
+ `\
+import React from "react"
+import { Test } from "./Test"
+import figma from "@figma/code-connect"
+
+/**
+ * -- This file was auto-generated by Code Connect --
+ * 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
+ */
+
+figma.connect(Test, "fake-url", {
+ props: {
+ // No matching props could be found for these Figma properties:
+ // \"label\": figma.string('Label')
+ },
+ example: (props) => ,
+})`,
+ {
+ parser: 'typescript',
+ semi: false,
+ trailingComma: 'all',
+ },
+ )
+
+ expect(fs.writeFileSync).toHaveBeenCalledWith('test.figma.tsx', expected)
+ })
+
it('Should use any available prop mappings', async () => {
fs.existsSync.mockReturnValue(false)
@@ -227,7 +286,6 @@ figma.connect(Test, "fake-url", {
destinationFile: 'test.figma.tsx',
config: { parser: 'react' },
mode: 'CREATE',
- propMapping: {},
reactTypeSignature: {
nonOptionalProp: 'false | true',
optionProp: '?string',
diff --git a/cli/src/react/__test__/expected_templates/EnumLikeBooleanFalseProp.expected_template b/cli/src/react/__test__/expected_templates/EnumLikeBooleanFalseProp.expected_template
index 9dbbe21..14663ca 100644
--- a/cli/src/react/__test__/expected_templates/EnumLikeBooleanFalseProp.expected_template
+++ b/cli/src/react/__test__/expected_templates/EnumLikeBooleanFalseProp.expected_template
@@ -3,5 +3,6 @@ const figma = require('figma')
const icon = figma.currentLayer.__properties__.boolean('Prop', {
"true": 'yes',
"false": 'no'})
+const __props = { icon }
-export default figma.tsx` `
+export default { ...figma.tsx` `, metadata: { __props } }
diff --git a/cli/src/react/__test__/expected_templates/PropMappings.expected_template b/cli/src/react/__test__/expected_templates/PropMappings.expected_template
index e9725e5..734d175 100644
--- a/cli/src/react/__test__/expected_templates/PropMappings.expected_template
+++ b/cli/src/react/__test__/expected_templates/PropMappings.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/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:
-function _fcc_renderPropValue(prop: FCCValue | { type: 'CODE' | 'INSTANCE' }[]) {
+function _fcc_renderPropValue(prop: FCCValue | (CodeSection | InstanceSection)[]) {
if (Array.isArray(prop)) {
return prop
}
@@ -94,7 +105,12 @@ function _fcc_renderPropValue(prop: FCCValue | { type: 'CODE' | 'INSTANCE' }[])
return prop
}
- if (prop.$type === 'function' || prop.$type === 'identifier' || prop.$type === 'jsx-element') {
+ if (
+ prop.$type === 'function' ||
+ prop.$type === 'identifier' ||
+ prop.$type === 'jsx-element' ||
+ prop.$type === 'react-component'
+ ) {
return prop.$value
}
@@ -112,7 +128,7 @@ function _fcc_renderPropValue(prop: FCCValue | { type: 'CODE' | 'INSTANCE' }[])
// Render a React prop correctly, based on its type
function _fcc_renderReactProp(
name: string,
- prop: FCCValue | { type: 'CODE' | 'INSTANCE' | 'ERROR' }[],
+ prop: FCCValue | (CodeSection | InstanceSection | ErrorSection)[],
) {
// If the value is an array, then it's an array of objects representing React
// children (either of type INSTANCE for pills, or CODE for inline code). The
@@ -159,7 +175,12 @@ function _fcc_renderReactProp(
return ''
}
- if (prop.$type === 'function' || prop.$type === 'identifier' || prop.$type === 'jsx-element') {
+ if (
+ prop.$type === 'function' ||
+ prop.$type === 'identifier' ||
+ prop.$type === 'jsx-element' ||
+ prop.$type === 'react-component'
+ ) {
return ` ${name}={${prop.$value}}`
}
@@ -175,7 +196,7 @@ function _fcc_renderReactProp(
}
// Renders React children correctly, based on their type
-function _fcc_renderReactChildren(prop: FCCValue | { type: 'CODE' | 'INSTANCE' }[]) {
+function _fcc_renderReactChildren(prop: FCCValue | (CodeSection | InstanceSection)[]) {
if (Array.isArray(prop)) {
return prop
}
@@ -206,6 +227,10 @@ function _fcc_renderReactChildren(prop: FCCValue | { type: 'CODE' | 'INSTANCE' }
if (prop.$type === 'object') {
return `{${_fcc_stringifyObject(prop.$value)}}`
}
+
+ if (prop.$type === 'react-component') {
+ return `<${prop.$value} />`
+ }
}
function _fcc_stringifyObject(obj: any): string {
@@ -234,6 +259,7 @@ export function getParsedTemplateHelpersString() {
_fcc_templateString,
_fcc_renderPropValue,
_fcc_stringifyObject,
+ _fcc_reactComponent,
]
.map((fn) => fn.toString())
.join('\n')
diff --git a/cli/src/react/types.ts b/cli/src/react/types.ts
index e478b7d..2bcb682 100644
--- a/cli/src/react/types.ts
+++ b/cli/src/react/types.ts
@@ -1,6 +1,20 @@
-import { FigmaConnectMeta } from '../connect/api'
+import { FigmaConnectMeta, ConnectedComponent } from '../connect/api'
-export type ReactMeta = FigmaConnectMeta
& {
+// Converts our internal type for instances, which adds methods to it,
+// to their underlying primitive type, so they can be used in examples.
+// prettier-ignore
+type MapType =
+ T extends ConnectedComponent ? JSX.Element :
+ // Apply recursively to objects and arrays
+ T extends object ? { [K in keyof T]: MapType } :
+ T extends Array ? MapType[] :
+ T
+
+export type ReactMeta = FigmaConnectMeta<
+ P,
+ MapType
,
+ React.Component | JSX.Element | string | ((props: any) => JSX.Element)
+> & {
/**
* A list of import statements that will render in the Code Snippet in Figma.
* This overrides the auto-generated imports for the component. When this is specified,
diff --git a/cli/src/storybook/__test__/convert.test.ts b/cli/src/storybook/__test__/convert.test.ts
index 467a5c7..60a3b9b 100644
--- a/cli/src/storybook/__test__/convert.test.ts
+++ b/cli/src/storybook/__test__/convert.test.ts
@@ -87,7 +87,7 @@ describe('convertStorybookFiles (JS templates)', () => {
source:
'https://github.com/figma/code-connect/blob/main/cli/src/storybook/__test__/examples/FunctionComponent.tsx',
sourceLocation: { line: 8 },
- template: getExpectedTemplate('FunctionComponent'),
+ template: getExpectedTemplate('FunctionComponentNoProps'),
},
])
})
@@ -249,7 +249,7 @@ describe('convertStorybookFiles (JS templates)', () => {
source:
'https://github.com/figma/code-connect/blob/main/cli/src/storybook/__test__/examples/FunctionComponent.tsx',
sourceLocation: { line: 8 },
- template: getExpectedTemplate('FunctionComponent'),
+ template: getExpectedTemplate('FunctionComponentNoProps'),
// name: 'Default',
},
{
@@ -272,7 +272,7 @@ describe('convertStorybookFiles (JS templates)', () => {
source:
'https://github.com/figma/code-connect/blob/main/cli/src/storybook/__test__/examples/FunctionComponent.tsx',
sourceLocation: { line: 8 },
- template: getExpectedTemplate('FunctionComponent'),
+ template: getExpectedTemplate('FunctionComponentNoProps'),
variant: { 'With icon': false },
// name: 'Default',
},
@@ -322,7 +322,7 @@ describe('convertStorybookFiles (JS templates)', () => {
source:
'https://storybook.com/?path=/docs/cli-src-storybook---test---examples-FunctionComponent',
sourceLocation: { line: 8 },
- template: getExpectedTemplate('FunctionComponent'),
+ template: getExpectedTemplate('FunctionComponentNoProps'),
},
])
})
diff --git a/cli/src/storybook/__test__/expected_templates/FunctionComponent.expected_template b/cli/src/storybook/__test__/expected_templates/FunctionComponent.expected_template
index d21dd6f..c58f4d8 100644
--- a/cli/src/storybook/__test__/expected_templates/FunctionComponent.expected_template
+++ b/cli/src/storybook/__test__/expected_templates/FunctionComponent.expected_template
@@ -1,3 +1,6 @@
const figma = require('figma')
-export default figma.tsx`Hello `
+const disabled = figma.currentLayer.__properties__.boolean('Disabled')
+const __props = { disabled }
+
+export default { ...figma.tsx`Hello `, metadata: { __props } }
diff --git a/cli/src/storybook/__test__/expected_templates/FunctionComponentNoProps.expected_template b/cli/src/storybook/__test__/expected_templates/FunctionComponentNoProps.expected_template
new file mode 100644
index 0000000..d21dd6f
--- /dev/null
+++ b/cli/src/storybook/__test__/expected_templates/FunctionComponentNoProps.expected_template
@@ -0,0 +1,3 @@
+const figma = require('figma')
+
+export default figma.tsx`Hello `
diff --git a/cli/src/storybook/__test__/expected_templates/FunctionComponentWithArgs.expected_template b/cli/src/storybook/__test__/expected_templates/FunctionComponentWithArgs.expected_template
index b03fb1e..7bc18e1 100644
--- a/cli/src/storybook/__test__/expected_templates/FunctionComponentWithArgs.expected_template
+++ b/cli/src/storybook/__test__/expected_templates/FunctionComponentWithArgs.expected_template
@@ -1,7 +1,8 @@
const figma = require('figma')
const disabled = figma.currentLayer.__properties__.boolean('Disabled')
+const __props = { disabled }
-export default figma.tsx`
+export default { ...figma.tsx`
Hello this line is long to cause it to wrap in brackets
- `
+ `, metadata: { __props } }
diff --git a/cli/src/storybook/__test__/expected_templates/FunctionComponentWithArgs_indented.expected_template b/cli/src/storybook/__test__/expected_templates/FunctionComponentWithArgs_indented.expected_template
index b2df038..e2ae794 100644
--- a/cli/src/storybook/__test__/expected_templates/FunctionComponentWithArgs_indented.expected_template
+++ b/cli/src/storybook/__test__/expected_templates/FunctionComponentWithArgs_indented.expected_template
@@ -1,7 +1,8 @@
const figma = require('figma')
const disabled = figma.currentLayer.__properties__.boolean('Disabled')
+const __props = { disabled }
-export default figma.tsx`
+export default { ...figma.tsx`
Hello this line is long to cause it to wrap in brackets
- `
+ `, metadata: { __props } }
diff --git a/cli/src/storybook/__test__/expected_templates/FunctionComponentWithArgs_indented_2.expected_template b/cli/src/storybook/__test__/expected_templates/FunctionComponentWithArgs_indented_2.expected_template
index 997a4fe..bc3c639 100644
--- a/cli/src/storybook/__test__/expected_templates/FunctionComponentWithArgs_indented_2.expected_template
+++ b/cli/src/storybook/__test__/expected_templates/FunctionComponentWithArgs_indented_2.expected_template
@@ -1,7 +1,8 @@
const figma = require('figma')
const disabled = figma.currentLayer.__properties__.boolean('Disabled')
+const __props = { disabled }
-export default figma.tsx`
+export default { ...figma.tsx`
Hello this line is long to cause it to wrap in brackets
- `
+ `, metadata: { __props } }
diff --git a/cli/src/storybook/__test__/expected_templates/FunctionComponentWithLogic.expected_template b/cli/src/storybook/__test__/expected_templates/FunctionComponentWithLogic.expected_template
index 258a992..32c3620 100644
--- a/cli/src/storybook/__test__/expected_templates/FunctionComponentWithLogic.expected_template
+++ b/cli/src/storybook/__test__/expected_templates/FunctionComponentWithLogic.expected_template
@@ -1,6 +1,9 @@
const figma = require('figma')
-export default figma.tsx`function Example() {
+const disabled = figma.currentLayer.__properties__.boolean('Disabled')
+const __props = { disabled }
+
+export default { ...figma.tsx`function Example() {
const someExtraCode = 'test';
return Hello ;
-}`
+}`, metadata: { __props } }
diff --git a/cli/src/storybook/__test__/expected_templates/PropMapping.expected_template b/cli/src/storybook/__test__/expected_templates/PropMapping.expected_template
index 0acf28d..b78915c 100644
--- a/cli/src/storybook/__test__/expected_templates/PropMapping.expected_template
+++ b/cli/src/storybook/__test__/expected_templates/PropMapping.expected_template
@@ -1,13 +1,14 @@
const figma = require('figma')
-const stringProp = figma.currentLayer.__properties__.string('Text')
-const booleanProp = figma.currentLayer.__properties__.boolean('Boolean Prop')
const enumProp = figma.currentLayer.__properties__.enum('Size', {
"Slim": 'slim',
"Medium": 'medium',
"Large": 'large'})
+const booleanProp = figma.currentLayer.__properties__.boolean('Boolean Prop')
+const stringProp = figma.currentLayer.__properties__.string('Text')
const children = figma.currentLayer.__properties__.string('Text')
+const __props = { enumProp, booleanProp, stringProp, children }
-export default figma.tsx`
+export default { ...figma.tsx`
${_fcc_renderReactChildren(children)}
- `
+ `, metadata: { __props } }
diff --git a/cli/src/storybook/convert.ts b/cli/src/storybook/convert.ts
index 364c8dd..5ef3360 100644
--- a/cli/src/storybook/convert.ts
+++ b/cli/src/storybook/convert.ts
@@ -7,7 +7,7 @@ import {
} from '../typescript/compiler'
import {
parseComponentMetadata,
- parseRenderFunction,
+ parseJSXRenderFunction,
getDefaultTemplate,
findAndResolveImports,
} from '../react/parser'
@@ -210,7 +210,7 @@ async function convertStorybookFile({
)
}
- let render = parseRenderFunction(statementToParse, parserContext, propMappings)
+ let render = parseJSXRenderFunction(statementToParse, parserContext, propMappings)
if (!render) {
continue
diff --git a/cli/src/storybook/external.ts b/cli/src/storybook/external.ts
index 857e304..e22fdcb 100644
--- a/cli/src/storybook/external.ts
+++ b/cli/src/storybook/external.ts
@@ -22,11 +22,11 @@ export type StoryParameters = {
* Optional array of examples to show in Figma. If none are specified, Figma
* will show a default code example.
*/
- examples?: (FigmaConnectMeta['example'] | string | ExampleObject)[]
+ examples?: (FigmaConnectMeta['example'] | string | ExampleObject)[]
} & Pick
}
-type ExampleObject = FigmaConnectMeta & {
+type ExampleObject = FigmaConnectMeta & {
variant?: FigmaConnectMeta['variant']
links?: FigmaConnectMeta['links']
}
diff --git a/docs/react.md b/docs/react.md
index 8bf17c7..15d8245 100644
--- a/docs/react.md
+++ b/docs/react.md
@@ -14,7 +14,86 @@ Install this package into your React project's directory.
npm install @figma/code-connect
```
-## Basic setup
+## Getting started - interactive setup
+
+For first-time setup of Code Connect in a codebase, we recommend using the interactive setup, which makes it easier to quickly connect a large codebase. Code Connect will attempt to automatically connect your codebase to your Figma design system components based on name, which you can then make any edits to before batch-creating Code Connect files. No data gets published or unpublished in this flow, so feel free to try it out!
+
+To start the interactive setup, enter `figma connect` without any subcommands:
+
+```sh
+npx figma connect
+```
+The interactive flow will ask you for information to help automatically link your codebase, including:
+
+- Your Figma access token
+- Your Figma design system file's URL
+- The path to your code files you wish to connect
+
+After providing those, Code Connect will attempt to link your code files to your design system components based on name, allowing you to review and edit the results before proceeding to create Code Connect files:
+
+
+
+> [!TIP]
+> If you have too many components to connect at once, try entering a subfolder when prompted for the path to your code files. You can later change this to another folder, and any already-connected code files will be filtered out of the list of connectable components.
+
+### Choosing whether to use AI
+
+Code Connect gives you the option to use AI to improve the accuracy of the generated prop mappings. If you choose to use AI, the following is sent to OpenAI for any components you're connecting:
+
+- React code prop names
+- Figma property names
+- Figma variant values
+
+Figma’s agreement with OpenAI provides that data is not to be used for model training. For more information, see: https://help.figma.com/hc/en-us/articles/23920389749655-Code-Connect
+
+If you choose not to use AI, fuzzy matching is used instead.
+
+### Creating Code Connect files
+
+After confirming the above, Code Connect will create Code Connect files for each of your linked components, and where possible will map your React props to your Figma component properties. You can now review each of these and make any updates you like before publishing (see [Publishing](#publishing)). Here's how they'll look:
+
+```typescript
+import React from 'react'
+import { Modal } from './Modal'
+import figma from '@figma/code-connect'
+
+/**
+ * -- This file was auto-generated by Code Connect --
+ * \`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
+ */
+
+figma.connect(Modal, 'https://www.figma.com/design/123/MyFile?node-id=1-1', {
+ props: {
+ // These props were automatically mapped based on your linked code:
+ isOpen: figma.boolean('Open'),
+ title: figma.string('Heading'),
+ // No matching props could be found for these Figma properties:
+ // "withLeadingIcon": figma.boolean('With Leading Icon'),
+ },
+ example: (props) => (
+
+ ),
+})
+
+```
+
+### Next steps
+
+Your newly generated Code Connect files will have placeholders for anything you need to fill out. We recommend reviewing these and checking everything looks correct, and adding any properties you'd like to connect. See [Dynamic code snippets](#dynamic-code-snippets).
+
+We recommend publishing a small set of your new Code Connect files to see how they look in Figma. You can do this by specifying the folder you wish to publish:
+
+```sh
+npx figma connect publish --dir ./Components --token
+```
+
+## Getting started - manual setup
To connect your first component go to Dev Mode in Figma and right-click on the component you want to connect, then choose `Copy link to selection` from the menu. Make sure you are copying the link to a main component and not an instance of the component. The main component will typically be located in a centralized design system library file. Using this link, run `figma connect create` from inside your React project. Note that depending on what terminal software you're using, you might need to wrap the URL in quotes.
@@ -50,16 +129,6 @@ Now go back to Dev Mode in Figma and select the component that you just connecte
> [!NOTE]
> Code Connect files are not executed. While they're written using real components from your codebase, the Figma CLI essentially treats code snippets as strings. This means you can use, for example, hooks without needing to mock data. However, this also means that logical operators such as ternaries or conditionals will be output verbatim in your example code rather than executed to show the result. You also won't be able to dynamically construct `figma.connect` calls in a for-loop, as an example. If something you're trying to do is not possible because of this restriction in the API, we'd love to hear your feedback.
-## Interactive setup
-
-A step-by-step interactive flow is provided which makes it easier to connect a large codebase. Code Connect will attempt to automatically connect your codebase to your Figma design system components based on name, which you can then make any edits to before batch-creating Code Connect files.
-
-To start the interactive setup, enter `figma connect` without any subcommands:
-
-```sh
-npx figma connect
-```
-
## Integrating with Storybook
If you already have Storybook set up for your design system then we recommend using the Storybook integration that comes with Code Connect. Storybook and Code Connect complement each other nicely and with this integration they are easy to maintain in parallel. The syntax for integrating with Storybook is slightly different to ensure alignment with the Storybook API.
@@ -577,6 +646,131 @@ export function DangerButtonStory() {
For connecting a lot of icons, we recommend creating a script that pulls icons from a Figma file to generate an `icons.figma.tsx` file that includes all icons. You can use the script [here](../cli/scripts/README.md) as a starting point. The script is marked with "EDIT THIS" in areas where you'll need to make edits for it to work with how your Figma design system is setup and how your icons are defined in code.
+Icons can be configured in many different ways in Figma and code. We recommend using instance-swap props in Figma for icons, to be able to access the nested Code Connect icon using a stable instance-swap prop ID.
+
+### Icons as JSX elements
+If your icons are passed as JSX elements in code, you can use Code Connect in the same way you create components.
+
+```tsx
+// icon
+figma.connect("my-icon-url", {
+ example: () =>
+})
+
+// parent
+figma.connect("my-button-url, {
+ props: {
+ icon: figma.instance("InstanceSwapPropName")
+ },
+ example: ({ icon }) => {icon}
+})
+
+// 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 }) =>
+})
+
+// renders in Dev Mode
+
+```
+
+### Icons as strings
+It's common to use IDs instead of passing around components for icons. In this case, you'll want your icon CC files to just return that string. `figma.instance` takes a type paremeter, to match what the nested template returns.
+
+```tsx
+// icon
+figma.connect("my-icon-url", {
+ example: () => "icon-heart"
+})
+
+// parent
+figma.connect("my-button-url, {
+ props: {
+ iconId: figma.instance("InstanceSwapPropName")
+ },
+ example: ({ iconId }) =>
+})
+
+// renders in Dev Mode
+
+```
+
+### Accessing icon props in parent component
+If you have different ways of rendering icons depending on parent, or, if you want to use icon strings but still be able to map properties of the icon components, you'll want to use `getProps` or `render` which are exposed on the return value of `figma.instance()`. The `example` function of the icon itself determines how that icon renders when clicked in Figma, but can be "overriden" via these additional helpers.
+
+`getProps` gives access to the props of the child (e.g. the icon) from the parent, so you can use those props in your parent component. Note the static prop `iconId: "my-icon"` - any custom/static props like this one will be included in the object returned from `getProps`.
+
+```tsx
+// icon
+figma.connect("my-icon-url", {
+ props: {
+ iconId: "my-icon",
+ size: figma.enum("Size", {
+ 'large': 'large',
+ 'small': 'small'
+ })
+ }
+ example: ({ size }) =>
+})
+
+// parent
+figma.connect("icon-button-url", {
+ props: {
+ iconProps: figma.instance("InstanceSwapPropName").getProps<{iconId: string, size: "small" | "large"}>()
+ },
+ example: ({ iconProps }) =>
+})
+
+// renders in Dev Mode
+
+```
+
+`render` allows you to conditionally render nested connected components. The argument is passed the resolved props of the nested component.
+This is useful if you need to dynamically render different JSX elements based on e.g a boolean prop.
+
+```tsx
+// icon
+figma.connect("my-icon-url", {
+ props: {
+ iconId: "my-icon",
+ size: figma.enum("Size", {
+ 'large': 'large',
+ 'small': 'small'
+ })
+ }
+ example: ({ size }) =>
+})
+
+// parent
+figma.connect("icon-button-url", {
+ props: {
+ icon: figma.boolean("Show icon", {
+ true: figma.instance("InstanceSwapPropName").render<{iconId: string, size: "small" | "large"}>(props => ),
+ }
+ },
+ example: ({ icon }) =>
+})
+
+// renders in Dev Mode
+ } />
+```
+
## CI / CD
The easiest way to get started using Code Connect is by using the CLI locally. However, once you have set up your first connected components it may be beneficial to integrate Code Connect with your CI/CD environment to simplify maintenance and to ensure component connections are always up to date. Using GitHub actions, we can specify that we want to publish new files when any PR is merged to the main branch. We recommend only running this on pull requests that are relevant to Code Connect to minimize impact on other pull requests.