diff options
Diffstat (limited to 'devtools/client/shared/output-parser.js')
-rw-r--r-- | devtools/client/shared/output-parser.js | 695 |
1 files changed, 695 insertions, 0 deletions
diff --git a/devtools/client/shared/output-parser.js b/devtools/client/shared/output-parser.js new file mode 100644 index 000000000..726c93b8b --- /dev/null +++ b/devtools/client/shared/output-parser.js @@ -0,0 +1,695 @@ +/* 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 {angleUtils} = require("devtools/client/shared/css-angle"); +const {colorUtils} = require("devtools/shared/css/color"); +const {getCSSLexer} = require("devtools/shared/css/lexer"); +const EventEmitter = require("devtools/shared/event-emitter"); +const { + ANGLE_TAKING_FUNCTIONS, + BEZIER_KEYWORDS, + COLOR_TAKING_FUNCTIONS, + CSS_TYPES +} = require("devtools/shared/css/properties-db"); +const Services = require("Services"); + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const CSS_GRID_ENABLED_PREF = "layout.css.grid.enabled"; + +/** + * 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 {OutputParser} = require("devtools/client/shared/output-parser"); + * + * let parser = new OutputParser(document, supportsType); + * + * parser.parseCssProperty("color", "red"); // Returns document fragment. + * + * @param {Document} document Used to create DOM nodes. + * @param {Function} supportsTypes - A function that returns a boolean when asked if a css + * property name supports a given css type. + * The function is executed like supportsType("color", CSS_TYPES.COLOR) + * where CSS_TYPES is defined in devtools/shared/css/properties-db.js + * @param {Function} isValidOnClient - A function that checks if a css property + * name/value combo is valid. + */ +function OutputParser(document, {supportsType, isValidOnClient}) { + this.parsed = []; + this.doc = document; + this.supportsType = supportsType; + this.isValidOnClient = isValidOnClient; + this.colorSwatches = new WeakMap(); + this.angleSwatches = new WeakMap(); + this._onColorSwatchMouseDown = this._onColorSwatchMouseDown.bind(this); + this._onAngleSwatchMouseDown = this._onAngleSwatchMouseDown.bind(this); +} + +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); + + options.expectCubicBezier = this.supportsType(name, CSS_TYPES.TIMING_FUNCTION); + options.expectDisplay = name === "display"; + options.expectFilter = name === "filter"; + options.supportsColor = this.supportsType(name, CSS_TYPES.COLOR) || + this.supportsType(name, CSS_TYPES.GRADIENT); + + // The filter property is special in that we want to show the + // swatch even if the value is invalid, because this way the user + // can easily use the editor to fix it. + if (options.expectFilter || this._cssPropertySupportsValue(name, value)) { + return this._parse(value, options); + } + this._appendTextNode(value); + + return this._toDOM(); + }, + + /** + * Given an initial FUNCTION token, read tokens from |tokenStream| + * and collect all the (non-comment) text. Return the collected + * text. The function token and the close paren are included in the + * result. + * + * @param {CSSToken} initialToken + * The FUNCTION token. + * @param {String} text + * The original CSS text. + * @param {CSSLexer} tokenStream + * The token stream from which to read. + * @return {String} + * The text of body of the function call. + */ + _collectFunctionText: function (initialToken, text, tokenStream) { + let result = text.substring(initialToken.startOffset, + initialToken.endOffset); + let depth = 1; + while (depth > 0) { + let token = tokenStream.nextToken(); + if (!token) { + break; + } + if (token.tokenType === "comment") { + continue; + } + result += text.substring(token.startOffset, token.endOffset); + if (token.tokenType === "symbol") { + if (token.text === "(") { + ++depth; + } else if (token.text === ")") { + --depth; + } + } else if (token.tokenType === "function") { + ++depth; + } + } + return result; + }, + + /** + * 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 tokenStream = getCSSLexer(text); + let parenDepth = 0; + let outerMostFunctionTakesColor = false; + + let colorOK = function () { + return options.supportsColor || + (options.expectFilter && parenDepth === 1 && + outerMostFunctionTakesColor); + }; + + let angleOK = function (angle) { + return (new angleUtils.CssAngle(angle)).valid; + }; + + while (true) { + let token = tokenStream.nextToken(); + if (!token) { + break; + } + if (token.tokenType === "comment") { + continue; + } + + switch (token.tokenType) { + case "function": { + if (COLOR_TAKING_FUNCTIONS.includes(token.text) || + ANGLE_TAKING_FUNCTIONS.includes(token.text)) { + // The function can accept a color or an angle argument, and we know + // it isn't special in some other way. So, we let it + // through to the ordinary parsing loop so that the value + // can be handled in a single place. + this._appendTextNode(text.substring(token.startOffset, + token.endOffset)); + if (parenDepth === 0) { + outerMostFunctionTakesColor = COLOR_TAKING_FUNCTIONS.includes( + token.text); + } + ++parenDepth; + } else { + let functionText = this._collectFunctionText(token, text, + tokenStream); + + if (options.expectCubicBezier && token.text === "cubic-bezier") { + this._appendCubicBezier(functionText, options); + } else if (colorOK() && colorUtils.isValidCSSColor(functionText)) { + this._appendColor(functionText, options); + } else { + this._appendTextNode(functionText); + } + } + break; + } + + case "ident": + if (options.expectCubicBezier && + BEZIER_KEYWORDS.indexOf(token.text) >= 0) { + this._appendCubicBezier(token.text, options); + } else if (Services.prefs.getBoolPref(CSS_GRID_ENABLED_PREF) && + options.expectDisplay && token.text === "grid" && + text === token.text) { + this._appendGrid(token.text, options); + } else if (colorOK() && colorUtils.isValidCSSColor(token.text)) { + this._appendColor(token.text, options); + } else if (angleOK(token.text)) { + this._appendAngle(token.text, options); + } else { + this._appendTextNode(text.substring(token.startOffset, + token.endOffset)); + } + break; + + case "id": + case "hash": { + let original = text.substring(token.startOffset, token.endOffset); + if (colorOK() && colorUtils.isValidCSSColor(original)) { + this._appendColor(original, options); + } else { + this._appendTextNode(original); + } + break; + } + case "dimension": + let value = text.substring(token.startOffset, token.endOffset); + if (angleOK(value)) { + this._appendAngle(value, options); + } else { + this._appendTextNode(value); + } + break; + case "url": + case "bad_url": + this._appendURL(text.substring(token.startOffset, token.endOffset), + token.text, options); + break; + + case "symbol": + if (token.text === "(") { + ++parenDepth; + } else if (token.text === ")") { + --parenDepth; + if (parenDepth === 0) { + outerMostFunctionTakesColor = false; + } + } + // falls through + default: + this._appendTextNode( + text.substring(token.startOffset, token.endOffset)); + break; + } + } + + let result = this._toDOM(); + + if (options.expectFilter && !options.filterSwatch) { + result = this._wrapFilter(text, options, result); + } + + return result; + }, + + /** + * Append a cubic-bezier timing function value to the output + * + * @param {String} bezier + * The cubic-bezier timing function + * @param {Object} options + * Options object. For valid options and default values see + * _mergeOptions() + */ + _appendCubicBezier: function (bezier, options) { + let container = this._createNode("span", { + "data-bezier": bezier + }); + + if (options.bezierSwatchClass) { + let swatch = this._createNode("span", { + class: options.bezierSwatchClass + }); + container.appendChild(swatch); + } + + let value = this._createNode("span", { + class: options.bezierClass + }, bezier); + + container.appendChild(value); + this.parsed.push(container); + }, + + /** + * Append a CSS Grid highlighter toggle icon next to the value in a + * 'display: grid' declaration + * + * @param {String} grid + * The grid text value to append + * @param {Object} options + * Options object. For valid options and default values see + * _mergeOptions() + */ + _appendGrid: function (grid, options) { + let container = this._createNode("span", {}); + + let toggle = this._createNode("span", { + class: options.gridClass + }); + + let value = this._createNode("span", {}); + value.textContent = grid; + + container.appendChild(toggle); + container.appendChild(value); + this.parsed.push(container); + }, + + /** + * Append a angle value to the output + * + * @param {String} angle + * angle to append + * @param {Object} options + * Options object. For valid options and default values see + * _mergeOptions() + */ + _appendAngle: function (angle, options) { + let angleObj = new angleUtils.CssAngle(angle); + let container = this._createNode("span", { + "data-angle": angle + }); + + if (options.angleSwatchClass) { + let swatch = this._createNode("span", { + class: options.angleSwatchClass + }); + this.angleSwatches.set(swatch, angleObj); + swatch.addEventListener("mousedown", this._onAngleSwatchMouseDown, false); + + // Add click listener to stop event propagation when shift key is pressed + // in order to prevent the value input to be focused. + // Bug 711942 will add a tooltip to edit angle values and we should + // be able to move this listener to Tooltip.js when it'll be implemented. + swatch.addEventListener("click", function (event) { + if (event.shiftKey) { + event.stopPropagation(); + } + }, false); + EventEmitter.decorate(swatch); + container.appendChild(swatch); + } + + let value = this._createNode("span", { + class: options.angleClass + }, angle); + + container.appendChild(value); + this.parsed.push(container); + }, + + /** + * 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) { + return this.isValidOnClient(name, value, this.doc); + }, + + /** + * 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(). + */ + _appendColor: function (color, options = {}) { + let colorObj = new colorUtils.CssColor(color); + + if (this._isValidColor(colorObj)) { + let container = this._createNode("span", { + "data-color": color + }); + + if (options.colorSwatchClass) { + let swatch = this._createNode("span", { + class: options.colorSwatchClass, + style: "background-color:" + color + }); + this.colorSwatches.set(swatch, colorObj); + swatch.addEventListener("mousedown", this._onColorSwatchMouseDown, + false); + EventEmitter.decorate(swatch); + container.appendChild(swatch); + } + + if (options.defaultColorType) { + color = colorObj.toString(); + container.dataset.colorĀ = color; + } + + let value = this._createNode("span", { + class: options.colorClass + }, color); + + container.appendChild(value); + this.parsed.push(container); + } else { + this._appendTextNode(color); + } + }, + + /** + * Wrap some existing nodes in a filter editor. + * + * @param {String} filters + * The full text of the "filter" property. + * @param {object} options + * The options object passed to parseCssProperty(). + * @param {object} nodes + * Nodes created by _toDOM(). + * + * @returns {object} + * A new node that supplies a filter swatch and that wraps |nodes|. + */ + _wrapFilter: function (filters, options, nodes) { + let container = this._createNode("span", { + "data-filters": filters + }); + + if (options.filterSwatchClass) { + let swatch = this._createNode("span", { + class: options.filterSwatchClass + }); + container.appendChild(swatch); + } + + let value = this._createNode("span", { + class: options.filterClass + }); + value.appendChild(nodes); + container.appendChild(value); + + return container; + }, + + _onColorSwatchMouseDown: function (event) { + if (!event.shiftKey) { + return; + } + + // Prevent click event to be fired to not show the tooltip + event.stopPropagation(); + + let swatch = event.target; + let color = this.colorSwatches.get(swatch); + let val = color.nextColorUnit(); + + swatch.nextElementSibling.textContent = val; + swatch.emit("unit-change", val); + }, + + _onAngleSwatchMouseDown: function (event) { + if (!event.shiftKey) { + return; + } + + event.stopPropagation(); + + let swatch = event.target; + let angle = this.angleSwatches.get(swatch); + let val = angle.nextAngleUnit(); + + swatch.nextElementSibling.textContent = val; + swatch.emit("unit-change", val); + }, + + /** + * A helper function that sanitizes a possibly-unterminated URL. + */ + _sanitizeURL: function (url) { + // Re-lex the URL and add any needed termination characters. + let urlTokenizer = getCSSLexer(url); + // Just read until EOF; there will only be a single token. + while (urlTokenizer.nextToken()) { + // Nothing. + } + + return urlTokenizer.performEOFFixup(url, true); + }, + + /** + * 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) { + // Sanitize the URL. Note that if we modify the URL, we just + // leave the termination characters. This isn't strictly + // "as-authored", but it makes a bit more sense. + match = this._sanitizeURL(match); + // This regexp matches a URL token. It puts the "url(", any + // leading whitespace, and any opening quote into |leader|; the + // URL text itself into |body|, and any trailing quote, trailing + // whitespace, and the ")" into |trailer|. We considered adding + // functionality for this to CSSLexer, in some way, but this + // seemed simpler on the whole. + let [, leader, , body, trailer] = + /^(url\([ \t\r\n\f]*(["']?))(.*?)(\2[ \t\r\n\f]*\))$/i.exec(match); + + this._appendTextNode(leader); + + let href = url; + if (options.baseURI) { + try { + href = new URL(url, options.baseURI).href; + } catch (e) { + // Ignore. + } + } + + this._appendNode("a", { + target: "_blank", + class: options.urlClass, + href: href + }, body); + + this._appendTextNode(trailer); + } else { + this._appendTextNode(match); + } + }, + + /** + * Create a node. + * + * @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. + * @return {Node} Newly created Node. + */ + _createNode: function (tagName, attributes, value = "") { + let node = this.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 = this.doc.createTextNode(value); + node.appendChild(textNode); + } + + return node; + }, + + /** + * 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 node = this._createNode(tagName, attributes, value); + 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 frag = this.doc.createDocumentFragment(); + + for (let item of this.parsed) { + if (typeof item === "string") { + frag.appendChild(this.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. + * - angleClass: "" // The class to use for the angle value + * // that follows the swatch. + * - angleSwatchClass: "" // The class to use for angle swatches. + * - bezierClass: "" // The class to use for the bezier value + * // that follows the swatch. + * - bezierSwatchClass: "" // The class to use for bezier swatches. + * - colorClass: "" // The class to use for the color value + * // that follows the swatch. + * - colorSwatchClass: "" // The class to use for color swatches. + * - filterSwatch: false // A special case for parsing a + * // "filter" property, causing the + * // parser to skip the call to + * // _wrapFilter. Used only for + * // previewing with the filter swatch. + * - gridClass: "" // The class to use for the grid icon. + * - supportsColor: false // Does the CSS property support colors? + * - urlClass: "" // The class to be used for url() links. + * - baseURI: undefined // A string used to resolve + * // relative links. + * @return {Object} + * Overridden options object + */ + _mergeOptions: function (overrides) { + let defaults = { + defaultColorType: true, + angleClass: "", + angleSwatchClass: "", + bezierClass: "", + bezierSwatchClass: "", + colorClass: "", + colorSwatchClass: "", + filterSwatch: false, + gridClass: "", + supportsColor: false, + urlClass: "", + baseURI: undefined, + }; + + for (let item in overrides) { + defaults[item] = overrides[item]; + } + return defaults; + } +}; |