Skip to content

Instantly share code, notes, and snippets.

@ZackDeRose
Created June 13, 2019 22:53
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ZackDeRose/2e24fa6b5873ab6219c68b917efd1f9d to your computer and use it in GitHub Desktop.
Save ZackDeRose/2e24fa6b5873ab6219c68b917efd1f9d to your computer and use it in GitHub Desktop.

Workspace Schematics

A utilitarian guide for creating Nx Workspace Schematics

Notes on Schematic - When They Are and Are Not Useful

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.

Scaffolding a Workspace Schematic

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>.

schema.json

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.

Creating An Interface For The Schematic Options

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.

index.ts

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.

Interfaces Worth Understanding

These interfaces move out of the Nx umbrella into the Angular dev-kit.

Tree

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.

Trees can also do fancier things via the branch(), merge(), commitUpdate(), etc. methods that will allow you to manage your Trees 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.

Rule

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.

Scripting Your Schematic

Looking at the Interfaces 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),
  ]);
}

Building Out Each Rule

Lower-level Rules tend to the one of the following things:

Wrap an Existing Schematic

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.

Edit/Delete a File

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 new Files

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment