From b14b959901d5a670da0df45e082b8fd4c3392d14 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Fri, 2 Jun 2023 11:50:09 +0200 Subject: [PATCH] feat(@schematics/angular): add bootstrap-agnostic utilities for writing ng-add schematics Currently writing schematics that support both NgModule-based and standalone projects is tricky, because they have different layouts. These changes introduce two new APIs that work both on NgModule and standalone projects and can be used by library authors to create their `ng add` schematics. Example rule for adding a `ModuleWithProviders`-style library: ```ts import { Rule } from '@angular-devkit/schematics'; import { addRootImport } from '@schematics/angular/utility'; export default function(): Rule { return addRootImport('default', ({code, external}) => { return code`${external('MyModule', '@my/module')}.forRoot({})`; }); } ``` This rulle will add `imports: [MyModule.forRoot({})]` to an NgModule app and `providers: [importProvidersFrom(MyModule.forRoot({}))]` to a standalone one. It also adds all of the necessary imports. --- .../schematics/angular/utility/ast-utils.ts | 2 +- packages/schematics/angular/utility/index.ts | 1 + .../angular/utility/standalone/app_config.ts | 127 +++++ .../angular/utility/standalone/code_block.ts | 115 +++++ .../angular/utility/standalone/index.ts | 10 + .../angular/utility/standalone/rules.ts | 260 ++++++++++ .../angular/utility/standalone/rules_spec.ts | 448 ++++++++++++++++++ .../angular/utility/standalone/util.ts | 166 +++++++ 8 files changed, 1128 insertions(+), 1 deletion(-) create mode 100644 packages/schematics/angular/utility/standalone/app_config.ts create mode 100644 packages/schematics/angular/utility/standalone/code_block.ts create mode 100644 packages/schematics/angular/utility/standalone/index.ts create mode 100644 packages/schematics/angular/utility/standalone/rules.ts create mode 100644 packages/schematics/angular/utility/standalone/rules_spec.ts create mode 100644 packages/schematics/angular/utility/standalone/util.ts diff --git a/packages/schematics/angular/utility/ast-utils.ts b/packages/schematics/angular/utility/ast-utils.ts index 9d25bc8f3a85..b74d2cf8d82d 100644 --- a/packages/schematics/angular/utility/ast-utils.ts +++ b/packages/schematics/angular/utility/ast-utils.ts @@ -217,7 +217,7 @@ function nodesByPosition(first: ts.Node, second: ts.Node): number { * @throw Error if toInsert is first occurence but fall back is not set */ export function insertAfterLastOccurrence( - nodes: ts.Node[], + nodes: ts.Node[] | ts.NodeArray, toInsert: string, file: string, fallbackPos: number, diff --git a/packages/schematics/angular/utility/index.ts b/packages/schematics/angular/utility/index.ts index af90d918ebff..46c560668b15 100644 --- a/packages/schematics/angular/utility/index.ts +++ b/packages/schematics/angular/utility/index.ts @@ -16,6 +16,7 @@ export { writeWorkspace, } from './workspace'; export { Builders as AngularBuilder } from './workspace-models'; +export * from './standalone'; // Package dependency related rules and types export { DependencyType, ExistingBehavior, InstallBehavior, addDependency } from './dependency'; diff --git a/packages/schematics/angular/utility/standalone/app_config.ts b/packages/schematics/angular/utility/standalone/app_config.ts new file mode 100644 index 000000000000..ae791b3c6a92 --- /dev/null +++ b/packages/schematics/angular/utility/standalone/app_config.ts @@ -0,0 +1,127 @@ +/** + * @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 { Tree } from '@angular-devkit/schematics'; +import { dirname, join } from 'path'; +import ts from '../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; +import { getSourceFile } from './util'; + +/** App config that was resolved to its source node. */ +export interface ResolvedAppConfig { + /** Tree-relative path of the file containing the app config. */ + filePath: string; + + /** Node defining the app config. */ + node: ts.ObjectLiteralExpression; +} + +/** + * Resolves the node that defines the app config from a bootstrap call. + * @param bootstrapCall Call for which to resolve the config. + * @param tree File tree of the project. + * @param filePath File path of the bootstrap call. + */ +export function findAppConfig( + bootstrapCall: ts.CallExpression, + tree: Tree, + filePath: string, +): ResolvedAppConfig | null { + if (bootstrapCall.arguments.length > 1) { + const config = bootstrapCall.arguments[1]; + + if (ts.isObjectLiteralExpression(config)) { + return { filePath, node: config }; + } + + if (ts.isIdentifier(config)) { + return resolveAppConfigFromIdentifier(config, tree, filePath); + } + } + + return null; +} + +/** + * Resolves the app config from an identifier referring to it. + * @param identifier Identifier referring to the app config. + * @param tree File tree of the project. + * @param bootstapFilePath Path of the bootstrap call. + */ +function resolveAppConfigFromIdentifier( + identifier: ts.Identifier, + tree: Tree, + bootstapFilePath: string, +): ResolvedAppConfig | null { + const sourceFile = identifier.getSourceFile(); + + for (const node of sourceFile.statements) { + // Only look at relative imports. This will break if the app uses a path + // mapping to refer to the import, but in order to resolve those, we would + // need knowledge about the entire program. + if ( + !ts.isImportDeclaration(node) || + !node.importClause?.namedBindings || + !ts.isNamedImports(node.importClause.namedBindings) || + !ts.isStringLiteralLike(node.moduleSpecifier) || + !node.moduleSpecifier.text.startsWith('.') + ) { + continue; + } + + for (const specifier of node.importClause.namedBindings.elements) { + if (specifier.name.text !== identifier.text) { + continue; + } + + // Look for a variable with the imported name in the file. Note that ideally we would use + // the type checker to resolve this, but we can't because these utilities are set up to + // operate on individual files, not the entire program. + const filePath = join(dirname(bootstapFilePath), node.moduleSpecifier.text + '.ts'); + const importedSourceFile = getSourceFile(tree, filePath); + const resolvedVariable = findAppConfigFromVariableName( + importedSourceFile, + (specifier.propertyName || specifier.name).text, + ); + + if (resolvedVariable) { + return { filePath, node: resolvedVariable }; + } + } + } + + const variableInSameFile = findAppConfigFromVariableName(sourceFile, identifier.text); + + return variableInSameFile ? { filePath: bootstapFilePath, node: variableInSameFile } : null; +} + +/** + * Finds an app config within the top-level variables of a file. + * @param sourceFile File in which to search for the config. + * @param variableName Name of the variable containing the config. + */ +function findAppConfigFromVariableName( + sourceFile: ts.SourceFile, + variableName: string, +): ts.ObjectLiteralExpression | null { + for (const node of sourceFile.statements) { + if (ts.isVariableStatement(node)) { + for (const decl of node.declarationList.declarations) { + if ( + ts.isIdentifier(decl.name) && + decl.name.text === variableName && + decl.initializer && + ts.isObjectLiteralExpression(decl.initializer) + ) { + return decl.initializer; + } + } + } + } + + return null; +} diff --git a/packages/schematics/angular/utility/standalone/code_block.ts b/packages/schematics/angular/utility/standalone/code_block.ts new file mode 100644 index 000000000000..9fc15c9b1b06 --- /dev/null +++ b/packages/schematics/angular/utility/standalone/code_block.ts @@ -0,0 +1,115 @@ +/** + * @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 { Rule, Tree } from '@angular-devkit/schematics'; +import ts from '../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; +import { hasTopLevelIdentifier, insertImport } from '../ast-utils'; +import { applyToUpdateRecorder } from '../change'; + +/** Generated code that hasn't been interpolated yet. */ +export interface PendingCode { + /** Code that will be inserted. */ + expression: string; + + /** Imports that need to be added to the file in which the code is inserted. */ + imports: PendingImports; +} + +/** Map keeping track of imports and aliases under which they're referred to in an expresion. */ +type PendingImports = Map>; + +/** Counter used to generate unique IDs. */ +let uniqueIdCounter = 0; + +/** + * Callback invoked by a Rule that produces the code + * that needs to be inserted somewhere in the app. + */ +export type CodeBlockCallback = (block: CodeBlock) => PendingCode; + +/** + * Utility class used to generate blocks of code that + * can be inserted by the devkit into a user's app. + */ +export class CodeBlock { + private _imports: PendingImports = new Map>(); + + // Note: the methods here are defined as arrow function so that they can be destructured by + // consumers without losing their context. This makes the API more concise. + + /** Function used to tag a code block in order to produce a `PendingCode` object. */ + code = (strings: TemplateStringsArray, ...params: unknown[]): PendingCode => { + return { + expression: strings.map((part, index) => part + (params[index] || '')).join(''), + imports: this._imports, + }; + }; + + /** + * Used inside of a code block to mark external symbols and which module they should be imported + * from. When the code is inserted, the required import statements will be produced automatically. + * @param symbolName Name of the external symbol. + * @param moduleName Module from which the symbol should be imported. + */ + external = (symbolName: string, moduleName: string): string => { + if (!this._imports.has(moduleName)) { + this._imports.set(moduleName, new Map()); + } + + const symbolsPerModule = this._imports.get(moduleName) as Map; + + if (!symbolsPerModule.has(symbolName)) { + symbolsPerModule.set(symbolName, `@@__SCHEMATIC_PLACEHOLDER_${uniqueIdCounter++}__@@`); + } + + return symbolsPerModule.get(symbolName) as string; + }; + + /** + * Produces the necessary rules to transform a `PendingCode` object into valid code. + * @param initialCode Code pending transformed. + * @param filePath Path of the file in which the code will be inserted. + */ + static transformPendingCode(initialCode: PendingCode, filePath: string) { + const code = { ...initialCode }; + const rules: Rule[] = []; + + code.imports.forEach((symbols, moduleName) => { + symbols.forEach((placeholder, symbolName) => { + rules.push((tree: Tree) => { + const recorder = tree.beginUpdate(filePath); + const sourceFile = ts.createSourceFile( + filePath, + tree.readText(filePath), + ts.ScriptTarget.Latest, + true, + ); + + // Note that this could still technically clash if there's a top-level symbol called + // `${symbolName}_alias`, however this is unlikely. We can revisit this if it becomes + // a problem. + const alias = hasTopLevelIdentifier(sourceFile, symbolName, moduleName) + ? symbolName + '_alias' + : undefined; + + code.expression = code.expression.replace( + new RegExp(placeholder, 'g'), + alias || symbolName, + ); + + applyToUpdateRecorder(recorder, [ + insertImport(sourceFile, filePath, symbolName, moduleName, false, alias), + ]); + tree.commitUpdate(recorder); + }); + }); + }); + + return { code, rules }; + } +} diff --git a/packages/schematics/angular/utility/standalone/index.ts b/packages/schematics/angular/utility/standalone/index.ts new file mode 100644 index 000000000000..1615b02b83b4 --- /dev/null +++ b/packages/schematics/angular/utility/standalone/index.ts @@ -0,0 +1,10 @@ +/** + * @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 { addRootImport, addRootProvider } from './rules'; +export { PendingCode, CodeBlockCallback, type CodeBlock } from './code_block'; diff --git a/packages/schematics/angular/utility/standalone/rules.ts b/packages/schematics/angular/utility/standalone/rules.ts new file mode 100644 index 000000000000..ba8e574d4fc2 --- /dev/null +++ b/packages/schematics/angular/utility/standalone/rules.ts @@ -0,0 +1,260 @@ +/** + * @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 { tags } from '@angular-devkit/core'; +import { Rule, SchematicsException, Tree, chain } from '@angular-devkit/schematics'; +import ts from '../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; +import { addSymbolToNgModuleMetadata, insertAfterLastOccurrence } from '../ast-utils'; +import { InsertChange } from '../change'; +import { getAppModulePath, isStandaloneApp } from '../ng-ast-utils'; +import { ResolvedAppConfig, findAppConfig } from './app_config'; +import { CodeBlock, CodeBlockCallback, PendingCode } from './code_block'; +import { + applyChangesToFile, + findBootstrapApplicationCall, + findProvidersLiteral, + getMainFilePath, + getSourceFile, + isMergeAppConfigCall, +} from './util'; + +/** + * Adds an import to the root of the project. + * @param project Name of the project to which to add the import. + * @param callback Function that generates the code block which should be inserted. + * @example + * + * ```ts + * import { Rule } from '@angular-devkit/schematics'; + * import { addRootImport } from '@schematics/angular/utility'; + * + * export default function(): Rule { + * return addRootImport('default', ({code, external}) => { + * return code`${external('MyModule', '@my/module')}.forRoot({})`; + * }); + * } + * ``` + */ +export function addRootImport(project: string, callback: CodeBlockCallback): Rule { + return getRootInsertionRule(project, callback, 'imports', { + name: 'importProvidersFrom', + module: '@angular/core', + }); +} + +/** + * Adds a provider to the root of the project. + * @param project Name of the project to which to add the import. + * @param callback Function that generates the code block which should be inserted. + * @example + * + * ```ts + * import { Rule } from '@angular-devkit/schematics'; + * import { addRootProvider } from '@schematics/angular/utility'; + * + * export default function(): Rule { + * return addRootProvider('default', ({code, external}) => { + * return code`${external('provideLibrary', '@my/library')}({})`; + * }); + * } + * ``` + */ +export function addRootProvider(project: string, callback: CodeBlockCallback): Rule { + return getRootInsertionRule(project, callback, 'providers'); +} + +/** + * Creates a rule that inserts code at the root of either a standalone or NgModule-based project. + * @param project Name of the project into which to inser tthe code. + * @param callback Function that generates the code block which should be inserted. + * @param ngModuleField Field of the root NgModule into which the code should be inserted, if the + * app is based on NgModule + * @param standaloneWrapperFunction Function with which to wrap the code if the app is standalone. + */ +function getRootInsertionRule( + project: string, + callback: CodeBlockCallback, + ngModuleField: string, + standaloneWrapperFunction?: { name: string; module: string }, +): Rule { + return async (host) => { + const mainFilePath = await getMainFilePath(host, project); + const codeBlock = new CodeBlock(); + + if (isStandaloneApp(host, mainFilePath)) { + return (tree) => + addProviderToStandaloneBootstrap( + tree, + callback(codeBlock), + mainFilePath, + standaloneWrapperFunction, + ); + } + + const modulePath = getAppModulePath(host, mainFilePath); + const pendingCode = CodeBlock.transformPendingCode(callback(codeBlock), modulePath); + + return chain([ + ...pendingCode.rules, + (tree) => { + const changes = addSymbolToNgModuleMetadata( + getSourceFile(tree, modulePath), + modulePath, + ngModuleField, + pendingCode.code.expression, + // Explicitly set the import path to null since we deal with imports here separately. + null, + ); + + applyChangesToFile(tree, modulePath, changes); + }, + ]); + }; +} + +/** + * Adds a provider to the root of a standalone project. + * @param host Tree of the root rule. + * @param pendingCode Code that should be inserted. + * @param mainFilePath Path to the project's main file. + * @param wrapperFunction Optional function with which to wrap the provider. + */ +function addProviderToStandaloneBootstrap( + host: Tree, + pendingCode: PendingCode, + mainFilePath: string, + wrapperFunction?: { name: string; module: string }, +): Rule { + const bootstrapCall = findBootstrapApplicationCall(host, mainFilePath); + const fileToEdit = findAppConfig(bootstrapCall, host, mainFilePath)?.filePath || mainFilePath; + const { code, rules } = CodeBlock.transformPendingCode(pendingCode, fileToEdit); + + return chain([ + ...rules, + () => { + let wrapped: PendingCode; + let additionalRules: Rule[]; + + if (wrapperFunction) { + const block = new CodeBlock(); + const result = CodeBlock.transformPendingCode( + block.code`${block.external(wrapperFunction.name, wrapperFunction.module)}(${ + code.expression + })`, + fileToEdit, + ); + + wrapped = result.code; + additionalRules = result.rules; + } else { + wrapped = code; + additionalRules = []; + } + + return chain([ + ...additionalRules, + (tree) => insertStandaloneRootProvider(tree, mainFilePath, wrapped.expression), + ]); + }, + ]); +} + +/** + * Inserts a string expression into the root of a standalone project. + * @param tree File tree used to modify the project. + * @param mainFilePath Path to the main file of the project. + * @param expression Code expression to be inserted. + */ +function insertStandaloneRootProvider(tree: Tree, mainFilePath: string, expression: string): void { + const bootstrapCall = findBootstrapApplicationCall(tree, mainFilePath); + const appConfig = findAppConfig(bootstrapCall, tree, mainFilePath); + + if (bootstrapCall.arguments.length === 0) { + throw new SchematicsException( + `Cannot add provider to invalid bootstrapApplication call in ${ + bootstrapCall.getSourceFile().fileName + }`, + ); + } + + if (appConfig) { + addProvidersExpressionToAppConfig(tree, appConfig, expression); + + return; + } + + const newAppConfig = `, {\n${tags.indentBy(2)`providers: [${expression}]`}\n}`; + let targetCall: ts.CallExpression; + + if (bootstrapCall.arguments.length === 1) { + targetCall = bootstrapCall; + } else if (isMergeAppConfigCall(bootstrapCall.arguments[1])) { + targetCall = bootstrapCall.arguments[1]; + } else { + throw new SchematicsException( + `Cannot statically analyze bootstrapApplication call in ${ + bootstrapCall.getSourceFile().fileName + }`, + ); + } + + applyChangesToFile(tree, mainFilePath, [ + insertAfterLastOccurrence( + targetCall.arguments, + newAppConfig, + mainFilePath, + targetCall.getEnd() - 1, + ), + ]); +} + +/** + * Adds a string expression to an app config object. + * @param tree File tree used to modify the project. + * @param appConfig Resolved configuration object of the project. + * @param expression Code expression to be inserted. + */ +function addProvidersExpressionToAppConfig( + tree: Tree, + appConfig: ResolvedAppConfig, + expression: string, +): void { + const { node, filePath } = appConfig; + const configProps = node.properties; + const providersLiteral = findProvidersLiteral(node); + + // If there's a `providers` property, we can add the provider + // to it, otherwise we need to declare it ourselves. + if (providersLiteral) { + const hasTrailingComma = providersLiteral.elements.hasTrailingComma; + + applyChangesToFile(tree, filePath, [ + insertAfterLastOccurrence( + providersLiteral.elements, + (hasTrailingComma || providersLiteral.elements.length === 0 ? '' : ', ') + expression, + filePath, + providersLiteral.getStart() + 1, + ), + ]); + } else { + const prop = tags.indentBy(2)`providers: [${expression}]`; + let toInsert: string; + let insertPosition: number; + + if (configProps.length === 0) { + toInsert = '\n' + prop + '\n'; + insertPosition = node.getEnd() - 1; + } else { + const hasTrailingComma = configProps.hasTrailingComma; + toInsert = (hasTrailingComma ? '' : ',') + '\n' + prop; + insertPosition = configProps[configProps.length - 1].getEnd() + (hasTrailingComma ? 1 : 0); + } + + applyChangesToFile(tree, filePath, [new InsertChange(filePath, insertPosition, toInsert)]); + } +} diff --git a/packages/schematics/angular/utility/standalone/rules_spec.ts b/packages/schematics/angular/utility/standalone/rules_spec.ts new file mode 100644 index 000000000000..66ea591aadd7 --- /dev/null +++ b/packages/schematics/angular/utility/standalone/rules_spec.ts @@ -0,0 +1,448 @@ +/** + * @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 { Rule, SchematicContext, Tree, callRule } from '@angular-devkit/schematics'; +import { SchematicTestRunner } from '@angular-devkit/schematics/testing'; +import { join } from 'path'; +import { addRootImport, addRootProvider } from './rules'; + +describe('standalone utilities', () => { + const projectName = 'test'; + let host: Tree; + + async function setupProject(standalone = false) { + const schematicRunner = new SchematicTestRunner( + '@schematics/angular', + require.resolve('../../collection.json'), + ); + + host = await schematicRunner.runSchematic('workspace', { + name: 'workspace', + newProjectRoot: '/', + version: '6.0.0', + }); + host = await schematicRunner.runSchematic( + 'application', + { + name: projectName, + standalone, + }, + host, + ); + } + + afterEach(() => { + // Clear the host so it doesn't leak between tests. + host = null as unknown as Tree; + }); + + function stripWhitespace(str: string) { + return str.replace(/\s/g, ''); + } + + function assertContains(source: string, targetString: string) { + expect(stripWhitespace(source)).toContain(stripWhitespace(targetString)); + } + + function getPathWithinProject(path: string): string { + return join('/', projectName, 'src', path); + } + + function readFile(projectPath: string): string { + return host.readText(getPathWithinProject(projectPath)); + } + + async function testRule(rule: Rule, tree: Tree): Promise { + await callRule(rule, tree, {} as unknown as SchematicContext).toPromise(); + } + + describe('addRootImport', () => { + it('should add a root import to an NgModule-based app', async () => { + await setupProject(); + + await testRule( + addRootImport( + projectName, + ({ code, external }) => code`${external('MyModule', '@my/module')}.forRoot([])`, + ), + host, + ); + + const content = readFile('app/app.module.ts'); + + assertContains(content, `import { MyModule } from '@my/module';`); + assertContains(content, `imports: [BrowserModule, MyModule.forRoot([])]`); + }); + + it('should add a root import to a standalone app', async () => { + await setupProject(true); + + await testRule( + addRootImport( + projectName, + ({ code, external }) => code`${external('MyModule', '@my/module')}`, + ), + host, + ); + + const content = readFile('app/app.config.ts'); + + assertContains( + content, + `import { ApplicationConfig, importProvidersFrom } from '@angular/core';`, + ); + assertContains(content, `import { MyModule } from '@my/module';`); + assertContains(content, `providers: [importProvidersFrom(MyModule)]`); + }); + + it('should add a root import to a standalone app whose app config does not have a providers array', async () => { + await setupProject(true); + + host.overwrite( + getPathWithinProject('app/app.config.ts'), + ` + import { ApplicationConfig } from '@angular/core'; + + export const appConfig: ApplicationConfig = {}; + `, + ); + + await testRule( + addRootImport( + projectName, + ({ code, external }) => code`${external('MyModule', '@my/module')}`, + ), + host, + ); + + const content = readFile('app/app.config.ts'); + + assertContains( + content, + `import { ApplicationConfig, importProvidersFrom } from '@angular/core';`, + ); + assertContains(content, `import { MyModule } from '@my/module';`); + assertContains(content, `providers: [importProvidersFrom(MyModule)]`); + }); + + it('should add a root import to a standalone app with a config with providers', async () => { + await setupProject(true); + + host.overwrite( + getPathWithinProject('app/app.config.ts'), + ` + import { ApplicationConfig } from '@angular/core'; + + export const appConfig: ApplicationConfig = { + providers: [ + {provide: 'foo', useValue: 123} + ] + }; + `, + ); + + await testRule( + addRootImport( + projectName, + ({ code, external }) => code`${external('MyModule', '@my/module')}`, + ), + host, + ); + + const content = readFile('app/app.config.ts'); + + assertContains( + content, + `import { ApplicationConfig, importProvidersFrom } from '@angular/core';`, + ); + assertContains(content, `import { MyModule } from '@my/module';`); + assertContains( + content, + `providers: [ + {provide: 'foo', useValue: 123}, + importProvidersFrom(MyModule) + ]`, + ); + }); + + it( + 'should add a root import to a standalone app whose app config does not have have ' + + 'a providers array, but has another property', + async () => { + await setupProject(true); + + host.overwrite( + getPathWithinProject('app/app.config.ts'), + ` + import { ApplicationConfig } from '@angular/core'; + + export const appConfig: ApplicationConfig = { + otherProp: {}, + }; + `, + ); + + await testRule( + addRootImport( + projectName, + ({ code, external }) => code`${external('MyModule', '@my/module')}`, + ), + host, + ); + + const content = readFile('app/app.config.ts'); + + assertContains( + content, + `import { ApplicationConfig, importProvidersFrom } from '@angular/core';`, + ); + assertContains(content, `import { MyModule } from '@my/module';`); + assertContains( + content, + ` + export const appConfig: ApplicationConfig = { + otherProp: {}, + providers: [importProvidersFrom(MyModule)] + }; + `, + ); + }, + ); + + it('should add a root import to a standalone app with an inline app config', async () => { + await setupProject(true); + + host.overwrite( + getPathWithinProject('main.ts'), + ` + import { bootstrapApplication } from '@angular/platform-browser'; + import { AppComponent } from './app/app.component'; + + bootstrapApplication(AppComponent, {}); + `, + ); + + await testRule( + addRootImport( + projectName, + ({ code, external }) => code`${external('MyModule', '@my/module')}`, + ), + host, + ); + + const content = readFile('main.ts'); + + assertContains(content, `import { importProvidersFrom } from '@angular/core';`); + assertContains(content, `import { MyModule } from '@my/module';`); + assertContains( + content, + `bootstrapApplication(AppComponent, { + providers: [importProvidersFrom(MyModule)] + });`, + ); + }); + + it('should add a root import to a standalone app without an app config', async () => { + await setupProject(true); + + host.overwrite( + getPathWithinProject('main.ts'), + ` + import { bootstrapApplication } from '@angular/platform-browser'; + import { AppComponent } from './app/app.component'; + + bootstrapApplication(AppComponent); + `, + ); + + await testRule( + addRootImport( + projectName, + ({ code, external }) => code`${external('MyModule', '@my/module')}`, + ), + host, + ); + + const content = readFile('main.ts'); + + assertContains(content, `import { importProvidersFrom } from '@angular/core';`); + assertContains(content, `import { MyModule } from '@my/module';`); + assertContains( + content, + `bootstrapApplication(AppComponent, { + providers: [importProvidersFrom(MyModule)] + });`, + ); + }); + + it('should add a root import to a standalone app with a merged app config', async () => { + await setupProject(true); + + host.overwrite( + getPathWithinProject('main.ts'), + ` + import { mergeApplicationConfig } from '@angular/core'; + import { bootstrapApplication } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + + bootstrapApplication(AppComponent, mergeApplicationConfig(a, b)); + `, + ); + + await testRule( + addRootImport( + projectName, + ({ code, external }) => code`${external('MyModule', '@my/module')}`, + ), + host, + ); + + const content = readFile('main.ts'); + + assertContains( + content, + `import { mergeApplicationConfig, importProvidersFrom } from '@angular/core';`, + ); + assertContains(content, `import { MyModule } from '@my/module';`); + assertContains( + content, + `bootstrapApplication(AppComponent, mergeApplicationConfig(a, b, { + providers: [importProvidersFrom(MyModule)] + }));`, + ); + }); + + it('should alias symbols that conflict with existing code', async () => { + await setupProject(); + + await testRule( + addRootImport( + projectName, + ({ code, external }) => code`${external('BrowserModule', '@my/module')}.forRoot([])`, + ), + host, + ); + + const content = readFile('app/app.module.ts'); + + assertContains(content, `import { BrowserModule as BrowserModule_alias } from '@my/module';`); + assertContains(content, `imports: [BrowserModule, BrowserModule_alias.forRoot([])]`); + }); + + it('should throw an error if the bootstrapApplication code has no arguments', async () => { + await setupProject(true); + + const mainPath = getPathWithinProject('main.ts'); + + host.overwrite( + mainPath, + ` + import { bootstrapApplication } from '@angular/platform-browser'; + + bootstrapApplication(); + `, + ); + + const promise = testRule( + addRootImport( + projectName, + ({ code, external }) => code`${external('MyModule', '@my/module')}`, + ), + host, + ); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + await expectAsync(promise).toBeRejectedWithError( + `Cannot add provider to invalid bootstrapApplication call in ${mainPath}`, + ); + }); + + it('should throw an error if the bootstrapApplication call cannot be analyzed', async () => { + await setupProject(true); + + const mainPath = getPathWithinProject('main.ts'); + + host.overwrite( + mainPath, + ` + import { bootstrapApplication } from '@angular/platform-browser'; + import { AppComponent } from './app/app.component'; + import { appConfig } from '@external/app-config'; + + bootstrapApplication(AppComponent, appConfig); + `, + ); + + const promise = testRule( + addRootImport( + projectName, + ({ code, external }) => code`${external('MyModule', '@my/module')}`, + ), + host, + ); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + await expectAsync(promise).toBeRejectedWithError( + `Cannot statically analyze bootstrapApplication call in ${mainPath}`, + ); + }); + + it('should throw an error if there is no bootstrapApplication call', async () => { + await setupProject(true); + host.overwrite(getPathWithinProject('main.ts'), ''); + + const promise = testRule( + addRootImport( + projectName, + ({ code, external }) => code`${external('MyModule', '@my/module')}`, + ), + host, + ); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + await expectAsync(promise).toBeRejectedWithError('Bootstrap call not found'); + }); + }); + + describe('addRootProvider', () => { + it('should add a root provider to an NgModule-based app', async () => { + await setupProject(); + + await testRule( + addRootProvider( + projectName, + ({ code, external }) => + code`{ provide: ${external('SOME_TOKEN', '@my/module')}, useValue: 123 }`, + ), + host, + ); + + const content = readFile('app/app.module.ts'); + + assertContains(content, `import { SOME_TOKEN } from '@my/module';`); + assertContains(content, `providers: [{ provide: SOME_TOKEN, useValue: 123 }]`); + }); + + it('should add a root provider to a standalone app', async () => { + await setupProject(true); + + await testRule( + addRootProvider( + projectName, + ({ code, external }) => code`${external('provideModule', '@my/module')}([])`, + ), + host, + ); + + const content = readFile('app/app.config.ts'); + + assertContains(content, `import { provideModule } from '@my/module';`); + assertContains(content, `providers: [provideModule([])]`); + }); + }); +}); diff --git a/packages/schematics/angular/utility/standalone/util.ts b/packages/schematics/angular/utility/standalone/util.ts new file mode 100644 index 000000000000..8195acd69725 --- /dev/null +++ b/packages/schematics/angular/utility/standalone/util.ts @@ -0,0 +1,166 @@ +/** + * @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 { Change, applyToUpdateRecorder } from '../change'; +import { targetBuildNotFoundError } from '../project-targets'; +import { getWorkspace } from '../workspace'; +import { BrowserBuilderOptions } from '../workspace-models'; + +/** + * Finds the main file of a project. + * @param tree File tree for the project. + * @param projectName Name of the project in which to search. + */ +export async function getMainFilePath(tree: Tree, projectName: string): Promise { + const workspace = await getWorkspace(tree); + const project = workspace.projects.get(projectName); + const buildTarget = project?.targets.get('build'); + + if (!buildTarget) { + throw targetBuildNotFoundError(); + } + + return ((buildTarget.options || {}) as unknown as BrowserBuilderOptions).main; +} + +/** + * Gets a TypeScript source file at a specific path. + * @param tree File tree of a project. + * @param path Path to the file. + */ +export function getSourceFile(tree: Tree, path: string): ts.SourceFile { + const content = tree.readText(path); + const source = ts.createSourceFile(path, content, ts.ScriptTarget.Latest, true); + + return source; +} + +/** Finds the call to `bootstrapApplication` within a file. */ +export function findBootstrapApplicationCall(tree: Tree, mainFilePath: string): ts.CallExpression { + const sourceFile = getSourceFile(tree, mainFilePath); + const localName = findImportLocalName( + sourceFile, + 'bootstrapApplication', + '@angular/platform-browser', + ); + + if (localName) { + let result: ts.CallExpression | null = null; + + sourceFile.forEachChild(function walk(node) { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === localName + ) { + result = node; + } + + if (!result) { + node.forEachChild(walk); + } + }); + + if (result) { + return result; + } + } + + throw new SchematicsException(`Could not find bootstrapApplication call in ${mainFilePath}`); +} + +/** + * 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; +} + +/** + * Applies a set of changes to a file. + * @param tree File tree of the project. + * @param path Path to the file that is being changed. + * @param changes Changes that should be applied to the file. + */ +export function applyChangesToFile(tree: Tree, path: string, changes: Change[]) { + if (changes.length > 0) { + const recorder = tree.beginUpdate(path); + applyToUpdateRecorder(recorder, changes); + tree.commitUpdate(recorder); + } +} + +/** Checks whether a node is a call to `mergeApplicationConfig`. */ +export function isMergeAppConfigCall(node: ts.Node): node is ts.CallExpression { + if (!ts.isCallExpression(node)) { + return false; + } + + const localName = findImportLocalName( + node.getSourceFile(), + 'mergeApplicationConfig', + '@angular/core', + ); + + return !!localName && ts.isIdentifier(node.expression) && node.expression.text === localName; +} + +/** Finds the `providers` array literal within an application config. */ +export function findProvidersLiteral( + config: ts.ObjectLiteralExpression, +): ts.ArrayLiteralExpression | null { + for (const prop of config.properties) { + if ( + ts.isPropertyAssignment(prop) && + ts.isIdentifier(prop.name) && + prop.name.text === 'providers' && + ts.isArrayLiteralExpression(prop.initializer) + ) { + return prop.initializer; + } + } + + return null; +}