UNPKG

workshopper

Version:

A terminal workshop runner framework

542 lines (409 loc) 15.2 kB
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