/**
* @module lib/util
*/
const fs = require('fs')
const path = require('path')
const { promisify } = require('util')
const stat = promisify(fs.stat)
const mkdir = promisify(fs.mkdir)
const writeFile = promisify(fs.writeFile)
const readFile = promisify(fs.readFile)
const readdir = promisify(fs.readdir)
/**
* ensures the given path exists, if not recursively generates folder leading to the paths
* @method ensureDirectoryExists
* @param {String} directory - the given path
*/
async function ensureDirectoryExists (directory) {
const parts = directory.split('/').slice(1)
let currentDirectory = ''
while (parts.length > 0) {
currentDirectory += `/${parts.shift()}`
try {
await stat(currentDirectory)
} catch (ex) {
await mkdir(currentDirectory)
}
}
}
/**
* recursively copies the content of one directory to another
* @method copyDirectory
* @param {String} source - path to source
* @param {String} destination - path to destination
*/
async function copyDirectory (source, destination) {
const stats = await stat(source)
if (!stats.isFile() && stats.isDirectory()) {
await ensureDirectoryExists(destination)
const files = await readdir(source)
for (var i = 0; i < files.length; i++) {
const childItemName = files[i]
await copyDirectory(path.join(source, childItemName), path.join(destination, childItemName))
}
} else {
await writeFile(destination, await readFile(source))
}
}
/**
* retrieves a config from the given directory
* @method getConfig
* @param {String} directory - path of directory
* @return {Object} - config object
*/
async function getConfig (directory) {
try {
await stat(`${directory}/.sweeney`)
const config = require(`${directory}/.sweeney`)
switch (typeof config) {
case 'object':
return config
case 'function':
return config()
}
} catch (ex) {
// propogate message to the user, this is something with the config
if (ex.message.indexOf('no such file or directory') === -1) {
throw ex
}
return {}
}
}
function escapeRegexValues (string) {
return string.replace(/[-[\]{}()*+!<=:?.\\^$|#\s,]/g, '\\$&')
}
/**
* turns a template object back into a string
* @param {Object} templateObject - template object obtained by using parseString or parse
* @return {String} - stringified template
*/
function templateToString (templateObject) {
const { options, content, type } = templateObject
return `
---
${JSON.stringify(Object.assign(options, { type }), null, 4)}
---
${content}
`.trim()
}
/**
* parses a template string for any options or content it contains
* @method parseString
* @param {String} filePath - The file path of where the string is from, needed for includes or anything path related
* @param {String} content - Template string
* @return {Object} - parsed template output
*/
async function parseString (plugins, filePath, content) {
const config = {}
// this is the name of the file without the extension, used for internal use or during the output process
config.name = filePath.substring(filePath.lastIndexOf('/') + 1, filePath.length - 3)
// let's run through the plugins
const pluginNames = Object.keys(plugins)
for (const name of pluginNames) {
const plugin = plugins[name]
if (!plugin.parse) continue
const output = await plugin.parse(filePath, content)
if (output) {
if (output.content !== content) content = output.content // update the content with the augmented one
if (output.found.length > 0) config[name] = output.found
}
}
if (/^---\n/.test(content)) {
var end = content.search(/\n---\n/)
if (end !== -1) {
const parsed = JSON.parse(content.slice(4, end + 1))
// this is the top level attribute in the render function that will allow users to do things like
// {{ posts.forEach((post) => {...}) }} _In this case posts will be an array_
config.collection = parsed.collection || 'page'
// type is the value that the parser will look at to decide what to do with that file
config.type = parsed.type || 'html'
config.options = parsed
config.content = content.slice(end + 5)
if (parsed.layout) config.layout = parsed.layout
}
} else {
config.content = content
config.type = 'html'
config.collection = 'page'
}
return config
}
/**
* parses the contents of a string to find the options block
* @method parse
* @param {String} content - file contents that could potentially contain options
* @return {Object} - with the attributes options and content
*/
async function parse (plugins, filePath) {
const content = await readFile(filePath, 'utf8')
const config = {
filePath,
options: {},
content: content
}
return Object.assign(config, await parseString(plugins, filePath, content))
}
/**
* takes ms and returns human readable time string
* @method ms
* @memberof lib/util
* @param {Number} ms - time in milleseconds
* @return {String} - human readable string
*/
const s = 1000
const m = s * 60
const h = m * 60
const d = h * 24
function ms (ms) {
if (ms >= d) {
return `${Math.floor(ms / d)}d`
}
if (ms >= h) {
return `${Math.floor(ms / h)}h`
}
if (ms >= m) {
return `${Math.floor(ms / m)}m`
}
if (ms >= s) {
return `${Math.floor(ms / s)}s`
}
return ms.toFixed(4).replace(/\.0000$/, '') + 'ms'
}
/**
* Renders a template object, parsed by `parse`
* @method render
* @param {Object} templates - object key template values
* @param {Object} template - parsed template object
* @param {Object} additional - additional data that needs to be merged into template data
* @return {String} - rendered template to string
*/
async function render (plugins, templates, template, additional = {}) {
const start = process.hrtime()
const { filePath } = template
let { content = '' } = template
// combine the data from the template and whatever is passed in
const data = merge(additional, template)
if (!content) return
try {
// let's run through rendering any plugin data that was parsed out
const pluginNames = Object.keys(plugins)
const tempDepends = []
for (var p = 0; p < pluginNames.length; p++) {
const name = pluginNames[p]
const plugin = plugins[name]
if (data[name] && data[name].length > 0) {
for (const found of data[name]) {
if (!plugin.render) continue
const output = await plugin.render(plugins, filePath, content, templates, data, found)
if (output.content) content = output.content
if (output.depends) tempDepends.push(output.depends)
}
}
}
if (tempDepends.length > 0) {
data.depends = [].concat.apply([], tempDepends)
}
const templ = content.replace(/[\r\t\n]/g, ' ')
const re = /{{[\s]*(.+?)[\s]*}}/g
let code = 'var r=[];\n'
let cursor = 0
let match = ''
function add (line, js) { // eslint-disable-line no-inner-declarations
const reExp = /(^( )?(if|for|else|switch|case|break|{|}))(.*)?/g
js ? (code += line.match(reExp) ? line + '\n' : 'r.push(' + line + ');\n')
: (code += line !== '' ? 'r.push("' + line.replace(/"/g, '\\"') + '");\n' : '')
return add
}
while ((match = re.exec(templ)) !== null) {
add(templ.slice(cursor, match.index))(match[1], true)
cursor = match.index + match[0].length
}
add(templ.substr(cursor, templ.length - cursor))
code += 'return r.join("");'
/* eslint-disable no-new-func */
const rendered = new Function(`
with(this) {
${code.replace(/[\r\t\n]/g, '')}
}
`).bind(data)()
/* eslint-enable no-new-func */
// if this template has a layout file we must render this then the layout file
const layout = template.layout && templates[template.layout]
// why are we doing this, well we pass around data a fairbit and we want to snapshot the state of the depends field at this point
const depends = data.depends ? JSON.parse(JSON.stringify(data.depends)) : []
const safeData = {}
Object.keys(data).forEach((k) => {
if (k !== 'depends' && k !== 'layout') {
safeData[k] = data[k]
}
})
if (layout) {
// we have to delete the old layout
delete data['layout']
const output = await render(plugins, templates, layout, Object.assign({
child: rendered
}, safeData))
return {
data: JSON.parse(JSON.stringify(safeData)),
filePath,
depends: output,
rendered: output.rendered,
time: process.hrtime(start)[1] / 1000000
}
} else {
return {
data: JSON.parse(JSON.stringify(safeData)),
filePath,
rendered,
depends, // this is if any plugin has added dependency information to the template
time: process.hrtime(start)[1] / 1000000
}
}
} catch (ex) {
throw new Error(JSON.stringify({
error: `Error building template ${filePath}`,
content: content,
stack: ex.stack
}))
}
}
/**
* deep merge two objects (either arrays or plain javascript objects)
* @method merge
* @param {Object|Array} target - an array or object, must be the same type as the other value being passed
* @param {Object|Array} source - an array or object, must be the same type as the other value being passed
* @return {Object|Array} - an array or object, depending on what has been passed in
*/
function merge (target, source) {
if (typeof target === 'object' && typeof source === 'object') {
for (const key in source) {
if (source[key] === null && (target[key] === undefined || target[key] === null)) {
target[key] = null
} else if (source[key] instanceof Array) {
if (!target[key]) target[key] = []
// concatenate arrays
target[key] = target[key].concat(source[key])
} else if (typeof source[key] === 'object') {
if (!target[key]) target[key] = {}
merge(target[key], source[key])
} else {
target[key] = source[key]
}
}
}
return target
}
/**
* recursively goes through render item to find the total time of rendered dependencies
* @param {Object} item - rendered item output by render method
* @return {Number} - time in milleseconds
*/
function getTotalTimeOfDepends (item) {
let time = 0
if (Array.isArray(item)) {
time += item.map((i) => getTotalTimeOfDepends(i)).reduce((a, b) => a + b, 0)
}
if (!Array.isArray(item) && typeof item === 'object') {
time += item.time
}
// recursively find the depends values
if (item.depends) time += getTotalTimeOfDepends(item.depends)
return time
}
/**
* renders a dependency tree from the given render item
* @param {Object} item - render item
* @param {Number} level - the level of the tree that the element is a part of (by default is 0)
* @return {String} - ascii representation of render tree for the given item
*/
function renderSubDepends (item, level = 0) {
let output = ''
if (Array.isArray(item)) {
output += item.map((i) => renderSubDepends(i, level)).join('')
}
if (!Array.isArray(item) && typeof item === 'object') {
output += '\n' + `${level === 0 ? '' : ' '.repeat(level * 2) + '-'} ${item.filePath} [${ms(item.time)}]`
}
if (item.depends) output += renderSubDepends(item.depends, level + 1)
return output
}
module.exports = {
merge,
parse,
parseString,
render,
ms,
getConfig,
escapeRegexValues,
ensureDirectoryExists,
copyDirectory,
renderSubDepends,
templateToString,
getTotalTimeOfDepends
}