@ordojs/core
Version:
Core compiler and runtime for OrdoJS framework
463 lines • 17.9 kB
JavaScript
/**
* @fileoverview CSS Optimization Engine for OrdoJS Framework
* Handles CSS dead code elimination, minification, and optimization
*/
import {} from '../types/index.js';
/**
* Default CSS optimization options
*/
const DEFAULT_CSS_OPTIMIZATION_OPTIONS = {
removeUnusedRules: true,
minify: true,
mergeDuplicateRules: true,
removeRedundantDeclarations: true,
optimizeShorthands: true,
removeEmptyRules: true,
sortDeclarations: true,
preserveImportantComments: false
};
/**
* CSS Optimizer for OrdoJS components
*/
export class OrdoJSCSSOptimizer {
options;
constructor(options = {}) {
this.options = { ...DEFAULT_CSS_OPTIMIZATION_OPTIONS, ...options };
}
/**
* Optimize CSS styles for a component
*/
optimize(styleBlock, componentAST) {
const originalCSS = this.generateCSS(styleBlock);
const originalSize = originalCSS.length;
let optimizedRules = [...styleBlock.rules];
let removedRules = 0;
// Perform dead code elimination if component AST is provided
if (componentAST && this.options.removeUnusedRules) {
const usageAnalysis = this.analyzeUsage(componentAST);
const beforeCount = optimizedRules.length;
optimizedRules = this.removeUnusedRules(optimizedRules, usageAnalysis);
removedRules += beforeCount - optimizedRules.length;
}
// Remove empty rules
if (this.options.removeEmptyRules) {
const beforeCount = optimizedRules.length;
optimizedRules = this.removeEmptyRules(optimizedRules);
removedRules += beforeCount - optimizedRules.length;
}
// Merge duplicate rules
let mergedRules = 0;
if (this.options.mergeDuplicateRules) {
const result = this.mergeDuplicateRules(optimizedRules);
optimizedRules = result.rules;
mergedRules = result.mergedCount;
}
// Remove redundant declarations
if (this.options.removeRedundantDeclarations) {
optimizedRules = this.removeRedundantDeclarations(optimizedRules);
}
// Optimize shorthand properties
let optimizedDeclarations = 0;
if (this.options.optimizeShorthands) {
const result = this.optimizeShorthands(optimizedRules);
optimizedRules = result.rules;
optimizedDeclarations = result.optimizedCount;
}
// Sort declarations for better compression
if (this.options.sortDeclarations) {
optimizedRules = this.sortDeclarations(optimizedRules);
}
// Generate optimized CSS
const optimizedStyleBlock = {
...styleBlock,
rules: optimizedRules
};
const optimizedCSS = this.options.minify
? this.minifyCSS(optimizedStyleBlock)
: this.generateCSS(optimizedStyleBlock);
const optimizedSize = optimizedCSS.length;
const compressionRatio = originalSize > 0 ? (originalSize - optimizedSize) / originalSize : 0;
return {
optimizedCSS,
originalSize,
optimizedSize,
compressionRatio,
removedRules,
mergedRules,
optimizedDeclarations
};
}
/**
* Analyze CSS usage in component AST
*/
analyzeUsage(componentAST) {
const analysis = {
usedSelectors: new Set(),
usedClasses: new Set(),
usedIds: new Set(),
usedElements: new Set(),
usedPseudoClasses: new Set(),
usedPseudoElements: new Set()
};
// Analyze markup block for used elements and classes
if (componentAST.component.markupBlock) {
this.analyzeMarkupUsage(componentAST.component.markupBlock, analysis);
}
// Analyze client block for dynamic class usage
if (componentAST.component.clientBlock) {
this.analyzeClientUsage(componentAST.component.clientBlock, analysis);
}
return analysis;
}
/**
* Analyze markup block for CSS usage
*/
analyzeMarkupUsage(markupBlock, analysis) {
// Recursively analyze HTML elements
const analyzeElement = (element) => {
if (element.type === 'HTMLElement') {
// Add element tag name
analysis.usedElements.add(element.tagName.toLowerCase());
// Analyze attributes for classes and IDs
element.attributes?.forEach((attr) => {
if (attr.name === 'class') {
const classes = typeof attr.value === 'string'
? attr.value.split(/\s+/).filter(Boolean)
: [];
classes.forEach((cls) => analysis.usedClasses.add(cls));
}
else if (attr.name === 'id') {
if (typeof attr.value === 'string') {
analysis.usedIds.add(attr.value);
}
}
});
// Recursively analyze children
element.children?.forEach(analyzeElement);
}
};
markupBlock.elements?.forEach(analyzeElement);
}
/**
* Analyze client block for dynamic CSS usage
*/
analyzeClientUsage(clientBlock, analysis) {
// This is a simplified analysis - in a real implementation,
// we would need to analyze JavaScript expressions for dynamic class usage
// For now, we'll be conservative and not remove any rules when dynamic usage is detected
}
/**
* Remove unused CSS rules based on usage analysis
*/
removeUnusedRules(rules, usage) {
return rules.filter(rule => this.isRuleUsed(rule, usage));
}
/**
* Check if a CSS rule is used based on usage analysis
*/
isRuleUsed(rule, usage) {
const selector = rule.selector.trim();
// Always keep global selectors and pseudo-selectors
if (selector.includes(':root') || selector.includes('html') || selector.includes('body')) {
return true;
}
// Parse selector to check for used elements, classes, and IDs
const selectorParts = this.parseSelector(selector);
// For complex selectors (with multiple parts), ALL parts must be used
// For simple selectors, ANY part being used is sufficient
if (selectorParts.length > 1) {
// Complex selector - all parts must be used
return selectorParts.every(part => this.isPartUsed(part, usage));
}
else {
// Simple selector - any part being used is sufficient
return selectorParts.some(part => this.isPartUsed(part, usage));
}
}
/**
* Check if a selector part is used
*/
isPartUsed(part, usage) {
// Check element selectors
if (part.element && usage.usedElements.has(part.element)) {
return true;
}
// Check class selectors
if (part.classes.some(cls => usage.usedClasses.has(cls))) {
return true;
}
// Check ID selectors
if (part.ids.some(id => usage.usedIds.has(id))) {
return true;
}
// Keep pseudo-class and pseudo-element selectors if base is used
if (part.pseudoClasses.length > 0 || part.pseudoElements.length > 0) {
// For pseudo-selectors, check if the base element or class is used
if (part.element && usage.usedElements.has(part.element)) {
return true;
}
if (part.classes.some(cls => usage.usedClasses.has(cls))) {
return true;
}
if (part.ids.some(id => usage.usedIds.has(id))) {
return true;
}
}
return false;
}
/**
* Parse CSS selector into components
*/
parseSelector(selector) {
// Split by combinators and parse each part
const parts = selector.split(/\s*[>+~]\s*|\s+/);
return parts.map(part => {
const result = {
element: undefined,
classes: [],
ids: [],
pseudoClasses: [],
pseudoElements: []
};
const trimmedPart = part.trim();
// Skip empty parts
if (!trimmedPart) {
return result;
}
// Extract pseudo-elements first (they have :: prefix)
const pseudoElementMatches = trimmedPart.match(/::([a-zA-Z0-9_-]+)/g);
if (pseudoElementMatches) {
result.pseudoElements = pseudoElementMatches.map(match => match.substring(2));
}
// Extract pseudo-classes (single : prefix, but not ::)
const pseudoClassMatches = trimmedPart.match(/:(?!:)([a-zA-Z0-9_-]+)/g);
if (pseudoClassMatches) {
result.pseudoClasses = pseudoClassMatches.map(match => match.substring(1));
}
// Extract classes
const classMatches = trimmedPart.match(/\.([a-zA-Z0-9_-]+)/g);
if (classMatches) {
result.classes = classMatches.map(match => match.substring(1));
}
// Extract IDs
const idMatches = trimmedPart.match(/#([a-zA-Z0-9_-]+)/g);
if (idMatches) {
result.ids = idMatches.map(match => match.substring(1));
}
// Extract element (what's left after removing classes, IDs, and pseudo-selectors)
let elementPart = trimmedPart.replace(/[.#][a-zA-Z0-9_-]+|::?[a-zA-Z0-9_-]+/g, '').trim();
if (elementPart && elementPart !== '*') {
result.element = elementPart;
}
return result;
});
}
/**
* Remove empty CSS rules
*/
removeEmptyRules(rules) {
return rules.filter(rule => rule.declarations.length > 0);
}
/**
* Merge duplicate CSS rules
*/
mergeDuplicateRules(rules) {
const selectorMap = new Map();
let mergedCount = 0;
for (const rule of rules) {
const selector = rule.selector;
if (selectorMap.has(selector)) {
// Merge declarations
const existingRule = selectorMap.get(selector);
const mergedDeclarations = [...existingRule.declarations];
// Add new declarations, overriding existing ones with same property
for (const newDecl of rule.declarations) {
const existingIndex = mergedDeclarations.findIndex(decl => decl.property === newDecl.property);
if (existingIndex >= 0) {
mergedDeclarations[existingIndex] = newDecl;
}
else {
mergedDeclarations.push(newDecl);
}
}
selectorMap.set(selector, {
...existingRule,
declarations: mergedDeclarations
});
mergedCount++;
}
else {
selectorMap.set(selector, rule);
}
}
return {
rules: Array.from(selectorMap.values()),
mergedCount
};
}
/**
* Remove redundant CSS declarations
*/
removeRedundantDeclarations(rules) {
return rules.map(rule => {
const seenProperties = new Set();
const uniqueDeclarations = [];
// Keep only the last declaration for each property
for (let i = rule.declarations.length - 1; i >= 0; i--) {
const decl = rule.declarations[i];
if (!seenProperties.has(decl.property)) {
seenProperties.add(decl.property);
uniqueDeclarations.unshift(decl);
}
}
return {
...rule,
declarations: uniqueDeclarations
};
});
}
/**
* Optimize shorthand properties
*/
optimizeShorthands(rules) {
let optimizedCount = 0;
const optimizedRules = rules.map(rule => {
const declarations = [...rule.declarations];
const optimizedDeclarations = [];
// Group related properties for shorthand optimization
const marginProps = new Map();
const paddingProps = new Map();
const borderProps = new Map();
for (const decl of declarations) {
if (decl.property.startsWith('margin-')) {
marginProps.set(decl.property, decl);
}
else if (decl.property.startsWith('padding-')) {
paddingProps.set(decl.property, decl);
}
else if (decl.property.startsWith('border-') && decl.property.endsWith('-width')) {
borderProps.set(decl.property, decl);
}
else {
optimizedDeclarations.push(decl);
}
}
// Optimize margin shorthand
const marginShorthand = this.createShorthand('margin', marginProps);
if (marginShorthand) {
optimizedDeclarations.push(marginShorthand);
optimizedCount++;
}
else {
optimizedDeclarations.push(...marginProps.values());
}
// Optimize padding shorthand
const paddingShorthand = this.createShorthand('padding', paddingProps);
if (paddingShorthand) {
optimizedDeclarations.push(paddingShorthand);
optimizedCount++;
}
else {
optimizedDeclarations.push(...paddingProps.values());
}
// Add border properties as-is (more complex optimization needed)
optimizedDeclarations.push(...borderProps.values());
return {
...rule,
declarations: optimizedDeclarations
};
});
return { rules: optimizedRules, optimizedCount };
}
/**
* Create shorthand property from individual properties
*/
createShorthand(property, props) {
const top = props.get(`${property}-top`);
const right = props.get(`${property}-right`);
const bottom = props.get(`${property}-bottom`);
const left = props.get(`${property}-left`);
// Only create shorthand if all four properties are present
if (!top || !right || !bottom || !left) {
return null;
}
// Create shorthand value
let shorthandValue;
if (top.value === right.value && right.value === bottom.value && bottom.value === left.value) {
// All same: margin: 10px
shorthandValue = top.value;
}
else if (top.value === bottom.value && right.value === left.value) {
// Vertical/horizontal: margin: 10px 20px
shorthandValue = `${top.value} ${right.value}`;
}
else if (right.value === left.value) {
// Top/horizontal/bottom: margin: 10px 20px 30px
shorthandValue = `${top.value} ${right.value} ${bottom.value}`;
}
else {
// All different: margin: 10px 20px 30px 40px
shorthandValue = `${top.value} ${right.value} ${bottom.value} ${left.value}`;
}
return {
type: 'CSSDeclaration',
property,
value: shorthandValue,
important: false,
range: top.range
};
}
/**
* Sort CSS declarations for better compression
*/
sortDeclarations(rules) {
return rules.map(rule => ({
...rule,
declarations: [...rule.declarations].sort((a, b) => a.property.localeCompare(b.property))
}));
}
/**
* Generate CSS string from StyleBlockNode
*/
generateCSS(styleBlock) {
return styleBlock.rules.map(rule => {
const declarations = rule.declarations.map(decl => ` ${decl.property}: ${decl.value};`).join('\n');
return `${rule.selector} {\n${declarations}\n}`;
}).join('\n\n');
}
/**
* Minify CSS output
*/
minifyCSS(styleBlock) {
return styleBlock.rules.map(rule => {
const declarations = rule.declarations.map(decl => `${decl.property}:${decl.value}`).join(';');
return `${rule.selector}{${declarations}}`;
}).join('');
}
/**
* Optimize multiple style blocks and generate a single optimized bundle
*/
optimizeBundle(styleBlocks, componentASTs) {
// Merge all rules from all style blocks
const allRules = [];
styleBlocks.forEach(styleBlock => {
allRules.push(...styleBlock.rules);
});
// Create a combined style block
const combinedStyleBlock = {
type: 'StyleBlock',
rules: allRules,
scoped: false,
range: { start: { line: 1, column: 0, offset: 0 }, end: { line: 1, column: 0, offset: 0 } }
};
// If component ASTs are provided, create combined usage analysis
let combinedAST;
if (componentASTs && componentASTs.length > 0) {
// For simplicity, we'll use the first AST as the base
// In a real implementation, we'd need to merge usage analysis from all components
combinedAST = componentASTs[0];
}
return this.optimize(combinedStyleBlock, combinedAST);
}
}
//# sourceMappingURL=css-optimizer.js.map