Table of Contents
This blog post is part of an article series.
- Part I: Generating Custom Code With The Angular CLI And Schematics
- Part II: Automatically Updating Angular Modules With Schematics And The CLI
- Part III: Extending Existing Code With The TypeScript Compiler API
- Part IV: Frictionless Library Setup with the Angular CLI and Schematics
- Part V: Seamlessly Updating your Angular Libraries with ng update
In my two previous blog posts, I've shown how to leverage Schematics to generate custom code with the Angular CLI as well as to update an existing NgModules with declarations for generated components. The latter one was not that difficult because this is a task the CLI performs too and hence there are already helper functions we can use.
But, as one can imagine, we are not always that lucky and find existing helper functions. In these cases we need to do the heavy lifting by ourselves and this is what this post is about: Showing how to directly modify existing source code in a safe way.
When we look into the helper functions used in the previous article, we see that they are using the TypeScript Compiler API which e. g. gives us a syntax tree for TypeScript files. By traversing this tree and looking at its nodes we can analyse existing code and find out where a modification is needed.
Using this approach, this post extends the schematic from the last article so that the generated Service is injected into the AppComponent
where it can be configured:
[...]
import { SideMenuService } from './core/side-menu/side-menu.service';
@Component({ [...] })
export class AppComponent {
constructor(
private sideMenuService: SideMenuService) {
// sideMenuService.show = true;
}
}
I think, providing boilerplate for configuring a library that way can lower the barrier for getting started with it. However, please note that this simple example represents a lot of situations where modifying existing code provides more convenience.
The source code for the examples used for this can be found here in my GitHub repository.
Schematics is currently an Angular Labs project. Its public API is experimental and can change in future.
Walking a Syntax Tree with the TypeScript Compiler API
To get familiar with the TypeScript Compiler API, let's start with a simple NodeJS example that demonstrates its fundamental usage. All we need for this is TypeScript itself. As I'm going to use it within an simple NodeJS application, let's also install the typings for it. For this, we can use the following commands in a new folder:
npm init
npm install typescript --save
npm install @types/node --save-dev
In addition to that, we need a tsconfig.json
with respective compiler settings:
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"lib": ["dom", "es2017"],
"moduleResolution": "node"
}
}
Now we have everything in place for our first experiment with the Compiler CLI. Let's create a new file index.ts
:
import * as ts from 'typescript';
import * as fs from 'fs';
function showTree(node: ts.Node, indent: string = ' '): void {
console.log(indent + ts.SyntaxKind[node.kind]);
if (node.getChildCount() === 0) {
console.log(indent + ' Text: ' + node.getText());
}
for(let child of node.getChildren()) {
showTree(child, indent + ' ');
}
}
let buffer = fs.readFileSync('demo.ts');
let content = buffer.toString('utf-8');
let node = ts.createSourceFile('demo.ts', content, ts.ScriptTarget.Latest, true);
showTree(node);
The showTree
function recursively traverses the syntax tree beginning with the passed node. For this it logs the node's kind
to the console. This property tells us whether the node represents for instance a class name, a constructor or a parameter list. If the node doesn't have any children, the program is also printing out the node's textual content, e. g. the represented class name. The function repeats this for each child node with an increased indent.
At the end, the program is reading a TypeScript file and constructing a new SourceFile
object with it's content. As the type SourceFile
is also a node, we can pass it to showTree
.
In addition to this, we also need the demo.ts
file the application is loading. For the sake of simplicity, let's go with the following simple class:
class Demo {
constructor(otherDemo: Demo) {}
}
To compile and run the application, we can use the following commands:
tsc index.ts
node index.js
Of course, it would make sense to create a npm script for this.
When running, the application should show the following syntax tree:
SourceFile
SyntaxList
ClassDeclaration
ClassKeyword
Text: class
Identifier
Text: Demo
FirstPunctuation
Text: {
SyntaxList
Constructor
ConstructorKeyword
Text: constructor
OpenParenToken
Text: (
SyntaxList
Parameter
Identifier
Text: otherDemo
ColonToken
Text: :
TypeReference
Identifier
Text: Demo
CloseParenToken
Text: )
Block
FirstPunctuation
Text: {
SyntaxList
Text:
CloseBraceToken
Text: }
CloseBraceToken
Text: }
EndOfFileToken
Text:
Take some time to look at this tree. As you see, it contains a node for every aspect of our demo.ts
. For instance, there is a node with of the kind ClassDeclaration
for our class and it contains a ClassKeyword
and an Identifier
with the text Demo
. You also see a Constructor
with nodes that represent all the pieces a constructor consists of. It contains a SyntaxList
with a sub tree for the constructor argument otherDemo
.
When we combine what we've learned when writing this example with the things we already know about Schematics from the previous articles, we have everything to implement the initially described endeavor. The next sections describe the necessary steps.
Providing Key Data
When writing a Schematics rule, a first good step is thinking about all the data it needs and creating a class for it. In our case, this class looks like this:
export interface AddInjectionContext {
appComponentFileName: string;
// e. g. /src/app/app.component.ts
relativeServiceFileName: string;
// e. g. ./core/side-menu/side-menu.service
serviceName: string;
// e. g. SideMenuService
}
To get this data, let's create a function createAddInjectionContext
:
function createAddInjectionContext(options: ModuleOptions): AddInjectionContext {
let appComponentFileName = findFileByName('app.component.ts', options.path || '/', host);
let destinationPath = constructDestinationPath(options);
let serviceName = classify(<span class="hljs-subst">${options.name}</span>Service
);
let serviceFileName = join(normalize(destinationPath), <span class="hljs-subst">${dasherize(options.name)}</span>.service
);
let relativeServiceFileName = buildRelativePath(appComponentFileName, serviceFileName);
return {
appComponentFileName,
relativeServiceFileName,
serviceName
}
}
function findFileByName(file: string, path: string, host: Tree): string {
let dir: DirEntry | null = host.getDir(path);
while(dir) {
let appComponentFileName = dir.path + '/' + file;
if (host.exists(appComponentFileName)) {
return appComponentFileName;
}
dir = dir.parent;
}
throw new SchematicsException(File <span class="hljs-subst">${file}</span> not found in <span class="hljs-subst">${path}</span> or one of its anchestors
);
}
As this listing shows, createAddInjectionContext
takes an instance of the class ModuleOptions
. It is part of the utils Schematics contains and represents the parameters the CLI passes. The three needed fields are inferred from those instance. To find out in which folder the generated files are placed, it uses the custom helper constructDestinationPath
:
export function constructDestinationPath(options: ModuleOptions): string {
return '/' + (options.sourceDir? options.sourceDir + '/' : '') + (options.path || '')
+ (options.flat ? '' : '/' + dasherize(options.name));
}
In addition to this, it uses further helper functions Schematics provides us:
classify
: Creates a class name, e. g.SideMenu
when passingside-menu
.normalize
: Normalizes a path in order to compensate for platform specific characters like \ under Windows.dasherize
: Converts to Kebab case, e. g. it returnsside-menu
forSideMenu
.join
: Combines two paths.buildRelativePath
: Builds a relative path that points from the first passed absolute path to the second one.
Please note, that some of the helper functions used here are not part of the public API. To prevent breaking changes I've copied the respective files. More about this wrinkle can be found in my previous article about this topic.
Adding a new constructor
In cases where the AppComponent
does not have a constructor, we have to create one. The Schematics way of doing this is creating a Change
-Object that describes this modification. For this task, I've created a function createConstructorForInjection
. Although it is a bit long because we have to include several null/undefined checks, it is quite straight:
function createConstructorForInjection(context: AddInjectionContext, nodes: ts.Node[], options: ModuleOptions): Change {
let classNode = nodes.find(n => n.kind === ts.SyntaxKind.ClassKeyword);
if (!classNode) {
throw new SchematicsException(expected class in <span class="hljs-subst">${context.appComponentFileName}</span>
);
}
if (!classNode.parent) {
throw new SchematicsException(expected constructor in <span class="hljs-subst">${context.appComponentFileName}</span> to have a parent node
);
}
let siblings = classNode.parent.getChildren();
let classIndex = siblings.indexOf(classNode);
siblings = siblings.slice(classIndex);
let classIdentifierNode = siblings.find(n => n.kind === ts.SyntaxKind.Identifier);
if (!classIdentifierNode) {
throw new SchematicsException(expected class in <span class="hljs-subst">${context.appComponentFileName}</span> to have an identifier
);
}
if (classIdentifierNode.getText() !== 'AppComponent') {
throw new SchematicsException(expected first class in <span class="hljs-subst">${context.appComponentFileName}</span> to have the name AppComponent
);
}
// Find opening cury braces (FirstPunctuation means '{' here).
let curlyNodeIndex = siblings.findIndex(n => n.kind === ts.SyntaxKind.FirstPunctuation);
siblings = siblings.slice(curlyNodeIndex);
let listNode = siblings.find(n => n.kind === ts.SyntaxKind.SyntaxList);
if (!listNode) {
throw new SchematicsException(expected first class in <span class="hljs-subst">${context.appComponentFileName}</span> to have a body
);
}
let toAdd = `
constructor(private ${camelize(context.serviceName)}: ${classify(context.serviceName)}) {
// ${camelize(context.serviceName)}.show = true;
}
`;
return new InsertChange(context.appComponentFileName, listNode.pos+1, toAdd);
}
The parameter nodes
contains all nodes of the syntax tree in a flat way. This structure is also used by some default rules Schematics comes with and allows to easily search the tree with Array methods. The function looks for the first node of the kind ClassKeyword
which contains the class
keyword. Compare this with the syntax tree above which was displayed by the first example.
After this it gets an array with the ClassKeyword
's siblings (=its parent's children) and searches it from left to right in order to find a position for the new constructor. To search from left to right, it truncates everything that is on the left of the current position using slice
several times. To be honest, this is not the best decision in view of performance, but it should be fast enough and I think that it makes the code more readable.
Using this approach, the functions walks to the right until it finds a SyntaxList
(= class body) that follows a FirstPunctuation
node (= the character '{' in this case) which in turn follows an Identifier
(= the class name). Then it uses the position of this SyntaxList
to create an InsertChange
object that describes that a constructor should be inserted there.
Of course, we could also search the body of the class to find a more fitting place for the constructor -- e. g. between the property declarations and the method declarations -- but for the sake of simplicity and demonstration, I've dropped this idea.
Adding a constructor argument
If there already is a constructor, we have to add another argument for our service. The following function is taking care about this task. Among other parameters, it takes the node that represents the constructor. You can also compare this with the syntax tree of our first example at the beginning.
function addConstructorArgument(context: AddInjectionContext, ctorNode: ts.Node, options: ModuleOptions): Change {
let siblings = ctorNode.getChildren();
let parameterListNode = siblings.find(n => n.kind === ts.SyntaxKind.SyntaxList);
if (!parameterListNode) {
throw new SchematicsException(expected constructor in <span class="hljs-subst">${context.appComponentFileName}</span> to have a parameter list
);
}
let parameterNodes = parameterListNode.getChildren();
let paramNode = parameterNodes.find(p => {
let typeNode = findSuccessor(p, [ts.SyntaxKind.TypeReference, ts.SyntaxKind.Identifier]);
if (!typeNode) return false;
return typeNode.getText() === context.serviceName;
});
// There is already a respective constructor argument --> nothing to do for us here ...
if (paramNode) return new NoopChange();
// Is the new argument the first one?
if (!paramNode && parameterNodes.length == 0) {
let toAdd = private <span class="hljs-subst">${camelize(context.serviceName)}</span>: <span class="hljs-subst">${classify(context.serviceName)}</span>
;
return new InsertChange(context.appComponentFileName, parameterListNode.pos, toAdd);
}
else if (!paramNode && parameterNodes.length > 0) {
let toAdd = `,
private ${camelize(context.serviceName)}: ${classify(context.serviceName)}`;
let lastParameter = parameterNodes[parameterNodes.length-1];
return new InsertChange(context.appComponentFileName, lastParameter.end, toAdd);
}
return new NoopChange();
}
This function retrieves all child nodes of the constructor and searches for a SyntaxList
(=the parameter list) node having a TypeReference
child which in turn has a Identifier
child. For this, it uses the helper function findSuccessor
displayed below. The found identifier holds the type of the argument in question. If there is already an argument that points to the type of our service, we don't need to do anything. Otherwise the function checks wether we are inserting the first argument or a subsequent one. In each case, the correct position for the new argument is located and then the function returns a respective InsertChange
-Object for the needed modification.
function findSuccessor(node: ts.Node, searchPath: ts.SyntaxKind[] ) {
let children = node.getChildren();
let next: ts.Node | undefined = undefined;
for(let syntaxKind of searchPath) {
next = children.find(n => n.kind == syntaxKind);
if (!next) return null;
children = next.getChildren();
}
return next;
}
Deciding whether to create or modify a Constructor
The good message first: We've done the heavy work. What we need now is a function that decides which of the two possible changes -- adding a constructor or modifying it -- needs to be done:
function buildInjectionChanges(context: AddInjectionContext, host: Tree, options: ModuleOptions): Change[] {
let text = host.read(context.appComponentFileName);
if (!text) throw new SchematicsException(File <span class="hljs-subst">${options.module}</span> does not exist.
);
let sourceText = text.toString('utf-8');
let sourceFile = ts.createSourceFile(context.appComponentFileName, sourceText, ts.ScriptTarget.Latest, true);
let nodes = getSourceNodes(sourceFile);
let ctorNode = nodes.find(n => n.kind == ts.SyntaxKind.Constructor);
let constructorChange: Change;
if (!ctorNode) {
// No constructor found
constructorChange = createConstructorForInjection(context, nodes, options);
}
else {
constructorChange = addConstructorArgument(context, ctorNode, options);
}
return [
constructorChange,
insertImport(sourceFile, context.appComponentFileName, context.serviceName, context.relativeServiceFileName)
];
}
As the first sample in this post, it uses the TypeScript Compiler API to create a SourceFile
object for the file containing the AppComponent
. Then it uses the function getSourceNodes
which is part of Schematics to traverse the whole tree and creates a flat array with all nodes. These nodes are searched for a constructor. If there is none, we are using our function createConstructorForInjection
to create a Change
object; otherwise we are going with addConstructorArgument
. At the end, the function returns this Change
together with another Change
created by insertImport
which also comes with Schematics and creates the needed import
statement at the beginning of the TypeScript file.
Please note that the order of these two changes is vital because they are adding lines to the source file which is forging the position information within the node objects.
Putting all together
Now, we just need a factory function for a rule that is calling buildInjectionChanges
and applying the returned changes:
export function injectServiceIntoAppComponent(options: ModuleOptions): Rule {
return (host: Tree) => {
let context = createAddInjectionContext(options);
let changes = buildInjectionChanges(context, host, options);
const declarationRecorder = host.beginUpdate(context.appComponentFileName);
for (let change of changes) {
if (change instanceof InsertChange) {
declarationRecorder.insertLeft(change.pos, change.toAdd);
}
}
host.commitUpdate(declarationRecorder);
return host;
};
};
This function takes the ModuleOptions
holding the parameters the CLI passes and returns a Rule
function. It creates the context object with the key data and delegates to buildInjectionChanges
. The received rules are iterated and applied.
Adding Rule to Schematic
To get our new injectServiceIntoAppComponent
rule called, we have to call it in its index.ts
:
[...]
export default function (options: MenuOptions): Rule {
return (host: Tree, context: SchematicContext) => {
[...]
const rule = chain([
branchAndMerge(chain([
mergeWith(templateSource),
addDeclarationToNgModule(options, options.export),
injectServiceIntoAppComponent(options)
]))
]);
return rule(host, context);
}
}
Testing the extended Schematic
To try the modified Schematic out, compile it and copy everything to the node_modules
folder of an example application. As in the former blog article, I've decided to copy it to node_modules/nav
. Please make sure to exclude the Schematic Collection's node_modules
folder, so that there is no folder node_modules/nav/node_modules
.
After this, switch to the example application's root and call the Schematic:
ng g nav:menu side-menu --menu-service --export
This not only created the SideMenu
but also injects its service into the AppComponent
:
import { Component } from '@angular/core';
import { OnChanges, OnInit } from '@angular/core';
import { SideMenuService } from './core/side-menu/side-menu.service';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
constructor(private sideMenuService: SideMenuService) {
// sideMenuService.show = true;
}
title = 'app';
}