diff --git a/docs/generated/packages/react/generators/application.json b/docs/generated/packages/react/generators/application.json index 4237795a6648c..588918160327f 100644 --- a/docs/generated/packages/react/generators/application.json +++ b/docs/generated/packages/react/generators/application.json @@ -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" diff --git a/docs/generated/packages/rsbuild/generators/configuration.json b/docs/generated/packages/rsbuild/generators/configuration.json index 8a787d4eb4781..0854db8457ce9 100644 --- a/docs/generated/packages/rsbuild/generators/configuration.json +++ b/docs/generated/packages/rsbuild/generators/configuration.json @@ -26,6 +26,11 @@ "description": "Path relative to the workspace root for the tsconfig file to build with. Defaults to '/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.", diff --git a/docs/generated/packages/vue/generators/application.json b/docs/generated/packages/vue/generators/application.json index d545250bec98a..38c268c383cbf 100644 --- a/docs/generated/packages/vue/generators/application.json +++ b/docs/generated/packages/vue/generators/application.json @@ -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.", diff --git a/e2e/react/src/react-rsbuild.test.ts b/e2e/react/src/react-rsbuild.test.ts new file mode 100644 index 0000000000000..22f39be4976a5 --- /dev/null +++ b/e2e/react/src/react-rsbuild.test.ts @@ -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); +}); diff --git a/e2e/vue/src/vue.test.ts b/e2e/vue/src/vue.test.ts index 2857d081d57a1..8205c04ad996e 100644 --- a/e2e/vue/src/vue.test.ts +++ b/e2e/vue/src/vue.test.ts @@ -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'); diff --git a/package.json b/package.json index 2072f5688f34a..93edebc1bbd15 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/react/.eslintrc.json b/packages/react/.eslintrc.json index a5f246856375d..c9453aea45e42 100644 --- a/packages/react/.eslintrc.json +++ b/packages/react/.eslintrc.json @@ -58,6 +58,7 @@ "@nx/playwright", "@nx/jest", "@nx/rollup", + "@nx/rsbuild", "@nx/storybook", "@nx/vite", "@nx/webpack", diff --git a/packages/react/src/generators/application/__snapshots__/application.spec.ts.snap b/packages/react/src/generators/application/__snapshots__/application.spec.ts.snap index 9029419e4bad2..0dce36a93dfe7 100644 --- a/packages/react/src/generators/application/__snapshots__/application.spec.ts.snap +++ b/packages/react/src/generators/application/__snapshots__/application.spec.ts.snap @@ -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 ( + + + + ); +} + +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 ( + + + + ); +} + +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 ( +
+ + +
+ ); +} + +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'; diff --git a/packages/react/src/generators/application/application.spec.ts b/packages/react/src/generators/application/application.spec.ts index 3da3698851e8e..2857872b838f9 100644 --- a/packages/react/src/generators/application/application.spec.ts +++ b/packages/react/src/generators/application/application.spec.ts @@ -1442,4 +1442,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(); + } + ); + }); }); diff --git a/packages/react/src/generators/application/application.ts b/packages/react/src/generators/application/application.ts index 88ad5adce58ca..28df726560e48 100644 --- a/packages/react/src/generators/application/application.ts +++ b/packages/react/src/generators/application/application.ts @@ -1,117 +1,63 @@ -import { extraEslintDependencies } from '../../utils/lint'; -import { NormalizedSchema, Schema } from './schema'; -import { createApplicationFiles } from './lib/create-application-files'; -import { updateSpecConfig } from './lib/update-jest-config'; -import { normalizeOptions } from './lib/normalize-options'; -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 { addE2e } from './lib/add-e2e'; -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); -} +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'; +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 { 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 { + 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 { - return await applicationGeneratorInternal(host, { + return await applicationGeneratorInternal(tree, { addPlugin: false, ...schema, }); } export async function applicationGeneratorInternal( - host: Tree, + tree: Tree, schema: Schema ): Promise { 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('@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(() => { diff --git a/packages/react/src/generators/application/files/base-rsbuild/src/app/__fileName__.spec.tsx__tmpl__ b/packages/react/src/generators/application/files/base-rsbuild/src/app/__fileName__.spec.tsx__tmpl__ new file mode 100644 index 0000000000000..5de60696f5158 --- /dev/null +++ b/packages/react/src/generators/application/files/base-rsbuild/src/app/__fileName__.spec.tsx__tmpl__ @@ -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 _%> +}); diff --git a/packages/react/src/generators/application/files/base-rsbuild/src/assets/.gitkeep b/packages/react/src/generators/application/files/base-rsbuild/src/assets/.gitkeep new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/packages/react/src/generators/application/files/base-rsbuild/src/favicon.ico b/packages/react/src/generators/application/files/base-rsbuild/src/favicon.ico new file mode 100644 index 0000000000000..317ebcb2336e0 Binary files /dev/null and b/packages/react/src/generators/application/files/base-rsbuild/src/favicon.ico differ diff --git a/packages/react/src/generators/application/files/base-rsbuild/src/index.html b/packages/react/src/generators/application/files/base-rsbuild/src/index.html new file mode 100644 index 0000000000000..85edca9f5dbfe --- /dev/null +++ b/packages/react/src/generators/application/files/base-rsbuild/src/index.html @@ -0,0 +1,14 @@ + + + + + <%= className %> + + + + + + +
+ + diff --git a/packages/react/src/generators/application/files/base-rsbuild/src/main.tsx__tmpl__ b/packages/react/src/generators/application/files/base-rsbuild/src/main.tsx__tmpl__ new file mode 100644 index 0000000000000..d31437ce4baae --- /dev/null +++ b/packages/react/src/generators/application/files/base-rsbuild/src/main.tsx__tmpl__ @@ -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( + + + +) +<%_ } _%> +<%_ if(!strict && routing) { _%> +root.render( + + + +) +<%_ } _%> +<%_ if(strict && routing) { _%> +root.render( + + + + + +) +<%_ } _%> diff --git a/packages/react/src/generators/application/files/base-rsbuild/tsconfig.app.json__tmpl__ b/packages/react/src/generators/application/files/base-rsbuild/tsconfig.app.json__tmpl__ new file mode 100644 index 0000000000000..8b1bc8bb50853 --- /dev/null +++ b/packages/react/src/generators/application/files/base-rsbuild/tsconfig.app.json__tmpl__ @@ -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"] +} +<% } %> diff --git a/packages/react/src/generators/application/files/style-tailwind/src/app/__fileName__.tsx__tmpl__ b/packages/react/src/generators/application/files/style-tailwind/src/app/__fileName__.tsx__tmpl__ index 55036b7730625..998633e963b5b 100644 --- a/packages/react/src/generators/application/files/style-tailwind/src/app/__fileName__.tsx__tmpl__ +++ b/packages/react/src/generators/application/files/style-tailwind/src/app/__fileName__.tsx__tmpl__ @@ -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'; <%_ } _%> diff --git a/packages/react/src/generators/application/lib/add-e2e.ts b/packages/react/src/generators/application/lib/add-e2e.ts index 2f43036fb6953..3335fa3b3b875 100644 --- a/packages/react/src/generators/application/lib/add-e2e.ts +++ b/packages/react/src/generators/application/lib/add-e2e.ts @@ -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, diff --git a/packages/react/src/generators/application/lib/add-linting.ts b/packages/react/src/generators/application/lib/add-linting.ts new file mode 100644 index 0000000000000..d1ddc390b6d98 --- /dev/null +++ b/packages/react/src/generators/application/lib/add-linting.ts @@ -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); +} diff --git a/packages/react/src/generators/application/lib/bundlers/add-rsbuild.ts b/packages/react/src/generators/application/lib/bundlers/add-rsbuild.ts new file mode 100644 index 0000000000000..8e836191f987e --- /dev/null +++ b/packages/react/src/generators/application/lib/bundlers/add-rsbuild.ts @@ -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, + 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, + 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)); +} diff --git a/packages/react/src/generators/application/lib/bundlers/add-rspack.ts b/packages/react/src/generators/application/lib/bundlers/add-rspack.ts new file mode 100644 index 0000000000000..99dd20d4b6c2b --- /dev/null +++ b/packages/react/src/generators/application/lib/bundlers/add-rspack.ts @@ -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, + 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, + 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 +) { + 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; + }); + ` + ); +} diff --git a/packages/react/src/generators/application/lib/bundlers/add-vite.ts b/packages/react/src/generators/application/lib/bundlers/add-vite.ts new file mode 100644 index 0000000000000..5a1e3175462a9 --- /dev/null +++ b/packages/react/src/generators/application/lib/bundlers/add-vite.ts @@ -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, + 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, + 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') + ); + } +} diff --git a/packages/react/src/generators/application/lib/bundlers/add-webpack.ts b/packages/react/src/generators/application/lib/bundlers/add-webpack.ts new file mode 100644 index 0000000000000..f15e8c0a29a88 --- /dev/null +++ b/packages/react/src/generators/application/lib/bundlers/add-webpack.ts @@ -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, + tasks: any[] +) { + const { webpackInitGenerator } = ensurePackage( + '@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' })); + } +} diff --git a/packages/react/src/generators/application/lib/create-application-files.ts b/packages/react/src/generators/application/lib/create-application-files.ts index d84b28be9aead..5935b106daccb 100644 --- a/packages/react/src/generators/application/lib/create-application-files.ts +++ b/packages/react/src/generators/application/lib/create-application-files.ts @@ -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 ( diff --git a/packages/react/src/generators/application/schema.d.ts b/packages/react/src/generators/application/schema.d.ts index 0dc79aaf1dfbb..55e28d1f11821 100644 --- a/packages/react/src/generators/application/schema.d.ts +++ b/packages/react/src/generators/application/schema.d.ts @@ -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; diff --git a/packages/react/src/generators/application/schema.json b/packages/react/src/generators/application/schema.json index eecbcf0bf36f6..7fa75f15889f7 100644 --- a/packages/react/src/generators/application/schema.json +++ b/packages/react/src/generators/application/schema.json @@ -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" diff --git a/packages/react/src/utils/has-rsbuild-plugin.ts b/packages/react/src/utils/has-rsbuild-plugin.ts new file mode 100644 index 0000000000000..3f53aedbd6cc7 --- /dev/null +++ b/packages/react/src/utils/has-rsbuild-plugin.ts @@ -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' + ); +} diff --git a/packages/rsbuild/config-utils.ts b/packages/rsbuild/config-utils.ts new file mode 100644 index 0000000000000..5bb3ac0938a2a --- /dev/null +++ b/packages/rsbuild/config-utils.ts @@ -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'; diff --git a/packages/rsbuild/generators.ts b/packages/rsbuild/generators.ts new file mode 100644 index 0000000000000..a2b7d6d93e942 --- /dev/null +++ b/packages/rsbuild/generators.ts @@ -0,0 +1,2 @@ +export { configurationGenerator } from './src/generators/configuration/configuration'; +export { initGenerator } from './src/generators/init/init'; diff --git a/packages/rsbuild/package.json b/packages/rsbuild/package.json index 5e285304b66a4..c6efe4082831c 100644 --- a/packages/rsbuild/package.json +++ b/packages/rsbuild/package.json @@ -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" + } } } diff --git a/packages/rsbuild/src/generators/configuration/configuration.spec.ts b/packages/rsbuild/src/generators/configuration/configuration.spec.ts index 6ec421e2d7396..cb5ea6ac3da89 100644 --- a/packages/rsbuild/src/generators/configuration/configuration.spec.ts +++ b/packages/rsbuild/src/generators/configuration/configuration.spec.ts @@ -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: { diff --git a/packages/rsbuild/src/generators/configuration/configuration.ts b/packages/rsbuild/src/generators/configuration/configuration.ts index 85184db79202d..23bce377f68ff 100644 --- a/packages/rsbuild/src/generators/configuration/configuration.ts +++ b/packages/rsbuild/src/generators/configuration/configuration.ts @@ -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); diff --git a/packages/rsbuild/src/generators/configuration/files/rsbuild.config.ts__tpl__ b/packages/rsbuild/src/generators/configuration/files/rsbuild.config.ts__tpl__ index 88c2d5def6980..c5f2ad99c9eb7 100644 --- a/packages/rsbuild/src/generators/configuration/files/rsbuild.config.ts__tpl__ +++ b/packages/rsbuild/src/generators/configuration/files/rsbuild.config.ts__tpl__ @@ -7,6 +7,9 @@ export default defineConfig({ },<% if (tsConfig) { %> tsconfigPath: '<%= tsConfig %>',<% } %> }, + server: { + port: <%= devServerPort %> + }, output: { target: '<%= target %>', distPath: { diff --git a/packages/rsbuild/src/generators/configuration/lib/normalize-options.ts b/packages/rsbuild/src/generators/configuration/lib/normalize-options.ts index 5fcc1150b4a72..7165ec011042f 100644 --- a/packages/rsbuild/src/generators/configuration/lib/normalize-options.ts +++ b/packages/rsbuild/src/generators/configuration/lib/normalize-options.ts @@ -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, diff --git a/packages/rsbuild/src/generators/configuration/schema.d.ts b/packages/rsbuild/src/generators/configuration/schema.d.ts index 6af6677d2b687..8f89e5663087f 100644 --- a/packages/rsbuild/src/generators/configuration/schema.d.ts +++ b/packages/rsbuild/src/generators/configuration/schema.d.ts @@ -2,6 +2,7 @@ export interface Schema { project: string; entry?: string; tsConfig?: string; + devServerPort?: number; target?: 'node' | 'web' | 'web-worker'; skipValidation?: boolean; skipFormat?: boolean; diff --git a/packages/rsbuild/src/generators/configuration/schema.json b/packages/rsbuild/src/generators/configuration/schema.json index d85164e6f1249..b7678dd6d50f3 100644 --- a/packages/rsbuild/src/generators/configuration/schema.json +++ b/packages/rsbuild/src/generators/configuration/schema.json @@ -26,6 +26,11 @@ "description": "Path relative to the workspace root for the tsconfig file to build with. Defaults to '/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.", diff --git a/packages/rsbuild/src/plugins/plugin.spec.ts b/packages/rsbuild/src/plugins/plugin.spec.ts index 06ec5d6d8a786..7249fcacd62c8 100644 --- a/packages/rsbuild/src/plugins/plugin.spec.ts +++ b/packages/rsbuild/src/plugins/plugin.spec.ts @@ -124,6 +124,10 @@ describe('@nx/rsbuild/plugin', () => { }, "preview-serve": { "command": "rsbuild preview", + "dependsOn": [ + "build-something", + "^build-something", + ], "options": { "args": [ "--mode=production", diff --git a/packages/rsbuild/src/plugins/plugin.ts b/packages/rsbuild/src/plugins/plugin.ts index 5b89c5ce57ae0..d30e2ea8f2ad3 100644 --- a/packages/rsbuild/src/plugins/plugin.ts +++ b/packages/rsbuild/src/plugins/plugin.ts @@ -193,6 +193,7 @@ async function createRsbuildTargets( targets[options.previewTargetName] = { command: `rsbuild preview`, + dependsOn: [`${options.buildTargetName}`, `^${options.buildTargetName}`], options: { cwd: projectRoot, args: ['--mode=production'], diff --git a/packages/rsbuild/src/utils/add-build-plugin.spec.ts b/packages/rsbuild/src/utils/add-build-plugin.spec.ts new file mode 100644 index 0000000000000..527b360832f22 --- /dev/null +++ b/packages/rsbuild/src/utils/add-build-plugin.spec.ts @@ -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' + }, + } + });" + `); + }); +}); diff --git a/packages/rsbuild/src/utils/add-build-plugin.ts b/packages/rsbuild/src/utils/add-build-plugin.ts new file mode 100644 index 0000000000000..3345b8853d497 --- /dev/null +++ b/packages/rsbuild/src/utils/add-build-plugin.ts @@ -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); +} diff --git a/packages/rsbuild/src/utils/ast-utils.spec.ts b/packages/rsbuild/src/utils/ast-utils.spec.ts new file mode 100644 index 0000000000000..aa19f81df1a74 --- /dev/null +++ b/packages/rsbuild/src/utils/ast-utils.spec.ts @@ -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', {}]] + } + } + } + } + });" + `); + }); + }); +}); diff --git a/packages/rsbuild/src/utils/ast-utils.ts b/packages/rsbuild/src/utils/ast-utils.ts new file mode 100644 index 0000000000000..17a91d36125f7 --- /dev/null +++ b/packages/rsbuild/src/utils/ast-utils.ts @@ -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); +} diff --git a/packages/rsbuild/src/utils/e2e-web-server-info-utils.ts b/packages/rsbuild/src/utils/e2e-web-server-info-utils.ts new file mode 100644 index 0000000000000..37630d1cab68c --- /dev/null +++ b/packages/rsbuild/src/utils/e2e-web-server-info-utils.ts @@ -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 + ); +} diff --git a/packages/rsbuild/src/utils/indent-by.ts b/packages/rsbuild/src/utils/indent-by.ts new file mode 100644 index 0000000000000..455fa7aca7cd9 --- /dev/null +++ b/packages/rsbuild/src/utils/indent-by.ts @@ -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'); + }; +} diff --git a/packages/rsbuild/src/utils/versions.ts b/packages/rsbuild/src/utils/versions.ts index 4abb29c6b72b5..9a0166bce9051 100644 --- a/packages/rsbuild/src/utils/versions.ts +++ b/packages/rsbuild/src/utils/versions.ts @@ -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'; diff --git a/packages/vue/.eslintrc.json b/packages/vue/.eslintrc.json index 9b91bcf672580..a49715ce18741 100644 --- a/packages/vue/.eslintrc.json +++ b/packages/vue/.eslintrc.json @@ -35,6 +35,7 @@ "@nx/cypress", "@nx/playwright", "@nx/storybook", + "@nx/rsbuild", "eslint" ] } diff --git a/packages/vue/src/generators/application/__snapshots__/application.spec.ts.snap b/packages/vue/src/generators/application/__snapshots__/application.spec.ts.snap index 2944d649a680a..38545506e5b5a 100644 --- a/packages/vue/src/generators/application/__snapshots__/application.spec.ts.snap +++ b/packages/vue/src/generators/application/__snapshots__/application.spec.ts.snap @@ -144,6 +144,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, diff --git a/packages/vue/src/generators/application/application.spec.ts b/packages/vue/src/generators/application/application.spec.ts index 7aa6caa4ff498..135b745d52267 100644 --- a/packages/vue/src/generators/application/application.spec.ts +++ b/packages/vue/src/generators/application/application.spec.ts @@ -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 ??= []; diff --git a/packages/vue/src/generators/application/application.ts b/packages/vue/src/generators/application/application.ts index b76078b87115c..dee0c4eae5b25 100644 --- a/packages/vue/src/generators/application/application.ts +++ b/packages/vue/src/generators/application/application.ts @@ -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)); diff --git a/packages/vue/src/generators/application/lib/add-e2e.ts b/packages/vue/src/generators/application/lib/add-e2e.ts index 9616bdf6a0ba7..804dd79bd49b4 100644 --- a/packages/vue/src/generators/application/lib/add-e2e.ts +++ b/packages/vue/src/generators/application/lib/add-e2e.ts @@ -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 { 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( - '@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': { diff --git a/packages/vue/src/generators/application/lib/add-rsbuild.ts b/packages/vue/src/generators/application/lib/add-rsbuild.ts new file mode 100644 index 0000000000000..b0958a6b6536e --- /dev/null +++ b/packages/vue/src/generators/application/lib/add-rsbuild.ts @@ -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; +} diff --git a/packages/vue/src/generators/application/lib/add-vite.ts b/packages/vue/src/generators/application/lib/add-vite.ts index efc185452876b..c5ef2a03b091a 100644 --- a/packages/vue/src/generators/application/lib/add-vite.ts +++ b/packages/vue/src/generators/application/lib/add-vite.ts @@ -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; +} diff --git a/packages/vue/src/generators/application/lib/normalize-options.ts b/packages/vue/src/generators/application/lib/normalize-options.ts index 5a8e8752b519d..544d0685f97aa 100644 --- a/packages/vue/src/generators/application/lib/normalize-options.ts +++ b/packages/vue/src/generators/application/lib/normalize-options.ts @@ -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; } diff --git a/packages/vue/src/generators/application/schema.d.ts b/packages/vue/src/generators/application/schema.d.ts index b98873f749af3..d4210348ad10f 100644 --- a/packages/vue/src/generators/application/schema.d.ts +++ b/packages/vue/src/generators/application/schema.d.ts @@ -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'; diff --git a/packages/vue/src/generators/application/schema.json b/packages/vue/src/generators/application/schema.json index a18386d23db6b..adb6b34147ac3 100644 --- a/packages/vue/src/generators/application/schema.json +++ b/packages/vue/src/generators/application/schema.json @@ -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.", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4502f6e90670a..f2becd64315e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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)) diff --git a/tsconfig.base.json b/tsconfig.base.json index 76efa5f2d32b2..2b85e8b59a3bc 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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"],