Table of Contents

This blog post is part of an article series.


Many thanks to Hans Larsen from the Angular CLI team for reviewing this article.

We've all been there: after pulling in a new library via npm, there's the inevitable ritual of walking through the readme, manually creating configuration objects, linking stylesheets, and wiring up Angular modules. These repetitive steps are prime candidates for automation.

That's exactly the problem the Angular CLI tackles starting with Version 6 (Beta 5). A new ng add command fetches an npm package and configures it using a schematic — a code generator built on the CLI's scaffolding tool, Schematics. For this to work, the package simply needs to expose a schematic named ng-add.

In this post, I'll walk you through creating such a package using ng-packagr and a custom schematic. The complete source code is available in my GitHub account.

If Schematics is new territory for you, I'd recommend reading the introduction on the Angular Blog before diving in here.

Objective

To show how ng add can be put to work, I've put together a simple logger library as an example. It's substantial enough to illustrate the mechanics without being production-ready. Once installed, the library needs to be wired into the root module via forRoot:

[...] import { LoggerModule } from '@my/logger-lib'; @NgModule({ imports: [ [...], LoggerModule.forRoot({ enableDebug: true }) ], [...] }) export class AppModule { }

As the snippet above shows, forRoot expects a configuration object. After that, the application can access the LoggerService and start logging:

[...] import { LoggerService } from '@my/logger-lib'; @Component({ selector: 'app-root', templateUrl: './app.component.html' }) export class AppComponent { constructor(private logger: LoggerService) { logger.debug('Hello World!'); logger.log('Application started'); } }

To eliminate the manual import step and the need to remember the configuration object's shape, the sections below build a schematic to handle this.

Schematics is an Angular Labs project at this point. The public API is still experimental and subject to change.

Angular Labs

Prerequisites

First, you'll need version 6 of the Angular CLI. Be sure to grab Beta 5 or a newer release:

npm i -g @angular/cli@~6.0.0-beta

The Schematics CLI is also required:

npm install -g @angular-devkit/schematics-cli

The logger library mentioned earlier lives in the start branch of my sample:

git clone https://github.com/manfredsteyer/schematics-ng-add
cd schematics-ng-add
git checkout start

Once you've checked out the start branch, install the dependencies with npm:

npm install

For a deeper dive into setting up a library project from scratch, the ng-packagr readme has solid guidance.

Creating an ng-add Schematic

With the groundwork in place, we can add a schematics project to the library. Running the blank schematic from the project's root does the trick:

schematics blank --name=schematics

This creates the following directory layout:

Generated Schematic

The src/schematics folder now holds an empty schematic. Since ng add looks for an ng-add schematic, we'll rename it:

Renamed Schematic

Inside the index.ts file of the ng-add folder, there's a factory function that returns a Rule for code generation. I've renamed it to ngAdd and included a step to generate a hello.txt file:

import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; export function ngAdd(): Rule { return (tree: Tree, _context: SchematicContext) => { tree.create('hello.txt', 'Hello World!'); return tree; }; }

Creating hello.txt serves as a stand-in for the actual library setup tasks. We'll swap it out for a real implementation shortly.

Since the schematic will be resolved through collection.json, we need to update that file as well:

{ "$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json", "schematics": { "ng-add": { "description": "Initializes Library", "factory": "./ng-add/index#ngAdd" } } }

Now the ng-add name maps to our rule — the ngAdd function in ng-add/index.ts.

Updating the Build Script

In this project, ng-packagr is set up to output the built library into dist/lib. Those settings live under the ngPackage key in the root package.json — not the one generated inside the schematics folder.

To get our schematic into the build output, we need to compile it and copy it over. For the copy step, I'm using the cpr npm package, which we'll install at the project root:

npm install cpr --save-dev

To automate these steps, add the following scripts to the package.json:

[...] "scripts": { [...], "build:schematics": "tsc -p schematics/tsconfig.json", "copy:schematics": "cpr schematics/src dist/lib/schematics --deleteFirst", [...] }, [...]

The build:lib script should also be extended to invoke these new scripts:

[...] "scripts": { [...] "build:lib": "ng-packagr -p package.json && npm run build:schematics && npm run copy:schematics", [...] }, [...]

When the CLI tries to find our ng-add schematic, it checks the schematics field in the package.json. That field points to the collection.json, which in turn references the available schematics. So let's add it to our package.json:

{ [...], "schematics": "./schematics/collection.json", [...] }

Note that the path here is relative to the lib folder, where ng-packagr places the package.json.

Testing the Schematic Directly

Let's kick the tires by building the library:

npm run build:lib

After that, navigate to dist/lib and run the schematic:

schematics .:ng-add

Testing the ng-add schematic

Although the output claims a hello.txt was created, it won't actually be there — running a schematic locally performs a dry run by default. To write the file, set the dry-run option to false:

schematics .:ng-add --dry-run false

Now that we've confirmed it works, let's spin up a fresh project with the CLI to see if our library plays nicely with ng add:

ng new demo-app
cd demo-app
ng add ..\logger-lib\dist\lib

ng add with relative path

Be sure to point to our dist/lib folder. Since I'm on Windows, I've used backslashes; on Linux or macOS, swap them for forward slashes.

If all goes well, you'll see a hello.txt appear.

Keep in mind that ng add doesn't yet add the installed dependency to your package.json, so you'll need to do that manually. This could change in upcoming releases.

Testing via an npm Registry

Now that we've verified the local flow, let's confirm it holds up when installing through an npm registry. A lightweight option is verdaccio, a node-based registry. Install it via npm:

npm install -g verdaccio

Then start it with the verdaccio command:

Running verdaccio

Before publishing to verdaccio, remove the private flag from our package.json or set it to false:

{ [...] "private": false, [...] }

To publish, move to the dist/lib folder and run npm publish:

npm publish --registry http://localhost:4873

Make sure to pass the registry switch to target verdaccio.

Next, switch to the generated demo-app. To ensure our registry is used, create an .npmrc file in the project root:

@my:registry=http://localhost:4873

This entry tells npm to resolve any package with the @my scope from our verdaccio instance.

With that configured, install the logger library:

ng add @my/logger-lib

ng add

If everything worked, the library will be in node_modules/@my/logger-lib and the generated hello.txt will sit in the project root.

Expanding the Schematic

So far, we've built a library with a basic ng-add schematic that runs automatically on installation via ng add. Now that the setup is proven, let's enhance the schematic to wire up the LoggerModule like we saw earlier.

Modifying existing code safely is a step up in complexity, but it's doable. The goal here is to update the project's app.module.ts.

The good news is that this is a routine task for the CLI, so its schematics already contain the required logic. The catch is that, at the time of writing, those routines weren't in the public API — so we'll need to fork them.

To that end, I cloned the Angular DevKit and copied the contents of packages/schematics/angular/utility into my project's schematics/src/utility folder. Since these files are liable to change, I've archived the current state here.

Now, let's add a rule for modifying the AppModule. Head to schematics/src/ng-add and create a file called add-declaration-to-module.rule.ts. This file exposes an addDeclarationToAppModule function that takes the path to app.module.ts and returns a rule for updating it:

import { Rule, Tree, SchematicsException } from '@angular-devkit/schematics'; import { normalize } from '@angular-devkit/core'; import * as ts from 'typescript'; import { addSymbolToNgModuleMetadata } from '../utility/ast-utils'; import { InsertChange } from "../utility/change"; export function addDeclarationToAppModule(appModule: string): Rule { return (host: Tree) => { if (!appModule) { return host; } // Part I: Construct path and read file const modulePath = normalize('/' + appModule); const text = host.read(modulePath); if (text === null) { throw new SchematicsException(File <span class="hljs-subst">${modulePath}</span> does not exist.); } const sourceText = text.toString('utf-8'); const source = ts.createSourceFile(modulePath, sourceText, ts.ScriptTarget.Latest, true); // Part II: Find out, what to change const changes = addSymbolToNgModuleMetadata(source, modulePath, 'imports', 'LoggerModule', '@my/logger-lib', 'LoggerModule.forRoot({ enableDebug: true })'); // Part III: Apply changes const recorder = host.beginUpdate(modulePath); for (const change of changes) { if (change instanceof InsertChange) { recorder.insertLeft(change.pos, change.toAdd); } } host.commitUpdate(recorder); return host; }; }

Much of this function is lifted from the Angular DevKit. It reads the module file and invokes the addSymbolToNgModuleMetadata utility we copied. That function determines what changes are needed; those changes are then applied to the file via the recorder object's insertLeft method.

To get this working, I tweaked the copied addSymbolToNgModuleMetadata slightly. In its original form, it imported the module by name only. My version adds a parameter for an expression like LoggerModule.forRoot({ enableDebug: true }), which is then inserted into the module's imports array.

Although the change is minor, the addSymbolToNgModuleMetadata function is quite long — so I won't print it here. You can find it in my solution.

With that in place, we can invoke addDeclarationToAppModule from our schematic:

import { Rule, SchematicContext, Tree, chain, branchAndMerge } from '@angular-devkit/schematics'; import { addDeclarationToAppModule } from './add-declaration-to-module.rule'; export function ngAdd(): Rule { return (tree: Tree, _context: SchematicContext) => { const appModule = '/src/app/app.module.ts'; let rule = branchAndMerge(addDeclarationToAppModule(appModule)); return rule(tree, _context); }; }

Now we can test the schematic as shown before. To republish to the npm registry, bump the version number in package.jsonnpm version is handy for that:

npm version minor

After rebuilding (npm run build:lib) and publishing the new version to verdaccio (npm publish --registry http://localhost:4873), we can add it to the demo app:

Add extended library

Wrap-Up

An Angular library can ship with an ng-add schematic to handle its own setup. When the library is installed via ng add, the CLI runs that schematic automatically. This is a game-changer that should significantly ease the path to adopting new libraries downstream.