Introducing a practical schematics library example

Schematics are an effective way to standardize patterns, enforce good practices, and keep repositories consistent across large organizations by automating repetitive implementation tasks. We have covered the theory in earlier posts; now it is time to see how this works in a concrete scenario.

This post continues our series on building and publishing a schematics-based library to `npm`. The use case I have chosen involves a widely adopted, schematics-heavy library: Scully.io.

If you are familiar with Scully, you know it generates static sites by leveraging a headless chromium browser spun up via Puppeteer to pre-render pages into static HTML, including inline assets, inside the dist/static directory.

Scully relies on a schematics collection to speed up content creation. Examples include add-blog and add-post, which work together to quickly scaffold a blog folder and then generate individual posts from the CLI.

The problem I chose to solve

Last August, I was teaching an Angular Schematics workshop organized by ngConfCo. It was a demanding event: four hours, fully remote, with over 60 attendees, each bringing a different skill level. In fact, the material you are reading is a derivative of the translated content prepared for that workshop.

My challenge was figuring out how to structure the material so every attendee could access it, regardless of their internet connection or device. The answer was straightforward: since it was an Angular conference, the site had to use Angular. And because I wanted speed, static was the way to go. So I built the content as an Angular app, pre-rendered it with Scully, and deployed it on a JAM stack platform.

I could have used the blog and post schematics as-is, but I chose instead to extend the post schematic so it would generate docs rather than blog posts. So instead of triggering

$ ng generate @scullyio/init:post

the scullyio generate post command and responding to blog-related prompts, I would simply execute

$ ng generate add-doc:doc

a custom Angular CLI generation command.

That command chains my own schematic, pre-filled with default values, to Scully's add-post schematic. For now, you can see how I built the docs module, the side navigation, and the route configuration for Scully in this write-up. In a future post, I will show how to automate even that setup with schematics. For the moment, let’s focus on the add-doc schematic extension in a hands-on way.

Building the custom schematic

You already know the routine by now, so let’s start by creating a new blank schematic named add-doc.

$ schematics blank --name=add-doc

the command to scaffold a blank schematic

Next, open the index.ts file for this schematic and begin writing the add-doc logic.

No validation checks required

Since this schematic is designed to be chained after a Scully schematic, there is no need to verify that you are inside an Angular workspace. You can still add such checks if you prefer, but they are not required. If you are building a library with many schematics, though, consider extracting those verification steps into a separate schematic that you chain first, to keep things reusable and clean.

Defining option overrides

The first step is to override the options of the Scully add-post schematic, which we will be chaining to.

// We want to override the blog default target folder of post, by docs
options.target = 'docs';
// we will use this target variable later
const target = tree.getDir(options.target);
options.title = options.name || 'doc-X';
options.description = 'doc description';

the code for overriding options

Note that some of these overrides will only take effect if certain parts of Scully's add-post function and schema are adjusted, as I proposed here.

At the time of writing that post and the accompanying code, Scully was still in beta. It has since shipped its first stable release, so stay tuned for future updates!

Handling the side navigation and file ordering

An important detail: we have already decided that every new route should be automatically added to the navigation so it stays accessible. Doing that manually is tedious and error-prone. As mentioned, I will add a dedicated schematic in an upcoming post to automate that part as well. For now, the navigation is created manually following the instructions shared here.

What I do want to handle in the schematic is keeping these files properly ordered. One option is to introduce a new metadata property, such as index, to explicitly set the order. But I prefer to make the ordering visible right in the file path. I accomplish this by prepending a numeric prefix to each generated file name.

  // Let's create an array to push the generated files to
      const indices: number[] = [];

      target.visit(file => {
        // Now let's just get the index of the last doc created to order the sidenav
        let fileName = basename(file);
        let fileIndex = parseInt(fileName.substring(0,3), 10);
        indices.push(fileIndex || 0o0);
      });

      if (indices.length !== 0) {
        let maxIndex = Math.max(...indices);
        let newIndex = ++maxIndex;
        let index = newIndex.toString();

        const _index = index.length === 1 ? `00${newIndex}` : index = `0${newIndex}`;

        // We increment index name so we have an ordered list for the sidenav
        // We want to make sure the mandatory name option of the post schematic is satisfied
        options.name = `${_index}${options.title}`;
      } else {
        // Even if the folder did not exist or was empty before, we need to satisfy this
        options.name = `000${options.title}`;
      }

the custom function that adds numeric prefixes for ordering

That is essentially the core of what this schematic does. The heavy lifting—creating the actual content files—is handled by Scully's add-post schematic, so there is no point in reinventing that. The only thing we need to do is chain our schematic to that existing external one.

externalSchematic('@scullyio/init','post', options)
    ])

the code for chaining to the external Scully schematic

Remember: for this to work, Scully must be listed as a dependency of your schematic package.

One nice side effect of schematics running synchronously by default is that execution order is guaranteed. In this case, the order is:

add-doc => add-post

Adding the validation schema and prompts

We are not done yet—there are a few more steps. Let’s add a validation schema so our schematic receives the correct data and shows the right prompts.

First, replace the generic any type for options with a dedicated interface. Create a schema.ts file and update it like this.

/**
 * Using the same model for options as
 * Scully ng-add-blog schematic
 */
export interface Schema {
  /**
   * add the title for the post
   */
  name?: string;
  /**
   * add the title for the doc post
   */
  title?: string;
  /**
   * define the target directory for the new post file
   */
  target?: string;
  /**
   * define the file extension for the target file
   */
  extension?: string;
  /**
   * override the post description
   */
  description?: string;
}

the exported Schema interface

Remember that the description and title values will only work as expected if the underlying Scully schematic is updated accordingly.

Now create the schema.json file. This is where we define the prompts. These prompts will override those from the add-post schema, so only ours will appear in the terminal—something you will see in action shortly.

{
  "$schema": "http://json-schema.org/schema",
  "id": "@scullyio/init:post",
  "title": "Scully: Add a blog post schematic",
  "type": "object",
  "properties": {
    "name": {
      "type": "string",
      "description": "add the title for the post",
      "x-prompt": "What's the title of this doc?",
      "default": "doc-X"
    },
    "title": {
      "type": "string",
      "description": "add the title for the doc",
      "default": "doc-X"
    },
    "target": {
      "type": "string",
      "description": "define the target directory for the new post file",
      "x-prompt": "What is the target folder for your docs?",
      "default": "docs"
    },
    "extension": {
      "type": "string",
      "description": "define the file extension for the target file",
      "default": "md"
    },
    "description": {
      "type": "string",
      "description": "use a meta data template file that's data will be added to the post",
      "x-prompt": "What is the description for this post?",
      "default": "document description"
    }
  },
  "required": ["name"]
}

the schema JSON with new properties and prompts

Let’s not forget to update the collection definition. First, we need to reference our new schema. Then we should add an alias so the schematic can be invoked as a single word, without dashes: doc.

{
  "$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
  "schematics": {
    "add-doc": {
      "description": "A blank schematic.",
      "factory": "./add-doc/index#addDoc",
      "aliases": ["doc"],
      "schema": "./add-doc/schema.json"
    }
  }
}

the updated collection with schema reference and alias

Putting it all together

Now let’s assemble the full implementation. Be sure to compile everything before testing.

import { Rule, SchematicContext, Tree, chain, externalSchematic } from '@angular-devkit/schematics';
// import { strings } from '@angular-devkit/core';
import { basename } from 'path';
import { Schema } from './schema';

// You don't have to export the function as default. You can also have more than one rule factory
// per file.
export function addDoc(options: Schema): Rule {
  return chain([
    chain([ (tree: Tree, _context: SchematicContext) => {
     
      // We want to override the blog default target folder of post, by docs
      options.target = 'docs';
      const target = tree.getDir(options.target);
      options.title = options.name || 'doc-X';
      options.description = 'doc description';

      // Let's create an array to push the generated files to
      const indices: number[] = [];

      target.visit(file => {
        // Now let's just get the index of the last doc created to order the sidenav
        let fileName = basename(file);
        let fileIndex = parseInt(fileName.substring(0,3), 10);
        indices.push(fileIndex || 0o0);
      });

      if (indices.length !== 0) {
        let maxIndex = Math.max(...indices);
        let newIndex = ++maxIndex;
        let index = newIndex.toString();

        const _index = index.length === 1 ? `00${newIndex}` : index = `0${newIndex}`;

        // We increment index name so we have an ordered list for the sidenav
        // We want to make sure the mandatory name option of the post schematic is satisfied
        options.name = `${_index}${options.title}`;
      } else {
        // Even if the folder did not exist or was empty before, we need to satisfy this
        options.name = `000${options.title}`;
      }
      return tree;
    },
    externalSchematic('@scullyio/init','post', options)
    ])
  ])
}

Running the schematic

To test this schematic (at least the file generation part), create a new Angular app with the CLI and link the package you have built locally.

Scully obviously needs to be installed as well! Other prerequisites also apply—please check the previous post to ensure you have all the required dependencies for working with schematics.

In the terminal, run:

$ ng generate add-doc:doc

You will then be prompted to either accept the default values or provide a custom title and target for the document being generated.

You can run it a few times to see how the numeric index is appended to keep your paths in order. Be aware that I only align the index up to three digits; if you end up with more than 1000 documents, the ordering will fail.

Testing your schematic

By now, you are probably wondering how to write unit tests for your schematic. There are dedicated utilities for that, so I will devote an entire section of the series to testing patterns. Stay tuned.

That wraps up this part. In the next and final installment, we will create the side navigation generator and publish the library with ng-add support.

Repository

The full schematic is available here: https://github.com/anfibiacreativa/add-doc/blob/master/README.md