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

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