Picture of Brian Love wearing black against a dark wall in Portland, OR.

Brian Love

NG Add Schematic

Learn how to build an Angular schematic that adds a library.

Getting Started Tutorial

If you’re brand new to Angular Schematics then I suggest you start with my Angular Schematics Tutorial.

AngularFire Schematics

Installing AngularFire is easy:

  1. Use npm or yarn to install both the firebase and @angular/fire modules.
  2. Add your Firebase project’s configuration to the Angular environment.ts file.
  3. Import the AngularFire module into the root module.

While this is an easy installation path, I thought it would be fun to tackle creating a schematic that does all of this for you, including prompting for your Firebase configuration.

ng add

Try it out:

$ ng new demo
$ cd demo
$ ng add angular-fire-schematics

Demo

ng add angular-fire-schematics

Download

Goals

My goal is that you will learn:

Our goal is to build a schematic that can be added using the Angular CLI.

Create Schematic Collection

If you haven’t installed the schematics CLI then do that first via npm or yarn:

$ npm install -g @angular-devkit/schematics-cli
$ yarn add -g @angular-devkit/schematics-cli

The first step is to create a new schematics collection using the blank schematic:

$ schematics blank --name=angular-fire-schematics

This will create a new angular-fire-schematics directory, add the necessary files and folders to build a schematic, and then install the necessary dependencies.

Let’s initialize a Git repository for our project:

$ cd angular-fire-schematics
$ git init

Go ahead and open the project in your favorite editor or IDE. I’ll be using VS Code:

$ code angular-fire-schematics

Create ng-add Directory

Let’s clean up our src directory real quick:

$ rm -rf src/angular-fire-schematics
$ mkdir src/ng-add
$ touch src/ng-add/schema.json
$ touch src/ng-add/index.ts

Add Schematic to Collection

Open the src/collection.json file and add the ng-add schematic to the collection:

{
  "$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
  "schematics": {
    "ng-add": {
      "description": "Adds Angular Firebase to the application without affecting any templates",
      "factory": "./ng-add/index",
      "schema": "./ng-add/schema.json",
      "aliases": ["install"]
    }
  }
}

I’ve specified the following properties:

Note, if you do not export a default function you can use the following syntax where we specify the function to invoke after a pound ( # ) symbol: "factory": "./ng-add/index#functionName.

Define Schematic Schema

Next, define the schema for the ng-add schematic. The schema will include the new x-prompt property that was introduced in v7. This will prompt the user to enter their Firebase configuration.

Create a new file src/ng-add/schema.json:

{
  "$schema": "http://json-schema.org/schema",
  "id": "angular-firebase-schematic-ng-add",
  "title": "Angular Firebase ng-add schematic",
  "type": "object",
  "properties": {
    "project": {
      "type": "string",
      "description": "The name of the project.",
      "$default": {
        "$source": "projectName"
      }
    },
    "apiKey": {
      "type": "string",
      "default": "",
      "description": "Your Firebase API key",
      "x-prompt": "What is your project's apiKey?"
    },
    "authDomain": {
      "type": "string",
      "default": "",
      "description": "Your Firebase domain",
      "x-prompt": "What is your project's authDomain?"
    },
    "databaseURL": {
      "type": "string",
      "default": "",
      "description": "Your Firebase database URL",
      "x-prompt": "What is your project's databaseURL?"
    },
    "projectId": {
      "type": "string",
      "default": "",
      "description": "Your Firebase project ID",
      "x-prompt": "What is your project's id?"
    },
    "storageBucket": {
      "type": "string",
      "default": "",
      "description": "Your Firebase storage bucket",
      "x-prompt": "What is your project's storageBucket?"
    },
    "messagingSenderId": {
      "type": "string",
      "default": "",
      "description": "Your Firebase message sender ID",
      "x-prompt": "What is your project's messagingSenderId?"
    }
  },
  "required": [],
  "additionalProperties": false
}

For each option we specify the:

Define Schema

I like to strongly type the options that the user can specify. This provides me with type safety when working with the options argument to the entry function of my schematic.

Create a new src/app/schema.ts file and export the Schema interface:

export interface Schema {
  /** Firebase API key. */
  apiKey: string;

  /** Firebase authorized domain. */
  authDomain: string;

  /** Firebase db URL. */
  databaseURL: string;

  /** Name of the project to target. */
  project: string;

  /** Firebase project ID. */
  projectId: string;

  /** Firebase storage bucket. */
  storageBucket: string;

  /** Firebase messaging sender ID. */
  messagingSenderId: string;
}

Create Default Function

The last step to scaffolding the project is to create what I commonly refer to as the “entry” function. This is the function that is invoked when a schematic is executed. If you recall, we specified the path to the src/index.js file in the collection.json file.

Add the following default function to the src/ng-add/index.ts file:



export default function(options: Schema): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    return tree;
  };
}

Build and Run

While our schematic is just an empty shell at the moment, let’s build and run the schematic to verify that what we have created so far works:

$ yarn build
$ schematics .:ng-add

Sandbox Testing

While a schematic should include a unit testing suite to provide the necessary coverage in order to validate the quality of the schematic, I like to use a sandbox testing approach to visually diff the changes made to my Angular application as a result of executing a schematic.

I learned this technique from a colleague, and very smart engineer, Kevin Schuchard. Check out his post on using a sandbox to develop an Angular Schematic.

First, create a new Angular application using the ng new command. I’ll provide the name “sandbox” for the application:

$ ng new sandbox

To avoid having an embedded git repository for our sandbox application remove the .git directory within the sandbox directory. Then, let’s create a new commit with all of the changes to our project.

$ rm -rf sandbox/.git
$ git add -A
$ git commit -m "Initial commit"

Then, let’s define some scripts to link, clean and test our schematic within the context of the Angular sandbox application using the Angular CLI:

{
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "clean": "git checkout HEAD -- sandbox && git clean -f -d sandbox",
    "link:schematic": "yarn link && cd sandbox && yarn link \"angular-fire-schematics\"",
    "sandbox:ng-add": "cd sandbox && ng g angular-fire-schematics:ng-add",
    "test": "yarn clean && yarn sandbox:ng-add && yarn test:sandbox",
    "test:unit": "yarn build && jasmine src/**/*_spec.js",
    "test:sandbox": "cd sandbox && yarn lint && yarn test && yarn build"
  }
}

Finally, link the angular-fire-schematics package into the sandbox by executing:

$ yarn link:schematic

We’re now ready to build and execute our schematic using the Angular CLI in our sandbox application.

Utilities

We’ll be using many utility functions provided by the @angular/schematics and @angular/cdk modules. Most of the functions are located within a utility directory. These utilities will enable us accomplish:

Let’s install both of the modules using either npm or yarn:

$ yarn add @angular/schematics @angular/cdk
$ npm install @angular/schematics @angular/cdk

I’ve also brought in several utility functions for this project. These utility functions are collected from a variety of sources, including the Angular CLI schematics, the CDK schematics, the Angular Material schematics, and more.

Copy the following files from the angular-fire-schematics src/util directory: npmjs.ts, project-configurations.ts, project-environment-file.ts and version-agnostic-typescript.ts.

Chain Rules

Our first task for building an ng-add schematic that installs and adds AngularFire to an Angular project is to add the necessary dependencies to the package.json file.

Update the default function in the src/index.ts file to use the chain() function to chain together multiple rules:

export default function(options: Schema): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    return chain([
      addPackageJsonDependencies(),
      installDependencies(),
      setupProject(options)
    ])(tree, _context);
  };
}

Then, declare the addPackageJsonDependencies(), installDependencies() and setupProject() functions:

function addPackageJsonDependencies(): Rule {}

function installDependencies(): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    return tree;
  }
}

function setupProject(options: Schema): Rule {
  console.log(options);
  return (tree: Tree, _context: SchematicContext) => {
    return tree;
  }
}

Add Dependencies

Next, we’ll implement the addPackageJsonDependencies() function in the src/index.ts file:

import { addPackageJsonDependency, NodeDependency, NodeDependencyType } from '@schematics/angular/utility/dependencies';
import { getLatestNodeVersion, NpmRegistryPackage } from '../util/npmjs';

function addPackageJsonDependencies(): Rule {
  return (tree: Tree, _context: SchematicContext): Observable<Tree> => {
    return of('firebase', '@angular/fire').pipe(
      concatMap(name => getLatestNodeVersion(name)),
      map((npmRegistryPackage: NpmRegistryPackage) => {
        const nodeDependency: NodeDependency = {
          type: NodeDependencyType.Default,
          name: npmRegistryPackage.name,
          version: npmRegistryPackage.version,
          overwrite: false
        };
        addPackageJsonDependency(tree, nodeDependency);
        _context.logger.info('✅️ Added dependency');
        return tree;
      })
    );
  };
}

Let’s review:

Then, run the build and test scripts:

$ yarn build
$ yarn test

Take a look at the diff for the package.json file:

$ git diff sandbox/package.json

Note that both the @angular/fire and firebase dependencies have been added to the sandbox application’s package.json file.

ng-add schematic add package dependencies

Install Dependencies

With our application’s package.json file updated the next step is to install the new dependencies.

Implement the installDependencies() as follows:

import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks';

function installDependencies(): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    _context.addTask(new NodePackageInstallTask());
    _context.logger.debug('✅️ Dependencies installed');
    return tree;
  };
}

Thankfully this is very easy to accomplish using the NodePackageInstallTask class that is exported from the @angular-devkit/schematics/tasks module.

Let’s build and run the schematic again and we should observe that our new dependencies are installed after updating the package.json file:

$ yarn build
$ yarn test

ng-add schematic add package dependencies

Setup Schematic

The ng-add schematic adds the necessary dependencies and installs them. The next step is to create a schematic that performs the necessary setup and configuration for AngularFire.

Open the src/collection.json file and define a new schematic entitled ng-add-setup-project. The complete file should now look like:

{
  "$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
  "schematics": {
    "ng-add": {
      "description": "Adds Angular Firebase to the application without affecting any templates",
      "factory": "./ng-add/index",
      "schema": "./ng-add/schema.json",
      "aliases": ["install"]
    },
    "ng-add-setup-project": {
      "description": "Sets up the specified project after the ng-add dependencies have been installed.",
      "private": true,
      "factory": "./ng-add/setup-project",
      "schema": "./ng-add/schema.json"
    }
  }
}

Next, create the src/ng-add/setup-project.ts file:

$ touch src/ng-add/setup-project.ts

To get started, we’ll stub out the default function:

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

export default function(options: Schema): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    return chain([
      addEnvironmentConfig(options),
      importEnvironemntIntoRootModule(options),
      addAngularFireModule(options)
    ])(tree, _context);
  };
}

function addEnvironmentConfig(options: Schema): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    return tree;
  };
}

function addAngularFireModule(options: Schema): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    return tree;
  };
}

function importEnvironemntIntoRootModule(options: Schema): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    return tree;
  };
}

The plan is to:

  1. Add the configuration information to the environment.ts file.
  2. Import the environment.ts file into the app.module.ts file.
  3. Add the AngularFireModule.initializeApp() method to the imports array in the @NgModule() decorator for the AppModule class.

This seems pretty straight forward.

RunSchematicTask

Before we continue working on the setup we need to run the newly created ng-add-setup-project schematic from the ng-add schematic. In order to do this we’ll use the RunSchematicTask class.

Let’s finish implementing the setupProject() function in the src/ng-add/index.ts file:

function setupProject(options: Schema): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    const installTaskId = _context.addTask(new NodePackageInstallTask());
    _context.addTask(new RunSchematicTask('ng-add-setup-project', options), [
      installTaskId
    ]);
    return tree;
  };
}

To review:

Update environment.ts

Open the src/setup-project.ts file and implement the addEnvironmentConfig() function:

function addEnvironmentConfig(options: Schema): Rule {
  return (tree: Tree, context: SchematicContext) => {
    const workspace = getWorkspace(tree);
    const project = getProjectFromWorkspace(workspace, options.project);
    const envPath = getProjectEnvironmentFile(project);

    // verify environment.ts file exists
    if (!envPath) {
      return context.logger.warn(
        `❌ Could not find environment file: "${envPath}". Skipping firebase configuration.`
      );
    }

    // firebase config to add to environment.ts file
    const insertion =
      ',\n' +
      `  firebase: {\n` +
      `    apiKey: '${options.apiKey}',\n` +
      `    authDomain: '${options.authDomain}',\n` +
      `    databaseURL: '${options.databaseURL}',\n` +
      `    projectId: '${options.projectId}',\n` +
      `    storageBucket: '${options.storageBucket}',\n` +
      `    messagingSenderId: '${options.messagingSenderId}',\n` +
      `  }`;
    const sourceFile = readIntoSourceFile(tree, envPath);

    // verify firebase config does not already exist
    const sourceFileText = sourceFile.getText();
    if (sourceFileText.includes(insertion)) {
      return;
    }

    // get the array of top-level Node objects in the AST from the SourceFile
    const nodes = getSourceNodes(sourceFile as any);
    const start = nodes.find(
      node => node.kind === ts.SyntaxKind.OpenBraceToken
    )!;
    const end = nodes.find(
      node => node.kind === ts.SyntaxKind.CloseBraceToken,
      start.end
    )!;

    const recorder = tree.beginUpdate(envPath);
    recorder.insertLeft(end.pos, insertion);
    tree.commitUpdate(recorder);

    context.logger.info('✅️ Environment configuration');
    return tree;
  };
}

This is pretty long!

Let’s dive in:

What is an AST?

Before we go any further, let’s quickly review what an abstract syntax tree (AST) is.

According to Wikipedia:

In computer science, an abstract syntax tree (AST), or just syntax tree, is a tree representation of the abstract syntactic structure of source code written in a programming language. Each node of the tree denotes a construct occurring in the source code.

As you might guess TypeScript provides a declaration file located at lib/typescript.d.ts that includes all of the type definitions for a TypeScript abstract syntax tree.

Why is this important?

Using an AST we can modify TypeScript source files using a programmatic interface that is reliable.

Accessing the SourceFile

A SourceFile is the top-level Node for a TypeScript AST. This is the starting point for working with the AST. We’ll use the createSourceFile() method provided by TypeScript to access the SourceFile:

function readIntoSourceFile(host: Tree, fileName: string): SourceFile {
  const buffer = host.read(fileName);
  if (buffer === null) {
    throw new SchematicsException(`File ${fileName} does not exist.`);
  }

  return ts.createSourceFile(
    fileName,
    buffer.toString('utf-8'),
    ts.ScriptTarget.Latest,
    true
  );
}

First, we use the read() method on the Tree to get the file Buffer. Then, we invoke the createSourceFile() method, providing the file path and the contents of the TypeScript file.

Import environment

Back to the task of creating an ng-add schematic for AngularFire, the next step for our schematic is to implement the importEnvironemntIntoRootModule() function. This function is responsible for importing the environment constant object into the root AppModule using the standard es6 import syntax.

As we’ve done before, we’ll use a few utility functions that are provided by the @schematics/angular module.

Open the src/setup-project.ts and implement the importEnvironemntIntoRootModule() function:

function importEnvironemntIntoRootModule(options: Schema): Rule {
  return (tree: Tree, context: SchematicContext) => {
    const IMPORT_IDENTIFIER = 'environment';
    const workspace = getWorkspace(tree);
    const project = getProjectFromWorkspace(workspace, options.project);
    const appModulePath = getAppModulePath(tree, getProjectMainFile(project));
    const envPath = getProjectEnvironmentFile(project);
    const sourceFile = readIntoSourceFile(tree, appModulePath);

    if (isImported(sourceFile as any, IMPORT_IDENTIFIER, envPath)) {
      context.logger.info(
        '✅️ The environment is already imported in the root module'
      );
      return tree;
    }

    const change = insertImport(
      sourceFile as any,
      appModulePath,
      IMPORT_IDENTIFIER,
      envPath.replace(/\.ts$/, '')
    ) as InsertChange;

    const recorder = tree.beginUpdate(appModulePath);
    recorder.insertLeft(change.pos, change.toAdd);
    tree.commitUpdate(recorder);

    context.logger.info('✅️ Import environment into root module');
    return tree;
  };
}

Let’s dive in:

Import Module

The final step is to update the @NgModule() decorator for the AppModule to import the Firebase module.

Open src/setup-project.ts and implement the addAngularFireModule() function:

function addAngularFireModule(options: Schema): Rule {
  return (tree: Tree, context: SchematicContext) => {
    const MODULE_NAME = 'AngularFireModule.initializeApp(environment.firebase)';
    const workspace = getWorkspace(tree);
    const project = getProjectFromWorkspace(workspace, options.project);
    const appModulePath = getAppModulePath(tree, getProjectMainFile(project));

    // verify module has not already been imported
    if (hasNgModuleImport(tree, appModulePath, MODULE_NAME)) {
      return console.warn(
        red(
          `Could not import "${bold(MODULE_NAME)}" because "${bold(
            MODULE_NAME
          )}" is already imported.`
        )
      );
    }

    // add NgModule to root NgModule imports
    addModuleImportToRootModule(tree, MODULE_NAME, '@angular/fire', project);

    context.logger.info('✅️ Import AngularFireModule into root module');
    return tree;
  };
}

Let’s review:

Conclusion

I hope that this post is helpful for those with an interest in learning to build schematics either for their own projects or organization, or for contributing to the Angular community.

I’ve presented on building Angular schematics at several meetups and one of the common questions I get asked is:

“How did you know where to find those utility functions?”

By studying the Angular CLI, Angular Material and Angular CDK schematics I learned about the many utility functions that are used by these teams to build schematics. I suggest you check out the source code to these schematics to help you learn how to build schematics and to discover the many utility functions that they provide and use.