mirror of
https://github.com/antebudimir/eslint-plugin-vanilla-extract.git
synced 2026-01-01 09:23:31 +00:00
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:
parent
9263c5dd24
commit
69dd109311
9 changed files with 464 additions and 16 deletions
166
src/css-rules/no-px-unit/_tests_/no-px-unit.test.ts
Normal file
166
src/css-rules/no-px-unit/_tests_/no-px-unit.test.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
});
|
||||
3
src/css-rules/no-px-unit/index.ts
Normal file
3
src/css-rules/no-px-unit/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import noPxUnitRule from './rule-definition.js'
|
||||
|
||||
export default noPxUnitRule
|
||||
116
src/css-rules/no-px-unit/px-unit-processor.ts
Normal file
116
src/css-rules/no-px-unit/px-unit-processor.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
77
src/css-rules/no-px-unit/px-unit-visitor-creator.ts
Normal file
77
src/css-rules/no-px-unit/px-unit-visitor-creator.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
40
src/css-rules/no-px-unit/rule-definition.ts
Normal file
40
src/css-rules/no-px-unit/rule-definition.ts
Normal 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 can’t 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue