Compare commits

...

28 commits
v1.1.2 ... main

Author SHA1 Message Date
62b1844b44 feat 🥁: add no-unitless-values rule
Some checks failed
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
- Disallow unitless numeric values for CSS properties that require units (e.g., width: 100 should be width: 100px)
- Allow zero values and unitless-valid properties (opacity, zIndex, lineHeight)
- Support both numeric literals and string literals with unitless numbers
- Configurable allowlist via 'allow' option
2025-12-01 19:09:27 +02:00
7261c78a42 fix 🐞: fix false positives for non-empty object arguments in empty-style-blocks rule
Some checks failed
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
2025-11-22 12:33:12 +02:00
1d88c12e3d feat 🥁: add prefer-theme-tokens rule
Some checks failed
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
- Enforce theme tokens over hard-coded values in vanilla-extract styles (colors, spacing, font sizes, border radius/widths, shadows, z-index, opacity, font weights, transitions)
- Provide token suggestions from configured theme contracts; optional auto-fix for unambiguous replacements
2025-11-14 08:09:39 +02:00
d5eae5dfc8 feat 🥁: add prefer-logical-properties rule for i18n-friendly styles
- Add new rule `prefer-logical-properties` that enforces logical CSS properties over physical directional properties
- Detects 140+ physical property mappings across margin, padding, border, inset, size, overflow, and scroll properties
- Supports value-based detection for `text-align`, `float`, `clear`, and `resize` properties
- Provides automatic fixes for all detected violations
- Preserves original formatting (camelCase/kebab-case and quote style)
- Configurable allowlist via `allow` option to skip specific properties
- Comprehensive test coverage
2025-11-09 21:29:18 +02:00
69dd109311 feat 🥁: add no-px-unit rule
- Add new rule `no-px-unit` that disallows `px` units in vanilla-extract styles with an allowlist option
- Provides fix suggestions for string literals and simple template literals (no expressions)
2025-11-04 08:55:42 +02:00
9263c5dd24 feat 🥁: add no-trailing-zero rule
- New rule that flags and fixes unnecessary trailing zeros in numeric values
- Handles various CSS units, negative numbers, and decimal values
- Preserves non-trailing zeros in numbers like 11.01rem and 2.05em
- Includes comprehensive test coverage for edge cases
2025-10-22 06:06:33 +03:00
24681ebad9 test : add coverage for reference-based visitor creator 2025-10-17 09:43:28 +03:00
d4bac62046 docs 📝: improve README structure and ordering rule documentation 2025-10-15 07:11:16 +03:00
1acb26d3e6 docs 📝: add sponsorship section 2025-10-12 18:23:56 +03:00
27ae2b7ec8 chore 💰: add additional sponsor methods 2025-10-12 17:19:21 +03:00
a5c3f01bdc chore 💰: add FUNDING.yml for GitHub sponsors 2025-10-12 13:59:03 +03:00
Seongmin Choi
02576d923c
feat 🥁: add wrapper function support with reference tracking
- add reference tracking for wrapper functions in vanilla-extract style objects
- implement ReferenceTracker class for detecting vanilla-extract imports
- add createReferenceBasedNodeVisitors for automatic function detection
- support wrapper functions with parameter mapping enable all lint rules to work with custom wrapper functions

This commit introduces robust reference tracking and wrapper function support, enabling all lint rules to work seamlessly with custom vanilla-extract style patterns while preserving compatibility with existing usage and improving rule extensibility.
2025-06-25 16:51:36 +00:00
35875fbb31 feat 🥁: add ESLint v9 extends field support and document 8.57.0 compatibility
- confirm compatibility with ESLint 8.57.0
- add support for ESLint v9 extends field in flat config
- maintain backward compatibility with existing usage patterns
- update docs with configuration examples

This commit enhances the plugin's configuration options for ESLint v9 users while
ensuring backward compatibility. Users can now use the familiar extends syntax
with flat config, simplifying integration into existing projects. Documentation
has been updated to demonstrate proper usage with both ESLint 8 and 9.
2025-04-19 20:53:58 +03:00
f880c051ff feat 🥁: add no-unknown-unit rule
Adds a rule to disallow unknown or invalid CSS units in vanilla-extract style objects.

- Reports any usage of unrecognized units in property values
- Handles all vanilla-extract APIs (style, styleVariants, recipe, etc.)
- Ignores valid units in special contexts (e.g., CSS functions, custom properties)

No autofix is provided because replacing or removing unknown units may result in unintended or invalid CSS. Manual developer review is required to ensure correctness.
2025-04-16 09:45:33 +03:00
7dc7204749 feat 🥁: add no-zero-unit rule
This rule enforces unitless zero values in vanilla-extract style objects:
- Automatically removes unnecessary units from zero values
- Handles both positive and negative zero values
- Preserves units where required (time properties, CSS functions)
- Works with all vanilla-extract APIs
2025-04-12 20:53:34 +03:00
52d38d4477 feat 🥁: add recommended config with error-level rules
Add a recommended configuration preset that enables concentric-order and no-empty-style-blocks rules with error severity.

- Fix plugin configuration structure to work properly with ESLint 9
- Set concentric-order and no-empty-style-blocks as recommended rules
- Use error severity for recommended rules to enforce best practices
- Maintain backward compatibility with existing implementations

This change improves developer experience by providing sensible defaults while maintaining flexibility for customization.
2025-04-07 13:00:55 +03:00
175ce9aef8 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.
2025-04-06 16:34:35 +03:00
f346002fb0 chore 📝: add bug and feature request templates 2025-03-12 09:23:41 +02:00
cd4314dc53 chore 🔧: add CODEOWNERS file to enforce code review requirements 2025-03-12 08:15:21 +02:00
d7b0ca87b4 chore 📦: update project dependencies to latest versions 2025-03-12 06:46:14 +02:00
46751da51b refactor ♻️: improve code quality and test coverage
- Fix handling of missing groupOrder configuration
- Refactor negative conditions to positive ones with optional chaining
- Add comprehensive tests to achieve total coverage
2025-03-12 06:11:29 +02:00
5557409368 chore 🔧: exclude test directories from published package 2025-03-10 21:12:32 +02:00
fb77b52800 docs 📝: add demo gif to README 2025-03-10 20:06:20 +02:00
4df6af76f7 ci 👷: add GitHub Actions workflow for linting and testing
Set up comprehensive CI pipeline with three jobs:

- Lint: Runs ESLint and type checking
- Test: Executes test suite with Coveralls integration
- Build: Verifies package builds correctly

The workflow uses pnpm and caches dependencies for faster runs.
2025-03-10 15:50:28 +02:00
d2b62d3995 chore 🔧: improve GitHub Actions workflow for release creation
- Fix workflow triggers to support both manual runs and tag pushes
- Add conditional logic to handle different event types
- Skip already existing releases to prevent errors
- Add completion messages for better feedback
- Improve error handling for the release creation process
2025-03-10 13:34:18 +02:00
1092b47f1c test : add coverage for shared utility functions
Add comprehensive tests for shared utility modules to improve code coverage:

- Test property name extraction edge cases
- Test CSS property priority map with invalid groups
- Test order strategy visitor creator edge cases
- Test font face property order enforcer early returns
- Test style node processor with arrays and null values

These tests ensure all code paths in shared utilities are properly exercised,
including error handling and edge cases.
2025-03-10 09:28:51 +02:00
44eeb7be6d chore 🔧: add GitHub Action to create releases from tags 2025-03-10 09:20:43 +02:00
5f1e602dee test : add comprehensive test suite for CSS ordering rules
Add tests for all three CSS property ordering rules:

    alphabetical-order,
    concentric-order,
    custom-order,

Tests cover all implemented vanilla-extract APIs, fontFace, globalFontFace, globalKeyframes, globalStyle, keyframes, style, and styleVariants.. Each test verifies both valid and invalid cases, along with proper auto-fixing functionality.
2025-03-10 09:20:32 +02:00
133 changed files with 18347 additions and 1069 deletions

3
.github/CODEOWNERS vendored Normal file
View file

@ -0,0 +1,3 @@
# Default owner
* @antebudimir

4
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1,4 @@
custom: ["https://revolut.me/antebudimir"]
github: antebudimir
ko_fi: antebudimir
liberapay: antebudimir

81
.github/ISSUE_TEMPLATE/bug-report.md vendored Normal file
View file

@ -0,0 +1,81 @@
---
name: Bug Report
about: Report an issue with the vanilla-extract CSS ESLint plugin
title: "[BUG] Concise descriptive title"
labels: bug, needs-triage
assignees: antebudimir
---
name: Bug Report
about: Report an issue with the vanilla-extract CSS ESLint plugin
title: '[BUG]: '
labels: bug, needs-triage
assignees: ''
## Contribution Checklist
- [ ] I have read documentation and understand the feature
- [ ] I have searched for similar issues before creating this one
- [ ] I have tested this with the latest plugin version
- [ ] I have included all information needed to reproduce the issue
## Bug Description
## Reproduction Steps
1.
2.
3.
## Expected Behavior
## Actual Behavior
## Code Example
```js
// Include a minimal code example that demonstrates the issue
import { style } from '@vanilla-extract/css';
const example = style({
// The problematic CSS properties
});
```
## Environment Information
- Node Version:
- Package Versions:
- ESLint:
- @vanilla-extract/css:
- TypeScript:
- @antebudimir/eslint-plugin-vanilla-extract:
## ESLint Configuration
```js
// Include your relevant ESLint configuration
{
files: [],
ignores: [],
plugins: {
'vanilla-extract': vanillaExtract,
},
rules: {},
}
```
## Screenshots/Videos
## Console Output
## Additional Context
## Impact
- [ ] Blocks critical functionality
- [ ] Produces false positives
- [ ] Misses valid errors
- [ ] Performance issue
- [ ] Other (please specify)

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Questions & Discussions
url: https://github.com/antebudimir/eslint-plugin-vanilla-extract/discussions
about: Please ask questions here instead of opening an issue
- name: Documentation
url: https://github.com/antebudimir/eslint-plugin-vanilla-extract
about: Check the documentation before reporting issues

View file

@ -0,0 +1,46 @@
---
name: Feature Request
about: Suggest an enhancement for the vanilla-extract CSS ESLint plugin
title: "[FEATURE]: Add concise descriptive title"
labels: enhancement, needs-triage
assignees: antebudimir
---
## Contribution Checklist
- [ ] I've confirmed that this feature isn't already implemented
- [ ] I've searched for similar feature requests before creating this one
- [ ] I've checked that this feature aligns with the project's goals
- [ ] I've considered how this would benefit the broader community
## Problem Statement
## Proposed Solution
```js
// Example of how the feature might work
import { style } from '@vanilla-extract/css';
const example = style({
// How your proposed feature would improve this code
});
```
## Expected Behavior
## Alternatives Considered
## Implementation Ideas
## Use Cases
## Additional Context
## Environment Information
- Node Version:
- Package Versions:
- ESLint:
- @vanilla-extract/css:
- TypeScript:

137
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,137 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
NODE_VERSION: '20.18.3'
PNPM_VERSION: '10.5.0'
COVERAGE_REPORT_PATH: './coverage/vitest-reports/lcov.info'
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: ${{ env.PNPM_VERSION }}
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Setup pnpm cache
uses: actions/cache@v3
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
- name: Build package
run: pnpm build
lint:
name: Lint
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: ${{ env.PNPM_VERSION }}
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Setup pnpm cache
uses: actions/cache@v3
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
- name: Build package
run: pnpm build
- name: Run ESLint
run: pnpm lint
- name: Run type checking
run: pnpm typecheck
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: ${{ env.PNPM_VERSION }}
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Setup pnpm cache
uses: actions/cache@v3
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
- name: Run tests with coverage
run: pnpm test:coverage
- name: Upload coverage to Coveralls
uses: coverallsapp/github-action@v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
path-to-lcov: ${{ env.COVERAGE_REPORT_PATH }}

65
.github/workflows/create-releases.yml vendored Normal file
View file

@ -0,0 +1,65 @@
name: Create Releases from Tags
on:
workflow_dispatch:
push:
tags:
- 'v*'
jobs:
create-releases:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Create Releases from Tags
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref_type }}" == "tag" ]]; then
# For tag push events, only create release for the pushed tag
TAG="${{ github.ref_name }}"
VERSION=${TAG#v} # Remove 'v' prefix
# Extract changelog entry for this version
BODY=$(sed -n "/## \[$VERSION\]/,/## \[/p" CHANGELOG.md | sed '$d')
# Create GitHub release
gh release create $TAG \
--title "Release $TAG" \
--notes "$BODY" \
--repo ${{ github.repository }}
echo "✅ Successfully created release for $TAG"
else
# For manual workflow runs, check each tag
# Get all tags sorted by version
TAGS=$(git tag -l | sort -V)
for TAG in $TAGS; do
VERSION=${TAG#v} # Remove 'v' prefix
# Check if release already exists
if ! gh release view $TAG --repo ${{ github.repository }} &>/dev/null; then
# Extract changelog entry for this version
BODY=$(sed -n "/## \[$VERSION\]/,/## \[/p" CHANGELOG.md | sed '$d')
# Create GitHub release
gh release create $TAG \
--title "Release $TAG" \
--notes "$BODY" \
--repo ${{ github.repository }}
echo "Created release for $TAG"
else
echo "Release for $TAG already exists, skipping"
fi
done
echo "🎉 Workflow completed successfully! All releases have been processed."
fi
- name: Completion Message
run: echo "🚀 Release creation process has finished!"

3
.gitignore vendored
View file

@ -30,3 +30,6 @@ pnpm-debug.log*
# typescript
*.tsbuildinfo
# dev helper
src/css-sample/

1
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1 @@
{}

195
CHANGELOG.md Normal file
View file

@ -0,0 +1,195 @@
# Changelog
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.16.0] - 2025-12-01
- Add new rule `no-unitless-values` that disallows unitless numeric values for CSS properties that require units ([issue #6](https://github.com/antebudimir/eslint-plugin-vanilla-extract/issues/6))
- Flags both numeric literals (e.g., `width: 100`) and string literals with unitless numbers (e.g., `width: '100'`)
- Allows zero values without units (valid CSS) and properties that accept unitless values (opacity, zIndex, lineHeight, etc.)
- Configurable allowlist via `allow` option to exclude specific properties from checking
- Optional rule (not included in recommended config) - teams can enable when they prefer explicit units over vanilla-extract's automatic px conversion
## [1.15.1] - 2025-11-22
- Fix [issue #7](https://github.com/antebudimir/eslint-plugin-vanilla-extract/issues/7) to prevent false positives for `sprinkles()`/`style()`/`recipe()` calls with non-empty object arguments while continuing to flag bare `({})` calls
- Add regression tests covering empty and non-empty call expressions in recipe base/variants to guard against future regressions
## [1.15.0] - 2025-11-14
- Add new rule `prefer-theme-tokens`that requires theme tokens instead of hard-coded values in vanilla-extract styles
- Detects hard-coded values across colors, spacing, font sizes, border radius/widths, shadows, z-index, opacity, font weights, and transitions
- Provides suggestions from configured theme contracts; optional auto-fix for unambiguous replacements
- Supports nested objects, media queries, selectors, and (optionally) template literals/helper calls
- Configurable via `themeContracts`, category toggles, `allowedValues`, `allowedProperties`, `autoFix`, `remBase`, `checkHelperFunctions` (see README for details)
## [1.14.0] - 2025-11-09
- Add new rule `prefer-logical-properties` that enforces logical CSS properties over physical directional properties
- Detects 140+ physical property mappings across margin, padding, border, inset, size, overflow, and scroll properties
- Supports value-based detection for `text-align`, `float`, `clear`, and `resize` properties
- Provides automatic fixes for all detected violations
- Preserves original formatting (camelCase/kebab-case and quote style)
- Configurable allowlist via `allow` option to skip specific properties
- Comprehensive test coverage
## [1.13.0] - 2025-11-04
- Add new rule `no-px-unit` that disallows `px` units in vanilla-extract styles with an allowlist option
- Provides fix suggestions for string literals and simple template literals (no expressions)
## [1.12.0] - 2025-10-22
- Add new rule `no-trailing-zero` that flags and fixes unnecessary trailing zeros in numeric values
- Handles various CSS units, negative numbers, and decimal values
- Preserves non-trailing zeros in numbers like 11.01rem and 2.05em
- Includes comprehensive test coverage for edge cases
## [1.11.1] - 2025-10-15
- Improve README structure and clarity
- Add "Important: Only Enable One Ordering Rule at a Time" section after configuration options
- Clarify that both `extends` and `plugins` approaches support rule customization
- Update "Recommended Configuration" section to list all 6 available rules (4 enabled by default, 2 alternatives)
- Add clear examples for switching between ordering rules
- Add warning about conflicting auto-fixes when multiple ordering rules are enabled simultaneously
- Clarify that users must explicitly disable the default ordering rule when switching to a different one
## [1.11.0] - 2025-06-25
- add reference tracking for wrapper functions in vanilla-extract style objects
- implement ReferenceTracker class for detecting vanilla-extract imports
- add createReferenceBasedNodeVisitors for automatic function detection
- support wrapper functions with parameter mapping enable all lint rules to work with custom wrapper functions
## [1.10.0] - 2025-04-19
- confirm compatibility with ESLint 8.57.0
- add support for ESLint v9 extends field in flat config
- maintain backward compatibility with existing usage patterns
- update [README.md](README.md#usage) with configuration examples for both ESLint 8 and ESLint 9
## [1.9.0] - 2025-04-16
- add new rule `no-unknown-unit` that disallows unknown or invalid CSS units in vanilla-extract style objects.
- Reports any usage of unrecognized units in property values
- Handles all vanilla-extract APIs, including style, recipe, fontFace, and keyframes
- Ignores valid units in special contexts (e.g., CSS functions, custom properties)
- Supports nested objects, media queries, and pseudo-selectors
- No autofix is provided because replacing or removing unknown units may result in unintended or invalid CSS; manual
developer review is required
## [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.
- Fix plugin configuration structure to work properly
- Set concentric-order and no-empty-style-blocks as recommended rules
- Use error severity for recommended rules to enforce best practices
- Maintain backward compatibility with existing implementations
## [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
## [1.5.2] - 2025-03-12
- Add CODEOWNERS file to enforce code review requirements
## [1.5.1] - 2025-03-12
- Update project dependencies to latest versions
## [1.5.0] - 2025-03-12
- 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
## [1.4.6] - 2025-03-10
- Add demo gif to README
## [1.4.5] - 2025-03-10
- Add GitHub Actions workflow for linting and testing
## [1.4.4] - 2025-03-10
- Improve GitHub Actions workflow for release creation
## [1.4.3] - 2025-03-10
- Add coverage for shared utility functions
## [1.4.2] - 2025-03-09
- Add GitHub Action to create releases from tags
## [1.4.1] - 2025-03-09
- Add comprehensive test suite for CSS ordering rules
## [1.4.0] - 2025-03-08
- Implement special ordering for fontFace APIs
## [1.3.1] - 2025-03-07
- Update milestones
## [1.3.0] - 2025-03-06
- Add script for versioning updates
## [1.2.0] - 2025-03-05
- Add support for linting keyframes and globalKeyframes
## [1.1.2] - 2025-03-05
- add .npmignore to exclude development files from npm package
## [1.1.1] - 2025-03-05
- Improve packaging and TypeScript configuration
## [1.1.0] - 2025-03-04
- Lower minimum Node.js version to 18.18.0
## [1.0.2] - 2025-03-04
- Add npm version badge and link to vanilla-extract
## [1.0.1] - 2025-03-04
- Add sample CSS file for linting demo during development
## [1.0.0] - 2025-03-04
- Initialize project with complete codebase

698
README.md
View file

@ -1,8 +1,25 @@
# @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)
[![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).
---
**Maintaining this plugin takes time and care! If you find it valuable, please consider supporting its development:**
💖 GitHub Sponsors: <https://github.com/sponsors/antebudimir>
☕ Ko-fi: <https://ko-fi.com/antebudimir>
💸 Revolut: <https://revolut.me/antebudimir>
🟡 Liberapay: <https://liberapay.com/antebudimir>
---
Comprehensive ESLint plugin for vanilla-extract that enforces best practices in [vanilla-extract](https://github.com/vanilla-extract-css/vanilla-extract) CSS styles. Includes support for CSS property ordering (alphabetical, [concentric](https://rhodesmill.org/brandon/2011/concentric-css/), and custom group ordering), advanced style linting rules, auto-fixing, and validation of style patterns specific to vanilla-extract. Ensures zero-runtime safety and integrates with multiple vanilla-extract APIs to promote maintainable, consistent code across projects
## Demo
![Plugin Demo](https://github.com/user-attachments/assets/93ba118d-84df-4da0-ac68-fdb429e581d6)
## Features
@ -10,18 +27,23 @@ An ESLint plugin for enforcing best practices in [vanilla-extract](https://githu
- Alphabetical ordering for clean, predictable style organization
- Concentric ordering for logical, outside-in property arrangement
- Custom group ordering option for more fine-grained control
- Built for ESLint 9 flat config system
- Compatible with ESLint 8.57.0+ and fully optimized for ESLint 9's flat config system
- Provides auto-fix capability to automatically sort properties
- Handles multiple vanilla-extract APIs (style, styleVariants, recipe, globalStyle, etc.)
- Handles complex cases like nested objects, arrays of styles, and pseudo selectors
- Works with camelCase properties as used in vanilla-extract
- Additional linting rules for enhanced code quality (see roadmap for upcoming features)
- Automatic wrapper function detection - works with custom wrapper functions that call vanilla-extract APIs, using
reference tracking to apply all rules regardless of how vanilla-extract functions are wrapped
## Requirements
- ESLint 9.0.0 or higher
- ESLint 8.57.0 or higher
- Node.js 18.18.0 or higher
- ESM (ECMAScript Modules) only
- Flat config system using either:
- `eslint.config.mjs` (recommended, always works with ESM plugins)
- `eslint.config.js` (only if your package.json has `"type": "module"`)
## Installation
@ -34,19 +56,225 @@ yarn add --dev @antebudimir/eslint-plugin-vanilla-extract
# Using pnpm
pnpm add -D @antebudimir/eslint-plugin-vanilla-extract
# For ESLint 8.57.0 with flat config, you'll also need:
npm install --save-dev @eslint/eslintrc @eslint/js
yarn add --dev @eslint/eslintrc @eslint/js
pnpm add -D @eslint/eslintrc @eslint/js
```
## Usage
**Note: This plugin is ESM-only.** It must be used with ESM configurations and can't be used with CommonJS `require()`.
### ESLint Flat Config (ESLint 9+)
### Configuration Options
Create or update your `eslint.config.js` or `eslint.config.mjs` file:
There are two main ways to configure this plugin in your ESLint flat config:
### Option 1: Using extends (recommended, available from v1.10.0)
The simplest and most concise way to apply the recommended ruleset:
```typescript
import { defineConfig } from 'eslint/config';
import vanillaExtract from '@antebudimir/eslint-plugin-vanilla-extract';
export default defineConfig([
{
files: ['**/*.css.ts'],
ignores: ['src/**/theme-contract.css.ts'],
extends: [vanillaExtract.configs.recommended],
// You can still override rules when using extends
// rules: {
// 'vanilla-extract/concentric-order': 'off',
// 'vanilla-extract/alphabetical-order': 'error',
// },
},
];
```
### Option 2: Using plugins with explicit rule spreading
This approach is more explicit - you manually register the plugin and spread the recommended rules:
```typescript
import { defineConfig } from 'eslint/config';
import vanillaExtract from '@antebudimir/eslint-plugin-vanilla-extract';
export default defineConfig([
{
files: ['**/*.css.ts'],
ignores: ['src/**/theme-contract.css.ts'],
plugins: {
'vanilla-extract': vanillaExtract,
},
rules: {
// Apply all recommended rules
...vanillaExtract.configs.recommended.rules,
// 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
// Switch to a different ordering rule (see "Important" section below)
// 'vanilla-extract/concentric-order': 'off',
// 'vanilla-extract/alphabetical-order': 'error',
},
},
];
```
#### Important: Only Enable One Ordering Rule at a Time
The plugin includes three CSS property ordering rules: `alphabetical-order`, `concentric-order`, and `custom-order`. **Only one ordering rule should be enabled at a time** to avoid conflicting auto-fixes.
If you want to use a different ordering rule than the one in the recommended config, you must explicitly disable the default rule.
##### Example: Switching from concentric to alphabetical ordering
```typescript
export default [
{
files: ['**/*.css.ts'],
plugins: {
'vanilla-extract': vanillaExtract,
},
rules: {
...vanillaExtract.configs.recommended.rules,
'vanilla-extract/concentric-order': 'off', // Disable the default
'vanilla-extract/alphabetical-order': 'error', // Enable alphabetical
},
},
];
```
##### Example: Using custom-order instead of the recommended concentric-order
```typescript
export default [
{
files: ['**/*.css.ts'],
plugins: {
'vanilla-extract': vanillaExtract,
},
rules: {
...vanillaExtract.configs.recommended.rules,
'vanilla-extract/concentric-order': 'off', // Disable the default
'vanilla-extract/custom-order': [
'error',
{
groupOrder: ['font', 'dimensions', 'margin', 'padding', 'position', 'border'],
sortRemainingProperties: 'alphabetical',
},
],
},
},
];
```
> **⚠️ Warning:** If multiple ordering rules are enabled simultaneously, they will produce conflicting auto-fixes that ESLint cannot apply, causing auto-fix on save to fail. Always ensure only one ordering rule is active.
### Using with FlatCompat (for ESLint 8.57.0 & 8.57.1)
If you're migrating from legacy ESLint configurations, you can use the `FlatCompat` utility to convert them while adding
vanilla-extract support:
```typescript
import path from 'path';
import { fileURLToPath } from 'url';
import { FlatCompat } from '@eslint/eslintrc';
import js from '@eslint/js';
import vanillaExtract from '@antebudimir/eslint-plugin-vanilla-extract';
// Mimic CommonJS variables
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Create a compatibility layer instance
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
});
export default [
// Convert your existing ESLint configs
...compat.config({
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
// etc
],
}),
// Add vanilla-extract by using explicit rule config
{
files: ['**/*.css.ts'],
ignores: ['src/**/theme-contract.css.ts'],
plugins: {
'vanilla-extract': vanillaExtract,
},
rules: {
// Apply all recommended rules
...vanillaExtract.configs.recommended.rules,
// or specify rule by rule as described above
},
},
];
```
#### Common Issues with FlatCompat
1. **Error: "Unexpected top-level property 'files'"**
- Solution: When using `compat.config()`, use `overrides` instead of `files` at the top level.
2. **Error: "Missing parameter 'recommendedConfig' in FlatCompat constructor"**
- Solution: Import `js` from `@eslint/js` and add `recommendedConfig: js.configs.recommended` to the FlatCompat
constructor.
3. **Error: "Unexpected undefined config at user-defined index 0"**
- Solution: Make sure you're using a default export for your configuration array.
### VS Code Integration
For VS Code users, add these settings to your `.vscode/settings.json`:
```json
{
"eslint.useFlatConfig": true,
"eslint.experimental.useFlatConfig": true,
"eslint.validate": ["javascript", "typescript", "typescriptreact"]
}
```
### Recommended Configuration
The recommended configuration enables the following rules with error severity:
- `vanilla-extract/concentric-order`: Enforces [concentric CSS](#concentric-css-model) property ordering
- `vanilla-extract/no-empty-style-blocks`: Prevents empty style blocks
- `vanilla-extract/no-trailing-zero`: Disallows trailing zeros in numeric CSS values
- `vanilla-extract/no-unknown-unit`: Prohibits usage of unrecognized CSS units
- `vanilla-extract/no-zero-unit`: Removes unnecessary units for zero values
**Additional rules available** (not enabled by default):
- `vanilla-extract/alphabetical-order`: Alternative ordering rule (alphabetical sorting)
- `vanilla-extract/custom-order`: Alternative ordering rule (custom group-based sorting)
- `vanilla-extract/no-px-unit`: Disallows px units with an optional allowlist
- `vanilla-extract/no-unitless-values`: Disallows unitless numeric values for CSS properties that require units
- `vanilla-extract/prefer-logical-properties`: Enforces logical CSS properties over physical directional properties
- `vanilla-extract/prefer-theme-tokens`: Enforces theme tokens instead of hard-coded values for colors, spacing, font sizes, border radius, border widths, shadows, z-index, opacity, font weights, and transitions/animations (optionally evaluates helper functions and template literals)
You can use the recommended configuration as a starting point and override rules as needed for your project. See the configuration examples above for how to switch between ordering rules.
### Custom Configuration
If you prefer not to use the recommended configuration, you can still configure rules manually:
```typescript
export default [
{
files: ['**/*.css.ts'],
@ -67,11 +295,15 @@ export default [
sortRemainingProperties: 'concentric', // 'alphabetical' is default
},
],
'vanilla-extract/no-unknown-unit': 'error',
'vanilla-extract/no-zero-unit': 'warn',
},
},
];
```
> **Note:** Remember to enable only one ordering rule at a time. See the "Important" section above for details on switching between ordering rules.
## Rules
### vanilla-extract/alphabetical-order
@ -106,8 +338,7 @@ export const myStyle = style({
### vanilla-extract/concentric-order
This rule enforces that CSS properties in vanilla-extract style objects follow the concentric CSS ordering pattern,
which organizes properties from outside to inside.
This rule enforces that CSS properties in vanilla-extract style objects follow the [concentric CSS](#concentric-css-model) ordering pattern, which organizes properties from outside to inside.
```typescript
// ❌ Incorrect
@ -135,11 +366,27 @@ export const myStyle = style({
### vanilla-extract/custom-order
The `vanilla-extract/custom-order` rule allows you to enforce a custom ordering of CSS properties in your vanilla-extract styles. You can specify an array of property groups in the order you prefer, and the rule will ensure that properties within these groups are sorted according to their position in the concentric CSS model. All other groups that aren't included in the groups array will have their respective properties sorted after the last group in the array. You can choose to sort them either alphabetically or following the concentric CSS order (see list of concentric groups) by setting the `sortRemainingProperties` option to 'alphabetical' or 'concentric' respectively. If not set, `sortRemainingProperties` defaults to 'alphabetical'.
The `vanilla-extract/custom-order` rule enables you to enforce a custom ordering of CSS properties in your
vanilla-extract styles. You can specify an array of property groups in your preferred order, and the rule will ensure
that properties within these groups are sorted according to their position in the [concentric CSS model](https://rhodesmill.org/brandon/2011/concentric-css/).
To configure the rule, add it to your ESLint configuration file with your desired options. You can customize the `groups` array to include any number of available CSS property groups you want to enforce, but minimum of 1 is required.
Key features of this rule include:
Example usage:
1. Custom group ordering: Define your preferred order of CSS property groups.
2. Handling of unspecified groups: All groups not included in the custom array will have their properties sorted after
the last specified group.
3. Flexible sorting options: You can choose to sort remaining properties either alphabetically or following the
concentric CSS order by setting the `sortRemainingProperties` option to 'alphabetical' or 'concentric' respectively.
Default behavior:
- If not set, `sortRemainingProperties` defaults to 'alphabetical'.
- If no `groupOrder` is specified or an empty array is provided, the rule will default to sorting all properties
alphabetically, and `sortRemainingProperties` will be ignored even if set.
To configure the rule, add it to your ESLint configuration file with your desired options. You can customize the
`groups` array to include any number of available CSS property groups you want to enforce, with a minimum of one group
required.
```typescript
// ❌ Incorrect (Unordered)
@ -180,9 +427,403 @@ 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' },
});
```
### vanilla-extract/no-px-unit
This rule disallows the use of hard-coded `px` units in vanilla-extract style declarations. Prefer `rem`, `em`, or theme tokens. A configurable allowlist lets you permit specific properties to use `px` where necessary. Allowlist supports both camelCase and kebab-case property names.
Configuration with an allowlist:
```json
{
"rules": {
"vanilla-extract/no-px-unit": ["error", { "allow": ["borderWidth", "outline-offset"] }]
}
}
```
Before:
```typescript
import { style } from '@vanilla-extract/css';
export const box = style({
marginTop: '8px',
padding: '16px',
selectors: {
'&:hover': { gap: '4px' },
},
});
```
After (suggested fix shown using rem):
```typescript
import { style } from '@vanilla-extract/css';
export const box = style({
marginTop: '8rem',
padding: '16rem',
selectors: {
'&:hover': { gap: '4rem' },
},
});
```
### vanilla-extract/no-trailing-zero
This rule disallows trailing zeros in numeric CSS values within vanilla-extract style objects. It helps maintain cleaner
and more consistent CSS by removing unnecessary trailing zeros from decimal numbers.
```typescript
// ❌ Incorrect
import { style } from '@vanilla-extract/css';
export const myStyle = style({
margin: '1.0px',
padding: '2.50rem',
opacity: 1.0,
lineHeight: 2.50,
width: '0.0em',
transition: 'all 0.30s ease',
});
// ✅ Correct
import { style } from '@vanilla-extract/css';
export const myStyle = style({
margin: '1px',
padding: '2.5rem',
opacity: 1,
lineHeight: 2.5,
width: '0',
transition: 'all 0.3s ease',
});
```
### vanilla-extract/no-unitless-values
This rule disallows unitless numeric values for CSS properties that require units in vanilla-extract style objects. It helps teams that prefer explicit units avoid confusion, as vanilla-extract automatically converts unitless numbers to `px` at runtime.
**Note:** This is an optional rule (not enabled in recommended config). Enable it only if your team prefers explicit units over vanilla-extract's automatic `px` conversion.
Configuration with allowed properties:
```json
{
"rules": {
"vanilla-extract/no-unitless-values": ["warn", { "allow": ["width", "height"] }]
}
}
```
```typescript
// ❌ Incorrect
import { style } from '@vanilla-extract/css';
export const myStyle = style({
width: 100,
margin: 20,
padding: 10.5,
height: '50',
top: '-10',
});
// ✅ Correct
import { style } from '@vanilla-extract/css';
export const myStyle = style({
width: '100px',
margin: '20px',
padding: 0,
height: '50rem',
opacity: 0.5, // opacity accepts unitless values
lineHeight: 1.5, // line-height accepts unitless values
zIndex: 10, // z-index accepts unitless values
});
```
**Properties that require units:**
- **Box model:** `width`, `height`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `min-width`, `max-width`, `min-height`, `max-height`
- **Spacing:** `margin`, `marginTop`, `marginRight`, `marginBottom`, `marginLeft`, `marginBlock`, `marginBlockStart`, `marginBlockEnd`, `marginInline`, `marginInlineStart`, `marginInlineEnd`, `margin-top`, `margin-right`, `margin-bottom`, `margin-left`, `margin-block`, `margin-block-start`, `margin-block-end`, `margin-inline`, `margin-inline-start`, `margin-inline-end`, `padding`, `paddingTop`, `paddingRight`, `paddingBottom`, `paddingLeft`, `paddingBlock`, `paddingBlockStart`, `paddingBlockEnd`, `paddingInline`, `paddingInlineStart`, `paddingInlineEnd`, `padding-top`, `padding-right`, `padding-bottom`, `padding-left`, `padding-block`, `padding-block-start`, `padding-block-end`, `padding-inline`, `padding-inline-start`, `padding-inline-end`
- **Positioning:** `top`, `right`, `bottom`, `left`, `inset`, `insetBlock`, `insetBlockStart`, `insetBlockEnd`, `insetInline`, `insetInlineStart`, `insetInlineEnd`, `inset-block`, `inset-block-start`, `inset-block-end`, `inset-inline`, `inset-inline-start`, `inset-inline-end`
- **Border:** `borderWidth`, `borderTopWidth`, `borderRightWidth`, `borderBottomWidth`, `borderLeftWidth`, `borderBlockWidth`, `borderBlockStartWidth`, `borderBlockEndWidth`, `borderInlineWidth`, `borderInlineStartWidth`, `borderInlineEndWidth`, `border-width`, `border-top-width`, `border-right-width`, `border-bottom-width`, `border-left-width`, `border-block-width`, `border-block-start-width`, `border-block-end-width`, `border-inline-width`, `border-inline-start-width`, `border-inline-end-width`, `borderRadius`, `borderTopLeftRadius`, `borderTopRightRadius`, `borderBottomLeftRadius`, `borderBottomRightRadius`, `borderStartStartRadius`, `borderStartEndRadius`, `borderEndStartRadius`, `borderEndEndRadius`, `border-radius`, `border-top-left-radius`, `border-top-right-radius`, `border-bottom-left-radius`, `border-bottom-right-radius`, `border-start-start-radius`, `border-start-end-radius`, `border-end-start-radius`, `border-end-end-radius`
- **Typography:** `fontSize`, `font-size`, `letterSpacing`, `letter-spacing`, `wordSpacing`, `word-spacing`, `textIndent`, `text-indent`
- **Layout:** `gap`, `rowGap`, `columnGap`, `row-gap`, `column-gap`, `flexBasis`, `flex-basis`
- **Outline:** `outlineWidth`, `outline-width`, `outlineOffset`, `outline-offset`
- **Other:** `blockSize`, `inlineSize`, `minBlockSize`, `maxBlockSize`, `minInlineSize`, `maxInlineSize`, `block-size`, `inline-size`, `min-block-size`, `max-block-size`, `min-inline-size`, `max-inline-size`
**Properties that accept unitless values:**
- **Common:** `opacity`, `zIndex`, `z-index`, `lineHeight`, `line-height`, `flexGrow`, `flex-grow`, `flexShrink`, `flex-shrink`, `order`, `fontWeight`, `font-weight`, `zoom`
- **Animation:** `animationIterationCount`, `animation-iteration-count`
- **Layout:** `columnCount`, `column-count`, `orphans`, `widows`
- **Grid:** `gridColumn`, `grid-column`, `gridColumnEnd`, `grid-column-end`, `gridColumnStart`, `grid-column-start`, `gridRow`, `grid-row`, `gridRowEnd`, `grid-row-end`, `gridRowStart`, `grid-row-start`
- **SVG:** `fillOpacity`, `fill-opacity`, `strokeOpacity`, `stroke-opacity`, `strokeMiterlimit`, `stroke-miterlimit`
**Why use this rule?**
While vanilla-extract safely converts unitless numbers to `px`, some teams prefer explicit units because:
1. It makes the intended unit clear (px, rem, em, %, etc.)
2. It prevents accidental use of px when rem or other units are preferred
3. It aligns with CSS best practices of being explicit about units
**Auto-fix:** Not available. Since different teams prefer different units (px, rem, em, %), you must manually add your preferred unit.
### vanilla-extract/no-unknown-unit
This rule enforces the use of valid CSS units in vanilla-extract style objects. It prevents typos and non-standard units
that could cause styling issues or browser compatibility problems.
```typescript
// ❌ Incorrect
import { style, globalStyle, recipe } from '@vanilla-extract/css';
export const invalidStyle = style({
margin: '5abc', // Non-existent unit
fontSize: '1.5rems', // Typo in unit
});
export const myRecipe = recipe({
variants: {
size: {
large: { padding: '4xm' }, // Invalid unit
},
},
});
// ✅ Correct
import { style, globalStyle, recipe } from '@vanilla-extract/css';
export const validStyle = style({
margin: '5rem',
fontSize: '1.5rem',
});
export const myRecipe = recipe({
variants: {
size: {
large: { padding: '4em' },
},
},
});
```
### 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',
});
```
### vanilla-extract/prefer-logical-properties
This rule enforces the use of CSS logical properties instead of physical (directional) properties in vanilla-extract style declarations. Logical properties adapt to different writing directions (LTR/RTL) and writing modes, making your styles more internationalization-friendly. Supports 140+ property mappings across margin, padding, border, inset, size, overflow, and scroll properties. Configurable allowlist lets you permit specific properties via the `allow` option (supports both camelCase and kebab-case).
Configuration with an allowlist:
```json
{
"rules": {
"vanilla-extract/prefer-logical-properties": ["error", { "allow": ["top", "left"] }]
}
}
```
```typescript
// ❌ Incorrect
import { style } from '@vanilla-extract/css';
export const box = style({
marginLeft: '1rem',
paddingTop: '2rem',
width: '100%',
borderRight: '1px solid',
textAlign: 'left',
});
// ✅ Correct
import { style } from '@vanilla-extract/css';
export const box = style({
marginInlineStart: '1rem',
paddingBlockStart: '2rem',
inlineSize: '100%',
borderInlineEnd: '1px solid',
textAlign: 'start',
});
```
### vanilla-extract/prefer-theme-tokens
Enforces theme tokens instead of hard-coded CSS values. Analyzes your theme contract files and suggests **specific tokens** when matches are found.
**Options:**
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `themeContracts` | `string[]` | `[]` | Theme contract file paths (relative to project root or absolute) |
| `checkColors` | `boolean` | `true` | Check colors (hex, rgb, hsl, named) |
| `checkSpacing` | `boolean` | `true` | Check spacing (margin, padding, gap, width, height) |
| `checkFontSizes` | `boolean` | `true` | Check font sizes (fontSize, lineHeight) |
| `checkBorderRadius` | `boolean` | `true` | Check border radius values |
| `checkBorderWidths` | `boolean` | `true` | Check border widths (including `border` shorthand) |
| `checkShadows` | `boolean` | `true` | Check shadows (boxShadow, textShadow, filter) |
| `checkZIndex` | `boolean` | `true` | Check z-index values |
| `checkOpacity` | `boolean` | `true` | Check opacity values |
| `checkFontWeights` | `boolean` | `true` | Check font weights (numeric and named) |
| `checkTransitions` | `boolean` | `true` | Check transitions and animations |
| `allowedValues` | `string[]` | `[]` | Whitelist specific values (e.g., `["0", "auto", "100vh"]`) |
| `allowedProperties` | `string[]` | `[]` | Skip checking specific properties |
| `autoFix` | `boolean` | `false` | Auto-fix when exactly one token matches |
| `remBase` | `number` | `16` | Base font size for `rem()` calculations |
| `checkHelperFunctions` | `boolean` | `false` | Check helper calls like `rem(48)`, `` `${rem(4)}` `` |
#### Dependency note
This rule uses a safe expression evaluator to optionally analyze helper calls when `checkHelperFunctions` is enabled. For this, the plugin internally relies on `@babel/parser` and `@babel/types` to parse small expression snippets (e.g., template literals, `rem()` calls). These are shipped as plugin dependencies, so users don't need to install them manually. They're only exercised when `checkHelperFunctions` is turned on.
**Example:**
```json
{
"rules": {
"vanilla-extract/prefer-theme-tokens": ["error", {
"themeContracts": ["./src/theme.css.ts"],
"checkColors": true,
"checkSpacing": true,
"allowedValues": ["0", "auto", "100%"],
"allowedProperties": ["borderWidth"],
"autoFix": false,
"checkHelperFunctions": false
}]
}
}
```
**How it works:**
1. **Analyzes theme contracts** - Reads your theme files and evaluates computed values:
- `rem(16)``"1rem"`
- `` `${rem(4)} ${rem(8)}` `` → `"0.25rem 0.5rem"`
- Arithmetic expressions
2. **Detects hard-coded values** - Checks literals, numbers, and (optionally) helper functions:
```typescript
color: '#0055FF' // ❌ Always flagged
padding: '16px' // ❌ Always flagged
opacity: 0.5 // ❌ Always flagged (numeric literal)
margin: rem(48) // ❌ Only with checkHelperFunctions: true
boxShadow: `${rem(4)}...` // ❌ Only with checkHelperFunctions: true
```
3. **Suggests specific tokens** - Matches values to theme tokens:
```text
❌ Hard-coded color '#0055FF'. Use theme token: vars.colors.brand
❌ Hard-coded padding '16px'. Use theme token: vars.spacing.md
```
- **Single match**: Shows one suggestion + auto-fix (if enabled)
- **Multiple matches**: Shows all as quick-fix options
**Theme contract example:**
```typescript
// theme.css.ts
export const [themeClass, vars] = createTheme({
colors: { brand: '#0055FF', text: '#1f2937' },
spacing: { sm: '8px', md: '16px' },
});
// styles.css.ts
export const button = style({
backgroundColor: '#0055FF', // ❌ Use vars.colors.brand
padding: '8px', // ❌ Use vars.spacing.sm
});
```
**Helper function detection:**
By default, only checks **literals**. Enable `checkHelperFunctions: true` to also check computed values:
```typescript
// checkHelperFunctions: false (default)
padding: rem(48) // ✅ Not flagged
padding: '3rem' // ❌ Flagged
// checkHelperFunctions: true
padding: rem(48) // ❌ Flagged if theme has matching token
padding: '3rem' // ❌ Flagged if theme has matching token
```
**Note:** Opt-in rule (not in recommended config). Enable when ready to enforce design tokens.
## Font Face Declarations
For `fontFace` and `globalFontFace` API calls, all three ordering rules (alphabetical, concentric, and custom) enforce the same special ordering:
For `fontFace` and `globalFontFace` API calls, all three ordering rules (alphabetical, concentric, and custom) enforce
the same special ordering:
1. The `src` property always appears first
2. All remaining properties are sorted alphabetically
@ -210,7 +851,8 @@ Opinionated, but it is what it is. If someone has a suggestion for a better orde
## Concentric CSS Model
Here's a list of all available groups from the provided [concentricGroups](src/css-rules/concentric-order/concentric-groups.ts) array:
Here's a list of all available groups from the provided
[concentricGroups](src/css-rules/concentric-order/concentric-groups.ts) array:
1. boxSizing
2. position
@ -242,7 +884,8 @@ Here's a list of all available groups from the provided [concentricGroups](src/c
28. counters
29. breaks
These groups represent different categories of CSS properties, organized in a concentric order from outside to inside. Each group contains related CSS properties that affect specific aspects of an element's styling and layout.
These groups represent different categories of CSS properties, organized in a concentric order from outside to inside.
Each group contains related CSS properties that affect specific aspects of an element's styling and layout.
## Roadmap
@ -253,22 +896,25 @@ 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.).
### Current Work
- Test coverage.
- `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.
- `no-unknown-unit` rule to disallow unknown units.
- `no-trailing-zero` rule to disallow trailing zeros in numbers.
- Support for using the plugin's recommended config via the extends field (as discussed in
[issue #3](https://github.com/antebudimir/eslint-plugin-vanilla-extract/issues/3))
- Comprehensive rule testing.
- `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 theme tokens instead of hard-coded values for colors, spacing, font sizes, border radius, border widths, shadows, z-index, opacity, font weights, and transitions/animations (optionally evaluates helper functions and template literals).
- `no-unitless-values` rule to disallow unitless numeric values for CSS properties that require units.
### Upcoming Features
- `no-empty-blocks` rule to disallow empty blocks.
- `no-unknown-units` rule to disallow unknown units.
- `no-number-trailing-zeros` rule to disallow trailing zeros in numbers.
- `no-zero-unit` rule to disallow units when the value is zero.
- `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.
- `no-global-style` rule to disallow use of `globalStyle` function.
- Option to sort properties within user-defined concentric groups alphabetically instead of following the concentric order. **Note**: This feature will only be implemented if there's sufficient interest from the community.
- `property-unit-match` rule to enforce valid units per CSS property specs. **Note**: This feature will only be
implemented if there's sufficient interest from the community.
- Option to sort properties within user-defined concentric groups alphabetically instead of following the concentric
order. **Note**: This feature will only be implemented if there's sufficient interest from the community.
## Contributing

View file

@ -1,23 +1,10 @@
import path from 'path';
import { fileURLToPath } from 'url';
import { FlatCompat } from '@eslint/eslintrc';
import eslintPluginESLintPlugin from 'eslint-plugin-eslint-plugin';
import importPlugin from 'eslint-plugin-import';
import { defineConfig } from 'eslint/config';
import prettierConfig from 'eslint-config-prettier';
import * as tseslint from 'typescript-eslint';
import vanillaExtract from '@antebudimir/eslint-plugin-vanilla-extract';
// mimic CommonJS variables
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
export default [
// mimic ESLintRC-style extends
// Prettier always must be last to override other style rules
...compat.extends('prettier'),
export default defineConfig([
{
files: ['**/*.js', '**/*.ts', '**/*.cjs', '**/*.mjs'],
plugins: {
@ -75,26 +62,8 @@ export default [
'import/export': 'error',
},
},
...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)
},
],
},
},
...tseslint.configs.recommended,
{
files: ['**/*.{js,ts}'],
@ -123,4 +92,6 @@ export default [
'no-unused-vars': 'off',
},
},
];
prettierConfig,
]);

View file

@ -1,7 +1,7 @@
{
"name": "@antebudimir/eslint-plugin-vanilla-extract",
"version": "1.4.0",
"description": "ESLint plugin for enforcing CSS ordering in vanilla-extract styles",
"version": "1.16.0",
"description": "Comprehensive ESLint plugin for vanilla-extract with CSS property ordering, style validation, and best practices enforcement. Supports alphabetical, concentric and custom CSS ordering, auto-fixing, and zero-runtime safety.",
"author": "Ante Budimir",
"license": "MIT",
"keywords": [
@ -38,6 +38,7 @@
"dist",
"LICENSE",
"README.md",
"!dist/**/__tests__",
"!dist/css-sample/"
],
"scripts": {
@ -46,31 +47,48 @@
"lint": "eslint src --ext .ts --fix --max-warnings 0",
"prepublishOnly": "pnpm run lint && pnpm run build",
"publish": "pnpm publish --access public",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"version:update": "node scripts/update-version.mjs"
},
"engines": {
"node": ">=18.18.0"
},
"packageManager": "pnpm@10.5.0",
"packageManager": "pnpm@10.6.2",
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
]
},
"peerDependencies": {
"eslint": ">=9.0.0"
"eslint": ">=8.57.0"
},
"dependencies": {
"@babel/parser": "^7.28.5",
"@babel/types": "^7.28.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.0",
"@types/node": "^20.17.19",
"@typescript-eslint/eslint-plugin": "^8.25.0",
"@typescript-eslint/parser": "^8.25.0",
"@typescript-eslint/utils": "^8.25.0",
"@types/node": "^20.17.24",
"@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.1",
"@typescript-eslint/rule-tester": "^8.26.1",
"@typescript-eslint/utils": "^8.26.1",
"@vanilla-extract/css": "^1.17.1",
"@vanilla-extract/recipes": "^0.5.5",
"eslint": "^9.21.0",
"eslint-config-prettier": "^10.0.2",
"eslint-import-resolver-typescript": "^3.8.3",
"@vanilla-extract/sprinkles": "^1.6.0",
"@vitest/coverage-v8": "3.0.8",
"eslint": "^9.22.0",
"eslint-config-prettier": "^10.1.1",
"eslint-import-resolver-typescript": "^3.8.5",
"eslint-plugin-eslint-plugin": "^6.4.0",
"eslint-plugin-import": "^2.31.0",
"prettier": "^3.5.2",
"typescript": "^5.7.3",
"typescript-eslint": "^8.25.0"
"eslint-vitest-rule-tester": "^1.1.0",
"prettier": "^3.5.3",
"typescript": "^5.8.2",
"typescript-eslint": "^8.26.1",
"vitest": "3.0.8"
}
}

1872
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 src/index.ts to version ${newVersion}`);
console.log(`Updated package.json and src/index.ts to version ${newVersion}`);

View file

@ -0,0 +1,111 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import alphabeticalOrderRule from '../rule-definition.js';
run({
name: 'vanilla-extract/alphabetical-order/animation',
rule: alphabeticalOrderRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// keyframes with alphabetical ordering
`
import { keyframes } from '@vanilla-extract/css';
const fadeIn = keyframes({
'0%': {
opacity: 0,
transform: 'translateY(10px)'
},
'100%': {
opacity: 1,
transform: 'translateY(0)'
}
});
`,
// globalKeyframes with alphabetical ordering
`
import { globalKeyframes } from '@vanilla-extract/css';
globalKeyframes('fadeIn', {
'0%': {
opacity: 0,
transform: 'translateY(10px)'
},
'100%': {
opacity: 1,
transform: 'translateY(0)'
}
});
`,
],
invalid: [
// keyframes with incorrect ordering
{
code: `
import { keyframes } from '@vanilla-extract/css';
const fadeIn = keyframes({
'0%': {
transform: 'translateY(10px)',
opacity: 0
},
'100%': {
transform: 'translateY(0)',
opacity: 1
}
});
`,
errors: [{ messageId: 'alphabeticalOrder' }, { messageId: 'alphabeticalOrder' }],
output: `
import { keyframes } from '@vanilla-extract/css';
const fadeIn = keyframes({
'0%': {
opacity: 0,
transform: 'translateY(10px)'
},
'100%': {
opacity: 1,
transform: 'translateY(0)'
}
});
`,
},
// globalKeyframes with incorrect ordering
{
code: `
import { globalKeyframes } from '@vanilla-extract/css';
globalKeyframes('fadeIn', {
'0%': {
transform: 'translateY(10px)',
opacity: 0
},
'100%': {
transform: 'translateY(0)',
opacity: 1
}
});
`,
errors: [{ messageId: 'alphabeticalOrder' }, { messageId: 'alphabeticalOrder' }],
output: `
import { globalKeyframes } from '@vanilla-extract/css';
globalKeyframes('fadeIn', {
'0%': {
opacity: 0,
transform: 'translateY(10px)'
},
'100%': {
opacity: 1,
transform: 'translateY(0)'
}
});
`,
},
],
});

View file

@ -0,0 +1,143 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import alphabeticalOrderRule from '../rule-definition.js';
run({
name: 'vanilla-extract/alphabetical-order/font-face',
rule: alphabeticalOrderRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// fontFace with src first and other properties alphabetically ordered
`
import { fontFace } from '@vanilla-extract/css';
const myFont = fontFace({
src: ['url("/fonts/MyFont.woff2") format("woff2")'],
ascentOverride: '90%',
descentOverride: '10%',
fontDisplay: 'swap',
fontFeatureSettings: '"liga" 1',
fontStretch: 'normal',
fontStyle: 'normal',
fontVariant: 'normal',
fontWeight: '400'
});
`,
// globalFontFace with src first and other properties alphabetically ordered
`
import { globalFontFace } from '@vanilla-extract/css';
globalFontFace('GlobalFont', {
src: ['url("/fonts/MyFont.woff2") format("woff2")'],
ascentOverride: '90%',
descentOverride: '10%',
fontDisplay: 'swap',
fontFeatureSettings: '"liga" 1',
fontStretch: 'normal',
fontStyle: 'normal',
fontVariant: 'normal',
fontWeight: '400'
});
`,
],
invalid: [
// fontFace with src not first
{
code: `
import { fontFace } from '@vanilla-extract/css';
const myFont = fontFace({
fontWeight: '400',
src: ['url("/fonts/MyFont.woff2") format("woff2")'],
ascentOverride: '90%',
fontStyle: 'normal'
});
`,
errors: [{ messageId: 'fontFaceOrder' }],
output: `
import { fontFace } from '@vanilla-extract/css';
const myFont = fontFace({
src: ['url("/fonts/MyFont.woff2") format("woff2")'],
ascentOverride: '90%',
fontStyle: 'normal',
fontWeight: '400'
});
`,
},
// fontFace with src first but other properties not in alphabetical order
{
code: `
import { fontFace } from '@vanilla-extract/css';
const myFont = fontFace({
src: ['url("/fonts/MyFont.woff2") format("woff2")'],
fontWeight: '400',
ascentOverride: '90%',
fontStyle: 'normal'
});
`,
errors: [{ messageId: 'fontFaceOrder' }],
output: `
import { fontFace } from '@vanilla-extract/css';
const myFont = fontFace({
src: ['url("/fonts/MyFont.woff2") format("woff2")'],
ascentOverride: '90%',
fontStyle: 'normal',
fontWeight: '400'
});
`,
},
// globalFontFace with src not first
{
code: `
import { globalFontFace } from '@vanilla-extract/css';
globalFontFace('GlobalFont', {
fontWeight: '400',
fontStyle: 'normal',
src: ['url("/fonts/MyFont.woff2") format("woff2")'],
ascentOverride: '90%'
});
`,
errors: [{ messageId: 'fontFaceOrder' }],
output: `
import { globalFontFace } from '@vanilla-extract/css';
globalFontFace('GlobalFont', {
src: ['url("/fonts/MyFont.woff2") format("woff2")'],
ascentOverride: '90%',
fontStyle: 'normal',
fontWeight: '400'
});
`,
},
// globalFontFace with src first but other properties not in alphabetical order
{
code: `
import { globalFontFace } from '@vanilla-extract/css';
globalFontFace('GlobalFont', {
src: ['url("/fonts/MyFont.woff2") format("woff2")'],
fontWeight: '400',
fontStyle: 'normal',
ascentOverride: '90%'
});
`,
errors: [{ messageId: 'fontFaceOrder' }],
output: `
import { globalFontFace } from '@vanilla-extract/css';
globalFontFace('GlobalFont', {
src: ['url("/fonts/MyFont.woff2") format("woff2")'],
ascentOverride: '90%',
fontStyle: 'normal',
fontWeight: '400'
});
`,
},
],
});

View file

@ -0,0 +1,52 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import alphabeticalOrderRule from '../rule-definition.js';
run({
name: 'vanilla-extract/alphabetical-order/global',
rule: alphabeticalOrderRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// globalStyle with alphabetical ordering
`
import { globalStyle } from '@vanilla-extract/css';
globalStyle('body', {
backgroundColor: 'white',
color: 'black',
margin: 0,
padding: 0
});
`,
],
invalid: [
// globalStyle with incorrect ordering
{
code: `
import { globalStyle } from '@vanilla-extract/css';
globalStyle('body', {
color: 'black',
backgroundColor: 'white',
padding: 0,
margin: 0
});
`,
errors: [{ messageId: 'alphabeticalOrder' }],
output: `
import { globalStyle } from '@vanilla-extract/css';
globalStyle('body', {
backgroundColor: 'white',
color: 'black',
margin: 0,
padding: 0
});
`,
},
],
});

View file

@ -0,0 +1,92 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import alphabeticalOrderRule from '../rule-definition.js';
run({
name: 'vanilla-extract/alphabetical-order/recipe',
rule: alphabeticalOrderRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// Recipe with alphabetical ordering
`
import { recipe } from '@vanilla-extract/recipes';
const myRecipe = recipe({
base: {
alignItems: 'center',
display: 'flex'
},
variants: {
color: {
blue: {
backgroundColor: 'blue',
color: 'white'
},
red: {
backgroundColor: 'red',
color: 'black'
}
}
}
});
`,
],
invalid: [
// Recipe with incorrect ordering
{
code: `
import { recipe } from '@vanilla-extract/recipes';
const myRecipe = recipe({
base: {
display: 'flex',
alignItems: 'center'
},
variants: {
color: {
blue: {
color: 'white',
backgroundColor: 'blue'
},
red: {
color: 'black',
backgroundColor: 'red'
}
}
}
});
`,
errors: [
{ messageId: 'alphabeticalOrder' },
{ messageId: 'alphabeticalOrder' },
{ messageId: 'alphabeticalOrder' },
],
output: `
import { recipe } from '@vanilla-extract/recipes';
const myRecipe = recipe({
base: {
alignItems: 'center',
display: 'flex'
},
variants: {
color: {
blue: {
backgroundColor: 'blue',
color: 'white'
},
red: {
backgroundColor: 'red',
color: 'black'
}
}
}
});
`,
},
],
});

View file

@ -0,0 +1,199 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import alphabeticalOrderRule from '../rule-definition.js';
run({
name: 'vanilla-extract/alphabetical-order/style',
rule: alphabeticalOrderRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// Basic style object with alphabetical ordering
`
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
alignItems: 'center',
backgroundColor: 'red',
color: 'blue',
display: 'flex',
margin: '10px',
padding: '20px',
zIndex: 1
});
`,
// Style with nested selectors
`
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
alignItems: 'center',
backgroundColor: 'red',
color: 'blue',
selectors: {
'&:hover': {
backgroundColor: 'blue',
color: 'white'
}
}
});
`,
],
invalid: [
// Basic style object with incorrect ordering
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
backgroundColor: 'red',
alignItems: 'center',
padding: '20px',
color: 'blue',
margin: '10px',
display: 'flex',
zIndex: 1
});
`,
errors: [{ messageId: 'alphabeticalOrder' }],
output: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
alignItems: 'center',
backgroundColor: 'red',
color: 'blue',
display: 'flex',
margin: '10px',
padding: '20px',
zIndex: 1
});
`,
},
// Style with nested selectors having incorrect ordering
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
backgroundColor: 'red',
alignItems: 'center',
color: 'blue',
selectors: {
'&:hover': {
color: 'white',
backgroundColor: 'blue'
}
}
});
`,
errors: [{ messageId: 'alphabeticalOrder' }, { messageId: 'alphabeticalOrder' }],
output: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
alignItems: 'center',
backgroundColor: 'red',
color: 'blue',
selectors: {
'&:hover': {
backgroundColor: 'blue',
color: 'white'
}
}
});
`,
},
],
});

View file

@ -0,0 +1,111 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import alphabeticalOrderRule from '../rule-definition.js';
run({
name: 'vanilla-extract/alphabetical-order/style',
rule: alphabeticalOrderRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// Basic style object with alphabetical ordering
`
import { style } from '@vanilla-extract/css';
const myStyle = style({
alignItems: 'center',
backgroundColor: 'red',
color: 'blue',
display: 'flex',
margin: '10px',
padding: '20px',
zIndex: 1
});
`,
// Style with nested selectors
`
import { style } from '@vanilla-extract/css';
const myStyle = style({
alignItems: 'center',
backgroundColor: 'red',
color: 'blue',
selectors: {
'&:hover': {
backgroundColor: 'blue',
color: 'white'
}
}
});
`,
],
invalid: [
// Basic style object with incorrect ordering
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
backgroundColor: 'red',
alignItems: 'center',
padding: '20px',
color: 'blue',
margin: '10px',
display: 'flex',
zIndex: 1
});
`,
errors: [{ messageId: 'alphabeticalOrder' }],
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
alignItems: 'center',
backgroundColor: 'red',
color: 'blue',
display: 'flex',
margin: '10px',
padding: '20px',
zIndex: 1
});
`,
},
// Style with nested selectors having incorrect ordering
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
backgroundColor: 'red',
alignItems: 'center',
color: 'blue',
selectors: {
'&:hover': {
color: 'white',
backgroundColor: 'blue'
}
}
});
`,
errors: [{ messageId: 'alphabeticalOrder' }, { messageId: 'alphabeticalOrder' }],
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
alignItems: 'center',
backgroundColor: 'red',
color: 'blue',
selectors: {
'&:hover': {
backgroundColor: 'blue',
color: 'white'
}
}
});
`,
},
],
});

View file

@ -0,0 +1,64 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import alphabeticalOrderRule from '../rule-definition.js';
run({
name: 'vanilla-extract/alphabetical-order/variants',
rule: alphabeticalOrderRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// styleVariants with alphabetical ordering
`
import { styleVariants } from '@vanilla-extract/css';
const variants = styleVariants({
primary: {
backgroundColor: 'blue',
color: 'white'
},
secondary: {
backgroundColor: 'gray',
color: 'black'
}
});
`,
],
invalid: [
// styleVariants with incorrect ordering
{
code: `
import { styleVariants } from '@vanilla-extract/css';
const variants = styleVariants({
primary: {
color: 'white',
backgroundColor: 'blue'
},
secondary: {
color: 'black',
backgroundColor: 'gray'
}
});
`,
errors: [{ messageId: 'alphabeticalOrder' }, { messageId: 'alphabeticalOrder' }],
output: `
import { styleVariants } from '@vanilla-extract/css';
const variants = styleVariants({
primary: {
backgroundColor: 'blue',
color: 'white'
},
secondary: {
backgroundColor: 'gray',
color: 'black'
}
});
`,
},
],
});

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

@ -16,13 +16,11 @@ import { enforceAlphabeticalCSSOrderInStyleObject } from './style-object-process
* 3. For each relevant property (e.g., 'base', 'variants'), it applies alphabetical ordering to the CSS properties.
*/
export const enforceAlphabeticalCSSOrderInRecipe = (node: TSESTree.CallExpression, context: Rule.RuleContext): void => {
if (!node.arguments[0] || node.arguments[0].type !== 'ObjectExpression') {
return;
if (node.arguments[0]?.type === 'ObjectExpression') {
const recipeObject = node.arguments[0];
processRecipeProperties(context, recipeObject, (context, object) =>
processStyleNode(context, object, enforceAlphabeticalCSSOrderInStyleObject),
);
}
const recipeObject = node.arguments[0];
processRecipeProperties(context, recipeObject, (context, object) =>
processStyleNode(context, object, enforceAlphabeticalCSSOrderInStyleObject),
);
};

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

@ -20,18 +20,16 @@ export const enforceAlphabeticalCSSOrderInStyleObject = (
ruleContext: Rule.RuleContext,
styleObject: TSESTree.ObjectExpression,
): void => {
if (!styleObject || styleObject.type !== AST_NODE_TYPES.ObjectExpression) {
return;
}
if (styleObject?.type === AST_NODE_TYPES.ObjectExpression) {
if (isSelectorsObject(styleObject)) {
processNestedSelectors(ruleContext, styleObject, enforceAlphabeticalCSSOrderInStyleObject);
return;
}
const { regularProperties } = separateProperties(styleObject.properties);
enforceAlphabeticalCSSOrder(ruleContext, regularProperties);
if (isSelectorsObject(styleObject)) {
processNestedSelectors(ruleContext, styleObject, enforceAlphabeticalCSSOrderInStyleObject);
return;
}
const { regularProperties } = separateProperties(styleObject.properties);
enforceAlphabeticalCSSOrder(ruleContext, regularProperties);
processNestedSelectors(ruleContext, styleObject, enforceAlphabeticalCSSOrderInStyleObject);
};

View file

@ -0,0 +1,70 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import concentricOrderRule from '../rule-definition.js';
run({
name: 'vanilla-extract/concentric-order/animation',
rule: concentricOrderRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// keyframes with concentric ordering
`
import { keyframes } from '@vanilla-extract/css';
const fadeIn = keyframes({
'0%': {
position: 'relative',
transform: 'translateY(1rem)',
opacity: 0
},
'100%': {
position: 'relative',
transform: 'translateY(0)',
opacity: 1
}
});
`,
],
invalid: [
// keyframes with incorrect ordering
{
code: `
import { keyframes } from '@vanilla-extract/css';
const fadeIn = keyframes({
'0%': {
opacity: 0,
transform: 'translateY(1rem)',
position: 'relative'
},
'100%': {
opacity: 1,
transform: 'translateY(0)',
position: 'relative'
}
});
`,
errors: [{ messageId: 'incorrectOrder' }, { messageId: 'incorrectOrder' }],
output: `
import { keyframes } from '@vanilla-extract/css';
const fadeIn = keyframes({
'0%': {
position: 'relative',
transform: 'translateY(1rem)',
opacity: 0
},
'100%': {
position: 'relative',
transform: 'translateY(0)',
opacity: 1
}
});
`,
},
],
});

View file

@ -0,0 +1,97 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import concentricOrderRule from '../rule-definition.js';
run({
name: 'vanilla-extract/concentric-order/font-face',
rule: concentricOrderRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// fontFace with src first and other properties alphabetically ordered
`
import { fontFace } from '@vanilla-extract/css';
const myFont = fontFace({
src: ['url("/fonts/MyFont.woff2") format("woff2")'],
ascentOverride: '90%',
descentOverride: '10%',
fontDisplay: 'swap',
fontFeatureSettings: '"liga" 1',
fontStretch: 'normal',
fontStyle: 'normal',
fontVariant: 'normal',
fontWeight: '400'
});
`,
// globalFontFace with src first and other properties alphabetically ordered
`
import { globalFontFace } from '@vanilla-extract/css';
globalFontFace('GlobalFont', {
src: ['url("/fonts/MyFont.woff2") format("woff2")'],
ascentOverride: '90%',
descentOverride: '10%',
fontDisplay: 'swap',
fontFeatureSettings: '"liga" 1',
fontStretch: 'normal',
fontStyle: 'normal',
fontVariant: 'normal',
fontWeight: '400'
});
`,
],
invalid: [
// fontFace with src not first
{
code: `
import { fontFace } from '@vanilla-extract/css';
const myFont = fontFace({
fontWeight: '400',
src: ['url("/fonts/MyFont.woff2") format("woff2")'],
ascentOverride: '90%',
fontStyle: 'normal'
});
`,
errors: [{ messageId: 'fontFaceOrder' }],
output: `
import { fontFace } from '@vanilla-extract/css';
const myFont = fontFace({
src: ['url("/fonts/MyFont.woff2") format("woff2")'],
ascentOverride: '90%',
fontStyle: 'normal',
fontWeight: '400'
});
`,
},
// globalFontFace with src not first
{
code: `
import { globalFontFace } from '@vanilla-extract/css';
globalFontFace('GlobalFont', {
fontWeight: '400',
fontStyle: 'normal',
src: ['url("/fonts/MyFont.woff2") format("woff2")'],
ascentOverride: '90%'
});
`,
errors: [{ messageId: 'fontFaceOrder' }],
output: `
import { globalFontFace } from '@vanilla-extract/css';
globalFontFace('GlobalFont', {
src: ['url("/fonts/MyFont.woff2") format("woff2")'],
ascentOverride: '90%',
fontStyle: 'normal',
fontWeight: '400'
});
`,
},
],
});

View file

@ -0,0 +1,58 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import concentricOrderRule from '../rule-definition.js';
run({
name: 'vanilla-extract/concentric-order/global',
rule: concentricOrderRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// globalStyle with concentric ordering
`
import { globalStyle } from '@vanilla-extract/css';
globalStyle('body', {
position: 'relative',
display: 'block',
margin: 0,
backgroundColor: 'white',
padding: 0,
color: 'black'
});
`,
],
invalid: [
// globalStyle with incorrect ordering
{
code: `
import { globalStyle } from '@vanilla-extract/css';
globalStyle('body', {
color: 'black',
margin: 0,
backgroundColor: 'white',
padding: 0,
display: 'block',
position: 'relative'
});
`,
errors: [{ messageId: 'incorrectOrder' }],
output: `
import { globalStyle } from '@vanilla-extract/css';
globalStyle('body', {
position: 'relative',
display: 'block',
margin: 0,
backgroundColor: 'white',
padding: 0,
color: 'black'
});
`,
},
],
});

View file

@ -0,0 +1,101 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import concentricOrderRule from '../rule-definition.js';
run({
name: 'vanilla-extract/concentric-order/recipe',
rule: concentricOrderRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// Recipe with concentric ordering
`
import { recipe } from '@vanilla-extract/recipes';
const myRecipe = recipe({
base: {
position: 'relative',
display: 'flex',
alignItems: 'center',
backgroundColor: 'white',
width: '100%'
},
variants: {
color: {
blue: {
position: 'relative',
backgroundColor: 'blue',
color: 'white'
},
red: {
position: 'relative',
backgroundColor: 'red',
color: 'black'
}
}
}
});
`,
],
invalid: [
// Recipe with incorrect ordering
{
code: `
import { recipe } from '@vanilla-extract/recipes';
const myRecipe = recipe({
base: {
backgroundColor: 'white',
width: '100%',
display: 'flex',
alignItems: 'center'
},
variants: {
color: {
blue: {
color: 'white',
backgroundColor: 'blue',
position: 'relative'
},
red: {
color: 'black',
backgroundColor: 'red',
position: 'relative'
}
}
}
});
`,
errors: [{ messageId: 'incorrectOrder' }, { messageId: 'incorrectOrder' }, { messageId: 'incorrectOrder' }],
output: `
import { recipe } from '@vanilla-extract/recipes';
const myRecipe = recipe({
base: {
display: 'flex',
alignItems: 'center',
backgroundColor: 'white',
width: '100%'
},
variants: {
color: {
blue: {
position: 'relative',
backgroundColor: 'blue',
color: 'white'
},
red: {
position: 'relative',
backgroundColor: 'red',
color: 'black'
}
}
}
});
`,
},
],
});

View file

@ -0,0 +1,217 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import concentricOrderRule from '../rule-definition.js';
run({
name: 'vanilla-extract/concentric-order/style-custom',
rule: concentricOrderRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// Basic style object with concentric ordering through wrapper function
`
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
boxSizing: 'border-box',
position: 'relative',
zIndex: 1,
display: 'flex',
alignItems: 'center',
transform: 'none',
opacity: 1,
margin: '1rem',
border: '1px solid black',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
backgroundColor: 'red',
padding: '2rem',
width: '10rem',
height: '10rem',
color: 'blue',
fontSize: '16rem'
});
`,
// Style with nested selectors
`
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
position: 'relative',
display: 'flex',
alignItems: 'center',
backgroundColor: 'red',
color: 'blue',
selectors: {
'&:hover': {
position: 'relative',
opacity: 0.8,
backgroundColor: 'blue',
color: 'white'
}
}
});
`,
],
invalid: [
// Basic style object with incorrect concentric ordering
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
color: 'blue',
width: '10rem',
display: 'flex',
backgroundColor: 'red',
margin: '1rem',
position: 'relative'
});
`,
errors: [{ messageId: 'incorrectOrder' }],
output: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
position: 'relative',
display: 'flex',
margin: '1rem',
backgroundColor: 'red',
width: '10rem',
color: 'blue'
});
`,
},
// Style with nested selectors having incorrect ordering
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
color: 'blue',
display: 'flex',
backgroundColor: 'red',
position: 'relative',
selectors: {
'&:hover': {
color: 'white',
position: 'relative',
backgroundColor: 'blue'
}
}
});
`,
errors: [{ messageId: 'incorrectOrder' }, { messageId: 'incorrectOrder' }],
output: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
position: 'relative',
display: 'flex',
backgroundColor: 'red',
color: 'blue',
selectors: {
'&:hover': {
position: 'relative',
backgroundColor: 'blue',
color: 'white'
}
}
});
`,
},
],
});

View file

@ -0,0 +1,129 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import concentricOrderRule from '../rule-definition.js';
run({
name: 'vanilla-extract/concentric-order/style',
rule: concentricOrderRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// Basic style object with concentric ordering
`
import { style } from '@vanilla-extract/css';
const myStyle = style({
boxSizing: 'border-box',
position: 'relative',
zIndex: 1,
display: 'flex',
alignItems: 'center',
transform: 'none',
opacity: 1,
margin: '1rem',
border: '1px solid black',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
backgroundColor: 'red',
padding: '2rem',
width: '10rem',
height: '10rem',
color: 'blue',
fontSize: '16rem'
});
`,
// Style with nested selectors
`
import { style } from '@vanilla-extract/css';
const myStyle = style({
position: 'relative',
display: 'flex',
alignItems: 'center',
backgroundColor: 'red',
color: 'blue',
selectors: {
'&:hover': {
position: 'relative',
opacity: 0.8,
backgroundColor: 'blue',
color: 'white'
}
}
});
`,
],
invalid: [
// Basic style object with incorrect ordering
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
color: 'blue',
width: '10rem',
display: 'flex',
backgroundColor: 'red',
margin: '1rem',
position: 'relative'
});
`,
errors: [{ messageId: 'incorrectOrder' }],
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
position: 'relative',
display: 'flex',
margin: '1rem',
backgroundColor: 'red',
width: '10rem',
color: 'blue'
});
`,
},
// Style with nested selectors having incorrect ordering
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
color: 'blue',
display: 'flex',
backgroundColor: 'red',
position: 'relative',
selectors: {
'&:hover': {
color: 'white',
position: 'relative',
backgroundColor: 'blue'
}
}
});
`,
errors: [{ messageId: 'incorrectOrder' }, { messageId: 'incorrectOrder' }],
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
position: 'relative',
display: 'flex',
backgroundColor: 'red',
color: 'blue',
selectors: {
'&:hover': {
position: 'relative',
backgroundColor: 'blue',
color: 'white'
}
}
});
`,
},
],
});

View file

@ -0,0 +1,135 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import concentricOrderRule from '../rule-definition.js';
run({
name: 'vanilla-extract/concentric-order/variants',
rule: concentricOrderRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// styleVariants with concentric ordering
`
import { styleVariants } from '@vanilla-extract/css';
const variants = styleVariants({
primary: {
position: 'relative',
display: 'flex',
margin: '1rem',
backgroundColor: 'blue',
padding: '0.5rem',
color: 'white'
},
secondary: {
position: 'relative',
display: 'flex',
margin: '0.8rem',
backgroundColor: 'gray',
padding: '0.4rem',
color: 'black'
}
});
`,
],
invalid: [
// styleVariants with incorrect ordering
{
code: `
import { styleVariants } from '@vanilla-extract/css';
const variants = styleVariants({
primary: {
color: 'white',
backgroundColor: 'blue',
padding: '0.5rem',
margin: '1rem',
display: 'flex',
position: 'relative'
},
secondary: {
color: 'black',
backgroundColor: 'gray',
padding: '0.4rem',
margin: '0.8rem',
display: 'flex',
position: 'relative'
}
});
`,
errors: [{ messageId: 'incorrectOrder' }, { messageId: 'incorrectOrder' }],
output: `
import { styleVariants } from '@vanilla-extract/css';
const variants = styleVariants({
primary: {
position: 'relative',
display: 'flex',
margin: '1rem',
backgroundColor: 'blue',
padding: '0.5rem',
color: 'white'
},
secondary: {
position: 'relative',
display: 'flex',
margin: '0.8rem',
backgroundColor: 'gray',
padding: '0.4rem',
color: 'black'
}
});
`,
},
// styleVariants with some variants having incorrect ordering
{
code: `
import { styleVariants } from '@vanilla-extract/css';
const variants = styleVariants({
primary: {
position: 'relative',
display: 'flex',
margin: '1rem',
padding: '0.5rem',
backgroundColor: 'blue',
color: 'white'
},
secondary: {
color: 'black',
backgroundColor: 'gray',
padding: '0.4rem',
margin: '0.8rem',
display: 'flex',
position: 'relative'
}
});
`,
errors: [{ messageId: 'incorrectOrder' }, { messageId: 'incorrectOrder' }],
output: `
import { styleVariants } from '@vanilla-extract/css';
const variants = styleVariants({
primary: {
position: 'relative',
display: 'flex',
margin: '1rem',
backgroundColor: 'blue',
padding: '0.5rem',
color: 'white'
},
secondary: {
position: 'relative',
display: 'flex',
margin: '0.8rem',
backgroundColor: 'gray',
padding: '0.4rem',
color: 'black'
}
});
`,
},
],
});

View file

@ -65,21 +65,19 @@ export const enforceConcentricCSSOrder = (
ruleContext: Rule.RuleContext,
cssPropertyInfoList: CSSPropertyInfo[],
): void => {
if (cssPropertyInfoList.length <= 1) {
return;
}
if (cssPropertyInfoList.length > 1) {
// Create pairs of consecutive properties
const propertyPairs = cssPropertyInfoList.slice(0, -1).map((currentProperty, index) => ({
currentProperty,
nextProperty: cssPropertyInfoList[index + 1] as CSSPropertyInfo,
}));
// Create pairs of consecutive properties
const propertyPairs = cssPropertyInfoList.slice(0, -1).map((currentProperty, index) => ({
currentProperty,
nextProperty: cssPropertyInfoList[index + 1] as CSSPropertyInfo,
}));
const violatingPair = propertyPairs.find(
({ currentProperty, nextProperty }) => compareProperties(currentProperty, nextProperty) > 0,
);
const violatingPair = propertyPairs.find(
({ currentProperty, nextProperty }) => compareProperties(currentProperty, nextProperty) > 0,
);
if (violatingPair) {
reportOrderingIssue(ruleContext, violatingPair.currentProperty, violatingPair.nextProperty, cssPropertyInfoList);
if (violatingPair) {
reportOrderingIssue(ruleContext, violatingPair.currentProperty, violatingPair.nextProperty, cssPropertyInfoList);
}
}
};

View file

@ -19,13 +19,11 @@ export const enforceConcentricCSSOrderInRecipe = (
ruleContext: Rule.RuleContext,
callExpression: TSESTree.CallExpression,
): void => {
if (!callExpression.arguments[0] || callExpression.arguments[0].type !== 'ObjectExpression') {
return;
if (callExpression.arguments[0]?.type === 'ObjectExpression') {
const recipeObjectExpression = callExpression.arguments[0];
processRecipeProperties(ruleContext, recipeObjectExpression, (currentContext, styleObject) =>
processStyleNode(currentContext, styleObject, enforceConcentricCSSOrderInStyleObject),
);
}
const recipeObjectExpression = callExpression.arguments[0];
processRecipeProperties(ruleContext, recipeObjectExpression, (currentContext, styleObject) =>
processStyleNode(currentContext, styleObject, enforceConcentricCSSOrderInStyleObject),
);
};

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 {
@ -50,23 +50,21 @@ export const enforceConcentricCSSOrderInStyleObject = (
ruleContext: Rule.RuleContext,
styleObject: TSESTree.ObjectExpression,
): void => {
if (!styleObject || styleObject.type !== AST_NODE_TYPES.ObjectExpression) {
return;
if (styleObject?.type === AST_NODE_TYPES.ObjectExpression) {
if (isSelectorsObject(styleObject)) {
styleObject.properties.forEach((property) => {
if (property.type === AST_NODE_TYPES.Property && property.value.type === AST_NODE_TYPES.ObjectExpression) {
enforceConcentricCSSOrderInStyleObject(ruleContext, property.value);
}
});
return;
}
const { regularProperties } = separateProperties(styleObject.properties);
const cssPropertyInfoList = buildCSSPropertyInfoList(regularProperties);
enforceConcentricCSSOrder(ruleContext, cssPropertyInfoList);
processNestedSelectors(ruleContext, styleObject, enforceConcentricCSSOrderInStyleObject);
}
if (isSelectorsObject(styleObject)) {
styleObject.properties.forEach((property) => {
if (property.type === AST_NODE_TYPES.Property && property.value.type === AST_NODE_TYPES.ObjectExpression) {
enforceConcentricCSSOrderInStyleObject(ruleContext, property.value);
}
});
return;
}
const { regularProperties } = separateProperties(styleObject.properties);
const cssPropertyInfoList = buildCSSPropertyInfoList(regularProperties);
enforceConcentricCSSOrder(ruleContext, cssPropertyInfoList);
processNestedSelectors(ruleContext, styleObject, enforceConcentricCSSOrderInStyleObject);
};

View file

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

View file

@ -0,0 +1,151 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import customGroupOrderRule from '../rule-definition.js';
run({
name: 'vanilla-extract/custom-order/animation',
rule: customGroupOrderRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// keyframes with custom group ordering (concentric for remaining)
{
code: `
import { keyframes } from '@vanilla-extract/css';
const fadeIn = keyframes({
'0%': {
position: 'relative',
transform: 'translateY(1rem)',
opacity: 0
},
'100%': {
position: 'relative',
transform: 'translateY(0)',
opacity: 1
}
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'concentric',
},
],
},
// keyframes with custom group ordering (alphabetical for remaining)
{
code: `
import { keyframes } from '@vanilla-extract/css';
const fadeIn = keyframes({
'0%': {
opacity: 0,
position: 'relative',
transform: 'translateY(1rem)'
},
'100%': {
opacity: 1,
position: 'relative',
transform: 'translateY(0)'
}
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'alphabetical',
},
],
},
],
invalid: [
// keyframes with incorrect ordering (concentric for remaining)
{
code: `
import { keyframes } from '@vanilla-extract/css';
const fadeIn = keyframes({
'0%': {
opacity: 0,
transform: 'translateY(1rem)',
position: 'relative'
},
'100%': {
opacity: 1,
transform: 'translateY(0)',
position: 'relative'
}
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'concentric',
},
],
errors: [{ messageId: 'incorrectOrder' }, { messageId: 'incorrectOrder' }],
output: `
import { keyframes } from '@vanilla-extract/css';
const fadeIn = keyframes({
'0%': {
position: 'relative',
transform: 'translateY(1rem)',
opacity: 0
},
'100%': {
position: 'relative',
transform: 'translateY(0)',
opacity: 1
}
});
`,
},
// keyframes with incorrect ordering (alphabetical for remaining)
{
code: `
import { keyframes } from '@vanilla-extract/css';
const fadeIn = keyframes({
'0%': {
transform: 'translateY(1rem)',
position: 'relative',
opacity: 0
},
'100%': {
transform: 'translateY(0)',
position: 'relative',
opacity: 1
}
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'alphabetical',
},
],
errors: [{ messageId: 'incorrectOrder' }, { messageId: 'incorrectOrder' }],
output: `
import { keyframes } from '@vanilla-extract/css';
const fadeIn = keyframes({
'0%': {
opacity: 0,
position: 'relative',
transform: 'translateY(1rem)'
},
'100%': {
opacity: 1,
position: 'relative',
transform: 'translateY(0)'
}
});
`,
},
],
});

View file

@ -0,0 +1,115 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import customGroupOrderRule from '../rule-definition.js';
run({
name: 'vanilla-extract/custom-order/defaults',
rule: customGroupOrderRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// Test with no options provided - should use defaults
`
import { style } from '@vanilla-extract/css';
const myStyle = style({
alignItems: 'center',
backgroundColor: 'red',
color: 'blue',
display: 'flex',
margin: '10px',
padding: '20px',
zIndex: 1
});
`,
// Test with empty groupOrder array - should use defaults
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
alignItems: 'center',
backgroundColor: 'red',
color: 'blue',
display: 'flex',
margin: '10px',
padding: '20px',
zIndex: 1
});
`,
options: [
{
groupOrder: [],
},
],
},
],
invalid: [
// Test with no options provided - should use alphabetical ordering by default
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
zIndex: 1,
padding: '20px',
margin: '10px',
display: 'flex',
color: 'blue',
backgroundColor: 'red',
alignItems: 'center'
});
`,
errors: [{ messageId: 'alphabeticalOrder' }],
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
alignItems: 'center',
backgroundColor: 'red',
color: 'blue',
display: 'flex',
margin: '10px',
padding: '20px',
zIndex: 1
});
`,
},
// Test with empty groupOrder array - should use alphabetical ordering by default
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
zIndex: 1,
padding: '20px',
margin: '10px',
display: 'flex',
color: 'blue',
backgroundColor: 'red',
alignItems: 'center'
});
`,
options: [
{
groupOrder: [],
},
],
errors: [{ messageId: 'alphabeticalOrder' }],
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
alignItems: 'center',
backgroundColor: 'red',
color: 'blue',
display: 'flex',
margin: '10px',
padding: '20px',
zIndex: 1
});
`,
},
],
});

View file

@ -0,0 +1,127 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import customGroupOrderRule from '../rule-definition.js';
run({
name: 'vanilla-extract/custom-order/font-face',
rule: customGroupOrderRule,
configs: {
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
},
valid: [
// fontFace
{
code: `
import { fontFace } from '@vanilla-extract/css';
const myFont = fontFace({
src: ['url("/fonts/MyFont.woff2") format("woff2")'],
ascentOverride: '90%',
descentOverride: '10%',
fontDisplay: 'swap',
fontFeatureSettings: '"liga" 1',
fontStretch: 'normal',
fontStyle: 'normal',
fontVariant: 'normal',
fontWeight: '400'
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font'],
sortRemainingProperties: 'concentric',
},
],
},
// globalFontFace
{
code: `
import { globalFontFace } from '@vanilla-extract/css';
globalFontFace('GlobalFont', {
src: ['url("/fonts/MyFont.woff2") format("woff2")'],
ascentOverride: '90%',
descentOverride: '10%',
fontDisplay: 'swap',
fontFeatureSettings: '"liga" 1',
fontStretch: 'normal',
fontStyle: 'normal',
fontVariant: 'normal',
fontWeight: '400'
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font'],
sortRemainingProperties: 'concentric',
},
],
},
],
invalid: [
// fontFace with src not first
{
code: `
import { fontFace } from '@vanilla-extract/css';
const myFont = fontFace({
fontWeight: '400',
src: ['url("/fonts/MyFont.woff2") format("woff2")'],
ascentOverride: '90%',
fontStyle: 'normal'
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font'],
sortRemainingProperties: 'concentric',
},
],
errors: [{ messageId: 'fontFaceOrder' }],
output: `
import { fontFace } from '@vanilla-extract/css';
const myFont = fontFace({
src: ['url("/fonts/MyFont.woff2") format("woff2")'],
ascentOverride: '90%',
fontStyle: 'normal',
fontWeight: '400'
});
`,
},
// globalFontFace with src not first
{
code: `
import { globalFontFace } from '@vanilla-extract/css';
globalFontFace('GlobalFont', {
fontWeight: '400',
fontStyle: 'normal',
src: ['url("/fonts/MyFont.woff2") format("woff2")'],
ascentOverride: '90%'
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font'],
sortRemainingProperties: 'concentric',
},
],
errors: [{ messageId: 'fontFaceOrder' }],
output: `
import { globalFontFace } from '@vanilla-extract/css';
globalFontFace('GlobalFont', {
src: ['url("/fonts/MyFont.woff2") format("woff2")'],
ascentOverride: '90%',
fontStyle: 'normal',
fontWeight: '400'
});
`,
},
],
});

View file

@ -0,0 +1,127 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import customGroupOrderRule from '../rule-definition.js';
run({
name: 'vanilla-extract/custom-order/global',
rule: customGroupOrderRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// globalStyle with custom group ordering (concentric for remaining)
{
code: `
import { globalStyle } from '@vanilla-extract/css';
globalStyle('body', {
margin: 0,
position: 'relative',
display: 'block',
backgroundColor: 'white',
padding: 0,
color: 'black'
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'concentric',
},
],
},
// globalStyle with custom group ordering (alphabetical for remaining)
{
code: `
import { globalStyle } from '@vanilla-extract/css';
globalStyle('body', {
margin: 0,
backgroundColor: 'white',
color: 'black',
display: 'block',
padding: 0,
position: 'relative'
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'alphabetical',
},
],
},
],
invalid: [
// globalStyle with incorrect ordering (concentric for remaining)
{
code: `
import { globalStyle } from '@vanilla-extract/css';
globalStyle('body', {
color: 'black',
margin: 0,
backgroundColor: 'white',
padding: 0,
display: 'block',
position: 'relative'
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'concentric',
},
],
errors: [{ messageId: 'incorrectOrder' }],
output: `
import { globalStyle } from '@vanilla-extract/css';
globalStyle('body', {
margin: 0,
position: 'relative',
display: 'block',
backgroundColor: 'white',
padding: 0,
color: 'black'
});
`,
},
// globalStyle with incorrect ordering (alphabetical for remaining)
{
code: `
import { globalStyle } from '@vanilla-extract/css';
globalStyle('body', {
position: 'relative',
display: 'block',
margin: 0,
backgroundColor: 'white',
padding: 0,
color: 'black'
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'alphabetical',
},
],
errors: [{ messageId: 'incorrectOrder' }],
output: `
import { globalStyle } from '@vanilla-extract/css';
globalStyle('body', {
margin: 0,
backgroundColor: 'white',
color: 'black',
display: 'block',
padding: 0,
position: 'relative'
});
`,
},
],
});

View file

@ -0,0 +1,211 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import customGroupOrderRule from '../rule-definition.js';
run({
name: 'vanilla-extract/custom-order/recipe',
rule: customGroupOrderRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// Recipe with custom group ordering (concentric for remaining)
{
code: `
import { recipe } from '@vanilla-extract/recipes';
const myRecipe = recipe({
base: {
width: '100%',
margin: 0,
position: 'relative',
display: 'flex',
alignItems: 'center',
backgroundColor: 'white'
},
variants: {
color: {
blue: {
position: 'relative',
backgroundColor: 'blue',
color: 'white'
},
red: {
position: 'relative',
backgroundColor: 'red',
color: 'black'
}
}
}
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'concentric',
},
],
},
// Recipe with custom group ordering (alphabetical for remaining)
{
code: `
import { recipe } from '@vanilla-extract/recipes';
const myRecipe = recipe({
base: {
width: '100%',
margin: 0,
alignItems: 'center',
backgroundColor: 'white',
display: 'flex',
position: 'relative'
},
variants: {
color: {
blue: {
backgroundColor: 'blue',
color: 'white',
position: 'relative'
},
red: {
backgroundColor: 'red',
color: 'black',
position: 'relative'
}
}
}
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'alphabetical',
},
],
},
],
invalid: [
// Recipe with incorrect ordering (concentric for remaining)
{
code: `
import { recipe } from '@vanilla-extract/recipes';
const myRecipe = recipe({
base: {
backgroundColor: 'white',
width: '100%',
display: 'flex',
alignItems: 'center',
margin: 0
},
variants: {
color: {
blue: {
color: 'white',
backgroundColor: 'blue',
position: 'relative'
},
red: {
color: 'black',
backgroundColor: 'red',
position: 'relative'
}
}
}
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'concentric',
},
],
errors: [{ messageId: 'incorrectOrder' }, { messageId: 'incorrectOrder' }, { messageId: 'incorrectOrder' }],
output: `
import { recipe } from '@vanilla-extract/recipes';
const myRecipe = recipe({
base: {
width: '100%',
margin: 0,
display: 'flex',
alignItems: 'center',
backgroundColor: 'white'
},
variants: {
color: {
blue: {
position: 'relative',
backgroundColor: 'blue',
color: 'white'
},
red: {
position: 'relative',
backgroundColor: 'red',
color: 'black'
}
}
}
});
`,
},
// Recipe with incorrect ordering (alphabetical for remaining)
{
code: `
import { recipe } from '@vanilla-extract/recipes';
const myRecipe = recipe({
base: {
position: 'relative',
display: 'flex',
alignItems: 'center',
backgroundColor: 'white',
width: '100%',
margin: 0
},
variants: {
color: {
blue: {
position: 'relative',
backgroundColor: 'blue',
color: 'white'
}
}
}
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'alphabetical',
},
],
errors: [{ messageId: 'incorrectOrder' }, { messageId: 'incorrectOrder' }],
output: `
import { recipe } from '@vanilla-extract/recipes';
const myRecipe = recipe({
base: {
width: '100%',
margin: 0,
alignItems: 'center',
backgroundColor: 'white',
display: 'flex',
position: 'relative'
},
variants: {
color: {
blue: {
backgroundColor: 'blue',
color: 'white',
position: 'relative'
}
}
}
});
`,
},
],
});

View file

@ -0,0 +1,379 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import customOrderRule from '../rule-definition.js';
run({
name: 'vanilla-extract/custom-order/style-custom',
rule: customOrderRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// Basic style object with custom group ordering through wrapper function
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
// dimensions group first
width: '10rem',
height: '5rem',
// margin group second
margin: '1rem',
// font group third
fontFamily: 'sans-serif',
// border group fourth
border: '1px solid black',
// boxShadow group fifth
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
// remaining properties in concentric order
position: 'relative',
display: 'flex',
backgroundColor: 'red',
padding: '2rem',
color: 'blue'
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'concentric',
},
],
},
// Style with nested selectors
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
width: '10rem',
margin: '1rem',
fontFamily: 'sans-serif',
border: '1px solid black',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
position: 'relative',
backgroundColor: 'red',
selectors: {
'&:hover': {
width: '12rem',
margin: '2rem',
fontFamily: 'serif',
border: '2px solid blue',
boxShadow: '0 4px 8px rgba(0,0,0,0.2)',
position: 'absolute',
backgroundColor: 'blue',
color: 'white'
}
}
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'concentric',
},
],
},
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{ '@layer': { [layerMap[layer]]: rule } },
debugId,
);
const myStyle = layerStyle('component', {
// dimensions group first
width: '10rem',
height: '10rem',
// margin group second
margin: '1rem',
// font group third
fontFamily: 'sans-serif',
// border group fourth
border: '1px solid black',
// boxShadow group fifth
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
// remaining properties in alphabetical order
backgroundColor: 'red',
color: 'blue',
display: 'flex',
padding: '2rem',
position: 'relative'
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'alphabetical',
},
],
},
],
invalid: [
// Basic style object with incorrect custom group ordering
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
position: 'relative',
border: '1px solid black',
width: '10rem',
color: 'blue',
margin: '1rem',
fontFamily: 'sans-serif',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
backgroundColor: 'red'
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'concentric',
},
],
errors: [{ messageId: 'incorrectOrder' }],
output: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
width: '10rem',
margin: '1rem',
fontFamily: 'sans-serif',
border: '1px solid black',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
position: 'relative',
backgroundColor: 'red',
color: 'blue'
});
`,
},
// Style with nested selectors having incorrect ordering
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
border: '1px solid black',
width: '10rem',
position: 'relative',
margin: '1rem',
selectors: {
'&:hover': {
color: 'white',
width: '12rem',
border: '2px solid blue',
margin: '1.2rem',
backgroundColor: 'blue'
}
}
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'concentric',
},
],
errors: [{ messageId: 'incorrectOrder' }, { messageId: 'incorrectOrder' }],
output: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
width: '10rem',
margin: '1rem',
border: '1px solid black',
position: 'relative',
selectors: {
'&:hover': {
width: '12rem',
margin: '1.2rem',
border: '2px solid blue',
backgroundColor: 'blue',
color: 'white'
}
}
});
`,
},
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
position: 'relative',
border: '1px solid black',
width: '10rem',
padding: '2rem',
color: 'blue',
margin: '1rem',
display: 'flex',
fontFamily: 'sans-serif',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
backgroundColor: 'red'
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'alphabetical',
},
],
errors: [{ messageId: 'incorrectOrder' }],
output: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
width: '10rem',
margin: '1rem',
fontFamily: 'sans-serif',
border: '1px solid black',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
backgroundColor: 'red',
color: 'blue',
display: 'flex',
padding: '2rem',
position: 'relative'
});
`,
},
],
});

View file

@ -0,0 +1,252 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import customGroupOrderRule from '../rule-definition.js';
run({
name: 'vanilla-extract/custom-order/style',
rule: customGroupOrderRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// Style with custom group ordering (dimensions, margin, font, border, boxShadow)
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
// dimensions group first
width: '10rem',
minWidth: '5rem',
height: '10rem',
maxHeight: '20rem',
// margin group second
margin: '1rem',
marginTop: '0.5rem',
// font group third
fontFamily: 'sans-serif',
fontSize: '1rem',
fontWeight: 'bold',
// border group fourth
border: '1px solid black',
borderRadius: '4px',
// boxShadow group fifth
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
// remaining properties in concentric order
position: 'relative',
display: 'flex',
backgroundColor: 'red',
padding: '2rem',
color: 'blue'
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'concentric',
},
],
},
// Style with nested selectors following custom group ordering
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
width: '10rem',
margin: '1rem',
fontFamily: 'sans-serif',
border: '1px solid black',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
position: 'relative',
backgroundColor: 'red',
selectors: {
'&:hover': {
width: '12rem',
margin: '12px',
fontSize: '1rem',
borderColor: 'blue',
boxShadow: '0 4px 8px rgba(0,0,0,0.2)',
position: 'relative',
backgroundColor: 'blue',
color: 'white'
}
}
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'concentric',
},
],
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
// dimensions group first
width: '10rem',
height: '10rem',
// margin group second
margin: '1rem',
// font group third
fontFamily: 'sans-serif',
// border group fourth
border: '1px solid black',
// boxShadow group fifth
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
// remaining properties in alphabetical order
backgroundColor: 'red',
color: 'blue',
display: 'flex',
padding: '2rem',
position: 'relative'
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'alphabetical',
},
],
},
],
invalid: [
// Style with incorrect custom group ordering
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
position: 'relative',
border: '1px solid black',
width: '10rem',
color: 'blue',
margin: '1rem',
fontFamily: 'sans-serif',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
backgroundColor: 'red'
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'concentric',
},
],
errors: [{ messageId: 'incorrectOrder' }],
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
width: '10rem',
margin: '1rem',
fontFamily: 'sans-serif',
border: '1px solid black',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
position: 'relative',
backgroundColor: 'red',
color: 'blue'
});
`,
},
// Style with nested selectors having incorrect ordering
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
border: '1px solid black',
width: '10rem',
position: 'relative',
margin: '1rem',
selectors: {
'&:hover': {
color: 'white',
width: '12rem',
border: '2px solid blue',
margin: '1.2rem',
backgroundColor: 'blue'
}
}
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'concentric',
},
],
errors: [{ messageId: 'incorrectOrder' }, { messageId: 'incorrectOrder' }],
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
width: '10rem',
margin: '1rem',
border: '1px solid black',
position: 'relative',
selectors: {
'&:hover': {
width: '12rem',
margin: '1.2rem',
border: '2px solid blue',
backgroundColor: 'blue',
color: 'white'
}
}
});
`,
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
position: 'relative',
border: '1px solid black',
width: '10rem',
padding: '2rem',
color: 'blue',
margin: '1rem',
display: 'flex',
fontFamily: 'sans-serif',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
backgroundColor: 'red'
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'alphabetical',
},
],
errors: [{ messageId: 'incorrectOrder' }],
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
width: '10rem',
margin: '1rem',
fontFamily: 'sans-serif',
border: '1px solid black',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
backgroundColor: 'red',
color: 'blue',
display: 'flex',
padding: '2rem',
position: 'relative'
});
`,
},
],
});

View file

@ -0,0 +1,187 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import customGroupOrderRule from '../rule-definition.js';
run({
name: 'vanilla-extract/custom-order/variants',
rule: customGroupOrderRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// styleVariants with custom group ordering (concentric for remaining)
{
code: `
import { styleVariants } from '@vanilla-extract/css';
const variants = styleVariants({
primary: {
margin: '1rem',
padding: '0.5rem',
position: 'relative',
display: 'flex',
backgroundColor: 'blue',
color: 'white'
},
secondary: {
margin: '0.8rem',
padding: '0.4rem',
position: 'relative',
display: 'flex',
backgroundColor: 'gray',
color: 'black'
}
});
`,
options: [
{
groupOrder: ['margin', 'padding', 'dimensions', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'concentric',
},
],
},
// styleVariants with custom group ordering (alphabetical for remaining)
{
code: `
import { styleVariants } from '@vanilla-extract/css';
const variants = styleVariants({
primary: {
margin: '1rem',
padding: '0.5rem',
backgroundColor: 'blue',
color: 'white',
display: 'flex',
position: 'relative'
},
secondary: {
margin: '0.8rem',
padding: '0.4rem',
backgroundColor: 'gray',
color: 'black',
display: 'flex',
position: 'relative'
}
});
`,
options: [
{
groupOrder: ['margin', 'padding', 'dimensions', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'alphabetical',
},
],
},
],
invalid: [
// styleVariants with incorrect ordering (concentric for remaining)
{
code: `
import { styleVariants } from '@vanilla-extract/css';
const variants = styleVariants({
primary: {
color: 'white',
backgroundColor: 'blue',
padding: '0.5rem',
margin: '1rem',
display: 'flex',
position: 'relative'
},
secondary: {
color: 'black',
backgroundColor: 'gray',
padding: '0.4rem',
margin: '0.8rem',
display: 'flex',
position: 'relative'
}
});
`,
options: [
{
groupOrder: ['margin', 'padding', 'dimensions', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'concentric',
},
],
errors: [{ messageId: 'incorrectOrder' }, { messageId: 'incorrectOrder' }],
output: `
import { styleVariants } from '@vanilla-extract/css';
const variants = styleVariants({
primary: {
margin: '1rem',
padding: '0.5rem',
position: 'relative',
display: 'flex',
backgroundColor: 'blue',
color: 'white'
},
secondary: {
margin: '0.8rem',
padding: '0.4rem',
position: 'relative',
display: 'flex',
backgroundColor: 'gray',
color: 'black'
}
});
`,
},
// styleVariants with incorrect ordering (alphabetical for remaining)
{
code: `
import { styleVariants } from '@vanilla-extract/css';
const variants = styleVariants({
primary: {
position: 'relative',
display: 'flex',
margin: '1rem',
padding: '0.5rem',
backgroundColor: 'blue',
color: 'white'
},
secondary: {
color: 'black',
backgroundColor: 'gray',
padding: '0.4rem',
margin: '0.8rem',
display: 'flex',
position: 'relative'
}
});
`,
options: [
{
groupOrder: ['margin', 'padding', 'dimensions', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'alphabetical',
},
],
errors: [{ messageId: 'incorrectOrder' }, { messageId: 'incorrectOrder' }],
output: `
import { styleVariants } from '@vanilla-extract/css';
const variants = styleVariants({
primary: {
margin: '1rem',
padding: '0.5rem',
backgroundColor: 'blue',
color: 'white',
display: 'flex',
position: 'relative'
},
secondary: {
margin: '0.8rem',
padding: '0.4rem',
backgroundColor: 'gray',
color: 'black',
display: 'flex',
position: 'relative'
}
});
`,
},
],
});

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,71 +21,75 @@ export const enforceCustomGroupOrder = (
ruleContext: Rule.RuleContext,
cssPropertyInfoList: CSSPropertyInfo[],
userDefinedGroups: string[] = [],
sortRemainingProperties?: 'alphabetical' | 'concentric',
sortRemainingProperties?: SortRemainingProperties,
): void => {
if (cssPropertyInfoList.length <= 1) {
return;
}
if (cssPropertyInfoList.length > 1) {
const cssPropertyPriorityMap = createCSSPropertyPriorityMap(userDefinedGroups);
const cssPropertyPriorityMap = createCSSPropertyPriorityMap(userDefinedGroups);
const compareProperties = (firstProperty: CSSPropertyInfo, secondProperty: CSSPropertyInfo) => {
const firstPropertyInfo = cssPropertyPriorityMap.get(firstProperty.name) || {
groupIndex: Infinity,
positionInGroup: Infinity,
inUserGroup: false,
};
const secondPropertyInfo = cssPropertyPriorityMap.get(secondProperty.name) || {
groupIndex: Infinity,
positionInGroup: Infinity,
inUserGroup: false,
};
const compareProperties = (firstProperty: CSSPropertyInfo, secondProperty: CSSPropertyInfo) => {
const firstPropertyInfo = cssPropertyPriorityMap.get(firstProperty.name) || {
groupIndex: Infinity,
positionInGroup: Infinity,
inUserGroup: false,
};
const secondPropertyInfo = cssPropertyPriorityMap.get(secondProperty.name) || {
groupIndex: Infinity,
positionInGroup: Infinity,
inUserGroup: false,
};
if (firstPropertyInfo.inUserGroup !== secondPropertyInfo.inUserGroup) {
return firstPropertyInfo.inUserGroup ? -1 : 1;
}
if (firstPropertyInfo.inUserGroup) {
if (firstPropertyInfo.groupIndex !== secondPropertyInfo.groupIndex) {
return firstPropertyInfo.groupIndex - secondPropertyInfo.groupIndex;
if (firstPropertyInfo.inUserGroup !== secondPropertyInfo.inUserGroup) {
return firstPropertyInfo.inUserGroup ? -1 : 1;
}
return firstPropertyInfo.positionInGroup - secondPropertyInfo.positionInGroup;
if (firstPropertyInfo.inUserGroup) {
if (firstPropertyInfo.groupIndex !== secondPropertyInfo.groupIndex) {
return firstPropertyInfo.groupIndex - secondPropertyInfo.groupIndex;
}
return firstPropertyInfo.positionInGroup - secondPropertyInfo.positionInGroup;
}
// For properties not in user-defined groups
if (sortRemainingProperties === ('alphabetical' as SortRemainingProperties)) {
return firstProperty.name.localeCompare(secondProperty.name);
} else {
return (
firstPropertyInfo.groupIndex - secondPropertyInfo.groupIndex ||
firstPropertyInfo.positionInGroup - secondPropertyInfo.positionInGroup
);
}
};
const sortedPropertyList = [...cssPropertyInfoList].sort(compareProperties);
// Find the first pair that violates the new ordering
const violatingProperty = cssPropertyInfoList
.slice(0, -1)
.find((currentProperty, index) => currentProperty.name !== sortedPropertyList[index]?.name);
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.
// This fallback is theoretically unreachable in practice but included for type safety.
const nextPropertyName = sortedProperty?.name ?? '';
ruleContext.report({
node: violatingProperty.node as Rule.Node,
messageId: 'incorrectOrder',
data: {
currentProperty: violatingProperty.name,
nextProperty: nextPropertyName,
},
fix: (fixer) =>
generateFixesForCSSOrder(
fixer,
ruleContext,
cssPropertyInfoList,
compareProperties,
(propertyInfo) => propertyInfo.node as Rule.Node,
),
});
}
// For properties not in user-defined groups
if (sortRemainingProperties === 'alphabetical') {
return firstProperty.name.localeCompare(secondProperty.name);
} else {
return (
firstPropertyInfo.groupIndex - secondPropertyInfo.groupIndex ||
firstPropertyInfo.positionInGroup - secondPropertyInfo.positionInGroup
);
}
};
const sortedPropertyList = [...cssPropertyInfoList].sort(compareProperties);
// Find the first pair that violates the new ordering
const violatingProperty = cssPropertyInfoList
.slice(0, -1)
.find((currentProperty, index) => currentProperty.name !== sortedPropertyList[index]?.name);
if (violatingProperty) {
ruleContext.report({
node: violatingProperty.node as Rule.Node,
messageId: 'incorrectOrder',
data: {
currentProperty: violatingProperty.name,
nextProperty: sortedPropertyList[cssPropertyInfoList.indexOf(violatingProperty)]?.name || '',
},
fix: (fixer) =>
generateFixesForCSSOrder(
fixer,
ruleContext,
cssPropertyInfoList,
compareProperties,
(propertyInfo) => propertyInfo.node as Rule.Node,
),
});
}
};

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,22 +22,20 @@ export const enforceUserDefinedGroupOrderInRecipe = (
ruleContext: Rule.RuleContext,
callExpression: TSESTree.CallExpression,
userDefinedGroups: string[],
sortRemainingPropertiesMethod?: 'alphabetical' | 'concentric',
sortRemainingProperties?: SortRemainingProperties,
): void => {
if (!callExpression.arguments[0] || callExpression.arguments[0].type !== 'ObjectExpression') {
return;
}
if (callExpression.arguments[0]?.type === 'ObjectExpression') {
const recipeObjectExpression = callExpression.arguments[0];
const recipeObjectExpression = callExpression.arguments[0];
processRecipeProperties(ruleContext, recipeObjectExpression, (currentContext, styleObject) =>
processStyleNode(currentContext, styleObject, (styleContext, styleObjectNode) =>
enforceUserDefinedGroupOrderInStyleObject(
styleContext,
styleObjectNode,
userDefinedGroups,
sortRemainingPropertiesMethod,
processRecipeProperties(ruleContext, recipeObjectExpression, (currentContext, styleObject) =>
processStyleNode(currentContext, styleObject, (styleContext, styleObjectNode) =>
enforceUserDefinedGroupOrderInStyleObject(
styleContext,
styleObjectNode,
userDefinedGroups,
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 = {
@ -34,16 +35,20 @@ const customGroupOrderRule: Rule.RuleModule = {
},
],
messages: {
incorrectOrder:
"Property '{{nextProperty}}' should come before '{{currentProperty}}' according to custom CSS group ordering.",
// default message for ordering in case no groupOrder is provided
alphabeticalOrder:
"Property '{{nextProperty}}' should come before '{{currentProperty}}' according to alphabetical ordering.",
fontFaceOrder:
"Properties in fontFace should be ordered with 'src' first, followed by other properties in alphabetical order. Property '{{nextProperty}}' should come before '{{currentProperty}}'.",
incorrectOrder:
"Property '{{nextProperty}}' should come before '{{currentProperty}}' according to custom CSS group ordering.",
},
},
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,53 +27,46 @@ export const enforceUserDefinedGroupOrderInStyleObject = (
ruleContext: Rule.RuleContext,
styleObject: TSESTree.ObjectExpression,
userDefinedGroups: string[],
sortRemainingPropertiesMethod: 'alphabetical' | 'concentric' = 'concentric',
sortRemainingProperties: SortRemainingProperties = 'concentric',
): void => {
if (!styleObject || styleObject.type !== AST_NODE_TYPES.ObjectExpression) {
return;
}
if (styleObject?.type === AST_NODE_TYPES.ObjectExpression) {
if (isSelectorsObject(styleObject)) {
styleObject.properties.forEach((property) => {
if (property.type === AST_NODE_TYPES.Property && property.value.type === AST_NODE_TYPES.ObjectExpression) {
enforceUserDefinedGroupOrderInStyleObject(
ruleContext,
property.value,
userDefinedGroups,
sortRemainingProperties,
);
}
});
return;
}
if (isSelectorsObject(styleObject)) {
styleObject.properties.forEach((property) => {
if (property.type === AST_NODE_TYPES.Property && property.value.type === AST_NODE_TYPES.ObjectExpression) {
enforceUserDefinedGroupOrderInStyleObject(
ruleContext,
property.value,
userDefinedGroups,
sortRemainingPropertiesMethod,
);
}
const cssPropertyPriorityMap = createCSSPropertyPriorityMap(userDefinedGroups);
const { regularProperties } = separateProperties(styleObject.properties);
const cssPropertyInfoList: CSSPropertyInfo[] = regularProperties.map((property) => {
const propertyName = getPropertyNameForSorting(property);
const propertyInfo = cssPropertyPriorityMap.get(propertyName);
const group =
userDefinedGroups.find((groupName) => concentricGroups[groupName]?.includes(propertyName)) || 'remaining';
return {
name: propertyName,
node: property,
priority: propertyInfo?.groupIndex ?? Number.MAX_SAFE_INTEGER,
positionInGroup: propertyInfo?.positionInGroup ?? Number.MAX_SAFE_INTEGER,
group,
inUserGroup: propertyInfo?.inUserGroup ?? false,
};
});
return;
enforceCustomGroupOrder(ruleContext, cssPropertyInfoList, userDefinedGroups, sortRemainingProperties);
processNestedSelectors(ruleContext, styleObject, (nestedContext, nestedNode) =>
enforceUserDefinedGroupOrderInStyleObject(nestedContext, nestedNode, userDefinedGroups, sortRemainingProperties),
);
}
const cssPropertyPriorityMap = createCSSPropertyPriorityMap(userDefinedGroups);
const { regularProperties } = separateProperties(styleObject.properties);
const cssPropertyInfoList: CSSPropertyInfo[] = regularProperties.map((property) => {
const propertyName = getPropertyName(property);
const propertyInfo = cssPropertyPriorityMap.get(propertyName);
const group =
userDefinedGroups.find((groupName) => concentricGroups[groupName]?.includes(propertyName)) || 'remaining';
return {
name: propertyName,
node: property,
priority: propertyInfo?.groupIndex ?? Number.MAX_SAFE_INTEGER,
positionInGroup: propertyInfo?.positionInGroup ?? Number.MAX_SAFE_INTEGER,
group,
inUserGroup: propertyInfo?.inUserGroup ?? false,
};
});
enforceCustomGroupOrder(ruleContext, cssPropertyInfoList, userDefinedGroups, sortRemainingPropertiesMethod);
processNestedSelectors(ruleContext, styleObject, (nestedContext, nestedNode) =>
enforceUserDefinedGroupOrderInStyleObject(
nestedContext,
nestedNode,
userDefinedGroups,
sortRemainingPropertiesMethod,
),
);
};

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,131 @@
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 }
});
`,
// 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,112 @@
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 false for an object with real CSS properties and empty nested objects', () => {
const object = createObjectExpression([
createProperty('color', createLiteral('blue')),
createProperty('selectors', createObjectExpression([])),
createProperty('@media', createObjectExpression([])),
createProperty('@supports', createObjectExpression([])),
]);
expect(isEffectivelyEmptyStylesObject(object)).toBe(false);
});
it('should return true for an object with only empty nested objects', () => {
const object = createObjectExpression([
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,582 @@
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' }
}
}
});
`,
// Recipe with sprinkles() in base - should be valid
`
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { display: ['flex'], flexDirection: ['row'], flexWrap: ['wrap-reverse'] }
}));
export const columnsStyle = recipe({
base: sprinkles({ display: 'flex', flexDirection: 'row' }),
variants: {
wrappingDirection: {
reverse: sprinkles({ flexWrap: 'wrap-reverse' }),
},
},
});
`,
// Recipe with sprinkles() in variant values - should be valid
`
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { padding: ['8px', '16px'] }
}));
const myRecipe = recipe({
base: { color: 'black' },
variants: {
spacing: {
small: sprinkles({ padding: '8px' }),
large: sprinkles({ padding: '16px' }),
},
},
});
`,
// Recipe with style() calls in variant values - should be valid
`
import { recipe } from '@vanilla-extract/recipes';
import { style } from '@vanilla-extract/css';
const myRecipe = recipe({
base: { color: 'black' },
variants: {
size: {
small: style({ fontSize: '12px' }),
large: style({ fontSize: '16px' }),
},
},
});
`,
// Recipe with mixed CallExpression and ObjectExpression in variants - should be valid
`
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { padding: ['8px'] }
}));
const myRecipe = recipe({
base: { color: 'black' },
variants: {
variant: {
sprinkled: sprinkles({ padding: '8px' }),
regular: { padding: '8px' },
},
},
});
`,
// Recipe with only CallExpression variants (no default) - should be valid
`
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { flexWrap: ['wrap-reverse'] }
}));
const myRecipe = recipe({
base: { color: 'black' },
variants: {
wrappingDirection: {
reverse: sprinkles({ flexWrap: 'wrap-reverse' }),
},
},
});
`,
// Recipe with nested CallExpression in multiple variant categories - should be valid
`
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { display: ['flex'], padding: ['8px', '16px'], color: ['blue', 'gray'] }
}));
const myRecipe = recipe({
base: sprinkles({ display: 'flex' }),
variants: {
spacing: {
small: sprinkles({ padding: '8px' }),
large: sprinkles({ padding: '16px' }),
},
color: {
primary: sprinkles({ color: 'blue' }),
secondary: sprinkles({ color: 'gray' }),
},
},
});
`,
],
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' } }],
},
// Recipe with empty variant category alongside CallExpression variants
// Should only report the empty category, not the CallExpression
{
code: `
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { padding: ['8px', '16px'] }
}));
const myRecipe = recipe({
base: { color: 'black' },
variants: {
spacing: {
small: sprinkles({ padding: '8px' }),
large: sprinkles({ padding: '16px' }),
},
emptyCategory: {},
},
});
`,
errors: [{ messageId: 'emptyVariantCategory' }],
output: `
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { padding: ['8px', '16px'] }
}));
const myRecipe = recipe({
base: { color: 'black' },
variants: {
spacing: {
small: sprinkles({ padding: '8px' }),
large: sprinkles({ padding: '16px' }),
},
},
});
`,
},
// Recipe with CallExpression in base and empty variants
{
code: `
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { display: ['flex'] }
}));
const myRecipe = recipe({
base: sprinkles({ display: 'flex' }),
variants: {},
});
`,
errors: [{ messageId: 'emptyRecipeProperty' }],
output: `
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { display: ['flex'] }
}));
const myRecipe = recipe({
base: sprinkles({ display: 'flex' }),
});
`,
},
// Recipe with mixed valid CallExpression and invalid literal in same category
{
code: `
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { padding: ['8px'] }
}));
const myRecipe = recipe({
base: { color: 'black' },
variants: {
spacing: {
small: sprinkles({ padding: '8px' }),
invalid: 'invalid-string',
},
},
});
`,
errors: [{ messageId: 'invalidPropertyType' }],
},
// Recipe with sprinkles({}) in base and empty variants - entire recipe is empty
{
code: `
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { display: ['flex'] }
}));
export const myRecipe = recipe({
base: sprinkles({}),
variants: {},
});
`,
errors: [{ messageId: 'emptyStyleDeclaration' }],
},
// Recipe with sprinkles({}) in variant value - should be flagged as empty
{
code: `
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { padding: ['8px'] }
}));
const myRecipe = recipe({
base: { color: 'black' },
variants: {
spacing: {
small: sprinkles({ padding: '8px' }),
empty: sprinkles({}),
},
},
});
`,
errors: [{ messageId: 'emptyVariantValue' }],
output: `
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { padding: ['8px'] }
}));
const myRecipe = recipe({
base: { color: 'black' },
variants: {
spacing: {
small: sprinkles({ padding: '8px' }),
},
},
});
`,
},
// Recipe with both base and variants using empty CallExpressions - entire recipe becomes empty
{
code: `
import { recipe } from '@vanilla-extract/recipes';
import { style } from '@vanilla-extract/css';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { display: ['flex'] }
}));
export const myRecipe = recipe({
base: style({}),
variants: {
layout: {
flex: sprinkles({}),
},
},
});
`,
errors: [{ messageId: 'emptyStyleDeclaration' }, { messageId: 'emptyVariantValue' }],
},
],
});

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,305 @@
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-blocks/style-custom',
rule: noEmptyStyleBlocksRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// Basic non-empty style through wrapper function
`
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
color: 'blue',
margin: '10px'
});
`,
// Style with comments (not empty)
`
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myStyle = layerStyle('component', {
/* This is a comment */
color: 'blue'
});
`,
],
invalid: [
// Empty style object through wrapper function
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const emptyStyle = layerStyle('component', {});
`,
errors: [{ messageId: 'emptyStyleDeclaration' }],
output: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
`,
},
// Empty exported style object
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
export const emptyStyle = layerStyle('component', {});
`,
errors: [{ messageId: 'emptyStyleDeclaration' }],
output: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
`,
},
// Style with empty nested selectors
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const styleWithComments = layerStyle('component', {
/* This is an empty style */
});
`,
errors: [{ messageId: 'emptyStyleDeclaration' }],
output: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
`,
},
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
export const emptyStyle1 = layerStyle('component', {});
export const emptyStyle2 = layerStyle('component', {});
`,
errors: [{ messageId: 'emptyStyleDeclaration' }, { messageId: 'emptyStyleDeclaration' }],
output: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
`,
},
// Export of variable with empty style
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
const myEmptyStyle = layerStyle('component', {});
export { myEmptyStyle };
`,
errors: [{ messageId: 'emptyStyleDeclaration' }],
output: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
export { myEmptyStyle };
`,
},
// Style in a callback or nested function
{
code: `
import { style } from '@vanilla-extract/css';
export const layerStyle = (
layer: 'reset' | 'theme' | 'component' | 'utilities',
rule: StyleRule,
debugId?: string,
) =>
style(
{
'@layer': {
[layerMap[layer]]: rule,
},
},
debugId,
);
[1, 2, 3].forEach(() => {
layerStyle('component', {});
});
`,
errors: [{ messageId: 'emptyStyleDeclaration' }],
},
],
});

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

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

@ -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 const 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,164 @@
import type { Rule } from 'eslint';
import { TSESTree } from '@typescript-eslint/utils';
import { isCallExpressionWithEmptyObject, 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 const 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;
}
const valueType = variantValueProperty.value.type;
// Allow CallExpression (e.g., sprinkles(), style()) as valid variant values unless it has an empty object argument, e.g. sprinkles({})
if (valueType === 'CallExpression') {
const callExpression = variantValueProperty.value as TSESTree.CallExpression;
if (isCallExpressionWithEmptyObject(callExpression)) {
// Treat sprinkles({}) or style({}) as empty
if (!reportedNodes.has(variantValueProperty)) {
reportedNodes.add(variantValueProperty);
ruleContext.report({
node: variantValueProperty as Rule.Node,
messageId: 'emptyVariantValue',
fix(fixer) {
return removeNodeWithComma(ruleContext, variantValueProperty, fixer);
},
});
}
}
// Valid CallExpressions with arguments are fine
return;
}
// Check for non-object variant values (excluding CallExpression)
if (valueType !== '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 const 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

@ -0,0 +1,166 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import noPxUnitRule from '../rule-definition.js';
run({
name: 'vanilla-extract/no-px-unit',
rule: noPxUnitRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '1rem',
padding: 8,
width: '100%',
color: vars.color.primary,
});
`,
name: 'allows rem, numbers, and token references',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
borderWidth: '1px',
outlineOffset: '2px',
});
`,
options: [{ allow: ['border-width', 'outline-offset', 'borderWidth', 'outlineOffset'] }],
name: 'respects allowlist for kebab and camelCase',
},
{
code: `
import { recipe } from '@vanilla-extract/css';
const myRecipe = recipe({
base: { margin: '1rem' },
variants: {
size: {
sm: { padding: '0.5rem' },
},
},
});
`,
name: 'passes when no px in recipe base/variants',
},
{
code: `
import { style } from '@vanilla-extract/css';
const s = style({
border: '1px solid',
borderWidth: '2px'
});
`,
options: [{ allow: ['borderWidth', 'border'] }],
name: 'does not report whitelisted properties',
},
],
invalid: [
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '10px',
padding: '2px',
});
`,
errors: [{ messageId: 'noPxUnit' }, { messageId: 'noPxUnit' }],
},
{
code: `
import { style } from '@vanilla-extract/css';
const s = style({
'@media': {
'(min-width: 768px)': {
lineHeight: '12px',
}
},
selectors: {
'&:hover': { gap: '4px' }
}
});
`,
errors: [{ messageId: 'noPxUnit' }, { messageId: 'noPxUnit' }],
name: 'reports within nested @media and selectors',
},
{
code: `
import { recipe } from '@vanilla-extract/css';
const r = recipe({
base: { marginTop: '3px' },
variants: { size: { md: { paddingBottom: '6px' } } }
});
`,
errors: [{ messageId: 'noPxUnit' }, { messageId: 'noPxUnit' }],
name: 'reports within recipe base and variants',
},
{
code: `
import { fontFace } from '@vanilla-extract/css';
fontFace({ sizeAdjust: '10px' });
`,
errors: [{ messageId: 'noPxUnit' }],
name: 'reports in fontFace() first-arg object (covers fontFace branch)',
},
{
code: `
import { globalFontFace } from '@vanilla-extract/css';
globalFontFace('MyFont', { lineGapOverride: '12px' });
`,
errors: [{ messageId: 'noPxUnit' }],
name: 'reports in globalFontFace() second-arg object (covers globalFontFace branch)',
},
{
code: `
import { globalStyle } from '@vanilla-extract/css';
globalStyle('html', { boxShadow: '0 1px 2px black' });
`,
errors: [{ messageId: 'noPxUnit' }],
name: 'reports in globalStyle() second-arg object (covers globalStyle branch)',
},
{
code: `
import { globalKeyframes } from '@vanilla-extract/css';
globalKeyframes('fade', {
from: { margin: '5px' },
to: { padding: '3px' }
});
`,
errors: [{ messageId: 'noPxUnit' }, { messageId: 'noPxUnit' }],
name: 'reports in globalKeyframes() frames (covers globalKeyframes branch)',
},
{
code:
`
import { style } from '@vanilla-extract/css';
const s = style({
margin: ` +
'`10px`' +
`,
});
`,
errors: [{ messageId: 'noPxUnit' }],
name: 'reports for simple template literal with px',
},
{
code:
`
import { style } from '@vanilla-extract/css';
const s = style({
margin: ` +
'`${token}px`' +
`,
});
`,
errors: [{ messageId: 'noPxUnit' }],
name: 'reports for complex template literals with expressions containing px',
},
],
});

View file

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

View file

@ -0,0 +1,116 @@
import type { Rule } from 'eslint';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
const containsPx = (text: string): boolean => /(^|\W)-?\d*\.?\d*px(?![a-zA-Z])/i.test(text);
const replacePxWith = (text: string, replacement: 'rem' | ''): string => text.replace(/px(?![a-zA-Z])/g, replacement);
const toKebab = (name: string): string => name.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
const getValueText = (node: TSESTree.Node): string | null => {
if (node.type === AST_NODE_TYPES.Literal && typeof node.value === 'string') {
return node.value;
}
if (node.type === AST_NODE_TYPES.TemplateLiteral) {
// Join all quasis (ignore expressions content)
const raw = node.quasis.map((quasi) => quasi.value.raw ?? '').join('');
return raw;
}
return null;
};
const canSuggestFix = (node: TSESTree.Node): 'literal' | 'simple-template' | null => {
if (node.type === AST_NODE_TYPES.Literal && typeof node.value === 'string') return 'literal';
if (node.type === AST_NODE_TYPES.TemplateLiteral && node.expressions.length === 0) return 'simple-template';
return null;
};
/**
* Recursively processes a vanilla-extract style object and reports occurrences of 'px' units.
*
* - Skips properties present in the allow list (supports camelCase and kebab-case).
* - Traverses nested object values and delegates deeper traversal to callers for arrays/at-rules/selectors.
* - Provides fix suggestions for string literals and simple template literals (no expressions).
*
* @param context ESLint rule context used to report diagnostics and apply suggestions.
* @param node The ObjectExpression node representing the style object to inspect.
* @param allowSet Set of property names (camelCase or kebab-case) that are allowed to contain 'px'.
*/
export const processNoPxUnitInStyleObject = (
context: Rule.RuleContext,
node: TSESTree.ObjectExpression,
allowSet: Set<string>,
): void => {
for (const property of node.properties) {
if (property.type !== AST_NODE_TYPES.Property) continue;
// Determine property name when possible
let propertyName: string | null = null;
if (property.key.type === AST_NODE_TYPES.Identifier) {
propertyName = property.key.name;
} else if (property.key.type === AST_NODE_TYPES.Literal && typeof property.key.value === 'string') {
propertyName = property.key.value;
}
// Recurse into known nested containers
if (propertyName === '@media' || propertyName === 'selectors') {
if (property.value.type === AST_NODE_TYPES.ObjectExpression) {
for (const nested of property.value.properties) {
if (nested.type === AST_NODE_TYPES.Property && nested.value.type === AST_NODE_TYPES.ObjectExpression) {
processNoPxUnitInStyleObject(context, nested.value, allowSet);
}
}
}
continue;
}
// Traverse any nested object
if (property.value.type === AST_NODE_TYPES.ObjectExpression) {
processNoPxUnitInStyleObject(context, property.value, allowSet);
continue;
}
// Skip if property is whitelisted (supports both camelCase and kebab-case)
if (propertyName) {
const kebab = toKebab(propertyName);
if (allowSet.has(propertyName) || allowSet.has(kebab)) {
continue;
}
}
// Check string or template literal values
const text = getValueText(property.value);
if (text && containsPx(text)) {
const fixability = canSuggestFix(property.value);
context.report({
node: property.value as unknown as Rule.Node,
messageId: 'noPxUnit',
suggest: fixability
? [
{
messageId: 'removePx',
fix: (fixer) => {
const newText = replacePxWith(text, '');
if (fixability === 'literal') {
return fixer.replaceText(property.value, `'${newText}'`);
}
// simple template with no expressions
return fixer.replaceText(property.value, `\`${newText}\``);
},
},
{
messageId: 'replaceWithRem',
fix: (fixer) => {
const newText = replacePxWith(text, 'rem');
if (fixability === 'literal') {
return fixer.replaceText(property.value, `'${newText}'`);
}
return fixer.replaceText(property.value, `\`${newText}\``);
},
},
]
: undefined,
});
}
}
};

View file

@ -0,0 +1,77 @@
import type { Rule } from 'eslint';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
import { processRecipeProperties } from '../shared-utils/recipe-property-processor.js';
import { ReferenceTracker, createReferenceTrackingVisitor } from '../shared-utils/reference-tracker.js';
import { processStyleNode } from '../shared-utils/style-node-processor.js';
import { processNoPxUnitInStyleObject } from './px-unit-processor.js';
/**
* Creates ESLint rule visitors for detecting and reporting 'px' units in vanilla-extract style objects.
* - Tracks calls to vanilla-extract APIs (style, recipe, keyframes, globalStyle, fontFace, etc.).
* This visitor only orchestrates traversal; the actual reporting and suggestion logic lives in the processor.
* - Respects the `allow` option (camelCase or kebab-case) by passing it as a Set to the processor.
*
* @param context ESLint rule context used to read options and report diagnostics.
* @returns Rule listener that inspects vanilla-extract call expressions and processes style objects.
*/
export const createNoPxUnitVisitors = (context: Rule.RuleContext): Rule.RuleListener => {
const tracker = new ReferenceTracker();
const trackingVisitor = createReferenceTrackingVisitor(tracker);
const options = (context.options?.[0] as { allow?: string[] } | undefined) || {};
const allowSet = new Set((options.allow ?? []).map((string) => string));
const process = (context: Rule.RuleContext, object: TSESTree.ObjectExpression) =>
processNoPxUnitInStyleObject(context, object, allowSet);
return {
...trackingVisitor,
CallExpression(node) {
if (node.callee.type !== AST_NODE_TYPES.Identifier) return;
const functionName = node.callee.name;
if (!tracker.isTrackedFunction(functionName)) return;
const originalName = tracker.getOriginalName(functionName);
if (!originalName) return;
switch (originalName) {
case 'fontFace':
if (node.arguments.length > 0 && node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression) {
process(context, node.arguments[0] as TSESTree.ObjectExpression);
}
break;
case 'globalFontFace':
if (node.arguments.length > 1 && node.arguments[1]?.type === AST_NODE_TYPES.ObjectExpression) {
process(context, node.arguments[1] as TSESTree.ObjectExpression);
}
break;
case 'style':
case 'styleVariants':
case 'keyframes':
if (node.arguments.length > 0) {
processStyleNode(context, node.arguments[0] as TSESTree.Node, (context, object) =>
process(context, object),
);
}
break;
case 'globalStyle':
case 'globalKeyframes':
if (node.arguments.length >= 2) {
processStyleNode(context, node.arguments[1] as TSESTree.Node, (context, object) =>
process(context, object),
);
}
break;
case 'recipe':
if (node.arguments.length > 0 && node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression) {
processRecipeProperties(context, node.arguments[0] as TSESTree.ObjectExpression, (context, object) =>
process(context, object),
);
}
break;
}
},
};
};

View file

@ -0,0 +1,40 @@
import type { Rule } from 'eslint';
import { createNoPxUnitVisitors } from './px-unit-visitor-creator.js';
const noPxUnitRule: Rule.RuleModule = {
meta: {
type: 'suggestion',
docs: {
description: "disallow 'px' units in vanilla-extract style objects, with allowlist option",
category: 'Best Practices',
recommended: false,
},
// Suggestions are reported from helper modules, so static analysis in this file cant detect them; disable the false positive.
// eslint-disable-next-line eslint-plugin/require-meta-has-suggestions
hasSuggestions: true,
schema: [
{
type: 'object',
properties: {
allow: {
type: 'array',
items: { type: 'string' },
uniqueItems: true,
default: [],
},
},
additionalProperties: false,
},
],
messages: {
noPxUnit: "Avoid using 'px' units. Use rem, em, or theme tokens instead.",
replaceWithRem: "Replace 'px' with 'rem'.",
removePx: "Remove 'px' unit.",
},
},
create(context) {
return createNoPxUnitVisitors(context);
},
};
export default noPxUnitRule;

View file

@ -0,0 +1,538 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import noTrailingZeroRule from '../rule-definition.js';
run({
name: 'vanilla-extract/no-trailing-zero',
rule: noTrailingZeroRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '0',
padding: '1px',
width: '1.5rem',
height: '0.5em',
fontSize: '2rem',
});
`,
name: 'should allow values without trailing zeros',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: 0,
padding: 1,
opacity: 0.5,
lineHeight: 1.5,
});
`,
name: 'should allow numeric literals without trailing zeros',
},
{
code: `
import { recipe } from '@vanilla-extract/css';
const myRecipe = recipe({
base: {
margin: '1px',
padding: '0.5rem',
},
variants: {
size: {
small: {
height: '10px',
width: '0.75em',
},
},
},
});
`,
name: 'should allow recipe values without trailing zeros',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
...spreadProps,
margin: '1px',
'@media': {
'1.0rem': '0.5rem' // Key shouldn't be checked
}
});
`,
name: 'should ignore spread elements and object keys',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: \`1.0\${someUnit}\`, // Template literal
padding: someVariable,
width: calculateWidth(),
});
`,
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',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '10px',
padding: '100rem',
width: '1000%',
});
`,
name: 'should allow integers without decimal points',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '11.01rem',
padding: '2.05em',
width: '0.101%',
height: '10.001px',
});
`,
name: 'should not flag zeros in the middle of decimal numbers',
},
],
invalid: [
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '1.0px',
padding: '2.50rem',
});
`,
errors: [{ messageId: 'trailingZero' }, { messageId: 'trailingZero' }],
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '1px',
padding: '2.5rem',
});
`,
name: 'should fix trailing zeros in string values with units',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
opacity: 1.0,
lineHeight: 2.50,
});
`,
errors: [{ messageId: 'trailingZero' }, { messageId: 'trailingZero' }],
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
opacity: 1,
lineHeight: 2.5,
});
`,
name: 'should fix trailing zeros in numeric literals',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '0.0',
padding: '0.00px',
width: '0.0rem',
});
`,
errors: [{ messageId: 'trailingZero' }, { messageId: 'trailingZero' }, { messageId: 'trailingZero' }],
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '0',
padding: '0',
width: '0',
});
`,
name: 'should convert 0.0 to 0',
},
{
code: `
import { recipe } from '@vanilla-extract/css';
const myRecipe = recipe({
base: {
margin: '1.0px',
},
variants: {
size: {
small: {
height: '2.50vh',
},
},
},
});
`,
errors: [{ messageId: 'trailingZero' }, { messageId: 'trailingZero' }],
output: `
import { recipe } from '@vanilla-extract/css';
const myRecipe = recipe({
base: {
margin: '1px',
},
variants: {
size: {
small: {
height: '2.5vh',
},
},
},
});
`,
name: 'should handle recipe trailing zeros',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '1.0px',
'@media': {
'(min-width: 768px)': {
padding: '2.50rem'
}
}
});
`,
errors: 2,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '1px',
'@media': {
'(min-width: 768px)': {
padding: '2.5rem'
}
}
});
`,
name: 'should handle nested media queries',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
'::before': {
content: '""',
margin: '1.0px'
}
});
`,
errors: 1,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
'::before': {
content: '""',
margin: '1px'
}
});
`,
name: 'should handle pseudo-elements',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '1.0px',
nested: {
object: {
padding: '2.50rem',
deeper: {
width: '3.00%'
}
}
}
});
`,
errors: 3,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '1px',
nested: {
object: {
padding: '2.5rem',
deeper: {
width: '3%'
}
}
}
});
`,
name: 'should handle multiple levels of nesting',
},
{
code: `
import { fontFace, globalFontFace } from '@vanilla-extract/css';
fontFace({
src: '...',
lineGap: '1.0rem'
});
globalFontFace('my-font', {
src: '...',
sizeAdjust: '100.0%'
});
`,
errors: 2,
output: `
import { fontFace, globalFontFace } from '@vanilla-extract/css';
fontFace({
src: '...',
lineGap: '1rem'
});
globalFontFace('my-font', {
src: '...',
sizeAdjust: '100%'
});
`,
name: 'should handle fontFace and globalFontFace arguments',
},
{
code: `
import { globalKeyframes, globalStyle } from '@vanilla-extract/css';
globalKeyframes('spin', {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360.0deg)' }
});
globalStyle('html', {
margin: '1.0px',
padding: '2.50rem'
});
`,
errors: 3,
output: `
import { globalKeyframes, globalStyle } from '@vanilla-extract/css';
globalKeyframes('spin', {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360deg)' }
});
globalStyle('html', {
margin: '1px',
padding: '2.5rem'
});
`,
name: 'should handle globalKeyframes and globalStyle arguments',
},
{
code: `
import { globalStyle } from '@vanilla-extract/css';
globalStyle('html', {
'@media': {
'(min-width: 768px)': {
margin: '1.0px'
}
}
});
`,
errors: 1,
output: `
import { globalStyle } from '@vanilla-extract/css';
globalStyle('html', {
'@media': {
'(min-width: 768px)': {
margin: '1px'
}
}
});
`,
name: 'should handle nested globalStyle arguments',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '-1.0px',
padding: '-2.50rem',
top: '-0.0vh',
});
`,
errors: [{ messageId: 'trailingZero' }, { messageId: 'trailingZero' }, { messageId: 'trailingZero' }],
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '-1px',
padding: '-2.5rem',
top: '0',
});
`,
name: 'should handle negative values with trailing zeros',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '1.50em',
padding: '0.50rem',
width: '10.00%',
});
`,
errors: [{ messageId: 'trailingZero' }, { messageId: 'trailingZero' }, { messageId: 'trailingZero' }],
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '1.5em',
padding: '0.5rem',
width: '10%',
});
`,
name: 'should remove trailing zeros from decimal values',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
padding: '1.0px 2.50rem 3.00em 0.50vh',
});
`,
errors: 1,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
padding: '1px 2.5rem 3em 0.5vh',
});
`,
name: 'should handle multiple values in a single string',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
transition: 'all 0.30s ease',
animation: 'spin 2.0s linear',
});
`,
errors: 2,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
transition: 'all 0.3s ease',
animation: 'spin 2s linear',
});
`,
name: 'should handle time units',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
transform: 'rotate(45.0deg)',
filter: 'hue-rotate(180.00deg)',
});
`,
errors: 2,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
transform: 'rotate(45deg)',
filter: 'hue-rotate(180deg)',
});
`,
name: 'should handle angle units',
},
{
code: `
import { styleVariants } from '@vanilla-extract/css';
const variants = styleVariants({
small: { padding: '1.0px' },
medium: { padding: '2.50px' },
large: { padding: '3.00px' },
});
`,
errors: 3,
output: `
import { styleVariants } from '@vanilla-extract/css';
const variants = styleVariants({
small: { padding: '1px' },
medium: { padding: '2.5px' },
large: { padding: '3px' },
});
`,
name: 'should handle styleVariants',
},
{
code: `
import { keyframes } from '@vanilla-extract/css';
const spin = keyframes({
'0%': { transform: 'rotate(0.0deg)' },
'50%': { transform: 'rotate(180.0deg)' },
'100%': { transform: 'rotate(360.0deg)' },
});
`,
errors: 3,
output: `
import { keyframes } from '@vanilla-extract/css';
const spin = keyframes({
'0%': { transform: 'rotate(0deg)' },
'50%': { transform: 'rotate(180deg)' },
'100%': { transform: 'rotate(360deg)' },
});
`,
name: 'should handle keyframes',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '1.000px',
padding: '2.5000rem',
width: '0.00000em',
});
`,
errors: 3,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '1px',
padding: '2.5rem',
width: '0',
});
`,
name: 'should handle multiple trailing zeros',
},
],
});

View file

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

View file

@ -0,0 +1,23 @@
import type { Rule } from 'eslint';
import { createTrailingZeroVisitors } from './trailing-zero-visitor-creator.js';
const noTrailingZeroRule: Rule.RuleModule = {
meta: {
type: 'suggestion',
docs: {
description: 'disallow trailing zeros in numeric CSS values',
category: 'Stylistic Issues',
recommended: true,
},
fixable: 'code',
schema: [],
messages: {
trailingZero: 'Numeric value "{{ value }}" has unnecessary trailing zeros. Use "{{ fixed }}" instead.',
},
},
create(context) {
return createTrailingZeroVisitors(context);
},
};
export default noTrailingZeroRule;

View file

@ -0,0 +1,197 @@
import type { Rule } from 'eslint';
import { TSESTree } from '@typescript-eslint/utils';
/**
* Regex to match numbers with trailing zeros.
* Matches patterns like:
* - 1.0, 2.50, 0.0, 0.50
* - 1.0px, 2.50rem, 0.0em
* - -1.0, -2.50px
*
* Groups:
* 1: Optional minus sign
* 2: Integer part
* 3: Significant fractional digits (optional)
* 4: Trailing zeros
* 5: Optional unit
*/
const TRAILING_ZERO_REGEX = /^(-?)(\d+)\.(\d*[1-9])?(0+)([a-z%]+)?$/i;
/**
* Checks if a value has trailing zeros and returns the fixed value if needed.
*
* @param value The string value to check
* @returns Object with hasTrailingZero flag and fixed value, or null if no trailing zeros
*/
export const checkTrailingZero = (value: string): { hasTrailingZero: boolean; fixed: string } | null => {
const trimmedValue = value.trim();
const match = trimmedValue.match(TRAILING_ZERO_REGEX);
if (!match) {
return null;
}
const [, minus = '', integerPart, significantFractional = '', , unit = ''] = match;
// Handle special case: 0.0 or 0.00 etc. should become just "0"
if (integerPart === '0' && !significantFractional) {
return {
hasTrailingZero: true,
fixed: '0',
};
}
// If there's no significant fractional part (e.g., "1.0" -> "1")
if (!significantFractional) {
return {
hasTrailingZero: true,
fixed: `${minus}${integerPart}${unit}`,
};
}
// If there's a significant fractional part (e.g., "1.50" -> "1.5")
return {
hasTrailingZero: true,
fixed: `${minus}${integerPart}.${significantFractional}${unit}`,
};
};
/**
* Processes a single string value and checks for trailing zeros in all numeric values.
* Handles strings with multiple numeric values (e.g., "1.0px 2.50em").
* Also handles values within function calls (e.g., "rotate(45.0deg)").
*
* @param value The string value to process
* @returns Object with hasTrailingZero flag and fixed value, or null if no trailing zeros
*/
export const processStringValue = (value: string): { hasTrailingZero: boolean; fixed: string } | null => {
// First, try to match the entire value
const directMatch = checkTrailingZero(value);
if (directMatch?.hasTrailingZero) {
return directMatch;
}
// Split by whitespace to handle multiple values
const parts = value.split(/(\s+)/);
let hasAnyTrailingZero = false;
const fixedParts = parts.map((part) => {
// Preserve whitespace
if (/^\s+$/.test(part)) {
return part;
}
// Try to match the whole part first
const result = checkTrailingZero(part);
if (result?.hasTrailingZero) {
hasAnyTrailingZero = true;
return result.fixed;
}
// If no match, try to find and replace numbers within the part (e.g., inside function calls)
const regex = /(-?\d+)\.(\d*[1-9])?(0+)(?![0-9])([a-z%]+)?/gi;
const fixedPart = part.replace(
regex,
(_: string, integerWithSign: string, significantFractional: string, __: string, unit: string) => {
// Reconstruct the number without trailing zeros
const integerPart = integerWithSign;
const sig = significantFractional || '';
const u = unit || '';
// Handle 0.0 case - if it's zero and no unit, return just '0', otherwise keep the unit
if (integerPart === '0' && !sig) {
hasAnyTrailingZero = true;
return u ? `0${u}` : '0';
}
// Handle X.0 case
if (!sig) {
hasAnyTrailingZero = true;
return `${integerPart}${u}`;
}
// Handle X.Y0 case
hasAnyTrailingZero = true;
return `${integerPart}.${sig}${u}`;
},
);
return fixedPart;
});
if (!hasAnyTrailingZero) {
return null;
}
return {
hasTrailingZero: true,
fixed: fixedParts.join(''),
};
};
/**
* Recursively processes a style object, reporting and fixing instances of trailing zeros in numeric values.
*
* @param ruleContext The ESLint rule context.
* @param node The ObjectExpression node representing the style object to be processed.
*/
export const processTrailingZeroInStyleObject = (
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') {
const result = processStringValue(property.value.value);
if (result?.hasTrailingZero) {
ruleContext.report({
node: property.value,
messageId: 'trailingZero',
data: {
value: property.value.value,
fixed: result.fixed,
},
fix: (fixer) => fixer.replaceText(property.value, `'${result.fixed}'`),
});
}
}
// Process numeric literal values (e.g., margin: 1.0)
if (property.value.type === 'Literal' && typeof property.value.value === 'number') {
// Use the raw property to get the original source text (which preserves trailing zeros)
const rawValue = property.value.raw || property.value.value.toString();
const result = checkTrailingZero(rawValue);
if (result?.hasTrailingZero) {
ruleContext.report({
node: property.value,
messageId: 'trailingZero',
data: {
value: rawValue,
fixed: result.fixed,
},
fix: (fixer) => fixer.replaceText(property.value, result.fixed),
});
}
}
// Process nested objects (selectors, media queries, etc.)
if (property.value.type === 'ObjectExpression') {
processTrailingZeroInStyleObject(ruleContext, property.value);
}
// Process arrays (for styleVariants with array values)
if (property.value.type === 'ArrayExpression') {
property.value.elements.forEach((element) => {
if (element && element.type === 'ObjectExpression') {
processTrailingZeroInStyleObject(ruleContext, element);
}
});
}
});
};

View file

@ -0,0 +1,103 @@
import type { Rule } from 'eslint';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
import { processRecipeProperties } from '../shared-utils/recipe-property-processor.js';
import { ReferenceTracker, createReferenceTrackingVisitor } from '../shared-utils/reference-tracker.js';
import { processStyleNode } from '../shared-utils/style-node-processor.js';
import { processTrailingZeroInStyleObject } from './trailing-zero-processor.js';
/**
* Creates ESLint rule visitors for detecting and processing trailing zeros in numeric CSS values.
* Uses reference tracking to automatically detect vanilla-extract functions based on their import statements.
*
* @param context The ESLint rule context.
* @returns An object with visitor functions for the ESLint rule.
*/
export const createTrailingZeroVisitors = (context: Rule.RuleContext): Rule.RuleListener => {
const tracker = new ReferenceTracker();
const trackingVisitor = createReferenceTrackingVisitor(tracker);
return {
// Include the reference tracking visitors
...trackingVisitor,
CallExpression(node) {
if (node.callee.type !== AST_NODE_TYPES.Identifier) {
return;
}
const functionName = node.callee.name;
// Check if this function is tracked as a vanilla-extract function
if (!tracker.isTrackedFunction(functionName)) {
return;
}
const originalName = tracker.getOriginalName(functionName);
if (!originalName) {
return;
}
// Handle different function types based on their original imported name
switch (originalName) {
case 'fontFace':
if (node.arguments.length > 0 && node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression) {
processTrailingZeroInStyleObject(context, node.arguments[0] as TSESTree.ObjectExpression);
}
break;
case 'globalFontFace':
if (node.arguments.length > 1 && node.arguments[1]?.type === AST_NODE_TYPES.ObjectExpression) {
processTrailingZeroInStyleObject(context, node.arguments[1] as TSESTree.ObjectExpression);
}
break;
case 'style':
if (node.arguments.length > 0) {
processStyleNode(context, node.arguments[0] as TSESTree.ObjectExpression, processTrailingZeroInStyleObject);
}
break;
case 'styleVariants':
case 'keyframes':
// For styleVariants and keyframes, the argument is an object where each property value is a style object
if (node.arguments.length > 0 && node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression) {
const variantsObject = node.arguments[0] as TSESTree.ObjectExpression;
variantsObject.properties.forEach((property) => {
if (property.type === 'Property' && property.value.type === 'ObjectExpression') {
processTrailingZeroInStyleObject(context, property.value);
}
});
}
break;
case 'globalStyle':
if (node.arguments.length >= 2) {
processStyleNode(context, node.arguments[1] as TSESTree.ObjectExpression, processTrailingZeroInStyleObject);
}
break;
case 'globalKeyframes':
// For globalKeyframes, the second argument is an object where each property value is a style object
if (node.arguments.length >= 2 && node.arguments[1]?.type === AST_NODE_TYPES.ObjectExpression) {
const keyframesObject = node.arguments[1] as TSESTree.ObjectExpression;
keyframesObject.properties.forEach((property) => {
if (property.type === 'Property' && property.value.type === 'ObjectExpression') {
processTrailingZeroInStyleObject(context, property.value);
}
});
}
break;
case 'recipe':
if (node.arguments.length > 0 && node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression) {
processRecipeProperties(
context,
node.arguments[0] as TSESTree.ObjectExpression,
processTrailingZeroInStyleObject,
);
}
break;
}
},
};
};

View file

@ -0,0 +1,472 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import noUnitlessValuesRule from '../rule-definition.js';
run({
name: 'vanilla-extract/no-unitless-values',
rule: noUnitlessValuesRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// Zero values are allowed
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: 0,
padding: 0,
width: 0,
});
`,
name: 'should allow zero values without units',
},
// String values with units
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
width: '100px',
margin: '20px',
padding: '1rem',
fontSize: '16px',
});
`,
name: 'should allow string values with units',
},
// String zero values
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '0',
padding: '0',
});
`,
name: 'should allow string zero values without units',
},
// Unitless-valid properties
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
opacity: 0.5,
zIndex: 10,
lineHeight: 1.5,
flexGrow: 1,
flexShrink: 0,
order: 2,
fontWeight: 700,
zoom: 1.2,
});
`,
name: 'should allow unitless values for properties that accept them',
},
// Recipe with valid values
{
code: `
import { recipe } from '@vanilla-extract/css';
const myRecipe = recipe({
base: {
margin: '0',
padding: 0,
opacity: 0.8,
},
variants: {
size: {
small: {
height: '10px',
width: '10px',
zIndex: 1,
},
},
},
});
`,
name: 'should allow valid values in recipe',
},
// Template literals (not checked)
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: \`\${someValue}px\`,
padding: someVariable,
});
`,
name: 'should ignore template literals and variables',
},
// Nested selectors with valid values
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
width: '100px',
':hover': {
margin: '20px',
opacity: 0.8,
},
});
`,
name: 'should allow valid values in nested selectors',
},
// Media queries with valid values
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: 0,
'@media': {
'(min-width: 768px)': {
padding: '20px',
zIndex: 10,
}
}
});
`,
name: 'should allow valid values in media queries',
},
// globalStyle with valid values
{
code: `
import { globalStyle } from '@vanilla-extract/css';
globalStyle('html', {
margin: '0',
padding: 0,
lineHeight: 1.5,
});
`,
name: 'should allow valid values in globalStyle',
},
// fontFace with valid values
{
code: `
import { fontFace } from '@vanilla-extract/css';
fontFace({
src: 'url(...)',
fontWeight: 400,
});
`,
name: 'should allow valid values in fontFace',
},
// keyframes (animation values are strings)
{
code: `
import { keyframes } from '@vanilla-extract/css';
const spin = keyframes({
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360deg)' }
});
`,
name: 'should allow keyframes with string values',
},
// allow option
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
width: 100,
margin: '20px',
});
`,
options: [{ allow: ['width'] }],
name: 'should allow properties specified in allow option',
},
// Kebab-case unitless-valid properties
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
'z-index': 10,
'line-height': 1.5,
'flex-grow': 1,
});
`,
name: 'should allow unitless values for kebab-case unitless-valid properties',
},
],
invalid: [
// Basic unitless values
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
width: 100,
margin: 20,
});
`,
errors: [
{ messageId: 'noUnitlessValue', data: { property: 'width', value: '100' } },
{ messageId: 'noUnitlessValue', data: { property: 'margin', value: '20' } },
],
name: 'should flag unitless numeric values for length properties',
},
// Decimal values
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
padding: 10.5,
fontSize: 16.5,
});
`,
errors: [
{ messageId: 'noUnitlessValue', data: { property: 'padding', value: '10.5' } },
{ messageId: 'noUnitlessValue', data: { property: 'fontSize', value: '16.5' } },
],
name: 'should flag decimal unitless values',
},
// Recipe with unitless values
{
code: `
import { recipe } from '@vanilla-extract/css';
const myRecipe = recipe({
base: {
margin: 10,
},
variants: {
size: {
small: {
height: 20,
},
},
},
});
`,
errors: [
{ messageId: 'noUnitlessValue', data: { property: 'margin', value: '10' } },
{ messageId: 'noUnitlessValue', data: { property: 'height', value: '20' } },
],
name: 'should flag unitless values in recipe',
},
// Nested selectors with unitless values
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
width: 100,
':hover': {
margin: 20,
},
});
`,
errors: [
{ messageId: 'noUnitlessValue', data: { property: 'width', value: '100' } },
{ messageId: 'noUnitlessValue', data: { property: 'margin', value: '20' } },
],
name: 'should flag unitless values in nested selectors',
},
// Media queries with unitless values
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
'@media': {
'(min-width: 768px)': {
padding: 20,
}
}
});
`,
errors: [{ messageId: 'noUnitlessValue', data: { property: 'padding', value: '20' } }],
name: 'should flag unitless values in media queries',
},
// Multiple levels of nesting
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: 10,
nested: {
object: {
padding: 20,
deeper: {
width: 30
}
}
}
});
`,
errors: [
{ messageId: 'noUnitlessValue', data: { property: 'margin', value: '10' } },
{ messageId: 'noUnitlessValue', data: { property: 'padding', value: '20' } },
{ messageId: 'noUnitlessValue', data: { property: 'width', value: '30' } },
],
name: 'should flag unitless values in deeply nested objects',
},
// globalStyle with unitless values
{
code: `
import { globalStyle } from '@vanilla-extract/css';
globalStyle('html', {
margin: 10,
padding: 20,
});
`,
errors: [
{ messageId: 'noUnitlessValue', data: { property: 'margin', value: '10' } },
{ messageId: 'noUnitlessValue', data: { property: 'padding', value: '20' } },
],
name: 'should flag unitless values in globalStyle',
},
// Various length properties
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
width: 100,
height: 200,
minWidth: 50,
maxWidth: 500,
top: 10,
left: 20,
borderWidth: 2,
borderRadius: 5,
gap: 15,
fontSize: 16,
});
`,
errors: 10,
name: 'should flag all unitless values for various length properties',
},
// Kebab-case properties
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
'margin-top': 10,
'padding-left': 20,
'font-size': 16,
});
`,
errors: [
{ messageId: 'noUnitlessValue', data: { property: 'margin-top', value: '10' } },
{ messageId: 'noUnitlessValue', data: { property: 'padding-left', value: '20' } },
{ messageId: 'noUnitlessValue', data: { property: 'font-size', value: '16' } },
],
name: 'should flag unitless values for kebab-case properties',
},
// Logical properties
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
marginBlock: 10,
paddingInline: 20,
insetBlockStart: 5,
});
`,
errors: [
{ messageId: 'noUnitlessValue', data: { property: 'marginBlock', value: '10' } },
{ messageId: 'noUnitlessValue', data: { property: 'paddingInline', value: '20' } },
{ messageId: 'noUnitlessValue', data: { property: 'insetBlockStart', value: '5' } },
],
name: 'should flag unitless values for logical properties',
},
// styleVariants
{
code: `
import { styleVariants } from '@vanilla-extract/css';
const variants = styleVariants({
small: { width: 10 },
large: { width: 100 },
});
`,
errors: [
{ messageId: 'noUnitlessValue', data: { property: 'width', value: '10' } },
{ messageId: 'noUnitlessValue', data: { property: 'width', value: '100' } },
],
name: 'should flag unitless values in styleVariants',
},
// globalKeyframes
{
code: `
import { globalKeyframes } from '@vanilla-extract/css';
globalKeyframes('slide', {
'0%': { left: 0 },
'100%': { left: 100 }
});
`,
errors: [{ messageId: 'noUnitlessValue', data: { property: 'left', value: '100' } }],
name: 'should flag unitless values in globalKeyframes',
},
// Negative values
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: -10,
top: -5,
});
`,
errors: [
{ messageId: 'noUnitlessValue', data: { property: 'margin', value: '-10' } },
{ messageId: 'noUnitlessValue', data: { property: 'top', value: '-5' } },
],
name: 'should flag negative unitless values',
},
// String unitless values
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
width: '100',
margin: '20',
padding: '10.5',
});
`,
errors: [
{ messageId: 'noUnitlessValue', data: { property: 'width', value: '100' } },
{ messageId: 'noUnitlessValue', data: { property: 'margin', value: '20' } },
{ messageId: 'noUnitlessValue', data: { property: 'padding', value: '10.5' } },
],
name: 'should flag string unitless values',
},
// String negative unitless values
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '-10',
top: '-5.5',
});
`,
errors: [
{ messageId: 'noUnitlessValue', data: { property: 'margin', value: '-10' } },
{ messageId: 'noUnitlessValue', data: { property: 'top', value: '-5.5' } },
],
name: 'should flag string negative unitless values',
},
],
});

View file

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

View file

@ -0,0 +1,40 @@
import type { Rule } from 'eslint';
import { createUnitlessValueVisitors } from './unitless-value-visitor-creator.js';
import type { NoUnitlessValuesOptions } from './unitless-value-processor.js';
const noUnitlessValuesRule: Rule.RuleModule = {
meta: {
type: 'suggestion',
docs: {
description: 'disallow unitless numeric values for CSS properties that require units',
category: 'Stylistic Issues',
recommended: false,
},
schema: [
{
type: 'object',
properties: {
allow: {
type: 'array',
items: {
type: 'string',
},
uniqueItems: true,
default: [],
},
},
additionalProperties: false,
},
],
messages: {
noUnitlessValue:
'Property "{{ property }}" has unitless value {{ value }}. Add an explicit unit (e.g., "{{ value }}px", "{{ value }}rem").',
},
},
create(context) {
const options: NoUnitlessValuesOptions = (context.options[0] as NoUnitlessValuesOptions | undefined) || {};
return createUnitlessValueVisitors(context, options);
},
};
export default noUnitlessValuesRule;

View file

@ -0,0 +1,371 @@
import type { Rule } from 'eslint';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
/**
* CSS properties that require units for length/dimension values.
* These properties should not have unitless numeric values (except 0).
*/
const PROPERTIES_REQUIRING_UNITS = new Set([
// Box model
'width',
'height',
'minWidth',
'maxWidth',
'minHeight',
'maxHeight',
'min-width',
'max-width',
'min-height',
'max-height',
// Spacing
'margin',
'marginTop',
'marginRight',
'marginBottom',
'marginLeft',
'marginBlock',
'marginBlockStart',
'marginBlockEnd',
'marginInline',
'marginInlineStart',
'marginInlineEnd',
'margin-top',
'margin-right',
'margin-bottom',
'margin-left',
'margin-block',
'margin-block-start',
'margin-block-end',
'margin-inline',
'margin-inline-start',
'margin-inline-end',
'padding',
'paddingTop',
'paddingRight',
'paddingBottom',
'paddingLeft',
'paddingBlock',
'paddingBlockStart',
'paddingBlockEnd',
'paddingInline',
'paddingInlineStart',
'paddingInlineEnd',
'padding-top',
'padding-right',
'padding-bottom',
'padding-left',
'padding-block',
'padding-block-start',
'padding-block-end',
'padding-inline',
'padding-inline-start',
'padding-inline-end',
// Positioning
'top',
'right',
'bottom',
'left',
'inset',
'insetBlock',
'insetBlockStart',
'insetBlockEnd',
'insetInline',
'insetInlineStart',
'insetInlineEnd',
'inset-block',
'inset-block-start',
'inset-block-end',
'inset-inline',
'inset-inline-start',
'inset-inline-end',
// Border
'borderWidth',
'borderTopWidth',
'borderRightWidth',
'borderBottomWidth',
'borderLeftWidth',
'borderBlockWidth',
'borderBlockStartWidth',
'borderBlockEndWidth',
'borderInlineWidth',
'borderInlineStartWidth',
'borderInlineEndWidth',
'border-width',
'border-top-width',
'border-right-width',
'border-bottom-width',
'border-left-width',
'border-block-width',
'border-block-start-width',
'border-block-end-width',
'border-inline-width',
'border-inline-start-width',
'border-inline-end-width',
'borderRadius',
'borderTopLeftRadius',
'borderTopRightRadius',
'borderBottomLeftRadius',
'borderBottomRightRadius',
'borderStartStartRadius',
'borderStartEndRadius',
'borderEndStartRadius',
'borderEndEndRadius',
'border-radius',
'border-top-left-radius',
'border-top-right-radius',
'border-bottom-left-radius',
'border-bottom-right-radius',
'border-start-start-radius',
'border-start-end-radius',
'border-end-start-radius',
'border-end-end-radius',
// Typography
'fontSize',
'font-size',
'letterSpacing',
'letter-spacing',
'wordSpacing',
'word-spacing',
'textIndent',
'text-indent',
// Flexbox/Grid
'gap',
'rowGap',
'columnGap',
'row-gap',
'column-gap',
'flexBasis',
'flex-basis',
// Outline
'outlineWidth',
'outline-width',
'outlineOffset',
'outline-offset',
// Other
'blockSize',
'inlineSize',
'minBlockSize',
'maxBlockSize',
'minInlineSize',
'maxInlineSize',
'block-size',
'inline-size',
'min-block-size',
'max-block-size',
'min-inline-size',
'max-inline-size',
]);
/**
* CSS properties that accept unitless numeric values.
* These properties should NOT be flagged when they have numeric values.
*/
const UNITLESS_VALID_PROPERTIES = new Set([
'opacity',
'zIndex',
'z-index',
'lineHeight',
'line-height',
'flexGrow',
'flex-grow',
'flexShrink',
'flex-shrink',
'order',
'fontWeight',
'font-weight',
'zoom',
'animationIterationCount',
'animation-iteration-count',
'columnCount',
'column-count',
'gridColumn',
'grid-column',
'gridColumnEnd',
'grid-column-end',
'gridColumnStart',
'grid-column-start',
'gridRow',
'grid-row',
'gridRowEnd',
'grid-row-end',
'gridRowStart',
'grid-row-start',
'orphans',
'widows',
'fillOpacity',
'fill-opacity',
'strokeOpacity',
'stroke-opacity',
'strokeMiterlimit',
'stroke-miterlimit',
]);
export interface NoUnitlessValuesOptions {
allow?: string[];
}
/**
* Checks if a property name requires units for numeric values.
*/
const requiresUnits = (propertyName: string, allow: string[] = []): boolean => {
if (allow.includes(propertyName)) {
return false;
}
if (UNITLESS_VALID_PROPERTIES.has(propertyName)) {
return false;
}
return PROPERTIES_REQUIRING_UNITS.has(propertyName);
};
/**
* Gets the property name from a Property node.
*/
const getPropertyName = (property: TSESTree.Property): string | null => {
if (property.key.type === AST_NODE_TYPES.Identifier) {
return property.key.name;
}
if (property.key.type === AST_NODE_TYPES.Literal && typeof property.key.value === 'string') {
return property.key.value;
}
return null;
};
/**
* Recursively processes a style object, reporting instances of unitless numeric values for properties that require units.
*
* @param ruleContext The ESLint rule context.
* @param node The ObjectExpression node representing the style object to be processed.
* @param options Rule options including allow list.
*/
export const processUnitlessValueInStyleObject = (
ruleContext: Rule.RuleContext,
node: TSESTree.ObjectExpression,
options: NoUnitlessValuesOptions = {},
): void => {
const allow = options.allow || [];
node.properties.forEach((property) => {
if (property.type !== AST_NODE_TYPES.Property) {
return;
}
const propertyName = getPropertyName(property);
if (!propertyName) {
return;
}
// Skip special nested structures like @media, selectors, etc.
// These will be processed recursively
if (propertyName.startsWith('@') || propertyName.startsWith(':') || propertyName === 'selectors') {
if (property.value.type === AST_NODE_TYPES.ObjectExpression) {
if (propertyName === '@media' || propertyName === 'selectors') {
property.value.properties.forEach((nestedProperty) => {
if (
nestedProperty.type === AST_NODE_TYPES.Property &&
nestedProperty.value.type === AST_NODE_TYPES.ObjectExpression
) {
processUnitlessValueInStyleObject(ruleContext, nestedProperty.value, options);
}
});
} else {
// For pseudo-selectors and other nested objects, process directly
processUnitlessValueInStyleObject(ruleContext, property.value, options);
}
}
return;
}
// Check if this property requires units
if (!requiresUnits(propertyName, allow)) {
// Still need to process nested objects for non-CSS properties
if (property.value.type === AST_NODE_TYPES.ObjectExpression) {
processUnitlessValueInStyleObject(ruleContext, property.value, options);
}
return;
}
// Check for unitless numeric literal values (e.g., width: 100)
if (property.value.type === AST_NODE_TYPES.Literal && typeof property.value.value === 'number') {
// Allow 0 without units (valid CSS), including -0
if (property.value.value === 0 || property.value.value === -0) {
return;
}
// Report unitless numeric value
ruleContext.report({
node: property.value,
messageId: 'noUnitlessValue',
data: {
property: propertyName,
value: String(property.value.value),
},
});
}
// Check for string literals that are unitless numbers (e.g., width: '100')
if (property.value.type === AST_NODE_TYPES.Literal && typeof property.value.value === 'string') {
const stringValue = property.value.value.trim();
// Check if the string is a pure number (with optional negative sign and decimals)
// This regex matches: -10, 10, 10.5, -10.5, but not 10px, 10rem, etc.
const unitlessNumberRegex = /^-?\d+(\.\d+)?$/;
if (unitlessNumberRegex.test(stringValue)) {
// Allow '0' and '-0' without units
const numValue = parseFloat(stringValue);
if (numValue === 0 || numValue === -0) {
return;
}
// Report unitless string numeric value
ruleContext.report({
node: property.value,
messageId: 'noUnitlessValue',
data: {
property: propertyName,
value: stringValue,
},
});
}
}
// Check for unary expressions (e.g., -10)
if (property.value.type === AST_NODE_TYPES.UnaryExpression && property.value.operator === '-') {
if (
property.value.argument.type === AST_NODE_TYPES.Literal &&
typeof property.value.argument.value === 'number'
) {
// Allow -0 without units
if (property.value.argument.value === 0) {
return;
}
// Report unitless numeric value
ruleContext.report({
node: property.value as unknown as Rule.Node,
messageId: 'noUnitlessValue',
data: {
property: propertyName,
value: `-${property.value.argument.value}`,
},
});
}
}
// Process nested objects (for complex selectors, etc.)
if (property.value.type === AST_NODE_TYPES.ObjectExpression) {
processUnitlessValueInStyleObject(ruleContext, property.value, options);
}
});
};

View file

@ -0,0 +1,85 @@
import type { Rule } from 'eslint';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
import { processRecipeProperties } from '../shared-utils/recipe-property-processor.js';
import { ReferenceTracker, createReferenceTrackingVisitor } from '../shared-utils/reference-tracker.js';
import { processStyleNode } from '../shared-utils/style-node-processor.js';
import { processUnitlessValueInStyleObject } from './unitless-value-processor.js';
import type { NoUnitlessValuesOptions } from './unitless-value-processor.js';
/**
* Creates ESLint rule visitors for detecting unitless numeric values in style-related function calls.
* Uses reference tracking to automatically detect vanilla-extract functions based on their import statements.
*
* @param context The ESLint rule context.
* @param options Rule options including allowed property names.
* @returns An object with visitor functions for the ESLint rule.
*/
export const createUnitlessValueVisitors = (
context: Rule.RuleContext,
options: NoUnitlessValuesOptions = {},
): Rule.RuleListener => {
const tracker = new ReferenceTracker();
const trackingVisitor = createReferenceTrackingVisitor(tracker);
const processWithOptions = (ruleContext: Rule.RuleContext, node: TSESTree.ObjectExpression): void => {
processUnitlessValueInStyleObject(ruleContext, node, options);
};
return {
...trackingVisitor,
CallExpression(node) {
if (node.callee.type !== AST_NODE_TYPES.Identifier) {
return;
}
const functionName = node.callee.name;
// Check if this function is tracked as a vanilla-extract function
if (!tracker.isTrackedFunction(functionName)) {
return;
}
const originalName = tracker.getOriginalName(functionName);
if (!originalName) {
return;
}
// Handle different function types based on their original imported name
switch (originalName) {
case 'fontFace':
if (node.arguments.length > 0 && node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression) {
processWithOptions(context, node.arguments[0] as TSESTree.ObjectExpression);
}
break;
case 'globalFontFace':
if (node.arguments.length > 1 && node.arguments[1]?.type === AST_NODE_TYPES.ObjectExpression) {
processWithOptions(context, node.arguments[1] as TSESTree.ObjectExpression);
}
break;
case 'style':
case 'styleVariants':
case 'keyframes':
if (node.arguments.length > 0) {
processStyleNode(context, node.arguments[0] as TSESTree.ObjectExpression, processWithOptions);
}
break;
case 'globalStyle':
case 'globalKeyframes':
if (node.arguments.length >= 2) {
processStyleNode(context, node.arguments[1] as TSESTree.ObjectExpression, processWithOptions);
}
break;
case 'recipe':
if (node.arguments.length > 0 && node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression) {
processRecipeProperties(context, node.arguments[0] as TSESTree.ObjectExpression, processWithOptions);
}
break;
}
},
};
};

View file

@ -0,0 +1,292 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import noUnknownUnitRule from '../rule-definition.js';
run({
name: 'vanilla-extract/no-unknown-unit',
rule: noUnknownUnitRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
`
import { style } from '@vanilla-extract/css';
const valid = style({
width: '100%',
padding: '2rem',
margin: '0',
fontSize: '1.5em',
});
`,
`
import { style } from '@vanilla-extract/css';
const nested = style({
'@media': {
'(min-width: 768px)': {
padding: '2cqw',
margin: '1svh'
}
},
selectors: {
'&:hover': {
rotate: '45deg'
}
}
});
`,
`
import { recipe } from '@vanilla-extract/css';
const button = recipe({
variants: {
size: {
small: { padding: '4mm' },
large: { fontSize: '2lh' }
}
}
});
`,
`
import { fontFace } from '@vanilla-extract/css';
const myFont = fontFace({
src: 'local("Comic Sans")',
lineGap: '2.3ex'
});
`,
`
import { style } from '@vanilla-extract/css';
const noUnits = style({
zIndex: 100,
opacity: 0.5,
flexGrow: 1
});
`,
{
code: `
import { style } from '@vanilla-extract/css';
const caseTest = style({
width: '10Px' // Should be valid (CSS is case-insensitive)
});
`,
},
{
code: `
import { style } from '@vanilla-extract/css';
const viaMemberExpression = someObject.style({
width: '10invalid' // Should be ignored
});
`,
},
{
code: `
import { style } from '@vanilla-extract/css';
const viaCallExpression = (style)();
`,
},
{
code: `
import { style } from '@vanilla-extract/css';
const nestedCall = someFn().style({
padding: '5pct' // Should be ignored
});
`,
},
{
code: `
import { style } from '@vanilla-extract/css';
const taggedTemplate = style\`width: 10pxx\`; // Different AST structure
`,
},
{
code: `
import { style } from '@vanilla-extract/css';
style({
width: \`10px\`, // Valid unit in template literal
height: \`calc(100% - \${10}px)\` // Should be ignored (multiple quasis)
});
`,
},
{
code: `
import { style } from '@vanilla-extract/css';
style({
margin: \` \${''} \`, // Empty template literal
padding: \`\${'2rem'}\` // Interpolation only
});
`,
},
{
code: `
import { style } from '@vanilla-extract/css';
style({
valid: '10px',
// Add nested non-object properties
invalidNested: [ { invalid: '10pxx' } ], // Array expression
invalidMedia: {
'@media': 'invalid-string' // String instead of object
}
});
`,
},
{
code: `
import { recipe } from '@vanilla-extract/css';
recipe({
base: {
valid: '1rem',
// Invalid nested structure
nestedInvalid: 'not-an-object'
}
});
`,
},
{
code: `
import { style } from '@vanilla-extract/css';
const baseStyles = { padding: '1rem' };
style({
...baseStyles, // Spread element (not a Property node)
margin: '2em'
});
`,
},
{
code: `
import { style } from '@vanilla-extract/css';
style({
...{ width: '10px' }, // Inline spread
height: '20vh'
});
`,
},
],
invalid: [
// Basic invalid units
{
code: `
import { style } from '@vanilla-extract/css';
const invalid = style({
width: '10pxx',
padding: '5pct'
});y
`,
errors: [
{
messageId: 'unknownUnit',
data: { unit: 'pxx', value: '10pxx' },
},
{
messageId: 'unknownUnit',
data: { unit: 'pct', value: '5pct' },
},
],
},
// Invalid units in nested contexts
{
code: `
import { style } from '@vanilla-extract/css';
const nestedInvalid = style({
'@media': {
'(min-width: 768px)': {
margin: '10dvhx'
}
},
selectors: {
'&:active': {
rotate: '90rads'
}
}
});
`,
errors: [
{ messageId: 'unknownUnit', data: { unit: 'dvhx', value: '10dvhx' } },
{ messageId: 'unknownUnit', data: { unit: 'rads', value: '90rads' } },
],
},
// Invalid units in recipes
{
code: `
import { recipe } from '@vanilla-extract/css';
const invalidRecipe = recipe({
base: {
fontSize: '12ptx'
},
variants: {
spacing: {
large: { padding: '20inchs' }
}
}
});
`,
errors: [
{ messageId: 'unknownUnit', data: { unit: 'ptx', value: '12ptx' } },
{ messageId: 'unknownUnit', data: { unit: 'inchs', value: '20inchs' } },
],
},
// Invalid units in global styles
{
code: `
import { globalStyle } from '@vanilla-extract/css';
globalStyle('body', {
margin: '5foot'
});
`,
errors: [{ messageId: 'unknownUnit', data: { unit: 'foot', value: '5foot' } }],
},
// Complex value patterns
{
code: `
import { style } from '@vanilla-extract/css';
const complexValues = style({
padding: '10px 20cmm', // Second value is invalid
margin: '1rem 2 3em 4whatever'
});
`,
errors: [
{ messageId: 'unknownUnit', data: { unit: 'cmm', value: '20cmm' } },
{ messageId: 'unknownUnit', data: { unit: 'whatever', value: '4whatever' } },
],
},
{
code: `
import { fontFace } from '@vanilla-extract/css';
fontFace({
src: 'local("Test Font")',
lineGap: '5foot' // Invalid unit
});
`,
errors: [{ messageId: 'unknownUnit', data: { unit: 'foot', value: '5foot' } }],
},
{
code: `
import { globalFontFace } from '@vanilla-extract/css';
globalFontFace('MyFont', {
src: 'local("Test Font")',
ascentOverride: '10hand' // Invalid unit
});
`,
errors: [{ messageId: 'unknownUnit', data: { unit: 'hand', value: '10hand' } }],
},
],
});

View file

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

View file

@ -0,0 +1,22 @@
import type { Rule } from 'eslint';
import { createUnknownUnitVisitors } from './unknown-unit-visitor-creator.js';
const noUnknownUnitRule: Rule.RuleModule = {
meta: {
type: 'problem',
docs: {
description: 'disallow invalid or unknown CSS units in vanilla-extract style objects',
category: 'Possible Errors',
recommended: true,
},
schema: [],
messages: {
unknownUnit: 'The unit "{{ unit }}" in value "{{ value }}" is not recognized as a valid CSS unit.',
},
},
create(context) {
return createUnknownUnitVisitors(context);
},
};
export default noUnknownUnitRule;

View file

@ -0,0 +1,196 @@
import type { Rule } from 'eslint';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
/**
* List of valid CSS units according to CSS specifications.
*/
const VALID_CSS_UNITS = [
// Absolute length units
'px',
'cm',
'mm',
'Q',
'in',
'pc',
'pt',
// Relative length units
'em',
'ex',
'ch',
'rem',
'lh',
'rlh',
'vw',
'vh',
'vmin',
'vmax',
'vb',
'vi',
'svw',
'svh',
'lvw',
'lvh',
'dvw',
'dvh',
// Percentage
'%',
// Angle units
'deg',
'grad',
'rad',
'turn',
// Time units
'ms',
's',
// Frequency units
'Hz',
'kHz',
// Resolution units
'dpi',
'dpcm',
'dppx',
'x',
// Flexible length units
'fr',
// Other valid units
'cap',
'ic',
'rex',
'cqw',
'cqh',
'cqi',
'cqb',
'cqmin',
'cqmax',
];
/**
* Regular expression to extract units from CSS values.
* Matches numeric values followed by a unit.
*/
const CSS_VALUE_WITH_UNIT_REGEX = /^(-?\d*\.?\d+)([a-zA-Z%]+)$/i;
/**
* Splits a CSS value string into individual parts, handling spaces not inside functions.
*/
const splitCssValues = (value: string): string[] => {
return value
.split(/(?<!\([^)]*)\s+/) // Split on spaces not inside functions
.map((part) => part.trim())
.filter((part) => part.length > 0);
};
/**
* Check if a CSS value contains a valid CSS unit.
*/
const checkCssUnit = (
value: string,
): { hasUnit: boolean; unit: string | null; isValid: boolean; invalidValue?: string } => {
const values = splitCssValues(value);
for (const value of values) {
// Skip values containing CSS functions
if (value.includes('(')) {
continue;
}
const match = value.match(CSS_VALUE_WITH_UNIT_REGEX);
if (!match) {
continue;
}
const unit = match[2]!.toLowerCase(); // match[2] is guaranteed by regex pattern
if (!VALID_CSS_UNITS.includes(unit)) {
return {
hasUnit: true,
unit: match[2]!, // Preserve original casing
isValid: false,
invalidValue: value,
};
}
}
return { hasUnit: false, unit: null, isValid: true };
};
/**
* Extracts string value from a node if it's a string literal or template literal.
*/
const getStringValue = (node: TSESTree.Node): string | null => {
if (node.type === AST_NODE_TYPES.Literal && typeof node.value === 'string') {
return node.value;
}
if (node.type === AST_NODE_TYPES.TemplateLiteral && node.quasis.length === 1) {
const firstQuasi = node.quasis[0];
return firstQuasi?.value.raw ? firstQuasi.value.raw : null;
}
return null;
};
/**
* Recursively processes a style object, reporting instances of
* unknown CSS units.
*
* @param context The ESLint rule context.
* @param node The ObjectExpression node representing the style object to be
* processed.
*/
export const processUnknownUnitInStyleObject = (context: Rule.RuleContext, node: TSESTree.ObjectExpression): void => {
// Defensive: This function is only called with ObjectExpression nodes by the rule visitor.
// This check's for type safety and future-proofing. It's not covered by rule tests
// because the rule architecture prevents non-ObjectExpression nodes from reaching here.
if (!node || node.type !== AST_NODE_TYPES.ObjectExpression) {
return;
}
for (const property of node.properties) {
if (property.type !== AST_NODE_TYPES.Property) {
continue;
}
// Get property key name if possible
let propertyName: string | null = null;
if (property.key.type === AST_NODE_TYPES.Identifier) {
propertyName = property.key.name;
} else if (property.key.type === AST_NODE_TYPES.Literal && typeof property.key.value === 'string') {
propertyName = property.key.value;
}
if (propertyName === '@media' || propertyName === 'selectors') {
if (property.value.type === AST_NODE_TYPES.ObjectExpression) {
for (const nestedProperty of property.value.properties) {
if (
nestedProperty.type === AST_NODE_TYPES.Property &&
nestedProperty.value.type === AST_NODE_TYPES.ObjectExpression
) {
processUnknownUnitInStyleObject(context, nestedProperty.value);
}
}
}
continue;
}
// Process direct string values
const value = getStringValue(property.value);
if (value) {
const result = checkCssUnit(value);
if (result.hasUnit && !result.isValid && result.invalidValue) {
context.report({
node: property.value as Rule.Node,
messageId: 'unknownUnit',
data: {
unit: result.unit || '',
value: result.invalidValue,
},
});
}
}
// Process nested objects (including those not handled by special cases)
if (property.value.type === AST_NODE_TYPES.ObjectExpression) {
processUnknownUnitInStyleObject(context, property.value);
}
}
};

View file

@ -0,0 +1,79 @@
import type { Rule } from 'eslint';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
import { processRecipeProperties } from '../shared-utils/recipe-property-processor.js';
import { ReferenceTracker, createReferenceTrackingVisitor } from '../shared-utils/reference-tracker.js';
import { processStyleNode } from '../shared-utils/style-node-processor.js';
import { processUnknownUnitInStyleObject } from './unknown-unit-processor.js';
/**
* Creates ESLint rule visitors for detecting and processing unknown CSS units
* in style-related function calls using reference tracking.
* This automatically detects vanilla-extract functions based on their import statements.
*/
export const createUnknownUnitVisitors = (context: Rule.RuleContext): Rule.RuleListener => {
const tracker = new ReferenceTracker();
const trackingVisitor = createReferenceTrackingVisitor(tracker);
return {
// Include the import/variable tracking visitors
...trackingVisitor,
CallExpression(node) {
if (node.callee.type !== AST_NODE_TYPES.Identifier) {
return;
}
const functionName = node.callee.name;
// Check if this function is tracked as a vanilla-extract function
if (!tracker.isTrackedFunction(functionName)) {
return;
}
const originalName = tracker.getOriginalName(functionName);
if (!originalName) {
return;
}
// Handle different function types based on their original imported name
switch (originalName) {
case 'fontFace':
if (node.arguments.length > 0 && node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression) {
processUnknownUnitInStyleObject(context, node.arguments[0] as TSESTree.ObjectExpression);
}
break;
case 'globalFontFace':
if (node.arguments.length > 1 && node.arguments[1]?.type === AST_NODE_TYPES.ObjectExpression) {
processUnknownUnitInStyleObject(context, node.arguments[1] as TSESTree.ObjectExpression);
}
break;
case 'style':
case 'styleVariants':
case 'keyframes':
if (node.arguments.length > 0) {
processStyleNode(context, node.arguments[0] as TSESTree.ObjectExpression, processUnknownUnitInStyleObject);
}
break;
case 'globalStyle':
case 'globalKeyframes':
if (node.arguments.length >= 2) {
processStyleNode(context, node.arguments[1] as TSESTree.ObjectExpression, processUnknownUnitInStyleObject);
}
break;
case 'recipe':
if (node.arguments.length > 0 && node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression) {
processRecipeProperties(
context,
node.arguments[0] as TSESTree.ObjectExpression,
processUnknownUnitInStyleObject,
);
}
break;
}
},
};
};

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: 'Zero values dont need a unit. Replace with "0".',
},
},
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,81 @@
import type { Rule } from 'eslint';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
import { processRecipeProperties } from '../shared-utils/recipe-property-processor.js';
import { ReferenceTracker, createReferenceTrackingVisitor } from '../shared-utils/reference-tracker.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.
* Uses reference tracking to automatically detect vanilla-extract functions based on their import statements.
*
* @param context The ESLint rule context.
* @returns An object with visitor functions for the ESLint rule.
*/
export const createZeroUnitVisitors = (context: Rule.RuleContext): Rule.RuleListener => {
const tracker = new ReferenceTracker();
const trackingVisitor = createReferenceTrackingVisitor(tracker);
return {
// Include the reference tracking visitors
...trackingVisitor,
CallExpression(node) {
if (node.callee.type !== AST_NODE_TYPES.Identifier) {
return;
}
const functionName = node.callee.name;
// Check if this function is tracked as a vanilla-extract function
if (!tracker.isTrackedFunction(functionName)) {
return;
}
const originalName = tracker.getOriginalName(functionName);
if (!originalName) {
return;
}
// Handle different function types based on their original imported name
switch (originalName) {
case 'fontFace':
if (node.arguments.length > 0 && node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression) {
processZeroUnitInStyleObject(context, node.arguments[0] as TSESTree.ObjectExpression);
}
break;
case 'globalFontFace':
if (node.arguments.length > 1 && node.arguments[1]?.type === AST_NODE_TYPES.ObjectExpression) {
processZeroUnitInStyleObject(context, node.arguments[1] as TSESTree.ObjectExpression);
}
break;
case 'style':
case 'styleVariants':
case 'keyframes':
if (node.arguments.length > 0) {
processStyleNode(context, node.arguments[0] as TSESTree.ObjectExpression, processZeroUnitInStyleObject);
}
break;
case 'globalStyle':
case 'globalKeyframes':
if (node.arguments.length >= 2) {
processStyleNode(context, node.arguments[1] as TSESTree.ObjectExpression, processZeroUnitInStyleObject);
}
break;
case 'recipe':
if (node.arguments.length > 0 && node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression) {
processRecipeProperties(
context,
node.arguments[0] as TSESTree.ObjectExpression,
processZeroUnitInStyleObject,
);
}
break;
}
},
};
};

View file

@ -0,0 +1,573 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import preferLogicalPropertiesRule from '../rule-definition.js';
run({
name: 'vanilla-extract/prefer-logical-properties',
rule: preferLogicalPropertiesRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
marginInlineStart: '1rem',
marginInlineEnd: '1rem',
marginBlockStart: '2rem',
marginBlockEnd: '2rem',
});
`,
name: 'allows logical properties in camelCase',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
'margin-inline-start': '1rem',
'margin-inline-end': '1rem',
'margin-block-start': '2rem',
'margin-block-end': '2rem',
});
`,
name: 'allows logical properties in kebab-case',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
insetInlineStart: 0,
insetInlineEnd: 0,
insetBlockStart: 0,
insetBlockEnd: 0,
});
`,
name: 'allows logical inset properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
borderInlineStartWidth: '1px',
borderInlineEndColor: 'red',
borderBlockStartStyle: 'solid',
});
`,
name: 'allows logical border properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
inlineSize: '100%',
blockSize: '50vh',
minInlineSize: '200px',
maxBlockSize: '800px',
});
`,
name: 'allows logical size properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
textAlign: 'start',
float: 'inline-start',
clear: 'inline-end',
});
`,
name: 'allows logical values for directional properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
marginLeft: '1rem',
paddingTop: '2rem',
top: 0,
left: 0,
});
`,
options: [{ allow: ['marginLeft', 'paddingTop', 'top', 'left'] }],
name: 'respects allowlist for camelCase properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
'margin-left': '1rem',
'padding-top': '2rem',
});
`,
options: [{ allow: ['margin-left', 'padding-top'] }],
name: 'respects allowlist for kebab-case properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
marginLeft: '1rem',
});
`,
options: [{ allow: ['margin-left'] }],
name: 'allowlist works with mixed case (kebab in config, camel in code)',
},
{
code: `
import { recipe } from '@vanilla-extract/css';
const myRecipe = recipe({
base: {
marginInlineStart: '1rem',
paddingBlockEnd: '2rem',
},
variants: {
size: {
sm: { insetInlineStart: 0 },
lg: { borderInlineEndWidth: '2px' },
},
},
});
`,
name: 'allows logical properties in recipe base and variants',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
'@media': {
'(min-width: 768px)': {
marginInlineStart: '2rem',
},
},
selectors: {
'&:hover': {
paddingBlockStart: '1rem',
},
},
});
`,
name: 'allows logical properties in nested @media and selectors',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
color: 'red',
display: 'flex',
fontSize: '16px',
});
`,
name: 'ignores non-directional properties',
},
],
invalid: [
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
marginTop: '1rem',
marginBottom: '2rem',
});
`,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
marginBlockStart: '1rem',
marginBlockEnd: '2rem',
});
`,
errors: [
{
messageId: 'preferLogicalProperty',
data: { physical: 'marginTop', logical: 'marginBlockStart' },
},
{
messageId: 'preferLogicalProperty',
data: { physical: 'marginBottom', logical: 'marginBlockEnd' },
},
],
name: 'reports and fixes margin properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
'padding-left': '1rem',
'padding-right': '2rem',
});
`,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
'padding-inline-start': '1rem',
'padding-inline-end': '2rem',
});
`,
errors: [
{
messageId: 'preferLogicalProperty',
data: { physical: 'padding-left', logical: 'padding-inline-start' },
},
{
messageId: 'preferLogicalProperty',
data: { physical: 'padding-right', logical: 'padding-inline-end' },
},
],
name: 'reports and fixes kebab-case padding properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
top: 0,
left: 0,
right: 0,
bottom: 0,
});
`,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
insetBlockStart: 0,
insetInlineStart: 0,
insetInlineEnd: 0,
insetBlockEnd: 0,
});
`,
errors: [
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
],
name: 'reports and fixes positioning properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
borderLeftWidth: '1px',
borderRightColor: 'red',
borderTopStyle: 'solid',
});
`,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
borderInlineStartWidth: '1px',
borderInlineEndColor: 'red',
borderBlockStartStyle: 'solid',
});
`,
errors: [
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
],
name: 'reports and fixes border sub-properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
borderLeft: '1px solid red',
borderRight: '2px dashed blue',
});
`,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
borderInlineStart: '1px solid red',
borderInlineEnd: '2px dashed blue',
});
`,
errors: [
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
],
name: 'reports and fixes border shorthand properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
borderTopLeftRadius: '4px',
borderBottomRightRadius: '8px',
});
`,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
borderStartStartRadius: '4px',
borderEndEndRadius: '8px',
});
`,
errors: [
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
],
name: 'reports and fixes border radius properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
width: '100px',
height: '200px',
minWidth: '50px',
maxHeight: '400px',
});
`,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
inlineSize: '100px',
blockSize: '200px',
minInlineSize: '50px',
maxBlockSize: '400px',
});
`,
errors: [
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
],
name: 'reports and fixes size properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
overflowX: 'auto',
overflowY: 'hidden',
});
`,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
overflowInline: 'auto',
overflowBlock: 'hidden',
});
`,
errors: [
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
],
name: 'reports and fixes overflow properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
textAlign: 'left',
float: 'right',
clear: 'left',
resize: 'horizontal',
});
`,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
textAlign: 'start',
float: 'inline-end',
clear: 'inline-start',
resize: 'inline',
});
`,
errors: [
{
messageId: 'preferLogicalValue',
data: { property: 'textAlign', physical: 'left', logical: 'start' },
},
{
messageId: 'preferLogicalValue',
data: { property: 'float', physical: 'right', logical: 'inline-end' },
},
{
messageId: 'preferLogicalValue',
data: { property: 'clear', physical: 'left', logical: 'inline-start' },
},
{
messageId: 'preferLogicalValue',
data: { property: 'resize', physical: 'horizontal', logical: 'inline' },
},
],
name: 'reports and fixes directional values',
},
{
code: `
import { recipe } from '@vanilla-extract/css';
const myRecipe = recipe({
base: {
marginLeft: '1rem',
paddingTop: '2rem',
},
variants: {
size: {
sm: { left: 0 },
lg: { borderRightWidth: '2px' },
},
},
});
`,
output: `
import { recipe } from '@vanilla-extract/css';
const myRecipe = recipe({
base: {
marginInlineStart: '1rem',
paddingBlockStart: '2rem',
},
variants: {
size: {
sm: { insetInlineStart: 0 },
lg: { borderInlineEndWidth: '2px' },
},
},
});
`,
errors: [
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
],
name: 'reports and fixes physical properties in recipe base and variants',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
'@media': {
'(min-width: 768px)': {
marginLeft: '2rem',
paddingTop: '1rem',
},
},
selectors: {
'&:hover': {
right: 0,
bottom: 0,
},
},
});
`,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
'@media': {
'(min-width: 768px)': {
marginInlineStart: '2rem',
paddingBlockStart: '1rem',
},
},
selectors: {
'&:hover': {
insetInlineEnd: 0,
insetBlockEnd: 0,
},
},
});
`,
errors: [
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
],
name: 'reports and fixes physical properties in nested @media and selectors',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
scrollMarginLeft: '10px',
scrollPaddingTop: '20px',
});
`,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
scrollMarginInlineStart: '10px',
scrollPaddingBlockStart: '20px',
});
`,
errors: [
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
],
name: 'reports and fixes scroll margin and padding properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
overscrollBehaviorX: 'contain',
overscrollBehaviorY: 'auto',
});
`,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
overscrollBehaviorInline: 'contain',
overscrollBehaviorBlock: 'auto',
});
`,
errors: [
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
],
name: 'reports and fixes overscroll behavior properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
marginLeft: \`1rem\`,
textAlign: \`left\`,
});
`,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
marginInlineStart: \`1rem\`,
textAlign: \`start\`,
});
`,
errors: [
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalValue' },
],
name: 'handles template literals',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
marginLeft: '1rem',
paddingTop: '2rem',
});
`,
options: [{ allow: ['paddingTop'] }],
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
marginInlineStart: '1rem',
paddingTop: '2rem',
});
`,
errors: [
{
messageId: 'preferLogicalProperty',
data: { physical: 'marginLeft', logical: 'marginInlineStart' },
},
],
name: 'only reports non-allowlisted properties',
},
],
});

View file

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

View file

@ -0,0 +1,252 @@
import type { Rule } from 'eslint';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
import {
isPhysicalProperty,
getLogicalProperty,
toKebabCase,
toCamelCase,
TEXT_ALIGN_PHYSICAL_VALUES,
FLOAT_PHYSICAL_VALUES,
CLEAR_PHYSICAL_VALUES,
VALUE_BASED_PHYSICAL_PROPERTIES,
} from './property-mappings.js';
export interface LogicalPropertiesOptions {
allow?: string[];
}
/**
* Get the text value from a node (string literal or simple template literal)
*/
const getValueText = (node: TSESTree.Node): string | null => {
if (node.type === AST_NODE_TYPES.Literal && typeof node.value === 'string') {
return node.value;
}
if (node.type === AST_NODE_TYPES.TemplateLiteral && node.expressions.length === 0) {
return node.quasis.map((quasi) => quasi.value.raw ?? '').join('');
}
return null;
};
/**
* Check if a node can be auto-fixed (literal or simple template literal)
*/
const canAutoFix = (node: TSESTree.Node): 'literal' | 'simple-template' | null => {
if (node.type === AST_NODE_TYPES.Literal && typeof node.value === 'string') {
return 'literal';
}
if (node.type === AST_NODE_TYPES.TemplateLiteral && node.expressions.length === 0) {
return 'simple-template';
}
return null;
};
/**
* Check if a property value contains physical directional values
*/
const hasPhysicalValue = (propertyName: string, value: string): { hasPhysical: boolean; fixedValue?: string } => {
const trimmedValue = value.trim().toLowerCase();
if (propertyName === 'text-align' || propertyName === 'textAlign') {
if (trimmedValue in TEXT_ALIGN_PHYSICAL_VALUES) {
return {
hasPhysical: true,
fixedValue: TEXT_ALIGN_PHYSICAL_VALUES[trimmedValue],
};
}
}
if (propertyName === 'float') {
if (trimmedValue in FLOAT_PHYSICAL_VALUES) {
return {
hasPhysical: true,
fixedValue: FLOAT_PHYSICAL_VALUES[trimmedValue],
};
}
}
if (propertyName === 'clear') {
if (trimmedValue in CLEAR_PHYSICAL_VALUES) {
return {
hasPhysical: true,
fixedValue: CLEAR_PHYSICAL_VALUES[trimmedValue],
};
}
}
if (propertyName === 'resize') {
if (trimmedValue === 'horizontal' || trimmedValue === 'vertical') {
const fixedValue = trimmedValue === 'horizontal' ? 'inline' : 'block';
return { hasPhysical: true, fixedValue };
}
}
return { hasPhysical: false };
};
/**
* Normalize property name to both camelCase and kebab-case for checking
*/
const normalizePropertyName = (name: string): { camel: string; kebab: string } => {
const kebab = toKebabCase(name);
const camel = toCamelCase(name);
return { camel, kebab };
};
/**
* Check if a property is in the allow list
*/
const isAllowed = (propertyName: string, allowSet: Set<string>): boolean => {
const { camel, kebab } = normalizePropertyName(propertyName);
return allowSet.has(propertyName) || allowSet.has(camel) || allowSet.has(kebab);
};
/**
* Get the appropriate logical property name based on the original format
*/
const getLogicalPropertyInFormat = (originalName: string, logicalName: string): string => {
// If original is kebab-case (contains hyphen), return kebab-case
if (originalName.includes('-')) {
return toKebabCase(logicalName);
}
// Otherwise return camelCase
return toCamelCase(logicalName);
};
/**
* Create a fix for replacing a property key
*/
const createPropertyKeyFix = (
fixer: Rule.RuleFixer,
property: TSESTree.Property,
newPropertyName: string,
context: Rule.RuleContext,
): Rule.Fix | null => {
const key = property.key;
if (key.type === AST_NODE_TYPES.Identifier) {
return fixer.replaceText(key as unknown as Rule.Node, newPropertyName);
}
if (key.type === AST_NODE_TYPES.Literal && typeof key.value === 'string') {
// Preserve quote style
const sourceCode = context.getSourceCode();
const originalText = sourceCode.getText(key as unknown as Rule.Node);
const quote = originalText[0];
return fixer.replaceText(key as unknown as Rule.Node, `${quote}${newPropertyName}${quote}`);
}
return null;
};
/**
* Create a fix for replacing a property value
*/
const createPropertyValueFix = (
fixer: Rule.RuleFixer,
valueNode: TSESTree.Node,
newValue: string,
fixType: 'literal' | 'simple-template',
): Rule.Fix => {
if (fixType === 'literal') {
return fixer.replaceText(valueNode as unknown as Rule.Node, `'${newValue}'`);
}
// simple-template
return fixer.replaceText(valueNode as unknown as Rule.Node, `\`${newValue}\``);
};
/**
* Recursively processes a vanilla-extract style object and reports physical CSS properties.
*
* - Detects physical property names and suggests logical equivalents
* - Detects physical directional values (e.g., text-align: left)
* - Skips properties in the allow list
* - Provides auto-fixes where unambiguous
* - Traverses nested objects, @media, and selectors
*
* @param context ESLint rule context
* @param node The ObjectExpression node representing the style object
* @param allowSet Set of property names to skip
*/
export const processLogicalPropertiesInStyleObject = (
context: Rule.RuleContext,
node: TSESTree.ObjectExpression,
allowSet: Set<string>,
): void => {
for (const property of node.properties) {
if (property.type !== AST_NODE_TYPES.Property) continue;
// Determine property name
let propertyName: string | null = null;
if (property.key.type === AST_NODE_TYPES.Identifier) {
propertyName = property.key.name;
} else if (property.key.type === AST_NODE_TYPES.Literal && typeof property.key.value === 'string') {
propertyName = property.key.value;
}
if (!propertyName) continue;
// Handle nested containers (@media, selectors, etc.)
if (propertyName === '@media' || propertyName === 'selectors') {
if (property.value.type === AST_NODE_TYPES.ObjectExpression) {
for (const nested of property.value.properties) {
if (nested.type === AST_NODE_TYPES.Property && nested.value.type === AST_NODE_TYPES.ObjectExpression) {
processLogicalPropertiesInStyleObject(context, nested.value, allowSet);
}
}
}
continue;
}
// Recurse into nested objects
if (property.value.type === AST_NODE_TYPES.ObjectExpression) {
processLogicalPropertiesInStyleObject(context, property.value, allowSet);
continue;
}
// Skip if property is in allow list
if (isAllowed(propertyName, allowSet)) {
continue;
}
// Check for physical property names
if (isPhysicalProperty(propertyName)) {
const logicalProp = getLogicalProperty(propertyName);
if (logicalProp) {
const logicalInFormat = getLogicalPropertyInFormat(propertyName, logicalProp);
context.report({
node: property.key as unknown as Rule.Node,
messageId: 'preferLogicalProperty',
data: {
physical: propertyName,
logical: logicalInFormat,
},
fix: (fixer) => createPropertyKeyFix(fixer, property, logicalInFormat, context),
});
}
continue;
}
// Check for value-based physical properties
if (VALUE_BASED_PHYSICAL_PROPERTIES.has(propertyName)) {
const valueText = getValueText(property.value);
if (valueText) {
const { hasPhysical, fixedValue } = hasPhysicalValue(propertyName, valueText);
if (hasPhysical && fixedValue) {
const fixType = canAutoFix(property.value);
context.report({
node: property.value as unknown as Rule.Node,
messageId: 'preferLogicalValue',
data: {
property: propertyName,
physical: valueText.trim(),
logical: fixedValue,
},
fix: fixType ? (fixer) => createPropertyValueFix(fixer, property.value, fixedValue, fixType) : undefined,
});
}
}
}
}
};

View file

@ -0,0 +1,76 @@
import type { Rule } from 'eslint';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
import { processRecipeProperties } from '../shared-utils/recipe-property-processor.js';
import { ReferenceTracker, createReferenceTrackingVisitor } from '../shared-utils/reference-tracker.js';
import { processStyleNode } from '../shared-utils/style-node-processor.js';
import { processLogicalPropertiesInStyleObject, type LogicalPropertiesOptions } from './logical-properties-processor.js';
/**
* Creates ESLint rule visitors for detecting and reporting physical CSS properties
* in vanilla-extract style objects.
*
* - Tracks calls to vanilla-extract APIs (style, recipe, keyframes, globalStyle, fontFace, etc.)
* - Detects physical property names and directional values
* - Respects the `allow` option for allowlisting properties
* - Provides auto-fixes for unambiguous conversions
*
* @param context ESLint rule context used to read options and report diagnostics
* @returns Rule listener that inspects vanilla-extract call expressions and processes style objects
*/
export const createLogicalPropertiesVisitors = (context: Rule.RuleContext): Rule.RuleListener => {
const tracker = new ReferenceTracker();
const trackingVisitor = createReferenceTrackingVisitor(tracker);
const options = (context.options?.[0] as LogicalPropertiesOptions | undefined) || {};
const allowSet = new Set((options.allow ?? []).map((prop) => prop));
const process = (context: Rule.RuleContext, object: TSESTree.ObjectExpression) =>
processLogicalPropertiesInStyleObject(context, object, allowSet);
return {
...trackingVisitor,
CallExpression(node) {
if (node.callee.type !== AST_NODE_TYPES.Identifier) return;
const functionName = node.callee.name;
if (!tracker.isTrackedFunction(functionName)) return;
const originalName = tracker.getOriginalName(functionName);
if (!originalName) return;
switch (originalName) {
case 'fontFace':
if (node.arguments.length > 0 && node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression) {
process(context, node.arguments[0] as TSESTree.ObjectExpression);
}
break;
case 'globalFontFace':
if (node.arguments.length > 1 && node.arguments[1]?.type === AST_NODE_TYPES.ObjectExpression) {
process(context, node.arguments[1] as TSESTree.ObjectExpression);
}
break;
case 'style':
case 'styleVariants':
case 'keyframes':
if (node.arguments.length > 0) {
processStyleNode(context, node.arguments[0] as TSESTree.Node, (context, object) => process(context, object));
}
break;
case 'globalStyle':
case 'globalKeyframes':
if (node.arguments.length >= 2) {
processStyleNode(context, node.arguments[1] as TSESTree.Node, (context, object) => process(context, object));
}
break;
case 'recipe':
if (node.arguments.length > 0 && node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression) {
processRecipeProperties(context, node.arguments[0] as TSESTree.ObjectExpression, (context, object) =>
process(context, object),
);
}
break;
}
},
};
};

View file

@ -0,0 +1,205 @@
/**
* Mapping of physical CSS properties to their logical equivalents.
* Includes margin, padding, border, inset, and positioning properties.
*/
export interface PropertyMapping {
logical: string;
description?: string;
}
/**
* Direct physical logical property mappings
*/
export const PHYSICAL_TO_LOGICAL: Record<string, PropertyMapping> = {
// Margin properties
'margin-left': { logical: 'margin-inline-start' },
'margin-right': { logical: 'margin-inline-end' },
'margin-top': { logical: 'margin-block-start' },
'margin-bottom': { logical: 'margin-block-end' },
marginLeft: { logical: 'marginInlineStart' },
marginRight: { logical: 'marginInlineEnd' },
marginTop: { logical: 'marginBlockStart' },
marginBottom: { logical: 'marginBlockEnd' },
// Padding properties
'padding-left': { logical: 'padding-inline-start' },
'padding-right': { logical: 'padding-inline-end' },
'padding-top': { logical: 'padding-block-start' },
'padding-bottom': { logical: 'padding-block-end' },
paddingLeft: { logical: 'paddingInlineStart' },
paddingRight: { logical: 'paddingInlineEnd' },
paddingTop: { logical: 'paddingBlockStart' },
paddingBottom: { logical: 'paddingBlockEnd' },
// Border width properties
'border-left-width': { logical: 'border-inline-start-width' },
'border-right-width': { logical: 'border-inline-end-width' },
'border-top-width': { logical: 'border-block-start-width' },
'border-bottom-width': { logical: 'border-block-end-width' },
borderLeftWidth: { logical: 'borderInlineStartWidth' },
borderRightWidth: { logical: 'borderInlineEndWidth' },
borderTopWidth: { logical: 'borderBlockStartWidth' },
borderBottomWidth: { logical: 'borderBlockEndWidth' },
// Border style properties
'border-left-style': { logical: 'border-inline-start-style' },
'border-right-style': { logical: 'border-inline-end-style' },
'border-top-style': { logical: 'border-block-start-style' },
'border-bottom-style': { logical: 'border-block-end-style' },
borderLeftStyle: { logical: 'borderInlineStartStyle' },
borderRightStyle: { logical: 'borderInlineEndStyle' },
borderTopStyle: { logical: 'borderBlockStartStyle' },
borderBottomStyle: { logical: 'borderBlockEndStyle' },
// Border color properties
'border-left-color': { logical: 'border-inline-start-color' },
'border-right-color': { logical: 'border-inline-end-color' },
'border-top-color': { logical: 'border-block-start-color' },
'border-bottom-color': { logical: 'border-block-end-color' },
borderLeftColor: { logical: 'borderInlineStartColor' },
borderRightColor: { logical: 'borderInlineEndColor' },
borderTopColor: { logical: 'borderBlockStartColor' },
borderBottomColor: { logical: 'borderBlockEndColor' },
// Border shorthand properties
'border-left': { logical: 'border-inline-start' },
'border-right': { logical: 'border-inline-end' },
'border-top': { logical: 'border-block-start' },
'border-bottom': { logical: 'border-block-end' },
borderLeft: { logical: 'borderInlineStart' },
borderRight: { logical: 'borderInlineEnd' },
borderTop: { logical: 'borderBlockStart' },
borderBottom: { logical: 'borderBlockEnd' },
// Border radius properties
'border-top-left-radius': { logical: 'border-start-start-radius' },
'border-top-right-radius': { logical: 'border-start-end-radius' },
'border-bottom-left-radius': { logical: 'border-end-start-radius' },
'border-bottom-right-radius': { logical: 'border-end-end-radius' },
borderTopLeftRadius: { logical: 'borderStartStartRadius' },
borderTopRightRadius: { logical: 'borderStartEndRadius' },
borderBottomLeftRadius: { logical: 'borderEndStartRadius' },
borderBottomRightRadius: { logical: 'borderEndEndRadius' },
// Inset properties
left: { logical: 'inset-inline-start' },
right: { logical: 'inset-inline-end' },
top: { logical: 'inset-block-start' },
bottom: { logical: 'inset-block-end' },
'inset-left': { logical: 'inset-inline-start' },
'inset-right': { logical: 'inset-inline-end' },
'inset-top': { logical: 'inset-block-start' },
'inset-bottom': { logical: 'inset-block-end' },
insetLeft: { logical: 'insetInlineStart' },
insetRight: { logical: 'insetInlineEnd' },
insetTop: { logical: 'insetBlockStart' },
insetBottom: { logical: 'insetBlockEnd' },
// Overflow properties
'overflow-x': { logical: 'overflow-inline' },
'overflow-y': { logical: 'overflow-block' },
overflowX: { logical: 'overflowInline' },
overflowY: { logical: 'overflowBlock' },
// Overscroll properties
'overscroll-behavior-x': { logical: 'overscroll-behavior-inline' },
'overscroll-behavior-y': { logical: 'overscroll-behavior-block' },
overscrollBehaviorX: { logical: 'overscrollBehaviorInline' },
overscrollBehaviorY: { logical: 'overscrollBehaviorBlock' },
// Scroll margin properties
'scroll-margin-left': { logical: 'scroll-margin-inline-start' },
'scroll-margin-right': { logical: 'scroll-margin-inline-end' },
'scroll-margin-top': { logical: 'scroll-margin-block-start' },
'scroll-margin-bottom': { logical: 'scroll-margin-block-end' },
scrollMarginLeft: { logical: 'scrollMarginInlineStart' },
scrollMarginRight: { logical: 'scrollMarginInlineEnd' },
scrollMarginTop: { logical: 'scrollMarginBlockStart' },
scrollMarginBottom: { logical: 'scrollMarginBlockEnd' },
// Scroll padding properties
'scroll-padding-left': { logical: 'scroll-padding-inline-start' },
'scroll-padding-right': { logical: 'scroll-padding-inline-end' },
'scroll-padding-top': { logical: 'scroll-padding-block-start' },
'scroll-padding-bottom': { logical: 'scroll-padding-block-end' },
scrollPaddingLeft: { logical: 'scrollPaddingInlineStart' },
scrollPaddingRight: { logical: 'scrollPaddingInlineEnd' },
scrollPaddingTop: { logical: 'scrollPaddingBlockStart' },
scrollPaddingBottom: { logical: 'scrollPaddingBlockEnd' },
// Size properties
width: { logical: 'inline-size' },
height: { logical: 'block-size' },
'min-width': { logical: 'min-inline-size' },
'min-height': { logical: 'min-block-size' },
'max-width': { logical: 'max-inline-size' },
'max-height': { logical: 'max-block-size' },
minWidth: { logical: 'minInlineSize' },
minHeight: { logical: 'minBlockSize' },
maxWidth: { logical: 'maxInlineSize' },
maxHeight: { logical: 'maxBlockSize' },
};
/**
* Text-align directional values that should be replaced
*/
export const TEXT_ALIGN_PHYSICAL_VALUES: Record<string, string> = {
left: 'start',
right: 'end',
};
/**
* Float directional values that should be replaced
*/
export const FLOAT_PHYSICAL_VALUES: Record<string, string> = {
left: 'inline-start',
right: 'inline-end',
};
/**
* Clear directional values that should be replaced
*/
export const CLEAR_PHYSICAL_VALUES: Record<string, string> = {
left: 'inline-start',
right: 'inline-end',
};
/**
* Properties where the value (not the property name) needs to be checked for physical directions
*/
export const VALUE_BASED_PHYSICAL_PROPERTIES = new Set([
'text-align',
'textAlign',
'float',
'clear',
'resize',
]);
/**
* Check if a property name is a physical property that should be converted
*/
export function isPhysicalProperty(propertyName: string): boolean {
return propertyName in PHYSICAL_TO_LOGICAL;
}
/**
* Get the logical equivalent of a physical property
*/
export function getLogicalProperty(propertyName: string): string | null {
return PHYSICAL_TO_LOGICAL[propertyName]?.logical ?? null;
}
/**
* Convert camelCase to kebab-case
*/
export function toKebabCase(name: string): string {
return name.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
}
/**
* Convert kebab-case to camelCase
*/
export function toCamelCase(name: string): string {
return name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
}

View file

@ -0,0 +1,40 @@
import type { Rule } from 'eslint';
import { createLogicalPropertiesVisitors } from './logical-properties-visitor-creator.js';
const preferLogicalPropertiesRule: Rule.RuleModule = {
meta: {
type: 'suggestion',
docs: {
description: 'enforce logical CSS properties over physical directional properties in vanilla-extract',
category: 'Best Practices',
recommended: false,
},
fixable: 'code',
schema: [
{
type: 'object',
properties: {
allow: {
type: 'array',
items: { type: 'string' },
uniqueItems: true,
default: [],
description: 'List of physical properties to allow (supports both camelCase and kebab-case)',
},
},
additionalProperties: false,
},
],
messages: {
preferLogicalProperty:
'Prefer logical CSS property "{{ logical }}" over physical property "{{ physical }}". Logical properties adapt to writing direction.',
preferLogicalValue:
'Prefer logical value "{{ logical }}" over physical value "{{ physical }}" for property "{{ property }}". Logical values adapt to writing direction.',
},
},
create(context) {
return createLogicalPropertiesVisitors(context);
},
};
export default preferLogicalPropertiesRule;

Some files were not shown because too many files have changed in this diff Show more