templz
Version:
Mustache-inspired template engine working with strings and elements
1,184 lines (1,063 loc) • 51.5 kB
JavaScript
/*!
* Templz.js - Logic-less <template>s with a sense
*/
/**
* Token type definition
* @typedef {Object} Token
* @property {String} type
* @property {*} value
* @property {Token[]} [children]
* @property {String} [indent] In string templates only
* @property {Element} [node] In fragment templates only
* @property {Object<String, StringTemplate>} [attributes] In fragment templates only
*/
/**
* Bundle of utility functions that rely on the context
* @typedef {Object} Utils
* @property {Function} isNode
* @property {Function} isFragment
* @property {Function} newText
* @property {Function} newFrag
* @property {Function} getFrag
* @property {Function} serializeFragment
* @property {Function} compile
*/
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define([], factory);
} else if (typeof exports === 'object') {
// Node. Does not work with strict CommonJS, but
// only CommonJS-like environments that support module.exports,
// like Node.
module.exports = factory();
} else {
// Browser globals (root is window)
root.Templz = factory();
}
})(this, function() {
"use strict";
/**
* @class
* @classdesc Dummy parent class for StringTemplate and FragmentTemplate
*/
function ParsedTemplate() {};
ParsedTemplate.prototype = {
// Prevents direct instantiations
constructor: function() { throw new TypeError("Illegal constructor"); }
};
/**
* Determines if Function.prototype.bind is supported
* @type {Boolean}
*/
var hasBind = "bind" in function(){},
/**
* Trims the whitespaces at the beginning and the end of a given string
* @see http://jsperf.com/native-trim-vs-regex
* @param {String} string
* @returns {String}
*/
trim = "".trim ? function(string) { return string.trim(); }
: (function(trimRE) {
return function(string) { return string.replace(trimRE, ""); }
})(/^\s+|\s+$/g),
/**
* Checks if the argument is an Array object. Polyfills Array.isArray.
* @function isArray
* @param {?*} object
* @returns {Boolean}
*/
isArray = Array.isArray || (function(toString) {
return function (object) { return toString.call(object) === "[object Array]"; };
})(Object.prototype.toString),
/**
* Returns the index of an item in a collection, or -1 if not found.
* Uses Array.prototype.indexOf is available.
* @function inArray
* @param {*} pivot Item to look for
* @param {Array} array
* @param {Number} [start=0] Index to start from
* @returns {Number}
*/
inArray = Array.prototype.indexOf ? function(pivot, array, start) {
return array.indexOf(pivot, start);
} : function(pivot, array, start) {
for (var i = start || 0; i < array.length; i++)
if (array[i] === pivot)
return i;
return -1;
},
/**
* String escaping for HTML/XML
* @param {String} string
* @returns {String}
*/
escapeString = (function(map, re) {
return function(string) {
return (string + "").replace(re, function(c) { return map[c]; });
};
})({
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
"/": "/"
}, /[&<>"'\/]/g);
/**
* Returns an array of two strings containing the tags
* @param {String|String[]}
* @returns {String[]}
*/
function getTags(tags) {
return typeof tags === "string" ? trim(tags).split(/\s+/, 2) : isArray(tags) && tags;
}
/**
* Returns the list of additional closing tags and the list of sections left open
* @param {Token[]} parsed
* @throws {Error}
* @returns {Token[][]}
*/
function getLoneSections(parsed) {
var opened = [],
closed = [],
i = 0, token;
for (; i < parsed.length;) {
token = parsed[i++];
if (token.type === "#" || token.type === "^")
opened.push(token.value);
else if (token.type === "/") {
if (opened.length) {
if (opened.pop() !== token.value)
// It shouldn't happen at this point
throw new Error("Unclosed section \"" + openSection[1] + "\"");
} else closed.push(token.value);
}
}
return [closed, opened];
}
/**
* Checks if the gives set of tokens is compatible with a given list of
* opened sections, and returns the tokens
* @param {Token[]} parsed
* @param {String[]} sections
* @throws {Error}
* @returns {String[]}
*/
function checkParsedString(parsed, sections) {
if (parsed.length) {
var lone = getLoneSections(parsed),
section, i = 0;
for (; i < lone[0].length; i++) {
section = sections.pop();
if (!section)
throw new Error("Unopened section \"" + lone[0][i] + "\"");
if (section !== lone[0][i])
throw new Error("Unclosed section \"" + section + "\"");
}
if (lone[1].length)
sections.push.apply(sections, lone[1]);
}
return parsed;
}
/**
* Assembles a list of tokens in a token tree, defining the chilren property
* in parent tokens.
* @param {Token[]} tokens
* @return {Token[]}
*/
function buildTree(tokens) {
var tree = [], stack = tree,
sections = [], token;
for (var i = 0; i < tokens.length;) {
token = tokens[i++];
if ((token.type === "#" || token.type === "^") && !token.children) {
stack.push(token);
sections.push(token);
token.children = stack = [];
} else if (token.type === "/") {
sections.pop();
stack = sections.length ? sections[sections.length - 1].children : tree;
} else stack.push(token);
}
return tree;
}
/**
* Checks a node's attributes to turn into templates when template tags are
* present, and returns a hashmap of string templates with attribute names
* as the keys.
* @param {Node} node
* @param {String[]} tags
* @returns {?Object<String, Token[]>}
*/
function parseAttributes(node, tags) {
var attribs = node.attributes,
attr, parsed,
attrset = null, j = 0;
for (; j < attribs.length; j++) {
attr = attribs[j].value;
if (attr && attr.indexOf(tags[0]) >= 0) {
parsed = parseString(attr, tags);
if (!parsed.length || parsed.length === 1 && parsed[0].type === "text") continue;
// Checking tags closure
parsed = buildTree(checkParsedString(parsed, []));
if (!attrset) attrset = {};
attrset[attribs[j].name] = parsed;
}
}
return attrset;
}
/**
* Parses a node and returns a list of tokens
* @param {Utils} utils
* @param {Node[]|NodeList|HTMLCollection} nodeList - can be the child nodes of a DocumentFragment
* @param {String[]} tags
* @param {String} prefix
* @returns {Token[]}
*/
function parseNode(utils, nodeList, tags, prefix) {
var tokens = [], token, children,
current,
curText = "",
curFrag = utils.newFrag(),
node, attr,
parsedAttributes,
sections = [];
/**
* Elaborates the current text sequence and converts it into a string
* template if it contains tags
*/
function processText() {
if (curText.indexOf(tags[0]) >= 0) {
var parts = checkParsedString(parseString(curText, tags), sections), i = 0, part;
while (i < parts.length) {
part = parts[i++];
if (part.type === "text")
curFrag.appendChild(utils.newText(part.value));
else {
pushFragment();
tokens.push(part);
}
}
} else curFrag.appendChild(utils.newText(curText));
curText = "";
}
/**
* Creates a fragment token if the current fragment isn't empty
*/
function pushFragment() {
var l = curFrag.childNodes.length;
if (!l) return;
tokens.push({ type: Templz.FRAGMENT_TEMPLATE, value: l === 1 ? curFrag.firstChild : curFrag });
curFrag = utils.newFrag();
}
for (var i = 0; i < nodeList.length;) {
current = nodeList[i++];
if (current.nodeType === 3) {
// Text node: joining the content and go on.
curText += current.nodeValue;
} else if (current.nodeType === 1) {
// Element node
if (curText) processText();
token = null;
// Checking the attributes
if (attr = current.getAttribute(prefix + "prefix"))
prefix = attr;
if (attr = current.getAttribute(prefix + "tags"))
tags = getTags(attr);
if (attr = current.getAttribute(prefix + "name")) {
token = { type: "name", value: trim(attr) };
token.node = current.cloneNode(false);
token.node.removeAttribute(prefix + "name");
} else if (attr = current.getAttribute(prefix + "content")) {
token = { type: "&", value: trim(attr) };
token.node = current.cloneNode(false);
token.node.removeAttribute(prefix + "content");
} else if (attr = current.getAttribute(prefix + "section")) {
token = { type: "#", value: trim(attr) };
node = current.cloneNode(true);
node.removeAttribute(prefix + "section");
token.children = parseNode(utils, [ node ], tags, prefix);
} else if (attr = current.getAttribute(prefix + "empty")) {
token = { type: "^", value: trim(attr) };
node = current.cloneNode(true);
node.removeAttribute(prefix + "empty");
token.children = parseNode(utils, [ node ], tags, prefix);
} else if (attr = current.getAttribute(prefix + "partial")) {
token = { type: ">", value: trim(attr) };
token.node = current.cloneNode(false);
token.node.removeAttribute(prefix + "partial");
} else {
// No template attributes found
parsedAttributes = parseAttributes(current, tags);
children = current.childNodes.length ? parseNode(utils, current.childNodes, tags, prefix) : [];
if (!parsedAttributes && (!children.length || children.length === 1 && children[0].type === "fragment")) {
// Just a common node without template tags
node = current.cloneNode(false);
if (children.length) node.appendChild(children[0].value);
curFrag.appendChild(node);
} else {
token = { type: "node" };
token.attributes = parsedAttributes;
if (children.length > 1 || children.length === 1 && children[0].type !== "fragment") {
token.children = buildTree(children);
token.node = current.cloneNode(false);
} else token.node = current.cloneNode(true);
}
}
if (token) {
if (token.node && !("attributes" in token))
token.attributes = parseAttributes(current, tags);
pushFragment();
tokens.push(token);
}
// Everything else is just not parsed (comments, etc.)
} else {
if (curText) processText();
curFrag.appendChild(current);
}
}
// Checking for trailing text
if (curText) processText();
if (sections.length)
throw new Error("Unclosed section: \"" + sections.pop() + "\"");
pushFragment();
return tokens;
}
/**
* Parses a string and returns a list of tokens
* @param {String} string
* @param {String[]} tags
* @returns {Token[]}
*/
function parseString(string, tags) {
if (!string) return [];
var tokens = [],
tagLen = [ tags[0].length, tags[1].length ],
value, name, token, last,
sections = [], // Stack to hold section tokens
index = 0, endIndex = 0, length = string.length;
/** Removes empty spaces around stand-alone tags. */
function clearEmptyLines() {
// Removing whitespaces from the last line of the last text token
// If the previous token isn't a text token, we're out
if (last && "text # ^ /".indexOf(last.type) === -1) return;
if (last && last.type === "text") {
// If the last token is a text token, but the last line contains
// non-space characters, or it doesn't contain an EOL but it's not
// entirely made of whitespace, we're out.
var lastCR = last.value.lastIndexOf("\n") + 1;
if (lastCR && /\S/.test(last.value.slice(lastCR))
|| !lastCR && (tokens.length > 1 || /\S/.test(last.value)))
return;
}
var strIdx = endIndex + tagLen[1],
nextCR = string.indexOf("\n", strIdx) + 1;
if (nextCR && !/\S/.test(string.slice(strIdx, nextCR))
|| (!nextCR && !/\S/.test(string.slice(strIdx)))) {
// If the following text contains an EOL, and before that there are
// only whitespaces, or the rest of the string it's entirely
// whitespace, we're in a standalone line
// Trimming the spaces since the last EOL
if (lastCR) last.value = last.value.slice(0, lastCR);
// Discarding the whitespace-only first token
else if (last && last.type === "text")
tokens.length = last = 0;
// Repositioning endIndex to trim the whitespaces to EOL or
// the end of the string
endIndex = nextCR ? nextCR - tagLen[1] : length;
}
}
while (index < length) {
index = string.indexOf(tags[0], index);
if (index === -1) {
// No more tags: add a text token and break the loop
if (last && last.type === "text")
last.value += string.substring(endIndex);
else tokens.push({ type: "text", value: string.substring(endIndex) });
break;
}
if (index > endIndex) {
// There's text before the tag
if (last && last.type === "text")
last.value += string.substring(endIndex, index);
else tokens.push(last = { type: "text", value: string.substring(endIndex, index) });
}
// Looking for closing delimiter
endIndex = string.indexOf(tags[1], index += tagLen[0]);
if (endIndex === -1)
throw new Error("Unclosed tag: " + tags[0] + string.substr(index, 6) + "...");
value = trim(string.substring(index, endIndex));
switch (value.charAt(0)) {
case "#": case "^": // New section, either normal or inverted
name = trim(value.substring(1));
sections.push(name);
clearEmptyLines();
tokens.push(last = { type: value.charAt(0), value: name });
last.index = endIndex + tagLen[1];
break;
case "/": // Closing section
name = trim(value.substring(1));
if (sections.length && (value = sections.pop()) !== name)
throw new Error("Unclosed section \"" + value + "\"");
clearEmptyLines();
tokens.push(last = { type: "/", value: name });
last.index = index - tagLen[0];
break;
case "{": // Binding value, unescaped
if (value.charAt(value.length - 1) === "}")
value = value.slice(0, -1);
else if (string.charAt(endIndex++) !== "}" || string.substr(endIndex, tagLen[1]) !== tags[1])
throw new Error("Unclosed tag at " + index);
// Fall-through!
case "&": // Binding value, unescaped
name = trim(value.substring(1));
tokens.push(last = { type: "&", value: name });
break;
case ">": // Partial
name = trim(value.substring(1));
token = { type: ">", value: name };
token.indent = "";
if (last && last.type === "text") {
value = last.value.lastIndexOf("\n") + 1;
if (value && !/\S/.test(value = last.value.slice(value)))
token.indent = value;
else if (tokens.length === 1 && !/\S/.test(last.value))
token.indent = last.value;
}
clearEmptyLines();
tokens.push(last = token);
break;
case "!": // Comment
// It should just delete the whitespaces around...
// Removing standalone lines
clearEmptyLines();
break;
case "=": // Changing delimiters
if (value.charAt(value.length - 1) === "=")
value = value.slice(0, -1);
else if (string.charAt(endIndex++) !== "=" || string.substr(endIndex, tagLen[1]) !== tags[1])
throw new Error("Unclosed tag at " + index);
clearEmptyLines()
tags = getTags(value.substring(1));
// Adjusting endIndex for later addition
endIndex += tagLen[1] - tags[1].length;
tagLen = [ tags[0].length, tags[1].length ];
break;
default: // Binding value
tokens.push(last = { type: "name", value: value });
}
index = endIndex += tagLen[1];
}
return tokens;
}
/**
* Finds the indexed value in the context stack
* @param {Object[]} contextStack
* @param {String} name Value to look for. Can use dot notation
* @return {?*}
*/
function lookup(contextStack, name) {
var split = name.split("."),
i = contextStack.length,
j, value;
for (; i--;) {
value = contextStack[i];
for (j = 0; j < split.length && value; j++)
if (value[split[j]])
value = value[split[j]];
else break;
if (j === split.length)
return value;
}
}
/**
* Builds the string result from a set of tokens and a context stack.
* Conceived as a recursively callable function.
* @param {Utils} utils
* @param {Object[]} tokens
* @param {Object[]} contextStack The stack of data to be used to
* retrieve data from
* @param {String} indent A string of whitespaces to indent the
* result with
* @param {Object<String, *>} [partials]
* @returns {String}
*/
function renderStringTokens(utils, tokens, contextStack, indent, partials) {
var i = 0, j, res = "",
token, value;
while (i < tokens.length) {
token = tokens[i++];
switch (token.type) {
// Plain text
case "text": res += token.value; break;
// Escaped and unescaped interpolation
case "name": case "&":
if (token.value === ".")
value = contextStack[contextStack.length - 1];
else value = lookup(contextStack, token.value);
if (value) {
if (typeof value === "function")
value = value(token.value);
res += token.type === "&" ? value : escapeString(value);
}
break;
// Section
case "#":
if (value = lookup(contextStack, token.value))
if (isArray(value))
for (j = 0; j < value.length; j++) {
if (value[j])
res += renderStringTokens(utils, token.children, contextStack.concat([ value[j] ]), indent, partials);
}
else {
if (typeof value === "function")
value = value(token.value);
if (value)
res += renderStringTokens(utils, token.children, contextStack.concat([ value ]), indent, partials);
}
break;
// Inverted section
case "^":
value = lookup(contextStack, token.value);
if (!value || isArray(value) && !value.length)
res += renderStringTokens(utils, token.children, contextStack, indent, partials);
break;
// Partial
case ">":
if (!partials || typeof partials !== "object" || !partials[token.value]) break;
value = partials[token.value];
if (typeof value === "string" || typeof value === "object" && "nodeType" in value)
value = utils.compile(value);
if (value instanceof ParsedTemplate) {
if (value = value.render(contextStack[contextStack.length - 1], partials)) {
if (typeof value === "string" && token.indent)
value = token.indent + value.replace(/\n/g, "\n" + token.indent);
res += typeof value === "string" ? value : utils.serializeFragment(value)
}
}
break;
}
}
return res;
};
/**
* Builds the fragment result from a set of tokens and a context stack.
* Conceived as a recursively callable function.
* @param {Utils} utils
* @param {Object[]} tokens
* @param {Object[]} contextStack The stack of data to be used to
* retrieve data from
* @param {Object<String, *>} [partials]
* @returns {String}
*/
function renderFragmentTokens(utils, tokens, contextStack, partials) {
var i = 0, j, res = utils.newFrag(),
token, value;
function copyNode(t, content, ctxstack) {
var copy = t.node.cloneNode(content === null),
attribs = t.attributes, name;
// Filling the copy with the content, if provided
if (content !== null)
if (typeof content === "object" && "nodeType" in content)
copy.appendChild(content);
else copy.innerHTML = content;
// Rendering the attributes
for (name in attribs)
copy.setAttribute(name, renderStringTokens(utils, attribs[name], ctxstack, "", partials));
res.appendChild(copy);
}
while (i < tokens.length) {
token = tokens[i++];
switch (token.type) {
case "text":
res.appendChild(utils.newText(token.value));
break;
case "fragment":
res.appendChild(token.value.cloneNode(true));
break;
case "node":
copyNode(token, token.children ? renderFragmentTokens(utils, token.children, contextStack, partials) : null, contextStack);
break;
case "name": case "&":
if (token.value === ".")
value = contextStack[contextStack.length - 1];
else value = lookup(contextStack, token.value);
if (value) {
if (typeof value === "function")
value = value(token.value);
if (token.node)
copyNode(token, token.type === "&" ? value : utils.newText(value), contextStack);
else res.appendChild(token.type === "&" ? utils.getFrag(value) : utils.newText(value));
}
break;
case "#":
if (value = lookup(contextStack, token.value))
if (isArray(value)) {
for (j = 0; j < value.length; j++) {
if (value[j])
res.appendChild(renderFragmentTokens(utils, token.children, contextStack.concat([ value[j] ]), partials));
}
} else {
if (typeof value === "function")
value = value(token.value);
if (value)
res.appendChild(renderFragmentTokens(utils, token.children, contextStack.concat([ value ]), partials));
}
break;
case "^":
value = lookup(contextStack, token.value);
if (!value || isArray(value) && !value.length)
res.appendChild(renderFragmentTokens(utils, token.children, contextStack, partials));
break;
case ">":
if (!partials || typeof partials !== "object" || !partials[token.value]) break;
value = partials[token.value];
if (typeof value === "string" || typeof value === "object" && "nodeType" in value)
value = utils.compile(value);
if (value instanceof ParsedTemplate) {
if (value = value.render(contextStack[contextStack.length - 1], partials)) {
if (typeof value === "string" && token.indent)
value = token.indent + value.replace(/\n/g, "\n" + token.indent);
copyNode(token, typeof value === "string" ? utils.newText(value) : value, contextStack);
}
}
break;
}
}
return res;
}
/**
* @class
* @classdesc Context for Templz's template creation and internal functions.
* @param {Object} window A global object used as the root
* @param {Document} [document] Document object to create nodes and
* fragments from. Defaults to window.document
*/
function Context(window, document) {
if (!document)
document = window && typeof window.document !== "undefined" ? window.document : null;
/**
* @class
* @param {String} source
* @param {String|String[]} [tags]
* @classdesc Class for templates of string type
*/
function StringTemplate(utils, source, tags) {
this.source = source;
this.tokens = buildTree(checkParsedString(parseString(source, tags), []));
}
/**
* @class
* @param {Node[]|NodeList|HTMLCollection} source
* @param {String|String[]} [tags]
* @param {String} [prefix]
* @classdesc Class for templates of fragment type
*/
function FragmentTemplate(utils, source, tags, prefix) {
this.source = source;
this.tokens = buildTree(parseNode(utils, source, tags, prefix));
}
// Sets StringTemplate as a subclass of ParsedTemplate
StringTemplate.prototype = new ParsedTemplate();
/**
* Build the result as a string using the provided data and partials
* @param {Object} data
* @param {Object<String, *>} [partials]
* @returns {String}
*/
StringTemplate.prototype.render = function(data, partials) {
return renderStringTokens(utils, this.tokens, [ data ], "", partials);
};
// Sets StringTemplate as a subclass of ParsedTemplate
FragmentTemplate.prototype = new ParsedTemplate();
/**
* Build the result as a fragment using the provided data and partials
* @param {Object} data
* @param {Object<String, *>} [partials]
* @returns {DocumentFragment|Text}
*/
FragmentTemplate.prototype.render = function(data, partials) {
return renderFragmentTokens(utils, this.tokens, [ data ], partials);
};
StringTemplate.prototype.type = this.STRING_TEMPLATE;
FragmentTemplate.prototype.type = this.FRAGMENT_TEMPLATE;
var that = this,
/**
* ID of a DocumentFragment object. IE6-7 support DocumentFragment, but
* the resulting nodeType is 9 (document) instead of 11.
* @type {?Number}
*/
docFragType = document && document.createDocumentFragment
&& document.createDocumentFragment().nodeType,
/**
* Defines if <template> elements are supported
* @type {Boolean}
*/
hasTemplates = !!window && typeof window.HTMLTemplateElement !== "undefined",
/** @type Utils */
utils = {
/**
* Checks if the argument is a Node object. Uses duck typing if Node is
* not defined.
* @function isNode
* @param {?*} node
* @returns {Boolean}
*/
isNode: window && window.Node ? function(node) {
return !!node && node instanceof window.Node;
} : function(node) {
// Duck typing
return node && typeof node === "object"
&& typeof node.nodeType === "number"
&& typeof node.nodeName === "string";
},
/**
* Checks if a node is actually a DocumentFragment object
* @param {Node} frag
* @returns {Boolean}
*/
isFragment: docFragType === 9
? function(frag) { return frag.nodeType === 9 && frag.nodeName === "#document-fragment"; }
: function(frag) { return frag.nodeType === 11; },
/**
* Creates a new DocumentFragment node
* @returns {DocumentFragment}
*/
newFrag: docFragType && (hasBind ? document.createDocumentFragment.bind(document)
: function() { return document.createDocumentFragment(); }),
/**
* Shorthand for
* @returns {DocumentFragment}
*/
newText: document && (hasBind ? document.createTextNode.bind(document)
: function(txt) { return document.createTextNode(txt); }),
/**
* Creates a DocumentFragment out of raw HTML code
* @param {String} content
* @return {DocumentFragment}
*/
getFrag: document && (hasTemplates
? (function(temp) {
return function(content) {
temp.innerHTML = content;
var frag = temp.content.cloneNode(true);
temp.innerHTML = "";
return frag;
};
})(document.createElement("template"))
: (function() {
var wrapperMap = {
td: "tr", th: "tr", tr: "tbody", thead: "table", tbody: "table", tfoot: "table", option: "select", optgroup: "select"
};
return function(content) {
var frag = utils.newFrag(),
tagMatch = (content + "").match(/<(t[hdr]|thead|tbody|tfoot|option|optgroup)\b/i),
wrapper = document.createElement(tagMatch && wrapperMap[tagMatch[1].toLowerCase()] || "div");
wrapper.innerHTML = content;
while (wrapper.firstChild) frag.appendChild(wrapper.firstChild);
return frag;
};
})()
),
/**
* Serializes a DocumentFragment node
* @param {DocumentFragment} frag
* @returns {String}
*/
serializeFragment: document && ("outerHTML" in document.documentElement
? function(frag) {
var i = 0, children = frag.childNodes, res = "", node;
while (i < children.length) {
var node = children[i++];
switch (node.nodeType) {
case 1: res += node.outerHTML; break;
case 3: res += node.nodeValue; break;
case 8: res += "<!--" + node.nodeValue + "-->"; break;
}
}
return res;
} : (function(div) {
return function(frag) {
div.innerHTML = "";
div.appendChild(frag.cloneNode(true));
return div.innerHTML;
};
})(document.createElement("div"))
)
},
/**
* Used for template instantiation
* @type {Object}
*/
classes = {
string: StringTemplate,
fragment: FragmentTemplate
};
/**
* Serialize the children of a node. Specifically thought for DocumentFragment
* nodes, it can be used with Element nodes too, and also every object with a
* childNodes property.
* @memberof Templz
* @param {Node} fragment Fragment to serialize
* @returns {string}
*/
this.serialize = utils.serializeFragment;
/**
* Creates a DocumentFragment out of a raw HTML string.
* @memberof Templz
* @param {string} html
* @returns {DocumentFragment}
*/
this.createFragment = utils.getFrag;
/**
* Compiles a template with the given tags delimiters and directive prefix.
* @memberof Templz
* @param {string|Node} template It can be a string, a DocumentFragment or
* an Element node.
* @param {string|String[]} [tags] Tag delimiters. It can be either a string
* of delimiters separater by whitespace, or
* an array of two strings. Defaults to
* Templz.tags
* @param {string} [prefix] Directive attribute prefix. Defaults to
* Templz.prefix
* @returns {ParsedTemplate}
* @throws {Error} Throws a TypeError is a null template is
* passed; an Error for compiling errors.
*/
this.compile = utils.compile = function(template, tags, prefix) {
if (tags == null)
tags = that.tags;
if (prefix == null)
prefix = that.prefix;
var source, type;
if (typeof template === "string") {
// Should work exactly like mustache.
source = template;
type = that.STRING_TEMPLATE;
} else if (template.nodeType === 3) {
// Single text node
source = [ template ];
type = that.FRAGMENT_TEMPLATE;
} else if (template.nodeType === 1 || utils.isFragment(template)) {
// The real deal
source = template.nodeName === "TEMPLATE" && hasTemplates
&& template instanceof HTMLTemplateElement ? template.content.childNodes : template.childNodes;
type = that.FRAGMENT_TEMPLATE;
}
if (!source)
throw new TypeError("Invalid template");
return new classes[type](utils, source, getTags(tags), prefix);
};
/* If Object.observe is present, it can be used for binding a template to
* some data, allowing the result to update on the fly if the data changes.
* Object.observe is natively supported by Chrome 36+, Opera 23+, io.js,
* node.js 0.11.13+ plus other Blink-based browsers/environments.
* Up to a certain degree it can be polyfilled:
* https://github.com/MaxArt2501/object-observe
* https://github.com/jdarling/Object.observe
*/
if (Object.observe) {
/**
* Binds a template to the given data, returning a BoundTemplate object.
* @param {Object} data
* @param {Object} [partials]
* @returns {BoundTemplate}
*/
ParsedTemplate.prototype.bindToData = function(data, partials) {
return new BoundTemplate(this, data, partials);
};
/**
* Binds an element to the given data, returning the BoundTemplate object.
* Immediately makes "live" a part of the DOM.
* @param {Node} element
* @param {Object} data
* @param {Object} [partials]
* @returns {BoundTemplate}
*/
this.bindElementToData = function(element, data, tags, prefix, partials) {
if (!utils.isNode(element))
throw new TypeError("Invalid node");
var template = this.compile(element, tags, prefix),
bound = template.bindToData(data, partials);
element.innerHTML = "";
bound.appendTo(element);
return bound;
};
/**
* @class
* @classdesc Defines a template that it's bound to some data.
*/
var BoundTemplate = function(template, data, partials) {
if (!(template instanceof ParsedTemplate))
throw new TypeError("Invalid template");
var isAttached = false,
isStale,
result,
nodes = [],
observed = [],
objectAncestors = [];
/**
* Attaches the live template before an element
* @memberof BoundTemplate
* @param {Node} node
* @throws {TypeError} In case of invalid node
* @throws {ReferenceError} If node has no parent
*/
this.insertBefore = function(node) {
if (!utils.isNode(node))
throw new TypeError("Invalid node");
var i, parent = node.parentNode;
if (!parent)
throw new ReferenceError("Cannot insert before a detached node");
if (isAttached) {
for (var i = 0; i < nodes.length; i++)
parent.insertBefore(nodes[i], node);
} else {
putBefore(parent, node);
isAttached = true;
}
};
/**
* Appends the live template to an element
* @memberof BoundTemplate
* @param {Node} parent
* @throws {TypeError} In case of invalid node
*/
this.appendTo = function(parent) {
if (!utils.isNode(parent))
throw new TypeError("Invalid node");
if (isAttached) {
for (var i = 0; i < nodes.length; i++)
parent.appendChild(nodes[i]);
} else {
putBefore(parent, null);
isAttached = true;
}
};
/**
* Returns the underlying template
* @returns {ParsedTemplate}
*/
this.getTemplate = function() { return template; };
/**
* Returns the attachable DOM node obtained by rendering the
* template. Reuses the old result if it's not stale.
* @returns {DocumentFragment|Text}
*/
var getFragment = function() {
if (isStale) {
result = template.render(data, partials);
isStale = false;
}
return template.type === that.STRING_TEMPLATE
? utils.newText(result) : result;
};
/**
* Binds the template to some data
* @param {Object} newData
* @throws {TypeError} If the data isn't an object
*/
this.bind = function(newData, newPartials) {
if (typeof newData !== "object")
throw new TypeError("Data must be an object");
if (typeof newPartials === "object")
partials = newPartials;
if (newData !== observed[0]) {
if (observed.length) unobserveAll();
isStale = true;
data = newData;
deepObserve(newData, []);
if (isAttached) replaceResult();
}
};
/**
* Unbinds the template from the data, and consequentely ends its
* "live" behaviour
*/
this.unbind = function unobserveAll() {
for (var i = 0; i < observed.length;)
Object.unobserve(observed[i++], observer);
observed.length = objectAncestors.length = 0;
};
/**
* Observes an object and all of its descendant objects
* @function deepUnobserve
* @param {Object} object
*/
function deepObserve(object, parentChain) {
Object.observe(object, observer, [ "add", "update", "delete" ]);
observed.push(object);
objectAncestors.push(parentChain);
parentChain = parentChain.concat([ object ]);
for (var prop in object) {
var val = object[prop];
if (val && typeof val === "object")
deepObserve(val, parentChain);
}
}
/**
* Unobserves an object and all of its descendant objects
* @function deepUnobserve
* @param {Object} object
*/
function deepUnobserve(object) {
var newObserved = [],
newObjectAncestors = [],
i = 0;
for (; i < observed.length; i++)
if (object !== observed[i] && objectAncestors[i].indexOf(object) === -1) {
newObserved.push(observed[i]);
newObjectAncestors.push(objectAncestors[i]);
} else Object.unobserve(observed[i], observer);
// observed = newObserved;
observed.splice.apply(observed, [0, observed.length].concat(newObserved));
objectAncestors = newObjectAncestors;
}
/**
* Change handler for observed objects
* @param {ChangeRecord[]} changes
*/
function observer(changes) {
isStale = true;
if (isAttached) {
var i = 0, type, value;
replaceResult();
for (; i < changes.length; i++) {
type = changes[i].type;
if (type === "delete" || type === "update") {
value = changes[i].oldValue;
if (value && typeof value === "object" && observed.indexOf(value) >= 0)
deepUnobserve(value);
}
if (type === "add" || type === "update") {
value = changes[i].object[changes[i].name];
if (value && typeof value === "object" && observed.indexOf(value) === -1) {
var observedIndex = observed.indexOf(changes[i].object);
deepObserve(value, objectAncestors[observedIndex] || []);
}
}
}
}
}
/**
* Attaches the result before a node
* @param {Node} parent
* @param {Node} node
*/
function putBefore(parent, node) {
var frag = getFragment();
if (frag.nodeType === 3)
nodes = [ frag ];
else if (!frag.childNodes.length) {
frag = document.createComment("Templz empty result");
nodes = [ frag ];
} else nodes = [].slice.call(frag.childNodes);
parent.insertBefore(frag, node);
}
/**
* Replaces the current attached result with an (allegedly) new one
*/
function replaceResult() {
var oldNodes = nodes.slice(),
parent =