diff --git a/CHANGELOG.md b/CHANGELOG.md index 68dc0d4..42344f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 + - 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 diff --git a/README.md b/README.md index 8e1ff4c..185724f 100644 --- a/README.md +++ b/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 diff --git a/package.json b/package.json index 648e93a..b8fbded 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/css-rules/no-trailing-zero/index.ts b/src/css-rules/no-trailing-zero/index.ts index 58ee791..25d6e11 100644 --- a/src/css-rules/no-trailing-zero/index.ts +++ b/src/css-rules/no-trailing-zero/index.ts @@ -1 +1,3 @@ -export { default } from './rule-definition.js'; +import noTrailingZeroRule from './rule-definition.js'; + +export default noTrailingZeroRule; diff --git a/src/css-rules/no-unitless-values/_tests_/no-unitless-values.test.ts b/src/css-rules/no-unitless-values/_tests_/no-unitless-values.test.ts new file mode 100644 index 0000000..2f45564 --- /dev/null +++ b/src/css-rules/no-unitless-values/_tests_/no-unitless-values.test.ts @@ -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', + }, + ], +}); diff --git a/src/css-rules/no-unitless-values/index.ts b/src/css-rules/no-unitless-values/index.ts new file mode 100644 index 0000000..235a28d --- /dev/null +++ b/src/css-rules/no-unitless-values/index.ts @@ -0,0 +1,3 @@ +import noUnitlessValuesRule from './rule-definition.js'; + +export default noUnitlessValuesRule; diff --git a/src/css-rules/no-unitless-values/rule-definition.ts b/src/css-rules/no-unitless-values/rule-definition.ts new file mode 100644 index 0000000..032efd1 --- /dev/null +++ b/src/css-rules/no-unitless-values/rule-definition.ts @@ -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; diff --git a/src/css-rules/no-unitless-values/unitless-value-processor.ts b/src/css-rules/no-unitless-values/unitless-value-processor.ts new file mode 100644 index 0000000..06055ed --- /dev/null +++ b/src/css-rules/no-unitless-values/unitless-value-processor.ts @@ -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); + } + }); +}; diff --git a/src/css-rules/no-unitless-values/unitless-value-visitor-creator.ts b/src/css-rules/no-unitless-values/unitless-value-visitor-creator.ts new file mode 100644 index 0000000..36b75fb --- /dev/null +++ b/src/css-rules/no-unitless-values/unitless-value-visitor-creator.ts @@ -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; + } + }, + }; +}; diff --git a/src/index.ts b/src/index.ts index 66bc0cf..743ddaa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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,