toolkit/devtools/output-parser.js

Wed, 31 Dec 2014 13:27:57 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Wed, 31 Dec 2014 13:27:57 +0100
branch
TOR_BUG_3246
changeset 6
8bccb770b82d
permissions
-rw-r--r--

Ignore runtime configuration files generated during quality assurance.

michael@0 1 /* This Source Code Form is subject to the terms of the Mozilla Public
michael@0 2 * License, v. 2.0. If a copy of the MPL was not distributed with this
michael@0 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
michael@0 4
michael@0 5 "use strict";
michael@0 6
michael@0 7 const {Cc, Ci, Cu} = require("chrome");
michael@0 8 const {colorUtils} = require("devtools/css-color");
michael@0 9 const {Services} = Cu.import("resource://gre/modules/Services.jsm", {});
michael@0 10
michael@0 11 const HTML_NS = "http://www.w3.org/1999/xhtml";
michael@0 12
michael@0 13 const MAX_ITERATIONS = 100;
michael@0 14 const REGEX_QUOTES = /^".*?"|^".*|^'.*?'|^'.*/;
michael@0 15 const REGEX_WHITESPACE = /^\s+/;
michael@0 16 const REGEX_FIRST_WORD_OR_CHAR = /^\w+|^./;
michael@0 17 const REGEX_CSS_PROPERTY_VALUE = /(^[^;]+)/;
michael@0 18
michael@0 19 /**
michael@0 20 * This regex matches:
michael@0 21 * - #F00
michael@0 22 * - #FF0000
michael@0 23 * - hsl()
michael@0 24 * - hsla()
michael@0 25 * - rgb()
michael@0 26 * - rgba()
michael@0 27 * - color names
michael@0 28 */
michael@0 29 const REGEX_ALL_COLORS = /^#[0-9a-fA-F]{3}\b|^#[0-9a-fA-F]{6}\b|^hsl\(.*?\)|^hsla\(.*?\)|^rgba?\(.*?\)|^[a-zA-Z-]+/;
michael@0 30
michael@0 31 loader.lazyGetter(this, "DOMUtils", function () {
michael@0 32 return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils);
michael@0 33 });
michael@0 34
michael@0 35 /**
michael@0 36 * This regular expression catches all css property names with their trailing
michael@0 37 * spaces and semicolon. This is used to ensure a value is valid for a property
michael@0 38 * name within style="" attributes.
michael@0 39 */
michael@0 40 loader.lazyGetter(this, "REGEX_ALL_CSS_PROPERTIES", function () {
michael@0 41 let names = DOMUtils.getCSSPropertyNames();
michael@0 42 let pattern = "^(";
michael@0 43
michael@0 44 for (let i = 0; i < names.length; i++) {
michael@0 45 if (i > 0) {
michael@0 46 pattern += "|";
michael@0 47 }
michael@0 48 pattern += names[i];
michael@0 49 }
michael@0 50 pattern += ")\\s*:\\s*";
michael@0 51
michael@0 52 return new RegExp(pattern);
michael@0 53 });
michael@0 54
michael@0 55 /**
michael@0 56 * This module is used to process text for output by developer tools. This means
michael@0 57 * linking JS files with the debugger, CSS files with the style editor, JS
michael@0 58 * functions with the debugger, placing color swatches next to colors and
michael@0 59 * adding doorhanger previews where possible (images, angles, lengths,
michael@0 60 * border radius, cubic-bezier etc.).
michael@0 61 *
michael@0 62 * Usage:
michael@0 63 * const {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
michael@0 64 * const {OutputParser} = devtools.require("devtools/output-parser");
michael@0 65 *
michael@0 66 * let parser = new OutputParser();
michael@0 67 *
michael@0 68 * parser.parseCssProperty("color", "red"); // Returns document fragment.
michael@0 69 * parser.parseHTMLAttribute("color:red; font-size: 12px;"); // Returns document
michael@0 70 * // fragment.
michael@0 71 */
michael@0 72 function OutputParser() {
michael@0 73 this.parsed = [];
michael@0 74 }
michael@0 75
michael@0 76 exports.OutputParser = OutputParser;
michael@0 77
michael@0 78 OutputParser.prototype = {
michael@0 79 /**
michael@0 80 * Parse a CSS property value given a property name.
michael@0 81 *
michael@0 82 * @param {String} name
michael@0 83 * CSS Property Name
michael@0 84 * @param {String} value
michael@0 85 * CSS Property value
michael@0 86 * @param {Object} [options]
michael@0 87 * Options object. For valid options and default values see
michael@0 88 * _mergeOptions().
michael@0 89 * @return {DocumentFragment}
michael@0 90 * A document fragment containing color swatches etc.
michael@0 91 */
michael@0 92 parseCssProperty: function(name, value, options={}) {
michael@0 93 options = this._mergeOptions(options);
michael@0 94
michael@0 95 if (this._cssPropertySupportsValue(name, value)) {
michael@0 96 return this._parse(value, options);
michael@0 97 }
michael@0 98 this._appendTextNode(value);
michael@0 99
michael@0 100 return this._toDOM();
michael@0 101 },
michael@0 102
michael@0 103 /**
michael@0 104 * Parse a string.
michael@0 105 *
michael@0 106 * @param {String} value
michael@0 107 * Text to parse.
michael@0 108 * @param {Object} [options]
michael@0 109 * Options object. For valid options and default values see
michael@0 110 * _mergeOptions().
michael@0 111 * @return {DocumentFragment}
michael@0 112 * A document fragment. Colors will not be parsed.
michael@0 113 */
michael@0 114 parseHTMLAttribute: function(value, options={}) {
michael@0 115 options.isHTMLAttribute = true;
michael@0 116 options = this._mergeOptions(options);
michael@0 117
michael@0 118 return this._parse(value, options);
michael@0 119 },
michael@0 120
michael@0 121 /**
michael@0 122 * Matches the beginning of the provided string to a css background-image url
michael@0 123 * and return both the whole url(...) match and the url itself.
michael@0 124 * This isn't handled via a regular expression to make sure we can match urls
michael@0 125 * that contain parenthesis easily
michael@0 126 */
michael@0 127 _matchBackgroundUrl: function(text) {
michael@0 128 let startToken = "url(";
michael@0 129 if (text.indexOf(startToken) !== 0) {
michael@0 130 return null;
michael@0 131 }
michael@0 132
michael@0 133 let uri = text.substring(startToken.length).trim();
michael@0 134 let quote = uri.substring(0, 1);
michael@0 135 if (quote === "'" || quote === '"') {
michael@0 136 uri = uri.substring(1, uri.search(new RegExp(quote + "\\s*\\)")));
michael@0 137 } else {
michael@0 138 uri = uri.substring(0, uri.indexOf(")"));
michael@0 139 quote = "";
michael@0 140 }
michael@0 141 let end = startToken + quote + uri;
michael@0 142 text = text.substring(0, text.indexOf(")", end.length) + 1);
michael@0 143
michael@0 144 return [text, uri.trim()];
michael@0 145 },
michael@0 146
michael@0 147 /**
michael@0 148 * Parse a string.
michael@0 149 *
michael@0 150 * @param {String} text
michael@0 151 * Text to parse.
michael@0 152 * @param {Object} [options]
michael@0 153 * Options object. For valid options and default values see
michael@0 154 * _mergeOptions().
michael@0 155 * @return {DocumentFragment}
michael@0 156 * A document fragment.
michael@0 157 */
michael@0 158 _parse: function(text, options={}) {
michael@0 159 text = text.trim();
michael@0 160 this.parsed.length = 0;
michael@0 161 let i = 0;
michael@0 162
michael@0 163 while (text.length > 0) {
michael@0 164 let matched = null;
michael@0 165
michael@0 166 // Prevent this loop from slowing down the browser with too
michael@0 167 // many nodes being appended into output. In practice it is very unlikely
michael@0 168 // that this will ever happen.
michael@0 169 i++;
michael@0 170 if (i > MAX_ITERATIONS) {
michael@0 171 this._appendTextNode(text);
michael@0 172 text = "";
michael@0 173 break;
michael@0 174 }
michael@0 175
michael@0 176 matched = text.match(REGEX_QUOTES);
michael@0 177 if (matched) {
michael@0 178 let match = matched[0];
michael@0 179
michael@0 180 text = this._trimMatchFromStart(text, match);
michael@0 181 this._appendTextNode(match);
michael@0 182 continue;
michael@0 183 }
michael@0 184
michael@0 185 matched = text.match(REGEX_WHITESPACE);
michael@0 186 if (matched) {
michael@0 187 let match = matched[0];
michael@0 188
michael@0 189 text = this._trimMatchFromStart(text, match);
michael@0 190 this._appendTextNode(match);
michael@0 191 continue;
michael@0 192 }
michael@0 193
michael@0 194 matched = this._matchBackgroundUrl(text);
michael@0 195 if (matched) {
michael@0 196 let [match, url] = matched;
michael@0 197 text = this._trimMatchFromStart(text, match);
michael@0 198
michael@0 199 this._appendURL(match, url, options);
michael@0 200 continue;
michael@0 201 }
michael@0 202
michael@0 203 matched = text.match(REGEX_ALL_CSS_PROPERTIES);
michael@0 204 if (matched) {
michael@0 205 let [match] = matched;
michael@0 206
michael@0 207 text = this._trimMatchFromStart(text, match);
michael@0 208 this._appendTextNode(match);
michael@0 209
michael@0 210 if (options.isHTMLAttribute) {
michael@0 211 [text] = this._appendColorOnMatch(text, options);
michael@0 212 }
michael@0 213 continue;
michael@0 214 }
michael@0 215
michael@0 216 if (!options.isHTMLAttribute) {
michael@0 217 let dirty;
michael@0 218
michael@0 219 [text, dirty] = this._appendColorOnMatch(text, options);
michael@0 220
michael@0 221 if (dirty) {
michael@0 222 continue;
michael@0 223 }
michael@0 224 }
michael@0 225
michael@0 226 // This test must always be last as it indicates use of an unknown
michael@0 227 // character that needs to be removed to prevent infinite loops.
michael@0 228 matched = text.match(REGEX_FIRST_WORD_OR_CHAR);
michael@0 229 if (matched) {
michael@0 230 let match = matched[0];
michael@0 231
michael@0 232 text = this._trimMatchFromStart(text, match);
michael@0 233 this._appendTextNode(match);
michael@0 234 }
michael@0 235 }
michael@0 236
michael@0 237 return this._toDOM();
michael@0 238 },
michael@0 239
michael@0 240 /**
michael@0 241 * Convenience function to make the parser a little more readable.
michael@0 242 *
michael@0 243 * @param {String} text
michael@0 244 * Main text
michael@0 245 * @param {String} match
michael@0 246 * Text to remove from the beginning
michael@0 247 *
michael@0 248 * @return {String}
michael@0 249 * The string passed as 'text' with 'match' stripped from the start.
michael@0 250 */
michael@0 251 _trimMatchFromStart: function(text, match) {
michael@0 252 return text.substr(match.length);
michael@0 253 },
michael@0 254
michael@0 255 /**
michael@0 256 * Check if there is a color match and append it if it is valid.
michael@0 257 *
michael@0 258 * @param {String} text
michael@0 259 * Main text
michael@0 260 * @param {Object} options
michael@0 261 * Options object. For valid options and default values see
michael@0 262 * _mergeOptions().
michael@0 263 *
michael@0 264 * @return {Array}
michael@0 265 * An array containing the remaining text and a dirty flag. This array
michael@0 266 * is designed for deconstruction using [text, dirty].
michael@0 267 */
michael@0 268 _appendColorOnMatch: function(text, options) {
michael@0 269 let dirty;
michael@0 270 let matched = text.match(REGEX_ALL_COLORS);
michael@0 271
michael@0 272 if (matched) {
michael@0 273 let match = matched[0];
michael@0 274 if (this._appendColor(match, options)) {
michael@0 275 text = this._trimMatchFromStart(text, match);
michael@0 276 dirty = true;
michael@0 277 }
michael@0 278 } else {
michael@0 279 dirty = false;
michael@0 280 }
michael@0 281
michael@0 282 return [text, dirty];
michael@0 283 },
michael@0 284
michael@0 285 /**
michael@0 286 * Check if a CSS property supports a specific value.
michael@0 287 *
michael@0 288 * @param {String} name
michael@0 289 * CSS Property name to check
michael@0 290 * @param {String} value
michael@0 291 * CSS Property value to check
michael@0 292 */
michael@0 293 _cssPropertySupportsValue: function(name, value) {
michael@0 294 let win = Services.appShell.hiddenDOMWindow;
michael@0 295 let doc = win.document;
michael@0 296
michael@0 297 name = name.replace(/-\w{1}/g, function(match) {
michael@0 298 return match.charAt(1).toUpperCase();
michael@0 299 });
michael@0 300
michael@0 301 value = value.replace("!important", "");
michael@0 302
michael@0 303 let div = doc.createElement("div");
michael@0 304 div.style[name] = value;
michael@0 305
michael@0 306 return !!div.style[name];
michael@0 307 },
michael@0 308
michael@0 309 /**
michael@0 310 * Tests if a given colorObject output by CssColor is valid for parsing.
michael@0 311 * Valid means it's really a color, not any of the CssColor SPECIAL_VALUES
michael@0 312 * except transparent
michael@0 313 */
michael@0 314 _isValidColor: function(colorObj) {
michael@0 315 return colorObj.valid &&
michael@0 316 (!colorObj.specialValue || colorObj.specialValue === "transparent");
michael@0 317 },
michael@0 318
michael@0 319 /**
michael@0 320 * Append a color to the output.
michael@0 321 *
michael@0 322 * @param {String} color
michael@0 323 * Color to append
michael@0 324 * @param {Object} [options]
michael@0 325 * Options object. For valid options and default values see
michael@0 326 * _mergeOptions().
michael@0 327 * @returns {Boolean}
michael@0 328 * true if the color passed in was valid, false otherwise. Special
michael@0 329 * values such as transparent also return false.
michael@0 330 */
michael@0 331 _appendColor: function(color, options={}) {
michael@0 332 let colorObj = new colorUtils.CssColor(color);
michael@0 333
michael@0 334 if (this._isValidColor(colorObj)) {
michael@0 335 if (options.colorSwatchClass) {
michael@0 336 this._appendNode("span", {
michael@0 337 class: options.colorSwatchClass,
michael@0 338 style: "background-color:" + color
michael@0 339 });
michael@0 340 }
michael@0 341 if (options.defaultColorType) {
michael@0 342 color = colorObj.toString();
michael@0 343 }
michael@0 344 this._appendNode("span", {
michael@0 345 class: options.colorClass
michael@0 346 }, color);
michael@0 347 return true;
michael@0 348 }
michael@0 349 return false;
michael@0 350 },
michael@0 351
michael@0 352 /**
michael@0 353 * Append a URL to the output.
michael@0 354 *
michael@0 355 * @param {String} match
michael@0 356 * Complete match that may include "url(xxx)"
michael@0 357 * @param {String} url
michael@0 358 * Actual URL
michael@0 359 * @param {Object} [options]
michael@0 360 * Options object. For valid options and default values see
michael@0 361 * _mergeOptions().
michael@0 362 */
michael@0 363 _appendURL: function(match, url, options={}) {
michael@0 364 if (options.urlClass) {
michael@0 365 // We use single quotes as this works inside html attributes (e.g. the
michael@0 366 // markup view).
michael@0 367 this._appendTextNode("url('");
michael@0 368
michael@0 369 let href = url;
michael@0 370 if (options.baseURI) {
michael@0 371 href = options.baseURI.resolve(url);
michael@0 372 }
michael@0 373
michael@0 374 this._appendNode("a", {
michael@0 375 target: "_blank",
michael@0 376 class: options.urlClass,
michael@0 377 href: href
michael@0 378 }, url);
michael@0 379
michael@0 380 this._appendTextNode("')");
michael@0 381 } else {
michael@0 382 this._appendTextNode("url('" + url + "')");
michael@0 383 }
michael@0 384 },
michael@0 385
michael@0 386 /**
michael@0 387 * Append a node to the output.
michael@0 388 *
michael@0 389 * @param {String} tagName
michael@0 390 * Tag type e.g. "div"
michael@0 391 * @param {Object} attributes
michael@0 392 * e.g. {class: "someClass", style: "cursor:pointer"};
michael@0 393 * @param {String} [value]
michael@0 394 * If a value is included it will be appended as a text node inside
michael@0 395 * the tag. This is useful e.g. for span tags.
michael@0 396 */
michael@0 397 _appendNode: function(tagName, attributes, value="") {
michael@0 398 let win = Services.appShell.hiddenDOMWindow;
michael@0 399 let doc = win.document;
michael@0 400 let node = doc.createElementNS(HTML_NS, tagName);
michael@0 401 let attrs = Object.getOwnPropertyNames(attributes);
michael@0 402
michael@0 403 for (let attr of attrs) {
michael@0 404 if (attributes[attr]) {
michael@0 405 node.setAttribute(attr, attributes[attr]);
michael@0 406 }
michael@0 407 }
michael@0 408
michael@0 409 if (value) {
michael@0 410 let textNode = doc.createTextNode(value);
michael@0 411 node.appendChild(textNode);
michael@0 412 }
michael@0 413
michael@0 414 this.parsed.push(node);
michael@0 415 },
michael@0 416
michael@0 417 /**
michael@0 418 * Append a text node to the output. If the previously output item was a text
michael@0 419 * node then we append the text to that node.
michael@0 420 *
michael@0 421 * @param {String} text
michael@0 422 * Text to append
michael@0 423 */
michael@0 424 _appendTextNode: function(text) {
michael@0 425 let lastItem = this.parsed[this.parsed.length - 1];
michael@0 426 if (typeof lastItem === "string") {
michael@0 427 this.parsed[this.parsed.length - 1] = lastItem + text;
michael@0 428 } else {
michael@0 429 this.parsed.push(text);
michael@0 430 }
michael@0 431 },
michael@0 432
michael@0 433 /**
michael@0 434 * Take all output and append it into a single DocumentFragment.
michael@0 435 *
michael@0 436 * @return {DocumentFragment}
michael@0 437 * Document Fragment
michael@0 438 */
michael@0 439 _toDOM: function() {
michael@0 440 let win = Services.appShell.hiddenDOMWindow;
michael@0 441 let doc = win.document;
michael@0 442 let frag = doc.createDocumentFragment();
michael@0 443
michael@0 444 for (let item of this.parsed) {
michael@0 445 if (typeof item === "string") {
michael@0 446 frag.appendChild(doc.createTextNode(item));
michael@0 447 } else {
michael@0 448 frag.appendChild(item);
michael@0 449 }
michael@0 450 }
michael@0 451
michael@0 452 this.parsed.length = 0;
michael@0 453 return frag;
michael@0 454 },
michael@0 455
michael@0 456 /**
michael@0 457 * Merges options objects. Default values are set here.
michael@0 458 *
michael@0 459 * @param {Object} overrides
michael@0 460 * The option values to override e.g. _mergeOptions({colors: false})
michael@0 461 *
michael@0 462 * Valid options are:
michael@0 463 * - defaultColorType: true // Convert colors to the default type
michael@0 464 * // selected in the options panel.
michael@0 465 * - colorSwatchClass: "" // The class to use for color swatches.
michael@0 466 * - colorClass: "" // The class to use for the color value
michael@0 467 * // that follows the swatch.
michael@0 468 * - isHTMLAttribute: false // This property indicates whether we
michael@0 469 * // are parsing an HTML attribute value.
michael@0 470 * // When the value is passed in from an
michael@0 471 * // HTML attribute we need to check that
michael@0 472 * // any CSS property values are supported
michael@0 473 * // by the property name before
michael@0 474 * // processing the property value.
michael@0 475 * - urlClass: "" // The class to be used for url() links.
michael@0 476 * - baseURI: "" // A string or nsIURI used to resolve
michael@0 477 * // relative links.
michael@0 478 * @return {Object}
michael@0 479 * Overridden options object
michael@0 480 */
michael@0 481 _mergeOptions: function(overrides) {
michael@0 482 let defaults = {
michael@0 483 defaultColorType: true,
michael@0 484 colorSwatchClass: "",
michael@0 485 colorClass: "",
michael@0 486 isHTMLAttribute: false,
michael@0 487 urlClass: "",
michael@0 488 baseURI: ""
michael@0 489 };
michael@0 490
michael@0 491 if (typeof overrides.baseURI === "string") {
michael@0 492 overrides.baseURI = Services.io.newURI(overrides.baseURI, null, null);
michael@0 493 }
michael@0 494
michael@0 495 for (let item in overrides) {
michael@0 496 defaults[item] = overrides[item];
michael@0 497 }
michael@0 498 return defaults;
michael@0 499 }
michael@0 500 };

mercurial