From d5eae5dfc80d6f9b1f9a54112c6a4aea861ecd97 Mon Sep 17 00:00:00 2001 From: Ante Budimir Date: Sun, 9 Nov 2025 20:53:47 +0200 Subject: [PATCH] =?UTF-8?q?feat=20=F0=9F=A5=81:=20add=20prefer-logical-pro?= =?UTF-8?q?perties=20rule=20for=20i18n-friendly=20styles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 10 + README.md | 45 +- package.json | 2 +- .../_tests_/prefer-logical-properties.test.ts | 573 ++++++++++++++++++ .../prefer-logical-properties/index.ts | 3 + .../logical-properties-processor.ts | 252 ++++++++ .../logical-properties-visitor-creator.ts | 76 +++ .../property-mappings.ts | 205 +++++++ .../rule-definition.ts | 40 ++ src/index.ts | 4 +- 10 files changed, 1204 insertions(+), 6 deletions(-) create mode 100644 src/css-rules/prefer-logical-properties/_tests_/prefer-logical-properties.test.ts create mode 100644 src/css-rules/prefer-logical-properties/index.ts create mode 100644 src/css-rules/prefer-logical-properties/logical-properties-processor.ts create mode 100644 src/css-rules/prefer-logical-properties/logical-properties-visitor-creator.ts create mode 100644 src/css-rules/prefer-logical-properties/property-mappings.ts create mode 100644 src/css-rules/prefer-logical-properties/rule-definition.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6befdd2..31e50ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 1037bba..a8b7469 100644 --- a/README.md +++ b/README.md @@ -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 @@ -687,16 +725,15 @@ The roadmap outlines the project's current status and future plans: [issue #3](https://github.com/antebudimir/eslint-plugin-vanilla-extract/issues/3)) - Comprehensive rule testing. - `no-px-unit` rule to disallow use of `px` units with configurable whitelist. +- `prefer-logical-properties` rule to enforce use of logical properties. ### Current Work -- TBA +- `prefer-theme-tokens` rule to enforce use of theme tokens instead of hard-coded values when available. ### 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. +- `no-unitless-values` rule that disallows numeric literals for CSS properties that are not unitless in CSS. - `property-unit-match` rule to enforce valid units per CSS property specs. **Note**: This feature will only be implemented if there's sufficient interest from the community. - Option to sort properties within user-defined concentric groups alphabetically instead of following the concentric diff --git a/package.json b/package.json index 12c46ab..91e2d4e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/css-rules/prefer-logical-properties/_tests_/prefer-logical-properties.test.ts b/src/css-rules/prefer-logical-properties/_tests_/prefer-logical-properties.test.ts new file mode 100644 index 0000000..db5b025 --- /dev/null +++ b/src/css-rules/prefer-logical-properties/_tests_/prefer-logical-properties.test.ts @@ -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', + }, + ], +}); diff --git a/src/css-rules/prefer-logical-properties/index.ts b/src/css-rules/prefer-logical-properties/index.ts new file mode 100644 index 0000000..4e3fb2c --- /dev/null +++ b/src/css-rules/prefer-logical-properties/index.ts @@ -0,0 +1,3 @@ +import preferLogicalPropertiesRule from './rule-definition.js'; + +export default preferLogicalPropertiesRule; diff --git a/src/css-rules/prefer-logical-properties/logical-properties-processor.ts b/src/css-rules/prefer-logical-properties/logical-properties-processor.ts new file mode 100644 index 0000000..2b782b6 --- /dev/null +++ b/src/css-rules/prefer-logical-properties/logical-properties-processor.ts @@ -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): 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, +): 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, + }); + } + } + } + } +}; diff --git a/src/css-rules/prefer-logical-properties/logical-properties-visitor-creator.ts b/src/css-rules/prefer-logical-properties/logical-properties-visitor-creator.ts new file mode 100644 index 0000000..8c901f4 --- /dev/null +++ b/src/css-rules/prefer-logical-properties/logical-properties-visitor-creator.ts @@ -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; + } + }, + }; +}; diff --git a/src/css-rules/prefer-logical-properties/property-mappings.ts b/src/css-rules/prefer-logical-properties/property-mappings.ts new file mode 100644 index 0000000..c609cc1 --- /dev/null +++ b/src/css-rules/prefer-logical-properties/property-mappings.ts @@ -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 = { + // 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 = { + left: 'start', + right: 'end', +}; + +/** + * Float directional values that should be replaced + */ +export const FLOAT_PHYSICAL_VALUES: Record = { + left: 'inline-start', + right: 'inline-end', +}; + +/** + * Clear directional values that should be replaced + */ +export const CLEAR_PHYSICAL_VALUES: Record = { + 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()); +} diff --git a/src/css-rules/prefer-logical-properties/rule-definition.ts b/src/css-rules/prefer-logical-properties/rule-definition.ts new file mode 100644 index 0000000..c43dded --- /dev/null +++ b/src/css-rules/prefer-logical-properties/rule-definition.ts @@ -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; diff --git a/src/index.ts b/src/index.ts index bc9b89c..8b9ae24 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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: {}, };