/** * domJSON.js: A simple framework for converting DOM nodes to special JSON objects, and vice versa * * @fileOverview * @author Alex Zaslavsky * @version 0.1.2 * @license The MIT License: Copyright (c) 2013 Alex Zaslavsky */ //Load the library ;(function(root, factory) { /* istanbul ignore next */ if (typeof define === 'function' && define.amd) { //AMD define(function(){ return factory(root); }); } else if (typeof exports !== 'undefined') { //CommonJS/node.js var domJSON = factory(root); if (typeof module !== 'undefined' && module.exports) { module.exports = domJSON; } exports = domJSON; } else { //Browser global window.pywebview.domJSON = factory(root); } })(this, function(win){ "use strict"; /** * domJSON is a global variable to store two methods: `.toJSON()` to convert a DOM Node into a JSON object, and `.toDOM()` to turn that JSON object back into a DOM Node * @namespace domJSON * @global */ var domJSON = {}; /** * An object specifying a list of fields and how to filter it, or an array with the first value being an optional boolean to convey the same information * @typedef {Object|Array} FilterList * @property {boolean} [exclude=false] If this is set to `true`, the `filter` property will specify which fields to exclude from the result (boolean difference), not which ones to include (boolean intersection) * @property {string[]} values An array of strings which specify the fields to include/exclude from some broader list */ /** * Default metadata for a JSON object * @private * @ignore */ var metadata = { href: win.location.href || null, userAgent: window.navigator && window.navigator.userAgent ? window.navigator.userAgent : null, version: '0.1.2' }; /** * Default options for creating the JSON object * @private * @ignore */ var defaultsForToJSON = { absolutePaths: ['action', 'data', 'href', 'src'], //absStylePaths: ['attr', 'background', 'background-image', 'border-image', 'border-image-source', 'content', 'list-style-image', 'mask-image'], //http://stackoverflow.com/questions/27790925/what-are-all-the-css3-properties-that-accept-urls-or-uris attributes: true, computedStyle: false, cull: true, deep: true, domProperties: true, filter: false, htmlOnly: false, metadata: true, //parse: false, serialProperties: false, stringify: false, allowDangerousElements: false }; /** * Default options for creating a DOM node from a previously generated domJSON object * @private * @ignore */ var defaultsForToDOM = { noMeta: false, allowDangerousElements: false }; /** * A list of disallowed HTMLElement tags - there is no flexibility here, these cannot be processed by domJSON for security reasons! * @private * @ignore */ var banned = [ 'link', 'script' ]; //Consider (maybe) adding the following tags: iframe, html, audio, video, object /** * A list of node properties that must be copied if they exist; there is no user option that will remove these * @private * @ignore */ var required = [ 'nodeType', 'nodeValue', 'tagName' ]; /** * A list of node properties to specifically avoid simply copying; there is no user option that will allow these to be copied directly * @private * @ignore */ var ignored = [ 'attributes', 'childNodes', 'children', 'classList', 'dataset', 'style' ]; /** * A list of serialized read-only nodes to ignore; these can overwritten if the user specifies the "filter" option * @private * @ignore */ var serials = [ 'innerHTML', 'innerText', 'outerHTML', 'outerText', 'prefix', 'text', 'textContent', 'wholeText' ]; /** * Utility function to extend an object - useful for synchronizing user-submitted options with default values; same API as underscore extend * @param {Object} [target] The object that will be extended * @param {...Object} [added] Additional objects that will extend the target * @private * @ignore */ var extend = function(target) { if (!arguments.length) { return arguments[0] || {}; } //Overwrite matching properties on the target from the added object for (var p in arguments[1]) { target[p] = arguments[1][p]; } //If we have more arguments, run the function recursively if (arguments.length > 2) { var moreArgs = [target].concat(Array.prototype.slice.call(arguments, 2)); return extend.apply( null, moreArgs); } else { return target; } }; /** * Get all of the unique values (in the order they first appeared) from one or more arrays * @param {...Array} constituent An array to combine into a larger array of unique values * @private * @ignore */ var unique = function() { if (!arguments.length) { return []; } var all = Array.prototype.concat.apply([], arguments); for (var a = 0; a < all.length; a++) { if (all.indexOf(all[a]) < a) { all.splice(a, 1); a--; } } return all; }; /** * Make a shallow copy of an object or array * @param {Object|string[]} item The object/array that will be copied * @private * @ignore */ var copy = function(item) { if (item instanceof Array) { return item.slice(); } else { var output = {}; for (var i in item) { output[i] = item[i]; } return output; } }; /** * Do a boolean intersection between an array/object and a filter array * @param {Object|string[]} item The object/array that will be intersected with the filter * @param {boolean|string[]} filter Specifies which properties to select from the "item" (or element to keep, if "item is an array") * @private * @ignore */ var boolInter = function(item, filter) { var output; if (item instanceof Array) { output = unique(item.filter(function(val) { return filter.indexOf(val) > -1; })); } else { output = {}; for (var f in filter) { if (item.hasOwnProperty(filter[f])) { output[filter[f]] = item[filter[f]]; } } } return output; }; /** * Do a boolean difference between an array/object and a filter array * @param {Object|string[]} item The object/array that will be differentiated with the filter * @param {boolean|string[]} filter Specifies which properties to exclude from the "item" (or element to remove, if "item is an array") * @private * @ignore */ var boolDiff = function(item, filter) { var output; if (item instanceof Array) { output = unique(item.filter(function(val) { return filter.indexOf(val) === -1; })); } else { output = {}; for (var i in item) { output[i] = item[i]; } for (var f in filter) { if (output.hasOwnProperty(filter[f])) { delete output[filter[f]]; } } } return output; }; /** * Determine whether we want to do a boolean intersection or difference * @param {Object|string[]} item The object/array that will be differentiated with the filter * @param {boolean|Array} filter Specifies which a filter behavior; if it is an array, the first value can be a boolean, indicating whether the filter array is intended for differentiation (true) or intersection (false) * @private * @ignore */ var boolFilter = function(item, filter) { //A "false" filter means we return an empty copy of item if (filter === false){ return (item instanceof Array) ? [] : {}; } if (filter instanceof Array && filter.length) { if (typeof filter[0] === 'boolean') { if (filter.length == 1 && typeof(filter[0]) === 'boolean') { //There is a filter array, but its only a single boolean if (filter[0] === true) { return copy(item); } else { return (item instanceof Array) ? [] : {}; } } else { //The filter operation has been set explicitly; true = difference if (filter[0] === true) { return boolDiff(item, filter.slice(1)); } else { return boolInter(item, filter.slice(1)); } } } else { //There is no explicit operation on the filter, meaning it defaults to an intersection return boolInter(item, filter); } } else { return copy(item); } }; /** * Ensure that a FilterList type input is converted into its shorthand array form * @param {boolean|FilterList} filterList The FilterList, or boolean, that will converted into the shorthand form * @private * @ignore */ var toShorthand = function(filterList) { var outputArray; if (typeof filterList === 'boolean') { return filterList; } else if (typeof filterList === 'object' && filterList !== null) { if (filterList instanceof Array) { return filterList.filter(function(v, i){ return typeof v === 'string' || (i === 0 && v === true) ? true : false; }); } else { if (!(filterList.values instanceof Array)) { return false; } outputArray = filterList.values.filter(function(v){ return typeof v === 'string' ? true : false; }); if (!outputArray.length) { return false; } if (filterList.exclude) { outputArray.unshift(filterList.exclude); } return outputArray; } } else if (filterList) { return true; } return false; }; /** * Check if the supplied string value is a relative path, and convert it to an absolute one if necessary; the segment processing paths leading with "../" was inspired by: http://stackoverflow.com/a/14780463/2230156 * @param {string} value The value that might be a relative path, and would thus need conversion * @param {Object} origin The origin URL from which to which non-absolute paths are relative * @private * @ignore */ var toAbsolute = function(value, origin) { var protocol, stack, parts; //Sometimes, we get lucky and the DOM Node we're working on already has the absolute URL as a DOM property, so we can just use that /*if (node[name]){ //We can just grab the compiled URL directly from the DOM element - easy peasy var sub = node[name].indexOf(value); if (sub !== -1) { return node[name]; } }*/ //Check to make sure we don't already have an absolute path, or even a dataURI if ( value.match(/(?:^data\:|^[\w\-\+\.]*?\:\/\/|^\/\/)/i) ){ return value; } //If we are using the root URL, start from there if ( value.charAt(0) === '/' ){ return origin + value.substr(1); } //Uh-oh, the relative path is leading with a single or double dot ("./" or "../"); things get a bit harder... protocol = origin.indexOf('://') > -1 ? origin.substring(0, origin.indexOf('://') + 3) : ''; stack = (protocol.length ? origin.substring(protocol.length) : origin).split('/'); parts = value.split('/'); //The value after the last slash is ALWAYS considered a filename, not a directory, so always have trailing slashes on paths ending at directories! stack.pop(); //Cycle through the relative path, changing the stack as we go for (var i=0; i 1) { stack.pop(); } } else { stack.push(parts[i]); } } return (protocol + stack.join('/')); }; /** * Create a copy of a node's properties, ignoring nasty things like event handles and functions * @param {Node} node The DOM Node whose properties will be copied * @param {Object} [opts] The options object passed down from the .toJSON() method; includes all options, even those not relevant to this function * @private * @ignore */ var copyJSON = function(node, opts) { var copy = {}; //Copy all of the node's properties for (var n in node){ //Make sure this property can be accessed try { //accessing `selectionDirection`, `selectionStart`, or `selectionEnd` throws in WebKit-based browsers node[n]; } catch (e) { continue; } //Make sure this is an own property, and isn't a live javascript function for security reasons if (typeof node[n] !== 'undefined' && typeof node[n] !== 'function' && n.charAt(0).toLowerCase() === n.charAt(0)) { //Only allowed objects are arrays if ( typeof node[n] !== 'object' || node[n] instanceof Array ) { //If we are eliminating empty fields, make sure this value is not NULL or UNDEFINED if (opts.cull) { if (node[n] || node[n] === 0 || node[n] === false) { copy[n] = node[n]; } } else { copy[n] = node[n]; } } } } copy = boolFilter(copy, opts.domProperties); return copy; }; /** * Convert the attributes property of a DOM Node to a JSON ready object * @param {Node} node The DOM Node whose attributes will be copied * @param {Object} [opts] The options object passed down from the .toJSON() method; includes all options, even those not relevant to this function * @private * @ignore */ var attrJSON = function(node, opts) { var attributes = {}; var attr = node.attributes; var length = attr.length; var absAttr; for (var i = 0; i < length; i++) { attributes[attr[i].name] = attr[i].value; } attributes = opts.attributes ? boolFilter(attributes, opts.attributes) : null; //Add the attributes object, converting any specified absolute paths along the way absAttr = boolFilter(attributes, opts.absolutePaths); for (var i in absAttr) { attributes[i] = toAbsolute(absAttr[i], opts.absoluteBase); } return attributes; }; /** * Grab a DOM Node's computed style * @param {Node} node The DOM Node whose computed style will be calculated * @param {Object} [opts] The options object passed down from the .toJSON() method; includes all options, even those not relevant to this function * @private * @ignore */ var styleJSON = function(node, opts) { //Grab the computed style var style, css = {}; if (opts.computedStyle && node.style instanceof CSSStyleDeclaration) { style = win.getComputedStyle(node); } else { return null; } //Get the relevant properties from the computed style for (var k in style) { if ( k !== 'cssText' && !k.match(/\d/) && typeof style[k] === 'string' && style[k].length ) { //css.push(k+ ': ' +style[k]+ ';'); css[k] = style[k]; } } //Filter the style object return (opts.computedStyle instanceof Array) ? boolFilter(css, opts.computedStyle) : css; }; /** * Convert a single DOM Node into a simple object * @param {Node} node The DOM Node that will be converted * @param {Object} [opts] The options object passed down from the .toJSON() method; includes all options, even those not relevant to this function * @private * @ignore */ var toJSON = function(node, opts, depth) { var style, kids, kidCount, thisChild, children, copy = copyJSON(node, opts); //Per default, some tags are not allowed if (node.nodeType === 1) { if (!opts.allowDangerousElements) { for (var b in banned) { if (node.tagName.toLowerCase() === banned[b]) { return null; } } } } else if (node.nodeType === 3 && !node.nodeValue.trim()) { //Ignore empty buffer text nodes return null; } //Copy all attributes and styles, if allowed if (opts.attributes && node.attributes) { copy.attributes = attrJSON(node, opts); } if (opts.computedStyle && (style = styleJSON(node, opts))) { copy.style = style; } //Should we continue iterating? if (opts.deep === true || (typeof opts.deep === 'number' && opts.deep > depth)) { //We should! children = []; kids = (opts.htmlOnly) ? node.children : node.childNodes; kidCount = kids.length; for (var c = 0; c < kidCount; c++) { thisChild = toJSON(kids[c], opts, depth + 1); if (thisChild) { children.push(thisChild); } } //Append the children in the appropriate place copy.childNodes = children; } return copy; }; /** * Take a DOM node and convert it to simple object literal (or JSON string) with no circular references and no functions or events * @param {Node} node The actual DOM Node which will be the starting point for parsing the DOM Tree * @param {Object} [opts] A list of all method options * @param {boolean} [opts.allowDangerousElements=`false`] Use `true` to parse the potentially dangerous elements `` and `