solid-styles
Version:
Zero-runtime styled components for SolidJS with Lightning CSS optimization and spring animations. Production-ready CSS-in-JS with 100% test coverage.
305 lines (256 loc) • 9.06 kB
text/typescript
/**
* Lightning CSS Generator Module
*
* Generates static CSS classes for prop combinations
*/
import type { ComponentMetadata, ExtractedStyle, PropPattern } from "../types";
/**
* Generate static CSS for all prop combinations
*/
export function generateStaticCSS(component: ComponentMetadata, maxCombinations: number = 100): ExtractedStyle[] {
const { propPatterns, styles, componentName } = component;
// Skip if component has animations (handled by spring system)
if (component.hasAnimations || component.isAnimated) {
console.log(`[Lightning CSS] Skipping ${componentName}: Uses spring animations`);
return [];
}
// Validate the styles are actually CSS
if (!isValidCSSContent(styles)) {
console.warn(`[Lightning CSS] Skipping ${componentName}: Invalid CSS content detected`);
return [];
}
// Calculate total combinations
const totalCombinations = calculateTotalCombinations(propPatterns);
// Skip if too many combinations
if (totalCombinations > maxCombinations) {
console.warn(
`[Lightning CSS] Component ${componentName} has ${totalCombinations} combinations, ` +
`exceeding limit of ${maxCombinations}. Using runtime styles.`
);
return [];
}
// Skip if no prop patterns (static CSS)
if (propPatterns.length === 0) {
console.log(`[Lightning CSS] Generating static CSS for ${componentName}`);
// Generate a single static style for components with no dynamic props
const className = generateClassName(componentName, {});
const css = styles; // Use the styles as-is since there are no interpolations
if (isValidCSSContent(css)) {
return [
{
className,
css: `.${className} { ${css} }`,
propCombination: {},
componentName: componentName,
hash: generateHash(css),
},
];
} else {
console.warn(`[Lightning CSS] Invalid CSS for static component ${componentName}`);
return [];
}
}
// Generate all combinations
const combinations = generatePropCombinations(propPatterns);
const extractedStyles: ExtractedStyle[] = [];
try {
for (const combination of combinations) {
const className = generateClassName(componentName, combination);
const css = interpolateStyles(styles, combination);
// Validate interpolated CSS
if (isValidCSSContent(css)) {
extractedStyles.push({
className,
css: `.${className} { ${css} }`,
propCombination: combination,
componentName: componentName,
hash: generateHash(css),
});
} else {
console.warn(`[Lightning CSS] Invalid CSS generated for ${componentName} with props:`, combination);
}
}
} catch (error) {
console.error(`[Lightning CSS] Error generating CSS for ${componentName}:`, error);
}
console.log(`[Lightning CSS] Generated ${extractedStyles.length} static styles for ${componentName}`);
return extractedStyles;
}
/**
* Validate CSS content
*/
function isValidCSSContent(content: string): boolean {
const trimmed = content.trim();
// Empty is ok
if (!trimmed) return true;
// Check for JavaScript patterns that shouldn't be in CSS
const invalidPatterns = [
/^(const|let|var|function|class)\s+/,
/^import\s+/,
/^export\s+/,
/}\s*(const|let|var)\s+/,
/;\s*(const|let|var)\s+/,
/^[A-Z][a-zA-Z]*\s*=/, // Component definitions
];
if (invalidPatterns.some((pattern) => pattern.test(trimmed))) {
return false;
}
return true;
}
/**
* Generate a hash for CSS content
*/
function generateHash(content: string): string {
let hash = 0;
for (let i = 0; i < content.length; i++) {
const char = content.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return Math.abs(hash).toString(36);
}
/**
* Calculate total number of combinations
*/
function calculateTotalCombinations(patterns: PropPattern[]): number {
if (patterns.length === 0) return 1;
return patterns.reduce((total, pattern) => {
const count = pattern.values.length || 1;
return total * count;
}, 1);
}
/**
* Generate all possible prop combinations
*/
function generatePropCombinations(patterns: PropPattern[]): Record<string, any>[] {
if (patterns.length === 0) return [{}];
const combinations: Record<string, any>[] = [];
// Recursive function to generate combinations
function generateCombination(index: number, current: Record<string, any>): void {
if (index === patterns.length) {
combinations.push({ ...current });
return;
}
const pattern = patterns[index];
const values = pattern.values.length > 0 ? pattern.values : [pattern.defaultValue];
for (const value of values) {
current[pattern.propName] = value;
generateCombination(index + 1, current);
}
}
generateCombination(0, {});
return combinations;
}
/**
* Generate a unique class name for a prop combination
*/
function generateClassName(componentName: string, combination: Record<string, any>): string {
// Create a deterministic hash from the combination
const hash = Object.entries(combination)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}-${value}`)
.join("_");
// Generate class name
const baseName = componentName.toLowerCase();
return hash ? `${baseName}_${hash}` : baseName;
}
/**
* Interpolate styles with prop values
*/
function interpolateStyles(styles: string, props: Record<string, any>): string {
// Replace template literal interpolations
return styles.replace(/\$\{([^}]+)\}/g, (match, expression) => {
try {
// Parse the expression
const value = evaluateExpression(expression.trim(), props);
return value !== undefined ? String(value) : match;
} catch (error) {
console.warn(`Failed to evaluate expression: ${expression}`);
return match;
}
});
}
/**
* Safely evaluate a prop expression
*/
function evaluateExpression(expression: string, props: Record<string, any>): any {
// Handle arrow function expressions
if (expression.includes("=>")) {
// Extract the function body
const arrowMatch = expression.match(/props\s*=>\s*(.+)/);
if (arrowMatch) {
expression = arrowMatch[1];
}
}
// Handle ternary operators with boolean props (no quotes around prop value)
const ternaryBooleanPropMatch = expression.match(/props\.(\w+)\s*\?\s*['"]([^'"]+)['"]\s*:\s*['"]([^'"]+)['"]/);
if (ternaryBooleanPropMatch) {
const propName = ternaryBooleanPropMatch[1];
const trueValue = ternaryBooleanPropMatch[2];
const falseValue = ternaryBooleanPropMatch[3];
// Evaluate the boolean prop value
return props[propName] ? trueValue : falseValue;
}
// Handle ternary operators with quoted strings
const ternaryQuotedMatch = expression.match(
/props\.(\w+)\s*===?\s*['"]([^'"]+)['"]\s*\?\s*['"]([^'"]+)['"]\s*:\s*['"]([^'"]+)['"]/
);
if (ternaryQuotedMatch) {
const propName = ternaryQuotedMatch[1];
const testValue = ternaryQuotedMatch[2];
const trueValue = ternaryQuotedMatch[3];
const falseValue = ternaryQuotedMatch[4];
return props[propName] === testValue ? trueValue : falseValue;
}
// Handle ternary operators with unquoted values (like numbers)
const ternaryUnquotedMatch = expression.match(/props\.(\w+)\s*\?\s*([^:]+?)\s*:\s*(.+)/);
if (ternaryUnquotedMatch) {
const propName = ternaryUnquotedMatch[1];
const trueValue = ternaryUnquotedMatch[2].trim();
const falseValue = ternaryUnquotedMatch[3].trim();
// Remove quotes if present
const cleanValue = (val: string) => {
if ((val.startsWith("'") && val.endsWith("'")) || (val.startsWith('"') && val.endsWith('"'))) {
return val.slice(1, -1);
}
return val;
};
return props[propName] ? cleanValue(trueValue) : cleanValue(falseValue);
}
// Handle default values
const defaultMatch = expression.match(/props\.(\w+)\s*\|\|\s*['"]([^'"]+)['"]/);
if (defaultMatch) {
const propName = defaultMatch[1];
const defaultValue = defaultMatch[2];
return props[propName] || defaultValue;
}
// Handle simple prop access
const propMatch = expression.match(/props\.(\w+)/);
if (propMatch) {
return props[propMatch[1]];
}
// Handle theme access
const themeMatch = expression.match(/props\.theme\.(\w+)\.(\w+)/);
if (themeMatch && props.theme) {
const category = themeMatch[1];
const property = themeMatch[2];
return props.theme[category]?.[property];
}
return undefined;
}
/**
* Merge duplicate CSS rules
*/
export function mergeDuplicateRules(styles: ExtractedStyle[]): ExtractedStyle[] {
const cssMap = new Map<string, ExtractedStyle>();
for (const style of styles) {
const existingStyle = cssMap.get(style.css);
if (existingStyle) {
// Merge class names
existingStyle.className += `, .${style.className}`;
} else {
cssMap.set(style.css, { ...style });
}
}
return Array.from(cssMap.values());
}