UNPKG

@ordojs/core

Version:

Core compiler and runtime for OrdoJS framework

463 lines 17.9 kB
/** * @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