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.

563 lines (562 loc) 23.8 kB
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; import { ValueEvaluator } from './value-evaluator.js'; // Color detection patterns const HEX_COLOR = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i; const RGB_COLOR = /^rgba?\s*\(/i; const HSL_COLOR = /^hsla?\s*\(/i; const NAMED_COLORS = new Set([ 'black', 'white', 'red', 'green', 'blue', 'yellow', 'orange', 'purple', 'pink', 'gray', 'grey', 'brown', 'cyan', 'magenta', 'lime', 'navy', 'teal', 'olive', 'maroon', 'aqua', 'fuchsia', 'silver', 'gold', 'indigo', 'violet', 'tan', ]); // CSS keywords that should be allowed const ALLOWED_KEYWORDS = new Set(['transparent', 'currentcolor', 'inherit', 'initial', 'unset', 'revert']); // Spacing-related properties const SPACING_PROPERTIES = new Set([ 'margin', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'marginBlock', 'marginBlockStart', 'marginBlockEnd', 'marginInline', 'marginInlineStart', 'marginInlineEnd', 'padding', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'paddingBlock', 'paddingBlockStart', 'paddingBlockEnd', 'paddingInline', 'paddingInlineStart', 'paddingInlineEnd', 'gap', 'rowGap', 'columnGap', 'gridGap', 'gridRowGap', 'gridColumnGap', 'inset', 'insetBlock', 'insetBlockStart', 'insetBlockEnd', 'insetInline', 'insetInlineStart', 'insetInlineEnd', 'top', 'right', 'bottom', 'left', 'width', 'height', 'minWidth', 'minHeight', 'maxWidth', 'maxHeight', 'blockSize', 'inlineSize', 'minBlockSize', 'minInlineSize', 'maxBlockSize', 'maxInlineSize', ]); // Font size properties const FONT_SIZE_PROPERTIES = new Set(['fontSize', 'lineHeight']); // Border radius properties const BORDER_RADIUS_PROPERTIES = new Set([ 'borderRadius', 'borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomLeftRadius', 'borderBottomRightRadius', 'borderStartStartRadius', 'borderStartEndRadius', 'borderEndStartRadius', 'borderEndRadius', ]); // Border width properties const BORDER_WIDTH_PROPERTIES = new Set([ 'borderWidth', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'borderBlockWidth', 'borderBlockStartWidth', 'borderBlockEndWidth', 'borderInlineWidth', 'borderInlineStartWidth', 'borderInlineEndWidth', 'outlineWidth', 'columnRuleWidth', // Shorthands that include width 'border', 'borderTop', 'borderRight', 'borderBottom', 'borderLeft', 'borderBlock', 'borderBlockStart', 'borderBlockEnd', 'borderInline', 'borderInlineStart', 'borderInlineEnd', 'outline', ]); // Shadow properties const SHADOW_PROPERTIES = new Set(['boxShadow', 'textShadow', 'filter', 'backdropFilter']); // Z-index property const Z_INDEX_PROPERTIES = new Set(['zIndex']); // Opacity property const OPACITY_PROPERTIES = new Set(['opacity']); // Font weight properties const FONT_WEIGHT_PROPERTIES = new Set(['fontWeight']); // Transition and animation properties const TRANSITION_PROPERTIES = new Set([ 'transition', 'transitionDelay', 'transitionDuration', 'transitionTimingFunction', 'animation', 'animationDelay', 'animationDuration', 'animationTimingFunction', ]); // Color properties const COLOR_PROPERTIES = new Set([ 'color', 'backgroundColor', 'borderColor', 'borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor', 'borderBlockStartColor', 'borderBlockEndColor', 'borderInlineStartColor', 'borderInlineEndColor', 'outlineColor', 'textDecorationColor', 'caretColor', 'columnRuleColor', 'fill', 'stroke', ]); const isHardCodedColor = (value) => { if (ALLOWED_KEYWORDS.has(value.toLowerCase())) { return false; } return (HEX_COLOR.test(value) || RGB_COLOR.test(value) || HSL_COLOR.test(value) || NAMED_COLORS.has(value.toLowerCase())); }; const hasNumericValue = (value) => { // Match numeric values with units (e.g., 10px, 1rem, 2em, 50%, etc.) return /\d+(\.\d+)?(px|rem|em|%|vh|vw|vmin|vmax|ch|ex)/.test(value); }; const hasShadowValue = (value) => { // Match shadow values (e.g., "0 4px 6px rgba(...)", "inset 0 1px 2px #000") // Also matches filter functions like blur(), drop-shadow() return /(\d+px|\d+rem|rgba?\(|hsla?\(|#[0-9a-f]{3,8}|blur\(|drop-shadow\(|brightness\(|contrast\()/.test(value.toLowerCase()); }; const hasZIndexValue = (value) => { // Match numeric z-index values return /^-?\d+$/.test(value.trim()); }; const hasOpacityValue = (value) => { // Match opacity values (0-1 or percentages) return /^(0?\.\d+|1(\.0+)?|\d+%)$/.test(value.trim()); }; const hasFontWeightValue = (value) => { // Match font weight values (numeric or named) const namedWeights = ['normal', 'bold', 'bolder', 'lighter']; const trimmed = value.trim().toLowerCase(); return /^[1-9]00$/.test(trimmed) || namedWeights.includes(trimmed); }; const hasTransitionValue = (value) => { // Match transition/animation values (e.g., "0.3s", "200ms", "ease-in-out", "cubic-bezier(...)") return /(^\d+(\.\d+)?(s|ms)$|ease|linear|cubic-bezier\(|steps\()/.test(value.toLowerCase()); }; const isAllowedValue = (value, allowedValues) => { const trimmed = value.trim(); return (allowedValues.has(trimmed) || allowedValues.has(trimmed.toLowerCase()) || trimmed === '0' || trimmed === 'auto' || trimmed === 'none' || trimmed === 'inherit' || trimmed === 'initial' || trimmed === 'unset' || /^\d+(\.\d+)?%$/.test(trimmed)); // Allow percentages }; const normalizePropertyName = (name) => { // Convert kebab-case to camelCase return name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); }; /** * Recursively processes a vanilla-extract style object and reports hard-coded values * that should use theme tokens instead. */ export const processThemeTokensInStyleObject = (context, node, options, analyzer) => { const { checkColors = true, checkSpacing = true, checkFontSizes = true, checkBorderRadius = true, checkBorderWidths = true, checkShadows = true, checkZIndex = true, checkOpacity = true, checkFontWeights = true, checkTransitions = true, allowedValues = [], allowedProperties = [], autoFix = false, checkHelperFunctions = false, } = options; const evaluator = new ValueEvaluator(); const allowedValuesSet = new Set(allowedValues); const allowedPropertiesSet = new Set([...allowedProperties.map(normalizePropertyName), ...allowedProperties]); for (const property of node.properties) { if (property.type !== AST_NODE_TYPES.Property) continue; // Determine property name let propertyName = null; if (property.key.type === AST_NODE_TYPES.Identifier) { propertyName = property.key.name; } else if (property.key.type === AST_NODE_TYPES.Literal && typeof property.key.value === 'string') { propertyName = property.key.value; } // Recurse into nested containers (@media, selectors, etc.) if (propertyName && (propertyName === '@media' || propertyName === 'selectors' || propertyName.startsWith('@'))) { if (property.value.type === AST_NODE_TYPES.ObjectExpression) { for (const nestedProp of property.value.properties) { if (nestedProp.type === AST_NODE_TYPES.Property && nestedProp.value.type === AST_NODE_TYPES.ObjectExpression) { processThemeTokensInStyleObject(context, nestedProp.value, options, analyzer); } } } continue; } if (!propertyName) continue; // Skip if property is in allowed list if (allowedPropertiesSet.has(propertyName) || allowedPropertiesSet.has(normalizePropertyName(propertyName))) { continue; } // Check if property value is a literal (string or number) if (property.value.type === AST_NODE_TYPES.Literal && (typeof property.value.value === 'string' || typeof property.value.value === 'number')) { const value = String(property.value.value); // Skip if value is in allowed list if (isAllowedValue(value, allowedValuesSet)) { continue; } // Check for hard-coded colors if (checkColors && COLOR_PROPERTIES.has(propertyName) && isHardCodedColor(value)) { reportHardCodedValue(context, property.value, value, 'color', propertyName, analyzer, autoFix); continue; } // Check for hard-coded spacing if (checkSpacing && SPACING_PROPERTIES.has(propertyName) && hasNumericValue(value)) { reportHardCodedValue(context, property.value, value, 'spacing', propertyName, analyzer, autoFix); continue; } // Check for hard-coded font sizes if (checkFontSizes && FONT_SIZE_PROPERTIES.has(propertyName) && hasNumericValue(value)) { reportHardCodedValue(context, property.value, value, 'fontSize', propertyName, analyzer, autoFix); continue; } // Check for hard-coded border radius if (checkBorderRadius && BORDER_RADIUS_PROPERTIES.has(propertyName) && hasNumericValue(value)) { reportHardCodedValue(context, property.value, value, 'borderRadius', propertyName, analyzer, autoFix); continue; } // Check for hard-coded border widths if (checkBorderWidths && BORDER_WIDTH_PROPERTIES.has(propertyName) && hasNumericValue(value)) { reportHardCodedValue(context, property.value, value, 'borderWidth', propertyName, analyzer, autoFix); continue; } // Check for hard-coded shadows if (checkShadows && SHADOW_PROPERTIES.has(propertyName) && hasShadowValue(value)) { reportHardCodedValue(context, property.value, value, 'shadow', propertyName, analyzer, autoFix); continue; } // Check for hard-coded z-index if (checkZIndex && Z_INDEX_PROPERTIES.has(propertyName) && hasZIndexValue(value)) { reportHardCodedValue(context, property.value, value, 'zIndex', propertyName, analyzer, autoFix); continue; } // Check for hard-coded opacity if (checkOpacity && OPACITY_PROPERTIES.has(propertyName) && hasOpacityValue(value)) { reportHardCodedValue(context, property.value, value, 'opacity', propertyName, analyzer, autoFix); continue; } // Check for hard-coded font weights if (checkFontWeights && FONT_WEIGHT_PROPERTIES.has(propertyName) && hasFontWeightValue(value)) { reportHardCodedValue(context, property.value, value, 'fontWeight', propertyName, analyzer, autoFix); continue; } // Check for hard-coded transitions if (checkTransitions && TRANSITION_PROPERTIES.has(propertyName) && hasTransitionValue(value)) { reportHardCodedValue(context, property.value, value, 'transition', propertyName, analyzer, autoFix); } } // Check if property value is a TemplateLiteral (e.g., `${rem(4)} ${rem(8)}`) if (checkHelperFunctions && property.value.type === AST_NODE_TYPES.TemplateLiteral) { // Get the source code of the template literal const sourceCode = context.sourceCode || context.getSourceCode(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const templateText = sourceCode.getText(property.value); // Try to evaluate it const evaluatedValue = evaluator.evaluate(templateText); if (evaluatedValue) { // Check for hard-coded colors if (checkColors && COLOR_PROPERTIES.has(propertyName) && isHardCodedColor(evaluatedValue)) { reportHardCodedValue(context, property.value, evaluatedValue, 'color', propertyName, analyzer, autoFix); continue; } // Check for hard-coded spacing if (checkSpacing && SPACING_PROPERTIES.has(propertyName) && hasNumericValue(evaluatedValue)) { reportHardCodedValue(context, property.value, evaluatedValue, 'spacing', propertyName, analyzer, autoFix); continue; } // Check for hard-coded font sizes if (checkFontSizes && FONT_SIZE_PROPERTIES.has(propertyName) && hasNumericValue(evaluatedValue)) { reportHardCodedValue(context, property.value, evaluatedValue, 'fontSize', propertyName, analyzer, autoFix); continue; } // Check for hard-coded border radius if (checkBorderRadius && BORDER_RADIUS_PROPERTIES.has(propertyName) && hasNumericValue(evaluatedValue)) { reportHardCodedValue(context, property.value, evaluatedValue, 'borderRadius', propertyName, analyzer, autoFix); continue; } // Check for hard-coded border widths if (checkBorderWidths && BORDER_WIDTH_PROPERTIES.has(propertyName) && hasNumericValue(evaluatedValue)) { reportHardCodedValue(context, property.value, evaluatedValue, 'borderWidth', propertyName, analyzer, autoFix); continue; } // Check for hard-coded shadows if (checkShadows && SHADOW_PROPERTIES.has(propertyName) && hasShadowValue(evaluatedValue)) { reportHardCodedValue(context, property.value, evaluatedValue, 'shadow', propertyName, analyzer, autoFix); continue; } // Check for hard-coded z-index if (checkZIndex && Z_INDEX_PROPERTIES.has(propertyName) && hasZIndexValue(evaluatedValue)) { reportHardCodedValue(context, property.value, evaluatedValue, 'zIndex', propertyName, analyzer, autoFix); continue; } // Check for hard-coded opacity if (checkOpacity && OPACITY_PROPERTIES.has(propertyName) && hasOpacityValue(evaluatedValue)) { reportHardCodedValue(context, property.value, evaluatedValue, 'opacity', propertyName, analyzer, autoFix); continue; } // Check for hard-coded font weights if (checkFontWeights && FONT_WEIGHT_PROPERTIES.has(propertyName) && hasFontWeightValue(evaluatedValue)) { reportHardCodedValue(context, property.value, evaluatedValue, 'fontWeight', propertyName, analyzer, autoFix); continue; } // Check for hard-coded transitions if (checkTransitions && TRANSITION_PROPERTIES.has(propertyName) && hasTransitionValue(evaluatedValue)) { reportHardCodedValue(context, property.value, evaluatedValue, 'transition', propertyName, analyzer, autoFix); } } } // Check if property value is a CallExpression (e.g., rem(48), clsx(...)) if (checkHelperFunctions && property.value.type === AST_NODE_TYPES.CallExpression) { // Get the source code of the call expression const sourceCode = context.sourceCode || context.getSourceCode(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const callExpressionText = sourceCode.getText(property.value); // Try to evaluate it const evaluatedValue = evaluator.evaluate(callExpressionText); if (evaluatedValue) { // Check for hard-coded colors if (checkColors && COLOR_PROPERTIES.has(propertyName) && isHardCodedColor(evaluatedValue)) { reportHardCodedValue(context, property.value, evaluatedValue, 'color', propertyName, analyzer, autoFix); continue; } // Check for hard-coded spacing if (checkSpacing && SPACING_PROPERTIES.has(propertyName) && hasNumericValue(evaluatedValue)) { reportHardCodedValue(context, property.value, evaluatedValue, 'spacing', propertyName, analyzer, autoFix); continue; } // Check for hard-coded font sizes if (checkFontSizes && FONT_SIZE_PROPERTIES.has(propertyName) && hasNumericValue(evaluatedValue)) { reportHardCodedValue(context, property.value, evaluatedValue, 'fontSize', propertyName, analyzer, autoFix); continue; } // Check for hard-coded border radius if (checkBorderRadius && BORDER_RADIUS_PROPERTIES.has(propertyName) && hasNumericValue(evaluatedValue)) { reportHardCodedValue(context, property.value, evaluatedValue, 'borderRadius', propertyName, analyzer, autoFix); continue; } // Check for hard-coded border widths if (checkBorderWidths && BORDER_WIDTH_PROPERTIES.has(propertyName) && hasNumericValue(evaluatedValue)) { reportHardCodedValue(context, property.value, evaluatedValue, 'borderWidth', propertyName, analyzer, autoFix); continue; } // Check for hard-coded shadows if (checkShadows && SHADOW_PROPERTIES.has(propertyName) && hasShadowValue(evaluatedValue)) { reportHardCodedValue(context, property.value, evaluatedValue, 'shadow', propertyName, analyzer, autoFix); continue; } // Check for hard-coded z-index if (checkZIndex && Z_INDEX_PROPERTIES.has(propertyName) && hasZIndexValue(evaluatedValue)) { reportHardCodedValue(context, property.value, evaluatedValue, 'zIndex', propertyName, analyzer, autoFix); continue; } // Check for hard-coded opacity if (checkOpacity && OPACITY_PROPERTIES.has(propertyName) && hasOpacityValue(evaluatedValue)) { reportHardCodedValue(context, property.value, evaluatedValue, 'opacity', propertyName, analyzer, autoFix); continue; } // Check for hard-coded font weights if (checkFontWeights && FONT_WEIGHT_PROPERTIES.has(propertyName) && hasFontWeightValue(evaluatedValue)) { reportHardCodedValue(context, property.value, evaluatedValue, 'fontWeight', propertyName, analyzer, autoFix); continue; } // Check for hard-coded transitions if (checkTransitions && TRANSITION_PROPERTIES.has(propertyName) && hasTransitionValue(evaluatedValue)) { reportHardCodedValue(context, property.value, evaluatedValue, 'transition', propertyName, analyzer, autoFix); } } } // Recurse into nested objects if (property.value.type === AST_NODE_TYPES.ObjectExpression) { processThemeTokensInStyleObject(context, property.value, options, analyzer); } } }; /** * Report a hard-coded value and suggest theme tokens */ const reportHardCodedValue = (context, node, value, category, propertyName, analyzer, autoFix) => { // Find matching tokens from the theme contract const matchingTokens = analyzer.findMatchingTokens(value, category); if (matchingTokens.length > 0) { // We have exact matches - suggest the specific token(s) const primaryToken = matchingTokens[0]; if (!primaryToken) return; const tokenPath = primaryToken.tokenPath; const suggestions = matchingTokens.map((token) => ({ messageId: 'replaceWithToken', data: { tokenPath: token.tokenPath }, fix: (fixer) => fixer.replaceText(node, token.tokenPath), })); const reportDescriptor = { node: node, messageId: 'hardCodedValueWithToken', data: { value, property: propertyName, tokenPath, }, suggest: suggestions, }; // Add fix if autoFix is enabled // Only auto-fix when there's exactly one match (unambiguous) // For multiple matches, user must manually select from suggestions if (autoFix && matchingTokens.length === 1) { reportDescriptor.fix = (fixer) => fixer.replaceText(node, tokenPath); } context.report(reportDescriptor); } else if (analyzer.hasContracts()) { // Theme contract exists but no exact match - give generic suggestion const categoryHint = getCategoryHint(category, analyzer.getVariableName()); context.report({ node: node, messageId: 'hardCodedValueGeneric', data: { value, property: propertyName, categoryHint, }, }); } else { // No theme contract loaded - give very generic message context.report({ node: node, messageId: 'hardCodedValueNoContract', data: { value, property: propertyName, category: getCategoryName(category), }, }); } }; /** * Get a helpful hint for the category */ const getCategoryHint = (category, variableName) => { switch (category) { case 'color': return `${variableName}.colors.*`; case 'spacing': return `${variableName}.spacing.*`; case 'fontSize': return `${variableName}.fontSizes.*`; case 'borderRadius': return `${variableName}.radii.*`; case 'borderWidth': return `${variableName}.borderWidths.*`; case 'shadow': return `${variableName}.shadows.*`; case 'zIndex': return `${variableName}.zIndex.*`; case 'opacity': return `${variableName}.opacity.*`; case 'fontWeight': return `${variableName}.fontWeights.*`; case 'transition': return `${variableName}.transitions.*`; default: return `${variableName}.*`; } }; /** * Get a readable category name */ const getCategoryName = (category) => { switch (category) { case 'color': return 'color'; case 'spacing': return 'spacing'; case 'fontSize': return 'font size'; case 'borderRadius': return 'border radius'; case 'borderWidth': return 'border width'; case 'shadow': return 'shadow'; case 'zIndex': return 'z-index'; case 'opacity': return 'opacity'; case 'fontWeight': return 'font weight'; case 'transition': return 'transition'; default: return 'value'; } };