(function bootstrap (root, factory) {
(c) 2015-2016 Corin Lawson
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to permit
persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
(function bootstrap (root, factory) {
AMD. Register as an anonymous module.
if (typeof define === 'function' && define.amd) {
define([], factory);
}
Node. Does not work with strict CommonJS, but only CommonJS-like environments that support module.exports, like Node.
else if (typeof module === 'object' && module.exports) {
module.exports = factory();
}
Browser globals (root is window)
else {
root.Tectonic = factory();
}
}(this, function factory () {
'use strict';
Tectonic is a functional rendering engine for DOM nodes, heavily inspired
by Beebole’s PURE rendering engine. It
ascribes to PURE’s unobtusive philosophy, whereby HTML code is completely
free of any application logic or new syntax and JavaScript code is
uninhibited by presentational concerns. This is achieved by both PURE and
Tectonic by the use of a directive object that marries HTML referenced by
CSS selectors to properties in your application’s data. Where Tectonic
departs from PURE is in the use of functions, known as renderers, to
directly manipulate DOM nodes. This permits Tectonic to provide two-way
data flow via a parse
method, which makes use of an inverse function that
is attached to a renderer. Tectonic takes the stance that it is your
responsiblity to provide a context to any function that you provide. I.e.
Tectonic won’t be using call
or apply
on functions that you define,
consider using underscore‘s bind
method or
simply defining your methods inside a closure.
The directive object’s keys are used to find elements and/or element attributes in the DOM. That element’s content or its attribute is then updated according to the directive’s value for that key. The directive’s value can specify a literal string, a property in a given data object, or a combination of both. It can also duplicate the element and recursively render those elements for each item of an array in the given data object.
A Tectonic object wraps the specified element
and defines methods for
compile, render, parse, etc. An optional basis
may also be specified;
think of the basis as an untouched version of element
, which will be used
by Tectonic as a point of reference.
var Tectonic = function Tectonic (element, basis) {
element
is expected to be a DOM Node
, otherwise we assume it’s a
different Tectonic object and get its element. Note this also works well
with jQuery objects.
if (!(element instanceof Node)) {
element = element.get(0);
}
The most common use case is to wrap an element that’s already in the browser’s DOM before any DOM changes, hence the element is currently untouched so the basis is simply a clone of the element.
if (arguments.length < 2) {
basis = element.cloneNode(true);
}
Accessor for this object’s element
. Think jQuery#get
.
this.get = function get () {
return element;
};
Accessor for this object’s context. The specified context object is bound to the compiled renderer function.
var ctx = this;
this.context = function context (context) {
if (arguments.length === 0) {
return ctx;
}
ctx = context;
return this;
};
this.clone = function clone (deep) {
Creates a deep copy of this object. Note that returned object’s
element
will be detached from the browser’s DOM.
if (deep) {
return new Tectonic(element.cloneNode(true), basis.cloneNode(true));
}
Creates a shallow copy of this object.
return new Tectonic(element, basis);
};
Compares the specified other
object to this object. When a single
argument is provided, returns true
if and only if other
is an
instance of Tectonic and both its element
and basis
are identical to
this object’s element
and basis
, respectively. When two arguments
are specified, returns true
if and only if other
and otherBasis
are
identical to this object’s element
and basis
, respectively.
this.equals = function equals (other, otherBasis) {
switch (arguments.length) {
case 2:
return element === other && basis === otherBasis;
case 1:
if (other instanceof Tectonic) {
return this === other || other.equals(element, basis);
}
default:
return false;
}
};
Like compile but also inspects this object’s element
‘s class names for
additional directives.
var autoCompile = function autoCompile (data, directive) {
return Tectonic.plugin.autoCompile(element, directive || {}, data);
};
Updates this object’s element
to reflect the specified data
according
to the specified directive
. Returns this object for chaining.
The directive
may be an object (to be compiled) or a pre-compiled
renderer function.
this.render = function reader (data, directive) {
Behave like autoRender
when there’s no directive.
if (!directive) {
directive = autoCompile(data);
}
Accept a pre-compiled renderer (or any function really!), otherwise
create a renderer function by compiling the directive
.
if (typeof directive !== 'function') {
directive = this.compile(directive);
}
Execute the renderer!
Although the common use case is for the renderer to simply make changes
directly to element
, we are nevertheless prepared for the renderer to
create a new element.
var newElement = directive.call(this, data);
if (newElement !== element) {
If it is the case that a new element was created then replace this
object’s element
with the newElement
.
if (element.parentNode) {
element.parentNode.replaceChild(newElement, element);
}
element = newElement;
}
Return this object to support chaining.
return this;
};
Creates a data object from this object’s element
according to the
specified directive
. Returns an object that contains the data that
would be required to render
this object, that would result in this
object’s element
. Note that this can be called before render
,
consider using parse
to extract default values from your browser’s DOM.
this.parse = function parse (directive) {
There’s no autoParse here…
if (!directive) {
throw new Error("Directive missing.");
}
Accept a pre-compiled renderer, otherwise create a renderer function by
compiling the directive
.
if (typeof directive !== 'function') {
directive = this.compile(directive);
}
return directive.inverse.call(this);
};
Create a renderer and its inverse function, according to the specified
directive
. Returns a function that can be used in place of a directive
to render
and parse
.
this.compile = function compile (directive) {
The real work is done by Tectonic.plugin.compile
.
var renderer = Tectonic.plugin.compile([basis], directive);
The resulting function is curried with this object’s element
or a
clone of element
if it is called within a different context. For
example you can produce many nodes like so
var render = new Tectonic(element).compile(directive);
for (var i in models) {
document.body.appendChild(render(models[i]));
}
var tectonic = this;
var bounded = function bounded (data) {
return renderer.call(ctx, data,
tectonic.equals(this)
? element
: basis.cloneNode(true));
};
bounded.inverse = function inverse (el) {
return renderer.inverse.call(ctx,
(tectonic.equals(this)
? element
: el) ||
basis, {});
};
return bounded;
};
Almost exactly like render
, except this object’s element
‘s class
names are inspected for additional directives.
this.autoRender = function autoRender (data, directive) {
return this.render(data, autoCompile(data, directive));
};
};
Creates an object that specifies what part of the DOM should be manipulated, given the specified key from a directive. The directive’s key is broken into four optional parts.
var parseSelector = function parseSelector (str) {
var spec = { raw: str };
var match = str.match(
/^ *([^@]*?)?? *(@([^ ]+?))? *(:(before|after|toggle))? *$/);
if (!match) {
throw new Error("invalid selector: '" + str + "'");
}
The first part specifies a (CSS) selector used to find the element to be updated.
spec.selector = match[1];
The next part, if present, must be preceeded by an @
, and names the
attribute of the element to be updated.
spec.attr = match[3];
The last part, is like a pseudo-class selector, but in this case it signifies that the content should be prepended (before) or appended (after). Note that it doesn’t make sense for tectonic is manipulate pseudo-classes.
spec.prepend = match[5] === 'before';
spec.append = match[5] === 'after';
A special pseudo-class, just for Tectonic, the that signifies that the content must be switched between two alternative values.
spec.toggle = match[5] === 'toggle';
return spec;
};
Creates a renderer function for one key-value pair (i.e. the specified
spec
and template
pair). Note that the specified basis
is an array of
DOM nodes to support recursively calling compile
with the
results Tectonic.plugin.find
as the basis
(e.g. see
Tectonic.plugin.loopWriter
). This method is responsible
for coordinating the five methods that is generated from the
Tectonic.plugin
, namely a finder, writer, formatter, parser
and reader. To that end, no assumption is made about the information that
these five plugin methods need, therefore they are called with all the
information that we have.
var compiler = function compiler (basis, spec, template) {
The real work of finding the correct element to update is done by the
function returned from Tectonic.plugin.finder
. The
resulting find
function mostly likely doesn’t need template
but
finder does use it to despatch on its type. Likewise, basis
is unlikely
to be used except in the event that the element in the DOM has already
been removed by a previous renderer. The important peice of information
that finder needs is spec.selector
.
var find = Tectonic.plugin.finder(basis, spec, template);
The real work of updating the DOM is done by the function returned from
Tectonic.plugin.writer
. Similar to finder, writer is
unlikely to need basis
or template
. The important information that
writer needs is spec.attr
, spec.append
, spec.prepend
, etc.
var write = Tectonic.plugin.writer(basis, spec, template);
The real work of finding the content to place into the DOM is done by the
function returned from Tectonic.plugin.formatter
.
Unlike finder and writer, formatter is unlikely to need spec
or
basis
. The important information is almost exclusively contained with
in template.
var format = Tectonic.plugin.formatter(basis, spec, template);
The real work of extracting data back out of the DOM is done by the
function returned from Tectonic.plugin.parser
. It is the
converse of write, and uses the same information contained in spec
.
var parse = Tectonic.plugin.parser(basis, spec, template);
The real work of putting data back into a data object is done by the
function returned from Tectonic.plugin.reader
. It is the
converse of format, and as such the important information is almost
exclusively contained with in template.
var read = Tectonic.plugin.reader(basis, spec, template);
These renderers take a signgle target
element to be rendered with
data
. The other arguments are optional and typically only present when
handling loop directives.
var renderAction = function renderAction (data, target) {
The same data will be applied to every node selected by spec
.
The common use case will be a single node for the data.
var bindData = format.apply(this, arguments);
var nodes = find(target, bindData, basis);
for (var i = 0, ii = nodes.length; i < ii; i++) {
var boundData = bindData;
Data can be tailored to each and every node by supplying a function
that returns a function as the value of the directive. E.g. see
Tectonic.toggleClass
.
if (typeof boundData === 'function') {
boundData = bindData.call(this, data, nodes[i], i, nodes);
}
Now, update the DOM.
var newNode = write.call(this, nodes[i], boundData, i, nodes);
Typically write
simply updates the DOM, but if a different node is
produced, update the DOM with that node instead.
if (newNode !== nodes[i]) {
if (newNode && nodes[i].parentNode) {
nodes[i].parentNode.replaceChild(newNode, nodes[i]);
}
nodes[i] = newNode;
}
}
};
Parsing relies on parse
and read
if (parse && read) {
renderAction.inverse = function inverse (source, data) {
if (read.length > 2) {
return read.call(this, data, parse(source, find), find(source));
}
return read.call(this, data, parse(source, find));
};
}
return renderAction;
};
Used by Tectonic.plugin.formatter
and
Tectonic.plugin.reader
to break a template string into
literal strings and paths of a property in a data object.
var stringDataPattern = / *(?:"([^"]*)"|'([^']*)'|([^'" ]+)) */g;
var identity = function identity (a) {
return a;
};
Most of the core functionality of Tectonic is exposed here, in order to
allow other authors to extend the functionality. For example, authors
working with SVG could override Tectonic.plugin.writer
and
Tectonic.plugin.parser
(or some of its helpers, such as
attrWriter
and loopParser
); authors using Backbone models could
override Tectonic.plugin.reader
and
Tectonic.plugin.formatter
(or just propFormatter
and
propReader
); or if you wish to use jQuery/Sizzle,
Tectonic.plugin.find
needs to be overridden.
The five plugin methods used in compiler
above, act as
despatchers based on either the spec or the template.
Tectonic.plugin = {
Returns a function to retrieve an array of Nodes that need to be updated.
The returned function’s parameters must be a DOM Node
and data object.
The returned function’s return value must be an array of nodes.
finder: function finder (basis, spec, template) {
if (!spec.selector || /^ *\.? *$/.test(spec.selector)) {
return this.topFinder(basis, spec, template);
} else if (typeof template === 'object' && !this.isArray(template)) {
return this.loopFinder(basis, spec, template);
} else {
return this.queryFinder(basis, spec, template);
}
},
Used in the case of when the directive refers to the current element being rendered. All the selectors in the following directive will result in calling this method. { ‘’: ‘empty string’, ‘@attr’: ‘also works with attributes’, ‘.’: ‘dot’, ‘.@attr’: ‘as you would expect’ }
topFinder: function topFinder () {
return function topFinder (target) {
return [target];
};
},
Used in the case of looping directives. Additionally, this method also ensures that there is the correct number of elements, one for each item in the loop.
loopFinder: function loopFinder (basis, spec) {
var p = this;
return function loopFinder (target, data, basis) {
var length = data && data.length || 0;
var ownerDocument = target.ownerDocument || document;
var nodes = p.find([target], spec.selector);
var i, ii = nodes.length;
When there is no data…
if (!length) {
…remove all the nodes in the DOM.
if (ii) {
First, remove all but one node.
for (i = 1; i < ii; i++) {
nodes[i].parentNode.removeChild(nodes[i]);
}
if (ownerDocument) {
Replace the last node with a marker so that we may insert new nodes in the future
nodes[0].parentNode.replaceChild(
ownerDocument.createComment(spec.raw), nodes[0]);
} else {
Otherwise remove the last node if a valid document cannot be found.
nodes[0].parentNode.removeChild(nodes[0]);
}
}
No data means no nodes.
return [];
}
When the number of nodes matches the number of data items. There’s nothing more to do.
else if (length === ii) {
return nodes;
}
Otherwise, add or remove nodes as needed.
else {
Newly created nodes are sourced from the original DOM element. This permits easy striping behaviour.
var newNodes = p.find(basis, spec.selector);
var sentinal, lastNode, mod = newNodes.length;
When no nodes are found in the DOM we must search for the specail
marker that is placed into the DOM when there is no data. Here we
perform a breadth-first search starting with target
and exiting
when the comment matching the directive’s key is found.
if (!ii) {
lastNode = newNodes[0].cloneNode(true);
var q = [target];
while (q.length) {
var comment = q.pop();
if (comment.nodeType === 8 && comment.nodeValue === spec.raw) {
comment.parentNode.replaceChild(lastNode, comment);
q.length = 0;
} else if (comment.childNodes.length) {
q.splice.apply(q, [0, 0].concat(
Array.prototype.slice.call(comment.childNodes)));
}
}
ii = 1;
nodes = [lastNode];
}
Otherwise, take note of the lastNode so that we may insert more nodes as needed.
if (length !== ii) {
lastNode = nodes[ii - 1];
Insertion will be either before the element after the last
matching node or (because lastNode
is infact the last node of
it’s parent) insertion will be achieved by appending to the
parent node.
var insert;
sentinal = lastNode.nextSibling;
lastNode = lastNode.parentNode;
if (lastNode) {
insert = sentinal ? function insert (newNode) {
lastNode.insertBefore(newNode, sentinal);
nodes.push(newNode);
} : function insert (newNode) {
lastNode.appendChild(newNode);
nodes.push(newNode);
};
}
Alternatively, there is no parent, hence nowhere to insert.
else {
insert = function insert (newNode) {
nodes.push(newNode);
};
}
Continue to insert nodes while the number of nodes is less then the number of items in the data.
for (i = Math.min(ii, length); i < length; i++) {
insert(newNodes[i % mod].cloneNode(true));
}
Alternatively, remove nodes until the number of nodes matches the number of items in the data.
for (; i < ii; i++) {
lastNode.removeChild(nodes[i]);
}
And remove nodes from the returned array as necessary.
nodes.length = length;
}
}
return nodes;
};
},
Used in the default case, passes spec.selector
to
Tectonic.plugin.find
.
queryFinder: function queryFinder (basis, spec) {
var p = this;
return function queryFinder (target) {
return p.find([target], spec.selector);
};
},
Finds elements within the specified array of Nodes, contexts
, matching
the specified selector
. Returns an array of Nodes.
The default implementation uses querySelectorAll
and this method is
used by various other plugin methods. This is the best point for a
jQuery/Sizzle (or similar) plugin to introduce its own functionality.
It’s important to return a real array (not a NodeList
) for use in other
plugin methods (e.g. loopFinder
).
find: function find (contexts, selector) {
var elements = [];
var found, i, ii, j, jj;
for (i = 0, ii = contexts.length; i < ii; i++) {
found = contexts[i].querySelectorAll(selector);
for (j = 0, jj = found.length; j < jj; j++) {
elements.push(found[j]);
}
}
return elements;
},
Returns a function to update the DOM for a given spec
.
The returned function’s parameters must be a target
DOM Node
and data
object. The returned function’s return value must be the DOM Node
written to, if this node is not the target
then the target
will be
replaced with the returned node.
writer: function writer (basis, spec, template) {
if (spec.attr) {
return this.attrWriter(basis, spec, template);
} else if (typeof template === 'object' && !this.isArray(template)) {
return this.loopWriter(basis, spec, template);
} else {
return this.elementWriter(basis, spec, template);
}
},
Used in the case where spec
refers to an element’s attribute.
attrWriter: function attrWriter (basis, spec) {
return function attrWriter (target, value) {
Attribute selectors are particularly useful for elements.
if (target.nodeType === 1) {
var tagName = target.tagName.toUpperCase();
As a convenience, handle dropdown boxes specially.
if (tagName === "OPTION" &&
spec.attr === "selected") {
var selected = value === 'false' ? false : Boolean(value);
target.selected = selected;
if (selected) {
target.setAttribute("selected", value);
} else {
target.removeAttribute("selected");
}
}
Check and radio boxes also require specail handling.
else if (tagName === "INPUT" &&
spec.attr === "checked") {
var checked = value === 'false' ? false : Boolean(value);
target.checked = checked;
if (checked) {
target.setAttribute("checked", value);
} else {
target.removeAttribute("checked");
}
}
Disabled elements are special too.
else if (spec.attr === "disabled" &&
/^(INPUT|TEXTAREA|BUTTON|SELECT|OPTION|OPTGROUP|FIELDSET)$/.test(
tagName)) {
var disabled = value === 'false' ? false : Boolean(value);
target.disabled = disabled;
if (disabled) {
target.setAttribute("disabled", value);
} else {
target.removeAttribute("disabled");
}
}
Treat class attribute (and aliases) specially.
else if (spec.attr === "class" ||
spec.attr === "className" ||
spec.attr === "classList") {
if (spec.toggle) {
var classList = ' ' + (target.getAttribute('class') || '') + ' ';
if (classList.indexOf(' ' + value + ' ') >= 0) {
classList = classList.replace(' ' + value + ' ', ' ');
} else {
classList += value;
}
value = classList.replace(/^ +| +$/g, '');
} else if (spec.append) {
value = target.getAttribute('class') + ' ' + value;
} else if (spec.prepend) {
value = value + ' ' + target.getAttribute('class');
}
target.setAttribute('class', value);
}
Otherwise, use setAttribute
with spec.attr
as-is and support
append and prepend.
else {
if (spec.append) {
value = target.getAttribute(spec.attr) + value;
} else if (spec.prepend) {
value = value + target.getAttribute(spec.attr);
}
target.setAttribute(spec.attr, value);
}
}
Attribute selectors might also be useful for other node types, but
since other node types do not have a setAttribute
method then we’ll
just assign values directly to properties of the target
node.
else {
if (spec.append) {
value = target[spec.attr] + value;
} else if (spec.prepend) {
value = value + target[spec.attr];
}
target[spec.attr] = value;
}
return target;
};
},
Used in the case of looping directives.
loopWriter: function loopWriter (basis, spec, template) {
Find the key that contains the loop spec (e.g. '<-'
).
var loopSpec = this.parseLoopSpec(template);
Recursively call compile
where a subtree of basis
becomes the new basis
and the object referenced by loopSpec
becomes
the new directive.
var renderer = this.compile(
this.find(basis, spec.selector),
loopSpec.directive);
return function loopWriter (target, items, i, targets) {
An item of the array becomes the data for the recursive call.
var data = items[i];
The lefthand side of <-
, if present, is used to refer to the data.
if (loopSpec.lhs) {
(data = {})[loopSpec.lhs] = items[i];
}
return renderer.call(this, data, target, i, targets, items);
};
},
Used in the case where spec
refers to an element.
elementWriter: function elementWriter (basis, spec) {
return function elementWriter (target, value) {
When target
and value
are the same, there’s nothing to do.
if (target !== value) {
Particularly useful for elements.
if (target.nodeType === 1) {
value
will probably need to be appended, hence we need a node.
var valueNode = value;
if (!(value instanceof Node)) {
valueNode = document.createTextNode(value);
}
Since input elements do not allow child nodes it is more useful
to treat the value
property as its content. This behaviour also
works well for textareas.
if (value !== valueNode &&
(target.tagName.toUpperCase() === 'INPUT' ||
target.tagName.toUpperCase() === 'TEXTAREA')) {
if (spec.append) {
value = target.value + value;
} else if (spec.prepend) {
value = value + target.value;
}
target.value = value;
}
Appending is straightforward and happens to be equivalent to
prepend when target
is already empty.
else if (spec.append || spec.prepend && !target.childNodes.length) {
target.appendChild(valueNode);
}
Prepend is now straightforward since target
is not empty.
else if (spec.prepend) {
target.insertBefore(valueNode, target.childNodes[0]);
}
When value
is text, hold back from replacing the entire node.
else if (value !== valueNode) {
if (target.childNodes.length) {
target.innerHTML = "";
}
target.appendChild(valueNode);
}
Otherwise, let the renderer complete the replacement.
See renderAction
.
else {
target = value;
}
}
To extend the functionality to other node types, simply use
nodeValue
.
else if (!(value instanceof Node)) {
if (spec.append) {
value = target.nodeValue + value;
} else if (spec.prepend) {
value = value + target.nodeValue;
}
target.nodeValue = value;
}
Otherwise, let the renderer complete the replacement.
See renderAction
.
else {
target = value;
}
}
return target;
};
},
Returns a function to find, extract and prepare, from a data object, the
content to be written into the DOM.
The returned function’s parameters must be the data object and the target
DOM Node
.
The returned function’s return value can be either a string that will be
set as the content of the target element or attribute or an element, in
which case the target node will be replaced.
formatter: function formatter (basis, spec, template) {
var found, parts;
switch (typeof template) {
case 'function':
return template;
case 'object':
if (this.isArray(template)) {
return this.propFormatter(basis, spec, template);
} else {
return this.loopFormatter(basis, spec, template);
}
Strings specify either a path of a property in the data object, or a literal string or both. Literal strings can be surrounded by either single or double quotes.
case 'string':
parts = [];
while ((found = stringDataPattern.exec(template))) {
if (found[3]) {
parts.push(this.propFormatter(basis, spec, found[3].split('.')));
} else {
parts.push(this.stringFormatter(basis, spec, found[1] || found[2]));
}
}
if (parts.length === 1) {
return parts[0];
}
else if (parts.length > 1) {
return this.concatenator(parts);
} else {
return this.emptyFormatter();
}
default:
return this.emptyFormatter();
}
},
Used in the case where the data should be treated as a literal string.
stringFormatter: function stringFormatter (basis, spec, literal) {
return function stringFormatter () {
return String(literal);
};
},
Used for directives that do not specify either a function, object, data property, nor literal string.
emptyFormatter: function emptyFormatter () {
return function emptyFormatter () {
return '';
};
},
Used for directives that specify a property in a data object. The path
argument must be an array of strings.
propFormatter: function propFormatter (basis, spec, path) {
return function propFormatter (data) {
Stop following the path as soon as data is a false-y.
for (var i = 0, ii = path.length; i < ii && data; i++) {
Treat empty string in path
as no-op.
if (path[i]) {
data = data[path[i]];
}
}
return data;
};
},
Used in the case of looping directives.
loopFormatter: function loopFormatter (basis, spec, template) {
The righthand side specifies the property in the data that is the array.
var formatter = this.propFormatter(
basis,
spec,
this.parseLoopSpec(template).rhs.split('.'));
The array will be used without further processing unless sort or filter is specified.
if (!('sort' in template) && !template.filter) {
return formatter;
}
return function loopFormatter () {
var filtered;
var array = formatter.apply(this, arguments);
if (template.filter) {
filtered = [];
for (var i = 0, ii = array.length; i < ii; i++) {
Execute the filter
function bound to the loop spec, providing
it with the item, index and array.
if (template.filter(array[i], i, array)) {
filtered.push(array[i]);
}
}
} else {
Do not attempt to sort the original array.
filtered = Array.prototype.slice.call(array);
}
if ('sort' in template) {
filtered.sort(template.sort);
}
return filtered;
};
},
Returns a function that executes each function of parts
in turn and
concatenates each, returning the result.
concatenator: function concatenator (parts) {
return function concatenator () {
var i, ii, part, cat = "";
for (i = 0, ii = parts.length; i < ii; i++) {
part = parts[i].apply(this, arguments);
if (typeof part !== 'undefined') {
cat += part;
}
}
return cat;
};
},
Returns a function to recontruct, from the DOM, the value to be placed in
a data object.
The returned function’s parameters must be a source
node from the DOM
and a finder
function (such as a function returned by
Tectonic.plugin.finder
). The finder
function may be used
to find nodes within source
or basis
.
The returned function’s return value must be the value to be placed into
a reconstructed data object.
parser: function parser (basis, spec, template) {
if (spec.attr) {
return this.attrParser(basis, spec, template);
} else if (typeof template === 'object' && !this.isArray(template)) {
return this.loopParser(basis, spec, template);
} else {
return this.elementParser(basis, spec, template);
}
},
Used in the case of looping directives.
loopParser: function loopParser (basis, spec, template) {
Find the key that contains the loop spec (e.g. '<-'
).
var loopSpec = this.parseLoopSpec(template);
Recursively call Tectonic.plugin.compile
where a
subtree of basis
becomes the new basis
and the object referenced by
loopSpec
becomes the new directive.
var renderer = this.compile(
this.find(basis, spec.selector),
loopSpec.directive);
var p = this;
return function loopParser (source) {
Each node in the DOM corresponds to one item in the returned array.
var nodes = p.find([source], spec.selector);
var array = [], data;
for (var i = 0, ii = nodes.length; i < ii; i++) {
Recontruct the data from the DOM node.
data = renderer.inverse.call(this, nodes[i], data = {});
The lefthand side of <-
, if present, is used to refer to the
data.
if (loopSpec.lhs) {
data = data[loopSpec.lhs];
}
array[i] = data;
}
return array;
};
},
Used in the case where spec
refers to an element.
elementParser: function elementParser (basis, spec) {
var p = this;
return function elementParser (source, finder) {
var value, original;
Use the first found element; we assume they are all the same (if they are different consider using a looping directive).
var target = finder(source)[0];
When there’s no target then we can’t go any further.
if (!target) {
return;
}
When appending or prepending, also find the same element in the basis in order to compare later.
if (spec.append || spec.prepend) {
original = finder(basis[0])[0];
}
Particularly useful for elements.
if (target.nodeType === 1) {
Since input elements do not allow child nodes it is more useful to
treat the value
property as its content. This behaviour also
works well for textareas.
if (target.tagName.toUpperCase() === 'INPUT' ||
target.tagName.toUpperCase() === 'TEXTAREA') {
value = target.value;
Given that we earlier found the same element from the basis
,
compare value
to the original value and find the difference.
if (original) {
if (target.tagName.toUpperCase() === 'INPUT') {
value = p.diff(
value,
original.getAttribute('value'),
spec.append);
} else {
value = p.diff(value, original.textContent, spec.append);
}
}
}
Otherwise textContent
is considered to be the data.
else {
value = target.textContent;
if (original) {
value = p.diff(value, original.textContent, spec.append);
}
}
}
To extend the functionality to other node types, simply use
nodeValue
.
else {
value = target.nodeValue;
if (original) {
value = p.diff(value, original.nodeValue, spec.append);
}
}
return value;
};
},
Used in the case where spec
refers to an element’s attribute.
attrParser: function attrParser (basis, spec) {
var p = this;
return function attrParser (source, finder) {
var value, original;
Use the first found element; we assume they are all the same (if they are different consider using a looping directive).
var target = finder(source)[0];
When there’s no target then we can’t go any further.
if (!target) {
return;
}
When appending or prepending, also find the same element in the basis in order to compare later.
if (spec.append || spec.prepend) {
original = finder(basis[0])[0];
}
Attribute selectors are particularly useful for elements.
if (target.nodeType === 1) {
var tagName = target.tagName.toUpperCase();
Both selected
, disabled
and checked
attributes are booleans.
if (tagName === "OPTION" &&
spec.attr === "selected") {
value = target.selected;
} else if (tagName === "INPUT" &&
spec.attr === "checked") {
value = target.checked;
} else if (spec.attr === "disabled" &&
/^(INPUT|TEXTAREA|BUTTON|SELECT|OPTION|OPTGROUP|FIELDSET)$/.test(
tagName)) {
value = target.disabled;
}
Treat class attribute (and aliases) specially.
else if (spec.attr === "class" ||
spec.attr === "className" ||
spec.attr === "classList") {
if (spec.toggle) {
throw new Error("Unable to parse '" + spec.raw +
"', cannot determine value of toggle.");
} else {
value = target.getAttribute('class');
if (original) {
value = p.diff(
value, original.getAttribute('class'), spec.append);
value = value.replace(/^ +| +$/g, '');
}
}
}
For all other attributes of elements use getAttribute
.
else {
value = target.getAttribute(spec.attr);
if (original) {
value = p.diff(
value, original.getAttribute(spec.attr), spec.append);
}
}
}
For other node types simply use properties of the target
node.
else {
value = target[spec.attr];
if (original) {
value = p.diff(value, original[spec.attr], spec.append);
}
}
return value;
};
},
If the specified original
string is located in tne specified value
string, then return the proceeding portion if end
is true or the
preceeding portion if end
is false
. Otherwise, return an empty
string.
diff: function diff (value, original, end) {
var index = value.indexOf(original);
if (index >= 0) {
if (end) {
return value.substr(index + original.length);
} else {
return value.substr(0, index);
}
}
return '';
},
Returns a function to place reconstructed values from the DOM back into a
data object.
The returned function’s parameters must be a data
object and the
reconstructed value
.
The returned function’s return value must be the data
object.
Typically, the data
argument is simply passed through (after
modification).
reader: function reader (basis, spec, template) {
if (typeof template === 'function') {
template = template.inverse || function deferReaderException () {
throw new Error("Unable to parse '" + spec.raw +
"', cannot find inverse of function.");
};
}
switch (typeof template) {
case 'function':
return template;
case 'object':
if (this.isArray(template)) {
return this.propReader(basis, spec, template);
} else {
return this.propReader(
basis, spec, this.parseLoopSpec(template).rhs.split('.'));
}
As per Tectonic.plugin.formatter
strings may be any
combination of literal strings (surrounded by either single or double
quotes) or a path of a property in the data object.
case 'string':
var i = -1;
var parts = [];
var found;
while ((found = stringDataPattern.exec(template))) {
if (found[3]) {
parts.push(this.propReader(basis, spec, found[3].split('.')));
i++;
} else if (i < 0 || typeof parts[i] === 'function') {
parts.push(found[1] || found[2]);
i++;
}
Otherwise, treat the two strings as one.
else {
parts[i] += found[1] || found[2];
}
}
if (parts.length === 1 && typeof parts[0] === 'function') {
return parts[0];
} else if (parts.length > 1) {
return this.deconcatenator(parts, spec);
} else {
return this.emptyReader(basis, spec, template);
}
default:
}
},
A reader that does nothing simply returns the data object (i.e. the first argument).
emptyReader: function emptyReader () {
return identity;
},
Set a property in the data object.
propReader: function propReader (basis, spec, path) {
return function propReader (data, value) {
var target = data;
var i, ii;
for (i = 0, ii = path.length - 1; i < ii; i++) {
if (!target[path[i]]) {
If the property looks like a non-negative number then it should probably be an array.
if (path[i] == parseInt(path[i]) && // eslint-disable-line eqeqeq
path[i] >= 0) {
target[path[i]] = [];
} else {
target[path[i]] = {};
}
}
target = target[path[i]];
}
target[path[i]] = value;
return data;
};
},
Returns a reader function that consumes the value
by either discarding
literal strings or other readers.
deconcatenator: function deconcatenator (parts, spec) {
return function deconcatenator (data, value) {
var part, partValue, index;
for (var i = 0, ii = parts.length; i < ii; i++) {
part = parts[i];
switch (typeof part) {
When part
is a reader it will consume value
up to the next
string.
case 'function':
if (i + 1 === ii || typeof parts[i + 1] !== 'function') {
if (i + 1 !== ii) {
Next part is a string (because it’s not a function),
extract the preceeding string (and pass it to the part
reader) and skip the next part.
index = value.indexOf(parts[++i]);
partValue = value.substr(0, index);
value = value.substr(index + parts[i].length);
} else {
partValue = value;
}
data = part(data, partValue);
}
When the next part is not a string there is no way to tell how the value should be split across the two readers.
else {
throw new Error("Unable to parse '" + spec.raw +
"', cannot separate consecutive data paths that have " +
"been concatenated together.");
}
break;
When part
is a string it will be at the beginning of value
.
case 'string':
value = value.substr(part.length);
break;
default:
throw new Error("Don't know how to deconcatenate " + part);
}
}
return data;
};
},
Compile the specified basis
element according to the specified
directive
.
compile: function compile (basis, directive) {
Each selector-directive pair in the directive
object is compiled
individually.
var actions = [];
for (var selector in directive) {
if (directive.hasOwnProperty(selector)) {
actions.push(compiler(
basis, parseSelector(selector), directive[selector]));
}
}
The renderer function is the accumulation of all actions
.
var renderer = function renderer (data, element) {
for (var i = 0, ii = actions.length; i < ii; i++) {
actions[i].apply(this, arguments);
}
return element;
};
renderer.inverse = function inverse (element, data) {
for (var i = 0, ii = actions.length; i < ii; i++) {
if (actions[i].inverse) {
data = actions[i].inverse.apply(this, arguments);
}
}
return data;
};
return renderer;
},
Auto compile inspects the class names of every descendant of the
specified element
and searches the specified data
object for a
matching property.
When a matching property is found the class name is incorporated into the
specified directive
to produce the final renderer function.
autoCompile: function autoCompile (element, directive, data) {
var e, up, q = [element];
var d, head, stack = [data];
var classNames, specs, spec, i, ii;
var children, els, j, jj;
var fn;
while (q.length) {
e = q.pop();
if (e) {
if (e.nodeType === 1) {
children = e.children;
if (e.className) {
specs = [];
classNames = e.className.split(/ +/);
head = stack[0];
for (i = 0, ii = classNames.length; i < ii; i++) {
if (classNames[i] &&
/^[+-]?([^@\+]+)(@([^\+]+))?[+-]?$/.test(classNames[i])) {
spec = parseSelector(classNames[i]);
if (spec.selector in head) {
specs.push(spec);
if (spec.selector !== classNames[i]) {
e.className = e.className.replace(
classNames[i], spec.selector);
}
}
}
}
for (i = 0, ii = specs.length; i < ii; i++) {
spec = specs[i];
d = head[spec.selector];
if (typeof d === 'object') {
spec.selector = '.' + spec.selector;
if (this.isArray(d)) {
if (e.parentNode) {
up = e.parentNode;
} else {
up = document.createDocumentFragment();
up.appendChild(e);
}
els = this.loopFinder(element, spec, d)(up, d, [up]);
for (j = 0, jj = d.length; j < jj; j++) {
if (typeof d[j] === 'object') {
stack.unshift(d[j]);
q.push(false, els[j]);
} else {
spec.selector = void 0;
fn = this.stringFormatter(element, spec, d[j]);
compiler([els[j]], spec, fn)(d[j], els[j]);
}
}
} else {
stack.unshift(d);
q.push(false);
q.push.apply(q, children);
children = [];
}
} else {
spec.selector = void 0;
fn = this.stringFormatter(element, spec, d);
compiler([e], spec, fn)(d, e);
}
}
stack.unshift(head);
q.push(false);
}
q.push.apply(q, children);
}
} else {
stack.shift();
}
}
for (var k in directive) {
if (directive.hasOwnProperty(k)) {
compiler([element], parseSelector(k), directive[k])(data, element);
}
}
return function autoRenderer () {
return element;
};
},
Used when a looping directive is encountered to extract the template and nested directive.
parseLoopSpec: function parseLoopSpec (template) {
var loopSpec;
var directive;
for (var key in template) {
if (template.hasOwnProperty(key)) {
The loopSpec takes the form of lhs<-rhs
or lhs<=rhs
. The
lefthand side (if present) specifies the property name that the
nested directive uses to refer to an item in the loop and the
righthand side specifies the property in the data object of the
array to loop over.
loopSpec = key.match(/^ *([^ ]*) *<([-=]) *([^ ]*) *$/);
if (loopSpec) {
if (directive) {
throw new Error("Found second looping directive, but found " +
"'" + key + "' and '" + directive.selector + "'.");
}
directive = {
selector: key,
directive: template[key],
lhs: loopSpec[1],
type: loopSpec[2],
rhs: loopSpec[3]
};
}
}
}
if (directive) {
return directive;
}
throw new Error("Expected looping directive (<-) is missing.");
},
Returns true
if the first argument is an array, false
otherwise.
isArray: Array.isArray
? function isArray (o) {
return Array.isArray(o);
}
: function isArray (o) {
return Object.prototype.toString.call(o) === "[object Array]";
}
};
Convenience function to attached an inverse function to a user defined formatter.
Tectonic.defineInverse = function defineInverse (fn, inverse) {
if (inverse) {
if (typeof inverse === 'function') {
fn.inverse = inverse;
} else {
fn.inverse = function inverse () {
throw new Error(inverse);
};
}
} else {
fn.inverse = identity;
}
return fn;
};
A formatter that returns the index of an item in a loop.
Tectonic.position = Tectonic.defineInverse(function position (__, _, i) {
return i + 1;
});
A formatter to add or remove the specified className
depending on the
truthiness of the specified property
in the data object. A formatter may
be specified in place of property
in which case an inverse
function may
also be specified.
Tectonic.toggleClass = function toggleClass (className, property, inverse) {
var format, read, path;
if (property) {
if (typeof property === 'string') {
path = property.split('.');
format = Tectonic.plugin.propFormatter(null, null, path);
read = Tectonic.plugin.propReader(null, null, path);
} else if (typeof property === 'function') {
format = property;
read = inverse || format.inverse || function missingInverse () {
throw new Error("Unable to parse, cannot find inverse of function.");
};
} else {
format = Tectonic.plugin.propFormatter(null, null, property);
read = Tectonic.plugin.propReader(null, null, property);
}
} else {
format = function format (data) {
return data;
};
read = function read () {
throw new Error("Unable to parse, expected an object.");
};
}
return Tectonic.defineInverse(function toggleClass () {
return function toggleClass (data, element) {
var value = format.call(this, data, element);
var selected = value === 'false' ? false : Boolean(value);
value = element.getAttribute('class') || '';
var classList = value.split(/\s+/);
var index = classList.indexOf(className);
if (index >= 0) {
if (!selected) {
classList.splice(index, 1);
value = classList.join(' ');
}
} else if (selected) {
value += " " + className;
}
return value.replace(/^\s+|\s+$/g, '');
};
}, function readClass (data, value) {
var classList = value.split(/\s+/);
var selected = classList.indexOf(className) >= 0;
return read.call(this, data, selected);
});
};
return Tectonic;
}));