Create a command line tutorial using Workshopper

- 16 mins

Introduction

An interactive tutorial is often one of the best tools for introducing a programming language, library or framework and is often the medium of instruction at workshops. Creating such tutorials can sometimes be daunting but Node.js provides the workshopper module to ease this process. Workshopper can be used to create interactive tutorials for Javascript (javascripting), backend tools like Express (expressworks) and even frontend tools like React (learnyoureact). You can find more interactive tutorials at Nodeschool.

This article will guide you through the process of creating your own tutorial and adding the first exercise.

How Workshopper works

A workshopper tutorial can contain several exercises: learnyounode exercises

When an exercise is selected, the user receives a problem statement and a suggested solution to the problem: problem statement

Once the user has created their solution, they test it against the accepted solution by running the verify command. Below is the result for a solution that passed the test: verify

What we’ll be creating

We will create a tutorial to teach the basics of Typescript. We’ll create the lesson and add the first exercise which introduces the basic types of Typescript. We’ll use the excellent documentation as source material for the exercise.

You’ll find the complete version of the tutorial here.

Scaffolding

To create the tutorial, we’ll need a bit of scaffolding. This process assumes you have Node.js installed. You can install and manage Node.js with nvm.

Once you’ve verified you have Node.js installed, create the repo and initialize npm:

$ mkdir typescripting; cd typescripting; git init; npm init -y

You can check your progress against this tag on Github.

Install required modules

Install workshopper-adventure and workshopper-exercise

$ npm install --save workshopper-exercise workshopper-adventure

We’ll use the workshopper-adventure module to create the tutorial and workshopper-exercise to create the exercises.

Install after and xtend

$ npm install --save after xtend

The after and xtend modules are required the exercise runner script.

Install Typescript

$ npm install --save typescript

These are all the required modules for the project. I forgot to mention that we need to create our .gitignore file and add the node_modules directory to it. At the root of your project, execute the following script:

$ echo "node_modules" > .gitignore

We’re now ready to create the tutorial. You can check your progress against this tag on Github.

Create exercise index

The index file is the tutorial’s entry point. It is initialized using workshopper-adventure and contains a list of all the exercises as well as metadata concerning the tutorial. At the project’s root, create an index.js file and insert the code below:

index.js

var workshopper = require('workshopper-adventure');
var path  = require('path');
var typescripting  = workshopper({
  title: 'TYPESCRIPTING',
  exerciseDir: path.join(__dirname, 'exercise'),
  appDir: __dirname,
  languages: ['en'],
});

module.exports = typescripting;

We import the workshopper-adventure module which we use to create the tutorial, supplying the required metadata. The value set to the exerciseDir property indicates that we’ll store the individual exercises in the 'exercises' directory.

While the index.js serves as the entry point, it is not the main executable. We need to create the executable file in the 'bin' directory:

bin/typescripting.js

#!/usr/bin/env node

require('../index').execute(process.argv.slice(2))

You’ll notice that we import the index.js file and execute it on all arguments from the second one. With this, we can run the command:

node bin/typescripting.js

to view the tutorial: basic menu

It’s missing the exercises which we’ll add in the next part but we have a little improvement we need to make. To enable the user start the tutorial with a single command, we need to add a "bin" config to our package.json file and run npm link: package.json

"bin": {
  "typescripting": "./bin/typescripting.js"
}
$ npm link

You should have something like this printed to the terminal:

/Users/gnerkus/.nvm/versions/node/v4.3.0/bin/typescripting -> /Users/gnerkus/.nvm/versions/node/v4.3.0/lib/node_modules/typescripting/bin/typescripting.js
/Users/gnerkus/.nvm/versions/node/v4.3.0/lib/node_modules/typescripting -> /Users/gnerkus/Projects/Personal-workspace/Node-workspace/typescripting

Now we can run typescripting to view the tutorial menu. You can check your progress against this tag on Github.

Create first exercise

Before we create the first exercise, we’ll need understand the structure of an exercise directory. For this, we’ll inspect the 'variables' exercise of the less-is-more tutorial.

Disclaimer: I created the less-is-more tutorial.

exercises
|-- variables
|   |-- solution
|   |   |-- solution.less
|   |-- exercise.js
|   |-- problem.md

The problem.md file contains the problem definition and suggested solution. The solution directory contains the solution.less file which the users solution will be tested against. The exercise.js file is the runner for the particular exercise. It utilizes the workshopper-exercise module. Let’s create the content for the first exercise:

The problem definition

exercises/basictypes/problem.md

# BASIC TYPES

For programs to be useful, we need to be able to work with some of the simplest units of data: numbers, strings, structures, boolean values, and the like. In TypeScript, we support much the same types as you would expect in JavaScript, with a convenient enumeration type thrown in to help things along.

The most basic datatype is the simple true/false value, which JavaScript and TypeScript call a `boolean` value.

```typescript
let isDone: boolean = false;
```

# EXERCISE

Create a boolean variable `isLearning` and set its value to `true`.

--
## HINTS

To create a TypeScript file, create a new file with a `.ts` extension. When you are done, you must run:

```sh
$ typescripting verify script.ts
```

to proceed. Your script will be tested, a report will be generated, and the lesson will be marked 'completed' if you are successful.

The problem definition often consists of three sections: an introductory section, an exercise section and a hint section.

For the introductory section, we’ll use the text in the Typescript documentation. I only included the part about booleans since this is a demo tutorial. You can add more information if you want and I’d recommend you do.

The exercise itself isn’t too complex as it involves the definition of a boolean variable in Typescript.

We make sure to include instructions for submission in the hint section so our users know what to do.

The solution file

exercises/basictypes/solution/solution.ts

let isLearning: boolean = true;

The exercise requires that the user define a boolean variable so the solution is just that.

The exercise runner script

exercises/basictypes/exercise.js

var exercise = require('workshopper-exercise')();
var filecheck = require('workshopper-exercise/filecheck');

// Override the default executor
var execute = require('../../execute');
var comparestdout = require('workshopper-exercise/comparestdout')

// checks that the submission file actually exists
exercise = filecheck(exercise);

// execute the solution and submission in parallel with spawn()
exercise = execute(exercise);

// compare stdout of solution and submission
exercise = comparestdout(exercise);

module.exports = exercise;

The exercise.js first runs a file check. Then it executes the exercise after which it compares it with the solution. There’s a lot more code required to run the exercise but that has been refactored into the execute.js file which was imported right after the workshopper-exercise/filecheck module. You’ll find the code for the execute.js file below:

execute.js

// Based on workshopper-exercise/execute
var spawn = require('child_process').spawn
var path = require('path');
var fs = require('fs');
var after = require('after');
var xtend = require('xtend');

function execute (exercise, opts) {
  if (!opts) {
    opts = {};
  }

  exercise.addSetup(setup);
  exercise.addProcessor(processor);

  // override if you want to mess with stdout
  exercise.getStdout = function (type, child) {
    return child.stdout;
  };

  exercise.getSolutionFiles = function (callback) {
    var translated = path.join(this.dir, './solution_' + this.lang);
    var fallback = path.join(this.dir, './solution');

    checkPath(translated, function(err, list) {
      if (list && list.length > 0) {
        return callback(null, list);
      }

      checkPath(fallback, callback);
    });


    function checkPath(dir, callback) {
      fs.exists(dir, function (exists) {
        if (!exists) {
          return callback(null, []);
        }

        fs.readdir(dir, function (err, list) {
          if (err) {
            return callback(err);
          }

          list = list
            .filter(function (f) { return (/\.js$/).test(f) })
            .map(function (f) { return path.join(dir, f)});

          callback(null, list);
        });
      });
    }
  }

  return exercise;

  function setup(mode, callback) {
    this.submission = this.args[0];

    // default args, override if you want to pass special args to the
    // solution and/or submission, override this.setup to do this
    this.submissionArgs = Array.prototype.slice.call(1, this.args);
    this.solutionArgs = Array.prototype.slice.call(1, this.args);

    // edit/override if you want to alter the child process environment
    this.env = xtend(process.env);

    // set this.solution if your solution is elsewhere
    if (!this.solution) {
      this.solution = path.join(this.dir, './solution/solution.ts');
    }

    process.nextTick(callback);
  }


  function kill() {
    ;[ this.submissionChild, this.solutionChild ].forEach(function (child) {
      if (child && typeof child.kill == 'function') {
        child.kill();
      }
    });

    setTimeout(function () {
      this.emit('executeEnd')
    }.bind(this), 10)
  }


  function processor (mode, callback) {
    var ended = after(mode == 'verify' ? 2 : 1, kill.bind(this))

    // backwards compat for workshops that use older custom setup functions
    if (!this.solutionCommand) {
      this.solutionCommand = [ this.solution ].concat(this.solutionArgs);
    }

    if (!this.submissionCommand) {
      this.submissionCommand = [ this.submission ].concat(this.submissionArgs);
    }

    this.typescriptExec = path.resolve(__dirname, './node_modules/typescript/bin/tsc');

    this.submissionChild  = spawn(this.typescriptExec, this.submissionCommand, { env: this.env });
    this.submissionStdout = this.getStdout('submission', this.submissionChild)

    setImmediate(function () { // give other processors a chance to overwrite stdout
      this.submissionStdout.on('end', ended)
    }.bind(this))

    if (mode == 'verify') {
      this.solutionChild  = spawn(this.typescriptExec, this.solutionCommand, { env: this.env });
      this.solutionStdout = this.getStdout('solution', this.solutionChild)

      setImmediate(function () { // give other processors a chance to overwrite stdout
        this.solutionStdout.on('end', ended)
      }.bind(this))
    }

    process.nextTick(function () {
      callback(null, true);
    })
  }
}

module.exports = execute;

The most important sections of the execute.js file are the processor and setup callbacks.

The setup callback

The setup callback, used in the exercise.addSetup method call, permits you to add arguments to be passed to the solution and submission processes. In our setup callback, we only use the default arguments. We also specify our solution file in the line:

this.solution = path.join(this.dir, './solution/solution.ts')

We use solution.ts because we expect the user to submit a Typescript file which we’ll compare with this. If it were a SASS tutorial, for instance, we’d have to specify a solution.scss file instead.

The processor callback

The processor callback tells workshopper how to test the solution. In our case, we specify that we want to transpile a Typescript file using the tsc command:

this.typescriptExec = path.resolve(__dirname, './node_modules/typescript/bin/tsc');

this.submissionChild = spawn(this.typescriptExec, this.submissionCommand, { env: this.env });

The other sections of the execute.js file rarely vary between tutorials.

With these, we have our basic tutorial and one exercise set up. Unfortunately, we can’t view the exercise yet because we haven’t added it to the menu. We’ve done a lot in this section so we’ll add the exercise to the menu in the next section.

You can check your progress against this tag on Github.

Add exercise to menu

To add the exercise to the menu, we’ll need to modify the index.js file we created in a previous section:

index.js

var workshopper = require('workshopper-adventure');
var path  = require('path');
var typescripting  = workshopper({
  title: 'TYPESCRIPTING',
  exerciseDir: path.join(__dirname, 'exercises'),
  appDir: __dirname,
  languages: ['en'],
});

typescripting.addAll([
  'basictypes'
]);

module.exports = typescripting;

We’ve added the ‘basic types’ exercise to the menu via the addAll method. Our users should be able to select it now: test run

You can check your progress against this tag on Github.

Conclusion

Building command line tutorials has been made easier with the help of the Workshopper module. Once you get past the boilerplate code, it’s not difficult to add exercises. You can also add translations to your tutorial to make it more accessible to your users.

I’d recommend you try it out for your next Node.js workshop.

Ifeanyi Oraelosi

Ifeanyi Oraelosi

Making stuff to facilitate learning and creativity. Also, video game experiments.

comments powered by Disqus
rss facebook twitter github youtube mail spotify instagram linkedin google pinterest medium vimeo