feat 🥁: add prefer-logical-properties rule for i18n-friendly styles

- Add new rule `prefer-logical-properties` that enforces logical CSS properties over physical directional properties
- Detects 140+ physical property mappings across margin, padding, border, inset, size, overflow, and scroll properties
- Supports value-based detection for `text-align`, `float`, `clear`, and `resize` properties
- Provides automatic fixes for all detected violations
- Preserves original formatting (camelCase/kebab-case and quote style)
- Configurable allowlist via `allow` option to skip specific properties
- Comprehensive test coverage
This commit is contained in:
Ante Budimir 2025-11-09 20:53:47 +02:00
parent 69dd109311
commit 5b0bcf17c7
10 changed files with 1201 additions and 3 deletions

View file

@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.14.0] - 2025-11-09
- Add new rule `prefer-logical-properties` that enforces logical CSS properties over physical directional properties
- Detects 140+ physical property mappings across margin, padding, border, inset, size, overflow, and scroll properties
- Supports value-based detection for `text-align`, `float`, `clear`, and `resize` properties
- Provides automatic fixes for all detected violations
- Preserves original formatting (camelCase/kebab-case and quote style)
- Configurable allowlist via `allow` option to skip specific properties
- Comprehensive test coverage
## [1.13.0] - 2025-11-04
- Add new rule `no-px-unit` that disallows `px` units in vanilla-extract styles with an allowlist option

View file

@ -264,6 +264,7 @@ The recommended configuration enables the following rules with error severity:
- `vanilla-extract/alphabetical-order`: Alternative ordering rule (alphabetical sorting)
- `vanilla-extract/custom-order`: Alternative ordering rule (custom group-based sorting)
- `vanilla-extract/no-px-unit`: Disallows px units with an optional allowlist
- `vanilla-extract/prefer-logical-properties`: Enforces logical CSS properties over physical directional properties
You can use the recommended configuration as a starting point and override rules as needed for your project. See the configuration examples above for how to switch between ordering rules.
@ -602,6 +603,43 @@ export const myStyle = style({
});
```
### vanilla-extract/prefer-logical-properties
This rule enforces the use of CSS logical properties instead of physical (directional) properties in vanilla-extract style declarations. Logical properties adapt to different writing directions (LTR/RTL) and writing modes, making your styles more internationalization-friendly. Supports 140+ property mappings across margin, padding, border, inset, size, overflow, and scroll properties. Configurable allowlist lets you permit specific properties via the `allow` option (supports both camelCase and kebab-case).
Configuration with an allowlist:
```json
{
"rules": {
"vanilla-extract/prefer-logical-properties": ["error", { "allow": ["top", "left"] }]
}
}
```
```typescript
// ❌ Incorrect
import { style } from '@vanilla-extract/css';
export const box = style({
marginLeft: '1rem',
paddingTop: '2rem',
width: '100%',
borderRight: '1px solid',
textAlign: 'left',
});
// ✅ Correct
import { style } from '@vanilla-extract/css';
export const box = style({
marginInlineStart: '1rem',
paddingBlockStart: '2rem',
inlineSize: '100%',
borderInlineEnd: '1px solid',
textAlign: 'start',
});
## Font Face Declarations
For `fontFace` and `globalFontFace` API calls, all three ordering rules (alphabetical, concentric, and custom) enforce
@ -694,7 +732,6 @@ The roadmap outlines the project's current status and future plans:
### Upcoming Features
- `prefer-logical-properties` rule to enforce use of logical properties.
- `prefer-theme-tokens` rule to enforce use of theme tokens instead of hard-coded values when available.
- `no-global-style` rule to disallow use of `globalStyle` function.
- `property-unit-match` rule to enforce valid units per CSS property specs. **Note**: This feature will only be

View file

@ -1,6 +1,6 @@
{
"name": "@antebudimir/eslint-plugin-vanilla-extract",
"version": "1.13.0",
"version": "1.14.0",
"description": "Comprehensive ESLint plugin for vanilla-extract with CSS property ordering, style validation, and best practices enforcement. Supports alphabetical, concentric and custom CSS ordering, auto-fixing, and zero-runtime safety.",
"author": "Ante Budimir",
"license": "MIT",

View file

@ -0,0 +1,573 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import preferLogicalPropertiesRule from '../rule-definition.js';
run({
name: 'vanilla-extract/prefer-logical-properties',
rule: preferLogicalPropertiesRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
marginInlineStart: '1rem',
marginInlineEnd: '1rem',
marginBlockStart: '2rem',
marginBlockEnd: '2rem',
});
`,
name: 'allows logical properties in camelCase',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
'margin-inline-start': '1rem',
'margin-inline-end': '1rem',
'margin-block-start': '2rem',
'margin-block-end': '2rem',
});
`,
name: 'allows logical properties in kebab-case',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
insetInlineStart: 0,
insetInlineEnd: 0,
insetBlockStart: 0,
insetBlockEnd: 0,
});
`,
name: 'allows logical inset properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
borderInlineStartWidth: '1px',
borderInlineEndColor: 'red',
borderBlockStartStyle: 'solid',
});
`,
name: 'allows logical border properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
inlineSize: '100%',
blockSize: '50vh',
minInlineSize: '200px',
maxBlockSize: '800px',
});
`,
name: 'allows logical size properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
textAlign: 'start',
float: 'inline-start',
clear: 'inline-end',
});
`,
name: 'allows logical values for directional properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
marginLeft: '1rem',
paddingTop: '2rem',
top: 0,
left: 0,
});
`,
options: [{ allow: ['marginLeft', 'paddingTop', 'top', 'left'] }],
name: 'respects allowlist for camelCase properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
'margin-left': '1rem',
'padding-top': '2rem',
});
`,
options: [{ allow: ['margin-left', 'padding-top'] }],
name: 'respects allowlist for kebab-case properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
marginLeft: '1rem',
});
`,
options: [{ allow: ['margin-left'] }],
name: 'allowlist works with mixed case (kebab in config, camel in code)',
},
{
code: `
import { recipe } from '@vanilla-extract/css';
const myRecipe = recipe({
base: {
marginInlineStart: '1rem',
paddingBlockEnd: '2rem',
},
variants: {
size: {
sm: { insetInlineStart: 0 },
lg: { borderInlineEndWidth: '2px' },
},
},
});
`,
name: 'allows logical properties in recipe base and variants',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
'@media': {
'(min-width: 768px)': {
marginInlineStart: '2rem',
},
},
selectors: {
'&:hover': {
paddingBlockStart: '1rem',
},
},
});
`,
name: 'allows logical properties in nested @media and selectors',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
color: 'red',
display: 'flex',
fontSize: '16px',
});
`,
name: 'ignores non-directional properties',
},
],
invalid: [
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
marginTop: '1rem',
marginBottom: '2rem',
});
`,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
marginBlockStart: '1rem',
marginBlockEnd: '2rem',
});
`,
errors: [
{
messageId: 'preferLogicalProperty',
data: { physical: 'marginTop', logical: 'marginBlockStart' },
},
{
messageId: 'preferLogicalProperty',
data: { physical: 'marginBottom', logical: 'marginBlockEnd' },
},
],
name: 'reports and fixes margin properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
'padding-left': '1rem',
'padding-right': '2rem',
});
`,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
'padding-inline-start': '1rem',
'padding-inline-end': '2rem',
});
`,
errors: [
{
messageId: 'preferLogicalProperty',
data: { physical: 'padding-left', logical: 'padding-inline-start' },
},
{
messageId: 'preferLogicalProperty',
data: { physical: 'padding-right', logical: 'padding-inline-end' },
},
],
name: 'reports and fixes kebab-case padding properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
top: 0,
left: 0,
right: 0,
bottom: 0,
});
`,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
insetBlockStart: 0,
insetInlineStart: 0,
insetInlineEnd: 0,
insetBlockEnd: 0,
});
`,
errors: [
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
],
name: 'reports and fixes positioning properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
borderLeftWidth: '1px',
borderRightColor: 'red',
borderTopStyle: 'solid',
});
`,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
borderInlineStartWidth: '1px',
borderInlineEndColor: 'red',
borderBlockStartStyle: 'solid',
});
`,
errors: [
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
],
name: 'reports and fixes border sub-properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
borderLeft: '1px solid red',
borderRight: '2px dashed blue',
});
`,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
borderInlineStart: '1px solid red',
borderInlineEnd: '2px dashed blue',
});
`,
errors: [
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
],
name: 'reports and fixes border shorthand properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
borderTopLeftRadius: '4px',
borderBottomRightRadius: '8px',
});
`,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
borderStartStartRadius: '4px',
borderEndEndRadius: '8px',
});
`,
errors: [
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
],
name: 'reports and fixes border radius properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
width: '100px',
height: '200px',
minWidth: '50px',
maxHeight: '400px',
});
`,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
inlineSize: '100px',
blockSize: '200px',
minInlineSize: '50px',
maxBlockSize: '400px',
});
`,
errors: [
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
],
name: 'reports and fixes size properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
overflowX: 'auto',
overflowY: 'hidden',
});
`,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
overflowInline: 'auto',
overflowBlock: 'hidden',
});
`,
errors: [
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
],
name: 'reports and fixes overflow properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
textAlign: 'left',
float: 'right',
clear: 'left',
resize: 'horizontal',
});
`,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
textAlign: 'start',
float: 'inline-end',
clear: 'inline-start',
resize: 'inline',
});
`,
errors: [
{
messageId: 'preferLogicalValue',
data: { property: 'textAlign', physical: 'left', logical: 'start' },
},
{
messageId: 'preferLogicalValue',
data: { property: 'float', physical: 'right', logical: 'inline-end' },
},
{
messageId: 'preferLogicalValue',
data: { property: 'clear', physical: 'left', logical: 'inline-start' },
},
{
messageId: 'preferLogicalValue',
data: { property: 'resize', physical: 'horizontal', logical: 'inline' },
},
],
name: 'reports and fixes directional values',
},
{
code: `
import { recipe } from '@vanilla-extract/css';
const myRecipe = recipe({
base: {
marginLeft: '1rem',
paddingTop: '2rem',
},
variants: {
size: {
sm: { left: 0 },
lg: { borderRightWidth: '2px' },
},
},
});
`,
output: `
import { recipe } from '@vanilla-extract/css';
const myRecipe = recipe({
base: {
marginInlineStart: '1rem',
paddingBlockStart: '2rem',
},
variants: {
size: {
sm: { insetInlineStart: 0 },
lg: { borderInlineEndWidth: '2px' },
},
},
});
`,
errors: [
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
],
name: 'reports and fixes physical properties in recipe base and variants',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
'@media': {
'(min-width: 768px)': {
marginLeft: '2rem',
paddingTop: '1rem',
},
},
selectors: {
'&:hover': {
right: 0,
bottom: 0,
},
},
});
`,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
'@media': {
'(min-width: 768px)': {
marginInlineStart: '2rem',
paddingBlockStart: '1rem',
},
},
selectors: {
'&:hover': {
insetInlineEnd: 0,
insetBlockEnd: 0,
},
},
});
`,
errors: [
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
],
name: 'reports and fixes physical properties in nested @media and selectors',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
scrollMarginLeft: '10px',
scrollPaddingTop: '20px',
});
`,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
scrollMarginInlineStart: '10px',
scrollPaddingBlockStart: '20px',
});
`,
errors: [
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
],
name: 'reports and fixes scroll margin and padding properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
overscrollBehaviorX: 'contain',
overscrollBehaviorY: 'auto',
});
`,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
overscrollBehaviorInline: 'contain',
overscrollBehaviorBlock: 'auto',
});
`,
errors: [
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalProperty' },
],
name: 'reports and fixes overscroll behavior properties',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
marginLeft: \`1rem\`,
textAlign: \`left\`,
});
`,
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
marginInlineStart: \`1rem\`,
textAlign: \`start\`,
});
`,
errors: [
{ messageId: 'preferLogicalProperty' },
{ messageId: 'preferLogicalValue' },
],
name: 'handles template literals',
},
{
code: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
marginLeft: '1rem',
paddingTop: '2rem',
});
`,
options: [{ allow: ['paddingTop'] }],
output: `
import { style } from '@vanilla-extract/css';
const myStyle = style({
marginInlineStart: '1rem',
paddingTop: '2rem',
});
`,
errors: [
{
messageId: 'preferLogicalProperty',
data: { physical: 'marginLeft', logical: 'marginInlineStart' },
},
],
name: 'only reports non-allowlisted properties',
},
],
});

View file

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

View file

@ -0,0 +1,252 @@
import type { Rule } from 'eslint';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
import {
isPhysicalProperty,
getLogicalProperty,
toKebabCase,
toCamelCase,
TEXT_ALIGN_PHYSICAL_VALUES,
FLOAT_PHYSICAL_VALUES,
CLEAR_PHYSICAL_VALUES,
VALUE_BASED_PHYSICAL_PROPERTIES,
} from './property-mappings.js';
export interface LogicalPropertiesOptions {
allow?: string[];
}
/**
* Get the text value from a node (string literal or simple template literal)
*/
const getValueText = (node: TSESTree.Node): string | null => {
if (node.type === AST_NODE_TYPES.Literal && typeof node.value === 'string') {
return node.value;
}
if (node.type === AST_NODE_TYPES.TemplateLiteral && node.expressions.length === 0) {
return node.quasis.map((quasi) => quasi.value.raw ?? '').join('');
}
return null;
};
/**
* Check if a node can be auto-fixed (literal or simple template literal)
*/
const canAutoFix = (node: TSESTree.Node): 'literal' | 'simple-template' | null => {
if (node.type === AST_NODE_TYPES.Literal && typeof node.value === 'string') {
return 'literal';
}
if (node.type === AST_NODE_TYPES.TemplateLiteral && node.expressions.length === 0) {
return 'simple-template';
}
return null;
};
/**
* Check if a property value contains physical directional values
*/
const hasPhysicalValue = (propertyName: string, value: string): { hasPhysical: boolean; fixedValue?: string } => {
const trimmedValue = value.trim().toLowerCase();
if (propertyName === 'text-align' || propertyName === 'textAlign') {
if (trimmedValue in TEXT_ALIGN_PHYSICAL_VALUES) {
return {
hasPhysical: true,
fixedValue: TEXT_ALIGN_PHYSICAL_VALUES[trimmedValue],
};
}
}
if (propertyName === 'float') {
if (trimmedValue in FLOAT_PHYSICAL_VALUES) {
return {
hasPhysical: true,
fixedValue: FLOAT_PHYSICAL_VALUES[trimmedValue],
};
}
}
if (propertyName === 'clear') {
if (trimmedValue in CLEAR_PHYSICAL_VALUES) {
return {
hasPhysical: true,
fixedValue: CLEAR_PHYSICAL_VALUES[trimmedValue],
};
}
}
if (propertyName === 'resize') {
if (trimmedValue === 'horizontal' || trimmedValue === 'vertical') {
const fixedValue = trimmedValue === 'horizontal' ? 'inline' : 'block';
return { hasPhysical: true, fixedValue };
}
}
return { hasPhysical: false };
};
/**
* Normalize property name to both camelCase and kebab-case for checking
*/
const normalizePropertyName = (name: string): { camel: string; kebab: string } => {
const kebab = toKebabCase(name);
const camel = toCamelCase(name);
return { camel, kebab };
};
/**
* Check if a property is in the allow list
*/
const isAllowed = (propertyName: string, allowSet: Set<string>): boolean => {
const { camel, kebab } = normalizePropertyName(propertyName);
return allowSet.has(propertyName) || allowSet.has(camel) || allowSet.has(kebab);
};
/**
* Get the appropriate logical property name based on the original format
*/
const getLogicalPropertyInFormat = (originalName: string, logicalName: string): string => {
// If original is kebab-case (contains hyphen), return kebab-case
if (originalName.includes('-')) {
return toKebabCase(logicalName);
}
// Otherwise return camelCase
return toCamelCase(logicalName);
};
/**
* Create a fix for replacing a property key
*/
const createPropertyKeyFix = (
fixer: Rule.RuleFixer,
property: TSESTree.Property,
newPropertyName: string,
context: Rule.RuleContext,
): Rule.Fix | null => {
const key = property.key;
if (key.type === AST_NODE_TYPES.Identifier) {
return fixer.replaceText(key as unknown as Rule.Node, newPropertyName);
}
if (key.type === AST_NODE_TYPES.Literal && typeof key.value === 'string') {
// Preserve quote style
const sourceCode = context.getSourceCode();
const originalText = sourceCode.getText(key as unknown as Rule.Node);
const quote = originalText[0];
return fixer.replaceText(key as unknown as Rule.Node, `${quote}${newPropertyName}${quote}`);
}
return null;
};
/**
* Create a fix for replacing a property value
*/
const createPropertyValueFix = (
fixer: Rule.RuleFixer,
valueNode: TSESTree.Node,
newValue: string,
fixType: 'literal' | 'simple-template',
): Rule.Fix => {
if (fixType === 'literal') {
return fixer.replaceText(valueNode as unknown as Rule.Node, `'${newValue}'`);
}
// simple-template
return fixer.replaceText(valueNode as unknown as Rule.Node, `\`${newValue}\``);
};
/**
* Recursively processes a vanilla-extract style object and reports physical CSS properties.
*
* - Detects physical property names and suggests logical equivalents
* - Detects physical directional values (e.g., text-align: left)
* - Skips properties in the allow list
* - Provides auto-fixes where unambiguous
* - Traverses nested objects, @media, and selectors
*
* @param context ESLint rule context
* @param node The ObjectExpression node representing the style object
* @param allowSet Set of property names to skip
*/
export const processLogicalPropertiesInStyleObject = (
context: Rule.RuleContext,
node: TSESTree.ObjectExpression,
allowSet: Set<string>,
): void => {
for (const property of node.properties) {
if (property.type !== AST_NODE_TYPES.Property) continue;
// Determine property name
let propertyName: string | null = null;
if (property.key.type === AST_NODE_TYPES.Identifier) {
propertyName = property.key.name;
} else if (property.key.type === AST_NODE_TYPES.Literal && typeof property.key.value === 'string') {
propertyName = property.key.value;
}
if (!propertyName) continue;
// Handle nested containers (@media, selectors, etc.)
if (propertyName === '@media' || propertyName === 'selectors') {
if (property.value.type === AST_NODE_TYPES.ObjectExpression) {
for (const nested of property.value.properties) {
if (nested.type === AST_NODE_TYPES.Property && nested.value.type === AST_NODE_TYPES.ObjectExpression) {
processLogicalPropertiesInStyleObject(context, nested.value, allowSet);
}
}
}
continue;
}
// Recurse into nested objects
if (property.value.type === AST_NODE_TYPES.ObjectExpression) {
processLogicalPropertiesInStyleObject(context, property.value, allowSet);
continue;
}
// Skip if property is in allow list
if (isAllowed(propertyName, allowSet)) {
continue;
}
// Check for physical property names
if (isPhysicalProperty(propertyName)) {
const logicalProp = getLogicalProperty(propertyName);
if (logicalProp) {
const logicalInFormat = getLogicalPropertyInFormat(propertyName, logicalProp);
context.report({
node: property.key as unknown as Rule.Node,
messageId: 'preferLogicalProperty',
data: {
physical: propertyName,
logical: logicalInFormat,
},
fix: (fixer) => createPropertyKeyFix(fixer, property, logicalInFormat, context),
});
}
continue;
}
// Check for value-based physical properties
if (VALUE_BASED_PHYSICAL_PROPERTIES.has(propertyName)) {
const valueText = getValueText(property.value);
if (valueText) {
const { hasPhysical, fixedValue } = hasPhysicalValue(propertyName, valueText);
if (hasPhysical && fixedValue) {
const fixType = canAutoFix(property.value);
context.report({
node: property.value as unknown as Rule.Node,
messageId: 'preferLogicalValue',
data: {
property: propertyName,
physical: valueText.trim(),
logical: fixedValue,
},
fix: fixType ? (fixer) => createPropertyValueFix(fixer, property.value, fixedValue, fixType) : undefined,
});
}
}
}
}
};

View file

@ -0,0 +1,76 @@
import type { Rule } from 'eslint';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
import { processRecipeProperties } from '../shared-utils/recipe-property-processor.js';
import { ReferenceTracker, createReferenceTrackingVisitor } from '../shared-utils/reference-tracker.js';
import { processStyleNode } from '../shared-utils/style-node-processor.js';
import { processLogicalPropertiesInStyleObject, type LogicalPropertiesOptions } from './logical-properties-processor.js';
/**
* Creates ESLint rule visitors for detecting and reporting physical CSS properties
* in vanilla-extract style objects.
*
* - Tracks calls to vanilla-extract APIs (style, recipe, keyframes, globalStyle, fontFace, etc.)
* - Detects physical property names and directional values
* - Respects the `allow` option for allowlisting properties
* - Provides auto-fixes for unambiguous conversions
*
* @param context ESLint rule context used to read options and report diagnostics
* @returns Rule listener that inspects vanilla-extract call expressions and processes style objects
*/
export const createLogicalPropertiesVisitors = (context: Rule.RuleContext): Rule.RuleListener => {
const tracker = new ReferenceTracker();
const trackingVisitor = createReferenceTrackingVisitor(tracker);
const options = (context.options?.[0] as LogicalPropertiesOptions | undefined) || {};
const allowSet = new Set((options.allow ?? []).map((prop) => prop));
const process = (context: Rule.RuleContext, object: TSESTree.ObjectExpression) =>
processLogicalPropertiesInStyleObject(context, object, allowSet);
return {
...trackingVisitor,
CallExpression(node) {
if (node.callee.type !== AST_NODE_TYPES.Identifier) return;
const functionName = node.callee.name;
if (!tracker.isTrackedFunction(functionName)) return;
const originalName = tracker.getOriginalName(functionName);
if (!originalName) return;
switch (originalName) {
case 'fontFace':
if (node.arguments.length > 0 && node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression) {
process(context, node.arguments[0] as TSESTree.ObjectExpression);
}
break;
case 'globalFontFace':
if (node.arguments.length > 1 && node.arguments[1]?.type === AST_NODE_TYPES.ObjectExpression) {
process(context, node.arguments[1] as TSESTree.ObjectExpression);
}
break;
case 'style':
case 'styleVariants':
case 'keyframes':
if (node.arguments.length > 0) {
processStyleNode(context, node.arguments[0] as TSESTree.Node, (context, object) => process(context, object));
}
break;
case 'globalStyle':
case 'globalKeyframes':
if (node.arguments.length >= 2) {
processStyleNode(context, node.arguments[1] as TSESTree.Node, (context, object) => process(context, object));
}
break;
case 'recipe':
if (node.arguments.length > 0 && node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression) {
processRecipeProperties(context, node.arguments[0] as TSESTree.ObjectExpression, (context, object) =>
process(context, object),
);
}
break;
}
},
};
};

View file

@ -0,0 +1,205 @@
/**
* Mapping of physical CSS properties to their logical equivalents.
* Includes margin, padding, border, inset, and positioning properties.
*/
export interface PropertyMapping {
logical: string;
description?: string;
}
/**
* Direct physical logical property mappings
*/
export const PHYSICAL_TO_LOGICAL: Record<string, PropertyMapping> = {
// Margin properties
'margin-left': { logical: 'margin-inline-start' },
'margin-right': { logical: 'margin-inline-end' },
'margin-top': { logical: 'margin-block-start' },
'margin-bottom': { logical: 'margin-block-end' },
marginLeft: { logical: 'marginInlineStart' },
marginRight: { logical: 'marginInlineEnd' },
marginTop: { logical: 'marginBlockStart' },
marginBottom: { logical: 'marginBlockEnd' },
// Padding properties
'padding-left': { logical: 'padding-inline-start' },
'padding-right': { logical: 'padding-inline-end' },
'padding-top': { logical: 'padding-block-start' },
'padding-bottom': { logical: 'padding-block-end' },
paddingLeft: { logical: 'paddingInlineStart' },
paddingRight: { logical: 'paddingInlineEnd' },
paddingTop: { logical: 'paddingBlockStart' },
paddingBottom: { logical: 'paddingBlockEnd' },
// Border width properties
'border-left-width': { logical: 'border-inline-start-width' },
'border-right-width': { logical: 'border-inline-end-width' },
'border-top-width': { logical: 'border-block-start-width' },
'border-bottom-width': { logical: 'border-block-end-width' },
borderLeftWidth: { logical: 'borderInlineStartWidth' },
borderRightWidth: { logical: 'borderInlineEndWidth' },
borderTopWidth: { logical: 'borderBlockStartWidth' },
borderBottomWidth: { logical: 'borderBlockEndWidth' },
// Border style properties
'border-left-style': { logical: 'border-inline-start-style' },
'border-right-style': { logical: 'border-inline-end-style' },
'border-top-style': { logical: 'border-block-start-style' },
'border-bottom-style': { logical: 'border-block-end-style' },
borderLeftStyle: { logical: 'borderInlineStartStyle' },
borderRightStyle: { logical: 'borderInlineEndStyle' },
borderTopStyle: { logical: 'borderBlockStartStyle' },
borderBottomStyle: { logical: 'borderBlockEndStyle' },
// Border color properties
'border-left-color': { logical: 'border-inline-start-color' },
'border-right-color': { logical: 'border-inline-end-color' },
'border-top-color': { logical: 'border-block-start-color' },
'border-bottom-color': { logical: 'border-block-end-color' },
borderLeftColor: { logical: 'borderInlineStartColor' },
borderRightColor: { logical: 'borderInlineEndColor' },
borderTopColor: { logical: 'borderBlockStartColor' },
borderBottomColor: { logical: 'borderBlockEndColor' },
// Border shorthand properties
'border-left': { logical: 'border-inline-start' },
'border-right': { logical: 'border-inline-end' },
'border-top': { logical: 'border-block-start' },
'border-bottom': { logical: 'border-block-end' },
borderLeft: { logical: 'borderInlineStart' },
borderRight: { logical: 'borderInlineEnd' },
borderTop: { logical: 'borderBlockStart' },
borderBottom: { logical: 'borderBlockEnd' },
// Border radius properties
'border-top-left-radius': { logical: 'border-start-start-radius' },
'border-top-right-radius': { logical: 'border-start-end-radius' },
'border-bottom-left-radius': { logical: 'border-end-start-radius' },
'border-bottom-right-radius': { logical: 'border-end-end-radius' },
borderTopLeftRadius: { logical: 'borderStartStartRadius' },
borderTopRightRadius: { logical: 'borderStartEndRadius' },
borderBottomLeftRadius: { logical: 'borderEndStartRadius' },
borderBottomRightRadius: { logical: 'borderEndEndRadius' },
// Inset properties
left: { logical: 'inset-inline-start' },
right: { logical: 'inset-inline-end' },
top: { logical: 'inset-block-start' },
bottom: { logical: 'inset-block-end' },
'inset-left': { logical: 'inset-inline-start' },
'inset-right': { logical: 'inset-inline-end' },
'inset-top': { logical: 'inset-block-start' },
'inset-bottom': { logical: 'inset-block-end' },
insetLeft: { logical: 'insetInlineStart' },
insetRight: { logical: 'insetInlineEnd' },
insetTop: { logical: 'insetBlockStart' },
insetBottom: { logical: 'insetBlockEnd' },
// Overflow properties
'overflow-x': { logical: 'overflow-inline' },
'overflow-y': { logical: 'overflow-block' },
overflowX: { logical: 'overflowInline' },
overflowY: { logical: 'overflowBlock' },
// Overscroll properties
'overscroll-behavior-x': { logical: 'overscroll-behavior-inline' },
'overscroll-behavior-y': { logical: 'overscroll-behavior-block' },
overscrollBehaviorX: { logical: 'overscrollBehaviorInline' },
overscrollBehaviorY: { logical: 'overscrollBehaviorBlock' },
// Scroll margin properties
'scroll-margin-left': { logical: 'scroll-margin-inline-start' },
'scroll-margin-right': { logical: 'scroll-margin-inline-end' },
'scroll-margin-top': { logical: 'scroll-margin-block-start' },
'scroll-margin-bottom': { logical: 'scroll-margin-block-end' },
scrollMarginLeft: { logical: 'scrollMarginInlineStart' },
scrollMarginRight: { logical: 'scrollMarginInlineEnd' },
scrollMarginTop: { logical: 'scrollMarginBlockStart' },
scrollMarginBottom: { logical: 'scrollMarginBlockEnd' },
// Scroll padding properties
'scroll-padding-left': { logical: 'scroll-padding-inline-start' },
'scroll-padding-right': { logical: 'scroll-padding-inline-end' },
'scroll-padding-top': { logical: 'scroll-padding-block-start' },
'scroll-padding-bottom': { logical: 'scroll-padding-block-end' },
scrollPaddingLeft: { logical: 'scrollPaddingInlineStart' },
scrollPaddingRight: { logical: 'scrollPaddingInlineEnd' },
scrollPaddingTop: { logical: 'scrollPaddingBlockStart' },
scrollPaddingBottom: { logical: 'scrollPaddingBlockEnd' },
// Size properties
width: { logical: 'inline-size' },
height: { logical: 'block-size' },
'min-width': { logical: 'min-inline-size' },
'min-height': { logical: 'min-block-size' },
'max-width': { logical: 'max-inline-size' },
'max-height': { logical: 'max-block-size' },
minWidth: { logical: 'minInlineSize' },
minHeight: { logical: 'minBlockSize' },
maxWidth: { logical: 'maxInlineSize' },
maxHeight: { logical: 'maxBlockSize' },
};
/**
* Text-align directional values that should be replaced
*/
export const TEXT_ALIGN_PHYSICAL_VALUES: Record<string, string> = {
left: 'start',
right: 'end',
};
/**
* Float directional values that should be replaced
*/
export const FLOAT_PHYSICAL_VALUES: Record<string, string> = {
left: 'inline-start',
right: 'inline-end',
};
/**
* Clear directional values that should be replaced
*/
export const CLEAR_PHYSICAL_VALUES: Record<string, string> = {
left: 'inline-start',
right: 'inline-end',
};
/**
* Properties where the value (not the property name) needs to be checked for physical directions
*/
export const VALUE_BASED_PHYSICAL_PROPERTIES = new Set([
'text-align',
'textAlign',
'float',
'clear',
'resize',
]);
/**
* Check if a property name is a physical property that should be converted
*/
export function isPhysicalProperty(propertyName: string): boolean {
return propertyName in PHYSICAL_TO_LOGICAL;
}
/**
* Get the logical equivalent of a physical property
*/
export function getLogicalProperty(propertyName: string): string | null {
return PHYSICAL_TO_LOGICAL[propertyName]?.logical ?? null;
}
/**
* Convert camelCase to kebab-case
*/
export function toKebabCase(name: string): string {
return name.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
}
/**
* Convert kebab-case to camelCase
*/
export function toCamelCase(name: string): string {
return name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
}

View file

@ -0,0 +1,40 @@
import type { Rule } from 'eslint';
import { createLogicalPropertiesVisitors } from './logical-properties-visitor-creator.js';
const preferLogicalPropertiesRule: Rule.RuleModule = {
meta: {
type: 'suggestion',
docs: {
description: 'enforce logical CSS properties over physical directional properties in vanilla-extract',
category: 'Best Practices',
recommended: false,
},
fixable: 'code',
schema: [
{
type: 'object',
properties: {
allow: {
type: 'array',
items: { type: 'string' },
uniqueItems: true,
default: [],
description: 'List of physical properties to allow (supports both camelCase and kebab-case)',
},
},
additionalProperties: false,
},
],
messages: {
preferLogicalProperty:
'Prefer logical CSS property "{{ logical }}" over physical property "{{ physical }}". Logical properties adapt to writing direction.',
preferLogicalValue:
'Prefer logical value "{{ logical }}" over physical value "{{ physical }}" for property "{{ property }}". Logical values adapt to writing direction.',
},
},
create(context) {
return createLogicalPropertiesVisitors(context);
},
};
export default preferLogicalPropertiesRule;

View file

@ -6,11 +6,12 @@ import noPxUnitRule from './css-rules/no-px-unit/index.js';
import noTrailingZeroRule from './css-rules/no-trailing-zero/rule-definition.js';
import noUnknownUnitRule from './css-rules/no-unknown-unit/rule-definition.js';
import noZeroUnitRule from './css-rules/no-zero-unit/rule-definition.js';
import preferLogicalPropertiesRule from './css-rules/prefer-logical-properties/index.js';
const vanillaExtract = {
meta: {
name: '@antebudimir/eslint-plugin-vanilla-extract',
version: '1.13.0',
version: '1.14.0',
},
rules: {
'alphabetical-order': alphabeticalOrderRule,
@ -21,6 +22,7 @@ const vanillaExtract = {
'no-trailing-zero': noTrailingZeroRule,
'no-unknown-unit': noUnknownUnitRule,
'no-zero-unit': noZeroUnitRule,
'prefer-logical-properties': preferLogicalPropertiesRule,
},
configs: {},
};