Angular Schematics from 0 to publishing your own library (III)

In the previous posts we read an overview of what schematics are and what they're used for, the important vocabulary to follow the next posts, as well as a quick reference to the most important and useful methods from the Schematics API.

Angular Schematics from 0 to publishing your own library (III)

In the previous posts we read an overview of what schematics are and what they're used for, the important vocabulary to follow the next posts, as well as a quick reference to the most important and useful methods from the Schematics API.

Now we will actually write schematics!

Make sure you have the following packages installed at a global level in your computer. Notice that in a real life development context, you may have some of those local to your project. But for the sake of having a stable development environment, we will globally install them.

node v12.8.0
npm v.6.10.2
@angular-cli (core y cli) v.10
@schematics/angular
@schematics/schematics@0.1000.5
requirements to follow this guide

Now we're ready to...

Generate our first blank schematic

Generating a blank schematic is super straight forward with the schematics-cli.

Go to the folder where you want your schematics to be at and type in your terminal:

$ schematics blank --name=indepth-dev-schematic
command to generate a blank schematic

As you can already appreciate, we're simply invoking the schematics function to generate a blank schematic, and we're passing the name of the collection, as option.

If we inspect the generated folder, we can verify it's an npm package, featuring a package.json with necessary dependencies, and the node_modules folder.

We will also find a tsconfig.json file, and a scr folder.

Let's focus in the contents of the src folder

+ src
--- collection.json
--- + indepth-dev-schematic
------ index.ts
------ index_spec.ts
contents of the folder generated

collection.json

This file features our schematic as the first schematic in the collection indepth-dev-schematic  , of name also indepth-dev-schematic

{
  "$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
  "schematics": {
    "indepth-dev-schematic": {
      "description": "A blank schematic.",
      "factory": "./indepth-dev-schematic/index#indepthDevSchematic"
    }
  }
}
collection.json example

This file is the one read by the schematic-cli and the angular-schematic tool, at the time of running schematics.

Any successive schematics in the same package, need to be added to the collection.

index.ts

This file is the entry point for the schematic. When a blank schematic is generated, the Rule Factory looks like this

import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics';


// You don't have to export the function as default. You can also have more than one rule factory
// per file.
export function indepthDevSchematic(_options: any): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    return tree;
  };
}
default rule factory, entry point for blank schematic

As you can see, the function is named as a camelized form of the schematic name. This function takes options as arguments, and returns a Rule. The Rule is a function that takes the tree and the context (please go to the previous post to learn more!) and returns another tree.

Important things to remember about the entry file index.ts:

  • it can feature a rule factory or several
  • you don't need to export the function as default

In theory, we could already run this schematic with the schematics-cli, but it will obviously output nothing but a console message `Nothing to be done.`, so let's make it more interesting and use the create method, to create a readme file.

import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics';


// You don't have to export the function as default. You can also have more than one rule factory
// per file.
export function indepthDevSchematic(_options: any): Rule {
  return (tree: Tree, _context: SchematicContext) => {

    tree.create('readme.md', '#This is the read me file');
    return tree;
  };
}
(method) Tree.create(path: string, content: string | Buffer): void

Execute a custom schematic with the schematic-cli

Now let's head back to the terminal and prompt the schematic execution. You must be inside the schematic folder, at root level (or where the package.json is at).

Before you can execute it, you need to build your package it to transpile the typescript to javascript and compile it. Now you can run

$ schematics .:indepth-dev-schematic
run schematic with schematics-cli

Because you're at root level, you don't need to pass the name of the collection, so it's . followed by a colon : and the name of the schematic, in this case `indepth-dev-schematic` . In the future, we will add an alias to the schematic, in order to invoke it with a shorter, or more user friendly name.

Just hit enter and see the magic happen!

The schematic did not generate anything

Do not despair. This is the expected behavior, since schematics run in debug mode, by default, so if you want to make sure that the schematics modify your file system, you need to run them with the --dry-run=false flag.

$ schematics .:indepth-dev-schematic --dry-run=false
run schematic with schematics-cli, in dry-run=false mode

Now you should see the readme.md file in your file system. Congratulations!

Passing options as arguments from the CLI

Right now we've just hardcoded the values for the file path or name, and the content string. Let's now pass it from the CLI, to achieve a more dynamic output.

In order to do that, let's update the RuleFactory like this:

import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics';
import { join } from 'path';
import { capitalize } from '@angular-devkit/core/src/utils/strings';

// You don't have to export the function as default. You can also have more than one rule factory
// per file.
export function indepthDevSchematic(_options: any): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    const name: string  = _options.name;
    const content: string = _options.content;
    const extension: string = _options.extension || '.md';

    tree.create(join(name, extension), capitalize(content));
    return tree;
  };
}
custom schematic that generates a markdown file

Now we can generate the schematic, like this

$ schematics .:indepth-dev-schematic --name=file --content=hello
custom schematic passing options from CLI argument vector

Let's create a model now, so we get rid of `any`

When you generate a blank schematic, options are declared as type any. That's because the generator has no idea what will be needed. We need to fix that by creating a schema model.

Create a file called schema.ts at the same level of your index.ts , and modify it like this.

export interface Schema {
  name: string;
  content: string;
  extension?: string;
}
schema model to serve as type

Now you can add the schema type to the options, like this.

import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics';
import { join } from 'path';
import { capitalize } from '@angular-devkit/core/src/utils/strings';
import { Schema } from './schema';

// You don't have to export the function as default. You can also have more than one rule factory
// per file.
export function indepthDevSchematic(_options: Schema): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    const name: string  = _options.name;
    const content: string = _options.content;
    const extension: string = _options.extension || '.md';

    tree.create(join(name, extension), capitalize(content));
    return tree;
  };
}
extended custom schematic with custom type

Adding a validation schema to our schematic

If you've read the previous posts, you know you can add a validation schema to your schematic, by creating a schema.json file at the same level as your entry file. This will serve us to define defaults for our options, flag them as required, make sure we're passing the right types, and even issuing prompts.

Add the following content to the schema.json

{
  "$schema": "http://json-schema.org/schema",
  "id": "indepth-dev-schematics",
  "title": "A schematic to learn schematics",
  "type": "object",
  "properties": {
    "name": {
      "description": "File name, also equivalent to its path",
      "type": "string",
      "$default": {
        "$source": "argv",
        "index": 0
      }
    },
    "content": {
      "description": "Some content for that file",
      "type": "string",
      "$default": {
        "$source": "argv",
        "index": 1
      }
    },
    "extension": {
      "description": "An extension for that file. Defaults to markdown",
      "type": "string",
      "default": ".md"
    }
  },
  "required": [
    "name", "content"
  ]
}
validation schema for custom schematic

This schema declares three options as properties of schema option, with id indepth-dev-schematic. name and content are argument vectors, in position 0 and 1, as defaults. They're also required. The third value is the extension and it's not mandatory as user input. It also has a default value.

The schema.json will only apply when referenced from the collection. So head to the collection and update it like this

{
  "$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
  "schematics": {
    "indepth-dev-schematic": {
      "description": "A blank schematic.",
      "factory": "./indepth-dev-schematic/index#indepthDevSchematic",
      "schema": "./indepth-dev-schematic/schema.json"
    }
  }
}
referencing the schema.json in your collection

Input prompts for custom schematics

Another important use of the schema, is to create prompts to interact with the user via the CLI. These prompts guarantee a better user experience so developers don't have to read tons of documentation to understand what input the schematic needs, in order to run.

Prompts are of three types, textual input, either string or number, decision, or a yes or no ( boolean maps to true or false) and list featuring an enum with subtypes.

Let's modify the schema.json to add prompts for the required options.

{
  "$schema": "http://json-schema.org/schema",
  "id": "indepth-dev-schematics",
  "title": "A schematic to learn schematics",
  "type": "object",
  "properties": {
    "name": {
      "description": "File name, also equivalent to its path",
      "type": "string",
      "x-prompt": "What's the file name? (matches path)"
    },
    "content": {
      "description": "Some content for that file",
      "type": "string",
      "x-prompt": "Enter some content for your file"
    },
    "extension": {
      "description": "An extension for that file. Defaults to markdown",
      "type": "string",
      "default": ".md"
    }
  },
  "required": [
    "name", "content"
  ]
}
adding input prompts to the schema

Aliases for custom schematics

Before we build and run the schematic again, we could optimize it a bit more by defining a shorter alias, since generating with .:indepth-dev-schematic is a bit long and error prone.

To give it an alias, let's go to the collection.json again and update it like this.

{
  "$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
  "schematics": {
    "indepth-dev-schematic": {
      "description": "A blank schematic.",
      "factory": "./indepth-dev-schematic/index#indepthDevSchematic",
      "schema": "./indepth-dev-schematic/schema.json",
      "aliases": ["dive"]
    }
  }
}
adding aliases to the custom schematic

Please notice that aliases takes an array of strings, so you can define multiple aliases for your schematic.

Now you can execute it from the CLI with

$ schematics .:dive

It should prompt you to pass a name and content as options. It will understand the default for extension is .md

Generating the schematic from an Angular app

Until now, we are running the schematic from the schematics-cli. But that's no fun. We want to run it in an Angular app!

Let's start by linking the package to our current node version, executing

 $ npm link

at the root of our package.

Then generate a new angular app, with the CLI and when done, run

$ npm link indepth-dev-schematic

in the app root folder. This creates a symlink to the schematic package, so you can execute it.

Before we run it, let's update the entry file a bit

import { Rule, SchematicContext, Tree, SchematicsException } from '@angular-devkit/schematics';
import { join } from 'path';
import { capitalize } from '@angular-devkit/core/src/utils/strings';
import { Schema } from './schema';

// You don't have to export the function as default. You can also have more than one rule factory
// per file.
export function indepthDevSchematic(_options: Schema): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    const name: string  = _options.name;
    const content: string = _options.content;
    const extension: string = _options.extension || '.md';
    const path = join(name, extension);
    const angularConfig = 'angular.json';
	
    // Let's make sure we're in an angular workspace
    if (!tree.exists(angularConfig)) {
      throw new SchematicsException('???This is not an Angular worksapce! Try again in an Angular project.');
    } else {
      if (!tree.exists(path)) {
        tree.create(path, capitalize(content));
      } else {
        throw new SchematicsException('???That file already exists! Try a new name');
      }
    }
    return tree;
  };
}
implementing SchematicException in a custom schematic

With this changes, we make sure we are executing the schematic in an Angular workspace and that the file does not already exist.

Now, after re-building, we can finally go to the app and run

$ ng generate indepth-dev-schematic:dive
running the schematic from an angular app

Conclusion (for now!)

Of course, this is a very basic example, and we want to do more exciting things. To do that, we need to set some goals. So in the next post, we will propose a real life problem to solve, and actually solve it with schematics.

See you then.