From 85d106dc1dc480a49f32f830a0ec6ab55c821b25 Mon Sep 17 00:00:00 2001 From: Ante Budimir Date: Tue, 4 Nov 2025 08:42:47 +0200 Subject: [PATCH] =?UTF-8?q?feat=20=F0=9F=A5=81:=20add=20no-px-unit=20rule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- CHANGELOG.md | 5 + README.md | 62 +++++-- package.json | 4 +- .../no-px-unit/_tests_/no-px-unit.test.ts | 166 ++++++++++++++++++ src/css-rules/no-px-unit/index.ts | 3 + src/css-rules/no-px-unit/px-unit-processor.ts | 116 ++++++++++++ .../no-px-unit/px-unit-visitor-creator.ts | 77 ++++++++ src/css-rules/no-px-unit/rule-definition.ts | 40 +++++ src/index.ts | 4 +- 9 files changed, 462 insertions(+), 15 deletions(-) create mode 100644 src/css-rules/no-px-unit/_tests_/no-px-unit.test.ts create mode 100644 src/css-rules/no-px-unit/index.ts create mode 100644 src/css-rules/no-px-unit/px-unit-processor.ts create mode 100644 src/css-rules/no-px-unit/px-unit-visitor-creator.ts create mode 100644 src/css-rules/no-px-unit/rule-definition.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a965fe5..6befdd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 [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 - Add new rule `no-trailing-zero` that flags and fixes unnecessary trailing zeros in numeric values diff --git a/README.md b/README.md index e08fd5a..10d16ef 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,7 @@ --- -An ESLint plugin for enforcing best practices in -[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). +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 ## 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: -- `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-trailing-zero`: Disallows trailing zeros in numeric CSS values - `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/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. @@ -338,8 +335,7 @@ export const myStyle = style({ ### vanilla-extract/concentric-order -This rule enforces that CSS properties in vanilla-extract style objects follow the concentric CSS ordering pattern, -which organizes properties from outside to inside. +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. ```typescript // ❌ 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 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: @@ -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 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 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 cleaner and more consistent CSS by eliminating redundant units when the value is zero. diff --git a/package.json b/package.json index 6b1ab07..12c46ab 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@antebudimir/eslint-plugin-vanilla-extract", - "version": "1.12.0", - "description": "ESLint plugin for enforcing best practices in vanilla-extract CSS styles, including CSS property ordering and additional linting rules.", + "version": "1.13.0", + "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", "license": "MIT", "keywords": [ diff --git a/src/css-rules/no-px-unit/_tests_/no-px-unit.test.ts b/src/css-rules/no-px-unit/_tests_/no-px-unit.test.ts new file mode 100644 index 0000000..b27e604 --- /dev/null +++ b/src/css-rules/no-px-unit/_tests_/no-px-unit.test.ts @@ -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', + }, + ], +}); diff --git a/src/css-rules/no-px-unit/index.ts b/src/css-rules/no-px-unit/index.ts new file mode 100644 index 0000000..1b50325 --- /dev/null +++ b/src/css-rules/no-px-unit/index.ts @@ -0,0 +1,3 @@ +import noPxUnitRule from './rule-definition.js' + +export default noPxUnitRule diff --git a/src/css-rules/no-px-unit/px-unit-processor.ts b/src/css-rules/no-px-unit/px-unit-processor.ts new file mode 100644 index 0000000..1ffdb9d --- /dev/null +++ b/src/css-rules/no-px-unit/px-unit-processor.ts @@ -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, +): 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, + }); + } + } +}; diff --git a/src/css-rules/no-px-unit/px-unit-visitor-creator.ts b/src/css-rules/no-px-unit/px-unit-visitor-creator.ts new file mode 100644 index 0000000..6743926 --- /dev/null +++ b/src/css-rules/no-px-unit/px-unit-visitor-creator.ts @@ -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; + } + }, + }; +}; diff --git a/src/css-rules/no-px-unit/rule-definition.ts b/src/css-rules/no-px-unit/rule-definition.ts new file mode 100644 index 0000000..9994780 --- /dev/null +++ b/src/css-rules/no-px-unit/rule-definition.ts @@ -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; diff --git a/src/index.ts b/src/index.ts index c0e9d04..bc9b89c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ 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 noPxUnitRule from './css-rules/no-px-unit/index.js'; import noTrailingZeroRule from './css-rules/no-trailing-zero/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'; @@ -9,13 +10,14 @@ import noZeroUnitRule from './css-rules/no-zero-unit/rule-definition.js'; const vanillaExtract = { meta: { name: '@antebudimir/eslint-plugin-vanilla-extract', - version: '1.12.0', + version: '1.13.0', }, rules: { 'alphabetical-order': alphabeticalOrderRule, 'concentric-order': concentricOrderRule, 'custom-order': customOrderRule, 'no-empty-style-blocks': noEmptyStyleBlocksRule, + 'no-px-unit': noPxUnitRule, 'no-trailing-zero': noTrailingZeroRule, 'no-unknown-unit': noUnknownUnitRule, 'no-zero-unit': noZeroUnitRule,