Skip to content

Commit 2397b22

Browse files
committed
fix(@schematics/angular): infer app component name and path in server schematic
Currently the `server` schematic assumes that the app component is called `App` and it's places in `./app/app`. This will fail if the user renamed it or moved it to a different file. These changes add a utility function to resolve the component name and path from the source the source code, and they use the new function to produce a more accurate result.
1 parent d8a5647 commit 2397b22

File tree

8 files changed

+298
-19
lines changed

8 files changed

+298
-19
lines changed
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { NgModule } from '@angular/core';
22
import { provideServerRendering, withRoutes } from '@angular/ssr';
3-
import { App } from './app';
4-
import { AppModule } from './app.module';
3+
import { <%= appComponentName %> } from '<%= appComponentPath %>';
4+
import { <%= appModuleName %> } from '<%= appModulePath %>';
55
import { serverRoutes } from './app.routes.server';
66

77
@NgModule({
8-
imports: [AppModule],
8+
imports: [<%= appModuleName %>],
99
providers: [provideServerRendering(withRoutes(serverRoutes))],
10-
bootstrap: [App],
10+
bootstrap: [<%= appComponentName %>],
1111
})
1212
export class AppServerModule {}
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { bootstrapApplication } from '@angular/platform-browser';
2-
import { App } from './app/app';
2+
import { <%= appComponentName %> } from '<%= appComponentPath %>';
33
import { config } from './app/app.config.server';
44

5-
const bootstrap = () => bootstrapApplication(App, config);
5+
const bootstrap = () => bootstrapApplication(<%= appComponentName %>, config);
66

77
export default bootstrap;
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { NgModule } from '@angular/core';
22
import { ServerModule } from '@angular/platform-server';
33

4-
import { AppModule } from './app.module';
5-
import { App } from './app';
4+
import { <%= appModuleName %> } from '<%= appModulePath %>';
5+
import { <%= appComponentName %> } from '<%= appComponentPath %>';
66

77
@NgModule({
88
imports: [
9-
AppModule,
9+
<%= appModuleName %>,
1010
ServerModule,
1111
],
12-
bootstrap: [App],
12+
bootstrap: [<%= appComponentName %>],
1313
})
1414
export class AppServerModule {}
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { bootstrapApplication } from '@angular/platform-browser';
2-
import { App } from './app/app';
2+
import { <%= appComponentName %> } from '<%= appComponentPath %>';
33
import { config } from './app/app.config.server';
44

5-
const bootstrap = () => bootstrapApplication(App, config);
5+
const bootstrap = () => bootstrapApplication(<%= appComponentName %>, config);
66

77
export default bootstrap;

packages/schematics/angular/server/index.ts

+15
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { latestVersions } from '../utility/latest-versions';
2727
import { isStandaloneApp } from '../utility/ng-ast-utils';
2828
import { relativePathToWorkspaceRoot } from '../utility/paths';
2929
import { isUsingApplicationBuilder, targetBuildNotFoundError } from '../utility/project-targets';
30+
import { resolveBootstrappedComponentData } from '../utility/standalone/app_component';
3031
import { getMainFilePath } from '../utility/standalone/util';
3132
import { getWorkspace, updateWorkspace } from '../utility/workspace';
3233
import { Builders } from '../utility/workspace-models';
@@ -187,10 +188,24 @@ export default function (options: ServerOptions): Rule {
187188
let filesUrl = `./files/${usingApplicationBuilder ? 'application-builder/' : 'server-builder/'}`;
188189
filesUrl += isStandalone ? 'standalone-src' : 'ngmodule-src';
189190

191+
const { componentName, componentImportPathInSameFile, moduleName, moduleImportPathInSameFile } =
192+
resolveBootstrappedComponentData(host, browserEntryPoint) || {
193+
componentName: 'App',
194+
componentImportPathInSameFile: './app/app',
195+
moduleName: 'AppModule',
196+
moduleImportPathInSameFile: './app/app.module',
197+
};
190198
const templateSource = apply(url(filesUrl), [
191199
applyTemplates({
192200
...strings,
193201
...options,
202+
appComponentName: componentName,
203+
appComponentPath: componentImportPathInSameFile,
204+
appModuleName: moduleName,
205+
appModulePath:
206+
moduleImportPathInSameFile === null
207+
? null
208+
: `./${posix.basename(moduleImportPathInSameFile)}`,
194209
}),
195210
move(sourceRoot),
196211
]);

packages/schematics/angular/server/index_spec.ts

+120
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,84 @@ describe('Server Schematic', () => {
7070
);
7171
});
7272

73+
it('should account for renamed app component and module', async () => {
74+
appTree.create(
75+
'/projects/bar/src/app/my-custom-module.ts',
76+
`
77+
import { NgModule } from '@angular/core';
78+
import { BrowserModule } from '@angular/platform-browser';
79+
import { MyCustomApp } from './foo/bar/baz/app.foo';
80+
81+
@NgModule({
82+
declarations: [MyCustomApp],
83+
imports: [BrowserModule],
84+
bootstrap: [MyCustomApp]
85+
})
86+
export class MyCustomModule {}
87+
`,
88+
);
89+
90+
appTree.overwrite(
91+
'/projects/bar/src/main.ts',
92+
`
93+
import { platformBrowser } from '@angular/platform-browser';
94+
import { MyCustomModule } from './app/my-custom-module';
95+
96+
platformBrowser().bootstrapModule(MyCustomModule)
97+
.catch(err => console.error(err));
98+
`,
99+
);
100+
101+
const tree = await schematicRunner.runSchematic('server', defaultOptions, appTree);
102+
const filePath = '/projects/bar/src/app/app.module.server.ts';
103+
expect(tree.exists(filePath)).toBeTrue();
104+
const contents = tree.readContent(filePath);
105+
106+
expect(contents).toContain(`import { MyCustomApp } from './foo/bar/baz/app.foo';`);
107+
expect(contents).toContain(`import { MyCustomModule } from './my-custom-module';`);
108+
expect(contents).toContain(`imports: [MyCustomModule],`);
109+
expect(contents).toContain(`bootstrap: [MyCustomApp],`);
110+
});
111+
112+
it('should account for renamed app component and module that have been aliased', async () => {
113+
appTree.create(
114+
'/projects/bar/src/app/my-custom-module.ts',
115+
`
116+
import { NgModule } from '@angular/core';
117+
import { BrowserModule } from '@angular/platform-browser';
118+
import { MyCustomApp as MyAliasedApp } from './foo/bar/baz/app.foo';
119+
120+
@NgModule({
121+
declarations: [MyAliasedApp],
122+
imports: [BrowserModule],
123+
bootstrap: [MyAliasedApp]
124+
})
125+
export class MyCustomModule {}
126+
`,
127+
);
128+
129+
appTree.overwrite(
130+
'/projects/bar/src/main.ts',
131+
`
132+
import { platformBrowser } from '@angular/platform-browser';
133+
import { MyCustomModule as MyAliasedModule } from './app/my-custom-module';
134+
135+
platformBrowser().bootstrapModule(MyAliasedModule)
136+
.catch(err => console.error(err));
137+
`,
138+
);
139+
140+
const tree = await schematicRunner.runSchematic('server', defaultOptions, appTree);
141+
const filePath = '/projects/bar/src/app/app.module.server.ts';
142+
expect(tree.exists(filePath)).toBeTrue();
143+
const contents = tree.readContent(filePath);
144+
145+
expect(contents).toContain(`import { MyCustomApp } from './foo/bar/baz/app.foo';`);
146+
expect(contents).toContain(`import { MyCustomModule } from './my-custom-module';`);
147+
expect(contents).toContain(`imports: [MyCustomModule],`);
148+
expect(contents).toContain(`bootstrap: [MyCustomApp],`);
149+
});
150+
73151
it('should add dependency: @angular/platform-server', async () => {
74152
const tree = await schematicRunner.runSchematic('server', defaultOptions, appTree);
75153
const filePath = '/package.json';
@@ -127,6 +205,48 @@ describe('Server Schematic', () => {
127205
expect(contents).toContain(`bootstrapApplication(App, config)`);
128206
});
129207

208+
it('should account for renamed app component', async () => {
209+
appTree.overwrite(
210+
'/projects/bar/src/main.ts',
211+
`
212+
import { bootstrapApplication } from '@angular/platform-browser';
213+
import { appConfig } from './app/app.config';
214+
import { MyCustomApp } from './foo/bar/baz/app.foo';
215+
216+
bootstrapApplication(MyCustomApp, appConfig)
217+
.catch((err) => console.error(err));
218+
`,
219+
);
220+
221+
const tree = await schematicRunner.runSchematic('server', defaultOptions, appTree);
222+
const filePath = '/projects/bar/src/main.server.ts';
223+
expect(tree.exists(filePath)).toBeTrue();
224+
const contents = tree.readContent(filePath);
225+
expect(contents).toContain(`import { MyCustomApp } from './foo/bar/baz/app.foo';`);
226+
expect(contents).toContain(`bootstrapApplication(MyCustomApp, config)`);
227+
});
228+
229+
it('should account for renamed app component that is aliased within the main file', async () => {
230+
appTree.overwrite(
231+
'/projects/bar/src/main.ts',
232+
`
233+
import { bootstrapApplication } from '@angular/platform-browser';
234+
import { appConfig } from './app/app.config';
235+
import { MyCustomApp as MyCustomAlias } from './foo/bar/baz/app.foo';
236+
237+
bootstrapApplication(MyCustomAlias, appConfig)
238+
.catch((err) => console.error(err));
239+
`,
240+
);
241+
242+
const tree = await schematicRunner.runSchematic('server', defaultOptions, appTree);
243+
const filePath = '/projects/bar/src/main.server.ts';
244+
expect(tree.exists(filePath)).toBeTrue();
245+
const contents = tree.readContent(filePath);
246+
expect(contents).toContain(`import { MyCustomApp } from './foo/bar/baz/app.foo';`);
247+
expect(contents).toContain(`bootstrapApplication(MyCustomApp, config)`);
248+
});
249+
130250
it('should create server app config file', async () => {
131251
const tree = await schematicRunner.runSchematic('server', defaultOptions, appTree);
132252
const filePath = '/projects/bar/src/app/app.config.server.ts';

packages/schematics/angular/utility/ast-utils.ts

+3-7
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,7 @@ export function getDecoratorMetadata(
343343
export function getMetadataField(
344344
node: ts.ObjectLiteralExpression,
345345
metadataField: string,
346-
): ts.ObjectLiteralElement[] {
346+
): ts.PropertyAssignment[] {
347347
return (
348348
node.properties
349349
.filter(ts.isPropertyAssignment)
@@ -561,13 +561,9 @@ export function getRouterModuleDeclaration(source: ts.SourceFile): ts.Expression
561561
}
562562

563563
const matchingProperties = getMetadataField(node, 'imports');
564-
if (!matchingProperties) {
565-
return;
566-
}
567-
568-
const assignment = matchingProperties[0] as ts.PropertyAssignment;
564+
const assignment = matchingProperties[0];
569565

570-
if (assignment.initializer.kind !== ts.SyntaxKind.ArrayLiteralExpression) {
566+
if (!assignment || assignment.initializer.kind !== ts.SyntaxKind.ArrayLiteralExpression) {
571567
return;
572568
}
573569

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { SchematicsException, Tree } from '@angular-devkit/schematics';
10+
import ts from '../../third_party/github.com/Microsoft/TypeScript/lib/typescript';
11+
import { getDecoratorMetadata, getMetadataField } from '../ast-utils';
12+
import { findBootstrapModuleCall, getAppModulePath } from '../ng-ast-utils';
13+
import { findBootstrapApplicationCall, getSourceFile } from './util';
14+
15+
/** Data resolved for a bootstrapped component. */
16+
interface BootstrappedComponentData {
17+
/** Original name of the component class. */
18+
componentName: string;
19+
20+
/** Path under which the component was imported in the main entrypoint. */
21+
componentImportPathInSameFile: string;
22+
23+
/** Original name of the NgModule being bootstrapped, null if the app isn't module-based. */
24+
moduleName: string | null;
25+
26+
/**
27+
* Path under which the module was imported in the main entrypoint,
28+
* null if the app isn't module-based.
29+
*/
30+
moduleImportPathInSameFile: string | null;
31+
}
32+
33+
/**
34+
* Finds the original name and path relative to the `main.ts` of the bootrstrapped app component.
35+
* @param tree File tree in which to look for the component.
36+
* @param mainFilePath Path of the `main` file.
37+
*/
38+
export function resolveBootstrappedComponentData(
39+
tree: Tree,
40+
mainFilePath: string,
41+
): BootstrappedComponentData | null {
42+
// First try to resolve for a standalone app.
43+
try {
44+
const call = findBootstrapApplicationCall(tree, mainFilePath);
45+
46+
if (call.arguments.length > 0 && ts.isIdentifier(call.arguments[0])) {
47+
const resolved = resolveIdentifier(call.arguments[0]);
48+
49+
if (resolved) {
50+
return {
51+
componentName: resolved.name,
52+
componentImportPathInSameFile: resolved.path,
53+
moduleName: null,
54+
moduleImportPathInSameFile: null,
55+
};
56+
}
57+
}
58+
} catch (e) {
59+
// `findBootstrapApplicationCall` will throw if it can't find the `bootrstrapApplication` call.
60+
// Handle it gracefully by returning `null` instead so the consumer can recover.
61+
if (!(e instanceof SchematicsException)) {
62+
throw e;
63+
}
64+
}
65+
66+
// Otherwise fall back to resolving an NgModule-based app.
67+
return resolveNgModuleBasedData(tree, mainFilePath);
68+
}
69+
70+
/** Resolves the bootstrap data for a NgModule-based app. */
71+
function resolveNgModuleBasedData(
72+
tree: Tree,
73+
mainFilePath: string,
74+
): BootstrappedComponentData | null {
75+
const appModulePath = getAppModulePath(tree, mainFilePath);
76+
const appModuleFile = getSourceFile(tree, appModulePath);
77+
const metadataNodes = getDecoratorMetadata(appModuleFile, 'NgModule', '@angular/core');
78+
79+
for (const node of metadataNodes) {
80+
if (!ts.isObjectLiteralExpression(node)) {
81+
continue;
82+
}
83+
84+
const bootstrapProp = getMetadataField(node, 'bootstrap').find((prop) => {
85+
return (
86+
ts.isArrayLiteralExpression(prop.initializer) &&
87+
prop.initializer.elements.length > 0 &&
88+
ts.isIdentifier(prop.initializer.elements[0])
89+
);
90+
});
91+
92+
const componentIdentifier = (bootstrapProp?.initializer as ts.ArrayLiteralExpression)
93+
.elements[0] as ts.Identifier | undefined;
94+
const componentResult = componentIdentifier ? resolveIdentifier(componentIdentifier) : null;
95+
const bootstrapCall = findBootstrapModuleCall(tree, mainFilePath);
96+
97+
if (
98+
componentResult &&
99+
bootstrapCall &&
100+
bootstrapCall.arguments.length > 0 &&
101+
ts.isIdentifier(bootstrapCall.arguments[0])
102+
) {
103+
const moduleResult = resolveIdentifier(bootstrapCall.arguments[0]);
104+
105+
if (moduleResult) {
106+
return {
107+
componentName: componentResult.name,
108+
componentImportPathInSameFile: componentResult.path,
109+
moduleName: moduleResult.name,
110+
moduleImportPathInSameFile: moduleResult.path,
111+
};
112+
}
113+
}
114+
}
115+
116+
return null;
117+
}
118+
119+
/** Resolves an identifier to its original name and path that it was imported from. */
120+
function resolveIdentifier(identifier: ts.Identifier): { name: string; path: string } | null {
121+
const sourceFile = identifier.getSourceFile();
122+
123+
// Try to resolve the import path by looking at the top-level named imports of the file.
124+
for (const node of sourceFile.statements) {
125+
if (
126+
!ts.isImportDeclaration(node) ||
127+
!ts.isStringLiteral(node.moduleSpecifier) ||
128+
!node.importClause ||
129+
!node.importClause.namedBindings ||
130+
!ts.isNamedImports(node.importClause.namedBindings)
131+
) {
132+
continue;
133+
}
134+
135+
for (const element of node.importClause.namedBindings.elements) {
136+
if (element.name.text === identifier.text) {
137+
return {
138+
// Note that we use `propertyName` if available, because it contains
139+
// the real name in the case where the import is aliased.
140+
name: (element.propertyName || element.name).text,
141+
path: node.moduleSpecifier.text,
142+
};
143+
}
144+
}
145+
}
146+
147+
return null;
148+
}

0 commit comments

Comments
 (0)