1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/toolkit/devtools/output-parser.js Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,500 @@ 1.4 +/* This Source Code Form is subject to the terms of the Mozilla Public 1.5 + * License, v. 2.0. If a copy of the MPL was not distributed with this 1.6 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 1.7 + 1.8 +"use strict"; 1.9 + 1.10 +const {Cc, Ci, Cu} = require("chrome"); 1.11 +const {colorUtils} = require("devtools/css-color"); 1.12 +const {Services} = Cu.import("resource://gre/modules/Services.jsm", {}); 1.13 + 1.14 +const HTML_NS = "http://www.w3.org/1999/xhtml"; 1.15 + 1.16 +const MAX_ITERATIONS = 100; 1.17 +const REGEX_QUOTES = /^".*?"|^".*|^'.*?'|^'.*/; 1.18 +const REGEX_WHITESPACE = /^\s+/; 1.19 +const REGEX_FIRST_WORD_OR_CHAR = /^\w+|^./; 1.20 +const REGEX_CSS_PROPERTY_VALUE = /(^[^;]+)/; 1.21 + 1.22 +/** 1.23 + * This regex matches: 1.24 + * - #F00 1.25 + * - #FF0000 1.26 + * - hsl() 1.27 + * - hsla() 1.28 + * - rgb() 1.29 + * - rgba() 1.30 + * - color names 1.31 + */ 1.32 +const REGEX_ALL_COLORS = /^#[0-9a-fA-F]{3}\b|^#[0-9a-fA-F]{6}\b|^hsl\(.*?\)|^hsla\(.*?\)|^rgba?\(.*?\)|^[a-zA-Z-]+/; 1.33 + 1.34 +loader.lazyGetter(this, "DOMUtils", function () { 1.35 + return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils); 1.36 +}); 1.37 + 1.38 +/** 1.39 + * This regular expression catches all css property names with their trailing 1.40 + * spaces and semicolon. This is used to ensure a value is valid for a property 1.41 + * name within style="" attributes. 1.42 + */ 1.43 +loader.lazyGetter(this, "REGEX_ALL_CSS_PROPERTIES", function () { 1.44 + let names = DOMUtils.getCSSPropertyNames(); 1.45 + let pattern = "^("; 1.46 + 1.47 + for (let i = 0; i < names.length; i++) { 1.48 + if (i > 0) { 1.49 + pattern += "|"; 1.50 + } 1.51 + pattern += names[i]; 1.52 + } 1.53 + pattern += ")\\s*:\\s*"; 1.54 + 1.55 + return new RegExp(pattern); 1.56 +}); 1.57 + 1.58 +/** 1.59 + * This module is used to process text for output by developer tools. This means 1.60 + * linking JS files with the debugger, CSS files with the style editor, JS 1.61 + * functions with the debugger, placing color swatches next to colors and 1.62 + * adding doorhanger previews where possible (images, angles, lengths, 1.63 + * border radius, cubic-bezier etc.). 1.64 + * 1.65 + * Usage: 1.66 + * const {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}); 1.67 + * const {OutputParser} = devtools.require("devtools/output-parser"); 1.68 + * 1.69 + * let parser = new OutputParser(); 1.70 + * 1.71 + * parser.parseCssProperty("color", "red"); // Returns document fragment. 1.72 + * parser.parseHTMLAttribute("color:red; font-size: 12px;"); // Returns document 1.73 + * // fragment. 1.74 + */ 1.75 +function OutputParser() { 1.76 + this.parsed = []; 1.77 +} 1.78 + 1.79 +exports.OutputParser = OutputParser; 1.80 + 1.81 +OutputParser.prototype = { 1.82 + /** 1.83 + * Parse a CSS property value given a property name. 1.84 + * 1.85 + * @param {String} name 1.86 + * CSS Property Name 1.87 + * @param {String} value 1.88 + * CSS Property value 1.89 + * @param {Object} [options] 1.90 + * Options object. For valid options and default values see 1.91 + * _mergeOptions(). 1.92 + * @return {DocumentFragment} 1.93 + * A document fragment containing color swatches etc. 1.94 + */ 1.95 + parseCssProperty: function(name, value, options={}) { 1.96 + options = this._mergeOptions(options); 1.97 + 1.98 + if (this._cssPropertySupportsValue(name, value)) { 1.99 + return this._parse(value, options); 1.100 + } 1.101 + this._appendTextNode(value); 1.102 + 1.103 + return this._toDOM(); 1.104 + }, 1.105 + 1.106 + /** 1.107 + * Parse a string. 1.108 + * 1.109 + * @param {String} value 1.110 + * Text to parse. 1.111 + * @param {Object} [options] 1.112 + * Options object. For valid options and default values see 1.113 + * _mergeOptions(). 1.114 + * @return {DocumentFragment} 1.115 + * A document fragment. Colors will not be parsed. 1.116 + */ 1.117 + parseHTMLAttribute: function(value, options={}) { 1.118 + options.isHTMLAttribute = true; 1.119 + options = this._mergeOptions(options); 1.120 + 1.121 + return this._parse(value, options); 1.122 + }, 1.123 + 1.124 + /** 1.125 + * Matches the beginning of the provided string to a css background-image url 1.126 + * and return both the whole url(...) match and the url itself. 1.127 + * This isn't handled via a regular expression to make sure we can match urls 1.128 + * that contain parenthesis easily 1.129 + */ 1.130 + _matchBackgroundUrl: function(text) { 1.131 + let startToken = "url("; 1.132 + if (text.indexOf(startToken) !== 0) { 1.133 + return null; 1.134 + } 1.135 + 1.136 + let uri = text.substring(startToken.length).trim(); 1.137 + let quote = uri.substring(0, 1); 1.138 + if (quote === "'" || quote === '"') { 1.139 + uri = uri.substring(1, uri.search(new RegExp(quote + "\\s*\\)"))); 1.140 + } else { 1.141 + uri = uri.substring(0, uri.indexOf(")")); 1.142 + quote = ""; 1.143 + } 1.144 + let end = startToken + quote + uri; 1.145 + text = text.substring(0, text.indexOf(")", end.length) + 1); 1.146 + 1.147 + return [text, uri.trim()]; 1.148 + }, 1.149 + 1.150 + /** 1.151 + * Parse a string. 1.152 + * 1.153 + * @param {String} text 1.154 + * Text to parse. 1.155 + * @param {Object} [options] 1.156 + * Options object. For valid options and default values see 1.157 + * _mergeOptions(). 1.158 + * @return {DocumentFragment} 1.159 + * A document fragment. 1.160 + */ 1.161 + _parse: function(text, options={}) { 1.162 + text = text.trim(); 1.163 + this.parsed.length = 0; 1.164 + let i = 0; 1.165 + 1.166 + while (text.length > 0) { 1.167 + let matched = null; 1.168 + 1.169 + // Prevent this loop from slowing down the browser with too 1.170 + // many nodes being appended into output. In practice it is very unlikely 1.171 + // that this will ever happen. 1.172 + i++; 1.173 + if (i > MAX_ITERATIONS) { 1.174 + this._appendTextNode(text); 1.175 + text = ""; 1.176 + break; 1.177 + } 1.178 + 1.179 + matched = text.match(REGEX_QUOTES); 1.180 + if (matched) { 1.181 + let match = matched[0]; 1.182 + 1.183 + text = this._trimMatchFromStart(text, match); 1.184 + this._appendTextNode(match); 1.185 + continue; 1.186 + } 1.187 + 1.188 + matched = text.match(REGEX_WHITESPACE); 1.189 + if (matched) { 1.190 + let match = matched[0]; 1.191 + 1.192 + text = this._trimMatchFromStart(text, match); 1.193 + this._appendTextNode(match); 1.194 + continue; 1.195 + } 1.196 + 1.197 + matched = this._matchBackgroundUrl(text); 1.198 + if (matched) { 1.199 + let [match, url] = matched; 1.200 + text = this._trimMatchFromStart(text, match); 1.201 + 1.202 + this._appendURL(match, url, options); 1.203 + continue; 1.204 + } 1.205 + 1.206 + matched = text.match(REGEX_ALL_CSS_PROPERTIES); 1.207 + if (matched) { 1.208 + let [match] = matched; 1.209 + 1.210 + text = this._trimMatchFromStart(text, match); 1.211 + this._appendTextNode(match); 1.212 + 1.213 + if (options.isHTMLAttribute) { 1.214 + [text] = this._appendColorOnMatch(text, options); 1.215 + } 1.216 + continue; 1.217 + } 1.218 + 1.219 + if (!options.isHTMLAttribute) { 1.220 + let dirty; 1.221 + 1.222 + [text, dirty] = this._appendColorOnMatch(text, options); 1.223 + 1.224 + if (dirty) { 1.225 + continue; 1.226 + } 1.227 + } 1.228 + 1.229 + // This test must always be last as it indicates use of an unknown 1.230 + // character that needs to be removed to prevent infinite loops. 1.231 + matched = text.match(REGEX_FIRST_WORD_OR_CHAR); 1.232 + if (matched) { 1.233 + let match = matched[0]; 1.234 + 1.235 + text = this._trimMatchFromStart(text, match); 1.236 + this._appendTextNode(match); 1.237 + } 1.238 + } 1.239 + 1.240 + return this._toDOM(); 1.241 + }, 1.242 + 1.243 + /** 1.244 + * Convenience function to make the parser a little more readable. 1.245 + * 1.246 + * @param {String} text 1.247 + * Main text 1.248 + * @param {String} match 1.249 + * Text to remove from the beginning 1.250 + * 1.251 + * @return {String} 1.252 + * The string passed as 'text' with 'match' stripped from the start. 1.253 + */ 1.254 + _trimMatchFromStart: function(text, match) { 1.255 + return text.substr(match.length); 1.256 + }, 1.257 + 1.258 + /** 1.259 + * Check if there is a color match and append it if it is valid. 1.260 + * 1.261 + * @param {String} text 1.262 + * Main text 1.263 + * @param {Object} options 1.264 + * Options object. For valid options and default values see 1.265 + * _mergeOptions(). 1.266 + * 1.267 + * @return {Array} 1.268 + * An array containing the remaining text and a dirty flag. This array 1.269 + * is designed for deconstruction using [text, dirty]. 1.270 + */ 1.271 + _appendColorOnMatch: function(text, options) { 1.272 + let dirty; 1.273 + let matched = text.match(REGEX_ALL_COLORS); 1.274 + 1.275 + if (matched) { 1.276 + let match = matched[0]; 1.277 + if (this._appendColor(match, options)) { 1.278 + text = this._trimMatchFromStart(text, match); 1.279 + dirty = true; 1.280 + } 1.281 + } else { 1.282 + dirty = false; 1.283 + } 1.284 + 1.285 + return [text, dirty]; 1.286 + }, 1.287 + 1.288 + /** 1.289 + * Check if a CSS property supports a specific value. 1.290 + * 1.291 + * @param {String} name 1.292 + * CSS Property name to check 1.293 + * @param {String} value 1.294 + * CSS Property value to check 1.295 + */ 1.296 + _cssPropertySupportsValue: function(name, value) { 1.297 + let win = Services.appShell.hiddenDOMWindow; 1.298 + let doc = win.document; 1.299 + 1.300 + name = name.replace(/-\w{1}/g, function(match) { 1.301 + return match.charAt(1).toUpperCase(); 1.302 + }); 1.303 + 1.304 + value = value.replace("!important", ""); 1.305 + 1.306 + let div = doc.createElement("div"); 1.307 + div.style[name] = value; 1.308 + 1.309 + return !!div.style[name]; 1.310 + }, 1.311 + 1.312 + /** 1.313 + * Tests if a given colorObject output by CssColor is valid for parsing. 1.314 + * Valid means it's really a color, not any of the CssColor SPECIAL_VALUES 1.315 + * except transparent 1.316 + */ 1.317 + _isValidColor: function(colorObj) { 1.318 + return colorObj.valid && 1.319 + (!colorObj.specialValue || colorObj.specialValue === "transparent"); 1.320 + }, 1.321 + 1.322 + /** 1.323 + * Append a color to the output. 1.324 + * 1.325 + * @param {String} color 1.326 + * Color to append 1.327 + * @param {Object} [options] 1.328 + * Options object. For valid options and default values see 1.329 + * _mergeOptions(). 1.330 + * @returns {Boolean} 1.331 + * true if the color passed in was valid, false otherwise. Special 1.332 + * values such as transparent also return false. 1.333 + */ 1.334 + _appendColor: function(color, options={}) { 1.335 + let colorObj = new colorUtils.CssColor(color); 1.336 + 1.337 + if (this._isValidColor(colorObj)) { 1.338 + if (options.colorSwatchClass) { 1.339 + this._appendNode("span", { 1.340 + class: options.colorSwatchClass, 1.341 + style: "background-color:" + color 1.342 + }); 1.343 + } 1.344 + if (options.defaultColorType) { 1.345 + color = colorObj.toString(); 1.346 + } 1.347 + this._appendNode("span", { 1.348 + class: options.colorClass 1.349 + }, color); 1.350 + return true; 1.351 + } 1.352 + return false; 1.353 + }, 1.354 + 1.355 + /** 1.356 + * Append a URL to the output. 1.357 + * 1.358 + * @param {String} match 1.359 + * Complete match that may include "url(xxx)" 1.360 + * @param {String} url 1.361 + * Actual URL 1.362 + * @param {Object} [options] 1.363 + * Options object. For valid options and default values see 1.364 + * _mergeOptions(). 1.365 + */ 1.366 + _appendURL: function(match, url, options={}) { 1.367 + if (options.urlClass) { 1.368 + // We use single quotes as this works inside html attributes (e.g. the 1.369 + // markup view). 1.370 + this._appendTextNode("url('"); 1.371 + 1.372 + let href = url; 1.373 + if (options.baseURI) { 1.374 + href = options.baseURI.resolve(url); 1.375 + } 1.376 + 1.377 + this._appendNode("a", { 1.378 + target: "_blank", 1.379 + class: options.urlClass, 1.380 + href: href 1.381 + }, url); 1.382 + 1.383 + this._appendTextNode("')"); 1.384 + } else { 1.385 + this._appendTextNode("url('" + url + "')"); 1.386 + } 1.387 + }, 1.388 + 1.389 + /** 1.390 + * Append a node to the output. 1.391 + * 1.392 + * @param {String} tagName 1.393 + * Tag type e.g. "div" 1.394 + * @param {Object} attributes 1.395 + * e.g. {class: "someClass", style: "cursor:pointer"}; 1.396 + * @param {String} [value] 1.397 + * If a value is included it will be appended as a text node inside 1.398 + * the tag. This is useful e.g. for span tags. 1.399 + */ 1.400 + _appendNode: function(tagName, attributes, value="") { 1.401 + let win = Services.appShell.hiddenDOMWindow; 1.402 + let doc = win.document; 1.403 + let node = doc.createElementNS(HTML_NS, tagName); 1.404 + let attrs = Object.getOwnPropertyNames(attributes); 1.405 + 1.406 + for (let attr of attrs) { 1.407 + if (attributes[attr]) { 1.408 + node.setAttribute(attr, attributes[attr]); 1.409 + } 1.410 + } 1.411 + 1.412 + if (value) { 1.413 + let textNode = doc.createTextNode(value); 1.414 + node.appendChild(textNode); 1.415 + } 1.416 + 1.417 + this.parsed.push(node); 1.418 + }, 1.419 + 1.420 + /** 1.421 + * Append a text node to the output. If the previously output item was a text 1.422 + * node then we append the text to that node. 1.423 + * 1.424 + * @param {String} text 1.425 + * Text to append 1.426 + */ 1.427 + _appendTextNode: function(text) { 1.428 + let lastItem = this.parsed[this.parsed.length - 1]; 1.429 + if (typeof lastItem === "string") { 1.430 + this.parsed[this.parsed.length - 1] = lastItem + text; 1.431 + } else { 1.432 + this.parsed.push(text); 1.433 + } 1.434 + }, 1.435 + 1.436 + /** 1.437 + * Take all output and append it into a single DocumentFragment. 1.438 + * 1.439 + * @return {DocumentFragment} 1.440 + * Document Fragment 1.441 + */ 1.442 + _toDOM: function() { 1.443 + let win = Services.appShell.hiddenDOMWindow; 1.444 + let doc = win.document; 1.445 + let frag = doc.createDocumentFragment(); 1.446 + 1.447 + for (let item of this.parsed) { 1.448 + if (typeof item === "string") { 1.449 + frag.appendChild(doc.createTextNode(item)); 1.450 + } else { 1.451 + frag.appendChild(item); 1.452 + } 1.453 + } 1.454 + 1.455 + this.parsed.length = 0; 1.456 + return frag; 1.457 + }, 1.458 + 1.459 + /** 1.460 + * Merges options objects. Default values are set here. 1.461 + * 1.462 + * @param {Object} overrides 1.463 + * The option values to override e.g. _mergeOptions({colors: false}) 1.464 + * 1.465 + * Valid options are: 1.466 + * - defaultColorType: true // Convert colors to the default type 1.467 + * // selected in the options panel. 1.468 + * - colorSwatchClass: "" // The class to use for color swatches. 1.469 + * - colorClass: "" // The class to use for the color value 1.470 + * // that follows the swatch. 1.471 + * - isHTMLAttribute: false // This property indicates whether we 1.472 + * // are parsing an HTML attribute value. 1.473 + * // When the value is passed in from an 1.474 + * // HTML attribute we need to check that 1.475 + * // any CSS property values are supported 1.476 + * // by the property name before 1.477 + * // processing the property value. 1.478 + * - urlClass: "" // The class to be used for url() links. 1.479 + * - baseURI: "" // A string or nsIURI used to resolve 1.480 + * // relative links. 1.481 + * @return {Object} 1.482 + * Overridden options object 1.483 + */ 1.484 + _mergeOptions: function(overrides) { 1.485 + let defaults = { 1.486 + defaultColorType: true, 1.487 + colorSwatchClass: "", 1.488 + colorClass: "", 1.489 + isHTMLAttribute: false, 1.490 + urlClass: "", 1.491 + baseURI: "" 1.492 + }; 1.493 + 1.494 + if (typeof overrides.baseURI === "string") { 1.495 + overrides.baseURI = Services.io.newURI(overrides.baseURI, null, null); 1.496 + } 1.497 + 1.498 + for (let item in overrides) { 1.499 + defaults[item] = overrides[item]; 1.500 + } 1.501 + return defaults; 1.502 + } 1.503 +};