From 35ab0cf0180d22c996f9d598feb23ae8f8101d59 Mon Sep 17 00:00:00 2001 From: Rel1cx Date: Sat, 6 Sep 2025 18:53:06 +0800 Subject: [PATCH 1/6] wip --- packages/core/docs/README.md | 3 +- packages/core/docs/functions/getAttribute.md | 31 +- .../core/docs/functions/getAttributeValue.md | 37 - .../docs/functions/resolveAttributeValue.md | 23 + .../core/docs/type-aliases/AttributeValue.md | 11 + packages/core/src/jsx/jsx-attribute-value.ts | 139 ++-- packages/core/src/jsx/jsx-attribute.ts | 66 +- packages/core/src/jsx/jsx-has.ts | 2 +- .../src/rules/no-dangerously-set-innerhtml.ts | 8 +- .../src/rules/no-missing-button-type.spec.ts | 24 - .../src/rules/no-missing-button-type.ts | 20 +- .../src/rules/no-missing-iframe-sandbox.ts | 33 +- .../src/rules/no-script-url.ts | 13 +- .../src/rules/no-string-style-prop.spec.ts | 19 - .../src/rules/no-string-style-prop.ts | 28 +- .../src/rules/no-unsafe-target-blank.spec.ts | 750 +++++++++--------- .../src/rules/no-unsafe-target-blank.ts | 92 ++- .../src/utils/create-jsx-element-resolver.ts | 25 +- .../src/utils/find-custom-component.ts | 25 - .../src/utils/index.ts | 2 - .../src/utils/resolve-attribute.ts | 50 -- .../src/rules/no-implicit-key.ts | 2 +- packages/utilities/ast/src/node-types.ts | 2 + 23 files changed, 654 insertions(+), 751 deletions(-) delete mode 100644 packages/core/docs/functions/getAttributeValue.md create mode 100644 packages/core/docs/functions/resolveAttributeValue.md create mode 100644 packages/core/docs/type-aliases/AttributeValue.md delete mode 100644 packages/plugins/eslint-plugin-react-dom/src/utils/find-custom-component.ts delete mode 100644 packages/plugins/eslint-plugin-react-dom/src/utils/resolve-attribute.ts diff --git a/packages/core/docs/README.md b/packages/core/docs/README.md index 0bea013ee1..18ce30d96a 100644 --- a/packages/core/docs/README.md +++ b/packages/core/docs/README.md @@ -22,6 +22,7 @@ ## Type Aliases +- [AttributeValue](type-aliases/AttributeValue.md) - [Component](type-aliases/Component.md) - [ComponentDetectionHint](type-aliases/ComponentDetectionHint.md) - [ComponentEffectPhaseKind](type-aliases/ComponentEffectPhaseKind.md) @@ -113,7 +114,6 @@ - [findParentAttribute](functions/findParentAttribute.md) - [getAttribute](functions/getAttribute.md) - [getAttributeName](functions/getAttributeName.md) -- [getAttributeValue](functions/getAttributeValue.md) - [getComponentFlagFromInitPath](functions/getComponentFlagFromInitPath.md) - [getComponentNameFromId](functions/getComponentNameFromId.md) - [getElementType](functions/getElementType.md) @@ -151,6 +151,7 @@ - [isRenderPropLoose](functions/isRenderPropLoose.md) - [isThisSetState](functions/isThisSetState.md) - [isUseEffectCallLoose](functions/isUseEffectCallLoose.md) +- [resolveAttributeValue](functions/resolveAttributeValue.md) - [stringifyJsx](functions/stringifyJsx.md) - [useComponentCollector](functions/useComponentCollector.md) - [useComponentCollectorLegacy](functions/useComponentCollectorLegacy.md) diff --git a/packages/core/docs/functions/getAttribute.md b/packages/core/docs/functions/getAttribute.md index b7dd80077c..3dd8b08f79 100644 --- a/packages/core/docs/functions/getAttribute.md +++ b/packages/core/docs/functions/getAttribute.md @@ -6,10 +6,7 @@ # Function: getAttribute() -> **getAttribute**(`context`, `name`, `attributes`, `initialScope?`): `undefined` \| `JSXAttribute` \| `JSXSpreadAttribute` - -Searches for a specific JSX attribute by name in a list of attributes -Returns the last matching attribute (rightmost in JSX) +> **getAttribute**(`context`, `attributes`, `initialScope?`): (`name`) => `undefined` \| `TSESTreeJSXAttributeLike` ## Parameters @@ -17,28 +14,24 @@ Returns the last matching attribute (rightmost in JSX) `RuleContext` -ESLint rule context - -### name - -`string` - -The name of the attribute to find - ### attributes -(`JSXAttribute` \| `JSXSpreadAttribute`)[] - -Array of JSX attributes to search through +`TSESTreeJSXAttributeLike`[] ### initialScope? `Scope` -Optional scope for resolving variables - ## Returns -`undefined` \| `JSXAttribute` \| `JSXSpreadAttribute` +> (`name`): `undefined` \| `TSESTreeJSXAttributeLike` + +### Parameters + +#### name + +`string` + +### Returns -The found attribute or undefined +`undefined` \| `TSESTreeJSXAttributeLike` diff --git a/packages/core/docs/functions/getAttributeValue.md b/packages/core/docs/functions/getAttributeValue.md deleted file mode 100644 index ba208e4f3c..0000000000 --- a/packages/core/docs/functions/getAttributeValue.md +++ /dev/null @@ -1,37 +0,0 @@ -[**@eslint-react/core**](../README.md) - -*** - -[@eslint-react/core](../README.md) / getAttributeValue - -# Function: getAttributeValue() - -> **getAttributeValue**(`context`, `node`, `name`): \{ `initialScope`: `undefined` \| `Scope`; `kind`: `"none"`; `node`: `Node`; \} \| \{ `initialScope`: `undefined` \| `Scope`; `kind`: `"some"`; `node`: `Node`; `value`: `unknown`; \} - -Extracts the value of a JSX attribute by name - -## Parameters - -### context - -`RuleContext` - -ESLint rule context - -### node - -JSX attribute or spread attribute node - -`JSXAttribute` | `JSXSpreadAttribute` - -### name - -`string` - -Name of the attribute to extract - -## Returns - -\{ `initialScope`: `undefined` \| `Scope`; `kind`: `"none"`; `node`: `Node`; \} \| \{ `initialScope`: `undefined` \| `Scope`; `kind`: `"some"`; `node`: `Node`; `value`: `unknown`; \} - -The extracted attribute value in a structured format diff --git a/packages/core/docs/functions/resolveAttributeValue.md b/packages/core/docs/functions/resolveAttributeValue.md new file mode 100644 index 0000000000..7dda553791 --- /dev/null +++ b/packages/core/docs/functions/resolveAttributeValue.md @@ -0,0 +1,23 @@ +[**@eslint-react/core**](../README.md) + +*** + +[@eslint-react/core](../README.md) / resolveAttributeValue + +# Function: resolveAttributeValue() + +> **resolveAttributeValue**(`context`, `attribute`): \{ `kind`: `"boolean"`; `node?`: `undefined`; `toStatic`: `true`; \} \| \{ `kind`: `"literal"`; `node`: `BigIntLiteral` \| `BooleanLiteral` \| `NullLiteral` \| `NumberLiteral` \| `RegExpLiteral` \| `StringLiteral`; `toStatic`: `null` \| `string` \| `number` \| `bigint` \| `boolean` \| [`RegExp`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/RegExp); \} \| \{ `kind`: `"expression"`; `node`: `JSXEmptyExpression` \| `Expression`; `toStatic`: `unknown`; \} \| \{ `kind`: `"element"`; `node`: `JSXElement`; `toStatic`: `undefined`; \} \| \{ `kind`: `"spreadChild"`; `node`: `JSXEmptyExpression` \| `Expression`; `toStatic`: `undefined`; \} \| \{ `kind`: `"spreadProps"`; `node`: `Expression`; `toStatic`: `unknown`; \} + +## Parameters + +### context + +`RuleContext` + +### attribute + +`TSESTreeJSXAttributeLike` + +## Returns + +\{ `kind`: `"boolean"`; `node?`: `undefined`; `toStatic`: `true`; \} \| \{ `kind`: `"literal"`; `node`: `BigIntLiteral` \| `BooleanLiteral` \| `NullLiteral` \| `NumberLiteral` \| `RegExpLiteral` \| `StringLiteral`; `toStatic`: `null` \| `string` \| `number` \| `bigint` \| `boolean` \| [`RegExp`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/RegExp); \} \| \{ `kind`: `"expression"`; `node`: `JSXEmptyExpression` \| `Expression`; `toStatic`: `unknown`; \} \| \{ `kind`: `"element"`; `node`: `JSXElement`; `toStatic`: `undefined`; \} \| \{ `kind`: `"spreadChild"`; `node`: `JSXEmptyExpression` \| `Expression`; `toStatic`: `undefined`; \} \| \{ `kind`: `"spreadProps"`; `node`: `Expression`; `toStatic`: `unknown`; \} diff --git a/packages/core/docs/type-aliases/AttributeValue.md b/packages/core/docs/type-aliases/AttributeValue.md new file mode 100644 index 0000000000..94d2b4ef49 --- /dev/null +++ b/packages/core/docs/type-aliases/AttributeValue.md @@ -0,0 +1,11 @@ +[**@eslint-react/core**](../README.md) + +*** + +[@eslint-react/core](../README.md) / AttributeValue + +# Type Alias: AttributeValue + +> **AttributeValue** = \{ `kind`: `"boolean"`; `toStatic`: `true`; \} \| \{ `kind`: `"element"`; `node`: `TSESTree.JSXElement`; `toStatic`: `unknown`; \} \| \{ `kind`: `"literal"`; `node`: `TSESTree.Literal`; `toStatic`: `null` \| `string` \| `number` \| `bigint` \| `boolean` \| [`RegExp`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/RegExp); \} \| \{ `kind`: `"expression"`; `node`: `TSESTree.JSXExpressionContainer`\[`"expression"`\]; `toStatic`: `unknown`; \} \| \{ `kind`: `"spreadProps"`; `node`: `TSESTree.JSXSpreadAttribute`\[`"argument"`\]; `toStatic`: `unknown`; \} \| \{ `kind`: `"spreadChild"`; `node`: `TSESTree.JSXSpreadChild`\[`"expression"`\]; `toStatic`: `unknown`; \} + +Represents possible JSX attribute value types that can be resolved diff --git a/packages/core/src/jsx/jsx-attribute-value.ts b/packages/core/src/jsx/jsx-attribute-value.ts index 37a72e6758..14779837ac 100644 --- a/packages/core/src/jsx/jsx-attribute-value.ts +++ b/packages/core/src/jsx/jsx-attribute-value.ts @@ -1,67 +1,98 @@ +import type * as AST from "@eslint-react/ast"; +import { unit } from "@eslint-react/eff"; +import { identity } from "@eslint-react/eff"; import type { RuleContext } from "@eslint-react/kit"; -import * as VAR from "@eslint-react/var"; +import type { TSESTree } from "@typescript-eslint/types"; import { AST_NODE_TYPES as T } from "@typescript-eslint/types"; -import type { TSESTree } from "@typescript-eslint/utils"; +import { getStaticValue } from "@typescript-eslint/utils/ast-utils"; import { match, P } from "ts-pattern"; /** - * Extracts the value of a JSX attribute by name - * @param context - ESLint rule context - * @param node - JSX attribute or spread attribute node - * @param name - Name of the attribute to extract - * @returns The extracted attribute value in a structured format + * Represents possible JSX attribute value types that can be resolved */ -export function getAttributeValue( - context: RuleContext, - node: TSESTree.JSXAttribute | TSESTree.JSXSpreadAttribute, - name: string, -): Exclude { - // Get the initial scope from the node's context - const initialScope = context.sourceCode.getScope(node); - switch (node.type) { - case T.JSXAttribute: - // Case 1: Literal value (e.g., className="container") - if (node.value?.type === T.Literal) { +export type AttributeValue = + | { kind: "boolean"; toStatic(): true } // Boolean attributes (e.g., disabled) + // | { kind: "default"; toStatic(): unit | string } // Default attribute values + | { kind: "element"; node: TSESTree.JSXElement; toStatic(): unknown } // JSX element as value (e.g., />) + | { kind: "literal"; node: TSESTree.Literal; toStatic(): TSESTree.Literal["value"] } // Literal values + | { kind: "expression"; node: TSESTree.JSXExpressionContainer["expression"]; toStatic(): unknown } // Expression attributes (e.g., {value}, {...props}) + | { kind: "spreadProps"; node: TSESTree.JSXSpreadAttribute["argument"]; toStatic(name?: string): unknown } // Spread props (e.g., {...props}) + | { kind: "spreadChild"; node: TSESTree.JSXSpreadChild["expression"]; toStatic(): unknown }; // Spread children (e.g., {...["Hello", " ", "spread", " ", "children"]}) + +export function resolveAttributeValue(context: RuleContext, attribute: AST.TSESTreeJSXAttributeLike) { + const initialScope = context.sourceCode.getScope(attribute); + function handleJsxAttribute(node: TSESTree.JSXAttribute) { + // Case 1: Boolean attribute with no value (e.g., disabled) + if (node.value == null) { + return { + kind: "boolean", + toStatic() { + return true; + }, + } as const satisfies AttributeValue; + } + switch (node.value.type) { + // Case 2: Literal value (e.g., className="container") + case T.Literal: { + const staticValue = node.value.value; return { - kind: "some", + kind: "literal", node: node.value, - initialScope, - value: node.value.value, - } as const; + toStatic() { + return staticValue; + }, + } as const satisfies AttributeValue; } - // Case 2: Expression container (e.g., className={variable}) - if (node.value?.type === T.JSXExpressionContainer) { - return VAR.toStaticValue({ - kind: "lazy", - node: node.value.expression, - initialScope, - }); - } - // Case 3: Boolean attribute with no value (e.g., disabled) - return { kind: "none", node, initialScope } as const; - case T.JSXSpreadAttribute: { - // For spread attributes (e.g., {...props}), try to extract static value - const staticValue = VAR.toStaticValue({ - kind: "lazy", - node: node.argument, - initialScope, - }); - // If can't extract static value, return none - if (staticValue.kind === "none") { - return staticValue; + // Case 3: Expression container (e.g., className={variable}) + case T.JSXExpressionContainer: { + const expr = node.value.expression; + return { + kind: "expression", + node: expr, + toStatic() { + return getStaticValue(expr, initialScope)?.value; + }, + } as const satisfies AttributeValue; } - // If spread object contains the named property, extract its value - return match(staticValue.value) - .with({ [name]: P.select(P.any) }, (value) => ({ - kind: "some", - node: node.argument, - initialScope, - value, - } as const)) - .otherwise(() => ({ kind: "none", node, initialScope } as const)); + // Case 4: JSX Element as value (e.g., element=) + case T.JSXElement: + return { + kind: "element", + node: node.value, + toStatic() { + return unit; + }, + } as const satisfies AttributeValue; + // Case 5: JSX spread children (e.g.,
{...["Hello", " ", "spread", " ", "children"]}
) + case T.JSXSpreadChild: + return { + kind: "spreadChild", + node: node.value.expression, + toStatic() { + return unit; + }, + } as const satisfies AttributeValue; } - default: - // Fallback case for unknown node types - return { kind: "none", node, initialScope } as const; + } + + function handleJsxSpreadAttribute(node: TSESTree.JSXSpreadAttribute) { + // For spread attributes (e.g., {...props}), try to extract static value + return { + kind: "spreadProps", + node: node.argument, + toStatic(name?: string) { + if (name == null) return unit; + // If spread object contains the named property, extract its value + return match(getStaticValue(node.argument, initialScope)?.value) + .with({ [name]: P.select(P.any) }, identity) + .otherwise(() => unit); + }, + } as const satisfies AttributeValue; + } + switch (attribute.type) { + case T.JSXAttribute: + return handleJsxAttribute(attribute); + case T.JSXSpreadAttribute: + return handleJsxSpreadAttribute(attribute); } } diff --git a/packages/core/src/jsx/jsx-attribute.ts b/packages/core/src/jsx/jsx-attribute.ts index 4daac7fb73..1c74673d37 100644 --- a/packages/core/src/jsx/jsx-attribute.ts +++ b/packages/core/src/jsx/jsx-attribute.ts @@ -1,51 +1,35 @@ -import type { unit } from "@eslint-react/eff"; +import type * as AST from "@eslint-react/ast"; import type { RuleContext } from "@eslint-react/kit"; import * as VAR from "@eslint-react/var"; import type { Scope } from "@typescript-eslint/scope-manager"; -import type { TSESTree } from "@typescript-eslint/utils"; import { AST_NODE_TYPES as T } from "@typescript-eslint/types"; import { getAttributeName } from "./jsx-attribute-name"; -/** - * Searches for a specific JSX attribute by name in a list of attributes - * Returns the last matching attribute (rightmost in JSX) - * - * @param context - ESLint rule context - * @param name - The name of the attribute to find - * @param attributes - Array of JSX attributes to search through - * @param initialScope - Optional scope for resolving variables - * @returns The found attribute or undefined - */ -export function getAttribute( - context: RuleContext, - name: string, - attributes: (TSESTree.JSXAttribute | TSESTree.JSXSpreadAttribute)[], - initialScope?: Scope, -): TSESTree.JSXAttribute | TSESTree.JSXSpreadAttribute | unit { - return attributes.findLast((attr) => { - // Case 1: Direct JSX attribute (e.g., className="value") - if (attr.type === T.JSXAttribute) { - return getAttributeName(context, attr) === name; - } - - // For spread attributes, we need a scope to resolve variables - if (initialScope == null) return false; - - switch (attr.argument.type) { - // Case 2: Spread from variable (e.g., {...props}) - case T.Identifier: { - const variable = VAR.findVariable(attr.argument.name, initialScope); - const variableNode = VAR.getVariableDefinitionNode(variable, 0); - if (variableNode?.type === T.ObjectExpression) { - return VAR.findProperty(name, variableNode.properties, initialScope) != null; +export function getAttribute(context: RuleContext, attributes: AST.TSESTreeJSXAttributeLike[], initialScope?: Scope) { + return (name: string) => { + return attributes.findLast((attr) => { + // Case 1: Direct JSX attribute (e.g., className="value") + if (attr.type === T.JSXAttribute) { + return getAttributeName(context, attr) === name; + } + // For spread attributes, we need a scope to resolve variables + if (initialScope == null) return false; + switch (attr.argument.type) { + // Case 2: Spread from variable (e.g., {...props}) + case T.Identifier: { + const variable = VAR.findVariable(attr.argument.name, initialScope); + const variableNode = VAR.getVariableDefinitionNode(variable, 0); + if (variableNode?.type === T.ObjectExpression) { + return VAR.findProperty(name, variableNode.properties, initialScope) != null; + } + return false; } - return false; + // Case 3: Spread from object literal (e.g., {{...{prop: value}}}) + case T.ObjectExpression: + return VAR.findProperty(name, attr.argument.properties, initialScope) != null; } - // Case 3: Spread from object literal (e.g., {{...{prop: value}}}) - case T.ObjectExpression: - return VAR.findProperty(name, attr.argument.properties, initialScope) != null; - } - return false; - }); + return false; + }); + }; } diff --git a/packages/core/src/jsx/jsx-has.ts b/packages/core/src/jsx/jsx-has.ts index 087572c06a..d26cd8940a 100644 --- a/packages/core/src/jsx/jsx-has.ts +++ b/packages/core/src/jsx/jsx-has.ts @@ -18,7 +18,7 @@ export function hasAttribute( attributes: TSESTree.JSXOpeningElement["attributes"], initialScope?: Scope, ) { - return getAttribute(context, name, attributes, initialScope) != null; + return getAttribute(context, attributes, initialScope)(name) != null; } /** diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-dangerously-set-innerhtml.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/no-dangerously-set-innerhtml.ts index de4b99272c..77b47d98ac 100644 --- a/packages/plugins/eslint-plugin-react-dom/src/rules/no-dangerously-set-innerhtml.ts +++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-dangerously-set-innerhtml.ts @@ -35,12 +35,8 @@ export function create(context: RuleContext): RuleListener { if (!context.sourceCode.text.includes(dangerouslySetInnerHTML)) return {}; return { JSXElement(node) { - const attribute = ER.getAttribute( - context, - dangerouslySetInnerHTML, - node.openingElement.attributes, - context.sourceCode.getScope(node), - ); + const getAttribute = ER.getAttribute(context, node.openingElement.attributes, context.sourceCode.getScope(node)); + const attribute = getAttribute(dangerouslySetInnerHTML); if (attribute == null) return; context.report({ messageId: "noDangerouslySetInnerhtml", diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-button-type.spec.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-button-type.spec.ts index dff69a291d..6f2cb71083 100644 --- a/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-button-type.spec.ts +++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-button-type.spec.ts @@ -136,29 +136,5 @@ ruleTester.run(RULE_NAME, rule, { return ; } `, - { - code: tsx` - function App() { - return ; - } - `, - settings: { - "react-x": { - additionalComponents: [ - { - name: "Button", - as: "button", - attributes: [ - { - name: "type", - as: "type", - defaultValue: "button", - }, - ], - }, - ], - }, - }, - }, ], }); diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-button-type.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-button-type.ts index eb2040fab8..786d63eb0c 100644 --- a/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-button-type.ts +++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-button-type.ts @@ -1,7 +1,9 @@ +import * as ER from "@eslint-react/core"; import type { RuleContext, RuleFeature, RuleSuggest } from "@eslint-react/kit"; import type { RuleFixer, RuleListener } from "@typescript-eslint/utils/ts-eslint"; import type { CamelCase } from "string-ts"; -import { createJsxElementResolver, createRule, resolveAttribute } from "../utils"; + +import { createJsxElementResolver, createRule } from "../utils"; export const RULE_NAME = "no-missing-button-type"; @@ -38,26 +40,26 @@ export function create(context: RuleContext): RuleListener { const resolver = createJsxElementResolver(context); return { JSXElement(node) { - const { attributes, domElementType } = resolver.resolve(node); + const { domElementType } = resolver.resolve(node); if (domElementType !== "button") return; - const typeAttribute = resolveAttribute(context, attributes, node, "type"); - if (typeAttribute.attributeValueString != null) return; - if (typeAttribute.attribute == null) { + const getAttribute = ER.getAttribute(context, node.openingElement.attributes, context.sourceCode.getScope(node)); + const typeAttribute = getAttribute("type"); + if (typeAttribute == null) { context.report({ messageId: "noMissingButtonType", node: node.openingElement, suggest: getSuggest((type) => (fixer: RuleFixer) => { - return fixer.insertTextAfter(node.openingElement.name, ` ${typeAttribute.attributeName}="${type}"`); + return fixer.insertTextAfter(node.openingElement.name, ` type="${type}"`); }), }); return; } + if (typeof ER.resolveAttributeValue(context, typeAttribute).toStatic("type") === "string") return; context.report({ messageId: "noMissingButtonType", - node: typeAttribute.attributeValue?.node ?? typeAttribute.attribute, + node: typeAttribute, suggest: getSuggest((type) => (fixer: RuleFixer) => { - if (typeAttribute.attribute == null) return null; - return fixer.replaceText(typeAttribute.attribute, `${typeAttribute.attributeName}="${type}"`); + return fixer.replaceText(typeAttribute, `type="${type}"`); }), }); }, diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-iframe-sandbox.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-iframe-sandbox.ts index ae25b901db..6ed80d904b 100644 --- a/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-iframe-sandbox.ts +++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-iframe-sandbox.ts @@ -1,8 +1,9 @@ +import * as ER from "@eslint-react/core"; import type { RuleContext, RuleFeature } from "@eslint-react/kit"; import type { RuleListener } from "@typescript-eslint/utils/ts-eslint"; import type { CamelCase } from "string-ts"; -import { createJsxElementResolver, createRule, resolveAttribute } from "../utils"; +import { createJsxElementResolver, createRule } from "../utils"; export const RULE_NAME = "no-missing-iframe-sandbox"; @@ -38,11 +39,11 @@ export function create(context: RuleContext): RuleListener { const resolver = createJsxElementResolver(context); return { JSXElement(node) { - const { attributes, domElementType } = resolver.resolve(node); + const { domElementType } = resolver.resolve(node); if (domElementType !== "iframe") return; - const sandboxAttribute = resolveAttribute(context, attributes, node, "sandbox"); - if (sandboxAttribute.attributeValueString != null) return; - if (sandboxAttribute.attribute == null) { + const getAttribute = ER.getAttribute(context, node.openingElement.attributes, context.sourceCode.getScope(node)); + const sandboxAttribute = getAttribute("sandbox"); + if (sandboxAttribute == null) { context.report({ messageId: "noMissingIframeSandbox", node: node.openingElement, @@ -50,23 +51,27 @@ export function create(context: RuleContext): RuleListener { messageId: "addIframeSandbox", data: { value: "" }, fix(fixer) { - return fixer.insertTextAfter(node.openingElement.name, ` ${sandboxAttribute.attributeName}=""`); + return fixer.insertTextAfter(node.openingElement.name, ` sandbox=""`); }, }], }); return; } + const sandboxAttributeValue = ER.resolveAttributeValue(context, sandboxAttribute); + if (typeof sandboxAttributeValue.toStatic("sandbox") === "string") return; context.report({ messageId: "noMissingIframeSandbox", - node: sandboxAttribute.attributeValue?.node ?? sandboxAttribute.attribute, - suggest: [{ - messageId: "addIframeSandbox", - data: { value: "" }, - fix(fixer) { - if (sandboxAttribute.attribute == null) return null; - return fixer.replaceText(sandboxAttribute.attribute, `${sandboxAttribute.attributeName}=""`); + node: sandboxAttributeValue.node ?? sandboxAttribute, + suggest: [ + { + messageId: "addIframeSandbox", + data: { value: "" }, + fix(fixer) { + if (sandboxAttributeValue.kind.startsWith("spread")) return null; + return fixer.replaceText(sandboxAttribute, `sandbox=""`); + }, }, - }], + ], }); }, }; diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-script-url.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/no-script-url.ts index 6751d418b7..ce918515d6 100644 --- a/packages/plugins/eslint-plugin-react-dom/src/rules/no-script-url.ts +++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-script-url.ts @@ -13,10 +13,6 @@ export const RULE_FEATURES = [] as const satisfies RuleFeature[]; export type MessageID = CamelCase; -/** - * This rule is adapted from eslint-plugin-solid's jsx-no-script-url rule under the MIT license. - * Thank you for your work! - */ export default createRule<[], MessageID>({ meta: { type: "problem", @@ -37,12 +33,9 @@ export default createRule<[], MessageID>({ export function create(context: RuleContext): RuleListener { return { JSXAttribute(node) { - if (node.name.type !== T.JSXIdentifier || node.value == null) { - return; - } - const attributeValue = ER.getAttributeValue(context, node, ER.getAttributeName(context, node)); - if (attributeValue.kind === "none" || typeof attributeValue.value !== "string") return; - if (RE.JAVASCRIPT_PROTOCOL.test(attributeValue.value)) { + if (node.name.type !== T.JSXIdentifier || node.value == null) return; + const value = ER.resolveAttributeValue(context, node).toStatic(); + if (typeof value === "string" && RE.JAVASCRIPT_PROTOCOL.test(value)) { context.report({ messageId: "noScriptUrl", node: node.value, diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-string-style-prop.spec.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/no-string-style-prop.spec.ts index 6e3a14c649..1800f3c029 100644 --- a/packages/plugins/eslint-plugin-react-dom/src/rules/no-string-style-prop.spec.ts +++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-string-style-prop.spec.ts @@ -54,24 +54,5 @@ ruleTester.run(RULE_NAME, rule, { return
; } `, - { - // https://github.com/Rel1cx/eslint-react/issues/1217 - code: tsx` - const a = ; - `, - settings: { - "react-x": { - additionalComponents: [ - { - name: "StatusBar", - attributes: [ - // inform that the style attribute on StatusBar is not an intrinsic attribute but a custom one - { name: "", as: "style" }, - ], - }, - ], - }, - }, - }, ], }); diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-string-style-prop.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/no-string-style-prop.ts index d9a36db3d1..fb197beccc 100644 --- a/packages/plugins/eslint-plugin-react-dom/src/rules/no-string-style-prop.ts +++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-string-style-prop.ts @@ -1,8 +1,9 @@ +import * as ER from "@eslint-react/core"; import type { RuleContext, RuleFeature } from "@eslint-react/kit"; import type { RuleListener } from "@typescript-eslint/utils/ts-eslint"; import type { CamelCase } from "string-ts"; -import { createJsxElementResolver, createRule, resolveAttribute } from "../utils"; +import { createRule } from "../utils"; export const RULE_NAME = "no-string-style-prop"; @@ -28,23 +29,18 @@ export default createRule<[], MessageID>({ }); export function create(context: RuleContext): RuleListener { - const resolver = createJsxElementResolver(context); return { JSXElement(node) { - const { attributes } = resolver.resolve(node); - const { - attribute, - attributeName, - attributeValue, - attributeValueString, - } = resolveAttribute(context, attributes, node, "style"); - if (attributeName !== "style") return; - if (attribute == null || attributeValue?.node == null) return; - if (attributeValueString == null) return; - context.report({ - messageId: "noStringStyleProp", - node: attributeValue.node, - }); + const getAttribute = ER.getAttribute(context, node.openingElement.attributes, context.sourceCode.getScope(node)); + const attribute = getAttribute("style"); + if (attribute == null) return; + const attributeValue = ER.resolveAttributeValue(context, attribute); + if (typeof attributeValue.toStatic() === "string") { + context.report({ + messageId: "noStringStyleProp", + node: attributeValue.node ?? attribute, + }); + } }, }; } diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-target-blank.spec.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-target-blank.spec.ts index dd65d38512..62c1fab92a 100644 --- a/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-target-blank.spec.ts +++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-target-blank.spec.ts @@ -107,230 +107,230 @@ ruleTester.run(RULE_NAME, rule, { }, }, }, - { - code: '', - errors: [ - { - messageId: "noUnsafeTargetBlank", - suggestions: [ - { - messageId: "addRelNoreferrerNoopener", - output: '', - }, - ], - }, - ], - settings: { - "react-x": { - additionalComponents: [ - { - name: "Link", - as: "a", - }, - ], - }, - }, - }, - { - code: '', - errors: [ - { - messageId: "noUnsafeTargetBlank", - suggestions: [ - { - messageId: "addRelNoreferrerNoopener", - output: '', - }, - ], - }, - ], - settings: { - "react-x": { - additionalComponents: [ - { - name: "Link", - as: "a", - }, - ], - }, - }, - }, - { - code: tsx` - const a = ; - const b = ; - `, - errors: [ - { - messageId: "noUnsafeTargetBlank", - suggestions: [ - { - messageId: "addRelNoreferrerNoopener", - output: tsx` - const a = ; - const b = ; - `, - }, - ], - }, - ], // should be 1 error - settings: { - "react-x": { - additionalComponents: [ - { - name: "Link", - as: "a", - attributes: [ - { - name: "to", - as: "href", - }, - { - name: "rel", - defaultValue: "noreferrer", - }, - ], - }, - ], - }, - }, - }, - { - code: tsx` - const a = ; - const b = ; - `, - errors: [ - { - messageId: "noUnsafeTargetBlank", - suggestions: [ - { - messageId: "addRelNoreferrerNoopener", - output: tsx` - const a = ; - const b = ; - `, - }, - ], - }, - { - messageId: "noUnsafeTargetBlank", - suggestions: [ - { - messageId: "addRelNoreferrerNoopener", - output: tsx` - const a = ; - const b = ; - `, - }, - ], - }, - ], - settings: { - "react-x": { - additionalComponents: [ - { - name: "Link", - as: "a", - attributes: [{ - name: "rel", - defaultValue: "noopener", - }], - }, - { - name: "LinkButton", - as: "a", - attributes: [ - { - name: "relation", - as: "rel", - defaultValue: "noreferrer", - }, - ], - }, - ], - }, - }, - }, - { - code: tsx` - const a = ; - const b = ; - `, - errors: [ - { - messageId: "noUnsafeTargetBlank", - suggestions: [ - { - messageId: "addRelNoreferrerNoopener", - output: tsx` - const a = ; - const b = ; - `, - }, - ], - }, - ], - settings: { - "react-x": { - additionalComponents: [ - { - name: "Link", - as: "a", - attributes: [{ - name: "rel", - defaultValue: "noreferrer", - }], - }, - { - name: "LinkButton", - as: "a", - attributes: [ - { - name: "relation", - as: "rel", - defaultValue: "noreferrer", - }, - ], - }, - ], - }, - }, - }, - { - code: tsx` - const a = ; - `, - errors: [ - { - messageId: "noUnsafeTargetBlank", - suggestions: [ - { - messageId: "addRelNoreferrerNoopener", - output: tsx` - const a = ; - `, - }, - ], - }, - ], - settings: { - "react-x": { - additionalComponents: [ - { - name: "Link", - as: "a", - attributes: [ - { - name: "target", - defaultValue: "_blank", - }, - ], - }, - ], - }, - }, - }, + // { + // code: '', + // errors: [ + // { + // messageId: "noUnsafeTargetBlank", + // suggestions: [ + // { + // messageId: "addRelNoreferrerNoopener", + // output: '', + // }, + // ], + // }, + // ], + // settings: { + // "react-x": { + // additionalComponents: [ + // { + // name: "Link", + // as: "a", + // }, + // ], + // }, + // }, + // }, + // { + // code: '', + // errors: [ + // { + // messageId: "noUnsafeTargetBlank", + // suggestions: [ + // { + // messageId: "addRelNoreferrerNoopener", + // output: '', + // }, + // ], + // }, + // ], + // settings: { + // "react-x": { + // additionalComponents: [ + // { + // name: "Link", + // as: "a", + // }, + // ], + // }, + // }, + // }, + // { + // code: tsx` + // const a = ; + // const b = ; + // `, + // errors: [ + // { + // messageId: "noUnsafeTargetBlank", + // suggestions: [ + // { + // messageId: "addRelNoreferrerNoopener", + // output: tsx` + // const a = ; + // const b = ; + // `, + // }, + // ], + // }, + // ], // should be 1 error + // settings: { + // "react-x": { + // additionalComponents: [ + // { + // name: "Link", + // as: "a", + // attributes: [ + // { + // name: "to", + // as: "href", + // }, + // { + // name: "rel", + // defaultValue: "noreferrer", + // }, + // ], + // }, + // ], + // }, + // }, + // }, + // { + // code: tsx` + // const a = ; + // const b = ; + // `, + // errors: [ + // { + // messageId: "noUnsafeTargetBlank", + // suggestions: [ + // { + // messageId: "addRelNoreferrerNoopener", + // output: tsx` + // const a = ; + // const b = ; + // `, + // }, + // ], + // }, + // { + // messageId: "noUnsafeTargetBlank", + // suggestions: [ + // { + // messageId: "addRelNoreferrerNoopener", + // output: tsx` + // const a = ; + // const b = ; + // `, + // }, + // ], + // }, + // ], + // settings: { + // "react-x": { + // additionalComponents: [ + // { + // name: "Link", + // as: "a", + // attributes: [{ + // name: "rel", + // defaultValue: "noopener", + // }], + // }, + // { + // name: "LinkButton", + // as: "a", + // attributes: [ + // { + // name: "relation", + // as: "rel", + // defaultValue: "noreferrer", + // }, + // ], + // }, + // ], + // }, + // }, + // }, + // { + // code: tsx` + // const a = ; + // const b = ; + // `, + // errors: [ + // { + // messageId: "noUnsafeTargetBlank", + // suggestions: [ + // { + // messageId: "addRelNoreferrerNoopener", + // output: tsx` + // const a = ; + // const b = ; + // `, + // }, + // ], + // }, + // ], + // settings: { + // "react-x": { + // additionalComponents: [ + // { + // name: "Link", + // as: "a", + // attributes: [{ + // name: "rel", + // defaultValue: "noreferrer", + // }], + // }, + // { + // name: "LinkButton", + // as: "a", + // attributes: [ + // { + // name: "relation", + // as: "rel", + // defaultValue: "noreferrer", + // }, + // ], + // }, + // ], + // }, + // }, + // }, + // { + // code: tsx` + // const a = ; + // `, + // errors: [ + // { + // messageId: "noUnsafeTargetBlank", + // suggestions: [ + // { + // messageId: "addRelNoreferrerNoopener", + // output: tsx` + // const a = ; + // `, + // }, + // ], + // }, + // ], + // settings: { + // "react-x": { + // additionalComponents: [ + // { + // name: "Link", + // as: "a", + // attributes: [ + // { + // name: "target", + // defaultValue: "_blank", + // }, + // ], + // }, + // ], + // }, + // }, + // }, ], valid: [ ...allValid, @@ -369,20 +369,20 @@ ruleTester.run(RULE_NAME, rule, { }, }, }, - { - code: '', - settings: { - "react-x": { - additionalComponents: [ - { - name: "LinkButton", - as: "a", - }, - ], - polymorphicPropName: "as", - }, - }, - }, + // { + // code: '', + // settings: { + // "react-x": { + // additionalComponents: [ + // { + // name: "LinkButton", + // as: "a", + // }, + // ], + // polymorphicPropName: "as", + // }, + // }, + // }, { code: '', settings: { @@ -391,143 +391,143 @@ ruleTester.run(RULE_NAME, rule, { }, }, }, - { - code: '', - settings: { - "react-x": { - polymorphicPropName: "component", - }, - }, - }, - { - code: '', - settings: { - "react-x": { - additionalComponents: [ - { - name: "LinkButton", - as: "a", - attributes: [{ - name: "rel", - defaultValue: "noreferrer", - }], - }, - ], - }, - }, - }, - { - code: '', - settings: { - "react-x": { - additionalComponents: [ - { - name: "LinkButton", - as: "a", - attributes: [{ - name: "rel", - defaultValue: "noreferrer", - }], - }, - { - name: "LinkButton", - as: "a", - attributes: [ - { - name: "to", - as: "href", - defaultValue: "", - }, - { - name: "rel", - defaultValue: "noreferrer", - }, - ], - }, - ], - }, - }, - }, - { - code: '', - settings: { - "react-x": { - additionalComponents: [ - { - name: "LinkButton", - as: "a", - attributes: [ - { - name: "to", - as: "href", - defaultValue: "noreferrer", - }, - { - name: "rel", - defaultValue: "noreferrer", - }, - ], - }, - ], - }, - }, - }, - { - code: tsx` - const a = ; - const b = ; - `, - settings: { - "react-x": { - additionalComponents: [ - { - name: "Link", - as: "a", - attributes: [{ - name: "rel", - defaultValue: "noreferrer noopener", - }], - }, - { - name: "LinkButton", - as: "a", - attributes: [ - { - name: "relation", - as: "rel", - defaultValue: "noreferrer noopener", - }, - ], - }, - ], - }, - }, - }, - { - code: tsx` - const a = ; - `, - settings: { - "react-x": { - additionalComponents: [ - { - name: "Link", - as: "a", - attributes: [ - { - name: "target", - defaultValue: "_blank", - }, - { - name: "rel", - defaultValue: "noreferrer", - }, - ], - }, - ], - }, - }, - }, + // { + // code: '', + // settings: { + // "react-x": { + // polymorphicPropName: "component", + // }, + // }, + // }, + // { + // code: '', + // settings: { + // "react-x": { + // additionalComponents: [ + // { + // name: "LinkButton", + // as: "a", + // attributes: [{ + // name: "rel", + // defaultValue: "noreferrer", + // }], + // }, + // ], + // }, + // }, + // }, + // { + // code: '', + // settings: { + // "react-x": { + // additionalComponents: [ + // { + // name: "LinkButton", + // as: "a", + // attributes: [{ + // name: "rel", + // defaultValue: "noreferrer", + // }], + // }, + // { + // name: "LinkButton", + // as: "a", + // attributes: [ + // { + // name: "to", + // as: "href", + // defaultValue: "", + // }, + // { + // name: "rel", + // defaultValue: "noreferrer", + // }, + // ], + // }, + // ], + // }, + // }, + // }, + // { + // code: '', + // settings: { + // "react-x": { + // additionalComponents: [ + // { + // name: "LinkButton", + // as: "a", + // attributes: [ + // { + // name: "to", + // as: "href", + // defaultValue: "noreferrer", + // }, + // { + // name: "rel", + // defaultValue: "noreferrer", + // }, + // ], + // }, + // ], + // }, + // }, + // }, + // { + // code: tsx` + // const a = ; + // const b = ; + // `, + // settings: { + // "react-x": { + // additionalComponents: [ + // { + // name: "Link", + // as: "a", + // attributes: [{ + // name: "rel", + // defaultValue: "noreferrer noopener", + // }], + // }, + // { + // name: "LinkButton", + // as: "a", + // attributes: [ + // { + // name: "relation", + // as: "rel", + // defaultValue: "noreferrer noopener", + // }, + // ], + // }, + // ], + // }, + // }, + // }, + // { + // code: tsx` + // const a = ; + // `, + // settings: { + // "react-x": { + // additionalComponents: [ + // { + // name: "Link", + // as: "a", + // attributes: [ + // { + // name: "target", + // defaultValue: "_blank", + // }, + // { + // name: "rel", + // defaultValue: "noreferrer", + // }, + // ], + // }, + // ], + // }, + // }, + // }, // TODO: Implement this // { // code: tsx` diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-target-blank.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-target-blank.ts index fa4d5b4967..db2e7a556d 100644 --- a/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-target-blank.ts +++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-target-blank.ts @@ -1,11 +1,10 @@ -import type { unit } from "@eslint-react/eff"; +import * as ER from "@eslint-react/core"; import type { RuleContext, RuleFeature } from "@eslint-react/kit"; import type { TSESTree } from "@typescript-eslint/types"; - import type { RuleListener } from "@typescript-eslint/utils/ts-eslint"; - import type { CamelCase } from "string-ts"; -import { createJsxElementResolver, createRule, resolveAttribute } from "../utils"; + +import { createJsxElementResolver, createRule } from "../utils"; export const RULE_NAME = "no-unsafe-target-blank"; @@ -17,16 +16,28 @@ export type MessageID = CamelCase | RuleSuggestMessageID; export type RuleSuggestMessageID = "addRelNoreferrerNoopener"; -function isExternalLinkLike(value: string | unit) { - if (value == null) return false; - return value.startsWith("https://") - || /^(?:\w+:|\/\/)/u.test(value); +/** + * Checks if a value appears to be an external link. + * External links typically start with http(s):// or have protocol-relative format. + * @param value - The value to check + * @returns Whether the value represents an external link + */ +function isExternalLinkLike(value: unknown): boolean { + if (typeof value !== "string") return false; + + return value.startsWith("https://") || /^(?:\w+:|\/\/)/u.test(value); } -function isSafeRel(value: string | unit) { - if (value == null) return false; - return value === "noreferrer" - || /\bnoreferrer\b/u.test(value); +/** + * Checks if a rel attribute value contains the necessary security attributes. + * At minimum, it should contain "noreferrer". + * @param value - The rel attribute value to check + * @returns Whether the rel value is considered secure + */ +function isSafeRel(value: unknown): boolean { + if (typeof value !== "string") return false; + + return value === "noreferrer" || /\bnoreferrer\b/u.test(value); } export default createRule<[], MessageID>({ @@ -52,23 +63,39 @@ export default createRule<[], MessageID>({ export function create(context: RuleContext): RuleListener { const resolver = createJsxElementResolver(context); + return { JSXElement(node: TSESTree.JSXElement) { - const { attributes, domElementType } = resolver.resolve(node); + // Only process anchor tags () + const { domElementType } = resolver.resolve(node); if (domElementType !== "a") return; - const targetAttribute = resolveAttribute(context, attributes, node, "target"); - if (targetAttribute.attributeValueString !== "_blank") { - return; - } - const hrefAttribute = resolveAttribute(context, attributes, node, "href"); - if (!isExternalLinkLike(hrefAttribute.attributeValueString)) { - return; - } - const relAttribute = resolveAttribute(context, attributes, node, "rel"); - if (isSafeRel(relAttribute.attributeValueString)) { - return; - } - if (relAttribute.attribute == null) { + + // Get access to the component attributes + const getAttributes = ER.getAttribute( + context, + node.openingElement.attributes, + context.sourceCode.getScope(node), + ); + + // Check if target="_blank" is present + const targetAttribute = getAttributes("target"); + if (targetAttribute == null) return; + + const targetAttributeValue = ER.resolveAttributeValue(context, targetAttribute).toStatic("target"); + if (targetAttributeValue !== "_blank") return; + + // Check if href points to an external resource + const hrefAttribute = getAttributes("href"); + if (hrefAttribute == null) return; + + const hrefAttributeValue = ER.resolveAttributeValue(context, hrefAttribute).toStatic("href"); + if (!isExternalLinkLike(hrefAttributeValue)) return; + + // Check if rel attribute exists and is secure + const relAttribute = getAttributes("rel"); + + // No rel attribute case - suggest adding one + if (relAttribute == null) { context.report({ messageId: "noUnsafeTargetBlank", node: node.openingElement, @@ -77,21 +104,26 @@ export function create(context: RuleContext): RuleListener { fix(fixer) { return fixer.insertTextAfter( node.openingElement.name, - ` ${relAttribute.attributeName}="noreferrer noopener"`, + ` rel="noreferrer noopener"`, ); }, }], }); return; } + + // Check if existing rel attribute is secure + const relAttributeValue = ER.resolveAttributeValue(context, relAttribute).toStatic("rel"); + if (isSafeRel(relAttributeValue)) return; + + // Existing rel attribute is not secure - suggest replacing it context.report({ messageId: "noUnsafeTargetBlank", - node: relAttribute.attributeValue?.node ?? relAttribute.attribute, + node: relAttribute, suggest: [{ messageId: "addRelNoreferrerNoopener", fix(fixer) { - if (relAttribute.attribute == null) return null; - return fixer.replaceText(relAttribute.attribute, `${relAttribute.attributeName}="noreferrer noopener"`); + return fixer.replaceText(relAttribute, `rel="noreferrer noopener"`); }, }], }); diff --git a/packages/plugins/eslint-plugin-react-dom/src/utils/create-jsx-element-resolver.ts b/packages/plugins/eslint-plugin-react-dom/src/utils/create-jsx-element-resolver.ts index 318fb5ae88..de67678773 100644 --- a/packages/plugins/eslint-plugin-react-dom/src/utils/create-jsx-element-resolver.ts +++ b/packages/plugins/eslint-plugin-react-dom/src/utils/create-jsx-element-resolver.ts @@ -5,37 +5,28 @@ import * as ER from "@eslint-react/core"; import { getSettingsFromContext } from "@eslint-react/shared"; export function createJsxElementResolver(context: RuleContext) { - const { components, polymorphicPropName } = getSettingsFromContext(context); + const { components, polymorphicPropName: polyPropName } = getSettingsFromContext(context); return { resolve(node: TSESTree.JSXElement) { const name = ER.getElementType(context, node); const component = components .findLast((c) => c.name === name || c.re.test(name)); const result = { - attributes: component?.attributes ?? [], domElementType: component?.as ?? name, jsxElementType: name, }; - if (name === name.toLowerCase() || component != null || polymorphicPropName == null) { + if (name === name.toLowerCase() || component != null || polyPropName == null) { return result; } const initialScope = context.sourceCode.getScope(node); - const polymorphicPropAttr = ER.getAttribute( - context, - polymorphicPropName, - node.openingElement.attributes, - initialScope, - ); - if (polymorphicPropAttr != null) { - const polymorphicPropValue = ER.getAttributeValue( - context, - polymorphicPropAttr, - polymorphicPropName, - ); - if (polymorphicPropValue.kind === "some" && typeof polymorphicPropValue.value === "string") { + const polyPropAttr = ER.getAttribute(context, node.openingElement.attributes, initialScope)(polyPropName); + if (polyPropAttr != null) { + const polyPropValue = ER.resolveAttributeValue(context, polyPropAttr); + const staticValue = polyPropValue.toStatic(polyPropName); + if (typeof staticValue === "string") { return { ...result, - domElementType: polymorphicPropValue.value, + domElementType: staticValue, }; } } diff --git a/packages/plugins/eslint-plugin-react-dom/src/utils/find-custom-component.ts b/packages/plugins/eslint-plugin-react-dom/src/utils/find-custom-component.ts deleted file mode 100644 index 82c3aa366a..0000000000 --- a/packages/plugins/eslint-plugin-react-dom/src/utils/find-custom-component.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { CustomComponentNormalized, CustomComponentPropNormalized } from "@eslint-react/shared"; - -/** - * Finds a custom component by name from the provided array of components. - * - * @param name - The name of the component to find - * @param components - Array of normalized custom components to search through - * @returns The matching component if found, undefined otherwise - */ -export function findCustomComponent(name: string, components: CustomComponentNormalized[]) { - return components - .findLast((c) => c.name === name || c.re.test(name)); -} - -/** - * Finds a custom component prop by its "as" name. - * - * @param name - The name to match against the prop's "as" property - * @param props - Array of normalized custom component props to search through - * @returns The matching prop if found, undefined otherwise - */ -export function findCustomComponentProp(name: string, props: CustomComponentPropNormalized[]) { - return props - .findLast((a) => a.as === name); -} diff --git a/packages/plugins/eslint-plugin-react-dom/src/utils/index.ts b/packages/plugins/eslint-plugin-react-dom/src/utils/index.ts index 158b76cca8..575feb2dd7 100644 --- a/packages/plugins/eslint-plugin-react-dom/src/utils/index.ts +++ b/packages/plugins/eslint-plugin-react-dom/src/utils/index.ts @@ -1,4 +1,2 @@ export * from "./create-jsx-element-resolver"; export * from "./create-rule"; -export * from "./find-custom-component"; -export * from "./resolve-attribute"; diff --git a/packages/plugins/eslint-plugin-react-dom/src/utils/resolve-attribute.ts b/packages/plugins/eslint-plugin-react-dom/src/utils/resolve-attribute.ts deleted file mode 100644 index 2b14a2d945..0000000000 --- a/packages/plugins/eslint-plugin-react-dom/src/utils/resolve-attribute.ts +++ /dev/null @@ -1,50 +0,0 @@ -import * as ER from "@eslint-react/core"; -import { unit } from "@eslint-react/eff"; -import type { RuleContext } from "@eslint-react/kit"; -import type { CustomComponentPropNormalized } from "@eslint-react/shared"; -import type { TSESTree } from "@typescript-eslint/types"; -import { findCustomComponentProp } from "./find-custom-component"; - -export function resolveAttribute( - context: RuleContext, - attributes: CustomComponentPropNormalized[], - elementNode: TSESTree.JSXElement, - attributeName: string, -): { - attribute: unit | TSESTree.JSXAttribute | TSESTree.JSXSpreadAttribute; - attributeName: string; - attributeValue: unit | ReturnType; - attributeValueString: unit | string; -} { - const customComponentProp = findCustomComponentProp(attributeName, attributes); - const propNameOnJsx = customComponentProp?.name ?? attributeName; - const attribute = ER.getAttribute( - context, - propNameOnJsx, - elementNode.openingElement.attributes, - context.sourceCode.getScope(elementNode), - ); - if (attribute == null) { - return { - attribute: unit, - attributeName: propNameOnJsx, - attributeValue: unit, - attributeValueString: customComponentProp?.defaultValue, - } as const; - } - const attributeValue = ER.getAttributeValue(context, attribute, propNameOnJsx); - if (attributeValue.kind === "some" && typeof attributeValue.value === "string") { - return { - attribute, - attributeName: propNameOnJsx, - attributeValue, - attributeValueString: attributeValue.value, - } as const; - } - return { - attribute, - attributeName: propNameOnJsx, - attributeValue: unit, - attributeValueString: customComponentProp?.defaultValue ?? unit, - } as const; -} diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-implicit-key.ts b/packages/plugins/eslint-plugin-react-x/src/rules/no-implicit-key.ts index 964650eeba..e2d088ffd3 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/no-implicit-key.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-implicit-key.ts @@ -39,7 +39,7 @@ export function create(context: RuleContext): RuleListener { return { JSXOpeningElement(node: TSESTree.JSXOpeningElement) { const initialScope = context.sourceCode.getScope(node); - const keyProp = ER.getAttribute(context, "key", node.attributes, initialScope); + const keyProp = ER.getAttribute(context, node.attributes, initialScope)("key"); const isKeyPropOnElement = node.attributes .some((n) => n.type === T.JSXAttribute diff --git a/packages/utilities/ast/src/node-types.ts b/packages/utilities/ast/src/node-types.ts index 6f0367b6fc..3566d5262a 100644 --- a/packages/utilities/ast/src/node-types.ts +++ b/packages/utilities/ast/src/node-types.ts @@ -55,6 +55,8 @@ export type TSESTreeJSX = | TSESTree.JSXText | TSESTree.JSXTextToken; +export type TSESTreeJSXAttributeLike = TSESTree.JSXAttribute | TSESTree.JSXSpreadAttribute; + export type TSESTreeDestructuringPattern = | TSESTree.ArrayPattern | TSESTree.AssignmentPattern From 9e6662269d4b85f06f308e7b3d3ca25109bd35bf Mon Sep 17 00:00:00 2001 From: Rel1cx Date: Sat, 6 Sep 2025 19:51:50 +0800 Subject: [PATCH 2/6] refactor: rework jsx attribute resolution --- .../src/rules/no-unsafe-iframe-sandbox.ts | 20 ++-- .../src/utils/create-jsx-element-resolver.ts | 58 +++++++-- .../src/rules/no-children-prop.ts | 8 +- packages/plugins/eslint-plugin/README.md | 4 +- packages/shared/docs/README.md | 6 - .../shared/docs/functions/coerceSettings.md | 13 -- .../shared/docs/functions/decodeSettings.md | 13 -- .../docs/functions/isESLintReactSettings.md | 4 +- .../docs/functions/normalizeSettings.md | 17 --- .../interfaces/CustomComponentNormalized.md | 47 -------- .../CustomComponentPropNormalized.md | 27 ----- .../ESLintReactSettingsNormalized.md | 6 - .../docs/type-aliases/CustomComponent.md | 9 -- .../docs/type-aliases/CustomComponentProp.md | 9 -- .../variables/CustomComponentPropSchema.md | 11 -- .../docs/variables/CustomComponentSchema.md | 13 -- .../DEFAULT_ESLINT_REACT_SETTINGS.md | 4 - .../docs/variables/DEFAULT_ESLINT_SETTINGS.md | 4 - packages/shared/src/settings.ts | 111 +----------------- 19 files changed, 69 insertions(+), 315 deletions(-) delete mode 100644 packages/shared/docs/interfaces/CustomComponentNormalized.md delete mode 100644 packages/shared/docs/interfaces/CustomComponentPropNormalized.md delete mode 100644 packages/shared/docs/type-aliases/CustomComponent.md delete mode 100644 packages/shared/docs/type-aliases/CustomComponentProp.md delete mode 100644 packages/shared/docs/variables/CustomComponentPropSchema.md delete mode 100644 packages/shared/docs/variables/CustomComponentSchema.md diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-iframe-sandbox.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-iframe-sandbox.ts index f399edd1c1..b46ded2fdf 100644 --- a/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-iframe-sandbox.ts +++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-iframe-sandbox.ts @@ -1,9 +1,9 @@ -import type { unit } from "@eslint-react/eff"; +import * as ER from "@eslint-react/core"; import type { RuleContext, RuleFeature } from "@eslint-react/kit"; - import type { RuleListener } from "@typescript-eslint/utils/ts-eslint"; import type { CamelCase } from "string-ts"; -import { createJsxElementResolver, createRule, resolveAttribute } from "../utils"; + +import { createJsxElementResolver, createRule } from "../utils"; export const RULE_NAME = "no-unsafe-iframe-sandbox"; @@ -15,7 +15,7 @@ const unsafeSandboxValues = [ ["allow-scripts", "allow-same-origin"], ] as const; -function isSafeSandbox(value: string | unit): value is string { +function isSafeSandbox(value: unknown): value is string { if (typeof value !== "string") return false; return !unsafeSandboxValues.some((values) => { return values.every((v) => value.includes(v)); @@ -43,13 +43,17 @@ export function create(context: RuleContext): RuleListener { const resolver = createJsxElementResolver(context); return { JSXElement(node) { - const { attributes, domElementType } = resolver.resolve(node); + const { domElementType } = resolver.resolve(node); if (domElementType !== "iframe") return; - const sandboxAttribute = resolveAttribute(context, attributes, node, "sandbox"); - if (!isSafeSandbox(sandboxAttribute.attributeValueString)) { + const getAttribute = ER.getAttribute(context, node.openingElement.attributes, context.sourceCode.getScope(node)); + const sandboxAttribute = getAttribute("sandbox"); + if (sandboxAttribute == null) return; + const sandboxValue = ER.resolveAttributeValue(context, sandboxAttribute); + const sandboxValueStatic = sandboxValue.toStatic("sandbox"); + if (!isSafeSandbox(sandboxValueStatic)) { context.report({ messageId: "noUnsafeIframeSandbox", - node: sandboxAttribute.attributeValue?.node ?? sandboxAttribute.attribute ?? node, + node: sandboxValue.node ?? sandboxAttribute, }); } }, diff --git a/packages/plugins/eslint-plugin-react-dom/src/utils/create-jsx-element-resolver.ts b/packages/plugins/eslint-plugin-react-dom/src/utils/create-jsx-element-resolver.ts index de67678773..777f30f5ef 100644 --- a/packages/plugins/eslint-plugin-react-dom/src/utils/create-jsx-element-resolver.ts +++ b/packages/plugins/eslint-plugin-react-dom/src/utils/create-jsx-element-resolver.ts @@ -4,25 +4,58 @@ import type { TSESTree } from "@typescript-eslint/types"; import * as ER from "@eslint-react/core"; import { getSettingsFromContext } from "@eslint-react/shared"; +/** + * Creates a resolver for JSX elements that determines both the JSX element type + * and the underlying DOM element type. + * + * This resolver handles: + * 1. Regular HTML elements (div, span, etc.) + * 2. Polymorphic components (components that can render as different elements via a prop) + * + * @param context - The ESLint rule context + * @returns An object with a resolve method to determine element types + */ export function createJsxElementResolver(context: RuleContext) { - const { components, polymorphicPropName: polyPropName } = getSettingsFromContext(context); + const { polymorphicPropName } = getSettingsFromContext(context); + return { + /** + * Resolves the JSX element to determine its type and the underlying DOM element type + * + * @param node - The JSX element node to resolve + * @returns An object containing the JSX element type and DOM element type + */ resolve(node: TSESTree.JSXElement) { - const name = ER.getElementType(context, node); - const component = components - .findLast((c) => c.name === name || c.re.test(name)); + // Get the element name/type (e.g., 'div', 'Button', etc.) + const elementName = ER.getElementType(context, node); + + // // Find if there's a matching component defined in settings + // const matchingComponent = components + // .findLast((component) => component.name === elementName || component.re.test(elementName)); + + // Create the base result with element types const result = { - domElementType: component?.as ?? name, - jsxElementType: name, + domElementType: elementName, + jsxElementType: elementName, }; - if (name === name.toLowerCase() || component != null || polyPropName == null) { + + // Early return if any of these conditions are met: + // 1. It's a native HTML element (lowercase name) + // 2. No polymorphic prop name is configured + if (elementName === elementName.toLowerCase() || polymorphicPropName == null) { return result; } - const initialScope = context.sourceCode.getScope(node); - const polyPropAttr = ER.getAttribute(context, node.openingElement.attributes, initialScope)(polyPropName); - if (polyPropAttr != null) { - const polyPropValue = ER.resolveAttributeValue(context, polyPropAttr); - const staticValue = polyPropValue.toStatic(polyPropName); + + // Look for the polymorphic prop (e.g., 'as', 'component') in the element's attributes + const getAttribute = ER.getAttribute(context, node.openingElement.attributes, context.sourceCode.getScope(node)); + const polymorphicProp = getAttribute(polymorphicPropName); + + // If the polymorphic prop exists, try to determine its static value + if (polymorphicProp != null) { + const polymorphicPropValue = ER.resolveAttributeValue(context, polymorphicProp); + const staticValue = polymorphicPropValue.toStatic(polymorphicPropName); + + // If we have a string value, use it as the DOM element type if (typeof staticValue === "string") { return { ...result, @@ -30,6 +63,7 @@ export function createJsxElementResolver(context: RuleContext) { }; } } + return result; }, } as const; diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-children-prop.ts b/packages/plugins/eslint-plugin-react-x/src/rules/no-children-prop.ts index b0f1934aa1..4f76746580 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/no-children-prop.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-children-prop.ts @@ -31,8 +31,12 @@ export default createRule<[], MessageID>({ export function create(context: RuleContext): RuleListener { return { JSXElement(node) { - const attributes = node.openingElement.attributes; - const childrenProp = ER.getAttribute(context, "children", attributes, context.sourceCode.getScope(node)); + const getAttribute = ER.getAttribute( + context, + node.openingElement.attributes, + context.sourceCode.getScope(node), + ); + const childrenProp = getAttribute("children"); if (childrenProp != null) { context.report({ messageId: "noChildrenProp", diff --git a/packages/plugins/eslint-plugin/README.md b/packages/plugins/eslint-plugin/README.md index b6a0d3adab..438d13e689 100644 --- a/packages/plugins/eslint-plugin/README.md +++ b/packages/plugins/eslint-plugin/README.md @@ -167,8 +167,8 @@ export default defineConfig([ Contributions are welcome! -Please follow our [contributing guidelines](https://github.com/Rel1cx/eslint-react/tree/2.0.0/.github/CONTRIBUTING.md). +Please follow our [contributing guidelines](https://github.com/Rel1cx/eslint-react/tree/refactor/jsx-attribute-resolution/.github/CONTRIBUTING.md). ## License -This project is licensed under the MIT License - see the [LICENSE](https://github.com/Rel1cx/eslint-react/tree/2.0.0/LICENSE) file for details. +This project is licensed under the MIT License - see the [LICENSE](https://github.com/Rel1cx/eslint-react/tree/refactor/jsx-attribute-resolution/LICENSE) file for details. diff --git a/packages/shared/docs/README.md b/packages/shared/docs/README.md index cdfc4e72c3..d810e773c3 100644 --- a/packages/shared/docs/README.md +++ b/packages/shared/docs/README.md @@ -6,22 +6,16 @@ ## Interfaces -- [CustomComponentNormalized](interfaces/CustomComponentNormalized.md) -- [CustomComponentPropNormalized](interfaces/CustomComponentPropNormalized.md) - [ESLintReactSettingsNormalized](interfaces/ESLintReactSettingsNormalized.md) ## Type Aliases -- [CustomComponent](type-aliases/CustomComponent.md) -- [CustomComponentProp](type-aliases/CustomComponentProp.md) - [CustomHooks](type-aliases/CustomHooks.md) - [ESLintReactSettings](type-aliases/ESLintReactSettings.md) - [ESLintSettings](type-aliases/ESLintSettings.md) ## Variables -- [CustomComponentPropSchema](variables/CustomComponentPropSchema.md) -- [CustomComponentSchema](variables/CustomComponentSchema.md) - [CustomHooksSchema](variables/CustomHooksSchema.md) - [DEFAULT\_ESLINT\_REACT\_SETTINGS](variables/DEFAULT_ESLINT_REACT_SETTINGS.md) - [DEFAULT\_ESLINT\_SETTINGS](variables/DEFAULT_ESLINT_SETTINGS.md) diff --git a/packages/shared/docs/functions/coerceSettings.md b/packages/shared/docs/functions/coerceSettings.md index a38315091a..0adaf0c137 100644 --- a/packages/shared/docs/functions/coerceSettings.md +++ b/packages/shared/docs/functions/coerceSettings.md @@ -20,19 +20,6 @@ The settings object to coerce ## Returns -### additionalComponents? - -> `optional` **additionalComponents**: `object`[] - -User-defined components configuration -Informs ESLint React how to treat these components during validation - -#### Example - -```ts -[{ name: "Link", as: "a", attributes: [{ name: "to", as: "href" }] }] -``` - ### additionalHooks? > `optional` **additionalHooks**: `object` diff --git a/packages/shared/docs/functions/decodeSettings.md b/packages/shared/docs/functions/decodeSettings.md index 5ba45eb3e8..f95a6dc201 100644 --- a/packages/shared/docs/functions/decodeSettings.md +++ b/packages/shared/docs/functions/decodeSettings.md @@ -20,19 +20,6 @@ The settings object to decode ## Returns -### additionalComponents? - -> `optional` **additionalComponents**: `object`[] - -User-defined components configuration -Informs ESLint React how to treat these components during validation - -#### Example - -```ts -[{ name: "Link", as: "a", attributes: [{ name: "to", as: "href" }] }] -``` - ### additionalHooks? > `optional` **additionalHooks**: `object` diff --git a/packages/shared/docs/functions/isESLintReactSettings.md b/packages/shared/docs/functions/isESLintReactSettings.md index 5d52472297..a160cd5774 100644 --- a/packages/shared/docs/functions/isESLintReactSettings.md +++ b/packages/shared/docs/functions/isESLintReactSettings.md @@ -6,7 +6,7 @@ # Function: isESLintReactSettings() -> **isESLintReactSettings**(`settings`): `settings is { additionalComponents?: { as?: string; attributes?: { as?: string; defaultValue?: string; name: string }[]; name: string }[]; additionalHooks?: { use?: string[]; useActionState?: string[]; useCallback?: string[]; useContext?: string[]; useDebugValue?: string[]; useDeferredValue?: string[]; useEffect?: string[]; useFormStatus?: string[]; useId?: string[]; useImperativeHandle?: string[]; useInsertionEffect?: string[]; useLayoutEffect?: string[]; useMemo?: string[]; useOptimistic?: string[]; useReducer?: string[]; useRef?: string[]; useState?: string[]; useSyncExternalStore?: string[]; useTransition?: string[] }; importSource?: string; polymorphicPropName?: string; version?: string }` +> **isESLintReactSettings**(`settings`): `settings is { additionalHooks?: { use?: string[]; useActionState?: string[]; useCallback?: string[]; useContext?: string[]; useDebugValue?: string[]; useDeferredValue?: string[]; useEffect?: string[]; useFormStatus?: string[]; useId?: string[]; useImperativeHandle?: string[]; useInsertionEffect?: string[]; useLayoutEffect?: string[]; useMemo?: string[]; useOptimistic?: string[]; useReducer?: string[]; useRef?: string[]; useState?: string[]; useSyncExternalStore?: string[]; useTransition?: string[] }; importSource?: string; polymorphicPropName?: string; version?: string }` Checks if the provided settings conform to ESLintReactSettings schema @@ -20,4 +20,4 @@ The settings object to validate ## Returns -`settings is { additionalComponents?: { as?: string; attributes?: { as?: string; defaultValue?: string; name: string }[]; name: string }[]; additionalHooks?: { use?: string[]; useActionState?: string[]; useCallback?: string[]; useContext?: string[]; useDebugValue?: string[]; useDeferredValue?: string[]; useEffect?: string[]; useFormStatus?: string[]; useId?: string[]; useImperativeHandle?: string[]; useInsertionEffect?: string[]; useLayoutEffect?: string[]; useMemo?: string[]; useOptimistic?: string[]; useReducer?: string[]; useRef?: string[]; useState?: string[]; useSyncExternalStore?: string[]; useTransition?: string[] }; importSource?: string; polymorphicPropName?: string; version?: string }` +`settings is { additionalHooks?: { use?: string[]; useActionState?: string[]; useCallback?: string[]; useContext?: string[]; useDebugValue?: string[]; useDeferredValue?: string[]; useEffect?: string[]; useFormStatus?: string[]; useId?: string[]; useImperativeHandle?: string[]; useInsertionEffect?: string[]; useLayoutEffect?: string[]; useMemo?: string[]; useOptimistic?: string[]; useReducer?: string[]; useRef?: string[]; useState?: string[]; useSyncExternalStore?: string[]; useTransition?: string[] }; importSource?: string; polymorphicPropName?: string; version?: string }` diff --git a/packages/shared/docs/functions/normalizeSettings.md b/packages/shared/docs/functions/normalizeSettings.md index 2cda67faa8..fcd6ef60cf 100644 --- a/packages/shared/docs/functions/normalizeSettings.md +++ b/packages/shared/docs/functions/normalizeSettings.md @@ -15,19 +15,6 @@ Transforms component definitions and resolves version information ### \_\_namedParameters -#### additionalComponents? - -`object`[] = `[]` - -User-defined components configuration -Informs ESLint React how to treat these components during validation - -**Example** - -```ts -[{ name: "Link", as: "a", attributes: [{ name: "to", as: "href" }] }] -``` - #### additionalHooks? \{ `use?`: `string`[]; `useActionState?`: `string`[]; `useCallback?`: `string`[]; `useContext?`: `string`[]; `useDebugValue?`: `string`[]; `useDeferredValue?`: `string`[]; `useEffect?`: `string`[]; `useFormStatus?`: `string`[]; `useId?`: `string`[]; `useImperativeHandle?`: `string`[]; `useInsertionEffect?`: `string`[]; `useLayoutEffect?`: `string`[]; `useMemo?`: `string`[]; `useOptimistic?`: `string`[]; `useReducer?`: `string`[]; `useRef?`: `string`[]; `useState?`: `string`[]; `useSyncExternalStore?`: `string`[]; `useTransition?`: `string`[]; \} = `{}` @@ -251,10 +238,6 @@ React version to use > `optional` **useTransition**: `string`[] -### components - -> `readonly` **components**: `object`[] - ### importSource > **importSource**: `string` diff --git a/packages/shared/docs/interfaces/CustomComponentNormalized.md b/packages/shared/docs/interfaces/CustomComponentNormalized.md deleted file mode 100644 index b390c6682d..0000000000 --- a/packages/shared/docs/interfaces/CustomComponentNormalized.md +++ /dev/null @@ -1,47 +0,0 @@ -[**@eslint-react/shared**](../README.md) - -*** - -[@eslint-react/shared](../README.md) / CustomComponentNormalized - -# Interface: CustomComponentNormalized - -Normalized representation of a custom component with RegExp for matching - -## Properties - -### as - -> **as**: `string` - -*** - -### attributes - -> **attributes**: [`CustomComponentPropNormalized`](CustomComponentPropNormalized.md)[] - -*** - -### name - -> **name**: `string` - -*** - -### re - -> **re**: `object` - -#### test() - -> **test**(`s`): `boolean` - -##### Parameters - -###### s - -`string` - -##### Returns - -`boolean` diff --git a/packages/shared/docs/interfaces/CustomComponentPropNormalized.md b/packages/shared/docs/interfaces/CustomComponentPropNormalized.md deleted file mode 100644 index 7f4144395c..0000000000 --- a/packages/shared/docs/interfaces/CustomComponentPropNormalized.md +++ /dev/null @@ -1,27 +0,0 @@ -[**@eslint-react/shared**](../README.md) - -*** - -[@eslint-react/shared](../README.md) / CustomComponentPropNormalized - -# Interface: CustomComponentPropNormalized - -Normalized representation of a custom component prop - -## Properties - -### as - -> **as**: `string` - -*** - -### defaultValue? - -> `optional` **defaultValue**: `string` - -*** - -### name - -> **name**: `string` diff --git a/packages/shared/docs/interfaces/ESLintReactSettingsNormalized.md b/packages/shared/docs/interfaces/ESLintReactSettingsNormalized.md index 959dc331b0..6b9a880606 100644 --- a/packages/shared/docs/interfaces/ESLintReactSettingsNormalized.md +++ b/packages/shared/docs/interfaces/ESLintReactSettingsNormalized.md @@ -92,12 +92,6 @@ Normalized ESLint React settings with processed values *** -### components - -> **components**: [`CustomComponentNormalized`](CustomComponentNormalized.md)[] - -*** - ### importSource > **importSource**: `string` diff --git a/packages/shared/docs/type-aliases/CustomComponent.md b/packages/shared/docs/type-aliases/CustomComponent.md deleted file mode 100644 index 8cf7529301..0000000000 --- a/packages/shared/docs/type-aliases/CustomComponent.md +++ /dev/null @@ -1,9 +0,0 @@ -[**@eslint-react/shared**](../README.md) - -*** - -[@eslint-react/shared](../README.md) / CustomComponent - -# Type Alias: CustomComponent - -> **CustomComponent** = `z.infer`\<*typeof* [`CustomComponentSchema`](../variables/CustomComponentSchema.md)\> diff --git a/packages/shared/docs/type-aliases/CustomComponentProp.md b/packages/shared/docs/type-aliases/CustomComponentProp.md deleted file mode 100644 index 5fca3dfc58..0000000000 --- a/packages/shared/docs/type-aliases/CustomComponentProp.md +++ /dev/null @@ -1,9 +0,0 @@ -[**@eslint-react/shared**](../README.md) - -*** - -[@eslint-react/shared](../README.md) / CustomComponentProp - -# Type Alias: CustomComponentProp - -> **CustomComponentProp** = `z.infer`\<*typeof* [`CustomComponentPropSchema`](../variables/CustomComponentPropSchema.md)\> diff --git a/packages/shared/docs/variables/CustomComponentPropSchema.md b/packages/shared/docs/variables/CustomComponentPropSchema.md deleted file mode 100644 index ec3ddef30b..0000000000 --- a/packages/shared/docs/variables/CustomComponentPropSchema.md +++ /dev/null @@ -1,11 +0,0 @@ -[**@eslint-react/shared**](../README.md) - -*** - -[@eslint-react/shared](../README.md) / CustomComponentPropSchema - -# Variable: CustomComponentPropSchema - -> `const` **CustomComponentPropSchema**: `ZodObject`\<\{ `as`: `ZodOptional`\<`ZodString`\>; `defaultValue`: `ZodOptional`\<`ZodString`\>; `name`: `ZodString`; \}, `$strip`\> - -Schema for component prop mapping between user-defined components and host components diff --git a/packages/shared/docs/variables/CustomComponentSchema.md b/packages/shared/docs/variables/CustomComponentSchema.md deleted file mode 100644 index b0c1bb915a..0000000000 --- a/packages/shared/docs/variables/CustomComponentSchema.md +++ /dev/null @@ -1,13 +0,0 @@ -[**@eslint-react/shared**](../README.md) - -*** - -[@eslint-react/shared](../README.md) / CustomComponentSchema - -# Variable: CustomComponentSchema - -> `const` **CustomComponentSchema**: `ZodObject`\<\{ `as`: `ZodOptional`\<`ZodString`\>; `attributes`: `ZodOptional`\<`ZodArray`\<`ZodObject`\<\{ `as`: `ZodOptional`\<`ZodString`\>; `defaultValue`: `ZodOptional`\<`ZodString`\>; `name`: `ZodString`; \}, `$strip`\>\>\>; `name`: `ZodString`; \}, `$strip`\> - -Schema for custom components configuration -Provides key information about user-defined components before validation -Example: Which prop is used as the `href` prop in a custom `Link` component diff --git a/packages/shared/docs/variables/DEFAULT_ESLINT_REACT_SETTINGS.md b/packages/shared/docs/variables/DEFAULT_ESLINT_REACT_SETTINGS.md index d90ee5248c..bfbe70de13 100644 --- a/packages/shared/docs/variables/DEFAULT_ESLINT_REACT_SETTINGS.md +++ b/packages/shared/docs/variables/DEFAULT_ESLINT_REACT_SETTINGS.md @@ -12,10 +12,6 @@ Default ESLint React settings ## Type Declaration -### additionalComponents - -> `readonly` **additionalComponents**: \[\] = `[]` - ### additionalHooks > `readonly` **additionalHooks**: `object` diff --git a/packages/shared/docs/variables/DEFAULT_ESLINT_SETTINGS.md b/packages/shared/docs/variables/DEFAULT_ESLINT_SETTINGS.md index 5af5a9a1d5..a884be212c 100644 --- a/packages/shared/docs/variables/DEFAULT_ESLINT_SETTINGS.md +++ b/packages/shared/docs/variables/DEFAULT_ESLINT_SETTINGS.md @@ -16,10 +16,6 @@ Default ESLint settings with React settings included > `readonly` **react-x**: `object` = `DEFAULT_ESLINT_REACT_SETTINGS` -#### react-x.additionalComponents - -> `readonly` **additionalComponents**: \[\] = `[]` - #### react-x.additionalHooks > `readonly` **additionalHooks**: `object` diff --git a/packages/shared/src/settings.ts b/packages/shared/src/settings.ts index 97af7cd572..35eca5b63e 100644 --- a/packages/shared/src/settings.ts +++ b/packages/shared/src/settings.ts @@ -2,7 +2,7 @@ /* eslint-disable perfectionist/sort-objects */ import type { unit } from "@eslint-react/eff"; import { getOrElseUpdate, identity } from "@eslint-react/eff"; -import { RegExp as RE, type RuleContext } from "@eslint-react/kit"; +import { type RuleContext } from "@eslint-react/kit"; import type { ESLint, SharedConfigurationSettings } from "@typescript-eslint/utils/ts-eslint"; // eslint-disable-line @typescript-eslint/no-unused-vars import type { PartialDeep } from "type-fest"; @@ -13,66 +13,6 @@ import { getReactVersion } from "./get-react-version"; // ===== Schema Definitions ===== -/** - * Schema for component prop mapping between user-defined components and host components - */ -export const CustomComponentPropSchema = z.object({ - /** - * The name of the prop in the user-defined component - * @example "to" - */ - name: z.string(), - - /** - * The name of the prop in the host component - * @example "href" - */ - as: z.optional(z.string()), - - /** - * Whether the prop is controlled in the user-defined component - * @internal - */ - controlled: z.optional(z.boolean()), - - /** - * The default value of the prop in the user-defined component - * @example "/", "noopener noreferrer" - */ - defaultValue: z.optional(z.string()), -}); - -/** - * Schema for custom components configuration - * Provides key information about user-defined components before validation - * Example: Which prop is used as the `href` prop in a custom `Link` component - */ -export const CustomComponentSchema = z.object({ - /** - * The name of the user-defined component - * @example "Link" - */ - name: z.string(), - - /** - * The name of the host component that the user-defined component represents - * @example "a" - */ - as: z.optional(z.string()), - - /** - * Attributes mapping between the user-defined component and the host component - * @example Link's "to" attribute maps to anchor "href" attribute - */ - attributes: z.optional(z.array(CustomComponentPropSchema)), - - /** - * ESQuery selector to precisely select the component - * @internal - */ - selector: z.optional(z.string()), -}); - /** * Schema for custom hooks aliases that should be treated as React Hooks */ @@ -145,13 +85,6 @@ export const ESLintReactSettingsSchema = z.object({ * @example { useEffect: ["useIsomorphicLayoutEffect"] } */ additionalHooks: z.optional(CustomHooksSchema), - - /** - * User-defined components configuration - * Informs ESLint React how to treat these components during validation - * @example [{ name: "Link", as: "a", attributes: [{ name: "to", as: "href" }] }] - */ - additionalComponents: z.optional(z.array(CustomComponentSchema)), }); /** @@ -165,40 +98,15 @@ export const ESLintSettingsSchema = z.optional( ); // ===== Type Definitions ===== - -export type CustomComponent = z.infer; -export type CustomComponentProp = z.infer; export type CustomHooks = z.infer; export type ESLintSettings = z.infer; export type ESLintReactSettings = z.infer; -/** - * Normalized representation of a custom component prop - */ -export interface CustomComponentPropNormalized { - name: string; - as: string; - // controlled?: boolean | unit; - defaultValue?: string | unit; -} - -/** - * Normalized representation of a custom component with RegExp for matching - */ -export interface CustomComponentNormalized { - name: string; - as: string; - attributes: CustomComponentPropNormalized[]; - re: { test(s: string): boolean }; - // selector?: string | unit; -} - /** * Normalized ESLint React settings with processed values */ export interface ESLintReactSettingsNormalized { additionalHooks: CustomHooks; - components: CustomComponentNormalized[]; importSource: string; polymorphicPropName: string | unit; skipImportCheck: boolean; @@ -217,7 +125,6 @@ export const DEFAULT_ESLINT_REACT_SETTINGS = { strict: true, skipImportCheck: true, polymorphicPropName: "as", - additionalComponents: [], additionalHooks: { useEffect: ["useIsomorphicLayoutEffect"], useLayoutEffect: ["useIsomorphicLayoutEffect"], @@ -292,7 +199,6 @@ export const decodeSettings = (settings: unknown): ESLintReactSettings => { * Transforms component definitions and resolves version information */ export const normalizeSettings = ({ - additionalComponents = [], additionalHooks = {}, importSource = "react", polymorphicPropName = "as", @@ -303,21 +209,6 @@ export const normalizeSettings = ({ }: ESLintReactSettings) => { return { ...rest, - components: additionalComponents.map((component) => { - const { name, as = name, attributes = [], ...rest } = component; - const re = RE.toRegExp(name); - return { - ...rest, - name, - re, - as, - attributes: attributes.map(({ name, as = name, ...rest }) => ({ - ...rest, - name, - as, - })), - }; - }), additionalHooks, importSource, polymorphicPropName, From a567e9bcf5c90da09422a1751c85bcfeff3446cf Mon Sep 17 00:00:00 2001 From: REL1CX Date: Sat, 6 Sep 2025 20:00:03 +0800 Subject: [PATCH 3/6] Update packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-target-blank.spec.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: REL1CX --- .../src/rules/no-unsafe-target-blank.spec.ts | 24 +------------------ 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-target-blank.spec.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-target-blank.spec.ts index 62c1fab92a..7208601dc4 100644 --- a/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-target-blank.spec.ts +++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-target-blank.spec.ts @@ -107,29 +107,7 @@ ruleTester.run(RULE_NAME, rule, { }, }, }, - // { - // code: '', - // errors: [ - // { - // messageId: "noUnsafeTargetBlank", - // suggestions: [ - // { - // messageId: "addRelNoreferrerNoopener", - // output: '', - // }, - // ], - // }, - // ], - // settings: { - // "react-x": { - // additionalComponents: [ - // { - // name: "Link", - // as: "a", - // }, - // ], - // }, - // }, + // TODO: Restore Link component test when support for additionalComponents is implemented. See issue #. // }, // { // code: '', From 1fdcd5790bda0b933843a994b6f9c54348ba608f Mon Sep 17 00:00:00 2001 From: REL1CX Date: Sat, 6 Sep 2025 20:00:17 +0800 Subject: [PATCH 4/6] Update packages/plugins/eslint-plugin-react-dom/src/utils/create-jsx-element-resolver.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: REL1CX --- .../src/utils/create-jsx-element-resolver.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/plugins/eslint-plugin-react-dom/src/utils/create-jsx-element-resolver.ts b/packages/plugins/eslint-plugin-react-dom/src/utils/create-jsx-element-resolver.ts index 777f30f5ef..9420a9357b 100644 --- a/packages/plugins/eslint-plugin-react-dom/src/utils/create-jsx-element-resolver.ts +++ b/packages/plugins/eslint-plugin-react-dom/src/utils/create-jsx-element-resolver.ts @@ -29,10 +29,6 @@ export function createJsxElementResolver(context: RuleContext) { // Get the element name/type (e.g., 'div', 'Button', etc.) const elementName = ER.getElementType(context, node); - // // Find if there's a matching component defined in settings - // const matchingComponent = components - // .findLast((component) => component.name === elementName || component.re.test(elementName)); - // Create the base result with element types const result = { domElementType: elementName, From 4e7720dde51deaf0e01e195529f4eded2c59edb9 Mon Sep 17 00:00:00 2001 From: Rel1cx Date: Sat, 6 Sep 2025 20:01:31 +0800 Subject: [PATCH 5/6] chore: remove ununsed code --- packages/core/src/jsx/jsx-attribute-value.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/jsx/jsx-attribute-value.ts b/packages/core/src/jsx/jsx-attribute-value.ts index 14779837ac..dc86e1fdeb 100644 --- a/packages/core/src/jsx/jsx-attribute-value.ts +++ b/packages/core/src/jsx/jsx-attribute-value.ts @@ -12,7 +12,6 @@ import { match, P } from "ts-pattern"; */ export type AttributeValue = | { kind: "boolean"; toStatic(): true } // Boolean attributes (e.g., disabled) - // | { kind: "default"; toStatic(): unit | string } // Default attribute values | { kind: "element"; node: TSESTree.JSXElement; toStatic(): unknown } // JSX element as value (e.g., />) | { kind: "literal"; node: TSESTree.Literal; toStatic(): TSESTree.Literal["value"] } // Literal values | { kind: "expression"; node: TSESTree.JSXExpressionContainer["expression"]; toStatic(): unknown } // Expression attributes (e.g., {value}, {...props}) From e8dbbee6f4f785f997b598a3d3b70f132ec0b493 Mon Sep 17 00:00:00 2001 From: Rel1cx Date: Sat, 6 Sep 2025 20:32:54 +0800 Subject: [PATCH 6/6] docs: remove experimental additional components and update documentation --- .../docs/configuration/configure-analyzer.mdx | 46 ------------- .../docs/configuration/configure-analyzer.tsx | 21 ------ ...nfigure-enhanced-additional-components.mdx | 69 ------------------- .../content/docs/configuration/meta.json | 1 - packages/plugins/eslint-plugin/README.md | 4 +- 5 files changed, 2 insertions(+), 139 deletions(-) delete mode 100644 apps/website/content/docs/configuration/configure-enhanced-additional-components.mdx diff --git a/apps/website/content/docs/configuration/configure-analyzer.mdx b/apps/website/content/docs/configuration/configure-analyzer.mdx index e16a4596c5..226a2f3fed 100644 --- a/apps/website/content/docs/configuration/configure-analyzer.mdx +++ b/apps/website/content/docs/configuration/configure-analyzer.mdx @@ -56,38 +56,6 @@ Example with `polymorphicPropName` set to `as`: // Evaluated as an h3 element ``` -### `additionalComponents` (Experimental) - - - Consider using `polymorphicPropName` instead when possible, as it's simpler - and more efficient. - - - - Experimental feature that may lack stability and documentation. - - -Maps components and their attributes for comprehensive analysis. Supports default attribute values. - -Example configuration: - -```json -[ - { - "name": "EmbedContent", - "as": "iframe", - "attributes": [ - { - "name": "sandbox", - "defaultValue": "" - } - ] - } -] -``` - -This makes `{:tsx}` evaluate as `