Skip to content

Commit 50b9e59

Browse files
alan-agius4angular-robot[bot]
authored andcommitted
feat(@schematics/angular): update app-shell schematic to support standalone applications
This commit adds support to run `ng generate app-shell` in standalone applications.
1 parent dd02caa commit 50b9e59

File tree

3 files changed

+220
-77
lines changed

3 files changed

+220
-77
lines changed

packages/schematics/angular/app-shell/index.ts

+129-24
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,20 @@ import {
1616
noop,
1717
schematic,
1818
} from '@angular-devkit/schematics';
19-
import { Schema as ComponentOptions } from '../component/schema';
19+
import { findBootstrapApplicationCall } from '../private/standalone';
2020
import * as ts from '../third_party/github.com/Microsoft/TypeScript/lib/typescript';
2121
import {
2222
addImportToModule,
2323
addSymbolToNgModuleMetadata,
2424
findNode,
25+
findNodes,
2526
getDecoratorMetadata,
2627
getSourceNodes,
2728
insertImport,
2829
isImported,
2930
} from '../utility/ast-utils';
3031
import { applyToUpdateRecorder } from '../utility/change';
31-
import { getAppModulePath } from '../utility/ng-ast-utils';
32+
import { getAppModulePath, isStandaloneApp } from '../utility/ng-ast-utils';
3233
import { targetBuildNotFoundError } from '../utility/project-targets';
3334
import { getWorkspace, updateWorkspace } from '../utility/workspace';
3435
import { BrowserBuilderOptions, Builders, ServerBuilderOptions } from '../utility/workspace-models';
@@ -87,28 +88,42 @@ function getComponentTemplate(host: Tree, compPath: string, tmplInfo: TemplateIn
8788
}
8889

8990
function getBootstrapComponentPath(host: Tree, mainPath: string): string {
90-
const modulePath = getAppModulePath(host, mainPath);
91-
const moduleSource = getSourceFile(host, modulePath);
92-
93-
const metadataNode = getDecoratorMetadata(moduleSource, 'NgModule', '@angular/core')[0];
94-
const bootstrapProperty = getMetadataProperty(metadataNode, 'bootstrap');
95-
96-
const arrLiteral = bootstrapProperty.initializer as ts.ArrayLiteralExpression;
97-
98-
const componentSymbol = arrLiteral.elements[0].getText();
91+
const mainSource = getSourceFile(host, mainPath);
92+
const bootstrapAppCall = findBootstrapApplicationCall(mainSource);
93+
94+
let bootstrappingFilePath: string;
95+
let bootstrappingSource: ts.SourceFile;
96+
let componentName: string;
97+
98+
if (bootstrapAppCall) {
99+
// Standalone Application
100+
componentName = bootstrapAppCall.arguments[0].getText();
101+
bootstrappingFilePath = mainPath;
102+
bootstrappingSource = mainSource;
103+
} else {
104+
// NgModule Application
105+
const modulePath = getAppModulePath(host, mainPath);
106+
const moduleSource = getSourceFile(host, modulePath);
107+
const metadataNode = getDecoratorMetadata(moduleSource, 'NgModule', '@angular/core')[0];
108+
const bootstrapProperty = getMetadataProperty(metadataNode, 'bootstrap');
109+
const arrLiteral = bootstrapProperty.initializer as ts.ArrayLiteralExpression;
110+
componentName = arrLiteral.elements[0].getText();
111+
bootstrappingSource = moduleSource;
112+
bootstrappingFilePath = modulePath;
113+
}
99114

100-
const relativePath = getSourceNodes(moduleSource)
115+
const componentRelativeFilePath = getSourceNodes(bootstrappingSource)
101116
.filter(ts.isImportDeclaration)
102117
.filter((imp) => {
103-
return findNode(imp, ts.SyntaxKind.Identifier, componentSymbol);
118+
return findNode(imp, ts.SyntaxKind.Identifier, componentName);
104119
})
105120
.map((imp) => {
106121
const pathStringLiteral = imp.moduleSpecifier as ts.StringLiteral;
107122

108123
return pathStringLiteral.text;
109124
})[0];
110125

111-
return join(dirname(normalize(modulePath)), relativePath + '.ts');
126+
return join(dirname(normalize(bootstrappingFilePath)), componentRelativeFilePath + '.ts');
112127
}
113128
// end helper functions.
114129

@@ -300,14 +315,97 @@ function addServerRoutes(options: AppShellOptions): Rule {
300315
};
301316
}
302317

303-
function addShellComponent(options: AppShellOptions): Rule {
304-
const componentOptions: ComponentOptions = {
305-
name: 'app-shell',
306-
module: options.rootModuleFileName,
307-
project: options.project,
308-
};
318+
function addStandaloneServerRoute(options: AppShellOptions): Rule {
319+
return async (host: Tree) => {
320+
const workspace = await getWorkspace(host);
321+
const project = workspace.projects.get(options.project);
322+
if (!project) {
323+
throw new SchematicsException(`Project name "${options.project}" doesn't not exist.`);
324+
}
325+
326+
const configFilePath = join(normalize(project.sourceRoot ?? 'src'), 'app/app.config.server.ts');
327+
if (!host.exists(configFilePath)) {
328+
throw new SchematicsException(`Cannot find "${configFilePath}".`);
329+
}
330+
331+
let configSourceFile = getSourceFile(host, configFilePath);
332+
if (!isImported(configSourceFile, 'ROUTES', '@angular/router')) {
333+
const routesChange = insertImport(
334+
configSourceFile,
335+
configFilePath,
336+
'ROUTES',
337+
'@angular/router',
338+
);
309339

310-
return schematic('component', componentOptions);
340+
const recorder = host.beginUpdate(configFilePath);
341+
if (routesChange) {
342+
applyToUpdateRecorder(recorder, [routesChange]);
343+
host.commitUpdate(recorder);
344+
}
345+
}
346+
347+
configSourceFile = getSourceFile(host, configFilePath);
348+
const providersLiteral = findNodes(configSourceFile, ts.isPropertyAssignment).find(
349+
(n) => ts.isArrayLiteralExpression(n.initializer) && n.name.getText() === 'providers',
350+
)?.initializer as ts.ArrayLiteralExpression | undefined;
351+
if (!providersLiteral) {
352+
throw new SchematicsException(
353+
`Cannot find the "providers" configuration in "${configFilePath}".`,
354+
);
355+
}
356+
357+
// Add route to providers literal.
358+
const newProvidersLiteral = ts.factory.updateArrayLiteralExpression(providersLiteral, [
359+
...providersLiteral.elements,
360+
ts.factory.createObjectLiteralExpression(
361+
[
362+
ts.factory.createPropertyAssignment('provide', ts.factory.createIdentifier('ROUTES')),
363+
ts.factory.createPropertyAssignment('multi', ts.factory.createIdentifier('true')),
364+
ts.factory.createPropertyAssignment(
365+
'useValue',
366+
ts.factory.createArrayLiteralExpression(
367+
[
368+
ts.factory.createObjectLiteralExpression(
369+
[
370+
ts.factory.createPropertyAssignment(
371+
'path',
372+
ts.factory.createIdentifier(`'${options.route}'`),
373+
),
374+
ts.factory.createPropertyAssignment(
375+
'component',
376+
ts.factory.createIdentifier('AppShellComponent'),
377+
),
378+
],
379+
true,
380+
),
381+
],
382+
true,
383+
),
384+
),
385+
],
386+
true,
387+
),
388+
]);
389+
390+
const recorder = host.beginUpdate(configFilePath);
391+
recorder.remove(providersLiteral.getStart(), providersLiteral.getWidth());
392+
const printer = ts.createPrinter();
393+
recorder.insertRight(
394+
providersLiteral.getStart(),
395+
printer.printNode(ts.EmitHint.Unspecified, newProvidersLiteral, configSourceFile),
396+
);
397+
398+
// Add AppShellComponent import
399+
const appShellImportChange = insertImport(
400+
configSourceFile,
401+
configFilePath,
402+
'AppShellComponent',
403+
'./app-shell/app-shell.component',
404+
);
405+
406+
applyToUpdateRecorder(recorder, [appShellImportChange]);
407+
host.commitUpdate(recorder);
408+
};
311409
}
312410

313411
export default function (options: AppShellOptions): Rule {
@@ -324,13 +422,20 @@ export default function (options: AppShellOptions): Rule {
324422
const clientBuildOptions = (clientBuildTarget.options ||
325423
{}) as unknown as BrowserBuilderOptions;
326424

425+
const isStandalone = isStandaloneApp(tree, clientBuildOptions.main);
426+
327427
return chain([
328428
validateProject(clientBuildOptions.main),
329429
clientProject.targets.has('server') ? noop() : addUniversalTarget(options),
330430
addAppShellConfigToWorkspace(options),
331-
addRouterModule(clientBuildOptions.main),
332-
addServerRoutes(options),
333-
addShellComponent(options),
431+
isStandalone ? noop() : addRouterModule(clientBuildOptions.main),
432+
isStandalone ? addStandaloneServerRoute(options) : addServerRoutes(options),
433+
schematic('component', {
434+
name: 'app-shell',
435+
module: options.rootModuleFileName,
436+
project: options.project,
437+
standalone: isStandalone,
438+
}),
334439
]);
335440
};
336441
}

packages/schematics/angular/app-shell/index_spec.ts

+84
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
import { tags } from '@angular-devkit/core';
910
import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';
1011
import { Schema as ApplicationOptions } from '../application/schema';
1112
import { Schema as WorkspaceOptions } from '../workspace/schema';
@@ -185,4 +186,87 @@ describe('App Shell Schematic', () => {
185186
const content = tree.readContent('/projects/bar/src/app/app.server.module.ts');
186187
expect(content).toMatch(/app-shell\.component/);
187188
});
189+
190+
describe('standalone application', () => {
191+
const standaloneAppName = 'baz';
192+
const standaloneAppOptions: ApplicationOptions = {
193+
...appOptions,
194+
name: standaloneAppName,
195+
standalone: true,
196+
};
197+
const defaultStandaloneOptions: AppShellOptions = {
198+
project: standaloneAppName,
199+
};
200+
201+
beforeEach(async () => {
202+
appTree = await schematicRunner.runSchematic('application', standaloneAppOptions, appTree);
203+
});
204+
205+
it('should ensure the client app has a router-outlet', async () => {
206+
appTree = await schematicRunner.runSchematic('workspace', workspaceOptions);
207+
appTree = await schematicRunner.runSchematic(
208+
'application',
209+
{ ...standaloneAppOptions, routing: false },
210+
appTree,
211+
);
212+
await expectAsync(
213+
schematicRunner.runSchematic('app-shell', defaultStandaloneOptions, appTree),
214+
).toBeRejected();
215+
});
216+
217+
it('should create the shell component', async () => {
218+
const tree = await schematicRunner.runSchematic(
219+
'app-shell',
220+
defaultStandaloneOptions,
221+
appTree,
222+
);
223+
expect(tree.exists('/projects/baz/src/app/app-shell/app-shell.component.ts')).toBe(true);
224+
const content = tree.readContent('/projects/baz/src/app/app.config.server.ts');
225+
expect(content).toMatch(/app-shell\.component/);
226+
});
227+
228+
it('should define a server route', async () => {
229+
const tree = await schematicRunner.runSchematic(
230+
'app-shell',
231+
defaultStandaloneOptions,
232+
appTree,
233+
);
234+
const filePath = '/projects/baz/src/app/app.config.server.ts';
235+
const content = tree.readContent(filePath);
236+
expect(tags.oneLine`${content}`).toContain(tags.oneLine`{
237+
provide: ROUTES,
238+
multi: true,
239+
useValue: [
240+
{
241+
path: 'shell',
242+
component: AppShellComponent
243+
}
244+
]
245+
}`);
246+
});
247+
248+
it(`should add import to 'ROUTES' token from '@angular/router'`, async () => {
249+
const tree = await schematicRunner.runSchematic(
250+
'app-shell',
251+
defaultStandaloneOptions,
252+
appTree,
253+
);
254+
const filePath = '/projects/baz/src/app/app.config.server.ts';
255+
const content = tree.readContent(filePath);
256+
expect(content).toContain(`import { ROUTES } from '@angular/router';`);
257+
});
258+
259+
it(`should add import to 'AppShellComponent'`, async () => {
260+
const tree = await schematicRunner.runSchematic(
261+
'app-shell',
262+
defaultStandaloneOptions,
263+
appTree,
264+
);
265+
const filePath = '/projects/baz/src/app/app.config.server.ts';
266+
const content = tree.readContent(filePath);
267+
expect(content).toContain(
268+
`import { AppShellComponent } from './app-shell/app-shell.component';`,
269+
);
270+
});
271+
});
188272
});
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { getGlobalVariable } from '../../../utils/env';
2-
import { appendToFile, expectFileToMatch, writeMultipleFiles } from '../../../utils/fs';
2+
import { expectFileToMatch } from '../../../utils/fs';
33
import { installPackage } from '../../../utils/packages';
44
import { ng } from '../../../utils/process';
55
import { updateJsonFile } from '../../../utils/project';
66

77
const snapshots = require('../../../ng-snapshot/package.json');
88

99
export default async function () {
10-
await appendToFile('src/app/app.component.html', '<router-outlet></router-outlet>');
11-
await ng('generate', 'app-shell', '--project', 'test-project');
10+
await ng('generate', 'app', 'test-project-two', '--routing', '--standalone', '--skip-install');
11+
await ng('generate', 'app-shell', '--project', 'test-project-two');
1212

1313
const isSnapshotBuild = getGlobalVariable('argv')['ng-snapshots'];
1414
if (isSnapshotBuild) {
@@ -30,55 +30,9 @@ export default async function () {
3030
}
3131
}
3232

33-
// TODO(alanagius): update the below once we have a standalone schematic.
34-
await writeMultipleFiles({
35-
'src/app/app.component.ts': `
36-
import { Component } from '@angular/core';
37-
import { RouterOutlet } from '@angular/router';
33+
await ng('run', 'test-project-two:app-shell:development');
34+
await expectFileToMatch('dist/test-project-two/browser/index.html', 'app-shell works!');
3835

39-
@Component({
40-
selector: 'app-root',
41-
standalone: true,
42-
template: '<router-outlet></router-outlet>',
43-
imports: [RouterOutlet],
44-
})
45-
export class AppComponent {}
46-
`,
47-
'src/main.ts': `
48-
import { bootstrapApplication } from '@angular/platform-browser';
49-
import { provideRouter } from '@angular/router';
50-
51-
import { AppComponent } from './app/app.component';
52-
53-
bootstrapApplication(AppComponent, {
54-
providers: [
55-
provideRouter([]),
56-
],
57-
});
58-
`,
59-
'src/main.server.ts': `
60-
import { importProvidersFrom } from '@angular/core';
61-
import { BrowserModule, bootstrapApplication } from '@angular/platform-browser';
62-
import { ServerModule } from '@angular/platform-server';
63-
64-
import { provideRouter } from '@angular/router';
65-
66-
import { AppShellComponent } from './app/app-shell/app-shell.component';
67-
import { AppComponent } from './app/app.component';
68-
69-
export default () => bootstrapApplication(AppComponent, {
70-
providers: [
71-
importProvidersFrom(BrowserModule),
72-
importProvidersFrom(ServerModule),
73-
provideRouter([{ path: 'shell', component: AppShellComponent }]),
74-
],
75-
});
76-
`,
77-
});
78-
79-
await ng('run', 'test-project:app-shell:development');
80-
await expectFileToMatch('dist/test-project/browser/index.html', /app-shell works!/);
81-
82-
await ng('run', 'test-project:app-shell');
83-
await expectFileToMatch('dist/test-project/browser/index.html', /app-shell works!/);
36+
await ng('run', 'test-project-two:app-shell');
37+
await expectFileToMatch('dist/test-project-two/browser/index.html', 'app-shell works!');
8438
}

0 commit comments

Comments
 (0)