From 7f843bf3147d712d341de773543ad677ec20fabe Mon Sep 17 00:00:00 2001 From: Jon Rohan Date: Wed, 6 Aug 2025 21:11:01 +0000 Subject: [PATCH 1/2] 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 457ac5f2d9d83708a4a488133b85f2052392b09c Mon Sep 17 00:00:00 2001 From: Jon Rohan Date: Wed, 6 Aug 2025 15:48:21 -0700 Subject: [PATCH 2/2] Update eslint-plugin-primer-react and add new rule --- .changeset/tricky-bees-promise.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tricky-bees-promise.md diff --git a/.changeset/tricky-bees-promise.md b/.changeset/tricky-bees-promise.md new file mode 100644 index 0000000..ece7600 --- /dev/null +++ b/.changeset/tricky-bees-promise.md @@ -0,0 +1,5 @@ +--- +"eslint-plugin-primer-react": minor +--- + +feat: Add rule to enforce importing components with sx prop from @primer/styled-react