feri
Version:
An easy to use build tool for web files.
1,424 lines (1,065 loc) • 48.4 kB
JavaScript
//----------------
// Includes: Self
//----------------
const color = require('./color.js')
const shared = require('./2 - shared.js')
const config = require('./3 - config.js')
const functions = require('./4 - functions.js')
//----------
// Includes
//----------
const fs = require('fs') // ~ 1 ms
const path = require('path') // ~ 1 ms
const util = require('util') // ~ 1 ms
const zlib = require('zlib') // ~ 6 ms
//---------------------
// Includes: Promisify
//---------------------
const brotliPromise = util.promisify(zlib.brotliCompress) // ~ 1 ms
const execFilePromise = util.promisify(require('child_process').execFile) // ~ 9 ms
const fsWriteFilePromise = util.promisify(fs.writeFile) // ~ 1 ms
const gzipPromise = util.promisify(zlib.gzip) // ~ 1 ms
//-----------------------------
// Includes: Paths to Binaries
//-----------------------------
let gif = '',
jpg = '',
png = '',
webp = ''
if (shared.global) {
// each of these take ~ 0 ms instead of ~ 297 ms when using require('name').path
gif = path.join(shared.path.self, 'node_modules', 'gifsicle', 'vendor', 'gifsicle')
jpg = path.join(shared.path.self, 'node_modules', 'jpegtran-bin', 'vendor', 'jpegtran')
png = path.join(shared.path.self, 'node_modules', 'optipng-bin', 'vendor', 'optipng')
webp = path.join(shared.path.self, 'node_modules', 'cwebp-bin', 'vendor', 'cwebp')
} else {
gif = path.join(shared.path.self, '..', 'gifsicle', 'vendor', 'gifsicle')
jpg = path.join(shared.path.self, '..', 'jpegtran-bin', 'vendor', 'jpegtran')
png = path.join(shared.path.self, '..', 'optipng-bin', 'vendor', 'optipng')
webp = path.join(shared.path.self, '..', 'cwebp-bin', 'vendor', 'cwebp')
}
//---------------------
// Includes: Lazy Load
//---------------------
let css // require('clean-css') // ~ 83 ms
let html // require('html-minifier').minify // ~ 4 ms
let js // require('terser') // ~ 41 ms
let markdown // require('markdown-it')() // ~ 56 ms
let transferMap // require('multi-stage-sourcemap').transfer // ~ 20 ms
let sourceMapGenerator // require('source-map').SourceMapGenerator // ~ 13 ms
let svg // require('svgo') // ~ 121 ms
//-----------
// Variables
//-----------
const build = {}
//----------------------------
// Build: Command and Control
//----------------------------
// The following functions control building, setting up promise chains and concurrency.
build.processBuild = function build_processBuild(files, watching) {
/*
Find all source files or optionally use the files parameter to start a build process.
@param {String,Object} [files] Optional. Glob search string like '*.html' or array of paths like ['/source/about.html', 'source/index.html']
@param {Boolean} [watching] Optional and defaults to false. If true, log less information.
@return {Promise} Promise that returns an array of file path strings for the files built like ['/dest/css/style.css', '/dest/index.html']
*/
watching = watching || false
if (!config.option.build && !watching) {
return Promise.resolve()
}
return Promise.resolve().then(function() {
if (!watching) {
// start build timer
shared.stats.timeTo.build = functions.sharedStatsTimeTo(shared.stats.timeTo.build)
}
let configPathsAreGood = functions.configPathsAreGood()
if (configPathsAreGood !== true) {
throw new Error(configPathsAreGood)
}
}).then(function() {
return functions.fileExists(config.path.source).then(function(exists) {
if (exists === false) {
throw new Error(shared.language.display('error.missingSourceDirectory'))
}
})
}).then(function() {
if (!watching) {
// display title
functions.log(color.gray('\n' + shared.language.display('words.build') + '\n'), false)
}
let filesType = typeof files
if (filesType === 'object') {
// we already have a specified list to work from
return files
} else {
if (filesType === 'string') {
if (files.charAt(0) === '/') {
files = files.replace('/', '')
}
} else {
if (config.glob.build !== '') {
files = config.glob.build
} else {
files = '**/{*,.htaccess}'
}
}
return functions.findFiles(config.path.source + '/' + files)
}
}).then(function(files) {
if (files.length > 0) {
return build.processFiles(files)
} else {
return []
}
}).then(function(filesBuilt) {
if (!watching) {
if (filesBuilt.length > 0) {
//------------------------
// Missing Build Mappings
//------------------------
let array = shared.cache.missingMapBuild.slice()
let len = array.length
if (len > 0) {
for (let i = 0; i < len; i++) {
array[i] = '.' + array[i]
}
functions.log('\n ' + color.gray(shared.language.display('message.missingSourceToDestTasks') + '\n ' + array.sort().join('\n ')), false)
}
} else {
functions.log(color.gray(shared.language.display('words.done') + '.'))
}
//------------
// Timer: End
//------------
shared.stats.timeTo.build = functions.sharedStatsTimeTo(shared.stats.timeTo.build)
return filesBuilt
}
})
} // processBuild
build.processFiles = function build_processFiles(files) {
/*
Create a promise chain of tasks for each file and control concurrency.
@param {Object,String} files Array of paths like ['/source/path1', '/source/path2'] or a string like '/source/path'
@return {Promise} Promise that returns an array of file path strings for the files built like ['/dest/css/style.css', '/dest/index.html']
*/
let filesBuilt = [] // keep track of any files built
return new Promise(function(resolve, reject) {
if (typeof files === 'string') {
files = [files]
}
functions.cacheReset()
let allFiles = [] // array of promises
let current = 0 // number of operations running currently
let resolved = false // true if all tasks have been queued
function proceed() {
current--
if (files.length > 0) {
queue()
} else if (!resolved) {
resolved = true
resolve(allFiles)
}
} // proceed
function queue() {
while (current < config.concurLimit && files.length > 0) {
let file = files.shift()
allFiles.push(Promise.resolve(file).then(function(file) {
return build.processOneBuild(file).then(function(filePath) {
if (typeof filePath === 'string') {
filesBuilt.push(filePath)
}
proceed()
}).catch(function(err) {
proceed()
functions.logError(err)
throw err
})
}))
current++
}
} // queue
queue()
}).then(function(allFiles) {
return Promise.all(allFiles).then(function() {
return filesBuilt
})
})
} // processFiles
build.processOneBuild = function build_processOneBuild(filePath) {
/*
Create a promise chain of building tasks based on a single file type.
@param {String} filePath Full path to a file like '/web/source/rss.xml'
@return {Promise} Promise that returns a file path string if something was built otherwise undefined.
*/
let fileExt = functions.fileExtension(filePath)
// reusable object that will be passed between build functions
let object = {
'source': filePath,
'dest': '',
'data': '',
'build': false
}
let p = Promise.resolve(object)
// figure out which array of functions apply to this file type
if (config.map.sourceToDestTasks.hasOwnProperty(fileExt)) {
let len = config.map.sourceToDestTasks[fileExt].length
for (let i = 0; i < len; i++) {
if (typeof config.map.sourceToDestTasks[fileExt][i] === 'string') {
// built-in build task
p = p.then(build[config.map.sourceToDestTasks[fileExt][i]])
} else {
// custom build task function
p = p.then(config.map.sourceToDestTasks[fileExt][i])
}
}
} else {
if (shared.cache.missingMapBuild.indexOf(fileExt) < 0 && fileExt !== '') {
shared.cache.missingMapBuild.push(fileExt)
}
p = p.then(build.copy) // add default copy task
}
p = p.then(build.finalize).then(function(obj) {
if (obj.build) {
return obj.dest
}
}).catch(function(err) {
if (err !== 'done') {
functions.logError(err)
throw err
}
})
return p
} // processOneBuild
//------------------
// Build: In Memory
//------------------
// The following functions do their primary task in memory.
build.css = function build_css(obj) {
/*
Minify CSS using https://www.npmjs.com/package/clean-css.
@param {Object} obj Reusable object originally created by build.processOneBuild
@return {Promise} obj Promise that returns a reusable object.
*/
let buildAlreadySet = obj.build
let sourceBaseName = path.basename(config.path.source)
return functions.objBuildInMemory(obj).then(function(obj) {
functions.logWorker('build.css', obj)
if (!obj.build) {
// no further chained promises should be called
throw 'done'
}
if (typeof css !== 'object') {
css = require('clean-css')
}
if ((config.sourceMaps || config.fileType.css.sourceMaps) && buildAlreadySet) {
return functions.useExistingSourceMap(obj.dest)
}
}).then(function(existingSourceMap) {
existingSourceMap = existingSourceMap || false
if (existingSourceMap) {
// temporarily set any sourceMappingURL to a full path for clean-css
let string = '/*# sourceMappingURL='
let pos = obj.data.indexOf(string)
if (pos > 0) {
obj.data = obj.data.substr(0, pos) + '\n' + string + path.dirname(obj.dest) + shared.slash + path.basename(obj.dest) + '.map */'
}
}
let options = functions.cloneObj(config.thirdParty.cleanCss)
if (config.sourceMaps || config.fileType.css.sourceMaps) {
options.sourceMap = true
options.sourceMapInlineSources = true
options.rebaseTo = path.dirname(obj.dest)
}
const cssMin = new css(options).minify(obj.data)
if (config.sourceMaps || config.fileType.css.sourceMaps) {
obj.data = cssMin.styles + '\n/*# sourceMappingURL=' + path.basename(obj.dest) + '.map */'
let sourceMap = JSON.parse(cssMin.sourceMap.toString())
if (existingSourceMap) {
sourceMap.sources = existingSourceMap.sources
sourceMap.sourcesContent = existingSourceMap.sourcesContent
} else {
sourceMap.sources = [path.basename(obj.source)]
}
sourceMap = functions.normalizeSourceMap(obj, sourceMap)
const mapObject = functions.objFromSourceMap(obj, sourceMap)
return build.map(mapObject)
} else {
obj.data = cssMin.styles
}
}).then(function() {
obj = functions.buildEmptyOk(obj)
return obj
})
} // css
build.html = async function build_html(obj) {
/*
Minify HTML using https://www.npmjs.com/package/html-minifier.
@param {Object} obj Reusable object originally created by build.processOneBuild
@return {Promise} obj Promise that returns a reusable object.
*/
obj = await functions.objBuildInMemory(obj)
functions.logWorker('build.html', obj)
if (obj.build) {
if (typeof html !== 'object') {
html = require('html-minifier').minify
}
obj.data = html(obj.data, config.thirdParty.htmlMinifier)
obj = functions.buildEmptyOk(obj)
return obj
} else {
// no further chained promises should be called
throw 'done'
}
} // html
build.js = function build_js(obj) {
/*
Minify JavaScript using https://www.npmjs.com/package/terser.
@param {Object} obj Reusable object originally created by build.processOneBuild
@return {Promise} obj Promise that returns a reusable object.
*/
let buildAlreadySet = obj.build
let previousSourceMap = false
return functions.objBuildInMemory(obj).then(function(obj) {
functions.logWorker('build.js', obj)
if (obj.build) {
if (typeof js !== 'object') {
js = require('terser')
}
if ((config.sourceMaps || config.fileType.js.sourceMaps) && buildAlreadySet) {
// What if buildAlready set is not set?
// Like there is a '.map' file in the source folder.
// Is this worth worrying about?
if (typeof transferMap !== 'object') {
transferMap = require('multi-stage-sourcemap').transfer
}
return functions.useExistingSourceMap(obj.dest).then(function(existingSourceMap) {
// set parent variable for possible use later
previousSourceMap = existingSourceMap
})
}
} else {
// no further chained promises should be called
throw 'done'
}
}).then(function() {
let options = {
'keep_classnames': true,
'keep_fnames': true,
'mangle': false
/*
// the following code is great for troubleshooting source maps
output: {
beautify: true
},
beautify: {
semicolons: false
}
*/
}
let outputFileName = path.basename(obj.dest)
if (config.sourceMaps || config.fileType.js.sourceMaps) {
options.sourceMap = {
'url': outputFileName + '.map'
}
if (previousSourceMap !== false) {
previousSourceMap.sourceRoot = '' // remove pre-existing sourceRoot since we do not want the upcoming transform function using it in path names
}
}
return js.minify(obj.data, options)
}).then(function(jsMin) {
if (jsMin.error) {
if (shared.cli) {
// display an error for command line users
const file = obj.source.replace(path.dirname(config.path.source), '')
let multiline = [
/* line 1 */
jsMin.error.name + ' ' + shared.language.display('words.in') + ' ' + file,
/* line 2 */
shared.language.display('error.locationInCode')
.replace('{error}', jsMin.error.message)
.replace('{number}', jsMin.error.line)
.replace('{position}', jsMin.error.pos),
/* line 3 */
shared.language.display('message.fileWasNotBuilt')
]
multiline = multiline.map(line => color.red(line))
functions.logMultiline(multiline)
functions.playSound('error.wav') // promise we do not need to wait for
throw 'done'
} else {
// api users
throw jsMin.error
}
}
if (config.sourceMaps || config.fileType.js.sourceMaps) {
let sourceMap = JSON.parse(jsMin.map)
if (previousSourceMap !== false) {
// associate each source path with its corresponding source content value for later
let sourcesToContent = {}
for (let i in previousSourceMap.sources) {
sourcesToContent[previousSourceMap.sources[i]] = previousSourceMap.sourcesContent[i]
}
// transfer the new source map to the previous one
// uglify 2.6.2 could not do this without help but we have since switched to terser so perhaps this code is not needed anymore
// needs testing
sourceMap = transferMap({
fromSourceMap: sourceMap,
toSourceMap: previousSourceMap
})
sourceMap = JSON.parse(sourceMap)
sourceMap.sourcesContent = []
for (let i in sourceMap.sources) {
sourceMap.sourcesContent.push(sourcesToContent[sourceMap.sources[i]])
}
} else {
// new source map
sourceMap.sources = [
path.basename(config.path.source) + '/' + path.basename(obj.source)
]
for (let i in sourceMap.sourcesContent) {
sourceMap.sourcesContent[i] = sourceMap.sourcesContent[i].replace('//# sourceMappingURL=' + path.basename(obj.dest) + '.map', '')
}
}
sourceMap = functions.normalizeSourceMap(obj, sourceMap)
let mapObject = functions.objFromSourceMap(obj, sourceMap)
return build.map(mapObject).then(function() {
return jsMin
})
}
return jsMin
}).then(function(jsMin) {
obj.data = jsMin.code
obj = functions.buildEmptyOk(obj)
return obj
})
} // js
build.markdown = async function build_markdown(obj) {
/*
Markdown using https://www.npmjs.com/package/markdown-it.
@param {Object} obj Reusable object originally created by build.processOneBuild
@return {Promise} obj Promise that returns a reusable object.
*/
obj = await functions.objBuildInMemory(obj)
functions.logWorker('build.markdown', obj)
if (obj.build) {
if (typeof markdown !== 'object') {
markdown = require('markdown-it')()
}
markdown.options = config.thirdParty.markdownIt
obj.data = markdown.render(obj.data)
obj = functions.buildEmptyOk(obj)
return obj
} else {
// no further chained promises should be called
throw 'done'
}
} // markdown
build.svg = async function build_svg(obj) {
/*
Optimize SVG files using https://www.npmjs.com/package/svgo.
@param {Object} obj Reusable object originally created by build.processOneBuild
@return {Promise} obj Promise that returns a reusable object.
*/
obj = await functions.objBuildInMemory(obj)
functions.logWorker('build.svg', obj)
if (obj.build) {
if (typeof svg !== 'object') {
svg = require('svgo')
}
const result = svg.optimize(obj.data, config.thirdParty.svgo)
obj.data = result.data
return obj
} else {
// no further chained promises should be called
throw 'done'
}
} // svg
//----------------
// Build: On Disk
//----------------
// The following functions like working on disk based files.
build.copy = async function build_copy(obj) {
/*
Copy source to destination.
@param {Object} obj Reusable object originally created by build.processOneBuild
@return {Promise} obj Promise that returns a reusable object.
*/
functions.logWorker('build.copy', obj)
if (obj.data === '') {
obj = await functions.objBuildOnDisk(obj)
if (obj.build) {
await new Promise(function(resolve, reject) {
let sourceFile = fs.createReadStream(obj.source)
sourceFile.on('error', reject)
let destFile = fs.createWriteStream(obj.dest)
destFile.on('error', reject)
destFile.on('finish', resolve)
// copy source to dest
sourceFile.pipe(destFile)
})
functions.logOutput(obj.dest, 'copy')
} else {
// obj.build is false so no further chained promises should be called
throw 'done'
}
} else {
// do nothing and let build.finalize take care of writing obj.data
}
return obj
} // copy
build.gif = async function build_gif(obj) {
/*
Losslessly optimize GIF files using https://www.npmjs.com/package/gifsicle.
@param {Object} obj Reusable object originally created by build.processOneBuild
@return {Promise} obj Promise that returns a reusable object.
*/
obj = await functions.objBuildOnDisk(obj)
functions.logWorker('build.gif', obj)
if (obj.build) {
await execFilePromise(gif, ['--output', obj.dest, '--optimize=3', '--no-extensions', obj.source])
} else {
// no further chained promises should be called
throw 'done'
}
functions.logOutput(obj.dest)
return obj
} // gif
build.jpg = async function build_jpg(obj) {
/*
Losslessly optimize JPG files using https://www.npmjs.com/package/jpegtran-bin.
@param {Object} obj Reusable object originally created by build.processOneBuild
@return {Promise} obj Promise that returns a reusable object.
*/
obj = await functions.objBuildOnDisk(obj)
functions.logWorker('build.jpg', obj)
if (obj.build) {
await execFilePromise(jpg, ['-copy', 'none', '-optimize', '-progressive', '-outfile', obj.dest, obj.source])
} else {
// no further chained promises should be called
throw 'done'
}
functions.logOutput(obj.dest)
return obj
} // jpg
build.png = async function build_png(obj) {
/*
Losslessly optimize PNG files using https://www.npmjs.com/package/optipng-bin.
@param {Object} obj Reusable object originally created by build.processOneBuild
@return {Promise} obj Promise that returns a reusable object.
*/
obj = await functions.objBuildOnDisk(obj)
functions.logWorker('build.png', obj)
if (obj.build) {
await execFilePromise(png, ['-clobber', '-o2', '-strip', 'all', '-out', obj.dest, obj.source])
// get rid of the possible .bak file optipng makes when overwriting an existing dest file with a different source file
await functions.removeDest(obj.dest + '.bak', false)
} else {
// no further chained promises should be called
throw 'done'
}
functions.logOutput(obj.dest)
return obj
} // png
build.webp = async function build_webp(obj) {
/*
Losslessly optimize WEBP files using https://www.npmjs.com/package/cwebp-bin.
@param {Object} obj Reusable object originally created by build.processOneBuild
@return {Promise} obj Promise that returns a reusable object.
*/
obj = await functions.objBuildOnDisk(obj)
functions.logWorker('build.webp', obj)
if (obj.build) {
// on the next line "-z 9" means lossless preset and slowest level to make the smallest possible file without using lossy compression
await execFilePromise(webp, ['-z', '9', obj.source, '-o', obj.dest])
// losslessly compressing a lossy source file can lead a larger destination file
// get the file size of the source and destination
const files = await functions.filesExistAndSize(obj.source, obj.dest)
// check the size of the source and destination
if (files.source.size < files.dest.size || files.dest.size === 0) {
// source file is smaller or destination is zero bytes
if (files.dest.exists === true) {
// remove the destination file
await functions.removeFile(obj.dest)
}
// copy source to destination
await build.copy(obj)
} else {
functions.logOutput(obj.dest)
}
} else {
// no further chained promises should be called
throw 'done'
}
return obj
} // webp
//----------------------
// Build: With Includes
//----------------------
// The following functions are for file types that may contain includes.
build.concat = function build_concat(obj) {
/*
Concatenate files like 'all.js.concat' which can contain globs and/or file path strings that point to other files.
@param {Object} obj Reusable object originally created by build.processOneBuild
@return {Promise} obj Promise that returns a reusable object.
*/
return functions.objBuildWithIncludes(obj, functions.includePathsConcat).then(function(obj) {
functions.logWorker('build.concat', obj)
if (obj.build && config.fileType.concat.enabled) {
let filePaths = []
return functions.includePathsConcat(obj.data, obj.source).then(function(includeFiles) {
filePaths = includeFiles
return functions.concatMetaWrite(obj.source, filePaths)
}).then(function() {
return functions.readFiles(filePaths)
}).then(function(arrayData) {
// empty obj.data in preparation to populate it with include file contents
obj.data = ''
let fileExtDest = functions.fileExtension(obj.dest)
let arrayDataLength = arrayData.length - 1
for (let i in arrayData) {
obj.data += arrayData[i]
if (i < arrayDataLength) {
obj.data += '\n'
if (fileExtDest === 'js') {
// add a safety separator for javascript
obj.data += ';'
}
}
}
let createSourceMaps = false
let configConcatMaps = config.sourceMaps || config.fileType.concat.sourceMaps
if (fileExtDest === 'js' && (configConcatMaps || config.fileType.js.sourceMaps)) {
createSourceMaps = true
} else if (fileExtDest === 'css' && (configConcatMaps || config.fileType.css.sourceMaps)) {
createSourceMaps = true
}
if (createSourceMaps) {
if (typeof sourceMapGenerator !== 'object') {
sourceMapGenerator = require('source-map').SourceMapGenerator
}
let map = new sourceMapGenerator({
file: path.basename(obj.dest)
})
let totalLines = 0
for (let i in filePaths) {
let lineArray = arrayData[i].split(/\r?\n/)
let linesInFile = arrayData[i].split(/^.*$/gm).length - 1
let sourceFile = filePaths[i].replace(config.path.source, path.basename(config.path.source)).replace(/\\/g, '/')
for (let x = 0; x < linesInFile; x++) {
totalLines += 1
let line = lineArray[x].trimRight()
let col = line.length - line.trimLeft().length
let mapping = {
source: sourceFile,
original: { line: x + 1, column: col },
generated: { line: totalLines, column: col }
}
map.addMapping(mapping)
}
}
let sourceMap = JSON.parse(map.toString())
sourceMap.sourceRoot = config.sourceRoot
sourceMap.sourcesContent = []
for (let i in arrayData) {
sourceMap.sourcesContent.push(arrayData[i])
}
if (fileExtDest === 'js') {
obj.data += '\n//# sourceMappingURL=' + path.basename(obj.dest) + '.map'
} else if (fileExtDest === 'css') {
obj.data += '\n/*# sourceMappingURL=' + path.basename(obj.dest) + '.map */'
}
let mapObject = functions.objFromSourceMap(obj, sourceMap)
return build.map(mapObject)
}
})
} else {
// no further chained promises should be called
throw 'done'
}
}).then(function() {
obj = functions.buildEmptyOk(obj)
let fileExtSource = path.basename(obj.source).split('.').reverse()[1] // for example, return 'js' for a file named 'file.js.concat'
// now return a chain of build tasks just like build.processOneBuild but don't add build.finalize since that will run after our final return anyway
let p = Promise.resolve(obj)
// figure out which array of functions apply to this file type
if (config.map.sourceToDestTasks.hasOwnProperty(fileExtSource)) {
let len = config.map.sourceToDestTasks[fileExtSource].length
for (let i = 0; i < len; i++) {
if (typeof config.map.sourceToDestTasks[fileExtSource][i] === 'string') {
// built-in build task
p = p.then(build[config.map.sourceToDestTasks[fileExtSource][i]])
} else {
// custom build task function
p = p.then(config.map.sourceToDestTasks[fileExtSource][i])
}
}
} else {
if (shared.cache.missingMapBuild.indexOf(fileExtSource) < 0 && fileExtSource !== '') {
shared.cache.missingMapBuild.push(fileExtSource)
}
p = p.then(build.copy) // add default copy task
}
return p
}).then(function() {
return obj
})
} // concat
build.jss = async function build_jss(obj) {
/*
JavaScript Static Server (JSS) files.
@param {Object} obj Reusable object originally created by build.processOneBuild
@return {Promise} obj Promise that returns a reusable object.
*/
try {
obj = await functions.objBuildWithIncludes(obj, functions.includePathsJss)
functions.logWorker('build.jss', obj)
if (obj.build === false) {
// no further chained promises should be called
throw 'done'
}
let placeNumber = 0 // will increment to ensure unique placeholders
async function recurse(data, filePath, parentIncludes) {
/*
Recursively process JS blocks.
@param {String} data String with zero or more <js>...</js> blocks.
@param {String} filePath Full path to the file that supplied the data.
@param {Object} [parentIncludes] Optional array of full path strings to parent include files. Used when recursing.
*/
parentIncludes = parentIncludes || []
let jssCode = []
let jssIncludes = []
let localPlace = 0
//-----------------------
// Find <js> code blocks
//-----------------------
data = data.replace(/<js>(.*?)<\/js>/gs, function(match, p1) {
// add <js> code to jssCode
jssCode.push({
place: placeNumber,
code: functions.removeJsComments(p1)
})
// leave a {{js-0}} numbered placeholder
return '{{js-' + (placeNumber++) + '}}'
})
if (jssCode.length === 0) {
// return early
return data
}
//--------------------------
// Find include() functions
//--------------------------
for (let i = 0; i < jssCode.length; i++) {
jssCode[i].code = jssCode[i].code.replace(/(?:^|\s)include\(['|"](.*?)['|"]\s*,?/gs, function(match, p1) {
// in the regex above, we want to capture "include('...'" without any additional arguments or a closing parentheses
// this will allow us pass those arguments to the include file later
// add include function to jssIncludes
jssIncludes.push({
jssNumber: i,
place: jssCode[i].place,
localPlace: localPlace,
code: p1
})
// leave a {{js-include-0-0}} numbered placeholder
return '{{js-include-' + jssCode[i].place + '-' + (localPlace++) + '}}'
})
}
//----------------------
// Recurse for includes
//----------------------
if (jssIncludes.length > 0) {
for (let i = 0; i < jssIncludes.length; i++) {
const include = jssIncludes[i]
let fullPath = ''
if (include.code.charAt(0) === '/') {
// path starting from source
fullPath = path.join(config.path.source, include.code)
} else {
// relative path
fullPath = path.join(path.dirname(filePath), include.code)
}
if (parentIncludes.indexOf(fullPath) >= 0) {
// endless nesting detected
parentIncludes.push(filePath) // add ourself for error logging
parentIncludes.push(fullPath) // add next include for error logging
const file = obj.source.replace(path.dirname(config.path.source), '')
const errorSequence = parentIncludes.map(pi => pi.replace(config.path.source, '')).join(' -> ')
if (shared.cli) {
// display an error for command line users
let multiline = [
/* line 1 */
'Endless nesting ' + shared.language.display('words.in') + ' ' + file,
/* line 2 */
errorSequence,
/* line 3 */
shared.language.display('message.fileWasNotBuilt')
]
multiline = multiline.map(line => color.red(line))
functions.logMultiline(multiline)
functions.playSound('error.wav') // promise we do not need to wait for
throw 'done'
} else {
// api users
throw new Error('build.jss -> Endless nesting in sequence -> ' + errorSequence)
}
} else {
parentIncludes.push(filePath) // add ourself for the next recurse
}
let fileData = await functions.readFile(fullPath)
let result = await recurse(fileData, fullPath, parentIncludes)
let lookFor = '{{js-include-' + include.place + '-' + include.localPlace + '}}'
// replace {{js-include-0-0}} numbered placeholders with a begin type placeholder, the result of the recurse call, and an end type placeholder
jssCode[include.jssNumber].code = jssCode[include.jssNumber].code.replace(lookFor, '{{js-include-begin}}</js>' + result + '<js>{{js-include-end}}')
} // for
} // jssIncludes.length > 0
for (let i = 0; i < jssCode.length; i++) {
// replace placeholders with code
data = data.replace('{{js-' + jssCode[i].place + '}}', '<js>' + jssCode[i].code + '</js>')
}
return data
} // recurse
let data = await recurse(obj.data, obj.source)
data = data.replace(/<js>\s*<\/js>/gm, '') // remove empty <js> pairs
data = data.replace(/{{js-include-begin}}/g, ';await (async function(sourcePath, shared, ...args){').replace(/{{js-include-end}}/g, '})(sourcePath, shared,') // because we originally captured "include('...'" without the last parentheses in case there were extra arguments to pass, do no include the last parentheses here
data = data.replace(/\(sourcePath, shared,\)/g, '(sourcePath, shared)') // clean up any trailing commas
let jssCode = []
let localPlace = 0
//-----------------------
// Find <js> code blocks
//-----------------------
data = data.replace(/<js>(.*?)<\/js>/gs, function(match, p1) {
// add <js> code to jssCode
jssCode.push({
place: localPlace,
code: p1
})
// leave a {{js-0}} numbered placeholder
return '{{js-' + (localPlace++) + '}}'
})
//--------------------------------
// Add place param to write calls
//--------------------------------
for (const jss of jssCode) {
// have each write pass the current place value
jss.code = jss.code.replace(/(write\(['|"]?.*?)\)/gs, function(match, p1) {
return p1 + ', place)'
})
}
//------------------------------------
// Create a write buffer and function
//------------------------------------
let writeBuffer = []
function writeFunction(string, place) {
writeBuffer.push({
place: place,
string: string
})
}
//-------------------
// Construct daFunc!
//-------------------
let daFunc = '' // a function built out of strings? madness!
daFunc += 'return async function(write, require, sourcePath, shared) {' + '\n'
daFunc += ' let place = 0;' + '\n'
for (const jss of jssCode) {
daFunc += ' place = ' + jss.place + ';' + '\n'
daFunc += ' ' + jss.code + '' + '\n'
}
daFunc += '}' + '\n'
const sourcePath = config.path.source
const sharedObj = {} // a shared object reference which will be accessible from any include
await Function(daFunc)()(writeFunction, require, sourcePath, sharedObj) // create with first () then run with second ()
//-------------------------------------------------
// Replace placeholders with write buffer elements
//-------------------------------------------------
for (const i of writeBuffer) {
let placeHolder = '{{js-' + i.place + '}}'
// replace {{js-0}} numbered placeholders
data = data.replace(placeHolder, i.string + placeHolder) // prepend strings before our placeholder in case write() was called more than once
}
data = data.replace(/{{js-[0-9]+}}/g, '') // remove any remaining js placeholders
obj.data = data
obj = functions.buildEmptyOk(obj)
//-------------------------------
// Create a chain of build tasks
//-------------------------------
let fileExtSource = path.basename(obj.source).split('.').reverse()[1] // for example, return 'html' for a file named 'file.html.jss'
// now return a chain of build tasks just like build.processOneBuild but don't add build.finalize since that will run after our final return anyway
let p = Promise.resolve(obj)
// figure out which array of functions apply to this file type
if (config.map.sourceToDestTasks.hasOwnProperty(fileExtSource)) {
let len = config.map.sourceToDestTasks[fileExtSource].length
for (let i = 0; i < len; i++) {
if (typeof config.map.sourceToDestTasks[fileExtSource][i] === 'string') {
// built-in build task
p = p.then(build[config.map.sourceToDestTasks[fileExtSource][i]])
} else {
// custom build task function
p = p.then(config.map.sourceToDestTasks[fileExtSource][i])
}
}
} else {
if (shared.cache.missingMapBuild.indexOf(fileExtSource) < 0 && fileExtSource !== '') {
shared.cache.missingMapBuild.push(fileExtSource)
}
p = p.then(build.copy) // add default copy task
}
await p
//--------------------------------------------------------
// Return the reusable object for any further build tasks
//--------------------------------------------------------
return obj
} catch(error) {
if (error === 'done') {
// rethrow done
throw 'done'
}
const errorFile = obj.source.replace(config.path.source, '')
let multiline = [] // array for writing multiline messages to the console for command line users
if (error.code === 'ENOENT') {
// include file not found
const errorInclude = error.path.replace(config.path.source, '')
if (shared.cli) {
// command line users
multiline.push(
'Include not found while building ' + errorFile,
'Missing include ' + errorInclude,
shared.language.display('message.fileWasNotBuilt')
)
} else {
// api users
throw new Error('build.jss -> missing include -> ' + errorInclude + ' in ' + errorFile)
}
} else {
// all other errors
if (shared.cli) {
// command line users
multiline.push(
'Error in ' + errorFile,
error.message + '.',
shared.language.display('message.fileWasNotBuilt')
)
} else {
// api users
let errorMessage = error.message
if (errorMessage.slice(-1) === '.') {
// remove the last period
errorMessage = errorMessage.slice(0, -1)
}
throw new Error('build.jss -> ' + errorMessage + ' in ' + errorFile)
}
} // if
if (shared.cli) {
// command line users
multiline = multiline.map(line => color.red(line))
functions.logMultiline(multiline)
functions.playSound('error.wav') // promise we do not need to wait for
throw 'done'
} // if
} // catch
} // jss
//------------------
// Build: Finishers
//------------------
// The following functions are typically called at the end of a build chain.
// Unlike most build functions, they don't bother to check if a source file is
// newer than a destination file since that should have been handled already.
build.finalize = async function build_finalize(obj) {
/*
Finalize by writing memory to disk or copying source to dest, if needed.
@param {Object} obj Reusable object originally created by build.processOneBuild
@return {Promise} obj Promise that returns a reusable object.
*/
functions.logWorker('build.finalize', obj)
if (obj.data !== '') {
// write obj.data to dest
if (obj.dest === '') {
obj.dest = functions.sourceToDest(obj.source)
} else if (functions.inSource(obj.dest)) {
// obj.dest points to a file in the source directory which could be dangerous
throw new Error('build.finalize -> ' + shared.language.display('error.destPointsToSource'))
}
await functions.makeDirPath(obj.dest)
await fsWriteFilePromise(obj.dest, obj.data)
obj.data = ''
functions.logOutput(obj.dest)
return obj
} else if (obj.dest === '') {
// copy source to dest
return build.copy(obj)
} else {
return obj
}
} // finalize
build.br = async function build_br(obj) {
/*
Create a brotli compressed version of a file to live alongside the original.
@param {Object} obj Reusable object originally created by build.processOneBuild
@return {Promise,Object} obj Promise that returns a reusable object or just the reusable object.
*/
if (obj.build) {
obj = await build.finalize(obj) // build.finalize ensures our destination file is ready to be compressed
functions.logWorker('build.br', obj)
let data = await functions.readFile(obj.dest)
let dataCompress = await brotliPromise(data, {
params: {
// the default compression level is 11 which is exactly what we want
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: data.length
}
})
await fsWriteFilePromise(obj.dest + '.br', dataCompress, 'binary')
}
return obj
} // br
build.gz = async function build_gz(obj) {
/*
Create a gzip compressed version of a file to live alongside the original.
@param {Object} obj Reusable object originally created by build.processOneBuild
@return {Promise,Object} obj Promise that returns a reusable object or just the reusable object.
*/
if (obj.build) {
obj = await build.finalize(obj) // build.finalize ensures our destination file is ready to be compressed
functions.logWorker('build.gz', obj)
let data = await functions.readFile(obj.dest)
let dataCompress = await gzipPromise(data, {
level: 9
})
await fsWriteFilePromise(obj.dest + '.gz', dataCompress, 'binary')
}
return obj
} // gz
build.map = async function build_map(obj) {
/*
Build a map file and if needed, also make a br and/or gz version of said map file.
@param {Object} obj Reusable object originally created by build.processOneBuild
@return {Promise,Object} obj Promise that returns a reusable object or just the reusable object.
*/
// Troubleshooting a JavaScript source map? Try http://sokra.github.io/source-map-visualization/ and https://sourcemaps.io
if (obj.build) {
obj = await build.finalize(obj) // build.finalize ensures our destination file is written to disk
functions.logWorker('build.map', obj)
if (config.map.sourceToDestTasks.map.indexOf('br') >= 0) {
// manually kick off a br task for the new .map file
await build.br({
'source': obj.dest,
'dest': obj.dest,
'data': '',
'build': true
})
}
if (config.map.sourceToDestTasks.map.indexOf('gz') >= 0) {
// manually kick off a gz task for the new .map file
await build.gz({
'source': obj.dest,
'dest': obj.dest,
'data': '',
'build': true
})
}
}
return obj
} // map
//---------
// Exports
//---------
module.exports = build