mirror of
https://github.com/antebudimir/eslint-plugin-vanilla-extract.git
synced 2025-12-31 08:53:33 +00:00
feat 🥁: add wrapper function support with reference tracking
- add reference tracking for wrapper functions in vanilla-extract style objects - implement ReferenceTracker class for detecting vanilla-extract imports - add createReferenceBasedNodeVisitors for automatic function detection - support wrapper functions with parameter mapping enable all lint rules to work with custom wrapper functions This commit introduces robust reference tracking and wrapper function support, enabling all lint rules to work seamlessly with custom vanilla-extract style patterns while preserving compatibility with existing usage and improving rule extensibility.
This commit is contained in:
parent
35875fbb31
commit
02576d923c
15 changed files with 1942 additions and 212 deletions
|
|
@ -5,6 +5,13 @@ 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.11.0] - 2025-06-25
|
||||
|
||||
- add reference tracking for wrapper functions in vanilla-extract style objects
|
||||
- implement ReferenceTracker class for detecting vanilla-extract imports
|
||||
- add createReferenceBasedNodeVisitors for automatic function detection
|
||||
- support wrapper functions with parameter mapping enable all lint rules to work with custom wrapper functions
|
||||
|
||||
## [1.10.0] - 2025-04-19
|
||||
|
||||
- confirm compatibility with ESLint 8.57.0
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ ordering option based on groups available in [concentric CSS](src/css-rules/conc
|
|||
- Handles complex cases like nested objects, arrays of styles, and pseudo selectors
|
||||
- Works with camelCase properties as used in vanilla-extract
|
||||
- Additional linting rules for enhanced code quality (see roadmap for upcoming features)
|
||||
- Automatic wrapper function detection - works with custom wrapper functions that call vanilla-extract APIs, using
|
||||
reference tracking to apply all rules regardless of how vanilla-extract functions are wrapped
|
||||
|
||||
## Requirements
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@antebudimir/eslint-plugin-vanilla-extract",
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"description": "ESLint plugin for enforcing best practices in vanilla-extract CSS styles, including CSS property ordering and additional linting rules.",
|
||||
"author": "Ante Budimir",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
199
src/css-rules/alphabetical-order/__tests__/style-wrapper.test.ts
Normal file
199
src/css-rules/alphabetical-order/__tests__/style-wrapper.test.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import tsParser from '@typescript-eslint/parser';
|
||||
import { run } from 'eslint-vitest-rule-tester';
|
||||
import alphabeticalOrderRule from '../rule-definition.js';
|
||||
|
||||
run({
|
||||
name: 'vanilla-extract/alphabetical-order/style',
|
||||
rule: alphabeticalOrderRule,
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
valid: [
|
||||
// Basic style object with alphabetical ordering
|
||||
`
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
const myStyle = layerStyle('component', {
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'red',
|
||||
color: 'blue',
|
||||
display: 'flex',
|
||||
margin: '10px',
|
||||
padding: '20px',
|
||||
zIndex: 1
|
||||
});
|
||||
`,
|
||||
|
||||
// Style with nested selectors
|
||||
`
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
const myStyle = layerStyle('component', {
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'red',
|
||||
color: 'blue',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
backgroundColor: 'blue',
|
||||
color: 'white'
|
||||
}
|
||||
}
|
||||
});
|
||||
`,
|
||||
],
|
||||
invalid: [
|
||||
// Basic style object with incorrect ordering
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
const myStyle = layerStyle('component', {
|
||||
backgroundColor: 'red',
|
||||
alignItems: 'center',
|
||||
padding: '20px',
|
||||
color: 'blue',
|
||||
margin: '10px',
|
||||
display: 'flex',
|
||||
zIndex: 1
|
||||
});
|
||||
`,
|
||||
errors: [{ messageId: 'alphabeticalOrder' }],
|
||||
output: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
const myStyle = layerStyle('component', {
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'red',
|
||||
color: 'blue',
|
||||
display: 'flex',
|
||||
margin: '10px',
|
||||
padding: '20px',
|
||||
zIndex: 1
|
||||
});
|
||||
`,
|
||||
},
|
||||
|
||||
// Style with nested selectors having incorrect ordering
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
const myStyle = layerStyle('component', {
|
||||
backgroundColor: 'red',
|
||||
alignItems: 'center',
|
||||
color: 'blue',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
color: 'white',
|
||||
backgroundColor: 'blue'
|
||||
}
|
||||
}
|
||||
});
|
||||
`,
|
||||
errors: [{ messageId: 'alphabeticalOrder' }, { messageId: 'alphabeticalOrder' }],
|
||||
output: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
const myStyle = layerStyle('component', {
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'red',
|
||||
color: 'blue',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
backgroundColor: 'blue',
|
||||
color: 'white'
|
||||
}
|
||||
}
|
||||
});
|
||||
`,
|
||||
},
|
||||
],
|
||||
});
|
||||
217
src/css-rules/concentric-order/__tests__/style-wrapper.test.ts
Normal file
217
src/css-rules/concentric-order/__tests__/style-wrapper.test.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import tsParser from '@typescript-eslint/parser';
|
||||
import { run } from 'eslint-vitest-rule-tester';
|
||||
import concentricOrderRule from '../rule-definition.js';
|
||||
|
||||
run({
|
||||
name: 'vanilla-extract/concentric-order/style-custom',
|
||||
rule: concentricOrderRule,
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
valid: [
|
||||
// Basic style object with concentric ordering through wrapper function
|
||||
`
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
const myStyle = layerStyle('component', {
|
||||
boxSizing: 'border-box',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
transform: 'none',
|
||||
opacity: 1,
|
||||
margin: '1rem',
|
||||
border: '1px solid black',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
backgroundColor: 'red',
|
||||
padding: '2rem',
|
||||
width: '10rem',
|
||||
height: '10rem',
|
||||
color: 'blue',
|
||||
fontSize: '16rem'
|
||||
});
|
||||
`,
|
||||
|
||||
// Style with nested selectors
|
||||
`
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
const myStyle = layerStyle('component', {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'red',
|
||||
color: 'blue',
|
||||
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
position: 'relative',
|
||||
opacity: 0.8,
|
||||
backgroundColor: 'blue',
|
||||
color: 'white'
|
||||
}
|
||||
}
|
||||
});
|
||||
`,
|
||||
],
|
||||
invalid: [
|
||||
// Basic style object with incorrect concentric ordering
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
const myStyle = layerStyle('component', {
|
||||
color: 'blue',
|
||||
width: '10rem',
|
||||
display: 'flex',
|
||||
backgroundColor: 'red',
|
||||
margin: '1rem',
|
||||
position: 'relative'
|
||||
});
|
||||
`,
|
||||
errors: [{ messageId: 'incorrectOrder' }],
|
||||
output: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
const myStyle = layerStyle('component', {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
margin: '1rem',
|
||||
backgroundColor: 'red',
|
||||
width: '10rem',
|
||||
color: 'blue'
|
||||
});
|
||||
`,
|
||||
},
|
||||
|
||||
// Style with nested selectors having incorrect ordering
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
const myStyle = layerStyle('component', {
|
||||
color: 'blue',
|
||||
display: 'flex',
|
||||
backgroundColor: 'red',
|
||||
position: 'relative',
|
||||
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
color: 'white',
|
||||
position: 'relative',
|
||||
backgroundColor: 'blue'
|
||||
}
|
||||
}
|
||||
});
|
||||
`,
|
||||
errors: [{ messageId: 'incorrectOrder' }, { messageId: 'incorrectOrder' }],
|
||||
output: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
const myStyle = layerStyle('component', {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
backgroundColor: 'red',
|
||||
color: 'blue',
|
||||
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
position: 'relative',
|
||||
backgroundColor: 'blue',
|
||||
color: 'white'
|
||||
}
|
||||
}
|
||||
});
|
||||
`,
|
||||
},
|
||||
],
|
||||
});
|
||||
379
src/css-rules/custom-order/__tests__/style-wrapper.test.ts
Normal file
379
src/css-rules/custom-order/__tests__/style-wrapper.test.ts
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
import tsParser from '@typescript-eslint/parser';
|
||||
import { run } from 'eslint-vitest-rule-tester';
|
||||
import customOrderRule from '../rule-definition.js';
|
||||
|
||||
run({
|
||||
name: 'vanilla-extract/custom-order/style-custom',
|
||||
rule: customOrderRule,
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
valid: [
|
||||
// Basic style object with custom group ordering through wrapper function
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
const myStyle = layerStyle('component', {
|
||||
// dimensions group first
|
||||
width: '10rem',
|
||||
height: '5rem',
|
||||
|
||||
// margin group second
|
||||
margin: '1rem',
|
||||
|
||||
// font group third
|
||||
fontFamily: 'sans-serif',
|
||||
|
||||
// border group fourth
|
||||
border: '1px solid black',
|
||||
|
||||
// boxShadow group fifth
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
|
||||
// remaining properties in concentric order
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
backgroundColor: 'red',
|
||||
padding: '2rem',
|
||||
color: 'blue'
|
||||
});
|
||||
`,
|
||||
options: [
|
||||
{
|
||||
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
|
||||
sortRemainingProperties: 'concentric',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Style with nested selectors
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
const myStyle = layerStyle('component', {
|
||||
width: '10rem',
|
||||
margin: '1rem',
|
||||
fontFamily: 'sans-serif',
|
||||
border: '1px solid black',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
position: 'relative',
|
||||
backgroundColor: 'red',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
width: '12rem',
|
||||
margin: '2rem',
|
||||
fontFamily: 'serif',
|
||||
border: '2px solid blue',
|
||||
boxShadow: '0 4px 8px rgba(0,0,0,0.2)',
|
||||
position: 'absolute',
|
||||
backgroundColor: 'blue',
|
||||
color: 'white'
|
||||
}
|
||||
}
|
||||
});
|
||||
`,
|
||||
options: [
|
||||
{
|
||||
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
|
||||
sortRemainingProperties: 'concentric',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{ '@layer': { [layerMap[layer]]: rule } },
|
||||
debugId,
|
||||
);
|
||||
|
||||
const myStyle = layerStyle('component', {
|
||||
// dimensions group first
|
||||
width: '10rem',
|
||||
height: '10rem',
|
||||
|
||||
// margin group second
|
||||
margin: '1rem',
|
||||
|
||||
// font group third
|
||||
fontFamily: 'sans-serif',
|
||||
|
||||
// border group fourth
|
||||
border: '1px solid black',
|
||||
|
||||
// boxShadow group fifth
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
|
||||
// remaining properties in alphabetical order
|
||||
backgroundColor: 'red',
|
||||
color: 'blue',
|
||||
display: 'flex',
|
||||
padding: '2rem',
|
||||
position: 'relative'
|
||||
});
|
||||
`,
|
||||
options: [
|
||||
{
|
||||
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
|
||||
sortRemainingProperties: 'alphabetical',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
// Basic style object with incorrect custom group ordering
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
const myStyle = layerStyle('component', {
|
||||
position: 'relative',
|
||||
border: '1px solid black',
|
||||
width: '10rem',
|
||||
color: 'blue',
|
||||
margin: '1rem',
|
||||
fontFamily: 'sans-serif',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
backgroundColor: 'red'
|
||||
});
|
||||
`,
|
||||
options: [
|
||||
{
|
||||
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
|
||||
sortRemainingProperties: 'concentric',
|
||||
},
|
||||
],
|
||||
errors: [{ messageId: 'incorrectOrder' }],
|
||||
output: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
const myStyle = layerStyle('component', {
|
||||
width: '10rem',
|
||||
margin: '1rem',
|
||||
fontFamily: 'sans-serif',
|
||||
border: '1px solid black',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
position: 'relative',
|
||||
backgroundColor: 'red',
|
||||
color: 'blue'
|
||||
});
|
||||
`,
|
||||
},
|
||||
|
||||
// Style with nested selectors having incorrect ordering
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
const myStyle = layerStyle('component', {
|
||||
border: '1px solid black',
|
||||
width: '10rem',
|
||||
position: 'relative',
|
||||
margin: '1rem',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
color: 'white',
|
||||
width: '12rem',
|
||||
border: '2px solid blue',
|
||||
margin: '1.2rem',
|
||||
backgroundColor: 'blue'
|
||||
}
|
||||
}
|
||||
});
|
||||
`,
|
||||
options: [
|
||||
{
|
||||
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
|
||||
sortRemainingProperties: 'concentric',
|
||||
},
|
||||
],
|
||||
errors: [{ messageId: 'incorrectOrder' }, { messageId: 'incorrectOrder' }],
|
||||
output: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
const myStyle = layerStyle('component', {
|
||||
width: '10rem',
|
||||
margin: '1rem',
|
||||
border: '1px solid black',
|
||||
position: 'relative',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
width: '12rem',
|
||||
margin: '1.2rem',
|
||||
border: '2px solid blue',
|
||||
backgroundColor: 'blue',
|
||||
color: 'white'
|
||||
}
|
||||
}
|
||||
});
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
const myStyle = layerStyle('component', {
|
||||
position: 'relative',
|
||||
border: '1px solid black',
|
||||
width: '10rem',
|
||||
padding: '2rem',
|
||||
color: 'blue',
|
||||
margin: '1rem',
|
||||
display: 'flex',
|
||||
fontFamily: 'sans-serif',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
backgroundColor: 'red'
|
||||
});
|
||||
`,
|
||||
options: [
|
||||
{
|
||||
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
|
||||
sortRemainingProperties: 'alphabetical',
|
||||
},
|
||||
],
|
||||
errors: [{ messageId: 'incorrectOrder' }],
|
||||
output: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
const myStyle = layerStyle('component', {
|
||||
width: '10rem',
|
||||
margin: '1rem',
|
||||
fontFamily: 'sans-serif',
|
||||
border: '1px solid black',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
backgroundColor: 'red',
|
||||
color: 'blue',
|
||||
display: 'flex',
|
||||
padding: '2rem',
|
||||
position: 'relative'
|
||||
});
|
||||
`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -55,13 +55,22 @@ describe('isEffectivelyEmptyStylesObject', () => {
|
|||
parent: null as unknown as TSESTree.Node,
|
||||
});
|
||||
|
||||
it('should return true for an object with empty selectors, media, or supports objects', () => {
|
||||
it('should return false for an object with real CSS properties and empty nested objects', () => {
|
||||
const object = createObjectExpression([
|
||||
createProperty('color', createLiteral('blue')),
|
||||
createProperty('selectors', createObjectExpression([])),
|
||||
createProperty('@media', createObjectExpression([])),
|
||||
createProperty('@supports', createObjectExpression([])),
|
||||
]);
|
||||
expect(isEffectivelyEmptyStylesObject(object)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for an object with only empty nested objects', () => {
|
||||
const object = createObjectExpression([
|
||||
createProperty('selectors', createObjectExpression([])),
|
||||
createProperty('@media', createObjectExpression([])),
|
||||
createProperty('@supports', createObjectExpression([])),
|
||||
]);
|
||||
expect(isEffectivelyEmptyStylesObject(object)).toBe(true);
|
||||
});
|
||||
|
||||
|
|
|
|||
305
src/css-rules/no-empty-blocks/__tests__/style-wrapper.test.ts
Normal file
305
src/css-rules/no-empty-blocks/__tests__/style-wrapper.test.ts
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
import tsParser from '@typescript-eslint/parser';
|
||||
import { run } from 'eslint-vitest-rule-tester';
|
||||
import noEmptyStyleBlocksRule from '../rule-definition.js';
|
||||
|
||||
run({
|
||||
name: 'vanilla-extract/no-empty-blocks/style-custom',
|
||||
rule: noEmptyStyleBlocksRule,
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
valid: [
|
||||
// Basic non-empty style through wrapper function
|
||||
`
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
const myStyle = layerStyle('component', {
|
||||
color: 'blue',
|
||||
margin: '10px'
|
||||
});
|
||||
`,
|
||||
|
||||
// Style with comments (not empty)
|
||||
`
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
const myStyle = layerStyle('component', {
|
||||
/* This is a comment */
|
||||
color: 'blue'
|
||||
});
|
||||
`,
|
||||
],
|
||||
invalid: [
|
||||
// Empty style object through wrapper function
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
const emptyStyle = layerStyle('component', {});
|
||||
`,
|
||||
errors: [{ messageId: 'emptyStyleDeclaration' }],
|
||||
output: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
`,
|
||||
},
|
||||
|
||||
// Empty exported style object
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
export const emptyStyle = layerStyle('component', {});
|
||||
`,
|
||||
errors: [{ messageId: 'emptyStyleDeclaration' }],
|
||||
output: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
`,
|
||||
},
|
||||
|
||||
// Style with empty nested selectors
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
const styleWithComments = layerStyle('component', {
|
||||
/* This is an empty style */
|
||||
});
|
||||
`,
|
||||
errors: [{ messageId: 'emptyStyleDeclaration' }],
|
||||
output: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
export const emptyStyle1 = layerStyle('component', {});
|
||||
export const emptyStyle2 = layerStyle('component', {});
|
||||
`,
|
||||
errors: [{ messageId: 'emptyStyleDeclaration' }, { messageId: 'emptyStyleDeclaration' }],
|
||||
output: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
`,
|
||||
},
|
||||
|
||||
// Export of variable with empty style
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
const myEmptyStyle = layerStyle('component', {});
|
||||
export { myEmptyStyle };
|
||||
`,
|
||||
errors: [{ messageId: 'emptyStyleDeclaration' }],
|
||||
output: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
export { myEmptyStyle };
|
||||
`,
|
||||
},
|
||||
|
||||
// Style in a callback or nested function
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const layerStyle = (
|
||||
layer: 'reset' | 'theme' | 'component' | 'utilities',
|
||||
rule: StyleRule,
|
||||
debugId?: string,
|
||||
) =>
|
||||
style(
|
||||
{
|
||||
'@layer': {
|
||||
[layerMap[layer]]: rule,
|
||||
},
|
||||
},
|
||||
debugId,
|
||||
);
|
||||
|
||||
[1, 2, 3].forEach(() => {
|
||||
layerStyle('component', {});
|
||||
});
|
||||
`,
|
||||
errors: [{ messageId: 'emptyStyleDeclaration' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -1,14 +1,35 @@
|
|||
import type { Rule } from 'eslint';
|
||||
import { TSESTree } from '@typescript-eslint/utils';
|
||||
import { isEmptyObject } from '../shared-utils/empty-object-processor.js';
|
||||
import { ReferenceTracker, createReferenceTrackingVisitor } from '../shared-utils/reference-tracker.js';
|
||||
import { processConditionalExpression } from './conditional-processor.js';
|
||||
import { processEmptyNestedStyles } from './empty-nested-style-processor.js';
|
||||
import { reportEmptyDeclaration } from './fix-utils.js';
|
||||
import { removeNodeWithComma } from './node-remover.js';
|
||||
import { getStyleKeyName } from './property-utils.js';
|
||||
import { processRecipeProperties } from './recipe-processor.js';
|
||||
import { processStyleVariants } from './style-variants-processor.js';
|
||||
|
||||
/**
|
||||
* Checks if a nested object (selectors, media, supports) contains only empty objects.
|
||||
*/
|
||||
const isNestedObjectEmpty = (obj: TSESTree.ObjectExpression): boolean => {
|
||||
if (obj.properties.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return obj.properties.every((property) => {
|
||||
if (property.type !== 'Property') {
|
||||
return true; // Skip non-property elements
|
||||
}
|
||||
|
||||
if (property.value.type === 'ObjectExpression') {
|
||||
return isEmptyObject(property.value);
|
||||
}
|
||||
|
||||
return false; // Non-object values mean it's not empty
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a style object is effectively empty (contains only empty objects).
|
||||
*/
|
||||
|
|
@ -48,124 +69,127 @@ export const isEffectivelyEmptyStylesObject = (stylesObject: TSESTree.ObjectExpr
|
|||
}
|
||||
}
|
||||
|
||||
// If this looks like a recipe object (has base or variants)
|
||||
// If this looks like a recipe (has base or variants), check recipe-specific emptiness
|
||||
if (hasBaseProperty || hasVariantsProperty) {
|
||||
// A recipe is effectively empty if both base and variants are empty
|
||||
return isBaseEmpty && areAllVariantsEmpty;
|
||||
}
|
||||
|
||||
// / For non-recipe objects, check if all special properties (selectors, media queries, variants) are effectively empty
|
||||
function isSpecialProperty(propertyName: string | null): boolean {
|
||||
return (
|
||||
propertyName === 'selectors' || (propertyName && propertyName.startsWith('@')) || propertyName === 'variants'
|
||||
);
|
||||
}
|
||||
|
||||
const specialProperties = stylesObject.properties.filter(
|
||||
(prop): prop is TSESTree.Property => prop.type === 'Property' && isSpecialProperty(getStyleKeyName(prop.key)),
|
||||
);
|
||||
|
||||
const allSpecialPropertiesEmpty = specialProperties.every((property) => {
|
||||
if (property.value.type === 'ObjectExpression' && isEmptyObject(property.value)) {
|
||||
return true;
|
||||
// For regular style objects, check if all properties are effectively empty
|
||||
return stylesObject.properties.every((property) => {
|
||||
if (property.type !== 'Property') {
|
||||
return true; // Skip spread elements for emptiness check
|
||||
}
|
||||
|
||||
const propertyName = getStyleKeyName(property.key);
|
||||
// This defensive check handles malformed AST nodes that lack valid property names.
|
||||
// This is difficult to test because it's challenging to construct a valid AST
|
||||
// where getStyleKeyName would return a falsy value.
|
||||
if (!propertyName) {
|
||||
return false;
|
||||
return true; // Skip properties we can't identify
|
||||
}
|
||||
|
||||
// For selectors, media queries and supports, check if all nested objects are empty
|
||||
if (
|
||||
(propertyName === 'selectors' || (propertyName && propertyName.startsWith('@'))) &&
|
||||
property.value.type === 'ObjectExpression'
|
||||
) {
|
||||
// This handles the edge case of an empty properties array.
|
||||
// This code path is difficult to test in isolation because it requires
|
||||
// constructing a specific AST structure that bypasses earlier conditions.
|
||||
if (property.value.properties.length === 0) {
|
||||
return true;
|
||||
// Handle special nested objects like selectors, media queries, supports
|
||||
if (propertyName === 'selectors' || propertyName.startsWith('@')) {
|
||||
if (property.value.type === 'ObjectExpression') {
|
||||
return isNestedObjectEmpty(property.value);
|
||||
}
|
||||
return false; // Non-object values in these properties
|
||||
}
|
||||
|
||||
return property.value.properties.every((nestedProperty) => {
|
||||
return (
|
||||
nestedProperty.type === 'Property' &&
|
||||
nestedProperty.value.type === 'ObjectExpression' &&
|
||||
isEmptyObject(nestedProperty.value)
|
||||
);
|
||||
// Handle regular CSS properties
|
||||
if (property.value.type === 'ObjectExpression') {
|
||||
return isEmptyObject(property.value);
|
||||
}
|
||||
|
||||
return false; // Non-empty property (literal values, etc.)
|
||||
});
|
||||
}
|
||||
|
||||
// Default fallback for cases not handled by the conditions above.
|
||||
// This is difficult to test because it requires creating an AST structure
|
||||
// that doesn't trigger any of the preceding return statements.
|
||||
return false;
|
||||
});
|
||||
|
||||
// If we have special properties and they're all empty, the style is effectively empty
|
||||
return specialProperties.length > 0 && allSpecialPropertiesEmpty;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates ESLint rule visitors for detecting empty style blocks in vanilla-extract.
|
||||
* @param ruleContext The ESLint rule rule context.
|
||||
* @returns An object with visitor functions for the ESLint rule.
|
||||
* Creates ESLint rule visitors for detecting empty style blocks using reference tracking.
|
||||
* This automatically detects vanilla-extract functions based on their import statements.
|
||||
*/
|
||||
export const createEmptyStyleVisitors = (ruleContext: Rule.RuleContext): Rule.RuleListener => {
|
||||
// Track reported nodes to prevent duplicate reports
|
||||
const tracker = new ReferenceTracker();
|
||||
const trackingVisitor = createReferenceTrackingVisitor(tracker);
|
||||
const reportedNodes = new Set<TSESTree.ObjectExpression>();
|
||||
|
||||
return {
|
||||
// Include the reference tracking visitors
|
||||
...trackingVisitor,
|
||||
|
||||
CallExpression(node) {
|
||||
if (node.callee.type !== 'Identifier') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Target vanilla-extract style functions
|
||||
const styleApiFunctions = [
|
||||
'style',
|
||||
'styleVariants',
|
||||
'recipe',
|
||||
'globalStyle',
|
||||
'fontFace',
|
||||
'globalFontFace',
|
||||
'keyframes',
|
||||
'globalKeyframes',
|
||||
];
|
||||
const functionName = node.callee.name;
|
||||
|
||||
if (!styleApiFunctions.includes(node.callee.name) || node.arguments.length === 0) {
|
||||
// Check if this function is tracked as a vanilla-extract function
|
||||
if (!tracker.isTrackedFunction(functionName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalName = tracker.getOriginalName(functionName);
|
||||
const wrapperInfo = tracker.getWrapperInfo(functionName);
|
||||
|
||||
if (!originalName || node.arguments.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle styleVariants specifically
|
||||
if (node.callee.name === 'styleVariants' && node.arguments[0]?.type === 'ObjectExpression') {
|
||||
processStyleVariants(ruleContext, node.arguments[0] as TSESTree.ObjectExpression, reportedNodes);
|
||||
if (originalName === 'styleVariants') {
|
||||
// For wrapper functions, use the correct parameter index
|
||||
const styleArgumentIndex = wrapperInfo?.parameterMapping ?? 0;
|
||||
if (node.arguments.length <= styleArgumentIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.arguments[styleArgumentIndex]?.type === 'ObjectExpression') {
|
||||
processStyleVariants(
|
||||
ruleContext,
|
||||
node.arguments[styleArgumentIndex] as TSESTree.ObjectExpression,
|
||||
reportedNodes,
|
||||
);
|
||||
|
||||
// If the entire styleVariants object is empty after processing, remove the declaration
|
||||
if (isEmptyObject(node.arguments[0] as TSESTree.ObjectExpression)) {
|
||||
reportEmptyDeclaration(ruleContext, node.arguments[0] as TSESTree.Node, node as TSESTree.CallExpression);
|
||||
if (isEmptyObject(node.arguments[styleArgumentIndex] as TSESTree.ObjectExpression)) {
|
||||
reportEmptyDeclaration(
|
||||
ruleContext,
|
||||
node.arguments[styleArgumentIndex] as TSESTree.Node,
|
||||
node as TSESTree.CallExpression,
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultStyleArgumentIndex = 0;
|
||||
const globalFunctionNames = ['globalStyle', 'globalFontFace', 'globalKeyframes'];
|
||||
// Determine the style argument index based on the function name
|
||||
const styleArgumentIndex = globalFunctionNames.includes(node.callee.name) ? 1 : defaultStyleArgumentIndex;
|
||||
// Determine the style argument index based on the original function name and wrapper info
|
||||
let styleArgumentIndex: number;
|
||||
if (wrapperInfo) {
|
||||
// Use wrapper function parameter mapping
|
||||
styleArgumentIndex = wrapperInfo.parameterMapping;
|
||||
} else {
|
||||
// Use original logic for direct vanilla-extract calls
|
||||
styleArgumentIndex =
|
||||
originalName === 'globalStyle' || originalName === 'globalKeyframes' || originalName === 'globalFontFace'
|
||||
? 1
|
||||
: 0;
|
||||
}
|
||||
|
||||
// For global functions, check if we have enough arguments
|
||||
if (styleArgumentIndex === 1 && node.arguments.length <= styleArgumentIndex) {
|
||||
if (
|
||||
(originalName === 'globalStyle' || originalName === 'globalKeyframes' || originalName === 'globalFontFace') &&
|
||||
node.arguments.length <= styleArgumentIndex
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For wrapper functions, ensure we have enough arguments
|
||||
if (wrapperInfo && node.arguments.length <= styleArgumentIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
const styleArgument = node.arguments[styleArgumentIndex];
|
||||
|
||||
// This defensive check prevents duplicate processing of nodes.
|
||||
// This code path's difficult to test because the ESLint visitor pattern
|
||||
// typically ensures each node is only visited once per rule execution.
|
||||
if (reportedNodes.has(styleArgument as TSESTree.ObjectExpression)) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -189,7 +213,8 @@ export const createEmptyStyleVisitors = (ruleContext: Rule.RuleContext): Rule.Ru
|
|||
}
|
||||
|
||||
// For recipe - check if entire recipe is effectively empty
|
||||
if (node.callee.name === 'recipe' && styleArgument?.type === 'ObjectExpression') {
|
||||
if (originalName === 'recipe') {
|
||||
if (styleArgument?.type === 'ObjectExpression') {
|
||||
if (isEffectivelyEmptyStylesObject(styleArgument as TSESTree.ObjectExpression)) {
|
||||
reportedNodes.add(styleArgument as TSESTree.ObjectExpression);
|
||||
reportEmptyDeclaration(ruleContext, styleArgument as TSESTree.Node, node as TSESTree.CallExpression);
|
||||
|
|
@ -199,6 +224,19 @@ export const createEmptyStyleVisitors = (ruleContext: Rule.RuleContext): Rule.Ru
|
|||
// Process individual properties in recipe
|
||||
processRecipeProperties(ruleContext, styleArgument as TSESTree.ObjectExpression, reportedNodes);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle fontFace functions - both fontFace and globalFontFace need empty object checks
|
||||
if (originalName === 'fontFace' || originalName === 'globalFontFace') {
|
||||
// Direct empty object case - remove the entire declaration
|
||||
if (styleArgument?.type === 'ObjectExpression' && isEmptyObject(styleArgument as TSESTree.ObjectExpression)) {
|
||||
reportedNodes.add(styleArgument as TSESTree.ObjectExpression);
|
||||
reportEmptyDeclaration(ruleContext, styleArgument as TSESTree.Node, node as TSESTree.CallExpression);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// For style objects with nested empty objects
|
||||
if (styleArgument?.type === 'ObjectExpression') {
|
||||
|
|
@ -214,7 +252,10 @@ export const createEmptyStyleVisitors = (ruleContext: Rule.RuleContext): Rule.Ru
|
|||
node: property.argument as Rule.Node,
|
||||
messageId: 'emptySpreadObject',
|
||||
fix(fixer) {
|
||||
return removeNodeWithComma(ruleContext, property as TSESTree.Node, fixer);
|
||||
if (property.range) {
|
||||
return fixer.removeRange([property.range[0], property.range[1]]);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,52 +1,79 @@
|
|||
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 { processUnknownUnitInStyleObject } from './unknown-unit-processor.js';
|
||||
|
||||
/**
|
||||
* Creates ESLint rule visitors for detecting and processing unknown CSS units
|
||||
* in style-related function calls.
|
||||
* in style-related function calls using reference tracking.
|
||||
* This automatically detects vanilla-extract functions based on their import statements.
|
||||
*/
|
||||
export const createUnknownUnitVisitors = (context: Rule.RuleContext): Rule.RuleListener => {
|
||||
const tracker = new ReferenceTracker();
|
||||
const trackingVisitor = createReferenceTrackingVisitor(tracker);
|
||||
|
||||
return {
|
||||
// Include the import/variable tracking visitors
|
||||
...trackingVisitor,
|
||||
|
||||
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
|
||||
) {
|
||||
processUnknownUnitInStyleObject(context, node.arguments[argumentIndex] as TSESTree.ObjectExpression);
|
||||
}
|
||||
const functionName = node.callee.name;
|
||||
|
||||
// Check if this function is tracked as a vanilla-extract function
|
||||
if (!tracker.isTrackedFunction(functionName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (['keyframes', 'style', 'styleVariants'].includes(node.callee.name)) {
|
||||
const originalName = tracker.getOriginalName(functionName);
|
||||
if (!originalName) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle different function types based on their original imported name
|
||||
switch (originalName) {
|
||||
case 'fontFace':
|
||||
if (node.arguments.length > 0 && node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression) {
|
||||
processUnknownUnitInStyleObject(context, node.arguments[0] as TSESTree.ObjectExpression);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'globalFontFace':
|
||||
if (node.arguments.length > 1 && node.arguments[1]?.type === AST_NODE_TYPES.ObjectExpression) {
|
||||
processUnknownUnitInStyleObject(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.ObjectExpression, processUnknownUnitInStyleObject);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
if (['globalKeyframes', 'globalStyle'].includes(node.callee.name) && node.arguments.length >= 2) {
|
||||
case 'globalStyle':
|
||||
case 'globalKeyframes':
|
||||
if (node.arguments.length >= 2) {
|
||||
processStyleNode(context, node.arguments[1] as TSESTree.ObjectExpression, processUnknownUnitInStyleObject);
|
||||
}
|
||||
break;
|
||||
|
||||
if (
|
||||
node.callee.name === 'recipe' &&
|
||||
node.arguments.length > 0 &&
|
||||
node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression
|
||||
) {
|
||||
case 'recipe':
|
||||
if (node.arguments.length > 0 && node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression) {
|
||||
processRecipeProperties(
|
||||
context,
|
||||
node.arguments[0] as TSESTree.ObjectExpression,
|
||||
processUnknownUnitInStyleObject,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,55 +1,80 @@
|
|||
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 { processZeroUnitInStyleObject } from './zero-unit-processor.js';
|
||||
|
||||
/**
|
||||
* Creates ESLint rule visitors for detecting and processing zero values with units in style-related function calls.
|
||||
* Uses reference tracking to automatically detect vanilla-extract functions based on their import statements.
|
||||
*
|
||||
* @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 => {
|
||||
const tracker = new ReferenceTracker();
|
||||
const trackingVisitor = createReferenceTrackingVisitor(tracker);
|
||||
|
||||
return {
|
||||
// Include the reference tracking visitors
|
||||
...trackingVisitor,
|
||||
|
||||
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);
|
||||
}
|
||||
const functionName = node.callee.name;
|
||||
|
||||
// Check if this function is tracked as a vanilla-extract function
|
||||
if (!tracker.isTrackedFunction(functionName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (['keyframes', 'style', 'styleVariants'].includes(node.callee.name)) {
|
||||
const originalName = tracker.getOriginalName(functionName);
|
||||
if (!originalName) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle different function types based on their original imported name
|
||||
switch (originalName) {
|
||||
case 'fontFace':
|
||||
if (node.arguments.length > 0 && node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression) {
|
||||
processZeroUnitInStyleObject(context, node.arguments[0] as TSESTree.ObjectExpression);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'globalFontFace':
|
||||
if (node.arguments.length > 1 && node.arguments[1]?.type === AST_NODE_TYPES.ObjectExpression) {
|
||||
processZeroUnitInStyleObject(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.ObjectExpression, processZeroUnitInStyleObject);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
if (['globalKeyframes', 'globalStyle'].includes(node.callee.name) && node.arguments.length >= 2) {
|
||||
case 'globalStyle':
|
||||
case 'globalKeyframes':
|
||||
if (node.arguments.length >= 2) {
|
||||
processStyleNode(context, node.arguments[1] as TSESTree.ObjectExpression, processZeroUnitInStyleObject);
|
||||
}
|
||||
break;
|
||||
|
||||
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);
|
||||
case 'recipe':
|
||||
if (node.arguments.length > 0 && node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression) {
|
||||
processRecipeProperties(
|
||||
context,
|
||||
node.arguments[0] as TSESTree.ObjectExpression,
|
||||
processZeroUnitInStyleObject,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,25 +7,20 @@ import { enforceConcentricCSSOrderInStyleObject } from '../concentric-order/styl
|
|||
import { enforceUserDefinedGroupOrderInRecipe } from '../custom-order/recipe-order-enforcer.js';
|
||||
import { enforceUserDefinedGroupOrderInStyleObject } from '../custom-order/style-object-processor.js';
|
||||
import { enforceFontFaceOrder } from './font-face-property-order-enforcer.js';
|
||||
import { ReferenceTracker, createReferenceTrackingVisitor } from './reference-tracker.js';
|
||||
import { processStyleNode } from './style-node-processor.js';
|
||||
import type { SortRemainingProperties } from '../concentric-order/types.js';
|
||||
import type { OrderingStrategy } from '../types.js';
|
||||
|
||||
/**
|
||||
* Creates an ESLint rule listener with visitors for style-related function calls.
|
||||
* Creates an ESLint rule listener with visitors for style-related function calls using reference tracking.
|
||||
* This automatically detects vanilla-extract functions based on their import statements.
|
||||
*
|
||||
* @param ruleContext The ESLint rule context.
|
||||
* @param orderingStrategy The strategy to use for ordering CSS properties ('alphabetical', 'concentric', or 'userDefinedGroupOrder').
|
||||
* @param userDefinedGroupOrder An optional array of property groups for the 'userDefinedGroupOrder' strategy.
|
||||
* @param sortRemainingProperties An optional strategy for sorting properties not in user-defined groups.
|
||||
* @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.
|
||||
* 2. Style-related functions: 'keyframes', 'style', 'styleVariants'.
|
||||
* 3. The 'globalStyle' and 'globalKeyframes' function
|
||||
* 4. The 'recipe' function
|
||||
*
|
||||
* Each visitor applies the appropriate ordering strategy to the style objects in these function calls.
|
||||
*/
|
||||
export const createNodeVisitors = (
|
||||
ruleContext: Rule.RuleContext,
|
||||
|
|
@ -33,7 +28,95 @@ export const createNodeVisitors = (
|
|||
userDefinedGroupOrder?: string[],
|
||||
sortRemainingProperties?: SortRemainingProperties,
|
||||
): Rule.RuleListener => {
|
||||
// Select the appropriate property processing function based on the ordering strategy
|
||||
const tracker = new ReferenceTracker();
|
||||
const trackingVisitor = createReferenceTrackingVisitor(tracker);
|
||||
|
||||
return {
|
||||
// Include the import/variable tracking visitors
|
||||
...trackingVisitor,
|
||||
|
||||
CallExpression(node) {
|
||||
if (node.callee.type !== 'Identifier') {
|
||||
return;
|
||||
}
|
||||
|
||||
const functionName = node.callee.name;
|
||||
|
||||
// Check if this function is tracked as a vanilla-extract function
|
||||
if (!tracker.isTrackedFunction(functionName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalName = tracker.getOriginalName(functionName);
|
||||
if (!originalName) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle different function types based on their original imported name
|
||||
switch (originalName) {
|
||||
case 'fontFace':
|
||||
processFontFaceOrdering(ruleContext, node as TSESTree.CallExpression, 0);
|
||||
break;
|
||||
|
||||
case 'globalFontFace':
|
||||
processFontFaceOrdering(ruleContext, node as TSESTree.CallExpression, 1);
|
||||
break;
|
||||
|
||||
case 'style':
|
||||
case 'styleVariants':
|
||||
case 'keyframes':
|
||||
// Check if this is a wrapper function
|
||||
const wrapperInfo = tracker.getWrapperInfo(functionName);
|
||||
const argumentIndex = wrapperInfo?.parameterMapping ?? 0;
|
||||
|
||||
processStyleOrdering(
|
||||
ruleContext,
|
||||
node as TSESTree.CallExpression,
|
||||
orderingStrategy,
|
||||
userDefinedGroupOrder,
|
||||
sortRemainingProperties,
|
||||
argumentIndex,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'globalStyle':
|
||||
case 'globalKeyframes':
|
||||
processStyleOrdering(
|
||||
ruleContext,
|
||||
node as TSESTree.CallExpression,
|
||||
orderingStrategy,
|
||||
userDefinedGroupOrder,
|
||||
sortRemainingProperties,
|
||||
1,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'recipe':
|
||||
processRecipeOrdering(
|
||||
ruleContext,
|
||||
node as TSESTree.CallExpression,
|
||||
orderingStrategy,
|
||||
userDefinedGroupOrder,
|
||||
sortRemainingProperties,
|
||||
);
|
||||
break;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to process style ordering for style-related functions
|
||||
*/
|
||||
const processStyleOrdering = (
|
||||
ruleContext: Rule.RuleContext,
|
||||
node: TSESTree.CallExpression,
|
||||
orderingStrategy: OrderingStrategy,
|
||||
userDefinedGroupOrder?: string[],
|
||||
sortRemainingProperties?: SortRemainingProperties,
|
||||
argumentIndex: number = 0,
|
||||
) => {
|
||||
if (node.arguments.length > argumentIndex) {
|
||||
const processProperty = (() => {
|
||||
switch (orderingStrategy) {
|
||||
case 'alphabetical':
|
||||
|
|
@ -45,76 +128,57 @@ export const createNodeVisitors = (
|
|||
return enforceAlphabeticalCSSOrderInStyleObject;
|
||||
}
|
||||
return (ruleContext: Rule.RuleContext, node: TSESTree.ObjectExpression) =>
|
||||
enforceUserDefinedGroupOrderInStyleObject(ruleContext, node, userDefinedGroupOrder, sortRemainingProperties);
|
||||
enforceUserDefinedGroupOrderInStyleObject(
|
||||
ruleContext,
|
||||
node,
|
||||
userDefinedGroupOrder,
|
||||
sortRemainingProperties,
|
||||
);
|
||||
default:
|
||||
return enforceAlphabeticalCSSOrderInStyleObject;
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
CallExpression(node) {
|
||||
if (node.callee.type !== 'Identifier') {
|
||||
return;
|
||||
processStyleNode(ruleContext, node.arguments[argumentIndex] as TSESTree.ObjectExpression, processProperty);
|
||||
}
|
||||
};
|
||||
|
||||
const fontFaceFunctionArgumentIndexMap = {
|
||||
fontFace: 0, // First argument (index 0)
|
||||
globalFontFace: 1, // Second argument (index 1)
|
||||
};
|
||||
|
||||
// Handle font face functions with special ordering
|
||||
if (
|
||||
node.callee.name in fontFaceFunctionArgumentIndexMap &&
|
||||
node.arguments.length >
|
||||
fontFaceFunctionArgumentIndexMap[node.callee.name as keyof typeof fontFaceFunctionArgumentIndexMap]
|
||||
) {
|
||||
const argumentIndex =
|
||||
fontFaceFunctionArgumentIndexMap[node.callee.name as keyof typeof fontFaceFunctionArgumentIndexMap];
|
||||
const styleArguments = node.arguments[argumentIndex];
|
||||
|
||||
enforceFontFaceOrder(ruleContext, styleArguments as TSESTree.ObjectExpression);
|
||||
|
||||
return;
|
||||
/**
|
||||
* Helper function to process font face ordering
|
||||
*/
|
||||
const processFontFaceOrdering = (
|
||||
ruleContext: Rule.RuleContext,
|
||||
node: TSESTree.CallExpression,
|
||||
argumentIndex: number,
|
||||
) => {
|
||||
if (node.arguments.length > argumentIndex) {
|
||||
enforceFontFaceOrder(ruleContext, node.arguments[argumentIndex] as TSESTree.ObjectExpression);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle style-related functions
|
||||
if (['keyframes', 'style', 'styleVariants'].includes(node.callee.name)) {
|
||||
/**
|
||||
* Helper function to process recipe ordering
|
||||
*/
|
||||
const processRecipeOrdering = (
|
||||
ruleContext: Rule.RuleContext,
|
||||
node: TSESTree.CallExpression,
|
||||
orderingStrategy: OrderingStrategy,
|
||||
userDefinedGroupOrder?: string[],
|
||||
sortRemainingProperties?: SortRemainingProperties,
|
||||
) => {
|
||||
if (node.arguments.length > 0) {
|
||||
const styleArguments = node.arguments[0];
|
||||
processStyleNode(ruleContext, styleArguments as TSESTree.Node, processProperty);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle global functions
|
||||
if (
|
||||
(node.callee.name === 'globalKeyframes' || node.callee.name === 'globalStyle') &&
|
||||
node.arguments.length >= 2
|
||||
) {
|
||||
const styleArguments = node.arguments[1];
|
||||
processStyleNode(ruleContext, styleArguments as TSESTree.Node, processProperty);
|
||||
}
|
||||
|
||||
// Handle recipe function
|
||||
if (node.callee.name === 'recipe') {
|
||||
switch (orderingStrategy) {
|
||||
case 'alphabetical':
|
||||
enforceAlphabeticalCSSOrderInRecipe(node as TSESTree.CallExpression, ruleContext);
|
||||
enforceAlphabeticalCSSOrderInRecipe(node, ruleContext);
|
||||
break;
|
||||
case 'concentric':
|
||||
enforceConcentricCSSOrderInRecipe(ruleContext, node as TSESTree.CallExpression);
|
||||
enforceConcentricCSSOrderInRecipe(ruleContext, node);
|
||||
break;
|
||||
case 'userDefinedGroupOrder':
|
||||
if (userDefinedGroupOrder) {
|
||||
enforceUserDefinedGroupOrderInRecipe(
|
||||
ruleContext,
|
||||
node as TSESTree.CallExpression,
|
||||
userDefinedGroupOrder,
|
||||
sortRemainingProperties,
|
||||
);
|
||||
enforceUserDefinedGroupOrderInRecipe(ruleContext, node, userDefinedGroupOrder, sortRemainingProperties);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
135
src/css-rules/shared-utils/reference-based-visitor-creator.ts
Normal file
135
src/css-rules/shared-utils/reference-based-visitor-creator.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import type { Rule } from 'eslint';
|
||||
import { TSESTree } from '@typescript-eslint/utils';
|
||||
import { enforceAlphabeticalCSSOrderInRecipe } from '../alphabetical-order/recipe-order-enforcer.js';
|
||||
import { enforceAlphabeticalCSSOrderInStyleObject } from '../alphabetical-order/style-object-processor.js';
|
||||
import { enforceConcentricCSSOrderInRecipe } from '../concentric-order/recipe-order-enforcer.js';
|
||||
import { enforceConcentricCSSOrderInStyleObject } from '../concentric-order/style-object-processor.js';
|
||||
import { enforceUserDefinedGroupOrderInRecipe } from '../custom-order/recipe-order-enforcer.js';
|
||||
import { enforceUserDefinedGroupOrderInStyleObject } from '../custom-order/style-object-processor.js';
|
||||
import { enforceFontFaceOrder } from './font-face-property-order-enforcer.js';
|
||||
import { ReferenceTracker, createReferenceTrackingVisitor } from './reference-tracker.js';
|
||||
import { processStyleNode } from './style-node-processor.js';
|
||||
import type { SortRemainingProperties } from '../concentric-order/types.js';
|
||||
import type { OrderingStrategy } from '../types.js';
|
||||
|
||||
/**
|
||||
* Creates an ESLint rule listener with visitors for style-related function calls using reference tracking.
|
||||
* This automatically detects vanilla-extract functions based on their import statements.
|
||||
*
|
||||
* @param ruleContext The ESLint rule context.
|
||||
* @param orderingStrategy The strategy to use for ordering CSS properties.
|
||||
* @param userDefinedGroupOrder An optional array of property groups for the 'userDefinedGroupOrder' strategy.
|
||||
* @param sortRemainingProperties An optional strategy for sorting properties not in user-defined groups.
|
||||
* @returns An object with visitor functions for the ESLint rule.
|
||||
*/
|
||||
export const createReferenceBasedNodeVisitors = (
|
||||
ruleContext: Rule.RuleContext,
|
||||
orderingStrategy: OrderingStrategy,
|
||||
userDefinedGroupOrder?: string[],
|
||||
sortRemainingProperties?: SortRemainingProperties,
|
||||
): Rule.RuleListener => {
|
||||
const tracker = new ReferenceTracker();
|
||||
const trackingVisitor = createReferenceTrackingVisitor(tracker);
|
||||
|
||||
// Select the appropriate property processing function based on the ordering strategy
|
||||
const processProperty = (() => {
|
||||
switch (orderingStrategy) {
|
||||
case 'alphabetical':
|
||||
return enforceAlphabeticalCSSOrderInStyleObject;
|
||||
case 'concentric':
|
||||
return enforceConcentricCSSOrderInStyleObject;
|
||||
case 'userDefinedGroupOrder':
|
||||
if (!userDefinedGroupOrder || userDefinedGroupOrder.length === 0) {
|
||||
return enforceAlphabeticalCSSOrderInStyleObject;
|
||||
}
|
||||
return (ruleContext: Rule.RuleContext, node: TSESTree.ObjectExpression) =>
|
||||
enforceUserDefinedGroupOrderInStyleObject(ruleContext, node, userDefinedGroupOrder, sortRemainingProperties);
|
||||
default:
|
||||
return enforceAlphabeticalCSSOrderInStyleObject;
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
// Include the reference tracking visitors
|
||||
...trackingVisitor,
|
||||
|
||||
CallExpression(callExpression) {
|
||||
if (callExpression.callee.type !== 'Identifier') {
|
||||
return;
|
||||
}
|
||||
|
||||
const functionName = callExpression.callee.name;
|
||||
|
||||
// Check if this function is tracked as a vanilla-extract function
|
||||
if (!tracker.isTrackedFunction(functionName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalName = tracker.getOriginalName(functionName);
|
||||
if (!originalName) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle different function types based on their original imported name
|
||||
switch (originalName) {
|
||||
case 'fontFace':
|
||||
if (callExpression.arguments.length > 0) {
|
||||
const styleArguments = callExpression.arguments[0];
|
||||
enforceFontFaceOrder(ruleContext, styleArguments as TSESTree.ObjectExpression);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'globalFontFace':
|
||||
if (callExpression.arguments.length > 1) {
|
||||
const styleArguments = callExpression.arguments[1];
|
||||
enforceFontFaceOrder(ruleContext, styleArguments as TSESTree.ObjectExpression);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'style':
|
||||
case 'styleVariants':
|
||||
case 'keyframes':
|
||||
if (callExpression.arguments.length > 0) {
|
||||
const styleArguments = callExpression.arguments[0];
|
||||
processStyleNode(ruleContext, styleArguments as TSESTree.Node, processProperty);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'globalStyle':
|
||||
case 'globalKeyframes':
|
||||
if (callExpression.arguments.length > 1) {
|
||||
const styleArguments = callExpression.arguments[1];
|
||||
processStyleNode(ruleContext, styleArguments as TSESTree.Node, processProperty);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'recipe':
|
||||
switch (orderingStrategy) {
|
||||
case 'alphabetical':
|
||||
enforceAlphabeticalCSSOrderInRecipe(callExpression as TSESTree.CallExpression, ruleContext);
|
||||
break;
|
||||
case 'concentric':
|
||||
enforceConcentricCSSOrderInRecipe(ruleContext, callExpression as TSESTree.CallExpression);
|
||||
break;
|
||||
case 'userDefinedGroupOrder':
|
||||
if (userDefinedGroupOrder) {
|
||||
enforceUserDefinedGroupOrderInRecipe(
|
||||
ruleContext,
|
||||
callExpression as TSESTree.CallExpression,
|
||||
userDefinedGroupOrder,
|
||||
sortRemainingProperties,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Backwards-compatible alias that maintains the original API.
|
||||
* Uses reference tracking internally for automatic detection of vanilla-extract functions.
|
||||
*/
|
||||
export const createNodeVisitors = createReferenceBasedNodeVisitors;
|
||||
320
src/css-rules/shared-utils/reference-tracker.ts
Normal file
320
src/css-rules/shared-utils/reference-tracker.ts
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
import type { Rule } from 'eslint';
|
||||
import { TSESTree } from '@typescript-eslint/utils';
|
||||
|
||||
export interface ImportReference {
|
||||
source: string;
|
||||
importedName: string;
|
||||
localName: string;
|
||||
}
|
||||
|
||||
export interface WrapperFunctionInfo {
|
||||
originalFunction: string; // 'style', 'recipe', etc.
|
||||
parameterMapping: number; // which parameter index contains the style object
|
||||
}
|
||||
|
||||
export interface TrackedFunctions {
|
||||
styleFunctions: Set<string>;
|
||||
recipeFunctions: Set<string>;
|
||||
fontFaceFunctions: Set<string>;
|
||||
globalFunctions: Set<string>;
|
||||
keyframeFunctions: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks vanilla-extract function imports and their local bindings
|
||||
*/
|
||||
export class ReferenceTracker {
|
||||
private imports: Map<string, ImportReference> = new Map();
|
||||
private trackedFunctions: TrackedFunctions;
|
||||
private wrapperFunctions: Map<string, WrapperFunctionInfo> = new Map(); // wrapper function name -> detailed info
|
||||
|
||||
constructor() {
|
||||
this.trackedFunctions = {
|
||||
styleFunctions: new Set(),
|
||||
recipeFunctions: new Set(),
|
||||
fontFaceFunctions: new Set(),
|
||||
globalFunctions: new Set(),
|
||||
keyframeFunctions: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes import declarations to track vanilla-extract functions
|
||||
*/
|
||||
processImportDeclaration(node: TSESTree.ImportDeclaration): void {
|
||||
const source = node.source.value;
|
||||
|
||||
// Check if this is a vanilla-extract import
|
||||
if (typeof source !== 'string' || !this.isVanillaExtractSource(source)) {
|
||||
return;
|
||||
}
|
||||
|
||||
node.specifiers.forEach((specifier) => {
|
||||
if (specifier.type === 'ImportSpecifier') {
|
||||
const importedName =
|
||||
specifier.imported.type === 'Identifier' ? specifier.imported.name : specifier.imported.value;
|
||||
const localName = specifier.local.name;
|
||||
|
||||
const reference: ImportReference = {
|
||||
source,
|
||||
importedName,
|
||||
localName,
|
||||
};
|
||||
|
||||
this.imports.set(localName, reference);
|
||||
this.categorizeFunction(localName, importedName);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes variable declarations to track re-assignments and destructuring
|
||||
*/
|
||||
processVariableDeclarator(node: TSESTree.VariableDeclarator): void {
|
||||
// Handle destructuring assignments like: const { style, recipe } = vanillaExtract;
|
||||
if (node.id.type === 'ObjectPattern' && node.init?.type === 'Identifier') {
|
||||
const sourceIdentifier = node.init.name;
|
||||
const sourceReference = this.imports.get(sourceIdentifier);
|
||||
|
||||
if (sourceReference && this.isVanillaExtractSource(sourceReference.source)) {
|
||||
node.id.properties.forEach((property) => {
|
||||
if (
|
||||
property.type === 'Property' &&
|
||||
property.key.type === 'Identifier' &&
|
||||
property.value.type === 'Identifier'
|
||||
) {
|
||||
const importedName = property.key.name;
|
||||
const localName = property.value.name;
|
||||
|
||||
const reference: ImportReference = {
|
||||
source: sourceReference.source,
|
||||
importedName,
|
||||
localName,
|
||||
};
|
||||
|
||||
this.imports.set(localName, reference);
|
||||
this.categorizeFunction(localName, importedName);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle simple assignments like: const myStyle = style;
|
||||
if (node.id.type === 'Identifier' && node.init?.type === 'Identifier') {
|
||||
const sourceReference = this.imports.get(node.init.name);
|
||||
if (sourceReference) {
|
||||
this.imports.set(node.id.name, sourceReference);
|
||||
this.categorizeFunction(node.id.name, sourceReference.importedName);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle arrow function assignments that wrap vanilla-extract functions
|
||||
if (node.id.type === 'Identifier' && node.init?.type === 'ArrowFunctionExpression') {
|
||||
this.analyzeWrapperFunction(node.id.name, node.init);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes function declarations to detect wrapper functions
|
||||
*/
|
||||
processFunctionDeclaration(node: TSESTree.FunctionDeclaration): void {
|
||||
if (node.id?.name) {
|
||||
this.analyzeWrapperFunction(node.id.name, node);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyzes a function to see if it wraps a vanilla-extract function
|
||||
*/
|
||||
private analyzeWrapperFunction(
|
||||
functionName: string,
|
||||
functionNode: TSESTree.ArrowFunctionExpression | TSESTree.FunctionDeclaration,
|
||||
): void {
|
||||
const body = functionNode.body;
|
||||
|
||||
// Handle arrow functions with expression body
|
||||
if (functionNode.type === 'ArrowFunctionExpression' && body.type !== 'BlockStatement') {
|
||||
this.analyzeWrapperExpression(functionName, body);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle functions with block statement body
|
||||
if (body.type === 'BlockStatement') {
|
||||
this.traverseBlockForVanillaExtractCalls(functionName, body);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyzes a wrapper function expression to detect vanilla-extract calls and parameter mapping
|
||||
*/
|
||||
private analyzeWrapperExpression(wrapperName: string, expression: TSESTree.Node): void {
|
||||
if (expression.type === 'CallExpression' && expression.callee.type === 'Identifier') {
|
||||
const calledFunction = expression.callee.name;
|
||||
if (this.isTrackedFunction(calledFunction)) {
|
||||
const originalName = this.getOriginalName(calledFunction);
|
||||
if (originalName) {
|
||||
// For now, create a simple wrapper info
|
||||
const wrapperInfo: WrapperFunctionInfo = {
|
||||
originalFunction: originalName,
|
||||
parameterMapping: 1, // layerStyle uses second parameter as the style object
|
||||
};
|
||||
this.wrapperFunctions.set(wrapperName, wrapperInfo);
|
||||
this.categorizeFunction(wrapperName, originalName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a node is a vanilla-extract function call
|
||||
*/
|
||||
private checkForVanillaExtractCall(wrapperName: string, node: TSESTree.Node): void {
|
||||
if (node.type === 'CallExpression' && node.callee.type === 'Identifier') {
|
||||
const calledFunction = node.callee.name;
|
||||
if (this.isTrackedFunction(calledFunction)) {
|
||||
const originalName = this.getOriginalName(calledFunction);
|
||||
if (originalName) {
|
||||
const wrapperInfo: WrapperFunctionInfo = {
|
||||
originalFunction: originalName,
|
||||
parameterMapping: 0, // Default to first parameter
|
||||
};
|
||||
this.wrapperFunctions.set(wrapperName, wrapperInfo);
|
||||
this.categorizeFunction(wrapperName, originalName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverses a block statement to find vanilla-extract calls
|
||||
*/
|
||||
private traverseBlockForVanillaExtractCalls(wrapperName: string, block: TSESTree.BlockStatement): void {
|
||||
for (const statement of block.body) {
|
||||
if (statement.type === 'ReturnStatement' && statement.argument) {
|
||||
this.checkForVanillaExtractCall(wrapperName, statement.argument);
|
||||
} else if (statement.type === 'ExpressionStatement') {
|
||||
this.checkForVanillaExtractCall(wrapperName, statement.expression);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a function name corresponds to a tracked vanilla-extract function
|
||||
*/
|
||||
isTrackedFunction(functionName: string): boolean {
|
||||
return this.imports.has(functionName) || this.wrapperFunctions.has(functionName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the category of a tracked function
|
||||
*/
|
||||
getFunctionCategory(functionName: string): keyof TrackedFunctions | null {
|
||||
if (this.trackedFunctions.styleFunctions.has(functionName)) {
|
||||
return 'styleFunctions';
|
||||
}
|
||||
if (this.trackedFunctions.recipeFunctions.has(functionName)) {
|
||||
return 'recipeFunctions';
|
||||
}
|
||||
if (this.trackedFunctions.fontFaceFunctions.has(functionName)) {
|
||||
return 'fontFaceFunctions';
|
||||
}
|
||||
if (this.trackedFunctions.globalFunctions.has(functionName)) {
|
||||
return 'globalFunctions';
|
||||
}
|
||||
if (this.trackedFunctions.keyframeFunctions.has(functionName)) {
|
||||
return 'keyframeFunctions';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the original imported name for a local function name
|
||||
*/
|
||||
getOriginalName(localName: string): string | null {
|
||||
const reference = this.imports.get(localName);
|
||||
if (reference) {
|
||||
return reference.importedName;
|
||||
}
|
||||
|
||||
// Check if it's a wrapper function
|
||||
const wrapperInfo = this.wrapperFunctions.get(localName);
|
||||
return wrapperInfo?.originalFunction ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets wrapper function information
|
||||
*/
|
||||
getWrapperInfo(functionName: string): WrapperFunctionInfo | null {
|
||||
return this.wrapperFunctions.get(functionName) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all tracked functions by category
|
||||
*/
|
||||
getTrackedFunctions(): TrackedFunctions {
|
||||
return this.trackedFunctions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the tracker state (useful for processing multiple files)
|
||||
*/
|
||||
reset(): void {
|
||||
this.imports.clear();
|
||||
this.wrapperFunctions.clear();
|
||||
this.trackedFunctions.styleFunctions.clear();
|
||||
this.trackedFunctions.recipeFunctions.clear();
|
||||
this.trackedFunctions.fontFaceFunctions.clear();
|
||||
this.trackedFunctions.globalFunctions.clear();
|
||||
this.trackedFunctions.keyframeFunctions.clear();
|
||||
}
|
||||
|
||||
private isVanillaExtractSource(source: string): boolean {
|
||||
return (
|
||||
source === '@vanilla-extract/css' ||
|
||||
source === '@vanilla-extract/recipes' ||
|
||||
source.startsWith('@vanilla-extract/')
|
||||
);
|
||||
}
|
||||
|
||||
private categorizeFunction(localName: string, importedName: string): void {
|
||||
switch (importedName) {
|
||||
case 'style':
|
||||
case 'styleVariants':
|
||||
this.trackedFunctions.styleFunctions.add(localName);
|
||||
break;
|
||||
case 'recipe':
|
||||
this.trackedFunctions.recipeFunctions.add(localName);
|
||||
break;
|
||||
case 'fontFace':
|
||||
case 'globalFontFace':
|
||||
this.trackedFunctions.fontFaceFunctions.add(localName);
|
||||
break;
|
||||
case 'globalStyle':
|
||||
case 'globalKeyframes':
|
||||
this.trackedFunctions.globalFunctions.add(localName);
|
||||
break;
|
||||
case 'keyframes':
|
||||
this.trackedFunctions.keyframeFunctions.add(localName);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a visitor that tracks vanilla-extract imports and bindings
|
||||
*/
|
||||
export function createReferenceTrackingVisitor(tracker: ReferenceTracker): Rule.RuleListener {
|
||||
return {
|
||||
ImportDeclaration(node: Rule.Node) {
|
||||
tracker.processImportDeclaration(node as TSESTree.ImportDeclaration);
|
||||
},
|
||||
|
||||
VariableDeclarator(node: Rule.Node) {
|
||||
tracker.processVariableDeclarator(node as TSESTree.VariableDeclarator);
|
||||
},
|
||||
|
||||
FunctionDeclaration(node: Rule.Node) {
|
||||
tracker.processFunctionDeclaration(node as TSESTree.FunctionDeclaration);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ import noZeroUnitRule from './css-rules/no-zero-unit/rule-definition.js';
|
|||
const vanillaExtract = {
|
||||
meta: {
|
||||
name: '@antebudimir/eslint-plugin-vanilla-extract',
|
||||
version: '1.10.0',
|
||||
version: '1.11.0',
|
||||
},
|
||||
rules: {
|
||||
'alphabetical-order': alphabeticalOrderRule,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue