From 767e170c5533204225f097daa45793fe2a8ef793 Mon Sep 17 00:00:00 2001 From: Jon Rohan Date: Wed, 6 Aug 2025 21:11:01 +0000 Subject: [PATCH 1/9] feat: Add rule to enforce importing components with sx prop from @primer/styled-react --- docs/rules/use-styled-react-import.md | 95 ++++++++ src/index.js | 1 + .../__tests__/use-styled-react-import.test.js | 118 ++++++++++ src/rules/use-styled-react-import.js | 202 ++++++++++++++++++ 4 files changed, 416 insertions(+) create mode 100644 docs/rules/use-styled-react-import.md create mode 100644 src/rules/__tests__/use-styled-react-import.test.js create mode 100644 src/rules/use-styled-react-import.js diff --git a/docs/rules/use-styled-react-import.md b/docs/rules/use-styled-react-import.md new file mode 100644 index 0000000..03d7a18 --- /dev/null +++ b/docs/rules/use-styled-react-import.md @@ -0,0 +1,95 @@ +# use-styled-react-import + +💼 This rule is _disabled_ in the ✅ `recommended` config. + +🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + + + +Enforce importing components that use `sx` prop from `@primer/styled-react` instead of `@primer/react`. + +## Rule Details + +This rule detects when certain Primer React components are used with the `sx` prop and ensures they are imported from the temporary `@primer/styled-react` package instead of `@primer/react`. It also moves certain types and utilities to the styled-react package. + +### Components that should be imported from `@primer/styled-react` when used with `sx`: + +- ActionList +- ActionMenu +- Box +- Breadcrumbs +- Button +- Flash +- FormControl +- Heading +- IconButton +- Label +- Link +- LinkButton +- PageLayout +- Text +- TextInput +- Truncate +- Octicon +- Dialog + +### Types and utilities that should always be imported from `@primer/styled-react`: + +- `BoxProps` (type) +- `SxProp` (type) +- `BetterSystemStyleObject` (type) +- `sx` (utility) + +## Examples + +### ❌ Incorrect + +```jsx +import {Button, Link} from '@primer/react' + +const Component = () => +``` + +```jsx +import {Box} from '@primer/react' + +const Component = () => Content +``` + +```jsx +import {sx} from '@primer/react' +``` + +### ✅ Correct + +```jsx +import {Link} from '@primer/react' +import {Button} from '@primer/styled-react' + +const Component = () => +``` + +```jsx +import {Box} from '@primer/styled-react' + +const Component = () => Content +``` + +```jsx +import {sx} from '@primer/styled-react' +``` + +```jsx +// Components without sx prop can stay in @primer/react +import {Button} from '@primer/react' + +const Component = () => +``` + +## Options + +This rule has no options. + +## When Not To Use It + +This rule is specifically for migrating components that use the `sx` prop to the temporary `@primer/styled-react` package. If you're not using the `sx` prop or not participating in this migration, you can disable this rule. diff --git a/src/index.js b/src/index.js index 68de6f2..1dc3315 100644 --- a/src/index.js +++ b/src/index.js @@ -19,6 +19,7 @@ module.exports = { 'prefer-action-list-item-onselect': require('./rules/prefer-action-list-item-onselect'), 'enforce-css-module-identifier-casing': require('./rules/enforce-css-module-identifier-casing'), 'enforce-css-module-default-import': require('./rules/enforce-css-module-default-import'), + 'use-styled-react-import': require('./rules/use-styled-react-import'), }, configs: { recommended: require('./configs/recommended'), diff --git a/src/rules/__tests__/use-styled-react-import.test.js b/src/rules/__tests__/use-styled-react-import.test.js new file mode 100644 index 0000000..e39deb2 --- /dev/null +++ b/src/rules/__tests__/use-styled-react-import.test.js @@ -0,0 +1,118 @@ +const rule = require('../use-styled-react-import') +const {RuleTester} = require('eslint') + +const ruleTester = new RuleTester({ + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, + }, +}) + +ruleTester.run('use-styled-react-import', rule, { + valid: [ + // Valid: Component used without sx prop + `import { Button } from '@primer/react' + const Component = () => `, + + // Valid: Component with sx prop imported from styled-react + `import { Button } from '@primer/styled-react' + const Component = () => `, + + // Valid: Utilities imported from styled-react + `import { sx } from '@primer/styled-react'`, + + // Valid: Component not in the styled list + `import { Avatar } from '@primer/react' + const Component = () => `, + + // Valid: Mixed imports - component without sx prop + `import { Button, Text } from '@primer/react' + const Component = () => `, + ], + invalid: [ + // Invalid: Box with sx prop imported from @primer/react + { + code: `import { Box } from '@primer/react' + const Component = () => Content`, + output: `import { Box } from '@primer/styled-react' + const Component = () => Content`, + errors: [ + { + messageId: 'useStyledReactImport', + data: {componentName: 'Box'}, + }, + ], + }, + + // Invalid: Button with sx prop imported from @primer/react + { + code: `import { Button } from '@primer/react' + const Component = () => `, + output: `import { Button } from '@primer/styled-react' + const Component = () => `, + errors: [ + { + messageId: 'useStyledReactImport', + data: {componentName: 'Button'}, + }, + ], + }, + + // Invalid: Multiple components, one with sx prop + { + code: `import { Button, Box, Avatar } from '@primer/react' + const Component = () => ( +
+ + Styled box + +
+ )`, + output: `import { Button, Avatar } from '@primer/react' +import { Box } from '@primer/styled-react' + const Component = () => ( +
+ + Styled box + +
+ )`, + errors: [ + { + messageId: 'useStyledReactImport', + data: {componentName: 'Box'}, + }, + ], + }, + + // Invalid: Utility import from @primer/react that should be from styled-react + { + code: `import { sx } from '@primer/react'`, + output: `import { sx } from '@primer/styled-react'`, + errors: [ + { + messageId: 'moveToStyledReact', + data: {importName: 'sx'}, + }, + ], + }, + + // Invalid: Button and Link, only Button uses sx + { + code: `import { Button, Link } from '@primer/react' + const Component = () => `, + output: `import { Link } from '@primer/react' +import { Button } from '@primer/styled-react' + const Component = () => `, + errors: [ + { + messageId: 'useStyledReactImport', + data: {componentName: 'Button'}, + }, + ], + }, + ], +}) diff --git a/src/rules/use-styled-react-import.js b/src/rules/use-styled-react-import.js new file mode 100644 index 0000000..91c6749 --- /dev/null +++ b/src/rules/use-styled-react-import.js @@ -0,0 +1,202 @@ +'use strict' + +const url = require('../url') +const {getJSXOpeningElementName} = require('../utils/get-jsx-opening-element-name') + +// Components that should be imported from @primer/styled-react when used with sx prop +const styledComponents = new Set([ + 'ActionList', + 'ActionMenu', + 'Box', + 'Breadcrumbs', + 'Button', + 'Flash', + 'FormControl', + 'Heading', + 'IconButton', + 'Label', + 'Link', + 'LinkButton', + 'PageLayout', + 'Text', + 'TextInput', + 'Truncate', + 'Octicon', + 'Dialog', +]) + +// Types that should be imported from @primer/styled-react +const styledTypes = new Set(['BoxProps', 'SxProp', 'BetterSystemStyleObject']) + +// Utilities that should be imported from @primer/styled-react +const styledUtilities = new Set(['sx']) + +/** + * @type {import('eslint').Rule.RuleModule} + */ +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: 'Enforce importing components that use sx prop from @primer/styled-react', + recommended: false, + url: url(module), + }, + fixable: 'code', + schema: [], + messages: { + useStyledReactImport: 'Import {{ componentName }} from "@primer/styled-react" when using with sx prop', + moveToStyledReact: 'Move {{ importName }} import to "@primer/styled-react"', + }, + }, + create(context) { + const componentsWithSx = new Set() + const primerReactImports = new Map() // Map of component name to import node + const styledReactImports = new Set() // Set of components already imported from styled-react + + return { + ImportDeclaration(node) { + const importSource = node.source.value + + if (importSource === '@primer/react') { + // Track imports from @primer/react + for (const specifier of node.specifiers) { + if (specifier.type === 'ImportSpecifier') { + const importedName = specifier.imported.name + if ( + styledComponents.has(importedName) || + styledTypes.has(importedName) || + styledUtilities.has(importedName) + ) { + primerReactImports.set(importedName, {node, specifier}) + } + } + } + } else if (importSource === '@primer/styled-react') { + // Track what's already imported from styled-react + for (const specifier of node.specifiers) { + if (specifier.type === 'ImportSpecifier') { + styledReactImports.add(specifier.imported.name) + } + } + } + }, + + JSXOpeningElement(node) { + const componentName = getJSXOpeningElementName(node) + + // Check if this component has an sx prop + const hasSxProp = node.attributes.some( + attr => attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'sx', + ) + + if (hasSxProp && styledComponents.has(componentName)) { + componentsWithSx.add(componentName) + } + }, + + 'Program:exit': function () { + // Report errors for components used with sx prop that are imported from @primer/react + for (const componentName of componentsWithSx) { + const importInfo = primerReactImports.get(componentName) + if (importInfo && !styledReactImports.has(componentName)) { + context.report({ + node: importInfo.specifier, + messageId: 'useStyledReactImport', + data: {componentName}, + fix(fixer) { + const {node: importNode, specifier} = importInfo + const otherSpecifiers = importNode.specifiers.filter(s => s !== specifier) + + // If this is the only import, replace the whole import + if (otherSpecifiers.length === 0) { + return fixer.replaceText(importNode, `import { ${componentName} } from '@primer/styled-react'`) + } + + // Otherwise, remove from current import and add new import + const fixes = [] + + // Remove the specifier from current import + if (importNode.specifiers.length === 1) { + fixes.push(fixer.remove(importNode)) + } else { + const isFirst = importNode.specifiers[0] === specifier + const isLast = importNode.specifiers[importNode.specifiers.length - 1] === specifier + + if (isFirst) { + const nextSpecifier = importNode.specifiers[1] + fixes.push(fixer.removeRange([specifier.range[0], nextSpecifier.range[0]])) + } else if (isLast) { + const prevSpecifier = importNode.specifiers[importNode.specifiers.length - 2] + fixes.push(fixer.removeRange([prevSpecifier.range[1], specifier.range[1]])) + } else { + const nextSpecifier = importNode.specifiers[importNode.specifiers.indexOf(specifier) + 1] + fixes.push(fixer.removeRange([specifier.range[0], nextSpecifier.range[0]])) + } + } + + // Add new import + fixes.push( + fixer.insertTextAfter(importNode, `\nimport { ${componentName} } from '@primer/styled-react'`), + ) + + return fixes + }, + }) + } + } + + // Also report for types and utilities that should always be from styled-react + for (const [importName, importInfo] of primerReactImports) { + if ((styledTypes.has(importName) || styledUtilities.has(importName)) && !styledReactImports.has(importName)) { + context.report({ + node: importInfo.specifier, + messageId: 'moveToStyledReact', + data: {importName}, + fix(fixer) { + const {node: importNode, specifier} = importInfo + const otherSpecifiers = importNode.specifiers.filter(s => s !== specifier) + + // If this is the only import, replace the whole import + if (otherSpecifiers.length === 0) { + const prefix = styledTypes.has(importName) ? 'type ' : '' + return fixer.replaceText(importNode, `import { ${prefix}${importName} } from '@primer/styled-react'`) + } + + // Otherwise, remove from current import and add new import + const fixes = [] + + // Remove the specifier from current import + if (importNode.specifiers.length === 1) { + fixes.push(fixer.remove(importNode)) + } else { + const isFirst = importNode.specifiers[0] === specifier + const isLast = importNode.specifiers[importNode.specifiers.length - 1] === specifier + + if (isFirst) { + const nextSpecifier = importNode.specifiers[1] + fixes.push(fixer.removeRange([specifier.range[0], nextSpecifier.range[0]])) + } else if (isLast) { + const prevSpecifier = importNode.specifiers[importNode.specifiers.length - 2] + fixes.push(fixer.removeRange([prevSpecifier.range[1], specifier.range[1]])) + } else { + const nextSpecifier = importNode.specifiers[importNode.specifiers.indexOf(specifier) + 1] + fixes.push(fixer.removeRange([specifier.range[0], nextSpecifier.range[0]])) + } + } + + // Add new import + const prefix = styledTypes.has(importName) ? 'type ' : '' + fixes.push( + fixer.insertTextAfter(importNode, `\nimport { ${prefix}${importName} } from '@primer/styled-react'`), + ) + + return fixes + }, + }) + } + } + }, + } + }, +} From e33e87d237d77ade0d024cd1bd842524cc2738f8 Mon Sep 17 00:00:00 2001 From: Jon Rohan Date: Thu, 7 Aug 2025 21:52:33 +0000 Subject: [PATCH 2/9] Update test for eslint v9 --- src/rules/__tests__/use-styled-react-import.test.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/rules/__tests__/use-styled-react-import.test.js b/src/rules/__tests__/use-styled-react-import.test.js index e39deb2..9fa129f 100644 --- a/src/rules/__tests__/use-styled-react-import.test.js +++ b/src/rules/__tests__/use-styled-react-import.test.js @@ -2,11 +2,13 @@ const rule = require('../use-styled-react-import') const {RuleTester} = require('eslint') const ruleTester = new RuleTester({ - parserOptions: { + languageOptions: { ecmaVersion: 'latest', sourceType: 'module', - ecmaFeatures: { - jsx: true, + parserOptions: { + ecmaFeatures: { + jsx: true, + }, }, }, }) From 881b3305967757d20cc4f7309d0eaa61b819b17b Mon Sep 17 00:00:00 2001 From: Jon Rohan Date: Thu, 7 Aug 2025 22:11:54 +0000 Subject: [PATCH 3/9] feat: Enhance use-styled-react-import rule to enforce correct imports for components without sx prop --- docs/rules/use-styled-react-import.md | 17 ++++- .../__tests__/use-styled-react-import.test.js | 56 +++++++++++++++++ src/rules/use-styled-react-import.js | 63 ++++++++++++++++++- 3 files changed, 131 insertions(+), 5 deletions(-) diff --git a/docs/rules/use-styled-react-import.md b/docs/rules/use-styled-react-import.md index 03d7a18..35449b9 100644 --- a/docs/rules/use-styled-react-import.md +++ b/docs/rules/use-styled-react-import.md @@ -6,11 +6,11 @@ -Enforce importing components that use `sx` prop from `@primer/styled-react` instead of `@primer/react`. +Enforce importing components that use `sx` prop from `@primer/styled-react` instead of `@primer/react`, and vice versa. ## Rule Details -This rule detects when certain Primer React components are used with the `sx` prop and ensures they are imported from the temporary `@primer/styled-react` package instead of `@primer/react`. It also moves certain types and utilities to the styled-react package. +This rule detects when certain Primer React components are used with the `sx` prop and ensures they are imported from the temporary `@primer/styled-react` package instead of `@primer/react`. When the same components are used without the `sx` prop, it ensures they are imported from `@primer/react` instead of `@primer/styled-react`. It also moves certain types and utilities to the styled-react package. ### Components that should be imported from `@primer/styled-react` when used with `sx`: @@ -60,6 +60,12 @@ const Component = () => Content import {sx} from '@primer/react' ``` +```jsx +import {Button} from '@primer/styled-react' + +const Component = () => +``` + ### ✅ Correct ```jsx @@ -86,6 +92,13 @@ import {Button} from '@primer/react' const Component = () => ``` +```jsx +// Components imported from styled-react but used without sx prop should be moved back +import {Button} from '@primer/react' + +const Component = () => +``` + ## Options This rule has no options. diff --git a/src/rules/__tests__/use-styled-react-import.test.js b/src/rules/__tests__/use-styled-react-import.test.js index 9fa129f..0c8e292 100644 --- a/src/rules/__tests__/use-styled-react-import.test.js +++ b/src/rules/__tests__/use-styled-react-import.test.js @@ -33,6 +33,9 @@ ruleTester.run('use-styled-react-import', rule, { // Valid: Mixed imports - component without sx prop `import { Button, Text } from '@primer/react' const Component = () => `, + + // Valid: Component without sx prop imported from styled-react (when not used) + `import { Button } from '@primer/styled-react'`, ], invalid: [ // Invalid: Box with sx prop imported from @primer/react @@ -116,5 +119,58 @@ import { Button } from '@primer/styled-react' }, ], }, + + // Invalid: Button imported from styled-react but used without sx prop + { + code: `import { Button } from '@primer/styled-react' + const Component = () => `, + output: `import { Button } from '@primer/react' + const Component = () => `, + errors: [ + { + messageId: 'usePrimerReactImport', + data: {componentName: 'Button'}, + }, + ], + }, + + // Invalid: Box imported from styled-react but used without sx prop + { + code: `import { Box } from '@primer/styled-react' + const Component = () => Content`, + output: `import { Box } from '@primer/react' + const Component = () => Content`, + errors: [ + { + messageId: 'usePrimerReactImport', + data: {componentName: 'Box'}, + }, + ], + }, + + // Invalid: Multiple components from styled-react, one used without sx + { + code: `import { Button, Box } from '@primer/styled-react' + const Component = () => ( +
+ + Styled box +
+ )`, + output: `import { Box } from '@primer/styled-react' +import { Button } from '@primer/react' + const Component = () => ( +
+ + Styled box +
+ )`, + errors: [ + { + messageId: 'usePrimerReactImport', + data: {componentName: 'Button'}, + }, + ], + }, ], }) diff --git a/src/rules/use-styled-react-import.js b/src/rules/use-styled-react-import.js index 91c6749..774e4d4 100644 --- a/src/rules/use-styled-react-import.js +++ b/src/rules/use-styled-react-import.js @@ -47,12 +47,14 @@ module.exports = { messages: { useStyledReactImport: 'Import {{ componentName }} from "@primer/styled-react" when using with sx prop', moveToStyledReact: 'Move {{ importName }} import to "@primer/styled-react"', + usePrimerReactImport: 'Import {{ componentName }} from "@primer/react" when not using sx prop', }, }, create(context) { const componentsWithSx = new Set() + const allUsedComponents = new Set() // Track all used components const primerReactImports = new Map() // Map of component name to import node - const styledReactImports = new Set() // Set of components already imported from styled-react + const styledReactImports = new Map() // Map of components imported from styled-react to import node return { ImportDeclaration(node) { @@ -73,10 +75,11 @@ module.exports = { } } } else if (importSource === '@primer/styled-react') { - // Track what's already imported from styled-react + // Track what's imported from styled-react for (const specifier of node.specifiers) { if (specifier.type === 'ImportSpecifier') { - styledReactImports.add(specifier.imported.name) + const importedName = specifier.imported.name + styledReactImports.set(importedName, {node, specifier}) } } } @@ -85,6 +88,11 @@ module.exports = { JSXOpeningElement(node) { const componentName = getJSXOpeningElementName(node) + // Track all used components that are in our styled components list + if (styledComponents.has(componentName)) { + allUsedComponents.add(componentName) + } + // Check if this component has an sx prop const hasSxProp = node.attributes.some( attr => attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'sx', @@ -146,6 +154,55 @@ module.exports = { } } + // Report errors for components used WITHOUT sx prop that are imported from @primer/styled-react + for (const componentName of allUsedComponents) { + // If component is used but NOT with sx prop, and it's imported from styled-react + if (!componentsWithSx.has(componentName) && styledReactImports.has(componentName)) { + const importInfo = styledReactImports.get(componentName) + context.report({ + node: importInfo.specifier, + messageId: 'usePrimerReactImport', + data: {componentName}, + fix(fixer) { + const {node: importNode, specifier} = importInfo + const otherSpecifiers = importNode.specifiers.filter(s => s !== specifier) + + // If this is the only import, replace the whole import + if (otherSpecifiers.length === 0) { + return fixer.replaceText(importNode, `import { ${componentName} } from '@primer/react'`) + } + + // Otherwise, remove from current import and add new import + const fixes = [] + + // Remove the specifier from current import + if (importNode.specifiers.length === 1) { + fixes.push(fixer.remove(importNode)) + } else { + const isFirst = importNode.specifiers[0] === specifier + const isLast = importNode.specifiers[importNode.specifiers.length - 1] === specifier + + if (isFirst) { + const nextSpecifier = importNode.specifiers[1] + fixes.push(fixer.removeRange([specifier.range[0], nextSpecifier.range[0]])) + } else if (isLast) { + const prevSpecifier = importNode.specifiers[importNode.specifiers.length - 2] + fixes.push(fixer.removeRange([prevSpecifier.range[1], specifier.range[1]])) + } else { + const nextSpecifier = importNode.specifiers[importNode.specifiers.indexOf(specifier) + 1] + fixes.push(fixer.removeRange([specifier.range[0], nextSpecifier.range[0]])) + } + } + + // Add new import + fixes.push(fixer.insertTextAfter(importNode, `\nimport { ${componentName} } from '@primer/react'`)) + + return fixes + }, + }) + } + } + // Also report for types and utilities that should always be from styled-react for (const [importName, importInfo] of primerReactImports) { if ((styledTypes.has(importName) || styledUtilities.has(importName)) && !styledReactImports.has(importName)) { From c7e25aee62caf1b2d6a028aab2796dce259b12b3 Mon Sep 17 00:00:00 2001 From: Jon Rohan Date: Thu, 7 Aug 2025 22:21:38 +0000 Subject: [PATCH 4/9] feat: Update use-styled-react-import rule to handle components used with and without sx prop, including aliasing for conflicts --- docs/rules/use-styled-react-import.md | 22 +++- .../__tests__/use-styled-react-import.test.js | 29 +++++ src/rules/use-styled-react-import.js | 122 ++++++++++++------ 3 files changed, 136 insertions(+), 37 deletions(-) diff --git a/docs/rules/use-styled-react-import.md b/docs/rules/use-styled-react-import.md index 35449b9..78a0612 100644 --- a/docs/rules/use-styled-react-import.md +++ b/docs/rules/use-styled-react-import.md @@ -10,7 +10,11 @@ Enforce importing components that use `sx` prop from `@primer/styled-react` inst ## Rule Details -This rule detects when certain Primer React components are used with the `sx` prop and ensures they are imported from the temporary `@primer/styled-react` package instead of `@primer/react`. When the same components are used without the `sx` prop, it ensures they are imported from `@primer/react` instead of `@primer/styled-react`. It also moves certain types and utilities to the styled-react package. +This rule detects when certain Primer React components are used with the `sx` prop and ensures they are imported from the temporary `@primer/styled-react` package instead of `@primer/react`. When the same components are used without the `sx` prop, it ensures they are imported from `@primer/react` instead of `@primer/styled-react`. + +When a component is used both with and without the `sx` prop in the same file, the rule will import the styled version with an alias (e.g., `StyledButton`) and update the JSX usage accordingly to avoid naming conflicts. + +It also moves certain types and utilities to the styled-react package. ### Components that should be imported from `@primer/styled-react` when used with `sx`: @@ -66,6 +70,13 @@ import {Button} from '@primer/styled-react' const Component = () => ``` +```jsx +import {Button} from '@primer/react' + +const Component1 = () => +const Component2 = () => +``` + ### ✅ Correct ```jsx @@ -99,6 +110,15 @@ import {Button} from '@primer/react' const Component = () => ``` +```jsx +// When a component is used both ways, use an alias for the styled version +import {Button} from '@primer/react' +import {Button as StyledButton} from '@primer/styled-react' + +const Component1 = () => +const Component2 = () => Styled me +``` + ## Options This rule has no options. diff --git a/src/rules/__tests__/use-styled-react-import.test.js b/src/rules/__tests__/use-styled-react-import.test.js index 0c8e292..6743aa7 100644 --- a/src/rules/__tests__/use-styled-react-import.test.js +++ b/src/rules/__tests__/use-styled-react-import.test.js @@ -172,5 +172,34 @@ import { Button } from '@primer/react' }, ], }, + + // Invalid: Button used both with and without sx prop - should use alias + { + code: `import { Button } from '@primer/react' + const Component = () => ( +
+ + +
+ )`, + output: `import { Button } from '@primer/react' +import { Button as StyledButton } from '@primer/styled-react' + const Component = () => ( +
+ + Styled button +
+ )`, + errors: [ + { + messageId: 'useStyledReactImportWithAlias', + data: {componentName: 'Button', aliasName: 'StyledButton'}, + }, + { + messageId: 'useAliasedComponent', + data: {componentName: 'Button', aliasName: 'StyledButton'}, + }, + ], + }, ], }) diff --git a/src/rules/use-styled-react-import.js b/src/rules/use-styled-react-import.js index 774e4d4..13d4fd1 100644 --- a/src/rules/use-styled-react-import.js +++ b/src/rules/use-styled-react-import.js @@ -46,15 +46,20 @@ module.exports = { schema: [], messages: { useStyledReactImport: 'Import {{ componentName }} from "@primer/styled-react" when using with sx prop', + useStyledReactImportWithAlias: + 'Import {{ componentName }} as {{ aliasName }} from "@primer/styled-react" when using with sx prop (conflicts with non-sx usage)', + useAliasedComponent: 'Use {{ aliasName }} instead of {{ componentName }} when using sx prop', moveToStyledReact: 'Move {{ importName }} import to "@primer/styled-react"', usePrimerReactImport: 'Import {{ componentName }} from "@primer/react" when not using sx prop', }, }, create(context) { const componentsWithSx = new Set() + const componentsWithoutSx = new Set() // Track components used without sx const allUsedComponents = new Set() // Track all used components const primerReactImports = new Map() // Map of component name to import node const styledReactImports = new Map() // Map of components imported from styled-react to import node + const jsxElementsWithSx = [] // Track JSX elements that use sx prop return { ImportDeclaration(node) { @@ -85,21 +90,25 @@ module.exports = { } }, - JSXOpeningElement(node) { - const componentName = getJSXOpeningElementName(node) + JSXElement(node) { + const openingElement = node.openingElement + const componentName = getJSXOpeningElementName(openingElement) // Track all used components that are in our styled components list if (styledComponents.has(componentName)) { allUsedComponents.add(componentName) - } - // Check if this component has an sx prop - const hasSxProp = node.attributes.some( - attr => attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'sx', - ) + // Check if this component has an sx prop + const hasSxProp = openingElement.attributes.some( + attr => attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'sx', + ) - if (hasSxProp && styledComponents.has(componentName)) { - componentsWithSx.add(componentName) + if (hasSxProp) { + componentsWithSx.add(componentName) + jsxElementsWithSx.push({node, componentName, openingElement}) + } else { + componentsWithoutSx.add(componentName) + } } }, @@ -108,45 +117,86 @@ module.exports = { for (const componentName of componentsWithSx) { const importInfo = primerReactImports.get(componentName) if (importInfo && !styledReactImports.has(componentName)) { + // Check if this component is also used without sx prop (conflict scenario) + const hasConflict = componentsWithoutSx.has(componentName) + context.report({ node: importInfo.specifier, - messageId: 'useStyledReactImport', - data: {componentName}, + messageId: hasConflict ? 'useStyledReactImportWithAlias' : 'useStyledReactImport', + data: hasConflict ? {componentName, aliasName: `Styled${componentName}`} : {componentName}, fix(fixer) { const {node: importNode, specifier} = importInfo const otherSpecifiers = importNode.specifiers.filter(s => s !== specifier) - // If this is the only import, replace the whole import - if (otherSpecifiers.length === 0) { - return fixer.replaceText(importNode, `import { ${componentName} } from '@primer/styled-react'`) - } - - // Otherwise, remove from current import and add new import - const fixes = [] - - // Remove the specifier from current import - if (importNode.specifiers.length === 1) { - fixes.push(fixer.remove(importNode)) + if (hasConflict) { + // Use alias when there's a conflict - keep original import and add aliased import + const aliasName = `Styled${componentName}` + return fixer.insertTextAfter( + importNode, + `\nimport { ${componentName} as ${aliasName} } from '@primer/styled-react'`, + ) } else { - const isFirst = importNode.specifiers[0] === specifier - const isLast = importNode.specifiers[importNode.specifiers.length - 1] === specifier + // No conflict - use the original logic + // If this is the only import, replace the whole import + if (otherSpecifiers.length === 0) { + return fixer.replaceText(importNode, `import { ${componentName} } from '@primer/styled-react'`) + } - if (isFirst) { - const nextSpecifier = importNode.specifiers[1] - fixes.push(fixer.removeRange([specifier.range[0], nextSpecifier.range[0]])) - } else if (isLast) { - const prevSpecifier = importNode.specifiers[importNode.specifiers.length - 2] - fixes.push(fixer.removeRange([prevSpecifier.range[1], specifier.range[1]])) + // Otherwise, remove from current import and add new import + const fixes = [] + + // Remove the specifier from current import + if (importNode.specifiers.length === 1) { + fixes.push(fixer.remove(importNode)) } else { - const nextSpecifier = importNode.specifiers[importNode.specifiers.indexOf(specifier) + 1] - fixes.push(fixer.removeRange([specifier.range[0], nextSpecifier.range[0]])) + const isFirst = importNode.specifiers[0] === specifier + const isLast = importNode.specifiers[importNode.specifiers.length - 1] === specifier + + if (isFirst) { + const nextSpecifier = importNode.specifiers[1] + fixes.push(fixer.removeRange([specifier.range[0], nextSpecifier.range[0]])) + } else if (isLast) { + const prevSpecifier = importNode.specifiers[importNode.specifiers.length - 2] + fixes.push(fixer.removeRange([prevSpecifier.range[1], specifier.range[1]])) + } else { + const nextSpecifier = importNode.specifiers[importNode.specifiers.indexOf(specifier) + 1] + fixes.push(fixer.removeRange([specifier.range[0], nextSpecifier.range[0]])) + } } + + // Add new import + fixes.push( + fixer.insertTextAfter(importNode, `\nimport { ${componentName} } from '@primer/styled-react'`), + ) + + return fixes } + }, + }) + } + } - // Add new import - fixes.push( - fixer.insertTextAfter(importNode, `\nimport { ${componentName} } from '@primer/styled-react'`), - ) + // Report on JSX elements that should use aliased components + for (const {node: jsxNode, componentName, openingElement} of jsxElementsWithSx) { + const hasConflict = componentsWithoutSx.has(componentName) + const isImportedFromPrimerReact = primerReactImports.has(componentName) + + if (hasConflict && isImportedFromPrimerReact && !styledReactImports.has(componentName)) { + const aliasName = `Styled${componentName}` + context.report({ + node: openingElement, + messageId: 'useAliasedComponent', + data: {componentName, aliasName}, + fix(fixer) { + const fixes = [] + + // Replace the component name in the JSX opening tag + fixes.push(fixer.replaceText(openingElement.name, aliasName)) + + // Replace the component name in the JSX closing tag if it exists + if (jsxNode.closingElement) { + fixes.push(fixer.replaceText(jsxNode.closingElement.name, aliasName)) + } return fixes }, From 5ec5b1ca76c21ef1a72a0b325398baa4a48f830c Mon Sep 17 00:00:00 2001 From: Jon Rohan Date: Thu, 7 Aug 2025 15:26:31 -0700 Subject: [PATCH 5/9] Update eslint-plugin-primer-react version and rule --- .changeset/smart-rocks-fail.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/smart-rocks-fail.md diff --git a/.changeset/smart-rocks-fail.md b/.changeset/smart-rocks-fail.md new file mode 100644 index 0000000..adf6e15 --- /dev/null +++ b/.changeset/smart-rocks-fail.md @@ -0,0 +1,5 @@ +--- +"eslint-plugin-primer-react": minor +--- + +Add rule `use-styled-react-import` to enforce importing components with sx prop from @primer/styled-react From c597a0e3a959ce6fb4c49c2687a5de81eeb078a5 Mon Sep 17 00:00:00 2001 From: Jon Rohan Date: Fri, 8 Aug 2025 20:14:05 +0000 Subject: [PATCH 6/9] Add test case for import from github-ui --- src/rules/__tests__/use-styled-react-import.test.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/rules/__tests__/use-styled-react-import.test.js b/src/rules/__tests__/use-styled-react-import.test.js index 6743aa7..bedc91f 100644 --- a/src/rules/__tests__/use-styled-react-import.test.js +++ b/src/rules/__tests__/use-styled-react-import.test.js @@ -30,6 +30,10 @@ ruleTester.run('use-styled-react-import', rule, { `import { Avatar } from '@primer/react' const Component = () => `, + // Valid: Component not imported from @primer/react + `import { Button } from '@github-ui/button' + const Component = () => `, From f163375ecda716e42c35e3c2d56c27006b279c61 Mon Sep 17 00:00:00 2001 From: Jon Rohan Date: Fri, 8 Aug 2025 20:52:32 +0000 Subject: [PATCH 7/9] feat: Enhance use-styled-react-import rule to support multiple components in imports, handling conflicts and aliasing for components used with and without sx prop --- .../__tests__/use-styled-react-import.test.js | 9 +- src/rules/use-styled-react-import.js | 124 +++++++++++++----- 2 files changed, 96 insertions(+), 37 deletions(-) diff --git a/src/rules/__tests__/use-styled-react-import.test.js b/src/rules/__tests__/use-styled-react-import.test.js index bedc91f..ec69fd6 100644 --- a/src/rules/__tests__/use-styled-react-import.test.js +++ b/src/rules/__tests__/use-styled-react-import.test.js @@ -179,17 +179,20 @@ import { Button } from '@primer/react' // Invalid: Button used both with and without sx prop - should use alias { - code: `import { Button } from '@primer/react' + code: `import { Button, Link } from '@primer/react' const Component = () => (
+
)`, output: `import { Button } from '@primer/react' +import { Link } from '@primer/styled-react' import { Button as StyledButton } from '@primer/styled-react' const Component = () => (
+ Styled button
@@ -199,6 +202,10 @@ import { Button as StyledButton } from '@primer/styled-react' messageId: 'useStyledReactImportWithAlias', data: {componentName: 'Button', aliasName: 'StyledButton'}, }, + { + messageId: 'useStyledReactImport', + data: {componentName: 'Link'}, + }, { messageId: 'useAliasedComponent', data: {componentName: 'Button', aliasName: 'StyledButton'}, diff --git a/src/rules/use-styled-react-import.js b/src/rules/use-styled-react-import.js index 13d4fd1..1e82004 100644 --- a/src/rules/use-styled-react-import.js +++ b/src/rules/use-styled-react-import.js @@ -113,6 +113,33 @@ module.exports = { }, 'Program:exit': function () { + // Group components by import node to handle multiple changes to same import + const importNodeChanges = new Map() + + // Collect all changes needed for components used with sx prop + for (const componentName of componentsWithSx) { + const importInfo = primerReactImports.get(componentName) + if (importInfo && !styledReactImports.has(componentName)) { + const hasConflict = componentsWithoutSx.has(componentName) + const {node: importNode} = importInfo + + if (!importNodeChanges.has(importNode)) { + importNodeChanges.set(importNode, { + toMove: [], + toAlias: [], + originalSpecifiers: [...importNode.specifiers], + }) + } + + const changes = importNodeChanges.get(importNode) + if (hasConflict) { + changes.toAlias.push(componentName) + } else { + changes.toMove.push(componentName) + } + } + } + // Report errors for components used with sx prop that are imported from @primer/react for (const componentName of componentsWithSx) { const importInfo = primerReactImports.get(componentName) @@ -126,51 +153,76 @@ module.exports = { data: hasConflict ? {componentName, aliasName: `Styled${componentName}`} : {componentName}, fix(fixer) { const {node: importNode, specifier} = importInfo - const otherSpecifiers = importNode.specifiers.filter(s => s !== specifier) + const changes = importNodeChanges.get(importNode) - if (hasConflict) { - // Use alias when there's a conflict - keep original import and add aliased import - const aliasName = `Styled${componentName}` - return fixer.insertTextAfter( - importNode, - `\nimport { ${componentName} as ${aliasName} } from '@primer/styled-react'`, - ) - } else { - // No conflict - use the original logic - // If this is the only import, replace the whole import - if (otherSpecifiers.length === 0) { - return fixer.replaceText(importNode, `import { ${componentName} } from '@primer/styled-react'`) - } + if (!changes) { + return null + } - // Otherwise, remove from current import and add new import - const fixes = [] + // Only apply the fix once per import node (for the first component processed) + const isFirstComponent = + changes.originalSpecifiers[0] === specifier || + (changes.toMove.length > 0 && changes.toMove[0] === componentName) || + (changes.toAlias.length > 0 && changes.toAlias[0] === componentName) - // Remove the specifier from current import - if (importNode.specifiers.length === 1) { - fixes.push(fixer.remove(importNode)) - } else { - const isFirst = importNode.specifiers[0] === specifier - const isLast = importNode.specifiers[importNode.specifiers.length - 1] === specifier - - if (isFirst) { - const nextSpecifier = importNode.specifiers[1] - fixes.push(fixer.removeRange([specifier.range[0], nextSpecifier.range[0]])) - } else if (isLast) { - const prevSpecifier = importNode.specifiers[importNode.specifiers.length - 2] - fixes.push(fixer.removeRange([prevSpecifier.range[1], specifier.range[1]])) - } else { - const nextSpecifier = importNode.specifiers[importNode.specifiers.indexOf(specifier) + 1] - fixes.push(fixer.removeRange([specifier.range[0], nextSpecifier.range[0]])) - } + if (!isFirstComponent) { + return null + } + + const fixes = [] + const componentsToMove = new Set(changes.toMove) + + // Find specifiers that remain in original import + const remainingSpecifiers = changes.originalSpecifiers.filter(spec => { + const name = spec.imported.name + // Keep components that are not being moved (only aliased components stay for non-sx usage) + return !componentsToMove.has(name) + }) + + // If no components remain, replace with new imports directly + if (remainingSpecifiers.length === 0) { + // Build the new imports to replace the original + const newImports = [] + + // Add imports for moved components + for (const componentName of changes.toMove) { + newImports.push(`import { ${componentName} } from '@primer/styled-react'`) + } + + // Add aliased imports for conflicted components + for (const componentName of changes.toAlias) { + const aliasName = `Styled${componentName}` + newImports.push(`import { ${componentName} as ${aliasName} } from '@primer/styled-react'`) } - // Add new import + fixes.push(fixer.replaceText(importNode, newImports.join('\n'))) + } else { + // Otherwise, update the import to only include remaining components + const remainingNames = remainingSpecifiers.map(spec => spec.imported.name) fixes.push( - fixer.insertTextAfter(importNode, `\nimport { ${componentName} } from '@primer/styled-react'`), + fixer.replaceText(importNode, `import { ${remainingNames.join(', ')} } from '@primer/react'`), ) - return fixes + // Add new imports for moved components + for (const componentName of changes.toMove) { + fixes.push( + fixer.insertTextAfter(importNode, `\nimport { ${componentName} } from '@primer/styled-react'`), + ) + } + + // Add new aliased imports for conflicted components + for (const componentName of changes.toAlias) { + const aliasName = `Styled${componentName}` + fixes.push( + fixer.insertTextAfter( + importNode, + `\nimport { ${componentName} as ${aliasName} } from '@primer/styled-react'`, + ), + ) + } } + + return fixes }, }) } From d3b6a1d665f8553e6f2af2657e926f527fb3ae11 Mon Sep 17 00:00:00 2001 From: Jon Rohan Date: Fri, 8 Aug 2025 21:11:35 +0000 Subject: [PATCH 8/9] feat: Refactor import handling in use-styled-react-import rule to consolidate styled-react imports into a single statement with aliasing support --- .../__tests__/use-styled-react-import.test.js | 3 +-- src/rules/use-styled-react-import.js | 21 ++++++++++++------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/rules/__tests__/use-styled-react-import.test.js b/src/rules/__tests__/use-styled-react-import.test.js index ec69fd6..5b17a4a 100644 --- a/src/rules/__tests__/use-styled-react-import.test.js +++ b/src/rules/__tests__/use-styled-react-import.test.js @@ -188,8 +188,7 @@ import { Button } from '@primer/react' )`, output: `import { Button } from '@primer/react' -import { Link } from '@primer/styled-react' -import { Button as StyledButton } from '@primer/styled-react' +import { Button as StyledButton, Link } from '@primer/styled-react' const Component = () => (
diff --git a/src/rules/use-styled-react-import.js b/src/rules/use-styled-react-import.js index 1e82004..41406f3 100644 --- a/src/rules/use-styled-react-import.js +++ b/src/rules/use-styled-react-import.js @@ -203,20 +203,25 @@ module.exports = { fixer.replaceText(importNode, `import { ${remainingNames.join(', ')} } from '@primer/react'`), ) - // Add new imports for moved components - for (const componentName of changes.toMove) { - fixes.push( - fixer.insertTextAfter(importNode, `\nimport { ${componentName} } from '@primer/styled-react'`), - ) - } + // Combine all styled-react imports into a single import statement + const styledReactImports = [] - // Add new aliased imports for conflicted components + // Add aliased components first for (const componentName of changes.toAlias) { const aliasName = `Styled${componentName}` + styledReactImports.push(`${componentName} as ${aliasName}`) + } + + // Add moved components second + for (const componentName of changes.toMove) { + styledReactImports.push(componentName) + } + + if (styledReactImports.length > 0) { fixes.push( fixer.insertTextAfter( importNode, - `\nimport { ${componentName} as ${aliasName} } from '@primer/styled-react'`, + `\nimport { ${styledReactImports.join(', ')} } from '@primer/styled-react'`, ), ) } From 263ee5ebd06e2568e19d9f19fef04e0fc0d5a612 Mon Sep 17 00:00:00 2001 From: Jon Rohan Date: Fri, 8 Aug 2025 21:38:27 +0000 Subject: [PATCH 9/9] feat: Enhance use-styled-react-import rule to support alias mapping for styled-react imports and improve error reporting for components used without sx prop --- .../__tests__/use-styled-react-import.test.js | 36 ++++ src/rules/use-styled-react-import.js | 171 +++++++++++++++--- 2 files changed, 180 insertions(+), 27 deletions(-) diff --git a/src/rules/__tests__/use-styled-react-import.test.js b/src/rules/__tests__/use-styled-react-import.test.js index 5b17a4a..bd11ae7 100644 --- a/src/rules/__tests__/use-styled-react-import.test.js +++ b/src/rules/__tests__/use-styled-react-import.test.js @@ -138,6 +138,42 @@ import { Button } from '@primer/styled-react' ], }, + // Invalid: and imported from styled-react but used without sx prop + { + code: `import { Button } from '@primer/react' +import { Button as StyledButton, Link } from '@primer/styled-react' + const Component = () => ( +
+ + + Styled button +
+ )`, + output: `import { Button, Link } from '@primer/react' + + const Component = () => ( +
+ + + +
+ )`, + errors: [ + { + messageId: 'usePrimerReactImport', + data: {componentName: 'Button'}, + }, + { + messageId: 'usePrimerReactImport', + data: {componentName: 'Link'}, + }, + { + messageId: 'usePrimerReactImport', + data: {componentName: 'Button'}, + }, + ], + }, + // Invalid: Box imported from styled-react but used without sx prop { code: `import { Box } from '@primer/styled-react' diff --git a/src/rules/use-styled-react-import.js b/src/rules/use-styled-react-import.js index 41406f3..b4ba55b 100644 --- a/src/rules/use-styled-react-import.js +++ b/src/rules/use-styled-react-import.js @@ -59,7 +59,9 @@ module.exports = { const allUsedComponents = new Set() // Track all used components const primerReactImports = new Map() // Map of component name to import node const styledReactImports = new Map() // Map of components imported from styled-react to import node + const aliasMapping = new Map() // Map local name to original component name for aliased imports const jsxElementsWithSx = [] // Track JSX elements that use sx prop + const jsxElementsWithoutSx = [] // Track JSX elements that don't use sx prop return { ImportDeclaration(node) { @@ -84,7 +86,13 @@ module.exports = { for (const specifier of node.specifiers) { if (specifier.type === 'ImportSpecifier') { const importedName = specifier.imported.name + const localName = specifier.local.name styledReactImports.set(importedName, {node, specifier}) + + // Track alias mapping for styled-react imports + if (localName !== importedName) { + aliasMapping.set(localName, importedName) + } } } } @@ -94,9 +102,12 @@ module.exports = { const openingElement = node.openingElement const componentName = getJSXOpeningElementName(openingElement) + // Check if this is an aliased component from styled-react + const originalComponentName = aliasMapping.get(componentName) || componentName + // Track all used components that are in our styled components list - if (styledComponents.has(componentName)) { - allUsedComponents.add(componentName) + if (styledComponents.has(originalComponentName)) { + allUsedComponents.add(originalComponentName) // Check if this component has an sx prop const hasSxProp = openingElement.attributes.some( @@ -104,10 +115,20 @@ module.exports = { ) if (hasSxProp) { - componentsWithSx.add(componentName) - jsxElementsWithSx.push({node, componentName, openingElement}) + componentsWithSx.add(originalComponentName) + jsxElementsWithSx.push({node, componentName: originalComponentName, openingElement}) } else { - componentsWithoutSx.add(componentName) + componentsWithoutSx.add(originalComponentName) + + // If this is an aliased component without sx, we need to track it for renaming + if (aliasMapping.has(componentName)) { + jsxElementsWithoutSx.push({ + node, + localName: componentName, + originalName: originalComponentName, + openingElement, + }) + } } } }, @@ -261,6 +282,32 @@ module.exports = { } } + // Group styled-react imports that need to be moved to primer-react + const styledReactImportNodeChanges = new Map() + + // Collect components that need to be moved from styled-react to primer-react + for (const componentName of allUsedComponents) { + if (!componentsWithSx.has(componentName) && styledReactImports.has(componentName)) { + const importInfo = styledReactImports.get(componentName) + const {node: importNode} = importInfo + + if (!styledReactImportNodeChanges.has(importNode)) { + styledReactImportNodeChanges.set(importNode, { + toMove: [], + originalSpecifiers: [...importNode.specifiers], + }) + } + + styledReactImportNodeChanges.get(importNode).toMove.push(componentName) + } + } + + // Find existing primer-react import nodes to merge with + const primerReactImportNodes = new Set() + for (const [, {node}] of primerReactImports) { + primerReactImportNodes.add(node) + } + // Report errors for components used WITHOUT sx prop that are imported from @primer/styled-react for (const componentName of allUsedComponents) { // If component is used but NOT with sx prop, and it's imported from styled-react @@ -271,38 +318,108 @@ module.exports = { messageId: 'usePrimerReactImport', data: {componentName}, fix(fixer) { - const {node: importNode, specifier} = importInfo - const otherSpecifiers = importNode.specifiers.filter(s => s !== specifier) + const {node: importNode} = importInfo + const changes = styledReactImportNodeChanges.get(importNode) - // If this is the only import, replace the whole import - if (otherSpecifiers.length === 0) { - return fixer.replaceText(importNode, `import { ${componentName} } from '@primer/react'`) + if (!changes) { + return null + } + + // Only apply the fix once per import node (for the first component processed) + const isFirstComponent = changes.toMove[0] === componentName + + if (!isFirstComponent) { + return null } - // Otherwise, remove from current import and add new import const fixes = [] + const componentsToMove = new Set(changes.toMove) - // Remove the specifier from current import - if (importNode.specifiers.length === 1) { + // Find specifiers that remain in styled-react import + const remainingSpecifiers = changes.originalSpecifiers.filter(spec => { + const name = spec.imported.name + return !componentsToMove.has(name) + }) + + // Check if there's an existing primer-react import to merge with + const existingPrimerReactImport = Array.from(primerReactImportNodes)[0] + + if (existingPrimerReactImport && remainingSpecifiers.length === 0) { + // Case: No remaining styled-react imports, merge with existing primer-react import + const existingSpecifiers = existingPrimerReactImport.specifiers.map(spec => spec.imported.name) + const newSpecifiers = [...existingSpecifiers, ...changes.toMove].filter( + (name, index, arr) => arr.indexOf(name) === index, + ) + + fixes.push( + fixer.replaceText( + existingPrimerReactImport, + `import { ${newSpecifiers.join(', ')} } from '@primer/react'`, + ), + ) fixes.push(fixer.remove(importNode)) + } else if (existingPrimerReactImport && remainingSpecifiers.length > 0) { + // Case: Some styled-react imports remain, merge moved components with existing primer-react + const existingSpecifiers = existingPrimerReactImport.specifiers.map(spec => spec.imported.name) + const newSpecifiers = [...existingSpecifiers, ...changes.toMove].filter( + (name, index, arr) => arr.indexOf(name) === index, + ) + + fixes.push( + fixer.replaceText( + existingPrimerReactImport, + `import { ${newSpecifiers.join(', ')} } from '@primer/react'`, + ), + ) + + const remainingNames = remainingSpecifiers.map(spec => spec.imported.name) + fixes.push( + fixer.replaceText( + importNode, + `import { ${remainingNames.join(', ')} } from '@primer/styled-react'`, + ), + ) + } else if (remainingSpecifiers.length === 0) { + // Case: No existing primer-react import, no remaining styled-react imports + const movedComponents = changes.toMove.join(', ') + fixes.push(fixer.replaceText(importNode, `import { ${movedComponents} } from '@primer/react'`)) } else { - const isFirst = importNode.specifiers[0] === specifier - const isLast = importNode.specifiers[importNode.specifiers.length - 1] === specifier + // Case: No existing primer-react import, some styled-react imports remain + const remainingNames = remainingSpecifiers.map(spec => spec.imported.name) + fixes.push( + fixer.replaceText( + importNode, + `import { ${remainingNames.join(', ')} } from '@primer/styled-react'`, + ), + ) - if (isFirst) { - const nextSpecifier = importNode.specifiers[1] - fixes.push(fixer.removeRange([specifier.range[0], nextSpecifier.range[0]])) - } else if (isLast) { - const prevSpecifier = importNode.specifiers[importNode.specifiers.length - 2] - fixes.push(fixer.removeRange([prevSpecifier.range[1], specifier.range[1]])) - } else { - const nextSpecifier = importNode.specifiers[importNode.specifiers.indexOf(specifier) + 1] - fixes.push(fixer.removeRange([specifier.range[0], nextSpecifier.range[0]])) - } + const movedComponents = changes.toMove.join(', ') + fixes.push(fixer.insertTextAfter(importNode, `\nimport { ${movedComponents} } from '@primer/react'`)) } - // Add new import - fixes.push(fixer.insertTextAfter(importNode, `\nimport { ${componentName} } from '@primer/react'`)) + return fixes + }, + }) + } + } + + // Report and fix JSX elements that use aliased components without sx prop + for (const {node: jsxNode, originalName, openingElement} of jsxElementsWithoutSx) { + if (!componentsWithSx.has(originalName) && styledReactImports.has(originalName)) { + context.report({ + node: openingElement, + messageId: 'usePrimerReactImport', + data: {componentName: originalName}, + fix(fixer) { + const fixes = [] + + // Replace the aliased component name with the original component name in JSX opening tag + fixes.push(fixer.replaceText(openingElement.name, originalName)) + + // Replace the aliased component name in JSX closing tag if it exists + if (jsxNode.closingElement) { + fixes.push(fixer.replaceText(jsxNode.closingElement.name, originalName)) + } return fixes },