output-parser.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703
  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/. */
  4. "use strict";
  5. const {angleUtils} = require("devtools/client/shared/css-angle");
  6. const {colorUtils} = require("devtools/shared/css/color");
  7. const {getCSSLexer} = require("devtools/shared/css/lexer");
  8. const EventEmitter = require("devtools/shared/event-emitter");
  9. const {
  10. ANGLE_TAKING_FUNCTIONS,
  11. BEZIER_KEYWORDS,
  12. COLOR_TAKING_FUNCTIONS,
  13. CSS_TYPES
  14. } = require("devtools/shared/css/properties-db");
  15. const Services = require("Services");
  16. const HTML_NS = "http://www.w3.org/1999/xhtml";
  17. const CSS_GRID_ENABLED_PREF = "layout.css.grid.enabled";
  18. /**
  19. * This module is used to process text for output by developer tools. This means
  20. * linking JS files with the debugger, CSS files with the style editor, JS
  21. * functions with the debugger, placing color swatches next to colors and
  22. * adding doorhanger previews where possible (images, angles, lengths,
  23. * border radius, cubic-bezier etc.).
  24. *
  25. * Usage:
  26. * const {OutputParser} = require("devtools/client/shared/output-parser");
  27. *
  28. * let parser = new OutputParser(document, supportsType);
  29. *
  30. * parser.parseCssProperty("color", "red"); // Returns document fragment.
  31. *
  32. * @param {Document} document Used to create DOM nodes.
  33. * @param {Function} supportsTypes - A function that returns a boolean when asked if a css
  34. * property name supports a given css type.
  35. * The function is executed like supportsType("color", CSS_TYPES.COLOR)
  36. * where CSS_TYPES is defined in devtools/shared/css/properties-db.js
  37. * @param {Function} isValidOnClient - A function that checks if a css property
  38. * name/value combo is valid.
  39. * @param {Function} supportsCssColor4ColorFunction - A function for checking
  40. * the supporting of css-color-4 color function.
  41. */
  42. function OutputParser(document,
  43. {supportsType, isValidOnClient, supportsCssColor4ColorFunction}) {
  44. this.parsed = [];
  45. this.doc = document;
  46. this.supportsType = supportsType;
  47. this.isValidOnClient = isValidOnClient;
  48. this.colorSwatches = new WeakMap();
  49. this.angleSwatches = new WeakMap();
  50. this._onColorSwatchMouseDown = this._onColorSwatchMouseDown.bind(this);
  51. this._onAngleSwatchMouseDown = this._onAngleSwatchMouseDown.bind(this);
  52. this.cssColor4 = supportsCssColor4ColorFunction();
  53. }
  54. exports.OutputParser = OutputParser;
  55. OutputParser.prototype = {
  56. /**
  57. * Parse a CSS property value given a property name.
  58. *
  59. * @param {String} name
  60. * CSS Property Name
  61. * @param {String} value
  62. * CSS Property value
  63. * @param {Object} [options]
  64. * Options object. For valid options and default values see
  65. * _mergeOptions().
  66. * @return {DocumentFragment}
  67. * A document fragment containing color swatches etc.
  68. */
  69. parseCssProperty: function (name, value, options = {}) {
  70. options = this._mergeOptions(options);
  71. options.expectCubicBezier = this.supportsType(name, CSS_TYPES.TIMING_FUNCTION);
  72. options.expectDisplay = name === "display";
  73. options.expectFilter = name === "filter";
  74. options.supportsColor = this.supportsType(name, CSS_TYPES.COLOR) ||
  75. this.supportsType(name, CSS_TYPES.GRADIENT);
  76. // The filter property is special in that we want to show the
  77. // swatch even if the value is invalid, because this way the user
  78. // can easily use the editor to fix it.
  79. if (options.expectFilter || this._cssPropertySupportsValue(name, value)) {
  80. return this._parse(value, options);
  81. }
  82. this._appendTextNode(value);
  83. return this._toDOM();
  84. },
  85. /**
  86. * Given an initial FUNCTION token, read tokens from |tokenStream|
  87. * and collect all the (non-comment) text. Return the collected
  88. * text. The function token and the close paren are included in the
  89. * result.
  90. *
  91. * @param {CSSToken} initialToken
  92. * The FUNCTION token.
  93. * @param {String} text
  94. * The original CSS text.
  95. * @param {CSSLexer} tokenStream
  96. * The token stream from which to read.
  97. * @return {String}
  98. * The text of body of the function call.
  99. */
  100. _collectFunctionText: function (initialToken, text, tokenStream) {
  101. let result = text.substring(initialToken.startOffset,
  102. initialToken.endOffset);
  103. let depth = 1;
  104. while (depth > 0) {
  105. let token = tokenStream.nextToken();
  106. if (!token) {
  107. break;
  108. }
  109. if (token.tokenType === "comment") {
  110. continue;
  111. }
  112. result += text.substring(token.startOffset, token.endOffset);
  113. if (token.tokenType === "symbol") {
  114. if (token.text === "(") {
  115. ++depth;
  116. } else if (token.text === ")") {
  117. --depth;
  118. }
  119. } else if (token.tokenType === "function") {
  120. ++depth;
  121. }
  122. }
  123. return result;
  124. },
  125. /**
  126. * Parse a string.
  127. *
  128. * @param {String} text
  129. * Text to parse.
  130. * @param {Object} [options]
  131. * Options object. For valid options and default values see
  132. * _mergeOptions().
  133. * @return {DocumentFragment}
  134. * A document fragment.
  135. */
  136. _parse: function (text, options = {}) {
  137. text = text.trim();
  138. this.parsed.length = 0;
  139. let tokenStream = getCSSLexer(text);
  140. let parenDepth = 0;
  141. let outerMostFunctionTakesColor = false;
  142. let colorOK = function () {
  143. return options.supportsColor ||
  144. (options.expectFilter && parenDepth === 1 &&
  145. outerMostFunctionTakesColor);
  146. };
  147. let angleOK = function (angle) {
  148. return (new angleUtils.CssAngle(angle)).valid;
  149. };
  150. while (true) {
  151. let token = tokenStream.nextToken();
  152. if (!token) {
  153. break;
  154. }
  155. if (token.tokenType === "comment") {
  156. continue;
  157. }
  158. switch (token.tokenType) {
  159. case "function": {
  160. if (COLOR_TAKING_FUNCTIONS.includes(token.text) ||
  161. ANGLE_TAKING_FUNCTIONS.includes(token.text)) {
  162. // The function can accept a color or an angle argument, and we know
  163. // it isn't special in some other way. So, we let it
  164. // through to the ordinary parsing loop so that the value
  165. // can be handled in a single place.
  166. this._appendTextNode(text.substring(token.startOffset,
  167. token.endOffset));
  168. if (parenDepth === 0) {
  169. outerMostFunctionTakesColor = COLOR_TAKING_FUNCTIONS.includes(
  170. token.text);
  171. }
  172. ++parenDepth;
  173. } else {
  174. let functionText = this._collectFunctionText(token, text,
  175. tokenStream);
  176. if (options.expectCubicBezier && token.text === "cubic-bezier") {
  177. this._appendCubicBezier(functionText, options);
  178. } else if (colorOK() &&
  179. colorUtils.isValidCSSColor(functionText, this.cssColor4)) {
  180. this._appendColor(functionText, options);
  181. } else {
  182. this._appendTextNode(functionText);
  183. }
  184. }
  185. break;
  186. }
  187. case "ident":
  188. if (options.expectCubicBezier &&
  189. BEZIER_KEYWORDS.indexOf(token.text) >= 0) {
  190. this._appendCubicBezier(token.text, options);
  191. } else if (Services.prefs.getBoolPref(CSS_GRID_ENABLED_PREF) &&
  192. options.expectDisplay && token.text === "grid" &&
  193. text === token.text) {
  194. this._appendGrid(token.text, options);
  195. } else if (colorOK() &&
  196. colorUtils.isValidCSSColor(token.text, this.cssColor4)) {
  197. this._appendColor(token.text, options);
  198. } else if (angleOK(token.text)) {
  199. this._appendAngle(token.text, options);
  200. } else {
  201. this._appendTextNode(text.substring(token.startOffset,
  202. token.endOffset));
  203. }
  204. break;
  205. case "id":
  206. case "hash": {
  207. let original = text.substring(token.startOffset, token.endOffset);
  208. if (colorOK() && colorUtils.isValidCSSColor(original, this.cssColor4)) {
  209. this._appendColor(original, options);
  210. } else {
  211. this._appendTextNode(original);
  212. }
  213. break;
  214. }
  215. case "dimension":
  216. let value = text.substring(token.startOffset, token.endOffset);
  217. if (angleOK(value)) {
  218. this._appendAngle(value, options);
  219. } else {
  220. this._appendTextNode(value);
  221. }
  222. break;
  223. case "url":
  224. case "bad_url":
  225. this._appendURL(text.substring(token.startOffset, token.endOffset),
  226. token.text, options);
  227. break;
  228. case "symbol":
  229. if (token.text === "(") {
  230. ++parenDepth;
  231. } else if (token.text === ")") {
  232. --parenDepth;
  233. if (parenDepth === 0) {
  234. outerMostFunctionTakesColor = false;
  235. }
  236. }
  237. // falls through
  238. default:
  239. this._appendTextNode(
  240. text.substring(token.startOffset, token.endOffset));
  241. break;
  242. }
  243. }
  244. let result = this._toDOM();
  245. if (options.expectFilter && !options.filterSwatch) {
  246. result = this._wrapFilter(text, options, result);
  247. }
  248. return result;
  249. },
  250. /**
  251. * Append a cubic-bezier timing function value to the output
  252. *
  253. * @param {String} bezier
  254. * The cubic-bezier timing function
  255. * @param {Object} options
  256. * Options object. For valid options and default values see
  257. * _mergeOptions()
  258. */
  259. _appendCubicBezier: function (bezier, options) {
  260. let container = this._createNode("span", {
  261. "data-bezier": bezier
  262. });
  263. if (options.bezierSwatchClass) {
  264. let swatch = this._createNode("span", {
  265. class: options.bezierSwatchClass
  266. });
  267. container.appendChild(swatch);
  268. }
  269. let value = this._createNode("span", {
  270. class: options.bezierClass
  271. }, bezier);
  272. container.appendChild(value);
  273. this.parsed.push(container);
  274. },
  275. /**
  276. * Append a CSS Grid highlighter toggle icon next to the value in a
  277. * 'display: grid' declaration
  278. *
  279. * @param {String} grid
  280. * The grid text value to append
  281. * @param {Object} options
  282. * Options object. For valid options and default values see
  283. * _mergeOptions()
  284. */
  285. _appendGrid: function (grid, options) {
  286. let container = this._createNode("span", {});
  287. let toggle = this._createNode("span", {
  288. class: options.gridClass
  289. });
  290. let value = this._createNode("span", {});
  291. value.textContent = grid;
  292. container.appendChild(toggle);
  293. container.appendChild(value);
  294. this.parsed.push(container);
  295. },
  296. /**
  297. * Append a angle value to the output
  298. *
  299. * @param {String} angle
  300. * angle to append
  301. * @param {Object} options
  302. * Options object. For valid options and default values see
  303. * _mergeOptions()
  304. */
  305. _appendAngle: function (angle, options) {
  306. let angleObj = new angleUtils.CssAngle(angle);
  307. let container = this._createNode("span", {
  308. "data-angle": angle
  309. });
  310. if (options.angleSwatchClass) {
  311. let swatch = this._createNode("span", {
  312. class: options.angleSwatchClass
  313. });
  314. this.angleSwatches.set(swatch, angleObj);
  315. swatch.addEventListener("mousedown", this._onAngleSwatchMouseDown, false);
  316. // Add click listener to stop event propagation when shift key is pressed
  317. // in order to prevent the value input to be focused.
  318. // Bug 711942 will add a tooltip to edit angle values and we should
  319. // be able to move this listener to Tooltip.js when it'll be implemented.
  320. swatch.addEventListener("click", function (event) {
  321. if (event.shiftKey) {
  322. event.stopPropagation();
  323. }
  324. }, false);
  325. EventEmitter.decorate(swatch);
  326. container.appendChild(swatch);
  327. }
  328. let value = this._createNode("span", {
  329. class: options.angleClass
  330. }, angle);
  331. container.appendChild(value);
  332. this.parsed.push(container);
  333. },
  334. /**
  335. * Check if a CSS property supports a specific value.
  336. *
  337. * @param {String} name
  338. * CSS Property name to check
  339. * @param {String} value
  340. * CSS Property value to check
  341. */
  342. _cssPropertySupportsValue: function (name, value) {
  343. return this.isValidOnClient(name, value, this.doc);
  344. },
  345. /**
  346. * Tests if a given colorObject output by CssColor is valid for parsing.
  347. * Valid means it's really a color, not any of the CssColor SPECIAL_VALUES
  348. * except transparent
  349. */
  350. _isValidColor: function (colorObj) {
  351. return colorObj.valid &&
  352. (!colorObj.specialValue || colorObj.specialValue === "transparent");
  353. },
  354. /**
  355. * Append a color to the output.
  356. *
  357. * @param {String} color
  358. * Color to append
  359. * @param {Object} [options]
  360. * Options object. For valid options and default values see
  361. * _mergeOptions().
  362. */
  363. _appendColor: function (color, options = {}) {
  364. let colorObj = new colorUtils.CssColor(color, this.cssColor4);
  365. if (this._isValidColor(colorObj)) {
  366. let container = this._createNode("span", {
  367. "data-color": color
  368. });
  369. if (options.colorSwatchClass) {
  370. let swatch = this._createNode("span", {
  371. class: options.colorSwatchClass,
  372. style: "background-color:" + color
  373. });
  374. this.colorSwatches.set(swatch, colorObj);
  375. swatch.addEventListener("mousedown", this._onColorSwatchMouseDown,
  376. false);
  377. EventEmitter.decorate(swatch);
  378. container.appendChild(swatch);
  379. }
  380. if (options.defaultColorType) {
  381. color = colorObj.toString();
  382. container.dataset.color = color;
  383. }
  384. let value = this._createNode("span", {
  385. class: options.colorClass
  386. }, color);
  387. container.appendChild(value);
  388. this.parsed.push(container);
  389. } else {
  390. this._appendTextNode(color);
  391. }
  392. },
  393. /**
  394. * Wrap some existing nodes in a filter editor.
  395. *
  396. * @param {String} filters
  397. * The full text of the "filter" property.
  398. * @param {object} options
  399. * The options object passed to parseCssProperty().
  400. * @param {object} nodes
  401. * Nodes created by _toDOM().
  402. *
  403. * @returns {object}
  404. * A new node that supplies a filter swatch and that wraps |nodes|.
  405. */
  406. _wrapFilter: function (filters, options, nodes) {
  407. let container = this._createNode("span", {
  408. "data-filters": filters
  409. });
  410. if (options.filterSwatchClass) {
  411. let swatch = this._createNode("span", {
  412. class: options.filterSwatchClass
  413. });
  414. container.appendChild(swatch);
  415. }
  416. let value = this._createNode("span", {
  417. class: options.filterClass
  418. });
  419. value.appendChild(nodes);
  420. container.appendChild(value);
  421. return container;
  422. },
  423. _onColorSwatchMouseDown: function (event) {
  424. if (!event.shiftKey) {
  425. return;
  426. }
  427. // Prevent click event to be fired to not show the tooltip
  428. event.stopPropagation();
  429. let swatch = event.target;
  430. let color = this.colorSwatches.get(swatch);
  431. let val = color.nextColorUnit();
  432. swatch.nextElementSibling.textContent = val;
  433. swatch.emit("unit-change", val);
  434. },
  435. _onAngleSwatchMouseDown: function (event) {
  436. if (!event.shiftKey) {
  437. return;
  438. }
  439. event.stopPropagation();
  440. let swatch = event.target;
  441. let angle = this.angleSwatches.get(swatch);
  442. let val = angle.nextAngleUnit();
  443. swatch.nextElementSibling.textContent = val;
  444. swatch.emit("unit-change", val);
  445. },
  446. /**
  447. * A helper function that sanitizes a possibly-unterminated URL.
  448. */
  449. _sanitizeURL: function (url) {
  450. // Re-lex the URL and add any needed termination characters.
  451. let urlTokenizer = getCSSLexer(url);
  452. // Just read until EOF; there will only be a single token.
  453. while (urlTokenizer.nextToken()) {
  454. // Nothing.
  455. }
  456. return urlTokenizer.performEOFFixup(url, true);
  457. },
  458. /**
  459. * Append a URL to the output.
  460. *
  461. * @param {String} match
  462. * Complete match that may include "url(xxx)"
  463. * @param {String} url
  464. * Actual URL
  465. * @param {Object} [options]
  466. * Options object. For valid options and default values see
  467. * _mergeOptions().
  468. */
  469. _appendURL: function (match, url, options) {
  470. if (options.urlClass) {
  471. // Sanitize the URL. Note that if we modify the URL, we just
  472. // leave the termination characters. This isn't strictly
  473. // "as-authored", but it makes a bit more sense.
  474. match = this._sanitizeURL(match);
  475. // This regexp matches a URL token. It puts the "url(", any
  476. // leading whitespace, and any opening quote into |leader|; the
  477. // URL text itself into |body|, and any trailing quote, trailing
  478. // whitespace, and the ")" into |trailer|. We considered adding
  479. // functionality for this to CSSLexer, in some way, but this
  480. // seemed simpler on the whole.
  481. let [, leader, , body, trailer] =
  482. /^(url\([ \t\r\n\f]*(["']?))(.*?)(\2[ \t\r\n\f]*\))$/i.exec(match);
  483. this._appendTextNode(leader);
  484. let href = url;
  485. if (options.baseURI) {
  486. try {
  487. href = new URL(url, options.baseURI).href;
  488. } catch (e) {
  489. // Ignore.
  490. }
  491. }
  492. this._appendNode("a", {
  493. target: "_blank",
  494. class: options.urlClass,
  495. href: href
  496. }, body);
  497. this._appendTextNode(trailer);
  498. } else {
  499. this._appendTextNode(match);
  500. }
  501. },
  502. /**
  503. * Create a node.
  504. *
  505. * @param {String} tagName
  506. * Tag type e.g. "div"
  507. * @param {Object} attributes
  508. * e.g. {class: "someClass", style: "cursor:pointer"};
  509. * @param {String} [value]
  510. * If a value is included it will be appended as a text node inside
  511. * the tag. This is useful e.g. for span tags.
  512. * @return {Node} Newly created Node.
  513. */
  514. _createNode: function (tagName, attributes, value = "") {
  515. let node = this.doc.createElementNS(HTML_NS, tagName);
  516. let attrs = Object.getOwnPropertyNames(attributes);
  517. for (let attr of attrs) {
  518. if (attributes[attr]) {
  519. node.setAttribute(attr, attributes[attr]);
  520. }
  521. }
  522. if (value) {
  523. let textNode = this.doc.createTextNode(value);
  524. node.appendChild(textNode);
  525. }
  526. return node;
  527. },
  528. /**
  529. * Append a node to the output.
  530. *
  531. * @param {String} tagName
  532. * Tag type e.g. "div"
  533. * @param {Object} attributes
  534. * e.g. {class: "someClass", style: "cursor:pointer"};
  535. * @param {String} [value]
  536. * If a value is included it will be appended as a text node inside
  537. * the tag. This is useful e.g. for span tags.
  538. */
  539. _appendNode: function (tagName, attributes, value = "") {
  540. let node = this._createNode(tagName, attributes, value);
  541. this.parsed.push(node);
  542. },
  543. /**
  544. * Append a text node to the output. If the previously output item was a text
  545. * node then we append the text to that node.
  546. *
  547. * @param {String} text
  548. * Text to append
  549. */
  550. _appendTextNode: function (text) {
  551. let lastItem = this.parsed[this.parsed.length - 1];
  552. if (typeof lastItem === "string") {
  553. this.parsed[this.parsed.length - 1] = lastItem + text;
  554. } else {
  555. this.parsed.push(text);
  556. }
  557. },
  558. /**
  559. * Take all output and append it into a single DocumentFragment.
  560. *
  561. * @return {DocumentFragment}
  562. * Document Fragment
  563. */
  564. _toDOM: function () {
  565. let frag = this.doc.createDocumentFragment();
  566. for (let item of this.parsed) {
  567. if (typeof item === "string") {
  568. frag.appendChild(this.doc.createTextNode(item));
  569. } else {
  570. frag.appendChild(item);
  571. }
  572. }
  573. this.parsed.length = 0;
  574. return frag;
  575. },
  576. /**
  577. * Merges options objects. Default values are set here.
  578. *
  579. * @param {Object} overrides
  580. * The option values to override e.g. _mergeOptions({colors: false})
  581. *
  582. * Valid options are:
  583. * - defaultColorType: true // Convert colors to the default type
  584. * // selected in the options panel.
  585. * - angleClass: "" // The class to use for the angle value
  586. * // that follows the swatch.
  587. * - angleSwatchClass: "" // The class to use for angle swatches.
  588. * - bezierClass: "" // The class to use for the bezier value
  589. * // that follows the swatch.
  590. * - bezierSwatchClass: "" // The class to use for bezier swatches.
  591. * - colorClass: "" // The class to use for the color value
  592. * // that follows the swatch.
  593. * - colorSwatchClass: "" // The class to use for color swatches.
  594. * - filterSwatch: false // A special case for parsing a
  595. * // "filter" property, causing the
  596. * // parser to skip the call to
  597. * // _wrapFilter. Used only for
  598. * // previewing with the filter swatch.
  599. * - gridClass: "" // The class to use for the grid icon.
  600. * - supportsColor: false // Does the CSS property support colors?
  601. * - urlClass: "" // The class to be used for url() links.
  602. * - baseURI: undefined // A string used to resolve
  603. * // relative links.
  604. * @return {Object}
  605. * Overridden options object
  606. */
  607. _mergeOptions: function (overrides) {
  608. let defaults = {
  609. defaultColorType: true,
  610. angleClass: "",
  611. angleSwatchClass: "",
  612. bezierClass: "",
  613. bezierSwatchClass: "",
  614. colorClass: "",
  615. colorSwatchClass: "",
  616. filterSwatch: false,
  617. gridClass: "",
  618. supportsColor: false,
  619. urlClass: "",
  620. baseURI: undefined,
  621. };
  622. for (let item in overrides) {
  623. defaults[item] = overrides[item];
  624. }
  625. return defaults;
  626. }
  627. };