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.

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

mercurial