feat 🥁: initialize project with complete codebase

This commit is contained in:
Ante Budimir 2025-03-04 20:16:50 +02:00
commit d569dea1fb
35 changed files with 4413 additions and 0 deletions

32
.gitignore vendored Normal file
View file

@ -0,0 +1,32 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
.pnpm-store/
.pnpm-debug.log*
# testing
/coverage
# production
/build
/dist
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# local env files
.env*.local
# typescript
*.tsbuildinfo

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
20

17
.prettierrc Normal file
View file

@ -0,0 +1,17 @@
{
"proseWrap": "always",
"printWidth": 120,
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"bracketSameLine": true,
"overrides": [
{
"files": "*.jsonc",
"options": {
"trailingComma": "none"
}
}
]
}

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Ante Budimir
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

242
README.md Normal file
View file

@ -0,0 +1,242 @@
# @antebudimir/eslint-plugin-vanilla-extract
An ESLint plugin for enforcing CSS ordering in vanilla-extract css styles. Available presets are for alphabetical and
[concentric](https://rhodesmill.org/brandon/2011/concentric-css/) CSS ordering. The plugin also supports a custom group ordering option based on groups available in concentric CSS.
## Features
- Enforces CSS property ordering in vanilla-extract style objects with two available presets:
- Alphabetical ordering for clean, predictable style organization
- Concentric ordering for logical, outside-in property arrangement
- Custom group ordering option for more fine-grained control
- Built for ESLint 9 flat config system
- Provides auto-fix capability to automatically sort properties
- Handles multiple vanilla-extract APIs (style, styleVariants, recipe, globalStyle, etc.)
- Handles complex cases like nested objects, arrays of styles, and pseudo selectors
- Works with camelCase properties as used in vanilla-extract
<!-- TODO: test with pre-react 18 edu-portal app to see if eslint v8 is supported -->
## Requirements
- ESLint 9.0.0 or higher
- Node.js 18.18.0 or higher
## Installation
```bash
# Using npm
npm install --save-dev @antebudimir/eslint-plugin-vanilla-extract
# Using yarn
yarn add --dev @antebudimir/eslint-plugin-vanilla-extract
# Using pnpm
pnpm add -D @antebudimir/eslint-plugin-vanilla-extract
```
## Usage
### ESLint Flat Config (ESLint 9+)
Create or update your `eslint.config.js` or `eslint.config.mjs` file:
```typescript
import vanillaExtract from '@antebudimir/eslint-plugin-vanilla-extract';
export default [
{
files: ['**/*.css.ts'],
ignores: ['src/**/theme-contract.css.ts'],
plugins: {
'vanilla-extract': vanillaExtract,
},
rules: {
// 'vanilla-extract/alphabetical-order': 'warn',
// OR
// 'vanilla-extract/concentric-order': 'warn',
// OR
'vanilla-extract/custom-order': [
'warn',
{
groupOrder: ['font', 'dimensions', 'margin', 'padding', 'position', 'border'], // example group order
// optional
sortRemainingProperties: 'concentric', // 'alphabetical' is default
},
],
},
},
];
```
## Rules
### vanilla-extract/alphabetical-order
This rule enforces that CSS properties in vanilla-extract style objects follow alphabetical ordering.
```typescript
// ❌ Incorrect
import { style } from '@vanilla-extract/css';
export const myStyle = style({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: 19,
marginBottom: 1,
marginLeft: 2,
});
// ✅ Correct
import { style } from '@vanilla-extract/css';
export const myStyle = style({
alignItems: 'center',
display: 'flex',
height: 19,
justifyContent: 'center',
marginBottom: 1,
marginLeft: 2,
});
```
### vanilla-extract/concentric-order
This rule enforces that CSS properties in vanilla-extract style objects follow the concentric CSS ordering pattern,
which organizes properties from outside to inside.
```typescript
// ❌ Incorrect
import { style } from '@vanilla-extract/css';
export const myStyle = style({
color: 'red',
display: 'flex',
position: 'relative',
padding: '10px',
margin: '20px',
});
// ✅ Correct
import { style } from '@vanilla-extract/css';
export const myStyle = style({
position: 'relative',
display: 'flex',
margin: '20px',
padding: '10px',
color: 'red',
});
```
### vanilla-extract/custom-order
The `vanilla-extract/custom-order` rule allows you to enforce a custom ordering of CSS properties in your vanilla-extract styles. You can specify an array of property groups in the order you prefer, and the rule will ensure that properties within these groups are sorted according to their position in the concentric CSS model. All other groups that aren't included in the groups array will have their respective properties sorted after the last group in the array. You can choose to sort them either alphabetically or following the concentric CSS order (see list of concentric groups) by setting the `sortRemainingProperties` option to 'alphabetical' or 'concentric' respectively. If not set, `sortRemainingProperties` defaults to 'alphabetical'.
To configure the rule, add it to your ESLint configuration file with your desired options. You can customize the `groups` array to include any number of available CSS property groups you want to enforce, but minimum of 1 is required.
Example usage:
```typescript
// ❌ Incorrect (Unordered)
import { style } from '@vanilla-extract/css';
export const myStyle = style({
color: 'blue',
padding: '10px',
fontFamily: 'Arial, sans-serif',
margin: '20px',
width: '200px',
border: '1px solid black',
display: 'flex',
});
// ✅ Correct
import { style } from '@vanilla-extract/css';
export const myStyle = style({
// font group
fontFamily: 'Arial, sans-serif',
color: 'blue',
// dimensions group
width: '200px',
// margin group
margin: '20px',
// padding group
padding: '10px',
// display group
display: 'flex',
// border group
border: '1px solid black',
});
```
## Concentric CSS Model
Here's a list of all available groups from the provided concentricGroups array:
1. boxSizing
2. position
3. display
4. flex
5. grid
6. alignment
7. columns
8. transform
9. transitions
10. visibility
11. shape
12. margin
13. outline
14. border
15. boxShadow
16. background
17. cursor
18. padding
19. dimensions
20. overflow
21. listStyle
22. tables
23. animation
24. text
25. textSpacing
26. font
27. content
28. counters
29. breaks
These groups represent different categories of CSS properties, organized in a concentric order from outside to inside. Each group contains related CSS properties that affect specific aspects of an element's styling and layout.
## Roadmap
The roadmap outlines the project's current status and future plans:
### Completed Milestones
- Initial release with support for alphabetical, concentric, and custom group CSS ordering.
- Auto-fix capability integrated into ESLint.
- Support for multiple vanilla-extract APIs (e.g., `style`, `styleVariants`, `recipe`, `globalStyle` etc.).
### Current Work
- Compatibility testing to determine if the plugin works with ESLint v8. **Note**: There are no plans to ensure compatibility if issues arise. Upcoming features will be prioritized.
### Upcoming Features
- Begin work on test coverage.
- Support for additional vanilla-extract APIs, including `fontFace`, `globalFontFace`, `globalKeyframes`, and `keyframes`.
- Option to sort properties within user-defined concentric groups alphabetically instead of following the concentric order. **Note**: This feature will only be implemented if there's sufficient interest from the community.
## Contributing
All well-intentioned contributions are welcome, of course! Please feel free to submit a Pull Request or get in touch via
GitHub issues.
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

10
eslint-plugin-eslint-plugin.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
declare module 'eslint-plugin-eslint-plugin' {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const configs: Record<string, any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rules: Record<string, any>;
export { configs, rules };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const _default: { configs: Record<string, any>; rules: Record<string, any> };
export default _default;
}

1
eslint-plugin-import.d.ts vendored Normal file
View file

@ -0,0 +1 @@
declare module 'eslint-plugin-import';

126
eslint.config.mjs Normal file
View file

@ -0,0 +1,126 @@
import path from 'path';
import { fileURLToPath } from 'url';
import { FlatCompat } from '@eslint/eslintrc';
import eslintPluginESLintPlugin from 'eslint-plugin-eslint-plugin';
import importPlugin from 'eslint-plugin-import';
import * as tseslint from 'typescript-eslint';
import vanillaExtract from '@antebudimir/eslint-plugin-vanilla-extract';
// mimic CommonJS variables
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
export default [
// mimic ESLintRC-style extends
// Prettier always must be last to override other style rules
...compat.extends('prettier'),
{
files: ['**/*.js', '**/*.ts', '**/*.cjs', '**/*.mjs'],
plugins: {
'eslint-plugin': eslintPluginESLintPlugin,
import: importPlugin,
},
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts'],
},
'import/resolver': {
typescript: {
alwaysTryTypes: true,
project: './tsconfig.json',
},
node: {
extensions: ['.js', '.ts'],
},
},
},
rules: {
...eslintPluginESLintPlugin.configs.recommended.rules,
'eslint-plugin/prefer-message-ids': 'error',
'eslint-plugin/require-meta-type': 'error',
'eslint-plugin/require-meta-docs-description': 'error',
'eslint-plugin/consistent-output': 'error',
'import/order': [
'error',
{
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'type'],
pathGroups: [
{
pattern: '{@eslint/**,eslint,eslint-plugin-*}',
group: 'external',
position: 'before',
},
{
pattern: '@vanilla-extract/**',
group: 'external',
position: 'after',
},
],
pathGroupsExcludedImportTypes: ['builtin', 'object'],
'newlines-between': 'never',
alphabetize: {
order: 'asc',
caseInsensitive: true,
},
},
],
'import/no-unresolved': 'error',
'import/named': 'error',
'import/namespace': 'error',
'import/default': 'error',
'import/export': 'error',
},
},
...tseslint.configs.recommended,
{
files: ['**/*.css.ts'],
plugins: {
'vanilla-extract': vanillaExtract,
},
rules: {
// 'vanilla-extract/alphabetical-order': 'warn',
// 'vanilla-extract/concentric-order': 'error',
'vanilla-extract/custom-order': [
'error',
{
groupOrder: ['dimensions', 'margin', 'font', 'border', 'boxShadow'],
// Optional
sortRemainingProperties: 'concentric', // or 'alphabetical' (default)
},
],
},
},
{
files: ['**/*.{js,ts}'],
languageOptions: {
parser: tseslint.parser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: './tsconfig.json',
},
},
rules: {
// TypeScript rules
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-empty-object-type': 'error',
'@typescript-eslint/no-unsafe-function-type': 'error',
'@typescript-eslint/no-unused-vars': 'warn',
'@typescript-eslint/no-wrapper-object-types': 'error',
// General rules
curly: 'error',
eqeqeq: 'error',
'no-console': 'warn',
'no-unused-vars': 'off',
},
},
];

75
package.json Normal file
View file

@ -0,0 +1,75 @@
{
"name": "@antebudimir/eslint-plugin-vanilla-extract",
"version": "1.0.0",
"description": "ESLint plugin for enforcing CSS ordering in vanilla-extract styles",
"author": "Ante Budimir",
"license": "MIT",
"keywords": [
"eslint",
"eslintplugin",
"eslint-plugin",
"vanilla-extract",
"css",
"css-in-js",
"concentric",
"alphabetical",
"typescript",
"style",
"ordering",
"zero-runtime"
],
"repository": {
"type": "git",
"url": "https://github.com/antebudimir/eslint-plugin-vanilla-extract.git"
},
"bugs": {
"url": "https://github.com/antebudimir/eslint-plugin-vanilla-extract/issues"
},
"homepage": "https://github.com/antebudimir/eslint-plugin-vanilla-extract#readme",
"type": "module",
"main": "dist/index.js",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"format": "prettier --write .",
"lint": "eslint src --ext .ts --fix --max-warnings 0",
"prepublishOnly": "pnpm run lint && pnpm run build",
"publish": "pnpm publish --access public",
"typecheck": "tsc --noEmit",
"version:major": "pnpm version major --no-git-tag-version",
"version:minor": "pnpm version minor --no-git-tag-version",
"version:patch": "pnpm version patch --no-git-tag-version"
},
"engines": {
"node": ">=20.18.3"
},
"packageManager": "pnpm@10.5.0",
"peerDependencies": {
"eslint": ">=9.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.0",
"@types/node": "^20.17.19",
"@typescript-eslint/eslint-plugin": "^8.25.0",
"@typescript-eslint/parser": "^8.25.0",
"@typescript-eslint/utils": "^8.25.0",
"@vanilla-extract/css": "^1.17.1",
"@vanilla-extract/recipes": "^0.5.5",
"eslint": "^9.21.0",
"eslint-config-prettier": "^10.0.2",
"eslint-import-resolver-typescript": "^3.8.3",
"eslint-plugin-eslint-plugin": "^6.4.0",
"eslint-plugin-import": "^2.31.0",
"prettier": "^3.5.2",
"typescript": "^5.7.3",
"typescript-eslint": "^8.25.0"
}
}

2427
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,3 @@
import alphabeticalOrderRule from './rule-definition.js';
export default alphabeticalOrderRule;

View file

@ -0,0 +1,84 @@
import type { Rule } from 'eslint';
import { TSESTree } from '@typescript-eslint/utils';
import { generateFixesForCSSOrder } from '../shared-utils/css-order-fixer.js';
import { getPropertyName } from '../shared-utils/property-separator.js';
/**
* Compares two CSS properties alphabetically.
* @param firstProperty The first property to compare.
* @param secondProperty The second property to compare.
* @returns A number indicating the relative order of the properties (-1, 0, or 1).
*/
const comparePropertiesAlphabetically = (
firstProperty: TSESTree.Property,
secondProperty: TSESTree.Property,
): number => {
const firstName = getPropertyName(firstProperty);
const secondName = getPropertyName(secondProperty);
return firstName.localeCompare(secondName);
};
/**
* Reports an ordering issue to ESLint and generates fixes.
* @param ruleContext The ESLint rule context.
* @param currentProperty The current property in the order.
* @param nextProperty The next property that is out of order.
* @param regularProperties The full list of regular properties to be reordered.
*/
const reportOrderingIssue = (
ruleContext: Rule.RuleContext,
currentProperty: TSESTree.Property,
nextProperty: TSESTree.Property,
regularProperties: TSESTree.Property[],
): void => {
ruleContext.report({
node: nextProperty as Rule.Node,
messageId: 'alphabeticalOrder',
data: {
nextProperty: getPropertyName(nextProperty),
currentProperty: getPropertyName(currentProperty),
},
fix: (fixer) =>
generateFixesForCSSOrder(
fixer,
ruleContext,
regularProperties,
comparePropertiesAlphabetically,
(property) => property as Rule.Node,
),
});
};
/**
* Enforces alphabetical ordering of CSS properties.
* @param ruleContext The ESLint rule context.
* @param regularProperties An array of regular CSS properties to be checked.
*
* This function does the following:
* 1. Checks if there are enough properties to compare (more than 1).
* 2. Creates pairs of consecutive properties for comparison.
* 3. Finds the first pair that violates alphabetical order.
* 4. If a violation is found, reports the issue and generates fixes.
*/
export const enforceAlphabeticalCSSOrder = (
ruleContext: Rule.RuleContext,
regularProperties: TSESTree.Property[],
): void => {
if (regularProperties.length <= 1) {
return;
}
// Create pairs of consecutive properties
const propertyPairs = regularProperties.slice(0, -1).map((currentProperty, index) => ({
currentProperty,
nextProperty: regularProperties[index + 1] as TSESTree.Property,
}));
const violatingPair = propertyPairs.find(
({ currentProperty, nextProperty }) => comparePropertiesAlphabetically(currentProperty, nextProperty) > 0,
);
if (violatingPair) {
reportOrderingIssue(ruleContext, violatingPair.currentProperty, violatingPair.nextProperty, regularProperties);
}
};

View file

@ -0,0 +1,28 @@
import type { Rule } from 'eslint';
import { TSESTree } from '@typescript-eslint/utils';
import { processRecipeProperties } from '../shared-utils/recipe-property-processor.js';
import { processStyleNode } from '../shared-utils/style-node-processor.js';
import { enforceAlphabeticalCSSOrderInStyleObject } from './style-object-processor.js';
/**
* Enforces alphabetical ordering of CSS properties within a recipe function call.
*
* @param node The CallExpression node representing the recipe function call.
* @param context The ESLint rule context.
*
* This function does the following:
* 1. Checks if the first argument of the recipe function is an ObjectExpression.
* 2. If valid, processes the recipe object's properties.
* 3. For each relevant property (e.g., 'base', 'variants'), it applies alphabetical ordering to the CSS properties.
*/
export const enforceAlphabeticalCSSOrderInRecipe = (node: TSESTree.CallExpression, context: Rule.RuleContext): void => {
if (!node.arguments[0] || node.arguments[0].type !== 'ObjectExpression') {
return;
}
const recipeObject = node.arguments[0];
processRecipeProperties(context, recipeObject, (context, object) =>
processStyleNode(context, object, enforceAlphabeticalCSSOrderInStyleObject),
);
};

View file

@ -0,0 +1,23 @@
import type { Rule } from 'eslint';
import { createNodeVisitors } from '../shared-utils/order-strategy-visitor-creator.js';
const alphabeticalOrderRule: Rule.RuleModule = {
meta: {
type: 'suggestion',
docs: {
description: 'enforce alphabetical CSS property ordering in vanilla-extract styles',
category: 'Stylistic Issues',
recommended: true,
},
fixable: 'code',
schema: [],
messages: {
alphabeticalOrder: "Property '{{next}}' should come before '{{current}}' in alphabetical order.",
},
},
create(context) {
return createNodeVisitors(context, 'alphabetical');
},
};
export default alphabeticalOrderRule;

View file

@ -0,0 +1,37 @@
import type { Rule } from 'eslint';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
import { isSelectorsObject, processNestedSelectors } from '../shared-utils/nested-selectors-processor.js';
import { separateProperties } from '../shared-utils/property-separator.js';
import { enforceAlphabeticalCSSOrder } from './property-order-enforcer.js';
/**
* Processes a style object to enforce alphabetical ordering of CSS properties.
*
* This function handles different types of style objects:
* 1. If the object is invalid or not an ObjectExpression, it returns immediately.
* 2. For 'selectors' objects, it processes nested selectors recursively.
* 3. For regular style objects, it separates and enforces alphabetical order on properties.
* 4. It always processes nested objects recursively, regardless of type.
*
* @param ruleContext - The ESLint rule context for reporting and fixing issues.
* @param styleObject - The object expression representing the style object to be processed.
*/
export const enforceAlphabeticalCSSOrderInStyleObject = (
ruleContext: Rule.RuleContext,
styleObject: TSESTree.ObjectExpression,
): void => {
if (!styleObject || styleObject.type !== AST_NODE_TYPES.ObjectExpression) {
return;
}
if (isSelectorsObject(styleObject)) {
processNestedSelectors(ruleContext, styleObject, enforceAlphabeticalCSSOrderInStyleObject);
return;
}
const { regularProperties } = separateProperties(styleObject.properties);
enforceAlphabeticalCSSOrder(ruleContext, regularProperties);
processNestedSelectors(ruleContext, styleObject, enforceAlphabeticalCSSOrderInStyleObject);
};

View file

@ -0,0 +1,336 @@
// CSS property groups in order of importance (outside -> inside)
export const concentricGroups: { [key: string]: string[] } = {
boxSizing: ['all', 'box-sizing', 'aspect-ratio', 'contain'],
position: [
'position',
'z-index',
'top',
'right',
'bottom',
'left',
'offset',
'offset-path',
'offset-distance',
'offset-rotate',
'inset',
'inset-block',
'inset-block-start',
'inset-block-end',
'inset-inline',
'inset-inline-start',
'inset-inline-end',
],
display: ['display', 'float', 'clear', 'isolation', 'appearance'],
flex: ['flex', 'flex-basis', 'flex-direction', 'flex-flow', 'flex-grow', 'flex-shrink', 'flex-wrap'],
grid: [
'grid',
'grid-area',
'grid-template',
'grid-template-areas',
'grid-template-rows',
'grid-template-columns',
'grid-row',
'grid-row-start',
'grid-row-end',
'grid-column',
'grid-column-start',
'grid-column-end',
'grid-auto-rows',
'grid-auto-columns',
'grid-auto-flow',
'grid-gap',
'grid-row-gap',
'grid-column-gap',
],
alignment: [
'align-content',
'align-items',
'align-self',
'justify-content',
'justify-items',
'justify-self',
'place-content',
'place-items',
'place-self',
'order',
'gap',
'row-gap',
'column-gap',
],
columns: [
'columns',
'column-fill',
'column-rule',
'column-rule-width',
'column-rule-style',
'column-rule-color',
'column-span',
'column-count',
'column-width',
],
transform: [
'backface-visibility',
'perspective',
'perspective-origin',
'transform',
'transform-origin',
'transform-style',
'transform-box',
],
transitions: [
'transition',
'transition-delay',
'transition-duration',
'transition-property',
'transition-timing-function',
'transition-behavior',
],
visibility: [
'visibility',
'opacity',
'backdrop-filter',
'content-visibility',
'filter',
'mix-blend-mode',
'will-change',
],
shape: ['clip-path', 'shape-outside', 'shape-image-threshold', 'shape-margin'],
margin: [
'margin',
'margin-top',
'margin-right',
'margin-bottom',
'margin-left',
'margin-block',
'margin-block-start',
'margin-block-end',
'margin-inline',
'margin-inline-start',
'margin-inline-end',
'margin-trim',
],
outline: ['outline', 'outline-offset', 'outline-width', 'outline-style', 'outline-color'],
border: [
'border',
'border-top',
'border-right',
'border-bottom',
'border-left',
'border-width',
'border-top-width',
'border-right-width',
'border-bottom-width',
'border-left-width',
'border-style',
'border-top-style',
'border-right-style',
'border-bottom-style',
'border-left-style',
'border-radius',
'border-top-left-radius',
'border-top-right-radius',
'border-bottom-left-radius',
'border-bottom-right-radius',
'border-start-start-radius',
'border-start-end-radius',
'border-end-start-radius',
'border-end-end-radius',
'border-color',
'border-top-color',
'border-right-color',
'border-bottom-color',
'border-left-color',
'border-image',
'border-image-source',
'border-image-width',
'border-image-outset',
'border-image-repeat',
'border-image-slice',
'border-block',
'border-block-start',
'border-block-end',
'border-block-width',
'border-block-style',
'border-block-color',
'border-inline',
'border-inline-start',
'border-inline-end',
'border-inline-width',
'border-inline-style',
'border-inline-color',
],
boxShadow: ['box-shadow'],
background: [
'background',
'background-attachment',
'background-clip',
'background-color',
'background-image',
'background-origin',
'background-position',
'background-repeat',
'background-size',
'background-blend-mode',
'object-fit',
'object-position',
'image-orientation',
'image-rendering',
],
cursor: ['cursor', 'pointer-events', 'touch-action'],
padding: [
'padding',
'padding-top',
'padding-right',
'padding-bottom',
'padding-left',
'padding-block',
'padding-block-start',
'padding-block-end',
'padding-inline',
'padding-inline-start',
'padding-inline-end',
],
dimensions: [
'width',
'min-width',
'max-width',
'height',
'min-height',
'max-height',
'block-size',
'min-block-size',
'max-block-size',
'inline-size',
'min-inline-size',
'max-inline-size',
],
overflow: [
'overflow',
'overflow-x',
'overflow-y',
'overflow-block',
'overflow-inline',
'overflow-clip-margin',
'overflow-anchor',
'overflow-wrap',
'overscroll-behavior',
'overscroll-behavior-x',
'overscroll-behavior-y',
'resize',
'scrollbar-width',
'scrollbar-color',
'scrollbar-gutter',
'scroll-behavior',
'scroll-margin',
'scroll-padding',
'scroll-snap-align',
'scroll-snap-stop',
'scroll-snap-type',
],
listStyle: ['list-style', 'list-style-type', 'list-style-position', 'list-style-image', 'caption-side'],
tables: ['table-layout', 'border-collapse', 'border-spacing', 'empty-cells'],
animation: [
'animation',
'animation-name',
'animation-duration',
'animation-timing-function',
'animation-delay',
'animation-iteration-count',
'animation-direction',
'animation-fill-mode',
'animation-play-state',
],
text: [
'vertical-align',
'direction',
'writing-mode',
'text-orientation',
'unicode-bidi',
'tab-size',
'text-align',
'text-align-last',
'text-justify',
'text-indent',
'text-transform',
'text-decoration',
'text-decoration-color',
'text-decoration-line',
'text-decoration-style',
'text-decoration-thickness',
'text-decoration-skip-ink',
'text-underline-position',
'text-rendering',
'text-shadow',
'text-overflow',
'text-wrap',
'text-size-adjust',
'text-combine-upright',
'hyphens',
'line-break',
'ruby-position',
'caret-color',
'user-select',
],
textSpacing: [
'line-height',
'word-spacing',
'letter-spacing',
'white-space',
'word-break',
'word-wrap',
'orphans',
'widows',
'color',
],
font: [
'font',
'font-family',
'font-size',
'font-size-adjust',
'font-stretch',
'font-weight',
'font-smoothing',
'osx-font-smoothing',
'font-variant',
'font-style',
'font-feature-settings',
'font-kerning',
'font-optical-sizing',
],
content: ['content', 'quotes'],
counters: ['counter-reset', 'counter-increment', 'counter-set'],
breaks: ['page-break-before', 'page-break-after', 'page-break-inside', 'break-before', 'break-after', 'break-inside'],
};
/**
* Export all available groups for use in custom groups rule
*/
export const availableGroups = Object.keys(concentricGroups);

View file

@ -0,0 +1,3 @@
import concentricOrderRule from './rule-definition.js';
export default concentricOrderRule;

View file

@ -0,0 +1,85 @@
import type { Rule } from 'eslint';
import { generateFixesForCSSOrder } from '../shared-utils/css-order-fixer.js';
import type { CSSPropertyInfo } from './types.js';
/**
* Reports a violation of the concentric CSS ordering rule and generates fixes.
*
* @param ruleContext The ESLint rule context used for reporting and fixing.
* @param currentProperty The current property in the order.
* @param nextProperty The next property that is out of order.
* @param cssPropertyInfoList The full list of CSS properties to be reordered.
*/
const reportOrderingIssue = (
ruleContext: Rule.RuleContext,
currentProperty: CSSPropertyInfo,
nextProperty: CSSPropertyInfo,
cssPropertyInfoList: CSSPropertyInfo[],
): void => {
ruleContext.report({
node: nextProperty.node as Rule.Node,
messageId: 'incorrectOrder',
data: {
next: nextProperty.name,
current: currentProperty.name,
},
fix: (fixer) =>
generateFixesForCSSOrder(
fixer,
ruleContext,
cssPropertyInfoList,
compareProperties,
(propertyInfo) => propertyInfo.node as Rule.Node,
),
});
};
/**
* Compares two CSS properties based on their priority and position within their group.
*
* @param firstProperty The first property to compare.
* @param secondProperty The second property to compare.
* @returns A number indicating the relative order of the properties (-1, 0, or 1).
*/
const compareProperties = (firstProperty: CSSPropertyInfo, secondProperty: CSSPropertyInfo): number => {
if (firstProperty.priority !== secondProperty.priority) {
return firstProperty.priority - secondProperty.priority;
}
return firstProperty.positionInGroup - secondProperty.positionInGroup;
};
/**
* Enforces concentric ordering of CSS properties.
*
* This function checks the order of CSS properties to ensure they follow a concentric order
* based on their priority and position within their group. It performs the following steps:
* 1. Validates if there are enough properties to compare.
* 2. Creates pairs of consecutive properties for comparison.
* 3. Identifies the first pair that violates the concentric order.
* 4. If a violation is detected, reports the issue and suggests fixes using ESLint.
*
* @param ruleContext - The ESLint rule context used for reporting and fixing.
* @param cssPropertyInfoList - An array of CSS property information objects to be checked.
*/
export const enforceConcentricCSSOrder = (
ruleContext: Rule.RuleContext,
cssPropertyInfoList: CSSPropertyInfo[],
): void => {
if (cssPropertyInfoList.length <= 1) {
return;
}
// Create pairs of consecutive properties
const propertyPairs = cssPropertyInfoList.slice(0, -1).map((currentProperty, index) => ({
currentProperty,
nextProperty: cssPropertyInfoList[index + 1] as CSSPropertyInfo,
}));
const violatingPair = propertyPairs.find(
({ currentProperty, nextProperty }) => compareProperties(currentProperty, nextProperty) > 0,
);
if (violatingPair) {
reportOrderingIssue(ruleContext, violatingPair.currentProperty, violatingPair.nextProperty, cssPropertyInfoList);
}
};

View file

@ -0,0 +1,31 @@
import type { Rule } from 'eslint';
import { TSESTree } from '@typescript-eslint/utils';
import { processRecipeProperties } from '../shared-utils/recipe-property-processor.js';
import { processStyleNode } from '../shared-utils/style-node-processor.js';
import { enforceConcentricCSSOrderInStyleObject } from './style-object-processor.js';
/**
* Enforces concentric ordering of CSS properties within a recipe function call.
*
* @param ruleContext The ESLint rule context for reporting and fixing issues.
* @param callExpression The CallExpression node representing the recipe function call.
*
* This function does the following:
* 1. Checks if the first argument of the recipe function is an ObjectExpression.
* 2. If valid, processes the recipe object's properties.
* 3. For each relevant property (e.g., 'base', 'variants'), it applies concentric ordering to the CSS properties.
*/
export const enforceConcentricCSSOrderInRecipe = (
ruleContext: Rule.RuleContext,
callExpression: TSESTree.CallExpression,
): void => {
if (!callExpression.arguments[0] || callExpression.arguments[0].type !== 'ObjectExpression') {
return;
}
const recipeObjectExpression = callExpression.arguments[0];
processRecipeProperties(ruleContext, recipeObjectExpression, (currentContext, styleObject) =>
processStyleNode(currentContext, styleObject, enforceConcentricCSSOrderInStyleObject),
);
};

View file

@ -0,0 +1,23 @@
import type { Rule } from 'eslint';
import { createNodeVisitors } from '../shared-utils/order-strategy-visitor-creator.js';
const concentricOrderRule: Rule.RuleModule = {
meta: {
type: 'suggestion',
docs: {
description: 'enforce concentric CSS property ordering in vanilla-extract styles',
category: 'Stylistic Issues',
recommended: true,
},
fixable: 'code',
schema: [],
messages: {
incorrectOrder: "Property '{{next}}' should come before '{{current}}' according to concentric CSS ordering.",
},
},
create(context) {
return createNodeVisitors(context, 'concentric');
},
};
export default concentricOrderRule;

View file

@ -0,0 +1,72 @@
import type { Rule } from 'eslint';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
import { createCSSPropertyPriorityMap } from '../shared-utils/css-property-priority-map.js';
import { isSelectorsObject, processNestedSelectors } from '../shared-utils/nested-selectors-processor.js';
import { getPropertyName, separateProperties } from '../shared-utils/property-separator.js';
import { enforceConcentricCSSOrder } from './property-order-enforcer.js';
import type { CSSPropertyInfo } from './types.js';
const cssPropertyPriorityMap = createCSSPropertyPriorityMap();
/**
* Builds a list of CSS properties with their priority information in the concentric order of their groups.
*
* @param regularStyleProperties An array of regular style properties.
* @returns An array of CSSPropertyInfo objects containing name, node, priority, and position information.
*/
const buildCSSPropertyInfoList = (regularStyleProperties: TSESTree.Property[]): CSSPropertyInfo[] => {
return regularStyleProperties.map((styleProperty) => {
const propertyName = getPropertyName(styleProperty);
const propertyInfo = cssPropertyPriorityMap.get(propertyName);
return {
name: propertyName,
node: styleProperty,
priority: propertyInfo?.groupIndex ?? Number.MAX_SAFE_INTEGER,
positionInGroup: propertyInfo?.positionInGroup ?? Number.MAX_SAFE_INTEGER,
};
});
};
/**
* Enforces concentric ordering of CSS properties within a style object.
*
* This function processes the given style object to ensure that CSS properties
* follow a concentric order based on predefined priority groups. It handles
* different types of style objects by:
* 1. Validating that the input is an ObjectExpression.
* 2. Processing 'selectors' objects separately and recursively applying the
* concentric order enforcement.
* 3. Separating regular properties and building a list of CSSPropertyInfo
* objects with priority details.
* 4. Enforcing concentric order on the properties using their priority
* information.
* 5. Recursively processing nested selectors and style objects.
*
* @param ruleContext - The ESLint rule context for reporting and fixing issues.
* @param styleObject - The object expression representing the style object to be processed.
*/
export const enforceConcentricCSSOrderInStyleObject = (
ruleContext: Rule.RuleContext,
styleObject: TSESTree.ObjectExpression,
): void => {
if (!styleObject || styleObject.type !== AST_NODE_TYPES.ObjectExpression) {
return;
}
if (isSelectorsObject(styleObject)) {
styleObject.properties.forEach((property) => {
if (property.type === AST_NODE_TYPES.Property && property.value.type === AST_NODE_TYPES.ObjectExpression) {
enforceConcentricCSSOrderInStyleObject(ruleContext, property.value);
}
});
return;
}
const { regularProperties } = separateProperties(styleObject.properties);
const cssPropertyInfoList = buildCSSPropertyInfoList(regularProperties);
enforceConcentricCSSOrder(ruleContext, cssPropertyInfoList);
processNestedSelectors(ruleContext, styleObject, enforceConcentricCSSOrderInStyleObject);
};

View file

@ -0,0 +1,9 @@
import { TSESTree } from '@typescript-eslint/utils';
export interface CSSPropertyInfo {
name: string;
node: TSESTree.Property;
priority: number;
positionInGroup: number;
group?: string;
}

View file

@ -0,0 +1,91 @@
import type { Rule } from 'eslint';
import { generateFixesForCSSOrder } from '../shared-utils/css-order-fixer.js';
import { createCSSPropertyPriorityMap } from '../shared-utils/css-property-priority-map.js';
import type { CSSPropertyInfo } from '../concentric-order/types.js';
/**
* Enforces a custom ordering of CSS properties based on user-defined groups.
*
* @param ruleContext The ESLint rule context used for reporting and fixing.
* @param cssPropertyInfoList An array of CSS property information objects to be ordered.
* @param userDefinedGroups Array of user-defined property groups for custom ordering.
* @param sortRemainingProperties Strategy for sorting properties not in user-defined groups
* ('alphabetical' or 'concentric'). Defaults to 'concentric'.
*
* This function compares CSS properties based on their group priority and position within
* those groups. Properties not part of user-defined groups are sorted according to the
* specified strategy. If an ordering violation is detected, an ESLint report is generated
* with a suggested fix.
*/
export const enforceCustomGroupOrder = (
ruleContext: Rule.RuleContext,
cssPropertyInfoList: CSSPropertyInfo[],
userDefinedGroups: string[] = [],
sortRemainingProperties?: 'alphabetical' | 'concentric',
): void => {
if (cssPropertyInfoList.length <= 1) {
return;
}
const cssPropertyPriorityMap = createCSSPropertyPriorityMap(userDefinedGroups);
const compareProperties = (firstProperty: CSSPropertyInfo, secondProperty: CSSPropertyInfo) => {
const firstPropertyInfo = cssPropertyPriorityMap.get(firstProperty.name) || {
groupIndex: Infinity,
positionInGroup: Infinity,
inUserGroup: false,
};
const secondPropertyInfo = cssPropertyPriorityMap.get(secondProperty.name) || {
groupIndex: Infinity,
positionInGroup: Infinity,
inUserGroup: false,
};
if (firstPropertyInfo.inUserGroup !== secondPropertyInfo.inUserGroup) {
return firstPropertyInfo.inUserGroup ? -1 : 1;
}
if (firstPropertyInfo.inUserGroup) {
if (firstPropertyInfo.groupIndex !== secondPropertyInfo.groupIndex) {
return firstPropertyInfo.groupIndex - secondPropertyInfo.groupIndex;
}
return firstPropertyInfo.positionInGroup - secondPropertyInfo.positionInGroup;
}
// For properties not in user-defined groups
if (sortRemainingProperties === 'alphabetical') {
return firstProperty.name.localeCompare(secondProperty.name);
} else {
return (
firstPropertyInfo.groupIndex - secondPropertyInfo.groupIndex ||
firstPropertyInfo.positionInGroup - secondPropertyInfo.positionInGroup
);
}
};
const sortedPropertyList = [...cssPropertyInfoList].sort(compareProperties);
// Find the first pair that violates the new ordering
const violatingProperty = cssPropertyInfoList
.slice(0, -1)
.find((currentProperty, index) => currentProperty.name !== sortedPropertyList[index]?.name);
if (violatingProperty) {
ruleContext.report({
node: violatingProperty.node as Rule.Node,
messageId: 'incorrectOrder',
data: {
currentProperty: violatingProperty.name,
nextProperty: sortedPropertyList[cssPropertyInfoList.indexOf(violatingProperty)]?.name || '',
},
fix: (fixer) =>
generateFixesForCSSOrder(
fixer,
ruleContext,
cssPropertyInfoList,
compareProperties,
(propertyInfo) => propertyInfo.node as Rule.Node,
),
});
}
};

View file

@ -0,0 +1,42 @@
import type { Rule } from 'eslint';
import { TSESTree } from '@typescript-eslint/utils';
import { processRecipeProperties } from '../shared-utils/recipe-property-processor.js';
import { processStyleNode } from '../shared-utils/style-node-processor.js';
import { enforceUserDefinedGroupOrderInStyleObject } from './style-object-processor.js';
/**
* Enforces custom group ordering of CSS properties within a recipe function call.
*
* @param ruleContext The ESLint rule context for reporting and fixing issues.
* @param callExpression The CallExpression node representing the recipe function call.
* @param userDefinedGroups An array of property groups in the desired order.
*
* This function does the following:
* 1. Validates that the first argument of the recipe function is an ObjectExpression.
* 2. Processes the recipe object's properties if valid.
* 3. Applies custom group ordering to CSS properties in relevant properties (e.g., 'base', 'variants').
* 4. Processes nested selectors and style objects recursively.
*/
export const enforceUserDefinedGroupOrderInRecipe = (
ruleContext: Rule.RuleContext,
callExpression: TSESTree.CallExpression,
userDefinedGroups: string[],
sortRemainingPropertiesMethod?: 'alphabetical' | 'concentric',
): void => {
if (!callExpression.arguments[0] || callExpression.arguments[0].type !== 'ObjectExpression') {
return;
}
const recipeObjectExpression = callExpression.arguments[0];
processRecipeProperties(ruleContext, recipeObjectExpression, (currentContext, styleObject) =>
processStyleNode(currentContext, styleObject, (styleContext, styleObjectNode) =>
enforceUserDefinedGroupOrderInStyleObject(
styleContext,
styleObjectNode,
userDefinedGroups,
sortRemainingPropertiesMethod,
),
),
);
};

View file

@ -0,0 +1,55 @@
import type { Rule } from 'eslint';
import { availableGroups } from '../concentric-order/concentric-groups.js';
import { createNodeVisitors } from '../shared-utils/order-strategy-visitor-creator.js';
interface CustomGroupRuleConfiguration {
groupOrder?: string[];
sortRemainingProperties: 'alphabetical' | 'concentric';
}
const customGroupOrderRule: Rule.RuleModule = {
meta: {
type: 'suggestion',
docs: {
description: 'enforce custom group CSS property ordering in vanilla-extract styles',
category: 'Stylistic Issues',
recommended: true,
},
fixable: 'code',
schema: [
{
type: 'object',
properties: {
groupOrder: {
type: 'array',
items: {
enum: availableGroups,
},
},
sortRemainingProperties: {
enum: ['alphabetical', 'concentric'],
},
},
additionalProperties: false,
},
],
messages: {
incorrectOrder:
"Property '{{nextProperty}}' should come before '{{currentProperty}}' according to custom CSS group ordering.",
},
},
create(ruleContext: Rule.RuleContext) {
const ruleConfiguration = ruleContext.options[0] as CustomGroupRuleConfiguration;
const userDefinedGroupOrder = ruleConfiguration?.groupOrder ?? [];
const sortRemainingPropertiesMethod = ruleConfiguration?.sortRemainingProperties ?? 'alphabetical';
return createNodeVisitors(
ruleContext,
'userDefinedGroupOrder',
userDefinedGroupOrder,
sortRemainingPropertiesMethod,
);
},
};
export default customGroupOrderRule;

View file

@ -0,0 +1,79 @@
import type { Rule } from 'eslint';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
import { concentricGroups } from '../concentric-order/concentric-groups.js';
import { createCSSPropertyPriorityMap } from '../shared-utils/css-property-priority-map.js';
import { isSelectorsObject, processNestedSelectors } from '../shared-utils/nested-selectors-processor.js';
import { getPropertyName, separateProperties } from '../shared-utils/property-separator.js';
import { enforceCustomGroupOrder } from './property-order-enforcer.js';
import type { CSSPropertyInfo } from '../concentric-order/types.js';
/**
* Enforces a custom ordering of CSS properties based on user-defined groups in a given style object.
*
* @param context The ESLint rule context for reporting and fixing issues.
* @param styleObject The ObjectExpression node representing the style object to be processed.
* @param userDefinedGroups An array of property groups in the desired order.
* @param sortRemainingPropertiesMethod Strategy for sorting properties not in user-defined groups ('alphabetical' or 'concentric'). Defaults to 'concentric'.
*
* This function:
* 1. Validates the input styleObject.
* 2. Handles 'selectors' objects separately, processing their nested style objects.
* 3. Creates a priority map based on user-defined groups.
* 4. Processes regular properties, creating a list of CSSPropertyInfo objects.
* 5. Enforces custom group ordering on the properties.
* 6. Recursively processes nested selectors and style objects.
*/
export const enforceUserDefinedGroupOrderInStyleObject = (
ruleContext: Rule.RuleContext,
styleObject: TSESTree.ObjectExpression,
userDefinedGroups: string[],
sortRemainingPropertiesMethod: 'alphabetical' | 'concentric' = 'concentric',
): void => {
if (!styleObject || styleObject.type !== AST_NODE_TYPES.ObjectExpression) {
return;
}
if (isSelectorsObject(styleObject)) {
styleObject.properties.forEach((property) => {
if (property.type === AST_NODE_TYPES.Property && property.value.type === AST_NODE_TYPES.ObjectExpression) {
enforceUserDefinedGroupOrderInStyleObject(
ruleContext,
property.value,
userDefinedGroups,
sortRemainingPropertiesMethod,
);
}
});
return;
}
const cssPropertyPriorityMap = createCSSPropertyPriorityMap(userDefinedGroups);
const { regularProperties } = separateProperties(styleObject.properties);
const cssPropertyInfoList: CSSPropertyInfo[] = regularProperties.map((property) => {
const propertyName = getPropertyName(property);
const propertyInfo = cssPropertyPriorityMap.get(propertyName);
const group =
userDefinedGroups.find((groupName) => concentricGroups[groupName]?.includes(propertyName)) || 'remaining';
return {
name: propertyName,
node: property,
priority: propertyInfo?.groupIndex ?? Number.MAX_SAFE_INTEGER,
positionInGroup: propertyInfo?.positionInGroup ?? Number.MAX_SAFE_INTEGER,
group,
inUserGroup: propertyInfo?.inUserGroup ?? false,
};
});
enforceCustomGroupOrder(ruleContext, cssPropertyInfoList, userDefinedGroups, sortRemainingPropertiesMethod);
processNestedSelectors(ruleContext, styleObject, (nestedContext, nestedNode) =>
enforceUserDefinedGroupOrderInStyleObject(
nestedContext,
nestedNode,
userDefinedGroups,
sortRemainingPropertiesMethod,
),
);
};

View file

@ -0,0 +1,55 @@
import type { Rule, SourceCode } from 'eslint';
/**
* Generates ESLint fixes for CSS property ordering violations.
* @param eslintFixer The ESLint fixer instance used to create fix objects.
* @param ruleContext The ESLint rule context, providing access to the source code.
* @param cssProperties The list of CSS properties to sort (can be TSESTree.Property[] or CSSPropertyInfo[]).
* @param compareProperties A comparison function that defines the sorting logic for the properties.
* @param extractNode A function that extracts the AST node from each property (used for text replacement).
* @returns An array of ESLint Fix objects to correct the property order.
*
* This function performs the following steps:
* 1. Sorts the input properties using the provided comparison function.
* 2. Maps the original and sorted properties to their text ranges.
* 3. Creates fix objects for properties whose positions have changed after sorting.
* 4. Returns an array of fixes that, when applied, will reorder the properties correctly.
*/
export const generateFixesForCSSOrder = <T>(
eslintFixer: Rule.RuleFixer,
ruleContext: Rule.RuleContext,
cssProperties: T[],
compareProperties: (firstProperty: T, secondProperty: T) => number,
extractNode: (property: T) => Rule.Node,
): Rule.Fix[] => {
const sourceCode: SourceCode = ruleContext.sourceCode;
// Sort properties using the provided comparison function
const sortedProperties = [...cssProperties].sort(compareProperties);
// Map each original property to its text range
const originalPropertyRanges = cssProperties.map((property) => ({
property,
range: extractNode(property).range,
}));
// Map sorted properties back to their original range information
const sortedPropertyRanges = sortedProperties.map((property) =>
originalPropertyRanges.find((rangeInfo) => rangeInfo.property === property),
);
// Generate fixes for properties that have changed position
return originalPropertyRanges
.map((originalRangeInfo, index) => {
const sortedRangeInfo = sortedPropertyRanges[index];
// Create a fix only if the property's position has changed
if (originalRangeInfo && sortedRangeInfo && originalRangeInfo !== sortedRangeInfo) {
const sortedPropertyText = sourceCode.getText(extractNode(sortedRangeInfo.property));
return eslintFixer.replaceText(extractNode(originalRangeInfo.property), sortedPropertyText);
}
return null;
})
.filter((fix): fix is Rule.Fix => fix !== null);
};

View file

@ -0,0 +1,60 @@
import { concentricGroups } from '../concentric-order/concentric-groups.js';
/**
* Creates a map of CSS properties to their priority information.
*
* This function generates a Map where each key is a CSS property (in camelCase),
* and each value is an object containing:
* - groupIndex: The index of the property's group
* - positionInGroup: The position of the property within its group
* - inUserGroup: Whether the property is in a user-specified group
*
* The function prioritizes user-specified groups over default concentric groups.
* If user groups are provided, they are processed first. Any remaining concentric
* groups are then processed to ensure complete coverage of CSS properties.
*
* @param userGroups - An optional array of user-specified group names to prioritize
* @returns A Map of CSS properties to their priority information
*
* @example
* const priorityMap = createCSSPropertyPriorityMap(['layout', 'typography']);
* console.log(priorityMap.get('display')); // { groupIndex: 0, positionInGroup: 0, inUserGroup: true }
*/
export const createCSSPropertyPriorityMap = (userGroups: string[] = []) => {
const cssPropertyPriorityMap = new Map<
string,
{
groupIndex: number;
positionInGroup: number;
inUserGroup: boolean;
}
>();
const processGroup = (groupName: string, groupIndex: number, inUserGroup: boolean) => {
const properties = concentricGroups[groupName] || [];
properties.forEach((property, positionInGroup) => {
const camelCaseProperty = property.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
if (!cssPropertyPriorityMap.has(camelCaseProperty)) {
cssPropertyPriorityMap.set(camelCaseProperty, {
groupIndex,
positionInGroup,
inUserGroup,
});
}
});
};
// Process user-specified groups first
userGroups.forEach((groupName, index) => processGroup(groupName, index, true));
// Process remaining groups if needed (for concentric order or as fallback)
if (userGroups.length === 0 || userGroups.length < Object.keys(concentricGroups).length) {
Object.keys(concentricGroups).forEach((groupName, index) => {
if (!userGroups.includes(groupName)) {
processGroup(groupName, userGroups.length + index, false);
}
});
}
return cssPropertyPriorityMap;
};

View file

@ -0,0 +1,44 @@
import type { Rule } from 'eslint';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
/**
* Determines if the given object node is a 'selectors' object within a style definition.
* @param objectNode The object expression node to check.
* @returns True if the node is a 'selectors' object, false otherwise.
*
* This function checks if:
* 1. The node has a parent
* 2. The parent is a Property node
* 3. The parent's key is an Identifier
* 4. The parent's key name is 'selectors'
*/
export const isSelectorsObject = (objectNode: TSESTree.ObjectExpression): boolean => {
return (
objectNode.parent &&
objectNode.parent.type === AST_NODE_TYPES.Property &&
objectNode.parent.key.type === 'Identifier' &&
objectNode.parent.key.name === 'selectors'
);
};
/**
* Processes nested selectors within a 'selectors' object by recursively validating their value objects.
* @param context The ESLint rule context.
* @param objectNode The object expression node representing the 'selectors' object.
* @param validateFn A function to validate each nested selector's value object.
*
* This function iterates through each property of the 'selectors' object:
* - If a property's value is an ObjectExpression, it applies the validateFn to that object.
* - This allows for validation of nested style objects within selectors.
*/
export const processNestedSelectors = (
context: Rule.RuleContext,
objectNode: TSESTree.ObjectExpression,
validateFn: (context: Rule.RuleContext, objectNode: TSESTree.ObjectExpression) => void,
): void => {
objectNode.properties.forEach((property) => {
if (property.type === AST_NODE_TYPES.Property && property.value.type === AST_NODE_TYPES.ObjectExpression) {
validateFn(context, property.value);
}
});
};

View file

@ -0,0 +1,98 @@
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 { processStyleNode } from './style-node-processor.js';
/**
* Creates an ESLint rule listener with visitors for style-related function calls.
* @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. Style-related functions: 'style', 'styleVariants', 'createVar', 'createTheme', 'createThemeContract'
* 2. The 'globalStyle' function
* 3. The 'recipe' function
*
* Each visitor applies the appropriate ordering strategy to the style objects in these function calls.
*/
export const createNodeVisitors = (
ruleContext: Rule.RuleContext,
orderingStrategy: 'alphabetical' | 'concentric' | 'userDefinedGroupOrder',
userDefinedGroupOrder?: string[],
sortRemainingProperties?: 'alphabetical' | 'concentric',
): Rule.RuleListener => {
// 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) {
throw new Error('💥 👿 User-defined group order must be provided for userDefinedGroupOrder strategy');
}
return (ruleContext: Rule.RuleContext, node: TSESTree.Node) =>
enforceUserDefinedGroupOrderInStyleObject(
ruleContext,
node as TSESTree.ObjectExpression,
userDefinedGroupOrder,
sortRemainingProperties,
);
default:
return enforceAlphabeticalCSSOrderInStyleObject;
}
})();
return {
CallExpression(node) {
if (node.callee.type !== 'Identifier') {
return;
}
// Handle style-related functions
if (['style', 'styleVariants', 'createVar', 'createTheme', 'createThemeContract'].includes(node.callee.name)) {
if (node.arguments.length > 0) {
const styleArg = node.arguments[0];
processStyleNode(ruleContext, styleArg as TSESTree.Node, processProperty);
}
}
// Handle globalStyle function
if (node.callee.name === 'globalStyle' && node.arguments.length >= 2) {
const styleArg = node.arguments[1];
processStyleNode(ruleContext, styleArg as TSESTree.Node, processProperty);
}
// Handle recipe function
if (node.callee.name === 'recipe') {
switch (orderingStrategy) {
case 'alphabetical':
enforceAlphabeticalCSSOrderInRecipe(node as TSESTree.CallExpression, ruleContext);
break;
case 'concentric':
enforceConcentricCSSOrderInRecipe(ruleContext, node as TSESTree.CallExpression);
break;
case 'userDefinedGroupOrder':
if (userDefinedGroupOrder) {
enforceUserDefinedGroupOrderInRecipe(
ruleContext,
node as TSESTree.CallExpression,
userDefinedGroupOrder,
sortRemainingProperties,
);
}
break;
}
}
},
};
};

View file

@ -0,0 +1,57 @@
import { TSESTree } from '@typescript-eslint/utils';
/**
* Extracts the name of a property from a TSESTree.Property node.
* @param property The property node to extract the name from.
* @returns The name of the property as a string, or an empty string if the name cannot be determined.
*
* This function handles two types of property keys:
* - Identifier: Returns the name directly.
* - Literal (string): Returns the string value.
* For any other type of key, it returns an empty string.
*/
export const getPropertyName = (property: TSESTree.Property): string => {
if (property.key.type === 'Identifier') {
return property.key.name;
} else if (property.key.type === 'Literal' && typeof property.key.value === 'string') {
return property.key.value;
}
return '';
};
/**
* Separates object properties into regular and special categories.
* @param properties An array of object literal elements to be categorized.
* @returns An object containing two arrays: regularProperties and specialProperties.
*
* This function categorizes properties as follows:
* - Regular properties: Standard CSS properties.
* - Special properties: Properties that start with ':' (pseudo-selectors),
* '@' (at-rules), or are named 'selectors'.
*
* Non-Property type elements in the input array are ignored.
*/
export const separateProperties = (
properties: TSESTree.ObjectLiteralElement[],
): {
regularProperties: TSESTree.Property[];
specialProperties: TSESTree.Property[];
} => {
const regularProperties: TSESTree.Property[] = [];
const specialProperties: TSESTree.Property[] = [];
// Separate regular CSS properties from special ones (pseudo selectors, etc.)
properties.forEach((property) => {
if (property.type === 'Property') {
const propName = getPropertyName(property);
if (propName.startsWith(':') || propName.startsWith('@') || propName === 'selectors') {
specialProperties.push(property);
} else {
regularProperties.push(property);
}
}
});
return { regularProperties, specialProperties };
};

View file

@ -0,0 +1,45 @@
import type { Rule } from 'eslint';
import { TSESTree } from '@typescript-eslint/utils';
/**
* Processes the `base` and `variants` properties of a recipe object.
* @param ruleContext The ESLint rule context.
* @param recipeNode The recipe object node to process.
* @param processProperty A callback function to process each property object (e.g., for alphabetical or concentric ordering).
*
* This function iterates through the properties of the recipe object:
* - For the `base` property, it directly processes the object.
* - For the `variants` property, it processes each variant's options individually.
*
* The function skips any non-Property nodes or nodes without an Identifier key.
* It only processes ObjectExpression values to ensure type safety.
*/
export const processRecipeProperties = (
ruleContext: Rule.RuleContext,
recipeNode: TSESTree.ObjectExpression,
processProperty: (ruleContext: Rule.RuleContext, value: TSESTree.ObjectExpression) => void,
): void => {
recipeNode.properties.forEach((property: TSESTree.Property | TSESTree.SpreadElement) => {
if (property.type !== 'Property' || property.key.type !== 'Identifier') {
return; // Skip non-property nodes or nodes without an identifier key
}
// Process the `base` property
if (property.key.name === 'base' && property.value.type === 'ObjectExpression') {
processProperty(ruleContext, property.value);
}
// Process the `variants` property
if (property.key.name === 'variants' && property.value.type === 'ObjectExpression') {
property.value.properties.forEach((variantProperty) => {
if (variantProperty.type === 'Property' && variantProperty.value.type === 'ObjectExpression') {
variantProperty.value.properties.forEach((optionProperty) => {
if (optionProperty.type === 'Property' && optionProperty.value.type === 'ObjectExpression') {
processProperty(ruleContext, optionProperty.value);
}
});
}
});
}
});
};

View file

@ -0,0 +1,30 @@
import type { Rule } from 'eslint';
import { TSESTree } from '@typescript-eslint/utils';
/**
* Recursively processes a style node, which can be an object or an array of objects.
* @param ruleContext The ESLint rule context.
* @param node The node to process.
* @param processProperty A function to process each object expression.
*/
export const processStyleNode = (
ruleContext: Rule.RuleContext,
node: TSESTree.Node | undefined,
processProperty: (ruleContext: Rule.RuleContext, value: TSESTree.ObjectExpression) => void,
): void => {
if (!node) {
return;
}
if (node.type === 'ObjectExpression') {
processProperty(ruleContext, node);
}
if (node.type === 'ArrayExpression') {
node.elements.forEach((element) => {
if (element && element.type === 'ObjectExpression') {
processProperty(ruleContext, element);
}
});
}
};

39
src/index.ts Normal file
View file

@ -0,0 +1,39 @@
import alphabeticalOrderRule from './css-rules/alphabetical-order/index.js';
import concentricOrderRule from './css-rules/concentric-order/index.js';
import customOrderRule from './css-rules/custom-order/rule-definition.js';
export const vanillaExtract = {
meta: {
name: '@antebudimir/eslint-plugin-vanilla-extract',
version: '1.0.0',
},
rules: {
'alphabetical-order': alphabeticalOrderRule,
'concentric-order': concentricOrderRule,
'custom-order': customOrderRule,
},
configs: {
recommended: [
{
plugins: {
'vanilla-extract': {
rules: { 'concentric-order': concentricOrderRule },
},
},
rules: { 'vanilla-extract/concentric-order': 'warn' },
},
],
alphabetical: [
{
plugins: {
'vanilla-extract': {
rules: { 'alphabetical-order': alphabeticalOrderRule },
},
},
rules: { 'vanilla-extract/alphabetical-order': 'warn' },
},
],
},
};
export default vanillaExtract;

32
tsconfig.json Normal file
View file

@ -0,0 +1,32 @@
{
"compilerOptions": {
// Target and Module Settings
"target": "es2020",
"module": "NodeNext",
"moduleResolution": "NodeNext",
// Output Settings
"declaration": true,
"outDir": "./dist",
// Type Checking Options
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"useUnknownInCatchVariables": true,
// Interop Options
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
// Other Options
"resolveJsonModule": true,
"isolatedModules": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*", "*.d.ts"],
"exclude": ["node_modules", "**/*.test.ts", "dist"],
"typeRoots": ["./node_modules/@types", "./"]
}