From 2a440ce617cc6754563bc8c41ae16dfd445a0b95 Mon Sep 17 00:00:00 2001 From: "Azat S." Date: Mon, 3 Mar 2025 21:24:12 +0300 Subject: [PATCH] feat: add prefer-logical-properties rule (#63) * feat: add prefer-logical-properties rule * perf: move to maps in prefer-logical-properties rule * feat: add allowes properties and units option in prefer-logical-properties * refactor: rename options in prefer-logical-properties rule * docs: add options in prefer-logical-properties * refactor: define fixable value as const * docs: improve prefer-logical-property docs * fix: prevent testing in supports declaration --- README.md | 17 +- docs/rules/prefer-logical-properties.md | 43 ++++ src/index.js | 2 + src/rules/prefer-logical-properties.js | 237 ++++++++++++++++++ tests/rules/prefer-logical-properties.test.js | 112 +++++++++ 5 files changed, 403 insertions(+), 8 deletions(-) create mode 100644 docs/rules/prefer-logical-properties.md create mode 100644 src/rules/prefer-logical-properties.js create mode 100644 tests/rules/prefer-logical-properties.test.js diff --git a/README.md b/README.md index 540d0c6..ee76d8d 100644 --- a/README.md +++ b/README.md @@ -56,14 +56,15 @@ export default [ -| **Rule Name** | **Description** | **Recommended** | -| :--------------------------------------------------------------- | :----------------------------------- | :-------------: | -| [`no-duplicate-imports`](./docs/rules/no-duplicate-imports.md) | Disallow duplicate @import rules | yes | -| [`no-empty-blocks`](./docs/rules/no-empty-blocks.md) | Disallow empty blocks | yes | -| [`no-invalid-at-rules`](./docs/rules/no-invalid-at-rules.md) | Disallow invalid at-rules | yes | -| [`no-invalid-properties`](./docs/rules/no-invalid-properties.md) | Disallow invalid properties | yes | -| [`require-baseline`](./docs/rules/require-baseline.md) | Enforce the use of baseline features | yes | -| [`use-layers`](./docs/rules/use-layers.md) | Require use of layers | no | +| **Rule Name** | **Description** | **Recommended** | +| :------------------------------------------------------------------------- | :------------------------------------ | :-------------: | +| [`no-duplicate-imports`](./docs/rules/no-duplicate-imports.md) | Disallow duplicate @import rules | yes | +| [`no-empty-blocks`](./docs/rules/no-empty-blocks.md) | Disallow empty blocks | yes | +| [`no-invalid-at-rules`](./docs/rules/no-invalid-at-rules.md) | Disallow invalid at-rules | yes | +| [`no-invalid-properties`](./docs/rules/no-invalid-properties.md) | Disallow invalid properties | yes | +| [`prefer-logical-properties`](./docs//rules//prefer-logical-properties.md) | Enforce the use of logical properties | no | +| [`require-baseline`](./docs/rules/require-baseline.md) | Enforce the use of baseline features | yes | +| [`use-layers`](./docs/rules/use-layers.md) | Require use of layers | no | diff --git a/docs/rules/prefer-logical-properties.md b/docs/rules/prefer-logical-properties.md new file mode 100644 index 0000000..a36d9b7 --- /dev/null +++ b/docs/rules/prefer-logical-properties.md @@ -0,0 +1,43 @@ +# prefer-logical-properties + +Prefer logical properties over physical properties. + +## Background + +Logical properties are a set of CSS properties that map to their physical counterparts. They are designed to make it easier to create styles that work in both left-to-right and right-to-left languages. Logical properties are useful for creating styles that are more flexible and easier to maintain. + +## Rule Details + +This rule checks for the use of physical properties and suggests using their logical counterparts instead. + +Examples of **incorrect** code for this rule: + +```css +/* incorrect use of physical properties */ +a { + margin-left: 10px; +} +``` + +Examples of **correct** code for this rule: + +```css +a { + margin-inline-start: 10px; +} +``` + +### Options + +This rule accepts an option object with the following properties: + +- `allowProperties` (default: `[]`) - Specify an array of physical properties that are allowed to be used. +- `allowUnits` (default: `[]`) - Specify an array of physical units that are allowed to be used. + +## When Not to Use It + +If you aren't concerned with the use of logical properties, then you can safely disable this rule. + +## Prior Art + +- [stylelint-use-logical](https://github.com/csstools/stylelint-use-logical) diff --git a/src/index.js b/src/index.js index 98ed84d..7c9b686 100644 --- a/src/index.js +++ b/src/index.js @@ -13,6 +13,7 @@ import noEmptyBlocks from "./rules/no-empty-blocks.js"; import noDuplicateImports from "./rules/no-duplicate-imports.js"; import noInvalidProperties from "./rules/no-invalid-properties.js"; import noInvalidAtRules from "./rules/no-invalid-at-rules.js"; +import preferLogicalProperties from "./rules/prefer-logical-properties.js"; import useLayers from "./rules/use-layers.js"; import requireBaseline from "./rules/require-baseline.js"; @@ -33,6 +34,7 @@ const plugin = { "no-duplicate-imports": noDuplicateImports, "no-invalid-at-rules": noInvalidAtRules, "no-invalid-properties": noInvalidProperties, + "prefer-logical-properties": preferLogicalProperties, "use-layers": useLayers, "require-baseline": requireBaseline, }, diff --git a/src/rules/prefer-logical-properties.js b/src/rules/prefer-logical-properties.js new file mode 100644 index 0000000..e8bce54 --- /dev/null +++ b/src/rules/prefer-logical-properties.js @@ -0,0 +1,237 @@ +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +const propertiesReplacements = new Map([ + ["bottom", "inset-block-end"], + ["border-bottom", "border-block-end"], + ["border-bottom-color", "border-block-end-color"], + ["border-bottom-left-radius", "border-end-start-radius"], + ["border-bottom-right-radius", "border-end-end-radius"], + ["border-bottom-style", "border-block-end-style"], + ["border-bottom-width", "border-block-end-width"], + ["border-left", "border-inline-start"], + ["border-left-color", "border-inline-start-color"], + ["border-left-style", "border-inline-start-style"], + ["border-left-width", "border-inline-start-width"], + ["border-right", "border-inline-end"], + ["border-right-color", "border-inline-end-color"], + ["border-right-style", "border-inline-end-style"], + ["border-right-width", "border-inline-end-width"], + ["border-top", "border-block-start"], + ["border-top-color", "border-block-start-color"], + ["border-top-left-radius", "border-start-start-radius"], + ["border-top-right-radius", "border-start-end-radius"], + ["border-top-style", "border-block-start-style"], + ["border-top-width", "border-block-start-width"], + ["contain-intrinsic-height", "contain-intrinsic-block-size"], + ["contain-intrinsic-width", "contain-intrinsic-inline-size"], + ["height", "block-size"], + ["left", "inset-inline-start"], + ["margin-bottom", "margin-block-end"], + ["margin-left", "margin-inline-start"], + ["margin-right", "margin-inline-end"], + ["margin-top", "margin-block-start"], + ["max-height", "max-block-size"], + ["max-width", "max-inline-size"], + ["min-height", "min-block-size"], + ["min-width", "min-inline-size"], + ["overflow-x", "overflow-inline"], + ["overflow-y", "overflow-block"], + ["overscroll-behavior-x", "overscroll-behavior-inline"], + ["overscroll-behavior-y", "overscroll-behavior-block"], + ["padding-bottom", "padding-block-end"], + ["padding-left", "padding-inline-start"], + ["padding-right", "padding-inline-end"], + ["padding-top", "padding-block-start"], + ["right", "inset-inline-end"], + ["scroll-margin-bottom", "scroll-margin-block-end"], + ["scroll-margin-left", "scroll-margin-inline-start"], + ["scroll-margin-right", "scroll-margin-inline-end"], + ["scroll-margin-top", "scroll-margin-block-start"], + ["scroll-padding-bottom", "scroll-padding-block-end"], + ["scroll-padding-left", "scroll-padding-inline-start"], + ["scroll-padding-right", "scroll-padding-inline-end"], + ["scroll-padding-top", "scroll-padding-block-start"], + ["top", "inset-block-start"], + ["width", "inline-size"], +]); + +const propertyValuesReplacements = new Map([ + [ + "text-align", + { + left: "start", + right: "end", + }, + ], + [ + "resize", + { + horizontal: "inline", + vertical: "block", + }, + ], + [ + "caption-side", + { + left: "inline-start", + right: "inline-end", + }, + ], + [ + "box-orient", + { + horizontal: "inline-axis", + vertical: "block-axis", + }, + ], + [ + "float", + { + left: "inline-start", + right: "inline-end", + }, + ], + [ + "clear", + { + left: "inline-start", + right: "inline-end", + }, + ], +]); + +const unitReplacements = new Map([ + ["cqh", "cqb"], + ["cqw", "cqi"], + ["dvh", "dvb"], + ["dvw", "dvi"], + ["lvh", "lvb"], + ["lvw", "lvi"], + ["svh", "svb"], + ["svw", "svi"], + ["vh", "vb"], + ["vw", "vi"], +]); + +//----------------------------------------------------------------------------- +// Rule Definition +//----------------------------------------------------------------------------- +export default { + meta: { + type: /** @type {const} */ ("problem"), + + fixable: /** @type {const} */ ("code"), + + docs: { + description: "Enforce the use of logical properties", + url: "https://github.com/eslint/css/blob/main/docs/rules/prefer-logical-properties.md", + }, + + schema: [ + { + type: "object", + properties: { + allowProperties: { + type: "array", + items: { + type: "string", + }, + }, + allowUnits: { + type: "array", + items: { + type: "string", + }, + }, + }, + additionalProperties: false, + }, + ], + + defaultOptions: [ + { + allowProperties: [], + allowUnits: [], + }, + ], + + messages: { + notLogicalProperty: + "Expected logical property '{{replacement}}' instead of '{{property}}'.", + notLogicalValue: + "Expected logical value '{{replacement}}' instead of '{{value}}'.", + notLogicalUnit: + "Expected logical unit '{{replacement}}' instead of '{{unit}}'.", + }, + }, + + create(context) { + return { + Declaration(node) { + const parent = context.sourceCode.getParent(node); + if (parent.type === "SupportsDeclaration") { + return; + } + + const allowProperties = context.options[0].allowProperties; + if ( + propertiesReplacements.get(node.property) && + !allowProperties.includes(node.property) + ) { + context.report({ + loc: node.loc, + messageId: "notLogicalProperty", + data: { + property: node.property, + replacement: propertiesReplacements.get( + node.property, + ), + }, + }); + } + + if ( + propertyValuesReplacements.get(node.property) && + node.value.children[0].type === "Identifier" + ) { + const nodeValue = node.value.children[0].name; + if ( + propertyValuesReplacements.get(node.property)[nodeValue] + ) { + const replacement = propertyValuesReplacements.get( + node.property, + )[nodeValue]; + if (replacement) { + context.report({ + loc: node.value.children[0].loc, + messageId: "notLogicalValue", + data: { + value: nodeValue, + replacement, + }, + }); + } + } + } + }, + Dimension(node) { + const allowUnits = context.options[0].allowUnits; + if ( + unitReplacements.get(node.unit) && + !allowUnits.includes(node.unit) + ) { + context.report({ + loc: node.loc, + messageId: "notLogicalUnit", + data: { + unit: node.unit, + replacement: unitReplacements.get(node.unit), + }, + }); + } + }, + }; + }, +}; diff --git a/tests/rules/prefer-logical-properties.test.js b/tests/rules/prefer-logical-properties.test.js new file mode 100644 index 0000000..f5fb5cf --- /dev/null +++ b/tests/rules/prefer-logical-properties.test.js @@ -0,0 +1,112 @@ +//------------------------------------------------------------------------------ +// Imports +//------------------------------------------------------------------------------ + +import rule from "../../src/rules/prefer-logical-properties.js"; +import css from "../../src/index.js"; +import { RuleTester } from "eslint"; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + plugins: { + css, + }, + language: "css/css", +}); + +ruleTester.run("prefer-logical-properties", rule, { + valid: [ + "a { margin-block: 10px; }", + "a { padding-inline: 20px; }", + "a { margin: 10px; }", + "a { padding: 20px; }", + "a { text-align: start }", + "@supports (text-align: left) {}", + "@supports (padding-left: 10px) {}", + { + code: "a { padding-left: 20px; }", + options: [ + { + allowProperties: ["padding-left"], + }, + ], + }, + { + code: "a { inline-size: 100vw; }", + options: [ + { + allowUnits: ["vw"], + }, + ], + }, + ], + invalid: [ + { + code: "a { margin-top: 10px; }", + errors: [ + { + messageId: "notLogicalProperty", + line: 1, + column: 5, + endLine: 1, + endColumn: 21, + data: { + property: "margin-top", + replacement: "margin-block-start", + }, + }, + ], + }, + { + code: "a { padding-top: 20px; }", + errors: [ + { + messageId: "notLogicalProperty", + line: 1, + column: 5, + endLine: 1, + endColumn: 22, + data: { + property: "padding-top", + replacement: "padding-block-start", + }, + }, + ], + }, + { + code: "a { text-align: left }", + errors: [ + { + messageId: "notLogicalValue", + line: 1, + column: 17, + endLine: 1, + endColumn: 21, + data: { + value: "left", + replacement: "start", + }, + }, + ], + }, + { + code: "a { block-size: 100vh }", + errors: [ + { + messageId: "notLogicalUnit", + line: 1, + column: 17, + endLine: 1, + endColumn: 22, + data: { + unit: "vh", + replacement: "vb", + }, + }, + ], + }, + ], +});