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

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