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 previous post, I demonstrated how Schematics, the code generator behind the Angular CLI, can scaffold custom components. This installment takes that concept further by showing how to connect generated building blocks like Components, Directives, Pipes, or Services to an existing NgModule. I'll build upon the earlier example that produces a SideMenuComponent. The source code demonstrated here is also available in my GitHub repository.

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

Objective

To properly register the generated SideMenuComponent, a handful of steps are needed. For starters, we need to locate the file that contains the relevant NgModule. Following that, several lines need to be inserted into that 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 evident in the previous listing, an import statement must be placed near the top of the file. From there, the imported component needs to be added to the declarations array, and, if the user requests it, also to the exports array. Should these arrays be missing, they need to be created from scratch.

The encouraging news is that the Angular CLI already bundles code specifically for these kinds of operations. This means we don't have to reinvent the wheel. The upcoming section highlights some of these pre-existing utility functions.

Utility Functions from the Angular CLI

The Schematics collection @schematics/angular, which the Angular CLI employs to generate components or services, turns out to be an excellent resource for modifying existing NgModules. For example, you'll find functions for locating modules in @schematics/angular/utility/find-module. The table below outlines two of these functions that I'll be using throughout this article:

Function Description
findModuleFromOptions Looks 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.
buildRelativePath Builds 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 packed with useful utilities is @schematics/angular/utility/ast-utils. It simplifies the process of altering existing TypeScript files using services from the TypeScript compiler. The following table lists some of its functions that are relevant here:

Function Description
addDeclarationToModule Adds a component, directive or pipe to the declarations array of an NgModule. If necessary, this array is created
addExportToModule Adds an export to the NgModule

Further methods exist for adding entries to other sections of an NgModule, such as addImportToModule, addProviderToModule, and addBootstrapToModule.

Keep in mind that these files aren't currently part of the package's official public API, so they could shift in the future. To mitigate this risk, Hans Larsen from the Angular CLI Team recommended forking the code. My fork of the DevKit Repository, which includes these functions, can be accessed here.

After forking, I transferred the contents of the packages\schematics\angular\utility folder, which holds the relevant functions, into the schematics-angular-utils folder in my project and tweaked a few import statements. You're welcome to copy my adjusted folder for your own use. I anticipate the API will eventually stabilize and become public, eliminating the need for this workaround.

Building a Rule to Add Declarations to an NgModule

With the handy utility functions now at our disposal, let's use them to construct a Rule for our purpose. To do this, we'll set up a utils folder containing two files:

Utils for custom Rule

The add-to-module-context.ts file defines a context class that holds the data necessary for the intended 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 second file, ng-module-utils.ts, we create a factory function for the required rule:

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 accepts a ModuleOptions instance that details the NgModule in question. This instance can be derived from the options object that holds the command line arguments passed by the caller to the CLI.

It also takes an exports flag indicating whether the declared component should also be exported. The returned Rule is essentially a function that receives a Tree object representing the portion of the file system it will modify. To implement this Rule, I examined how similar rules are built in the CLI's Schematics within @schematics/angular and "borrowed" the patterns found there. The Rule triggered by ng generated component proved particularly useful in this regard.

Before diving into the function's implementation, let's review a few helper functions placed in the same file. The first one gathers the context information mentioned earlier:

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, addDeclaration, works by calling addDeclarationToModule from the @schematics/angular package 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 provided ModuleOptions. Rather than updating the module file directly, it returns an array of necessary modifications. These modifications are then applied to the module file within a transaction, starting with beginUpdate and concluding with commitUpdate.

The next helper function is addExport. It handles adding the component to the module's exports array and operates identically to 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, having examined these helpers, let's finish implementing our Rule:

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

As shown, it simply delegates to addDeclaration and addExport. Following this, it returns the modified file tree, represented by the host variable.

Expanding the Options Class and its JSON Schema

Before implementing our new Rule, we need to extend the MenuOptions class, which describes the incoming (command line) arguments. As is standard in Schematics, this is defined in the schema.ts file. For our needs, it gains two new properties:

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

The module property specifies the path to the module file that needs modification, while export determines whether the generated component should also be exported.

Next, we must declare these additional properties in the schema.json file:

{ "$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 noted in the previous blog article, we also have the option to generate the schema.ts file based on the details in schema.json.

Putting the Rule into Action

With our rule created, it's time to integrate it. To do so, we need to invoke 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); } }

Since the provided MenuOptions object is structurally compatible with the required ModuleOptions, we can pass it directly to addDeclarationToNgModule. This mirrors how the CLI currently processes option objects.

Additionally, we determine the module path at the start using findModuleFromOptions.

Validating the Updated Schematic

To test the modified Schematic, compile it and transfer everything to the node_modules folder of a sample app. As in the earlier blog article, I chose to copy it to node_modules/nav. Be sure to exclude the collection's node_modules folder to avoid creating node_modules/nav/node_modules.

After that, navigate to the root of the sample app, generate a core module, and move into its directory:

ng g module core
cd src\app\core

Now, execute the custom Schematic:

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

This will not only produce the SideMenuComponent but also register 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 { }