UNPKG

8.51 kBJavaScriptView Raw
1/**
2 * analyze-css CommonJS module
3 */
4'use strict';
5
6var cssParser = require('css').parse,
7 debug = require('debug')('analyze-css'),
8 fs = require('fs'),
9 path = require('path'),
10 preprocessors = new(require('./preprocessors'))(),
11 slickParse = require('slick').parse,
12 VERSION = require('./../package').version;
13
14function analyzer(css, options, callback) {
15 var res;
16
17 // options can be omitted
18 if (typeof options === 'function') {
19 callback = options;
20 options = {};
21 }
22
23 this.options = options;
24 debug('opts: %j', this.options);
25
26 if (typeof css !== 'string') {
27 callback(this.error('css parameter passed is not a string!', analyzer.EXIT_CSS_PASSED_IS_NOT_STRING), null);
28 return;
29 }
30
31 // preprocess the CSS (issue #3)
32 if (typeof options.preprocessor === 'string') {
33 debug('Using "%s" preprocessor', options.preprocessor);
34
35 var preprocessor = preprocessors.get(options.preprocessor);
36
37 try {
38 css = preprocessor.process(css, options);
39 } catch (ex) {
40 throw new Error('Preprocessing failed: ' + ex);
41 }
42
43 debug('Preprocessing completed');
44 }
45
46 res = this.analyze(css);
47
48 // error handling
49 if (res !== true) {
50 callback(res, null);
51 return;
52 }
53
54 // return the results
55 res = {
56 generator: 'analyze-css v' + VERSION,
57 metrics: this.metrics,
58 };
59
60 // disable offenders output if requested (issue #64)
61 if (options.noOffenders !== true) {
62 res.offenders = this.offenders;
63 }
64
65 callback(null, res);
66}
67
68analyzer.version = VERSION;
69
70// @see https://github.com/macbre/phantomas/issues/664
71analyzer.path = path.normalize(__dirname + '/..');
72analyzer.pathBin = analyzer.path + '/bin/analyze-css.js';
73
74// exit codes
75analyzer.EXIT_NEED_OPTIONS = 2;
76analyzer.EXIT_PARSING_FAILED = 251;
77analyzer.EXIT_EMPTY_CSS = 252;
78analyzer.EXIT_CSS_PASSED_IS_NOT_STRING = 253;
79analyzer.EXIT_URL_LOADING_FAILED = 254;
80analyzer.EXIT_FILE_LOADING_FAILED = 255;
81
82analyzer.prototype = {
83 emitter: false,
84 tree: false,
85
86 metrics: {},
87 offenders: {},
88
89 error: function(msg, code) {
90 var err = new Error(msg);
91 err.code = code;
92
93 return err;
94 },
95
96 // emit given event
97 emit: function( /* eventName, arg1, arg2, ... */ ) {
98 //debug('Event %s emitted', arguments[0]);
99 this.emitter.emit.apply(this.emitter, arguments);
100 },
101
102 // bind to a given event
103 on: function(ev, fn) {
104 this.emitter.on(ev, fn);
105 },
106
107 setMetric: function(name, value) {
108 value = value || 0;
109
110 //debug('setMetric(%s) = %d', name, value);
111 this.metrics[name] = value;
112 },
113
114 // increements given metric by given number (default is one)
115 incrMetric: function(name, incr /* =1 */ ) {
116 var currVal = this.metrics[name] || 0;
117 incr = incr || 1;
118
119 //debug('incrMetric(%s) += %d', name, incr);
120 this.setMetric(name, currVal + incr);
121 },
122
123 addOffender: function(metricName, msg, position /* = undefined */ ) {
124 if (typeof this.offenders[metricName] === 'undefined') {
125 this.offenders[metricName] = [];
126 }
127
128 this.offenders[metricName].push({
129 'message': msg,
130 'position': position || this.currentPosition
131 });
132 },
133
134 setCurrentPosition: function(position) {
135 this.currentPosition = position;
136 },
137
138 initRules: function() {
139 var debug = require('debug')('analyze-css:rules'),
140 re = /\.js$/,
141 rules = [];
142
143 // init events emitter
144 this.emitter = new(require('events').EventEmitter)();
145 this.emitter.setMaxListeners(200);
146
147 // load all rules
148 rules = fs.readdirSync(fs.realpathSync(__dirname + '/../rules/'))
149 // filter out all non *.js files
150 .filter(function(file) {
151 return re.test(file);
152 })
153 // remove file extensions to get just names
154 .map(function(file) {
155 return file.replace(re, '');
156 });
157
158 debug('Rules to be loaded: %s', rules.join(', '));
159
160 rules.forEach(function(name) {
161 var rule = require('./../rules/' + name);
162 rule(this);
163
164 debug('"%s" loaded: %s', name, rule.description || '-');
165 }, this);
166 },
167
168 parseCss: function(css) {
169 var debug = require('debug')('analyze-css:parser');
170 debug('Going to parse %s kB of CSS', (css.length / 1024).toFixed(2));
171
172 if (css.trim() === '') {
173 return this.error('Empty CSS was provided', analyzer.EXIT_EMPTY_CSS);
174 }
175
176 this.tree = cssParser(css, {
177 // errors are listed in the parsingErrors property instead of being thrown (#84)
178 silent: true
179 });
180
181 debug('CSS parsed');
182 return true;
183 },
184
185 parseRules: function(rules) {
186 rules.forEach(function(rule, idx) {
187 debug('%j', rule);
188
189 // store the default current position
190 //
191 // it will be used when this.addOffender is called from within the rule
192 // it can be overridden by providing a "custom" position via a call to this.setCurrentPosition
193 this.setCurrentPosition(rule.position);
194
195 switch (rule.type) {
196 // {
197 // "type":"media"
198 // "media":"screen and (min-width: 1370px)",
199 // "rules":[{"type":"rule","selectors":["#foo"],"declarations":[]}]
200 // }
201 case 'media':
202 this.emit('media', rule.media, rule.rules);
203
204 // now run recursively to parse rules within the media query
205 if (rule.rules) {
206 this.parseRules(rule.rules);
207 }
208
209 this.emit('mediaEnd', rule.media, rule.rules);
210 break;
211
212 // {
213 // "type":"rule",
214 // "selectors":[".ui-header .ui-btn-up-a",".ui-header .ui-btn-hover-a"],
215 // "declarations":[{"type":"declaration","property":"border","value":"0"},{"type":"declaration","property":"background","value":"none"}]
216 // }
217 case 'rule':
218 if (!rule.selectors || !rule.declarations) {
219 return;
220 }
221
222 this.emit('rule', rule);
223
224 // analyze each selector and declaration
225 rule.selectors.forEach(function(selector) {
226 var parsedSelector,
227 expressions = [],
228 i, len;
229
230 // "#features > div:first-child" will become two expressions:
231 // {"combinator":" ","tag":"*","id":"features"}
232 // {"combinator":">","tag":"div","pseudos":[{"key":"first-child","value":null}]}
233 parsedSelector = slickParse(selector)[0];
234
235 if (typeof parsedSelector === 'undefined') {
236 var positionDump = "Rule position start @ " + rule.position.start.line + ':' + rule.position.start.column + ", end @ " + rule.position.end.line + ':' + rule.position.end.column;
237 throw this.error('Unable to parse "' + selector + '" selector. ' + positionDump, analyzer.EXIT_PARSING_FAILED);
238 }
239
240 // convert object with keys to array with numeric index
241 for (i = 0, len = parsedSelector.length; i < len; i++) {
242 expressions.push(parsedSelector[i]);
243 }
244
245 this.emit('selector', rule, selector, expressions);
246
247 expressions.forEach(function(expression) {
248 this.emit('expression', selector, expression);
249 }, this);
250 }, this);
251
252 rule.declarations.forEach(function(declaration) {
253 this.setCurrentPosition(declaration.position);
254
255 switch (declaration.type) {
256 case 'declaration':
257 this.emit('declaration', rule, declaration.property, declaration.value);
258 break;
259
260 case 'comment':
261 this.emit('comment', declaration.comment);
262 break;
263 }
264 }, this);
265 break;
266
267 // {"type":"comment","comment":" Cached as static-css-r518-9b0f5ab4632defb55d67a1d672aa31bd120f4414 "}
268 case 'comment':
269 this.emit('comment', rule.comment);
270 break;
271
272 // {"type":"font-face","declarations":[{"type":"declaration","property":"font-family","value":"myFont"...
273 case 'font-face':
274 this.emit('font-face', rule);
275 break;
276
277 // {"type":"import","import":"url('/css/styles.css')"}
278 case 'import':
279 this.emit('import', rule.import);
280 break;
281 }
282 }, this);
283 },
284
285 run: function() {
286 var stylesheet = this.tree && this.tree.stylesheet,
287 rules = stylesheet && stylesheet.rules;
288
289 this.emit('stylesheet', stylesheet);
290
291 // check for parsing errors (#84)
292 stylesheet.parsingErrors.forEach(function(err) {
293 debug('error: %j', err);
294
295 var pos = {
296 line: err.line,
297 column: err.column
298 };
299 this.setCurrentPosition({
300 start: pos,
301 end: pos
302 });
303
304 this.emit('error', err);
305 }, this);
306
307 this.parseRules(rules);
308 },
309
310 analyze: function(css) {
311 var res,
312 then = Date.now();
313
314 this.metrics = {};
315 this.offenders = {};
316
317 // load and init all rules
318 this.initRules();
319
320 // parse CSS
321 res = this.parseCss(css);
322
323 if (res !== true) {
324 return res;
325 }
326
327 this.emit('css', css);
328
329 // now go through parsed CSS tree and emit events for rules
330 try {
331 this.run();
332 } catch (ex) {
333 return ex;
334 }
335
336 this.emit('report');
337
338 debug('Completed in %d ms', Date.now() - then);
339 return true;
340 }
341};
342
343module.exports = analyzer;