diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e543c5..63365ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,29 @@ +# Code Connect v1.0.0 (19th June 2024) + +## Features + +### General +- Added [documentUrlSubstitutions](README.md#documenturlsubstitutions) config option + +### Jetpack Compose +- Added support for Android Jetpack Compose. See the [README](compose/README.md) to get started + +### React +- Interactive setup flow, launched by running `figma connect`, which guides you through the setup process and auto-connects your components + +## Fixed + +### General +- Automatic config migration (added in v0.2.0) now correctly preserves `include`/`exclude` config options +- Icon script helpers moved to a named export so they can be imported correctly (see [README](cli/scripts/README.md)) + +### React +- Nested helpers within `figma.nestedProps` now work as expected +- Props can now be rendered in nested object props + +### SwiftUI +- `create` now outputs Swift files with the correct syntax + # Code Connect v0.2.1 (17th June 2024) ## Fixed diff --git a/README.md b/README.md index 0ba284e..5f4a620 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ Code Connect is a tool for connecting your design system components in code with your design system in Figma. When using Code Connect, Figma's Dev Mode will display true-to-production code snippets from your design system instead of autogenerated code examples. In addition to connecting component definitions, Code Connect also supports mapping properties from code to Figma enabling dynamic and correct examples. This can be useful for when you have an existing design system and are looking to drive consistent and correct adoption of that design system across design and engineering. -Code Connect is easy to set up, easy to maintain, type-safe, and extensible. Out of the box Code Connect comes with support for React, Storybook and SwiftUI. +Code Connect is easy to set up, easy to maintain, type-safe, and extensible. Out of the box Code Connect comes with support for React (and React Native), Storybook, SwiftUI and Jetpack Compose. -![image](https://static.figma.com/uploads/9a04c8236ff9cc18303b98a0983c82a589b6cfe3.png) +![image](https://static.figma.com/uploads/d98e747613e01685d6a0f9dd3e2dcd022ff289c0.png) > [!NOTE] > Code Connect is available on Organization and Enterprise plans and requires a full Design or Dev Mode seat to use. Code Connect is currently in beta, so you can expect this feature to change. You may also experience bugs or performance issues during this time. @@ -25,8 +25,9 @@ We hope to provide a way to install Code Connect without requiring Node.js soon. To learn how to implement Code Connect for your platform, please navigate to the platform-specific API usage and documentation. -- [React](cli/README.md) +- [React (or React Native)](cli/README.md) - [SwiftUI](swiftui/README.md) +- [Jetpack Compose](compose/README.md) ## General configuration @@ -36,7 +37,7 @@ Every platform supports some common configuration options, in addition to any pl ### `include` and `exclude` -`include` and `exclude` are lists of globs for where to parse Code Connect files. `include` and `exclude` paths must be relative to the location of the config file. +`include` and `exclude` are lists of globs for where to parse Code Connect files, and for where to search for your component code when using the [interactive setup](cli/README.md#interactive-setup). `include` and `exclude` paths must be relative to the location of the config file. ```jsonp { @@ -50,10 +51,12 @@ Every platform supports some common configuration options, in addition to any pl ### `parser` Code Connect will attempt to determine your project type by looking the first ancestor of the working directory which matches one of the following: + - If a `package.json` containing `react` is found, your project is detected as React - If a file matching `Package.swift` or `*.xcodeproj` is found, your project is detected as Swift +- If a file matching `build.gradle.kts` is found, your project is detected as Jetpack Compose -In case this does not correctly work for your project, you can override the project type by using the `parser` configuration key. Valid values are `react` or `swift`. +In case this does not correctly work for your project, you can override the project type by using the `parser` configuration key. Valid values are `react`, `swift` and `compose`. ```jsonp { @@ -62,3 +65,29 @@ In case this does not correctly work for your project, you can override the proj } } ``` + +### `documentUrlSubstitutions` + +`documentUrlSubstitutions` allows you to specify a set of substitutions which will be run on the `figmaNode` URLs when parsing or publishing documents. + +This allows you to use different config files to switch publishing Code Connect between different files, without having to modify every Code Connect file (e.g. if you have a test version of your document you want to publish to). The substitutions are specified as an object, where the key is the string to be replaced, and the value is the string to replace that with. + +For example, the config: + +``` +{ + "codeConnect": { + "documentUrlSubstitutions": { + "https://figma.com/design/1234abcd/File-1": "https://figma.com/design/5678dcba/File-2" + } + } +} +``` + +would change Figma node URLs like `https://figma.com/design/1234abcd/File-1/?node-id=12:345` to `https://figma.com/design/5678dbca/File-2/?node-id=12:345`. + +## Common issues + +### Connectivity issues due to proxies or network security software + +Some proxies or network security software can prevent Code Connect from communicating with Figma's servers. If you encounter issues, you may need to explicitly allow connections to `https://api.figma.com/`. Please reach out to [Figma support](https://help.figma.com/hc/en-us/requests/new) if you are still unable to use Code Connect. diff --git a/cli/README.md b/cli/README.md index 3650dd2..9a2aa50 100644 --- a/cli/README.md +++ b/cli/README.md @@ -2,7 +2,7 @@ For more information about Code Connect as well as guides for other platforms and frameworks, please [go here](../README.md). -This documentation will help you connect your React components with Figma components using Code Connect. We'll cover basic setup to display your first connected code snippet, followed by making snippets dynamic by using property mappings. Code Connect for React works as both a standalone implementation and as an integration with existing Storybook files to enable easily maintaining both systems in parallel. +This documentation will help you connect your React (or React Native) components with Figma components using Code Connect. We'll cover basic setup to display your first connected code snippet, followed by making snippets dynamic by using property mappings. Code Connect for React works as both a standalone implementation and as an integration with existing Storybook files to enable easily maintaining both systems in parallel. ## Installation @@ -48,6 +48,16 @@ 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. @@ -291,7 +301,7 @@ figma.boolean('Has Icon', { ### Enums -Variants (or enums) in Figma are commonly used to control the look and feel of components that require more complex options than a simple boolean toggle. Variant properties are always strings in Figma but they can be mapped to any type in code. +Variants (or enums) in Figma are commonly used to control the look and feel of components that require more complex options than a simple boolean toggle. Variant properties are always strings in Figma but they can be mapped to any type in code. The first parameter is the name of the Variant in Figma, and the second parameter is a value mapping. The _keys_ in this object should match the different options of that Variant in Figma, and the _value_ is whatever you want to output instead. ```tsx // maps the 'Options' variant in Figma to enum values in code @@ -388,9 +398,7 @@ figma.children('Icon*') ### Nested properties -In cases where you don't want to connect a child component, but instead map its properties on the parent level, you can use -`figma.nestedProps()` to achieve this. This helper takes the name of the layer as it's first parameter (similar to `figma.children`), -and a mapping object as the second parameter. These props can then be referenced in the example function. +In cases where you don't want to connect a child component, but instead map its properties on the parent level, you can use `figma.nestedProps()` to achieve this. This helper takes the name of the layer as it's first parameter, and a mapping object as the second parameter. These props can then be referenced in the example function. `nestedProps` will always select a **single** instance, and cannot be used to map multiple children. ```tsx // map the properties of a nested instance named "Button Shape" diff --git a/cli/package.json b/cli/package.json index 80e5c2c..66720e4 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@figma/code-connect", - "version": "0.2.1", + "version": "1.0.0", "description": "A tool for connecting your design system components in code with your design system in Figma", "keywords": [], "author": "Figma", diff --git a/cli/scripts/README.md b/cli/scripts/README.md index e570636..d844f42 100644 --- a/cli/scripts/README.md +++ b/cli/scripts/README.md @@ -1,77 +1,59 @@ # Code Connect for icons -`import-icons.ts` is a node script that uses the [Figma API](https://www.figma.com/developers/api) to pull icons from a Figma file and generate a Code Connect file for your icons. This template is meant to be used as a starting point - some parts will need to be edited to work with your design system and code base. These areas are marked with "EDIT THIS" in the file. +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. ## Usage -Run the script with e.g `npx tsx`: +To run the scripts, you'll need to set the `FIGMA_ACCESS_TOKEN` env variable. [See here](https://www.figma.com/developers/api#access-tokens) for how to get this token. + +Run the scripts with e.g `npx tsx`: ``` FIGMA_ACCESS_TOKEN= npx tsx import-icons.ts ``` -## Modifying the script - -There are many ways your icons can be setup in Figma and in code. This base template assumes that: -* Your icons in Figma include the string "icon" in the name -* Your icon components in code are named similarly and include the size, e.g `Icon32Search` - -Here are some examples of how you can modify the script to work with your setup. - -### Icons with size as a prop - -If your icons in Figma has properties/variants for size, you can modify the script to handle this. - -Change the `generateCodeConnectIcons` function: +or, if you have the access token in an `.env` file, Code Connect will pick that up: +``` +npx tsx import-icons.ts +``` -```ts -// ... +## Code Connect Client -let name = figmaName - .split(/[.-]/g) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join('') +`client` includes helper functions for interacting with Figma files and generating Code Connect files. It uses the [Figma REST API](https://www.figma.com/developers/api) under the hood. This folder includes a few example scripts that can be modified to fit your needs. -// added line: remove the size from the component name -name = name.replace(/[0-9]+/g, '') +`getComponents` will fetch any components in a file, or if a node-id query parameter is provided, any nodes within that frame. The result can then be used to dynamically connect components with `figma.connect()` and write this to a file that can be published with `figma connect publish`. -// added line: extract the size from the figma name. -// default to 16 if no size specified in the Figma name -const [_match, size] = figmaName.match(/([0-9]+)/) ?? [null, '16'] +``` +import { client } from '@figma/code-connect' -const info: IconInfo = { - id, - name, - figmaName, - figmaUrl, - size, +async function getIcons() { + const components = await client.getComponents('https://figma.com/file/ABc123IjkLmnOPq?node-id=41-41') + const icons = components.filter(({ name }) => name.startsWith('icon')) + // ... write code connect file } -icons.push(info) - -// ... ``` -Change the `writeCodeConnectFile` function to include an `example` that passes the size to your icon component: +`getComponents` returns an array of `Component` objects with the following type: -```ts -async function writeCodeConnectFile(dir: string, icons: IconInfo[]) { - const uniqueNames = new Set([...icons.map((icon) => icon.name)]) - fs.writeFileSync( - path.join(dir, ICONS_CODE_CONNECT_FILE), - `\ -import figma from '@figma/code-connect' -import { -${Array.from(uniqueNames) - .map((iconName) => ` ${iconName},`) - .join('\n')} -} from '${ICONS_IMPORT_PATH}' -${icons - .map( - (icon) => `figma.connect(${icon.name}, '${icon.figmaUrl}', { - example: () => <${icon.name} size={${icon.size}} />, -})`, - ) - .join('\n')} -`, - ) +``` +interface Component { + // the type of component (only COMPONENT_SET nodes can have variant properties) + type: 'COMPONENT' | 'COMPONENT_SET' + // the name of the component in Figma + name: string + // a unique ID for this node + id: string + // file key for the file containing this node + fileKey: string + // URL to this node + figmaUrl: string + // Properties for this component, keyed by the name of the property + componentPropertyDefinitions: Record } ``` + diff --git a/cli/scripts/import-icons-size-prop.ts b/cli/scripts/import-icons-size-prop.ts new file mode 100644 index 0000000..8441570 --- /dev/null +++ b/cli/scripts/import-icons-size-prop.ts @@ -0,0 +1,58 @@ +// change to: "import { client } from '@figma/code-connect'" +import { client } from '../src' +import fs from 'fs' +import path from 'path' + +async function generateIconsWithSizeProp() { + // 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', + ) + + // Map from figma to React component names + components = components.map((component) => ({ + ...component, + name: component.name + .split(/[.-]/g) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''), + })) + + const uniqueNames = new Set([...components.map((c) => c.name)]) + + const file = 'src/components/icons.figma.tsx' + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync( + file, + `\ +import figma from '@figma/code-connect' + +import { +${Array.from(uniqueNames) + .map((iconName) => ` ${iconName},`) + .join('\n')} +} from './Icons' + +const props = { + size: figma.enum('Size', { + "12": 12, + "16": 16, + "24": 24, + }) +} + +${components + .map( + (c) => `figma.connect(${c.name}, '${c.figmaUrl}', { + props, + example: ({ size }) => <${c.name} size={size} /> +})`, + ) + .join('\n')} +`, + ) +} + +generateIconsWithSizeProp() diff --git a/cli/scripts/import-icons.ts b/cli/scripts/import-icons.ts index edd930a..80b6204 100644 --- a/cli/scripts/import-icons.ts +++ b/cli/scripts/import-icons.ts @@ -1,206 +1,44 @@ +// change to: "import { client } from '@figma/code-connect'" +import { client } from '../src' import fs from 'fs' -import path from 'path' -/** - * --- EDIT THESE CONSTANTS --- - */ - -/** The name of the Code Connect file that will be generated */ -const ICONS_CODE_CONNECT_FILE = 'src/components/icons.figma.tsx' - -/** Where your Icon components should be imported from in your codebase */ -const ICONS_IMPORT_PATH = './Icons' - -/** - * The ID/key of your figma file, for example in: - * https://figma.com/file/ABc123IjkLmnOPq/ - * ^ this is the file key - */ -const FIGMA_FILE_KEY = 'ABc123IjkLmnOPq' - -interface IconInfo { - id: string - name: string - figmaName: string - figmaUrl: string - size?: string -} - -/** - * Entry point for the script - * --- EDIT THIS FUNCTION --- - */ -async function generateCodeConnectIcons() { - console.log('fetching published component info') - - const components = await fetchPublishedFileComponents() - const icons: IconInfo[] = [] - - // --- EDIT THIS --- - // This is where you define what components are considered icons. - // For example, this filters components that have 'icon' in their name. - const isIcon = (name: string) => name.includes('icon') - - for (const icon of components) { - const meta = getComponentMeta(icon, isIcon) - if (!meta) continue - - const id = icon.node_id - const figmaName = meta.name - const figmaUrl = figmaUrlOfComponent(icon) - - // --- EDIT THIS --- - // This is where you want to convert the Figma Component name to - // a Component in your codebase. For example here icons are - // renamed like this: - // `icon.32.arrow.right` -> `Icon32ArrowRight` - let name = figmaName - .split(/[.-]/g) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join('') +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', + ) - icons.push({ - id, - name, - figmaName, - figmaUrl, + // Converts icon names from e.g `icon-32-list` to `Icon32List` + components = components + .filter(({ name }) => { + return name.includes('icon') }) - } + .map((component) => ({ + ...component, + name: component.name + .split(/[.-]/g) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''), + })) - console.log(`found ${icons.length} published icons`) + const uniqueNames = new Set([...components.map((c) => c.name)]) - await writeCodeConnectFile('.', icons) -} - -/** - * Writes the icons to a Code Connect file. - * --- EDIT THIS FUNCTION --- - * - * @param dir directory to write the file to - * @param icons icons to write to the file - */ -async function writeCodeConnectFile(dir: string, icons: IconInfo[]) { - const uniqueNames = new Set([...icons.map((icon) => icon.name)]) fs.writeFileSync( - path.join(dir, ICONS_CODE_CONNECT_FILE), + 'icons.figma.tsx', `\ -import figma from '@figma/code-connect' + import figma from '@figma/code-connect' -import { -${Array.from(uniqueNames) - .map((iconName) => ` ${iconName},`) - .join('\n')} -} from '${ICONS_IMPORT_PATH}' + import { + ${Array.from(uniqueNames) + .map((iconName) => ` ${iconName},`) + .join('\n')} + } from './Icons' -${icons.map((icon) => `figma.connect(${icon.name}, '${icon.figmaUrl}')`).join('\n')} -`, + ${components.map((c) => `figma.connect(${c.name}, '${c.figmaUrl}')`).join('\n')} + `, ) } -generateCodeConnectIcons() - -/** - * ------------------------- - * Typings and helper functions - */ - -/** - * Gets the id and name of a figma component, and filters out - * components that are not icons. - * - * @param component a published figma component - * @returns the id and name of the component - */ -function getComponentMeta( - component: PublishedComponent, - isIcon: (componentName: string) => boolean, -): { id: string; name: string } | null { - let id = component.node_id - let name = component.name - - const isIconComponent = isIcon(component.name) - - // This part handles icons that are variants in a component set - // and can be removed if you're using separate components for icons. - if (!isIconComponent) { - const stateGroup = component.containing_frame.containingStateGroup - const isIconVariant = stateGroup && isIcon(stateGroup.name) - if (!isIconVariant) return null - - id = stateGroup.nodeId - name = stateGroup.name - } - - return { id, name } -} - -/** - * Fetch all published components from the figma file - * - * @returns a list of components - */ -async function fetchPublishedFileComponents() { - const apiUrl = process.env.API_URL || `https://api.figma.com/v1/files/` - if (!process.env.FIGMA_ACCESS_TOKEN) { - throw new Error('FIGMA_ACCESS_TOKEN env variable is not set') - } - const url = `${apiUrl}${FIGMA_FILE_KEY}/components` - const res = await fetch(url, { - headers: { 'X-Figma-Token': process.env.FIGMA_ACCESS_TOKEN }, - }) - if (!res.ok) { - const txt = await res.text() - throw new Error(`Failed to fetch ${url.toString()}: ${res.status}\n\n${txt}`) - } - const json = (await res.json()) as PublishedFileComponentsResponse - return json.meta.components -} - -/** - * Gets the URL of a figma component - * - * @param icon a published figma component - * @returns a URL to the figma component - */ -function figmaUrlOfComponent(icon: PublishedComponent) { - const fileUrl = process.env.FILE_URL || `https://figma.com/file/` - const nodeId = icon.containing_frame.containingStateGroup?.nodeId ?? icon.node_id - const urlId = nodeId.replace(':', '-') - return `${fileUrl}${icon.file_key}/?node-id=${urlId}` -} - -interface PublishedComponent { - key: string - file_key: string - node_id: string - thumbnail_url: string - name: string - description: string - description_rt: string - created_at: string - updated_at: string - containing_frame: { - name: string - nodeId: string - pageId: string - pageName: string - backgroundColor: string - containingStateGroup?: { - name: string - nodeId: string - } - } - user: { - id: string - handle: string - img_url: string - } -} - -interface PublishedFileComponentsResponse { - error: boolean - status: number - meta: { - components: PublishedComponent[] - } -} +generateIcons() diff --git a/cli/src/__test__/e2e_connect_command.test.ts b/cli/src/__test__/e2e_connect_command.test.ts index 149e670..3641a0b 100644 --- a/cli/src/__test__/e2e_connect_command.test.ts +++ b/cli/src/__test__/e2e_connect_command.test.ts @@ -16,9 +16,8 @@ describe('e2e test for `connect` command', () => { }) expect(tidyStdOutput(result.stderr)).toBe( - `No config found, attempting to determine project type + `No config file found in ${testPath}, proceeding with default options Using "react" parser as package.json containing a "react" dependency was found in ${testPath}. If this is incorrect, please check you are running Code Connect from your project root, or add a \`parser\` key to your config file. See https://github.com/figma/code-connect for more information. -No config file found in ${testPath}, proceeding with default options ${path.join(testPath, 'ReactApiComponent.figmadoc.tsx')}`, ) const json = JSON.parse(result.stdout) diff --git a/cli/src/__test__/e2e_connect_command/legacy_react_config/figma.config.json b/cli/src/__test__/e2e_connect_command/legacy_react_config/figma.config.json index 06b1c63..f302de1 100644 --- a/cli/src/__test__/e2e_connect_command/legacy_react_config/figma.config.json +++ b/cli/src/__test__/e2e_connect_command/legacy_react_config/figma.config.json @@ -1,7 +1,14 @@ { "codeConnect": { "react": { - "include": ["src/components/**/*.tsx"] + "paths": { + "a": "b" + } + }, + "include": ["src/components/**/*.tsx"], + "exclude": ["src/components/**/*.test.tsx"], + "documentUrlSubstitutions": { + "c": "d" } } } diff --git a/cli/src/__test__/e2e_connect_command/legacy_react_minimal_config/figma.config.json b/cli/src/__test__/e2e_connect_command/legacy_react_minimal_config/figma.config.json new file mode 100644 index 0000000..ae8a96b --- /dev/null +++ b/cli/src/__test__/e2e_connect_command/legacy_react_minimal_config/figma.config.json @@ -0,0 +1,10 @@ +{ + "codeConnect": { + "react": { + "paths": { + "a": "b" + } + }, + "include": ["src/components/**/*.tsx"] + } +} diff --git a/cli/src/__test__/e2e_connect_command/legacy_react_minimal_config/package.json b/cli/src/__test__/e2e_connect_command/legacy_react_minimal_config/package.json new file mode 100644 index 0000000..de5e85d --- /dev/null +++ b/cli/src/__test__/e2e_connect_command/legacy_react_minimal_config/package.json @@ -0,0 +1,15 @@ +{ + "name": "tests", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "react": "^18.3.1" + } +} diff --git a/cli/src/__test__/e2e_connect_command/legacy_swift_config/figma.config.json b/cli/src/__test__/e2e_connect_command/legacy_swift_config/figma.config.json index 27ec1cb..25e5954 100644 --- a/cli/src/__test__/e2e_connect_command/legacy_swift_config/figma.config.json +++ b/cli/src/__test__/e2e_connect_command/legacy_swift_config/figma.config.json @@ -1,7 +1,14 @@ { "codeConnect": { "swift": { - "include": ["src/components/**/*.swift"] + "importPaths": { + "a": "b" + } + }, + "include": ["src/components/**/*.swift"], + "exclude": ["src/components/**/*.test.swift"], + "documentUrlSubstitutions": { + "c": "d" } } } diff --git a/cli/src/__test__/e2e_connect_command_swift.test.ts b/cli/src/__test__/e2e_connect_command_swift.test.ts index b76bb3b..6398139 100644 --- a/cli/src/__test__/e2e_connect_command_swift.test.ts +++ b/cli/src/__test__/e2e_connect_command_swift.test.ts @@ -29,9 +29,8 @@ describe('e2e test for `connect` command (Swift)', () => { const tidiedStdErr = tidyStdOutput(result.stderr) expect( - tidiedStdErr.startsWith(`No config found, attempting to determine project type -Using "swift" parser as a file matching *.xcodeproj or Package.swift was found in ${testPath}. If this is incorrect, please check you are running Code Connect from your project root, or add a \`parser\` key to your config file. See https://github.com/figma/code-connect for more information. -No config file found in ${testPath}, proceeding with default option`), + tidiedStdErr.startsWith(`No config file found in ${testPath}, proceeding with default options +Using "swift" parser as a file matching *.xcodeproj or Package.swift was found in ${testPath}. If this is incorrect, please check you are running Code Connect from your project root, or add a \`parser\` key to your config file. See https://github.com/figma/code-connect for more information.`), ).toBe(true) // xcodebuild sometimes outputs some messages to stderr here, I couldn't diff --git a/cli/src/__test__/e2e_create_command.test.ts b/cli/src/__test__/e2e_create_command.test.ts index 730c79c..1e8d4cf 100644 --- a/cli/src/__test__/e2e_create_command.test.ts +++ b/cli/src/__test__/e2e_create_command.test.ts @@ -32,9 +32,8 @@ describe('e2e test for `create` command', () => { ) expect(tidyStdOutput(result.stderr)).toBe( - `No config found, attempting to determine project type + `No config file found in ${testPath}, proceeding with default options Using "react" parser as package.json containing a "react" dependency was found in ${testPath}. If this is incorrect, please check you are running Code Connect from your project root, or add a \`parser\` key to your config file. See https://github.com/figma/code-connect for more information. -No config file found in ${testPath}, proceeding with default options Fetching component information from Figma... Parsing response Generating Code Connect files... @@ -122,7 +121,8 @@ Invalid parser specified: "does-not-exist". Valid parsers are: swift, compose, _ } catch (e: any) { expect(e.code).toBe(1) expect(tidyStdOutput(e.stderr)).toBe(`${getSuccessPreamble(testPath)} -Failed to create: Validation error: Required at "createdFiles"`) +Failed to create: Validation error: Required at "createdFiles" +Please raise any bugs or feedback at https://github.com/figma/code-connect/issues.`) } }) }) diff --git a/cli/src/__test__/e2e_legacy_config.test.ts b/cli/src/__test__/e2e_legacy_config.test.ts index 6494f8e..ec15f6b 100644 --- a/cli/src/__test__/e2e_legacy_config.test.ts +++ b/cli/src/__test__/e2e_legacy_config.test.ts @@ -45,19 +45,43 @@ describe('e2e test for legacy config handling', () => { } describe('legacy react config', () => { - const expectedOutput = `⚠️ Your Code Connect configuration needs to be updated + function getExpectedOutput(minimal = false) { + return `⚠️ Your Code Connect configuration needs to be updated Code Connect is migrating from a single configuration file for all supported languages, to individual configuration files for each language. As part of this change, your Code Connect configuration file needs to be updated to remove the react key and add { parser: "react" }: -{ +${ + minimal + ? `{ "codeConnect": { "parser": "react", + "paths": { + "a": "b" + }, "include": [ "src/components/**/*.tsx" ] } +}` + : `{ + "codeConnect": { + "parser": "react", + "paths": { + "a": "b" + }, + "include": [ + "src/components/**/*.tsx" + ], + "exclude": [ + "src/components/**/*.test.tsx" + ], + "documentUrlSubstitutions": { + "c": "d" + } + } +}` } Code Connect can make this change for you automatically, or you can do it manually. @@ -69,13 +93,21 @@ Please raise an issue at https://github.com/figma/code-connect/issues if you hav --- Would you like Code Connect to update your configuration file for you? (y/n)` + } + + function getExpectedSuccessOutput(minimal = false) { + return ( + getExpectedOutput(minimal) + + `\nConfiguration file updated +Config file found, parsing ./e2e_connect_command/legacy_react_${ + minimal ? 'minimal_' : '' + }config using specified include globs` + ) + } - const expectedSuccessOutput = - expectedOutput + - `\nConfiguration file updated -Config file found, parsing ./e2e_connect_command/legacy_react_config using specified include globs` - - const expectedErrorOutput = expectedOutput + `\nPlease update your configuration file manually` + function getExpectedErrorOutput(minimal = false) { + return getExpectedOutput(minimal) + `\nPlease update your configuration file manually` + } it( 'displays a message and exits if a "react" config is defined and the user does not answer "y"', @@ -84,7 +116,7 @@ Config file found, parsing ./e2e_connect_command/legacy_react_config using speci ({ code, stdout, stderr }) => { expect(code).toBe(1) expect(tidyStdOutput(stdout)).toBe('') - expect(tidyStdOutput(stderr)).toBe(expectedErrorOutput) + expect(tidyStdOutput(stderr)).toBe(getExpectedErrorOutput()) }, ) }, @@ -113,16 +145,68 @@ Config file found, parsing ./e2e_connect_command/legacy_react_config using speci it( 'displays a message and updates the config and proceeds if a "react" config is defined and the user answers "y"', async () => { - const testPath = path.join(__dirname, 'e2e_connect_command', 'legacy_react_config') - await runCommandInteractively('legacy_react_config', 'y').then( ({ code, stdout, stderr }) => { expect(code).toBe(0) expect(tidyStdOutput(stdout)).toBe('[]') - expect(tidyStdOutput(stderr)).toBe(expectedSuccessOutput) - expect(readFileSync(configPath, 'utf8')).toBe(`{ + expect(tidyStdOutput(stderr)).toBe(getExpectedSuccessOutput()) + expect(readFileSync(configPath, 'utf8')).toBe(`\ +{ + "codeConnect": { + "parser": "react", + "paths": { + "a": "b" + }, + "include": [ + "src/components/**/*.tsx" + ], + "exclude": [ + "src/components/**/*.test.tsx" + ], + "documentUrlSubstitutions": { + "c": "d" + } + } +}`) + }, + ) + }, + LONG_TEST_TIMEOUT_MS, + ) + }) + + describe('', () => { + const testPath = path.join(__dirname, 'e2e_connect_command', 'legacy_react_minimal_config') + + const configPath = path.join(testPath, 'figma.config.json') + const configBackupPath = path.join(testPath, 'figma.config.json.backup') + + beforeEach(() => { + copyFileSync(configPath, configBackupPath) + }) + + afterEach(() => { + copyFileSync(configBackupPath, configPath) + rmSync(configBackupPath) + }) + + it( + 'displays a message and updates the config and proceeds if a more minimal "react" config is defined and the user answers "y"', + async () => { + await runCommandInteractively('legacy_react_minimal_config', 'y').then( + ({ code, stdout, stderr }) => { + expect(code).toBe(0) + expect(tidyStdOutput(stdout)).toBe('[]') + console.log({ stderr, stdout }) + + expect(tidyStdOutput(stderr)).toBe(getExpectedSuccessOutput(true)) + expect(readFileSync(configPath, 'utf8')).toBe(`\ +{ "codeConnect": { "parser": "react", + "paths": { + "a": "b" + }, "include": [ "src/components/**/*.tsx" ] @@ -146,9 +230,18 @@ As part of this change, your Code Connect configuration file needs to be updated { "codeConnect": { "parser": "swift", + "importPaths": { + "a": "b" + }, "include": [ "src/components/**/*.swift" - ] + ], + "exclude": [ + "src/components/**/*.test.swift" + ], + "documentUrlSubstitutions": { + "c": "d" + } } } @@ -210,12 +303,22 @@ Config file found, parsing ./e2e_connect_command/legacy_swift_config using speci expect(tidyStdOutput(stdout)).toBe('') // We check startsWith because there will be some error output after this expect(tidyStdOutput(stderr).startsWith(expectedSuccessOutput)).toBe(true) - expect(readFileSync(configPath, 'utf8')).toBe(`{ + expect(readFileSync(configPath, 'utf8')).toBe(`\ +{ "codeConnect": { "parser": "swift", + "importPaths": { + "a": "b" + }, "include": [ "src/components/**/*.swift" - ] + ], + "exclude": [ + "src/components/**/*.test.swift" + ], + "documentUrlSubstitutions": { + "c": "d" + } } }`) }, @@ -269,8 +372,7 @@ The Swift figma.config.json should be located in your Swift project root and con } You will need to check any include/exclude paths are correct relative to the new locations. - -Please raise an issue at https://github.com/figma/code-connect/issues if you have any problems.`) +Please raise any bugs or feedback at https://github.com/figma/code-connect/issues.`) } }, LONG_TEST_TIMEOUT_MS, diff --git a/cli/src/client/external.ts b/cli/src/client/external.ts new file mode 100644 index 0000000..68a9127 --- /dev/null +++ b/cli/src/client/external.ts @@ -0,0 +1 @@ +export { getComponents } from './figma_client' diff --git a/cli/src/client/figma_client.ts b/cli/src/client/figma_client.ts new file mode 100644 index 0000000..4267709 --- /dev/null +++ b/cli/src/client/figma_client.ts @@ -0,0 +1,76 @@ +import { FigmaRestApi, getApiUrl, getDocument } from '../connect/figma_rest_api' +import { + figmaUrlOfComponent, + findComponentsInDocument, + normalizePropName, + parseFileKey, + parseNodeIds, +} from '../connect/helpers' + +export interface ComponentInfo { + id: string + name: string + fileKey: string + figmaUrl: string +} + +export interface FigmaConnectClient { + /** + * Fetches components from a figma file, filtering out components that don't + * match the provided function. + * + * @param fileOrNode figma URL + * @param match a function that returns true if the component should be + * included + * @returns a list of components + */ + getComponents: (fileOrNode: string) => Promise<(FigmaRestApi.Component & ComponentInfo)[]> +} + +require('dotenv').config() + +/** + * 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. + * + * @param fileOrNode a figma file URL + * @param match a function that returns true if the component should be included + * @returns + */ +export async function getComponents(fileOrNode: string) { + if (!process.env.FIGMA_ACCESS_TOKEN) { + throw new Error('FIGMA_ACCESS_TOKEN is not set') + } + + const fileKey = parseFileKey(fileOrNode) + if (!fileKey) { + throw new Error(`Invalid Figma file URL: ${fileOrNode}, file key missing`) + } + + const nodeIds = parseNodeIds([fileOrNode]) + let apiUrl = getApiUrl(fileOrNode ?? '') + `/files/${fileKey}` + if (nodeIds.length > 0) { + apiUrl += `?ids=${nodeIds.join(',')}` + } + + const doc = await getDocument(apiUrl, process.env.FIGMA_ACCESS_TOKEN) + + // `doc` in this case will only include the top frame(s) passed via `ids`. We omit the + // nodeIds arg here because we want to return all components within the frame(s) + return findComponentsInDocument(doc).map((component) => ({ + ...component, + fileKey, + figmaUrl: figmaUrlOfComponent(component, fileKey), + componentPropertyDefinitions: Object.keys(component.componentPropertyDefinitions).reduce( + (result, key) => { + return { + ...result, + // this removes the ID prefix from property names e.g #123:name -> name + [normalizePropName(key)]: component.componentPropertyDefinitions[key], + } + }, + {}, + ), + })) +} diff --git a/cli/src/commands/connect.ts b/cli/src/commands/connect.ts index c2aa893..5a45942 100644 --- a/cli/src/commands/connect.ts +++ b/cli/src/commands/connect.ts @@ -5,6 +5,7 @@ import { upload } from '../connect/upload' import { validateDocs } from '../connect/validation' import { createCodeConnectFromUrl } from '../connect/create' import { + CodeConnectConfig, CodeConnectExecutableParserConfig, CodeConnectReactConfig, ProjectInfo, @@ -22,6 +23,7 @@ import { fromError } from 'zod-validation-error' import { ParseRequestPayload, ParseResponsePayload } from '../connect/parser_executable_types' import z from 'zod' import { withUpdateCheck } from '../common/updates' +import { exitWithFeedbackMessage } from '../connect/helpers' export type BaseCommand = commander.Command & { token: string @@ -46,11 +48,17 @@ function addBaseCommand(command: commander.Command, name: string, description: s .option('-o --outDir ', 'specify a directory to output generated Code Connect') .option('-c --config ', 'path to a figma config file') .option('--dry-run', 'tests publishing without actually publishing') + .addHelpText( + 'before', + 'For feedback or bugs, please raise an issue: https://github.com/figma/code-connect/issues', + ) } export function addConnectCommandToProgram(program: commander.Command) { // Main command, invoked with `figma connect` - const connectCommand = addBaseCommand(program, 'connect', 'Figma Code Connect') + const connectCommand = addBaseCommand(program, 'connect', 'Figma Code Connect').action( + withUpdateCheck(runWizard), + ) // Sub-commands, invoked with e.g. `figma connect publish` addBaseCommand( @@ -92,7 +100,11 @@ export function addConnectCommandToProgram(program: commander.Command) { } export function getAccessToken(cmd: BaseCommand) { - const token = cmd.token ?? process.env.FIGMA_ACCESS_TOKEN + return cmd.token ?? process.env.FIGMA_ACCESS_TOKEN +} + +function getAccessTokenOrExit(cmd: BaseCommand) { + const token = getAccessToken(cmd) if (!token) { exitWithError( @@ -115,7 +127,11 @@ function setupHandler(cmd: BaseCommand) { type ParserDoc = z.infer['docs'][0] -function transformDocFromParser(doc: ParserDoc, remoteUrl: string): ParserDoc { +function transformDocFromParser( + doc: ParserDoc, + remoteUrl: string, + config: CodeConnectConfig, +): ParserDoc { let source = doc.source if (source) { try { @@ -128,9 +144,18 @@ function transformDocFromParser(doc: ParserDoc, remoteUrl: string): ParserDoc { } } + // TODO This logic is duplicated in parser.ts parseDoc due to some type issues + let figmaNode = doc.figmaNode + if (config.documentUrlSubstitutions) { + Object.entries(config.documentUrlSubstitutions).forEach(([from, to]) => { + figmaNode = figmaNode.replace(from, to) + }) + } + return { ...doc, source, + figmaNode, } } @@ -181,7 +206,7 @@ export async function getCodeConnectObjects( } return parsed.docs.map((doc) => ({ - ...transformDocFromParser(doc, projectInfo.remoteUrl), + ...transformDocFromParser(doc, projectInfo.remoteUrl, projectInfo.config), metadata: { cliVersion: require('../../package.json').version, }, @@ -208,14 +233,11 @@ async function getReactCodeConnectObjects( for (const file of files.filter((f: string) => isFigmaConnectFile(tsProgram, f))) { try { - const docs = await parse( - tsProgram, - file, - config, - reactProjectInfo.absPath, - remoteUrl, - cmd.verbose, - ) + const docs = await parse(tsProgram, file, config, reactProjectInfo.absPath, { + repoUrl: remoteUrl, + debug: cmd.verbose, + silent, + }) codeConnectObjects.push(...docs) if (!silent || cmd.verbose) { logger.info(success(file)) @@ -269,7 +291,7 @@ async function handlePublish(cmd: BaseCommand & { skipValidation: boolean }) { logger.info(codeConnectObjects.map((o) => `- ${o.component} (${o.figmaNode})`).join('\n')) } - const accessToken = getAccessToken(cmd) + const accessToken = getAccessTokenOrExit(cmd) if (cmd.skipValidation) { logger.info('Validation skipped') @@ -278,7 +300,7 @@ async function handlePublish(cmd: BaseCommand & { skipValidation: boolean }) { var start = new Date().getTime() const valid = await validateDocs(cmd, accessToken, codeConnectObjects) if (!valid) { - process.exit(1) + exitWithFeedbackMessage(1) } else { var end = new Date().getTime() var time = end - start @@ -326,7 +348,7 @@ async function handleUnpublish(cmd: BaseCommand & { node: string }) { } } - const accessToken = getAccessToken(cmd) + const accessToken = getAccessTokenOrExit(cmd) delete_docs({ accessToken, @@ -366,7 +388,7 @@ async function handleCreate(nodeUrl: string, cmd: BaseCommand) { process.exit(0) } - const accessToken = getAccessToken(cmd) + const accessToken = getAccessTokenOrExit(cmd) return createCodeConnectFromUrl({ accessToken, diff --git a/cli/src/common/api.ts b/cli/src/common/api.ts index e477e78..f21d377 100644 --- a/cli/src/common/api.ts +++ b/cli/src/common/api.ts @@ -89,10 +89,7 @@ export interface FigmaConnectAPI { * @param figmaPropName The name of the property on the Figma component * @param valueMapping A mapping of values for the Figma Variant */ - enum( - figmaPropName: string, - valueMapping: PropMapping>, - ): ValueOf> + enum(figmaPropName: string, valueMapping: Record): V /** * Maps a Figma property to a string value for the connected component. This prop is replaced @@ -161,7 +158,7 @@ export interface FigmaConnectAPI { * (props) =>