Sie sind hier: Weblog

Generating Angular Code with Schematics, Part II: Modifying NgModules

Foto ,
01.12.2017 01:00:00

In my last blog article, Ive shown how to leverage Schematics, the Angular CLIs 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 Ill 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 youve 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 dont exist, we have to create them too.

The good message is, that the Angular CLI contains existing code for such tasks. Hence, we dont 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:

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 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:

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

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

Please note, that those files are not part of the packages public API. Therefore, they can change in future.

Creating an Rule for adding a declaration to an NgModule

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

Folder for new 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 { ModuleOptions, buildRelativePath } from @schematics/angular/utility/find-module;
import { Rule, Tree, SchematicsException } from @angular-devkit/schematics;
import { dasherize, classify } from @angular-devkit/core;
import { addDeclarationToModule, addExportToModule } from @schematics/angular/utility/ast-utils;
import { InsertChange } from @schematics/angular/utility/change;
import { AddToModuleContext } from ./add-to-module-context;
import * as ts from typescript;

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 Ive looked up the implementation of similar rules within the CLIs 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, lets have a look at some helper functions Ive put in the same file. The first one collects the context information weve 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 ${options.module} does not exist.`);
  }

  const sourceText = text.toString(utf-8);
  result.source = ts.createSourceFile(options.module, sourceText, ts.ScriptTarget.Latest, true);

  const componentPath = `/${options.sourceDir}/${options.path}/`
		      + stringUtils.dasherize(options.name) + /
		      + stringUtils.dasherize(options.name)
		      + .component;

  result.relativePath = buildRelativePath(options.module, componentPath);

  result.classifiedName = stringUtils.classify(`${options.name}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 modules 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 modules 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 weve looked at these helper function, lets 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 youve 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, its defined in the file schema.ts. For our purpose, it gets two new properties:

export interface MenuOptions {
    name: string;
    appRoot: string;
    path: string;
    sourceDir: string;
    menuService: boolean;

	// 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 weve created our rule, lets 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, Ive decided to copy it to node_modules/nav. Please make sure to exclude the collections node_modules folder, so that there is no folder node_modules/nav/node_modules.

After this, switch to the example applications root, generate a module core and navigate to its folder:

ng g module core
cd src\app\core

Now call the custom Schematic:

Calling Schematic which generated component and registers it with the module

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 { }