From 028ca14f76986cc2519143a5ea0bb63a8e34b55b Mon Sep 17 00:00:00 2001 From: Eric Hwang Date: Wed, 14 Jun 2023 14:10:01 -0700 Subject: [PATCH 01/11] Move code and tests from derbyjs/saddle into this repo, as-is --- lib/templates/templates.js | 1351 +++++++++++++++++ package.json | 1 + test/dom/templates/templates.dom.mocha.js | 1199 +++++++++++++++ .../templates/templates.server.mocha.js | 101 ++ 4 files changed, 2652 insertions(+) create mode 100644 lib/templates/templates.js create mode 100644 test/dom/templates/templates.dom.mocha.js create mode 100644 test/server/templates/templates.server.mocha.js diff --git a/lib/templates/templates.js b/lib/templates/templates.js new file mode 100644 index 000000000..26e47fe4d --- /dev/null +++ b/lib/templates/templates.js @@ -0,0 +1,1351 @@ +if (typeof require === 'function') { + var serializeObject = require('serialize-object'); +} + +// UPDATE_PROPERTIES map HTML attribute names to an Element DOM property that +// should be used for setting on bindings updates instead of setAttribute. +// +// https://github.com/jquery/jquery/blob/1.x-master/src/attributes/prop.js +// https://github.com/jquery/jquery/blob/master/src/attributes/prop.js +// http://webbugtrack.blogspot.com/2007/08/bug-242-setattribute-doesnt-always-work.html +var BOOLEAN_PROPERTIES = { + checked: 'checked' +, disabled: 'disabled' +, indeterminate: 'indeterminate' +, readonly: 'readOnly' +, selected: 'selected' +}; +var INTEGER_PROPERTIES = { + colspan: 'colSpan' +, maxlength: 'maxLength' +, rowspan: 'rowSpan' +, tabindex: 'tabIndex' +}; +var STRING_PROPERTIES = { + cellpadding: 'cellPadding' +, cellspacing: 'cellSpacing' +, 'class': 'className' +, contenteditable: 'contentEditable' +, enctype: 'encoding' +, 'for': 'htmlFor' +, frameborder: 'frameBorder' +, id: 'id' +, title: 'title' +, type: 'type' +, usemap: 'useMap' +, value: 'value' +}; +var UPDATE_PROPERTIES = {}; +mergeInto(BOOLEAN_PROPERTIES, UPDATE_PROPERTIES); +mergeInto(INTEGER_PROPERTIES, UPDATE_PROPERTIES); +mergeInto(STRING_PROPERTIES, UPDATE_PROPERTIES); + +// CREATE_PROPERTIES map HTML attribute names to an Element DOM property that +// should be used for setting on Element rendering instead of setAttribute. +// input.defaultChecked and input.defaultValue affect the attribute, so we want +// to use these for initial dynamic rendering. For binding updates, +// input.checked and input.value are modified. +var CREATE_PROPERTIES = {}; +mergeInto(UPDATE_PROPERTIES, CREATE_PROPERTIES); +CREATE_PROPERTIES.checked = 'defaultChecked'; +CREATE_PROPERTIES.value = 'defaultValue'; + +// http://www.w3.org/html/wg/drafts/html/master/syntax.html#void-elements +var VOID_ELEMENTS = { + area: true +, base: true +, br: true +, col: true +, embed: true +, hr: true +, img: true +, input: true +, keygen: true +, link: true +, menuitem: true +, meta: true +, param: true +, source: true +, track: true +, wbr: true +}; + +var NAMESPACE_URIS = { + svg: 'http://www.w3.org/2000/svg' +, xlink: 'http://www.w3.org/1999/xlink' +, xmlns: 'http://www.w3.org/2000/xmlns/' +}; + +exports.CREATE_PROPERTIES = CREATE_PROPERTIES; +exports.BOOLEAN_PROPERTIES = BOOLEAN_PROPERTIES; +exports.INTEGER_PROPERTIES = INTEGER_PROPERTIES; +exports.STRING_PROPERTIES = STRING_PROPERTIES; +exports.UPDATE_PROPERTIES = UPDATE_PROPERTIES; +exports.VOID_ELEMENTS = VOID_ELEMENTS; +exports.NAMESPACE_URIS = NAMESPACE_URIS; + +// Template Classes +exports.Template = Template; +exports.Doctype = Doctype; +exports.Text = Text; +exports.DynamicText = DynamicText; +exports.Comment = Comment; +exports.DynamicComment = DynamicComment; +exports.Html = Html; +exports.DynamicHtml = DynamicHtml; +exports.Element = Element; +exports.DynamicElement = DynamicElement; +exports.Block = Block; +exports.ConditionalBlock = ConditionalBlock; +exports.EachBlock = EachBlock; + +exports.Attribute = Attribute; +exports.DynamicAttribute = DynamicAttribute; + +// Binding Classes +exports.Binding = Binding; +exports.NodeBinding = NodeBinding; +exports.AttributeBinding = AttributeBinding; +exports.RangeBinding = RangeBinding; + +function Template(content, source) { + this.content = content; + this.source = source; +} +Template.prototype.toString = function() { + return this.source; +}; +Template.prototype.get = function(context, unescaped) { + return contentHtml(this.content, context, unescaped); +}; +Template.prototype.getFragment = function(context, binding) { + var fragment = document.createDocumentFragment(); + this.appendTo(fragment, context, binding); + return fragment; +}; +Template.prototype.appendTo = function(parent, context) { + context.pause(); + appendContent(parent, this.content, context); + context.unpause(); +}; +Template.prototype.attachTo = function(parent, node, context) { + context.pause(); + var node = attachContent(parent, node, this.content, context); + context.unpause(); + return node; +}; +Template.prototype.update = function() {}; +Template.prototype.stringify = function(value) { + return (value == null) ? '' : value + ''; +}; +Template.prototype.equals = function(other) { + return this === other; +}; +Template.prototype.module = 'templates'; +Template.prototype.type = 'Template'; +Template.prototype.serialize = function() { + return serializeObject.instance(this, this.content, this.source); +}; + + +function Doctype(name, publicId, systemId) { + this.name = name; + this.publicId = publicId; + this.systemId = systemId; +} +Doctype.prototype = Object.create(Template.prototype); +Doctype.prototype.constructor = Doctype; +Doctype.prototype.get = function() { + var publicText = (this.publicId) ? + ' PUBLIC "' + this.publicId + '"' : + ''; + var systemText = (this.systemId) ? + (this.publicId) ? + ' "' + this.systemId + '"' : + ' SYSTEM "' + this.systemId + '"' : + ''; + return ''; +}; +Doctype.prototype.appendTo = function() { + // Doctype could be created via: + // document.implementation.createDocumentType(this.name, this.publicId, this.systemId) + // However, it does not appear possible or useful to append it to the + // document fragment. Therefore, just don't render it in the browser +}; +Doctype.prototype.attachTo = function(parent, node) { + if (!node || node.nodeType !== 10) { + throw attachError(parent, node); + } + return node.nextSibling; +}; +Doctype.prototype.type = 'Doctype'; +Doctype.prototype.serialize = function() { + return serializeObject.instance(this, this.name, this.publicId, this.systemId); +}; + +function Text(data) { + this.data = data; + this.escaped = escapeHtml(data); +} +Text.prototype = Object.create(Template.prototype); +Text.prototype.constructor = Text; +Text.prototype.get = function(context, unescaped) { + return (unescaped) ? this.data : this.escaped; +}; +Text.prototype.appendTo = function(parent) { + var node = document.createTextNode(this.data); + parent.appendChild(node); +}; +Text.prototype.attachTo = function(parent, node) { + return attachText(parent, node, this.data, this); +}; +Text.prototype.type = 'Text'; +Text.prototype.serialize = function() { + return serializeObject.instance(this, this.data); +}; + +// DynamicText might be more accurately named DynamicContent. When its +// expression returns a template, it acts similar to a Block, and it renders +// the template surrounded by comment markers for range replacement. When its +// expression returns any other type, it renders a DOM Text node with no +// markers. Text nodes are bound by updating their data property dynamically. +// The update method must take care to switch between these types of bindings +// in case the expression return type changes dynamically. +function DynamicText(expression) { + this.expression = expression; + this.unbound = false; +} +DynamicText.prototype = Object.create(Template.prototype); +DynamicText.prototype.constructor = DynamicText; +DynamicText.prototype.get = function(context, unescaped) { + var value = this.expression.get(context); + if (value instanceof Template) { + do { + value = value.get(context, unescaped); + } while (value instanceof Template); + return value; + } + var data = this.stringify(value); + return (unescaped) ? data : escapeHtml(data); +}; +DynamicText.prototype.appendTo = function(parent, context, binding) { + var value = this.expression.get(context); + if (value instanceof Template) { + var start = document.createComment(this.expression); + var end = document.createComment('/' + this.expression); + var condition = this.getCondition(context); + parent.appendChild(start); + value.appendTo(parent, context); + parent.appendChild(end); + updateRange(context, binding, this, start, end, null, condition); + return; + } + var data = this.stringify(value); + var node = document.createTextNode(data); + parent.appendChild(node); + addNodeBinding(this, context, node); +}; +DynamicText.prototype.attachTo = function(parent, node, context) { + var value = this.expression.get(context); + if (value instanceof Template) { + var start = document.createComment(this.expression); + var end = document.createComment('/' + this.expression); + var condition = this.getCondition(context); + parent.insertBefore(start, node || null); + node = value.attachTo(parent, node, context); + parent.insertBefore(end, node || null); + updateRange(context, null, this, start, end, null, condition); + return node; + } + var data = this.stringify(value); + return attachText(parent, node, data, this, context); +}; +DynamicText.prototype.update = function(context, binding) { + if (binding instanceof RangeBinding) { + this._blockUpdate(context, binding); + return; + } + var value = this.expression.get(context); + if (value instanceof Template) { + var start = binding.node; + if (!start.parentNode) return; + var end = start; + var fragment = this.getFragment(context); + replaceRange(context, start, end, fragment, binding); + return; + } + binding.node.data = this.stringify(value); +}; +DynamicText.prototype.getCondition = function(context) { + return this.expression.get(context); +}; +DynamicText.prototype.type = 'DynamicText'; +DynamicText.prototype.serialize = function() { + return serializeObject.instance(this, this.expression); +}; + +function attachText(parent, node, data, template, context) { + if (!node) { + var newNode = document.createTextNode(data); + parent.appendChild(newNode); + addNodeBinding(template, context, newNode); + return; + } + if (node.nodeType === 3) { + // Proceed if nodes already match + if (node.data === data) { + addNodeBinding(template, context, node); + return node.nextSibling; + } + data = normalizeLineBreaks(data); + // Split adjacent text nodes that would have been merged together in HTML + var nextNode = splitData(node, data.length); + if (node.data !== data) { + throw attachError(parent, node); + } + addNodeBinding(template, context, node); + return nextNode; + } + // An empty text node might not be created at the end of some text + if (data === '') { + var newNode = document.createTextNode(''); + parent.insertBefore(newNode, node || null); + addNodeBinding(template, context, newNode); + return node; + } + throw attachError(parent, node); +} + +function Comment(data, hooks) { + this.data = data; + this.hooks = hooks; +} +Comment.prototype = Object.create(Template.prototype); +Comment.prototype.constructor = Comment; +Comment.prototype.get = function() { + return ''; +}; +Comment.prototype.appendTo = function(parent, context) { + var node = document.createComment(this.data); + parent.appendChild(node); + emitHooks(this.hooks, context, node); +}; +Comment.prototype.attachTo = function(parent, node, context) { + return attachComment(parent, node, this.data, this, context); +}; +Comment.prototype.type = 'Comment'; +Comment.prototype.serialize = function() { + return serializeObject.instance(this, this.data, this.hooks); +} + +function DynamicComment(expression, hooks) { + this.expression = expression; + this.hooks = hooks; +} +DynamicComment.prototype = Object.create(Template.prototype); +DynamicComment.prototype.constructor = DynamicComment; +DynamicComment.prototype.get = function(context) { + var value = getUnescapedValue(this.expression, context); + var data = this.stringify(value); + return ''; +}; +DynamicComment.prototype.appendTo = function(parent, context) { + var value = getUnescapedValue(this.expression, context); + var data = this.stringify(value); + var node = document.createComment(data); + parent.appendChild(node); + addNodeBinding(this, context, node); +}; +DynamicComment.prototype.attachTo = function(parent, node, context) { + var value = getUnescapedValue(this.expression, context); + var data = this.stringify(value); + return attachComment(parent, node, data, this, context); +}; +DynamicComment.prototype.update = function(context, binding) { + var value = getUnescapedValue(this.expression, context); + binding.node.data = this.stringify(value); +}; +DynamicComment.prototype.type = 'DynamicComment'; +DynamicComment.prototype.serialize = function() { + return serializeObject.instance(this, this.expression, this.hooks); +} + +function attachComment(parent, node, data, template, context) { + // Sometimes IE fails to create Comment nodes from HTML or innerHTML. + // This is an issue inside of , then once they've typed "1.0", + // the context value is set to `1`, triggering this update function to set the input value to + // "1". That means typing "1.01" would be impossible without special handling to avoid + // overwriting an existing input value of "1.0" with a new value of "1". + if (element.tagName === 'INPUT' && propertyName === 'value' && typeof value === 'number') { + if (parseFloat(element.value) === value) { + return; + } + } + var propertyValue = (STRING_PROPERTIES[binding.name]) ? + this.stringify(value) : value; + if (element[propertyName] === propertyValue) return; + element[propertyName] = propertyValue; + return; + } + if (value === false || value == null) { + if (this.ns) { + element.removeAttributeNS(this.ns, binding.name); + } else { + element.removeAttribute(binding.name); + } + return; + } + if (value === true) value = binding.name; + if (this.ns) { + element.setAttributeNS(this.ns, binding.name, value); + } else { + element.setAttribute(binding.name, value); + } +}; +DynamicAttribute.prototype.type = 'DynamicAttribute'; +DynamicAttribute.prototype.serialize = function() { + return serializeObject.instance(this, this.expression, this.ns); +}; + +function getUnescapedValue(expression, context) { + var unescaped = true; + var value = expression.get(context, unescaped); + while (value instanceof Template) { + value = value.get(context, unescaped); + } + return value; +} + +function Element(tagName, attributes, content, hooks, selfClosing, notClosed, ns) { + this.tagName = tagName; + this.attributes = attributes; + this.content = content; + this.hooks = hooks; + this.selfClosing = selfClosing; + this.notClosed = notClosed; + this.ns = ns; + + this.endTag = getEndTag(tagName, selfClosing, notClosed); + this.startClose = getStartClose(selfClosing); + var lowerTagName = tagName && tagName.toLowerCase(); + this.unescapedContent = (lowerTagName === 'script' || lowerTagName === 'style'); + this.bindContentToValue = (lowerTagName === 'textarea'); +} +Element.prototype = Object.create(Template.prototype); +Element.prototype.constructor = Element; +Element.prototype.getTagName = function() { + return this.tagName; +}; +Element.prototype.getEndTag = function() { + return this.endTag; +}; +Element.prototype.get = function(context) { + var tagName = this.getTagName(context); + var endTag = this.getEndTag(tagName); + var tagItems = [tagName]; + for (var key in this.attributes) { + var value = this.attributes[key].get(context); + if (value === true) { + tagItems.push(key); + } else if (value !== false && value != null) { + tagItems.push(key + '="' + escapeAttribute(value) + '"'); + } + } + var startTag = '<' + tagItems.join(' ') + this.startClose; + if (this.content) { + var inner = contentHtml(this.content, context, this.unescapedContent); + return startTag + inner + endTag; + } + return startTag + endTag; +}; +Element.prototype.appendTo = function(parent, context) { + var tagName = this.getTagName(context); + var element = (this.ns) ? + document.createElementNS(this.ns, tagName) : + document.createElement(tagName); + for (var key in this.attributes) { + var attribute = this.attributes[key]; + var value = attribute.getBound(context, element, key, this.ns); + if (value === false || value == null) continue; + var propertyName = !this.ns && CREATE_PROPERTIES[key]; + if (propertyName) { + element[propertyName] = value; + continue; + } + if (value === true) value = key; + if (attribute.ns) { + element.setAttributeNS(attribute.ns, key, value); + } else { + element.setAttribute(key, value); + } + } + if (this.content) { + this._bindContent(context, element); + appendContent(element, this.content, context); + } + parent.appendChild(element); + emitHooks(this.hooks, context, element); +}; +Element.prototype.attachTo = function(parent, node, context) { + var tagName = this.getTagName(context); + if ( + !node || + node.nodeType !== 1 || + node.tagName.toLowerCase() !== tagName.toLowerCase() + ) { + throw attachError(parent, node); + } + for (var key in this.attributes) { + // Get each attribute to create bindings + this.attributes[key].getBound(context, node, key, this.ns); + // TODO: Ideally, this would also check that the node's current attributes + // are equivalent, but there are some tricky edge cases + } + if (this.content) { + this._bindContent(context, node); + attachContent(node, node.firstChild, this.content, context); + } + emitHooks(this.hooks, context, node); + return node.nextSibling; +}; +Element.prototype._bindContent = function(context, element) { + // For textareas with dynamic text content, bind to the value property + var child = this.bindContentToValue && + this.content.length === 1 && + this.content[0]; + if (child instanceof DynamicText) { + child.unbound = true; + var template = new DynamicAttribute(child.expression); + context.addBinding(new AttributeBinding(template, context, element, 'value')); + } +}; +Element.prototype.type = 'Element'; +Element.prototype.serialize = function() { + return serializeObject.instance( + this + , this.tagName + , this.attributes + , this.content + , this.hooks + , this.selfClosing + , this.notClosed + , this.ns + ); +}; + +function DynamicElement(tagName, attributes, content, hooks, selfClosing, notClosed, ns) { + this.tagName = tagName; + this.attributes = attributes; + this.content = content; + this.hooks = hooks; + this.selfClosing = selfClosing; + this.notClosed = notClosed; + this.ns = ns; + + this.startClose = getStartClose(selfClosing); + this.unescapedContent = false; +} +DynamicElement.prototype = Object.create(Element.prototype); +DynamicElement.prototype.constructor = DynamicElement; +DynamicElement.prototype.getTagName = function(context) { + return getUnescapedValue(this.tagName, context); +}; +DynamicElement.prototype.getEndTag = function(tagName) { + return getEndTag(tagName, this.selfClosing, this.notClosed); +}; +DynamicElement.prototype.type = 'DynamicElement'; + +function getStartClose(selfClosing) { + return (selfClosing) ? ' />' : '>'; +} + +function getEndTag(tagName, selfClosing, notClosed) { + var lowerTagName = tagName && tagName.toLowerCase(); + var isVoid = VOID_ELEMENTS[lowerTagName]; + return (isVoid || selfClosing || notClosed) ? '' : ''; +} + +function getAttributeValue(element, name) { + var propertyName = UPDATE_PROPERTIES[name]; + return (propertyName) ? element[propertyName] : element.getAttribute(name); +} + +function emitHooks(hooks, context, value) { + if (!hooks) return; + context.queue(function queuedHooks() { + for (var i = 0, len = hooks.length; i < len; i++) { + hooks[i].emit(context, value); + } + }); +} + +function Block(expression, content) { + this.expression = expression; + this.ending = '/' + expression; + this.content = content; +} +Block.prototype = Object.create(Template.prototype); +Block.prototype.constructor = Block; +Block.prototype.get = function(context, unescaped) { + var blockContext = context.child(this.expression); + return contentHtml(this.content, blockContext, unescaped); +}; +Block.prototype.appendTo = function(parent, context, binding) { + var blockContext = context.child(this.expression); + var start = document.createComment(this.expression); + var end = document.createComment(this.ending); + var condition = this.getCondition(context); + parent.appendChild(start); + appendContent(parent, this.content, blockContext); + parent.appendChild(end); + updateRange(context, binding, this, start, end, null, condition); +}; +Block.prototype.attachTo = function(parent, node, context) { + var blockContext = context.child(this.expression); + var start = document.createComment(this.expression); + var end = document.createComment(this.ending); + var condition = this.getCondition(context); + parent.insertBefore(start, node || null); + node = attachContent(parent, node, this.content, blockContext); + parent.insertBefore(end, node || null); + updateRange(context, null, this, start, end, null, condition); + return node; +}; +Block.prototype.type = 'Block'; +Block.prototype.serialize = function() { + return serializeObject.instance(this, this.expression, this.content); +}; +Block.prototype.update = function(context, binding) { + if (!binding.start.parentNode) return; + var condition = this.getCondition(context); + // Cancel update if prior condition is equivalent to current value + if (equalConditions(condition, binding.condition)) return; + binding.condition = condition; + // Get start and end in advance, since binding is mutated in getFragment + var start = binding.start; + var end = binding.end; + var fragment = this.getFragment(context, binding); + replaceRange(context, start, end, fragment, binding); +}; +Block.prototype.getCondition = function(context) { + // We do an identity check to see if the value has changed before updating. + // With objects, the object would still be the same, so this identity check + // would fail to update enough. Thus, return NaN, which never equals anything + // including itself, so that we always update on objects. + // + // We could also JSON stringify or use some other hashing approach. However, + // that could be really expensive on gets of things that never change, and + // is probably not a good tradeoff. Perhaps there should be a separate block + // type that is only used in the case of dynamic updates + var value = this.expression.get(context); + return (typeof value === 'object') ? NaN : value; +}; +DynamicText.prototype._blockUpdate = Block.prototype.update; + +function ConditionalBlock(expressions, contents) { + this.expressions = expressions; + this.beginning = expressions.join('; '); + this.ending = '/' + this.beginning; + this.contents = contents; +} +ConditionalBlock.prototype = Object.create(Block.prototype); +ConditionalBlock.prototype.constructor = ConditionalBlock; +ConditionalBlock.prototype.get = function(context, unescaped) { + var condition = this.getCondition(context); + if (condition == null) return ''; + var expression = this.expressions[condition]; + var blockContext = context.child(expression); + return contentHtml(this.contents[condition], blockContext, unescaped); +}; +ConditionalBlock.prototype.appendTo = function(parent, context, binding) { + var start = document.createComment(this.beginning); + var end = document.createComment(this.ending); + parent.appendChild(start); + var condition = this.getCondition(context); + if (condition != null) { + var expression = this.expressions[condition]; + var blockContext = context.child(expression); + appendContent(parent, this.contents[condition], blockContext); + } + parent.appendChild(end); + updateRange(context, binding, this, start, end, null, condition); +}; +ConditionalBlock.prototype.attachTo = function(parent, node, context) { + var start = document.createComment(this.beginning); + var end = document.createComment(this.ending); + parent.insertBefore(start, node || null); + var condition = this.getCondition(context); + if (condition != null) { + var expression = this.expressions[condition]; + var blockContext = context.child(expression); + node = attachContent(parent, node, this.contents[condition], blockContext); + } + parent.insertBefore(end, node || null); + updateRange(context, null, this, start, end, null, condition); + return node; +}; +ConditionalBlock.prototype.type = 'ConditionalBlock'; +ConditionalBlock.prototype.serialize = function() { + return serializeObject.instance(this, this.expressions, this.contents); +}; +ConditionalBlock.prototype.update = function(context, binding) { + if (!binding.start.parentNode) return; + var condition = this.getCondition(context); + // Cancel update if prior condition is equivalent to current value + if (equalConditions(condition, binding.condition)) return; + binding.condition = condition; + // Get start and end in advance, since binding is mutated in getFragment + var start = binding.start; + var end = binding.end; + var fragment = this.getFragment(context, binding); + replaceRange(context, start, end, fragment, binding); +}; +ConditionalBlock.prototype.getCondition = function(context) { + for (var i = 0, len = this.expressions.length; i < len; i++) { + if (this.expressions[i].truthy(context)) { + return i; + } + } +}; + +function EachBlock(expression, content, elseContent) { + this.expression = expression; + this.ending = '/' + expression; + this.content = content; + this.elseContent = elseContent; +} +EachBlock.prototype = Object.create(Block.prototype); +EachBlock.prototype.constructor = EachBlock; +EachBlock.prototype.get = function(context, unescaped) { + var items = this.expression.get(context); + if (items && items.length) { + var html = ''; + for (var i = 0, len = items.length; i < len; i++) { + var itemContext = context.eachChild(this.expression, i); + html += contentHtml(this.content, itemContext, unescaped); + } + return html; + } else if (this.elseContent) { + return contentHtml(this.elseContent, context, unescaped); + } + return ''; +}; +EachBlock.prototype.appendTo = function(parent, context, binding) { + var items = this.expression.get(context); + var start = document.createComment(this.expression); + var end = document.createComment(this.ending); + parent.appendChild(start); + if (items && items.length) { + for (var i = 0, len = items.length; i < len; i++) { + var itemContext = context.eachChild(this.expression, i); + this.appendItemTo(parent, itemContext, start); + } + } else if (this.elseContent) { + appendContent(parent, this.elseContent, context); + } + parent.appendChild(end); + updateRange(context, binding, this, start, end); +}; +EachBlock.prototype.appendItemTo = function(parent, context, itemFor, binding) { + var before = parent.lastChild; + var start, end; + appendContent(parent, this.content, context); + if (before === parent.lastChild) { + start = end = document.createComment('empty'); + parent.appendChild(start); + } else { + start = (before && before.nextSibling) || parent.firstChild; + end = parent.lastChild; + } + updateRange(context, binding, this, start, end, itemFor); +}; +EachBlock.prototype.attachTo = function(parent, node, context) { + var items = this.expression.get(context); + var start = document.createComment(this.expression); + var end = document.createComment(this.ending); + parent.insertBefore(start, node || null); + if (items && items.length) { + for (var i = 0, len = items.length; i < len; i++) { + var itemContext = context.eachChild(this.expression, i); + node = this.attachItemTo(parent, node, itemContext, start); + } + } else if (this.elseContent) { + node = attachContent(parent, node, this.elseContent, context); + } + parent.insertBefore(end, node || null); + updateRange(context, null, this, start, end); + return node; +}; +EachBlock.prototype.attachItemTo = function(parent, node, context, itemFor) { + var start, end; + var oldPrevious = node && node.previousSibling; + var nextNode = attachContent(parent, node, this.content, context); + if (nextNode === node) { + start = end = document.createComment('empty'); + parent.insertBefore(start, node || null); + } else { + start = (oldPrevious && oldPrevious.nextSibling) || parent.firstChild; + end = (nextNode && nextNode.previousSibling) || parent.lastChild; + } + updateRange(context, null, this, start, end, itemFor); + return nextNode; +}; +EachBlock.prototype.update = function(context, binding) { + if (!binding.start.parentNode) return; + var start = binding.start; + var end = binding.end; + if (binding.itemFor) { + var fragment = document.createDocumentFragment(); + this.appendItemTo(fragment, context, binding.itemFor, binding); + } else { + var fragment = this.getFragment(context, binding); + } + replaceRange(context, start, end, fragment, binding); +}; +EachBlock.prototype.insert = function(context, binding, index, howMany) { + var parent = binding.start.parentNode; + if (!parent) return; + // In case we are inserting all of the items, update instead. This is needed + // when we were previously rendering elseContent so that it is replaced + if (index === 0 && this.expression.get(context).length === howMany) { + return this.update(context, binding); + } + var node = indexStartNode(binding, index); + var fragment = document.createDocumentFragment(); + for (var i = index, len = index + howMany; i < len; i++) { + var itemContext = context.eachChild(this.expression, i); + this.appendItemTo(fragment, itemContext, binding.start); + } + parent.insertBefore(fragment, node || null); +}; +EachBlock.prototype.remove = function(context, binding, index, howMany) { + var parent = binding.start.parentNode; + if (!parent) return; + // In case we are removing all of the items, update instead. This is needed + // when elseContent should be rendered + if (index === 0 && this.expression.get(context).length === 0) { + return this.update(context, binding); + } + var node = indexStartNode(binding, index); + var i = 0; + while (node) { + if (node === binding.end) return; + if (node.$bindItemStart && node.$bindItemStart.itemFor === binding.start) { + if (howMany === i++) return; + } + var nextNode = node.nextSibling; + parent.removeChild(node); + emitRemoved(context, node, binding); + node = nextNode; + } +}; +EachBlock.prototype.move = function(context, binding, from, to, howMany) { + var parent = binding.start.parentNode; + if (!parent) return; + var node = indexStartNode(binding, from); + var fragment = document.createDocumentFragment(); + var i = 0; + while (node) { + if (node === binding.end) break; + if (node.$bindItemStart && node.$bindItemStart.itemFor === binding.start) { + if (howMany === i++) break; + } + var nextNode = node.nextSibling; + fragment.appendChild(node); + node = nextNode; + } + node = indexStartNode(binding, to); + parent.insertBefore(fragment, node || null); +}; +EachBlock.prototype.type = 'EachBlock'; +EachBlock.prototype.serialize = function() { + return serializeObject.instance(this, this.expression, this.content, this.elseContent); +}; + +function indexStartNode(binding, index) { + var node = binding.start; + var i = 0; + while (node = node.nextSibling) { + if (node === binding.end) return node; + if (node.$bindItemStart && node.$bindItemStart.itemFor === binding.start) { + if (index === i) return node; + i++; + } + } +} + +function updateRange(context, binding, template, start, end, itemFor, condition) { + if (binding) { + binding.start = start; + binding.end = end; + binding.condition = condition; + setNodeBounds(binding, start, itemFor); + } else { + context.addBinding(new RangeBinding(template, context, start, end, itemFor, condition)); + } +} +function setNodeBounds(binding, start, itemFor) { + if (itemFor) { + setNodeProperty(start, '$bindItemStart', binding); + } else { + setNodeProperty(start, '$bindStart', binding); + } +} + +function appendContent(parent, content, context) { + for (var i = 0, len = content.length; i < len; i++) { + content[i].appendTo(parent, context); + } +} +function attachContent(parent, node, content, context) { + for (var i = 0, len = content.length; i < len; i++) { + while (node && node.hasAttribute && node.hasAttribute('data-no-attach')) { + node = node.nextSibling; + } + node = content[i].attachTo(parent, node, context); + } + return node; +} +function contentHtml(content, context, unescaped) { + var html = ''; + for (var i = 0, len = content.length; i < len; i++) { + html += content[i].get(context, unescaped); + } + return html; +} +function replaceRange(context, start, end, fragment, binding, innerOnly) { + // Note: the calling function must make sure to check that there is a parent + var parent = start.parentNode; + // Copy item binding from old start to fragment being inserted + if (start.$bindItemStart && fragment.firstChild) { + setNodeProperty(fragment.firstChild, '$bindItemStart', start.$bindItemStart); + start.$bindItemStart.start = fragment.firstChild; + } + // Fast path for single node replacements + if (start === end) { + parent.replaceChild(fragment, start); + emitRemoved(context, start, binding); + return; + } + // Remove all nodes from start to end + var node = (innerOnly) ? start.nextSibling : start; + var nextNode; + while (node) { + nextNode = node.nextSibling; + emitRemoved(context, node, binding); + if (innerOnly && node === end) { + nextNode = end; + break; + } + parent.removeChild(node); + if (node === end) break; + node = nextNode; + } + // This also works if nextNode is null, by doing an append + parent.insertBefore(fragment, nextNode || null); +} +function emitRemoved(context, node, ignore) { + context.removeNode(node); + emitRemovedBinding(context, ignore, node, '$bindNode'); + emitRemovedBinding(context, ignore, node, '$bindStart'); + emitRemovedBinding(context, ignore, node, '$bindItemStart'); + var attributes = node.$bindAttributes; + if (attributes) { + node.$bindAttributes = null; + for (var key in attributes) { + context.removeBinding(attributes[key]); + } + } + for (node = node.firstChild; node; node = node.nextSibling) { + emitRemoved(context, node, ignore); + } +} +function emitRemovedBinding(context, ignore, node, property) { + var binding = node[property]; + if (binding) { + node[property] = null; + if (binding !== ignore) { + context.removeBinding(binding); + } + } +} + +function attachError(parent, node) { + if (typeof console !== 'undefined') { + console.error('Attach failed for', node, 'within', parent); + } + return new Error('Attaching bindings failed, because HTML structure ' + + 'does not match client rendering.' + ); +} + +function Binding() { + this.meta = null; +} +Binding.prototype.type = 'Binding'; +Binding.prototype.update = function() { + this.context.pause(); + this.template.update(this.context, this); + this.context.unpause(); +}; +Binding.prototype.insert = function() { + this.update(); +}; +Binding.prototype.remove = function() { + this.update(); +}; +Binding.prototype.move = function() { + this.update(); +}; + +function NodeBinding(template, context, node) { + this.template = template; + this.context = context; + this.node = node; + this.meta = null; + setNodeProperty(node, '$bindNode', this); +} +NodeBinding.prototype = Object.create(Binding.prototype); +NodeBinding.prototype.constructor = NodeBinding; +NodeBinding.prototype.type = 'NodeBinding'; + +function AttributeBindingsMap() {} +function AttributeBinding(template, context, element, name) { + this.template = template; + this.context = context; + this.element = element; + this.name = name; + this.meta = null; + var map = element.$bindAttributes || + (element.$bindAttributes = new AttributeBindingsMap()); + map[name] = this; +} +AttributeBinding.prototype = Object.create(Binding.prototype); +AttributeBinding.prototype.constructor = AttributeBinding; +AttributeBinding.prototype.type = 'AttributeBinding'; + +function RangeBinding(template, context, start, end, itemFor, condition) { + this.template = template; + this.context = context; + this.start = start; + this.end = end; + this.itemFor = itemFor; + this.condition = condition; + this.meta = null; + setNodeBounds(this, start, itemFor); +} +RangeBinding.prototype = Object.create(Binding.prototype); +RangeBinding.prototype.constructor = RangeBinding; +RangeBinding.prototype.type = 'RangeBinding'; +RangeBinding.prototype.insert = function(index, howMany) { + this.context.pause(); + if (this.template.insert) { + this.template.insert(this.context, this, index, howMany); + } else { + this.template.update(this.context, this); + } + this.context.unpause(); +}; +RangeBinding.prototype.remove = function(index, howMany) { + this.context.pause(); + if (this.template.remove) { + this.template.remove(this.context, this, index, howMany); + } else { + this.template.update(this.context, this); + } + this.context.unpause(); +}; +RangeBinding.prototype.move = function(from, to, howMany) { + this.context.pause(); + if (this.template.move) { + this.template.move(this.context, this, from, to, howMany); + } else { + this.template.update(this.context, this); + } + this.context.unpause(); +}; + + +//// Utility functions //// + +function noop() {} + +function mergeInto(from, to) { + for (var key in from) { + to[key] = from[key]; + } +} + +function escapeHtml(string) { + string = string + ''; + return string.replace(/[&<]/g, function(match) { + return (match === '&') ? '&' : '<'; + }); +} + +function escapeAttribute(string) { + string = string + ''; + return string.replace(/[&"]/g, function(match) { + return (match === '&') ? '&' : '"'; + }); +} + +function equalConditions(a, b) { + // First, test for strict equality + if (a === b) return true; + // Failing that, allow for template objects used as a condition to define a + // custom `equals()` method to indicate equivalence + return (a instanceof Template) && a.equals(b); +} + + +//// Shims & workarounds //// + +// General notes: +// +// In all cases, Node.insertBefore should have `|| null` after its second +// argument. IE works correctly when the argument is ommitted or equal +// to null, but it throws and error if it is equal to undefined. + +if (!Array.isArray) { + Array.isArray = function(value) { + return Object.prototype.toString.call(value) === '[object Array]'; + }; +} + +// Equivalent to textNode.splitText, which is buggy in IE <=9 +function splitData(node, index) { + var newNode = node.cloneNode(false); + newNode.deleteData(0, index); + node.deleteData(index, node.length - index); + node.parentNode.insertBefore(newNode, node.nextSibling || null); + return newNode; +} + +// Defined so that it can be overriden in IE <=8 +function setNodeProperty(node, key, value) { + return node[key] = value; +} + +function normalizeLineBreaks(string) { + return string; +} + +(function() { + // Don't try to shim in Node.js environment + if (typeof document === 'undefined') return; + + var div = document.createElement('div'); + div.innerHTML = '\r\n
\n' + var windowsLength = div.firstChild.data.length; + var unixLength = div.lastChild.data.length; + if (windowsLength === 1 && unixLength === 1) { + normalizeLineBreaks = function(string) { + return string.replace(/\r\n/g, '\n'); + }; + } else if (windowsLength === 2 && unixLength === 2) { + normalizeLineBreaks = function(string) { + return string.replace(/(^|[^\r])(\n+)/g, function(match, value, newLines) { + for (var i = newLines.length; i--;) { + value += '\r\n'; + } + return value; + }); + }; + } + + // TODO: Shim createHtmlFragment for old IE + + // TODO: Shim setAttribute('style'), which doesn't work in IE <=7 + // http://webbugtrack.blogspot.com/2007/10/bug-245-setattribute-style-does-not.html + + // TODO: Investigate whether input name attribute works in IE <=7. We could + // override Element::appendTo to use IE's alternative createElement syntax: + // document.createElement('') + // http://webbugtrack.blogspot.com/2007/10/bug-235-createelement-is-broken-in-ie.html + + // In IE, input.defaultValue doesn't work correctly, so use input.value, + // which mistakenly but conveniently sets both the value property and attribute. + // + // Surprisingly, in IE <=7, input.defaultChecked must be used instead of + // input.checked before the input is in the document. + // http://webbugtrack.blogspot.com/2007/11/bug-299-setattribute-checked-does-not.html + var input = document.createElement('input'); + input.defaultValue = 'x'; + if (input.value !== 'x') { + CREATE_PROPERTIES.value = 'value'; + } + + try { + // TextNodes are not expando in IE <=8 + document.createTextNode('').$try = 0; + } catch (err) { + setNodeProperty = function(node, key, value) { + // If trying to set a property on a TextNode, create a proxy CommentNode + // and set the property on that node instead. Put the proxy after the + // TextNode if marking the end of a range, and before otherwise. + if (node.nodeType === 3) { + var proxyNode = node.previousSibling; + if (!proxyNode || proxyNode.$bindProxy !== node) { + proxyNode = document.createComment('proxy'); + proxyNode.$bindProxy = node; + node.parentNode.insertBefore(proxyNode, node || null); + } + return proxyNode[key] = value; + } + // Set the property directly on other node types + return node[key] = value; + }; + } +})(); diff --git a/package.json b/package.json index 57b358009..83ee3bef5 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "qs": "^6.11.0", "racer": "^1.0.3", "resolve": "^1.22.1", + "serialize-object": "^1.0.0", "through": "^2.3.8", "tracks": "^0.5.8" }, diff --git a/test/dom/templates/templates.dom.mocha.js b/test/dom/templates/templates.dom.mocha.js new file mode 100644 index 000000000..cf3038c56 --- /dev/null +++ b/test/dom/templates/templates.dom.mocha.js @@ -0,0 +1,1199 @@ +var chai = require('chai'); +var expect = chai.expect; +var saddle = require('../index'); +var expressions = require('../example/expressions'); + +//add fixture to page +//only 90s kids will remember this +document.write('
'); + +describe('Static rendering', function() { + + var context = getContext(); + + describe('HTML', function() { + testStaticRendering(function test(options) { + var html = options.template.get(context); + expect(html).equal(options.html); + }); + }); + + describe('Fragment', function() { + testStaticRendering(function test(options) { + // getFragment calls appendTo, so these Fragment tests cover appendTo. + var fragment = options.template.getFragment(context); + options.fragment(fragment); + }); + }); + +}); + +describe('Dynamic rendering', function() { + + var context = getContext({ + show: true + }); + + describe('HTML', function() { + testDynamicRendering(function test(options) { + var html = options.template.get(context); + expect(html).equal(options.html); + }); + }); + + describe('Fragment', function() { + testDynamicRendering(function test(options) { + var fragment = options.template.getFragment(context); + options.fragment(fragment); + }); + }); + +}); + +function testStaticRendering(test) { + it('renders an empty div', function() { + test({ + template: new saddle.Element('div') + , html: '
' + , fragment: function(fragment) { + expect(fragment.childNodes.length).equal(1); + expect(fragment.childNodes[0].tagName.toLowerCase()).equal('div'); + } + }); + }); + + it('renders a void element', function() { + test({ + template: new saddle.Element('br') + , html: '
' + , fragment: function(fragment) { + expect(fragment.childNodes.length).equal(1); + expect(fragment.childNodes[0].tagName.toLowerCase()).equal('br'); + } + }); + }); + + it('renders a div with literal attributes', function() { + test({ + template: new saddle.Element('div', { + id: new saddle.Attribute('page') + , 'data-x': new saddle.Attribute('24') + , 'class': new saddle.Attribute('content fit') + }) + , html: '
' + , fragment: function(fragment) { + expect(fragment.childNodes.length).equal(1); + expect(fragment.childNodes[0].tagName.toLowerCase()).equal('div'); + expect(fragment.childNodes[0].id).equal('page'); + expect(fragment.childNodes[0].className).equal('content fit'); + expect(fragment.childNodes[0].getAttribute('data-x')).equal('24'); + } + }); + }); + + it('renders a true boolean attribute', function() { + test({ + template: new saddle.Element('input', { + autofocus: new saddle.Attribute(true) + }) + , html: '' + , fragment: function(fragment) { + expect(fragment.childNodes.length).equal(1); + expect(fragment.childNodes[0].tagName.toLowerCase()).equal('input'); + expect(fragment.childNodes[0].getAttribute('autofocus')).not.eql(null); + } + }); + }); + + it('renders a false boolean attribute', function() { + test({ + template: new saddle.Element('input', { + autofocus: new saddle.Attribute(false) + }) + , html: '' + , fragment: function(fragment) { + expect(fragment.childNodes.length).equal(1); + expect(fragment.childNodes[0].tagName.toLowerCase()).equal('input'); + expect(fragment.childNodes[0].getAttribute('autofocus')).eql(null); + } + }); + }); + + describe('title attribute', function() { + it('renders string value', function() { + test({ + template: new saddle.Element('div', { + title: new saddle.Attribute('My tooltip') + }) + , html: '
' + , fragment: function(fragment) { + expect(fragment.childNodes.length).equal(1); + expect(fragment.childNodes[0].tagName.toLowerCase()).equal('div'); + expect(fragment.childNodes[0].getAttribute('title')).eql('My tooltip'); + } + }); + }); + + it('renders numeric value as a string', function() { + test({ + template: new saddle.Element('div', { + title: new saddle.Attribute(123) + }) + , html: '
' + , fragment: function(fragment) { + expect(fragment.childNodes.length).equal(1); + expect(fragment.childNodes[0].tagName.toLowerCase()).equal('div'); + expect(fragment.childNodes[0].getAttribute('title')).eql('123'); + } + }); + }); + + it('does not render undefined value', function() { + test({ + template: new saddle.Element('div', { + title: new saddle.Attribute(undefined) + }) + , html: '
' + , fragment: function(fragment) { + expect(fragment.childNodes.length).equal(1); + expect(fragment.childNodes[0].tagName.toLowerCase()).equal('div'); + expect(fragment.childNodes[0].hasAttribute('title')).eql(false); + } + }); + }); + }); + + it('renders nested elements', function() { + test({ + template: new saddle.Element('div', null, [ + new saddle.Element('div', null, [ + new saddle.Element('span') + , new saddle.Element('span') + ]) + ]) + , html: '
' + , fragment: function(fragment) { + expect(fragment.childNodes.length).equal(1); + var node = fragment.childNodes[0]; + expect(node.tagName.toLowerCase()).equal('div'); + expect(node.childNodes.length).equal(1); + var node = node.childNodes[0]; + expect(node.tagName.toLowerCase()).equal('div'); + expect(node.childNodes.length).equal(2); + expect(node.childNodes[0].tagName.toLowerCase()).equal('span'); + expect(node.childNodes[0].childNodes.length).equal(0); + expect(node.childNodes[1].tagName.toLowerCase()).equal('span'); + expect(node.childNodes[1].childNodes.length).equal(0); + } + }); + }); + + it('renders a text node', function() { + test({ + template: new saddle.Text('Hi') + , html: 'Hi' + , fragment: function(fragment) { + expect(fragment.childNodes.length).equal(1); + expect(fragment.childNodes[0].nodeType).equal(3); + expect(fragment.childNodes[0].data).equal('Hi'); + } + }); + }); + + it('renders text nodes in an element', function() { + test({ + template: new saddle.Element('div', null, [ + new saddle.Text('Hello, ') + , new saddle.Text('world.') + ]) + , html: '
Hello, world.
' + , fragment: function(fragment) { + expect(fragment.childNodes.length).equal(1); + var node = fragment.childNodes[0]; + expect(node.tagName.toLowerCase()).equal('div'); + expect(node.childNodes.length).equal(2); + expect(node.childNodes[0].nodeType).equal(3); + expect(node.childNodes[0].data).equal('Hello, '); + expect(node.childNodes[1].nodeType).equal(3); + expect(node.childNodes[1].data).equal('world.'); + } + }); + }); + + it('renders a comment', function() { + test({ + template: new saddle.Comment('Hi') + , html: '' + , fragment: function(fragment) { + expect(fragment.childNodes.length).equal(1); + expect(fragment.childNodes[0].nodeType).equal(8); + expect(fragment.childNodes[0].data).equal('Hi'); + } + }); + }); + + it('renders a template', function() { + test({ + template: new saddle.Template([ + new saddle.Comment('Hi') + , new saddle.Element('div', null, [ + new saddle.Text('Ho') + ]) + ]) + , html: '
Ho
' + , fragment: function(fragment) { + expect(fragment.childNodes.length).equal(2); + expect(fragment.childNodes[0].nodeType).equal(8); + expect(fragment.childNodes[0].data).equal('Hi'); + var node = fragment.childNodes[1]; + expect(node.tagName.toLowerCase()).equal('div'); + expect(node.childNodes.length).equal(1); + expect(node.childNodes[0].nodeType).equal(3); + expect(node.childNodes[0].data).equal('Ho'); + } + }); + }); + + it('renders raw HTML', function() { + test({ + template: new saddle.Html('
Hi
') + , html: '
Hi
' + , fragment: function(fragment) { + expect(fragment.childNodes.length).equal(2); + var node = fragment.childNodes[0]; + expect(node.tagName.toLowerCase()).equal('div'); + expect(node.innerHTML).equal('Hi'); + var node = fragment.childNodes[1]; + expect(node.tagName.toLowerCase()).equal('input'); + } + }); + }); + + it('renders from HTML within tbody context', function() { + test({ + template: new saddle.Element('table', null, [ + new saddle.Element('tbody', null, [ + new saddle.Html('Hi') + ]) + ]) + , html: '
Hi
' + , fragment: function(fragment) { + var node = fragment.firstChild; + expect(node.tagName.toLowerCase()).equal('table'); + node = node.firstChild; + expect(node.tagName.toLowerCase()).equal('tbody'); + node = node.firstChild; + expect(node.tagName.toLowerCase()).equal('tr'); + node = node.firstChild; + expect(node.tagName.toLowerCase()).equal('td'); + expect(node.innerHTML).equal('Hi'); + } + }); + }); + + it('renders value attribute', function() { + test({ + template: new saddle.Element('input', { + value: new saddle.Attribute('hello') + }) + , html: '' + , fragment: function(fragment) { + expect(fragment.childNodes[0].value).equal('hello'); + expect(fragment.childNodes[0].getAttribute('value')).equal('hello'); + } + }); + }); + + it('renders checked attribute: true', function() { + test({ + template: new saddle.Element('input', { + type: new saddle.Attribute('radio') + , checked: new saddle.Attribute(true) + }) + , html: '' + , fragment: function(fragment) { + expect(fragment.childNodes[0].checked).equal(true); + } + }); + }); + + it('renders indeterminate attribute: true', function() { + test({ + template: new saddle.Element('input', { + type: new saddle.Attribute('checkbox') + , indeterminate: new saddle.Attribute(true) + }) + , html: '' + , fragment: function(fragment) { + expect(fragment.childNodes[0].indeterminate).equal(true); + } + }); + }); + + it('renders checked attribute: false', function() { + test({ + template: new saddle.Element('input', { + type: new saddle.Attribute('radio') + , checked: new saddle.Attribute(false) + }) + , html: '' + , fragment: function(fragment) { + expect(fragment.childNodes[0].checked).equal(false); + } + }); + }); +} + +function testDynamicRendering(test) { + // TODO: More tests + + it('renders a template as an attribute expression', function() { + test({ + template: new saddle.Element('div', { + 'class': new saddle.DynamicAttribute(new saddle.Template([ + new saddle.Text('dropdown') + , new saddle.ConditionalBlock([ + new expressions.Expression('show') + ], [ + [new saddle.Text(' show')] + ]) + ])) + }) + , html: '' + , fragment: function(fragment) { + expect(fragment.childNodes.length).equal(1); + expect(fragment.childNodes[0].tagName.toLowerCase()).equal('div'); + expect(fragment.childNodes[0].className).equal('dropdown show'); + } + }); + }); + +} + +describe('attachTo', function() { + var fixture = document.getElementById('fixture'); + + after(function() { + removeChildren(fixture); + }); + + function renderAndAttach(template) { + var context = getContext(); + removeChildren(fixture); + fixture.innerHTML = template.get(context); + template.attachTo(fixture, fixture.firstChild, context); + } + + it('splits static text nodes', function() { + var template = new saddle.Template([ + new saddle.Text('Hi') + , new saddle.Text(' there.') + ]); + renderAndAttach(template); + expect(fixture.childNodes.length).equal(2); + }); + + it('splits empty static text nodes', function() { + var template = new saddle.Template([ + new saddle.Text('') + , new saddle.Text('') + ]); + renderAndAttach(template); + expect(fixture.childNodes.length).equal(2); + }); + + it('splits mixed empty static text nodes', function() { + var template = new saddle.Template([ + new saddle.Text('') + , new saddle.Text('Hi') + , new saddle.Text('') + , new saddle.Text('') + , new saddle.Text(' there.') + , new saddle.Text('') + ]); + renderAndAttach(template); + expect(fixture.childNodes.length).equal(6); + }); + + it('adds empty text nodes around a comment', function() { + var template = new saddle.Template([ + new saddle.Text('Hi') + , new saddle.Text('') + , new saddle.Comment('cool') + , new saddle.Comment('thing') + , new saddle.Text('') + ]); + renderAndAttach(template); + expect(fixture.childNodes.length).equal(5); + }); + + it('attaches to nested elements', function() { + var template = new saddle.Template([ + new saddle.Element('ul', null, [ + new saddle.Element('li', null, [ + new saddle.Text('One') + ]) + , new saddle.Element('li', null, [ + new saddle.Text('Two') + ]) + ]) + ]); + renderAndAttach(template); + }); + + it('attaches to element attributes', function() { + var template = new saddle.Template([ + new saddle.Element('input', { + type: new saddle.Attribute('text') + , autofocus: new saddle.Attribute(true) + , placeholder: new saddle.Attribute(null) + }) + ]); + renderAndAttach(template); + }); + + it('attaches to from HTML within tbody context', function() { + var template = new saddle.Element('table', null, [ + new saddle.Element('tbody', null, [ + new saddle.Comment('OK') + , new saddle.Html('Hi') + , new saddle.Element('tr', null, [ + new saddle.Element('td', null, [ + new saddle.Text('Ho') + ]) + ]) + ]) + ]); + renderAndAttach(template); + }); + + it('traverses with comments in a table and select', function() { + // IE fails to create comments in certain locations when parsing HTML + var template = new saddle.Template([ + new saddle.Element('table', null, [ + new saddle.Comment('table comment') + , new saddle.Element('tbody', null, [ + new saddle.Comment('tbody comment') + , new saddle.Element('tr', null, [ + new saddle.Element('td') + ]) + ]) + ]) + , new saddle.Element('select', null, [ + new saddle.Comment('select comment start') + , new saddle.Element('option') + , new saddle.Comment('select comment inner') + , new saddle.Element('option') + , new saddle.Comment('select comment end') + , new saddle.Comment('select comment end 2') + ]) + ]); + renderAndAttach(template); + }); + + it('throws when fragment does not match HTML', function() { + // This template is invalid HTML, and when it is parsed it will produce + // a different tree structure than when the nodes are created one-by-one + var template = new saddle.Template([ + new saddle.Element('table', null, [ + new saddle.Element('div', null, [ + new saddle.Element('td', null, [ + new saddle.Text('Hi') + ]) + ]) + ]) + ]); + expect(function() { + renderAndAttach(template); + }).throw(Error); + }); + +}); + +describe('Binding updates', function() { + + var fixture = document.getElementById('fixture'); + after(function() { + removeChildren(fixture); + }); + + describe('getFragment', function() { + testBindingUpdates(function render(template, data) { + var bindings = []; + var context = getContext(data, bindings); + var fragment = template.getFragment(context); + removeChildren(fixture); + fixture.appendChild(fragment); + return bindings; + }); + }); + + describe('get + attachTo', function() { + testBindingUpdates(function render(template, data) { + var bindings = []; + var context = getContext(data, bindings); + removeChildren(fixture); + fixture.innerHTML = template.get(context); + template.attachTo(fixture, fixture.firstChild, context); + return bindings; + }); + }); + +}); + +function testBindingUpdates(render) { + var fixture = document.getElementById('fixture'); + + it('updates a single TextNode', function() { + var template = new saddle.Template([ + new saddle.DynamicText(new expressions.Expression('text')) + ]); + var binding = render(template).pop(); + expect(getText(fixture)).equal(''); + binding.context = getContext({text: 'Yo'}); + binding.update(); + expect(getText(fixture)).equal('Yo'); + }); + + it('updates sibling TextNodes', function() { + var template = new saddle.Template([ + new saddle.DynamicText(new expressions.Expression('first')) + , new saddle.DynamicText(new expressions.Expression('second')) + ]); + var bindings = render(template, {second: 2}); + expect(bindings.length).equal(2); + expect(getText(fixture)).equal('2'); + var context = getContext({first: 'one', second: 'two'}); + bindings[0].context = context; + bindings[0].update(); + expect(getText(fixture)).equal('one2'); + bindings[1].context = context; + bindings[1].update(); + expect(getText(fixture)).equal('onetwo'); + }); + + it('updates a TextNode that returns text, then a Template', function() { + var template = new saddle.Template([ + new saddle.DynamicText(new expressions.Expression('dynamicTemplate')) + ]); + var data = {dynamicTemplate: 'Hola'}; + var binding = render(template, data).pop(); + expect(getText(fixture)).equal('Hola'); + binding.context = getContext({ + dynamicTemplate: new saddle.DynamicText(new expressions.Expression('text')) + , text: 'Yo' + }); + binding.update(); + expect(getText(fixture)).equal('Yo'); + }); + + it('updates a TextNode that returns a Template, then text', function() { + var template = new saddle.Template([ + new saddle.DynamicText(new expressions.Expression('dynamicTemplate')) + ]); + var data = { + dynamicTemplate: new saddle.DynamicText(new expressions.Expression('text')) + , text: 'Yo' + }; + var binding = render(template, data).pop(); + expect(getText(fixture)).equal('Yo'); + binding.context = getContext({dynamicTemplate: 'Hola'}); + binding.update(); + expect(getText(fixture)).equal('Hola'); + }); + + it('updates a TextNode that returns a Template, then another Template', function() { + var template = new saddle.Template([ + new saddle.DynamicText(new expressions.Expression('dynamicTemplate')) + ]); + var data = { + dynamicTemplate: new saddle.DynamicText(new expressions.Expression('text')) + , text: 'Yo' + }; + var binding = render(template, data).pop(); + expect(getText(fixture)).equal('Yo'); + binding.context = getContext({ + dynamicTemplate: new saddle.Template([ + new saddle.DynamicText(new expressions.Expression('first')) + , new saddle.DynamicText(new expressions.Expression('second')) + ]) + , first: 'one' + , second: 'two' + }); + binding.update(); + expect(getText(fixture)).equal('onetwo'); + }); + + it('updates within a template returned by a TextNode', function() { + var template = new saddle.Template([ + new saddle.DynamicText(new expressions.Expression('dynamicTemplate')) + ]); + var data = { + dynamicTemplate: new saddle.DynamicText(new expressions.Expression('text')) + , text: 'Yo' + }; + var textBinding = render(template, data).shift(); + expect(getText(fixture)).equal('Yo'); + data.text = 'Hola'; + textBinding.context = getContext(data); + textBinding.update(); + expect(getText(fixture)).equal('Hola'); + }); + + it('updates a CommentNode', function() { + var template = new saddle.Template([ + new saddle.DynamicComment(new expressions.Expression('comment')) + ]); + var binding = render(template, {comment: 'Hi'}).pop(); + expect(fixture.innerHTML).equal(''); + binding.context = getContext({comment: 'Bye'}); + binding.update(); + expect(fixture.innerHTML).equal(''); + }); + + it('updates raw HTML', function() { + var template = new saddle.Template([ + new saddle.DynamicHtml(new expressions.Expression('html')) + , new saddle.Element('div') + ]); + var binding = render(template, {html: 'Hi'}).pop(); + var children = getChildren(fixture); + expect(children.length).equal(2); + expect(children[0].tagName.toLowerCase()).equal('b'); + expect(children[0].innerHTML).equal('Hi'); + expect(children[1].tagName.toLowerCase()).equal('div'); + binding.context = getContext({html: 'What?'}); + binding.update(); + var children = getChildren(fixture); + expect(children.length).equal(2); + expect(children[0].tagName.toLowerCase()).equal('i'); + expect(children[0].innerHTML).equal('What?'); + expect(children[1].tagName.toLowerCase()).equal('div'); + binding.context = getContext({html: 'Hola'}); + binding.update(); + var children = getChildren(fixture); + expect(children.length).equal(1); + expect(getText(fixture)).equal('Hola'); + expect(children[0].tagName.toLowerCase()).equal('div'); + }); + + it('updates an Element attribute', function() { + var template = new saddle.Template([ + new saddle.Element('div', { + 'class': new saddle.Attribute('message') + , 'data-greeting': new saddle.DynamicAttribute(new expressions.Expression('greeting')) + }) + ]); + var binding = render(template).pop(); + var node = fixture.firstChild; + expect(node.className).equal('message'); + expect(node.getAttribute('data-greeting')).eql(null); + // Set initial value + binding.context = getContext({greeting: 'Yo'}); + binding.update(); + expect(node.getAttribute('data-greeting')).equal('Yo'); + // Change value for same attribute + binding.context = getContext({greeting: 'Hi'}); + binding.update(); + expect(node.getAttribute('data-greeting')).equal('Hi'); + // Clear value + binding.context = getContext(); + binding.update(); + expect(node.getAttribute('data-greeting')).eql(null); + // Dynamic updates don't affect static attribute + expect(node.className).equal('message'); + }); + + it('updates text input "value" property', function() { + var template = new saddle.Template([ + new saddle.Element('input', { + 'value': new saddle.DynamicAttribute(new expressions.Expression('text')), + }) + ]); + + var binding = render(template).pop(); + var input = fixture.firstChild; + + // Set initial value to string. + binding.context = getContext({text: 'Hi'}); + binding.update(); + expect(input.value).equal('Hi'); + + // Update using numeric value, check that title is the stringified number. + binding.context = getContext({text: 123}); + binding.update(); + expect(input.value).equal('123'); + + // Change value to undefined, make sure attribute is removed. + binding.context = getContext({}); + binding.update(); + expect(input.value).equal(''); + }); + + it('does not clobber input type="number" value when typing "1.0"', function() { + var template = new saddle.Template([ + new saddle.Element('input', { + 'type': new saddle.Attribute('number'), + 'value': new saddle.DynamicAttribute(new expressions.Expression('amount')), + }) + ]); + + var binding = render(template).pop(); + var input = fixture.firstChild; + + // Make sure that a user-typed input value of "1.0" does not get clobbered by + // a context value of `1`. + input.value = '1.0'; + binding.context = getContext({amount: 1}); + binding.update(); + expect(input.value).equal('1.0'); + }); + + it('updates "title" attribute', function() { + var template = new saddle.Template([ + new saddle.Element('div', { + 'title': new saddle.DynamicAttribute(new expressions.Expression('divTooltip')), + }) + ]); + + var binding = render(template).pop(); + var node = fixture.firstChild; + + // Set initial value to string. + binding.context = getContext({divTooltip: 'My tooltip'}); + binding.update(); + expect(node.title).equal('My tooltip'); + + // Update using numeric value, check that title is the stringified number. + binding.context = getContext({divTooltip: 123}); + binding.update(); + expect(node.title).equal('123'); + + // Change value to undefined, make sure attribute is removed. + binding.context = getContext({}); + binding.update(); + expect(node.title).equal(''); + }); + + it('updates a Block', function() { + var template = new saddle.Template([ + new saddle.Block(new expressions.Expression('author'), [ + new saddle.Element('h3', null, [ + new saddle.DynamicText(new expressions.Expression('name')) + ]) + , new saddle.DynamicText(new expressions.Expression('name')) + ]) + ]); + var binding = render(template).pop(); + var children = getChildren(fixture); + expect(children.length).equal(1); + expect(children[0].tagName.toLowerCase()).equal('h3'); + expect(getText(children[0])).equal(''); + expect(getText(fixture)).equal(''); + // Update entire block context + binding.context = getContext({author: {name: 'John'}}); + binding.update(); + var children = getChildren(fixture); + expect(children.length).equal(1); + expect(children[0].tagName.toLowerCase()).equal('h3'); + expect(getText(children[0])).equal('John'); + expect(getText(fixture)).equal('JohnJohn'); + // Reset to no data + binding.context = getContext(); + binding.update(); + var children = getChildren(fixture); + expect(children.length).equal(1); + expect(children[0].tagName.toLowerCase()).equal('h3'); + expect(getText(children[0])).equal(''); + expect(getText(fixture)).equal(''); + }); + + it('updates a single condition ConditionalBlock', function() { + var template = new saddle.Template([ + new saddle.ConditionalBlock([ + new expressions.Expression('show') + ], [ + [new saddle.Text('shown')] + ]) + ]); + var binding = render(template).pop(); + expect(getText(fixture)).equal(''); + // Update value + binding.context = getContext({show: true}); + binding.update(); + expect(getText(fixture)).equal('shown'); + // Reset to no data + binding.context = getContext({show: false}); + binding.update(); + expect(getText(fixture)).equal(''); + }); + + it('updates a multi-condition ConditionalBlock', function() { + var template = new saddle.Template([ + new saddle.ConditionalBlock([ + new expressions.Expression('primary') + , new expressions.Expression('alternate') + , new expressions.ElseExpression() + ], [ + [new saddle.DynamicText(new expressions.Expression())] + , [] + , [new saddle.Text('else')] + ]) + ]); + var binding = render(template).pop(); + expect(getText(fixture)).equal('else'); + // Update value + binding.context = getContext({primary: 'Heyo'}); + binding.update(); + expect(getText(fixture)).equal('Heyo'); + // Update value + binding.context = getContext({alternate: true}); + binding.update(); + expect(getText(fixture)).equal(''); + // Reset to no data + binding.context = getContext(); + binding.update(); + expect(getText(fixture)).equal('else'); + }); + + it('updates an each of text', function() { + var template = new saddle.Template([ + new saddle.EachBlock(new expressions.Expression('items'), [ + new saddle.DynamicText(new expressions.Expression()) + ]) + ]); + var binding = render(template).pop(); + expect(getText(fixture)).equal(''); + // Update value + binding.context = getContext({items: ['One', 'Two', 'Three']}); + binding.update(); + expect(getText(fixture)).equal('OneTwoThree'); + // Update value + binding.context = getContext({items: ['Four', 'Five']}); + binding.update(); + expect(getText(fixture)).equal('FourFive'); + // Update value + binding.context = getContext({items: []}); + binding.update(); + expect(getText(fixture)).equal(''); + // Reset to no data + binding.context = getContext(); + binding.update(); + expect(getText(fixture)).equal(''); + }); + + it('updates an each with an else', function() { + var template = new saddle.Template([ + new saddle.EachBlock(new expressions.Expression('items'), [ + new saddle.DynamicText(new expressions.Expression('name')) + ], [ + new saddle.Text('else') + ]) + ]); + var binding = render(template).pop(); + expect(getText(fixture)).equal('else'); + // Update value + binding.context = getContext({items: [ + {name: 'One'}, {name: 'Two'}, {name: 'Three'} + ]}); + binding.update(); + expect(getText(fixture)).equal('OneTwoThree'); + // Update value + binding.context = getContext({items: [ + {name: 'Four'}, {name: 'Five'} + ]}); + binding.update(); + expect(getText(fixture)).equal('FourFive'); + // Update value + binding.context = getContext({items: []}); + binding.update(); + expect(getText(fixture)).equal('else'); + // Reset to no data + binding.context = getContext(); + binding.update(); + expect(getText(fixture)).equal('else'); + }); + + it('inserts in an each', function() { + var template = new saddle.Template([ + new saddle.EachBlock(new expressions.Expression('items'), [ + new saddle.DynamicText(new expressions.Expression('name')) + ]) + ]); + var binding = render(template).pop(); + expect(getText(fixture)).equal(''); + // Insert from null state + var data = {items: []}; + binding.context = getContext(data); + insert(binding, data.items, 0, [{name: 'One'}, {name: 'Two'}, {name: 'Three'}]); + expect(getText(fixture)).equal('OneTwoThree'); + // Insert new items + insert(binding, data.items, 1, [{name: 'Four'}, {name: 'Five'}]); + expect(getText(fixture)).equal('OneFourFiveTwoThree'); + }); + + it('inserts into empty each with else', function() { + var template = new saddle.Template([ + new saddle.EachBlock(new expressions.Expression('items'), [ + new saddle.DynamicText(new expressions.Expression('name')) + ], [ + new saddle.Text('else') + ]) + ]); + var binding = render(template).pop(); + expect(getText(fixture)).equal('else'); + // Insert from null state + var data = {items: []}; + binding.context = getContext(data); + insert(binding, data.items, 0, [{name: 'One'}, {name: 'Two'}, {name: 'Three'}]); + expect(getText(fixture)).equal('OneTwoThree'); + }); + + it('removes all items in an each with else', function() { + var template = new saddle.Template([ + new saddle.EachBlock(new expressions.Expression('items'), [ + new saddle.DynamicText(new expressions.Expression('name')) + ], [ + new saddle.Text('else') + ]) + ]); + var data = {items: [ + {name: 'One'}, {name: 'Two'}, {name: 'Three'} + ]}; + var binding = render(template, data).pop(); + expect(getText(fixture)).equal('OneTwoThree'); + binding.context = getContext(data); + // Remove all items + remove(binding, data.items, 0, 3); + expect(getText(fixture)).equal('else'); + }); + + it('removes in an each', function() { + var template = new saddle.Template([ + new saddle.EachBlock(new expressions.Expression('items'), [ + new saddle.DynamicText(new expressions.Expression('name')) + ]) + ]); + var data = {items: [ + {name: 'One'}, {name: 'Two'}, {name: 'Three'} + ]}; + var binding = render(template, data).pop(); + expect(getText(fixture)).equal('OneTwoThree'); + binding.context = getContext(data); + // Remove inner item + remove(binding, data.items, 1, 1); + expect(getText(fixture)).equal('OneThree'); + // Remove multiple remaining + remove(binding, data.items, 0, 2); + expect(getText(fixture)).equal(''); + }); + + it('moves in an each', function() { + var template = new saddle.Template([ + new saddle.EachBlock(new expressions.Expression('items'), [ + new saddle.DynamicText(new expressions.Expression('name')) + ]) + ]); + var data = {items: [ + {name: 'One'}, {name: 'Two'}, {name: 'Three'} + ]}; + var binding = render(template, data).pop(); + expect(getText(fixture)).equal('OneTwoThree'); + binding.context = getContext(data); + // Move one item + move(binding, data.items, 1, 2, 1); + expect(getText(fixture)).equal('OneThreeTwo'); + // Move multiple items + move(binding, data.items, 1, 0, 2); + expect(getText(fixture)).equal('ThreeTwoOne'); + }); + + it('insert, move, and remove with multiple node items', function() { + var template = new saddle.Template([ + new saddle.EachBlock(new expressions.Expression('items'), [ + new saddle.Element('h3', null, [ + new saddle.DynamicText(new expressions.Expression('title')) + ]) + , new saddle.DynamicText(new expressions.Expression('text')) + ]) + ]); + var data = {items: [ + {title: '1', text: 'one'} + , {title: '2', text: 'two'} + , {title: '3', text: 'three'} + ]}; + var binding = render(template, data).pop(); + expect(getText(fixture)).equal('1one2two3three'); + binding.context = getContext(data); + // Insert an item + insert(binding, data.items, 2, [{title: '4', text: 'four'}]); + expect(getText(fixture)).equal('1one2two4four3three'); + // Move items + move(binding, data.items, 1, 0, 3); + expect(getText(fixture)).equal('2two4four3three1one'); + // Remove an item + remove(binding, data.items, 2, 1); + expect(getText(fixture)).equal('2two4four1one'); + }); + + it('inserts to outer nested each', function() { + var template = new saddle.Template([ + new saddle.EachBlock(new expressions.Expression('items'), [ + new saddle.DynamicText(new expressions.Expression('name')) + , new saddle.EachBlock(new expressions.Expression('subitems'), [ + new saddle.DynamicText(new expressions.Expression()) + ]) + ]) + ]); + var binding = render(template).pop(); + expect(getText(fixture)).equal(''); + // Insert from null state + var data = {items: []}; + binding.context = getContext(data); + insert(binding, data.items, 0, [ + {name: 'One', subitems: [1, 2, 3]} + , {name: 'Two', subitems: [2, 4, 6]} + , {name: 'Three', subitems: [3, 6, 9]} + ]); + expect(getText(fixture)).equal('One123Two246Three369'); + // Insert new items + insert(binding, data.items, 1, [ + {name: 'Four', subitems: [4, 8, 12]} + , {name: 'Five', subitems: [5, 10, 15]} + ]); + expect(getText(fixture)).equal('One123Four4812Five51015Two246Three369'); + // Insert new items again + insert(binding, data.items, 2, [ + {name: 'Six', subitems: [6, 12, 18]} + ]); + expect(getText(fixture)).equal('One123Four4812Six61218Five51015Two246Three369'); + }); + + it('removes from outer nested each', function() { + var template = new saddle.Template([ + new saddle.EachBlock(new expressions.Expression('items'), [ + new saddle.DynamicText(new expressions.Expression('name')) + , new saddle.EachBlock(new expressions.Expression('subitems'), [ + new saddle.DynamicText(new expressions.Expression()) + ]) + ]) + ]); + var data = {items: [ + {name: 'One', subitems: [1, 2, 3]} + , {name: 'Two', subitems: [2, 4, 6]} + , {name: 'Three', subitems: [3, 6, 9]} + ]}; + var binding = render(template, data).pop(); + expect(getText(fixture)).equal('One123Two246Three369'); + binding.context = getContext(data); + // Remove inner item + remove(binding, data.items, 1, 1); + expect(getText(fixture)).equal('One123Three369'); + // Remove multiple remaining + remove(binding, data.items, 0, 2); + expect(getText(fixture)).equal(''); + }); + + it('moves to outer nested each', function() { + var template = new saddle.Template([ + new saddle.EachBlock(new expressions.Expression('items'), [ + new saddle.DynamicText(new expressions.Expression('name')) + , new saddle.EachBlock(new expressions.Expression('subitems'), [ + new saddle.DynamicText(new expressions.Expression()) + ]) + ]) + ]); + var data = {items: [ + {name: 'One', subitems: [1, 2, 3]} + , {name: 'Two', subitems: [2, 4, 6]} + , {name: 'Three', subitems: [3, 6, 9]} + ]}; + var binding = render(template, data).pop(); + expect(getText(fixture)).equal('One123Two246Three369'); + binding.context = getContext(data); + // Move one item + move(binding, data.items, 1, 2, 1); + expect(getText(fixture)).equal('One123Three369Two246'); + // Move multiple items + move(binding, data.items, 1, 0, 2); + expect(getText(fixture)).equal('Three369Two246One123'); + }); + + it('updates an if inside an each', function() { + var template = new saddle.Template([ + new saddle.EachBlock(new expressions.Expression('items'), [ + new saddle.ConditionalBlock([ + new expressions.Expression('flag'), + new expressions.ElseExpression() + ], [ + [new saddle.Text('A')], + [new saddle.Text('B')] + ]) + ]) + ]); + var data = {items: [0, 1], flag: true}; + var bindings = render(template, data); + expect(getText(fixture)).equal('AA'); + + var eachBinding = bindings[4]; + var if1Binding = bindings[2]; + var if2Binding = bindings[0]; + + data.flag = false; + if1Binding.update(); + if2Binding.update(); + expect(getText(fixture)).equal('BB'); + + remove(eachBinding, data.items, 0, 1); + expect(getText(fixture)).equal('B'); + }); +} + +function getContext(data, bindings) { + var contextMeta = new expressions.ContextMeta(); + contextMeta.addBinding = function(binding) { + bindings && bindings.push(binding); + }; + return new expressions.Context(contextMeta, data); +} + +function removeChildren(node) { + while (node && node.firstChild) { + node.removeChild(node.firstChild); + } +} + +// IE <=8 return comments for Node.children +function getChildren(node) { + var nodeChildren = node.children; + var children = []; + for (var i = 0, len = nodeChildren.length; i < len; i++) { + var child = nodeChildren[i]; + if (child.nodeType === 1) children.push(child); + } + return children; +} + +function getText(node) { + return node.textContent; +} +if (!document.createTextNode('x').textContent) { + // IE only supports innerText, and it sometimes returns extra whitespace + getText = function(node) { + return node.innerText.replace(/\s/g, ''); + }; +} + +function insert(binding, array, index, items) { + array.splice.apply(array, [index, 0].concat(items)); + binding.insert(index, items.length); +} +function remove(binding, array, index, howMany) { + array.splice(index, howMany); + binding.remove(index, howMany); +} +function move(binding, array, from, to, howMany) { + var values = array.splice(from, howMany); + array.splice.apply(array, [to, 0].concat(values)); + binding.move(from, to, howMany); +} diff --git a/test/server/templates/templates.server.mocha.js b/test/server/templates/templates.server.mocha.js new file mode 100644 index 000000000..a3422306b --- /dev/null +++ b/test/server/templates/templates.server.mocha.js @@ -0,0 +1,101 @@ +var expect = require('chai').expect; +var templates = require('../index'); +var expressions = require('../example/expressions'); + +function test(createTemplate) { + return function() { + var serialized = createTemplate().serialize(); + var expected = createTemplate.toString() + // Remove leading & trailing whitespace and newlines + .replace(/\s*\r?\n\s*/g, '') + // Remove the wrapping function boilerplate + .replace(/^function\s*\(\)\s*\{return (.*?);?}$/, '$1'); + expect(serialized).equal(expected); + }; +} + +describe('Block::serialize', function() { + it('serializes without arguments', test(function() { + return new templates.Block(); + })); + it('serializes content', test(function() { + return new templates.Block(null, [new templates.Element('div')]); + })); +}); + +describe('Text::serialize', function() { + it('serializes', test(function() { + return new templates.Text('test'); + })); +}); + +describe('Comment::serialize', function() { + it('serializes', test(function() { + return new templates.Comment('test\''); + })); +}); + +describe('Element::serialize', function() { + it('serializes tagName only', test(function() { + return new templates.Element('test'); + })); +}); + +describe('Attribute::serialize', function() { + it('serializes naked attribute', test(function() { + return new templates.Attribute('test'); + })); + it('serializes attribute in Element', test(function() { + return new templates.Element('div', { + 'class': new templates.Attribute('post') + }); + })); + it('serializes attribute in nested Element', test(function() { + return new templates.Block(null, [ + new templates.Element('div', { + 'class': new templates.Attribute('post') + }) + ]); + })); +}); + +describe('Expression::serialize', function() { + it('serializes example expression', test(function() { + return new expressions.Expression('test'); + })); +}); + +describe('ConditionalBlock::serialize', function() { + it('serializes multiple condition block', test(function() { + return new templates.ConditionalBlock( + [new expressions.Expression('comments'), null] + , [ + [new templates.Element('h1', null, [new templates.Text('Comments')]), new templates.Text('')] + , [new templates.Element('h1', null, [new templates.Text('No comments')])] + ] + ); + })); +}); + +describe('EachBlock::serialize', function() { + it('serializes each block with else', test(function() { + return new templates.EachBlock( + new expressions.Expression('comments') + , [ + new templates.Element('h2', null, [ + new templates.Text('By ') + , new templates.Block( + new expressions.Expression('nonsense') + , [new templates.DynamicText(new expressions.Expression('author'))] + ) + ]) + , new templates.Element('div', { + 'class': new templates.Attribute('body') + } + , [new templates.DynamicText(new expressions.Expression('body'))] + ) + ] + , [new templates.Text('Lamers')] + ); + })); +}); From 2dfde9df70b37a593cde52135751326a7f3f352e Mon Sep 17 00:00:00 2001 From: Eric Hwang Date: Wed, 14 Jun 2023 14:11:24 -0700 Subject: [PATCH 02/11] Fix lint issues in code moved from saddle --- lib/templates/templates.js | 100 +++--- test/dom/templates/templates.dom.mocha.js | 320 +++++++++--------- .../templates/templates.server.mocha.js | 33 +- 3 files changed, 228 insertions(+), 225 deletions(-) diff --git a/lib/templates/templates.js b/lib/templates/templates.js index 26e47fe4d..703698803 100644 --- a/lib/templates/templates.js +++ b/lib/templates/templates.js @@ -9,31 +9,31 @@ if (typeof require === 'function') { // https://github.com/jquery/jquery/blob/master/src/attributes/prop.js // http://webbugtrack.blogspot.com/2007/08/bug-242-setattribute-doesnt-always-work.html var BOOLEAN_PROPERTIES = { - checked: 'checked' -, disabled: 'disabled' -, indeterminate: 'indeterminate' -, readonly: 'readOnly' -, selected: 'selected' + checked: 'checked', + disabled: 'disabled', + indeterminate: 'indeterminate', + readonly: 'readOnly', + selected: 'selected' }; var INTEGER_PROPERTIES = { - colspan: 'colSpan' -, maxlength: 'maxLength' -, rowspan: 'rowSpan' -, tabindex: 'tabIndex' + colspan: 'colSpan', + maxlength: 'maxLength', + rowspan: 'rowSpan', + tabindex: 'tabIndex' }; var STRING_PROPERTIES = { - cellpadding: 'cellPadding' -, cellspacing: 'cellSpacing' -, 'class': 'className' -, contenteditable: 'contentEditable' -, enctype: 'encoding' -, 'for': 'htmlFor' -, frameborder: 'frameBorder' -, id: 'id' -, title: 'title' -, type: 'type' -, usemap: 'useMap' -, value: 'value' + cellpadding: 'cellPadding', + cellspacing: 'cellSpacing', + 'class': 'className', + contenteditable: 'contentEditable', + enctype: 'encoding', + 'for': 'htmlFor', + frameborder: 'frameBorder', + id: 'id', + title: 'title', + type: 'type', + usemap: 'useMap', + value: 'value' }; var UPDATE_PROPERTIES = {}; mergeInto(BOOLEAN_PROPERTIES, UPDATE_PROPERTIES); @@ -52,28 +52,28 @@ CREATE_PROPERTIES.value = 'defaultValue'; // http://www.w3.org/html/wg/drafts/html/master/syntax.html#void-elements var VOID_ELEMENTS = { - area: true -, base: true -, br: true -, col: true -, embed: true -, hr: true -, img: true -, input: true -, keygen: true -, link: true -, menuitem: true -, meta: true -, param: true -, source: true -, track: true -, wbr: true + area: true, + base: true, + br: true, + col: true, + embed: true, + hr: true, + img: true, + input: true, + keygen: true, + link: true, + menuitem: true, + meta: true, + param: true, + source: true, + track: true, + wbr: true }; var NAMESPACE_URIS = { - svg: 'http://www.w3.org/2000/svg' -, xlink: 'http://www.w3.org/1999/xlink' -, xmlns: 'http://www.w3.org/2000/xmlns/' + svg: 'http://www.w3.org/2000/svg', + xlink: 'http://www.w3.org/1999/xlink', + xmlns: 'http://www.w3.org/2000/xmlns/' }; exports.CREATE_PROPERTIES = CREATE_PROPERTIES; @@ -409,7 +409,7 @@ Html.prototype.appendTo = function(parent) { Html.prototype.attachTo = function(parent, node) { return attachHtml(parent, node, this.data); }; -Html.prototype.type = "Html"; +Html.prototype.type = 'Html'; Html.prototype.serialize = function() { return serializeObject.instance(this, this.data); }; @@ -673,14 +673,14 @@ Element.prototype._bindContent = function(context, element) { Element.prototype.type = 'Element'; Element.prototype.serialize = function() { return serializeObject.instance( - this - , this.tagName - , this.attributes - , this.content - , this.hooks - , this.selfClosing - , this.notClosed - , this.ns + this, + this.tagName, + this.attributes, + this.content, + this.hooks, + this.selfClosing, + this.notClosed, + this.ns ); }; @@ -1016,7 +1016,7 @@ EachBlock.prototype.serialize = function() { function indexStartNode(binding, index) { var node = binding.start; var i = 0; - while (node = node.nextSibling) { + while ((node = node.nextSibling)) { if (node === binding.end) return node; if (node.$bindItemStart && node.$bindItemStart.itemFor === binding.start) { if (index === i) return node; diff --git a/test/dom/templates/templates.dom.mocha.js b/test/dom/templates/templates.dom.mocha.js index cf3038c56..8558430cc 100644 --- a/test/dom/templates/templates.dom.mocha.js +++ b/test/dom/templates/templates.dom.mocha.js @@ -53,9 +53,9 @@ describe('Dynamic rendering', function() { function testStaticRendering(test) { it('renders an empty div', function() { test({ - template: new saddle.Element('div') - , html: '
' - , fragment: function(fragment) { + template: new saddle.Element('div'), + html: '
', + fragment: function(fragment) { expect(fragment.childNodes.length).equal(1); expect(fragment.childNodes[0].tagName.toLowerCase()).equal('div'); } @@ -64,9 +64,9 @@ function testStaticRendering(test) { it('renders a void element', function() { test({ - template: new saddle.Element('br') - , html: '
' - , fragment: function(fragment) { + template: new saddle.Element('br'), + html: '
', + fragment: function(fragment) { expect(fragment.childNodes.length).equal(1); expect(fragment.childNodes[0].tagName.toLowerCase()).equal('br'); } @@ -76,12 +76,12 @@ function testStaticRendering(test) { it('renders a div with literal attributes', function() { test({ template: new saddle.Element('div', { - id: new saddle.Attribute('page') - , 'data-x': new saddle.Attribute('24') - , 'class': new saddle.Attribute('content fit') - }) - , html: '
' - , fragment: function(fragment) { + id: new saddle.Attribute('page'), + 'data-x': new saddle.Attribute('24'), + 'class': new saddle.Attribute('content fit') + }), + html: '
', + fragment: function(fragment) { expect(fragment.childNodes.length).equal(1); expect(fragment.childNodes[0].tagName.toLowerCase()).equal('div'); expect(fragment.childNodes[0].id).equal('page'); @@ -95,9 +95,9 @@ function testStaticRendering(test) { test({ template: new saddle.Element('input', { autofocus: new saddle.Attribute(true) - }) - , html: '' - , fragment: function(fragment) { + }), + html: '', + fragment: function(fragment) { expect(fragment.childNodes.length).equal(1); expect(fragment.childNodes[0].tagName.toLowerCase()).equal('input'); expect(fragment.childNodes[0].getAttribute('autofocus')).not.eql(null); @@ -109,9 +109,9 @@ function testStaticRendering(test) { test({ template: new saddle.Element('input', { autofocus: new saddle.Attribute(false) - }) - , html: '' - , fragment: function(fragment) { + }), + html: '', + fragment: function(fragment) { expect(fragment.childNodes.length).equal(1); expect(fragment.childNodes[0].tagName.toLowerCase()).equal('input'); expect(fragment.childNodes[0].getAttribute('autofocus')).eql(null); @@ -124,9 +124,9 @@ function testStaticRendering(test) { test({ template: new saddle.Element('div', { title: new saddle.Attribute('My tooltip') - }) - , html: '
' - , fragment: function(fragment) { + }), + html: '
', + fragment: function(fragment) { expect(fragment.childNodes.length).equal(1); expect(fragment.childNodes[0].tagName.toLowerCase()).equal('div'); expect(fragment.childNodes[0].getAttribute('title')).eql('My tooltip'); @@ -138,9 +138,9 @@ function testStaticRendering(test) { test({ template: new saddle.Element('div', { title: new saddle.Attribute(123) - }) - , html: '
' - , fragment: function(fragment) { + }), + html: '
', + fragment: function(fragment) { expect(fragment.childNodes.length).equal(1); expect(fragment.childNodes[0].tagName.toLowerCase()).equal('div'); expect(fragment.childNodes[0].getAttribute('title')).eql('123'); @@ -152,9 +152,9 @@ function testStaticRendering(test) { test({ template: new saddle.Element('div', { title: new saddle.Attribute(undefined) - }) - , html: '
' - , fragment: function(fragment) { + }), + html: '
', + fragment: function(fragment) { expect(fragment.childNodes.length).equal(1); expect(fragment.childNodes[0].tagName.toLowerCase()).equal('div'); expect(fragment.childNodes[0].hasAttribute('title')).eql(false); @@ -167,12 +167,12 @@ function testStaticRendering(test) { test({ template: new saddle.Element('div', null, [ new saddle.Element('div', null, [ + new saddle.Element('span'), new saddle.Element('span') - , new saddle.Element('span') ]) - ]) - , html: '
' - , fragment: function(fragment) { + ]), + html: '
', + fragment: function(fragment) { expect(fragment.childNodes.length).equal(1); var node = fragment.childNodes[0]; expect(node.tagName.toLowerCase()).equal('div'); @@ -190,9 +190,9 @@ function testStaticRendering(test) { it('renders a text node', function() { test({ - template: new saddle.Text('Hi') - , html: 'Hi' - , fragment: function(fragment) { + template: new saddle.Text('Hi'), + html: 'Hi', + fragment: function(fragment) { expect(fragment.childNodes.length).equal(1); expect(fragment.childNodes[0].nodeType).equal(3); expect(fragment.childNodes[0].data).equal('Hi'); @@ -203,11 +203,11 @@ function testStaticRendering(test) { it('renders text nodes in an element', function() { test({ template: new saddle.Element('div', null, [ - new saddle.Text('Hello, ') - , new saddle.Text('world.') - ]) - , html: '
Hello, world.
' - , fragment: function(fragment) { + new saddle.Text('Hello, '), + new saddle.Text('world.') + ]), + html: '
Hello, world.
', + fragment: function(fragment) { expect(fragment.childNodes.length).equal(1); var node = fragment.childNodes[0]; expect(node.tagName.toLowerCase()).equal('div'); @@ -222,9 +222,9 @@ function testStaticRendering(test) { it('renders a comment', function() { test({ - template: new saddle.Comment('Hi') - , html: '' - , fragment: function(fragment) { + template: new saddle.Comment('Hi'), + html: '', + fragment: function(fragment) { expect(fragment.childNodes.length).equal(1); expect(fragment.childNodes[0].nodeType).equal(8); expect(fragment.childNodes[0].data).equal('Hi'); @@ -235,13 +235,13 @@ function testStaticRendering(test) { it('renders a template', function() { test({ template: new saddle.Template([ - new saddle.Comment('Hi') - , new saddle.Element('div', null, [ + new saddle.Comment('Hi'), + new saddle.Element('div', null, [ new saddle.Text('Ho') ]) - ]) - , html: '
Ho
' - , fragment: function(fragment) { + ]), + html: '
Ho
', + fragment: function(fragment) { expect(fragment.childNodes.length).equal(2); expect(fragment.childNodes[0].nodeType).equal(8); expect(fragment.childNodes[0].data).equal('Hi'); @@ -256,9 +256,9 @@ function testStaticRendering(test) { it('renders raw HTML', function() { test({ - template: new saddle.Html('
Hi
') - , html: '
Hi
' - , fragment: function(fragment) { + template: new saddle.Html('
Hi
'), + html: '
Hi
', + fragment: function(fragment) { expect(fragment.childNodes.length).equal(2); var node = fragment.childNodes[0]; expect(node.tagName.toLowerCase()).equal('div'); @@ -275,9 +275,9 @@ function testStaticRendering(test) { new saddle.Element('tbody', null, [ new saddle.Html('Hi') ]) - ]) - , html: '
Hi
' - , fragment: function(fragment) { + ]), + html: '
Hi
', + fragment: function(fragment) { var node = fragment.firstChild; expect(node.tagName.toLowerCase()).equal('table'); node = node.firstChild; @@ -295,9 +295,9 @@ function testStaticRendering(test) { test({ template: new saddle.Element('input', { value: new saddle.Attribute('hello') - }) - , html: '' - , fragment: function(fragment) { + }), + html: '', + fragment: function(fragment) { expect(fragment.childNodes[0].value).equal('hello'); expect(fragment.childNodes[0].getAttribute('value')).equal('hello'); } @@ -307,11 +307,11 @@ function testStaticRendering(test) { it('renders checked attribute: true', function() { test({ template: new saddle.Element('input', { - type: new saddle.Attribute('radio') - , checked: new saddle.Attribute(true) - }) - , html: '' - , fragment: function(fragment) { + type: new saddle.Attribute('radio'), + checked: new saddle.Attribute(true) + }), + html: '', + fragment: function(fragment) { expect(fragment.childNodes[0].checked).equal(true); } }); @@ -320,11 +320,11 @@ function testStaticRendering(test) { it('renders indeterminate attribute: true', function() { test({ template: new saddle.Element('input', { - type: new saddle.Attribute('checkbox') - , indeterminate: new saddle.Attribute(true) - }) - , html: '' - , fragment: function(fragment) { + type: new saddle.Attribute('checkbox'), + indeterminate: new saddle.Attribute(true) + }), + html: '', + fragment: function(fragment) { expect(fragment.childNodes[0].indeterminate).equal(true); } }); @@ -333,11 +333,11 @@ function testStaticRendering(test) { it('renders checked attribute: false', function() { test({ template: new saddle.Element('input', { - type: new saddle.Attribute('radio') - , checked: new saddle.Attribute(false) - }) - , html: '' - , fragment: function(fragment) { + type: new saddle.Attribute('radio'), + checked: new saddle.Attribute(false) + }), + html: '', + fragment: function(fragment) { expect(fragment.childNodes[0].checked).equal(false); } }); @@ -351,16 +351,16 @@ function testDynamicRendering(test) { test({ template: new saddle.Element('div', { 'class': new saddle.DynamicAttribute(new saddle.Template([ - new saddle.Text('dropdown') - , new saddle.ConditionalBlock([ + new saddle.Text('dropdown'), + new saddle.ConditionalBlock([ new expressions.Expression('show') ], [ [new saddle.Text(' show')] ]) ])) - }) - , html: '' - , fragment: function(fragment) { + }), + html: '', + fragment: function(fragment) { expect(fragment.childNodes.length).equal(1); expect(fragment.childNodes[0].tagName.toLowerCase()).equal('div'); expect(fragment.childNodes[0].className).equal('dropdown show'); @@ -386,8 +386,8 @@ describe('attachTo', function() { it('splits static text nodes', function() { var template = new saddle.Template([ - new saddle.Text('Hi') - , new saddle.Text(' there.') + new saddle.Text('Hi'), + new saddle.Text(' there.') ]); renderAndAttach(template); expect(fixture.childNodes.length).equal(2); @@ -395,8 +395,8 @@ describe('attachTo', function() { it('splits empty static text nodes', function() { var template = new saddle.Template([ + new saddle.Text(''), new saddle.Text('') - , new saddle.Text('') ]); renderAndAttach(template); expect(fixture.childNodes.length).equal(2); @@ -404,12 +404,12 @@ describe('attachTo', function() { it('splits mixed empty static text nodes', function() { var template = new saddle.Template([ + new saddle.Text(''), + new saddle.Text('Hi'), + new saddle.Text(''), + new saddle.Text(''), + new saddle.Text(' there.'), new saddle.Text('') - , new saddle.Text('Hi') - , new saddle.Text('') - , new saddle.Text('') - , new saddle.Text(' there.') - , new saddle.Text('') ]); renderAndAttach(template); expect(fixture.childNodes.length).equal(6); @@ -417,11 +417,11 @@ describe('attachTo', function() { it('adds empty text nodes around a comment', function() { var template = new saddle.Template([ - new saddle.Text('Hi') - , new saddle.Text('') - , new saddle.Comment('cool') - , new saddle.Comment('thing') - , new saddle.Text('') + new saddle.Text('Hi'), + new saddle.Text(''), + new saddle.Comment('cool'), + new saddle.Comment('thing'), + new saddle.Text('') ]); renderAndAttach(template); expect(fixture.childNodes.length).equal(5); @@ -432,8 +432,8 @@ describe('attachTo', function() { new saddle.Element('ul', null, [ new saddle.Element('li', null, [ new saddle.Text('One') - ]) - , new saddle.Element('li', null, [ + ]), + new saddle.Element('li', null, [ new saddle.Text('Two') ]) ]) @@ -444,9 +444,9 @@ describe('attachTo', function() { it('attaches to element attributes', function() { var template = new saddle.Template([ new saddle.Element('input', { - type: new saddle.Attribute('text') - , autofocus: new saddle.Attribute(true) - , placeholder: new saddle.Attribute(null) + type: new saddle.Attribute('text'), + autofocus: new saddle.Attribute(true), + placeholder: new saddle.Attribute(null) }) ]); renderAndAttach(template); @@ -455,9 +455,9 @@ describe('attachTo', function() { it('attaches to from HTML within tbody context', function() { var template = new saddle.Element('table', null, [ new saddle.Element('tbody', null, [ - new saddle.Comment('OK') - , new saddle.Html('Hi') - , new saddle.Element('tr', null, [ + new saddle.Comment('OK'), + new saddle.Html('Hi'), + new saddle.Element('tr', null, [ new saddle.Element('td', null, [ new saddle.Text('Ho') ]) @@ -471,21 +471,21 @@ describe('attachTo', function() { // IE fails to create comments in certain locations when parsing HTML var template = new saddle.Template([ new saddle.Element('table', null, [ - new saddle.Comment('table comment') - , new saddle.Element('tbody', null, [ - new saddle.Comment('tbody comment') - , new saddle.Element('tr', null, [ + new saddle.Comment('table comment'), + new saddle.Element('tbody', null, [ + new saddle.Comment('tbody comment'), + new saddle.Element('tr', null, [ new saddle.Element('td') ]) ]) - ]) - , new saddle.Element('select', null, [ - new saddle.Comment('select comment start') - , new saddle.Element('option') - , new saddle.Comment('select comment inner') - , new saddle.Element('option') - , new saddle.Comment('select comment end') - , new saddle.Comment('select comment end 2') + ]), + new saddle.Element('select', null, [ + new saddle.Comment('select comment start'), + new saddle.Element('option'), + new saddle.Comment('select comment inner'), + new saddle.Element('option'), + new saddle.Comment('select comment end'), + new saddle.Comment('select comment end 2') ]) ]); renderAndAttach(template); @@ -557,8 +557,8 @@ function testBindingUpdates(render) { it('updates sibling TextNodes', function() { var template = new saddle.Template([ - new saddle.DynamicText(new expressions.Expression('first')) - , new saddle.DynamicText(new expressions.Expression('second')) + new saddle.DynamicText(new expressions.Expression('first')), + new saddle.DynamicText(new expressions.Expression('second')) ]); var bindings = render(template, {second: 2}); expect(bindings.length).equal(2); @@ -580,8 +580,8 @@ function testBindingUpdates(render) { var binding = render(template, data).pop(); expect(getText(fixture)).equal('Hola'); binding.context = getContext({ - dynamicTemplate: new saddle.DynamicText(new expressions.Expression('text')) - , text: 'Yo' + dynamicTemplate: new saddle.DynamicText(new expressions.Expression('text')), + text: 'Yo' }); binding.update(); expect(getText(fixture)).equal('Yo'); @@ -592,8 +592,8 @@ function testBindingUpdates(render) { new saddle.DynamicText(new expressions.Expression('dynamicTemplate')) ]); var data = { - dynamicTemplate: new saddle.DynamicText(new expressions.Expression('text')) - , text: 'Yo' + dynamicTemplate: new saddle.DynamicText(new expressions.Expression('text')), + text: 'Yo' }; var binding = render(template, data).pop(); expect(getText(fixture)).equal('Yo'); @@ -607,18 +607,18 @@ function testBindingUpdates(render) { new saddle.DynamicText(new expressions.Expression('dynamicTemplate')) ]); var data = { - dynamicTemplate: new saddle.DynamicText(new expressions.Expression('text')) - , text: 'Yo' + dynamicTemplate: new saddle.DynamicText(new expressions.Expression('text')), + text: 'Yo' }; var binding = render(template, data).pop(); expect(getText(fixture)).equal('Yo'); binding.context = getContext({ dynamicTemplate: new saddle.Template([ - new saddle.DynamicText(new expressions.Expression('first')) - , new saddle.DynamicText(new expressions.Expression('second')) - ]) - , first: 'one' - , second: 'two' + new saddle.DynamicText(new expressions.Expression('first')), + new saddle.DynamicText(new expressions.Expression('second')) + ]), + first: 'one', + second: 'two' }); binding.update(); expect(getText(fixture)).equal('onetwo'); @@ -629,8 +629,8 @@ function testBindingUpdates(render) { new saddle.DynamicText(new expressions.Expression('dynamicTemplate')) ]); var data = { - dynamicTemplate: new saddle.DynamicText(new expressions.Expression('text')) - , text: 'Yo' + dynamicTemplate: new saddle.DynamicText(new expressions.Expression('text')), + text: 'Yo' }; var textBinding = render(template, data).shift(); expect(getText(fixture)).equal('Yo'); @@ -653,8 +653,8 @@ function testBindingUpdates(render) { it('updates raw HTML', function() { var template = new saddle.Template([ - new saddle.DynamicHtml(new expressions.Expression('html')) - , new saddle.Element('div') + new saddle.DynamicHtml(new expressions.Expression('html')), + new saddle.Element('div') ]); var binding = render(template, {html: 'Hi'}).pop(); var children = getChildren(fixture); @@ -680,8 +680,8 @@ function testBindingUpdates(render) { it('updates an Element attribute', function() { var template = new saddle.Template([ new saddle.Element('div', { - 'class': new saddle.Attribute('message') - , 'data-greeting': new saddle.DynamicAttribute(new expressions.Expression('greeting')) + 'class': new saddle.Attribute('message'), + 'data-greeting': new saddle.DynamicAttribute(new expressions.Expression('greeting')) }) ]); var binding = render(template).pop(); @@ -780,8 +780,8 @@ function testBindingUpdates(render) { new saddle.Block(new expressions.Expression('author'), [ new saddle.Element('h3', null, [ new saddle.DynamicText(new expressions.Expression('name')) - ]) - , new saddle.DynamicText(new expressions.Expression('name')) + ]), + new saddle.DynamicText(new expressions.Expression('name')) ]) ]); var binding = render(template).pop(); @@ -831,13 +831,13 @@ function testBindingUpdates(render) { it('updates a multi-condition ConditionalBlock', function() { var template = new saddle.Template([ new saddle.ConditionalBlock([ - new expressions.Expression('primary') - , new expressions.Expression('alternate') - , new expressions.ElseExpression() + new expressions.Expression('primary'), + new expressions.Expression('alternate'), + new expressions.ElseExpression() ], [ - [new saddle.DynamicText(new expressions.Expression())] - , [] - , [new saddle.Text('else')] + [new saddle.DynamicText(new expressions.Expression())], + [], + [new saddle.Text('else')] ]) ]); var binding = render(template).pop(); @@ -1013,14 +1013,14 @@ function testBindingUpdates(render) { new saddle.EachBlock(new expressions.Expression('items'), [ new saddle.Element('h3', null, [ new saddle.DynamicText(new expressions.Expression('title')) - ]) - , new saddle.DynamicText(new expressions.Expression('text')) + ]), + new saddle.DynamicText(new expressions.Expression('text')) ]) ]); var data = {items: [ - {title: '1', text: 'one'} - , {title: '2', text: 'two'} - , {title: '3', text: 'three'} + {title: '1', text: 'one'}, + {title: '2', text: 'two'}, + {title: '3', text: 'three'} ]}; var binding = render(template, data).pop(); expect(getText(fixture)).equal('1one2two3three'); @@ -1039,8 +1039,8 @@ function testBindingUpdates(render) { it('inserts to outer nested each', function() { var template = new saddle.Template([ new saddle.EachBlock(new expressions.Expression('items'), [ - new saddle.DynamicText(new expressions.Expression('name')) - , new saddle.EachBlock(new expressions.Expression('subitems'), [ + new saddle.DynamicText(new expressions.Expression('name')), + new saddle.EachBlock(new expressions.Expression('subitems'), [ new saddle.DynamicText(new expressions.Expression()) ]) ]) @@ -1051,15 +1051,15 @@ function testBindingUpdates(render) { var data = {items: []}; binding.context = getContext(data); insert(binding, data.items, 0, [ - {name: 'One', subitems: [1, 2, 3]} - , {name: 'Two', subitems: [2, 4, 6]} - , {name: 'Three', subitems: [3, 6, 9]} + {name: 'One', subitems: [1, 2, 3]}, + {name: 'Two', subitems: [2, 4, 6]}, + {name: 'Three', subitems: [3, 6, 9]} ]); expect(getText(fixture)).equal('One123Two246Three369'); // Insert new items insert(binding, data.items, 1, [ - {name: 'Four', subitems: [4, 8, 12]} - , {name: 'Five', subitems: [5, 10, 15]} + {name: 'Four', subitems: [4, 8, 12]}, + {name: 'Five', subitems: [5, 10, 15]} ]); expect(getText(fixture)).equal('One123Four4812Five51015Two246Three369'); // Insert new items again @@ -1072,16 +1072,16 @@ function testBindingUpdates(render) { it('removes from outer nested each', function() { var template = new saddle.Template([ new saddle.EachBlock(new expressions.Expression('items'), [ - new saddle.DynamicText(new expressions.Expression('name')) - , new saddle.EachBlock(new expressions.Expression('subitems'), [ + new saddle.DynamicText(new expressions.Expression('name')), + new saddle.EachBlock(new expressions.Expression('subitems'), [ new saddle.DynamicText(new expressions.Expression()) ]) ]) ]); var data = {items: [ - {name: 'One', subitems: [1, 2, 3]} - , {name: 'Two', subitems: [2, 4, 6]} - , {name: 'Three', subitems: [3, 6, 9]} + {name: 'One', subitems: [1, 2, 3]}, + {name: 'Two', subitems: [2, 4, 6]}, + {name: 'Three', subitems: [3, 6, 9]} ]}; var binding = render(template, data).pop(); expect(getText(fixture)).equal('One123Two246Three369'); @@ -1097,16 +1097,16 @@ function testBindingUpdates(render) { it('moves to outer nested each', function() { var template = new saddle.Template([ new saddle.EachBlock(new expressions.Expression('items'), [ - new saddle.DynamicText(new expressions.Expression('name')) - , new saddle.EachBlock(new expressions.Expression('subitems'), [ + new saddle.DynamicText(new expressions.Expression('name')), + new saddle.EachBlock(new expressions.Expression('subitems'), [ new saddle.DynamicText(new expressions.Expression()) ]) ]) ]); var data = {items: [ - {name: 'One', subitems: [1, 2, 3]} - , {name: 'Two', subitems: [2, 4, 6]} - , {name: 'Three', subitems: [3, 6, 9]} + {name: 'One', subitems: [1, 2, 3]}, + {name: 'Two', subitems: [2, 4, 6]}, + {name: 'Three', subitems: [3, 6, 9]} ]}; var binding = render(template, data).pop(); expect(getText(fixture)).equal('One123Two246Three369'); @@ -1152,7 +1152,9 @@ function testBindingUpdates(render) { function getContext(data, bindings) { var contextMeta = new expressions.ContextMeta(); contextMeta.addBinding = function(binding) { - bindings && bindings.push(binding); + if (bindings) { + bindings.push(binding); + } }; return new expressions.Context(contextMeta, data); } diff --git a/test/server/templates/templates.server.mocha.js b/test/server/templates/templates.server.mocha.js index a3422306b..dd44db977 100644 --- a/test/server/templates/templates.server.mocha.js +++ b/test/server/templates/templates.server.mocha.js @@ -68,10 +68,10 @@ describe('Expression::serialize', function() { describe('ConditionalBlock::serialize', function() { it('serializes multiple condition block', test(function() { return new templates.ConditionalBlock( - [new expressions.Expression('comments'), null] - , [ - [new templates.Element('h1', null, [new templates.Text('Comments')]), new templates.Text('')] - , [new templates.Element('h1', null, [new templates.Text('No comments')])] + [new expressions.Expression('comments'), null], + [ + [new templates.Element('h1', null, [new templates.Text('Comments')]), new templates.Text('')], + [new templates.Element('h1', null, [new templates.Text('No comments')])] ] ); })); @@ -80,22 +80,23 @@ describe('ConditionalBlock::serialize', function() { describe('EachBlock::serialize', function() { it('serializes each block with else', test(function() { return new templates.EachBlock( - new expressions.Expression('comments') - , [ + new expressions.Expression('comments'), + [ new templates.Element('h2', null, [ - new templates.Text('By ') - , new templates.Block( - new expressions.Expression('nonsense') - , [new templates.DynamicText(new expressions.Expression('author'))] + new templates.Text('By '), + new templates.Block( + new expressions.Expression('nonsense'), + [new templates.DynamicText(new expressions.Expression('author'))] ) - ]) - , new templates.Element('div', { + ]), + new templates.Element('div', + { 'class': new templates.Attribute('body') - } - , [new templates.DynamicText(new expressions.Expression('body'))] + }, + [new templates.DynamicText(new expressions.Expression('body'))] ) - ] - , [new templates.Text('Lamers')] + ], + [new templates.Text('Lamers')] ); })); }); From 7e27aa691c88a4fd4c8f876f366f61e474244ed9 Mon Sep 17 00:00:00 2001 From: Eric Hwang Date: Wed, 14 Jun 2023 14:26:16 -0700 Subject: [PATCH 03/11] Move/merge code from derbyjs/derby-templates into this repo as-is --- lib/templates/contexts.js | 226 ++++++++ lib/templates/dependencyOptions.js | 16 + lib/templates/expressions.js | 759 ++++++++++++++++++++++++++ lib/templates/operatorFns.js | 132 +++++ lib/templates/templates.js | 756 +++++++++++++++++++++++++ lib/templates/util.js | 23 + test/all/templates/templates.mocha.js | 41 ++ 7 files changed, 1953 insertions(+) create mode 100644 lib/templates/contexts.js create mode 100644 lib/templates/dependencyOptions.js create mode 100644 lib/templates/expressions.js create mode 100644 lib/templates/operatorFns.js create mode 100644 lib/templates/util.js create mode 100644 test/all/templates/templates.mocha.js diff --git a/lib/templates/contexts.js b/lib/templates/contexts.js new file mode 100644 index 000000000..eb5f35173 --- /dev/null +++ b/lib/templates/contexts.js @@ -0,0 +1,226 @@ +exports.ContextMeta = ContextMeta; +exports.Context = Context; + +function noop() {} + +function ContextMeta() { + this.addBinding = noop; + this.removeBinding = noop; + this.removeNode = noop; + this.addItemContext = noop; + this.removeItemContext = noop; + this.views = null; + this.idNamespace = ''; + this.idCount = 0; + this.pending = []; + this.pauseCount = 0; +} + +function Context(meta, controller, parent, unbound, expression) { + // Required properties // + + // Properties which are globally inherited for the entire page + this.meta = meta; + // The page or component. Must have a `model` property with a `data` property + this.controller = controller; + + // Optional properties // + + // Containing context + this.parent = parent; + // Boolean set to true when bindings should be ignored + this.unbound = unbound; + // The expression for a block + this.expression = expression; + // Alias name for the given expression + this.alias = expression && expression.meta && expression.meta.as; + // Alias name for the index or iterated key + this.keyAlias = expression && expression.meta && expression.meta.keyAs; + + // For Context::eachChild + // The context of the each at render time + this.item = null; + + // For Context::viewChild + // Reference to the current view + this.view = null; + // Attribute values passed to the view instance + this.attributes = null; + // MarkupHooks to be called after insert into DOM of component + this.hooks = null; + // MarkupHooks to be called immediately before init of component + this.initHooks = null; + + // For Context::closureChild + // Reference to another context established at render time by ContextClosure + this.closure = null; + + // Used in EventModel + this._id = null; + this._eventModels = null; +} + +Context.prototype.id = function() { + var count = ++this.meta.idCount; + return this.meta.idNamespace + '_' + count.toString(36); +}; + +Context.prototype.addBinding = function(binding) { + // Don't add bindings that wrap list items. Only their outer range is needed + if (binding.itemFor) return; + var expression = binding.template.expression; + // Don't rerender in unbound sections + if (expression ? expression.isUnbound(this) : this.unbound) return; + // Don't rerender to changes in a with expression + if (expression && expression.meta && expression.meta.blockType === 'with') return; + this.meta.addBinding(binding); +}; +Context.prototype.removeBinding = function(binding) { + this.meta.removeBinding(binding); +}; +Context.prototype.removeNode = function(node) { + var bindItemStart = node.$bindItemStart; + if (bindItemStart) { + this.meta.removeItemContext(bindItemStart.context); + } + var component = node.$component; + if (component) { + node.$component = null; + if (!component.singleton) { + component.destroy(); + } + } + var destroyListeners = node.$destroyListeners; + if (destroyListeners) { + node.$destroyListeners = null; + for (var i = 0, len = destroyListeners.length; i < len; i++) { + destroyListeners[i](); + } + } +}; + +Context.prototype.child = function(expression) { + // Set or inherit the binding mode + var blockType = expression.meta && expression.meta.blockType; + var unbound = (blockType === 'unbound') ? true : + (blockType === 'bound') ? false : + this.unbound; + return new Context(this.meta, this.controller, this, unbound, expression); +}; + +Context.prototype.componentChild = function(component) { + return new Context(this.meta, component, this, this.unbound); +}; + +// Make a context for an item in an each block +Context.prototype.eachChild = function(expression, item) { + var context = new Context(this.meta, this.controller, this, this.unbound, expression); + context.item = item; + this.meta.addItemContext(context); + return context; +}; + +Context.prototype.viewChild = function(view, attributes, hooks, initHooks) { + var context = new Context(this.meta, this.controller, this, this.unbound); + context.view = view; + context.attributes = attributes; + context.hooks = hooks; + context.initHooks = initHooks; + return context; +}; + +Context.prototype.closureChild = function(closure) { + var context = new Context(this.meta, this.controller, this, this.unbound); + context.closure = closure; + return context; +}; + +Context.prototype.forRelative = function(expression) { + var context = this; + while (context && context.expression === expression || context.view) { + context = context.parent; + } + return context; +}; + +// Returns the closest context which defined the named alias +Context.prototype.forAlias = function(alias) { + var context = this; + while (context) { + if (context.alias === alias || context.keyAlias === alias) return context; + context = context.parent; + } +}; + +// Returns the closest containing context for a view attribute name or nothing +Context.prototype.forAttribute = function(attribute) { + var context = this; + while (context) { + // Find the closest context associated with a view + if (context.view) { + var attributes = context.attributes; + if (!attributes) return; + if (attributes.hasOwnProperty(attribute)) return context; + // If the attribute isn't found, but the attributes inherit, continue + // looking in the next closest view context + if (!attributes.inherit && !attributes.extend) return; + } + context = context.parent; + } +}; + +Context.prototype.forViewParent = function() { + var context = this; + while (context) { + // When a context with a `closure` property is encountered, skip to its + // parent context rather than returning the nearest view's. This reference + // is created by wrapping a template in a ContextClosure template + if (context.closure) return context.closure.parent; + // Find the closest view and return the containing context + if (context.view) return context.parent; + context = context.parent; + } +}; + +Context.prototype.getView = function() { + var context = this; + while (context) { + // Find the closest view + if (context.view) return context.view; + context = context.parent; + } +}; + +// Returns the `this` value for a context +Context.prototype.get = function() { + var value = (this.expression) ? + this.expression.get(this) : + this.controller.model.data; + if (this.item != null) { + return value && value[this.item]; + } + return value; +}; + +Context.prototype.pause = function() { + this.meta.pauseCount++; +}; + +Context.prototype.unpause = function() { + if (--this.meta.pauseCount) return; + this.flush(); +}; + +Context.prototype.flush = function() { + var pending = this.meta.pending; + var len = pending.length; + if (!len) return; + this.meta.pending = []; + for (var i = 0; i < len; i++) { + pending[i](); + } +}; + +Context.prototype.queue = function(cb) { + this.meta.pending.push(cb); +}; diff --git a/lib/templates/dependencyOptions.js b/lib/templates/dependencyOptions.js new file mode 100644 index 000000000..ac2c6adc8 --- /dev/null +++ b/lib/templates/dependencyOptions.js @@ -0,0 +1,16 @@ +var templates = require('./templates'); + +exports.DependencyOptions = DependencyOptions; + +function DependencyOptions(options) { + this.setIgnoreTemplate(options && options.ignoreTemplate); +} +DependencyOptions.shouldIgnoreTemplate = function(template, options) { + return (options) ? options.ignoreTemplate === template : false; +}; +DependencyOptions.prototype.setIgnoreTemplate = function(template) { + while (template instanceof templates.ContextClosure) { + template = template.template; + } + this.ignoreTemplate = template; +}; diff --git a/lib/templates/expressions.js b/lib/templates/expressions.js new file mode 100644 index 000000000..c22441ab0 --- /dev/null +++ b/lib/templates/expressions.js @@ -0,0 +1,759 @@ +var serializeObject = require('serialize-object'); +var operatorFns = require('./operatorFns'); +var templates = require('./templates'); +var Template = templates.Template; +var util = require('./util'); +var concat = util.concat; + +exports.lookup = lookup; +exports.templateTruthy = templateTruthy; +exports.pathSegments = pathSegments; +exports.renderValue = renderValue; +exports.renderTemplate = renderTemplate; +exports.ExpressionMeta = ExpressionMeta; + +exports.Expression = Expression; +exports.LiteralExpression = LiteralExpression; +exports.PathExpression = PathExpression; +exports.RelativePathExpression = RelativePathExpression; +exports.AliasPathExpression = AliasPathExpression; +exports.AttributePathExpression = AttributePathExpression; +exports.BracketsExpression = BracketsExpression; +exports.DeferRenderExpression = DeferRenderExpression; +exports.ArrayExpression = ArrayExpression; +exports.ObjectExpression = ObjectExpression; +exports.FnExpression = FnExpression; +exports.OperatorExpression = OperatorExpression; +exports.NewExpression = NewExpression; +exports.SequenceExpression = SequenceExpression; +exports.ViewParentExpression = ViewParentExpression; +exports.ScopedModelExpression = ScopedModelExpression; + +function lookup(segments, value) { + if (!segments) return value; + + for (var i = 0, len = segments.length; i < len; i++) { + if (value == null) return value; + value = value[segments[i]]; + } + return value; +} + +// Unlike JS, `[]` is falsey. Otherwise, truthiness is the same as JS +function templateTruthy(value) { + return (Array.isArray(value)) ? value.length > 0 : !!value; +} + +function pathSegments(segments) { + var result = []; + for (var i = 0; i < segments.length; i++) { + var segment = segments[i]; + result[i] = (typeof segment === 'object') ? segment.item : segment; + } + return result; +} + +function renderValue(value, context) { + return (typeof value !== 'object') ? value : + (value instanceof Template) ? renderTemplate(value, context) : + (Array.isArray(value)) ? renderArray(value, context) : + renderObject(value, context); +} +function renderTemplate(value, context) { + var i = 1000; + while (value instanceof Template) { + if (--i < 0) throw new Error('Maximum template render passes exceeded'); + value = value.get(context, true); + } + return value; +} +function renderArray(array, context) { + for (var i = 0; i < array.length; i++) { + if (hasTemplateProperty(array[i])) { + return renderArrayProperties(array, context); + } + } + return array; +} +function renderObject(object, context) { + return (hasTemplateProperty(object)) ? + renderObjectProperties(object, context) : object; +} +function hasTemplateProperty(object) { + if (!object) return false; + if (object.constructor !== Object) return false; + for (var key in object) { + if (object[key] instanceof Template) return true; + } + return false; +} +function renderArrayProperties(array, context) { + var out = new Array(array.length); + for (var i = 0; i < array.length; i++) { + out[i] = renderValue(array[i], context); + } + return out; +} +function renderObjectProperties(object, context) { + var out = {}; + for (var key in object) { + out[key] = renderValue(object[key], context); + } + return out; +} + +function ExpressionMeta(source, blockType, isEnd, as, keyAs, unescaped, bindType, valueType) { + this.source = source; + this.blockType = blockType; + this.isEnd = isEnd; + this.as = as; + this.keyAs = keyAs; + this.unescaped = unescaped; + this.bindType = bindType; + this.valueType = valueType; +} +ExpressionMeta.prototype.module = 'expressions'; +ExpressionMeta.prototype.type = 'ExpressionMeta'; +ExpressionMeta.prototype.serialize = function() { + return serializeObject.instance( + this + , this.source + , this.blockType + , this.isEnd + , this.as + , this.keyAs + , this.unescaped + , this.bindType + , this.valueType + ); +}; + +function Expression(meta) { + this.meta = meta; +} +Expression.prototype.module = 'expressions'; +Expression.prototype.type = 'Expression'; +Expression.prototype.serialize = function() { + return serializeObject.instance(this, this.meta); +}; +Expression.prototype.toString = function() { + return this.meta && this.meta.source; +}; +Expression.prototype.truthy = function(context) { + var blockType = this.meta.blockType; + if (blockType === 'else') return true; + var value = this.get(context, true); + var truthy = templateTruthy(value); + return (blockType === 'unless') ? !truthy : truthy; +}; +Expression.prototype.get = function() {}; +// Return the expression's segment list with context objects +Expression.prototype.resolve = function() {}; +// Return a list of segment lists or null +Expression.prototype.dependencies = function() {}; +// Return the pathSegments that the expression currently resolves to or null +Expression.prototype.pathSegments = function(context) { + var segments = this.resolve(context); + return segments && pathSegments(segments); +}; +Expression.prototype.set = function(context, value) { + var segments = this.pathSegments(context); + if (!segments) throw new Error('Expression does not support setting'); + context.controller.model._set(segments, value); +}; +Expression.prototype._resolvePatch = function(context, segments) { + return (context && context.expression === this && context.item != null) ? + segments.concat(context) : segments; +}; +Expression.prototype.isUnbound = function(context) { + // If the template being rendered has an explicit bindType keyword, such as: + // {{unbound #item.text}} + var bindType = this.meta && this.meta.bindType; + if (bindType === 'unbound') return true; + if (bindType === 'bound') return false; + // Otherwise, inherit from the context + return context.unbound; +}; +Expression.prototype._lookupAndContextifyValue = function(value, context) { + if (this.segments && this.segments.length) { + // If expression has segments, e.g. `bar.baz` in `#foo.bar.baz`, then + // render the base value (e.g. `#foo`) if it's a template and look up the + // value at the indicated path. + value = renderTemplate(value, context); + value = lookup(this.segments, value); + } + if (value instanceof Template && !(value instanceof templates.ContextClosure)) { + // If we're not immediately rendering the template, then create a ContextClosure + // so that the value renders with the correct context later. + value = new templates.ContextClosure(value, context); + } + return value; +}; + + +function LiteralExpression(value, meta) { + this.value = value; + this.meta = meta; +} +LiteralExpression.prototype = Object.create(Expression.prototype); +LiteralExpression.prototype.constructor = LiteralExpression; +LiteralExpression.prototype.type = 'LiteralExpression'; +LiteralExpression.prototype.serialize = function() { + return serializeObject.instance(this, this.value, this.meta); +}; +LiteralExpression.prototype.get = function() { + return this.value; +}; + +function PathExpression(segments, meta) { + this.segments = segments; + this.meta = meta; +} +PathExpression.prototype = Object.create(Expression.prototype); +PathExpression.prototype.constructor = PathExpression; +PathExpression.prototype.type = 'PathExpression'; +PathExpression.prototype.serialize = function() { + return serializeObject.instance(this, this.segments, this.meta); +}; +PathExpression.prototype.get = function(context) { + // See View::dependencies. This is needed in order to handle the case of + // getting dependencies within a component template, in which case we cannot + // access model data separate from rendering. + if (!context.controller) return; + return lookup(this.segments, context.controller.model.data); +}; +PathExpression.prototype.resolve = function(context) { + // See View::dependencies. This is needed in order to handle the case of + // getting dependencies within a component template, in which case we cannot + // access model data separate from rendering. + if (!context.controller) return; + var segments = concat(context.controller._scope, this.segments); + return this._resolvePatch(context, segments); +}; +PathExpression.prototype.dependencies = function(context, options) { + // See View::dependencies. This is needed in order to handle the case of + // getting dependencies within a component template, in which case we cannot + // access model data separate from rendering. + if (!context.controller) return; + var value = lookup(this.segments, context.controller.model.data); + var dependencies = getDependencies(value, context, options); + return appendDependency(dependencies, this, context); +}; + +function RelativePathExpression(segments, meta) { + this.segments = segments; + this.meta = meta; +} +RelativePathExpression.prototype = Object.create(Expression.prototype); +RelativePathExpression.prototype.constructor = RelativePathExpression; +RelativePathExpression.prototype.type = 'RelativePathExpression'; +RelativePathExpression.prototype.serialize = function() { + return serializeObject.instance(this, this.segments, this.meta); +}; +RelativePathExpression.prototype.get = function(context) { + var relativeContext = context.forRelative(this); + var value = relativeContext.get(); + return this._lookupAndContextifyValue(value, relativeContext); +}; +RelativePathExpression.prototype.resolve = function(context) { + var relativeContext = context.forRelative(this); + var base = (relativeContext.expression) ? + relativeContext.expression.resolve(relativeContext) : + []; + if (!base) return; + var segments = base.concat(this.segments); + return this._resolvePatch(context, segments); +}; +RelativePathExpression.prototype.dependencies = function(context, options) { + // Return inner dependencies from our ancestor + // (e.g., {{ with foo[bar] }} ... {{ this.x }} has 'bar' as a dependency.) + var relativeContext = context.forRelative(this); + var dependencies = relativeContext.expression && + relativeContext.expression.dependencies(relativeContext, options); + return swapLastDependency(dependencies, this, context); +}; + +function AliasPathExpression(alias, segments, meta) { + this.alias = alias; + this.segments = segments; + this.meta = meta; +} +AliasPathExpression.prototype = Object.create(Expression.prototype); +AliasPathExpression.prototype.constructor = AliasPathExpression; +AliasPathExpression.prototype.type = 'AliasPathExpression'; +AliasPathExpression.prototype.serialize = function() { + return serializeObject.instance(this, this.alias, this.segments, this.meta); +}; +AliasPathExpression.prototype.get = function(context) { + var aliasContext = context.forAlias(this.alias); + if (!aliasContext) return; + if (aliasContext.keyAlias === this.alias) { + return aliasContext.item; + } + var value = aliasContext.get(); + return this._lookupAndContextifyValue(value, aliasContext); +}; +AliasPathExpression.prototype.resolve = function(context) { + var aliasContext = context.forAlias(this.alias); + if (!aliasContext) return; + if (aliasContext.keyAlias === this.alias) return; + var base = aliasContext.expression.resolve(aliasContext); + if (!base) return; + var segments = base.concat(this.segments); + return this._resolvePatch(context, segments); +}; +AliasPathExpression.prototype.dependencies = function(context, options) { + var aliasContext = context.forAlias(this.alias); + if (!aliasContext) return; + if (aliasContext.keyAlias === this.alias) { + // For keyAliases, use a dependency of the entire list, so that it will + // always update when the list itself changes. This is over-binding, but + // would otherwise be much more complex + var base = aliasContext.expression.resolve(aliasContext.parent); + if (!base) return; + return [base]; + } + + var dependencies = aliasContext.expression.dependencies(aliasContext, options); + return swapLastDependency(dependencies, this, context); +}; + +function AttributePathExpression(attribute, segments, meta) { + this.attribute = attribute; + this.segments = segments; + this.meta = meta; +} +AttributePathExpression.prototype = Object.create(Expression.prototype); +AttributePathExpression.prototype.constructor = AttributePathExpression; +AttributePathExpression.prototype.type = 'AttributePathExpression'; +AttributePathExpression.prototype.serialize = function() { + return serializeObject.instance(this, this.attribute, this.segments, this.meta); +}; +AttributePathExpression.prototype.get = function(context) { + var attributeContext = context.forAttribute(this.attribute); + if (!attributeContext) return; + var value = attributeContext.attributes[this.attribute]; + if (value instanceof Expression) { + value = value.get(attributeContext); + } + return this._lookupAndContextifyValue(value, attributeContext); +}; +AttributePathExpression.prototype.resolve = function(context) { + var attributeContext = context.forAttribute(this.attribute); + if (!attributeContext) return; + // Attributes may be a template, an expression, or a literal value + var base; + var value = attributeContext.attributes[this.attribute]; + if (value instanceof Expression || value instanceof Template) { + base = value.resolve(attributeContext); + } + if (!base) return; + var segments = base.concat(this.segments); + return this._resolvePatch(context, segments); +}; +AttributePathExpression.prototype.dependencies = function(context, options) { + var attributeContext = context.forAttribute(this.attribute); + if (!attributeContext) return; + + // Attributes may be a template, an expression, or a literal value + var value = attributeContext.attributes[this.attribute]; + var dependencies = getDependencies(value, attributeContext, options); + return swapLastDependency(dependencies, this, context); +}; + +function BracketsExpression(before, inside, afterSegments, meta) { + this.before = before; + this.inside = inside; + this.afterSegments = afterSegments; + this.meta = meta; +} +BracketsExpression.prototype = Object.create(Expression.prototype); +BracketsExpression.prototype.constructor = BracketsExpression; +BracketsExpression.prototype.type = 'BracketsExpression'; +BracketsExpression.prototype.serialize = function() { + return serializeObject.instance(this, this.before, this.inside, this.afterSegments, this.meta); +}; +BracketsExpression.prototype.get = function(context) { + var inside = this.inside.get(context); + if (inside == null) return; + var before = this.before.get(context); + if (!before) return; + var base = before[inside]; + return (this.afterSegments) ? lookup(this.afterSegments, base) : base; +}; +BracketsExpression.prototype.resolve = function(context) { + // Get and split the current value of the expression inside the brackets + var inside = this.inside.get(context); + if (inside == null) return; + + // Concat the before, inside, and optional after segments + var base = this.before.resolve(context); + if (!base) return; + var segments = (this.afterSegments) ? + base.concat(inside, this.afterSegments) : + base.concat(inside); + return this._resolvePatch(context, segments); +}; +BracketsExpression.prototype.dependencies = function(context, options) { + var before = this.before.dependencies(context, options); + if (before) before.pop(); + var inner = this.inside.dependencies(context, options); + var dependencies = concat(before, inner); + return appendDependency(dependencies, this, context); +}; + +// This Expression is used to wrap a template so that when its containing +// Expression--such as an ObjectExpression or ArrayExpression--is evaluated, +// it returns the template unrendered and wrapped in the current context. +// Separating evaluation of the containing expression from template rendering +// is used to support array attributes of views. This way, we can evaluate an +// array and iterate through it separately from rendering template content +function DeferRenderExpression(template, meta) { + if (!(template instanceof Template)) { + throw new Error('DeferRenderExpression requires a Template argument'); + } + this.template = template; + this.meta = meta; +} +DeferRenderExpression.prototype = Object.create(Expression.prototype); +DeferRenderExpression.prototype.constructor = DeferRenderExpression; +DeferRenderExpression.prototype.type = 'DeferRenderExpression'; +DeferRenderExpression.prototype.serialize = function() { + return serializeObject.instance(this, this.template, this.meta); +}; +DeferRenderExpression.prototype.get = function(context) { + return new templates.ContextClosure(this.template, context); +}; + +function ArrayExpression(items, afterSegments, meta) { + this.items = items; + this.afterSegments = afterSegments; + this.meta = meta; +} +ArrayExpression.prototype = Object.create(Expression.prototype); +ArrayExpression.prototype.constructor = ArrayExpression; +ArrayExpression.prototype.type = 'ArrayExpression'; +ArrayExpression.prototype.serialize = function() { + return serializeObject.instance(this, this.items, this.afterSegments, this.meta); +}; +ArrayExpression.prototype.get = function(context) { + var items = new Array(this.items.length); + for (var i = 0; i < this.items.length; i++) { + var value = this.items[i].get(context); + items[i] = value; + } + return (this.afterSegments) ? lookup(this.afterSegments, items) : items; +}; +ArrayExpression.prototype.dependencies = function(context, options) { + if (!this.items) return; + var dependencies; + for (var i = 0; i < this.items.length; i++) { + var itemDependencies = this.items[i].dependencies(context, options); + dependencies = concat(dependencies, itemDependencies); + } + return dependencies; +}; + +function ObjectExpression(properties, afterSegments, meta) { + this.properties = properties; + this.afterSegments = afterSegments; + this.meta = meta; +} +ObjectExpression.prototype = Object.create(Expression.prototype); +ObjectExpression.prototype.constructor = ObjectExpression; +ObjectExpression.prototype.type = 'ObjectExpression'; +ObjectExpression.prototype.serialize = function() { + return serializeObject.instance(this, this.properties, this.afterSegments, this.meta); +}; +ObjectExpression.prototype.get = function(context) { + var object = {}; + for (var key in this.properties) { + var value = this.properties[key].get(context); + object[key] = value; + } + return (this.afterSegments) ? lookup(this.afterSegments, object) : object; +}; +ObjectExpression.prototype.dependencies = function(context, options) { + if (!this.properties) return; + var dependencies; + for (var key in this.properties) { + var propertyDependencies = this.properties[key].dependencies(context, options); + dependencies = concat(dependencies, propertyDependencies); + } + return dependencies; +}; + +function FnExpression(segments, args, afterSegments, meta) { + this.segments = segments; + this.args = args; + this.afterSegments = afterSegments; + this.meta = meta; + var parentSegments = segments && segments.slice(); + this.lastSegment = parentSegments && parentSegments.pop(); + this.parentSegments = (parentSegments && parentSegments.length) ? parentSegments : null; +} +FnExpression.prototype = Object.create(Expression.prototype); +FnExpression.prototype.constructor = FnExpression; +FnExpression.prototype.type = 'FnExpression'; +FnExpression.prototype.serialize = function() { + return serializeObject.instance(this, this.segments, this.args, this.afterSegments, this.meta); +}; +FnExpression.prototype.get = function(context) { + var value = this.apply(context); + // Lookup property underneath computed value if needed + return (this.afterSegments) ? lookup(this.afterSegments, value) : value; +}; +FnExpression.prototype.apply = function(context, extraInputs) { + // See View::dependencies. This is needed in order to handle the case of + // getting dependencies within a component template, in which case we cannot + // access model data separate from rendering. + if (!context.controller) return; + var parent = this._lookupParent(context); + var fn = parent[this.lastSegment]; + var getFn = fn.get || fn; + var out = this._applyFn(getFn, context, extraInputs, parent); + return out; +}; +FnExpression.prototype._lookupParent = function(context) { + // Lookup function on current controller + var controller = context.controller; + var segments = this.parentSegments; + var parent = (segments) ? lookup(segments, controller) : controller; + if (parent && parent[this.lastSegment]) return parent; + // Otherwise lookup function on page + var page = controller.page; + if (controller !== page) { + parent = (segments) ? lookup(segments, page) : page; + if (parent && parent[this.lastSegment]) return parent; + } + // Otherwise lookup function on global + parent = (segments) ? lookup(segments, global) : global; + if (parent && parent[this.lastSegment]) return parent; + // Throw if not found + throw new Error('Function not found for: ' + this.segments.join('.')); +}; +FnExpression.prototype._getInputs = function(context) { + var inputs = []; + for (var i = 0, len = this.args.length; i < len; i++) { + var value = this.args[i].get(context); + inputs.push(renderValue(value, context)); + } + return inputs; +}; +FnExpression.prototype._applyFn = function(fn, context, extraInputs, thisArg) { + // Apply if there are no path inputs + if (!this.args) { + return (extraInputs) ? + fn.apply(thisArg, extraInputs) : + fn.call(thisArg); + } + // Otherwise, get the current value for path inputs and apply + var inputs = this._getInputs(context); + if (extraInputs) { + for (var i = 0, len = extraInputs.length; i < len; i++) { + inputs.push(extraInputs[i]); + } + } + return fn.apply(thisArg, inputs); +}; +FnExpression.prototype.dependencies = function(context, options) { + var dependencies = []; + if (!this.args) return dependencies; + for (var i = 0, len = this.args.length; i < len; i++) { + var argDependencies = this.args[i].dependencies(context, options); + if (!argDependencies || argDependencies.length < 1) continue; + var end = argDependencies.length - 1; + for (var j = 0; j < end; j++) { + dependencies.push(argDependencies[j]); + } + var last = argDependencies[end]; + if (last[last.length - 1] !== '*') { + last = last.concat('*'); + } + dependencies.push(last); + } + return dependencies; +}; +FnExpression.prototype.set = function(context, value) { + var controller = context.controller; + var fn, parent; + while (controller) { + parent = (this.parentSegments) ? + lookup(this.parentSegments, controller) : + controller; + fn = parent && parent[this.lastSegment]; + if (fn) break; + controller = controller.parent; + } + var setFn = fn && fn.set; + if (!setFn) throw new Error('No setter function for: ' + this.segments.join('.')); + var inputs = this._getInputs(context); + inputs.unshift(value); + var out = setFn.apply(parent, inputs); + for (var i in out) { + this.args[i].set(context, out[i]); + } +}; + +function NewExpression(segments, args, afterSegments, meta) { + FnExpression.call(this, segments, args, afterSegments, meta); +} +NewExpression.prototype = Object.create(FnExpression.prototype); +NewExpression.prototype.constructor = NewExpression; +NewExpression.prototype.type = 'NewExpression'; +NewExpression.prototype._applyFn = function(Fn, context) { + // Apply if there are no path inputs + if (!this.args) return new Fn(); + // Otherwise, get the current value for path inputs and apply + var inputs = this._getInputs(context); + inputs.unshift(null); + return new (Fn.bind.apply(Fn, inputs))(); +}; + +function OperatorExpression(name, args, afterSegments, meta) { + this.name = name; + this.args = args; + this.afterSegments = afterSegments; + this.meta = meta; + this.getFn = operatorFns.get[name]; + this.setFn = operatorFns.set[name]; +} +OperatorExpression.prototype = Object.create(FnExpression.prototype); +OperatorExpression.prototype.constructor = OperatorExpression; +OperatorExpression.prototype.type = 'OperatorExpression'; +OperatorExpression.prototype.serialize = function() { + return serializeObject.instance(this, this.name, this.args, this.afterSegments, this.meta); +}; +OperatorExpression.prototype.apply = function(context) { + var inputs = this._getInputs(context); + return this.getFn.apply(null, inputs); +}; +OperatorExpression.prototype.set = function(context, value) { + var inputs = this._getInputs(context); + inputs.unshift(value); + var out = this.setFn.apply(null, inputs); + for (var i in out) { + this.args[i].set(context, out[i]); + } +}; + +function SequenceExpression(args, afterSegments, meta) { + this.args = args; + this.afterSegments = afterSegments; + this.meta = meta; +} +SequenceExpression.prototype = Object.create(OperatorExpression.prototype); +SequenceExpression.prototype.constructor = SequenceExpression; +SequenceExpression.prototype.type = 'SequenceExpression'; +SequenceExpression.prototype.serialize = function() { + return serializeObject.instance(this, this.args, this.afterSegments, this.meta); +}; +SequenceExpression.prototype.name = ','; +SequenceExpression.prototype.getFn = operatorFns.get[',']; +SequenceExpression.prototype.resolve = function(context) { + var last = this.args[this.args.length - 1]; + return last.resolve(context); +}; +SequenceExpression.prototype.dependencies = function(context, options) { + var dependencies = []; + for (var i = 0, len = this.args.length; i < len; i++) { + var argDependencies = this.args[i].dependencies(context, options); + for (var j = 0, jLen = argDependencies.length; j < jLen; j++) { + dependencies.push(argDependencies[j]); + } + } + return dependencies; +}; + +// For each method that takes a context argument, get the nearest parent view +// context, then delegate methods to the inner expression +function ViewParentExpression(expression, meta) { + this.expression = expression; + this.meta = meta; +} +ViewParentExpression.prototype = Object.create(Expression.prototype); +ViewParentExpression.prototype.constructor = ViewParentExpression; +ViewParentExpression.prototype.type = 'ViewParentExpression'; +ViewParentExpression.prototype.serialize = function() { + return serializeObject.instance(this, this.expression, this.meta); +}; +ViewParentExpression.prototype.get = function(context) { + var parentContext = context.forViewParent(); + return this.expression.get(parentContext); +}; +ViewParentExpression.prototype.resolve = function(context) { + var parentContext = context.forViewParent(); + return this.expression.resolve(parentContext); +}; +ViewParentExpression.prototype.dependencies = function(context, options) { + var parentContext = context.forViewParent(); + return this.expression.dependencies(parentContext, options); +}; +ViewParentExpression.prototype.pathSegments = function(context) { + var parentContext = context.forViewParent(); + return this.expression.pathSegments(parentContext); +}; +ViewParentExpression.prototype.set = function(context, value) { + var parentContext = context.forViewParent(); + return this.expression.set(parentContext, value); +}; + +function ScopedModelExpression(expression, meta) { + this.expression = expression; + this.meta = meta; +} +ScopedModelExpression.prototype = Object.create(Expression.prototype); +ScopedModelExpression.prototype.constructor = ScopedModelExpression; +ScopedModelExpression.prototype.type = 'ScopedModelExpression'; +ScopedModelExpression.prototype.serialize = function() { + return serializeObject.instance(this, this.expression, this.meta); +}; +// Return a scoped model instead of the value +ScopedModelExpression.prototype.get = function(context) { + var segments = this.pathSegments(context); + if (!segments) return; + return context.controller.model.scope(segments.join('.')); +}; +// Delegate other methods to the inner expression +ScopedModelExpression.prototype.resolve = function(context) { + return this.expression.resolve(context); +}; +ScopedModelExpression.prototype.dependencies = function(context, options) { + return this.expression.dependencies(context, options); +}; +ScopedModelExpression.prototype.pathSegments = function(context) { + return this.expression.pathSegments(context); +}; +ScopedModelExpression.prototype.set = function(context, value) { + return this.expression.set(context, value); +}; + +function getDependencies(value, context, options) { + if (value instanceof Expression || value instanceof Template) { + return value.dependencies(context, options); + } +} + +function appendDependency(dependencies, expression, context) { + var segments = expression.resolve(context); + if (!segments) return dependencies; + if (dependencies) { + dependencies.push(segments); + return dependencies; + } + return [segments]; +} + +function swapLastDependency(dependencies, expression, context) { + if (!expression.segments.length) { + return dependencies; + } + var segments = expression.resolve(context); + if (!segments) return dependencies; + if (dependencies) { + dependencies.pop(); + dependencies.push(segments); + return dependencies; + } + return [segments]; +} diff --git a/lib/templates/operatorFns.js b/lib/templates/operatorFns.js new file mode 100644 index 000000000..c446e9b72 --- /dev/null +++ b/lib/templates/operatorFns.js @@ -0,0 +1,132 @@ +// `-` and `+` can be either unary or binary, so all unary operators are +// postfixed with `U` to differentiate + +exports.get = { + // Unary operators + '!U': function(value) { + return !value; + } +, '-U': function(value) { + return -value; + } +, '+U': function(value) { + return +value; + } +, '~U': function(value) { + return ~value; + } +, 'typeofU': function(value) { + return typeof value; + } + // Binary operators +, '||': function(left, right) { + return left || right; + } +, '&&': function(left, right) { + return left && right; + } +, '|': function(left, right) { + return left | right; + } +, '^': function(left, right) { + return left ^ right; + } +, '&': function(left, right) { + return left & right; + } +, '==': function(left, right) { + return left == right; // jshint ignore:line + } +, '!=': function(left, right) { + return left != right; // jshint ignore:line + } +, '===': function(left, right) { + return left === right; + } +, '!==': function(left, right) { + return left !== right; + } +, '<': function(left, right) { + return left < right; + } +, '>': function(left, right) { + return left > right; + } +, '<=': function(left, right) { + return left <= right; + } +, '>=': function(left, right) { + return left >= right; + } +, 'instanceof': function(left, right) { + return left instanceof right; + } +, 'in': function(left, right) { + return left in right; + } +, '<<': function(left, right) { + return left << right; + } +, '>>': function(left, right) { + return left >> right; + } +, '>>>': function(left, right) { + return left >>> right; + } +, '+': function(left, right) { + return left + right; + } +, '-': function(left, right) { + return left - right; + } +, '*': function(left, right) { + return left * right; + } +, '/': function(left, right) { + return left / right; + } +, '%': function(left, right) { + return left % right; + } + // Conditional operator +, '?': function(test, consequent, alternate) { + return (test) ? consequent : alternate; + } +, // Sequence + ',': function() { + return arguments[arguments.length - 1]; + } +}; + +exports.set = { + // Unary operators + '!U': function(value) { + return [!value]; + } +, '-U': function(value) { + return [-value]; + } + // Binary operators +, '==': function(value, left, right) { + if (value) return [right]; + } +, '===': function(value, left, right) { + if (value) return [right]; + } +, 'in': function(value, left, right) { + right[left] = true; + return {1: right}; + } +, '+': function(value, left, right) { + return [value - right]; + } +, '-': function(value, left, right) { + return [value + right]; + } +, '*': function(value, left, right) { + return [value / right]; + } +, '/': function(value, left, right) { + return [value * right]; + } +}; diff --git a/lib/templates/templates.js b/lib/templates/templates.js index 703698803..bc7793837 100644 --- a/lib/templates/templates.js +++ b/lib/templates/templates.js @@ -1,6 +1,11 @@ if (typeof require === 'function') { var serializeObject = require('serialize-object'); } +var DependencyOptions = require('./dependencyOptions').DependencyOptions; +var util = require('./util'); +var concat = util.concat; +var hasKeys = util.hasKeys; +var traverseAndCreate = util.traverseAndCreate; // UPDATE_PROPERTIES map HTML attribute names to an Element DOM property that // should be used for setting on bindings updates instead of setAttribute. @@ -1349,3 +1354,754 @@ function normalizeLineBreaks(string) { }; } })(); + +exports.Marker = Marker; +exports.View = View; +exports.ViewInstance = ViewInstance; +exports.DynamicViewInstance = DynamicViewInstance; +exports.ViewParent = ViewParent; +exports.ContextClosure = ContextClosure; + +exports.Views = Views; + +exports.MarkupHook = MarkupHook; +exports.ElementOn = ElementOn; +exports.ComponentOn = ComponentOn; +exports.AsProperty = AsProperty; +exports.AsPropertyComponent = AsPropertyComponent; +exports.AsObject = AsObject; +exports.AsObjectComponent = AsObjectComponent; +exports.AsArray = AsArray; +exports.AsArrayComponent = AsArrayComponent; + +exports.emptyTemplate = new Template([]); + +exports.elementAddDestroyListener = elementAddDestroyListener; +exports.elementRemoveDestroyListener = elementRemoveDestroyListener; + +// Add ::isUnbound to Template && Binding +Template.prototype.isUnbound = function(context) { + return context.unbound; +}; +Binding.prototype.isUnbound = function() { + return this.template.expression.isUnbound(this.context); +}; + +// Add Template::resolve +Template.prototype.resolve = function() {}; + +// The Template::dependencies method is specific to how Derby bindings work, +// so extend all of the Saddle Template types here +Template.prototype.dependencies = function(context, options) { + if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; + return concatArrayDependencies(null, this.content, context, options); +}; +Doctype.prototype.dependencies = function() {}; +Text.prototype.dependencies = function() {}; +DynamicText.prototype.dependencies = function(context, options) { + if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; + return getDependencies(this.expression, context, options); +}; +Comment.prototype.dependencies = function() {}; +DynamicComment.prototype.dependencies = function(context, options) { + if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; + return getDependencies(this.expression, context, options); +}; +Html.prototype.dependencies = function() {}; +DynamicHtml.prototype.dependencies = function(context, options) { + if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; + return getDependencies(this.expression, context, options); +}; +Element.prototype.dependencies = function(context, options) { + if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; + var dependencies = concatMapDependencies(null, this.attributes, context, options); + if (!this.content) return dependencies; + return concatArrayDependencies(dependencies, this.content, context, options); +}; +DynamicElement.prototype.dependencies = function(context, options) { + if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; + var dependencies = Element.prototype.dependencies(context, options); + return concatDependencies(dependencies, this.tagName, context, options); +}; +Block.prototype.dependencies = function(context, options) { + if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; + var dependencies = (this.expression.meta && this.expression.meta.blockType === 'on') ? + getDependencies(this.expression, context, options) : null; + var blockContext = context.child(this.expression); + return concatArrayDependencies(dependencies, this.content, blockContext, options); +}; +ConditionalBlock.prototype.dependencies = function(context, options) { + if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; + var condition = this.getCondition(context); + if (condition == null) { + return getDependencies(this.expressions[0], context, options); + } + var dependencies = concatSubArrayDependencies(null, this.expressions, context, options, condition); + var expression = this.expressions[condition]; + var content = this.contents[condition]; + var blockContext = context.child(expression); + return concatArrayDependencies(dependencies, content, blockContext, options); +}; +EachBlock.prototype.dependencies = function(context, options) { + if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; + var dependencies = getDependencies(this.expression, context, options); + var items = this.expression.get(context); + if (items && items.length) { + for (var i = 0; i < items.length; i++) { + var itemContext = context.eachChild(this.expression, i); + dependencies = concatArrayDependencies(dependencies, this.content, itemContext, options); + } + } else if (this.elseContent) { + dependencies = concatArrayDependencies(dependencies, this.elseContent, context, options); + } + return dependencies; +}; +Attribute.prototype.dependencies = function() {}; +DynamicAttribute.prototype.dependencies = function(context, options) { + if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; + return getDependencies(this.expression, context, options); +}; + +function concatSubArrayDependencies(dependencies, expressions, context, options, end) { + for (var i = 0; i <= end; i++) { + dependencies = concatDependencies(dependencies, expressions[i], context, options); + } + return dependencies; +} +function concatArrayDependencies(dependencies, expressions, context, options) { + for (var i = 0; i < expressions.length; i++) { + dependencies = concatDependencies(dependencies, expressions[i], context, options); + } + return dependencies; +} +function concatMapDependencies(dependencies, expressions, context, options) { + for (var key in expressions) { + dependencies = concatDependencies(dependencies, expressions[key], context, options); + } + return dependencies; +} +function concatDependencies(dependencies, expression, context, options) { + var expressionDependencies = getDependencies(expression, context, options); + return concat(dependencies, expressionDependencies); +} +function getDependencies(expression, context, options) { + return expression.dependencies(context, options); +} + +var markerHooks = [{ + emit: function(context, node) { + node.$component = context.controller; + context.controller.markerNode = node; + } +}]; +function Marker(data) { + Comment.call(this, data, markerHooks); +} +Marker.prototype = Object.create(Comment.prototype); +Marker.prototype.constructor = Marker; +Marker.prototype.type = 'Marker'; +Marker.prototype.serialize = function() { + return serializeObject.instance(this, this.data); +}; +Marker.prototype.get = function() { + return ''; +}; + +function ViewAttributesMap(source) { + var items = source.split(/\s+/); + for (var i = 0, len = items.length; i < len; i++) { + this[items[i]] = true; + } +} +function ViewArraysMap(source) { + var items = source.split(/\s+/); + for (var i = 0, len = items.length; i < len; i++) { + var item = items[i].split('/'); + this[item[0]] = item[1] || item[0]; + } +} +function View(views, name, source, options) { + this.views = views; + this.name = name; + this.source = source; + this.options = options; + + var nameSegments = (this.name || '').split(':'); + var lastSegment = nameSegments.pop(); + this.namespace = nameSegments.join(':'); + this.registeredName = (lastSegment === 'index') ? this.namespace : this.name; + + this.attributesMap = options && options.attributes && + new ViewAttributesMap(options.attributes); + this.arraysMap = options && options.arrays && + new ViewArraysMap(options.arrays); + // The empty string is considered true for easier HTML attribute parsing + this.unminified = options && (options.unminified || options.unminified === ''); + this.string = options && (options.string || options.string === ''); + this.literal = options && (options.literal || options.literal === ''); + this.template = null; + this.componentFactory = null; + this.fromSerialized = false; +} +View.prototype = Object.create(Template.prototype); +View.prototype.constructor = View; +View.prototype.type = 'View'; +View.prototype.serialize = function() { + return null; +}; +View.prototype._isComponent = function(context) { + if (!this.componentFactory) return false; + if (context.attributes && context.attributes.extend) return false; + return true; +}; +View.prototype._initComponent = function(context) { + return (this._isComponent(context)) ? + this.componentFactory.init(context) : context; +}; +View.prototype._queueCreate = function(context, viewContext) { + if (this._isComponent(context)) { + var componentFactory = this.componentFactory; + context.queue(function queuedCreate() { + componentFactory.create(viewContext); + }); + + if (!context.hooks) return; + context.queue(function queuedComponentHooks() { + // Kick off hooks if view instance specified `on` or `as` attributes + for (var i = 0, len = context.hooks.length; i < len; i++) { + context.hooks[i].emit(context, viewContext.controller); + } + }); + } +}; +View.prototype.get = function(context, unescaped) { + var viewContext = this._initComponent(context); + var template = this.template || this.parse(); + return template.get(viewContext, unescaped); +}; +View.prototype.getFragment = function(context, binding) { + var viewContext = this._initComponent(context); + var template = this.template || this.parse(); + var fragment = template.getFragment(viewContext, binding); + this._queueCreate(context, viewContext); + return fragment; +}; +View.prototype.appendTo = function(parent, context) { + var viewContext = this._initComponent(context); + var template = this.template || this.parse(); + template.appendTo(parent, viewContext); + this._queueCreate(context, viewContext); +}; +View.prototype.attachTo = function(parent, node, context) { + var viewContext = this._initComponent(context); + var template = this.template || this.parse(); + var node = template.attachTo(parent, node, viewContext); + this._queueCreate(context, viewContext); + return node; +}; +View.prototype.dependencies = function(context, options) { + if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; + var template = this.template || this.parse(); + // We can't figure out relative path dependencies within a component without + // rendering it, because each component instance's scope is dynamically set + // based on its unique `id` property. To represent this, set the context + // controller to `null`. + // + // Under normal rendering conditions, contexts should always have reference + // to a controller. Expression::get() methods use the reference to + // `context.controller.model.data` to lookup values, and paths are resolved + // based on `context.controller.model._scope`. + // + // To handle this, Expression methods guard against a null controller by not + // returning any dependencies for model paths. In addition, they return + // `undefined` from get, which affect dependencies computed for + // ConditionalBlock and EachBlock, as their dependencies will differ based + // on the value of model data. + // + // TODO: This likely under-estimates the true dependencies within a + // template. However, to provide a more complete view of dependencies, we'd + // need information we only have at render time, namely, the scope and data + // within the component model. This may indicate that Derby should use a + // more Functional Reactive Programming (FRP)-like approach of having + // dependencies be returned from getFragment and attach methods along with + // DOM nodes rather than computing dependencies separately from rendering. + var viewContext = (this._isComponent(context)) ? + context.componentChild(null) : context; + return template.dependencies(viewContext, options); +}; +View.prototype.parse = function() { + this._parse(); + if (this.componentFactory && !this.componentFactory.constructor.prototype.singleton) { + var marker = new Marker(this.name); + this.template.content.unshift(marker); + } + return this.template; +}; +// View.prototype._parse is defined in parsing.js, so that it doesn't have to +// be included in the client if templates are all parsed server-side +View.prototype._parse = function() { + throw new Error('View parsing not available'); +}; + +function ViewInstance(name, attributes, hooks, initHooks) { + this.name = name; + this.attributes = attributes; + this.hooks = hooks; + this.initHooks = initHooks; + this.view = null; +} +ViewInstance.prototype = Object.create(Template.prototype); +ViewInstance.prototype.constructor = ViewInstance; +ViewInstance.prototype.type = 'ViewInstance'; +ViewInstance.prototype.serialize = function() { + return serializeObject.instance(this, this.name, this.attributes, this.hooks, this.initHooks); +}; +ViewInstance.prototype.get = function(context, unescaped) { + var view = this._find(context); + var viewContext = context.viewChild(view, this.attributes, this.hooks, this.initHooks); + return view.get(viewContext, unescaped); +}; +ViewInstance.prototype.getFragment = function(context, binding) { + var view = this._find(context); + var viewContext = context.viewChild(view, this.attributes, this.hooks, this.initHooks); + return view.getFragment(viewContext, binding); +}; +ViewInstance.prototype.appendTo = function(parent, context) { + var view = this._find(context); + var viewContext = context.viewChild(view, this.attributes, this.hooks, this.initHooks); + view.appendTo(parent, viewContext); +}; +ViewInstance.prototype.attachTo = function(parent, node, context) { + var view = this._find(context); + var viewContext = context.viewChild(view, this.attributes, this.hooks, this.initHooks); + return view.attachTo(parent, node, viewContext); +}; +ViewInstance.prototype.dependencies = function(context, options) { + if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; + var view = this._find(context); + var viewContext = context.viewChild(view, this.attributes, this.hooks, this.initHooks); + return view.dependencies(viewContext, options); +}; +ViewInstance.prototype._find = function(context) { + if (this.view) return this.view; + var contextView = context.getView(); + var namespace = contextView && contextView.namespace; + this.view = context.meta.views.find(this.name, namespace); + if (!this.view) { + var message = context.meta.views.findErrorMessage(this.name, contextView); + throw new Error(message); + } + return this.view; +}; + +function DynamicViewInstance(nameExpression, attributes, hooks, initHooks) { + this.nameExpression = nameExpression; + this.attributes = attributes; + this.hooks = hooks; + this.initHooks = initHooks; +} +DynamicViewInstance.prototype = Object.create(ViewInstance.prototype); +DynamicViewInstance.prototype.constructor = DynamicViewInstance; +DynamicViewInstance.prototype.type = 'DynamicViewInstance'; +DynamicViewInstance.prototype.serialize = function() { + return serializeObject.instance(this, this.nameExpression, this.attributes, this.hooks, this.initHooks); +}; +DynamicViewInstance.prototype._find = function(context) { + var name = this.nameExpression.get(context); + var contextView = context.getView(); + var namespace = contextView && contextView.namespace; + var view = name && context.meta.views.find(name, namespace); + return view || exports.emptyTemplate; +}; +DynamicViewInstance.prototype.dependencies = function(context, options) { + if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; + var nameDependencies = this.nameExpression.dependencies(context); + var viewDependencies = ViewInstance.prototype.dependencies.call(this, context, options); + return concat(nameDependencies, viewDependencies); +}; + +// Without a ContextClosure, ViewParent will return the nearest context that +// is the parent of a view instance. When a context with a `closure` property +// is encountered first, ViewParent will find the specific referenced context, +// even if it is further up the context hierarchy. +function ViewParent(template) { + this.template = template; +} +ViewParent.prototype = Object.create(Template.prototype); +ViewParent.prototype.constructor = ViewParent; +ViewParent.prototype.type = 'ViewParent'; +ViewParent.prototype.serialize = function() { + return serializeObject.instance(this, this.template); +}; +ViewParent.prototype.get = function(context, unescaped) { + var parentContext = context.forViewParent(); + return this.template.get(parentContext, unescaped); +}; +ViewParent.prototype.getFragment = function(context, binding) { + var parentContext = context.forViewParent(); + return this.template.getFragment(parentContext, binding); +}; +ViewParent.prototype.appendTo = function(parent, context) { + var parentContext = context.forViewParent(); + this.template.appendTo(parent, parentContext); +}; +ViewParent.prototype.attachTo = function(parent, node, context) { + var parentContext = context.forViewParent(); + return this.template.attachTo(parent, node, parentContext); +}; +ViewParent.prototype.dependencies = function(context, options) { + if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; + var parentContext = context.forViewParent(); + return this.template.dependencies(parentContext, options); +}; + +// At render time, this template creates a context child and sets its +// `closure` property to a fixed reference. It is used in combination with +// ViewParent in order to control which context is returned. +// +// Instances of this template cannot be serialized. It is intended for use +// dynamically during rendering only. +function ContextClosure(template, context) { + this.template = template; + this.context = context; +} +ContextClosure.prototype = Object.create(Template.prototype); +ContextClosure.prototype.constructor = ContextClosure; +ContextClosure.prototype.serialize = function() { + throw new Error('ContextClosure cannot be serialized'); +}; +ContextClosure.prototype.get = function(context, unescaped) { + var closureContext = context.closureChild(this.context); + return this.template.get(closureContext, unescaped); +}; +ContextClosure.prototype.getFragment = function(context, binding) { + var closureContext = context.closureChild(this.context); + return this.template.getFragment(closureContext, binding); +}; +ContextClosure.prototype.appendTo = function(parent, context) { + var closureContext = context.closureChild(this.context); + this.template.appendTo(parent, closureContext); +}; +ContextClosure.prototype.attachTo = function(parent, node, context) { + var closureContext = context.closureChild(this.context); + return this.template.attachTo(parent, node, closureContext); +}; +ContextClosure.prototype.dependencies = function(context, options) { + if (DependencyOptions.shouldIgnoreTemplate(this.template, options)) return; + var closureContext = context.closureChild(this.context); + return this.template.dependencies(closureContext, options); +}; +ContextClosure.prototype.equals = function(other) { + return (other instanceof ContextClosure) && + (this.context === other.context) && + (this.template.equals(other.template)); +}; + +function ViewsMap() {} +function Views() { + this.nameMap = new ViewsMap(); + this.tagMap = new ViewsMap(); + // TODO: elementMap is deprecated and should be removed with Derby 0.6.0 + this.elementMap = this.tagMap; +} +Views.prototype.find = function(name, namespace) { + var map = this.nameMap; + + // Exact match lookup + var exactName = (namespace) ? namespace + ':' + name : name; + var match = map[exactName]; + if (match) return match; + + // Relative lookup + var segments = name.split(':'); + var segmentsDepth = segments.length; + if (namespace) segments = namespace.split(':').concat(segments); + // Iterate through segments, leaving the `segmentsDepth` segments and + // removing the second to `segmentsDepth` segment to traverse up the + // namespaces. Decrease `segmentsDepth` if not found and repeat again. + while (segmentsDepth > 0) { + var testSegments = segments.slice(); + while (testSegments.length > segmentsDepth) { + testSegments.splice(-1 - segmentsDepth, 1); + var testName = testSegments.join(':'); + var match = map[testName]; + if (match) return match; + } + segmentsDepth--; + } +}; +Views.prototype.register = function(name, source, options) { + var mapName = name.replace(/:index$/, ''); + var view = this.nameMap[mapName]; + if (view) { + // Recreate the view if it already exists. We re-apply the constructor + // instead of creating a new view object so that references to object + // can be cached after finding the first time + var componentFactory = view.componentFactory; + View.call(view, this, name, source, options); + view.componentFactory = componentFactory; + } else { + view = new View(this, name, source, options); + } + this.nameMap[mapName] = view; + // TODO: element is deprecated and should be removed with Derby 0.6.0 + var tagName = options && (options.tag || options.element); + if (tagName) this.tagMap[tagName] = view; + return view; +}; +Views.prototype.deserialize = function(items) { + for (var i = 0; i < items.length; i++) { + var item = items[i]; + var setTemplate = item[0]; + var name = item[1]; + var source = item[2]; + var options = item[3]; + var view = this.register(name, source, options); + view.parse = setTemplate; + view.fromSerialized = true; + } +}; +Views.prototype.serialize = function(options) { + var forServer = options && options.server; + var minify = options && options.minify; + var items = []; + for (var name in this.nameMap) { + var view = this.nameMap[name]; + var template = view.template || view.parse(); + if (!forServer && view.options) { + // Do not serialize views with the `serverOnly` option, except when + // serializing for a server script + if (view.options.serverOnly) continue; + // For views with the `server` option, serialize them with a blank + // template body. This allows them to be used from other views on the + // browser, but they will output nothing on the browser + if (view.options.server) template = exports.emptyTemplate; + } + // Serializing views as a function allows them to be constructed lazily upon + // first use. This can improve initial load times of the application when + // there are many views + items.push( + '[function(){return this.template=' + + template.serialize() + '},' + + serializeObject.args([ + view.name, + (minify) ? null : view.source, + (hasKeys(view.options)) ? view.options : null + ]) + + ']' + ); + } + return 'function(derbyTemplates, views){' + + 'var expressions = derbyTemplates.expressions,' + + 'templates = derbyTemplates.templates;' + + 'views.deserialize([' + items.join(',') + '])}'; +}; +Views.prototype.findErrorMessage = function(name, contextView) { + var names = Object.keys(this.nameMap); + var message = 'Cannot find view "' + name + '" in' + + [''].concat(names).join('\n ') + '\n'; + if (contextView) { + message += '\nWithin template "' + contextView.name + '":\n' + contextView.source; + } + return message; +}; + + +function MarkupHook() {} +MarkupHook.prototype.module = Template.prototype.module; + +function ElementOn(name, expression) { + this.name = name; + this.expression = expression; +} +ElementOn.prototype = Object.create(MarkupHook.prototype); +ElementOn.prototype.constructor = ElementOn; +ElementOn.prototype.type = 'ElementOn'; +ElementOn.prototype.serialize = function() { + return serializeObject.instance(this, this.name, this.expression); +}; +ElementOn.prototype.emit = function(context, element) { + if (this.name === 'create') { + this.apply(context, element); + return; + } + var elementOn = this; + var listener = function elementOnListener(event) { + return elementOn.apply(context, element, event); + }; + // Using `context.controller.dom.on` would be better for garbage collection, + // but since it synchronously removes listeners on component destroy, it would + // break existing code relying on `on-*` listeners firing as a component is + // being destroyed. Even with `addEventListener`, browsers should still GC + // the listeners once there are no references to the element. + element.addEventListener(this.name, listener, false); + // context.controller.dom.on(this.name, element, listener, false); +}; +ElementOn.prototype.apply = function(context, element, event) { + var modelData = context.controller.model.data; + modelData.$event = event; + modelData.$element = element; + var out = this.expression.apply(context); + delete modelData.$event; + delete modelData.$element; + return out; +}; + +function ComponentOn(name, expression) { + this.name = name; + this.expression = expression; +} +ComponentOn.prototype = Object.create(MarkupHook.prototype); +ComponentOn.prototype.constructor = ComponentOn; +ComponentOn.prototype.type = 'ComponentOn'; +ComponentOn.prototype.serialize = function() { + return serializeObject.instance(this, this.name, this.expression); +}; +ComponentOn.prototype.emit = function(context, component) { + var expression = this.expression; + component.on(this.name, function componentOnListener() { + var args = arguments.length && Array.prototype.slice.call(arguments); + return expression.apply(context, args); + }); +}; + +function AsProperty(segments) { + this.segments = segments; + this.lastSegment = segments.pop(); +} +AsProperty.prototype = Object.create(MarkupHook.prototype); +AsProperty.prototype.constructor = AsProperty; +AsProperty.prototype.type = 'AsProperty'; +AsProperty.prototype.serialize = function() { + var segments = this.segments.concat(this.lastSegment); + return serializeObject.instance(this, segments); +}; +AsProperty.prototype.emit = function(context, target) { + var node = traverseAndCreate(context.controller, this.segments); + node[this.lastSegment] = target; + this.addListeners(target, node, this.lastSegment); +}; +AsProperty.prototype.addListeners = function(target, object, key) { + this.addDestroyListener(target, function asPropertyDestroy() { + // memoize initial reference so we dont destroy + // property that has been replaced with a different reference + var intialRef = object[key]; + process.nextTick(function deleteProperty() { + if (intialRef !== object[key]) { + return; + } + delete object[key]; + }); + }); +}; +AsProperty.prototype.addDestroyListener = elementAddDestroyListener; + +function AsPropertyComponent(segments) { + AsProperty.call(this, segments); +} +AsPropertyComponent.prototype = Object.create(AsProperty.prototype); +AsPropertyComponent.prototype.constructor = AsPropertyComponent; +AsPropertyComponent.prototype.type = 'AsPropertyComponent'; +AsPropertyComponent.prototype.addDestroyListener = componentAddDestroyListener; + +function AsObject(segments, keyExpression) { + AsProperty.call(this, segments); + this.keyExpression = keyExpression; +} +AsObject.prototype = Object.create(AsProperty.prototype); +AsObject.prototype.constructor = AsObject; +AsObject.prototype.type = 'AsObject'; +AsObject.prototype.serialize = function() { + var segments = this.segments.concat(this.lastSegment); + return serializeObject.instance(this, segments, this.keyExpression); +}; +AsObject.prototype.emit = function(context, target) { + var node = traverseAndCreate(context.controller, this.segments); + var object = node[this.lastSegment] || (node[this.lastSegment] = {}); + var key = this.keyExpression.get(context); + object[key] = target; + this.addListeners(target, object, key); +}; + +function AsObjectComponent(segments, keyExpression) { + AsObject.call(this, segments, keyExpression); +} +AsObjectComponent.prototype = Object.create(AsObject.prototype); +AsObjectComponent.prototype.constructor = AsObjectComponent; +AsObjectComponent.prototype.type = 'AsObjectComponent'; +AsObjectComponent.prototype.addDestroyListener = componentAddDestroyListener; + +function AsArray(segments) { + AsProperty.call(this, segments); +} +AsArray.prototype = Object.create(AsProperty.prototype); +AsArray.prototype.constructor = AsArray; +AsArray.prototype.type = 'AsArray'; +AsArray.prototype.emit = function(context, target) { + var node = traverseAndCreate(context.controller, this.segments); + var array = node[this.lastSegment] || (node[this.lastSegment] = []); + + // Iterate backwards, since rendering will usually append + for (var i = array.length; i--;) { + var item = array[i]; + // Don't add an item if already in the array + if (item === target) return; + var mask = this.comparePosition(target, item); + // If the emitted target is after the current item in the document, + // insert it next in the array + // Node.DOCUMENT_POSITION_FOLLOWING = 4 + if (mask & 4) { + array.splice(i + 1, 0, target); + this.addListeners(target, array); + return; + } + } + // Add to the beginning if before all items + array.unshift(target); + this.addListeners(target, array); +}; +AsArray.prototype.addListeners = function(target, array) { + this.addDestroyListener(target, function asArrayDestroy() { + removeArrayItem(array, target); + }); +}; +AsArray.prototype.comparePosition = function(target, item) { + return item.compareDocumentPosition(target); +}; + +function AsArrayComponent(segments) { + AsArray.call(this, segments); +} +AsArrayComponent.prototype = Object.create(AsArray.prototype); +AsArrayComponent.prototype.constructor = AsArrayComponent; +AsArrayComponent.prototype.type = 'AsArrayComponent'; +AsArrayComponent.prototype.comparePosition = function(target, item) { + return item.markerNode.compareDocumentPosition(target.markerNode); +}; +AsArrayComponent.prototype.addDestroyListener = componentAddDestroyListener; + +function elementAddDestroyListener(element, listener) { + var destroyListeners = element.$destroyListeners; + if (destroyListeners) { + if (destroyListeners.indexOf(listener) === -1) { + destroyListeners.push(listener); + } + } else { + element.$destroyListeners = [listener]; + } +} +function elementRemoveDestroyListener(element, listener) { + var destroyListeners = element.$destroyListeners; + if (destroyListeners) { + removeArrayItem(destroyListeners, listener); + } +} +function componentAddDestroyListener(target, listener) { + target.on('destroy', listener); +} +function removeArrayItem(array, item) { + var index = array.indexOf(item); + if (index > -1) { + array.splice(index, 1); + } +} diff --git a/lib/templates/util.js b/lib/templates/util.js new file mode 100644 index 000000000..fff5ec357 --- /dev/null +++ b/lib/templates/util.js @@ -0,0 +1,23 @@ +exports.concat = function(a, b) { + if (!a) return b; + if (!b) return a; + return a.concat(b); +}; + +exports.hasKeys = function(value) { + if (!value) return false; + for (var key in value) { + return true; + } + return false; +}; + +exports.traverseAndCreate = function(node, segments) { + var len = segments.length; + if (!len) return node; + for (var i = 0; i < len; i++) { + var segment = segments[i]; + node = node[segment] || (node[segment] = {}); + } + return node; +}; diff --git a/test/all/templates/templates.mocha.js b/test/all/templates/templates.mocha.js new file mode 100644 index 000000000..8551a80e3 --- /dev/null +++ b/test/all/templates/templates.mocha.js @@ -0,0 +1,41 @@ +var expect = require('expect.js'); +var templates = require('../lib/templates'); + +describe('Views', function() { + + it('registers and finds a view', function() { + var views = new templates.Views(); + views.register('greeting', 'Hi'); + var view = views.find('greeting'); + expect(view.source).equal('Hi'); + }); + + it('registers and finds a nested view', function() { + var views = new templates.Views(); + views.register('greetings:informal', 'Hi'); + var view = views.find('greetings:informal'); + expect(view.source).equal('Hi'); + }); + + it('finds a view relatively', function() { + var views = new templates.Views(); + views.register('greetings:informal', 'Hi'); + var view = views.find('informal', 'greetings'); + expect(view.source).equal('Hi'); + }); + + it('does not find a view in a child namespace', function() { + var views = new templates.Views(); + views.register('greetings:informal', 'Hi'); + var view = views.find('informal'); + expect(view).equal(undefined); + }); + + it('registers and finds an index view', function() { + var views = new templates.Views(); + views.register('greetings:informal:index', 'Hi'); + var view = views.find('greetings:informal'); + expect(view.source).equal('Hi'); + }); + +}); From 289295f8dc4f7b72a8f9cf9df1a1567dba943cce Mon Sep 17 00:00:00 2001 From: Eric Hwang Date: Wed, 14 Jun 2023 14:34:15 -0700 Subject: [PATCH 04/11] Fix lint issues in code moved from derby-templates --- lib/templates/contexts.js | 2 +- lib/templates/expressions.js | 22 ++--- lib/templates/operatorFns.js | 156 ++++++++++++++++++----------------- 3 files changed, 92 insertions(+), 88 deletions(-) diff --git a/lib/templates/contexts.js b/lib/templates/contexts.js index eb5f35173..5368ecbe1 100644 --- a/lib/templates/contexts.js +++ b/lib/templates/contexts.js @@ -104,7 +104,7 @@ Context.prototype.child = function(expression) { var blockType = expression.meta && expression.meta.blockType; var unbound = (blockType === 'unbound') ? true : (blockType === 'bound') ? false : - this.unbound; + this.unbound; return new Context(this.meta, this.controller, this, unbound, expression); }; diff --git a/lib/templates/expressions.js b/lib/templates/expressions.js index c22441ab0..844929638 100644 --- a/lib/templates/expressions.js +++ b/lib/templates/expressions.js @@ -56,8 +56,8 @@ function pathSegments(segments) { function renderValue(value, context) { return (typeof value !== 'object') ? value : (value instanceof Template) ? renderTemplate(value, context) : - (Array.isArray(value)) ? renderArray(value, context) : - renderObject(value, context); + (Array.isArray(value)) ? renderArray(value, context) : + renderObject(value, context); } function renderTemplate(value, context) { var i = 1000; @@ -116,15 +116,15 @@ ExpressionMeta.prototype.module = 'expressions'; ExpressionMeta.prototype.type = 'ExpressionMeta'; ExpressionMeta.prototype.serialize = function() { return serializeObject.instance( - this - , this.source - , this.blockType - , this.isEnd - , this.as - , this.keyAs - , this.unescaped - , this.bindType - , this.valueType + this, + this.source, + this.blockType, + this.isEnd, + this.as, + this.keyAs, + this.unescaped, + this.bindType, + this.valueType ); }; diff --git a/lib/templates/operatorFns.js b/lib/templates/operatorFns.js index c446e9b72..2bba53b1a 100644 --- a/lib/templates/operatorFns.js +++ b/lib/templates/operatorFns.js @@ -5,94 +5,98 @@ exports.get = { // Unary operators '!U': function(value) { return !value; - } -, '-U': function(value) { + }, + '-U': function(value) { return -value; - } -, '+U': function(value) { + }, + '+U': function(value) { return +value; - } -, '~U': function(value) { + }, + '~U': function(value) { return ~value; - } -, 'typeofU': function(value) { + }, + 'typeofU': function(value) { return typeof value; - } + }, // Binary operators -, '||': function(left, right) { + '||': function(left, right) { return left || right; - } -, '&&': function(left, right) { + }, + '&&': function(left, right) { return left && right; - } -, '|': function(left, right) { + }, + '|': function(left, right) { return left | right; - } -, '^': function(left, right) { + }, + '^': function(left, right) { return left ^ right; - } -, '&': function(left, right) { + }, + '&': function(left, right) { return left & right; - } -, '==': function(left, right) { - return left == right; // jshint ignore:line - } -, '!=': function(left, right) { - return left != right; // jshint ignore:line - } -, '===': function(left, right) { + }, + '==': function(left, right) { + // Template `==` intentionally uses same behavior as JS + // eslint-disable-next-line eqeqeq + return left == right; + }, + '!=': function(left, right) { + // Template `!=` intentionally uses same behavior as JS + // eslint-disable-next-line eqeqeq + return left != right; + }, + '===': function(left, right) { return left === right; - } -, '!==': function(left, right) { + }, + '!==': function(left, right) { return left !== right; - } -, '<': function(left, right) { + }, + '<': function(left, right) { return left < right; - } -, '>': function(left, right) { + }, + '>': function(left, right) { return left > right; - } -, '<=': function(left, right) { + }, + '<=': function(left, right) { return left <= right; - } -, '>=': function(left, right) { + }, + '>=': function(left, right) { return left >= right; - } -, 'instanceof': function(left, right) { + }, + 'instanceof': function(left, right) { return left instanceof right; - } -, 'in': function(left, right) { + }, + 'in': function(left, right) { return left in right; - } -, '<<': function(left, right) { + }, + '<<': function(left, right) { return left << right; - } -, '>>': function(left, right) { + }, + '>>': function(left, right) { return left >> right; - } -, '>>>': function(left, right) { + }, + '>>>': function(left, right) { return left >>> right; - } -, '+': function(left, right) { + }, + '+': function(left, right) { return left + right; - } -, '-': function(left, right) { + }, + '-': function(left, right) { return left - right; - } -, '*': function(left, right) { + }, + '*': function(left, right) { return left * right; - } -, '/': function(left, right) { + }, + '/': function(left, right) { return left / right; - } -, '%': function(left, right) { + }, + '%': function(left, right) { return left % right; - } + }, // Conditional operator -, '?': function(test, consequent, alternate) { + '?': function(test, consequent, alternate) { return (test) ? consequent : alternate; - } -, // Sequence + }, + // Sequence ',': function() { return arguments[arguments.length - 1]; } @@ -102,31 +106,31 @@ exports.set = { // Unary operators '!U': function(value) { return [!value]; - } -, '-U': function(value) { + }, + '-U': function(value) { return [-value]; - } + }, // Binary operators -, '==': function(value, left, right) { + '==': function(value, left, right) { if (value) return [right]; - } -, '===': function(value, left, right) { + }, + '===': function(value, left, right) { if (value) return [right]; - } -, 'in': function(value, left, right) { + }, + 'in': function(value, left, right) { right[left] = true; return {1: right}; - } -, '+': function(value, left, right) { + }, + '+': function(value, left, right) { return [value - right]; - } -, '-': function(value, left, right) { + }, + '-': function(value, left, right) { return [value + right]; - } -, '*': function(value, left, right) { + }, + '*': function(value, left, right) { return [value / right]; - } -, '/': function(value, left, right) { + }, + '/': function(value, left, right) { return [value * right]; } }; From 919fbacc92f9d94895a37a7e022986ab0568e6df Mon Sep 17 00:00:00 2001 From: Eric Hwang Date: Wed, 14 Jun 2023 17:04:39 -0700 Subject: [PATCH 05/11] Fix tests that were moved from saddle and derby-templates, including using jsdom for DOM-based tests --- test/all/templates/templates.mocha.js | 4 +- test/dom/templates/templates.dom.mocha.js | 1658 +++++++++-------- .../templates/templates.server.mocha.js | 5 +- 3 files changed, 880 insertions(+), 787 deletions(-) diff --git a/test/all/templates/templates.mocha.js b/test/all/templates/templates.mocha.js index 8551a80e3..58936c7ae 100644 --- a/test/all/templates/templates.mocha.js +++ b/test/all/templates/templates.mocha.js @@ -1,5 +1,5 @@ -var expect = require('expect.js'); -var templates = require('../lib/templates'); +var expect = require('chai').expect; +var templates = require('../../../lib/templates/templates'); describe('Views', function() { diff --git a/test/dom/templates/templates.dom.mocha.js b/test/dom/templates/templates.dom.mocha.js index 8558430cc..9d9a21f14 100644 --- a/test/dom/templates/templates.dom.mocha.js +++ b/test/dom/templates/templates.dom.mocha.js @@ -1,53 +1,53 @@ var chai = require('chai'); var expect = chai.expect; -var saddle = require('../index'); -var expressions = require('../example/expressions'); - -//add fixture to page -//only 90s kids will remember this -document.write('
'); - -describe('Static rendering', function() { - - var context = getContext(); - - describe('HTML', function() { - testStaticRendering(function test(options) { - var html = options.template.get(context); - expect(html).equal(options.html); +var saddle = require('../../../lib/templates/templates'); +var domTestRunner = require('../../../test-utils/domTestRunner'); + +describe('templates rendering', function() { + domTestRunner.install({jsdomOptions: {pretendToBeVisual: true}}); + + describe('Static rendering', function() { + describe('HTML', function() { + testStaticRendering(function test(options) { + var context = getContext(); + var html = options.template.get(context); + expect(html).equal(options.html); + }); }); - }); - - describe('Fragment', function() { - testStaticRendering(function test(options) { - // getFragment calls appendTo, so these Fragment tests cover appendTo. - var fragment = options.template.getFragment(context); - options.fragment(fragment); + + describe('Fragment', function() { + testStaticRendering(function test(options) { + var context = getContext(); + // getFragment calls appendTo, so these Fragment tests cover appendTo. + var fragment = options.template.getFragment(context); + options.fragment(fragment); + }); }); + }); - -}); - -describe('Dynamic rendering', function() { - - var context = getContext({ - show: true - }); - - describe('HTML', function() { - testDynamicRendering(function test(options) { - var html = options.template.get(context); - expect(html).equal(options.html); + + describe('Dynamic rendering', function() { + + describe('HTML', function() { + testDynamicRendering(function test(options) { + var context = getContext({ + show: true + }); + var html = options.template.get(context); + expect(html).equal(options.html); + }); }); - }); - - describe('Fragment', function() { - testDynamicRendering(function test(options) { - var fragment = options.template.getFragment(context); - options.fragment(fragment); + + describe('Fragment', function() { + testDynamicRendering(function test(options) { + var context = getContext({ + show: true + }); + var fragment = options.template.getFragment(context); + options.fragment(fragment); + }); }); }); - }); function testStaticRendering(test) { @@ -353,7 +353,7 @@ function testDynamicRendering(test) { 'class': new saddle.DynamicAttribute(new saddle.Template([ new saddle.Text('dropdown'), new saddle.ConditionalBlock([ - new expressions.Expression('show') + new FakeExpression('show') ], [ [new saddle.Text(' show')] ]) @@ -370,793 +370,795 @@ function testDynamicRendering(test) { } -describe('attachTo', function() { - var fixture = document.getElementById('fixture'); +describe('templates DOM manipulation', function() { + domTestRunner.install({jsdomOptions: {pretendToBeVisual: true}}); - after(function() { - removeChildren(fixture); + var fixture; + beforeEach(function() { + fixture = document.getElementById('fixture'); + if (!fixture) { + fixture = document.createElement('div'); + fixture.id = 'fixture'; + } }); - function renderAndAttach(template) { - var context = getContext(); + afterEach(function() { removeChildren(fixture); - fixture.innerHTML = template.get(context); - template.attachTo(fixture, fixture.firstChild, context); - } - - it('splits static text nodes', function() { - var template = new saddle.Template([ - new saddle.Text('Hi'), - new saddle.Text(' there.') - ]); - renderAndAttach(template); - expect(fixture.childNodes.length).equal(2); }); - it('splits empty static text nodes', function() { - var template = new saddle.Template([ - new saddle.Text(''), - new saddle.Text('') - ]); - renderAndAttach(template); - expect(fixture.childNodes.length).equal(2); - }); - - it('splits mixed empty static text nodes', function() { - var template = new saddle.Template([ - new saddle.Text(''), - new saddle.Text('Hi'), - new saddle.Text(''), - new saddle.Text(''), - new saddle.Text(' there.'), - new saddle.Text('') - ]); - renderAndAttach(template); - expect(fixture.childNodes.length).equal(6); - }); - - it('adds empty text nodes around a comment', function() { - var template = new saddle.Template([ - new saddle.Text('Hi'), - new saddle.Text(''), - new saddle.Comment('cool'), - new saddle.Comment('thing'), - new saddle.Text('') - ]); - renderAndAttach(template); - expect(fixture.childNodes.length).equal(5); - }); - - it('attaches to nested elements', function() { - var template = new saddle.Template([ - new saddle.Element('ul', null, [ - new saddle.Element('li', null, [ - new saddle.Text('One') - ]), - new saddle.Element('li', null, [ - new saddle.Text('Two') - ]) - ]) - ]); - renderAndAttach(template); - }); - - it('attaches to element attributes', function() { - var template = new saddle.Template([ - new saddle.Element('input', { - type: new saddle.Attribute('text'), - autofocus: new saddle.Attribute(true), - placeholder: new saddle.Attribute(null) - }) - ]); - renderAndAttach(template); - }); - - it('attaches to from HTML within tbody context', function() { - var template = new saddle.Element('table', null, [ - new saddle.Element('tbody', null, [ - new saddle.Comment('OK'), - new saddle.Html('Hi'), - new saddle.Element('tr', null, [ - new saddle.Element('td', null, [ - new saddle.Text('Ho') + describe('attachTo', function() { + function renderAndAttach(template) { + var context = getContext(); + removeChildren(fixture); + fixture.innerHTML = template.get(context); + template.attachTo(fixture, fixture.firstChild, context); + } + + it('splits static text nodes', function() { + var template = new saddle.Template([ + new saddle.Text('Hi'), + new saddle.Text(' there.') + ]); + renderAndAttach(template); + expect(fixture.childNodes.length).equal(2); + }); + + it('splits empty static text nodes', function() { + var template = new saddle.Template([ + new saddle.Text(''), + new saddle.Text('') + ]); + renderAndAttach(template); + expect(fixture.childNodes.length).equal(2); + }); + + it('splits mixed empty static text nodes', function() { + var template = new saddle.Template([ + new saddle.Text(''), + new saddle.Text('Hi'), + new saddle.Text(''), + new saddle.Text(''), + new saddle.Text(' there.'), + new saddle.Text('') + ]); + renderAndAttach(template); + expect(fixture.childNodes.length).equal(6); + }); + + it('adds empty text nodes around a comment', function() { + var template = new saddle.Template([ + new saddle.Text('Hi'), + new saddle.Text(''), + new saddle.Comment('cool'), + new saddle.Comment('thing'), + new saddle.Text('') + ]); + renderAndAttach(template); + expect(fixture.childNodes.length).equal(5); + }); + + it('attaches to nested elements', function() { + var template = new saddle.Template([ + new saddle.Element('ul', null, [ + new saddle.Element('li', null, [ + new saddle.Text('One') + ]), + new saddle.Element('li', null, [ + new saddle.Text('Two') ]) ]) - ]) - ]); - renderAndAttach(template); - }); - - it('traverses with comments in a table and select', function() { - // IE fails to create comments in certain locations when parsing HTML - var template = new saddle.Template([ - new saddle.Element('table', null, [ - new saddle.Comment('table comment'), + ]); + renderAndAttach(template); + }); + + it('attaches to element attributes', function() { + var template = new saddle.Template([ + new saddle.Element('input', { + type: new saddle.Attribute('text'), + autofocus: new saddle.Attribute(true), + placeholder: new saddle.Attribute(null) + }) + ]); + renderAndAttach(template); + }); + + it('attaches to from HTML within tbody context', function() { + var template = new saddle.Element('table', null, [ new saddle.Element('tbody', null, [ - new saddle.Comment('tbody comment'), + new saddle.Comment('OK'), + new saddle.Html('Hi'), new saddle.Element('tr', null, [ - new saddle.Element('td') + new saddle.Element('td', null, [ + new saddle.Text('Ho') + ]) ]) ]) - ]), - new saddle.Element('select', null, [ - new saddle.Comment('select comment start'), - new saddle.Element('option'), - new saddle.Comment('select comment inner'), - new saddle.Element('option'), - new saddle.Comment('select comment end'), - new saddle.Comment('select comment end 2') - ]) - ]); - renderAndAttach(template); - }); - - it('throws when fragment does not match HTML', function() { - // This template is invalid HTML, and when it is parsed it will produce - // a different tree structure than when the nodes are created one-by-one - var template = new saddle.Template([ - new saddle.Element('table', null, [ - new saddle.Element('div', null, [ - new saddle.Element('td', null, [ - new saddle.Text('Hi') + ]); + renderAndAttach(template); + }); + + it('traverses with comments in a table and select', function() { + // IE fails to create comments in certain locations when parsing HTML + var template = new saddle.Template([ + new saddle.Element('table', null, [ + new saddle.Comment('table comment'), + new saddle.Element('tbody', null, [ + new saddle.Comment('tbody comment'), + new saddle.Element('tr', null, [ + new saddle.Element('td') + ]) ]) + ]), + new saddle.Element('select', null, [ + new saddle.Comment('select comment start'), + new saddle.Element('option'), + new saddle.Comment('select comment inner'), + new saddle.Element('option'), + new saddle.Comment('select comment end'), + new saddle.Comment('select comment end 2') ]) - ]) - ]); - expect(function() { + ]); renderAndAttach(template); - }).throw(Error); - }); - -}); - -describe('Binding updates', function() { - - var fixture = document.getElementById('fixture'); - after(function() { - removeChildren(fixture); - }); - - describe('getFragment', function() { - testBindingUpdates(function render(template, data) { - var bindings = []; - var context = getContext(data, bindings); - var fragment = template.getFragment(context); - removeChildren(fixture); - fixture.appendChild(fragment); - return bindings; }); - }); - - describe('get + attachTo', function() { - testBindingUpdates(function render(template, data) { - var bindings = []; - var context = getContext(data, bindings); - removeChildren(fixture); - fixture.innerHTML = template.get(context); - template.attachTo(fixture, fixture.firstChild, context); - return bindings; + + it('throws when fragment does not match HTML', function() { + // This template is invalid HTML, and when it is parsed it will produce + // a different tree structure than when the nodes are created one-by-one + var template = new saddle.Template([ + new saddle.Element('table', null, [ + new saddle.Element('div', null, [ + new saddle.Element('td', null, [ + new saddle.Text('Hi') + ]) + ]) + ]) + ]); + expect(function() { + renderAndAttach(template); + }).throw(Error); }); + }); + + describe('binding updates', function() { + describe('getFragment', function() { + testBindingUpdates(function render(template, data) { + var bindings = []; + var context = getContext(data, bindings); + var fragment = template.getFragment(context); + removeChildren(fixture); + fixture.appendChild(fragment); + return bindings; + }); + }); + + describe('get + attachTo', function() { + testBindingUpdates(function render(template, data) { + var bindings = []; + var context = getContext(data, bindings); + removeChildren(fixture); + fixture.innerHTML = template.get(context); + template.attachTo(fixture, fixture.firstChild, context); + return bindings; + }); + }); -}); - -function testBindingUpdates(render) { - var fixture = document.getElementById('fixture'); - - it('updates a single TextNode', function() { - var template = new saddle.Template([ - new saddle.DynamicText(new expressions.Expression('text')) - ]); - var binding = render(template).pop(); - expect(getText(fixture)).equal(''); - binding.context = getContext({text: 'Yo'}); - binding.update(); - expect(getText(fixture)).equal('Yo'); - }); + function testBindingUpdates(render) { + it('updates a single TextNode', function() { + var template = new saddle.Template([ + new saddle.DynamicText(new FakeExpression('text')) + ]); + var binding = render(template).pop(); + expect(getText(fixture)).equal(''); + binding.context = getContext({text: 'Yo'}); + binding.update(); + expect(getText(fixture)).equal('Yo'); + }); - it('updates sibling TextNodes', function() { - var template = new saddle.Template([ - new saddle.DynamicText(new expressions.Expression('first')), - new saddle.DynamicText(new expressions.Expression('second')) - ]); - var bindings = render(template, {second: 2}); - expect(bindings.length).equal(2); - expect(getText(fixture)).equal('2'); - var context = getContext({first: 'one', second: 'two'}); - bindings[0].context = context; - bindings[0].update(); - expect(getText(fixture)).equal('one2'); - bindings[1].context = context; - bindings[1].update(); - expect(getText(fixture)).equal('onetwo'); - }); + it('updates sibling TextNodes', function() { + var template = new saddle.Template([ + new saddle.DynamicText(new FakeExpression('first')), + new saddle.DynamicText(new FakeExpression('second')) + ]); + var bindings = render(template, {second: 2}); + expect(bindings.length).equal(2); + expect(getText(fixture)).equal('2'); + var context = getContext({first: 'one', second: 'two'}); + bindings[0].context = context; + bindings[0].update(); + expect(getText(fixture)).equal('one2'); + bindings[1].context = context; + bindings[1].update(); + expect(getText(fixture)).equal('onetwo'); + }); - it('updates a TextNode that returns text, then a Template', function() { - var template = new saddle.Template([ - new saddle.DynamicText(new expressions.Expression('dynamicTemplate')) - ]); - var data = {dynamicTemplate: 'Hola'}; - var binding = render(template, data).pop(); - expect(getText(fixture)).equal('Hola'); - binding.context = getContext({ - dynamicTemplate: new saddle.DynamicText(new expressions.Expression('text')), - text: 'Yo' - }); - binding.update(); - expect(getText(fixture)).equal('Yo'); - }); + it('updates a TextNode that returns text, then a Template', function() { + var template = new saddle.Template([ + new saddle.DynamicText(new FakeExpression('dynamicTemplate')) + ]); + var data = {dynamicTemplate: 'Hola'}; + var binding = render(template, data).pop(); + expect(getText(fixture)).equal('Hola'); + binding.context = getContext({ + dynamicTemplate: new saddle.DynamicText(new FakeExpression('text')), + text: 'Yo' + }); + binding.update(); + expect(getText(fixture)).equal('Yo'); + }); - it('updates a TextNode that returns a Template, then text', function() { - var template = new saddle.Template([ - new saddle.DynamicText(new expressions.Expression('dynamicTemplate')) - ]); - var data = { - dynamicTemplate: new saddle.DynamicText(new expressions.Expression('text')), - text: 'Yo' - }; - var binding = render(template, data).pop(); - expect(getText(fixture)).equal('Yo'); - binding.context = getContext({dynamicTemplate: 'Hola'}); - binding.update(); - expect(getText(fixture)).equal('Hola'); - }); + it('updates a TextNode that returns a Template, then text', function() { + var template = new saddle.Template([ + new saddle.DynamicText(new FakeExpression('dynamicTemplate')) + ]); + var data = { + dynamicTemplate: new saddle.DynamicText(new FakeExpression('text')), + text: 'Yo' + }; + var binding = render(template, data).pop(); + expect(getText(fixture)).equal('Yo'); + binding.context = getContext({dynamicTemplate: 'Hola'}); + binding.update(); + expect(getText(fixture)).equal('Hola'); + }); - it('updates a TextNode that returns a Template, then another Template', function() { - var template = new saddle.Template([ - new saddle.DynamicText(new expressions.Expression('dynamicTemplate')) - ]); - var data = { - dynamicTemplate: new saddle.DynamicText(new expressions.Expression('text')), - text: 'Yo' - }; - var binding = render(template, data).pop(); - expect(getText(fixture)).equal('Yo'); - binding.context = getContext({ - dynamicTemplate: new saddle.Template([ - new saddle.DynamicText(new expressions.Expression('first')), - new saddle.DynamicText(new expressions.Expression('second')) - ]), - first: 'one', - second: 'two' - }); - binding.update(); - expect(getText(fixture)).equal('onetwo'); - }); + it('updates a TextNode that returns a Template, then another Template', function() { + var template = new saddle.Template([ + new saddle.DynamicText(new FakeExpression('dynamicTemplate')) + ]); + var data = { + dynamicTemplate: new saddle.DynamicText(new FakeExpression('text')), + text: 'Yo' + }; + var binding = render(template, data).pop(); + expect(getText(fixture)).equal('Yo'); + binding.context = getContext({ + dynamicTemplate: new saddle.Template([ + new saddle.DynamicText(new FakeExpression('first')), + new saddle.DynamicText(new FakeExpression('second')) + ]), + first: 'one', + second: 'two' + }); + binding.update(); + expect(getText(fixture)).equal('onetwo'); + }); - it('updates within a template returned by a TextNode', function() { - var template = new saddle.Template([ - new saddle.DynamicText(new expressions.Expression('dynamicTemplate')) - ]); - var data = { - dynamicTemplate: new saddle.DynamicText(new expressions.Expression('text')), - text: 'Yo' - }; - var textBinding = render(template, data).shift(); - expect(getText(fixture)).equal('Yo'); - data.text = 'Hola'; - textBinding.context = getContext(data); - textBinding.update(); - expect(getText(fixture)).equal('Hola'); - }); + it('updates within a template returned by a TextNode', function() { + var template = new saddle.Template([ + new saddle.DynamicText(new FakeExpression('dynamicTemplate')) + ]); + var data = { + dynamicTemplate: new saddle.DynamicText(new FakeExpression('text')), + text: 'Yo' + }; + var textBinding = render(template, data).shift(); + expect(getText(fixture)).equal('Yo'); + data.text = 'Hola'; + textBinding.context = getContext(data); + textBinding.update(); + expect(getText(fixture)).equal('Hola'); + }); - it('updates a CommentNode', function() { - var template = new saddle.Template([ - new saddle.DynamicComment(new expressions.Expression('comment')) - ]); - var binding = render(template, {comment: 'Hi'}).pop(); - expect(fixture.innerHTML).equal(''); - binding.context = getContext({comment: 'Bye'}); - binding.update(); - expect(fixture.innerHTML).equal(''); - }); + it('updates a CommentNode', function() { + var template = new saddle.Template([ + new saddle.DynamicComment(new FakeExpression('comment')) + ]); + var binding = render(template, {comment: 'Hi'}).pop(); + expect(fixture.innerHTML).equal(''); + binding.context = getContext({comment: 'Bye'}); + binding.update(); + expect(fixture.innerHTML).equal(''); + }); - it('updates raw HTML', function() { - var template = new saddle.Template([ - new saddle.DynamicHtml(new expressions.Expression('html')), - new saddle.Element('div') - ]); - var binding = render(template, {html: 'Hi'}).pop(); - var children = getChildren(fixture); - expect(children.length).equal(2); - expect(children[0].tagName.toLowerCase()).equal('b'); - expect(children[0].innerHTML).equal('Hi'); - expect(children[1].tagName.toLowerCase()).equal('div'); - binding.context = getContext({html: 'What?'}); - binding.update(); - var children = getChildren(fixture); - expect(children.length).equal(2); - expect(children[0].tagName.toLowerCase()).equal('i'); - expect(children[0].innerHTML).equal('What?'); - expect(children[1].tagName.toLowerCase()).equal('div'); - binding.context = getContext({html: 'Hola'}); - binding.update(); - var children = getChildren(fixture); - expect(children.length).equal(1); - expect(getText(fixture)).equal('Hola'); - expect(children[0].tagName.toLowerCase()).equal('div'); - }); + it('updates raw HTML', function() { + var template = new saddle.Template([ + new saddle.DynamicHtml(new FakeExpression('html')), + new saddle.Element('div') + ]); + var binding = render(template, {html: 'Hi'}).pop(); + var children = getChildren(fixture); + expect(children.length).equal(2); + expect(children[0].tagName.toLowerCase()).equal('b'); + expect(children[0].innerHTML).equal('Hi'); + expect(children[1].tagName.toLowerCase()).equal('div'); + binding.context = getContext({html: 'What?'}); + binding.update(); + var children = getChildren(fixture); + expect(children.length).equal(2); + expect(children[0].tagName.toLowerCase()).equal('i'); + expect(children[0].innerHTML).equal('What?'); + expect(children[1].tagName.toLowerCase()).equal('div'); + binding.context = getContext({html: 'Hola'}); + binding.update(); + var children = getChildren(fixture); + expect(children.length).equal(1); + expect(getText(fixture)).equal('Hola'); + expect(children[0].tagName.toLowerCase()).equal('div'); + }); - it('updates an Element attribute', function() { - var template = new saddle.Template([ - new saddle.Element('div', { - 'class': new saddle.Attribute('message'), - 'data-greeting': new saddle.DynamicAttribute(new expressions.Expression('greeting')) - }) - ]); - var binding = render(template).pop(); - var node = fixture.firstChild; - expect(node.className).equal('message'); - expect(node.getAttribute('data-greeting')).eql(null); - // Set initial value - binding.context = getContext({greeting: 'Yo'}); - binding.update(); - expect(node.getAttribute('data-greeting')).equal('Yo'); - // Change value for same attribute - binding.context = getContext({greeting: 'Hi'}); - binding.update(); - expect(node.getAttribute('data-greeting')).equal('Hi'); - // Clear value - binding.context = getContext(); - binding.update(); - expect(node.getAttribute('data-greeting')).eql(null); - // Dynamic updates don't affect static attribute - expect(node.className).equal('message'); - }); + it('updates an Element attribute', function() { + var template = new saddle.Template([ + new saddle.Element('div', { + 'class': new saddle.Attribute('message'), + 'data-greeting': new saddle.DynamicAttribute(new FakeExpression('greeting')) + }) + ]); + var binding = render(template).pop(); + var node = fixture.firstChild; + expect(node.className).equal('message'); + expect(node.getAttribute('data-greeting')).eql(null); + // Set initial value + binding.context = getContext({greeting: 'Yo'}); + binding.update(); + expect(node.getAttribute('data-greeting')).equal('Yo'); + // Change value for same attribute + binding.context = getContext({greeting: 'Hi'}); + binding.update(); + expect(node.getAttribute('data-greeting')).equal('Hi'); + // Clear value + binding.context = getContext(); + binding.update(); + expect(node.getAttribute('data-greeting')).eql(null); + // Dynamic updates don't affect static attribute + expect(node.className).equal('message'); + }); - it('updates text input "value" property', function() { - var template = new saddle.Template([ - new saddle.Element('input', { - 'value': new saddle.DynamicAttribute(new expressions.Expression('text')), - }) - ]); - - var binding = render(template).pop(); - var input = fixture.firstChild; - - // Set initial value to string. - binding.context = getContext({text: 'Hi'}); - binding.update(); - expect(input.value).equal('Hi'); - - // Update using numeric value, check that title is the stringified number. - binding.context = getContext({text: 123}); - binding.update(); - expect(input.value).equal('123'); - - // Change value to undefined, make sure attribute is removed. - binding.context = getContext({}); - binding.update(); - expect(input.value).equal(''); - }); + it('updates text input "value" property', function() { + var template = new saddle.Template([ + new saddle.Element('input', { + 'value': new saddle.DynamicAttribute(new FakeExpression('text')), + }) + ]); + + var binding = render(template).pop(); + var input = fixture.firstChild; + + // Set initial value to string. + binding.context = getContext({text: 'Hi'}); + binding.update(); + expect(input.value).equal('Hi'); + + // Update using numeric value, check that title is the stringified number. + binding.context = getContext({text: 123}); + binding.update(); + expect(input.value).equal('123'); + + // Change value to undefined, make sure attribute is removed. + binding.context = getContext({}); + binding.update(); + expect(input.value).equal(''); + }); - it('does not clobber input type="number" value when typing "1.0"', function() { - var template = new saddle.Template([ - new saddle.Element('input', { - 'type': new saddle.Attribute('number'), - 'value': new saddle.DynamicAttribute(new expressions.Expression('amount')), - }) - ]); - - var binding = render(template).pop(); - var input = fixture.firstChild; - - // Make sure that a user-typed input value of "1.0" does not get clobbered by - // a context value of `1`. - input.value = '1.0'; - binding.context = getContext({amount: 1}); - binding.update(); - expect(input.value).equal('1.0'); - }); + it('does not clobber input type="number" value when typing "1.0"', function() { + var template = new saddle.Template([ + new saddle.Element('input', { + 'type': new saddle.Attribute('number'), + 'value': new saddle.DynamicAttribute(new FakeExpression('amount')), + }) + ]); + + var binding = render(template).pop(); + var input = fixture.firstChild; + + // Make sure that a user-typed input value of "1.0" does not get clobbered by + // a context value of `1`. + input.value = '1.0'; + binding.context = getContext({amount: 1}); + binding.update(); + expect(input.value).equal('1.0'); + }); - it('updates "title" attribute', function() { - var template = new saddle.Template([ - new saddle.Element('div', { - 'title': new saddle.DynamicAttribute(new expressions.Expression('divTooltip')), - }) - ]); - - var binding = render(template).pop(); - var node = fixture.firstChild; - - // Set initial value to string. - binding.context = getContext({divTooltip: 'My tooltip'}); - binding.update(); - expect(node.title).equal('My tooltip'); - - // Update using numeric value, check that title is the stringified number. - binding.context = getContext({divTooltip: 123}); - binding.update(); - expect(node.title).equal('123'); - - // Change value to undefined, make sure attribute is removed. - binding.context = getContext({}); - binding.update(); - expect(node.title).equal(''); - }); + it('updates "title" attribute', function() { + var template = new saddle.Template([ + new saddle.Element('div', { + 'title': new saddle.DynamicAttribute(new FakeExpression('divTooltip')), + }) + ]); + + var binding = render(template).pop(); + var node = fixture.firstChild; + + // Set initial value to string. + binding.context = getContext({divTooltip: 'My tooltip'}); + binding.update(); + expect(node.title).equal('My tooltip'); + + // Update using numeric value, check that title is the stringified number. + binding.context = getContext({divTooltip: 123}); + binding.update(); + expect(node.title).equal('123'); + + // Change value to undefined, make sure attribute is removed. + binding.context = getContext({}); + binding.update(); + expect(node.title).equal(''); + }); - it('updates a Block', function() { - var template = new saddle.Template([ - new saddle.Block(new expressions.Expression('author'), [ - new saddle.Element('h3', null, [ - new saddle.DynamicText(new expressions.Expression('name')) - ]), - new saddle.DynamicText(new expressions.Expression('name')) - ]) - ]); - var binding = render(template).pop(); - var children = getChildren(fixture); - expect(children.length).equal(1); - expect(children[0].tagName.toLowerCase()).equal('h3'); - expect(getText(children[0])).equal(''); - expect(getText(fixture)).equal(''); - // Update entire block context - binding.context = getContext({author: {name: 'John'}}); - binding.update(); - var children = getChildren(fixture); - expect(children.length).equal(1); - expect(children[0].tagName.toLowerCase()).equal('h3'); - expect(getText(children[0])).equal('John'); - expect(getText(fixture)).equal('JohnJohn'); - // Reset to no data - binding.context = getContext(); - binding.update(); - var children = getChildren(fixture); - expect(children.length).equal(1); - expect(children[0].tagName.toLowerCase()).equal('h3'); - expect(getText(children[0])).equal(''); - expect(getText(fixture)).equal(''); - }); + it('updates a Block', function() { + var template = new saddle.Template([ + new saddle.Block(new FakeExpression('author'), [ + new saddle.Element('h3', null, [ + new saddle.DynamicText(new FakeExpression('name')) + ]), + new saddle.DynamicText(new FakeExpression('name')) + ]) + ]); + var binding = render(template).pop(); + var children = getChildren(fixture); + expect(children.length).equal(1); + expect(children[0].tagName.toLowerCase()).equal('h3'); + expect(getText(children[0])).equal(''); + expect(getText(fixture)).equal(''); + // Update entire block context + binding.context = getContext({author: {name: 'John'}}); + binding.update(); + var children = getChildren(fixture); + expect(children.length).equal(1); + expect(children[0].tagName.toLowerCase()).equal('h3'); + expect(getText(children[0])).equal('John'); + expect(getText(fixture)).equal('JohnJohn'); + // Reset to no data + binding.context = getContext(); + binding.update(); + var children = getChildren(fixture); + expect(children.length).equal(1); + expect(children[0].tagName.toLowerCase()).equal('h3'); + expect(getText(children[0])).equal(''); + expect(getText(fixture)).equal(''); + }); - it('updates a single condition ConditionalBlock', function() { - var template = new saddle.Template([ - new saddle.ConditionalBlock([ - new expressions.Expression('show') - ], [ - [new saddle.Text('shown')] - ]) - ]); - var binding = render(template).pop(); - expect(getText(fixture)).equal(''); - // Update value - binding.context = getContext({show: true}); - binding.update(); - expect(getText(fixture)).equal('shown'); - // Reset to no data - binding.context = getContext({show: false}); - binding.update(); - expect(getText(fixture)).equal(''); - }); + it('updates a single condition ConditionalBlock', function() { + var template = new saddle.Template([ + new saddle.ConditionalBlock([ + new FakeExpression('show') + ], [ + [new saddle.Text('shown')] + ]) + ]); + var binding = render(template).pop(); + expect(getText(fixture)).equal(''); + // Update value + binding.context = getContext({show: true}); + binding.update(); + expect(getText(fixture)).equal('shown'); + // Reset to no data + binding.context = getContext({show: false}); + binding.update(); + expect(getText(fixture)).equal(''); + }); - it('updates a multi-condition ConditionalBlock', function() { - var template = new saddle.Template([ - new saddle.ConditionalBlock([ - new expressions.Expression('primary'), - new expressions.Expression('alternate'), - new expressions.ElseExpression() - ], [ - [new saddle.DynamicText(new expressions.Expression())], - [], - [new saddle.Text('else')] - ]) - ]); - var binding = render(template).pop(); - expect(getText(fixture)).equal('else'); - // Update value - binding.context = getContext({primary: 'Heyo'}); - binding.update(); - expect(getText(fixture)).equal('Heyo'); - // Update value - binding.context = getContext({alternate: true}); - binding.update(); - expect(getText(fixture)).equal(''); - // Reset to no data - binding.context = getContext(); - binding.update(); - expect(getText(fixture)).equal('else'); - }); + it('updates a multi-condition ConditionalBlock', function() { + var template = new saddle.Template([ + new saddle.ConditionalBlock([ + new FakeExpression('primary'), + new FakeExpression('alternate'), + new FakeElseExpression() + ], [ + [new saddle.DynamicText(new FakeExpression())], + [], + [new saddle.Text('else')] + ]) + ]); + var binding = render(template).pop(); + expect(getText(fixture)).equal('else'); + // Update value + binding.context = getContext({primary: 'Heyo'}); + binding.update(); + expect(getText(fixture)).equal('Heyo'); + // Update value + binding.context = getContext({alternate: true}); + binding.update(); + expect(getText(fixture)).equal(''); + // Reset to no data + binding.context = getContext(); + binding.update(); + expect(getText(fixture)).equal('else'); + }); - it('updates an each of text', function() { - var template = new saddle.Template([ - new saddle.EachBlock(new expressions.Expression('items'), [ - new saddle.DynamicText(new expressions.Expression()) - ]) - ]); - var binding = render(template).pop(); - expect(getText(fixture)).equal(''); - // Update value - binding.context = getContext({items: ['One', 'Two', 'Three']}); - binding.update(); - expect(getText(fixture)).equal('OneTwoThree'); - // Update value - binding.context = getContext({items: ['Four', 'Five']}); - binding.update(); - expect(getText(fixture)).equal('FourFive'); - // Update value - binding.context = getContext({items: []}); - binding.update(); - expect(getText(fixture)).equal(''); - // Reset to no data - binding.context = getContext(); - binding.update(); - expect(getText(fixture)).equal(''); - }); + it('updates an each of text', function() { + var template = new saddle.Template([ + new saddle.EachBlock(new FakeExpression('items'), [ + new saddle.DynamicText(new FakeExpression()) + ]) + ]); + var binding = render(template).pop(); + expect(getText(fixture)).equal(''); + // Update value + binding.context = getContext({items: ['One', 'Two', 'Three']}); + binding.update(); + expect(getText(fixture)).equal('OneTwoThree'); + // Update value + binding.context = getContext({items: ['Four', 'Five']}); + binding.update(); + expect(getText(fixture)).equal('FourFive'); + // Update value + binding.context = getContext({items: []}); + binding.update(); + expect(getText(fixture)).equal(''); + // Reset to no data + binding.context = getContext(); + binding.update(); + expect(getText(fixture)).equal(''); + }); - it('updates an each with an else', function() { - var template = new saddle.Template([ - new saddle.EachBlock(new expressions.Expression('items'), [ - new saddle.DynamicText(new expressions.Expression('name')) - ], [ - new saddle.Text('else') - ]) - ]); - var binding = render(template).pop(); - expect(getText(fixture)).equal('else'); - // Update value - binding.context = getContext({items: [ - {name: 'One'}, {name: 'Two'}, {name: 'Three'} - ]}); - binding.update(); - expect(getText(fixture)).equal('OneTwoThree'); - // Update value - binding.context = getContext({items: [ - {name: 'Four'}, {name: 'Five'} - ]}); - binding.update(); - expect(getText(fixture)).equal('FourFive'); - // Update value - binding.context = getContext({items: []}); - binding.update(); - expect(getText(fixture)).equal('else'); - // Reset to no data - binding.context = getContext(); - binding.update(); - expect(getText(fixture)).equal('else'); - }); + it('updates an each with an else', function() { + var template = new saddle.Template([ + new saddle.EachBlock(new FakeExpression('items'), [ + new saddle.DynamicText(new FakeExpression('name')) + ], [ + new saddle.Text('else') + ]) + ]); + var binding = render(template).pop(); + expect(getText(fixture)).equal('else'); + // Update value + binding.context = getContext({items: [ + {name: 'One'}, {name: 'Two'}, {name: 'Three'} + ]}); + binding.update(); + expect(getText(fixture)).equal('OneTwoThree'); + // Update value + binding.context = getContext({items: [ + {name: 'Four'}, {name: 'Five'} + ]}); + binding.update(); + expect(getText(fixture)).equal('FourFive'); + // Update value + binding.context = getContext({items: []}); + binding.update(); + expect(getText(fixture)).equal('else'); + // Reset to no data + binding.context = getContext(); + binding.update(); + expect(getText(fixture)).equal('else'); + }); - it('inserts in an each', function() { - var template = new saddle.Template([ - new saddle.EachBlock(new expressions.Expression('items'), [ - new saddle.DynamicText(new expressions.Expression('name')) - ]) - ]); - var binding = render(template).pop(); - expect(getText(fixture)).equal(''); - // Insert from null state - var data = {items: []}; - binding.context = getContext(data); - insert(binding, data.items, 0, [{name: 'One'}, {name: 'Two'}, {name: 'Three'}]); - expect(getText(fixture)).equal('OneTwoThree'); - // Insert new items - insert(binding, data.items, 1, [{name: 'Four'}, {name: 'Five'}]); - expect(getText(fixture)).equal('OneFourFiveTwoThree'); - }); + it('inserts in an each', function() { + var template = new saddle.Template([ + new saddle.EachBlock(new FakeExpression('items'), [ + new saddle.DynamicText(new FakeExpression('name')) + ]) + ]); + var binding = render(template).pop(); + expect(getText(fixture)).equal(''); + // Insert from null state + var data = {items: []}; + binding.context = getContext(data); + insert(binding, data.items, 0, [{name: 'One'}, {name: 'Two'}, {name: 'Three'}]); + expect(getText(fixture)).equal('OneTwoThree'); + // Insert new items + insert(binding, data.items, 1, [{name: 'Four'}, {name: 'Five'}]); + expect(getText(fixture)).equal('OneFourFiveTwoThree'); + }); - it('inserts into empty each with else', function() { - var template = new saddle.Template([ - new saddle.EachBlock(new expressions.Expression('items'), [ - new saddle.DynamicText(new expressions.Expression('name')) - ], [ - new saddle.Text('else') - ]) - ]); - var binding = render(template).pop(); - expect(getText(fixture)).equal('else'); - // Insert from null state - var data = {items: []}; - binding.context = getContext(data); - insert(binding, data.items, 0, [{name: 'One'}, {name: 'Two'}, {name: 'Three'}]); - expect(getText(fixture)).equal('OneTwoThree'); - }); + it('inserts into empty each with else', function() { + var template = new saddle.Template([ + new saddle.EachBlock(new FakeExpression('items'), [ + new saddle.DynamicText(new FakeExpression('name')) + ], [ + new saddle.Text('else') + ]) + ]); + var binding = render(template).pop(); + expect(getText(fixture)).equal('else'); + // Insert from null state + var data = {items: []}; + binding.context = getContext(data); + insert(binding, data.items, 0, [{name: 'One'}, {name: 'Two'}, {name: 'Three'}]); + expect(getText(fixture)).equal('OneTwoThree'); + }); - it('removes all items in an each with else', function() { - var template = new saddle.Template([ - new saddle.EachBlock(new expressions.Expression('items'), [ - new saddle.DynamicText(new expressions.Expression('name')) - ], [ - new saddle.Text('else') - ]) - ]); - var data = {items: [ - {name: 'One'}, {name: 'Two'}, {name: 'Three'} - ]}; - var binding = render(template, data).pop(); - expect(getText(fixture)).equal('OneTwoThree'); - binding.context = getContext(data); - // Remove all items - remove(binding, data.items, 0, 3); - expect(getText(fixture)).equal('else'); - }); + it('removes all items in an each with else', function() { + var template = new saddle.Template([ + new saddle.EachBlock(new FakeExpression('items'), [ + new saddle.DynamicText(new FakeExpression('name')) + ], [ + new saddle.Text('else') + ]) + ]); + var data = {items: [ + {name: 'One'}, {name: 'Two'}, {name: 'Three'} + ]}; + var binding = render(template, data).pop(); + expect(getText(fixture)).equal('OneTwoThree'); + binding.context = getContext(data); + // Remove all items + remove(binding, data.items, 0, 3); + expect(getText(fixture)).equal('else'); + }); - it('removes in an each', function() { - var template = new saddle.Template([ - new saddle.EachBlock(new expressions.Expression('items'), [ - new saddle.DynamicText(new expressions.Expression('name')) - ]) - ]); - var data = {items: [ - {name: 'One'}, {name: 'Two'}, {name: 'Three'} - ]}; - var binding = render(template, data).pop(); - expect(getText(fixture)).equal('OneTwoThree'); - binding.context = getContext(data); - // Remove inner item - remove(binding, data.items, 1, 1); - expect(getText(fixture)).equal('OneThree'); - // Remove multiple remaining - remove(binding, data.items, 0, 2); - expect(getText(fixture)).equal(''); - }); + it('removes in an each', function() { + var template = new saddle.Template([ + new saddle.EachBlock(new FakeExpression('items'), [ + new saddle.DynamicText(new FakeExpression('name')) + ]) + ]); + var data = {items: [ + {name: 'One'}, {name: 'Two'}, {name: 'Three'} + ]}; + var binding = render(template, data).pop(); + expect(getText(fixture)).equal('OneTwoThree'); + binding.context = getContext(data); + // Remove inner item + remove(binding, data.items, 1, 1); + expect(getText(fixture)).equal('OneThree'); + // Remove multiple remaining + remove(binding, data.items, 0, 2); + expect(getText(fixture)).equal(''); + }); - it('moves in an each', function() { - var template = new saddle.Template([ - new saddle.EachBlock(new expressions.Expression('items'), [ - new saddle.DynamicText(new expressions.Expression('name')) - ]) - ]); - var data = {items: [ - {name: 'One'}, {name: 'Two'}, {name: 'Three'} - ]}; - var binding = render(template, data).pop(); - expect(getText(fixture)).equal('OneTwoThree'); - binding.context = getContext(data); - // Move one item - move(binding, data.items, 1, 2, 1); - expect(getText(fixture)).equal('OneThreeTwo'); - // Move multiple items - move(binding, data.items, 1, 0, 2); - expect(getText(fixture)).equal('ThreeTwoOne'); - }); + it('moves in an each', function() { + var template = new saddle.Template([ + new saddle.EachBlock(new FakeExpression('items'), [ + new saddle.DynamicText(new FakeExpression('name')) + ]) + ]); + var data = {items: [ + {name: 'One'}, {name: 'Two'}, {name: 'Three'} + ]}; + var binding = render(template, data).pop(); + expect(getText(fixture)).equal('OneTwoThree'); + binding.context = getContext(data); + // Move one item + move(binding, data.items, 1, 2, 1); + expect(getText(fixture)).equal('OneThreeTwo'); + // Move multiple items + move(binding, data.items, 1, 0, 2); + expect(getText(fixture)).equal('ThreeTwoOne'); + }); - it('insert, move, and remove with multiple node items', function() { - var template = new saddle.Template([ - new saddle.EachBlock(new expressions.Expression('items'), [ - new saddle.Element('h3', null, [ - new saddle.DynamicText(new expressions.Expression('title')) - ]), - new saddle.DynamicText(new expressions.Expression('text')) - ]) - ]); - var data = {items: [ - {title: '1', text: 'one'}, - {title: '2', text: 'two'}, - {title: '3', text: 'three'} - ]}; - var binding = render(template, data).pop(); - expect(getText(fixture)).equal('1one2two3three'); - binding.context = getContext(data); - // Insert an item - insert(binding, data.items, 2, [{title: '4', text: 'four'}]); - expect(getText(fixture)).equal('1one2two4four3three'); - // Move items - move(binding, data.items, 1, 0, 3); - expect(getText(fixture)).equal('2two4four3three1one'); - // Remove an item - remove(binding, data.items, 2, 1); - expect(getText(fixture)).equal('2two4four1one'); - }); + it('insert, move, and remove with multiple node items', function() { + var template = new saddle.Template([ + new saddle.EachBlock(new FakeExpression('items'), [ + new saddle.Element('h3', null, [ + new saddle.DynamicText(new FakeExpression('title')) + ]), + new saddle.DynamicText(new FakeExpression('text')) + ]) + ]); + var data = {items: [ + {title: '1', text: 'one'}, + {title: '2', text: 'two'}, + {title: '3', text: 'three'} + ]}; + var binding = render(template, data).pop(); + expect(getText(fixture)).equal('1one2two3three'); + binding.context = getContext(data); + // Insert an item + insert(binding, data.items, 2, [{title: '4', text: 'four'}]); + expect(getText(fixture)).equal('1one2two4four3three'); + // Move items + move(binding, data.items, 1, 0, 3); + expect(getText(fixture)).equal('2two4four3three1one'); + // Remove an item + remove(binding, data.items, 2, 1); + expect(getText(fixture)).equal('2two4four1one'); + }); - it('inserts to outer nested each', function() { - var template = new saddle.Template([ - new saddle.EachBlock(new expressions.Expression('items'), [ - new saddle.DynamicText(new expressions.Expression('name')), - new saddle.EachBlock(new expressions.Expression('subitems'), [ - new saddle.DynamicText(new expressions.Expression()) - ]) - ]) - ]); - var binding = render(template).pop(); - expect(getText(fixture)).equal(''); - // Insert from null state - var data = {items: []}; - binding.context = getContext(data); - insert(binding, data.items, 0, [ - {name: 'One', subitems: [1, 2, 3]}, - {name: 'Two', subitems: [2, 4, 6]}, - {name: 'Three', subitems: [3, 6, 9]} - ]); - expect(getText(fixture)).equal('One123Two246Three369'); - // Insert new items - insert(binding, data.items, 1, [ - {name: 'Four', subitems: [4, 8, 12]}, - {name: 'Five', subitems: [5, 10, 15]} - ]); - expect(getText(fixture)).equal('One123Four4812Five51015Two246Three369'); - // Insert new items again - insert(binding, data.items, 2, [ - {name: 'Six', subitems: [6, 12, 18]} - ]); - expect(getText(fixture)).equal('One123Four4812Six61218Five51015Two246Three369'); - }); + it('inserts to outer nested each', function() { + var template = new saddle.Template([ + new saddle.EachBlock(new FakeExpression('items'), [ + new saddle.DynamicText(new FakeExpression('name')), + new saddle.EachBlock(new FakeExpression('subitems'), [ + new saddle.DynamicText(new FakeExpression()) + ]) + ]) + ]); + var binding = render(template).pop(); + expect(getText(fixture)).equal(''); + // Insert from null state + var data = {items: []}; + binding.context = getContext(data); + insert(binding, data.items, 0, [ + {name: 'One', subitems: [1, 2, 3]}, + {name: 'Two', subitems: [2, 4, 6]}, + {name: 'Three', subitems: [3, 6, 9]} + ]); + expect(getText(fixture)).equal('One123Two246Three369'); + // Insert new items + insert(binding, data.items, 1, [ + {name: 'Four', subitems: [4, 8, 12]}, + {name: 'Five', subitems: [5, 10, 15]} + ]); + expect(getText(fixture)).equal('One123Four4812Five51015Two246Three369'); + // Insert new items again + insert(binding, data.items, 2, [ + {name: 'Six', subitems: [6, 12, 18]} + ]); + expect(getText(fixture)).equal('One123Four4812Six61218Five51015Two246Three369'); + }); - it('removes from outer nested each', function() { - var template = new saddle.Template([ - new saddle.EachBlock(new expressions.Expression('items'), [ - new saddle.DynamicText(new expressions.Expression('name')), - new saddle.EachBlock(new expressions.Expression('subitems'), [ - new saddle.DynamicText(new expressions.Expression()) - ]) - ]) - ]); - var data = {items: [ - {name: 'One', subitems: [1, 2, 3]}, - {name: 'Two', subitems: [2, 4, 6]}, - {name: 'Three', subitems: [3, 6, 9]} - ]}; - var binding = render(template, data).pop(); - expect(getText(fixture)).equal('One123Two246Three369'); - binding.context = getContext(data); - // Remove inner item - remove(binding, data.items, 1, 1); - expect(getText(fixture)).equal('One123Three369'); - // Remove multiple remaining - remove(binding, data.items, 0, 2); - expect(getText(fixture)).equal(''); - }); + it('removes from outer nested each', function() { + var template = new saddle.Template([ + new saddle.EachBlock(new FakeExpression('items'), [ + new saddle.DynamicText(new FakeExpression('name')), + new saddle.EachBlock(new FakeExpression('subitems'), [ + new saddle.DynamicText(new FakeExpression()) + ]) + ]) + ]); + var data = {items: [ + {name: 'One', subitems: [1, 2, 3]}, + {name: 'Two', subitems: [2, 4, 6]}, + {name: 'Three', subitems: [3, 6, 9]} + ]}; + var binding = render(template, data).pop(); + expect(getText(fixture)).equal('One123Two246Three369'); + binding.context = getContext(data); + // Remove inner item + remove(binding, data.items, 1, 1); + expect(getText(fixture)).equal('One123Three369'); + // Remove multiple remaining + remove(binding, data.items, 0, 2); + expect(getText(fixture)).equal(''); + }); - it('moves to outer nested each', function() { - var template = new saddle.Template([ - new saddle.EachBlock(new expressions.Expression('items'), [ - new saddle.DynamicText(new expressions.Expression('name')), - new saddle.EachBlock(new expressions.Expression('subitems'), [ - new saddle.DynamicText(new expressions.Expression()) - ]) - ]) - ]); - var data = {items: [ - {name: 'One', subitems: [1, 2, 3]}, - {name: 'Two', subitems: [2, 4, 6]}, - {name: 'Three', subitems: [3, 6, 9]} - ]}; - var binding = render(template, data).pop(); - expect(getText(fixture)).equal('One123Two246Three369'); - binding.context = getContext(data); - // Move one item - move(binding, data.items, 1, 2, 1); - expect(getText(fixture)).equal('One123Three369Two246'); - // Move multiple items - move(binding, data.items, 1, 0, 2); - expect(getText(fixture)).equal('Three369Two246One123'); - }); + it('moves to outer nested each', function() { + var template = new saddle.Template([ + new saddle.EachBlock(new FakeExpression('items'), [ + new saddle.DynamicText(new FakeExpression('name')), + new saddle.EachBlock(new FakeExpression('subitems'), [ + new saddle.DynamicText(new FakeExpression()) + ]) + ]) + ]); + var data = {items: [ + {name: 'One', subitems: [1, 2, 3]}, + {name: 'Two', subitems: [2, 4, 6]}, + {name: 'Three', subitems: [3, 6, 9]} + ]}; + var binding = render(template, data).pop(); + expect(getText(fixture)).equal('One123Two246Three369'); + binding.context = getContext(data); + // Move one item + move(binding, data.items, 1, 2, 1); + expect(getText(fixture)).equal('One123Three369Two246'); + // Move multiple items + move(binding, data.items, 1, 0, 2); + expect(getText(fixture)).equal('Three369Two246One123'); + }); - it('updates an if inside an each', function() { - var template = new saddle.Template([ - new saddle.EachBlock(new expressions.Expression('items'), [ - new saddle.ConditionalBlock([ - new expressions.Expression('flag'), - new expressions.ElseExpression() - ], [ - [new saddle.Text('A')], - [new saddle.Text('B')] - ]) - ]) - ]); - var data = {items: [0, 1], flag: true}; - var bindings = render(template, data); - expect(getText(fixture)).equal('AA'); - - var eachBinding = bindings[4]; - var if1Binding = bindings[2]; - var if2Binding = bindings[0]; - - data.flag = false; - if1Binding.update(); - if2Binding.update(); - expect(getText(fixture)).equal('BB'); - - remove(eachBinding, data.items, 0, 1); - expect(getText(fixture)).equal('B'); + it('updates an if inside an each', function() { + var template = new saddle.Template([ + new saddle.EachBlock(new FakeExpression('items'), [ + new saddle.ConditionalBlock([ + new FakeExpression('flag'), + new FakeElseExpression() + ], [ + [new saddle.Text('A')], + [new saddle.Text('B')] + ]) + ]) + ]); + var data = {items: [0, 1], flag: true}; + var bindings = render(template, data); + expect(getText(fixture)).equal('AA'); + + var eachBinding = bindings[4]; + var if1Binding = bindings[2]; + var if2Binding = bindings[0]; + + data.flag = false; + if1Binding.update(); + if2Binding.update(); + expect(getText(fixture)).equal('BB'); + + remove(eachBinding, data.items, 0, 1); + expect(getText(fixture)).equal('B'); + }); + } }); -} +}); function getContext(data, bindings) { - var contextMeta = new expressions.ContextMeta(); + var contextMeta = new FakeContextMeta(); contextMeta.addBinding = function(binding) { if (bindings) { bindings.push(binding); } }; - return new expressions.Context(contextMeta, data); + return new FakeContext(contextMeta, data); } function removeChildren(node) { @@ -1179,12 +1181,12 @@ function getChildren(node) { function getText(node) { return node.textContent; } -if (!document.createTextNode('x').textContent) { - // IE only supports innerText, and it sometimes returns extra whitespace - getText = function(node) { - return node.innerText.replace(/\s/g, ''); - }; -} +// if (!document.createTextNode('x').textContent) { +// // IE only supports innerText, and it sometimes returns extra whitespace +// getText = function(node) { +// return node.innerText.replace(/\s/g, ''); +// }; +// } function insert(binding, array, index, items) { array.splice.apply(array, [index, 0].concat(items)); @@ -1199,3 +1201,93 @@ function move(binding, array, from, to, howMany) { array.splice.apply(array, [to, 0].concat(values)); binding.move(from, to, howMany); } + +function FakeExpression(source) { + this.source = source; +} +FakeExpression.prototype.toString = function() { + return this.source; +}; +FakeExpression.prototype.get = function(context) { + return ((this.source == null) + ? context.data + : context._get(this.source) + ); +}; +FakeExpression.prototype.truthy = function(context) { + return templateTruthy(this.get(context)); +}; +FakeExpression.prototype.module = 'expressions'; +FakeExpression.prototype.type = 'Expression'; +// FakeExpression.prototype.serialize = function() { +// return serializeObject.instance(this, this.source); +// }; + +function FakeElseExpression() {} +FakeElseExpression.prototype = new FakeExpression(); +FakeElseExpression.prototype.truthy = function() { + return true; +}; +FakeElseExpression.prototype.type = 'ElseExpression'; + +function templateTruthy(value) { + return (Array.isArray(value)) ? value.length > 0 : !!value; +} + +function FakeContext(meta, data, parent) { + this.meta = meta; + this.data = data; + this.parent = parent; +} +FakeContext.prototype = Object.create(FakeExpression.prototype); +FakeContext.prototype.constructor = FakeContext; +FakeContext.prototype.addBinding = function(binding) { + this.meta.addBinding(binding); +}; +FakeContext.prototype.removeBinding = function(binding) { + this.meta.removeBinding(binding); +}; +FakeContext.prototype.removeNode = function(node) { + this.meta.removeNode(node); +}; +FakeContext.prototype.child = function(expression) { + var data = expression.get(this); + return new FakeContext(this.meta, data, this); +}; +FakeContext.prototype.eachChild = function(expression, index) { + var data = expression.get(this)[index]; + return new FakeContext(this.meta, data, this); +}; +FakeContext.prototype._get = function(property) { + return (this.data && this.data.hasOwnProperty(property)) ? + this.data[property] : + this.parent && this.parent._get(property); +}; +FakeContext.prototype.pause = function() { + this.meta.pauseCount++; +}; +FakeContext.prototype.unpause = function() { + if (--this.meta.pauseCount) return; + this.flush(); +}; +FakeContext.prototype.flush = function() { + var pending = this.meta.pending; + var len = pending.length; + if (!len) return; + this.meta.pending = []; + for (var i = 0; i < len; i++) { + pending[i](); + } +}; +FakeContext.prototype.queue = function(cb) { + this.meta.pending.push(cb); +}; + +function noop() {} +function FakeContextMeta() { + this.addBinding = noop; + this.removeBinding = noop; + this.removeNode = noop; + this.pauseCount = 0; + this.pending = []; +}; diff --git a/test/server/templates/templates.server.mocha.js b/test/server/templates/templates.server.mocha.js index dd44db977..aa17a6c86 100644 --- a/test/server/templates/templates.server.mocha.js +++ b/test/server/templates/templates.server.mocha.js @@ -1,11 +1,12 @@ var expect = require('chai').expect; -var templates = require('../index'); -var expressions = require('../example/expressions'); +var templates = require('../../../lib/templates/templates'); +var expressions = require('../../../lib/templates/expressions'); function test(createTemplate) { return function() { var serialized = createTemplate().serialize(); var expected = createTemplate.toString() + .replace(/,\n\s*/g, ', ') // Remove leading & trailing whitespace and newlines .replace(/\s*\r?\n\s*/g, '') // Remove the wrapping function boilerplate From c443663dd6a8d44d697594b8eaf7c2d3cbc704a2 Mon Sep 17 00:00:00 2001 From: Eric Hwang Date: Thu, 15 Jun 2023 22:09:41 -0700 Subject: [PATCH 06/11] Move code and tests from derbyjs/derby-parsing into this repo, as-is --- lib/parsing/createPathExpression.js | 252 ++++++++ lib/parsing/index.js | 851 ++++++++++++++++++++++++- lib/parsing/markup.js | 53 ++ test/all/parsing/dependencies.mocha.js | 747 ++++++++++++++++++++++ test/all/parsing/expressions.mocha.js | 411 ++++++++++++ test/all/parsing/templates.mocha.js | 778 ++++++++++++++++++++++ test/all/parsing/truthy.mocha.js | 43 ++ 7 files changed, 3133 insertions(+), 2 deletions(-) create mode 100644 lib/parsing/createPathExpression.js create mode 100644 lib/parsing/markup.js create mode 100644 test/all/parsing/dependencies.mocha.js create mode 100644 test/all/parsing/expressions.mocha.js create mode 100644 test/all/parsing/templates.mocha.js create mode 100644 test/all/parsing/truthy.mocha.js diff --git a/lib/parsing/createPathExpression.js b/lib/parsing/createPathExpression.js new file mode 100644 index 000000000..a0ade7c28 --- /dev/null +++ b/lib/parsing/createPathExpression.js @@ -0,0 +1,252 @@ +var derbyTemplates = require('derby-templates'); +var expressions = derbyTemplates.expressions; +var operatorFns = derbyTemplates.operatorFns; +var esprima = require('esprima-derby'); +var Syntax = esprima.Syntax; + +module.exports = createPathExpression; + +function createPathExpression(source) { + var node = esprima.parse(source).expression; + return reduce(node); +} + +function reduce(node) { + var type = node.type; + if (type === Syntax.MemberExpression) { + return reduceMemberExpression(node); + } else if (type === Syntax.Identifier) { + return reduceIdentifier(node); + } else if (type === Syntax.ThisExpression) { + return reduceThis(node); + } else if (type === Syntax.CallExpression) { + return reduceCallExpression(node); + } else if (type === Syntax.Literal) { + return reduceLiteral(node); + } else if (type === Syntax.UnaryExpression) { + return reduceUnaryExpression(node); + } else if (type === Syntax.BinaryExpression || type === Syntax.LogicalExpression) { + return reduceBinaryExpression(node); + } else if (type === Syntax.ConditionalExpression) { + return reduceConditionalExpression(node); + } else if (type === Syntax.ArrayExpression) { + return reduceArrayExpression(node); + } else if (type === Syntax.ObjectExpression) { + return reduceObjectExpression(node); + } else if (type === Syntax.SequenceExpression) { + return reduceSequenceExpression(node); + } else if (type === Syntax.NewExpression) { + return reduceNewExpression(node); + } + unexpected(node); +} + +function reduceMemberExpression(node, afterSegments) { + if (node.computed) { + // Square brackets + if (node.property.type === Syntax.Literal) { + return reducePath(node, node.property.value, afterSegments); + } + var before = reduce(node.object); + var inside = reduce(node.property); + return new expressions.BracketsExpression(before, inside, afterSegments); + } + // Dot notation + if (node.property.type === Syntax.Identifier) { + return reducePath(node, node.property.name); + } + unexpected(node); +} + +function reducePath(node, segment, afterSegments) { + var segments = [segment]; + if (afterSegments) segments = segments.concat(afterSegments); + var relative = false; + while (node = node.object) { + if (node.type === Syntax.MemberExpression) { + if (node.computed) { + return reduceMemberExpression(node, segments); + } else if (node.property.type === Syntax.Identifier) { + segments.unshift(node.property.name); + } else { + unexpected(node); + } + } else if (node.type === Syntax.Identifier) { + segments.unshift(node.name); + } else if (node.type === Syntax.ThisExpression) { + relative = true; + } else if (node.type === Syntax.CallExpression) { + return reduceCallExpression(node, segments); + } else if (node.type === Syntax.SequenceExpression) { + return reduceSequenceExpression(node, segments); + } else if (node.type === Syntax.NewExpression) { + return reduceNewExpression(node, segments); + } else { + unexpected(node); + } + } + return (relative) ? + new expressions.RelativePathExpression(segments) : + createSegmentsExpression(segments); +} + +function reduceIdentifier(node) { + var segments = [node.name]; + return createSegmentsExpression(segments); +} + +function reduceThis(node) { + var segments = []; + return new expressions.RelativePathExpression(segments); +} + +function createSegmentsExpression(segments) { + var firstSegment = segments[0]; + var firstChar = firstSegment.charAt && firstSegment.charAt(0); + + if (firstChar === '#') { + var alias = firstSegment; + segments.shift(); + return new expressions.AliasPathExpression(alias, segments); + + } else if (firstChar === '@') { + var attribute = firstSegment.slice(1); + segments.shift(); + return new expressions.AttributePathExpression(attribute, segments); + + } else { + return new expressions.PathExpression(segments); + } +} + +function reduceCallExpression(node, afterSegments) { + return reduceFnExpression(node, afterSegments, expressions.FnExpression); +} + +function reduceNewExpression(node, afterSegments) { + return reduceFnExpression(node, afterSegments, expressions.NewExpression); +} + +function reduceFnExpression(node, afterSegments, Constructor) { + var args = node.arguments.map(reduce); + var callee = node.callee; + if (callee.type === Syntax.Identifier) { + if (callee.name === '$at') { + return new expressions.ScopedModelExpression(args[0]); + } + var segments = [callee.name]; + return new Constructor(segments, args, afterSegments); + } else if (callee.type === Syntax.MemberExpression) { + var segments = reduceMemberExpression(callee).segments; + return new Constructor(segments, args, afterSegments); + } else { + unexpected(node); + } +} + +function reduceLiteral(node) { + return new expressions.LiteralExpression(node.value); +} + +function reduceUnaryExpression(node) { + // `-` and `+` can be either unary or binary, so all unary operators are + // postfixed with `U` to differentiate + var operator = node.operator + 'U'; + var expression = reduce(node.argument); + if (expression instanceof expressions.LiteralExpression) { + var fn = operatorFns.get[operator]; + expression.value = fn(expression.value); + return expression; + } + return new expressions.OperatorExpression(operator, [expression]); +} + +function reduceBinaryExpression(node) { + var operator = node.operator; + var left = reduce(node.left); + var right = reduce(node.right); + if ( + left instanceof expressions.LiteralExpression && + right instanceof expressions.LiteralExpression + ) { + var fn = operatorFns.get[operator]; + var value = fn(left.value, right.value); + return new expressions.LiteralExpression(value); + } + return new expressions.OperatorExpression(operator, [left, right]); +} + +function reduceConditionalExpression(node) { + var test = reduce(node.test); + var consequent = reduce(node.consequent); + var alternate = reduce(node.alternate); + if ( + test instanceof expressions.LiteralExpression && + consequent instanceof expressions.LiteralExpression && + alternate instanceof expressions.LiteralExpression + ) { + var value = (test.value) ? consequent.value : alternate.value; + return new expressions.LiteralExpression(value); + } + return new expressions.OperatorExpression('?', [test, consequent, alternate]); +} + +function reduceArrayExpression(node) { + var literal = []; + var items = []; + var isLiteral = true; + for (var i = 0; i < node.elements.length; i++) { + var expression = reduce(node.elements[i]); + items.push(expression); + if (isLiteral && expression instanceof expressions.LiteralExpression) { + literal.push(expression.value); + } else { + isLiteral = false; + } + } + return (isLiteral) ? + new expressions.LiteralExpression(literal) : + new expressions.ArrayExpression(items); +} + +function reduceObjectExpression(node) { + var literal = {}; + var properties = {}; + var isLiteral = true; + for (var i = 0; i < node.properties.length; i++) { + var property = node.properties[i]; + var key = getKeyName(property.key); + var expression = reduce(property.value); + properties[key] = expression; + if (isLiteral && expression instanceof expressions.LiteralExpression) { + literal[key] = expression.value; + } else { + isLiteral = false; + } + } + return (isLiteral) ? + new expressions.LiteralExpression(literal) : + new expressions.ObjectExpression(properties); +} + +function getKeyName(key) { + return (key.type === Syntax.Identifier) ? key.name : + (key.type === Syntax.Literal) ? key.value : + unexpected(key); +} + +function reduceSequenceExpression(node, afterSegments) { + // Note that sequence expressions are not reduced to a literal if they only + // contain literals. There isn't any utility to such an expression, so it + // isn't worth optimizing. + // + // The fact that expressions separated by commas always parse into a sequence + // is relied upon in parsing template tags that have comma-separated + // arguments following a keyword + var args = node.expressions.map(reduce); + return new expressions.SequenceExpression(args, afterSegments); +} + +function unexpected(node) { + throw new Error('Unexpected Esprima node: ' + JSON.stringify(node, null, 2)); +} diff --git a/lib/parsing/index.js b/lib/parsing/index.js index a19ac59d5..11f5b28f2 100644 --- a/lib/parsing/index.js +++ b/lib/parsing/index.js @@ -1,9 +1,856 @@ -// TODO: Refactor and include derby-parsing module in derby itself -exports = module.exports = require('derby-parsing'); var htmlUtil = require('html-util'); var path = require('path'); var App = require('../App'); +var htmlUtil = require('html-util'); +var derbyTemplates = require('derby-templates'); +var templates = derbyTemplates.templates; +var expressions = derbyTemplates.expressions; +var createPathExpression = require('./createPathExpression'); +var markup = require('./markup'); + +exports.createTemplate = createTemplate; +exports.createStringTemplate = createStringTemplate; +exports.createExpression = createExpression; +exports.createPathExpression = createPathExpression; +exports.markup = markup; + +// View.prototype._parse is defined here, so that it doesn't have to +// be included in the client if templates are all parsed server-side +templates.View.prototype._parse = function() { + // Wrap parsing in a try / catch to add context to message when throwing + var template; + try { + if (this.literal) { + var source = (this.unminified) ? this.source : + // Remove leading and trailing whitespace only lines by default + this.source.replace(/^\s*\n/, '').replace(/\s*$/, ''); + template = new templates.Text(source); + } else if (this.string) { + template = createStringTemplate(this.source, this); + } else { + var source = (this.unminified) ? this.source : + htmlUtil.minify(this.source).replace(/&sp;/g, ' '); + template = createTemplate(source, this); + } + } catch (err) { + var message = '\n\nWithin template "' + this.name + '":\n' + this.source; + throw appendErrorMessage(err, message); + } + this.template = template; + return template; +}; + +// Modified and shared among the following parse functions. It's OK for this +// to be shared at the module level, since it is only used by synchronous code +var parseNode; + +function createTemplate(source, view) { + source = escapeBraced(source); + parseNode = new ParseNode(view); + htmlUtil.parse(source, { + start: parseHtmlStart + , end: parseHtmlEnd + , text: parseHtmlText + , comment: parseHtmlComment + , other: parseHtmlOther + }); + // Allow for certain elements at the end of a template to not be closed. This + // is especially important so that and tags can be omitted, + // since Derby sends an additional script tag after the HTML for the page + while (parseNode.parent) { + parseNode = parseNode.parent; + var last = parseNode.last(); + if (last instanceof templates.Element) { + if (last.tagName === 'body' || last.tagName === 'html') { + last.notClosed = true; + last.endTag = ''; + continue; + } else { + throw new Error('Missing closing HTML tag: ' + last.endTag); + } + } + unexpected(); + } + return new templates.Template(parseNode.content); +} + +function createStringTemplate(source, view) { + source = escapeBraced(source); + parseNode = new ParseNode(view); + parseText(source, parseTextLiteral, parseTextExpression, 'string'); + return new templates.Template(parseNode.content); +} + +function parseHtmlStart(tag, tagName, attributes, selfClosing) { + var lowerTagName = tagName.toLowerCase(); + var hooks; + if (lowerTagName !== 'view' && !viewForTagName(lowerTagName)) { + hooks = elementHooksFromAttributes(attributes); + } + var attributesMap = parseAttributes(attributes); + var namespaceUri = (lowerTagName === 'svg') ? + templates.NAMESPACE_URIS.svg : parseNode.namespaceUri; + var Constructor = templates.Element; + if (lowerTagName === 'tag') { + Constructor = templates.DynamicElement; + tagName = attributesMap.is; + delete attributesMap.is; + } + if (selfClosing || templates.VOID_ELEMENTS[lowerTagName]) { + var element = new Constructor(tagName, attributesMap, null, hooks, selfClosing, null, namespaceUri); + parseNode.content.push(element); + parseElementClose(lowerTagName); + } else { + parseNode = parseNode.child(); + parseNode.namespaceUri = namespaceUri; + var element = new Constructor(tagName, attributesMap, parseNode.content, hooks, selfClosing, null, namespaceUri); + parseNode.parent.content.push(element); + } +} + +function parseAttributes(attributes) { + var attributesMap; + for (var key in attributes) { + if (!attributesMap) attributesMap = {}; + + var value = attributes[key]; + var match = /([^:]+):[^:]/.exec(key); + var nsUri = match && templates.NAMESPACE_URIS[match[1]]; + if (value === '' || typeof value !== 'string') { + attributesMap[key] = new templates.Attribute(value, nsUri); + continue; + } + + parseNode = parseNode.child(); + parseText(value, parseTextLiteral, parseTextExpression, 'attribute'); + + if (parseNode.content.length === 1) { + var item = parseNode.content[0]; + attributesMap[key] = + (item instanceof templates.Text) ? new templates.Attribute(item.data, nsUri) : + (item instanceof templates.DynamicText) ? + (item.expression instanceof expressions.LiteralExpression) ? + new templates.Attribute(item.expression.value, nsUri) : + new templates.DynamicAttribute(item.expression, nsUri) : + new templates.DynamicAttribute(item, nsUri); + + } else if (parseNode.content.length > 1) { + var template = new templates.Template(parseNode.content, value); + attributesMap[key] = new templates.DynamicAttribute(template, nsUri); + + } else { + throw new Error('Error parsing ' + key + ' attribute: ' + value); + } + + parseNode = parseNode.parent; + } + return attributesMap; +} + +function parseHtmlEnd(tag, tagName) { + parseNode = parseNode.parent; + var last = parseNode.last(); + if (!( + (last instanceof templates.DynamicElement && tagName.toLowerCase() === 'tag') || + (last instanceof templates.Element && last.tagName === tagName) + )) { + throw new Error('Mismatched closing HTML tag: ' + tag); + } + parseElementClose(tagName); +} + +function parseElementClose(tagName) { + if (tagName === 'view') { + var element = parseNode.content.pop(); + parseViewElement(element); + return; + } + var view = viewForTagName(tagName); + if (view) { + var element = parseNode.content.pop(); + parseNamedViewElement(element, view, view.name); + return; + } + var element = parseNode.last(); + markup.emit('element', element); + markup.emit('element:' + tagName, element); +} + +function viewForTagName(tagName) { + return parseNode.view && parseNode.view.views.tagMap[tagName]; +} + +function parseHtmlText(data, isRawText) { + var environment = (isRawText) ? 'string' : 'html'; + parseText(data, parseTextLiteral, parseTextExpression, environment); +} + +function parseHtmlComment(tag, data) { + // Only output comments that start with `` + if (!htmlUtil.isConditionalComment(tag)) return; + var comment = new templates.Comment(data); + parseNode.content.push(comment); +} + +var doctypeRegExp = /^/i; + +function parseHtmlOther(tag) { + var match = doctypeRegExp.exec(tag); + if (match) { + var name = match[1]; + var idType = match[2] && match[2].toLowerCase(); + var publicId, systemId; + if (idType === 'public') { + publicId = match[3]; + systemId = match[4]; + } else if (idType === 'system') { + systemId = match[3]; + } + var doctype = new templates.Doctype(name, publicId, systemId); + parseNode.content.push(doctype); + } else { + unexpected(tag); + } +} + +function parseTextLiteral(data) { + var text = new templates.Text(data); + parseNode.content.push(text); +} + +function parseTextExpression(source, environment) { + var expression = createExpression(source); + if (expression.meta.blockType) { + parseBlockExpression(expression); + } else if (expression.meta.valueType === 'view') { + parseViewExpression(expression); + } else if (expression.meta.unescaped && environment === 'html') { + var html = new templates.DynamicHtml(expression); + parseNode.content.push(html); + } else { + var text = new templates.DynamicText(expression); + parseNode.content.push(text); + } +} + +function parseBlockExpression(expression) { + var blockType = expression.meta.blockType; + + // Block ending + if (expression.meta.isEnd) { + parseNode = parseNode.parent; + // Validate that the block ending matches an appropriate block start + var last = parseNode.last(); + var lastExpression = last && (last.expression || (last.expressions && last.expressions[0])); + if (!( + lastExpression && + (blockType === 'end' && lastExpression.meta.blockType) || + (blockType === lastExpression.meta.blockType) + )) { + throw new Error('Mismatched closing template tag: ' + expression.meta.source); + } + + // Continuing block + } else if (blockType === 'else' || blockType === 'else if') { + parseNode = parseNode.parent; + var last = parseNode.last(); + parseNode = parseNode.child(); + + if (last instanceof templates.ConditionalBlock) { + last.expressions.push(expression); + last.contents.push(parseNode.content); + } else if (last instanceof templates.EachBlock) { + if (blockType !== 'else') unexpected(expression.meta.source); + last.elseContent = parseNode.content; + } else { + unexpected(expression.meta.source); + } + + // Block start + } else { + var nextNode = parseNode.child(); + var block; + if (blockType === 'if' || blockType === 'unless') { + block = new templates.ConditionalBlock([expression], [nextNode.content]); + } else if (blockType === 'each') { + block = new templates.EachBlock(expression, nextNode.content); + } else { + block = new templates.Block(expression, nextNode.content); + } + parseNode.content.push(block); + parseNode = nextNode; + } +} + +function parseViewElement(element) { + // TODO: "name" is deprecated in lieu of "is". Remove "name" in Derby 0.6.0 + var nameAttribute = element.attributes.is || element.attributes.name; + if (!nameAttribute) { + throw new Error('The element requires an "is" attribute'); + } + delete element.attributes.is; + delete element.attributes.name; + + if (nameAttribute.expression) { + var viewAttributes = viewAttributesFromElement(element); + var componentHooks = componentHooksFromAttributes(viewAttributes); + var remaining = element.content || []; + var viewInstance = createDynamicViewInstance(nameAttribute.expression, viewAttributes, componentHooks.hooks, componentHooks.initHooks); + finishParseViewElement(viewAttributes, remaining, viewInstance); + } else { + var name = nameAttribute.data; + var view = findView(name); + parseNamedViewElement(element, view, name); + } +} + +function findView(name) { + var view = parseNode.view.views.find(name, parseNode.view.namespace); + if (!view) { + var message = parseNode.view.views.findErrorMessage(name); + throw new Error(message); + } + return view; +} + +function parseNamedViewElement(element, view, name) { + var viewAttributes = viewAttributesFromElement(element); + var componentHooks = componentHooksFromAttributes(viewAttributes); + var remaining = parseContentAttributes(element.content, view, viewAttributes); + var viewInstance = new templates.ViewInstance(view.registeredName, viewAttributes, componentHooks.hooks, componentHooks.initHooks); + finishParseViewElement(viewAttributes, remaining, viewInstance); +} + +function createDynamicViewInstance(expression, attributes, hooks, initHooks) { + var viewInstance = new templates.DynamicViewInstance(expression, attributes, hooks, initHooks); + // Wrap the viewInstance in a block with the same expression, so that it is + // re-rendered when any of its dependencies change + return new templates.Block(expression, [viewInstance]); +} + +function finishParseViewElement(viewAttributes, remaining, viewInstance) { + setContentAttribute(viewAttributes, remaining); + delete viewAttributes.within; + parseNode.content.push(viewInstance); +} + +function setContentAttribute(attributes, content) { + if (attributes.hasOwnProperty('content')) return; + if (!content.length) return; + attributes.content = attributeValueFromContent(content, attributes.within); +} + +function attributeValueFromContent(content, isWithin) { + // Optimize common cases where content can be a literal or a single expression + if (content.length === 1) { + var item = content[0]; + if (item instanceof templates.Text) { + return item.data; + } + if (item instanceof templates.DynamicText) { + var expression = item.expression; + if (expression instanceof expressions.LiteralExpression) { + return expression.value; + } + // In the case of within attributes, always use a template, never an + // expression. A within value depends on the rendering context, so we + // cannot get a single value for the attribute and store it on the + // component model when the component is initialized + if (isWithin) return item; + // Create an expression in cases where it is safe to do so. This allows + // derby to get the intended value and store it on the component model + return new expressions.ViewParentExpression(expression); + } + } + // Otherwise, wrap a template as needed for the context + var template = new templates.Template(content); + return (isWithin) ? template : new templates.ViewParent(template); +} + +function viewAttributesFromElement(element) { + var viewAttributes = {}; + for (var key in element.attributes) { + var attribute = element.attributes[key]; + var camelCased = dashToCamelCase(key); + viewAttributes[camelCased] = + (attribute.expression instanceof templates.Template) ? + new templates.ViewParent(attribute.expression) : + (attribute.expression instanceof expressions.Expression) ? + new expressions.ViewParentExpression(attribute.expression) : + attribute.data; + } + return viewAttributes; +} + +function parseAsAttribute(key, value) { + var expression = createPathExpression(value); + if (!(expression instanceof expressions.PathExpression)) { + throw new Error(key + ' attribute must be a path: ' + key + '="' + value + '"'); + } + return expression.segments; +} + +function parseAsObjectAttribute(key, value) { + var expression = createPathExpression(value); + if (!( + expression instanceof expressions.SequenceExpression && + expression.args.length === 2 && + expression.args[0] instanceof expressions.PathExpression + )) { + throw new Error(key + ' attribute requires a path and a key argument: ' + key + '="' + value + '"'); + } + var segments = expression.args[0].segments; + var expression = expression.args[1]; + return {segments: segments, expression: expression}; +} + +function parseOnAttribute(key, value) { + // TODO: Argument checking + return createPathExpression(value); +} + +function elementHooksFromAttributes(attributes, type) { + if (!attributes) return; + var hooks = []; + + for (var key in attributes) { + var value = attributes[key]; + + // Parse `as` assignments + if (key === 'as') { + var segments = parseAsAttribute(key, value); + hooks.push(new templates.AsProperty(segments)); + delete attributes[key]; + continue; + } + if (key === 'as-array') { + var segments = parseAsAttribute(key, value); + hooks.push(new templates.AsArray(segments)); + delete attributes[key]; + continue; + } + if (key === 'as-object') { + var parsed = parseAsObjectAttribute(key, value); + hooks.push(new templates.AsObject(parsed.segments, parsed.expression)); + delete attributes[key]; + continue; + } + + // Parse event listeners + var match = /^on-(.+)/.exec(key); + var eventName = match && match[1]; + if (eventName) { + var expression = parseOnAttribute(key, value); + hooks.push(new templates.ElementOn(eventName, expression)); + delete attributes[key]; + } + } + + if (hooks.length) return hooks; +} + +function componentHooksFromAttributes(attributes) { + if (!attributes) return {}; + var hooks = []; + var initHooks = []; + + for (var key in attributes) { + var value = attributes[key]; + + // Parse `as` assignments + if (key === 'as') { + var segments = parseAsAttribute(key, value); + hooks.push(new templates.AsPropertyComponent(segments)); + delete attributes[key]; + continue; + } + if (key === 'asArray') { + var segments = parseAsAttribute('as-array', value); + hooks.push(new templates.AsArrayComponent(segments)); + delete attributes[key]; + continue; + } + if (key === 'asObject') { + var parsed = parseAsObjectAttribute('as-object', value); + hooks.push(new templates.AsObjectComponent(parsed.segments, parsed.expression)); + delete attributes[key]; + continue; + } + + // Parse event listeners + var match = /^on([A-Z_].*)/.exec(key); + var eventName = match && match[1].charAt(0).toLowerCase() + match[1].slice(1); + if (eventName) { + var expression = parseOnAttribute(key, value); + initHooks.push(new templates.ComponentOn(eventName, expression)); + delete attributes[key]; + } + } + + return { + hooks: (hooks.length) ? hooks : null, + initHooks: (initHooks.length) ? initHooks : null + }; +} + +function dashToCamelCase(string) { + return string.replace(/-./g, function(match) { + return match.charAt(1).toUpperCase(); + }); +} + +function parseContentAttributes(content, view, viewAttributes) { + var remaining = []; + if (!content) return remaining; + for (var i = 0, len = content.length; i < len; i++) { + var item = content[i]; + var name = (item instanceof templates.Element) && item.tagName; + + if (name === 'attribute') { + var name = parseNameAttribute(item); + parseAttributeElement(item, name, viewAttributes); + + } else if (view.attributesMap && view.attributesMap[name]) { + parseAttributeElement(item, name, viewAttributes); + + } else if (name === 'array') { + var name = parseNameAttribute(item); + parseArrayElement(item, name, viewAttributes); + + } else if (view.arraysMap && view.arraysMap[name]) { + parseArrayElement(item, view.arraysMap[name], viewAttributes); + + } else { + remaining.push(item); + } + } + return remaining; +} + +function parseNameAttribute(element) { + // TODO: "name" is deprecated in lieu of "is". Remove "name" in Derby 0.6.0 + var nameAttribute = element.attributes.is || element.attributes.name; + var name = nameAttribute.data; + if (!name) { + throw new Error('The <' + element.tagName + '> element requires a literal "is" attribute'); + } + delete element.attributes.is; + delete element.attributes.name; + return name; +} + +function parseAttributeElement(element, name, viewAttributes) { + var camelName = dashToCamelCase(name); + var isWithin = element.attributes && element.attributes.within; + viewAttributes[camelName] = attributeValueFromContent(element.content, isWithin); +} + +function createAttributesExpression(attributes) { + var dynamicAttributes = {}; + var literalAttributes = {}; + var isLiteral = true; + for (var key in attributes) { + var attribute = attributes[key]; + if (attribute instanceof expressions.Expression) { + dynamicAttributes[key] = attribute; + isLiteral = false; + } else if (attribute instanceof templates.Template) { + dynamicAttributes[key] = new expressions.DeferRenderExpression(attribute); + isLiteral = false; + } else { + dynamicAttributes[key] = new expressions.LiteralExpression(attribute); + literalAttributes[key] = attribute; + } + } + return (isLiteral) ? + new expressions.LiteralExpression(literalAttributes) : + new expressions.ObjectExpression(dynamicAttributes); +} + +function parseArrayElement(element, name, viewAttributes) { + var attributes = viewAttributesFromElement(element); + setContentAttribute(attributes, element.content); + delete attributes.within; + var expression = createAttributesExpression(attributes); + var camelName = dashToCamelCase(name); + var viewAttribute = viewAttributes[camelName]; + + // If viewAttribute is already an ArrayExpression, push the expression for + // the current array element + if (viewAttribute instanceof expressions.ArrayExpression) { + viewAttribute.items.push(expression); + + // Alternatively, viewAttribute will be an array if its items have all been + // literal values thus far + } else if (Array.isArray(viewAttribute)) { + if (expression instanceof expressions.LiteralExpression) { + // If the current array element continues to be a literal value, push it + // on the existing array + viewAttribute.push(expression.value); + } else { + // However, if the array element produces a non-literal expression, + // convert the values in the array into an equivalent ArrayExpression of + // LiteralExpressions, then push on this expression as well + var items = []; + for (var i = 0; i < viewAttribute.length; i++) { + items[i] = new expressions.LiteralExpression(viewAttribute[i]); + } + items.push(expression); + viewAttributes[camelName] = new expressions.ArrayExpression(items); + } + + // For the first array element encountered, create a containing array or + // ArrayExpression. Create an array of raw values in the literal case and an + // ArrayExpression of expressions in the non-literal case + } else if (viewAttribute == null) { + viewAttributes[camelName] = (expression instanceof expressions.LiteralExpression) ? + [expression.value] : new expressions.ArrayExpression([expression]); + + } else { + unexpected(); + } +} + +function parseViewExpression(expression) { + // If there are multiple arguments separated by commas, they will get parsed + // as a SequenceExpression + var nameExpression, attributesExpression; + if (expression instanceof expressions.SequenceExpression) { + nameExpression = expression.args[0]; + attributesExpression = expression.args[1]; + } else { + nameExpression = expression; + } + + var viewAttributes = viewAttributesFromExpression(attributesExpression); + var componentHooks = componentHooksFromAttributes(viewAttributes); + + // A ViewInstance has a static name, and a DynamicViewInstance gets its name + // at render time + var viewInstance; + if (nameExpression instanceof expressions.LiteralExpression) { + var name = nameExpression.get(); + // Will throw if the view can't be found immediately + findView(name); + viewInstance = new templates.ViewInstance(name, viewAttributes, componentHooks.hooks, componentHooks.initHooks); + } else { + viewInstance = createDynamicViewInstance(nameExpression, viewAttributes, componentHooks.hooks, componentHooks.initHooks); + } + parseNode.content.push(viewInstance); +} + +function viewAttributesFromExpression(expression) { + if (!expression) return; + var object = (expression instanceof expressions.ObjectExpression) ? expression.properties : + (expression instanceof expressions.LiteralExpression) ? expression.value : null; + if (typeof object !== 'object') unexpected(); + + var viewAttributes = {}; + for (var key in object) { + var value = object[key]; + viewAttributes[key] = + (value instanceof expressions.LiteralExpression) ? value.value : + (value instanceof expressions.Expression) ? + new expressions.ViewParentExpression(value) : + value; + } + return viewAttributes; +} + +function ParseNode(view, parent) { + this.view = view; + this.parent = parent; + this.content = []; + this.namespaceUri = parent && parent.namespaceUri; +} +ParseNode.prototype.child = function() { + return new ParseNode(this.view, this); +}; +ParseNode.prototype.last = function() { + return this.content[this.content.length - 1]; +}; + +function escapeBraced(source) { + var out = ''; + parseText(source, onLiteral, onExpression, 'string'); + function onLiteral(text) { + out += text; + } + function onExpression(text) { + var escaped = text.replace(/[&<]/g, function(match) { + return (match === '&') ? '&' : '<'; + }); + out += '{{' + escaped + '}}'; + } + return out; +} + +function unescapeBraced(source) { + return source.replace(/(?:&|<)/g, function(match) { + return (match === '&') ? '&' : '<'; + }); +} + +function unescapeTextLiteral(text, environment) { + return (environment === 'html' || environment === 'attribute') ? + htmlUtil.unescapeEntities(text) : + text; +} + +function parseText(data, onLiteral, onExpression, environment) { + var current = data; + var last; + while (current) { + if (current === last) throw new Error('Error parsing template text: ' + data); + last = current; + + var start = current.indexOf('{{'); + if (start === -1) { + var unescapedCurrent = unescapeTextLiteral(current, environment); + onLiteral(unescapedCurrent); + return; + } + + var end = matchBraces(current, 2, start, '{', '}'); + if (end === -1) throw new Error('Mismatched braces in: ' + data); + + if (start > 0) { + var before = current.slice(0, start); + var unescapedBefore = unescapeTextLiteral(before, environment); + onLiteral(unescapedBefore); + } + + var inside = current.slice(start + 2, end - 2); + if (inside) { + var unescapedInside = unescapeBraced(inside); + unescapedInside = unescapeTextLiteral(unescapedInside, environment); + onExpression(unescapedInside, environment); + } + + current = current.slice(end); + } +} + +function matchBraces(text, num, i, openChar, closeChar) { + i += num; + while (num) { + var close = text.indexOf(closeChar, i); + var open = text.indexOf(openChar, i); + var hasClose = close !== -1; + var hasOpen = open !== -1; + if (hasClose && (!hasOpen || (close < open))) { + i = close + 1; + num--; + continue; + } else if (hasOpen) { + i = open + 1; + num++; + continue; + } else { + return -1; + } + } + return i; +} + +var blockRegExp = /^(if|unless|else if|each|with|on)\s+([\s\S]+?)(?:\s+as\s+([^,\s]+)\s*(?:,\s*(\S+))?)?$/; +var valueRegExp = /^(?:(view|unbound|bound|unescaped)\s+)?([\s\S]*)/; + +function createExpression(source) { + source = source.trim(); + var meta = new expressions.ExpressionMeta(source); + + // Parse block expression // + + // The block expressions `if`, `unless`, `else if`, `each`, `with`, and `on` + // must have a single blockType keyword and a path. They may have an optional + // alias assignment + var match = blockRegExp.exec(source); + var path, as, keyAs; + if (match) { + meta.blockType = match[1]; + path = match[2]; + as = match[3]; + keyAs = match[4]; + + // The blocks `else`, `unbound`, and `bound` may not have a path or alias + } else if (source === 'else' || source === 'unbound' || source === 'bound') { + meta.blockType = source; + + // Any source that starts with a `/` is treated as an end block. Either a + // `{{/}}` with no following characters or a `{{/if}}` style ending is valid + } else if (source.charAt(0) === '/') { + meta.isEnd = true; + meta.blockType = source.slice(1).trim() || 'end'; + + + // Parse value expression // + + // A value expression has zero or many keywords and an expression + } else { + path = source; + var keyword; + do { + match = valueRegExp.exec(path); + keyword = match[1]; + path = match[2]; + if (keyword === 'unescaped') { + meta.unescaped = true; + } else if (keyword === 'unbound' || keyword === 'bound') { + meta.bindType = keyword; + } else if (keyword) { + meta.valueType = keyword; + } + } while (keyword); + } + + // Wrap parsing in a try / catch to add context to message when throwing + var expression; + try { + expression = (path) ? + createPathExpression(path) : + new expressions.Expression(); + if (as) { + meta.as = parseAlias(as); + } + if (keyAs) { + meta.keyAs = parseAlias(keyAs); + } + } catch (err) { + var message = '\n\nWithin expression: ' + source; + throw appendErrorMessage(err, message); + } + expression.meta = meta; + return expression; +} + +function unexpected(source) { + throw new Error('Error parsing template: ' + source); +} + +function appendErrorMessage(err, message) { + if (err instanceof Error) { + err.message += message; + return err; + } + return new Error(err + message); +} + +function parseAlias(source) { + // Try parsing into a path expression. This throws on invalid expressions. + var expression = createPathExpression(source); + // Verify that it's an AliasPathExpression with no segments, i.e. that + // it has the format "#IDENTIFIER". + if (expression instanceof expressions.AliasPathExpression) { + if (expression.segments.length === 0) { + return expression.alias; + } + throw new Error('Alias must not have dots or brackets: ' + source); + } + throw new Error('Alias must be an identifier starting with "#": ' + source); +} + App.prototype.addViews = function(file, namespace) { var views = exports.parseViews(file, namespace); exports.registerParsedViews(this, views); diff --git a/lib/parsing/markup.js b/lib/parsing/markup.js new file mode 100644 index 000000000..c0fc143a8 --- /dev/null +++ b/lib/parsing/markup.js @@ -0,0 +1,53 @@ +var EventEmitter = require('events').EventEmitter; +var templates = require('derby-templates').templates; +var createPathExpression = require('./createPathExpression'); + +// TODO: Should be its own module + +var markup = module.exports = new MarkupParser(); + +function MarkupParser() { + EventEmitter.call(this); +} +mergeInto(MarkupParser.prototype, EventEmitter.prototype); + +markup.on('element:a', function(template) { + if (hasListenerFor(template, 'click')) { + var attributes = template.attributes || (template.attributes = {}); + if (!attributes.href) { + attributes.href = new templates.Attribute('#'); + addListener(template, 'click', '$preventDefault($event)'); + } + } +}); + +markup.on('element:form', function(template) { + if (hasListenerFor(template, 'submit')) { + addListener(template, 'submit', '$preventDefault($event)'); + } +}); + +function hasListenerFor(template, eventName) { + var hooks = template.hooks; + if (!hooks) return false; + for (var i = 0, len = hooks.length; i < len; i++) { + var hook = hooks[i]; + if (hook instanceof templates.ElementOn && hook.name === eventName) { + return true; + } + } + return false; +} + +function addListener(template, eventName, source) { + var hooks = template.hooks || (template.hooks = []); + var expression = createPathExpression(source); + hooks.push(new templates.ElementOn(eventName, expression)); +} + +function mergeInto(to, from) { + for (var key in from) { + to[key] = from[key]; + } + return to; +} diff --git a/test/all/parsing/dependencies.mocha.js b/test/all/parsing/dependencies.mocha.js new file mode 100644 index 000000000..7cb6696b8 --- /dev/null +++ b/test/all/parsing/dependencies.mocha.js @@ -0,0 +1,747 @@ +var expect = require('expect.js'); +var derbyTemplates = require('derby-templates'); +var contexts = derbyTemplates.contexts; +var expressions = derbyTemplates.expressions; +var templates = derbyTemplates.templates; +var parsing = require('../lib/index'); +var createExpression = parsing.createExpression; +var createTemplate = parsing.createTemplate; + +var controller = { + plus: function(a, b) { + return a + b; + } +, minus: function(a, b) { + return a - b; + } +, greeting: function() { + return 'Hi.'; + } +, keys: function(object) { + var keys = []; + for (var key in object) { + keys.push(key); + } + return keys; + } +, passThrough: function(value) { + return value; + } +, informal: { + greeting: function() { + return 'Yo!'; + } + } +, Date: Date +, global: global +}; +controller.model = { + data: { + key: 'green' + , lightTemplate: createTemplate('light {{_page.colors[key].name}}') + , _page: { + colors: { + green: { + name: 'Green' + , hex: '#0f0' + , rgb: [0, 255, 0] + , light: { + hex: '#90ee90' + } + , dark: { + hex: '#006400' + } + } + } + , key: 'green' + , channel: 0 + , variation: 'light' + , variationHex: 'light.hex' + , keys: ['red', 'green'] + , index: 1 + , tagName: 'div' + , html: '
Hi
' + + , nums: [2, 11, 3, 7] + , first: 2 + , second: 3 + , year: 2018 + , date: new Date(1000) + } + } +}; +controller.model.scope = function(path) { + return { + _at: path + , path: function() { + return this._at; + } + }; +}; +var views = new templates.Views(); +var contextMeta = new contexts.ContextMeta({views: views}); +var context = new contexts.Context(contextMeta, controller); +var view = new templates.View(); + +function stripContexts(dependencies) { + if (!dependencies) return dependencies; + for (var i = 0; i < dependencies.length; i++) { + var dependency = dependencies[i]; + for (var j = 0; j < dependency.length; j++) { + var segment = dependency[j]; + if (segment instanceof contexts.Context) { + dependency[j] = {item: segment.item}; + } + } + } + return dependencies; +} + +describe('template dependencies', function() { + describe('text', function() { + it('gets dependencies', function() { + var template = createTemplate('Hi'); + expect(template.dependencies(context)).to.be.null; + expect(template.get(context)).to.equal('Hi'); + }); + }); + + describe('dynamic text', function() { + it('gets dependencies', function() { + var template = createTemplate('{{_page.key}}'); + expect(template.dependencies(context)).to.eql([['_page', 'key']]); + expect(template.get(context)).to.equal('green'); + }); + }); + + describe('with block', function() { + it('gets dependencies', function() { + var template = createTemplate( + '{{with _page.key as #key}}' + + '{{_page.colors[#key].name}}' + + '{{/with}}'); + expect(template.dependencies(context)).to.eql([ + ['_page', 'key'], + ['_page', 'colors', 'green', 'name'] + ]); + expect(template.get(context)).to.equal('Green'); + }); + }); + + describe('on block', function() { + it('gets dependencies', function() { + var template = createTemplate( + '{{on _page.key}}' + + '{{_page.variation}}' + + '{{/on}}'); + expect(template.dependencies(context)).to.eql([ + ['_page', 'key'], + ['_page', 'variation'] + ]); + expect(template.get(context)).to.equal('light'); + }); + }); + + describe('each block', function() { + it('gets item alias dependencies', function() { + var template = createTemplate( + '{{each _page.keys as #key}}' + + '{{#key}}' + + '{{/each}}'); + expect(stripContexts(template.dependencies(context))).to.eql([ + ['_page', 'keys'], + ['_page', 'keys', {item: 0}], + ['_page', 'keys', {item: 1}] + ]); + expect(template.get(context)).to.equal('redgreen'); + }); + + it('gets index alias dependencies', function() { + var template = createTemplate( + '{{each _page.keys as #key, #i}}' + + '{{#i}}.' + + '{{/each}}'); + expect(stripContexts(template.dependencies(context))).to.eql([ + ['_page', 'keys'], + ['_page', 'keys'], + ['_page', 'keys'], + ]); + expect(template.get(context)).to.equal('0.1.'); + }); + + it('gets alias dependencies from a literal', function() { + var template = createTemplate( + '{{each [33, 77] as #key, #i}}' + + '{{#i}},{{#key}};' + + '{{/each}}'); + expect(stripContexts(template.dependencies(context))).to.be.null; + expect(template.get(context)).to.equal('0,33;1,77;'); + }); + }); + + describe('HTML', function() { + it('gets empty Template dependencies', function() { + var template = createTemplate(''); + expect(template.dependencies(context)).to.be.null; + expect(template.get(context)).to.equal(''); + }); + + it('gets Doctype dependencies', function() { + var template = createTemplate(''); + expect(template.dependencies(context)).to.be.null; + expect(template.get(context)).to.equal(''); + }); + + it('gets Text dependencies', function() { + var template = createTemplate('Hi!'); + expect(template.dependencies(context)).to.be.null; + expect(template.get(context)).to.equal('Hi!'); + }); + + it('gets DynamicText dependencies', function() { + var template = createTemplate('Choose {{_page.key}}'); + expect(template.dependencies(context)).to.eql([ + ['_page', 'key'] + ]); + expect(template.get(context)).to.equal('Choose green'); + }); + + it('gets Comment dependencies', function() { + var template = createTemplate(''); + expect(template.dependencies(context)).to.be.null; + expect(template.get(context)).to.equal(''); + }); + + it.skip('gets DynamicComment dependencies from parsed template', function() { + // Template tag within comment is not parsed. It probably should be, + // since we do parse the content of other special regions, such as the + // text inside of scripts and styles + var template = createTemplate(''); + expect(template.dependencies(context)).to.eql([ + ['_page', 'year'] + ]); + expect(template.get(context)).to.equal(''); + }); + + it('gets DynamicComment dependencies', function() { + var expression = createExpression('_page.year'); + var template = new templates.DynamicComment(expression); + expect(template.dependencies(context)).to.eql([ + ['_page', 'year'] + ]); + expect(template.get(context)).to.equal(''); + }); + + it('gets Html dependencies', function() { + // It is not currently possible to create a template of type Html via + // derby-parsing, as there is no syntax that would require it + var template = new templates.Html('
Hi
'); + expect(template.dependencies(context)).to.be.null; + expect(template.get(context)).to.equal('
Hi
'); + }); + + it('gets DynamicHtml dependencies', function() { + var template = createTemplate('{{unescaped _page.html}}'); + expect(template.dependencies(context)).to.eql([ + ['_page', 'html'] + ]); + expect(template.get(context)).to.equal('
Hi
'); + }); + + it('gets Element dependencies', function() { + var template = createTemplate('
Hi
there
'); + expect(template.dependencies(context)).to.be.null; + expect(template.get(context)).to.equal('
Hi
there
'); + }); + + it('gets DynamicElement dependencies', function() { + var template = createTemplate('Hello'); + expect(template.dependencies(context)).to.eql([ + ['_page', 'tagName'] + ]); + expect(template.get(context)).to.equal('
Hello
'); + }); + + it('gets Attribute dependencies', function() { + var template = createTemplate(''); + expect(template.dependencies(context)).to.be.null; + expect(template.get(context)).to.equal(''); + }); + + it('gets DynamicAttribute dependencies', function() { + var template = createTemplate(''); + expect(template.dependencies(context)).to.eql([ + ['_page', 'key'] + ]); + expect(template.get(context)).to.equal(''); + }); + }); +}); + +describe('expression dependencies', function() { + + describe('literal', function() { + it('gets literal dependencies', function() { + var expression = createExpression('34'); + expect(expression.dependencies(context)).to.be.null; + }); + }); + + describe('path', function() { + it('gets path dependencies', function() { + var expression = createExpression('_page.colors.green.name'); + expect(expression.dependencies(context)).to.eql([['_page', 'colors', 'green', 'name']]); + }); + }); + + describe('brackets', function() { + it('gets bracket + path dependencies', function() { + var expression = createExpression('_page.colors[_page.key].name'); + expect(expression.dependencies(context)).to.eql([ + ['_page', 'key'], + ['_page', 'colors', 'green', 'name'] + ]); + }); + + it('gets bracket + path + bracket + path dependencies', function() { + var expression = createExpression('_page.colors[_page.key].rgb[_page.channel]'); + expect(expression.dependencies(context)).to.eql([ + ['_page', 'key'], + ['_page', 'channel'], + ['_page', 'colors', 'green', 'rgb', 0] + ]); + }); + + it('gets bracket + bracket + path dependencies', function() { + var expression = createExpression('_page.colors[_page.key][_page.variation].hex'); + expect(expression.dependencies(context)).to.eql([ + ['_page', 'key'], + ['_page', 'variation'], + ['_page', 'colors', 'green', 'light', 'hex'] + ]); + }); + + it('gets nested bracket + path dependencies', function() { + var expression = createExpression('_page.colors[_page.keys[_page.index]].name'); + expect(expression.dependencies(context)).to.eql([ + ['_page', 'index'], + ['_page', 'keys', 1], + ['_page', 'colors', 'green', 'name'] + ]); + }); + }); + + describe('fn', function() { + it('gets path + path dependencies', function() { + var expression = createExpression('plus(_page.nums[0], _page.nums[1])'); + expect(expression.dependencies(context)).to.eql([ + ['_page', 'nums', 0, '*'], + ['_page', 'nums', 1, '*'] + ]); + }); + + it('gets path + fn dependencies', function() { + var expression = createExpression('plus(_page.nums[0], minus(_page.nums[3], _page.nums[2]))'); + expect(expression.dependencies(context)).to.eql([ + ['_page', 'nums', 0, '*'], + ['_page', 'nums', 3, '*'], + ['_page', 'nums', 2, '*'] + ]); + }); + + it('gets bracket + bracket dependencies', function() { + var expression = createExpression('plus(_page.nums[_page.first], _page.nums[_page.second])'); + expect(expression.dependencies(context)).to.eql([ + ['_page', 'first'], + ['_page', 'nums', 2, '*'], + ['_page', 'second'], + ['_page', 'nums', 3, '*'] + ]); + }); + + it('gets fn inside bracket dependencies', function() { + var expression = createExpression('_page.keys[minus(_page.nums[2], _page.nums[0])]'); + expect(expression.dependencies(context)).to.eql([ + ['_page', 'nums', 2, '*'], + ['_page', 'nums', 0, '*'], + ['_page', 'keys', 1] + ]); + }); + }); + + describe('operators', function() { + it('gets path + path dependencies', function() { + var expression = createExpression('_page.nums[0] + _page.nums[1]'); + expect(expression.dependencies(context)).to.eql([ + ['_page', 'nums', 0, '*'], + ['_page', 'nums', 1, '*'] + ]); + }); + + it('gets chained operator dependencies', function() { + var expression = createExpression('_page.nums[0] + (_page.nums[3] - _page.nums[2])'); + expect(expression.dependencies(context)).to.eql([ + ['_page', 'nums', 0, '*'], + ['_page', 'nums', 3, '*'], + ['_page', 'nums', 2, '*'] + ]); + }); + + it('gets bracket + bracket dependencies', function() { + var expression = createExpression('_page.nums[_page.first] + _page.nums[_page.second]'); + expect(expression.dependencies(context)).to.eql([ + ['_page', 'first'], + ['_page', 'nums', 2, '*'], + ['_page', 'second'], + ['_page', 'nums', 3, '*'] + ]); + }); + + it('gets operator inside bracket dependencies', function() { + var expression = createExpression('_page.keys[_page.nums[2] - _page.nums[0]]'); + expect(expression.dependencies(context)).to.eql([ + ['_page', 'nums', 2, '*'], + ['_page', 'nums', 0, '*'], + ['_page', 'keys', 1], + ]); + }); + + it('gets path + literal dependencies', function() { + var expression = createExpression('_page.nums[0] + 3'); + expect(expression.dependencies(context)).to.eql([ + ['_page', 'nums', 0, '*'] + ]); + }); + + it('gets path + literal + path dependencies', function() { + var expression = createExpression('_page.nums[0] + (100 - _page.nums[2])'); + expect(expression.dependencies(context)).to.eql([ + ['_page', 'nums', 0, '*'], + ['_page', 'nums', 2, '*'] + ]); + }); + + it('gets path + bracket dependencies', function() { + var expression = createExpression('_page.nums[2] + _page.nums[_page.second]'); + expect(expression.dependencies(context)).to.eql([ + ['_page', 'nums', 2, '*'], + ['_page', 'second'], + ['_page', 'nums', 3, '*'] + ]); + }); + + it('gets path + literal inside bracket dependencies', function() { + var expression = createExpression('_page.keys[_page.nums[2] - 2]'); + expect(expression.dependencies(context)).to.eql([ + ['_page', 'nums', 2, '*'], + ['_page', 'keys', 1] + ]); + }); + }); + + describe('relative paths', function() { + describe('with block', function() { + it('gets dependencies', function() { + var aliasExpression = createExpression('with _page.colors.green'); + var blockContext = context.child(aliasExpression); + var expression = createExpression('this'); + expect(expression.dependencies(blockContext)).to.eql([ + ['_page', 'colors', 'green'] + ]); + expect(expression.get(blockContext).name).to.eql('Green'); + }); + + it('gets subpath dependencies', function() { + var aliasExpression = createExpression('with _page.colors.green'); + var blockContext = context.child(aliasExpression); + var expression = createExpression('this.name'); + expect(expression.dependencies(blockContext)).to.eql([ + ['_page', 'colors', 'green', 'name'] + ]); + expect(expression.get(blockContext)).to.eql('Green'); + }); + + it('gets function dependencies', function() { + var aliasExpression = createExpression('with passThrough(_page.colors.green)'); + var blockContext = context.child(aliasExpression); + var expression = createExpression('this'); + expect(expression.dependencies(blockContext)).to.eql([ + ['_page', 'colors', 'green', '*'] + ]); + expect(expression.get(blockContext).name).to.eql('Green'); + }); + + it('gets subpath from function dependencies', function() { + var aliasExpression = createExpression('with passThrough(_page.colors.green)'); + var blockContext = context.child(aliasExpression); + var expression = createExpression('this.name'); + expect(expression.dependencies(blockContext)).to.eql([ + ['_page', 'colors', 'green', '*'] + ]); + expect(expression.get(blockContext)).to.eql('Green'); + }); + + it('gets template in model dependencies', function() { + var aliasExpression = createExpression('with lightTemplate'); + var blockContext = context.child(aliasExpression); + var expression = createExpression('this'); + expect(expression.dependencies(blockContext)).to.eql([ + ['key'], + ['_page', 'colors', 'green', 'name'], + ['lightTemplate'] + ]); + expect(expression.get(blockContext)).a(templates.Template); + }); + + it('gets subpath from template in model dependencies', function() { + // Getting a template returns a string, so this combination is not + // likely to be of much use. However, this test is included to clarify + // what is expected behavior + var aliasExpression = createExpression('with lightTemplate'); + var blockContext = context.child(aliasExpression); + var expression = createExpression('this.length'); + expect(expression.dependencies(blockContext)).to.eql([ + ['key'], + ['_page', 'colors', 'green', 'name'], + ['lightTemplate', 'length'] + ]); + expect(expression.get(blockContext)).to.eql(11); + }); + }); + }); + + describe('aliases', function() { + describe('with block', function() { + it('gets dependencies', function() { + var aliasExpression = createExpression('with _page.colors.green as #color'); + var blockContext = context.child(aliasExpression); + var expression = createExpression('#color'); + expect(expression.dependencies(blockContext)).to.eql([ + ['_page', 'colors', 'green'] + ]); + expect(expression.get(blockContext).name).to.eql('Green'); + }); + + it('gets subpath dependencies', function() { + var aliasExpression = createExpression('with _page.colors.green as #color'); + var blockContext = context.child(aliasExpression); + var expression = createExpression('#color.name'); + expect(expression.dependencies(blockContext)).to.eql([ + ['_page', 'colors', 'green', 'name'] + ]); + expect(expression.get(blockContext)).to.eql('Green'); + }); + + it('gets function dependencies', function() { + var aliasExpression = createExpression('with passThrough(_page.colors.green) as #color'); + var blockContext = context.child(aliasExpression); + var expression = createExpression('#color'); + expect(expression.dependencies(blockContext)).to.eql([ + ['_page', 'colors', 'green', '*'] + ]); + expect(expression.get(blockContext).name).to.eql('Green'); + }); + + it('gets subpath from function dependencies', function() { + var aliasExpression = createExpression('with passThrough(_page.colors.green) as #color'); + var blockContext = context.child(aliasExpression); + var expression = createExpression('#color.name'); + expect(expression.dependencies(blockContext)).to.eql([ + ['_page', 'colors', 'green', '*'] + ]); + expect(expression.get(blockContext)).to.eql('Green'); + }); + + it('gets template in model dependencies', function() { + var aliasExpression = createExpression('with lightTemplate as #color'); + var blockContext = context.child(aliasExpression); + var expression = createExpression('#color'); + expect(expression.dependencies(blockContext)).to.eql([ + ['key'], + ['_page', 'colors', 'green', 'name'], + ['lightTemplate'] + ]); + expect(expression.get(blockContext)).a(templates.Template); + }); + + it('gets subpath from template in model dependencies', function() { + // Getting a template returns a string, so this combination is not + // likely to be of much use. However, this test is included to clarify + // what is expected behavior + var aliasExpression = createExpression('with lightTemplate as #color'); + var blockContext = context.child(aliasExpression); + var expression = createExpression('#color.length'); + expect(expression.dependencies(blockContext)).to.eql([ + ['key'], + ['_page', 'colors', 'green', 'name'], + ['lightTemplate', 'length'] + ]); + expect(expression.get(blockContext)).to.eql(11); + }); + }); + + describe('each block', function() { + it('gets item alias dependencies', function() { + var aliasExpression = createExpression('each _page.keys as #key, #index'); + var eachContext = context.eachChild(aliasExpression, 0); + var expression = createExpression('#key'); + expect(expression.dependencies(eachContext)).to.eql([ + ['_page', 'keys', eachContext] + ]); + expect(expression.get(eachContext)).to.eql('red'); + }); + + it('gets subpath from item alias dependencies', function() { + var aliasExpression = createExpression('each _page.keys as #key, #index'); + var eachContext = context.eachChild(aliasExpression, 0); + var expression = createExpression('#key.length'); + expect(expression.dependencies(eachContext)).to.eql([ + ['_page', 'keys', eachContext, 'length'] + ]); + expect(expression.get(eachContext)).to.eql(3); + }); + + it('gets key alias dependencies', function() { + var aliasExpression = createExpression('each _page.keys as #key, #index'); + var eachContext = context.eachChild(aliasExpression, 0); + var expression = createExpression('#index'); + expect(expression.dependencies(eachContext)).to.eql([ + ['_page', 'keys'] + ]); + expect(expression.get(eachContext)).to.eql(0); + }); + }); + }); + + describe('view attributes', function() { + describe('expression values', function() { + it('gets path dependencies', function() { + var attributes = { + color: createExpression('_page.colors.green') + }; + var viewContext = context.viewChild(view, attributes); + var expression = createExpression('@color'); + expect(expression.dependencies(viewContext)).to.eql([ + ['_page', 'colors', 'green'] + ]); + expect(expression.get(viewContext).name).to.eql('Green'); + }); + + it('gets subpath from path dependencies', function() { + var attributes = { + color: createExpression('_page.colors.green') + }; + var viewContext = context.viewChild(view, attributes); + var expression = createExpression('@color.name'); + expect(expression.dependencies(viewContext)).to.eql([ + ['_page', 'colors', 'green', 'name'] + ]); + expect(expression.get(viewContext)).to.eql('Green'); + }); + + it('gets function dependencies', function() { + var attributes = { + color: createExpression('passThrough(_page.colors.green)') + }; + var viewContext = context.viewChild(view, attributes); + var expression = createExpression('@color'); + expect(expression.dependencies(viewContext)).to.eql([ + ['_page', 'colors', 'green', '*'] + ]); + expect(expression.get(viewContext).name).to.eql('Green'); + }); + + it('gets subpath from function dependencies', function() { + var attributes = { + color: createExpression('passThrough(_page.colors.green)') + }; + var viewContext = context.viewChild(view, attributes); + var expression = createExpression('@color.name'); + expect(expression.dependencies(viewContext)).to.eql([ + ['_page', 'colors', 'green', '*'] + ]); + expect(expression.get(viewContext)).to.eql('Green'); + }); + + it('gets bracket dependencies', function() { + var attributes = { + color: createExpression('_page.colors[key]') + }; + var viewContext = context.viewChild(view, attributes); + var expression = createExpression('@color'); + expect(expression.dependencies(viewContext)).to.eql([ + ['key'], + ['_page', 'colors', 'green'] + ]); + expect(expression.get(viewContext).name).to.eql('Green'); + }); + + it('gets subpath from bracket dependencies', function() { + var attributes = { + color: createExpression('_page.colors[key]') + }; + var viewContext = context.viewChild(view, attributes); + var expression = createExpression('@color.name'); + expect(expression.dependencies(viewContext)).to.eql([ + ['key'], + ['_page', 'colors', 'green', 'name'] + ]); + expect(expression.get(viewContext)).to.eql('Green'); + }); + }); + + describe('template values', function() { + it('gets function template attribute dependencies', function() { + var attributes = { + color: createTemplate('light{{_page.colors.green.name}}') + }; + var viewContext = context.viewChild(view, attributes); + var expression = createExpression('@color'); + expect(expression.dependencies(viewContext)).to.eql([ + ['_page', 'colors', 'green', 'name'] + ]); + expect(expression.get(viewContext)).a(templates.Template); + }); + + it('gets subpath from function template attribute dependencies', function() { + var attributes = { + color: createTemplate('light{{_page.colors.green.name}}') + }; + var viewContext = context.viewChild(view, attributes); + var expression = createExpression('@color.length'); + expect(expression.dependencies(viewContext)).to.eql([ + ['_page', 'colors', 'green', 'name'] + ]); + expect(expression.get(viewContext)).equal(10); + }); + + it('gets template in model attribute dependencies', function() { + var attributes = { + color: createExpression('lightTemplate') + }; + var viewContext = context.viewChild(view, attributes); + var expression = createExpression('@color'); + expect(expression.dependencies(viewContext)).to.eql([ + ['key'], + ['_page', 'colors', 'green', 'name'], + ['lightTemplate'] + ]); + expect(expression.get(viewContext)).a(templates.Template); + }); + + it('gets subpath from template in model attribute dependencies', function() { + var attributes = { + color: createExpression('lightTemplate') + }; + var viewContext = context.viewChild(view, attributes); + var expression = createExpression('@color.length'); + expect(expression.dependencies(viewContext)).to.eql([ + ['key'], + ['_page', 'colors', 'green', 'name'], + ['lightTemplate', 'length'] + ]); + expect(expression.get(viewContext)).equal(11); + }); + }); + }); +}); diff --git a/test/all/parsing/expressions.mocha.js b/test/all/parsing/expressions.mocha.js new file mode 100644 index 000000000..b61cdb5ae --- /dev/null +++ b/test/all/parsing/expressions.mocha.js @@ -0,0 +1,411 @@ +var expect = require('expect.js'); +var derbyTemplates = require('derby-templates'); +var contexts = derbyTemplates.contexts; +var expressions = derbyTemplates.expressions; +var create = require('../lib/createPathExpression'); + +var controller = { + plus: function(a, b) { + return a + b; + } +, minus: function(a, b) { + return a - b; + } +, greeting: function() { + return 'Hi.'; + } +, keys: function(object) { + var keys = []; + for (var key in object) { + keys.push(key); + } + return keys; + } +, passThrough: function(value) { + return value; + } +, informal: { + greeting: function() { + return 'Yo!'; + } + } +, Date: Date +, global: global +}; +controller.model = { + data: { + key: 'green' + , _page: { + colors: { + green: { + name: 'Green' + , hex: '#0f0' + , rgb: [0, 255, 0] + , light: { + hex: '#90ee90' + } + , dark: { + hex: '#006400' + } + } + } + , key: 'green' + , channel: 0 + , variation: 'light' + , variationHex: 'light.hex' + , keys: ['red', 'green'] + , index: 1 + + , nums: [2, 11, 3, 7] + , first: 2 + , second: 3 + , date: new Date(1000) + } + } +}; +controller.model.scope = function(path) { + return { + _at: path + , path: function() { + return this._at; + } + }; +}; +var contextMeta = new contexts.ContextMeta({}); +var context = new contexts.Context(contextMeta, controller); + +describe('Expression::resolve', function() { + + it('resolves a simple path expression', function() { + var expression = create('_page.colors.green.name'); + expect(expression.resolve(context)).to.eql(['_page', 'colors', 'green', 'name']); + }); + + it('resolves a `this` path expression', function() { + var expression = create('this'); + expect(expression.resolve(context)).to.eql([]); + var withExpression = create('_page.colors'); + withExpression.meta = new expressions.ExpressionMeta(); + var childContext = context.child(withExpression); + expect(expression.resolve(childContext)).to.eql(['_page', 'colors']); + }); + + it('resolves a relative path expression', function() { + var expression = create('this.green'); + expect(expression.resolve(context)).to.eql(['green']); + var withExpression = create('_page.colors'); + withExpression.meta = new expressions.ExpressionMeta(); + var childContext = context.child(withExpression); + expect(expression.resolve(childContext)).to.eql(['_page', 'colors', 'green']); + }); + + it('resolves an alias path expression', function() { + var expression = create('#color'); + var expression2 = create('#color.name'); + var withExpression = create('_page.colors.green'); + withExpression.meta = new expressions.ExpressionMeta(); + withExpression.meta.as = '#color'; + var childContext = context.child(withExpression); + expect(expression.resolve(childContext)).to.eql(['_page', 'colors', 'green']); + expect(expression2.resolve(childContext)).to.eql(['_page', 'colors', 'green', 'name']); + }); + + it('resolves square brackets expressions with single segments', function() { + var expression = create('colors[key]'); + var expression2 = create('colors[key].name'); + expect(expression.resolve(context)).to.eql(['colors', 'green']); + expect(expression2.resolve(context)).to.eql(['colors', 'green', 'name']); + }); + + it('resolves simple square brackets expressions', function() { + var expression = create('_page.colors[_page.key]'); + var expression2 = create('_page.colors[_page.key].name'); + expect(expression.resolve(context)).to.eql(['_page', 'colors', 'green']); + expect(expression2.resolve(context)).to.eql(['_page', 'colors', 'green', 'name']); + }); + + it('resolves sibling square brackets', function() { + var expression = create('_page.colors[_page.key].rgb[_page.channel]'); + var expression2 = create('_page.colors[_page.key][_page.variation]'); + var expression3 = create('_page.colors[_page.key][_page.variation].hex'); + var expression4 = create('_page.colors[_page.key][_page.variationHex]'); + expect(expression.resolve(context)).to.eql(['_page', 'colors', 'green', 'rgb', 0]); + expect(expression2.resolve(context)).to.eql(['_page', 'colors', 'green', 'light']); + expect(expression3.resolve(context)).to.eql(['_page', 'colors', 'green', 'light', 'hex']); + expect(expression4.resolve(context)).to.eql(['_page', 'colors', 'green', 'light.hex']); + }); + + it('resolves nested square brackets', function() { + var expression = create('_page.colors[_page.keys[_page.index]]'); + var expression2 = create('_page.colors[_page.keys[_page.index]].name'); + expect(expression.resolve(context)).to.eql(['_page', 'colors', 'green']); + expect(expression2.resolve(context)).to.eql(['_page', 'colors', 'green', 'name']); + }); + + it('resolves literal properties in square brackets', function() { + var expression = create('_page.nums[0]'); + var expression2 = create('_page["colors"]["green"].hex'); + expect(expression.resolve(context)).to.eql(['_page', 'nums', 0]); + expect(expression2.resolve(context)).to.eql(['_page', 'colors', 'green', 'hex']); + }); + +}); + +describe('Expression::get', function() { + + it('gets a simple path expression', function() { + var expression = create('_page.colors.green.name'); + expect(expression.get(context)).to.equal('Green'); + }); + + it('gets a relative path expression', function() { + var expression = create('this.green.name'); + var withExpression = create('_page.colors'); + withExpression.meta = new expressions.ExpressionMeta(); + var childContext = context.child(withExpression); + expect(expression.get(childContext)).to.equal('Green'); + }); + + it('gets an alias path expression', function() { + var expression = create('#color.name'); + var withExpression = create('_page.colors.green'); + withExpression.meta = new expressions.ExpressionMeta(); + withExpression.meta.as = '#color'; + var childContext = context.child(withExpression); + expect(expression.get(childContext)).to.equal('Green'); + }); + + it('gets a square brackets expression', function() { + var expression = create('_page.colors[_page.key].name'); + var expression2 = create('_page.colors[_page.key][_page.variation].hex'); + expect(expression.get(context)).to.equal('Green'); + expect(expression2.get(context)).to.equal('#90ee90'); + }); + + it('gets an fn expression', function() { + var expression = create('plus(_page.nums[0], _page.nums[1])'); + expect(expression.get(context)).to.equal(13); + }); + + it('gets an fn expression with no args', function() { + var expression = create('greeting()'); + expect(expression.get(context)).to.equal('Hi.'); + }); + + it('gets an fn expression on a subpath', function() { + var expression = create('informal.greeting()'); + expect(expression.get(context)).to.equal('Yo!'); + }); + + it('gets an fn expression with relative paths', function() { + var expression = create('plus(this[0], this[1])'); + var withExpression = create('_page.nums'); + withExpression.meta = new expressions.ExpressionMeta(); + var childContext = context.child(withExpression); + expect(expression.get(childContext)).to.equal(13); + }); + + it('gets an fn expression with alias paths', function() { + var expression = create('plus(#nums[1], #nums[2])'); + var withExpression = create('_page.nums'); + withExpression.meta = new expressions.ExpressionMeta(); + withExpression.meta.as = '#nums'; + var childContext = context.child(withExpression); + expect(expression.get(childContext)).to.equal(14); + }); + + it('gets a property of an fn expression', function() { + var expression = create('keys(_page.colors)[0]'); + var expression2 = create('passThrough(_page.colors).green'); + expect(expression.get(context)).to.equal('green'); + expect(expression2.get(context)).to.equal(controller.model.data._page.colors.green); + }); + + it('gets square bracket paths of an fn expression', function() { + var expression = create('keys(_page.colors)[_page.channel]'); + var expression2 = create('passThrough(_page.colors).green[_page.variation].hex'); + expect(expression.get(context)).to.equal('green'); + expect(expression2.get(context)).to.equal('#90ee90'); + }); + + it('gets an fn expression containing bracket paths', function() { + var expression = create('plus(_page.nums[_page.first], _page.nums[_page.second])'); + expect(expression.get(context)).to.equal(10); + }); + + it('gets a bracket path containing an fn expression', function() { + var expression = create('_page.keys[minus(_page.nums[2], _page.nums[0])]'); + expect(expression.get(context)).to.equal('green'); + }); + + it('gets nested fn expressions', function() { + var expression = create('plus(_page.nums[0], minus(_page.nums[3], _page.nums[2]))'); + var expression2 = create('plus(minus(_page.nums[3], _page.nums[2]), _page.nums[1])'); + expect(expression.get(context)).to.equal(6); + expect(expression2.get(context)).to.equal(15); + }); + + it('gets scoped model expressions', function() { + var expression = create('$at(_page.nums[0])'); + expect(expression.get(context).path()).to.equal('_page.nums.0'); + }); + + it('gets scoped model expressions in fn expressions', function() { + var expression = create('passThrough($at(_page.nums[3]))'); + expect(expression.get(context).path()).to.equal('_page.nums.3'); + }); + + it('gets a `new` expression without arguments', function() { + var expression = create('new Date'); + var date = expression.get(context); + expect(date).to.be.a(Date); + }); + + it('gets a `new` expression with arguments', function() { + var expression = create('new Date(2000, 0)'); + var date = expression.get(context); + expect(date.getFullYear()).equal(2000); + expect(date.getMonth()).equal(0); + }); + + it('gets `new` expression on nested path', function() { + var expression = create('new global.Error()'); + expect(expression.get(context)).to.be.a(Error); + }); + + // None of these are supported yet, but ideally they would be + it.skip('gets method call of the result of an fn expressions', function() { + var expression = create('(_page.date).valueOf()'); + expect(expression.get(context)).to.equal(1000); + }); + it.skip('gets method call of the result of an fn expressions', function() { + var expression = create('passThrough(_page.date).valueOf()'); + expect(expression.get(context)).to.equal(1000); + }); + it.skip('gets method call of the result of a `new` expressions', function() { + var expression = create('new Date(1000).valueOf()'); + expect(expression.get(context)).to.equal(1000); + }); + it.skip('gets method call of a scoped model expression', function() { + var expression = create('$at(_page.nums[3]).path()'); + expect(expression.get(context)).to.equal('_page.nums.3'); + }); + + it('gets literal values', function() { + // Numbers + expect(create('0').get()).equal(0); + expect(create('1.5').get()).equal(1.5); + expect(create('1.1e3').get()).equal(1100); + expect(create('0xff').get()).equal(255); + // Booleans + expect(create('true').get()).equal(true); + expect(create('false').get()).equal(false); + // Strings + expect(create('""').get()).equal(''); + expect(create('\'Howdy\'').get()).equal('Howdy'); + // Regular Expressions + var re = create('/([0-9]+)/').get(); + expect(re).to.be.a(RegExp); + expect(re.source).equal('([0-9]+)'); + // Other + expect(create('null').get()).equal(null); + }); + + it('gets `undefined` as a literal', function() { + // `undefined` is a top-level property in JavaScript, but esprima-derby + // parses it as a literal like `null` instead + expect(create('undefined').get()).equal(void 0); + }); + + it('gets literals modified by a unary operator', function() { + expect(create('!null').get()).equal(true); + expect(create('-2.3').get()).equal(-2.3); + expect(create('+"4"').get()).equal(4); + expect(create('~0').get()).equal(-1); + expect(create('typeof 0').get()).equal('number'); + }); + + it('gets literals modified by nested unary operators', function() { + // Nested unary operators + expect(create('~-1').get()).equal(0); + expect(create('typeof !!""').get()).equal('boolean'); + }); + + it('gets literals modified by a boolean operator', function() { + expect(create('false || null').get()).equal(null); + expect(create('"" && 3').get()).equal(''); + expect(create('1 + 1').get()).equal(2); + expect(create('4 - 3').get()).equal(1); + expect(create('1 > 0').get()).equal(true); + }); + + it('gets literals modified by nested boolean expressions', function() { + expect(create('2*2*2*2').get()).equal(16); + expect(create('true && true && 0 && true').get()).equal(0); + }); + + it('gets literals modified by a conditional operator', function() { + expect(create('(true) ? "yes" : "no"').get()).equal('yes'); + expect(create('0 ? "yes" : "no"').get()).equal('no'); + }); + + it('gets literals modified in mixed nested operators', function() { + expect(create('(1 < 0) ? null : (2 == "2") ? !!23 : false').get()).equal(true); + }); + + it('gets expressions modified by a unary operator', function() { + var expression = create('!_page.first'); + expect(expression.get(context)).to.equal(false); + var expression = create('!!_page.colors[_page.key].name'); + expect(expression.get(context)).to.equal(true); + var expression = create('typeof greeting()'); + expect(expression.get(context)).to.equal('string'); + }); + + it('gets expressions modified by a boolean operator', function() { + var expression = create('_page.nums[0] + _page.nums[1]'); + expect(expression.get(context)).to.equal(13); + }); + + it('gets expressions modified by a conditional operator', function() { + var expression = create('(_page.key === "green") ? _page.colors.green.name : "Other"'); + expect(expression.get(context)).to.equal('Green'); + }); + + it('gets array literals', function() { + expect(create('[]').get()).eql([]); + expect(create('[0, 2, 1]').get()).eql([0, 2, 1]); + expect(create('[[0, 1], [1, 0]]').get()).eql([[0, 1], [1, 0]]); + }); + + it('gets object literals', function() { + expect(create('{}').get()).eql({}); + expect(create('{foo: 0, bar: 1}').get()).eql({foo: 0, bar: 1}); + expect(create('{foo: 0, bar: {"!": "baz"}}').get()).eql({foo: 0, bar: {'!': 'baz'}}); + }); + + it('gets nested array and object literals', function() { + expect(create('[{arr: [{}, {}]}, []]').get()).eql([{arr: [{}, {}]}, []]); + }); + + it('gets array literals containing paths', function() { + var expression = create('[_page.nums[0], 99, [_page.nums[1]], 13]'); + expect(expression.get(context)).to.eql([2, 99, [11], 13]); + }); + + it('gets object literals containing paths', function() { + var expression = create('{foo: _page.nums[0], bar: {"!": _page.nums[1], baz: "Hi"}}'); + expect(expression.get(context)).to.eql({foo: 2, bar: {'!': 11, baz: 'Hi'}}); + }); + + it('gets sequence expressions containing paths', function() { + var expression = create('_page.nums[0], 5, _page.nums[1]'); + expect(expression.get(context)).to.eql(11); + }); + + it('gets a property of a sequence expression', function() { + var expression = create('(null, _page.colors).green.name'); + expect(expression.get(context)).to.eql('Green'); + }); + +}); diff --git a/test/all/parsing/templates.mocha.js b/test/all/parsing/templates.mocha.js new file mode 100644 index 000000000..d6eca0430 --- /dev/null +++ b/test/all/parsing/templates.mocha.js @@ -0,0 +1,778 @@ +var expect = require('expect.js'); +var derbyTemplates = require('derby-templates'); +var contexts = derbyTemplates.contexts; +var templates = derbyTemplates.templates; +var parsing = require('../lib/index'); + +var model = { + data: { + _page: { + greeting: 'Howdy!' + , zero: 0 + , yep: true + , nope: false + , nada: null + , letters: ['A', 'B', 'C'] + , emptyList: [] + , matrix: [[0, 1], [1, 0]] + , view: 'section' + , html: 'Qua?' + , tag: 'strong' + } + } +}; +var contextMeta = new contexts.ContextMeta({}); +var controller = {model: model}; +var context = new contexts.Context(contextMeta, controller); + +describe('Parse and render literal HTML', function() { + + var literalTests = { + 'empty string': '' + , 'empty div': '
' + , 'div with attributes': '
' + , 'text': 'Hi.' + , 'conditional comment': '' + , 'div containing text': '
' + , 'nested divs': '
' + , 'sibling divs': '
' + , 'input': '' + , 'self-closing input': '' + , 'void and nonvoid elements': '

Hi

' + , 'HTML5 doctype': '' + , 'HTML4 doctype': '' + , 'XHTML doctype': '' + , 'MathML 1.01 doctype': '' + , 'html5 basic page': '

' + , 'page missing end body and html tags': '

' + }; + + for (var name in literalTests) { + test(name, literalTests[name]); + } + function test(name, source) { + it(name, function() { + var template = parsing.createTemplate(source); + expect(template.get()).equal(source); + }); + } + + it('throws on a mismatched closing HTML tag', function() { + expect(function() { + parsing.createTemplate('
'); + }).to.throwException(/Mismatched closing HTML tag: <\/div>/); + }); + + it('throws on a missing tag', function() { + expect(function() { + parsing.createTemplate(''); + }).to.throwException(/Missing closing HTML tag: <\/span>/); + }); + + it('throws on a missing tag', function() { + expect(function() { + parsing.createTemplate('
'); + }).to.throwException(/Missing closing HTML tag: <\/div>/); + }); +}); + +describe('Parse and render dynamic text and blocks', function() { + + function test(source, expected) { + var template = parsing.createTemplate(source); + expect(template.get(context)).equal(expected); + } + + it('value within text', function() { + test('Say, "{{_page.greeting}}"', 'Say, "Howdy!"'); + test('{{_page.zero}}', '0'); + test('{{_page.nope}}', 'false'); + test('{{_page.yep}}', 'true'); + test('{{_page.nada}}', ''); + test('{{nothing}}', ''); + }); + + it('with block', function() { + test('{{with _page.yep}}yes{{/with}}', 'yes'); + test('{{with _page.nope}}yes{{/with}}', 'yes'); + test('{{with _page.yep}}{{this}}{{/with}}', 'true'); + test('{{with _page.nope}}{{this}}{{/with}}', 'false'); + }); + + it('with block, valid alias', function() { + test('{{with _page.greeting as #greeting}}{{#greeting}}{{/with}}', 'Howdy!'); + }); + + describe('with block, invalid alias throws during parsing', function() { + it('no pound sign at start of alias', function() { + var source = '{{with _page.greeting as greeting}}{{/with}}'; + expect(function() { + var template = parsing.createTemplate(source); + console.log(template.content[0]); + }).to.throwException(/Alias must be an identifier starting with "#"/); + }); + + it('trailing parenthesis in alias', function() { + var source = '{{with _page.greeting as #greeting)}}{{/with}}'; + expect(function() { + var template = parsing.createTemplate(source); + }).to.throwException(/Unexpected token \)/); + }); + + it('brackets in alias', function() { + var source = '{{with _page.greeting as #greeting[0]}}{{/with}}'; + expect(function() { + var template = parsing.createTemplate(source); + }).to.throwException(/Alias must not have dots or brackets/); + }); + + it('dots in alias', function() { + var source = '{{with _page.greeting as #greeting.a}}{{/with}}'; + expect(function() { + var template = parsing.createTemplate(source); + }).to.throwException(/Alias must not have dots or brackets/); + }); + }); + + it('if block', function() { + test('{{if _page.yep}}yes{{/if}}', 'yes'); + test('{{if _page.yep}}{{this}}{{/if}}', 'true'); + test('{{if _page.nope}}yes{{/if}}', ''); + test('{{if nothing}}yes{{/if}}', ''); + }); + + it('unless block', function() { + test('{{unless _page.yep}}yes{{/unless}}', ''); + test('{{unless _page.nope}}yes{{/unless}}', 'yes'); + test('{{unless _page.nope}}{{this}}{{/unless}}', 'false'); + test('{{unless nothing}}yes{{/unless}}', 'yes'); + }); + + it('else block', function() { + test('{{if _page.yep}}yes{{else}}no{{/if}}', 'yes'); + test('{{if _page.nope}}yes{{else}}no{{/if}}', 'no'); + test('{{if nothing}}yes{{else}}no{{/if}}', 'no'); + }); + + it('else if block', function() { + test('{{if _page.yep}}1{{else if _page.yep}}2{{else}}3{{/if}}', '1'); + test('{{if _page.nope}}1{{else if _page.yep}}2{{else}}3{{/if}}', '2'); + test('{{if _page.nope}}1{{else if _page.yep}}{{this}}{{else}}3{{/if}}', 'true'); + test('{{if _page.nope}}1{{else if _page.nope}}2{{else}}3{{/if}}', '3'); + }); + + it('each block', function() { + test('{{each _page.letters}}{{this}}:{{/each}}', 'A:B:C:'); + test('{{each [1, 2, 3]}}{{this * 2}}{{/each}}', '246'); + test('{{each [1, _page.zero, 3]}}{{this * 2}}{{/each}}', '206'); + test('{{each [2, 1, 0]}}{{_page.letters[this]}}{{/each}}', 'CBA'); + test('{{each _page.matrix[1]}}{{this}}:{{/each}}', '1:0:'); + }); + + it('each else block', function() { + test('{{each _page.letters}}{{this}}:{{else}}Nada{{/each}}', 'A:B:C:'); + test('{{each _page.emptyList}}{{this}}:{{else}}Nada{{/each}}', 'Nada'); + test('{{each nothing}}{{this}}:{{else}}Nada{{/each}}', 'Nada'); + }); + + it('nested each blocks', function() { + test( + '{{each _page.matrix}}' + + '{{each this}}' + + '{{this}}.' + + '{{/each}};' + + '{{/each}}' + , '0.1.;1.0.;' + ); + test( + '{{each _page.matrix}}' + + '{{each this}}' + + '{{each _page.matrix}}' + + '{{each this}}' + + '{{this}}!' + + '{{/each}}|' + + '{{/each}}' + + '{{this}}.' + + '{{/each}};' + + '{{/each}}' + , '0!1!|1!0!|0.' + + '0!1!|1!0!|1.;' + + '0!1!|1!0!|1.' + + '0!1!|1!0!|0.;' + ); + }); + + it('alias to each block', function() { + test('{{each _page.letters as #letter}}{{#letter}}:{{/each}}', 'A:B:C:'); + test('{{each [1, 2, 3] as #number}}{{#number * 2}}{{/each}}', '246'); + test('{{each [1, _page.zero, 3] as #number}}{{#number * 2}}{{/each}}', '206'); + test('{{each [2, 1, 0] as #number}}{{_page.letters[#number]}}{{/each}}', 'CBA'); + test('{{each _page.matrix[1] as #number}}{{#number}}:{{/each}}', '1:0:'); + }); + + it('index alias to each block', function() { + test('{{each _page.letters as #letter, #i}}{{#i + 1}}:{{#letter}};{{/each}}', '1:A;2:B;3:C;'); + }); + + describe('each block, invalid alias throws during parsing', function() { + it('no pound sign at start of alias', function() { + var source = '{{each _page.letters as letter}}{{/each}}'; + expect(function() { + var template = parsing.createTemplate(source); + }).to.throwException(/Alias must be an identifier starting with "#"/); + }); + + it('trailing parenthesis in alias', function() { + var source = '{{each _page.letters as #letter)}}{{/each}}'; + expect(function() { + var template = parsing.createTemplate(source); + }).to.throwException(/Unexpected token \)/); + }); + + it('brackets in alias', function() { + var source = '{{each _page.letters as #letter[0]}}{{/each}}'; + expect(function() { + var template = parsing.createTemplate(source); + }).to.throwException(/Alias must not have dots or brackets/); + }); + + it('dots in alias', function() { + var source = '{{each _page.letters as #letter.a}}{{/each}}'; + expect(function() { + var template = parsing.createTemplate(source); + }).to.throwException(/Alias must not have dots or brackets/); + }); + + it('no pound sign at start of index alias', function() { + var source = '{{each _page.letters as #letter, index}}{{/each}}'; + expect(function() { + var template = parsing.createTemplate(source); + }).to.throwException(/Alias must be an identifier starting with "#"/); + }); + + it('trailing parenthesis in index alias', function() { + var source = '{{each _page.letters as #letter, #index)}}{{/each}}'; + expect(function() { + var template = parsing.createTemplate(source); + }).to.throwException(/Unexpected token \)/); + }); + + it('brackets in index alias', function() { + var source = '{{each _page.letters as #letter, #index[0]}}{{/each}}'; + expect(function() { + var template = parsing.createTemplate(source); + }).to.throwException(/Alias must not have dots or brackets/); + }); + + it('dots in index alias', function() { + var source = '{{each _page.letters as #letter, #index.a}}{{/each}}'; + expect(function() { + var template = parsing.createTemplate(source); + }).to.throwException(/Alias must not have dots or brackets/); + }); + }); +}); + +describe('Parse and render HTML and blocks', function() { + function test(source, expected) { + var template = parsing.createTemplate(source); + expect(template.get(context)).equal(expected); + } + + it('block within an element attribute', function() { + test('
', '
'); + }); + + it('unescaped HTML', function() { + test('
{{unescaped _page.html}}
', '
Qua?
'); + }); + + it('dynamic element', function() { + test('
Hi
', '
Hi
'); + }); + + it('less than sign in double braces', function() { + test('{{_page.zero < 0}} {{_page.zero <= 0}}', 'false true'); + }); + + it('less than sign in double braces in attribute', function() { + test('
', '
'); + }); + + it('less than sign in double braces in script tag', function() { + test('', ''); + }); + + it('less than sign in string in double braces', function() { + test('{{"
"}}', '<div>'); + }); + + it('less than sign in string in double braces in attribute', function() { + test('
', '
'); + }); + + it('less than sign in string in double braces in script tag', function() { + test('', ''); + }); + + it('amphersand in double braces', function() { + test('{{1 && 2}} < {{_page.zero && 2}}', '2 < 0'); + }); + + it('amphersandin double braces in attribute', function() { + test('
', '
'); + }); + + it('amphersand in double braces in script tag', function() { + test('', ''); + }); + + it('braces containing hex escaped literal braces', function() { + test('{{"\\x7b\\x7b"}} {{"\\x7d"}}', '{{ }'); + }); + + it('double braces can be escaped with HTML entity', function() { + test('{{ }} { {', '{{ }} { {'); + }); +}); + +describe('View insertion', function() { + + describe('find', function() { + it('can register and find a view', function() { + var views = new templates.Views(); + context.meta.views = views; + views.register('body', '
'); + var view = views.find('body'); + expect(view.get(context)).equal('
'); + }); + }); + + describe('inserts a literal view', function() { + function test(source) { + it(source, function() { + var views = new templates.Views(); + context.meta.views = views; + views.register('body', source); + views.register('section', '
'); + var view = views.find('body'); + expect(view.get(context)).equal('
'); + }); + } + test('{{view "section"}}'); + test(''); + test(''); + }); + + describe('inserts a dynamic view', function() { + function test(source) { + it(source, function() { + var views = new templates.Views(); + context.meta.views = views; + views.register('body', source); + views.register('section', '
'); + var view = views.find('body'); + expect(view.get(context)).equal('
'); + }); + } + test('{{view _page.view}}'); + test(''); + test(''); + }); + + describe('inserts a view with literal arguments', function() { + function test(source) { + it(source, function() { + var views = new templates.Views(); + context.meta.views = views; + views.register('body', source); + views.register('section', '
{{@text}}
'); + var view = views.find('body'); + expect(view.get(context)).equal('
Hi
'); + }); + } + test('{{view "section", {text: "Hi"}}}'); + test(''); + test(''); + }); + + describe('dashed html view arguments become camel cased', function() { + function test(source) { + it(source, function() { + var views = new templates.Views(); + context.meta.views = views; + views.register('body', source); + views.register('section', '
{{@messageText}}
'); + var view = views.find('body'); + expect(view.get(context)).equal('
Hi
'); + }); + } + test('{{view "section", {messageText: "Hi"}}}'); + test(''); + test(''); + }); + + describe('inserts a view with dynamic arguments', function() { + function test(source) { + it(source, function() { + var views = new templates.Views(); + context.meta.views = views; + views.register('body', source); + views.register('section', '
{{@text}}
'); + var view = views.find('body'); + expect(view.get(context)).equal('
Howdy!
'); + }); + } + test('{{view "section", {text: _page.greeting}}}'); + test(''); + test(''); + }); + + describe('content attribute', function() { + it('passes HTML inside as {{@content}}', function() { + var views = new templates.Views(); + context.meta.views = views; + views.register('body', 'Hi'); + views.register('section', '
{{@content}}
'); + var view = views.find('body'); + expect(view.get(context)).equal('
Hi
'); + }); + + it('can be overridden', function() { + var views = new templates.Views(); + context.meta.views = views; + views.register('body', 'Hi'); + views.register('section', '
{{@content}}
'); + var view = views.find('body'); + expect(view.get(context)).equal('
Stuff
'); + }); + + it('can pass through parent content', function() { + var views = new templates.Views(); + context.meta.views = views; + views.register('body', 'Hi'); + views.register('section', '
'); + views.register('paragraph', '

{{@content}}

'); + var view = views.find('body'); + expect(view.get(context)).equal('

Hi

'); + }); + }); + + describe('attribute tag', function() { + it('can be defined as an option of a view', function() { + var views = new templates.Views(); + context.meta.views = views; + views.register('body' + , '' + + '<b>Hi</b>' + + 'More text' + + '' + ); + views.register('section' + , '

{{@title}}

' + + '
{{@content}}
' + , {attributes: 'title'} + ); + var view = views.find('body'); + expect(view.get(context)).equal('

Hi

More text
'); + }); + + it('translates dashed tag name into camel-cased attribute name', function() { + var views = new templates.Views(); + context.meta.views = views; + views.register('body' + , '' + + 'Hi' + + 'More text' + + '' + ); + views.register('section' + , '

{{@mainTitle}}

' + + '
{{@content}}
' + , {attributes: 'main-title'} + ); + var view = views.find('body'); + expect(view.get(context)).equal('

Hi

More text
'); + }); + + it('can be dynamically defined with a generic attribute tag', function() { + var views = new templates.Views(); + context.meta.views = views; + views.register('body' + , '' + + 'Hi' + + 'More text' + + '' + ); + views.register('section' + , '

{{@title}}

' + + '
{{@content}}
' + ); + var view = views.find('body'); + expect(view.get(context)).equal('

Hi

More text
'); + }); + }); + + describe('array tag', function() { + it('can be defined as an option of a view', function() { + var views = new templates.Views(); + context.meta.views = views; + views.register('body' + , '' + + 'Hi' + + 'Ho' + + '' + ); + views.register('tabs' + , '
    ' + + '{{each @panes}}' + + '
  • {{this.title}}
  • ' + + '{{/each}}' + + '
' + + '{{each @panes}}' + + '
{{this.content}}
' + + '{{/each}}' + , {arrays: 'pane/panes'} + ); + var view = views.find('body'); + expect(view.get(context)).equal( + '
    ' + + '
  • One
  • ' + + '
  • Two
  • ' + + '
' + + '
Hi
' + + '
Ho
' + ); + }); + + it('can be dynamically defined with generic array tags', function() { + var views = new templates.Views(); + context.meta.views = views; + views.register('body' + , '' + + 'Hi' + + 'Ho' + + '' + ); + views.register('tabs' + , '
    ' + + '{{each @panes}}' + + '
  • {{this.title}}
  • ' + + '{{/each}}' + + '
' + + '{{each @panes}}' + + '
{{this.content}}
' + + '{{/each}}' + ); + var view = views.find('body'); + expect(view.get(context)).equal( + '
    ' + + '
  • One
  • ' + + '
  • Two
  • ' + + '
' + + '
Hi
' + + '
Ho
' + ); + }); + + it('passes in expression values', function() { + var views = new templates.Views(); + context.meta.views = views; + views.register('body' + , '' + + '{{_page.letters[0]}}' + + '{{33}}' + + '' + ); + views.register('tabs' + , '
    ' + + '{{each @panes as #pane}}' + + '
  • {{#pane.title}}
  • ' + + '{{/each}}' + + '
' + + '{{each @panes as #pane}}' + + '
{{#pane.content}}
' + + '{{/each}}' + , {arrays: 'pane/panes'} + ); + var view = views.find('body'); + expect(view.get(context)).equal( + '
    ' + + '
  • Howdy!
  • ' + + '
  • Hi
  • ' + + '
' + + '
A
' + + '
33
' + ); + }); + + it('is rendered before passing to view functions', function() { + var views = new templates.Views(); + context.meta.views = views; + views.register('body' + , '' + + '{{_page.letters[0]}}' + + '{{33}}' + + '' + ); + views.register('tabs', '{{JSON.stringify(@panes)}}', {arrays: 'pane/panes'}); + var view = views.find('body'); + expect(view.get(context)).equal( + '[' + + '{"title":"Howdy!","content":"A"},' + + '{"title":"Hi","content":33}' + + ']' + ); + }); + }); + + describe('within attribute', function() { + it('supports "within" attribute on child tags to use context from inside view', function() { + var views = new templates.Views(); + context.meta.views = views; + views.register('body' + , '' + + '{{#item}}' + + '' + ); + views.register('custom-list' + , '
    ' + + '{{each @items as #item}}' + + '
  • {{@itemContent}}
  • ' + + '{{/each}}' + + '
' + , {attributes: 'item-content'} + ); + var view = views.find('body'); + expect(view.get(context)).equal('
  • item A
  • item B
'); + }); + + it('supports "within" attribute on child array tags to use context from inside view', function() { + var views = new templates.Views(); + context.meta.views = views; + views.register('body' + , '' + + 'Text: {{#item}}' + + 'Length: {{#item.length}}' + + '' + ); + views.register('custom-table' + , '' + + '{{each @items as #item}}' + + '' + + '{{each @rowCells as #rowCell}}' + + '' + + '{{/each}}' + + '' + + '{{/each}}' + + '
{{#rowCell.content}}
' + , {arrays: 'row-cell/rowCells'} + ); + var view = views.find('body'); + expect(view.get(context)).equal( + '' + + '' + + '' + + '
Text: item ALength: 6
Text: item BBLength: 7
' + ); + }); + }); + + describe('HTML content', function() { + it('escapes a literal view attribute', function() { + var views = new templates.Views(); + context.meta.views = views; + views.register('body', ''); + views.register('partial', '{{@text}}'); + var view = views.find('body'); + expect(view.get(context)).equal('<b>Hi</b>'); + }); + + it('escapes a path expression view attribute', function() { + var views = new templates.Views(); + context.meta.views = views; + views.register('body', ''); + views.register('partial', '{{@text}}'); + var view = views.find('body'); + expect(view.get(context)).equal('<b class="foo">Qua?</b>'); + }); + + it('escapes a complex template view attribute', function() { + var views = new templates.Views(); + context.meta.views = views; + views.register('body', ''); + views.register('partial', '{{@text}}'); + var view = views.find('body'); + expect(view.get(context)).equal('<b class="foo">Qua?</b> bar'); + }); + }); + + describe('HTML attribute', function() { + it('escapes a literal view attribute', function() { + var views = new templates.Views(); + context.meta.views = views; + views.register('body', ''); + views.register('partial', '
'); + var view = views.find('body'); + expect(view.get(context)).equal('
'); + }); + + it('escapes a path expression view attribute', function() { + var views = new templates.Views(); + context.meta.views = views; + views.register('body', ''); + views.register('partial', '
'); + var view = views.find('body'); + expect(view.get(context)).equal('
'); + }); + + it('escapes a complex template view attribute', function() { + var views = new templates.Views(); + context.meta.views = views; + views.register('body', ''); + views.register('partial', '
'); + var view = views.find('body'); + expect(view.get(context)).equal('
'); + }); + }); + + describe('alias from outside view', function() { + it('gets each context', function() { + var views = new templates.Views(); + context.meta.views = views; + views.register('body' + , '
    ' + + '{{each _page.matrix as #row}}' + + '' + + '{{/each}}' + + '
' + ); + views.register('row' + , '
  • ' + + '
      ' + + '{{each #row as #item}}' + + '
    1. {{#item}}
    2. ' + + '{{/each}}' + + '
    ' + + '
  • ' + ); + var view = views.find('body'); + expect(view.get(context)).equal( + '
      ' + + '
    1. ' + + '
        ' + + '
      1. 0
      2. ' + + '
      3. 1
      4. ' + + '
      ' + + '
    2. ' + + '
    3. ' + + '
        ' + + '
      1. 1
      2. ' + + '
      3. 0
      4. ' + + '
      ' + + '
    4. ' + + '
    ' + ); + }); + }); +}); diff --git a/test/all/parsing/truthy.mocha.js b/test/all/parsing/truthy.mocha.js new file mode 100644 index 000000000..9d065046a --- /dev/null +++ b/test/all/parsing/truthy.mocha.js @@ -0,0 +1,43 @@ +var expect = require('expect.js'); +var parsing = require('../lib/index'); + +describe('template truthy', function() { + + it('gets standard truthy value for if block', function() { + expect(parsing.createExpression('if false').truthy()).equal(false); + expect(parsing.createExpression('if undefined').truthy()).equal(false); + expect(parsing.createExpression('if null').truthy()).equal(false); + expect(parsing.createExpression('if ""').truthy()).equal(false); + expect(parsing.createExpression('if []').truthy()).equal(false); + + expect(parsing.createExpression('if true').truthy()).equal(true); + expect(parsing.createExpression('if 0').truthy()).equal(false); + expect(parsing.createExpression('if 1').truthy()).equal(true); + expect(parsing.createExpression('if "Hi"').truthy()).equal(true); + expect(parsing.createExpression('if [0]').truthy()).equal(true); + expect(parsing.createExpression('if {}').truthy()).equal(true); + expect(parsing.createExpression('if {foo: 0}').truthy()).equal(true); + }); + + it('gets inverse truthy value for unless block', function() { + expect(parsing.createExpression('unless false').truthy()).equal(true); + expect(parsing.createExpression('unless undefined').truthy()).equal(true); + expect(parsing.createExpression('unless null').truthy()).equal(true); + expect(parsing.createExpression('unless ""').truthy()).equal(true); + expect(parsing.createExpression('unless []').truthy()).equal(true); + + expect(parsing.createExpression('unless true').truthy()).equal(false); + expect(parsing.createExpression('unless 0').truthy()).equal(true); + expect(parsing.createExpression('unless 1').truthy()).equal(false); + expect(parsing.createExpression('unless "Hi"').truthy()).equal(false); + expect(parsing.createExpression('unless [0]').truthy()).equal(false); + expect(parsing.createExpression('unless {}').truthy()).equal(false); + expect(parsing.createExpression('unless {foo: 0}').truthy()).equal(false); + }); + + it('gets always truthy value for else block', function() { + parsing.createExpression('else'); + expect(parsing.createExpression('else').truthy()).equal(true); + }); + +}); From f2955e80a392c0502a1cd62502d895c854efc6b4 Mon Sep 17 00:00:00 2001 From: Eric Hwang Date: Fri, 16 Jun 2023 14:30:50 -0700 Subject: [PATCH 07/11] Fix lint issues with code merged from derby-parsing --- lib/parsing/createPathExpression.js | 4 +- lib/parsing/index.js | 43 ++++++++--------- test/all/parsing/expressions.mocha.js | 69 +++++++++++++-------------- 3 files changed, 57 insertions(+), 59 deletions(-) diff --git a/lib/parsing/createPathExpression.js b/lib/parsing/createPathExpression.js index a0ade7c28..f8b827778 100644 --- a/lib/parsing/createPathExpression.js +++ b/lib/parsing/createPathExpression.js @@ -62,7 +62,7 @@ function reducePath(node, segment, afterSegments) { var segments = [segment]; if (afterSegments) segments = segments.concat(afterSegments); var relative = false; - while (node = node.object) { + while ((node = node.object)) { if (node.type === Syntax.MemberExpression) { if (node.computed) { return reduceMemberExpression(node, segments); @@ -232,7 +232,7 @@ function reduceObjectExpression(node) { function getKeyName(key) { return (key.type === Syntax.Identifier) ? key.name : (key.type === Syntax.Literal) ? key.value : - unexpected(key); + unexpected(key); } function reduceSequenceExpression(node, afterSegments) { diff --git a/lib/parsing/index.js b/lib/parsing/index.js index 11f5b28f2..001a8b2eb 100644 --- a/lib/parsing/index.js +++ b/lib/parsing/index.js @@ -1,13 +1,12 @@ +var derbyTemplates = require('derby-templates'); var htmlUtil = require('html-util'); var path = require('path'); var App = require('../App'); +var createPathExpression = require('./createPathExpression'); +var markup = require('./markup'); -var htmlUtil = require('html-util'); -var derbyTemplates = require('derby-templates'); var templates = derbyTemplates.templates; var expressions = derbyTemplates.expressions; -var createPathExpression = require('./createPathExpression'); -var markup = require('./markup'); exports.createTemplate = createTemplate; exports.createStringTemplate = createStringTemplate; @@ -49,11 +48,11 @@ function createTemplate(source, view) { source = escapeBraced(source); parseNode = new ParseNode(view); htmlUtil.parse(source, { - start: parseHtmlStart - , end: parseHtmlEnd - , text: parseHtmlText - , comment: parseHtmlComment - , other: parseHtmlOther + start: parseHtmlStart, + end: parseHtmlEnd, + text: parseHtmlText, + comment: parseHtmlComment, + other: parseHtmlOther }); // Allow for certain elements at the end of a template to not be closed. This // is especially important so that and tags can be omitted, @@ -128,12 +127,14 @@ function parseAttributes(attributes) { if (parseNode.content.length === 1) { var item = parseNode.content[0]; attributesMap[key] = - (item instanceof templates.Text) ? new templates.Attribute(item.data, nsUri) : - (item instanceof templates.DynamicText) ? - (item.expression instanceof expressions.LiteralExpression) ? - new templates.Attribute(item.expression.value, nsUri) : - new templates.DynamicAttribute(item.expression, nsUri) : - new templates.DynamicAttribute(item, nsUri); + (item instanceof templates.Text) ? + new templates.Attribute(item.data, nsUri) : + (item instanceof templates.DynamicText) ? + (item.expression instanceof expressions.LiteralExpression) ? + new templates.Attribute(item.expression.value, nsUri) : + new templates.DynamicAttribute(item.expression, nsUri) + : + new templates.DynamicAttribute(item, nsUri); } else if (parseNode.content.length > 1) { var template = new templates.Template(parseNode.content, value); @@ -376,9 +377,9 @@ function viewAttributesFromElement(element) { viewAttributes[camelCased] = (attribute.expression instanceof templates.Template) ? new templates.ViewParent(attribute.expression) : - (attribute.expression instanceof expressions.Expression) ? - new expressions.ViewParentExpression(attribute.expression) : - attribute.data; + (attribute.expression instanceof expressions.Expression) ? + new expressions.ViewParentExpression(attribute.expression) : + attribute.data; } return viewAttributes; } @@ -651,9 +652,8 @@ function viewAttributesFromExpression(expression) { var value = object[key]; viewAttributes[key] = (value instanceof expressions.LiteralExpression) ? value.value : - (value instanceof expressions.Expression) ? - new expressions.ViewParentExpression(value) : - value; + (value instanceof expressions.Expression) ? new expressions.ViewParentExpression(value) : + value; } return viewAttributes; } @@ -786,7 +786,6 @@ function createExpression(source) { // Parse value expression // - // A value expression has zero or many keywords and an expression } else { path = source; diff --git a/test/all/parsing/expressions.mocha.js b/test/all/parsing/expressions.mocha.js index b61cdb5ae..3911cbb2a 100644 --- a/test/all/parsing/expressions.mocha.js +++ b/test/all/parsing/expressions.mocha.js @@ -7,66 +7,65 @@ var create = require('../lib/createPathExpression'); var controller = { plus: function(a, b) { return a + b; - } -, minus: function(a, b) { + }, + minus: function(a, b) { return a - b; - } -, greeting: function() { + }, + greeting: function() { return 'Hi.'; - } -, keys: function(object) { + }, + keys: function(object) { var keys = []; for (var key in object) { keys.push(key); } return keys; - } -, passThrough: function(value) { + }, + passThrough: function(value) { return value; - } -, informal: { + }, + informal: { greeting: function() { return 'Yo!'; } - } -, Date: Date -, global: global + }, + Date: Date, + global: global }; controller.model = { data: { - key: 'green' - , _page: { + key: 'green', + _page: { colors: { green: { - name: 'Green' - , hex: '#0f0' - , rgb: [0, 255, 0] - , light: { + name: 'Green', + hex: '#0f0', + rgb: [0, 255, 0], + light: { hex: '#90ee90' - } - , dark: { + }, + dark: { hex: '#006400' } } - } - , key: 'green' - , channel: 0 - , variation: 'light' - , variationHex: 'light.hex' - , keys: ['red', 'green'] - , index: 1 - - , nums: [2, 11, 3, 7] - , first: 2 - , second: 3 - , date: new Date(1000) + }, + key: 'green', + channel: 0, + variation: 'light', + variationHex: 'light.hex', + keys: ['red', 'green'], + index: 1, + nums: [2, 11, 3, 7], + first: 2, + second: 3, + date: new Date(1000) } } }; controller.model.scope = function(path) { return { - _at: path - , path: function() { + _at: path, + path: function() { return this._at; } }; From 816d32b39f89ee7c696ea252e6e9eaf7992cab5f Mon Sep 17 00:00:00 2001 From: Eric Hwang Date: Fri, 16 Jun 2023 15:34:23 -0700 Subject: [PATCH 08/11] Switch from external derby-templates and derby-parsing to the now-internal versions, fix tests and linting --- lib/App.js | 4 +- lib/Page.js | 2 +- lib/components.js | 2 +- lib/eventmodel.js | 2 +- lib/parsing/createPathExpression.js | 2 +- lib/parsing/index.js | 2 +- lib/parsing/markup.js | 2 +- lib/templates/index.js | 7 +- package.json | 2 - test/all/parsing/dependencies.mocha.js | 112 +++++++------- test/all/parsing/expressions.mocha.js | 12 +- test/all/parsing/templates.mocha.js | 200 ++++++++++++------------- test/all/parsing/truthy.mocha.js | 4 +- test/browser/components.js | 2 +- test/browser/util.js | 2 +- 15 files changed, 178 insertions(+), 179 deletions(-) diff --git a/lib/App.js b/lib/App.js index 64ae11ca8..9ad3b33be 100644 --- a/lib/App.js +++ b/lib/App.js @@ -10,7 +10,7 @@ var path = require('path'); var EventEmitter = require('events').EventEmitter; var tracks = require('tracks'); var util = require('racer/lib/util'); -var derbyTemplates = require('derby-templates'); +var derbyTemplates = require('./templates'); var templates = derbyTemplates.templates; var components = require('./components'); var PageBase = require('./Page'); @@ -254,7 +254,7 @@ App.prototype.component = function(name, constructor, isDependency) { throw new Error('Component may not specify both a view file and source'); } - // TODO: DRY. This is copy-pasted from derby-templates + // TODO: DRY. This is copy-pasted from ./templates var mapName = viewName.replace(/:index$/, ''); var currentView = this.views.nameMap[mapName]; var currentConstructor = (currentView && currentView.componentFactory) ? diff --git a/lib/Page.js b/lib/Page.js index 0d36ea406..30622a43b 100644 --- a/lib/Page.js +++ b/lib/Page.js @@ -1,4 +1,4 @@ -var derbyTemplates = require('derby-templates'); +var derbyTemplates = require('./templates'); var contexts = derbyTemplates.contexts; var expressions = derbyTemplates.expressions; var templates = derbyTemplates.templates; diff --git a/lib/components.js b/lib/components.js index ff95b06b1..d2d1113a4 100644 --- a/lib/components.js +++ b/lib/components.js @@ -8,7 +8,7 @@ */ var util = require('racer/lib/util'); -var derbyTemplates = require('derby-templates'); +var derbyTemplates = require('./templates'); var templates = derbyTemplates.templates; var expressions = derbyTemplates.expressions; var Controller = require('./Controller'); diff --git a/lib/eventmodel.js b/lib/eventmodel.js index 331db4684..c9032b2c0 100644 --- a/lib/eventmodel.js +++ b/lib/eventmodel.js @@ -1,4 +1,4 @@ -var expressions = require('derby-templates').expressions; +var expressions = require('./templates').expressions; // The many trees of bindings: // diff --git a/lib/parsing/createPathExpression.js b/lib/parsing/createPathExpression.js index f8b827778..c11968d5c 100644 --- a/lib/parsing/createPathExpression.js +++ b/lib/parsing/createPathExpression.js @@ -1,4 +1,4 @@ -var derbyTemplates = require('derby-templates'); +var derbyTemplates = require('../templates'); var expressions = derbyTemplates.expressions; var operatorFns = derbyTemplates.operatorFns; var esprima = require('esprima-derby'); diff --git a/lib/parsing/index.js b/lib/parsing/index.js index 001a8b2eb..16db0bc5b 100644 --- a/lib/parsing/index.js +++ b/lib/parsing/index.js @@ -1,4 +1,4 @@ -var derbyTemplates = require('derby-templates'); +var derbyTemplates = require('../templates'); var htmlUtil = require('html-util'); var path = require('path'); var App = require('../App'); diff --git a/lib/parsing/markup.js b/lib/parsing/markup.js index c0fc143a8..691d692d9 100644 --- a/lib/parsing/markup.js +++ b/lib/parsing/markup.js @@ -1,5 +1,5 @@ var EventEmitter = require('events').EventEmitter; -var templates = require('derby-templates').templates; +var templates = require('../templates').templates; var createPathExpression = require('./createPathExpression'); // TODO: Should be its own module diff --git a/lib/templates/index.js b/lib/templates/index.js index f369866aa..22ee76465 100644 --- a/lib/templates/index.js +++ b/lib/templates/index.js @@ -1,2 +1,5 @@ -// TODO: Refactor and include derby-templates module in derby itself -module.exports = require('derby-templates'); +exports.contexts = require('./contexts'); +exports.expressions = require('./expressions'); +exports.operatorFns = require('./operatorFns'); +exports.options = require('./dependencyOptions'); +exports.templates = require('./templates'); diff --git a/package.json b/package.json index 83ee3bef5..cf03e7aea 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,6 @@ }, "dependencies": { "chokidar": "^3.5.3", - "derby-parsing": "^0.8.0", - "derby-templates": "^0.8.1", "html-util": "^0.2.3", "qs": "^6.11.0", "racer": "^1.0.3", diff --git a/test/all/parsing/dependencies.mocha.js b/test/all/parsing/dependencies.mocha.js index 7cb6696b8..a9e529c62 100644 --- a/test/all/parsing/dependencies.mocha.js +++ b/test/all/parsing/dependencies.mocha.js @@ -1,79 +1,77 @@ -var expect = require('expect.js'); -var derbyTemplates = require('derby-templates'); +var expect = require('chai').expect; +var derbyTemplates = require('../../../lib/templates'); var contexts = derbyTemplates.contexts; -var expressions = derbyTemplates.expressions; var templates = derbyTemplates.templates; -var parsing = require('../lib/index'); +var parsing = require('../../../lib/parsing'); var createExpression = parsing.createExpression; var createTemplate = parsing.createTemplate; var controller = { plus: function(a, b) { return a + b; - } -, minus: function(a, b) { + }, + minus: function(a, b) { return a - b; - } -, greeting: function() { + }, + greeting: function() { return 'Hi.'; - } -, keys: function(object) { + }, + keys: function(object) { var keys = []; for (var key in object) { keys.push(key); } return keys; - } -, passThrough: function(value) { + }, + passThrough: function(value) { return value; - } -, informal: { + }, + informal: { greeting: function() { return 'Yo!'; } - } -, Date: Date -, global: global + }, + Date: Date, + global: global }; controller.model = { data: { - key: 'green' - , lightTemplate: createTemplate('light {{_page.colors[key].name}}') - , _page: { + key: 'green', + lightTemplate: createTemplate('light {{_page.colors[key].name}}'), + _page: { colors: { green: { - name: 'Green' - , hex: '#0f0' - , rgb: [0, 255, 0] - , light: { + name: 'Green', + hex: '#0f0', + rgb: [0, 255, 0], + light: { hex: '#90ee90' - } - , dark: { + }, + dark: { hex: '#006400' } } - } - , key: 'green' - , channel: 0 - , variation: 'light' - , variationHex: 'light.hex' - , keys: ['red', 'green'] - , index: 1 - , tagName: 'div' - , html: '
    Hi
    ' - - , nums: [2, 11, 3, 7] - , first: 2 - , second: 3 - , year: 2018 - , date: new Date(1000) + }, + key: 'green', + channel: 0, + variation: 'light', + variationHex: 'light.hex', + keys: ['red', 'green'], + index: 1, + tagName: 'div', + html: '
    Hi
    ', + nums: [2, 11, 3, 7], + first: 2, + second: 3, + year: 2018, + date: new Date(1000) } } }; controller.model.scope = function(path) { return { - _at: path - , path: function() { + _at: path, + path: function() { return this._at; } }; @@ -101,7 +99,7 @@ describe('template dependencies', function() { describe('text', function() { it('gets dependencies', function() { var template = createTemplate('Hi'); - expect(template.dependencies(context)).to.be.null; + expect(template.dependencies(context)).to.equal(undefined); expect(template.get(context)).to.equal('Hi'); }); }); @@ -174,7 +172,7 @@ describe('template dependencies', function() { '{{each [33, 77] as #key, #i}}' + '{{#i}},{{#key}};' + '{{/each}}'); - expect(stripContexts(template.dependencies(context))).to.be.null; + expect(stripContexts(template.dependencies(context))).to.equal(undefined); expect(template.get(context)).to.equal('0,33;1,77;'); }); }); @@ -182,19 +180,19 @@ describe('template dependencies', function() { describe('HTML', function() { it('gets empty Template dependencies', function() { var template = createTemplate(''); - expect(template.dependencies(context)).to.be.null; + expect(template.dependencies(context)).to.equal(null); expect(template.get(context)).to.equal(''); }); it('gets Doctype dependencies', function() { var template = createTemplate(''); - expect(template.dependencies(context)).to.be.null; + expect(template.dependencies(context)).to.equal(undefined); expect(template.get(context)).to.equal(''); }); it('gets Text dependencies', function() { var template = createTemplate('Hi!'); - expect(template.dependencies(context)).to.be.null; + expect(template.dependencies(context)).to.equal(undefined); expect(template.get(context)).to.equal('Hi!'); }); @@ -208,7 +206,7 @@ describe('template dependencies', function() { it('gets Comment dependencies', function() { var template = createTemplate(''); - expect(template.dependencies(context)).to.be.null; + expect(template.dependencies(context)).to.equal(undefined); expect(template.get(context)).to.equal(''); }); @@ -236,7 +234,7 @@ describe('template dependencies', function() { // It is not currently possible to create a template of type Html via // derby-parsing, as there is no syntax that would require it var template = new templates.Html('
    Hi
    '); - expect(template.dependencies(context)).to.be.null; + expect(template.dependencies(context)).to.equal(undefined); expect(template.get(context)).to.equal('
    Hi
    '); }); @@ -250,7 +248,7 @@ describe('template dependencies', function() { it('gets Element dependencies', function() { var template = createTemplate('
    Hi
    there
    '); - expect(template.dependencies(context)).to.be.null; + expect(template.dependencies(context)).to.equal(undefined); expect(template.get(context)).to.equal('
    Hi
    there
    '); }); @@ -264,7 +262,7 @@ describe('template dependencies', function() { it('gets Attribute dependencies', function() { var template = createTemplate(''); - expect(template.dependencies(context)).to.be.null; + expect(template.dependencies(context)).to.equal(undefined); expect(template.get(context)).to.equal(''); }); @@ -283,7 +281,7 @@ describe('expression dependencies', function() { describe('literal', function() { it('gets literal dependencies', function() { var expression = createExpression('34'); - expect(expression.dependencies(context)).to.be.null; + expect(expression.dependencies(context)).to.equal(undefined); }); }); @@ -490,7 +488,7 @@ describe('expression dependencies', function() { ['_page', 'colors', 'green', 'name'], ['lightTemplate'] ]); - expect(expression.get(blockContext)).a(templates.Template); + expect(expression.get(blockContext)).an.instanceOf(templates.Template); }); it('gets subpath from template in model dependencies', function() { @@ -561,7 +559,7 @@ describe('expression dependencies', function() { ['_page', 'colors', 'green', 'name'], ['lightTemplate'] ]); - expect(expression.get(blockContext)).a(templates.Template); + expect(expression.get(blockContext)).an.instanceOf(templates.Template); }); it('gets subpath from template in model dependencies', function() { @@ -700,7 +698,7 @@ describe('expression dependencies', function() { expect(expression.dependencies(viewContext)).to.eql([ ['_page', 'colors', 'green', 'name'] ]); - expect(expression.get(viewContext)).a(templates.Template); + expect(expression.get(viewContext)).an.instanceOf(templates.Template); }); it('gets subpath from function template attribute dependencies', function() { @@ -726,7 +724,7 @@ describe('expression dependencies', function() { ['_page', 'colors', 'green', 'name'], ['lightTemplate'] ]); - expect(expression.get(viewContext)).a(templates.Template); + expect(expression.get(viewContext)).an.instanceOf(templates.Template); }); it('gets subpath from template in model attribute dependencies', function() { diff --git a/test/all/parsing/expressions.mocha.js b/test/all/parsing/expressions.mocha.js index 3911cbb2a..19a71a001 100644 --- a/test/all/parsing/expressions.mocha.js +++ b/test/all/parsing/expressions.mocha.js @@ -1,8 +1,8 @@ -var expect = require('expect.js'); -var derbyTemplates = require('derby-templates'); +var expect = require('chai').expect; +var derbyTemplates = require('../../../lib/templates'); var contexts = derbyTemplates.contexts; var expressions = derbyTemplates.expressions; -var create = require('../lib/createPathExpression'); +var create = require('../../../lib/parsing/createPathExpression'); var controller = { plus: function(a, b) { @@ -257,7 +257,7 @@ describe('Expression::get', function() { it('gets a `new` expression without arguments', function() { var expression = create('new Date'); var date = expression.get(context); - expect(date).to.be.a(Date); + expect(date).to.be.an.instanceOf(Date); }); it('gets a `new` expression with arguments', function() { @@ -269,7 +269,7 @@ describe('Expression::get', function() { it('gets `new` expression on nested path', function() { var expression = create('new global.Error()'); - expect(expression.get(context)).to.be.a(Error); + expect(expression.get(context)).to.be.an.instanceOf(Error); }); // None of these are supported yet, but ideally they would be @@ -304,7 +304,7 @@ describe('Expression::get', function() { expect(create('\'Howdy\'').get()).equal('Howdy'); // Regular Expressions var re = create('/([0-9]+)/').get(); - expect(re).to.be.a(RegExp); + expect(re).to.be.an.instanceOf(RegExp); expect(re.source).equal('([0-9]+)'); // Other expect(create('null').get()).equal(null); diff --git a/test/all/parsing/templates.mocha.js b/test/all/parsing/templates.mocha.js index d6eca0430..0e3944703 100644 --- a/test/all/parsing/templates.mocha.js +++ b/test/all/parsing/templates.mocha.js @@ -1,23 +1,23 @@ -var expect = require('expect.js'); -var derbyTemplates = require('derby-templates'); +var expect = require('chai').expect; +var derbyTemplates = require('../../../lib/templates'); var contexts = derbyTemplates.contexts; var templates = derbyTemplates.templates; -var parsing = require('../lib/index'); +var parsing = require('../../../lib/parsing'); var model = { data: { _page: { - greeting: 'Howdy!' - , zero: 0 - , yep: true - , nope: false - , nada: null - , letters: ['A', 'B', 'C'] - , emptyList: [] - , matrix: [[0, 1], [1, 0]] - , view: 'section' - , html: 'Qua?' - , tag: 'strong' + greeting: 'Howdy!', + zero: 0, + yep: true, + nope: false, + nada: null, + letters: ['A', 'B', 'C'], + emptyList: [], + matrix: [[0, 1], [1, 0]], + view: 'section', + html: 'Qua?', + tag: 'strong' } } }; @@ -28,23 +28,23 @@ var context = new contexts.Context(contextMeta, controller); describe('Parse and render literal HTML', function() { var literalTests = { - 'empty string': '' - , 'empty div': '
    ' - , 'div with attributes': '
    ' - , 'text': 'Hi.' - , 'conditional comment': '' - , 'div containing text': '
    ' - , 'nested divs': '
    ' - , 'sibling divs': '
    ' - , 'input': '' - , 'self-closing input': '' - , 'void and nonvoid elements': '

    Hi

    ' - , 'HTML5 doctype': '' - , 'HTML4 doctype': '' - , 'XHTML doctype': '' - , 'MathML 1.01 doctype': '' - , 'html5 basic page': '

    ' - , 'page missing end body and html tags': '

    ' + 'empty string': '', + 'empty div': '
    ', + 'div with attributes': '
    ', + 'text': 'Hi.', + 'conditional comment': '', + 'div containing text': '
    ', + 'nested divs': '
    ', + 'sibling divs': '
    ', + 'input': '', + 'self-closing input': '', + 'void and nonvoid elements': '

    Hi

    ', + 'HTML5 doctype': '', + 'HTML4 doctype': '', + 'XHTML doctype': '', + 'MathML 1.01 doctype': '', + 'html5 basic page': '

    ', + 'page missing end body and html tags': '

    ' }; for (var name in literalTests) { @@ -60,19 +60,19 @@ describe('Parse and render literal HTML', function() { it('throws on a mismatched closing HTML tag', function() { expect(function() { parsing.createTemplate('
    '); - }).to.throwException(/Mismatched closing HTML tag: <\/div>/); + }).to.throw(/Mismatched closing HTML tag: <\/div>/); }); it('throws on a missing tag', function() { expect(function() { parsing.createTemplate(''); - }).to.throwException(/Missing closing HTML tag: <\/span>/); + }).to.throw(/Missing closing HTML tag: <\/span>/); }); it('throws on a missing
    tag', function() { expect(function() { parsing.createTemplate('
    '); - }).to.throwException(/Missing closing HTML tag: <\/div>/); + }).to.throw(/Missing closing HTML tag: <\/div>/); }); }); @@ -109,28 +109,28 @@ describe('Parse and render dynamic text and blocks', function() { expect(function() { var template = parsing.createTemplate(source); console.log(template.content[0]); - }).to.throwException(/Alias must be an identifier starting with "#"/); + }).to.throw(/Alias must be an identifier starting with "#"/); }); it('trailing parenthesis in alias', function() { var source = '{{with _page.greeting as #greeting)}}{{/with}}'; expect(function() { var template = parsing.createTemplate(source); - }).to.throwException(/Unexpected token \)/); + }).to.throw(/Unexpected token \)/); }); it('brackets in alias', function() { var source = '{{with _page.greeting as #greeting[0]}}{{/with}}'; expect(function() { var template = parsing.createTemplate(source); - }).to.throwException(/Alias must not have dots or brackets/); + }).to.throw(/Alias must not have dots or brackets/); }); it('dots in alias', function() { var source = '{{with _page.greeting as #greeting.a}}{{/with}}'; expect(function() { var template = parsing.createTemplate(source); - }).to.throwException(/Alias must not have dots or brackets/); + }).to.throw(/Alias must not have dots or brackets/); }); }); @@ -181,8 +181,8 @@ describe('Parse and render dynamic text and blocks', function() { '{{each this}}' + '{{this}}.' + '{{/each}};' + - '{{/each}}' - , '0.1.;1.0.;' + '{{/each}}', + '0.1.;1.0.;' ); test( '{{each _page.matrix}}' + @@ -194,8 +194,8 @@ describe('Parse and render dynamic text and blocks', function() { '{{/each}}' + '{{this}}.' + '{{/each}};' + - '{{/each}}' - , '0!1!|1!0!|0.' + + '{{/each}}', + '0!1!|1!0!|0.' + '0!1!|1!0!|1.;' + '0!1!|1!0!|1.' + '0!1!|1!0!|0.;' @@ -219,56 +219,56 @@ describe('Parse and render dynamic text and blocks', function() { var source = '{{each _page.letters as letter}}{{/each}}'; expect(function() { var template = parsing.createTemplate(source); - }).to.throwException(/Alias must be an identifier starting with "#"/); + }).to.throw(/Alias must be an identifier starting with "#"/); }); it('trailing parenthesis in alias', function() { var source = '{{each _page.letters as #letter)}}{{/each}}'; expect(function() { var template = parsing.createTemplate(source); - }).to.throwException(/Unexpected token \)/); + }).to.throw(/Unexpected token \)/); }); it('brackets in alias', function() { var source = '{{each _page.letters as #letter[0]}}{{/each}}'; expect(function() { var template = parsing.createTemplate(source); - }).to.throwException(/Alias must not have dots or brackets/); + }).to.throw(/Alias must not have dots or brackets/); }); it('dots in alias', function() { var source = '{{each _page.letters as #letter.a}}{{/each}}'; expect(function() { var template = parsing.createTemplate(source); - }).to.throwException(/Alias must not have dots or brackets/); + }).to.throw(/Alias must not have dots or brackets/); }); it('no pound sign at start of index alias', function() { var source = '{{each _page.letters as #letter, index}}{{/each}}'; expect(function() { var template = parsing.createTemplate(source); - }).to.throwException(/Alias must be an identifier starting with "#"/); + }).to.throw(/Alias must be an identifier starting with "#"/); }); it('trailing parenthesis in index alias', function() { var source = '{{each _page.letters as #letter, #index)}}{{/each}}'; expect(function() { var template = parsing.createTemplate(source); - }).to.throwException(/Unexpected token \)/); + }).to.throw(/Unexpected token \)/); }); it('brackets in index alias', function() { var source = '{{each _page.letters as #letter, #index[0]}}{{/each}}'; expect(function() { var template = parsing.createTemplate(source); - }).to.throwException(/Alias must not have dots or brackets/); + }).to.throw(/Alias must not have dots or brackets/); }); it('dots in index alias', function() { var source = '{{each _page.letters as #letter, #index.a}}{{/each}}'; expect(function() { var template = parsing.createTemplate(source); - }).to.throwException(/Alias must not have dots or brackets/); + }).to.throw(/Alias must not have dots or brackets/); }); }); }); @@ -462,16 +462,16 @@ describe('View insertion', function() { it('can be defined as an option of a view', function() { var views = new templates.Views(); context.meta.views = views; - views.register('body' - , '' + + views.register('body', + '' + '<b>Hi</b>' + 'More text' + '' ); - views.register('section' - , '

    {{@title}}

    ' + - '
    {{@content}}
    ' - , {attributes: 'title'} + views.register('section', + '

    {{@title}}

    ' + + '
    {{@content}}
    ', + {attributes: 'title'} ); var view = views.find('body'); expect(view.get(context)).equal('

    Hi

    More text
    '); @@ -480,16 +480,16 @@ describe('View insertion', function() { it('translates dashed tag name into camel-cased attribute name', function() { var views = new templates.Views(); context.meta.views = views; - views.register('body' - , '' + + views.register('body', + '' + 'Hi' + 'More text' + '' ); - views.register('section' - , '

    {{@mainTitle}}

    ' + - '
    {{@content}}
    ' - , {attributes: 'main-title'} + views.register('section', + '

    {{@mainTitle}}

    ' + + '
    {{@content}}
    ', + {attributes: 'main-title'} ); var view = views.find('body'); expect(view.get(context)).equal('

    Hi

    More text
    '); @@ -498,14 +498,14 @@ describe('View insertion', function() { it('can be dynamically defined with a generic attribute tag', function() { var views = new templates.Views(); context.meta.views = views; - views.register('body' - , '' + + views.register('body', + '' + 'Hi' + 'More text' + '' ); - views.register('section' - , '

    {{@title}}

    ' + + views.register('section', + '

    {{@title}}

    ' + '
    {{@content}}
    ' ); var view = views.find('body'); @@ -517,22 +517,22 @@ describe('View insertion', function() { it('can be defined as an option of a view', function() { var views = new templates.Views(); context.meta.views = views; - views.register('body' - , '' + + views.register('body', + '' + 'Hi' + 'Ho' + '' ); - views.register('tabs' - , '
      ' + + views.register('tabs', + '
        ' + '{{each @panes}}' + '
      • {{this.title}}
      • ' + '{{/each}}' + '
      ' + '{{each @panes}}' + '
      {{this.content}}
      ' + - '{{/each}}' - , {arrays: 'pane/panes'} + '{{/each}}', + {arrays: 'pane/panes'} ); var view = views.find('body'); expect(view.get(context)).equal( @@ -548,14 +548,14 @@ describe('View insertion', function() { it('can be dynamically defined with generic array tags', function() { var views = new templates.Views(); context.meta.views = views; - views.register('body' - , '' + + views.register('body', + '' + 'Hi' + 'Ho' + '' ); - views.register('tabs' - , '
        ' + + views.register('tabs', + '
          ' + '{{each @panes}}' + '
        • {{this.title}}
        • ' + '{{/each}}' + @@ -578,22 +578,22 @@ describe('View insertion', function() { it('passes in expression values', function() { var views = new templates.Views(); context.meta.views = views; - views.register('body' - , '' + + views.register('body', + '' + '{{_page.letters[0]}}' + '{{33}}' + '' ); - views.register('tabs' - , '
            ' + + views.register('tabs', + '
              ' + '{{each @panes as #pane}}' + '
            • {{#pane.title}}
            • ' + '{{/each}}' + '
            ' + '{{each @panes as #pane}}' + '
            {{#pane.content}}
            ' + - '{{/each}}' - , {arrays: 'pane/panes'} + '{{/each}}', + {arrays: 'pane/panes'} ); var view = views.find('body'); expect(view.get(context)).equal( @@ -609,8 +609,8 @@ describe('View insertion', function() { it('is rendered before passing to view functions', function() { var views = new templates.Views(); context.meta.views = views; - views.register('body' - , '' + + views.register('body', + '' + '{{_page.letters[0]}}' + '{{33}}' + '' @@ -630,18 +630,18 @@ describe('View insertion', function() { it('supports "within" attribute on child tags to use context from inside view', function() { var views = new templates.Views(); context.meta.views = views; - views.register('body' - , '' + + views.register('body', + '' + '{{#item}}' + '' ); - views.register('custom-list' - , '
              ' + + views.register('custom-list', + '
                ' + '{{each @items as #item}}' + '
              • {{@itemContent}}
              • ' + '{{/each}}' + - '
              ' - , {attributes: 'item-content'} + '
            ', + {attributes: 'item-content'} ); var view = views.find('body'); expect(view.get(context)).equal('
            • item A
            • item B
            '); @@ -650,14 +650,14 @@ describe('View insertion', function() { it('supports "within" attribute on child array tags to use context from inside view', function() { var views = new templates.Views(); context.meta.views = views; - views.register('body' - , '' + + views.register('body', + '' + 'Text: {{#item}}' + 'Length: {{#item.length}}' + '' ); - views.register('custom-table' - , '' + + views.register('custom-table', + '
            ' + '{{each @items as #item}}' + '' + '{{each @rowCells as #rowCell}}' + @@ -665,8 +665,8 @@ describe('View insertion', function() { '{{/each}}' + '' + '{{/each}}' + - '
            ' - , {arrays: 'row-cell/rowCells'} + '', + {arrays: 'row-cell/rowCells'} ); var view = views.find('body'); expect(view.get(context)).equal( @@ -740,15 +740,15 @@ describe('View insertion', function() { it('gets each context', function() { var views = new templates.Views(); context.meta.views = views; - views.register('body' - , '
              ' + + views.register('body', + '
                ' + '{{each _page.matrix as #row}}' + '' + '{{/each}}' + '
              ' ); - views.register('row' - , '
            1. ' + + views.register('row', + '
            2. ' + '
                ' + '{{each #row as #item}}' + '
              1. {{#item}}
              2. ' + diff --git a/test/all/parsing/truthy.mocha.js b/test/all/parsing/truthy.mocha.js index 9d065046a..f150f5cb9 100644 --- a/test/all/parsing/truthy.mocha.js +++ b/test/all/parsing/truthy.mocha.js @@ -1,5 +1,5 @@ -var expect = require('expect.js'); -var parsing = require('../lib/index'); +var expect = require('chai').expect; +var parsing = require('../../../lib/parsing'); describe('template truthy', function() { diff --git a/test/browser/components.js b/test/browser/components.js index 0069bc2f6..bf3cfdbc3 100644 --- a/test/browser/components.js +++ b/test/browser/components.js @@ -1,5 +1,5 @@ var expect = require('chai').expect; -var templates = require('derby-templates').templates; +var templates = require('../../lib/templates').templates; var derby = require('./util').derby; describe('components', function() { diff --git a/test/browser/util.js b/test/browser/util.js index 0f89b6632..54ec1d3c3 100644 --- a/test/browser/util.js +++ b/test/browser/util.js @@ -1,6 +1,6 @@ var chai = require('chai'); var DerbyStandalone = require('../../lib/DerbyStandalone'); -require('derby-parsing'); +require('../../lib/parsing'); require('../../test-utils').assertions(window, chai.Assertion); exports.derby = new DerbyStandalone(); From fe7ac405a553373e9221d8b9303bee65ed57415c Mon Sep 17 00:00:00 2001 From: Eric Hwang Date: Fri, 16 Jun 2023 16:12:42 -0700 Subject: [PATCH 09/11] Fix NPM lint and test scripts to pick up all files via glob NPM runs package.json scripts in `sh`, which has no recursive ** glob support in some environments like Mac OS. Specifying the globs as strings lets ESLint/Mocha handle the globs, correctly. --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index cf03e7aea..d74b277bb 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,8 @@ "main": "index.js", "scripts": { "checks": "npm run lint && npm test", - "lint": "npx eslint *.js lib/**/*.js test/**/*.js test-utils/**/*.js", - "test": "npx mocha test/all/*.mocha.js test/dom/*.mocha.js test/server/*.mocha.js", + "lint": "npx eslint '**/*.js'", + "test": "npx mocha 'test/all/**/*.mocha.js' 'test/dom/**/*.mocha.js' 'test/server/**/*.mocha.js'", "test-browser": "node test/server.js" }, "dependencies": { From 29aa680b76fecd1c41fc7a8109eb492a644e1837 Mon Sep 17 00:00:00 2001 From: Eric Hwang Date: Fri, 16 Jun 2023 17:00:18 -0700 Subject: [PATCH 10/11] Make App.js ES5-compatible again, update ESLint rules around ES versions --- .eslintrc | 12 ++++++++++-- lib/App.js | 11 ++++++----- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/.eslintrc b/.eslintrc index db482617c..d6b5f88bf 100644 --- a/.eslintrc +++ b/.eslintrc @@ -3,7 +3,7 @@ "node": true }, "parserOptions": { - "ecmaVersion": 8 + "ecmaVersion": 5 }, "globals": { "window": false, @@ -37,7 +37,15 @@ }, "overrides": [ { - "files": ["test/**/*.mocha.js", "test/browser/*.js"], + // Files that are only run in Node can use more modern ES syntax. + "files": ["**/*ForServer.js", "test/**/*.mocha.js"], + "parserOptions": { + // Node 16 LTS supports up through ES2021. + "ecmaVersion": 2021 + } + }, + { + "files": ["test/**/*.js"], "env": {"mocha": true, "node": true} } ] diff --git a/lib/App.js b/lib/App.js index 9ad3b33be..cc480d14a 100644 --- a/lib/App.js +++ b/lib/App.js @@ -17,7 +17,8 @@ var PageBase = require('./Page'); module.exports = App; -globalThis.APPS = globalThis.APPS || new Map(); +// TODO: Change to Map once we officially drop support for ES5. +global.APPS = global.APPS || {}; function App(derby, name, filename, options) { EventEmitter.call(this); @@ -71,14 +72,14 @@ App.prototype._finishInit = function() { var previousAppInfo; if (!util.isProduction) { - previousAppInfo = globalThis.APPS.get(this.name); + previousAppInfo = global.APPS[this.name]; if (previousAppInfo) { previousAppInfo.app._destroyCurrentPage(); } - globalThis.APPS.set(this.name, { + global.APPS[this.name] = { app: this, initialState: data, - }); + }; } this.model.createConnection(data); @@ -120,7 +121,7 @@ App.prototype._getAppData = function () { if (script) { return App._parseInitialData(script.textContent); } else { - return globalThis.APPS.get(this.name).initialState; + return global.APPS[this.name].initialState; } } From 69a0cac7c6af8958c2214192a6e9bb3d71442897 Mon Sep 17 00:00:00 2001 From: Eric Hwang Date: Fri, 16 Jun 2023 17:12:26 -0700 Subject: [PATCH 11/11] Add package.json "files" field to not publish tests, add missing esprima-derby dependency from derby-parsing --- package.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/package.json b/package.json index d74b277bb..39f256579 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,11 @@ "url": "git://github.com/derbyjs/derby.git" }, "main": "index.js", + "files": [ + "*.js", + "lib/**/*.js", + "test-utils/**/*.js" + ], "scripts": { "checks": "npm run lint && npm test", "lint": "npx eslint '**/*.js'", @@ -16,6 +21,7 @@ }, "dependencies": { "chokidar": "^3.5.3", + "esprima-derby": "^0.1.0", "html-util": "^0.2.3", "qs": "^6.11.0", "racer": "^1.0.3",