Skip to content

Commit 263ee5e

Browse files
committed
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
1 parent d3b6a1d commit 263ee5e

File tree

2 files changed

+180
-27
lines changed

2 files changed

+180
-27
lines changed

src/rules/__tests__/use-styled-react-import.test.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,42 @@ import { Button } from '@primer/styled-react'
138138
],
139139
},
140140

141+
// Invalid: <Link /> and <StyledButton /> imported from styled-react but used without sx prop
142+
{
143+
code: `import { Button } from '@primer/react'
144+
import { Button as StyledButton, Link } from '@primer/styled-react'
145+
const Component = () => (
146+
<div>
147+
<Link />
148+
<Button>Regular button</Button>
149+
<StyledButton>Styled button</StyledButton>
150+
</div>
151+
)`,
152+
output: `import { Button, Link } from '@primer/react'
153+
154+
const Component = () => (
155+
<div>
156+
<Link />
157+
<Button>Regular button</Button>
158+
<Button>Styled button</Button>
159+
</div>
160+
)`,
161+
errors: [
162+
{
163+
messageId: 'usePrimerReactImport',
164+
data: {componentName: 'Button'},
165+
},
166+
{
167+
messageId: 'usePrimerReactImport',
168+
data: {componentName: 'Link'},
169+
},
170+
{
171+
messageId: 'usePrimerReactImport',
172+
data: {componentName: 'Button'},
173+
},
174+
],
175+
},
176+
141177
// Invalid: Box imported from styled-react but used without sx prop
142178
{
143179
code: `import { Box } from '@primer/styled-react'

src/rules/use-styled-react-import.js

Lines changed: 144 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ module.exports = {
5959
const allUsedComponents = new Set() // Track all used components
6060
const primerReactImports = new Map() // Map of component name to import node
6161
const styledReactImports = new Map() // Map of components imported from styled-react to import node
62+
const aliasMapping = new Map() // Map local name to original component name for aliased imports
6263
const jsxElementsWithSx = [] // Track JSX elements that use sx prop
64+
const jsxElementsWithoutSx = [] // Track JSX elements that don't use sx prop
6365

6466
return {
6567
ImportDeclaration(node) {
@@ -84,7 +86,13 @@ module.exports = {
8486
for (const specifier of node.specifiers) {
8587
if (specifier.type === 'ImportSpecifier') {
8688
const importedName = specifier.imported.name
89+
const localName = specifier.local.name
8790
styledReactImports.set(importedName, {node, specifier})
91+
92+
// Track alias mapping for styled-react imports
93+
if (localName !== importedName) {
94+
aliasMapping.set(localName, importedName)
95+
}
8896
}
8997
}
9098
}
@@ -94,20 +102,33 @@ module.exports = {
94102
const openingElement = node.openingElement
95103
const componentName = getJSXOpeningElementName(openingElement)
96104

105+
// Check if this is an aliased component from styled-react
106+
const originalComponentName = aliasMapping.get(componentName) || componentName
107+
97108
// Track all used components that are in our styled components list
98-
if (styledComponents.has(componentName)) {
99-
allUsedComponents.add(componentName)
109+
if (styledComponents.has(originalComponentName)) {
110+
allUsedComponents.add(originalComponentName)
100111

101112
// Check if this component has an sx prop
102113
const hasSxProp = openingElement.attributes.some(
103114
attr => attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'sx',
104115
)
105116

106117
if (hasSxProp) {
107-
componentsWithSx.add(componentName)
108-
jsxElementsWithSx.push({node, componentName, openingElement})
118+
componentsWithSx.add(originalComponentName)
119+
jsxElementsWithSx.push({node, componentName: originalComponentName, openingElement})
109120
} else {
110-
componentsWithoutSx.add(componentName)
121+
componentsWithoutSx.add(originalComponentName)
122+
123+
// If this is an aliased component without sx, we need to track it for renaming
124+
if (aliasMapping.has(componentName)) {
125+
jsxElementsWithoutSx.push({
126+
node,
127+
localName: componentName,
128+
originalName: originalComponentName,
129+
openingElement,
130+
})
131+
}
111132
}
112133
}
113134
},
@@ -261,6 +282,32 @@ module.exports = {
261282
}
262283
}
263284

285+
// Group styled-react imports that need to be moved to primer-react
286+
const styledReactImportNodeChanges = new Map()
287+
288+
// Collect components that need to be moved from styled-react to primer-react
289+
for (const componentName of allUsedComponents) {
290+
if (!componentsWithSx.has(componentName) && styledReactImports.has(componentName)) {
291+
const importInfo = styledReactImports.get(componentName)
292+
const {node: importNode} = importInfo
293+
294+
if (!styledReactImportNodeChanges.has(importNode)) {
295+
styledReactImportNodeChanges.set(importNode, {
296+
toMove: [],
297+
originalSpecifiers: [...importNode.specifiers],
298+
})
299+
}
300+
301+
styledReactImportNodeChanges.get(importNode).toMove.push(componentName)
302+
}
303+
}
304+
305+
// Find existing primer-react import nodes to merge with
306+
const primerReactImportNodes = new Set()
307+
for (const [, {node}] of primerReactImports) {
308+
primerReactImportNodes.add(node)
309+
}
310+
264311
// Report errors for components used WITHOUT sx prop that are imported from @primer/styled-react
265312
for (const componentName of allUsedComponents) {
266313
// If component is used but NOT with sx prop, and it's imported from styled-react
@@ -271,38 +318,108 @@ module.exports = {
271318
messageId: 'usePrimerReactImport',
272319
data: {componentName},
273320
fix(fixer) {
274-
const {node: importNode, specifier} = importInfo
275-
const otherSpecifiers = importNode.specifiers.filter(s => s !== specifier)
321+
const {node: importNode} = importInfo
322+
const changes = styledReactImportNodeChanges.get(importNode)
276323

277-
// If this is the only import, replace the whole import
278-
if (otherSpecifiers.length === 0) {
279-
return fixer.replaceText(importNode, `import { ${componentName} } from '@primer/react'`)
324+
if (!changes) {
325+
return null
326+
}
327+
328+
// Only apply the fix once per import node (for the first component processed)
329+
const isFirstComponent = changes.toMove[0] === componentName
330+
331+
if (!isFirstComponent) {
332+
return null
280333
}
281334

282-
// Otherwise, remove from current import and add new import
283335
const fixes = []
336+
const componentsToMove = new Set(changes.toMove)
284337

285-
// Remove the specifier from current import
286-
if (importNode.specifiers.length === 1) {
338+
// Find specifiers that remain in styled-react import
339+
const remainingSpecifiers = changes.originalSpecifiers.filter(spec => {
340+
const name = spec.imported.name
341+
return !componentsToMove.has(name)
342+
})
343+
344+
// Check if there's an existing primer-react import to merge with
345+
const existingPrimerReactImport = Array.from(primerReactImportNodes)[0]
346+
347+
if (existingPrimerReactImport && remainingSpecifiers.length === 0) {
348+
// Case: No remaining styled-react imports, merge with existing primer-react import
349+
const existingSpecifiers = existingPrimerReactImport.specifiers.map(spec => spec.imported.name)
350+
const newSpecifiers = [...existingSpecifiers, ...changes.toMove].filter(
351+
(name, index, arr) => arr.indexOf(name) === index,
352+
)
353+
354+
fixes.push(
355+
fixer.replaceText(
356+
existingPrimerReactImport,
357+
`import { ${newSpecifiers.join(', ')} } from '@primer/react'`,
358+
),
359+
)
287360
fixes.push(fixer.remove(importNode))
361+
} else if (existingPrimerReactImport && remainingSpecifiers.length > 0) {
362+
// Case: Some styled-react imports remain, merge moved components with existing primer-react
363+
const existingSpecifiers = existingPrimerReactImport.specifiers.map(spec => spec.imported.name)
364+
const newSpecifiers = [...existingSpecifiers, ...changes.toMove].filter(
365+
(name, index, arr) => arr.indexOf(name) === index,
366+
)
367+
368+
fixes.push(
369+
fixer.replaceText(
370+
existingPrimerReactImport,
371+
`import { ${newSpecifiers.join(', ')} } from '@primer/react'`,
372+
),
373+
)
374+
375+
const remainingNames = remainingSpecifiers.map(spec => spec.imported.name)
376+
fixes.push(
377+
fixer.replaceText(
378+
importNode,
379+
`import { ${remainingNames.join(', ')} } from '@primer/styled-react'`,
380+
),
381+
)
382+
} else if (remainingSpecifiers.length === 0) {
383+
// Case: No existing primer-react import, no remaining styled-react imports
384+
const movedComponents = changes.toMove.join(', ')
385+
fixes.push(fixer.replaceText(importNode, `import { ${movedComponents} } from '@primer/react'`))
288386
} else {
289-
const isFirst = importNode.specifiers[0] === specifier
290-
const isLast = importNode.specifiers[importNode.specifiers.length - 1] === specifier
387+
// Case: No existing primer-react import, some styled-react imports remain
388+
const remainingNames = remainingSpecifiers.map(spec => spec.imported.name)
389+
fixes.push(
390+
fixer.replaceText(
391+
importNode,
392+
`import { ${remainingNames.join(', ')} } from '@primer/styled-react'`,
393+
),
394+
)
291395

292-
if (isFirst) {
293-
const nextSpecifier = importNode.specifiers[1]
294-
fixes.push(fixer.removeRange([specifier.range[0], nextSpecifier.range[0]]))
295-
} else if (isLast) {
296-
const prevSpecifier = importNode.specifiers[importNode.specifiers.length - 2]
297-
fixes.push(fixer.removeRange([prevSpecifier.range[1], specifier.range[1]]))
298-
} else {
299-
const nextSpecifier = importNode.specifiers[importNode.specifiers.indexOf(specifier) + 1]
300-
fixes.push(fixer.removeRange([specifier.range[0], nextSpecifier.range[0]]))
301-
}
396+
const movedComponents = changes.toMove.join(', ')
397+
fixes.push(fixer.insertTextAfter(importNode, `\nimport { ${movedComponents} } from '@primer/react'`))
302398
}
303399

304-
// Add new import
305-
fixes.push(fixer.insertTextAfter(importNode, `\nimport { ${componentName} } from '@primer/react'`))
400+
return fixes
401+
},
402+
})
403+
}
404+
}
405+
406+
// Report and fix JSX elements that use aliased components without sx prop
407+
for (const {node: jsxNode, originalName, openingElement} of jsxElementsWithoutSx) {
408+
if (!componentsWithSx.has(originalName) && styledReactImports.has(originalName)) {
409+
context.report({
410+
node: openingElement,
411+
messageId: 'usePrimerReactImport',
412+
data: {componentName: originalName},
413+
fix(fixer) {
414+
const fixes = []
415+
416+
// Replace the aliased component name with the original component name in JSX opening tag
417+
fixes.push(fixer.replaceText(openingElement.name, originalName))
418+
419+
// Replace the aliased component name in JSX closing tag if it exists
420+
if (jsxNode.closingElement) {
421+
fixes.push(fixer.replaceText(jsxNode.closingElement.name, originalName))
422+
}
306423

307424
return fixes
308425
},

0 commit comments

Comments
 (0)