gecko/toolkit/devtools/output-parser.js

501 lines
14 KiB
JavaScript
Raw Normal View History

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const {Cc, Ci, Cu} = require("chrome");
const {colorUtils} = require("devtools/css-color");
const {Services} = Cu.import("resource://gre/modules/Services.jsm", {});
const HTML_NS = "http://www.w3.org/1999/xhtml";
const MAX_ITERATIONS = 100;
const REGEX_QUOTES = /^".*?"|^".*|^'.*?'|^'.*/;
const REGEX_WHITESPACE = /^\s+/;
const REGEX_FIRST_WORD_OR_CHAR = /^\w+|^./;
const REGEX_CSS_PROPERTY_VALUE = /(^[^;]+)/;
/**
* This regex matches:
* - #F00
* - #FF0000
* - hsl()
* - hsla()
* - rgb()
* - rgba()
* - color names
*/
const REGEX_ALL_COLORS = /^#[0-9a-fA-F]{3}\b|^#[0-9a-fA-F]{6}\b|^hsl\(.*?\)|^hsla\(.*?\)|^rgba?\(.*?\)|^[a-zA-Z-]+/;
loader.lazyGetter(this, "DOMUtils", function () {
return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils);
});
/**
* This regular expression catches all css property names with their trailing
* spaces and semicolon. This is used to ensure a value is valid for a property
* name within style="" attributes.
*/
loader.lazyGetter(this, "REGEX_ALL_CSS_PROPERTIES", function () {
let names = DOMUtils.getCSSPropertyNames();
let pattern = "^(";
for (let i = 0; i < names.length; i++) {
if (i > 0) {
pattern += "|";
}
pattern += names[i];
}
pattern += ")\\s*:\\s*";
return new RegExp(pattern);
});
/**
* This module is used to process text for output by developer tools. This means
* linking JS files with the debugger, CSS files with the style editor, JS
* functions with the debugger, placing color swatches next to colors and
* adding doorhanger previews where possible (images, angles, lengths,
* border radius, cubic-bezier etc.).
*
* Usage:
* const {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
* const {OutputParser} = devtools.require("devtools/output-parser");
*
* let parser = new OutputParser();
*
* parser.parseCssProperty("color", "red"); // Returns document fragment.
* parser.parseHTMLAttribute("color:red; font-size: 12px;"); // Returns document
* // fragment.
*/
function OutputParser() {
this.parsed = [];
}
exports.OutputParser = OutputParser;
OutputParser.prototype = {
/**
* Parse a CSS property value given a property name.
*
* @param {String} name
* CSS Property Name
* @param {String} value
* CSS Property value
* @param {Object} [options]
* Options object. For valid options and default values see
* _mergeOptions().
* @return {DocumentFragment}
* A document fragment containing color swatches etc.
*/
parseCssProperty: function(name, value, options={}) {
options = this._mergeOptions(options);
if (this._cssPropertySupportsValue(name, value)) {
return this._parse(value, options);
}
this._appendTextNode(value);
return this._toDOM();
},
/**
* Parse a string.
*
* @param {String} value
* Text to parse.
* @param {Object} [options]
* Options object. For valid options and default values see
* _mergeOptions().
* @return {DocumentFragment}
* A document fragment. Colors will not be parsed.
*/
parseHTMLAttribute: function(value, options={}) {
options.isHTMLAttribute = true;
options = this._mergeOptions(options);
return this._parse(value, options);
},
/**
* Matches the beginning of the provided string to a css background-image url
* and return both the whole url(...) match and the url itself.
* This isn't handled via a regular expression to make sure we can match urls
* that contain parenthesis easily
*/
_matchBackgroundUrl: function(text) {
let startToken = "url(";
if (text.indexOf(startToken) !== 0) {
return null;
}
let uri = text.substring(startToken.length).trim();
let quote = uri.substring(0, 1);
if (quote === "'" || quote === '"') {
uri = uri.substring(1, uri.search(new RegExp(quote + "\\s*\\)")));
} else {
uri = uri.substring(0, uri.indexOf(")"));
quote = "";
}
let end = startToken + quote + uri;
text = text.substring(0, text.indexOf(")", end.length) + 1);
return [text, uri.trim()];
},
/**
* Parse a string.
*
* @param {String} text
* Text to parse.
* @param {Object} [options]
* Options object. For valid options and default values see
* _mergeOptions().
* @return {DocumentFragment}
* A document fragment.
*/
_parse: function(text, options={}) {
text = text.trim();
this.parsed.length = 0;
let i = 0;
while (text.length > 0) {
let matched = null;
// Prevent this loop from slowing down the browser with too
// many nodes being appended into output. In practice it is very unlikely
// that this will ever happen.
i++;
if (i > MAX_ITERATIONS) {
this._appendTextNode(text);
text = "";
break;
}
matched = text.match(REGEX_QUOTES);
if (matched) {
let match = matched[0];
text = this._trimMatchFromStart(text, match);
this._appendTextNode(match);
continue;
}
matched = text.match(REGEX_WHITESPACE);
if (matched) {
let match = matched[0];
text = this._trimMatchFromStart(text, match);
this._appendTextNode(match);
continue;
}
matched = this._matchBackgroundUrl(text);
if (matched) {
let [match, url] = matched;
text = this._trimMatchFromStart(text, match);
this._appendURL(match, url, options);
continue;
}
matched = text.match(REGEX_ALL_CSS_PROPERTIES);
if (matched) {
let [match] = matched;
text = this._trimMatchFromStart(text, match);
this._appendTextNode(match);
if (options.isHTMLAttribute) {
[text] = this._appendColorOnMatch(text, options);
}
continue;
}
if (!options.isHTMLAttribute) {
let dirty;
[text, dirty] = this._appendColorOnMatch(text, options);
if (dirty) {
continue;
}
}
// This test must always be last as it indicates use of an unknown
// character that needs to be removed to prevent infinite loops.
matched = text.match(REGEX_FIRST_WORD_OR_CHAR);
if (matched) {
let match = matched[0];
text = this._trimMatchFromStart(text, match);
this._appendTextNode(match);
}
}
return this._toDOM();
},
/**
* Convenience function to make the parser a little more readable.
*
* @param {String} text
* Main text
* @param {String} match
* Text to remove from the beginning
*
* @return {String}
* The string passed as 'text' with 'match' stripped from the start.
*/
_trimMatchFromStart: function(text, match) {
return text.substr(match.length);
},
/**
* Check if there is a color match and append it if it is valid.
*
* @param {String} text
* Main text
* @param {Object} options
* Options object. For valid options and default values see
* _mergeOptions().
*
* @return {Array}
* An array containing the remaining text and a dirty flag. This array
* is designed for deconstruction using [text, dirty].
*/
_appendColorOnMatch: function(text, options) {
let dirty;
let matched = text.match(REGEX_ALL_COLORS);
if (matched) {
let match = matched[0];
if (this._appendColor(match, options)) {
text = this._trimMatchFromStart(text, match);
dirty = true;
}
} else {
dirty = false;
}
return [text, dirty];
},
/**
* Check if a CSS property supports a specific value.
*
* @param {String} name
* CSS Property name to check
* @param {String} value
* CSS Property value to check
*/
_cssPropertySupportsValue: function(name, value) {
let win = Services.appShell.hiddenDOMWindow;
let doc = win.document;
name = name.replace(/-\w{1}/g, function(match) {
return match.charAt(1).toUpperCase();
});
value = value.replace("!important", "");
let div = doc.createElement("div");
div.style[name] = value;
return !!div.style[name];
},
/**
* Tests if a given colorObject output by CssColor is valid for parsing.
* Valid means it's really a color, not any of the CssColor SPECIAL_VALUES
* except transparent
*/
_isValidColor: function(colorObj) {
return colorObj.valid &&
(!colorObj.specialValue || colorObj.specialValue === "transparent");
},
/**
* Append a color to the output.
*
* @param {String} color
* Color to append
* @param {Object} [options]
* Options object. For valid options and default values see
* _mergeOptions().
* @returns {Boolean}
* true if the color passed in was valid, false otherwise. Special
* values such as transparent also return false.
*/
_appendColor: function(color, options={}) {
let colorObj = new colorUtils.CssColor(color);
if (this._isValidColor(colorObj)) {
if (options.colorSwatchClass) {
this._appendNode("span", {
class: options.colorSwatchClass,
style: "background-color:" + color
});
}
if (options.defaultColorType) {
color = colorObj.toString();
}
this._appendNode("span", {
class: options.colorClass
}, color);
return true;
}
return false;
},
/**
* Append a URL to the output.
*
* @param {String} match
* Complete match that may include "url(xxx)"
* @param {String} url
* Actual URL
* @param {Object} [options]
* Options object. For valid options and default values see
* _mergeOptions().
*/
_appendURL: function(match, url, options={}) {
if (options.urlClass) {
// We use single quotes as this works inside html attributes (e.g. the
// markup view).
this._appendTextNode("url('");
let href = url;
if (options.baseURI) {
href = options.baseURI.resolve(url);
}
this._appendNode("a", {
target: "_blank",
class: options.urlClass,
href: href
}, url);
this._appendTextNode("')");
} else {
this._appendTextNode("url('" + url + "')");
}
},
/**
* Append a node to the output.
*
* @param {String} tagName
* Tag type e.g. "div"
* @param {Object} attributes
* e.g. {class: "someClass", style: "cursor:pointer"};
* @param {String} [value]
* If a value is included it will be appended as a text node inside
* the tag. This is useful e.g. for span tags.
*/
_appendNode: function(tagName, attributes, value="") {
let win = Services.appShell.hiddenDOMWindow;
let doc = win.document;
let node = doc.createElementNS(HTML_NS, tagName);
let attrs = Object.getOwnPropertyNames(attributes);
for (let attr of attrs) {
if (attributes[attr]) {
node.setAttribute(attr, attributes[attr]);
}
}
if (value) {
let textNode = doc.createTextNode(value);
node.appendChild(textNode);
}
this.parsed.push(node);
},
/**
* Append a text node to the output. If the previously output item was a text
* node then we append the text to that node.
*
* @param {String} text
* Text to append
*/
_appendTextNode: function(text) {
let lastItem = this.parsed[this.parsed.length - 1];
if (typeof lastItem === "string") {
this.parsed[this.parsed.length - 1] = lastItem + text;
} else {
this.parsed.push(text);
}
},
/**
* Take all output and append it into a single DocumentFragment.
*
* @return {DocumentFragment}
* Document Fragment
*/
_toDOM: function() {
let win = Services.appShell.hiddenDOMWindow;
let doc = win.document;
let frag = doc.createDocumentFragment();
for (let item of this.parsed) {
if (typeof item === "string") {
frag.appendChild(doc.createTextNode(item));
} else {
frag.appendChild(item);
}
}
this.parsed.length = 0;
return frag;
},
/**
* Merges options objects. Default values are set here.
*
* @param {Object} overrides
* The option values to override e.g. _mergeOptions({colors: false})
*
* Valid options are:
* - defaultColorType: true // Convert colors to the default type
* // selected in the options panel.
* - colorSwatchClass: "" // The class to use for color swatches.
* - colorClass: "" // The class to use for the color value
* // that follows the swatch.
* - isHTMLAttribute: false // This property indicates whether we
* // are parsing an HTML attribute value.
* // When the value is passed in from an
* // HTML attribute we need to check that
* // any CSS property values are supported
* // by the property name before
* // processing the property value.
* - urlClass: "" // The class to be used for url() links.
* - baseURI: "" // A string or nsIURI used to resolve
* // relative links.
* @return {Object}
* Overridden options object
*/
_mergeOptions: function(overrides) {
let defaults = {
defaultColorType: true,
colorSwatchClass: "",
colorClass: "",
isHTMLAttribute: false,
urlClass: "",
baseURI: ""
};
if (typeof overrides.baseURI === "string") {
overrides.baseURI = Services.io.newURI(overrides.baseURI, null, null);
}
for (let item in overrides) {
defaults[item] = overrides[item];
}
return defaults;
}
};