Creating a yeoman generator

In the previous post we looked at the basics of getting started with yeoman and the tools etc. around it. Let’s now write some real code. This is going to be a generator for creating a node server using my preferred stack of technologies and allow the person running it to supply data and/or options for the generator to allow it to generate code specific to the user’s needs.

Input

Our node server will offer the option of handling REST, websocket or GraphQL endpoints. So we’re going to need some input from the user to choose the options they want.

First off, DO NOT USE the standard console.log etc. methods for output. Yeoman supplies the log function for this purpose.

Here’s an example of our generator with some basic interaction

var Generator = require("yeoman-generator");
module.exports = class extends Generator {
    async prompting() {
        const input = await this.prompt([
            {
                type: "input",
                name: "name",
                message: "Enter project name",
                default: this.appname
            },
            {
                type: "list",
                name: "endpoint",
                message: "Endpoint type?",
                    choices: ["REST", "REST/websocket", "GraphQL"]
            }
        ]);


        this.log("Project name: ", input.name);
        this.log("Endpoint: ", input.endpoint);
    }
};

Now if we run our generator we’ll be promoted (from the CLI) for a project name and for the selected endpoint type. The results of these prompts will then be output to the output stream.

As you can see from this simple example we can now start to build up a list of options for our generator to use when we generate our code.

Command line arguments

In some cases we might want to allow the user to supply arguments from the command line, i.e. not be prompted for them. To achieve this we add a constructor, like this

constructor(args, opts) {
   super(args, opts);

   this.argument("name", { type: String, required: false });

   this.log(this.options.name);
}

Here’s we’ve declared an argument name which is not required on the command line, this also allows us to run yo server –help to see a list of options available for our generator.

The only problem with the above code is that if the user supplies this argument, they are still prompted for it via the prompting method. To solve this we can add the following

yarn add yeoman-option-or-prompt

Now change our code to require yeoman-option-or-prompt, i.e.

var OptionOrPrompt = require('yeoman-option-or-prompt');

Next change the constructor slightly, to this

constructor(args, opts) {
   super(args, opts);

   this.argument("name", { type: String, required: false });

   this.optionOrPrompt = OptionOrPrompt;
}

and finally let’s change our prompting method to

async prompting() {

   const input = await this.optionOrPrompt([           
      {
         type: "input",
         name: "name",
         message: "Enter project name",
         default: this.appname
      },
      {
         type: "list",
         name: "endpoint",
         message: "Endpoint type?",
            choices: ["REST", "REST/websocket", "GraphQL"]
      }
   ]);

   this.log("Project name: ", input.name);
   this.log("Endpoint: ", input.endpoint);
}

Now when we run yo server without an argument we still get the project name prompt, but when we supply the argument, i.e. yo server MyProject then the project name prompt no longer appears.

Templates

With projects such as the one we’re developing here, it would be a pain if all output had to be written via code. Luckily yeoman includes a template capability from https://ejs.co/.

So in this example add a templates folder to generators/app and then within it add package.json, here’s my file

{
    "name": "<%= name %>",
    "version": "1.0.0",
    "description": "",
    "module": "es6",
    "dependencies": {
    },
    "devDependencies": {
    }
  }

Notice the use of <%= %> to define our template variables. The variable name now needs to be supplied via our generator. We need to make a couple of changes from our original source, the const input needs to change to this.input to allow the input variable to be accessible in another method, the writing method, which looks like this

writing() {
   this.fs.copyTpl(
      this.templatePath('package.json'),
      this.destinationPath('public/package.json'),
         { name: this.input.name } 
   );
}

here’s the changed prompting method as well

async prompting() {

   this.input = await this.optionOrPrompt([           
      {
         type: "input",
         name: "name",
         message: "Enter project name",
         default: this.options.name
      },
      {
         type: "list",
         name: "endpoint",
         message: "Endpoint type?",
            choices: ["REST", "REST/websocket", "GraphQL"]
      }
   ]);
}

Now we can take this further

{
    "name": "<%= name %>",
    "version": "1.0.0",
    "description": "",
    "module": "es6",
    "dependencies": { <% for (let i = 0; i < dependencies.length; i++) {%>
      "<%= dependencies[i].name%>": "<%= dependencies[i].version%>"<% if(i < dependencies.length - 1) {%>,<%}-%>
      <%}%>
    },
    "devDependencies": {
    }
  }

and here’s the changes to the writing function

writing() {

   const dependencies = [
      { name: "express", version: "^4.17.1" },
      { name: "body-parser", version: "^1.19.0" }
   ]

   this.fs.copyTpl(
      this.templatePath('package.json'),
      this.destinationPath('public/package.json'),
      { 
         name: this.input.name, 
         dependencies: dependencies 
      } 
   );
}

The above is quite convoluted, luckily yeoman includes functionality just for such things, using JSON objects

const pkgJson = {
   dependencies: {
      "express": "^4.17.1",
      "body-parser": "^1.19.0"
   }
}

this.fs.extendJSON(
   this.destinationPath('public/package.json'), pkgJson)