feri
Version:
An easy to use build tool for web files.
1,555 lines (1,215 loc) • 79.8 kB
JavaScript
'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
}