mirror of
https://github.com/antebudimir/eslint-plugin-vanilla-extract.git
synced 2026-01-01 17:23:31 +00:00
feat 🥁: add no-zero-unit rule
This rule enforces unitless zero values in vanilla-extract style objects: - Automatically removes unnecessary units from zero values - Handles both positive and negative zero values - Preserves units where required (time properties, CSS functions) - Works with all vanilla-extract APIs
This commit is contained in:
parent
52d38d4477
commit
7dc7204749
20 changed files with 650 additions and 737 deletions
|
|
@ -6,12 +6,12 @@ import { reportEmptyDeclaration } from './fix-utils.js';
|
|||
/**
|
||||
* Handles conditional expressions with empty objects.
|
||||
*/
|
||||
export function processConditionalExpression(
|
||||
export const processConditionalExpression = (
|
||||
context: Rule.RuleContext,
|
||||
node: TSESTree.ConditionalExpression,
|
||||
reportedNodes: Set<TSESTree.Node>,
|
||||
callNode: TSESTree.CallExpression,
|
||||
): void {
|
||||
): void => {
|
||||
const isConsequentEmpty = node.consequent.type === 'ObjectExpression' && isEmptyObject(node.consequent);
|
||||
const isAlternateEmpty = node.alternate.type === 'ObjectExpression' && isEmptyObject(node.alternate);
|
||||
|
||||
|
|
@ -33,4 +33,4 @@ export function processConditionalExpression(
|
|||
messageId: 'emptyConditionalStyle',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@ import { areAllChildrenEmpty, getStyleKeyName } from './property-utils.js';
|
|||
/**
|
||||
* Processes nested style objects like selectors and media queries.
|
||||
*/
|
||||
export function processEmptyNestedStyles(
|
||||
export const processEmptyNestedStyles = (
|
||||
ruleContext: Rule.RuleContext,
|
||||
node: TSESTree.ObjectExpression,
|
||||
reportedNodes: Set<TSESTree.Node>,
|
||||
): void {
|
||||
): void => {
|
||||
node.properties.forEach((property) => {
|
||||
if (property.type !== 'Property') {
|
||||
return;
|
||||
|
|
@ -72,4 +72,4 @@ export function processEmptyNestedStyles(
|
|||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,128 +9,10 @@ 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: Rule.RuleContext): Rule.RuleListener => {
|
||||
// Track reported nodes to prevent duplicate reports
|
||||
const reportedNodes = new Set<TSESTree.Node>();
|
||||
|
||||
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] as TSESTree.ObjectExpression, reportedNodes);
|
||||
|
||||
// If the entire styleVariants object is empty after processing, remove the declaration
|
||||
if (isEmptyObject(node.arguments[0] as TSESTree.ObjectExpression)) {
|
||||
reportEmptyDeclaration(ruleContext, node.arguments[0] as TSESTree.Node, node as TSESTree.CallExpression);
|
||||
}
|
||||
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 as TSESTree.Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle conditional expressions
|
||||
if (styleArgument?.type === 'ConditionalExpression') {
|
||||
processConditionalExpression(
|
||||
ruleContext,
|
||||
styleArgument as TSESTree.ConditionalExpression,
|
||||
reportedNodes,
|
||||
node as TSESTree.CallExpression,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Direct empty object case - remove the entire declaration
|
||||
if (styleArgument?.type === 'ObjectExpression' && isEmptyObject(styleArgument as TSESTree.ObjectExpression)) {
|
||||
reportedNodes.add(styleArgument as TSESTree.ObjectExpression);
|
||||
reportEmptyDeclaration(ruleContext, styleArgument as TSESTree.Node, node as TSESTree.CallExpression);
|
||||
return;
|
||||
}
|
||||
|
||||
// For recipe - check if entire recipe is effectively empty
|
||||
if (node.callee.name === 'recipe' && styleArgument?.type === 'ObjectExpression') {
|
||||
if (isEffectivelyEmptyStylesObject(styleArgument as TSESTree.ObjectExpression)) {
|
||||
reportedNodes.add(styleArgument as TSESTree.ObjectExpression);
|
||||
reportEmptyDeclaration(ruleContext, styleArgument as TSESTree.Node, node as TSESTree.CallExpression);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process individual properties in recipe
|
||||
processRecipeProperties(ruleContext, styleArgument as TSESTree.ObjectExpression, 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 as TSESTree.ObjectExpression)
|
||||
) {
|
||||
reportedNodes.add(property.argument as TSESTree.Node);
|
||||
ruleContext.report({
|
||||
node: property.argument as Rule.Node,
|
||||
messageId: 'emptySpreadObject',
|
||||
fix(fixer) {
|
||||
return removeNodeWithComma(ruleContext, property as TSESTree.Node, fixer);
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Process nested selectors and media queries
|
||||
processEmptyNestedStyles(ruleContext, styleArgument as TSESTree.ObjectExpression, reportedNodes);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a style object is effectively empty (contains only empty objects).
|
||||
*/
|
||||
export function isEffectivelyEmptyStylesObject(stylesObject: TSESTree.ObjectExpression): boolean {
|
||||
export const isEffectivelyEmptyStylesObject = (stylesObject: TSESTree.ObjectExpression): boolean => {
|
||||
// Empty object itself
|
||||
if (stylesObject.properties.length === 0) {
|
||||
return true;
|
||||
|
|
@ -225,4 +107,122 @@ export function isEffectivelyEmptyStylesObject(stylesObject: TSESTree.ObjectExpr
|
|||
|
||||
// If we have special properties and they're all empty, the style is effectively empty
|
||||
return specialProperties.length > 0 && allSpecialPropertiesEmpty;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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: Rule.RuleContext): Rule.RuleListener => {
|
||||
// Track reported nodes to prevent duplicate reports
|
||||
const reportedNodes = new Set<TSESTree.ObjectExpression>();
|
||||
|
||||
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] as TSESTree.ObjectExpression, reportedNodes);
|
||||
|
||||
// If the entire styleVariants object is empty after processing, remove the declaration
|
||||
if (isEmptyObject(node.arguments[0] as TSESTree.ObjectExpression)) {
|
||||
reportEmptyDeclaration(ruleContext, node.arguments[0] as TSESTree.Node, node as TSESTree.CallExpression);
|
||||
}
|
||||
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 as TSESTree.ObjectExpression)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle conditional expressions
|
||||
if (styleArgument?.type === 'ConditionalExpression') {
|
||||
processConditionalExpression(
|
||||
ruleContext,
|
||||
styleArgument as TSESTree.ConditionalExpression,
|
||||
reportedNodes,
|
||||
node as TSESTree.CallExpression,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Direct empty object case - remove the entire declaration
|
||||
if (styleArgument?.type === 'ObjectExpression' && isEmptyObject(styleArgument as TSESTree.ObjectExpression)) {
|
||||
reportedNodes.add(styleArgument as TSESTree.ObjectExpression);
|
||||
reportEmptyDeclaration(ruleContext, styleArgument as TSESTree.Node, node as TSESTree.CallExpression);
|
||||
return;
|
||||
}
|
||||
|
||||
// For recipe - check if entire recipe is effectively empty
|
||||
if (node.callee.name === 'recipe' && styleArgument?.type === 'ObjectExpression') {
|
||||
if (isEffectivelyEmptyStylesObject(styleArgument as TSESTree.ObjectExpression)) {
|
||||
reportedNodes.add(styleArgument as TSESTree.ObjectExpression);
|
||||
reportEmptyDeclaration(ruleContext, styleArgument as TSESTree.Node, node as TSESTree.CallExpression);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process individual properties in recipe
|
||||
processRecipeProperties(ruleContext, styleArgument as TSESTree.ObjectExpression, 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 as TSESTree.ObjectExpression)
|
||||
) {
|
||||
reportedNodes.add(property.argument as TSESTree.ObjectExpression);
|
||||
ruleContext.report({
|
||||
node: property.argument as Rule.Node,
|
||||
messageId: 'emptySpreadObject',
|
||||
fix(fixer) {
|
||||
return removeNodeWithComma(ruleContext, property as TSESTree.Node, fixer);
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Process nested selectors and media queries
|
||||
processEmptyNestedStyles(ruleContext, styleArgument as TSESTree.ObjectExpression, reportedNodes);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ import type { TSESTree } from '@typescript-eslint/utils';
|
|||
* @param fixer The ESLint fixer.
|
||||
* @returns The fix object.
|
||||
*/
|
||||
export function removeNodeWithComma(ruleContext: Rule.RuleContext, node: TSESTree.Node, fixer: Rule.RuleFixer) {
|
||||
export const removeNodeWithComma = (ruleContext: Rule.RuleContext, node: TSESTree.Node, fixer: Rule.RuleFixer) => {
|
||||
const sourceCode = ruleContext.sourceCode;
|
||||
const tokenAfter = sourceCode.getTokenAfter(node as Rule.Node);
|
||||
if (tokenAfter && tokenAfter.value === ',' && node.range && tokenAfter.range) {
|
||||
return fixer.removeRange([node.range[0], tokenAfter.range[1]]);
|
||||
}
|
||||
return fixer.remove(node as Rule.Node);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { isEmptyObject } from '../shared-utils/empty-object-processor.js';
|
|||
/**
|
||||
* Gets the property name regardless of whether it's an identifier or a literal.
|
||||
*/
|
||||
export function getStyleKeyName(key: TSESTree.Expression | TSESTree.PrivateIdentifier): string | null {
|
||||
export const getStyleKeyName = (key: TSESTree.Expression | TSESTree.PrivateIdentifier): string | null => {
|
||||
if (key.type === 'Identifier') {
|
||||
return key.name;
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ export function getStyleKeyName(key: TSESTree.Expression | TSESTree.PrivateIdent
|
|||
return key.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if all properties in a style object are empty objects.
|
||||
|
|
|
|||
|
|
@ -13,11 +13,11 @@ import { areAllChildrenEmpty, getStyleKeyName } from './property-utils.js';
|
|||
* @param recipeNode The recipe object node to process.
|
||||
* @param reportedNodes A set of nodes that have already been reported by other processors.
|
||||
*/
|
||||
export function processRecipeProperties(
|
||||
export const processRecipeProperties = (
|
||||
ruleContext: Rule.RuleContext,
|
||||
recipeNode: TSESTree.ObjectExpression,
|
||||
reportedNodes: Set<TSESTree.Node>,
|
||||
): void {
|
||||
): void => {
|
||||
recipeNode.properties.forEach((property) => {
|
||||
if (property.type !== 'Property') {
|
||||
return;
|
||||
|
|
@ -139,4 +139,4 @@ export function processRecipeProperties(
|
|||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@ import { removeNodeWithComma } from './node-remover.js';
|
|||
* @param node The styleVariants call argument (object expression).
|
||||
* @param reportedNodes A set of nodes that have already been reported.
|
||||
*/
|
||||
export function processStyleVariants(
|
||||
export const processStyleVariants = (
|
||||
ruleContext: Rule.RuleContext,
|
||||
node: TSESTree.ObjectExpression,
|
||||
reportedNodes: Set<TSESTree.Node>,
|
||||
): void {
|
||||
): void => {
|
||||
node.properties.forEach((property) => {
|
||||
if (property.type !== 'Property') {
|
||||
return;
|
||||
|
|
@ -50,4 +50,4 @@ export function processStyleVariants(
|
|||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue