Automatically Updating Angular Modules With Schematics And The CLI


Table of Contents

This blog post is part of an article series.


Update, 2018-05-09: Updated for newest CLI version

Thanks to Hans Larsen from the Angular CLI Team for providing valuable feedback

In my last blog article, I've shown how to leverage Schematics, the Angular CLI's code generator, to scaffold custom components. This article goes one step further and shows how to register generated building blocks like Components, Directives, Pipes, or Services with an existing NgModule. For this I'll extend the example from the last article that generates a SideMenuComponent. The source code shown here can also be found in my GitHub repository.

Schematics is currently experimental and can change in future.
Angular Labs

Goal

To register the generated SideMenuComponent we need to perform several tasks. For instance, we have to lookup the file with respective NgModule. After this, we have to insert several lines into this file:

import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; // Add this line to reference component import { SideMenuComponent } from './side-menu/side-menu.component'; @NgModule({ imports: [ CommonModule ], // Add this Line declarations: [SideMenuComponent], // Add this Line if we want to export the component too exports: [SideMenuComponent] }) export class CoreModule { }

As you've seen in the last listing, we have to create an import statement at the beginning of the file. And then we have to add the imported component to the declarations array and - if the caller requests it - to the exports array too. If those arrays don't exist, we have to create them too.

The good message is, that the Angular CLI contains existing code for such tasks. Hence, we don't have to build everything from scratch. The next section shows some of those existing utility functions.

Utility Functions provided by the Angular CLI

The Schematics Collection @schematics/angular used by the Angular CLI for generating stuff like components or services turns out to be a real gold mine for modifying existing NgModules. For instance, you find some function to look up modules within @schematics/angular/utility/find-module. The following table shows two of them which I will use in the course of this article:

FunctionDescription
findModuleFromOptionsLooks up the current module file. For this, it starts in a given folder and looks for a file with the suffix .module.ts while the suffix .routing.module.ts is not accepted. If nothing has been found in the current folder, its parent folders are searched.
buildRelativePathBuilds a relative path that points from one file to another one. This function comes in handy for generating the import statement pointing from the module file to the file with the component to register.

Another file containing useful utility functions is @schematics/angular/utility/ast-utils. It helps with modifying existing TypeScript files by leveraging services provided by the TypeScript compiler. The next table shows some of its functions used here:

FunctionDescription
addDeclarationToModuleAdds a component, directive or pipe to the declarations array of an NgModule. If necessary, this array is created
addExportToModuleAdds an export to the NgModule

There are also other methods that add entries to the other sections of an NgModule (addImportToModule, addProviderToModule, addBootstrapToModule).

Please note, that those files are currently not part of the package's public API. Therefore, they can change in future. To be on the safe side, Hans Larsen from the Angular CLI Team suggested to fork it. My fork of the DevKit Repository containing those functions can be found here.

After forking, I've copied the contents of the folder packages\schematics\angular\utility containing the functions in question to the folder schematics-angular-utils in my project and adjusted some import statements. For the time being, you can also copy my folder with this adjustments for your own projects. I think that sooner or later the API will stabilize and be published as a public one so that we don't need this workaround.

Creating a Rule for adding a declaration to an NgModule

After we've seen that there are handy utility functions, let's use them to build a Rule for our endeavor. For this, we use a folder utils with the following two files:

Utils for custom Rule

The file add-to-module-context.ts gets a context class holding data for the planned modifications:

import * as ts from 'typescript'; export class AddToModuleContext { // source of the module file source: ts.SourceFile; // the relative path that points from // the module file to the component file relativePath: string; // name of the component class classifiedName: string; }

In the other file, ng-module-utils.ts, a factory function for the needed rule is created:

import { Rule, Tree, SchematicsException } from '@angular-devkit/schematics'; import { AddToModuleContext } from './add-to-module-context'; import * as ts from 'typescript'; import { dasherize, classify } from '@angular-devkit/core'; import { ModuleOptions, buildRelativePath } from '../schematics-angular-utils/find-module'; import { addDeclarationToModule, addExportToModule } from '../schematics-angular-utils/ast-utils'; import { InsertChange } from '../schematics-angular-utils/change'; const stringUtils = { dasherize, classify }; export function addDeclarationToNgModule(options: ModuleOptions, exports: boolean): Rule { return (host: Tree) => { [...] }; }

This function takes an ModuleOptions instance that describes the NgModule in question. It can be deduced by the options object containing the command line arguments the caller passes to the CLI.

It also takes a flag exports that indicates whether the declared component should be exported too. The returned Rule is just a function that gets a Tree object representing the part of the file system it modifies. For implementing this Rule I've looked up the implementation of similar rules within the CLI's Schematics in @schematics/angular and "borrowed" the patterns found there. Especially the Rule triggered by ng generated component was very helpful for this.

Before we discuss how this function is implemented, let's have a look at some helper functions I've put in the same file. The first one collects the context information we've talked about before:

function createAddToModuleContext(host: Tree, options: ModuleOptions): AddToModuleContext { const result = new AddToModuleContext(); if (!options.module) { throw new SchematicsException(Module not found.); } // Reading the module file const text = host.read(options.module); if (text === null) { throw new SchematicsException(File <span class="hljs-subst">${options.module}</span> does not exist.); } const sourceText = text.toString('utf-8'); result.source = ts.createSourceFile(options.module, sourceText, ts.ScriptTarget.Latest, true); const componentPath = /<span class="hljs-subst">${options.sourceDir}</span>/<span class="hljs-subst">${options.path}</span>/ + stringUtils.dasherize(options.name) + '/' + stringUtils.dasherize(options.name) + '.component'; result.relativePath = buildRelativePath(options.module, componentPath); result.classifiedName = stringUtils.classify(<span class="hljs-subst">${options.name}</span>Component); return result; }

The second helper function is addDeclaration. It delegates to addDeclarationToModule located within the package @schematics/angular to add the component to the module's declarations array:

function addDeclaration(host: Tree, options: ModuleOptions) { const context = createAddToModuleContext(host, options); const modulePath = options.module || ''; const declarationChanges = addDeclarationToModule( context.source, modulePath, context.classifiedName, context.relativePath); const declarationRecorder = host.beginUpdate(modulePath); for (const change of declarationChanges) { if (change instanceof InsertChange) { declarationRecorder.insertLeft(change.pos, change.toAdd); } } host.commitUpdate(declarationRecorder); };

The addDeclarationToModule function takes the retrieved context information and the modulePath from the passed ModuleOptions. Instead of directly updating the module file it returns an array with necessary modifications. These are iterated and applied to the module file within a transaction, started with beginUpdate and completed with commitUpdate.

The second helper function is addExport. It adds the component to the module's exports array and works exactly like the addDeclaration:

function addExport(host: Tree, options: ModuleOptions) { const context = createAddToModuleContext(host, options); const modulePath = options.module || ''; const exportChanges = addExportToModule( context.source, modulePath, context.classifiedName, context.relativePath); const exportRecorder = host.beginUpdate(modulePath); for (const change of exportChanges) { if (change instanceof InsertChange) { exportRecorder.insertLeft(change.pos, change.toAdd); } } host.commitUpdate(exportRecorder); };

Now, as we've looked at these helper function, let's finish the implementation of our Rule:

export function addDeclarationToNgModule(options: ModuleOptions, exports: boolean): Rule { return (host: Tree) => { addDeclaration(host, options); if (exports) { addExport(host, options); } return host; }; }

As you've seen, it just delegates to addDeclaration and addExport. After this, it returns the modified file tree represented by the variable host.

Extending the used Options Class and its JSON schema

Before we put our new Rule in place, we have to extend the class MenuOptions which describes the passed (command line) arguments. As usual in Schematics, it's defined in the file schema.ts. For our purpose, it gets two new properties:

export interface MenuOptions { name?: string; project?: string; path?: string; module?: string; // New Properties: module?: string; export?: boolean; }

The property module holds the path for the module file to modify and export defines whether the generated component should be exported too.

After this, we have to declare these additional property in the file schema.json:

{ "$schema": "http://json-schema.org/schema", "id": "SchemanticsForMenu", "title": "Menu Schema", "type": "object", "properties": { [...] "module": { "type": "string", "description": "The declaring module.", "alias": "m" }, "export": { "type": "boolean", "default": false, "description": "Export component from module?" } } }

As mentioned in the last blog article, we also could generate the file schema.ts with the information provided by schema.json.

Calling the Rule

Now, as we've created our rule, let's put it in place. For this, we have to call it within the Rule function in index.ts:

export default function (options: MenuOptions): Rule { return (host: Tree, context: SchematicContext) => { options.path = options.path ? normalize(options.path) : options.path; // Infer module path, if not passed: options.module = options.module || findModuleFromOptions(host, options) || ''; [...] const rule = chain([ branchAndMerge(chain([ [...] // Call new rule addDeclarationToNgModule(options, options.export) ])), ]); return rule(host, context); } }

As the passed MenuOptions object is structurally compatible to the needed ModuleOptions we can directly pass it to addDeclarationToNgModule. This is the way, the CLI currently deals with option objects.

In addition to that, we infer the module path at the beginning using findModuleFromOptions.

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 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, generate a module core and navigate to its folder:

ng g module core
cd src\app\core

Now call the custom Schematic:

ng g nav:menu side-menu --menu-service --export

This not only generates the SideMenuComponent but also registers it with the CoreModule:

import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { SideMenuComponent } from './side-menu/side-menu.component'; @NgModule({ imports: [ CommonModule ], declarations: [SideMenuComponent], exports: [SideMenuComponent] }) export class CoreModule { }