Skip to content

Commit b14b959

Browse files
crisbetoalan-agius4
authored andcommitted
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.
1 parent b36effd commit b14b959

File tree

8 files changed

+1128
-1
lines changed

8 files changed

+1128
-1
lines changed

packages/schematics/angular/utility/ast-utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ function nodesByPosition(first: ts.Node, second: ts.Node): number {
217217
* @throw Error if toInsert is first occurence but fall back is not set
218218
*/
219219
export function insertAfterLastOccurrence(
220-
nodes: ts.Node[],
220+
nodes: ts.Node[] | ts.NodeArray<ts.Node>,
221221
toInsert: string,
222222
file: string,
223223
fallbackPos: number,

packages/schematics/angular/utility/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export {
1616
writeWorkspace,
1717
} from './workspace';
1818
export { Builders as AngularBuilder } from './workspace-models';
19+
export * from './standalone';
1920

2021
// Package dependency related rules and types
2122
export { DependencyType, ExistingBehavior, InstallBehavior, addDependency } from './dependency';
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import { Tree } from '@angular-devkit/schematics';
10+
import { dirname, join } from 'path';
11+
import ts from '../../third_party/github.com/Microsoft/TypeScript/lib/typescript';
12+
import { getSourceFile } from './util';
13+
14+
/** App config that was resolved to its source node. */
15+
export interface ResolvedAppConfig {
16+
/** Tree-relative path of the file containing the app config. */
17+
filePath: string;
18+
19+
/** Node defining the app config. */
20+
node: ts.ObjectLiteralExpression;
21+
}
22+
23+
/**
24+
* Resolves the node that defines the app config from a bootstrap call.
25+
* @param bootstrapCall Call for which to resolve the config.
26+
* @param tree File tree of the project.
27+
* @param filePath File path of the bootstrap call.
28+
*/
29+
export function findAppConfig(
30+
bootstrapCall: ts.CallExpression,
31+
tree: Tree,
32+
filePath: string,
33+
): ResolvedAppConfig | null {
34+
if (bootstrapCall.arguments.length > 1) {
35+
const config = bootstrapCall.arguments[1];
36+
37+
if (ts.isObjectLiteralExpression(config)) {
38+
return { filePath, node: config };
39+
}
40+
41+
if (ts.isIdentifier(config)) {
42+
return resolveAppConfigFromIdentifier(config, tree, filePath);
43+
}
44+
}
45+
46+
return null;
47+
}
48+
49+
/**
50+
* Resolves the app config from an identifier referring to it.
51+
* @param identifier Identifier referring to the app config.
52+
* @param tree File tree of the project.
53+
* @param bootstapFilePath Path of the bootstrap call.
54+
*/
55+
function resolveAppConfigFromIdentifier(
56+
identifier: ts.Identifier,
57+
tree: Tree,
58+
bootstapFilePath: string,
59+
): ResolvedAppConfig | null {
60+
const sourceFile = identifier.getSourceFile();
61+
62+
for (const node of sourceFile.statements) {
63+
// Only look at relative imports. This will break if the app uses a path
64+
// mapping to refer to the import, but in order to resolve those, we would
65+
// need knowledge about the entire program.
66+
if (
67+
!ts.isImportDeclaration(node) ||
68+
!node.importClause?.namedBindings ||
69+
!ts.isNamedImports(node.importClause.namedBindings) ||
70+
!ts.isStringLiteralLike(node.moduleSpecifier) ||
71+
!node.moduleSpecifier.text.startsWith('.')
72+
) {
73+
continue;
74+
}
75+
76+
for (const specifier of node.importClause.namedBindings.elements) {
77+
if (specifier.name.text !== identifier.text) {
78+
continue;
79+
}
80+
81+
// Look for a variable with the imported name in the file. Note that ideally we would use
82+
// the type checker to resolve this, but we can't because these utilities are set up to
83+
// operate on individual files, not the entire program.
84+
const filePath = join(dirname(bootstapFilePath), node.moduleSpecifier.text + '.ts');
85+
const importedSourceFile = getSourceFile(tree, filePath);
86+
const resolvedVariable = findAppConfigFromVariableName(
87+
importedSourceFile,
88+
(specifier.propertyName || specifier.name).text,
89+
);
90+
91+
if (resolvedVariable) {
92+
return { filePath, node: resolvedVariable };
93+
}
94+
}
95+
}
96+
97+
const variableInSameFile = findAppConfigFromVariableName(sourceFile, identifier.text);
98+
99+
return variableInSameFile ? { filePath: bootstapFilePath, node: variableInSameFile } : null;
100+
}
101+
102+
/**
103+
* Finds an app config within the top-level variables of a file.
104+
* @param sourceFile File in which to search for the config.
105+
* @param variableName Name of the variable containing the config.
106+
*/
107+
function findAppConfigFromVariableName(
108+
sourceFile: ts.SourceFile,
109+
variableName: string,
110+
): ts.ObjectLiteralExpression | null {
111+
for (const node of sourceFile.statements) {
112+
if (ts.isVariableStatement(node)) {
113+
for (const decl of node.declarationList.declarations) {
114+
if (
115+
ts.isIdentifier(decl.name) &&
116+
decl.name.text === variableName &&
117+
decl.initializer &&
118+
ts.isObjectLiteralExpression(decl.initializer)
119+
) {
120+
return decl.initializer;
121+
}
122+
}
123+
}
124+
}
125+
126+
return null;
127+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import { Rule, Tree } from '@angular-devkit/schematics';
10+
import ts from '../../third_party/github.com/Microsoft/TypeScript/lib/typescript';
11+
import { hasTopLevelIdentifier, insertImport } from '../ast-utils';
12+
import { applyToUpdateRecorder } from '../change';
13+
14+
/** Generated code that hasn't been interpolated yet. */
15+
export interface PendingCode {
16+
/** Code that will be inserted. */
17+
expression: string;
18+
19+
/** Imports that need to be added to the file in which the code is inserted. */
20+
imports: PendingImports;
21+
}
22+
23+
/** Map keeping track of imports and aliases under which they're referred to in an expresion. */
24+
type PendingImports = Map<string, Map<string, string>>;
25+
26+
/** Counter used to generate unique IDs. */
27+
let uniqueIdCounter = 0;
28+
29+
/**
30+
* Callback invoked by a Rule that produces the code
31+
* that needs to be inserted somewhere in the app.
32+
*/
33+
export type CodeBlockCallback = (block: CodeBlock) => PendingCode;
34+
35+
/**
36+
* Utility class used to generate blocks of code that
37+
* can be inserted by the devkit into a user's app.
38+
*/
39+
export class CodeBlock {
40+
private _imports: PendingImports = new Map<string, Map<string, string>>();
41+
42+
// Note: the methods here are defined as arrow function so that they can be destructured by
43+
// consumers without losing their context. This makes the API more concise.
44+
45+
/** Function used to tag a code block in order to produce a `PendingCode` object. */
46+
code = (strings: TemplateStringsArray, ...params: unknown[]): PendingCode => {
47+
return {
48+
expression: strings.map((part, index) => part + (params[index] || '')).join(''),
49+
imports: this._imports,
50+
};
51+
};
52+
53+
/**
54+
* Used inside of a code block to mark external symbols and which module they should be imported
55+
* from. When the code is inserted, the required import statements will be produced automatically.
56+
* @param symbolName Name of the external symbol.
57+
* @param moduleName Module from which the symbol should be imported.
58+
*/
59+
external = (symbolName: string, moduleName: string): string => {
60+
if (!this._imports.has(moduleName)) {
61+
this._imports.set(moduleName, new Map());
62+
}
63+
64+
const symbolsPerModule = this._imports.get(moduleName) as Map<string, string>;
65+
66+
if (!symbolsPerModule.has(symbolName)) {
67+
symbolsPerModule.set(symbolName, `@@__SCHEMATIC_PLACEHOLDER_${uniqueIdCounter++}__@@`);
68+
}
69+
70+
return symbolsPerModule.get(symbolName) as string;
71+
};
72+
73+
/**
74+
* Produces the necessary rules to transform a `PendingCode` object into valid code.
75+
* @param initialCode Code pending transformed.
76+
* @param filePath Path of the file in which the code will be inserted.
77+
*/
78+
static transformPendingCode(initialCode: PendingCode, filePath: string) {
79+
const code = { ...initialCode };
80+
const rules: Rule[] = [];
81+
82+
code.imports.forEach((symbols, moduleName) => {
83+
symbols.forEach((placeholder, symbolName) => {
84+
rules.push((tree: Tree) => {
85+
const recorder = tree.beginUpdate(filePath);
86+
const sourceFile = ts.createSourceFile(
87+
filePath,
88+
tree.readText(filePath),
89+
ts.ScriptTarget.Latest,
90+
true,
91+
);
92+
93+
// Note that this could still technically clash if there's a top-level symbol called
94+
// `${symbolName}_alias`, however this is unlikely. We can revisit this if it becomes
95+
// a problem.
96+
const alias = hasTopLevelIdentifier(sourceFile, symbolName, moduleName)
97+
? symbolName + '_alias'
98+
: undefined;
99+
100+
code.expression = code.expression.replace(
101+
new RegExp(placeholder, 'g'),
102+
alias || symbolName,
103+
);
104+
105+
applyToUpdateRecorder(recorder, [
106+
insertImport(sourceFile, filePath, symbolName, moduleName, false, alias),
107+
]);
108+
tree.commitUpdate(recorder);
109+
});
110+
});
111+
});
112+
113+
return { code, rules };
114+
}
115+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
export { addRootImport, addRootProvider } from './rules';
10+
export { PendingCode, CodeBlockCallback, type CodeBlock } from './code_block';

0 commit comments

Comments
 (0)