feat(rsbuild): add react and vue support for app generation (#29349)

## Current Behavior
We do not have a generator that can scaffold a react or vue app using
rsbuild.


## Expected Behavior
Update the react application generator to support a bundler option of
rsbuild
Update the vue application generator to support a bundler option of
rsbuild
This commit is contained in:
Colum Ferry 2024-12-18 16:44:21 +00:00 committed by GitHub
parent b03c9c1e38
commit 4b586a1acc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
57 changed files with 2310 additions and 306 deletions

View File

@ -95,7 +95,7 @@
"bundler": {
"description": "The bundler to use.",
"type": "string",
"enum": ["vite", "webpack", "rspack"],
"enum": ["vite", "webpack", "rspack", "rsbuild"],
"x-prompt": "Which bundler do you want to use to build the application?",
"default": "vite",
"x-priority": "important"

View File

@ -26,6 +26,11 @@
"description": "Path relative to the workspace root for the tsconfig file to build with. Defaults to '<projectRoot>/tsconfig.app.json'.",
"x-priority": "important"
},
"devServerPort": {
"type": "number",
"description": "The port for the dev server to listen on.",
"default": 4200
},
"target": {
"type": "string",
"description": "Target platform for the build, same as the Rsbuild output.target config option.",

View File

@ -54,6 +54,14 @@
]
}
},
"bundler": {
"description": "The bundler to use.",
"type": "string",
"enum": ["vite", "rsbuild"],
"x-prompt": "Which bundler do you want to use to build the application?",
"default": "vite",
"x-priority": "important"
},
"routing": {
"type": "boolean",
"description": "Generate application with routes.",

View File

@ -0,0 +1,110 @@
import {
checkFilesExist,
cleanupProject,
newProject,
runCLI,
runCLIAsync,
runE2ETests,
uniq,
} from '@nx/e2e/utils';
describe('Build React applications and libraries with Rsbuild', () => {
beforeAll(() => {
newProject({
packages: ['@nx/react'],
});
});
afterAll(() => {
cleanupProject();
});
it('should test and lint app with bundler=rsbuild', async () => {
const rsbuildApp = uniq('rsbuildapp');
runCLI(
`generate @nx/react:app apps/${rsbuildApp} --bundler=rsbuild --unitTestRunner=vitest --no-interactive --linter=eslint`
);
const appTestResults = await runCLIAsync(`test ${rsbuildApp}`);
expect(appTestResults.combinedOutput).toContain(
'Successfully ran target test'
);
const appLintResults = await runCLIAsync(`lint ${rsbuildApp}`);
expect(appLintResults.combinedOutput).toContain(
'Successfully ran target lint'
);
await runCLIAsync(`build ${rsbuildApp}`);
checkFilesExist(`apps/${rsbuildApp}/dist/index.html`);
}, 300_000);
it('should test and lint app with bundler=rsbuild', async () => {
const rsbuildApp = uniq('rsbuildapp');
runCLI(
`generate @nx/react:app apps/${rsbuildApp} --bundler=rsbuild --unitTestRunner=vitest --no-interactive --linter=eslint`
);
const appTestResults = await runCLIAsync(`test ${rsbuildApp}`);
expect(appTestResults.combinedOutput).toContain(
'Successfully ran target test'
);
const appLintResults = await runCLIAsync(`lint ${rsbuildApp}`);
expect(appLintResults.combinedOutput).toContain(
'Successfully ran target lint'
);
await runCLIAsync(`build ${rsbuildApp}`);
checkFilesExist(`apps/${rsbuildApp}/dist/index.html`);
}, 300_000);
it('should test and lint app with bundler=rsbuild and inSourceTests', async () => {
const rsbuildApp = uniq('rsbuildapp');
runCLI(
`generate @nx/react:app apps/${rsbuildApp} --bundler=rsbuild --unitTestRunner=vitest --inSourceTests --no-interactive --linter=eslint`
);
expect(() => {
checkFilesExist(`apps/${rsbuildApp}/src/app/app.spec.tsx`);
}).toThrow();
const appTestResults = await runCLIAsync(`test ${rsbuildApp}`);
expect(appTestResults.combinedOutput).toContain(
'Successfully ran target test'
);
const appLintResults = await runCLIAsync(`lint ${rsbuildApp}`);
expect(appLintResults.combinedOutput).toContain(
'Successfully ran target lint'
);
await runCLIAsync(`build ${rsbuildApp}`);
checkFilesExist(`apps/${rsbuildApp}/dist/index.html`);
}, 300_000);
it('should support bundling with Rsbuild and Jest', async () => {
const rsbuildApp = uniq('rsbuildapp');
runCLI(
`generate @nx/react:app apps/${rsbuildApp} --bundler=rsbuild --unitTestRunner=jest --no-interactive --linter=eslint`
);
const appTestResults = await runCLIAsync(`test ${rsbuildApp}`);
expect(appTestResults.combinedOutput).toContain(
'Successfully ran target test'
);
await runCLIAsync(`build ${rsbuildApp}`);
checkFilesExist(`apps/${rsbuildApp}/dist/index.html`);
if (runE2ETests()) {
const result = runCLI(`e2e ${rsbuildApp}-e2e --verbose`);
expect(result).toContain(
`Successfully ran target e2e for project ${rsbuildApp}-e2e`
);
}
}, 300_000);
});

View File

@ -1,4 +1,11 @@
import { cleanupProject, newProject, runCLI, uniq } from '@nx/e2e/utils';
import {
cleanupProject,
killPorts,
newProject,
runCLI,
runE2ETests,
uniq,
} from '@nx/e2e/utils';
describe('Vue Plugin', () => {
let proj: string;
@ -33,6 +40,29 @@ describe('Vue Plugin', () => {
// }
}, 200_000);
it('should serve application in dev mode with rsbuild', async () => {
const app = uniq('app');
runCLI(
`generate @nx/vue:app ${app} --bundler=rsbuild --unitTestRunner=vitest --e2eTestRunner=playwright`
);
let result = runCLI(`test ${app}`);
expect(result).toContain(`Successfully ran target test for project ${app}`);
result = runCLI(`build ${app}`);
expect(result).toContain(
`Successfully ran target build for project ${app}`
);
// TODO: enable this when tests are passing again.
// Colum confirmed locally that the generated config and the playwright tests are working.
// if (runE2ETests()) {
// const e2eResults = runCLI(`e2e ${app}-e2e --no-watch`);
// expect(e2eResults).toContain('Successfully ran target e2e');
// expect(await killPorts()).toBeTruthy();
// }
}, 200_000);
it('should build library', async () => {
const lib = uniq('lib');

View File

@ -83,11 +83,12 @@
"@nx/powerpack-enterprise-cloud": "1.1.0-beta.9",
"@nx/powerpack-license": "1.1.0-beta.9",
"@nx/react": "20.3.0-beta.0",
"@nx/rsbuild": "20.3.0-beta.0",
"@nx/rspack": "20.3.0-beta.0",
"@nx/storybook": "20.3.0-beta.0",
"@nx/vite": "20.3.0-beta.0",
"@nx/web": "20.3.0-beta.0",
"@nx/webpack": "20.3.0-beta.0",
"@nx/vite": "20.3.0-beta.0",
"@phenomnomnominal/tsquery": "~5.0.1",
"@playwright/test": "^1.36.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.7",

View File

@ -58,6 +58,7 @@
"@nx/playwright",
"@nx/jest",
"@nx/rollup",
"@nx/rsbuild",
"@nx/storybook",
"@nx/vite",
"@nx/webpack",

View File

@ -1,5 +1,192 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`app --bundler=rsbuild should generate valid rsbuild config files for @emotion/styled 1`] = `
"import styled from '@emotion/styled';
import NxWelcome from "./nx-welcome";
const StyledApp = styled.div\`
// Your style here
\`;
export function App() {
return (
<StyledApp>
<NxWelcome title="my-app"/>
</StyledApp>
);
}
export default App;
"
`;
exports[`app --bundler=rsbuild should generate valid rsbuild config files for @emotion/styled 2`] = `
"import { pluginReact } from '@rsbuild/plugin-react';
import { defineConfig } from '@rsbuild/core';
export default defineConfig({
html: {
template: './src/index.html'
},
tools: {
swc: {
jsc: {
experimental: {
plugins: [
['@swc/plugin-emotion', {}],
],
},
},
},
},
plugins: [pluginReact(swcReactOptions: {
importSource: '@emotion/react',
})],
source: {
entry: {
index: './src/main.tsx'
},
tsconfigPath: './tsconfig.app.json',
},
server: {
port: 4200
},
output: {
copy: [
{ from: './src/favicon.ico' },
{ from: './src/assets' }],
target: 'web',
distPath: {
root: 'dist',
},
}
});
"
`;
exports[`app --bundler=rsbuild should generate valid rsbuild config files for styled-components 1`] = `
"import styled from 'styled-components';
import NxWelcome from "./nx-welcome";
const StyledApp = styled.div\`
// Your style here
\`;
export function App() {
return (
<StyledApp>
<NxWelcome title="my-app"/>
</StyledApp>
);
}
export default App;
"
`;
exports[`app --bundler=rsbuild should generate valid rsbuild config files for styled-components 2`] = `
"import { pluginStyledComponents } from '@rsbuild/plugin-styled-components';
import { pluginReact } from '@rsbuild/plugin-react';
import { defineConfig } from '@rsbuild/core';
export default defineConfig({
html: {
template: './src/index.html'
},
plugins: [
pluginReact(),
pluginStyledComponents()
],
source: {
entry: {
index: './src/main.tsx'
},
tsconfigPath: './tsconfig.app.json',
},
server: {
port: 4200
},
output: {
copy: [
{ from: './src/favicon.ico' },
{ from: './src/assets' }],
target: 'web',
distPath: {
root: 'dist',
},
}
});
"
`;
exports[`app --bundler=rsbuild should generate valid rsbuild config files for styled-jsx 1`] = `
"import NxWelcome from "./nx-welcome";
export function App() {
return (
<div>
<style jsx>{\`/** your style here **/\`}</style>
<NxWelcome title="my-app"/>
</div>
);
}
export default App;
"
`;
exports[`app --bundler=rsbuild should generate valid rsbuild config files for styled-jsx 2`] = `
"import { pluginReact } from '@rsbuild/plugin-react';
import { defineConfig } from '@rsbuild/core';
export default defineConfig({
html: {
template: './src/index.html'
},
tools: {
swc: {
jsc: {
experimental: {
plugins: [
['@swc/plugin-styled-jsx', {}],
],
},
},
},
},
plugins: [pluginReact()],
source: {
entry: {
index: './src/main.tsx'
},
tsconfigPath: './tsconfig.app.json',
},
server: {
port: 4200
},
output: {
copy: [
{ from: './src/favicon.ico' },
{ from: './src/assets' }],
target: 'web',
distPath: {
root: 'dist',
},
}
});
"
`;
exports[`app --minimal should create default application without Nx welcome component 1`] = `
"// Uncomment this line to use CSS modules
// import styles from './app.module.css';

View File

@ -1454,4 +1454,28 @@ describe('app', () => {
`);
});
});
describe('--bundler=rsbuild', () => {
it.each([
{ style: 'styled-components' },
{ style: 'styled-jsx' },
{ style: '@emotion/styled' },
])(
`should generate valid rsbuild config files for $style`,
async ({ style }) => {
await applicationGenerator(appTree, {
...schema,
bundler: 'rsbuild',
style: style as any,
});
const content = appTree.read('my-app/src/app/app.tsx').toString();
expect(content).toMatchSnapshot();
const configContents = appTree
.read('my-app/rsbuild.config.ts')
.toString();
expect(configContents).toMatchSnapshot();
}
);
});
});

View File

@ -1,5 +1,19 @@
import { extraEslintDependencies } from '../../utils/lint';
import { NormalizedSchema, Schema } from './schema';
import {
formatFiles,
GeneratorCallback,
joinPathFragments,
readNxJson,
runTasksInSerial,
Tree,
updateNxJson,
} from '@nx/devkit';
import { initGenerator as jsInitGenerator } from '@nx/js';
import { logShowProjectCommand } from '@nx/devkit/src/utils/log-show-project-command';
import { updateTsconfigFiles } from '@nx/js/src/utils/typescript/ts-solution-setup';
import { extractTsConfigBase } from '../../utils/create-ts-config';
import { addStyledModuleDependencies } from '../../rules/add-styled-dependencies';
import { setupTailwindGenerator } from '../setup-tailwind/setup-tailwind';
import reactInitGenerator from '../init/init';
import { createApplicationFiles } from './lib/create-application-files';
import { updateSpecConfig } from './lib/update-jest-config';
import { normalizeOptions } from './lib/normalize-options';
@ -7,111 +21,43 @@ import { addProject } from './lib/add-project';
import { addJest } from './lib/add-jest';
import { addRouting } from './lib/add-routing';
import { setDefaults } from './lib/set-defaults';
import { addStyledModuleDependencies } from '../../rules/add-styled-dependencies';
import {
addDependenciesToPackageJson,
ensurePackage,
formatFiles,
GeneratorCallback,
joinPathFragments,
logger,
readNxJson,
runTasksInSerial,
stripIndents,
Tree,
updateNxJson,
} from '@nx/devkit';
import reactInitGenerator from '../init/init';
import { Linter, lintProjectGenerator } from '@nx/eslint';
import { babelLoaderVersion, nxVersion } from '../../utils/versions';
import { maybeJs } from '../../utils/maybe-js';
import { installCommonDependencies } from './lib/install-common-dependencies';
import { extractTsConfigBase } from '../../utils/create-ts-config';
import { addSwcDependencies } from '@nx/js/src/utils/swc/add-swc-dependencies';
import * as pc from 'picocolors';
import { showPossibleWarnings } from './lib/show-possible-warnings';
import { addLinting } from './lib/add-linting';
import { addE2e } from './lib/add-e2e';
import { showPossibleWarnings } from './lib/show-possible-warnings';
import { installCommonDependencies } from './lib/install-common-dependencies';
import { initWebpack } from './lib/bundlers/add-webpack';
import {
addExtendsToLintConfig,
addOverrideToLintConfig,
addPredefinedConfigToFlatLintConfig,
isEslintConfigSupported,
} from '@nx/eslint/src/generators/utils/eslint-file';
import { initGenerator as jsInitGenerator } from '@nx/js';
import { logShowProjectCommand } from '@nx/devkit/src/utils/log-show-project-command';
import { setupTailwindGenerator } from '../setup-tailwind/setup-tailwind';
import { useFlatConfig } from '@nx/eslint/src/utils/flat-config';
import { updateTsconfigFiles } from '@nx/js/src/utils/typescript/ts-solution-setup';
async function addLinting(host: Tree, options: NormalizedSchema) {
const tasks: GeneratorCallback[] = [];
if (options.linter === Linter.EsLint) {
const lintTask = await lintProjectGenerator(host, {
linter: options.linter,
project: options.projectName,
tsConfigPaths: [
joinPathFragments(options.appProjectRoot, 'tsconfig.app.json'),
],
unitTestRunner: options.unitTestRunner,
skipFormat: true,
rootProject: options.rootProject,
skipPackageJson: options.skipPackageJson,
addPlugin: options.addPlugin,
});
tasks.push(lintTask);
if (isEslintConfigSupported(host)) {
if (useFlatConfig(host)) {
addPredefinedConfigToFlatLintConfig(
host,
options.appProjectRoot,
'flat/react'
);
// Add an empty rules object to users know how to add/override rules
addOverrideToLintConfig(host, options.appProjectRoot, {
files: ['*.ts', '*.tsx', '*.js', '*.jsx'],
rules: {},
});
} else {
const addExtendsTask = addExtendsToLintConfig(
host,
options.appProjectRoot,
{ name: 'plugin:@nx/react', needCompatFixup: true }
);
tasks.push(addExtendsTask);
}
}
if (!options.skipPackageJson) {
const installTask = addDependenciesToPackageJson(
host,
extraEslintDependencies.dependencies,
extraEslintDependencies.devDependencies
);
const addSwcTask = addSwcDependencies(host);
tasks.push(installTask, addSwcTask);
}
}
return runTasksInSerial(...tasks);
}
handleStyledJsxForRspack,
initRspack,
setupRspackConfiguration,
} from './lib/bundlers/add-rspack';
import {
initRsbuild,
setupRsbuildConfiguration,
} from './lib/bundlers/add-rsbuild';
import {
setupViteConfiguration,
setupVitestConfiguration,
} from './lib/bundlers/add-vite';
import { Schema } from './schema';
export async function applicationGenerator(
host: Tree,
tree: Tree,
schema: Schema
): Promise<GeneratorCallback> {
return await applicationGeneratorInternal(host, {
return await applicationGeneratorInternal(tree, {
addPlugin: false,
...schema,
});
}
export async function applicationGeneratorInternal(
host: Tree,
tree: Tree,
schema: Schema
): Promise<GeneratorCallback> {
const tasks = [];
const jsInitTask = await jsInitGenerator(host, {
const jsInitTask = await jsInitGenerator(tree, {
...schema,
tsConfigName: schema.rootProject ? 'tsconfig.json' : 'tsconfig.base.json',
skipFormat: true,
@ -120,17 +66,17 @@ export async function applicationGeneratorInternal(
});
tasks.push(jsInitTask);
const options = await normalizeOptions(host, schema);
showPossibleWarnings(host, options);
const options = await normalizeOptions(tree, schema);
showPossibleWarnings(tree, options);
const initTask = await reactInitGenerator(host, {
const initTask = await reactInitGenerator(tree, {
...options,
skipFormat: true,
});
tasks.push(initTask);
if (!options.addPlugin) {
const nxJson = readNxJson(host);
const nxJson = readNxJson(tree);
nxJson.targetDefaults ??= {};
if (!Object.keys(nxJson.targetDefaults).includes('build')) {
nxJson.targetDefaults.build = {
@ -140,159 +86,48 @@ export async function applicationGeneratorInternal(
} else if (!nxJson.targetDefaults.build.dependsOn) {
nxJson.targetDefaults.build.dependsOn = ['^build'];
}
updateNxJson(host, nxJson);
updateNxJson(tree, nxJson);
}
if (options.bundler === 'webpack') {
const { webpackInitGenerator } = ensurePackage<
typeof import('@nx/webpack')
>('@nx/webpack', nxVersion);
const webpackInitTask = await webpackInitGenerator(host, {
skipPackageJson: options.skipPackageJson,
skipFormat: true,
addPlugin: options.addPlugin,
});
tasks.push(webpackInitTask);
if (!options.skipPackageJson) {
const { ensureDependencies } = await import(
'@nx/webpack/src/utils/ensure-dependencies'
);
tasks.push(ensureDependencies(host, { uiFramework: 'react' }));
}
await initWebpack(tree, options, tasks);
} else if (options.bundler === 'rspack') {
const { rspackInitGenerator } = ensurePackage('@nx/rspack', nxVersion);
const rspackInitTask = await rspackInitGenerator(host, {
...options,
addPlugin: false,
skipFormat: true,
});
tasks.push(rspackInitTask);
await initRspack(tree, options, tasks);
} else if (options.bundler === 'rsbuild') {
await initRsbuild(tree, options, tasks);
}
if (!options.rootProject) {
extractTsConfigBase(host);
extractTsConfigBase(tree);
}
await createApplicationFiles(host, options);
addProject(host, options);
await createApplicationFiles(tree, options);
addProject(tree, options);
if (options.style === 'tailwind') {
const twTask = await setupTailwindGenerator(host, {
const twTask = await setupTailwindGenerator(tree, {
project: options.projectName,
});
tasks.push(twTask);
}
if (options.bundler === 'vite') {
const { createOrEditViteConfig, viteConfigurationGenerator } =
ensurePackage<typeof import('@nx/vite')>('@nx/vite', nxVersion);
// We recommend users use `import.meta.env.MODE` and other variables in their code to differentiate between production and development.
// See: https://vitejs.dev/guide/env-and-mode.html
if (
host.exists(joinPathFragments(options.appProjectRoot, 'src/environments'))
) {
host.delete(
joinPathFragments(options.appProjectRoot, 'src/environments')
);
}
const viteTask = await viteConfigurationGenerator(host, {
uiFramework: 'react',
project: options.projectName,
newProject: true,
includeVitest: options.unitTestRunner === 'vitest',
inSourceTests: options.inSourceTests,
compiler: options.compiler,
skipFormat: true,
addPlugin: options.addPlugin,
projectType: 'application',
});
tasks.push(viteTask);
createOrEditViteConfig(
host,
{
project: options.projectName,
includeLib: false,
includeVitest: options.unitTestRunner === 'vitest',
inSourceTests: options.inSourceTests,
rollupOptionsExternal: [
"'react'",
"'react-dom'",
"'react/jsx-runtime'",
],
imports: [
options.compiler === 'swc'
? `import react from '@vitejs/plugin-react-swc'`
: `import react from '@vitejs/plugin-react'`,
],
plugins: ['react()'],
},
false
);
await setupViteConfiguration(tree, options, tasks);
} else if (options.bundler === 'rspack') {
const { configurationGenerator } = ensurePackage('@nx/rspack', nxVersion);
const rspackTask = await configurationGenerator(host, {
project: options.projectName,
main: joinPathFragments(
options.appProjectRoot,
maybeJs(
{
js: options.js,
useJsx: true,
},
`src/main.tsx`
)
),
tsConfig: joinPathFragments(options.appProjectRoot, 'tsconfig.app.json'),
target: 'web',
newProject: true,
framework: 'react',
});
tasks.push(rspackTask);
await setupRspackConfiguration(tree, options, tasks);
} else if (options.bundler === 'rsbuild') {
await setupRsbuildConfiguration(tree, options, tasks);
}
if (options.bundler !== 'vite' && options.unitTestRunner === 'vitest') {
const { createOrEditViteConfig, vitestGenerator } = ensurePackage<
typeof import('@nx/vite')
>('@nx/vite', nxVersion);
const vitestTask = await vitestGenerator(host, {
uiFramework: 'react',
coverageProvider: 'v8',
project: options.projectName,
inSourceTests: options.inSourceTests,
skipFormat: true,
addPlugin: options.addPlugin,
});
tasks.push(vitestTask);
createOrEditViteConfig(
host,
{
project: options.projectName,
includeLib: false,
includeVitest: true,
inSourceTests: options.inSourceTests,
rollupOptionsExternal: [
"'react'",
"'react-dom'",
"'react/jsx-runtime'",
],
imports: [
options.compiler === 'swc'
? `import react from '@vitejs/plugin-react-swc'`
: `import react from '@vitejs/plugin-react'`,
],
plugins: ['react()'],
},
true
);
await setupVitestConfiguration(tree, options, tasks);
}
if (
(options.bundler === 'vite' || options.unitTestRunner === 'vitest') &&
options.inSourceTests
) {
host.delete(
tree.delete(
joinPathFragments(
options.appProjectRoot,
`src/app/${options.fileName}.spec.tsx`
@ -300,69 +135,33 @@ export async function applicationGeneratorInternal(
);
}
const lintTask = await addLinting(host, options);
const lintTask = await addLinting(tree, options);
tasks.push(lintTask);
const e2eTask = await addE2e(host, options);
const e2eTask = await addE2e(tree, options);
tasks.push(e2eTask);
if (options.unitTestRunner === 'jest') {
const jestTask = await addJest(host, options);
const jestTask = await addJest(tree, options);
tasks.push(jestTask);
}
// Handle tsconfig.spec.json for jest or vitest
updateSpecConfig(host, options);
const stylePreprocessorTask = installCommonDependencies(host, options);
updateSpecConfig(tree, options);
const stylePreprocessorTask = installCommonDependencies(tree, options);
tasks.push(stylePreprocessorTask);
const styledTask = addStyledModuleDependencies(host, options);
const styledTask = addStyledModuleDependencies(tree, options);
tasks.push(styledTask);
const routingTask = addRouting(host, options);
const routingTask = addRouting(tree, options);
tasks.push(routingTask);
setDefaults(host, options);
setDefaults(tree, options);
if (options.bundler === 'rspack' && options.style === 'styled-jsx') {
logger.warn(
`${pc.bold('styled-jsx')} is not supported by ${pc.bold(
'Rspack'
)}. We've added ${pc.bold(
'babel-loader'
)} to your project, but using babel will slow down your build.`
);
tasks.push(
addDependenciesToPackageJson(
host,
{},
{ 'babel-loader': babelLoaderVersion }
)
);
host.write(
joinPathFragments(options.appProjectRoot, 'rspack.config.js'),
stripIndents`
const { composePlugins, withNx, withReact } = require('@nx/rspack');
module.exports = composePlugins(withNx(), withReact(), (config) => {
config.module.rules.push({
test: /\\.[jt]sx$/i,
use: [
{
loader: 'babel-loader',
options: {
presets: ['@babel/preset-typescript'],
plugins: ['styled-jsx/babel'],
},
},
],
});
return config;
});
`
);
handleStyledJsxForRspack(tasks, tree, options);
}
updateTsconfigFiles(
host,
tree,
options.appProjectRoot,
'tsconfig.app.json',
{
@ -376,7 +175,7 @@ export async function applicationGeneratorInternal(
);
if (!options.skipFormat) {
await formatFiles(host);
await formatFiles(tree);
}
tasks.push(() => {

View File

@ -0,0 +1,10 @@
import { render } from '@testing-library/react';
<%_ if (routing) { _%>
import { BrowserRouter } from 'react-router-dom';
<%_ } _%>
import App from './<%= fileName %>';
describe('App', () => {
<%- appTests _%>
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title><%= className %></title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,32 @@
<%_ if (strict) { _%>import { StrictMode } from 'react';<%_ } _%>
import * as ReactDOM from 'react-dom/client';
<%_ if (routing) { _%>import { BrowserRouter } from 'react-router-dom';<%_ } _%>
import App from './app/<%= fileName %>';
<%_ if(hasStyleFile) { _%>
import './styles.<%= style %>'
<%_ } _%>
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
<%_ if(strict && !routing) { _%>
root.render(
<StrictMode>
<App/>
</StrictMode>
)
<%_ } _%>
<%_ if(!strict && routing) { _%>
root.render(
<BrowserRouter>
<App/>
</BrowserRouter>
)
<%_ } _%>
<%_ if(strict && routing) { _%>
root.render(
<StrictMode>
<BrowserRouter>
<App/>
</BrowserRouter>
</StrictMode>
)
<%_ } _%>

View File

@ -0,0 +1,31 @@
<%_ if (isUsingTsSolutionSetup) { _%>{
"extends": "<%= offsetFromRoot%>tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo",
"jsx": "react-jsx",
"lib": ["dom"],
"types": [
"node",
<%_ if (style === 'styled-jsx') { _%>"@nx/react/typings/styled-jsx.d.ts",<%_ } _%>
"@nx/react/typings/cssmodule.d.ts",
"@nx/react/typings/image.d.ts"
]
},
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts", "src/**/*.spec.tsx", "src/**/*.test.tsx", "src/**/*.spec.js", "src/**/*.test.js", "src/**/*.spec.jsx", "src/**/*.test.jsx"],
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
}<% } else { %>{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "<%= offsetFromRoot %>dist/out-tsc",
"types": [
"node",
<%_ if (style === 'styled-jsx') { _%>"@nx/react/typings/styled-jsx.d.ts",<%_ } _%>
"@nx/react/typings/cssmodule.d.ts",
"@nx/react/typings/image.d.ts"
]
},
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts", "src/**/*.spec.tsx", "src/**/*.test.tsx", "src/**/*.spec.js", "src/**/*.test.js", "src/**/*.spec.jsx", "src/**/*.test.jsx"],
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
}
<% } %>

View File

@ -2,7 +2,7 @@
import { Component } from 'react';
<%_ } if (!minimal) { _%>
import NxWelcome from "./nx-welcome";
<%_ } if (bundler === "rspack") { _%>
<%_ } if (bundler === "rspack" || bundler === 'rsbuild') { _%>
import '../styles.css';
<%_ } _%>

View File

@ -14,6 +14,7 @@ import { nxVersion } from '../../../utils/versions';
import { hasWebpackPlugin } from '../../../utils/has-webpack-plugin';
import { hasVitePlugin } from '../../../utils/has-vite-plugin';
import { hasRspackPlugin } from '../../../utils/has-rspack-plugin';
import { hasRsbuildPlugin } from '../../../utils/has-rsbuild-plugin';
import { NormalizedSchema } from '../schema';
import { findPluginForConfigFile } from '@nx/devkit/src/utils/find-plugin-for-config-file';
import { addE2eCiTargetDefaults } from '@nx/devkit/src/generators/target-defaults-utils';
@ -26,6 +27,7 @@ export async function addE2e(
const hasNxBuildPlugin =
(options.bundler === 'webpack' && hasWebpackPlugin(tree)) ||
(options.bundler === 'rspack' && hasRspackPlugin(tree)) ||
(options.bundler === 'rsbuild' && hasRsbuildPlugin(tree)) ||
(options.bundler === 'vite' && hasVitePlugin(tree));
let e2eWebServerInfo: E2EWebServerDetails = {
@ -68,6 +70,22 @@ export async function addE2e(
options.addPlugin,
options.devServerPort ?? 4200
);
} else if (options.bundler === 'rsbuild') {
ensurePackage('@nx/rsbuild', nxVersion);
const { getRsbuildE2EWebServerInfo } = await import(
'@nx/rsbuild/config-utils'
);
e2eWebServerInfo = await getRsbuildE2EWebServerInfo(
tree,
options.projectName,
joinPathFragments(
options.appProjectRoot,
`rsbuild.config.${options.js ? 'js' : 'ts'}`
),
options.addPlugin,
options.devServerPort ?? 4200
);
}
if (!hasNxBuildPlugin) {
@ -114,7 +132,12 @@ export async function addE2e(
project: options.e2eProjectName,
directory: 'src',
// the name and root are already normalized, instruct the generator to use them as is
bundler: options.bundler === 'rspack' ? 'webpack' : options.bundler,
bundler:
options.bundler === 'rspack'
? 'webpack'
: options.bundler === 'rsbuild'
? 'none'
: options.bundler,
skipFormat: true,
devServerTarget: e2eWebServerInfo.e2eDevServerTarget,
baseUrl: e2eWebServerInfo.e2eWebServerAddress,

View File

@ -0,0 +1,69 @@
import {
type Tree,
type GeneratorCallback,
joinPathFragments,
} from '@nx/devkit';
import { Linter, lintProjectGenerator } from '@nx/eslint';
import {
addExtendsToLintConfig,
addOverrideToLintConfig,
addPredefinedConfigToFlatLintConfig,
isEslintConfigSupported,
} from '@nx/eslint/src/generators/utils/eslint-file';
import { useFlatConfig } from '@nx/eslint/src/utils/flat-config';
import { addDependenciesToPackageJson, runTasksInSerial } from '@nx/devkit';
import { addSwcDependencies } from '@nx/js/src/utils/swc/add-swc-dependencies';
import { extraEslintDependencies } from '../../../utils/lint';
import { NormalizedSchema } from '../schema';
export async function addLinting(host: Tree, options: NormalizedSchema) {
const tasks: GeneratorCallback[] = [];
if (options.linter === Linter.EsLint) {
const lintTask = await lintProjectGenerator(host, {
linter: options.linter,
project: options.projectName,
tsConfigPaths: [
joinPathFragments(options.appProjectRoot, 'tsconfig.app.json'),
],
unitTestRunner: options.unitTestRunner,
skipFormat: true,
rootProject: options.rootProject,
skipPackageJson: options.skipPackageJson,
addPlugin: options.addPlugin,
});
tasks.push(lintTask);
if (isEslintConfigSupported(host)) {
if (useFlatConfig(host)) {
addPredefinedConfigToFlatLintConfig(
host,
options.appProjectRoot,
'flat/react'
);
// Add an empty rules object to users know how to add/override rules
addOverrideToLintConfig(host, options.appProjectRoot, {
files: ['*.ts', '*.tsx', '*.js', '*.jsx'],
rules: {},
});
} else {
const addExtendsTask = addExtendsToLintConfig(
host,
options.appProjectRoot,
{ name: 'plugin:@nx/react', needCompatFixup: true }
);
tasks.push(addExtendsTask);
}
}
if (!options.skipPackageJson) {
const installTask = addDependenciesToPackageJson(
host,
extraEslintDependencies.dependencies,
extraEslintDependencies.devDependencies
);
const addSwcTask = addSwcDependencies(host);
tasks.push(installTask, addSwcTask);
}
}
return runTasksInSerial(...tasks);
}

View File

@ -0,0 +1,109 @@
import {
type Tree,
ensurePackage,
joinPathFragments,
addDependenciesToPackageJson,
} from '@nx/devkit';
import { nxVersion } from '../../../../utils/versions';
import { maybeJs } from '../../../../utils/maybe-js';
import { NormalizedSchema, Schema } from '../../schema';
export async function initRsbuild(
tree: Tree,
options: NormalizedSchema<Schema>,
tasks: any[]
) {
ensurePackage('@nx/rsbuild', nxVersion);
const { initGenerator } = await import('@nx/rsbuild/generators');
const initTask = await initGenerator(tree, {
skipPackageJson: options.skipPackageJson,
addPlugin: true,
skipFormat: true,
});
tasks.push(initTask);
}
export async function setupRsbuildConfiguration(
tree: Tree,
options: NormalizedSchema<Schema>,
tasks: any[]
) {
ensurePackage('@nx/rsbuild', nxVersion);
const { configurationGenerator } = await import('@nx/rsbuild/generators');
const {
addBuildPlugin,
addCopyAssets,
addHtmlTemplatePath,
addExperimentalSwcPlugin,
versions,
} = await import('@nx/rsbuild/config-utils');
const rsbuildTask = await configurationGenerator(tree, {
project: options.projectName,
entry: maybeJs(
{
js: options.js,
useJsx: true,
},
`./src/main.tsx`
),
tsConfig: './tsconfig.app.json',
target: 'web',
devServerPort: options.devServerPort ?? 4200,
});
tasks.push(rsbuildTask);
const pathToConfigFile = joinPathFragments(
options.appProjectRoot,
'rsbuild.config.ts'
);
const deps = { '@rsbuild/plugin-react': versions.rsbuildPluginReactVersion };
addBuildPlugin(
tree,
pathToConfigFile,
'@rsbuild/plugin-react',
'pluginReact',
options.style === '@emotion/styled'
? `swcReactOptions: {\n\timportSource: '@emotion/react',\n}`
: undefined
);
if (options.style === 'scss') {
addBuildPlugin(
tree,
pathToConfigFile,
'@rsbuild/plugin-sass',
'pluginSass'
);
deps['@rsbuild/plugin-sass'] = versions.rsbuildPluginSassVersion;
} else if (options.style === 'less') {
addBuildPlugin(
tree,
pathToConfigFile,
'@rsbuild/plugin-less',
'pluginLess'
);
deps['@rsbuild/plugin-less'] = versions.rsbuildPluginLessVersion;
} else if (options.style === '@emotion/styled') {
deps['@swc/plugin-emotion'] = versions.rsbuildSwcPluginEmotionVersion;
addExperimentalSwcPlugin(tree, pathToConfigFile, '@swc/plugin-emotion');
} else if (options.style === 'styled-jsx') {
deps['@swc/plugin-styled-jsx'] = versions.rsbuildSwcPluginStyledJsxVersion;
addExperimentalSwcPlugin(tree, pathToConfigFile, '@swc/plugin-styled-jsx');
} else if (options.style === 'styled-components') {
deps['@rsbuild/plugin-styled-components'] =
versions.rsbuildPluginStyledComponentsVersion;
addBuildPlugin(
tree,
pathToConfigFile,
'@rsbuild/plugin-styled-components',
'pluginStyledComponents'
);
}
addHtmlTemplatePath(tree, pathToConfigFile, './src/index.html');
addCopyAssets(tree, pathToConfigFile, './src/assets');
addCopyAssets(tree, pathToConfigFile, './src/favicon.ico');
tasks.push(addDependenciesToPackageJson(tree, {}, deps));
}

View File

@ -0,0 +1,96 @@
import {
type Tree,
ensurePackage,
joinPathFragments,
logger,
addDependenciesToPackageJson,
stripIndents,
} from '@nx/devkit';
import * as pc from 'picocolors';
import { babelLoaderVersion, nxVersion } from '../../../../utils/versions';
import { maybeJs } from '../../../../utils/maybe-js';
import { NormalizedSchema, Schema } from '../../schema';
export async function initRspack(
tree: Tree,
options: NormalizedSchema<Schema>,
tasks: any[]
) {
const { rspackInitGenerator } = ensurePackage('@nx/rspack', nxVersion);
const rspackInitTask = await rspackInitGenerator(tree, {
...options,
addPlugin: false,
skipFormat: true,
});
tasks.push(rspackInitTask);
}
export async function setupRspackConfiguration(
tree: Tree,
options: NormalizedSchema<Schema>,
tasks: any[]
) {
const { configurationGenerator } = ensurePackage('@nx/rspack', nxVersion);
const rspackTask = await configurationGenerator(tree, {
project: options.projectName,
main: joinPathFragments(
options.appProjectRoot,
maybeJs(
{
js: options.js,
useJsx: true,
},
`src/main.tsx`
)
),
tsConfig: joinPathFragments(options.appProjectRoot, 'tsconfig.app.json'),
target: 'web',
newProject: true,
framework: 'react',
});
tasks.push(rspackTask);
}
export function handleStyledJsxForRspack(
tasks: any[],
tree: Tree,
options: NormalizedSchema<Schema>
) {
logger.warn(
`${pc.bold('styled-jsx')} is not supported by ${pc.bold(
'Rspack'
)}. We've added ${pc.bold(
'babel-loader'
)} to your project, but using babel will slow down your build.`
);
tasks.push(
addDependenciesToPackageJson(
tree,
{},
{ 'babel-loader': babelLoaderVersion }
)
);
tree.write(
joinPathFragments(options.appProjectRoot, 'rspack.config.js'),
stripIndents`
const { composePlugins, withNx, withReact } = require('@nx/rspack');
module.exports = composePlugins(withNx(), withReact(), (config) => {
config.module.rules.push({
test: /\\.[jt]sx$/i,
use: [
{
loader: 'babel-loader',
options: {
presets: ['@babel/preset-typescript'],
plugins: ['styled-jsx/babel'],
},
},
],
});
return config;
});
`
);
}

View File

@ -0,0 +1,93 @@
import { type Tree, ensurePackage, joinPathFragments } from '@nx/devkit';
import { nxVersion } from '../../../../utils/versions';
import { NormalizedSchema, Schema } from '../../schema';
export async function setupViteConfiguration(
tree: Tree,
options: NormalizedSchema<Schema>,
tasks: any[]
) {
const { createOrEditViteConfig, viteConfigurationGenerator } = ensurePackage<
typeof import('@nx/vite')
>('@nx/vite', nxVersion);
// We recommend users use `import.meta.env.MODE` and other variables in their code to differentiate between production and development.
// See: https://vitejs.dev/guide/env-and-mode.html
if (
tree.exists(joinPathFragments(options.appProjectRoot, 'src/environments'))
) {
tree.delete(joinPathFragments(options.appProjectRoot, 'src/environments'));
}
const viteTask = await viteConfigurationGenerator(tree, {
uiFramework: 'react',
project: options.projectName,
newProject: true,
includeVitest: options.unitTestRunner === 'vitest',
inSourceTests: options.inSourceTests,
compiler: options.compiler,
skipFormat: true,
addPlugin: options.addPlugin,
projectType: 'application',
});
tasks.push(viteTask);
createOrEditViteConfig(
tree,
{
project: options.projectName,
includeLib: false,
includeVitest: options.unitTestRunner === 'vitest',
inSourceTests: options.inSourceTests,
rollupOptionsExternal: ["'react'", "'react-dom'", "'react/jsx-runtime'"],
imports: [
options.compiler === 'swc'
? `import react from '@vitejs/plugin-react-swc'`
: `import react from '@vitejs/plugin-react'`,
],
plugins: ['react()'],
},
false
);
}
export async function setupVitestConfiguration(
tree: Tree,
options: NormalizedSchema<Schema>,
tasks: any[]
) {
const { createOrEditViteConfig, vitestGenerator } = ensurePackage<
typeof import('@nx/vite')
>('@nx/vite', nxVersion);
const vitestTask = await vitestGenerator(tree, {
uiFramework: 'react',
coverageProvider: 'v8',
project: options.projectName,
inSourceTests: options.inSourceTests,
skipFormat: true,
addPlugin: options.addPlugin,
});
tasks.push(vitestTask);
createOrEditViteConfig(
tree,
{
project: options.projectName,
includeLib: false,
includeVitest: true,
inSourceTests: options.inSourceTests,
rollupOptionsExternal: ["'react'", "'react-dom'", "'react/jsx-runtime'"],
imports: [
options.compiler === 'swc'
? `import react from '@vitejs/plugin-react-swc'`
: `import react from '@vitejs/plugin-react'`,
],
plugins: ['react()'],
},
true
);
if (options.bundler === 'rsbuild') {
tree.rename(
joinPathFragments(options.appProjectRoot, 'vite.config.ts'),
joinPathFragments(options.appProjectRoot, 'vitest.config.ts')
);
}
}

View File

@ -0,0 +1,26 @@
import { type Tree, ensurePackage } from '@nx/devkit';
import { nxVersion } from '../../../../utils/versions';
import { Schema, NormalizedSchema } from '../../schema';
export async function initWebpack(
tree: Tree,
options: NormalizedSchema<Schema>,
tasks: any[]
) {
const { webpackInitGenerator } = ensurePackage<typeof import('@nx/webpack')>(
'@nx/webpack',
nxVersion
);
const webpackInitTask = await webpackInitGenerator(tree, {
skipPackageJson: options.skipPackageJson,
skipFormat: true,
addPlugin: options.addPlugin,
});
tasks.push(webpackInitTask);
if (!options.skipPackageJson) {
const { ensureDependencies } = await import(
'@nx/webpack/src/utils/ensure-dependencies'
);
tasks.push(ensureDependencies(tree, { uiFramework: 'react' }));
}
}

View File

@ -155,6 +155,15 @@ export async function createApplicationFiles(
: null,
}
);
} else if (options.bundler === 'rsbuild') {
generateFiles(
host,
join(__dirname, '../files/base-rsbuild'),
options.appProjectRoot,
{
...templateVariables,
}
);
}
if (

View File

@ -23,7 +23,7 @@ export interface Schema {
devServerPort?: number;
skipPackageJson?: boolean;
rootProject?: boolean;
bundler?: 'webpack' | 'vite' | 'rspack';
bundler?: 'webpack' | 'vite' | 'rspack' | 'rsbuild';
minimal?: boolean;
// Internal options
addPlugin?: boolean;

View File

@ -101,7 +101,7 @@
"bundler": {
"description": "The bundler to use.",
"type": "string",
"enum": ["vite", "webpack", "rspack"],
"enum": ["vite", "webpack", "rspack", "rsbuild"],
"x-prompt": "Which bundler do you want to use to build the application?",
"default": "vite",
"x-priority": "important"

View File

@ -0,0 +1,10 @@
import { readNxJson, Tree } from '@nx/devkit';
export function hasRsbuildPlugin(tree: Tree) {
const nxJson = readNxJson(tree);
return !!nxJson.plugins?.some((p) =>
typeof p === 'string'
? p === '@nx/rsbuild/plugin'
: p.plugin === '@nx/rsbuild/plugin'
);
}

View File

@ -0,0 +1,8 @@
export { addBuildPlugin } from './src/utils/add-build-plugin';
export {
addCopyAssets,
addHtmlTemplatePath,
addExperimentalSwcPlugin,
} from './src/utils/ast-utils';
export * as versions from './src/utils/versions';
export { getRsbuildE2EWebServerInfo } from './src/utils/e2e-web-server-info-utils';

View File

@ -0,0 +1,2 @@
export { configurationGenerator } from './src/generators/configuration/configuration';
export { initGenerator } from './src/generators/init/init';

View File

@ -33,10 +33,32 @@
"@nx/devkit": "file:../devkit",
"@nx/js": "file:../js",
"@rsbuild/core": "1.1.8",
"minimatch": "9.0.3"
"minimatch": "9.0.3",
"@phenomnomnominal/tsquery": "~5.0.1"
},
"peerDependencies": {},
"nx-migrations": {
"migrations": "./migrations.json"
},
"exports": {
".": {
"types": "./index.d.ts",
"default": "./index.js"
},
"./package.json": {
"default": "./package.json"
},
"./generators": {
"types": "./generators.d.ts",
"default": "./generators.js"
},
"./config-utils": {
"types": "./config-utils.d.ts",
"default": "./config-utils.js"
},
"./plugin": {
"types": "./plugin.d.ts",
"default": "./plugin.js"
}
}
}

View File

@ -61,6 +61,9 @@ describe('Rsbuild configuration generator', () => {
index: './src/index.ts'
},
},
server: {
port: 4200
},
output: {
target: 'web',
distPath: {
@ -94,6 +97,9 @@ describe('Rsbuild configuration generator', () => {
index: './src/main.ts'
},
},
server: {
port: 4200
},
output: {
target: 'web',
distPath: {
@ -127,6 +133,9 @@ describe('Rsbuild configuration generator', () => {
index: './src/main.ts'
},
},
server: {
port: 4200
},
output: {
target: 'web',
distPath: {
@ -157,6 +166,9 @@ describe('Rsbuild configuration generator', () => {
},
tsconfigPath: './tsconfig.json',
},
server: {
port: 4200
},
output: {
target: 'web',
distPath: {

View File

@ -18,11 +18,14 @@ import { join } from 'path';
export async function configurationGenerator(tree: Tree, schema: Schema) {
const projectGraph = await createProjectGraphAsync();
const projects = readProjectsConfigurationFromProjectGraph(projectGraph);
const project = projects.projects[schema.project];
let project = projects.projects[schema.project];
if (!project) {
throw new Error(
`Could not find project '${schema.project}'. Please choose a project that exists in the Nx Workspace.`
);
project = readProjectConfiguration(tree, schema.project);
if (!project) {
throw new Error(
`Could not find project '${schema.project}'. Please choose a project that exists in the Nx Workspace.`
);
}
}
const options = await normalizeOptions(tree, schema, project);

View File

@ -7,6 +7,9 @@ export default defineConfig({
},<% if (tsConfig) { %>
tsconfigPath: '<%= tsConfig %>',<% } %>
},
server: {
port: <%= devServerPort %>
},
output: {
target: '<%= target %>',
distPath: {

View File

@ -9,6 +9,7 @@ import { relative } from 'path';
export interface NormalizedOptions extends Schema {
entry: string;
target: 'node' | 'web' | 'web-worker';
devServerPort: number;
tsConfig: string;
projectRoot: string;
}
@ -30,6 +31,7 @@ export async function normalizeOptions(
schema.tsConfig ?? './tsconfig.json',
project.root
),
devServerPort: schema.devServerPort ?? 4200,
projectRoot: project.root,
skipFormat: schema.skipFormat ?? false,
skipValidation: schema.skipValidation ?? false,

View File

@ -2,6 +2,7 @@ export interface Schema {
project: string;
entry?: string;
tsConfig?: string;
devServerPort?: number;
target?: 'node' | 'web' | 'web-worker';
skipValidation?: boolean;
skipFormat?: boolean;

View File

@ -26,6 +26,11 @@
"description": "Path relative to the workspace root for the tsconfig file to build with. Defaults to '<projectRoot>/tsconfig.app.json'.",
"x-priority": "important"
},
"devServerPort": {
"type": "number",
"description": "The port for the dev server to listen on.",
"default": 4200
},
"target": {
"type": "string",
"description": "Target platform for the build, same as the Rsbuild output.target config option.",

View File

@ -124,6 +124,10 @@ describe('@nx/rsbuild/plugin', () => {
},
"preview-serve": {
"command": "rsbuild preview",
"dependsOn": [
"build-something",
"^build-something",
],
"options": {
"args": [
"--mode=production",

View File

@ -193,6 +193,7 @@ async function createRsbuildTargets(
targets[options.previewTargetName] = {
command: `rsbuild preview`,
dependsOn: [`${options.buildTargetName}`, `^${options.buildTargetName}`],
options: {
cwd: projectRoot,
args: ['--mode=production'],

View File

@ -0,0 +1,177 @@
import { addBuildPlugin } from './add-build-plugin';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
describe('addBuildPlugin', () => {
it('should add the plugin to the config file when plugins array does not exist', () => {
// ARRANGE
const tree = createTreeWithEmptyWorkspace();
tree.write(
'apps/my-app/rsbuild.config.ts',
`import { defineConfig } from '@rsbuild/core';
export default defineConfig({
source: {
entry: {
index: './src/index.ts'
},
}
});`
);
// ACT
addBuildPlugin(
tree,
'apps/my-app/rsbuild.config.ts',
'@rsbuild/plugin-less',
'less'
);
// ASSERT
expect(tree.read('apps/my-app/rsbuild.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { less } from '@rsbuild/plugin-less';
import { defineConfig } from '@rsbuild/core';
export default defineConfig({
plugins: [less()],
source: {
entry: {
index: './src/index.ts'
},
}
});"
`);
});
it('should add the plugin to the config file when plugins array exists and has other plugins', () => {
// ARRANGE
const tree = createTreeWithEmptyWorkspace();
tree.write(
'apps/my-app/rsbuild.config.ts',
`import { defineConfig } from '@rsbuild/core';
import { less } from '@rsbuild/plugin-less';
export default defineConfig({
plugins: [less()],
source: {
entry: {
index: './src/index.ts'
},
}
});`
);
// ACT
addBuildPlugin(
tree,
'apps/my-app/rsbuild.config.ts',
'@rsbuild/plugin-react',
'pluginReact'
);
// ASSERT
expect(tree.read('apps/my-app/rsbuild.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { pluginReact } from '@rsbuild/plugin-react';
import { defineConfig } from '@rsbuild/core';
import { less } from '@rsbuild/plugin-less';
export default defineConfig({
plugins: [
less(),
pluginReact()
],
source: {
entry: {
index: './src/index.ts'
},
}
});"
`);
});
it('should add the plugin to the config file when plugins array exists and is empty', () => {
// ARRANGE
const tree = createTreeWithEmptyWorkspace();
tree.write(
'apps/my-app/rsbuild.config.ts',
`import { defineConfig } from '@rsbuild/core';
export default defineConfig({
plugins: [],
source: {
entry: {
index: './src/index.ts'
},
}
});`
);
// ACT
addBuildPlugin(
tree,
'apps/my-app/rsbuild.config.ts',
'@rsbuild/plugin-react',
'pluginReact'
);
// ASSERT
expect(tree.read('apps/my-app/rsbuild.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { pluginReact } from '@rsbuild/plugin-react';
import { defineConfig } from '@rsbuild/core';
export default defineConfig({
plugins: [
pluginReact()
],
source: {
entry: {
index: './src/index.ts'
},
}
});"
`);
});
it('should add the plugin to the config file when plugins doesnt not exist and its being added with options', () => {
// ARRANGE
const tree = createTreeWithEmptyWorkspace();
tree.write(
'apps/my-app/rsbuild.config.ts',
`import { defineConfig } from '@rsbuild/core';
export default defineConfig({
plugins: [],
source: {
entry: {
index: './src/index.ts'
},
}
});`
);
// ACT
addBuildPlugin(
tree,
'apps/my-app/rsbuild.config.ts',
'@rsbuild/plugin-react',
'pluginReact',
`swcReactOptions: {\n\timportSource: '@emotion/react',\n}`
);
// ASSERT
expect(tree.read('apps/my-app/rsbuild.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { pluginReact } from '@rsbuild/plugin-react';
import { defineConfig } from '@rsbuild/core';
export default defineConfig({
plugins: [
pluginReact({
swcReactOptions: {
importSource: '@emotion/react',
}
})
],
source: {
entry: {
index: './src/index.ts'
},
}
});"
`);
});
});

View File

@ -0,0 +1,68 @@
import { type Tree } from '@nx/devkit';
import { tsquery } from '@phenomnomnominal/tsquery';
import { indentBy } from './indent-by';
const DEFINE_CONFIG_SELECTOR =
'CallExpression:has(Identifier[name=defineConfig]) > ObjectLiteralExpression';
const PLUGINS_ARRAY_SELECTOR =
'CallExpression:has(Identifier[name=defineConfig]) PropertyAssignment:has(Identifier[name=plugins]) > ArrayLiteralExpression';
/**
* Adds a plugin to the build configuration.
* @param tree - Nx Devkit Tree
* @param pathToConfigFile - Path to the build configuration file
* @param importPath - Path to the plugin
* @param pluginName - Name of the plugin
* @param options - Optional but should be defined as a string such as `property: {foo: 'bar'}`
*/
export function addBuildPlugin(
tree: Tree,
pathToConfigFile: string,
importPath: string,
pluginName: string,
options?: string
) {
let configContents = tree.read(pathToConfigFile, 'utf-8');
configContents = `import { ${pluginName} } from '${importPath}';
${configContents}`;
const ast = tsquery.ast(configContents);
const pluginsArrayNodes = tsquery(ast, PLUGINS_ARRAY_SELECTOR);
if (pluginsArrayNodes.length === 0) {
const defineConfigNodes = tsquery(ast, DEFINE_CONFIG_SELECTOR);
if (defineConfigNodes.length === 0) {
throw new Error(
`Could not find defineConfig in the config file at ${pathToConfigFile}.`
);
}
const defineConfigNode = defineConfigNodes[0];
configContents = `${configContents.slice(
0,
defineConfigNode.getStart() + 1
)}\n${indentBy(1)(
`plugins: [${pluginName}(${options ?? ''})],`
)}\n\t${configContents.slice(defineConfigNode.getStart() + 1)}`;
} else {
const pluginsArrayNode = pluginsArrayNodes[0];
const pluginsArrayContents = pluginsArrayNode.getText();
const newPluginsArrayContents = `[\n${indentBy(2)(
`${
pluginsArrayContents.length > 2
? `${pluginsArrayContents.slice(
1,
pluginsArrayContents.length - 1
)},\n${pluginName}`
: pluginName
}(${options ? `{\n${indentBy(1)(`${options}`)}\n}` : ''})`
)}\n\t]`;
configContents = `${configContents.slice(
0,
pluginsArrayNode.getStart()
)}${newPluginsArrayContents}${configContents.slice(
pluginsArrayNode.getEnd()
)}`;
}
tree.write(pathToConfigFile, configContents);
}

View File

@ -0,0 +1,437 @@
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import {
addHtmlTemplatePath,
addCopyAssets,
addExperimentalSwcPlugin,
} from './ast-utils';
describe('ast-utils', () => {
describe('addHtmlTemplatePath', () => {
it('should add the template path to the config when html object does not exist', async () => {
// ARRANGE
const tree = createTreeWithEmptyWorkspace();
tree.write(
'apps/my-app/rsbuild.config.ts',
`import {defineConfig} from '@rsbuild/core';
export default defineConfig({
});`
);
// ACT
addHtmlTemplatePath(
tree,
'apps/my-app/rsbuild.config.ts',
'./src/index.html'
);
// ASSERT
expect(tree.read('apps/my-app/rsbuild.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import {defineConfig} from '@rsbuild/core';
export default defineConfig({
html: {
template: './src/index.html'
},
});"
`);
});
it('should add the template path to the config when html object exists but template is not', async () => {
// ARRANGE
const tree = createTreeWithEmptyWorkspace();
tree.write(
'apps/my-app/rsbuild.config.ts',
`import {defineConfig} from '@rsbuild/core';
export default defineConfig({
html: {
otherValue: true
}
});`
);
// ACT
addHtmlTemplatePath(
tree,
'apps/my-app/rsbuild.config.ts',
'./src/index.html'
);
// ASSERT
expect(tree.read('apps/my-app/rsbuild.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import {defineConfig} from '@rsbuild/core';
export default defineConfig({
html: {
template: './src/index.html',
otherValue: true
}
});"
`);
});
it('should add the template path to the config when html object exists along with template', async () => {
// ARRANGE
const tree = createTreeWithEmptyWorkspace();
tree.write(
'apps/my-app/rsbuild.config.ts',
`import {defineConfig} from '@rsbuild/core';
export default defineConfig({
html: {
template: 'my.html'
}
});`
);
// ACT
addHtmlTemplatePath(
tree,
'apps/my-app/rsbuild.config.ts',
'./src/index.html'
);
// ASSERT
expect(tree.read('apps/my-app/rsbuild.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import {defineConfig} from '@rsbuild/core';
export default defineConfig({
html: {
template: './src/index.html',
}
});"
`);
});
});
describe('addCopyAssets', () => {
it('should add the copy path to the config when output object does not exist', async () => {
// ARRANGE
const tree = createTreeWithEmptyWorkspace();
tree.write(
'apps/my-app/rsbuild.config.ts',
`import {defineConfig} from '@rsbuild/core';
export default defineConfig({
});`
);
// ACT
addCopyAssets(tree, 'apps/my-app/rsbuild.config.ts', './src/assets');
// ASSERT
expect(tree.read('apps/my-app/rsbuild.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import {defineConfig} from '@rsbuild/core';
export default defineConfig({
output: {
copy: [{ from: './src/assets' }],
},
});"
`);
});
it('should add the copy path to the config when outout object exists but copy does not', async () => {
// ARRANGE
const tree = createTreeWithEmptyWorkspace();
tree.write(
'apps/my-app/rsbuild.config.ts',
`import {defineConfig} from '@rsbuild/core';
export default defineConfig({
output: {
distPath: {
root: 'dist',
}
}
});`
);
// ACT
addCopyAssets(tree, 'apps/my-app/rsbuild.config.ts', './src/assets');
// ASSERT
expect(tree.read('apps/my-app/rsbuild.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import {defineConfig} from '@rsbuild/core';
export default defineConfig({
output: {
copy: [{ from: './src/assets' }],
distPath: {
root: 'dist',
}
}
});"
`);
});
it('should add the copy path to the config when output object exists along with copy object', async () => {
// ARRANGE
const tree = createTreeWithEmptyWorkspace();
tree.write(
'apps/my-app/rsbuild.config.ts',
`import {defineConfig} from '@rsbuild/core';
export default defineConfig({
output: {
copy: [
{ from: './src/assets' }
]
}
});`
);
// ACT
addCopyAssets(tree, 'apps/my-app/rsbuild.config.ts', './src/favicon.ico');
// ASSERT
expect(tree.read('apps/my-app/rsbuild.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import {defineConfig} from '@rsbuild/core';
export default defineConfig({
output: {
copy: [
{ from: './src/favicon.ico' },
{ from: './src/assets' }
]
}
});"
`);
});
});
describe('addExperimentalSwcPlugin', () => {
it('should add the swc plugin to the config when tools object does not exist', async () => {
// ARRANGE
const tree = createTreeWithEmptyWorkspace();
tree.write(
'apps/my-app/rsbuild.config.ts',
`import {defineConfig} from '@rsbuild/core';\nexport default defineConfig({\n});`
);
// ACT
addExperimentalSwcPlugin(
tree,
'apps/my-app/rsbuild.config.ts',
'@swc/plugin-emotion'
);
// ASSERT
expect(tree.read('apps/my-app/rsbuild.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import {defineConfig} from '@rsbuild/core';
export default defineConfig({
tools: {
swc: {
jsc: {
experimental: {
plugins: [
['@swc/plugin-emotion', {}],
],
},
},
},
},
});"
`);
});
it('should add the swc plugin to the config when swc object does not exist', async () => {
// ARRANGE
const tree = createTreeWithEmptyWorkspace();
tree.write(
'apps/my-app/rsbuild.config.ts',
`import {defineConfig} from '@rsbuild/core';\nexport default defineConfig({\n\ttools: {}\n});`
);
// ACT
addExperimentalSwcPlugin(
tree,
'apps/my-app/rsbuild.config.ts',
'@swc/plugin-emotion'
);
// ASSERT
expect(tree.read('apps/my-app/rsbuild.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import {defineConfig} from '@rsbuild/core';
export default defineConfig({
tools: {
swc: {
jsc: {
experimental: {
plugins: [
['@swc/plugin-emotion', {}],
],
},
},
},
}
});"
`);
});
it('should add the swc plugin to the config when jsc object does not exist', async () => {
// ARRANGE
const tree = createTreeWithEmptyWorkspace();
tree.write(
'apps/my-app/rsbuild.config.ts',
`import {defineConfig} from '@rsbuild/core';\nexport default defineConfig({\n\ttools: {\n\t\tswc: {}\n\t}\n});`
);
// ACT
addExperimentalSwcPlugin(
tree,
'apps/my-app/rsbuild.config.ts',
'@swc/plugin-emotion'
);
// ASSERT
expect(tree.read('apps/my-app/rsbuild.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import {defineConfig} from '@rsbuild/core';
export default defineConfig({
tools: {
swc: {
jsc: {
experimental: {
plugins: [
['@swc/plugin-emotion', {}],
],
},
},
}
}
});"
`);
});
it('should add the swc plugin to the config when experimental object does not exist', async () => {
// ARRANGE
const tree = createTreeWithEmptyWorkspace();
tree.write(
'apps/my-app/rsbuild.config.ts',
`import {defineConfig} from '@rsbuild/core';
export default defineConfig({
tools: {
swc: {
jsc: {}
}
}
});`
);
// ACT
addExperimentalSwcPlugin(
tree,
'apps/my-app/rsbuild.config.ts',
'@swc/plugin-emotion'
);
// ASSERT
expect(tree.read('apps/my-app/rsbuild.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import {defineConfig} from '@rsbuild/core';
export default defineConfig({
tools: {
swc: {
jsc: {
experimental: {
plugins: [
['@swc/plugin-emotion', {}],
],
},
}
}
}
});"
`);
});
it('should add the swc plugin to the config when plugins array does not exist', async () => {
// ARRANGE
const tree = createTreeWithEmptyWorkspace();
tree.write(
'apps/my-app/rsbuild.config.ts',
`import {defineConfig} from '@rsbuild/core';
export default defineConfig({
tools: {
swc: {
jsc: {
experimental: {}
}
}
}
});`
);
// ACT
addExperimentalSwcPlugin(
tree,
'apps/my-app/rsbuild.config.ts',
'@swc/plugin-emotion'
);
// ASSERT
expect(tree.read('apps/my-app/rsbuild.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import {defineConfig} from '@rsbuild/core';
export default defineConfig({
tools: {
swc: {
jsc: {
experimental: {
plugins: [
['@swc/plugin-emotion', {}],
],
}
}
}
}
});"
`);
});
it('should add the swc plugin to the config when plugins array does exist', async () => {
// ARRANGE
const tree = createTreeWithEmptyWorkspace();
tree.write(
'apps/my-app/rsbuild.config.ts',
`import {defineConfig} from '@rsbuild/core';
export default defineConfig({
tools: {
swc: {
jsc: {
experimental: {
plugins: [['@swc/plugin-styled-jsx', {}]]
}
}
}
}
});`
);
// ACT
addExperimentalSwcPlugin(
tree,
'apps/my-app/rsbuild.config.ts',
'@swc/plugin-emotion'
);
// ASSERT
expect(tree.read('apps/my-app/rsbuild.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import {defineConfig} from '@rsbuild/core';
export default defineConfig({
tools: {
swc: {
jsc: {
experimental: {
plugins: [
['@swc/plugin-emotion', {}],
['@swc/plugin-styled-jsx', {}]]
}
}
}
}
});"
`);
});
});
});

View File

@ -0,0 +1,217 @@
import { type Tree } from '@nx/devkit';
import { indentBy } from './indent-by';
import { tsquery } from '@phenomnomnominal/tsquery';
const DEFINE_CONFIG_SELECTOR =
'CallExpression:has(Identifier[name=defineConfig]) > ObjectLiteralExpression';
export function addHtmlTemplatePath(
tree: Tree,
configFilePath: string,
templatePath: string
) {
const HTML_CONFIG_SELECTOR =
'CallExpression:has(Identifier[name=defineConfig]) ObjectLiteralExpression PropertyAssignment:has(Identifier[name=html]) > ObjectLiteralExpression';
const TEMPLATE_STRING_SELECTOR =
'PropertyAssignment:has(Identifier[name=template]) > StringLiteral';
let configContents = tree.read(configFilePath, 'utf-8');
const ast = tsquery.ast(configContents);
const htmlConfigNodes = tsquery(ast, HTML_CONFIG_SELECTOR);
if (htmlConfigNodes.length === 0) {
const defineConfigNodes = tsquery(ast, DEFINE_CONFIG_SELECTOR);
if (defineConfigNodes.length === 0) {
throw new Error(
`Could not find 'defineConfig' in the config file at ${configFilePath}.`
);
}
const defineConfigNode = defineConfigNodes[0];
configContents = `${configContents.slice(
0,
defineConfigNode.getStart() + 1
)}\n${indentBy(1)(
`html: {\n${indentBy(1)(`template: '${templatePath}'`)}\n},`
)}\t${configContents.slice(defineConfigNode.getStart() + 1)}`;
} else {
const htmlConfigNode = htmlConfigNodes[0];
const templateStringNodes = tsquery(
htmlConfigNode,
TEMPLATE_STRING_SELECTOR
);
if (templateStringNodes.length === 0) {
configContents = `${configContents.slice(
0,
htmlConfigNode.getStart() + 1
)}\n${indentBy(2)(
`template: '${templatePath}',`
)}\n\t\t${configContents.slice(htmlConfigNode.getStart() + 1)}`;
} else {
const templateStringNode = templateStringNodes[0];
configContents = `${configContents.slice(
0,
templateStringNode.getStart()
)}'${templatePath}',${configContents.slice(templateStringNode.getEnd())}`;
}
}
tree.write(configFilePath, configContents);
}
export function addCopyAssets(
tree: Tree,
configFilePath: string,
from: string
) {
const OUTPUT_CONFIG_SELECTOR =
'CallExpression:has(Identifier[name=defineConfig]) ObjectLiteralExpression PropertyAssignment:has(Identifier[name=output]) > ObjectLiteralExpression';
const COPY_ARRAY_SELECTOR =
'PropertyAssignment:has(Identifier[name=copy]) > ArrayLiteralExpression';
const copyAssetArrayElement = `{ from: '${from}' }`;
let configContents = tree.read(configFilePath, 'utf-8');
const ast = tsquery.ast(configContents);
const outputConfigNodes = tsquery(ast, OUTPUT_CONFIG_SELECTOR);
if (outputConfigNodes.length === 0) {
const defineConfigNodes = tsquery(ast, DEFINE_CONFIG_SELECTOR);
if (defineConfigNodes.length === 0) {
throw new Error(
`Could not find 'defineConfig' in the config file at ${configFilePath}.`
);
}
const defineConfigNode = defineConfigNodes[0];
configContents = `${configContents.slice(
0,
defineConfigNode.getStart() + 1
)}\n${indentBy(1)(
`output: {\n${indentBy(2)(`copy: [${copyAssetArrayElement}]`)},\n}`
)},${configContents.slice(defineConfigNode.getStart() + 1)}`;
} else {
const outputConfigNode = outputConfigNodes[0];
const copyAssetsArrayNodes = tsquery(outputConfigNode, COPY_ARRAY_SELECTOR);
if (copyAssetsArrayNodes.length === 0) {
configContents = `${configContents.slice(
0,
outputConfigNode.getStart() + 1
)}\n${indentBy(2)(
`copy: [${copyAssetArrayElement}],`
)}\n\t${configContents.slice(outputConfigNode.getStart() + 1)}`;
} else {
const copyAssetsArrayNode = copyAssetsArrayNodes[0];
configContents = `${configContents.slice(
0,
copyAssetsArrayNode.getStart() + 1
)}\n${indentBy(2)(copyAssetArrayElement)},\n\t\t${configContents.slice(
copyAssetsArrayNode.getStart() + 1
)}`;
}
}
tree.write(configFilePath, configContents);
}
export function addExperimentalSwcPlugin(
tree: Tree,
configFilePath: string,
pluginName: string
) {
const SWC_JSC_EXPERIMENTAL_PLUGIN_ARRAY_SELECTOR =
'CallExpression:has(Identifier[name=defineConfig]) ObjectLiteralExpression PropertyAssignment:has(Identifier[name=tools]) PropertyAssignment:has(Identifier[name=swc]) PropertyAssignment:has(Identifier[name=jsc]) PropertyAssignment:has(Identifier[name=experimental]) PropertyAssignment:has(Identifier[name=plugins]) > ArrayLiteralExpression';
const SWC_JSC_EXPERIMENTAL_SELECTOR =
'CallExpression:has(Identifier[name=defineConfig]) ObjectLiteralExpression PropertyAssignment:has(Identifier[name=tools]) PropertyAssignment:has(Identifier[name=swc]) PropertyAssignment:has(Identifier[name=jsc]) PropertyAssignment:has(Identifier[name=experimental]) > ObjectLiteralExpression';
const SWC_JSC_SELECTOR =
'CallExpression:has(Identifier[name=defineConfig]) ObjectLiteralExpression PropertyAssignment:has(Identifier[name=tools]) PropertyAssignment:has(Identifier[name=swc]) PropertyAssignment:has(Identifier[name=jsc]) > ObjectLiteralExpression';
const SWC_SELECTOR =
'CallExpression:has(Identifier[name=defineConfig]) ObjectLiteralExpression PropertyAssignment:has(Identifier[name=tools]) PropertyAssignment:has(Identifier[name=swc]) > ObjectLiteralExpression';
const TOOLS_SELECTOR =
'CallExpression:has(Identifier[name=defineConfig]) ObjectLiteralExpression PropertyAssignment:has(Identifier[name=tools]) > ObjectLiteralExpression';
let configContents = tree.read(configFilePath, 'utf-8');
const ast = tsquery.ast(configContents);
const pluginToAdd = indentBy(1)(`['${pluginName}', {}],`);
const pluginsArrayToAdd = indentBy(1)(`plugins: [\n${pluginToAdd}\n],`);
const experimentalObjectToAdd = indentBy(1)(
`experimental: {\n${pluginsArrayToAdd} \n},`
);
const jscObjectToAdd = indentBy(1)(`jsc: {\n${experimentalObjectToAdd}\n},`);
const swcObjectToAdd = indentBy(1)(`swc: {\n${jscObjectToAdd}\n},`);
const toolsObjectToAdd = indentBy(1)(`tools: {\n${swcObjectToAdd}\n},`);
const toolsNodes = tsquery(ast, TOOLS_SELECTOR);
if (toolsNodes.length === 0) {
const defineConfigNodes = tsquery(ast, DEFINE_CONFIG_SELECTOR);
if (defineConfigNodes.length === 0) {
throw new Error(
`Could not find 'defineConfig' in the config file at ${configFilePath}.`
);
}
const defineConfigNode = defineConfigNodes[0];
configContents = `${configContents.slice(
0,
defineConfigNode.getStart() + 1
)}\n${toolsObjectToAdd}${configContents.slice(
defineConfigNode.getStart() + 1
)}`;
} else {
const swcNodes = tsquery(ast, SWC_SELECTOR);
if (swcNodes.length === 0) {
const toolsNode = toolsNodes[0];
configContents = `${configContents.slice(
0,
toolsNode.getStart() + 1
)}\n${indentBy(1)(swcObjectToAdd)}\n\t${configContents.slice(
toolsNode.getStart() + 1
)}`;
} else {
const jscNodes = tsquery(ast, SWC_JSC_SELECTOR);
if (jscNodes.length === 0) {
const swcNode = swcNodes[0];
configContents = `${configContents.slice(
0,
swcNode.getStart() + 1
)}\n${indentBy(2)(jscObjectToAdd)}\n\t\t${configContents.slice(
swcNode.getStart() + 1
)}`;
} else {
const experimentalNodes = tsquery(ast, SWC_JSC_EXPERIMENTAL_SELECTOR);
if (experimentalNodes.length === 0) {
const jscNode = jscNodes[0];
configContents = `${configContents.slice(
0,
jscNode.getStart() + 1
)}\n${indentBy(3)(
experimentalObjectToAdd
)}\n\t\t\t${configContents.slice(jscNode.getStart() + 1)}`;
} else {
const pluginsArrayNodes = tsquery(
ast,
SWC_JSC_EXPERIMENTAL_PLUGIN_ARRAY_SELECTOR
);
if (pluginsArrayNodes.length === 0) {
const experimentalNode = experimentalNodes[0];
configContents = `${configContents.slice(
0,
experimentalNode.getStart() + 1
)}\n${indentBy(4)(
pluginsArrayToAdd
)}\n\t\t\t\t${configContents.slice(
experimentalNode.getStart() + 1
)}`;
} else {
const pluginsArrayNode = pluginsArrayNodes[0];
configContents = `${configContents.slice(
0,
pluginsArrayNode.getStart() + 1
)}\n${indentBy(4)(pluginToAdd)}\n\t\t\t\t\t${configContents.slice(
pluginsArrayNode.getStart() + 1
)}`;
}
}
}
}
}
tree.write(configFilePath, configContents);
}

View File

@ -0,0 +1,39 @@
import { type Tree, readNxJson } from '@nx/devkit';
import { getE2EWebServerInfo } from '@nx/devkit/src/generators/e2e-web-server-info-utils';
export async function getRsbuildE2EWebServerInfo(
tree: Tree,
projectName: string,
configFilePath: string,
isPluginBeingAdded: boolean,
e2ePortOverride?: number
) {
const nxJson = readNxJson(tree);
let e2ePort = e2ePortOverride ?? 4200;
if (
nxJson.targetDefaults?.['dev'] &&
nxJson.targetDefaults?.['dev'].options?.port
) {
e2ePort = nxJson.targetDefaults?.['dev'].options?.port;
}
return getE2EWebServerInfo(
tree,
projectName,
{
plugin: '@nx/rsbuild/plugin',
serveTargetName: 'devTargetName',
serveStaticTargetName: 'previewTargetName',
configFilePath,
},
{
defaultServeTargetName: 'dev',
defaultServeStaticTargetName: 'preview',
defaultE2EWebServerAddress: `http://localhost:${e2ePort}`,
defaultE2ECiBaseUrl: 'http://localhost:4200',
defaultE2EPort: e2ePort,
},
isPluginBeingAdded
);
}

View File

@ -0,0 +1,9 @@
export function indentBy(tabNumber: number) {
return (str: string) => {
const indentation = '\t'.repeat(tabNumber);
return str
.split('\n')
.map((line) => `${indentation}${line}`)
.join('\n');
};
}

View File

@ -1,2 +1,17 @@
export const nxVersion = require('../../package.json').version;
export const rsbuildVersion = '1.1.8';
export const rsbuildVersion = '1.1.10';
export const rsbuildPluginReactVersion = '1.1.0';
export const rsbuildPluginVueVersion = '1.0.5';
export const rsbuildPluginSassVersion = '1.1.2';
export const rsbuildPluginLessVersion = '1.1.0';
export const rsbuildPluginStyledComponentsVersion = '1.1.0';
/**
* These versions need to line up with the version of the swc_core crate Rspack uses for the version of Rsbuild above
* Checking the `cargo.toml` at https://github.com/web-infra-dev/rspack/blob/main/Cargo.toml for the correct Rspack version
* is the best way to ensure that these versions are correct.
*
* The release notes for the packages below are very helpful in understanding what version of swc_core crate they require.
*/
export const rsbuildSwcPluginEmotionVersion = '^7.0.3';
export const rsbuildSwcPluginStyledJsxVersion = '^5.0.2';

View File

@ -35,6 +35,7 @@
"@nx/cypress",
"@nx/playwright",
"@nx/storybook",
"@nx/rsbuild",
"eslint"
]
}

View File

@ -143,6 +143,66 @@ export default defineConfig({
"
`;
exports[`application generator should set up project correctly for rsbuild 1`] = `
"import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default defineConfig({
root: __dirname,
cacheDir: '../node_modules/.vite/test',
plugins: [nxViteTsPaths(), nxCopyAssetsPlugin(['*.md']), vue()],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
coverage: { reportsDirectory: '../coverage/test', provider: 'v8' },
},
});
"
`;
exports[`application generator should set up project correctly for rsbuild 3`] = `
[
".eslintignore",
".eslintrc.json",
".gitignore",
".prettierignore",
".prettierrc",
".vscode/extensions.json",
"nx.json",
"package.json",
"test-e2e/.eslintrc.json",
"test-e2e/playwright.config.ts",
"test-e2e/project.json",
"test-e2e/src/example.spec.ts",
"test-e2e/tsconfig.json",
"test/.eslintrc.json",
"test/index.html",
"test/project.json",
"test/rsbuild.config.ts",
"test/src/app/App.spec.ts",
"test/src/app/App.vue",
"test/src/app/NxWelcome.vue",
"test/src/main.ts",
"test/src/styles.css",
"test/src/vue-shims.d.ts",
"test/tsconfig.app.json",
"test/tsconfig.json",
"test/tsconfig.spec.json",
"test/vitest.config.ts",
"tsconfig.base.json",
"vitest.workspace.ts",
]
`;
exports[`application generator should set up project correctly with given options 1`] = `
"{
"root": true,

View File

@ -69,6 +69,67 @@ describe('application generator', () => {
`);
});
it('should set up project correctly for rsbuild', async () => {
const nxJson = readNxJson(tree);
nxJson.plugins ??= [];
updateNxJson(tree, nxJson);
await applicationGenerator(tree, {
...options,
bundler: 'rsbuild',
unitTestRunner: 'vitest',
e2eTestRunner: 'playwright',
addPlugin: true,
});
expect(tree.read('test/vitest.config.ts', 'utf-8')).toMatchSnapshot();
expect(tree.read('test/rsbuild.config.ts', 'utf-8')).toMatchInlineSnapshot(`
"import { pluginVue } from '@rsbuild/plugin-vue';
import { defineConfig } from '@rsbuild/core';
export default defineConfig({
html: {
template: './index.html',
},
plugins: [pluginVue()],
source: {
entry: {
index: './src/main.ts',
},
tsconfigPath: './tsconfig.app.json',
},
server: {
port: 4200,
},
output: {
target: 'web',
distPath: {
root: 'dist',
},
},
});
"
`);
expect(listFiles(tree)).toMatchSnapshot();
expect(
readNxJson(tree).plugins.find(
(p) => typeof p !== 'string' && p.plugin === '@nx/rsbuild/plugin'
)
).toMatchInlineSnapshot(`
{
"options": {
"buildTargetName": "build",
"devTargetName": "dev",
"inspectTargetName": "inspect",
"previewTargetName": "preview",
"typecheckTargetName": "typecheck",
},
"plugin": "@nx/rsbuild/plugin",
}
`);
});
it('should set up project correctly for cypress', async () => {
const nxJson = readNxJson(tree);
nxJson.plugins ??= [];

View File

@ -17,7 +17,8 @@ import { vueInitGenerator } from '../init/init';
import { addLinting } from '../../utils/add-linting';
import { addE2e } from './lib/add-e2e';
import { createApplicationFiles } from './lib/create-application-files';
import { addVite } from './lib/add-vite';
import { addVite, addVitest } from './lib/add-vite';
import { addRsbuild } from './lib/add-rsbuild';
import { extractTsConfigBase } from '../../utils/create-ts-config';
import { ensureDependencies } from '../../utils/ensure-dependencies';
import { logShowProjectCommand } from '@nx/devkit/src/utils/log-show-project-command';
@ -107,7 +108,16 @@ export async function applicationGeneratorInternal(
)
);
tasks.push(await addVite(tree, options));
if (options.bundler === 'rsbuild') {
tasks.push(...(await addRsbuild(tree, options)));
tasks.push(...(await addVitest(tree, options)));
tree.rename(
joinPathFragments(options.appProjectRoot, 'vite.config.ts'),
joinPathFragments(options.appProjectRoot, 'vitest.config.ts')
);
} else {
tasks.push(await addVite(tree, options));
}
tasks.push(await addE2e(tree, options));

View File

@ -2,7 +2,6 @@ import type { GeneratorCallback, Tree } from '@nx/devkit';
import {
addProjectConfiguration,
ensurePackage,
getPackageManagerCommand,
joinPathFragments,
readNxJson,
} from '@nx/devkit';
@ -12,31 +11,56 @@ import { nxVersion } from '../../../utils/versions';
import { NormalizedSchema } from '../schema';
import { findPluginForConfigFile } from '@nx/devkit/src/utils/find-plugin-for-config-file';
import { addE2eCiTargetDefaults } from '@nx/devkit/src/generators/target-defaults-utils';
import { E2EWebServerDetails } from '@nx/devkit/src/generators/e2e-web-server-info-utils';
export async function addE2e(
tree: Tree,
options: NormalizedSchema
): Promise<GeneratorCallback> {
const nxJson = readNxJson(tree);
const hasPlugin = nxJson.plugins?.find((p) =>
typeof p === 'string'
? p === '@nx/vite/plugin'
: p.plugin === '@nx/vite/plugin'
);
const { getViteE2EWebServerInfo } = ensurePackage<typeof import('@nx/vite')>(
'@nx/vite',
nxVersion
);
const e2eWebServerInfo = await getViteE2EWebServerInfo(
tree,
options.projectName,
joinPathFragments(
options.appProjectRoot,
`vite.config.${options.js ? 'js' : 'ts'}`
),
options.addPlugin,
options.devServerPort ?? 4200
);
const hasPlugin =
options.bundler === 'rsbuild'
? nxJson.plugins?.find((p) =>
typeof p === 'string'
? p === '@nx/rsbuild/plugin'
: p.plugin === '@nx/rsbuild/plugin'
)
: nxJson.plugins?.find((p) =>
typeof p === 'string'
? p === '@nx/vite/plugin'
: p.plugin === '@nx/vite/plugin'
);
let e2eWebServerInfo: E2EWebServerDetails;
if (options.bundler === 'vite') {
const { getViteE2EWebServerInfo } = ensurePackage<
typeof import('@nx/vite')
>('@nx/vite', nxVersion);
e2eWebServerInfo = await getViteE2EWebServerInfo(
tree,
options.projectName,
joinPathFragments(
options.appProjectRoot,
`vite.config.${options.js ? 'js' : 'ts'}`
),
options.addPlugin,
options.devServerPort ?? 4200
);
} else if (options.bundler === 'rsbuild') {
ensurePackage('@nx/rsbuild', nxVersion);
const { getRsbuildE2EWebServerInfo } = await import(
'@nx/rsbuild/config-utils'
);
e2eWebServerInfo = await getRsbuildE2EWebServerInfo(
tree,
options.projectName,
joinPathFragments(
options.appProjectRoot,
`rsbuild.config.${options.js ? 'js' : 'ts'}`
),
options.addPlugin,
options.devServerPort ?? 4200
);
}
switch (options.e2eTestRunner) {
case 'cypress': {

View File

@ -0,0 +1,67 @@
import {
type Tree,
type GeneratorCallback,
joinPathFragments,
addDependenciesToPackageJson,
ensurePackage,
} from '@nx/devkit';
import { NormalizedSchema } from '../schema';
import { nxVersion } from '../../../utils/versions';
export async function addRsbuild(tree: Tree, options: NormalizedSchema) {
const tasks: GeneratorCallback[] = [];
ensurePackage('@nx/rsbuild', nxVersion);
const { initGenerator, configurationGenerator } = await import(
'@nx/rsbuild/generators'
);
const initTask = await initGenerator(tree, {
skipPackageJson: options.skipPackageJson,
addPlugin: true,
skipFormat: true,
});
tasks.push(initTask);
const rsbuildTask = await configurationGenerator(tree, {
project: options.projectName,
entry: `./src/main.ts`,
tsConfig: './tsconfig.app.json',
target: 'web',
devServerPort: options.devServerPort ?? 4200,
});
tasks.push(rsbuildTask);
const { addBuildPlugin, addHtmlTemplatePath, versions } = await import(
'@nx/rsbuild/config-utils'
);
const deps = { '@rsbuild/plugin-vue': versions.rsbuildPluginVueVersion };
const pathToConfigFile = joinPathFragments(
options.appProjectRoot,
'rsbuild.config.ts'
);
addBuildPlugin(tree, pathToConfigFile, '@rsbuild/plugin-vue', 'pluginVue');
if (options.style === 'scss') {
addBuildPlugin(
tree,
pathToConfigFile,
'@rsbuild/plugin-sass',
'pluginSass'
);
deps['@rsbuild/plugin-sass'] = versions.rsbuildPluginSassVersion;
} else if (options.style === 'less') {
addBuildPlugin(
tree,
pathToConfigFile,
'@rsbuild/plugin-less',
'pluginLess'
);
deps['@rsbuild/plugin-less'] = versions.rsbuildPluginLessVersion;
}
addHtmlTemplatePath(tree, pathToConfigFile, './index.html');
tasks.push(addDependenciesToPackageJson(tree, {}, deps));
return tasks;
}

View File

@ -4,7 +4,11 @@ import {
Tree,
updateProjectConfiguration,
} from '@nx/devkit';
import { createOrEditViteConfig, viteConfigurationGenerator } from '@nx/vite';
import {
createOrEditViteConfig,
viteConfigurationGenerator,
vitestGenerator,
} from '@nx/vite';
import { NormalizedSchema } from '../schema';
@ -47,3 +51,33 @@ export async function addVite(
return viteTask;
}
export async function addVitest(tree: Tree, options: NormalizedSchema) {
const tasks: GeneratorCallback[] = [];
const vitestTask = await vitestGenerator(tree, {
uiFramework: 'none',
project: options.projectName,
coverageProvider: 'v8',
inSourceTests: options.inSourceTests,
skipFormat: true,
testEnvironment: 'jsdom',
addPlugin: options.addPlugin,
runtimeTsconfigFileName: 'tsconfig.app.json',
});
tasks.push(vitestTask);
createOrEditViteConfig(
tree,
{
project: options.projectName,
includeLib: false,
includeVitest: true,
inSourceTests: options.inSourceTests,
imports: [`import vue from '@vitejs/plugin-vue'`],
plugins: ['vue()'],
},
true
);
return tasks;
}

View File

@ -41,6 +41,7 @@ export async function normalizeOptions(
normalized.unitTestRunner ??= 'vitest';
normalized.e2eTestRunner = normalized.e2eTestRunner ?? 'playwright';
normalized.isUsingTsSolutionConfig = isUsingTsSolutionSetup(host);
normalized.bundler = normalized.bundler ?? 'vite';
return normalized;
}

View File

@ -4,6 +4,7 @@ export interface Schema {
directory: string;
name?: string;
style: 'none' | 'css' | 'scss' | 'less';
bundler?: 'vite' | 'rsbuild';
skipFormat?: boolean;
tags?: string;
unitTestRunner?: 'vitest' | 'none';

View File

@ -60,6 +60,14 @@
]
}
},
"bundler": {
"description": "The bundler to use.",
"type": "string",
"enum": ["vite", "rsbuild"],
"x-prompt": "Which bundler do you want to use to build the application?",
"default": "vite",
"x-priority": "important"
},
"routing": {
"type": "boolean",
"description": "Generate application with routes.",

25
pnpm-lock.yaml generated
View File

@ -337,6 +337,9 @@ importers:
'@nx/react':
specifier: 20.3.0-beta.0
version: 20.3.0-beta.0(@babel/traverse@7.25.9)(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.11))(@swc/types@0.1.12)(typescript@5.6.3))(@swc/core@1.5.7(@swc/helpers@0.5.11))(@swc/helpers@0.5.11)(@types/node@20.16.10)(@zkochan/js-yaml@0.0.7)(esbuild@0.19.5)(eslint@8.57.0)(next@14.2.16(@babel/core@7.25.2)(@playwright/test@1.47.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.55.0))(nx@20.3.0-beta.0(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.11))(@swc/types@0.1.12)(typescript@5.6.3))(@swc/core@1.5.7(@swc/helpers@0.5.11)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3)(verdaccio@5.32.2(encoding@0.1.13)(typanion@3.14.0))(webpack-cli@5.1.4(webpack-dev-server@5.0.4)(webpack@5.88.0))(webpack@5.88.0(@swc/core@1.5.7(@swc/helpers@0.5.11))(esbuild@0.19.5)(webpack-cli@5.1.4))
'@nx/rsbuild':
specifier: 20.3.0-beta.0
version: 20.3.0-beta.0(@babel/traverse@7.25.9)(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.11))(@swc/types@0.1.12)(typescript@5.6.3))(@swc/core@1.5.7(@swc/helpers@0.5.11))(@types/node@20.16.10)(nx@20.3.0-beta.0(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.11))(@swc/types@0.1.12)(typescript@5.6.3))(@swc/core@1.5.7(@swc/helpers@0.5.11)))(typescript@5.6.3)(verdaccio@5.32.2(encoding@0.1.13)(typanion@3.14.0))
'@nx/rspack':
specifier: 20.3.0-beta.0
version: 20.3.0-beta.0(oxln5c2nr22bidmz7io6uliuga)
@ -5262,6 +5265,9 @@ packages:
'@nx/react@20.3.0-beta.0':
resolution: {integrity: sha512-oUaF2NTgP8bKkVz61frF0XhD0Y2W/hMU3Vzdp8N9ew4h28DFTuLdNFITvgHG9vJWsw7+jrQ8DucntuU85QcR9A==}
'@nx/rsbuild@20.3.0-beta.0':
resolution: {integrity: sha512-xmNCtkr5P8YzxwoK18XJ8krOZNtTHXei5vh6tPz/Y02aOcrPEfTwrS0z2gLP3VNd+9i2IZCS8WEyRGW7f3o24g==}
'@nx/rspack@20.3.0-beta.0':
resolution: {integrity: sha512-P6XOMoR8Pv/CtK/w/SRy/wVAJyZcAL6e5w0yLae50lRsNsd+FyNiqybovmpBGXpnre2Ovf6EIzK+jVU97bTgjw==}
peerDependencies:
@ -22641,6 +22647,25 @@ snapshots:
- webpack
- webpack-cli
'@nx/rsbuild@20.3.0-beta.0(@babel/traverse@7.25.9)(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.11))(@swc/types@0.1.12)(typescript@5.6.3))(@swc/core@1.5.7(@swc/helpers@0.5.11))(@types/node@20.16.10)(nx@20.3.0-beta.0(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.11))(@swc/types@0.1.12)(typescript@5.6.3))(@swc/core@1.5.7(@swc/helpers@0.5.11)))(typescript@5.6.3)(verdaccio@5.32.2(encoding@0.1.13)(typanion@3.14.0))':
dependencies:
'@nx/devkit': 20.3.0-beta.0(nx@20.3.0-beta.0(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.11))(@swc/types@0.1.12)(typescript@5.6.3))(@swc/core@1.5.7(@swc/helpers@0.5.11)))
'@nx/js': 20.3.0-beta.0(@babel/traverse@7.25.9)(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.11))(@swc/types@0.1.12)(typescript@5.6.3))(@swc/core@1.5.7(@swc/helpers@0.5.11))(@types/node@20.16.10)(nx@20.3.0-beta.0(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.11))(@swc/types@0.1.12)(typescript@5.6.3))(@swc/core@1.5.7(@swc/helpers@0.5.11)))(typescript@5.6.3)(verdaccio@5.32.2(encoding@0.1.13)(typanion@3.14.0))
'@rsbuild/core': 1.1.8
minimatch: 9.0.3
tslib: 2.8.1
transitivePeerDependencies:
- '@babel/traverse'
- '@swc-node/register'
- '@swc/core'
- '@swc/wasm'
- '@types/node'
- debug
- nx
- supports-color
- typescript
- verdaccio
'@nx/rspack@20.3.0-beta.0(oxln5c2nr22bidmz7io6uliuga)':
dependencies:
'@module-federation/enhanced': 0.7.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3)(webpack@5.88.0(@swc/core@1.5.7(@swc/helpers@0.5.11))(esbuild@0.19.5)(webpack-cli@5.1.4))

View File

@ -132,8 +132,8 @@
"@nx/remix/*": ["packages/remix/*"],
"@nx/rollup": ["packages/rollup"],
"@nx/rollup/*": ["packages/rollup/*"],
"@nx/rsbuild": ["packages/rsbuild/src"],
"@nx/rsbuild/*": ["packages/rsbuild/src/*"],
"@nx/rsbuild": ["packages/rsbuild/"],
"@nx/rsbuild/*": ["packages/rsbuild/*"],
"@nx/rspack": ["packages/rspack/src"],
"@nx/rspack/*": ["packages/rspack/src/*"],
"@nx/storybook": ["packages/storybook"],