mirror of
https://github.com/antebudimir/eslint-plugin-vanilla-extract.git
synced 2025-12-31 08:53:33 +00:00
Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
| 62b1844b44 |
10 changed files with 1059 additions and 7 deletions
|
|
@ -5,6 +5,14 @@ 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
|
||||
|
|
|
|||
77
README.md
77
README.md
|
|
@ -264,6 +264,7 @@ The recommended configuration enables the following rules with error severity:
|
|||
- `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)
|
||||
|
||||
|
|
@ -536,6 +537,77 @@ export const myStyle = style({
|
|||
});
|
||||
```
|
||||
|
||||
### 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
|
||||
|
|
@ -835,10 +907,7 @@ The roadmap outlines the project's current status and future plans:
|
|||
- `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).
|
||||
|
||||
### Current Work
|
||||
|
||||
- `no-unitless-values` rule that disallows numeric literals for CSS properties that are not unitless in CSS.
|
||||
- `no-unitless-values` rule to disallow unitless numeric values for CSS properties that require units.
|
||||
|
||||
### Upcoming Features
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@antebudimir/eslint-plugin-vanilla-extract",
|
||||
"version": "1.15.1",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -1 +1,3 @@
|
|||
export { default } from './rule-definition.js';
|
||||
import noTrailingZeroRule from './rule-definition.js';
|
||||
|
||||
export default noTrailingZeroRule;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
});
|
||||
3
src/css-rules/no-unitless-values/index.ts
Normal file
3
src/css-rules/no-unitless-values/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import noUnitlessValuesRule from './rule-definition.js';
|
||||
|
||||
export default noUnitlessValuesRule;
|
||||
40
src/css-rules/no-unitless-values/rule-definition.ts
Normal file
40
src/css-rules/no-unitless-values/rule-definition.ts
Normal 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;
|
||||
371
src/css-rules/no-unitless-values/unitless-value-processor.ts
Normal file
371
src/css-rules/no-unitless-values/unitless-value-processor.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -4,6 +4,7 @@ import customOrderRule from './css-rules/custom-order/rule-definition.js';
|
|||
import noEmptyStyleBlocksRule from './css-rules/no-empty-blocks/rule-definition.js';
|
||||
import noPxUnitRule from './css-rules/no-px-unit/index.js';
|
||||
import noTrailingZeroRule from './css-rules/no-trailing-zero/rule-definition.js';
|
||||
import noUnitlessValuesRule from './css-rules/no-unitless-values/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 preferLogicalPropertiesRule from './css-rules/prefer-logical-properties/index.js';
|
||||
|
|
@ -12,7 +13,7 @@ import preferThemeTokensRule from './css-rules/prefer-theme-tokens/index.js';
|
|||
const vanillaExtract = {
|
||||
meta: {
|
||||
name: '@antebudimir/eslint-plugin-vanilla-extract',
|
||||
version: '1.15.1',
|
||||
version: '1.16.0',
|
||||
},
|
||||
rules: {
|
||||
'alphabetical-order': alphabeticalOrderRule,
|
||||
|
|
@ -21,6 +22,7 @@ const vanillaExtract = {
|
|||
'no-empty-style-blocks': noEmptyStyleBlocksRule,
|
||||
'no-px-unit': noPxUnitRule,
|
||||
'no-trailing-zero': noTrailingZeroRule,
|
||||
'no-unitless-values': noUnitlessValuesRule,
|
||||
'no-unknown-unit': noUnknownUnitRule,
|
||||
'no-zero-unit': noZeroUnitRule,
|
||||
'prefer-logical-properties': preferLogicalPropertiesRule,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue