UNPKG

node-amy

Version:

A HTML template framework without client-side JavaScript dependencies

373 lines (309 loc) 14 kB
'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