mirror of
https://github.com/antebudimir/eslint-plugin-vanilla-extract.git
synced 2025-12-31 17:03:32 +00:00
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:
parent
69dd109311
commit
5b0bcf17c7
10 changed files with 1201 additions and 3 deletions
10
CHANGELOG.md
10
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
|
||||
|
|
|
|||
39
README.md
39
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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
});
|
||||
3
src/css-rules/prefer-logical-properties/index.ts
Normal file
3
src/css-rules/prefer-logical-properties/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import preferLogicalPropertiesRule from './rule-definition.js';
|
||||
|
||||
export default preferLogicalPropertiesRule;
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
205
src/css-rules/prefer-logical-properties/property-mappings.ts
Normal file
205
src/css-rules/prefer-logical-properties/property-mappings.ts
Normal 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());
|
||||
}
|
||||
40
src/css-rules/prefer-logical-properties/rule-definition.ts
Normal file
40
src/css-rules/prefer-logical-properties/rule-definition.ts
Normal 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;
|
||||
|
|
@ -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: {},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue