diff --git a/docs/generated/manifests/menus.json b/docs/generated/manifests/menus.json index 2293da8e55dda..bda5a6e4082da 100644 --- a/docs/generated/manifests/menus.json +++ b/docs/generated/manifests/menus.json @@ -6789,6 +6789,14 @@ "isExternal": false, "disableCollapsible": false }, + { + "id": "convert-to-application-executor", + "path": "/nx-api/angular/generators/convert-to-application-executor", + "name": "convert-to-application-executor", + "children": [], + "isExternal": false, + "disableCollapsible": false + }, { "id": "directive", "path": "/nx-api/angular/generators/directive", diff --git a/docs/generated/manifests/nx-api.json b/docs/generated/manifests/nx-api.json index 334dd6d42c7bb..604f72d2e9c2f 100644 --- a/docs/generated/manifests/nx-api.json +++ b/docs/generated/manifests/nx-api.json @@ -177,6 +177,15 @@ "path": "/nx-api/angular/generators/component-test", "type": "generator" }, + "/nx-api/angular/generators/convert-to-application-executor": { + "description": "Converts projects to use the `@nx/angular:application` executor or the `@angular-devkit/build-angular:application` builder. _Note: this is only supported in Angular versions >= 17.0.0_.", + "file": "generated/packages/angular/generators/convert-to-application-executor.json", + "hidden": false, + "name": "convert-to-application-executor", + "originalFilePath": "/packages/angular/src/generators/convert-to-application-executor/schema.json", + "path": "/nx-api/angular/generators/convert-to-application-executor", + "type": "generator" + }, "/nx-api/angular/generators/directive": { "description": "Generate an Angular directive.", "file": "generated/packages/angular/generators/directive.json", diff --git a/docs/generated/packages-metadata.json b/docs/generated/packages-metadata.json index 7e506bbcfce95..1b41cc5fae58b 100644 --- a/docs/generated/packages-metadata.json +++ b/docs/generated/packages-metadata.json @@ -172,6 +172,15 @@ "path": "angular/generators/component-test", "type": "generator" }, + { + "description": "Converts projects to use the `@nx/angular:application` executor or the `@angular-devkit/build-angular:application` builder. _Note: this is only supported in Angular versions >= 17.0.0_.", + "file": "generated/packages/angular/generators/convert-to-application-executor.json", + "hidden": false, + "name": "convert-to-application-executor", + "originalFilePath": "/packages/angular/src/generators/convert-to-application-executor/schema.json", + "path": "angular/generators/convert-to-application-executor", + "type": "generator" + }, { "description": "Generate an Angular directive.", "file": "generated/packages/angular/generators/directive.json", diff --git a/docs/generated/packages/angular/generators/convert-to-application-executor.json b/docs/generated/packages/angular/generators/convert-to-application-executor.json new file mode 100644 index 0000000000000..f394734b7eb98 --- /dev/null +++ b/docs/generated/packages/angular/generators/convert-to-application-executor.json @@ -0,0 +1,34 @@ +{ + "name": "convert-to-application-executor", + "factory": "./src/generators/convert-to-application-executor/convert-to-application-executor", + "schema": { + "$schema": "http://json-schema.org/schema", + "$id": "NxAngularConvertToApplicationExecutorGenerator", + "cli": "nx", + "title": "Converts projects to use the `@nx/angular:application` executor or the `@angular-devkit/build-angular:application` builder. _Note: this is only supported in Angular versions >= 17.0.0_.", + "description": "Converts a project or all projects using one of the `@angular-devkit/build-angular:browser`, `@angular-devkit/build-angular:browser-esbuild`, `@nx/angular:browser` and `@nx/angular:browser-esbuild` executors to use the `@nx/angular:application` executor or the `@angular-devkit/build-angular:application` builder. If the converted target is using one of the `@nx/angular` executors, the `@nx/angular:application` executor will be used. Otherwise, the `@angular-devkit/build-angular:application` builder will be used.", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "Name of the Angular application project to convert. It has to contain a target using one of the `@angular-devkit/build-angular:browser`, `@angular-devkit/build-angular:browser-esbuild`, `@nx/angular:browser` and `@nx/angular:browser-esbuild` executors. If not specified, all projects with such targets will be converted.", + "$default": { "$source": "argv", "index": 0 }, + "x-priority": "important" + }, + "skipFormat": { + "description": "Skip formatting files.", + "type": "boolean", + "default": false, + "x-priority": "internal" + } + }, + "additionalProperties": false, + "presets": [] + }, + "description": "Converts projects to use the `@nx/angular:application` executor or the `@angular-devkit/build-angular:application` builder. _Note: this is only supported in Angular versions >= 17.0.0_.", + "implementation": "/packages/angular/src/generators/convert-to-application-executor/convert-to-application-executor.ts", + "aliases": [], + "hidden": false, + "path": "/packages/angular/src/generators/convert-to-application-executor/schema.json", + "type": "generator" +} diff --git a/docs/shared/reference/sitemap.md b/docs/shared/reference/sitemap.md index 33eb35183f86f..7a64f6e9ffc1f 100644 --- a/docs/shared/reference/sitemap.md +++ b/docs/shared/reference/sitemap.md @@ -343,6 +343,7 @@ - [component-cypress-spec](/nx-api/angular/generators/component-cypress-spec) - [component-story](/nx-api/angular/generators/component-story) - [component-test](/nx-api/angular/generators/component-test) + - [convert-to-application-executor](/nx-api/angular/generators/convert-to-application-executor) - [directive](/nx-api/angular/generators/directive) - [federate-module](/nx-api/angular/generators/federate-module) - [init](/nx-api/angular/generators/init) diff --git a/e2e/angular-core/src/projects.test.ts b/e2e/angular-core/src/projects.test.ts index 4912d9b21a278..c30eb14951504 100644 --- a/e2e/angular-core/src/projects.test.ts +++ b/e2e/angular-core/src/projects.test.ts @@ -1,5 +1,6 @@ import { names } from '@nx/devkit'; import { + checkFilesDoNotExist, checkFilesExist, cleanupProject, getSize, @@ -8,6 +9,7 @@ import { newProject, readFile, removeFile, + rmDist, runCLI, runCommandUntil, runE2ETests, @@ -552,4 +554,52 @@ describe('Angular Projects', () => { `Successfully ran target test for project ${libName}` ); }, 500_000); + + it('should support generating applications with SSR and converting targets with webpack-based executors to use the application executor', async () => { + const esbuildApp = uniq('esbuild-app'); + const webpackApp = uniq('webpack-app'); + + runCLI( + `generate @nx/angular:app ${esbuildApp} --bundler=esbuild --ssr --project-name-and-root-format=as-provided --no-interactive` + ); + + // check build produces both the browser and server bundles + runCLI(`build ${esbuildApp} --output-hashing none`); + checkFilesExist( + `dist/${esbuildApp}/browser/main.js`, + `dist/${esbuildApp}/server/server.mjs` + ); + + runCLI( + `generate @nx/angular:app ${webpackApp} --bundler=webpack --ssr --project-name-and-root-format=as-provided --no-interactive` + ); + + // check build only produces the browser bundle + runCLI(`build ${webpackApp} --output-hashing none`); + checkFilesExist(`dist/${webpackApp}/browser/main.js`); + checkFilesDoNotExist(`dist/${webpackApp}/server/main.js`); + + // check server produces the server bundle + runCLI(`server ${webpackApp} --output-hashing none`); + checkFilesExist(`dist/${webpackApp}/server/main.js`); + + rmDist(); + + // convert target with webpack-based executors to use the application executor + runCLI( + `generate @nx/angular:convert-to-application-executor ${webpackApp}` + ); + + // check build now produces both the browser and server bundles + runCLI(`build ${webpackApp} --output-hashing none`); + checkFilesExist( + `dist/${webpackApp}/browser/main.js`, + `dist/${webpackApp}/server/server.mjs` + ); + + // check server target is no longer available + expect(() => + runCLI(`server ${webpackApp} --output-hashing none`) + ).toThrow(); + }, 500_000); }); diff --git a/packages/angular/generators.json b/packages/angular/generators.json index 7a81ee936f605..64ceff741694b 100644 --- a/packages/angular/generators.json +++ b/packages/angular/generators.json @@ -39,6 +39,11 @@ "schema": "./src/generators/component-test/schema.json", "description": "Creates a cypress component test file for a component." }, + "convert-to-application-executor": { + "factory": "./src/generators/convert-to-application-executor/convert-to-application-executor", + "schema": "./src/generators/convert-to-application-executor/schema.json", + "description": "Converts projects to use the `@nx/angular:application` executor or the `@angular-devkit/build-angular:application` builder. _Note: this is only supported in Angular versions >= 17.0.0_." + }, "directive": { "factory": "./src/generators/directive/directive", "schema": "./src/generators/directive/schema.json", diff --git a/packages/angular/src/generators/convert-to-application-executor/convert-to-application-executor.spec.ts b/packages/angular/src/generators/convert-to-application-executor/convert-to-application-executor.spec.ts new file mode 100644 index 0000000000000..45d42f245fa3d --- /dev/null +++ b/packages/angular/src/generators/convert-to-application-executor/convert-to-application-executor.spec.ts @@ -0,0 +1,315 @@ +import { + addProjectConfiguration, + logger, + readProjectConfiguration, + updateJson, + type Tree, +} from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { convertToApplicationExecutor } from './convert-to-application-executor'; + +describe('convert-to-application-executor generator', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + jest.spyOn(logger, 'info').mockImplementation(() => {}); + jest.spyOn(logger, 'warn').mockImplementation(() => {}); + }); + + it.each` + executor | expected + ${'@angular-devkit/build-angular:browser'} | ${'@angular-devkit/build-angular:application'} + ${'@angular-devkit/build-angular:browser-esbuild'} | ${'@angular-devkit/build-angular:application'} + ${'@nx/angular:webpack-browser'} | ${'@nx/angular:application'} + ${'@nx/angular:browser-esbuild'} | ${'@nx/angular:application'} + `( + 'should replace "$executor" with "$expected"', + async ({ executor, expected }) => { + addProjectConfiguration(tree, 'app1', { + root: 'app1', + projectType: 'application', + targets: { build: { executor } }, + }); + + await convertToApplicationExecutor(tree, {}); + + const project = readProjectConfiguration(tree, 'app1'); + expect(project.targets.build.executor).toBe(expected); + } + ); + + it('should not convert the target when using a custom webpack config', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'app1', + projectType: 'application', + targets: { + build: { + executor: '@nx/angular:webpack-browser', + options: { + customWebpackConfig: { + path: 'app1/webpack.config.js', + }, + }, + }, + }, + }); + + await convertToApplicationExecutor(tree, {}); + + const project = readProjectConfiguration(tree, 'app1'); + expect(project.targets.build.executor).toBe('@nx/angular:webpack-browser'); + expect(project.targets.build.options.customWebpackConfig).toStrictEqual({ + path: 'app1/webpack.config.js', + }); + }); + + it('should rename "main" to "browser"', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'app1', + projectType: 'application', + targets: { + build: { + executor: '@angular-devkit/build-angular:browser', + options: { + main: 'app1/main.ts', + }, + }, + }, + }); + + await convertToApplicationExecutor(tree, {}); + + const project = readProjectConfiguration(tree, 'app1'); + expect(project.targets.build.options.browser).toBe('app1/main.ts'); + expect(project.targets.build.options.main).toBeUndefined(); + }); + + it('should rename "ngswConfigPath" to "serviceWorker"', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'app1', + projectType: 'application', + targets: { + build: { + executor: '@angular-devkit/build-angular:browser', + options: { + ngswConfigPath: 'app1/ngsw-config.json', + }, + }, + }, + }); + + await convertToApplicationExecutor(tree, {}); + + const project = readProjectConfiguration(tree, 'app1'); + expect(project.targets.build.options.serviceWorker).toBe( + 'app1/ngsw-config.json' + ); + expect(project.targets.build.options.ngswConfigPath).toBeUndefined(); + }); + + it('should convert a string value for "polyfills" to an array', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'app1', + projectType: 'application', + targets: { + build: { + executor: '@angular-devkit/build-angular:browser', + options: { + polyfills: 'zone.js', + }, + }, + }, + }); + + await convertToApplicationExecutor(tree, {}); + + const project = readProjectConfiguration(tree, 'app1'); + expect(project.targets.build.options.polyfills).toStrictEqual(['zone.js']); + }); + + it('should update "outputs"', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'app1', + projectType: 'application', + targets: { + build: { + executor: '@angular-devkit/build-angular:browser', + outputs: ['{options.outputPath}'], + options: { + outputPath: 'dist/app1', + resourcesOutputPath: 'media', + }, + }, + }, + }); + + await convertToApplicationExecutor(tree, {}); + + const project = readProjectConfiguration(tree, 'app1'); + expect(project.targets.build.outputs).toStrictEqual([ + '{options.outputPath.base}', + ]); + }); + + it('should replace "outputPath" to string if "resourcesOutputPath" is set to "media"', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'app1', + projectType: 'application', + targets: { + build: { + executor: '@angular-devkit/build-angular:browser', + options: { + outputPath: 'dist/app1', + resourcesOutputPath: 'media', + }, + }, + }, + }); + + await convertToApplicationExecutor(tree, {}); + + const project = readProjectConfiguration(tree, 'app1'); + const { outputPath, resourcesOutputPath } = project.targets.build.options; + expect(outputPath).toStrictEqual({ base: 'dist/app1' }); + expect(resourcesOutputPath).toBeUndefined(); + }); + + it('should set "outputPath.media" if "resourcesOutputPath" is set and is not "media"', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'app1', + projectType: 'application', + targets: { + build: { + executor: '@angular-devkit/build-angular:browser', + options: { + outputPath: 'dist/app1', + resourcesOutputPath: 'resources', + }, + }, + }, + }); + + await convertToApplicationExecutor(tree, {}); + + const project = readProjectConfiguration(tree, 'app1'); + const { outputPath, resourcesOutputPath } = project.targets.build.options; + expect(outputPath).toStrictEqual({ base: 'dist/app1', media: 'resources' }); + expect(resourcesOutputPath).toBeUndefined(); + }); + + it('should remove "browser" portion from "outputPath"', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'app1', + projectType: 'application', + targets: { + build: { + executor: '@angular-devkit/build-angular:browser', + options: { + outputPath: 'dist/app1/browser', + resourcesOutputPath: 'resources', + }, + }, + }, + }); + + await convertToApplicationExecutor(tree, {}); + + const project = readProjectConfiguration(tree, 'app1'); + expect(project.targets.build.options.outputPath).toStrictEqual({ + base: 'dist/app1', + media: 'resources', + }); + }); + + it('should remove unsupported options', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'app1', + projectType: 'application', + targets: { + build: { + executor: '@angular-devkit/build-angular:browser', + options: {}, + configurations: { + development: { + buildOptimizer: false, + vendorChunk: true, + commonChunk: true, + }, + }, + }, + }, + }); + + await convertToApplicationExecutor(tree, {}); + + const project = readProjectConfiguration(tree, 'app1'); + expect( + project.targets.build.configurations.development.buildOptimizer + ).toBeUndefined(); + expect( + project.targets.build.configurations.development.vendorChunk + ).toBeUndefined(); + expect( + project.targets.build.configurations.development.commonChunk + ).toBeUndefined(); + }); + + describe('compat', () => { + it('should not convert outputs to the object notation when angular version is lower that 17.1.0', async () => { + updateJson(tree, 'package.json', (json) => { + json.dependencies['@angular/core'] = '17.0.0'; + return json; + }); + addProjectConfiguration(tree, 'app1', { + root: 'app1', + projectType: 'application', + targets: { + build: { + executor: '@angular-devkit/build-angular:browser', + outputs: ['{options.outputPath}'], + options: { + outputPath: 'dist/app1', + }, + }, + }, + }); + + await convertToApplicationExecutor(tree, {}); + + const project = readProjectConfiguration(tree, 'app1'); + expect(project.targets.build.outputs).toStrictEqual([ + '{options.outputPath}', + ]); + expect(project.targets.build.options.outputPath).toBe('dist/app1'); + }); + + it('should remove trailing "/browser" from output path when angular version is lower that 17.1.0', async () => { + updateJson(tree, 'package.json', (json) => { + json.dependencies['@angular/core'] = '17.0.0'; + return json; + }); + addProjectConfiguration(tree, 'app1', { + root: 'app1', + projectType: 'application', + targets: { + build: { + executor: '@angular-devkit/build-angular:browser', + outputs: ['{options.outputPath}'], + options: { + outputPath: 'dist/app1/browser', + }, + }, + }, + }); + + await convertToApplicationExecutor(tree, {}); + + const project = readProjectConfiguration(tree, 'app1'); + expect(project.targets.build.outputs).toStrictEqual([ + '{options.outputPath}', + ]); + expect(project.targets.build.options.outputPath).toBe('dist/app1'); + }); + }); +}); diff --git a/packages/angular/src/generators/convert-to-application-executor/convert-to-application-executor.ts b/packages/angular/src/generators/convert-to-application-executor/convert-to-application-executor.ts new file mode 100644 index 0000000000000..528b64c620015 --- /dev/null +++ b/packages/angular/src/generators/convert-to-application-executor/convert-to-application-executor.ts @@ -0,0 +1,338 @@ +import { + formatFiles, + getProjects, + installPackagesTask, + logger, + readJson, + readProjectConfiguration, + updateProjectConfiguration, + writeJson, + type TargetConfiguration, + type Tree, +} from '@nx/devkit'; +import { dirname, join } from 'node:path/posix'; +import { gte, lt } from 'semver'; +import { allTargetOptions } from '../../utils/targets'; +import { setupSsr } from '../setup-ssr/setup-ssr'; +import { validateProject } from '../utils/validations'; +import { getInstalledAngularVersionInfo } from '../utils/version-utils'; +import type { GeneratorOptions } from './schema'; + +const executorsToConvert = new Set([ + '@angular-devkit/build-angular:browser', + '@angular-devkit/build-angular:browser-esbuild', + '@nx/angular:webpack-browser', + '@nx/angular:browser-esbuild', +]); +const serverTargetExecutors = new Set([ + '@angular-devkit/build-angular:server', + '@nx/angular:webpack-server', +]); +const redundantExecutors = new Set([ + '@angular-devkit/build-angular:server', + '@angular-devkit/build-angular:prerender', + '@angular-devkit/build-angular:app-shell', + '@angular-devkit/build-angular:ssr-dev-server', + '@nx/angular:webpack-server', +]); + +export async function convertToApplicationExecutor( + tree: Tree, + options: GeneratorOptions +) { + const { major: angularMajorVersion, version: angularVersion } = + getInstalledAngularVersionInfo(tree); + if (angularMajorVersion < 17) { + throw new Error( + `The "convert-to-application-executor" generator is only supported in Angular >= 17.0.0. You are currently using "${angularVersion}".` + ); + } + + let didAnySucceed = false; + if (options.project) { + validateProject(tree, options.project); + didAnySucceed = await convertProjectTargets( + tree, + options.project, + angularVersion, + true + ); + } else { + const projects = getProjects(tree); + for (const [projectName] of projects) { + logger.info(`Converting project "${projectName}"...`); + const success = await convertProjectTargets( + tree, + projectName, + angularVersion + ); + + if (success) { + logger.info(`Project "${projectName}" converted successfully.`); + } else { + logger.info( + `Project "${projectName}" could not be converted. See above for more information.` + ); + } + logger.info(''); + didAnySucceed = didAnySucceed || success; + } + } + + if (!options.skipFormat) { + await formatFiles(tree); + } + + return didAnySucceed ? () => installPackagesTask(tree) : () => {}; +} + +async function convertProjectTargets( + tree: Tree, + projectName: string, + angularVersion: string, + isProvidedProject = false +): Promise { + function warnIfProvided(message: string): void { + if (isProvidedProject) { + logger.warn(message); + } + } + + let project = readProjectConfiguration(tree, projectName); + if (project.projectType !== 'application') { + warnIfProvided( + `The provided project "${projectName}" is not an application. Skipping conversion.` + ); + return false; + } + + const { buildTargetName, serverTargetName } = getTargetsToConvert( + project.targets + ); + if (!buildTargetName) { + warnIfProvided( + `The provided project "${projectName}" does not have any targets using on of the ` + + `'@angular-devkit/build-angular:browser', '@angular-devkit/build-angular:browser-esbuild', ` + + `'@nx/angular:browser' and '@nx/angular:browser-esbuild' executors. Skipping conversion.` + ); + return false; + } + + const useNxExecutor = + project.targets[buildTargetName].executor.startsWith('@nx/angular:'); + const newExecutor = useNxExecutor + ? '@nx/angular:application' + : '@angular-devkit/build-angular:application'; + + const buildTarget = project.targets[buildTargetName]; + buildTarget.executor = newExecutor; + + if (gte(angularVersion, '17.1.0') && buildTarget.outputs) { + buildTarget.outputs = buildTarget.outputs.map((output) => + output === '{options.outputPath}' ? '{options.outputPath.base}' : output + ); + } + + for (const [, options] of allTargetOptions(buildTarget)) { + if (options['index'] === '') { + options['index'] = false; + } + + // Rename and transform options + options['browser'] = options['main']; + if (serverTargetName && typeof options['browser'] === 'string') { + options['server'] = dirname(options['browser']) + '/main.server.ts'; + } + options['serviceWorker'] = + options['ngswConfigPath'] ?? options['serviceWorker']; + + if (typeof options['polyfills'] === 'string') { + options['polyfills'] = [options['polyfills']]; + } + + let outputPath = options['outputPath']; + if (lt(angularVersion, '17.1.0')) { + options['outputPath'] = outputPath?.replace(/\/browser\/?$/, ''); + } else if (typeof outputPath === 'string') { + if (!/\/browser\/?$/.test(outputPath)) { + logger.warn( + `The output location of the browser build has been updated from "${outputPath}" to ` + + `"${join(outputPath, 'browser')}". ` + + 'You might need to adjust your deployment pipeline or, as an alternative, ' + + 'set outputPath.browser to "" in order to maintain the previous functionality.' + ); + } else { + outputPath = outputPath.replace(/\/browser\/?$/, ''); + } + + options['outputPath'] = { + base: outputPath, + }; + + if (typeof options['resourcesOutputPath'] === 'string') { + const media = options['resourcesOutputPath'].replaceAll('/', ''); + if (media && media !== 'media') { + options['outputPath'] = { + base: outputPath, + media: media, + }; + } + } + } + + // Delete removed options + delete options['deployUrl']; + delete options['vendorChunk']; + delete options['commonChunk']; + delete options['resourcesOutputPath']; + delete options['buildOptimizer']; + delete options['main']; + delete options['ngswConfigPath']; + } + + // Merge browser and server tsconfig + if (serverTargetName) { + const browserTsConfigPath = buildTarget?.options?.tsConfig; + const serverTsConfigPath = project.targets['server']?.options?.tsConfig; + + if (typeof browserTsConfigPath !== 'string') { + logger.warn( + `Cannot update project "${projectName}" to use the application executor ` + + `as the browser tsconfig cannot be located.` + ); + } + + if (typeof serverTsConfigPath !== 'string') { + logger.warn( + `Cannot update project "${projectName}" to use the application executor ` + + `as the server tsconfig cannot be located.` + ); + } + + const browserTsConfigJson = readJson(tree, browserTsConfigPath); + const serverTsConfigJson = readJson(tree, serverTsConfigPath); + + const files = new Set([ + ...(browserTsConfigJson.files ?? []), + ...(serverTsConfigJson.files ?? []), + ]); + + // Server file will be added later by the setup-ssr generator + files.delete('server.ts'); + + browserTsConfigJson.files = Array.from(files); + browserTsConfigJson.compilerOptions ?? {}; + browserTsConfigJson.compilerOptions.types = Array.from( + new Set([ + ...(browserTsConfigJson.compilerOptions.types ?? []), + ...(serverTsConfigJson.compilerOptions?.types ?? []), + ]) + ); + + // Delete server tsconfig + tree.delete(serverTsConfigPath); + } + + // Update project main tsconfig + const projectRootTsConfigPath = join(project.root, 'tsconfig.json'); + if (tree.exists(projectRootTsConfigPath)) { + const rootTsConfigJson = readJson(tree, projectRootTsConfigPath); + rootTsConfigJson.compilerOptions ?? {}; + rootTsConfigJson.compilerOptions.esModuleInterop = true; + rootTsConfigJson.compilerOptions.downlevelIteration = undefined; + rootTsConfigJson.compilerOptions.allowSyntheticDefaultImports = undefined; + writeJson(tree, projectRootTsConfigPath, rootTsConfigJson); + } + + // Update server file + const ssrMainFile = project.targets['server']?.options?.['main']; + if (typeof ssrMainFile === 'string') { + tree.delete(ssrMainFile); + // apply changes so the setup-ssr generator can access the updated project + updateProjectConfiguration(tree, projectName, project); + await setupSsr(tree, { project: projectName, skipFormat: true }); + // re-read project configuration as it might have changed + project = readProjectConfiguration(tree, projectName); + } + + // Delete all redundant targets + for (const [targetName, target] of Object.entries(project.targets)) { + if (redundantExecutors.has(target.executor)) { + delete project.targets[targetName]; + } + } + + updateProjectConfiguration(tree, projectName, project); + return true; +} + +function getTargetsToConvert(targets: Record): { + buildTargetName?: string; + serverTargetName?: string; +} { + let buildTargetName: string; + let serverTargetName: string; + for (const target of Object.keys(targets)) { + if ( + targets[target].executor === '@nx/angular:application' || + targets[target].executor === '@angular-devkit/build-angular:application' + ) { + logger.warn( + 'The project is already using the application builder. Skipping conversion.' + ); + return {}; + } + + // build target + if (executorsToConvert.has(targets[target].executor)) { + for (const [, options] of allTargetOptions(targets[target])) { + if (options.deployUrl) { + logger.warn( + `The project is using the "deployUrl" option which is not available in the application builder. Skipping conversion.` + ); + return {}; + } + if (options.customWebpackConfig) { + logger.warn( + `The project is using a custom webpack configuration which is not supported by the esbuild-based application executor. Skipping conversion.` + ); + return {}; + } + } + + if (buildTargetName) { + logger.warn( + 'The project has more than one build target. Skipping conversion.' + ); + return {}; + } + buildTargetName = target; + } + + // server target + if (serverTargetExecutors.has(targets[target].executor)) { + if (targets[target].executor === '@nx/angular:webpack-server') { + for (const [, options] of allTargetOptions(targets[target])) { + if (options.customWebpackConfig) { + logger.warn( + `The project is using a custom webpack configuration which is not supported by the esbuild-based application executor. Skipping conversion.` + ); + return {}; + } + } + } + + if (serverTargetName) { + logger.warn( + 'The project has more than one server target. Skipping conversion.' + ); + return {}; + } + serverTargetName = target; + } + } + + return { buildTargetName, serverTargetName }; +} + +export default convertToApplicationExecutor; diff --git a/packages/angular/src/generators/convert-to-application-executor/schema.d.ts b/packages/angular/src/generators/convert-to-application-executor/schema.d.ts new file mode 100644 index 0000000000000..89d185c1bb2d7 --- /dev/null +++ b/packages/angular/src/generators/convert-to-application-executor/schema.d.ts @@ -0,0 +1,4 @@ +export interface GeneratorOptions { + project?: string; + skipFormat?: boolean; +} diff --git a/packages/angular/src/generators/convert-to-application-executor/schema.json b/packages/angular/src/generators/convert-to-application-executor/schema.json new file mode 100644 index 0000000000000..f4cadea063e4a --- /dev/null +++ b/packages/angular/src/generators/convert-to-application-executor/schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "NxAngularConvertToApplicationExecutorGenerator", + "cli": "nx", + "title": "Converts projects to use the `@nx/angular:application` executor or the `@angular-devkit/build-angular:application` builder. _Note: this is only supported in Angular versions >= 17.0.0_.", + "description": "Converts a project or all projects using one of the `@angular-devkit/build-angular:browser`, `@angular-devkit/build-angular:browser-esbuild`, `@nx/angular:browser` and `@nx/angular:browser-esbuild` executors to use the `@nx/angular:application` executor or the `@angular-devkit/build-angular:application` builder. If the converted target is using one of the `@nx/angular` executors, the `@nx/angular:application` executor will be used. Otherwise, the `@angular-devkit/build-angular:application` builder will be used.", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "Name of the Angular application project to convert. It has to contain a target using one of the `@angular-devkit/build-angular:browser`, `@angular-devkit/build-angular:browser-esbuild`, `@nx/angular:browser` and `@nx/angular:browser-esbuild` executors. If not specified, all projects with such targets will be converted.", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-priority": "important" + }, + "skipFormat": { + "description": "Skip formatting files.", + "type": "boolean", + "default": false, + "x-priority": "internal" + } + }, + "additionalProperties": false +} diff --git a/packages/angular/src/generators/setup-ssr/lib/add-hydration.spec.ts b/packages/angular/src/generators/setup-ssr/lib/add-hydration.spec.ts new file mode 100644 index 0000000000000..269ffffbe7736 --- /dev/null +++ b/packages/angular/src/generators/setup-ssr/lib/add-hydration.spec.ts @@ -0,0 +1,159 @@ +import { addProjectConfiguration, type Tree } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { addHydration } from './add-hydration'; + +describe('add-hydration', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + addProjectConfiguration(tree, 'app1', { + root: 'app1', + sourceRoot: 'app1/src', + projectType: 'application', + targets: {}, + }); + }); + + it('should add "provideClientHydration" for standalone config', () => { + tree.write( + 'app1/src/app/app.config.ts', + `import { ApplicationConfig } from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { appRoutes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [provideRouter(appRoutes)], +}; +` + ); + + addHydration(tree, { project: 'app1', standalone: true }); + + expect(tree.read('app1/src/app/app.config.ts', 'utf-8')) + .toMatchInlineSnapshot(` + "import { ApplicationConfig } from '@angular/core'; + import { provideRouter } from '@angular/router'; + import { appRoutes } from './app.routes'; + import { provideClientHydration } from '@angular/platform-browser'; + + export const appConfig: ApplicationConfig = { + providers: [provideClientHydration(),provideRouter(appRoutes)], + }; + " + `); + }); + + it('should not duplicate "provideClientHydration" for standalone config', () => { + tree.write( + 'app1/src/app/app.config.ts', + `import { ApplicationConfig } from '@angular/core'; +import { provideClientHydration } from '@angular/platform-browser'; +import { provideRouter } from '@angular/router'; +import { appRoutes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [provideClientHydration(), provideRouter(appRoutes)], +}; +` + ); + + addHydration(tree, { project: 'app1', standalone: true }); + + expect(tree.read('app1/src/app/app.config.ts', 'utf-8')) + .toMatchInlineSnapshot(` + "import { ApplicationConfig } from '@angular/core'; + import { provideClientHydration } from '@angular/platform-browser'; + import { provideRouter } from '@angular/router'; + import { appRoutes } from './app.routes'; + + export const appConfig: ApplicationConfig = { + providers: [provideClientHydration(), provideRouter(appRoutes)], + }; + " + `); + }); + + it('should add "provideClientHydration" for non-standalone config', () => { + tree.write( + 'app1/src/app/app.module.ts', + `import { NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { RouterModule } from '@angular/router'; +import { AppComponent } from './app.component'; +import { appRoutes } from './app.routes'; +import { NxWelcomeComponent } from './nx-welcome.component'; + +@NgModule({ + declarations: [AppComponent, NxWelcomeComponent], + imports: [BrowserModule, RouterModule.forRoot(appRoutes)], + bootstrap: [AppComponent], +}) +export class AppModule {} +` + ); + + addHydration(tree, { project: 'app1', standalone: false }); + + expect(tree.read('app1/src/app/app.module.ts', 'utf-8')) + .toMatchInlineSnapshot(` + "import { NgModule } from '@angular/core'; + import { BrowserModule, provideClientHydration } from '@angular/platform-browser'; + import { RouterModule } from '@angular/router'; + import { AppComponent } from './app.component'; + import { appRoutes } from './app.routes'; + import { NxWelcomeComponent } from './nx-welcome.component'; + + @NgModule({ + declarations: [AppComponent, NxWelcomeComponent], + imports: [BrowserModule, RouterModule.forRoot(appRoutes)], + bootstrap: [AppComponent], + providers: [provideClientHydration()], + }) + export class AppModule {} + " + `); + }); + + it('should not duplicate "provideClientHydration" for non-standalone config', () => { + tree.write( + 'app1/src/app/app.module.ts', + `import { NgModule } from '@angular/core'; +import { BrowserModule, provideClientHydration } from '@angular/platform-browser'; +import { RouterModule } from '@angular/router'; +import { AppComponent } from './app.component'; +import { appRoutes } from './app.routes'; +import { NxWelcomeComponent } from './nx-welcome.component'; + +@NgModule({ + declarations: [AppComponent, NxWelcomeComponent], + imports: [BrowserModule, RouterModule.forRoot(appRoutes)], + bootstrap: [AppComponent], + providers: [provideClientHydration()], +}) +export class AppModule {} +` + ); + + addHydration(tree, { project: 'app1', standalone: false }); + + expect(tree.read('app1/src/app/app.module.ts', 'utf-8')) + .toMatchInlineSnapshot(` + "import { NgModule } from '@angular/core'; + import { BrowserModule, provideClientHydration } from '@angular/platform-browser'; + import { RouterModule } from '@angular/router'; + import { AppComponent } from './app.component'; + import { appRoutes } from './app.routes'; + import { NxWelcomeComponent } from './nx-welcome.component'; + + @NgModule({ + declarations: [AppComponent, NxWelcomeComponent], + imports: [BrowserModule, RouterModule.forRoot(appRoutes)], + bootstrap: [AppComponent], + providers: [provideClientHydration()], + }) + export class AppModule {} + " + `); + }); +}); diff --git a/packages/angular/src/generators/setup-ssr/lib/add-hydration.ts b/packages/angular/src/generators/setup-ssr/lib/add-hydration.ts index 131070c7a442a..9dc8fc5ef1a10 100644 --- a/packages/angular/src/generators/setup-ssr/lib/add-hydration.ts +++ b/packages/angular/src/generators/setup-ssr/lib/add-hydration.ts @@ -3,23 +3,46 @@ import { readProjectConfiguration, type Tree, } from '@nx/devkit'; -import { type Schema } from '../schema'; +import { insertImport } from '@nx/js'; +import { ensureTypescript } from '@nx/js/src/utils/typescript/ensure-typescript'; +import type { CallExpression, SourceFile } from 'typescript'; import { addProviderToAppConfig, addProviderToModule, } from '../../../utils/nx-devkit/ast-utils'; -import { ensureTypescript } from '@nx/js/src/utils/typescript/ensure-typescript'; -import { SourceFile } from 'typescript'; -import { insertImport } from '@nx/js'; +import { type Schema } from '../schema'; let tsModule: typeof import('typescript'); +let tsquery: typeof import('@phenomnomnominal/tsquery').tsquery; export function addHydration(tree: Tree, options: Schema) { const projectConfig = readProjectConfiguration(tree, options.project); if (!tsModule) { tsModule = ensureTypescript(); + tsquery = require('@phenomnomnominal/tsquery').tsquery; + } + + const pathToClientConfigFile = options.standalone + ? joinPathFragments(projectConfig.sourceRoot, 'app/app.config.ts') + : joinPathFragments(projectConfig.sourceRoot, 'app/app.module.ts'); + + const sourceText = tree.read(pathToClientConfigFile, 'utf-8'); + let sourceFile = tsModule.createSourceFile( + pathToClientConfigFile, + sourceText, + tsModule.ScriptTarget.Latest, + true + ); + + const provideClientHydrationCallExpression = tsquery( + sourceFile, + 'ObjectLiteralExpression PropertyAssignment:has(Identifier[name=providers]) ArrayLiteralExpression CallExpression:has(Identifier[name=provideClientHydration])' + )[0]; + if (provideClientHydrationCallExpression) { + return; } + const addImport = ( source: SourceFile, symbolName: string, @@ -37,18 +60,6 @@ export function addHydration(tree: Tree, options: Schema) { ); }; - const pathToClientConfigFile = options.standalone - ? joinPathFragments(projectConfig.sourceRoot, 'app/app.config.ts') - : joinPathFragments(projectConfig.sourceRoot, 'app/app.module.ts'); - - const sourceText = tree.read(pathToClientConfigFile, 'utf-8'); - let sourceFile = tsModule.createSourceFile( - pathToClientConfigFile, - sourceText, - tsModule.ScriptTarget.Latest, - true - ); - sourceFile = addImport( sourceFile, 'provideClientHydration', diff --git a/packages/angular/src/generators/setup-ssr/lib/update-project-config.ts b/packages/angular/src/generators/setup-ssr/lib/update-project-config.ts index 71fffd66f7524..783fa63c7efcf 100644 --- a/packages/angular/src/generators/setup-ssr/lib/update-project-config.ts +++ b/packages/angular/src/generators/setup-ssr/lib/update-project-config.ts @@ -84,7 +84,9 @@ export function updateProjectConfigForBrowserBuilder( projectConfig.targets.server = { dependsOn: ['build'], - executor: '@angular-devkit/build-angular:server', + executor: buildTarget.executor.startsWith('@angular-devkit/build-angular:') + ? '@angular-devkit/build-angular:server' + : '@nx/angular:webpack-server', options: { outputPath: joinPathFragments(baseOutputPath, 'server'), main: joinPathFragments(projectConfig.root, schema.serverFileName), diff --git a/packages/angular/src/generators/setup-ssr/setup-ssr.ts b/packages/angular/src/generators/setup-ssr/setup-ssr.ts index 83fa3f152e1a6..7de23cbd33815 100644 --- a/packages/angular/src/generators/setup-ssr/setup-ssr.ts +++ b/packages/angular/src/generators/setup-ssr/setup-ssr.ts @@ -27,7 +27,8 @@ export async function setupSsr(tree: Tree, schema: Schema) { const { targets } = readProjectConfiguration(tree, options.project); const isUsingApplicationBuilder = - targets.build.executor === '@angular-devkit/build-angular:application'; + targets.build.executor === '@angular-devkit/build-angular:application' || + targets.build.executor === '@nx/angular:application'; addDependencies(tree, isUsingApplicationBuilder); generateSSRFiles(tree, options, isUsingApplicationBuilder); diff --git a/packages/angular/src/migrations/update-17-1-0/replace-nguniversal-engines.ts b/packages/angular/src/migrations/update-17-1-0/replace-nguniversal-engines.ts index 969005d04164c..5d60998555a3b 100644 --- a/packages/angular/src/migrations/update-17-1-0/replace-nguniversal-engines.ts +++ b/packages/angular/src/migrations/update-17-1-0/replace-nguniversal-engines.ts @@ -5,7 +5,6 @@ import { readJson, removeDependenciesFromPackageJson, visitNotIgnoredFiles, - type TargetConfiguration, type Tree, } from '@nx/devkit'; import { dirname, relative } from 'path'; @@ -13,6 +12,7 @@ import { getInstalledPackageVersionInfo, versions, } from '../../generators/utils/version-utils'; +import { allTargetOptions } from '../../utils/targets'; import { getProjectsFilteredByDependencies } from '../utils/projects'; const UNIVERSAL_PACKAGES = [ @@ -141,24 +141,6 @@ export default async function (tree: Tree) { await formatFiles(tree); } -function* allTargetOptions( - target: TargetConfiguration -): Iterable<[string | undefined, T]> { - if (target.options) { - yield [undefined, target.options]; - } - - if (!target.configurations) { - return; - } - - for (const [name, options] of Object.entries(target.configurations)) { - if (options !== undefined) { - yield [name, options]; - } - } -} - const TOKENS_FILE_CONTENT = ` import { InjectionToken } from '@angular/core'; import { Request, Response } from 'express'; diff --git a/packages/angular/src/utils/targets.ts b/packages/angular/src/utils/targets.ts new file mode 100644 index 0000000000000..b46122730d9c3 --- /dev/null +++ b/packages/angular/src/utils/targets.ts @@ -0,0 +1,19 @@ +import { TargetConfiguration } from '@nx/devkit'; + +export function* allTargetOptions( + target: TargetConfiguration +): Iterable<[string | undefined, T]> { + if (target.options) { + yield [undefined, target.options]; + } + + if (!target.configurations) { + return; + } + + for (const [name, options] of Object.entries(target.configurations)) { + if (options !== undefined) { + yield [name, options]; + } + } +}