Sie sind hier: Weblog

Generating Custom Code with the Angular CLI and Schematics

Foto ,
29.10.2017 02:00:00

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 Nrwls 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 to register generated components with existing modules.

In this post, Im 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, youll find a nice video with Mike Brocchi from the CLI-Team explaining the basics and ideas behind Schematics here.

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

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

  • @angular-devkit/schematics for executing Schematics
  • @schematics/schematics for scaffolding a Collection
  • ```rxjs`` which is a needed transitive dependency

For the sake of simplicity, Ive installed them globally:

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

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

schematics @schematics/schematics:schematic --name nav

The parameter @schematics/schematics:schematic consists of two parts. The first part -- @schematics/schematics -- is the name of the collection, or to be more precise, the npm package with the collection. The second part -- schematic -- is the name of the Schematic we want to use for generating code.

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 dont 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.0.15",
    "@angular-devkit/schematics": "^0.0.25",
    "@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, Ive deleted them to concentrate on my own schematic. For this, Im using the following structure:

Structure for custom Schematics

The new folder menu contains the custom schematic. Its 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 Ive 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, lets 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 schematics factory. Here, it points to ./menu which is just a folder. Thats 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": "name"
      },
      "path": {
        "type": "string",
        "default": "app"
      },
      "appRoot": {
        "type": "string"
      },
      "sourceDir": {
        "type": "string",
        "default": "src"
      },
      "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, its path as well as the path of the app (appRoot) and the src folder (sourceDir). These parameters are usually used by all schematics the CLI provides. In addition to that, Ive 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;
    appRoot: string;
    path: string;
    sourceDir: 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, Ive defined two helper constructs at the top of the file:

import { dasherize, classify } from @angular-devkit/core;
import { MenuOptions } from ./schema;
import { filter, Rule, [...] } from @angular-devkit/schematics;

[...]

const stringUtils = { dasherize, classify };

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$/));
}

[...]

The first one is the object stringUtils which just groups some functions we will need later within the templates: The function dasherize 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, Im excluding .bak in each case.

Now lets have a look at the factory function:

import { chain, mergeWith } from @angular-devkit/schematics;
import { dasherize, classify } from @angular-devkit/core;
import { MenuOptions } from ./schema;
import { apply, filter, move, Rule, template, url, branchAndMerge } from @angular-devkit/schematics;
import { normalize } from @angular-devkit/core;

[...]

export default function (options: MenuOptions): Rule {

    options.path = options.path ? normalize(options.path) : options.path;
    
    const templateSource = apply(url(./files), [
        filterTemplates(options),
        template({
          ...stringUtils,
          ...options
        }),
        move(options.sourceDir)
      ]);
      
      return chain([
          mergeWith(templateSource)
      ]);

}

At the beginning, the factory normalizes the path the caller passed in. This means that it deals with the conventions of different operating systems, e. g. using different path separators (e. g. / vs. \).

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 sourceDir.

The resulting templateSource is a Source instance. Its 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 its 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 components template. When the caller requested a menu service, its 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 an 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 applications node_module folder so that everything ended up within node_modules/nav. Please make sure to exclude the collections 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 wasnt the case (at least not in any case).

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

ng g menu side-menu --menuService --collection nav

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

Using the Schematic

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.