mirror of
https://github.com/angular/angular-cli.git
synced 2025-05-22 23:15:56 +08:00
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:
parent
dd02caa61e
commit
50b9e59a50
@ -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 modulePath = getAppModulePath(host, mainPath);
|
const mainSource = getSourceFile(host, mainPath);
|
||||||
const moduleSource = getSourceFile(host, modulePath);
|
const bootstrapAppCall = findBootstrapApplicationCall(mainSource);
|
||||||
|
|
||||||
const metadataNode = getDecoratorMetadata(moduleSource, 'NgModule', '@angular/core')[0];
|
let bootstrappingFilePath: string;
|
||||||
const bootstrapProperty = getMetadataProperty(metadataNode, 'bootstrap');
|
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 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,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -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';`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -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!/);
|
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user