diff --git a/packages/schematics/angular/package.json b/packages/schematics/angular/package.json index ba8e7cb75650..5bc3cc810fb1 100644 --- a/packages/schematics/angular/package.json +++ b/packages/schematics/angular/package.json @@ -13,7 +13,8 @@ "./utility": "./utility/index.js", "./utility/*": "./utility/*.js", "./migrations/migration-collection.json": "./migrations/migration-collection.json", - "./*": "./*.js" + "./*": "./*.js", + "./private/components": "./private/components.js" }, "schematics": "./collection.json", "dependencies": { diff --git a/packages/schematics/angular/private/components.ts b/packages/schematics/angular/private/components.ts new file mode 100644 index 000000000000..3365c0bb353d --- /dev/null +++ b/packages/schematics/angular/private/components.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export { + addModuleImportToStandaloneBootstrap, + findBootstrapApplicationCall, + importsProvidersFrom, +} from './standalone'; diff --git a/packages/schematics/angular/private/standalone.ts b/packages/schematics/angular/private/standalone.ts new file mode 100644 index 000000000000..b171b8ae7597 --- /dev/null +++ b/packages/schematics/angular/private/standalone.ts @@ -0,0 +1,277 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { SchematicsException, Tree } from '@angular-devkit/schematics'; +import ts from '../third_party/github.com/Microsoft/TypeScript/lib/typescript'; +import { insertImport } from '../utility/ast-utils'; +import { InsertChange } from '../utility/change'; + +/** + * Checks whether the providers from a module are being imported in a `bootstrapApplication` call. + * @param tree File tree of the project. + * @param filePath Path of the file in which to check. + * @param className Class name of the module to search for. + */ +export function importsProvidersFrom(tree: Tree, filePath: string, className: string): boolean { + const sourceFile = ts.createSourceFile( + filePath, + tree.readText(filePath), + ts.ScriptTarget.Latest, + true, + ); + + const bootstrapCall = findBootstrapApplicationCall(sourceFile); + const importProvidersFromCall = bootstrapCall ? findImportProvidersFromCall(bootstrapCall) : null; + + return ( + !!importProvidersFromCall && + importProvidersFromCall.arguments.some((arg) => ts.isIdentifier(arg) && arg.text === className) + ); +} + +/** + * Adds an `importProvidersFrom` call to the `bootstrapApplication` call. + * @param tree File tree of the project. + * @param filePath Path to the file that should be updated. + * @param moduleName Name of the module that should be imported. + * @param modulePath Path from which to import the module. + */ +export function addModuleImportToStandaloneBootstrap( + tree: Tree, + filePath: string, + moduleName: string, + modulePath: string, +) { + const sourceFile = ts.createSourceFile( + filePath, + tree.readText(filePath), + ts.ScriptTarget.Latest, + true, + ); + + const bootstrapCall = findBootstrapApplicationCall(sourceFile); + + if (!bootstrapCall) { + throw new SchematicsException(`Could not find bootstrapApplication call in ${filePath}`); + } + + const recorder = tree.beginUpdate(filePath); + const importCall = findImportProvidersFromCall(bootstrapCall); + const printer = ts.createPrinter(); + const sourceText = sourceFile.getText(); + + // Add imports to the module being added and `importProvidersFrom`. We don't + // have to worry about duplicates, because `insertImport` handles them. + [ + insertImport(sourceFile, sourceText, moduleName, modulePath), + insertImport(sourceFile, sourceText, 'importProvidersFrom', '@angular/core'), + ].forEach((change) => { + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } + }); + + // If there is an `importProvidersFrom` call already, reuse it. + if (importCall) { + recorder.insertRight( + importCall.arguments[importCall.arguments.length - 1].getEnd(), + `, ${moduleName}`, + ); + } else if (bootstrapCall.arguments.length === 1) { + // Otherwise if there is no options parameter to `bootstrapApplication`, + // create an object literal with a `providers` array and the import. + const newCall = ts.factory.updateCallExpression( + bootstrapCall, + bootstrapCall.expression, + bootstrapCall.typeArguments, + [ + ...bootstrapCall.arguments, + ts.factory.createObjectLiteralExpression([createProvidersAssignment(moduleName)], true), + ], + ); + + recorder.remove(bootstrapCall.getStart(), bootstrapCall.getWidth()); + recorder.insertRight( + bootstrapCall.getStart(), + printer.printNode(ts.EmitHint.Unspecified, newCall, sourceFile), + ); + } else { + const providersLiteral = findProvidersLiteral(bootstrapCall); + + if (providersLiteral) { + // If there's a `providers` array, add the import to it. + const newProvidersLiteral = ts.factory.updateArrayLiteralExpression(providersLiteral, [ + ...providersLiteral.elements, + createImportProvidersFromCall(moduleName), + ]); + recorder.remove(providersLiteral.getStart(), providersLiteral.getWidth()); + recorder.insertRight( + providersLiteral.getStart(), + printer.printNode(ts.EmitHint.Unspecified, newProvidersLiteral, sourceFile), + ); + } else { + // Otherwise add a `providers` array to the existing object literal. + const optionsLiteral = bootstrapCall.arguments[1] as ts.ObjectLiteralExpression; + const newOptionsLiteral = ts.factory.updateObjectLiteralExpression(optionsLiteral, [ + ...optionsLiteral.properties, + createProvidersAssignment(moduleName), + ]); + recorder.remove(optionsLiteral.getStart(), optionsLiteral.getWidth()); + recorder.insertRight( + optionsLiteral.getStart(), + printer.printNode(ts.EmitHint.Unspecified, newOptionsLiteral, sourceFile), + ); + } + } + + tree.commitUpdate(recorder); +} + +/** Finds the call to `bootstrapApplication` within a file. */ +export function findBootstrapApplicationCall(sourceFile: ts.SourceFile): ts.CallExpression | null { + const localName = findImportLocalName( + sourceFile, + 'bootstrapApplication', + '@angular/platform-browser', + ); + + return localName ? findCall(sourceFile, localName) : null; +} + +/** Find a call to `importProvidersFrom` within a `bootstrapApplication` call. */ +function findImportProvidersFromCall(bootstrapCall: ts.CallExpression): ts.CallExpression | null { + const providersLiteral = findProvidersLiteral(bootstrapCall); + const importProvidersName = findImportLocalName( + bootstrapCall.getSourceFile(), + 'importProvidersFrom', + '@angular/core', + ); + + if (providersLiteral && importProvidersName) { + for (const element of providersLiteral.elements) { + // Look for an array element that calls the `importProvidersFrom` function. + if ( + ts.isCallExpression(element) && + ts.isIdentifier(element.expression) && + element.expression.text === importProvidersName + ) { + return element; + } + } + } + + return null; +} + +/** Finds the `providers` array literal within a `bootstrapApplication` call. */ +function findProvidersLiteral(bootstrapCall: ts.CallExpression): ts.ArrayLiteralExpression | null { + // The imports have to be in the second argument of + // the function which has to be an object literal. + if ( + bootstrapCall.arguments.length > 1 && + ts.isObjectLiteralExpression(bootstrapCall.arguments[1]) + ) { + for (const prop of bootstrapCall.arguments[1].properties) { + if ( + ts.isPropertyAssignment(prop) && + ts.isIdentifier(prop.name) && + prop.name.text === 'providers' && + ts.isArrayLiteralExpression(prop.initializer) + ) { + return prop.initializer; + } + } + } + + return null; +} + +/** + * Finds the local name of an imported symbol. Could be the symbol name itself or its alias. + * @param sourceFile File within which to search for the import. + * @param name Actual name of the import, not its local alias. + * @param moduleName Name of the module from which the symbol is imported. + */ +function findImportLocalName( + sourceFile: ts.SourceFile, + name: string, + moduleName: string, +): string | null { + for (const node of sourceFile.statements) { + // Only look for top-level imports. + if ( + !ts.isImportDeclaration(node) || + !ts.isStringLiteral(node.moduleSpecifier) || + node.moduleSpecifier.text !== moduleName + ) { + continue; + } + + // Filter out imports that don't have the right shape. + if ( + !node.importClause || + !node.importClause.namedBindings || + !ts.isNamedImports(node.importClause.namedBindings) + ) { + continue; + } + + // Look through the elements of the declaration for the specific import. + for (const element of node.importClause.namedBindings.elements) { + if ((element.propertyName || element.name).text === name) { + // The local name is always in `name`. + return element.name.text; + } + } + } + + return null; +} + +/** + * Finds a call to a function with a specific name. + * @param rootNode Node from which to start searching. + * @param name Name of the function to search for. + */ +function findCall(rootNode: ts.Node, name: string): ts.CallExpression | null { + let result: ts.CallExpression | null = null; + + rootNode.forEachChild(function walk(node) { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === name + ) { + result = node; + } + + if (!result) { + node.forEachChild(walk); + } + }); + + return result; +} + +/** Creates an `importProvidersFrom({{moduleName}})` call. */ +function createImportProvidersFromCall(moduleName: string): ts.CallExpression { + return ts.factory.createCallChain( + ts.factory.createIdentifier('importProvidersFrom'), + undefined, + undefined, + [ts.factory.createIdentifier(moduleName)], + ); +} + +/** Creates a `providers: [importProvidersFrom({{moduleName}})]` property assignment. */ +function createProvidersAssignment(moduleName: string): ts.PropertyAssignment { + return ts.factory.createPropertyAssignment( + 'providers', + ts.factory.createArrayLiteralExpression([createImportProvidersFromCall(moduleName)]), + ); +} diff --git a/packages/schematics/angular/private/standalone_spec.ts b/packages/schematics/angular/private/standalone_spec.ts new file mode 100644 index 000000000000..e268b47b36b7 --- /dev/null +++ b/packages/schematics/angular/private/standalone_spec.ts @@ -0,0 +1,263 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { EmptyTree } from '@angular-devkit/schematics'; +import ts from '../third_party/github.com/Microsoft/TypeScript/lib/typescript'; +import { + addModuleImportToStandaloneBootstrap, + findBootstrapApplicationCall, + importsProvidersFrom, +} from './standalone'; + +describe('standalone utilities', () => { + let host: EmptyTree; + + beforeEach(() => { + host = new EmptyTree(); + }); + + function getSourceFileFrom(path: string) { + return ts.createSourceFile(path, host.readText(path), ts.ScriptTarget.Latest, true); + } + + function stripWhitespace(str: string) { + return str.replace(/\s/g, ''); + } + + function assertContains(source: string, targetString: string) { + expect(stripWhitespace(source)).toContain(stripWhitespace(targetString)); + } + + describe('findBootstrapApplicationCall', () => { + it('should find a call to `bootstrapApplication`', () => { + host.create( + '/test.ts', + ` + import { BrowserModule, bootstrapApplication } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + + bootstrapApplication(AppComponent, { + providers: [importProvidersFrom(BrowserModule)] + }); + `, + ); + + expect(findBootstrapApplicationCall(getSourceFileFrom('/test.ts'))).toBeTruthy(); + }); + + it('should find an aliased call to `bootstrapApplication`', () => { + host.create( + '/test.ts', + ` + import { BrowserModule, bootstrapApplication as boot } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + + boot(AppComponent, { + providers: [importProvidersFrom(BrowserModule)] + }); + `, + ); + + expect(findBootstrapApplicationCall(getSourceFileFrom('/test.ts'))).toBeTruthy(); + }); + + it('should return null if there are no bootstrapApplication calls', () => { + host.create( + '/test.ts', + ` + import { AppComponent } from './app.component'; + + console.log(AppComponent); + `, + ); + + expect(findBootstrapApplicationCall(getSourceFileFrom('/test.ts'))).toBeNull(); + }); + }); + + describe('importsProvidersFrom', () => { + it('should find that a bootstrapApplication call imports providers from a module', () => { + host.create( + '/test.ts', + ` + import { importProvidersFrom } from '@angular/core'; + import { BrowserModule, bootstrapApplication } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + + bootstrapApplication(AppComponent, { + providers: [ + {provide: foo, useValue: 10}, + importProvidersFrom(BrowserModule) + ] + }); + `, + ); + + expect(importsProvidersFrom(host, '/test.ts', 'BrowserModule')).toBe(true); + expect(importsProvidersFrom(host, '/test.ts', 'FooModule')).toBe(false); + }); + + it('should find that a bootstrapApplication call imports providers from a module if importProvidersFrom is aliased', () => { + host.create( + '/test.ts', + ` + import { importProvidersFrom as imp } from '@angular/core'; + import { BrowserModule, bootstrapApplication } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + + bootstrapApplication(AppComponent, { + providers: [imp(BrowserModule)] + }); + `, + ); + + expect(importsProvidersFrom(host, '/test.ts', 'BrowserModule')).toBe(true); + expect(importsProvidersFrom(host, '/test.ts', 'FooModule')).toBe(false); + }); + + it('should return false if there is no bootstrapApplication calls', () => { + host.create( + '/test.ts', + ` + import { AppComponent } from './app.component'; + + console.log(AppComponent); + `, + ); + + expect(importsProvidersFrom(host, '/test.ts', 'FooModule')).toBe(false); + }); + }); + + describe('addModuleImportToStandaloneBootstrap', () => { + it('should be able to add a module import to a simple `bootstrapApplication` call', () => { + host.create( + '/test.ts', + ` + import { bootstrapApplication } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + + bootstrapApplication(AppComponent); + `, + ); + + addModuleImportToStandaloneBootstrap(host, '/test.ts', 'FooModule', '@foo/bar'); + + const content = stripWhitespace(host.readText('/test.ts')); + + assertContains(content, `import {importProvidersFrom} from '@angular/core';`); + assertContains(content, `import {FooModule} from '@foo/bar';`); + assertContains( + content, + `bootstrapApplication(AppComponent, {providers: [importProvidersFrom(FooModule)]});`, + ); + }); + + it('should be able to add a module import to a `bootstrapApplication` call with an empty options object', () => { + host.create( + '/test.ts', + ` + import { bootstrapApplication } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + + bootstrapApplication(AppComponent, {}); + `, + ); + + addModuleImportToStandaloneBootstrap(host, '/test.ts', 'FooModule', '@foo/bar'); + + const content = stripWhitespace(host.readText('/test.ts')); + + assertContains(content, `import {importProvidersFrom} from '@angular/core';`); + assertContains(content, `import {FooModule} from '@foo/bar';`); + assertContains( + content, + `bootstrapApplication(AppComponent, {providers: [importProvidersFrom(FooModule)]});`, + ); + }); + + it('should be able to add a module import to a `bootstrapApplication` call with a pre-existing `providers` array', () => { + host.create( + '/test.ts', + ` + import { enableProdMode } from '@angular/core'; + import { bootstrapApplication } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + + enableProdMode(); + + bootstrapApplication(AppComponent, { + providers: [{provide: 'foo', useValue: 'bar'}] + }); + `, + ); + + addModuleImportToStandaloneBootstrap(host, '/test.ts', 'FooModule', '@foo/bar'); + + const content = stripWhitespace(host.readText('/test.ts')); + + assertContains(content, `import {enableProdMode, importProvidersFrom} from '@angular/core';`); + assertContains(content, `import {FooModule} from '@foo/bar';`); + assertContains( + content, + `bootstrapApplication(AppComponent, { + providers: [ + {provide: 'foo', useValue: 'bar'}, + importProvidersFrom(FooModule) + ] + });`, + ); + }); + + it('should be able to add a module import to a `bootstrapApplication` call with a pre-existing `importProvidersFrom` call', () => { + host.create( + '/test.ts', + ` + import { importProvidersFrom } from '@angular/core'; + import { bootstrapApplication, BrowserModule } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + + bootstrapApplication(AppComponent, { + providers: [{provide: 'foo', useValue: 'bar'}, importProvidersFrom(BrowserModule)] + }); + `, + ); + + addModuleImportToStandaloneBootstrap(host, '/test.ts', 'FooModule', '@foo/bar'); + + const content = stripWhitespace(host.readText('/test.ts')); + + assertContains(content, `import {importProvidersFrom} from '@angular/core';`); + assertContains(content, `import {FooModule} from '@foo/bar';`); + assertContains( + content, + `bootstrapApplication(AppComponent, { + providers: [ + {provide: 'foo', useValue: 'bar'}, + importProvidersFrom(BrowserModule, FooModule) + ] + });`, + ); + }); + + it('should throw if there is no `bootstrapModule` call', () => { + host.create( + '/test.ts', + ` + import { AppComponent } from './app.component'; + + console.log(AppComponent); + `, + ); + + expect(() => { + addModuleImportToStandaloneBootstrap(host, '/test.ts', 'FooModule', '@foo/bar'); + }).toThrowError(/Could not find bootstrapApplication call in \/test\.ts/); + }); + }); +});