feat(nextjs): Add convert-to-inferred generator (#26706)
This PR enables the ability to migrate project(s) from using nextjs executors to inferred targets. --------- Co-authored-by: Jack Hsu <jack.hsu@gmail.com>
This commit is contained in:
parent
7f8bb4ba1f
commit
4197a91845
@ -8216,6 +8216,14 @@
|
||||
"children": [],
|
||||
"isExternal": false,
|
||||
"disableCollapsible": false
|
||||
},
|
||||
{
|
||||
"id": "convert-to-inferred",
|
||||
"path": "/nx-api/next/generators/convert-to-inferred",
|
||||
"name": "convert-to-inferred",
|
||||
"children": [],
|
||||
"isExternal": false,
|
||||
"disableCollapsible": false
|
||||
}
|
||||
],
|
||||
"isExternal": false,
|
||||
|
||||
@ -1576,6 +1576,15 @@
|
||||
"originalFilePath": "/packages/next/src/generators/cypress-component-configuration/schema.json",
|
||||
"path": "/nx-api/next/generators/cypress-component-configuration",
|
||||
"type": "generator"
|
||||
},
|
||||
"/nx-api/next/generators/convert-to-inferred": {
|
||||
"description": "Convert an existing Next.js project(s) using `@nx/next:build` to use `@nx/next/plugin`. Defaults to migrating all projects. Pass '--project' to migrate a single project.",
|
||||
"file": "generated/packages/next/generators/convert-to-inferred.json",
|
||||
"hidden": false,
|
||||
"name": "convert-to-inferred",
|
||||
"originalFilePath": "/packages/next/src/generators/convert-to-inferred/schema.json",
|
||||
"path": "/nx-api/next/generators/convert-to-inferred",
|
||||
"type": "generator"
|
||||
}
|
||||
},
|
||||
"path": "/nx-api/next"
|
||||
|
||||
@ -1556,6 +1556,15 @@
|
||||
"originalFilePath": "/packages/next/src/generators/cypress-component-configuration/schema.json",
|
||||
"path": "next/generators/cypress-component-configuration",
|
||||
"type": "generator"
|
||||
},
|
||||
{
|
||||
"description": "Convert an existing Next.js project(s) using `@nx/next:build` to use `@nx/next/plugin`. Defaults to migrating all projects. Pass '--project' to migrate a single project.",
|
||||
"file": "generated/packages/next/generators/convert-to-inferred.json",
|
||||
"hidden": false,
|
||||
"name": "convert-to-inferred",
|
||||
"originalFilePath": "/packages/next/src/generators/convert-to-inferred/schema.json",
|
||||
"path": "next/generators/convert-to-inferred",
|
||||
"type": "generator"
|
||||
}
|
||||
],
|
||||
"githubRoot": "https://github.com/nrwl/nx/blob/master",
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "convert-to-inferred",
|
||||
"factory": "./src/generators/convert-to-inferred/convert-to-inferred",
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/schema",
|
||||
"$id": "NxNextjsConvertToInferred",
|
||||
"description": "Convert existing Next.js project(s) using `@nx/next:build` executor to use `@nx/next/plugin`.",
|
||||
"title": "Convert a Nextjs project from executor to plugin",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project": {
|
||||
"type": "string",
|
||||
"description": "The project to convert from using the `@nx/next:build` executor to use `@nx/next/plugin`. If not provided, all projects using the `@nx/next:build` executor will be converted.",
|
||||
"x-priority": "important"
|
||||
},
|
||||
"skipFormat": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to format files.",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"presets": []
|
||||
},
|
||||
"description": "Convert an existing Next.js project(s) using `@nx/next:build` to use `@nx/next/plugin`. Defaults to migrating all projects. Pass '--project' to migrate a single project.",
|
||||
"implementation": "/packages/next/src/generators/convert-to-inferred/convert-to-inferred.ts",
|
||||
"aliases": [],
|
||||
"hidden": false,
|
||||
"path": "/packages/next/src/generators/convert-to-inferred/schema.json",
|
||||
"type": "generator"
|
||||
}
|
||||
@ -1,6 +1,10 @@
|
||||
# Troubleshoot Convert to Inferred Migration
|
||||
|
||||
Nx comes with plugins that automatically [infer tasks](/concepts/inferred-tasks) (i.e. Project Crystal) for your projects based on the configuration of different tools. Inference plugins come with many benefits, such as reduced boilerplate and access to features such as [task splitting](/ci/features/split-e2e-tasks). To make the transition easier for existing projects that are not yet using inference plugins, many plugins provide the `convert-to-inferred` generator that will switch from executor-based tasks to inferred tasks.
|
||||
Nx comes with plugins that automatically [infer tasks](/concepts/inferred-tasks) (i.e. Project Crystal) for your
|
||||
projects based on the configuration of different tools. Inference plugins come with many benefits, such as reduced
|
||||
boilerplate and access to features such as [task splitting](/ci/features/split-e2e-tasks). To make the transition easier
|
||||
for existing projects that are not yet using inference plugins, many plugins provide the `convert-to-inferred` generator
|
||||
that will switch from executor-based tasks to inferred tasks.
|
||||
|
||||
To see a list of the available migration generators, run:
|
||||
|
||||
@ -10,19 +14,69 @@ nx g convert-to-inferred
|
||||
|
||||
This will prompt you to choose a plugin to run the migration for.
|
||||
|
||||
Although the `convert-to-inferred` generator should work for most projects, there are situations that require additional changes to be done by hand. If you run into issues that are not covered on this page, please open an issue on [GitHub](https://github.com/nrwl/nx/issues).
|
||||
Although the `convert-to-inferred` generator should work for most projects, there are situations that require additional
|
||||
changes to be done by hand. If you run into issues that are not covered on this page, please open an issue
|
||||
on [GitHub](https://github.com/nrwl/nx/issues).
|
||||
|
||||
## Error: The nx plugin did not find a project inside...
|
||||
|
||||
This error occurs when a configuration file matching the tooling cannot be found. For example, Vite works with `vite.config.ts` (or `.js`, `.cts`, `.mts`, etc.). If you've named your configuration file to something unconventional, you must rename it back to the standard naming convention before running the migration generator again.
|
||||
This error occurs when a configuration file matching the tooling cannot be found. For example, Vite works
|
||||
with `vite.config.ts` (or `.js`, `.cts`, `.mts`, etc.). If you've named your configuration file to something
|
||||
unconventional, you must rename it back to the standard naming convention before running the migration generator again.
|
||||
|
||||
For example, if you have a `apps/demo/vite.custom.ts` file and are running `nx g @nx/vite:convert-to-inferred`, you must first rename the file to `apps/demo/vite.config.ts` before running the generator.
|
||||
For example, if you have a `apps/demo/vite.custom.ts` file and are running `nx g @nx/vite:convert-to-inferred`, you must
|
||||
first rename the file to `apps/demo/vite.config.ts` before running the generator.
|
||||
|
||||
## Remix: Unsupported `outputPath` Option
|
||||
## Next.js: Unable to Migrate `outputPath`, `generateLockfile` and `includeDevDependenciesInPackageJson` Options
|
||||
|
||||
The [`outputPath`](/nx-api/remix/executors/build#outputpath) option from `@nx/remix:build` is ignored because it often leads to ESM errors when the output path is outside the project root. The ESM error occurs because the root `package.json` may not have `"type": "module"` set, which means that the compiled ESM code will fail to run. To guarantee that `serve` works, we migrate the outputs to the Remix defaults (`build` and `public/build` inside the project root). If you have custom directories already defined in your Remix config, it will continue to be used.
|
||||
The [`outputPath`](/nx-api/remix/executors/build#outputpath) option from `@nx/next:build` is ignored because it
|
||||
conflicts with Next.js' requirement that [`distDir`](https://nextjs.org/docs/app/api-reference/next-config-js/distDir)
|
||||
remain inside the project directory. Previously, the `@nx/next:build` executor performed workarounds to bring it outside
|
||||
the project root, but those workarounds lead to other issues, such as Turbopack not working.
|
||||
|
||||
To change the outputs after the migration, edit the remix config file, and look for `serverBuildPath` and `assetsBuildDirectory` and set it to the locations you want.
|
||||
To customize the output directory, set `distDir` in your Next.js config file.
|
||||
|
||||
```js
|
||||
const configuration = process.env.NX_TASK_TARGET_CONFIGURATION || 'default';
|
||||
// ...
|
||||
const nextConfig = {
|
||||
nx: {
|
||||
...options,
|
||||
},
|
||||
// Differentiate production and development builds. You can also use the `configuration` variable that will match the `--configuration` option passed to Nx.
|
||||
distDir: process.env.NODE_ENV === 'production' ? 'dist' : 'dist-dev',
|
||||
};
|
||||
const plugins = [withNx];
|
||||
module.exports = composePlugins(...plugins)(nextConfig);
|
||||
```
|
||||
|
||||
Since the output directory is now inside the project, we do not generate `package.json` since it is already present. The
|
||||
lockfile generation support also no longer exists, which does not affect deployments to Vercel, Netlify, or similar
|
||||
environments. However, it could affect deployments via Docker images where you do not copy the whole monorepo, but
|
||||
rather just the build artifacts.
|
||||
|
||||
These removals are necessary to align with Next.js recommendations.
|
||||
|
||||
## Next.js: Nx `serve` Only Starts Dev Server
|
||||
|
||||
To better align with Next.js CLI, projects after the migration have two targets to start the server:
|
||||
|
||||
1. `serve` - Starts the dev server (same as `next dev`)
|
||||
2. `start` - Starts the prod server (same as `next start`)
|
||||
|
||||
Note that `serve` could be different depending on what you used for `@nx/next:server` previously. After the
|
||||
migration, `nx run <proj>:serve --prod` not longer starts the prod server. Use `nx run <proj>:start` instead.
|
||||
|
||||
## Remix: Unable to Migrate `outputPath` Option
|
||||
|
||||
The [`outputPath`](/nx-api/remix/executors/build#outputpath) option from `@nx/remix:build` is ignored because it often
|
||||
leads to ESM errors when the output path is outside the project root. The ESM error occurs because the
|
||||
root `package.json` may not have `"type": "module"` set, which means that the compiled ESM code will fail to run. To
|
||||
guarantee that `serve` works, we migrate the outputs to the Remix defaults (`build` and `public/build` inside the
|
||||
project root). If you have custom directories already defined in your Remix config, it will continue to be used.
|
||||
|
||||
To change the outputs after the migration, edit the remix config file, and look for `serverBuildPath`
|
||||
and `assetsBuildDirectory` and set it to the locations you want.
|
||||
|
||||
```ts
|
||||
// ...
|
||||
@ -33,17 +87,24 @@ export default {
|
||||
};
|
||||
```
|
||||
|
||||
Note that you will need to address potential ESM issues that may arise. For example, change the root `package.json` to `"type": "module"`.
|
||||
Note that you will need to address potential ESM issues that may arise. For example, change the root `package.json`
|
||||
to `"type": "module"`.
|
||||
|
||||
## Remix: Unsupported `generatePackageJson` and `generateLockFile` Options
|
||||
|
||||
The `generatePackageJson` and `generateLockFile` options in [`@nx/remix:build`](/nx-api/remix/executors/build) cannot currently be migrated. There is support for this feature in the [Nx Vite plugin](/recipes/vite/configure-vite#typescript-paths), so in the future we may be able to support it if using Remix+Vite.
|
||||
The `generatePackageJson` and `generateLockFile` options in [`@nx/remix:build`](/nx-api/remix/executors/build) cannot
|
||||
currently be migrated. There is support for this feature in
|
||||
the [Nx Vite plugin](/recipes/vite/configure-vite#typescript-paths), so in the future we may be able to support it if
|
||||
using Remix+Vite.
|
||||
|
||||
## Storybook: Conflicting `staticDir` Options
|
||||
|
||||
Using `staticDir` for both `@nx/storybook:build-storybook` and `@nx/storybook:storybook` executor options will result in the one from `build-storybook` being used in the resulting `.storybook/main.ts` file. It is not possible for us to support both automatically.
|
||||
Using `staticDir` for both `@nx/storybook:build-storybook` and `@nx/storybook:storybook` executor options will result in
|
||||
the one from `build-storybook` being used in the resulting `.storybook/main.ts` file. It is not possible for us to
|
||||
support both automatically.
|
||||
|
||||
If you need to differentiate `staticDir` between build and serve, then consider putting logic into your `main.ts` file directly.
|
||||
If you need to differentiate `staticDir` between build and serve, then consider putting logic into your `main.ts` file
|
||||
directly.
|
||||
|
||||
```ts
|
||||
// ...
|
||||
@ -60,7 +121,8 @@ export default config;
|
||||
|
||||
## Vite: Unsupported `proxyConfig` Option
|
||||
|
||||
Projects that used the [`proxyConfig`](/nx-api/vite/executors/dev-server#proxyconfig) option of `@nx/vite:dev-server` will need to inline the proxy configuration from the original file into `vite.config.ts`.
|
||||
Projects that used the [`proxyConfig`](/nx-api/vite/executors/dev-server#proxyconfig) option of `@nx/vite:dev-server`
|
||||
will need to inline the proxy configuration from the original file into `vite.config.ts`.
|
||||
|
||||
For example, if you previously used this in `proxy.config.json`:
|
||||
|
||||
@ -90,13 +152,20 @@ export default defineConfig({
|
||||
|
||||
## Webpack: Project Cannot Be Migrated
|
||||
|
||||
Projects that use [Nx-enhanced Webpack configuration](/recipes/webpack/webpack-config-setup#nxenhanced-configuration-with-composable-plugins) files cannot be migrated to use Webpack CLI. Nx-enhanced configuration files that contain `composePlugins` and `withNx` require the `@nx/webpack:webpack` executor to work.
|
||||
Projects that
|
||||
use [Nx-enhanced Webpack configuration](/recipes/webpack/webpack-config-setup#nxenhanced-configuration-with-composable-plugins)
|
||||
files cannot be migrated to use Webpack CLI. Nx-enhanced configuration files that contain `composePlugins` and `withNx`
|
||||
require the `@nx/webpack:webpack` executor to work.
|
||||
|
||||
To solve this issue, run `nx g @nx/webpack:convert-config-to-webpack-plugin` first, and then try again.
|
||||
|
||||
## Webpack: Usage of `useLegacyNxPlugin`
|
||||
|
||||
When converting from Nx-enhanced to basic Webpack configuration, we add the `useLegacyNxPlugin` utility to ensure that the functionality of your existing configuration continues to function normally. We recommend that you refactor the configuration such that `useLegacyNxPlugin` is not needed.
|
||||
When converting
|
||||
from [Nx-enhanced](/recipes/webpack/webpack-config-setup#nxenhanced-configuration-with-composable-plugins) to basic
|
||||
Webpack configuration, we add the `useLegacyNxPlugin` utility function to
|
||||
ensure that your build tasks behave the same after the migration. We recommend that you refactor the configuration such
|
||||
that `useLegacyNxPlugin` is not needed.
|
||||
|
||||
For example, if you previously added plugins using the configuration function.
|
||||
|
||||
@ -117,7 +186,8 @@ module.exports = async () => ({
|
||||
});
|
||||
```
|
||||
|
||||
If you need to apply configuration changes after `NxAppWebpackPlugin` is applied, then you can create a plugin object as follows.
|
||||
If you need to apply configuration changes after `NxAppWebpackPlugin` is applied, then you can create a plugin object as
|
||||
follows.
|
||||
|
||||
```js
|
||||
module.exports = async () => ({
|
||||
|
||||
@ -508,6 +508,7 @@
|
||||
- [library](/nx-api/next/generators/library)
|
||||
- [custom-server](/nx-api/next/generators/custom-server)
|
||||
- [cypress-component-configuration](/nx-api/next/generators/cypress-component-configuration)
|
||||
- [convert-to-inferred](/nx-api/next/generators/convert-to-inferred)
|
||||
- [node](/nx-api/node)
|
||||
- [documents](/nx-api/node/documents)
|
||||
- [Overview](/nx-api/node/documents/overview)
|
||||
|
||||
@ -28,12 +28,15 @@ import type { ConfigurationResult } from 'nx/src/project-graph/utils/project-con
|
||||
import { forEachExecutorOptions } from '../executor-options-utils';
|
||||
import { deleteMatchingProperties } from './plugin-migration-utils';
|
||||
|
||||
export type InferredTargetConfiguration = TargetConfiguration & {
|
||||
name: string;
|
||||
};
|
||||
type PluginOptionsBuilder<T> = (targetName: string) => T;
|
||||
type PostTargetTransformer = (
|
||||
targetConfiguration: TargetConfiguration,
|
||||
tree: Tree,
|
||||
projectDetails: { projectName: string; root: string },
|
||||
inferredTargetConfiguration: TargetConfiguration
|
||||
inferredTargetConfiguration: InferredTargetConfiguration
|
||||
) => TargetConfiguration | Promise<TargetConfiguration>;
|
||||
type SkipTargetFilter = (
|
||||
targetConfiguration: TargetConfiguration
|
||||
@ -154,7 +157,7 @@ class ExecutorToPluginMigrator<T> {
|
||||
projectTarget,
|
||||
this.tree,
|
||||
{ projectName, root: projectFromGraph.data.root },
|
||||
createdTarget
|
||||
{ ...createdTarget, name: targetName }
|
||||
);
|
||||
|
||||
if (
|
||||
|
||||
@ -42,6 +42,11 @@
|
||||
"factory": "./src/generators/cypress-component-configuration/cypress-component-configuration#cypressComponentConfigurationInternal",
|
||||
"schema": "./src/generators/cypress-component-configuration/schema.json",
|
||||
"description": "cypress-component-configuration generator"
|
||||
},
|
||||
"convert-to-inferred": {
|
||||
"factory": "./src/generators/convert-to-inferred/convert-to-inferred",
|
||||
"schema": "./src/generators/convert-to-inferred/schema.json",
|
||||
"description": "Convert an existing Next.js project(s) using `@nx/next:build` to use `@nx/next/plugin`. Defaults to migrating all projects. Pass '--project' to migrate a single project."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,7 +51,8 @@
|
||||
"@nx/react": "file:../react",
|
||||
"@nx/web": "file:../web",
|
||||
"@nx/webpack": "file:../webpack",
|
||||
"@nx/workspace": "file:../workspace"
|
||||
"@nx/workspace": "file:../workspace",
|
||||
"@phenomnomnominal/tsquery": "~5.0.1"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
type ProjectGraphProjectNode,
|
||||
type Target,
|
||||
} from '@nx/devkit';
|
||||
import type { AssetGlobPattern } from '@nx/webpack';
|
||||
|
||||
export interface SvgrOptions {
|
||||
svgo?: boolean;
|
||||
@ -22,6 +23,8 @@ export interface WithNxOptions extends NextConfig {
|
||||
nx?: {
|
||||
svgr?: boolean | SvgrOptions;
|
||||
babelUpwardRootMode?: boolean;
|
||||
fileReplacements?: { replace: string; with: string }[];
|
||||
assets?: AssetGlobPattern[];
|
||||
};
|
||||
}
|
||||
|
||||
@ -210,12 +213,15 @@ function withNx(
|
||||
const userWebpackConfig = nextConfig.webpack;
|
||||
|
||||
const { createWebpackConfig } = require('@nx/next/src/utils/config');
|
||||
// If we have file replacements or assets, inside of the next config we pass the workspaceRoot as a join of the workspaceRoot and the projectDirectory
|
||||
// Because the file replacements and assets are relative to the projectRoot, not the workspaceRoot
|
||||
nextConfig.webpack = (a, b) =>
|
||||
createWebpackConfig(
|
||||
workspaceRoot,
|
||||
projectDirectory,
|
||||
options.fileReplacements,
|
||||
options.assets
|
||||
_nextConfig.nx?.fileReplacements
|
||||
? joinPathFragments(workspaceRoot, projectDirectory)
|
||||
: workspaceRoot,
|
||||
_nextConfig.nx?.assets || options.assets,
|
||||
_nextConfig.nx?.fileReplacements || options.fileReplacements
|
||||
)(userWebpackConfig ? userWebpackConfig(a, b) : a, b);
|
||||
|
||||
return nextConfig;
|
||||
|
||||
@ -0,0 +1,400 @@
|
||||
import {
|
||||
addProjectConfiguration,
|
||||
type ExpandedPluginConfiguration,
|
||||
joinPathFragments,
|
||||
type ProjectConfiguration,
|
||||
type ProjectGraph,
|
||||
readNxJson,
|
||||
readProjectConfiguration,
|
||||
type Tree,
|
||||
writeJson,
|
||||
} from '@nx/devkit';
|
||||
import { TempFs } from '@nx/devkit/internal-testing-utils';
|
||||
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
|
||||
import { join } from 'node:path';
|
||||
import { getRelativeProjectJsonSchemaPath } from 'nx/src/generators/utils/project-configuration';
|
||||
import { convertToInferred } from './convert-to-inferred';
|
||||
|
||||
let fs: TempFs;
|
||||
let projectGraph: ProjectGraph;
|
||||
jest.mock('@nx/devkit', () => ({
|
||||
...jest.requireActual('@nx/devkit'),
|
||||
createProjectGraphAsync: jest
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve(projectGraph)),
|
||||
updateProjectConfiguration: jest
|
||||
.fn()
|
||||
.mockImplementation((tree, projectName, projectConfiguration) => {
|
||||
function handleEmptyTargets(
|
||||
projectName: string,
|
||||
projectConfiguration: ProjectConfiguration
|
||||
): void {
|
||||
if (
|
||||
projectConfiguration.targets &&
|
||||
!Object.keys(projectConfiguration.targets).length
|
||||
) {
|
||||
// Re-order `targets` to appear after the `// target` comment.
|
||||
delete projectConfiguration.targets;
|
||||
projectConfiguration[
|
||||
'// targets'
|
||||
] = `to see all targets run: nx show project ${projectName} --web`;
|
||||
projectConfiguration.targets = {};
|
||||
} else {
|
||||
delete projectConfiguration['// targets'];
|
||||
}
|
||||
}
|
||||
|
||||
const projectConfigFile = joinPathFragments(
|
||||
projectConfiguration.root,
|
||||
'project.json'
|
||||
);
|
||||
|
||||
if (!tree.exists(projectConfigFile)) {
|
||||
throw new Error(
|
||||
`Cannot update Project ${projectName} at ${projectConfiguration.root}. It either doesn't exist yet, or may not use project.json for configuration. Use \`addProjectConfiguration()\` instead if you want to create a new project.`
|
||||
);
|
||||
}
|
||||
handleEmptyTargets(projectName, projectConfiguration);
|
||||
writeJson(tree, projectConfigFile, {
|
||||
name: projectConfiguration.name ?? projectName,
|
||||
$schema: getRelativeProjectJsonSchemaPath(tree, projectConfiguration),
|
||||
...projectConfiguration,
|
||||
root: undefined,
|
||||
});
|
||||
projectGraph.nodes[projectName].data = projectConfiguration;
|
||||
}),
|
||||
}));
|
||||
jest.mock('nx/src/devkit-internals', () => ({
|
||||
...jest.requireActual('nx/src/devkit-internals'),
|
||||
getExecutorInformation: jest
|
||||
.fn()
|
||||
.mockImplementation((pkg, ...args) =>
|
||||
jest
|
||||
.requireActual('nx/src/devkit-internals')
|
||||
.getExecutorInformation('@nx/webpack', ...args)
|
||||
),
|
||||
}));
|
||||
|
||||
function addProject(tree: Tree, name: string, project: ProjectConfiguration) {
|
||||
addProjectConfiguration(tree, name, project);
|
||||
projectGraph.nodes[name] = {
|
||||
name: name,
|
||||
type: project.projectType === 'application' ? 'app' : 'lib',
|
||||
data: {
|
||||
projectType: project.projectType,
|
||||
root: project.root,
|
||||
targets: project.targets,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface ProjectOptions {
|
||||
appName: string;
|
||||
appRoot: string;
|
||||
buildTargetName: string;
|
||||
buildExecutor: string;
|
||||
serverTargetName: string;
|
||||
serverExecutor: string;
|
||||
}
|
||||
|
||||
const defaultProjectOptions: ProjectOptions = {
|
||||
appName: 'my-app',
|
||||
appRoot: 'apps/my-app',
|
||||
buildTargetName: 'build',
|
||||
buildExecutor: '@nx/next:build',
|
||||
serverTargetName: 'serve',
|
||||
serverExecutor: '@nx/next:server',
|
||||
};
|
||||
|
||||
const defaultNextConfig = `
|
||||
//@ts-check
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { composePlugins, withNx } = require('@nx/next');
|
||||
|
||||
/**
|
||||
* @type {import('@nx/next/plugins/with-nx').WithNxOptions}
|
||||
**/
|
||||
const nextConfig = {
|
||||
nx: {
|
||||
// Set this to true if you would like to use SVGR
|
||||
// See: https://github.com/gregberge/svgr
|
||||
svgr: false,
|
||||
},
|
||||
};
|
||||
|
||||
const plugins = [
|
||||
// Add more Next.js plugins to this list if needed.
|
||||
withNx,
|
||||
];
|
||||
|
||||
module.exports = composePlugins(...plugins)(nextConfig)
|
||||
`;
|
||||
|
||||
function writeNextConfig(
|
||||
tree: Tree,
|
||||
projectRoot: string,
|
||||
nextConfig = defaultNextConfig
|
||||
) {
|
||||
tree.write(`${projectRoot}/next.config.js`, defaultNextConfig);
|
||||
fs.createFileSync(`${projectRoot}/next.config.js`, nextConfig);
|
||||
jest.doMock(
|
||||
join(fs.tempDir, projectRoot, 'next.config.js'),
|
||||
() => nextConfig,
|
||||
{ virtual: true }
|
||||
);
|
||||
}
|
||||
|
||||
function createProject(
|
||||
tree: Tree,
|
||||
options: Partial<ProjectOptions> = {},
|
||||
additionalTargetOptions?: Record<string, Record<string, unknown>>
|
||||
) {
|
||||
let projectOptions = { ...defaultProjectOptions, ...options };
|
||||
const project: ProjectConfiguration = {
|
||||
name: projectOptions.appName,
|
||||
root: projectOptions.appRoot,
|
||||
projectType: 'application',
|
||||
targets: {
|
||||
[projectOptions.buildTargetName]: {
|
||||
executor: projectOptions.buildExecutor,
|
||||
defaultConfiguration: 'production',
|
||||
options: {
|
||||
outputPath: `dist/${projectOptions.appRoot}`,
|
||||
...additionalTargetOptions?.[projectOptions.buildTargetName],
|
||||
},
|
||||
configurations: {
|
||||
development: {
|
||||
outputPath: projectOptions.appRoot,
|
||||
},
|
||||
production: {},
|
||||
},
|
||||
},
|
||||
[projectOptions.serverTargetName]: {
|
||||
executor: projectOptions.serverExecutor,
|
||||
defaultConfiguration: 'development',
|
||||
options: {
|
||||
dev: true,
|
||||
port: 4200,
|
||||
...additionalTargetOptions?.[projectOptions.serverTargetName],
|
||||
},
|
||||
configurations: {
|
||||
development: {
|
||||
buildTarget: `${projectOptions.appName}:${projectOptions.buildTargetName}:development`,
|
||||
dev: true,
|
||||
},
|
||||
production: {
|
||||
buildTarget: `${projectOptions.appName}:${projectOptions.buildTargetName}:production`,
|
||||
dev: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
addProject(tree, project.name, project);
|
||||
fs.createFileSync(
|
||||
`${projectOptions.appRoot}/project.json`,
|
||||
JSON.stringify(project)
|
||||
);
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
describe('convert-to-inferred', () => {
|
||||
let tree: Tree;
|
||||
|
||||
beforeEach(() => {
|
||||
fs = new TempFs('nextjs');
|
||||
tree = createTreeWithEmptyWorkspace();
|
||||
tree.root = fs.tempDir;
|
||||
|
||||
projectGraph = {
|
||||
nodes: {},
|
||||
dependencies: {},
|
||||
externalNodes: {},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.cleanup();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
describe('--project', () => {
|
||||
it('should register plugin in nx.json', async () => {
|
||||
const project = createProject(tree);
|
||||
writeNextConfig(tree, project.root);
|
||||
|
||||
const project2 = createProject(tree, {
|
||||
appName: 'app2',
|
||||
appRoot: 'apps/app2',
|
||||
});
|
||||
|
||||
const project2Build = project2.targets.build;
|
||||
|
||||
await convertToInferred(tree, { project: project.name });
|
||||
|
||||
const nxJsonPlugins = readNxJson(tree).plugins;
|
||||
const nextPlugin = nxJsonPlugins.find(
|
||||
(plugin): plugin is ExpandedPluginConfiguration =>
|
||||
typeof plugin !== 'string' && plugin.plugin === '@nx/next/plugin'
|
||||
);
|
||||
|
||||
const projectConfig = readProjectConfiguration(tree, project.name);
|
||||
|
||||
expect(nextPlugin).toBeDefined();
|
||||
expect(projectConfig.targets.build).toEqual({
|
||||
configurations: { development: {} },
|
||||
});
|
||||
|
||||
const updatedProject2 = readProjectConfiguration(tree, project2.name);
|
||||
expect(updatedProject2.targets.build).toEqual(project2Build);
|
||||
});
|
||||
});
|
||||
|
||||
it('should move fileReplacement and assets option to withNx', async () => {
|
||||
const project = createProject(
|
||||
tree,
|
||||
{},
|
||||
{
|
||||
build: {
|
||||
assets: [{ input: 'tools', output: '.', glob: 'test.txt' }],
|
||||
fileReplacements: [
|
||||
{
|
||||
replace: 'apps/my-app/environments/environment.ts',
|
||||
with: 'apps/my-app/environments/environment.foo.ts',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
writeNextConfig(tree, project.root);
|
||||
|
||||
await convertToInferred(tree, { project: project.name });
|
||||
|
||||
expect(tree.read(`${project.root}/next.config.js`, 'utf-8'))
|
||||
.toMatchInlineSnapshot(`
|
||||
"//@ts-check
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { composePlugins, withNx } = require('@nx/next');
|
||||
const configValues = {
|
||||
default: {
|
||||
fileReplacements: [
|
||||
{
|
||||
replace: './environments/environment.ts',
|
||||
with: './environments/environment.foo.ts',
|
||||
},
|
||||
],
|
||||
assets: [
|
||||
{
|
||||
input: '../../tools',
|
||||
output: '../..',
|
||||
glob: 'test.txt',
|
||||
},
|
||||
],
|
||||
},
|
||||
development: {},
|
||||
};
|
||||
const configuration = process.env.NX_TASK_TARGET_CONFIGURATION || 'default';
|
||||
const options = {
|
||||
...configValues.default,
|
||||
// @ts-expect-error: Ignore TypeScript error for indexing configValues with a dynamic key
|
||||
...configValues[configuration],
|
||||
};
|
||||
/**
|
||||
* @type {import('@nx/next/plugins/with-nx').WithNxOptions}
|
||||
**/
|
||||
const nextConfig = {
|
||||
nx: {
|
||||
// Set this to true if you would like to use SVGR
|
||||
// See: https://github.com/gregberge/svgr
|
||||
svgr: false,
|
||||
...options,
|
||||
},
|
||||
};
|
||||
const plugins = [
|
||||
// Add more Next.js plugins to this list if needed.
|
||||
withNx,
|
||||
];
|
||||
module.exports = composePlugins(...plugins)(nextConfig);
|
||||
"
|
||||
`);
|
||||
});
|
||||
|
||||
it('should leave port option in serve target', async () => {
|
||||
const project = createProject(tree);
|
||||
writeNextConfig(tree, project.root);
|
||||
|
||||
await convertToInferred(tree, { project: project.name });
|
||||
|
||||
const projectConfig = readProjectConfiguration(tree, project.name);
|
||||
expect(projectConfig.targets.serve.options).toEqual({
|
||||
port: 4200,
|
||||
});
|
||||
|
||||
expect(projectConfig.targets.serve).toMatchInlineSnapshot(`
|
||||
{
|
||||
"configurations": {
|
||||
"development": {},
|
||||
"production": {},
|
||||
},
|
||||
"defaultConfiguration": "development",
|
||||
"options": {
|
||||
"port": 4200,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should leave hostname option in serve target', async () => {
|
||||
const project = createProject(tree, {}, { serve: { hostname: 'foo' } });
|
||||
writeNextConfig(tree, project.root);
|
||||
|
||||
await convertToInferred(tree, { project: project.name });
|
||||
|
||||
const projectConfig = readProjectConfiguration(tree, project.name);
|
||||
|
||||
expect(projectConfig.targets.serve.options).toEqual({
|
||||
port: 4200,
|
||||
hostname: 'foo',
|
||||
});
|
||||
|
||||
expect(projectConfig.targets.serve).toMatchInlineSnapshot(`
|
||||
{
|
||||
"configurations": {
|
||||
"development": {},
|
||||
"production": {},
|
||||
},
|
||||
"defaultConfiguration": "development",
|
||||
"options": {
|
||||
"hostname": "foo",
|
||||
"port": 4200,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should migrate options to CLI options and args', async () => {
|
||||
const project = createProject(
|
||||
tree,
|
||||
{},
|
||||
{
|
||||
build: {
|
||||
experimentalAppOnly: true,
|
||||
experimentalBuildMode: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
writeNextConfig(tree, project.root);
|
||||
|
||||
await convertToInferred(tree, { project: project.name });
|
||||
|
||||
const projectConfig = readProjectConfiguration(tree, project.name);
|
||||
expect(projectConfig.targets.build).toMatchObject({
|
||||
options: {
|
||||
args: ['--experimental-app-only', '--experimental-build-mode'],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,60 @@
|
||||
import { createProjectGraphAsync, formatFiles, Tree } from '@nx/devkit';
|
||||
import { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util';
|
||||
import { migrateProjectExecutorsToPluginV1 } from '@nx/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator';
|
||||
import { createNodes } from '../../plugins/plugin';
|
||||
import { buildPostTargetTransformer } from './lib/build-post-target-transformer';
|
||||
import { servePosTargetTransformer } from './lib/serve-post-target-tranformer';
|
||||
|
||||
interface Schema {
|
||||
project?: string;
|
||||
skipFormat?: boolean;
|
||||
}
|
||||
|
||||
export async function convertToInferred(tree: Tree, options: Schema) {
|
||||
const projectGraph = await createProjectGraphAsync();
|
||||
const migrationLogs = new AggregatedLog();
|
||||
|
||||
const migratedProjects = await migrateProjectExecutorsToPluginV1(
|
||||
tree,
|
||||
projectGraph,
|
||||
'@nx/next/plugin',
|
||||
createNodes,
|
||||
{
|
||||
buildTargetName: 'build',
|
||||
devTargetName: 'dev',
|
||||
startTargetName: 'start',
|
||||
serveStaticTargetName: 'serve-static',
|
||||
},
|
||||
[
|
||||
{
|
||||
executors: ['@nx/next:build'],
|
||||
postTargetTransformer: buildPostTargetTransformer(migrationLogs),
|
||||
targetPluginOptionMapper: (targetName) => ({
|
||||
buildTargetName: targetName,
|
||||
}),
|
||||
},
|
||||
{
|
||||
executors: ['@nx/next:server'],
|
||||
postTargetTransformer: servePosTargetTransformer(migrationLogs),
|
||||
targetPluginOptionMapper: (targetName) => ({
|
||||
devTargetName: targetName,
|
||||
}),
|
||||
},
|
||||
],
|
||||
options.project
|
||||
);
|
||||
|
||||
if (migratedProjects.size === 0) {
|
||||
throw new Error('Could not find any targets to migrate');
|
||||
}
|
||||
|
||||
if (!options.skipFormat) {
|
||||
await formatFiles(tree);
|
||||
}
|
||||
|
||||
return () => {
|
||||
migrationLogs.flushLogs();
|
||||
};
|
||||
}
|
||||
|
||||
export default convertToInferred;
|
||||
@ -0,0 +1,173 @@
|
||||
import { TargetConfiguration, Tree } from '@nx/devkit';
|
||||
import { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util';
|
||||
import {
|
||||
processTargetOutputs,
|
||||
toProjectRelativePath,
|
||||
} from '@nx/devkit/src/generators/plugin-migrations/plugin-migration-utils';
|
||||
import { NextBuildBuilderOptions } from '../../../utils/types';
|
||||
import { updateNextConfig } from './update-next-config';
|
||||
|
||||
export function buildPostTargetTransformer(migrationLogs: AggregatedLog) {
|
||||
return (
|
||||
target: TargetConfiguration<NextBuildBuilderOptions>,
|
||||
tree: Tree,
|
||||
projectDetails: { projectName: string; root: string },
|
||||
inferredTargetConfiguration: TargetConfiguration
|
||||
) => {
|
||||
const configValues = {};
|
||||
if (target.options) {
|
||||
handlePropertiesFromTargetOptions(
|
||||
target.options,
|
||||
projectDetails,
|
||||
migrationLogs,
|
||||
'default',
|
||||
configValues
|
||||
);
|
||||
}
|
||||
|
||||
if (target.configurations) {
|
||||
for (const configurationName in target.configurations) {
|
||||
const configuration = target.configurations[configurationName];
|
||||
handlePropertiesFromTargetOptions(
|
||||
configuration,
|
||||
projectDetails,
|
||||
migrationLogs,
|
||||
configurationName,
|
||||
configValues
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.keys(target.configurations).length === 0) {
|
||||
if ('defaultConfiguration' in target) {
|
||||
delete target.defaultConfiguration;
|
||||
}
|
||||
delete target.configurations;
|
||||
}
|
||||
|
||||
if (
|
||||
'defaultConfiguration' in target &&
|
||||
!target.configurations[target.defaultConfiguration]
|
||||
) {
|
||||
delete target.defaultConfiguration;
|
||||
}
|
||||
}
|
||||
|
||||
if (target.outputs) {
|
||||
target.outputs = target.outputs.filter(
|
||||
(out) => !out.includes('options.outputPath')
|
||||
);
|
||||
processTargetOutputs(target, [], inferredTargetConfiguration, {
|
||||
projectName: projectDetails.projectName,
|
||||
projectRoot: projectDetails.root,
|
||||
});
|
||||
}
|
||||
const partialNextConfig = `
|
||||
|
||||
const configValues = ${JSON.stringify(configValues, null, 2)};
|
||||
|
||||
const configuration = process.env.NX_TASK_TARGET_CONFIGURATION || 'default';
|
||||
|
||||
const options = {
|
||||
...configValues.default,
|
||||
// @ts-expect-error: Ignore TypeScript error for indexing configValues with a dynamic key
|
||||
...configValues[configuration],
|
||||
};
|
||||
`;
|
||||
updateNextConfig(tree, partialNextConfig, projectDetails, migrationLogs);
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
function handlePropertiesFromTargetOptions(
|
||||
options: NextBuildBuilderOptions,
|
||||
projectDetails: { projectName: string; root: string },
|
||||
migrationLogs: AggregatedLog,
|
||||
configuration: string = 'default',
|
||||
configValues: any
|
||||
) {
|
||||
let configMap = configValues[configuration] ?? {};
|
||||
|
||||
if ('outputPath' in options) {
|
||||
migrationLogs.addLog({
|
||||
project: projectDetails.projectName,
|
||||
executorName: '@nx/next:build',
|
||||
log: 'Unable to migrate `outputPath` to Next.js config as it may lead to unexpected behavior. Please use the `distDir` option in your next.config.js file instead.',
|
||||
});
|
||||
delete options.outputPath;
|
||||
}
|
||||
if ('fileReplacements' in options) {
|
||||
configMap['fileReplacements'] = options.fileReplacements.map(
|
||||
({ replace: replacePath, with: withPath }) => {
|
||||
return {
|
||||
replace: toProjectRelativePath(replacePath, projectDetails.root),
|
||||
with: toProjectRelativePath(withPath, projectDetails.root),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
delete options.fileReplacements;
|
||||
}
|
||||
if ('nextConfig' in options) {
|
||||
delete options.nextConfig;
|
||||
}
|
||||
if ('assets' in options) {
|
||||
configMap['assets'] = options.assets.map((asset) => {
|
||||
return {
|
||||
...asset,
|
||||
input: toProjectRelativePath(asset.input, projectDetails.root),
|
||||
output: toProjectRelativePath(asset.output, projectDetails.root),
|
||||
};
|
||||
});
|
||||
|
||||
delete options.assets;
|
||||
}
|
||||
|
||||
if ('includeDevDependenciesInPackageJson' in options) {
|
||||
migrationLogs.addLog({
|
||||
project: projectDetails.projectName,
|
||||
executorName: '@nx/next:build',
|
||||
log: "Unable to migrate `includeDevDependenciesInPackageJson` to Next.js config. Use the `@nx/dependency-checks` ESLint rule to update your project's package.json.",
|
||||
});
|
||||
|
||||
delete options.includeDevDependenciesInPackageJson;
|
||||
}
|
||||
|
||||
if ('generatePackageJson' in options) {
|
||||
migrationLogs.addLog({
|
||||
project: projectDetails.projectName,
|
||||
executorName: '@nx/next:build',
|
||||
log: "Unable to migrate `generatePackageJson` to Next.js config. Use the `@nx/dependency-checks` ESLint rule to update your project's package.json.",
|
||||
});
|
||||
|
||||
delete options.generatePackageJson;
|
||||
}
|
||||
|
||||
if ('generateLockfile' in options) {
|
||||
migrationLogs.addLog({
|
||||
project: projectDetails.projectName,
|
||||
executorName: '@nx/next:build',
|
||||
log: 'Unable to migrate `generateLockfile` to Next.js config. This option is not supported.',
|
||||
});
|
||||
|
||||
delete options.generateLockfile;
|
||||
}
|
||||
|
||||
if ('watch' in options) {
|
||||
// Watch is default for serve not available while running 'build'
|
||||
delete options.watch;
|
||||
}
|
||||
|
||||
if ('experimentalAppOnly' in options && options.experimentalAppOnly) {
|
||||
options['args'] ??= [];
|
||||
options['args'].push('--experimental-app-only');
|
||||
delete options.experimentalAppOnly;
|
||||
}
|
||||
|
||||
if ('experimentalBuildMode' in options && options.experimentalBuildMode) {
|
||||
options['args'] ??= [];
|
||||
options['args'].push(`--experimental-build-mode`);
|
||||
delete options.experimentalBuildMode;
|
||||
}
|
||||
|
||||
configValues[configuration] = configMap;
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
import { TargetConfiguration, Tree } from '@nx/devkit';
|
||||
import { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util';
|
||||
import type { NextServeBuilderOptions } from '../../../utils/types';
|
||||
import type { InferredTargetConfiguration } from '@nx/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator';
|
||||
|
||||
export function servePosTargetTransformer(migrationLogs: AggregatedLog) {
|
||||
return (
|
||||
target: TargetConfiguration<NextServeBuilderOptions>,
|
||||
_tree: Tree,
|
||||
projectDetails: { projectName: string; root: string },
|
||||
inferredTargetConfiguration: InferredTargetConfiguration
|
||||
) => {
|
||||
if (target.options) {
|
||||
handlePropertiesFromTargetOptions(target.options);
|
||||
}
|
||||
|
||||
if (target.configurations) {
|
||||
for (const configurationName in target.configurations) {
|
||||
const configuration = target.configurations[configurationName];
|
||||
|
||||
handlePropertiesFromTargetOptions(configuration);
|
||||
}
|
||||
|
||||
if (Object.keys(target.configurations).length === 0) {
|
||||
if ('defaultConfiguration' in target) {
|
||||
delete target.defaultConfiguration;
|
||||
}
|
||||
delete target.configurations;
|
||||
}
|
||||
|
||||
if (
|
||||
'defaultConfiguration' in target &&
|
||||
!target.configurations[target.defaultConfiguration]
|
||||
) {
|
||||
delete target.defaultConfiguration;
|
||||
}
|
||||
}
|
||||
|
||||
migrationLogs.addLog({
|
||||
project: projectDetails.projectName,
|
||||
executorName: '@nx/next:server',
|
||||
log: `Note that "nx run ${projectDetails.projectName}:${inferredTargetConfiguration.name}" only runs the dev server after the migration. To start the prod server, use "nx run ${projectDetails.projectName}:start".`,
|
||||
});
|
||||
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
const executorFieldsToRename: Array<keyof NextServeBuilderOptions> = [
|
||||
'experimentalHttps',
|
||||
'experimentalHttpsCa',
|
||||
'experimentalHttpsKey',
|
||||
'keepAliveTimeout',
|
||||
];
|
||||
|
||||
const executorFieldsToRemain: Array<keyof NextServeBuilderOptions> = [
|
||||
'port',
|
||||
'hostname',
|
||||
];
|
||||
|
||||
const camelCaseToKebabCase = (str: string) =>
|
||||
str.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
||||
|
||||
function handlePropertiesFromTargetOptions(options: NextServeBuilderOptions) {
|
||||
Object.keys(options).forEach((key) => {
|
||||
if (
|
||||
!executorFieldsToRename.includes(key as keyof NextServeBuilderOptions) &&
|
||||
!executorFieldsToRemain.includes(key as keyof NextServeBuilderOptions)
|
||||
) {
|
||||
delete options[key];
|
||||
} else {
|
||||
if (
|
||||
executorFieldsToRename.includes(key as keyof NextServeBuilderOptions)
|
||||
) {
|
||||
const value = options[key];
|
||||
const kebabCase = camelCaseToKebabCase(key);
|
||||
options['args'] ??= [];
|
||||
if (value === true || typeof value !== 'boolean') {
|
||||
options['args'].push(
|
||||
`--${kebabCase}` + (typeof value !== 'boolean' ? ` ${value}` : '')
|
||||
);
|
||||
}
|
||||
delete options[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,148 @@
|
||||
import { Tree } from '@nx/devkit';
|
||||
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
|
||||
import { updateNextConfig } from './update-next-config';
|
||||
|
||||
describe('UpdateNextConfig', () => {
|
||||
let tree: Tree;
|
||||
const mockLog = {
|
||||
addLog: jest.fn(),
|
||||
logs: new Map(),
|
||||
flushLogs: jest.fn(),
|
||||
reset: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
tree = createTreeWithEmptyWorkspace();
|
||||
});
|
||||
|
||||
it('should update the next config file adding the options passed in', () => {
|
||||
const initConfig = `
|
||||
//@ts-check
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { composePlugins, withNx } = require('@nx/next');
|
||||
|
||||
/**
|
||||
* @type {import('@nx/next/plugins/with-nx').WithNxOptions}
|
||||
**/
|
||||
const nextConfig = {
|
||||
nx: {
|
||||
// Set this to true if you would like to use SVGR
|
||||
// See: https://github.com/gregberge/svgr
|
||||
svgr: false,
|
||||
},
|
||||
};
|
||||
|
||||
const plugins = [
|
||||
// Add more Next.js plugins to this list if needed.
|
||||
withNx,
|
||||
];
|
||||
|
||||
module.exports = composePlugins(...plugins)(nextConfig);
|
||||
`;
|
||||
|
||||
const projectName = 'my-app';
|
||||
tree.write(`${projectName}/next.config.js`, initConfig);
|
||||
|
||||
const executorOptionsString = `
|
||||
const configValues = {
|
||||
default: {
|
||||
fileReplacements: [
|
||||
{
|
||||
replace: './environments/environment.ts',
|
||||
with: './environments/environment.foo.ts',
|
||||
},
|
||||
],
|
||||
},
|
||||
development: {},
|
||||
};
|
||||
const configuration = process.env.NX_TASK_TARGET_CONFIGURATION || 'default';
|
||||
const options = {
|
||||
...configValues.default,
|
||||
//@ts-expect-error: Ignore TypeScript error for indexing configValues with a dynamic key
|
||||
...configValues[configuration],
|
||||
};`;
|
||||
|
||||
const projectDetails = { projectName, root: projectName };
|
||||
updateNextConfig(tree, executorOptionsString, projectDetails, mockLog);
|
||||
|
||||
const result = tree.read(`${projectName}/next.config.js`, 'utf-8');
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"//@ts-check
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { composePlugins, withNx } = require('@nx/next');
|
||||
const configValues = {
|
||||
default: {
|
||||
fileReplacements: [
|
||||
{
|
||||
replace: './environments/environment.ts',
|
||||
with: './environments/environment.foo.ts',
|
||||
},
|
||||
],
|
||||
},
|
||||
development: {},
|
||||
};
|
||||
const configuration = process.env.NX_TASK_TARGET_CONFIGURATION || 'default';
|
||||
const options = {
|
||||
...configValues.default,
|
||||
//@ts-expect-error: Ignore TypeScript error for indexing configValues with a dynamic key
|
||||
...configValues[configuration],
|
||||
};
|
||||
;
|
||||
/**
|
||||
* @type {import('@nx/next/plugins/with-nx').WithNxOptions}
|
||||
**/
|
||||
const nextConfig = {
|
||||
nx: {
|
||||
// Set this to true if you would like to use SVGR
|
||||
// See: https://github.com/gregberge/svgr
|
||||
svgr: false,
|
||||
...options
|
||||
},
|
||||
};
|
||||
const plugins = [
|
||||
// Add more Next.js plugins to this list if needed.
|
||||
withNx,
|
||||
];
|
||||
module.exports = composePlugins(...plugins)(nextConfig);
|
||||
"
|
||||
`);
|
||||
});
|
||||
|
||||
it('should warm the user if the next config file is not support (.mjs)', () => {
|
||||
const initConfig = `export default {}`;
|
||||
const projectDetails = { projectName: 'mjs-config', root: 'mjs-config' };
|
||||
tree.write(`${projectDetails.root}/next.config.mjs`, initConfig);
|
||||
|
||||
updateNextConfig(tree, '', projectDetails, mockLog);
|
||||
|
||||
expect(mockLog.addLog).toHaveBeenCalledWith({
|
||||
executorName: '@nx/next:build',
|
||||
log: 'The project mjs-config does not use a supported Next.js config file format. Only .js and .cjs files using "composePlugins" is supported. Leaving it as is.',
|
||||
project: 'mjs-config',
|
||||
});
|
||||
});
|
||||
|
||||
it('should warm the user if composePlugins is not found in the next config file', () => {
|
||||
// Example of a typical next.config.js file
|
||||
const initConfig = `
|
||||
module.exports = {
|
||||
distDir: 'dist',
|
||||
reactStrictMode: true,
|
||||
};
|
||||
`;
|
||||
const projectDetails = {
|
||||
projectName: 'no-compose-plugins',
|
||||
root: 'no-compose-plugins',
|
||||
};
|
||||
tree.write(`${projectDetails.root}/next.config.js`, initConfig);
|
||||
|
||||
updateNextConfig(tree, '', projectDetails, mockLog);
|
||||
|
||||
expect(mockLog.addLog).toHaveBeenCalledWith({
|
||||
executorName: '@nx/next:build',
|
||||
log: 'The project no-compose-plugins does not use a supported Next.js config file format. Only .js and .cjs files using "composePlugins" is supported. Leaving it as is.',
|
||||
project: 'no-compose-plugins',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,104 @@
|
||||
import { Tree } from '@nx/devkit';
|
||||
import { findNextConfigPath } from './utils';
|
||||
import { tsquery } from '@phenomnomnominal/tsquery';
|
||||
import * as ts from 'typescript';
|
||||
import { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util';
|
||||
|
||||
export function updateNextConfig(
|
||||
tree: Tree,
|
||||
updatedConfigFileContents: string,
|
||||
project: { projectName: string; root: string },
|
||||
migrationLogs: AggregatedLog
|
||||
) {
|
||||
const nextConfigPath = findNextConfigPath(tree, project.root);
|
||||
if (!nextConfigPath) {
|
||||
migrationLogs.addLog({
|
||||
project: project.projectName,
|
||||
executorName: '@nx/next:build',
|
||||
log: `The project ${project.projectName} does not use a supported Next.js config file format. Only .js and .cjs files using "composePlugins" is supported. Leaving it as is.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const nextConfigContents = tree.read(nextConfigPath, 'utf-8');
|
||||
let ast = tsquery.ast(nextConfigContents);
|
||||
|
||||
// Query to check for composePlugins in module.exports
|
||||
const composePluginsQuery = `ExpressionStatement > BinaryExpression > CallExpression > CallExpression:has(Identifier[name=composePlugins])`;
|
||||
const composePluginNode = tsquery(ast, composePluginsQuery)[0];
|
||||
|
||||
if (!composePluginNode) {
|
||||
migrationLogs.addLog({
|
||||
project: project.projectName,
|
||||
executorName: '@nx/next:build',
|
||||
log: `The project ${project.projectName} does not use a supported Next.js config file format. Only .js and .cjs files using "composePlugins" is supported. Leaving it as is.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let lastRequireEndPosition = -1;
|
||||
|
||||
const findLastRequire = (node: ts.Node) => {
|
||||
if (
|
||||
ts.isCallExpression(node) &&
|
||||
ts.isIdentifier(node.expression) &&
|
||||
node.expression.text === 'require'
|
||||
) {
|
||||
lastRequireEndPosition = node.end;
|
||||
}
|
||||
ts.forEachChild(node, findLastRequire);
|
||||
};
|
||||
|
||||
findLastRequire(ast);
|
||||
|
||||
let updatedCode = `
|
||||
${nextConfigContents.slice(0, lastRequireEndPosition)}\n
|
||||
${updatedConfigFileContents}\n\n
|
||||
${nextConfigContents.slice(lastRequireEndPosition)}
|
||||
`;
|
||||
ast = tsquery.ast(updatedCode);
|
||||
|
||||
const nextConfigNode = tsquery(
|
||||
ast,
|
||||
'VariableDeclaration:has(Identifier[name=nextConfig]) ObjectLiteralExpression'
|
||||
)[0];
|
||||
|
||||
if (nextConfigNode) {
|
||||
const nxNode = tsquery(
|
||||
nextConfigNode,
|
||||
'PropertyAssignment:has(Identifier[name=nx]) ObjectLiteralExpression'
|
||||
)[0];
|
||||
|
||||
if (nxNode) {
|
||||
const spread = ts.factory.createSpreadAssignment(
|
||||
ts.factory.createIdentifier('options')
|
||||
);
|
||||
|
||||
const updatedNxNode = ts.factory.updateObjectLiteralExpression(
|
||||
nxNode as ts.ObjectLiteralExpression,
|
||||
ts.factory.createNodeArray([...nxNode['properties'], spread])
|
||||
);
|
||||
|
||||
const transformer =
|
||||
<T extends ts.Node>(context: ts.TransformationContext) =>
|
||||
(rootNode: T) => {
|
||||
function visit(node: ts.Node): ts.Node {
|
||||
if (node === nxNode) {
|
||||
return updatedNxNode;
|
||||
}
|
||||
return ts.visitEachChild(node, visit, context);
|
||||
}
|
||||
|
||||
return ts.visitNode(rootNode, visit);
|
||||
};
|
||||
|
||||
const result = ts.transform(ast, [transformer]);
|
||||
const transformedSourceFile = result.transformed[0] as ts.SourceFile;
|
||||
|
||||
const printer = ts.createPrinter();
|
||||
updatedCode = printer.printFile(transformedSourceFile);
|
||||
}
|
||||
|
||||
tree.write(nextConfigPath, updatedCode);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
export function findNextConfigPath(tree, projectRoot) {
|
||||
const validExtensions = ['js', 'cjs'];
|
||||
|
||||
for (const extension of validExtensions) {
|
||||
const path = `${projectRoot}/next.config.${extension}`;
|
||||
if (tree.exists(path)) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
}
|
||||
19
packages/next/src/generators/convert-to-inferred/schema.json
Normal file
19
packages/next/src/generators/convert-to-inferred/schema.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/schema",
|
||||
"$id": "NxNextjsConvertToInferred",
|
||||
"description": "Convert existing Next.js project(s) using `@nx/next:build` executor to use `@nx/next/plugin`.",
|
||||
"title": "Convert a Nextjs project from executor to plugin",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project": {
|
||||
"type": "string",
|
||||
"description": "The project to convert from using the `@nx/next:build` executor to use `@nx/next/plugin`. If not provided, all projects using the `@nx/next:build` executor will be converted.",
|
||||
"x-priority": "important"
|
||||
},
|
||||
"skipFormat": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to format files.",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user