analyze-css
Version:
CSS selectors complexity and performance analyzer
286 lines (226 loc) • 7.99 kB
JavaScript
const analyze = require("."),
cssParser = require("@adobe/css-tools").parse,
debug = require("debug")("analyze-css:analyzer"),
fs = require("fs"),
CSSwhat = require("css-what");
function error(msg, code) {
var err = new Error(msg);
err.code = code;
return err;
}
class CSSAnalyzer {
constructor(options) {
this.options = options;
this.metrics = {};
this.offenders = {};
// init events emitter
this.emitter = new (require("events").EventEmitter)();
this.emitter.setMaxListeners(200);
}
// emit given event
emit(/* eventName, arg1, arg2, ... */) {
//debug('Event %s emitted', arguments[0]);
this.emitter.emit.apply(this.emitter, arguments);
}
// bind to a given event
on(ev, fn) {
this.emitter.on(ev, fn);
}
setMetric(name, value) {
value = value || 0;
//debug('setMetric(%s) = %d', name, value);
this.metrics[name] = value;
}
// increements given metric by given number (default is one)
incrMetric(name, incr /* =1 */) {
var currVal = this.metrics[name] || 0;
incr = incr || 1;
//debug('incrMetric(%s) += %d', name, incr);
this.setMetric(name, currVal + incr);
}
addOffender(metricName, msg, position /* = undefined */) {
if (typeof this.offenders[metricName] === "undefined") {
this.offenders[metricName] = [];
}
this.offenders[metricName].push({
message: msg,
position: position || this.currentPosition,
});
}
setCurrentPosition(position) {
this.currentPosition = position;
}
initRules() {
var debug = require("debug")("analyze-css:rules"),
re = /\.js$/,
rules = [];
// load all rules
rules = fs
.readdirSync(fs.realpathSync(__dirname + "/../rules/"))
// filter out all non *.js files
.filter(function (file) {
return re.test(file);
})
// remove file extensions to get just names
.map(function (file) {
return file.replace(re, "");
});
debug("Rules to be loaded: %s", rules.join(", "));
rules.forEach(function (name) {
var rule = require("./../rules/" + name);
rule(this);
debug('"%s" loaded: %s', name, rule.description);
}, this);
}
fixCss(css) {
// properly handle ; in @import URLs
// see https://github.com/macbre/analyze-css/pull/322
// see https://github.com/reworkcss/css/issues/137
return css.replace(/@import url([^)]+["'])/, (match) => {
return match.replace(/;/g, "%3B");
});
}
parseCss(css) {
var debug = require("debug")("analyze-css:parser");
debug("Going to parse %s kB of CSS", (css.length / 1024).toFixed(2));
if (css.trim() === "") {
return error("Empty CSS was provided", analyze.EXIT_EMPTY_CSS);
}
css = this.fixCss(css);
this.tree = cssParser(css, {
// errors are listed in the parsingErrors property instead of being thrown (#84)
silent: true,
});
debug("CSS parsed");
return true;
}
/**
*
* @param { import("./types").CSSRule[] } rules
*/
parseRules(rules) {
const debug = require("debug")("analyze-css:parseRules");
rules.forEach(function (rule) {
debug("rule: %j", rule);
// store the default current position
//
// it will be used when this.addOffender is called from within the rule
// it can be overridden by providing a "custom" position via a call to this.setCurrentPosition
this.setCurrentPosition(rule.position);
switch (rule.type) {
// {
// "type":"media"
// "media":"screen and (min-width: 1370px)",
// "rules":[{"type":"rule","selectors":["#foo"],"declarations":[]}]
// }
case "media":
this.emit("media", rule.media, rule.rules);
// now run recursively to parse rules within the media query
/* istanbul ignore else */
if (rule.rules) {
this.parseRules(rule.rules);
}
this.emit("mediaEnd", rule.media, rule.rules);
break;
// {
// "type":"rule",
// "selectors":[".ui-header .ui-btn-up-a",".ui-header .ui-btn-hover-a"],
// "declarations":[{"type":"declaration","property":"border","value":"0"},{"type":"declaration","property":"background","value":"none"}]
// }
case "rule":
this.emit("rule", rule);
// analyze each selector and declaration
rule.selectors.forEach(function (selector) {
// https://github.com/fb55/css-what#example
// "#features > div:first-child" will become:
// {"type":"attribute","name":"id","action":"equals","value":"features","namespace":null,"ignoreCase":false}
// {"type":"child"}
// {"type":"tag","name":"div","namespace":null}
// {"type":"pseudo","name":"first-child","data":null}
debug("selector: %s", selector);
let parsedSelector;
try {
parsedSelector = CSSwhat.parse(selector);
} catch (err) {
/**
* > require("css-what").parse('foo { color= red; }');
Uncaught Error: Unmatched selector: { color= red; }
at Object.parse (node_modules/css-what/lib/parse.js:139:15)
*/
debug("selector parsing failed: %s", err.message);
this.emit("error", err);
return;
}
// convert object with keys to array with numeric index
const expressions = [];
for (let i = 0, len = parsedSelector[0].length; i < len; i++) {
expressions.push(parsedSelector[0][i]);
}
debug("selector expressions: %j", expressions);
this.emit("selector", rule, selector, expressions);
expressions.forEach(function (expression) {
this.emit("expression", selector, expression);
}, this);
}, this);
rule.declarations.forEach(function (declaration) {
this.setCurrentPosition(declaration.position);
switch (declaration.type) {
case "declaration":
this.emit(
"declaration",
rule,
declaration.property,
declaration.value,
);
break;
case "comment":
this.emit("comment", declaration.comment);
break;
}
}, this);
break;
// {"type":"comment","comment":" Cached as static-css-r518-9b0f5ab4632defb55d67a1d672aa31bd120f4414 "}
case "comment":
this.emit("comment", rule.comment);
break;
// {"type":"font-face","declarations":[{"type":"declaration","property":"font-family","value":"myFont"...
case "font-face":
this.emit("font-face", rule);
break;
// {"type":"import","import":"url('/css/styles.css')"}
case "import":
// replace encoded semicolon back into ;
// https://github.com/macbre/analyze-css/pull/322
this.emit("import", rule.import.replace(/%3B/g, ";"));
break;
}
}, this);
}
run() {
const stylesheet = this.tree && this.tree.stylesheet,
rules = stylesheet && stylesheet.rules;
this.emit("stylesheet", stylesheet);
this.parseRules(rules);
}
analyze(css) {
var res,
then = Date.now();
this.metrics = {};
this.offenders = {};
// load and init all rules
this.initRules();
// parse CSS
res = this.parseCss(css);
if (res !== true) {
debug("parseCss() returned an error: " + res);
return res;
}
this.emit("css", css);
// now go through parsed CSS tree and emit events for rules
this.run();
this.emit("report");
debug("Completed in %d ms", Date.now() - then);
return true;
}
}
module.exports = CSSAnalyzer;