reading/pre-compiling-reader.js

'use strict'

const Parser = require('../parsing/parser2')
const Writer = require('../writing/html-string-writer')
const Reader = require('./reader')
const ComponentRegistry = require('./component-registry-reader')
const Interpolator = require('../compiling/interpolator')
const PathBuilder = require('../traversing/path-builder')
const createError = require('../utilities/error')

const resolve = require('path').resolve
const crypto = require('crypto')
const Nodes = require('node-html-light').Nodes
const Node = require('node-html-light').Node

const htmlParser = require('htmlparser2')
const domUtils = htmlParser.DomUtils

class PrecompilingReader extends Reader {

    constructor(basePath, options = {}) {
        super(basePath)

        if (options.registry && options.registry.enabled) {
            this._componentRegistry = new ComponentRegistry(basePath, options.registry)
        }
        this._parser = new Parser()
        this._writer = new Writer()
        this._interpolator = new Interpolator()
        this._pathBuilder = new PathBuilder()
        this._precompileResults = {}
        this._htmlFileCache = {}

        this._attributes_to_remove_if_value_false = ['autofocus', 'checked', 'disabled', 'required']
    }

    initialize() {
        if (this._componentRegistry) {
            if (!this._basePath) {
                throw createError('To enable component registry, please set a basePath in your Compiler. Current value is: ' + this._basePath + ' with length ' + this._basePath.length)
            }
            return this._componentRegistry.initializeRegistry()
        } else {
            return Promise.resolve()
        }
    }

    /** 
     * @param {Array<String>} arguments an array of path elements that will, 
     * once joined and resolved relative to the root directory, point to a html file that will be read and parsed
     * 
     * This specialized method will also precompile each fragment / HTML file to speed up compilation process
     * 
     * @returns {Array<Node>} an array of HTML Nodes
    */
    readNodes(root, path) {
        if (!path) {
            path = root
            root = this._basePath
        }

        const cachePath = path

        if (Object.prototype.hasOwnProperty.call(this._htmlFileCache, cachePath)) {
            const text = this._htmlFileCache[cachePath]
            const node = Node.fromString(text)
            return Promise.resolve(this._isArrayElseToArray(node))
        }

        const resolvedPath = resolve(root, path)
        return this._readFileFromDisk(resolvedPath).then((html) => {

            let node = Node.fromString(html)
            let nodes = this._isArrayElseToArray(node)

            const htmlWithComponents = this._resolveComponents(nodes)
            this._htmlFileCache[cachePath] = htmlWithComponents

            node = Node.fromString(htmlWithComponents)
            nodes = this._isArrayElseToArray(node)

            if (!this._precompileResults[cachePath]) {
                const result = this._precompile(nodes)
                this._precompileResults[cachePath] = result
            }

            return nodes
        })
    }

    /** @private */
    _resolveComponents(nodes) {
        if (this._componentRegistry) {
            for (let index = nodes.length - 1; index >= 0; index--) {
                const root = nodes[index]
                root.filterByType((node) => {
                    const name = node.name
                    if (this._componentRegistry.hasComponent(name)) {

                        const component = this._componentRegistry.getComponent(name)
                        if (!component.template || typeof component.template !== 'function') {
                            throw createError(`Component with name "${name}" has no template function. Value of ${name}.template is ${component.template}`)
                        }

                        if (!component.render || typeof component.render !== 'function') {
                            throw createError(`Component with name "${name}" has no render function. Value of ${name}.render is ${component.render}`)
                        }

                        const componentNodes = this._isArrayElseToArray(Node.fromString(component.template()))
                        let renderedNodes = component.render(componentNodes, node.attribs)

                        if (!renderedNodes) {
                            throw createError(`No nodes for precompilation available. 

Does the render function of "${name}" return nodes?"
                            
${component.render.toString()}
                            `)
                        }

                        if (!node.parent || node.parent.type === 'root') {
                            nodes[index] = renderedNodes[0]

                            renderedNodes.slice(1).reverse().forEach((componentNode) => {
                                nodes.splice(index + 1, 0, componentNode)
                            })
                        } else {
                            renderedNodes.reverse().forEach((componentNode) => {
                                root.appendChildAfter(componentNode, Node.of(node))
                            })

                            root.removeChild(Node.of(node))
                        }

                        this._resolveComponentsTemplatesAndSlots(renderedNodes, Node.of(node))
                        this._resolveComponents(renderedNodes)
                    }
                }, [Node.TYPE_TAG])
            }
        }
        return this._writer.toHtmlString(nodes)
    }

    _resolveComponentsTemplatesAndSlots(componentTemplate, htmlPlaceholderNode) {
        const templates = this._findTemplateTagsRecursively(htmlPlaceholderNode)
        const slots = componentTemplate.reduce((current, next) => {
            return current.concat(next.find('slot'))
        }, [])

        if (slots.length !== templates.length) {
            throw createError(`Uneven number of templates and slots. "${htmlPlaceholderNode.toHtml()}" has ${templates.length} templates and component "${this._writer.toHtmlString(componentTemplate)}" has ${slots.length} slots.`)
        }

        /**
         * @typedef {Object} SlotAndTemplate
         * @property {Node} slot
         * @property {Array.<Node>} template
         */

        /**
         * @type {Array.<SlotAndTemplate>}
         */
        const slotsAndTemplatesByName = slots.reduce((current, next) => {
            let name = next.attributes.name
            const templatesForName = templates.filter(template => template.attributes.slot === name)

            if (!templatesForName[0]) {
                throw createError(`Could not find a template for slot with name "${name}" element in "${htmlPlaceholderNode.toHtml()}"`)
            }
            if (name && Object.prototype.hasOwnProperty.call(current, name)) {
                throw createError(`There is more than one slot with name "${name}" in "${componentTemplate.toHtml}"`)
            }
            if (!name) {
                name = ''
            }
            if (!current[name]) {
                current[name] = []
            }

            current[name].push({
                slot: next,
                template: templatesForName[0].children
            })
            return current
        }, {})

        Object.entries(slotsAndTemplatesByName).forEach(([, [{ slot, template }]]) => {
            template.reverse().forEach((template) => {
                slot.root.appendChildAfter(template, slot)
            })

            slot.root.removeChild(slot)
        })

        return componentTemplate
    }

    /**
     * Search child elements of the given node for template elements. To allow nesting of components, search will end when another Component element
     * was found in child elemens
     * 
     * @private
     * @param {Node} search the root node
     * @returns {Array.<Nodes>} found template elements
     */
    _findTemplateTagsRecursively(search) {
        const templateNodes = []

        search.children.forEach(searchNode => {
            domUtils.filter((n) => {
                const node = new Node(n)
                const name = node.name
                if (this._componentRegistry.hasComponent(name)) {
                    return
                } else if (name === 'template') {
                    templateNodes.push(node)
                } else if (node.type === Node.TYPE_TAG) {
                    templateNodes.push(this._findTemplateTagsRecursively(node))
                }

            }, searchNode.get(), false, Infinity)
        }, [])

        return templateNodes
    }

    /** @private */
    _precompile(nodes) {
        const result = []

        new Nodes(nodes).forEach((node, index) => {
            if (!result[index]) {
                result[index] = {
                    attributes: {},
                    commands: {},
                    conditionalTemplate: {},
                    forEach: {},
                    add: {},
                    if: {},
                    include: {},
                    import: {},
                    text: {}
                }
            }

            node.filterByType((node) => {
                const type = node.type
                const attributes = node.attribs

                if (type === Node.TYPE_TAG || type === Node.TYPE_STYLE || type === Node.TYPE_SCRIPT) {
                    for (let key in attributes) {
                        const value = attributes[key]
                        if (node.name === 'amy:conditional-template') {
                            this._handleConditionalTemplate(node, result, index)
                        } else if (this._interpolator.canInterpolate(value)) {
                            this._handleInterpolatableAttribute(node, result, index, value, key)
                        }
                    }
                }

                if (type === Node.TYPE_COMMENT) {
                    const text = node.data
                    if (this._parser.isParseable(text)) {
                        this._handleParseableCommand(node, text, result, index)
                    }
                }

                if (type === Node.TYPE_TEXT) {
                    const text = node.data
                    if (this._interpolator.canInterpolate(text)) {
                        this._handleInterpolatableText(node, text, result, index)
                    }
                }
            }, [Node.TYPE_TAG, Node.TYPE_STYLE, Node.TYPE_SCRIPT, Node.TYPE_COMMENT, Node.TYPE_TEXT])
        })

        return result
    }

    _handleInterpolatableAttribute(node, result, index, value, key) {
        const id = this._createId()
        const path = this._pathBuilder.buildPathFromParentToNode(node)

        result[index].attributes[id] = (node, context) => {
            const target = path.reduce((parent, index) => {
                return parent.children[index]
            }, node.get())

            const newValue = this._interpolator.interpolate(value, context)
            if (newValue === 'false' && this._attributes_to_remove_if_value_false.includes(key)) {
                delete target.attribs[key]
            } else {
                target.attribs[key] = newValue
            }
        }
    }

    _createId() {
        return crypto.randomBytes(32).toString('base64')
    }

    _handleConditionalTemplate(node, result, index) {
        const id = this._createId()
        const path = this._pathBuilder.buildPathFromParentToNode(node)

        result[index].conditionalTemplate[id] = (node, context) => {
            const target = new Node(path.reduce((parent, index) => {
                return parent.children[index]
            }, node.get()))

            const replace = this._interpolator.interpolate(target.attributes.render, context)
            if (replace && replace !== 'false') {
                target.children.forEach((child) => {
                    target.parent.appendChildBefore(child, target)
                })
            }
            target.parent.removeChild(target)
        }
    }

    _handleParseableCommand(node, text, result, index) {
        const id = this._createId()
        const path = this._pathBuilder.buildPathFromParentToNode(node)
        const commands = this._parser.parseLine(text)

        let command

        if (this._hasCommandWithName(commands, 'if')) {
            command = 'if'
        } else if (this._hasCommandWithName(commands, 'forEach')) {
            command = 'forEach'
        } else if (this._hasCommandWithName(commands, 'import')) {
            command = 'import'
        } else if (this._hasCommandWithName(commands, 'add')) {
            command = 'add'
        } else if (this._hasCommandWithName(commands, 'include')) {
            command = 'include'
        }

        result[index][command][id] = (node, context, callback) => {
            const target = path.reduce((parent, index) => {
                return parent.children[index]
            }, node.get())
            callback(new Node(target))
        }
        result[index].commands[id] = (node, context, callback) => {
            const target = path.reduce((parent, index) => {
                return parent.children[index]
            }, node.get())
            callback(new Node(target))
        }
    }

    _handleInterpolatableText(node, text, result, index) {
        const id = this._createId()
        const path = this._pathBuilder.buildPathFromParentToNode(node)
        const tokens = this._interpolator.interpolatables(text)

        result[index].text[id] = (node, context) => {
            const target = path.reduce((parent, index) => {
                return parent.children[index]
            }, node.get())

            target.data = this._interpolator.interpolateWithTokens(tokens, context)
        }
    }

    _hasCommandWithName(commands, name) {
        return commands.find(command => command.name() === name)
    }
}

module.exports = PrecompilingReader