diff --git a/CODEOWNERS b/CODEOWNERS index ac5e917dfb..6a2f86803d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -56,6 +56,11 @@ rust-toolchain @nrwl/nx-native-reviewers /packages/react-native/** @nrwl/nx-react-reviewers /e2e/react-native/** @nrwl/nx-react-reviewers +## remix +/docs/generated/packages/remix/** @nrwl/nx-react-reviewers @nrwl/nx-docs-reviewers @Coly010 +/packages/remix/** @nrwl/nx-react-reviewers @Coly010 +/e2e/remix/** @nrwl/nx-react-reviewers @Coly010 + # Vue /packages/vue/** @nrwl/nx-vue-reviewers /e2e/vue/** @nrwl/nx-vue-reviewers diff --git a/docs/generated/manifests/menus.json b/docs/generated/manifests/menus.json index a97444d36a..dc80661327 100644 --- a/docs/generated/manifests/menus.json +++ b/docs/generated/manifests/menus.json @@ -8941,6 +8941,186 @@ "isExternal": false, "disableCollapsible": false }, + { + "id": "remix", + "path": "/nx-api/remix", + "name": "remix", + "children": [ + { + "id": "documents", + "path": "/nx-api/remix/documents", + "name": "documents", + "children": [ + { + "name": "Overview", + "path": "/nx-api/remix/documents/overview", + "id": "overview", + "isExternal": false, + "children": [], + "disableCollapsible": false + } + ], + "isExternal": false, + "disableCollapsible": false + }, + { + "id": "executors", + "path": "/nx-api/remix/executors", + "name": "executors", + "children": [ + { + "id": "serve", + "path": "/nx-api/remix/executors/serve", + "name": "serve", + "children": [], + "isExternal": false, + "disableCollapsible": false + }, + { + "id": "build", + "path": "/nx-api/remix/executors/build", + "name": "build", + "children": [], + "isExternal": false, + "disableCollapsible": false + } + ], + "isExternal": false, + "disableCollapsible": false + }, + { + "id": "generators", + "path": "/nx-api/remix/generators", + "name": "generators", + "children": [ + { + "id": "preset", + "path": "/nx-api/remix/generators/preset", + "name": "preset", + "children": [], + "isExternal": false, + "disableCollapsible": false + }, + { + "id": "setup", + "path": "/nx-api/remix/generators/setup", + "name": "setup", + "children": [], + "isExternal": false, + "disableCollapsible": false + }, + { + "id": "application", + "path": "/nx-api/remix/generators/application", + "name": "application", + "children": [], + "isExternal": false, + "disableCollapsible": false + }, + { + "id": "cypress-component-configuration", + "path": "/nx-api/remix/generators/cypress-component-configuration", + "name": "cypress-component-configuration", + "children": [], + "isExternal": false, + "disableCollapsible": false + }, + { + "id": "library", + "path": "/nx-api/remix/generators/library", + "name": "library", + "children": [], + "isExternal": false, + "disableCollapsible": false + }, + { + "id": "route", + "path": "/nx-api/remix/generators/route", + "name": "route", + "children": [], + "isExternal": false, + "disableCollapsible": false + }, + { + "id": "resource-route", + "path": "/nx-api/remix/generators/resource-route", + "name": "resource-route", + "children": [], + "isExternal": false, + "disableCollapsible": false + }, + { + "id": "action", + "path": "/nx-api/remix/generators/action", + "name": "action", + "children": [], + "isExternal": false, + "disableCollapsible": false + }, + { + "id": "loader", + "path": "/nx-api/remix/generators/loader", + "name": "loader", + "children": [], + "isExternal": false, + "disableCollapsible": false + }, + { + "id": "style", + "path": "/nx-api/remix/generators/style", + "name": "style", + "children": [], + "isExternal": false, + "disableCollapsible": false + }, + { + "id": "setup-tailwind", + "path": "/nx-api/remix/generators/setup-tailwind", + "name": "setup-tailwind", + "children": [], + "isExternal": false, + "disableCollapsible": false + }, + { + "id": "storybook-configuration", + "path": "/nx-api/remix/generators/storybook-configuration", + "name": "storybook-configuration", + "children": [], + "isExternal": false, + "disableCollapsible": false + }, + { + "id": "meta", + "path": "/nx-api/remix/generators/meta", + "name": "meta", + "children": [], + "isExternal": false, + "disableCollapsible": false + }, + { + "id": "error-boundary", + "path": "/nx-api/remix/generators/error-boundary", + "name": "error-boundary", + "children": [], + "isExternal": false, + "disableCollapsible": false + }, + { + "id": "cypress", + "path": "/nx-api/remix/generators/cypress", + "name": "cypress", + "children": [], + "isExternal": false, + "disableCollapsible": false + } + ], + "isExternal": false, + "disableCollapsible": false + } + ], + "isExternal": false, + "disableCollapsible": false + }, { "id": "rollup", "path": "/nx-api/rollup", diff --git a/docs/generated/manifests/nx-api.json b/docs/generated/manifests/nx-api.json index 9cacbfe53e..9e6b825dfa 100644 --- a/docs/generated/manifests/nx-api.json +++ b/docs/generated/manifests/nx-api.json @@ -2414,6 +2414,185 @@ }, "path": "/nx-api/react-native" }, + "remix": { + "githubRoot": "https://github.com/nrwl/nx/blob/master", + "name": "remix", + "packageName": "@nx/remix", + "description": "The Remix plugin for Nx contains executors and generators for managing Remix applications and libraries within an Nx workspace. It provides:\n\n\n- Integration with libraries such as Vitest, Jest, Cypress, and Storybook.\n\n- Generators for applications, libraries, routes, loaders, and more.\n\n- Library build support for publishing packages to npm or other registries.\n\n- Utilities for automatic workspace refactoring.", + "documents": { + "/nx-api/remix/documents/overview": { + "id": "overview", + "name": "Overview", + "description": "The Remix plugin for Nx contains executors and generators for managing Remix applications and libraries within an Nx workspace. It provides:\n\n\n- Integration with libraries such as Vitest, Jest, Cypress, and Storybook.\n\n- Generators for applications, libraries, routes, loaders, and more.\n\n- Library build support for publishing packages to npm or other registries.\n\n- Utilities for automatic workspace refactoring.", + "file": "generated/packages/remix/documents/overview", + "itemList": [], + "isExternal": false, + "path": "/nx-api/remix/documents/overview", + "tags": [], + "originalFilePath": "shared/packages/remix/remix-plugin" + } + }, + "root": "/packages/remix", + "source": "/packages/remix/src", + "executors": { + "/nx-api/remix/executors/serve": { + "description": "Serve a Remix application.", + "file": "generated/packages/remix/executors/serve.json", + "hidden": false, + "name": "serve", + "originalFilePath": "/packages/remix/src/executors/serve/schema.json", + "path": "/nx-api/remix/executors/serve", + "type": "executor" + }, + "/nx-api/remix/executors/build": { + "description": "Build a Remix application.", + "file": "generated/packages/remix/executors/build.json", + "hidden": false, + "name": "build", + "originalFilePath": "/packages/remix/src/executors/build/schema.json", + "path": "/nx-api/remix/executors/build", + "type": "executor" + } + }, + "generators": { + "/nx-api/remix/generators/preset": { + "description": "Generate a new Remix workspace", + "file": "generated/packages/remix/generators/preset.json", + "hidden": true, + "name": "preset", + "originalFilePath": "/packages/remix/src/generators/preset/schema.json", + "path": "/nx-api/remix/generators/preset", + "type": "generator" + }, + "/nx-api/remix/generators/setup": { + "description": "Setup a Remix in an existing workspace", + "file": "generated/packages/remix/generators/setup.json", + "hidden": true, + "name": "setup", + "originalFilePath": "/packages/remix/src/generators/setup/schema.json", + "path": "/nx-api/remix/generators/setup", + "type": "generator" + }, + "/nx-api/remix/generators/application": { + "description": "Generate a new Remix application", + "file": "generated/packages/remix/generators/application.json", + "hidden": false, + "name": "application", + "originalFilePath": "/packages/remix/src/generators/application/schema.json", + "path": "/nx-api/remix/generators/application", + "type": "generator" + }, + "/nx-api/remix/generators/cypress-component-configuration": { + "description": "Generate a Cypress Component Testing configuration for a Remix project", + "file": "generated/packages/remix/generators/cypress-component-configuration.json", + "hidden": false, + "name": "cypress-component-configuration", + "originalFilePath": "/packages/remix/src/generators/cypress-component-configuration/schema.json", + "path": "/nx-api/remix/generators/cypress-component-configuration", + "type": "generator" + }, + "/nx-api/remix/generators/library": { + "description": "Generate a new library", + "file": "generated/packages/remix/generators/library.json", + "hidden": false, + "name": "library", + "originalFilePath": "/packages/remix/src/generators/library/schema.json", + "path": "/nx-api/remix/generators/library", + "type": "generator" + }, + "/nx-api/remix/generators/route": { + "description": "Generate a new route", + "file": "generated/packages/remix/generators/route.json", + "hidden": false, + "name": "route", + "originalFilePath": "/packages/remix/src/generators/route/schema.json", + "path": "/nx-api/remix/generators/route", + "type": "generator" + }, + "/nx-api/remix/generators/resource-route": { + "description": "Generate a new resource route", + "file": "generated/packages/remix/generators/resource-route.json", + "hidden": false, + "name": "resource-route", + "originalFilePath": "/packages/remix/src/generators/resource-route/schema.json", + "path": "/nx-api/remix/generators/resource-route", + "type": "generator" + }, + "/nx-api/remix/generators/action": { + "description": "Add an action function to an existing route", + "file": "generated/packages/remix/generators/action.json", + "hidden": false, + "name": "action", + "originalFilePath": "/packages/remix/src/generators/action/schema.json", + "path": "/nx-api/remix/generators/action", + "type": "generator" + }, + "/nx-api/remix/generators/loader": { + "description": "Add a loader function to an existing route", + "file": "generated/packages/remix/generators/loader.json", + "hidden": false, + "name": "loader", + "originalFilePath": "/packages/remix/src/generators/loader/schema.json", + "path": "/nx-api/remix/generators/loader", + "type": "generator" + }, + "/nx-api/remix/generators/style": { + "description": "Generates a new stylesheet and adds it to an existing route", + "file": "generated/packages/remix/generators/style.json", + "hidden": false, + "name": "style", + "originalFilePath": "/packages/remix/src/generators/style/schema.json", + "path": "/nx-api/remix/generators/style", + "type": "generator" + }, + "/nx-api/remix/generators/setup-tailwind": { + "description": "Generates a TailwindCSS configuration for the Remix application", + "file": "generated/packages/remix/generators/setup-tailwind.json", + "hidden": false, + "name": "setup-tailwind", + "originalFilePath": "/packages/remix/src/generators/setup-tailwind/schema.json", + "path": "/nx-api/remix/generators/setup-tailwind", + "type": "generator" + }, + "/nx-api/remix/generators/storybook-configuration": { + "description": "Generates a Storybook configuration for a Remix application", + "file": "generated/packages/remix/generators/storybook-configuration.json", + "hidden": false, + "name": "storybook-configuration", + "originalFilePath": "/packages/remix/src/generators/storybook-configuration/schema.json", + "path": "/nx-api/remix/generators/storybook-configuration", + "type": "generator" + }, + "/nx-api/remix/generators/meta": { + "description": "Add a meta function to an existing route", + "file": "generated/packages/remix/generators/meta.json", + "hidden": false, + "name": "meta", + "originalFilePath": "/packages/remix/src/generators/meta/schema.json", + "path": "/nx-api/remix/generators/meta", + "type": "generator" + }, + "/nx-api/remix/generators/error-boundary": { + "description": "Add an ErrorBoundary to an existing route", + "file": "generated/packages/remix/generators/error-boundary.json", + "hidden": false, + "name": "error-boundary", + "originalFilePath": "/packages/remix/src/generators/error-boundary/schema.json", + "path": "/nx-api/remix/generators/error-boundary", + "type": "generator" + }, + "/nx-api/remix/generators/cypress": { + "description": "Generate a project for testing Remix apps using Cypress", + "file": "generated/packages/remix/generators/cypress.json", + "hidden": false, + "name": "cypress", + "originalFilePath": "/packages/remix/src/generators/cypress/schema.json", + "path": "/nx-api/remix/generators/cypress", + "type": "generator" + } + }, + "path": "/nx-api/remix" + }, "rollup": { "githubRoot": "https://github.com/nrwl/nx/blob/master", "name": "rollup", diff --git a/docs/generated/packages-metadata.json b/docs/generated/packages-metadata.json index caa8ae34dc..86c53a2e4b 100644 --- a/docs/generated/packages-metadata.json +++ b/docs/generated/packages-metadata.json @@ -2392,6 +2392,184 @@ "root": "/packages/react-native", "source": "/packages/react-native/src" }, + { + "description": "The Remix plugin for Nx contains executors and generators for managing Remix applications and libraries within an Nx workspace. It provides:\n\n\n- Integration with libraries such as Vitest, Jest, Cypress, and Storybook.\n\n- Generators for applications, libraries, routes, loaders, and more.\n\n- Library build support for publishing packages to npm or other registries.\n\n- Utilities for automatic workspace refactoring.", + "documents": [ + { + "id": "overview", + "name": "Overview", + "description": "The Remix plugin for Nx contains executors and generators for managing Remix applications and libraries within an Nx workspace. It provides:\n\n\n- Integration with libraries such as Vitest, Jest, Cypress, and Storybook.\n\n- Generators for applications, libraries, routes, loaders, and more.\n\n- Library build support for publishing packages to npm or other registries.\n\n- Utilities for automatic workspace refactoring.", + "file": "generated/packages/remix/documents/overview", + "itemList": [], + "isExternal": false, + "path": "remix/documents/overview", + "tags": [], + "originalFilePath": "shared/packages/remix/remix-plugin" + } + ], + "executors": [ + { + "description": "Serve a Remix application.", + "file": "generated/packages/remix/executors/serve.json", + "hidden": false, + "name": "serve", + "originalFilePath": "/packages/remix/src/executors/serve/schema.json", + "path": "remix/executors/serve", + "type": "executor" + }, + { + "description": "Build a Remix application.", + "file": "generated/packages/remix/executors/build.json", + "hidden": false, + "name": "build", + "originalFilePath": "/packages/remix/src/executors/build/schema.json", + "path": "remix/executors/build", + "type": "executor" + } + ], + "generators": [ + { + "description": "Generate a new Remix workspace", + "file": "generated/packages/remix/generators/preset.json", + "hidden": true, + "name": "preset", + "originalFilePath": "/packages/remix/src/generators/preset/schema.json", + "path": "remix/generators/preset", + "type": "generator" + }, + { + "description": "Setup a Remix in an existing workspace", + "file": "generated/packages/remix/generators/setup.json", + "hidden": true, + "name": "setup", + "originalFilePath": "/packages/remix/src/generators/setup/schema.json", + "path": "remix/generators/setup", + "type": "generator" + }, + { + "description": "Generate a new Remix application", + "file": "generated/packages/remix/generators/application.json", + "hidden": false, + "name": "application", + "originalFilePath": "/packages/remix/src/generators/application/schema.json", + "path": "remix/generators/application", + "type": "generator" + }, + { + "description": "Generate a Cypress Component Testing configuration for a Remix project", + "file": "generated/packages/remix/generators/cypress-component-configuration.json", + "hidden": false, + "name": "cypress-component-configuration", + "originalFilePath": "/packages/remix/src/generators/cypress-component-configuration/schema.json", + "path": "remix/generators/cypress-component-configuration", + "type": "generator" + }, + { + "description": "Generate a new library", + "file": "generated/packages/remix/generators/library.json", + "hidden": false, + "name": "library", + "originalFilePath": "/packages/remix/src/generators/library/schema.json", + "path": "remix/generators/library", + "type": "generator" + }, + { + "description": "Generate a new route", + "file": "generated/packages/remix/generators/route.json", + "hidden": false, + "name": "route", + "originalFilePath": "/packages/remix/src/generators/route/schema.json", + "path": "remix/generators/route", + "type": "generator" + }, + { + "description": "Generate a new resource route", + "file": "generated/packages/remix/generators/resource-route.json", + "hidden": false, + "name": "resource-route", + "originalFilePath": "/packages/remix/src/generators/resource-route/schema.json", + "path": "remix/generators/resource-route", + "type": "generator" + }, + { + "description": "Add an action function to an existing route", + "file": "generated/packages/remix/generators/action.json", + "hidden": false, + "name": "action", + "originalFilePath": "/packages/remix/src/generators/action/schema.json", + "path": "remix/generators/action", + "type": "generator" + }, + { + "description": "Add a loader function to an existing route", + "file": "generated/packages/remix/generators/loader.json", + "hidden": false, + "name": "loader", + "originalFilePath": "/packages/remix/src/generators/loader/schema.json", + "path": "remix/generators/loader", + "type": "generator" + }, + { + "description": "Generates a new stylesheet and adds it to an existing route", + "file": "generated/packages/remix/generators/style.json", + "hidden": false, + "name": "style", + "originalFilePath": "/packages/remix/src/generators/style/schema.json", + "path": "remix/generators/style", + "type": "generator" + }, + { + "description": "Generates a TailwindCSS configuration for the Remix application", + "file": "generated/packages/remix/generators/setup-tailwind.json", + "hidden": false, + "name": "setup-tailwind", + "originalFilePath": "/packages/remix/src/generators/setup-tailwind/schema.json", + "path": "remix/generators/setup-tailwind", + "type": "generator" + }, + { + "description": "Generates a Storybook configuration for a Remix application", + "file": "generated/packages/remix/generators/storybook-configuration.json", + "hidden": false, + "name": "storybook-configuration", + "originalFilePath": "/packages/remix/src/generators/storybook-configuration/schema.json", + "path": "remix/generators/storybook-configuration", + "type": "generator" + }, + { + "description": "Add a meta function to an existing route", + "file": "generated/packages/remix/generators/meta.json", + "hidden": false, + "name": "meta", + "originalFilePath": "/packages/remix/src/generators/meta/schema.json", + "path": "remix/generators/meta", + "type": "generator" + }, + { + "description": "Add an ErrorBoundary to an existing route", + "file": "generated/packages/remix/generators/error-boundary.json", + "hidden": false, + "name": "error-boundary", + "originalFilePath": "/packages/remix/src/generators/error-boundary/schema.json", + "path": "remix/generators/error-boundary", + "type": "generator" + }, + { + "description": "Generate a project for testing Remix apps using Cypress", + "file": "generated/packages/remix/generators/cypress.json", + "hidden": false, + "name": "cypress", + "originalFilePath": "/packages/remix/src/generators/cypress/schema.json", + "path": "remix/generators/cypress", + "type": "generator" + } + ], + "githubRoot": "https://github.com/nrwl/nx/blob/master", + "name": "remix", + "packageName": "@nx/remix", + "root": "/packages/remix", + "source": "/packages/remix/src" + }, { "description": "The Nx Plugin for Rollup contains executors and generators that support building applications using Rollup.", "documents": [], diff --git a/docs/generated/packages/remix/documents/overview.md b/docs/generated/packages/remix/documents/overview.md new file mode 100644 index 0000000000..d2de7ce90b --- /dev/null +++ b/docs/generated/packages/remix/documents/overview.md @@ -0,0 +1,236 @@ +The Nx Plugin for Remix contains executors, generators, and utilities for managing Remix applications and libraries +within an Nx workspace. It provides: + +- Integration with libraries such as Storybook, Jest, Vitest and Cypress. +- Generators to help scaffold code quickly, including: + - Libraries, both internal to your codebase and publishable to npm + - Routes + - Loaders + - Actions + - Meta +- Utilities for automatic workspace refactoring. + +## Setting up the Remix plugin + +{% callout type="note" title="Keep Nx Package Versions In Sync" %} +Make sure to install the `@nx/remix` version that matches the version of `nx` in your repository. If the version +numbers get out of sync, you can encounter some difficult to debug errors. You +can [fix Nx version mismatches with this recipe](/recipes/tips-n-tricks/keep-nx-versions-in-sync). +{% /callout %} + +Adding the Remix plugin to an existing Nx workspace can be done with the following: + +```shell +yarn add -D @nx/remix +``` + +```shell +npm install -D @nx/remix +``` + +## Using the Remix Plugin + +## Generate a Remix Application + +{% callout type="note" title="Directory Flag Behavior Changes" %} +The command below uses the `as-provided` directory flag behavior, which is the default in Nx 16.8.0. If you're on an earlier version of Nx or using the `derived` option, omit the `--directory` flag. See the [as-provided vs. derived documentation](/deprecated/as-provided-vs-derived) for more details. +{% /callout %} + +```{% command="nx g @nx/remix:app myapp --directory=apps/myapp" path="~/acme" %} +> NX Generating @nx/remix:application + +✔ What unit test runner should be used? · vitest + +CREATE apps/myapp/project.json +UPDATE package.json +CREATE apps/myapp/README.md +CREATE apps/myapp/app/root.tsx +CREATE apps/myapp/app/routes/_index.tsx +CREATE apps/myapp/public/favicon.ico +CREATE apps/myapp/remix.config.js +CREATE apps/myapp/remix.env.d.ts +CREATE apps/myapp/tsconfig.json +CREATE apps/myapp/.gitignore +CREATE apps/myapp/package.json +UPDATE nx.json +CREATE tsconfig.base.json +CREATE .prettierrc +CREATE .prettierignore +UPDATE .vscode/extensions.json +CREATE apps/myapp/vite.config.ts +CREATE apps/myapp/tsconfig.spec.json +CREATE apps/myapp/test-setup.ts +CREATE apps/myapp-e2e/cypress.config.ts +CREATE apps/myapp-e2e/src/e2e/app.cy.ts +CREATE apps/myapp-e2e/src/fixtures/example.json +CREATE apps/myapp-e2e/src/support/commands.ts +CREATE apps/myapp-e2e/src/support/e2e.ts +CREATE apps/myapp-e2e/tsconfig.json +CREATE apps/myapp-e2e/project.json +CREATE .eslintrc.json +CREATE .eslintignore +CREATE apps/myapp-e2e/.eslintrc.json +``` + +## Build, Serve and Test your Application + +1. To build your application run: + +```{% command="nx build myapp" path="~/acme" %} +> nx run myapp:build + +Building Remix app in production mode... + +Built in 857ms + + —————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————— + + > NX Successfully ran target build for project myapp (3s) +``` + +2. To serve your application for use during development run: + +```{% command="nx serve myapp" path="~/acme" %} +> nx run myapp:serve + +💿 Building... +💿 Rebuilt in 377ms +Remix App Server started at http://localhost:3000 (http://192.168.0.14:3000) +``` + +3. To test the application using vitest run: + +```{% command="nx test myapp" path="~/acme" %} +> nx run myapp:test + + RUN v0.31.4 /Users/columferry/dev/nrwl/issues/remixguide/acme/apps/myapp +stderr | app/routes/index.spec.ts > test > should render +Warning: Functions are not valid as a React child. This may happen if you return a Component instead of from render. Or maybe you meant to call this function rather than return it. + ✓ app/routes/index.spec.ts (1 test) 10ms + Test Files 1 passed (1) + Tests 1 passed (1) + Start at 16:15:45 + Duration 1.20s (transform 51ms, setup 139ms, collect 180ms, tests 10ms, environment 379ms, prepare 103ms) + + —————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————— + + > NX Successfully ran target test for project myapp (2s) +``` + +## Generating an Nx Library + +When developing your application, it often makes sense to split your codebase into smaller more focused libraries. + +To generate a library to use in your Remix application run: + +```{% command="nx g @nx/remix:lib login --directory=libs/login" path="~/acme" %} +> NX Generating @nx/remix:library + +✔ What test runner should be used? · vitest +UPDATE nx.json +UPDATE package.json +CREATE babel.config.json +CREATE libs/login/project.json +CREATE libs/login/.eslintrc.json +CREATE libs/login/README.md +CREATE libs/login/src/index.ts +CREATE libs/login/tsconfig.lib.json +CREATE libs/login/tsconfig.json +CREATE libs/login/vite.config.ts +CREATE libs/login/tsconfig.spec.json +CREATE libs/login/src/lib/login.module.css +CREATE libs/login/src/lib/login.spec.tsx +CREATE libs/login/src/lib/login.tsx +UPDATE tsconfig.base.json +CREATE libs/login/src/test-setup.ts +CREATE libs/login/src/server.ts +``` + +You can then use the library by importing one of the exports into your application: + +`apps/myapp/app/routes/index.tsx` + +```tsx +import { Login } from '@acme/login'; + +export default function Index() { + return ( +
+ +
+ ); +} +``` + +You can also run test on your library: + +`nx test login` + +## Generating a Route + +To generate a route for your application: + +```{% command="nx g @nx/remix:route admin --path=apps/myapp/app/routes" path="~/acme" %} +> NX Generating @nx/remix:route + +CREATE apps/myapp/app/routes/admin.tsx +CREATE apps/myapp/app/styles/admin.css +``` + +## Using a loader from your Library + +To use a Route Loader where the logic lives in your library, follow the steps below. + +1. Generate a loader for your route: + +```{% command="nx g @nx/remix:loader admin --path=apps/myapp/app/routes" path="~/acme" %} +> NX Generating @nx/remix:loader + +UPDATE apps/myapp/app/routes/admin.tsx +``` + +2. Add a new file in your `login` lib + +`libs/login/src/lib/admin/admin.loader.ts` + +```ts +import { json, LoaderFunctionArgs } from '@remix-run/node'; + +export const adminLoader = async ({ request }: LoaderFunctionArgs) => { + return json({ + message: 'Hello, world!', + }); +}; +``` + +Export the function from the `libs/login/src/server.ts` file: + +```ts +export * from './lib/admin/admin.loader'; +``` + +3. Use the loader in your `apps/myapp/app/routes/admin.tsx` + +Replace the default loader code: + +```tsx +export const loader = async ({ request }: LoaderFunctionArgs) => { + return json({ + message: 'Hello, world!', + }); +}; +``` + +with + +```tsx +import { adminLoader } from '@acme/login/server'; + +export const loader = adminLoader; +``` + +## GitHub Repository with Example + +You can see an example of an Nx Workspace using Remix by clicking below. + +{% github-repository url="https://github.com/nrwl/nx-recipes/tree/main/remix" /%} diff --git a/docs/generated/packages/remix/executors/build.json b/docs/generated/packages/remix/executors/build.json new file mode 100644 index 0000000000..7dbc7c5f72 --- /dev/null +++ b/docs/generated/packages/remix/executors/build.json @@ -0,0 +1,48 @@ +{ + "name": "build", + "implementation": "/packages/remix/src/executors/build/build.impl.ts", + "schema": { + "version": 2, + "outputCapture": "pipe", + "$schema": "http://json-schema.org/schema", + "cli": "nx", + "title": "Remix Build", + "description": "Build a Remix app.", + "type": "object", + "properties": { + "outputPath": { + "type": "string", + "description": "The output path of the generated files.", + "x-completion-type": "directory", + "x-priority": "important" + }, + "includeDevDependenciesInPackageJson": { + "type": "boolean", + "description": "Include `devDependencies` in the generated package.json file. By default only production `dependencies` are included.", + "default": false + }, + "generatePackageJson": { + "type": "boolean", + "description": "Generate package.json file in the output folder.", + "default": false + }, + "generateLockfile": { + "type": "boolean", + "description": "Generate a lockfile (e.g. package-lock.json) that matches the workspace lockfile to ensure package versions match.", + "default": false + }, + "sourcemap": { + "type": "boolean", + "description": "Generate source maps for production.", + "default": false + } + }, + "required": ["outputPath"], + "presets": [] + }, + "description": "Build a Remix application.", + "aliases": [], + "hidden": false, + "path": "/packages/remix/src/executors/build/schema.json", + "type": "executor" +} diff --git a/docs/generated/packages/remix/executors/serve.json b/docs/generated/packages/remix/executors/serve.json new file mode 100644 index 0000000000..c86d2b2e77 --- /dev/null +++ b/docs/generated/packages/remix/executors/serve.json @@ -0,0 +1,51 @@ +{ + "name": "serve", + "implementation": "/packages/remix/src/executors/serve/serve.impl.ts", + "schema": { + "version": 2, + "outputCapture": "pipe", + "cli": "nx", + "title": "Remix Serve", + "description": "Serve a Remix app.", + "type": "object", + "properties": { + "port": { + "type": "number", + "description": "Set PORT environment variable that can be used to serve the Remix application.", + "default": 4200 + }, + "devServerPort": { + "type": "number", + "description": "Port to start the dev server on." + }, + "debug": { + "type": "boolean", + "description": "Attach a Node.js inspector.", + "default": false + }, + "command": { + "type": "string", + "description": "Command used to run your app server." + }, + "manual": { + "type": "boolean", + "description": "Enable manual mode", + "default": false + }, + "tlsKey": { + "type": "string", + "description": "Path to TLS key (key.pem)." + }, + "tlsCert": { + "type": "string", + "description": "Path to TLS certificate (cert.pem)." + } + }, + "presets": [] + }, + "description": "Serve a Remix application.", + "aliases": [], + "hidden": false, + "path": "/packages/remix/src/executors/serve/schema.json", + "type": "executor" +} diff --git a/docs/generated/packages/remix/generators/action.json b/docs/generated/packages/remix/generators/action.json new file mode 100644 index 0000000000..06b3a6a5c3 --- /dev/null +++ b/docs/generated/packages/remix/generators/action.json @@ -0,0 +1,38 @@ +{ + "name": "action", + "implementation": "/packages/remix/src/generators/action/action.impl.ts", + "schema": { + "$schema": "http://json-schema.org/schema", + "$id": "action", + "type": "object", + "description": "Generate an action for a given route.", + "properties": { + "path": { + "type": "string", + "description": "The route path or path to the filename of the route.", + "$default": { "$source": "argv", "index": 0 }, + "x-prompt": "What is the path of the route? (e.g. 'apps/demo/app/routes/foo/bar.tsx')" + }, + "nameAndDirectoryFormat": { + "description": "Whether to generate the action in the directory as provided, relative to the current working directory and ignoring the project (`as-provided`) or generate it using the project and directory relative to the workspace root (`derived`).", + "type": "string", + "enum": ["as-provided", "derived"] + }, + "project": { + "type": "string", + "description": "The name of the project.", + "$default": { "$source": "projectName" }, + "x-prompt": "What project is this route for?", + "pattern": "^[a-zA-Z].*$", + "x-deprecated": "Provide the `path` option instead and use the `as-provided` format. The project will be determined from the path provided. It will be removed in Nx v18." + } + }, + "required": ["path"], + "presets": [] + }, + "description": "Add an action function to an existing route", + "aliases": [], + "hidden": false, + "path": "/packages/remix/src/generators/action/schema.json", + "type": "generator" +} diff --git a/docs/generated/packages/remix/generators/application.json b/docs/generated/packages/remix/generators/application.json new file mode 100644 index 0000000000..0cddbd21ce --- /dev/null +++ b/docs/generated/packages/remix/generators/application.json @@ -0,0 +1,76 @@ +{ + "name": "application", + "implementation": "/packages/remix/src/generators/application/application.impl.ts", + "schema": { + "$schema": "http://json-schema.org/schema", + "$id": "NxRemixApplication", + "title": "Create an Application", + "description": "Generate a new Remix application.", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "", + "$default": { "$source": "argv", "index": 0 }, + "x-prompt": "What is the name of the application?" + }, + "js": { + "type": "boolean", + "description": "Generate JavaScript files rather than TypeScript files.", + "default": false + }, + "directory": { + "type": "string", + "description": "A directory where the app is placed.", + "alias": "dir", + "x-priority": "important" + }, + "projectNameAndRootFormat": { + "description": "Whether to generate the project name and root directory as provided (`as-provided`) or generate them composing their values and taking the configured layout into account (`derived`).", + "type": "string", + "enum": ["as-provided", "derived"] + }, + "linter": { + "description": "The tool to use for running lint checks.", + "type": "string", + "enum": ["eslint", "none"], + "default": "eslint" + }, + "unitTestRunner": { + "type": "string", + "enum": ["vitest", "jest", "none"], + "default": "vitest", + "description": "Test runner to use for unit tests.", + "x-prompt": "What unit test runner should be used?" + }, + "e2eTestRunner": { + "type": "string", + "enum": ["cypress", "none"], + "default": "cypress", + "description": "Test runner to use for e2e tests" + }, + "tags": { + "type": "string", + "description": "Add tags to the project (used for linting)", + "alias": "t" + }, + "skipFormat": { + "type": "boolean", + "description": "Skip formatting files", + "default": false + }, + "rootProject": { + "type": "boolean", + "x-priority": "internal", + "default": false + } + }, + "presets": [] + }, + "description": "Generate a new Remix application", + "aliases": ["app"], + "x-type": "application", + "hidden": false, + "path": "/packages/remix/src/generators/application/schema.json", + "type": "generator" +} diff --git a/docs/generated/packages/remix/generators/cypress-component-configuration.json b/docs/generated/packages/remix/generators/cypress-component-configuration.json new file mode 100644 index 0000000000..9f676dbbc5 --- /dev/null +++ b/docs/generated/packages/remix/generators/cypress-component-configuration.json @@ -0,0 +1,51 @@ +{ + "name": "cypress-component-configuration", + "implementation": "/packages/remix/src/generators/cypress-component-configuration/cypress-component-configuration.impl.ts", + "schema": { + "$schema": "https://json-schema.org/schema", + "cli": "nx", + "$id": "NxRemixCypressComponentTestConfiguration", + "title": "Add Cypress component testing", + "description": "Add a Cypress component testing configuration to an existing project.", + "type": "object", + "examples": [ + { + "command": "nx g @nx/remix:cypress-component-configuration --project=my-remix-project", + "description": "Add component testing to your Remix project" + }, + { + "command": "nx g @nx/remix:cypress-component-configuration --project=my-remix-project --generate-tests", + "description": "Add component testing to your Remix project and generate component tests for your existing components" + } + ], + "properties": { + "project": { + "type": "string", + "description": "The name of the project to add cypress component testing configuration to", + "x-dropdown": "projects", + "x-prompt": "What project should we add Cypress component testing to?", + "x-priority": "important" + }, + "generateTests": { + "type": "boolean", + "description": "Generate default component tests for existing components in the project", + "x-prompt": "Automatically generate tests for components declared in this project?", + "default": false, + "x-priority": "important" + }, + "skipFormat": { + "type": "boolean", + "description": "Skip formatting files", + "default": false, + "x-priority": "internal" + } + }, + "required": ["project"], + "presets": [] + }, + "description": "Generate a Cypress Component Testing configuration for a Remix project", + "aliases": [], + "hidden": false, + "path": "/packages/remix/src/generators/cypress-component-configuration/schema.json", + "type": "generator" +} diff --git a/docs/generated/packages/remix/generators/cypress.json b/docs/generated/packages/remix/generators/cypress.json new file mode 100644 index 0000000000..d70e3f8f72 --- /dev/null +++ b/docs/generated/packages/remix/generators/cypress.json @@ -0,0 +1,66 @@ +{ + "name": "cypress", + "implementation": "/packages/remix/src/generators/cypress/cypress.impl.ts", + "schema": { + "$schema": "http://json-schema.org/schema", + "$id": "NxRemixCypress", + "title": "", + "type": "object", + "description": "Generate a Cypress e2e project for a given application.", + "properties": { + "project": { + "type": "string", + "description": "The name of the frontend project to test.", + "$default": { "$source": "projectName" } + }, + "projectNameAndRootFormat": { + "description": "Whether to generate the project name and root directory as provided (`as-provided`) or generate them composing their values and taking the configured layout into account (`derived`).", + "type": "string", + "enum": ["as-provided", "derived"] + }, + "baseUrl": { + "type": "string", + "description": "URL to access the application on", + "default": "http://localhost:3000" + }, + "name": { + "type": "string", + "description": "Name of the E2E Project", + "$default": { "$source": "argv", "index": 0 }, + "x-prompt": "What name would you like to use for the e2e project?" + }, + "directory": { + "type": "string", + "description": "A directory where the project is placed" + }, + "linter": { + "description": "The tool to use for running lint checks.", + "type": "string", + "enum": ["eslint", "none"], + "default": "eslint" + }, + "js": { + "description": "Generate JavaScript files rather than TypeScript files", + "type": "boolean", + "default": false + }, + "skipFormat": { + "description": "Skip formatting files", + "type": "boolean", + "default": false + }, + "setParserOptionsProject": { + "type": "boolean", + "description": "Whether or not to configure the ESLint \"parserOptions.project\" option. We do not do this by default for lint performance reasons.", + "default": false + } + }, + "required": ["name"], + "presets": [] + }, + "description": "Generate a project for testing Remix apps using Cypress", + "aliases": [], + "hidden": false, + "path": "/packages/remix/src/generators/cypress/schema.json", + "type": "generator" +} diff --git a/docs/generated/packages/remix/generators/error-boundary.json b/docs/generated/packages/remix/generators/error-boundary.json new file mode 100644 index 0000000000..c52625de6e --- /dev/null +++ b/docs/generated/packages/remix/generators/error-boundary.json @@ -0,0 +1,49 @@ +{ + "name": "error-boundary", + "implementation": "/packages/remix/src/generators/error-boundary/error-boundary.impl.ts", + "schema": { + "$schema": "http://json-schema.org/schema", + "$id": "NxRemixErrorBoundary", + "title": "Create an ErrorBoundary for a Route", + "description": "Generate an ErrorBoundary for a given route.", + "type": "object", + "examples": [ + { + "command": "g error-boundary --routePath=apps/demo/app/routes/my-route.tsx", + "description": "Generate an ErrorBoundary for my-route.tsx" + } + ], + "properties": { + "path": { + "type": "string", + "description": "The path to route file relative to the project root." + }, + "nameAndDirectoryFormat": { + "description": "Whether to generate the error boundary in the path as provided, relative to the current working directory and ignoring the project (`as-provided`) or generate it using the project and directory relative to the workspace root (`derived`).", + "type": "string", + "enum": ["as-provided", "derived"] + }, + "project": { + "type": "string", + "description": "The name of the project.", + "$default": { "$source": "projectName" }, + "x-prompt": "What project contains the route file that this ErrorBoundary is for?", + "pattern": "^[a-zA-Z].*$", + "x-deprecated": "Provide the `path` option instead and use the `as-provided` format. The project will be determined from the path provided. It will be removed in Nx v18." + }, + "skipFormat": { + "type": "boolean", + "description": "Skip formatting files after generation.", + "default": false, + "x-priority": "internal" + } + }, + "required": ["path"], + "presets": [] + }, + "description": "Add an ErrorBoundary to an existing route", + "aliases": [], + "hidden": false, + "path": "/packages/remix/src/generators/error-boundary/schema.json", + "type": "generator" +} diff --git a/docs/generated/packages/remix/generators/library.json b/docs/generated/packages/remix/generators/library.json new file mode 100644 index 0000000000..23dbd2f5b6 --- /dev/null +++ b/docs/generated/packages/remix/generators/library.json @@ -0,0 +1,82 @@ +{ + "name": "library", + "implementation": "/packages/remix/src/generators/library/library.impl.ts", + "schema": { + "$schema": "http://json-schema.org/schema", + "$id": "NxRemixLibrary", + "title": "Create a Library", + "description": "Generate a Remix library to help structure workspace and application.", + "type": "object", + "examples": [ + { + "command": "g lib mylib --directory=myapp", + "description": "Generate libs/myapp/mylib" + } + ], + "properties": { + "name": { + "type": "string", + "description": "Library name", + "$default": { "$source": "argv", "index": 0 }, + "x-prompt": "What name would you like to use for the library?", + "pattern": "^[a-zA-Z].*$" + }, + "directory": { + "type": "string", + "description": "A directory where the lib is placed.", + "alias": "dir", + "x-priority": "important" + }, + "projectNameAndRootFormat": { + "description": "Whether to generate the project name and root directory as provided (`as-provided`) or generate them composing their values and taking the configured layout into account (`derived`).", + "type": "string", + "enum": ["as-provided", "derived"] + }, + "tags": { + "type": "string", + "description": "Add tags to the library (used for linting)" + }, + "style": { + "type": "string", + "description": "Generate a stylesheet", + "enum": ["none", "css"], + "default": "css" + }, + "buildable": { + "type": "boolean", + "description": "Should the library be buildable?", + "default": false + }, + "unitTestRunner": { + "type": "string", + "enum": ["jest", "vitest", "none"], + "description": "Test Runner to use for Unit Tests", + "x-prompt": "What test runner should be used?", + "default": "vitest" + }, + "importPath": { + "type": "string", + "description": "The library name used to import it, like @myorg/my-awesome-lib" + }, + "js": { + "type": "boolean", + "description": "Generate JavaScript files rather than TypeScript files", + "default": false + }, + "skipFormat": { + "type": "boolean", + "description": "Skip formatting files after generator runs", + "default": false, + "x-priority": "internal" + } + }, + "required": ["name"], + "presets": [] + }, + "description": "Generate a new library", + "aliases": ["lib"], + "x-type": "library", + "hidden": false, + "path": "/packages/remix/src/generators/library/schema.json", + "type": "generator" +} diff --git a/docs/generated/packages/remix/generators/loader.json b/docs/generated/packages/remix/generators/loader.json new file mode 100644 index 0000000000..6e004cb692 --- /dev/null +++ b/docs/generated/packages/remix/generators/loader.json @@ -0,0 +1,38 @@ +{ + "name": "loader", + "implementation": "/packages/remix/src/generators/loader/loader.impl.ts", + "schema": { + "$schema": "http://json-schema.org/schema", + "$id": "data-loader", + "type": "object", + "description": "Generate an loader for a given route.", + "properties": { + "path": { + "type": "string", + "description": "The route path or path to the filename of the route.", + "$default": { "$source": "argv", "index": 0 }, + "x-prompt": "What is the path of the route? (e.g. 'apps/demo/app/routes/foo/bar.tsx')" + }, + "nameAndDirectoryFormat": { + "description": "Whether to generate the loader in the path as provided, relative to the current working directory and ignoring the project (`as-provided`) or generate it using the project and directory relative to the workspace root (`derived`).", + "type": "string", + "enum": ["as-provided", "derived"] + }, + "project": { + "type": "string", + "description": "The name of the project.", + "$default": { "$source": "projectName" }, + "x-prompt": "What project is this route for?", + "pattern": "^[a-zA-Z].*$", + "x-deprecated": "Provide the `path` option instead and use the `as-provided` format. The project will be determined from the path provided. It will be removed in Nx v18." + } + }, + "required": ["path"], + "presets": [] + }, + "description": "Add a loader function to an existing route", + "aliases": [], + "hidden": false, + "path": "/packages/remix/src/generators/loader/schema.json", + "type": "generator" +} diff --git a/docs/generated/packages/remix/generators/meta.json b/docs/generated/packages/remix/generators/meta.json new file mode 100644 index 0000000000..509a608a44 --- /dev/null +++ b/docs/generated/packages/remix/generators/meta.json @@ -0,0 +1,38 @@ +{ + "name": "meta", + "implementation": "/packages/remix/src/generators/meta/meta.impl.ts", + "schema": { + "$schema": "http://json-schema.org/schema", + "$id": "meta", + "type": "object", + "description": "Generate a meta function for a given route.", + "properties": { + "path": { + "type": "string", + "description": "The route path or path to the filename of the route.", + "$default": { "$source": "argv", "index": 0 }, + "x-prompt": "What is the path of the route? (e.g. 'apps/demo/app/routes/foo/bar.tsx')" + }, + "nameAndDirectoryFormat": { + "description": "Whether to generate the meta function in the path as provided, relative to the current working directory and ignoring the project (`as-provided`) or generate it using the project and directory relative to the workspace root (`derived`).", + "type": "string", + "enum": ["as-provided", "derived"] + }, + "project": { + "type": "string", + "description": "The name of the project.", + "$default": { "$source": "projectName" }, + "x-prompt": "What project is this route for?", + "pattern": "^[a-zA-Z].*$", + "x-deprecated": "Provide the `path` option instead and use the `as-provided` format. The project will be determined from the path provided. It will be removed in Nx v18." + } + }, + "required": ["path"], + "presets": [] + }, + "description": "Add a meta function to an existing route", + "aliases": [], + "hidden": false, + "path": "/packages/remix/src/generators/meta/schema.json", + "type": "generator" +} diff --git a/docs/generated/packages/remix/generators/preset.json b/docs/generated/packages/remix/generators/preset.json new file mode 100644 index 0000000000..e04e5842e0 --- /dev/null +++ b/docs/generated/packages/remix/generators/preset.json @@ -0,0 +1,24 @@ +{ + "name": "preset", + "implementation": "/packages/remix/src/generators/preset/preset.impl.ts", + "schema": { + "$schema": "http://json-schema.org/schema", + "$id": "Remix", + "title": "", + "type": "object", + "description": "Generate a Remix application in a standalone workspace. Can be used with `create-nx-workspace --preset=@nx/remix`.", + "properties": { + "tags": { + "type": "string", + "description": "Add tags to the app (used for linting).", + "alias": "t" + } + }, + "presets": [] + }, + "description": "Generate a new Remix workspace", + "hidden": true, + "aliases": [], + "path": "/packages/remix/src/generators/preset/schema.json", + "type": "generator" +} diff --git a/docs/generated/packages/remix/generators/resource-route.json b/docs/generated/packages/remix/generators/resource-route.json new file mode 100644 index 0000000000..94c21fde3c --- /dev/null +++ b/docs/generated/packages/remix/generators/resource-route.json @@ -0,0 +1,60 @@ +{ + "name": "resource-route", + "implementation": "/packages/remix/src/generators/resource-route/resource-route.impl.ts", + "schema": { + "$schema": "http://json-schema.org/schema", + "$id": "NxRemixResourceRoute", + "title": "Create a Resource Route", + "type": "object", + "description": "Generate a resource route.", + "examples": [ + { + "command": "g resource-route 'path/to/page'", + "description": "Generate resource route at /path/to/page" + } + ], + "properties": { + "path": { + "type": "string", + "description": "The route path or path to the filename of the route.", + "$default": { "$source": "argv", "index": 0 }, + "x-prompt": "What is the path of the route? (e.g. 'apps/demo/app/routes/foo/bar')" + }, + "nameAndDirectoryFormat": { + "description": "Whether to generate the styles in the path as provided, relative to the current working directory and ignoring the project (`as-provided`) or generate it using the project and directory relative to the workspace root (`derived`).", + "type": "string", + "enum": ["as-provided", "derived"] + }, + "project": { + "type": "string", + "description": "The name of the project.", + "$default": { "$source": "projectName" }, + "x-prompt": "What project is this route for?", + "pattern": "^[a-zA-Z].*$", + "x-deprecated": "Provide the `path` option instead and use the `as-provided` format. The project will be determined from the path provided. It will be removed in Nx v18." + }, + "action": { + "type": "boolean", + "description": "Generate an action function", + "default": false + }, + "loader": { + "type": "boolean", + "description": "Generate a loader function", + "default": true + }, + "skipChecks": { + "type": "boolean", + "description": "Skip route error detection", + "default": false + } + }, + "required": ["path"], + "presets": [] + }, + "description": "Generate a new resource route", + "aliases": [], + "hidden": false, + "path": "/packages/remix/src/generators/resource-route/schema.json", + "type": "generator" +} diff --git a/docs/generated/packages/remix/generators/route.json b/docs/generated/packages/remix/generators/route.json new file mode 100644 index 0000000000..1a49403569 --- /dev/null +++ b/docs/generated/packages/remix/generators/route.json @@ -0,0 +1,71 @@ +{ + "name": "route", + "implementation": "/packages/remix/src/generators/route/route.impl.ts", + "schema": { + "$schema": "http://json-schema.org/schema", + "$id": "NxRemixRoute", + "title": "Create a Route", + "description": "Generate a route.", + "type": "object", + "examples": [ + { + "command": "g route 'path/to/page'", + "description": "Generate route at /path/to/page" + } + ], + "properties": { + "path": { + "type": "string", + "description": "The route path or path to the filename of the route. When `--nameAndDirectoryFormat=as-provided`, it will be relative to the current working directory. Otherwise, it will be relative to the workspace root.", + "$default": { "$source": "argv", "index": 0 }, + "x-prompt": "What is the path of the route? (e.g. 'apps/demo/app/routes/foo/bar')" + }, + "nameAndDirectoryFormat": { + "description": "Whether to generate the route in the path as provided, relative to the current working directory and ignoring the project (`as-provided`) or generate it using the project and path relative to the workspace root (`derived`).", + "type": "string", + "enum": ["as-provided", "derived"] + }, + "project": { + "type": "string", + "description": "The name of the project.", + "$default": { "$source": "projectName" }, + "x-prompt": "What project is this route for?", + "pattern": "^[a-zA-Z].*$", + "x-deprecated": "Provide the `path` option instead and use the `as-provided` format. The project will be determined from the path provided. It will be removed in Nx v18." + }, + "style": { + "type": "string", + "description": "Generate a stylesheet", + "enum": ["none", "css"], + "default": "css" + }, + "meta": { + "type": "boolean", + "description": "Generate a meta function", + "default": false + }, + "action": { + "type": "boolean", + "description": "Generate an action function", + "default": false + }, + "loader": { + "type": "boolean", + "description": "Generate a loader function", + "default": false + }, + "skipChecks": { + "type": "boolean", + "description": "Skip route error detection", + "default": false + } + }, + "required": ["path"], + "presets": [] + }, + "description": "Generate a new route", + "aliases": [], + "hidden": false, + "path": "/packages/remix/src/generators/route/schema.json", + "type": "generator" +} diff --git a/docs/generated/packages/remix/generators/setup-tailwind.json b/docs/generated/packages/remix/generators/setup-tailwind.json new file mode 100644 index 0000000000..d117412836 --- /dev/null +++ b/docs/generated/packages/remix/generators/setup-tailwind.json @@ -0,0 +1,44 @@ +{ + "name": "setup-tailwind", + "implementation": "/packages/remix/src/generators/setup-tailwind/setup-tailwind.impl.ts", + "schema": { + "$schema": "http://json-schema.org/schema", + "$id": "NxRemixTailwind", + "title": "Add TailwindCSS to a Remix App", + "description": "Setup tailwindcss for a given project.", + "type": "object", + "examples": [ + { + "command": "g setup-tailwind --project=myapp", + "description": "Generate a TailwindCSS config for your Remix app" + } + ], + "properties": { + "project": { + "type": "string", + "description": "The name of the project to add tailwind to", + "$default": { "$source": "projectName" }, + "x-prompt": "What project would you like to add Tailwind to?", + "pattern": "^[a-zA-Z].*$" + }, + "js": { + "type": "boolean", + "description": "Generate a JavaScript config file instead of a TypeScript config file", + "default": false + }, + "skipFormat": { + "type": "boolean", + "description": "Skip formatting files after generator runs", + "default": false, + "x-priority": "internal" + } + }, + "required": ["project"], + "presets": [] + }, + "description": "Generates a TailwindCSS configuration for the Remix application", + "aliases": [], + "hidden": false, + "path": "/packages/remix/src/generators/setup-tailwind/schema.json", + "type": "generator" +} diff --git a/docs/generated/packages/remix/generators/setup.json b/docs/generated/packages/remix/generators/setup.json new file mode 100644 index 0000000000..2f1a6c7fd7 --- /dev/null +++ b/docs/generated/packages/remix/generators/setup.json @@ -0,0 +1,24 @@ +{ + "name": "setup", + "implementation": "/packages/remix/src/generators/setup/setup.impl.ts", + "schema": { + "$schema": "http://json-schema.org/schema", + "$id": "NxRemixSetup", + "title": "", + "type": "object", + "description": "Generate initial files required for Remix to work within the workspace.", + "properties": { + "packageManager": { + "type": "string", + "description": "The package manager to setup for", + "enum": ["yarn", "npm", "pnpm"] + } + }, + "presets": [] + }, + "description": "Setup a Remix in an existing workspace", + "hidden": true, + "aliases": [], + "path": "/packages/remix/src/generators/setup/schema.json", + "type": "generator" +} diff --git a/docs/generated/packages/remix/generators/storybook-configuration.json b/docs/generated/packages/remix/generators/storybook-configuration.json new file mode 100644 index 0000000000..a379d7b903 --- /dev/null +++ b/docs/generated/packages/remix/generators/storybook-configuration.json @@ -0,0 +1,93 @@ +{ + "name": "storybook-configuration", + "implementation": "/packages/remix/src/generators/storybook-configuration/storybook-configuration.impl.ts", + "schema": { + "$schema": "http://json-schema.org/schema", + "cli": "nx", + "$id": "NxRemixStorybookConfigure", + "title": "Remix Storybook Configuration", + "description": "Set up Storybook for a Remix library.", + "type": "object", + "properties": { + "project": { + "type": "string", + "aliases": ["name", "projectName"], + "description": "Project for which to generate Storybook configuration.", + "$default": { "$source": "argv", "index": 0 }, + "x-prompt": "For which project do you want to generate Storybook configuration?", + "x-dropdown": "projects", + "x-priority": "important" + }, + "configureCypress": { + "type": "boolean", + "description": "Run the cypress-configure generator.", + "x-prompt": "Configure a cypress e2e app to run against the storybook instance?", + "default": true, + "x-priority": "important" + }, + "generateStories": { + "type": "boolean", + "description": "Automatically generate `*.stories.ts` files for components declared in this project?", + "x-prompt": "Automatically generate *.stories.ts files for components declared in this project?", + "default": true, + "x-priority": "important" + }, + "generateCypressSpecs": { + "type": "boolean", + "description": "Automatically generate test files in the Cypress E2E app generated by the `cypress-configure` generator.", + "x-prompt": "Automatically generate test files in the Cypress E2E app generated by the cypress-configure generator?", + "default": true, + "x-priority": "important" + }, + "configureStaticServe": { + "type": "boolean", + "description": "Specifies whether to configure a static file server target for serving storybook. Helpful for speeding up CI build/test times.", + "x-prompt": "Configure a static file server for the storybook instance?", + "default": true, + "x-priority": "important" + }, + "cypressDirectory": { + "type": "string", + "description": "A directory where the Cypress project will be placed. Placed at the root by default." + }, + "js": { + "type": "boolean", + "description": "Generate JavaScript story files rather than TypeScript story files.", + "default": false + }, + "tsConfiguration": { + "type": "boolean", + "description": "Configure your project with TypeScript. Generate main.ts and preview.ts files, instead of main.js and preview.js.", + "default": false + }, + "linter": { + "description": "The tool to use for running lint checks.", + "type": "string", + "enum": ["eslint"], + "default": "eslint" + }, + "ignorePaths": { + "type": "array", + "description": "Paths to ignore when looking for components.", + "items": { "type": "string", "description": "Path to ignore." }, + "examples": [ + "**/**/src/**/not-stories/**", + "libs/my-lib/**/*.something.ts", + "**/**/src/**/*.other.*", + "libs/my-lib/src/not-stories/**,**/**/src/**/*.other.*,apps/my-app/**/*.something.ts" + ] + }, + "configureTestRunner": { + "type": "boolean", + "description": "Add a Storybook Test-Runner target." + } + }, + "required": ["name"], + "presets": [] + }, + "description": "Generates a Storybook configuration for a Remix application", + "aliases": [], + "hidden": false, + "path": "/packages/remix/src/generators/storybook-configuration/schema.json", + "type": "generator" +} diff --git a/docs/generated/packages/remix/generators/style.json b/docs/generated/packages/remix/generators/style.json new file mode 100644 index 0000000000..cc02d4bc3f --- /dev/null +++ b/docs/generated/packages/remix/generators/style.json @@ -0,0 +1,45 @@ +{ + "name": "style", + "implementation": "/packages/remix/src/generators/style/style.impl.ts", + "schema": { + "$schema": "http://json-schema.org/schema", + "$id": "NxRemixRouteStyle", + "title": "Add style import to a route", + "description": "Generate a style import and file for a given route.", + "type": "object", + "examples": [ + { + "command": "g style --path='apps/demo/app/routes/path/to/page.tsx'", + "description": "Generate route at apps/demo/app/routes/path/to/page.tsx" + } + ], + "properties": { + "path": { + "type": "string", + "description": "Route path", + "$default": { "$source": "argv", "index": 0 }, + "x-prompt": "What is the path of the route? (e.g. 'apps/demo/app/routes/foo/bar.tsx')" + }, + "nameAndDirectoryFormat": { + "description": "Whether to generate the styles in the path as provided, relative to the current working directory and ignoring the project (`as-provided`) or generate it using the project and directory relative to the workspace root (`derived`).", + "type": "string", + "enum": ["as-provided", "derived"] + }, + "project": { + "type": "string", + "description": "The name of the project.", + "$default": { "$source": "projectName" }, + "x-prompt": "What project is this route in?", + "pattern": "^[a-zA-Z].*$", + "x-deprecated": "Provide the `path` option instead and use the `as-provided` format. The project will be determined from the path provided. It will be removed in Nx v18." + } + }, + "required": ["path"], + "presets": [] + }, + "description": "Generates a new stylesheet and adds it to an existing route", + "aliases": [], + "hidden": false, + "path": "/packages/remix/src/generators/style/schema.json", + "type": "generator" +} diff --git a/docs/map.json b/docs/map.json index e0b3a5a5dc..3171e93b7a 100644 --- a/docs/map.json +++ b/docs/map.json @@ -2390,6 +2390,19 @@ } ] }, + { + "name": "remix", + "id": "remix", + "description": "Remix package.", + "itemList": [ + { + "id": "overview", + "name": "Overview", + "path": "/nx-api/remix", + "file": "shared/packages/remix/remix-plugin" + } + ] + }, { "name": "detox", "id": "detox", diff --git a/docs/shared/getting-started/intro.md b/docs/shared/getting-started/intro.md index d5931a9529..078111dbd4 100644 --- a/docs/shared/getting-started/intro.md +++ b/docs/shared/getting-started/intro.md @@ -62,7 +62,7 @@ npx create-nx-workspace@latest {% link-card title="Next" appearance="small" url="/nx-api/next" icon="nextjs" /%} {% link-card title="Nuxt" appearance="small" url="/showcase/example-repos/add-nuxt" icon="nuxt" /%} {% link-card title="Nest" appearance="small" url="/nx-api/nest" icon="nestjs" /%} -{% link-card title="Remix" appearance="small" url="/recipes/react/remix" icon="remix" /%} +{% link-card title="Remix" appearance="small" url="/nx-api/remix" icon="remix" /%} {% link-card title="Expo" appearance="small" url="/nx-api/expo" icon="expo" /%} {% link-card title="React Native" appearance="small" url="/nx-api/react-native" icon="react" /%} {% link-card title="Fastify" appearance="small" url="/showcase/example-repos/mongo-fastify" icon="fastify" /%} diff --git a/docs/shared/guides/remix.md b/docs/shared/guides/remix.md index a66390fea9..4768a9f68e 100644 --- a/docs/shared/guides/remix.md +++ b/docs/shared/guides/remix.md @@ -28,7 +28,7 @@ In this recipe, we'll show you how to create a [Remix](https://remix.run) applic ## Install Nx Remix Plugin {% callout type="note" title="Keep Nx Package Versions In Sync" %} -Make sure to install the `@nx/remix` version that is on the same minor version as the `nx` version in your repository. If the version numbers get out of sync, you can encounter some difficult to debug errors. You can [fix Nx version mismatches with this recipe](/recipes/tips-n-tricks/keep-nx-versions-in-sync). The `@nx/remix` package is still being developed under [nx-labs](https://github.com/nrwl/nx-labs), so the publishing cadence is not perfectly coordinated with the other Nx packages. +Make sure to install the `@nx/remix` version that is on the same minor version as the `nx` version in your repository. If the version numbers get out of sync, you can encounter some difficult to debug errors. You can [fix Nx version mismatches with this recipe](/recipes/tips-n-tricks/keep-nx-versions-in-sync). {% /callout %} ```shell @@ -50,7 +50,7 @@ CREATE apps/myapp/project.json UPDATE package.json CREATE apps/myapp/README.md CREATE apps/myapp/app/root.tsx -CREATE apps/myapp/app/routes/index.tsx +CREATE apps/myapp/app/routes/_index.tsx CREATE apps/myapp/public/favicon.ico CREATE apps/myapp/remix.config.js CREATE apps/myapp/remix.env.d.ts @@ -175,7 +175,7 @@ You can also run test on your library: To generate a route for your application: -```{% command="nx g @nx/remix:route admin --project=myapp" path="~/acme" %} +```{% command="nx g @nx/remix:route admin --path=apps/myapp/app/routes" path="~/acme" %} > NX Generating @nx/remix:route CREATE apps/myapp/app/routes/admin.tsx @@ -188,7 +188,7 @@ To use a Route Loader where the logic lives in your library, follow the steps be 1. Generate a loader for your route: -```{% command="nx g @nx/remix:loader admin --project=myapp" path="~/acme" %} +```{% command="nx g @nx/remix:loader admin --path=apps/myapp/app/routes" path="~/acme" %} > NX Generating @nx/remix:loader UPDATE apps/myapp/app/routes/admin.tsx @@ -199,9 +199,9 @@ UPDATE apps/myapp/app/routes/admin.tsx `libs/login/src/lib/admin/admin.loader.ts` ```ts -import { json, LoaderArgs } from '@remix-run/node'; +import { json, LoaderFunctionArgs } from '@remix-run/node'; -export const adminLoader = async ({ request }: LoaderArgs) => { +export const adminLoader = async ({ request }: LoaderFunctionArgs) => { return json({ message: 'Hello, world!', }); @@ -219,7 +219,7 @@ export * from './lib/admin/admin.loader'; Replace the default loader code: ```tsx -export const loader = async ({ request }: LoaderArgs) => { +export const loader = async ({ request }: LoaderFunctionArgs) => { return json({ message: 'Hello, world!', }); diff --git a/docs/shared/packages/remix/remix-plugin.md b/docs/shared/packages/remix/remix-plugin.md new file mode 100644 index 0000000000..d2de7ce90b --- /dev/null +++ b/docs/shared/packages/remix/remix-plugin.md @@ -0,0 +1,236 @@ +The Nx Plugin for Remix contains executors, generators, and utilities for managing Remix applications and libraries +within an Nx workspace. It provides: + +- Integration with libraries such as Storybook, Jest, Vitest and Cypress. +- Generators to help scaffold code quickly, including: + - Libraries, both internal to your codebase and publishable to npm + - Routes + - Loaders + - Actions + - Meta +- Utilities for automatic workspace refactoring. + +## Setting up the Remix plugin + +{% callout type="note" title="Keep Nx Package Versions In Sync" %} +Make sure to install the `@nx/remix` version that matches the version of `nx` in your repository. If the version +numbers get out of sync, you can encounter some difficult to debug errors. You +can [fix Nx version mismatches with this recipe](/recipes/tips-n-tricks/keep-nx-versions-in-sync). +{% /callout %} + +Adding the Remix plugin to an existing Nx workspace can be done with the following: + +```shell +yarn add -D @nx/remix +``` + +```shell +npm install -D @nx/remix +``` + +## Using the Remix Plugin + +## Generate a Remix Application + +{% callout type="note" title="Directory Flag Behavior Changes" %} +The command below uses the `as-provided` directory flag behavior, which is the default in Nx 16.8.0. If you're on an earlier version of Nx or using the `derived` option, omit the `--directory` flag. See the [as-provided vs. derived documentation](/deprecated/as-provided-vs-derived) for more details. +{% /callout %} + +```{% command="nx g @nx/remix:app myapp --directory=apps/myapp" path="~/acme" %} +> NX Generating @nx/remix:application + +✔ What unit test runner should be used? · vitest + +CREATE apps/myapp/project.json +UPDATE package.json +CREATE apps/myapp/README.md +CREATE apps/myapp/app/root.tsx +CREATE apps/myapp/app/routes/_index.tsx +CREATE apps/myapp/public/favicon.ico +CREATE apps/myapp/remix.config.js +CREATE apps/myapp/remix.env.d.ts +CREATE apps/myapp/tsconfig.json +CREATE apps/myapp/.gitignore +CREATE apps/myapp/package.json +UPDATE nx.json +CREATE tsconfig.base.json +CREATE .prettierrc +CREATE .prettierignore +UPDATE .vscode/extensions.json +CREATE apps/myapp/vite.config.ts +CREATE apps/myapp/tsconfig.spec.json +CREATE apps/myapp/test-setup.ts +CREATE apps/myapp-e2e/cypress.config.ts +CREATE apps/myapp-e2e/src/e2e/app.cy.ts +CREATE apps/myapp-e2e/src/fixtures/example.json +CREATE apps/myapp-e2e/src/support/commands.ts +CREATE apps/myapp-e2e/src/support/e2e.ts +CREATE apps/myapp-e2e/tsconfig.json +CREATE apps/myapp-e2e/project.json +CREATE .eslintrc.json +CREATE .eslintignore +CREATE apps/myapp-e2e/.eslintrc.json +``` + +## Build, Serve and Test your Application + +1. To build your application run: + +```{% command="nx build myapp" path="~/acme" %} +> nx run myapp:build + +Building Remix app in production mode... + +Built in 857ms + + —————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————— + + > NX Successfully ran target build for project myapp (3s) +``` + +2. To serve your application for use during development run: + +```{% command="nx serve myapp" path="~/acme" %} +> nx run myapp:serve + +💿 Building... +💿 Rebuilt in 377ms +Remix App Server started at http://localhost:3000 (http://192.168.0.14:3000) +``` + +3. To test the application using vitest run: + +```{% command="nx test myapp" path="~/acme" %} +> nx run myapp:test + + RUN v0.31.4 /Users/columferry/dev/nrwl/issues/remixguide/acme/apps/myapp +stderr | app/routes/index.spec.ts > test > should render +Warning: Functions are not valid as a React child. This may happen if you return a Component instead of from render. Or maybe you meant to call this function rather than return it. + ✓ app/routes/index.spec.ts (1 test) 10ms + Test Files 1 passed (1) + Tests 1 passed (1) + Start at 16:15:45 + Duration 1.20s (transform 51ms, setup 139ms, collect 180ms, tests 10ms, environment 379ms, prepare 103ms) + + —————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————— + + > NX Successfully ran target test for project myapp (2s) +``` + +## Generating an Nx Library + +When developing your application, it often makes sense to split your codebase into smaller more focused libraries. + +To generate a library to use in your Remix application run: + +```{% command="nx g @nx/remix:lib login --directory=libs/login" path="~/acme" %} +> NX Generating @nx/remix:library + +✔ What test runner should be used? · vitest +UPDATE nx.json +UPDATE package.json +CREATE babel.config.json +CREATE libs/login/project.json +CREATE libs/login/.eslintrc.json +CREATE libs/login/README.md +CREATE libs/login/src/index.ts +CREATE libs/login/tsconfig.lib.json +CREATE libs/login/tsconfig.json +CREATE libs/login/vite.config.ts +CREATE libs/login/tsconfig.spec.json +CREATE libs/login/src/lib/login.module.css +CREATE libs/login/src/lib/login.spec.tsx +CREATE libs/login/src/lib/login.tsx +UPDATE tsconfig.base.json +CREATE libs/login/src/test-setup.ts +CREATE libs/login/src/server.ts +``` + +You can then use the library by importing one of the exports into your application: + +`apps/myapp/app/routes/index.tsx` + +```tsx +import { Login } from '@acme/login'; + +export default function Index() { + return ( +
+ +
+ ); +} +``` + +You can also run test on your library: + +`nx test login` + +## Generating a Route + +To generate a route for your application: + +```{% command="nx g @nx/remix:route admin --path=apps/myapp/app/routes" path="~/acme" %} +> NX Generating @nx/remix:route + +CREATE apps/myapp/app/routes/admin.tsx +CREATE apps/myapp/app/styles/admin.css +``` + +## Using a loader from your Library + +To use a Route Loader where the logic lives in your library, follow the steps below. + +1. Generate a loader for your route: + +```{% command="nx g @nx/remix:loader admin --path=apps/myapp/app/routes" path="~/acme" %} +> NX Generating @nx/remix:loader + +UPDATE apps/myapp/app/routes/admin.tsx +``` + +2. Add a new file in your `login` lib + +`libs/login/src/lib/admin/admin.loader.ts` + +```ts +import { json, LoaderFunctionArgs } from '@remix-run/node'; + +export const adminLoader = async ({ request }: LoaderFunctionArgs) => { + return json({ + message: 'Hello, world!', + }); +}; +``` + +Export the function from the `libs/login/src/server.ts` file: + +```ts +export * from './lib/admin/admin.loader'; +``` + +3. Use the loader in your `apps/myapp/app/routes/admin.tsx` + +Replace the default loader code: + +```tsx +export const loader = async ({ request }: LoaderFunctionArgs) => { + return json({ + message: 'Hello, world!', + }); +}; +``` + +with + +```tsx +import { adminLoader } from '@acme/login/server'; + +export const loader = adminLoader; +``` + +## GitHub Repository with Example + +You can see an example of an Nx Workspace using Remix by clicking below. + +{% github-repository url="https://github.com/nrwl/nx-recipes/tree/main/remix" /%} diff --git a/docs/shared/reference/sitemap.md b/docs/shared/reference/sitemap.md index 6a81673a35..bc6d38560a 100644 --- a/docs/shared/reference/sitemap.md +++ b/docs/shared/reference/sitemap.md @@ -612,6 +612,28 @@ - [component-story](/nx-api/react-native/generators/component-story) - [stories](/nx-api/react-native/generators/stories) - [upgrade-native](/nx-api/react-native/generators/upgrade-native) + - [remix](/nx-api/remix) + - [documents](/nx-api/remix/documents) + - [Overview](/nx-api/remix/documents/overview) + - [executors](/nx-api/remix/executors) + - [serve](/nx-api/remix/executors/serve) + - [build](/nx-api/remix/executors/build) + - [generators](/nx-api/remix/generators) + - [preset](/nx-api/remix/generators/preset) + - [setup](/nx-api/remix/generators/setup) + - [application](/nx-api/remix/generators/application) + - [cypress-component-configuration](/nx-api/remix/generators/cypress-component-configuration) + - [library](/nx-api/remix/generators/library) + - [route](/nx-api/remix/generators/route) + - [resource-route](/nx-api/remix/generators/resource-route) + - [action](/nx-api/remix/generators/action) + - [loader](/nx-api/remix/generators/loader) + - [style](/nx-api/remix/generators/style) + - [setup-tailwind](/nx-api/remix/generators/setup-tailwind) + - [storybook-configuration](/nx-api/remix/generators/storybook-configuration) + - [meta](/nx-api/remix/generators/meta) + - [error-boundary](/nx-api/remix/generators/error-boundary) + - [cypress](/nx-api/remix/generators/cypress) - [rollup](/nx-api/rollup) - [executors](/nx-api/rollup/executors) - [rollup](/nx-api/rollup/executors/rollup) diff --git a/e2e/remix/jest.config.ts b/e2e/remix/jest.config.ts new file mode 100644 index 0000000000..e2d184e435 --- /dev/null +++ b/e2e/remix/jest.config.ts @@ -0,0 +1,13 @@ +/* eslint-disable */ +export default { + displayName: 'e2e-remix', + preset: '../../jest.preset.js', + transform: { + '^.+\\.[tj]sx?$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'html'], + maxWorkers: 1, + globals: {}, + globalSetup: '../utils/global-setup.ts', + globalTeardown: '../utils/global-teardown.ts', +}; diff --git a/e2e/remix/project.json b/e2e/remix/project.json new file mode 100644 index 0000000000..3a583364c8 --- /dev/null +++ b/e2e/remix/project.json @@ -0,0 +1,10 @@ +{ + "name": "e2e-remix", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "e2e/remix", + "projectType": "application", + "targets": { + "e2e": {} + }, + "implicitDependencies": ["remix"] +} diff --git a/e2e/remix/tests/nx-remix.test.ts b/e2e/remix/tests/nx-remix.test.ts new file mode 100644 index 0000000000..3d14cc39b0 --- /dev/null +++ b/e2e/remix/tests/nx-remix.test.ts @@ -0,0 +1,175 @@ +import { + cleanupProject, + killPorts, + newProject, + runCLI, + checkFilesExist, + readJson, + uniq, + updateFile, + runCommandAsync, +} from '@nx/e2e/utils'; + +describe('remix e2e', () => { + let proj: string; + + beforeAll(() => { + proj = newProject({ packages: ['@nx/remix'] }); + }); + + afterAll(() => { + killPorts(); + cleanupProject(); + }); + + it('should create a standalone remix app', async () => { + const appName = uniq('remix'); + runCLI(`generate @nx/remix:preset --name ${appName} --verbose`); + + // Can import using ~ alias like a normal Remix setup. + updateFile(`app/foo.ts`, `export const foo = 'foo';`); + updateFile( + `app/routes/index.tsx`, + ` + import { foo } from '~/foo'; + export default function Index() { + return ( +

{foo}

+ ); + } + ` + ); + + const result = runCLI(`build ${appName}`); + expect(result).toContain('Successfully ran target build'); + }, 120_000); + + it('should create app', async () => { + const plugin = uniq('remix'); + runCLI(`generate @nx/remix:app ${plugin}`); + + const buildResult = runCLI(`build ${plugin}`); + expect(buildResult).toContain('Successfully ran target build'); + + const testResult = runCLI(`test ${plugin}`); + expect(testResult).toContain('Successfully ran target test'); + }, 120000); + + describe('--directory', () => { + it('should create src in the specified directory --projectNameAndRootFormat=derived', async () => { + const plugin = uniq('remix'); + const appName = `sub-${plugin}`; + runCLI( + `generate @nx/remix:app ${plugin} --directory=sub --projectNameAndRootFormat=derived --rootProject=false` + ); + const project = readJson(`sub/${plugin}/project.json`); + expect(project.targets.build.options.outputPath).toEqual( + `dist/sub/${plugin}` + ); + + const result = runCLI(`build ${appName}`); + expect(result).toContain('Successfully ran target build'); + }, 120000); + + it('should create src in the specified directory --projectNameAndRootFormat=as-provided', async () => { + const plugin = uniq('remix'); + runCLI( + `generate @nx/remix:app ${plugin} --directory=subdir --projectNameAndRootFormat=as-provided --rootProject=false` + ); + const project = readJson(`subdir/project.json`); + expect(project.targets.build.options.outputPath).toEqual(`dist/subdir`); + + const result = runCLI(`build ${plugin}`); + expect(result).toContain('Successfully ran target build'); + }, 120000); + }); + + describe('--tags', () => { + it('should add tags to the project', async () => { + const plugin = uniq('remix'); + runCLI(`generate @nx/remix:app ${plugin} --tags e2etag,e2ePackage`); + const project = readJson(`${plugin}/project.json`); + expect(project.tags).toEqual(['e2etag', 'e2ePackage']); + }, 120000); + }); + + describe('--js', () => { + it('should create js app and build correctly', async () => { + const plugin = uniq('remix'); + runCLI(`generate @nx/remix:app ${plugin} --js=true`); + + const result = runCLI(`build ${plugin}`); + expect(result).toContain('Successfully ran target build'); + }, 120000); + }); + + describe('--unitTestRunner', () => { + it('should generate a library with vitest and test correctly', async () => { + const plugin = uniq('remix'); + runCLI(`generate @nx/remix:library ${plugin} --unitTestRunner=vitest`); + + const result = runCLI(`test ${plugin}`); + expect(result).toContain(`Successfully ran target test`); + }, 120_000); + }); + + describe('error checking', () => { + const plugin = uniq('remix'); + + beforeAll(async () => { + runCLI(`generate @nx/remix:app ${plugin} --tags e2etag,e2ePackage`); + }, 120000); + + it('should check for un-escaped dollar signs in routes', async () => { + await expect(async () => + runCLI( + `generate @nx/remix:route --project ${plugin} --path my.route.$withParams.tsx` + ) + ).rejects.toThrow(); + + runCLI( + `generate @nx/remix:route --project ${plugin} --path my.route.\\$withParams.tsx` + ); + + expect(() => + checkFilesExist(`${plugin}/app/routes/my.route.$withParams.tsx`) + ).not.toThrow(); + }, 120000); + + it('should pass un-escaped dollar signs in routes with skipChecks flag', async () => { + await runCommandAsync( + `someWeirdUseCase=route-segment && yarn nx generate @nx/remix:route --project ${plugin} --path my.route.$someWeirdUseCase.tsx --force` + ); + + expect(() => + checkFilesExist(`${plugin}/app/routes/my.route.route-segment.tsx`) + ).not.toThrow(); + }, 120000); + + it('should check for un-escaped dollar signs in resource routes', async () => { + await expect(async () => + runCLI( + `generate @nx/remix:resource-route --project ${plugin} --path my.route.$withParams.ts` + ) + ).rejects.toThrow(); + + runCLI( + `generate @nx/remix:resource-route --project ${plugin} --path my.route.\\$withParams.ts` + ); + + expect(() => + checkFilesExist(`${plugin}/app/routes/my.route.$withParams.ts`) + ).not.toThrow(); + }, 120000); + + it('should pass un-escaped dollar signs in resource routes with skipChecks flag', async () => { + await runCommandAsync( + `someWeirdUseCase=route-segment && yarn nx generate @nx/remix:resource-route --project ${plugin} --path my.route.$someWeirdUseCase.ts --force` + ); + + expect(() => + checkFilesExist(`${plugin}/app/routes/my.route.route-segment.ts`) + ).not.toThrow(); + }, 120000); + }); +}); diff --git a/e2e/remix/tsconfig.json b/e2e/remix/tsconfig.json new file mode 100644 index 0000000000..6d5abf8483 --- /dev/null +++ b/e2e/remix/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node", "jest"] + }, + "include": [], + "files": [], + "references": [ + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/e2e/remix/tsconfig.spec.json b/e2e/remix/tsconfig.spec.json new file mode 100644 index 0000000000..1a24bfb0a1 --- /dev/null +++ b/e2e/remix/tsconfig.spec.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "**/*.test.ts", + "**/*.spec.ts", + "**/*.spec.tsx", + "**/*.test.tsx", + "**/*.spec.js", + "**/*.test.js", + "**/*.spec.jsx", + "**/*.test.jsx", + "**/*.d.ts", + "jest.config.ts" + ] +} diff --git a/e2e/utils/create-project-utils.ts b/e2e/utils/create-project-utils.ts index b72266fa3d..4722f14af9 100644 --- a/e2e/utils/create-project-utils.ts +++ b/e2e/utils/create-project-utils.ts @@ -53,6 +53,7 @@ const nxPackages = [ `@nx/playwright`, `@nx/rollup`, `@nx/react`, + `@nx/remix`, `@nx/storybook`, `@nx/vue`, `@nx/vite`, diff --git a/nx-dev/nx-dev/public/images/icons/remix.svg b/nx-dev/nx-dev/public/images/icons/remix.svg new file mode 100644 index 0000000000..ef0f590d3e --- /dev/null +++ b/nx-dev/nx-dev/public/images/icons/remix.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/nx-dev/ui-references/src/lib/icons-map.ts b/nx-dev/ui-references/src/lib/icons-map.ts index feef0bf4cf..6f2d811b6d 100644 --- a/nx-dev/ui-references/src/lib/icons-map.ts +++ b/nx-dev/ui-references/src/lib/icons-map.ts @@ -22,6 +22,7 @@ export const iconsMap: Record = { plugin: '/images/icons/nx.svg', react: '/images/icons/react.svg', 'react-native': '/images/icons/react.svg', + remix: '/images/icons/remix.svg', rollup: '/images/icons/rollup.svg', rspack: '/images/icons/rspack.svg', storybook: '/images/icons/storybook.svg', diff --git a/package.json b/package.json index 2759f61138..1a43274ee4 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,8 @@ "@pmmmwh/react-refresh-webpack-plugin": "^0.5.7", "@pnpm/lockfile-types": "^5.0.0", "@reduxjs/toolkit": "1.9.0", + "@remix-run/dev": "^2.3.0", + "@remix-run/node": "^2.3.0", "@rollup/plugin-babel": "^5.3.0", "@rollup/plugin-commonjs": "^20.0.0", "@rollup/plugin-image": "^2.1.0", diff --git a/packages-legacy/remix/README.md b/packages-legacy/remix/README.md new file mode 100644 index 0000000000..8178d2dd5f --- /dev/null +++ b/packages-legacy/remix/README.md @@ -0,0 +1,11 @@ +## @nrwl/remix has been deprecated! + +@nrwl/remix has been deprecated in favor of [@nx/remix](https://www.npmjs.com/package/@nx/remix). Please use that instead. + +@nrwl/remix will no longer be published in Nx v17. + +

Nx - Smart, Fast and Extensible Build System

+ +# Nx: Smart, Fast and Extensible Build System + +Nx is a next generation build system with first class monorepo support and powerful integrations. diff --git a/packages-legacy/remix/generators.json b/packages-legacy/remix/generators.json new file mode 100644 index 0000000000..aa164af155 --- /dev/null +++ b/packages-legacy/remix/generators.json @@ -0,0 +1,4 @@ +{ + "extends": ["@nx/remix"], + "schematics": {} +} diff --git a/packages-legacy/remix/index.ts b/packages-legacy/remix/index.ts new file mode 100644 index 0000000000..17f4557097 --- /dev/null +++ b/packages-legacy/remix/index.ts @@ -0,0 +1 @@ +export * from '@nx/remix'; diff --git a/packages-legacy/remix/package.json b/packages-legacy/remix/package.json new file mode 100644 index 0000000000..b44d06c675 --- /dev/null +++ b/packages-legacy/remix/package.json @@ -0,0 +1,35 @@ +{ + "name": "@nrwl/remix", + "version": "0.0.1", + "description": "The Remix plugin for Nx contains executors and generators for managing Remix applications and libraries within an Nx workspace. It provides:\n\n\n- Integration with libraries such as Vitest, Jest, Cypress, and Storybook.\n\n- Generators for applications, libraries, routes, loaders, and more.\n\n- Library build support for publishing packages to npm or other registries.\n\n- Utilities for automatic workspace refactoring.", + "repository": { + "type": "git", + "url": "https://github.com/nrwl/nx.git", + "directory": "packages-legacy/remix" + }, + "keywords": [ + "Monorepo", + "Remix", + "React", + "Web", + "CLI" + ], + "author": "Victor Savkin", + "license": "MIT", + "bugs": { + "url": "https://github.com/nrwl/nx/issues" + }, + "homepage": "https://nx.dev", + "main": "index.js", + "typings": "./index.d.ts", + "generators": "./generators.json", + "dependencies": { + "@nx/remix": "file:../../packages/remix" + }, + "nx-migrations": { + "migrations": "@nx/remix/migrations.json" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages-legacy/remix/project.json b/packages-legacy/remix/project.json new file mode 100644 index 0000000000..ac8cfb00b6 --- /dev/null +++ b/packages-legacy/remix/project.json @@ -0,0 +1,38 @@ +{ + "name": "remix-legacy", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages-legacy/remix", + "projectType": "library", + "targets": { + "build": { + "outputs": ["{workspaceRoot}/build/packages/{projectName}/README.md"], + "command": "node ./scripts/copy-readme.js react-legacy" + }, + "build-base": { + "executor": "@nrwl/js:tsc", + "dependsOn": ["^build"], + "options": { + "main": "packages-legacy/remix/index.ts", + "tsConfig": "packages-legacy/remix/tsconfig.json", + "outputPath": "build/packages/remix-legacy", + "updateBuildableProjectDepsInPackageJson": false, + "assets": [ + "packages-legacy/remix/*.md", + { + "input": "packages-legacy/remix", + "glob": "**/*.json", + "ignore": ["**/tsconfig*.json", "project.json"], + "output": "/" + }, + { + "input": "packages-legacy/remix", + "glob": "**/*.d.ts", + "output": "/" + }, + "LICENSE" + ] + } + } + }, + "tags": [] +} diff --git a/packages-legacy/remix/tsconfig.json b/packages-legacy/remix/tsconfig.json new file mode 100644 index 0000000000..9cf8a29c54 --- /dev/null +++ b/packages-legacy/remix/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "declaration": true + }, + "include": ["**/*.ts"], + "files": ["index.ts"] +} diff --git a/packages/nx/package.json b/packages/nx/package.json index 7779f36a7c..2a5e2f4b6d 100644 --- a/packages/nx/package.json +++ b/packages/nx/package.json @@ -134,6 +134,8 @@ "@nrwl/react-native", "@nx/rollup", "@nrwl/rollup", + "@nx/remix", + "@nrwl/remix", "@nx/storybook", "@nrwl/storybook", "@nrwl/tao", diff --git a/packages/nx/src/utils/plugins/core-plugins.ts b/packages/nx/src/utils/plugins/core-plugins.ts index c545b76854..7bb395d4d9 100644 --- a/packages/nx/src/utils/plugins/core-plugins.ts +++ b/packages/nx/src/utils/plugins/core-plugins.ts @@ -68,6 +68,10 @@ export function fetchCorePlugins(): CorePlugin[] { name: '@nx/react-native', capabilities: 'executors,generators', }, + { + name: '@nx/remix', + capabilities: 'executors,generators', + }, { name: '@nx/rollup', capabilities: 'executors,generators', diff --git a/packages/remix/.eslintrc.json b/packages/remix/.eslintrc.json new file mode 100644 index 0000000000..590b6cf39c --- /dev/null +++ b/packages/remix/.eslintrc.json @@ -0,0 +1,92 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": { + "@typescript-eslint/no-var-requires": "off" + } + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + }, + { + "files": [ + "./package.json", + "./generators.json", + "./executors.json", + "./migrations.json" + ], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/nx-plugin-checks": "error" + } + }, + { + "files": ["./package.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": [ + "error", + { + "buildTargets": ["build-base"], + "ignoredDependencies": [ + "nx", + "eslint", + "prettier", + "typescript", + "react", + "@nx/workspace", + "fs-extra", + "@remix-run/dev", + "@nx/web", + "@nx/eslint", + // These are installed by ensurePackage so missing in package.json + "@nx/cypress", + "@nx/playwright", + "@nx/jest", + "@nx/rollup", + "@nx/storybook", + "@nx/vite", + "@nx/webpack", + // These are brought in by the webpack, rollup, or vite packages via init generators. + "@babel/preset-react", + "@phenomnomnominal/tsquery", + "@pmmmwh/react-refresh-webpack-plugin", + "@svgr/rollup", + "@rollup/plugin-url", + "@svgr/webpack", + "@swc/jest", + "babel-jest", + "babel-loader", + "babel-plugin-emotion", + "babel-plugin-styled-components", + "css-loader", + "file-loader", + "less-loader", + "react-refresh", + "rollup", + "sass", + "sass-loader", + "style-loader", + "stylus-loader", + "swc-loader", + "tsconfig-paths-webpack-plugin", + "url-loader", + "webpack", + "webpack-merge", + // used via the CT react plugin installed via vite plugin + "vite" + ] + } + ] + } + } + ] +} diff --git a/packages/remix/README.md b/packages/remix/README.md new file mode 100644 index 0000000000..9c8e787864 --- /dev/null +++ b/packages/remix/README.md @@ -0,0 +1,13 @@ +

Nx - Smart Monorepos · Fast CI

+ +{{links}} + +
+ +# Nx: Smart Monorepos · Fast CI + +Nx is a build system with built-in tooling and advanced CI capabilities. It helps you maintain and scale monorepos, both locally and on CI. + +This package is a [Remix plugin for Nx](https://nx.dev/packages/remix). + +{{content}} diff --git a/packages/remix/executors.json b/packages/remix/executors.json new file mode 100644 index 0000000000..3774ea9251 --- /dev/null +++ b/packages/remix/executors.json @@ -0,0 +1,14 @@ +{ + "executors": { + "serve": { + "implementation": "./src/executors/serve/serve.impl", + "schema": "./src/executors/serve/schema.json", + "description": "Serve a Remix application." + }, + "build": { + "implementation": "./src/executors/build/build.impl", + "schema": "./src/executors/build/schema.json", + "description": "Build a Remix application." + } + } +} diff --git a/packages/remix/generators.json b/packages/remix/generators.json new file mode 100644 index 0000000000..6e500205be --- /dev/null +++ b/packages/remix/generators.json @@ -0,0 +1,89 @@ +{ + "$schema": "http://json-schema.org/schema", + "name": "NxRemix", + "version": "0.0.1", + "extends": ["@nx/react"], + "generators": { + "preset": { + "implementation": "./src/generators/preset/preset.impl", + "schema": "./src/generators/preset/schema.json", + "description": "Generate a new Remix workspace", + "hidden": true + }, + "setup": { + "implementation": "./src/generators/setup/setup.impl", + "schema": "./src/generators/setup/schema.json", + "description": "Setup a Remix in an existing workspace", + "hidden": true + }, + "application": { + "implementation": "./src/generators/application/application.impl", + "schema": "./src/generators/application/schema.json", + "description": "Generate a new Remix application", + "aliases": ["app"], + "x-type": "application" + }, + "cypress-component-configuration": { + "implementation": "./src/generators/cypress-component-configuration/cypress-component-configuration.impl", + "schema": "./src/generators/cypress-component-configuration/schema.json", + "description": "Generate a Cypress Component Testing configuration for a Remix project" + }, + "library": { + "implementation": "./src/generators/library/library.impl", + "schema": "./src/generators/library/schema.json", + "description": "Generate a new library", + "aliases": ["lib"], + "x-type": "library" + }, + "route": { + "implementation": "./src/generators/route/route.impl", + "schema": "./src/generators/route/schema.json", + "description": "Generate a new route" + }, + "resource-route": { + "implementation": "./src/generators/resource-route/resource-route.impl", + "schema": "./src/generators/resource-route/schema.json", + "description": "Generate a new resource route" + }, + "action": { + "implementation": "./src/generators/action/action.impl", + "schema": "./src/generators/action/schema.json", + "description": "Add an action function to an existing route" + }, + "loader": { + "implementation": "./src/generators/loader/loader.impl", + "schema": "./src/generators/loader/schema.json", + "description": "Add a loader function to an existing route" + }, + "style": { + "implementation": "./src/generators/style/style.impl", + "schema": "./src/generators/style/schema.json", + "description": "Generates a new stylesheet and adds it to an existing route" + }, + "setup-tailwind": { + "implementation": "./src/generators/setup-tailwind/setup-tailwind.impl", + "schema": "./src/generators/setup-tailwind/schema.json", + "description": "Generates a TailwindCSS configuration for the Remix application" + }, + "storybook-configuration": { + "implementation": "./src/generators/storybook-configuration/storybook-configuration.impl", + "schema": "./src/generators/storybook-configuration/schema.json", + "description": "Generates a Storybook configuration for a Remix application" + }, + "meta": { + "implementation": "./src/generators/meta/meta.impl", + "schema": "./src/generators/meta/schema.json", + "description": "Add a meta function to an existing route" + }, + "error-boundary": { + "implementation": "./src/generators/error-boundary/error-boundary.impl", + "schema": "./src/generators/error-boundary/schema.json", + "description": "Add an ErrorBoundary to an existing route" + }, + "cypress": { + "implementation": "./src/generators/cypress/cypress.impl", + "schema": "./src/generators/cypress/schema.json", + "description": "Generate a project for testing Remix apps using Cypress" + } + } +} diff --git a/packages/remix/generators.ts b/packages/remix/generators.ts new file mode 100644 index 0000000000..35f74abce9 --- /dev/null +++ b/packages/remix/generators.ts @@ -0,0 +1,14 @@ +export * from './src/generators/action/action.impl'; +export * from './src/generators/application/application.impl'; +export * from './src/generators/cypress-component-configuration/cypress-component-configuration.impl'; +export * from './src/generators/cypress/cypress.impl'; +export * from './src/generators/error-boundary/error-boundary.impl'; +export * from './src/generators/library/library.impl'; +export * from './src/generators/loader/loader.impl'; +export * from './src/generators/meta/meta.impl'; +export * from './src/generators/preset/preset.impl'; +export * from './src/generators/resource-route/resource-route.impl'; +export * from './src/generators/route/route.impl'; +export * from './src/generators/setup-tailwind/setup-tailwind.impl'; +export * from './src/generators/storybook-configuration/storybook-configuration.impl'; +export * from './src/generators/style/style.impl'; diff --git a/packages/remix/index.ts b/packages/remix/index.ts new file mode 100644 index 0000000000..edcce281a9 --- /dev/null +++ b/packages/remix/index.ts @@ -0,0 +1 @@ +export { createWatchPaths } from './src/utils/create-watch-paths'; diff --git a/packages/remix/jest.config.ts b/packages/remix/jest.config.ts new file mode 100644 index 0000000000..7d88d213ae --- /dev/null +++ b/packages/remix/jest.config.ts @@ -0,0 +1,15 @@ +/* eslint-disable */ +export default { + displayName: 'remix', + preset: '../../jest.preset.js', + transform: { + '^.+\\.[tj]sx?$': [ + 'ts-jest', + { + tsconfig: '/tsconfig.spec.json', + }, + ], + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + coverageDirectory: '../../coverage/packages/remix', +}; diff --git a/packages/remix/migrations.json b/packages/remix/migrations.json new file mode 100644 index 0000000000..b41b073498 --- /dev/null +++ b/packages/remix/migrations.json @@ -0,0 +1,54 @@ +{ + "generators": {}, + "packageJsonUpdates": { + "17.2.1": { + "version": "17.2.1-beta.0", + "packages": { + "@remix-run/node": { + "version": "^2.3.0", + "alwaysAddToPackageJson": true + }, + "@remix-run/react": { + "version": "^2.3.0", + "alwaysAddToPackageJson": true + }, + "@remix-run/serve": { + "version": "^2.3.0", + "alwaysAddToPackageJson": true + }, + "@remix-run/dev": { + "version": "^2.3.0", + "alwaysAddToPackageJson": true + }, + "@remix-run/css-bundle": { + "version": "^2.3.0", + "alwaysAddToPackageJson": true + }, + "@remix-run/eslint-config": { + "version": "^2.3.0", + "alwaysAddToPackageJson": true + }, + "isbot": { + "version": "^3.6.8", + "alwaysAddToPackageJson": true + }, + "eslint": { + "version": "^8.38.0", + "alwaysAddToPackageJson": true + }, + "@testing-library/react": { + "version": "^14.1.2", + "alwaysAddToPackageJson": false + }, + "@testing-library/jest-dom": { + "version": "^6.1.4", + "alwaysAddToPackageJson": false + }, + "@testing-library/user-event": { + "version": "^14.5.1", + "alwaysAddToPackageJson": false + } + } + } + } +} diff --git a/packages/remix/nx-remix.png b/packages/remix/nx-remix.png new file mode 100644 index 0000000000..9f873e22dc Binary files /dev/null and b/packages/remix/nx-remix.png differ diff --git a/packages/remix/package.json b/packages/remix/package.json new file mode 100644 index 0000000000..a61a0a7be1 --- /dev/null +++ b/packages/remix/package.json @@ -0,0 +1,41 @@ +{ + "name": "@nx/remix", + "version": "0.0.1", + "description": "The Remix plugin for Nx contains executors and generators for managing Remix applications and libraries within an Nx workspace. It provides:\n\n\n- Integration with libraries such as Vitest, Jest, Cypress, and Storybook.\n\n- Generators for applications, libraries, routes, loaders, and more.\n\n- Library build support for publishing packages to npm or other registries.\n\n- Utilities for automatic workspace refactoring.", + "repository": { + "type": "git", + "url": "https://github.com/nrwl/nx.git", + "directory": "packages/remix" + }, + "keywords": [ + "Monorepo", + "Remix", + "React", + "Web", + "CLI" + ], + "author": "Victor Savkin", + "license": "MIT", + "bugs": { + "url": "https://github.com/nrwl/nx/issues" + }, + "homepage": "https://nx.dev", + "main": "./index.js", + "typings": "./index.d.ts", + "generators": "./generators.json", + "executors": "./executors.json", + "nx-migrations": { + "migrations": "./migrations.json" + }, + "dependencies": { + "@nx/devkit": "file:../devkit", + "@nx/js": "file:../js", + "@nx/react": "file:../react", + "tslib": "^2.3.1", + "@phenomnomnominal/tsquery": "~5.0.1" + }, + "peerDependencies": {}, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/remix/plugins/component-testing/index.ts b/packages/remix/plugins/component-testing/index.ts new file mode 100644 index 0000000000..60deef6f22 --- /dev/null +++ b/packages/remix/plugins/component-testing/index.ts @@ -0,0 +1,85 @@ +import { nxBaseCypressPreset } from '@nx/cypress/plugins/cypress-preset'; +import { joinPathFragments, workspaceRoot } from '@nx/devkit'; + +import { existsSync } from 'fs'; +import { dirname, join } from 'path'; + +type ViteDevServer = { + framework: 'react'; + bundler: 'vite'; + viteConfig?: any; +}; + +/** + * Remix nx preset for Cypress Component Testing + * + * This preset contains the base configuration + * for your component tests that nx recommends. + * including a devServer that supports nx workspaces. + * you can easily extend this within your cypress config via spreading the preset + * @example + * export default defineConfig({ + * component: { + * ...nxComponentTestingPreset(__dirname) + * // add your own config here + * } + * }) + * + * @param pathToConfig will be used for loading project options and to construct the output paths for videos and screenshots + */ +export function nxComponentTestingPreset(pathToConfig: string): { + specPattern: string; + devServer: ViteDevServer; + videosFolder: string; + screenshotsFolder: string; + chromeWebSecurity: boolean; +} { + const normalizedProjectRootPath = ['.ts', '.js'].some((ext) => + pathToConfig.endsWith(ext) + ) + ? pathToConfig + : dirname(pathToConfig); + + return { + ...nxBaseCypressPreset(pathToConfig), + specPattern: 'src/**/*.cy.{js,jsx,ts,tsx}', + devServer: { + ...({ framework: 'react', bundler: 'vite' } as const), + viteConfig: async () => { + const viteConfigPath = findViteConfig(normalizedProjectRootPath); + + const { mergeConfig, loadConfigFromFile, searchForWorkspaceRoot } = + await import('vite'); + + const resolved = await loadConfigFromFile( + { + mode: 'watch', + command: 'serve', + }, + viteConfigPath + ); + return mergeConfig(resolved.config, { + server: { + fs: { + allow: [ + searchForWorkspaceRoot(normalizedProjectRootPath), + workspaceRoot, + joinPathFragments(workspaceRoot, 'node_modules/vite'), + ], + }, + }, + }); + }, + }, + }; +} + +function findViteConfig(projectRootFullPath: string): string { + const allowsExt = ['js', 'mjs', 'ts', 'cjs', 'mts', 'cts']; + + for (const ext of allowsExt) { + if (existsSync(join(projectRootFullPath, `vite.config.${ext}`))) { + return join(projectRootFullPath, `vite.config.${ext}`); + } + } +} diff --git a/packages/remix/project.json b/packages/remix/project.json new file mode 100644 index 0000000000..18599bca43 --- /dev/null +++ b/packages/remix/project.json @@ -0,0 +1,53 @@ +{ + "name": "remix", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/remix/src", + "projectType": "library", + "targets": { + "build": { + "command": "node ./scripts/copy-readme.js remix", + "outputs": ["{workspaceRoot}/build/packages/remix"] + }, + "lint": {}, + "test": {}, + "build-base": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "build/packages/remix", + "tsConfig": "packages/remix/tsconfig.lib.json", + "packageJson": "packages/remix/package.json", + "main": "packages/remix/index.ts", + "generateExportsField": true, + "additionalEntryPoints": [ + "{projectRoot}/{executors,generators,migrations}.json", + "{projectRoot}/generators.ts" + ], + "assets": [ + "packages/remix/*.md", + { + "input": "./packages/remix/src", + "glob": "**/!(*.ts)", + "output": "./src" + }, + { + "input": "./packages/remix/src", + "glob": "**/*.d.ts", + "output": "./src" + }, + { + "input": "./packages/remix", + "glob": "**.json", + "output": ".", + "ignore": ["**/tsconfig*.json", "project.json", ".eslintrc.json"] + }, + "LICENSE" + ] + } + }, + "add-extra-dependencies": { + "command": "node ./scripts/add-dependency-to-build.js remix @nrwl/remix" + } + }, + "tags": [] +} diff --git a/packages/remix/src/executors/build/build.impl.ts b/packages/remix/src/executors/build/build.impl.ts new file mode 100644 index 0000000000..0aff8873dd --- /dev/null +++ b/packages/remix/src/executors/build/build.impl.ts @@ -0,0 +1,141 @@ +import { + detectPackageManager, + logger, + readJsonFile, + writeJsonFile, + type ExecutorContext, +} from '@nx/devkit'; +import { createLockFile, createPackageJson, getLockFileName } from '@nx/js'; +import { directoryExists } from '@nx/workspace/src/utilities/fileutils'; +import { fork } from 'child_process'; +import { copySync, mkdir, writeFileSync } from 'fs-extra'; +import { type PackageJson } from 'nx/src/utils/package-json'; +import { join } from 'path'; +import { type RemixBuildSchema } from './schema'; + +function buildRemixBuildArgs(options: RemixBuildSchema) { + const args = ['build']; + + if (options.sourcemap) { + args.push(`--sourcemap`); + } + + return args; +} + +async function runBuild( + options: RemixBuildSchema, + context: ExecutorContext +): Promise { + const projectRoot = context.projectGraph.nodes[context.projectName].data.root; + return new Promise((resolve, reject) => { + const remixBin = require.resolve('@remix-run/dev/dist/cli'); + const args = buildRemixBuildArgs(options); + const p = fork(remixBin, args, { + cwd: join(context.root, projectRoot), + stdio: 'inherit', + }); + p.on('exit', (code) => { + if (code === 0) resolve(); + else reject(); + }); + }); +} + +export default async function buildExecutor( + options: RemixBuildSchema, + context: ExecutorContext +) { + const projectRoot = context.projectGraph.nodes[context.projectName].data.root; + + try { + await runBuild(options, context); + } catch (error) { + logger.error( + `Error occurred while trying to build application. See above for more details.` + ); + return { success: false }; + } + + if (!directoryExists(options.outputPath)) { + mkdir(options.outputPath); + } + let packageJson: PackageJson; + if (options.generatePackageJson) { + packageJson = createPackageJson(context.projectName, context.projectGraph, { + target: context.targetName, + root: context.root, + isProduction: !options.includeDevDependenciesInPackageJson, // By default we remove devDependencies since this is a production build. + }); + + // Update `package.json` to reflect how users should run the build artifacts + packageJson.scripts ??= {}; + // Don't override existing custom script since project may have its own server. + if (!packageJson.scripts.start) { + packageJson.scripts['start'] = 'remix-serve ./build'; + } + + updatePackageJson(packageJson, context); + writeJsonFile(`${options.outputPath}/package.json`, packageJson); + } else { + packageJson = readJsonFile(join(projectRoot, 'package.json')); + } + + if (options.generateLockfile) { + const packageManager = detectPackageManager(context.root); + const lockFile = createLockFile( + packageJson, + context.projectGraph, + packageManager + ); + writeFileSync( + `${options.outputPath}/${getLockFileName(packageManager)}`, + lockFile, + { + encoding: 'utf-8', + } + ); + } + + // If output path is different from source path, then copy over the config and public files. + // This is the default behavior when running `nx build `. + if (options.outputPath.replace(/\/$/, '') !== projectRoot) { + copySync(join(projectRoot, 'public'), join(options.outputPath, 'public'), { + dereference: true, + }); + copySync(join(projectRoot, 'build'), join(options.outputPath, 'build'), { + dereference: true, + }); + } + + return { success: true }; +} + +function updatePackageJson(packageJson: PackageJson, context: ExecutorContext) { + if (!packageJson.scripts) { + packageJson.scripts = {}; + } + if (!packageJson.scripts.start) { + packageJson.scripts.start = 'remix-serve build'; + } + + packageJson.dependencies ??= {}; + + // These are always required for a production Remix app to run. + const requiredPackages = [ + 'react', + 'react-dom', + 'isbot', + '@remix-run/css-bundle', + '@remix-run/node', + '@remix-run/react', + '@remix-run/serve', + '@remix-run/dev', + ]; + for (const pkg of requiredPackages) { + const externalNode = context.projectGraph.externalNodes[`npm:${pkg}`]; + if (externalNode) { + packageJson.dependencies[pkg] ??= externalNode.data.version; + } + } +} diff --git a/packages/remix/src/executors/build/schema.d.ts b/packages/remix/src/executors/build/schema.d.ts new file mode 100644 index 0000000000..4002a7e166 --- /dev/null +++ b/packages/remix/src/executors/build/schema.d.ts @@ -0,0 +1,7 @@ +export interface RemixBuildSchema { + outputPath: string; + includeDevDependenciesInPackageJson?: boolean; + generatePackageJson?: boolean; + generateLockfile?: boolean; + sourcemap?: boolean; +} diff --git a/packages/remix/src/executors/build/schema.json b/packages/remix/src/executors/build/schema.json new file mode 100644 index 0000000000..cd9e3c6bb7 --- /dev/null +++ b/packages/remix/src/executors/build/schema.json @@ -0,0 +1,38 @@ +{ + "version": 2, + "outputCapture": "pipe", + "$schema": "http://json-schema.org/schema", + "cli": "nx", + "title": "Remix Build", + "description": "Build a Remix app.", + "type": "object", + "properties": { + "outputPath": { + "type": "string", + "description": "The output path of the generated files.", + "x-completion-type": "directory", + "x-priority": "important" + }, + "includeDevDependenciesInPackageJson": { + "type": "boolean", + "description": "Include `devDependencies` in the generated package.json file. By default only production `dependencies` are included.", + "default": false + }, + "generatePackageJson": { + "type": "boolean", + "description": "Generate package.json file in the output folder.", + "default": false + }, + "generateLockfile": { + "type": "boolean", + "description": "Generate a lockfile (e.g. package-lock.json) that matches the workspace lockfile to ensure package versions match.", + "default": false + }, + "sourcemap": { + "type": "boolean", + "description": "Generate source maps for production.", + "default": false + } + }, + "required": ["outputPath"] +} diff --git a/packages/remix/src/executors/serve/schema.d.ts b/packages/remix/src/executors/serve/schema.d.ts new file mode 100644 index 0000000000..61d67bd09a --- /dev/null +++ b/packages/remix/src/executors/serve/schema.d.ts @@ -0,0 +1,9 @@ +export interface RemixServeSchema { + port: number; + devServerPort?: number; + debug?: boolean; + command?: string; + manual?: boolean; + tlsKey?: string; + tlsCert?: string; +} diff --git a/packages/remix/src/executors/serve/schema.json b/packages/remix/src/executors/serve/schema.json new file mode 100644 index 0000000000..1e8eff1573 --- /dev/null +++ b/packages/remix/src/executors/serve/schema.json @@ -0,0 +1,41 @@ +{ + "version": 2, + "outputCapture": "pipe", + "cli": "nx", + "title": "Remix Serve", + "description": "Serve a Remix app.", + "type": "object", + "properties": { + "port": { + "type": "number", + "description": "Set PORT environment variable that can be used to serve the Remix application.", + "default": 4200 + }, + "devServerPort": { + "type": "number", + "description": "Port to start the dev server on." + }, + "debug": { + "type": "boolean", + "description": "Attach a Node.js inspector.", + "default": false + }, + "command": { + "type": "string", + "description": "Command used to run your app server." + }, + "manual": { + "type": "boolean", + "description": "Enable manual mode", + "default": false + }, + "tlsKey": { + "type": "string", + "description": "Path to TLS key (key.pem)." + }, + "tlsCert": { + "type": "string", + "description": "Path to TLS certificate (cert.pem)." + } + } +} diff --git a/packages/remix/src/executors/serve/serve.impl.ts b/packages/remix/src/executors/serve/serve.impl.ts new file mode 100644 index 0000000000..73429280da --- /dev/null +++ b/packages/remix/src/executors/serve/serve.impl.ts @@ -0,0 +1,95 @@ +import { workspaceRoot, type ExecutorContext } from '@nx/devkit'; +import { createAsyncIterable } from '@nx/devkit/src/utils/async-iterable'; +import { waitForPortOpen } from '@nx/web/src/utils/wait-for-port-open'; +import { fork } from 'node:child_process'; +import { join } from 'node:path'; +import { type RemixServeSchema } from './schema'; + +function normalizeOptions(schema: RemixServeSchema) { + return { + ...schema, + port: schema.port ?? 4200, + debug: schema.debug ?? false, + manual: schema.manual ?? false, + } as RemixServeSchema; +} + +function buildRemixDevArgs(options: RemixServeSchema) { + const args = []; + + if (options.command) { + args.push(`--command=${options.command}`); + } + + if (options.devServerPort) { + args.push(`--port=${options.devServerPort}`); + } + + if (options.debug) { + args.push(`--debug`); + } + + if (options.manual) { + args.push(`--manual`); + } + + if (options.tlsKey) { + args.push(`--tls-key=${options.tlsKey}`); + } + + if (options.tlsCert) { + args.push(`--tls-cert=${options.tlsCert}`); + } + + return args; +} + +export default async function* serveExecutor( + schema: RemixServeSchema, + context: ExecutorContext +) { + const options = normalizeOptions(schema); + const projectRoot = context.workspace.projects[context.projectName].root; + + const remixBin = require.resolve('@remix-run/dev/dist/cli'); + const args = buildRemixDevArgs(options); + // Cast to any to overwrite NODE_ENV + (process.env as any).NODE_ENV = process.env.NODE_ENV + ? process.env.NODE_ENV + : 'development'; + process.env.PORT = `${options.port}`; + + yield* createAsyncIterable<{ success: boolean; baseUrl: string }>( + async ({ done, next, error }) => { + const server = fork(remixBin, ['dev', ...args], { + cwd: join(workspaceRoot, projectRoot), + stdio: 'inherit', + }); + + server.once('exit', (code) => { + if (code === 0) { + done(); + } else { + error(new Error(`Remix app exited with code ${code}`)); + } + }); + + const killServer = () => { + if (server.connected) { + server.kill('SIGTERM'); + } + }; + process.on('exit', () => killServer()); + process.on('SIGINT', () => killServer()); + process.on('SIGTERM', () => killServer()); + process.on('SIGHUP', () => killServer()); + + await waitForPortOpen(options.port); + + next({ + success: true, + baseUrl: `http://localhost:${options.port}`, + }); + } + ); +} diff --git a/packages/remix/src/generators/action/action.impl.spec.ts b/packages/remix/src/generators/action/action.impl.spec.ts new file mode 100644 index 0000000000..0876f79a2c --- /dev/null +++ b/packages/remix/src/generators/action/action.impl.spec.ts @@ -0,0 +1,90 @@ +import { Tree } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import applicationGenerator from '../application/application.impl'; +import routeGenerator from '../route/route.impl'; +import actionGenerator from './action.impl'; + +describe('action', () => { + let tree: Tree; + + beforeEach(async () => { + tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + tree.write('.gitignore', `/node_modules/dist`); + + await applicationGenerator(tree, { name: 'demo' }); + await routeGenerator(tree, { + path: 'example', + project: 'demo', + style: 'none', + loader: false, + action: false, + meta: false, + skipChecks: false, + }); + }); + + [ + { + path: 'apps/demo/app/routes/example.tsx', + }, + { + path: 'example', + }, + { + path: 'example.tsx', + }, + ].forEach((config) => { + describe(`Generating action using path ${config.path}`, () => { + beforeEach(async () => { + await actionGenerator(tree, { + path: config.path, + // path: 'apps/demo/app/routes/example.tsx', + project: 'demo', + }); + }); + it('should add imports', async () => { + const content = tree.read('apps/demo/app/routes/example.tsx', 'utf-8'); + expect(content).toMatch(`import { json } from '@remix-run/node';`); + expect(content).toMatch( + `import type { ActionFunctionArgs } from '@remix-run/node';` + ); + expect(content).toMatch( + `import { useActionData } from '@remix-run/react';` + ); + }); + + it('should add action function', () => { + const actionFunction = `export const action = async ({ request }: ActionFunctionArgs)`; + const content = tree.read('apps/demo/app/routes/example.tsx', 'utf-8'); + expect(content).toMatch(actionFunction); + }); + + it('should add useActionData to component', () => { + const useActionData = `const actionMessage = useActionData();`; + + const content = tree.read('apps/demo/app/routes/example.tsx', 'utf-8'); + expect(content).toMatch(useActionData); + }); + }); + }); + + it('--nameAndDirectoryFormat=as-provided', async () => { + // ACT + await actionGenerator(tree, { + path: 'apps/demo/app/routes/example.tsx', + }); + // ASSERT + const content = tree.read('apps/demo/app/routes/example.tsx', 'utf-8'); + const useActionData = `const actionMessage = useActionData();`; + const actionFunction = `export const action = async ({ request }: ActionFunctionArgs)`; + expect(content).toMatch(`import { json } from '@remix-run/node';`); + expect(content).toMatch( + `import type { ActionFunctionArgs } from '@remix-run/node';` + ); + expect(content).toMatch( + `import { useActionData } from '@remix-run/react';` + ); + expect(content).toMatch(useActionData); + expect(content).toMatch(actionFunction); + }); +}); diff --git a/packages/remix/src/generators/action/action.impl.ts b/packages/remix/src/generators/action/action.impl.ts new file mode 100644 index 0000000000..2f5fa8b660 --- /dev/null +++ b/packages/remix/src/generators/action/action.impl.ts @@ -0,0 +1,48 @@ +import { formatFiles, Tree } from '@nx/devkit'; +import { insertImport } from '../../utils/insert-import'; +import { insertStatementAfterImports } from '../../utils/insert-statement-after-imports'; +import { insertStatementInDefaultFunction } from '../../utils/insert-statement-in-default-function'; +import { resolveRemixRouteFile } from '../../utils/remix-route-utils'; +import { LoaderSchema } from './schema'; + +export default async function (tree: Tree, schema: LoaderSchema) { + const routeFilePath = + schema.nameAndDirectoryFormat === 'as-provided' + ? schema.path + : await resolveRemixRouteFile(tree, schema.path, schema.project); + + if (!tree.exists(routeFilePath)) { + throw new Error( + `Route path does not exist: ${routeFilePath}. Please generate a Remix route first.` + ); + } + + insertImport(tree, routeFilePath, 'ActionFunctionArgs', '@remix-run/node', { + typeOnly: true, + }); + insertImport(tree, routeFilePath, 'json', '@remix-run/node'); + insertImport(tree, routeFilePath, 'useActionData', '@remix-run/react'); + + insertStatementAfterImports( + tree, + routeFilePath, + ` + export const action = async ({ request }: ActionFunctionArgs) => { + let formData = await request.formData(); + + return json({message: formData.toString()}, { status: 200 }); + }; + + ` + ); + + const statement = `\nconst actionMessage = useActionData();`; + + try { + insertStatementInDefaultFunction(tree, routeFilePath, statement); + } catch (err) { + // eslint-disable-next-line no-empty + } finally { + await formatFiles(tree); + } +} diff --git a/packages/remix/src/generators/action/schema.d.ts b/packages/remix/src/generators/action/schema.d.ts new file mode 100644 index 0000000000..9315409e92 --- /dev/null +++ b/packages/remix/src/generators/action/schema.d.ts @@ -0,0 +1,10 @@ +import { NameAndDirectoryFormat } from '@nx/devkit/src/generators/artifact-name-and-directory-utils'; + +export interface LoaderSchema { + path: string; + nameAndDirectoryFormat?: NameAndDirectoryFormat; + /** + * @deprecated Provide the `path` option instead. The project will be determined from the path provided. It will be removed in Nx v18. + */ + project?: string; +} diff --git a/packages/remix/src/generators/action/schema.json b/packages/remix/src/generators/action/schema.json new file mode 100644 index 0000000000..72aa0537d6 --- /dev/null +++ b/packages/remix/src/generators/action/schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "action", + "type": "object", + "description": "Generate an action for a given route.", + "properties": { + "path": { + "type": "string", + "description": "The route path or path to the filename of the route.", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What is the path of the route? (e.g. 'apps/demo/app/routes/foo/bar.tsx')" + }, + "nameAndDirectoryFormat": { + "description": "Whether to generate the action in the directory as provided, relative to the current working directory and ignoring the project (`as-provided`) or generate it using the project and directory relative to the workspace root (`derived`).", + "type": "string", + "enum": ["as-provided", "derived"] + }, + "project": { + "type": "string", + "description": "The name of the project.", + "$default": { + "$source": "projectName" + }, + "x-prompt": "What project is this route for?", + "pattern": "^[a-zA-Z].*$", + "x-deprecated": "Provide the `path` option instead and use the `as-provided` format. The project will be determined from the path provided. It will be removed in Nx v18." + } + }, + "required": ["path"] +} diff --git a/packages/remix/src/generators/application/__snapshots__/application.impl.spec.ts.snap b/packages/remix/src/generators/application/__snapshots__/application.impl.spec.ts.snap new file mode 100644 index 0000000000..387aa40300 --- /dev/null +++ b/packages/remix/src/generators/application/__snapshots__/application.impl.spec.ts.snap @@ -0,0 +1,1296 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=as-provided --directory should create the application correctly 1`] = ` +"/** + * @type {import('@remix-run/dev').AppConfig} + */ +module.exports = { + ignoredRouteFiles: ['**/.*'], + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // serverBuildPath: "build/index.js", + // publicPath: "/build/", + watchPaths: () => require('@nx/remix').createWatchPaths(__dirname), +}; +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=as-provided --directory should create the application correctly 2`] = ` +"import type { MetaFunction } from '@remix-run/node'; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from '@remix-run/react'; + +export const meta: MetaFunction = () => [ + { + charset: 'utf-8', + title: 'New Remix App', + viewport: 'width=device-width,initial-scale=1', + }, +]; + +export default function App() { + return ( + + + + + + + + + + + + + ); +} +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=as-provided --directory should create the application correctly 3`] = ` +"export default function Index() { + return ( + + ); +} +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=as-provided --directory should extract the layout directory from the directory options if it exists 1`] = ` +"/** + * @type {import('@remix-run/dev').AppConfig} + */ +module.exports = { + ignoredRouteFiles: ['**/.*'], + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // serverBuildPath: "build/index.js", + // publicPath: "/build/", + watchPaths: () => require('@nx/remix').createWatchPaths(__dirname), +}; +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=as-provided --directory should extract the layout directory from the directory options if it exists 2`] = ` +"import type { MetaFunction } from '@remix-run/node'; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from '@remix-run/react'; + +export const meta: MetaFunction = () => [ + { + charset: 'utf-8', + title: 'New Remix App', + viewport: 'width=device-width,initial-scale=1', + }, +]; + +export default function App() { + return ( + + + + + + + + + + + + + ); +} +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=as-provided --directory should extract the layout directory from the directory options if it exists 3`] = ` +"export default function Index() { + return ( + + ); +} +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=as-provided --e2eTestRunner should generate an e2e application for the app 1`] = ` +"import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + +import { defineConfig } from 'cypress'; + +export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { cypressDir: 'src' }), + baseUrl: 'http://localhost:4200', + }, +}); +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=as-provided --js should create the application correctly 1`] = ` +"/** + * @type {import('@remix-run/dev').AppConfig} + */ +module.exports = { + ignoredRouteFiles: ['**/.*'], + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // serverBuildPath: "build/index.js", + // publicPath: "/build/", + watchPaths: () => require('@nx/remix').createWatchPaths(__dirname), +}; +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=as-provided --js should create the application correctly 2`] = ` +"import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from '@remix-run/react'; +export const meta = () => [ + { + charset: 'utf-8', + title: 'New Remix App', + viewport: 'width=device-width,initial-scale=1', + }, +]; +export default function App() { + return ( + + + + + + + + + + + + + ); +} +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=as-provided --js should create the application correctly 3`] = ` +"export default function Index() { + return ( + + ); +} +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=as-provided --unitTestRunner should generate the correct files for testing using jest 1`] = ` +"/** + * @type {import('@remix-run/dev').AppConfig} + */ +module.exports = { + ignoredRouteFiles: ['**/.*'], + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // serverBuildPath: "build/index.js", + // publicPath: "/build/", + watchPaths: () => require('@nx/remix').createWatchPaths(__dirname), +}; +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=as-provided --unitTestRunner should generate the correct files for testing using jest 2`] = ` +"/* eslint-disable */ +export default { + setupFilesAfterEnv: ['/test-setup.ts'], + displayName: 'test', + preset: '../jest.preset.cjs', + transform: { + '^.+\\\\.[tj]sx?$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + coverageDirectory: '../coverage/test', +}; +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=as-provided --unitTestRunner should generate the correct files for testing using jest 3`] = ` +"import { installGlobals } from '@remix-run/node'; +import '@testing-library/jest-dom/matchers'; +installGlobals(); +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=as-provided --unitTestRunner should generate the correct files for testing using vitest 1`] = ` +"/** + * @type {import('@remix-run/dev').AppConfig} + */ +module.exports = { + ignoredRouteFiles: ['**/.*'], + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // serverBuildPath: "build/index.js", + // publicPath: "/build/", + watchPaths: () => require('@nx/remix').createWatchPaths(__dirname), +}; +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=as-provided --unitTestRunner should generate the correct files for testing using vitest 2`] = ` +"/// +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + root: __dirname, + cacheDir: '../node_modules/.vite/test', + + plugins: [react(), nxViteTsPaths()], + + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + + test: { + setupFiles: ['./test-setup.ts'], + globals: true, + cache: { + dir: '../node_modules/.vitest', + }, + environment: 'jsdom', + include: ['./tests/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + + reporters: ['default'], + coverage: { + reportsDirectory: '../coverage/test', + provider: 'v8', + }, + }, +}); +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=as-provided --unitTestRunner should generate the correct files for testing using vitest 3`] = ` +"import { installGlobals } from '@remix-run/node'; +import '@testing-library/jest-dom/matchers'; +installGlobals(); +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=as-provided should create the application correctly 1`] = ` +"/** + * @type {import('@remix-run/dev').AppConfig} + */ +module.exports = { + ignoredRouteFiles: ['**/.*'], + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // serverBuildPath: "build/index.js", + // publicPath: "/build/", + watchPaths: () => require('@nx/remix').createWatchPaths(__dirname), +}; +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=as-provided should create the application correctly 2`] = ` +"import type { MetaFunction } from '@remix-run/node'; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from '@remix-run/react'; + +export const meta: MetaFunction = () => [ + { + charset: 'utf-8', + title: 'New Remix App', + viewport: 'width=device-width,initial-scale=1', + }, +]; + +export default function App() { + return ( + + + + + + + + + + + + + ); +} +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=as-provided should create the application correctly 3`] = ` +"export default function Index() { + return ( + + ); +} +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=derived --directory should create the application correctly 1`] = ` +"/** + * @type {import('@remix-run/dev').AppConfig} + */ +module.exports = { + ignoredRouteFiles: ['**/.*'], + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // serverBuildPath: "build/index.js", + // publicPath: "/build/", + watchPaths: () => require('@nx/remix').createWatchPaths(__dirname), +}; +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=derived --directory should create the application correctly 2`] = ` +"import type { MetaFunction } from '@remix-run/node'; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from '@remix-run/react'; + +export const meta: MetaFunction = () => [ + { + charset: 'utf-8', + title: 'New Remix App', + viewport: 'width=device-width,initial-scale=1', + }, +]; + +export default function App() { + return ( + + + + + + + + + + + + + ); +} +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=derived --directory should create the application correctly 3`] = ` +"export default function Index() { + return ( + + ); +} +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=derived --directory should extract the layout directory from the directory options if it exists 1`] = ` +"/** + * @type {import('@remix-run/dev').AppConfig} + */ +module.exports = { + ignoredRouteFiles: ['**/.*'], + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // serverBuildPath: "build/index.js", + // publicPath: "/build/", + watchPaths: () => require('@nx/remix').createWatchPaths(__dirname), +}; +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=derived --directory should extract the layout directory from the directory options if it exists 2`] = ` +"import type { MetaFunction } from '@remix-run/node'; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from '@remix-run/react'; + +export const meta: MetaFunction = () => [ + { + charset: 'utf-8', + title: 'New Remix App', + viewport: 'width=device-width,initial-scale=1', + }, +]; + +export default function App() { + return ( + + + + + + + + + + + + + ); +} +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=derived --directory should extract the layout directory from the directory options if it exists 3`] = ` +"export default function Index() { + return ( + + ); +} +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=derived --e2eTestRunner should generate an e2e application for the app 1`] = ` +"import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + +import { defineConfig } from 'cypress'; + +export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { cypressDir: 'src' }), + baseUrl: 'http://localhost:4200', + }, +}); +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=derived --js should create the application correctly 1`] = ` +"/** + * @type {import('@remix-run/dev').AppConfig} + */ +module.exports = { + ignoredRouteFiles: ['**/.*'], + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // serverBuildPath: "build/index.js", + // publicPath: "/build/", + watchPaths: () => require('@nx/remix').createWatchPaths(__dirname), +}; +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=derived --js should create the application correctly 2`] = ` +"import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from '@remix-run/react'; +export const meta = () => [ + { + charset: 'utf-8', + title: 'New Remix App', + viewport: 'width=device-width,initial-scale=1', + }, +]; +export default function App() { + return ( + + + + + + + + + + + + + ); +} +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=derived --js should create the application correctly 3`] = ` +"export default function Index() { + return ( + + ); +} +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=derived --unitTestRunner should generate the correct files for testing using jest 1`] = ` +"/** + * @type {import('@remix-run/dev').AppConfig} + */ +module.exports = { + ignoredRouteFiles: ['**/.*'], + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // serverBuildPath: "build/index.js", + // publicPath: "/build/", + watchPaths: () => require('@nx/remix').createWatchPaths(__dirname), +}; +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=derived --unitTestRunner should generate the correct files for testing using jest 2`] = ` +"/* eslint-disable */ +export default { + setupFilesAfterEnv: ['/test-setup.ts'], + displayName: 'test', + preset: '../../jest.preset.cjs', + transform: { + '^.+\\\\.[tj]sx?$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + coverageDirectory: '../../coverage/apps/test', +}; +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=derived --unitTestRunner should generate the correct files for testing using jest 3`] = ` +"import { installGlobals } from '@remix-run/node'; +import '@testing-library/jest-dom/matchers'; +installGlobals(); +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=derived --unitTestRunner should generate the correct files for testing using vitest 1`] = ` +"/** + * @type {import('@remix-run/dev').AppConfig} + */ +module.exports = { + ignoredRouteFiles: ['**/.*'], + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // serverBuildPath: "build/index.js", + // publicPath: "/build/", + watchPaths: () => require('@nx/remix').createWatchPaths(__dirname), +}; +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=derived --unitTestRunner should generate the correct files for testing using vitest 2`] = ` +"/// +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + root: __dirname, + cacheDir: '../../node_modules/.vite/apps/test', + + plugins: [react(), nxViteTsPaths()], + + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + + test: { + setupFiles: ['./test-setup.ts'], + globals: true, + cache: { + dir: '../../node_modules/.vitest', + }, + environment: 'jsdom', + include: ['./tests/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + + reporters: ['default'], + coverage: { + reportsDirectory: '../../coverage/apps/test', + provider: 'v8', + }, + }, +}); +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=derived --unitTestRunner should generate the correct files for testing using vitest 3`] = ` +"import { installGlobals } from '@remix-run/node'; +import '@testing-library/jest-dom/matchers'; +installGlobals(); +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=derived should create the application correctly 1`] = ` +"/** + * @type {import('@remix-run/dev').AppConfig} + */ +module.exports = { + ignoredRouteFiles: ['**/.*'], + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // serverBuildPath: "build/index.js", + // publicPath: "/build/", + watchPaths: () => require('@nx/remix').createWatchPaths(__dirname), +}; +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=derived should create the application correctly 2`] = ` +"import type { MetaFunction } from '@remix-run/node'; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from '@remix-run/react'; + +export const meta: MetaFunction = () => [ + { + charset: 'utf-8', + title: 'New Remix App', + viewport: 'width=device-width,initial-scale=1', + }, +]; + +export default function App() { + return ( + + + + + + + + + + + + + ); +} +" +`; + +exports[`Remix Application Integrated Repo --projectNameAndRootFormat=derived should create the application correctly 3`] = ` +"export default function Index() { + return ( + + ); +} +" +`; + +exports[`Remix Application Standalone Project Repo --e2eTestRunner should generate an e2e application for the app 1`] = ` +"import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + +import { defineConfig } from 'cypress'; + +export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { cypressDir: 'src' }), + baseUrl: 'http://localhost:4200', + }, +}); +" +`; + +exports[`Remix Application Standalone Project Repo --js should create the application correctly 1`] = ` +"/** + * @type {import('@remix-run/dev').AppConfig} + */ +module.exports = { + ignoredRouteFiles: ['**/.*'], + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // serverBuildPath: "build/index.js", + // publicPath: "/build/", + watchPaths: () => require('@nx/remix').createWatchPaths(__dirname), +}; +" +`; + +exports[`Remix Application Standalone Project Repo --js should create the application correctly 2`] = ` +"import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from '@remix-run/react'; +export const meta = () => [ + { + charset: 'utf-8', + title: 'New Remix App', + viewport: 'width=device-width,initial-scale=1', + }, +]; +export default function App() { + return ( + + + + + + + + + + + + + ); +} +" +`; + +exports[`Remix Application Standalone Project Repo --js should create the application correctly 3`] = ` +"export default function Index() { + return ( + + ); +} +" +`; + +exports[`Remix Application Standalone Project Repo --unitTestRunner should generate the correct files for testing using jest 1`] = ` +"/** + * @type {import('@remix-run/dev').AppConfig} + */ +module.exports = { + ignoredRouteFiles: ['**/.*'], + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // serverBuildPath: "build/index.js", + // publicPath: "/build/", + watchPaths: () => require('@nx/remix').createWatchPaths(__dirname), +}; +" +`; + +exports[`Remix Application Standalone Project Repo --unitTestRunner should generate the correct files for testing using jest 2`] = ` +"/* eslint-disable */ +export default { + setupFilesAfterEnv: ['/test-setup.ts'], + displayName: 'test', + preset: './jest.preset.cjs', + transform: { + '^.+\\\\.[tj]sx?$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + coverageDirectory: './coverage/test', + testMatch: [ + '/src/**/__tests__/**/*.[jt]s?(x)', + '/src/**/*(*.)@(spec|test).[jt]s?(x)', + ], +}; +" +`; + +exports[`Remix Application Standalone Project Repo --unitTestRunner should generate the correct files for testing using jest 3`] = ` +"import { installGlobals } from '@remix-run/node'; +import '@testing-library/jest-dom/matchers'; +installGlobals(); +" +`; + +exports[`Remix Application Standalone Project Repo --unitTestRunner should generate the correct files for testing using jest 4`] = ` +"import { createRemixStub } from '@remix-run/testing'; +import { render, screen, waitFor } from '@testing-library/react'; +import Index from '../../app/routes/_index'; + +test('renders loader data', async () => { + const RemixStub = createRemixStub([ + { + path: '/', + Component: Index, + }, + ]); + + render(); + + await waitFor(() => screen.findByText('Welcome to Remix')); +}); +" +`; + +exports[`Remix Application Standalone Project Repo --unitTestRunner should generate the correct files for testing using vitest 1`] = ` +"/** + * @type {import('@remix-run/dev').AppConfig} + */ +module.exports = { + ignoredRouteFiles: ['**/.*'], + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // serverBuildPath: "build/index.js", + // publicPath: "/build/", + watchPaths: () => require('@nx/remix').createWatchPaths(__dirname), +}; +" +`; + +exports[`Remix Application Standalone Project Repo --unitTestRunner should generate the correct files for testing using vitest 2`] = ` +"/// +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + root: __dirname, + cacheDir: './node_modules/.vite/.', + + plugins: [react(), nxViteTsPaths()], + + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + + test: { + setupFiles: ['./test-setup.ts'], + globals: true, + cache: { + dir: './node_modules/.vitest', + }, + environment: 'jsdom', + include: ['./tests/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + + reporters: ['default'], + coverage: { + reportsDirectory: './coverage/.', + provider: 'v8', + }, + }, +}); +" +`; + +exports[`Remix Application Standalone Project Repo --unitTestRunner should generate the correct files for testing using vitest 3`] = ` +"import { createRemixStub } from '@remix-run/testing'; +import { render, screen, waitFor } from '@testing-library/react'; +import Index from '../../app/routes/_index'; + +test('renders loader data', async () => { + const RemixStub = createRemixStub([ + { + path: '/', + Component: Index, + }, + ]); + + render(); + + await waitFor(() => screen.findByText('Welcome to Remix')); +}); +" +`; + +exports[`Remix Application Standalone Project Repo --unitTestRunner should generate the correct files for testing using vitest 4`] = ` +"import { installGlobals } from '@remix-run/node'; +import '@testing-library/jest-dom/matchers'; +installGlobals(); +" +`; + +exports[`Remix Application Standalone Project Repo should create the application correctly 1`] = ` +"/** + * @type {import('@remix-run/dev').AppConfig} + */ +module.exports = { + ignoredRouteFiles: ['**/.*'], + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // serverBuildPath: "build/index.js", + // publicPath: "/build/", + watchPaths: () => require('@nx/remix').createWatchPaths(__dirname), +}; +" +`; + +exports[`Remix Application Standalone Project Repo should create the application correctly 2`] = ` +"import type { MetaFunction } from '@remix-run/node'; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from '@remix-run/react'; + +export const meta: MetaFunction = () => [ + { + charset: 'utf-8', + title: 'New Remix App', + viewport: 'width=device-width,initial-scale=1', + }, +]; + +export default function App() { + return ( + + + + + + + + + + + + + ); +} +" +`; + +exports[`Remix Application Standalone Project Repo should create the application correctly 3`] = ` +"export default function Index() { + return ( + + ); +} +" +`; + +exports[`Remix Application Standalone Project Repo should create the application correctly 4`] = ` +"import { createRemixStub } from '@remix-run/testing'; +import { render, screen, waitFor } from '@testing-library/react'; +import Index from '../../app/routes/_index'; + +test('renders loader data', async () => { + const RemixStub = createRemixStub([ + { + path: '/', + Component: Index, + }, + ]); + + render(); + + await waitFor(() => screen.findByText('Welcome to Remix')); +}); +" +`; + +exports[`Remix Application Standalone Project Repo should create the application correctly 5`] = `null`; + +exports[`Remix Application Standalone Project Repo should create the application correctly 6`] = ` +"{ + "root": true, + "ignorePatterns": ["!**/*"], + "plugins": ["@nx"], + "overrides": [ + { + "files": ["*.ts", "*.tsx"], + "extends": ["plugin:@nx/typescript"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "extends": ["plugin:@nx/javascript"], + "rules": {} + } + ] +} +" +`; diff --git a/packages/remix/src/generators/application/application.impl.spec.ts b/packages/remix/src/generators/application/application.impl.spec.ts new file mode 100644 index 0000000000..50fb963cca --- /dev/null +++ b/packages/remix/src/generators/application/application.impl.spec.ts @@ -0,0 +1,328 @@ +import type { Tree } from '@nx/devkit'; +import { joinPathFragments, readJson } from '@nx/devkit'; +import { ProjectNameAndRootFormat } from '@nx/devkit/src/generators/project-name-and-root-utils'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import applicationGenerator from './application.impl'; + +describe('Remix Application', () => { + describe('Standalone Project Repo', () => { + it('should create the application correctly', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + + // ACT + await applicationGenerator(tree, { + name: 'test', + rootProject: true, + }); + + // ASSERT + expectTargetsToBeCorrect(tree, '.'); + + expect(tree.read('remix.config.cjs', 'utf-8')).toMatchSnapshot(); + expect(tree.read('app/root.tsx', 'utf-8')).toMatchSnapshot(); + expect(tree.read('app/routes/_index.tsx', 'utf-8')).toMatchSnapshot(); + expect( + tree.read('tests/routes/_index.spec.tsx', 'utf-8') + ).toMatchSnapshot(); + expect(tree.read('vite.config.ts', 'utf-8')).toMatchSnapshot(); + expect(tree.read('.eslintrc.json', 'utf-8')).toMatchSnapshot(); + }); + + describe(`--js`, () => { + it('should create the application correctly', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + + // ACT + await applicationGenerator(tree, { + name: 'test', + js: true, + rootProject: true, + }); + + // ASSERT + expectTargetsToBeCorrect(tree, '.'); + + expect(tree.read('remix.config.cjs', 'utf-8')).toMatchSnapshot(); + expect(tree.read('app/root.js', 'utf-8')).toMatchSnapshot(); + expect(tree.read('app/routes/_index.js', 'utf-8')).toMatchSnapshot(); + }); + }); + + describe('--unitTestRunner', () => { + it('should generate the correct files for testing using vitest', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + + // ACT + await applicationGenerator(tree, { + name: 'test', + unitTestRunner: 'vitest', + rootProject: true, + }); + + // ASSERT + expectTargetsToBeCorrect(tree, '.'); + + expect(tree.read('remix.config.cjs', 'utf-8')).toMatchSnapshot(); + expect(tree.read('vite.config.ts', 'utf-8')).toMatchSnapshot(); + expect( + tree.read('tests/routes/_index.spec.tsx', 'utf-8') + ).toMatchSnapshot(); + expect(tree.read('test-setup.ts', 'utf-8')).toMatchSnapshot(); + }); + + it('should generate the correct files for testing using jest', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + + // ACT + await applicationGenerator(tree, { + name: 'test', + unitTestRunner: 'jest', + rootProject: true, + }); + + // ASSERT + expectTargetsToBeCorrect(tree, '.'); + + expect(tree.read('remix.config.cjs', 'utf-8')).toMatchSnapshot(); + expect(tree.read('jest.config.ts', 'utf-8')).toMatchSnapshot(); + expect(tree.read('test-setup.ts', 'utf-8')).toMatchSnapshot(); + expect( + tree.read('tests/routes/_index.spec.tsx', 'utf-8') + ).toMatchSnapshot(); + expect(tree.exists('jest.preset.cjs')).toBeTruthy(); + }); + }); + + describe('--e2eTestRunner', () => { + it('should generate an e2e application for the app', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + + // ACT + await applicationGenerator(tree, { + name: 'test', + e2eTestRunner: 'cypress', + rootProject: true, + }); + + // ASSERT + expectTargetsToBeCorrect(tree, '.'); + + expect(tree.read('e2e/cypress.config.ts', 'utf-8')).toMatchSnapshot(); + }); + }); + }); + + describe.each([ + ['derived', 'apps/test', 'apps/test-e2e'], + ['as-provided', 'test', 'test-e2e'], + ])( + 'Integrated Repo --projectNameAndRootFormat=%s', + (projectNameAndRootFormat: ProjectNameAndRootFormat, appDir, e2eDir) => { + it('should create the application correctly', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + + // ACT + await applicationGenerator(tree, { + name: 'test', + projectNameAndRootFormat, + }); + + // ASSERT + expectTargetsToBeCorrect(tree, appDir); + + expect( + tree.read(`${appDir}/remix.config.cjs`, 'utf-8') + ).toMatchSnapshot(); + expect(tree.read(`${appDir}/app/root.tsx`, 'utf-8')).toMatchSnapshot(); + expect( + tree.read(`${appDir}/app/routes/_index.tsx`, 'utf-8') + ).toMatchSnapshot(); + }); + + describe('--js', () => { + it('should create the application correctly', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + + // ACT + await applicationGenerator(tree, { + name: 'test', + js: true, + projectNameAndRootFormat, + }); + + // ASSERT + expectTargetsToBeCorrect(tree, appDir); + + expect( + tree.read(`${appDir}/remix.config.cjs`, 'utf-8') + ).toMatchSnapshot(); + expect(tree.read(`${appDir}/app/root.js`, 'utf-8')).toMatchSnapshot(); + expect( + tree.read(`${appDir}/app/routes/_index.js`, 'utf-8') + ).toMatchSnapshot(); + }); + }); + describe('--directory', () => { + it('should create the application correctly', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + const newAppDir = + projectNameAndRootFormat === 'as-provided' + ? 'demo' + : 'apps/demo/test'; + + // ACT + await applicationGenerator(tree, { + name: 'test', + directory: 'demo', + projectNameAndRootFormat, + }); + + // ASSERT + expectTargetsToBeCorrect(tree, newAppDir); + + expect( + tree.read(`${newAppDir}/remix.config.cjs`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`${newAppDir}/app/root.tsx`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`${newAppDir}/app/routes/_index.tsx`, 'utf-8') + ).toMatchSnapshot(); + }); + + it('should extract the layout directory from the directory options if it exists', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + const newAppDir = + projectNameAndRootFormat === 'as-provided' + ? 'apps/demo' + : 'apps/demo/test'; + + // ACT + await applicationGenerator(tree, { + name: 'test', + directory: 'apps/demo', + projectNameAndRootFormat, + }); + + // ASSERT + expectTargetsToBeCorrect(tree, newAppDir); + + expect( + tree.read(`${newAppDir}/remix.config.cjs`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`${newAppDir}/app/root.tsx`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`${newAppDir}/app/routes/_index.tsx`, 'utf-8') + ).toMatchSnapshot(); + }); + }); + + describe('--unitTestRunner', () => { + it('should generate the correct files for testing using vitest', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + + // ACT + await applicationGenerator(tree, { + name: 'test', + unitTestRunner: 'vitest', + projectNameAndRootFormat, + }); + + // ASSERT + expectTargetsToBeCorrect(tree, appDir); + + expect( + tree.read(`${appDir}/remix.config.cjs`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`${appDir}/vite.config.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`${appDir}/test-setup.ts`, 'utf-8') + ).toMatchSnapshot(); + }); + + it('should generate the correct files for testing using jest', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + + // ACT + await applicationGenerator(tree, { + name: 'test', + unitTestRunner: 'jest', + projectNameAndRootFormat, + }); + + // ASSERT + expectTargetsToBeCorrect(tree, appDir); + + expect( + tree.read(`${appDir}/remix.config.cjs`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`${appDir}/jest.config.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`${appDir}/test-setup.ts`, 'utf-8') + ).toMatchSnapshot(); + }); + }); + + describe('--e2eTestRunner', () => { + it('should generate an e2e application for the app', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + + // ACT + await applicationGenerator(tree, { + name: 'test', + e2eTestRunner: 'cypress', + projectNameAndRootFormat, + }); + + // ASSERT + expectTargetsToBeCorrect(tree, appDir); + + expect( + tree.read(`${appDir}-e2e/cypress.config.ts`, 'utf-8') + ).toMatchSnapshot(); + }); + }); + } + ); +}); + +function expectTargetsToBeCorrect(tree: Tree, projectRoot: string) { + const { targets } = readJson( + tree, + joinPathFragments(projectRoot === '.' ? '/' : projectRoot, 'project.json') + ); + expect(targets.lint).toBeTruthy(); + expect(targets.build).toBeTruthy(); + expect(targets.build.executor).toEqual('@nx/remix:build'); + expect(targets.build.options.outputPath).toEqual( + joinPathFragments('dist', projectRoot) + ); + expect(targets.serve).toBeTruthy(); + expect(targets.serve.executor).toEqual('@nx/remix:serve'); + expect(targets.serve.options.port).toEqual(4200); + expect(targets.start).toBeTruthy(); + expect(targets.start.command).toEqual('remix-serve build/index.js'); + expect(targets.start.options.cwd).toEqual(projectRoot); + expect(targets.typecheck).toBeTruthy(); + expect(targets.typecheck.command).toEqual('tsc'); + expect(targets.typecheck.options.cwd).toEqual(projectRoot); +} diff --git a/packages/remix/src/generators/application/application.impl.ts b/packages/remix/src/generators/application/application.impl.ts new file mode 100644 index 0000000000..c3636446a6 --- /dev/null +++ b/packages/remix/src/generators/application/application.impl.ts @@ -0,0 +1,289 @@ +import { + addDependenciesToPackageJson, + addProjectConfiguration, + ensurePackage, + formatFiles, + generateFiles, + GeneratorCallback, + getPackageManagerCommand, + joinPathFragments, + offsetFromRoot, + readJson, + readProjectConfiguration, + runTasksInSerial, + toJS, + Tree, + updateJson, + updateProjectConfiguration, +} from '@nx/devkit'; +import { extractTsConfigBase } from '@nx/js/src/utils/typescript/create-ts-config'; +import { + eslintVersion, + getPackageVersion, + isbotVersion, + reactDomVersion, + reactVersion, + remixVersion, + typescriptVersion, + typesReactDomVersion, + typesReactVersion, +} from '../../utils/versions'; +import { + NormalizedSchema, + normalizeOptions, + updateUnitTestConfig, +} from './lib'; +import { NxRemixGeneratorSchema } from './schema'; + +export default async function (tree: Tree, _options: NxRemixGeneratorSchema) { + const options = await normalizeOptions(tree, _options); + const tasks: GeneratorCallback[] = []; + + addProjectConfiguration(tree, options.projectName, { + root: options.projectRoot, + sourceRoot: `${options.projectRoot}`, + projectType: 'application', + tags: options.parsedTags, + targets: { + build: { + executor: '@nx/remix:build', + outputs: ['{options.outputPath}'], + options: { + outputPath: joinPathFragments('dist', options.projectRoot), + }, + }, + serve: { + executor: `@nx/remix:serve`, + options: { + command: `${ + getPackageManagerCommand().exec + } remix-serve build/index.js`, + manual: true, + port: 4200, + }, + }, + start: { + dependsOn: ['build'], + command: `remix-serve build/index.js`, + options: { + cwd: options.projectRoot, + }, + }, + typecheck: { + command: `tsc`, + options: { + cwd: options.projectRoot, + }, + }, + }, + }); + + const installTask = addDependenciesToPackageJson( + tree, + { + '@remix-run/node': remixVersion, + '@remix-run/react': remixVersion, + '@remix-run/serve': remixVersion, + isbot: isbotVersion, + react: reactVersion, + 'react-dom': reactDomVersion, + }, + { + '@remix-run/dev': remixVersion, + '@remix-run/eslint-config': remixVersion, + '@types/react': typesReactVersion, + '@types/react-dom': typesReactDomVersion, + eslint: eslintVersion, + typescript: typescriptVersion, + } + ); + tasks.push(installTask); + + const vars = { + ...options, + tmpl: '', + offsetFromRoot: offsetFromRoot(options.projectRoot), + remixVersion, + isbotVersion, + reactVersion, + reactDomVersion, + typesReactVersion, + typesReactDomVersion, + eslintVersion, + typescriptVersion, + }; + + generateFiles( + tree, + joinPathFragments(__dirname, 'files/common'), + options.projectRoot, + vars + ); + + if (options.rootProject) { + const gitignore = tree.read('.gitignore', 'utf-8'); + tree.write( + '.gitignore', + `${gitignore}\n.cache\nbuild\npublic/build\n.env\n` + ); + } else { + generateFiles( + tree, + joinPathFragments(__dirname, 'files/integrated'), + options.projectRoot, + vars + ); + } + + if (options.unitTestRunner !== 'none') { + if (options.unitTestRunner === 'vitest') { + const { vitestGenerator } = ensurePackage( + '@nx/vite', + getPackageVersion(tree, 'nx') + ); + const vitestTask = await vitestGenerator(tree, { + uiFramework: 'react', + project: options.projectName, + coverageProvider: 'v8', + inSourceTests: false, + skipFormat: true, + testEnvironment: 'jsdom', + }); + tasks.push(vitestTask); + } else { + const { configurationGenerator: jestConfigurationGenerator } = + ensurePackage( + '@nx/jest', + getPackageVersion(tree, 'nx') + ); + const jestTask = await jestConfigurationGenerator(tree, { + project: options.projectName, + setupFile: 'none', + supportTsx: true, + skipSerializers: false, + skipPackageJson: false, + skipFormat: true, + }); + const projectConfig = readProjectConfiguration(tree, options.projectName); + projectConfig.targets['test'].options.passWithNoTests = true; + updateProjectConfiguration(tree, options.projectName, projectConfig); + + tasks.push(jestTask); + } + + const pkgInstallTask = updateUnitTestConfig( + tree, + options.projectRoot, + options.unitTestRunner + ); + tasks.push(pkgInstallTask); + } else { + tree.delete( + joinPathFragments(options.projectRoot, `tests/routes/_index.spec.tsx`) + ); + } + + if (options.linter !== 'none') { + const { lintProjectGenerator } = ensurePackage( + '@nx/eslint', + getPackageVersion(tree, 'nx') + ); + const eslintTask = await lintProjectGenerator(tree, { + linter: options.linter, + project: options.projectName, + tsConfigPaths: [ + joinPathFragments(options.projectRoot, 'tsconfig.app.json'), + ], + unitTestRunner: options.unitTestRunner, + skipFormat: true, + rootProject: options.rootProject, + }); + tasks.push(eslintTask); + } + + if (options.js) { + toJS(tree); + } + + if (options.rootProject && tree.exists('tsconfig.base.json')) { + // If this is a standalone project, merge tsconfig.json and tsconfig.base.json. + const tsConfigBaseJson = readJson(tree, 'tsconfig.base.json'); + updateJson(tree, 'tsconfig.json', (json) => { + delete json.extends; + json.compilerOptions = { + ...tsConfigBaseJson.compilerOptions, + ...json.compilerOptions, + // Taken from remix default setup + // https://github.com/remix-run/remix/blob/68c8982/templates/remix/tsconfig.json#L15-L17 + paths: { + '~/*': ['./app/*'], + }, + }; + json.include = [ + ...(tsConfigBaseJson.include ?? []), + ...(json.include ?? []), + ]; + json.exclude = [ + ...(tsConfigBaseJson.exclude ?? []), + ...(json.exclude ?? []), + ]; + return json; + }); + tree.delete('tsconfig.base.json'); + } else { + // Otherwise, extract the tsconfig.base.json from tsconfig.json so we can share settings. + extractTsConfigBase(tree); + } + + if (options.e2eTestRunner === 'cypress') { + const { configurationGenerator } = ensurePackage< + typeof import('@nx/cypress') + >('@nx/cypress', getPackageVersion(tree, 'nx')); + addFileServerTarget(tree, options, 'serve-static'); + addProjectConfiguration(tree, options.e2eProjectName, { + projectType: 'application', + root: options.e2eProjectRoot, + sourceRoot: joinPathFragments(options.e2eProjectRoot, 'src'), + targets: {}, + tags: [], + implicitDependencies: [options.projectName], + }); + tasks.push( + await configurationGenerator(tree, { + project: options.e2eProjectName, + directory: 'src', + skipFormat: true, + devServerTarget: `${options.projectName}:serve:development`, + baseUrl: 'http://localhost:4200', + }) + ); + } + + if (!options.skipFormat) { + await formatFiles(tree); + } + + return runTasksInSerial(...tasks); +} + +function addFileServerTarget( + tree: Tree, + options: NormalizedSchema, + targetName: string +) { + addDependenciesToPackageJson( + tree, + {}, + { '@nx/web': getPackageVersion(tree, 'nx') } + ); + + const projectConfig = readProjectConfiguration(tree, options.projectName); + projectConfig.targets[targetName] = { + executor: '@nx/web:file-server', + options: { + buildTarget: `${options.projectName}:build`, + port: 4200, + }, + }; + updateProjectConfiguration(tree, options.projectName, projectConfig); +} diff --git a/packages/remix/src/generators/application/files/common/README.md__tmpl__ b/packages/remix/src/generators/application/files/common/README.md__tmpl__ new file mode 100644 index 0000000000..3eaf4e37cb --- /dev/null +++ b/packages/remix/src/generators/application/files/common/README.md__tmpl__ @@ -0,0 +1,54 @@ +# Welcome to Nx + Remix! + +- [Remix Docs](https://remix.run/docs) +- [Nx Docs](https://nx.dev) + +## Development + +From your terminal: + +```sh +npx nx dev <%= projectName %> +``` + +This starts your app in development mode, rebuilding assets on file changes. + +## Deployment + +First, build your app for production: + +```sh +npx nx build <%= projectName %> +``` + +Then run the app in production mode: + +```sh +npx nx start <%= projectName %> +``` + +Now you'll need to pick a host to deploy it to. + +### DIY + +If you're familiar with deploying node applications, the built-in Remix app server is production-ready. + +Make sure to deploy the output of `remix build` + +- `packages/<%= projectName %>/build/` +- `packages/<%= projectName %>/public/build/` + +### Using a Template + +When you ran `npx create-remix@latest` there were a few choices for hosting. You can run that again to create a new project, then copy over your `app/` folder to the new project that's pre-configured for your target server. + +```sh +cd .. +# create a new project, and pick a pre-configured host +npx create-remix@latest +cd my-new-remix-app +# remove the new project's app (not the old one!) +rm -rf app +# copy your app over +cp -R ../my-old-remix-app/app app +``` diff --git a/packages/remix/src/generators/application/files/common/app/root.tsx__tmpl__ b/packages/remix/src/generators/application/files/common/app/root.tsx__tmpl__ new file mode 100644 index 0000000000..f7bdf668ca --- /dev/null +++ b/packages/remix/src/generators/application/files/common/app/root.tsx__tmpl__ @@ -0,0 +1,32 @@ +import type { MetaFunction } from "@remix-run/node"; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "@remix-run/react"; + +export const meta: MetaFunction = () => ([{ + charset: "utf-8", + title: "New Remix App", + viewport: "width=device-width,initial-scale=1", +}]); + +export default function App() { + return ( + + + + + + + + + + + + + ); +} diff --git a/packages/remix/src/generators/application/files/common/app/routes/_index.tsx__tmpl__ b/packages/remix/src/generators/application/files/common/app/routes/_index.tsx__tmpl__ new file mode 100644 index 0000000000..43e520bf30 --- /dev/null +++ b/packages/remix/src/generators/application/files/common/app/routes/_index.tsx__tmpl__ @@ -0,0 +1,32 @@ +export default function Index() { + return ( + + ); +} \ No newline at end of file diff --git a/packages/remix/src/generators/application/files/common/public/favicon.ico b/packages/remix/src/generators/application/files/common/public/favicon.ico new file mode 100644 index 0000000000..8830cf6821 Binary files /dev/null and b/packages/remix/src/generators/application/files/common/public/favicon.ico differ diff --git a/packages/remix/src/generators/application/files/common/remix.config.cjs__tmpl__ b/packages/remix/src/generators/application/files/common/remix.config.cjs__tmpl__ new file mode 100644 index 0000000000..c2beffa337 --- /dev/null +++ b/packages/remix/src/generators/application/files/common/remix.config.cjs__tmpl__ @@ -0,0 +1,11 @@ +/** + * @type {import('@remix-run/dev').AppConfig} + */ +module.exports = { + ignoredRouteFiles: ["**/.*"], + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // serverBuildPath: "build/index.js", + // publicPath: "/build/", + watchPaths: () => require("@nx/remix").createWatchPaths(__dirname), +}; diff --git a/packages/remix/src/generators/application/files/common/remix.env.d.ts__tmpl__ b/packages/remix/src/generators/application/files/common/remix.env.d.ts__tmpl__ new file mode 100644 index 0000000000..dcf8c45e1d --- /dev/null +++ b/packages/remix/src/generators/application/files/common/remix.env.d.ts__tmpl__ @@ -0,0 +1,2 @@ +/// +/// diff --git a/packages/remix/src/generators/application/files/common/tests/routes/_index.spec.tsx__tmpl__ b/packages/remix/src/generators/application/files/common/tests/routes/_index.spec.tsx__tmpl__ new file mode 100644 index 0000000000..1a86247e73 --- /dev/null +++ b/packages/remix/src/generators/application/files/common/tests/routes/_index.spec.tsx__tmpl__ @@ -0,0 +1,16 @@ +import { createRemixStub } from '@remix-run/testing'; +import { render, screen, waitFor } from '@testing-library/react'; +import Index from '../../app/routes/_index'; + +test('renders loader data', async () => { + const RemixStub = createRemixStub([ + { + path: '/', + Component: Index, + }, + ]); + + render(); + + await waitFor(() => screen.findByText('Welcome to Remix')); +}); diff --git a/packages/remix/src/generators/application/files/common/tsconfig.json__tmpl__ b/packages/remix/src/generators/application/files/common/tsconfig.json__tmpl__ new file mode 100644 index 0000000000..058aafdd27 --- /dev/null +++ b/packages/remix/src/generators/application/files/common/tsconfig.json__tmpl__ @@ -0,0 +1,18 @@ +{ + "extends": "<%= offsetFromRoot %>tsconfig.base.json", + "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2019"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "moduleResolution": "node", + "resolveJsonModule": true, + "target": "ES2019", + "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, + // Remix takes care of building everything in `remix build`. + "noEmit": true + } +} diff --git a/packages/remix/src/generators/application/files/integrated/.gitignore__tmpl__ b/packages/remix/src/generators/application/files/integrated/.gitignore__tmpl__ new file mode 100644 index 0000000000..9ca4842f9c --- /dev/null +++ b/packages/remix/src/generators/application/files/integrated/.gitignore__tmpl__ @@ -0,0 +1,4 @@ +.cache +build +public/build +.env diff --git a/packages/remix/src/generators/application/files/integrated/package.json__tmpl__ b/packages/remix/src/generators/application/files/integrated/package.json__tmpl__ new file mode 100644 index 0000000000..fb01011bd7 --- /dev/null +++ b/packages/remix/src/generators/application/files/integrated/package.json__tmpl__ @@ -0,0 +1,28 @@ +{ + "private": true, + "name": "<%= projectName %>", + "description": "", + "license": "", + "scripts": {}, + "type": "module", + "dependencies": { + "@remix-run/node": "<%= remixVersion %>", + "@remix-run/react": "<%= remixVersion %>", + "@remix-run/serve": "<%= remixVersion %>", + "isbot": "<%= isbotVersion %>", + "react": "<%= reactVersion %>", + "react-dom": "<%= reactDomVersion %>" + }, + "devDependencies": { + "@remix-run/dev": "<%= remixVersion %>", + "@remix-run/eslint-config": "<%= remixVersion %>", + "@types/react": "<%= typesReactVersion %>", + "@types/react-dom": "<%= typesReactDomVersion %>", + "eslint": "<%= eslintVersion %>", + "typescript": "<%= typescriptVersion %>" + }, + "engines": { + "node": ">=14" + }, + "sideEffects": false +} diff --git a/packages/remix/src/generators/application/lib/index.ts b/packages/remix/src/generators/application/lib/index.ts new file mode 100644 index 0000000000..df3573ed82 --- /dev/null +++ b/packages/remix/src/generators/application/lib/index.ts @@ -0,0 +1,2 @@ +export * from './normalize-options'; +export * from './update-unit-test-config'; diff --git a/packages/remix/src/generators/application/lib/normalize-options.ts b/packages/remix/src/generators/application/lib/normalize-options.ts new file mode 100644 index 0000000000..98cc4b0353 --- /dev/null +++ b/packages/remix/src/generators/application/lib/normalize-options.ts @@ -0,0 +1,45 @@ +import { type Tree } from '@nx/devkit'; +import { determineProjectNameAndRootOptions } from '@nx/devkit/src/generators/project-name-and-root-utils'; +import { type NxRemixGeneratorSchema } from '../schema'; +import { Linter } from '@nx/eslint'; + +export interface NormalizedSchema extends NxRemixGeneratorSchema { + projectName: string; + projectRoot: string; + e2eProjectName: string; + e2eProjectRoot: string; + parsedTags: string[]; +} + +export async function normalizeOptions( + tree: Tree, + options: NxRemixGeneratorSchema +): Promise { + const { projectName, projectRoot, projectNameAndRootFormat } = + await determineProjectNameAndRootOptions(tree, { + name: options.name, + projectType: 'application', + directory: options.directory, + projectNameAndRootFormat: options.projectNameAndRootFormat, + rootProject: options.rootProject, + callingGenerator: '@nx/remix:application', + }); + options.rootProject = projectRoot === '.'; + options.projectNameAndRootFormat = projectNameAndRootFormat; + const e2eProjectName = options.rootProject ? 'e2e' : `${projectName}-e2e`; + const e2eProjectRoot = options.rootProject ? 'e2e' : `${projectRoot}-e2e`; + + const parsedTags = options.tags + ? options.tags.split(',').map((s) => s.trim()) + : []; + + return { + ...options, + linter: options.linter ?? Linter.EsLint, + projectName, + projectRoot, + e2eProjectName, + e2eProjectRoot, + parsedTags, + }; +} diff --git a/packages/remix/src/generators/application/lib/update-unit-test-config.ts b/packages/remix/src/generators/application/lib/update-unit-test-config.ts new file mode 100644 index 0000000000..c14d53794f --- /dev/null +++ b/packages/remix/src/generators/application/lib/update-unit-test-config.ts @@ -0,0 +1,70 @@ +import { + addDependenciesToPackageJson, + joinPathFragments, + stripIndents, + type Tree, + workspaceRoot, +} from '@nx/devkit'; +import { + updateJestTestSetup, + updateViteTestIncludes, + updateViteTestSetup, +} from '../../../utils/testing-config-utils'; +import { + getRemixVersion, + testingLibraryJestDomVersion, + testingLibraryReactVersion, + testingLibraryUserEventsVersion, +} from '../../../utils/versions'; + +export function updateUnitTestConfig( + tree: Tree, + pathToRoot: string, + unitTestRunner: 'vitest' | 'jest' +) { + const pathToTestSetup = joinPathFragments(pathToRoot, `test-setup.ts`); + tree.write( + pathToTestSetup, + stripIndents` + import { installGlobals } from '@remix-run/node'; + import '@testing-library/jest-dom/matchers'; + installGlobals();` + ); + + if (unitTestRunner === 'vitest') { + const pathToViteConfig = joinPathFragments(pathToRoot, 'vite.config.ts'); + updateViteTestIncludes( + tree, + pathToViteConfig, + './app/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}' + ); + updateViteTestIncludes( + tree, + pathToViteConfig, + './tests/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}' + ); + updateViteTestSetup(tree, pathToViteConfig, './test-setup.ts'); + } else if (unitTestRunner === 'jest') { + const pathToJestConfig = joinPathFragments(pathToRoot, 'jest.config.ts'); + tree.rename('jest.preset.js', 'jest.preset.cjs'); + updateJestTestSetup(tree, pathToJestConfig, `/test-setup.ts`); + tree.write( + pathToJestConfig, + tree + .read(pathToJestConfig, 'utf-8') + .replace('jest.preset.js', 'jest.preset.cjs') + ); + } + + return addDependenciesToPackageJson( + tree, + {}, + { + '@testing-library/jest-dom': testingLibraryJestDomVersion, + '@testing-library/react': testingLibraryReactVersion, + '@testing-library/user-event': testingLibraryUserEventsVersion, + '@remix-run/node': getRemixVersion(tree), + '@remix-run/testing': getRemixVersion(tree), + } + ); +} diff --git a/packages/remix/src/generators/application/schema.d.ts b/packages/remix/src/generators/application/schema.d.ts new file mode 100644 index 0000000000..8e07a67294 --- /dev/null +++ b/packages/remix/src/generators/application/schema.d.ts @@ -0,0 +1,15 @@ +import { ProjectNameAndRootFormat } from '@nx/devkit/src/generators/project-name-and-root-utils'; +import type { Linter } from '@nx/eslint'; + +export interface NxRemixGeneratorSchema { + name: string; + tags?: string; + js?: boolean; + directory?: string; + projectNameAndRootFormat?: ProjectNameAndRootFormat; + linter?: Linter; + unitTestRunner?: 'vitest' | 'jest' | 'none'; + e2eTestRunner?: 'cypress' | 'none'; + skipFormat?: boolean; + rootProject?: boolean; +} diff --git a/packages/remix/src/generators/application/schema.json b/packages/remix/src/generators/application/schema.json new file mode 100644 index 0000000000..8d1acb4bf1 --- /dev/null +++ b/packages/remix/src/generators/application/schema.json @@ -0,0 +1,68 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "NxRemixApplication", + "title": "Create an Application", + "description": "Generate a new Remix application.", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What is the name of the application?" + }, + "js": { + "type": "boolean", + "description": "Generate JavaScript files rather than TypeScript files.", + "default": false + }, + "directory": { + "type": "string", + "description": "A directory where the app is placed.", + "alias": "dir", + "x-priority": "important" + }, + "projectNameAndRootFormat": { + "description": "Whether to generate the project name and root directory as provided (`as-provided`) or generate them composing their values and taking the configured layout into account (`derived`).", + "type": "string", + "enum": ["as-provided", "derived"] + }, + "linter": { + "description": "The tool to use for running lint checks.", + "type": "string", + "enum": ["eslint", "none"], + "default": "eslint" + }, + "unitTestRunner": { + "type": "string", + "enum": ["vitest", "jest", "none"], + "default": "vitest", + "description": "Test runner to use for unit tests.", + "x-prompt": "What unit test runner should be used?" + }, + "e2eTestRunner": { + "type": "string", + "enum": ["cypress", "none"], + "default": "cypress", + "description": "Test runner to use for e2e tests" + }, + "tags": { + "type": "string", + "description": "Add tags to the project (used for linting)", + "alias": "t" + }, + "skipFormat": { + "type": "boolean", + "description": "Skip formatting files", + "default": false + }, + "rootProject": { + "type": "boolean", + "x-priority": "internal", + "default": false + } + } +} diff --git a/packages/remix/src/generators/cypress-component-configuration/cypress-component-configuration.impl.spec.ts b/packages/remix/src/generators/cypress-component-configuration/cypress-component-configuration.impl.spec.ts new file mode 100644 index 0000000000..f402f81f96 --- /dev/null +++ b/packages/remix/src/generators/cypress-component-configuration/cypress-component-configuration.impl.spec.ts @@ -0,0 +1,51 @@ +import { joinPathFragments, readProjectConfiguration } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import libraryGenerator from '../library/library.impl'; +import cypressComponentConfigurationGenerator from './cypress-component-configuration.impl'; + +describe('CypressComponentConfiguration', () => { + it('should create the cypress configuration correctly', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + + await libraryGenerator(tree, { + name: 'cypress-test', + unitTestRunner: 'vitest', + style: 'css', + }); + + // ACT + await cypressComponentConfigurationGenerator(tree, { + project: 'cypress-test', + generateTests: true, + }); + + // ASSERT + const project = readProjectConfiguration(tree, 'cypress-test'); + expect( + tree.read(joinPathFragments(project.root, 'cypress.config.ts'), 'utf-8') + ).toMatchInlineSnapshot(` + "import { defineConfig } from 'cypress'; + import { nxComponentTestingPreset } from '@nx/remix/plugins/component-testing'; + + export default defineConfig({ + component: nxComponentTestingPreset(__filename), + }); + " + `); + expect(project.targets['component-test']).toMatchInlineSnapshot(` + { + "executor": "@nx/cypress:cypress", + "options": { + "cypressConfig": "cypress-test/cypress.config.ts", + "devServerTarget": "", + "skipServe": true, + "testingType": "component", + }, + } + `); + expect( + tree.exists(joinPathFragments(project.root, 'cypress')) + ).toBeTruthy(); + }); +}); diff --git a/packages/remix/src/generators/cypress-component-configuration/cypress-component-configuration.impl.ts b/packages/remix/src/generators/cypress-component-configuration/cypress-component-configuration.impl.ts new file mode 100644 index 0000000000..399e4109cd --- /dev/null +++ b/packages/remix/src/generators/cypress-component-configuration/cypress-component-configuration.impl.ts @@ -0,0 +1,30 @@ +import { + formatFiles, + generateFiles, + readProjectConfiguration, + type Tree, +} from '@nx/devkit'; +import { join } from 'path'; +import { type CypressComponentConfigurationSchema } from './schema'; +import { cypressComponentConfigGenerator } from '@nx/react'; + +export default async function cypressComponentConfigurationGenerator( + tree: Tree, + options: CypressComponentConfigurationSchema +) { + await cypressComponentConfigGenerator(tree, { + project: options.project, + generateTests: options.generateTests, + skipFormat: true, + bundler: 'vite', + buildTarget: '', + }); + + const project = readProjectConfiguration(tree, options.project); + + generateFiles(tree, join(__dirname, './files'), project.root, { tmpl: '' }); + + if (!options.skipFormat) { + await formatFiles(tree); + } +} diff --git a/packages/remix/src/generators/cypress-component-configuration/files/cypress.config.ts__tmpl__ b/packages/remix/src/generators/cypress-component-configuration/files/cypress.config.ts__tmpl__ new file mode 100644 index 0000000000..6b04b7c252 --- /dev/null +++ b/packages/remix/src/generators/cypress-component-configuration/files/cypress.config.ts__tmpl__ @@ -0,0 +1,6 @@ +import {defineConfig} from 'cypress'; +import {nxComponentTestingPreset} from '@nx/remix/plugins/component-testing'; + +export default defineConfig({ + component: nxComponentTestingPreset(__filename), +}); diff --git a/packages/remix/src/generators/cypress-component-configuration/schema.d.ts b/packages/remix/src/generators/cypress-component-configuration/schema.d.ts new file mode 100644 index 0000000000..be3f161cc0 --- /dev/null +++ b/packages/remix/src/generators/cypress-component-configuration/schema.d.ts @@ -0,0 +1,5 @@ +export interface CypressComponentConfigurationSchema { + project: string; + generateTests?: boolean; + skipFormat?: boolean; +} diff --git a/packages/remix/src/generators/cypress-component-configuration/schema.json b/packages/remix/src/generators/cypress-component-configuration/schema.json new file mode 100644 index 0000000000..27f17c56e1 --- /dev/null +++ b/packages/remix/src/generators/cypress-component-configuration/schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/schema", + "cli": "nx", + "$id": "NxRemixCypressComponentTestConfiguration", + "title": "Add Cypress component testing", + "description": "Add a Cypress component testing configuration to an existing project.", + "type": "object", + "examples": [ + { + "command": "nx g @nx/remix:cypress-component-configuration --project=my-remix-project", + "description": "Add component testing to your Remix project" + }, + { + "command": "nx g @nx/remix:cypress-component-configuration --project=my-remix-project --generate-tests", + "description": "Add component testing to your Remix project and generate component tests for your existing components" + } + ], + "properties": { + "project": { + "type": "string", + "description": "The name of the project to add cypress component testing configuration to", + "x-dropdown": "projects", + "x-prompt": "What project should we add Cypress component testing to?", + "x-priority": "important" + }, + "generateTests": { + "type": "boolean", + "description": "Generate default component tests for existing components in the project", + "x-prompt": "Automatically generate tests for components declared in this project?", + "default": false, + "x-priority": "important" + }, + "skipFormat": { + "type": "boolean", + "description": "Skip formatting files", + "default": false, + "x-priority": "internal" + } + }, + "required": ["project"] +} diff --git a/packages/remix/src/generators/cypress/cypress.impl.spec.ts b/packages/remix/src/generators/cypress/cypress.impl.spec.ts new file mode 100644 index 0000000000..5637dd3f8b --- /dev/null +++ b/packages/remix/src/generators/cypress/cypress.impl.spec.ts @@ -0,0 +1,35 @@ +import { readProjectConfiguration, Tree } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import generator from './cypress.impl'; +import applicationGenerator from '../application/application.impl'; + +describe('Cypress generator', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + }); + + it('should generate cypress project', async () => { + await applicationGenerator(tree, { name: 'demo', e2eTestRunner: 'none' }); + await generator(tree, { project: 'demo', name: 'demo-e2e' }); + + const config = readProjectConfiguration(tree, 'demo-e2e'); + expect(config.targets).toEqual({ + e2e: { + executor: '@nx/cypress:cypress', + options: { + cypressConfig: 'demo-e2e/cypress.config.ts', + testingType: 'e2e', + devServerTarget: 'demo:serve:development', + }, + configurations: { + ci: { + devServerTarget: 'demo:serve-static', + }, + }, + }, + lint: { executor: '@nx/eslint:lint', outputs: ['{options.outputFile}'] }, + }); + }); +}); diff --git a/packages/remix/src/generators/cypress/cypress.impl.ts b/packages/remix/src/generators/cypress/cypress.impl.ts new file mode 100644 index 0000000000..dc7c92e2ea --- /dev/null +++ b/packages/remix/src/generators/cypress/cypress.impl.ts @@ -0,0 +1,113 @@ +import { + addDependenciesToPackageJson, + addProjectConfiguration, + GeneratorCallback, + joinPathFragments, + readProjectConfiguration, + runTasksInSerial, + Tree, + updateProjectConfiguration, +} from '@nx/devkit'; +import { configurationGenerator } from '@nx/cypress'; +import { CypressGeneratorSchema } from './schema'; +import { determineProjectNameAndRootOptions } from '@nx/devkit/src/generators/project-name-and-root-utils'; +import { nxVersion } from '../../utils/versions'; + +export default async function ( + tree: Tree, + options: CypressGeneratorSchema +): Promise { + const { projectName: e2eProjectName, projectRoot: e2eProjectRoot } = + await determineProjectNameAndRootOptions(tree, { + name: options.name, + projectType: 'application', + directory: options.directory, + projectNameAndRootFormat: options.projectNameAndRootFormat, + callingGenerator: '@nx/remix:cypress', + }); + const rootProject = e2eProjectRoot === '.'; + let projectConfig = readProjectConfiguration(tree, options.project); + options.baseUrl ??= `http://localhost:${projectConfig.targets['serve'].options.port}`; + + addFileServerTarget(tree, options, 'serve-static'); + addProjectConfiguration(tree, e2eProjectName, { + projectType: 'application', + root: e2eProjectRoot, + sourceRoot: joinPathFragments(e2eProjectRoot, 'src'), + targets: {}, + tags: [], + implicitDependencies: [options.name], + }); + const installTask = await configurationGenerator(tree, { + project: e2eProjectName, + directory: 'src', + linter: options.linter, + skipPackageJson: false, + skipFormat: true, + devServerTarget: `${options.project}:serve:development`, + baseUrl: options.baseUrl, + rootProject, + }); + + projectConfig = readProjectConfiguration(tree, e2eProjectName); + + tree.delete( + joinPathFragments(projectConfig.sourceRoot, 'support', 'app.po.ts') + ); + tree.write( + joinPathFragments(projectConfig.sourceRoot, 'e2e', 'app.cy.ts'), + `describe('webapp', () => { + beforeEach(() => cy.visit('/')); + + it('should display welcome message', () => { + cy.get('h1').contains('Welcome to Remix'); + }); +});` + ); + + const supportFilePath = joinPathFragments( + projectConfig.sourceRoot, + 'support', + 'e2e.ts' + ); + const supportContent = tree.read(supportFilePath, 'utf-8'); + + tree.write( + supportFilePath, + `${supportContent} + +// from https://github.com/remix-run/indie-stack +Cypress.on("uncaught:exception", (err) => { + // Cypress and React Hydrating the document don't get along + // for some unknown reason. Hopefully we figure out why eventually + // so we can remove this. + if ( + /hydrat/i.test(err.message) || + /Minified React error #418/.test(err.message) || + /Minified React error #423/.test(err.message) + ) { + return false; + } +});` + ); + + return runTasksInSerial(installTask); +} + +function addFileServerTarget( + tree: Tree, + options: CypressGeneratorSchema, + targetName: string +) { + addDependenciesToPackageJson(tree, {}, { '@nx/web': nxVersion }); + + const projectConfig = readProjectConfiguration(tree, options.project); + projectConfig.targets[targetName] = { + executor: '@nx/web:file-server', + options: { + buildTarget: `${options.project}:build`, + port: projectConfig.targets['serve'].options.port, + }, + }; + updateProjectConfiguration(tree, options.project, projectConfig); +} diff --git a/packages/remix/src/generators/cypress/schema.d.ts b/packages/remix/src/generators/cypress/schema.d.ts new file mode 100644 index 0000000000..ddd64b3936 --- /dev/null +++ b/packages/remix/src/generators/cypress/schema.d.ts @@ -0,0 +1,14 @@ +import { type ProjectNameAndRootFormat } from '@nx/devkit/src/generators/project-name-and-root-utils'; +import { Linter } from '@nx/eslint'; + +export interface CypressGeneratorSchema { + project: string; + name: string; + baseUrl?: string; + directory?: string; + projectNameAndRootFormat?: ProjectNameAndRootFormat; + linter?: Linter; + js?: boolean; + skipFormat?: boolean; + setParserOptionsProject?: boolean; +} diff --git a/packages/remix/src/generators/cypress/schema.json b/packages/remix/src/generators/cypress/schema.json new file mode 100644 index 0000000000..0dced5cdd1 --- /dev/null +++ b/packages/remix/src/generators/cypress/schema.json @@ -0,0 +1,61 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "NxRemixCypress", + "title": "", + "type": "object", + "description": "Generate a Cypress e2e project for a given application.", + "properties": { + "project": { + "type": "string", + "description": "The name of the frontend project to test.", + "$default": { + "$source": "projectName" + } + }, + "projectNameAndRootFormat": { + "description": "Whether to generate the project name and root directory as provided (`as-provided`) or generate them composing their values and taking the configured layout into account (`derived`).", + "type": "string", + "enum": ["as-provided", "derived"] + }, + "baseUrl": { + "type": "string", + "description": "URL to access the application on", + "default": "http://localhost:3000" + }, + "name": { + "type": "string", + "description": "Name of the E2E Project", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What name would you like to use for the e2e project?" + }, + "directory": { + "type": "string", + "description": "A directory where the project is placed" + }, + "linter": { + "description": "The tool to use for running lint checks.", + "type": "string", + "enum": ["eslint", "none"], + "default": "eslint" + }, + "js": { + "description": "Generate JavaScript files rather than TypeScript files", + "type": "boolean", + "default": false + }, + "skipFormat": { + "description": "Skip formatting files", + "type": "boolean", + "default": false + }, + "setParserOptionsProject": { + "type": "boolean", + "description": "Whether or not to configure the ESLint \"parserOptions.project\" option. We do not do this by default for lint performance reasons.", + "default": false + } + }, + "required": ["name"] +} diff --git a/packages/remix/src/generators/error-boundary/__snapshots__/error-boundary.impl.spec.ts.snap b/packages/remix/src/generators/error-boundary/__snapshots__/error-boundary.impl.spec.ts.snap new file mode 100644 index 0000000000..aa89f2fc78 --- /dev/null +++ b/packages/remix/src/generators/error-boundary/__snapshots__/error-boundary.impl.spec.ts.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ErrorBoundary --nameAndDirectoryFormat=as-provided --apiVersion=2 should correctly add the ErrorBoundary to the route file 1`] = ` +"import { useRouteError, isRouteErrorResponse } from '@remix-run/react'; +export function ErrorBoundary() { + const error = useRouteError(); + + // when true, this is what used to go to 'CatchBoundary' + if (isRouteErrorResponse(error)) { + return ( +
+

Oops

+

Status: {error.status}

+

{error.data.message}

+
+ ); + } else if (error instanceof Error) { + return ( +
+

Error

+

{error.message}

+

The stack trace is:

+
{error.stack}
+
+ ); + } else { + return

Unknown Error

; + } +} +" +`; + +exports[`ErrorBoundary --nameAndDirectoryFormat=as-provided --apiVersion=2 should correctly add the ErrorBoundary to the route file 2`] = ` +"import { useRouteError, isRouteErrorResponse } from '@remix-run/react'; +export function ErrorBoundary() { + const error = useRouteError(); + + // when true, this is what used to go to 'CatchBoundary' + if (isRouteErrorResponse(error)) { + return ( +
+

Oops

+

Status: {error.status}

+

{error.data.message}

+
+ ); + } else if (error instanceof Error) { + return ( +
+

Error

+

{error.message}

+

The stack trace is:

+
{error.stack}
+
+ ); + } else { + return

Unknown Error

; + } +} +" +`; diff --git a/packages/remix/src/generators/error-boundary/error-boundary.impl.spec.ts b/packages/remix/src/generators/error-boundary/error-boundary.impl.spec.ts new file mode 100644 index 0000000000..6e6198ea3f --- /dev/null +++ b/packages/remix/src/generators/error-boundary/error-boundary.impl.spec.ts @@ -0,0 +1,67 @@ +import { addProjectConfiguration } from '@nx/devkit'; +import { NameAndDirectoryFormat } from '@nx/devkit/src/generators/artifact-name-and-directory-utils'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import errorBoundaryGenerator from './error-boundary.impl'; + +describe('ErrorBoundary', () => { + describe.each([ + ['derived', 'app/routes/test.tsx', 'demo'], + ['as-provided', 'app/routes/test.tsx', ''], + ])( + `--nameAndDirectoryFormat=as-provided`, + ( + nameAndDirectoryFormat: NameAndDirectoryFormat, + routeFilePath: string, + project: string + ) => { + describe('--apiVersion=2', () => { + it('should correctly add the ErrorBoundary to the route file', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + addProjectConfiguration(tree, 'demo', { + name: 'demo', + root: '.', + sourceRoot: '.', + projectType: 'application', + }); + const routeFilePath = `app/routes/test.tsx`; + tree.write(routeFilePath, ``); + tree.write('remix.config.cjs', `module.exports = {}`); + + // ACT + await errorBoundaryGenerator(tree, { + project, + nameAndDirectoryFormat, + path: routeFilePath, + }); + + // ASSERT + expect(tree.read(routeFilePath, 'utf-8')).toMatchSnapshot(); + }); + + it('should error when the route file cannot be found', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + addProjectConfiguration(tree, 'demo', { + name: 'demo', + root: '.', + sourceRoot: '.', + projectType: 'application', + }); + const routeFilePath = `app/routes/test.tsx`; + tree.write(routeFilePath, ``); + tree.write('remix.config.cjs', `module.exports = {}`); + + // ACT & ASSERT + await expect( + errorBoundaryGenerator(tree, { + project, + nameAndDirectoryFormat, + path: `my-route.tsx`, + }) + ).rejects.toThrow(); + }); + }); + } + ); +}); diff --git a/packages/remix/src/generators/error-boundary/error-boundary.impl.ts b/packages/remix/src/generators/error-boundary/error-boundary.impl.ts new file mode 100644 index 0000000000..fa1a7e22f8 --- /dev/null +++ b/packages/remix/src/generators/error-boundary/error-boundary.impl.ts @@ -0,0 +1,16 @@ +import { formatFiles, type Tree } from '@nx/devkit'; +import { addV2ErrorBoundary, normalizeOptions } from './lib'; +import type { ErrorBoundarySchema } from './schema'; + +export default async function errorBoundaryGenerator( + tree: Tree, + schema: ErrorBoundarySchema +) { + const options = await normalizeOptions(tree, schema); + + addV2ErrorBoundary(tree, options); + + if (!options.skipFormat) { + await formatFiles(tree); + } +} diff --git a/packages/remix/src/generators/error-boundary/lib/add-v2-error-boundary.ts b/packages/remix/src/generators/error-boundary/lib/add-v2-error-boundary.ts new file mode 100644 index 0000000000..381384f857 --- /dev/null +++ b/packages/remix/src/generators/error-boundary/lib/add-v2-error-boundary.ts @@ -0,0 +1,41 @@ +import { stripIndents, type Tree } from '@nx/devkit'; +import { insertImport } from '../../../utils/insert-import'; +import { insertStatementAfterImports } from '../../../utils/insert-statement-after-imports'; +import type { ErrorBoundarySchema } from '../schema'; + +export function addV2ErrorBoundary(tree: Tree, options: ErrorBoundarySchema) { + insertImport(tree, options.path, `useRouteError`, '@remix-run/react'); + insertImport(tree, options.path, `isRouteErrorResponse`, '@remix-run/react'); + + insertStatementAfterImports( + tree, + options.path, + stripIndents` + export function ErrorBoundary() { + const error = useRouteError(); + + // when true, this is what used to go to 'CatchBoundary' + if (isRouteErrorResponse(error)) { + return ( +
+

Oops

+

Status: {error.status}

+

{error.data.message}

+
+ ); + } else if (error instanceof Error) { + return ( +
+

Error

+

{error.message}

+

The stack trace is:

+
{error.stack}
+
+ ); + } else { + return

Unknown Error

; + } + } + ` + ); +} diff --git a/packages/remix/src/generators/error-boundary/lib/index.ts b/packages/remix/src/generators/error-boundary/lib/index.ts new file mode 100644 index 0000000000..0cce967d13 --- /dev/null +++ b/packages/remix/src/generators/error-boundary/lib/index.ts @@ -0,0 +1,2 @@ +export * from './add-v2-error-boundary'; +export * from './normalize-options'; diff --git a/packages/remix/src/generators/error-boundary/lib/normalize-options.ts b/packages/remix/src/generators/error-boundary/lib/normalize-options.ts new file mode 100644 index 0000000000..d1505fc3b0 --- /dev/null +++ b/packages/remix/src/generators/error-boundary/lib/normalize-options.ts @@ -0,0 +1,24 @@ +import { type Tree } from '@nx/devkit'; +import { resolveRemixRouteFile } from '../../../utils/remix-route-utils'; +import type { ErrorBoundarySchema } from '../schema'; + +export async function normalizeOptions( + tree: Tree, + schema: ErrorBoundarySchema +): Promise { + const pathToRouteFile = + schema.nameAndDirectoryFormat === 'as-provided' + ? schema.path + : await resolveRemixRouteFile(tree, schema.path, schema.project); + + if (!tree.exists(pathToRouteFile)) { + throw new Error( + `Route file specified does not exist "${pathToRouteFile}". Please ensure you pass a correct path to the file.` + ); + } + + return { + ...schema, + path: pathToRouteFile, + }; +} diff --git a/packages/remix/src/generators/error-boundary/schema.d.ts b/packages/remix/src/generators/error-boundary/schema.d.ts new file mode 100644 index 0000000000..9c4893aa62 --- /dev/null +++ b/packages/remix/src/generators/error-boundary/schema.d.ts @@ -0,0 +1,11 @@ +import { NameAndDirectoryFormat } from '@nx/devkit/src/generators/artifact-name-and-directory-utils'; + +export interface ErrorBoundarySchema { + path: string; + skipFormat?: false; + nameAndDirectoryFormat?: NameAndDirectoryFormat; + /** + * @deprecated Provide the `path` option instead. The project will be determined from the path provided. It will be removed in Nx v18. + */ + project?: string; +} diff --git a/packages/remix/src/generators/error-boundary/schema.json b/packages/remix/src/generators/error-boundary/schema.json new file mode 100644 index 0000000000..c04381b047 --- /dev/null +++ b/packages/remix/src/generators/error-boundary/schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "NxRemixErrorBoundary", + "title": "Create an ErrorBoundary for a Route", + "description": "Generate an ErrorBoundary for a given route.", + "type": "object", + "examples": [ + { + "command": "g error-boundary --routePath=apps/demo/app/routes/my-route.tsx", + "description": "Generate an ErrorBoundary for my-route.tsx" + } + ], + "properties": { + "path": { + "type": "string", + "description": "The path to route file relative to the project root." + }, + "nameAndDirectoryFormat": { + "description": "Whether to generate the error boundary in the path as provided, relative to the current working directory and ignoring the project (`as-provided`) or generate it using the project and directory relative to the workspace root (`derived`).", + "type": "string", + "enum": ["as-provided", "derived"] + }, + "project": { + "type": "string", + "description": "The name of the project.", + "$default": { + "$source": "projectName" + }, + "x-prompt": "What project contains the route file that this ErrorBoundary is for?", + "pattern": "^[a-zA-Z].*$", + "x-deprecated": "Provide the `path` option instead and use the `as-provided` format. The project will be determined from the path provided. It will be removed in Nx v18." + }, + "skipFormat": { + "type": "boolean", + "description": "Skip formatting files after generation.", + "default": false, + "x-priority": "internal" + } + }, + "required": ["path"] +} diff --git a/packages/remix/src/generators/library/__snapshots__/library.impl.spec.ts.snap b/packages/remix/src/generators/library/__snapshots__/library.impl.spec.ts.snap new file mode 100644 index 0000000000..cd70040d87 --- /dev/null +++ b/packages/remix/src/generators/library/__snapshots__/library.impl.spec.ts.snap @@ -0,0 +1,157 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Remix Library Generator -projectNameAndRootFormat=as-provided --unitTestRunner should create the correct config files for testing with jest 1`] = ` +"/* eslint-disable */ +export default { + setupFilesAfterEnv: ['./src/test-setup.ts'], + displayName: 'test', + preset: '../jest.preset.js', + transform: { + '^(?!.*\\\\.(js|jsx|ts|tsx|css|json)$)': '@nx/react/plugins/jest', + '^.+\\\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/react/babel'] }], + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + coverageDirectory: '../coverage/test', +}; +" +`; + +exports[`Remix Library Generator -projectNameAndRootFormat=as-provided --unitTestRunner should create the correct config files for testing with jest 2`] = ` +"import { installGlobals } from '@remix-run/node'; +import '@testing-library/jest-dom/matchers'; +installGlobals(); +" +`; + +exports[`Remix Library Generator -projectNameAndRootFormat=as-provided --unitTestRunner should create the correct config files for testing with vitest 1`] = ` +"import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + root: __dirname, + cacheDir: '../node_modules/.vite/test', + + plugins: [react(), nxViteTsPaths()], + + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + + test: { + setupFiles: ['./src/test-setup.ts'], + globals: true, + cache: { dir: '../node_modules/.vitest' }, + environment: 'jsdom', + include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + coverage: { reportsDirectory: '../coverage/test', provider: 'v8' }, + }, +}); +" +`; + +exports[`Remix Library Generator -projectNameAndRootFormat=as-provided --unitTestRunner should create the correct config files for testing with vitest 2`] = ` +"import { installGlobals } from '@remix-run/node'; +import '@testing-library/jest-dom/matchers'; +installGlobals(); +" +`; + +exports[`Remix Library Generator -projectNameAndRootFormat=as-provided should generate a library correctly 1`] = ` +[ + "test.module.css", + "test.spec.tsx", + "test.tsx", +] +`; + +exports[`Remix Library Generator -projectNameAndRootFormat=as-provided should generate a library correctly 2`] = ` +{ + "@proj/test": [ + "test/src/index.ts", + ], + "@proj/test/server": [ + "test/src/server.ts", + ], +} +`; + +exports[`Remix Library Generator -projectNameAndRootFormat=derived --unitTestRunner should create the correct config files for testing with jest 1`] = ` +"/* eslint-disable */ +export default { + setupFilesAfterEnv: ['./src/test-setup.ts'], + displayName: 'test', + preset: '../../jest.preset.js', + transform: { + '^(?!.*\\\\.(js|jsx|ts|tsx|css|json)$)': '@nx/react/plugins/jest', + '^.+\\\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/react/babel'] }], + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + coverageDirectory: '../../coverage/libs/test', +}; +" +`; + +exports[`Remix Library Generator -projectNameAndRootFormat=derived --unitTestRunner should create the correct config files for testing with jest 2`] = ` +"import { installGlobals } from '@remix-run/node'; +import '@testing-library/jest-dom/matchers'; +installGlobals(); +" +`; + +exports[`Remix Library Generator -projectNameAndRootFormat=derived --unitTestRunner should create the correct config files for testing with vitest 1`] = ` +"import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + root: __dirname, + cacheDir: '../../node_modules/.vite/libs/test', + + plugins: [react(), nxViteTsPaths()], + + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + + test: { + setupFiles: ['./src/test-setup.ts'], + globals: true, + cache: { dir: '../../node_modules/.vitest' }, + environment: 'jsdom', + include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + coverage: { reportsDirectory: '../../coverage/libs/test', provider: 'v8' }, + }, +}); +" +`; + +exports[`Remix Library Generator -projectNameAndRootFormat=derived --unitTestRunner should create the correct config files for testing with vitest 2`] = ` +"import { installGlobals } from '@remix-run/node'; +import '@testing-library/jest-dom/matchers'; +installGlobals(); +" +`; + +exports[`Remix Library Generator -projectNameAndRootFormat=derived should generate a library correctly 1`] = ` +[ + "test.module.css", + "test.spec.tsx", + "test.tsx", +] +`; + +exports[`Remix Library Generator -projectNameAndRootFormat=derived should generate a library correctly 2`] = ` +{ + "@proj/libs/test": [ + "libs/test/src/index.ts", + ], + "@proj/libs/test/server": [ + "libs/test/src/server.ts", + ], +} +`; diff --git a/packages/remix/src/generators/library/lib/add-tsconfig-entry-points.ts b/packages/remix/src/generators/library/lib/add-tsconfig-entry-points.ts new file mode 100644 index 0000000000..f1081b544c --- /dev/null +++ b/packages/remix/src/generators/library/lib/add-tsconfig-entry-points.ts @@ -0,0 +1,35 @@ +import type { Tree } from '@nx/devkit'; +import { + joinPathFragments, + readProjectConfiguration, + updateJson, +} from '@nx/devkit'; +import { getRootTsConfigPathInTree } from '@nx/js'; +import type { RemixLibraryOptions } from './normalize-options'; + +export function addTsconfigEntryPoints( + tree: Tree, + options: RemixLibraryOptions +) { + const { sourceRoot } = readProjectConfiguration(tree, options.projectName); + const serverFilePath = joinPathFragments(sourceRoot, 'server.ts'); + + tree.write( + serverFilePath, + `// This file should be used to export ONLY server-code from the library.` + ); + + const baseTsConfig = getRootTsConfigPathInTree(tree); + updateJson(tree, baseTsConfig, (json) => { + if ( + json.compilerOptions.paths && + json.compilerOptions.paths[options.importPath] + ) { + json.compilerOptions.paths[ + joinPathFragments(options.importPath, 'server') + ] = [serverFilePath]; + } + + return json; + }); +} diff --git a/packages/remix/src/generators/library/lib/add-unit-testing.ts b/packages/remix/src/generators/library/lib/add-unit-testing.ts new file mode 100644 index 0000000000..8698227b9e --- /dev/null +++ b/packages/remix/src/generators/library/lib/add-unit-testing.ts @@ -0,0 +1,62 @@ +import { + addDependenciesToPackageJson, + joinPathFragments, + stripIndents, + type Tree, +} from '@nx/devkit'; +import { + updateJestTestSetup, + updateViteTestSetup, +} from '../../../utils/testing-config-utils'; +import { + getRemixVersion, + testingLibraryJestDomVersion, + testingLibraryReactVersion, + testingLibraryUserEventsVersion, +} from '../../../utils/versions'; +import type { RemixLibraryOptions } from './normalize-options'; + +export function addUnitTestingSetup(tree: Tree, options: RemixLibraryOptions) { + const pathToTestSetup = joinPathFragments( + options.projectRoot, + 'src/test-setup.ts' + ); + let testSetupFileContents = ''; + + if (tree.exists(pathToTestSetup)) { + testSetupFileContents = tree.read(pathToTestSetup, 'utf-8'); + } + + tree.write( + pathToTestSetup, + stripIndents`${testSetupFileContents} + import { installGlobals } from '@remix-run/node'; + import "@testing-library/jest-dom/matchers"; + installGlobals();` + ); + + if (options.unitTestRunner === 'vitest') { + const pathToVitestConfig = joinPathFragments( + options.projectRoot, + `vite.config.ts` + ); + updateViteTestSetup(tree, pathToVitestConfig, './src/test-setup.ts'); + } else if (options.unitTestRunner === 'jest') { + const pathToJestConfig = joinPathFragments( + options.projectRoot, + `jest.config.ts` + ); + updateJestTestSetup(tree, pathToJestConfig, './src/test-setup.ts'); + } + + return addDependenciesToPackageJson( + tree, + {}, + { + '@testing-library/jest-dom': testingLibraryJestDomVersion, + '@testing-library/react': testingLibraryReactVersion, + '@testing-library/user-event': testingLibraryUserEventsVersion, + '@remix-run/node': getRemixVersion(tree), + } + ); +} diff --git a/packages/remix/src/generators/library/lib/index.ts b/packages/remix/src/generators/library/lib/index.ts new file mode 100644 index 0000000000..95f271206e --- /dev/null +++ b/packages/remix/src/generators/library/lib/index.ts @@ -0,0 +1,4 @@ +export * from './add-tsconfig-entry-points'; +export * from './add-unit-testing'; +export * from './normalize-options'; +export * from './update-buildable-config'; diff --git a/packages/remix/src/generators/library/lib/normalize-options.ts b/packages/remix/src/generators/library/lib/normalize-options.ts new file mode 100644 index 0000000000..a4e1bdf382 --- /dev/null +++ b/packages/remix/src/generators/library/lib/normalize-options.ts @@ -0,0 +1,33 @@ +import type { Tree } from '@nx/devkit'; +import { determineProjectNameAndRootOptions } from '@nx/devkit/src/generators/project-name-and-root-utils'; +import { getImportPath } from '@nx/js/src/utils/get-import-path'; +import type { NxRemixGeneratorSchema } from '../schema'; + +export interface RemixLibraryOptions extends NxRemixGeneratorSchema { + projectName: string; + projectRoot: string; +} + +export async function normalizeOptions( + tree: Tree, + options: NxRemixGeneratorSchema +): Promise { + const { projectName, projectRoot, projectNameAndRootFormat } = + await determineProjectNameAndRootOptions(tree, { + name: options.name, + projectType: 'library', + directory: options.directory, + projectNameAndRootFormat: options.projectNameAndRootFormat, + callingGenerator: '@nx/remix:library', + }); + + const importPath = options.importPath ?? getImportPath(tree, projectRoot); + + return { + ...options, + unitTestRunner: options.unitTestRunner ?? 'vitest', + importPath, + projectName, + projectRoot, + }; +} diff --git a/packages/remix/src/generators/library/lib/update-buildable-config.ts b/packages/remix/src/generators/library/lib/update-buildable-config.ts new file mode 100644 index 0000000000..a095853954 --- /dev/null +++ b/packages/remix/src/generators/library/lib/update-buildable-config.ts @@ -0,0 +1,25 @@ +import type { Tree } from '@nx/devkit'; +import { + joinPathFragments, + readProjectConfiguration, + updateJson, + updateProjectConfiguration, +} from '@nx/devkit'; + +export function updateBuildableConfig(tree: Tree, name: string) { + // Nest dist under project root to we can link it + const project = readProjectConfiguration(tree, name); + project.targets.build.options = { + ...project.targets.build.options, + format: ['cjs'], + outputPath: joinPathFragments(project.root, 'dist'), + }; + updateProjectConfiguration(tree, name, project); + + // Point to nested dist for yarn/npm/pnpm workspaces + updateJson(tree, joinPathFragments(project.root, 'package.json'), (json) => { + json.main = './dist/index.cjs.js'; + json.typings = './dist/index.d.ts'; + return json; + }); +} diff --git a/packages/remix/src/generators/library/library.impl.spec.ts b/packages/remix/src/generators/library/library.impl.spec.ts new file mode 100644 index 0000000000..110f855e93 --- /dev/null +++ b/packages/remix/src/generators/library/library.impl.spec.ts @@ -0,0 +1,147 @@ +import { readJson, readProjectConfiguration } from '@nx/devkit'; +import { type ProjectNameAndRootFormat } from '@nx/devkit/src/generators/project-name-and-root-utils'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import applicationGenerator from '../application/application.impl'; +import libraryGenerator from './library.impl'; + +describe('Remix Library Generator', () => { + describe.each([ + ['derived', 'libs/test'], + ['as-provided', 'test'], + ])( + '-projectNameAndRootFormat=%s', + (projectNameAndRootFormat: ProjectNameAndRootFormat, libDir) => { + it('should generate a library correctly', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + + // ACT + await libraryGenerator(tree, { + name: 'test', + style: 'css', + projectNameAndRootFormat, + }); + + // ASSERT + const tsconfig = readJson(tree, 'tsconfig.base.json'); + expect(tree.exists(`${libDir}/src/server.ts`)); + expect(tree.children(`${libDir}/src/lib`)).toMatchSnapshot(); + expect(tsconfig.compilerOptions.paths).toMatchSnapshot(); + }, 25_000); + + describe('Standalone Project Repo', () => { + it('should update the tsconfig paths correctly', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + await applicationGenerator(tree, { + name: 'demo', + rootProject: true, + }); + const originalBaseTsConfig = readJson(tree, 'tsconfig.json'); + + // ACT + await libraryGenerator(tree, { + name: 'test', + style: 'css', + projectNameAndRootFormat, + }); + + // ASSERT + const updatedBaseTsConfig = readJson(tree, 'tsconfig.base.json'); + expect( + Object.keys(originalBaseTsConfig.compilerOptions.paths) + ).toContain('~/*'); + expect( + Object.keys(updatedBaseTsConfig.compilerOptions.paths) + ).toContain('~/*'); + }); + }); + + describe('--unitTestRunner', () => { + it('should not create config files when unitTestRunner=none', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + + // ACT + await libraryGenerator(tree, { + name: 'test', + style: 'css', + unitTestRunner: 'none', + projectNameAndRootFormat, + }); + + // ASSERT + expect(tree.exists(`${libDir}/jest.config.ts`)).toBeFalsy(); + expect(tree.exists(`${libDir}/vite.config.ts`)).toBeFalsy(); + }); + + it('should create the correct config files for testing with jest', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + + // ACT + await libraryGenerator(tree, { + name: 'test', + style: 'css', + unitTestRunner: 'jest', + projectNameAndRootFormat, + }); + + // ASSERT + expect( + tree.read(`${libDir}/jest.config.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`${libDir}/src/test-setup.ts`, 'utf-8') + ).toMatchSnapshot(); + }); + + it('should create the correct config files for testing with vitest', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + + // ACT + await libraryGenerator(tree, { + name: 'test', + style: 'css', + unitTestRunner: 'vitest', + projectNameAndRootFormat, + }); + + // ASSERT + expect( + tree.read(`${libDir}/vite.config.ts`, 'utf-8') + ).toMatchSnapshot(); + + expect( + tree.read(`${libDir}/src/test-setup.ts`, 'utf-8') + ).toMatchSnapshot(); + }, 25_000); + }); + + // TODO(Colum): Unskip this when buildable is investigated correctly + xit('should generate the config files correctly when the library is buildable', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + + // ACT + await libraryGenerator(tree, { + name: 'test', + style: 'css', + buildable: true, + projectNameAndRootFormat, + }); + + // ASSERT + const project = readProjectConfiguration(tree, 'test'); + const pkgJson = readJson(tree, `${libDir}/package.json`); + expect(project.targets.build.options.format).toEqual(['cjs']); + expect(project.targets.build.options.outputPath).toEqual( + `${libDir}/dist` + ); + expect(pkgJson.main).toEqual('./dist/index.cjs.js'); + expect(pkgJson.typings).toEqual('./dist/index.d.ts'); + }); + } + ); +}); diff --git a/packages/remix/src/generators/library/library.impl.ts b/packages/remix/src/generators/library/library.impl.ts new file mode 100644 index 0000000000..8eebe8b360 --- /dev/null +++ b/packages/remix/src/generators/library/library.impl.ts @@ -0,0 +1,49 @@ +import type { Tree } from '@nx/devkit'; +import { formatFiles, GeneratorCallback, runTasksInSerial } from '@nx/devkit'; +import { Linter } from '@nx/eslint'; +import { libraryGenerator } from '@nx/react'; +import { + addTsconfigEntryPoints, + addUnitTestingSetup, + normalizeOptions, + updateBuildableConfig, +} from './lib'; +import type { NxRemixGeneratorSchema } from './schema'; + +export default async function (tree: Tree, schema: NxRemixGeneratorSchema) { + const tasks: GeneratorCallback[] = []; + const options = await normalizeOptions(tree, schema); + + const libGenTask = await libraryGenerator(tree, { + name: options.projectName, + style: options.style, + unitTestRunner: options.unitTestRunner, + tags: options.tags, + importPath: options.importPath, + directory: options.projectRoot, + projectNameAndRootFormat: 'as-provided', + skipFormat: true, + skipTsConfig: false, + linter: Linter.EsLint, + component: true, + buildable: options.buildable, + }); + tasks.push(libGenTask); + + if (options.unitTestRunner && options.unitTestRunner !== 'none') { + const pkgInstallTask = addUnitTestingSetup(tree, options); + tasks.push(pkgInstallTask); + } + + addTsconfigEntryPoints(tree, options); + + if (options.buildable) { + updateBuildableConfig(tree, options.projectName); + } + + if (!options.skipFormat) { + await formatFiles(tree); + } + + return runTasksInSerial(...tasks); +} diff --git a/packages/remix/src/generators/library/schema.d.ts b/packages/remix/src/generators/library/schema.d.ts new file mode 100644 index 0000000000..05ce3c85ae --- /dev/null +++ b/packages/remix/src/generators/library/schema.d.ts @@ -0,0 +1,15 @@ +import { ProjectNameAndRootFormat } from '@nx/devkit/src/generators/project-name-and-root-utils'; +import { SupportedStyles } from '@nx/react'; + +export interface NxRemixGeneratorSchema { + name: string; + style: SupportedStyles; + directory?: string; + projectNameAndRootFormat?: ProjectNameAndRootFormat; + tags?: string; + importPath?: string; + buildable?: boolean; + unitTestRunner?: 'jest' | 'vitest' | 'none'; + js?: boolean; + skipFormat?: boolean; +} diff --git a/packages/remix/src/generators/library/schema.json b/packages/remix/src/generators/library/schema.json new file mode 100644 index 0000000000..a4fb026cd6 --- /dev/null +++ b/packages/remix/src/generators/library/schema.json @@ -0,0 +1,74 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "NxRemixLibrary", + "title": "Create a Library", + "description": "Generate a Remix library to help structure workspace and application.", + "type": "object", + "examples": [ + { + "command": "g lib mylib --directory=myapp", + "description": "Generate libs/myapp/mylib" + } + ], + "properties": { + "name": { + "type": "string", + "description": "Library name", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What name would you like to use for the library?", + "pattern": "^[a-zA-Z].*$" + }, + "directory": { + "type": "string", + "description": "A directory where the lib is placed.", + "alias": "dir", + "x-priority": "important" + }, + "projectNameAndRootFormat": { + "description": "Whether to generate the project name and root directory as provided (`as-provided`) or generate them composing their values and taking the configured layout into account (`derived`).", + "type": "string", + "enum": ["as-provided", "derived"] + }, + "tags": { + "type": "string", + "description": "Add tags to the library (used for linting)" + }, + "style": { + "type": "string", + "description": "Generate a stylesheet", + "enum": ["none", "css"], + "default": "css" + }, + "buildable": { + "type": "boolean", + "description": "Should the library be buildable?", + "default": false + }, + "unitTestRunner": { + "type": "string", + "enum": ["jest", "vitest", "none"], + "description": "Test Runner to use for Unit Tests", + "x-prompt": "What test runner should be used?", + "default": "vitest" + }, + "importPath": { + "type": "string", + "description": "The library name used to import it, like @myorg/my-awesome-lib" + }, + "js": { + "type": "boolean", + "description": "Generate JavaScript files rather than TypeScript files", + "default": false + }, + "skipFormat": { + "type": "boolean", + "description": "Skip formatting files after generator runs", + "default": false, + "x-priority": "internal" + } + }, + "required": ["name"] +} diff --git a/packages/remix/src/generators/loader/loader.impl.spec.ts b/packages/remix/src/generators/loader/loader.impl.spec.ts new file mode 100644 index 0000000000..fbe3869afe --- /dev/null +++ b/packages/remix/src/generators/loader/loader.impl.spec.ts @@ -0,0 +1,90 @@ +import { Tree } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import applicationGenerator from '../application/application.impl'; +import routeGenerator from '../route/route.impl'; +import loaderGenerator from './loader.impl'; + +describe('loader', () => { + let tree: Tree; + + beforeEach(async () => { + tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + tree.write('.gitignore', `/node_modules/dist`); + + await applicationGenerator(tree, { name: 'demo' }); + await routeGenerator(tree, { + path: 'example', + project: 'demo', + style: 'none', + loader: false, + action: false, + meta: false, + skipChecks: false, + }); + }); + + [ + { + path: 'apps/demo/app/routes/example.tsx', + }, + { + path: 'example', + }, + { + path: 'example.tsx', + }, + ].forEach((config) => { + describe(`add loader using route path "${config.path}"`, () => { + beforeEach(async () => { + await loaderGenerator(tree, { + path: config.path, + project: 'demo', + }); + }); + + it('should add imports', async () => { + const content = tree.read('apps/demo/app/routes/example.tsx', 'utf-8'); + expect(content).toMatch(`import { json } from '@remix-run/node';`); + expect(content).toMatch( + `import type { LoaderFunctionArgs } from '@remix-run/node';` + ); + expect(content).toMatch( + `import { useLoaderData } from '@remix-run/react';` + ); + }); + + it('should add loader function', () => { + const loaderFunction = `export const loader = async`; + const content = tree.read('apps/demo/app/routes/example.tsx', 'utf-8'); + expect(content).toMatch(loaderFunction); + }); + + it('should add useLoaderData to component', () => { + const useLoaderData = `const data = useLoaderData();`; + + const content = tree.read('apps/demo/app/routes/example.tsx', 'utf-8'); + expect(content).toMatch(useLoaderData); + }); + }); + }); + + describe('--nameAndDirectoryFormat=as-provided', () => { + it('should add imports', async () => { + // ACT + await loaderGenerator(tree, { + path: 'apps/demo/app/routes/example.tsx', + nameAndDirectoryFormat: 'as-provided', + }); + + // ASSERT + const content = tree.read('apps/demo/app/routes/example.tsx', 'utf-8'); + expect(content).toMatch(`import { json } from '@remix-run/node';`); + expect(content).toMatch( + `import type { LoaderFunctionArgs } from '@remix-run/node';` + ); + expect(content).toMatch( + `import { useLoaderData } from '@remix-run/react';` + ); + }); + }); +}); diff --git a/packages/remix/src/generators/loader/loader.impl.ts b/packages/remix/src/generators/loader/loader.impl.ts new file mode 100644 index 0000000000..d575f1c228 --- /dev/null +++ b/packages/remix/src/generators/loader/loader.impl.ts @@ -0,0 +1,48 @@ +import { formatFiles, Tree } from '@nx/devkit'; +import { insertImport } from '../../utils/insert-import'; +import { insertStatementAfterImports } from '../../utils/insert-statement-after-imports'; +import { insertStatementInDefaultFunction } from '../../utils/insert-statement-in-default-function'; +import { resolveRemixRouteFile } from '../../utils/remix-route-utils'; +import { LoaderSchema } from './schema'; + +export default async function (tree: Tree, schema: LoaderSchema) { + const routeFilePath = + schema.nameAndDirectoryFormat === 'as-provided' + ? schema.path + : await resolveRemixRouteFile(tree, schema.path, schema.project); + + if (!tree.exists(routeFilePath)) { + throw new Error( + `Route path does not exist: ${routeFilePath}. Please generate a Remix route first.` + ); + } + + insertImport(tree, routeFilePath, 'useLoaderData', '@remix-run/react'); + insertImport(tree, routeFilePath, 'json', '@remix-run/node'); + insertImport(tree, routeFilePath, 'LoaderFunctionArgs', '@remix-run/node', { + typeOnly: true, + }); + + insertStatementAfterImports( + tree, + routeFilePath, + ` + export const loader = async ({request}: LoaderFunctionArgs ) => { + return json({ + message: 'Hello, world!', + }) + }; + + ` + ); + + const statement = `\nconst data = useLoaderData();`; + + try { + insertStatementInDefaultFunction(tree, routeFilePath, statement); + // eslint-disable-next-line no-empty + } catch (err) { + } finally { + await formatFiles(tree); + } +} diff --git a/packages/remix/src/generators/loader/schema.d.ts b/packages/remix/src/generators/loader/schema.d.ts new file mode 100644 index 0000000000..9315409e92 --- /dev/null +++ b/packages/remix/src/generators/loader/schema.d.ts @@ -0,0 +1,10 @@ +import { NameAndDirectoryFormat } from '@nx/devkit/src/generators/artifact-name-and-directory-utils'; + +export interface LoaderSchema { + path: string; + nameAndDirectoryFormat?: NameAndDirectoryFormat; + /** + * @deprecated Provide the `path` option instead. The project will be determined from the path provided. It will be removed in Nx v18. + */ + project?: string; +} diff --git a/packages/remix/src/generators/loader/schema.json b/packages/remix/src/generators/loader/schema.json new file mode 100644 index 0000000000..b2e1795770 --- /dev/null +++ b/packages/remix/src/generators/loader/schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "data-loader", + "type": "object", + "description": "Generate an loader for a given route.", + "properties": { + "path": { + "type": "string", + "description": "The route path or path to the filename of the route.", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What is the path of the route? (e.g. 'apps/demo/app/routes/foo/bar.tsx')" + }, + "nameAndDirectoryFormat": { + "description": "Whether to generate the loader in the path as provided, relative to the current working directory and ignoring the project (`as-provided`) or generate it using the project and directory relative to the workspace root (`derived`).", + "type": "string", + "enum": ["as-provided", "derived"] + }, + "project": { + "type": "string", + "description": "The name of the project.", + "$default": { + "$source": "projectName" + }, + "x-prompt": "What project is this route for?", + "pattern": "^[a-zA-Z].*$", + "x-deprecated": "Provide the `path` option instead and use the `as-provided` format. The project will be determined from the path provided. It will be removed in Nx v18." + } + }, + "required": ["path"] +} diff --git a/packages/remix/src/generators/meta/lib/v2.impl.spec.ts b/packages/remix/src/generators/meta/lib/v2.impl.spec.ts new file mode 100644 index 0000000000..17154a0e3b --- /dev/null +++ b/packages/remix/src/generators/meta/lib/v2.impl.spec.ts @@ -0,0 +1,41 @@ +import { Tree } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import applicationGenerator from '../../application/application.impl'; +import routeGenerator from '../../route/route.impl'; +import { v2MetaGenerator } from './v2.impl'; + +describe('meta v2', () => { + let tree: Tree; + + test.each([['apps/demo/app/routes/example.tsx', 'example', 'example.tsx']])( + 'add meta using route path "%s"', + async (path) => { + tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + tree.write('.gitignore', `/node_modules/dist`); + + await applicationGenerator(tree, { name: 'demo' }); + await routeGenerator(tree, { + path: 'example', + project: 'demo', + style: 'none', + loader: false, + action: false, + meta: false, + skipChecks: false, + }); + + await v2MetaGenerator(tree, { + path, + project: 'demo', + }); + + const content = tree.read('apps/demo/app/routes/example.tsx', 'utf-8'); + expect(content).toMatch( + `import type { MetaFunction } from '@remix-run/node';` + ); + + expect(content).toMatch(`export const meta: MetaFunction`); + expect(content).toMatch(`return [`); + } + ); +}); diff --git a/packages/remix/src/generators/meta/lib/v2.impl.ts b/packages/remix/src/generators/meta/lib/v2.impl.ts new file mode 100644 index 0000000000..ede4596141 --- /dev/null +++ b/packages/remix/src/generators/meta/lib/v2.impl.ts @@ -0,0 +1,36 @@ +import { formatFiles, Tree } from '@nx/devkit'; +import { getDefaultExportName } from '../../../utils/get-default-export-name'; +import { insertImport } from '../../../utils/insert-import'; +import { insertStatementAfterImports } from '../../../utils/insert-statement-after-imports'; +import { resolveRemixRouteFile } from '../../../utils/remix-route-utils'; +import { MetaSchema } from '../schema'; + +export async function v2MetaGenerator(tree: Tree, schema: MetaSchema) { + const routeFilePath = + schema.nameAndDirectoryFormat === 'as-provided' + ? schema.path + : await resolveRemixRouteFile(tree, schema.path, schema.project); + + if (!tree.exists(routeFilePath)) { + throw new Error( + `Route path does not exist: ${routeFilePath}. Please generate a Remix route first.` + ); + } + + insertImport(tree, routeFilePath, 'MetaFunction', '@remix-run/node', { + typeOnly: true, + }); + + const defaultExportName = getDefaultExportName(tree, routeFilePath); + insertStatementAfterImports( + tree, + routeFilePath, + ` + export const meta: MetaFunction = () => { + return [{ title: '${defaultExportName} Route' }]; + }; + + ` + ); + await formatFiles(tree); +} diff --git a/packages/remix/src/generators/meta/meta.impl.spec.ts b/packages/remix/src/generators/meta/meta.impl.spec.ts new file mode 100644 index 0000000000..db2159938b --- /dev/null +++ b/packages/remix/src/generators/meta/meta.impl.spec.ts @@ -0,0 +1,54 @@ +import { Tree } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import applicationGenerator from '../application/application.impl'; +import routeGenerator from '../route/route.impl'; +import metaGenerator from './meta.impl'; + +describe('meta', () => { + let tree: Tree; + beforeEach(async () => { + tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + tree.write('.gitignore', `/node_modules/dist`); + + await applicationGenerator(tree, { name: 'demo' }); + await routeGenerator(tree, { + path: 'example', + project: 'demo', + style: 'none', + loader: false, + action: false, + meta: false, + skipChecks: false, + }); + }); + + it('should use v2 when specified', async () => { + await metaGenerator(tree, { + path: 'example', + project: 'demo', + }); + + const content = tree.read('apps/demo/app/routes/example.tsx', 'utf-8'); + expect(content).toMatch( + `import type { MetaFunction } from '@remix-run/node';` + ); + + expect(content).toMatch(`export const meta: MetaFunction`); + expect(content).toMatch(`return [`); + }); + + it('--nameAndDirectoryFormat=as=provided', async () => { + await metaGenerator(tree, { + path: 'apps/demo/app/routes/example.tsx', + nameAndDirectoryFormat: 'as-provided', + }); + + const content = tree.read('apps/demo/app/routes/example.tsx', 'utf-8'); + expect(content).toMatch( + `import type { MetaFunction } from '@remix-run/node';` + ); + + expect(content).toMatch(`export const meta: MetaFunction`); + expect(content).toMatch(`return [`); + }); +}); diff --git a/packages/remix/src/generators/meta/meta.impl.ts b/packages/remix/src/generators/meta/meta.impl.ts new file mode 100644 index 0000000000..308edd0ca7 --- /dev/null +++ b/packages/remix/src/generators/meta/meta.impl.ts @@ -0,0 +1,7 @@ +import { Tree } from '@nx/devkit'; +import { v2MetaGenerator } from './lib/v2.impl'; +import { MetaSchema } from './schema'; + +export default async function (tree: Tree, schema: MetaSchema) { + await v2MetaGenerator(tree, schema); +} diff --git a/packages/remix/src/generators/meta/schema.d.ts b/packages/remix/src/generators/meta/schema.d.ts new file mode 100644 index 0000000000..c81caf4076 --- /dev/null +++ b/packages/remix/src/generators/meta/schema.d.ts @@ -0,0 +1,10 @@ +import { NameAndDirectoryFormat } from '@nx/devkit/src/generators/artifact-name-and-directory-utils'; + +export interface MetaSchema { + path: string; + nameAndDirectoryFormat?: NameAndDirectoryFormat; + /** + * @deprecated Provide the `path` option instead. The project will be determined from the path provided. It will be removed in Nx v18. + */ + project?: string; +} diff --git a/packages/remix/src/generators/meta/schema.json b/packages/remix/src/generators/meta/schema.json new file mode 100644 index 0000000000..bc7a179d6f --- /dev/null +++ b/packages/remix/src/generators/meta/schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "meta", + "type": "object", + "description": "Generate a meta function for a given route.", + "properties": { + "path": { + "type": "string", + "description": "The route path or path to the filename of the route.", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What is the path of the route? (e.g. 'apps/demo/app/routes/foo/bar.tsx')" + }, + "nameAndDirectoryFormat": { + "description": "Whether to generate the meta function in the path as provided, relative to the current working directory and ignoring the project (`as-provided`) or generate it using the project and directory relative to the workspace root (`derived`).", + "type": "string", + "enum": ["as-provided", "derived"] + }, + "project": { + "type": "string", + "description": "The name of the project.", + "$default": { + "$source": "projectName" + }, + "x-prompt": "What project is this route for?", + "pattern": "^[a-zA-Z].*$", + "x-deprecated": "Provide the `path` option instead and use the `as-provided` format. The project will be determined from the path provided. It will be removed in Nx v18." + } + }, + "required": ["path"] +} diff --git a/packages/remix/src/generators/preset/lib/normalize-options.ts b/packages/remix/src/generators/preset/lib/normalize-options.ts new file mode 100644 index 0000000000..7ebd7f5447 --- /dev/null +++ b/packages/remix/src/generators/preset/lib/normalize-options.ts @@ -0,0 +1,32 @@ +import { Tree } from '@nx/devkit'; +import { RemixGeneratorSchema } from '../schema'; + +export interface NormalizedSchema extends RemixGeneratorSchema { + appName: string; + projectRoot: string; + parsedTags: string[]; + unitTestRunner?: 'jest' | 'none' | 'vitest'; + e2eTestRunner?: 'cypress' | 'none'; + js?: boolean; +} + +export function normalizeOptions( + tree: Tree, + options: RemixGeneratorSchema +): NormalizedSchema { + // There is a bug in Nx core where custom preset args are not passed correctly for boolean values, thus causing the name to be "commit" or "nx-cloud" when not passed. + // TODO(jack): revert this hack once Nx core is fixed for custom preset args. + // TODO(philip): presets should probably be using the `appName` flag to name the app, but it's not getting passed down to this generator properly and is always an empty string + const appName = options.name; + const projectRoot = `packages/${appName}`; + const parsedTags = options.tags + ? options.tags.split(',').map((s) => s.trim()) + : []; + + return { + ...options, + appName, + projectRoot, + parsedTags, + }; +} diff --git a/packages/remix/src/generators/preset/preset.impl.ts b/packages/remix/src/generators/preset/preset.impl.ts new file mode 100644 index 0000000000..a64081e3ea --- /dev/null +++ b/packages/remix/src/generators/preset/preset.impl.ts @@ -0,0 +1,33 @@ +import { formatFiles, GeneratorCallback, Tree } from '@nx/devkit'; + +import { runTasksInSerial } from '@nx/devkit'; +import applicationGenerator from '../application/application.impl'; +import setupGenerator from '../setup/setup.impl'; +import { normalizeOptions } from './lib/normalize-options'; +import { RemixGeneratorSchema } from './schema'; + +export default async function (tree: Tree, _options: RemixGeneratorSchema) { + const options = normalizeOptions(tree, _options); + const tasks: GeneratorCallback[] = []; + + const setupGenTask = await setupGenerator(tree); + tasks.push(setupGenTask); + + const appGenTask = await applicationGenerator(tree, { + name: options.appName, + tags: options.tags, + skipFormat: true, + rootProject: true, + unitTestRunner: options.unitTestRunner ?? 'vitest', + e2eTestRunner: options.e2eTestRunner ?? 'cypress', + js: options.js ?? false, + }); + tasks.push(appGenTask); + + tree.delete('apps'); + tree.delete('libs'); + + await formatFiles(tree); + + return runTasksInSerial(...tasks); +} diff --git a/packages/remix/src/generators/preset/schema.d.ts b/packages/remix/src/generators/preset/schema.d.ts new file mode 100644 index 0000000000..041395c7d0 --- /dev/null +++ b/packages/remix/src/generators/preset/schema.d.ts @@ -0,0 +1,4 @@ +export interface RemixGeneratorSchema { + name: string; + tags?: string; +} diff --git a/packages/remix/src/generators/preset/schema.json b/packages/remix/src/generators/preset/schema.json new file mode 100644 index 0000000000..776b067e94 --- /dev/null +++ b/packages/remix/src/generators/preset/schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "Remix", + "title": "", + "type": "object", + "description": "Generate a Remix application in a standalone workspace. Can be used with `create-nx-workspace --preset=@nx/remix`.", + "properties": { + "tags": { + "type": "string", + "description": "Add tags to the app (used for linting).", + "alias": "t" + } + } +} diff --git a/packages/remix/src/generators/resource-route/__snapshots__/resource-route.impl.spec.ts.snap b/packages/remix/src/generators/resource-route/__snapshots__/resource-route.impl.spec.ts.snap new file mode 100644 index 0000000000..4b664673dd --- /dev/null +++ b/packages/remix/src/generators/resource-route/__snapshots__/resource-route.impl.spec.ts.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`resource route --nameAndDirectoryFormat=as-provided should error if it detects a possible missing route param because of un-escaped dollar sign 1`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`; + +exports[`resource route --nameAndDirectoryFormat=as-provided should error if it detects a possible missing route param because of un-escaped dollar sign 3`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`; + +exports[`resource route --nameAndDirectoryFormat=as-provided should error if it detects a possible missing route param because of un-escaped dollar sign 4`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`; + +exports[`resource route --nameAndDirectoryFormat=as-provided should error if it detects a possible missing route param because of un-escaped dollar sign 6`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`; + +exports[`resource route --nameAndDirectoryFormat=derived should error if it detects a possible missing route param because of un-escaped dollar sign 1`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`; + +exports[`resource route --nameAndDirectoryFormat=derived should error if it detects a possible missing route param because of un-escaped dollar sign 3`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`; + +exports[`resource route --nameAndDirectoryFormat=derived should error if it detects a possible missing route param because of un-escaped dollar sign 4`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`; + +exports[`resource route --nameAndDirectoryFormat=derived should error if it detects a possible missing route param because of un-escaped dollar sign 6`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`; + +exports[`resource route --nameAndDirectoryFormat=derived should error if it detects a possible missing route param because of un-escaped dollar sign 7`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`; + +exports[`resource route --nameAndDirectoryFormat=derived should error if it detects a possible missing route param because of un-escaped dollar sign 9`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`; diff --git a/packages/remix/src/generators/resource-route/resource-route.impl.spec.ts b/packages/remix/src/generators/resource-route/resource-route.impl.spec.ts new file mode 100644 index 0000000000..a25d48b6f7 --- /dev/null +++ b/packages/remix/src/generators/resource-route/resource-route.impl.spec.ts @@ -0,0 +1,155 @@ +import { Tree } from '@nx/devkit'; +import { NameAndDirectoryFormat } from '@nx/devkit/src/generators/artifact-name-and-directory-utils'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { dirname } from 'path'; +import applicationGenerator from '../application/application.impl'; +import resourceRouteGenerator from './resource-route.impl'; + +describe('resource route', () => { + let tree: Tree; + + beforeEach(async () => { + tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + tree.write('.gitignore', `/node_modules/dist`); + + await applicationGenerator(tree, { name: 'demo' }); + }); + + it('should not create a component', async () => { + await resourceRouteGenerator(tree, { + project: 'demo', + path: '/example/', + action: false, + loader: true, + skipChecks: false, + }); + const fileContents = tree.read('apps/demo/app/routes/example.ts', 'utf-8'); + expect(fileContents).not.toMatch('export default function'); + }); + + it('should throw an error if loader and action are both false', async () => { + await expect( + async () => + await resourceRouteGenerator(tree, { + project: 'demo', + path: 'example', + action: false, + loader: false, + skipChecks: false, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"The resource route generator requires either \`loader\` or \`action\` to be true"` + ); + }); + + describe.each([ + ['derived', 'apps/demo/app/routes/example.ts', 'demo'], + ['derived', 'example', 'demo'], + ['derived', 'example.ts', 'demo'], + ['as-provided', 'apps/demo/app/routes/example', ''], + ['as-provided', 'apps/demo/app/routes/example.ts', ''], + ])( + '--nameAndDirectoryFormat=%s', + ( + nameAndDirectoryFormat: NameAndDirectoryFormat, + path: string, + project: string + ) => { + it(`should create correct file for path ${path}`, async () => { + await resourceRouteGenerator(tree, { + project, + path, + action: false, + loader: true, + skipChecks: false, + nameAndDirectoryFormat, + }); + + expect(tree.exists('apps/demo/app/routes/example.ts')).toBeTruthy(); + }); + + it('should error if it detects a possible missing route param because of un-escaped dollar sign', async () => { + expect.assertions(3); + + await resourceRouteGenerator(tree, { + project, + path: `${dirname(path)}/route1/.ts`, // route.$withParams.tsx => route..tsx + loader: true, + action: true, + skipChecks: false, + nameAndDirectoryFormat, + }).catch((e) => expect(e).toMatchSnapshot()); + + await resourceRouteGenerator(tree, { + project, + path: `${dirname(path)}/route2//index.ts`, // route/$withParams/index.tsx => route//index.tsx + loader: true, + action: true, + skipChecks: false, + nameAndDirectoryFormat, + }).catch((e) => + expect(e).toMatchInlineSnapshot( + `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]` + ) + ); + + await resourceRouteGenerator(tree, { + project, + path: `${dirname(path)}/route3/.ts`, // route/$withParams.tsx => route/.tsx + loader: true, + action: true, + skipChecks: false, + nameAndDirectoryFormat, + }).catch((e) => expect(e).toMatchSnapshot()); + }); + + it(`should succeed if skipChecks flag is passed, and it detects a possible missing route param because of un-escaped dollar sign for ${path}`, async () => { + const basePath = + nameAndDirectoryFormat === 'as-provided' + ? '' + : 'apps/demo/app/routes'; + const normalizedPath = ( + dirname(path) === '' ? '' : `${dirname(path)}/` + ).replace(basePath, ''); + await resourceRouteGenerator(tree, { + project, + path: `${normalizedPath}route1/..ts`, // route.$withParams.tsx => route..tsx + loader: true, + action: true, + skipChecks: true, + nameAndDirectoryFormat, + }); + + expect(tree.exists(`${basePath}/${normalizedPath}route1/..ts`)).toBe( + true + ); + + await resourceRouteGenerator(tree, { + project, + path: `${normalizedPath}route2//index.ts`, // route/$withParams/index.tsx => route//index.tsx + loader: true, + action: true, + skipChecks: true, + nameAndDirectoryFormat, + }); + + expect( + tree.exists(`${basePath}/${normalizedPath}route2/index.ts`) + ).toBe(true); + + await resourceRouteGenerator(tree, { + project, + path: `${normalizedPath}route3/.ts`, // route/$withParams.tsx => route/.tsx + loader: true, + action: true, + skipChecks: true, + nameAndDirectoryFormat, + }); + + expect(tree.exists(`${basePath}/${normalizedPath}route3/.ts`)).toBe( + true + ); + }); + } + ); +}); diff --git a/packages/remix/src/generators/resource-route/resource-route.impl.ts b/packages/remix/src/generators/resource-route/resource-route.impl.ts new file mode 100644 index 0000000000..d366fb3f28 --- /dev/null +++ b/packages/remix/src/generators/resource-route/resource-route.impl.ts @@ -0,0 +1,64 @@ +import { formatFiles, joinPathFragments, Tree } from '@nx/devkit'; +import { determineArtifactNameAndDirectoryOptions } from '@nx/devkit/src/generators/artifact-name-and-directory-utils'; +import { + checkRoutePathForErrors, + resolveRemixRouteFile, +} from '../../utils/remix-route-utils'; +import actionGenerator from '../action/action.impl'; +import loaderGenerator from '../loader/loader.impl'; +import { RemixRouteSchema } from './schema'; + +export default async function (tree: Tree, options: RemixRouteSchema) { + const { + artifactName: name, + directory, + project: projectName, + } = await determineArtifactNameAndDirectoryOptions(tree, { + artifactType: 'resource-route', + callingGenerator: '@nx/remix:resource-route', + name: options.path.replace(/^\//, '').replace(/\/$/, ''), + nameAndDirectoryFormat: options.nameAndDirectoryFormat, + project: options.project, + }); + + if (!options.skipChecks && checkRoutePathForErrors(options.path)) { + throw new Error( + `Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.` + ); + } + + const routeFilePath = await resolveRemixRouteFile( + tree, + options.nameAndDirectoryFormat === 'as-provided' + ? joinPathFragments(directory, name) + : options.path, + options.nameAndDirectoryFormat === 'as-provided' ? undefined : projectName, + '.ts' + ); + + if (tree.exists(routeFilePath)) + throw new Error(`Path already exists: ${options.path}`); + + if (!options.loader && !options.action) + throw new Error( + 'The resource route generator requires either `loader` or `action` to be true' + ); + + tree.write(routeFilePath, ''); + + if (options.loader) { + await loaderGenerator(tree, { + path: routeFilePath, + nameAndDirectoryFormat: 'as-provided', + }); + } + + if (options.action) { + await actionGenerator(tree, { + path: routeFilePath, + nameAndDirectoryFormat: 'as-provided', + }); + } + + await formatFiles(tree); +} diff --git a/packages/remix/src/generators/resource-route/schema.d.ts b/packages/remix/src/generators/resource-route/schema.d.ts new file mode 100644 index 0000000000..2512f88757 --- /dev/null +++ b/packages/remix/src/generators/resource-route/schema.d.ts @@ -0,0 +1,13 @@ +import { NameAndDirectoryFormat } from '@nx/devkit/src/generators/artifact-name-and-directory-utils'; + +export interface RemixRouteSchema { + path: string; + nameAndDirectoryFormat?: NameAndDirectoryFormat; + action: boolean; + loader: boolean; + skipChecks: boolean; + /** + * @deprecated Provide the `path` option instead. The project will be determined from the path provided. It will be removed in Nx v18. + */ + project?: string; +} diff --git a/packages/remix/src/generators/resource-route/schema.json b/packages/remix/src/generators/resource-route/schema.json new file mode 100644 index 0000000000..d5217bc6d5 --- /dev/null +++ b/packages/remix/src/generators/resource-route/schema.json @@ -0,0 +1,55 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "NxRemixResourceRoute", + "title": "Create a Resource Route", + "type": "object", + "description": "Generate a resource route.", + "examples": [ + { + "command": "g resource-route 'path/to/page'", + "description": "Generate resource route at /path/to/page" + } + ], + "properties": { + "path": { + "type": "string", + "description": "The route path or path to the filename of the route.", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What is the path of the route? (e.g. 'apps/demo/app/routes/foo/bar')" + }, + "nameAndDirectoryFormat": { + "description": "Whether to generate the styles in the path as provided, relative to the current working directory and ignoring the project (`as-provided`) or generate it using the project and directory relative to the workspace root (`derived`).", + "type": "string", + "enum": ["as-provided", "derived"] + }, + "project": { + "type": "string", + "description": "The name of the project.", + "$default": { + "$source": "projectName" + }, + "x-prompt": "What project is this route for?", + "pattern": "^[a-zA-Z].*$", + "x-deprecated": "Provide the `path` option instead and use the `as-provided` format. The project will be determined from the path provided. It will be removed in Nx v18." + }, + "action": { + "type": "boolean", + "description": "Generate an action function", + "default": false + }, + "loader": { + "type": "boolean", + "description": "Generate a loader function", + "default": true + }, + "skipChecks": { + "type": "boolean", + "description": "Skip route error detection", + "default": false + } + }, + "required": ["path"] +} diff --git a/packages/remix/src/generators/route/__snapshots__/route.impl.spec.ts.snap b/packages/remix/src/generators/route/__snapshots__/route.impl.spec.ts.snap new file mode 100644 index 0000000000..f807b61913 --- /dev/null +++ b/packages/remix/src/generators/route/__snapshots__/route.impl.spec.ts.snap @@ -0,0 +1,95 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`route --nameAndDirectoryFormat=as-provided should add route component 1`] = ` +"import { useLoaderData, useActionData } from '@remix-run/react'; +import { json } from '@remix-run/node'; +import type { + LoaderFunctionArgs, + MetaFunction, + ActionFunctionArgs, + LinksFunction, +} from '@remix-run/node'; + +import stylesUrl from '../../../styles/path/to/example.css'; + +export const links: LinksFunction = () => { + return [{ rel: 'stylesheet', href: stylesUrl }]; +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + let formData = await request.formData(); + + return json({ message: formData.toString() }, { status: 200 }); +}; + +export const meta: MetaFunction = () => { + return [{ title: 'Example Route' }]; +}; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + return json({ + message: 'Hello, world!', + }); +}; + +export default function Example() { + const actionMessage = useActionData(); + const data = useLoaderData(); + + return

Message: {data.message}

; +} +" +`; + +exports[`route --nameAndDirectoryFormat=as-provided should error if it detects a possible missing route param because of un-escaped dollar sign 1`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`; + +exports[`route --nameAndDirectoryFormat=as-provided should error if it detects a possible missing route param because of un-escaped dollar sign 2`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`; + +exports[`route --nameAndDirectoryFormat=as-provided should error if it detects a possible missing route param because of un-escaped dollar sign 3`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`; + +exports[`route --nameAndDirectoryFormat=derived should add route component 1`] = ` +"import { useLoaderData, useActionData } from '@remix-run/react'; +import { json } from '@remix-run/node'; +import type { + LoaderFunctionArgs, + MetaFunction, + ActionFunctionArgs, + LinksFunction, +} from '@remix-run/node'; + +import stylesUrl from '../../../styles/path/to/example.css'; + +export const links: LinksFunction = () => { + return [{ rel: 'stylesheet', href: stylesUrl }]; +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + let formData = await request.formData(); + + return json({ message: formData.toString() }, { status: 200 }); +}; + +export const meta: MetaFunction = () => { + return [{ title: 'PathToExample Route' }]; +}; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + return json({ + message: 'Hello, world!', + }); +}; + +export default function PathToExample() { + const actionMessage = useActionData(); + const data = useLoaderData(); + + return

Message: {data.message}

; +} +" +`; + +exports[`route --nameAndDirectoryFormat=derived should error if it detects a possible missing route param because of un-escaped dollar sign 1`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`; + +exports[`route --nameAndDirectoryFormat=derived should error if it detects a possible missing route param because of un-escaped dollar sign 2`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`; + +exports[`route --nameAndDirectoryFormat=derived should error if it detects a possible missing route param because of un-escaped dollar sign 3`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`; diff --git a/packages/remix/src/generators/route/route.impl.spec.ts b/packages/remix/src/generators/route/route.impl.spec.ts new file mode 100644 index 0000000000..1370389bdf --- /dev/null +++ b/packages/remix/src/generators/route/route.impl.spec.ts @@ -0,0 +1,282 @@ +import { Tree } from '@nx/devkit'; +import { NameAndDirectoryFormat } from '@nx/devkit/src/generators/artifact-name-and-directory-utils'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import applicationGenerator from '../application/application.impl'; +import presetGenerator from '../preset/preset.impl'; +import routeGenerator from './route.impl'; + +describe('route', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + tree.write('.gitignore', `/node_modules/dist`); + }); + describe.each([ + [ + 'derived', + 'path/to/example', + '', + 'apps/demo/app/routes/path/to/example.tsx', + 'apps/demo/app/styles/path/to/example.css', + 'PathToExample', + 'demo', + ], + [ + 'as-provided', + 'apps/demo/app/routes/path/to/example', + 'app/routes', + 'apps/demo/app/routes/path/to/example.tsx', + 'apps/demo/app/styles/path/to/example.css', + 'Example', + '', + ], + ])( + `--nameAndDirectoryFormat=%s`, + ( + nameAndDirectoryFormat: NameAndDirectoryFormat, + path, + standalonePath, + expectedRoutePath, + expectedStylePath, + expectedComponentName, + project: string + ) => { + it('should add route component', async () => { + await applicationGenerator(tree, { name: 'demo' }); + await routeGenerator(tree, { + project, + path, + nameAndDirectoryFormat, + style: 'css', + loader: true, + action: true, + meta: true, + skipChecks: false, + }); + + const content = tree.read(expectedRoutePath, 'utf-8'); + expect(content).toMatchSnapshot(); + expect(content).toMatch('LinksFunction'); + expect(content).toMatch(`function ${expectedComponentName}(`); + expect(tree.exists(expectedStylePath)).toBeTruthy(); + }, 25_000); + + it('should support --style=none', async () => { + await applicationGenerator(tree, { name: 'demo' }); + await routeGenerator(tree, { + project, + path, + nameAndDirectoryFormat, + style: 'none', + loader: true, + action: true, + meta: true, + skipChecks: false, + }); + + const content = tree.read(expectedRoutePath).toString(); + expect(content).not.toMatch('LinksFunction'); + expect(tree.exists(expectedStylePath)).toBeFalsy(); + }); + + it('should handle trailing and prefix slashes', async () => { + await applicationGenerator(tree, { name: 'demo' }); + await routeGenerator(tree, { + project, + path: `/${path}/`, + nameAndDirectoryFormat, + style: 'css', + loader: true, + action: true, + meta: true, + skipChecks: false, + }); + + const content = tree.read(expectedRoutePath).toString(); + expect(content).toMatch(`function ${expectedComponentName}(`); + }); + + it('should handle routes that end in a file', async () => { + await applicationGenerator(tree, { name: 'demo' }); + await routeGenerator(tree, { + project: 'demo', + path: `${path}.tsx`, + nameAndDirectoryFormat, + style: 'css', + loader: true, + action: true, + meta: true, + skipChecks: false, + }); + + const content = tree.read(expectedRoutePath).toString(); + expect(content).toMatch(`function ${expectedComponentName}(`); + }); + + it('should handle routes that have a param', async () => { + const componentName = + nameAndDirectoryFormat === 'as-provided' + ? 'WithParam' + : `${expectedComponentName}WithParam`; + await applicationGenerator(tree, { name: 'demo' }); + await routeGenerator(tree, { + project, + path: `/${path}/$withParam.tsx`, + nameAndDirectoryFormat, + style: 'css', + loader: true, + action: true, + meta: true, + skipChecks: false, + }); + + const content = tree + .read('apps/demo/app/routes/path/to/example/$withParam.tsx') + .toString(); + expect(content).toMatch(`function ${componentName}(`); + }); + + it('should error if it detects a possible missing route param because of un-escaped dollar sign', async () => { + await applicationGenerator(tree, { name: 'demo' }); + + expect.assertions(3); + + await routeGenerator(tree, { + project, + path: `${path}/route1/.tsx`, // route.$withParams.tsx => route..tsx + nameAndDirectoryFormat, + style: 'css', + loader: true, + action: true, + meta: true, + skipChecks: false, + }).catch((e) => expect(e).toMatchSnapshot()); + + await routeGenerator(tree, { + project, + path: `${path}/route2//index.tsx`, // route/$withParams/index.tsx => route//index.tsx + nameAndDirectoryFormat, + style: 'css', + loader: true, + action: true, + meta: true, + skipChecks: false, + }).catch((e) => expect(e).toMatchSnapshot()); + + await routeGenerator(tree, { + project: 'demo', + path: `${path}/route3/.tsx`, // route/$withParams.tsx => route/.tsx + nameAndDirectoryFormat, + style: 'css', + loader: true, + action: true, + meta: true, + skipChecks: false, + }).catch((e) => expect(e).toMatchSnapshot()); + }); + + it('should succeed if skipChecks flag is passed, and it detects a possible missing route param because of un-escaped dollar sign', async () => { + await applicationGenerator(tree, { name: 'demo' }); + + await routeGenerator(tree, { + project, + path: `${path}/route1/..tsx`, // route.$withParams.tsx => route..tsx + nameAndDirectoryFormat, + style: 'css', + loader: true, + action: true, + meta: true, + skipChecks: true, + }); + + expect( + tree.exists('apps/demo/app/routes/path/to/example/route1/..tsx') + ).toBe(true); + + await routeGenerator(tree, { + project, + path: `${path}/route2//index.tsx`, // route/$withParams/index.tsx => route//index.tsx + nameAndDirectoryFormat, + style: 'css', + loader: true, + action: true, + meta: true, + skipChecks: true, + }); + + expect( + tree.exists('apps/demo/app/routes/path/to/example/route2/index.tsx') + ).toBe(true); + + await routeGenerator(tree, { + project, + path: `${path}/route3/.tsx`, // route/$withParams.tsx => route/.tsx + nameAndDirectoryFormat, + style: 'css', + loader: true, + action: true, + meta: true, + skipChecks: true, + }); + + expect( + tree.exists('apps/demo/app/routes/path/to/example/route3/.tsx') + ).toBe(true); + }, 120000); + + if (nameAndDirectoryFormat === 'derived') { + it('should place routes correctly when app dir is changed', async () => { + await applicationGenerator(tree, { name: 'demo' }); + + tree.write( + 'apps/demo/remix.config.cjs', + ` + /** + * @type {import('@remix-run/dev').AppConfig} + */ + module.exports = { + ignoredRouteFiles: ["**/.*"], + appDirectory: "my-custom-dir", + };` + ); + + await routeGenerator(tree, { + project: 'demo', + path: 'route.tsx', + nameAndDirectoryFormat, + style: 'css', + loader: true, + action: true, + meta: true, + skipChecks: false, + }); + + expect(tree.exists('apps/demo/my-custom-dir/routes/route.tsx')).toBe( + true + ); + expect(tree.exists('apps/demo/my-custom-dir/styles/route.css')).toBe( + true + ); + }); + } + + it('should place the route correctly in a standalone app', async () => { + await presetGenerator(tree, { name: 'demo' }); + + await routeGenerator(tree, { + project, + path: `${standalonePath}/route.tsx`, + nameAndDirectoryFormat, + style: 'none', + loader: true, + action: true, + meta: true, + skipChecks: false, + }); + + expect(tree.exists('app/routes/route.tsx')).toBe(true); + }); + } + ); +}); diff --git a/packages/remix/src/generators/route/route.impl.ts b/packages/remix/src/generators/route/route.impl.ts new file mode 100644 index 0000000000..fa55c8d2d8 --- /dev/null +++ b/packages/remix/src/generators/route/route.impl.ts @@ -0,0 +1,116 @@ +import { + formatFiles, + joinPathFragments, + names, + readProjectConfiguration, + stripIndents, + Tree, +} from '@nx/devkit'; +import { determineArtifactNameAndDirectoryOptions } from '@nx/devkit/src/generators/artifact-name-and-directory-utils'; +import { basename, dirname } from 'path'; +import { + checkRoutePathForErrors, + resolveRemixRouteFile, +} from '../../utils/remix-route-utils'; +import ActionGenerator from '../action/action.impl'; +import LoaderGenerator from '../loader/loader.impl'; +import MetaGenerator from '../meta/meta.impl'; +import StyleGenerator from '../style/style.impl'; +import { RemixRouteSchema } from './schema'; + +export default async function (tree: Tree, options: RemixRouteSchema) { + const { + artifactName: name, + directory, + project: projectName, + } = await determineArtifactNameAndDirectoryOptions(tree, { + artifactType: 'route', + callingGenerator: '@nx/remix:route', + name: options.path.replace(/^\//, '').replace(/\/$/, ''), + nameAndDirectoryFormat: options.nameAndDirectoryFormat, + project: options.project, + }); + + const project = readProjectConfiguration(tree, projectName); + if (!project) throw new Error(`Project does not exist: ${projectName}`); + + if (!options.skipChecks && checkRoutePathForErrors(options.path)) { + throw new Error( + `Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.` + ); + } + + const routeFilePath = await resolveRemixRouteFile( + tree, + options.nameAndDirectoryFormat === 'as-provided' + ? joinPathFragments(directory, name) + : options.path, + options.nameAndDirectoryFormat === 'as-provided' ? undefined : projectName, + '.tsx' + ); + + const nameToUseForComponent = + options.nameAndDirectoryFormat === 'as-provided' + ? name.replace('.tsx', '') + : options.path.replace(/^\//, '').replace(/\/$/, '').replace('.tsx', ''); + + const { className: componentName } = names( + nameToUseForComponent === '.' || nameToUseForComponent === '' + ? basename(dirname(routeFilePath)) + : nameToUseForComponent + ); + + if (tree.exists(routeFilePath)) + throw new Error(`Path already exists: ${routeFilePath}`); + + tree.write( + routeFilePath, + stripIndents` + + + export default function ${componentName}() { + ${ + options.loader + ? ` + return ( +

+ Message: {data.message} +

+ ); + ` + : `return (

${componentName} works!

)` + } + } + ` + ); + + if (options.loader) { + await LoaderGenerator(tree, { + path: routeFilePath, + nameAndDirectoryFormat: 'as-provided', + }); + } + + if (options.meta) { + await MetaGenerator(tree, { + path: routeFilePath, + nameAndDirectoryFormat: 'as-provided', + }); + } + + if (options.action) { + await ActionGenerator(tree, { + path: routeFilePath, + nameAndDirectoryFormat: 'as-provided', + }); + } + + if (options.style === 'css') { + await StyleGenerator(tree, { + project: projectName, + path: routeFilePath, + }); + } + + await formatFiles(tree); +} diff --git a/packages/remix/src/generators/route/schema.d.ts b/packages/remix/src/generators/route/schema.d.ts new file mode 100644 index 0000000000..b94691c6bd --- /dev/null +++ b/packages/remix/src/generators/route/schema.d.ts @@ -0,0 +1,15 @@ +import { NameAndDirectoryFormat } from '@nx/devkit/src/generators/artifact-name-and-directory-utils'; + +export interface RemixRouteSchema { + path: string; + nameAndDirectoryFormat?: NameAndDirectoryFormat; + style: 'css' | 'none'; + action: boolean; + meta: boolean; + loader: boolean; + skipChecks: boolean; + /** + * @deprecated Provide the `path` option instead. The project will be determined from the path provided. It will be removed in Nx v18. + */ + project?: string; +} diff --git a/packages/remix/src/generators/route/schema.json b/packages/remix/src/generators/route/schema.json new file mode 100644 index 0000000000..2d52ab448f --- /dev/null +++ b/packages/remix/src/generators/route/schema.json @@ -0,0 +1,66 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "NxRemixRoute", + "title": "Create a Route", + "description": "Generate a route.", + "type": "object", + "examples": [ + { + "command": "g route 'path/to/page'", + "description": "Generate route at /path/to/page" + } + ], + "properties": { + "path": { + "type": "string", + "description": "The route path or path to the filename of the route. When `--nameAndDirectoryFormat=as-provided`, it will be relative to the current working directory. Otherwise, it will be relative to the workspace root.", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What is the path of the route? (e.g. 'apps/demo/app/routes/foo/bar')" + }, + "nameAndDirectoryFormat": { + "description": "Whether to generate the route in the path as provided, relative to the current working directory and ignoring the project (`as-provided`) or generate it using the project and path relative to the workspace root (`derived`).", + "type": "string", + "enum": ["as-provided", "derived"] + }, + "project": { + "type": "string", + "description": "The name of the project.", + "$default": { + "$source": "projectName" + }, + "x-prompt": "What project is this route for?", + "pattern": "^[a-zA-Z].*$", + "x-deprecated": "Provide the `path` option instead and use the `as-provided` format. The project will be determined from the path provided. It will be removed in Nx v18." + }, + "style": { + "type": "string", + "description": "Generate a stylesheet", + "enum": ["none", "css"], + "default": "css" + }, + "meta": { + "type": "boolean", + "description": "Generate a meta function", + "default": false + }, + "action": { + "type": "boolean", + "description": "Generate an action function", + "default": false + }, + "loader": { + "type": "boolean", + "description": "Generate a loader function", + "default": false + }, + "skipChecks": { + "type": "boolean", + "description": "Skip route error detection", + "default": false + } + }, + "required": ["path"] +} diff --git a/packages/remix/src/generators/setup-tailwind/__snapshots__/setup-tailwind.impl.spec.ts.snap b/packages/remix/src/generators/setup-tailwind/__snapshots__/setup-tailwind.impl.spec.ts.snap new file mode 100644 index 0000000000..18e26fdb54 --- /dev/null +++ b/packages/remix/src/generators/setup-tailwind/__snapshots__/setup-tailwind.impl.spec.ts.snap @@ -0,0 +1,156 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`setup-tailwind generator should add a js tailwind config to an application correctly 1`] = ` +"import { createGlobPatternsForDependencies } from '@nx/react/tailwind'; +export default { + content: [ + './app/**/*.{js,jsx,ts,tsx}', + ...createGlobPatternsForDependencies(__dirname), + ], + theme: { + extend: {}, + }, + plugins: [], +}; +" +`; + +exports[`setup-tailwind generator should add a js tailwind config to an application correctly 2`] = ` +"@tailwind base; +@tailwind components; +@tailwind utilities; +" +`; + +exports[`setup-tailwind generator should add a js tailwind config to an application correctly 3`] = ` +"import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from '@remix-run/react'; +import styles from './tailwind.css'; +export const links = () => [{ rel: 'stylesheet', href: styles }]; +export const meta = () => [ + { + charset: 'utf-8', + title: 'New Remix App', + viewport: 'width=device-width,initial-scale=1', + }, +]; +export default function App() { + return ( + + + + + + + + + + + + + ); +} +" +`; + +exports[`setup-tailwind generator should add a js tailwind config to an application correctly 4`] = ` +"/** + * @type {import('@remix-run/dev').AppConfig} + */ +module.exports = { + tailwind: true, + ignoredRouteFiles: ['**/.*'], + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // serverBuildPath: "build/index.js", + // publicPath: "/build/", + watchPaths: () => require('@nx/remix').createWatchPaths(__dirname), +}; +" +`; + +exports[`setup-tailwind generator should add a tailwind config to an application correctly 1`] = ` +"import type { Config } from "tailwindcss"; +import { createGlobPatternsForDependencies } from '@nx/react/tailwind'; + +export default { + content: [ + "./app/**/*.{js,jsx,ts,tsx}", + ...createGlobPatternsForDependencies(__dirname) + ], + theme: { + extend: {}, + }, + plugins: [], +} satisfies Config; +" +`; + +exports[`setup-tailwind generator should add a tailwind config to an application correctly 2`] = ` +"@tailwind base; +@tailwind components; +@tailwind utilities; +" +`; + +exports[`setup-tailwind generator should add a tailwind config to an application correctly 3`] = ` +"import type { MetaFunction, LinksFunction } from '@remix-run/node'; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from '@remix-run/react'; +import styles from './tailwind.css'; +export const links: LinksFunction = () => [{ rel: 'stylesheet', href: styles }]; + +export const meta: MetaFunction = () => [ + { + charset: 'utf-8', + title: 'New Remix App', + viewport: 'width=device-width,initial-scale=1', + }, +]; + +export default function App() { + return ( + + + + + + + + + + + + + ); +} +" +`; + +exports[`setup-tailwind generator should add a tailwind config to an application correctly 4`] = ` +"/** + * @type {import('@remix-run/dev').AppConfig} + */ +module.exports = { + tailwind: true, + ignoredRouteFiles: ['**/.*'], + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // serverBuildPath: "build/index.js", + // publicPath: "/build/", + watchPaths: () => require('@nx/remix').createWatchPaths(__dirname), +}; +" +`; diff --git a/packages/remix/src/generators/setup-tailwind/files/app/tailwind.css__tpl__ b/packages/remix/src/generators/setup-tailwind/files/app/tailwind.css__tpl__ new file mode 100644 index 0000000000..b5c61c9567 --- /dev/null +++ b/packages/remix/src/generators/setup-tailwind/files/app/tailwind.css__tpl__ @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/packages/remix/src/generators/setup-tailwind/files/tailwind.config.ts__tpl__ b/packages/remix/src/generators/setup-tailwind/files/tailwind.config.ts__tpl__ new file mode 100644 index 0000000000..14bac87243 --- /dev/null +++ b/packages/remix/src/generators/setup-tailwind/files/tailwind.config.ts__tpl__ @@ -0,0 +1,13 @@ +import type { Config } from "tailwindcss"; +import { createGlobPatternsForDependencies } from '@nx/react/tailwind'; + +export default { + content: [ + "./app/**/*.{js,jsx,ts,tsx}", + ...createGlobPatternsForDependencies(__dirname) + ], + theme: { + extend: {}, + }, + plugins: [], +} satisfies Config; diff --git a/packages/remix/src/generators/setup-tailwind/lib/index.ts b/packages/remix/src/generators/setup-tailwind/lib/index.ts new file mode 100644 index 0000000000..f614834a1b --- /dev/null +++ b/packages/remix/src/generators/setup-tailwind/lib/index.ts @@ -0,0 +1 @@ +export * from './update-remix-config'; diff --git a/packages/remix/src/generators/setup-tailwind/lib/update-remix-config.spec.ts b/packages/remix/src/generators/setup-tailwind/lib/update-remix-config.spec.ts new file mode 100644 index 0000000000..5d9fb29d87 --- /dev/null +++ b/packages/remix/src/generators/setup-tailwind/lib/update-remix-config.spec.ts @@ -0,0 +1,79 @@ +import { stripIndents } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { updateRemixConfig } from './update-remix-config'; + +describe('updateRemixConfig', () => { + it('should add tailwind property to an existing config that doesnt have it', () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + tree.write( + `remix.config.cjs`, + stripIndents`module.exports = { + ignoredRouteFiles: ['**/.*'], + watchPaths: ['../../libs'] + };` + ); + + // ACT + updateRemixConfig(tree, '.'); + + // ASSERT + expect(tree.read('remix.config.cjs', 'utf-8')).toMatchInlineSnapshot(` + "module.exports = { + tailwind: true, + ignoredRouteFiles: ['**/.*'], + watchPaths: ['../../libs'] + };" + `); + }); + + it('should update tailwind property if the config has it and set to false', () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + tree.write( + `remix.config.cjs`, + stripIndents`module.exports = { + ignoredRouteFiles: ['**/.*'], + tailwind: false, + watchPaths: ['../../libs'] + };` + ); + + // ACT + updateRemixConfig(tree, '.'); + + // ASSERT + expect(tree.read('remix.config.cjs', 'utf-8')).toMatchInlineSnapshot(` + "module.exports = { + ignoredRouteFiles: ['**/.*'], + tailwind: true, + watchPaths: ['../../libs'] + };" + `); + }); + + it('should not update tailwind property if the config has it and set to true', () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + tree.write( + `remix.config.cjs`, + stripIndents`module.exports = { + ignoredRouteFiles: ['**/.*'], + tailwind: true, + watchPaths: ['../../libs'] + };` + ); + + // ACT + updateRemixConfig(tree, '.'); + + // ASSERT + expect(tree.read('remix.config.cjs', 'utf-8')).toMatchInlineSnapshot(` + "module.exports = { + ignoredRouteFiles: ['**/.*'], + tailwind: true, + watchPaths: ['../../libs'] + };" + `); + }); +}); diff --git a/packages/remix/src/generators/setup-tailwind/lib/update-remix-config.ts b/packages/remix/src/generators/setup-tailwind/lib/update-remix-config.ts new file mode 100644 index 0000000000..e1fda1aab3 --- /dev/null +++ b/packages/remix/src/generators/setup-tailwind/lib/update-remix-config.ts @@ -0,0 +1,52 @@ +import { joinPathFragments, type Tree } from '@nx/devkit'; +import { tsquery } from '@phenomnomnominal/tsquery'; + +export function updateRemixConfig(tree: Tree, projectRoot: string) { + const pathToRemixConfig = joinPathFragments(projectRoot, 'remix.config.cjs'); + + if (!tree.exists(pathToRemixConfig)) { + throw new Error( + `Could not find "${pathToRemixConfig}". Please ensure a "remix.config.cjs" exists at the root of your project.` + ); + } + + const fileContents = tree.read(pathToRemixConfig, 'utf-8'); + + const REMIX_CONFIG_OBJECT_SELECTOR = + 'PropertyAccessExpression:has(Identifier[name=module], Identifier[name=exports])~ObjectLiteralExpression'; + const ast = tsquery.ast(fileContents); + + const nodes = tsquery(ast, REMIX_CONFIG_OBJECT_SELECTOR, { + visitAllChildren: true, + }); + if (nodes.length === 0) { + throw new Error(`Remix Config is not valid, unable to update the file.`); + } + + const configObjectNode = nodes[0]; + + const propertyNodes = tsquery(configObjectNode, 'PropertyAssignment', { + visitAllChildren: true, + }); + + for (const propertyNode of propertyNodes) { + const nodeText = propertyNode.getText(); + if (nodeText.includes('tailwind') && nodeText.includes('true')) { + return; + } else if (nodeText.includes('tailwind') && nodeText.includes('false')) { + const updatedFileContents = `${fileContents.slice( + 0, + propertyNode.getStart() + )}tailwind: true${fileContents.slice(propertyNode.getEnd())}`; + tree.write(pathToRemixConfig, updatedFileContents); + return; + } + } + + const updatedFileContents = `${fileContents.slice( + 0, + configObjectNode.getStart() + 1 + )}\ntailwind: true,${fileContents.slice(configObjectNode.getStart() + 1)}`; + + tree.write(pathToRemixConfig, updatedFileContents); +} diff --git a/packages/remix/src/generators/setup-tailwind/schema.d.ts b/packages/remix/src/generators/setup-tailwind/schema.d.ts new file mode 100644 index 0000000000..2a098d99ba --- /dev/null +++ b/packages/remix/src/generators/setup-tailwind/schema.d.ts @@ -0,0 +1,5 @@ +export interface SetupTailwindSchema { + project: string; + js?: boolean; + skipFormat?: boolean; +} diff --git a/packages/remix/src/generators/setup-tailwind/schema.json b/packages/remix/src/generators/setup-tailwind/schema.json new file mode 100644 index 0000000000..33ca126e04 --- /dev/null +++ b/packages/remix/src/generators/setup-tailwind/schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "NxRemixTailwind", + "title": "Add TailwindCSS to a Remix App", + "description": "Setup tailwindcss for a given project.", + "type": "object", + "examples": [ + { + "command": "g setup-tailwind --project=myapp", + "description": "Generate a TailwindCSS config for your Remix app" + } + ], + "properties": { + "project": { + "type": "string", + "description": "The name of the project to add tailwind to", + "$default": { + "$source": "projectName" + }, + "x-prompt": "What project would you like to add Tailwind to?", + "pattern": "^[a-zA-Z].*$" + }, + "js": { + "type": "boolean", + "description": "Generate a JavaScript config file instead of a TypeScript config file", + "default": false + }, + "skipFormat": { + "type": "boolean", + "description": "Skip formatting files after generator runs", + "default": false, + "x-priority": "internal" + } + }, + "required": ["project"] +} diff --git a/packages/remix/src/generators/setup-tailwind/setup-tailwind.impl.spec.ts b/packages/remix/src/generators/setup-tailwind/setup-tailwind.impl.spec.ts new file mode 100644 index 0000000000..3be07313c0 --- /dev/null +++ b/packages/remix/src/generators/setup-tailwind/setup-tailwind.impl.spec.ts @@ -0,0 +1,53 @@ +import { readJson } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import applicationGenerator from '../application/application.impl'; +import setupTailwind from './setup-tailwind.impl'; + +describe('setup-tailwind generator', () => { + it('should add a tailwind config to an application correctly', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + await applicationGenerator(tree, { + name: 'test', + rootProject: true, + }); + + // ACT + await setupTailwind(tree, { project: 'test' }); + + // ASSERT + expect(tree.exists('tailwind.config.ts')).toBeTruthy(); + expect(tree.read('tailwind.config.ts', 'utf-8')).toMatchSnapshot(); + expect(tree.exists('app/tailwind.css')).toBeTruthy(); + expect(tree.read('app/tailwind.css', 'utf-8')).toMatchSnapshot(); + expect(tree.read('app/root.tsx', 'utf-8')).toMatchSnapshot(); + expect(tree.read('remix.config.cjs', 'utf-8')).toMatchSnapshot(); + expect( + readJson(tree, 'package.json').dependencies['tailwindcss'] + ).toBeTruthy(); + }); + + it('should add a js tailwind config to an application correctly', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + await applicationGenerator(tree, { + name: 'test', + js: true, + rootProject: true, + }); + + // ACT + await setupTailwind(tree, { project: 'test', js: true }); + + // ASSERT + expect(tree.exists('tailwind.config.js')).toBeTruthy(); + expect(tree.read('tailwind.config.js', 'utf-8')).toMatchSnapshot(); + expect(tree.exists('app/tailwind.css')).toBeTruthy(); + expect(tree.read('app/tailwind.css', 'utf-8')).toMatchSnapshot(); + expect(tree.read('app/root.js', 'utf-8')).toMatchSnapshot(); + expect(tree.read('remix.config.cjs', 'utf-8')).toMatchSnapshot(); + expect( + readJson(tree, 'package.json').dependencies['tailwindcss'] + ).toBeTruthy(); + }); +}); diff --git a/packages/remix/src/generators/setup-tailwind/setup-tailwind.impl.ts b/packages/remix/src/generators/setup-tailwind/setup-tailwind.impl.ts new file mode 100644 index 0000000000..bf53eee653 --- /dev/null +++ b/packages/remix/src/generators/setup-tailwind/setup-tailwind.impl.ts @@ -0,0 +1,68 @@ +import { + addDependenciesToPackageJson, + formatFiles, + generateFiles, + installPackagesTask, + joinPathFragments, + readProjectConfiguration, + toJS, + type Tree, +} from '@nx/devkit'; + +import { upsertLinksFunction } from '../../utils/upsert-links-function'; +import { tailwindVersion } from '../../utils/versions'; +import { updateRemixConfig } from './lib'; +import type { SetupTailwindSchema } from './schema'; + +export default async function setupTailwind( + tree: Tree, + options: SetupTailwindSchema +) { + const project = readProjectConfiguration(tree, options.project); + if (project.projectType !== 'application') { + throw new Error( + `Project "${options.project}" is not an application. Please ensure the project is an application.` + ); + } + + updateRemixConfig(tree, project.root); + + generateFiles(tree, joinPathFragments(__dirname, 'files'), project.root, { + tpl: '', + }); + + if (options.js) { + tree.rename( + joinPathFragments(project.root, 'app/root.js'), + joinPathFragments(project.root, 'app/root.tsx') + ); + } + const pathToRoot = joinPathFragments(project.root, 'app/root.tsx'); + upsertLinksFunction( + tree, + pathToRoot, + 'styles', + './tailwind.css', + `{ rel: "stylesheet", href: styles }` + ); + + addDependenciesToPackageJson( + tree, + { + tailwindcss: tailwindVersion, + }, + {} + ); + + if (options.js) { + toJS(tree); + } + + if (!options.skipFormat) { + await formatFiles(tree); + } + + return () => { + installPackagesTask(tree); + }; +} diff --git a/packages/remix/src/generators/setup/schema.json b/packages/remix/src/generators/setup/schema.json new file mode 100644 index 0000000000..14d6d7e89a --- /dev/null +++ b/packages/remix/src/generators/setup/schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "NxRemixSetup", + "title": "", + "type": "object", + "description": "Generate initial files required for Remix to work within the workspace.", + "properties": { + "packageManager": { + "type": "string", + "description": "The package manager to setup for", + "enum": ["yarn", "npm", "pnpm"] + } + } +} diff --git a/packages/remix/src/generators/setup/setup.impl.spec.ts b/packages/remix/src/generators/setup/setup.impl.spec.ts new file mode 100644 index 0000000000..f94c9c3c09 --- /dev/null +++ b/packages/remix/src/generators/setup/setup.impl.spec.ts @@ -0,0 +1,30 @@ +import { Tree } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import setupGenerator from './setup.impl'; + +describe('app', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + tree.write( + '.gitignore', + `/node_modules +/dist` + ); + }); + + it('should update ignore file', async () => { + // Idempotency + await setupGenerator(tree); + await setupGenerator(tree); + + const ignoreFile = tree.read('.gitignore').toString(); + expect(ignoreFile).toEqual(`node_modules +dist +# Remix files +apps/**/build +apps/**/.cache + `); + }); +}); diff --git a/packages/remix/src/generators/setup/setup.impl.ts b/packages/remix/src/generators/setup/setup.impl.ts new file mode 100644 index 0000000000..e90bf0ed1a --- /dev/null +++ b/packages/remix/src/generators/setup/setup.impl.ts @@ -0,0 +1,43 @@ +import { + formatFiles, + GeneratorCallback, + runTasksInSerial, + Tree, + updateJson, +} from '@nx/devkit'; +import { initGenerator as jsInitGenerator } from '@nx/js'; + +export default async function (tree: Tree) { + const tasks: GeneratorCallback[] = []; + + const jsInitTask = await jsInitGenerator(tree, { + skipFormat: true, + }); + tasks.push(jsInitTask); + + // Ignore nested project files + let ignoreFile = tree.read('.gitignore').toString(); + if (ignoreFile.indexOf('/dist') !== -1) { + ignoreFile = ignoreFile.replace('/dist', 'dist'); + } + if (ignoreFile.indexOf('/node_modules') !== -1) { + ignoreFile = ignoreFile.replace('/node_modules', 'node_modules'); + } + if (ignoreFile.indexOf('# Remix files') === -1) { + ignoreFile = `${ignoreFile} +# Remix files +apps/**/build +apps/**/.cache + `; + } + tree.write('.gitignore', ignoreFile); + + updateJson(tree, `package.json`, (json) => { + json.type = 'module'; + return json; + }); + + await formatFiles(tree); + + return runTasksInSerial(...tasks); +} diff --git a/packages/remix/src/generators/storybook-configuration/__snapshots__/storybook-configuration.impl.spec.ts.snap b/packages/remix/src/generators/storybook-configuration/__snapshots__/storybook-configuration.impl.spec.ts.snap new file mode 100644 index 0000000000..f79f750776 --- /dev/null +++ b/packages/remix/src/generators/storybook-configuration/__snapshots__/storybook-configuration.impl.spec.ts.snap @@ -0,0 +1,85 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storybook Configuration it should create a storybook configuration and use react-vite framework with testing framework jest 1`] = ` +"import type { StorybookConfig } from '@storybook/react-vite'; + +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +import { mergeConfig } from 'vite'; + +const config: StorybookConfig = { + stories: ['../src/lib/**/*.stories.@(js|jsx|ts|tsx|mdx)'], + addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'], + framework: { + name: '@storybook/react-vite', + options: {}, + }, + + viteFinal: async (config) => + mergeConfig(config, { + plugins: [nxViteTsPaths()], + }), +}; + +export default config; + +// To customize your Vite configuration you can use the viteFinal field. +// Check https://storybook.js.org/docs/react/builders/vite#configuration +// and https://nx.dev/recipes/storybook/custom-builder-configs +" +`; + +exports[`Storybook Configuration it should create a storybook configuration and use react-vite framework with testing framework none 1`] = ` +"import type { StorybookConfig } from '@storybook/react-vite'; + +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +import { mergeConfig } from 'vite'; + +const config: StorybookConfig = { + stories: ['../src/lib/**/*.stories.@(js|jsx|ts|tsx|mdx)'], + addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'], + framework: { + name: '@storybook/react-vite', + options: {}, + }, + + viteFinal: async (config) => + mergeConfig(config, { + plugins: [nxViteTsPaths()], + }), +}; + +export default config; + +// To customize your Vite configuration you can use the viteFinal field. +// Check https://storybook.js.org/docs/react/builders/vite#configuration +// and https://nx.dev/recipes/storybook/custom-builder-configs +" +`; + +exports[`Storybook Configuration it should create a storybook configuration and use react-vite framework with testing framework vitest 1`] = ` +"import type { StorybookConfig } from '@storybook/react-vite'; + +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +import { mergeConfig } from 'vite'; + +const config: StorybookConfig = { + stories: ['../src/lib/**/*.stories.@(js|jsx|ts|tsx|mdx)'], + addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'], + framework: { + name: '@storybook/react-vite', + options: {}, + }, + + viteFinal: async (config) => + mergeConfig(config, { + plugins: [nxViteTsPaths()], + }), +}; + +export default config; + +// To customize your Vite configuration you can use the viteFinal field. +// Check https://storybook.js.org/docs/react/builders/vite#configuration +// and https://nx.dev/recipes/storybook/custom-builder-configs +" +`; diff --git a/packages/remix/src/generators/storybook-configuration/files/vite.config.ts__tpl__ b/packages/remix/src/generators/storybook-configuration/files/vite.config.ts__tpl__ new file mode 100644 index 0000000000..5cc91668a0 --- /dev/null +++ b/packages/remix/src/generators/storybook-configuration/files/vite.config.ts__tpl__ @@ -0,0 +1,15 @@ +/// +import {defineConfig} from 'vite'; +import react from '@vitejs/plugin-react'; +import viteTsConfigPaths from 'vite-tsconfig-paths'; + +export default defineConfig({ + cacheDir: '../../../node_modules/.vite/storybook-generator-test', + + plugins: [ + react(), + viteTsConfigPaths({ + root: '../../../', + }), + ], +}) diff --git a/packages/remix/src/generators/storybook-configuration/schema.d.ts b/packages/remix/src/generators/storybook-configuration/schema.d.ts new file mode 100644 index 0000000000..67855b1ee6 --- /dev/null +++ b/packages/remix/src/generators/storybook-configuration/schema.d.ts @@ -0,0 +1,15 @@ +import { Linter } from '@nx/eslint'; + +export interface StorybookConfigurationSchema { + project: string; + configureCypress: boolean; + generateStories?: boolean; + generateCypressSpecs?: boolean; + js?: boolean; + tsConfiguration?: boolean; + linter?: Linter; + cypressDirectory?: string; + ignorePaths?: string[]; + configureTestRunner?: boolean; + configureStaticServe?: boolean; +} diff --git a/packages/remix/src/generators/storybook-configuration/schema.json b/packages/remix/src/generators/storybook-configuration/schema.json new file mode 100644 index 0000000000..933b6f16ae --- /dev/null +++ b/packages/remix/src/generators/storybook-configuration/schema.json @@ -0,0 +1,89 @@ +{ + "$schema": "http://json-schema.org/schema", + "cli": "nx", + "$id": "NxRemixStorybookConfigure", + "title": "Remix Storybook Configuration", + "description": "Set up Storybook for a Remix library.", + "type": "object", + "properties": { + "project": { + "type": "string", + "aliases": ["name", "projectName"], + "description": "Project for which to generate Storybook configuration.", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "For which project do you want to generate Storybook configuration?", + "x-dropdown": "projects", + "x-priority": "important" + }, + "configureCypress": { + "type": "boolean", + "description": "Run the cypress-configure generator.", + "x-prompt": "Configure a cypress e2e app to run against the storybook instance?", + "default": true, + "x-priority": "important" + }, + "generateStories": { + "type": "boolean", + "description": "Automatically generate `*.stories.ts` files for components declared in this project?", + "x-prompt": "Automatically generate *.stories.ts files for components declared in this project?", + "default": true, + "x-priority": "important" + }, + "generateCypressSpecs": { + "type": "boolean", + "description": "Automatically generate test files in the Cypress E2E app generated by the `cypress-configure` generator.", + "x-prompt": "Automatically generate test files in the Cypress E2E app generated by the cypress-configure generator?", + "default": true, + "x-priority": "important" + }, + "configureStaticServe": { + "type": "boolean", + "description": "Specifies whether to configure a static file server target for serving storybook. Helpful for speeding up CI build/test times.", + "x-prompt": "Configure a static file server for the storybook instance?", + "default": true, + "x-priority": "important" + }, + "cypressDirectory": { + "type": "string", + "description": "A directory where the Cypress project will be placed. Placed at the root by default." + }, + "js": { + "type": "boolean", + "description": "Generate JavaScript story files rather than TypeScript story files.", + "default": false + }, + "tsConfiguration": { + "type": "boolean", + "description": "Configure your project with TypeScript. Generate main.ts and preview.ts files, instead of main.js and preview.js.", + "default": false + }, + "linter": { + "description": "The tool to use for running lint checks.", + "type": "string", + "enum": ["eslint"], + "default": "eslint" + }, + "ignorePaths": { + "type": "array", + "description": "Paths to ignore when looking for components.", + "items": { + "type": "string", + "description": "Path to ignore." + }, + "examples": [ + "**/**/src/**/not-stories/**", + "libs/my-lib/**/*.something.ts", + "**/**/src/**/*.other.*", + "libs/my-lib/src/not-stories/**,**/**/src/**/*.other.*,apps/my-app/**/*.something.ts" + ] + }, + "configureTestRunner": { + "type": "boolean", + "description": "Add a Storybook Test-Runner target." + } + }, + "required": ["name"] +} diff --git a/packages/remix/src/generators/storybook-configuration/storybook-configuration.impl.spec.ts b/packages/remix/src/generators/storybook-configuration/storybook-configuration.impl.spec.ts new file mode 100644 index 0000000000..e2395527c0 --- /dev/null +++ b/packages/remix/src/generators/storybook-configuration/storybook-configuration.impl.spec.ts @@ -0,0 +1,33 @@ +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import libraryGenerator from '../library/library.impl'; +import storybookConfigurationGenerator from './storybook-configuration.impl'; + +describe('Storybook Configuration', () => { + it.each(['jest', 'vitest', 'none'])( + 'it should create a storybook configuration and use react-vite framework with testing framework %s', + async (unitTestRunner: 'jest' | 'vitest' | 'none') => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + + await libraryGenerator(tree, { + name: 'storybook-test', + style: 'css', + unitTestRunner, + }); + + // ACT + await storybookConfigurationGenerator(tree, { + project: 'storybook-test', + configureCypress: false, + configureStaticServe: false, + generateStories: true, + }); + + // ASSERT + expect(tree.exists(`libs/storybook-test/vite.config.ts`)); + expect( + tree.read(`libs/storybook-test/.storybook/main.ts`, 'utf-8') + ).toMatchSnapshot(); + } + ); +}); diff --git a/packages/remix/src/generators/storybook-configuration/storybook-configuration.impl.ts b/packages/remix/src/generators/storybook-configuration/storybook-configuration.impl.ts new file mode 100644 index 0000000000..e5a27fd4ad --- /dev/null +++ b/packages/remix/src/generators/storybook-configuration/storybook-configuration.impl.ts @@ -0,0 +1,24 @@ +import { + generateFiles, + joinPathFragments, + readProjectConfiguration, + type Tree, +} from '@nx/devkit'; +import { join } from 'path'; +import type { StorybookConfigurationSchema } from './schema'; +import { storybookConfigurationGenerator } from '@nx/react'; + +export default async function remixStorybookConfiguration( + tree: Tree, + schema: StorybookConfigurationSchema +) { + const { root } = readProjectConfiguration(tree, schema.project); + + if (!tree.exists(joinPathFragments(root, 'vite.config.ts'))) { + generateFiles(tree, join(__dirname, 'files'), root, { tpl: '' }); + } + + const task = await storybookConfigurationGenerator(tree, schema); + + return task; +} diff --git a/packages/remix/src/generators/style/schema.d.ts b/packages/remix/src/generators/style/schema.d.ts new file mode 100644 index 0000000000..b0bb7d20a8 --- /dev/null +++ b/packages/remix/src/generators/style/schema.d.ts @@ -0,0 +1,10 @@ +import { NameAndDirectoryFormat } from '@nx/devkit/src/generators/artifact-name-and-directory-utils'; + +export interface RemixStyleSchema { + path: string; + nameAndDirectoryFormat?: NameAndDirectoryFormat; + /** + * @deprecated Provide the `path` option instead. The project will be determined from the path provided. It will be removed in Nx v18. + */ + project?: string; +} diff --git a/packages/remix/src/generators/style/schema.json b/packages/remix/src/generators/style/schema.json new file mode 100644 index 0000000000..31bd23bf33 --- /dev/null +++ b/packages/remix/src/generators/style/schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "NxRemixRouteStyle", + "title": "Add style import to a route", + "description": "Generate a style import and file for a given route.", + "type": "object", + "examples": [ + { + "command": "g style --path='apps/demo/app/routes/path/to/page.tsx'", + "description": "Generate route at apps/demo/app/routes/path/to/page.tsx" + } + ], + "properties": { + "path": { + "type": "string", + "description": "Route path", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What is the path of the route? (e.g. 'apps/demo/app/routes/foo/bar.tsx')" + }, + "nameAndDirectoryFormat": { + "description": "Whether to generate the styles in the path as provided, relative to the current working directory and ignoring the project (`as-provided`) or generate it using the project and directory relative to the workspace root (`derived`).", + "type": "string", + "enum": ["as-provided", "derived"] + }, + "project": { + "type": "string", + "description": "The name of the project.", + "$default": { + "$source": "projectName" + }, + "x-prompt": "What project is this route in?", + "pattern": "^[a-zA-Z].*$", + "x-deprecated": "Provide the `path` option instead and use the `as-provided` format. The project will be determined from the path provided. It will be removed in Nx v18." + } + }, + "required": ["path"] +} diff --git a/packages/remix/src/generators/style/style.impl.spec.ts b/packages/remix/src/generators/style/style.impl.spec.ts new file mode 100644 index 0000000000..d2e0a48cf2 --- /dev/null +++ b/packages/remix/src/generators/style/style.impl.spec.ts @@ -0,0 +1,163 @@ +import { Tree } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import applicationGenerator from '../application/application.impl'; +import presetGenerator from '../preset/preset.impl'; +import routeGenerator from '../route/route.impl'; +import styleGenerator from './style.impl'; + +describe('route', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + tree.write('.gitignore', `/node_modules/dist`); + }); + + it('should add css file to shared styles directory', async () => { + await applicationGenerator(tree, { name: 'demo' }); + await routeGenerator(tree, { + project: 'demo', + path: 'path/to/example', + style: 'none', + loader: false, + action: false, + meta: false, + skipChecks: false, + }); + await styleGenerator(tree, { + project: 'demo', + path: 'path/to/example', + }); + + expect( + tree.exists('apps/demo/app/styles/path/to/example.css') + ).toBeTruthy(); + }); + + it('should handle routes that have a param', async () => { + await applicationGenerator(tree, { name: 'demo' }); + await routeGenerator(tree, { + project: 'demo', + path: '/example/$withParam.tsx', + style: 'none', + loader: false, + action: false, + meta: false, + skipChecks: false, + }); + await styleGenerator(tree, { + project: 'demo', + path: '/example/$withParam.tsx', + }); + + expect( + tree.exists('apps/demo/app/styles/example/$withParam.css') + ).toBeTruthy(); + }); + + it('should place styles correctly when app dir is changed', async () => { + await applicationGenerator(tree, { name: 'demo' }); + + tree.write( + 'apps/demo/remix.config.cjs', + ` + /** + * @type {import('@remix-run/dev').AppConfig} + */ + module.exports = { + ignoredRouteFiles: ["**/.*"], + appDirectory: "my-custom-dir", + };` + ); + + await routeGenerator(tree, { + project: 'demo', + path: 'route.tsx', + style: 'none', + loader: true, + action: true, + meta: true, + skipChecks: false, + }); + await styleGenerator(tree, { + project: 'demo', + path: '/route.tsx', + }); + + expect(tree.exists('apps/demo/my-custom-dir/styles/route.css')).toBe(true); + }); + + it('should import stylesheet with a relative path in an integrated workspace', async () => { + await applicationGenerator(tree, { name: 'demo' }); + await routeGenerator(tree, { + project: 'demo', + path: '/example/$withParam.tsx', + style: 'none', + loader: false, + action: false, + meta: false, + skipChecks: false, + }); + await styleGenerator(tree, { + project: 'demo', + path: '/example/$withParam.tsx', + }); + const content = tree.read( + 'apps/demo/app/routes/example/$withParam.tsx', + 'utf-8' + ); + + expect(content).toMatch( + "import stylesUrl from '../../styles/example/$withParam.css';" + ); + }); + + it('should import stylesheet using ~ in a standalone project', async () => { + await presetGenerator(tree, { name: 'demo' }); + + await routeGenerator(tree, { + project: 'demo', + path: '/example/$withParam.tsx', + style: 'none', + loader: false, + action: false, + meta: false, + skipChecks: false, + }); + + await styleGenerator(tree, { + project: 'demo', + path: '/example/$withParam.tsx', + }); + const content = tree.read('app/routes/example/$withParam.tsx', 'utf-8'); + + expect(content).toMatch( + "import stylesUrl from '~/styles/example/$withParam.css';" + ); + }); + + it('--nameAndDirectoryFormat=as-provided', async () => { + await applicationGenerator(tree, { name: 'demo' }); + await routeGenerator(tree, { + path: 'apps/demo/app/routes/example/$withParam.tsx', + nameAndDirectoryFormat: 'as-provided', + style: 'none', + loader: false, + action: false, + meta: false, + skipChecks: false, + }); + await styleGenerator(tree, { + path: 'apps/demo/app/routes/example/$withParam.tsx', + nameAndDirectoryFormat: 'as-provided', + }); + const content = tree.read( + 'apps/demo/app/routes/example/$withParam.tsx', + 'utf-8' + ); + + expect(content).toMatch( + "import stylesUrl from '../../styles/example/$withParam.css';" + ); + }); +}); diff --git a/packages/remix/src/generators/style/style.impl.ts b/packages/remix/src/generators/style/style.impl.ts new file mode 100644 index 0000000000..cfd9e13450 --- /dev/null +++ b/packages/remix/src/generators/style/style.impl.ts @@ -0,0 +1,91 @@ +import { + formatFiles, + joinPathFragments, + readProjectConfiguration, + stripIndents, + Tree, +} from '@nx/devkit'; +import { RemixStyleSchema } from './schema'; + +import { determineArtifactNameAndDirectoryOptions } from '@nx/devkit/src/generators/artifact-name-and-directory-utils'; +import { dirname, relative } from 'path'; +import { insertImport } from '../../utils/insert-import'; +import { insertStatementAfterImports } from '../../utils/insert-statement-after-imports'; +import { + normalizeRoutePath, + resolveRemixAppDirectory, + resolveRemixRouteFile, +} from '../../utils/remix-route-utils'; + +export default async function (tree: Tree, options: RemixStyleSchema) { + const { project: projectName, artifactName: name } = + await determineArtifactNameAndDirectoryOptions(tree, { + artifactType: 'style', + callingGenerator: '@nx/remix:style', + name: options.path, + nameAndDirectoryFormat: options.nameAndDirectoryFormat, + project: options.project, + }); + const project = readProjectConfiguration(tree, projectName); + if (!project) throw new Error(`Project does not exist: ${projectName}`); + + const appDir = await resolveRemixAppDirectory(tree, project.name); + const normalizedRoutePath = `${normalizeRoutePath(options.path) + .replace(/^\//, '') + .replace('.tsx', '')}.css`; + const stylesheetPath = joinPathFragments( + appDir, + 'styles', + normalizedRoutePath + ); + + tree.write( + stylesheetPath, + stripIndents` + :root { + --color-foreground: #fff; + --color-background: #143157; + --color-links: hsl(214, 73%, 69%); + --color-border: #275da8; + --font-body: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + Liberation Mono, Courier New, monospace; + } + ` + ); + + const routeFilePath = options.nameAndDirectoryFormat + ? options.path + : await resolveRemixRouteFile(tree, options.path, options.project, '.tsx'); + + insertImport(tree, routeFilePath, 'LinksFunction', '@remix-run/node', { + typeOnly: true, + }); + + if (project.root === '.') { + insertStatementAfterImports( + tree, + routeFilePath, + ` + import stylesUrl from '~/styles/${normalizedRoutePath}' + + export const links: LinksFunction = () => { + return [{ rel: 'stylesheet', href: stylesUrl }]; + }; + ` + ); + } else { + insertStatementAfterImports( + tree, + routeFilePath, + ` + import stylesUrl from '${relative(dirname(routeFilePath), stylesheetPath)}'; + + export const links: LinksFunction = () => { + return [{ rel: 'stylesheet', href: stylesUrl }]; + }; + ` + ); + } + + await formatFiles(tree); +} diff --git a/packages/remix/src/utils/create-watch-paths.spec.ts b/packages/remix/src/utils/create-watch-paths.spec.ts new file mode 100644 index 0000000000..fe657316ce --- /dev/null +++ b/packages/remix/src/utils/create-watch-paths.spec.ts @@ -0,0 +1,160 @@ +import { joinPathFragments, workspaceRoot } from '@nx/devkit'; +import { + createWatchPaths, + getRelativeDependencyPaths, +} from './create-watch-paths'; + +describe('createWatchPaths', () => { + it('should list root paths of dependencies relative to project root', async () => { + const testDir = joinPathFragments(workspaceRoot, 'e2e/remix'); + + const paths = await createWatchPaths(testDir); + expect(paths).toEqual(['../../packages', '../../graph', '../../e2e/utils']); + }); +}); + +describe('getRelativeDependencyPaths', () => { + it('should work for standalone projects', () => { + const project = { + type: 'app' as const, + name: 'test', + data: { root: '.', files: [] }, + }; + const result = getRelativeDependencyPaths( + project, + ['lib-1', 'lib-2', 'lib-3'], + { + nodes: { + test: project, + 'lib-1': { + type: 'lib', + name: 'lib-1', + data: { root: 'lib-1' }, + }, + 'lib-2': { + type: 'lib', + name: 'lib-2', + data: { root: 'lib-2' }, + }, + 'lib-3': { + type: 'lib', + name: 'lib-3', + data: { root: 'lib-3' }, + }, + }, + dependencies: {}, + } + ); + + expect(result).toEqual(['lib-1', 'lib-2', 'lib-3']); + }); + + it('should watch the entire libs folder for integrated monorepos', () => { + const project = { + type: 'app' as const, + name: 'test', + data: { root: 'apps/test', files: [] }, + }; + const result = getRelativeDependencyPaths( + project, + ['lib-1', 'lib-2', 'lib-3'], + { + nodes: { + test: project, + 'lib-1': { + type: 'lib', + name: 'lib-1', + data: { root: 'libs/lib-1' }, + }, + 'lib-2': { + type: 'lib', + name: 'lib-2', + data: { root: 'libs/lib-2' }, + }, + 'lib-3': { + type: 'lib', + name: 'lib-3', + data: { root: 'libs/lib-3' }, + }, + }, + dependencies: {}, + } + ); + + expect(result).toEqual(['../../libs']); + }); + + it('should watch the entire packages folder for monorepos if apps is not contained in it', () => { + const project = { + type: 'app' as const, + name: 'test', + data: { root: 'apps/test', files: [] }, + }; + const result = getRelativeDependencyPaths( + project, + ['lib-1', 'lib-2', 'lib-3'], + { + nodes: { + test: project, + 'lib-1': { + type: 'lib', + name: 'lib-1', + data: { root: 'packages/lib-1' }, + }, + 'lib-2': { + type: 'lib', + name: 'lib-2', + data: { root: 'packages/lib-2' }, + }, + 'lib-3': { + type: 'lib', + name: 'lib-3', + data: { root: 'packages/lib-3' }, + }, + }, + dependencies: {}, + } + ); + + expect(result).toEqual(['../../packages']); + }); + + it('should watch individual dependency folder if app is contained in the same base path', () => { + const project = { + type: 'app' as const, + name: 'test', + data: { root: 'packages/test', files: [] }, + }; + const result = getRelativeDependencyPaths( + project, + ['lib-1', 'lib-2', 'lib-3'], + { + nodes: { + test: project, + 'lib-1': { + type: 'lib', + name: 'lib-1', + data: { root: 'packages/lib-1' }, + }, + 'lib-2': { + type: 'lib', + name: 'lib-2', + data: { root: 'packages/lib-2' }, + }, + 'lib-3': { + type: 'lib', + name: 'lib-3', + data: { root: 'packages/lib-3' }, + }, + }, + dependencies: {}, + } + ); + + expect(result).toEqual([ + '../../packages/lib-1', + '../../packages/lib-2', + '../../packages/lib-3', + ]); + }); +}); diff --git a/packages/remix/src/utils/create-watch-paths.ts b/packages/remix/src/utils/create-watch-paths.ts new file mode 100644 index 0000000000..d262d326cb --- /dev/null +++ b/packages/remix/src/utils/create-watch-paths.ts @@ -0,0 +1,59 @@ +import { + createProjectGraphAsync, + joinPathFragments, + offsetFromRoot, + workspaceRoot, + type ProjectGraph, + type ProjectGraphProjectNode, +} from '@nx/devkit'; +import { + createProjectRootMappings, + findProjectForPath, +} from 'nx/src/project-graph/utils/find-project-for-path'; +import { findAllProjectNodeDependencies } from 'nx/src/utils/project-graph-utils'; +import { normalize, relative, sep } from 'path'; + +/** + * Generates an array of paths to watch based on the project dependencies. + * + * @param {string} dirname The absolute path to the Remix project, typically `__dirname`. + */ +export async function createWatchPaths(dirname: string): Promise { + const graph = await createProjectGraphAsync(); + const projectRootMappings = createProjectRootMappings(graph.nodes); + const projectName = findProjectForPath( + relative(workspaceRoot, dirname), + projectRootMappings + ); + const deps = findAllProjectNodeDependencies(projectName, graph); + + return getRelativeDependencyPaths(graph.nodes[projectName], deps, graph); +} + +// Exported for testing +export function getRelativeDependencyPaths( + project: ProjectGraphProjectNode, + deps: string[], + graph: ProjectGraph +): string[] { + if (!project.data?.root) { + throw new Error( + `Project ${project.name} has no root set. Check the project configuration.` + ); + } + + const paths = new Set(); + const offset = offsetFromRoot(project.data.root); + const [baseProjectPath] = project.data.root.split('/'); + + for (const dep of deps) { + const node = graph.nodes[dep]; + if (!node?.data?.root) continue; + const [basePath] = normalize(node.data.root).split(sep); + const watchPath = baseProjectPath !== basePath ? basePath : node.data.root; + const relativeWatchPath = joinPathFragments(offset, watchPath); + paths.add(relativeWatchPath); + } + + return Array.from(paths); +} diff --git a/packages/remix/src/utils/get-default-export-name.spec.ts b/packages/remix/src/utils/get-default-export-name.spec.ts new file mode 100644 index 0000000000..c7736e6311 --- /dev/null +++ b/packages/remix/src/utils/get-default-export-name.spec.ts @@ -0,0 +1,31 @@ +import { Tree } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { getDefaultExportName } from './get-default-export-name'; + +describe('getDefaultExportName', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + tree.write('.gitignore', `/node_modules/dist`); + }); + + it("should get the default export's name", () => { + tree.write( + 'component.tsx', + `export default function Component() { return (

Hello world!

); };` + ); + + const defaultExportName = getDefaultExportName(tree, 'component.tsx'); + + expect(defaultExportName).toEqual('Component'); + }); + + it("should return 'Unknown' if there is no default export", () => { + tree.write('util.ts', `export function util() { return 'hello world'; };`); + + const defaultExportName = getDefaultExportName(tree, 'util.ts'); + + expect(defaultExportName).toEqual('Unknown'); + }); +}); diff --git a/packages/remix/src/utils/get-default-export-name.ts b/packages/remix/src/utils/get-default-export-name.ts new file mode 100644 index 0000000000..bda9cab522 --- /dev/null +++ b/packages/remix/src/utils/get-default-export-name.ts @@ -0,0 +1,6 @@ +import { Tree } from '@nx/devkit'; +import { getDefaultExport } from './get-default-export'; + +export function getDefaultExportName(tree: Tree, path: string) { + return getDefaultExport(tree, path)?.name.text ?? 'Unknown'; +} diff --git a/packages/remix/src/utils/get-default-export.ts b/packages/remix/src/utils/get-default-export.ts new file mode 100644 index 0000000000..3adbd7c281 --- /dev/null +++ b/packages/remix/src/utils/get-default-export.ts @@ -0,0 +1,29 @@ +import { Tree } from '@nx/devkit'; +import { + createSourceFile, + isFunctionDeclaration, + ScriptTarget, + SyntaxKind, +} from 'typescript'; + +export function getDefaultExport(tree: Tree, path: string) { + const contents = tree.read(path, 'utf-8'); + + const sourceFile = createSourceFile(path, contents, ScriptTarget.ESNext); + + const functionDeclarations = sourceFile.statements.filter( + isFunctionDeclaration + ); + + return functionDeclarations.find((functionDeclaration) => { + const isDefault = functionDeclaration.modifiers.find( + (mod) => mod.kind === SyntaxKind.DefaultKeyword + ); + + const isExport = functionDeclaration.modifiers.find( + (mod) => mod.kind === SyntaxKind.ExportKeyword + ); + + return isDefault && isExport; + }); +} diff --git a/packages/remix/src/utils/insert-import.spec.ts b/packages/remix/src/utils/insert-import.spec.ts new file mode 100644 index 0000000000..a0b55ddf40 --- /dev/null +++ b/packages/remix/src/utils/insert-import.spec.ts @@ -0,0 +1,93 @@ +import { Tree } from '@nx/devkit'; +import { createTree } from '@nx/devkit/testing'; +import { insertImport } from './insert-import'; + +describe('insertImport', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTree(); + }); + + it('should insert a statement after the last import', () => { + tree.write('index.ts', `import { a } from 'a-path';`); + + insertImport(tree, 'index.ts', 'b', 'a-path'); + + expect(tree.read('index.ts', 'utf-8')).toMatchInlineSnapshot( + `"import { a ,b} from 'a-path';"` + ); + }); + + it('should insert a statement after the last import with a trailing comma', () => { + tree.write('index.ts', `import { a, } from 'a-path';`); + + insertImport(tree, 'index.ts', 'b', 'a-path'); + + expect(tree.read('index.ts', 'utf-8')).toMatchInlineSnapshot( + `"import { a, b,} from 'a-path';"` + ); + }); + + it('should insert a statement at the beginning if there are no imports', () => { + tree.write('index.ts', `import { a } from 'a-path';`); + + insertImport(tree, 'index.ts', 'b', 'b-path'); + + expect(tree.read('index.ts', 'utf-8')).toMatchInlineSnapshot(` + "import { a } from 'a-path'; + import { b } from 'b-path';" + `); + }); + + it('should insert a type-only statement after the last import', () => { + tree.write('index.ts', `import type { a } from 'a-path';`); + + insertImport(tree, 'index.ts', 'b', 'a-path', { typeOnly: true }); + + expect(tree.read('index.ts', 'utf-8')).toMatchInlineSnapshot( + `"import type { a ,b} from 'a-path';"` + ); + }); + + it('should insert a type-only statement after the last import with a trailing comma', () => { + tree.write('index.ts', `import type { a, } from 'a-path';`); + + insertImport(tree, 'index.ts', 'b', 'a-path', { typeOnly: true }); + + expect(tree.read('index.ts', 'utf-8')).toMatchInlineSnapshot( + `"import type { a, b,} from 'a-path';"` + ); + }); + + it('should insert a type-only statement at the beginning if there are no imports', () => { + tree.write('index.ts', `import { a } from 'a-path';`); + + insertImport(tree, 'index.ts', 'b', 'b-path', { typeOnly: true }); + + expect(tree.read('index.ts', 'utf-8')).toMatchInlineSnapshot(` + "import { a } from 'a-path'; + import type { b } from 'b-path';" + `); + }); + + it('should not insert a type-only statement into an existing import', () => { + tree.write('index.ts', `import { a } from 'a-path';`); + + insertImport(tree, 'index.ts', 'b', 'a-path', { typeOnly: true }); + + expect(tree.read('index.ts', 'utf-8')).toMatchInlineSnapshot(` + "import { a } from 'a-path'; + import type { b } from 'a-path';" + `); + }); + + it('should not add the same import twice', () => { + tree.write('index.ts', `import { a } from 'a-path';`); + insertImport(tree, 'index.ts', 'a', 'a-path'); + + expect(tree.read('index.ts', 'utf-8')).toMatchInlineSnapshot( + `"import { a } from 'a-path';"` + ); + }); +}); diff --git a/packages/remix/src/utils/insert-import.ts b/packages/remix/src/utils/insert-import.ts new file mode 100644 index 0000000000..823ecee3f9 --- /dev/null +++ b/packages/remix/src/utils/insert-import.ts @@ -0,0 +1,90 @@ +import { applyChangesToString, ChangeType, Tree } from '@nx/devkit'; +import { + createSourceFile, + isImportDeclaration, + isNamedImports, + isStringLiteral, + NamedImports, + ScriptTarget, +} from 'typescript'; +import { insertStatementAfterImports } from './insert-statement-after-imports'; + +export function insertImport( + tree: Tree, + path: string, + name: string, + modulePath: string, + options: { typeOnly: boolean } = { typeOnly: false } +) { + if (!tree.exists(path)) + throw Error( + `Could not insert import ${name} from ${modulePath} in ${path}: path not found` + ); + + const contents = tree.read(path, 'utf-8'); + + const sourceFile = createSourceFile(path, contents, ScriptTarget.ESNext); + + let importStatements = sourceFile.statements.filter(isImportDeclaration); + + if (options.typeOnly) { + importStatements = importStatements.filter( + (node) => node.importClause.isTypeOnly + ); + } else { + importStatements = importStatements.filter( + (node) => !node.importClause.isTypeOnly + ); + } + + const existingImport = importStatements.find( + (statement) => + isStringLiteral(statement.moduleSpecifier) && + statement.moduleSpecifier + .getText(sourceFile) + .replace(/['"`]/g, '') + .trim() === modulePath && + statement.importClause.namedBindings && + isNamedImports(statement.importClause.namedBindings) + ); + + if (!existingImport) { + insertStatementAfterImports( + tree, + path, + options.typeOnly + ? `import type { ${name} } from '${modulePath}';` + : `import { ${name} } from '${modulePath}';` + ); + return; + } + + const namedImports = existingImport.importClause + .namedBindings as NamedImports; + + const alreadyImported = + namedImports.elements.find( + (element) => element.name.escapedText === name + ) !== undefined; + + if (!alreadyImported) { + const index = namedImports.getEnd() - 1; + + let text: string; + if (namedImports.elements.hasTrailingComma) { + text = `${name},`; + } else { + text = `,${name}`; + } + + const newContents = applyChangesToString(contents, [ + { + type: ChangeType.Insert, + index, + text, + }, + ]); + + tree.write(path, newContents); + } +} diff --git a/packages/remix/src/utils/insert-statement-after-imports.spec.ts b/packages/remix/src/utils/insert-statement-after-imports.spec.ts new file mode 100644 index 0000000000..b515e3fc9b --- /dev/null +++ b/packages/remix/src/utils/insert-statement-after-imports.spec.ts @@ -0,0 +1,33 @@ +import { Tree } from '@nx/devkit'; +import { createTree } from '@nx/devkit/testing'; +import { insertStatementAfterImports } from './insert-statement-after-imports'; + +describe('insertStatement', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTree(); + }); + + it('should insert a statement after the last import', () => { + tree.write('index.ts', `import { a } from 'a';`); + + insertStatementAfterImports(tree, 'index.ts', 'const b = 0;'); + + expect(tree.read('index.ts', 'utf-8')).toMatchInlineSnapshot(` + "import { a } from 'a'; + const b = 0;" + `); + }); + + it('should insert a statement at the beginning if there are no imports', () => { + tree.write('index.ts', `const a = 0;`); + + insertStatementAfterImports(tree, 'index.ts', 'const b = 0;\n'); + + expect(tree.read('index.ts', 'utf-8')).toMatchInlineSnapshot(` + "const b = 0; + const a = 0;" + `); + }); +}); diff --git a/packages/remix/src/utils/insert-statement-after-imports.ts b/packages/remix/src/utils/insert-statement-after-imports.ts new file mode 100644 index 0000000000..9c6e90d05c --- /dev/null +++ b/packages/remix/src/utils/insert-statement-after-imports.ts @@ -0,0 +1,39 @@ +import { applyChangesToString, ChangeType, Tree } from '@nx/devkit'; +import { + createSourceFile, + isImportDeclaration, + ScriptTarget, +} from 'typescript'; + +/** + * Insert a statement after the last import statement in a file + */ +export function insertStatementAfterImports( + tree: Tree, + path: string, + statement: string +) { + const contents = tree.read(path, 'utf-8'); + + const sourceFile = createSourceFile(path, contents, ScriptTarget.ESNext); + + const importStatements = sourceFile.statements.filter(isImportDeclaration); + const index = + importStatements.length > 0 + ? importStatements[importStatements.length - 1].getEnd() + : 0; + + if (importStatements.length > 0) { + statement = `\n${statement}`; + } + + const newContents = applyChangesToString(contents, [ + { + type: ChangeType.Insert, + index, + text: statement, + }, + ]); + + tree.write(path, newContents); +} diff --git a/packages/remix/src/utils/insert-statement-in-default-function.spec.ts b/packages/remix/src/utils/insert-statement-in-default-function.spec.ts new file mode 100644 index 0000000000..16ed6c24d9 --- /dev/null +++ b/packages/remix/src/utils/insert-statement-in-default-function.spec.ts @@ -0,0 +1,40 @@ +import { Tree } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { insertStatementInDefaultFunction } from './insert-statement-in-default-function'; + +describe('insertStatementInDefaultFunction', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + tree.write('.gitignore', `/node_modules/dist`); + }); + + it('should insert statement in default function', () => { + tree.write( + 'component.tsx', + `export default function Component() { return (

Hello world!

); };` + ); + + insertStatementInDefaultFunction( + tree, + 'component.tsx', + `const someVar = "whatever";` + ); + + expect(tree.read('component.tsx', 'utf-8')).toMatchInlineSnapshot( + `"export default function Component() {const someVar = "whatever"; return (

Hello world!

); };"` + ); + }); + + it('should throw if there is no default export', () => { + tree.write('util.ts', `export function hello() { return 'helloWorld'; }`); + expect(() => + insertStatementInDefaultFunction( + tree, + 'util.ts', + `const someVar = "whatever";` + ) + ).toThrowErrorMatchingInlineSnapshot(`"No default export found!"`); + }); +}); diff --git a/packages/remix/src/utils/insert-statement-in-default-function.ts b/packages/remix/src/utils/insert-statement-in-default-function.ts new file mode 100644 index 0000000000..d300f10ae2 --- /dev/null +++ b/packages/remix/src/utils/insert-statement-in-default-function.ts @@ -0,0 +1,29 @@ +import { applyChangesToString, ChangeType, Tree } from '@nx/devkit'; +import { getDefaultExport } from './get-default-export'; + +export function insertStatementInDefaultFunction( + tree: Tree, + path: string, + statement +) { + const defaultExport = getDefaultExport(tree, path); + + if (!defaultExport) { + throw Error('No default export found!'); + } + + const index = + defaultExport.body.statements.length > 0 + ? defaultExport.body.statements[0].pos + : 0; + + const newContents = applyChangesToString(tree.read(path, 'utf-8'), [ + { + type: ChangeType.Insert, + index, + text: statement, + }, + ]); + + tree.write(path, newContents); +} diff --git a/packages/remix/src/utils/remix-config.ts b/packages/remix/src/utils/remix-config.ts new file mode 100644 index 0000000000..4d646fee81 --- /dev/null +++ b/packages/remix/src/utils/remix-config.ts @@ -0,0 +1,19 @@ +import { joinPathFragments, readProjectConfiguration, Tree } from '@nx/devkit'; +import type { AppConfig } from '@remix-run/dev'; + +export function getRemixConfigPath(tree: Tree, projectName: string) { + const project = readProjectConfiguration(tree, projectName); + if (!project) throw new Error(`Project does not exist: ${projectName}`); + + for (const ext of ['.cjs', '.js']) { + const configPath = joinPathFragments(project.root, `remix.config${ext}`); + if (tree.exists(configPath)) { + return configPath; + } + } +} + +export async function getRemixConfigValues(tree: Tree, projectName: string) { + const remixConfigPath = getRemixConfigPath(tree, projectName); + return eval(tree.read(remixConfigPath, 'utf-8')) as AppConfig; +} diff --git a/packages/remix/src/utils/remix-route-utils.ts b/packages/remix/src/utils/remix-route-utils.ts new file mode 100644 index 0000000000..36da8f6c2d --- /dev/null +++ b/packages/remix/src/utils/remix-route-utils.ts @@ -0,0 +1,95 @@ +import { + joinPathFragments, + names, + readProjectConfiguration, + Tree, +} from '@nx/devkit'; +import { getRemixConfigValues } from './remix-config'; + +/** + * + * @param tree + * @param path to the route which could be fully specified or just "foo/bar" + * @param projectName the name of the project where the route should be added + * @param fileExtension the file extension to add to resolved route file + * @returns file path to the route + */ +export async function resolveRemixRouteFile( + tree: Tree, + path: string, + projectName?: string, + fileExtension?: string +): Promise { + const { name: routePath } = names(path.replace(/^\//, '').replace(/\/$/, '')); + + if (!projectName) { + return appendRouteFileExtension(tree, routePath, fileExtension); + } else { + const project = readProjectConfiguration(tree, projectName); + if (!project) throw new Error(`Project does not exist: ${projectName}`); + const normalizedRoutePath = normalizeRoutePath(routePath); + const fileName = appendRouteFileExtension( + tree, + normalizedRoutePath, + fileExtension + ); + + return joinPathFragments( + await resolveRemixAppDirectory(tree, projectName), + 'routes', + fileName + ); + } +} + +function appendRouteFileExtension( + tree: Tree, + routePath: string, + fileExtension?: string +) { + // if no file extension specified, let's try to find it + if (!fileExtension) { + // see if the path already has it + const extensionMatch = routePath.match(/(\.[^.]+)$/); + + if (extensionMatch) { + fileExtension = extensionMatch[0]; + } else { + // look for either .ts or .tsx to exist in tree + if (tree.exists(`${routePath}.ts`)) { + fileExtension = '.ts'; + } else { + // default to .tsx if nothing else found + fileExtension = '.tsx'; + } + } + } + + return routePath.endsWith(fileExtension) + ? routePath + : `${routePath}${fileExtension}`; +} + +export function normalizeRoutePath(path: string) { + return path.indexOf('/routes/') > -1 + ? path.substring(path.indexOf('/routes/') + 8) + : path; +} + +export function checkRoutePathForErrors(path: string) { + return ( + path.match(/\w\.\.\w/) || // route.$withParams.tsx => route..tsx + path.match(/\w\/\/\w/) || // route/$withParams/index.tsx => route//index.tsx + path.match(/\w\/\.\w/) // route/$withParams.tsx => route/.tsx + ); +} + +export async function resolveRemixAppDirectory( + tree: Tree, + projectName: string +) { + const project = readProjectConfiguration(tree, projectName); + const remixConfig = await getRemixConfigValues(tree, projectName); + + return joinPathFragments(project.root, remixConfig.appDirectory ?? 'app'); +} diff --git a/packages/remix/src/utils/testing-config-utils.ts b/packages/remix/src/utils/testing-config-utils.ts new file mode 100644 index 0000000000..bdc446ffbb --- /dev/null +++ b/packages/remix/src/utils/testing-config-utils.ts @@ -0,0 +1,128 @@ +import { stripIndents, type Tree } from '@nx/devkit'; +import { ensureTypescript } from '@nx/js/src/utils/typescript/ensure-typescript'; + +let tsModule: typeof import('typescript'); + +export function updateViteTestSetup( + tree: Tree, + pathToViteConfig: string, + pathToTestSetup: string +) { + if (!tsModule) { + tsModule = ensureTypescript(); + } + const { tsquery } = require('@phenomnomnominal/tsquery'); + const fileContents = tree.read(pathToViteConfig, 'utf-8'); + + const ast = tsquery.ast(fileContents); + + const TEST_SETUPFILES_SELECTOR = + 'PropertyAssignment:has(Identifier[name=test]) PropertyAssignment:has(Identifier[name=setupFiles])'; + + const nodes = tsquery(ast, TEST_SETUPFILES_SELECTOR, { + visitAllChildren: true, + }); + + let updatedFileContents = fileContents; + if (nodes.length === 0) { + const TEST_CONFIG_SELECTOR = + 'PropertyAssignment:has(Identifier[name=test]) > ObjectLiteralExpression'; + const testConfigNodes = tsquery(ast, TEST_CONFIG_SELECTOR, { + visitAllChildren: true, + }); + updatedFileContents = stripIndents`${fileContents.slice( + 0, + testConfigNodes[0].getStart() + 1 + )}setupFiles: ['${pathToTestSetup}'],${fileContents.slice( + testConfigNodes[0].getStart() + 1 + )}`; + } else { + const arrayNodes = tsquery(nodes[0], 'ArrayLiteralExpression', { + visitAllChildren: true, + }); + if (arrayNodes.length !== 0) { + updatedFileContents = stripIndents`${fileContents.slice( + 0, + arrayNodes[0].getStart() + 1 + )}'${pathToTestSetup}',${fileContents.slice( + arrayNodes[0].getStart() + 1 + )}`; + } + } + + tree.write(pathToViteConfig, updatedFileContents); +} + +export function updateJestTestSetup( + tree: Tree, + pathToJestConfig: string, + pathToTestSetup: string +) { + if (!tsModule) { + tsModule = ensureTypescript(); + } + const { tsquery } = require('@phenomnomnominal/tsquery'); + const fileContents = tree.read(pathToJestConfig, 'utf-8'); + + const ast = tsquery.ast(fileContents); + + const TEST_SETUPFILES_SELECTOR = + 'PropertyAssignment:has(Identifier[name=setupFilesAfterEnv])'; + const nodes = tsquery(ast, TEST_SETUPFILES_SELECTOR, { + visitAllChildren: true, + }); + + if (nodes.length === 0) { + const CONFIG_SELECTOR = 'ObjectLiteralExpression'; + const nodes = tsquery(ast, CONFIG_SELECTOR, { visitAllChildren: true }); + + const updatedFileContents = stripIndents`${fileContents.slice( + 0, + nodes[0].getStart() + 1 + )}setupFilesAfterEnv: ['${pathToTestSetup}'],${fileContents.slice( + nodes[0].getStart() + 1 + )}`; + tree.write(pathToJestConfig, updatedFileContents); + } else { + const arrayNodes = tsquery(nodes[0], 'ArrayLiteralExpression', { + visitAllChildren: true, + }); + if (arrayNodes.length !== 0) { + const updatedFileContents = stripIndents`${fileContents.slice( + 0, + arrayNodes[0].getStart() + 1 + )}'${pathToTestSetup}',${fileContents.slice( + arrayNodes[0].getStart() + 1 + )}`; + + tree.write(pathToJestConfig, updatedFileContents); + } + } +} + +export function updateViteTestIncludes( + tree: Tree, + pathToViteConfig: string, + includesString: string +) { + if (!tsModule) { + tsModule = ensureTypescript(); + } + const { tsquery } = require('@phenomnomnominal/tsquery'); + const fileContents = tree.read(pathToViteConfig, 'utf-8'); + + const ast = tsquery.ast(fileContents); + + const TEST_INCLUDE_SELECTOR = + 'PropertyAssignment:has(Identifier[name=test]) PropertyAssignment:has(Identifier[name=include])'; + const nodes = tsquery(ast, TEST_INCLUDE_SELECTOR, { visitAllChildren: true }); + + if (nodes.length !== 0) { + const updatedFileContents = stripIndents`${fileContents.slice( + 0, + nodes[0].getStart() + )}include: ["${includesString}"]${fileContents.slice(nodes[0].getEnd())}`; + + tree.write(pathToViteConfig, updatedFileContents); + } +} diff --git a/packages/remix/src/utils/upsert-links-function.spec.ts b/packages/remix/src/utils/upsert-links-function.spec.ts new file mode 100644 index 0000000000..ccf75dcfac --- /dev/null +++ b/packages/remix/src/utils/upsert-links-function.spec.ts @@ -0,0 +1,61 @@ +import { stripIndents } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { upsertLinksFunction } from './upsert-links-function'; + +describe('upsertLinksFunctions', () => { + it('should add the imports and the link function when it does not exist', () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + tree.write(`root.tsx`, ``); + + // ACT + upsertLinksFunction( + tree, + 'root.tsx', + 'styles', + './tailwind.css', + '{ rel: "stylesheet", href: styles }' + ); + + // ASSERT + expect(tree.read('root.tsx', 'utf-8')).toMatchInlineSnapshot(` + "import type { LinksFunction } from '@remix-run/node'; + import styles from "./tailwind.css"; + export const links: LinksFunction = () => [ + { rel: "stylesheet", href: styles }, + ];" + `); + }); + + it('should update an existing links function with the new object', () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + tree.write( + `root.tsx`, + stripIndents`import type { LinksFunction } from '@remix-run/node'; + + export const links: LinksFunction = () => [ + { rel: "icon", href: "/favicon.png" type: "image/png" } + ` + ); + + // ACT + upsertLinksFunction( + tree, + 'root.tsx', + 'styles', + './tailwind.css', + '{ rel: "stylesheet", href: styles }' + ); + + // ASSERT + expect(tree.read('root.tsx', 'utf-8')).toMatchInlineSnapshot(` + "import type { LinksFunction } from '@remix-run/node'; + import styles from "./tailwind.css"; + + export const links: LinksFunction = () => [ + { rel: "stylesheet", href: styles }, + { rel: "icon", href: "/favicon.png" type: "image/png" }" + `); + }); +}); diff --git a/packages/remix/src/utils/upsert-links-function.ts b/packages/remix/src/utils/upsert-links-function.ts new file mode 100644 index 0000000000..aac4c79c35 --- /dev/null +++ b/packages/remix/src/utils/upsert-links-function.ts @@ -0,0 +1,51 @@ +import { stripIndents, type Tree } from '@nx/devkit'; +import { tsquery } from '@phenomnomnominal/tsquery'; +import { insertImport } from './insert-import'; +import { insertStatementAfterImports } from './insert-statement-after-imports'; + +export function upsertLinksFunction( + tree: Tree, + filePath: string, + importName: string, + importPath: string, + linkObject: string +) { + insertImport(tree, filePath, 'LinksFunction', '@remix-run/node', { + typeOnly: true, + }); + insertStatementAfterImports( + tree, + filePath, + stripIndents`import ${importName} from "${importPath}";` + ); + + const fileContents = tree.read(filePath, 'utf-8'); + const LINKS_FUNCTION_SELECTOR = + 'VariableDeclaration:has(TypeReference > Identifier[name=LinksFunction])'; + const ast = tsquery.ast(fileContents); + + const linksFunctionNodes = tsquery(ast, LINKS_FUNCTION_SELECTOR, { + visitAllChildren: true, + }); + if (linksFunctionNodes.length === 0) { + insertStatementAfterImports( + tree, + filePath, + stripIndents`export const links: LinksFunction = () => [ + ${linkObject}, +];` + ); + } else { + const linksArrayNodes = tsquery( + linksFunctionNodes[0], + 'ArrayLiteralExpression', + { visitAllChildren: true } + ); + const arrayNode = linksArrayNodes[0]; + const updatedFileContents = `${fileContents.slice( + 0, + arrayNode.getStart() + 1 + )}\n${linkObject},${fileContents.slice(arrayNode.getStart() + 1)}`; + tree.write(filePath, updatedFileContents); + } +} diff --git a/packages/remix/src/utils/versions.ts b/packages/remix/src/utils/versions.ts new file mode 100644 index 0000000000..43d703c3f7 --- /dev/null +++ b/packages/remix/src/utils/versions.ts @@ -0,0 +1,29 @@ +import { readJson, Tree } from '@nx/devkit'; + +export const nxVersion = require('../../package.json').version; + +export const remixVersion = '^2.3.0'; +export const isbotVersion = '^3.6.8'; +export const reactVersion = '^18.2.0'; +export const reactDomVersion = '^18.2.0'; +export const typesReactVersion = '^18.2.0'; +export const typesReactDomVersion = '^18.2.0'; +export const eslintVersion = '^8.38.0'; +export const typescriptVersion = '^5.1.6'; +export const tailwindVersion = '^3.3.0'; +export const testingLibraryReactVersion = '^14.1.2'; +export const testingLibraryJestDomVersion = '^6.1.4'; +export const testingLibraryUserEventsVersion = '^14.5.1'; + +export function getRemixVersion(tree: Tree): string { + return getPackageVersion(tree, '@remix-run/dev') ?? remixVersion; +} + +export function getPackageVersion(tree: Tree, packageName: string) { + const packageJsonContents = readJson(tree, 'package.json'); + return ( + packageJsonContents?.['devDependencies']?.[packageName] ?? + packageJsonContents?.['dependencies']?.[packageName] ?? + null + ); +} diff --git a/packages/remix/tsconfig.json b/packages/remix/tsconfig.json new file mode 100644 index 0000000000..62ebbd9464 --- /dev/null +++ b/packages/remix/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/remix/tsconfig.lib.json b/packages/remix/tsconfig.lib.json new file mode 100644 index 0000000000..003684a30d --- /dev/null +++ b/packages/remix/tsconfig.lib.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"], + "resolveJsonModule": true + }, + "exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"], + "include": ["**/*.ts"] +} diff --git a/packages/remix/tsconfig.spec.json b/packages/remix/tsconfig.spec.json new file mode 100644 index 0000000000..cc20ac97d1 --- /dev/null +++ b/packages/remix/tsconfig.spec.json @@ -0,0 +1,21 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"], + "resolveJsonModule": true + }, + "include": [ + "**/*.test.ts", + "**/*.spec.ts", + "**/*.test.tsx", + "**/*.spec.tsx", + "**/*.test.js", + "**/*.spec.js", + "**/*.test.jsx", + "**/*.spec.jsx", + "**/*.d.ts", + "jest.config.ts" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97bfb5d2fa..0d02185283 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -329,6 +329,12 @@ devDependencies: '@reduxjs/toolkit': specifier: 1.9.0 version: 1.9.0(react-redux@8.0.5)(react@18.2.0) + '@remix-run/dev': + specifier: ^2.3.0 + version: 2.3.0(@types/node@18.16.9)(less@4.1.3)(sass@1.55.0)(stylus@0.59.0)(ts-node@10.9.1)(typescript@5.2.2)(vite@5.0.5) + '@remix-run/node': + specifier: ^2.3.0 + version: 2.3.0(typescript@5.2.2) '@rollup/plugin-babel': specifier: ^5.3.0 version: 5.3.1(@babel/core@7.22.9)(rollup@2.79.0) @@ -2664,6 +2670,16 @@ packages: '@babel/helper-plugin-utils': 7.22.5 dev: true + /@babel/plugin-syntax-decorators@7.23.3(@babel/core@7.23.2): + resolution: {integrity: sha512-cf7Niq4/+/juY67E0PbgH0TDhLQ5J7zS8C/Q5FFx+DWyrRa9sUQdTXkjqKu8zGvuqr7vw1muKiukseihU+PJDA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.23.2 + '@babel/helper-plugin-utils': 7.22.5 + dev: true + /@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.22.9): resolution: {integrity: sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==} peerDependencies: @@ -2796,16 +2812,6 @@ packages: '@babel/helper-plugin-utils': 7.22.5 dev: true - /@babel/plugin-syntax-jsx@7.21.4(@babel/core@7.23.2): - resolution: {integrity: sha512-5hewiLct5OKyh6PLKEYaFclcqtIgCb6bmELouxjF6up5q3Sov7rOayW4RwhbaBL0dit8rA80GNfY+UuDp2mBbQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.23.2 - '@babel/helper-plugin-utils': 7.22.5 - dev: true - /@babel/plugin-syntax-jsx@7.22.5(@babel/core@7.22.9): resolution: {integrity: sha512-gvyP4hZrgrs/wWMaocvxZ44Hw0b3W8Pe+cMxc8V1ULQ07oh8VNbIRaoD1LRZVTvD+0nieDKjfgKg89sD7rrKrg==} engines: {node: '>=6.9.0'} @@ -4721,6 +4727,10 @@ packages: - '@algolia/client-search' dev: false + /@emotion/hash@0.9.1: + resolution: {integrity: sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==} + dev: true + /@emotion/is-prop-valid@0.8.8: resolution: {integrity: sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==} requiresBuild: true @@ -4761,6 +4771,15 @@ packages: react: 18.2.0 dev: true + /@esbuild/android-arm64@0.17.6: + resolution: {integrity: sha512-YnYSCceN/dUzUr5kdtUzB+wZprCafuD89Hs0Aqv9QSdwhYQybhXTaSTcrl6X/aWThn1a/j0eEpUBGOE7269REg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + /@esbuild/android-arm64@0.18.17: resolution: {integrity: sha512-9np+YYdNDed5+Jgr1TdWBsozZ85U1Oa3xW0c7TWqH0y2aGghXtZsuT8nYRbzOMcl0bXZXjOGbksoTtVOlWrRZg==} engines: {node: '>=12'} @@ -4778,6 +4797,15 @@ packages: requiresBuild: true optional: true + /@esbuild/android-arm@0.17.6: + resolution: {integrity: sha512-bSC9YVUjADDy1gae8RrioINU6e1lCkg3VGVwm0QQ2E1CWcC4gnMce9+B6RpxuSsrsXsk1yojn7sp1fnG8erE2g==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + /@esbuild/android-arm@0.18.17: resolution: {integrity: sha512-wHsmJG/dnL3OkpAcwbgoBTTMHVi4Uyou3F5mf58ZtmUyIKfcdA7TROav/6tCzET4A3QW2Q2FC+eFneMU+iyOxg==} engines: {node: '>=12'} @@ -4795,6 +4823,15 @@ packages: requiresBuild: true optional: true + /@esbuild/android-x64@0.17.6: + resolution: {integrity: sha512-MVcYcgSO7pfu/x34uX9u2QIZHmXAB7dEiLQC5bBl5Ryqtpj9lT2sg3gNDEsrPEmimSJW2FXIaxqSQ501YLDsZQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + /@esbuild/android-x64@0.18.17: resolution: {integrity: sha512-O+FeWB/+xya0aLg23hHEM2E3hbfwZzjqumKMSIqcHbNvDa+dza2D0yLuymRBQQnC34CWrsJUXyH2MG5VnLd6uw==} engines: {node: '>=12'} @@ -4812,6 +4849,15 @@ packages: requiresBuild: true optional: true + /@esbuild/darwin-arm64@0.17.6: + resolution: {integrity: sha512-bsDRvlbKMQMt6Wl08nHtFz++yoZHsyTOxnjfB2Q95gato+Yi4WnRl13oC2/PJJA9yLCoRv9gqT/EYX0/zDsyMA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + /@esbuild/darwin-arm64@0.18.17: resolution: {integrity: sha512-M9uJ9VSB1oli2BE/dJs3zVr9kcCBBsE883prage1NWz6pBS++1oNn/7soPNS3+1DGj0FrkSvnED4Bmlu1VAE9g==} engines: {node: '>=12'} @@ -4829,6 +4875,15 @@ packages: requiresBuild: true optional: true + /@esbuild/darwin-x64@0.17.6: + resolution: {integrity: sha512-xh2A5oPrYRfMFz74QXIQTQo8uA+hYzGWJFoeTE8EvoZGHb+idyV4ATaukaUvnnxJiauhs/fPx3vYhU4wiGfosg==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + /@esbuild/darwin-x64@0.18.17: resolution: {integrity: sha512-XDre+J5YeIJDMfp3n0279DFNrGCXlxOuGsWIkRb1NThMZ0BsrWXoTg23Jer7fEXQ9Ye5QjrvXpxnhzl3bHtk0g==} engines: {node: '>=12'} @@ -4846,6 +4901,15 @@ packages: requiresBuild: true optional: true + /@esbuild/freebsd-arm64@0.17.6: + resolution: {integrity: sha512-EnUwjRc1inT4ccZh4pB3v1cIhohE2S4YXlt1OvI7sw/+pD+dIE4smwekZlEPIwY6PhU6oDWwITrQQm5S2/iZgg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + /@esbuild/freebsd-arm64@0.18.17: resolution: {integrity: sha512-cjTzGa3QlNfERa0+ptykyxs5A6FEUQQF0MuilYXYBGdBxD3vxJcKnzDlhDCa1VAJCmAxed6mYhA2KaJIbtiNuQ==} engines: {node: '>=12'} @@ -4863,6 +4927,15 @@ packages: requiresBuild: true optional: true + /@esbuild/freebsd-x64@0.17.6: + resolution: {integrity: sha512-Uh3HLWGzH6FwpviUcLMKPCbZUAFzv67Wj5MTwK6jn89b576SR2IbEp+tqUHTr8DIl0iDmBAf51MVaP7pw6PY5Q==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + /@esbuild/freebsd-x64@0.18.17: resolution: {integrity: sha512-sOxEvR8d7V7Kw8QqzxWc7bFfnWnGdaFBut1dRUYtu+EIRXefBc/eIsiUiShnW0hM3FmQ5Zf27suDuHsKgZ5QrA==} engines: {node: '>=12'} @@ -4880,6 +4953,15 @@ packages: requiresBuild: true optional: true + /@esbuild/linux-arm64@0.17.6: + resolution: {integrity: sha512-bUR58IFOMJX523aDVozswnlp5yry7+0cRLCXDsxnUeQYJik1DukMY+apBsLOZJblpH+K7ox7YrKrHmJoWqVR9w==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-arm64@0.18.17: resolution: {integrity: sha512-c9w3tE7qA3CYWjT+M3BMbwMt+0JYOp3vCMKgVBrCl1nwjAlOMYzEo+gG7QaZ9AtqZFj5MbUc885wuBBmu6aADQ==} engines: {node: '>=12'} @@ -4897,6 +4979,15 @@ packages: requiresBuild: true optional: true + /@esbuild/linux-arm@0.17.6: + resolution: {integrity: sha512-7YdGiurNt7lqO0Bf/U9/arrPWPqdPqcV6JCZda4LZgEn+PTQ5SMEI4MGR52Bfn3+d6bNEGcWFzlIxiQdS48YUw==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-arm@0.18.17: resolution: {integrity: sha512-2d3Lw6wkwgSLC2fIvXKoMNGVaeY8qdN0IC3rfuVxJp89CRfA3e3VqWifGDfuakPmp90+ZirmTfye1n4ncjv2lg==} engines: {node: '>=12'} @@ -4914,6 +5005,15 @@ packages: requiresBuild: true optional: true + /@esbuild/linux-ia32@0.17.6: + resolution: {integrity: sha512-ujp8uoQCM9FRcbDfkqECoARsLnLfCUhKARTP56TFPog8ie9JG83D5GVKjQ6yVrEVdMie1djH86fm98eY3quQkQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-ia32@0.18.17: resolution: {integrity: sha512-1DS9F966pn5pPnqXYz16dQqWIB0dmDfAQZd6jSSpiT9eX1NzKh07J6VKR3AoXXXEk6CqZMojiVDSZi1SlmKVdg==} engines: {node: '>=12'} @@ -4931,6 +5031,15 @@ packages: requiresBuild: true optional: true + /@esbuild/linux-loong64@0.17.6: + resolution: {integrity: sha512-y2NX1+X/Nt+izj9bLoiaYB9YXT/LoaQFYvCkVD77G/4F+/yuVXYCWz4SE9yr5CBMbOxOfBcy/xFL4LlOeNlzYQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-loong64@0.18.17: resolution: {integrity: sha512-EvLsxCk6ZF0fpCB6w6eOI2Fc8KW5N6sHlIovNe8uOFObL2O+Mr0bflPHyHwLT6rwMg9r77WOAWb2FqCQrVnwFg==} engines: {node: '>=12'} @@ -4948,6 +5057,15 @@ packages: requiresBuild: true optional: true + /@esbuild/linux-mips64el@0.17.6: + resolution: {integrity: sha512-09AXKB1HDOzXD+j3FdXCiL/MWmZP0Ex9eR8DLMBVcHorrWJxWmY8Nms2Nm41iRM64WVx7bA/JVHMv081iP2kUA==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-mips64el@0.18.17: resolution: {integrity: sha512-e0bIdHA5p6l+lwqTE36NAW5hHtw2tNRmHlGBygZC14QObsA3bD4C6sXLJjvnDIjSKhW1/0S3eDy+QmX/uZWEYQ==} engines: {node: '>=12'} @@ -4965,6 +5083,15 @@ packages: requiresBuild: true optional: true + /@esbuild/linux-ppc64@0.17.6: + resolution: {integrity: sha512-AmLhMzkM8JuqTIOhxnX4ubh0XWJIznEynRnZAVdA2mMKE6FAfwT2TWKTwdqMG+qEaeyDPtfNoZRpJbD4ZBv0Tg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-ppc64@0.18.17: resolution: {integrity: sha512-BAAilJ0M5O2uMxHYGjFKn4nJKF6fNCdP1E0o5t5fvMYYzeIqy2JdAP88Az5LHt9qBoUa4tDaRpfWt21ep5/WqQ==} engines: {node: '>=12'} @@ -4982,6 +5109,15 @@ packages: requiresBuild: true optional: true + /@esbuild/linux-riscv64@0.17.6: + resolution: {integrity: sha512-Y4Ri62PfavhLQhFbqucysHOmRamlTVK10zPWlqjNbj2XMea+BOs4w6ASKwQwAiqf9ZqcY9Ab7NOU4wIgpxwoSQ==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-riscv64@0.18.17: resolution: {integrity: sha512-Wh/HW2MPnC3b8BqRSIme/9Zhab36PPH+3zam5pqGRH4pE+4xTrVLx2+XdGp6fVS3L2x+DrsIcsbMleex8fbE6g==} engines: {node: '>=12'} @@ -4999,6 +5135,15 @@ packages: requiresBuild: true optional: true + /@esbuild/linux-s390x@0.17.6: + resolution: {integrity: sha512-SPUiz4fDbnNEm3JSdUW8pBJ/vkop3M1YwZAVwvdwlFLoJwKEZ9L98l3tzeyMzq27CyepDQ3Qgoba44StgbiN5Q==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-s390x@0.18.17: resolution: {integrity: sha512-j/34jAl3ul3PNcK3pfI0NSlBANduT2UO5kZ7FCaK33XFv3chDhICLY8wJJWIhiQ+YNdQ9dxqQctRg2bvrMlYgg==} engines: {node: '>=12'} @@ -5016,6 +5161,15 @@ packages: requiresBuild: true optional: true + /@esbuild/linux-x64@0.17.6: + resolution: {integrity: sha512-a3yHLmOodHrzuNgdpB7peFGPx1iJ2x6m+uDvhP2CKdr2CwOaqEFMeSqYAHU7hG+RjCq8r2NFujcd/YsEsFgTGw==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-x64@0.18.17: resolution: {integrity: sha512-QM50vJ/y+8I60qEmFxMoxIx4de03pGo2HwxdBeFd4nMh364X6TIBZ6VQ5UQmPbQWUVWHWws5MmJXlHAXvJEmpQ==} engines: {node: '>=12'} @@ -5033,6 +5187,15 @@ packages: requiresBuild: true optional: true + /@esbuild/netbsd-x64@0.17.6: + resolution: {integrity: sha512-EanJqcU/4uZIBreTrnbnre2DXgXSa+Gjap7ifRfllpmyAU7YMvaXmljdArptTHmjrkkKm9BK6GH5D5Yo+p6y5A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + /@esbuild/netbsd-x64@0.18.17: resolution: {integrity: sha512-/jGlhWR7Sj9JPZHzXyyMZ1RFMkNPjC6QIAan0sDOtIo2TYk3tZn5UDrkE0XgsTQCxWTTOcMPf9p6Rh2hXtl5TQ==} engines: {node: '>=12'} @@ -5050,6 +5213,15 @@ packages: requiresBuild: true optional: true + /@esbuild/openbsd-x64@0.17.6: + resolution: {integrity: sha512-xaxeSunhQRsTNGFanoOkkLtnmMn5QbA0qBhNet/XLVsc+OVkpIWPHcr3zTW2gxVU5YOHFbIHR9ODuaUdNza2Vw==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + /@esbuild/openbsd-x64@0.18.17: resolution: {integrity: sha512-rSEeYaGgyGGf4qZM2NonMhMOP/5EHp4u9ehFiBrg7stH6BYEEjlkVREuDEcQ0LfIl53OXLxNbfuIj7mr5m29TA==} engines: {node: '>=12'} @@ -5067,6 +5239,15 @@ packages: requiresBuild: true optional: true + /@esbuild/sunos-x64@0.17.6: + resolution: {integrity: sha512-gnMnMPg5pfMkZvhHee21KbKdc6W3GR8/JuE0Da1kjwpK6oiFU3nqfHuVPgUX2rsOx9N2SadSQTIYV1CIjYG+xw==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + /@esbuild/sunos-x64@0.18.17: resolution: {integrity: sha512-Y7ZBbkLqlSgn4+zot4KUNYst0bFoO68tRgI6mY2FIM+b7ZbyNVtNbDP5y8qlu4/knZZ73fgJDlXID+ohY5zt5g==} engines: {node: '>=12'} @@ -5084,6 +5265,15 @@ packages: requiresBuild: true optional: true + /@esbuild/win32-arm64@0.17.6: + resolution: {integrity: sha512-G95n7vP1UnGJPsVdKXllAJPtqjMvFYbN20e8RK8LVLhlTiSOH1sd7+Gt7rm70xiG+I5tM58nYgwWrLs6I1jHqg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@esbuild/win32-arm64@0.18.17: resolution: {integrity: sha512-bwPmTJsEQcbZk26oYpc4c/8PvTY3J5/QK8jM19DVlEsAB41M39aWovWoHtNm78sd6ip6prilxeHosPADXtEJFw==} engines: {node: '>=12'} @@ -5101,6 +5291,15 @@ packages: requiresBuild: true optional: true + /@esbuild/win32-ia32@0.17.6: + resolution: {integrity: sha512-96yEFzLhq5bv9jJo5JhTs1gI+1cKQ83cUpyxHuGqXVwQtY5Eq54ZEsKs8veKtiKwlrNimtckHEkj4mRh4pPjsg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@esbuild/win32-ia32@0.18.17: resolution: {integrity: sha512-H/XaPtPKli2MhW+3CQueo6Ni3Avggi6hP/YvgkEe1aSaxw+AeO8MFjq8DlgfTd9Iz4Yih3QCZI6YLMoyccnPRg==} engines: {node: '>=12'} @@ -5118,6 +5317,15 @@ packages: requiresBuild: true optional: true + /@esbuild/win32-x64@0.17.6: + resolution: {integrity: sha512-n6d8MOyUrNp6G4VSpRcgjs5xj4A91svJSaiwLIDWVWEsZtpN5FA9NlBbZHDmAJc2e8e6SF4tkBD3HAvPF+7igA==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@esbuild/win32-x64@0.18.17: resolution: {integrity: sha512-fGEb8f2BSA3CW7riJVurug65ACLuQAzKq0SSqkY2b2yHHH0MzDfbLyKIGzHwOI/gkHcxM/leuSW6D5w/LMNitA==} engines: {node: '>=12'} @@ -5617,6 +5825,10 @@ packages: resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} dev: false + /@jspm/core@2.0.1: + resolution: {integrity: sha512-Lg3PnLp0QXpxwLIAuuJboLeRaIhrgJjeuh797QADg3xz8wGLugQOS5DpsE8A6i6Adgzf+bacllkKZG3J0tGfDw==} + dev: true + /@juggle/resize-observer@3.4.0: resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} dev: true @@ -5654,12 +5866,36 @@ packages: '@types/markdown-it': 12.2.3 dev: false + /@mdx-js/mdx@2.3.0: + resolution: {integrity: sha512-jLuwRlz8DQfQNiUCJR50Y09CGPq3fLtmtUQfVrj79E0JWu3dvsVcxVIcfhR5h0iXu+/z++zDrYeiJqifRynJkA==} + dependencies: + '@types/estree-jsx': 1.0.3 + '@types/mdx': 2.0.10 + estree-util-build-jsx: 2.2.2 + estree-util-is-identifier-name: 2.1.0 + estree-util-to-js: 1.2.0 + estree-walker: 3.0.3 + hast-util-to-estree: 2.3.3 + markdown-extensions: 1.1.1 + periscopic: 3.1.0 + remark-mdx: 2.3.0 + remark-parse: 10.0.2 + remark-rehype: 10.1.0 + unified: 10.1.2 + unist-util-position-from-estree: 1.1.2 + unist-util-stringify-position: 3.0.3 + unist-util-visit: 4.1.2 + vfile: 5.3.7 + transitivePeerDependencies: + - supports-color + dev: true + /@mdx-js/react@2.3.0(react@18.2.0): resolution: {integrity: sha512-zQH//gdOmuu7nt2oJR29vFhDv88oGPmVw6BggmrHeMI+xgEkp1B2dX9/bMBSYtK0dyLX/aOmesKS09g222K1/g==} peerDependencies: react: '>=16' dependencies: - '@types/mdx': 2.0.4 + '@types/mdx': 2.0.10 '@types/react': 18.2.24 react: 18.2.0 dev: true @@ -6214,6 +6450,22 @@ packages: semver: 7.5.3 dev: true + /@npmcli/git@4.1.0: + resolution: {integrity: sha512-9hwoB3gStVfa0N31ymBmrX+GuDGdVA/QWShZVqE0HK2Af+7QGGrCTbZia/SW0ImUTjTne7SP91qxDmtXvDHRPQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + dependencies: + '@npmcli/promise-spawn': 6.0.2 + lru-cache: 7.14.0 + npm-pick-manifest: 8.0.2 + proc-log: 3.0.0 + promise-inflight: 1.0.1 + promise-retry: 2.0.1 + semver: 7.5.3 + which: 3.0.1 + transitivePeerDependencies: + - bluebird + dev: true + /@npmcli/git@5.0.3: resolution: {integrity: sha512-UZp9NwK+AynTrKvHn5k3KviW/hA5eENmFsu3iAPe7sWRt0lFUdsY/wXIYjpDFe7cdSNwOIzbObfwgt6eL5/2zw==} engines: {node: ^16.14.0 || >=18.0.0} @@ -6253,6 +6505,28 @@ packages: engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} dev: true + /@npmcli/package-json@4.0.1: + resolution: {integrity: sha512-lRCEGdHZomFsURroh522YvA/2cVb9oPIJrjHanCJZkiasz1BzcnLr3tBJhlV7S86MBJBuAQ33is2D60YitZL2Q==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + dependencies: + '@npmcli/git': 4.1.0 + glob: 10.2.2 + hosted-git-info: 6.1.1 + json-parse-even-better-errors: 3.0.0 + normalize-package-data: 5.0.0 + proc-log: 3.0.0 + semver: 7.5.3 + transitivePeerDependencies: + - bluebird + dev: true + + /@npmcli/promise-spawn@6.0.2: + resolution: {integrity: sha512-gGq0NJkIGSwdbUt4yhdF8ZrmkGKVz9vAdVzpOfnom+V8PLSmSOVhZwbNvZZS1EYcJN5hzzKBxmmVVAInM6HQLg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + dependencies: + which: 3.0.1 + dev: true + /@npmcli/promise-spawn@7.0.0: resolution: {integrity: sha512-wBqcGsMELZna0jDblGd7UXgOby45TQaMWmbFwWX+SEotk4HV6zG2t6rT9siyLhPk4P6YYqgfL1UO8nMWDBVJXQ==} engines: {node: ^16.14.0 || >=18.0.0} @@ -8445,11 +8719,188 @@ packages: reselect: 4.1.7 dev: true + /@remix-run/dev@2.3.0(@types/node@18.16.9)(less@4.1.3)(sass@1.55.0)(stylus@0.59.0)(ts-node@10.9.1)(typescript@5.2.2)(vite@5.0.5): + resolution: {integrity: sha512-Eno0XHyIKo5GyzN4OAwNkgkyl4H1mLWbqeVUA8T5HmVDj+8qJLIcYeayS2BmA1KYAHJBiy5ufAGi2MpaXMjKww==} + engines: {node: '>=18.0.0'} + hasBin: true + peerDependencies: + '@remix-run/serve': ^2.3.0 + typescript: ^5.1.0 + vite: ^4.4.9 || ^5.0.0 + peerDependenciesMeta: + '@remix-run/serve': + optional: true + typescript: + optional: true + vite: + optional: true + dependencies: + '@babel/core': 7.23.2 + '@babel/generator': 7.23.0 + '@babel/parser': 7.23.0 + '@babel/plugin-syntax-decorators': 7.23.3(@babel/core@7.23.2) + '@babel/plugin-syntax-jsx': 7.22.5(@babel/core@7.23.2) + '@babel/preset-typescript': 7.22.5(@babel/core@7.23.2) + '@babel/traverse': 7.23.2 + '@babel/types': 7.23.0 + '@mdx-js/mdx': 2.3.0 + '@npmcli/package-json': 4.0.1 + '@remix-run/node': 2.3.0(typescript@5.2.2) + '@remix-run/router': 1.12.0-pre.0 + '@remix-run/server-runtime': 2.3.0(typescript@5.2.2) + '@types/mdx': 2.0.10 + '@vanilla-extract/integration': 6.2.4(@types/node@18.16.9)(less@4.1.3)(sass@1.55.0)(stylus@0.59.0) + arg: 5.0.2 + cacache: 17.1.4 + chalk: 4.1.2 + chokidar: 3.5.3 + cross-spawn: 7.0.3 + dotenv: 16.3.1 + es-module-lexer: 1.4.1 + esbuild: 0.17.6 + esbuild-plugins-node-modules-polyfill: 1.6.1(esbuild@0.17.6) + execa: 5.1.1 + exit-hook: 2.2.1 + express: 4.18.2 + fs-extra: 10.1.0 + get-port: 5.1.1 + gunzip-maybe: 1.4.2 + jsesc: 3.0.2 + json5: 2.2.3 + lodash: 4.17.21 + lodash.debounce: 4.0.8 + minimatch: 9.0.3 + node-fetch: 2.6.12 + ora: 5.4.1 + parse-multipart-data: 1.5.0 + picocolors: 1.0.0 + picomatch: 2.3.1 + pidtree: 0.6.0 + postcss: 8.4.19 + postcss-discard-duplicates: 5.1.0(postcss@8.4.19) + postcss-load-config: 4.0.2(postcss@8.4.19)(ts-node@10.9.1) + postcss-modules: 6.0.0(postcss@8.4.19) + prettier: 2.7.1 + pretty-ms: 7.0.1 + react-refresh: 0.14.0 + remark-frontmatter: 4.0.1 + remark-mdx-frontmatter: 1.1.1 + semver: 7.5.3 + set-cookie-parser: 2.6.0 + tar-fs: 2.1.1 + tsconfig-paths: 4.1.2 + typescript: 5.2.2 + undici: 5.27.2 + vite: 5.0.5(@types/node@18.16.9)(less@4.1.3)(sass@1.55.0)(stylus@0.59.0) + ws: 7.5.9 + transitivePeerDependencies: + - '@types/node' + - bluebird + - bufferutil + - encoding + - less + - lightningcss + - sass + - stylus + - sugarss + - supports-color + - terser + - ts-node + - utf-8-validate + dev: true + + /@remix-run/node@2.3.0(typescript@5.2.2): + resolution: {integrity: sha512-WQybWc1EWPLMD/btDtchVrhoLvz/ek6MB0gr2cV2N3Sxgn1VaJmpsN3+sUA5lK8vR2S/kOmGun2Ut3tKi8TKHg==} + engines: {node: '>=18.0.0'} + peerDependencies: + typescript: ^5.1.0 + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@remix-run/server-runtime': 2.3.0(typescript@5.2.2) + '@remix-run/web-fetch': 4.4.2 + '@remix-run/web-file': 3.1.0 + '@remix-run/web-stream': 1.1.0 + '@web3-storage/multipart-parser': 1.0.0 + cookie-signature: 1.2.1 + source-map-support: 0.5.21 + stream-slice: 0.1.2 + typescript: 5.2.2 + dev: true + + /@remix-run/router@1.12.0: + resolution: {integrity: sha512-2hXv036Bux90e1GXTWSMfNzfDDK8LA8JYEWfyHxzvwdp6GyoWEovKc9cotb3KCKmkdwsIBuFGX7ScTWyiHv7Eg==} + engines: {node: '>=14.0.0'} + dev: true + + /@remix-run/router@1.12.0-pre.0: + resolution: {integrity: sha512-+bBn9KqD2AC0pttSGydVFOZSsT0NqQ1+rGFwMTx9dRANk6oGxrPbKTDxLLikocscGzSL5przvcK4Uxfq8yU7BQ==} + engines: {node: '>=14.0.0'} + dev: true + /@remix-run/router@1.6.2: resolution: {integrity: sha512-LzqpSrMK/3JBAVBI9u3NWtOhWNw5AMQfrUFYB0+bDHTSw17z++WJLsPsxAuK+oSddsxk4d7F/JcdDPM1M5YAhA==} engines: {node: '>=14'} dev: true + /@remix-run/server-runtime@2.3.0(typescript@5.2.2): + resolution: {integrity: sha512-9BiRK7VPm5nt/aOlRmeROXWA8HKgqjvQy+f9NNpqvf3jj62EUl0h4eUdyqRj6nNh44I+0XUBG7ZQ2xXTrGJATw==} + engines: {node: '>=18.0.0'} + peerDependencies: + typescript: ^5.1.0 + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@remix-run/router': 1.12.0 + '@types/cookie': 0.5.4 + '@web3-storage/multipart-parser': 1.0.0 + cookie: 0.5.0 + set-cookie-parser: 2.6.0 + source-map: 0.7.3 + typescript: 5.2.2 + dev: true + + /@remix-run/web-blob@3.1.0: + resolution: {integrity: sha512-owGzFLbqPH9PlKb8KvpNJ0NO74HWE2euAn61eEiyCXX/oteoVzTVSN8mpLgDjaxBf2btj5/nUllSUgpyd6IH6g==} + dependencies: + '@remix-run/web-stream': 1.1.0 + web-encoding: 1.1.5 + dev: true + + /@remix-run/web-fetch@4.4.2: + resolution: {integrity: sha512-jgKfzA713/4kAW/oZ4bC3MoLWyjModOVDjFPNseVqcJKSafgIscrYL9G50SurEYLswPuoU3HzSbO0jQCMYWHhA==} + engines: {node: ^10.17 || >=12.3} + dependencies: + '@remix-run/web-blob': 3.1.0 + '@remix-run/web-file': 3.1.0 + '@remix-run/web-form-data': 3.1.0 + '@remix-run/web-stream': 1.1.0 + '@web3-storage/multipart-parser': 1.0.0 + abort-controller: 3.0.0 + data-uri-to-buffer: 3.0.1 + mrmime: 1.0.1 + dev: true + + /@remix-run/web-file@3.1.0: + resolution: {integrity: sha512-dW2MNGwoiEYhlspOAXFBasmLeYshyAyhIdrlXBi06Duex5tDr3ut2LFKVj7tyHLmn8nnNwFf1BjNbkQpygC2aQ==} + dependencies: + '@remix-run/web-blob': 3.1.0 + dev: true + + /@remix-run/web-form-data@3.1.0: + resolution: {integrity: sha512-NdeohLMdrb+pHxMQ/Geuzdp0eqPbea+Ieo8M8Jx2lGC6TBHsgHzYcBvr0LyPdPVycNRDEpWpiDdCOdCryo3f9A==} + dependencies: + web-encoding: 1.1.5 + dev: true + + /@remix-run/web-stream@1.1.0: + resolution: {integrity: sha512-KRJtwrjRV5Bb+pM7zxcTJkhIqWWSy+MYsIxHK+0m5atcznsf15YwUBWHWulZerV2+vvHH1Lp1DD7pw6qKW8SgA==} + dependencies: + web-streams-polyfill: 3.2.1 + dev: true + /@rollup/plugin-babel@5.3.1(@babel/core@7.22.9)(rollup@2.79.0): resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==} engines: {node: '>= 10.0.0'} @@ -10370,6 +10821,12 @@ packages: minimatch: 9.0.3 dev: true + /@types/acorn@4.0.6: + resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==} + dependencies: + '@types/estree': 1.0.1 + dev: true + /@types/aria-query@4.2.2: resolution: {integrity: sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==} dev: true @@ -10470,6 +10927,10 @@ packages: resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==} dev: true + /@types/cookie@0.5.4: + resolution: {integrity: sha512-7z/eR6O859gyWIAjuvBWFzNURmf2oPBmJlfVWkwehU5nzIyjwBsTh7WMmEEV4JFnHuQ3ex4oyTvfKzcyJVDBNA==} + dev: true + /@types/cors@2.8.13: resolution: {integrity: sha512-RG8AStHlUiV5ysZQKq97copd2UmVYw3/pRMLefISZ3S1hK104Cwm7iLQ3fTKx+lsUH2CE8FlLaYeEA2LSeqYUA==} dependencies: @@ -10516,6 +10977,12 @@ packages: '@types/json-schema': 7.0.12 dev: true + /@types/estree-jsx@1.0.3: + resolution: {integrity: sha512-pvQ+TKeRHeiUGRhvYwRrQ/ISnohKkSJR14fT2yqyZ4e9K5vqc7hrtY2Y1Dw0ZwAzQ6DQsxsaCUuSIIi8v0Cq6w==} + dependencies: + '@types/estree': 1.0.1 + dev: true + /@types/estree@0.0.39: resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==} dev: true @@ -10751,8 +11218,8 @@ packages: dev: false optional: true - /@types/mdx@2.0.4: - resolution: {integrity: sha512-qCYrNdpKwN6YO6FVnx+ulfqifKlE3lQGsNhvDaW9Oxzyob/cRLBJWow8GHBBD4NxQ7BVvtsATgLsX0vZAWmtrg==} + /@types/mdx@2.0.10: + resolution: {integrity: sha512-Rllzc5KHk0Al5/WANwgSPl1/CwjqCy+AZrGd78zuK+jO9aDM6ffblZ+zIjgPNAaEBmlO0RYDvLNh7wD0zKVgEg==} dev: true /@types/mime@3.0.1: @@ -11266,6 +11733,61 @@ packages: eslint-visitor-keys: 3.4.3 dev: true + /@vanilla-extract/babel-plugin-debug-ids@1.0.3: + resolution: {integrity: sha512-vm4jYu1xhSa6ofQ9AhIpR3DkAp4c+eoR1Rpm8/TQI4DmWbmGbOjYRcqV0aWsfaIlNhN4kFuxFMKBNN9oG6iRzA==} + dependencies: + '@babel/core': 7.23.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@vanilla-extract/css@1.14.0: + resolution: {integrity: sha512-rYfm7JciWZ8PFzBM/HDiE2GLnKI3xJ6/vdmVJ5BSgcCZ5CxRlM9Cjqclni9lGzF3eMOijnUhCd/KV8TOzyzbMA==} + dependencies: + '@emotion/hash': 0.9.1 + '@vanilla-extract/private': 1.0.3 + chalk: 4.1.2 + css-what: 6.1.0 + cssesc: 3.0.0 + csstype: 3.1.1 + deep-object-diff: 1.1.9 + deepmerge: 4.3.1 + media-query-parser: 2.0.2 + modern-ahocorasick: 1.0.1 + outdent: 0.8.0 + dev: true + + /@vanilla-extract/integration@6.2.4(@types/node@18.16.9)(less@4.1.3)(sass@1.55.0)(stylus@0.59.0): + resolution: {integrity: sha512-+AfymNMVq9sEUe0OJpdCokmPZg4Zi6CqKaW/PnUOfDwEn53ighHOMOBl5hAgxYR8Kiz9NG43Bn00mkjWlFi+ng==} + dependencies: + '@babel/core': 7.23.2 + '@babel/plugin-syntax-typescript': 7.22.5(@babel/core@7.23.2) + '@vanilla-extract/babel-plugin-debug-ids': 1.0.3 + '@vanilla-extract/css': 1.14.0 + esbuild: 0.17.6 + eval: 0.1.8 + find-up: 5.0.0 + javascript-stringify: 2.1.0 + lodash: 4.17.21 + mlly: 1.4.2 + outdent: 0.8.0 + vite: 4.5.0(@types/node@18.16.9)(less@4.2.0)(sass@1.69.5)(stylus@0.59.0)(terser@5.24.0) + vite-node: 0.28.5(@types/node@18.16.9)(less@4.1.3)(sass@1.55.0)(stylus@0.59.0) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - stylus + - sugarss + - supports-color + - terser + dev: true + + /@vanilla-extract/private@1.0.3: + resolution: {integrity: sha512-17kVyLq3ePTKOkveHxXuIJZtGYs+cSoev7BlP+Lf4916qfDhk/HBjvlYDe8egrea7LNPHKwSZJK/bzZC+Q6AwQ==} + dev: true + /@verdaccio/commons-api@10.2.0: resolution: {integrity: sha512-F/YZANu4DmpcEV0jronzI7v2fGVWkQ5Mwi+bVmV+ACJ+EzR0c9Jbhtbe5QyLUuzR97t8R5E/Xe53O0cc2LukdQ==} engines: {node: '>=8'} @@ -11449,6 +11971,10 @@ packages: resolution: {integrity: sha512-7OjdcV8vQ74eiz1TZLzZP4JwqM5fA94K6yntPS5Z25r9HDuGNzaGdgvwKYq6S+MxwF0TFRwe50fIR/MYnakdkQ==} dev: true + /@web3-storage/multipart-parser@1.0.0: + resolution: {integrity: sha512-BEO6al7BYqcnfX15W2cnGR+Q566ACXAT9UQykORCWW80lmkpWsnEob6zJS1ZVBKsSJC8+7vJkHwlp+lXG1UCdw==} + dev: true + /@webassemblyjs/ast@1.11.1: resolution: {integrity: sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==} dependencies: @@ -11740,6 +12266,12 @@ packages: dependencies: argparse: 2.0.1 + /@zxing/text-encoding@0.9.0: + resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==} + requiresBuild: true + dev: true + optional: true + /JSONStream@1.3.5: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} hasBin: true @@ -12305,6 +12837,11 @@ packages: engines: {node: '>=8'} dev: true + /astring@1.8.6: + resolution: {integrity: sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==} + hasBin: true + dev: true + /async-each-series@0.1.1: resolution: {integrity: sha512-p4jj6Fws4Iy2m0iCmI2am2ZNZCgbdgE+P8F/8csmn2vx7ixXrO2zGcuNsD46X5uZSVecmkEy/M06X2vG8KD6dQ==} engines: {node: '>=0.8.0'} @@ -13070,6 +13607,12 @@ packages: - utf-8-validate dev: true + /browserify-zlib@0.1.4: + resolution: {integrity: sha512-19OEpq7vWgsH6WkvkBJQDFvJS1uPcbFOQ4v9CU839dO+ZZXUZO6XpE6hNCqvlIIj+4fZvRiJ6DsAQ382GwiyTQ==} + dependencies: + pako: 0.2.9 + dev: true + /browserslist@4.21.4: resolution: {integrity: sha512-CBHJJdDmgjl3daYjN5Cp5kbTf1mUhZoS+beLklHIvkOWscs83YAhLlF3Wsh/lciQYAcbBJgTOD44VtG31ZM4Hw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -13249,7 +13792,6 @@ packages: /cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} - dev: false /cacache@16.1.3: resolution: {integrity: sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==} @@ -13277,6 +13819,24 @@ packages: - bluebird dev: true + /cacache@17.1.4: + resolution: {integrity: sha512-/aJwG2l3ZMJ1xNAnqbMpA40of9dj/pIH3QfiuQSqjfPJF747VR0J/bHn+/KdNnHKc6XQcWt/AfRSBft82W1d2A==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + dependencies: + '@npmcli/fs': 3.1.0 + fs-minipass: 3.0.0 + glob: 10.2.2 + lru-cache: 7.14.0 + minipass: 7.0.3 + minipass-collect: 1.0.2 + minipass-flush: 1.0.5 + minipass-pipeline: 1.2.4 + p-map: 4.0.0 + ssri: 10.0.4 + tar: 6.2.0 + unique-filename: 3.0.0 + dev: true + /cacache@18.0.0: resolution: {integrity: sha512-I7mVOPl3PUCeRub1U8YoGz2Lqv9WOBpobZ8RyWFXmReuILz+3OAyTa5oH3QPdtKZD7N0Yk00aLfzn0qvp8dZ1w==} engines: {node: ^16.14.0 || >=18.0.0} @@ -13419,6 +13979,10 @@ packages: resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} dev: true + /ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + dev: true + /chai@4.3.10: resolution: {integrity: sha512-0UXG04VuVbruMUYbJ6JctvH0YnC/4q3/AkT18q4NaITo91CUm0liMS9VqzT9vZhVQ/1eqPanMWjBM+Juhfb/9g==} engines: {node: '>=4'} @@ -13480,10 +14044,18 @@ packages: engines: {node: '>=10'} dev: true + /character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + dev: true + /character-entities-legacy@1.1.4: resolution: {integrity: sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==} dev: false + /character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + dev: true + /character-entities@1.2.4: resolution: {integrity: sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==} dev: false @@ -13496,6 +14068,10 @@ packages: resolution: {integrity: sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==} dev: false + /character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + dev: true + /chardet@0.7.0: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} dev: true @@ -14080,6 +14656,11 @@ packages: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} dev: true + /cookie-signature@1.2.1: + resolution: {integrity: sha512-78KWk9T26NhzXtuL26cIJ8/qNHANyJ/ZYrmEXFzUmhZdjpBv+DlWlOANRTGBt48YcyslsLrj0bMLFTmXvLRCOw==} + engines: {node: '>=6.6.0'} + dev: true + /cookie@0.4.2: resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==} engines: {node: '>= 0.6'} @@ -14746,6 +15327,11 @@ packages: assert-plus: 1.0.0 dev: true + /data-uri-to-buffer@3.0.1: + resolution: {integrity: sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og==} + engines: {node: '>= 6'} + dev: true + /data-urls@2.0.0: resolution: {integrity: sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==} engines: {node: '>=10'} @@ -14894,6 +15480,10 @@ packages: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true + /deep-object-diff@1.1.9: + resolution: {integrity: sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==} + dev: true + /deepmerge@4.2.2: resolution: {integrity: sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==} engines: {node: '>=0.10.0'} @@ -15218,6 +15808,15 @@ packages: /duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + /duplexify@3.7.1: + resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==} + dependencies: + end-of-stream: 1.4.4 + inherits: 2.0.4 + readable-stream: 2.3.8 + stream-shift: 1.0.1 + dev: true + /eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} dev: true @@ -15469,6 +16068,10 @@ packages: resolution: {integrity: sha512-vZK7T0N2CBmBOixhmjdqx2gWVbFZ4DXZ/NyRMZVlJXPa7CyFS+/a4QQsDGDQy9ZfEzxFuNEsMLeQJnKP2p5/JA==} dev: true + /es-module-lexer@1.4.1: + resolution: {integrity: sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==} + dev: true + /es-shim-unscopables@1.0.0: resolution: {integrity: sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==} dependencies: @@ -15522,6 +16125,18 @@ packages: resolution: {integrity: sha512-jyfL/pwPqaFXyKnj8lP8iLk6Z0m099uXR45aSN8Av1XD4vhvQutxxPzgA2bTcAwQpa1zCXDcWOlhFgyP3GKqhQ==} dev: true + /esbuild-plugins-node-modules-polyfill@1.6.1(esbuild@0.17.6): + resolution: {integrity: sha512-6sAwI24PV8W0zxeO+i4BS5zoQypS3SzEGwIdxpzpy65riRuK8apMw8PN0aKVLCTnLr0FgNIxUMRd9BsreBrtog==} + engines: {node: '>=14.0.0'} + peerDependencies: + esbuild: ^0.14.0 || ^0.15.0 || ^0.16.0 || ^0.17.0 || ^0.18.0 || ^0.19.0 + dependencies: + '@jspm/core': 2.0.1 + esbuild: 0.17.6 + local-pkg: 0.4.3 + resolve.exports: 2.0.2 + dev: true + /esbuild-register@3.5.0(esbuild@0.18.17): resolution: {integrity: sha512-+4G/XmakeBAsvJuDugJvtyF1x+XJT4FMocynNpxrvEBViirpfUn2PgNpCHedfWhF4WokNsO/OvMKrmJOIJsI5A==} peerDependencies: @@ -15539,6 +16154,36 @@ packages: hasBin: true dev: true + /esbuild@0.17.6: + resolution: {integrity: sha512-TKFRp9TxrJDdRWfSsSERKEovm6v30iHnrjlcGhLBOtReE28Yp1VSBRfO3GTaOFMoxsNerx4TjrhzSuma9ha83Q==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/android-arm': 0.17.6 + '@esbuild/android-arm64': 0.17.6 + '@esbuild/android-x64': 0.17.6 + '@esbuild/darwin-arm64': 0.17.6 + '@esbuild/darwin-x64': 0.17.6 + '@esbuild/freebsd-arm64': 0.17.6 + '@esbuild/freebsd-x64': 0.17.6 + '@esbuild/linux-arm': 0.17.6 + '@esbuild/linux-arm64': 0.17.6 + '@esbuild/linux-ia32': 0.17.6 + '@esbuild/linux-loong64': 0.17.6 + '@esbuild/linux-mips64el': 0.17.6 + '@esbuild/linux-ppc64': 0.17.6 + '@esbuild/linux-riscv64': 0.17.6 + '@esbuild/linux-s390x': 0.17.6 + '@esbuild/linux-x64': 0.17.6 + '@esbuild/netbsd-x64': 0.17.6 + '@esbuild/openbsd-x64': 0.17.6 + '@esbuild/sunos-x64': 0.17.6 + '@esbuild/win32-arm64': 0.17.6 + '@esbuild/win32-ia32': 0.17.6 + '@esbuild/win32-x64': 0.17.6 + dev: true + /esbuild@0.18.17: resolution: {integrity: sha512-1GJtYnUxsJreHYA0Y+iQz2UEykonY66HNWOb0yXYZi9/kNrORUEHVg87eQsCtqh59PEJ5YVZJO98JHznMJSWjg==} engines: {node: '>=12'} @@ -16062,6 +16707,50 @@ packages: - supports-color dev: true + /estree-util-attach-comments@2.1.1: + resolution: {integrity: sha512-+5Ba/xGGS6mnwFbXIuQiDPTbuTxuMCooq3arVv7gPZtYpjp+VXH/NkHAP35OOefPhNG/UGqU3vt/LTABwcHX0w==} + dependencies: + '@types/estree': 1.0.1 + dev: true + + /estree-util-build-jsx@2.2.2: + resolution: {integrity: sha512-m56vOXcOBuaF+Igpb9OPAy7f9w9OIkb5yhjsZuaPm7HoGi4oTOQi0h2+yZ+AtKklYFZ+rPC4n0wYCJCEU1ONqg==} + dependencies: + '@types/estree-jsx': 1.0.3 + estree-util-is-identifier-name: 2.1.0 + estree-walker: 3.0.3 + dev: true + + /estree-util-is-identifier-name@1.1.0: + resolution: {integrity: sha512-OVJZ3fGGt9By77Ix9NhaRbzfbDV/2rx9EP7YIDJTmsZSEc5kYn2vWcNccYyahJL2uAQZK2a5Or2i0wtIKTPoRQ==} + dev: true + + /estree-util-is-identifier-name@2.1.0: + resolution: {integrity: sha512-bEN9VHRyXAUOjkKVQVvArFym08BTWB0aJPppZZr0UNyAqWsLaVfAqP7hbaTJjzHifmB5ebnR8Wm7r7yGN/HonQ==} + dev: true + + /estree-util-to-js@1.2.0: + resolution: {integrity: sha512-IzU74r1PK5IMMGZXUVZbmiu4A1uhiPgW5hm1GjcOfr4ZzHaMPpLNJjR7HjXiIOzi25nZDrgFTobHTkV5Q6ITjA==} + dependencies: + '@types/estree-jsx': 1.0.3 + astring: 1.8.6 + source-map: 0.7.3 + dev: true + + /estree-util-value-to-estree@1.3.0: + resolution: {integrity: sha512-Y+ughcF9jSUJvncXwqRageavjrNPAI+1M/L3BI3PyLp1nmgYTGUXU6t5z1Y7OWuThoDdhPME07bQU+d5LxdJqw==} + engines: {node: '>=12.0.0'} + dependencies: + is-plain-obj: 3.0.0 + dev: true + + /estree-util-visit@1.2.1: + resolution: {integrity: sha512-xbgqcrkIVbIG+lI/gzbvd9SGTJL4zqJKBFttUl5pP27KhAjtMKbX/mQXJ7qgyXpMgVy/zvpm0xoQQaGL8OloOw==} + dependencies: + '@types/estree-jsx': 1.0.3 + '@types/unist': 2.0.6 + dev: true + /estree-walker@0.6.1: resolution: {integrity: sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==} dev: true @@ -16089,6 +16778,14 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + /eval@0.1.8: + resolution: {integrity: sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw==} + engines: {node: '>= 0.8'} + dependencies: + '@types/node': 18.16.9 + require-like: 0.1.2 + dev: true + /event-emitter@0.3.5: resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==} dependencies: @@ -16172,6 +16869,11 @@ packages: pify: 2.3.0 dev: true + /exit-hook@2.2.1: + resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} + engines: {node: '>=6'} + dev: true + /exit@0.1.2: resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} engines: {node: '>= 0.8.0'} @@ -16412,6 +17114,12 @@ packages: format: 0.2.2 dev: false + /fault@2.0.1: + resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} + dependencies: + format: 0.2.2 + dev: true + /faye-websocket@0.11.4: resolution: {integrity: sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==} engines: {node: '>=0.8.0'} @@ -16772,7 +17480,6 @@ packages: /format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} - dev: false /formdata-node@4.4.1: resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} @@ -17009,6 +17716,11 @@ packages: yargs: 16.2.0 dev: true + /get-port@5.1.1: + resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==} + engines: {node: '>=8'} + dev: true + /get-stdin@8.0.0: resolution: {integrity: sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==} engines: {node: '>=10'} @@ -17333,6 +18045,18 @@ packages: lodash: 4.17.21 dev: true + /gunzip-maybe@1.4.2: + resolution: {integrity: sha512-4haO1M4mLO91PW57BMsDFf75UmwoRX0GkdD+Faw+Lr+r/OZrOCS0pIBwOL1xCKQqnQzbNFGgK2V2CpBUPeFNTw==} + hasBin: true + dependencies: + browserify-zlib: 0.1.4 + is-deflate: 1.0.0 + is-gzip: 1.0.0 + peek-stream: 1.1.3 + pumpify: 1.5.1 + through2: 2.0.5 + dev: true + /handle-thing@2.0.1: resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==} dev: true @@ -17421,6 +18145,28 @@ packages: resolution: {integrity: sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==} dev: false + /hast-util-to-estree@2.3.3: + resolution: {integrity: sha512-ihhPIUPxN0v0w6M5+IiAZZrn0LH2uZomeWwhn7uP7avZC6TE7lIiEh2yBMPr5+zi1aUCXq6VoYRgs2Bw9xmycQ==} + dependencies: + '@types/estree': 1.0.1 + '@types/estree-jsx': 1.0.3 + '@types/hast': 2.3.4 + '@types/unist': 2.0.6 + comma-separated-tokens: 2.0.3 + estree-util-attach-comments: 2.1.1 + estree-util-is-identifier-name: 2.1.0 + hast-util-whitespace: 2.0.1 + mdast-util-mdx-expression: 1.3.2 + mdast-util-mdxjs-esm: 1.3.1 + property-information: 6.3.0 + space-separated-tokens: 2.0.2 + style-to-object: 0.4.2 + unist-util-position: 4.0.4 + zwitch: 2.0.4 + transitivePeerDependencies: + - supports-color + dev: true + /hast-util-whitespace@2.0.1: resolution: {integrity: sha512-nAxA0v8+vXSBDt3AnRUNjyRIQ0rD+ntpbAp4LnPkumc5M9yUbSMa4XDU9Q6etY4f1Wp4bNgvc1yjiZtsTTrSng==} dev: true @@ -17500,6 +18246,13 @@ packages: lru-cache: 6.0.0 dev: true + /hosted-git-info@6.1.1: + resolution: {integrity: sha512-r0EI+HBMcXadMrugk0GCQ+6BQV39PiWAZVfq7oIckeGiN7sjRGyQxPdft3nQekFTCQbYxLBH+/axZMeH8UX6+w==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + dependencies: + lru-cache: 7.14.0 + dev: true + /hosted-git-info@7.0.0: resolution: {integrity: sha512-ICclEpTLhHj+zCuSb2/usoNXSVkxUSIopre+b1w8NDY9Dntp9LO4vLdHYI336TH8sAqwrRgnSfdkBG2/YpisHA==} engines: {node: ^16.14.0 || >=18.0.0} @@ -18098,6 +18851,10 @@ packages: resolution: {integrity: sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==} dev: false + /is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + dev: true + /is-alphanumerical@1.0.4: resolution: {integrity: sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==} dependencies: @@ -18105,6 +18862,13 @@ packages: is-decimal: 1.0.4 dev: false + /is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + dev: true + /is-arguments@1.1.1: resolution: {integrity: sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==} engines: {node: '>= 0.4'} @@ -18193,6 +18957,14 @@ packages: resolution: {integrity: sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==} dev: false + /is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + dev: true + + /is-deflate@1.0.0: + resolution: {integrity: sha512-YDoFpuZWu1VRXlsnlYMzKyVRITXj7Ej/V9gXQ2/pAe7X1J7M/RNOqaIYi6qUn+B7nGyB9pDXrv02dsB58d2ZAQ==} + dev: true + /is-directory@0.3.1: resolution: {integrity: sha512-yVChGzahRFvbkscn2MlwGismPO12i9+znNruC5gVEntG3qu0xQMzsGg/JFbrsqDOHtHFPci+V5aP5T9I+yeKqw==} engines: {node: '>=0.10.0'} @@ -18230,10 +19002,19 @@ packages: dependencies: is-extglob: 2.1.1 + /is-gzip@1.0.0: + resolution: {integrity: sha512-rcfALRIb1YewtnksfRIHGcIY93QnK8BIQ/2c9yDYcG/Y6+vRoJuTWBmmSEbyLLYtXm7q35pHOHbZFQBaLrhlWQ==} + engines: {node: '>=0.10.0'} + dev: true + /is-hexadecimal@1.0.4: resolution: {integrity: sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==} dev: false + /is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + dev: true + /is-installed-globally@0.4.0: resolution: {integrity: sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==} engines: {node: '>=10'} @@ -18558,6 +19339,10 @@ packages: colors: 1.1.2 dev: true + /javascript-stringify@2.1.0: + resolution: {integrity: sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==} + dev: true + /jest-changed-files@29.4.3: resolution: {integrity: sha512-Vn5cLuWuwmi2GNNbokPOEcvrXGSGrqVnPEZV7rC6P7ck07Dyw9RFnvWglnupSh+hGys0ajGtw/bc2ZgweljQoQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -18903,7 +19688,7 @@ packages: dependencies: '@babel/core': 7.23.2 '@babel/generator': 7.23.0 - '@babel/plugin-syntax-jsx': 7.21.4(@babel/core@7.23.2) + '@babel/plugin-syntax-jsx': 7.22.5(@babel/core@7.23.2) '@babel/plugin-syntax-typescript': 7.22.5(@babel/core@7.23.2) '@babel/traverse': 7.23.2 '@babel/types': 7.23.0 @@ -19139,6 +19924,12 @@ packages: engines: {node: '>=4'} hasBin: true + /jsesc@3.0.2: + resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} + engines: {node: '>=6'} + hasBin: true + dev: true + /json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} dev: true @@ -19553,6 +20344,11 @@ packages: resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} engines: {node: '>=10'} + /lilconfig@3.0.0: + resolution: {integrity: sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g==} + engines: {node: '>=14'} + dev: true + /limiter@1.1.5: resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==} dev: true @@ -19631,7 +20427,6 @@ packages: /local-pkg@0.4.3: resolution: {integrity: sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==} engines: {node: '>=14'} - dev: false /local-pkg@0.5.0: resolution: {integrity: sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==} @@ -19996,6 +20791,11 @@ packages: resolution: {integrity: sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==} dev: true + /markdown-extensions@1.1.1: + resolution: {integrity: sha512-WWC0ZuMzCyDHYCasEGs4IPvLyTGftYwh6wIEOULOF0HXcqZlhwRzrK0w2VUlxWA98xnvb/jszw4ZSkJ6ADpM6Q==} + engines: {node: '>=0.10.0'} + dev: true + /markdown-factory@0.0.6: resolution: {integrity: sha512-epJKNY4rlcMIJ+czEkPgstlk+9cKmHUkhRxemCPf+38vKbehBoiH9gmsxXkgzRYQx98hpE9l/zVkg2WI+IbT3Q==} dev: true @@ -20068,6 +20868,69 @@ packages: - supports-color dev: true + /mdast-util-frontmatter@1.0.1: + resolution: {integrity: sha512-JjA2OjxRqAa8wEG8hloD0uTU0kdn8kbtOWpPP94NBkfAlbxn4S8gCGf/9DwFtEeGPXrDcNXdiDjVaRdUFqYokw==} + dependencies: + '@types/mdast': 3.0.12 + mdast-util-to-markdown: 1.5.0 + micromark-extension-frontmatter: 1.1.1 + dev: true + + /mdast-util-mdx-expression@1.3.2: + resolution: {integrity: sha512-xIPmR5ReJDu/DHH1OoIT1HkuybIfRGYRywC+gJtI7qHjCJp/M9jrmBEJW22O8lskDWm562BX2W8TiAwRTb0rKA==} + dependencies: + '@types/estree-jsx': 1.0.3 + '@types/hast': 2.3.4 + '@types/mdast': 3.0.12 + mdast-util-from-markdown: 1.3.1 + mdast-util-to-markdown: 1.5.0 + transitivePeerDependencies: + - supports-color + dev: true + + /mdast-util-mdx-jsx@2.1.4: + resolution: {integrity: sha512-DtMn9CmVhVzZx3f+optVDF8yFgQVt7FghCRNdlIaS3X5Bnym3hZwPbg/XW86vdpKjlc1PVj26SpnLGeJBXD3JA==} + dependencies: + '@types/estree-jsx': 1.0.3 + '@types/hast': 2.3.4 + '@types/mdast': 3.0.12 + '@types/unist': 2.0.6 + ccount: 2.0.1 + mdast-util-from-markdown: 1.3.1 + mdast-util-to-markdown: 1.5.0 + parse-entities: 4.0.1 + stringify-entities: 4.0.3 + unist-util-remove-position: 4.0.2 + unist-util-stringify-position: 3.0.3 + vfile-message: 3.1.4 + transitivePeerDependencies: + - supports-color + dev: true + + /mdast-util-mdx@2.0.1: + resolution: {integrity: sha512-38w5y+r8nyKlGvNjSEqWrhG0w5PmnRA+wnBvm+ulYCct7nsGYhFVb0lljS9bQav4psDAS1eGkP2LMVcZBi/aqw==} + dependencies: + mdast-util-from-markdown: 1.3.1 + mdast-util-mdx-expression: 1.3.2 + mdast-util-mdx-jsx: 2.1.4 + mdast-util-mdxjs-esm: 1.3.1 + mdast-util-to-markdown: 1.5.0 + transitivePeerDependencies: + - supports-color + dev: true + + /mdast-util-mdxjs-esm@1.3.1: + resolution: {integrity: sha512-SXqglS0HrEvSdUEfoXFtcg7DRl7S2cwOXc7jkuusG472Mmjag34DUDeOJUZtl+BVnyeO1frIgVpHlNRWc2gk/w==} + dependencies: + '@types/estree-jsx': 1.0.3 + '@types/hast': 2.3.4 + '@types/mdast': 3.0.12 + mdast-util-from-markdown: 1.3.1 + mdast-util-to-markdown: 1.5.0 + transitivePeerDependencies: + - supports-color + dev: true + /mdast-util-phrasing@3.0.1: resolution: {integrity: sha512-WmI1gTXUBJo4/ZmSk79Wcb2HcjPJBzM1nlI/OUWA8yk2X9ik3ffNbBGsU+09BFmXaL1IBb9fiuvq6/KMiNycSg==} dependencies: @@ -20123,6 +20986,12 @@ packages: resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} dev: true + /media-query-parser@2.0.2: + resolution: {integrity: sha512-1N4qp+jE0pL5Xv4uEcwVUhIkwdUO3S/9gML90nqKA7v7FcOS5vUtatfzok9S9U1EJU8dHWlcv95WLnKmmxZI9w==} + dependencies: + '@babel/runtime': 7.23.2 + dev: true + /media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -20431,13 +21300,13 @@ packages: engines: {node: '>=16'} hasBin: true dependencies: - '@babel/code-frame': 7.22.5 + '@babel/code-frame': 7.22.13 '@babel/core': 7.23.2 - '@babel/generator': 7.22.9 - '@babel/parser': 7.22.7 + '@babel/generator': 7.23.0 + '@babel/parser': 7.23.0 '@babel/template': 7.22.5 - '@babel/traverse': 7.22.8 - '@babel/types': 7.22.5 + '@babel/traverse': 7.23.2 + '@babel/types': 7.23.0 accepts: 1.3.8 async: 3.2.4 chalk: 4.1.2 @@ -20507,6 +21376,76 @@ packages: uvu: 0.5.6 dev: true + /micromark-extension-frontmatter@1.1.1: + resolution: {integrity: sha512-m2UH9a7n3W8VAH9JO9y01APpPKmNNNs71P0RbknEmYSaZU5Ghogv38BYO94AI5Xw6OYfxZRdHZZ2nYjs/Z+SZQ==} + dependencies: + fault: 2.0.1 + micromark-util-character: 1.2.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + dev: true + + /micromark-extension-mdx-expression@1.0.8: + resolution: {integrity: sha512-zZpeQtc5wfWKdzDsHRBY003H2Smg+PUi2REhqgIhdzAa5xonhP03FcXxqFSerFiNUr5AWmHpaNPQTBVOS4lrXw==} + dependencies: + '@types/estree': 1.0.1 + micromark-factory-mdx-expression: 1.0.9 + micromark-factory-space: 1.1.0 + micromark-util-character: 1.2.0 + micromark-util-events-to-acorn: 1.2.3 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + uvu: 0.5.6 + dev: true + + /micromark-extension-mdx-jsx@1.0.5: + resolution: {integrity: sha512-gPH+9ZdmDflbu19Xkb8+gheqEDqkSpdCEubQyxuz/Hn8DOXiXvrXeikOoBA71+e8Pfi0/UYmU3wW3H58kr7akA==} + dependencies: + '@types/acorn': 4.0.6 + '@types/estree': 1.0.1 + estree-util-is-identifier-name: 2.1.0 + micromark-factory-mdx-expression: 1.0.9 + micromark-factory-space: 1.1.0 + micromark-util-character: 1.2.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + uvu: 0.5.6 + vfile-message: 3.1.4 + dev: true + + /micromark-extension-mdx-md@1.0.1: + resolution: {integrity: sha512-7MSuj2S7xjOQXAjjkbjBsHkMtb+mDGVW6uI2dBL9snOBCbZmoNgDAeZ0nSn9j3T42UE/g2xVNMn18PJxZvkBEA==} + dependencies: + micromark-util-types: 1.1.0 + dev: true + + /micromark-extension-mdxjs-esm@1.0.5: + resolution: {integrity: sha512-xNRBw4aoURcyz/S69B19WnZAkWJMxHMT5hE36GtDAyhoyn/8TuAeqjFJQlwk+MKQsUD7b3l7kFX+vlfVWgcX1w==} + dependencies: + '@types/estree': 1.0.1 + micromark-core-commonmark: 1.1.0 + micromark-util-character: 1.2.0 + micromark-util-events-to-acorn: 1.2.3 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + unist-util-position-from-estree: 1.1.2 + uvu: 0.5.6 + vfile-message: 3.1.4 + dev: true + + /micromark-extension-mdxjs@1.0.1: + resolution: {integrity: sha512-7YA7hF6i5eKOfFUzZ+0z6avRG52GpWR8DL+kN47y3f2KhxbBZMhmxe7auOeaTBrW2DenbbZTf1ea9tA2hDpC2Q==} + dependencies: + acorn: 8.10.0 + acorn-jsx: 5.3.2(acorn@8.10.0) + micromark-extension-mdx-expression: 1.0.8 + micromark-extension-mdx-jsx: 1.0.5 + micromark-extension-mdx-md: 1.0.1 + micromark-extension-mdxjs-esm: 1.0.5 + micromark-util-combine-extensions: 1.1.0 + micromark-util-types: 1.1.0 + dev: true + /micromark-factory-destination@1.1.0: resolution: {integrity: sha512-XaNDROBgx9SgSChd69pjiGKbV+nfHGDPVYFs5dOoDd7ZnMAE+Cuu91BCpsY8RT2NP9vo/B8pds2VQNCLiu0zhg==} dependencies: @@ -20524,6 +21463,19 @@ packages: uvu: 0.5.6 dev: true + /micromark-factory-mdx-expression@1.0.9: + resolution: {integrity: sha512-jGIWzSmNfdnkJq05c7b0+Wv0Kfz3NJ3N4cBjnbO4zjXIlxJr+f8lk+5ZmwFvqdAbUy2q6B5rCY//g0QAAaXDWA==} + dependencies: + '@types/estree': 1.0.1 + micromark-util-character: 1.2.0 + micromark-util-events-to-acorn: 1.2.3 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + unist-util-position-from-estree: 1.1.2 + uvu: 0.5.6 + vfile-message: 3.1.4 + dev: true + /micromark-factory-space@1.1.0: resolution: {integrity: sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==} dependencies: @@ -20596,6 +21548,19 @@ packages: resolution: {integrity: sha512-EuEzTWSTAj9PA5GOAs992GzNh2dGQO52UvAbtSOMvXTxv3Criqb6IOzJUBCmEqrrXSblJIJBbFFv6zPxpreiJw==} dev: true + /micromark-util-events-to-acorn@1.2.3: + resolution: {integrity: sha512-ij4X7Wuc4fED6UoLWkmo0xJQhsktfNh1J0m8g4PbIMPlx+ek/4YdW5mvbye8z/aZvAPUoxgXHrwVlXAPKMRp1w==} + dependencies: + '@types/acorn': 4.0.6 + '@types/estree': 1.0.1 + '@types/unist': 2.0.6 + estree-util-visit: 1.2.1 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + uvu: 0.5.6 + vfile-message: 3.1.4 + dev: true + /micromark-util-html-tag-name@1.2.0: resolution: {integrity: sha512-VTQzcuQgFUD7yYztuQFKXT49KghjtETQ+Wv/zUjGSGBioZnkA4P1XXZPT1FHeJA6RwRXSF47yvJ1tsJdoxwO+Q==} dev: true @@ -20922,6 +21887,10 @@ packages: pkg-types: 1.0.3 ufo: 1.3.1 + /modern-ahocorasick@1.0.1: + resolution: {integrity: sha512-yoe+JbhTClckZ67b2itRtistFKf8yPYelHLc7e5xAwtNAXxM6wJTUx2C7QeVSJFDzKT7bCIFyBVybPMKvmB9AA==} + dev: true + /modify-values@1.0.1: resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==} engines: {node: '>=0.10.0'} @@ -21328,6 +22297,16 @@ packages: validate-npm-package-license: 3.0.4 dev: true + /normalize-package-data@5.0.0: + resolution: {integrity: sha512-h9iPVIfrVZ9wVYQnxFgtw1ugSvGEMOlyPWWtm8BMJhnwyEL/FLbYbTY3V3PpjI/BUK67n9PEWDu6eHzu1fB15Q==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + dependencies: + hosted-git-info: 6.1.1 + is-core-module: 2.13.0 + semver: 7.5.3 + validate-npm-package-license: 3.0.4 + dev: true + /normalize-package-data@6.0.0: resolution: {integrity: sha512-UL7ELRVxYBHBgYEtZCXjxuD5vPxnmvMGq0jp/dGPKKrN7tfsBh2IY7TlJ15WWwdjRWD3RJbnsygUurTK3xkPkg==} engines: {node: ^16.14.0 || >=18.0.0} @@ -21375,6 +22354,16 @@ packages: engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} dev: true + /npm-package-arg@10.1.0: + resolution: {integrity: sha512-uFyyCEmgBfZTtrKk/5xDfHp6+MdrqGotX/VoOyEEl3mBwiEE5FlBaePanazJSVMPT7vKepcjYBY2ztg9A3yPIA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + dependencies: + hosted-git-info: 6.1.1 + proc-log: 3.0.0 + semver: 7.5.3 + validate-npm-package-name: 5.0.0 + dev: true + /npm-package-arg@11.0.1: resolution: {integrity: sha512-M7s1BD4NxdAvBKUPqqRW957Xwcl/4Zvo8Aj+ANrzvIPzGJZElrH7Z//rSaec2ORcND6FHHLnZeY8qgTpXDMFQQ==} engines: {node: ^16.14.0 || >=18.0.0} @@ -21392,6 +22381,16 @@ packages: ignore-walk: 6.0.3 dev: true + /npm-pick-manifest@8.0.2: + resolution: {integrity: sha512-1dKY+86/AIiq1tkKVD3l0WI+Gd3vkknVGAggsFeBkTvbhMQ1OND/LKkYv4JtXPKUJ8bOTCyLiqEg2P6QNdK+Gg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + dependencies: + npm-install-checks: 6.0.0 + npm-normalize-package-bin: 3.0.0 + npm-package-arg: 10.1.0 + semver: 7.5.3 + dev: true + /npm-pick-manifest@9.0.0: resolution: {integrity: sha512-VfvRSs/b6n9ol4Qb+bDwNGUXutpy76x6MARw/XssevE0TnctIKcmklJZM5Z7nqs5z5aW+0S63pgCNbpkUNNXBg==} engines: {node: ^16.14.0 || >=18.0.0} @@ -21896,6 +22895,10 @@ packages: resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==} dev: true + /outdent@0.8.0: + resolution: {integrity: sha512-KiOAIsdpUTcAXuykya5fnVVT+/5uS0Q1mrkRHcF89tpieSmY33O/tmc54CqwA+bfhbtEfZUNLHaPUiB9X3jt1A==} + dev: true + /p-cancelable@2.1.1: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} @@ -22029,6 +23032,10 @@ packages: - supports-color dev: true + /pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + dev: true + /pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} dev: true @@ -22058,6 +23065,19 @@ packages: is-hexadecimal: 1.0.4 dev: false + /parse-entities@4.0.1: + resolution: {integrity: sha512-SWzvYcSJh4d/SGLIOQfZ/CoNv6BTlI6YEQ7Nj82oDVnRpwe/Z/F1EMx42x3JAOwGBlCjeCH0BRJQbQ/opHL17w==} + dependencies: + '@types/unist': 2.0.6 + character-entities: 2.0.2 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.0.2 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + dev: true + /parse-json@4.0.0: resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} engines: {node: '>=4'} @@ -22088,6 +23108,10 @@ packages: engines: {node: '>=6'} dev: true + /parse-multipart-data@1.5.0: + resolution: {integrity: sha512-ck5zaMF0ydjGfejNMnlo5YU2oJ+pT+80Jb1y4ybanT27j+zbVP/jkYmCrUGsEln0Ox/hZmuvgy8Ra7AxbXP2Mw==} + dev: true + /parse-node-version@1.0.1: resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==} engines: {node: '>= 0.10'} @@ -22212,6 +23236,14 @@ packages: engines: {node: '>=14.16'} dev: true + /peek-stream@1.1.3: + resolution: {integrity: sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==} + dependencies: + buffer-from: 1.1.2 + duplexify: 3.7.1 + through2: 2.0.5 + dev: true + /pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} dev: true @@ -22244,6 +23276,12 @@ packages: engines: {node: '>=10'} dev: true + /pidtree@0.6.0: + resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} + engines: {node: '>=0.10'} + hasBin: true + dev: true + /pify@2.3.0: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} @@ -22730,6 +23768,24 @@ packages: ts-node: 10.9.1(@swc/core@1.3.86)(@types/node@18.16.9)(typescript@5.2.2) yaml: 1.10.2 + /postcss-load-config@4.0.2(postcss@8.4.19)(ts-node@10.9.1): + resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} + engines: {node: '>= 14'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + dependencies: + lilconfig: 3.0.0 + postcss: 8.4.19 + ts-node: 10.9.1(@swc/core@1.3.86)(@types/node@18.16.9)(typescript@5.2.2) + yaml: 2.3.4 + dev: true + /postcss-loader@6.2.1(postcss@8.4.19)(webpack@5.88.0): resolution: {integrity: sha512-WbbYpmAaKcux/P66bZ40bpWsBucjx/TTgVVzRZ9yUO8yQfVBlameJ0ZGVaPfH64hNSBh63a+ICP5nqOpBA0w+Q==} engines: {node: '>= 12.13.0'} @@ -23010,6 +24066,22 @@ packages: string-hash: 1.1.3 dev: true + /postcss-modules@6.0.0(postcss@8.4.19): + resolution: {integrity: sha512-7DGfnlyi/ju82BRzTIjWS5C4Tafmzl3R79YP/PASiocj+aa6yYphHhhKUOEoXQToId5rgyFgJ88+ccOUydjBXQ==} + peerDependencies: + postcss: ^8.0.0 + dependencies: + generic-names: 4.0.0 + icss-utils: 5.1.0(postcss@8.4.19) + lodash.camelcase: 4.3.0 + postcss: 8.4.19 + postcss-modules-extract-imports: 3.0.0(postcss@8.4.19) + postcss-modules-local-by-default: 4.0.3(postcss@8.4.19) + postcss-modules-scope: 3.0.0(postcss@8.4.19) + postcss-modules-values: 4.0.0(postcss@8.4.19) + string-hash: 1.1.3 + dev: true + /postcss-nested@6.0.0(postcss@8.4.19): resolution: {integrity: sha512-0DkamqrPcmkBDsLn+vQDIrtkSbNkv5AD/M322ySo9kqFkCIYklym2xEmWkwo+Y3/qZo34tzEPNUw4y7yMCdv5w==} engines: {node: '>=12.0'} @@ -23718,6 +24790,13 @@ packages: resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} dev: true + /pump@2.0.1: + resolution: {integrity: sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==} + dependencies: + end-of-stream: 1.4.4 + once: 1.4.0 + dev: true + /pump@3.0.0: resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==} dependencies: @@ -23725,6 +24804,14 @@ packages: once: 1.4.0 dev: true + /pumpify@1.5.1: + resolution: {integrity: sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==} + dependencies: + duplexify: 3.7.1 + inherits: 2.0.4 + pump: 2.0.1 + dev: true + /punycode@1.4.1: resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==} dev: true @@ -24008,6 +25095,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /react-refresh@0.14.0: + resolution: {integrity: sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==} + engines: {node: '>=0.10.0'} + dev: true + /react-refresh@0.4.3: resolution: {integrity: sha512-Hwln1VNuGl/6bVwnd0Xdn1e84gT/8T9aYNL+HAKDArLCS7LWjwr7StE30IEYbIkx0Vi3vs+coQxe+SQDbGbbpA==} engines: {node: '>=0.10.0'} @@ -24373,6 +25465,34 @@ packages: unist-util-visit: 2.0.3 dev: true + /remark-frontmatter@4.0.1: + resolution: {integrity: sha512-38fJrB0KnmD3E33a5jZC/5+gGAC2WKNiPw1/fdXJvijBlhA7RCsvJklrYJakS0HedninvaCYW8lQGf9C918GfA==} + dependencies: + '@types/mdast': 3.0.12 + mdast-util-frontmatter: 1.0.1 + micromark-extension-frontmatter: 1.1.1 + unified: 10.1.2 + dev: true + + /remark-mdx-frontmatter@1.1.1: + resolution: {integrity: sha512-7teX9DW4tI2WZkXS4DBxneYSY7NHiXl4AKdWDO9LXVweULlCT8OPWsOjLEnMIXViN1j+QcY8mfbq3k0EK6x3uA==} + engines: {node: '>=12.2.0'} + dependencies: + estree-util-is-identifier-name: 1.1.0 + estree-util-value-to-estree: 1.3.0 + js-yaml: 4.1.0 + toml: 3.0.0 + dev: true + + /remark-mdx@2.3.0: + resolution: {integrity: sha512-g53hMkpM0I98MU266IzDFMrTD980gNF3BJnkyFcmN+dD873mQeD5rdMO3Y2X+x8umQfbSE0PcoEDl7ledSA+2g==} + dependencies: + mdast-util-mdx: 2.0.1 + micromark-extension-mdxjs: 1.0.1 + transitivePeerDependencies: + - supports-color + dev: true + /remark-parse@10.0.2: resolution: {integrity: sha512-3ydxgHa/ZQzG8LvC7jTXccARYDcRld3VfcgIIFs7bI6vbRSxJJmzgLEIIoYKyrfhaY+ujuWaf/PJiMZXoiCXgw==} dependencies: @@ -24462,6 +25582,10 @@ packages: engines: {node: '>=0.10.0'} dev: true + /require-like@0.1.2: + resolution: {integrity: sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==} + dev: true + /requireindex@1.2.0: resolution: {integrity: sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==} engines: {node: '>=0.10.5'} @@ -24522,6 +25646,11 @@ packages: engines: {node: '>=10'} dev: true + /resolve.exports@2.0.2: + resolution: {integrity: sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==} + engines: {node: '>=10'} + dev: true + /resolve@1.22.1: resolution: {integrity: sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==} hasBin: true @@ -25104,6 +26233,10 @@ packages: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} dev: true + /set-cookie-parser@2.6.0: + resolution: {integrity: sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==} + dev: true + /setimmediate-napi@1.0.6: resolution: {integrity: sha512-sdNXN15Av1jPXuSal4Mk4tEAKn0+8lfF9Z50/negaQMrAIO9c1qM0eiCh8fT6gctp0RiCObk+6/Xfn5RMGdZoA==} dependencies: @@ -25667,6 +26800,14 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: true + /stream-shift@1.0.1: + resolution: {integrity: sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==} + dev: true + + /stream-slice@0.1.2: + resolution: {integrity: sha512-QzQxpoacatkreL6jsxnVb7X5R/pGw9OUv2qWTYWnmLpg4NdN31snPy/f3TdQE1ZUXaThRvj1Zw4/OGg0ZkaLMA==} + dev: true + /stream-throttle@0.1.3: resolution: {integrity: sha512-889+B9vN9dq7/vLbGyuHeZ6/ctf5sNuGWsDy89uNxkFTAgzy0eK7+w5fL3KLNRTkLle7EgZGvHUphZW0Q26MnQ==} engines: {node: '>= 0.10.0'} @@ -25750,6 +26891,13 @@ packages: safe-buffer: 5.2.1 dev: true + /stringify-entities@4.0.3: + resolution: {integrity: sha512-BP9nNHMhhfcMbiuQKCqMjhDP5yBCAxsPu4pHFFzJ6Alo9dZgY4VLDPutXqIjpRiMoKdp7Av85Gr73Q5uH9k7+g==} + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + dev: true + /strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -26485,6 +27633,10 @@ packages: ieee754: 1.2.1 dev: true + /toml@3.0.0: + resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} + dev: true + /tough-cookie@2.4.3: resolution: {integrity: sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==} engines: {node: '>=0.8'} @@ -27038,12 +28190,25 @@ packages: '@types/unist': 2.0.6 dev: true + /unist-util-position-from-estree@1.1.2: + resolution: {integrity: sha512-poZa0eXpS+/XpoQwGwl79UUdea4ol2ZuCYguVaJS4qzIOMDzbqz8a3erUCOmubSZkaOuGamb3tX790iwOIROww==} + dependencies: + '@types/unist': 2.0.6 + dev: true + /unist-util-position@4.0.4: resolution: {integrity: sha512-kUBE91efOWfIVBo8xzh/uZQ7p9ffYRtUbMRZBNFYwf0RK8koUMx6dGUfwylLOKmaT2cs4wSW96QoYUSXAyEtpg==} dependencies: '@types/unist': 2.0.6 dev: true + /unist-util-remove-position@4.0.2: + resolution: {integrity: sha512-TkBb0HABNmxzAcfLf4qsIbFbaPDvMO6wa3b3j4VcEzFVaw1LBKwnW4/sRJ/atSLSzoIg41JWEdnE7N6DIhGDGQ==} + dependencies: + '@types/unist': 2.0.6 + unist-util-visit: 4.1.2 + dev: true + /unist-util-stringify-position@3.0.3: resolution: {integrity: sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==} dependencies: @@ -27544,6 +28709,30 @@ packages: vfile-message: 3.1.4 dev: true + /vite-node@0.28.5(@types/node@18.16.9)(less@4.1.3)(sass@1.55.0)(stylus@0.59.0): + resolution: {integrity: sha512-LmXb9saMGlrMZbXTvOveJKwMTBTNUH66c8rJnQ0ZPNX+myPEol64+szRzXtV5ORb0Hb/91yq+/D3oERoyAt6LA==} + engines: {node: '>=v14.16.0'} + hasBin: true + dependencies: + cac: 6.7.14 + debug: 4.3.4(supports-color@5.5.0) + mlly: 1.4.2 + pathe: 1.1.1 + picocolors: 1.0.0 + source-map: 0.6.1 + source-map-support: 0.5.21 + vite: 4.5.0(@types/node@18.16.9)(less@4.2.0)(sass@1.69.5)(stylus@0.59.0)(terser@5.24.0) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - stylus + - sugarss + - supports-color + - terser + dev: true + /vite-node@0.34.6(@types/node@18.16.9)(less@4.1.3)(sass@1.55.0)(stylus@0.59.0): resolution: {integrity: sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA==} engines: {node: '>=v14.18.0'} @@ -27787,6 +28976,19 @@ packages: setimmediate-napi: 1.0.6 dev: false + /web-encoding@1.1.5: + resolution: {integrity: sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==} + dependencies: + util: 0.12.5 + optionalDependencies: + '@zxing/text-encoding': 0.9.0 + dev: true + + /web-streams-polyfill@3.2.1: + resolution: {integrity: sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==} + engines: {node: '>= 8'} + dev: true + /web-streams-polyfill@4.0.0-beta.3: resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} engines: {node: '>= 14'} @@ -28323,6 +29525,14 @@ packages: isexe: 2.0.0 dev: true + /which@3.0.1: + resolution: {integrity: sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hasBin: true + dependencies: + isexe: 2.0.0 + dev: true + /which@4.0.0: resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} engines: {node: ^16.13.0 || >=18.0.0} @@ -28512,6 +29722,11 @@ packages: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} + /yaml@2.3.4: + resolution: {integrity: sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==} + engines: {node: '>= 14'} + dev: true + /yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} diff --git a/scripts/commitizen.js b/scripts/commitizen.js index 6c542ea3aa..0d2fec9496 100644 --- a/scripts/commitizen.js +++ b/scripts/commitizen.js @@ -19,6 +19,7 @@ const scopes = [ { value: 'nx-dev', name: 'nx-dev: anything related to docs infrastructure' }, { value: 'react', name: 'react: anything React specific' }, { value: 'react-native', name: 'react-native: anything React Native specific' }, + { value: 'remix', name: 'remix: anything Remix specific' }, { value: 'expo', name: 'expo: anything Expo specific' }, { value: 'release', name: 'release: anything related to nx release' }, { value: 'repo', name: 'repo: anything related to managing the repo itself' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 18f8bf5b8d..cdd99a81f1 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -97,6 +97,8 @@ "@nx/react-native": ["packages/react-native"], "@nx/react-native/*": ["packages/react-native/*"], "@nx/react/*": ["packages/react/*"], + "@nx/remix": ["packages/remix"], + "@nx/remix/*": ["packages/remix/*"], "@nx/rollup": ["packages/rollup"], "@nx/rollup/*": ["packages/rollup/*"], "@nx/storybook": ["packages/storybook"],