feat 🥁: add no-zero-unit rule

This commit is contained in:
Ante Budimir 2025-04-12 20:43:11 +03:00
parent 52d38d4477
commit c1b4e70bd9
19 changed files with 784 additions and 285 deletions

View file

@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.8.0] - 2025-04-12
- add new rule `no-zero-unit` that enforces unitless zero values in vanilla-extract style objects
- Automatically removes unnecessary units from zero values (e.g., '0px' → '0')
- Handles both positive and negative zero values
- Preserves units where required (time properties, CSS functions)
- Works with all vanilla-extract APIs including style, recipe, fontFace, and keyframes
- Supports nested objects, media queries, and pseudo-selectors
## [1.7.0] - 2025-04-07
- add a recommended configuration preset that enables concentric-order and no-empty-style-blocks rules with error severity.

View file

@ -66,6 +66,7 @@ export default [
// Optionally override specific rules
// 'vanilla-extract/concentric-order': 'warn', // Change severity from error to warn
// 'vanilla-extract/no-empty-style-blocks': 'off', // Disable a recommended rule
// 'vanilla-extract/no-zero-unit': 'warn', // Change severity from error to warn
// Add additional rules not in recommended config
// 'vanilla-extract/alphabetical-order': 'error', // Override concentric-order rule
@ -80,6 +81,7 @@ The recommended configuration enables the following rules with error severity:
- `vanilla-extract/concentric-order`: Enforces concentric CSS property ordering
- `vanilla-extract/no-empty-style-blocks`: Prevents empty style blocks
- `vanilla-extract/no-zero-unit`: removes unnecessary units for zero values
You can use the recommended configuration as a starting point and override rules as needed for your project.
@ -108,6 +110,7 @@ export default [
sortRemainingProperties: 'concentric', // 'alphabetical' is default
},
],
'vanilla-extract/no-zero-unit': 'warn',
},
},
];
@ -266,6 +269,34 @@ export const recipeWithEmptyVariants = recipe({
});
```
## vanilla-extract/no-zero-unit
This rule enforces the removal of unnecessary units for zero values in vanilla-extract style objects. It helps maintain cleaner and more consistent CSS by eliminating redundant units when the value is zero.
```typescript
// ❌ Incorrect
import { style } from '@vanilla-extract/css';
export const myStyle = style({
margin: '0px',
padding: '0rem',
width: '0%',
height: '0vh',
top: '-0em',
});
// ✅ Correct
import { style } from '@vanilla-extract/css';
export const myStyle = style({
margin: '0',
padding: '0',
width: '0',
height: '0',
top: '0',
});
```
## Font Face Declarations
For `fontFace` and `globalFontFace` API calls, all three ordering rules (alphabetical, concentric, and custom) enforce the same special ordering:
@ -341,16 +372,16 @@ The roadmap outlines the project's current status and future plans:
- Support for multiple vanilla-extract APIs (e.g., `style`, `styleVariants`, `recipe`, `globalStyle`, `fontFace`, etc.).
- `no-empty-style-blocks` rule to disallow empty blocks.
- Recommended ESLint configuration for the plugin.
- `no-zero-unit` rule to disallow units when the value is zero.
- Comprehensive rule testing.
### Current Work
- `no-zero-unit` rule to disallow units when the value is zero.
- `no-unknown-unit` rule to disallow unknown units.
### Upcoming Features
- `no-unknown-units` rule to disallow unknown units.
- `no-number-trailing-zeros` rule to disallow trailing zeros in numbers.
- `no-number-trailing-zero` rule to disallow trailing zeros in numbers.
- `no-px-unit` rule to disallow use of `px` units with configurable whitelist.
- `prefer-logical-properties` rule to enforce use of logical properties.
- `prefer-theme-tokens` rule to enforce use of theme tokens instead of hard-coded values when available.

View file

@ -1,6 +1,6 @@
{
"name": "@antebudimir/eslint-plugin-vanilla-extract",
"version": "1.7.0",
"version": "1.8.0",
"description": "ESLint plugin for enforcing best practices in vanilla-extract CSS styles, including CSS property ordering and additional linting rules.",
"author": "Ante Budimir",
"license": "MIT",

View file

@ -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',
});
}
}
};

View file

@ -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(
});
}
});
}
};

View file

@ -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);
}
},
};
};

View file

@ -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);
}
};

View file

@ -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.

View file

@ -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(
}
}
});
}
};

View file

@ -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;
}
});
}
};

View file

@ -0,0 +1,341 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import noZeroUnitRule from '../rule-definition.js';
run({
name: 'vanilla-extract/no-zero-unit',
rule: noZeroUnitRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '0',
padding: 0,
width: '100%',
});
`,
},
{
code: `
import { recipe } from '@vanilla-extract/css';
const myRecipe = recipe({
base: {
margin: '0',
padding: 0,
},
variants: {
size: {
small: {
height: '0',
width: '10px',
},
},
},
});
`,
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
...spreadProps,
margin: 0,
'@media': {
'0rem': '0' // Key shouldn't be checked
}
});
`,
name: 'should ignore spread elements and object keys',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: \`0\${someUnit}\`, // Template literal
padding: someVariable
});
`,
name: 'should ignore non-literal values',
},
{
code: `
import { globalStyle } from '@vanilla-extract/css';
const callExpression = someObject.fontFace({ src: '...' }); // Non-Identifier callee
`,
name: 'should ignore member expression callees',
},
{
code: `
import { fontFace } from '@vanilla-extract/css';
fontFace(); // Missing arguments
`,
name: 'should handle missing fontFace arguments',
},
{
code: `
import { globalFontFace } from '@vanilla-extract/css';
globalFontFace('my-font'); // Missing style argument
`,
name: 'should handle missing globalFontFace style argument',
},
],
invalid: [
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '0px',
padding: '0rem',
});
`,
errors: [{ messageId: 'noZeroUnit' }, { messageId: 'noZeroUnit' }],
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '0',
padding: '0',
});
`,
},
{
code: `
import { recipe } from '@vanilla-extract/css';
const myRecipe = recipe({
base: {
margin: '0px',
},
variants: {
size: {
small: {
height: '0vh',
},
},
},
});
`,
errors: [{ messageId: 'noZeroUnit' }, { messageId: 'noZeroUnit' }],
output: `
import { recipe } from '@vanilla-extract/css';
const myRecipe = recipe({
base: {
margin: '0',
},
variants: {
size: {
small: {
height: '0',
},
},
},
});
`,
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '0px',
'@media': {
'(min-width: 768px)': {
padding: '0rem'
}
}
});
`,
errors: 2,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '0',
'@media': {
'(min-width: 768px)': {
padding: '0'
}
}
});
`,
name: 'should handle nested media queries',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
'::before': {
content: '""',
margin: '0px'
}
});
`,
errors: 1,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
'::before': {
content: '""',
margin: '0'
}
});
`,
name: 'should handle pseudo-elements',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '0px',
nested: {
object: {
padding: '0rem',
deeper: {
width: '0%'
}
}
}
});
`,
errors: 3,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '0',
nested: {
object: {
padding: '0',
deeper: {
width: '0'
}
}
}
});
`,
name: 'should handle multiple levels of nesting',
},
{
code: `
import { fontFace, globalFontFace } from '@vanilla-extract/css';
fontFace({
src: '...',
lineGap: '0rem'
});
globalFontFace('my-font', {
src: '...',
sizeAdjust: '0%'
});
`,
errors: 2,
output: `
import { fontFace, globalFontFace } from '@vanilla-extract/css';
fontFace({
src: '...',
lineGap: '0'
});
globalFontFace('my-font', {
src: '...',
sizeAdjust: '0'
});
`,
name: 'should handle fontFace and globalFontFace arguments',
},
// 0deg is valid (deg isn't in our unit check)
{
code: `
import { globalKeyframes, globalStyle } from '@vanilla-extract/css';
globalKeyframes('spin', {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(0deg)' }
});
globalStyle('html', {
margin: '0px',
padding: '0rem'
});
`,
errors: 2,
output: `
import { globalKeyframes, globalStyle } from '@vanilla-extract/css';
globalKeyframes('spin', {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(0deg)' }
});
globalStyle('html', {
margin: '0',
padding: '0'
});
`,
name: 'should handle globalKeyframes and globalStyle arguments',
},
{
code: `
import { globalStyle } from '@vanilla-extract/css';
globalStyle('html', {
'@media': {
'(min-width: 768px)': {
margin: '0px'
}
}
});
`,
errors: 1,
output: `
import { globalStyle } from '@vanilla-extract/css';
globalStyle('html', {
'@media': {
'(min-width: 768px)': {
margin: '0'
}
}
});
`,
name: 'should handle nested globalStyle arguments',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '-0px',
padding: '-0rem',
top: '-0vh',
left: '-0%',
});
`,
errors: [
{ messageId: 'noZeroUnit' },
{ messageId: 'noZeroUnit' },
{ messageId: 'noZeroUnit' },
{ messageId: 'noZeroUnit' },
],
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '0',
padding: '0',
top: '0',
left: '0',
});
`,
name: 'should convert negative zero with units to simple zero',
},
],
});

View file

@ -0,0 +1,3 @@
import noZeroUnitRule from './rule-definition.js';
export default noZeroUnitRule;

View file

@ -0,0 +1,23 @@
import type { Rule } from 'eslint';
import { createZeroUnitVisitors } from './zero-unit-visitor-creator.js';
const noZeroUnitRule: Rule.RuleModule = {
meta: {
type: 'suggestion',
docs: {
description: 'enforce unitless zero in numeric values',
category: 'Stylistic Issues',
recommended: true,
},
fixable: 'code',
schema: [],
messages: {
noZeroUnit: 'Unit with zero value is unnecessary. Use 0 instead.',
},
},
create(context) {
return createZeroUnitVisitors(context);
},
};
export default noZeroUnitRule;

View file

@ -0,0 +1,36 @@
import type { Rule } from 'eslint';
import { TSESTree } from '@typescript-eslint/utils';
const ZERO_VALUE_WITH_UNIT_REGEX = /^-?0(px|em|rem|%|vh|vw|vmin|vmax|ex|ch|cm|mm|in|pt|pc|Q|fr)$/;
/**
* Recursively processes a style object, reporting and fixing instances of zero values with units.
*
* @param ruleContext The ESLint rule context.
* @param node The ObjectExpression node representing the style object to be processed.
*/
export const processZeroUnitInStyleObject = (ruleContext: Rule.RuleContext, node: TSESTree.ObjectExpression): void => {
node.properties.forEach((property) => {
if (property.type !== 'Property') {
return;
}
// Process direct string literal values
if (
property.value.type === 'Literal' &&
typeof property.value.value === 'string' &&
ZERO_VALUE_WITH_UNIT_REGEX.test(property.value.value)
) {
ruleContext.report({
node: property.value,
messageId: 'noZeroUnit',
fix: (fixer) => fixer.replaceText(property.value, "'0'"),
});
}
// Process nested objects (selectors, media queries, etc.)
if (property.value.type === 'ObjectExpression') {
processZeroUnitInStyleObject(ruleContext, property.value);
}
});
};

View file

@ -0,0 +1,56 @@
import type { Rule } from 'eslint';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
import { processRecipeProperties } from '../shared-utils/recipe-property-processor.js';
import { processStyleNode } from '../shared-utils/style-node-processor.js';
import { processZeroUnitInStyleObject } from './zero-unit-processor.js';
/**
* Creates ESLint rule visitors for detecting and processing zero values with units in style-related function calls.
*
* @param context The ESLint rule context.
* @returns An object with visitor functions for the ESLint rule.
*
* This function sets up visitors for the following cases:
* 1. The `fontFace` and `globalFontFace` functions, processing their object arguments.
* 2. Style-related functions: `keyframes`, `style`, `styleVariants`, processing their style objects.
* 3. The `globalKeyframes` and `globalStyle` functions, processing the second argument as style objects.
* 4. The `recipe` function, processing the first argument as the recipe object.
*/
export const createZeroUnitVisitors = (context: Rule.RuleContext): Rule.RuleListener => {
return {
CallExpression(node) {
if (node.callee.type !== AST_NODE_TYPES.Identifier) {
return;
}
if (['fontFace', 'globalFontFace'].includes(node.callee.name)) {
const argumentIndex = node.callee.name === 'fontFace' ? 0 : 1;
if (
node.arguments.length > argumentIndex &&
node.arguments[argumentIndex]?.type === AST_NODE_TYPES.ObjectExpression
) {
processZeroUnitInStyleObject(context, node.arguments[argumentIndex] as TSESTree.ObjectExpression);
}
return;
}
if (['keyframes', 'style', 'styleVariants'].includes(node.callee.name)) {
if (node.arguments.length > 0) {
processStyleNode(context, node.arguments[0] as TSESTree.ObjectExpression, processZeroUnitInStyleObject);
}
}
if (['globalKeyframes', 'globalStyle'].includes(node.callee.name) && node.arguments.length >= 2) {
processStyleNode(context, node.arguments[1] as TSESTree.ObjectExpression, processZeroUnitInStyleObject);
}
if (
node.callee.name === 'recipe' &&
node.arguments.length > 0 &&
node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression
) {
processRecipeProperties(context, node.arguments[0] as TSESTree.ObjectExpression, processZeroUnitInStyleObject);
}
},
};
};

View file

@ -44,13 +44,8 @@ export const createNodeVisitors = (
if (!userDefinedGroupOrder || userDefinedGroupOrder.length === 0) {
return enforceAlphabeticalCSSOrderInStyleObject;
}
return (ruleContext: Rule.RuleContext, node: TSESTree.Node) =>
enforceUserDefinedGroupOrderInStyleObject(
ruleContext,
node as TSESTree.ObjectExpression,
userDefinedGroupOrder,
sortRemainingProperties,
);
return (ruleContext: Rule.RuleContext, node: TSESTree.ObjectExpression) =>
enforceUserDefinedGroupOrderInStyleObject(ruleContext, node, userDefinedGroupOrder, sortRemainingProperties);
default:
return enforceAlphabeticalCSSOrderInStyleObject;
}

View file

@ -14,7 +14,7 @@ export const theFont = fontFace({
// Comment to test that the linter doesn't remove it
src: ['url("/fonts/MyFont.woff2") format("woff2")', 'url("/fonts/MyFont.woff") format("woff")'],
ascentOverride: '90%',
descentOverride: '10%',
descentOverride: '0',
fontDisplay: 'swap',
fontFeatureSettings: '"liga" 1',
fontStretch: 'normal',
@ -32,7 +32,7 @@ globalFontFace('GlobalFont', {
// Comment to test that the linter doesn't remove it
src: ['url("/fonts/MyFont.woff2") format("woff2")', 'url("/fonts/MyFont.woff") format("woff")'],
ascentOverride: '90%',
descentOverride: '10%',
descentOverride: '0',
fontDisplay: 'swap',
fontFeatureSettings: '"liga" 1',
fontStretch: 'normal',
@ -50,9 +50,14 @@ globalFontFace('GlobalFont', {
export const spinster = globalKeyframes('spin', {
// Comment to test that the linter doesn't remove it
from: {
width: '100%',
boxSizing: 'inherit',
position: 'relative',
right: 'inherit',
display: 'flex',
gap: 'revert',
transform: 'none',
margin: '0',
fontSize: 'large',
outline: 'none',
border: 'Background',
borderRight: 'ActiveBorder',
borderLeft: 'ActiveBorder',
@ -60,24 +65,24 @@ export const spinster = globalKeyframes('spin', {
borderBottomLeftRadius: 'initial',
borderBottomRightRadius: 'initial',
boxShadow: 'none',
boxSizing: 'inherit',
position: 'relative',
right: 'inherit',
display: 'flex',
gap: 'revert',
transform: 'none',
outline: 'none',
backgroundColor: 'initial',
cursor: 'pointer',
width: '100%',
color: 'red',
fontSize: 'large',
},
// Comment to test that the linter doesn't remove it
to: {
// Comment to test that the linter doesn't remove it
width: '100%',
boxSizing: 'inherit',
position: 'relative',
right: 'inherit',
display: 'flex',
gap: 'revert',
transform: 'none',
margin: '0',
fontSize: 'large',
outline: 'none',
border: 'Background',
borderRight: 'ActiveBorder',
borderLeft: 'ActiveBorder',
@ -85,25 +90,25 @@ export const spinster = globalKeyframes('spin', {
borderBottomLeftRadius: 'initial',
borderBottomRightRadius: 'initial',
boxShadow: 'none',
boxSizing: 'inherit',
position: 'relative',
right: 'inherit',
display: 'flex',
gap: 'revert',
transform: 'none',
outline: 'none',
backgroundColor: 'initial',
cursor: 'pointer',
width: '100%',
color: 'red',
fontSize: 'large',
},
});
export const starter = keyframes({
// Comment to test that the linter doesn't remove it
'0%': {
width: '100%',
boxSizing: 'inherit',
position: 'relative',
right: 'inherit',
display: 'flex',
gap: 'revert',
transform: 'none',
margin: '0',
fontSize: 'large',
outline: 'none',
border: 'Background',
borderRight: 'ActiveBorder',
borderLeft: 'ActiveBorder',
@ -111,24 +116,24 @@ export const starter = keyframes({
borderBottomLeftRadius: 'initial',
borderBottomRightRadius: 'initial',
boxShadow: 'none',
boxSizing: 'inherit',
position: 'relative',
right: 'inherit',
display: 'flex',
gap: 'revert',
transform: 'none',
outline: 'none',
backgroundColor: 'initial',
cursor: 'pointer',
width: '100%',
color: 'red',
fontSize: 'large',
},
// Comment to test that the linter doesn't remove it
'100%': {
// Comment to test that the linter doesn't remove it
width: '100%',
boxSizing: 'inherit',
position: 'relative',
right: 'inherit',
display: 'flex',
gap: 'revert',
transform: 'none',
margin: '0',
fontSize: 'large',
outline: 'none',
border: 'Background',
borderRight: 'ActiveBorder',
borderLeft: 'ActiveBorder',
@ -136,40 +141,35 @@ export const starter = keyframes({
borderBottomLeftRadius: 'initial',
borderBottomRightRadius: 'initial',
boxShadow: 'none',
backgroundColor: 'initial',
cursor: 'pointer',
width: '100%',
color: 'red',
fontSize: 'large',
},
});
globalStyle('*, ::before, ::after', {
// Comment to test that the linter doesn't remove it
boxSizing: 'inherit',
position: 'relative',
right: 'inherit',
display: 'flex',
gap: 'revert',
transform: 'none',
outline: 'none',
backgroundColor: 'initial',
cursor: 'pointer',
color: 'red',
},
});
globalStyle('*, ::before, ::after', {
// Comment to test that the linter doesn't remove it
width: '100%',
margin: '0',
fontSize: 'large',
outline: 'none',
border: 'Background',
borderRight: 'ActiveBorder',
borderLeft: 'ActiveBorder',
borderRadius: 'initial',
borderBottomLeftRadius: 'initial',
borderBottomRightRadius: 'initial',
boxSizing: 'inherit',
position: 'relative',
right: 'inherit',
display: 'flex',
gap: 'revert',
transform: 'none',
outline: 'none',
backgroundColor: 'initial',
cursor: 'pointer',
width: '100%',
color: 'red',
fontSize: 'large',
});
// style with an array
@ -177,9 +177,14 @@ const accordionContentBase = style([
// Comment to test that the linter doesn't remove it
{
// Comment to test that the linter doesn't remove it
width: '100%',
boxSizing: 'inherit',
position: 'relative',
right: 'inherit',
display: 'flex',
gap: 'revert',
transform: 'none',
margin: '0',
fontSize: 'large',
outline: 'none',
border: 'Background',
borderRight: 'ActiveBorder',
borderLeft: 'ActiveBorder',
@ -187,24 +192,24 @@ const accordionContentBase = style([
borderBottomLeftRadius: 'initial',
borderBottomRightRadius: 'initial',
boxShadow: 'none',
boxSizing: 'inherit',
position: 'relative',
right: 'inherit',
display: 'flex',
gap: 'revert',
transform: 'none',
outline: 'none',
backgroundColor: 'initial',
cursor: 'pointer',
width: '100%',
color: 'red',
fontSize: 'large',
// special selector to test that the linter doesn't remove it
'@supports': {
'(hanging-punctuation: first) and (font: -apple-system-body) and (-webkit-appearance: none)': {
// Comment to test that the linter doesn't remove it
width: '100%',
boxSizing: 'inherit',
position: 'relative',
right: 'inherit',
display: 'flex',
gap: 'revert',
transform: 'none',
margin: '0',
fontSize: 'large',
outline: 'none',
border: 'Background',
borderRight: 'ActiveBorder',
borderLeft: 'ActiveBorder',
@ -212,16 +217,11 @@ const accordionContentBase = style([
borderBottomLeftRadius: 'initial',
borderBottomRightRadius: 'initial',
boxShadow: 'none',
boxSizing: 'inherit',
position: 'relative',
right: 'inherit',
display: 'flex',
gap: 'revert',
transform: 'none',
outline: 'none',
backgroundColor: 'initial',
cursor: 'pointer',
width: '100%',
color: 'red',
fontSize: 'large',
},
},
},
@ -237,9 +237,14 @@ export const accordionContent = recipe({
// Comment to test that the linter doesn't remove it
false: {
// Comment to test that the linter doesn't remove it
width: '100%',
boxSizing: 'inherit',
position: 'relative',
right: 'inherit',
display: 'flex',
gap: 'revert',
transform: 'none',
margin: '0',
fontSize: 'large',
outline: 'none',
border: 'Background',
borderRight: 'ActiveBorder',
borderLeft: 'ActiveBorder',
@ -247,22 +252,22 @@ export const accordionContent = recipe({
borderBottomLeftRadius: 'initial',
borderBottomRightRadius: 'initial',
boxShadow: 'none',
boxSizing: 'inherit',
position: 'relative',
right: 'inherit',
display: 'flex',
gap: 'revert',
transform: 'none',
outline: 'none',
backgroundColor: 'initial',
cursor: 'pointer',
width: '100%',
color: 'red',
fontSize: 'large',
},
true: {
width: '100%',
boxSizing: 'inherit',
position: 'relative',
right: 'inherit',
display: 'flex',
gap: 'revert',
transform: 'none',
margin: '0',
fontSize: 'large',
outline: 'none',
border: 'Background',
borderRight: 'ActiveBorder',
borderLeft: 'ActiveBorder',
@ -270,23 +275,23 @@ export const accordionContent = recipe({
borderBottomLeftRadius: 'initial',
borderBottomRightRadius: 'initial',
boxShadow: 'none',
boxSizing: 'inherit',
position: 'relative',
right: 'inherit',
display: 'flex',
gap: 'revert',
transform: 'none',
outline: 'none',
backgroundColor: 'initial',
cursor: 'pointer',
width: '100%',
color: 'red',
fontSize: 'large',
// pseudo selector inside a variant
':hover': {
// Comment to test that the linter doesn't remove it
width: '100%',
boxSizing: 'inherit',
position: 'relative',
right: 'inherit',
display: 'flex',
gap: 'revert',
transform: 'none',
margin: '0',
fontSize: 'large',
outline: 'none',
border: 'Background',
borderRight: 'ActiveBorder',
borderLeft: 'ActiveBorder',
@ -294,16 +299,11 @@ export const accordionContent = recipe({
borderBottomLeftRadius: 'initial',
borderBottomRightRadius: 'initial',
boxShadow: 'none',
boxSizing: 'inherit',
position: 'relative',
right: 'inherit',
display: 'flex',
gap: 'revert',
transform: 'none',
outline: 'none',
backgroundColor: 'initial',
cursor: 'pointer',
width: '100%',
color: 'red',
fontSize: 'large',
},
},
},
@ -311,32 +311,37 @@ export const accordionContent = recipe({
});
export const item = style({
width: '100%',
margin: '0',
fontSize: 'large',
border: 'Background',
borderRight: 'ActiveBorder',
borderLeft: 'ActiveBorder',
borderRadius: 'initial',
borderBottomLeftRadius: 'initial',
borderBottomRightRadius: 'initial',
boxSizing: 'inherit',
position: 'relative',
right: 'inherit',
display: 'flex',
gap: 'revert',
transform: 'none',
margin: '0',
outline: 'none',
border: 'Background',
borderRight: 'ActiveBorder',
borderLeft: 'ActiveBorder',
borderRadius: 'initial',
borderBottomLeftRadius: 'initial',
borderBottomRightRadius: 'initial',
backgroundColor: 'initial',
cursor: 'pointer',
width: '100%',
color: 'red',
fontSize: 'large',
// pseudo selector inside a style
':focus-visible': {
// Comment to test that the linter doesn't remove it
width: '100%',
boxSizing: 'inherit',
position: 'relative',
right: 'inherit',
display: 'flex',
gap: 'revert',
transform: 'none',
margin: '0',
fontSize: 'large',
outline: 'none',
border: 'Background',
borderRight: 'ActiveBorder',
borderLeft: 'ActiveBorder',
@ -344,25 +349,25 @@ export const item = style({
borderBottomLeftRadius: 'initial',
borderBottomRightRadius: 'initial',
boxShadow: 'none',
boxSizing: 'inherit',
position: 'relative',
right: 'inherit',
display: 'flex',
gap: 'revert',
transform: 'none',
outline: 'none',
backgroundColor: 'initial',
cursor: 'pointer',
width: '100%',
color: 'red',
fontSize: 'large',
},
selectors: {
// Comment to test that the linter doesn't remove it
'&[data-pressed]': {
// Comment to test that the linter doesn't remove it
width: '100%',
boxSizing: 'inherit',
position: 'relative',
right: 'inherit',
display: 'flex',
gap: 'revert',
transform: 'none',
margin: '0',
fontSize: 'large',
outline: 'none',
border: 'Background',
borderRight: 'ActiveBorder',
borderLeft: 'ActiveBorder',
@ -370,16 +375,11 @@ export const item = style({
borderBottomLeftRadius: 'initial',
borderBottomRightRadius: 'initial',
boxShadow: 'none',
boxSizing: 'inherit',
position: 'relative',
right: 'inherit',
display: 'flex',
gap: 'revert',
transform: 'none',
outline: 'none',
backgroundColor: 'initial',
cursor: 'pointer',
width: '100%',
color: 'red',
fontSize: 'large',
},
},
});
@ -388,31 +388,36 @@ export const selectButtonVariants = styleVariants({
// Comment to test that the linter doesn't remove it
bordered: {
// Comment to test that the linter doesn't remove it
width: '100%',
margin: '0',
fontSize: 'large',
border: 'Background',
borderRight: 'ActiveBorder',
borderLeft: 'ActiveBorder',
borderRadius: 'initial',
borderBottomLeftRadius: 'initial',
borderBottomRightRadius: 'initial',
boxSizing: 'inherit',
position: 'relative',
right: 'inherit',
display: 'flex',
gap: 'revert',
transform: 'none',
margin: '0',
outline: 'none',
border: 'Background',
borderRight: 'ActiveBorder',
borderLeft: 'ActiveBorder',
borderRadius: 'initial',
borderBottomLeftRadius: 'initial',
borderBottomRightRadius: 'initial',
backgroundColor: 'initial',
cursor: 'pointer',
width: '100%',
color: 'red',
fontSize: 'large',
},
borderless: {
width: '100%',
boxSizing: 'inherit',
position: 'relative',
right: 'inherit',
display: 'flex',
gap: 'revert',
transform: 'none',
margin: '0',
fontSize: 'large',
outline: 'none',
border: 'Background',
borderRight: 'ActiveBorder',
borderLeft: 'ActiveBorder',
@ -420,16 +425,11 @@ export const selectButtonVariants = styleVariants({
borderBottomLeftRadius: 'initial',
borderBottomRightRadius: 'initial',
boxShadow: 'none',
boxSizing: 'inherit',
position: 'relative',
right: 'inherit',
display: 'flex',
gap: 'revert',
transform: 'none',
outline: 'none',
backgroundColor: 'initial',
cursor: 'pointer',
width: '100%',
color: 'red',
fontSize: 'large',
},
});

View file

@ -2,17 +2,19 @@ import alphabeticalOrderRule from './css-rules/alphabetical-order/index.js';
import concentricOrderRule from './css-rules/concentric-order/index.js';
import customOrderRule from './css-rules/custom-order/rule-definition.js';
import noEmptyStyleBlocksRule from './css-rules/no-empty-blocks/rule-definition.js';
import noZeroUnitRule from './css-rules/no-zero-unit/rule-definition.js';
export const vanillaExtract = {
meta: {
name: '@antebudimir/eslint-plugin-vanilla-extract',
version: '1.7.0',
version: '1.8.0',
},
rules: {
'alphabetical-order': alphabeticalOrderRule,
'concentric-order': concentricOrderRule,
'custom-order': customOrderRule,
'no-empty-style-blocks': noEmptyStyleBlocksRule,
'no-zero-unit': noZeroUnitRule,
},
configs: {
recommended: {
@ -20,6 +22,7 @@ export const vanillaExtract = {
rules: {
'vanilla-extract/concentric-order': 'error',
'vanilla-extract/no-empty-style-blocks': 'error',
'vanilla-extract/no-zero-unit': 'error',
},
},
},

View file

@ -13,6 +13,8 @@
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"useUnknownInCatchVariables": true,
// Interop Options