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:
- Use npm or yarn to install both the firebase and @angular/fire modules.
- Add your Firebase project’s configuration to the Angular environment.ts file.
- 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
Download
Goals
My goal is that you will learn:
- How to create a schematic that add’s a library to an Angular project (application or library).
- The beginnings of working with the TypeScript abstract syntax tree (AST).
- How to commit file updates to a file in the
Tree
.
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:
- The
description
of the schematic. - The
factory
function that will be executed as the “entry” point of the schematic. In this case I am exporting a defult function from the src/ng-add/index.js file. - The
schema
for the schematic. - The
aliases
array that declares aliases for our schematic. This is why you can generate a component using the shorthand “c” when using the Angular CLI.
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:
- TypeScript
type
. - A
default
value. - The
description
. - The
x-prompt
value with the text to prompt the user when the option is not specified on the command line
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:
- updating the dependencies in a project’s package.json file,
- installing dependencies,
- determining the workspace configuration of an Angular project,
- updating Angular modules,
- and more.
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:
- First, the
addPackageJsonDependencies()
is a factory function that returns aRule
, which accepts theTree
andSchematicContext
objects. - We create a new
Observable
using theof()
function that emits two notifications that are strings: ‘firebase’ and ‘@angular/fire’. - I’m using a utility function
getLatestNodeVersion()
that returns anObservable
of theNpmRegistryPackage
for each of these dependencies from the http://registry.npmjs.org API. - We create a new
NodeDependency
object using the information provided by the API and then invoke theaddPackageJsonDependency()
function. These types and the function are imported from the @schematics/angular module and are located in the utility/dependencies directory. - The
addPackageJsonDependency()
function does the heavy lifting of adding the dependency to the package.json file. - Finally, we use the
SchematicContext
to log out an info statement indicating that the dependency was added successfully.
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.
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
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:
- Add the configuration information to the environment.ts file.
- Import the environment.ts file into the app.module.ts file.
- Add the
AngularFireModule.initializeApp()
method to theimports
array in the@NgModule()
decorator for theAppModule
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:
- Before we can execute the task, we need to wait until the previous task to install the node package dependencies has completed. To do this, we first need to get the
installTaskId
that our new task is dependent upon. - Next we invoke the
addTask()
method on theSchematicContext
class specifying the newRunSchematicTask
followed by the array of dependent task identifiers. - Note that when running another schematic we specify the schematic name along with the options for the schematic. We’ll pass along the options that were specified by the user for the ng-add schematic.
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:
- We’ll use a few utility functions provided by the @schematics/angular module, located within the utility directory.
- First we get the
WorkspaceSchema
using thegetWorkspace()
function. This contains the metadata for the Angular workspace (introduced in Angular version 6). - Next we get the
WorkspaceProject
using thegetProjectFromWorkspace()
function. This contains the metadata for the Angular project (either lib or app), including: the root directory, the default component prefix, etc. - Then we get the path to the project’s environment.ts file. We’ll need to know this in order to update the file.
- Next we build the
insertion
string. This contains the firebase configuration data that the user entered. - The
readIntoSourceFile()
function is important, and we’ll cover it in detail next. It returns the top-level abstract syntax tree (AST) node for a TypeScript file. - We also want to verify that the firebase config does not already exist in the environment.ts file. We’ll simply use the
getText()
method and then determine if the insertion text already exists. This is a bit brittle and could use some hardening to ensure that a Firebase configuration does not already exist. - Using the
getSourceNodes()
utility function we can retrieve an array of theNode
objects within theSourceFile
AST. - We’ll use the
Array.prototype.find()
method to find theNode
that is anOpenBraceToken
. Remember that the environment.ts file contains a singleenvironment
constant object that is exported. We are finding the node that is the opening brace ( { ) that starts the environment configuration object. - We’ll also find the end
Node
that is the closing brace ( } ). - Using the
tree.beginUpdate()
method we can start recording changes to the specified file in the tree. - We’ll insert the
insertion
text starting at the end of theenvironment
object. - Finally, we log out some information stating that the environment configuration step has completed successfully.
What is an AST?
Before we go any further, let’s quickly review what an abstract syntax tree (AST) is.
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:
- We’ll use some familiar functions to get information on the workspace and project that we are executing the schematic within.
- The
getAppModulePath()
returns the path string to the app.module.ts file within the Angular project (either lib or app). - Using the information we have on the environment file and the root application module we are ready to insert the es6 import statement into the app.module.ts file.
- We first verify that the
environment
identifier is not already imported into the module. If it is, then we are done here. - Otherwise, we need to insert the import. We’ll use the
insertImport()
function exported in the @angular/schematics module to create a newInsertChange
object. - Using the
change
object we start and commit an update to the app module file using theUpdateRecorder
.
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:
- Again, you’ll note that we are using several utility functions to access the metadata of the workspace and project, and then using this, we get the path to the app.module.ts file.
- First we verify that the module has not alredy been imported using the
hasNgModuleImport()
function. This one I sourced from the @angular/cdk module, located in the schematics/utils/ast directory. - Then we invoke the
addModuleImportToRootModule()
function to add the Firebase module import to the@NgModule()
decorator’simport
array.
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.