From b0aeeaf33c0f0db1027050d1c91e578aa256ebbc Mon Sep 17 00:00:00 2001 From: Ahmed Abdelbaset Date: Sun, 4 Aug 2024 06:32:34 +0300 Subject: [PATCH] Complete rewrite to the plugin --- .changeset/sweet-gifts-behave.md | 5 + src/configs/tw-logical-properties.ts | 85 -------------- src/index.ts | 2 +- src/rules/no-phyisical-properties/rule.ts | 99 ++++++++++++++++ src/rules/no-phyisical-properties/test.ts | 4 +- src/rules/no-physical-properties.ts | 131 ---------------------- src/utils/ast.ts | 71 ++++++++++++ src/utils/tailwind.ts | 66 +++++++++++ 8 files changed, 243 insertions(+), 220 deletions(-) create mode 100644 .changeset/sweet-gifts-behave.md delete mode 100644 src/configs/tw-logical-properties.ts create mode 100644 src/rules/no-phyisical-properties/rule.ts delete mode 100644 src/rules/no-physical-properties.ts create mode 100644 src/utils/ast.ts create mode 100644 src/utils/tailwind.ts diff --git a/.changeset/sweet-gifts-behave.md b/.changeset/sweet-gifts-behave.md new file mode 100644 index 0000000..71829fb --- /dev/null +++ b/.changeset/sweet-gifts-behave.md @@ -0,0 +1,5 @@ +--- +"eslint-plugin-rtl-friendly": patch +--- + +Cache result and read from cache for better performance diff --git a/src/configs/tw-logical-properties.ts b/src/configs/tw-logical-properties.ts deleted file mode 100644 index 33e4e42..0000000 --- a/src/configs/tw-logical-properties.ts +++ /dev/null @@ -1,85 +0,0 @@ -export const logicalProperties: { - physical: string; - logical: string; -}[] = [ - { - physical: "ml-", - logical: "ms-", - }, - { - physical: "mr-", - logical: "me-", - }, - { - physical: "pl-", - logical: "ps-", - }, - { - physical: "pr-", - logical: "pe-", - }, - { - physical: "left-", - logical: "start-", - }, - { - physical: "right-", - logical: "end-", - }, - { - physical: "text-left", - logical: "text-start", - }, - { - physical: "text-right", - logical: "text-end", - }, - { - physical: "border-l-", - logical: "border-s-", - }, - { - physical: "border-r-", - logical: "border-e-", - }, - { - physical: "rounded-l-", - logical: "rounded-s-", - }, - { - physical: "rounded-r-", - logical: "rounded-e-", - }, - { - physical: "rounded-tl-", - logical: "rounded-ss-", - }, - { - physical: "rounded-tr-", - logical: "rounded-se-", - }, - { - physical: "rounded-bl-", - logical: "rounded-es-", - }, - { - physical: "rounded-br-", - logical: "rounded-ee-", - }, - { - physical: "scroll-ml-", - logical: "scroll-ms-", - }, - { - physical: "scroll-mr-", - logical: "scroll-me-", - }, - { - physical: "scroll-pl-", - logical: "scroll-ps-", - }, - { - physical: "scroll-pr-", - logical: "scroll-pe-", - }, -]; diff --git a/src/index.ts b/src/index.ts index 7889685..5cb3894 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import type { ESLint, Linter } from "eslint"; -import noPhysicalProperties from "./rules/no-physical-properties.js"; import pkg from "../package.json"; +import { noPhysicalProperties } from "./rules/no-phyisical-properties/rule"; const rtlFriendly = { meta: { diff --git a/src/rules/no-phyisical-properties/rule.ts b/src/rules/no-phyisical-properties/rule.ts new file mode 100644 index 0000000..f6a3a41 --- /dev/null +++ b/src/rules/no-phyisical-properties/rule.ts @@ -0,0 +1,99 @@ +import { Rule } from "eslint"; + +import * as ESTree from "estree"; +import type { JSXAttribute } from "estree-jsx"; +import { extractFromNode } from "../../utils/ast.js"; +import { parseForPhysicalClasses } from "../../utils/tailwind.js"; + +const cache = new Map(); + +export const NO_PHYSICAL_CLASSESS = "NO_PHYSICAL_CLASSESS"; + +export const noPhysicalProperties: Rule.RuleModule = { + meta: { + type: "suggestion", + docs: { + description: "Encourage the use of RTL-friendly styles", + recommended: true, + }, + fixable: "code", + messages: { + [NO_PHYSICAL_CLASSESS]: `Avoid using physical properties such as "{{ invalid }}". Instead, use logical properties like "{{ valid }}" for better RTL support.`, + }, + schema: [], + }, + create(ctx) { + return { + JSXAttribute: (estreeNode: ESTree.Node) => { + const node = estreeNode as JSXAttribute; + + if (node.name.type !== "JSXIdentifier") return; + const attr = node.name.name; + + const isClassAttribute = ["className", "class"].includes(attr); + if (!isClassAttribute) return; + + let result = extractFromNode(node); + if (!result) return; + + result = result.filter((c) => typeof c === "string"); + if (!result.length) return; + + const classesAsString = result.join(" "); + const cachedValid = cache.get(classesAsString); + if (cachedValid) { + report({ ctx, node, invalid: classesAsString, valid: cachedValid }); + return; + } + + const classes = classesAsString.split(" "); + + const parsed = parseForPhysicalClasses(classes); + + const isInvalid = parsed.some((p) => p.isInvalid); + if (!isInvalid) return; + + const invalid = parsed.map((p) => p.original).join(" "); + const valid = parsed.map((p) => p.valid).join(" "); + + cache.set(classesAsString, valid); + report({ ctx, node, invalid, valid }); + }, + }; + }, +}; + +function report({ + ctx, + invalid, + valid, + node, +}: { + ctx: Rule.RuleContext; + node: JSXAttribute; + invalid: string; + valid: string; +}) { + return ctx.report({ + node, + messageId: "NO_PHYSICAL_CLASSESS", + data: { + invalid, + valid, + }, + loc: { + start: node.loc!.start, + end: node.loc!.end, + }, + fix: (fixer) => { + if (node.value?.type === "Literal") { + return fixer.replaceText( + node.value, + node.value.raw?.replace(invalid, valid) ?? "" + ); + } + + return null; + }, + }); +} diff --git a/src/rules/no-phyisical-properties/test.ts b/src/rules/no-phyisical-properties/test.ts index d8d0a10..fdb4043 100644 --- a/src/rules/no-phyisical-properties/test.ts +++ b/src/rules/no-phyisical-properties/test.ts @@ -1,7 +1,5 @@ import { RuleTester } from "eslint"; -import noPhysicalProperties, { - NO_PHYSICAL_CLASSESS, -} from "./../no-physical-properties"; +import { noPhysicalProperties, NO_PHYSICAL_CLASSESS } from "./rule"; const tester = new RuleTester({ languageOptions: { diff --git a/src/rules/no-physical-properties.ts b/src/rules/no-physical-properties.ts deleted file mode 100644 index 4c731ef..0000000 --- a/src/rules/no-physical-properties.ts +++ /dev/null @@ -1,131 +0,0 @@ -/* eslint-disable */ -import { Rule } from "eslint"; -import { logicalProperties } from "../configs/tw-logical-properties.js"; - -import * as ESTree from "estree"; -import type { JSXAttribute } from "estree-jsx"; - -const regexes = (physical: string) => [ - new RegExp(`^${physical}.*`), - new RegExp(`^!${physical}.*`), - new RegExp(`^-${physical}.*`), - new RegExp(`^.+:${physical}.*`), - new RegExp(`^.+:-${physical}.*`), - new RegExp(`^.+:!${physical}.*`), - new RegExp(`^.+:!-${physical}.*`), -]; - -export const NO_PHYSICAL_CLASSESS = "NO_PHYSICAL_CLASSESS"; - -const noPhysicalProperties: Rule.RuleModule = { - meta: { - type: "suggestion", - docs: { - description: "Encourage the use of RTL-friendly styles", - recommended: true, - }, - fixable: "code", - messages: { - [NO_PHYSICAL_CLASSESS]: `Avoid using physical properties such as "{{ invalid }}". Instead, use logical properties like "{{ valid }}" for better RTL support.`, - }, - schema: [], - }, - create(ctx): Rule.RuleListener { - return { - JSXAttribute: (_node: ESTree.Node) => { - const node = _node as JSXAttribute; - - if (node.name.type !== "JSXIdentifier") return; - const attr = node.name.name; - - const isClassAttribute = ["className", "class"].includes(attr); - if (!isClassAttribute) return; - - const valueType = node.value?.type; - let value = "" as any; - if (valueType === "Literal") value = node.value?.value; - else if (valueType === "JSXExpressionContainer") { - const expression = node.value?.expression; - if (expression?.type === "Literal") { - value = expression.value; - } else if (expression?.type === "TemplateLiteral") { - value = expression.quasis[0].value.raw; - } else if (expression?.type === "CallExpression") { - // TODO: Handle functions - // const callee = expression.callee; - // if (callee?.type === "Identifier" && callee.name === "cn") { - // const args = expression.arguments; - // if (args.length === 1) { - // const arg = args[0]; - // if (arg.type === "Literal") v1 = arg.value as string; - // } - // } - } - } - if (typeof value !== "string") return; - - const cnArr = value.split(" "); - - // PH = Physical, LG = Logical - const PH_CNs = logicalProperties.map((c) => c.physical); - - const conflictClassNames = cnArr.filter((cn) => - PH_CNs.some((c) => { - let isValid = false; - regexes(c).forEach((regex) => { - if (regex.test(cn)) isValid = true; - }); - return isValid; - }) - ); - - if (!conflictClassNames.length) return; - - ctx.report({ - node: _node, - messageId: NO_PHYSICAL_CLASSESS, - data: { - invalid: conflictClassNames.join(" "), - valid: conflictClassNames - .map((cn) => { - const prop = logicalProperties.find((c) => { - let isValid = false; - regexes(c.physical).forEach((regex) => { - if (regex.test(cn)) isValid = true; - }); - return isValid; - }); - if (!prop) return cn; - return cn.replace(prop.physical, prop.logical); - }) - .join(" "), - }, - fix: (fixer) => { - const fixedClassName = cnArr - .map((cn) => { - if (conflictClassNames.includes(cn)) { - const prop = logicalProperties.find((c) => { - let isValid = false; - regexes(c.physical).forEach((regex) => { - if (regex.test(cn)) isValid = true; - }); - return isValid; - }); - if (!prop) return cn; - return cn.replace(prop.physical, prop.logical); - } - return cn; - }) - .join(" "); - - return fixer.replaceText(_node, `${attr}="${fixedClassName}"`); - }, - }); - - return; - }, - }; - }, -}; - -export default noPhysicalProperties; diff --git a/src/utils/ast.ts b/src/utils/ast.ts new file mode 100644 index 0000000..2eb9f57 --- /dev/null +++ b/src/utils/ast.ts @@ -0,0 +1,71 @@ +import type { Expression, JSXAttribute } from "estree-jsx"; + +type Val = string | number | bigint | boolean | RegExp | null | undefined; + +export function extractFromNode(node: JSXAttribute) { + // value: Literal | JSXExpressionContainer | JSXElement | JSXFragment | null + const valueType = node.value?.type; + if (!valueType) return; + + const result: Val[] = []; + + // 1. Literal className="..." + if (valueType === "Literal") result.push(node.value?.value); + // 2. JSXExpressionContainer className={...} + else if (valueType === "JSXExpressionContainer") { + const expression = node.value?.expression; + + if (expression?.type === "JSXEmptyExpression" || !expression) return; + + result.push(...extractFromExpression(expression)); + } + + // Exit if JSXElement | JSXFragment | null + if ( + valueType === "JSXElement" || + valueType === "JSXFragment" || + !node.value + ) { + return; + } + + return result; +} + +function extractFromExpression(expression: Expression) { + // We care about: + // -> Literal; + // -> TemplateLiteral; + // -> BinaryExpression + // -> CallExpression; + // -> ConditionalExpression; + // -> LogicalExpression; + + const result: Val[] = []; + + if (expression.type === "Literal") result.push(expression.value); + if (expression.type === "TemplateLiteral") + result.push(expression.quasis[0].value.raw); + if (expression.type === "BinaryExpression") { + result.push(...extractFromExpression(expression.left)); + result.push(...extractFromExpression(expression.right)); + } + if (expression.type === "CallExpression") { + expression.arguments.forEach((arg) => { + if (arg.type === "SpreadElement") { + result.push(...extractFromExpression(arg.argument)); + } else { + result.push(...extractFromExpression(arg)); + } + }); + } + if (expression.type === "ConditionalExpression") { + result.push(...extractFromExpression(expression.consequent)); + result.push(...extractFromExpression(expression.alternate)); + } + if (expression.type === "LogicalExpression") { + result.push(...extractFromExpression(expression.right)); + } + + return result; +} diff --git a/src/utils/tailwind.ts b/src/utils/tailwind.ts new file mode 100644 index 0000000..d3d9b4b --- /dev/null +++ b/src/utils/tailwind.ts @@ -0,0 +1,66 @@ +export const twLogicalClasses = [ + { physical: "ml-", /* */ logical: "ms-" }, + { physical: "mr-", /* */ logical: "me-" }, + { physical: "pl-", /* */ logical: "ps-" }, + { physical: "pr-", /* */ logical: "pe-" }, + { physical: "left-", /* */ logical: "start-" }, + { physical: "right-", /* */ logical: "end-" }, + { physical: "text-left", /* */ logical: "text-start" }, + { physical: "text-right", /* */ logical: "text-end" }, + { physical: "border-l-", /* */ logical: "border-s-" }, + { physical: "border-r-", /* */ logical: "border-e-" }, + { physical: "rounded-l-", /* */ logical: "rounded-s-" }, + { physical: "rounded-r-", /* */ logical: "rounded-e-" }, + { physical: "rounded-tl-", /**/ logical: "rounded-ss-" }, + { physical: "rounded-tr-", /**/ logical: "rounded-se-" }, + { physical: "rounded-bl-", /**/ logical: "rounded-es-" }, + { physical: "rounded-br-", /**/ logical: "rounded-ee-" }, + { physical: "scroll-ml-", /* */ logical: "scroll-ms-" }, + { physical: "scroll-mr-", /* */ logical: "scroll-me-" }, + { physical: "scroll-pl-", /* */ logical: "scroll-ps-" }, + { physical: "scroll-pr-", /* */ logical: "scroll-pe-" }, +] satisfies { physical: string; logical: string }[]; + +export function tailwindClassCases(cls: string) { + return [ + new RegExp(`^${cls}.*`), + new RegExp(`^!${cls}.*`), + new RegExp(`^-${cls}.*`), + new RegExp(`^.+:${cls}.*`), + new RegExp(`^.+:-${cls}.*`), + new RegExp(`^.+:!${cls}.*`), + new RegExp(`^.+:!-${cls}.*`), + ]; +} + +const allCases = twLogicalClasses.flatMap(({ physical, logical }) => + tailwindClassCases(physical).map((regex) => ({ regex, physical, logical })) +); + +export function parseForPhysicalClasses(classes: string[]) { + return classes.map((cls) => { + const isInvalid = allCases.some(({ regex }) => regex.test(cls)); + const valid = allCases.reduce( + (acc, { physical, logical }) => acc.replace(physical, logical), + cls + ); + + return { + isInvalid, + original: cls, + valid: isInvalid ? valid : cls, + }; + }); + + // return allCases.map(({ physical, logical, regex }) => { + // const isInvalid = regex.test(cls); + // return { + // isInvalid, + // invalid: cls, + // valid: isInvalid ? cls.replace(physical, logical) : cls, + // physical, + // logical, + // }; + // }); + // }); +}