UNPKG

@antebudimir/eslint-plugin-vanilla-extract

Version:

ESLint plugin for enforcing best practices in vanilla-extract CSS styles, including CSS property ordering and additional linting rules.

184 lines (183 loc) 9.05 kB
import { TSESTree } from '@typescript-eslint/utils'; import { isEmptyObject } from '../shared-utils/empty-object-processor.js'; import { processConditionalExpression } from './conditional-processor.js'; import { processEmptyNestedStyles } from './empty-nested-style-processor.js'; import { reportEmptyDeclaration } from './fix-utils.js'; import { removeNodeWithComma } from './node-remover.js'; import { getStyleKeyName } from './property-utils.js'; import { processRecipeProperties } from './recipe-processor.js'; import { processStyleVariants } from './style-variants-processor.js'; /** * Creates ESLint rule visitors for detecting empty style blocks in vanilla-extract. * @param ruleContext The ESLint rule rule context. * @returns An object with visitor functions for the ESLint rule. */ export const createEmptyStyleVisitors = (ruleContext) => { // Track reported nodes to prevent duplicate reports const reportedNodes = new Set(); return { CallExpression(node) { if (node.callee.type !== 'Identifier') { return; } // Target vanilla-extract style functions const styleApiFunctions = [ 'style', 'styleVariants', 'recipe', 'globalStyle', 'fontFace', 'globalFontFace', 'keyframes', 'globalKeyframes', ]; if (!styleApiFunctions.includes(node.callee.name) || node.arguments.length === 0) { return; } // Handle styleVariants specifically if (node.callee.name === 'styleVariants' && node.arguments[0]?.type === 'ObjectExpression') { processStyleVariants(ruleContext, node.arguments[0], reportedNodes); // If the entire styleVariants object is empty after processing, remove the declaration if (isEmptyObject(node.arguments[0])) { reportEmptyDeclaration(ruleContext, node.arguments[0], node); } return; } const defaultStyleArgumentIndex = 0; const globalFunctionNames = ['globalStyle', 'globalFontFace', 'globalKeyframes']; // Determine the style argument index based on the function name const styleArgumentIndex = globalFunctionNames.includes(node.callee.name) ? 1 : defaultStyleArgumentIndex; // For global functions, check if we have enough arguments if (styleArgumentIndex === 1 && node.arguments.length <= styleArgumentIndex) { return; } const styleArgument = node.arguments[styleArgumentIndex]; // This defensive check prevents duplicate processing of nodes. // This code path's difficult to test because the ESLint visitor pattern // typically ensures each node is only visited once per rule execution. if (reportedNodes.has(styleArgument)) { return; } // Handle conditional expressions if (styleArgument?.type === 'ConditionalExpression') { processConditionalExpression(ruleContext, styleArgument, reportedNodes, node); return; } // Direct empty object case - remove the entire declaration if (styleArgument?.type === 'ObjectExpression' && isEmptyObject(styleArgument)) { reportedNodes.add(styleArgument); reportEmptyDeclaration(ruleContext, styleArgument, node); return; } // For recipe - check if entire recipe is effectively empty if (node.callee.name === 'recipe' && styleArgument?.type === 'ObjectExpression') { if (isEffectivelyEmptyStylesObject(styleArgument)) { reportedNodes.add(styleArgument); reportEmptyDeclaration(ruleContext, styleArgument, node); return; } // Process individual properties in recipe processRecipeProperties(ruleContext, styleArgument, reportedNodes); } // For style objects with nested empty objects if (styleArgument?.type === 'ObjectExpression') { // Check for spread elements styleArgument.properties.forEach((property) => { if (property.type === 'SpreadElement' && property.argument.type === 'ObjectExpression' && isEmptyObject(property.argument)) { reportedNodes.add(property.argument); ruleContext.report({ node: property.argument, messageId: 'emptySpreadObject', fix(fixer) { return removeNodeWithComma(ruleContext, property, fixer); }, }); } }); // Process nested selectors and media queries processEmptyNestedStyles(ruleContext, styleArgument, reportedNodes); } }, }; }; /** * Checks if a style object is effectively empty (contains only empty objects). */ export function isEffectivelyEmptyStylesObject(stylesObject) { // Empty object itself if (stylesObject.properties.length === 0) { return true; } // For recipe objects, we need special handling let hasBaseProperty = false; let isBaseEmpty = true; let hasVariantsProperty = false; let areAllVariantsEmpty = true; // First pass: identify recipe properties for (const property of stylesObject.properties) { if (property.type !== 'Property') { continue; } const propertyName = getStyleKeyName(property.key); if (!propertyName) { continue; } if (propertyName === 'base') { hasBaseProperty = true; if (property.value.type === 'ObjectExpression' && !isEmptyObject(property.value)) { isBaseEmpty = false; } } else if (propertyName === 'variants') { hasVariantsProperty = true; if (property.value.type === 'ObjectExpression' && !isEmptyObject(property.value)) { areAllVariantsEmpty = false; } } } // If this looks like a recipe object (has base or variants) if (hasBaseProperty || hasVariantsProperty) { // A recipe is effectively empty if both base and variants are empty return isBaseEmpty && areAllVariantsEmpty; } // / For non-recipe objects, check if all special properties (selectors, media queries, variants) are effectively empty function isSpecialProperty(propertyName) { return (propertyName === 'selectors' || (propertyName && propertyName.startsWith('@')) || propertyName === 'variants'); } const specialProperties = stylesObject.properties.filter((prop) => prop.type === 'Property' && isSpecialProperty(getStyleKeyName(prop.key))); const allSpecialPropertiesEmpty = specialProperties.every((property) => { if (property.value.type === 'ObjectExpression' && isEmptyObject(property.value)) { return true; } const propertyName = getStyleKeyName(property.key); // This defensive check handles malformed AST nodes that lack valid property names. // This is difficult to test because it's challenging to construct a valid AST // where getStyleKeyName would return a falsy value. if (!propertyName) { return false; } // For selectors, media queries and supports, check if all nested objects are empty if ((propertyName === 'selectors' || (propertyName && propertyName.startsWith('@'))) && property.value.type === 'ObjectExpression') { // This handles the edge case of an empty properties array. // This code path is difficult to test in isolation because it requires // constructing a specific AST structure that bypasses earlier conditions. if (property.value.properties.length === 0) { return true; } return property.value.properties.every((nestedProperty) => { return (nestedProperty.type === 'Property' && nestedProperty.value.type === 'ObjectExpression' && isEmptyObject(nestedProperty.value)); }); } // Default fallback for cases not handled by the conditions above. // This is difficult to test because it requires creating an AST structure // that doesn't trigger any of the preceding return statements. return false; }); // If we have special properties and they're all empty, the style is effectively empty return specialProperties.length > 0 && allSpecialPropertiesEmpty; }