UNPKG

@antebudimir/eslint-plugin-vanilla-extract

Version:

Comprehensive ESLint plugin for vanilla-extract with CSS property ordering, style validation, and best practices enforcement. Supports alphabetical, concentric and custom CSS ordering, auto-fixing, and zero-runtime safety.

323 lines (322 loc) 13.2 kB
import * as fs from 'fs'; import * as path from 'path'; import { ValueEvaluator } from './value-evaluator.js'; /** * Analyzes theme contract files to extract token values and their paths */ export class ThemeContractAnalyzer { constructor() { this.contracts = new Map(); this.evaluator = new ValueEvaluator(); } /** * Set rem base for evaluation (default: 16) */ setRemBase(base) { this.evaluator.setRemBase(base); } /** * Load and analyze a theme contract file */ loadThemeContract(filePath, baseDir) { const absolutePath = path.resolve(baseDir, filePath); if (!fs.existsSync(absolutePath)) { return; } try { const content = fs.readFileSync(absolutePath, 'utf-8'); const contract = this.parseThemeContract(content); if (contract) { this.contracts.set(filePath, contract); } } catch { // Silently fail - theme file might not be available during linting } } /** * Parse theme contract from source code */ parseThemeContract(content) { const tokens = new Map(); let variableName = 'theme'; // Try to extract theme variable name from exports // Prefer the second identifier in destructuring like: export const [themeClass, vars] = createTheme(...) const destructureMatch = content.match(/export\s+(?:const|let)\s*\[\s*(\w+)\s*,\s*(\w+)\s*\]\s*=/); if (destructureMatch?.[2]) { variableName = destructureMatch[2]; } else { // Fallback: capture single identifier export const exportMatch = content.match(/export\s+(?:const|let)\s+(\w+)\s*=|export\s+(?:const|let)\s*\[\s*(\w+)\s*\]/); const candidate = exportMatch?.[1] || exportMatch?.[2]; if (candidate) { variableName = candidate; } } // Parse createTheme (one-arg), createTheme(contract, values), createGlobalTheme, or contract definitions const createThemeTwoArg = content.match(/createTheme\s*\(\s*([A-Za-z_$][\w$]*)\s*,\s*({[\s\S]*?})\s*\)/); const themeObjectMatch = content.match(/createTheme\s*\(\s*({[\s\S]*?})\s*\)/); const globalThemeMatch = content.match(/createGlobalTheme\s*\([^,]+,\s*({[\s\S]*?})\s*\)/); const contractMatch = content.match(/createThemeContract\s*\(\s*({[\s\S]*?})\s*\)/); const themeContent = createThemeTwoArg?.[2] || themeObjectMatch?.[1] || globalThemeMatch?.[1] || contractMatch?.[1]; if (!themeContent) { return null; } // If using createTheme(contractIdentifier, values), prefer the contract identifier for variable name (e.g., 'theme') if (createThemeTwoArg?.[1]) { variableName = createThemeTwoArg[1]; } else { // Try to extract theme variable name from exports // Prefer the second identifier in destructuring like: export const [themeClass, vars] = createTheme(...) const destructureMatch = content.match(/export\s+(?:const|let)\s*\[\s*(\w+)\s*,\s*(\w+)\s*\]\s*=/); if (destructureMatch?.[2]) { variableName = destructureMatch[2]; } else { // Fallback: capture single identifier export const exportMatch = content.match(/export\s+(?:const|let)\s+(\w+)\s*=|export\s+(?:const|let)\s*\[\s*(\w+)\s*\]/); const candidate = exportMatch?.[1] || exportMatch?.[2]; if (candidate) { variableName = candidate; } } } // Parse the theme object structure this.parseThemeObject(themeContent, variableName, tokens); return { tokens, variableName }; } /** * Parse theme object and extract token values */ parseThemeObject(content, variableName, tokens, pathPrefix = '') { // Remove comments const cleaned = content.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, ''); // First, extract and process nested objects to avoid matching their contents const objectRegex = /['"]?(\w+)['"]?\s*:\s*{([^{}]*(?:{[^{}]*}[^{}]*)*?)}/g; const nestedObjects = []; let objectMatch; while ((objectMatch = objectRegex.exec(cleaned)) !== null) { const key = objectMatch[1]; const nestedContent = objectMatch[2]; if (key && nestedContent) { nestedObjects.push({ key, content: nestedContent }); } } // Remove nested objects from content to avoid double-matching let contentWithoutNested = cleaned; nestedObjects.forEach(({ content }) => { contentWithoutNested = contentWithoutNested.replace(content, ''); }); // Match key-value pairs - both string literals and expressions // String literals: key: 'value' or key: "value" or key: `value` const stringLiteralRegex = /['"]?(\w+)['"]?\s*:\s*(['"`])(.*?)\2/g; // Expressions: key: rem(16) or key: clsx(...) const expressionRegex = /['"]?(\w+)['"]?\s*:\s*([^,}\n]+?)(?=[,}\n])/g; let match; // First pass: extract string literals (only from content without nested objects) while ((match = stringLiteralRegex.exec(contentWithoutNested)) !== null) { const key = match[1]; const value = match[3]; if (!key || !value) continue; const tokenPath = pathPrefix ? `${pathPrefix}.${key}` : key; const fullPath = `${variableName}.${tokenPath}`; // Determine category based on key name and value const category = this.categorizeToken(tokenPath, value); // Normalize the value const normalizedValue = this.normalizeValue(value); if (normalizedValue) { const existing = tokens.get(normalizedValue) || []; existing.push({ value: normalizedValue, tokenPath: fullPath, category }); tokens.set(normalizedValue, existing); } } // Second pass: extract and evaluate expressions (only from content without nested objects) let exprMatch; while ((exprMatch = expressionRegex.exec(contentWithoutNested)) !== null) { const key = exprMatch[1]; let value = exprMatch[2]; if (!key || !value) continue; value = value.trim(); // Skip if it's a string literal (already processed) if (value.startsWith('"') || value.startsWith("'") || value.startsWith('`')) { continue; } // Skip nested objects if (value.startsWith('{')) { continue; } const tokenPath = pathPrefix ? `${pathPrefix}.${key}` : key; const fullPath = `${variableName}.${tokenPath}`; // Try to evaluate the expression const evaluatedValue = this.evaluator.evaluate(value); if (!evaluatedValue) continue; // Determine category based on key name and evaluated value const category = this.categorizeToken(tokenPath, evaluatedValue); // Normalize the evaluated value const normalizedValue = this.normalizeValue(evaluatedValue); if (normalizedValue) { const existing = tokens.get(normalizedValue) || []; existing.push({ value: normalizedValue, tokenPath: fullPath, category }); tokens.set(normalizedValue, existing); } } // Process nested objects (already extracted earlier) nestedObjects.forEach(({ key, content }) => { const newPrefix = pathPrefix ? `${pathPrefix}.${key}` : key; this.parseThemeObject(content, variableName, tokens, newPrefix); }); } /** * Categorize a token based on its path and value */ categorizeToken(tokenPath, value) { const lowerPath = tokenPath.toLowerCase(); // Check by path if (lowerPath.includes('color') || lowerPath.includes('bg') || lowerPath.includes('background')) { return 'color'; } if (lowerPath.includes('spacing') || lowerPath.includes('space') || lowerPath.includes('gap')) { return 'spacing'; } if (lowerPath.includes('fontsize') || lowerPath.includes('font') && lowerPath.includes('size')) { return 'fontSize'; } if (lowerPath.includes('radius') || lowerPath.includes('radii')) { return 'borderRadius'; } if (lowerPath.includes('borderwidth') || lowerPath.includes('border') && lowerPath.includes('width')) { return 'borderWidth'; } if (lowerPath.includes('shadow')) { return 'shadow'; } if (lowerPath.includes('zindex') || lowerPath.includes('z-index') || lowerPath.includes('z')) { return 'zIndex'; } if (lowerPath.includes('opacity')) { return 'opacity'; } if (lowerPath.includes('fontweight') || lowerPath.includes('font') && lowerPath.includes('weight') || lowerPath.includes('weight')) { return 'fontWeight'; } if (lowerPath.includes('transition') || lowerPath.includes('animation') || lowerPath.includes('duration') || lowerPath.includes('delay')) { return 'transition'; } // Check by value pattern if (this.isColor(value)) { return 'color'; } if (/^\d+(\.\d+)?(px|rem|em)$/.test(value)) { if (/font|size/.test(lowerPath)) { return 'fontSize'; } if (/radius/.test(lowerPath)) { return 'borderRadius'; } if (/border.*width|width/.test(lowerPath)) { return 'borderWidth'; } return 'spacing'; } if (/^-?\d+$/.test(value)) { return 'zIndex'; } if (/^(0?\.\d+|1(\.0+)?)$/.test(value)) { return 'opacity'; } if (/^[1-9]00$/.test(value) || /^(normal|bold|bolder|lighter)$/i.test(value)) { return 'fontWeight'; } if (/^\d+(\.\d+)?(s|ms)$/.test(value) || /(ease|linear|cubic-bezier|steps)/i.test(value)) { return 'transition'; } return 'other'; } /** * Check if a value is a color */ isColor(value) { return /^#[0-9a-f]{3,8}$/i.test(value) || /^rgba?\(/.test(value) || /^hsla?\(/.test(value); } /** * Normalize a value for comparison */ normalizeValue(value) { const trimmed = value.trim(); // Normalize hex colors if (/^#[0-9a-f]{3}$/i.test(trimmed)) { // Expand 3-digit hex to 6-digit const r = trimmed[1]; const g = trimmed[2]; const b = trimmed[3]; return `#${r}${r}${g}${g}${b}${b}`.toLowerCase(); } if (/^#[0-9a-f]{6}$/i.test(trimmed)) { return trimmed.toLowerCase(); } // Normalize RGB/RGBA const rgbMatch = trimmed.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)$/i); if (rgbMatch?.[1] && rgbMatch[2] && rgbMatch[3]) { const r = parseInt(rgbMatch[1]); const g = parseInt(rgbMatch[2]); const b = parseInt(rgbMatch[3]); const a = rgbMatch[4]; if (a && a !== '1') { return `rgba(${r}, ${g}, ${b}, ${a})`; } return `rgb(${r}, ${g}, ${b})`; } // Normalize spacing values const spacingMatch = trimmed.match(/^(\d+(\.\d+)?)(px|rem|em|%)$/); if (spacingMatch) { return trimmed; } // Return as-is for other values return trimmed || null; } /** * Find matching tokens for a given value and category */ findMatchingTokens(value, category) { const normalized = this.normalizeValue(value); if (!normalized) { return []; } const allMatches = []; for (const contract of this.contracts.values()) { const matches = contract.tokens.get(normalized) || []; allMatches.push(...matches); } // Filter by category if specified if (category) { return allMatches.filter((token) => token.category === category); } return allMatches; } /** * Get the primary variable name from loaded contracts */ getVariableName() { const firstContract = Array.from(this.contracts.values())[0]; return firstContract?.variableName || 'theme'; } /** * Check if any contracts are loaded */ hasContracts() { return this.contracts.size > 0; } /** * Clear all loaded contracts */ clear() { this.contracts.clear(); } }