From 8e3161eaf3f184e6639e83c97b060db58f021b0c Mon Sep 17 00:00:00 2001 From: Pau Date: Fri, 12 Apr 2024 12:37:51 +0100 Subject: [PATCH] Release 0.1.0 --- .gitignore | 12 + CONTRIBUTING.md | 14 + LICENSE | 21 + Package.resolved | 32 + Package.swift | 59 + README.md | 17 + react/BUILD.bazel | 58 + react/README.md | 449 ++ react/bin/figma | 5 + react/jest.config.js | 6 + react/package-lock.json | 6604 +++++++++++++++++ react/package.json | 56 + react/scripts/README.md | 77 + react/scripts/import-icons.ts | 206 + .../src/__test__/e2e_connect_command.test.ts | 44 + .../ReactApiComponent.figmadoc.tsx | 5 + .../e2e_connect_command/ReactApiComponent.tsx | 16 + .../StorybookComponent.stories.tsx | 18 + .../StorybookComponent.tsx | 12 + .../e2e_connect_command/tsconfig.json | 11 + react/src/cli.ts | 21 + react/src/commands/connect.ts | 280 + .../__test__/ButtonArrowFunction.figma.tsx | 5 + .../src/common/__test__/ButtonTest.figma.tsx | 7 + .../common/__test__/ChildInstances.figma.tsx | 24 + .../common/__test__/ColocatedCodeConnect.tsx | 13 + .../__test__/ComponentWithLogic.figma.tsx | 10 + .../common/__test__/DefaultImport.figma.tsx | 4 + .../EnumLikeBooleanFalseProp.figma.tsx | 13 + .../__test__/ForwardRefComponent.figma.tsx | 4 + .../__test__/ImportMappingsTest.figma.tsx | 14 + .../common/__test__/ImportMappingsTest.tsx | 13 + .../__test__/InvalidCodeConnect.figma.tsx | 3 + .../__test__/MemoizedComponent.figma.tsx | 7 + .../__test__/NamespacedComponent.figma.tsx | 7 + react/src/common/__test__/NoProps.figma.tsx | 4 + .../common/__test__/PathAliasImport.figma.tsx | 7 + .../common/__test__/PropMappings.figma.tsx | 164 + .../src/common/__test__/PropsSpread.figma.tsx | 26 + react/src/common/__test__/PropsSpread.tsx | 11 + .../__test__/SignatureManyImports.figma.tsx | 8 + .../common/__test__/SignatureManyImports.tsx | 17 + react/src/common/__test__/TestComponents.tsx | 43 + .../__test__/VariableRefFigmaNode.figma.tsx | 6 + .../__test__/VariantRestriction.figma.tsx | 11 + .../Button.expected_template | 3 + .../ButtonWithIcon.expected_template | 3 + .../ComponentWithLogic.expected_template | 6 + ...EnumLikeBooleanFalseProp.expected_template | 7 + .../ImportMappingsTest.expected_template | 7 + .../MemoizedComponent.expected_template | 3 + .../PropMappings.expected_template | 23 + .../PropMappings_indented.expected_template | 23 + .../PropsSpread.expected_template | 17 + .../__test__/expected_templates/README.md | 3 + react/src/common/__test__/parser.test.ts | 546 ++ react/src/common/__test__/tsconfig.json | 19 + react/src/common/api.ts | 165 + react/src/common/compiler.ts | 393 + react/src/common/external.ts | 43 + react/src/common/figma_connect.ts | 22 + react/src/common/intrinsics.ts | 308 + react/src/common/logging.ts | 39 + react/src/common/parser.ts | 968 +++ react/src/common/parser_template_helpers.ts | 54 + react/src/common/project.ts | 238 + react/src/connect/create.ts | 158 + react/src/connect/delete_docs.ts | 48 + react/src/connect/figma_rest_api.ts | 35 + react/src/connect/helpers.ts | 67 + react/src/connect/upload.ts | 46 + react/src/connect/validation.ts | 278 + react/src/index.ts | 10 + react/src/storybook/__test__/convert.test.ts | 286 + .../examples/ArrowComponent.stories.tsx | 18 + .../__test__/examples/ArrowComponent.tsx | 12 + .../ArrowStoriesExplicitReturn.stories.tsx | 35 + .../ArrowStoriesImplicitReturn.stories.tsx | 25 + .../__test__/examples/Examples.stories.tsx | 30 + .../ExamplesVariantRestrictions.stories.tsx | 51 + .../examples/FunctionComponent.stories.tsx | 18 + .../__test__/examples/FunctionComponent.tsx | 12 + .../examples/FunctionStories.stories.tsx | 35 + .../examples/NoDesignParameter.stories.tsx | 13 + .../__test__/examples/NoExamples.stories.tsx | 13 + .../examples/NoParameters.stories.tsx | 10 + .../NoTypeDesignParameter.stories.tsx | 15 + .../NonFigmaDesignParameter.stories.tsx | 15 + .../__test__/examples/PropMapping.stories.tsx | 36 + .../__test__/examples/PropMapping.tsx | 10 + .../StoryObjectWithRender.stories.tsx | 39 + .../storybook/__test__/examples/tsconfig.json | 11 + .../ArrowComponent.expected_template | 3 + .../ArrowComponent_default.expected_template | 3 + .../FunctionComponent.expected_template | 3 + ...nentMultipleRestrictions.expected_template | 3 + ...ctionComponentStringName.expected_template | 3 + ...unctionComponentWithArgs.expected_template | 7 + ...mponentWithArgs_indented.expected_template | 7 + ...onentWithArgs_indented_2.expected_template | 7 + ...unctionComponentWithIcon.expected_template | 3 + ...nctionComponentWithLogic.expected_template | 6 + .../PropMapping.expected_template | 13 + react/src/storybook/convert.ts | 389 + react/src/storybook/external.ts | 32 + react/tsconfig.json | 27 + react/tslint.json | 5 + swiftui/README.md | 275 + .../CodeConnectParserTest/Button.figma.test | 59 + .../CodeConnectCreatorTest.swift | 65 + .../CodeConnectParserTest.swift | 56 + .../CodeConnectTemplateWriterTest.swift | 69 + swiftui/cli/CLI.swift | 290 + swiftui/cli/CodeConnectUnpublisher.swift | 67 + swiftui/cli/CodeConnectUploader.swift | 52 + swiftui/figma-connect/Package.resolved | 32 + swiftui/figma-connect/Package.swift | 55 + swiftui/lib/CodeConnectParser.swift | 344 + swiftui/lib/CodeConnectRequestBody.swift | 117 + swiftui/lib/CodeConnectTemplateWriter.swift | 112 + swiftui/lib/Creation/CodeConnectCreator.swift | 269 + swiftui/lib/Creation/Component.swift | 111 + .../lib/Utility/CollectionExtensions.swift | 8 + swiftui/lib/Utility/FigmaAPIRoute.swift | 91 + swiftui/lib/Utility/Glob.swift | 117 + swiftui/lib/Utility/Logging.swift | 9 + swiftui/lib/Utility/SyntaxExtensions.swift | 153 + swiftui/lib/Utility/Validation.swift | 274 + swiftui/sdk/FigmaConnect.swift | 36 + 129 files changed, 15832 insertions(+) create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 Package.resolved create mode 100644 Package.swift create mode 100644 README.md create mode 100644 react/BUILD.bazel create mode 100644 react/README.md create mode 100755 react/bin/figma create mode 100644 react/jest.config.js create mode 100644 react/package-lock.json create mode 100644 react/package.json create mode 100644 react/scripts/README.md create mode 100644 react/scripts/import-icons.ts create mode 100644 react/src/__test__/e2e_connect_command.test.ts create mode 100644 react/src/__test__/e2e_connect_command/ReactApiComponent.figmadoc.tsx create mode 100644 react/src/__test__/e2e_connect_command/ReactApiComponent.tsx create mode 100644 react/src/__test__/e2e_connect_command/StorybookComponent.stories.tsx create mode 100644 react/src/__test__/e2e_connect_command/StorybookComponent.tsx create mode 100644 react/src/__test__/e2e_connect_command/tsconfig.json create mode 100644 react/src/cli.ts create mode 100644 react/src/commands/connect.ts create mode 100644 react/src/common/__test__/ButtonArrowFunction.figma.tsx create mode 100644 react/src/common/__test__/ButtonTest.figma.tsx create mode 100644 react/src/common/__test__/ChildInstances.figma.tsx create mode 100644 react/src/common/__test__/ColocatedCodeConnect.tsx create mode 100644 react/src/common/__test__/ComponentWithLogic.figma.tsx create mode 100644 react/src/common/__test__/DefaultImport.figma.tsx create mode 100644 react/src/common/__test__/EnumLikeBooleanFalseProp.figma.tsx create mode 100644 react/src/common/__test__/ForwardRefComponent.figma.tsx create mode 100644 react/src/common/__test__/ImportMappingsTest.figma.tsx create mode 100644 react/src/common/__test__/ImportMappingsTest.tsx create mode 100644 react/src/common/__test__/InvalidCodeConnect.figma.tsx create mode 100644 react/src/common/__test__/MemoizedComponent.figma.tsx create mode 100644 react/src/common/__test__/NamespacedComponent.figma.tsx create mode 100644 react/src/common/__test__/NoProps.figma.tsx create mode 100644 react/src/common/__test__/PathAliasImport.figma.tsx create mode 100644 react/src/common/__test__/PropMappings.figma.tsx create mode 100644 react/src/common/__test__/PropsSpread.figma.tsx create mode 100644 react/src/common/__test__/PropsSpread.tsx create mode 100644 react/src/common/__test__/SignatureManyImports.figma.tsx create mode 100644 react/src/common/__test__/SignatureManyImports.tsx create mode 100644 react/src/common/__test__/TestComponents.tsx create mode 100644 react/src/common/__test__/VariableRefFigmaNode.figma.tsx create mode 100644 react/src/common/__test__/VariantRestriction.figma.tsx create mode 100644 react/src/common/__test__/expected_templates/Button.expected_template create mode 100644 react/src/common/__test__/expected_templates/ButtonWithIcon.expected_template create mode 100644 react/src/common/__test__/expected_templates/ComponentWithLogic.expected_template create mode 100644 react/src/common/__test__/expected_templates/EnumLikeBooleanFalseProp.expected_template create mode 100644 react/src/common/__test__/expected_templates/ImportMappingsTest.expected_template create mode 100644 react/src/common/__test__/expected_templates/MemoizedComponent.expected_template create mode 100644 react/src/common/__test__/expected_templates/PropMappings.expected_template create mode 100644 react/src/common/__test__/expected_templates/PropMappings_indented.expected_template create mode 100644 react/src/common/__test__/expected_templates/PropsSpread.expected_template create mode 100644 react/src/common/__test__/expected_templates/README.md create mode 100644 react/src/common/__test__/parser.test.ts create mode 100644 react/src/common/__test__/tsconfig.json create mode 100644 react/src/common/api.ts create mode 100644 react/src/common/compiler.ts create mode 100644 react/src/common/external.ts create mode 100644 react/src/common/figma_connect.ts create mode 100644 react/src/common/intrinsics.ts create mode 100644 react/src/common/logging.ts create mode 100644 react/src/common/parser.ts create mode 100644 react/src/common/parser_template_helpers.ts create mode 100644 react/src/common/project.ts create mode 100644 react/src/connect/create.ts create mode 100644 react/src/connect/delete_docs.ts create mode 100644 react/src/connect/figma_rest_api.ts create mode 100644 react/src/connect/helpers.ts create mode 100644 react/src/connect/upload.ts create mode 100644 react/src/connect/validation.ts create mode 100644 react/src/index.ts create mode 100644 react/src/storybook/__test__/convert.test.ts create mode 100644 react/src/storybook/__test__/examples/ArrowComponent.stories.tsx create mode 100644 react/src/storybook/__test__/examples/ArrowComponent.tsx create mode 100644 react/src/storybook/__test__/examples/ArrowStoriesExplicitReturn.stories.tsx create mode 100644 react/src/storybook/__test__/examples/ArrowStoriesImplicitReturn.stories.tsx create mode 100644 react/src/storybook/__test__/examples/Examples.stories.tsx create mode 100644 react/src/storybook/__test__/examples/ExamplesVariantRestrictions.stories.tsx create mode 100644 react/src/storybook/__test__/examples/FunctionComponent.stories.tsx create mode 100644 react/src/storybook/__test__/examples/FunctionComponent.tsx create mode 100644 react/src/storybook/__test__/examples/FunctionStories.stories.tsx create mode 100644 react/src/storybook/__test__/examples/NoDesignParameter.stories.tsx create mode 100644 react/src/storybook/__test__/examples/NoExamples.stories.tsx create mode 100644 react/src/storybook/__test__/examples/NoParameters.stories.tsx create mode 100644 react/src/storybook/__test__/examples/NoTypeDesignParameter.stories.tsx create mode 100644 react/src/storybook/__test__/examples/NonFigmaDesignParameter.stories.tsx create mode 100644 react/src/storybook/__test__/examples/PropMapping.stories.tsx create mode 100644 react/src/storybook/__test__/examples/PropMapping.tsx create mode 100644 react/src/storybook/__test__/examples/StoryObjectWithRender.stories.tsx create mode 100644 react/src/storybook/__test__/examples/tsconfig.json create mode 100644 react/src/storybook/__test__/expected_templates/ArrowComponent.expected_template create mode 100644 react/src/storybook/__test__/expected_templates/ArrowComponent_default.expected_template create mode 100644 react/src/storybook/__test__/expected_templates/FunctionComponent.expected_template create mode 100644 react/src/storybook/__test__/expected_templates/FunctionComponentMultipleRestrictions.expected_template create mode 100644 react/src/storybook/__test__/expected_templates/FunctionComponentStringName.expected_template create mode 100644 react/src/storybook/__test__/expected_templates/FunctionComponentWithArgs.expected_template create mode 100644 react/src/storybook/__test__/expected_templates/FunctionComponentWithArgs_indented.expected_template create mode 100644 react/src/storybook/__test__/expected_templates/FunctionComponentWithArgs_indented_2.expected_template create mode 100644 react/src/storybook/__test__/expected_templates/FunctionComponentWithIcon.expected_template create mode 100644 react/src/storybook/__test__/expected_templates/FunctionComponentWithLogic.expected_template create mode 100644 react/src/storybook/__test__/expected_templates/PropMapping.expected_template create mode 100644 react/src/storybook/convert.ts create mode 100644 react/src/storybook/external.ts create mode 100644 react/tsconfig.json create mode 100644 react/tslint.json create mode 100644 swiftui/README.md create mode 100644 swiftui/Tests/CodeConnectParserTest/Button.figma.test create mode 100644 swiftui/Tests/CodeConnectParserTest/CodeConnectCreatorTest.swift create mode 100644 swiftui/Tests/CodeConnectParserTest/CodeConnectParserTest.swift create mode 100644 swiftui/Tests/CodeConnectParserTest/CodeConnectTemplateWriterTest.swift create mode 100644 swiftui/cli/CLI.swift create mode 100644 swiftui/cli/CodeConnectUnpublisher.swift create mode 100644 swiftui/cli/CodeConnectUploader.swift create mode 100644 swiftui/figma-connect/Package.resolved create mode 100644 swiftui/figma-connect/Package.swift create mode 100644 swiftui/lib/CodeConnectParser.swift create mode 100644 swiftui/lib/CodeConnectRequestBody.swift create mode 100644 swiftui/lib/CodeConnectTemplateWriter.swift create mode 100644 swiftui/lib/Creation/CodeConnectCreator.swift create mode 100644 swiftui/lib/Creation/Component.swift create mode 100644 swiftui/lib/Utility/CollectionExtensions.swift create mode 100644 swiftui/lib/Utility/FigmaAPIRoute.swift create mode 100644 swiftui/lib/Utility/Glob.swift create mode 100644 swiftui/lib/Utility/Logging.swift create mode 100644 swiftui/lib/Utility/SyntaxExtensions.swift create mode 100644 swiftui/lib/Utility/Validation.swift create mode 100644 swiftui/sdk/FigmaConnect.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..753b724 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.env +figma.config.json +.DS_Store +node_modules +dist/ +src/components +src/bindings.ts +test-files +coverage +bundle/ +.build/ +.swiftpm/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..43b5a8c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,14 @@ +# Contributing + +Thank you for your interest in contributing to this project! However, we are currently not accepting any PRs at this time. We are in the early stages of the development of Code Connect and not ready to accept external contributions through PRs. + +## How can you contribute? + +Although we are not accepting PRs, there are still ways you can contribute to the project: + +1. **Testing**: Help us by testing Code Connect in your codebase and reporting any bugs or issues you encounter. +2. **Feedback**: Provide feedback on the existing features and API and suggest improvements or new ideas. + +## Contact + +If you have any questions please reach out to us through the project's issue tracker. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e9388b5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Figma + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..47b3c84 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,32 @@ +{ + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "46989693916f56d1186bd59ac15124caef896560", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax", + "state" : { + "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", + "version" : "509.1.1" + } + }, + { + "identity" : "swiftformat", + "kind" : "remoteSourceControl", + "location" : "https://github.com/nicklockwood/SwiftFormat", + "state" : { + "revision" : "9e5d0d588ab6e271fe9887ec3dde21d544d4b080", + "version" : "0.53.5" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..2b69c49 --- /dev/null +++ b/Package.swift @@ -0,0 +1,59 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import CompilerPluginSupport +import PackageDescription + +let package = Package( + name: "Figma", + platforms: [ + .iOS(.v15), + .macOS(.v13) + ], + products: [ + .library(name: "Figma", targets: ["Figma"]), + .executable(name: "figma-swift", targets: ["CodeConnectCLI"]) + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-syntax", from: "509.1.1"), + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0"), + .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.49.0"), + ], + targets: [ + .target( + name: "Figma", + path: "swiftui/sdk" + ), + .executableTarget( + name: "CodeConnectCLI", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .target(name: "CodeConnectParser") + ], + path: "swiftui/cli" + ), + .target( + name: "CodeConnectParser", + dependencies: [ + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftParser", package: "swift-syntax"), + .product(name: "SwiftFormat", package: "SwiftFormat"), + .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"), + .target(name: "Figma") + ], + path: "swiftui/lib" + ), + .testTarget( + name: "CodeConnectParserTest", + dependencies: [ + .product(name: "SwiftSyntax", package: "swift-syntax"), + .target(name: "CodeConnectParser"), + .target(name: "Figma") + ], + path: "swiftui/Tests/CodeConnectParserTest", + resources: [ + .copy("Button.figma.test"), + ] + ) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..480b71d --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# Code Connect + +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. + +![image](https://static.figma.com/uploads/1886c9dbc6742f3eeaba7c0b4d6276a44aac0cb7.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. + +## Installation + +To learn how to implement Code Connect for your platform, please navigate to the platform-specific API usage and documentation. + +- [React](react/README.md) +- [SwiftUI](swiftui/README.md) diff --git a/react/BUILD.bazel b/react/BUILD.bazel new file mode 100644 index 0000000..089c63c --- /dev/null +++ b/react/BUILD.bazel @@ -0,0 +1,58 @@ +load("@aspect_bazel_lib//lib:copy_to_directory.bzl", "copy_to_directory") +load("@aspect_bazel_lib//lib:write_source_files.bzl", "write_source_files") +load("@aspect_rules_js//npm:defs.bzl", "npm_package") +load("@npm//:defs.bzl", "npm_link_all_packages") +load("//bazel/js:defs.bzl", "ts_project") + +npm_link_all_packages(name = "node_modules") + +ts_project( + name = "ts", + srcs = glob( + [ + "src/**/*.ts", + ], + exclude = [ + "src/**/__test__/**", + ], + ), + tsconfig = "tsconfig.json", + deps = [ + ":node_modules/@types", + ":node_modules/axios", + ":node_modules/chalk", + ":node_modules/commander", + ":node_modules/glob", + ":node_modules/lodash", + ":node_modules/minimatch", + ":node_modules/prettier", + ":node_modules/typescript", + ], +) + +copy_to_directory( + name = "dist", + srcs = [ + ":ts", + ":ts_typecheck", + ], + replace_prefixes = { + "src/": "/", + }, +) + +write_source_files( + name = "build_dist", + files = {"dist": "dist"}, + visibility = ["//web:__pkg__"], +) + +npm_package( + name = "npm_package", + srcs = [ + "package.json", + ":dist", + ], + package = "@figma/code-connect", + visibility = ["//visibility:public"], +) diff --git a/react/README.md b/react/README.md new file mode 100644 index 0000000..5b9a6ff --- /dev/null +++ b/react/README.md @@ -0,0 +1,449 @@ +# Code Connect (React) + +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. + +## Installation + +Code Connect is used through a command line interface (CLI). The CLI comes bundled with the `@figma/code-connect` package, which you'll need to install through `npm`. This package also includes helper functions and types associated with Code Connect. + +```sh +npm install @figma/code-connect +``` + +## Basic 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`. + +```sh +npx figma connect create https://... --token +``` + +This will create a Code Connect file with some basic scaffolding for the component you want to connect. By default this file will be called `.figma.tsx` based on the name of the component in Figma. However, you may rename this file as you see fit. The scaffolding that is generated is based on the interface of the component in Figma. Depending on how closely this matches your code component you'll need to make some edits to this file before you publish it. + +Some CLI commands, like `create`, require a valid [authentication token](https://help.figma.com/hc/en-us/articles/8085703771159-Manage-personal-access-tokens) with write permission for the Code Connect scope. You can either pass this via the `--token` flag, or set the `FIGMA_ACCESS_TOKEN` environment variable (the Figma CLI reads this from a `.env` file in the same folder, if it exists). + +To keep things simple, we're going to start by replacing the contents of the generated file with the most basic Code Connect configuration possible to make sure everything is set up and working as expected. Replace the contents of the file with the following, replacing the `Button` reference with a reference to whatever component you are trying to connect. The object called by `figma.connect` is your Code Connect doc. + +```tsx +import * as figma from '@figma/code-connect' +import { Button } from 'src/components' + +figma.connect(Button, 'https://...', { + example: () => { + return + ) + }, +}) +``` + +And this is how we would achieve the same thing using the Storybook integration. Notice how this works well with existing `args` configuration you may already be using in Storybook. + +```tsx +import * as figma from '@figma/code-connect' + +export default { + component: Button, + parameters: { + design: { + type: 'figma', + url: 'https://...', + examples: [ButtonExample], + props: { + label: figma.string('Text Content'), + disabled: figma.boolean('Disabled'), + type: figma.enum('Type', { + Primary: ButtonType.Primary, + Secondary: ButtonType.Secondary + }, + }, + }, + argTypes: { + label: { control: 'string' }, + disabled: { control: 'boolean' }, + type: { + control: { + type: 'select', + options: [ButtonType.Primary, ButtonType.Secondary] + } + } + }, + args: { + label: 'Hello world', + disabled: false, + type: ButtonType.Primary + } + } +} + +export function ButtonExample({ label, disabled, type }) { + return +} +``` + +The `figma` import contains helpers for mapping all sorts of properties from design to code. They work for simple mappings where only the naming differs between Figma and code, as well as more complex mappings where the type differs. See the below reference for all the helpers that exist and the ways you can use them to connect Figma and code components using Code Connect. + +### Strings + +Strings are the simplest value to map from Figma to code. Simply call `figma.string` with the Figma prop name you want to reference as a parameter. This is useful for things like button labels, header titles, tooltips, etc. + +```tsx +figma.string('Title') +``` + +### Booleans + +Booleans work similar to strings. However Code Connect also provides helpers for mapping booleans in Figma to more complex types in code. For example you may want to map a Figma boolean to the existence of a specific sublayer in code. + +```tsx +// simple mapping of boolean from figma to code +figma.boolean('Has Icon') + +// map a boolean value to one of two options of any type +figma.boolean('Has Icon', { + true: , + false: , +}) +``` + +In some cases, you only want to render a certain prop if it matches some value in Figma. You can do this either by passing a partial mapping object, or setting the value to `undefined`. + +```tsx +// Don't render the prop if 'Has Icon' in figma is `false` +figma.boolean('Has Icon', { + true: , + false: undefined, +}) +``` + +### 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. + +```tsx +// maps the 'Options' variant in Figma to enum values in code +figma.enum('Options', { + 'Option 1': Option.first, + 'Option 2': Option.second, +}) + +// maps the 'Options' variant in Figma to sub-component values in code +figma.enum('Options', { + 'Option 1': , + 'Option 2': , +}) + +// result is true for disabled variants otherwise undefined +figma.enum('Variant', { Disabled: true }) +``` + +Mapping objects for `figma.enum` as well as `figma.boolean` allows nested references, which is useful if you want to conditionally render a nested instance for example. (see the next section for how to use `figma.instance`) + +```tsx +// maps the 'Options' variant in Figma to enum values in code +figma.enum('Type', { + WithIcon: figma.instance('Icon'), + WithoutIcon: undefined, +}) +``` + +### Instances + +Instances is a Figma term for nested component references. For example, in the case of a `Button` containing an `Icon` as a nested component, we would call the `Icon` an instance. In Figma instances can be properties, (that is, inputs to the component), just like we have render props in code. Similarly to how we can map booleans, enums, and strings from Figma to code, we can also map these to instance props. + +To ensure instance properties are as useful as possible with Code Connect, it is advised that you also provide Code Connect for the common components which you would expect to be used as values to this property. Dev Mode will automatically hydrate the referenced component's connected code snippet example and how changes it in Dev Mode for instance props. + +```tsx +// maps an instance-swap property from Figma +figma.instance('PropName') +``` + +The return value of `figma.instance` is a JSX component and can be used in your example like a typical JSX component prop would be in your codebase. + +```tsx +figma.connect(Button, 'https://...', { + props: { + icon: figma.instance('Icon'), + }, + example: ({ icon }) => { + return + }, +}) +``` + +You should then have a separate `figma.connect` call that connects the Icon component with the nested Figma component. Make sure to connect the backing component of that instance, not the instance itself. + +```tsx +figma.connect(Icon32Add, 'https://...') +``` + +### Instance children + +It's common for components in Figma to have child instances that aren't bound to an instance-swap prop. Similarly to `figma.instance`, we can render the code snippets for these nested instances with `figma.children`. This helper takes the _name of the instance layer_ as it's parameter, rather than a Figma prop name. Note that the nested instance also must be connected separately. + +```tsx +// map one child instance with the layer name "Tab" +figma.children('Tab') + +// map multiple child instances by their layer names to a single prop +figma.children(['Tab 1', 'Tab 2']) +``` + +## Variant restrictions + +Sometimes a component in Figma is represented by more than one component in code. For example you may have a single `Button` in your Figma design system with a `type` property to switch between primary, secondary, and danger variants. However, in code this may be represented by three different components, a `PrimaryButton`, `SecondaryButton` and `DangerButton`. + +To model this behaviour with Code Connect we can make use of something called variant restrictions. Variant restrictions allow you to provide entirely different code samples for different variants of a single Figma component. The keys and values used should match the name of the variant (or [roperty) in Figma and it's options respectively. + +```tsx +figma.connect(PrimaryButton, 'https://...', { + variant: { Type: 'Primary' }, + example: () => , +}) + +figma.connect(SecondaryButton, 'https://...', { + variant: { Type: 'Secondary' }, + example: () => , +}) + +figma.connect(DangerButton, 'https://...', { + variant: { Type: 'Danger' }, + example: () => , +}) +``` + +This will also work for Figma properties that aren't variants (for example, boolean props). + +``` +figma.connect(IconButton, 'https://...', { + variant: { "Has Icon": true }, + example: () => , +}) +``` + +In some complex cases you may also want to map a code component to a combination of variants in Figma. + +```tsx +figma.connect(DangerButton, 'https://...', { + variant: { Type: 'Danger', Disabled: true }, + example: () => , +}) +``` + +You can achieve the same thing using the Storybook API. + +```tsx +export default { + component: Button, + parameters: { + design: { + type: 'figma', + url: 'https://...', + examples: [ + { example: PrimaryButtonStory, variant: { Type: 'Primary' } }, + { example: SecondaryButtonStory, variant: { Type: 'Secondary' } }, + { example: DangerButtonStory, variant: { Type: 'Danger' } }, + ], + }, + }, +} + +export function PrimaryButtonStory() { + return +} + +export function SecondaryButtonStory() { + return +} + +export function DangerButtonStory() { + return +} +``` + +## Icons + +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](./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. + +## 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. + +```yml +on: + push: + paths: + - src/components/**/*.figma.tsx + branches: + - main + +jobs: + code-connect: + name: Code Connect + runs-on: ubuntu-latest + steps: + - run: npx figma connect publish +``` + +## Co-locating Code Connect files + +By default Code Connect creates a new file which lives alongside the components you want to connect to Figma. However, Code Connect may also be co-located with the component it is connecting in code. To do this, simply move the contents of the `.figma.tsx` file into your component definition file. + +```tsx +import * as figma from '@figma/code-connect' + +interface ButtonProps { ... } +export function Button(props: ButtonProps) { ... } + +figma.connect(Button, "https://...", { + example: () => { + return +} diff --git a/react/src/__test__/e2e_connect_command/StorybookComponent.stories.tsx b/react/src/__test__/e2e_connect_command/StorybookComponent.stories.tsx new file mode 100644 index 0000000..b6e039a --- /dev/null +++ b/react/src/__test__/e2e_connect_command/StorybookComponent.stories.tsx @@ -0,0 +1,18 @@ +import { StoryParameters } from '../..' +import { StorybookComponent } from './StorybookComponent' + +export default { + title: 'StorybookComponent', + component: StorybookComponent, + parameters: { + design: { + type: 'figma', + url: 'https://figma.com/test', + examples: ['Default'], + }, + } satisfies StoryParameters, +} + +export function Default() { + return Hello +} diff --git a/react/src/__test__/e2e_connect_command/StorybookComponent.tsx b/react/src/__test__/e2e_connect_command/StorybookComponent.tsx new file mode 100644 index 0000000..12ddf80 --- /dev/null +++ b/react/src/__test__/e2e_connect_command/StorybookComponent.tsx @@ -0,0 +1,12 @@ +import { ReactNode } from 'react' + +interface Props { + disabled: boolean + children: ReactNode +} + +export const StorybookComponent = ({ disabled, children }: Props) => { + const someOtherCode = 'some other code' + + return +} diff --git a/react/src/__test__/e2e_connect_command/tsconfig.json b/react/src/__test__/e2e_connect_command/tsconfig.json new file mode 100644 index 0000000..7345def --- /dev/null +++ b/react/src/__test__/e2e_connect_command/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "outDir": "dist", + "module": "commonjs", + "target": "es6", + "esModuleInterop": true, + "skipLibCheck": true, + }, + "include": ["*.ts", "*.tsx"], +} diff --git a/react/src/cli.ts b/react/src/cli.ts new file mode 100644 index 0000000..58f8cab --- /dev/null +++ b/react/src/cli.ts @@ -0,0 +1,21 @@ +#!/usr/bin/env node + +import * as commander from 'commander' +import { addConnectCommandToProgram } from './commands/connect' + +require('dotenv').config() + +async function run() { + const program = new commander.Command().version(require('./../package.json').version) + program.enablePositionalOptions() + + addConnectCommandToProgram(program) + + program.parse(process.argv) + if (program.args.length < 1) { + program.outputHelp() + process.exit(1) + } +} + +run() diff --git a/react/src/commands/connect.ts b/react/src/commands/connect.ts new file mode 100644 index 0000000..1a2a9d1 --- /dev/null +++ b/react/src/commands/connect.ts @@ -0,0 +1,280 @@ +import * as commander from 'commander' +import { InternalError, ParserError, isFigmaConnectFile, parse } from '../common/parser' +import fs from 'fs' +import { upload } from '../connect/upload' +import { validateDocs } from '../connect/validation' +import { createFigmadocFromUrl } from '../connect/create' +import { ProjectInfo, getProjectInfo } from '../common/project' +import { LogLevel, error, highlight, logger, success } from '../common/logging' +import { FigmaConnectJSON } from '../common/figma_connect' +import { convertStorybookFiles } from '../storybook/convert' +import { delete_docs } from '../connect/delete_docs' + +type BaseCommand = commander.Command & { + token: string + debug: boolean + outfile: string + config: string + dryRun: boolean + dir: string +} + +function addBaseCommand(command: commander.Command, name: string, description: string) { + return command + .command(name) + .description(description) + .usage('[options]') + .option('-r --dir ', 'directory to parse') + .option('-t --token ', 'figma access token') + .option('-d --debug', 'output debug logs') + .option('-o --outfile ', 'output to JSON file') + .option('-c --config ', 'path to a figma config file') + .option('--dry-run', 'tests publishing without actually publishing') +} + +export function addConnectCommandToProgram(program: commander.Command) { + // Main command, invoked with `figma connect` + const connectCommand = addBaseCommand( + program, + 'connect', + 'Start the Code Connect Wizard (not implemented yet)', + ) + + // Sub-commands, invoked with e.g. `figma connect publish` + addBaseCommand( + connectCommand, + 'publish', + 'Run Code Connect locally to find any files that include calls to `figma.connect()` and publishes those to Figma. ' + + 'By default this looks for a config file named "figma.config.json", and uses the `include` and `exclude` fields to determine which files to parse. ' + + 'If no config file is found, this parses the current directory. An optional `--dir` flag can be used to specify a directory to parse.', + ) + .option('--skip-validation', 'skip validation of Code Connect docs') + .action(handlePublish) + + addBaseCommand( + connectCommand, + 'unpublish', + 'Run to find any files that include calls to `figma.connect()` and unpublish them from Figma. ' + + 'By default this looks for a config file named "figma.config.json", and uses the `include` and `exclude` fields to determine which files to parse. ' + + 'If no config file is found, this parses the current directory. An optional `--dir` flag can be used to specify a directory to parse.', + ) + .option( + '--node ', + 'specify the node to unpublish. This will unpublish for both React and Storybook.', + ) + .action(handleUnpublish) + + addBaseCommand( + connectCommand, + 'parse', + 'Run Code Connect locally to find any files that include calls to `figma.connect()`, then converts to JSON and outputs to stdout.', + ).action(handleParse) + + addBaseCommand( + connectCommand, + 'create', + 'Generate a Code Connect file with boilerplate in the current directory for a Figma node URL', + ) + .argument('', 'Figma node URL to create the Code Connect file from') + .action(handleCreate) +} + +function getAccessToken(cmd: BaseCommand) { + return cmd.token ?? process.env.FIGMA_ACCESS_TOKEN +} + +function setupHandler(cmd: BaseCommand) { + if (cmd.debug) { + logger.setLogLevel(LogLevel.Debug) + } +} + +async function getFigmadocs(dir: string, cmd: BaseCommand, projectInfo: ProjectInfo) { + const figmadocs: FigmaConnectJSON[] = [] + const { files, remoteUrl, config, tsProgram } = projectInfo + + const figmaNodeToFile = new Map() + for (const file of files.filter((f: string) => isFigmaConnectFile(tsProgram, f))) { + try { + const docs = await parse(tsProgram, file, remoteUrl, config, cmd.debug) + for (const doc of docs) { + figmaNodeToFile.set(doc.figmaNode, file) + } + figmadocs.push(...docs) + logger.info(success(file)) + } catch (e) { + logger.error(`❌ ${file}`) + if (e instanceof ParserError) { + if (cmd.debug) { + console.trace(e) + } else { + logger.error(e.toString()) + } + } else { + if (cmd.debug) { + console.trace(e) + } else { + logger.error(new InternalError(String(e)).toString()) + } + } + } + } + + return figmadocs +} + +async function handlePublish(cmd: BaseCommand & { skipValidation: boolean }) { + setupHandler(cmd) + + let dir = cmd.dir ?? process.cwd() + const projectInfo = getProjectInfo(dir, cmd.config) + + if (cmd.dryRun) { + logger.info(`Files that would be published:`) + } + + const figmadocs = await getFigmadocs(dir, cmd, projectInfo) + const storybookFigmadocs = await convertStorybookFiles({ + projectInfo, + }) + + const allCodeConnectFiles = figmadocs.concat(storybookFigmadocs) + if (allCodeConnectFiles.length === 0) { + logger.warn( + `No Code Connect files found in ${dir} - Make sure you have configured \`include\` and \`exclude\` in your figma.config.json file correctly, or that you are running in a directory that contains Code Connect files.`, + ) + process.exit(0) + } + + const accessToken = getAccessToken(cmd) + if (!accessToken) { + logger.error( + `Couldn't find a Figma access token. Please provide one with \`--token \` or set the FIGMA_ACCESS_TOKEN environment variable`, + ) + process.exit(1) + } + + if (cmd.skipValidation) { + logger.info('Validation skipped') + } else { + logger.info('Validating Code Connect files...') + var start = new Date().getTime() + const valid = await validateDocs(accessToken, allCodeConnectFiles) + if (!valid) { + process.exit(1) + } else { + var end = new Date().getTime() + var time = end - start + logger.info(`All Code Connect files are valid (${time}ms)`) + } + } + + if (cmd.dryRun) { + logger.info(`Dry run complete`) + process.exit(0) + } + + upload({ accessToken, docs: allCodeConnectFiles }) +} + +async function handleUnpublish(cmd: BaseCommand & { node: string }) { + setupHandler(cmd) + + let dir = cmd.dir ?? process.cwd() + + if (cmd.dryRun) { + logger.info(`Files that would be unpublished:`) + } + + let nodesToDeleteRelevantInfo + + if (cmd.node) { + nodesToDeleteRelevantInfo = [ + { figmaNode: cmd.node, label: 'React' }, + { figmaNode: cmd.node, label: 'Storybook' }, + ] + } else { + const projectInfo = getProjectInfo(dir, cmd.config) + + const figmadocs = await getFigmadocs(dir, cmd, projectInfo) + const storybookFigmadocs = await convertStorybookFiles({ + projectInfo, + }) + + const allCodeConnectFiles = figmadocs.concat(storybookFigmadocs) + + nodesToDeleteRelevantInfo = allCodeConnectFiles.map((doc) => ({ + figmaNode: doc.figmaNode, + label: doc.label, + })) + + if (cmd.dryRun) { + logger.info(`Dry run complete`) + process.exit(0) + } + } + + const accessToken = getAccessToken(cmd) + + delete_docs({ + accessToken, + docs: nodesToDeleteRelevantInfo, + }) +} + +async function handleParse(cmd: BaseCommand) { + setupHandler(cmd) + + // if we're not doing a dry run, we don't want to output logs + if (!cmd.dryRun) { + logger.setLogLevel(LogLevel.Error) + } + + let dir = cmd.dir ?? process.cwd() + const projectInfo = getProjectInfo(dir, cmd.config) + + const figmadocs = await getFigmadocs(dir, cmd, projectInfo) + const storybookFigmadocs = await convertStorybookFiles({ + projectInfo, + }) + + const allCodeConnectFiles = figmadocs.concat(storybookFigmadocs) + + if (cmd.dryRun) { + logger.info(`Dry run complete`) + process.exit(0) + } + + if (cmd.outfile) { + fs.writeFileSync(cmd.outfile, JSON.stringify(figmadocs, null, 2)) + logger.info(`Wrote Code Connect JSON to ${highlight(cmd.outfile)}`) + } else { + // don't format the output, so it can be piped to other commands + console.log(JSON.stringify(allCodeConnectFiles, undefined, 2)) + } +} + +function handleCreate(nodeUrl: string, cmd: BaseCommand) { + setupHandler(cmd) + + if (cmd.dryRun) { + process.exit(0) + } + + const accessToken = getAccessToken(cmd) + if (!accessToken) { + logger.error( + `Couldn't find a Figma access token. Please provide one with \`--token \` or set the FIGMA_ACCESS_TOKEN environment variable`, + ) + process.exit(1) + } + + return createFigmadocFromUrl({ + accessToken, + // We remove \s to allow users to paste URLs inside quotes - the terminal + // paste will add backslashes, which the quotes preserve, but expected user + // behaviour would be to strip the quotes + figmaNodeUrl: nodeUrl.replace(/\\/g, ''), + outFile: cmd.outfile, + }) +} diff --git a/react/src/common/__test__/ButtonArrowFunction.figma.tsx b/react/src/common/__test__/ButtonArrowFunction.figma.tsx new file mode 100644 index 0000000..c8d8735 --- /dev/null +++ b/react/src/common/__test__/ButtonArrowFunction.figma.tsx @@ -0,0 +1,5 @@ +import figma from '../..' + +import { ButtonArrowFunction } from './TestComponents' + +figma.connect(ButtonArrowFunction, 'ui/button') diff --git a/react/src/common/__test__/ButtonTest.figma.tsx b/react/src/common/__test__/ButtonTest.figma.tsx new file mode 100644 index 0000000..d77cd2d --- /dev/null +++ b/react/src/common/__test__/ButtonTest.figma.tsx @@ -0,0 +1,7 @@ +import React from 'react' +import figma from '../..' +import { Button } from './TestComponents' + +figma.connect(Button, 'ui/button', { + example: () => , +}) diff --git a/react/src/common/__test__/ChildInstances.figma.tsx b/react/src/common/__test__/ChildInstances.figma.tsx new file mode 100644 index 0000000..5e8516c --- /dev/null +++ b/react/src/common/__test__/ChildInstances.figma.tsx @@ -0,0 +1,24 @@ +import * as React from 'react' +import { figma } from '../..' +import { Button } from './TestComponents' + +figma.connect(Button, 'instanceSwap', { + props: { + icon: figma.instance('Icon Prop'), + }, + example: ({ icon }) => , +}) + +figma.connect(Button, 'children', { + props: { + icon: figma.children('Icon Layer'), + }, + example: ({ icon }) => , +}) + +figma.connect(Button, 'children array', { + props: { + icons: figma.children(['Icon 1', 'Icon 2', 'Icon 3']), + }, + example: ({ icons }) => , +}) diff --git a/react/src/common/__test__/ColocatedCodeConnect.tsx b/react/src/common/__test__/ColocatedCodeConnect.tsx new file mode 100644 index 0000000..abe7671 --- /dev/null +++ b/react/src/common/__test__/ColocatedCodeConnect.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import figma from '../..' + +interface ButtonProps { + disabled?: boolean + children: any +} + +export function ColocatedButton({ children, disabled = false }: ButtonProps) { + return +} + +figma.connect(ColocatedButton, 'ui/button') diff --git a/react/src/common/__test__/ComponentWithLogic.figma.tsx b/react/src/common/__test__/ComponentWithLogic.figma.tsx new file mode 100644 index 0000000..18c575e --- /dev/null +++ b/react/src/common/__test__/ComponentWithLogic.figma.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import figma from '../..' +import { Button } from './TestComponents' + +figma.connect(Button, 'ui/button', { + example: () => { + const [state] = React.useState(false) + return , +}) diff --git a/react/src/common/__test__/PropMappings.figma.tsx b/react/src/common/__test__/PropMappings.figma.tsx new file mode 100644 index 0000000..36ea717 --- /dev/null +++ b/react/src/common/__test__/PropMappings.figma.tsx @@ -0,0 +1,164 @@ +import React from 'react' +import figma from '../..' +import { Button } from './TestComponents' + +figma.connect(Button, 'propsInline', { + props: { + variant: figma.enum('👥 Variant', { + Primary: 'primary', + Destructive: 'destructive', + Inverse: 'inverse', + Success: 'success', + FigJam: 'FigJam', + Secondary: 'secondary', + 'Secondary Destruct': 'destructive-secondary', + }), + size: figma.enum('👥 Size', { + Default: 'hug-contents', + Large: undefined, + Wide: 'fit-parent', + }), + state: figma.enum('🐣 State', { + Default: 'Default', + Active: 'Active', + Focused: 'Focused', + }), + disabled: figma.boolean('🎛️ Disabled'), + iconLead: figma.boolean('🎛️ Icon Lead', { + true: 'icon', + false: undefined, + }), + label: figma.string('🎛️ Label'), + }, + example: ({ variant, size, disabled, label, iconLead }) => ( + + ), +}) + +const props = { + variant: figma.enum('👥 Variant', { + Primary: 'primary', + Destructive: 'destructive', + Inverse: 'inverse', + Success: 'success', + FigJam: 'FigJam', + Secondary: 'secondary', + 'Secondary Destruct': 'destructive-secondary', + }), + size: figma.enum('👥 Size', { + Default: 'hug-contents', + Large: undefined, + Wide: 'fit-parent', + }), + state: figma.enum('🐣 State', { + Default: 'Default', + Active: 'Active', + Focused: 'Focused', + }), + disabled: figma.boolean('🎛️ Disabled'), + iconLead: figma.boolean('🎛️ Icon Lead', { + true: 'icon', + false: undefined, + }), + label: figma.string('🎛️ Label'), +} + +figma.connect(Button, 'propsSeparateObject', { + props, + example: ({ variant, size, disabled, label, iconLead }) => ( + + ), +}) + +figma.connect(Button, 'propsSpreadSyntax', { + props: { ...props }, + example: ({ variant, size, disabled, label, iconLead }) => ( + + ), +}) + +figma.connect(Button, 'dotNotation', { + props, + example: (props) => ( + + ), +}) + +figma.connect(Button, 'quotesNotation', { + props, + example: (props) => ( + + ), +}) + +figma.connect(Button, 'destructured', { + props, + example: ({ variant, size, disabled, label, iconLead }) => ( + + ), +}) + +figma.connect(Button, 'namedFunction', { + props, + example: function ButtonExample({ variant, size, disabled, label, iconLead }) { + return ( + + ) + }, +}) diff --git a/react/src/common/__test__/PropsSpread.figma.tsx b/react/src/common/__test__/PropsSpread.figma.tsx new file mode 100644 index 0000000..ebc5df7 --- /dev/null +++ b/react/src/common/__test__/PropsSpread.figma.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import figma from '../..' +import { Button } from './PropsSpread' + +const props = { + variant: figma.enum('👥 Variant', { + Primary: 'primary', + Destructive: 'destructive', + Inverse: 'inverse', + Success: 'success', + FigJam: 'FigJam', + Secondary: 'secondary', + 'Secondary Destruct': 'destructive-secondary', + }), + width: figma.enum('👥 Size', { + Default: 'hug-contents', + Large: undefined, + Wide: 'fit-parent', + }), + disabled: figma.boolean('🎛️ Disabled'), +} + +figma.connect(Button, 'spread', { + props, + example: (props: any) => +} + +export const ButtonArrowFunction = ({ children, disabled = false }: ButtonProps) => { + return +} + +export const ForwardRefButton = React.forwardRef(Button) + +export const MemoButton = React.memo(Button) + +const _Button = React.forwardRef<{}, ButtonProps>(function Button({ children, disabled = false }) { + return +}) + +const _Other = React.forwardRef<{}, ButtonProps>(function Other({ children, disabled = false }) { + return +}) + +export const NamespacedComponents = { Button: _Button, Other: _Other } + +export function ComponentWithoutProps() { + return null +} + +export function Icon() { + return Icon +} + +export default Button diff --git a/react/src/common/__test__/VariableRefFigmaNode.figma.tsx b/react/src/common/__test__/VariableRefFigmaNode.figma.tsx new file mode 100644 index 0000000..924401d --- /dev/null +++ b/react/src/common/__test__/VariableRefFigmaNode.figma.tsx @@ -0,0 +1,6 @@ +import figma from '../..' +import Button from './TestComponents' + +const buttonURL = 'identifierAsUrl' + +figma.connect(Button, buttonURL) diff --git a/react/src/common/__test__/VariantRestriction.figma.tsx b/react/src/common/__test__/VariantRestriction.figma.tsx new file mode 100644 index 0000000..3065e66 --- /dev/null +++ b/react/src/common/__test__/VariantRestriction.figma.tsx @@ -0,0 +1,11 @@ +import React from 'react' +import figma from '../..' +import { Button } from './TestComponents' + +figma.connect(Button, 'ui/button', { + example: () => , +}) +figma.connect(Button, 'ui/button', { + variant: { HasIcon: true }, + example: () => , +}) diff --git a/react/src/common/__test__/expected_templates/Button.expected_template b/react/src/common/__test__/expected_templates/Button.expected_template new file mode 100644 index 0000000..43fcd2f --- /dev/null +++ b/react/src/common/__test__/expected_templates/Button.expected_template @@ -0,0 +1,3 @@ +const figma = require('figma') + +export default figma.tsx`` diff --git a/react/src/common/__test__/expected_templates/ButtonWithIcon.expected_template b/react/src/common/__test__/expected_templates/ButtonWithIcon.expected_template new file mode 100644 index 0000000..ab610dc --- /dev/null +++ b/react/src/common/__test__/expected_templates/ButtonWithIcon.expected_template @@ -0,0 +1,3 @@ +const figma = require('figma') + +export default figma.tsx`` diff --git a/react/src/common/__test__/expected_templates/ComponentWithLogic.expected_template b/react/src/common/__test__/expected_templates/ComponentWithLogic.expected_template new file mode 100644 index 0000000..c82ae10 --- /dev/null +++ b/react/src/common/__test__/expected_templates/ComponentWithLogic.expected_template @@ -0,0 +1,6 @@ +const figma = require('figma') + +export default figma.tsx`function Example() { + const [state] = React.useState(false); + return ` diff --git a/react/src/common/__test__/expected_templates/PropMappings_indented.expected_template b/react/src/common/__test__/expected_templates/PropMappings_indented.expected_template new file mode 100644 index 0000000..625173b --- /dev/null +++ b/react/src/common/__test__/expected_templates/PropMappings_indented.expected_template @@ -0,0 +1,23 @@ +const figma = require('figma') + +const variant = figma.properties.enum('👥 Variant', { +"Primary": 'primary', +"Destructive": 'destructive', +"Inverse": 'inverse', +"Success": 'success', +"FigJam": 'FigJam', +"Secondary": 'secondary', +"Secondary Destruct": 'destructive-secondary'}) +const size = figma.properties.enum('👥 Size', { +"Default": 'hug-contents', +"Large": undefined, +"Wide": 'fit-parent'}) +const disabled = figma.properties.boolean('🎛️ Disabled') +const iconLead = figma.properties.boolean('🎛️ Icon Lead', { +"true": 'icon', +"false": undefined}) +const label = figma.properties.string('🎛️ Label') + +export default figma.tsx` { }}${_fcc_renderReactProp('width', size)}${_fcc_renderReactProp('disabled', disabled)}${_fcc_renderReactProp('iconLead', iconLead)}> + ${label} + ` diff --git a/react/src/common/__test__/expected_templates/PropsSpread.expected_template b/react/src/common/__test__/expected_templates/PropsSpread.expected_template new file mode 100644 index 0000000..78413a7 --- /dev/null +++ b/react/src/common/__test__/expected_templates/PropsSpread.expected_template @@ -0,0 +1,17 @@ +const figma = require('figma') + +const variant = figma.properties.enum('👥 Variant', { +"Primary": 'primary', +"Destructive": 'destructive', +"Inverse": 'inverse', +"Success": 'success', +"FigJam": 'FigJam', +"Secondary": 'secondary', +"Secondary Destruct": 'destructive-secondary'}) +const width = figma.properties.enum('👥 Size', { +"Default": 'hug-contents', +"Large": undefined, +"Wide": 'fit-parent'}) +const disabled = figma.properties.boolean('🎛️ Disabled') + +export default figma.tsx`` diff --git a/react/src/common/__test__/expected_templates/README.md b/react/src/common/__test__/expected_templates/README.md new file mode 100644 index 0000000..5dd654f --- /dev/null +++ b/react/src/common/__test__/expected_templates/README.md @@ -0,0 +1,3 @@ +These files match the raw templates generated by the parser. The best way to capture them is to console.log them and then create a file for them. + +Be careful not to auto-format on save (use "Save Without Formatting" in VS Code). They are named `.expected_template` to prevent Prettier trying to format/check them. \ No newline at end of file diff --git a/react/src/common/__test__/parser.test.ts b/react/src/common/__test__/parser.test.ts new file mode 100644 index 0000000..cf2a142 --- /dev/null +++ b/react/src/common/__test__/parser.test.ts @@ -0,0 +1,546 @@ +import { ParserError, parse } from '../parser' +import ts from 'typescript' +import path from 'path' +import { FigmaConnectConfig } from '../project' +import { readFileSync, writeFileSync } from 'fs' + +async function testParse(file: string, extraFiles: string[] = [], config?: FigmaConnectConfig) { + const program = ts.createProgram( + [ + path.join(__dirname, file), + path.join(__dirname, 'TestComponents.tsx'), + ...extraFiles.map((file) => path.join(__dirname, file)), + ], + { + paths: config?.paths ?? {}, + }, + ) + return await parse( + program, + path.join(__dirname, file), + 'git@github.com:figma/code-connect.git', + config, + false, + ) +} + +function getExpectedTemplate(name: string) { + return ( + require('../parser_template_helpers').getParsedTemplateHelpersString() + + '\n\n' + + readFileSync(path.join(__dirname, 'expected_templates', `${name}.expected_template`), 'utf-8') + ) +} + +describe('Parser (JS templates)', () => { + it('should parse a simple code sample', async () => { + const result = await testParse('ButtonTest.figma.tsx') + + expect(result).toMatchObject([ + { + figmaNode: 'ui/button', + source: + 'https://github.com/figma/code-connect/tree/master/react/src/common/__test__/TestComponents.tsx', + sourceLocation: { line: 12 }, + template: getExpectedTemplate('Button'), + templateData: { + imports: ["import { Button } from './TestComponents'"], + }, + }, + ]) + }) + + it('should handle components exported as arrow functions', async () => { + const result = await testParse('ButtonArrowFunction.figma.tsx') + + expect(result).toMatchObject([ + { + figmaNode: 'ui/button', + template: + 'const figma = require("figma")\n\nexport default figma.tsx``', + templateData: { + imports: ["import { ButtonArrowFunction } from './TestComponents'"], + }, + }, + ]) + }) + + it('throws an error for invalid Code Connect metadata format', async () => { + await expect(() => testParse('InvalidCodeConnect.figma.tsx')).rejects.toThrowError(ParserError) + }) + + it('should parse variant restrictions', async () => { + const result = await testParse('VariantRestriction.figma.tsx') + + expect(result).toMatchObject([ + { + figmaNode: 'ui/button', + template: getExpectedTemplate('Button'), + templateData: { + imports: ["import { Button } from './TestComponents'"], + }, + }, + { + figmaNode: 'ui/button', + variant: { HasIcon: true }, + template: getExpectedTemplate('ButtonWithIcon'), + templateData: { + imports: ["import { Button } from './TestComponents'"], + }, + }, + ]) + }) + + it('should parse forwardRef:d components', async () => { + const result = await testParse('ForwardRefComponent.figma.tsx') + + expect(result).toMatchObject([ + { + figmaNode: 'ui/button', + sourceLocation: { line: 20 }, + template: + 'const figma = require("figma")\n\nexport default figma.tsx``', + templateData: { + imports: ["import { ForwardRefButton } from './TestComponents'"], + }, + }, + ]) + }) + + it('should parse memoized components', async () => { + const result = await testParse('MemoizedComponent.figma.tsx') + + expect(result).toMatchObject([ + { + figmaNode: 'ui/button', + sourceLocation: { line: 22 }, + template: getExpectedTemplate('MemoizedComponent'), + templateData: { + imports: ["import { MemoButton } from './TestComponents'"], + }, + }, + ]) + }) + + it('should rewrite paths if importPaths is specified', async () => { + const result = await testParse('ButtonTest.figma.tsx', [], { + importPaths: { + '__test__/*': '@lib', + }, + }) + + expect(result).toMatchObject([ + { + templateData: { + imports: ["import { Button } from '@lib'"], + }, + }, + ]) + }) + + it('should handle rewriting * paths', async () => { + const result = await testParse('ButtonTest.figma.tsx', [], { + importPaths: { + '__test__/*': '@lib/*', + }, + }) + + expect(result).toMatchObject([ + { + templateData: { + imports: ["import { Button } from '@lib/ButtonTest'"], + }, + }, + ]) + }) + + it('should insert import statements into examples', async () => { + const result = await testParse('ImportMappingsTest.figma.tsx', ['ImportMappingsTest.tsx'], { + importPaths: { + '__test__/*': '@lib/*', + }, + }) + + expect(result).toMatchObject([ + { + template: getExpectedTemplate('ImportMappingsTest'), + templateData: { + imports: [ + "import { One, Two } from '@lib/ImportMappingsTest'", + "import Three from '@lib/ImportMappingsTest'", + ], + }, + }, + ]) + }) + + it('handles default imports for components', async () => { + const result = await testParse('DefaultImport.figma.tsx') + + expect(result).toMatchObject([ + { + template: 'const figma = require("figma")\n\nexport default figma.tsx``', + templateData: { + imports: ["import RenamedButton from './TestComponents'"], + }, + }, + ]) + }) + + it('handles namespaced components without crashing', async () => { + const result = await testParse('NamespacedComponent.figma.tsx') + + expect(result).toMatchObject([ + { + figmaNode: 'ui/button', + component: 'NamespacedComponents.Button', + }, + ]) + }) + + it('Only includes the imports used in the example', async () => { + const result = await testParse('SignatureManyImports.figma.tsx', ['SignatureManyImports.tsx']) + + expect(result).toMatchObject([ + { + figmaNode: '1', + component: 'Icon1', + templateData: { + imports: ["import { Icon1 } from './SignatureManyImports'"], + }, + }, + { + figmaNode: '2', + component: 'Icon2', + templateData: { + imports: ["import { Icon2 } from './SignatureManyImports'"], + }, + }, + { + figmaNode: '3', + component: 'Icon3', + templateData: { + imports: ["import { Icon3 } from './SignatureManyImports'"], + }, + }, + { + figmaNode: '4', + component: 'Icon4', + templateData: { + imports: ["import { Icon4 } from './SignatureManyImports'"], + }, + }, + { + figmaNode: '5', + component: 'Icon5', + templateData: { + imports: ["import { Icon5 } from './SignatureManyImports'"], + }, + }, + ]) + }) + + it('Parses example function with logic and wraps in an Example function', async () => { + const result = await testParse('ComponentWithLogic.figma.tsx') + + expect(result).toMatchObject([ + { + figmaNode: 'ui/button', + component: 'Button', + template: getExpectedTemplate('ComponentWithLogic'), + templateData: { + imports: ["import { Button } from './TestComponents'"], + }, + }, + ]) + }) + + it('Parses prop mappings and forwards them to the template', async () => { + const result = await testParse('PropMappings.figma.tsx') + + // The TS printer seems to preserving the source indentation, which means + // that the namedFunction example is indented one level deeper than the + // rest. It would be nice to solve but for now we load a different expected + // template. + function getExpectedDoc(name: string, indented = false) { + return { + figmaNode: name, + label: 'React', + language: 'typescript', + component: 'Button', + source: + 'https://github.com/figma/code-connect/tree/master/react/src/common/__test__/TestComponents.tsx', + sourceLocation: { line: 12 }, + template: getExpectedTemplate(indented ? 'PropMappings_indented' : 'PropMappings'), + templateData: { + props: { + variant: { + kind: 'enum', + args: { + figmaPropName: '👥 Variant', + valueMapping: { + Primary: 'primary', + Destructive: 'destructive', + Inverse: 'inverse', + Success: 'success', + FigJam: 'FigJam', + Secondary: 'secondary', + 'Secondary Destruct': 'destructive-secondary', + }, + }, + }, + size: { + kind: 'enum', + args: { + figmaPropName: '👥 Size', + valueMapping: { + Default: 'hug-contents', + Large: undefined, + Wide: 'fit-parent', + }, + }, + }, + state: { + kind: 'enum', + args: { + figmaPropName: '🐣 State', + valueMapping: { + Default: 'Default', + Active: 'Active', + Focused: 'Focused', + }, + }, + }, + disabled: { + kind: 'boolean', + args: { figmaPropName: '🎛️ Disabled' }, + }, + iconLead: { + kind: 'boolean', + args: { + figmaPropName: '🎛️ Icon Lead', + valueMapping: { + true: 'icon', + }, + }, + }, + label: { kind: 'string', args: { figmaPropName: '🎛️ Label' } }, + }, + imports: ["import { Button } from './TestComponents'"], + }, + } + } + + expect(result).toMatchObject([ + getExpectedDoc('propsInline'), + getExpectedDoc('propsSeparateObject'), + getExpectedDoc('propsSpreadSyntax'), + getExpectedDoc('dotNotation'), + getExpectedDoc('quotesNotation'), + getExpectedDoc('destructured'), + getExpectedDoc('namedFunction', true), + ]) + }) + + it('handles enum-like boolean props with values for false', async () => { + const result = await testParse('EnumLikeBooleanFalseProp.figma.tsx') + + expect(result).toMatchObject([ + { + figmaNode: 'test', + label: 'React', + language: 'typescript', + component: 'Button', + source: + 'https://github.com/figma/code-connect/tree/master/react/src/common/__test__/TestComponents.tsx', + sourceLocation: { line: 12 }, + template: getExpectedTemplate('EnumLikeBooleanFalseProp'), + templateData: { + props: { + icon: { + kind: 'boolean', + args: { + figmaPropName: 'Prop', + valueMapping: { true: 'yes', false: 'no' }, + }, + }, + }, + imports: ["import { Button } from './TestComponents'"], + nestable: true, + }, + }, + ]) + }) + + it('generates Code Connect for components with spread props', async () => { + const tsProgram = ts.createProgram( + [path.join(__dirname, 'PropsSpread.tsx'), path.join(__dirname, 'PropsSpread.figma.tsx')], + {}, + ) + + const result = await parse(tsProgram, path.join(__dirname, 'PropsSpread.figma.tsx')) + + expect(result).toMatchObject([ + { + figmaNode: 'spread', + component: 'Button', + label: 'React', + language: 'typescript', + source: '', + sourceLocation: { line: 8 }, + template: getExpectedTemplate('PropsSpread'), + templateData: { + props: { + variant: { + kind: 'enum', + args: { + figmaPropName: '👥 Variant', + valueMapping: { + Primary: 'primary', + Destructive: 'destructive', + Inverse: 'inverse', + Success: 'success', + FigJam: 'FigJam', + Secondary: 'secondary', + 'Secondary Destruct': 'destructive-secondary', + }, + }, + }, + width: { + kind: 'enum', + args: { + figmaPropName: '👥 Size', + valueMapping: { + Default: 'hug-contents', + Large: undefined, + Wide: 'fit-parent', + }, + }, + }, + disabled: { + kind: 'boolean', + args: { figmaPropName: '🎛️ Disabled' }, + }, + }, + imports: ["import { Button } from './PropsSpread'"], + }, + }, + ]) + }) + + it('generates Code Connect for components with no props', async () => { + const result = await testParse('NoProps.figma.tsx') + + expect(result).toMatchObject([ + { + component: 'ComponentWithoutProps', + }, + ]) + }) + + it('Handles path aliases', async () => { + // without passing a path alias, this should fail to resolve the import, + // but not throw an error + const noAlias = await testParse('PathAliasImport.figma.tsx') + expect(noAlias).toMatchObject([ + { + component: 'Button', + source: '', + }, + ]) + + // with `paths` set the import should resolve correctly + const withAlias = await testParse('PathAliasImport.figma.tsx', [], { + paths: { + '@components/*': [path.join(__dirname, '*')], + }, + }) + expect(withAlias).toMatchObject([ + { + component: 'Button', + source: + 'https://github.com/figma/code-connect/tree/master/react/src/common/__test__/TestComponents.tsx', + }, + ]) + }) + + it('Can parse a varible reference for figmaNode', async () => { + const result = await testParse('VariableRefFigmaNode.figma.tsx') + + expect(result).toMatchObject([ + { + figmaNode: 'identifierAsUrl', + component: 'Button', + }, + ]) + }) + + it('Can map import paths for a generated import statement for co-located components', async () => { + const result = await testParse('ColocatedCodeConnect.tsx', [], { + importPaths: { + '__test__/*': '@lib/*', + }, + }) + + expect(result).toMatchObject([ + { + component: 'ColocatedButton', + templateData: { + imports: ["import { ColocatedButton } from '@lib/ColocatedCodeConnect'"], + }, + }, + ]) + }) + + it('Parses instance and children prop mappings', async () => { + const result = await testParse('ChildInstances.figma.tsx') + + expect(result).toMatchObject([ + { + figmaNode: 'instanceSwap', + component: 'Button', + templateData: { + props: { + icon: { + kind: 'instance', + args: { + figmaPropName: 'Icon Prop', + }, + }, + }, + imports: ["import { Button } from './TestComponents'"], + }, + }, + { + figmaNode: 'children', + component: 'Button', + templateData: { + props: { + icon: { + kind: 'children', + args: { + layers: ['Icon Layer'], + }, + }, + }, + imports: ["import { Button } from './TestComponents'"], + }, + }, + { + figmaNode: 'children array', + component: 'Button', + templateData: { + props: { + icons: { + kind: 'children', + args: { + layers: ['Icon 1', 'Icon 2', 'Icon 3'], + }, + }, + }, + imports: ["import { Button } from './TestComponents'"], + }, + }, + ]) + }) +}) diff --git a/react/src/common/__test__/tsconfig.json b/react/src/common/__test__/tsconfig.json new file mode 100644 index 0000000..53f896f --- /dev/null +++ b/react/src/common/__test__/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "jsx": "react", + "outDir": "dist", + "module": "commonjs", + "target": "es6", + "esModuleInterop": true, + "skipLibCheck": true, + "baseUrl": ".", + "moduleResolution": "node", + "paths": { + "@components/*": ["*"] // enables us to use @components/MyComponent + } + }, + "include": [ + "*.ts", + "*.tsx", + ], +} diff --git a/react/src/common/api.ts b/react/src/common/api.ts new file mode 100644 index 0000000..9ec19a8 --- /dev/null +++ b/react/src/common/api.ts @@ -0,0 +1,165 @@ +export interface FigmaConnectAPI { + /** + * Creates Figma documentation for a React component and a Figma component. This function is used to + * define a code example that displays in Figma when that component is selected. The function must be + * called with a link to the figmaNode and a reference to the React component. + * + * @param component A reference to the React component + * @param figmaNodeUrl A link to the node in Figma, for example:`https://www.figma.com/file/123abc/My-Component?node-id=123:456` + * @param meta {@link FigmaConnectMeta} + */ + connect

(component: any, figmaNodeUrl: string, meta?: FigmaConnectMeta

): void + + /** + * Maps a Figma property to a boolean value for the connected component. This prop is replaced + * with values from the Figma instance when viewed in Dev Mode. For example: + * ```ts + * props: { + * disabled: figma.boolean('Disabled'), + * } + * ``` + * Would show the `disabled` property if the Figma property "Disabled" is true. + * + * @param figmaPropName The name of the property on the Figma component + */ + boolean(figmaPropName: string): boolean + + /** + * Maps a Figma boolean property to a set of values for the connected component, providing + * a value mapping for `true` and `false`. This prop is replaced with values from the + * Figma instance when viewed in Dev Mode. Example: + * ```ts + * props: { + * label: figma.boolean('Disabled', { + * true:

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

) {} + +function booleanType(_figmaPropName: string): boolean +function booleanType( + _figmaPropName: string, + _valueMapping?: Record<'true' | 'false', V>, +) { + if (_valueMapping) { + return enumType(_figmaPropName, _valueMapping) + } + return true +} + +function enumType( + _figmaPropName: string, + _valueMapping: PropMapping>, +): ValueOf> { + return Object.values(_valueMapping)[0] as ValueOf> +} + +function stringType(_figmaPropName: string) { + return '' +} + +function instanceType(_figmaPropName: string) { + return React.createElement('div') +} + +function childrenType(_layers: string | string[]) { + return React.createElement('div') +} + +export { + connectType as connect, + booleanType as boolean, + enumType as enum, + stringType as string, + instanceType as instance, + childrenType as children, +} diff --git a/react/src/common/figma_connect.ts b/react/src/common/figma_connect.ts new file mode 100644 index 0000000..55ee183 --- /dev/null +++ b/react/src/common/figma_connect.ts @@ -0,0 +1,22 @@ +import { FigmaConnectLink } from './api' +import { Intrinsic } from './intrinsics' + +export interface FigmaConnectJSON { + figmaNode: string + component: string + variant?: Record + source: string + sourceLocation: { line: number } + template: string + templateData: { + props: Record | undefined + imports?: string[] + nestable?: boolean + } + language: 'typescript' + label: string + links?: FigmaConnectLink[] + metadata: { + cliVersion: string + } +} diff --git a/react/src/common/intrinsics.ts b/react/src/common/intrinsics.ts new file mode 100644 index 0000000..ec8ba08 --- /dev/null +++ b/react/src/common/intrinsics.ts @@ -0,0 +1,308 @@ +import * as ts from 'typescript' +import { InternalError, ParserContext, ParserError } from './parser' +import { assertIsStringLiteral, stripQuotes } from './compiler' +import { convertObjectLiteralToJs } from './compiler' +import { assertIsObjectLiteralExpression } from './compiler' +import { FigmaConnectAPI } from './api' + +export const API_PREFIX = 'figma' +export const FIGMA_CONNECT_CALL = `${API_PREFIX}.connect` + +export enum IntrinsicKind { + Enum = 'enum', + String = 'string', + Boolean = 'boolean', + Instance = 'instance', + Children = 'children', +} + +export interface IntrinsicBase { + kind: IntrinsicKind + args: {} +} + +export type ValueMappingKind = string | boolean | number | undefined | JSX.Element | Intrinsic + +export interface FigmaBoolean extends IntrinsicBase { + kind: IntrinsicKind.Boolean + args: { + figmaPropName: string + valueMapping?: Record<'true' | 'false', ValueMappingKind> + } +} + +export interface FigmaEnum extends IntrinsicBase { + kind: IntrinsicKind.Enum + args: { + figmaPropName: string + valueMapping: Record + } +} + +export interface FigmaString extends IntrinsicBase { + kind: IntrinsicKind.String + args: { + figmaPropName: string + } +} + +export interface FigmaInstance extends IntrinsicBase { + kind: IntrinsicKind.Instance + args: { + figmaPropName: string + } +} + +export interface FigmaChildren extends IntrinsicBase { + kind: IntrinsicKind.Children + args: { + layers: string[] + } +} + +export type Intrinsic = FigmaBoolean | FigmaEnum | FigmaString | FigmaInstance | FigmaChildren + +const Intrinsics: { + [key: string]: { + match: (exp: ts.CallExpression) => exp is ts.CallExpression + parse: (exp: ts.CallExpression, parser: ParserContext) => Intrinsic + } +} = {} + +/** + * These functions are used to convert "intrinsic" parser types (which are calls to helper functions + * like `Figma.boolean() in code)` to an object representing that intrinsic that we can serialize to JSON. + * + * Each call to `makeIntrinsic` should take a function from the {@link FigmaConnectAPI}, + * ensuring that the name of the intrinsic that we're parsing matches the name of the function + * + * @param staticFunctionMember + * @param obj + */ +function makeIntrinsic( + intrinsicName: K, + obj: (name: string) => any, +) { + const name = `${API_PREFIX}.${intrinsicName}` + Intrinsics[name] = { + match: (exp: ts.CallExpression) => ts.isCallExpression(exp) && exp.getText().includes(name), + ...obj(name), + } +} + +makeIntrinsic('boolean', (name) => { + return { + parse: (exp: ts.CallExpression, ctx: ParserContext): FigmaBoolean => { + const figmaPropNameIdentifier = exp.arguments?.[0] + assertIsStringLiteral( + figmaPropNameIdentifier, + ctx.sourceFile, + `${name} takes at least one argument, which is the Figma property name`, + ) + const valueMappingArg = exp.arguments?.[1] + let valueMapping + if (valueMappingArg) { + assertIsObjectLiteralExpression( + valueMappingArg, + ctx.sourceFile, + `${name} second argument should be an object literal, that sets values for 'true' and 'false'`, + ) + valueMapping = convertObjectLiteralToJs( + valueMappingArg, + ctx.sourceFile, + ctx.checker, + (valueNode) => { + if (ts.isCallExpression(valueNode)) { + return parseIntrinsic(valueNode, ctx) + } + }, + ) + } + return { + kind: IntrinsicKind.Boolean, + args: { + figmaPropName: stripQuotes(figmaPropNameIdentifier), + valueMapping, + }, + } + }, + } +}) + +makeIntrinsic('enum', (name) => { + return { + parse: (exp: ts.CallExpression, ctx: ParserContext): FigmaEnum => { + const { sourceFile, checker } = ctx + const figmaPropNameIdentifier = exp.arguments?.[0] + assertIsStringLiteral( + figmaPropNameIdentifier, + sourceFile, + `${name} takes at least one argument, which is the Figma property name`, + ) + const valueMapping = exp.arguments?.[1] + assertIsObjectLiteralExpression( + valueMapping, + sourceFile, + `${name} second argument should be an object literal, that maps Figma prop values to code`, + ) + return { + kind: IntrinsicKind.Enum, + args: { + figmaPropName: stripQuotes(figmaPropNameIdentifier), + valueMapping: convertObjectLiteralToJs(valueMapping, sourceFile, checker, (valueNode) => { + if (ts.isCallExpression(valueNode)) { + return parseIntrinsic(valueNode, ctx) + } + }), + }, + } + }, + } +}) + +makeIntrinsic('string', (name) => { + return { + parse: (exp: ts.CallExpression, ctx: ParserContext): FigmaString => { + const { sourceFile } = ctx + const figmaPropNameIdentifier = exp.arguments?.[0] + assertIsStringLiteral( + figmaPropNameIdentifier, + sourceFile, + `${name} takes at least one argument, which is the Figma property name`, + ) + return { + kind: IntrinsicKind.String, + args: { + figmaPropName: stripQuotes(figmaPropNameIdentifier), + }, + } + }, + } +}) + +makeIntrinsic('instance', (name) => { + return { + parse: (exp: ts.CallExpression, ctx: ParserContext): FigmaInstance => { + const { sourceFile } = ctx + const figmaPropNameIdentifier = exp.arguments?.[0] + assertIsStringLiteral( + figmaPropNameIdentifier, + sourceFile, + `${name} takes at least one argument, which is the Figma property name`, + ) + return { + kind: IntrinsicKind.Instance, + args: { + figmaPropName: stripQuotes(figmaPropNameIdentifier), + }, + } + }, + } +}) + +makeIntrinsic('children', (name) => { + return { + parse: (exp: ts.CallExpression, ctx: ParserContext): FigmaChildren => { + const { sourceFile } = ctx + const layerName = exp.arguments?.[0] + const layers: string[] = [] + if (ts.isStringLiteral(layerName)) { + layers.push(stripQuotes(layerName)) + } else if (ts.isArrayLiteralExpression(layerName) && layerName.elements.length > 0) { + layerName.elements.forEach((el) => { + assertIsStringLiteral(el, sourceFile) + layers.push(stripQuotes(el)) + }) + } else { + throw new ParserError( + `Invalid argument to ${name}, should be a string literal or an array of strings`, + { + node: layerName, + sourceFile, + }, + ) + } + + return { + kind: IntrinsicKind.Children, + args: { + layers, + }, + } + }, + } +}) + +/** + * Parses a call expression to an intrinsic + * + * @param exp Expression to parse + * @param parserContext parser context + * @returns + */ +export function parseIntrinsic(exp: ts.CallExpression, parserContext: ParserContext): Intrinsic { + for (const key in Intrinsics) { + if (Intrinsics[key].match(exp)) { + return Intrinsics[key].parse(exp, parserContext) + } + } + + throw new ParserError(`Unknown intrinsic: ${exp.getText()}`, { + node: exp, + sourceFile: parserContext.sourceFile, + }) +} + +export function valueMappingToString(valueMapping: Record): string { + // For enums (and booleans with a valueMapping provided), convert the + // value mapping to an object. + return ( + '{\n' + + Object.entries(valueMapping) + .map(([key, value]) => { + if (typeof value === 'object' && 'kind' in value) { + // Mappings can be nested, e.g. an enum value can be figma.instance(...) + return `"${key}": ${intrinsicToString(value as Intrinsic)}` + } else if (typeof value === 'boolean' || typeof value === 'undefined') { + return `"${key}": ${value}` + } else { + return `"${key}": '${value}'` + } + }) + .join(',\n') + + '}' + ) +} + +export function intrinsicToString({ kind, args }: Intrinsic): string { + switch (kind) { + case IntrinsicKind.String: + case IntrinsicKind.Instance: { + // Outputs: + // `const propName = figma.properties.string('propName')`, or + // `const propName = figma.properties.boolean('propName')`, or + // `const propName = figma.properties.instance('propName')` + return `figma.properties.${kind}('${args.figmaPropName}')` + } + case IntrinsicKind.Boolean: { + if (args.valueMapping) { + const mappingString = valueMappingToString(args.valueMapping) + // Outputs: `const propName = figma.properties.boolean('propName', { ... mapping object from above ... })` + return `figma.properties.boolean('${args.figmaPropName}', ${mappingString})` + } + return `figma.properties.boolean('${args.figmaPropName}')` + } + case IntrinsicKind.Enum: { + const mappingString = valueMappingToString(args.valueMapping) + + // Outputs: `const propName = figma.properties.enum('propName', { ... mapping object from above ... })` + return `figma.properties.enum('${args.figmaPropName}', ${mappingString})` + } + case IntrinsicKind.Children: { + // Outputs: `const propName = figma.properties.children(["Layer 1", "Layer 2"])` + return `figma.properties.children([${args.layers.map((layerName) => `"${layerName}"`).join(',')}])` + } + default: + throw new InternalError(`Unknown intrinsic: ${kind}`) + } +} diff --git a/react/src/common/logging.ts b/react/src/common/logging.ts new file mode 100644 index 0000000..bbe099c --- /dev/null +++ b/react/src/common/logging.ts @@ -0,0 +1,39 @@ +import chalk from 'chalk' + +export const error = chalk.red +export const success = chalk.green +export const info = chalk.white +export const warn = chalk.yellow +export const debug = chalk.gray +export const verbose = chalk.cyan +export const highlight = chalk.bold +export const reset = chalk.reset +export const underline = chalk.underline + +export enum LogLevel { + Nothing = 0, + Error = 1, + Warn = 2, + Info = 3, + Debug = 4, +} + +let logLevel: LogLevel = LogLevel.Info + +export const logger = { + setLogLevel: (level: LogLevel) => { + logLevel = level + }, + error: (...msgs: unknown[]) => { + if (logLevel >= LogLevel.Error) console.error(error(...msgs)) + }, + warn: (...msgs: unknown[]) => { + if (logLevel >= LogLevel.Warn) console.warn(warn(...msgs)) + }, + info: (...msgs: unknown[]) => { + if (logLevel >= LogLevel.Info) console.info(info(...msgs)) + }, + debug: (...msgs: unknown[]) => { + if (logLevel >= LogLevel.Debug) console.debug(debug(...msgs)) + }, +} diff --git a/react/src/common/parser.ts b/react/src/common/parser.ts new file mode 100644 index 0000000..5fefcdb --- /dev/null +++ b/react/src/common/parser.ts @@ -0,0 +1,968 @@ +import ts from 'typescript' +import * as prettier from 'prettier' +import { FigmaConnectConfig, getRemoteFileUrl, resolveImportPath } from './project' +import { error, highlight, logger, reset } from './logging' +import { + assertIsObjectLiteralExpression, + convertObjectLiteralToJs, + bfsFindNode, + getTagName, + stripQuotes, + parsePropertyOfType, + parseFunctionArgument, + isOneOf, +} from './compiler' +import { + FIGMA_CONNECT_CALL, + Intrinsic, + IntrinsicKind, + ValueMappingKind, + intrinsicToString, + parseIntrinsic, +} from './intrinsics' +import { FigmaConnectJSON } from './figma_connect' +import { FigmaConnectMeta } from './api' +import { getParsedTemplateHelpersString } from './parser_template_helpers' + +interface ParserErrorContext { + sourceFile: ts.SourceFile + node: ts.Node | undefined +} + +export class ParserError extends Error { + sourceFilePosition: ts.LineAndCharacter | null + sourceFileName: string + + constructor(message: string, context?: ParserErrorContext) { + super(message) + this.name = 'ParserError' + this.sourceFileName = context?.sourceFile.fileName || '' + this.sourceFilePosition = + context && context.node + ? getPositionInSourceFile(context.node, context.sourceFile) || null + : null + } + + toString() { + let msg = `${highlight(error(this.name))}: ${this.message}\n` + if (this.sourceFileName && this.sourceFilePosition) { + msg += ` -> ${reset(this.sourceFileName)}:${this.sourceFilePosition.line}:${this.sourceFilePosition.character}\n` + } + return msg + } + + toDebugString() { + return this.toString() + `\n ${this.stack}` + } +} + +export class InternalError extends ParserError { + constructor(message: string) { + super(message) + this.name = 'InternalError' + } +} + +export interface ParserContext { + checker: ts.TypeChecker + sourceFile: ts.SourceFile + config: FigmaConnectConfig | undefined +} + +/** + * Traverses the AST and returns the first JSX element it finds + * @param node AST node + * @returns + */ +function findJSXElement( + node: ts.Node, +): ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment | undefined { + if (ts.isJsxElement(node) || ts.isJsxFragment(node) || ts.isJsxSelfClosingElement(node)) { + return node + } else { + return ts.forEachChild(node, findJSXElement) + } +} + +function findBlock(node: ts.Node): ts.Block | undefined { + if (ts.isBlock(node)) { + return node + } else { + return ts.forEachChild(node, findBlock) + } +} + +function findDescendants(node: ts.Node, cb: (node: ts.Node) => boolean): ts.Node[] { + const matches: ts.Node[] = [] + function visit(node: ts.Node) { + if (cb(node)) { + matches.push(node) + } + ts.forEachChild(node, visit) + } + visit(node) + return matches +} + +function getPositionInSourceFile(node: ts.Node, sourceFile: ts.SourceFile) { + return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)) +} + +/** + * Walks up the AST from an assignment to find the import declaration + */ +function findParentImportDeclaration( + declaration: ts.Declaration, +): ts.ImportDeclaration | undefined { + let current = declaration + while (current) { + if (ts.isImportDeclaration(current)) { + return current + } + current = current.parent as ts.Declaration + } +} + +function getImportsOfModule(sourceFile: ts.SourceFile): ts.ImportDeclaration[] { + const imports: ts.ImportDeclaration[] = [] + + function visit(node: ts.Node) { + if (ts.isImportDeclaration(node)) { + imports.push(node) + } + ts.forEachChild(node, visit) + } + + visit(sourceFile) + + return imports +} + +/** + * Finds all import statements in a file that matches the given identifiers + * + * @param parserContext Parser context + * @param identifiers List of identifiers to find imports for + * @returns + */ +function getImportsForIdentifiers({ sourceFile }: ParserContext, _identifiers: string[]) { + const importDeclarations = getImportsOfModule(sourceFile) + const imports: { + statement: string + file: string + }[] = [] + + const identifiers = _identifiers.map((identifier) => identifier.split('.')[0]) + + for (const declaration of importDeclarations) { + let statement = declaration.getText() + const file = declaration.getSourceFile() + + if (declaration.importClause) { + // Default imports + if (declaration.importClause.name) { + const identifier = declaration.importClause.name.text + if (identifiers.includes(identifier)) { + imports.push({ + statement, + file: file.fileName, + }) + } + } + + if (declaration.importClause.namedBindings) { + const namedBindings = declaration.importClause.namedBindings + + if (ts.isNamedImports(namedBindings)) { + // Named imports (import { x, y } from 'module') + // filter out any unused imports from the statement the identifier belongs to + const elements = namedBindings.elements + .map((specifier) => specifier.name.text) + .filter((name) => identifiers.includes(name)) + + if (elements.length > 0) { + imports.push({ + statement: statement.replace(/{.*}/s, `{ ${elements.join(', ')} }`), + file: file.fileName, + }) + } + } else if (ts.isNamespaceImport(namedBindings)) { + // Namespace import (import * as name from 'module') + const identifier = namedBindings.name.text + if (identifiers.includes(identifier)) { + imports.push({ + statement, + file: file.fileName, + }) + } + } + } + } + } + + return imports +} + +/** + * Parsers the `props` field of a `Figma.connect()` call, returning a mapping of + * prop names to their respective intrinsic types + * + * @param objectLiteral An object literal expression + * @param parserContext Parser context + * @returns + */ +export function parsePropsObject( + objectLiteral: ts.ObjectLiteralExpression, + parserContext: ParserContext, +): PropMappings { + const { sourceFile, checker } = parserContext + return convertObjectLiteralToJs(objectLiteral, sourceFile, checker, (valueNode) => { + if (ts.isCallExpression(valueNode)) { + return parseIntrinsic(valueNode, parserContext) + } + }) +} + +export type PropMappings = Record + +/** + * Extract metadata about the referenced React component. Used by both the + * Figmadoc and Storybook commands. + * + * @param parserContext Parser context + * @param componentSymbol The ts.Symbol from the metadata referencing the + * component being documented + * @param node The node being parsed. Used for error logging. + * @returns Metadata object + */ +export async function parseComponentMetadata( + node: ts.PropertyAccessExpression | ts.Identifier | ts.Expression, + { checker, sourceFile }: ParserContext, +) { + let componentSymbol = checker.getSymbolAtLocation(node) + let componentSourceFile = sourceFile + let component = '' + let componentDeclaration + + // Hacky fix for namespaced components, this probably doesn't work for storybook + if (ts.isPropertyAccessExpression(node)) { + componentSymbol = checker.getSymbolAtLocation(node.expression) + if (!componentSymbol) { + throw new ParserError(`Could not find symbol for component ${node.expression.getText()}`, { + sourceFile, + node, + }) + } + } + + // Component declared in a different file + if ( + componentSymbol && + componentSymbol.declarations && + (ts.isImportSpecifier(componentSymbol.declarations[0]) || + ts.isImportClause(componentSymbol.declarations[0])) + ) { + let importDeclaration = findParentImportDeclaration(componentSymbol.declarations[0]) + if (!importDeclaration) { + throw new ParserError( + 'No import statement found for component, make sure the component is imported', + { + sourceFile, + node, + }, + ) + } + + // 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) { + componentSymbol = checker.getAliasedSymbol(componentSymbol) + } + + if (!componentSymbol || !componentSymbol.declarations) { + logger.warn( + `Import for ${node.getText()} could not be resolved, make sure that your \`include\` globs in \`figma.config.json\` matches the component source file (in addition to the Code Connect file). If you're using path aliases, make sure to include the same aliases in \`figma.config.json\` with the \`paths\` option.`, + ) + return { + source: '', + line: 0, + component: node.getText(), + } + } + + // If we haven't found the component declaration by now, it's likely because it's + // assigned to an object/namespace, for example: `export const Button = { Primary: () => +} diff --git a/react/src/storybook/__test__/examples/ArrowStoriesExplicitReturn.stories.tsx b/react/src/storybook/__test__/examples/ArrowStoriesExplicitReturn.stories.tsx new file mode 100644 index 0000000..23d37de --- /dev/null +++ b/react/src/storybook/__test__/examples/ArrowStoriesExplicitReturn.stories.tsx @@ -0,0 +1,35 @@ +import { FunctionComponent } from './FunctionComponent' +import { StoryParameters } from '../../../' + +export default { + title: 'FunctionComponent', + component: FunctionComponent, + parameters: { + design: { + type: 'figma', + url: 'https://figma.com/test', + examples: ['Default', 'Disabled', 'WithArgs'], + props: { + disabled: figma.boolean('Disabled'), + }, + }, + } satisfies StoryParameters, +} + +export const Default = () => { + return Hello +} + +export const Disabled = () => { + const someExtraCode = 'test' + + return Hello +} + +export const WithArgs = (args: { disabled: boolean }) => { + return ( + + Hello this line is long to cause it to wrap in brackets + + ) +} diff --git a/react/src/storybook/__test__/examples/ArrowStoriesImplicitReturn.stories.tsx b/react/src/storybook/__test__/examples/ArrowStoriesImplicitReturn.stories.tsx new file mode 100644 index 0000000..000c26b --- /dev/null +++ b/react/src/storybook/__test__/examples/ArrowStoriesImplicitReturn.stories.tsx @@ -0,0 +1,25 @@ +import { FunctionComponent } from './FunctionComponent' +import { StoryParameters } from '../../../' + +export default { + title: 'FunctionComponent', + component: FunctionComponent, + parameters: { + design: { + type: 'figma', + url: 'https://figma.com/test', + examples: ['Default', 'WithArgs'], + props: { + disabled: figma.boolean('Disabled'), + }, + }, + } satisfies StoryParameters, +} + +export const Default = () => Hello + +export const WithArgs = (args: { disabled: boolean }) => ( + + Hello this line is long to cause it to wrap in brackets + +) diff --git a/react/src/storybook/__test__/examples/Examples.stories.tsx b/react/src/storybook/__test__/examples/Examples.stories.tsx new file mode 100644 index 0000000..c570ae7 --- /dev/null +++ b/react/src/storybook/__test__/examples/Examples.stories.tsx @@ -0,0 +1,30 @@ +import { FunctionComponent } from './FunctionComponent' +import { StoryParameters } from '../../../' + +export default { + title: 'FunctionComponent', + component: FunctionComponent, + parameters: { + design: { + type: 'figma', + url: 'https://figma.com/test', + examples: [Default, WithIcon, 'StringName'], + }, + } satisfies StoryParameters, +} + +export function Default() { + return Hello +} + +export function WithIcon() { + return Icon +} + +export function StringName() { + return String name +} + +export function NotIncluded() { + return Not included +} diff --git a/react/src/storybook/__test__/examples/ExamplesVariantRestrictions.stories.tsx b/react/src/storybook/__test__/examples/ExamplesVariantRestrictions.stories.tsx new file mode 100644 index 0000000..c2d57c3 --- /dev/null +++ b/react/src/storybook/__test__/examples/ExamplesVariantRestrictions.stories.tsx @@ -0,0 +1,51 @@ +import { StoryParameters } from '../../..' +import { FunctionComponent } from './FunctionComponent' + +export default { + title: 'FunctionComponent', + component: FunctionComponent, + parameters: { + design: { + type: 'figma', + url: 'https://figma.com/test', + examples: [ + { + example: Default, + variant: { 'With icon': false }, + }, + { + example: WithIcon, + variant: { 'With icon': true }, + }, + { + example: 'StringName', + variant: { DummyOption: 'DummyValue' }, + }, + { + example: Multiple, + variant: { DummyOption: 'DummyValue', 'With icon': true }, + }, + ], + }, + } satisfies StoryParameters, +} + +export function Default() { + return Hello +} + +export function WithIcon() { + return Icon +} + +export function StringName() { + return String name +} + +export function Multiple() { + return Multiple restrictions +} + +export function NotIncluded() { + return Not included +} diff --git a/react/src/storybook/__test__/examples/FunctionComponent.stories.tsx b/react/src/storybook/__test__/examples/FunctionComponent.stories.tsx new file mode 100644 index 0000000..b84b004 --- /dev/null +++ b/react/src/storybook/__test__/examples/FunctionComponent.stories.tsx @@ -0,0 +1,18 @@ +import { StoryParameters } from '../../..' +import { FunctionComponent } from './FunctionComponent' + +export default { + title: 'FunctionComponent', + component: FunctionComponent, + parameters: { + design: { + type: 'figma', + url: 'https://figma.com/test', + examples: [Default], + }, + } satisfies StoryParameters, +} + +export function Default() { + return Hello +} diff --git a/react/src/storybook/__test__/examples/FunctionComponent.tsx b/react/src/storybook/__test__/examples/FunctionComponent.tsx new file mode 100644 index 0000000..d4a98e5 --- /dev/null +++ b/react/src/storybook/__test__/examples/FunctionComponent.tsx @@ -0,0 +1,12 @@ +import { ReactNode } from 'react' + +interface Props { + disabled: boolean + children: ReactNode +} + +export function FunctionComponent({ disabled, children }: Props) { + const someOtherCode = 'some other code' + + return +} diff --git a/react/src/storybook/__test__/examples/FunctionStories.stories.tsx b/react/src/storybook/__test__/examples/FunctionStories.stories.tsx new file mode 100644 index 0000000..cad3561 --- /dev/null +++ b/react/src/storybook/__test__/examples/FunctionStories.stories.tsx @@ -0,0 +1,35 @@ +import { StoryParameters, figma } from '../../..' +import { FunctionComponent } from './FunctionComponent' + +export default { + title: 'FunctionComponent', + component: FunctionComponent, + parameters: { + design: { + type: 'figma', + url: 'https://figma.com/test', + examples: [Default, WithLogic, WithArgs], + props: { + disabled: figma.boolean('Disabled'), + }, + }, + } satisfies StoryParameters, +} + +export function Default() { + return Hello +} + +export function WithLogic() { + const someExtraCode = 'test' + + return Hello +} + +export function WithArgs(args: { disabled: boolean }) { + return ( + + Hello this line is long to cause it to wrap in brackets + + ) +} diff --git a/react/src/storybook/__test__/examples/NoDesignParameter.stories.tsx b/react/src/storybook/__test__/examples/NoDesignParameter.stories.tsx new file mode 100644 index 0000000..1837342 --- /dev/null +++ b/react/src/storybook/__test__/examples/NoDesignParameter.stories.tsx @@ -0,0 +1,13 @@ +import { ArrowComponent } from './ArrowComponent' + +export default { + title: 'ArrowComponent', + component: ArrowComponent, + parameters: { + somethingElse: true, + }, +} + +export function Default() { + return Hello +} diff --git a/react/src/storybook/__test__/examples/NoExamples.stories.tsx b/react/src/storybook/__test__/examples/NoExamples.stories.tsx new file mode 100644 index 0000000..b6aac50 --- /dev/null +++ b/react/src/storybook/__test__/examples/NoExamples.stories.tsx @@ -0,0 +1,13 @@ +import { StoryParameters } from '../../..' +import { ArrowComponent } from './ArrowComponent' + +export default { + title: 'ArrowComponent', + component: ArrowComponent, + parameters: { + design: { + type: 'figma', + url: 'https://figma.com/test', + }, + } satisfies StoryParameters, +} diff --git a/react/src/storybook/__test__/examples/NoParameters.stories.tsx b/react/src/storybook/__test__/examples/NoParameters.stories.tsx new file mode 100644 index 0000000..344c569 --- /dev/null +++ b/react/src/storybook/__test__/examples/NoParameters.stories.tsx @@ -0,0 +1,10 @@ +import { ArrowComponent } from './ArrowComponent' + +export default { + title: 'ArrowComponent', + component: ArrowComponent, +} + +export function Default() { + return Hello +} diff --git a/react/src/storybook/__test__/examples/NoTypeDesignParameter.stories.tsx b/react/src/storybook/__test__/examples/NoTypeDesignParameter.stories.tsx new file mode 100644 index 0000000..7172736 --- /dev/null +++ b/react/src/storybook/__test__/examples/NoTypeDesignParameter.stories.tsx @@ -0,0 +1,15 @@ +import { ArrowComponent } from './ArrowComponent' + +export default { + title: 'ArrowComponent', + component: ArrowComponent, + parameters: { + design: { + url: 'xxx', + }, + }, +} + +export function Default() { + return Hello +} diff --git a/react/src/storybook/__test__/examples/NonFigmaDesignParameter.stories.tsx b/react/src/storybook/__test__/examples/NonFigmaDesignParameter.stories.tsx new file mode 100644 index 0000000..0380bbf --- /dev/null +++ b/react/src/storybook/__test__/examples/NonFigmaDesignParameter.stories.tsx @@ -0,0 +1,15 @@ +import { ArrowComponent } from './ArrowComponent' + +export default { + title: 'ArrowComponent', + component: ArrowComponent, + parameters: { + design: { + type: 'mspaint', + }, + }, +} + +export function Default() { + return Hello +} diff --git a/react/src/storybook/__test__/examples/PropMapping.stories.tsx b/react/src/storybook/__test__/examples/PropMapping.stories.tsx new file mode 100644 index 0000000..5f21719 --- /dev/null +++ b/react/src/storybook/__test__/examples/PropMapping.stories.tsx @@ -0,0 +1,36 @@ +import { PropMapping, PropMappingProps } from './PropMapping' +import figma, { StoryParameters } from '../../..' + +export default { + title: 'PropMapping', + component: PropMapping, + parameters: { + design: { + type: 'figma', + url: 'https://figma.com/test', + props: { + enumProp: figma.enum('Size', { + Slim: 'slim', + Medium: 'medium', + Large: 'large', + }), + booleanProp: figma.boolean('Boolean Prop'), + stringProp: figma.string('Text'), + children: figma.string('Text'), + }, + examples: ['Default'], + }, + } satisfies StoryParameters, +} + +export function Default(args: PropMappingProps) { + return ( + + {args.children} + + ) +} diff --git a/react/src/storybook/__test__/examples/PropMapping.tsx b/react/src/storybook/__test__/examples/PropMapping.tsx new file mode 100644 index 0000000..6dde31f --- /dev/null +++ b/react/src/storybook/__test__/examples/PropMapping.tsx @@ -0,0 +1,10 @@ +export type PropMappingProps = { + stringProp: string + booleanProp: boolean + enumProp: 'slim' | 'medium' | 'large' + children: React.ReactNode +} + +export function PropMapping(props: PropMappingProps) { + return

+} diff --git a/react/src/storybook/__test__/examples/StoryObjectWithRender.stories.tsx b/react/src/storybook/__test__/examples/StoryObjectWithRender.stories.tsx new file mode 100644 index 0000000..1e62c00 --- /dev/null +++ b/react/src/storybook/__test__/examples/StoryObjectWithRender.stories.tsx @@ -0,0 +1,39 @@ +import { StoryParameters, figma } from '../../../' +import { FunctionComponent } from './FunctionComponent' + +export default { + title: 'StoryObjectWithRender', + component: FunctionComponent, + parameters: { + design: { + type: 'figma', + url: 'https://figma.com/test', + examples: ['Default', 'Disabled', 'WithArgs'], + props: { + disabled: figma.boolean('Disabled'), + }, + }, + } satisfies StoryParameters, +} + +export const Default = { + render: () => Hello, +} + +export const Disabled = { + render: () => { + const someExtraCode = 'test' + + return Hello + }, +} + +export const WithArgs = { + render: (args) => { + return ( + + Hello this line is long to cause it to wrap in brackets + + ) + }, +} diff --git a/react/src/storybook/__test__/examples/tsconfig.json b/react/src/storybook/__test__/examples/tsconfig.json new file mode 100644 index 0000000..c434a3d --- /dev/null +++ b/react/src/storybook/__test__/examples/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "outDir": "dist", + "module": "commonjs", + "target": "es6", + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["*.ts", "*.tsx"] +} diff --git a/react/src/storybook/__test__/expected_templates/ArrowComponent.expected_template b/react/src/storybook/__test__/expected_templates/ArrowComponent.expected_template new file mode 100644 index 0000000..cea42ca --- /dev/null +++ b/react/src/storybook/__test__/expected_templates/ArrowComponent.expected_template @@ -0,0 +1,3 @@ +const figma = require('figma') + +export default figma.tsx`Hello` diff --git a/react/src/storybook/__test__/expected_templates/ArrowComponent_default.expected_template b/react/src/storybook/__test__/expected_templates/ArrowComponent_default.expected_template new file mode 100644 index 0000000..3bb0669 --- /dev/null +++ b/react/src/storybook/__test__/expected_templates/ArrowComponent_default.expected_template @@ -0,0 +1,3 @@ +const figma = require('figma') + +export default figma.tsx`` diff --git a/react/src/storybook/__test__/expected_templates/FunctionComponent.expected_template b/react/src/storybook/__test__/expected_templates/FunctionComponent.expected_template new file mode 100644 index 0000000..d21dd6f --- /dev/null +++ b/react/src/storybook/__test__/expected_templates/FunctionComponent.expected_template @@ -0,0 +1,3 @@ +const figma = require('figma') + +export default figma.tsx`Hello` diff --git a/react/src/storybook/__test__/expected_templates/FunctionComponentMultipleRestrictions.expected_template b/react/src/storybook/__test__/expected_templates/FunctionComponentMultipleRestrictions.expected_template new file mode 100644 index 0000000..2426514 --- /dev/null +++ b/react/src/storybook/__test__/expected_templates/FunctionComponentMultipleRestrictions.expected_template @@ -0,0 +1,3 @@ +const figma = require('figma') + +export default figma.tsx`Multiple restrictions` diff --git a/react/src/storybook/__test__/expected_templates/FunctionComponentStringName.expected_template b/react/src/storybook/__test__/expected_templates/FunctionComponentStringName.expected_template new file mode 100644 index 0000000..8053d9c --- /dev/null +++ b/react/src/storybook/__test__/expected_templates/FunctionComponentStringName.expected_template @@ -0,0 +1,3 @@ +const figma = require('figma') + +export default figma.tsx`String name` diff --git a/react/src/storybook/__test__/expected_templates/FunctionComponentWithArgs.expected_template b/react/src/storybook/__test__/expected_templates/FunctionComponentWithArgs.expected_template new file mode 100644 index 0000000..3341d10 --- /dev/null +++ b/react/src/storybook/__test__/expected_templates/FunctionComponentWithArgs.expected_template @@ -0,0 +1,7 @@ +const figma = require('figma') + +const disabled = figma.properties.boolean('Disabled') + +export default figma.tsx` + Hello this line is long to cause it to wrap in brackets + ` diff --git a/react/src/storybook/__test__/expected_templates/FunctionComponentWithArgs_indented.expected_template b/react/src/storybook/__test__/expected_templates/FunctionComponentWithArgs_indented.expected_template new file mode 100644 index 0000000..0efb2ef --- /dev/null +++ b/react/src/storybook/__test__/expected_templates/FunctionComponentWithArgs_indented.expected_template @@ -0,0 +1,7 @@ +const figma = require('figma') + +const disabled = figma.properties.boolean('Disabled') + +export default figma.tsx` + Hello this line is long to cause it to wrap in brackets + ` diff --git a/react/src/storybook/__test__/expected_templates/FunctionComponentWithArgs_indented_2.expected_template b/react/src/storybook/__test__/expected_templates/FunctionComponentWithArgs_indented_2.expected_template new file mode 100644 index 0000000..73782cb --- /dev/null +++ b/react/src/storybook/__test__/expected_templates/FunctionComponentWithArgs_indented_2.expected_template @@ -0,0 +1,7 @@ +const figma = require('figma') + +const disabled = figma.properties.boolean('Disabled') + +export default figma.tsx` + Hello this line is long to cause it to wrap in brackets + ` diff --git a/react/src/storybook/__test__/expected_templates/FunctionComponentWithIcon.expected_template b/react/src/storybook/__test__/expected_templates/FunctionComponentWithIcon.expected_template new file mode 100644 index 0000000..2e5fc38 --- /dev/null +++ b/react/src/storybook/__test__/expected_templates/FunctionComponentWithIcon.expected_template @@ -0,0 +1,3 @@ +const figma = require('figma') + +export default figma.tsx`Icon` diff --git a/react/src/storybook/__test__/expected_templates/FunctionComponentWithLogic.expected_template b/react/src/storybook/__test__/expected_templates/FunctionComponentWithLogic.expected_template new file mode 100644 index 0000000..258a992 --- /dev/null +++ b/react/src/storybook/__test__/expected_templates/FunctionComponentWithLogic.expected_template @@ -0,0 +1,6 @@ +const figma = require('figma') + +export default figma.tsx`function Example() { + const someExtraCode = 'test'; + return Hello; +}` diff --git a/react/src/storybook/__test__/expected_templates/PropMapping.expected_template b/react/src/storybook/__test__/expected_templates/PropMapping.expected_template new file mode 100644 index 0000000..5a44cb4 --- /dev/null +++ b/react/src/storybook/__test__/expected_templates/PropMapping.expected_template @@ -0,0 +1,13 @@ +const figma = require('figma') + +const stringProp = figma.properties.string('Text') +const booleanProp = figma.properties.boolean('Boolean Prop') +const enumProp = figma.properties.enum('Size', { +"Slim": 'slim', +"Medium": 'medium', +"Large": 'large'}) +const children = figma.properties.string('Text') + +export default figma.tsx` + ${children} + ` diff --git a/react/src/storybook/convert.ts b/react/src/storybook/convert.ts new file mode 100644 index 0000000..22fcb34 --- /dev/null +++ b/react/src/storybook/convert.ts @@ -0,0 +1,389 @@ +import { readFileSync, stat } from 'fs' +import { + bfsFindNode, + convertObjectLiteralToJs, + getDefaultExport, + parsePropertyOfType, +} from '../common/compiler' +import { + ParserContext, + parsePropsObject, + ParserError, + parseComponentMetadata, + InternalError, + parseRenderFunction, + getDefaultTemplate, +} from '../common/parser' +import { FigmaConnectConfig, ProjectInfo, getRemoteFileUrl } from '../common/project' +import { logger } from '../common/logging' +import { FigmaConnectJSON } from '../common/figma_connect' +import ts from 'typescript' +import { FigmaConnectMeta } from '../common/api' +import { minimatch } from 'minimatch' + +interface ConvertStorybookFilesArgs { + /** + * Optionally override the glob used to find stories. This is currently not + * exposed in the config, but is used by the tests + */ + storiesGlob?: string + + /** + * Information about the project + */ + projectInfo: ProjectInfo +} + +/** + * Converts all Storyboook files in a directory into Figmadoc objects. If a file + * cannot be converted (e.g. unsupported syntax), it is ignored and an error is + * logged. + * + * @param args + * @returns An array of Figmadoc objects + */ +export async function convertStorybookFiles({ + projectInfo, + storiesGlob = '**/*.stories.tsx', +}: ConvertStorybookFilesArgs): Promise { + const { remoteUrl, config, files, tsProgram } = projectInfo + + const storyFiles = files.filter((file) => minimatch(file, storiesGlob, { matchBase: true })) + logger.debug(`Story files found:\n${storyFiles.map((f) => `- ${f}`).join('\n')}`) + + return Promise.all( + storyFiles.map((path) => convertStorybookFile({ path, tsProgram, config, remoteUrl })), + ) + .then((f) => f.filter((x): x is NonNullable => Boolean(x))) + .then((f) => f.flat()) +} + +interface FigmaStoryMetadata { + type: string + url: string +} + +interface ConvertStorybookFileArgs { + path: string + tsProgram: ts.Program + remoteUrl: string + config?: FigmaConnectConfig +} + +type MappedPropType = 'FigmaString' | 'FigmaBoolean' | 'Mapped' +type MappedProps = Map | undefined + +async function convertStorybookFile({ + path, + tsProgram, + remoteUrl, + config, +}: ConvertStorybookFileArgs): Promise { + const checker = tsProgram.getTypeChecker() + const sourceFile = tsProgram.getSourceFile(path) + + if (!sourceFile) { + throw new InternalError(`Source file not found: ${path}`) + } + + const parserContext: ParserContext = { + checker, + config, + sourceFile, + } + + let source = readFileSync(path).toString() + // Replace backticks with ' as csf-tools can't parse dynamic titles + source = source.replace(/title: `(.*)`/g, (match, title) => { + return `title: '${title}'` + }) + + logger.debug(`Parsing story ${path}`) + + try { + // We need to get the default export, which contains the story file meta, + // from the TS Program rather than using `babelNodeToTsSourceFile(csf._metaNode)`, + // because we need access to the full Program to parse it for prop types etc. + const storyFileMetaNode = getDefaultExport(sourceFile) + if (!storyFileMetaNode) { + return + } + + const parseResult = parseStoryMetadata(storyFileMetaNode, parserContext) + + if (!parseResult) { + logger.debug(`Could not parse story metadata for ${path}`) + return + } + + const { figmaStoryMetadata, componentDeclaration, propMappings, mappedProps, examples } = + parseResult + + const componentMetadata = await parseComponentMetadata(componentDeclaration, parserContext) + + const figmadocs: FigmaConnectJSON[] = [] + const baseFigmadoc: FigmaConnectJSON = { + figmaNode: figmaStoryMetadata.url, + source: getRemoteFileUrl(componentMetadata.source, remoteUrl), + sourceLocation: { line: componentMetadata.line }, + template: '', + templateData: { + props: propMappings, + imports: [], + }, + component: componentMetadata.component, + label: 'Storybook', + language: 'typescript', + metadata: { + cliVersion: require('../../package.json').version, + }, + } + + // If there are no examples, just return a default Figmadoc + if (!examples) { + figmadocs.push({ + ...baseFigmadoc, + template: getDefaultTemplate(componentMetadata), + }) + return figmadocs + } + + for (const statement of sourceFile.statements) { + // Find any exported function or variable declarations, which correspond to stories + if (!(ts.isFunctionDeclaration(statement) || ts.isVariableStatement(statement))) { + continue + } + + const name = ts.isFunctionDeclaration(statement) + ? statement.name?.text + : statement.declarationList.declarations?.[0].name.getText(sourceFile) + + const example = examples?.find((example) => example.example === name) + // This story is not in the examples array, so skip it + if (examples && !example) { + continue + } + + let statementToParse: ts.ArrowFunction | ts.FunctionDeclaration | undefined + + if (ts.isFunctionDeclaration(statement)) { + statementToParse = statement + } else { + const initializer = statement.declarationList.declarations[0].initializer + if (initializer && ts.isArrowFunction(initializer)) { + statementToParse = initializer + } else if (initializer && ts.isObjectLiteralExpression(initializer)) { + // Handle stories like `export const Primary = { render: () =>