UNPKG

@adobe/htlengine

Version:

Javascript Based HTL (Sightly) parser

341 lines (306 loc) 12.1 kB
/* * Copyright 2018 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ const ElementContext = require('./ElementContext'); const ExpressionTransformer = require('./ExpressionTransformer'); const ExpressionContext = require('./ExpressionContext'); const SymbolGenerator = require('./SymbolGenerator'); const MarkupContext = require('./MarkupContext'); const HTLParser = require('../htl/HTLParser'); const Identifier = require('../htl/nodes/Identifier'); const UnaryOperation = require('../htl/nodes/UnaryOperation'); const UnaryOperator = require('../htl/nodes/UnaryOperator'); const StringConstant = require('../htl/nodes/StringConstant'); const RuntimeCall = require('../htl/nodes/RuntimeCall'); const MultiOperation = require('../htl/nodes/MultiOperation'); const OutText = require('../commands/OutText'); const Doctype = require('../commands/Doctype'); const Comment = require('../commands/Comment'); const CreateElement = require('../commands/CreateElement'); const PushElement = require('../commands/PushElement'); const PopElement = require('../commands/PopElement'); const AddAttribute = require('../commands/AddAttribute'); const VariableBinding = require('../commands/VariableBinding'); const Conditional = require('../commands/Conditional'); const OutputVariable = require('../commands/OutputVariable'); const PluginSignature = require('./PluginSignature'); const PluginContext = require('./PluginContext'); /* eslint-disable global-require */ const PLUGINS = { set: require('../plugins/SetPlugin'), text: require('../plugins/TextPlugin'), list: require('../plugins/ListPlugin'), repeat: require('../plugins/RepeatPlugin'), test: require('../plugins/TestPlugin'), unwrap: require('../plugins/UnwrapPlugin'), attribute: require('../plugins/AttributePlugin'), use: require('../plugins/UsePlugin'), resource: require('../plugins/ResourcePlugin'), include: require('../plugins/IncludePlugin'), template: require('../plugins/TemplatePlugin'), call: require('../plugins/CallPlugin'), element: require('../plugins/ElementPlugin'), }; /* eslint-enable global-require */ const SLY_COMMENT_PREFIX = '/*'; const SLY_COMMENT_SUFFIX = '*/'; module.exports = class MarkupHandler { constructor(stream) { this._stream = stream; this._htlParser = new HTLParser(); this._transformer = new ExpressionTransformer(); this._symbolGenerator = new SymbolGenerator(); this._defaultMarkupContext = MarkupContext.HTML; } /** * Sets the default markup context when writing properties to the response. * @param {MarkupContext} context the default context * @return this */ withDefaultMarkupContext(context) { this._defaultMarkupContext = context; return this; } onDocumentStart() { this._stack = []; } onDocumentEnd() { this._stream.close(); } onOpenTagStart(tagName) { this._stack.push(new ElementContext(tagName)); const tag = tagName.toLowerCase(); this._inScriptOrStyle = tag === 'script' || tag === 'style'; } onAttribute(name, value, quoteChar, line, column) { const context = this._stack[this._stack.length - 1]; const signature = PluginSignature.fromAttribute(name); if (signature) { this._handlePlugin(name, signature, value, context, { line, column }); } else { context.addAttribute(name, value, { line, column }); } } onOpenTagEnd(isEmpty = false, isVoid = false) { // const markup = isEmpty ? '/>' : '>'; const context = this._stack[this._stack.length - 1]; const { plugin } = context; const stream = this._stream; this._inScriptOrStyle = false; // we never have nesting script or style tags. plugin.beforeElement(stream, context); if (context.isSlyTag) { stream.beginIgnore(); } plugin.beforeTagOpen(stream, isEmpty, isVoid); // this._out(`<${context.tagName}`); this._stream.write(new CreateElement(context.tagName, isEmpty, isVoid)); plugin.beforeAttributes(stream); this._emitAttributes(context, plugin); plugin.afterAttributes(stream); // this._out(markup); this._stream.write(new PushElement(context.tagName, isEmpty, isVoid)); plugin.afterTagOpen(stream); if (context.isSlyTag) { stream.endIgnore(); } plugin.beforeChildren(stream); if (isEmpty || isVoid) { this._onEndTag(isEmpty, isVoid); } } onCloseTag(tagName, isVoid) { if (!isVoid) { this._onEndTag(false, isVoid); } } _onEndTag(isEmpty, isVoid) { const context = this._stack.pop(); const { plugin } = context; const stream = this._stream; plugin.afterChildren(stream); if (context.isSlyTag) { stream.beginIgnore(); } plugin.beforeTagClose(stream, isEmpty, isVoid); this._stream.write(new PopElement(context.tagName, isEmpty, isVoid)); plugin.afterTagClose(stream, isEmpty); if (context.isSlyTag) { stream.endIgnore(); } plugin.afterElement(stream); } onText(text, line, column) { const markupContext = this._inScriptOrStyle ? MarkupContext.TEXT : this._defaultMarkupContext; this._outText(text, markupContext, line, column); } onComment(markup, line, column) { const trimmed = markup ? markup.trim() : ''; const isSlyComment = trimmed.startsWith(SLY_COMMENT_PREFIX) && trimmed.endsWith(SLY_COMMENT_SUFFIX); if (!isSlyComment) { this._outComment(markup, { line, column }); } } onDocType(markup, line, column) { // todo: respect proper name this._stream.write(new Doctype('html', '', '', { line, column })); } _handlePlugin(name, signature, value, context, location) { const expressionContext = ExpressionContext.getContextForPlugin(signature.name); const interpolation = this._htlParser.parse(value, location); const expr = this._transformer.transform(interpolation, null, expressionContext); const PluginClass = this._lookupPlugin(signature.name); const pluginContext = new PluginContext(this._symbolGenerator, this._transformer, this._stream); const plugin = new PluginClass(signature, pluginContext, expr, location); if (plugin.isValid()) { context.addPlugin(plugin); context.addPluginAttribute(name, signature, expr); } } _emitAttributes(context, plugin) { Object.values(context.attributes).forEach((attr) => { const { name, value, location } = attr; if (attr.callback) { plugin.beforeAttribute(this._stream, name); attr.callback(context, this._stream, attr); plugin.afterAttribute(this._stream, name); } else if (attr.signature) { plugin.onPluginCall(this._stream, attr.signature, attr.expression); } else if (value == null || typeof value === 'string') { this._emitAttribute(name, value, plugin, location); } }); } _emitAttribute(name, value, plugin, location) { plugin.beforeAttribute(this._stream, name); if (value == null) { this._emitSimpleTextAttribute(name, null); } else if (value.indexOf('${') >= 0) { const interpolation = this._htlParser.parse(value, location); const plainText = interpolation.getPlainText(); if (plainText !== null) { this._emitSimpleTextAttribute(name, plainText); } else { this._emitFragment(name, interpolation, plugin, location); } } else { this._emitSimpleTextAttribute(name, value); } plugin.afterAttribute(this._stream, name); } _emitSimpleTextAttribute(name, textValue) { this._stream.write(new AddAttribute(name, textValue)); } _emitFragment(name, interpolation, quoteChar, plugin, location) { const stream = this._stream; const valueExpression = this._transformer.transform( interpolation, null, null, ); // raw expression const attrValue = this._symbolGenerator.next('attrValue'); // holds the raw attribute value const node = valueExpression.root; stream.write(new VariableBinding.Start(attrValue, node)); // attrValue = <expr> stream.write(new Conditional.Start(new UnaryOperation( UnaryOperator.NOT, new UnaryOperation( UnaryOperator.IS_EMPTY, new Identifier(attrValue), ), ), false, location)); // if (attrValue) stream.write(new AddAttribute(name, new OutputVariable(attrValue))); stream.write(Conditional.END); // end if (attrContent) stream.write(VariableBinding.END); // end scope for attrValue } _outComment(content, location) { // skip HTL parser if no HTL expression in content if (!content || content.indexOf('${') < 0) { this._stream.write(new Comment(content)); return; } const interpolation = this._htlParser.parse(content, location); const plainText = interpolation.getPlainText(); if (plainText != null) { this._stream.write(new Comment(plainText)); } else { const node = this._transformer.transform( interpolation, MarkupContext.COMMENT, ExpressionContext.TEXT, ).root; const variable = this._symbolGenerator.next(); this._stream.write(new VariableBinding.Start(variable, node)); // todo: location doesn't work correctly this._stream.write(new Comment(new OutputVariable(variable), location)); this._stream.write(VariableBinding.END); } } _outText(content, markupContext, line, column) { // skip HTL parser if no HTL expression in content if (!content || content.indexOf('${') < 0) { this._out(content); return; } const location = { line, column }; const interpolation = this._htlParser.parse(content, location); const plainText = interpolation.getPlainText(); if (plainText != null) { this._out(plainText); } else { const node = this._transformer.transform( interpolation, markupContext, ExpressionContext.TEXT, ).root; // increase line number in passed location for any preceding strings containing line feeds if (node instanceof MultiOperation) { node.operands.every((op) => { if (op instanceof StringConstant) { // eslint-disable-next-line no-param-reassign location.line += op.text.split('\n').length - 1; this._out(op.text); } else { const variable = this._symbolGenerator.next(); this._stream.write(new VariableBinding.Start(variable, op)); this._stream.write(new OutputVariable(variable, location)); this._stream.write(VariableBinding.END); } return true; }); } else { const variable = this._symbolGenerator.next(); this._stream.write(new VariableBinding.Start(variable, node)); this._stream.write(new OutputVariable(variable, location)); this._stream.write(VariableBinding.END); } } } _out(text, location) { this._stream.write(new OutText(text, location)); } _adjustContext(expression, markupContext, expressionContext) { const { root } = expression; if (root instanceof RuntimeCall) { if (root.functionName === 'xss') { return expression; } } return this._transformer.adjustToContext(expression, markupContext, expressionContext); } // eslint-disable-next-line class-methods-use-this _lookupPlugin(name) { // eslint-disable-next-line no-prototype-builtins if (PLUGINS.hasOwnProperty(name)) { return PLUGINS[name]; } throw new Error(`Plugin not implemented: ${name}`); } };