Skip to content

Instantly share code, notes, and snippets.

@jonbretman
Last active August 29, 2015 13:56
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jonbretman/8828909 to your computer and use it in GitHub Desktop.
Save jonbretman/8828909 to your computer and use it in GitHub Desktop.
Look for bad CSS
/**
* CSSLinter Class
* @param {Object} options
* @constructor
*/
var CSSLinter = function (options) {
var css = require('css');
var fs = require('fs');
this.file = options.file;
this.cssText = fs.readFileSync(this.file, 'utf8');
this.stylesheet = css.parse(this.cssText).stylesheet;
this.selectors = {};
this.selectorsArray = [];
this.mediaQueries = {};
this.mediaQueryArray = [];
this.classes = {};
this.classesArray = [];
this.ids = {};
this.idsArray = [];
this.properties = {};
this.emptyRules = [];
this.blocks = [];
this.processStylesheet();
this.output();
};
CSSLinter.prototype = {
/**
* Process a stylesheet object returned from css.parse()
*/
processStylesheet: function () {
this.stylesheet.rules.forEach(this.processRule.bind(this));
this.selectorsArray = this.createSortedArray(this.selectors);
this.classesArray = this.createSortedArray(this.classes);
this.idsArray = this.createSortedArray(this.ids);
this.mediaQueryArray = this.createSortedArray(this.mediaQueries);
},
/**
* Process a single rule, could be a rule or a media query
* @param {Object} rule
*/
processRule: function (rule) {
if (rule.type === 'rule') {
// parse selectors
rule.selectors.forEach(this.processSelector.bind(this));
// detect empty rules
if (!this.keyValueFilter(rule.declarations, 'type', 'declaration').length) {
this.emptyRules.push(rule.selectors);
}
else {
rule.declarations.forEach(this.processProperty.bind(this));
this.blocks.push({
declarations: rule.declarations,
selectors: rule.selectors
});
}
}
// handle media query
if (rule.type === 'media') {
this.mediaQueries[rule.media] = this.increment(this.mediaQueries[rule.media]);
rule.rules.forEach(this.processRule.bind(this));
}
},
processSelector: function (selector) {
this.selectors[selector] = this.increment(this.selectors[selector]);
// classes
var match = selector.match(/\.[a-zA-Z0-9_\-]+/g);
if (match) {
match.forEach(function (c) {
this.classes[c] = this.increment(this.classes[c]);
}.bind(this));
}
// ids
match = selector.match(/#[A-Za-z0-9_\-]+/g);
if (match) {
match.forEach(function (id) {
this.ids[id] = this.increment(this.ids[id]);
}.bind(this));
}
},
processProperty: function (property) {
var key = property.property + ':' + property.value;
this.properties[key] = this.increment(this.properties[key]);
},
increment: function (n) {
return n ? n + 1 : 1;
},
/**
* Return an array containing the longest selectors.
* @returns {Array[]}
*/
getLongSelectors: function () {
// calculate average selector length
var averageLength = this.selectorsArray.reduce(function (length, selector) {
return length + selector.length;
}, 0) / this.selectorsArray.length;
// return array of all selectors that are longer than the average
return this.selectorsArray.filter(function (selector) {
return selector.length > averageLength;
});
},
/**
* Returns array of selectors sorted by their efficiency
* @returns {Array[]}
*/
getInefficientSelectors: function () {
return this.selectorsArray.map(function (selector) {
// initial score
var score = 1;
// number of parts in the selector has quite low weight
score = score * ((selector.split(' ').length / 5) + 1);
// the number of different classes have a heigher weight
if (selector.match(/\.[a-zA-Z0-9_\-]+/g)) {
score = score * ((selector.match(/\.[a-zA-Z0-9_\-]+/g).length / 3) + 1);
}
// the number of ids have the highest weight
if (selector.match(/#[a-zA-Z0-9_\-]+/g)) {
score = score * selector.match(/#[a-zA-Z0-9_\-]+/g).length;
}
// weight is multiplies by the length of the selector
score = score * selector.length;
return {
selector: selector,
score: score
};
}).sort(function (a, b) {
return b.score - a.score;
}).map(function (obj) {
return obj.selector + ' (score ' + obj.score + ')';
});
},
getCommonBlocks: function () {
return this.blocks.map(function (block) {
block.matches = this.blocks.filter(function (comparison) {
var count = 0;
for (var i = 0; i < block.declarations.length; i++) {
for (var j = 0; j < comparison.declarations.length; j++) {
if (block.declarations[i].property === comparison.declarations[j].property &&
block.declarations[i].value === comparison.declarations[j].value) {
count++;
}
}
}
return count >= 5;
}.bind(this)).map(function (block) {
return {
properties: block.declarations.map(function (d) {
return [d.property, d.value];
}),
selectors: block.selectors
}
});
return block;
}.bind(this)).sort(function (a, b) {
return b.matches.length - a.matches.length;
}).filter(function (block) {
return block.matches.length > 0;
});
},
/**
* Creates an array that is sorted based on a score derived from
* the length of the keys multiplied by the number of times they occured
* @param map
* @returns {Array}
*/
createSortedArray: function (map) {
return Object.keys(map).map(function (value) {
return {
selector: value,
score: value.length * map[value]
};
}).sort(function (a, b) {
return b.score - a.score;
}).map(function (obj) {
return obj.selector;
});
},
/**
* Returns an array where number of times each item is used is added to the end.
*/
addUsedByCount: function (sortedArray, map) {
return sortedArray.map(function (value) {
return value + ' (used ' + map[value] + ' times)';
}.bind(this));
},
/**
*
* @param {Object[]} arr
* @param {String} key
* @param {String} value
* @returns {Object[]}
*/
keyValueFilter: function (arr, key, value) {
return arr.filter(function (obj) {
return obj[key] === value;
});
},
/**
* Outputs the results.
*/
output: function () {
if (this.cmdOption('props')) {
console.log(this.addUsedByCount(this.createSortedArray(this.properties), this.properties).splice(0, 20));
}
if (this.cmdOption('common')) {
console.log(JSON.stringify(this.getCommonBlocks()));
}
else {
this.printList('Long Selectors (eg. above average length)', this.addUsedByCount(this.getLongSelectors(), this.selectors));
this.printList('Inefficient Selectors', this.getInefficientSelectors());
this.printList('Most Used Classes', this.addUsedByCount(this.classesArray, this.classes));
this.printList('Most Used ID\'s', this.addUsedByCount(this.idsArray, this.ids));
this.printList('Media Queries', this.addUsedByCount(this.mediaQueryArray, this.mediaQueries));
this.printList('Empty Rules', this.emptyRules);
this.printList('Most Used Properties', this.addUsedByCount(this.createSortedArray(this.properties), this.properties));
}
},
/**
* Prints an ordered list with a title.
* @param title
* @param items
*/
printList: function (title, items) {
console.log('');
console.log(title + ':');
for (var i = 0; i < Math.min(items.length, 20); i++) {
console.log(' ' + (i + 1 < 10 ? ' ' + (i + 1) : i + 1) + ': ' + items[i]);
}
if (items.length > 20) {
console.log(' + ' + (items.length - 20) + ' more');
}
console.log('');
},
cmdOption: function (option) {
return process.argv.indexOf('-' + option) !== -1;
}
};
module.exports = CSSLinter;
new CSSLinter({
file: 'media/css/lyst.css'
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment