mirror of
https://github.com/antebudimir/eslint-plugin-vanilla-extract.git
synced 2026-01-02 01:23:33 +00:00
feat 🥁: add no-zero-unit rule
This rule enforces unitless zero values in vanilla-extract style objects: - Automatically removes unnecessary units from zero values - Handles both positive and negative zero values - Preserves units where required (time properties, CSS functions) - Works with all vanilla-extract APIs
This commit is contained in:
parent
52d38d4477
commit
7dc7204749
20 changed files with 650 additions and 737 deletions
341
src/css-rules/no-zero-unit/_tests_/no-zero-unit.test.ts
Normal file
341
src/css-rules/no-zero-unit/_tests_/no-zero-unit.test.ts
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
import tsParser from '@typescript-eslint/parser';
|
||||
import { run } from 'eslint-vitest-rule-tester';
|
||||
import noZeroUnitRule from '../rule-definition.js';
|
||||
|
||||
run({
|
||||
name: 'vanilla-extract/no-zero-unit',
|
||||
rule: noZeroUnitRule,
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
margin: '0',
|
||||
padding: 0,
|
||||
width: '100%',
|
||||
});
|
||||
`,
|
||||
},
|
||||
{
|
||||
code: `
|
||||
import { recipe } from '@vanilla-extract/css';
|
||||
const myRecipe = recipe({
|
||||
base: {
|
||||
margin: '0',
|
||||
padding: 0,
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
small: {
|
||||
height: '0',
|
||||
width: '10px',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
...spreadProps,
|
||||
margin: 0,
|
||||
'@media': {
|
||||
'0rem': '0' // Key shouldn't be checked
|
||||
}
|
||||
});
|
||||
`,
|
||||
name: 'should ignore spread elements and object keys',
|
||||
},
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
margin: \`0\${someUnit}\`, // Template literal
|
||||
padding: someVariable
|
||||
});
|
||||
`,
|
||||
name: 'should ignore non-literal values',
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
import { globalStyle } from '@vanilla-extract/css';
|
||||
const callExpression = someObject.fontFace({ src: '...' }); // Non-Identifier callee
|
||||
`,
|
||||
name: 'should ignore member expression callees',
|
||||
},
|
||||
{
|
||||
code: `
|
||||
import { fontFace } from '@vanilla-extract/css';
|
||||
fontFace(); // Missing arguments
|
||||
`,
|
||||
name: 'should handle missing fontFace arguments',
|
||||
},
|
||||
{
|
||||
code: `
|
||||
import { globalFontFace } from '@vanilla-extract/css';
|
||||
globalFontFace('my-font'); // Missing style argument
|
||||
`,
|
||||
name: 'should handle missing globalFontFace style argument',
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
margin: '0px',
|
||||
padding: '0rem',
|
||||
});
|
||||
`,
|
||||
errors: [{ messageId: 'noZeroUnit' }, { messageId: 'noZeroUnit' }],
|
||||
output: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
margin: '0',
|
||||
padding: '0',
|
||||
});
|
||||
`,
|
||||
},
|
||||
{
|
||||
code: `
|
||||
import { recipe } from '@vanilla-extract/css';
|
||||
const myRecipe = recipe({
|
||||
base: {
|
||||
margin: '0px',
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
small: {
|
||||
height: '0vh',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
`,
|
||||
errors: [{ messageId: 'noZeroUnit' }, { messageId: 'noZeroUnit' }],
|
||||
output: `
|
||||
import { recipe } from '@vanilla-extract/css';
|
||||
const myRecipe = recipe({
|
||||
base: {
|
||||
margin: '0',
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
small: {
|
||||
height: '0',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
margin: '0px',
|
||||
'@media': {
|
||||
'(min-width: 768px)': {
|
||||
padding: '0rem'
|
||||
}
|
||||
}
|
||||
});
|
||||
`,
|
||||
errors: 2,
|
||||
output: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
margin: '0',
|
||||
'@media': {
|
||||
'(min-width: 768px)': {
|
||||
padding: '0'
|
||||
}
|
||||
}
|
||||
});
|
||||
`,
|
||||
name: 'should handle nested media queries',
|
||||
},
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
'::before': {
|
||||
content: '""',
|
||||
margin: '0px'
|
||||
}
|
||||
});
|
||||
`,
|
||||
errors: 1,
|
||||
output: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
'::before': {
|
||||
content: '""',
|
||||
margin: '0'
|
||||
}
|
||||
});
|
||||
`,
|
||||
name: 'should handle pseudo-elements',
|
||||
},
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
margin: '0px',
|
||||
nested: {
|
||||
object: {
|
||||
padding: '0rem',
|
||||
deeper: {
|
||||
width: '0%'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
`,
|
||||
errors: 3,
|
||||
output: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
margin: '0',
|
||||
nested: {
|
||||
object: {
|
||||
padding: '0',
|
||||
deeper: {
|
||||
width: '0'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
`,
|
||||
name: 'should handle multiple levels of nesting',
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
import { fontFace, globalFontFace } from '@vanilla-extract/css';
|
||||
|
||||
fontFace({
|
||||
src: '...',
|
||||
lineGap: '0rem'
|
||||
});
|
||||
|
||||
globalFontFace('my-font', {
|
||||
src: '...',
|
||||
sizeAdjust: '0%'
|
||||
});
|
||||
`,
|
||||
errors: 2,
|
||||
output: `
|
||||
import { fontFace, globalFontFace } from '@vanilla-extract/css';
|
||||
|
||||
fontFace({
|
||||
src: '...',
|
||||
lineGap: '0'
|
||||
});
|
||||
|
||||
globalFontFace('my-font', {
|
||||
src: '...',
|
||||
sizeAdjust: '0'
|
||||
});
|
||||
`,
|
||||
name: 'should handle fontFace and globalFontFace arguments',
|
||||
},
|
||||
|
||||
// 0deg is valid (deg isn't in our unit check)
|
||||
{
|
||||
code: `
|
||||
import { globalKeyframes, globalStyle } from '@vanilla-extract/css';
|
||||
|
||||
globalKeyframes('spin', {
|
||||
'0%': { transform: 'rotate(0deg)' },
|
||||
'100%': { transform: 'rotate(0deg)' }
|
||||
});
|
||||
|
||||
globalStyle('html', {
|
||||
margin: '0px',
|
||||
padding: '0rem'
|
||||
});
|
||||
`,
|
||||
errors: 2,
|
||||
output: `
|
||||
import { globalKeyframes, globalStyle } from '@vanilla-extract/css';
|
||||
|
||||
globalKeyframes('spin', {
|
||||
'0%': { transform: 'rotate(0deg)' },
|
||||
'100%': { transform: 'rotate(0deg)' }
|
||||
});
|
||||
|
||||
globalStyle('html', {
|
||||
margin: '0',
|
||||
padding: '0'
|
||||
});
|
||||
`,
|
||||
name: 'should handle globalKeyframes and globalStyle arguments',
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
import { globalStyle } from '@vanilla-extract/css';
|
||||
globalStyle('html', {
|
||||
'@media': {
|
||||
'(min-width: 768px)': {
|
||||
margin: '0px'
|
||||
}
|
||||
}
|
||||
});
|
||||
`,
|
||||
errors: 1,
|
||||
output: `
|
||||
import { globalStyle } from '@vanilla-extract/css';
|
||||
globalStyle('html', {
|
||||
'@media': {
|
||||
'(min-width: 768px)': {
|
||||
margin: '0'
|
||||
}
|
||||
}
|
||||
});
|
||||
`,
|
||||
name: 'should handle nested globalStyle arguments',
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
margin: '-0px',
|
||||
padding: '-0rem',
|
||||
top: '-0vh',
|
||||
left: '-0%',
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'noZeroUnit' },
|
||||
{ messageId: 'noZeroUnit' },
|
||||
{ messageId: 'noZeroUnit' },
|
||||
{ messageId: 'noZeroUnit' },
|
||||
],
|
||||
output: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
margin: '0',
|
||||
padding: '0',
|
||||
top: '0',
|
||||
left: '0',
|
||||
});
|
||||
`,
|
||||
name: 'should convert negative zero with units to simple zero',
|
||||
},
|
||||
],
|
||||
});
|
||||
3
src/css-rules/no-zero-unit/index.ts
Normal file
3
src/css-rules/no-zero-unit/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import noZeroUnitRule from './rule-definition.js';
|
||||
|
||||
export default noZeroUnitRule;
|
||||
23
src/css-rules/no-zero-unit/rule-definition.ts
Normal file
23
src/css-rules/no-zero-unit/rule-definition.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { Rule } from 'eslint';
|
||||
import { createZeroUnitVisitors } from './zero-unit-visitor-creator.js';
|
||||
|
||||
const noZeroUnitRule: Rule.RuleModule = {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description: 'enforce unitless zero in numeric values',
|
||||
category: 'Stylistic Issues',
|
||||
recommended: true,
|
||||
},
|
||||
fixable: 'code',
|
||||
schema: [],
|
||||
messages: {
|
||||
noZeroUnit: 'Unit with zero value is unnecessary. Use 0 instead.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return createZeroUnitVisitors(context);
|
||||
},
|
||||
};
|
||||
|
||||
export default noZeroUnitRule;
|
||||
36
src/css-rules/no-zero-unit/zero-unit-processor.ts
Normal file
36
src/css-rules/no-zero-unit/zero-unit-processor.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { Rule } from 'eslint';
|
||||
import { TSESTree } from '@typescript-eslint/utils';
|
||||
|
||||
const ZERO_VALUE_WITH_UNIT_REGEX = /^-?0(px|em|rem|%|vh|vw|vmin|vmax|ex|ch|cm|mm|in|pt|pc|Q|fr)$/;
|
||||
|
||||
/**
|
||||
* Recursively processes a style object, reporting and fixing instances of zero values with units.
|
||||
*
|
||||
* @param ruleContext The ESLint rule context.
|
||||
* @param node The ObjectExpression node representing the style object to be processed.
|
||||
*/
|
||||
export const processZeroUnitInStyleObject = (ruleContext: Rule.RuleContext, node: TSESTree.ObjectExpression): void => {
|
||||
node.properties.forEach((property) => {
|
||||
if (property.type !== 'Property') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Process direct string literal values
|
||||
if (
|
||||
property.value.type === 'Literal' &&
|
||||
typeof property.value.value === 'string' &&
|
||||
ZERO_VALUE_WITH_UNIT_REGEX.test(property.value.value)
|
||||
) {
|
||||
ruleContext.report({
|
||||
node: property.value,
|
||||
messageId: 'noZeroUnit',
|
||||
fix: (fixer) => fixer.replaceText(property.value, "'0'"),
|
||||
});
|
||||
}
|
||||
|
||||
// Process nested objects (selectors, media queries, etc.)
|
||||
if (property.value.type === 'ObjectExpression') {
|
||||
processZeroUnitInStyleObject(ruleContext, property.value);
|
||||
}
|
||||
});
|
||||
};
|
||||
56
src/css-rules/no-zero-unit/zero-unit-visitor-creator.ts
Normal file
56
src/css-rules/no-zero-unit/zero-unit-visitor-creator.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { Rule } from 'eslint';
|
||||
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
|
||||
import { processRecipeProperties } from '../shared-utils/recipe-property-processor.js';
|
||||
import { processStyleNode } from '../shared-utils/style-node-processor.js';
|
||||
import { processZeroUnitInStyleObject } from './zero-unit-processor.js';
|
||||
|
||||
/**
|
||||
* Creates ESLint rule visitors for detecting and processing zero values with units in style-related function calls.
|
||||
*
|
||||
* @param context The ESLint rule context.
|
||||
* @returns An object with visitor functions for the ESLint rule.
|
||||
*
|
||||
* This function sets up visitors for the following cases:
|
||||
* 1. The `fontFace` and `globalFontFace` functions, processing their object arguments.
|
||||
* 2. Style-related functions: `keyframes`, `style`, `styleVariants`, processing their style objects.
|
||||
* 3. The `globalKeyframes` and `globalStyle` functions, processing the second argument as style objects.
|
||||
* 4. The `recipe` function, processing the first argument as the recipe object.
|
||||
*/
|
||||
export const createZeroUnitVisitors = (context: Rule.RuleContext): Rule.RuleListener => {
|
||||
return {
|
||||
CallExpression(node) {
|
||||
if (node.callee.type !== AST_NODE_TYPES.Identifier) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (['fontFace', 'globalFontFace'].includes(node.callee.name)) {
|
||||
const argumentIndex = node.callee.name === 'fontFace' ? 0 : 1;
|
||||
if (
|
||||
node.arguments.length > argumentIndex &&
|
||||
node.arguments[argumentIndex]?.type === AST_NODE_TYPES.ObjectExpression
|
||||
) {
|
||||
processZeroUnitInStyleObject(context, node.arguments[argumentIndex] as TSESTree.ObjectExpression);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (['keyframes', 'style', 'styleVariants'].includes(node.callee.name)) {
|
||||
if (node.arguments.length > 0) {
|
||||
processStyleNode(context, node.arguments[0] as TSESTree.ObjectExpression, processZeroUnitInStyleObject);
|
||||
}
|
||||
}
|
||||
|
||||
if (['globalKeyframes', 'globalStyle'].includes(node.callee.name) && node.arguments.length >= 2) {
|
||||
processStyleNode(context, node.arguments[1] as TSESTree.ObjectExpression, processZeroUnitInStyleObject);
|
||||
}
|
||||
|
||||
if (
|
||||
node.callee.name === 'recipe' &&
|
||||
node.arguments.length > 0 &&
|
||||
node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression
|
||||
) {
|
||||
processRecipeProperties(context, node.arguments[0] as TSESTree.ObjectExpression, processZeroUnitInStyleObject);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue