diff --git a/.changeset/young-ties-relate.md b/.changeset/young-ties-relate.md new file mode 100644 index 0000000..12c55e1 --- /dev/null +++ b/.changeset/young-ties-relate.md @@ -0,0 +1,7 @@ +--- +"eslint-plugin-rtl-friendly": minor +--- + +Add option `allowPhysicalInsetWithAbsolute` to allow the use of `left-1/2` with `fixed -translate-x-1/2` + +Add option `debug` diff --git a/eslint.config.js b/eslint.config.js index 91e69cd..0324ffe 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -4,7 +4,7 @@ import js from "@eslint/js"; import eslintPlugin from "eslint-plugin-eslint-plugin"; import { config, configs } from "typescript-eslint"; -import rtlFriendly from "./dist/index.js"; +import rtlFriendly, { ruleSettings } from "./dist/index.js"; export default config( { @@ -25,5 +25,11 @@ export default config( { files: ["**/*.{tsx,jsx}"], ...rtlFriendly.configs.recommended, + rules: { + "rtl-friendly/no-physical-properties": [ + "warn", + ruleSettings({ allowPhysicalInsetWithAbsolute: true }), + ], + }, } ); diff --git a/package.json b/package.json index efad5a3..5d2840b 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "eslint-plugin-eslint-plugin": "^6.2.0", "tailwindcss": "~3.3.3", "tsup": "^8.2.4", - "typescript": "^5.5.4", + "typescript": "^5.6.2", "typescript-eslint": "^8.0.0", "vitest": "^2.0.5" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 756a3a1..e34c86a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,10 +25,10 @@ importers: version: 20.16.2 '@typescript-eslint/rule-tester': specifier: ^8.0.0 - version: 8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4) + version: 8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.6.2) '@typescript-eslint/utils': specifier: ^8.0.0 - version: 8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4) + version: 8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.6.2) '@vitest/coverage-v8': specifier: ^2.0.5 version: 2.0.5(vitest@2.0.5(@types/node@20.16.2)) @@ -43,13 +43,13 @@ importers: version: 3.3.7 tsup: specifier: ^8.2.4 - version: 8.2.4(jiti@1.21.6)(postcss@8.4.42)(typescript@5.5.4)(yaml@2.5.0) + version: 8.2.4(jiti@1.21.6)(postcss@8.4.42)(typescript@5.6.2)(yaml@2.5.0) typescript: - specifier: ^5.5.4 - version: 5.5.4 + specifier: ^5.6.2 + version: 5.6.2 typescript-eslint: specifier: ^8.0.0 - version: 8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4) + version: 8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.6.2) vitest: specifier: ^2.0.5 version: 2.0.5(@types/node@20.16.2) @@ -1859,8 +1859,8 @@ packages: typescript: optional: true - typescript@5.5.4: - resolution: {integrity: sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==} + typescript@5.6.2: + resolution: {integrity: sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==} engines: {node: '>=14.17'} hasBin: true @@ -2524,41 +2524,41 @@ snapshots: '@types/semver@7.5.8': {} - '@typescript-eslint/eslint-plugin@8.3.0(@typescript-eslint/parser@8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4))(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4)': + '@typescript-eslint/eslint-plugin@8.3.0(@typescript-eslint/parser@8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.6.2))(eslint@9.9.1(jiti@1.21.6))(typescript@5.6.2)': dependencies: '@eslint-community/regexpp': 4.11.0 - '@typescript-eslint/parser': 8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4) + '@typescript-eslint/parser': 8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.6.2) '@typescript-eslint/scope-manager': 8.3.0 - '@typescript-eslint/type-utils': 8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4) - '@typescript-eslint/utils': 8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4) + '@typescript-eslint/type-utils': 8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.6.2) + '@typescript-eslint/utils': 8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.6.2) '@typescript-eslint/visitor-keys': 8.3.0 eslint: 9.9.1(jiti@1.21.6) graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 - ts-api-utils: 1.3.0(typescript@5.5.4) + ts-api-utils: 1.3.0(typescript@5.6.2) optionalDependencies: - typescript: 5.5.4 + typescript: 5.6.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4)': + '@typescript-eslint/parser@8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.6.2)': dependencies: '@typescript-eslint/scope-manager': 8.3.0 '@typescript-eslint/types': 8.3.0 - '@typescript-eslint/typescript-estree': 8.3.0(typescript@5.5.4) + '@typescript-eslint/typescript-estree': 8.3.0(typescript@5.6.2) '@typescript-eslint/visitor-keys': 8.3.0 debug: 4.3.6 eslint: 9.9.1(jiti@1.21.6) optionalDependencies: - typescript: 5.5.4 + typescript: 5.6.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/rule-tester@8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4)': + '@typescript-eslint/rule-tester@8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.6.2)': dependencies: - '@typescript-eslint/typescript-estree': 8.3.0(typescript@5.5.4) - '@typescript-eslint/utils': 8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4) + '@typescript-eslint/typescript-estree': 8.3.0(typescript@5.6.2) + '@typescript-eslint/utils': 8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.6.2) ajv: 6.12.6 eslint: 9.9.1(jiti@1.21.6) json-stable-stringify-without-jsonify: 1.0.1 @@ -2573,21 +2573,21 @@ snapshots: '@typescript-eslint/types': 8.3.0 '@typescript-eslint/visitor-keys': 8.3.0 - '@typescript-eslint/type-utils@8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4)': + '@typescript-eslint/type-utils@8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.6.2)': dependencies: - '@typescript-eslint/typescript-estree': 8.3.0(typescript@5.5.4) - '@typescript-eslint/utils': 8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4) + '@typescript-eslint/typescript-estree': 8.3.0(typescript@5.6.2) + '@typescript-eslint/utils': 8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.6.2) debug: 4.3.6 - ts-api-utils: 1.3.0(typescript@5.5.4) + ts-api-utils: 1.3.0(typescript@5.6.2) optionalDependencies: - typescript: 5.5.4 + typescript: 5.6.2 transitivePeerDependencies: - eslint - supports-color '@typescript-eslint/types@8.3.0': {} - '@typescript-eslint/typescript-estree@8.3.0(typescript@5.5.4)': + '@typescript-eslint/typescript-estree@8.3.0(typescript@5.6.2)': dependencies: '@typescript-eslint/types': 8.3.0 '@typescript-eslint/visitor-keys': 8.3.0 @@ -2596,18 +2596,18 @@ snapshots: is-glob: 4.0.3 minimatch: 9.0.5 semver: 7.6.3 - ts-api-utils: 1.3.0(typescript@5.5.4) + ts-api-utils: 1.3.0(typescript@5.6.2) optionalDependencies: - typescript: 5.5.4 + typescript: 5.6.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4)': + '@typescript-eslint/utils@8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.6.2)': dependencies: '@eslint-community/eslint-utils': 4.4.0(eslint@9.9.1(jiti@1.21.6)) '@typescript-eslint/scope-manager': 8.3.0 '@typescript-eslint/types': 8.3.0 - '@typescript-eslint/typescript-estree': 8.3.0(typescript@5.5.4) + '@typescript-eslint/typescript-estree': 8.3.0(typescript@5.6.2) eslint: 9.9.1(jiti@1.21.6) transitivePeerDependencies: - supports-color @@ -3718,13 +3718,13 @@ snapshots: tree-kill@1.2.2: {} - ts-api-utils@1.3.0(typescript@5.5.4): + ts-api-utils@1.3.0(typescript@5.6.2): dependencies: - typescript: 5.5.4 + typescript: 5.6.2 ts-interface-checker@0.1.13: {} - tsup@8.2.4(jiti@1.21.6)(postcss@8.4.42)(typescript@5.5.4)(yaml@2.5.0): + tsup@8.2.4(jiti@1.21.6)(postcss@8.4.42)(typescript@5.6.2)(yaml@2.5.0): dependencies: bundle-require: 5.0.0(esbuild@0.23.1) cac: 6.7.14 @@ -3744,7 +3744,7 @@ snapshots: tree-kill: 1.2.2 optionalDependencies: postcss: 8.4.42 - typescript: 5.5.4 + typescript: 5.6.2 transitivePeerDependencies: - jiti - supports-color @@ -3755,18 +3755,18 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4): + typescript-eslint@8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.6.2): dependencies: - '@typescript-eslint/eslint-plugin': 8.3.0(@typescript-eslint/parser@8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4))(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4) - '@typescript-eslint/parser': 8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4) - '@typescript-eslint/utils': 8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4) + '@typescript-eslint/eslint-plugin': 8.3.0(@typescript-eslint/parser@8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.6.2))(eslint@9.9.1(jiti@1.21.6))(typescript@5.6.2) + '@typescript-eslint/parser': 8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.6.2) + '@typescript-eslint/utils': 8.3.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.6.2) optionalDependencies: - typescript: 5.5.4 + typescript: 5.6.2 transitivePeerDependencies: - eslint - supports-color - typescript@5.5.4: {} + typescript@5.6.2: {} undici-types@6.19.8: {} diff --git a/src/configs/recommended.test.ts b/src/configs/recommended.test.ts new file mode 100644 index 0000000..5f30530 --- /dev/null +++ b/src/configs/recommended.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { recommended } from "./recommended.js"; + +describe("recommended", () => { + it("should export recommended", () => { + expect(recommended).toMatchInlineSnapshot(` + { + "rules": { + "rtl-friendly/no-physical-properties": "warn", + }, + } + `); + }); +}); diff --git a/src/flags.ts b/src/flags.ts new file mode 100644 index 0000000..bf10cb5 --- /dev/null +++ b/src/flags.ts @@ -0,0 +1,7 @@ +/** + * Act as default options for rules + */ +export const FLAGS = { + allowPhysicalInsetWithAbsolute: false, + debug: false, +}; diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..eac4a1b --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, expectTypeOf } from "vitest"; +import rtlFriendly, { ruleSettings } from "./index.js"; +import { name, version } from "../package.json"; +import type { Rule } from "./rules/no-phyisical-properties/rule.js"; + +// useless tests generated by copilot :) + +describe("rtlFriendly", () => { + it("should have correct meta information", () => { + expect(rtlFriendly.meta.name).toBe(name); + expect(rtlFriendly.meta.version).toBe(version); + }); + + it("should have the no-physical-properties rule", () => { + expect(rtlFriendly.rules).toHaveProperty("no-physical-properties"); + expectTypeOf( + rtlFriendly.rules["no-physical-properties"] + ).toMatchTypeOf(); + }); + + describe("configs", () => { + it("should have a recommended config", () => { + expect(rtlFriendly.configs).toHaveProperty("recommended"); + }); + + it("recommended config should have the correct rules", () => { + expect(rtlFriendly.configs.recommended.rules).toHaveProperty( + "rtl-friendly/no-physical-properties", + "warn" + ); + }); + + it("recommended config should include the rtlFriendly plugin", () => { + expect(rtlFriendly.configs.recommended.plugins).toHaveProperty( + "rtl-friendly", + rtlFriendly + ); + }); + + it("recommended config should have jsx enabled in parser options", () => { + expect( + rtlFriendly.configs.recommended.languageOptions?.parserOptions + ?.ecmaFeatures?.jsx + ).toBe(true); + }); + }); +}); + +describe("ruleSettings", () => { + it("should return the options", () => { + expect(ruleSettings({ allowPhysicalInsetWithAbsolute: true })).toEqual({ + allowPhysicalInsetWithAbsolute: true, + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index ba230e9..d20623a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,9 @@ import type { FlatConfig } from "@typescript-eslint/utils/ts-eslint"; import { name, version } from "../package.json"; -import { noPhysicalProperties } from "./rules/no-phyisical-properties/rule.js"; +import { + noPhysicalProperties, + ruleSettings, +} from "./rules/no-phyisical-properties/rule.js"; const rtlFriendly = { meta: { name, version }, @@ -32,3 +35,5 @@ const configs = { Object.assign(rtlFriendly.configs, configs); export default rtlFriendly; + +export { ruleSettings, rtlFriendly }; diff --git a/src/rules/no-phyisical-properties/ast.test.ts b/src/rules/no-phyisical-properties/ast.test.ts new file mode 100644 index 0000000..781a9d9 --- /dev/null +++ b/src/rules/no-phyisical-properties/ast.test.ts @@ -0,0 +1,20 @@ +// the ast.ts exports are tested in `test.ts` but this file is only to 100% coverage + +import { describe, expect, it } from "vitest"; +import { extractTokensFromNode } from "./ast.js"; +import type { Context } from "./rule.js"; +import type { AST_NODE_TYPES, TSESTree } from "@typescript-eslint/utils"; + +describe("ast", () => { + it("non JSXAttribute", () => { + expect( + extractTokensFromNode( + { + type: "VariableDeclaration" as AST_NODE_TYPES.VariableDeclaration, + } as TSESTree.Node, + {} as Context, + "fixer" + ) + ).toEqual([]); + }); +}); diff --git a/src/utils/ast.ts b/src/rules/no-phyisical-properties/ast.ts similarity index 85% rename from src/utils/ast.ts rename to src/rules/no-phyisical-properties/ast.ts index a1d18cf..35bde31 100644 --- a/src/utils/ast.ts +++ b/src/rules/no-phyisical-properties/ast.ts @@ -5,7 +5,7 @@ import { NO_PHYSICAL_CLASSESS, type Context, type MessageId, -} from "../rules/no-phyisical-properties/rule"; +} from "./rule.js"; const unimplemented = new Set(); @@ -23,10 +23,8 @@ export function extractTokensFromNode( // node: TSESTree.JSXAttribute, node: TSESTree.Node, ctx: Context, - runner: "checker" | "fixer" + { debug }: { debug: boolean } ): Token[] { - const run = (exp: Exp) => extractTokensFromExpression(exp, ctx, runner); - if (node.type === "JSXAttribute") { // value: Literal | JSXExpressionContainer | JSXElement | JSXFragment | null const value = node.value; @@ -42,20 +40,21 @@ export function extractTokensFromNode( if (!expression || expression?.type === "JSXEmptyExpression") return []; - return extractTokensFromExpression(expression, ctx, runner); + return extractTokensFromExpression(expression, ctx, { debug }); } - if (value.type === "JSXElement" || value.type === "JSXSpreadChild") { - return []; - } + // if (value.type === "JSXElement" || value.type === "JSXSpreadChild") { + // return []; + // } - return []; + // return []; } - if (is(node, "VariableDeclarator")) { - if (!node.init) return []; - return run(node.init); - } + // Handled somewhere else > find the call of `getDefinitions` + // if (is(node, "VariableDeclarator")) { + // if (!node.init) return []; + // return run(node.init); + // } // if (is(node, "ArrowFunctionExpression")) return run(node); @@ -67,12 +66,15 @@ type Exp = TSESTree.Expression | TSESTree.TemplateElement; function extractTokensFromExpression( exp: Exp, ctx: Context, - runner: "checker" | "fixer", - { isIdentifier = false }: { isIdentifier?: boolean } = {} + { + isIdentifier = false, + debug, + }: { isIdentifier?: boolean; debug?: boolean } = {} ): Token[] { const rerun = (expression: Exp, referenceIsIdentifier?: boolean) => { - return extractTokensFromExpression(expression, ctx, runner, { + return extractTokensFromExpression(expression, ctx, { isIdentifier: referenceIsIdentifier || isIdentifier, + debug, }); }; @@ -170,12 +172,14 @@ function extractTokensFromExpression( const writes = getDefinitions(exp, ctx, scope).filter( (r) => r?.type === "Literal" || r?.type === "Identifier" + // || r?.type === "ObjectExpression" || + // r?.type === "AssignmentExpression" ); - return writes.flatMap((n) => rerun(n, true)); } if (is(exp, "MemberExpression")) { + // Unsupported return []; } @@ -225,7 +229,14 @@ function extractTokensFromExpression( // } if (!unimplemented.has(exp.type)) { - console.log("Unimplemented: ", exp.type, exp); + if (debug) { + console.log( + "rtl-friendly plugin detected that you are writing your writing your tailwind classes in a way that is not yet supported by this plugin.\n" + + "Kindly open an issue on GitHub so we can add support for this case. Thanks!\n" + + `https://github.com/AhmedBaset/eslint-plugin-rtl-friendly/issues/new?title=Unimplemented+Node%3A+%60${exp.type}%60\n`, + "You can disable this warning by setteng the `debug` option to `false` the rule options." + ); + } unimplemented.add(exp.type); } diff --git a/src/rules/no-phyisical-properties/rule.ts b/src/rules/no-phyisical-properties/rule.ts index 41c97b1..cb5ce03 100644 --- a/src/rules/no-phyisical-properties/rule.ts +++ b/src/rules/no-phyisical-properties/rule.ts @@ -3,8 +3,9 @@ import type { RuleContext, RuleModule, } from "@typescript-eslint/utils/ts-eslint"; -import { type Token, extractTokensFromNode } from "../../utils/ast.js"; -import { parseForPhysicalClasses } from "../../utils/tailwind.js"; +import { type Token, extractTokensFromNode } from "./ast.js"; +import { parseForPhysicalClasses } from "./tailwind.js"; +import { FLAGS } from "../../flags.js"; // const cache = new Map< // /** invalid */ string, @@ -15,15 +16,20 @@ import { parseForPhysicalClasses } from "../../utils/tailwind.js"; // return `https://github.com/AhmedBaset/eslint-plugin-rtl-friendly/blob/main/src/rules/${ruleName}/README.md`; // }); +// Since the rule is no longer for physical properties specifically, +// we consider renaming it e.g. `rtl-friendly/tailwind` export const RULE_NAME = "no-physical-properties"; export const NO_PHYSICAL_CLASSESS = "NO_PHYSICAL_CLASSESS"; export const IDENTIFIER_USED = "IDENTIFIER_USED"; export type MessageId = "NO_PHYSICAL_CLASSESS" | "IDENTIFIER_USED"; +export type Rule = RuleModule< + MessageId, + [{ allowPhysicalInsetWithAbsolute?: boolean; debug?: boolean }] +>; -export const noPhysicalProperties: RuleModule = { +export const noPhysicalProperties: Rule = { // name: RULE_NAME, - defaultOptions: [], meta: { type: "suggestion", docs: { @@ -35,8 +41,24 @@ export const noPhysicalProperties: RuleModule = { [NO_PHYSICAL_CLASSESS]: `Avoid using physical properties such as "{{ invalid }}". Instead, use logical properties like "{{ valid }}" for better RTL support.`, [IDENTIFIER_USED]: `This text is used later as a class name but contains physical properties such as "{{ invalid }}". It's better to use logical properties like "{{ valid }}" for improved RTL support.`, }, - schema: [], + schema: [ + { + type: "object", + properties: { + allowPhysicalInsetWithAbsolute: { + type: "boolean", + default: false, + }, + debug: { + type: "boolean", + default: false, + }, + }, + additionalProperties: false, + }, + ], }, + defaultOptions: [FLAGS], create: (ctx) => { return { JSXAttribute: (node) => { @@ -46,12 +68,6 @@ export const noPhysicalProperties: RuleModule = { 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) { @@ -60,29 +76,31 @@ export const noPhysicalProperties: RuleModule = { // return; // } - const tokens = extractTokensFromNode(node, ctx, "checker"); + const allowPhysicalInsetWithAbsolute = + ctx.options[0]?.allowPhysicalInsetWithAbsolute ?? + FLAGS.allowPhysicalInsetWithAbsolute; + const debug = ctx.options[0]?.debug ?? FLAGS.debug; + + const tokens = extractTokensFromNode(node, ctx, { debug }); tokens?.forEach((token) => { const classValue = token?.getValue(); if (!classValue) return; - const classes = classValue.split(" "); - - const parsed = parseForPhysicalClasses(classes); + const parsed = parseForPhysicalClasses( + classValue, + allowPhysicalInsetWithAbsolute + ); 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, messageId: token.messageId, - invalid, - valid, - token: token ?? null, + parsed, + token, }); }); }, @@ -90,14 +108,17 @@ export const noPhysicalProperties: RuleModule = { }, }; +export function ruleSettings(options: Partial) { + return options; +} + export type Context = Readonly< - RuleContext<"NO_PHYSICAL_CLASSESS" | "IDENTIFIER_USED", []> + RuleContext >; function report({ ctx, - invalid, - valid, + parsed, node, token, messageId, @@ -105,25 +126,36 @@ function report({ messageId: MessageId; ctx: Context; node: TSESTree.JSXAttribute; - invalid: string; - valid: string; - token: Token | null; + token: Token; + parsed: ReturnType; }) { return ctx.report({ node, messageId, data: { - invalid, - valid, + invalid: parsed + .filter((c) => c.isInvalid) + .map((c) => c.original) + .join(" "), + valid: parsed + .filter((c) => c.isInvalid) + .map((c) => c.valid) + .join(" "), }, loc: { - start: token?.loc?.start ?? node.loc!.start, - end: token?.loc?.end ?? node.loc!.end, + start: token?.loc?.start, + end: token?.loc?.end, }, fix: (fixer) => { - if (!token) return null; - - return fixer.replaceText(token, token?.getRaw()?.replace(invalid, valid)); + return fixer.replaceText( + token, + token + ?.getRaw() + ?.replace( + parsed.map((p) => p.original).join(" "), + parsed.map((p) => p.valid).join(" ") + ) + ); }, }); } diff --git a/src/utils/tailwind.ts b/src/rules/no-phyisical-properties/tailwind.ts similarity index 59% rename from src/utils/tailwind.ts rename to src/rules/no-phyisical-properties/tailwind.ts index d3d9b4b..aeff3a2 100644 --- a/src/utils/tailwind.ts +++ b/src/rules/no-phyisical-properties/tailwind.ts @@ -3,8 +3,8 @@ export const twLogicalClasses = [ { physical: "mr-", /* */ logical: "me-" }, { physical: "pl-", /* */ logical: "ps-" }, { physical: "pr-", /* */ logical: "pe-" }, - { physical: "left-", /* */ logical: "start-" }, - { physical: "right-", /* */ logical: "end-" }, + { physical: "left-", /* */ logical: "start-", if: isNotAbsoluteCenterd }, + { physical: "right-", /* */ logical: "end-", if: isNotAbsoluteCenterd }, { physical: "text-left", /* */ logical: "text-start" }, { physical: "text-right", /* */ logical: "text-end" }, { physical: "border-l-", /* */ logical: "border-s-" }, @@ -19,7 +19,11 @@ export const twLogicalClasses = [ { physical: "scroll-mr-", /* */ logical: "scroll-me-" }, { physical: "scroll-pl-", /* */ logical: "scroll-ps-" }, { physical: "scroll-pr-", /* */ logical: "scroll-pe-" }, -] satisfies { physical: string; logical: string }[]; +] satisfies { + physical: string; + logical: string; + if?: (className: string) => boolean; +}[]; export function tailwindClassCases(cls: string) { return [ @@ -33,11 +37,35 @@ export function tailwindClassCases(cls: string) { ]; } -const allCases = twLogicalClasses.flatMap(({ physical, logical }) => - tailwindClassCases(physical).map((regex) => ({ regex, physical, logical })) -); +function getAllCases( + className: string, + allowPhysicalInsetWithAbsolute: boolean +) { + return twLogicalClasses.flatMap((cls) => { + const shouldValidate = allowPhysicalInsetWithAbsolute + ? cls.if?.(className) ?? true + : true; + if (!shouldValidate) return []; + + const { physical, logical } = cls; + return tailwindClassCases(physical).map((regex) => { + return { + regex, + physical, + logical, + }; + }); + }); +} + +export function parseForPhysicalClasses( + className: string, + allowPhysicalInsetWithAbsolute: boolean +) { + const allCases = getAllCases(className, allowPhysicalInsetWithAbsolute); + + const classes = className.split(" "); -export function parseForPhysicalClasses(classes: string[]) { return classes.map((cls) => { const isInvalid = allCases.some(({ regex }) => regex.test(cls)); const valid = allCases.reduce( @@ -64,3 +92,12 @@ export function parseForPhysicalClasses(classes: string[]) { // }); // }); } + +function isNotAbsoluteCenterd(className: string) { + return !["absolute", "fixed", "sticky"].some((c) => { + // We match absolute-CENTERED not every absolute position + // We encourage the usage of logical properties with positioning except for valid + // cases like center with fixed/absolute + return className.includes(c) && className.includes("translate-x"); + }); +} diff --git a/src/rules/no-phyisical-properties/test.ts b/src/rules/no-phyisical-properties/test.ts index 477e4d7..eff09e2 100644 --- a/src/rules/no-phyisical-properties/test.ts +++ b/src/rules/no-phyisical-properties/test.ts @@ -6,6 +6,7 @@ import { RULE_NAME, noPhysicalProperties, } from "./rule.js"; +import type { AST_NODE_TYPES } from "@typescript-eslint/utils"; RuleTester.afterAll = vitest.afterAll; RuleTester.it = vitest.it; @@ -51,6 +52,14 @@ vitest.describe(RULE_NAME, () => { name: 'className={isCondition && "..."}', code: `
`, }, + { + name: "Not a className attribute", + code: `
`, + }, + { + name: "JSXNamespacedName", + code: ``, + }, ], invalid: [ { @@ -67,10 +76,32 @@ vitest.describe(RULE_NAME, () => { code: `
text
`, output: `
text
`, errors: [ - { messageId: NO_PHYSICAL_CLASSESS }, - { messageId: NO_PHYSICAL_CLASSESS }, + { + messageId: NO_PHYSICAL_CLASSESS, + data: { invalid: "pl-1 mr-2", valid: "ps-1 me-2" }, + }, + { + messageId: NO_PHYSICAL_CLASSESS, + data: { invalid: "pl-2 pr-2", valid: "ps-2 pe-2" }, + }, ], }, + { + name: "should only include unvalid class names in the error message", + code: `
text
`, + errors: [ + { + messageId: NO_PHYSICAL_CLASSESS, + data: { invalid: "pl-1 text-left", valid: "ps-1 text-start" }, + line: 1, + column: 16, + endLine: 1, + endColumn: 44, + type: "JSXAttribute" as AST_NODE_TYPES.JSXAttribute, + }, + ], + output: `
text
`, + }, { name: `className={"..."}`, code: `
text
`, @@ -288,20 +319,55 @@ vitest.describe(RULE_NAME, () => { ], }, { - name: "Outside the scope", + skip: true, + name: "MemberExpression", code: ` - const cls = "left-2"; function Comp() { - return
+ const styles = { main: "left-2" }; + return
} `, + errors: [{ messageId: IDENTIFIER_USED }], output: ` - const cls = "start-2"; function Comp() { - return
+ const styles = { main: "start-2" }; + return
+ } + `, + }, + { + skip: true, + name: "MemberExpression with computed property", + code: ` + const styles = { main: "left-2" }; + function Comp() { + return
+ } + `, + errors: [{ messageId: IDENTIFIER_USED }], + output: ` + const styles = { main: "start-2" }; + function Comp() { + return
+ } + `, + }, + { + skip: true, + name: "MemberExpression deeply", + code: ` + const styles = { main: { title: "left-2" } }; + function Comp() { + return
} `, errors: [{ messageId: IDENTIFIER_USED }], + output: ` + const styles = { main: { title: "start-2" } }; + function Comp() { + return
+ } + `, }, { name: "Reassignment in a nested scope", @@ -324,6 +390,47 @@ vitest.describe(RULE_NAME, () => { { messageId: IDENTIFIER_USED }, ], }, + { + skip: true, + name: "Reassignment object in a nested scope", + code: ` + let styles = { main: "left-2" }; + function Comp() { + styles = { main: "text-left", hey: "pl-2" }; + return
+ } + `, + output: ` + let styles = { main: "start-2" }; + function Comp() { + styles = { main: "text-start", hey: "pl-2" }; + return
+ } + `, + errors: [ + { messageId: IDENTIFIER_USED }, + { messageId: IDENTIFIER_USED }, + ], + }, + { + skip: true, + name: "Reassignment object property in a nested scope", + code: ` + const styles = { main: "left-2" }; + function Comp() { + styles.main = "text-left"; + return
+ } + `, + output: ` + const styles = { main: "start-2" }; + function Comp() { + styles.main = "text-start"; + return
+ } + `, + errors: [{ messageId: IDENTIFIER_USED }], + }, { name: "Don't conflict with other vars with the same name", code: ` @@ -504,4 +611,65 @@ vitest.describe(RULE_NAME, () => { }, ], }); + + tester.run("inset with absolute centerd", noPhysicalProperties, { + valid: [ + ``, + ``, + { + // Valid even with left-* because it has fixed and translate-x + code: ``, + options: [{ allowPhysicalInsetWithAbsolute: true }], + }, + { + code: ``, + options: [{ allowPhysicalInsetWithAbsolute: true }], + }, + { + code: ``, + options: [{ allowPhysicalInsetWithAbsolute: true }], + }, + ], + invalid: [ + { + code: ``, + output: ``, + errors: [{ messageId: NO_PHYSICAL_CLASSESS }], + }, + { + code: ``, + output: ``, + errors: [{ messageId: NO_PHYSICAL_CLASSESS }], + }, + { + code: ``, + output: ``, + errors: [{ messageId: NO_PHYSICAL_CLASSESS }], + }, + { + code: ``, + output: ``, + errors: [{ messageId: NO_PHYSICAL_CLASSESS }], + }, + { + options: [{ allowPhysicalInsetWithAbsolute: false }], + code: ``, + errors: [{ messageId: NO_PHYSICAL_CLASSESS }], + output: ``, + }, + { + options: [ + { allowPhysicalInsetWithAbsolute: undefined as unknown as boolean }, + ], + code: ``, + errors: [{ messageId: NO_PHYSICAL_CLASSESS }], + output: ``, + }, + { + code: ``, + errors: [{ messageId: NO_PHYSICAL_CLASSESS }], + output: ``, + }, + ], + }); }); diff --git a/src/utils/eslint.ts b/src/utils/eslint.ts new file mode 100644 index 0000000..5723ecb --- /dev/null +++ b/src/utils/eslint.ts @@ -0,0 +1,47 @@ +// /// + +// import type { TSESLint, TSESTree } from "@typescript-eslint/utils"; + +// export function findVariable( +// initialScope: TSESLint.Scope.Scope, +// node: TSESTree.Identifier +// ) { +// let scope: TSESLint.Scope.Scope | null = initialScope; +// const name = node.name; + +// scope = getInnermostScope(scope, node); + +// while (scope != null) { +// const variable = scope.set.get(name); +// if (variable != null) { +// return variable; +// } +// scope = scope.upper; +// } + +// return null; +// } + +// export function getInnermostScope( +// initialScope: TSESLint.Scope.Scope, +// node: TSESTree.Node +// ): TSESLint.Scope.Scope { +// const location = node.range[0]; + +// let scope = initialScope; +// let found = false; +// do { +// found = false; +// for (const childScope of scope.childScopes) { +// const range = childScope.block.range; + +// if (range[0] <= location && location < range[1]) { +// scope = childScope; +// found = true; +// break; +// } +// } +// } while (found); + +// return scope; +// } diff --git a/vite.config.ts b/vite.config.ts index 6a25499..21b47cd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,9 +4,10 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ cacheDir: path.resolve(__dirname, "./node_modules/.cache/vitest"), test: { - include: ["src/**/test.ts"], + include: ["src/**/test.ts", "src/**/*.test.ts"], coverage: { include: ["src/**"], }, + slowTestThreshold: 10, }, });