Source: tss_parser.js

/**
 * The contents of this file are subject to the Mozilla Public License
 * Version 1.1 (the "License"); you may not use this file except in
 * compliance with the License. You may obtain a copy of the License at
 * https://www.mozilla.org/MPL/
 * 
 * Software distributed under the License is distributed on an "AS IS"
 * basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the
 * License for the specific language governing rights and limitations
 * under the License.
 * 
 * The Original Code is a JavaScript implementation of Time Style Sheets (TSS).
 * 
 * The Initial Developer of the Original Code is IBM Research (Brazil).
 * Portions created by the Initial Developer are Copyright (C) 2015 
 * the Initial Developer. All Rights Reserved.
 * 
 * Contributor(s): Rodrigo Laiola GuimarĂ£es <http://www.rodrigolaiola.com>
 */

/**
 * @overview implements a Time Style Sheets (TSS) parser.
 * @requires cssParser
 * @license MPL v1.1
 * @version 0.1
 */

/** 
 * this module implements a Time Style Sheets (TSS) parser. Once the browser finishes loading the page completely, the TSS parser examines the associated styles and then triggers the <i>ontssparserready</i> event.
 * @module
 */

/* hide all time tags upfront */
{
	var time_style = document.createElement('style');
	time_style.setAttribute('id', 'tss-styles');
	time_style.type = 'text/css';
	time_style.innerHTML = 'body{} time {display: none;}'; /* body {} is a hack. Somehow jQuery Mobile breaks TSS */
	document.head.appendChild(time_style);
}
 
/**
 * @namespace window
 */
 
/**
 * Given an element or its selector, returns the computed TSS properties and CSS specificity.
 * @function
 * @param {String|Object} elem - Element selector (String) or its DOM reference (Object).
 * @return {Object[]} <pre>{
	specificity: Number,			// CSS specificity
	timingContainer: String,		// "par" or "seq"
	timingDelay: Number,
	timingDuration: Number | String,	// e.g., 10 or "infinite"
	timingIterationCount: Number,		// e.g., 1 or "infinite"
	timingPlayState: String,		// "running" or "paused"
	timingClipBegin: Number,
	timingClipEnd: Number,
	timingVolume: Number,
	timingSyncMaster: String,		// sync master selector
	timingCurrentTime: Number
}</pre>
 */
window.tss = {};

/* DOM tree is ready! */
document.onreadystatechange = function() {
	/* hide body to avoid flickering behaviour. We will show it ontssparserready event */
	document.body.style.visibility = 'hidden';

    if (document.readyState === 'complete') {
    
    	/* start timers to track how long the parser execution takes */
		try {
			/* http://www.html5rocks.com/en/tutorials/webperformance/usertiming/ */
			window.performance.mark('tss_parser');
		} catch (e) {
			console.time('tss_parser');
		}

		/* for now, let's pause audio and video elements with the autoplay attribute */
		{
			var allElements = document.getElementsByTagName('*');
			for (var i = 0, n = allElements.length; i < n; i++) {
				if (allElements[i].hasAttribute('autoplay') && 
					(allElements[i].nodeName != 'VIDEO' || 
					allElements[i].nodeName != 'AUDIO')) {
					allElements[i].pause();
				}
			}
		}
		
    	window.tss = new function() {
			/*************************
			 * private variables
			 *************************/
			 
			var that = this;
			
			/* keep track of time style sheets selectors, elements and pseudo-classes */
			var tss_selectors = {};
			var tss_elements = {};
			
			/**/
			var TSS_TIME_TAGS = true;
			var TSS_STYLE_TAGS = true;
			
			/*************************
			 * private functions
			 *************************/
			 
			/* merge contents of two objects together into the first object */
			function extend(a, b, force) {
				for (var key in b)
					if (!a[key] || force) a[key] = b[key];
				return a;
			}
			
			/* capitalize a letter following a dash and remove the dash 
			   source: http://stackoverflow.com/questions/6009386/capitalizing-the-letter-following-a-dash-and-removing-the-dash
			*/
			function camelCase (string) {
				return string.replace( /-([a-z])/ig, function(all, letter) {
					return letter.toUpperCase();
				});
			}
			
			/* verify if a given property is valid within tss workspace */
			function isValidTSSProperty(property) {
				/* standard timing properties */
				if (property == 'timing-container' || 
					property == 'timing-delay' ||
					property == 'timing-duration' ||
					property == 'timing-iteration-count' ||
					property == 'timing-play-state' ||
					/* timing properties related to continuous media */
					property == 'timing-clip-begin' ||
					property == 'timing-clip-end' ||
					property == 'timing-volume' ||
					property == 'timing-sync-master' ||
					/* non-standard timing properties */
					property == 'timing-current-time') {
					return true;
				}
				else return false;
			}
			
			/* compute the valid value of a property */
			function getValidTSSValue(property, value) {
				switch(property) {
					case 'timing-container':
						if (value == 'par' || value == 'seq') return value;
						else return 'par';
						break;
					case 'timing-delay':
						if (typeof parseFloat(value) == 'number' && parseFloat(value) >= 0.0) return parseFloat(value);
						else return 0;
						break;
					case 'timing-duration':
						if (typeof parseFloat(value) == 'number' && parseFloat(value) >= 0.0) return parseFloat(value);
						else if (value == 'infinite') return value;
						else if (value == 'implicit') return undefined;
						else return 0.0;
						break;
					case 'timing-iteration-count':
						if (typeof parseInt(value) == 'number' && parseInt(value) >= 0.0) return parseInt(value);
						else if (value == 'infinite') return value;
						else return 1;
						break;
					case 'timing-play-state':
						if (value == 'paused' || value == 'running') return value;
						else return 'running';
						break;
					/* timing properties related to continuous media */
					case 'timing-clip-begin':
						if (typeof parseFloat(value) == 'number' && parseFloat(value) >= 0.0) return parseFloat(value);
						else return 0;
						break;
					case 'timing-clip-end':
						if (typeof parseFloat(value) == 'number' && parseFloat(value) >= 0.0) return parseFloat(value);
						else return undefined;
						break;
					case 'timing-volume':
						if (typeof parseFloat(value) == 'number' && parseFloat(value) >= 0.0 && parseFloat(value) <= 1.0)
							return parseFloat(value);
						else return 1.0;
						break;
					case 'timing-sync-master':
						return value;
						break;
					/* non-standard timing properties */
					case 'timing-current-time':
						return value;
						break;
					default:
						break; 
				}
				
				return null;
			}
			
			/* use jscssp to parse time style sheets as if it were css. if elem is defined 
			   return its time properties */
			function parse(tss_str, elem) {
				/* remove comments from string */
				var comments = /(\/\*([\s\S]*?)\*\/)|(\/\/(.*)$)|(\<\!\-\-([\s\S]*?)\-\-\>)/gm;
				tss_str = tss_str.replace(comments,'');
	
				var parser = new CSSParser();
				var sheet = parser.parse(tss_str, false, true);
				/* console.log(sheet.cssText()); */
	
				if (sheet) {
					/* parse time styles and return resulting style */
					if (elem) {
						var properties = {};
						/* iterate through properties */
						for (var j=0; j<sheet.cssRules[0].declarations.length; j++) {
							/* console.log('\t' + sheet.cssRules[0].declarations[j].property + 
								' = ' + 
										sheet.cssRules[0].declarations[j].valueText); */
							
							if (isValidTSSProperty(sheet.cssRules[0].declarations[j].property)) {
								var pName = camelCase(sheet.cssRules[0].declarations[j].property);
								properties[pName] = 
									getValidTSSValue(sheet.cssRules[0].declarations[j].property, sheet.cssRules[0].declarations[j].valueText);
							}
						}
			
						/* console.log(properties); */
						/* workaround: think over a cleaner way */
						var tsshash = elem.getAttribute('tss-hash');
						if (!tsshash) {
							tsshash = Math.floor(Math.random()*90000) + 10000;
							elem.setAttribute('tss-hash', tsshash);
						}
						tss_elements[tsshash] = extend(properties, tss_elements[tsshash]);
						return tss_elements[tsshash];
					}
					/* parse and store time styles */
					else {
						for (var i=0; i<sheet.cssRules.length; i++) {
							if (!sheet.cssRules[i].mSelectorText || 
								sheet.cssRules[i].mSelectorText.trim() == '')
								continue;
							
							/* console.log(sheet.cssRules[i].mSelectorText); */
							
							/* split into multiple selectors, if necessary */
							var selectors = sheet.cssRules[i].mSelectorText.split(',');
							
							for (var j=0; j<selectors.length; j++) {
								/* is it a pseudo class? If so, we need to process it */
								if (selectors[j].trim().indexOf(':') != -1) {
									var s = selectors[j].substring(0, selectors[j].indexOf(':'));
									var pseudoc = selectors[j].substring(selectors[j].indexOf(':')+1, selectors[j].length);
									/*console.log(s + ' ' + pseudo);*/
									
									/* process properties inside the 'for' works clone function for
								       all selectors */
									var properties = {};
									/* iterate through properties */
									for (var k=0; k<sheet.cssRules[i].declarations.length; k++) {
										/* console.log('\t' + 
											sheet.cssRules[i].declarations[k].property + ' = ' + 
													sheet.cssRules[i].declarations[k].valueText); */
									
										var pName = sheet.cssRules[i].declarations[k].property;
										properties[pName] = sheet.cssRules[i].declarations[k].valueText;
									}
									
									/*console.log(s + '[tss-state=' + pseudoc + ']');
									console.log(properties);*/
									
									var styleTag = document.getElementById('tss-styles');
									var sheet_aux = styleTag.sheet ? styleTag.sheet : styleTag.styleSheet;
									
									function toString(array) {
										var t = '';
										
										if (!array || array.length == 0) return t;
										
										for (var key in array)
											t = t + key + ': ' + array[key] + '; ';
										
										return t;
									}
									/* http://davidwalsh.name/add-rules-stylesheets */
									/* as a workaround, we decided to use a class[attribute] approach */
									try {
										sheet_aux.insertRule(s + '[tss-state=' + pseudoc + '] {' + toString(properties) + '}', 1);
									} catch (e) {}
								}
								/* it is an ordinary selector */
								else {
									/* process properties inside the 'for' works clone function for
								   all selectors */
									var properties = {};
									/* iterate through properties */
									for (var k=0; k<sheet.cssRules[i].declarations.length; k++) {
										/* console.log('\t' + 
											sheet.cssRules[i].declarations[k].property + ' = ' + 
													sheet.cssRules[i].declarations[k].valueText); */
									
										if (isValidTSSProperty(sheet.cssRules[i].declarations[k].property)) {
											var pName = camelCase(sheet.cssRules[i].declarations[k].property);
											properties[pName] = getValidTSSValue(sheet.cssRules[i].declarations[k].property, sheet.cssRules[i].declarations[k].valueText);
										}
									}
					
									/* does this selector already exist? */
									tss_selectors[selectors[j].trim()] ?
										/* extend existing properties */
										tss_selectors[selectors[j].trim()] = 
											extend(properties, tss_selectors[selectors[j].trim()]) :
										/* otherwise, add these properties as current */
										tss_selectors[selectors[j].trim()] = properties;
								}
							}
						}
			
						return;
					}
				}
				else
					console.log('error parsing time style sheets');
			}
			
			/* calculate the tss specificity based on css specification */
			function getSpecificity(selector) {
				/* has it been already calculated? If so, just return it */
				if (tss_selectors[selector] && tss_selectors[selector].specificity)
					return tss_selectors[selector].specificity;
	
				/* split a given selector into smaller pieces */
				function parse_selector(sel) {
					/* split each selector group by combinators ' ', '+', '~', '>'
					   :not() is a special case, do not include it as a pseudo-class */
					sel = sel.replace(/\#+/g, ' #');
					sel = sel.replace(/\.+/g, ' .');
					sel = sel.replace(/\:+/g, ' :');
					sel = sel.replace(/\[+/g, ' [');
		
					/* '*' has no specificity */
					sel = sel.replace(/\*+/g, ' ');
		
					/* for the selector div > p:not(.foo) ~ span.bar,
					   sample output is ['div', 'p', '.foo', 'span', '.bar'] */
					return sel.split(/\ +|\++|\~+|\>+/).filter(function(n){return n});
				}
	
				/* split selector */
				var ss = parse_selector(selector);
	
				/* calculate specificity based on tss rules */
				var a = b = c = 0;
				for (var i=0; i<ss.length; i++) {
					/* look for ids -> # */
					a = a + (ss[i].match(/\#/) ? ss[i].match(/\#/).length : 0);
					/* look for classes -> ., pseudo classes -> :, or attributes -> [] */
					b = b + (ss[i].match(/\./) ? ss[i].match(/\./).length : 0) +
							(ss[i].match(/:[:]+/) ? ss[i].match(/:[:]+/).length : 0) +
							(ss[i].match(/\[.+\]$/) ? ss[i].match(/\[.+\]$/).length : 0);
				}
				/* look for elements */
				var c = ss.length - (a + b);
	
				return parseInt('' + a + b + c);
			}
			
			/* sort list of time sheets' selectors based on tss specificity (same as css)
			 source: stackoverflow.com/questions/5158631/sorting-a-set-of-css-selectors-on-the-basis-of-specificity */
			function sortBySpecificity(selectors) {
				if (!selectors) return {};
				
				var simple_selectors = [];
				
				for (var sel in selectors) {
					simple_selectors.push({key: sel, specificity: getSpecificity(sel)});
				}
				
				/* console.log(simple_selectors); */
				/* sort time styles based on specificity (highest -> lowest). */
				return simple_selectors.sort(
					function(a,b) { 
						return b.specificity - a.specificity;
					}
				);
			}
			
			/* compute parsed styles to DOM elements */
			function compute(targetElem, styleAdded) {
				/* sort selectors based on specificity */
				var selectors = sortBySpecificity(tss_selectors);
				
				/* for now, we compute styles for all elements. We need to double check that in the future */
				if (styleAdded) tss_elements = [];
				
				/*for (var i=0; i<selectors.length; i++) {
					console.log(selectors[i]);
				}*/
				
				/* first, compute time styles based on parsed selectors */
				for (var i=0; i<selectors.length; i++) {
					/* register specificity of this selector */
					tss_selectors[selectors[i].key].specificity = selectors[i].specificity;
					
					/* source: http://stackoverflow.com/questions/886863/best-way-to-find-dom-elements-with-css-selectors */
					var elems = document.querySelectorAll(selectors[i].key);
					
					/* console.log(selectors[i].key + ' ' + selectors[i].specificity + ' ' + elems.length); */
					
					for (var j=0; j<elems.length; j++) {
						var elem = elems[j];
						var timeStyles = tss_selectors[selectors[i].key];
						
						/* if targetElem has been defined we will try to parse it only */
						if (!elem || (targetElem && elem != targetElem)) continue;
						
						/* has the element been already processed? */
						/* workaround: think over a cleaner way */
						var tsshash = elem.getAttribute('tss-hash');
						if (!tsshash) {
							tsshash = Math.floor(Math.random()*90000) + 10000;
							elem.setAttribute('tss-hash', tsshash);
						}
						
						if (tss_elements[tsshash] != null) {
							/* console.log(elem.getAttribute('style')); */
							timeStyles = extend(tss_elements[tsshash], timeStyles);
						}
						/* does it have a style attribute? */
						else if (elem.getAttribute('style') != null) {
							/* console.log(elem.getAttribute('style')); */
							timeStyles = extend(parse('{' + elem.getAttribute('style') + '}', elem), 
											timeStyles);
						}
						
						/* set as parsed */
						elem.setAttribute('tss-parsed', true);
						/* and store current time properties */
						tss_elements[tsshash] = timeStyles;
					}
				}
				
				/* continue only if we are not focusing on a targetElem */
				if (!targetElem) {
					/* we also need to iterate through inline styles that have not been caught by selectors */
					var timeStyles = document.body.getElementsByTagName('*');
					for (var i=0; i<timeStyles.length; i++) {
						var elem = timeStyles[i];
						if (!elem.getAttribute('tss-parsed')) {
							/* workaround: think over a cleaner way */
							var tsshash = elem.getAttribute('tss-hash');
							if (!tsshash) {
								tsshash = Math.floor(Math.random()*90000) + 10000;
								elem.setAttribute('tss-hash', tsshash);
							}
							
							if (elem.getAttribute('style') != null) {
								/* console.log(elem.getAttribute('style')); */
								tss_elements[tsshash] = parse('{' + elem.getAttribute('style') + '}', elem);
							}
							
							/* does it have an autoplay attribute? */
							if (elem.hasAttribute('autoplay')) {
								/* console.log(elem.getAttribute('style')); */
								tss_elements[tsshash] = extend({'timingPlayState' : 'running'}, tss_elements[tsshash]);
								elem.removeAttribute('autoplay');
							}
						}
					}
					
					/* let's show body tag (but when new styles are inserted) */
					if (!styleAdded) document.body.style.visibility = '';
					
					/* stop timers that were previously started by calling console.time() and User Timing API */
					try {
						window.performance.measure('tss_parser');
						console.log('calculating tss_parser.js execution time: ' +
							(window.performance.getEntriesByType('measure')[0].duration
							- window.performance.getEntriesByType('mark')[0].startTime).toPrecision(5) + "ms");
					} catch (e) {
						console.timeEnd('calculating tss_parser.js execution time');
					}
					
					/**
					 * triggered when the TSS parser is done examining the associated styles.
					 *
					 * @event ontssparserready
					 * @type {Object}
					 * @property {Boolean} detail - True, if the event is associated with a new style added after the page is loaded for the first time. False, otherwise.
					 */
					var tssEvent;
					try {
						/* new browsers */
						tssEvent = new CustomEvent('ontssparserready', { 'detail': styleAdded });
					} catch(e) {
						/* old browsers: deprecated */
						tssEvent = document.createEvent('Event');
						tssEvent.initEvent('ontssparserready', true, true);
						tssEvent.detail = styleAdded;
					}
					/* fire ontssparserready. If a new style has been added we add this info to the event */
					document.dispatchEvent(tssEvent);
				}
			}
			
			/* initialize time style sheets parser */
			function init() {
				/* note: tss embedded in the html always come after external time sheets (imports)
						 regardless of the order in the html (same behavior as css).
				*/
				function parseEmbeddedTimeSheets() {
					if (TSS_STYLE_TAGS) {
						var timeTags = document.getElementsByTagName('style');
						for (var i=0; i<timeTags.length; i++) {
							parse(timeTags[i].innerHTML, null);
						}
					}
					
					if (TSS_TIME_TAGS) {
						var timeTags = document.getElementsByTagName('time');
						for (var i=0; i<timeTags.length; i++) {
							parse(timeTags[i].innerHTML, null);
						}
					}
					
					/* once we parsed everything it is time to compute all time styles */
					compute();
				}
				
				/* calculate the number of time sheets imports */
				var timeLinksCounter = 0;
				var timeLinks = document.getElementsByTagName('link');
				for (var i=0; i<timeLinks.length; i++) {
					var link = timeLinks[i];
					if ( (TSS_TIME_TAGS && (link.rel == 'timesheet' || link.type == 'text/tss')) ||
						 (TSS_STYLE_TAGS && (link.rel == 'stylesheet' || link.type == 'text/css')))
						timeLinksCounter = timeLinksCounter + 1; /* count the number of time sheets links */
				}
				
				/* parse embedded time tags if there is no import links */
				if (timeLinksCounter == 0) {
					parseEmbeddedTimeSheets();
				}
				/* otherwise, we will handle embedded styles only after all imports have been processed */
				else {
					for (var i=0; i<timeLinks.length; i++) {
						var link = timeLinks[i];
						if ( TSS_TIME_TAGS && (link.rel == 'timesheet' || link.type == 'text/tss') ||
							 TSS_STYLE_TAGS && (link.rel == 'stylesheet' || link.type == 'text/css') ) {
				
							/* initialize the Ajax request */
							function importTimeSheet(l) {
								var xhr = new XMLHttpRequest();
								xhr.open('get', l.href);
					
								/* track the state changes of the request */
								xhr.onreadystatechange = function() {
									/* ready state 4 means the request is done */
									if (xhr.readyState === 4) {
										/* 200 is a successful return */
										if(xhr.status === 200) {
											/* console.log(xhr.responseText); */
											parse(xhr.responseText, null);
										} else {
											/* An error occurred during the request */
											console.log('error importing time style sheets: ' + xhr.status);
										}
							
										timeLinksCounter = timeLinksCounter - 1;
										/* is this the last import? */
										if (timeLinksCounter == 0) {
											parseEmbeddedTimeSheets();
										}
									}
								}
					
								/* Send the request */
								xhr.send(null);
							}
							
							importTimeSheet(link);
						}
					}
				}
			}
			
			/* this allows us to capture and compute dynamic modications on the document accordingly */
			function initMutationObserver() {
				/* select the target node */
				var target = document.querySelector('body');
				
				function processNewNode(child) {
					if (child.nodeType === 1 && 
						child.nodeName != 'SCRIPT' &&
						child.nodeName != 'LINK' &&
						child.nodeName != 'STYLE' &&
						child.nodeName != 'SOURCE' &&
						child.nodeName != 'TIME') {
						/* are there styles defined in this element? If so, they will not be detected automatically */
						var styleMatches = child.querySelectorAll('style');
						for (var i=0; i<styleMatches.length; i++) {
							parse(styleMatches[i].innerHTML, null);
						}
						
						if (styleMatches.length > 0)
							/* double check that in the future */ 
							compute(child, styleMatches.length > 0);
						else compute(child);
					}
					else if (child.nodeName == 'LINK') {
						/* TODO */
					} 
					else if ((child.nodeName == 'STYLE' && TSS_STYLE_TAGS) ||
							(child.nodeName == 'TIME' && TSS_TIME_TAGS)) {
						/* parse embedded style */
						parse(child.innerHTML, null);
						
						compute(null, true);
					}
				}
				
				/* new browsers */
				try {
					/* create an observer instance
					   source: https://developer.mozilla.org/en/docs/Web/API/MutationObserver 
					*/
					var observer = new MutationObserver(function(mutations) {
						mutations.forEach(function(mutation) {
							switch (mutation.type) {
								case 'childList':
									/* process new elements */
									for (var i=0; i<mutation.addedNodes.length; i++) {
										var child = mutation.addedNodes[i];
										
										processNewNode(child);
									}
									
									/* process removed elements */
									/*for (var i=0; i<mutation.removedNodes.length; i++) {
										var child = mutation.removedNodes[i];
									}*/
									break;
								case 'attributes':
									if (mutation.attributeName == 'style') {
										/* we need to check whether a TSS property has been updated */
									}
									break;
								default:
									break;
							}
						});
					});
					
					/* configuration of the observer */
					var config = { attributes: true, childList: true, characterData: true, subtree: true };
					
					/* pass in the target node, as well as the observer options */
					observer.observe(target, config);
					
					/* later, we can stop observing document mutations */
					/*observer.disconnect();*/
				} catch(e) {
					/* old browsers: deprecated */
					target.addEventListener('DOMNodeInserted', function (ev) {
						if (ev.target != null) processNewNode(ev.target);
					}, false);
				}
			}
			
			/*************************
			 * public functions
			 *************************/
			
			that.lookup = function(obj) {
				/* is it a selector? */
				if (typeof obj == 'string') {
					var r = tss_selectors[obj];
					if (r) {
						/* if specificity is not defined yet, set it and return */
						if (!r.specificity) r.specificity = getSpecificity(obj)
						return r;
					}
					else return {specificity: getSpecificity(obj)};
				}
				/* otherwise it is an element */
				else {
					/* worst case, we should compute the styles for the element in its first time */
					if (!obj.getAttribute('tss-hash')) compute(obj);
					
					return tss_elements[obj.getAttribute('tss-hash')];
				}
			}
			
			/* workaround: we need to set this global variable before init */
			window.tss = that.lookup;
			
			/* call initialization function */
			init();
			
			/* initialize mutation observer */
			initMutationObserver();
			
			return that.lookup;
		}
    }
};