@fe-fast/unused-css-pruner
Version:
A powerful CSS pruning tool that removes unused styles with support for dynamic class names, CSS-in-JS, and component-level analysis
207 lines • 7.42 kB
JavaScript
import { parse, walk, generate } from 'css-tree';
import * as fs from 'fs';
export class CSSParser {
/**
* Parse CSS files and extract all rules
*/
async parseCSSFiles(cssFiles) {
const rules = [];
for (const file of cssFiles) {
try {
const content = fs.readFileSync(file, 'utf-8');
const fileRules = await this.parseCSSContent(content, file);
rules.push(...fileRules);
}
catch (error) {
console.warn(`Warning: Could not parse CSS file ${file}:`, error);
}
}
return rules;
}
/**
* Parse CSS content and extract rules
*/
async parseCSSContent(content, filePath) {
const rules = [];
try {
const ast = parse(content, {
positions: true,
filename: filePath
});
walk(ast, (node, item, list) => {
if (node.type === 'Rule') {
const selectors = this.extractSelectors(node);
const properties = this.extractProperties(node);
const position = node.loc;
for (const selector of selectors) {
rules.push({
selector: selector.trim(),
properties,
file: filePath,
line: position?.start.line || 0,
column: position?.start.column || 0,
size: this.calculateRuleSize(selector, properties)
});
}
}
// Handle @media rules
if (node.type === 'Atrule' && node.name === 'media') {
const mediaQuery = generate(node.prelude);
this.parseMediaRules(node, filePath, mediaQuery, rules);
}
// Handle @keyframes rules
if (node.type === 'Atrule' && node.name === 'keyframes') {
const keyframeName = generate(node.prelude);
this.parseKeyframeRules(node, filePath, keyframeName, rules);
}
});
}
catch (error) {
console.warn(`Warning: Could not parse CSS content in ${filePath}:`, error);
}
return rules;
}
/**
* Extract selectors from a CSS rule node
*/
extractSelectors(ruleNode) {
const selectors = [];
if (ruleNode.prelude && ruleNode.prelude.type === 'SelectorList') {
ruleNode.prelude.children.forEach((selector) => {
const selectorText = generate(selector);
selectors.push(selectorText);
});
}
return selectors;
}
/**
* Extract properties from a CSS rule node
*/
extractProperties(ruleNode) {
if (ruleNode.block && ruleNode.block.type === 'Block') {
return generate(ruleNode.block);
}
return '';
}
/**
* Parse rules inside @media queries
*/
parseMediaRules(mediaNode, filePath, mediaQuery, rules) {
if (mediaNode.block && mediaNode.block.type === 'Block') {
walk(mediaNode.block, (node) => {
if (node.type === 'Rule') {
const selectors = this.extractSelectors(node);
const properties = this.extractProperties(node);
const position = node.loc;
for (const selector of selectors) {
rules.push({
selector: selector.trim(),
properties,
file: filePath,
line: position?.start.line || 0,
column: position?.start.column || 0,
size: this.calculateRuleSize(selector, properties),
mediaQuery
});
}
}
});
}
}
/**
* Parse rules inside @keyframes
*/
parseKeyframeRules(keyframeNode, filePath, keyframeName, rules) {
// Don't extract individual keyframe rules (0%, 50%, etc.) as they are not selectors
// that can be independently removed. The entire @keyframes rule should be treated as a unit.
// This prevents keyframe percentage rules from being incorrectly identified as unused selectors.
// Instead, we could add the @keyframes rule itself as a special rule type
const position = keyframeNode.loc;
const keyframeRule = `@keyframes ${keyframeName}`;
rules.push({
selector: keyframeRule,
properties: keyframeNode.block ? generate(keyframeNode.block) : '',
file: filePath,
line: position?.start.line || 0,
column: position?.start.column || 0,
size: this.calculateRuleSize(keyframeRule, keyframeNode.block ? generate(keyframeNode.block) : ''),
keyframe: keyframeName
});
}
/**
* Calculate the size of a CSS rule in bytes
*/
calculateRuleSize(selector, properties) {
return Buffer.byteLength(`${selector}${properties}`, 'utf8');
}
/**
* Extract all class names from CSS selectors
*/
extractClassNames(rules) {
const classNames = new Set();
for (const rule of rules) {
const classes = this.extractClassNamesFromSelector(rule.selector);
classes.forEach(className => classNames.add(className));
}
return classNames;
}
/**
* Extract class names from a single CSS selector
*/
extractClassNamesFromSelector(selector) {
const classNames = [];
// Match class selectors (.class-name)
const classRegex = /\.([a-zA-Z_-][a-zA-Z0-9_-]*)/g;
let match;
while ((match = classRegex.exec(selector)) !== null) {
classNames.push(match[1]);
}
return classNames;
}
/**
* Extract all ID names from CSS selectors
*/
extractIdNames(rules) {
const idNames = new Set();
for (const rule of rules) {
const ids = this.extractIdNamesFromSelector(rule.selector);
ids.forEach(idName => idNames.add(idName));
}
return idNames;
}
/**
* Extract ID names from a single CSS selector
*/
extractIdNamesFromSelector(selector) {
const idNames = [];
// Match ID selectors (#id-name)
const idRegex = /#([a-zA-Z_-][a-zA-Z0-9_-]*)/g;
let match;
while ((match = idRegex.exec(selector)) !== null) {
idNames.push(match[1]);
}
return idNames;
}
/**
* Check if a selector is a pseudo-class or pseudo-element
*/
isPseudoSelector(selector) {
return /::?[a-zA-Z-]+/.test(selector);
}
/**
* Check if a selector contains attribute selectors
*/
hasAttributeSelector(selector) {
return /\[[^\]]+\]/.test(selector);
}
/**
* Normalize selector for comparison
*/
normalizeSelector(selector) {
return selector
.replace(/\s+/g, ' ')
.replace(/\s*([>+~])\s*/g, '$1')
.trim();
}
}
//# sourceMappingURL=css-parser.js.map