feat(angular): add generator to convert targets to use the esbuild-based application executor (#21333)

This commit is contained in:
Leosvel Pérez Espinosa 2024-01-30 17:27:00 +01:00 committed by GitHub
parent c46c28694f
commit 7a77b0d6a7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1010 additions and 37 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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"
}

View File

@ -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)

View File

@ -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);
});

View File

@ -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",

View File

@ -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');
});
});
});

View File

@ -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<boolean> {
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<string, TargetConfiguration>): {
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;

View File

@ -0,0 +1,4 @@
export interface GeneratorOptions {
project?: string;
skipFormat?: boolean;
}

View File

@ -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
}

View File

@ -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 {}
"
`);
});
});

View File

@ -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<CallExpression>(
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',

View File

@ -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),

View File

@ -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);

View File

@ -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<T>(
target: TargetConfiguration<T>
): 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';

View File

@ -0,0 +1,19 @@
import { TargetConfiguration } from '@nx/devkit';
export function* allTargetOptions<T>(
target: TargetConfiguration<T>
): 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];
}
}
}