diff --git a/packages/schematics/angular/app-shell/index.ts b/packages/schematics/angular/app-shell/index.ts
index 0e7e966368..0a0b549194 100644
--- a/packages/schematics/angular/app-shell/index.ts
+++ b/packages/schematics/angular/app-shell/index.ts
@@ -16,19 +16,20 @@ import {
noop,
schematic,
} from '@angular-devkit/schematics';
-import { Schema as ComponentOptions } from '../component/schema';
+import { findBootstrapApplicationCall } from '../private/standalone';
import * as ts from '../third_party/github.com/Microsoft/TypeScript/lib/typescript';
import {
addImportToModule,
addSymbolToNgModuleMetadata,
findNode,
+ findNodes,
getDecoratorMetadata,
getSourceNodes,
insertImport,
isImported,
} from '../utility/ast-utils';
import { applyToUpdateRecorder } from '../utility/change';
-import { getAppModulePath } from '../utility/ng-ast-utils';
+import { getAppModulePath, isStandaloneApp } from '../utility/ng-ast-utils';
import { targetBuildNotFoundError } from '../utility/project-targets';
import { getWorkspace, updateWorkspace } from '../utility/workspace';
import { BrowserBuilderOptions, Builders, ServerBuilderOptions } from '../utility/workspace-models';
@@ -87,20 +88,34 @@ function getComponentTemplate(host: Tree, compPath: string, tmplInfo: TemplateIn
}
function getBootstrapComponentPath(host: Tree, mainPath: string): string {
- const modulePath = getAppModulePath(host, mainPath);
- const moduleSource = getSourceFile(host, modulePath);
+ const mainSource = getSourceFile(host, mainPath);
+ const bootstrapAppCall = findBootstrapApplicationCall(mainSource);
- const metadataNode = getDecoratorMetadata(moduleSource, 'NgModule', '@angular/core')[0];
- const bootstrapProperty = getMetadataProperty(metadataNode, 'bootstrap');
+ let bootstrappingFilePath: string;
+ let bootstrappingSource: ts.SourceFile;
+ let componentName: string;
- const arrLiteral = bootstrapProperty.initializer as ts.ArrayLiteralExpression;
+ if (bootstrapAppCall) {
+ // Standalone Application
+ componentName = bootstrapAppCall.arguments[0].getText();
+ bootstrappingFilePath = mainPath;
+ bootstrappingSource = mainSource;
+ } else {
+ // NgModule Application
+ const modulePath = getAppModulePath(host, mainPath);
+ const moduleSource = getSourceFile(host, modulePath);
+ const metadataNode = getDecoratorMetadata(moduleSource, 'NgModule', '@angular/core')[0];
+ const bootstrapProperty = getMetadataProperty(metadataNode, 'bootstrap');
+ const arrLiteral = bootstrapProperty.initializer as ts.ArrayLiteralExpression;
+ componentName = arrLiteral.elements[0].getText();
+ bootstrappingSource = moduleSource;
+ bootstrappingFilePath = modulePath;
+ }
- const componentSymbol = arrLiteral.elements[0].getText();
-
- const relativePath = getSourceNodes(moduleSource)
+ const componentRelativeFilePath = getSourceNodes(bootstrappingSource)
.filter(ts.isImportDeclaration)
.filter((imp) => {
- return findNode(imp, ts.SyntaxKind.Identifier, componentSymbol);
+ return findNode(imp, ts.SyntaxKind.Identifier, componentName);
})
.map((imp) => {
const pathStringLiteral = imp.moduleSpecifier as ts.StringLiteral;
@@ -108,7 +123,7 @@ function getBootstrapComponentPath(host: Tree, mainPath: string): string {
return pathStringLiteral.text;
})[0];
- return join(dirname(normalize(modulePath)), relativePath + '.ts');
+ return join(dirname(normalize(bootstrappingFilePath)), componentRelativeFilePath + '.ts');
}
// end helper functions.
@@ -300,14 +315,97 @@ function addServerRoutes(options: AppShellOptions): Rule {
};
}
-function addShellComponent(options: AppShellOptions): Rule {
- const componentOptions: ComponentOptions = {
- name: 'app-shell',
- module: options.rootModuleFileName,
- project: options.project,
- };
+function addStandaloneServerRoute(options: AppShellOptions): Rule {
+ return async (host: Tree) => {
+ const workspace = await getWorkspace(host);
+ const project = workspace.projects.get(options.project);
+ if (!project) {
+ throw new SchematicsException(`Project name "${options.project}" doesn't not exist.`);
+ }
- return schematic('component', componentOptions);
+ const configFilePath = join(normalize(project.sourceRoot ?? 'src'), 'app/app.config.server.ts');
+ if (!host.exists(configFilePath)) {
+ throw new SchematicsException(`Cannot find "${configFilePath}".`);
+ }
+
+ let configSourceFile = getSourceFile(host, configFilePath);
+ if (!isImported(configSourceFile, 'ROUTES', '@angular/router')) {
+ const routesChange = insertImport(
+ configSourceFile,
+ configFilePath,
+ 'ROUTES',
+ '@angular/router',
+ );
+
+ const recorder = host.beginUpdate(configFilePath);
+ if (routesChange) {
+ applyToUpdateRecorder(recorder, [routesChange]);
+ host.commitUpdate(recorder);
+ }
+ }
+
+ configSourceFile = getSourceFile(host, configFilePath);
+ const providersLiteral = findNodes(configSourceFile, ts.isPropertyAssignment).find(
+ (n) => ts.isArrayLiteralExpression(n.initializer) && n.name.getText() === 'providers',
+ )?.initializer as ts.ArrayLiteralExpression | undefined;
+ if (!providersLiteral) {
+ throw new SchematicsException(
+ `Cannot find the "providers" configuration in "${configFilePath}".`,
+ );
+ }
+
+ // Add route to providers literal.
+ const newProvidersLiteral = ts.factory.updateArrayLiteralExpression(providersLiteral, [
+ ...providersLiteral.elements,
+ ts.factory.createObjectLiteralExpression(
+ [
+ ts.factory.createPropertyAssignment('provide', ts.factory.createIdentifier('ROUTES')),
+ ts.factory.createPropertyAssignment('multi', ts.factory.createIdentifier('true')),
+ ts.factory.createPropertyAssignment(
+ 'useValue',
+ ts.factory.createArrayLiteralExpression(
+ [
+ ts.factory.createObjectLiteralExpression(
+ [
+ ts.factory.createPropertyAssignment(
+ 'path',
+ ts.factory.createIdentifier(`'${options.route}'`),
+ ),
+ ts.factory.createPropertyAssignment(
+ 'component',
+ ts.factory.createIdentifier('AppShellComponent'),
+ ),
+ ],
+ true,
+ ),
+ ],
+ true,
+ ),
+ ),
+ ],
+ true,
+ ),
+ ]);
+
+ const recorder = host.beginUpdate(configFilePath);
+ recorder.remove(providersLiteral.getStart(), providersLiteral.getWidth());
+ const printer = ts.createPrinter();
+ recorder.insertRight(
+ providersLiteral.getStart(),
+ printer.printNode(ts.EmitHint.Unspecified, newProvidersLiteral, configSourceFile),
+ );
+
+ // Add AppShellComponent import
+ const appShellImportChange = insertImport(
+ configSourceFile,
+ configFilePath,
+ 'AppShellComponent',
+ './app-shell/app-shell.component',
+ );
+
+ applyToUpdateRecorder(recorder, [appShellImportChange]);
+ host.commitUpdate(recorder);
+ };
}
export default function (options: AppShellOptions): Rule {
@@ -324,13 +422,20 @@ export default function (options: AppShellOptions): Rule {
const clientBuildOptions = (clientBuildTarget.options ||
{}) as unknown as BrowserBuilderOptions;
+ const isStandalone = isStandaloneApp(tree, clientBuildOptions.main);
+
return chain([
validateProject(clientBuildOptions.main),
clientProject.targets.has('server') ? noop() : addUniversalTarget(options),
addAppShellConfigToWorkspace(options),
- addRouterModule(clientBuildOptions.main),
- addServerRoutes(options),
- addShellComponent(options),
+ isStandalone ? noop() : addRouterModule(clientBuildOptions.main),
+ isStandalone ? addStandaloneServerRoute(options) : addServerRoutes(options),
+ schematic('component', {
+ name: 'app-shell',
+ module: options.rootModuleFileName,
+ project: options.project,
+ standalone: isStandalone,
+ }),
]);
};
}
diff --git a/packages/schematics/angular/app-shell/index_spec.ts b/packages/schematics/angular/app-shell/index_spec.ts
index b53a6efb74..35fd342538 100644
--- a/packages/schematics/angular/app-shell/index_spec.ts
+++ b/packages/schematics/angular/app-shell/index_spec.ts
@@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
+import { tags } from '@angular-devkit/core';
import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';
import { Schema as ApplicationOptions } from '../application/schema';
import { Schema as WorkspaceOptions } from '../workspace/schema';
@@ -185,4 +186,87 @@ describe('App Shell Schematic', () => {
const content = tree.readContent('/projects/bar/src/app/app.server.module.ts');
expect(content).toMatch(/app-shell\.component/);
});
+
+ describe('standalone application', () => {
+ const standaloneAppName = 'baz';
+ const standaloneAppOptions: ApplicationOptions = {
+ ...appOptions,
+ name: standaloneAppName,
+ standalone: true,
+ };
+ const defaultStandaloneOptions: AppShellOptions = {
+ project: standaloneAppName,
+ };
+
+ beforeEach(async () => {
+ appTree = await schematicRunner.runSchematic('application', standaloneAppOptions, appTree);
+ });
+
+ it('should ensure the client app has a router-outlet', async () => {
+ appTree = await schematicRunner.runSchematic('workspace', workspaceOptions);
+ appTree = await schematicRunner.runSchematic(
+ 'application',
+ { ...standaloneAppOptions, routing: false },
+ appTree,
+ );
+ await expectAsync(
+ schematicRunner.runSchematic('app-shell', defaultStandaloneOptions, appTree),
+ ).toBeRejected();
+ });
+
+ it('should create the shell component', async () => {
+ const tree = await schematicRunner.runSchematic(
+ 'app-shell',
+ defaultStandaloneOptions,
+ appTree,
+ );
+ expect(tree.exists('/projects/baz/src/app/app-shell/app-shell.component.ts')).toBe(true);
+ const content = tree.readContent('/projects/baz/src/app/app.config.server.ts');
+ expect(content).toMatch(/app-shell\.component/);
+ });
+
+ it('should define a server route', async () => {
+ const tree = await schematicRunner.runSchematic(
+ 'app-shell',
+ defaultStandaloneOptions,
+ appTree,
+ );
+ const filePath = '/projects/baz/src/app/app.config.server.ts';
+ const content = tree.readContent(filePath);
+ expect(tags.oneLine`${content}`).toContain(tags.oneLine`{
+ provide: ROUTES,
+ multi: true,
+ useValue: [
+ {
+ path: 'shell',
+ component: AppShellComponent
+ }
+ ]
+ }`);
+ });
+
+ it(`should add import to 'ROUTES' token from '@angular/router'`, async () => {
+ const tree = await schematicRunner.runSchematic(
+ 'app-shell',
+ defaultStandaloneOptions,
+ appTree,
+ );
+ const filePath = '/projects/baz/src/app/app.config.server.ts';
+ const content = tree.readContent(filePath);
+ expect(content).toContain(`import { ROUTES } from '@angular/router';`);
+ });
+
+ it(`should add import to 'AppShellComponent'`, async () => {
+ const tree = await schematicRunner.runSchematic(
+ 'app-shell',
+ defaultStandaloneOptions,
+ appTree,
+ );
+ const filePath = '/projects/baz/src/app/app.config.server.ts';
+ const content = tree.readContent(filePath);
+ expect(content).toContain(
+ `import { AppShellComponent } from './app-shell/app-shell.component';`,
+ );
+ });
+ });
});
diff --git a/tests/legacy-cli/e2e/tests/build/app-shell/app-shell-standalone.ts b/tests/legacy-cli/e2e/tests/build/app-shell/app-shell-standalone.ts
index 7abbbae9fa..d002da509a 100644
--- a/tests/legacy-cli/e2e/tests/build/app-shell/app-shell-standalone.ts
+++ b/tests/legacy-cli/e2e/tests/build/app-shell/app-shell-standalone.ts
@@ -1,5 +1,5 @@
import { getGlobalVariable } from '../../../utils/env';
-import { appendToFile, expectFileToMatch, writeMultipleFiles } from '../../../utils/fs';
+import { expectFileToMatch } from '../../../utils/fs';
import { installPackage } from '../../../utils/packages';
import { ng } from '../../../utils/process';
import { updateJsonFile } from '../../../utils/project';
@@ -7,8 +7,8 @@ import { updateJsonFile } from '../../../utils/project';
const snapshots = require('../../../ng-snapshot/package.json');
export default async function () {
- await appendToFile('src/app/app.component.html', '');
- await ng('generate', 'app-shell', '--project', 'test-project');
+ await ng('generate', 'app', 'test-project-two', '--routing', '--standalone', '--skip-install');
+ await ng('generate', 'app-shell', '--project', 'test-project-two');
const isSnapshotBuild = getGlobalVariable('argv')['ng-snapshots'];
if (isSnapshotBuild) {
@@ -30,55 +30,9 @@ export default async function () {
}
}
- // TODO(alanagius): update the below once we have a standalone schematic.
- await writeMultipleFiles({
- 'src/app/app.component.ts': `
- import { Component } from '@angular/core';
- import { RouterOutlet } from '@angular/router';
+ await ng('run', 'test-project-two:app-shell:development');
+ await expectFileToMatch('dist/test-project-two/browser/index.html', 'app-shell works!');
- @Component({
- selector: 'app-root',
- standalone: true,
- template: '',
- imports: [RouterOutlet],
- })
- export class AppComponent {}
- `,
- 'src/main.ts': `
- import { bootstrapApplication } from '@angular/platform-browser';
- import { provideRouter } from '@angular/router';
-
- import { AppComponent } from './app/app.component';
-
- bootstrapApplication(AppComponent, {
- providers: [
- provideRouter([]),
- ],
- });
- `,
- 'src/main.server.ts': `
- import { importProvidersFrom } from '@angular/core';
- import { BrowserModule, bootstrapApplication } from '@angular/platform-browser';
- import { ServerModule } from '@angular/platform-server';
-
- import { provideRouter } from '@angular/router';
-
- import { AppShellComponent } from './app/app-shell/app-shell.component';
- import { AppComponent } from './app/app.component';
-
- export default () => bootstrapApplication(AppComponent, {
- providers: [
- importProvidersFrom(BrowserModule),
- importProvidersFrom(ServerModule),
- provideRouter([{ path: 'shell', component: AppShellComponent }]),
- ],
- });
- `,
- });
-
- await ng('run', 'test-project:app-shell:development');
- await expectFileToMatch('dist/test-project/browser/index.html', /app-shell works!/);
-
- await ng('run', 'test-project:app-shell');
- await expectFileToMatch('dist/test-project/browser/index.html', /app-shell works!/);
+ await ng('run', 'test-project-two:app-shell');
+ await expectFileToMatch('dist/test-project-two/browser/index.html', 'app-shell works!');
}