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