From b330c8092a75bb4a2821f7ce73422cb7e99b064a Mon Sep 17 00:00:00 2001 From: Ahmed Abdelbaset Date: Sun, 4 Aug 2024 01:24:57 +0300 Subject: [PATCH 1/9] Complete rewrite to the plugin --- .changeset/sweet-gifts-behave.md | 5 + src/index.ts | 2 +- src/rules/no-phyisical-properties/rule.ts | 100 +++++++++++++++ src/rules/no-physical-properties.ts | 129 -------------------- src/utils/ast.ts | 142 ++++++++++++++++++++++ src/utils/tailwind.ts | 66 ++++++++++ 6 files changed, 314 insertions(+), 130 deletions(-) create mode 100644 .changeset/sweet-gifts-behave.md 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/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..1c1154b --- /dev/null +++ b/src/rules/no-phyisical-properties/rule.ts @@ -0,0 +1,100 @@ +import { type AST, Rule } from "eslint"; + +import fs from "fs"; +import * as ESTree from "estree"; +import type { JSXAttribute } from "estree-jsx"; +import { extractFromNode, foo } 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-physical-properties.ts b/src/rules/no-physical-properties.ts deleted file mode 100644 index 2ba538b..0000000 --- a/src/rules/no-physical-properties.ts +++ /dev/null @@ -1,129 +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}.*`), -]; - -const noPhysicalProperties: Rule.RuleModule = { - meta: { - type: "suggestion", - docs: { - description: "Encourage the use of RTL-friendly styles", - recommended: true, - }, - fixable: "code", - messages: { - noPhysicalProperties: `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: "noPhysicalProperties", - 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..6e5297d --- /dev/null +++ b/src/utils/ast.ts @@ -0,0 +1,142 @@ +import type { AST } from "eslint"; +import type { Expression, JSXAttribute, Node } from "estree-jsx"; + +type Val = string | number | bigint | boolean | RegExp | null | undefined; + +export function foo(node: JSXAttribute) { + // value: Literal | JSXExpressionContainer | JSXElement | JSXFragment | null + const valueType = node.value?.type; + if (!valueType) return; + + // let result: Val[] = []; + + // 1. Literal className="..." + if (valueType === "Literal") return node.value; + // 2. JSXExpressionContainer className={...} + else if (valueType === "JSXExpressionContainer") { + const expression = node.value?.expression; + + if (expression?.type === "JSXEmptyExpression" || !expression) return; + + return fooExp(expression); + } + + // Exit if JSXElement | JSXFragment | null + if ( + valueType === "JSXElement" || + valueType === "JSXFragment" || + !node.value + ) { + return; + } +} + +interface Token { + type: string; + value: string | boolean | number | null; + range?: [number, number]; + loc?: AST.SourceLocation | null; +} + +function fooExp(expression: Expression) { + // We care about: + // -> Literal; + // -> TemplateLiteral; + // -> BinaryExpression + // -> CallExpression; + // -> ConditionalExpression; + // -> LogicalExpression; + + if (expression.type === "Literal") { + if ("regex" in expression || "bigint" in expression) return null; + if (typeof expression.value !== "string") return null; + return expression; + } + if (expression.type === "TemplateLiteral") { + return { + ...expression.quasis[0], + value: expression.quasis[0].value.raw, + }; + } + if (expression.type === "BinaryExpression") { + // return [fooExp(expression.left), fooExp(expression.right)] + } + if (expression.type === "CallExpression") { + expression.arguments.forEach((arg) => { + if (arg.type === "SpreadElement") return fooExp(arg.argument); + return fooExp(arg); + }); + } + if (expression.type === "ConditionalExpression") { + // return [fooExp(expression.consequent), fooExp(expression.alternate)]; + } + if (expression.type === "LogicalExpression") return fooExp(expression.right); +} + +export function extractFromNode(node: JSXAttribute) { + // value: Literal | JSXExpressionContainer | JSXElement | JSXFragment | null + const valueType = node.value?.type; + if (!valueType) return; + + let 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, + // }; + // }); + // }); +} From 33c4261b48b1c33309142d257a0f9b84cbc70c6e Mon Sep 17 00:00:00 2001 From: Ahmed Abdelbaset Date: Sun, 4 Aug 2024 01:24:57 +0300 Subject: [PATCH 2/9] Complete rewrite to the plugin --- .changeset/sweet-gifts-behave.md | 5 + src/index.ts | 2 +- src/rules/no-phyisical-properties/rule.ts | 100 +++++++++++++++ src/utils/ast.ts | 142 ++++++++++++++++++++++ src/utils/tailwind.ts | 66 ++++++++++ 5 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 .changeset/sweet-gifts-behave.md create mode 100644 src/rules/no-phyisical-properties/rule.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/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..1c1154b --- /dev/null +++ b/src/rules/no-phyisical-properties/rule.ts @@ -0,0 +1,100 @@ +import { type AST, Rule } from "eslint"; + +import fs from "fs"; +import * as ESTree from "estree"; +import type { JSXAttribute } from "estree-jsx"; +import { extractFromNode, foo } 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/utils/ast.ts b/src/utils/ast.ts new file mode 100644 index 0000000..6e5297d --- /dev/null +++ b/src/utils/ast.ts @@ -0,0 +1,142 @@ +import type { AST } from "eslint"; +import type { Expression, JSXAttribute, Node } from "estree-jsx"; + +type Val = string | number | bigint | boolean | RegExp | null | undefined; + +export function foo(node: JSXAttribute) { + // value: Literal | JSXExpressionContainer | JSXElement | JSXFragment | null + const valueType = node.value?.type; + if (!valueType) return; + + // let result: Val[] = []; + + // 1. Literal className="..." + if (valueType === "Literal") return node.value; + // 2. JSXExpressionContainer className={...} + else if (valueType === "JSXExpressionContainer") { + const expression = node.value?.expression; + + if (expression?.type === "JSXEmptyExpression" || !expression) return; + + return fooExp(expression); + } + + // Exit if JSXElement | JSXFragment | null + if ( + valueType === "JSXElement" || + valueType === "JSXFragment" || + !node.value + ) { + return; + } +} + +interface Token { + type: string; + value: string | boolean | number | null; + range?: [number, number]; + loc?: AST.SourceLocation | null; +} + +function fooExp(expression: Expression) { + // We care about: + // -> Literal; + // -> TemplateLiteral; + // -> BinaryExpression + // -> CallExpression; + // -> ConditionalExpression; + // -> LogicalExpression; + + if (expression.type === "Literal") { + if ("regex" in expression || "bigint" in expression) return null; + if (typeof expression.value !== "string") return null; + return expression; + } + if (expression.type === "TemplateLiteral") { + return { + ...expression.quasis[0], + value: expression.quasis[0].value.raw, + }; + } + if (expression.type === "BinaryExpression") { + // return [fooExp(expression.left), fooExp(expression.right)] + } + if (expression.type === "CallExpression") { + expression.arguments.forEach((arg) => { + if (arg.type === "SpreadElement") return fooExp(arg.argument); + return fooExp(arg); + }); + } + if (expression.type === "ConditionalExpression") { + // return [fooExp(expression.consequent), fooExp(expression.alternate)]; + } + if (expression.type === "LogicalExpression") return fooExp(expression.right); +} + +export function extractFromNode(node: JSXAttribute) { + // value: Literal | JSXExpressionContainer | JSXElement | JSXFragment | null + const valueType = node.value?.type; + if (!valueType) return; + + let 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, + // }; + // }); + // }); +} From ca6a83165ebf2826bd006e9ea95527b96333ac2d Mon Sep 17 00:00:00 2001 From: Ahmed Abdelbaset Date: Sun, 4 Aug 2024 01:24:57 +0300 Subject: [PATCH 3/9] Complete rewrite to the plugin --- .changeset/sweet-gifts-behave.md | 5 + src/index.ts | 2 +- src/rules/no-phyisical-properties/rule.ts | 100 +++++++++++++++ src/utils/ast.ts | 142 ++++++++++++++++++++++ src/utils/tailwind.ts | 66 ++++++++++ 5 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 .changeset/sweet-gifts-behave.md create mode 100644 src/rules/no-phyisical-properties/rule.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/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..1c1154b --- /dev/null +++ b/src/rules/no-phyisical-properties/rule.ts @@ -0,0 +1,100 @@ +import { type AST, Rule } from "eslint"; + +import fs from "fs"; +import * as ESTree from "estree"; +import type { JSXAttribute } from "estree-jsx"; +import { extractFromNode, foo } 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/utils/ast.ts b/src/utils/ast.ts new file mode 100644 index 0000000..6e5297d --- /dev/null +++ b/src/utils/ast.ts @@ -0,0 +1,142 @@ +import type { AST } from "eslint"; +import type { Expression, JSXAttribute, Node } from "estree-jsx"; + +type Val = string | number | bigint | boolean | RegExp | null | undefined; + +export function foo(node: JSXAttribute) { + // value: Literal | JSXExpressionContainer | JSXElement | JSXFragment | null + const valueType = node.value?.type; + if (!valueType) return; + + // let result: Val[] = []; + + // 1. Literal className="..." + if (valueType === "Literal") return node.value; + // 2. JSXExpressionContainer className={...} + else if (valueType === "JSXExpressionContainer") { + const expression = node.value?.expression; + + if (expression?.type === "JSXEmptyExpression" || !expression) return; + + return fooExp(expression); + } + + // Exit if JSXElement | JSXFragment | null + if ( + valueType === "JSXElement" || + valueType === "JSXFragment" || + !node.value + ) { + return; + } +} + +interface Token { + type: string; + value: string | boolean | number | null; + range?: [number, number]; + loc?: AST.SourceLocation | null; +} + +function fooExp(expression: Expression) { + // We care about: + // -> Literal; + // -> TemplateLiteral; + // -> BinaryExpression + // -> CallExpression; + // -> ConditionalExpression; + // -> LogicalExpression; + + if (expression.type === "Literal") { + if ("regex" in expression || "bigint" in expression) return null; + if (typeof expression.value !== "string") return null; + return expression; + } + if (expression.type === "TemplateLiteral") { + return { + ...expression.quasis[0], + value: expression.quasis[0].value.raw, + }; + } + if (expression.type === "BinaryExpression") { + // return [fooExp(expression.left), fooExp(expression.right)] + } + if (expression.type === "CallExpression") { + expression.arguments.forEach((arg) => { + if (arg.type === "SpreadElement") return fooExp(arg.argument); + return fooExp(arg); + }); + } + if (expression.type === "ConditionalExpression") { + // return [fooExp(expression.consequent), fooExp(expression.alternate)]; + } + if (expression.type === "LogicalExpression") return fooExp(expression.right); +} + +export function extractFromNode(node: JSXAttribute) { + // value: Literal | JSXExpressionContainer | JSXElement | JSXFragment | null + const valueType = node.value?.type; + if (!valueType) return; + + let 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, + // }; + // }); + // }); +} From 589d767c77b3dd1721e3a57e395a84914ed5e317 Mon Sep 17 00:00:00 2001 From: Ahmed Abdelbaset Date: Sun, 4 Aug 2024 01:24:12 +0300 Subject: [PATCH 4/9] Move tests and docs to the rule folder --- src/configs/tw-logical-properties.ts | 85 ----------------------- src/rules/no-phyisical-properties/test.ts | 4 +- 2 files changed, 1 insertion(+), 88 deletions(-) delete mode 100644 src/configs/tw-logical-properties.ts 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/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: { From 4c76e9238ccf2c3c0877e64dd0f9f8cb5f502204 Mon Sep 17 00:00:00 2001 From: Ahmed Abdelbaset Date: Sun, 4 Aug 2024 01:24:57 +0300 Subject: [PATCH 5/9] Complete rewrite to the plugin --- src/rules/no-physical-properties.ts | 131 ---------------------------- 1 file changed, 131 deletions(-) delete mode 100644 src/rules/no-physical-properties.ts 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; From e2147ce239f502b1105b446049414784c4f0208d Mon Sep 17 00:00:00 2001 From: Ahmed Abdelbaset Date: Sun, 4 Aug 2024 02:01:58 +0300 Subject: [PATCH 6/9] fix lint --- src/rules/no-phyisical-properties/rule.ts | 5 ++--- src/utils/ast.ts | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/rules/no-phyisical-properties/rule.ts b/src/rules/no-phyisical-properties/rule.ts index 1c1154b..f6a3a41 100644 --- a/src/rules/no-phyisical-properties/rule.ts +++ b/src/rules/no-phyisical-properties/rule.ts @@ -1,9 +1,8 @@ -import { type AST, Rule } from "eslint"; +import { Rule } from "eslint"; -import fs from "fs"; import * as ESTree from "estree"; import type { JSXAttribute } from "estree-jsx"; -import { extractFromNode, foo } from "../../utils/ast.js"; +import { extractFromNode } from "../../utils/ast.js"; import { parseForPhysicalClasses } from "../../utils/tailwind.js"; const cache = new Map(); diff --git a/src/utils/ast.ts b/src/utils/ast.ts index 6e5297d..14553b0 100644 --- a/src/utils/ast.ts +++ b/src/utils/ast.ts @@ -1,5 +1,5 @@ import type { AST } from "eslint"; -import type { Expression, JSXAttribute, Node } from "estree-jsx"; +import type { Expression, JSXAttribute } from "estree-jsx"; type Val = string | number | bigint | boolean | RegExp | null | undefined; From 2f0d0f85d0011077b4422452fc137e28a2bd7fee Mon Sep 17 00:00:00 2001 From: Ahmed Abdelbaset Date: Sun, 4 Aug 2024 02:11:49 +0300 Subject: [PATCH 7/9] fix release ci --- .github/workflows/release.yml | 3 +-- .npmrc | 1 - release.config.js | 17 ----------------- 3 files changed, 1 insertion(+), 20 deletions(-) delete mode 100644 .npmrc delete mode 100644 release.config.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d1f36a6..426da07 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,14 +29,13 @@ jobs: git config --global user.name "Ahmed Abdelbaset" git add . - - name: Create Release Pull Request or Publish to npm id: changesets uses: changesets/action@v1 with: commit: "release: 📦 version packages" title: "release: 📦 version packages" - publish: npm publish ./packages/eslint-config --access public + publish: npm publish --access public setupGitUser: false createGithubReleases: false env: diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 1b9cb70..0000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -# package-lock = false diff --git a/release.config.js b/release.config.js deleted file mode 100644 index 1ef76d2..0000000 --- a/release.config.js +++ /dev/null @@ -1,17 +0,0 @@ -/** @type {import('semantic-release').Config} */ -module.exports = { - branches: ["main"], - plugins: [ - "@semantic-release/commit-analyzer", - "@semantic-release/release-notes-generator", - [ - "@semantic-release/git", - { - assets: ["dist/*.js", "dist/*.js.map"], - message: - "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}", - }, - ], - "@semantic-release/github", - ], -}; From e613ef95733563d426ff36b5007a31a2a457214a Mon Sep 17 00:00:00 2001 From: Ahmed Abdelbaset Date: Sun, 4 Aug 2024 02:18:46 +0300 Subject: [PATCH 8/9] cleanup changlog --- CHANGELOG.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70bbd00..84e0ee4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,5 @@ # Changelog -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). - -## [Unreleased] - -- Support `className={"..."}` and backticks in curly braces {`...`} - ## [0.2.0] - 2023-09-08 ### Fixed From ee66573b8790cc551eda9af360ab37f24d51b645 Mon Sep 17 00:00:00 2001 From: Ahmed Abdelbaset Date: Sun, 4 Aug 2024 02:21:45 +0300 Subject: [PATCH 9/9] rm unused code --- src/utils/ast.ts | 73 +----------------------------------------------- 1 file changed, 1 insertion(+), 72 deletions(-) diff --git a/src/utils/ast.ts b/src/utils/ast.ts index 14553b0..2eb9f57 100644 --- a/src/utils/ast.ts +++ b/src/utils/ast.ts @@ -1,84 +1,13 @@ -import type { AST } from "eslint"; import type { Expression, JSXAttribute } from "estree-jsx"; type Val = string | number | bigint | boolean | RegExp | null | undefined; -export function foo(node: JSXAttribute) { - // value: Literal | JSXExpressionContainer | JSXElement | JSXFragment | null - const valueType = node.value?.type; - if (!valueType) return; - - // let result: Val[] = []; - - // 1. Literal className="..." - if (valueType === "Literal") return node.value; - // 2. JSXExpressionContainer className={...} - else if (valueType === "JSXExpressionContainer") { - const expression = node.value?.expression; - - if (expression?.type === "JSXEmptyExpression" || !expression) return; - - return fooExp(expression); - } - - // Exit if JSXElement | JSXFragment | null - if ( - valueType === "JSXElement" || - valueType === "JSXFragment" || - !node.value - ) { - return; - } -} - -interface Token { - type: string; - value: string | boolean | number | null; - range?: [number, number]; - loc?: AST.SourceLocation | null; -} - -function fooExp(expression: Expression) { - // We care about: - // -> Literal; - // -> TemplateLiteral; - // -> BinaryExpression - // -> CallExpression; - // -> ConditionalExpression; - // -> LogicalExpression; - - if (expression.type === "Literal") { - if ("regex" in expression || "bigint" in expression) return null; - if (typeof expression.value !== "string") return null; - return expression; - } - if (expression.type === "TemplateLiteral") { - return { - ...expression.quasis[0], - value: expression.quasis[0].value.raw, - }; - } - if (expression.type === "BinaryExpression") { - // return [fooExp(expression.left), fooExp(expression.right)] - } - if (expression.type === "CallExpression") { - expression.arguments.forEach((arg) => { - if (arg.type === "SpreadElement") return fooExp(arg.argument); - return fooExp(arg); - }); - } - if (expression.type === "ConditionalExpression") { - // return [fooExp(expression.consequent), fooExp(expression.alternate)]; - } - if (expression.type === "LogicalExpression") return fooExp(expression.right); -} - export function extractFromNode(node: JSXAttribute) { // value: Literal | JSXExpressionContainer | JSXElement | JSXFragment | null const valueType = node.value?.type; if (!valueType) return; - let result: Val[] = []; + const result: Val[] = []; // 1. Literal className="..." if (valueType === "Literal") result.push(node.value?.value);