diff --git a/.changeset/smart-rocks-fail.md b/.changeset/smart-rocks-fail.md new file mode 100644 index 00000000..adf6e15d --- /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 diff --git a/docs/rules/use-styled-react-import.md b/docs/rules/use-styled-react-import.md new file mode 100644 index 00000000..78a06128 --- /dev/null +++ b/docs/rules/use-styled-react-import.md @@ -0,0 +1,128 @@ +# 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`, 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`. 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`: + +- 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' +``` + +```jsx +import {Button} from '@primer/styled-react' + +const Component = () => +``` + +```jsx +import {Button} from '@primer/react' + +const Component1 = () => +const Component2 = () => +``` + +### ✅ 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 = () => +``` + +```jsx +// Components imported from styled-react but used without sx prop should be moved back +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. + +## 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 68de6f20..1dc3315e 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 00000000..bd11ae7d --- /dev/null +++ b/src/rules/__tests__/use-styled-react-import.test.js @@ -0,0 +1,251 @@ +const rule = require('../use-styled-react-import') +const {RuleTester} = require('eslint') + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + parserOptions: { + 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: Component not imported from @primer/react + `import { Button } from '@github-ui/button' + 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 + { + 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'}, + }, + ], + }, + + // 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: 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' + 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'}, + }, + ], + }, + + // Invalid: Button used both with and without sx prop - should use alias + { + code: `import { Button, Link } from '@primer/react' + const Component = () => ( +
+ + + +
+ )`, + output: `import { Button } from '@primer/react' +import { Button as StyledButton, Link } from '@primer/styled-react' + const Component = () => ( +
+ + + Styled button +
+ )`, + errors: [ + { + 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 new file mode 100644 index 00000000..b4ba55bd --- /dev/null +++ b/src/rules/use-styled-react-import.js @@ -0,0 +1,483 @@ +'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', + 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 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) { + 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 imported from styled-react + 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) + } + } + } + } + }, + + JSXElement(node) { + 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(originalComponentName)) { + allUsedComponents.add(originalComponentName) + + // 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) { + componentsWithSx.add(originalComponentName) + jsxElementsWithSx.push({node, componentName: originalComponentName, openingElement}) + } else { + 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, + }) + } + } + } + }, + + '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) + 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: hasConflict ? 'useStyledReactImportWithAlias' : 'useStyledReactImport', + data: hasConflict ? {componentName, aliasName: `Styled${componentName}`} : {componentName}, + fix(fixer) { + const {node: importNode, specifier} = importInfo + const changes = importNodeChanges.get(importNode) + + if (!changes) { + return null + } + + // 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) + + 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'`) + } + + 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.replaceText(importNode, `import { ${remainingNames.join(', ')} } from '@primer/react'`), + ) + + // Combine all styled-react imports into a single import statement + const styledReactImports = [] + + // 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 { ${styledReactImports.join(', ')} } from '@primer/styled-react'`, + ), + ) + } + } + + return fixes + }, + }) + } + } + + // 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 + }, + }) + } + } + + // 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 + 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} = importInfo + const changes = styledReactImportNodeChanges.get(importNode) + + 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 + } + + const fixes = [] + const componentsToMove = new Set(changes.toMove) + + // 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 { + // 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'`, + ), + ) + + const movedComponents = changes.toMove.join(', ') + fixes.push(fixer.insertTextAfter(importNode, `\nimport { ${movedComponents} } 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 + }, + }) + } + } + + // 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 + }, + }) + } + } + }, + } + }, +}