'use strict'
const Node = require('node-html-light').Node
const Nodes = require('node-html-light').Nodes
const Reader = require('../reading/pre-compiling-reader')
const Writer = require('../writing/writer')
const Parser = require('../parsing/parser2')
const Interpolator = require('./interpolator')
const createError = require('../utilities/error')
/** */
class TemplateCompiler {
constructor(inputPath, removeComments, options = {}) {
this._basePath = inputPath
this._reader = new Reader(inputPath, options.reader)
this._writer = new Writer()
this._parser = new Parser()
this._interpolator = new Interpolator()
this._compileCache = {}
if (removeComments !== undefined) {
this._removeComments = removeComments === true
} else {
this._removeComments = process.env.NODE_ENV === 'production'
}
// attributes like checked have no values
this._attributes_which_values_are_ignored = ['autofocus', 'checked', 'disabled', 'required']
}
/**
*
* @param {String} glob the glob pattern
* @returns {Promise} a promise that will be resolved once all templates are loaded
*/
initialize(glob) {
return this._reader.initialize().then(() => {
return this._findNodes(glob, this._basePath)
})
}
/**
* Find all html fragments, that match the given pattern
* @param {String} glob the glob pattern
* @param {String} inputPath the root folder to start searching
* @returns {Promise} a promise that will be resolved once all templates are loaded
* @private
*/
_findNodes(glob, inputPath) {
return this._reader.matchFiles(glob, inputPath).then((inputFiles) => {
console.log('Runtime Compiler: Initializing nodes from ', inputFiles)
return this._readNodes(inputPath, inputFiles)
})
}
/**
* Instructs the fragment reader to read and cache given set of html fragments
* @param {String} inputPath the root folder to start searching
* @param {String} inputFiles the html fragments to read and cache
* @returns {Promise} a promise that will be resolved once all templates are loaded
* @private
*/
_readNodes(inputPath, inputFiles) {
return Promise.all(inputFiles.map((inputFile) => {
return this._reader.readNodes(inputPath, inputFile)
}))
}
/**
* Compiles the given template file and writes
* to the output directory. Commands are executed using the given context.
*
* @param {String} inputPath the base path of the input file
* @param {String} inputFile the input file
* @param {String} outputPath the output path of the compiled files
* @param {Object} context the context that will be used to execute commands and interpolate placeholders
* @returns {Promise} a promise that will be resolved once all templates are compiled
*/
compile(inputFile, context, shouldBeCached) {
if (shouldBeCached && this._compileCache[inputFile]) {
return Promise.resolve(this._compileCache[inputFile])
} else {
return this._compileFile(this._basePath, inputFile, context).then((nodes) => {
return this._writer.toHtmlString(nodes)
}).then((html) => {
if (shouldBeCached) {
this._compileCache[inputFile] = html
}
return html
})
}
}
/**
* Compiles the given template file and writes
* to the output directory. Commands are executed using the given context.
*
* @param {String} inputPath the base path of the input file
* @param {String} inputFile the input file
* @param {String} outputPath the output path of the compiled files
* @param {Object} context the context that will be used to execute commands and interpolate placeholders
* @returns {Promise} a promise that will be resolved once all templates are compiled
*/
_compileFile(inputPath, inputFile, context) {
return this._reader.readNodes(inputPath, inputFile)
.then((nodes) => {
return this._interpolatePrecompiled(inputFile, nodes, context)
}).then((nodes) => {
return this._compile(inputFile, nodes, context)
})
}
/** @private */
_compile(inputFile, nodes, context) {
const promises = []
const precompileResults = this._reader._precompileResults[inputFile]
new Nodes(nodes).forEach((node, index) => {
if (!precompileResults[index]) {
return
}
// iterating backwards because we might insert some elements
// while iterating through the array. iterating backwards will
// help us do that because we will insert at index i+1
Object.keys(precompileResults[index].commands).reverse().forEach((key) => {
const commandCallback = precompileResults[index].commands[key]
commandCallback(node, {}, (commentNode => {
if (this._parser.isParseable(commentNode.get().data)) {
promises.push(this._execute(commentNode, context).then((elements) => {
if (elements) { // could be falsy case for marker commands like 'include'
elements.reverse().forEach((element) => {
// it is important to check the parent of the comment node because
// if we are compiling a template which contains a command at the
// highest level of the dom, we cannot just append the element node.
// hell we can but the node won't make it into the serialized output
// so if we have no parent we gotta insert the new element manually into the nodes array
const parent = commentNode.parent
if (!parent || parent.type === 'root') {
nodes.splice(index + 1, 0, element)
} else {
parent.appendChildAfter(element, commentNode)
}
})
}
// don't remove include comments here because we have not yet
// executed them
if (this._removeComments && !commentNode.get().data.includes('include')) {
commentNode.removeChild(commentNode)
}
}))
}
}))
})
})
return Promise.all(promises).then(_ => nodes).then(() => {
// need to remove conditionals after compiliation
// because we depend on correct indices of commands, attributes and conditionalTemplates
new Nodes(nodes).forEach((node, index) => {
if (!precompileResults[index]) {
return
}
Object.keys(precompileResults[index].conditionalTemplate).forEach((key) => {
precompileResults[index].conditionalTemplate[key](node, context)
})
})
return nodes
})
}
/** @private */
_execute(commentNode, context) {
const rawCommands = this._parser.parseLine(commentNode.get().data)
const commands = this._createCommandsObject(rawCommands)
// if needs to go first. if it returns true we can execute other commands
if (commands.if) {
const truthy = this._if(commands, context)
if (!truthy) {
return Promise.resolve()
}
}
if (commands.forEach) {
return this._reader.readNodes(commands.import.arguments()).then((nodes) => {
return this._forEach(nodes, commands, context)
})
} else if (commands.add) {
return this._add(commands, context)
} else if (commands.import) {
return this._import(commands, context)
} else if (commands.include) {
return Promise.resolve()
} else {
throw createError('Cannot handle unknown command in comment', commentNode.get().data, context)
}
}
/** @private */
_if(commands, context) {
const value = this._interpolator.valueFor(commands.if.arguments(), (context))
if (commands.is) {
return this._is(commands, value)
} else if (value) {
return value
}
}
_is(commands, value) {
switch (commands.is.arguments()) {
case 'truthy': {
return value
}
case 'falsy': {
return !value
}
case 'true': {
return value === true
}
case 'false': {
return value === false
}
default:
throw createError('Cannot handle unknown conditional in command', commands.is, value)
}
}
/** @private */
_forEach(nodes, commands, globalContext) {
const elements = this._interpolator.valueFor(commands.forEach.arguments(), (globalContext))
if (!nodes) {
throw createError('Cannot invoke forEach operation on falsy nodes. Did you invoke forEach before import?', commands.forEach, globalContext)
}
if (!Array.isArray(elements)) {
throw createError(`Cannot invoke forEach operation with a context that is not of type Array: Context is ${typeof elements} and has value ${elements}`, commands.forEach, globalContext)
}
const htmlStrings = nodes.map((node) => node.toHtml())
const promises = elements.map((element) => {
const nodes = htmlStrings.map((string) => Node.fromString(string))
const context = this._alias(commands, element)
return this._compile(commands.import.arguments(), nodes, context).then((compiled) => {
return this._interpolatePrecompiled(commands.import.arguments(), compiled, context)
})
})
return Promise.all(promises).then(nodes => {
return nodes.flat(1)
})
}
_add(commands, context) {
// need to create new commands object to support, i.e.
// import axz and add abc.html with context as contextName
const indexOfInclude = commands._raw.findIndex((command) => {
return command.name() === 'add'
})
const includeCommands = this._createCommandsObject(commands._raw.slice(indexOfInclude))
includeCommands.import = includeCommands.add
const parentCommands = this._createCommandsObject(commands._raw.slice(0, indexOfInclude))
return this._import(includeCommands, context).then((newChildNodes) => {
return this._import(parentCommands, context).then((parentNodes) => {
return this._include(parentNodes, newChildNodes)
})
})
}
_include(siblingNodes, newChildNodes) {
const topLevelIncludeCommentNodeIndex = siblingNodes.findIndex((node) => {
if (node.type === Node.TYPE_COMMENT) {
const commands = this._parser.parseLine(node.get().data)
if (commands.find((command) => {
return command.name() === 'include'
})) {
return true
}
}
})
if (topLevelIncludeCommentNodeIndex !== -1) {
const topLevelIncludeCommentNode = siblingNodes[topLevelIncludeCommentNodeIndex]
// newChildNodes.reduce((current, next) => {
// current.appendChildAfter(next, current)
// return current
// }, topLevelIncludeCommentNode)
newChildNodes.forEach((node, index) => {
siblingNodes.splice(topLevelIncludeCommentNodeIndex + topLevelIncludeCommentNodeIndex, 0, node)
})
if (this._removeComments) {
topLevelIncludeCommentNode.removeChild(topLevelIncludeCommentNode)
siblingNodes.splice(topLevelIncludeCommentNodeIndex, 1)
}
return siblingNodes
}
// if theres no include comment at root level of the fragment
// we need to traves all node
siblingNodes.some((parentNode) => {
const commentNodes = parentNode.find({
type: Node.TYPE_COMMENT
})
return commentNodes.some((commentNode) => {
const commands = this._parser.parseLine(commentNode.get().data)
const includeCommand = commands.find((command) => {
return command.name() === 'include'
})
if (includeCommand && commands.length == 1) {
for (let i = newChildNodes.length - 1; i >= 0; i--) {
parentNode.appendChildAfter(newChildNodes[i], commentNode)
}
if (this._removeComments) {
commentNode.removeChild(commentNode)
}
return true;
}
})
})
return siblingNodes
}
/** @private */
_import(commands, globalContext) {
const inputFile = commands.import.arguments()
return this._reader.readNodes(inputFile).then((nodes) => {
const context = this._alias(commands, globalContext)
return this._compile(inputFile, nodes, context).then((nodes) => {
return this._interpolatePrecompiled(inputFile, nodes, context)
})
})
}
_interpolatePrecompiled(inputFile, nodes, context) {
const precompileResults = this._reader._precompileResults[inputFile]
new Nodes(nodes).forEach((node, index) => {
if (!precompileResults[index]) {
return
}
Object.entries(precompileResults[index].attributes).forEach(entry => {
entry[1](node, context)
})
Object.entries(precompileResults[index].text).forEach(entry => {
entry[1](node, context)
})
})
return nodes
}
/** @private */
_alias(commands, context) {
const aliasCommand = commands.as
const withCommand = commands.with
if (!aliasCommand && !withCommand) {
// return the initial context
return context
} else if (aliasCommand && !withCommand) {
// return a new object with the alias arguments values as key
// and the initial context as value
const aliasArgument = aliasCommand.arguments()
const newContext = {}
newContext[aliasArgument] = context
return newContext
} else if (!aliasCommand && withCommand) {
// no need to return a new object, extract the value of the with arguments
// and get the value from the initial contet
const withArgument = withCommand.arguments()
const withValue = this._interpolator.valueFor(withArgument, context)
return withValue
} else {
// having both commands means we will return a new object with the
// alias arguments values as key and the value which we extracted out of our
// context with help from the with commands argument
const aliasArgument = aliasCommand.arguments()
const withArgument = withCommand.arguments()
const withValue = this._interpolator.valueFor(withArgument, context)
const newContext = {}
newContext[aliasArgument] = withValue
return newContext
}
}
_createCommandsObject(rawCommands) {
return {
if: this._commandForName(rawCommands, 'if'),
forEach: this._commandForName(rawCommands, 'forEach'),
import: this._commandForName(rawCommands, 'import'),
add: this._commandForName(rawCommands, 'add'),
include: this._commandForName(rawCommands, 'include'),
is: this._commandForName(rawCommands, 'is'),
as: this._commandForName(rawCommands, 'as'),
with: this._commandForName(rawCommands, 'with'),
_raw: rawCommands
}
}
_commandForName(commands, name) {
return commands.find((command) => {
return command.name() === name
})
}
}
module.exports = TemplateCompiler