From 3c223c29096869a565953517467c84c5b458c000 Mon Sep 17 00:00:00 2001 From: seongminn Date: Sat, 21 Mar 2026 17:54:16 +0900 Subject: [PATCH] fix: move wrapper detection to global settings and support cross-module style/recipe wrappers --- README.md | 116 ++++++++++++++++++ .../__tests__/recipe.test.ts | 30 +++++ .../__tests__/style-wrapper.test.ts | 64 ++++++++++ .../concentric-order/__tests__/recipe.test.ts | 34 +++++ .../__tests__/style-wrapper.test.ts | 64 ++++++++++ .../custom-order/__tests__/recipe.test.ts | 41 +++++++ .../__tests__/style-wrapper.test.ts | 76 ++++++++++++ .../order-strategy-visitor-creator.ts | 20 ++- .../shared-utils/reference-tracker.ts | 39 +++++- src/css-rules/types.ts | 5 + 10 files changed, 482 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 185724f..ffebdf0 100644 --- a/README.md +++ b/README.md @@ -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'; diff --git a/src/css-rules/alphabetical-order/__tests__/recipe.test.ts b/src/css-rules/alphabetical-order/__tests__/recipe.test.ts index 82ba7ed..46826ab 100644 --- a/src/css-rules/alphabetical-order/__tests__/recipe.test.ts +++ b/src/css-rules/alphabetical-order/__tests__/recipe.test.ts @@ -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' + } + }); + `, + }, ], }); diff --git a/src/css-rules/alphabetical-order/__tests__/style-wrapper.test.ts b/src/css-rules/alphabetical-order/__tests__/style-wrapper.test.ts index 104cef6..cb01fb9 100644 --- a/src/css-rules/alphabetical-order/__tests__/style-wrapper.test.ts +++ b/src/css-rules/alphabetical-order/__tests__/style-wrapper.test.ts @@ -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', + }); + `, + }, ], }); diff --git a/src/css-rules/concentric-order/__tests__/recipe.test.ts b/src/css-rules/concentric-order/__tests__/recipe.test.ts index 0eff340..d2c829d 100644 --- a/src/css-rules/concentric-order/__tests__/recipe.test.ts +++ b/src/css-rules/concentric-order/__tests__/recipe.test.ts @@ -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%' + }, + }); + `, + }, ], }); diff --git a/src/css-rules/concentric-order/__tests__/style-wrapper.test.ts b/src/css-rules/concentric-order/__tests__/style-wrapper.test.ts index af8de1f..e4aa5f1 100644 --- a/src/css-rules/concentric-order/__tests__/style-wrapper.test.ts +++ b/src/css-rules/concentric-order/__tests__/style-wrapper.test.ts @@ -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', + }); + `, + }, ], }); diff --git a/src/css-rules/custom-order/__tests__/recipe.test.ts b/src/css-rules/custom-order/__tests__/recipe.test.ts index 16e66e7..1e4bb6b 100644 --- a/src/css-rules/custom-order/__tests__/recipe.test.ts +++ b/src/css-rules/custom-order/__tests__/recipe.test.ts @@ -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' + } + }); + `, + }, ], }); diff --git a/src/css-rules/custom-order/__tests__/style-wrapper.test.ts b/src/css-rules/custom-order/__tests__/style-wrapper.test.ts index dfd7af5..5acb061 100644 --- a/src/css-rules/custom-order/__tests__/style-wrapper.test.ts +++ b/src/css-rules/custom-order/__tests__/style-wrapper.test.ts @@ -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', + }); + `, + }, ], }); diff --git a/src/css-rules/shared-utils/order-strategy-visitor-creator.ts b/src/css-rules/shared-utils/order-strategy-visitor-creator.ts index e9fcabe..df9d60f 100644 --- a/src/css-rules/shared-utils/order-strategy-visitor-creator.ts +++ b/src/css-rules/shared-utils/order-strategy-visitor-creator.ts @@ -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 */ diff --git a/src/css-rules/shared-utils/reference-tracker.ts b/src/css-rules/shared-utils/reference-tracker.ts index f946fb2..5e7401e 100644 --- a/src/css-rules/shared-utils/reference-tracker.ts +++ b/src/css-rules/shared-utils/reference-tracker.ts @@ -27,8 +27,10 @@ export class ReferenceTracker { private imports: Map = new Map(); private trackedFunctions: TrackedFunctions; private wrapperFunctions: Map = new Map(); // wrapper function name -> detailed info + private style: Set; + private recipe: Set; - 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': diff --git a/src/css-rules/types.ts b/src/css-rules/types.ts index fff9f86..3574c43 100644 --- a/src/css-rules/types.ts +++ b/src/css-rules/types.ts @@ -1 +1,6 @@ export type OrderingStrategy = 'alphabetical' | 'concentric' | 'userDefinedGroupOrder'; + +export interface VanillaExtractPluginSettings { + style?: string[]; + recipe?: string[]; +}