feat(angular): add standalone app generation (#11592)
This commit is contained in:
parent
3c1d39b7c5
commit
af93cfd597
@ -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,
|
||||||
|
|||||||
@ -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`
|
||||||
|
|||||||
@ -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`] = `
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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';
|
||||||
|
|||||||
@ -33,4 +33,5 @@ export interface Schema {
|
|||||||
skipPostInstall?: boolean;
|
skipPostInstall?: boolean;
|
||||||
federationType?: 'static' | 'dynamic';
|
federationType?: 'static' | 'dynamic';
|
||||||
skipDefaultProject?: boolean;
|
skipDefaultProject?: boolean;
|
||||||
|
standalone?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user