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
This commit is contained in:
Ante Budimir 2025-12-01 18:53:50 +02:00
parent 7261c78a42
commit 62b1844b44
10 changed files with 1059 additions and 7 deletions

View file

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

View file

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

View file

@ -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",

View file

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

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

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