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