From d2189d04745c01b047ea2b51d2ca11be1f5b8f3d Mon Sep 17 00:00:00 2001 From: Rel1cx Date: Sat, 1 Nov 2025 16:16:58 +0800 Subject: [PATCH 1/4] WIP --- dprint.json | 2 +- .../src/rules/jsx-dollar.spec.ts | 44 +++++++++++++ .../src/rules/jsx-dollar.ts | 61 +++++++++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.spec.ts create mode 100644 packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.ts diff --git a/dprint.json b/dprint.json index 7ef61712f..5c162bf15 100644 --- a/dprint.json +++ b/dprint.json @@ -25,7 +25,7 @@ "packages/**/docs" ], "plugins": [ - "https://plugins.dprint.dev/typescript-0.95.11.wasm", + "https://plugins.dprint.dev/typescript-0.95.12.wasm", "https://plugins.dprint.dev/json-0.21.0.wasm", "https://plugins.dprint.dev/markdown-0.20.0.wasm", "https://plugins.dprint.dev/toml-0.7.0.wasm", diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.spec.ts b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.spec.ts new file mode 100644 index 000000000..3601e4c94 --- /dev/null +++ b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.spec.ts @@ -0,0 +1,44 @@ +import tsx from "dedent"; + +import { allValid, ruleTester } from "../../../../../test"; +import rule, { RULE_NAME } from "./jsx-dollar"; + +ruleTester.run(RULE_NAME, rule, { + invalid: [ + { + code: tsx` + const App = (props) => { + return
Hello \${props.name}
; + }; + `, + errors: [ + { + messageId: "jsxDollar", + suggestions: [ + { + messageId: "removeDollarSign", + output: tsx` + const App = (props) => { + return
Hello {props.name}
; + }; + `, + }, + ], + }, + ], + }, + ], + valid: [ + ...allValid, + tsx` + const App = (props) => { + return [
1
] + }; + `, + tsx` + const App = (props) => { + return
Hello $
; + }; + `, + ], +}); diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.ts b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.ts new file mode 100644 index 000000000..1e6f8d695 --- /dev/null +++ b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.ts @@ -0,0 +1,61 @@ +import * as AST from "@eslint-react/ast"; +import type { RuleContext, RuleFeature } from "@eslint-react/shared"; +import type { TSESTree } from "@typescript-eslint/types"; +import { AST_NODE_TYPES as T } from "@typescript-eslint/types"; +import type { RuleListener } from "@typescript-eslint/utils/ts-eslint"; +import type { CamelCase } from "string-ts"; + +import { createRule } from "../utils"; + +export const RULE_NAME = "jsx-dollar"; + +export const RULE_FEATURES = [] as const satisfies RuleFeature[]; + +export type MessageID = CamelCase | "removeDollarSign"; + +export default createRule<[], MessageID>({ + meta: { + type: "problem", + docs: { + description: "Prevents dollar signs from being inserted as text nodes before expressions.", + [Symbol.for("rule_features")]: RULE_FEATURES, + }, + fixable: "code", + hasSuggestions: true, + messages: { + jsxDollar: + "Possible misused dollar sign in text node. If you want to explicitly display '$' character i.e. show price, you can use template literals.", + removeDollarSign: "Remove the dollar sign '$' before the expression.", + }, + schema: [], + }, + name: RULE_NAME, + create, + defaultOptions: [], +}); + +export function create(context: RuleContext): RuleListener { + const visitorFunction = (node: TSESTree.JSXElement | TSESTree.JSXFragment) => { + for (const [index, child] of node.children.entries()) { + if (child.type !== T.JSXText) continue; + if (!child.raw.endsWith("$")) continue; + if (node.children[index + 1]?.type !== T.JSXExpressionContainer) continue; + context.report({ + messageId: "jsxDollar", + node: child, + suggest: [ + { + messageId: "removeDollarSign", + fix(fixer) { + return fixer.removeRange([child.range[1] - 1, child.range[1]]); + }, + }, + ], + }); + } + }; + return { + JSXElement: visitorFunction, + JSXFragment: visitorFunction, + }; +} From 1b9a179b58d50857360a0f3de8ce91ade32e7c6e Mon Sep 17 00:00:00 2001 From: Rel1cx Date: Sun, 2 Nov 2025 02:28:02 +0800 Subject: [PATCH 2/4] Add jsx-dollar rule to react-x plugin --- apps/website/content/docs/rules/meta.json | 1 + .../src/rules/jsx-dollar.mdx | 108 ++++++++++++++++++ .../src/rules/jsx-dollar.spec.ts | 41 +++++++ .../src/rules/jsx-dollar.ts | 16 ++- packages/plugins/eslint-plugin/README.md | 4 +- 5 files changed, 162 insertions(+), 8 deletions(-) create mode 100644 packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.mdx diff --git a/apps/website/content/docs/rules/meta.json b/apps/website/content/docs/rules/meta.json index 153f03a9a..81f730c27 100644 --- a/apps/website/content/docs/rules/meta.json +++ b/apps/website/content/docs/rules/meta.json @@ -2,6 +2,7 @@ "pages": [ "overview", "---X Rules---", + "jsx-dollar", "jsx-key-before-spread", "jsx-no-comment-textnodes", "jsx-no-duplicate-props", diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.mdx b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.mdx new file mode 100644 index 000000000..546408f5f --- /dev/null +++ b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.mdx @@ -0,0 +1,108 @@ +--- +title: jsx-dollar +--- + +**Full Name in `@eslint-react/eslint-plugin`** + +```plain copy +@eslint-react/jsx-dollar +``` + +**Full Name in `eslint-plugin-react-x`** + +```plain copy +react-x/jsx-dollar +``` + +**Features** + +`🔧` + +## Description + +Prevents unnecessary dollar signs (`$`) from being inserted before an expression in JSX. + +This can happen when refactoring from a template literal to JSX and forgetting to remove the dollar sign. This results in an unintentional `$` being rendered in the output. + +```tsx +import React from "react"; + +function MyComponent({ user }) { + return `Hello ${user.name}`; +} +``` + +When refactored to JSX, it might look like this: + +```tsx +import React from "react"; + +function MyComponent({ user }) { + return <>Hello ${user.name}; +} +``` + +In this example, the `$` before `{user.name}` is unnecessary and will be rendered as part of the output. + +## Examples + +### Failing + +```tsx +import React from "react"; + +function MyComponent({ user }) { + return
Hello ${user.name}
; + // ^^^^^^^^^^^^^^ + // - Possible unnecessary '$' character before expression. +} +``` + +### Passing + +```tsx +import React from "react"; + +function MyComponent({ user }) { + return `Hello ${user.name}`; +} +``` + +```tsx +import React from "react"; + +function MyComponent({ user }) { + return
Hello {user.name}
; +} +``` + +```tsx +import React from "react"; + +function MyComponent({ price }) { + return
${price}
; +} +``` + +### Legitimate uses + +If you legitimately need to output a dollar sign before an expression (for example, to display a price), you can wrap it in a template literal or use a string literal. + +```tsx +import React from "react"; + +function MyComponent({ price }) { + // 🟢 Good: This is a legitimate use of the '$' character. + return
{`$${price}`}
; +} + +function AnotherComponent({ price }) { + // 🟢 Good: Another legitimate way to display a price. + return
${price}
; +} +``` + +## Implementation + +- [Rule Source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.ts) +- [Test Source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.spec.ts) diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.spec.ts b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.spec.ts index 3601e4c94..e4d0db7b9 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.spec.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.spec.ts @@ -5,6 +5,24 @@ import rule, { RULE_NAME } from "./jsx-dollar"; ruleTester.run(RULE_NAME, rule, { invalid: [ + { + code: tsx` + const MyComponent = () => <>Hello \${user.name} + `, + errors: [ + { + messageId: "jsxDollar", + suggestions: [ + { + messageId: "removeDollarSign", + output: tsx` + const MyComponent = () => <>Hello {user.name} + `, + }, + ], + }, + ], + }, { code: tsx` const App = (props) => { @@ -30,6 +48,9 @@ ruleTester.run(RULE_NAME, rule, { ], valid: [ ...allValid, + tsx` + const MyComponent = () => \`Hello \${user.name}\` + `, tsx` const App = (props) => { return [
1
] @@ -40,5 +61,25 @@ ruleTester.run(RULE_NAME, rule, { return
Hello $
; }; `, + tsx` + const App = (props) => { + return
Hello {props.name}
; + }; + `, + tsx` + import React from "react"; + + function MyComponent({ price }) { + // 🟢 Good: This is a legitimate use of the '$' character. + return
{\`$\${price}\`}
; + } + `, + tsx` + import React from "react"; + function AnotherComponent({ price }) { + // 🟢 Good: Another legitimate way to display a price. + return
\${price}
; + } + `, ], }); diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.ts b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.ts index 1e6f8d695..530a39731 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.ts @@ -1,4 +1,3 @@ -import * as AST from "@eslint-react/ast"; import type { RuleContext, RuleFeature } from "@eslint-react/shared"; import type { TSESTree } from "@typescript-eslint/types"; import { AST_NODE_TYPES as T } from "@typescript-eslint/types"; @@ -11,7 +10,9 @@ export const RULE_NAME = "jsx-dollar"; export const RULE_FEATURES = [] as const satisfies RuleFeature[]; -export type MessageID = CamelCase | "removeDollarSign"; +export type MessageID = CamelCase | RuleSuggestMessageID; + +export type RuleSuggestMessageID = "removeDollarSign"; export default createRule<[], MessageID>({ meta: { @@ -23,8 +24,7 @@ export default createRule<[], MessageID>({ fixable: "code", hasSuggestions: true, messages: { - jsxDollar: - "Possible misused dollar sign in text node. If you want to explicitly display '$' character i.e. show price, you can use template literals.", + jsxDollar: "Possible unnecessary '$' character before expression.", removeDollarSign: "Remove the dollar sign '$' before the expression.", }, schema: [], @@ -35,10 +35,14 @@ export default createRule<[], MessageID>({ }); export function create(context: RuleContext): RuleListener { + /** + * Visitor function for JSXElement and JSXFragment nodes + * @param node The JSXElement or JSXFragment node to be checked + */ const visitorFunction = (node: TSESTree.JSXElement | TSESTree.JSXFragment) => { for (const [index, child] of node.children.entries()) { - if (child.type !== T.JSXText) continue; - if (!child.raw.endsWith("$")) continue; + if (child.type !== T.JSXText || child.raw === "$" || !child.raw.endsWith("$")) continue; + // Ensure the next sibling is a JSXExpressionContainer if (node.children[index + 1]?.type !== T.JSXExpressionContainer) continue; context.report({ messageId: "jsxDollar", diff --git a/packages/plugins/eslint-plugin/README.md b/packages/plugins/eslint-plugin/README.md index fcb6b6a91..1493c4e40 100644 --- a/packages/plugins/eslint-plugin/README.md +++ b/packages/plugins/eslint-plugin/README.md @@ -186,8 +186,8 @@ ESLint React is not affiliated with Meta Corporation or [facebook/react](https:/ Contributions are welcome! -Please follow our [contributing guidelines](https://github.com/Rel1cx/eslint-react/tree/main/.github/CONTRIBUTING.md). +Please follow our [contributing guidelines](https://github.com/Rel1cx/eslint-react/tree/jsx-dollar/.github/CONTRIBUTING.md). ## License -This project is licensed under the MIT License - see the [LICENSE](https://github.com/Rel1cx/eslint-react/tree/main/LICENSE) file for details. +This project is licensed under the MIT License - see the [LICENSE](https://github.com/Rel1cx/eslint-react/tree/jsx-dollar/LICENSE) file for details. From a195c2e2828a805f1c81a64236603f6f7f9472f7 Mon Sep 17 00:00:00 2001 From: Rel1cx Date: Sun, 2 Nov 2025 02:34:52 +0800 Subject: [PATCH 3/4] Add jsx-dollar rule to plugin and docs --- apps/website/content/docs/rules/overview.mdx | 1 + packages/plugins/eslint-plugin-react-x/src/plugin.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/apps/website/content/docs/rules/overview.mdx b/apps/website/content/docs/rules/overview.mdx index 1bfd8415d..61e596a26 100644 --- a/apps/website/content/docs/rules/overview.mdx +++ b/apps/website/content/docs/rules/overview.mdx @@ -27,6 +27,7 @@ full: true | Rule | ✅ | 🌟 | Description | `react` | | :----------------------------------------------------------------------------------- | :-----: | :-------: | :-------------------------------------------------------------------------------------------------- | :------: | +| [`jsx-dollar`](./jsx-dollar) | 0️⃣ 0️⃣ | | Prevents unnecessary dollar signs (`$`) from being inserted before an expression in JSX | | | [`jsx-key-before-spread`](./jsx-key-before-spread) | 1️⃣ 1️⃣ | | Enforces that the 'key' prop is placed before the spread prop in JSX elements | | | [`jsx-no-comment-textnodes`](./jsx-no-comment-textnodes) | 1️⃣ 1️⃣ | | Prevents comments from being inserted as text nodes | | | [`jsx-no-duplicate-props`](./jsx-no-duplicate-props) | 1️⃣ 1️⃣ | | Disallow duplicate props in JSX elements | | diff --git a/packages/plugins/eslint-plugin-react-x/src/plugin.ts b/packages/plugins/eslint-plugin-react-x/src/plugin.ts index b50461c47..bb90cd05a 100644 --- a/packages/plugins/eslint-plugin-react-x/src/plugin.ts +++ b/packages/plugins/eslint-plugin-react-x/src/plugin.ts @@ -2,6 +2,7 @@ import type { CompatiblePlugin } from "@eslint-react/shared"; import { name, version } from "../package.json"; +import jsxDollar from "./rules/jsx-dollar"; import jsxKeyBeforeSpread from "./rules/jsx-key-before-spread"; import jsxNoCommentTextnodes from "./rules/jsx-no-comment-textnodes"; import jsxNoDuplicateProps from "./rules/jsx-no-duplicate-props"; @@ -71,6 +72,7 @@ export const plugin: CompatiblePlugin = { version, }, rules: { + "jsx-dollar": jsxDollar, "jsx-key-before-spread": jsxKeyBeforeSpread, "jsx-no-comment-textnodes": jsxNoCommentTextnodes, "jsx-no-duplicate-props": jsxNoDuplicateProps, From e7e95ede83fa705bee6b7e8856f4af2301dd3bdb Mon Sep 17 00:00:00 2001 From: Rel1cx Date: Sun, 2 Nov 2025 03:05:01 +0800 Subject: [PATCH 4/4] Update jsx-dollar rule to handle two-child case --- .../src/rules/jsx-dollar.mdx | 27 +++++------- .../src/rules/jsx-dollar.spec.ts | 44 +++++++++++++++++++ .../src/rules/jsx-dollar.ts | 4 +- 3 files changed, 57 insertions(+), 18 deletions(-) diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.mdx b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.mdx index 546408f5f..83aeb1cd1 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.mdx +++ b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.mdx @@ -53,51 +53,44 @@ import React from "react"; function MyComponent({ user }) { return
Hello ${user.name}
; - // ^^^^^^^^^^^^^^ + // ^^^^^^^ // - Possible unnecessary '$' character before expression. } ``` -### Passing - ```tsx import React from "react"; function MyComponent({ user }) { - return `Hello ${user.name}`; + return
${user.name} is your name
; + // ^ + // - Possible unnecessary '$' character before expression. } ``` +### Passing + ```tsx import React from "react"; function MyComponent({ user }) { - return
Hello {user.name}
; + return `Hello ${user.name}`; } ``` ```tsx import React from "react"; -function MyComponent({ price }) { - return
${price}
; +function MyComponent({ user }) { + return
Hello {user.name}
; } ``` -### Legitimate uses - -If you legitimately need to output a dollar sign before an expression (for example, to display a price), you can wrap it in a template literal or use a string literal. - ```tsx import React from "react"; function MyComponent({ price }) { - // 🟢 Good: This is a legitimate use of the '$' character. - return
{`$${price}`}
; -} - -function AnotherComponent({ price }) { - // 🟢 Good: Another legitimate way to display a price. + // 🟢 Good: If there are only two children (the dollar sign and the expression) it doesn't seem to be split from a template literal return
${price}
; } ``` diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.spec.ts b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.spec.ts index e4d0db7b9..a0c402d3c 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.spec.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.spec.ts @@ -45,6 +45,50 @@ ruleTester.run(RULE_NAME, rule, { }, ], }, + { + code: tsx` + const App = (props) => { + return
\${props.name} is your name
; + }; + `, + errors: [ + { + messageId: "jsxDollar", + suggestions: [ + { + messageId: "removeDollarSign", + output: tsx` + const App = (props) => { + return
{props.name} is your name
; + }; + `, + }, + ], + }, + ], + }, + { + code: tsx` + const App = (props) => { + return
Hello \${props.name} is your name
; + }; + `, + errors: [ + { + messageId: "jsxDollar", + suggestions: [ + { + messageId: "removeDollarSign", + output: tsx` + const App = (props) => { + return
Hello {props.name} is your name
; + }; + `, + }, + ], + }, + ], + }, ], valid: [ ...allValid, diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.ts b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.ts index 530a39731..52377ce42 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-dollar.ts @@ -41,9 +41,11 @@ export function create(context: RuleContext): RuleListener { */ const visitorFunction = (node: TSESTree.JSXElement | TSESTree.JSXFragment) => { for (const [index, child] of node.children.entries()) { - if (child.type !== T.JSXText || child.raw === "$" || !child.raw.endsWith("$")) continue; + if (child.type !== T.JSXText || !child.value.endsWith("$")) continue; // Ensure the next sibling is a JSXExpressionContainer if (node.children[index + 1]?.type !== T.JSXExpressionContainer) continue; + // Skip if there are only two children (the dollar sign and the expression) it doesn't seem to be split from a template literal + if (child.value === "$" && node.children.length === 2) continue; context.report({ messageId: "jsxDollar", node: child,