diff --git a/docs/generated/packages/angular.json b/docs/generated/packages/angular.json index f54aee2300..807cd5d7b2 100644 --- a/docs/generated/packages/angular.json +++ b/docs/generated/packages/angular.json @@ -252,6 +252,11 @@ "description": "Skip setting the project as the default project. When `false` (the default), the project is set as the default project only if there is no default project already set.", "type": "boolean", "default": false + }, + "standalone": { + "description": "Generate an application that is setup to use standalone components.", + "type": "boolean", + "default": false } }, "additionalProperties": false, diff --git a/e2e/angular-core/src/projects.test.ts b/e2e/angular-core/src/projects.test.ts index 8a2618bbce..11f72902d3 100644 --- a/e2e/angular-core/src/projects.test.ts +++ b/e2e/angular-core/src/projects.test.ts @@ -32,7 +32,7 @@ describe('Angular Projects', () => { `generate @nrwl/angular:app ${myapp} --directory=myDir --no-interactive` ); runCLI( - `generate @nrwl/angular:app ${myapp2} --directory=myDir --no-interactive` + `generate @nrwl/angular:app ${myapp2} --standalone=true --directory=myDir --no-interactive` ); runCLI( `generate @nrwl/angular:lib ${mylib} --directory=myDir --add-module-spec --no-interactive` diff --git a/packages/angular/src/generators/application/__snapshots__/application.spec.ts.snap b/packages/angular/src/generators/application/__snapshots__/application.spec.ts.snap index 77f6d07ea5..618a807e24 100644 --- a/packages/angular/src/generators/application/__snapshots__/application.spec.ts.snap +++ b/packages/angular/src/generators/application/__snapshots__/application.spec.ts.snap @@ -24,6 +24,133 @@ const config = require('./module-federation.config'); module.exports = withModuleFederation(config);" `; +exports[`app --standalone should generate a standalone app correctly with routing 1`] = ` +"import { enableProdMode, importProvidersFrom } from '@angular/core'; +import { bootstrapApplication } from '@angular/platform-browser'; +import { RouterModule } from '@angular/router'; +import { AppComponent } from './app/app.component'; +import { environment } from './environments/environment'; + +if (environment.production) { + enableProdMode(); +} + +bootstrapApplication(AppComponent, { + providers: [importProvidersFrom(RouterModule.forRoot([], {initialNavigation: 'enabledBlocking'}))], +}).catch((err) => console.error(err));" +`; + +exports[`app --standalone should generate a standalone app correctly with routing 2`] = ` +"import { NxWelcomeComponent } from './nx-welcome.component'; +import { RouterModule } from '@angular/router'; +import { Component } from '@angular/core'; + +@Component({ + standalone: true, + imports: [NxWelcomeComponent, RouterModule], + selector: 'proj-root', + templateUrl: './app.component.html', + styleUrls: ['./app.component.css'] +}) +export class AppComponent { + title = 'standalone'; +}" +`; + +exports[`app --standalone should generate a standalone app correctly with routing 3`] = ` +"import { TestBed } from '@angular/core/testing'; +import { AppComponent } from './app.component'; +import { NxWelcomeComponent } from './nx-welcome.component'; +import { RouterTestingModule } from '@angular/router/testing'; + +describe('AppComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({imports: [AppComponent, NxWelcomeComponent, RouterTestingModule] }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + + it(\`should have as title 'standalone'\`, () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app.title).toEqual('standalone'); + }); + + it('should render title', () => { + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('h1')?.textContent).toContain('Welcome standalone'); + }); +}); +" +`; + +exports[`app --standalone should generate a standalone app correctly without routing 1`] = ` +"import { enableProdMode } from '@angular/core'; +import { bootstrapApplication } from '@angular/platform-browser';; +import { AppComponent } from './app/app.component'; +import { environment } from './environments/environment'; + +if (environment.production) { + enableProdMode(); +} + +bootstrapApplication(AppComponent).catch((err) => console.error(err));" +`; + +exports[`app --standalone should generate a standalone app correctly without routing 2`] = ` +"import { NxWelcomeComponent } from './nx-welcome.component'; +import { Component } from '@angular/core'; + +@Component({ + standalone: true, + imports: [NxWelcomeComponent], + selector: 'proj-root', + templateUrl: './app.component.html', + styleUrls: ['./app.component.css'] +}) +export class AppComponent { + title = 'standalone'; +}" +`; + +exports[`app --standalone should generate a standalone app correctly without routing 3`] = ` +"import { TestBed } from '@angular/core/testing'; +import { AppComponent } from './app.component'; +import { NxWelcomeComponent } from './nx-welcome.component'; + +describe('AppComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({imports: [AppComponent, NxWelcomeComponent]}).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + + it(\`should have as title 'standalone'\`, () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app.title).toEqual('standalone'); + }); + + it('should render title', () => { + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('h1')?.textContent).toContain('Welcome standalone'); + }); +}); +" +`; + exports[`app at the root should accept numbers in the path 1`] = `"src/9-websites/my-app"`; exports[`app nested should update workspace.json 1`] = ` diff --git a/packages/angular/src/generators/application/application.spec.ts b/packages/angular/src/generators/application/application.spec.ts index a98e239936..9d3ca58e29 100644 --- a/packages/angular/src/generators/application/application.spec.ts +++ b/packages/angular/src/generators/application/application.spec.ts @@ -1074,6 +1074,58 @@ describe('app', () => { expect(devDependencies['autoprefixer']).toBe(autoprefixerVersion); }); }); + + describe('--standalone', () => { + it('should generate a standalone app correctly with routing', async () => { + // ACT + await generateApp(appTree, 'standalone', { + standalone: true, + routing: true, + }); + + // ASSERT + expect( + appTree.read('apps/standalone/src/main.ts', 'utf-8') + ).toMatchSnapshot(); + expect( + appTree.read('apps/standalone/src/app/app.component.ts', 'utf-8') + ).toMatchSnapshot(); + expect( + appTree.read('apps/standalone/src/app/app.component.spec.ts', 'utf-8') + ).toMatchSnapshot(); + expect( + appTree.exists('apps/standalone/src/app/app.module.ts') + ).toBeFalsy(); + expect( + appTree.read('apps/standalone/src/app/nx-welcome.component.ts', 'utf-8') + ).toContain('standalone: true'); + }); + + it('should generate a standalone app correctly without routing', async () => { + // ACT + await generateApp(appTree, 'standalone', { + standalone: true, + routing: false, + }); + + // ASSERT + expect( + appTree.read('apps/standalone/src/main.ts', 'utf-8') + ).toMatchSnapshot(); + expect( + appTree.read('apps/standalone/src/app/app.component.ts', 'utf-8') + ).toMatchSnapshot(); + expect( + appTree.read('apps/standalone/src/app/app.component.spec.ts', 'utf-8') + ).toMatchSnapshot(); + expect( + appTree.exists('apps/standalone/src/app/app.module.ts') + ).toBeFalsy(); + expect( + appTree.read('apps/standalone/src/app/nx-welcome.component.ts', 'utf-8') + ).toContain('standalone: true'); + }); + }); }); async function generateApp( diff --git a/packages/angular/src/generators/application/application.ts b/packages/angular/src/generators/application/application.ts index 3f338fc624..0373f00439 100644 --- a/packages/angular/src/generators/application/application.ts +++ b/packages/angular/src/generators/application/application.ts @@ -16,6 +16,7 @@ import { addProxyConfig, addRouterRootConfiguration, addUnitTestRunner, + convertToStandaloneApp, createFiles, enableStrictTypeChecking, normalizeOptions, @@ -84,6 +85,7 @@ export async function applicationGenerator( flat: true, viewEncapsulation: 'None', project: options.name, + standalone: options.standalone, }); updateNxComponentTemplate(host, options); @@ -133,6 +135,10 @@ export async function applicationGenerator( await addMf(host, options); } + if (options.standalone) { + convertToStandaloneApp(host, options); + } + if (!options.skipFormat) { await formatFiles(host); } diff --git a/packages/angular/src/generators/application/lib/convert-to-standalone-app.ts b/packages/angular/src/generators/application/lib/convert-to-standalone-app.ts new file mode 100644 index 0000000000..1bcd1691cc --- /dev/null +++ b/packages/angular/src/generators/application/lib/convert-to-standalone-app.ts @@ -0,0 +1,132 @@ +import type { Tree } from '@nrwl/devkit'; +import { joinPathFragments } from '@nrwl/devkit'; +import type { NormalizedSchema } from './normalized-schema'; +import { tsquery } from '@phenomnomnominal/tsquery'; + +export function convertToStandaloneApp(tree: Tree, options: NormalizedSchema) { + const pathToAppModule = joinPathFragments( + options.appProjectRoot, + 'src/app/app.module.ts' + ); + updateMainEntrypoint(options, tree, pathToAppModule); + updateAppComponent(tree, options); + if (!options.skipTests) { + updateAppComponentSpec(tree, options); + } + + tree.delete(pathToAppModule); +} + +function updateMainEntrypoint( + options: NormalizedSchema, + tree: Tree, + pathToAppModule: string +) { + let routerModuleSetup: string; + if (options.routing) { + const appModuleContents = tree.read(pathToAppModule, 'utf-8'); + const ast = tsquery.ast(appModuleContents); + + const ROUTER_MODULE_SELECTOR = + 'PropertyAssignment:has(Identifier[name=imports]) CallExpression:has(PropertyAccessExpression > Identifier[name=RouterModule])'; + const nodes = tsquery(ast, ROUTER_MODULE_SELECTOR, { + visitAllChildren: true, + }); + if (nodes.length > 0) { + routerModuleSetup = nodes[0].getText(); + } + } + + tree.write( + joinPathFragments(options.appProjectRoot, 'src/main.ts'), + standaloneComponentMainContents(routerModuleSetup) + ); +} + +const standaloneComponentMainContents = ( + routerModuleSetup +) => `import { enableProdMode${ + routerModuleSetup ? `, importProvidersFrom` : `` +} } from '@angular/core'; +import { bootstrapApplication } from '@angular/platform-browser';${ + routerModuleSetup + ? ` +import { RouterModule } from '@angular/router'` + : `` +}; +import { AppComponent } from './app/app.component'; +import { environment } from './environments/environment'; + +if (environment.production) { + enableProdMode(); +} + +bootstrapApplication(AppComponent${ + routerModuleSetup + ? `, { + providers: [importProvidersFrom(${routerModuleSetup})], +}` + : '' +}).catch((err) => console.error(err));`; + +function updateAppComponent(tree: Tree, options: NormalizedSchema) { + const pathToAppComponent = joinPathFragments( + options.appProjectRoot, + 'src/app/app.component.ts' + ); + const appComponentContents = tree.read(pathToAppComponent, 'utf-8'); + + const ast = tsquery.ast(appComponentContents); + const COMPONENT_DECORATOR_SELECTOR = + 'Decorator > CallExpression:has(Identifier[name=Component]) ObjectLiteralExpression'; + const nodes = tsquery(ast, COMPONENT_DECORATOR_SELECTOR, { + visitAllChildren: true, + }); + + if (nodes.length === 0) { + throw new Error( + 'Could not find Component decorator within app.component.ts for standalone app generation.' + ); + } + + const startPos = nodes[0].getStart() + 1; + + const newAppComponentContents = `import { NxWelcomeComponent } from './nx-welcome.component';${ + options.routing + ? ` +import { RouterModule } from '@angular/router';` + : '' + } +${appComponentContents.slice(0, startPos)} + standalone: true, + imports: [NxWelcomeComponent${ + options.routing ? ', RouterModule' : '' + }],${appComponentContents.slice(startPos, -1)}`; + + tree.write(pathToAppComponent, newAppComponentContents); +} + +function updateAppComponentSpec(tree: Tree, options: NormalizedSchema) { + const pathToAppComponentSpec = joinPathFragments( + options.appProjectRoot, + 'src/app/app.component.spec.ts' + ); + const appComponentSpecContents = tree.read(pathToAppComponentSpec, 'utf-8'); + + let newAppComponentSpecContents: string; + if (!options.routing) { + newAppComponentSpecContents = appComponentSpecContents.replace( + 'declarations', + 'imports' + ); + } else { + newAppComponentSpecContents = appComponentSpecContents + .replace( + 'imports: [RouterTestingModule],', + 'imports: [AppComponent, NxWelcomeComponent, RouterTestingModule]' + ) + .replace('declarations: [AppComponent, NxWelcomeComponent]', ''); + } + + tree.write(pathToAppComponentSpec, newAppComponentSpecContents); +} diff --git a/packages/angular/src/generators/application/lib/index.ts b/packages/angular/src/generators/application/lib/index.ts index c3e57c9634..babd5ad1dc 100644 --- a/packages/angular/src/generators/application/lib/index.ts +++ b/packages/angular/src/generators/application/lib/index.ts @@ -19,3 +19,4 @@ export * from './update-nx-component-template'; export * from './update-config-files'; export * from './update-e2e-project'; export * from './update-editor-tsconfig'; +export * from './convert-to-standalone-app'; diff --git a/packages/angular/src/generators/application/schema.d.ts b/packages/angular/src/generators/application/schema.d.ts index ecfda84f84..9df54195e5 100644 --- a/packages/angular/src/generators/application/schema.d.ts +++ b/packages/angular/src/generators/application/schema.d.ts @@ -33,4 +33,5 @@ export interface Schema { skipPostInstall?: boolean; federationType?: 'static' | 'dynamic'; skipDefaultProject?: boolean; + standalone?: boolean; } diff --git a/packages/angular/src/generators/application/schema.json b/packages/angular/src/generators/application/schema.json index 3b1c253102..5978429cd0 100644 --- a/packages/angular/src/generators/application/schema.json +++ b/packages/angular/src/generators/application/schema.json @@ -181,6 +181,11 @@ "description": "Skip setting the project as the default project. When `false` (the default), the project is set as the default project only if there is no default project already set.", "type": "boolean", "default": false + }, + "standalone": { + "description": "Generate an application that is setup to use standalone components.", + "type": "boolean", + "default": false } }, "additionalProperties": false,