summaryrefslogtreecommitdiffstats
path: root/devtools/client/shared/output-parser.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/shared/output-parser.js')
-rw-r--r--devtools/client/shared/output-parser.js695
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;
+ }
+};