Wed, 31 Dec 2014 13:27:57 +0100
Ignore runtime configuration files generated during quality assurance.
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/. */
5 "use strict";
7 const {Cc, Ci, Cu} = require("chrome");
8 const {colorUtils} = require("devtools/css-color");
9 const {Services} = Cu.import("resource://gre/modules/Services.jsm", {});
11 const HTML_NS = "http://www.w3.org/1999/xhtml";
13 const MAX_ITERATIONS = 100;
14 const REGEX_QUOTES = /^".*?"|^".*|^'.*?'|^'.*/;
15 const REGEX_WHITESPACE = /^\s+/;
16 const REGEX_FIRST_WORD_OR_CHAR = /^\w+|^./;
17 const REGEX_CSS_PROPERTY_VALUE = /(^[^;]+)/;
19 /**
20 * This regex matches:
21 * - #F00
22 * - #FF0000
23 * - hsl()
24 * - hsla()
25 * - rgb()
26 * - rgba()
27 * - color names
28 */
29 const REGEX_ALL_COLORS = /^#[0-9a-fA-F]{3}\b|^#[0-9a-fA-F]{6}\b|^hsl\(.*?\)|^hsla\(.*?\)|^rgba?\(.*?\)|^[a-zA-Z-]+/;
31 loader.lazyGetter(this, "DOMUtils", function () {
32 return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils);
33 });
35 /**
36 * This regular expression catches all css property names with their trailing
37 * spaces and semicolon. This is used to ensure a value is valid for a property
38 * name within style="" attributes.
39 */
40 loader.lazyGetter(this, "REGEX_ALL_CSS_PROPERTIES", function () {
41 let names = DOMUtils.getCSSPropertyNames();
42 let pattern = "^(";
44 for (let i = 0; i < names.length; i++) {
45 if (i > 0) {
46 pattern += "|";
47 }
48 pattern += names[i];
49 }
50 pattern += ")\\s*:\\s*";
52 return new RegExp(pattern);
53 });
55 /**
56 * This module is used to process text for output by developer tools. This means
57 * linking JS files with the debugger, CSS files with the style editor, JS
58 * functions with the debugger, placing color swatches next to colors and
59 * adding doorhanger previews where possible (images, angles, lengths,
60 * border radius, cubic-bezier etc.).
61 *
62 * Usage:
63 * const {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
64 * const {OutputParser} = devtools.require("devtools/output-parser");
65 *
66 * let parser = new OutputParser();
67 *
68 * parser.parseCssProperty("color", "red"); // Returns document fragment.
69 * parser.parseHTMLAttribute("color:red; font-size: 12px;"); // Returns document
70 * // fragment.
71 */
72 function OutputParser() {
73 this.parsed = [];
74 }
76 exports.OutputParser = OutputParser;
78 OutputParser.prototype = {
79 /**
80 * Parse a CSS property value given a property name.
81 *
82 * @param {String} name
83 * CSS Property Name
84 * @param {String} value
85 * CSS Property value
86 * @param {Object} [options]
87 * Options object. For valid options and default values see
88 * _mergeOptions().
89 * @return {DocumentFragment}
90 * A document fragment containing color swatches etc.
91 */
92 parseCssProperty: function(name, value, options={}) {
93 options = this._mergeOptions(options);
95 if (this._cssPropertySupportsValue(name, value)) {
96 return this._parse(value, options);
97 }
98 this._appendTextNode(value);
100 return this._toDOM();
101 },
103 /**
104 * Parse a string.
105 *
106 * @param {String} value
107 * Text to parse.
108 * @param {Object} [options]
109 * Options object. For valid options and default values see
110 * _mergeOptions().
111 * @return {DocumentFragment}
112 * A document fragment. Colors will not be parsed.
113 */
114 parseHTMLAttribute: function(value, options={}) {
115 options.isHTMLAttribute = true;
116 options = this._mergeOptions(options);
118 return this._parse(value, options);
119 },
121 /**
122 * Matches the beginning of the provided string to a css background-image url
123 * and return both the whole url(...) match and the url itself.
124 * This isn't handled via a regular expression to make sure we can match urls
125 * that contain parenthesis easily
126 */
127 _matchBackgroundUrl: function(text) {
128 let startToken = "url(";
129 if (text.indexOf(startToken) !== 0) {
130 return null;
131 }
133 let uri = text.substring(startToken.length).trim();
134 let quote = uri.substring(0, 1);
135 if (quote === "'" || quote === '"') {
136 uri = uri.substring(1, uri.search(new RegExp(quote + "\\s*\\)")));
137 } else {
138 uri = uri.substring(0, uri.indexOf(")"));
139 quote = "";
140 }
141 let end = startToken + quote + uri;
142 text = text.substring(0, text.indexOf(")", end.length) + 1);
144 return [text, uri.trim()];
145 },
147 /**
148 * Parse a string.
149 *
150 * @param {String} text
151 * Text to parse.
152 * @param {Object} [options]
153 * Options object. For valid options and default values see
154 * _mergeOptions().
155 * @return {DocumentFragment}
156 * A document fragment.
157 */
158 _parse: function(text, options={}) {
159 text = text.trim();
160 this.parsed.length = 0;
161 let i = 0;
163 while (text.length > 0) {
164 let matched = null;
166 // Prevent this loop from slowing down the browser with too
167 // many nodes being appended into output. In practice it is very unlikely
168 // that this will ever happen.
169 i++;
170 if (i > MAX_ITERATIONS) {
171 this._appendTextNode(text);
172 text = "";
173 break;
174 }
176 matched = text.match(REGEX_QUOTES);
177 if (matched) {
178 let match = matched[0];
180 text = this._trimMatchFromStart(text, match);
181 this._appendTextNode(match);
182 continue;
183 }
185 matched = text.match(REGEX_WHITESPACE);
186 if (matched) {
187 let match = matched[0];
189 text = this._trimMatchFromStart(text, match);
190 this._appendTextNode(match);
191 continue;
192 }
194 matched = this._matchBackgroundUrl(text);
195 if (matched) {
196 let [match, url] = matched;
197 text = this._trimMatchFromStart(text, match);
199 this._appendURL(match, url, options);
200 continue;
201 }
203 matched = text.match(REGEX_ALL_CSS_PROPERTIES);
204 if (matched) {
205 let [match] = matched;
207 text = this._trimMatchFromStart(text, match);
208 this._appendTextNode(match);
210 if (options.isHTMLAttribute) {
211 [text] = this._appendColorOnMatch(text, options);
212 }
213 continue;
214 }
216 if (!options.isHTMLAttribute) {
217 let dirty;
219 [text, dirty] = this._appendColorOnMatch(text, options);
221 if (dirty) {
222 continue;
223 }
224 }
226 // This test must always be last as it indicates use of an unknown
227 // character that needs to be removed to prevent infinite loops.
228 matched = text.match(REGEX_FIRST_WORD_OR_CHAR);
229 if (matched) {
230 let match = matched[0];
232 text = this._trimMatchFromStart(text, match);
233 this._appendTextNode(match);
234 }
235 }
237 return this._toDOM();
238 },
240 /**
241 * Convenience function to make the parser a little more readable.
242 *
243 * @param {String} text
244 * Main text
245 * @param {String} match
246 * Text to remove from the beginning
247 *
248 * @return {String}
249 * The string passed as 'text' with 'match' stripped from the start.
250 */
251 _trimMatchFromStart: function(text, match) {
252 return text.substr(match.length);
253 },
255 /**
256 * Check if there is a color match and append it if it is valid.
257 *
258 * @param {String} text
259 * Main text
260 * @param {Object} options
261 * Options object. For valid options and default values see
262 * _mergeOptions().
263 *
264 * @return {Array}
265 * An array containing the remaining text and a dirty flag. This array
266 * is designed for deconstruction using [text, dirty].
267 */
268 _appendColorOnMatch: function(text, options) {
269 let dirty;
270 let matched = text.match(REGEX_ALL_COLORS);
272 if (matched) {
273 let match = matched[0];
274 if (this._appendColor(match, options)) {
275 text = this._trimMatchFromStart(text, match);
276 dirty = true;
277 }
278 } else {
279 dirty = false;
280 }
282 return [text, dirty];
283 },
285 /**
286 * Check if a CSS property supports a specific value.
287 *
288 * @param {String} name
289 * CSS Property name to check
290 * @param {String} value
291 * CSS Property value to check
292 */
293 _cssPropertySupportsValue: function(name, value) {
294 let win = Services.appShell.hiddenDOMWindow;
295 let doc = win.document;
297 name = name.replace(/-\w{1}/g, function(match) {
298 return match.charAt(1).toUpperCase();
299 });
301 value = value.replace("!important", "");
303 let div = doc.createElement("div");
304 div.style[name] = value;
306 return !!div.style[name];
307 },
309 /**
310 * Tests if a given colorObject output by CssColor is valid for parsing.
311 * Valid means it's really a color, not any of the CssColor SPECIAL_VALUES
312 * except transparent
313 */
314 _isValidColor: function(colorObj) {
315 return colorObj.valid &&
316 (!colorObj.specialValue || colorObj.specialValue === "transparent");
317 },
319 /**
320 * Append a color to the output.
321 *
322 * @param {String} color
323 * Color to append
324 * @param {Object} [options]
325 * Options object. For valid options and default values see
326 * _mergeOptions().
327 * @returns {Boolean}
328 * true if the color passed in was valid, false otherwise. Special
329 * values such as transparent also return false.
330 */
331 _appendColor: function(color, options={}) {
332 let colorObj = new colorUtils.CssColor(color);
334 if (this._isValidColor(colorObj)) {
335 if (options.colorSwatchClass) {
336 this._appendNode("span", {
337 class: options.colorSwatchClass,
338 style: "background-color:" + color
339 });
340 }
341 if (options.defaultColorType) {
342 color = colorObj.toString();
343 }
344 this._appendNode("span", {
345 class: options.colorClass
346 }, color);
347 return true;
348 }
349 return false;
350 },
352 /**
353 * Append a URL to the output.
354 *
355 * @param {String} match
356 * Complete match that may include "url(xxx)"
357 * @param {String} url
358 * Actual URL
359 * @param {Object} [options]
360 * Options object. For valid options and default values see
361 * _mergeOptions().
362 */
363 _appendURL: function(match, url, options={}) {
364 if (options.urlClass) {
365 // We use single quotes as this works inside html attributes (e.g. the
366 // markup view).
367 this._appendTextNode("url('");
369 let href = url;
370 if (options.baseURI) {
371 href = options.baseURI.resolve(url);
372 }
374 this._appendNode("a", {
375 target: "_blank",
376 class: options.urlClass,
377 href: href
378 }, url);
380 this._appendTextNode("')");
381 } else {
382 this._appendTextNode("url('" + url + "')");
383 }
384 },
386 /**
387 * Append a node to the output.
388 *
389 * @param {String} tagName
390 * Tag type e.g. "div"
391 * @param {Object} attributes
392 * e.g. {class: "someClass", style: "cursor:pointer"};
393 * @param {String} [value]
394 * If a value is included it will be appended as a text node inside
395 * the tag. This is useful e.g. for span tags.
396 */
397 _appendNode: function(tagName, attributes, value="") {
398 let win = Services.appShell.hiddenDOMWindow;
399 let doc = win.document;
400 let node = doc.createElementNS(HTML_NS, tagName);
401 let attrs = Object.getOwnPropertyNames(attributes);
403 for (let attr of attrs) {
404 if (attributes[attr]) {
405 node.setAttribute(attr, attributes[attr]);
406 }
407 }
409 if (value) {
410 let textNode = doc.createTextNode(value);
411 node.appendChild(textNode);
412 }
414 this.parsed.push(node);
415 },
417 /**
418 * Append a text node to the output. If the previously output item was a text
419 * node then we append the text to that node.
420 *
421 * @param {String} text
422 * Text to append
423 */
424 _appendTextNode: function(text) {
425 let lastItem = this.parsed[this.parsed.length - 1];
426 if (typeof lastItem === "string") {
427 this.parsed[this.parsed.length - 1] = lastItem + text;
428 } else {
429 this.parsed.push(text);
430 }
431 },
433 /**
434 * Take all output and append it into a single DocumentFragment.
435 *
436 * @return {DocumentFragment}
437 * Document Fragment
438 */
439 _toDOM: function() {
440 let win = Services.appShell.hiddenDOMWindow;
441 let doc = win.document;
442 let frag = doc.createDocumentFragment();
444 for (let item of this.parsed) {
445 if (typeof item === "string") {
446 frag.appendChild(doc.createTextNode(item));
447 } else {
448 frag.appendChild(item);
449 }
450 }
452 this.parsed.length = 0;
453 return frag;
454 },
456 /**
457 * Merges options objects. Default values are set here.
458 *
459 * @param {Object} overrides
460 * The option values to override e.g. _mergeOptions({colors: false})
461 *
462 * Valid options are:
463 * - defaultColorType: true // Convert colors to the default type
464 * // selected in the options panel.
465 * - colorSwatchClass: "" // The class to use for color swatches.
466 * - colorClass: "" // The class to use for the color value
467 * // that follows the swatch.
468 * - isHTMLAttribute: false // This property indicates whether we
469 * // are parsing an HTML attribute value.
470 * // When the value is passed in from an
471 * // HTML attribute we need to check that
472 * // any CSS property values are supported
473 * // by the property name before
474 * // processing the property value.
475 * - urlClass: "" // The class to be used for url() links.
476 * - baseURI: "" // A string or nsIURI used to resolve
477 * // relative links.
478 * @return {Object}
479 * Overridden options object
480 */
481 _mergeOptions: function(overrides) {
482 let defaults = {
483 defaultColorType: true,
484 colorSwatchClass: "",
485 colorClass: "",
486 isHTMLAttribute: false,
487 urlClass: "",
488 baseURI: ""
489 };
491 if (typeof overrides.baseURI === "string") {
492 overrides.baseURI = Services.io.newURI(overrides.baseURI, null, null);
493 }
495 for (let item in overrides) {
496 defaults[item] = overrides[item];
497 }
498 return defaults;
499 }
500 };