UNPKG

ractive

Version:

Next-generation DOM manipulation

1,921 lines (1,448 loc) 98.8 kB
/*! Ractive - v0.2.0 - 2013-04-18 * Faster, easier, better interactive web development * http://rich-harris.github.com/Ractive/ * Copyright (c) 2013 Rich Harris; Licensed MIT */ /*jslint eqeq: true, plusplus: true */ /*global document, HTMLElement */ (function ( global ) { 'use strict'; var Ractive; (function () { 'use strict'; var getEl; Ractive = function ( options ) { var defaults, key; // Options // ------- if ( options ) { for ( key in options ) { if ( options.hasOwnProperty( key ) ) { this[ key ] = options[ key ]; } } } defaults = { preserveWhitespace: false, append: false, twoway: true, formatters: {}, modifyArrays: true }; for ( key in defaults ) { if ( defaults.hasOwnProperty( key ) && this[ key ] === undefined ) { this[ key ] = defaults[ key ]; } } // Initialization // -------------- if ( this.el !== undefined ) { this.el = getEl( this.el ); // turn ID string into DOM element } // Set up event bus this._subs = {}; // Set up cache this._cache = {}; this._cacheMap = {}; // Set up observers this.observers = {}; this.pendingResolution = []; // Create an array for deferred attributes this.deferredAttributes = []; // Initialise (or update) viewmodel with data if ( this.data ) { this.set( this.data ); } // If we were given uncompiled partials, compile them if ( this.partials ) { for ( key in this.partials ) { if ( this.partials.hasOwnProperty( key ) ) { if ( typeof this.partials[ key ] === 'string' ) { if ( !Ractive.compile ) { throw new Error( 'Missing Ractive.compile - cannot compile partial "' + key + '". Either precompile or use the version that includes the compiler' ); } this.partials[ key ] = Ractive.compile( this.partials[ key ], this ); // all compiler options are present on `this`, so just passing `this` } } } } // Compile template, if it hasn't been compiled already if ( typeof this.template === 'string' ) { if ( !Ractive.compile ) { throw new Error( 'Missing Ractive.compile - cannot compile template. Either precompile or use the version that includes the compiler' ); } this.template = Ractive.compile( this.template, this ); } // If the template was an array with a single string member, that means // we can use innerHTML - we just need to unpack it if ( this.template && ( this.template.length === 1 ) && ( typeof this.template[0] === 'string' ) ) { this.template = this.template[0]; } // If passed an element, render immediately if ( this.el ) { this.render({ el: this.el, callback: this.callback, append: this.append }); } }; // Prototype methods // ================= Ractive.prototype = { // Render instance to element specified here or at initialization render: function ( options ) { var el = ( options.el ? getEl( options.el ) : this.el ); if ( !el ) { throw new Error( 'You must specify a DOM element to render to' ); } // Clear the element, unless `append` is `true` if ( !options.append ) { el.innerHTML = ''; } if ( options.callback ) { this.callback = options.callback; } // Render our *root fragment* this.rendered = new _private.DomFragment({ model: this.template, root: this, parentNode: el }); el.appendChild( this.rendered.docFrag ); }, // Teardown. This goes through the root fragment and all its children, removing observers // and generally cleaning up after itself teardown: function () { this.rendered.teardown(); }, set: function ( keypath, value ) { if ( _private.isObject( keypath ) ) { this._setMultiple( keypath ); } else { this._setSingle( keypath, value ); } while ( this.deferredAttributes.length ) { this.deferredAttributes.pop().update().updateDeferred = false; } }, _setSingle: function ( keypath, value ) { var keys, key, obj, normalised, i, resolved, unresolved; if ( _private.isArray( keypath ) ) { keys = keypath.slice(); } else { keys = _private.splitKeypath( keypath ); } normalised = keys.join( '.' ); // clear cache this._clearCache( normalised ); // update data obj = this.data; while ( keys.length > 1 ) { key = keys.shift(); // if this branch doesn't exist yet, create a new one - if the next // key matches /^[0-9]+$/, assume we want an array branch rather // than an object if ( !obj[ key ] ) { obj[ key ] = ( /^[0-9]+$/.test( keys[0] ) ? [] : {} ); } obj = obj[ key ]; } key = keys[0]; obj[ key ] = value; // fire set event if ( !this.setting ) { this.setting = true; // short-circuit any potential infinite loops this.fire( 'set', normalised, value ); this.fire( 'set:' + normalised, value ); this.setting = false; } // Trigger updates of views that observe `keypaths` or its descendants this._notifyObservers( normalised, value ); // See if we can resolve any of the unresolved keypaths (if such there be) i = this.pendingResolution.length; while ( i-- ) { // Work backwards, so we don't go in circles! unresolved = this.pendingResolution.splice( i, 1 )[0]; resolved = this.resolveRef( unresolved.view.model.ref, unresolved.view.contextStack ); // If we were able to find a keypath, initialise the view if ( resolved ) { unresolved.callback( resolved.keypath, resolved.value ); } // Otherwise add to the back of the queue (this is why we're working backwards) else { this.registerUnresolvedKeypath( unresolved ); } } }, _setMultiple: function ( map ) { var keypath; for ( keypath in map ) { if ( map.hasOwnProperty( keypath ) ) { this._setSingle( keypath, map[ keypath ] ); } } }, _clearCache: function ( keypath ) { var children = this._cacheMap[ keypath ]; delete this._cache[ keypath ]; if ( !children ) { return; } while ( children.length ) { this._clearCache( children.pop() ); } }, get: function ( keypath ) { var keys, normalised, lastDotIndex, formula, match, parentKeypath, parentValue, propertyName, unformatted, unformattedKeypath, value, formatters; if ( _private.isArray( keypath ) ) { keys = keypath.slice(); // clone normalised = keys.join( '.' ); } else { // cache hit? great if ( keypath in this._cache ) { return this._cache[ keypath ]; } keys = _private.splitKeypath( keypath ); normalised = keys.join( '.' ); } // we may have a cache hit now that it's been normalised if ( normalised in this._cache ) { return this._cache[ normalised ]; } // otherwise it looks like we need to do some work if ( keys.length > 1 ) { formula = keys.pop(); parentValue = this.get( keys ); } else { formula = keys.pop(); parentValue = this.data; } // is this a set of formatters? if ( match = /^⭆(.+)⭅$/.exec( formula ) ) { formatters = _private.getFormattersFromString( match[1] ); value = this._format( parentValue, formatters ); } else { if ( typeof parentValue !== 'object' ) { return; } value = parentValue[ formula ]; } // update cacheMap if ( keys.length ) { parentKeypath = keys.join( '.' ); if ( !this._cacheMap[ parentKeypath ] ) { this._cacheMap[ parentKeypath ] = []; } this._cacheMap[ parentKeypath ].push( normalised ); } // allow functions as values // TODO allow arguments, same as formatters? if ( typeof value === 'function' ) { value = value(); } // update cache this._cache[ normalised ] = value; return value; }, update: function () { // TODO throw new Error( 'not implemented yet!' ); return this; }, link: function ( keypath ) { var self = this; return function ( value ) { self.set( keypath, value ); }; }, registerView: function ( view ) { var self = this, resolved, initialUpdate, value, index; if ( view.parentFragment && ( view.parentFragment.indexRefs.hasOwnProperty( view.model.ref ) ) ) { // this isn't a real keypath, it's an index reference index = view.parentFragment.indexRefs[ view.model.ref ]; value = ( view.model.fmtrs ? this._format( index, view.model.fmtrs ) : index ); view.update( value ); return; // this value will never change, and doesn't have a keypath } initialUpdate = function ( keypath, value ) { if ( view.model.fmtrs ) { view.keypath = keypath + '.' + _private.stringifyFormatters( view.model.fmtrs ); } else { view.keypath = keypath; } // create observers view.observerRefs = self.observe( view.model.p || 0, view ); view.update( self.get( view.keypath ) ); }; resolved = this.resolveRef( view.model.ref, view.contextStack ); if ( !resolved ) { // we may still need to do an update, if the view has formatters // that e.g. offer an alternative to undefined if ( view.model.fmtrs ) { view.update( this._format( undefined, view.model.fmtrs ) ); } this.registerUnresolvedKeypath({ view: view, callback: initialUpdate }); } else { initialUpdate( resolved.keypath, resolved.value ); } }, // Resolve a full keypath from `ref` within the given `contextStack` (e.g. // `'bar.baz'` within the context stack `['foo']` might resolve to `'foo.bar.baz'` resolveRef: function ( ref, contextStack ) { var innerMost, keypath, value; // Implicit iterators - i.e. {{.}} - are a special case if ( ref === '.' ) { keypath = contextStack[ contextStack.length - 1 ]; value = this.get( keypath ); return { keypath: keypath, value: value }; } // Clone the context stack, so we don't mutate the original contextStack = contextStack.concat(); // Take each context from the stack, working backwards from the innermost context while ( contextStack.length ) { innerMost = contextStack.pop(); keypath = innerMost + '.' + ref; value = this.get( keypath ); if ( value !== undefined ) { return { keypath: keypath, value: value }; } } value = this.get( ref ); if ( value !== undefined ) { return { keypath: ref, value: value }; } }, registerUnresolvedKeypath: function ( unresolved ) { this.pendingResolution[ this.pendingResolution.length ] = unresolved; }, // Internal method to format a value, using formatters passed in at initialization _format: function ( value, formatters ) { var i, numFormatters, formatter, name, args, fn; // If there are no formatters, groovy - just return the value unchanged if ( !formatters ) { return value; } // Otherwise go through each in turn, applying sequentially numFormatters = formatters.length; for ( i=0; i<numFormatters; i+=1 ) { formatter = formatters[i]; name = formatter.name; args = formatter.args || []; // If a formatter was passed in, use it, otherwise see if there's a default // one with this name fn = this.formatters[ name ] || Ractive.formatters[ name ]; if ( fn ) { value = fn.apply( this, [ value ].concat( args ) ); } } return value; }, _notifyObservers: function ( keypath, value ) { var self = this, observersGroupedByPriority = this.observers[ keypath ] || [], i, j, priorityGroup, observer, actualValue; for ( i=0; i<observersGroupedByPriority.length; i+=1 ) { priorityGroup = observersGroupedByPriority[i]; if ( priorityGroup ) { for ( j=0; j<priorityGroup.length; j+=1 ) { observer = priorityGroup[j]; observer.update( self.get( observer.keypath ) ); } } } }, observe: function ( priority, view ) { var self = this, keypath, originalKeypath = view.keypath, observerRefs = [], observe, keys; if ( !originalKeypath ) { return undefined; } observe = function ( keypath ) { var observers, observer; observers = self.observers[ keypath ] = self.observers[ keypath ] || []; observers = observers[ priority ] = observers[ priority ] || []; observers[ observers.length ] = view; observerRefs[ observerRefs.length ] = { keypath: keypath, priority: priority, view: view }; }; keys = _private.splitKeypath( view.keypath ); while ( keys.length > 1 ) { observe( keys.join( '.' ) ); // remove the last item in the keypath, so that data.set( 'parent', { child: 'newValue' } ) affects views dependent on parent.child keys.pop(); } observe( keys[0] ); return observerRefs; }, unobserve: function ( observerRef ) { var priorities, observers, index, i, len; priorities = this.observers[ observerRef.keypath ]; if ( !priorities ) { // nothing to unobserve return; } observers = priorities[ observerRef.priority ]; if ( !observers ) { // nothing to unobserve return; } if ( observers.indexOf ) { index = observers.indexOf( observerRef.observer ); } else { // fuck you IE for ( i=0, len=observers.length; i<len; i+=1 ) { if ( observers[i] === observerRef.view ) { index = i; break; } } } if ( index === -1 ) { // nothing to unobserve return; } // remove the observer from the list... observers.splice( index, 1 ); // ...then tidy up if necessary if ( observers.length === 0 ) { delete priorities[ observerRef.priority ]; } if ( priorities.length === 0 ) { delete this.observers[ observerRef.keypath ]; } }, unobserveAll: function ( observerRefs ) { while ( observerRefs.length ) { this.unobserve( observerRefs.shift() ); } } }; // helper functions getEl = function ( input ) { var output, doc; if ( typeof window === 'undefined' ) { return; } doc = window.document; if ( !input ) { throw new Error( 'No container element specified' ); } // We already have a DOM node - no work to do if ( input.tagName ) { return input; } // Get node from string if ( typeof input === 'string' ) { // try ID first output = doc.getElementById( input ); // then as selector, if possible if ( !output && doc.querySelector ) { output = doc.querySelector( input ); } // did it work? if ( output.tagName ) { return output; } } // If we've been given a collection (jQuery, Zepto etc), extract the first item if ( input[0] && input[0].tagName ) { return input[0]; } throw new Error( 'Could not find container element' ); }; return Ractive; }()); var _private; (function () { 'use strict'; var formattersCache = {}; _private = { // thanks, http://perfectionkills.com/instanceof-considered-harmful-or-how-to-write-a-robust-isarray/ isArray: function ( obj ) { return Object.prototype.toString.call( obj ) === '[object Array]'; }, // TODO what about non-POJOs? isObject: function ( obj ) { return ( Object.prototype.toString.call( obj ) === '[object Object]' ) && ( typeof obj !== 'function' ); }, // http://stackoverflow.com/questions/18082/validate-numbers-in-javascript-isnumeric isNumeric: function ( n ) { return !isNaN( parseFloat( n ) ) && isFinite( n ); }, // TODO this is a bit regex-heavy... could be optimised maybe? splitKeypath: function ( keypath ) { var result, hasEscapedDots, hasFormatters, formatters, split, i, replacer, index, startIndex, key, keys, remaining, blanked, part; // if this string contains no escaped dots or formatters, // we can just split on dots, after converting from array notation if ( !( hasEscapedDots = /\\\./.test( keypath ) ) && !( hasFormatters = /⭆.+⭅/.test( keypath ) ) ) { return keypath.replace( /\[\s*([0-9]+)\s*\]/g, '.$1' ).split( '.' ); } keys = []; remaining = keypath; // first, blank formatters in case they contain dots, but store them // so we can reinstate them later if ( hasFormatters ) { formatters = []; remaining = remaining.replace( /⭆(.+)⭅/g, function ( match, $1 ) { var blanked, i; formatters[ formatters.length ] = $1; return '⭆x⭅'; }); } startIndex = 0; // split into keys while ( remaining.length ) { // find next dot index = remaining.indexOf( '.', startIndex ); // final part? if ( index === -1 ) { // TODO tidy up! part = remaining; remaining = ''; } else { // if this dot is preceded by a backslash, which isn't // itself preceded by a backslash, we consider it escaped if ( remaining.charAt( index - 1) === '\\' && remaining.charAt( index - 2 ) !== '\\' ) { // we don't want to keep this part, we want to keep looking // for the separator startIndex = index + 1; continue; } // otherwise, we have our next part part = remaining.substr( 0, index ); startIndex = 0; } if ( /\[/.test( part ) ) { keys = keys.concat( part.replace( /\[\s*([0-9]+)\s*\]/g, '.$1' ).split( '.' ) ); } else { keys[ keys.length ] = part; } remaining = remaining.substring( index + 1 ); } // then, reinstate formatters if ( hasFormatters ) { replacer = function ( match ) { return '⭆' + formatters.pop() + '⭅'; }; i = keys.length; while ( i-- ) { if ( keys[i] === '⭆x⭅' ) { keys[i] = '⭆' + formatters.pop() + '⭅'; } } } return keys; }, getFormattersFromString: function ( str ) { var formatters, raw, remaining; if ( formattersCache[ str ] ) { return formattersCache[ str ]; } raw = str.split( '⤋' ); formatters = raw.map( function ( str ) { var index; index = str.indexOf( '[' ); if ( index === -1 ) { return { name: str, args: [] }; } return { name: str.substr( 0, index ), args: JSON.parse( str.substring( index ) ) }; }); formattersCache[ str ] = formatters; return formatters; }, stringifyFormatters: function ( formatters ) { var stringified = formatters.map( function ( formatter ) { if ( formatter.args && formatter.args.length ) { return formatter.name + JSON.stringify( formatter.args ); } return formatter.name; }); return '⭆' + stringified.join( '⤋' ) + '⭅'; } }; }()); _private.types = { TEXT: 1, INTERPOLATOR: 2, TRIPLE: 3, SECTION: 4, INVERTED: 5, CLOSING: 6, ELEMENT: 7, PARTIAL: 8, COMMENT: 9, DELIMCHANGE: 10, MUSTACHE: 11, TAG: 12, ATTR_VALUE_TOKEN: 13 }; (function ( _private ) { 'use strict'; _private._Mustache = function ( options ) { this.root = options.root; this.model = options.model; this.parent = options.parent; this.parentFragment = options.parentFragment; this.contextStack = options.contextStack || []; this.index = options.index || 0; // DOM only if ( options.parentNode || options.anchor ) { this.parentNode = options.parentNode; this.anchor = options.anchor; } this.type = options.model.type; this.root.registerView( this ); // if we have a failed keypath lookup, and this is an inverted section, // we need to trigger this.update() so the contents are rendered if ( !this.keypath && this.model.inv ) { // test both section-hood and inverticity in one go this.update( false ); } }; _private._Fragment = function ( options ) { var numItems, i, itemOptions, parentRefs, ref; this.parent = options.parent; this.index = options.index; this.items = []; this.indexRefs = {}; if ( this.parent && this.parent.parentFragment ) { parentRefs = this.parent.parentFragment.indexRefs; for ( ref in parentRefs ) { if ( parentRefs.hasOwnProperty( ref ) ) { this.indexRefs[ ref ] = parentRefs[ ref ]; } } } if ( options.indexRef ) { this.indexRefs[ options.indexRef ] = options.index; } itemOptions = { root: options.root, parentFragment: this, parent: this, parentNode: options.parentNode, contextStack: options.contextStack }; numItems = ( options.model ? options.model.length : 0 ); for ( i=0; i<numItems; i+=1 ) { itemOptions.model = options.model[i]; itemOptions.index = i; // this.items[ this.items.length ] = createView( itemOptions ); this.items[ this.items.length ] = this.createItem( itemOptions ); } }; _private._sectionUpdate = function ( value ) { var fragmentOptions, valueIsArray, emptyArray, i, itemsToRemove; fragmentOptions = { model: this.model.frag, root: this.root, parentNode: this.parentNode, parent: this }; // TODO if DOM type, need to know anchor if ( this.parentNode ) { fragmentOptions.anchor = this.parentFragment.findNextNode( this ); } valueIsArray = _private.isArray( value ); // modify the array to allow updates via push, pop etc if ( valueIsArray && this.root.modifyArrays ) { _private.modifyArray( value, this.keypath, this.root ); } // treat empty arrays as false values if ( valueIsArray && value.length === 0 ) { emptyArray = true; } // if section is inverted, only check for truthiness/falsiness if ( this.model.inv ) { if ( value && !emptyArray ) { if ( this.length ) { this.unrender(); this.length = 0; } } else { if ( !this.length ) { // no change to context stack in this situation fragmentOptions.contextStack = this.contextStack; fragmentOptions.index = 0; this.fragments[0] = this.createFragment( fragmentOptions ); this.length = 1; return; } } if ( this.postUpdate ) { this.postUpdate(); } return; } // otherwise we need to work out what sort of section we're dealing with // if value is an array, iterate through if ( valueIsArray ) { // if the array is shorter than it was previously, remove items if ( value.length < this.length ) { itemsToRemove = this.fragments.splice( value.length, this.length - value.length ); while ( itemsToRemove.length ) { itemsToRemove.pop().teardown(); } } // otherwise... else { if ( value.length > this.length ) { // add any new ones for ( i=this.length; i<value.length; i+=1 ) { // append list item to context stack fragmentOptions.contextStack = this.contextStack.concat( this.keypath + '.' + i ); fragmentOptions.index = i; if ( this.model.i ) { fragmentOptions.indexRef = this.model.i; } this.fragments[i] = this.createFragment( fragmentOptions ); } } } this.length = value.length; } // if value is a hash... else if ( _private.isObject( value ) ) { // ...then if it isn't rendered, render it, adding this.keypath to the context stack // (if it is already rendered, then any children dependent on the context stack // will update themselves without any prompting) if ( !this.length ) { // append this section to the context stack fragmentOptions.contextStack = this.contextStack.concat( this.keypath ); fragmentOptions.index = 0; this.fragments[0] = this.createFragment( fragmentOptions ); this.length = 1; } } // otherwise render if value is truthy, unrender if falsy else { if ( value && !emptyArray ) { if ( !this.length ) { // no change to context stack fragmentOptions.contextStack = this.contextStack; fragmentOptions.index = 0; this.fragments[0] = this.createFragment( fragmentOptions ); this.length = 1; } } else { if ( this.length ) { this.unrender(); this.length = 0; } } } if ( this.postUpdate ) { this.postUpdate(); } }; }( _private )); (function ( proto ) { 'use strict'; proto.on = function ( eventName, callback ) { var self = this; if ( !this._subs[ eventName ] ) { this._subs[ eventName ] = [ callback ]; } else { this._subs[ eventName ].push( callback ); } return { cancel: function () { self.off( eventName, callback ); } }; }; proto.off = function ( eventName, callback ) { var subscribers, index; // if no callback specified, remove all callbacks if ( !callback ) { // if no event name specified, remove all callbacks for all events if ( !eventName ) { this._subs = {}; } else { this._subs[ eventName ] = []; } } subscribers = this._subs[ eventName ]; if ( subscribers ) { index = subscribers.indexOf( callback ); if ( index !== -1 ) { subscribers.splice( index, 1 ); } } }; proto.fire = function ( eventName ) { var args, i, len, subscribers = this._subs[ eventName ]; if ( !subscribers ) { return; } args = Array.prototype.slice.call( arguments, 1 ); for ( i=0, len=subscribers.length; i<len; i+=1 ) { subscribers[i].apply( this, args ); } }; }( Ractive.prototype )); var Ractive = Ractive || {}, _private = _private || {}; // in case we're not using the runtime (function ( R, _private ) { 'use strict'; var FragmentStub, getFragmentStubFromTokens, TextStub, ElementStub, SectionStub, MustacheStub, decodeCharacterReferences, htmlEntities, getFormatter, types, voidElementNames, allElementNames, closedByParentClose, implicitClosersByTagName; R.compile = function ( template, options ) { var tokens, fragmentStub, json; options = options || {}; if ( options.sanitize === true ) { options.sanitize = { // blacklist from https://code.google.com/p/google-caja/source/browse/trunk/src/com/google/caja/lang/html/html4-elements-whitelist.json elements: [ 'applet', 'base', 'basefont', 'body', 'frame', 'frameset', 'head', 'html', 'isindex', 'link', 'meta', 'noframes', 'noscript', 'object', 'param', 'script', 'style', 'title' ], eventAttributes: true }; } // If delimiters are specified use them, otherwise reset to defaults R.delimiters = options.delimiters || [ '{{', '}}' ]; R.tripleDelimiters = options.tripleDelimiters || [ '{{{', '}}}' ]; tokens = _private.tokenize( template ); fragmentStub = getFragmentStubFromTokens( tokens, 0, options, options.preserveWhitespace ); json = fragmentStub.toJson(); if ( typeof json === 'string' ) { return [ json ]; // signal that this shouldn't be recompiled } return json; }; types = _private.types; voidElementNames = [ 'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr' ]; allElementNames = [ 'a', 'abbr', 'acronym', 'address', 'applet', 'area', 'b', 'base', 'basefont', 'bdo', 'big', 'blockquote', 'body', 'br', 'button', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', 'dd', 'del', 'dfn', 'dir', 'div', 'dl', 'dt', 'em', 'fieldset', 'font', 'form', 'frame', 'frameset', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'hr', 'html', 'i', 'iframe', 'img', 'input', 'ins', 'isindex', 'kbd', 'label', 'legend', 'li', 'link', 'map', 'menu', 'meta', 'noframes', 'noscript', 'object', 'ol', 'optgroup', 'option', 'p', 'param', 'pre', 'q', 's', 'samp', 'script', 'select', 'small', 'span', 'strike', 'strong', 'style', 'sub', 'sup', 'table', 'tbody', 'td', 'textarea', 'tfoot', 'th', 'thead', 'title', 'tr', 'tt', 'u', 'ul', 'var', 'article', 'aside', 'audio', 'bdi', 'canvas', 'command', 'data', 'datagrid', 'datalist', 'details', 'embed', 'eventsource', 'figcaption', 'figure', 'footer', 'header', 'hgroup', 'keygen', 'mark', 'meter', 'nav', 'output', 'progress', 'ruby', 'rp', 'rt', 'section', 'source', 'summary', 'time', 'track', 'video', 'wbr' ]; closedByParentClose = [ 'li', 'dd', 'rt', 'rp', 'optgroup', 'option', 'tbody', 'tfoot', 'tr', 'td', 'th' ]; implicitClosersByTagName = { li: [ 'li' ], dt: [ 'dt', 'dd' ], dd: [ 'dt', 'dd' ], p: [ 'address', 'article', 'aside', 'blockquote', 'dir', 'div', 'dl', 'fieldset', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'menu', 'nav', 'ol', 'p', 'pre', 'section', 'table', 'ul' ], rt: [ 'rt', 'rp' ], rp: [ 'rp', 'rt' ], optgroup: [ 'optgroup' ], option: [ 'option', 'optgroup' ], thead: [ 'tbody', 'tfoot' ], tbody: [ 'tbody', 'tfoot' ], tr: [ 'tr' ], td: [ 'td', 'th' ], th: [ 'td', 'th' ] }; getFormatter = function ( str ) { var name, argsStr, args, openIndex; openIndex = str.indexOf( '[' ); if ( openIndex !== -1 ) { name = str.substr( 0, openIndex ); argsStr = str.substring( openIndex, str.length ); try { args = JSON.parse( argsStr ); } catch ( err ) { throw 'Could not parse arguments (' + argsStr + ') using JSON.parse'; } return { name: name, args: args }; } return { name: str }; }; htmlEntities = { quot: 34, amp: 38, apos: 39, lt: 60, gt: 62, nbsp: 160, iexcl: 161, cent: 162, pound: 163, curren: 164, yen: 165, brvbar: 166, sect: 167, uml: 168, copy: 169, ordf: 170, laquo: 171, not: 172, shy: 173, reg: 174, macr: 175, deg: 176, plusmn: 177, sup2: 178, sup3: 179, acute: 180, micro: 181, para: 182, middot: 183, cedil: 184, sup1: 185, ordm: 186, raquo: 187, frac14: 188, frac12: 189, frac34: 190, iquest: 191, Agrave: 192, Aacute: 193, Acirc: 194, Atilde: 195, Auml: 196, Aring: 197, AElig: 198, Ccedil: 199, Egrave: 200, Eacute: 201, Ecirc: 202, Euml: 203, Igrave: 204, Iacute: 205, Icirc: 206, Iuml: 207, ETH: 208, Ntilde: 209, Ograve: 210, Oacute: 211, Ocirc: 212, Otilde: 213, Ouml: 214, times: 215, Oslash: 216, Ugrave: 217, Uacute: 218, Ucirc: 219, Uuml: 220, Yacute: 221, THORN: 222, szlig: 223, agrave: 224, aacute: 225, acirc: 226, atilde: 227, auml: 228, aring: 229, aelig: 230, ccedil: 231, egrave: 232, eacute: 233, ecirc: 234, euml: 235, igrave: 236, iacute: 237, icirc: 238, iuml: 239, eth: 240, ntilde: 241, ograve: 242, oacute: 243, ocirc: 244, otilde: 245, ouml: 246, divide: 247, oslash: 248, ugrave: 249, uacute: 250, ucirc: 251, uuml: 252, yacute: 253, thorn: 254, yuml: 255, OElig: 338, oelig: 339, Scaron: 352, scaron: 353, Yuml: 376, fnof: 402, circ: 710, tilde: 732, Alpha: 913, Beta: 914, Gamma: 915, Delta: 916, Epsilon: 917, Zeta: 918, Eta: 919, Theta: 920, Iota: 921, Kappa: 922, Lambda: 923, Mu: 924, Nu: 925, Xi: 926, Omicron: 927, Pi: 928, Rho: 929, Sigma: 931, Tau: 932, Upsilon: 933, Phi: 934, Chi: 935, Psi: 936, Omega: 937, alpha: 945, beta: 946, gamma: 947, delta: 948, epsilon: 949, zeta: 950, eta: 951, theta: 952, iota: 953, kappa: 954, lambda: 955, mu: 956, nu: 957, xi: 958, omicron: 959, pi: 960, rho: 961, sigmaf: 962, sigma: 963, tau: 964, upsilon: 965, phi: 966, chi: 967, psi: 968, omega: 969, thetasym: 977, upsih: 978, piv: 982, ensp: 8194, emsp: 8195, thinsp: 8201, zwnj: 8204, zwj: 8205, lrm: 8206, rlm: 8207, ndash: 8211, mdash: 8212, lsquo: 8216, rsquo: 8217, sbquo: 8218, ldquo: 8220, rdquo: 8221, bdquo: 8222, dagger: 8224, Dagger: 8225, bull: 8226, hellip: 8230, permil: 8240, prime: 8242, Prime: 8243, lsaquo: 8249, rsaquo: 8250, oline: 8254, frasl: 8260, euro: 8364, image: 8465, weierp: 8472, real: 8476, trade: 8482, alefsym: 8501, larr: 8592, uarr: 8593, rarr: 8594, darr: 8595, harr: 8596, crarr: 8629, lArr: 8656, uArr: 8657, rArr: 8658, dArr: 8659, hArr: 8660, forall: 8704, part: 8706, exist: 8707, empty: 8709, nabla: 8711, isin: 8712, notin: 8713, ni: 8715, prod: 8719, sum: 8721, minus: 8722, lowast: 8727, radic: 8730, prop: 8733, infin: 8734, ang: 8736, and: 8743, or: 8744, cap: 8745, cup: 8746, 'int': 8747, there4: 8756, sim: 8764, cong: 8773, asymp: 8776, ne: 8800, equiv: 8801, le: 8804, ge: 8805, sub: 8834, sup: 8835, nsub: 8836, sube: 8838, supe: 8839, oplus: 8853, otimes: 8855, perp: 8869, sdot: 8901, lceil: 8968, rceil: 8969, lfloor: 8970, rfloor: 8971, lang: 9001, rang: 9002, loz: 9674, spades: 9824, clubs: 9827, hearts: 9829, diams: 9830 }; decodeCharacterReferences = function ( html ) { var result; // named entities result = html.replace( /&([a-zA-Z]+);/, function ( match, name ) { if ( htmlEntities[ name ] ) { return String.fromCharCode( htmlEntities[ name ] ); } return match; }); // hex references result = result.replace( /&#x([0-9]+);/, function ( match, hex ) { return String.fromCharCode( parseInt( hex, 16 ) ); }); // decimal references result = result.replace( /&#([0-9]+);/, function ( match, num ) { return String.fromCharCode( num ); }); return result; }; TextStub = function ( token ) { this.type = types.TEXT; this.text = token.value; }; TextStub.prototype = { toJson: function () { // this will be used as text, so we need to decode things like &amp; return this.decoded || ( this.decoded = decodeCharacterReferences( this.text) ); }, toString: function () { // this will be used as straight text return this.text; }, decodeCharacterReferences: function () { } }; ElementStub = function ( token, parentFragment ) { var items, attributes, numAttributes, i, attribute, preserveWhitespace; this.type = types.ELEMENT; this.tag = token.tag; this.parentFragment = parentFragment; this.parentElement = parentFragment.parentElement; items = token.attributes.items; numAttributes = items.length; if ( numAttributes ) { attributes = []; for ( i=0; i<numAttributes; i+=1 ) { // sanitize if ( parentFragment.options.sanitize && parentFragment.options.sanitize.eventAttributes ) { if ( items[i].name.value.toLowerCase().substr( 0, 2 ) === 'on' ) { continue; } } attribute = { name: items[i].name.value }; if ( !items[i].value.isNull ) { attribute.value = getFragmentStubFromTokens( items[i].value.tokens, this.parentFragment.priority + 1 ); } attributes[i] = attribute; } this.attributes = attributes; } // if this is a void element, or a self-closing tag, seal the element if ( token.isSelfClosingTag || voidElementNames.indexOf( token.tag.toLowerCase() ) !== -1 ) { return; } // preserve whitespace if parent fragment has preserveWhitespace flag, or // if this is a <pre> element preserveWhitespace = parentFragment.preserveWhitespace || this.tag.toLowerCase() === 'pre'; this.fragment = new FragmentStub( this, parentFragment.priority + 1, parentFragment.options, preserveWhitespace ); }; ElementStub.prototype = { read: function ( token ) { return this.fragment && this.fragment.read( token ); }, toJson: function ( noStringify ) { var json, attrName, attrValue, str, i; json = { type: types.ELEMENT, tag: this.tag }; if ( this.attributes ) { json.attrs = {}; for ( i=0; i<this.attributes.length; i+=1 ) { attrName = this.attributes[i].name; // empty attributes (e.g. autoplay, checked) if( this.attributes[i].value === undefined ) { attrValue = null; } else { // can we stringify the value? str = this.attributes[i].value.toString(); if ( str !== false ) { // need to explicitly check, as '' === false attrValue = str; } else { attrValue = this.attributes[i].value.toJson(); } } json.attrs[ attrName ] = attrValue; } } if ( this.fragment && this.fragment.items.length ) { json.frag = this.fragment.toJson( noStringify ); } return json; }, toString: function () { var str, i, len, attrStr, attrValueStr, fragStr, isVoid; // if this isn't an HTML element, it can't be stringified (since the only reason to stringify an // element is to use with innerHTML, and SVG doesn't support that method if ( allElementNames.indexOf( this.tag.toLowerCase() ) === -1 ) { return false; } // see if children can be stringified (i.e. don't contain mustaches) fragStr = ( this.fragment ? this.fragment.toString() : '' ); if ( fragStr === false ) { return false; } // is this a void element? isVoid = ( voidElementNames.indexOf( this.tag.toLowerCase() ) !== -1 ); str = '<' + this.tag; if ( this.attributes ) { for ( i=0, len=this.attributes.length; i<len; i+=1 ) { // does this look like a namespaced attribute? if so we can't stringify it if ( this.attributes[i].name.indexOf( ':' ) !== -1 ) { return false; } attrStr = ' ' + this.attributes[i].name; // empty attributes if ( this.attributes[i].value !== undefined ) { attrValueStr = this.attributes[i].value.toString(); if ( attrValueStr === false ) { return false; } if ( attrValueStr !== '' ) { attrStr += '='; // does it need to be quoted? if ( /[\s"'=<>`]/.test( attrValueStr ) ) { attrStr += '"' + attrValueStr.replace( /"/g, '&quot;' ) + '"'; } else { attrStr += attrValueStr; } } } str += attrStr; } } // if this isn't a void tag, but is self-closing, add a solidus. Aaaaand, we're done if ( this.isSelfClosing && !isVoid ) { str += '/>'; return str; } str += '>'; // void element? we're done if ( isVoid ) { return str; } // if this has children, add them str += fragStr; str += '</' + this.tag + '>'; return str; } }; SectionStub = function ( token, parentFragment ) { this.type = types.SECTION; this.parentFragment = parentFragment; this.ref = token.ref; this.inverted = ( token.type === types.INVERTED ); this.formatters = token.formatters; this.i = token.i; this.fragment = new FragmentStub( this, parentFragment.priority + 1, parentFragment.options, parentFragment.preserveWhitespace ); }; SectionStub.prototype = { read: function ( token ) { return this.fragment.read( token ); }, toJson: function ( noStringify ) { var json; json = { type: types.SECTION, ref: this.ref }; if ( this.fragment ) { json.frag = this.fragment.toJson( noStringify ); } if ( this.formatters && this.formatters.length ) { json.fmtrs = this.formatters.map( getFormatter ); } if ( this.inverted ) { json.inv = true; } if ( this.priority ) { json.p = this.parentFragment.priority; } if ( this.i ) { json.i = this.i; } return json; }, toString: function () { // sections cannot be stringified return false; } }; MustacheStub = function ( token, priority ) { this.type = token.type; this.priority = priority; this.ref = token.ref; this.formatters = token.formatters; }; MustacheStub.prototype = { toJson: function () { var json = { type: this.type, ref: this.ref }; if ( this.formatters ) { json.fmtrs = this.formatters.map( getFormatter ); } if ( this.priority ) { json.p = this.priority; } return json; }, toString: function () { // mustaches cannot be stringified return false; } }; FragmentStub = function ( owner, priority, options, preserveWhitespace ) { this.owner = owner; this.items = []; this.options = options; this.preserveWhitespace = preserveWhitespace; if ( owner ) { this.parentElement = ( owner.type === types.ELEMENT ? owner : owner.parentElement ); } this.priority = priority; }; FragmentStub.prototype = { read: function ( token ) { if ( this.sealed ) { return false; } // does this token implicitly close this fragment? (e.g. an <li> without a </li> being closed by another <li>) if ( this.isImplicitlyClosedBy( token ) ) { this.seal(); return false; } // do we have an open child section/element? if ( this.currentChild ) { // can it use this token? if ( this.currentChild.read( token ) ) { return true; } // if not, we no longer have an open child this.currentChild = null; } // does this token explicitly close this fragment? if ( this.isExplicitlyClosedBy( token ) ) { this.seal(); return true; } // time to create a new child... // (...unless this is a section closer or a delimiter change or a comment) if ( token.type === types.CLOSING || token.type === types.DELIMCHANGE || token.type === types.COMMENT ) { return false; } // section? if ( token.type === types.SECTION || token.type === types.INVERTED ) { this.currentChild = new SectionStub( token, this ); this.items[ this.items.length ] = this.currentChild; return true; } // element? if ( token.type === types.TAG ) { this.currentChild = new ElementStub( token, this ); // sanitize if ( this.options.sanitize && this.options.sanitize.elements && this.options.sanitize.elements.indexOf( token.tag.toLowerCase() ) !== -1 ) { return true; } this.items[ this.items.length ] = this.currentChild; return true; } // text or attribute value? if ( token.type === types.TEXT || token.type === types.ATTR_VALUE_TOKEN ) { this.items[ this.items.length ] = new TextStub( token ); return true; } // none of the above? must be a mustache this.items[ this.items.length ] = new MustacheStub( token, this.priority ); return true; }, isClosedBy: function ( token ) { return this.isImplicitlyClosedBy( token ) || this.isExplicitlyClosedBy( token ); }, isImplicitlyClosedBy: function ( token ) { var implicitClosers, element, parentElement, thisTag, tokenTag; if ( !token.tag || !this.owner || ( this.owner.type !== types.ELEMENT ) ) { return false; } thisTag = this.owner.tag.toLowerCase(); tokenTag = token.tag.toLowerCase(); element = this.owner; parentElement = element.parentElement || null; // if this is an element whose end tag can be omitted if followed by an element // which is an 'implicit closer', return true implicitClosers = implicitClosersByTagName[ thisTag ]; if ( implicitClosers ) { if ( !token.isClosingTag && implicitClosers.indexOf( tokenTag ) !== -1 ) { return true; } } // if this is an element that is closed when its parent closes, return true if ( closedByParentClose.indexOf( thisTag ) !== -1 ) { if ( parentElement && parentElement.fragment.isClosedBy( token ) ) { return true; } } // special cases // p element end tag can be omitted when parent closes if it is not an a element if ( thisTag === 'p' ) { if ( parentElement && parentElement.tag.toLowerCase() === 'a' && parentElement.fragment.isClosedBy( token ) ) { return true; } } }, isExplicitlyClosedBy: function ( token ) { if ( !this.owner ) { return false; } if ( this.owner.type === types.SECTION ) { if ( token.type === types.CLOSING && token.ref === this.owner.ref ) { return true; } } if ( this.owner.type === types.ELEMENT && this.owner ) { if ( token.isClosingTag && ( token.tag.toLowerCase() === this.owner.tag.toLowerCase() ) ) { return true; } } }, toJson: function ( noStringify ) { var result = [], i, len, str; // can we stringify this? if ( !noStringify ) { str = this.toString(); if ( str !== false ) { return str; } } for ( i=0, len=this.items.length; i<len; i+=1 ) { result[i] = this.items[i].toJson( noStringify ); } return result; }, toString: function () { var str = '', i, len, itemStr; for ( i=0, len=this.items.length; i<len; i+=1 ) { itemStr = this.items[i].toString(); // if one of the child items cannot be stringified (i.e. contains a mustache) return false if ( itemStr === false ) { return false; } str += itemStr; } return str; }, seal: function () { var first, last, i, item; this.sealed = true; // if this is an element fragment, remove leading and trailing whitespace if ( !this.preserveWhitespace ) { if ( this.owner.type === types.ELEMENT ) { first = this.items[0]; if ( first && first.type === types.TEXT ) { first.text = first.text.replace( /^\s*/, '' ); if ( first.text === '' ) { this.items.shift(); } } last = this.items[ this.items.length - 1 ]; if ( last && last.type === types.TEXT ) { last.text = last.text.replace( /\s*$/, '' ); if ( last.text === '' ) { this.items.pop(); } } } // collapse multiple whitespace characters i = this.items.length; while ( i-- ) { item = this.items[i]; if ( item.type === types.TEXT ) { item.text = item.text.replace( /\s{2,}/g, ' ' ); } } } if ( !this.items.length ) { delete this.owner.fragment; } } }; getFragmentStubFromTokens = function ( tokens, priority, options, preserveWhitespace ) { var fragStub = new FragmentStub( null, priority, options, preserveWhitespace ), token; while ( tokens.length ) { token = tokens.shift(); fragStub.read( token ); } return fragStub; }; }( Ractive, _private )); (function ( R, _private ) { 'use strict'; var types, whitespace, stripHtmlComments, stripStandalones, stripCommentTokens, TokenStream, MustacheBuffer, TextToken, MustacheToken, TripleToken, TagToken, AttributeValueToken, mustacheTypes, OpeningBracket, TagName, AttributeCollection, Solidus, ClosingBracket, Attribute, AttributeName, AttributeValue; _private.tokenize = function ( template ) { var stream = TokenStream.fromString( stripHtmlComments( template ) ); return stripCommentTokens( stripStandalones( stream.tokens ) ); }; // TokenStream generates an array of tokens from an HTML string TokenStream = function () { this.tokens = []; this.buffer = new MustacheBuffer(); }; TokenStream.prototype = { read: function ( char ) { var mustacheToken, bufferValue; // if we're building a tag or mustache, send everything to it including delimiter characters if ( this.currentToken && this.currentToken.type !== types.TEXT ) { if ( this.currentToken.read( char ) ) { return true; } } // either we're not building a tag, or the character was rejected // send to buffer. if accepted, we don't need to do anything else if ( this.buffer.read( char ) ) { return true; } // can we convert the buffer to a mustache or triple? mustacheToken = this.buffer.convert(); if ( mustacheToken ) { // if we were building a token, seal it if ( this.currentToken ) { this.currentToken.seal(); } // start building the new mustache instead this.currentToken = this.tokens[ this.tokens.length ] = mustacheToken; return true; } // could not convert to a mustache. can we append to current token? bufferValue = this.buffer.release(); if ( this.currentToken ) { while ( bufferValue.length ) { while ( bufferValue.length && this.currentToken.read( bufferValue.charAt( 0 ) ) ) { bufferValue = bufferValue.substring( 1 ); } // still got something left over? create a new token if ( bufferValue.length ) { if ( bufferValue.charAt( 0 ) === '<' ) { this.currentToken = new TagToken(); this.currentToken.read( '<' ); } else { this.currentToken = new TextToken(); this.currentToken.read( bufferValue.charAt( 0 ) ); } this.tokens[ this.tokens.length ] = this.currentToken; bufferValue = bufferValue.substring( 1 ); } } return true; } // otherwise we need to create a new token if ( char === '<' ) { this.currentToken = new TagToken(); } else { this.currentToken = new TextToken(); } this.currentToken.read( char ); this.tokens[ this.tokens.length ] = this.currentToken; return true; }, end: function () { if ( !this.buffer.isEmpty() ) { this.tokens[ this.tokens.length ] = this.buffer.convert(); } } }; TokenStream.fromString = function ( string ) { var stream, i, len; stream = new TokenStream(); i = 0; len = string.length; while ( i < len ) { stream.read( string.charAt( i ) ); i += 1; } stream.end(); return stream; }; // MustacheBuffer intercepts characters in the token stream and determines // whether they could be a mustache/triple delimiter MustacheBuffer = function () { this.value = ''; }; MustacheBuffer.prototype = { read: function ( char ) { var continueBuffering; this.value += char; // if this could turn out to be a tag, a mustache or a triple return true continueBuffering = ( this.isPartialMatchOf( R.delimiters[0] ) || this.isPartialMatchOf( R.tripleDelimiters[0] ) ); return continueBuffering; }, convert: function () { var value, mustache, triple, token, getTriple, getMustache; // store mustache and triple opening delimiters mustache = R.delimiters[0]; triple = R.tripleDelimiters[0]; value = this.value; getTriple = function () { if ( value.indexOf( triple ) === 0 ) { return new TripleToken(); } }; getMustache = function () { if ( value.indexOf( mustache ) === 0 ) { return new MustacheToken(); } }; // out of mustache and triple opening delimiters, try to match longest first. // if they're the same length then only one will match anyway, unless some // plonker has set them to the same thing (which should probably throw an error) if ( triple.length > mustache.length ) { token = getTriple() || getMustache(); } else { token = getMustache() || getTriple(); } if ( token ) { while ( this.value.length ) { token.read( this.value.charAt( 0 ) ); this.value = this.value.substring( 1 ); } return token; } return false; }, release: function () { var value = this.value; this.value = ''; return value; }, isEmpty: function () { return !this.value.length; }, isPartialMatchOf: function ( str ) { // if str begins with this.value, the index will be 0 return str.indexOf( this.value ) === 0; } }; TextToken = function