feat 🥁: add no-unknown-unit rule

Adds a rule to disallow unknown or invalid CSS units in vanilla-extract style objects.

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

No autofix is provided because replacing or removing unknown units may result in unintended or invalid CSS. Manual developer review is required to ensure correctness.
This commit is contained in:
Ante Budimir 2025-04-16 09:43:06 +03:00
parent 7dc7204749
commit f880c051ff
11 changed files with 623 additions and 5 deletions

View file

@ -5,6 +5,15 @@ 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.9.0] - 2025-04-16
- add new rule `no-unknown-unit` that disallows unknown or invalid CSS units in vanilla-extract style objects.
- Reports any usage of unrecognized units in property values
- Handles all vanilla-extract APIs, including style, recipe, fontFace, and keyframes
- Ignores valid units in special contexts (e.g., CSS functions, custom properties)
- Supports nested objects, media queries, and pseudo-selectors
- No autofix is provided because replacing or removing unknown units may result in unintended or invalid CSS; manual developer review is required
## [1.8.0] - 2025-04-12
- add new rule `no-zero-unit` that enforces unitless zero values in vanilla-extract style objects

View file

@ -81,6 +81,7 @@ The recommended configuration enables the following rules with error severity:
- `vanilla-extract/concentric-order`: Enforces concentric CSS property ordering
- `vanilla-extract/no-empty-style-blocks`: Prevents empty style blocks
- `vanilla-extract/no-unknown-unit`: prohibits usage of unrecognized CSS units.
- `vanilla-extract/no-zero-unit`: removes unnecessary units for zero values
You can use the recommended configuration as a starting point and override rules as needed for your project.
@ -110,6 +111,7 @@ export default [
sortRemainingProperties: 'concentric', // 'alphabetical' is default
},
],
'vanilla-extract/no-unknown-unit': 'error',
'vanilla-extract/no-zero-unit': 'warn',
},
},
@ -269,6 +271,44 @@ export const recipeWithEmptyVariants = recipe({
});
```
## vanilla-extract/no-unknown-unit
This rule enforces the use of valid CSS units in vanilla-extract style objects. It prevents typos and non-standard units that could cause styling issues or browser compatibility problems.
```typescript
// ❌ Incorrect
import { style, globalStyle, recipe } from '@vanilla-extract/css';
export const invalidStyle = style({
margin: '5abc', // Non-existent unit
fontSize: '1.5rems', // Typo in unit
});
export const myRecipe = recipe({
variants: {
size: {
large: { padding: '4xm' } // Invalid unit
}
}
});
// ✅ Correct
import { style, globalStyle, recipe } from '@vanilla-extract/css';
export const validStyle = style({
margin: '5rem',
fontSize: '1.5rem',
});
export const myRecipe = recipe({
variants: {
size: {
large: { padding: '4em' }
}
}
});
```
## vanilla-extract/no-zero-unit
This rule enforces the removal of unnecessary units for zero values in vanilla-extract style objects. It helps maintain cleaner and more consistent CSS by eliminating redundant units when the value is zero.
@ -373,11 +413,12 @@ The roadmap outlines the project's current status and future plans:
- `no-empty-style-blocks` rule to disallow empty blocks.
- Recommended ESLint configuration for the plugin.
- `no-zero-unit` rule to disallow units when the value is zero.
- `no-unknown-unit` rule to disallow unknown units.
- Comprehensive rule testing.
### Current Work
- `no-unknown-unit` rule to disallow unknown units.
- Support for using the plugins recommended config via the extends field (as discussed in [issue #3](https://github.com/antebudimir/eslint-plugin-vanilla-extract/issues/3))
### Upcoming Features
@ -386,6 +427,7 @@ The roadmap outlines the project's current status and future plans:
- `prefer-logical-properties` rule to enforce use of logical properties.
- `prefer-theme-tokens` rule to enforce use of theme tokens instead of hard-coded values when available.
- `no-global-style` rule to disallow use of `globalStyle` function.
- `property-unit-match` rule to enforce valid units per CSS property specs. **Note**: This feature will only be implemented if there's sufficient interest from the community.
- Option to sort properties within user-defined concentric groups alphabetically instead of following the concentric order. **Note**: This feature will only be implemented if there's sufficient interest from the community.
## Contributing

View file

@ -1,6 +1,6 @@
{
"name": "@antebudimir/eslint-plugin-vanilla-extract",
"version": "1.8.0",
"version": "1.9.0",
"description": "ESLint plugin for enforcing best practices in vanilla-extract CSS styles, including CSS property ordering and additional linting rules.",
"author": "Ante Budimir",
"license": "MIT",

View file

@ -61,7 +61,6 @@ run({
});
`,
// Add these to the valid array
// Test for global functions without enough arguments
`
import { globalStyle } from '@vanilla-extract/css';

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,52 @@
import type { Rule } from 'eslint';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
import { processRecipeProperties } from '../shared-utils/recipe-property-processor.js';
import { processStyleNode } from '../shared-utils/style-node-processor.js';
import { processUnknownUnitInStyleObject } from './unknown-unit-processor.js';
/**
* Creates ESLint rule visitors for detecting and processing unknown CSS units
* in style-related function calls.
*/
export const createUnknownUnitVisitors = (context: Rule.RuleContext): Rule.RuleListener => {
return {
CallExpression(node) {
if (node.callee.type !== AST_NODE_TYPES.Identifier) {
return;
}
if (['fontFace', 'globalFontFace'].includes(node.callee.name)) {
const argumentIndex = node.callee.name === 'fontFace' ? 0 : 1;
if (
node.arguments.length > argumentIndex &&
node.arguments[argumentIndex]?.type === AST_NODE_TYPES.ObjectExpression
) {
processUnknownUnitInStyleObject(context, node.arguments[argumentIndex] as TSESTree.ObjectExpression);
}
return;
}
if (['keyframes', 'style', 'styleVariants'].includes(node.callee.name)) {
if (node.arguments.length > 0) {
processStyleNode(context, node.arguments[0] as TSESTree.ObjectExpression, processUnknownUnitInStyleObject);
}
}
if (['globalKeyframes', 'globalStyle'].includes(node.callee.name) && node.arguments.length >= 2) {
processStyleNode(context, node.arguments[1] as TSESTree.ObjectExpression, processUnknownUnitInStyleObject);
}
if (
node.callee.name === 'recipe' &&
node.arguments.length > 0 &&
node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression
) {
processRecipeProperties(
context,
node.arguments[0] as TSESTree.ObjectExpression,
processUnknownUnitInStyleObject,
);
}
},
};
};

View file

@ -12,7 +12,7 @@ const noZeroUnitRule: Rule.RuleModule = {
fixable: 'code',
schema: [],
messages: {
noZeroUnit: 'Unit with zero value is unnecessary. Use 0 instead.',
noZeroUnit: 'Zero values dont need a unit. Replace with "0".',
},
},
create(context) {

View file

@ -2,18 +2,20 @@ import alphabeticalOrderRule from './css-rules/alphabetical-order/index.js';
import concentricOrderRule from './css-rules/concentric-order/index.js';
import customOrderRule from './css-rules/custom-order/rule-definition.js';
import noEmptyStyleBlocksRule from './css-rules/no-empty-blocks/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';
export const vanillaExtract = {
meta: {
name: '@antebudimir/eslint-plugin-vanilla-extract',
version: '1.8.0',
version: '1.9.0',
},
rules: {
'alphabetical-order': alphabeticalOrderRule,
'concentric-order': concentricOrderRule,
'custom-order': customOrderRule,
'no-empty-style-blocks': noEmptyStyleBlocksRule,
'no-unknown-unit': noUnknownUnitRule,
'no-zero-unit': noZeroUnitRule,
},
configs: {
@ -22,6 +24,7 @@ export const vanillaExtract = {
rules: {
'vanilla-extract/concentric-order': 'error',
'vanilla-extract/no-empty-style-blocks': 'error',
'vanilla-extract/no-unknown-unit': 'error',
'vanilla-extract/no-zero-unit': 'error',
},
},