UNPKG

feri

Version:

An easy to use build tool for web files.

1,555 lines (1,215 loc) 79.8 kB
'use strict' //---------------- // Includes: Self //---------------- const color = require('./color.js') const shared = require('./2 - shared.js') const config = require('./3 - config.js') //---------- // Includes //---------- const fs = require('fs') // ~ 1 ms const { glob } = require('glob') // ~ 13 ms const { mkdirp } = require('mkdirp') // ~ 3 ms const path = require('path') // ~ 1 ms const { rimraf } = require('rimraf') // ~ 13 ms const util = require('util') // ~ 1 ms //--------------------- // Includes: Promisify //--------------------- const fsReaddir = util.promisify(fs.readdir) // ~ 1 ms const fsReadFilePromise = util.promisify(fs.readFile) // ~ 1 ms const fsStatPromise = util.promisify(fs.stat) // ~ 1 ms const fsWriteFilePromise = util.promisify(fs.writeFile) // ~ 1 ms //--------------------- // Includes: Lazy Load //--------------------- let https // require('https') // ~ 33 ms let playSound // require('node-wav-player') // ~ 11 ms //----------- // Variables //----------- let playSoundLastFile = '' // used by functions.playSound() let playSoundPlaying = false // used by functions.playSound() let functions = {} //----------- // Functions //----------- functions.addDestToSourceExt = function functions_addDestToSourceExt(ext, mappings) { /* Add or append a mapping to config.map.destToSourceExt without harming existing entries. @param {String} ext Extension like 'html' @param {String,Object} mappings String like 'md' or array of strings like ['md'] */ if (typeof mappings === 'string') { mappings = [mappings] } if (!config.map.destToSourceExt.hasOwnProperty(ext)) { // create extension mapping property and empty array config.map.destToSourceExt[ext] = [] } // append array Array.prototype.push.apply(config.map.destToSourceExt[ext], mappings) } // addDestToSourceExt functions.buildEmptyOk = function functions_buildEmptyOk(obj) { /* Allow empty files to be built in memory once they get to build.finalize. @param {Object} obj Reusable object originally created by build.processOneBuild @return {Promise} obj Promise that returns a reusable object. */ if (obj.build && obj.data === '') { // empty files are valid files // add a space to obj.data so build.finalize builds the file from memory and does not copy the source file directly to the destination obj.data = ' ' } return obj } // buildEmptyOk functions.cacheReset = function functions_cacheReset() { /* Reset shared.cache and shared.uniqueNumber for a new pass through a set of files. */ for (let i in shared.cache) { shared.cache[i] = shared.cache[i].constructor() } shared.uniqueNumber = 0 } // cacheReset functions.changeExt = function functions_changeExt(filePath, newExtension) { /* Change one extension to another. @param {String} filePath File path like '/files/index.md' @param {String} newExtension Extension like 'html' @return {String} File path like '/files/index.html' */ return filePath.substr(0, filePath.lastIndexOf('.')) + '.' + newExtension } // changeExt functions.cleanArray = function functions_cleanArray(array) { /* Remove empty items from an array. @param {Object} array Array like [1,,3] @return {Object} Cleaned array like [1,3] */ // This function comes from http://stackoverflow.com/questions/281264/remove-empty-elements-from-an-array-in-javascript let len = array.length for (let i = 0; i < len; i++) { array[i] && array.push(array[i]) // copy non-empty values to the end of the array } array.splice(0 , len) // cut the array and leave only the non-empty values return array } // cleanArray functions.cloneObj = function functions_cloneObj(object) { /* Clone an object recursively so the return is not a reference to the original object. @param {Object} obj Object like { number: 1, bool: true, array: [], subObject: {} } @return {Object} */ if (object === null || typeof object !== 'object') { // return early for boolean, function, null, number, string, symbol, undefined return object } if (object instanceof Date) { return new Date(object) } if (object instanceof RegExp) { return new RegExp(object) } let objectConstructor = object.constructor() for (let key in object) { // call self recursively objectConstructor[key] = functions.cloneObj(object[key]) } return objectConstructor } // cloneObj functions.concatMetaClean = async function functions_concatMetaClean() { /* Silently clean up any orphaned '.filename.ext.concat' meta files in the source directory. */ let metaFiles = await functions.findFiles(config.path.source + '/**/.*.concat') for (const file of metaFiles) { const originalFile = path.join(path.dirname(file), path.basename(file).replace('.', '')) const originalExists = await functions.fileExists(originalFile) if (originalExists === false) { await functions.removeFile(file) } } } // concatMetaClean functions.concatMetaRead = async function functions_concatMetaRead(file) { /* Read a meta information file which lists the includes used to build a concat file. @param {String} file Full file path to a source concat file. */ file = path.join(path.dirname(file), '.' + path.basename(file)) let data = '' try { data = await functions.readFile(file) data = JSON.parse(data) data = data.map(i => path.join(config.path.source, i)) } catch (error) { // do nothing } return data } // concatMetaRead functions.concatMetaWrite = async function functions_concatMetaWrite(file, includeArray) { /* Write a meta information file with a list of includes used to build a concat file. @param {String} file Full file path to a source concat file. @param {Object} includeArray Array of include file strings. */ file = path.join(path.dirname(file), '.' + path.basename(file)) // make sure we are going to write to the source folder only if (file.indexOf(config.path.source) !== 0) { throw new Error('functions.concatMetaWrite -> refusing to write to non source location "' + file + '"') } let data = includeArray.map(i => i.replace(config.path.source, '')) data = JSON.stringify(data) await functions.writeFile(file, data) } // concatMetaWrite functions.configPathsAreGood = function functions_configPathsAreGood() { /* Ensure source and destination are not blank, not the same, and not in each other's path. Also ensure that the destination is not a protected folder. @return {*} Boolean true if both paths are good. String with an error message if not. */ // resolve any relative paths to absolute config.path.source = path.resolve(config.path.source) config.path.dest = path.resolve(config.path.dest) let source = config.path.source.toLowerCase() let dest = config.path.dest.toLowerCase() let sourceSlash = source + shared.slash let destSlash = dest + shared.slash let protect = false let test = '' if (source === dest || sourceSlash.indexOf(destSlash) === 0 || destSlash.indexOf(sourceSlash) === 0) { // source and destination are the same or in each others path return shared.language.display('error.configPaths') } if (shared.slash === '\\') { // we are on windows if (typeof process.env.windir === 'string') { test = process.env.windir.toLowerCase() + shared.slash if (destSlash.indexOf(test) === 0) { // protect 'C:\Windows' and sub folders protect = true } } let env = [process.env.ProgramFiles, process.env['ProgramFiles(x86)'], path.dirname(process.env.USERPROFILE)] for (let i in env) { if (typeof env[i] === 'string') { test = env[i].toLowerCase() if (dest === test) { // protect folders like 'C:\Program Files', 'C:\Program Files(x86)', or 'C:\Users' protect = true break } else if (path.dirname(dest) === test) { // protect one level deeper into folder, but not farther protect = true break } } } if (dest === 'c:\\') { // zoinks protect = true } } else { // mac, unix, etc... if (typeof process.env.HOME === 'string') { test = path.dirname(process.env.HOME.toLowerCase()) if (dest === test) { // protect Mac '/Users' and Unix '/home' folders protect = true } else if (path.dirname(dest) === test) { // protect one level deeper like '/Users/name' protect = true } } if (dest === '/') { // yikes protect = true } } if (protect) { return shared.language.display('error.destProtected').replace('{path}', "'" + config.path.dest + "'") } return true } // configPathsAreGood functions.destToSource = function functions_destToSource(dest) { /* Convert a destination path to its source equivalent. @param {String} dest File path like '/dest/index.html' @return {String} File path like '/source/index.html' */ let source = dest.replace(config.path.dest, '').replace(config.path.source, '') switch (config.case.source) { case 'upper': source = source.toUpperCase() break case 'lower': source = source.toLowerCase() break } // switch source = config.path.source + source return source } // destToSource functions.detectCaseDest = async function functions_detectCaseDest() { /* Find out how a destination folder deals with case. Writes a test file once and then caches that result for subsequent calls. @return {Promise} Promise that returns a string like 'lower', 'upper', 'nocase', or 'case'. */ /* Testing results. Mac ExFAT = nocase MS-DOS (FAT-32) = nocase Mac OS Extended (Journaled) = nocase Mac OS Extended (Case-sensitive, Journaled) = case Linux Ext4 = case Windows NTFS = nocase // beware... windows 10 can supposedly set individual folders to 'case' */ const dest = config.path.dest if (shared.folder.dest.lastPath === dest) { // dest has not changed since we last checked if (shared.folder.dest.case !== '') { // case is not empty so return the previously figured out case return Promise.resolve(shared.folder.dest.case) } } else { shared.folder.dest.lastPath = dest } const file = 'aB.feri' const fileLowerCase = file.toLowerCase() const fileUpperCase = file.toUpperCase() const fileFullPath = path.join(dest, file) try { // try to delete a previous test file await functions.removeFile(fileFullPath) } catch (error) { // do nothing } let result = '' // will be set to 'lower', 'upper', 'nocase', or 'case' const writeFile = await functions.writeFile(fileFullPath, 'This is a test file created by Feri. It is safe to delete.') if (writeFile === false) { throw new Error('functions.detectCaseDest -> could not write a test file to "' + dest + '"') } const dir = await fsReaddir(dest) for (let i in dir) { if (fileLowerCase === dir[i].toLowerCase()) { // this is our matching file if (fileLowerCase === dir[i]) { // the destination forces all file names to lowercase result = 'lower' } if (fileUpperCase === dir[i]) { // the destination forces all file names to uppercase result = 'upper' } break } } if (result === '') { // ask the os for a lower case version of the test file let fileExists = false try { await fsStatPromise(path.join(dest, fileLowerCase)) fileExists = true } catch (error) { // do nothing } if (fileExists) { // the destination folder is NOT case sensitive result = 'nocase' } else { // the destination folder is case sensitive result = 'case' } } const removeFile = await functions.removeFile(fileFullPath) if (removeFile === false) { throw new Error('functions.detectCaseDest -> could not remove test file "' + fileFullPath + '"') } shared.folder.dest.case = result // save the result for next time so we can run faster return result } // detectCaseDest functions.detectCaseSource = async function functions_detectCaseSource() { /* Find out how a source folder deals with case. Writes a test file once and then caches that result for subsequent calls. @return {Promise} Promise that returns a string like 'lower', 'upper', 'nocase', or 'case'. */ /* Testing results. Mac ExFAT = nocase MS-DOS (FAT-32) = nocase Mac OS Extended (Journaled) = nocase Mac OS Extended (Case-sensitive, Journaled) = case Linux Ext4 = case Windows NTFS = nocase // beware... windows 10 can supposedly set individual folders to 'case' */ const source = config.path.source if (shared.folder.source.lastPath === source) { // source has not changed since we last checked if (shared.folder.source.case !== '') { // case is not empty so return the previously figured out case return Promise.resolve(shared.folder.source.case) } } else { shared.folder.source.lastPath = source } const file = 'aB.feri' const fileLowerCase = file.toLowerCase() const fileUpperCase = file.toUpperCase() const fileFullPath = path.join(source, file) try { // try to delete a previous test file await functions.removeFile(fileFullPath) } catch (error) { // do nothing } let result = '' // will be set to 'lower', 'upper', 'nocase', or 'case' const writeFile = await functions.writeFile(fileFullPath, 'This is a test file created by Feri. It is safe to delete.') if (writeFile === false) { throw new Error('functions.detectCaseSource -> could not write a test file to "' + source + '"') } const dir = await fsReaddir(source) for (let i in dir) { if (fileLowerCase === dir[i].toLowerCase()) { // this is our matching file if (fileLowerCase === dir[i]) { // the source forces all file names to lowercase result = 'lower' } if (fileUpperCase === dir[i]) { // the source forces all file names to uppercase result = 'upper' } break } } if (result === '') { // ask the os for a lower case version of the test file let fileExists = false try { await fsStatPromise(path.join(source, fileLowerCase)) fileExists = true } catch (error) { // do nothing } if (fileExists) { // the source folder is NOT case sensitive result = 'nocase' } else { // the source folder is case sensitive result = 'case' } } const removeFile = await functions.removeFile(fileFullPath) if (removeFile === false) { throw new Error('functions.detectCaseSource -> could not remove test file "' + fileFullPath + '"') } shared.folder.source.case = result // save the result for next time so we can run faster return result } // detectCaseSource functions.figureOutPath = function functions_figureOutPath(filePath) { /* Figure out if a path is relative and if so, return an absolute version of the path. @param {String} filePath File path like '/full/path/to/folder' or '/relative/path' @return {String} File path like '/fully/resolved/relative/path' */ let pos = 0 let str = '/' filePath = path.normalize(filePath) if (shared.slash === '\\') { // we are on windows pos = 1 str = ':' } if (filePath.charAt(pos) === str) { // absolute path return filePath } // relative path return path.join(shared.path.pwd, filePath) } // figureOutPath functions.fileExists = function functions_fileExists(filePath) { /* Find out if a file or folder exists. @param {String} filePath Path to a file or folder. @return {Promise} Promise that returns a boolean. True if yes. */ return functions.fileStat(filePath).then(function() { return true }).catch(function(err) { return false }) } // fileExists functions.fileExistsAndSize = function functions_fileExistsAndSize(filePath) { /* Find out if a file exists along with its size. @param {String} filePath Path to a file or folder. @return {Promise} Promise that returns an object like { exists: true, size: 12345 } */ return functions.fileStat(filePath).then(function(stat) { return { 'exists': true, 'size': stat.size // bytes } }).catch(function(err) { return { 'exists': false, 'size': 0 } }) } // fileExistsAndSize functions.fileExistsAndTime = function functions_fileExistsAndTime(filePath) { /* Find out if a file exists along with its modified time. @param {String} filePath Path to a file or folder. @return {Promise} Promise that returns an object like { exists: true, mtime: 123456789 } */ return functions.fileStat(filePath).then(function(stat) { return { 'exists': true, 'mtime': stat.mtime.getTime() } }).catch(function(err) { return { 'exists': false, 'mtime': 0 } }) } // fileExistsAndTime functions.fileExtension = function functions_fileExtension(filePath) { /* Return a file extension from a string. @param {String} filePath File path like '/conan/riddle-of-steel.txt' @return {String} String like 'txt' */ return path.extname(filePath).replace('.', '').toLowerCase() } // fileExtension functions.filesExist = function functions_filesExist(filePaths) { /* Find out if one or more files or folders exist. @param {Object} filePaths Array of file paths like ['/source/index.html', '/source/about.html'] @return {Promise} Promise that returns an array of booleans. True if a particular file exists. */ let files = filePaths.map(function(file) { return functions.fileExists(file) }) return Promise.all(files) } // filesExist functions.filesExistAndSize = function functions_filesExistAndSize(source, dest) { /* Find out if one or both files exist along with their file size. @param {String} source Source file path like '/source/favicon.ico' @param {String} dest Destination file path like '/dest/favicon.ico' @return {Promise} Promise that returns an object like { source: { exists: true, size: 12345 }, dest: { exists: false, size: 0 } } */ let files = [source, dest].map(function(file) { return functions.fileExistsAndSize(file) }) return Promise.all(files).then(function(array) { return { 'source': array[0], 'dest': array[1] } }) } // filesExistAndSize functions.filesExistAndTime = function functions_filesExistAndTime(source, dest) { /* Find out if one or both files exist along with their modified time. @param {String} source Source file path like '/source/favicon.ico' @param {String} dest Destination file path like '/dest/favicon.ico' @return {Promise} Promise that returns an object like { source: { exists: true, mtime: 123456789 }, dest: { exists: false, mtime: 0 } } */ let files = [source, dest].map(function(file) { return functions.fileExistsAndTime(file) }) return Promise.all(files).then(function(array) { return { 'source': array[0], 'dest': array[1] } }) } // filesExistAndTime functions.fileSize = function functions_fileSize(filePath) { /* Find out the size of a file or folder. @param {String} filePath Path to a file or folder. @return {Promise} Promise that will return the number of bytes or 0. */ return functions.fileStat(filePath).then(function(stats) { return stats.size }).catch(function(err) { return 0 }) } // fileSize functions.fileStat = async function functions_fileStat(filePath) { /* Return an fs stats object if a file or folder exists otherwise an error. A case sensitive version of fsStatPromise for source and dest locations. @param {String} filePath Path to a file or folder. @return {Promise} Promise that returns an fs stats object if a file or folder exists. An error if not. */ let theCase = '' // can be set to 'lower', 'upper', 'nocase', or 'case' if (functions.inDest(filePath)) { //check the file case style of the destination volume if (config.case.dest !== '') { // user specified override theCase = config.case.dest } else { theCase = await functions.detectCaseDest() } } else if (functions.inSource(filePath)) { //check the file case style of the source volume if (config.case.source !== '') { // user specified override theCase = config.case.source } else { theCase = await functions.detectCaseSource() } } else { // this file is not in the source or dest // this can happen during mocha tests return fsStatPromise(filePath) } const isConcat = (functions.fileExtension(filePath) === 'concat') if (theCase === 'case') { // this volume is case sensitive so we can use fsStatPromise to ask for a very specific file like 'inDex.html' and be sure that it will not match a pre-existing file like 'index.html' if (isConcat) { // do a fuzzy case match for the 'concat' extension part of a file name only const dirName = path.dirname(filePath) const dir = await fsReaddir(dirName) let fileName = path.basename(filePath) const fileNameLowerCase = fileName.toLowerCase() const fileNameSansConcat = functions.removeExt(fileName) for (let i in dir) { if (functions.fileExtension(dir[i]) === 'concat') { if (fileNameSansConcat === functions.removeExt(dir[i])) { // irrespective of the concat extension, an exact match fileName = dir[i] break } } } return fsStatPromise(path.join(dirName, fileName)) } // isConcat // ask for an exact file name match return fsStatPromise(filePath) } else { // this volume is NOT case sensitive so a call like fsStatPromise('inDex.html') could return true if a pre-existing file like 'index.html' already exists // using fsStatPromise on a volume that is NOT case sensitive can lead to files not being cleaned or built, especially if a file is renamed by changing the case of existing characters only const dir = await fsReaddir(path.dirname(filePath)) let fileName = path.basename(filePath) let filePathActual = '' if (theCase === 'upper') { fileName = fileName.toUpperCase() } else if (theCase === 'lower') { fileName = fileName.toLowerCase() } if (isConcat) { // do a fuzzy case match for the 'concat' extension part of a file name only let fileNameSansConcat = functions.removeExt(fileName) for (let i in dir) { if (functions.fileExtension(dir[i]) === 'concat') { if (fileNameSansConcat === functions.removeExt(dir[i])) { // irrespective of the concat extension, an exact match filePathActual = filePath break } } } } else { for (let i in dir) { if (fileName === dir[i]) { // an exact match, including case filePathActual = filePath break } } } if (filePathActual === '') { throw new Error('functions.fileStat -> "' + filePath + '" does not exist') } else { // safe to call fsStatPromise since our earlier logic ensures an exact match return fsStatPromise(filePath) } } } // fileStat functions.findFiles = async function functions_findFiles(match, options) { /* Find the files using https://www.npmjs.com/package/glob. @param {String} match String like '*.jpg' @param {Object} [options] Optional. Options for glob. @return {Promise} Promise that returns an array of files or empty array if successful. Returns an error if not successful. */ if (typeof options === 'undefined') { options = functions.globOptions() } // if if (match.charAt(1) === ':') { // we have a windows path // glob doesn't like c: or similar so trim two characters match = match.substr(2) // glob only likes forward slashes match = match.replace(/\\/g, '/') } // if try { const files = await glob(match, options) return files.sort() } catch (err) { throw new Error('functions.findFiles -> ' + err.message) } // try } // findFiles functions.globOptions = function functions_globOptions() { /* Return glob options updated to ignore include prefixed files. @return {Object} */ const obj = { 'ignore' : '**/' + config.includePrefix + '*', // glob ignores dot files by default 'nocase' : true, 'nodir' : true, 'realpath': true } if (config.includePrefix === '') { // remove the ignore option delete obj.ignore } return obj } // globOptions functions.inDest = function functions_inDest(filePath) { /* Find out if a path is in the destination directory. @param {String} filePath Full file path like '/projects/dest/index.html' @return {Boolean} True if the file path is in the destination directory. */ return filePath.indexOf(config.path.dest) === 0 } // inDest functions.initFeri = async function initFeri() { /* If needed, create the source and destination folders along with a custom config file in the present working directory. @return {Promise} */ let messageDone = '\n' + color.gray(shared.language.display('words.done') + '.') + '\n' // make sure config.path.source is an absolute path in case it was set programmatically config.path.source = functions.figureOutPath(config.path.source) await functions.makeDirPath(config.path.source, true) // make sure config.path.dest is an absolute path in case it was set programmatically config.path.dest = functions.figureOutPath(config.path.dest) await functions.makeDirPath(config.path.dest, true) let configFile = path.join(shared.path.pwd, 'feri.js') let configFileAlt = path.join(shared.path.pwd, 'feri-config.js') let exists = await functions.filesExist([configFile, configFileAlt]) if (exists.indexOf(true) >= 0) { functions.log(messageDone, false) return 'early' } let data = await functions.readFile(path.join(shared.path.self, 'templates', 'custom-config.js')) if (shared.slash === '\\') { configFile = configFileAlt } await functions.writeFile(configFile, data) functions.log(messageDone, false) } // initFeri functions.inSource = function functions_inSource(filePath) { /* Find out if a path is in the source directory. @param {String} filePath Full file path like '/projects/source/index.html' @return {Boolean} True if the file path is in the source directory. */ return filePath.indexOf(config.path.source) === 0 } // inSource functions.isGlob = function functions_isGlob(string) { /* Find out if a string is a glob. @param {String} string String to test. @return {Boolean} True if string is a glob. */ if (string.search(/\*|\?|!|\+|@|\[|\]|\(|\)/) >= 0) { return true } else { return false } } // isGlob functions.log = function functions_log(message, indent) { /* Display a console message if logging is enabled. @param {String} message String to display. @param {Boolean} [indent] Optional and defaults to true. If true, the string will be indented using the shared.indent value. */ if (shared.log) { indent = (indent === false) ? '' : shared.indent console.info(indent + message) } } // log functions.logError = function functions_logError(error) { /* Log a stack trace or text string depending on the type of object passed in. @param {Object,String} err Error object or string describing the error. */ let message = error.message || error let displayError = false if (shared.log) { if (message === '') { if (typeof error.stack === 'string') { displayError = true } } else { if (config.option.watch) { displayError = true } else { // check if we have seen this error before if (shared.cache.errorsSeen.indexOf(error) < 0) { // error is unique so cache it for next time shared.cache.errorsSeen.push(error) displayError = true } } } } if (displayError) { if (typeof error.stack === 'string') { // error is an object console.warn('\n' + color.red(error.stack) + '\n') } else { // error is a string console.warn('\n' + color.gray('Error: ') + color.red(error) + '\n') } } } // logError functions.logMultiline = function functions_logMultiline(lines, indent) { /* Log a multiline message with a single indent for the first line and two indents for subsequent lines. @param {Object} lines Array of strings to write on separate lines. @param {Boolean} [indent] Optional and defaults to true. If true, each indent will use the shared.indent value. */ if (shared.log) { if (typeof lines === 'string') { lines = [lines] } indent = (indent === false) ? '' : shared.indent console.info(indent + lines.shift()) for (const i of lines) { console.info(indent + indent + i) } } } // logMultiline functions.logOutput = function functions_logOutput(destFilePath, message) { /* Log a pretty output message with a relative looking path. @param {String} destFilePath Full path to a destination file. @param {String} [message] Optional and defaults to 'output'. */ let file = destFilePath.replace(path.dirname(config.path.dest), '') message = message || 'output' if (shared.slash === '\\') { // we are on windows file = file.replace(/\\/g, '/') } functions.log(color.gray(shared.language.display('paddedGroups.build.' + message)) + ' ' + color.cyan(file)) } // logOutput functions.logWorker = function functions_logWorker(workerName, obj) { /* Overly chatty logging utility used by build functions. @param {String} workerName Name of worker. @param {Object} obj Reusable object originally created by build.processOneBuild */ if (config.option.debug) { let data = (obj.data === '') ? '' : 'yes' functions.log(color.gray('\n' + workerName + ' -> called')) functions.log('source = ' + obj.source) functions.log('dest = ' + obj.dest) functions.log('data = ' + data) functions.log('build = ' + obj.build) } } // logWorker functions.makeDirPath = function functions_makeDirPath(filePath, isDir) { /* Create an entire directory structure leading up to a file or folder, if needed. @param {String} filePath Path like '/images/koi.png' or '/images'. @param {Boolean} isDir True if filePath is a directory that should be used as is. @return {Promise} Promise that returns true if successful. An error if not. */ isDir = isDir || false if (!isDir) { filePath = path.dirname(filePath) } return mkdirp(filePath).then(function(confirmPath) { return true }) } // makeDirPath functions.mathRoundPlaces = function functions_mathRoundPlaces(number, decimals) { /* Round a number to a certain amount of decimal places. @param {Number} number Number to round. @param {Number} decimals Number of decimal places. @return {Number} Returns 0.04 if mathRoundPlaces(0.037, 2) was called. */ return +(Math.round(number + 'e+' + decimals) + 'e-' + decimals) } // mathRoundPlaces functions.normalizeSourceMap = function functions_normalizeSourceMap(obj, sourceMap) { /* Normalize source maps. @param {Object} obj Reusable object most likely created by functions.objFromSourceMap @param {Object} sourceMap Source map to normalize. @return {Object} Normalized source map. */ function missingSource() { let preferredPath = path.basename(config.path.source) let source = obj.source source = source.replace(config.path.source, preferredPath) source = source.replace(config.path.dest, preferredPath) source = source.replace(path.basename(config.path.dest), preferredPath) if (source.toLowerCase().endsWith('.map')) { source = functions.removeExt(source) } if (shared.slash !== '/') { // we are on windows source = source.replace(/\\/g, '/') } return [source] } // an example of a nice source map /* { "version" : 3, "sources" : ["source/js/javascript.js"], "names" : ["context_menu","e","window","event","eTarget","srcElement","target","nodeName","document","oncontextmenu"], "mappings" : "AAEA,QAASA,cAAaC,GAClB,IAAKA,EAAG,GAAIA,GAAIC,OAAOC,KACvB,IAAIC,GAAWF,OAAY,MAAID,EAAEI,WAAaJ,EAAEK,MAEhD,OAAwB,OAApBF,EAAQG,UAED,EAFX,OANJC,SAASC,cAAgBT", "file" : "javascript.js", "sourceRoot" : "/source-maps", "sourcesContent": ["document.oncontextmenu = context_menu;\n \nfunction context_menu(e) {\n if (!e) var e = window.event;\n var eTarget = (window.event) ? e.srcElement : e.target;\n \n if (eTarget.nodeName == \"IMG\") {\n //context menu attempt on top of an image element\n return false;\n }\n}"] } */ // sources if (Array.isArray(sourceMap.sources) === false) { sourceMap.sources = missingSource() } if (sourceMap.sources.length === 0) { sourceMap.sources = missingSource() } if (sourceMap.sources.length === 1 && (sourceMap.sources[0] === 'unknown' || sourceMap.sources[0] === '?' || sourceMap.sources[0] === '')) { sourceMap.sources = missingSource() } for (let i in sourceMap.sources) { sourceMap.sources[i] = sourceMap.sources[i].replace(/\.\.\//g, '') } // names if (sourceMap.names === undefined) { sourceMap.names = [] } // mappings if (sourceMap.mappings === undefined) { sourceMap.mappings = '' } // file sourceMap.file = path.basename(obj.dest) if (sourceMap.file.toLowerCase().endsWith('.map')) { sourceMap.file = functions.removeExt(sourceMap.file) } // source root sourceMap.sourceRoot = config.sourceRoot // sources content if (sourceMap.sourcesContent === undefined) { // fall back to obj.data since more specific sources are not available sourceMap.sourcesContent = [obj.data] } return sourceMap } // normalizeSourceMap functions.objFromSourceMap = function functions_objFromSourceMap(obj, sourceMap) { /* Create a reusable object based on a source map. @param {Object} obj Reusable object originally created by build.processOneBuild @param {Object} sourceMap Source map to use in the data field of the returned object. @return {Object} A reusable object crafted especially for build.map */ return { 'source': obj.dest + '.map', 'dest': obj.dest + '.map', 'data': JSON.stringify(sourceMap), 'build': true } } // objFromSourceMap functions.occurrences = function functions_occurrences(string, subString, allowOverlapping) { /* Find out how many characters or strings are in a string. @param {String} string String to search. @param {String} subString Character or string to search for. @param {Boolean} [allowOverlapping] Optional and defaults to false. @return {Number} Number of occurrences of 'subString' in 'string'. */ // This function comes from http://stackoverflow.com/questions/4009756/how-to-count-string-occurrence-in-string string += '' subString += '' if (subString.length <= 0) { return string.length + 1 } let n = 0 let pos = 0 let step = (allowOverlapping) ? 1 : subString.length while (true) { pos = string.indexOf(subString, pos) if (pos >= 0) { n++ pos += step } else { break } } return n } // occurrences functions.playSound = async function functions_playSound(file) { /* Play a sound file using https://www.npmjs.com/package/node-wav-player. @param {String} file File path or file name string. A file name without a directory component like 'sound.wav' will be prepended with feri's sound folder location. @return {Promise} */ if (config.playSound) { if (path.parse(file).dir === '') { // prepend the default path to feri sounds file = path.join(shared.path.self, 'sound', file) } if (typeof playSound !== 'object') { playSound = require('node-wav-player') } let proceed = true if (playSoundPlaying) { // a sound is currently playing if (file === playSoundLastFile) { // same file requested as file currently playing proceed = false } else { playSound.stop() } } // if if (proceed) { playSoundPlaying = true playSoundLastFile = file await playSound.play({ path: file, sync: true }) playSoundPlaying = false } // if } // if } // playSound functions.possibleSourceFiles = function functions_possibleSourceFiles(filePath) { /* Figure out all the possible source files for any given destination file path. @param {String} filepath File path like '/dest/code.js' @return {Object} Array of possible source files. */ filePath = functions.destToSource(filePath) let destExt = functions.fileExtension(filePath) let sources = [filePath] if (functions.fileExtension(filePath) !== 'concat' && config.fileType.concat.enabled) { sources.push(filePath + '.concat') } if (functions.fileExtension(filePath) !== 'jss' && config.fileType.jss.enabled) { sources.push(filePath + '.jss') } if (config.map.destToSourceExt.hasOwnProperty(destExt)) { let proceed = false if (destExt === 'map') { if (config.sourceMaps) { proceed = true } else { try { let parentFileType = functions.fileExtension(functions.removeExt(filePath)) if (config.fileType[parentFileType].sourceMaps) { proceed = true } } catch(e) { // do nothing } } } else if (destExt === 'gz') { try { let parentFileType = functions.fileExtension(functions.removeExt(filePath)) if (config.map.sourceToDestTasks[parentFileType].indexOf('gz') >= 0) { proceed = true } } catch(e) { // do nothing } } else if (destExt === 'br') { try { let parentFileType = functions.fileExtension(functions.removeExt(filePath)) if (config.map.sourceToDestTasks[parentFileType].indexOf('br') >= 0) { proceed = true } } catch(e) { // do nothing } } else { // file is not a BR, GZ, or MAP file type proceed = true } if (proceed) { let ext = '' let filePathMinusOneExt = '' let len = config.map.destToSourceExt[destExt].length for (let i = 0; i < len; i++) { ext = config.map.destToSourceExt[destExt][i] if (ext === '*') { // extension is in addition to another extension // for example, index.html.gz, library.js.map, etc... // trim off one file extension filePathMinusOneExt = functions.removeExt(filePath) if (filePathMinusOneExt.indexOf('.') >= 0) { // call self recursively sources = sources.concat(functions.possibleSourceFiles(filePathMinusOneExt)) } } else { let sourceFilePath = functions.changeExt(filePath, ext) sources.push(sourceFilePath) if (config.fileType.concat.enabled) { sources.push(sourceFilePath + '.concat') // check for a file like code.js.concat } } } } } return sources } // possibleSourceFiles functions.readFile = function functions_readFile(filePath, encoding) { /* Promisified version of fs.readFile. @param {String} filePath File path like '/dest/index.html' @param {String} [encoding] Optional and defaults to 'utf8' @return {String} Data from file. */ encoding = encoding || 'utf8' return fsReadFilePromise(filePath, { 'encoding': encoding }) } // readFile functions.readFiles = function functions_readFiles(filePaths, encoding) { /* Sequentially read in multiple files and return an array of their contents. @param {Object} filePaths Array of file paths like ['/source/file1.txt', '/source/file2.txt'] @param {String} [encoding] Optional and defaults to 'utf8' @return {Promise} Promise that returns an array of data like ['data from file1', 'data from file2'] */ encoding = encoding || 'utf8' let len = filePaths.length let p = Promise.resolve([]) for (let i = 0; i < len; i++) { (function() { let file = filePaths[i] p = p.then(function(dataArray) { return functions.readFile(file, encoding).then(function(data) { dataArray.push(data) return dataArray }).catch(function(err) { dataArray.push('') return dataArray }) }) })() } return p } // readFiles functions.removeDest = async function functions_removeDest(filePath, log, isDir) { /* Remove file or folder if unrelated to the source directory. @param {String} filePath Path to a file or folder. @param {Boolean} [log] Optional and defaults to true. Set to false to disable console log removal messages. @param {Boolean} [isDir] Optional and defaults to false. If true, log with 'words.removedDir' instead of 'words.remove'. @return {Promise} Promise that returns true if the file or folder was removed successfully otherwise an error if not. */ log = (log === false) ? false : true isDir = (isDir === true) ? true : false if (filePath.indexOf(config.path.source) >= 0) { throw new Error('functions.removeDest -> ' + shared.language.display('error.removeDest') + ' -> ' + filePath) } await functions.removeFile(filePath) if (isDir === false) { const fileExt = functions.fileExtension(filePath) const tasks = config.map.sourceToDestTasks[fileExt] if (Array.isArray(tasks)) { if (tasks.indexOf('br') >= 0) { await functions.removeFile(filePath + '.br') } if (tasks.indexOf('gz') >= 0) { await functions.removeFile(filePath + '.gz') } } // if } // if if (log) { let message = 'words.removed' if (isDir) { message = 'words.removedDirectory' } functions.log(color.gray(filePath.replace(config.path.dest, '/' + path.basename(config.path.dest)).replace(/\\/g, '/') + ' ' + shared.language.display(message))) } return true } // removeDest functions.removeExt = function functions_removeExt(filePath) { /* Remove one extension from a file path. @param {String} filePath File path like '/files/index.html.gz' @return {String} File path like '/files/index.html' */ return filePath.substr(0, filePath.lastIndexOf('.')) } // removeExt functions.removeFile = function functions_removeFile(filePath) { /* Remove a file or folder. @param {String} filePath String like '/dest/index.html' @return {Promise} Promise that returns true if the file or folder was removed or if there was nothing to do. An error otherwise. */ return rimraf(filePath, { glob: false }).then(function(error) { return error || true }) } // removeFile functions.removeFiles = function functions_removeFile(files) { /* Remove files and folders. @param {String,Object} files String like '/dest/index.html' or Object like ['/dest/index.html', '/dest/css'] @return {Promise} Promise that returns true if the files and folders were removed or if there was nothing to do. An error otherwise. */ if (typeof files === 'string') { files = [files] } let promiseArray = [] for (let i in files) { promiseArray.push(functions.removeFile(files[i])) } return Promise.all(promiseArray).then(function() { return true }) } // removeFiles functions.removeJsComments = function functions_removeJsComments(code) { /* Remove single line and block comments from JavaScript. @param {String} code JavaScript code to remove comments from. @return {String} JavaScript code without comments. */ // This function is a slightly modified version of https://stackoverflow.com/questions/5989315/regex-for-match-replacing-javascript-comments-both-multiline-and-inline#answer-52630274 let inQuoteChar = null let inBlockComment = false let inLineComment = false let inRegexLiteral = false let cleanedCode = '' const len = code.length for (var i = 0; i < len; i++) { if (!inQuoteChar && !inBlockComment && !inLineComment && !inRegexLiteral) { if (code[i] === '"' || code[i] === "'" || code[i] === '`') { inQuoteChar = code[i] } else if (code[i] === '/' && code[i+1] === '*') { inBlockComment = true } else if (code[i] === '/' && code[i+1] === '/') { inLineComment = true } else if (code[i] === '/' && code[i+1] !== '/') { inRegexLiteral = true } } else { if (inQuoteChar && ((code[i] === inQuoteChar && code[i-1] != '\\') || (code[i] === '\n' && inQuoteChar !== '`'))) { inQuoteChar = null } if (inRegexLiteral && ((code[i] === '/' && code[i-1] !== '\\') || code[i] === '\n')) { inRegexLiteral = false }