mirror of
https://github.com/antebudimir/eslint-plugin-vanilla-extract.git
synced 2025-12-31 17:03:32 +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
|
|
@ -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
|
||||||
|
|
|
||||||
65
README.md
65
README.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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": [
|
||||||
|
|
|
||||||
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;
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue