Skip to content

Instantly share code, notes, and snippets.

@victor-homyakov
Last active February 17, 2023 18:36
Show Gist options
  • Star 34 You must be signed in to star a gist
  • Fork 9 You must be signed in to fork a gist
  • Save victor-homyakov/aefd2ed05a050b1068c667d829a17419 to your computer and use it in GitHub Desktop.
Save victor-homyakov/aefd2ed05a050b1068c667d829a17419 to your computer and use it in GitHub Desktop.
Detect unused CSS selectors. Show possible CSS duplicates. Monitor realtime CSS usage.
/* eslint-disable no-var,no-console */
// detect unused CSS selectors
(function() {
var parsedRules = parseCssRules();
console.log('Parsed CSS rules:', parsedRules);
detectDuplicateSelectors(parsedRules);
var selectorsToTrack = getSelectorsToTrack(parsedRules);
window.selectorStats = { unused: [], added: [], removed: [] };
console.log('Tracking style usage (inspect window.selectorStats for details)...');
setInterval(function() {
var newSelectors = getSelectorsToTrack(parseCssRules());
// Calculation order for removed/added/unused is significant
var removed = Object.keys(selectorsToTrack)
.filter(selector => newSelectors[selector] === undefined);
var added = Object.keys(newSelectors)
.filter(selector => {
if (selectorsToTrack[selector] === undefined) {
selectorsToTrack[selector] = 0;
return true;
}
return false;
});
var unused = Object.keys(selectorsToTrack)
.filter(selector => {
if (document.querySelector(selector)) {
selectorsToTrack[selector]++;
}
return selectorsToTrack[selector] === 0;
});
var message = [];
if (unused.length !== window.selectorStats.unused.length) {
message.push(unused.length + ' unused');
}
window.selectorStats.unused = unused;
if (added.length > 0) {
message.push(added.length + ' added');
window.selectorStats.added = added;
}
if (removed.length > 0) {
message.push(removed.length + ' removed', removed);
window.selectorStats.removed = removed;
}
if (message.length > 0) {
console.log('Selectors: ' + message.join(', '));
}
}, 1000);
function parseCssRules() {
var styleSheets = document.styleSheets,
parsedRules = {
fontFaces: [],
keyframes: [],
media: [],
style: [],
support: [],
unknown: []
};
for (var i = 0; i < styleSheets.length; i++) {
var styleSheet = styleSheets[i];
var rules;
try {
rules = styleSheet.cssRules; // styleSheet.rules
} catch (e) {
if (styleSheet.ignored) {
continue;
}
console.log(e.name + ' while accessing style sheet', styleSheet.ownerNode);
styleSheet.ignored = true;
if (e.name === 'SecurityError') {
// Security error when accessing cross-origin style sheet.
// Possible workaround if we want to analyze content: fetch styleSheet.href
// (will anyways have problems with relative urls and @import).
// https://discourse.mozilla.org/t/webextensions-porting-access-to-cross-origin-document-stylesheets-cssrules/18359
// Appended style sheet will be discovered in the next iteration
loadStyleSheet(styleSheet.href, styleSheet.ownerNode);
}
continue;
}
for (var j = 0; j < rules.length; j++) {
var rule = rules[j];
var ruleClass = Object.prototype.toString.call(rule).replace(/\[object (.+)]/, '$1');
switch (ruleClass) {
case 'CSSFontFaceRule':
parsedRules.fontFaces.push(rule.cssText);
break;
case 'CSSKeyframesRule':
parsedRules.keyframes.push(rule.cssText);
break;
case 'CSSMediaRule':
// if (rule.conditionText)
parsedRules.media.push(rule.conditionText);
break;
case 'CSSStyleRule':
// if (rule.selectorText)
parsedRules.style.push(rule.selectorText);
// rule.cssText
break;
case 'CSSSupportsRule':
parsedRules.support.push(rule.conditionText);
break;
default:
parsedRules.unknown.push(rule);
}
}
}
return parsedRules;
}
function loadStyleSheet(href, node) {
// node.parentNode.removeChild(node);
fetch(href).then(response => response.text()).then(css => {
var style = document.createElement('style');
// style.innerText = css; inserts line breaks as `<br>`
style.innerHTML = css;
// Insert before the original style sheet.
// This way broken relative URLs will be fixed by the original rules.
node.parentNode.insertBefore(style, node);
});
}
function detectDuplicateSelectors(parsedRules) {
var seenSelectors = {},
duplicatedSelectors = [],
duplicatedSequence = [];
parsedRules.style.forEach(function(selector) {
if (selector in seenSelectors) {
duplicatedSelectors.push(selector);
duplicatedSequence.push(selector);
} else {
seenSelectors[selector] = true;
if (duplicatedSequence.length > 5) {
console.warn('Duplicated sequence of selectors:', duplicatedSequence);
}
duplicatedSequence = [];
}
});
if (duplicatedSelectors.length > 0) {
console.log('List of all duplicated selectors:', duplicatedSelectors);
}
}
function getSelectorsToTrack(parsedRules) {
return parsedRules.style
.filter(function(selector) {
return !(
selector === 'html' ||
selector.includes(':hover') ||
selector.includes('::after') ||
selector.includes('::before')
);
})
.reduce(function(selectors, selector) {
selectors[selector] = 0;
return selectors;
}, {});
}
}());
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment