productionline
Version:
An extendable class for creating common build pipelines.
1,497 lines (1,245 loc) • 38.4 kB
JavaScript
const TaskRunner = require('shortbus')
const path = require('path')
const fs = require('fs-extra')
const glob = require('glob')
const minimatch = require('minimatch')
const chalk = require('chalk')
const CLITable = require('cliui')
const localpackage = require('./package.json')
const Monitor = require('./Monitor')
const EventEmitter = require('events').EventEmitter
class FileManager {
constructor (filepath) {
Object.defineProperties(this, {
PRIVATE: {
enumerable: false,
configurable: false,
writable: true,
value: {
filepath,
content: null,
lines: null,
linecount: null,
lineindex: new Map(),
indexline: new Map(),
stats: new fs.Dirent()
}
}
})
// Make sure the file is readable
fs.accessSync(filepath, fs.constants.R_OK)
}
set content (value) {
this.PRIVATE.content = value
this.lines = null
this.linecount = null
}
get content () {
if (this.PRIVATE.content === null) {
this.PRIVATE.content = this.readSync()
}
return this.PRIVATE.content
}
get lines () {
if (this.PRIVATE.lines === null) {
this.processLines()
}
return this.PRIVATE.lines
}
get lineCount () {
if (this.PRIVATE.linecount === null) {
let lines = this.lines // eslint-disable-line
}
return this.PRIVATE.linecount || 0
}
get filename () {
return path.basename(this.PRIVATE.filepath)
}
processLines () {
this.PRIVATE.lines = {}
let lines = this.content.split(require('os').EOL)
let currentPosition = -1
lines.forEach((content, line) => {
currentPosition++
let lineEnd = currentPosition + content.length - (currentPosition === 0 ? 1 : 0)
if (lineEnd <= currentPosition) {
lineEnd = currentPosition + 1
}
this.PRIVATE.lines[line + 1] = content
let start = this.content.indexOf(content, currentPosition)
let end = start + content.length - (currentPosition === 0 ? 1 : 0)
this.PRIVATE.lineindex.set([start, end], line + 1)
this.PRIVATE.indexline.set(line + 1, [start, end])
currentPosition = end
})
this.PRIVATE.linecount = lines.length
}
readSync () {
return fs.readFileSync(this.PRIVATE.filepath).toString()
}
read () {
fs.readFile(this.PRIVATE.filepath, ...arguments)
}
getLine (number) {
return this.lines[number]
}
// Accepts any number of indexes as arguments
getLineByIndex () {
let indice = {}
// Forcibly calculate lines if they don't exist.
if (this.PRIVATE.lines === null) {
this.processLines()
}
// Sort index values, then get the line numbers
Array.from(arguments).sort().reverse().forEach(index => {
let line = null
let ranges = this.PRIVATE.lineindex.entries()
let range
while (line === null && !(range = ranges.next()).done) {
if (index >= range.value[0][0] && index <= range.value[0][1]) {
line = range.value[1]
indice[index] = line
}
}
})
return indice
}
// Accepts any number of lines as arguments
getLineIndexRange () {
let indice = {}
// Forcibly calculate lines if they don't exist.
if (this.PRIVATE.lines === null) {
this.processLines()
}
// Sort index values, then get the line numbers
Array.from(arguments).sort().reverse().forEach(line => {
indice[line] = this.PRIVATE.indexline.get(line)
})
return indice
}
getSnippet (start, end) {
let snippet = []
for (let i = start; i <= end; i++) {
snippet.push(this.lines[i])
}
return snippet.join('\n')
}
/**
* Similar to Array#forEach, except it passes the line content, line number, and full line object.
* @param {Function} fn
*/
forEachLine (fn) {
for (let line = 1; line <= this.lineCount; line++) {
fn(this.PRIVATE.lines[line], line, this.PRIVATE.lines)
}
}
}
/**
* @class ProductionLine.Builder
* A queueing and execution system.
* @extends EventEmitter
*/
class Builder extends EventEmitter {
constructor (cfg = {}) {
super()
/**
* @cfg {boolean} [checkForUpdates=true]
* Check for updates to the module.
*/
this.CHECKFORUPDATES = typeof cfg.checkForUpdates === 'boolean' ? cfg.checkForUpdates : true
this.tasks = new TaskRunner()
this.PKG = require(path.join(process.cwd(), 'package.json'))
// Metadata
this.APPVERSION = this.PKG.version
/**
* @cfg {string} [header]
* A standard header to be applied to files.
* Defaults to
* ```sh
* Copyright (c) <DATE> <AUTHOR> and contributors. All Rights Reserved.
* Version X.X.X built on <DATE>.
* ```
* If a project name is available in the local package.json, this will default to:
*
* ```
* <PROJECT NAME> vX.X.X generated on <DATE>.
* Copyright (c) <DATE> <AUTHOR> and contributors. All Rights Reserved.
* [LICENSE: <LICENSE TEXT>]
* ```
* _Notice the optional license._ If a license is supplied in the package.json,
* the it will be applied to the header. This only applies the name of the license
* (as i.e. license attribute of package.json). It does not read a license file
* into the header.
*/
this.HEADER = cfg.header || `Copyright (c) ${(new Date()).getFullYear()} ${this.author} and contributors. All Rights Reserved.${this.package.hasOwnProperty('license') ? '\nLICENSE: ' + this.package.license : ''}`
if (this.name !== 'Untitled') {
this.HEADER = `${this.name} v${this.version} generated on ${(new Date().toDateString())}.\n` + this.HEADER
} else {
this.HEADER = `${this.HEADER}\nVersion ${this.version} built on ${(new Date().toDateString())}.`
}
/**
* @cfg {string} [footer]
* A standard footer to be applied to files. Defaults to blank.
*/
this.FOOTER = null
this.COLORS = {
failure: chalk.bold.rgb(214, 48, 49),
warn: chalk.bold.rgb(225, 112, 85),
info: chalk.rgb(116, 185, 255),
log: chalk.rgb(223, 230, 233),
highlight: chalk.bold.rgb(232, 67, 147),
success: chalk.bold.rgb(85, 239, 196),
subtle: chalk.rgb(99, 110, 114),
verysubtle: chalk.rgb(45, 52, 54)
}
// Filepaths
/**
* @cfg {string} [source=./src]
* The root directory containing source files.
*/
this.SOURCE = path.resolve(cfg.source || './src')
/**
* @cfg {string} [output=./dist]
* The root directory where output files will be stored.
* This will be created if it does not already exist.
*/
this.OUTPUT = path.resolve(cfg.output || './dist')
/**
* @cfg {string} [assets=./assets]
* The root directory containing non-buildable assets such as multimedia.
*/
this.ASSETS = cfg.assets || [
'./assets'
]
if (typeof this.ASSETS === 'string') {
this.ASSETS = [this.ASSETS]
}
/**
* @cfg {string} [ignoredList=node_modules]
* A list of directories to ignore.
*/
this.IGNOREDLIST = cfg.ignoredList || [
'node_modules/**/*'
]
// Find .gitignore and .buildignore. Add them to the ignored list.
this.ignoreFile(path.join(process.cwd(), '.gitignore'))
this.ignoreFile(path.join(process.cwd(), '.buildignore'))
try {
if (cfg.ignore) {
if (!Array.isArray(cfg.ignore)) {
cfg.ignore = [cfg.ignore]
}
this.IGNOREDLIST = this.IGNOREDLIST.concat(cfg.ignore)
}
} catch (e) {}
// Helper tool for custom logging.
this.joinArguments = args => {
let out = []
for (let i = 0; i < args.length; i++) {
out.push(args[i])
}
return out.join(' ')
}
let width = 15
// Initialize tasks.
Object.defineProperties(this, {
prepareBuild: {
enumerable: false,
writable: true,
configurable: true,
value: () => {
this.tasks.add('Preparing Build', next => {
let ui = new CLITable()
ui.div({
text: this.COLORS.info(`Running ${localpackage.name} v${localpackage.version} for ${this.PKG.name}`),
border: false,
padding: [1, 0, 1, 5]
})
ui.div({
text: chalk.bold('Source:'),
width,
padding: [0, 0, 0, 5]
}, {
text: this.SOURCE
})
ui.div({
text: chalk.bold('Assets:'),
width,
padding: [0, 0, 0, 5]
}, {
text: this.ASSETS.map(asset => {
return asset.indexOf(path.join(this.SOURCE, '..')) < 0
? path.join(this.SOURCE, asset)
: asset
}).join('--')
})
ui.div({
text: chalk.bold('Output:'),
width,
padding: [0, 0, 0, 5]
}, {
text: this.OUTPUT
})
ui.div({
text: this.COLORS.subtle('Ignored:'),
width,
padding: [1, 0, 1, 5]
}, {
text: this.COLORS.subtle(this.IGNOREDLIST.join(', ')),
padding: [1, 0, 1, 0]
})
this.checkForUpdate(() => {
console.log(ui.toString())
next()
})
})
}
},
LOCAL_MONITOR: {
enumerable: false,
configurable: false,
writable: true,
value: null
},
COMMANDS: {
enumerable: false,
configurable: false,
writable: true,
value: cfg.commands || null
},
CLI_ARGUMENTS: {
enumerable: false,
configurable: false,
writable: true,
value: null
},
CURRENT_STEP: {
enumerable: false,
configurable: false,
writable: true,
value: 0
},
TIMER: {
enumerable: false,
configurable: false,
writable: true,
value: {
total: null,
markers: new Map()
}
},
REPORT: {
enumerable: false,
configurable: false,
writable: true,
value: []
},
NOTIFIED_OF_UPDATE: {
enumerable: false,
configurable: false,
writable: true,
value: false
},
MODULE_NAME: {
enumerable: false,
configurable: false,
writable: true,
value: path.basename(__dirname)
},
/**
* @property {Class} TaskRunner
* A [Shortbus](https://github.com/coreybutler/shortbus) task runner.
*/
TaskRunner: {
enumerable: true,
configurable: false,
writable: false,
value: TaskRunner
},
minimatch: {
enumerable: false,
configurable: false,
writable: false,
value: minimatch
}
})
}
get package () {
return this.PKG
}
get author () {
// No author?
if (!this.PKG.hasOwnProperty('author')) {
return require('os').userInfo().username
}
// Author specified as a string
if (typeof this.PKG.author === 'string') {
return this.PKG.author
}
// No name?
if (!this.PKG.author.hasOwnProperty('name')) {
return require('os').userInfo().username
}
// Has a name
return this.PKG.author.name
}
get name () {
if (this.PKG.hasOwnProperty('name')) {
return this.PKG.name
}
return 'Untitled'
}
get version () {
return this.APPVERSION
}
set header (value) {
if (value === null || value === undefined) {
delete this.HEADER
return
}
this.HEADER = value
}
set footer (value) {
if (value === null || value === undefined) {
delete this.FOOTER
return
}
this.FOOTER = value
}
get source () {
return this.SOURCE
}
set source (value) {
let oldpath = this.SOURCE
let newpath = path.resolve(value)
if (oldpath === newpath) {
return
}
try {
fs.accessSync(newpath, fs.constants.R_OK)
} catch (e) {
this.warn(`SOURCE DIRECTORY NOT FOUND: "${newpath}"`)
}
this.SOURCE = newpath
this.emit('source.updated', {
old: oldpath,
new: newpath
})
}
get output () {
return this.OUTPUT
}
set output (value) {
let oldpath = this.OUTPUT
let newpath = path.resolve(value)
if (oldpath === newpath) {
return
}
try {
fs.accessSync(newpath, fs.constants.R_OK)
} catch (e) {
this.warn(`OUTPUT DIRECTORY NOT FOUND: "${newpath}"`)
}
this.OUTPUT = newpath
this.emit('output.updated', {
old: oldpath,
new: newpath
})
}
set destination (value) {
this.output = path.resolve(value)
}
get destination () {
return this.OUTPUT
}
set assets (value) {
this.ASSETS = typeof value === 'string' ? [value] : value
}
get assets () {
return this.ASSETS
}
get Table () {
return CLITable
}
/**
* Set the list of paths to ignore (supports glob patterns).
* If you only want to add to the list, use #ignorePath instead.
* By default, `node_modules` is ignored, as well as anything
* in the ``.gitignore` file (if it exists) or a `.buildignore`
* file (if it exists).
* @param {Array} paths
* An array of paths to ignore in the build process.
*/
set ignore (value) {
this.IGNOREDLIST = value
}
/**
* Provides a reference to the watcher.
*/
get monitor () {
return this.LOCAL_MONITOR
}
/**
* @property {Boolean}
* @readonly
* Indicates the builder is in "watch" mode (monitoring the file system)
*/
get monitoring () {
return this.LOCAL_MONITOR !== null
}
/**
* @property {array}
* @readonly
* Returns the CLI arguments passed to the builder.
*/
get cliarguments () {
return this.CLI_ARGUMENTS
}
get File () {
return FileManager
}
get SemanticVersion () {
return require('semver')
}
// Retrieves the latest version number for the specified module.
checkModuleVersion (moduleName, callback) {
require('child_process').exec(`npm info ${moduleName} --json`, (err, data) => {
if (err) {
return callback(err)
}
try {
callback(null, JSON.parse(data).version)
} catch (e) {
callback(e)
}
})
}
checkForUpdate (callback) {
if (this.CHECKFORUPDATES && !this.NOTIFIED_OF_UPDATE) {
this.NOTIFIED_OF_UPDATE = true
this.checkModuleVersion(this.MODULE_NAME, (err, latestVersion) => {
if (!err) {
let currentVersion = this.version
if (this.MODULE_NAME !== this.name) {
currentVersion = require(path.join(process.cwd(), 'node_modules', this.MODULE_NAME, 'package.json')).version
}
if (this.SemanticVersion.lt(currentVersion, latestVersion)) {
console.log(this.COLORS.warn(`\n ** An update for ${this.MODULE_NAME} is available (${currentVersion} ==> `) + this.COLORS.success(latestVersion) + this.COLORS.warn(') **\n'))
}
}
callback && callback()
})
} else {
callback && callback()
}
}
identifyOutdatedModules (type = 'all', callback) {
if (this.CHECKFORUPDATES && this.PKG) {
let pkgModules = {}
let updateTasks = new TaskRunner()
let list = []
if ((type === 'all' || type === 'production') && this.PKG.hasOwnProperty('dependencies')) {
list = Object.keys(this.PKG.dependencies)
}
if ((type === 'all' || type === 'development') && this.PKG.hasOwnProperty('devDependencies')) {
list = list.concat(Object.keys(this.PKG.devDependencies))
}
list.forEach(mod => {
updateTasks.add(cont => {
this.checkModuleVersion(mod, (err, version) => {
let currentVersion = require(path.join(process.cwd(), 'node_modules', mod, 'package.json')).version
if (!err && this.SemanticVersion.lt(currentVersion, version)) {
pkgModules[mod] = {
current: currentVersion,
latest: version
}
}
cont()
})
})
})
updateTasks.on('complete', () => {
let mods = Object.keys(pkgModules)
if (mods.length > 0) {
let ui = new this.Table()
ui.div({
text: chalk.bold('Outdated Module'),
width: 20,
padding: [0, 0, 0, 3]
}, {
text: chalk.bold('Current'),
width: 10,
padding: [0, 0, 0, 0],
align: 'right'
}, {
text: '',
width: 5,
padding: [0, 1, 0, 1]
}, {
text: chalk.bold('Latest'),
width: 10,
padding: [0, 0, 0, 0],
align: 'left'
})
mods.forEach(mod => {
ui.div({
text: chalk.bold(this.COLORS.warn(mod)),
width: 20,
padding: [0, 0, 0, 3]
}, {
text: chalk.bold(this.COLORS.warn(pkgModules[mod].current)),
width: 10,
padding: [0, 0, 0, 0],
align: 'right'
}, {
text: this.COLORS.verysubtle('==>'),
width: 5,
padding: [0, 1, 0, 1]
}, {
text: chalk.bold(this.COLORS.success(pkgModules[mod].latest)),
width: 10,
padding: [0, 0, 0, 0],
align: 'left'
})
})
console.log(ui.toString())
}
})
updateTasks.run()
}
}
/**
* Ignore the contents of the specified file.
* This is automatically done for `.buildignore` and `.gitignore`.
* @param {[type]} file [description]
* @return {[type]} [description]
*/
ignoreFile (file) {
try {
this.IGNOREDLIST = this.IGNOREDLIST.concat(fs.readFileSync(path.resolve(file)).toString()
.replace(/#.*/gi, '')
.split(require('os').EOL)
.filter(glob => {
if (glob.trim().charAt(0) === '!') {
return false
}
return glob.trim().length > 0
}))
} catch (e) {}
}
/**
* A rounding method (like Math.round) that rounds to
* a specific number of decimal points.
* @param {number} number
* A float (decimal) number.
* @param {number} precision
* The number of decimal places.
* @return {number}
*/
round (number, precision) {
const factor = Math.pow(10, precision)
return Math.round(number * factor) / factor
}
// Returns the minimum number decimal places required to
// show a non-zero result.
minSignificantFigures () {
let min = 0
for (let i = 0; i < arguments.length; i++) {
let value = Math.abs(arguments[i]).toString().split('.').pop()
for (let x = 0; x < value.length; x++) {
if (value.charAt(x) !== '0') {
min = x > min ? x : min
break
}
}
}
return min + 1
}
/**
* Empty the output directory.
*/
clean () {
fs.emptyDir(this.OUTPUT)
}
/**
* This adds directories and/or files to the list of ignored files,
* where as setting the ignore property overrides the whole list.
* @param {String|Array} paths
* The path(s) to add to the list of ignored paths.
*/
ignorePath (dir) {
if (typeof dir === 'string') {
this.IGNOREDLIST.push(dir)
} else if (dir.length > 0) {
this.IGNOREDLIST = this.IGNOREDLIST.concat(dir)
}
}
/**
* Retrieves all files (recursively) within a directory.
* Supports glob patterns.
* @param {string} directory
* The directory to walk.
* @return {Array}
* An array containing the absolute path of each file in the directory.
*/
walk (directory, ignore = []) {
if (!directory) {
return []
}
let ignored = this.IGNOREDLIST.concat(ignore)
// Support globbing
if (glob.hasMagic(directory)) {
let root = './'
if (directory.startsWith(this.SOURCE)) {
root = this.SOURCE
directory = directory.replace(this.SOURCE, '')
} else if (directory.startsWith(this.OUTPUT)) {
root = this.OUTPUT
directory = directory.replace(this.OUTPUT, '')
}
return glob.sync(directory, {
cwd: root,
root,
ignore: ignored
})
}
// Walk the directory without globbing
let files = []
fs.readdirSync(directory).forEach(dir => {
let process = true
for (let i = 0; i < ignored.length; i++) {
if (minimatch(path.join(directory, dir), `/**/${ignored[i]}`)) {
process = false
break
}
}
if (process) {
if (fs.statSync(path.join(directory, dir)).isDirectory()) {
files = files.concat(this.walk(path.join(directory, dir)))
} else {
files.push(path.join(directory, dir))
}
}
})
return files
}
/**
* Read a file and autoconvert bytecode to a string.
* @param {String} filepath
* Absolute path of the input file.
* @param {Function} callback
* The content of the file is passed as the only attribute to the callback.
* If an error occurs, it is thrown.
*/
readFile (filepath, callback) {
fs.readFile(filepath, (err, content) => {
if (err) {
throw err
}
callback(content.toString().trim())
})
}
/**
* Read a file and autoconvert bytecode to a string.
* @param {String} filepath
* Absolute path of the input file.
* @return {String}
* The content of the file.
*/
readFileSync (filepath) {
return fs.readFileSync(filepath).toString().trim()
}
/**
* Write a file to disk.
* Almost the same as fs.writeFileSync (i.e. it overwrites),
* except if the parent directory does not exist, it's created.
* Accepts the same parameters as fs.writeFileSync.
*/
writeFileSync () {
fs.outputFileSync(...arguments)
}
/**
* Create the same path in the output directory as the input directory.
* @param {string} inputFilepath
* The path to mimic in the output directory.
* @return {string}
* The corresponding output path.
*/
outputDirectory (inputFilepath) {
let p = path.parse(inputFilepath)
inputFilepath = path.join(p.root, p.dir, p.base)
if (path.normalize(path.resolve(inputFilepath)) !== path.normalize(inputFilepath)) {
inputFilepath = path.join(this.OUTPUT, inputFilepath)
}
return inputFilepath.replace(this.SOURCE, this.OUTPUT)
}
/**
* Opposite of the output() method.
* @param {string} outputFilepath
* The output path to mimic in the source/input directory.
* @return {string}
* The corresponding input path.
*/
localDirectory (outFilepath) {
return outFilepath.replace(this.SOURCE, '').replace(this.OUTPUT, '')
}
/**
* The path, stripped of the preceding source/destination path.
* This is promarily used to create a _relative_ path.
* @param {String} path
* Abosulute/full path of the file.
* @return {String}
* The relative path from either the source or destination.
*/
relativePath (filepath) {
return ('./' + filepath.replace(this.SOURCE, '').replace(this.OUTPUT, '').trim()).replace(/\/{2,100}/, '/')
}
/**
* Apply text as a header to a file. This injects a comment at the top of
* the file, based on the content supplied to #header.
* @param {String} code
* The code/content to inject the header above.
* @param {String} [type='sh']
* The type of file. Supported values include `sh`, `sql`, `js`, `css`, and `html`.
* @return {String}
* Returns the code with the header content applied.
*/
applyHeader (code, type = 'js') {
if (!this.HEADER) {
return code
}
let msg = this.HEADER.split('\n')
switch (type.trim().toLowerCase()) {
case 'htm':
case 'html':
return '<!--\n' + msg.join('\n') + '\n-->\n' + code
case 'css':
case 'js':
if (msg.length === 1) {
return `// ${msg[0]}\n${code}`
}
return '/**\n' + msg.map(line => ` * ${line}`).join('\n') + '\n */\n' + code
case 'sql':
if (msg.length === 1) {
return `-- ${msg[0]}\n${code}`
}
return '/*\n' + msg.join('\n') + '\n*/\n' + code
case 'sh':
return msg.map(line => `# ${line}`).join('\n')
}
}
/**
* Apply text as a header to a file. This injects a comment at the top of
* the file, based on the content supplied to #footer.
* @param {String} code
* The code/content to inject the footer below.
* @param {String} [type='sh']
* The type of file. Supported values include `sh`, `sql`, `js`, `css`, and `html`.
* @return {String}
* Returns the code with the header content applied.
*/
applyFooter (code, type = 'js') {
if (!this.FOOTER) {
return code
}
let msg = this.FOOTER.split('\n')
switch (type.trim().toLowerCase()) {
case 'htm':
case 'html':
return code + '\n<!--\n' + msg.join('\n') + '\n-->\n'
case 'css':
case 'js':
if (msg.length === 1) {
return `${code}\n// ${msg[0]}\n`
}
return code + '\n/**\n' + msg.map(line => ` * ${line}`).join('\n') + '\n */\n'
case 'sql':
if (msg.length === 1) {
return `${code}\n-- ${msg[0]}\n`
}
return code + '\n/*\n' + msg.join('\n') + '\n*/\n'
case 'sh':
return msg.map(line => `# ${line}`).join('\n')
}
}
// Writes a file, guaranteeing the specified output directory exists.
writeFile (filepath, content, callback) {
fs.ensureDir(path.dirname(filepath), () => fs.writeFile(...arguments))
}
/**
* An asynchronous method to copy a source file to the output.
* @param {String} filepath
* The relative path (from source) of the file to copy to the output directory.
* @param {Function} callback
*/
copyToOutput (filepath, callback) {
let sourcePath = (path.join(this.SOURCE, filepath).replace(/\\/gi, '/')).replace(new RegExp(`(${this.SOURCE.replace(/\\/gi, '/')}/?){2,100}`), this.SOURCE + '/').replace(/\//gi, path.sep)
let outputPath = this.outputDirectory(sourcePath)
fs.copy(sourcePath, outputPath, callback)
}
/**
* Add a custom named task.
*
* ```js
* ProductionLine.customStep(function (next) {
* // .. do something ..
* next() // Advances to the next step.
* })
* ```
* @param {string} [stepName]
* An optional argument containing the step name.
* @param {function} callback
* The function containing the logic of the step.
* @param {function} callback.next
* The method to call when to complete an asynchronous step.
*/
addTask () {
this.tasks.add(...arguments)
}
cli () {
// Support commands
let args = process.argv.slice(2)
this.CLI_ARGUMENTS = args
if (args.length > 0 && this.COMMANDS !== null) {
if (this.COMMANDS.hasOwnProperty(args[0])) {
if (typeof this.COMMANDS[args[0]] !== 'function') {
return console.log(`${args[0]} flag does not have a valid function associated with it.`)
}
this.COMMANDS[args[0]].apply(this, args)
} else if (this.COMMANDS.hasOwnProperty('default') && typeof this.COMMANDS.default === 'function') {
this.COMMANDS.default.apply(this, args)
}
}
}
/**
* An overridable method that can be used to add tasks before all other tasks.
*/
before () {
// Validate source
try {
fs.accessSync(this.SOURCE, fs.constants.F_OK)
} catch (e) {
this.failure(` CANNOT FIND SOURCE DIRECTORY: "${this.SOURCE}"`)
}
// Validate assets
if (this.ASSETS !== null && this.ASSETS.length > 0) {
this.ASSETS.forEach((assetDirectory, i) => {
this.ASSETS[i] = path.resolve(assetDirectory)
try {
fs.accessSync(this.ASSETS[i], fs.constants.F_OK)
} catch (e) {
this.warn(` CANNOT FIND ASSET DIRECTORY: "${this.ASSETS[i]}"`)
}
})
}
}
/**
* An overridable method that can be used to add tasks after all other tasks.
*/
after () {}
/**
* Run the all of the tasks in the production/assembly line.
* @param {Boolean} [sequential=true]
* By default, all tasks are run in sequential order (one after the other).
* This can be set to `false` to run them all in parallel. Remember, running
* in parallel does not guarantee an order, so it is entirely possible to
* run all tasks and then have the #clean task wipe everything out. This
* option is here specifically for build pipelines that are designed for
* parallel processing.
*/
stepStarted (step) {
if (step.name !== '::EXECUTIONREPORT::') {
this.CURRENT_STEP++
let ui = new CLITable()
ui.div({
text: this.CURRENT_STEP,
width: 3,
padding: [0, 0, 0, 2]
}, {
text: ')',
width: 2
}, {
text: this.COLORS.log(`${step.name}`)
})
console.log(ui.toString())
this.startTime(step.name || `STEP ${step.number}`)
this.emit('step.started', step)
}
}
complete (callback) {
let ui = new CLITable()
ui.div({
text: this.COLORS.log('Complete.'),
padding: [1, 2, 1, 2]
})
console.log(ui.toString())
// Fire the callback if it exists
callback && callback()
// Trigger the completion event.
this.emit('complete')
this.tasks.removeAllListeners()
}
startTimer () {
this.startTime('::PRODUCTIONLINE_START::')
}
startTime (label) {
if (!label || (typeof label !== 'string' && typeof label !== 'number')) {
throw new Error('startTime method requires a label argument.')
}
this.TIMER.markers.set(label, {
start: new Date(),
mark: process.hrtime(),
get end () {
return new Date(this.start.getTime() + (this.duration * 1000))
},
get duration () {
let diff = process.hrtime(this.mark)
// Convert nanoseconds to seconds
return ((diff[0] * 1e9) + diff[1]) / 1000000000 // 1000000 nanoseconds per millisecond
}
})
}
timeSince (label) {
let marker = this.TIMER.markers.get(label)
if (marker === null) {
throw new Error(`"${label}" does not exist in the timer.`)
}
return marker.duration
}
run (sequential = true, callback) {
if (typeof sequential === 'function') {
callback = sequential
sequential = true
}
let queue = new TaskRunner()
// Check for internet connection
queue.add(cont => {
let checkComplete = false
require('dns').resolve('npmjs.org', err => {
if (checkComplete) {
return
}
checkComplete = true
if (err) {
this.highlight('Could not connect to npmjs.org. Cannot check for updated modules.')
this.CHECKFORUPDATES = false
}
cont()
})
setTimeout(() => {
if (!checkComplete) {
checkComplete = true
this.CHECKFORUPDATES = false
this.highlight('Could not connect to npmjs.org.')
cont()
}
}, 2500)
})
queue.add(cont => {
this.prepareBuild()
this.startTimer()
this.before()
this.CURRENT_STEP = 0
this.tasks.on('stepstarted', step => this.stepStarted(step))
this.tasks.on('stepcomplete', step => {
if (step.name !== '::EXECUTIONREPORT::') {
let label = step.name || `STEP ${step.number}`
let timer = this.TIMER.markers.get(label)
let duration = timer.duration
this.REPORT.push({
label,
number: step.number,
start: timer.start,
end: new Date(timer.start.getTime() + (duration * 1000)),
duration
})
this.emit('step.complete', step)
}
})
this.tasks.on('complete', () => this.complete(callback))
// "Before" tasks are applied in the constructor.
if (!this.monitoring) {
this.cli()
}
this.after()
this.tasks.add('::EXECUTIONREPORT::', () => {
this.TIMER.total = this.timeSince('::PRODUCTIONLINE_START::')
this.verysubtle(`\n Process completed in ${this.TIMER.total} seconds.\n\n`)
})
this.tasks.run(sequential)
})
queue.run(true)
}
displayReport () {
let report = this.report
let ui = new this.Table()
let width = 15
ui.div({
text: chalk.bold(this.COLORS.info(`${report.name} v${report.version} Execution Report`.toUpperCase())),
padding: [1, 0, 0, 3]
})
ui.div({
text: this.COLORS.verysubtle(`Ran ${report.taskCount} task${report.taskCount !== 1 ? 's' : ''} for ${report.duration} seconds (from ${report.start.toLocaleTimeString()} to ${report.end.toLocaleTimeString()}).`),
padding: [0, 0, 1, 3]
})
ui.div({
text: 'Source:',
width,
padding: [0, 0, 0, 3]
}, {
text: this.SOURCE
})
ui.div({
text: 'Output:',
width,
padding: [0, 0, 0, 3]
}, {
text: this.OUTPUT
})
ui.div({
text: 'Assets:',
width,
padding: [0, 0, 0, 3]
}, {
text: this.ASSETS.map(asset => path.join(this.SOURCE, asset)).join('\n')
})
ui.div({
text: this.COLORS.subtle('Ignored:'),
width,
padding: [1, 0, 1, 3]
}, {
text: this.COLORS.subtle(this.IGNOREDLIST.join(', ')),
padding: [1, 0, 1, 0]
})
ui.div({
text: chalk.bold(this.COLORS.info('TASK EXECUTION SUMMARY:')),
padding: [1, 0, 0, 3]
})
let sigfigs = this.minSignificantFigures.apply(this, report.tasks.map(step => step.duration))
sigfigs = sigfigs < 2 ? 2 : sigfigs
report.tasks.forEach(step => {
let duration = step.duration
ui.div({
text: step.number,
width: 3 + (report.tasks.length > 99 ? 3 : (report.tasks.length > 9 ? 2 : 1)),
align: 'right',
padding: [1, 0, 1, 3]
}, {
text: ')',
width: 2,
padding: [1, 1, 0, 0]
}, {
text: chalk.bold(step.label),
width: 45,
padding: [1, 0, 1, 0]
}, {
text: this.COLORS[duration > 2 ? (duration > 10 ? (duration > 20 ? 'highlight' : 'warn') : 'subtle') : 'verysubtle'](`${this.round(duration, sigfigs)} seconds.`),
width: 20,
padding: [1, 0, 1, 3]
})
})
console.log(ui.toString() + '\n\n')
}
get report () {
let report = { tasks: [] }
let monitor = this.TIMER.markers.get('::PRODUCTIONLINE_START::')
report.start = monitor.start
report.end = monitor.end
report.duration = monitor.duration
this.REPORT.forEach(step => report.tasks.push(step))
report.name = this.name
report.version = this.version
report.source = this.SOURCE
report.output = this.OUTPUT
report.assets = this.ASSETS
report.ignored = this.IGNOREDLIST
report.taskCount = report.tasks.length
report.createDate = new Date()
return report
}
/**
* Watch the source directory for changes. Any time a change occurs,
* the callback is executed. Running this will prevent the build from
* exiting, so it should only be used for development automation.
* @param {Function} callback
* The method to execute when a file change is detected.
* @param {String} callback.action
* The type of action detected. This will be `create`, `update`, or `delete`.
* @param {String} callback.path
* The absolute path of the file or directory that changed.
* @return {Object}
* Returns the monitoring object (chokidar). This has a `close()` method to
* stop watching.
* @fires watch
* Triggered when the monitor starts. Sends a chokidar instance as a payload.
*/
watch (callback) {
if (this.LOCAL_MONITOR === null) {
// Intentionally delay the start of the watch so the builder initializes.
setTimeout(() => {
this.LOCAL_MONITOR = new Monitor(this, callback)
this.LOCAL_MONITOR.on('ready', () => {
this.emit('watch', this.LOCAL_MONITOR)
this.verysubtle(` Monitoring ${this.SOURCE} for changes. Press ctrl+c to exit.\n`)
})
this.LOCAL_MONITOR.on('error', e => this.failure(e))
}, 100)
}
}
unwatch (callback) {
if (this.LOCAL_MONITOR !== null) {
this.LOCAL_MONITOR.stop()
this.LOCAL_MONITOR = null
this.emit('unwatch')
}
}
/**
* A special color-coded (red) console logger. Behaves the same as `console.log`
*/
failure () {
console.log(this.COLORS.failure(this.joinArguments(arguments)))
}
/**
* A special color-coded (orange) console logger. Behaves the same as `console.log`
*/
warn () {
console.log(this.COLORS.warn(this.joinArguments(arguments)))
}
/**
* A special color-coded (light blue) console logger. Behaves the same as `console.log`
*/
info () {
console.log(this.COLORS.info(this.joinArguments(arguments)))
}
/**
* A special color-coded (almost white) console logger. Behaves the same as `console.log`
*/
log () {
console.log(this.COLORS.log(this.joinArguments(arguments)))
}
/**
* A special color-coded (bright pink) console logger. Behaves the same as `console.log`
*/
highlight () {
console.log(this.COLORS.highlight(this.joinArguments(arguments)))
}
/**
* A special color-coded (tea green) console logger. Behaves the same as `console.log`
*/
success () {
console.log(this.COLORS.success(this.joinArguments(arguments)))
}
/**
* A special color-coded (gray) console logger. Behaves the same as `console.log`
*/
subtle () {
console.log(this.COLORS.subtle(this.joinArguments(arguments)))
}
/**
* A special color-coded (dark gray) console logger. Behaves the same as `console.log`
*/
verysubtle () {
console.log(this.COLORS.verysubtle(this.joinArguments(arguments)))
}
}
module.exports = Builder