toolkit/devtools/output-parser.js

changeset 0
6474c204b198
     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 +};

mercurial