Skip to content

Commit d35c34c

Browse files
Copilotjoshblack
andcommitted
Add no-deprecated-octicon ESLint rule to replace Octicon with specific icons
Co-authored-by: joshblack <[email protected]>
1 parent 3e7d162 commit d35c34c

File tree

3 files changed

+313
-0
lines changed

3 files changed

+313
-0
lines changed

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ module.exports = {
1919
'prefer-action-list-item-onselect': require('./rules/prefer-action-list-item-onselect'),
2020
'enforce-css-module-identifier-casing': require('./rules/enforce-css-module-identifier-casing'),
2121
'enforce-css-module-default-import': require('./rules/enforce-css-module-default-import'),
22+
'no-deprecated-octicon': require('./rules/no-deprecated-octicon'),
2223
},
2324
configs: {
2425
recommended: require('./configs/recommended'),
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
'use strict'
2+
3+
const {RuleTester} = require('eslint')
4+
const rule = require('../no-deprecated-octicon')
5+
6+
const ruleTester = new RuleTester({
7+
parserOptions: {
8+
ecmaVersion: 'latest',
9+
sourceType: 'module',
10+
ecmaFeatures: {
11+
jsx: true,
12+
},
13+
},
14+
})
15+
16+
ruleTester.run('no-deprecated-octicon', rule, {
17+
valid: [
18+
// Not an Octicon component
19+
{
20+
code: `import {Button} from '@primer/react'
21+
export default function App() {
22+
return <Button>Click me</Button>
23+
}`,
24+
},
25+
26+
// Already using direct icon import
27+
{
28+
code: `import {XIcon} from '@primer/octicons-react'
29+
export default function App() {
30+
return <XIcon />
31+
}`,
32+
},
33+
34+
// Octicon without icon prop (edge case - can't transform)
35+
{
36+
code: `import {Octicon} from '@primer/react/deprecated'
37+
export default function App() {
38+
return <Octicon />
39+
}`,
40+
},
41+
],
42+
43+
invalid: [
44+
// Basic case: simple Octicon with icon prop
45+
{
46+
code: `import {Octicon} from '@primer/react/deprecated'
47+
import {XIcon} from '@primer/octicons-react'
48+
export default function App() {
49+
return <Octicon icon={XIcon} />
50+
}`,
51+
output: `import {Octicon} from '@primer/react/deprecated'
52+
import {XIcon} from '@primer/octicons-react'
53+
export default function App() {
54+
return <XIcon />
55+
}`,
56+
errors: [
57+
{
58+
messageId: 'replaceDeprecatedOcticon',
59+
},
60+
],
61+
},
62+
63+
// Octicon with additional props
64+
{
65+
code: `import {Octicon} from '@primer/react/deprecated'
66+
import {XIcon} from '@primer/octicons-react'
67+
export default function App() {
68+
return <Octicon icon={XIcon} size={16} className="test" />
69+
}`,
70+
output: `import {Octicon} from '@primer/react/deprecated'
71+
import {XIcon} from '@primer/octicons-react'
72+
export default function App() {
73+
return <XIcon size={16} className="test" />
74+
}`,
75+
errors: [
76+
{
77+
messageId: 'replaceDeprecatedOcticon',
78+
},
79+
],
80+
},
81+
82+
// Octicon with spread props
83+
{
84+
code: `import {Octicon} from '@primer/react/deprecated'
85+
import {XIcon} from '@primer/octicons-react'
86+
export default function App() {
87+
const props = { size: 16 }
88+
return <Octicon {...props} icon={XIcon} className="test" />
89+
}`,
90+
output: `import {Octicon} from '@primer/react/deprecated'
91+
import {XIcon} from '@primer/octicons-react'
92+
export default function App() {
93+
const props = { size: 16 }
94+
return <XIcon {...props} className="test" />
95+
}`,
96+
errors: [
97+
{
98+
messageId: 'replaceDeprecatedOcticon',
99+
},
100+
],
101+
},
102+
103+
// Octicon with closing tag
104+
{
105+
code: `import {Octicon} from '@primer/react/deprecated'
106+
import {XIcon} from '@primer/octicons-react'
107+
export default function App() {
108+
return <Octicon icon={XIcon}>
109+
<span>Content</span>
110+
</Octicon>
111+
}`,
112+
output: `import {Octicon} from '@primer/react/deprecated'
113+
import {XIcon} from '@primer/octicons-react'
114+
export default function App() {
115+
return <XIcon>
116+
<span>Content</span>
117+
</XIcon>
118+
}`,
119+
errors: [
120+
{
121+
messageId: 'replaceDeprecatedOcticon',
122+
},
123+
],
124+
},
125+
126+
// Multiple Octicons
127+
{
128+
code: `import {Octicon} from '@primer/react/deprecated'
129+
import {XIcon, CheckIcon} from '@primer/octicons-react'
130+
export default function App() {
131+
return (
132+
<div>
133+
<Octicon icon={XIcon} />
134+
<Octicon icon={CheckIcon} size={24} />
135+
</div>
136+
)
137+
}`,
138+
output: `import {Octicon} from '@primer/react/deprecated'
139+
import {XIcon, CheckIcon} from '@primer/octicons-react'
140+
export default function App() {
141+
return (
142+
<div>
143+
<XIcon />
144+
<CheckIcon size={24} />
145+
</div>
146+
)
147+
}`,
148+
errors: [
149+
{
150+
messageId: 'replaceDeprecatedOcticon',
151+
},
152+
{
153+
messageId: 'replaceDeprecatedOcticon',
154+
},
155+
],
156+
},
157+
158+
// Complex conditional case - should report but not autofix
159+
{
160+
code: `import {Octicon} from '@primer/react/deprecated'
161+
import {XIcon, CheckIcon} from '@primer/octicons-react'
162+
export default function App() {
163+
return <Octicon icon={condition ? XIcon : CheckIcon} />
164+
}`,
165+
output: null,
166+
errors: [
167+
{
168+
messageId: 'replaceDeprecatedOcticon',
169+
},
170+
],
171+
},
172+
173+
// Dynamic icon access - should report but not autofix
174+
{
175+
code: `import {Octicon} from '@primer/react/deprecated'
176+
export default function App() {
177+
const icons = { x: XIcon }
178+
return <Octicon icon={icons.x} />
179+
}`,
180+
output: null,
181+
errors: [
182+
{
183+
messageId: 'replaceDeprecatedOcticon',
184+
},
185+
],
186+
},
187+
],
188+
})

src/rules/no-deprecated-octicon.js

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
'use strict'
2+
3+
const {getJSXOpeningElementAttribute} = require('../utils/get-jsx-opening-element-attribute')
4+
const {getJSXOpeningElementName} = require('../utils/get-jsx-opening-element-name')
5+
const url = require('../url')
6+
7+
/**
8+
* @type {import('eslint').Rule.RuleModule}
9+
*/
10+
module.exports = {
11+
meta: {
12+
type: 'problem',
13+
docs: {
14+
description: 'Replace deprecated `Octicon` component with specific icon imports from `@primer/octicons-react`',
15+
recommended: true,
16+
url: url(module),
17+
},
18+
fixable: 'code',
19+
schema: [],
20+
messages: {
21+
replaceDeprecatedOcticon:
22+
'Replace deprecated `Octicon` component with the specific icon from `@primer/octicons-react`',
23+
},
24+
},
25+
create(context) {
26+
const sourceCode = context.getSourceCode()
27+
28+
return {
29+
JSXElement(node) {
30+
const {openingElement, closingElement} = node
31+
const elementName = getJSXOpeningElementName(openingElement)
32+
33+
if (elementName !== 'Octicon') {
34+
return
35+
}
36+
37+
// Get the icon prop
38+
const iconProp = getJSXOpeningElementAttribute(openingElement, 'icon')
39+
if (!iconProp) {
40+
// No icon prop - can't determine what to replace with
41+
return
42+
}
43+
44+
let iconName = null
45+
let isDynamic = false
46+
47+
// Analyze the icon prop to determine the icon name
48+
if (iconProp.value?.type === 'JSXExpressionContainer') {
49+
const expression = iconProp.value.expression
50+
51+
if (expression.type === 'Identifier') {
52+
// Simple case: icon={XIcon}
53+
iconName = expression.name
54+
} else if (expression.type === 'ConditionalExpression') {
55+
// Conditional case: icon={condition ? XIcon : YIcon}
56+
// For now, we'll skip auto-fixing complex conditionals
57+
isDynamic = true
58+
} else if (expression.type === 'MemberExpression') {
59+
// Dynamic lookup: icon={icons.x}
60+
isDynamic = true
61+
}
62+
}
63+
64+
if (!iconName && !isDynamic) {
65+
return
66+
}
67+
68+
// For simple cases, we can provide an autofix
69+
if (iconName && !isDynamic) {
70+
context.report({
71+
node: openingElement,
72+
messageId: 'replaceDeprecatedOcticon',
73+
*fix(fixer) {
74+
// Replace opening element name
75+
yield fixer.replaceText(openingElement.name, iconName)
76+
77+
// Replace closing element name if it exists
78+
if (closingElement) {
79+
yield fixer.replaceText(closingElement.name, iconName)
80+
}
81+
82+
// Remove the icon prop with proper whitespace handling
83+
// Use the JSXAttribute node's properties to determine proper removal boundaries
84+
const attributes = openingElement.attributes
85+
const iconIndex = attributes.indexOf(iconProp)
86+
87+
if (iconIndex === 0 && attributes.length === 1) {
88+
// Only attribute: remove with leading space
89+
const beforeIcon = sourceCode.getTokenBefore(iconProp)
90+
const startPos =
91+
beforeIcon && /\s/.test(sourceCode.getText().substring(beforeIcon.range[1], iconProp.range[0]))
92+
? beforeIcon.range[1]
93+
: iconProp.range[0]
94+
yield fixer.removeRange([startPos, iconProp.range[1]])
95+
} else if (iconIndex === 0) {
96+
// First attribute: remove including trailing whitespace/comma
97+
const afterIcon = attributes[1]
98+
const afterPos = sourceCode.getText().substring(iconProp.range[1], afterIcon.range[0])
99+
const whitespaceMatch = /^\s*/.exec(afterPos)
100+
const endPos = whitespaceMatch ? iconProp.range[1] + whitespaceMatch[0].length : iconProp.range[1]
101+
yield fixer.removeRange([iconProp.range[0], endPos])
102+
} else {
103+
// Not first attribute: remove including leading whitespace/comma
104+
const beforeIcon = attributes[iconIndex - 1]
105+
const beforePos = sourceCode.getText().substring(beforeIcon.range[1], iconProp.range[0])
106+
const whitespaceMatch = /\s*$/.exec(beforePos)
107+
const startPos = whitespaceMatch
108+
? beforeIcon.range[1] + beforePos.length - whitespaceMatch[0].length
109+
: iconProp.range[0]
110+
yield fixer.removeRange([startPos, iconProp.range[1]])
111+
}
112+
},
113+
})
114+
} else {
115+
// For complex cases, just report without autofix
116+
context.report({
117+
node: openingElement,
118+
messageId: 'replaceDeprecatedOcticon',
119+
})
120+
}
121+
},
122+
}
123+
},
124+
}

0 commit comments

Comments
 (0)