feat 🥁: add no-px-unit rule

- Add new rule `no-px-unit` that disallows `px` units in vanilla-extract styles with an allowlist option
- Provides fix suggestions for string literals and simple template literals (no expressions)
This commit is contained in:
Ante Budimir 2025-11-04 08:42:47 +02:00
parent 9263c5dd24
commit 69dd109311
9 changed files with 464 additions and 16 deletions

View file

@ -5,6 +5,11 @@ 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 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). [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.13.0] - 2025-11-04
- Add new rule `no-px-unit` that disallows `px` units in vanilla-extract styles with an allowlist option
- Provides fix suggestions for string literals and simple template literals (no expressions)
## [1.12.0] - 2025-10-22 ## [1.12.0] - 2025-10-22
- Add new rule `no-trailing-zero` that flags and fixes unnecessary trailing zeros in numeric values - Add new rule `no-trailing-zero` that flags and fixes unnecessary trailing zeros in numeric values

View file

@ -15,11 +15,7 @@
--- ---
An ESLint plugin for enforcing best practices in Comprehensive ESLint plugin for vanilla-extract that enforces best practices in [vanilla-extract](https://github.com/vanilla-extract-css/vanilla-extract) CSS styles. Includes support for CSS property ordering (alphabetical, [concentric](https://rhodesmill.org/brandon/2011/concentric-css/), and custom group ordering), advanced style linting rules, auto-fixing, and validation of style patterns specific to vanilla-extract. Ensures zero-runtime safety and integrates with multiple vanilla-extract APIs to promote maintainable, consistent code across projects
[vanilla-extract](https://github.com/vanilla-extract-css/vanilla-extract) CSS styles, including CSS property ordering
and additional linting rules. Available presets are for alphabetical and
[concentric](https://rhodesmill.org/brandon/2011/concentric-css/) CSS ordering. The plugin also supports a custom group
ordering option based on groups available in [concentric CSS](src/css-rules/concentric-order/concentric-groups.ts).
## Demo ## Demo
@ -257,7 +253,7 @@ For VS Code users, add these settings to your `.vscode/settings.json`:
The recommended configuration enables the following rules with error severity: The recommended configuration enables the following rules with error severity:
- `vanilla-extract/concentric-order`: Enforces concentric CSS property ordering - `vanilla-extract/concentric-order`: Enforces [concentric CSS](#concentric-css-model) property ordering
- `vanilla-extract/no-empty-style-blocks`: Prevents empty style blocks - `vanilla-extract/no-empty-style-blocks`: Prevents empty style blocks
- `vanilla-extract/no-trailing-zero`: Disallows trailing zeros in numeric CSS values - `vanilla-extract/no-trailing-zero`: Disallows trailing zeros in numeric CSS values
- `vanilla-extract/no-unknown-unit`: Prohibits usage of unrecognized CSS units - `vanilla-extract/no-unknown-unit`: Prohibits usage of unrecognized CSS units
@ -267,6 +263,7 @@ The recommended configuration enables the following rules with error severity:
- `vanilla-extract/alphabetical-order`: Alternative ordering rule (alphabetical sorting) - `vanilla-extract/alphabetical-order`: Alternative ordering rule (alphabetical sorting)
- `vanilla-extract/custom-order`: Alternative ordering rule (custom group-based sorting) - `vanilla-extract/custom-order`: Alternative ordering rule (custom group-based sorting)
- `vanilla-extract/no-px-unit`: Disallows px units with an optional allowlist
You can use the recommended configuration as a starting point and override rules as needed for your project. See the configuration examples above for how to switch between ordering rules. You can use the recommended configuration as a starting point and override rules as needed for your project. See the configuration examples above for how to switch between ordering rules.
@ -338,8 +335,7 @@ export const myStyle = style({
### vanilla-extract/concentric-order ### vanilla-extract/concentric-order
This rule enforces that CSS properties in vanilla-extract style objects follow the concentric CSS ordering pattern, This rule enforces that CSS properties in vanilla-extract style objects follow the [concentric CSS](#concentric-css-model) ordering pattern, which organizes properties from outside to inside.
which organizes properties from outside to inside.
```typescript ```typescript
// ❌ Incorrect // ❌ Incorrect
@ -369,7 +365,7 @@ export const myStyle = style({
The `vanilla-extract/custom-order` rule enables you to enforce a custom ordering of CSS properties in your The `vanilla-extract/custom-order` rule enables you to enforce a custom ordering of CSS properties in your
vanilla-extract styles. You can specify an array of property groups in your preferred order, and the rule will ensure vanilla-extract styles. You can specify an array of property groups in your preferred order, and the rule will ensure
that properties within these groups are sorted according to their position in the concentric CSS model. that properties within these groups are sorted according to their position in the [concentric CSS model](https://rhodesmill.org/brandon/2011/concentric-css/).
Key features of this rule include: Key features of this rule include:
@ -465,7 +461,49 @@ export const recipeWithEmptyVariants = recipe({
}); });
``` ```
## vanilla-extract/no-trailing-zero ### vanilla-extract/no-px-unit
This rule disallows the use of hard-coded `px` units in vanilla-extract style declarations. Prefer `rem`, `em`, or theme tokens. A configurable allowlist lets you permit specific properties to use `px` where necessary. Allowlist supports both camelCase and kebab-case property names.
Configuration with an allowlist:
```json
{
"rules": {
"vanilla-extract/no-px-unit": ["error", { "allow": ["borderWidth", "outline-offset"] }]
}
}
```
Before:
```typescript
import { style } from '@vanilla-extract/css';
export const box = style({
marginTop: '8px',
padding: '16px',
selectors: {
'&:hover': { gap: '4px' },
},
});
```
After (suggested fix shown using rem):
```typescript
import { style } from '@vanilla-extract/css';
export const box = style({
marginTop: '8rem',
padding: '16rem',
selectors: {
'&:hover': { gap: '4rem' },
},
});
```
### vanilla-extract/no-trailing-zero
This rule disallows trailing zeros in numeric CSS values within vanilla-extract style objects. It helps maintain cleaner This rule disallows trailing zeros in numeric CSS values within vanilla-extract style objects. It helps maintain cleaner
and more consistent CSS by removing unnecessary trailing zeros from decimal numbers. and more consistent CSS by removing unnecessary trailing zeros from decimal numbers.
@ -496,7 +534,7 @@ export const myStyle = style({
}); });
``` ```
## vanilla-extract/no-unknown-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 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. that could cause styling issues or browser compatibility problems.
@ -535,7 +573,7 @@ export const myRecipe = recipe({
}); });
``` ```
## vanilla-extract/no-zero-unit ### vanilla-extract/no-zero-unit
This rule enforces the removal of unnecessary units for zero values in vanilla-extract style objects. It helps maintain 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. cleaner and more consistent CSS by eliminating redundant units when the value is zero.
@ -648,10 +686,11 @@ The roadmap outlines the project's current status and future plans:
- Support for using the plugin's recommended config via the extends field (as discussed in - Support for using the plugin's recommended config via the extends field (as discussed in
[issue #3](https://github.com/antebudimir/eslint-plugin-vanilla-extract/issues/3)) [issue #3](https://github.com/antebudimir/eslint-plugin-vanilla-extract/issues/3))
- Comprehensive rule testing. - Comprehensive rule testing.
- `no-px-unit` rule to disallow use of `px` units with configurable whitelist.
### Current Work ### Current Work
- `no-px-unit` rule to disallow use of `px` units with configurable whitelist. - TBA
### Upcoming Features ### Upcoming Features

View file

@ -1,7 +1,7 @@
{ {
"name": "@antebudimir/eslint-plugin-vanilla-extract", "name": "@antebudimir/eslint-plugin-vanilla-extract",
"version": "1.12.0", "version": "1.13.0",
"description": "ESLint plugin for enforcing best practices in vanilla-extract CSS styles, including CSS property ordering and additional linting rules.", "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", "author": "Ante Budimir",
"license": "MIT", "license": "MIT",
"keywords": [ "keywords": [

View file

@ -0,0 +1,166 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import noPxUnitRule from '../rule-definition.js';
run({
name: 'vanilla-extract/no-px-unit',
rule: noPxUnitRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '1rem',
padding: 8,
width: '100%',
color: vars.color.primary,
});
`,
name: 'allows rem, numbers, and token references',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
borderWidth: '1px',
outlineOffset: '2px',
});
`,
options: [{ allow: ['border-width', 'outline-offset', 'borderWidth', 'outlineOffset'] }],
name: 'respects allowlist for kebab and camelCase',
},
{
code: `
import { recipe } from '@vanilla-extract/css';
const myRecipe = recipe({
base: { margin: '1rem' },
variants: {
size: {
sm: { padding: '0.5rem' },
},
},
});
`,
name: 'passes when no px in recipe base/variants',
},
{
code: `
import { style } from '@vanilla-extract/css';
const s = style({
border: '1px solid',
borderWidth: '2px'
});
`,
options: [{ allow: ['borderWidth', 'border'] }],
name: 'does not report whitelisted properties',
},
],
invalid: [
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
margin: '10px',
padding: '2px',
});
`,
errors: [{ messageId: 'noPxUnit' }, { messageId: 'noPxUnit' }],
},
{
code: `
import { style } from '@vanilla-extract/css';
const s = style({
'@media': {
'(min-width: 768px)': {
lineHeight: '12px',
}
},
selectors: {
'&:hover': { gap: '4px' }
}
});
`,
errors: [{ messageId: 'noPxUnit' }, { messageId: 'noPxUnit' }],
name: 'reports within nested @media and selectors',
},
{
code: `
import { recipe } from '@vanilla-extract/css';
const r = recipe({
base: { marginTop: '3px' },
variants: { size: { md: { paddingBottom: '6px' } } }
});
`,
errors: [{ messageId: 'noPxUnit' }, { messageId: 'noPxUnit' }],
name: 'reports within recipe base and variants',
},
{
code: `
import { fontFace } from '@vanilla-extract/css';
fontFace({ sizeAdjust: '10px' });
`,
errors: [{ messageId: 'noPxUnit' }],
name: 'reports in fontFace() first-arg object (covers fontFace branch)',
},
{
code: `
import { globalFontFace } from '@vanilla-extract/css';
globalFontFace('MyFont', { lineGapOverride: '12px' });
`,
errors: [{ messageId: 'noPxUnit' }],
name: 'reports in globalFontFace() second-arg object (covers globalFontFace branch)',
},
{
code: `
import { globalStyle } from '@vanilla-extract/css';
globalStyle('html', { boxShadow: '0 1px 2px black' });
`,
errors: [{ messageId: 'noPxUnit' }],
name: 'reports in globalStyle() second-arg object (covers globalStyle branch)',
},
{
code: `
import { globalKeyframes } from '@vanilla-extract/css';
globalKeyframes('fade', {
from: { margin: '5px' },
to: { padding: '3px' }
});
`,
errors: [{ messageId: 'noPxUnit' }, { messageId: 'noPxUnit' }],
name: 'reports in globalKeyframes() frames (covers globalKeyframes branch)',
},
{
code:
`
import { style } from '@vanilla-extract/css';
const s = style({
margin: ` +
'`10px`' +
`,
});
`,
errors: [{ messageId: 'noPxUnit' }],
name: 'reports for simple template literal with px',
},
{
code:
`
import { style } from '@vanilla-extract/css';
const s = style({
margin: ` +
'`${token}px`' +
`,
});
`,
errors: [{ messageId: 'noPxUnit' }],
name: 'reports for complex template literals with expressions containing px',
},
],
});

View file

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

View file

@ -0,0 +1,116 @@
import type { Rule } from 'eslint';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
const containsPx = (text: string): boolean => /(^|\W)-?\d*\.?\d*px(?![a-zA-Z])/i.test(text);
const replacePxWith = (text: string, replacement: 'rem' | ''): string => text.replace(/px(?![a-zA-Z])/g, replacement);
const toKebab = (name: string): string => name.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
const getValueText = (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) {
// Join all quasis (ignore expressions content)
const raw = node.quasis.map((quasi) => quasi.value.raw ?? '').join('');
return raw;
}
return null;
};
const canSuggestFix = (node: TSESTree.Node): 'literal' | 'simple-template' | null => {
if (node.type === AST_NODE_TYPES.Literal && typeof node.value === 'string') return 'literal';
if (node.type === AST_NODE_TYPES.TemplateLiteral && node.expressions.length === 0) return 'simple-template';
return null;
};
/**
* Recursively processes a vanilla-extract style object and reports occurrences of 'px' units.
*
* - Skips properties present in the allow list (supports camelCase and kebab-case).
* - Traverses nested object values and delegates deeper traversal to callers for arrays/at-rules/selectors.
* - Provides fix suggestions for string literals and simple template literals (no expressions).
*
* @param context ESLint rule context used to report diagnostics and apply suggestions.
* @param node The ObjectExpression node representing the style object to inspect.
* @param allowSet Set of property names (camelCase or kebab-case) that are allowed to contain 'px'.
*/
export const processNoPxUnitInStyleObject = (
context: Rule.RuleContext,
node: TSESTree.ObjectExpression,
allowSet: Set<string>,
): void => {
for (const property of node.properties) {
if (property.type !== AST_NODE_TYPES.Property) continue;
// Determine property name when 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;
}
// Recurse into known nested containers
if (propertyName === '@media' || propertyName === 'selectors') {
if (property.value.type === AST_NODE_TYPES.ObjectExpression) {
for (const nested of property.value.properties) {
if (nested.type === AST_NODE_TYPES.Property && nested.value.type === AST_NODE_TYPES.ObjectExpression) {
processNoPxUnitInStyleObject(context, nested.value, allowSet);
}
}
}
continue;
}
// Traverse any nested object
if (property.value.type === AST_NODE_TYPES.ObjectExpression) {
processNoPxUnitInStyleObject(context, property.value, allowSet);
continue;
}
// Skip if property is whitelisted (supports both camelCase and kebab-case)
if (propertyName) {
const kebab = toKebab(propertyName);
if (allowSet.has(propertyName) || allowSet.has(kebab)) {
continue;
}
}
// Check string or template literal values
const text = getValueText(property.value);
if (text && containsPx(text)) {
const fixability = canSuggestFix(property.value);
context.report({
node: property.value as unknown as Rule.Node,
messageId: 'noPxUnit',
suggest: fixability
? [
{
messageId: 'removePx',
fix: (fixer) => {
const newText = replacePxWith(text, '');
if (fixability === 'literal') {
return fixer.replaceText(property.value, `'${newText}'`);
}
// simple template with no expressions
return fixer.replaceText(property.value, `\`${newText}\``);
},
},
{
messageId: 'replaceWithRem',
fix: (fixer) => {
const newText = replacePxWith(text, 'rem');
if (fixability === 'literal') {
return fixer.replaceText(property.value, `'${newText}'`);
}
return fixer.replaceText(property.value, `\`${newText}\``);
},
},
]
: undefined,
});
}
}
};

View file

@ -0,0 +1,77 @@
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 { processNoPxUnitInStyleObject } from './px-unit-processor.js';
/**
* Creates ESLint rule visitors for detecting and reporting 'px' units in vanilla-extract style objects.
* - Tracks calls to vanilla-extract APIs (style, recipe, keyframes, globalStyle, fontFace, etc.).
* This visitor only orchestrates traversal; the actual reporting and suggestion logic lives in the processor.
* - Respects the `allow` option (camelCase or kebab-case) by passing it as a Set to the processor.
*
* @param context ESLint rule context used to read options and report diagnostics.
* @returns Rule listener that inspects vanilla-extract call expressions and processes style objects.
*/
export const createNoPxUnitVisitors = (context: Rule.RuleContext): Rule.RuleListener => {
const tracker = new ReferenceTracker();
const trackingVisitor = createReferenceTrackingVisitor(tracker);
const options = (context.options?.[0] as { allow?: string[] } | undefined) || {};
const allowSet = new Set((options.allow ?? []).map((string) => string));
const process = (context: Rule.RuleContext, object: TSESTree.ObjectExpression) =>
processNoPxUnitInStyleObject(context, object, allowSet);
return {
...trackingVisitor,
CallExpression(node) {
if (node.callee.type !== AST_NODE_TYPES.Identifier) return;
const functionName = node.callee.name;
if (!tracker.isTrackedFunction(functionName)) return;
const originalName = tracker.getOriginalName(functionName);
if (!originalName) return;
switch (originalName) {
case 'fontFace':
if (node.arguments.length > 0 && node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression) {
process(context, node.arguments[0] as TSESTree.ObjectExpression);
}
break;
case 'globalFontFace':
if (node.arguments.length > 1 && node.arguments[1]?.type === AST_NODE_TYPES.ObjectExpression) {
process(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.Node, (context, object) =>
process(context, object),
);
}
break;
case 'globalStyle':
case 'globalKeyframes':
if (node.arguments.length >= 2) {
processStyleNode(context, node.arguments[1] as TSESTree.Node, (context, object) =>
process(context, object),
);
}
break;
case 'recipe':
if (node.arguments.length > 0 && node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression) {
processRecipeProperties(context, node.arguments[0] as TSESTree.ObjectExpression, (context, object) =>
process(context, object),
);
}
break;
}
},
};
};

View file

@ -0,0 +1,40 @@
import type { Rule } from 'eslint';
import { createNoPxUnitVisitors } from './px-unit-visitor-creator.js';
const noPxUnitRule: Rule.RuleModule = {
meta: {
type: 'suggestion',
docs: {
description: "disallow 'px' units in vanilla-extract style objects, with allowlist option",
category: 'Best Practices',
recommended: false,
},
// Suggestions are reported from helper modules, so static analysis in this file cant detect them; disable the false positive.
// eslint-disable-next-line eslint-plugin/require-meta-has-suggestions
hasSuggestions: true,
schema: [
{
type: 'object',
properties: {
allow: {
type: 'array',
items: { type: 'string' },
uniqueItems: true,
default: [],
},
},
additionalProperties: false,
},
],
messages: {
noPxUnit: "Avoid using 'px' units. Use rem, em, or theme tokens instead.",
replaceWithRem: "Replace 'px' with 'rem'.",
removePx: "Remove 'px' unit.",
},
},
create(context) {
return createNoPxUnitVisitors(context);
},
};
export default noPxUnitRule;

View file

@ -2,6 +2,7 @@ import alphabeticalOrderRule from './css-rules/alphabetical-order/index.js';
import concentricOrderRule from './css-rules/concentric-order/index.js'; import concentricOrderRule from './css-rules/concentric-order/index.js';
import customOrderRule from './css-rules/custom-order/rule-definition.js'; import customOrderRule from './css-rules/custom-order/rule-definition.js';
import noEmptyStyleBlocksRule from './css-rules/no-empty-blocks/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 noTrailingZeroRule from './css-rules/no-trailing-zero/rule-definition.js';
import noUnknownUnitRule from './css-rules/no-unknown-unit/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 noZeroUnitRule from './css-rules/no-zero-unit/rule-definition.js';
@ -9,13 +10,14 @@ import noZeroUnitRule from './css-rules/no-zero-unit/rule-definition.js';
const vanillaExtract = { const vanillaExtract = {
meta: { meta: {
name: '@antebudimir/eslint-plugin-vanilla-extract', name: '@antebudimir/eslint-plugin-vanilla-extract',
version: '1.12.0', version: '1.13.0',
}, },
rules: { rules: {
'alphabetical-order': alphabeticalOrderRule, 'alphabetical-order': alphabeticalOrderRule,
'concentric-order': concentricOrderRule, 'concentric-order': concentricOrderRule,
'custom-order': customOrderRule, 'custom-order': customOrderRule,
'no-empty-style-blocks': noEmptyStyleBlocksRule, 'no-empty-style-blocks': noEmptyStyleBlocksRule,
'no-px-unit': noPxUnitRule,
'no-trailing-zero': noTrailingZeroRule, 'no-trailing-zero': noTrailingZeroRule,
'no-unknown-unit': noUnknownUnitRule, 'no-unknown-unit': noUnknownUnitRule,
'no-zero-unit': noZeroUnitRule, 'no-zero-unit': noZeroUnitRule,