A utilitarian guide for creating Nx Workspace Schematics
Schematics are a one-time event that adjusts your filesystem - usually for the purpose of automating boilerplate or configuration.
If you've ever written down a list of things to do or files to adjust everytime you create a component (for example), a schematic is an excellent solution.
Schematics are essentially just fancy string manipulation to modify your file system, so they do have their limitations.
Schematics also require a slightly different mindset than most other tasks - you're usually manipulating written code to help a developer automate tasks, instead of writing code for a compiler.
Given the complexity of writting schematics, it's usually best to target large sweeping tasks that touch several files. For smaller tasks, code snippet pluggins (https://github.com/johnpapa/vscode-angular-snippets) are likely a better choice.
Nx tooling will scaffold your schematic for you. To create a new schematic, use the command-line:
ng generate @nrwl/schematics:workspace-schematic <schematic name>
Or using the Angular Console: Generate > @nrwl/schematics > workspace-schematics > Enter Schematic Name > Generate
Using an Nx Workspace Schematic removes some of the complexity that you'd have to deal with otherwise (including building/distributing the schematic).
To run the workspace-schematic, you should be able to immediately run:
yarn workspace-schematic <schematic name> [options]
The Angular Console will also automatically detect that a workspace-schematic was created, and will add it to its UI. You will be able to find it in Generate > workspace-schematics >
To adjust our schematic once generated, we'll look at the files that were generated in tools/schematics/<schematic name>
.
This file will define the command-line arguments to be provided to the schematic.
Here's an example of what a schema.json
might look like:
{
"$schema": "http://json-schema.org/schema",
"id": "example-schematic",
"type": "object",
"properties": {
"mainArgument": {
"type": "string",
"description": "This is the main argument of the schematic",
"$default": {
"$source": "argv",
"index": 0
}
},
"requiredArgument": {
"type": "string",
"description": "This argument is required."
},
"optionalArgument": {
"type": "string",
"description": "This argument is optional"
},
"optionalArgumentWithDefaultValue": {
"type": "string",
"description": "This argument is optional but it defaults to 'foo'",
"default": "foo"
}
},
"required": ["mainArgument", "requiredArguemnt"]
}
Each potential argument for your schematic will go in the properties
field of this json
.
Our main argument uses the $default
field to specificy that it will be found at argv[0]
. This means you should be able to write it as:
yarn workspace-schematic example-schematic mainArgument
The rest of the arguments will require the person running the command to use a named option for the command like so:
yarn workspace-schematic example-schematic mainArgument --requiredArgument=requiredArgument
Note also the required
array of this json
. This will mark the properties
that the user must provide for the schematic to run.
Finally, note the default
field of optionalArgumentWithDefaultValue
. This will specify a value for this property
in the case where the user does not provide one.
The Angular Console also leverages this json
for building out its UI, and the description
of each property
will be visible.
Based on the example schema from above, we'd create the following interface in order to strongly type the options provided to the schematic:
example-schematic-options.types.ts
export interface IExampleSchematicOptions {
mainArgument: string;
requiredArgument: string;
optionalArgument?: string;
optionalArgumentWithDefaultValue: string;
}
Note that optionalArgument
is marked with an ?
to specify that it is optional, but that optionalArguementWithDefaultValue
is not specified as optional.
Since the schema.json
file specifies a value for optionalArgumentWithDefaultValue
, this field is gauranteed to not be undefined
in the options object that will be passed to our script.
In this file, we'll need to export a default function. This function serves as the "script" that will be passed to the schematic engine to alter your file system.
Here's how that function might look for our example (take note of the signature):
export default function(options: IExampleSchematicOptions): Rule | void {}
Note that the options param passed to this function should be set to the interface we created based on the arguments for this schematic.
These interfaces move out of the Nx umbrella into the Angular dev-kit.
A Tree
is an object that will carry the file system for your workspace in memory. You can Tree.read()
to read a file in your workspace to inform your script about certain things about your project, and you can also Tree.overwrite()
/Tree.delete()
/Tree.create()
/etc. to modify the file system inside your script.
Tree
s can also do fancier things via the branch()
, merge()
, commitUpdate()
, etc. methods that will allow you to manage your Tree
s similar to how you'd manage git branches. These can be helpful in some use-cases, but in most situations you'll be able to accomplish the Schematic's goal without these features.
export declare type Rule = (
tree: Tree,
context: SchematicContext
) => Tree | Observable<Tree> | Rule | void;
A Rule
essentually represents a mutation to the files of a Tree
object. Inside of a Rule
, we'll take a given Tree
and we can return a new Tree
or simply modify the given Tree
and return void
.
Looking at the Interface
s above, the scripting we'll do inside the schematic will take a Tree
and the user's options (IExampleSchematicOptions
) and return a Rule
to show how to change the file system.
It will be helpful for reading and maintaining your schematic code to first break your scripting down into logical pieces. For example, in the convert-leaf-component
schematic, the logic pieces determined were:
- Create a new Angular Library if the given one does not exist.
- Create a new Angular Component.
- Add the Component to the Library's barrel file.
- Copy the old Component/Controller code into the new component code as a comment
- Copy the template over as a comment
- Copy the spec file over as a comment
- Copy the styles over as a comment
- Add new Component to the Downgrade Module
- Delete Files from the AngularJS Component/Controller
- Mark any usages of the Old Component in any other templates in the workspace
For each of these, we'll create a named function that takes in the IExampleSchematicOptions
object as a parameter, and returns a Rule
for each.
We can then combine them via the chain()
function:
export default function(options: IConvertLeafComponentOptions): Rule | void {
return chain([
createLibIfItDoesNotExist(options),
createComponent(options),
addComponentToLibraryBarrel(options),
copyOldControllerAsComment(options),
copyOldTemplate(options),
copyOldSpec(options),
copyOldStyles(options),
addDowngradeComponentToMain(options),
removeOldComponent(options),
markUsagesOfOldComponent(options),
]);
}
Lower-level Rule
s tend to the one of the following things:
The createComponent()
function above is a perfect example of wrapping an existing schematic. Here's that function slightly simplified:
const createComponent: (options: IConvertLeafComponentOptions) => Rule = (
options: IConvertLeafComponentOptions
) =>
externalSchematic(
'@schematics/angular',
'component',
{
name: options.selector,
project: projectName(options.lib),
export: true,
entryComponent: true,
selector: dasherize(options.selector),
style: 'scss',
},
{ interactive: false }
);
Note the externalSchematic()
function from the Angular DevKit. With this we can pass an existing schematic (in this case the Angular CLI component schematic a la ng g c component-name
). The third parameter here is the options arugment to pass to that schematic. Note how some of the properties in this object are hard-coded to specific values, and others are derived from the options
parameter.
Also note the 4th parameter is an ExecutionOptions
parameter. Some schematics are interactive in that they will ask the user questions in the console and key off of responses as the schematic is running. It's generally good practice to always set interactive: false
inside your execution options object here, as any question asked when wrapping an external schematic may not make sense in context.
Let's look at our addComponentToLibraryBarrel
function:
const addComponentToLibraryBarrel: (options: IConvertLeafComponentOptions) => Rule = (
options: IConvertLeafComponentOptions
) => (tree: Tree, _context: SchematicContext) => {
const lineToAdd = `\nexport * from './lib/${dasherize(options.selector)}/${dasherize(
options.selector
)}.component';`;
const pathToBarrelFile = path.join(ANGULAR_LIB_PATH, dasherize(options.lib), 'src', 'index.ts');
const buffer = tree.read(pathToBarrelFile);
if (!buffer) {
throw Error(`Invalid or unavailable '${pathToBarrelFile}'.`);
}
const old = buffer.toString();
const insertIndex = old.lastIndexOf(';') + 1;
const newText = insertAtIndex(old, lineToAdd, insertIndex);
tree.overwrite(pathToBarrelFile, newText);
};
This higher-order function takes in an IConvertLeafComponentOptions
and returns a Rule
function, that simply adds another export statement to a barrel file (an index.ts
file that acts as a central hub for all its module's exports).
Most of this is string manipulation, but you can see how we take data from the user-specified options and the Tree
and builds a new file that the Tree overwrites in it's virtual file system.
Creating a new file is a bit of a unique case, as there are great tools in the Angular DevKit specifically for this case.
Take this Rule:
const createNewFiles = (tree: Tree, _context: SchematicContext) => {
// these would probably come from user options or something...
const path = '...'; // stubbed
const name = '...'; // stubbed
const properties = ['...']; // stubbed
const templateSource = apply(url('./files'), [
template({
tmpl: '',
dasherize,
camelize,
classify,
name,
properties,
}),
move(path),
]);
return mergeWith(templateSource);
};
Imagine your director for your schematic in question looked like:
example-schematic
|-- index.ts
|-- example-schematic-options.type.ts
|-- schema.json
|-- files
| |-- __name@dasherize__
| | |-- __name@dasherize__.example.ts__tmpl__
And your __name@dasherize__.example.ts__tmpl__
file looked like:
export interface <%= classify(name) %>Example {
<% for (const property of properties) { %>)
<%= camelize(property) %>: string;
<% } %>
}
To unpack this, let's start with the Rule
itself.
The apply()
function takes a target directory, and an array of rules. The first Rule
is created via template()
. This will take the object passed in and apply it's properties to all directory names, file names, and file contents of the targetted directory.
Looking at our tree structure, the adjustments to file names are scripted such that we'll encase an expression to be resolved via a pair of double-underscores, __
. The @
when within the __
is used to apply an argument to a function, so __name@dasherize__
is equivalent of dasherize(name)
.
Also note the __tmpl__
applied at the end of __name@dasherize__.example.ts__tmpl__
. This is common-practice to pass an empty string to the tmpl
property of the templating object. This allows us to append it to a file name as we do here. The resulting file will simply end with .ts
, but when writing this template, your IDE will no longer complain about the Typescript errors we'll be making in it since the template itself isn't marked as .ts
.
Inside of a file, the template()
function will use <%= expression %>
to evaluate Typescript expressions (informed by the object passed in), you may also script in typescript logic between <% %>
tags, similiar to how you might with Java in .jsp
files back in the day. It's best not to put too much logic into these files, but it can be handy for the odd for
loop or if
/else
branch.
Given the above files/file structure, and the inputs
const name = 'fooBar';
const propertiesForClass = ['foo', 'bar'];
The resulting file tree would be:
|-- index.ts
|-- example-schematic-options.type.ts
|-- schema.json
|-- files
| |-- foo-bar
| | |-- foo-bar.example.ts
And the file contents of the .ts
file would be:
export interface FooBarExample {
foo: string;
bar: string;
}
Going all the way back to the Rule
, we're going to take this entire file tree, and move it in the virtual tree to the given path via the move()
function.
Finally we'll return mergeWith()
the result of the apply()
to return a Rule
that describes all the changes we've made to the tree in this Rule
.