@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.
223 lines (222 loc) • 10.4 kB
JavaScript
import { TSESTree } from '@typescript-eslint/utils';
import { isCallExpressionWithEmptyObject, isEmptyObject } from '../shared-utils/empty-object-processor.js';
import { ReferenceTracker, createReferenceTrackingVisitor } from '../shared-utils/reference-tracker.js';
import { processConditionalExpression } from './conditional-processor.js';
import { processEmptyNestedStyles } from './empty-nested-style-processor.js';
import { reportEmptyDeclaration } from './fix-utils.js';
import { getStyleKeyName } from './property-utils.js';
import { processRecipeProperties } from './recipe-processor.js';
import { processStyleVariants } from './style-variants-processor.js';
/**
* Checks if a nested object (selectors, media, supports) contains only empty objects.
*/
const isNestedObjectEmpty = (obj) => {
if (obj.properties.length === 0) {
return true;
}
return obj.properties.every((property) => {
if (property.type !== 'Property') {
return true; // Skip non-property elements
}
if (property.value.type === 'ObjectExpression') {
return isEmptyObject(property.value);
}
return false; // Non-object values mean it's not empty
});
};
/**
* Checks if a style object is effectively empty (contains only empty objects).
*/
export const 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;
// CallExpression (e.g., sprinkles(), style()) is considered non-empty unless it has an empty object argument, e.g. sprinkles({})
if (property.value.type === 'CallExpression') {
if (!isCallExpressionWithEmptyObject(property.value)) {
isBaseEmpty = false;
}
}
else 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 (has base or variants), check recipe-specific emptiness
if (hasBaseProperty || hasVariantsProperty) {
return isBaseEmpty && areAllVariantsEmpty;
}
// For regular style objects, check if all properties are effectively empty
return stylesObject.properties.every((property) => {
if (property.type !== 'Property') {
return true; // Skip spread elements for emptiness check
}
const propertyName = getStyleKeyName(property.key);
if (!propertyName) {
return true; // Skip properties we can't identify
}
// Handle special nested objects like selectors, media queries, supports
if (propertyName === 'selectors' || propertyName.startsWith('@')) {
if (property.value.type === 'ObjectExpression') {
return isNestedObjectEmpty(property.value);
}
return false; // Non-object values in these properties
}
// Handle regular CSS properties
if (property.value.type === 'ObjectExpression') {
return isEmptyObject(property.value);
}
return false; // Non-empty property (literal values, etc.)
});
};
/**
* Creates ESLint rule visitors for detecting empty style blocks using reference tracking.
* This automatically detects vanilla-extract functions based on their import statements.
*/
export const createEmptyStyleVisitors = (ruleContext) => {
const tracker = new ReferenceTracker();
const trackingVisitor = createReferenceTrackingVisitor(tracker);
const reportedNodes = new Set();
return {
// Include the reference tracking visitors
...trackingVisitor,
CallExpression(node) {
if (node.callee.type !== 'Identifier') {
return;
}
const functionName = node.callee.name;
// Check if this function is tracked as a vanilla-extract function
if (!tracker.isTrackedFunction(functionName)) {
return;
}
const originalName = tracker.getOriginalName(functionName);
const wrapperInfo = tracker.getWrapperInfo(functionName);
if (!originalName || node.arguments.length === 0) {
return;
}
// Handle styleVariants specifically
if (originalName === 'styleVariants') {
// For wrapper functions, use the correct parameter index
const styleArgumentIndex = wrapperInfo?.parameterMapping ?? 0;
if (node.arguments.length <= styleArgumentIndex) {
return;
}
if (node.arguments[styleArgumentIndex]?.type === 'ObjectExpression') {
processStyleVariants(ruleContext, node.arguments[styleArgumentIndex], reportedNodes);
// If the entire styleVariants object is empty after processing, remove the declaration
if (isEmptyObject(node.arguments[styleArgumentIndex])) {
reportEmptyDeclaration(ruleContext, node.arguments[styleArgumentIndex], node);
}
}
return;
}
// Determine the style argument index based on the original function name and wrapper info
let styleArgumentIndex;
if (wrapperInfo) {
// Use wrapper function parameter mapping
styleArgumentIndex = wrapperInfo.parameterMapping;
}
else {
// Use original logic for direct vanilla-extract calls
styleArgumentIndex =
originalName === 'globalStyle' || originalName === 'globalKeyframes' || originalName === 'globalFontFace'
? 1
: 0;
}
// For global functions, check if we have enough arguments
if ((originalName === 'globalStyle' || originalName === 'globalKeyframes' || originalName === 'globalFontFace') &&
node.arguments.length <= styleArgumentIndex) {
return;
}
// For wrapper functions, ensure we have enough arguments
if (wrapperInfo && node.arguments.length <= styleArgumentIndex) {
return;
}
const styleArgument = node.arguments[styleArgumentIndex];
// This defensive check prevents duplicate processing of nodes.
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 (originalName === 'recipe') {
if (styleArgument?.type === 'ObjectExpression') {
if (isEffectivelyEmptyStylesObject(styleArgument)) {
reportedNodes.add(styleArgument);
reportEmptyDeclaration(ruleContext, styleArgument, node);
return;
}
// Process individual properties in recipe
processRecipeProperties(ruleContext, styleArgument, reportedNodes);
}
return;
}
// Handle fontFace functions - both fontFace and globalFontFace need empty object checks
if (originalName === 'fontFace' || originalName === 'globalFontFace') {
// Direct empty object case - remove the entire declaration
if (styleArgument?.type === 'ObjectExpression' && isEmptyObject(styleArgument)) {
reportedNodes.add(styleArgument);
reportEmptyDeclaration(ruleContext, styleArgument, node);
return;
}
return;
}
// 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) {
if (property.range) {
return fixer.removeRange([property.range[0], property.range[1]]);
}
return null;
},
});
}
});
// Process nested selectors and media queries
processEmptyNestedStyles(ruleContext, styleArgument, reportedNodes);
}
},
};
};