mirror of
https://github.com/antebudimir/eslint-plugin-vanilla-extract.git
synced 2025-12-31 08:53:33 +00:00
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
This commit is contained in:
parent
24681ebad9
commit
9263c5dd24
9 changed files with 909 additions and 5 deletions
|
|
@ -5,6 +5,13 @@ 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
|
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).
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [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
|
## [1.11.1] - 2025-10-15
|
||||||
|
|
||||||
- Improve README structure and clarity
|
- Improve README structure and clarity
|
||||||
|
|
|
||||||
38
README.md
38
README.md
|
|
@ -259,6 +259,7 @@ The recommended configuration enables the following rules with error severity:
|
||||||
|
|
||||||
- `vanilla-extract/concentric-order`: Enforces concentric CSS property ordering
|
- `vanilla-extract/concentric-order`: Enforces concentric CSS property ordering
|
||||||
- `vanilla-extract/no-empty-style-blocks`: Prevents empty style blocks
|
- `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-unknown-unit`: Prohibits usage of unrecognized CSS units
|
||||||
- `vanilla-extract/no-zero-unit`: Removes unnecessary units for zero values
|
- `vanilla-extract/no-zero-unit`: Removes unnecessary units for zero values
|
||||||
|
|
||||||
|
|
@ -464,6 +465,37 @@ export const recipeWithEmptyVariants = recipe({
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 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-unknown-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
|
This rule enforces the use of valid CSS units in vanilla-extract style objects. It prevents typos and non-standard units
|
||||||
|
|
@ -612,17 +644,17 @@ The roadmap outlines the project's current status and future plans:
|
||||||
- Recommended ESLint configuration for the plugin.
|
- Recommended ESLint configuration for the plugin.
|
||||||
- `no-zero-unit` rule to disallow units when the value is zero.
|
- `no-zero-unit` rule to disallow units when the value is zero.
|
||||||
- `no-unknown-unit` rule to disallow unknown units.
|
- `no-unknown-unit` rule to disallow unknown units.
|
||||||
- Support for using the plugin’s recommended config via the extends field (as discussed in
|
- `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))
|
[issue #3](https://github.com/antebudimir/eslint-plugin-vanilla-extract/issues/3))
|
||||||
- Comprehensive rule testing.
|
- Comprehensive rule testing.
|
||||||
|
|
||||||
### Current Work
|
### Current Work
|
||||||
|
|
||||||
- `no-number-trailing-zero` rule to disallow trailing zeros in numbers.
|
- `no-px-unit` rule to disallow use of `px` units with configurable whitelist.
|
||||||
|
|
||||||
### Upcoming Features
|
### Upcoming Features
|
||||||
|
|
||||||
- `no-px-unit` rule to disallow use of `px` units with configurable whitelist.
|
|
||||||
- `prefer-logical-properties` rule to enforce use of logical properties.
|
- `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.
|
- `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.
|
- `no-global-style` rule to disallow use of `globalStyle` function.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@antebudimir/eslint-plugin-vanilla-extract",
|
"name": "@antebudimir/eslint-plugin-vanilla-extract",
|
||||||
"version": "1.11.1",
|
"version": "1.12.0",
|
||||||
"description": "ESLint plugin for enforcing best practices in vanilla-extract CSS styles, including CSS property ordering and additional linting rules.",
|
"description": "ESLint plugin for enforcing best practices in vanilla-extract CSS styles, including CSS property ordering and additional linting rules.",
|
||||||
"author": "Ante Budimir",
|
"author": "Ante Budimir",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
|
||||||
538
src/css-rules/no-trailing-zero/_tests_/no-trailing-zero.test.ts
Normal file
538
src/css-rules/no-trailing-zero/_tests_/no-trailing-zero.test.ts
Normal 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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
1
src/css-rules/no-trailing-zero/index.ts
Normal file
1
src/css-rules/no-trailing-zero/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from './rule-definition.js';
|
||||||
23
src/css-rules/no-trailing-zero/rule-definition.ts
Normal file
23
src/css-rules/no-trailing-zero/rule-definition.ts
Normal 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;
|
||||||
197
src/css-rules/no-trailing-zero/trailing-zero-processor.ts
Normal file
197
src/css-rules/no-trailing-zero/trailing-zero-processor.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
103
src/css-rules/no-trailing-zero/trailing-zero-visitor-creator.ts
Normal file
103
src/css-rules/no-trailing-zero/trailing-zero-visitor-creator.ts
Normal 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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -2,19 +2,21 @@ import alphabeticalOrderRule from './css-rules/alphabetical-order/index.js';
|
||||||
import concentricOrderRule from './css-rules/concentric-order/index.js';
|
import concentricOrderRule from './css-rules/concentric-order/index.js';
|
||||||
import customOrderRule from './css-rules/custom-order/rule-definition.js';
|
import customOrderRule from './css-rules/custom-order/rule-definition.js';
|
||||||
import noEmptyStyleBlocksRule from './css-rules/no-empty-blocks/rule-definition.js';
|
import noEmptyStyleBlocksRule from './css-rules/no-empty-blocks/rule-definition.js';
|
||||||
|
import noTrailingZeroRule from './css-rules/no-trailing-zero/rule-definition.js';
|
||||||
import noUnknownUnitRule from './css-rules/no-unknown-unit/rule-definition.js';
|
import noUnknownUnitRule from './css-rules/no-unknown-unit/rule-definition.js';
|
||||||
import noZeroUnitRule from './css-rules/no-zero-unit/rule-definition.js';
|
import noZeroUnitRule from './css-rules/no-zero-unit/rule-definition.js';
|
||||||
|
|
||||||
const vanillaExtract = {
|
const vanillaExtract = {
|
||||||
meta: {
|
meta: {
|
||||||
name: '@antebudimir/eslint-plugin-vanilla-extract',
|
name: '@antebudimir/eslint-plugin-vanilla-extract',
|
||||||
version: '1.11.1',
|
version: '1.12.0',
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
'alphabetical-order': alphabeticalOrderRule,
|
'alphabetical-order': alphabeticalOrderRule,
|
||||||
'concentric-order': concentricOrderRule,
|
'concentric-order': concentricOrderRule,
|
||||||
'custom-order': customOrderRule,
|
'custom-order': customOrderRule,
|
||||||
'no-empty-style-blocks': noEmptyStyleBlocksRule,
|
'no-empty-style-blocks': noEmptyStyleBlocksRule,
|
||||||
|
'no-trailing-zero': noTrailingZeroRule,
|
||||||
'no-unknown-unit': noUnknownUnitRule,
|
'no-unknown-unit': noUnknownUnitRule,
|
||||||
'no-zero-unit': noZeroUnitRule,
|
'no-zero-unit': noZeroUnitRule,
|
||||||
},
|
},
|
||||||
|
|
@ -29,6 +31,7 @@ Object.assign(vanillaExtract.configs, {
|
||||||
rules: {
|
rules: {
|
||||||
'vanilla-extract/concentric-order': 'error',
|
'vanilla-extract/concentric-order': 'error',
|
||||||
'vanilla-extract/no-empty-style-blocks': 'error',
|
'vanilla-extract/no-empty-style-blocks': 'error',
|
||||||
|
'vanilla-extract/no-trailing-zero': 'error',
|
||||||
'vanilla-extract/no-unknown-unit': 'error',
|
'vanilla-extract/no-unknown-unit': 'error',
|
||||||
'vanilla-extract/no-zero-unit': 'error',
|
'vanilla-extract/no-zero-unit': 'error',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue