Generating Custom Code With The Angular CLI And Schematics

Generating Custom Code With The Angular CLI And Schematics


Table of Contents

This blog post is part of an article series.


Update, 2018-05-08: Updated for newest CLI version.

Since some versions, the Angular CLI uses a library called Schematics to scaffold building blocks like components or services. One of the best things about this is that Schematics allows to create own code generators too. Using this extension mechanism, we can modify the way the CLI generates code. But we can also provide custom collections with code generators and publish them as npm packages. A good example for this is Nrwl's Nx which allows to generated boilerplate code for Ngrx or upgrading an existing application from AngularJS 1.x to Angular.

These code generators are called Schematics and can not only create new files but also modify existing ones. For instance, the CLI uses the latter idea to register generated components with existing modules.

In this post, I'm showing how to create a collection with a custom Schematic from scratch and how to use it with an Angular project. The sources can be found here.

In addition to this, you'll find a nice video with Mike Brocchi from the CLI-Team explaining the basics and ideas behind Schematics here.

Angular Labs

The public API of Schematics is currently experimental and can change in future.

Goal

To demonstrate how to write a simple Schematic from scratch, I will build a code generator for a Bootstrap based side menu. With an respective template like the free ones at Creative Tim the result could look like this:

Solution

Before creating a generator it is a good idea to have an existing solution that contains the code you want to generate in all variations.

In our case, the component is quite simple:

import { Component, OnInit } from '@angular/core'; @Component({ selector: 'menu', templateUrl: 'menu.component.html' }) export class MenuComponent { }

In addition to that, the template for this component is just a bunch of html tags with the right Bootstrap based classes -- something I cannot learn by heart what's the reason a generator seems to be a good idea:

<div class="sidebar-wrapper"> <div class="logo"> <a class="simple-text"> AppTitle </a> </div> <ul class="nav"> <li> <a> <i class="ti-home"></i> <p>Home</p> </a> </li> <!-- add here some other items as shown before --> </ul> </div>

In addition to the code shown before, I want also have the possibility to create a more dynamic version of this side menu. This version uses an interface MenuItem to represent the items to display:

export interface MenuItem { title: string; iconClass: string; }

A MenuService is providing instances of MenuItem:

import { MenuItem } from './menu-item'; export class MenuService { public items: MenuItem[] = [ { title: 'Home', iconClass: 'ti-home' }, { title: 'Other Menu Item', iconClass: 'ti-arrow-top-right' }, { title: 'Further Menu Item', iconClass: 'ti-shopping-cart'}, { title: 'Yet another one', iconClass: 'ti-close'} ]; }

The component gets an instance of the service by the means of dependency injection:

import { Component, OnInit } from '@angular/core'; import { menuItem } from './menu-item'; import { menuService } from './menu.service'; @Component({ selector: 'menu', templateUrl: './menu.component.html', providers:[MenuService] }) export class MenuComponent { items: MenuItem[]; constructor(service: MenuService) { this.items = service.items; } }

After fetching the MenuItems from the service the component iterates over them using *ngFor and creates the needed markup:

<div class="sidebar-wrapper"> <div class="logo"> <a class="simple-text"> AppTitle </a> </div> <ul class="nav"> <li *ngFor="let item of items"> <a href="#"> <i class="{{item.iconClass}}"></i> <p>{{item.title}}</p> </a> </li> </ul> </div>

Even though this example is quite easy it provides enough stuff to demonstrate the basics of Schematics.

Scaffolding a Collection for Schematics ... with Schematics

To provide a project structure for an npm package with a Schematics Collection, we can leverage Schematics itself. The reason is that the product team provides a "meta schematic" for this. To get everything up and running we need to install the following npm package:

npm i -g @angular-devkit/schematics-cli

In order to get our collection scaffolded we just need to type in the following command:

schematics schematic --name nav

After executing this command we get an npm package with a collection that holds three demo schematics:

npm package with collection

The file collection.json contains metadata about the collection and points to the schematics in the three sub folders. Each schematic has meta data of its own describing the command line arguments it supports as well as generator code. Usually, they also contain template files with placeholders used for generating code. But more about this in the following sections.

Before we can start, we need to npm install the dependencies the generated package.json points to. In addition to that, it is a good idea to rename its section dependencies to devDependencies because we don't want to install them when we load the npm package into a project:

{ "name": "nav", "version": "0.0.0", "description": "A schematics", "scripts": { "build": "tsc -p tsconfig.json", "test": "npm run build && jasmine **/*_spec.js" }, "keywords": [ "schematics" ], "author": "", "license": "MIT", "schematics": "./src/collection.json", "devDependencies": { "@angular-devkit/core": "^0.6.0", "@angular-devkit/schematics": "^0.6.0", "@types/jasmine": "^2.6.0", "@types/node": "^8.0.31", "jasmine": "^2.8.0", "typescript": "^2.5.2" } }

As you saw in the last listing, the packages.json contains a field schematics which is pointing to the file collection.json to inform about the metadata.

Adding an custom Schematic

The three generated schematics contain comments that describe quite well how Schematics works. It is a good idea to have a look at them. For this tutorial, I've deleted them to concentrate on my own schematic. For this, I'm using the following structure:

Structure for custom Schematics

If you don't need the sample schematics you can also use the following command to create an empty scheamtics project:

schematics blank --name myProject

The new folder menu contains the custom schematic. It's command line arguments are described by the file schema.json using a JSON schema. The described data structure can also be found as an interface within the file schema.ts. Normally it would be a good idea to generate this interface out of the schema but for this easy case I've just handwritten it.

The index.ts contains the so called factory for the schematic. This is a function that generates a rule (containing other rules) which describes how the code can be scaffolded. The templates used for this are located in the files folder. We will have a look at them later.

First of all, let's update the collection.json to make it point to our menu schematic:

{ "schematics": { "menu": { "aliases": [ "mnu" ], "factory": "./menu", "description": "Generates a menu component", "schema": "./menu/schema.json" } } }

Here we have an property menu for the menu schematic. This is also the name we reference when calling it. The array aliases contains other possible names to use and factory points to the file with the schematic's factory. Here, it points to ./menu which is just a folder. That's why the factory is looked up in the file ./menu/index.js.

In addition to that, the collection.json also points to the schema with the command line arguments. This file describes a property for each possible argument:

{ "$schema": "http://json-schema.org/schema", "id": "SchemanticsForMenu", "title": "Menu Schema", "type": "object", "properties": { "name": { "type": "string", "$default": { "$source": "argv", "index": 0 } }, "path": { "type": "string", "format": "path", "description": "The path to create the component.", "visible": false }, "project": { "type": "string", "description": "The name of the project.", "$default": { "$source": "projectName" } }, "module": { "type": "string", "description": "The declaring module.", "alias": "m" }, "menuService": { "type": "boolean", "default": false, "description": "Flag to indicate whether an menu service should be generated.", "alias": "ms" } } }

The argument name holds the name of the menu component. We find also the component's path and module. As an Angular application can contain several projects, the project property points to the right one.

To prevent the developer from typing all those properties into the console, the schema.json points to defaults. For instance, "$source": "projectName" points to the Angular project in the current folder. To point to specific command line arguments, it is using "$source": "argv" with an respective index.

I've also defined a property menuService to indicate, whether the above mentioned service class should be generated too.

The interface for the schema within schema.ts is called MenuOptions:

export interface MenuOptions { name: string; project?: string; path?: string; module?: string; menuService?: boolean; }

Schematic Factory

To tell Schematics how to generated the requested code files, we need to provide a factory. This function describes the necessary steps with a rule which normally makes use of further rules:

import { MenuOptions } from './schema'; import { Rule, [...] } from '@angular-devkit/schematics'; [...] export default function (options: MenuOptions): Rule { [...] }

For this factory, I've defined two helper constructs at the top of the file:

import { strings } from '@angular-devkit/core'; import { MenuOptions } from './schema'; import { filter, Rule, [...] } from '@angular-devkit/schematics';
import { parseName } from '@schematics/angular/utility/parse-name';
import { getWorkspace } from '@schematics/angular/utility/config';
[...] function filterTemplates(options: MenuOptions): Rule { if (!options.menuService) { return filter(path => !path.match(/\.service\.ts$/) && !path.match(/-item\.ts$/) && !path.match(/\.bak$/)); } return filter(path => !path.match(/\.bak$/)); } function setupOptions(options: MenuOptions, host: Tree): void { const workspace = getWorkspace(host); if (!options.project) { options.project = Object.keys(workspace.projects)[0]; } const project = workspace.projects[options.project]; if (options.path === undefined) { const projectDirName = project.projectType === 'application' ? 'app' : 'lib'; options.path = /<span class="hljs-subst">${project.root}</span>/src/<span class="hljs-subst">${projectDirName}</span>; } const parsedPath = parseName(options.path, options.name); options.name = parsedPath.name; options.path = parsedPath.path; } [...]

The imported object strings contains some functions we will need later within the templates, One of those functions is dasherize that transforms a name into its kebab case equivalent which can be used as a file name (e. g. SideMenu to side-menu) and classify transforms into Pascal case for class names (e. g. side-menu to SideMenu).

The function filterTemplates creates a Rule that filters the templates within the folder files. For this, it delegates to the existing filter rule. Depending on whether the user requested a menu service, more or less template files are used. To make testing and debugging easier, I'm excluding .bak in each case.

The function setupOptions makes sure we have all properties to generate our menu component. For this, it reads the CLI's configuration file with getWorkspace to get information about the configured projects. The the name of the project to use was not passed, it uses the first one. If no path was passed, it updates the path option with the defined project's root.

Now let's have a look at the factory function:

export default function (options: MenuOptions): Rule { return (host: Tree, context: SchematicContext) => { setupOptions(options, host); const templateSource = apply(url('./files'), [ filterTemplates(options), template({ ...strings, ...options }), move(options.path || '') ]); const rule = chain([ branchAndMerge(chain([ mergeWith(templateSource) ])) ]); return rule(host, context); } }

At the beginning, the factory deletes to setupOptions. Then, it uses apply to apply all templates within the files folder to the passed rules. After filtering the available templates they are executed with the rule returned by template. The passed properties are used within the templates. This creates a virtual folder structure with generated files that is moved to the current path.

The resulting templateSource is a Source instance. It's responsibility is creating a Tree object that represents a file tree which can be either virtual or physical. Schematics uses virtual file trees as a staging area. Only when everything worked, it is merged with the physical file tree on your disk. You can also think about this as committing a transaction.

At the end, the factory returns a rule created with the chain function (which is a rule too). It creates a new rule by chaining the passed ones. In this example we are just using the rule mergeWith but the enclosing chain makes it extendable.

As the name implies, mergeWith merges the Tree represented by templateSource with the tree which represents the current Angular project.

Templates

Now it's time to look at our templates within the files folder:

Folder with Templates

The nice thing about this is that the file names are templates too. For instance __x__ would be replaced with the contents of the variable x which is passed to the template rule. You can even call functions to transform these variables. In our case, we are using __name@dasherize__ which passes the variable name to the function dasherize which in turn is passed to template too.

The easiest one is the template for the item class which represents a menu item:

export interface <%= classify(name) %>Item { title: string; iconClass: string; }

Like in other known template languages (e. g. PHP), we can execute code for the generation within the delimiters <% and %>. Here, we are using the short form <%=value%> to write a value to the generated file. This value is just the name the caller passed transformed with classify to be used as a class name.

The template for the menu service is build in a similar way:

import { <%= classify(name) %>Item } from './<%=dasherize(name)%>-item'; export class <%= classify(name) %>Service { public items: <%= classify(name) %>Item[] = [ { title: 'Home', iconClass: 'ti-home' }, { title: 'Other Menu Item', iconClass: 'ti-arrow-top-right' }, { title: 'Further Menu Item', iconClass: 'ti-shopping-cart'}, { title: 'Yet another one', iconClass: 'ti-close'} ]; }

In addition to that, the component template contains some if statements that check whether a menu service should be used:

import { Component, OnInit } from '@angular/core'; <% if (menuService) { %> import { <%= classify(name) %>Item } from './<%=dasherize(name)%>-item'; import { <%= classify(name) %>Service } from './<%=dasherize(name)%>.service'; <% } %> @Component({ selector: '<%=dasherize(name)%>', templateUrl: '<%=dasherize(name)%>.component.html', <% if (menuService) { %> providers: [<%= classify(name) %>Service] <% } %> }) export class <%= classify(name) %>Component { <% if (menuService) { %> items: <%= classify(name) %>Item[]; constructor(service: <%= classify(name) %>Service) { this.items = service.items; } <% } %> }

The same is the case for the component's template. When the caller requested a menu service, it's using it; otherwise it just gets hardcoded sample items:

<div class="sidebar-wrapper"> <div class="logo"> <a class="simple-text"> AppTitle </a> </div> <ul class="nav"> <% if (menuService) { %> <li *ngFor="let item of items"> <a> <i class="{{item.iconClass}}"></i> <p>{{item.title}}</p> </a> </li> <% } else { %> <li> <a> <i class="ti-home"></i> <p>Home</p> </a> </li> <li> <a> <i class="ti-arrow-top-right"></i> <p>Other Menu Item</p> </a> </li> <li> <a> <i class="ti-shopping-cart"></i> <p>Further Menu Item</p> </a> </li> <li> <a> <i class="ti-close"></i> <p>Yet another one</p> </a> </li> <% } %> </ul> </div>

Building and Testing with a Sample Application

To build the npm package, we just need to call npm run build which is just triggering the TypeScript compiler.

For testing it, we need a sample application that can be created with the CLI. Please make sure to use Angular CLI version 1.5 RC.4 or higher.

For me, the easiest way to test the collection was to copy the whole package into the sample application's node_module folder so that everything ended up within 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.

Instead of this, pointing to a relative folder with the collection should work too. In my experiments, I did with a release candidate, this wasn't the case (at least not in any case).

After this, we can use the CLI to scaffold our side menu:

ng g nav:menu side-menu --menuService 

Here, menu is the name of the schematic, side-menu the file name we are passing and nav the name of the npm package.

After this, we need to register the generated component with the AppModule:

import { SideMenuComponent } from './side-menu/side-menu.component'; import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; import { AppComponent } from './app.component'; @NgModule({ declarations: [ AppComponent, SideMenuComponent ], imports: [ BrowserModule ], providers: [], bootstrap: [AppComponent] }) export class AppModule { }

In an other post, I will show how to even automate this task with Schematics.

After this, we can call the component in our AppModule. The following sample also contains some boiler blade for the Bootstrap Theme used in the initial screen shot.

<div class="wrapper"> <div class="sidebar" data-background-color="white" data-active-color="danger"> <side-menu></side-menu> </div> <div class="main-panel"> <div class="content"> <div class="card"> <div class="header"> <h1 class="title">Hello World</h1> </div> <div class="content"> <div style="padding:7px"> Lorem ipsum ... </div> </div> </div> </div> </div> </div>

To get Bootstrap and the Bootstrap Theme, you can download the free version of the paper theme and copy it to your assets folder. Also reference the necessary files within the file .angular-cli.json to make sure they are copied to the output folder:

[...]
"styles": [
  "styles.css",
  "assets/css/bootstrap.min.css",
  "assets/css/paper-dashboard.css",
  "assets/css/demo.css",
  "assets/css/themify-icons.css"
],
[...]

After this, we can finally run our application: ng serve.