diff --git a/README.md b/README.md index d2a7c78f4..79c95fc06 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ export default [ | [jsx/no-missing-key](packages/eslint-plugin-jsx/src/rules/no-missing-key.md) | require `key` prop when rendering list | | [jsx/no-misused-comment-in-textnode](packages/eslint-plugin-jsx/src/rules/no-misused-comment-in-textnode.md) | disallow comments from being inserted as text nodes | | [jsx/no-script-url](packages/eslint-plugin-jsx/src/rules/no-script-url.md) | disallow `javascript:` URLs as JSX event handler prop's value | +| [jsx/no-useless-fragment](packages/eslint-plugin-jsx/src/rules/no-useless-fragment.md) | disallow unnecessary fragments | | [jsx/prefer-shorthand-boolean](packages/eslint-plugin-jsx/src/rules/prefer-shorthand-boolean.md) | enforce boolean attributes notation in JSX | ### naming-convention @@ -156,7 +157,7 @@ export default [ - [x] `jsx/no-script-url` - [ ] `jsx/no-target-blank` - [ ] `jsx/no-unknown-property` -- [ ] `jsx/no-useless-fragment` +- [x] `jsx/no-useless-fragment` - [ ] `jsx/prefer-fragment-syntax` - [x] `jsx/prefer-shorthand-boolean` - [x] `naming-convention/filename-extension` diff --git a/eslint-doc-generator.config.ts b/eslint-doc-generator.config.ts index 42e2ee82f..f0b098345 100644 --- a/eslint-doc-generator.config.ts +++ b/eslint-doc-generator.config.ts @@ -30,6 +30,7 @@ export default { pathRuleList: "README.md", ruleDocSectionInclude: ["Rule Details"], ruleDocTitleFormat: "name", + ruleListColumns: ["name", "description"], ruleListSplit(rules) { const record = rules.reduce>((acc, [name, rule]) => { const title = /^([\w-]+)\/[\w-]+/iu.exec(name)?.[1] ?? defaultTitle; diff --git a/packages/eslint-plugin-jsx/src/index.ts b/packages/eslint-plugin-jsx/src/index.ts index 9ef8cfd51..ed64618ad 100644 --- a/packages/eslint-plugin-jsx/src/index.ts +++ b/packages/eslint-plugin-jsx/src/index.ts @@ -7,6 +7,7 @@ import jsxNoLeakedConditionalRendering from "./rules/no-leaked-conditional-rende import jsxNoMissingKey from "./rules/no-missing-key"; import jsxNoMisusedCommentInTextNode from "./rules/no-misused-comment-in-textnode"; import jsxNoScriptUrl from "./rules/no-script-url"; +import jsxNoUselessFragment from "./rules/no-useless-fragment"; import jsxPreferShorthandJsxBoolean from "./rules/prefer-shorthand-boolean"; export { name } from "../package.json"; @@ -18,5 +19,6 @@ export const rules = { "no-missing-key": jsxNoMissingKey, "no-misused-comment-in-textnode": jsxNoMisusedCommentInTextNode, "no-script-url": jsxNoScriptUrl, + "no-useless-fragment": jsxNoUselessFragment, "prefer-shorthand-boolean": jsxPreferShorthandJsxBoolean, } as const; diff --git a/packages/eslint-plugin-jsx/src/rules/no-useless-fragment.md b/packages/eslint-plugin-jsx/src/rules/no-useless-fragment.md new file mode 100644 index 000000000..b09c41763 --- /dev/null +++ b/packages/eslint-plugin-jsx/src/rules/no-useless-fragment.md @@ -0,0 +1,83 @@ +# jsx/no-useless-fragment + +🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + + + +A fragment is redundant if it contains only one child, or if it is the child of a html element, and is not a [keyed fragment](https://react.dev/reference/react/Fragment#caveats). + +## Rule Details + +### ❌ Incorrect + +```tsx +<>{foo} + +<> + +

<>foo

+ +<> + +foo + +foo + +
+ <> +
+
+ +
+ +{showFullName ? <>{fullName} : <>{firstName}} +``` + +### ✅ Correct + +```tsx +{foo} + + + +<> + + + + +<>foo {bar} + +<> {foo} + +const cat = <>meow + + + <> +
+
+ + + +{item.value} + +{showFullName ? fullName : firstName} +``` + +## Rule Options + +### `allowExpressions` + +When `true` single expressions in a fragment will be allowed. This is useful in +places like Typescript where `string` does not satisfy the expected return type +of `JSX.Element`. A common workaround is to wrap the variable holding a string +in a fragment and expression. + +Examples of **correct** code for the rule, when `"allowExpressions"` is `true`: + +```jsx +<>{foo} + +<> + {foo} + +``` diff --git a/packages/eslint-plugin-jsx/src/rules/no-useless-fragment.spec.ts b/packages/eslint-plugin-jsx/src/rules/no-useless-fragment.spec.ts new file mode 100644 index 000000000..be85646dc --- /dev/null +++ b/packages/eslint-plugin-jsx/src/rules/no-useless-fragment.spec.ts @@ -0,0 +1,222 @@ +import { allValid } from "@eslint-react/shared"; +import { AST_NODE_TYPES } from "@typescript-eslint/types"; + +import RuleTester, { getFixturesRootDir } from "../../../../test/rule-tester"; +import rule, { RULE_NAME } from "./no-useless-fragment"; + +const rootDir = getFixturesRootDir(); + +const ruleTester = new RuleTester({ + parser: "@typescript-eslint/parser", + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 2021, + sourceType: "module", + project: "./tsconfig.json", + tsconfigRootDir: rootDir, + }, +}); + +ruleTester.run(RULE_NAME, rule, { + valid: [ + ...allValid, + "<>", + "<>foo
", + "<>
", + '<>{"moo"} ', + "", + "", + "", + "<>
", + '
{"a"}{"b"}} />', + "{item.value}", + "eeee ee eeeeeee eeeeeeee} />", + "<>{foos.map(foo => foo)}", + { + code: "<>{moo}", + options: [{ allowExpressions: true }], + }, + { + code: ` + <> + {moo} + + `, + options: [{ allowExpressions: true }], + }, + ], + invalid: [ + { + code: "<>", + output: null, + errors: [{ messageId: "NeedsMoreChildren", type: AST_NODE_TYPES.JSXFragment }], + }, + { + code: "<>{}", + output: null, + errors: [{ messageId: "NeedsMoreChildren", type: AST_NODE_TYPES.JSXFragment }], + }, + { + code: "

moo<>foo

", + output: "

moofoo

", + errors: [ + { messageId: "NeedsMoreChildren", type: AST_NODE_TYPES.JSXFragment }, + { messageId: "ChildOfHtmlElement", type: AST_NODE_TYPES.JSXFragment }, + ], + }, + { + code: "<>{meow}", + output: null, + errors: [{ messageId: "NeedsMoreChildren" }], + }, + { + code: "

<>{meow}

", + output: "

{meow}

", + errors: [ + { messageId: "NeedsMoreChildren", type: AST_NODE_TYPES.JSXFragment }, + { messageId: "ChildOfHtmlElement", type: AST_NODE_TYPES.JSXFragment }, + ], + }, + { + code: "<>
", + output: "
", + errors: [{ messageId: "NeedsMoreChildren", type: AST_NODE_TYPES.JSXFragment }], + }, + { + code: ` + <> +
+ + `, + output: ` +
+ `, + errors: [{ messageId: "NeedsMoreChildren", type: AST_NODE_TYPES.JSXFragment }], + }, + { + code: "", + output: null, + errors: [{ messageId: "NeedsMoreChildren", type: AST_NODE_TYPES.JSXElement }], + }, + { + code: ` + + + + `, + output: ` + + `, + errors: [{ messageId: "NeedsMoreChildren", type: AST_NODE_TYPES.JSXElement }], + }, + { + code: ` + + {foo} + + `, + output: null, + errors: [{ messageId: "NeedsMoreChildren", type: AST_NODE_TYPES.JSXElement }], + settings: { + react: { + pragma: "SomeReact", + fragment: "SomeFragment", + }, + }, + }, + { + // Not safe to fix this case because `Eeee` might require child be ReactElement + code: "<>foo", + output: null, + errors: [{ messageId: "NeedsMoreChildren", type: AST_NODE_TYPES.JSXFragment }], + }, + { + code: "
<>foo
", + output: "
foo
", + errors: [ + { messageId: "NeedsMoreChildren", type: AST_NODE_TYPES.JSXFragment }, + { messageId: "ChildOfHtmlElement", type: AST_NODE_TYPES.JSXFragment }, + ], + }, + { + code: '
<>{"a"}{"b"}
', + output: '
{"a"}{"b"}
', + errors: [{ messageId: "ChildOfHtmlElement", type: AST_NODE_TYPES.JSXFragment }], + }, + { + code: ` +
+ + + <>{"a"}{"b"} +
`, + output: ` +
+ + + {"a"}{"b"} +
`, + errors: [{ messageId: "ChildOfHtmlElement", type: AST_NODE_TYPES.JSXFragment }], + }, + { + code: '
{"a"}{"b"}
', + output: '
{"a"}{"b"}
', + errors: [{ messageId: "ChildOfHtmlElement", type: AST_NODE_TYPES.JSXElement }], + }, + { + // whitepace tricky case + code: ` +
+ git<> + hub. + + + git<> hub +
`, + output: ` +
+ github. + + git hub +
`, + errors: [ + { messageId: "ChildOfHtmlElement", type: AST_NODE_TYPES.JSXFragment, line: 3 }, + { messageId: "ChildOfHtmlElement", type: AST_NODE_TYPES.JSXFragment, line: 7 }, + ], + }, + { + code: '
a <>{""}{""} a
', + output: '
a {""}{""} a
', + errors: [{ messageId: "ChildOfHtmlElement", type: AST_NODE_TYPES.JSXFragment }], + }, + { + code: ` + const Comp = () => ( + + + + ); + `, + output: ` + const Comp = () => ( + + ${/* dprint-ignore the trailing whitespace here is intentional */ ""} + + ); + `, + errors: [ + { messageId: "NeedsMoreChildren", type: AST_NODE_TYPES.JSXElement, line: 4 }, + { messageId: "ChildOfHtmlElement", type: AST_NODE_TYPES.JSXElement, line: 4 }, + ], + }, + // Ensure allowExpressions still catches expected violations + { + code: "<>{moo}", + output: "{moo}", + options: [{ allowExpressions: true }], + errors: [{ messageId: "NeedsMoreChildren", type: AST_NODE_TYPES.JSXFragment }], + }, + ], +}); diff --git a/packages/eslint-plugin-jsx/src/rules/no-useless-fragment.ts b/packages/eslint-plugin-jsx/src/rules/no-useless-fragment.ts new file mode 100644 index 000000000..8b4417312 --- /dev/null +++ b/packages/eslint-plugin-jsx/src/rules/no-useless-fragment.ts @@ -0,0 +1,167 @@ +import { + getFragmentFromContext, + getPragmaFromContext, + hasProp, + isChildOfBuiltinComponentElement, + isChildOfUserDefinedComponentElement, + isFragment, + isFragmentHasLessThanTwoChildren, + isFragmentWithOnlyTextAndIsNotChild, + isFragmentWithSingleExpression, + isLiteral, + isWhiteSpace, +} from "@eslint-react/jsx"; +import type { TSESTree } from "@typescript-eslint/utils"; +import type { ESLintUtils } from "@typescript-eslint/utils"; +import { AST_NODE_TYPES } from "@typescript-eslint/utils"; +import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema"; +import type { RuleFixer } from "@typescript-eslint/utils/ts-eslint"; + +import { createRule } from "../utils"; + +function trimLikeReact(text: string) { + const leadingSpaces = /^\s*/u.exec(text)?.[0]; + const trailingSpaces = /\s*$/u.exec(text)?.[0]; + + const start = leadingSpaces?.includes("\n") ? leadingSpaces.length : 0; + const end = trailingSpaces?.includes("\n") ? text.length - trailingSpaces.length : text.length; + + return text.slice(start, end); +} + +export const RULE_NAME = "no-useless-fragment"; + +type MessageID = "ChildOfHtmlElement" | "NeedsMoreChildren"; + +type Options = readonly [ + { + allowExpressions?: boolean; + }?, +]; + +const defaultOptions = [ + { + allowExpressions: false, + }, +] as const; + +const schema = [{ + type: "object", + properties: { + allowExpressions: { + type: "boolean", + }, + }, +}] satisfies [JSONSchema4]; + +export default createRule({ + name: RULE_NAME, + meta: { + type: "suggestion", + docs: { + description: "disallow unnecessary fragments", + requiresTypeChecking: false, + }, + fixable: "code", + // eslint-disable-next-line perfectionist/sort-objects + schema, + messages: { + ChildOfHtmlElement: "Passing a fragment to an HTML element is useless.", + NeedsMoreChildren: + "Fragments should contain more than one child - otherwise, there’s no need for a Fragment at all.", + }, + }, + defaultOptions, + create(context) { + const config = context.options[0] || {}; + const allowExpressions = config.allowExpressions || false; + + const reactPragma = getPragmaFromContext(context); + const fragmentPragma = getFragmentFromContext(context); + + function canFix(node: TSESTree.JSXElement | TSESTree.JSXFragment) { + // Not safe to fix fragments without a jsx parent. + if (!(node.parent.type === AST_NODE_TYPES.JSXElement || node.parent.type === AST_NODE_TYPES.JSXFragment)) { + // const a = <> + if (node.children.length === 0) { + return false; + } + + // const a = <>cat {meow} + if ( + node.children.some( + (child) => + // eslint-disable-next-line @typescript-eslint/no-extra-parens + (isLiteral(child) && !isWhiteSpace(child)) + || child.type === AST_NODE_TYPES.JSXExpressionContainer, + ) + ) { + return false; + } + } + + // Not safe to fix `<>foo` because `Eeee` might require its children be a ReactElement. + return !isChildOfUserDefinedComponentElement(node, reactPragma, fragmentPragma); + } + + function getFix(node: TSESTree.JSXElement | TSESTree.JSXFragment) { + if (!canFix(node)) { + return null; + } + + return function fix(fixer: RuleFixer) { + const opener = node.type === AST_NODE_TYPES.JSXFragment ? node.openingFragment : node.openingElement; + const closer = node.type === AST_NODE_TYPES.JSXFragment ? node.closingFragment : node.closingElement; + + const childrenText = opener.type === AST_NODE_TYPES.JSXOpeningElement + && opener.selfClosing + ? "" + : context.getSourceCode().getText().slice(opener.range[1], closer?.range[0]); + + return fixer.replaceText(node, trimLikeReact(childrenText)); + }; + } + + function checkNode(node: TSESTree.JSXElement | TSESTree.JSXFragment) { + if ( + node.type === AST_NODE_TYPES.JSXElement + && hasProp( + node.openingElement.attributes, + "key", + context, + ) + ) { + return; + } + + if ( + isFragmentHasLessThanTwoChildren(node) + && !isFragmentWithOnlyTextAndIsNotChild(node) + && !(allowExpressions && isFragmentWithSingleExpression(node)) + ) { + context.report({ + fix: getFix(node), + messageId: "NeedsMoreChildren", + node, + }); + } + + if (isChildOfBuiltinComponentElement(node)) { + context.report({ + fix: getFix(node), + messageId: "ChildOfHtmlElement", + node, + }); + } + } + + return { + JSXElement(node) { + if (isFragment(node, reactPragma, fragmentPragma)) { + checkNode(node); + } + }, + JSXFragment: checkNode, + }; + }, +}); diff --git a/packages/jsx/docs/README.md b/packages/jsx/docs/README.md index c94d5307d..66217d3fe 100644 --- a/packages/jsx/docs/README.md +++ b/packages/jsx/docs/README.md @@ -42,9 +42,15 @@ - [hasEveryProp](README.md#haseveryprop) - [hasProp](README.md#hasprop) - [isCallFromPragma](README.md#iscallfrompragma) +- [isChildOfBuiltinComponentElement](README.md#ischildofbuiltincomponentelement) +- [isChildOfUserDefinedComponentElement](README.md#ischildofuserdefinedcomponentelement) - [isChildrenOfCreateElement](README.md#ischildrenofcreateelement) - [isCloneElementCall](README.md#iscloneelementcall) - [isCreateElementCall](README.md#iscreateelementcall) +- [isFragment](README.md#isfragment) +- [isFragmentHasLessThanTwoChildren](README.md#isfragmenthaslessthantwochildren) +- [isFragmentWithOnlyTextAndIsNotChild](README.md#isfragmentwithonlytextandisnotchild) +- [isFragmentWithSingleExpression](README.md#isfragmentwithsingleexpression) - [isFunctionReturningJSXValue](README.md#isfunctionreturningjsxvalue) - [isInitializedFromPragma](README.md#isinitializedfrompragma) - [isInsideCreateElementProps](README.md#isinsidecreateelementprops) @@ -52,6 +58,7 @@ - [isJSXValue](README.md#isjsxvalue) - [isLineBreak](README.md#islinebreak) - [isLiteral](README.md#isliteral) +- [isPaddingSpaces](README.md#ispaddingspaces) - [isPropertyOfPragma](README.md#ispropertyofpragma) - [isWhiteSpace](README.md#iswhitespace) - [traverseUpProp](README.md#traverseupprop) @@ -496,6 +503,40 @@ node is CallExpression --- +### isChildOfBuiltinComponentElement + +▸ **isChildOfBuiltinComponentElement**(`node`): `boolean` + +#### Parameters + +| Name | Type | +| :----- | :----- | +| `node` | `Node` | + +#### Returns + +`boolean` + +--- + +### isChildOfUserDefinedComponentElement + +▸ **isChildOfUserDefinedComponentElement**(`node`, `pragma`, `fragment`): `boolean` + +#### Parameters + +| Name | Type | +| :--------- | :------- | +| `node` | `Node` | +| `pragma` | `string` | +| `fragment` | `string` | + +#### Returns + +`boolean` + +--- + ### isChildrenOfCreateElement ▸ **isChildrenOfCreateElement**(`node`, `context`): `boolean` @@ -555,6 +596,86 @@ node is CallExpression --- +### isFragment + +▸ **isFragment**(`node`, `pragma`, `fragment`): `boolean` + +#### Parameters + +| Name | Type | +| :--------- | :----------- | +| `node` | `JSXElement` | +| `pragma` | `string` | +| `fragment` | `string` | + +#### Returns + +`boolean` + +--- + +### isFragmentHasLessThanTwoChildren + +▸ **isFragmentHasLessThanTwoChildren**(`node`): `boolean` + +Check if a JSXElement or JSXFragment has less than two non-padding children and the first child is not a call expression + +#### Parameters + +| Name | Type | Description | +| :----- | :---------------------------- | :-------------------- | +| `node` | `JSXElement` \| `JSXFragment` | The AST node to check | + +#### Returns + +`boolean` + +boolean + +--- + +### isFragmentWithOnlyTextAndIsNotChild + +▸ **isFragmentWithOnlyTextAndIsNotChild**(`node`): `boolean` + +Check if a JSXElement or JSXFragment has only one literal child and is not a child + +#### Parameters + +| Name | Type | Description | +| :----- | :---------------------------- | :-------------------- | +| `node` | `JSXElement` \| `JSXFragment` | The AST node to check | + +#### Returns + +`boolean` + +`true` if the node has only one literal child and is not a child + +**`Example`** + +```ts +Somehow fragment like this is useful: ee eeee eeee ...} /> +``` + +--- + +### isFragmentWithSingleExpression + +▸ **isFragmentWithSingleExpression**(`node`): `boolean` + +#### Parameters + +| Name | Type | +| :----- | :---------------------------- | +| `node` | `JSXElement` \| `JSXFragment` | + +#### Returns + +`boolean` + +--- + ### isFunctionReturningJSXValue ▸ **isFunctionReturningJSXValue**(`node`, `context`, `options?`): `boolean` @@ -698,6 +819,26 @@ boolean `true` if the node is a Literal or JSXText --- +### isPaddingSpaces + +▸ **isPaddingSpaces**(`node`): `boolean` + +Check if a Literal or JSXText node is padding spaces + +#### Parameters + +| Name | Type | Description | +| :----- | :----- | :-------------------- | +| `node` | `Node` | The AST node to check | + +#### Returns + +`boolean` + +boolean + +--- + ### isPropertyOfPragma ▸ **isPropertyOfPragma**(`name`, `context`, `pragma?`): (`node`: `Node`) => `boolean` diff --git a/packages/jsx/src/children.ts b/packages/jsx/src/children.ts index c2444507c..41ab52369 100644 --- a/packages/jsx/src/children.ts +++ b/packages/jsx/src/children.ts @@ -1,4 +1,6 @@ -import type { TSESTree } from "@typescript-eslint/types"; +import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/types"; + +import { isFragment } from "./fragment"; /** * Check if a JSXElement or JSXFragment has children @@ -8,3 +10,15 @@ import type { TSESTree } from "@typescript-eslint/types"; export function hasChildren(node: TSESTree.JSXElement | TSESTree.JSXFragment) { return node.children.length > 0; } + +export function isChildOfBuiltinComponentElement(node: TSESTree.Node) { + return node.parent?.type === AST_NODE_TYPES.JSXElement + && node.parent.openingElement.name.type === AST_NODE_TYPES.JSXIdentifier + && /^[a-z]+$/u.test(node.parent.openingElement.name.name); +} + +export function isChildOfUserDefinedComponentElement(node: TSESTree.Node, pragma: string, fragment: string) { + return node.parent?.type === AST_NODE_TYPES.JSXElement + && !isChildOfBuiltinComponentElement(node) + && !isFragment(node.parent, pragma, fragment); +} diff --git a/packages/jsx/src/fragment.ts b/packages/jsx/src/fragment.ts new file mode 100644 index 000000000..a417703a6 --- /dev/null +++ b/packages/jsx/src/fragment.ts @@ -0,0 +1,61 @@ +import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/types"; + +import { isLiteral, isPaddingSpaces } from "./textnode"; + +export function isFragment(node: TSESTree.JSXElement, pragma: string, fragment: string) { + const { name } = node.openingElement; + + // + if (name.type === AST_NODE_TYPES.JSXIdentifier && name.name === fragment) { + return true; + } + + // + return name.type === AST_NODE_TYPES.JSXMemberExpression + && name.object.type === AST_NODE_TYPES.JSXIdentifier + && name.object.name === pragma + && name.property.name === fragment; +} + +/** + * Check if a JSXElement or JSXFragment has only one literal child and is not a child + * @param node The AST node to check + * @returns `true` if the node has only one literal child and is not a child + * @example Somehow fragment like this is useful: ee eeee eeee ...} /> + */ +export function isFragmentWithOnlyTextAndIsNotChild(node: TSESTree.JSXElement | TSESTree.JSXFragment) { + return node.children.length === 1 + && isLiteral(node.children[0]) + && !(node.parent.type === AST_NODE_TYPES.JSXElement || node.parent.type === AST_NODE_TYPES.JSXFragment); +} + +function containsCallExpression(node: TSESTree.Node) { + return node.type === AST_NODE_TYPES.JSXExpressionContainer + && node.expression.type === AST_NODE_TYPES.CallExpression; +} + +/** + * Check if a JSXElement or JSXFragment has less than two non-padding children and the first child is not a call expression + * @param node The AST node to check + * @returns boolean + */ +export function isFragmentHasLessThanTwoChildren(node: TSESTree.JSXElement | TSESTree.JSXFragment) { + const nonPaddingChildren = node.children.filter( + (child) => !isPaddingSpaces(child), + ); + + if (nonPaddingChildren.length === 1) { + return !containsCallExpression(nonPaddingChildren[0] as TSESTree.Node); + } + + return nonPaddingChildren.length === 0; +} + +export function isFragmentWithSingleExpression(node: TSESTree.JSXElement | TSESTree.JSXFragment) { + const children = node.children.filter((child) => !isPaddingSpaces(child)); + + return ( + children.length === 1 + && children[0]?.type === AST_NODE_TYPES.JSXExpressionContainer + ); +} diff --git a/packages/jsx/src/index.ts b/packages/jsx/src/index.ts index 1664eb22d..2feb4d0b1 100644 --- a/packages/jsx/src/index.ts +++ b/packages/jsx/src/index.ts @@ -2,6 +2,7 @@ export * from "./children"; export * from "./element"; export * from "./element-type"; export * from "./event-handler"; +export * from "./fragment"; export * from "./misc"; export * from "./pragma"; export * from "./prop"; diff --git a/packages/jsx/src/textnode.ts b/packages/jsx/src/textnode.ts index dde8ce86e..4821e420b 100644 --- a/packages/jsx/src/textnode.ts +++ b/packages/jsx/src/textnode.ts @@ -26,3 +26,14 @@ export function isWhiteSpace(node: TSESTree.JSXText | TSESTree.Literal) { export function isLineBreak(node: TSESTree.Node) { return isLiteral(node) && isWhiteSpace(node) && isMultiLine(node); } + +/** + * Check if a Literal or JSXText node is padding spaces + * @param node The AST node to check + * @returns boolean + */ +export function isPaddingSpaces(node: TSESTree.Node) { + return isLiteral(node) + && isWhiteSpace(node) + && node.raw.includes("\n"); +}