Table of Contents
This blog post is part of an article series.
- Part I: Generating Custom Code With The Angular CLI And Schematics
- Part II: Automatically Updating Angular Modules With Schematics And The CLI
- Part III: Extending Existing Code With The TypeScript Compiler API
- Part IV: Frictionless Library Setup with the Angular CLI and Schematics
- Part V: Seamlessly Updating your Angular Libraries with ng update
Thanks a lot to Hans Larsen from the Angular CLI team for reviewing this article.
It's always the same: After npm installing a new library, we have to follow a readme step by step to include it into our application. Usually this involves creating configuration objects, referencing css files, and importing Angular Modules. As such tasks aren't fun at all it would be nice to automate this.
This is exactly what the Angular CLI supports beginning with Version 6 (Beta 5). It gives us a new ng add
command that fetches an npm package and sets it up with a schematic -- a code generator written with the CLI's scaffolding tool Schematics. To support this, the package just needs to name this schematic ng-add
.
In this article, I show you how to create such a package. For this, I'll use ng-packagr and a custom schematic. You can find the source code in my GitHub account.
If you haven't got an overview to Schematics so far, you should lookup the well written introduction in the Angular Blog before proceeding here.
Goal
To demonstrate how to leverage ng add
, I'm using an example with a very simple logger library here. It is complex enough to explain how everything works and not indented for production. After installing it, one has to import it into the root module using forRoot
:
[...]
import { LoggerModule } from '@my/logger-lib';
@NgModule({
imports: [
[...],
LoggerModule.forRoot({ enableDebug: true })
],
[...]
})
export class AppModule { }
As you see in the previous listing, forRoot
takes a configuration object. After this, the application can get hold of the LoggerService
and use it:
[...]
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 prevent the need for importing the module manually and for remembering the structure of the configuration object, the following sections present a schematic for this.
Schematics is currently an Angular Labs project. Its public API is experimental and can change in future.
Getting Started
To get started, you need to install version 6 of the Angular CLI. Make sure to fetch Beta 5 or higher:
npm i -g @angular/cli@~6.0.0-beta
You also need the Schematics CLI:
npm install -g @angular-devkit/schematics-cli
The above mentioned logger library can be found in the start
branch of my sample:
git clone https://github.com/manfredsteyer/schematics-ng-add
cd schematics-ng-add
git checkout start
After checking out the start
branch, npm install its dependencies:
npm install
If you want to learn more about setting up a library project from scratch, I recommend the resources outlined in the readme of ng-packagr.
Adding an ng-add Schematic
As we have everything in place now, let's add a schematics project to the library. For this, we just need to run the blank
Schematics in the project's root:
schematics blank --name=schematics
This generates the following folder structure:
The folder src/schematics
contains an empty schematic. As ng add
looks for an ng-add
schematic, let's rename it:
In the index.ts
file in the ng-add
folder we find a factory function. It returns a Rule
for code generation. I've adjusted its name to ngAdd
and added a line for generating a hello.txt
.
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;
};
}
The generation of the hello.txt
file represents the tasks for setting up the library. We will replace it later with a respective implementation.
As our schematic will be looked up in the collection.json
later, we have also to adjust it:
{
"$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
"schematics": {
"ng-add": {
"description": "Initializes Library",
"factory": "./ng-add/index#ngAdd"
}
}
}
Now, the name ng-add
points to our rule -- the ngAdd
function in the ng-add/index.ts
file.
Adjusting the Build Script
In the current project, ng-packagr
is configured to put the library build out of our sources in the folder dist/lib
. The respective settings can be found within the ngPackage
node in the package.json
. When I'm mentioning package.json
here, I'm referring to the project root's package.json
and not to the generated one in the schematics
folder.
To make use of our schematic, we have to make sure it is compiled and copied over to this folder. For the latter task, I'm using the cpr
npm package we need to install in the project's root:
npm install cpr --save-dev
In order to automate the mentioned tasks, 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",
[...]
},
[...]
Also, extend the build:lib
script so that the newly introduced scripts are called:
[...]
"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 looks up the schematics
field in the package.json
. By definition it points to the collection.json
which in turn points to the provided schematics. Hence, let's add this field to our package.json too:
{
[...],
"schematics": "./schematics/collection.json",
[...]
}
Please note that the mentioned path is relative to the folder lib
where ng-packagr
copies the package.json
over.
Test the Schematic Directly
For testing the schematic, let's build the library:
npm run build:lib
After this, move to the dist/lib
folder and run the schematic:
schematics .:ng-add
Even though the output mentions that a hello.txt
is generated, you won't find it because when executing a schematic locally it's performing a dry run. To get the file, set the dry-run
switch to false
:
schematics .:ng-add --dry-run false
After we've seen that this works, generate a new project with the CLI to find out whether our library plays together with the new ng add
:
ng new demo-app
cd demo-app
ng add ..\logger-lib\dist\lib
Make sure that you point to our dist/lib
folder. Because I'm working on Windows, I've used backslashes here. For Linux or Mac, replace them with forward slashes.
When everything worked, we should see a hello.txt
.
As ng add
is currently not adding the installed dependency to your package.json
, you should do this manually. This might change in future releases.
Test the Schematic via an npm Registry
As we know now that everything works locally, let's also check whether it works when we install it via an npm registry. For this, we can for instance use verdaccio
-- a very lightweight node-based implementation. You can directly npm install it:
npm install -g verdaccio
After this, it is started by simply running the verdaccio
command:
Before we can publish our library to verdaccio, we have to remove the private
flag from our package.json
or at least set it to false
:
{
[...]
"private": false,
[...]
}
To publish the library, move to your project's dist/lib
folder and run npm publish
:
npm publish --registry http://localhost:4873
Don't forget to point to verdaccio
using the registry switch.
Now, let's switch over to the generated demo-app
. To make sure our registry is used, create an .npmrc
file in the project's root:
@my:registry=http://localhost:4873
This entry causes npm to look up each library with the @my
scope in our verdaccio instance.
After this, we can install our logger library:
ng add @my/logger-lib
When everything worked, we should find our library in the node_modules/@my/logger-lib
folder and the generated hello.txt
in the root.
Extend our Schematic
So far, we've created a library with a prototypical ng-add
schematic that is automatically executed when installing it with ng add
. As we know that our setup works, let's extend the schematic to setup the LoggerModule
as shown in the beginning.
Frankly, modifying existing code in a safe way is a bit more complicated than what we've seen before. But I'm sure, we can accomplish this together ;-).
For this endeavour, our schematic has to modify the project's app.module.ts
file. The good message is, that this is a common task the CLI performs and hence its schematics already contain the necessary logic. However, when writing this, the respective routines have not been part of the public API and so we have to fork it.
For this, I've checked out the Angular DevKit and copied the contents of its packages/schematics/angular/utility
folder to my library project's schematics/src/utility
folder. Because those files are subject to change, I've conserved the current state here.
Now, let's add a Schematics rule for modifying the AppModule
. For this, move to our schematics/src/ng-add
folder and add a add-declaration-to-module.rule.ts
file. This file gets an addDeclarationToAppModule
function that takes the path of the app.module.ts
and creates 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;
};
}
Most of this function has been "borrowed" from the Angular DevKit. It reads the module file and calls the addSymbolToNgModuleMetadata
utility function copied from the DevKit. This function finds out what to modify. Those changes are applied to the file using the recorder
object and its insertLeft
method.
To make this work, I had to tweak the copied addSymbolToNgModuleMetadata
function a bit. Originally, it imported the mentioned Angular module just by mentioning its name. My modified version has an additional parameter which takes an expression like LoggerModule.forRoot({ enableDebug: true })
. This expression is put into the module's imports
array.
Even though this just takes some minor changes, the whole addSymbolToNgModuleMetadata
method is rather long. That's why I'm not printing it here but you can look it up in my solution.
After this modification, we can call addDeclarationToAppModule
in 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 our Schematic as shown above. To re-publish it to the npm registry, we have to increase the version number in the package.json
. For this, you can make use of npm version
:
npm version minor
After re-building it (npm run build:lib
) and publishing the new version to verdaccio (npm publish --registry http://localhost:4873
), we can add it to our demo app
:
Conclusion
An Angular-based library can provide an ng-add
Schematic for setting it up. When installing the library using ng add
, the CLI calls this schematic automatically. This innovation has a lot of potential and will dramatically lower the entry barrier for installing libraries in the future.