feat(@schematics/angular): update app-shell schematic to support standalone applications

This commit adds support to run `ng generate app-shell` in standalone applications.
This commit is contained in:
Alan Agius 2023-03-28 14:05:30 +00:00 committed by angular-robot[bot]
parent dd02caa61e
commit 50b9e59a50
3 changed files with 218 additions and 75 deletions

View File

@ -16,19 +16,20 @@ import {
noop, noop,
schematic, schematic,
} from '@angular-devkit/schematics'; } 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 * as ts from '../third_party/github.com/Microsoft/TypeScript/lib/typescript';
import { import {
addImportToModule, addImportToModule,
addSymbolToNgModuleMetadata, addSymbolToNgModuleMetadata,
findNode, findNode,
findNodes,
getDecoratorMetadata, getDecoratorMetadata,
getSourceNodes, getSourceNodes,
insertImport, insertImport,
isImported, isImported,
} from '../utility/ast-utils'; } from '../utility/ast-utils';
import { applyToUpdateRecorder } from '../utility/change'; 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 { targetBuildNotFoundError } from '../utility/project-targets';
import { getWorkspace, updateWorkspace } from '../utility/workspace'; import { getWorkspace, updateWorkspace } from '../utility/workspace';
import { BrowserBuilderOptions, Builders, ServerBuilderOptions } from '../utility/workspace-models'; 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 { function getBootstrapComponentPath(host: Tree, mainPath: string): string {
const mainSource = getSourceFile(host, mainPath);
const bootstrapAppCall = findBootstrapApplicationCall(mainSource);
let bootstrappingFilePath: string;
let bootstrappingSource: ts.SourceFile;
let componentName: string;
if (bootstrapAppCall) {
// Standalone Application
componentName = bootstrapAppCall.arguments[0].getText();
bootstrappingFilePath = mainPath;
bootstrappingSource = mainSource;
} else {
// NgModule Application
const modulePath = getAppModulePath(host, mainPath); const modulePath = getAppModulePath(host, mainPath);
const moduleSource = getSourceFile(host, modulePath); const moduleSource = getSourceFile(host, modulePath);
const metadataNode = getDecoratorMetadata(moduleSource, 'NgModule', '@angular/core')[0]; const metadataNode = getDecoratorMetadata(moduleSource, 'NgModule', '@angular/core')[0];
const bootstrapProperty = getMetadataProperty(metadataNode, 'bootstrap'); const bootstrapProperty = getMetadataProperty(metadataNode, 'bootstrap');
const arrLiteral = bootstrapProperty.initializer as ts.ArrayLiteralExpression; const arrLiteral = bootstrapProperty.initializer as ts.ArrayLiteralExpression;
componentName = arrLiteral.elements[0].getText();
bootstrappingSource = moduleSource;
bootstrappingFilePath = modulePath;
}
const componentSymbol = arrLiteral.elements[0].getText(); const componentRelativeFilePath = getSourceNodes(bootstrappingSource)
const relativePath = getSourceNodes(moduleSource)
.filter(ts.isImportDeclaration) .filter(ts.isImportDeclaration)
.filter((imp) => { .filter((imp) => {
return findNode(imp, ts.SyntaxKind.Identifier, componentSymbol); return findNode(imp, ts.SyntaxKind.Identifier, componentName);
}) })
.map((imp) => { .map((imp) => {
const pathStringLiteral = imp.moduleSpecifier as ts.StringLiteral; const pathStringLiteral = imp.moduleSpecifier as ts.StringLiteral;
@ -108,7 +123,7 @@ function getBootstrapComponentPath(host: Tree, mainPath: string): string {
return pathStringLiteral.text; return pathStringLiteral.text;
})[0]; })[0];
return join(dirname(normalize(modulePath)), relativePath + '.ts'); return join(dirname(normalize(bootstrappingFilePath)), componentRelativeFilePath + '.ts');
} }
// end helper functions. // end helper functions.
@ -300,14 +315,97 @@ function addServerRoutes(options: AppShellOptions): Rule {
}; };
} }
function addShellComponent(options: AppShellOptions): Rule { function addStandaloneServerRoute(options: AppShellOptions): Rule {
const componentOptions: ComponentOptions = { return async (host: Tree) => {
name: 'app-shell', const workspace = await getWorkspace(host);
module: options.rootModuleFileName, const project = workspace.projects.get(options.project);
project: 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 { export default function (options: AppShellOptions): Rule {
@ -324,13 +422,20 @@ export default function (options: AppShellOptions): Rule {
const clientBuildOptions = (clientBuildTarget.options || const clientBuildOptions = (clientBuildTarget.options ||
{}) as unknown as BrowserBuilderOptions; {}) as unknown as BrowserBuilderOptions;
const isStandalone = isStandaloneApp(tree, clientBuildOptions.main);
return chain([ return chain([
validateProject(clientBuildOptions.main), validateProject(clientBuildOptions.main),
clientProject.targets.has('server') ? noop() : addUniversalTarget(options), clientProject.targets.has('server') ? noop() : addUniversalTarget(options),
addAppShellConfigToWorkspace(options), addAppShellConfigToWorkspace(options),
addRouterModule(clientBuildOptions.main), isStandalone ? noop() : addRouterModule(clientBuildOptions.main),
addServerRoutes(options), isStandalone ? addStandaloneServerRoute(options) : addServerRoutes(options),
addShellComponent(options), schematic('component', {
name: 'app-shell',
module: options.rootModuleFileName,
project: options.project,
standalone: isStandalone,
}),
]); ]);
}; };
} }

View File

@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.io/license * 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 { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';
import { Schema as ApplicationOptions } from '../application/schema'; import { Schema as ApplicationOptions } from '../application/schema';
import { Schema as WorkspaceOptions } from '../workspace/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'); const content = tree.readContent('/projects/bar/src/app/app.server.module.ts');
expect(content).toMatch(/app-shell\.component/); 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';`,
);
});
});
}); });

View File

@ -1,5 +1,5 @@
import { getGlobalVariable } from '../../../utils/env'; import { getGlobalVariable } from '../../../utils/env';
import { appendToFile, expectFileToMatch, writeMultipleFiles } from '../../../utils/fs'; import { expectFileToMatch } from '../../../utils/fs';
import { installPackage } from '../../../utils/packages'; import { installPackage } from '../../../utils/packages';
import { ng } from '../../../utils/process'; import { ng } from '../../../utils/process';
import { updateJsonFile } from '../../../utils/project'; import { updateJsonFile } from '../../../utils/project';
@ -7,8 +7,8 @@ import { updateJsonFile } from '../../../utils/project';
const snapshots = require('../../../ng-snapshot/package.json'); const snapshots = require('../../../ng-snapshot/package.json');
export default async function () { export default async function () {
await appendToFile('src/app/app.component.html', '<router-outlet></router-outlet>'); await ng('generate', 'app', 'test-project-two', '--routing', '--standalone', '--skip-install');
await ng('generate', 'app-shell', '--project', 'test-project'); await ng('generate', 'app-shell', '--project', 'test-project-two');
const isSnapshotBuild = getGlobalVariable('argv')['ng-snapshots']; const isSnapshotBuild = getGlobalVariable('argv')['ng-snapshots'];
if (isSnapshotBuild) { if (isSnapshotBuild) {
@ -30,55 +30,9 @@ export default async function () {
} }
} }
// TODO(alanagius): update the below once we have a standalone schematic. await ng('run', 'test-project-two:app-shell:development');
await writeMultipleFiles({ await expectFileToMatch('dist/test-project-two/browser/index.html', 'app-shell works!');
'src/app/app.component.ts': `
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({ await ng('run', 'test-project-two:app-shell');
selector: 'app-root', await expectFileToMatch('dist/test-project-two/browser/index.html', 'app-shell works!');
standalone: true,
template: '<router-outlet></router-outlet>',
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!/);
} }