fix: move wrapper detection to global settings and support cross-module style/recipe wrappers

This commit is contained in:
seongminn 2026-03-21 17:54:16 +09:00
parent 62b1844b44
commit 3c223c2909
10 changed files with 482 additions and 7 deletions

116
README.md
View file

@ -304,6 +304,36 @@ export default [
> **Note:** Remember to enable only one ordering rule at a time. See the "Important" section above for details on switching between ordering rules.
### Global Settings (Recommended for shared wrappers)
If you use imported wrapper functions across multiple ordering rules, configure them once in ESLint `settings`.
All ordering rules (`alphabetical-order`, `concentric-order`, `custom-order`) read this value.
```typescript
import { defineConfig } from 'eslint/config';
import vanillaExtract from '@antebudimir/eslint-plugin-vanilla-extract';
export default defineConfig([
{
files: ['**/*.css.ts'],
plugins: {
'vanilla-extract': vanillaExtract,
},
settings: {
'vanilla-extract': {
style: ['componentStyle', 'layerStyle'],
recipe: ['componentRecipe'],
},
},
rules: {
...vanillaExtract.configs.recommended.rules,
},
},
]);
```
Configure wrapper names only via ESLint `settings` (`style`, `recipe`).
## Rules
### vanilla-extract/alphabetical-order
@ -336,6 +366,32 @@ export const myStyle = style({
});
```
#### Global setting: `settings['vanilla-extract'].style`
`vanilla-extract/alphabetical-order` reads wrapper names from global ESLint settings.
```typescript
import { defineConfig } from 'eslint/config';
import vanillaExtract from '@antebudimir/eslint-plugin-vanilla-extract';
export default defineConfig([
{
files: ['**/*.css.ts'],
plugins: {
'vanilla-extract': vanillaExtract,
},
settings: {
'vanilla-extract': {
style: ['componentStyle'],
},
},
rules: {
'vanilla-extract/alphabetical-order': 'error',
},
},
]);
```
### vanilla-extract/concentric-order
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.
@ -364,6 +420,33 @@ export const myStyle = style({
});
```
#### Global setting: `settings['vanilla-extract'].style`
Use this setting when style wrappers are imported from local modules (for example `componentStyle`, `layerStyle`).
If a wrapper function name is listed, the rule treats calls to that function like `style(...)` calls.
```typescript
import { defineConfig } from 'eslint/config';
import vanillaExtract from '@antebudimir/eslint-plugin-vanilla-extract';
export default defineConfig([
{
files: ['**/*.css.ts'],
plugins: {
'vanilla-extract': vanillaExtract,
},
settings: {
'vanilla-extract': {
style: ['componentStyle', 'layerStyle'],
},
},
rules: {
'vanilla-extract/concentric-order': 'error',
},
},
]);
```
### vanilla-extract/custom-order
The `vanilla-extract/custom-order` rule enables you to enforce a custom ordering of CSS properties in your
@ -388,6 +471,39 @@ To configure the rule, add it to your ESLint configuration file with your desire
`groups` array to include any number of available CSS property groups you want to enforce, with a minimum of one group
required.
#### Global setting: `settings['vanilla-extract'].style`
`vanilla-extract/custom-order` also reads `style` from global settings together with
`groupOrder` and `sortRemainingProperties`.
```typescript
import { defineConfig } from 'eslint/config';
import vanillaExtract from '@antebudimir/eslint-plugin-vanilla-extract';
export default defineConfig([
{
files: ['**/*.css.ts'],
plugins: {
'vanilla-extract': vanillaExtract,
},
settings: {
'vanilla-extract': {
style: ['componentStyle'],
},
},
rules: {
'vanilla-extract/custom-order': [
'error',
{
groupOrder: ['font', 'dimensions', 'margin', 'padding', 'position', 'border'],
sortRemainingProperties: 'alphabetical',
},
],
},
},
]);
```
```typescript
// ❌ Incorrect (Unordered)
import { style } from '@vanilla-extract/css';

View file

@ -88,5 +88,35 @@ run({
});
`,
},
// Imported local recipe wrapper with global settings recipe
{
code: `
import { componentRecipe } from './component-recipe.css.js';
const myRecipe = componentRecipe({
base: {
display: 'flex',
alignItems: 'center'
}
});
`,
settings: {
'vanilla-extract': {
recipe: ['componentRecipe'],
},
},
errors: [{ messageId: 'alphabeticalOrder' }],
output: `
import { componentRecipe } from './component-recipe.css.js';
const myRecipe = componentRecipe({
base: {
alignItems: 'center',
display: 'flex'
}
});
`,
},
],
});

View file

@ -195,5 +195,69 @@ run({
});
`,
},
// Imported local wrapper with global settings style
{
code: `
import { componentStyle } from './style.css.js';
export const myStyle = componentStyle({
padding: '18px',
backgroundColor: 'black',
});
`,
settings: {
'vanilla-extract': {
style: ['componentStyle'],
},
},
errors: [{ messageId: 'alphabeticalOrder' }],
output: `
import { componentStyle } from './style.css.js';
export const myStyle = componentStyle({
backgroundColor: 'black',
padding: '18px',
});
`,
},
// Both vanilla style and configured wrapper should be linted (wrapper augments, not replaces)
{
code: `
import { style } from '@vanilla-extract/css';
import { componentStyle } from './style.css.js';
export const a = style({
display: 'flex',
color: 'white',
});
export const b = componentStyle({
padding: '18px',
backgroundColor: 'black',
});
`,
settings: {
'vanilla-extract': {
style: ['componentStyle'],
},
},
errors: [{ messageId: 'alphabeticalOrder' }, { messageId: 'alphabeticalOrder' }],
output: `
import { style } from '@vanilla-extract/css';
import { componentStyle } from './style.css.js';
export const a = style({
color: 'white',
display: 'flex',
});
export const b = componentStyle({
backgroundColor: 'black',
padding: '18px',
});
`,
},
],
});

View file

@ -97,5 +97,39 @@ run({
});
`,
},
// Imported local recipe wrapper with global settings recipe
{
code: `
import { componentRecipe } from './recipe.css.js';
const myRecipe = componentRecipe({
base: {
backgroundColor: 'white',
width: '100%',
display: 'flex',
position: 'relative'
},
});
`,
settings: {
'vanilla-extract': {
recipe: ['componentRecipe'],
},
},
errors: [{ messageId: 'incorrectOrder' }],
output: `
import { componentRecipe } from './recipe.css.js';
const myRecipe = componentRecipe({
base: {
position: 'relative',
display: 'flex',
backgroundColor: 'white',
width: '100%'
},
});
`,
},
],
});

View file

@ -213,5 +213,69 @@ run({
});
`,
},
// Imported local wrapper with global settings style
{
code: `
import { componentStyle } from './style.css.js';
export const myStyle = componentStyle({
padding: '18px',
backgroundColor: 'black',
});
`,
settings: {
'vanilla-extract': {
style: ['componentStyle'],
},
},
errors: [{ messageId: 'incorrectOrder' }],
output: `
import { componentStyle } from './style.css.js';
export const myStyle = componentStyle({
backgroundColor: 'black',
padding: '18px',
});
`,
},
// Both vanilla style and configured wrapper should be linted (wrapper augments, not replaces)
{
code: `
import { style } from '@vanilla-extract/css';
import { componentStyle } from './layer-style.css.js';
export const a = style({
color: 'white',
display: 'flex',
});
export const b = componentStyle({
padding: '18px',
backgroundColor: 'black',
});
`,
settings: {
'vanilla-extract': {
style: ['componentStyle'],
},
},
errors: [{ messageId: 'incorrectOrder' }, { messageId: 'incorrectOrder' }],
output: `
import { style } from '@vanilla-extract/css';
import { componentStyle } from './layer-style.css.js';
export const a = style({
display: 'flex',
color: 'white',
});
export const b = componentStyle({
backgroundColor: 'black',
padding: '18px',
});
`,
},
],
});

View file

@ -207,5 +207,46 @@ run({
});
`,
},
// Imported local recipe wrapper with global settings recipe
{
code: `
import { componentRecipe } from './component-recipe.css.js';
const myRecipe = componentRecipe({
base: {
backgroundColor: 'white',
width: '100%',
display: 'flex',
alignItems: 'center',
margin: 0
}
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'concentric',
},
],
settings: {
'vanilla-extract': {
recipe: ['componentRecipe'],
},
},
errors: [{ messageId: 'incorrectOrder' }],
output: `
import { componentRecipe } from './component-recipe.css.js';
const myRecipe = componentRecipe({
base: {
width: '100%',
margin: 0,
display: 'flex',
alignItems: 'center',
backgroundColor: 'white'
}
});
`,
},
],
});

View file

@ -375,5 +375,81 @@ run({
});
`,
},
// Imported local wrapper with global settings style
{
code: `
import { componentStyle } from './style.css.js';
export const myStyle = componentStyle({
padding: '18px',
backgroundColor: 'black',
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'concentric',
},
],
settings: {
'vanilla-extract': {
style: ['componentStyle'],
},
},
errors: [{ messageId: 'incorrectOrder' }],
output: `
import { componentStyle } from './style.css.js';
export const myStyle = componentStyle({
backgroundColor: 'black',
padding: '18px',
});
`,
},
// Both vanilla style and configured wrapper should be linted (wrapper augments, not replaces)
{
code: `
import { style } from '@vanilla-extract/css';
import { componentStyle } from './style.css.js';
export const a = style({
color: 'white',
display: 'flex',
});
export const b = componentStyle({
padding: '18px',
backgroundColor: 'black',
});
`,
options: [
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
sortRemainingProperties: 'concentric',
},
],
settings: {
'vanilla-extract': {
style: ['componentStyle'],
},
},
errors: [{ messageId: 'incorrectOrder' }, { messageId: 'incorrectOrder' }],
output: `
import { style } from '@vanilla-extract/css';
import { componentStyle } from './style.css.js';
export const a = style({
display: 'flex',
color: 'white',
});
export const b = componentStyle({
backgroundColor: 'black',
padding: '18px',
});
`,
},
],
});

View file

@ -10,7 +10,7 @@ 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';
import type { OrderingStrategy, VanillaExtractPluginSettings } from '../types.js';
/**
* Creates an ESLint rule listener with visitors for style-related function calls using reference tracking.
@ -28,7 +28,8 @@ export const createNodeVisitors = (
userDefinedGroupOrder?: string[],
sortRemainingProperties?: SortRemainingProperties,
): Rule.RuleListener => {
const tracker = new ReferenceTracker();
const wrapperSettings = resolveWrapperSettings(ruleContext);
const tracker = new ReferenceTracker(wrapperSettings);
const trackingVisitor = createReferenceTrackingVisitor(tracker);
return {
@ -105,6 +106,21 @@ export const createNodeVisitors = (
};
};
const resolveWrapperSettings = (
ruleContext: Rule.RuleContext,
): {
style: string[];
recipe: string[];
} => {
const pluginSettings = (ruleContext.settings?.['vanilla-extract'] ??
{}) as VanillaExtractPluginSettings;
return {
style: pluginSettings.style ?? [],
recipe: pluginSettings.recipe ?? [],
};
};
/**
* Helper function to process style ordering for style-related functions
*/

View file

@ -27,8 +27,10 @@ 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
private style: Set<string>;
private recipe: Set<string>;
constructor() {
constructor(options?: { style?: string[]; recipe?: string[] }) {
this.trackedFunctions = {
styleFunctions: new Set(),
recipeFunctions: new Set(),
@ -36,6 +38,8 @@ export class ReferenceTracker {
globalFunctions: new Set(),
keyframeFunctions: new Set(),
};
this.style = new Set(options?.style ?? []);
this.recipe = new Set(options?.recipe ?? []);
}
/**
@ -44,25 +48,38 @@ export class ReferenceTracker {
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)) {
if (typeof source !== 'string') {
return;
}
const isVanillaExtractImport = this.isVanillaExtractSource(source);
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 customWrapper = this.getCustomWrapper(importedName, localName);
let trackedImportName: string;
if (isVanillaExtractImport) {
trackedImportName = importedName;
} else {
if (!customWrapper) {
return;
}
trackedImportName = customWrapper;
}
const reference: ImportReference = {
source,
importedName,
importedName: trackedImportName,
localName,
};
this.imports.set(localName, reference);
this.categorizeFunction(localName, importedName);
this.categorizeFunction(localName, trackedImportName);
}
});
}
@ -276,6 +293,18 @@ export class ReferenceTracker {
);
}
private getCustomWrapper(importedName: string, localName: string): 'style' | 'recipe' | null {
if (this.style.has(importedName) || this.style.has(localName)) {
return 'style';
}
if (this.recipe.has(importedName) || this.recipe.has(localName)) {
return 'recipe';
}
return null;
}
private categorizeFunction(localName: string, importedName: string): void {
switch (importedName) {
case 'style':

View file

@ -1 +1,6 @@
export type OrderingStrategy = 'alphabetical' | 'concentric' | 'userDefinedGroupOrder';
export interface VanillaExtractPluginSettings {
style?: string[];
recipe?: string[];
}