workshopper
Version:
A terminal workshop runner framework
542 lines (409 loc) • 15.2 kB
JavaScript
const argv = require('optimist').argv
, fs = require('fs')
, path = require('path')
, map = require('map-async')
, msee = require('msee')
, chalk = require('chalk')
, inherits = require('util').inherits
, EventEmitter = require('events').EventEmitter
/* jshint -W079 */
const showMenu = require('./exerciseMenu')
, showLanguageMenu = require('./languageMenu')
, print = require('./print-text')
, util = require('./util')
, i18n = require('./i18n')
/* jshint +W079 */
const defaultWidth = 65
function Workshopper (options) {
if (!(this instanceof Workshopper))
return new Workshopper(options)
EventEmitter.call(this)
var handled = false
, exercise
, mode = argv._[0]
if (typeof options != 'object')
throw new TypeError('need to provide an options object')
if (typeof options.name != 'string')
throw new TypeError('need to provide a `name` String option')
this.appName = options.name
this.appDir = util.assertDir(options, 'appDir')
this.exerciseDir = util.assertDir(options, 'exerciseDir', options.appDir, 'exercises')
this.globalDataDir = util.userDir('.config', 'workshopper')
this.dataDir = util.userDir('.config', this.appName)
util.assertFile(options, 'menuJson', options.exerciseDir, 'menu.json')
if (!options.languages) {
// In case a workshopper didn't define a any language
options.languages = ['en']
}
this.defaultLang = options.languages[0]
// optional
this.menuOptions = options.menu
// helpFile is additional to the usage in usage.txt
this.helpFile = options.helpFile
// optional
this.footerFile = options.footerFile === false
? []
: [options.footerFile, path.join(__dirname, './i18n/footer/{lang}.md')]
this.width = typeof options.width == 'number'
? options.width
: defaultWidth
// an `onComplete` hook function *must* call the callback given to it when it's finished, async or not
this.onComplete = typeof options.onComplete == 'function' && options.onComplete
this.exercises = require(options.menuJson).filter(function (e) {
return !/^\/\//.test(e)
})
try {
this.lang = i18n.chooseLang(
this.globalDataDir
, this.dataDir
, argv.l || argv.lang
, this.defaultLang
, options.languages
)
} catch (e) {
if (e instanceof TypeError) // In case the language couldn't be selected
console.log(e.message)
else
console.error(e.stack)
process.exit(1)
}
this.i18n = i18n.init(options, this.exercises, this.lang, this.globalDataDir)
this.__ = this.i18n.__
this.__n = this.i18n.__n
this.languages = this.i18n.languages
// backwards compatibility for title and subtitle
this.__defineGetter__('title', this.__.bind(this, 'title'))
this.__defineGetter__('subtitle', this.__.bind(this, 'subtitle'))
if (argv.v || argv.version || mode == 'version')
return console.log(
this.appName
+ '@'
+ require(path.join(this.appDir, 'package.json')).version
)
if (argv.h || argv.help || mode == 'help')
return this._printHelp()
this.current = this.getData('current')
if (options.menuItems && !options.commands)
options.commands = options.menuItems
if (Array.isArray(options.commands)) {
options.commands.forEach(function (item) {
if (mode == item.name
|| argv[item.name]
|| (item.short && argv[item.short])) {
handled = true
return item.handler(this)
}
}.bind(this))
if (handled)
return
this.commands = options.commands
}
if (mode == 'list') {
return this.exercises.forEach(function (name) {
console.log(this.__('exercise.' + name))
}.bind(this))
}
if (mode == 'current')
return console.log(this.__('exercise.' + this.current))
if (mode == 'select' || mode == 'print') {
var selected = argv._.length > 1 ? argv._.slice(1).join(' ') : this.current
if (/[0-9]+/.test(selected)) {
selected = this.exercises[parseInt(selected-1, 10)] || selected
} else {
selected = this.exercises.filter(function (exercise) {
return selected === this.__('exercise.' + exercise)
}.bind(this))[0] || selected;
}
onselect.call(this, selected)
return
}
if (mode == 'verify' || mode == 'run') {
exercise = this.current && this.loadExercise(this.current)
if (!this.current)
return error(this.__('error.exercise.none_active'))
if (!exercise)
return error(this.__('error.exercise.missing', {name: name}))
if (exercise.requireSubmission !== false && argv._.length == 1)
return error(this.__('ui.usage', {appName: this.appName, mode: mode}))
return this.execute(exercise, mode, argv._.slice(1))
}
if (argv._[0] == 'next') {
var remainingAfterCurrent = this.exercises.slice(this.exercises.indexOf(this.current))
var completed = this.getData('completed')
if (!completed)
return error(this.__('error.exercise.none_active') + '\n')
var incompleteAfterCurrent = remainingAfterCurrent.filter(function (elem) {
return completed.indexOf(elem) < 0
})
if (incompleteAfterCurrent.length === 0)
return console.log(this.__('error.no_uncomplete_left') + '\n')
return onselect.call(this, incompleteAfterCurrent[0])
}
if (mode == 'reset') {
this.reset()
return console.log(this.__('progress.reset', {title: this.__('title')}))
}
this.printMenu()
}
inherits(Workshopper, EventEmitter)
Workshopper.prototype.end = function (mode, pass, exercise, callback) {
exercise.end(mode, pass, function (err) {
if (err)
return error(this.__('error.cleanup', {err: err.message || err}))
setImmediate(callback || function () {
process.exit(pass ? 0 : -1)
})
}.bind(this))
}
// overall exercise fail
Workshopper.prototype.exerciseFail = function (mode, exercise) {
console.log('\n' + chalk.bold.red('# ' + this.__('solution.fail.title')) + '\n')
console.log(this.__('solution.fail.message', {name: this.__('exercise.' + exercise.name)}))
this.end(mode, false, exercise)
}
// overall exercise pass
Workshopper.prototype.exercisePass = function (mode, exercise) {
console.log('\n' + chalk.bold.green('# ' + this.__('solution.pass.title')) + '\n')
console.log(chalk.bold(this.__('solution.pass.message', {name: this.__('exercise.' + exercise.name)})) + '\n')
var done = function done () {
var completed = this.getData('completed') || []
, remaining
this.updateData('completed', function (xs) {
if (!xs)
xs = []
return xs.indexOf(exercise.name) >= 0 ? xs : xs.concat(exercise.name)
})
completed = this.getData('completed') || []
remaining = this.exercises.length - completed.length
if (remaining === 0) {
if (this.onComplete)
return this.onComplete(this.end.bind(this, mode, true, exercise))
else
console.log(this.__('progress.finished'))
} else {
console.log(this.__n('progress.remaining', remaining))
console.log(this.__('ui.return', {appName: this.appName}))
}
this.end(mode, true, exercise)
}.bind(this)
if (exercise.hideSolutions)
return done()
exercise.getSolutionFiles(function (err, files) {
if (err)
return error(this.__('solution.notes.load_error', {err: err.message || err}))
if (!files.length)
return done()
console.log(this.__('solution.notes.compare'))
function processSolutionFile (file, callback) {
fs.readFile(file, 'utf8', function (err, content) {
if (err)
return callback(err)
var filename = path.basename(file)
// code fencing is necessary for msee to render the solution as code
content = msee.parse('```js\n' + content + '\n```')
callback(null, { name: filename, content: content })
})
}
function printSolutions (err, solutions) {
if (err)
return error(this.__('solution.notes.load_error', {err: err.message || err}))
solutions.forEach(function (file, i) {
console.log(chalk.yellow(util.repeat('\u2500', 80)))
if (solutions.length > 1)
console.log(chalk.bold.yellow(file.name + ':') + '\n')
console.log(file.content.replace(/^\n/m, '').replace(/\n$/m, ''))
if (i == solutions.length - 1)
console.log(chalk.yellow(util.repeat('\u2500', 80)) + '\n')
}.bind(this))
done()
}
map(files, processSolutionFile, printSolutions.bind(this))
}.bind(this))
}
// single 'pass' event for a validation
function onpass (msg) {
console.log(chalk.green.bold('\u2713 ') + msg)
}
// single 'fail' event for validation
function onfail (msg) {
console.log(chalk.red.bold('\u2717 ') + msg)
}
Workshopper.prototype.execute = function (exercise, mode, args) {
// individual validation events
exercise.on('pass', onpass)
exercise.on('fail', onfail)
exercise.on('pass', this.emit.bind(this, 'pass', exercise, mode))
exercise.on('fail', this.emit.bind(this, 'fail', exercise, mode))
function done (err, pass) {
var errback
if (err) {
// if there was an error then we need to do this after cleanup
errback = function () {
error(this.__('error.exercise.unexpected_error', {mode: mode, err: (err.message || err) }))
}.bind(this)
}
if (mode == 'run' || err)
return this.end(mode, true, exercise, errback) // clean up
if (!pass)
return this.exerciseFail(mode, exercise)
this.exercisePass(mode, exercise)
}
exercise[mode](args, done.bind(this))
}
Workshopper.prototype.selectLanguage = function (lang) {
this.i18n.change(this.globalDataDir, this.dataDir, lang, this.defaultLang, this.i18n.languages)
this.lang = lang
this.printMenu()
}
Workshopper.prototype.printLanguageMenu = function () {
var menu = showLanguageMenu({
name : this.appName
, languages : this.i18n.languages
, lang : this.lang
, width : this.width
, menu : this.menuOptions
}, this.i18n)
menu.on('select', this.selectLanguage.bind(this))
menu.on('cancel', this.printMenu.bind(this))
menu.on('exit', this._exit.bind(this))
}
Workshopper.prototype._exit = function () {
process.exit(0)
}
Workshopper.prototype.printMenu = function () {
var menu = showMenu({
name : this.appName
, languages : this.i18n.languages
, width : this.width
, completed : this.getData('completed') || []
, exercises : this.exercises
, extras : this.commands && this.commands
.filter(function (item) {
return item.menu !== false
})
.map(function (item) {
return item.name.toLowerCase()
})
, menu : this.menuOptions
}, this.i18n)
menu.on('select', onselect.bind(this))
menu.on('exit', this._exit.bind(this))
menu.on('language', this.printLanguageMenu.bind(this))
menu.on('help', this._printHelp.bind(this))
if (this.commands) {
this.commands.forEach(function (item) {
menu.on('extra-' + item.name, function () {
item.handler(this)
}.bind(this))
}.bind(this))
}
}
Workshopper.prototype.getData = function (name) {
var file = path.resolve(this.dataDir, name + '.json')
try {
return JSON.parse(fs.readFileSync(file, 'utf8'))
} catch (e) {}
return null
}
Workshopper.prototype.updateData = function (id, fn) {
var json = {}
, file
try {
json = this.getData(id)
} catch (e) {}
file = path.resolve(this.dataDir, id + '.json')
fs.writeFileSync(file, JSON.stringify(fn(json)))
}
Workshopper.prototype.reset = function () {
fs.unlink(path.resolve(this.dataDir, 'completed.json'), function () {})
fs.unlink(path.resolve(this.dataDir, 'current.json'), function () {})
}
Workshopper.prototype.dirFromName = function (name) {
return util.dirFromName(this.exerciseDir, name)
}
Workshopper.prototype._printHelp = function () {
this._printUsage(print.localisedFile.bind(print, this.appName, this.appDir, this.helpFile, this.lang))
}
Workshopper.prototype._printUsage = function (callback) {
print.localisedFirstFile(this.appName, this.appDir, [
path.join(__dirname, './i18n/usage/{lang}.txt'),
path.join(__dirname, './i18n/usage/en.txt')
], this.lang, callback)
}
Workshopper.prototype.getExerciseMeta = function (name) {
if (!name)
return false
name = name.toLowerCase().trim()
var number
, dir
this.exercises.some(function (_name, i) {
if (_name.toLowerCase().trim() != name)
return false
number = i + 1
name = _name
return true
})
if (number === undefined)
return null
dir = this.dirFromName(name)
return {
name : name
, number : number
, dir : dir
, id : util.idFromName(name)
, exerciseFile : path.join(dir, './exercise.js')
}
}
Workshopper.prototype.loadExercise = function (name) {
var meta = this.getExerciseMeta(name)
, stat
, exercise
if (!meta)
return null
try {
stat = fs.statSync(meta.exerciseFile)
} catch (err) {
return error(this.__('error.exercise.missing_file', {exerciseFile: meta.exerciseFile}))
}
if (!stat || !stat.isFile())
return error(this.__('error.exercise.missing_file', {exerciseFile: meta.exerciseFile}))
exercise = require(meta.exerciseFile)
if (!exercise || typeof exercise.init != 'function')
return error(this.__('error.exercise.not_a_workshopper', {exerciseFile: meta.exerciseFile}))
exercise.init(this, meta.id, meta.name, meta.dir, meta.number)
return exercise
}
function error () {
var pr = chalk.bold.red
console.log(pr.apply(pr, arguments))
process.exit(-1)
}
function onselect (name) {
var exercise = this.loadExercise(name)
if (!exercise)
return error(this.__('error.exercise.missing', {name: name}))
console.log(
'\n ' + chalk.green.bold(this.__('title'))
+ '\n' + chalk.green.bold(util.repeat('\u2500', chalk.stripColor(this.__('title')).length + 2))
+ '\n ' + chalk.yellow.bold(this.__('exercise.' + exercise.name))
+ '\n ' + chalk.yellow.italic(this.__('progress.state', {count: exercise.number, amount: this.exercises.length}))
+ '\n'
)
this.current = exercise.name
this.updateData('current', function () {
return exercise.name
})
exercise.prepare(function (err) {
if (err)
return error(this.__('error.exercise.preparing', {err: err.message || err}))
exercise.getExerciseText(function (err, type, exerciseText) {
if (err)
return error(this.__('error.exercise.loading', {err: err.message || err}))
print.text(this.appName, this.appDir, type, exerciseText)
print.localisedFirstFile(this.appName, this.appDir, this.footerFile, this.lang)
}.bind(this))
}.bind(this))
}
Workshopper.prototype.error = error
Workshopper.prototype.print = print
module.exports = Workshopper