feat(angular): add standalone app generation (#11592)

This commit is contained in:
Colum Ferry 2022-08-17 12:03:56 +01:00 committed by GitHub
parent 3c1d39b7c5
commit af93cfd597
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 330 additions and 1 deletions

View File

@ -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.", "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", "type": "boolean",
"default": false "default": false
},
"standalone": {
"description": "Generate an application that is setup to use standalone components.",
"type": "boolean",
"default": false
} }
}, },
"additionalProperties": false, "additionalProperties": false,

View File

@ -32,7 +32,7 @@ describe('Angular Projects', () => {
`generate @nrwl/angular:app ${myapp} --directory=myDir --no-interactive` `generate @nrwl/angular:app ${myapp} --directory=myDir --no-interactive`
); );
runCLI( runCLI(
`generate @nrwl/angular:app ${myapp2} --directory=myDir --no-interactive` `generate @nrwl/angular:app ${myapp2} --standalone=true --directory=myDir --no-interactive`
); );
runCLI( runCLI(
`generate @nrwl/angular:lib ${mylib} --directory=myDir --add-module-spec --no-interactive` `generate @nrwl/angular:lib ${mylib} --directory=myDir --add-module-spec --no-interactive`

View File

@ -24,6 +24,133 @@ const config = require('./module-federation.config');
module.exports = withModuleFederation(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 at the root should accept numbers in the path 1`] = `"src/9-websites/my-app"`;
exports[`app nested should update workspace.json 1`] = ` exports[`app nested should update workspace.json 1`] = `

View File

@ -1074,6 +1074,58 @@ describe('app', () => {
expect(devDependencies['autoprefixer']).toBe(autoprefixerVersion); 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( async function generateApp(

View File

@ -16,6 +16,7 @@ import {
addProxyConfig, addProxyConfig,
addRouterRootConfiguration, addRouterRootConfiguration,
addUnitTestRunner, addUnitTestRunner,
convertToStandaloneApp,
createFiles, createFiles,
enableStrictTypeChecking, enableStrictTypeChecking,
normalizeOptions, normalizeOptions,
@ -84,6 +85,7 @@ export async function applicationGenerator(
flat: true, flat: true,
viewEncapsulation: 'None', viewEncapsulation: 'None',
project: options.name, project: options.name,
standalone: options.standalone,
}); });
updateNxComponentTemplate(host, options); updateNxComponentTemplate(host, options);
@ -133,6 +135,10 @@ export async function applicationGenerator(
await addMf(host, options); await addMf(host, options);
} }
if (options.standalone) {
convertToStandaloneApp(host, options);
}
if (!options.skipFormat) { if (!options.skipFormat) {
await formatFiles(host); await formatFiles(host);
} }

View File

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

View File

@ -19,3 +19,4 @@ export * from './update-nx-component-template';
export * from './update-config-files'; export * from './update-config-files';
export * from './update-e2e-project'; export * from './update-e2e-project';
export * from './update-editor-tsconfig'; export * from './update-editor-tsconfig';
export * from './convert-to-standalone-app';

View File

@ -33,4 +33,5 @@ export interface Schema {
skipPostInstall?: boolean; skipPostInstall?: boolean;
federationType?: 'static' | 'dynamic'; federationType?: 'static' | 'dynamic';
skipDefaultProject?: boolean; skipDefaultProject?: boolean;
standalone?: boolean;
} }

View File

@ -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.", "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", "type": "boolean",
"default": false "default": false
},
"standalone": {
"description": "Generate an application that is setup to use standalone components.",
"type": "boolean",
"default": false
} }
}, },
"additionalProperties": false, "additionalProperties": false,