feat 🥁: add no-empty-style-blocks rule

Add comprehensive rule to detect and prevent empty CSS style blocks:

- Identify style objects with no properties
- Flag empty style blocks as potential code quality issues
- Provide auto-fix capability to remove empty blocks
- Handle edge cases like comments-only blocks

This rule helps maintain cleaner codebases by eliminating empty style definitions that often result from incomplete refactoring or forgotten implementations, reducing confusion and unnecessary code.
This commit is contained in:
Ante Budimir 2025-04-06 11:37:34 +03:00
parent f346002fb0
commit 175ce9aef8
45 changed files with 2674 additions and 566 deletions

View file

@ -5,88 +5,96 @@ 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.6.0] - 2025-04-06
- add new rule `no-empty-style-blocks` that detects and disallows empty style objects in vanilla-extract style functions
- Identifies empty objects in style, styleVariants, recipe, globalStyle and other API functions
- Handles nested empty selectors, media queries, and conditional styles
- Provides auto-fix capability to remove unnecessary empty blocks
- Special handling for recipe objects with empty base and variants
## [1.5.3] - 2025-03-12
- Add bug and feature request templates ()
- Add bug and feature request templates
## [1.5.2] - 2025-03-12
- Add CODEOWNERS file to enforce code review requirements (cd4314d)
- Add CODEOWNERS file to enforce code review requirements
## [1.5.1] - 2025-03-12
- Update project dependencies to latest versions (d7b0ca8)
- Update project dependencies to latest versions
## [1.5.0] - 2025-03-12
- Fix handling of missing groupOrder configuration (46751da)
- Fix handling of missing groupOrder configuration
- Refactor negative conditions to positive ones with optional chaining
- Add comprehensive tests to achieve total coverage
## [1.4.7] - 2025-03-10
- Exclude test directories from published package (5557409)
- Exclude test directories from published package
## [1.4.6] - 2025-03-10
- Add demo gif to README (fb77b52)
- Add demo gif to README
## [1.4.5] - 2025-03-10
- Add GitHub Actions workflow for linting and testing (58249ba)
- Add GitHub Actions workflow for linting and testing
## [1.4.4] - 2025-03-10
- Improve GitHub Actions workflow for release creation (d2b62d3)
- Improve GitHub Actions workflow for release creation
## [1.4.3] - 2025-03-10
- Add coverage for shared utility functions (1092b47)
- Add coverage for shared utility functions
## [1.4.2] - 2025-03-09
- Add GitHub Action to create releases from tags (7c19c9d)
- Add GitHub Action to create releases from tags
## [1.4.1] - 2025-03-09
- Add comprehensive test suite for CSS ordering rules (5f1e602)
- Add comprehensive test suite for CSS ordering rules
## [1.4.0] - 2025-03-08
- Implement special ordering for fontFace APIs (3e9bad1)
- Implement special ordering for fontFace APIs
## [1.3.1] - 2025-03-07
- Update milestones (8916be7)
- Update milestones
## [1.3.0] - 2025-03-06
- Add script for versioning updates (f2ad87c)
- Add script for versioning updates
## [1.2.0] - 2025-03-05
- Add support for linting keyframes and globalKeyframes (dea0a32)
- Add support for linting keyframes and globalKeyframes
## [1.1.2] - 2025-03-05
- add .npmignore to exclude development files from npm package (223a81d)
- add .npmignore to exclude development files from npm package
## [1.1.1] - 2025-03-05
- Improve packaging and TypeScript configuration (c616fb0)
- Improve packaging and TypeScript configuration
## [1.1.0] - 2025-03-04
- Lower minimum Node.js version to 18.18.0 (44aba94)
- Lower minimum Node.js version to 18.18.0
## [1.0.2] - 2025-03-04
- Add npm version badge and link to vanilla-extract (87acd61)
- Add npm version badge and link to vanilla-extract
## [1.0.1] - 2025-03-04
- Add sample CSS file for linting demo during development (88a9d43)
- Add sample CSS file for linting demo during development
## [1.0.0] - 2025-03-04
- Initialize project with complete codebase (d569dea)
- Initialize project with complete codebase

View file

@ -1,6 +1,6 @@
# @antebudimir/eslint-plugin-vanilla-extract
[![npm version](https://img.shields.io/npm/v/@antebudimir/eslint-plugin-vanilla-extract.svg)](https://www.npmjs.com/package/@antebudimir/eslint-plugin-vanilla-extract) [![CI](https://github.com/antebudimir/eslint-plugin-vanilla-extract/actions/workflows/ci.yml/badge.svg)](https://github.com/antebudimir/eslint-plugin-vanilla-extract/actions/workflows/ci.yml) [![Coverage Status](https://coveralls.io/repos/github/antebudimir/eslint-plugin-vanilla-extract/badge.svg?branch=main)](https://coveralls.io/github/antebudimir/eslint-plugin-vanilla-extract?branch=main)
[![CI](https://github.com/antebudimir/eslint-plugin-vanilla-extract/actions/workflows/ci.yml/badge.svg)](https://github.com/antebudimir/eslint-plugin-vanilla-extract/actions/workflows/ci.yml) [![Coverage Status](https://coveralls.io/repos/github/antebudimir/eslint-plugin-vanilla-extract/badge.svg?branch=main)](https://coveralls.io/github/antebudimir/eslint-plugin-vanilla-extract?branch=main) [![npm version](https://img.shields.io/npm/v/@antebudimir/eslint-plugin-vanilla-extract.svg)](https://www.npmjs.com/package/@antebudimir/eslint-plugin-vanilla-extract) ![NPM Downloads](https://img.shields.io/npm/d18m/%40antebudimir%2Feslint-plugin-vanilla-extract)
An ESLint plugin for enforcing best practices in [vanilla-extract](https://github.com/vanilla-extract-css/vanilla-extract) CSS styles, including CSS property ordering and additional linting rules. Available presets are for alphabetical and [concentric](https://rhodesmill.org/brandon/2011/concentric-css/) CSS ordering. The plugin also supports a custom group ordering option based on groups available in [concentric CSS](src/css-rules/concentric-order/concentric-groups.ts).
@ -193,6 +193,42 @@ export const myStyle = style({
});
```
### vanilla-extract/no-empty-style-blocks
This rule detects and prevents empty style blocks in vanilla-extract stylesheets. It helps maintain cleaner codebases by eliminating empty style definitions that often result from incomplete refactoring or forgotten implementations.
```typescript
// ❌ Incorrect
import { style } from '@vanilla-extract/css';
export const emptyStyle = style({});
export const nestedEmpty = style({
color: 'blue',
':hover': {},
'@media': {
'(min-width: 768px)': {},
},
});
export const recipeWithEmptyVariants = recipe({
base: { color: 'black' },
variants: {},
});
// ✅ Correct
import { style } from '@vanilla-extract/css';
export const nestedEmpty = style({
color: 'blue',
});
export const recipeWithEmptyVariants = recipe({
base: { color: 'black' },
});
```
## Font Face Declarations
For `fontFace` and `globalFontFace` API calls, all three ordering rules (alphabetical, concentric, and custom) enforce the same special ordering:
@ -266,11 +302,12 @@ The roadmap outlines the project's current status and future plans:
- Initial release with support for alphabetical, concentric, and custom group CSS ordering.
- Auto-fix capability integrated into ESLint.
- Support for multiple vanilla-extract APIs (e.g., `style`, `styleVariants`, `recipe`, `globalStyle`, `fontFace`, etc.).
- Rules tested.
- `no-empty-style-blocks` rule to disallow empty blocks.
- Comprehensive rule testing.
### Current Work
- `no-empty-blocks` rule to disallow empty blocks.
- Setting up recommended ESLint configuration for the plugin.
### Upcoming Features

View file

@ -4,7 +4,6 @@ import { FlatCompat } from '@eslint/eslintrc';
import eslintPluginESLintPlugin from 'eslint-plugin-eslint-plugin';
import importPlugin from 'eslint-plugin-import';
import * as tseslint from 'typescript-eslint';
import vanillaExtract from '@antebudimir/eslint-plugin-vanilla-extract';
// mimic CommonJS variables
const __filename = fileURLToPath(import.meta.url);
@ -77,25 +76,6 @@ export default [
},
...tseslint.configs.recommended,
{
files: ['**/*.css.ts'],
plugins: {
'vanilla-extract': vanillaExtract,
},
rules: {
// 'vanilla-extract/alphabetical-order': 'warn',
// 'vanilla-extract/concentric-order': 'error',
'vanilla-extract/custom-order': [
'error',
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
// Optional
sortRemainingProperties: 'concentric', // or 'alphabetical' (default)
},
],
},
},
{
files: ['**/*.{js,ts}'],
languageOptions: {

View file

@ -1,6 +1,6 @@
{
"name": "@antebudimir/eslint-plugin-vanilla-extract",
"version": "1.5.3",
"version": "1.6.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",
@ -84,6 +84,6 @@
"prettier": "^3.5.3",
"typescript": "^5.8.2",
"typescript-eslint": "^8.26.1",
"vitest": "^3.0.8"
"vitest": "3.0.8"
}
}

808
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -22,12 +22,12 @@ const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8'));
const newVersion = packageJson.version;
// Read src/index.ts
let indexTsContent = await fs.readFile(indexTsPath, 'utf-8');
const indexTsContent = await fs.readFile(indexTsPath, 'utf-8');
// Replace the version string in src/index.ts
indexTsContent = indexTsContent.replace(/version: '(\d+\.\d+\.\d+)'/, `version: '${newVersion}'`);
const updatedIndexTsContent = indexTsContent.replace(/version: '(\d+\.\d+\.\d+)'/, `version: '${newVersion}'`);
// Write the updated content back to src/index.ts
await fs.writeFile(indexTsPath, indexTsContent);
await fs.writeFile(indexTsPath, updatedIndexTsContent);
console.log(`Updated package.json and src/index.ts to version ${newVersion}`);

View file

@ -2,7 +2,7 @@ import type { Rule } from 'eslint';
import { TSESTree } from '@typescript-eslint/utils';
import { comparePropertiesAlphabetically } from '../shared-utils/alphabetical-property-comparator.js';
import { generateFixesForCSSOrder } from '../shared-utils/css-order-fixer.js';
import { getPropertyName } from '../shared-utils/property-separator.js';
import { getPropertyNameForSorting } from '../shared-utils/property-separator.js';
/**
* Reports an ordering issue to ESLint and generates fixes.
@ -21,8 +21,8 @@ const reportOrderingIssue = (
node: nextProperty as Rule.Node,
messageId: 'alphabeticalOrder',
data: {
nextProperty: getPropertyName(nextProperty),
currentProperty: getPropertyName(currentProperty),
nextProperty: getPropertyNameForSorting(nextProperty),
currentProperty: getPropertyNameForSorting(currentProperty),
},
fix: (fixer) =>
generateFixesForCSSOrder(

View file

@ -1,5 +1,6 @@
import type { Rule } from 'eslint';
import { createNodeVisitors } from '../shared-utils/order-strategy-visitor-creator.js';
import type { OrderingStrategy } from '../types.js';
const alphabeticalOrderRule: Rule.RuleModule = {
meta: {
@ -18,7 +19,7 @@ const alphabeticalOrderRule: Rule.RuleModule = {
},
},
create(context) {
return createNodeVisitors(context, 'alphabetical');
return createNodeVisitors(context, 'alphabetical' as OrderingStrategy);
},
};

View file

@ -2,7 +2,7 @@ import type { Rule } from 'eslint';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
import { createCSSPropertyPriorityMap } from '../shared-utils/css-property-priority-map.js';
import { isSelectorsObject, processNestedSelectors } from '../shared-utils/nested-selectors-processor.js';
import { getPropertyName, separateProperties } from '../shared-utils/property-separator.js';
import { getPropertyNameForSorting, separateProperties } from '../shared-utils/property-separator.js';
import { enforceConcentricCSSOrder } from './property-order-enforcer.js';
import type { CSSPropertyInfo } from './types.js';
@ -16,7 +16,7 @@ const cssPropertyPriorityMap = createCSSPropertyPriorityMap();
*/
const buildCSSPropertyInfoList = (regularStyleProperties: TSESTree.Property[]): CSSPropertyInfo[] => {
return regularStyleProperties.map((styleProperty) => {
const propertyName = getPropertyName(styleProperty);
const propertyName = getPropertyNameForSorting(styleProperty);
const propertyInfo = cssPropertyPriorityMap.get(propertyName);
return {

View file

@ -7,3 +7,5 @@ export interface CSSPropertyInfo {
positionInGroup: number;
group?: string;
}
export type SortRemainingProperties = 'alphabetical' | 'concentric';

View file

@ -1,7 +1,7 @@
import type { Rule } from 'eslint';
import { generateFixesForCSSOrder } from '../shared-utils/css-order-fixer.js';
import { createCSSPropertyPriorityMap } from '../shared-utils/css-property-priority-map.js';
import type { CSSPropertyInfo } from '../concentric-order/types.js';
import type { CSSPropertyInfo, SortRemainingProperties } from '../concentric-order/types.js';
/**
* Enforces a custom ordering of CSS properties based on user-defined groups.
@ -21,7 +21,7 @@ export const enforceCustomGroupOrder = (
ruleContext: Rule.RuleContext,
cssPropertyInfoList: CSSPropertyInfo[],
userDefinedGroups: string[] = [],
sortRemainingProperties?: 'alphabetical' | 'concentric',
sortRemainingProperties?: SortRemainingProperties,
): void => {
if (cssPropertyInfoList.length > 1) {
const cssPropertyPriorityMap = createCSSPropertyPriorityMap(userDefinedGroups);
@ -50,7 +50,7 @@ export const enforceCustomGroupOrder = (
}
// For properties not in user-defined groups
if (sortRemainingProperties === 'alphabetical') {
if (sortRemainingProperties === ('alphabetical' as SortRemainingProperties)) {
return firstProperty.name.localeCompare(secondProperty.name);
} else {
return (
@ -70,9 +70,8 @@ export const enforceCustomGroupOrder = (
if (violatingProperty) {
const indexInSorted = cssPropertyInfoList.indexOf(violatingProperty);
const sortedProperty = sortedPropertyList[indexInSorted];
// Defensive programming - sortedProperty will always exist and have a name because sortedPropertyList is derived from cssPropertyInfoList and cssPropertyInfoList exists and is non-empty
// Therefore, we can exclude the next line from coverage because it's unreachable in practice
/* v8 ignore next */
// Defensive programming - sortedProperty will always exist and have a name because sortedPropertyList is derived from cssPropertyInfoList and cssPropertyInfoList exists and is non-empty.
// This fallback is theoretically unreachable in practice but included for type safety.
const nextPropertyName = sortedProperty?.name ?? '';
ruleContext.report({

View file

@ -3,6 +3,7 @@ import { TSESTree } from '@typescript-eslint/utils';
import { processRecipeProperties } from '../shared-utils/recipe-property-processor.js';
import { processStyleNode } from '../shared-utils/style-node-processor.js';
import { enforceUserDefinedGroupOrderInStyleObject } from './style-object-processor.js';
import type { SortRemainingProperties } from '../concentric-order/types.js';
/**
* Enforces custom group ordering of CSS properties within a recipe function call.
@ -21,7 +22,7 @@ export const enforceUserDefinedGroupOrderInRecipe = (
ruleContext: Rule.RuleContext,
callExpression: TSESTree.CallExpression,
userDefinedGroups: string[],
sortRemainingPropertiesMethod?: 'alphabetical' | 'concentric',
sortRemainingProperties?: SortRemainingProperties,
): void => {
if (callExpression.arguments[0]?.type === 'ObjectExpression') {
const recipeObjectExpression = callExpression.arguments[0];
@ -32,7 +33,7 @@ export const enforceUserDefinedGroupOrderInRecipe = (
styleContext,
styleObjectNode,
userDefinedGroups,
sortRemainingPropertiesMethod,
sortRemainingProperties,
),
),
);

View file

@ -1,10 +1,11 @@
import type { Rule } from 'eslint';
import { availableGroups } from '../concentric-order/concentric-groups.js';
import { createNodeVisitors } from '../shared-utils/order-strategy-visitor-creator.js';
import type { SortRemainingProperties } from '../concentric-order/types.js';
interface CustomGroupRuleConfiguration {
groupOrder?: string[];
sortRemainingProperties: 'alphabetical' | 'concentric';
sortRemainingProperties: SortRemainingProperties;
}
const customGroupOrderRule: Rule.RuleModule = {
@ -46,7 +47,8 @@ const customGroupOrderRule: Rule.RuleModule = {
create(ruleContext: Rule.RuleContext) {
const ruleConfiguration = ruleContext.options[0] as CustomGroupRuleConfiguration;
const userDefinedGroupOrder = ruleConfiguration?.groupOrder ?? [];
const sortRemainingPropertiesMethod = ruleConfiguration?.sortRemainingProperties ?? 'alphabetical';
const sortRemainingPropertiesMethod =
ruleConfiguration?.sortRemainingProperties ?? ('alphabetical' as SortRemainingProperties);
return createNodeVisitors(
ruleContext,

View file

@ -3,9 +3,9 @@ import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
import { concentricGroups } from '../concentric-order/concentric-groups.js';
import { createCSSPropertyPriorityMap } from '../shared-utils/css-property-priority-map.js';
import { isSelectorsObject, processNestedSelectors } from '../shared-utils/nested-selectors-processor.js';
import { getPropertyName, separateProperties } from '../shared-utils/property-separator.js';
import { getPropertyNameForSorting, separateProperties } from '../shared-utils/property-separator.js';
import { enforceCustomGroupOrder } from './property-order-enforcer.js';
import type { CSSPropertyInfo } from '../concentric-order/types.js';
import type { CSSPropertyInfo, SortRemainingProperties } from '../concentric-order/types.js';
/**
* Enforces a custom ordering of CSS properties based on user-defined groups in a given style object.
@ -13,7 +13,7 @@ import type { CSSPropertyInfo } from '../concentric-order/types.js';
* @param context The ESLint rule context for reporting and fixing issues.
* @param styleObject The ObjectExpression node representing the style object to be processed.
* @param userDefinedGroups An array of property groups in the desired order.
* @param sortRemainingPropertiesMethod Strategy for sorting properties not in user-defined groups ('alphabetical' or 'concentric'). Defaults to 'concentric'.
* @param sortRemainingProperties Strategy for sorting properties not in user-defined groups ('alphabetical' or 'concentric'). Defaults to 'concentric'.
*
* This function:
* 1. Validates the input styleObject.
@ -27,7 +27,7 @@ export const enforceUserDefinedGroupOrderInStyleObject = (
ruleContext: Rule.RuleContext,
styleObject: TSESTree.ObjectExpression,
userDefinedGroups: string[],
sortRemainingPropertiesMethod: 'alphabetical' | 'concentric' = 'concentric',
sortRemainingProperties: SortRemainingProperties = 'concentric',
): void => {
if (styleObject?.type === AST_NODE_TYPES.ObjectExpression) {
if (isSelectorsObject(styleObject)) {
@ -37,7 +37,7 @@ export const enforceUserDefinedGroupOrderInStyleObject = (
ruleContext,
property.value,
userDefinedGroups,
sortRemainingPropertiesMethod,
sortRemainingProperties,
);
}
});
@ -48,7 +48,7 @@ export const enforceUserDefinedGroupOrderInStyleObject = (
const { regularProperties } = separateProperties(styleObject.properties);
const cssPropertyInfoList: CSSPropertyInfo[] = regularProperties.map((property) => {
const propertyName = getPropertyName(property);
const propertyName = getPropertyNameForSorting(property);
const propertyInfo = cssPropertyPriorityMap.get(propertyName);
const group =
userDefinedGroups.find((groupName) => concentricGroups[groupName]?.includes(propertyName)) || 'remaining';
@ -63,15 +63,10 @@ export const enforceUserDefinedGroupOrderInStyleObject = (
};
});
enforceCustomGroupOrder(ruleContext, cssPropertyInfoList, userDefinedGroups, sortRemainingPropertiesMethod);
enforceCustomGroupOrder(ruleContext, cssPropertyInfoList, userDefinedGroups, sortRemainingProperties);
processNestedSelectors(ruleContext, styleObject, (nestedContext, nestedNode) =>
enforceUserDefinedGroupOrderInStyleObject(
nestedContext,
nestedNode,
userDefinedGroups,
sortRemainingPropertiesMethod,
),
enforceUserDefinedGroupOrderInStyleObject(nestedContext, nestedNode, userDefinedGroups, sortRemainingProperties),
);
}
};

View file

@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import { areAllChildrenEmpty } from '../property-utils.js';
import type { TSESTree } from '@typescript-eslint/utils';
describe('areAllChildrenEmpty', () => {
it('should return true for an empty object with no properties', () => {
const emptyObject = {
type: 'ObjectExpression',
properties: [],
};
const result = areAllChildrenEmpty(emptyObject as unknown as TSESTree.ObjectExpression);
expect(result).toBe(true);
});
it('should return false when a property is not of type Property', () => {
const objectWithNonPropertyType = {
type: 'ObjectExpression',
properties: [
{
type: 'SpreadElement', // Not a Property type
argument: {
type: 'Identifier',
name: 'spread',
},
},
],
};
const result = areAllChildrenEmpty(objectWithNonPropertyType as unknown as TSESTree.ObjectExpression);
expect(result).toBe(false);
});
it('should return false when a property value is not an ObjectExpression', () => {
const objectWithNonObjectValue = {
type: 'ObjectExpression',
properties: [
{
type: 'Property',
key: {
type: 'Identifier',
name: 'prop',
},
value: {
type: 'Literal', // Not an ObjectExpression
value: 'string value',
},
},
],
};
const result = areAllChildrenEmpty(objectWithNonObjectValue as unknown as TSESTree.ObjectExpression);
expect(result).toBe(false);
});
});

View file

@ -0,0 +1,58 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import noEmptyStyleBlocksRule from '../rule-definition.js';
run({
name: 'vanilla-extract/no-empty-style-blocks/conditional',
rule: noEmptyStyleBlocksRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// Valid cases with non-empty objects
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style(condition ? { color: 'red' } : { background: 'blue' });
`,
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style(condition ? { /* comment */ color: 'red' } : { background: 'blue' });
`,
},
],
invalid: [
// Test empty consequent (first branch)
{
code: `import { style } from '@vanilla-extract/css';
const myStyle = style(condition ? {} : { background: 'blue' });`,
errors: [{ messageId: 'emptyConditionalStyle' }],
},
// Test empty alternate (second branch)
{
code: `import { style } from '@vanilla-extract/css';
const myStyle = style(condition ? { color: 'red' } : {});`,
errors: [{ messageId: 'emptyConditionalStyle' }],
},
// Test both branches empty - should report the entire declaration
{
code: `import { style } from '@vanilla-extract/css';
const myStyle = style(condition ? {} : {});
`,
errors: [{ messageId: 'emptyStyleDeclaration' }],
},
// Test nested conditional expressions with empty objects
{
code: `import { style } from '@vanilla-extract/css';
const myStyle = style(outerCondition ? (innerCondition ? {} : { color: 'blue' }) : {});`,
errors: [{ messageId: 'emptyConditionalStyle' }],
},
],
});

View file

@ -0,0 +1,132 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import noEmptyStyleBlocksRule from '../rule-definition.js';
run({
name: 'vanilla-extract/no-empty-style-blocks/style',
rule: noEmptyStyleBlocksRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// Basic non-empty style
`
import { style } from '@vanilla-extract/css';
const myStyle = style({
color: 'blue',
margin: '10px'
});
`,
// Style with comments (not empty)
`
import { style } from '@vanilla-extract/css';
const myStyle = style({
/* This is a comment */
color: 'blue'
});
`,
// Valid globalStyle with non-empty style object
`
import { globalStyle } from '@vanilla-extract/css';
globalStyle('a', {
color: 'blue'
});
`,
// Valid globalFontFace with non-empty style object
`
import { globalFontFace } from '@vanilla-extract/css';
globalFontFace('MyFont', {
src: 'url("/fonts/my-font.woff2")'
});
`,
// Valid globalKeyframes with non-empty style object
`
import { globalKeyframes } from '@vanilla-extract/css';
globalKeyframes('fadeIn', {
'0%': { opacity: 0 },
'100%': { opacity: 1 }
});
`,
// Add these to the valid array
// Test for global functions without enough arguments
`
import { globalStyle } from '@vanilla-extract/css';
// Missing second argument (style object)
globalStyle('.selector');
`,
// Test for globalFontFace without enough arguments
`
import { globalFontFace } from '@vanilla-extract/css';
// Missing second argument (style object)
globalFontFace('MyFont');
`,
// Test for globalKeyframes without enough arguments
`
import { globalKeyframes } from '@vanilla-extract/css';
// Missing second argument (style object)
globalKeyframes('fadeIn');
`,
],
invalid: [
// Empty globalStyle object
{
code: `
import { globalStyle } from '@vanilla-extract/css';
globalStyle('ul', {});
`,
errors: [{ messageId: 'emptyStyleDeclaration' }],
output: `
import { globalStyle } from '@vanilla-extract/css';
`,
},
// Empty globalFontFace object
{
code: `
import { globalFontFace } from '@vanilla-extract/css';
globalFontFace('MyFont', {});
`,
errors: [{ messageId: 'emptyStyleDeclaration' }],
output: `
import { globalFontFace } from '@vanilla-extract/css';
`,
},
// Empty globalKeyframes object
{
code: `
import { globalKeyframes } from '@vanilla-extract/css';
globalKeyframes('fadeIn', {});
`,
errors: [{ messageId: 'emptyStyleDeclaration' }],
output: `
import { globalKeyframes } from '@vanilla-extract/css';
`,
},
],
});

View file

@ -0,0 +1,103 @@
import { TSESTree, AST_NODE_TYPES } from '@typescript-eslint/utils';
import { describe, expect, it } from 'vitest';
import { isEffectivelyEmptyStylesObject } from '../empty-style-visitor-creator.js';
describe('isEffectivelyEmptyStylesObject', () => {
const createObjectExpression = (properties: TSESTree.Property[]): TSESTree.ObjectExpression => ({
type: AST_NODE_TYPES.ObjectExpression,
properties,
range: [0, 0],
loc: {
start: { line: 0, column: 0 },
end: { line: 0, column: 0 },
},
parent: null as unknown as TSESTree.Node,
});
const createProperty = (key: string, value: TSESTree.Expression): TSESTree.Property => ({
type: AST_NODE_TYPES.Property,
key: {
type: AST_NODE_TYPES.Identifier,
name: key,
range: [0, 0],
loc: {
start: { line: 0, column: 0 },
end: { line: 0, column: 0 },
},
parent: null as unknown as TSESTree.Property,
decorators: [],
optional: false,
typeAnnotation: undefined,
},
value,
computed: false,
method: false,
shorthand: false,
kind: 'init',
range: [0, 0],
loc: {
start: { line: 0, column: 0 },
end: { line: 0, column: 0 },
},
parent: null as unknown as TSESTree.ObjectExpression,
optional: false,
});
const createLiteral = (value: string): TSESTree.Literal => ({
type: AST_NODE_TYPES.Literal,
value,
raw: `'${value}'`,
range: [0, 0],
loc: {
start: { line: 0, column: 0 },
end: { line: 0, column: 0 },
},
parent: null as unknown as TSESTree.Node,
});
it('should return true for an object with empty selectors, media, or supports objects', () => {
const object = createObjectExpression([
createProperty('color', createLiteral('blue')),
createProperty('selectors', createObjectExpression([])),
createProperty('@media', createObjectExpression([])),
createProperty('@supports', createObjectExpression([])),
]);
expect(isEffectivelyEmptyStylesObject(object)).toBe(true);
});
it('should return true for an empty object', () => {
const emptyObject = createObjectExpression([]);
expect(isEffectivelyEmptyStylesObject(emptyObject)).toBe(true);
});
it('should return false for an object with non-empty properties', () => {
const object = createObjectExpression([createProperty('color', createLiteral('blue'))]);
expect(isEffectivelyEmptyStylesObject(object)).toBe(false);
});
it('should return true for an object with only empty nested selectors', () => {
const object = createObjectExpression([
createProperty(
'selectors',
createObjectExpression([
createProperty('&:hover', createObjectExpression([])),
createProperty('&:focus', createObjectExpression([])),
]),
),
]);
expect(isEffectivelyEmptyStylesObject(object)).toBe(true);
});
it('should return true for an object with only empty nested media queries', () => {
const object = createObjectExpression([
createProperty(
'@media',
createObjectExpression([
createProperty('(min-width: 768px)', createObjectExpression([])),
createProperty('(max-width: 1024px)', createObjectExpression([])),
]),
),
]);
expect(isEffectivelyEmptyStylesObject(object)).toBe(true);
});
});

View file

@ -0,0 +1,266 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import noEmptyStyleBlocksRule from '../rule-definition.js';
run({
name: 'vanilla-extract/no-empty-style-blocks/nested',
rule: noEmptyStyleBlocksRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// Style with non-empty nested selectors
`
import { style } from '@vanilla-extract/css';
const myStyle = style({
color: 'blue',
selectors: {
'&:hover': {
color: 'red'
}
}
});
`,
// Style with non-empty media queries
`
import { style } from '@vanilla-extract/css';
const myStyle = style({
color: 'blue',
'@media': {
'(min-width: 768px)': {
color: 'red'
}
}
});
`,
// Style with computed property name
`
import { style } from '@vanilla-extract/css';
const styleWithComputedProperty = style({
color: 'blue',
// Using a computed property name
[Symbol('test')]: {
color: 'red'
}
});
`,
],
invalid: [
// Style with empty nested selectors
{
code: `
import { style } from '@vanilla-extract/css';
const styleWithEmptySelector = style({
color: 'blue',
selectors: {
'&:hover': {}
}
});
`,
errors: [{ messageId: 'emptySelectors' }],
output: `
import { style } from '@vanilla-extract/css';
const styleWithEmptySelector = style({
color: 'blue',
});
`,
},
// Style with empty media queries
{
code: `
import { style } from '@vanilla-extract/css';
const styleWithEmptyMedia = style({
color: 'blue',
'@media': {
'(min-width: 768px)': {}
}
});
`,
errors: [{ messageId: 'emptyMedia' }],
output: `
import { style } from '@vanilla-extract/css';
const styleWithEmptyMedia = style({
color: 'blue',
});
`,
},
// Style with empty @supports
{
code: `
import { style } from '@vanilla-extract/css';
const styleWithEmptySupports = style({
display: 'block',
'@supports': {
'(display: grid)': {}
}
});
`,
errors: [{ messageId: 'emptySupports' }],
output: `
import { style } from '@vanilla-extract/css';
const styleWithEmptySupports = style({
display: 'block',
});
`,
},
// Nested empty style with multiple levels
{
code: `
import { style } from '@vanilla-extract/css';
export const nestedEmptyStyle = style({
selectors: {
'&:hover': {},
'&:focus': {}
}
});
`,
errors: [{ messageId: 'emptySelectors' }],
output: `
import { style } from '@vanilla-extract/css';
`,
},
// Multiple empty nested styles (individual reporting)
{
code: `
import { style } from '@vanilla-extract/css';
const styleWithMultipleEmptySelectors = style({
'@media': {
'(min-width: 768px)': {},
'(max-width: 1024px)': {},
'(prefers-color-scheme: dark)': { color: 'white' }
}
});
`,
errors: [{ messageId: 'emptyNestedStyle' }, { messageId: 'emptyNestedStyle' }],
output: `
import { style } from '@vanilla-extract/css';
const styleWithMultipleEmptySelectors = style({
'@media': {
'(prefers-color-scheme: dark)': { color: 'white' }
}
});
`,
},
// Style with custom empty conditional style (not selectors, media, or supports)
{
code: `
import { style } from '@vanilla-extract/css';
const styleWithCustomConditional = style({
color: 'blue',
'@customCondition': {}
});
`,
errors: [{ messageId: 'emptyConditionalStyle' }],
output: `
import { style } from '@vanilla-extract/css';
const styleWithCustomConditional = style({
color: 'blue',
});
`,
},
// Style with nested empty selectors, media queries, and supports
{
code: `
import { style } from '@vanilla-extract/css';
const styleWithNestedEmpty = style({
color: 'blue',
selectors: {
'&:hover': {},
'&:focus': {}
},
'@media': {
'(min-width: 768px)': {},
'(max-width: 1024px)': {}
},
'@supports': {
'(display: grid)': {}
}
});
`,
errors: [{ messageId: 'emptySelectors' }, { messageId: 'emptyMedia' }, { messageId: 'emptySupports' }],
output: `
import { style } from '@vanilla-extract/css';
const styleWithNestedEmpty = style({
color: 'blue',
});
`,
},
// Style with mixed empty and non-empty nested objects
{
code: `
import { style } from '@vanilla-extract/css';
const styleWithMixedNested = style({
color: 'blue',
selectors: {
'&:hover': { color: 'red' },
'&:focus': {}
},
'@media': {
'(min-width: 768px)': {},
'(max-width: 1024px)': { fontSize: '16px' }
},
'@supports': {
'(display: grid)': {}
}
});
`,
errors: [{ messageId: 'emptyNestedStyle' }, { messageId: 'emptyNestedStyle' }, { messageId: 'emptySupports' }],
output: `
import { style } from '@vanilla-extract/css';
const styleWithMixedNested = style({
color: 'blue',
selectors: {
'&:hover': { color: 'red' },
},
'@media': {
'(max-width: 1024px)': { fontSize: '16px' }
},
});
`,
},
],
});

View file

@ -0,0 +1,284 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import noEmptyStyleBlocksRule from '../rule-definition.js';
run({
name: 'vanilla-extract/no-empty-style-blocks/recipe',
rule: noEmptyStyleBlocksRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// Recipe with non-empty variants
`
import { recipe } from '@vanilla-extract/recipes';
const myRecipe = recipe({
base: {
color: 'black'
},
variants: {
color: {
blue: { color: 'blue' },
red: { color: 'red' }
}
}
});
`,
// Tests the early return when property.type !== 'Property'
// Valid because the spread operator is skipped by the rule (early return)
// This covers the code path: if (property.type !== 'Property') { return; }
`
import { recipe } from '@vanilla-extract/recipes';
const recipeWithSpreadProperty = recipe({
...{ someProperty: true },
base: { color: 'black' }
});
`,
// Tests the early return when property name cannot be determined
// Valid because computed properties are skipped by the rule (early return)
// This covers the code path: if (!propertyName) { return; }
`
import { recipe } from '@vanilla-extract/recipes';
function computedKey() { return 'dynamicKey'; }
const recipeWithComputedProperty = recipe({
[computedKey()]: { color: 'black' },
base: { color: 'black' }
});
`,
// Tests the early return when variantCategoryProperty is not a Property or its value is not an ObjectExpression
// Valid because non-Property variant categories or non-ObjectExpression values are skipped by the rule
// This covers the code path: if (variantCategoryProperty.type !== 'Property' || variantCategoryProperty.value.type !== 'ObjectExpression') { return; }
`
import { recipe } from '@vanilla-extract/recipes';
const recipeWithNonPropertyVariantCategory = recipe({
base: { color: 'black' },
variants: {
...{ size: { small: { fontSize: '12px' } } },
color: {
blue: { color: 'blue' }
}
}
});
`,
// Tests the early return when variantValueProperty is not a Property
// Valid because non-Property variant values are skipped by the rule
// This covers the code path: if (variantValueProperty.type !== 'Property') { return; }
`
import { recipe } from '@vanilla-extract/recipes';
const recipeWithNonPropertyVariantValue = recipe({
base: { color: 'black' },
variants: {
color: {
...{ blue: { color: 'blue' } },
red: { color: 'red' }
}
}
});
`,
],
invalid: [
// Empty recipe
{
code: `
import { recipe } from '@vanilla-extract/recipes';
export const emptyRecipe = recipe({});
`,
errors: [{ messageId: 'emptyStyleDeclaration' }],
output: `
import { recipe } from '@vanilla-extract/recipes';
`,
},
// Recipe with empty base
{
code: `
import { recipe } from '@vanilla-extract/recipes';
const recipeWithEmptyBase = recipe({
base: {},
variants: {
color: {
blue: { color: 'blue' }
}
}
});
`,
errors: [{ messageId: 'emptyRecipeProperty' }],
output: `
import { recipe } from '@vanilla-extract/recipes';
const recipeWithEmptyBase = recipe({
variants: {
color: {
blue: { color: 'blue' }
}
}
});
`,
},
// Recipe with empty variant values
{
code: `
import { recipe } from '@vanilla-extract/recipes';
const recipeWithEmptyVariantValues = recipe({
base: { color: 'black' },
variants: {
color: {
blue: {},
red: {}
}
}
});
`,
errors: [{ messageId: 'emptyVariantCategory' }],
output: `
import { recipe } from '@vanilla-extract/recipes';
const recipeWithEmptyVariantValues = recipe({
base: { color: 'black' },
});
`,
},
// Recipe with both empty base and variants
{
code: `
import { recipe } from '@vanilla-extract/recipes';
export const recipeWithBothEmpty = recipe({
base: {},
variants: {}
});
`,
errors: [{ messageId: 'emptyStyleDeclaration' }],
output: `
import { recipe } from '@vanilla-extract/recipes';
`,
},
// Test for non-object property values in recipe
//
{
code: `
import { recipe } from '@vanilla-extract/recipes';
const recipeWithNonObjectValue = recipe({
base: { color: 'black' },
variants: {
color: {
blue: "blue", // String instead of object
red: { color: 'red' }
}
}
});
`,
errors: [{ messageId: 'invalidPropertyType' }],
},
// Tests the Identifier type handling in variant values
// This covers the code path where friendlyType === 'Identifier'
{
code: `
import { recipe } from '@vanilla-extract/recipes';
const colorValue = { color: 'blue' };
const recipeWithIdentifierValue = recipe({
base: { color: 'black' },
variants: {
color: {
blue: colorValue // Using a variable (Identifier) instead of an object literal
}
}
});
`,
errors: [{ messageId: 'invalidPropertyType', data: { type: 'variable' } }],
},
// Test for handling null literal values in variant properties
{
code: `
import { recipe } from '@vanilla-extract/recipes';
const recipeWithNullVariantValue = recipe({
base: { color: 'black' },
variants: {
color: {
blue: null,
red: { color: 'red' }
}
}
});
`,
errors: [{ messageId: 'invalidPropertyType', data: { type: 'null' } }],
},
// This test ensures the rule correctly identifies and reports empty object values in variants
{
code: `
import { recipe } from '@vanilla-extract/recipes';
const recipeWithEmptyVariantValue = recipe({
base: { color: 'black' },
variants: {
color: {
blue: {}, // Empty object should be reported
red: { color: 'red' } // Non-empty to prevent category-level reporting
}
}
});
const anotherRecipe = recipe({
base: { color: 'black' },
variants: {
color: {
blue: {}, // Another empty object that should be reported
red: { color: 'red' }
}
}
});
`,
errors: [{ messageId: 'emptyVariantValue' }, { messageId: 'emptyVariantValue' }],
},
// Tests the default case for node type handling in variant values
// This covers the code path where neither Literal nor Identifier conditions are met
{
code: `
import { recipe } from '@vanilla-extract/recipes';
const recipeWithArrowFunctionValue = recipe({
base: { color: 'black' },
variants: {
color: {
blue: () => ({ color: 'blue' }), // Arrow function instead of an object literal
red: { color: 'red' }
}
}
});
`,
errors: [{ messageId: 'invalidPropertyType', data: { type: 'ArrowFunctionExpression' } }],
},
],
});

View file

@ -0,0 +1,48 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import noEmptyStyleBlocksRule from '../rule-definition.js';
run({
name: 'vanilla-extract/no-empty-style-blocks/spread',
rule: noEmptyStyleBlocksRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [],
invalid: [
// Style with empty spread object
{
code: `
import { style } from '@vanilla-extract/css';
const baseStyles = {
color: 'blue',
margin: '10px'
};
const spreadStyle = style({
...baseStyles,
...{}
});
`,
errors: [{ messageId: 'emptySpreadObject' }],
output: `
import { style } from '@vanilla-extract/css';
const baseStyles = {
color: 'blue',
margin: '10px'
};
const spreadStyle = style({
...baseStyles,
});
`,
},
],
});

View file

@ -0,0 +1,220 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import { expect } from 'vitest';
import noEmptyStyleBlocksRule from '../rule-definition.js';
// The output function approach with assertions is used instead of exact string comparison because ESLint's autofix functionality modifies whitespace in ways that are difficult to predict exactly, and ESLint v9 has stricter RuleTester checks that require output to match character-for-character. This approach allows for more flexible assertions about the important parts of the output without requiring exact whitespace matching, making tests more resilient to small changes in whitespace handling.
run({
name: 'vanilla-extract/no-empty-style-blocks/style-variants',
rule: noEmptyStyleBlocksRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// In the valid array:
{
code: `
import { styleVariants } from '@vanilla-extract/css';
const styles = { color: 'blue' };
export const variantsWithSpread = styleVariants({
...styles,
primary: { color: 'red' }
});
`,
},
// Valid style variants with non-empty objects
{
code: `
import { styleVariants } from '@vanilla-extract/css';
export const validVariants = styleVariants({
primary: { color: 'blue' },
secondary: { color: 'green' }
});
`,
},
// Valid style variants with arrays containing values
{
code: `
import { styleVariants } from '@vanilla-extract/css';
export const validArrayVariants = styleVariants({
primary: [{ color: 'blue' }, { fontWeight: 'bold' }],
secondary: [{ color: 'green' }]
});
`,
},
// Mixed valid variants
{
code: `
import { styleVariants } from '@vanilla-extract/css';
export const mixedVariants = styleVariants({
primary: { color: 'blue' },
secondary: [{ color: 'green' }, { fontWeight: 'bold' }]
});
`,
},
],
invalid: [
// Empty style variants object
{
code: `
import { styleVariants } from '@vanilla-extract/css';
export const emptyVariants = styleVariants({});
`,
errors: [{ messageId: 'emptyStyleDeclaration' }],
output(output) {
expect(output).toContain('import { styleVariants } from');
expect(output).not.toContain('export const emptyVariants');
},
},
// Style variants with empty object property
{
code: `
import { styleVariants } from '@vanilla-extract/css';
export const variantsWithEmptyObject = styleVariants({
primary: {},
secondary: { color: 'green' }
});
`,
errors: [{ messageId: 'emptyVariantValue' }],
output(output) {
expect(output).toContain("secondary: { color: 'green' }");
expect(output).not.toContain('primary: {}');
},
},
// Style variants with empty array property
{
code: `
import { styleVariants } from '@vanilla-extract/css';
export const variantsWithEmptyArray = styleVariants({
primary: [],
secondary: { color: 'green' }
});
`,
errors: [{ messageId: 'emptyVariantValue' }],
output(output) {
expect(output).toContain("secondary: { color: 'green' }");
expect(output).not.toContain('primary: []');
},
},
// Style variants with multiple empty properties
{
code: `
import { styleVariants } from '@vanilla-extract/css';
export const variantsWithMultipleEmptyProps = styleVariants({
primary: {},
secondary: [],
tertiary: { color: 'green' }
});
`,
errors: [{ messageId: 'emptyVariantValue' }, { messageId: 'emptyVariantValue' }],
output(output) {
expect(output).toContain("tertiary: { color: 'green' }");
expect(output).not.toContain('primary: {}');
expect(output).not.toContain('secondary: []');
},
},
// Style variants with all empty properties
{
code: `
import { styleVariants } from '@vanilla-extract/css';
export const allEmptyVariants = styleVariants({
primary: {},
secondary: []
});
`,
errors: [{ messageId: 'emptyVariantValue' }, { messageId: 'emptyVariantValue' }],
output(output) {
expect(output).toContain('import { styleVariants } from');
expect(output).not.toContain('export const allEmptyVariants');
expect(output).not.toContain('primary: {}');
expect(output).not.toContain('secondary: []');
},
},
// Style variants with trailing comma
{
code: `
import { styleVariants } from '@vanilla-extract/css';
export const variantsWithTrailingComma = styleVariants({
primary: {},
secondary: { color: 'green' },
});
`,
errors: [{ messageId: 'emptyVariantValue' }],
output(output) {
expect(output).toContain("secondary: { color: 'green' },");
expect(output).not.toContain('primary: {}');
},
},
// First empty property
{
code: `
import { styleVariants } from '@vanilla-extract/css';
export const firstEmptyProperty = styleVariants({
primary: {},
secondary: { color: 'green' },
tertiary: { color: 'blue' }
});
`,
errors: [{ messageId: 'emptyVariantValue' }],
output(output) {
expect(output).toContain("secondary: { color: 'green' }");
expect(output).toContain("tertiary: { color: 'blue' }");
expect(output).not.toContain('primary: {}');
},
},
// Middle empty property
{
code: `
import { styleVariants } from '@vanilla-extract/css';
export const middleEmptyProperty = styleVariants({
primary: { color: 'red' },
secondary: {},
tertiary: { color: 'blue' }
});
`,
errors: [{ messageId: 'emptyVariantValue' }],
output(output) {
expect(output).toContain("primary: { color: 'red' }");
expect(output).toContain("tertiary: { color: 'blue' }");
expect(output).not.toContain('secondary: {}');
},
},
// Last empty property
{
code: `
import { styleVariants } from '@vanilla-extract/css';
export const lastEmptyProperty = styleVariants({
primary: { color: 'red' },
secondary: { color: 'green' },
tertiary: {}
});
`,
errors: [{ messageId: 'emptyVariantValue' }],
output(output) {
expect(output).toContain("primary: { color: 'red' }");
expect(output).toContain("secondary: { color: 'green' }");
expect(output).not.toContain('tertiary: {}');
},
},
],
});

View file

@ -0,0 +1,138 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import noEmptyStyleBlocksRule from '../rule-definition.js';
run({
name: 'vanilla-extract/no-empty-style-blocks/style',
rule: noEmptyStyleBlocksRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// Basic non-empty style
`
import { style } from '@vanilla-extract/css';
const myStyle = style({
color: 'blue',
margin: '10px'
});
`,
// Style with comments (not empty)
`
import { style } from '@vanilla-extract/css';
const myStyle = style({
/* This is a comment */
color: 'blue'
});
`,
],
invalid: [
// Empty style object
{
code: `
import { style } from '@vanilla-extract/css';
const emptyStyle = style({});
`,
errors: [{ messageId: 'emptyStyleDeclaration' }],
output: `
import { style } from '@vanilla-extract/css';
`,
},
// Empty exported style object
{
code: `
import { style } from '@vanilla-extract/css';
export const emptyStyle = style({});
`,
errors: [{ messageId: 'emptyStyleDeclaration' }],
output: `
import { style } from '@vanilla-extract/css';
`,
},
// Style with comments in empty object
{
code: `
import { style } from '@vanilla-extract/css';
const styleWithComments = style({
/* This is an empty style */
});
`,
errors: [{ messageId: 'emptyStyleDeclaration' }],
output: `
import { style } from '@vanilla-extract/css';
`,
},
// Multiple empty styles
{
code: `
import { style } from '@vanilla-extract/css';
export const emptyStyle1 = style({});
export const emptyStyle2 = style({});
`,
errors: [{ messageId: 'emptyStyleDeclaration' }, { messageId: 'emptyStyleDeclaration' }],
output: `
import { style } from '@vanilla-extract/css';
`,
},
// Variable declaration with empty style
{
code: `
import { style } from '@vanilla-extract/css';
const { className } = style({});
`,
errors: [{ messageId: 'emptyStyleDeclaration' }],
output: `
import { style } from '@vanilla-extract/css';
`,
},
// Export of variable with empty style
{
code: `
import { style } from '@vanilla-extract/css';
const myEmptyStyle = style({});
export { myEmptyStyle };
`,
errors: [{ messageId: 'emptyStyleDeclaration' }],
output: `
import { style } from '@vanilla-extract/css';
export { myEmptyStyle };
`,
},
// Style in a callback or nested function
{
code: `
import { style } from '@vanilla-extract/css';
[1, 2, 3].forEach(() => {
style({});
});
`,
errors: [{ messageId: 'emptyStyleDeclaration' }],
},
],
});

View file

@ -0,0 +1,36 @@
import type { Rule } from 'eslint';
import { TSESTree } from '@typescript-eslint/utils';
import { isEmptyObject } from '../shared-utils/empty-object-processor.js';
import { reportEmptyDeclaration } from './fix-utils.js';
/**
* Handles conditional expressions with empty objects.
*/
export function processConditionalExpression(
context: Rule.RuleContext,
node: TSESTree.ConditionalExpression,
reportedNodes: Set<TSESTree.Node>,
callNode: TSESTree.CallExpression,
): void {
const isConsequentEmpty = node.consequent.type === 'ObjectExpression' && isEmptyObject(node.consequent);
const isAlternateEmpty = node.alternate.type === 'ObjectExpression' && isEmptyObject(node.alternate);
// If both branches are empty, report the entire declaration for removal
if (isConsequentEmpty && isAlternateEmpty) {
reportedNodes.add(node);
reportEmptyDeclaration(context, node, callNode);
return;
}
// Otherwise, handle individual empty branches
if (isConsequentEmpty || isAlternateEmpty) {
const emptyNode = isConsequentEmpty ? node.consequent : node.alternate;
reportedNodes.add(emptyNode);
// No fix provided, just flagging the issue
context.report({
node: emptyNode as Rule.Node,
messageId: 'emptyConditionalStyle',
});
}
}

View file

@ -0,0 +1,75 @@
import type { Rule } from 'eslint';
import { TSESTree } from '@typescript-eslint/utils';
import { isEmptyObject } from '../shared-utils/empty-object-processor.js';
import { removeNodeWithComma } from './node-remover.js';
import { areAllChildrenEmpty, getStyleKeyName } from './property-utils.js';
/**
* Processes nested style objects like selectors and media queries.
*/
export function processEmptyNestedStyles(
ruleContext: Rule.RuleContext,
node: TSESTree.ObjectExpression,
reportedNodes: Set<TSESTree.Node>,
): void {
node.properties.forEach((property) => {
if (property.type !== 'Property') {
return;
}
const propertyName = getStyleKeyName(property.key);
if (!propertyName) {
return;
}
// Handle selectors, media queries, and supports
if ((propertyName === 'selectors' || propertyName.startsWith('@')) && property.value.type === 'ObjectExpression') {
// If the container is empty or all its children are empty, remove the entire property
if (isEmptyObject(property.value) || areAllChildrenEmpty(property.value)) {
if (!reportedNodes.has(property)) {
reportedNodes.add(property);
const messageId =
propertyName === 'selectors'
? 'emptySelectors'
: propertyName === '@media'
? 'emptyMedia'
: propertyName === '@supports'
? 'emptySupports'
: 'emptyConditionalStyle';
ruleContext.report({
node: property as Rule.Node,
messageId,
fix(fixer) {
return removeNodeWithComma(ruleContext, property, fixer);
},
});
}
return;
}
// Process individual selectors or media queries if we're not removing the entire container
property.value.properties.forEach((nestedProperty) => {
if (nestedProperty.type === 'Property') {
if (nestedProperty.value.type === 'ObjectExpression') {
if (isEmptyObject(nestedProperty.value)) {
if (!reportedNodes.has(nestedProperty)) {
reportedNodes.add(nestedProperty);
ruleContext.report({
node: nestedProperty as Rule.Node,
messageId: 'emptyNestedStyle',
fix(fixer) {
return removeNodeWithComma(ruleContext, nestedProperty, fixer);
},
});
}
} else {
// Recursively process nested styles (for deeply nested selectors/media)
processEmptyNestedStyles(ruleContext, nestedProperty.value, reportedNodes);
}
}
}
});
}
});
}

View file

@ -0,0 +1,228 @@
import type { Rule } from 'eslint';
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: 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 {
// 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: string | null): boolean {
return (
propertyName === 'selectors' || (propertyName && propertyName.startsWith('@')) || propertyName === 'variants'
);
}
const specialProperties = stylesObject.properties.filter(
(prop): prop is TSESTree.Property => 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;
}

View file

@ -0,0 +1,94 @@
import type { Rule } from 'eslint';
import { TSESTree } from '@typescript-eslint/utils';
/**
* Finds the target declaration node for a given call expression.
*
* Traverses the AST upwards from the `callNode` to find the appropriate
* declaration node. For global APIs, it targets the expression statement.
* For non-global APIs, it looks for variable declarations or export
* named declarations.
*
* @param callNode - The call expression node to start from.
* @param isGlobalApi - A flag indicating whether the API is global.
* @returns The target declaration node or null if not found.
*/
export const findTargetDeclarationNode = (
callNode: TSESTree.CallExpression,
isGlobalApi: boolean,
): TSESTree.Node | null => {
let current: TSESTree.Node = callNode;
while (current.parent) {
current = current.parent;
// For global APIs, we only need to check for ExpressionStatement
if (isGlobalApi && current.type === 'ExpressionStatement') {
return current;
}
// For non-global APIs, check for variable declarations and exports
if (!isGlobalApi) {
if (current.type === 'VariableDeclarator' && current.parent && current.parent.type === 'VariableDeclaration') {
// If this is part of an export, get the export declaration
if (current.parent.parent && current.parent.parent.type === 'ExportNamedDeclaration') {
return current.parent.parent;
}
return current.parent;
}
}
}
return null;
};
/**
* Reports an issue for an empty style declaration, and provides a fix to remove the declaration.
* @param ruleContext The ESLint rule context.
* @param node The node to report the issue on.
* @param callNode The CallExpression node of the style() function.
*/
export const reportEmptyDeclaration = (
ruleContext: Rule.RuleContext,
node: TSESTree.Node,
callNode: TSESTree.CallExpression,
): void => {
// Check if this is a global API function
const isGlobalApi =
callNode.callee.type === 'Identifier' &&
['globalStyle', 'globalFontFace', 'globalKeyframes'].includes(callNode.callee.name);
// Find the parent declaration node
const targetDeclarationNode = findTargetDeclarationNode(callNode, isGlobalApi);
if (targetDeclarationNode) {
ruleContext.report({
node: node as Rule.Node,
messageId: 'emptyStyleDeclaration',
fix(fixer) {
const sourceCode = ruleContext.sourceCode;
const startLine = sourceCode.getLocFromIndex(targetDeclarationNode.range[0]).line;
const lineStart = sourceCode.getIndexFromLoc({
line: startLine,
column: 0,
});
// Find next line after the declaration
const endLine = sourceCode.getLocFromIndex(targetDeclarationNode.range[1]).line;
const nextLineStart = sourceCode.getIndexFromLoc({
line: endLine + 1,
column: 0,
});
return fixer.removeRange([lineStart, nextLineStart]);
},
});
} else {
// Report the issue without providing a fix
ruleContext.report({
node: node as Rule.Node,
messageId: 'emptyStyleDeclaration',
});
}
};

View file

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

View file

@ -0,0 +1,18 @@
import type { Rule } from 'eslint';
import type { TSESTree } from '@typescript-eslint/utils';
/**
* Removes the given node and also removes a trailing comma if it exists.
* @param ruleContext The ESLint rule context.
* @param node The node to remove.
* @param fixer The ESLint fixer.
* @returns The fix object.
*/
export function 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

@ -0,0 +1,31 @@
import { TSESTree } from '@typescript-eslint/utils';
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 {
if (key.type === 'Identifier') {
return key.name;
}
if (key.type === 'Literal' && typeof key.value === 'string') {
return key.value;
}
return null;
}
/**
* Checks if all properties in a style object are empty objects.
*/
export const areAllChildrenEmpty = (stylesObject: TSESTree.ObjectExpression): boolean => {
if (stylesObject.properties.length === 0) {
return true;
}
return stylesObject.properties.every((property) => {
if (property.type !== 'Property' || property.value.type !== 'ObjectExpression') {
return false;
}
return isEmptyObject(property.value);
});
};

View file

@ -0,0 +1,142 @@
import type { Rule } from 'eslint';
import { TSESTree } from '@typescript-eslint/utils';
import { isEmptyObject } from '../shared-utils/empty-object-processor.js';
import { processEmptyNestedStyles } from './empty-nested-style-processor.js';
import { removeNodeWithComma } from './node-remover.js';
import { areAllChildrenEmpty, getStyleKeyName } from './property-utils.js';
/**
* Processes a recipe object, removing empty `base` and `variants` properties, as well as empty
* variant categories and values.
*
* @param ruleContext The ESLint rule context.
* @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(
ruleContext: Rule.RuleContext,
recipeNode: TSESTree.ObjectExpression,
reportedNodes: Set<TSESTree.Node>,
): void {
recipeNode.properties.forEach((property) => {
if (property.type !== 'Property') {
return;
}
const propertyName = getStyleKeyName(property.key);
if (!propertyName) {
return;
}
// Handle empty base or variants properties
if (
(propertyName === 'base' || propertyName === 'variants') &&
property.value.type === 'ObjectExpression' &&
isEmptyObject(property.value)
) {
if (!reportedNodes.has(property)) {
reportedNodes.add(property);
ruleContext.report({
node: property as Rule.Node,
messageId: 'emptyRecipeProperty',
data: {
propertyName,
},
fix(fixer) {
return removeNodeWithComma(ruleContext, property, fixer);
},
});
}
}
// Process base property nested objects
if (propertyName === 'base' && property.value.type === 'ObjectExpression') {
processEmptyNestedStyles(ruleContext, property.value, reportedNodes);
}
// Process variant values
if (propertyName === 'variants' && property.value.type === 'ObjectExpression') {
// If variants is empty, it will be handled by the check above
if (!isEmptyObject(property.value)) {
// Process variant categories
property.value.properties.forEach((variantCategoryProperty) => {
if (
variantCategoryProperty.type !== 'Property' ||
variantCategoryProperty.value.type !== 'ObjectExpression'
) {
return;
}
// Check if all values in this category are empty
if (isEmptyObject(variantCategoryProperty.value) || areAllChildrenEmpty(variantCategoryProperty.value)) {
if (!reportedNodes.has(variantCategoryProperty)) {
reportedNodes.add(variantCategoryProperty);
ruleContext.report({
node: variantCategoryProperty as Rule.Node,
messageId: 'emptyVariantCategory',
fix(fixer) {
return removeNodeWithComma(ruleContext, variantCategoryProperty, fixer);
},
});
}
return;
}
// Process individual variant values
variantCategoryProperty.value.properties.forEach((variantValueProperty) => {
if (variantValueProperty.type !== 'Property') {
return;
}
// Check for non-object variant values
if (variantValueProperty.value.type !== 'ObjectExpression') {
if (!reportedNodes.has(variantValueProperty)) {
reportedNodes.add(variantValueProperty);
// Get a user-friendly type description as a string
const friendlyType = (() => {
const nodeType = variantValueProperty.value.type;
if (nodeType === 'Literal') {
const literalValue = variantValueProperty.value as TSESTree.Literal;
return literalValue.value === null ? 'null' : typeof literalValue.value;
} else if (nodeType === 'Identifier') {
return 'variable';
}
return nodeType;
})();
ruleContext.report({
node: variantValueProperty as Rule.Node,
messageId: 'invalidPropertyType',
data: {
type: friendlyType,
},
});
}
return;
}
// Check for empty objects in variant properties
if (isEmptyObject(variantValueProperty.value)) {
if (!reportedNodes.has(variantValueProperty)) {
reportedNodes.add(variantValueProperty);
ruleContext.report({
node: variantValueProperty as Rule.Node,
messageId: 'emptyVariantValue',
fix(fixer) {
return removeNodeWithComma(ruleContext, variantValueProperty, fixer);
},
});
}
} else {
// Process nested styles within variant values
processEmptyNestedStyles(ruleContext, variantValueProperty.value, reportedNodes);
}
});
});
}
}
});
}

View file

@ -0,0 +1,33 @@
import type { Rule } from 'eslint';
import { createEmptyStyleVisitors } from './empty-style-visitor-creator.js';
const noEmptyStyleBlocksRule: Rule.RuleModule = {
meta: {
type: 'suggestion',
docs: {
description: 'disallow empty style blocks in vanilla-extract stylesheets',
category: 'Best Practices',
recommended: true,
},
fixable: 'code',
schema: [],
messages: {
emptyConditionalStyle: 'Empty conditional style object should be removed.',
emptyMedia: 'Empty @media object should be removed.',
emptyNestedStyle: 'Empty nested style object should be removed.',
emptyRecipeProperty: 'Empty {{propertyName}} object in recipe should be removed.',
emptySelectors: 'Empty selectors object should be removed.',
emptySpreadObject: 'Empty spread object should be removed.',
emptyStyleDeclaration: 'Declarations with only empty style blocks should be removed.',
emptySupports: 'Empty @supports object should be removed.',
emptyVariantCategory: 'Empty variant category should be removed.',
emptyVariantValue: 'Empty variant value should be removed.',
invalidPropertyType: 'Variant values must be objects, found {{type}} instead.',
},
},
create(ruleContext) {
return createEmptyStyleVisitors(ruleContext);
},
};
export default noEmptyStyleBlocksRule;

View file

@ -0,0 +1,53 @@
import type { Rule } from 'eslint';
import { TSESTree } from '@typescript-eslint/utils';
import { isEmptyObject } from '../shared-utils/empty-object-processor.js';
import { removeNodeWithComma } from './node-remover.js';
/**
* Processes styleVariants function calls to detect and remove empty style variants.
*
* @param ruleContext The ESLint rule context.
* @param node The styleVariants call argument (object expression).
* @param reportedNodes A set of nodes that have already been reported.
*/
export function processStyleVariants(
ruleContext: Rule.RuleContext,
node: TSESTree.ObjectExpression,
reportedNodes: Set<TSESTree.Node>,
): void {
node.properties.forEach((property) => {
if (property.type !== 'Property') {
return;
}
// Check for empty arrays
if (property.value.type === 'ArrayExpression' && property.value.elements.length === 0) {
if (!reportedNodes.has(property)) {
reportedNodes.add(property);
ruleContext.report({
node: property as Rule.Node,
messageId: 'emptyVariantValue',
fix(fixer) {
return removeNodeWithComma(ruleContext, property, fixer);
},
});
}
return;
}
// Check for empty objects
if (property.value.type === 'ObjectExpression' && isEmptyObject(property.value)) {
if (!reportedNodes.has(property)) {
reportedNodes.add(property);
ruleContext.report({
node: property as Rule.Node,
messageId: 'emptyVariantValue',
fix(fixer) {
return removeNodeWithComma(ruleContext, property, fixer);
},
});
}
return;
}
});
}

View file

@ -14,15 +14,13 @@ const fontFaceEdgeCasesRule = {
CallExpression(node: TSESTree.CallExpression) {
if (node.callee.type === 'Identifier' && node.callee.name === 'testNullCase') {
// This will trigger the first early return
// eslint-disable-next-line @typescript-eslint/no-explicit-any
enforceFontFaceOrder(context, null as any);
enforceFontFaceOrder(context, null as unknown as TSESTree.ObjectExpression);
}
// Test case for non-ObjectExpression node
if (node.callee.type === 'Identifier' && node.callee.name === 'testNonObjectCase') {
// This will trigger the first early return (wrong type)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
enforceFontFaceOrder(context, { type: 'Literal' } as any);
enforceFontFaceOrder(context, { type: 'Literal' } as unknown as TSESTree.ObjectExpression);
}
// Test case for empty or single property object
@ -41,8 +39,7 @@ const fontFaceEdgeCasesRule = {
shorthand: false,
},
],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
} as TSESTree.ObjectExpression);
}
},
};

View file

@ -4,6 +4,7 @@ import { run } from 'eslint-vitest-rule-tester';
import alphabeticalOrderRule from '../../alphabetical-order/rule-definition.js';
import customGroupOrderRule from '../../custom-order/rule-definition.js';
import { createNodeVisitors } from '../order-strategy-visitor-creator.js';
import type { OrderingStrategy } from '../../types.js';
import type { TSESTree } from '@typescript-eslint/utils';
// Modified version of the custom order rule with empty group order
@ -29,8 +30,8 @@ const defaultCaseRule = {
...alphabeticalOrderRule,
create(context: Rule.RuleContext) {
// Force the default case by passing an invalid ordering strategy
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const visitors = createNodeVisitors(context, 'invalid-strategy' as any);
const visitors = createNodeVisitors(context, 'invalid-strategy' as OrderingStrategy);
return visitors;
},
};
@ -44,8 +45,7 @@ const nonIdentifierCalleeRule = {
// Original rule's visitor will be called first
const visitors = alphabeticalOrderRule.create(context);
if (visitors.CallExpression) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
visitors.CallExpression(node as any);
visitors.CallExpression(node as unknown as Parameters<typeof visitors.CallExpression>[0]);
}
},
};

View file

@ -1,5 +1,5 @@
import type { Rule } from 'eslint';
import { getPropertyName } from '../property-separator.js';
import { getPropertyNameForSorting } from '../property-separator.js';
import type { TSESTree } from '@typescript-eslint/utils';
const testRuleForPropertyNameExtractor: Rule.RuleModule = {
@ -22,8 +22,8 @@ const testRuleForPropertyNameExtractor: Rule.RuleModule = {
// Extract property names without enforcing any order
node.properties.forEach((property) => {
if (property.type === 'Property') {
// Test the getPropertyName function
getPropertyName(property as TSESTree.Property);
// Test the getPropertyNameForSorting function
getPropertyNameForSorting(property as TSESTree.Property);
}
});
},

View file

@ -1,4 +1,4 @@
import { getPropertyName } from './property-separator.js';
import { getPropertyNameForSorting } from './property-separator.js';
import type { TSESTree } from '@typescript-eslint/utils';
/**
@ -11,8 +11,8 @@ export const comparePropertiesAlphabetically = (
firstProperty: TSESTree.Property,
secondProperty: TSESTree.Property,
): number => {
const firstName = getPropertyName(firstProperty);
const secondName = getPropertyName(secondProperty);
const firstName = getPropertyNameForSorting(firstProperty);
const secondName = getPropertyNameForSorting(secondProperty);
// Special handling for 'src' property - it should always come first (relates to font face APIs only)
if (firstName === 'src') {

View file

@ -0,0 +1,17 @@
import { TSESTree } from '@typescript-eslint/utils';
/**
* Type guard to check if a node is an ObjectExpression.
*/
export const isObjectExpression = (node: TSESTree.Node): node is TSESTree.ObjectExpression => {
return node.type === 'ObjectExpression';
};
/**
* Checks if an object expression is empty (has no properties).
* @param node The node to check.
* @returns True if the node is an ObjectExpression with no properties.
*/
export const isEmptyObject = (node: TSESTree.Node): boolean => {
return isObjectExpression(node) && node.properties.length === 0;
};

View file

@ -1,7 +1,7 @@
import type { Rule } from 'eslint';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
import { generateFixesForCSSOrder } from '../shared-utils/css-order-fixer.js';
import { getPropertyName, separateProperties } from '../shared-utils/property-separator.js';
import { getPropertyNameForSorting, separateProperties } from '../shared-utils/property-separator.js';
import { comparePropertiesAlphabetically } from './alphabetical-property-comparator.js';
/**
@ -36,8 +36,8 @@ export const enforceFontFaceOrder = (
);
if (violatingPair) {
const nextPropertyName = getPropertyName(violatingPair.nextProperty);
const currentPropertyName = getPropertyName(violatingPair.currentProperty);
const nextPropertyName = getPropertyNameForSorting(violatingPair.nextProperty);
const currentPropertyName = getPropertyNameForSorting(violatingPair.currentProperty);
ruleContext.report({
node: violatingPair.nextProperty as Rule.Node,

View file

@ -8,6 +8,8 @@ import { enforceUserDefinedGroupOrderInRecipe } from '../custom-order/recipe-ord
import { enforceUserDefinedGroupOrderInStyleObject } from '../custom-order/style-object-processor.js';
import { enforceFontFaceOrder } from './font-face-property-order-enforcer.js';
import { processStyleNode } from './style-node-processor.js';
import type { SortRemainingProperties } from '../concentric-order/types.js';
import type { OrderingStrategy } from '../types.js';
/**
* Creates an ESLint rule listener with visitors for style-related function calls.
@ -27,9 +29,9 @@ import { processStyleNode } from './style-node-processor.js';
*/
export const createNodeVisitors = (
ruleContext: Rule.RuleContext,
orderingStrategy: 'alphabetical' | 'concentric' | 'userDefinedGroupOrder',
orderingStrategy: OrderingStrategy,
userDefinedGroupOrder?: string[],
sortRemainingProperties?: 'alphabetical' | 'concentric',
sortRemainingProperties?: SortRemainingProperties,
): Rule.RuleListener => {
// Select the appropriate property processing function based on the ordering strategy
const processProperty = (() => {

View file

@ -10,7 +10,7 @@ import { TSESTree } from '@typescript-eslint/utils';
* - Literal (string): Returns the string value.
* For any other type of key, it returns an empty string.
*/
export const getPropertyName = (property: TSESTree.Property): string => {
export const getPropertyNameForSorting = (property: TSESTree.Property): string => {
if (property.key.type === 'Identifier') {
return property.key.name;
} else if (property.key.type === 'Literal' && typeof property.key.value === 'string') {
@ -43,7 +43,7 @@ export const separateProperties = (
// Separate regular CSS properties from special ones (pseudo selectors, etc.)
properties.forEach((property) => {
if (property.type === 'Property') {
const propName = getPropertyName(property);
const propName = getPropertyNameForSorting(property);
if (propName.startsWith(':') || propName.startsWith('@') || propName === 'selectors') {
specialProperties.push(property);

1
src/css-rules/types.ts Normal file
View file

@ -0,0 +1 @@
export type OrderingStrategy = 'alphabetical' | 'concentric' | 'userDefinedGroupOrder';

View file

@ -432,3 +432,158 @@ export const selectButtonVariants = styleVariants({
color: 'red',
},
});
// Test cases for noEmptyStyleBlocksRule
// export const myRecipe = recipe({
// base: {
// color: 'blue',
// selectors: {},
// '@media': {},
// '@supports': {},
// },
// variants: {
// size: {
// small: {
// selectors: {
// '&:hover': {},
// },
// '@media': {
// '(min-width: 768px)': {},
// },
// '@supports': {
// '(display: grid)': {},
// },
// },
// },
// },
// });
// const base = style({ padding: 12 });
// export const variant = styleVariants({
// primary: [],
// secondary: [],
// bordered: {},
// borderless: {},
// });
// const baseStyles = {
// color: 'blue',
// margin: '10px',
// };
// const isDarkMode = false;
// export const spreadStyle = style({
// ...baseStyles,
// ...{},
// });
// export const recipeWithNonObjectValue = recipe({
// base: { color: 'black' },
// variants: {
// color: {
// red: { color: 'red' },
// // string instead of object
// string: 'string',
// // variable instead of object
// variable: baseStyles,
// },
// },
// });
// export const conditionalStyle = style(isDarkMode ? {} : {});
// export const recipeWithEmptyVariantValues = recipe({
// base: { color: 'black' },
// variants: {
// color: {
// blue: {},
// red: {},
// },
// },
// });
// export const nestedEmptyStyle = style({
// selectors: {
// '&:hover': {},
// '&:focus': {},
// },
// });
// const myEmptyStyle = style({});
// export { myEmptyStyle };
// export const emptyStyle1 = style({});
// export const emptyStyle2 = style({});
// export const emptyVariants = styleVariants({});
// export const emptyRecipe = recipe({});
// export const styleWithComments = style({
// /* This is an empty style */
// });
// export const styleWithEmptyMedia = style({
// color: 'blue',
// '@media': {
// '(min-width: 768px)': {},
// },
// });
// export const styleWithEmptySelector = style({
// color: 'blue',
// selectors: {
// '&:hover': {},
// },
// });
// export const recipeWithBothEmpty = recipe({
// base: {},
// variants: {},
// });
// export const recipeWithEmptyVariants = recipe({
// base: { color: 'black' },
// variants: {},
// });
// export const recipeWithEmptyBase = recipe({
// base: {},
// variants: {
// color: {
// blue: { color: 'blue' },
// },
// },
// });
// export const recipe = recipe({
// base: {},
// variants: {
// color: {
// red: {},
// blue: {},
// },
// },
// });
// export const recipeWithNonObjectVariants = recipe({
// base: { color: 'blue' },
// variants: {
// color: {
// size: 'string instead of object', // This is a string, not an object
// red: {},
// },
// },
// });
// Using the same empty object reference in both branches
// export const myStyle = style(true ? {} : {});
// export const emptyFontFace = fontFace({});
// globalFontFace('GlobalFont', {});
// globalKeyframes('a', {});
// export const emptyKeyframes = keyframes({});
// globalStyle('ul', {});
// export const emptyStyleVariants = styleVariants({});
// export const emptyStyle = style({});

View file

@ -1,26 +1,34 @@
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';
export const vanillaExtract = {
meta: {
name: '@antebudimir/eslint-plugin-vanilla-extract',
version: '1.5.3',
version: '1.6.0',
},
rules: {
'alphabetical-order': alphabeticalOrderRule,
'concentric-order': concentricOrderRule,
'custom-order': customOrderRule,
'no-empty-style-blocks': noEmptyStyleBlocksRule,
},
configs: {
recommended: [
{
plugins: {
'vanilla-extract': {
rules: { 'concentric-order': concentricOrderRule },
rules: {
'concentric-order': concentricOrderRule,
'no-empty-style-blocks': noEmptyStyleBlocksRule,
},
},
rules: { 'vanilla-extract/concentric-order': 'warn' },
},
rules: {
'vanilla-extract/concentric-order': 'warn',
'vanilla-extract/no-empty-style-blocks': 'warn',
},
},
],
alphabetical: [

View file

@ -6,7 +6,7 @@ export default defineConfig({
provider: 'v8',
reporter: ['html', 'json', 'lcov', 'text'],
reportsDirectory: './coverage/vitest-reports',
include: ['src/css-rules/**/*.ts', 'src/shared-utils/**/*.ts'],
include: ['src/css-rules/**/*.ts'],
exclude: ['src/**/*.css.ts', 'src/**/*index.ts', 'src/**/*types.ts'],
},
reporters: [