├── .github └── workflows │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── docs ├── cra-babel-setup.md ├── next-data-fetching.md └── steps-after-setup.md ├── e2e ├── .gitignore ├── assets │ ├── cra │ │ ├── TestComponent.jsx │ │ └── TestComponent.tsx │ ├── next │ │ ├── test.js │ │ └── test.tsx │ └── vite │ │ ├── TestComponent.jsx │ │ └── TestComponent.tsx ├── package.json ├── playwright.config.ts ├── tests │ ├── cra-js.spec.ts │ ├── cra-ts.spec.ts │ ├── helpers.ts │ ├── next-js.spec.ts │ ├── next-ts.spec.ts │ ├── vite-js.spec.ts │ └── vite-ts.spec.ts └── yarn.lock ├── jest.config.js ├── package.json ├── showcase.gif ├── src ├── arguments │ ├── ArgumentBase.ts │ ├── ArgumentHandler.ts │ ├── ArtifactDirectoryArgument.ts │ ├── PackageManagerArgument.ts │ ├── SchemaFileArgument.ts │ ├── SrcArgument.ts │ ├── SubscriptionsArgument.ts │ ├── ToolchainArgument.ts │ ├── TypeScriptArgument.ts │ └── index.ts ├── bin.ts ├── consts.ts ├── misc │ ├── CommandRunner.ts │ ├── Environment.ts │ ├── Filesystem.ts │ ├── Git.ts │ ├── PackageJsonFile.ts │ ├── ProjectContext.ts │ ├── RelativePath.ts │ └── packageManagers │ │ ├── NpmPackageManager.ts │ │ ├── PackageManager.ts │ │ ├── PnpmPackageManager.ts │ │ ├── YarnPackageManager.ts │ │ └── index.ts ├── tasks │ ├── AddRelayCompilerScriptsTask.ts │ ├── ConfigureEolOfArtifactsTask.ts │ ├── ConfigureRelayCompilerTask.ts │ ├── GenerateArtifactDirectoryTask.ts │ ├── GenerateGraphQlSchemaFileTask.ts │ ├── GenerateRelayEnvironmentTask.ts │ ├── InstallNpmDependenciesTask.ts │ ├── InstallNpmDevDependenciesTask.ts │ ├── TaskBase.ts │ ├── TaskRunner.ts │ ├── cra │ │ ├── Cra_AddBabelMacroTypeDefinitionsTask.ts │ │ └── Cra_AddRelayEnvironmentProvider.ts │ ├── index.ts │ ├── next │ │ ├── Next_AddRelayEnvironmentProvider.ts │ │ ├── Next_AddTypeHelpers.ts │ │ └── Next_ConfigureNextCompilerTask.ts │ └── vite │ │ ├── Vite_AddRelayEnvironmentProvider.ts │ │ └── Vite_ConfigureVitePluginRelayTask.ts ├── types.ts └── utils │ ├── ast.ts │ ├── cli.ts │ └── index.ts ├── tsconfig.json └── yarn.lock /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * 1" # Every Monday at 00:00 UTC 6 | workflow_dispatch: 7 | inputs: 8 | publishExperimental: 9 | description: 'Publish an experimental release' 10 | required: false 11 | type: boolean 12 | default: false 13 | push: 14 | branches: 15 | - main 16 | paths-ignore: 17 | - '**/**.md' 18 | 19 | jobs: 20 | build: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: actions/setup-node@v4 25 | with: 26 | node-version: 20.x 27 | - uses: actions/cache@v4 28 | with: 29 | path: node_modules 30 | key: node_modules-${{ hashFiles('yarn.lock') }} 31 | restore-keys: node_modules- 32 | - run: yarn install --frozen-lockfile 33 | - run: yarn build 34 | # - run: yarn --cwd ./e2e install --frozen-lockfile 35 | # - run: yarn --cwd ./e2e test 36 | - uses: actions/upload-artifact@v4 37 | if: ${{ inputs.publishExperimental }} 38 | with: 39 | name: build-output 40 | path: dist 41 | experimental-publish: 42 | if: ${{ inputs.publishExperimental }} 43 | needs: build 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v4 47 | - uses: actions/download-artifact@v4 48 | with: 49 | name: build-output 50 | path: dist 51 | - uses: actions/setup-node@v4 52 | with: 53 | node-version: 20.x 54 | registry-url: https://registry.npmjs.org/ 55 | - run: yarn version --no-git-tag-version --new-version "0.0.0-experimental-$(echo $GITHUB_SHA | cut -c1-9)" 56 | - run: yarn pack --filename package.tgz 57 | - run: yarn publish package.tgz --tag experimental --access public 58 | env: 59 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 60 | - uses: actions/upload-artifact@v4 61 | with: 62 | name: experimental-package 63 | path: package.tgz 64 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: 'The new version' 8 | required: true 9 | type: text 10 | 11 | release: 12 | types: [published] 13 | 14 | jobs: 15 | publish: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: 20.x 22 | registry-url: https://registry.npmjs.org/ 23 | - uses: actions/cache@v4 24 | with: 25 | path: node_modules 26 | key: node_modules-${{ hashFiles('**/yarn.lock') }} 27 | restore-keys: node_modules- 28 | - run: yarn install --frozen-lockfile 29 | - run: yarn build 30 | - run: yarn version --new-version "${{ github.event.release.tag_name || inputs.version }}" --no-git-tag-version 31 | - run: yarn pack --filename package.tgz 32 | - run: yarn publish package.tgz --access public 33 | env: 34 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 35 | - uses: actions/upload-artifact@v4 36 | with: 37 | name: package 38 | path: package.tgz 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | dist 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Tobias Tengler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

create-relay-app

2 |

Easy configuration of Relay.js for existing projects

3 | 4 |

5 | 6 | Latest version published to npm 7 | npm downloads per month 8 | Project license 9 | 10 |

11 | 12 |

13 | Showcase 14 |

15 | 16 | ## Motivation 17 | 18 | Setting up Relay can be quite time consuming, since there are _many_ setup steps that might differ depending on the toolchain you use. 19 | 20 | The goal of this project is to automate the setup process as much as possible and give you a fast and consistent configuration experience across the most popular React toolchains. 21 | 22 | Contrary to many existing tools that aim to solve similiar use cases, this project isn't simply scaffolding a pre-configured boilerplate. We actually analyze your existing code and only insert the necessary Relay configuration pieces. 23 | 24 | ## Supported toolchains 25 | 26 | `create-relay-app` supports: 27 | 28 | - [Next.js](https://nextjs.org/) (the v13 App Router is not yet supported) 29 | - [Vite.js](https://vitejs.dev/) 30 | - [Create React App](https://create-react-app.dev/) 31 | 32 | ## Usage 33 | 34 | 1. Scaffold a new project using the toolchain of your choice (as long as [it's supported](#supported-toolchains)) 35 | - [Next.js](https://nextjs.org/docs#automatic-setup) 36 | - [Vite.js](https://vitejs.dev/guide/#scaffolding-your-first-vite-project) 37 | - [Create React App](https://create-react-app.dev/docs/getting-started) 38 | 2. If you are inside a Git repository, ensure your working directory is clean, by commiting or discarding any changes. 39 | 3. Run the script inside of the scaffolded directory: 40 | 41 | ```bash 42 | npm/yarn/pnpm create @tobiastengler/relay-app 43 | ``` 44 | 45 | > Note: You can specify `-i` after the command to walk through an interactive prompt, instead of the script inferring your project's details. 46 | 47 | 4. Follow the displayed _Next steps_ to complete the setup (You can also find them [here](./docs/steps-after-setup.md)) 48 | 49 | ## Arguments 50 | 51 | ```bash 52 | npm/yarn/pnpm create @tobiastengler/relay-app [options] 53 | ``` 54 | 55 | > **Warning** 56 | > 57 | > npm requires you to pass `--` before any command to a starter kit, e.g. 58 | > 59 | > `npm create @tobiastengler/relay-app -- --interactive`. 60 | 61 | ### -h, --help 62 | 63 | Displays information about all of the available options. 64 | 65 | ### -v, --version 66 | 67 | Displays the current version of the script. 68 | 69 | ### -i, --interactive 70 | 71 | Displays an interactive prompt that allows you to manually input your project's details for options that weren't supplied as CLI arguments. 72 | 73 | Default: `false` 74 | 75 | ### -t, --toolchain <toolchain> 76 | 77 | The toolchain, e.g. bundler and configuration, your project was setup with. 78 | 79 | Expects: 80 | 81 | - `next` 82 | - `vite` 83 | - `cra` 84 | 85 | Default: `next`, if the `next` package is installed. `vite`, if the `vite` package is installed and otherwise `cra`. 86 | 87 | ### --typescript 88 | 89 | If specified, we assume your project is built with TypeScript. 90 | 91 | Default: `true`, if the `typescript` package is installed **or** there is a `tsconfig.json` file in the root directory of your project. Otherwise `false`. 92 | 93 | ### -f, --schema-file <path> 94 | 95 | Specifies the location of the GraphQL schema file inside of your project directory. 96 | 97 | Expects: 98 | 99 | A path relative to the root directory of your project and ending in the `.graphql` extension. 100 | 101 | Default: `./src/schema.graphql`, if the [toolchain](#t---toolchain-toolchain) is `next`, otherwise the value of [--src](#s---src-path) joined with `schema.graphql`. 102 | 103 | ### -s, --src <path> 104 | 105 | Specifies the source directory of your project, where the Relay compiler will be run on. 106 | 107 | Expects: 108 | 109 | A path to a directory relative to the root directory of your project. 110 | 111 | Default: `./`, if the [toolchain](#t---toolchain-toolchain) is `next`, otherwise `./src`. 112 | 113 | ### -a, --artifact-directory <path> 114 | 115 | Specifies a directory, where all artifacts generated by the Relay compiler will be placed. 116 | 117 | Expects: 118 | 119 | A path to a directory relative to the root directory of your project. 120 | 121 | Default: `./__generated__`, if the [toolchain](#t---toolchain-toolchain) is `next`, otherwise it's not set. 122 | 123 | ### --subscriptions 124 | 125 | Adds support for GraphQL Subscriptions via [graphql-ws](https://github.com/enisdenjo/graphql-ws) to your network layer. 126 | 127 | Default: Not set. 128 | 129 | ### -p, --package-manager <manager> 130 | 131 | Specify the Node.js package manager to use when packages need to be installed. 132 | 133 | Expects: 134 | 135 | - `npm` 136 | - `yarn` 137 | - `pnpm` 138 | 139 | Default: `yarn`, if there's a `yarn.lock` file and `yarn` is installed. `pnpm`, if there's a `pnpm-lock.yml` file and `pnpm` is installed. Otherwise the package manager that is executing the script will be used to install packages. 140 | 141 | ### --ignore-git-changes 142 | 143 | Does not exit the script, if it's run in a directory with un-commited Git changes. 144 | 145 | Default: `false` 146 | 147 | ### --skip-install 148 | 149 | Skips the installation of packages. 150 | 151 | Default: `false` 152 | 153 | ## Additional documents 154 | 155 | - [Manual steps after running the script](./docs/steps-after-setup.md) 156 | - [Data fetching with Next.js](./docs/next-data-fetching.md) 157 | - [babel-plugin-relay in combination with Create-React-App](./docs/cra-babel-setup.md) 158 | -------------------------------------------------------------------------------- /docs/cra-babel-setup.md: -------------------------------------------------------------------------------- 1 | The recommended way to use the `graphql` transform in Create React App projects is through the `babel-plugin-relay` macro: 2 | 3 | ```js 4 | import graphql from "babel-plugin-relay/macro"; 5 | 6 | const query = graphql` 7 | query App_Query { 8 | field 9 | } 10 | `; 11 | ``` 12 | 13 | There is nothing wrong with this approach, but it can be frustrating if your editor imports `graphql` from the `react-relay` package instead and now suddenly things don't work. 14 | 15 | Unfortunately Create React App does not offer a way to configure Babel plugins without ejecting. Fortunately we have some options. 16 | 17 | # Ejecting 18 | 19 | Yep, you guessed it: Ejecting is the first option. 20 | 21 | Ejecting is the easiest and most "official" way to configure Babel plugins for Create React App projects. But it also comes with a cost, since you loose all of the convenience of Create React App. 22 | 23 | If you want to go ahead regardless, follow the official tutorial on ejecting: https://create-react-app.dev/docs/available-scripts/#npm-run-eject 24 | 25 | Once you have ejected, locate the `./config/webpack.config.js` file. 26 | 27 | Now search for `plugins` until you find a section that looks similar to the following: 28 | 29 | ```js 30 | plugins: [ 31 | isEnvDevelopment && 32 | shouldUseReactRefresh && 33 | require.resolve("react-refresh/babel"), 34 | ].filter(Boolean); 35 | ``` 36 | 37 | Now simply add the `babel-plugin-relay` plugin: 38 | 39 | ```diff 40 | plugins: [ 41 | + require.resolve("babel-plugin-relay"), 42 | isEnvDevelopment && 43 | shouldUseReactRefresh && 44 | require.resolve("react-refresh/babel"), 45 | ].filter(Boolean); 46 | ``` 47 | 48 | Now you should be able to import `graphql` from `react-relay`, like you are supposed to: 49 | 50 | ```js 51 | import { graphql } from "react-relay"; 52 | 53 | const query = graphql` 54 | query App_Query { 55 | field 56 | } 57 | `; 58 | ``` 59 | 60 | # Craco 61 | 62 | Another option is to use a tool like [craco](https://github.com/dilanx/craco) to configure Babel plugins without ejecting. 63 | 64 | > ⚠️ Note: As of me writing this, craco does not support Create React App v5. Check back on their respository for the current status. 65 | 66 | First, install craco as shown here: https://github.com/dilanx/craco/blob/master/packages/craco/README.md#installation 67 | 68 | Next create a `craco.config.js` file at the root of your project and add the following code to it: 69 | 70 | ```js 71 | module.exports = { 72 | babel: { 73 | plugins: ["babel-plugin-relay"], 74 | }, 75 | }; 76 | ``` 77 | 78 | Now you should be able to import `graphql` from `react-relay`, like you are supposed to: 79 | 80 | ```js 81 | import { graphql } from "react-relay"; 82 | 83 | const query = graphql` 84 | query App_Query { 85 | field 86 | } 87 | `; 88 | ``` 89 | -------------------------------------------------------------------------------- /docs/next-data-fetching.md: -------------------------------------------------------------------------------- 1 | ## Server Components 2 | 3 | If you're using Next.js v13 with the experimental `app` directory, the Relay Team put together a full example featuring Server Components [here](https://github.com/relayjs/relay-examples/tree/main/issue-tracker-next-v13). 4 | 5 | ## Client-side data fetching 6 | 7 | If you only want to start fetching data, once the JS is executed in the browser, you can stick to `useLazyLoadQuery` and `preloadQuery` without any additional setup: 8 | 9 | ```tsx 10 | import { graphql, useLazyLoadQuery } from "react-relay"; 11 | 12 | const query = graphql` 13 | query MyComponentQuery { 14 | field 15 | } 16 | `; 17 | 18 | export default function MyComponent() { 19 | const data = useLazyLoadQuery(query, {}); 20 | 21 | // ... 22 | } 23 | ``` 24 | 25 | ## Server-side data fetching 26 | 27 | The solution I'm describing below is to my knowledge the only valid approach without using experimental features at the moment. The Relay Team put togehter a [more comprehensive example](https://github.com/relayjs/relay-examples/tree/main/data-driven-dependencies) that uses query preloading, but it's depending on experimental features. 28 | 29 | ### Without hydration 30 | 31 | You can use [getServerSideProps](https://nextjs.org/docs/basic-features/data-fetching/get-server-side-props) and [getStaticProps](https://nextjs.org/docs/basic-features/data-fetching/get-static-props) to fetch data and pass that data as props to your component. 32 | 33 | Combining these approaches with Relay is pretty straightforward: 34 | 35 | ```tsx 36 | import { graphql, fetchQuery } from "relay-runtime"; 37 | import { initRelayEnvironment } from "../src/RelayEnvironment"; 38 | import type { NextPage, GetServerSideProps, GetStaticProps } from "next"; 39 | import type { 40 | MyComponentQuery, 41 | MyComponentQuery$data, 42 | } from "../__generated__/MyComponentQuery.graphql"; 43 | 44 | const query = graphql` 45 | query MyComponentQuery { 46 | field 47 | } 48 | `; 49 | 50 | type Props = { 51 | data: MyComponentQuery$data; 52 | }; 53 | 54 | const MyComponent: NextPage = ({ data }) => { 55 | // ... 56 | }; 57 | 58 | export const getServerSideProps: GetServerSideProps = async () => { 59 | // Get a fresh environment. 60 | const environment = initRelayEnvironment(); 61 | 62 | // Execute the query. 63 | const observable = fetchQuery(environment, query, {}); 64 | const data = await observable.toPromise(); 65 | 66 | return { 67 | props: { 68 | // Pass the result of the query to your component 69 | data: data!, 70 | }, 71 | }; 72 | }; 73 | 74 | export const getStaticProps: GetStaticProps = async () => { 75 | // The code from getServerSideProps can just be copied here. 76 | }; 77 | 78 | export default MyComponent; 79 | ``` 80 | 81 | ### With hydration 82 | 83 | To hydrate the fetched entities into the Relay store on the client, we need to change the code a little (applies to both `getServerSideProps` and `getStatisProps`): 84 | 85 | ```tsx 86 | import type { GetRelayServerSideProps } from "../src/relay-types"; 87 | 88 | // ... 89 | 90 | export const getServerSideProps: GetRelayServerSideProps = async () => { 91 | const environment = initRelayEnvironment(); 92 | 93 | const observable = fetchQuery(environment, query, {}); 94 | const data = await observable.toPromise(); 95 | 96 | // Get the records that the query added to the store. 97 | const initialRecords = environment.getStore().getSource().toJSON(); 98 | 99 | return { 100 | props: { 101 | data: data!, 102 | // This is not intended for your component, 103 | // but it will be used by the _app component 104 | // to hydrate the Relay store on the client. 105 | // 106 | // IMPORTANT: The property name needs to be 107 | // `initialRecords`, otherwise the _app 108 | // component can not extract it. 109 | initialRecords, 110 | }, 111 | }; 112 | }; 113 | ``` 114 | 115 | For TypeScript users I have created the types `GetRelayServerSideProps` and `GetRelayStaticProps` that will force you to return the `initialRecords`. 116 | 117 | The `initialRecords` are then processed by the `_app` component, which should have already been setup to handle these records, if you used `create-relay-app` to setup the project. 118 | -------------------------------------------------------------------------------- /docs/steps-after-setup.md: -------------------------------------------------------------------------------- 1 | After you have [run the script](../README.md#usage), there are still some manual steps you need to take to finish the Relay setup (they are also printed to the console). 2 | 3 | ### 1. Replace the GraphQL schema file 4 | 5 | If you haven't already done so, grab the schema (SDL) of your GraphQL server and place it in the `.graphql` file you specified when running the script. You can also find the location of the schema file by opening your `package.json` file and inspecting the `relay.schema` property. 6 | 7 | ### 2. Specify the endpoints of your GraphQL server 8 | 9 | Open the `RelayEnvironment` file that was created by the script. 10 | 11 | At the top you should see a `HTTP_ENDPOINT` variable. Switch the value to the URL of your GraphQL server, where you want to send queries and mutations to. 12 | 13 | If you have choosen to configure subscriptions, there will also be a `WEBSOCKET_ENDPOINT` variable. Point it to the URL of your GraphQL server, where you want to send subscriptions to (must be a WebSocket URL). 14 | 15 | ### 3. Ensure that your tooling does not modify Relay's artifacts 16 | 17 | You might have certain tools configured that format or lint your code. Relay's artifact should not be modified by these tools or you might get validation errors from the `relay-compiler`. 18 | 19 | Make sure your tools ignore `*.graphql.ts` / `*.graphql.js` files. 20 | 21 | [Prettier](https://github.com/prettier/prettier) for example has a [`.prettierignore`](https://prettier.io/docs/en/ignore.html#ignoring-files-prettierignore) file and [ESLint](https://github.com/eslint/eslint) a [`.eslintignore`](https://eslint.org/docs/latest/user-guide/configuring/ignoring-code#the-eslintignore-file) file. 22 | 23 | > Note: If you are using GIT, the script will already fix the line ending of Relay artifacts to `LF` using a `.gitattributes` file. 24 | 25 | ### Create-React-App 26 | 27 | If you are using Create-React-App, you need to remember to import `graphql` from `babel-plugin-relay/macro` instead of `react-relay` or any other package exporting it: 28 | 29 | ```typescript 30 | import graphql from "babel-plugin-relay/macro"; 31 | 32 | const query = graphql` 33 | query App_Query { 34 | # ... 35 | } 36 | `; 37 | ``` 38 | 39 | If you wish to stick to importing from `react-relay`, there are some other options you can explore [here](./cra-babel-setup.md). 40 | 41 | ### Next.js 42 | 43 | If you are using Next.js and you want to do server-side rendering with Relay, check out [this guide](./next-data-fetching.md). 44 | -------------------------------------------------------------------------------- /e2e/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | /test-results/ 3 | /playwright-report/ 4 | /playwright/.cache/ 5 | 6 | /cra-js 7 | /cra-ts 8 | /vite-js 9 | /vite-ts 10 | /next-js 11 | /next-ts -------------------------------------------------------------------------------- /e2e/assets/cra/TestComponent.jsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react"; 2 | import { useLazyLoadQuery } from "react-relay"; 3 | import graphql from "babel-plugin-relay/macro"; 4 | 5 | export const TestComponent = () => { 6 | return ( 7 | Loading}> 8 | 9 | 10 | ); 11 | }; 12 | 13 | export const InnerTestComponent = () => { 14 | const data = useLazyLoadQuery( 15 | graphql` 16 | query TestComponentQuery { 17 | field 18 | } 19 | `, 20 | {} 21 | ); 22 | 23 | return
{data.field}
; 24 | }; 25 | -------------------------------------------------------------------------------- /e2e/assets/cra/TestComponent.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react"; 2 | import { useLazyLoadQuery } from "react-relay"; 3 | import graphql from "babel-plugin-relay/macro"; 4 | import { TestComponentQuery } from "./__generated__/TestComponentQuery.graphql"; 5 | 6 | export const TestComponent = () => { 7 | return ( 8 | Loading}> 9 | 10 | 11 | ); 12 | }; 13 | 14 | export const InnerTestComponent = () => { 15 | const data = useLazyLoadQuery( 16 | graphql` 17 | query TestComponentQuery { 18 | field 19 | } 20 | `, 21 | {} 22 | ); 23 | 24 | return
{data.field}
; 25 | }; 26 | -------------------------------------------------------------------------------- /e2e/assets/next/test.js: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react"; 2 | import { useLazyLoadQuery, graphql } from "react-relay"; 3 | 4 | export default function Test() { 5 | return ( 6 | Loading}> 7 | 8 | 9 | ); 10 | } 11 | 12 | export const InnerTestComponent = () => { 13 | const data = useLazyLoadQuery( 14 | graphql` 15 | query testQuery { 16 | field 17 | } 18 | `, 19 | {} 20 | ); 21 | 22 | return
{data.field}
; 23 | }; 24 | -------------------------------------------------------------------------------- /e2e/assets/next/test.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react"; 2 | import { useLazyLoadQuery, graphql } from "react-relay"; 3 | import { testQuery } from "../__generated__/testQuery.graphql"; 4 | 5 | export default function Test() { 6 | return ( 7 | Loading}> 8 | 9 | 10 | ); 11 | } 12 | 13 | export const InnerTestComponent = () => { 14 | const data = useLazyLoadQuery( 15 | graphql` 16 | query testQuery { 17 | field 18 | } 19 | `, 20 | {} 21 | ); 22 | 23 | return
{data.field}
; 24 | }; 25 | -------------------------------------------------------------------------------- /e2e/assets/vite/TestComponent.jsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react"; 2 | import { useLazyLoadQuery, graphql } from "react-relay"; 3 | 4 | export const TestComponent = () => { 5 | return ( 6 | Loading}> 7 | 8 | 9 | ); 10 | }; 11 | 12 | export const InnerTestComponent = () => { 13 | const data = useLazyLoadQuery( 14 | graphql` 15 | query TestComponentQuery { 16 | field 17 | } 18 | `, 19 | {} 20 | ); 21 | 22 | return
{data.field}
; 23 | }; 24 | -------------------------------------------------------------------------------- /e2e/assets/vite/TestComponent.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react"; 2 | import { useLazyLoadQuery, graphql } from "react-relay"; 3 | import { TestComponentQuery } from "./__generated__/TestComponentQuery.graphql"; 4 | 5 | export const TestComponent = () => { 6 | return ( 7 | Loading}> 8 | 9 | 10 | ); 11 | }; 12 | 13 | export const InnerTestComponent = () => { 14 | const data = useLazyLoadQuery( 15 | graphql` 16 | query TestComponentQuery { 17 | field 18 | } 19 | `, 20 | {} 21 | ); 22 | 23 | return
{data.field}
; 24 | }; 25 | -------------------------------------------------------------------------------- /e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "e2e", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "test": "playwright test", 7 | "postinstall": "playwright install chromium" 8 | }, 9 | "devDependencies": { 10 | "@playwright/test": "^1.25.0", 11 | "@types/babel__core": "^7.1.19" 12 | }, 13 | "dependencies": { 14 | "@babel/core": "^7.18.10", 15 | "@babel/generator": "^7.18.12", 16 | "@babel/traverse": "^7.18.11", 17 | "@babel/types": "^7.18.10" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /e2e/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from "@playwright/test"; 2 | import { devices } from "@playwright/test"; 3 | 4 | /** 5 | * Read environment variables from file. 6 | * https://github.com/motdotla/dotenv 7 | */ 8 | // require('dotenv').config(); 9 | 10 | /** 11 | * See https://playwright.dev/docs/test-configuration. 12 | */ 13 | const config: PlaywrightTestConfig = { 14 | testDir: "./tests", 15 | /* Maximum time one test can run for. */ 16 | timeout: 30 * 1000, 17 | expect: { 18 | /** 19 | * Maximum time expect() should wait for the condition to be met. 20 | * For example in `await expect(locator).toHaveText();` 21 | */ 22 | timeout: 5000, 23 | }, 24 | /* Run tests in files in parallel */ 25 | fullyParallel: false, 26 | maxFailures: 1, 27 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 28 | forbidOnly: !!process.env.CI, 29 | /* Retry on CI only */ 30 | retries: 0, 31 | /* Opt out of parallel tests on CI. */ 32 | workers: 1, 33 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 34 | reporter: "line", 35 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 36 | use: { 37 | /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ 38 | actionTimeout: 0, 39 | /* Base URL to use in actions like `await page.goto('/')`. */ 40 | // baseURL: 'http://localhost:3000', 41 | 42 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 43 | trace: "on-first-retry", 44 | }, 45 | 46 | /* Configure projects for major browsers */ 47 | projects: [ 48 | { 49 | name: "chromium", 50 | use: { 51 | ...devices["Desktop Chrome"], 52 | }, 53 | }, 54 | 55 | // { 56 | // name: "firefox", 57 | // use: { 58 | // ...devices["Desktop Firefox"], 59 | // }, 60 | // }, 61 | 62 | // { 63 | // name: "webkit", 64 | 65 | // use: { 66 | // ...devices["Desktop Safari"], 67 | // }, 68 | // }, 69 | 70 | /* Test against mobile viewports. */ 71 | // { 72 | // name: 'Mobile Chrome', 73 | // use: { 74 | // ...devices['Pixel 5'], 75 | // }, 76 | // }, 77 | // { 78 | // name: 'Mobile Safari', 79 | // use: { 80 | // ...devices['iPhone 12'], 81 | // }, 82 | // }, 83 | 84 | /* Test against branded browsers. */ 85 | // { 86 | // name: 'Microsoft Edge', 87 | // use: { 88 | // channel: 'msedge', 89 | // }, 90 | // }, 91 | // { 92 | // name: 'Google Chrome', 93 | // use: { 94 | // channel: 'chrome', 95 | // }, 96 | // }, 97 | ], 98 | 99 | /* Folder for test artifacts such as screenshots, videos, traces, etc. */ 100 | // outputDir: 'test-results/', 101 | 102 | /* Run your local dev server before starting the tests */ 103 | // webServer: { 104 | // command: 'npm run start', 105 | // port: 3000, 106 | // }, 107 | }; 108 | 109 | export default config; 110 | -------------------------------------------------------------------------------- /e2e/tests/cra-js.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { ChildProcess, exec, spawn } from "child_process"; 3 | import { copyFileSync, existsSync } from "fs"; 4 | import { 5 | fireCmd, 6 | insertTestComponentBelowRelayProvider, 7 | runCmd, 8 | } from "./helpers"; 9 | 10 | const TARGET_DIR = "./cra-js"; 11 | const PORT = 4000; 12 | 13 | let webServerProcess: ChildProcess; 14 | 15 | test.beforeAll(async () => { 16 | test.setTimeout(180000); 17 | 18 | if (!existsSync(TARGET_DIR)) { 19 | await runCmd(`yarn create react-app ${TARGET_DIR}`); 20 | } 21 | 22 | await runCmd( 23 | `node ../../dist/bin.js --ignore-git-changes --package-manager yarn`, 24 | { cwd: TARGET_DIR } 25 | ); 26 | 27 | copyFileSync( 28 | "./assets/cra/TestComponent.jsx", 29 | TARGET_DIR + "/src/TestComponent.jsx" 30 | ); 31 | 32 | const indexPath = TARGET_DIR + "/src/index.js"; 33 | await insertTestComponentBelowRelayProvider(indexPath, "TestComponent"); 34 | 35 | await runCmd(`yarn --cwd ${TARGET_DIR} run relay`); 36 | 37 | await runCmd(`yarn --cwd ${TARGET_DIR} run build`); 38 | 39 | await runCmd(`yarn global add serve`); 40 | 41 | webServerProcess = fireCmd(`serve -s ./build -l ${PORT}`, { 42 | cwd: TARGET_DIR, 43 | stdio: "inherit", 44 | }); 45 | 46 | // Give the server some time to come up 47 | await new Promise((resolve) => setTimeout(resolve, 5000)); 48 | }); 49 | 50 | test("Execute CRA/JS graphql request", async ({ page }) => { 51 | await page.route("**/graphql", async (route) => { 52 | route.fulfill({ 53 | status: 200, 54 | contentType: "application/json", 55 | body: JSON.stringify({ data: { field: "cra-js text" } }), 56 | }); 57 | }); 58 | 59 | await page.goto("http://localhost:" + PORT, { waitUntil: "networkidle" }); 60 | 61 | const innerText = await page.locator("#test-data").innerText(); 62 | 63 | await expect(innerText).toEqual("cra-js text"); 64 | }); 65 | 66 | test.afterAll(() => { 67 | const killed = webServerProcess?.kill(); 68 | 69 | if (!killed) { 70 | console.log("failed to kill dev server"); 71 | } 72 | 73 | // if (existsSync(scaffoldDir)) { 74 | // fs.rm(scaffoldDir, { recursive: true }); 75 | // } 76 | }); 77 | -------------------------------------------------------------------------------- /e2e/tests/cra-ts.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { ChildProcess, exec, spawn } from "child_process"; 3 | import { copyFileSync, existsSync } from "fs"; 4 | import { 5 | fireCmd, 6 | insertTestComponentBelowRelayProvider, 7 | runCmd, 8 | } from "./helpers"; 9 | 10 | const TARGET_DIR = "./cra-ts"; 11 | const PORT = 4001; 12 | 13 | let webServerProcess: ChildProcess; 14 | 15 | test.beforeAll(async () => { 16 | test.setTimeout(180000); 17 | 18 | if (!existsSync(TARGET_DIR)) { 19 | await runCmd(`yarn create react-app ${TARGET_DIR} --template typescript`); 20 | } 21 | 22 | await runCmd( 23 | `node ../../dist/bin.js --ignore-git-changes --package-manager yarn`, 24 | { cwd: TARGET_DIR } 25 | ); 26 | 27 | copyFileSync( 28 | "./assets/cra/TestComponent.tsx", 29 | TARGET_DIR + "/src/TestComponent.tsx" 30 | ); 31 | 32 | const indexPath = TARGET_DIR + "/src/index.tsx"; 33 | await insertTestComponentBelowRelayProvider(indexPath, "TestComponent"); 34 | 35 | await runCmd(`yarn --cwd ${TARGET_DIR} run relay`); 36 | 37 | await runCmd(`yarn --cwd ${TARGET_DIR} run build`); 38 | 39 | await runCmd(`yarn global add serve`); 40 | 41 | webServerProcess = fireCmd(`serve -s ./build -l ${PORT}`, { 42 | cwd: TARGET_DIR, 43 | stdio: "inherit", 44 | }); 45 | 46 | // Give the server some time to come up 47 | await new Promise((resolve) => setTimeout(resolve, 5000)); 48 | }); 49 | 50 | test("Execute CRA/TS graphql request", async ({ page }) => { 51 | await page.route("**/graphql", async (route) => { 52 | route.fulfill({ 53 | status: 200, 54 | contentType: "application/json", 55 | body: JSON.stringify({ data: { field: "cra-ts text" } }), 56 | }); 57 | }); 58 | 59 | await page.goto("http://localhost:" + PORT, { waitUntil: "networkidle" }); 60 | 61 | const innerText = await page.locator("#test-data").innerText(); 62 | 63 | await expect(innerText).toEqual("cra-ts text"); 64 | }); 65 | 66 | test.afterAll(() => { 67 | webServerProcess?.kill(); 68 | 69 | // if (existsSync(scaffoldDir)) { 70 | // fs.rm(scaffoldDir, { recursive: true }); 71 | // } 72 | }); 73 | -------------------------------------------------------------------------------- /e2e/tests/helpers.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcess, spawn, SpawnOptions } from "child_process"; 2 | import { Filesystem } from "../../src/misc/Filesystem"; 3 | import { RelativePath } from "../../src/misc/RelativePath"; 4 | import { traverse, types as t } from "@babel/core"; 5 | import { parse, ParseResult } from "@babel/parser"; 6 | import generate from "@babel/generator"; 7 | import { NodePath } from "@babel/traverse"; 8 | import path from "path"; 9 | 10 | export function runCmd(cmd: string, opt?: SpawnOptions) { 11 | return new Promise((resolve, reject) => { 12 | const [executable, ...args] = cmd.split(" "); 13 | 14 | const child = spawn(executable, args, { 15 | ...opt, 16 | shell: true, 17 | }); 18 | 19 | if (child.stdout) { 20 | child.stdout.setEncoding("utf8"); 21 | child.stdout.on("data", function (data) { 22 | process.stdout.write(data); 23 | }); 24 | } 25 | 26 | if (child.stderr) { 27 | child.stderr.setEncoding("utf8"); 28 | child.stderr.on("data", function (data) { 29 | process.stdout.write(data); 30 | }); 31 | } 32 | 33 | child.on("close", (code) => { 34 | if (code !== 0) { 35 | reject(`Command \"${executable} ${args.join(" ")}\" failed`); 36 | return; 37 | } 38 | 39 | resolve(); 40 | }); 41 | }); 42 | } 43 | 44 | export function fireCmd(cmd: string, opt?: SpawnOptions): ChildProcess { 45 | const [executable, ...args] = cmd.split(" "); 46 | 47 | const child = spawn(executable, args, { 48 | ...opt, 49 | stdio: "inherit", 50 | shell: true, 51 | }); 52 | 53 | return child; 54 | } 55 | 56 | export async function insertTestComponentBelowRelayProvider( 57 | filepath: string, 58 | relativeImport: string 59 | ) { 60 | const fs = new Filesystem(); 61 | const code = await fs.readFromFile(filepath); 62 | 63 | const ast = parseAst(code); 64 | 65 | let inserted = false; 66 | 67 | traverse(ast, { 68 | JSXElement: (nodePath) => { 69 | if (inserted) { 70 | return; 71 | } 72 | 73 | const openingElement = nodePath.node.openingElement; 74 | 75 | if ( 76 | !t.isJSXIdentifier(openingElement.name) || 77 | openingElement.name.name !== "RelayEnvironmentProvider" 78 | ) { 79 | return; 80 | } 81 | 82 | const parentDirectory = path.dirname(filepath); 83 | 84 | const relativeImportPath = new RelativePath( 85 | parentDirectory, 86 | removeExtension(relativeImport) 87 | ); 88 | 89 | const importName = "TestComponent"; 90 | 91 | const testComponentId = insertNamedImport( 92 | nodePath, 93 | importName, 94 | relativeImportPath.rel 95 | ); 96 | 97 | if ( 98 | nodePath.node.children.some( 99 | (c) => 100 | t.isJSXElement(c) && 101 | t.isJSXIdentifier(c.openingElement.name) && 102 | c.openingElement.name.name === importName 103 | ) 104 | ) { 105 | inserted = true; 106 | nodePath.skip; 107 | return; 108 | } 109 | 110 | const newTest = t.jsxElement( 111 | t.jsxOpeningElement(t.jsxIdentifier(testComponentId.name), [], true), 112 | null, 113 | [], 114 | true 115 | ); 116 | 117 | nodePath.node.children.push(newTest); 118 | 119 | inserted = true; 120 | 121 | nodePath.skip(); 122 | }, 123 | }); 124 | 125 | if (!inserted) { 126 | throw new Error("Could not insert reference to TestComponent"); 127 | } 128 | 129 | const updatedCode = printAst(ast, code); 130 | 131 | await fs.writeToFile(filepath, updatedCode); 132 | } 133 | 134 | function removeExtension(filename: string): string { 135 | return filename.substring(0, filename.lastIndexOf(".")) || filename; 136 | } 137 | 138 | // taken from src/utils/ast 139 | function parseAst(code: string): ParseResult { 140 | return parse(code, { 141 | sourceType: "module", 142 | plugins: ["typescript", "jsx"], 143 | }); 144 | } 145 | 146 | function printAst(ast: ParseResult, oldCode: string): string { 147 | return generate(ast, { retainLines: true }, oldCode).code; 148 | } 149 | 150 | function insertNamedImport( 151 | path: NodePath, 152 | importName: string, 153 | packageName: string 154 | ): t.Identifier { 155 | const importIdentifier = t.identifier(importName); 156 | 157 | const program = path.findParent((p) => p.isProgram()) as NodePath; 158 | 159 | const existingImport = getNamedImport(program, importName, packageName); 160 | 161 | if (!!existingImport) { 162 | return importIdentifier; 163 | } 164 | 165 | const importDeclaration = t.importDeclaration( 166 | [t.importSpecifier(t.cloneNode(importIdentifier), importIdentifier)], 167 | t.stringLiteral(packageName) 168 | ); 169 | 170 | // Insert import at start of file. 171 | program.node.body.unshift(importDeclaration); 172 | 173 | return importIdentifier; 174 | } 175 | 176 | export function getNamedImport( 177 | path: NodePath, 178 | importName: string, 179 | packageName: string 180 | ): t.ImportDeclaration { 181 | return path.node.body.find( 182 | (s) => 183 | t.isImportDeclaration(s) && 184 | s.source.value === packageName && 185 | s.specifiers.some( 186 | (sp) => t.isImportSpecifier(sp) && sp.local.name === importName 187 | ) 188 | ) as t.ImportDeclaration; 189 | } 190 | -------------------------------------------------------------------------------- /e2e/tests/next-js.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { ChildProcess } from "child_process"; 3 | import { copyFileSync, existsSync } from "fs"; 4 | import { fireCmd, runCmd } from "./helpers"; 5 | 6 | const TARGET_DIR = "./next-js"; 7 | const PORT = 4004; 8 | 9 | let webServerProcess: ChildProcess; 10 | 11 | test.beforeAll(async () => { 12 | test.setTimeout(180000); 13 | 14 | if (!existsSync(TARGET_DIR)) { 15 | await runCmd(`yarn create next-app ${TARGET_DIR} --javascript --eslint`); 16 | } 17 | 18 | await runCmd( 19 | `node ../../dist/bin.js --ignore-git-changes --package-manager yarn`, 20 | { cwd: TARGET_DIR } 21 | ); 22 | 23 | copyFileSync("./assets/next/test.js", TARGET_DIR + "/pages/test.js"); 24 | 25 | await runCmd(`yarn --cwd ${TARGET_DIR} run relay`); 26 | 27 | await runCmd(`yarn --cwd ${TARGET_DIR} run build`); 28 | 29 | webServerProcess = fireCmd(`yarn start -- -p ${PORT}`, { 30 | cwd: TARGET_DIR, 31 | stdio: "inherit", 32 | }); 33 | 34 | // Give the server some time to come up 35 | await new Promise((resolve) => setTimeout(resolve, 5000)); 36 | }); 37 | 38 | test("Execute NEXT/JS graphql request", async ({ page }) => { 39 | await page.route("**/graphql", async (route) => { 40 | route.fulfill({ 41 | status: 200, 42 | contentType: "application/json", 43 | body: JSON.stringify({ data: { field: "next-js text" } }), 44 | }); 45 | }); 46 | 47 | await page.goto("http://localhost:" + PORT + "/test", { 48 | waitUntil: "networkidle", 49 | }); 50 | 51 | const innerText = await page.locator("#test-data").innerText(); 52 | 53 | await expect(innerText).toEqual("next-js text"); 54 | }); 55 | 56 | test.afterAll(() => { 57 | const killed = webServerProcess?.kill(); 58 | 59 | if (!killed) { 60 | console.log("failed to kill dev server"); 61 | } 62 | 63 | // if (existsSync(scaffoldDir)) { 64 | // fs.rm(scaffoldDir, { recursive: true }); 65 | // } 66 | }); 67 | -------------------------------------------------------------------------------- /e2e/tests/next-ts.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { ChildProcess } from "child_process"; 3 | import { copyFileSync, existsSync } from "fs"; 4 | import { fireCmd, runCmd } from "./helpers"; 5 | 6 | const TARGET_DIR = "./next-ts"; 7 | const PORT = 4005; 8 | 9 | let webServerProcess: ChildProcess; 10 | 11 | test.beforeAll(async () => { 12 | test.setTimeout(180000); 13 | 14 | if (!existsSync(TARGET_DIR)) { 15 | await runCmd( 16 | `yarn create next-app ${TARGET_DIR} --typescript --no-eslint --no-tailwind --no-app --no-src-dir --import-alias '@/*'` 17 | ); 18 | } 19 | 20 | await runCmd(`node ../../dist/bin.js --ignore-git-changes --package-manager yarn`, { cwd: TARGET_DIR }); 21 | 22 | copyFileSync("./assets/next/test.tsx", TARGET_DIR + "/pages/test.tsx"); 23 | 24 | await runCmd(`yarn --cwd ${TARGET_DIR} run relay`); 25 | 26 | await runCmd(`yarn --cwd ${TARGET_DIR} run build`); 27 | 28 | webServerProcess = fireCmd(`yarn start -- -p ${PORT}`, { 29 | cwd: TARGET_DIR, 30 | stdio: "inherit", 31 | }); 32 | 33 | // Give the server some time to come up 34 | await new Promise((resolve) => setTimeout(resolve, 5000)); 35 | }); 36 | 37 | test("Execute NEXT/TS graphql request", async ({ page }) => { 38 | await page.route("**/graphql", async (route) => { 39 | route.fulfill({ 40 | status: 200, 41 | contentType: "application/json", 42 | body: JSON.stringify({ data: { field: "next-ts text" } }), 43 | }); 44 | }); 45 | 46 | await page.goto("http://localhost:" + PORT + "/test", { 47 | waitUntil: "networkidle", 48 | }); 49 | 50 | const innerText = await page.locator("#test-data").innerText(); 51 | 52 | await expect(innerText).toEqual("next-ts text"); 53 | }); 54 | 55 | test.afterAll(() => { 56 | webServerProcess?.kill(); 57 | 58 | // if (existsSync(scaffoldDir)) { 59 | // fs.rm(scaffoldDir, { recursive: true }); 60 | // } 61 | }); 62 | -------------------------------------------------------------------------------- /e2e/tests/vite-js.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { ChildProcess, exec } from "child_process"; 3 | import { copyFileSync, existsSync } from "fs"; 4 | import { 5 | fireCmd, 6 | insertTestComponentBelowRelayProvider, 7 | runCmd, 8 | } from "./helpers"; 9 | 10 | const TARGET_DIR = "./vite-js"; 11 | const PORT = 4002; 12 | let webServerProcess: ChildProcess; 13 | 14 | test.beforeAll(async () => { 15 | test.setTimeout(180000); 16 | 17 | if (!existsSync(TARGET_DIR)) { 18 | await runCmd(`yarn create vite vite-js --template react`); 19 | } 20 | 21 | await runCmd( 22 | `node ../../dist/bin.js --ignore-git-changes --package-manager yarn`, 23 | { 24 | cwd: TARGET_DIR, 25 | } 26 | ); 27 | 28 | copyFileSync( 29 | "./assets/vite/TestComponent.jsx", 30 | TARGET_DIR + "/src/TestComponent.jsx" 31 | ); 32 | 33 | const indexPath = TARGET_DIR + "/src/main.jsx"; 34 | await insertTestComponentBelowRelayProvider(indexPath, "TestComponent"); 35 | 36 | await runCmd(`yarn --cwd ${TARGET_DIR} run relay`); 37 | 38 | await runCmd(`yarn --cwd ${TARGET_DIR} run build`); 39 | 40 | webServerProcess = fireCmd( 41 | `yarn --cwd ${TARGET_DIR} run preview -- --port ${PORT}`, 42 | { stdio: "inherit" } 43 | ); 44 | 45 | // Give the server some time to come up 46 | await new Promise((resolve) => setTimeout(resolve, 5000)); 47 | }); 48 | 49 | test("Execute Vite/JS graphql request", async ({ page }) => { 50 | await page.route("**/graphql", async (route) => { 51 | route.fulfill({ 52 | status: 200, 53 | contentType: "application/json", 54 | body: JSON.stringify({ data: { field: "vite-js text" } }), 55 | }); 56 | }); 57 | 58 | await page.goto("http://localhost:" + PORT, { waitUntil: "networkidle" }); 59 | 60 | const innerText = await page.locator("#test-data").innerText(); 61 | 62 | await expect(innerText).toEqual("vite-js text"); 63 | }); 64 | 65 | test.afterAll(() => { 66 | webServerProcess?.kill(); 67 | 68 | // if (existsSync(scaffoldDir)) { 69 | // fs.rm(scaffoldDir, { recursive: true }); 70 | // } 71 | }); 72 | -------------------------------------------------------------------------------- /e2e/tests/vite-ts.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { ChildProcess, exec } from "child_process"; 3 | import { copyFileSync, existsSync } from "fs"; 4 | import { 5 | fireCmd, 6 | insertTestComponentBelowRelayProvider, 7 | runCmd, 8 | } from "./helpers"; 9 | 10 | const TARGET_DIR = "./vite-ts"; 11 | 12 | const PORT = 4003; 13 | let webServerProcess: ChildProcess; 14 | 15 | test.beforeAll(async () => { 16 | test.setTimeout(180000); 17 | 18 | if (!existsSync(TARGET_DIR)) { 19 | await runCmd(`yarn create vite vite-ts --template react-ts`); 20 | } 21 | 22 | await runCmd( 23 | `node ../../dist/bin.js --ignore-git-changes --package-manager yarn`, 24 | { 25 | cwd: TARGET_DIR, 26 | } 27 | ); 28 | 29 | copyFileSync( 30 | "./assets/vite/TestComponent.tsx", 31 | TARGET_DIR + "/src/TestComponent.tsx" 32 | ); 33 | 34 | const indexPath = TARGET_DIR + "/src/main.tsx"; 35 | await insertTestComponentBelowRelayProvider(indexPath, "TestComponent"); 36 | 37 | await runCmd(`yarn --cwd ${TARGET_DIR} run relay`); 38 | 39 | await runCmd(`yarn --cwd ${TARGET_DIR} run build`); 40 | 41 | webServerProcess = fireCmd( 42 | `yarn --cwd ${TARGET_DIR} run preview -- --port ${PORT}`, 43 | { stdio: "inherit" } 44 | ); 45 | 46 | // Give the server some time to come up 47 | await new Promise((resolve) => setTimeout(resolve, 5000)); 48 | }); 49 | 50 | test("Execute Vite/TS graphql request", async ({ page }) => { 51 | await page.route("**/graphql", async (route) => { 52 | route.fulfill({ 53 | status: 200, 54 | contentType: "application/json", 55 | body: JSON.stringify({ data: { field: "vite-ts text" } }), 56 | }); 57 | }); 58 | 59 | await page.goto("http://localhost:" + PORT, { waitUntil: "networkidle" }); 60 | 61 | const innerText = await page.locator("#test-data").innerText(); 62 | 63 | await expect(innerText).toEqual("vite-ts text"); 64 | }); 65 | 66 | test.afterAll(() => { 67 | webServerProcess?.kill(); 68 | 69 | // if (existsSync(scaffoldDir)) { 70 | // fs.rm(scaffoldDir, { recursive: true }); 71 | // } 72 | }); 73 | -------------------------------------------------------------------------------- /e2e/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@ampproject/remapping@^2.1.0": 6 | version "2.2.0" 7 | resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.0.tgz#56c133824780de3174aed5ab6834f3026790154d" 8 | integrity sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w== 9 | dependencies: 10 | "@jridgewell/gen-mapping" "^0.1.0" 11 | "@jridgewell/trace-mapping" "^0.3.9" 12 | 13 | "@babel/code-frame@^7.18.6": 14 | version "7.18.6" 15 | resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a" 16 | integrity sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q== 17 | dependencies: 18 | "@babel/highlight" "^7.18.6" 19 | 20 | "@babel/compat-data@^7.18.8": 21 | version "7.18.8" 22 | resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.18.8.tgz#2483f565faca607b8535590e84e7de323f27764d" 23 | integrity sha512-HSmX4WZPPK3FUxYp7g2T6EyO8j96HlZJlxmKPSh6KAcqwyDrfx7hKjXpAW/0FhFfTJsR0Yt4lAjLI2coMptIHQ== 24 | 25 | "@babel/core@^7.18.10": 26 | version "7.18.10" 27 | resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.18.10.tgz#39ad504991d77f1f3da91be0b8b949a5bc466fb8" 28 | integrity sha512-JQM6k6ENcBFKVtWvLavlvi/mPcpYZ3+R+2EySDEMSMbp7Mn4FexlbbJVrx2R7Ijhr01T8gyqrOaABWIOgxeUyw== 29 | dependencies: 30 | "@ampproject/remapping" "^2.1.0" 31 | "@babel/code-frame" "^7.18.6" 32 | "@babel/generator" "^7.18.10" 33 | "@babel/helper-compilation-targets" "^7.18.9" 34 | "@babel/helper-module-transforms" "^7.18.9" 35 | "@babel/helpers" "^7.18.9" 36 | "@babel/parser" "^7.18.10" 37 | "@babel/template" "^7.18.10" 38 | "@babel/traverse" "^7.18.10" 39 | "@babel/types" "^7.18.10" 40 | convert-source-map "^1.7.0" 41 | debug "^4.1.0" 42 | gensync "^1.0.0-beta.2" 43 | json5 "^2.2.1" 44 | semver "^6.3.0" 45 | 46 | "@babel/generator@^7.18.10", "@babel/generator@^7.18.12": 47 | version "7.18.12" 48 | resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.18.12.tgz#fa58daa303757bd6f5e4bbca91b342040463d9f4" 49 | integrity sha512-dfQ8ebCN98SvyL7IxNMCUtZQSq5R7kxgN+r8qYTGDmmSion1hX2C0zq2yo1bsCDhXixokv1SAWTZUMYbO/V5zg== 50 | dependencies: 51 | "@babel/types" "^7.18.10" 52 | "@jridgewell/gen-mapping" "^0.3.2" 53 | jsesc "^2.5.1" 54 | 55 | "@babel/helper-compilation-targets@^7.18.9": 56 | version "7.18.9" 57 | resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.18.9.tgz#69e64f57b524cde3e5ff6cc5a9f4a387ee5563bf" 58 | integrity sha512-tzLCyVmqUiFlcFoAPLA/gL9TeYrF61VLNtb+hvkuVaB5SUjW7jcfrglBIX1vUIoT7CLP3bBlIMeyEsIl2eFQNg== 59 | dependencies: 60 | "@babel/compat-data" "^7.18.8" 61 | "@babel/helper-validator-option" "^7.18.6" 62 | browserslist "^4.20.2" 63 | semver "^6.3.0" 64 | 65 | "@babel/helper-environment-visitor@^7.18.9": 66 | version "7.18.9" 67 | resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz#0c0cee9b35d2ca190478756865bb3528422f51be" 68 | integrity sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg== 69 | 70 | "@babel/helper-function-name@^7.18.9": 71 | version "7.18.9" 72 | resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.18.9.tgz#940e6084a55dee867d33b4e487da2676365e86b0" 73 | integrity sha512-fJgWlZt7nxGksJS9a0XdSaI4XvpExnNIgRP+rVefWh5U7BL8pPuir6SJUmFKRfjWQ51OtWSzwOxhaH/EBWWc0A== 74 | dependencies: 75 | "@babel/template" "^7.18.6" 76 | "@babel/types" "^7.18.9" 77 | 78 | "@babel/helper-hoist-variables@^7.18.6": 79 | version "7.18.6" 80 | resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz#d4d2c8fb4baeaa5c68b99cc8245c56554f926678" 81 | integrity sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q== 82 | dependencies: 83 | "@babel/types" "^7.18.6" 84 | 85 | "@babel/helper-module-imports@^7.18.6": 86 | version "7.18.6" 87 | resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz#1e3ebdbbd08aad1437b428c50204db13c5a3ca6e" 88 | integrity sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA== 89 | dependencies: 90 | "@babel/types" "^7.18.6" 91 | 92 | "@babel/helper-module-transforms@^7.18.9": 93 | version "7.18.9" 94 | resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.18.9.tgz#5a1079c005135ed627442df31a42887e80fcb712" 95 | integrity sha512-KYNqY0ICwfv19b31XzvmI/mfcylOzbLtowkw+mfvGPAQ3kfCnMLYbED3YecL5tPd8nAYFQFAd6JHp2LxZk/J1g== 96 | dependencies: 97 | "@babel/helper-environment-visitor" "^7.18.9" 98 | "@babel/helper-module-imports" "^7.18.6" 99 | "@babel/helper-simple-access" "^7.18.6" 100 | "@babel/helper-split-export-declaration" "^7.18.6" 101 | "@babel/helper-validator-identifier" "^7.18.6" 102 | "@babel/template" "^7.18.6" 103 | "@babel/traverse" "^7.18.9" 104 | "@babel/types" "^7.18.9" 105 | 106 | "@babel/helper-simple-access@^7.18.6": 107 | version "7.18.6" 108 | resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.18.6.tgz#d6d8f51f4ac2978068df934b569f08f29788c7ea" 109 | integrity sha512-iNpIgTgyAvDQpDj76POqg+YEt8fPxx3yaNBg3S30dxNKm2SWfYhD0TGrK/Eu9wHpUW63VQU894TsTg+GLbUa1g== 110 | dependencies: 111 | "@babel/types" "^7.18.6" 112 | 113 | "@babel/helper-split-export-declaration@^7.18.6": 114 | version "7.18.6" 115 | resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz#7367949bc75b20c6d5a5d4a97bba2824ae8ef075" 116 | integrity sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA== 117 | dependencies: 118 | "@babel/types" "^7.18.6" 119 | 120 | "@babel/helper-string-parser@^7.18.10": 121 | version "7.18.10" 122 | resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.18.10.tgz#181f22d28ebe1b3857fa575f5c290b1aaf659b56" 123 | integrity sha512-XtIfWmeNY3i4t7t4D2t02q50HvqHybPqW2ki1kosnvWCwuCMeo81Jf0gwr85jy/neUdg5XDdeFE/80DXiO+njw== 124 | 125 | "@babel/helper-validator-identifier@^7.18.6": 126 | version "7.18.6" 127 | resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.18.6.tgz#9c97e30d31b2b8c72a1d08984f2ca9b574d7a076" 128 | integrity sha512-MmetCkz9ej86nJQV+sFCxoGGrUbU3q02kgLciwkrt9QqEB7cP39oKEY0PakknEO0Gu20SskMRi+AYZ3b1TpN9g== 129 | 130 | "@babel/helper-validator-option@^7.18.6": 131 | version "7.18.6" 132 | resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz#bf0d2b5a509b1f336099e4ff36e1a63aa5db4db8" 133 | integrity sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw== 134 | 135 | "@babel/helpers@^7.18.9": 136 | version "7.18.9" 137 | resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.18.9.tgz#4bef3b893f253a1eced04516824ede94dcfe7ff9" 138 | integrity sha512-Jf5a+rbrLoR4eNdUmnFu8cN5eNJT6qdTdOg5IHIzq87WwyRw9PwguLFOWYgktN/60IP4fgDUawJvs7PjQIzELQ== 139 | dependencies: 140 | "@babel/template" "^7.18.6" 141 | "@babel/traverse" "^7.18.9" 142 | "@babel/types" "^7.18.9" 143 | 144 | "@babel/highlight@^7.18.6": 145 | version "7.18.6" 146 | resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.18.6.tgz#81158601e93e2563795adcbfbdf5d64be3f2ecdf" 147 | integrity sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g== 148 | dependencies: 149 | "@babel/helper-validator-identifier" "^7.18.6" 150 | chalk "^2.0.0" 151 | js-tokens "^4.0.0" 152 | 153 | "@babel/parser@^7.1.0", "@babel/parser@^7.18.10", "@babel/parser@^7.18.11": 154 | version "7.18.11" 155 | resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.11.tgz#68bb07ab3d380affa9a3f96728df07969645d2d9" 156 | integrity sha512-9JKn5vN+hDt0Hdqn1PiJ2guflwP+B6Ga8qbDuoF0PzzVhrzsKIJo8yGqVk6CmMHiMei9w1C1Bp9IMJSIK+HPIQ== 157 | 158 | "@babel/template@^7.18.10", "@babel/template@^7.18.6": 159 | version "7.18.10" 160 | resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.10.tgz#6f9134835970d1dbf0835c0d100c9f38de0c5e71" 161 | integrity sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA== 162 | dependencies: 163 | "@babel/code-frame" "^7.18.6" 164 | "@babel/parser" "^7.18.10" 165 | "@babel/types" "^7.18.10" 166 | 167 | "@babel/traverse@^7.18.10", "@babel/traverse@^7.18.11", "@babel/traverse@^7.18.9": 168 | version "7.18.11" 169 | resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.18.11.tgz#3d51f2afbd83ecf9912bcbb5c4d94e3d2ddaa16f" 170 | integrity sha512-TG9PiM2R/cWCAy6BPJKeHzNbu4lPzOSZpeMfeNErskGpTJx6trEvFaVCbDvpcxwy49BKWmEPwiW8mrysNiDvIQ== 171 | dependencies: 172 | "@babel/code-frame" "^7.18.6" 173 | "@babel/generator" "^7.18.10" 174 | "@babel/helper-environment-visitor" "^7.18.9" 175 | "@babel/helper-function-name" "^7.18.9" 176 | "@babel/helper-hoist-variables" "^7.18.6" 177 | "@babel/helper-split-export-declaration" "^7.18.6" 178 | "@babel/parser" "^7.18.11" 179 | "@babel/types" "^7.18.10" 180 | debug "^4.1.0" 181 | globals "^11.1.0" 182 | 183 | "@babel/types@^7.0.0", "@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.3.0": 184 | version "7.18.10" 185 | resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.18.10.tgz#4908e81b6b339ca7c6b7a555a5fc29446f26dde6" 186 | integrity sha512-MJvnbEiiNkpjo+LknnmRrqbY1GPUUggjv+wQVjetM/AONoupqRALB7I6jGqNUAZsKcRIEu2J6FRFvsczljjsaQ== 187 | dependencies: 188 | "@babel/helper-string-parser" "^7.18.10" 189 | "@babel/helper-validator-identifier" "^7.18.6" 190 | to-fast-properties "^2.0.0" 191 | 192 | "@jridgewell/gen-mapping@^0.1.0": 193 | version "0.1.1" 194 | resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz#e5d2e450306a9491e3bd77e323e38d7aff315996" 195 | integrity sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w== 196 | dependencies: 197 | "@jridgewell/set-array" "^1.0.0" 198 | "@jridgewell/sourcemap-codec" "^1.4.10" 199 | 200 | "@jridgewell/gen-mapping@^0.3.2": 201 | version "0.3.2" 202 | resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz#c1aedc61e853f2bb9f5dfe6d4442d3b565b253b9" 203 | integrity sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A== 204 | dependencies: 205 | "@jridgewell/set-array" "^1.0.1" 206 | "@jridgewell/sourcemap-codec" "^1.4.10" 207 | "@jridgewell/trace-mapping" "^0.3.9" 208 | 209 | "@jridgewell/resolve-uri@^3.0.3": 210 | version "3.1.0" 211 | resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78" 212 | integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w== 213 | 214 | "@jridgewell/set-array@^1.0.0", "@jridgewell/set-array@^1.0.1": 215 | version "1.1.2" 216 | resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72" 217 | integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw== 218 | 219 | "@jridgewell/sourcemap-codec@^1.4.10": 220 | version "1.4.14" 221 | resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24" 222 | integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw== 223 | 224 | "@jridgewell/trace-mapping@^0.3.9": 225 | version "0.3.15" 226 | resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.15.tgz#aba35c48a38d3fd84b37e66c9c0423f9744f9774" 227 | integrity sha512-oWZNOULl+UbhsgB51uuZzglikfIKSUBO/M9W2OfEjn7cmqoAiCgmv9lyACTUacZwBz0ITnJ2NqjU8Tx0DHL88g== 228 | dependencies: 229 | "@jridgewell/resolve-uri" "^3.0.3" 230 | "@jridgewell/sourcemap-codec" "^1.4.10" 231 | 232 | "@playwright/test@^1.25.0": 233 | version "1.25.0" 234 | resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.25.0.tgz#e0de134651e78e45e986c5f16578188dd5937331" 235 | integrity sha512-j4EZhTTQI3dBeWblE21EV//swwmBtOpIrLdOIJIRv4uqsLdHgBg1z+JtTg+AeC5o2bAXIE26kDNW5A0TimG8Bg== 236 | dependencies: 237 | "@types/node" "*" 238 | playwright-core "1.25.0" 239 | 240 | "@types/babel__core@^7.1.19": 241 | version "7.1.19" 242 | resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.19.tgz#7b497495b7d1b4812bdb9d02804d0576f43ee460" 243 | integrity sha512-WEOTgRsbYkvA/KCsDwVEGkd7WAr1e3g31VHQ8zy5gul/V1qKullU/BU5I68X5v7V3GnB9eotmom4v5a5gjxorw== 244 | dependencies: 245 | "@babel/parser" "^7.1.0" 246 | "@babel/types" "^7.0.0" 247 | "@types/babel__generator" "*" 248 | "@types/babel__template" "*" 249 | "@types/babel__traverse" "*" 250 | 251 | "@types/babel__generator@*": 252 | version "7.6.4" 253 | resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.4.tgz#1f20ce4c5b1990b37900b63f050182d28c2439b7" 254 | integrity sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg== 255 | dependencies: 256 | "@babel/types" "^7.0.0" 257 | 258 | "@types/babel__template@*": 259 | version "7.4.1" 260 | resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.1.tgz#3d1a48fd9d6c0edfd56f2ff578daed48f36c8969" 261 | integrity sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g== 262 | dependencies: 263 | "@babel/parser" "^7.1.0" 264 | "@babel/types" "^7.0.0" 265 | 266 | "@types/babel__traverse@*": 267 | version "7.18.0" 268 | resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.18.0.tgz#8134fd78cb39567465be65b9fdc16d378095f41f" 269 | integrity sha512-v4Vwdko+pgymgS+A2UIaJru93zQd85vIGWObM5ekZNdXCKtDYqATlEYnWgfo86Q6I1Lh0oXnksDnMU1cwmlPDw== 270 | dependencies: 271 | "@babel/types" "^7.3.0" 272 | 273 | "@types/node@*": 274 | version "18.7.6" 275 | resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.6.tgz#31743bc5772b6ac223845e18c3fc26f042713c83" 276 | integrity sha512-EdxgKRXgYsNITy5mjjXjVE/CS8YENSdhiagGrLqjG0pvA2owgJ6i4l7wy/PFZGC0B1/H20lWKN7ONVDNYDZm7A== 277 | 278 | ansi-styles@^3.2.1: 279 | version "3.2.1" 280 | resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" 281 | integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== 282 | dependencies: 283 | color-convert "^1.9.0" 284 | 285 | browserslist@^4.20.2: 286 | version "4.21.3" 287 | resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.3.tgz#5df277694eb3c48bc5c4b05af3e8b7e09c5a6d1a" 288 | integrity sha512-898rgRXLAyRkM1GryrrBHGkqA5hlpkV5MhtZwg9QXeiyLUYs2k00Un05aX5l2/yJIOObYKOpS2JNo8nJDE7fWQ== 289 | dependencies: 290 | caniuse-lite "^1.0.30001370" 291 | electron-to-chromium "^1.4.202" 292 | node-releases "^2.0.6" 293 | update-browserslist-db "^1.0.5" 294 | 295 | caniuse-lite@^1.0.30001370: 296 | version "1.0.30001378" 297 | resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001378.tgz#3d2159bf5a8f9ca093275b0d3ecc717b00f27b67" 298 | integrity sha512-JVQnfoO7FK7WvU4ZkBRbPjaot4+YqxogSDosHv0Hv5mWpUESmN+UubMU6L/hGz8QlQ2aY5U0vR6MOs6j/CXpNA== 299 | 300 | chalk@^2.0.0: 301 | version "2.4.2" 302 | resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" 303 | integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== 304 | dependencies: 305 | ansi-styles "^3.2.1" 306 | escape-string-regexp "^1.0.5" 307 | supports-color "^5.3.0" 308 | 309 | color-convert@^1.9.0: 310 | version "1.9.3" 311 | resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" 312 | integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== 313 | dependencies: 314 | color-name "1.1.3" 315 | 316 | color-name@1.1.3: 317 | version "1.1.3" 318 | resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" 319 | integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== 320 | 321 | convert-source-map@^1.7.0: 322 | version "1.8.0" 323 | resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369" 324 | integrity sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA== 325 | dependencies: 326 | safe-buffer "~5.1.1" 327 | 328 | debug@^4.1.0: 329 | version "4.3.4" 330 | resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" 331 | integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== 332 | dependencies: 333 | ms "2.1.2" 334 | 335 | electron-to-chromium@^1.4.202: 336 | version "1.4.224" 337 | resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.224.tgz#ecf2eed395cfedcbbe634658ccc4b457f7b254c3" 338 | integrity sha512-dOujC5Yzj0nOVE23iD5HKqrRSDj2SD7RazpZS/b/WX85MtO6/LzKDF4TlYZTBteB+7fvSg5JpWh0sN7fImNF8w== 339 | 340 | escalade@^3.1.1: 341 | version "3.1.1" 342 | resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" 343 | integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== 344 | 345 | escape-string-regexp@^1.0.5: 346 | version "1.0.5" 347 | resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" 348 | integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== 349 | 350 | gensync@^1.0.0-beta.2: 351 | version "1.0.0-beta.2" 352 | resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" 353 | integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== 354 | 355 | globals@^11.1.0: 356 | version "11.12.0" 357 | resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" 358 | integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== 359 | 360 | has-flag@^3.0.0: 361 | version "3.0.0" 362 | resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" 363 | integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== 364 | 365 | js-tokens@^4.0.0: 366 | version "4.0.0" 367 | resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" 368 | integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== 369 | 370 | jsesc@^2.5.1: 371 | version "2.5.2" 372 | resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" 373 | integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== 374 | 375 | json5@^2.2.1: 376 | version "2.2.1" 377 | resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.1.tgz#655d50ed1e6f95ad1a3caababd2b0efda10b395c" 378 | integrity sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA== 379 | 380 | ms@2.1.2: 381 | version "2.1.2" 382 | resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" 383 | integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== 384 | 385 | node-releases@^2.0.6: 386 | version "2.0.6" 387 | resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.6.tgz#8a7088c63a55e493845683ebf3c828d8c51c5503" 388 | integrity sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg== 389 | 390 | picocolors@^1.0.0: 391 | version "1.0.0" 392 | resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" 393 | integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== 394 | 395 | playwright-core@1.25.0: 396 | version "1.25.0" 397 | resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.25.0.tgz#54dc867c6c2cc5e4233905e249206a02914d14f1" 398 | integrity sha512-kZ3Jwaf3wlu0GgU0nB8UMQ+mXFTqBIFz9h1svTlNduNKjnbPXFxw7mJanLVjqxHJRn62uBfmgBj93YHidk2N5Q== 399 | 400 | safe-buffer@~5.1.1: 401 | version "5.1.2" 402 | resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" 403 | integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== 404 | 405 | semver@^6.3.0: 406 | version "6.3.0" 407 | resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" 408 | integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== 409 | 410 | supports-color@^5.3.0: 411 | version "5.5.0" 412 | resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" 413 | integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== 414 | dependencies: 415 | has-flag "^3.0.0" 416 | 417 | to-fast-properties@^2.0.0: 418 | version "2.0.0" 419 | resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" 420 | integrity sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog== 421 | 422 | update-browserslist-db@^1.0.5: 423 | version "1.0.5" 424 | resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.5.tgz#be06a5eedd62f107b7c19eb5bcefb194411abf38" 425 | integrity sha512-dteFFpCyvuDdr9S/ff1ISkKt/9YZxKjI9WlRR99c180GaztJtRa/fn18FdxGVKVsnPY7/a/FDN68mcvUmP4U7Q== 426 | dependencies: 427 | escalade "^3.1.1" 428 | picocolors "^1.0.0" 429 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | export default { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | rootDir: "./src", 6 | transform: { 7 | "^.+\\.tsx?$": [ 8 | "ts-jest", 9 | { 10 | isolatedModules: true, 11 | }, 12 | ], 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tobiastengler/create-relay-app", 3 | "version": "0.0.0", 4 | "description": "Easy configuration of Relay for existing projects", 5 | "homepage": "https://github.com/tobias-tengler/create-relay-app#readme", 6 | "license": "MIT", 7 | "author": { 8 | "name": "Tobias Tengler", 9 | "url": "https://github.com/tobias-tengler", 10 | "email": "contact.tobiastengler@gmail.com" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/tobias-tengler/create-relay-app.git" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/tobias-tengler/create-relay-app/issues" 18 | }, 19 | "keywords": [ 20 | "create-relay-app", 21 | "relay", 22 | "relay.js", 23 | "react", 24 | "vite", 25 | "vite.js", 26 | "next", 27 | "next.js", 28 | "cra", 29 | "create react app" 30 | ], 31 | "files": [ 32 | "./dist" 33 | ], 34 | "bin": "./dist/bin.js", 35 | "type": "module", 36 | "scripts": { 37 | "build": "tsc -b", 38 | "start": "tsc --watch", 39 | "test": "jest", 40 | "format": "prettier --write ./src/**/*.ts" 41 | }, 42 | "peerDependencies": {}, 43 | "devDependencies": { 44 | "@types/babel__generator": "^7.6.4", 45 | "@types/babel__traverse": "^7.18.0", 46 | "@types/fs-extra": "^9.0.13", 47 | "@types/inquirer": "^9.0.0", 48 | "@types/jest": "^29.2.4", 49 | "@types/node": "^18.6.5", 50 | "@types/ora": "^3.2.0", 51 | "@types/prettier": "^2.7.0", 52 | "jest": "^29.3.1", 53 | "ts-jest": "^29.0.3", 54 | "typescript": "^4.7.4" 55 | }, 56 | "dependencies": { 57 | "@babel/generator": "^7.18.12", 58 | "@babel/parser": "^7.18.11", 59 | "@babel/traverse": "^7.18.11", 60 | "chalk": "^5.0.1", 61 | "commander": "^9.4.0", 62 | "fs-extra": "^10.1.0", 63 | "inquirer": "^9.1.0", 64 | "ora": "^6.1.2", 65 | "prettier": "^2.7.1" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /showcase.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tobias-tengler/create-relay-app/750ad22d86508942a8c9ba6bd6cb4e99281a427a/showcase.gif -------------------------------------------------------------------------------- /src/arguments/ArgumentBase.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import { Command } from "commander"; 3 | import inquirer from "inquirer"; 4 | import { CliArguments } from "../types.js"; 5 | 6 | type PromptOptions = { 7 | type: "list" | "confirm" | "input"; 8 | choices?: readonly CliArguments[TName][]; 9 | validate?(input: CliArguments[TName]): true | string; 10 | filter?(input: string): CliArguments[TName]; 11 | }; 12 | 13 | export abstract class ArgumentBase { 14 | public abstract readonly name: TName; 15 | public abstract readonly promptMessage: string; 16 | 17 | private _cliArg?: string; 18 | 19 | get cliArg(): string { 20 | return this._cliArg ?? "--" + this.name; 21 | } 22 | 23 | protected set cliArg(value: string) { 24 | this._cliArg = value; 25 | } 26 | 27 | abstract registerCliOption(command: Command): void; 28 | 29 | abstract promptForValue(existingArgs: Partial): Promise; 30 | 31 | abstract getDefaultValue(existingArgs: Partial): Promise; 32 | 33 | abstract isValid(value: CliArguments[TName], existingArgs: Partial): true | string; 34 | 35 | submitWithValue(value: CliArguments[TName]) { 36 | let val = value; 37 | 38 | if (val === null || (typeof val === "string" && !val)) { 39 | val = chalk.italic("empty") as CliArguments[TName]; 40 | } else if (typeof value === "boolean") { 41 | val = (!!value ? "Yes" : "No") as CliArguments[TName]; 42 | } 43 | 44 | console.log(`${chalk.green("?")} ${this.promptMessage} ${chalk.cyan(val)}`); 45 | } 46 | 47 | getInvalidArgError( 48 | value: any, 49 | validValues?: readonly CliArguments[TName][] | CliArguments[TName], 50 | reason?: string 51 | ): Error { 52 | let msg = `Received an invalid value for ${this.cliArg}: \"${value}\".`; 53 | 54 | if (validValues) { 55 | const validValueString: string = 56 | validValues instanceof Array 57 | ? validValues.join(", ") 58 | : typeof validValues === "string" 59 | ? validValues 60 | : validValues.toString(); 61 | 62 | msg += " Valid values are: " + validValueString + "."; 63 | } else if (reason) { 64 | msg += " " + reason; 65 | } 66 | 67 | return new InvalidArgError(msg); 68 | } 69 | 70 | protected getCliFlags(shorthand?: string, argument?: string) { 71 | let flags: string = ""; 72 | 73 | if (shorthand) { 74 | flags += shorthand + ", "; 75 | } 76 | 77 | flags += this.cliArg; 78 | 79 | if (argument) { 80 | flags += " " + argument; 81 | } 82 | 83 | return flags; 84 | } 85 | 86 | protected async showInquirerPrompt( 87 | options: PromptOptions, 88 | existingArgs: Partial 89 | ): Promise { 90 | const defaultValue = await this.getDefaultValue(existingArgs); 91 | 92 | const answer = await inquirer.prompt({ 93 | name: this.name, 94 | message: this.promptMessage, 95 | default: defaultValue, 96 | ...options, 97 | }); 98 | 99 | return answer[this.name]; 100 | } 101 | } 102 | 103 | export function getNormalizedCliString(input?: string): string { 104 | return input?.toLowerCase().trim() || ""; 105 | } 106 | 107 | export class InvalidArgError extends Error {} 108 | -------------------------------------------------------------------------------- /src/arguments/ArgumentHandler.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentBase } from "./ArgumentBase.js"; 2 | import { CliArguments } from "../types.js"; 3 | import { program } from "commander"; 4 | import { Environment } from "../misc/Environment.js"; 5 | 6 | export class ArgumentHandler { 7 | private readonly argumentDefinitions: ArgumentBase[]; 8 | 9 | constructor(argumentDefinitions: ArgumentBase[]) { 10 | this.argumentDefinitions = argumentDefinitions; 11 | } 12 | 13 | async parseArgs(env: Environment): Promise> { 14 | const details = await env.ownPackageJson.getDetails(); 15 | 16 | program.name(details.name).description(details.description).version(details.version, `-v, --version`); 17 | 18 | // Register CLI options. 19 | for (const argumentDefinition of this.argumentDefinitions) { 20 | argumentDefinition.registerCliOption(program); 21 | } 22 | 23 | program 24 | .option("--ignore-git-changes", "do not exit if the current directory has un-commited Git changes") 25 | .option("--skip-install", "skip the install of npm packages (only for testing)") 26 | .option( 27 | `-i, --interactive`, 28 | `display an interactive prompt that allows you to manually input your project's details` 29 | ); 30 | 31 | // Parse CLI options. 32 | await program.parseAsync(); 33 | 34 | const cliArgs = program.opts>(); 35 | 36 | this.validateArgs(cliArgs); 37 | 38 | return cliArgs; 39 | } 40 | 41 | async resolveMissingArgs(parsedArgs: Partial): Promise { 42 | const allArgs: Partial = { ...parsedArgs }; 43 | 44 | for (const argumentDefinition of this.argumentDefinitions) { 45 | const existingValue = parsedArgs[argumentDefinition.name]; 46 | 47 | if (existingValue !== undefined) { 48 | // Value was supplied as CLI argument, we don't need to prompt for it. 49 | argumentDefinition.submitWithValue(existingValue); 50 | continue; 51 | } 52 | 53 | if (parsedArgs.interactive) { 54 | const answer = await argumentDefinition.promptForValue(allArgs); 55 | 56 | // @ts-ignore 57 | allArgs[argumentDefinition.name] = answer; 58 | } else { 59 | // The user does not want to be prompted, so we choose default values. 60 | const defaultValue = await argumentDefinition.getDefaultValue(allArgs); 61 | 62 | argumentDefinition.submitWithValue(defaultValue); 63 | 64 | // @ts-ignore 65 | allArgs[argumentDefinition.name] = defaultValue; 66 | } 67 | } 68 | 69 | this.validateArgs(allArgs); 70 | 71 | return allArgs as CliArguments; 72 | } 73 | 74 | private validateArgs(args: Partial) { 75 | for (const argumentDefinition of this.argumentDefinitions) { 76 | const value = args[argumentDefinition.name]; 77 | 78 | if (value === undefined) { 79 | continue; 80 | } 81 | 82 | const successOrErrorReason = argumentDefinition.isValid(value, args); 83 | 84 | if (successOrErrorReason === true) { 85 | continue; 86 | } 87 | 88 | throw argumentDefinition.getInvalidArgError(value, undefined, successOrErrorReason); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/arguments/ArtifactDirectoryArgument.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "commander"; 2 | import path from "path"; 3 | import { Environment } from "../misc/Environment.js"; 4 | import { Filesystem } from "../misc/Filesystem.js"; 5 | import { CliArguments } from "../types.js"; 6 | import { bold } from "../utils/index.js"; 7 | import { ArgumentBase } from "./ArgumentBase.js"; 8 | 9 | export class ArtifactDirectoryArgument extends ArgumentBase<"artifactDirectory"> { 10 | public name = "artifactDirectory" as const; 11 | public promptMessage = "(Optional) Where to place Relay artifacts"; 12 | 13 | constructor(private fs: Filesystem, private env: Environment) { 14 | super(); 15 | this.cliArg = "--artifact-directory"; 16 | } 17 | 18 | registerCliOption(command: Command): void { 19 | const flags = this.getCliFlags("-a", ""); 20 | 21 | command.option(flags, "directory to place all Relay artifacts in", (value) => this.env.rel(value)?.rel); 22 | } 23 | 24 | promptForValue(existingArgs: Partial): Promise { 25 | return this.showInquirerPrompt( 26 | { 27 | type: "input", 28 | validate: (input) => this.isValid(input, existingArgs), 29 | filter: (input) => (input ? this.env.rel(input)?.rel || "" : ""), 30 | }, 31 | existingArgs 32 | ); 33 | } 34 | 35 | isValid(value: CliArguments["artifactDirectory"], existingArgs: Partial): true | string { 36 | if (!value) { 37 | if (existingArgs.toolchain === "next") { 38 | return "Required"; 39 | } 40 | 41 | // The artifactDirectory is optional. 42 | return true; 43 | } 44 | 45 | if (!this.fs.isDirectory(value)) { 46 | return `Must be a directory`; 47 | } 48 | 49 | if (path.basename(value) !== "__generated__") { 50 | return `Last directory segment should be called ${bold("__generated__")}`; 51 | } 52 | 53 | if (!this.fs.isSubDirectory(this.env.cwd, value)) { 54 | return `Must be directory below ${bold(this.env.cwd)}`; 55 | } 56 | 57 | if (existingArgs.toolchain === "next") { 58 | const pagesDirectory = this.env.rel("./pages"); 59 | 60 | if (this.fs.isSubDirectory(pagesDirectory.abs, value)) { 61 | return `Can not be under ${bold(pagesDirectory.rel)}`; 62 | } 63 | } 64 | 65 | return true; 66 | } 67 | 68 | getDefaultValue(existingArgs: Partial): Promise { 69 | if (existingArgs.toolchain === "next") { 70 | // Artifacts need to be located outside the ./pages directory, 71 | // or they will be treated as pages. 72 | return Promise.resolve("./__generated__"); 73 | } 74 | 75 | return Promise.resolve(""); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/arguments/PackageManagerArgument.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "child_process"; 2 | import { Command } from "commander"; 3 | import { Environment } from "../misc/Environment.js"; 4 | import { Filesystem } from "../misc/Filesystem.js"; 5 | import { getExecutingPackageManager } from "../misc/packageManagers/index.js"; 6 | import { CliArguments, PackageManagerType, PackageManagerOptions } from "../types.js"; 7 | import { ArgumentBase, getNormalizedCliString } from "./ArgumentBase.js"; 8 | 9 | export class PackageManagerArgument extends ArgumentBase<"packageManager"> { 10 | public name = "packageManager" as const; 11 | public promptMessage = "What package manager to install packages with"; 12 | 13 | constructor(private fs: Filesystem, private env: Environment) { 14 | super(); 15 | this.cliArg = "--package-manager"; 16 | } 17 | 18 | registerCliOption(command: Command): void { 19 | const flags = this.getCliFlags("-p", ""); 20 | 21 | command.option(flags, "the package manager to use for installing packages", (value) => 22 | this.parsePackageManager(value) 23 | ); 24 | } 25 | 26 | promptForValue(existingArgs: Partial): Promise { 27 | return this.showInquirerPrompt( 28 | { 29 | type: "list", 30 | choices: PackageManagerOptions, 31 | }, 32 | existingArgs 33 | ); 34 | } 35 | 36 | isValid(value: PackageManagerType, existingArgs: Partial): true | string { 37 | return true; 38 | } 39 | 40 | getDefaultValue(existingArgs: Partial): Promise { 41 | const yarnLockFile = this.env.rel("yarn.lock"); 42 | 43 | if (this.fs.exists(yarnLockFile.abs)) { 44 | try { 45 | execSync("yarn --version", { stdio: "ignore" }); 46 | 47 | // Project has a yarn.lock file and yarn is installed. 48 | return Promise.resolve("yarn"); 49 | } catch {} 50 | } 51 | 52 | const pnpmLockFile = this.env.rel("pnpm-lock.yaml"); 53 | 54 | if (this.fs.exists(pnpmLockFile.abs)) { 55 | try { 56 | execSync("pnpm --version", { stdio: "ignore" }); 57 | 58 | // Project has a pnpm-lock.yaml file and pnpm is installed. 59 | return Promise.resolve("pnpm"); 60 | } catch {} 61 | } 62 | 63 | const executingPackageManager = getExecutingPackageManager(); 64 | 65 | return Promise.resolve(executingPackageManager); 66 | } 67 | 68 | parsePackageManager(rawInput?: string): PackageManagerType | null { 69 | if (!rawInput) { 70 | return null; 71 | } 72 | 73 | const input = getNormalizedCliString(rawInput); 74 | 75 | if (input === "yarn") { 76 | return "yarn"; 77 | } 78 | 79 | if (input === "pnpm") { 80 | return "pnpm"; 81 | } 82 | 83 | if (input === "npm") { 84 | return "npm"; 85 | } 86 | 87 | throw this.getInvalidArgError(input, PackageManagerOptions); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/arguments/SchemaFileArgument.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "commander"; 2 | import path from "path"; 3 | import { NEXT_SRC_PATH } from "../consts.js"; 4 | import { Environment } from "../misc/Environment.js"; 5 | import { Filesystem } from "../misc/Filesystem.js"; 6 | import { CliArguments } from "../types.js"; 7 | import { bold } from "../utils/index.js"; 8 | import { ArgumentBase } from "./ArgumentBase.js"; 9 | 10 | export class SchemaFileArgument extends ArgumentBase<"schemaFile"> { 11 | public name = "schemaFile" as const; 12 | public promptMessage = "Where's your GraphQL schema file"; 13 | 14 | constructor(private fs: Filesystem, private env: Environment) { 15 | super(); 16 | this.cliArg = "--schema-file"; 17 | } 18 | 19 | registerCliOption(command: Command): void { 20 | const flags = this.getCliFlags("-f", ""); 21 | 22 | command.option(flags, "path to a GraphQL schema file", (value) => this.env.rel(value)?.rel); 23 | } 24 | 25 | promptForValue(existingArgs: Partial): Promise { 26 | return this.showInquirerPrompt( 27 | { 28 | type: "input", 29 | validate: (input) => this.isValid(input, existingArgs), 30 | filter: (input) => this.env.rel(input)?.rel || "", 31 | }, 32 | existingArgs 33 | ); 34 | } 35 | 36 | isValid(value: CliArguments["schemaFile"], existingArgs: Partial): true | string { 37 | if (!value) { 38 | return "Required"; 39 | } 40 | 41 | const graphqlExt = ".graphql"; 42 | 43 | const filename = path.basename(value); 44 | 45 | if (!filename.endsWith(graphqlExt)) { 46 | return `File needs to end in ${bold(graphqlExt)}`; 47 | } 48 | 49 | if (!this.fs.isFile(value)) { 50 | return `Must be a file`; 51 | } 52 | 53 | if (!this.fs.isSubDirectory(this.env.cwd, value)) { 54 | return `Must be a file somewhere below ${bold(this.env.cwd)}`; 55 | } 56 | 57 | return true; 58 | } 59 | 60 | getDefaultValue(existingArgs: Partial): Promise { 61 | const filename = "schema.graphql"; 62 | 63 | let srcPath: string = existingArgs.src!; 64 | 65 | if (existingArgs.toolchain === "next") { 66 | srcPath = NEXT_SRC_PATH; 67 | } 68 | 69 | const filepath = path.join(srcPath, filename); 70 | 71 | return Promise.resolve(this.env.rel(filepath).rel); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/arguments/SrcArgument.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "commander"; 2 | import { NEXT_APP_ROOT, APP_ROOT } from "../consts.js"; 3 | import { Environment } from "../misc/Environment.js"; 4 | import { Filesystem } from "../misc/Filesystem.js"; 5 | import { CliArguments } from "../types.js"; 6 | import { bold } from "../utils/index.js"; 7 | import { ArgumentBase } from "./ArgumentBase.js"; 8 | 9 | export class SrcArgument extends ArgumentBase<"src"> { 10 | public name = "src" as const; 11 | public promptMessage = "Where's the root directory of your application code"; 12 | 13 | constructor(private fs: Filesystem, private env: Environment) { 14 | super(); 15 | } 16 | 17 | registerCliOption(command: Command): void { 18 | const flags = this.getCliFlags("-s", ""); 19 | 20 | command.option(flags, "root directory of your application code", (value) => this.env.rel(value)?.rel); 21 | } 22 | 23 | promptForValue(existingArgs: Partial): Promise { 24 | return this.showInquirerPrompt( 25 | { 26 | type: "input", 27 | validate: (input) => this.isValid(input, existingArgs), 28 | filter: (input) => this.env.rel(input)?.rel || "", 29 | }, 30 | existingArgs 31 | ); 32 | } 33 | 34 | isValid(value: CliArguments["src"], existingArgs: Partial): true | string { 35 | if (!value) { 36 | return `Required`; 37 | } 38 | 39 | if (!this.fs.isDirectory(value)) { 40 | return `Must be a directory`; 41 | } 42 | 43 | if (!this.fs.isSubDirectory(this.env.cwd, value)) { 44 | return `Must be directory below ${bold(this.env.cwd)}`; 45 | } 46 | 47 | return true; 48 | } 49 | 50 | getDefaultValue(existingArgs: Partial): Promise { 51 | if (existingArgs.toolchain === "next") { 52 | return Promise.resolve(NEXT_APP_ROOT); 53 | } 54 | 55 | return Promise.resolve(APP_ROOT); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/arguments/SubscriptionsArgument.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "commander"; 2 | import { CliArguments } from "../types.js"; 3 | import { ArgumentBase } from "./ArgumentBase.js"; 4 | 5 | export class SubscriptionsArgument extends ArgumentBase<"subscriptions"> { 6 | public name = "subscriptions" as const; 7 | public promptMessage = "Do you want to setup Subscriptions"; 8 | 9 | registerCliOption(command: Command): void { 10 | const flags = this.getCliFlags(); 11 | 12 | command.option(flags, "setup GraphQL subscriptions using graphql-ws"); 13 | } 14 | 15 | promptForValue(existingArgs: Partial): Promise { 16 | return this.showInquirerPrompt( 17 | { 18 | type: "confirm", 19 | }, 20 | existingArgs 21 | ); 22 | } 23 | 24 | isValid(value: boolean, existingArgs: Partial): true | string { 25 | return true; 26 | } 27 | 28 | async getDefaultValue(existingArgs: Partial): Promise { 29 | return false; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/arguments/ToolchainArgument.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "commander"; 2 | import { Environment } from "../misc/Environment.js"; 3 | import { CliArguments, ToolchainType, ToolchainOptions } from "../types.js"; 4 | import { ArgumentBase, getNormalizedCliString } from "./ArgumentBase.js"; 5 | 6 | export class ToolchainArgument extends ArgumentBase<"toolchain"> { 7 | public name = "toolchain" as const; 8 | public promptMessage = "What's the toolchain of your project"; 9 | 10 | constructor(private env: Environment) { 11 | super(); 12 | } 13 | 14 | registerCliOption(command: Command): void { 15 | const flags = this.getCliFlags("-t", ""); 16 | 17 | command.option(flags, "the toolchain used to bundle / serve the project", (value) => this.parseToolChain(value)); 18 | } 19 | 20 | promptForValue(existingArgs: Partial): Promise { 21 | return this.showInquirerPrompt( 22 | { 23 | type: "list", 24 | choices: ToolchainOptions, 25 | }, 26 | existingArgs 27 | ); 28 | } 29 | 30 | isValid(value: ToolchainType, existingArgs: Partial): true | string { 31 | return true; 32 | } 33 | 34 | async getDefaultValue(existingArgs: Partial): Promise { 35 | if (await this.env.packageJson.containsDependency("next")) { 36 | return "next"; 37 | } 38 | 39 | if (await this.env.packageJson.containsDependency("vite")) { 40 | return "vite"; 41 | } 42 | 43 | return "cra"; 44 | } 45 | 46 | parseToolChain(rawInput?: string): ToolchainType | null { 47 | if (!rawInput) { 48 | return null; 49 | } 50 | 51 | const input = getNormalizedCliString(rawInput); 52 | 53 | if (input === "next") { 54 | return "next"; 55 | } 56 | 57 | if (input === "vite") { 58 | return "vite"; 59 | } 60 | 61 | if (input === "cra") { 62 | return "cra"; 63 | } 64 | 65 | throw this.getInvalidArgError(input, ToolchainOptions); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/arguments/TypeScriptArgument.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "commander"; 2 | import { TS_CONFIG_FILE, TYPESCRIPT_PACKAGE } from "../consts.js"; 3 | import { Environment } from "../misc/Environment.js"; 4 | import { Filesystem } from "../misc/Filesystem.js"; 5 | import { CliArguments } from "../types.js"; 6 | import { ArgumentBase } from "./ArgumentBase.js"; 7 | 8 | export class TypeScriptArgument extends ArgumentBase<"typescript"> { 9 | public name = "typescript" as const; 10 | public promptMessage = "Does your project use TypeScript"; 11 | 12 | constructor(private fs: Filesystem, private env: Environment) { 13 | super(); 14 | } 15 | 16 | registerCliOption(command: Command): void { 17 | const flags = this.getCliFlags(); 18 | 19 | command.option(flags, "use TypeScript"); 20 | } 21 | 22 | promptForValue(existingArgs: Partial): Promise { 23 | return this.showInquirerPrompt( 24 | { 25 | type: "confirm", 26 | }, 27 | existingArgs 28 | ); 29 | } 30 | 31 | isValid(value: boolean, existingArgs: Partial): true | string { 32 | return true; 33 | } 34 | 35 | async getDefaultValue(existingArgs: Partial): Promise { 36 | const tsconfigFile = this.env.rel(TS_CONFIG_FILE); 37 | 38 | if (this.fs.exists(tsconfigFile.abs)) { 39 | return true; 40 | } 41 | 42 | const typescriptInstalled = await this.env.packageJson.containsDependency(TYPESCRIPT_PACKAGE); 43 | 44 | if (typescriptInstalled) { 45 | return true; 46 | } 47 | 48 | return false; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/arguments/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ArgumentHandler.js"; 2 | export * from "./ArtifactDirectoryArgument.js"; 3 | export * from "./PackageManagerArgument.js"; 4 | export * from "./SchemaFileArgument.js"; 5 | export * from "./SrcArgument.js"; 6 | export * from "./ToolchainArgument.js"; 7 | export * from "./TypeScriptArgument.js"; 8 | export * from "./SubscriptionsArgument.js"; 9 | -------------------------------------------------------------------------------- /src/bin.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import path, { dirname } from "path"; 4 | import { exit } from "process"; 5 | import { fileURLToPath } from "url"; 6 | import { InvalidArgError } from "./arguments/ArgumentBase.js"; 7 | import { 8 | ArgumentHandler, 9 | ArtifactDirectoryArgument, 10 | PackageManagerArgument, 11 | SchemaFileArgument, 12 | SrcArgument, 13 | ToolchainArgument, 14 | TypeScriptArgument, 15 | SubscriptionsArgument, 16 | } from "./arguments/index.js"; 17 | import { BABEL_RELAY_MACRO, GITHUB_CODE_URL, PACKAGE_FILE } from "./consts.js"; 18 | import { Filesystem } from "./misc/Filesystem.js"; 19 | import { getPackageManger, getExecutingPackageManager } from "./misc/packageManagers/index.js"; 20 | import { 21 | GenerateArtifactDirectoryTask, 22 | GenerateRelayEnvironmentTask, 23 | GenerateGraphQlSchemaFileTask, 24 | TaskRunner, 25 | ConfigureRelayCompilerTask, 26 | Cra_AddBabelMacroTypeDefinitionsTask, 27 | InstallNpmDependenciesTask, 28 | InstallNpmDevDependenciesTask, 29 | Vite_ConfigureVitePluginRelayTask, 30 | Next_ConfigureNextCompilerTask, 31 | Cra_AddRelayEnvironmentProvider, 32 | Vite_AddRelayEnvironmentProvider, 33 | Next_AddRelayEnvironmentProvider, 34 | ConfigureEolOfArtifactsTask, 35 | HTTP_ENDPOINT, 36 | WEBSOCKET_ENDPOINT, 37 | Next_AddTypeHelpers, 38 | } from "./tasks/index.js"; 39 | import { CliArguments } from "./types.js"; 40 | import { headline, bold, importantHeadline, printError } from "./utils/index.js"; 41 | import { ProjectContext } from "./misc/ProjectContext.js"; 42 | import { Environment, MissingPackageJsonError } from "./misc/Environment.js"; 43 | import { Git } from "./misc/Git.js"; 44 | import { CommandRunner } from "./misc/CommandRunner.js"; 45 | import { AddRelayCompilerScriptsTask } from "./tasks/AddRelayCompilerScriptsTask.js"; 46 | 47 | const fs = new Filesystem(); 48 | const cmdRunner = new CommandRunner(); 49 | 50 | const distDirectory = dirname(fileURLToPath(import.meta.url)); 51 | const ownPackageJsonFilepath = path.join(distDirectory, "..", PACKAGE_FILE); 52 | 53 | const cwd = process.cwd(); 54 | const pacMan = getExecutingPackageManager(); 55 | 56 | const env = new Environment(cwd, ownPackageJsonFilepath, fs); 57 | 58 | // Determine environment information, such as where the package.json 59 | // of the target project lies. 60 | try { 61 | await env.init(); 62 | } catch (error) { 63 | if (error instanceof MissingPackageJsonError) { 64 | printError(`Could not find a ${bold(PACKAGE_FILE)} in the ${bold(cwd)} directory.`); 65 | 66 | console.log(); 67 | console.log(headline("Correct usage")); 68 | console.log(); 69 | 70 | console.log("1. Remember to first scaffold a React project using:"); 71 | console.log(" Next.js: " + bold(pacMan + " create next-app --typescript")); 72 | console.log(" Vite.js: " + bold(pacMan + " create vite --template react-ts")); 73 | console.log(" Create React App: " + bold(pacMan + " create react-app --template typescript")); 74 | console.log(); 75 | console.log("2. Move into the scaffolded directory:"); 76 | console.log(" " + bold("cd ")); 77 | console.log(); 78 | console.log(`3. Run the original command again:`); 79 | console.log(" " + bold(pacMan + " create @tobiastengler/relay-app")); 80 | } else if (error instanceof Error) { 81 | printError("Unexpected error while gathering environment information: " + error.message); 82 | } else { 83 | printError("Unexpected error while gathering environment information"); 84 | } 85 | 86 | exit(1); 87 | } 88 | 89 | // Define all of the possible CLI arguments. 90 | const argumentHandler = new ArgumentHandler([ 91 | new ToolchainArgument(env), 92 | new TypeScriptArgument(fs, env), 93 | new SrcArgument(fs, env), 94 | new SchemaFileArgument(fs, env), 95 | new ArtifactDirectoryArgument(fs, env), 96 | new SubscriptionsArgument(), 97 | new PackageManagerArgument(fs, env), 98 | ]); 99 | 100 | let isGitRepo = false; 101 | let userArgs: CliArguments; 102 | 103 | try { 104 | // Get the arguments provided to the program. 105 | const cliArgs = await argumentHandler.parseArgs(env); 106 | 107 | try { 108 | const git = new Git(); 109 | isGitRepo = await git.isGitRepository(env.cwd); 110 | 111 | if (isGitRepo && !cliArgs.ignoreGitChanges) { 112 | const hasUnsavedChanges = await git.hasUnsavedChanges(env.cwd); 113 | 114 | if (hasUnsavedChanges) { 115 | printError(`Please commit or discard all changes in the ${bold(env.cwd)} directory before continuing.`); 116 | exit(1); 117 | } 118 | } 119 | } 120 | catch { 121 | // We just ignore it if something goes wrong in the git detection. 122 | } 123 | 124 | // Prompt for all of the missing arguments, required to execute the program. 125 | userArgs = await argumentHandler.resolveMissingArgs(cliArgs); 126 | 127 | console.log(); 128 | } catch (error) { 129 | if (error instanceof InvalidArgError) { 130 | printError(error.message); 131 | } else if (error instanceof Error) { 132 | printError("Error while parsing CLI arguments: " + error.message); 133 | } else { 134 | printError("Unexpected error while parsing CLI arguments"); 135 | } 136 | 137 | exit(1); 138 | } 139 | 140 | // Instantiate a package manager, based on the user's choice. 141 | const packageManager = getPackageManger(userArgs.packageManager, cmdRunner, env.cwd); 142 | 143 | // Build a context that contains all of the configuration. 144 | const context = new ProjectContext(env, userArgs, packageManager, fs); 145 | 146 | // Define tasks that should be executed. 147 | const runner = new TaskRunner([ 148 | new InstallNpmDependenciesTask(context), 149 | new InstallNpmDevDependenciesTask(context), 150 | new ConfigureRelayCompilerTask(context), 151 | new AddRelayCompilerScriptsTask(context), 152 | new GenerateRelayEnvironmentTask(context), 153 | new GenerateGraphQlSchemaFileTask(context), 154 | new GenerateArtifactDirectoryTask(context), 155 | new ConfigureEolOfArtifactsTask(context, isGitRepo), 156 | new Cra_AddBabelMacroTypeDefinitionsTask(context), 157 | new Cra_AddRelayEnvironmentProvider(context), 158 | new Vite_ConfigureVitePluginRelayTask(context), 159 | new Vite_AddRelayEnvironmentProvider(context), 160 | new Next_ConfigureNextCompilerTask(context), 161 | new Next_AddTypeHelpers(context), 162 | new Next_AddRelayEnvironmentProvider(context), 163 | ]); 164 | 165 | // Execute all of the tasks sequentially. 166 | try { 167 | await runner.run(); 168 | } catch { 169 | // The error should've already been correctly handled by the runner, 170 | // we just exit the program here. 171 | 172 | console.log(); 173 | printError("Some of the tasks failed unexpectedly."); 174 | exit(1); 175 | } 176 | 177 | console.log(); 178 | console.log(); 179 | 180 | // Display a guide to the user on how to continue setting up his project. 181 | console.log(headline("Next steps")); 182 | console.log(); 183 | 184 | console.log(`1. Replace ${bold(context.schemaPath.rel)} with your own GraphQL schema file.`); 185 | 186 | const endpoints = bold(HTTP_ENDPOINT) + (!context.args.subscriptions ? "" : " / " + bold(WEBSOCKET_ENDPOINT)); 187 | console.log(`2. Replace the value of the ${endpoints} variable in the ${bold(context.relayEnvFile.rel)} file.`); 188 | 189 | const artifactFileExt = "*" + context.artifactExtension; 190 | 191 | console.log(`3. Ignore ${bold(artifactFileExt)} files in your linter / formatter configuration.`); 192 | 193 | // Create React app comes with some annoyances, so we warn the user about it, 194 | // and provide possible solutions that can be manually implemented. 195 | if (context.is("cra")) { 196 | console.log(); 197 | console.log(importantHeadline("Important")); 198 | console.log(); 199 | console.log(`Remember you need to import ${bold("graphql")} like the following:`); 200 | console.log(" " + bold(`import graphql from \"${BABEL_RELAY_MACRO}\";`)); 201 | console.log(); 202 | console.log(`Otherwise the transform of the ${bold("graphql``")} tagged literal will not work!`); 203 | console.log("If you do not want to use the macro, you can check out the following document for guidance:"); 204 | console.log(GITHUB_CODE_URL + "/docs/cra-babel-setup.md"); 205 | } 206 | 207 | if (context.is("next")) { 208 | console.log(); 209 | console.log(importantHeadline("Important")); 210 | console.log(); 211 | console.log(`Follow this guide, if you want to fetch data on the server instead of on the client:`); 212 | console.log(GITHUB_CODE_URL + "/docs/next-data-fetching.md"); 213 | } 214 | 215 | console.log(); 216 | -------------------------------------------------------------------------------- /src/consts.ts: -------------------------------------------------------------------------------- 1 | export const TS_CONFIG_FILE = "tsconfig.json"; 2 | export const PACKAGE_FILE = "package.json"; 3 | export const APP_ROOT = "./src"; 4 | export const NEXT_APP_ROOT = "./"; 5 | export const NEXT_SRC_PATH = "./src"; 6 | 7 | export const TYPESCRIPT_PACKAGE = "typescript"; 8 | export const BABEL_RELAY_PACKAGE = "babel-plugin-relay"; 9 | export const BABEL_RELAY_MACRO = BABEL_RELAY_PACKAGE + "/macro"; 10 | export const REACT_RELAY_PACKAGE = "react-relay"; 11 | export const RELAY_RUNTIME_PACKAGE = "relay-runtime"; 12 | export const GRAPHQL_WS_PACKAGE = "graphql-ws"; 13 | export const VITE_RELAY_PACKAGE = "vite-plugin-relay"; 14 | 15 | export const RELAY_ENV_PROVIDER = "RelayEnvironmentProvider"; 16 | export const RELAY_ENV = "RelayEnvironment"; 17 | 18 | export const GITHUB_CODE_URL = "https://github.com/tobias-tengler/create-relay-app/blob/main"; 19 | -------------------------------------------------------------------------------- /src/misc/CommandRunner.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from "child_process"; 2 | import { EOL } from "os"; 3 | 4 | export class CommandRunner { 5 | run(command: string, args: string[], cwd?: string) { 6 | return new Promise((resolve, reject) => { 7 | const child = spawn(command, args, { 8 | cwd: cwd, 9 | shell: true, 10 | }); 11 | 12 | let errorMsg: string = ""; 13 | 14 | child.stderr.setEncoding("utf-8"); 15 | 16 | child.stderr.on("data", (data) => { 17 | errorMsg += data; 18 | }); 19 | 20 | child.on("close", (code) => { 21 | if (code !== 0) { 22 | let output = `Command \"${command} ${args.join(" ")}\" failed`; 23 | 24 | if (!!errorMsg) { 25 | output += EOL + EOL + " " + errorMsg.split(EOL).join(EOL + " "); 26 | } 27 | 28 | reject(output); 29 | return; 30 | } 31 | 32 | resolve(); 33 | }); 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/misc/Environment.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { PACKAGE_FILE } from "../consts.js"; 3 | import { Filesystem } from "./Filesystem.js"; 4 | import { PackageJsonFile } from "./PackageJsonFile.js"; 5 | import { RelativePath } from "./RelativePath.js"; 6 | 7 | export class Environment { 8 | constructor(public readonly cwd: string, ownPackageJsonFilepath: string, private readonly fs: Filesystem) { 9 | this.ownPackageDirectory = fs.getParent(ownPackageJsonFilepath); 10 | this.ownPackageJson = new PackageJsonFile(ownPackageJsonFilepath, this.fs); 11 | } 12 | 13 | async init(): Promise { 14 | const packageJsonFilepath = path.join(this.cwd, PACKAGE_FILE); 15 | 16 | if (!this.fs.exists(packageJsonFilepath)) { 17 | throw new MissingPackageJsonError(); 18 | } 19 | 20 | this.packageJson = new PackageJsonFile(packageJsonFilepath, this.fs); 21 | } 22 | 23 | rel(relPath: string | undefined): RelativePath { 24 | return new RelativePath(this.cwd, relPath); 25 | } 26 | 27 | ownPackageDirectory: string; 28 | ownPackageJson: PackageJsonFile; 29 | packageJson: PackageJsonFile = null!; 30 | } 31 | 32 | export class MissingPackageJsonError extends Error { } 33 | -------------------------------------------------------------------------------- /src/misc/Filesystem.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import fs from "fs/promises"; 3 | import { existsSync, lstatSync } from "fs"; 4 | import fsExtra from "fs-extra"; 5 | 6 | export class Filesystem { 7 | getParent(filepath: string): string { 8 | return path.dirname(filepath); 9 | } 10 | 11 | isDirectory(directoryPath: string): boolean { 12 | if (!this.exists(directoryPath)) { 13 | // If the path does not exist, we check that it doesn't 14 | // have a file extension to determine whether it's a 15 | // directory path. 16 | const ext = path.extname(directoryPath); 17 | 18 | return !ext; 19 | } 20 | 21 | return lstatSync(directoryPath).isDirectory(); 22 | } 23 | 24 | isFile(filePath: string): boolean { 25 | if (!this.exists(filePath)) { 26 | // If the path does not exist, we check if it has a 27 | // file extension to determine whether it's a file or not. 28 | const ext = path.extname(filePath); 29 | 30 | return !!ext; 31 | } 32 | 33 | return lstatSync(filePath).isFile(); 34 | } 35 | 36 | isSubDirectory(parent: string, dir: string): boolean { 37 | const relative = path.relative(parent, dir); 38 | 39 | return !relative.startsWith("..") && !path.isAbsolute(relative); 40 | } 41 | 42 | copyFile(src: string, dest: string): Promise { 43 | return fs.copyFile(src, dest); 44 | } 45 | 46 | exists(filepath: string): boolean { 47 | return existsSync(filepath); 48 | } 49 | 50 | async readFromFile(filepath: string): Promise { 51 | try { 52 | return await fs.readFile(filepath, "utf-8"); 53 | } catch (error) { 54 | throw new ReadFromFileError(filepath, error); 55 | } 56 | } 57 | 58 | async writeToFile(filepath: string, content: string): Promise { 59 | try { 60 | await fs.writeFile(filepath, content, "utf-8"); 61 | } catch (error) { 62 | throw new WriteToFileError(filepath, error); 63 | } 64 | } 65 | 66 | async appendToFile(filepath: string, content: string): Promise { 67 | try { 68 | await fs.appendFile(filepath, content, "utf-8"); 69 | } catch (error) { 70 | throw new AppendToFileError(filepath, error); 71 | } 72 | } 73 | 74 | async createDirectory(directoryPath: string): Promise { 75 | try { 76 | await fsExtra.mkdir(directoryPath, { recursive: true }); 77 | } catch (error) { 78 | throw new CreateDirectoryError(directoryPath, error); 79 | } 80 | } 81 | } 82 | 83 | class CreateDirectoryError extends Error { 84 | constructor(path: string, cause: any) { 85 | super(`Failed to create directory: ${path}`, { cause }); 86 | } 87 | } 88 | 89 | class WriteToFileError extends Error { 90 | constructor(path: string, cause: any) { 91 | super(`Failed to write to file: ${path}`, { cause }); 92 | } 93 | } 94 | 95 | class AppendToFileError extends Error { 96 | constructor(path: string, cause: any) { 97 | super(`Failed to append to file: ${path}`, { cause }); 98 | } 99 | } 100 | 101 | class ReadFromFileError extends Error { 102 | constructor(path: string, cause: any) { 103 | super(`Failed to read from file: ${path}`, { cause }); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/misc/Git.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "child_process"; 2 | 3 | export class Git { 4 | async isGitRepository(directory: string): Promise { 5 | return await new Promise((resolve) => { 6 | exec("git rev-parse --is-inside-work-tree", { cwd: directory }, (error) => { 7 | resolve(!error); 8 | }); 9 | }); 10 | } 11 | 12 | async hasUnsavedChanges(directory: string): Promise { 13 | const hasUnsavedChanges = await new Promise((resolve) => { 14 | exec("git status --porcelain", { cwd: directory }, (error, stdout) => { 15 | resolve(!!error || !!stdout); 16 | }); 17 | }); 18 | 19 | return hasUnsavedChanges; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/misc/PackageJsonFile.ts: -------------------------------------------------------------------------------- 1 | import { Filesystem } from "./Filesystem.js"; 2 | 3 | type PackageDetails = Readonly<{ 4 | name: string; 5 | version: string; 6 | description: string; 7 | }>; 8 | 9 | export class PackageJsonFile { 10 | constructor(private filepath: string, private fs: Filesystem) {} 11 | 12 | async parse(): Promise> { 13 | const packageJsonContent = await this.fs.readFromFile(this.filepath); 14 | 15 | const packageJson: Record = JSON.parse(packageJsonContent); 16 | 17 | return packageJson; 18 | } 19 | 20 | async persist(content: Record): Promise { 21 | const serializedPackageJson = JSON.stringify(content, null, 2); 22 | 23 | await this.fs.writeToFile(this.filepath, serializedPackageJson); 24 | } 25 | 26 | async getDetails(): Promise { 27 | const packageJson = await this.parse(); 28 | 29 | const name = packageJson?.name; 30 | 31 | if (!name) { 32 | throw new Error(`Could not determine name in ${this.filepath}`); 33 | } 34 | 35 | const version = packageJson?.version; 36 | 37 | if (!version) { 38 | throw new Error(`Could not determine version in ${this.filepath}`); 39 | } 40 | 41 | const description = packageJson?.description; 42 | 43 | if (!description) { 44 | throw new Error(`Could not determine description in ${this.filepath}`); 45 | } 46 | 47 | return { name, version, description }; 48 | } 49 | 50 | async containsDependency(packageName: string): Promise { 51 | try { 52 | const content = await this.parse(); 53 | 54 | const dependencies: Record = content["dependencies"] ?? {}; 55 | const devDpendencies: Record = content["devDependencies"] ?? {}; 56 | 57 | const installedPackages = Object.keys({ 58 | ...dependencies, 59 | ...devDpendencies, 60 | }); 61 | 62 | return installedPackages.includes(packageName); 63 | } catch { 64 | return false; 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/misc/ProjectContext.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { NEXT_SRC_PATH, PACKAGE_FILE, RELAY_ENV } from "../consts.js"; 3 | import { CliArguments, RelayCompilerLanguage, ToolchainType } from "../types.js"; 4 | import { Environment } from "./Environment.js"; 5 | import { Filesystem } from "./Filesystem.js"; 6 | import { PackageManager } from "./packageManagers/PackageManager.js"; 7 | import { RelativePath } from "./RelativePath.js"; 8 | 9 | export class ProjectContext { 10 | constructor(public env: Environment, args: CliArguments, public manager: PackageManager, public fs: Filesystem) { 11 | this.args = args; 12 | 13 | this.schemaPath = this.env.rel(args.schemaFile); 14 | this.srcPath = this.env.rel(args.src); 15 | 16 | if (args.artifactDirectory) { 17 | this.artifactPath = this.env.rel(args.artifactDirectory); 18 | } else { 19 | this.artifactPath = null; 20 | } 21 | 22 | this.compilerLanguage = getRelayCompilerLanguage(args.typescript, args.toolchain); 23 | this.relayEnvFile = getRelayEnvFilepath(env, args); 24 | } 25 | 26 | args: Omit; 27 | 28 | schemaPath: RelativePath; 29 | srcPath: RelativePath; 30 | artifactPath: RelativePath | null; 31 | compilerLanguage: RelayCompilerLanguage; 32 | 33 | relayEnvFile: RelativePath; 34 | 35 | get gitAttributesFile() { 36 | return this.env.rel(".gitattributes"); 37 | } 38 | 39 | get relayConfigFile() { 40 | return this.env.rel("relay.config.json"); 41 | } 42 | 43 | get ownPackageJsonFile() { 44 | return this.env.rel(PACKAGE_FILE); 45 | } 46 | 47 | get artifactExtension() { 48 | if (this.args.typescript) { 49 | return ".graphql.ts"; 50 | } else { 51 | return ".graphql.js"; 52 | } 53 | } 54 | 55 | is(toolchain: ToolchainType): boolean { 56 | return this.args.toolchain === toolchain; 57 | } 58 | } 59 | 60 | function getRelayCompilerLanguage(useTypeScript: boolean, toolchain: ToolchainType): RelayCompilerLanguage { 61 | if ( 62 | useTypeScript || 63 | // Next does not support 'javascript' as an option, 64 | // only typescript or flow. So we opt for typescript 65 | // since it's more wide spread. 66 | toolchain === "next" 67 | ) { 68 | return "typescript"; 69 | } else { 70 | return "javascript"; 71 | } 72 | } 73 | 74 | function getRelayEnvFilepath(env: Environment, args: CliArguments): RelativePath { 75 | const filename = RELAY_ENV + (args.typescript ? ".ts" : ".js"); 76 | 77 | let srcDirectory = args.src; 78 | 79 | // The src directory for next is likely the project root, 80 | // so we always default to the ./src directory to place the 81 | // RelayEnvironment file in. 82 | if (args.toolchain === "next") { 83 | srcDirectory = NEXT_SRC_PATH; 84 | } 85 | 86 | const filepath = path.join(srcDirectory, filename); 87 | 88 | return env.rel(filepath); 89 | } 90 | -------------------------------------------------------------------------------- /src/misc/RelativePath.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | 3 | export class RelativePath { 4 | private readonly root: string; 5 | readonly abs: string; 6 | readonly rel: string; 7 | 8 | constructor(root: string, rel?: string) { 9 | this.root = root; 10 | 11 | if (rel) { 12 | if (path.isAbsolute(rel)) { 13 | this.abs = rel; 14 | } else { 15 | this.abs = path.join(this.root, rel); 16 | } 17 | } else { 18 | this.abs = root; 19 | } 20 | 21 | this.rel = prettifyPath(path.relative(this.root, this.abs)); 22 | } 23 | 24 | get parentDirectory(): string { 25 | return path.dirname(this.abs); 26 | } 27 | 28 | get name(): string { 29 | return path.basename(this.abs); 30 | } 31 | 32 | toString(): string { 33 | return this.rel; 34 | } 35 | } 36 | 37 | function prettifyPath(input: string): string { 38 | let normalizedPath = normalizePath(input); 39 | 40 | if (!normalizedPath.startsWith("..") && !normalizedPath.startsWith("./")) { 41 | normalizedPath = "./" + normalizedPath; 42 | } 43 | 44 | return normalizedPath; 45 | } 46 | 47 | function normalizePath(input: string): string { 48 | return input.split(path.sep).join("/"); 49 | } 50 | -------------------------------------------------------------------------------- /src/misc/packageManagers/NpmPackageManager.ts: -------------------------------------------------------------------------------- 1 | import { CommandRunner } from "../CommandRunner.js"; 2 | import { PackageManager } from "./PackageManager.js"; 3 | 4 | export class NpmPackageManager implements PackageManager { 5 | id = "npm" as const; 6 | 7 | constructor(private readonly cwd: string, private cmdRunner: CommandRunner) {} 8 | 9 | addDependency(packages: string | string[]): Promise { 10 | return this.installDependency(packages, false); 11 | } 12 | 13 | addDevDependency(packages: string | string[]): Promise { 14 | return this.installDependency(packages, true); 15 | } 16 | 17 | private installDependency(packages: string | string[], isDevDependency: boolean) { 18 | const args = ["install", isDevDependency ? "--save-dev" : "--save", "--legacy-peer-deps"]; 19 | 20 | if (typeof packages === "string") { 21 | args.push(packages); 22 | } else { 23 | args.push(...packages); 24 | } 25 | 26 | return this.cmdRunner.run("npm", args, this.cwd); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/misc/packageManagers/PackageManager.ts: -------------------------------------------------------------------------------- 1 | import { PackageManagerType } from "../../types.js"; 2 | 3 | export interface PackageManager { 4 | readonly id: PackageManagerType; 5 | 6 | addDependency(packages: string[] | string): Promise; 7 | 8 | addDevDependency(packages: string[] | string): Promise; 9 | } 10 | -------------------------------------------------------------------------------- /src/misc/packageManagers/PnpmPackageManager.ts: -------------------------------------------------------------------------------- 1 | import { CommandRunner } from "../CommandRunner.js"; 2 | import { PackageManager } from "./PackageManager.js"; 3 | 4 | export class PnpmPackageManager implements PackageManager { 5 | id = "pnpm" as const; 6 | 7 | constructor(private readonly cwd: string, private cmdRunner: CommandRunner) {} 8 | 9 | addDependency(packages: string | string[]): Promise { 10 | return this.installDependency(packages, false); 11 | } 12 | 13 | addDevDependency(packages: string | string[]): Promise { 14 | return this.installDependency(packages, true); 15 | } 16 | 17 | private installDependency(packages: string | string[], isDevDependency: boolean) { 18 | const args = ["install", "--save-exact", isDevDependency ? "--save-dev" : "--save"]; 19 | 20 | if (typeof packages === "string") { 21 | args.push(packages); 22 | } else { 23 | args.push(...packages); 24 | } 25 | 26 | return this.cmdRunner.run("pnpm", args, this.cwd); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/misc/packageManagers/YarnPackageManager.ts: -------------------------------------------------------------------------------- 1 | import { CommandRunner } from "../CommandRunner.js"; 2 | import { PackageManager } from "./PackageManager.js"; 3 | 4 | export class YarnPackageManager implements PackageManager { 5 | id = "yarn" as const; 6 | 7 | constructor(private readonly cwd: string, private cmdRunner: CommandRunner) {} 8 | 9 | addDependency(packages: string | string[]): Promise { 10 | return this.installDependency(packages, false); 11 | } 12 | 13 | addDevDependency(packages: string | string[]): Promise { 14 | return this.installDependency(packages, true); 15 | } 16 | 17 | private installDependency(packages: string | string[], isDevDependency: boolean) { 18 | const args = ["add", "--exact", "--cwd", this.cwd]; 19 | 20 | if (isDevDependency) { 21 | args.push("--dev"); 22 | } 23 | 24 | if (typeof packages === "string") { 25 | args.push(packages); 26 | } else { 27 | args.push(...packages); 28 | } 29 | 30 | return this.cmdRunner.run("yarn", args, this.cwd); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/misc/packageManagers/index.ts: -------------------------------------------------------------------------------- 1 | import { PackageManagerType } from "../../types.js"; 2 | import { CommandRunner } from "../CommandRunner.js"; 3 | import { NpmPackageManager } from "./NpmPackageManager.js"; 4 | import { PackageManager } from "./PackageManager.js"; 5 | import { PnpmPackageManager } from "./PnpmPackageManager.js"; 6 | import { YarnPackageManager } from "./YarnPackageManager.js"; 7 | 8 | export type { PackageManager } from "./PackageManager.js"; 9 | 10 | export function getPackageManger(type: PackageManagerType, cmdRunner: CommandRunner, cwd: string): PackageManager { 11 | switch (type) { 12 | case "npm": 13 | return new NpmPackageManager(cwd, cmdRunner); 14 | case "yarn": 15 | return new YarnPackageManager(cwd, cmdRunner); 16 | case "pnpm": 17 | return new PnpmPackageManager(cwd, cmdRunner); 18 | } 19 | } 20 | 21 | export function getExecutingPackageManager(): PackageManagerType { 22 | const userAgent = process.env.npm_config_user_agent; 23 | 24 | if (userAgent) { 25 | if (userAgent.startsWith("yarn")) { 26 | return "yarn"; 27 | } else if (userAgent.startsWith("pnpm")) { 28 | return "pnpm"; 29 | } 30 | } 31 | 32 | return "npm"; 33 | } 34 | -------------------------------------------------------------------------------- /src/tasks/AddRelayCompilerScriptsTask.ts: -------------------------------------------------------------------------------- 1 | import { TaskBase } from "./TaskBase.js"; 2 | import { PACKAGE_FILE } from "../consts.js"; 3 | import { bold } from "../utils/cli.js"; 4 | import { ProjectContext } from "../misc/ProjectContext.js"; 5 | import { execSync } from "child_process"; 6 | 7 | const validateRelayArtifactsScript = "relay-compiler --validate"; 8 | 9 | export class AddRelayCompilerScriptsTask extends TaskBase { 10 | message: string = `Add ${bold("relay-compiler")} scripts`; 11 | 12 | constructor(private context: ProjectContext) { 13 | super(); 14 | } 15 | 16 | isEnabled(): boolean { 17 | return true; 18 | } 19 | 20 | async run(): Promise { 21 | this.updateMessage(this.message + ` in ${bold(this.context.ownPackageJsonFile.rel)}`) 22 | 23 | const packageJson = await this.context.env.packageJson.parse(); 24 | 25 | const scriptsSection: Record = packageJson["scripts"] ?? {}; 26 | 27 | if (!scriptsSection["relay"]) { 28 | const watchmanInstalled = isWatchmanInstalled(); 29 | 30 | // Add "relay" script 31 | scriptsSection["relay"] = "relay-compiler"; 32 | 33 | if (watchmanInstalled) { 34 | scriptsSection["relay"] += " --watch"; 35 | } 36 | } 37 | 38 | const buildScript = scriptsSection["build"]; 39 | 40 | if (buildScript && typeof buildScript === "string" && !buildScript.includes(validateRelayArtifactsScript)) { 41 | // Validate Relay's artifacts as the first build step. 42 | scriptsSection["build"] = validateRelayArtifactsScript + " && " + buildScript; 43 | } 44 | 45 | this.context.env.packageJson.persist(packageJson); 46 | } 47 | } 48 | 49 | function isWatchmanInstalled() { 50 | try { 51 | execSync("watchman", { stdio: "ignore" }); 52 | 53 | return true 54 | } catch { 55 | return false 56 | } 57 | } -------------------------------------------------------------------------------- /src/tasks/ConfigureEolOfArtifactsTask.ts: -------------------------------------------------------------------------------- 1 | import { EOL } from "os"; 2 | import { ProjectContext } from "../misc/ProjectContext.js"; 3 | import { bold } from "../utils/cli.js"; 4 | import { TaskBase } from "./TaskBase.js"; 5 | 6 | export class ConfigureEolOfArtifactsTask extends TaskBase { 7 | message: string = "Configure Relay artifact EOL in .gitattributes"; 8 | 9 | constructor(private context: ProjectContext, private isGitRepo: boolean) { 10 | super(); 11 | } 12 | 13 | isEnabled(): boolean { 14 | return this.isGitRepo; 15 | } 16 | 17 | async run(): Promise { 18 | const gitAttributesPath = this.context.gitAttributesFile; 19 | 20 | this.updateMessage(this.message + " in " + bold(gitAttributesPath.rel)); 21 | 22 | const gitAttributesExpression = `*${this.context.artifactExtension} auto eol=lf`; 23 | 24 | if (!this.context.fs.exists(gitAttributesPath.abs)) { 25 | // .gitattributes does not exist - we create it. 26 | 27 | this.context.fs.writeToFile(gitAttributesPath.abs, gitAttributesExpression + EOL); 28 | } else { 29 | // .gitattributes exist - we check if our expression exists. 30 | const gitAttributesContent = await this.context.fs.readFromFile(gitAttributesPath.abs); 31 | 32 | if (gitAttributesContent.includes(gitAttributesExpression)) { 33 | this.skip("Already configured"); 34 | return; 35 | } 36 | 37 | // The expression is not part of the file - we add it. 38 | 39 | await this.context.fs.appendToFile(gitAttributesPath.abs, EOL + gitAttributesExpression + EOL); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/tasks/ConfigureRelayCompilerTask.ts: -------------------------------------------------------------------------------- 1 | import { TaskBase } from "./TaskBase.js"; 2 | import { bold } from "../utils/cli.js"; 3 | import { ProjectContext } from "../misc/ProjectContext.js"; 4 | import { RelayCompilerLanguage } from "../types.js"; 5 | 6 | type RelayCompilerConfig = { 7 | src: string; 8 | language: RelayCompilerLanguage; 9 | schema: string; 10 | excludes: string[]; 11 | eagerEsModules?: boolean; 12 | artifactDirectory?: string; 13 | }; 14 | 15 | export class ConfigureRelayCompilerTask extends TaskBase { 16 | message: string = `Configure ${bold("relay-compiler")}`; 17 | 18 | constructor(private context: ProjectContext) { 19 | super(); 20 | } 21 | 22 | isEnabled(): boolean { 23 | return true; 24 | } 25 | 26 | async run(): Promise { 27 | this.updateMessage(this.message + ` in ${bold(this.context.relayConfigFile.rel)}`) 28 | 29 | let relayConfig: Partial; 30 | 31 | try { 32 | const relayConfigContent = await this.context.fs.readFromFile(this.context.relayConfigFile.abs); 33 | 34 | relayConfig = JSON.parse(relayConfigContent) || {} 35 | } 36 | catch { 37 | relayConfig = {}; 38 | } 39 | 40 | relayConfig["src"] = this.context.srcPath.rel; 41 | relayConfig["language"] = this.context.compilerLanguage; 42 | relayConfig["schema"] = this.context.schemaPath.rel; 43 | 44 | if (!relayConfig["excludes"]) { 45 | // We only want to add this, if the user hasn't already specified it. 46 | relayConfig["excludes"] = ["**/node_modules/**", "**/__mocks__/**", "**/__generated__/**"]; 47 | } 48 | 49 | if (this.context.is("vite")) { 50 | // When generating without eagerEsModules artifacts contain 51 | // module.exports, which Vite can not handle correctly. 52 | // eagerEsModules will output export default. 53 | relayConfig["eagerEsModules"] = true; 54 | } 55 | 56 | if (this.context.artifactPath) { 57 | relayConfig["artifactDirectory"] = this.context.artifactPath.rel; 58 | } 59 | 60 | const relayConfigWriteContent = JSON.stringify(relayConfig, null, 2); 61 | 62 | await this.context.fs.writeToFile(this.context.relayConfigFile.abs, relayConfigWriteContent) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/tasks/GenerateArtifactDirectoryTask.ts: -------------------------------------------------------------------------------- 1 | import { TaskBase } from "./TaskBase.js"; 2 | import { bold } from "../utils/index.js"; 3 | import { ProjectContext } from "../misc/ProjectContext.js"; 4 | 5 | export class GenerateArtifactDirectoryTask extends TaskBase { 6 | message: string = "Generate artifact directory"; 7 | 8 | constructor(private context: ProjectContext) { 9 | super(); 10 | } 11 | 12 | isEnabled(): boolean { 13 | return !!this.context.artifactPath; 14 | } 15 | 16 | async run(): Promise { 17 | if (!this.context.artifactPath) { 18 | return; 19 | } 20 | 21 | this.updateMessage(this.message + " " + bold(this.context.artifactPath.rel)); 22 | 23 | if (this.context.fs.exists(this.context.artifactPath.abs)) { 24 | this.skip("Directory exists"); 25 | return; 26 | } 27 | 28 | await this.context.fs.createDirectory(this.context.artifactPath.abs); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/tasks/GenerateGraphQlSchemaFileTask.ts: -------------------------------------------------------------------------------- 1 | import { TaskBase } from "./TaskBase.js"; 2 | import { bold } from "../utils/index.js"; 3 | import { ProjectContext } from "../misc/ProjectContext.js"; 4 | 5 | const schemaGraphQLContent = `# Replace this with your own GraphQL schema file! 6 | type Query { 7 | field: String 8 | }`; 9 | 10 | export class GenerateGraphQlSchemaFileTask extends TaskBase { 11 | message: string = "Generate GraphQL schema file"; 12 | 13 | constructor(private context: ProjectContext) { 14 | super(); 15 | } 16 | 17 | isEnabled(): boolean { 18 | return true; 19 | } 20 | 21 | async run(): Promise { 22 | this.updateMessage(this.message + " " + bold(this.context.schemaPath.rel)); 23 | 24 | if (this.context.fs.exists(this.context.schemaPath.abs)) { 25 | this.skip("File exists"); 26 | return; 27 | } 28 | 29 | await this.context.fs.createDirectory(this.context.schemaPath.parentDirectory); 30 | 31 | await this.context.fs.writeToFile(this.context.schemaPath.abs, schemaGraphQLContent); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/tasks/GenerateRelayEnvironmentTask.ts: -------------------------------------------------------------------------------- 1 | import { TaskBase } from "./TaskBase.js"; 2 | import { bold, prettifyCode } from "../utils/index.js"; 3 | import { ProjectContext } from "../misc/ProjectContext.js"; 4 | import { EOL } from "os"; 5 | 6 | export const HTTP_ENDPOINT = "HTTP_ENDPOINT"; 7 | export const WEBSOCKET_ENDPOINT = "WEBSOCKET_ENDPOINT"; 8 | 9 | export class GenerateRelayEnvironmentTask extends TaskBase { 10 | message: string = "Generate Relay environment"; 11 | 12 | constructor(private context: ProjectContext) { 13 | super(); 14 | } 15 | 16 | isEnabled(): boolean { 17 | return true; 18 | } 19 | 20 | async run(): Promise { 21 | await this.addRelayEnvironmentFile(); 22 | } 23 | 24 | // todo: this could maybe be simplified by also using the AST. 25 | // this would also enable us to update an existing configuration. 26 | private async addRelayEnvironmentFile() { 27 | this.updateMessage(this.message + " " + bold(this.context.relayEnvFile.rel)); 28 | 29 | if (this.context.fs.exists(this.context.relayEnvFile.abs)) { 30 | this.skip("File exists"); 31 | return; 32 | } 33 | 34 | const b = new CodeBuilder(); 35 | 36 | // Add imports 37 | const relayRuntimeImports: string[] = ["Environment", "Network", "RecordSource", "Store"]; 38 | 39 | if (this.context.args.subscriptions) { 40 | relayRuntimeImports.push("Observable"); 41 | } 42 | 43 | if (this.context.args.typescript) { 44 | relayRuntimeImports.push("FetchFunction"); 45 | 46 | if (this.context.args.subscriptions) { 47 | relayRuntimeImports.push("SubscribeFunction"); 48 | } 49 | } 50 | 51 | // prettier-ignore 52 | b.addLine(`import { ${relayRuntimeImports.join(", ")} } from "relay-runtime";`) 53 | 54 | if (this.context.args.subscriptions) { 55 | b.addLine(`import { createClient } from "graphql-ws";`); 56 | } 57 | 58 | b.addLine(); 59 | 60 | // Add configurations 61 | b.addLine(`const ${HTTP_ENDPOINT} = "http://localhost:5000/graphql";`); 62 | 63 | if (this.context.args.subscriptions) { 64 | b.addLine(`const ${WEBSOCKET_ENDPOINT} = "ws://localhost:5000/graphql";`); 65 | } 66 | 67 | b.addLine(); 68 | 69 | // Add fetchFn 70 | let fetchFn = `const fetchFn: FetchFunction = async (request, variables) => { 71 | const resp = await fetch(${HTTP_ENDPOINT}, { 72 | method: "POST", 73 | headers: { 74 | Accept: "application/graphql-response+json; charset=utf-8, application/json; charset=utf-8", 75 | "Content-Type": "application/json", 76 | // <-- Additional headers like 'Authorization' would go here 77 | }, 78 | body: JSON.stringify({ 79 | query: request.text, // <-- The GraphQL document composed by Relay 80 | variables, 81 | }), 82 | }); 83 | 84 | return await resp.json(); 85 | };`; 86 | 87 | if (!this.context.args.typescript) { 88 | // Remove TypeScript type 89 | fetchFn = fetchFn.replace("fetchFn: FetchFunction", "fetchFn"); 90 | } 91 | 92 | b.addLine(fetchFn); 93 | 94 | b.addLine(); 95 | 96 | // Add subscribeFn 97 | if (this.context.args.subscriptions) { 98 | if (this.context.args.typescript) { 99 | b.addLine(`let subscribeFn: SubscribeFunction; 100 | 101 | if (typeof window !== "undefined") { 102 | // We only want to setup subscriptions if we are on the client. 103 | const subscriptionsClient = createClient({ 104 | url: WEBSOCKET_ENDPOINT, 105 | }); 106 | 107 | subscribeFn = (request, variables) => { 108 | // To understand why we return Observable.create, 109 | // please see: https://github.com/enisdenjo/graphql-ws/issues/316#issuecomment-1047605774 110 | return Observable.create((sink) => { 111 | if (!request.text) { 112 | return sink.error(new Error("Operation text cannot be empty")); 113 | } 114 | 115 | return subscriptionsClient.subscribe( 116 | { 117 | operationName: request.name, 118 | query: request.text, 119 | variables, 120 | }, 121 | sink 122 | ); 123 | }); 124 | }; 125 | }`); 126 | } else { 127 | b.addLine(`let subscribeFn; 128 | 129 | if (typeof window !== "undefined") { 130 | // We only want to setup subscriptions if we are on the client. 131 | const subscriptionsClient = createClient({ 132 | url: WEBSOCKET_ENDPOINT, 133 | }); 134 | 135 | subscribeFn = (request, variables) => { 136 | return Observable.create((sink) => { 137 | if (!request.text) { 138 | return sink.error(new Error("Operation text cannot be empty")); 139 | } 140 | 141 | return subscriptionsClient.subscribe( 142 | { 143 | operationName: request.name, 144 | query: request.text, 145 | variables, 146 | }, 147 | sink 148 | ); 149 | }); 150 | }; 151 | }`); 152 | } 153 | } 154 | 155 | b.addLine(); 156 | 157 | // Create environment 158 | let createEnv = `function createRelayEnvironment() { 159 | return new Environment({ 160 | network: Network.create(fetchFn), 161 | store: new Store(new RecordSource()), 162 | }); 163 | }`; 164 | 165 | if (this.context.args.subscriptions) { 166 | createEnv = createEnv.replace("fetchFn", "fetchFn, subscribeFn"); 167 | } 168 | 169 | b.addLine(createEnv); 170 | 171 | b.addLine(); 172 | 173 | // Export environment 174 | if (this.context.is("next")) { 175 | let initEnv = `let relayEnvironment: Environment | undefined; 176 | 177 | export function initRelayEnvironment() { 178 | const environment = relayEnvironment ?? createRelayEnvironment(); 179 | 180 | // For SSG and SSR always create a new Relay environment. 181 | if (typeof window === "undefined") { 182 | return environment; 183 | } 184 | 185 | // Create the Relay environment once in the client 186 | // and then reuse it. 187 | if (!relayEnvironment) { 188 | relayEnvironment = environment; 189 | } 190 | 191 | return relayEnvironment; 192 | }`; 193 | 194 | if (!this.context.args.typescript) { 195 | initEnv = initEnv.replace(": Environment | undefined", ""); 196 | } 197 | 198 | b.addLine(initEnv); 199 | } else { 200 | b.addLine(`export const RelayEnvironment = createRelayEnvironment();`); 201 | } 202 | 203 | const prettifiedCode = prettifyCode(b.code); 204 | 205 | await this.context.fs.createDirectory(this.context.relayEnvFile.parentDirectory); 206 | 207 | await this.context.fs.writeToFile(this.context.relayEnvFile.abs, prettifiedCode); 208 | } 209 | } 210 | 211 | class CodeBuilder { 212 | private _code: string = ""; 213 | 214 | get code() { 215 | return this._code; 216 | } 217 | 218 | addLine(line?: string) { 219 | this._code += (line || "") + EOL; 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/tasks/InstallNpmDependenciesTask.ts: -------------------------------------------------------------------------------- 1 | import { TaskBase } from "./TaskBase.js"; 2 | import { GRAPHQL_WS_PACKAGE, REACT_RELAY_PACKAGE, RELAY_RUNTIME_PACKAGE } from "../consts.js"; 3 | import { bold } from "../utils/cli.js"; 4 | import { ProjectContext } from "../misc/ProjectContext.js"; 5 | 6 | export class InstallNpmDependenciesTask extends TaskBase { 7 | message = "Add Relay dependencies"; 8 | 9 | constructor(private context: ProjectContext) { 10 | super(); 11 | } 12 | 13 | isEnabled(): boolean { 14 | return true; 15 | } 16 | 17 | async run(): Promise { 18 | if (this.context.args.skipInstall) { 19 | this.skip(); 20 | return; 21 | } 22 | 23 | const packages = [REACT_RELAY_PACKAGE, RELAY_RUNTIME_PACKAGE]; 24 | 25 | if (this.context.args.subscriptions) { 26 | packages.push(GRAPHQL_WS_PACKAGE, "graphql@15.x"); 27 | } 28 | 29 | this.updateMessage(this.message + " " + packages.map((p) => bold(p)).join(" ")); 30 | 31 | const latestPackages = packages.map((p) => (p.substring(1).includes("@") ? p : p + "@latest")); 32 | 33 | await this.context.manager.addDependency(latestPackages); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/tasks/InstallNpmDevDependenciesTask.ts: -------------------------------------------------------------------------------- 1 | import { TaskBase } from "./TaskBase.js"; 2 | import { BABEL_RELAY_PACKAGE, VITE_RELAY_PACKAGE } from "../consts.js"; 3 | import { bold } from "../utils/cli.js"; 4 | import { ProjectContext } from "../misc/ProjectContext.js"; 5 | 6 | export class InstallNpmDevDependenciesTask extends TaskBase { 7 | message = "Add Relay devDependencies"; 8 | 9 | constructor(private context: ProjectContext) { 10 | super(); 11 | } 12 | 13 | isEnabled(): boolean { 14 | return true; 15 | } 16 | 17 | async run(): Promise { 18 | if (this.context.args.skipInstall) { 19 | this.skip(); 20 | return; 21 | } 22 | 23 | const packages = this.getPackages(); 24 | 25 | this.updateMessage(this.message + " " + packages.map((p) => bold(p)).join(" ")); 26 | 27 | const latestPackages = packages.map((p) => (p.substring(1).includes("@") ? p : p + "@latest")); 28 | 29 | await this.context.manager.addDevDependency(latestPackages); 30 | } 31 | 32 | private getPackages() { 33 | const relayDevDep = ["relay-compiler"]; 34 | 35 | if (this.context.args.typescript) { 36 | relayDevDep.push("@types/react-relay"); 37 | relayDevDep.push("@types/relay-runtime"); 38 | } 39 | 40 | if (this.context.is("cra") || this.context.is("vite")) { 41 | relayDevDep.push(BABEL_RELAY_PACKAGE); 42 | } 43 | 44 | if (this.context.is("vite")) { 45 | relayDevDep.push(VITE_RELAY_PACKAGE); 46 | } 47 | 48 | return relayDevDep; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/tasks/TaskBase.ts: -------------------------------------------------------------------------------- 1 | export abstract class TaskBase { 2 | abstract message: string; 3 | 4 | abstract isEnabled(): boolean; 5 | 6 | abstract run(): Promise; 7 | 8 | updateMessage(message: string) { 9 | if (this.onUpdateMessage) { 10 | this.onUpdateMessage(message); 11 | } 12 | } 13 | 14 | skip(reason?: string): void { 15 | throw new TaskSkippedError(reason); 16 | } 17 | 18 | onUpdateMessage?(message: string): void; 19 | } 20 | 21 | export class TaskSkippedError extends Error { 22 | constructor(reason?: string) { 23 | super("Task skipped: " + (reason ?? "Reason not specified")); 24 | 25 | this.reason = reason; 26 | } 27 | 28 | reason?: string; 29 | } 30 | -------------------------------------------------------------------------------- /src/tasks/TaskRunner.ts: -------------------------------------------------------------------------------- 1 | import ora from "ora"; 2 | import { dim } from "../utils/cli.js"; 3 | import { TaskBase, TaskSkippedError } from "./TaskBase.js"; 4 | 5 | export class TaskRunner { 6 | constructor(private taskDefs: (false | TaskBase)[]) {} 7 | 8 | async run(): Promise { 9 | let hadError = false; 10 | 11 | for (let i = 0; i < this.taskDefs.length; i++) { 12 | const task = this.taskDefs[i]; 13 | 14 | if (!task || !task.isEnabled()) { 15 | continue; 16 | } 17 | 18 | const spinner = ora(task.message); 19 | 20 | task.onUpdateMessage = (msg) => (spinner.text = msg); 21 | 22 | try { 23 | spinner.start(); 24 | 25 | await task.run(); 26 | 27 | spinner.succeed(); 28 | } catch (error) { 29 | if (error instanceof TaskSkippedError) { 30 | const reason = error.reason ? ": " + error.reason : ""; 31 | 32 | spinner.warn(spinner.text + " " + dim(`[Skipped${reason}]`)); 33 | 34 | continue; 35 | } 36 | 37 | let errorMsg: string | undefined = undefined; 38 | 39 | if (!!error) { 40 | if (typeof error === "string") { 41 | errorMsg = error; 42 | } else if (error instanceof Error) { 43 | errorMsg = error.message; 44 | } 45 | } 46 | 47 | spinner.fail(); 48 | 49 | if (errorMsg) { 50 | console.log(); 51 | console.log(" " + errorMsg); 52 | console.log(); 53 | } 54 | 55 | if (!hadError) { 56 | hadError = true; 57 | } 58 | } 59 | } 60 | 61 | if (hadError) { 62 | throw new Error(); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/tasks/cra/Cra_AddBabelMacroTypeDefinitionsTask.ts: -------------------------------------------------------------------------------- 1 | import { TaskBase } from "../TaskBase.js"; 2 | import { bold } from "../../utils/index.js"; 3 | import { EOL } from "os"; 4 | import { BABEL_RELAY_MACRO } from "../../consts.js"; 5 | import { ProjectContext } from "../../misc/ProjectContext.js"; 6 | import path from "path"; 7 | 8 | const babelMacroTypeDef = `${EOL} 9 | declare module "babel-plugin-relay/macro" { 10 | export { graphql as default } from "react-relay"; 11 | }`; 12 | 13 | export class Cra_AddBabelMacroTypeDefinitionsTask extends TaskBase { 14 | message: string = `Add ${bold(BABEL_RELAY_MACRO)} type definitions`; 15 | 16 | constructor(private context: ProjectContext) { 17 | super(); 18 | } 19 | 20 | isEnabled(): boolean { 21 | return this.context.is("cra") && this.context.args.typescript; 22 | } 23 | 24 | async run(): Promise { 25 | const reactTypeDefFilepath = this.context.env.rel(path.join("src", "react-app-env.d.ts")); 26 | 27 | this.updateMessage(this.message + " to " + bold(reactTypeDefFilepath.rel)); 28 | 29 | if (!this.context.fs.exists(reactTypeDefFilepath.abs)) { 30 | throw new Error(`Could not find ${bold(reactTypeDefFilepath.rel)}`); 31 | } 32 | 33 | const typeDefContent = await this.context.fs.readFromFile(reactTypeDefFilepath.abs); 34 | 35 | if (typeDefContent.includes('declare module "babel-plugin-relay/macro"')) { 36 | this.skip("Already exists"); 37 | return; 38 | } 39 | 40 | try { 41 | await this.context.fs.appendToFile(reactTypeDefFilepath.abs, babelMacroTypeDef); 42 | } catch (error) { 43 | throw new Error(`Could not append ${BABEL_RELAY_MACRO} to ${bold(reactTypeDefFilepath.rel)}`, { 44 | cause: error instanceof Error ? error : undefined, 45 | }); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/tasks/cra/Cra_AddRelayEnvironmentProvider.ts: -------------------------------------------------------------------------------- 1 | import traverse, { NodePath } from "@babel/traverse"; 2 | import path from "path"; 3 | import { REACT_RELAY_PACKAGE, RELAY_ENV, RELAY_ENV_PROVIDER } from "../../consts.js"; 4 | import { ProjectContext } from "../../misc/ProjectContext.js"; 5 | import { RelativePath } from "../../misc/RelativePath.js"; 6 | import { insertNamedImport, insertNamedImports, parseAst, printAst } from "../../utils/ast.js"; 7 | import { bold } from "../../utils/cli.js"; 8 | import { TaskBase, TaskSkippedError } from "../TaskBase.js"; 9 | import t from "@babel/types"; 10 | import { ParseResult } from "@babel/parser"; 11 | 12 | export class Cra_AddRelayEnvironmentProvider extends TaskBase { 13 | message: string = "Add " + RELAY_ENV_PROVIDER; 14 | 15 | constructor(private context: ProjectContext) { 16 | super(); 17 | } 18 | 19 | isEnabled(): boolean { 20 | return this.context.is("cra"); 21 | } 22 | 23 | async run(): Promise { 24 | const mainFilename = "index" + (this.context.args.typescript ? ".tsx" : ".js"); 25 | 26 | const mainFile = this.context.env.rel(path.join("src", mainFilename)); 27 | 28 | this.updateMessage(this.message + " in " + bold(mainFile.rel)); 29 | 30 | const code = await this.context.fs.readFromFile(mainFile.abs); 31 | 32 | const ast = parseAst(code); 33 | 34 | configureRelayProviderInReactDomRender(ast, mainFile, this.context.relayEnvFile); 35 | 36 | const updatedCode = printAst(ast, code); 37 | 38 | await this.context.fs.writeToFile(mainFile.abs, updatedCode); 39 | } 40 | } 41 | 42 | export function hasRelayProvider(jsxPath: NodePath): boolean { 43 | let isProviderConfigured = false; 44 | 45 | jsxPath.traverse({ 46 | JSXOpeningElement: (path) => { 47 | if (isProviderConfigured) { 48 | return; 49 | } 50 | 51 | if (t.isJSXIdentifier(path.node.name) && path.node.name.name === RELAY_ENV_PROVIDER) { 52 | isProviderConfigured = true; 53 | path.skip(); 54 | return; 55 | } 56 | }, 57 | }); 58 | 59 | return isProviderConfigured; 60 | } 61 | 62 | export function configureRelayProviderInReactDomRender( 63 | ast: ParseResult, 64 | currentFile: RelativePath, 65 | relayEnvFile: RelativePath 66 | ) { 67 | let providerWrapped = false; 68 | 69 | traverse.default(ast, { 70 | JSXElement: (path) => { 71 | if (providerWrapped) { 72 | return; 73 | } 74 | 75 | const parent = path.parentPath.node; 76 | 77 | // Check if it's the top JSX in ReactDOM.render(...) 78 | if ( 79 | !t.isCallExpression(parent) || 80 | !t.isMemberExpression(parent.callee) || 81 | !t.isIdentifier(parent.callee.property) || 82 | parent.callee.property.name !== "render" 83 | ) { 84 | return; 85 | } 86 | 87 | const isProviderConfigured = hasRelayProvider(path); 88 | 89 | if (isProviderConfigured) { 90 | throw new TaskSkippedError("Already added"); 91 | } 92 | 93 | const relativeRelayImport = new RelativePath(currentFile.parentDirectory, removeExtension(relayEnvFile.abs)); 94 | 95 | const envId = insertNamedImport(path, RELAY_ENV, relativeRelayImport.rel); 96 | 97 | const envProviderId = t.jsxIdentifier(insertNamedImport(path, RELAY_ENV_PROVIDER, REACT_RELAY_PACKAGE).name); 98 | 99 | wrapJsxInRelayProvider(path, envProviderId, envId); 100 | 101 | providerWrapped = true; 102 | 103 | path.skip(); 104 | }, 105 | }); 106 | 107 | if (!providerWrapped) { 108 | throw new Error("Could not find JSX being passed to ReactDOM.render"); 109 | } 110 | } 111 | 112 | export function wrapJsxInRelayProvider( 113 | jsxPath: NodePath, 114 | envProviderId: t.JSXIdentifier, 115 | envId: t.Identifier 116 | ) { 117 | // Wrap JSX into RelayEnvironmentProvider. 118 | jsxPath.replaceWith( 119 | t.jsxElement( 120 | t.jsxOpeningElement(envProviderId, [ 121 | t.jsxAttribute(t.jsxIdentifier("environment"), t.jsxExpressionContainer(envId)), 122 | ]), 123 | t.jsxClosingElement(envProviderId), 124 | [jsxPath.node] 125 | ) 126 | ); 127 | } 128 | 129 | export function removeExtension(filename: string): string { 130 | return filename.substring(0, filename.lastIndexOf(".")) || filename; 131 | } 132 | -------------------------------------------------------------------------------- /src/tasks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./TaskRunner.js"; 2 | export * from "./InstallNpmDependenciesTask.js"; 3 | export * from "./InstallNpmDevDependenciesTask.js"; 4 | export * from "./ConfigureRelayCompilerTask.js"; 5 | export * from "./GenerateArtifactDirectoryTask.js"; 6 | export * from "./GenerateGraphQlSchemaFileTask.js"; 7 | export * from "./GenerateRelayEnvironmentTask.js"; 8 | export * from "./ConfigureEolOfArtifactsTask.js"; 9 | export * from "./cra/Cra_AddBabelMacroTypeDefinitionsTask.js"; 10 | export * from "./cra/Cra_AddRelayEnvironmentProvider.js"; 11 | export * from "./vite/Vite_ConfigureVitePluginRelayTask.js"; 12 | export * from "./vite/Vite_AddRelayEnvironmentProvider.js"; 13 | export * from "./next/Next_ConfigureNextCompilerTask.js"; 14 | export * from "./next/Next_AddRelayEnvironmentProvider.js"; 15 | export * from "./next/Next_AddTypeHelpers.js"; 16 | -------------------------------------------------------------------------------- /src/tasks/next/Next_AddRelayEnvironmentProvider.ts: -------------------------------------------------------------------------------- 1 | import traverse from "@babel/traverse"; 2 | import path from "path"; 3 | import { REACT_RELAY_PACKAGE, RELAY_ENV_PROVIDER, RELAY_RUNTIME_PACKAGE } from "../../consts.js"; 4 | import { ProjectContext } from "../../misc/ProjectContext.js"; 5 | import { RelativePath } from "../../misc/RelativePath.js"; 6 | import { astToString, insertNamedImport, insertNamedImports, parseAst, prettifyCode } from "../../utils/ast.js"; 7 | import { bold } from "../../utils/cli.js"; 8 | import { TaskBase, TaskSkippedError } from "../TaskBase.js"; 9 | import t from "@babel/types"; 10 | import { removeExtension, hasRelayProvider, wrapJsxInRelayProvider } from "../cra/Cra_AddRelayEnvironmentProvider.js"; 11 | import { Next_AddTypeHelpers } from "./Next_AddTypeHelpers.js"; 12 | 13 | // todo: test this 14 | const envCreationAndHydration = ` 15 | const environment = useMemo(initRelayEnvironment, []); 16 | 17 | useEffect(() => { 18 | const store = environment.getStore(); 19 | 20 | // Hydrate the store. 21 | store.publish(new RecordSource(pageProps.initialRecords)); 22 | 23 | // Notify any existing subscribers. 24 | store.notify(); 25 | }, [environment, pageProps.initialRecords]) 26 | 27 | `; 28 | 29 | const APP_PROPS = "AppProps"; 30 | const RELAY_PAGE_PROPS = "RelayPageProps"; 31 | 32 | export class Next_AddRelayEnvironmentProvider extends TaskBase { 33 | message: string = "Add " + RELAY_ENV_PROVIDER; 34 | 35 | constructor(private context: ProjectContext) { 36 | super(); 37 | } 38 | 39 | isEnabled(): boolean { 40 | return this.context.is("next"); 41 | } 42 | 43 | async run(): Promise { 44 | const pagesMainFilename = "_app" + (this.context.args.typescript ? ".tsx" : ".js"); 45 | 46 | const possiblePagesMainFileLocations = [ 47 | path.join("pages", pagesMainFilename), 48 | path.join("src", "pages", pagesMainFilename), 49 | ]; 50 | 51 | let pagesMainFile: RelativePath | null = null; 52 | 53 | for (const possibleMainFileLocation of possiblePagesMainFileLocations) { 54 | const file = this.context.env.rel(possibleMainFileLocation); 55 | 56 | if (this.context.fs.exists(file.abs)) { 57 | pagesMainFile = file; 58 | break; 59 | } 60 | } 61 | 62 | if (pagesMainFile) { 63 | await this.setupPages(pagesMainFile); 64 | } else { 65 | throw new Error(`${pagesMainFilename} could not be found`); 66 | } 67 | } 68 | 69 | private async setupPages(mainFile: RelativePath) { 70 | this.updateMessage(this.message + " in " + bold(mainFile.rel)); 71 | 72 | const code = await this.context.fs.readFromFile(mainFile.abs); 73 | 74 | const ast = parseAst(code); 75 | 76 | let providerWrapped = false; 77 | 78 | traverse.default(ast, { 79 | JSXElement: (path) => { 80 | // Find first JSX being returned *somewhere*. 81 | if (providerWrapped || !path.parentPath.isReturnStatement()) { 82 | return; 83 | } 84 | 85 | const functionReturn = path.parentPath; 86 | 87 | const isProviderConfigured = hasRelayProvider(path); 88 | 89 | if (isProviderConfigured) { 90 | throw new TaskSkippedError("Already added"); 91 | } 92 | 93 | // We need to modify the type of the _app arguments, 94 | // starting with Next 12.3. 95 | if (this.context.args.typescript) { 96 | // Import RelayPageProps. 97 | const relayTypesPath = Next_AddTypeHelpers.getRelayTypesPath(this.context); 98 | const relayTypesImportPath = new RelativePath(mainFile!.parentDirectory, removeExtension(relayTypesPath.abs)); 99 | 100 | insertNamedImport(path, RELAY_PAGE_PROPS, relayTypesImportPath.rel); 101 | 102 | // Change argument of type AppProps to AppProps. 103 | const functionBodyPath = functionReturn.parentPath; 104 | if (!functionBodyPath.isBlockStatement()) { 105 | throw new Error("Expected parentPath to be a block statement."); 106 | } 107 | 108 | const functionPath = functionBodyPath.parentPath; 109 | if (!functionPath.isFunctionDeclaration() || !t.isFunctionDeclaration(functionPath.node)) { 110 | throw new Error("Expected parentPath to be a function declaration."); 111 | } 112 | 113 | const appPropsArg = functionPath.node.params[0]; 114 | 115 | if (!appPropsArg) { 116 | throw new Error("Expected function to have one argument."); 117 | } 118 | 119 | const genericAppProps = t.genericTypeAnnotation( 120 | t.identifier(APP_PROPS), 121 | t.typeParameterInstantiation([t.genericTypeAnnotation(t.identifier(RELAY_PAGE_PROPS))]) 122 | ); 123 | 124 | appPropsArg.typeAnnotation = t.typeAnnotation(genericAppProps); 125 | } 126 | 127 | insertNamedImports(path, ["useMemo", "useEffect"], "react"); 128 | insertNamedImport(path, "RecordSource", RELAY_RUNTIME_PACKAGE); 129 | 130 | const relayEnvImportPath = new RelativePath( 131 | mainFile!.parentDirectory, 132 | removeExtension(this.context.relayEnvFile.abs) 133 | ); 134 | 135 | insertNamedImport(path, "initRelayEnvironment", relayEnvImportPath.rel); 136 | 137 | functionReturn.addComment("leading", "--MARKER", true); 138 | 139 | const envProviderId = t.jsxIdentifier(insertNamedImport(path, RELAY_ENV_PROVIDER, REACT_RELAY_PACKAGE).name); 140 | 141 | wrapJsxInRelayProvider(path, envProviderId, t.identifier("environment")); 142 | 143 | providerWrapped = true; 144 | 145 | path.skip(); 146 | }, 147 | }); 148 | 149 | if (!providerWrapped) { 150 | throw new Error("Could not find JSX"); 151 | } 152 | 153 | let updatedCode = astToString(ast, code); 154 | 155 | updatedCode = updatedCode.replace("//--MARKER", envCreationAndHydration); 156 | updatedCode = prettifyCode(updatedCode); 157 | 158 | await this.context.fs.writeToFile(mainFile.abs, updatedCode); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/tasks/next/Next_AddTypeHelpers.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { NEXT_SRC_PATH } from "../../consts.js"; 3 | import { ProjectContext } from "../../misc/ProjectContext.js"; 4 | import { RelativePath } from "../../misc/RelativePath.js"; 5 | import { prettifyCode } from "../../utils/ast.js"; 6 | import { bold } from "../../utils/cli.js"; 7 | import { TaskBase } from "../TaskBase.js"; 8 | 9 | const code = ` 10 | import type { GetServerSideProps, GetStaticProps, PreviewData } from "next"; 11 | import type { ParsedUrlQuery } from "querystring"; 12 | import type { RecordMap } from "relay-runtime/lib/store/RelayStoreTypes"; 13 | 14 | export type RelayPageProps = { 15 | initialRecords?: RecordMap; 16 | }; 17 | 18 | export type GetRelayServerSideProps< 19 | P extends { [key: string]: any } = { [key: string]: any }, 20 | Q extends ParsedUrlQuery = ParsedUrlQuery, 21 | D extends PreviewData = PreviewData 22 | > = GetServerSideProps

, Q, D>; 23 | 24 | export type GetRelayStaticProps< 25 | P extends { [key: string]: any } = { [key: string]: any }, 26 | Q extends ParsedUrlQuery = ParsedUrlQuery, 27 | D extends PreviewData = PreviewData 28 | > = GetStaticProps

, Q, D>; 29 | `; 30 | 31 | export class Next_AddTypeHelpers extends TaskBase { 32 | message = "Add type helpers"; 33 | 34 | constructor(private context: ProjectContext) { 35 | super(); 36 | } 37 | 38 | isEnabled(): boolean { 39 | return this.context.is("next") && this.context.args.typescript; 40 | } 41 | 42 | async run(): Promise { 43 | const filepath = Next_AddTypeHelpers.getRelayTypesPath(this.context); 44 | 45 | this.updateMessage(this.message + " " + bold(filepath.rel)); 46 | 47 | if (this.context.fs.exists(filepath.abs)) { 48 | this.skip("File exists"); 49 | return; 50 | } 51 | 52 | const prettifiedCode = prettifyCode(code); 53 | 54 | await this.context.fs.writeToFile(filepath.abs, prettifiedCode); 55 | } 56 | 57 | static getRelayTypesPath(context: ProjectContext): RelativePath { 58 | const filepath = path.join(NEXT_SRC_PATH, "relay-types.ts"); 59 | 60 | return context.env.rel(filepath); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/tasks/next/Next_ConfigureNextCompilerTask.ts: -------------------------------------------------------------------------------- 1 | import traverse from "@babel/traverse"; 2 | import t from "@babel/types"; 3 | import { ProjectContext } from "../../misc/ProjectContext.js"; 4 | import { parseAst, printAst, mergeProperties } from "../../utils/ast.js"; 5 | import { bold } from "../../utils/cli.js"; 6 | import { TaskBase } from "../TaskBase.js"; 7 | 8 | export class Next_ConfigureNextCompilerTask extends TaskBase { 9 | message: string = `Configure Next.js compiler`; 10 | 11 | constructor(private context: ProjectContext) { 12 | super(); 13 | } 14 | 15 | isEnabled(): boolean { 16 | return this.context.is("next"); 17 | } 18 | 19 | async run(): Promise { 20 | const configFilename = "next.config.js"; 21 | 22 | const configFile = this.context.env.rel(configFilename); 23 | 24 | this.updateMessage(this.message + " in " + bold(configFile.rel)); 25 | 26 | const configCode = await this.context.fs.readFromFile(configFile.abs); 27 | 28 | const ast = parseAst(configCode); 29 | 30 | let configured = false; 31 | 32 | traverse.default(ast, { 33 | AssignmentExpression: (path) => { 34 | if (configured) { 35 | return; 36 | } 37 | 38 | const node = path.node; 39 | 40 | // We are looking for module.exports = ???. 41 | if ( 42 | node.operator !== "=" || 43 | !t.isMemberExpression(node.left) || 44 | !t.isIdentifier(node.left.object) || 45 | !t.isIdentifier(node.left.property) || 46 | node.left.object.name !== "module" || 47 | node.left.property.name !== "exports" 48 | ) { 49 | return; 50 | } 51 | 52 | let objExp: t.ObjectExpression; 53 | 54 | // We are looking for the object expression 55 | // that was assigned to module.exports. 56 | if (t.isIdentifier(node.right)) { 57 | // The export is linked to a variable, 58 | // so we need to resolve the variable declaration. 59 | const binding = path.scope.getBinding(node.right.name); 60 | 61 | if (!binding || !t.isVariableDeclarator(binding.path.node) || !t.isObjectExpression(binding.path.node.init)) { 62 | throw new Error("`module.exports` references a variable, but the variable is not an object."); 63 | } 64 | 65 | objExp = binding.path.node.init; 66 | } else if (t.isObjectExpression(node.right)) { 67 | objExp = node.right; 68 | } else { 69 | throw new Error("Expected to find an object initializer or variable assigned to `module.exports`."); 70 | } 71 | 72 | // We are creating or getting the 'compiler' property. 73 | let compiler_Prop = objExp.properties.find( 74 | (p) => t.isObjectProperty(p) && t.isIdentifier(p.key) && p.key.name === "compiler" 75 | ) as t.ObjectProperty; 76 | 77 | if (!compiler_Prop) { 78 | compiler_Prop = t.objectProperty(t.identifier("compiler"), t.objectExpression([])); 79 | 80 | objExp.properties.push(compiler_Prop); 81 | } 82 | 83 | if (!t.isObjectExpression(compiler_Prop.value)) { 84 | throw new Error("Expected the `compiler` property to be an object."); 85 | } 86 | 87 | let relay_ObjProps: t.ObjectProperty[] = [ 88 | t.objectProperty(t.identifier("src"), t.stringLiteral(this.context.srcPath.rel)), 89 | t.objectProperty(t.identifier("language"), t.stringLiteral(this.context.compilerLanguage)), 90 | ]; 91 | 92 | if (this.context.artifactPath) { 93 | relay_ObjProps.push( 94 | t.objectProperty(t.identifier("artifactDirectory"), t.stringLiteral(this.context.artifactPath.rel)) 95 | ); 96 | } 97 | 98 | const compiler_relayProp = compiler_Prop.value.properties.find( 99 | (p) => t.isObjectProperty(p) && t.isIdentifier(p.key) && p.key.name === "relay" 100 | ) as t.ObjectProperty; 101 | 102 | if (compiler_relayProp && t.isObjectExpression(compiler_relayProp.value)) { 103 | // We already have a "relay" property, so we merge its properties, 104 | // with the new ones. 105 | compiler_relayProp.value = t.objectExpression( 106 | mergeProperties(compiler_relayProp.value.properties, relay_ObjProps) 107 | ); 108 | } else { 109 | // We do not yet have a "relay" propery, so we add it. 110 | compiler_Prop.value.properties.push( 111 | t.objectProperty(t.identifier("relay"), t.objectExpression(relay_ObjProps)) 112 | ); 113 | } 114 | 115 | path.skip(); 116 | }, 117 | }); 118 | 119 | const updatedConfigCode = printAst(ast, configCode); 120 | 121 | await this.context.fs.writeToFile(configFile.abs, updatedConfigCode); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/tasks/vite/Vite_AddRelayEnvironmentProvider.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { RELAY_ENV_PROVIDER } from "../../consts.js"; 3 | import { ProjectContext } from "../../misc/ProjectContext.js"; 4 | import { parseAst, printAst } from "../../utils/ast.js"; 5 | import { bold } from "../../utils/cli.js"; 6 | import { TaskBase } from "../TaskBase.js"; 7 | import { configureRelayProviderInReactDomRender } from "../cra/Cra_AddRelayEnvironmentProvider.js"; 8 | 9 | export class Vite_AddRelayEnvironmentProvider extends TaskBase { 10 | message: string = "Add " + RELAY_ENV_PROVIDER; 11 | 12 | constructor(private context: ProjectContext) { 13 | super(); 14 | } 15 | 16 | isEnabled(): boolean { 17 | return this.context.is("vite"); 18 | } 19 | 20 | async run(): Promise { 21 | const mainFilename = "main" + (this.context.args.typescript ? ".tsx" : ".jsx"); 22 | 23 | const mainFile = this.context.env.rel(path.join("src", mainFilename)); 24 | 25 | this.updateMessage(this.message + " in " + bold(mainFile.rel)); 26 | 27 | const code = await this.context.fs.readFromFile(mainFile.abs); 28 | 29 | const ast = parseAst(code); 30 | 31 | configureRelayProviderInReactDomRender(ast, mainFile, this.context.relayEnvFile); 32 | 33 | const updatedCode = printAst(ast, code); 34 | 35 | await this.context.fs.writeToFile(mainFile.abs, updatedCode); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/tasks/vite/Vite_ConfigureVitePluginRelayTask.ts: -------------------------------------------------------------------------------- 1 | import traverse from "@babel/traverse"; 2 | import t from "@babel/types"; 3 | import { VITE_RELAY_PACKAGE } from "../../consts.js"; 4 | import { ProjectContext } from "../../misc/ProjectContext.js"; 5 | import { parseAst, insertDefaultImport, printAst } from "../../utils/ast.js"; 6 | import { bold } from "../../utils/cli.js"; 7 | import { TaskBase } from "../TaskBase.js"; 8 | 9 | export class Vite_ConfigureVitePluginRelayTask extends TaskBase { 10 | message: string = `Configure ${bold(VITE_RELAY_PACKAGE)}`; 11 | 12 | constructor(private context: ProjectContext) { 13 | super(); 14 | } 15 | 16 | isEnabled(): boolean { 17 | return this.context.is("vite"); 18 | } 19 | 20 | async run(): Promise { 21 | const configFilename = "vite.config" + (this.context.args.typescript ? ".ts" : ".js"); 22 | 23 | const configFile = this.context.env.rel(configFilename); 24 | 25 | this.updateMessage(this.message + " in " + bold(configFile.rel)); 26 | 27 | const configCode = await this.context.fs.readFromFile(configFile.abs); 28 | 29 | const ast = parseAst(configCode); 30 | 31 | traverse.default(ast, { 32 | ExportDefaultDeclaration: (path) => { 33 | const relayImportId = insertDefaultImport(path, "relay", VITE_RELAY_PACKAGE); 34 | 35 | const node = path.node; 36 | 37 | // Find export default defineConfig(???) 38 | if ( 39 | !t.isCallExpression(node.declaration) || 40 | node.declaration.arguments.length < 1 || 41 | !t.isIdentifier(node.declaration.callee) || 42 | node.declaration.callee.name !== "defineConfig" 43 | ) { 44 | throw new Error("Expected `export default defineConfig()`"); 45 | } 46 | 47 | const arg = node.declaration.arguments[0]; 48 | 49 | if (!t.isObjectExpression(arg)) { 50 | throw new Error("Expected first argument of `defineConfig` to be an object"); 51 | } 52 | 53 | // We are creating or getting the 'plugins' property. 54 | let pluginsProperty = arg.properties.find( 55 | (p) => t.isObjectProperty(p) && t.isIdentifier(p.key) && p.key.name === "plugins" 56 | ) as t.ObjectProperty; 57 | 58 | if (!pluginsProperty) { 59 | pluginsProperty = t.objectProperty(t.identifier("plugins"), t.arrayExpression([])); 60 | 61 | arg.properties.push(pluginsProperty); 62 | } 63 | 64 | if (!t.isArrayExpression(pluginsProperty.value)) { 65 | throw new Error("Expected the `plugins` property in the object passed to `defineConfig()` to be an array"); 66 | } 67 | 68 | const vitePlugins = pluginsProperty.value.elements; 69 | 70 | if (vitePlugins.some((p) => t.isIdentifier(p) && p.name === relayImportId.name)) { 71 | this.skip("Already configured"); 72 | return; 73 | } 74 | 75 | // Add the "relay" import to the beginning of "plugins". 76 | vitePlugins.splice(0, 0, relayImportId); 77 | }, 78 | }); 79 | 80 | const updatedConfigCode = printAst(ast, configCode); 81 | 82 | await this.context.fs.writeToFile(configFile.abs, updatedConfigCode); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export const ToolchainOptions = ["cra", "next", "vite"] as const; 2 | export const PackageManagerOptions = ["npm", "yarn", "pnpm"] as const; 3 | 4 | export type ToolchainType = typeof ToolchainOptions[number]; 5 | export type PackageManagerType = typeof PackageManagerOptions[number]; 6 | 7 | export type RelayCompilerLanguage = "javascript" | "typescript" | "flow"; 8 | 9 | export type CliArguments = { 10 | toolchain: ToolchainType; 11 | typescript: boolean; 12 | subscriptions: boolean; 13 | schemaFile: string; 14 | src: string; 15 | artifactDirectory: string; 16 | packageManager: PackageManagerType; 17 | ignoreGitChanges: boolean; 18 | skipInstall: boolean; 19 | interactive: boolean; 20 | }; 21 | -------------------------------------------------------------------------------- /src/utils/ast.ts: -------------------------------------------------------------------------------- 1 | import generate from "@babel/generator"; 2 | import { ParseResult, parse } from "@babel/parser"; 3 | import { NodePath } from "@babel/traverse"; 4 | import t from "@babel/types"; 5 | import { format } from "prettier"; 6 | 7 | export function parseAst(code: string): ParseResult { 8 | return parse(code, { 9 | sourceType: "module", 10 | plugins: ["typescript", "jsx"], 11 | }); 12 | } 13 | 14 | export function astToString(ast: ParseResult, oldCode: string): string { 15 | return generate.default(ast, { retainLines: true }, oldCode).code; 16 | } 17 | 18 | export function printAst(ast: ParseResult, oldCode: string): string { 19 | const newCode = astToString(ast, oldCode); 20 | 21 | return prettifyCode(newCode); 22 | } 23 | 24 | export function prettifyCode(code: string): string { 25 | return format(code, { 26 | bracketSameLine: false, 27 | endOfLine: "auto", 28 | parser: "babel-ts", 29 | }); 30 | } 31 | 32 | export function insertNamedImport(path: NodePath, importName: string, packageName: string): t.Identifier { 33 | return insertNamedImports(path, [importName], packageName)[0]; 34 | } 35 | 36 | export function insertNamedImports(path: NodePath, imports: string[], packageName: string): t.Identifier[] { 37 | const program = path.findParent((p) => p.isProgram()) as NodePath; 38 | 39 | const identifiers: t.Identifier[] = []; 40 | const missingImports: string[] = []; 41 | 42 | for (const namedImport of imports) { 43 | const importIdentifier = t.identifier(namedImport); 44 | 45 | const existingImport = getNamedImport(program, namedImport, packageName); 46 | 47 | if (!!existingImport) { 48 | identifiers.push(importIdentifier); 49 | continue; 50 | } 51 | 52 | missingImports.push(namedImport); 53 | } 54 | 55 | let importDeclaration: t.ImportDeclaration; 56 | const isFirstImportFromPackage = missingImports.length === imports.length; 57 | 58 | if (isFirstImportFromPackage) { 59 | importDeclaration = t.importDeclaration([], t.stringLiteral(packageName)); 60 | } else { 61 | importDeclaration = getImportDeclaration(program, packageName)!; 62 | } 63 | 64 | for (const namedImport of missingImports) { 65 | const importIdentifier = t.identifier(namedImport); 66 | 67 | const newImport = t.importSpecifier(t.cloneNode(importIdentifier), importIdentifier); 68 | 69 | importDeclaration.specifiers.push(newImport); 70 | 71 | identifiers.push(importIdentifier); 72 | } 73 | 74 | if (isFirstImportFromPackage) { 75 | // Insert import at start of file. 76 | program.node.body.unshift(importDeclaration); 77 | } 78 | 79 | return identifiers; 80 | } 81 | 82 | export function insertDefaultImport(path: NodePath, importName: string, packageName: string): t.Identifier { 83 | const importIdentifier = t.identifier(importName); 84 | 85 | const program = path.findParent((p) => p.isProgram()) as NodePath; 86 | 87 | const existingImport = getDefaultImport(program, importName, packageName); 88 | 89 | if (!!existingImport) { 90 | return importIdentifier; 91 | } 92 | 93 | const importDeclaration = t.importDeclaration( 94 | [t.importDefaultSpecifier(t.cloneNode(importIdentifier))], 95 | 96 | t.stringLiteral(packageName) 97 | ); 98 | 99 | // Insert import at start of file. 100 | program.node.body.unshift(importDeclaration); 101 | 102 | return importIdentifier; 103 | } 104 | 105 | function getImportDeclaration(path: NodePath, packageName: string): t.ImportDeclaration | null { 106 | return path.node.body.find( 107 | (s) => t.isImportDeclaration(s) && s.source.value === packageName 108 | ) as t.ImportDeclaration | null; 109 | } 110 | 111 | export function getNamedImport( 112 | path: NodePath, 113 | importName: string, 114 | packageName: string 115 | ): t.ImportDeclaration { 116 | return path.node.body.find( 117 | (s) => 118 | t.isImportDeclaration(s) && 119 | s.source.value === packageName && 120 | s.specifiers.some((sp) => t.isImportSpecifier(sp) && sp.local.name === importName) 121 | ) as t.ImportDeclaration; 122 | } 123 | 124 | function getDefaultImport(path: NodePath, importName: string, packageName: string): t.ImportDeclaration { 125 | return path.node.body.find( 126 | (s) => 127 | t.isImportDeclaration(s) && 128 | s.source.value === packageName && 129 | s.specifiers.some((sp) => t.isImportDefaultSpecifier(sp) && sp.local.name === importName) 130 | ) as t.ImportDeclaration; 131 | } 132 | 133 | export function mergeProperties( 134 | existingProps: t.ObjectExpression["properties"], 135 | newProps: t.ObjectProperty[] 136 | ): t.ObjectExpression["properties"] { 137 | let existingCopy = [...existingProps]; 138 | 139 | for (const prop of newProps) { 140 | const existingIndex = existingCopy.findIndex( 141 | (p) => t.isObjectProperty(p) && t.isIdentifier(p.key) && t.isIdentifier(prop.key) && p.key.name === prop.key.name 142 | ); 143 | 144 | if (existingIndex !== -1) { 145 | existingCopy[existingIndex] = prop; 146 | } else { 147 | existingCopy.push(prop); 148 | } 149 | } 150 | 151 | return existingCopy; 152 | } 153 | -------------------------------------------------------------------------------- /src/utils/cli.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | 3 | export function printError(message: string): void { 4 | console.log(chalk.red("✖") + " " + message); 5 | } 6 | 7 | export function headline(message: string): string { 8 | return chalk.cyan.bold.underline(message); 9 | } 10 | 11 | export function importantHeadline(message: string): string { 12 | return chalk.red.bold.underline(message); 13 | } 14 | 15 | export function bold(message: string): string { 16 | return chalk.cyan.bold(message); 17 | } 18 | 19 | export function dim(message: string): string { 20 | return chalk.dim(message); 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./cli.js"; 2 | export * from "./ast.js"; 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types", "assets/env"], 4 | "compilerOptions": { 5 | "target": "ES2017", 6 | "module": "Node16", 7 | "moduleResolution": "Node16", 8 | "lib": ["dom", "esnext"], 9 | "rootDir": "./src", 10 | "outDir": "dist", 11 | "strict": true, 12 | "noImplicitReturns": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "esModuleInterop": true, 15 | "skipLibCheck": true, 16 | "forceConsistentCasingInFileNames": true 17 | } 18 | } 19 | --------------------------------------------------------------------------------