UNPKG

spike-core

Version:

an opinionated static build tool, powered by webpack

367 lines (337 loc) 10.6 kB
/** * @module Spike */ const path = require('path') const fs = require('fs') const os = require('os') const mkdirp = require('mkdirp') const W = require('when') const node = require('when/node') const {exec} = require('child_process') const webpack = require('webpack') const rimraf = require('rimraf') const Sprout = require('sprout') const Joi = require('joi') const {EventEmitter} = require('events') const Config = require('./config') const {Error, Warning} = require('./errors') /** * @class Spike * @classdesc Creates a spike project instance and allows interaction with it * @param {Object} opts - documented in the readme */ class Spike extends EventEmitter { constructor (opts = {}) { super() this.config = new Config(opts, this) } /** * Compiles the spike project once * @fires Spike#error * @fires Spike#warning * @fires Spike#compile * @return {Object} compile id, webpack compiler */ compile () { const id = uuid() const compiler = webpack(this.config) compiler.run(compileCallback.bind(this, id)) return {id, compiler} } /** * Compiles a project and watches it, when a file changes, recompiles * @param {Object} opts - options to be passed to webpack.watch * @fires Spike#error * @fires Spike#warning * @fires Spike#compile * @return {Object} watch id, webpack watcher */ watch (opts = {}) { const id = uuid() this.compiler = webpack(this.config) const watcher = this.compiler.watch(opts, compileCallback.bind(this, id)) this.watcher = watcher return {id, watcher} } /** * Removes the public directory * @fires Spike#remove */ clean () { rimraf(this.config.output.path, () => { this.emit('remove', 'cleaned output directory') }) } /** * Creates a new spike project from a template and returns the instance * @static * @param {Object} options - options for new project * @param {String} options.root - path ro the root of the project * @param {String} [options.template] - name of the template to use * @param {Object} [options.locals] - locals provided to the sprout template * @param {EventEmitter} [options.emitter] - an event emitter for feedback * @param {Inquirer} [options.inquirer] - inquirer instance for CLI * @fires Spike#info * @fires Spike#error * @fires Spike#done */ static new (options) { // validate options const schema = Joi.object().keys({ root: Joi.string().required(), template: Joi.string(), locals: Joi.object(), emitter: Joi.func(), inquirer: Joi.func() }) const opts = Joi.validate(options, schema).value const emit = opts.emitter.emit.bind(opts.emitter) const sprout = initSprout(emit) // If a template option was passed, grab the source from sprout if possible // If not, use the default template options if (opts.template) { const tpl = sprout.templates[opts.template] if (tpl) { opts.src = tpl.src } else { return emit('error', `template "${opts.template}" has not been added to spike`) } } else { const conf = readConfig().defaultTemplate opts.template = conf.name opts.src = conf.src } // if the template doesn't exist, add it from the source let promise = W.resolve() if (!sprout.templates[opts.template]) { emit('info', `adding template "${opts.template}"`) promise = sprout.add(opts.template, opts.src) } // set up sprout options and iniquirer for prompts const sproutOptions = { locals: opts.locals, questionnaire: opts.inquirer } // run it promise .tap(() => emit('info', 'initializing template')) .then(sprout.init.bind(sprout, opts.template, opts.root, sproutOptions)) .tap(() => emit('info', 'installing production dependencies')) .then(npmInstall.bind(null, opts)) .then(() => new Spike({ root: opts.root })) .done( (instance) => { emit('done', instance) }, (err) => { emit('error', err) } ) } } /** * Generates a unique id for each compile run * @private */ function uuid () { return (Math.random().toString(16) + '000000000').substr(2, 8) } /** * Runs `npm install` from the command line in the project root * @private * @return {Promise.<String>} promise for the command output */ function npmInstall (opts) { const pkgPath = path.join(opts.root, 'package.json') if (!fs.existsSync(pkgPath)) { return } return node.call(exec, 'npm install --production', { cwd: opts.root }) } /** * Function to be executed after a compile finishes. Handles errors and emits * events as necessary. * @param {Number} id - the compile's id * @param {Error} [err] - if there was an error, it's here * @param {WebpackStats} stats - stats object from webpack * @fires Spike#error * @fires Spike#warning * @fires Spike#compile */ function compileCallback (id, err, stats) { if (err) { return this.emit('error', new Error({ id, err })) } // Webpack "soft errors" are classified as warnings in spike. An error is // an error. If it doesn't break the build, it's a warning. const cstats = stats.compilation if (cstats.errors.length) { this.emit('warning', new Warning({ id, err: cstats.errors[0] })) } /* istanbul ignore next */ if (cstats.warnings.length) { this.emit('warning', new Warning({ id, err: cstats.warnings[0] })) } this.emit('compile', {id, stats}) } /** * A set of functions for controlling new project templates */ Spike.template = { /** * Adds a template to spike's stash * @param {Object} options * @param {String} name - name of the template to add * @param {String} src - url from which the template can be `git clone`d * @param {EventEmitter} emitter - will return events to report progress * @fires emitter#info * @fires emitter#success * @fires emitter#error */ add: function (options = {}) { const schema = Joi.object().keys({ name: Joi.string().required(), src: Joi.string().required(), emitter: Joi.func().required() }) const {opts, emit, sprout} = this._init(options, schema) emit('info', 'adding template') sprout.add(opts.name, opts.src).done(() => { emit('success', `template "${opts.name}" added`) }, (err) => { emit('error', err) }) }, /** * Removes a template from spike's list * @param {Object} options * @param {String} name - name of the template to remove * @param {EventEmitter} emitter - will return events to report progress * @fires emitter#info * @fires emitter#success */ remove: function (options = {}) { const schema = Joi.object().keys({ name: Joi.string().required(), emitter: Joi.func().required() }) const {opts, emit, sprout} = this._init(options, schema) emit('info', 'removing template') sprout.remove(opts.name).done(() => { emit('success', `template "${opts.name}" removed`) }) }, /** * Sets a template as the default when creating projects with `Spike.new` * @param {Object} options * @param {String} name - name of the template to make default * @param {EventEmitter} [emitter] - will return events to report progress * @fires emitter#info * @fires emitter#success * @fires emitter#error */ default: function (options = {}) { const schema = Joi.object().keys({ name: Joi.string().required(), emitter: Joi.func().default({ emit: (x) => x }) }) const {opts, emit, sprout} = this._init(options, schema) const tpl = sprout.templates[opts.name] if (tpl) { writeConfig({ defaultTemplate: { name: tpl.name, src: tpl.src } }) const message = `template "${opts.name}" is now the default` emit('success', message) return message } else { const message = `template "${opts.name}" doesn't exist` emit('error', message) return message } }, /** * Sets a template as the default when creating projects with `Spike.new` * @param {EventEmitter} [emitter] - will return events to report progress * @fires emitter#info * @fires emitter#success */ list: function (options = {}) { const schema = Joi.object().keys({ emitter: Joi.func().default({ emit: (x) => x }) }) const {emit, sprout} = this._init(options, schema) emit('success', sprout.templates) return sprout.templates }, /** * Removes the primary spike config and all templates, like a clean install */ reset: function () { try { fs.unlinkSync(Spike.configPath) rimraf.sync(Spike.tplPath) } catch (_) { } }, /** * Internal utility function * @private */ _init: function (options, schema) { const opts = Joi.validate(options, schema).value const emit = opts.emitter.emit.bind(opts.emitter) const sprout = initSprout(emit) return {opts, emit, sprout} } } // path where spike stores defaults and new project templates Spike.configPath = path.join(os.homedir(), '.spike/config.json') Spike.tplPath = path.join(os.homedir(), '.spike/templates') Spike.globalConfig = readConfig module.exports = Spike /** * Ensures the folder structure is present and initializes sprout * @param {EventEmitter} - event emitter to report progress * @return {Sprout} initialized sprout instance */ function initSprout (emit) { try { fs.accessSync(Spike.tplPath) } catch (_) { emit('info', 'configuring template storage') mkdirp.sync(Spike.tplPath) } return new Sprout(Spike.tplPath) } /** * Reads spike's global configuration file * @return {Object} config values */ function readConfig () { ensureConfigExists() return JSON.parse(fs.readFileSync(Spike.configPath, 'utf8')) } /** * Writes an object to spike's global config * @param {Object} data - what you want to write to the file */ function writeConfig (data) { ensureConfigExists() fs.writeFileSync(Spike.configPath, JSON.stringify(data)) } /** * If spike's global config file doesn't exist, creates it with the default * template in place. */ function ensureConfigExists () { const defaultConfig = { id: uuid(), analytics: true, defaultTemplate: { name: 'base', src: 'https://github.com/static-dev/spike-tpl-base.git' } } try { fs.accessSync(Spike.tplPath) } catch (_) { mkdirp.sync(Spike.tplPath) } try { fs.accessSync(Spike.configPath) } catch (_) { fs.writeFileSync(Spike.configPath, JSON.stringify(defaultConfig)) } }