Bootstrapping our first schematic

Having covered the conceptual groundwork in earlier installments, it's time to get our hands dirty and build something tangible. Before diving in, confirm these packages are installed globally. While local installation is an option in many team settings, we'll opt for global installation to keep our environment consistent throughout this guide.

node v12.8.0
npm v.6.10.2
@angular-cli (core y cli) v.10
@schematics/angular
@schematics/schematics@0.1000.5

prerequisites for following along

With that squared away, let's begin.

Scaffolding an empty schematic

The schematics-cli makes initializing an empty schematic remarkably painless.

Navigate to your preferred working directory and execute the following command:

$ schematics blank --name=indepth-dev-schematic

terminal command for generating an empty schematic

We're essentially calling the schematics tool to scaffold a blank project, supplying the collection name as our sole option.

Looking at the generated directory structure, it's clear this is an npm package. You'll spot a package.json carrying the required dependencies, along with the node_modules directory.

Additionally, there's a tsconfig.json file and a scr folder waiting for our code.

Let's take a closer look at the src folder's contents.

+ src
--- collection.json
--- + indepth-dev-schematic
------ index.ts
------ index_spec.ts

structure of the generated folder

collection.json

This file declares our schematic as the primary entry within the indepth-dev-schematic collection, bearing the same name.

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

sample collection.json

Both the schematics-cli and the Angular CLI refer to this specific file whenever they execute a schematic.

Any additional schematics introduced into this package must be registered within this collection file.

index.ts

Serving as the schematic's entry point, this file contains the Rule Factory when initially generated.

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

The function adopts a camelized version of the schematic's name as its own. It accepts an options object and returns a Rule. Remember, a Rule is itself a function which takes a tree and a context, ultimately returning a tree.

Key points regarding the entry file:

  • It can house a single or multiple rule factories
  • A default export is not required

In principle, we could run this as-is, but it would only print `Nothing to be done.` to the console. Let's make it functional by leveraging the create method to generate 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

Invoking your custom schematic via the CLI

Let's return to the terminal and trigger the schematic. Ensure you're in the root directory of your schematic package, where package.json resides.

A crucial step before execution: build your package to transpile the TypeScript into JavaScript. Following a successful compilation, run the command below.

$ schematics .:indepth-dev-schematic

executing a schematic with schematics-cli

Being in the package root, we can skip the collection name and use . followed by a colon : and the schematic's name, which here is `indepth-dev-schematic`. Later on, we'll introduce an alias to make this invocation more concise.

Press enter and observe what happens next.

Dealing with no output from the schematic

Don't worry if nothing seems to change. Schematics operate in a debug mode by default. To apply changes to your filesystem, you'll need to specify the --dry-run=false option.

$ schematics .:indepth-dev-schematic --dry-run=false

running a schematic without dry-run mode

You should now spot the readme.md file on your disk. Nice work!

Supplying options from the command line

Until now, we've hardcoded values for the path and content. To make things more flexible, let's pass these as CLI arguments.

Update the RuleFactory as follows:

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 generating a markdown file

You can now execute the schematic like this:

$ schematics .:indepth-dev-schematic --name=file --content=hello

custom schematic with options passed from the CLI

Introducing a proper model, moving beyond `any`

Creating an empty schematic initially types options as any since the generator can't predict what's needed. To rectify this, we'll craft a schema model.

Create a schema.ts file alongside your index.ts and fill it with the following:

export interface Schema {
  name: string;
  content: string;
  extension?: string;
}

schema model for typed options

Now, apply this new type to your options parameter:

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

custom schematic with explicit type

Implementing a validation schema

As discussed earlier, you can enhance your schematic with a schema.json file, placed next to the entry point. This schema is key for defining default values, marking options as required, enforcing data types, and even setting up interactive prompts.

Populate the schema.json file with this content:

{
  "$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 a custom schematic

This schema defines three properties within the indepth-dev-schematic id. The name and content fields are positional arguments (indexes 0 and 1) and are mandatory. The third property, extension, holds a default value and isn't strictly required from the user.

This schema takes effect only when the collection explicitly references it. So, update your collection.json file accordingly.

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

linking schema.json within the collection

Using prompts for user input

Another powerful feature is defining prompts, which create interactive dialogs in the CLI. This greatly enhances usability, sparing developers from sifting through extensive docs to provide the correct input.

Prompts come in three flavors: textual input (string or number), decision (a boolean translating to true/false), and list backed by an enum.

Let's adjust the schema.json to introduce prompts for the mandatory fields.

{
  "$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 prompts to the schema

Creating aliases for convenience

Prior to rebuilding and running, defining a shorter alias is a nice optimization. Typing .:indepth-dev-schematic repeatedly is both tedious and prone to errors.

To add an alias, revisit the collection.json and modify it as shown:

{
  "$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"]
    }
  }
}

defining aliases within the collection

Keep in mind that thealiases property accepts an array, so you can set multiple aliases if desired.

You can now execute the schematic via the CLI with the following:

$ schematics .:dive

It will present prompts for the name and content. The default extension, .md, will be applied automatically.

Executing the schematic from an Angular project

Running this from schematics-cli is fine, but the real goal is to use it inside an Angular application.

First, create a global link for our package using:

 $ npm link

run at the package root.

Next, scaffold a new Angular project via the CLI. Once created, execute the command below from the project root to link the schematic package:

$ npm link indepth-dev-schematic

command run in app root to symlink the package

Before running, let's enhance the entry file with some checks.

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

using SchematicException for error handling

These additions ensure two things: the schematic operates from an Angular workspace, and the target file doesn't already exist.

After rebuilding the schematic, execute it from the Angular app directory:

$ ng generate indepth-dev-schematic:dive

invoking the schematic from an angular app

Wrap-up (for now!)

Clearly, this is a basic demonstration. To tackle more compelling scenarios, we need a clear set of objectives. In the next article, we'll identify a practical, real-world problem and build a schematic to solve it.

Until next time.