Creating an Angular Schematic and Turn It Into an Nx Plugin

Apr 08, 2020 reading time 17 minutes

Creating an Angular Schematic and Turn It Into an Nx Plugin

In this blog post I would like to explain how you can write a schematic and turn it into an nx plugin or turn an existing schematic into an nx plugin.

Knowledge Prerequisites

There are a lot of blog posts out there which deal with how you can get started writing a schematic in much more detail as we do not cover this here one more time in depth. Be sure to check out:

We will however cover the get started things to ramp you up.

What is a schematic?

In general a schematic can help you to create, move, delete, … files automatically inside your angular or nx workspace. It can help you to stay organized and automates tasks you would normally do manually. Schematics are pretty powerful and the possibilities are endless.

In the following we are going to

  1. Create our first schematic
  2. Separating into smaller actions
  3. Preparing the schematic
  4. Building specific Actions
  5. Adding ng add support
  6. Building your schematic
  7. Testing your schematic locally
  8. Turning the schematic in an nx plugin
  9. Building your schematic locally
  10. Testing the plugin locally
  11. Next Steps
  12. Releasing it to npm

Creating a schematic

After installing the @angular-devkit/schematics-cli we can work with the cli command globally and execute schematics in the console. If we want to create a schematic we can run

schematics blank --name=my-first

which will create a folder and a blank schematic for us.

You will find several files including an index.ts which shows the following code

export function myFirst(_options: any): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    return tree;
  };
}

The Tree is like the projection of a file system of a workspace, an AngularCLI or an nx workspace for example. So with providing methods like create(...)delete(...)exists(...)overwrite(...) etc. it is possible to modify the workspace the schematic is running on and move files around. Perfect fit.

But take a closer look. The method myFirst() is returning a Rule. What is getting returned is another anonymous function taking the Tree and a context as parameter and returning – in this case – the unmodified Tree again.

That works, because if we take a deeper look the Rule type is specified as this

Rule = (tree: Tree, context: SchematicContext) => Tree | Observable<Tree> | Rule | Promise<void> | Promise<Rule> | void;

Rule is a function which returns a TreeObservable<Tree> or the mentioned return types above.

So far so good. But before we start let us think of what we want to achieve with our schematic here.

Separating into smaller actions

I was doing a schematic lately (which we will get to later on) but basically every schematic is made of several steps.

Think about these steps first!!!

So make a list of which actions should be performed, because this has a direct influence of what we are going to write down in code!

Because beside returning a single Rule we can also chain them as the @angular-devkit/schematics package is providing methods to for example combine rules.

So what also works is

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

export function myFirst(_options: any): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    const arrayOfRules: Rule[] = [];
    // modifying the tree in several rules
    return chain(arrayOfRules);
  };
}

See where this is gonna lead us? With chain() we have the possibility to run several rules one after another. That is a perfect fit if we want to break our task into smaller actions!

Preparing the schematic

Let us create an actions folder and an index.ts inside of it.

├── src
│   ├── my-first
│   │   ├── actions             // <<< Add this
│   │   │   └── index.ts        // <<< Add this
│   │   ├── index_spec.ts
│   │   └── index.ts
│   └── collection.json
├── .gitignore
├── .npmignore
├── package-lock.json
├── package.json
├── README.md
└── tsconfig.json

Now let us extract the inner code into a method to the new created actions/index.ts file and call it from the existing outer my-first/index.ts file.

Code of my-first/index.ts

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

export function myFirst(_options: any): Rule {
  return (host: Tree, context: SchematicContext) => {
    return executeSchematic();
  };
}

Code of actions/index.ts

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

export function executeSchematic(host: Tree, context: SchematicContext): Rule {
  const arrayOfRules: Rule[] = [];
  // modifying the tree in several rules
  return chain(arrayOfRules);
}

The abstraction on this level makes it easier to migrate to a nx plugin later.

Now think about the actions you have to perform based on what your schematic should do. For me there are two types of actions. Those who are modifying the root workspace (files like the angular.json file for general properties or root files which are not inside a specific project) and actions which are transforming files which exist in a specific project or the angular.json file but for project specific properties.

This leads me to two folders root-actions and project-actions. Each of them gets an index.ts file in it again.

.
├── src
│   ├── my-first
│   │   ├── actions
│   │   │   ├── project-actions         // <<< Add this
│   │   │   │   └── index.ts            // <<< Add this
│   │   │   ├── root-actions            // <<< Add this
│   │   │   │   └── index.ts            // <<< Add this
│   │   │   └── index.ts
│   │   ├── index_spec.ts
│   │   └── index.ts
│   └── collection.json
├── .gitignore
├── .npmignore
├── package-lock.json
├── package.json
├── README.md
└── tsconfig.json

Building specific Actions

Now it is time to build specific actions which are gonna execute something you want to. Let us for example update the angular.json file on root as an action and as a project action we delete some files which are no longer needed inside a project folder.

Create the file update-angularjson.ts inside the root-actions folder and the file delete-project-files.ts inside the project-actions folder.

.
├── src
│   ├── my-first
│   │   ├── actions
│   │   │   ├── project-actions
│   │   │   │   ├── delete-project-files.ts         // <<< Add this
│   │   │   │   └── index.ts
│   │   │   ├── root-actions
│   │   │   │   ├── index.ts
│   │   │   │   └── update-angularjson.ts         // <<< Add this
│   │   │   └── index.ts
│   │   ├── index_spec.ts
│   │   └── index.ts
│   └── collection.json
├── .gitignore
├── .npmignore
├── package-lock.json
├── package.json
├── README.md
└── tsconfig.json

Inside the files we are exporting a function which exports a Rule again getting passed the parameters we get given from the schematic itself.

Code of delete-project-files.ts

export function deleteProjectFiles(/* Params _you_ want to pass in */): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    // modify the Tree with tree.delete(path);
    // or whatever you want to do in the action :-)

    return tree;
  };
}

and in the project-actions/index.ts file we give it a place where all the actions are gonna be collected and put in order:

Code of project-actions/index.ts

import { Rule } from '@angular-devkit/schematics';
import { deleteProjectFiles } from './delete-project-files';

export function getRulesForProjects() {
  let projectRules: Rule[] = [];
  // Maybe iterate over all projects, fetch all of them and execute the actions
  // per project
  projectRules.push(deleteProjectFiles());
  // maybe push other actions in sequence?

  return projectRules;
}

The project-actions/index.ts file is the place where you can iterate over all the projects in the workspace and execute them per project.

Let us do the same with the root-actions and the update-angularjson.ts. This time we are gonna receive the workspace which we modify to have an example of custom parameters and those which are getting passed fro the schematics.

Code of update-angularjson.ts

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

export function updateAngularJson(
  workspace: experimental.workspace.WorkspaceSchema /* CUSTOM PARAM*/
): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    // Modify workspace like you want
    // tree, _context and workspace are available here
    tree.overwrite(`angular.json`, JSON.stringify(workspace, null, '  '));
    return tree;
  };
}

The code of the corresponding index file is closely the same

Code of project-actions/index.ts

import { experimental } from '@angular-devkit/core';
import { Rule } from '@angular-devkit/schematics';
import { updateAngularJson } from './update-angularjson';

export function getRulesForWorkspaceRoot(
  workspace: experimental.workspace.WorkspaceSchema
) {
  let rulesToApply: Rule[] = [];

  rulesToApply.push(updateAngularJson(workspace));
  // maybe more actions

  return rulesToApply;
}

In the actions/index.ts file we are collecting the actions from project-actions and root-actions now and call the chain() method with the actions array as param.

import {
  chain,
  SchematicContext,
  Tree,
  SchematicsException,
} from '@angular-devkit/schematics';
import { getRulesForProjects } from './project-actions';
import { getRulesForWorkspaceRoot } from './root-actions';

export function executeSchematic(host: Tree, context: SchematicContext) {
  const workspace = getAngularWorkspace(host);

  const projectAndLibActions = getRulesForProjects();
  const workspaceActions = getRulesForWorkspaceRoot(workspace);
  const rulesToApply = [...projectAndLibActions, ...workspaceActions];

  return chain(rulesToApply);
}

export function getAngularWorkspace(tree: Tree) {
  const workspaceConfig = tree.read(`angular.json`);

  if (!workspaceConfig) {
    throw new SchematicsException(
      'Could not find Angular workspace configuration'
    );
  }

  const workspaceContent = workspaceConfig.toString();
  const workspace = JSON.parse(workspaceContent);

  return workspace;
}

and from the my-first/index.ts file we only have to pass the corresponding arguments to our new created method like this:

Code of my-first/index.ts

import { Rule, Tree, SchematicContext } from '@angular-devkit/schematics';
import { executeSchematic } from './actions';
export function myFirst(_options: any): Rule {
  return (host: Tree, context: SchematicContext) => {
    return executeSchematic(host, context);
  };
}

Adding ng add support

To provide the executing via ng add to your schematic enter the file collection.json and add the ng add as follows:

{
  "$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
  "schematics": {
    "my-first": {
      "description": "A blank schematic.",
      "factory": "./my-first/index#myFirst"
    },
    "ng-add": {
      "description": "A blank schematic.",
      "factory": "./my-first/index#myFirst"
    }
  }
}

Building your schematic

To build your schematic locally you can add the following scripts in the scripts section of your package.json.

  "scripts": {
    "build": "tsc -p tsconfig.json",
    "build:watch": "tsc -p tsconfig.json --watch",
  },

The npm run build command will build you schematic locally and create your *.js files.

Testing your schematic locally

If you want to test your schematic locally you can do this using the

npm link

command.

So first create a new workspace with the AngularCLI with

ng new myWorkspace --createApplication=false

and then

ng g app myApp

Pay attention that this is the workspace you are gonna test you schematics with. So bring it in the position that you can apply your changes.

Having done that: Commit! have a “clean” workspace. I am not saying to push it, have the workspace clean that you can see what the schematic did. Checking the file changes is very easy like this.

Now you can link your schematic into your workspace to run it locally. Therefore run the npm link command from the workspace you want to test on with the path to your package.json of the schematic without the package.json filename.

npm link path/to/my/schematics/packagejson

After linking it you can use the previously added ng add support.

ng add my-first

After it ran through you can see what is has done in your workspace by comparing the differences with git for example.

Make sure to always build your schematic and execute npm link <path> before executing it again with ng add when testing new changes

Turning the schematic in an nx plugin

At this point you should have a running schematic which does everything to your workspace as needed. Let us move this one into an nx plugin with the nx cli.

It would be helpful to get a feeling of nx plugins by reading this guide https://nx.dev/angular/guides/nx-plugin

So we start off a blank ground by and create a new nx plugin with

npx create-nx-plugin exampleorg --pluginName myFirstPlugin

this creates a workspace with a pre configured plugin called my-first-plugin. Inside of the libs/my-first-plugin/src folder you can see a schematics folder. This is where we gonna place our created schematic to turn it into an nx plugin.

We will not cover how we can use the builders here beside the schematics. However the builders are very powerful and you should really consider taking a look at them in the link given above.

In the libs\my-first-plugin\src\schematics\my-first-plugin\schematic.ts file you can see a default function at the bottom which gets executed if the plugin is gonna be called

export default function (options: MyFirstPluginSchematicSchema): Rule {
  return chain([
    /* actions */
  ]);
}

And the Nx Team provides incredibly useful methods like

 updateWorkspace(workspace => {
      workspace.projects
        .add({
          name: normalizedOptions.projectName,
          root: normalizedOptions.projectRoot,
          sourceRoot: `${normalizedOptions.projectRoot}/src`,
          projectType
        })
        .targets.add({
          name: 'build',
          builder: '@exampleorg/my-first-plugin:build'
        });
    }),

or

 addProjectToNxJsonInTree(normalizedOptions.projectName, {
      tags: normalizedOptions.parsedTags
    }),

Which we could use to achieve our goals with our schematic. However this time we gonna stick to what we have and provide our existing schematic as an nx plugin.

First we can take the complete actions folder of our schematic and paste it into the libs\my-first-plugin\src\schematics\my-first-plugin\ folder.

.
├── apps
│   ...
├── libs
│   ├── my-first-plugin
│   │   ├── src
│   │   │   ├── builders
│   │   │   │   └── ...
│   │   │   ├── schematics
│   │   │   │   └── my-first-plugin
│   │   │   │       ├── actions                     <<< paste this ...
│   │   │   │       │   ├── project-actions
│   │   │   │       │   │   ├── delete-project-files.ts
│   │   │   │       │   │   └── index.ts
│   │   │   │       │   ├── root-actions
│   │   │   │       │   │   ├── index.ts
│   │   │   │       │   │   └── update-angularjson.ts
│   │   │   │       │   └── index.ts                <<< ... w/ entry point
│   │   │   │       ├── files
│   │   │   │       │   └── ...
│   │   │   │       ├── schema.json
│   │   │   │       ├── schematic.spec.ts
│   │   │   │       └── schematic.ts
│   │   │   └── index.ts
│   │   ├── .eslintrc
│   │   ├── builders.json
│   │   ├── collection.json
│   │   ├── jest.config.js
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── tsconfig.json
│   │   ├── tsconfig.lib.json
│   │   └── tsconfig.spec.json
│   └── .gitkeep
├── tools
│   ├── ...
├── ...
...

As we have defined an entry point in our schematic in the index.ts file

Code of libs\my-first-plugin\src\schematics\my-first-plugin\actions\index.ts

import {
  chain,
  SchematicContext,
  Tree,
  SchematicsException,
} from '@angular-devkit/schematics';
import { getRulesForProjects } from './project-actions';
import { getRulesForWorkspaceRoot } from './root-actions';

export function executeSchematic(host: Tree, context: SchematicContext) {
  const workspace = getAngularWorkspace(host);

  const projectAndLibActions = getRulesForProjects();
  const workspaceActions = getRulesForWorkspaceRoot(workspace);
  const rulesToApply = [...projectAndLibActions, ...workspaceActions];

  return chain(rulesToApply);
}

export function getAngularWorkspace(tree: Tree) {
  const workspaceConfig = tree.read(`angular.json`);

  if (!workspaceConfig) {
    throw new SchematicsException(
      'Could not find Angular workspace configuration'
    );
  }

  const workspaceContent = workspaceConfig.toString();
  const workspace = JSON.parse(workspaceContent);

  return workspace;
}

We can call that one from the default function from our nx plugin.

Code of libs\my-first-plugin\src\schematics\my-first-plugin\schematic.ts

import { Tree, Rule, SchematicContext } from '@angular-devkit/schematics';
import { MyFirstPluginSchematicSchema } from './schema';
import { executeSchematic } from './actions';

export default function (options: MyFirstPluginSchematicSchema): Rule {
  return (host: Tree, context: SchematicContext) => {
    return executeSchematic(host, context);
  };
}

Now merge the collection.json, too by adding ng add support

{
  "$schema": "../../node_modules/@angular-devkit/schematics/collection-schema.json",
  "name": "my-first-plugin",
  "version": "0.0.1",
  "schematics": {
    "myFirstPlugin": {
      "factory": "./src/schematics/my-first-plugin/schematic",
      "schema": "./src/schematics/my-first-plugin/schema.json",
      "description": "my-first-plugin schematic"
    },
    "ng-add": {
      "description": "my-first-plugin schematic",
      "factory": "./src/schematics/my-first-plugin/schematic"
    }
  }
}

Building your schematic locally

You can build your nx plugin with the nx command nx build my-first-plugin

{
  //...
  "scripts": {
    //...
    "build:my:first:plugin": "nx build my-first-plugin"
    //...
  }
  //...
}

which will create a dist folder with your nx plugin.

Testing the plugin locally

In general testing stays the same with the difference that we will use a nx workspace this time to test our schematic and we will use the artifact from our dist folder. but the commands stay the same.

So create an nx workspace as described on https://nx.dev and link your schematic with the npm link command and use the path from your distribution from the nx plugin.

Now you can see if it works or not and maybe modify it.

Next Steps

At this point it would be nice to look at the amazing methods the nx team provides to us and maybe for your next schematic or nx plugin start with the scaffolded template immediately. The methods are really helpful. Consider taking a look!

Releasing it to npm

If you want to release the plugin to npm you can use the npm commands as usual. Running npm publish from the dist/<your-package> folder. I can recommend you https://www.npmjs.com/package/release-it to make the release process as smooth as possible. In my package.json I always have a release script like this

Code taken from https://github.com/FabianGosebrink/nx-protractor-to-cypress

{
  "scripts": {
    "build:nx:protractor:to:cypress": "nx build nx-protractor-to-cypress && npm run copy:files",
    "build:nx:protractor:to:cypress:watch": "nx build nx-protractor-to-cypress -- --watch",
    "release:dryrun": "npm run release -- --dry-run",
    "release": "npm run build:nx:protractor:to:cypress && cd dist/libs/nx-protractor-to-cypress && release-it"
  }

  //...
}

That is it! I hope this journey did help you a bit.

Thanks for reading

Fabian