eslint-plugin-primer-react 8.0.0-rc.d848c88 → 8.1.0-rc.d5a1ac8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/.github/copilot-instructions.md +159 -0
  2. package/.github/workflows/add-to-inbox.yml +2 -2
  3. package/.markdownlint-cli2.cjs +2 -2
  4. package/CHANGELOG.md +8 -2
  5. package/docs/rules/use-styled-react-import.md +128 -0
  6. package/eslint.config.js +54 -0
  7. package/package-lock.json +2006 -8418
  8. package/package.json +15 -9
  9. package/src/index.js +1 -0
  10. package/src/rules/__tests__/a11y-explicit-heading.test.js +5 -3
  11. package/src/rules/__tests__/a11y-link-in-text-block.test.js +5 -3
  12. package/src/rules/__tests__/a11y-no-duplicate-form-labels.test.js +5 -9
  13. package/src/rules/__tests__/a11y-no-title-usage.test.js +5 -3
  14. package/src/rules/__tests__/a11y-remove-disable-tooltip.test.js +5 -3
  15. package/src/rules/__tests__/a11y-tooltip-interactive-trigger.test.js +5 -3
  16. package/src/rules/__tests__/a11y-use-accessible-tooltip.test.js +5 -3
  17. package/src/rules/__tests__/direct-slot-children.test.js +5 -3
  18. package/src/rules/__tests__/enforce-button-for-link-with-nohref.test.js +5 -3
  19. package/src/rules/__tests__/enforce-css-module-identifier-casing.test.js +5 -3
  20. package/src/rules/__tests__/new-color-css-vars.test.js +5 -3
  21. package/src/rules/__tests__/no-deprecated-entrypoints.test.js +5 -3
  22. package/src/rules/__tests__/no-deprecated-experimental-components.test.js +5 -3
  23. package/src/rules/__tests__/no-deprecated-props.test.js +5 -3
  24. package/src/rules/__tests__/no-system-props.test.js +5 -4
  25. package/src/rules/__tests__/no-unnecessary-components.test.js +6 -4
  26. package/src/rules/__tests__/no-wildcard-imports.test.js +6 -43
  27. package/src/rules/__tests__/prefer-action-list-item-onselect.test.js +6 -4
  28. package/src/rules/__tests__/use-deprecated-from-deprecated.test.js +5 -3
  29. package/src/rules/__tests__/use-styled-react-import.test.js +251 -0
  30. package/src/rules/no-deprecated-experimental-components.js +0 -1
  31. package/src/rules/use-styled-react-import.js +483 -0
  32. package/.eslintignore +0 -2
  33. package/.eslintrc.js +0 -39
@@ -0,0 +1,483 @@
1
+ 'use strict'
2
+
3
+ const url = require('../url')
4
+ const {getJSXOpeningElementName} = require('../utils/get-jsx-opening-element-name')
5
+
6
+ // Components that should be imported from @primer/styled-react when used with sx prop
7
+ const styledComponents = new Set([
8
+ 'ActionList',
9
+ 'ActionMenu',
10
+ 'Box',
11
+ 'Breadcrumbs',
12
+ 'Button',
13
+ 'Flash',
14
+ 'FormControl',
15
+ 'Heading',
16
+ 'IconButton',
17
+ 'Label',
18
+ 'Link',
19
+ 'LinkButton',
20
+ 'PageLayout',
21
+ 'Text',
22
+ 'TextInput',
23
+ 'Truncate',
24
+ 'Octicon',
25
+ 'Dialog',
26
+ ])
27
+
28
+ // Types that should be imported from @primer/styled-react
29
+ const styledTypes = new Set(['BoxProps', 'SxProp', 'BetterSystemStyleObject'])
30
+
31
+ // Utilities that should be imported from @primer/styled-react
32
+ const styledUtilities = new Set(['sx'])
33
+
34
+ /**
35
+ * @type {import('eslint').Rule.RuleModule}
36
+ */
37
+ module.exports = {
38
+ meta: {
39
+ type: 'suggestion',
40
+ docs: {
41
+ description: 'Enforce importing components that use sx prop from @primer/styled-react',
42
+ recommended: false,
43
+ url: url(module),
44
+ },
45
+ fixable: 'code',
46
+ schema: [],
47
+ messages: {
48
+ useStyledReactImport: 'Import {{ componentName }} from "@primer/styled-react" when using with sx prop',
49
+ useStyledReactImportWithAlias:
50
+ 'Import {{ componentName }} as {{ aliasName }} from "@primer/styled-react" when using with sx prop (conflicts with non-sx usage)',
51
+ useAliasedComponent: 'Use {{ aliasName }} instead of {{ componentName }} when using sx prop',
52
+ moveToStyledReact: 'Move {{ importName }} import to "@primer/styled-react"',
53
+ usePrimerReactImport: 'Import {{ componentName }} from "@primer/react" when not using sx prop',
54
+ },
55
+ },
56
+ create(context) {
57
+ const componentsWithSx = new Set()
58
+ const componentsWithoutSx = new Set() // Track components used without sx
59
+ const allUsedComponents = new Set() // Track all used components
60
+ const primerReactImports = new Map() // Map of component name to import node
61
+ 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
63
+ const jsxElementsWithSx = [] // Track JSX elements that use sx prop
64
+ const jsxElementsWithoutSx = [] // Track JSX elements that don't use sx prop
65
+
66
+ return {
67
+ ImportDeclaration(node) {
68
+ const importSource = node.source.value
69
+
70
+ if (importSource === '@primer/react') {
71
+ // Track imports from @primer/react
72
+ for (const specifier of node.specifiers) {
73
+ if (specifier.type === 'ImportSpecifier') {
74
+ const importedName = specifier.imported.name
75
+ if (
76
+ styledComponents.has(importedName) ||
77
+ styledTypes.has(importedName) ||
78
+ styledUtilities.has(importedName)
79
+ ) {
80
+ primerReactImports.set(importedName, {node, specifier})
81
+ }
82
+ }
83
+ }
84
+ } else if (importSource === '@primer/styled-react') {
85
+ // Track what's imported from styled-react
86
+ for (const specifier of node.specifiers) {
87
+ if (specifier.type === 'ImportSpecifier') {
88
+ const importedName = specifier.imported.name
89
+ const localName = specifier.local.name
90
+ styledReactImports.set(importedName, {node, specifier})
91
+
92
+ // Track alias mapping for styled-react imports
93
+ if (localName !== importedName) {
94
+ aliasMapping.set(localName, importedName)
95
+ }
96
+ }
97
+ }
98
+ }
99
+ },
100
+
101
+ JSXElement(node) {
102
+ const openingElement = node.openingElement
103
+ const componentName = getJSXOpeningElementName(openingElement)
104
+
105
+ // Check if this is an aliased component from styled-react
106
+ const originalComponentName = aliasMapping.get(componentName) || componentName
107
+
108
+ // Track all used components that are in our styled components list
109
+ if (styledComponents.has(originalComponentName)) {
110
+ allUsedComponents.add(originalComponentName)
111
+
112
+ // Check if this component has an sx prop
113
+ const hasSxProp = openingElement.attributes.some(
114
+ attr => attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'sx',
115
+ )
116
+
117
+ if (hasSxProp) {
118
+ componentsWithSx.add(originalComponentName)
119
+ jsxElementsWithSx.push({node, componentName: originalComponentName, openingElement})
120
+ } else {
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
+ }
132
+ }
133
+ }
134
+ },
135
+
136
+ 'Program:exit': function () {
137
+ // Group components by import node to handle multiple changes to same import
138
+ const importNodeChanges = new Map()
139
+
140
+ // Collect all changes needed for components used with sx prop
141
+ for (const componentName of componentsWithSx) {
142
+ const importInfo = primerReactImports.get(componentName)
143
+ if (importInfo && !styledReactImports.has(componentName)) {
144
+ const hasConflict = componentsWithoutSx.has(componentName)
145
+ const {node: importNode} = importInfo
146
+
147
+ if (!importNodeChanges.has(importNode)) {
148
+ importNodeChanges.set(importNode, {
149
+ toMove: [],
150
+ toAlias: [],
151
+ originalSpecifiers: [...importNode.specifiers],
152
+ })
153
+ }
154
+
155
+ const changes = importNodeChanges.get(importNode)
156
+ if (hasConflict) {
157
+ changes.toAlias.push(componentName)
158
+ } else {
159
+ changes.toMove.push(componentName)
160
+ }
161
+ }
162
+ }
163
+
164
+ // Report errors for components used with sx prop that are imported from @primer/react
165
+ for (const componentName of componentsWithSx) {
166
+ const importInfo = primerReactImports.get(componentName)
167
+ if (importInfo && !styledReactImports.has(componentName)) {
168
+ // Check if this component is also used without sx prop (conflict scenario)
169
+ const hasConflict = componentsWithoutSx.has(componentName)
170
+
171
+ context.report({
172
+ node: importInfo.specifier,
173
+ messageId: hasConflict ? 'useStyledReactImportWithAlias' : 'useStyledReactImport',
174
+ data: hasConflict ? {componentName, aliasName: `Styled${componentName}`} : {componentName},
175
+ fix(fixer) {
176
+ const {node: importNode, specifier} = importInfo
177
+ const changes = importNodeChanges.get(importNode)
178
+
179
+ if (!changes) {
180
+ return null
181
+ }
182
+
183
+ // Only apply the fix once per import node (for the first component processed)
184
+ const isFirstComponent =
185
+ changes.originalSpecifiers[0] === specifier ||
186
+ (changes.toMove.length > 0 && changes.toMove[0] === componentName) ||
187
+ (changes.toAlias.length > 0 && changes.toAlias[0] === componentName)
188
+
189
+ if (!isFirstComponent) {
190
+ return null
191
+ }
192
+
193
+ const fixes = []
194
+ const componentsToMove = new Set(changes.toMove)
195
+
196
+ // Find specifiers that remain in original import
197
+ const remainingSpecifiers = changes.originalSpecifiers.filter(spec => {
198
+ const name = spec.imported.name
199
+ // Keep components that are not being moved (only aliased components stay for non-sx usage)
200
+ return !componentsToMove.has(name)
201
+ })
202
+
203
+ // If no components remain, replace with new imports directly
204
+ if (remainingSpecifiers.length === 0) {
205
+ // Build the new imports to replace the original
206
+ const newImports = []
207
+
208
+ // Add imports for moved components
209
+ for (const componentName of changes.toMove) {
210
+ newImports.push(`import { ${componentName} } from '@primer/styled-react'`)
211
+ }
212
+
213
+ // Add aliased imports for conflicted components
214
+ for (const componentName of changes.toAlias) {
215
+ const aliasName = `Styled${componentName}`
216
+ newImports.push(`import { ${componentName} as ${aliasName} } from '@primer/styled-react'`)
217
+ }
218
+
219
+ fixes.push(fixer.replaceText(importNode, newImports.join('\n')))
220
+ } else {
221
+ // Otherwise, update the import to only include remaining components
222
+ const remainingNames = remainingSpecifiers.map(spec => spec.imported.name)
223
+ fixes.push(
224
+ fixer.replaceText(importNode, `import { ${remainingNames.join(', ')} } from '@primer/react'`),
225
+ )
226
+
227
+ // Combine all styled-react imports into a single import statement
228
+ const styledReactImports = []
229
+
230
+ // Add aliased components first
231
+ for (const componentName of changes.toAlias) {
232
+ const aliasName = `Styled${componentName}`
233
+ styledReactImports.push(`${componentName} as ${aliasName}`)
234
+ }
235
+
236
+ // Add moved components second
237
+ for (const componentName of changes.toMove) {
238
+ styledReactImports.push(componentName)
239
+ }
240
+
241
+ if (styledReactImports.length > 0) {
242
+ fixes.push(
243
+ fixer.insertTextAfter(
244
+ importNode,
245
+ `\nimport { ${styledReactImports.join(', ')} } from '@primer/styled-react'`,
246
+ ),
247
+ )
248
+ }
249
+ }
250
+
251
+ return fixes
252
+ },
253
+ })
254
+ }
255
+ }
256
+
257
+ // Report on JSX elements that should use aliased components
258
+ for (const {node: jsxNode, componentName, openingElement} of jsxElementsWithSx) {
259
+ const hasConflict = componentsWithoutSx.has(componentName)
260
+ const isImportedFromPrimerReact = primerReactImports.has(componentName)
261
+
262
+ if (hasConflict && isImportedFromPrimerReact && !styledReactImports.has(componentName)) {
263
+ const aliasName = `Styled${componentName}`
264
+ context.report({
265
+ node: openingElement,
266
+ messageId: 'useAliasedComponent',
267
+ data: {componentName, aliasName},
268
+ fix(fixer) {
269
+ const fixes = []
270
+
271
+ // Replace the component name in the JSX opening tag
272
+ fixes.push(fixer.replaceText(openingElement.name, aliasName))
273
+
274
+ // Replace the component name in the JSX closing tag if it exists
275
+ if (jsxNode.closingElement) {
276
+ fixes.push(fixer.replaceText(jsxNode.closingElement.name, aliasName))
277
+ }
278
+
279
+ return fixes
280
+ },
281
+ })
282
+ }
283
+ }
284
+
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
+
311
+ // Report errors for components used WITHOUT sx prop that are imported from @primer/styled-react
312
+ for (const componentName of allUsedComponents) {
313
+ // If component is used but NOT with sx prop, and it's imported from styled-react
314
+ if (!componentsWithSx.has(componentName) && styledReactImports.has(componentName)) {
315
+ const importInfo = styledReactImports.get(componentName)
316
+ context.report({
317
+ node: importInfo.specifier,
318
+ messageId: 'usePrimerReactImport',
319
+ data: {componentName},
320
+ fix(fixer) {
321
+ const {node: importNode} = importInfo
322
+ const changes = styledReactImportNodeChanges.get(importNode)
323
+
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
333
+ }
334
+
335
+ const fixes = []
336
+ const componentsToMove = new Set(changes.toMove)
337
+
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
+ )
360
+ 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'`))
386
+ } else {
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
+ )
395
+
396
+ const movedComponents = changes.toMove.join(', ')
397
+ fixes.push(fixer.insertTextAfter(importNode, `\nimport { ${movedComponents} } from '@primer/react'`))
398
+ }
399
+
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
+ }
423
+
424
+ return fixes
425
+ },
426
+ })
427
+ }
428
+ }
429
+
430
+ // Also report for types and utilities that should always be from styled-react
431
+ for (const [importName, importInfo] of primerReactImports) {
432
+ if ((styledTypes.has(importName) || styledUtilities.has(importName)) && !styledReactImports.has(importName)) {
433
+ context.report({
434
+ node: importInfo.specifier,
435
+ messageId: 'moveToStyledReact',
436
+ data: {importName},
437
+ fix(fixer) {
438
+ const {node: importNode, specifier} = importInfo
439
+ const otherSpecifiers = importNode.specifiers.filter(s => s !== specifier)
440
+
441
+ // If this is the only import, replace the whole import
442
+ if (otherSpecifiers.length === 0) {
443
+ const prefix = styledTypes.has(importName) ? 'type ' : ''
444
+ return fixer.replaceText(importNode, `import { ${prefix}${importName} } from '@primer/styled-react'`)
445
+ }
446
+
447
+ // Otherwise, remove from current import and add new import
448
+ const fixes = []
449
+
450
+ // Remove the specifier from current import
451
+ if (importNode.specifiers.length === 1) {
452
+ fixes.push(fixer.remove(importNode))
453
+ } else {
454
+ const isFirst = importNode.specifiers[0] === specifier
455
+ const isLast = importNode.specifiers[importNode.specifiers.length - 1] === specifier
456
+
457
+ if (isFirst) {
458
+ const nextSpecifier = importNode.specifiers[1]
459
+ fixes.push(fixer.removeRange([specifier.range[0], nextSpecifier.range[0]]))
460
+ } else if (isLast) {
461
+ const prevSpecifier = importNode.specifiers[importNode.specifiers.length - 2]
462
+ fixes.push(fixer.removeRange([prevSpecifier.range[1], specifier.range[1]]))
463
+ } else {
464
+ const nextSpecifier = importNode.specifiers[importNode.specifiers.indexOf(specifier) + 1]
465
+ fixes.push(fixer.removeRange([specifier.range[0], nextSpecifier.range[0]]))
466
+ }
467
+ }
468
+
469
+ // Add new import
470
+ const prefix = styledTypes.has(importName) ? 'type ' : ''
471
+ fixes.push(
472
+ fixer.insertTextAfter(importNode, `\nimport { ${prefix}${importName} } from '@primer/styled-react'`),
473
+ )
474
+
475
+ return fixes
476
+ },
477
+ })
478
+ }
479
+ }
480
+ },
481
+ }
482
+ },
483
+ }
package/.eslintignore DELETED
@@ -1,2 +0,0 @@
1
- **/dist/**
2
- **/node_modules/**
package/.eslintrc.js DELETED
@@ -1,39 +0,0 @@
1
- 'use strict'
2
-
3
- /**
4
- * @type {import('eslint').Linter.Config}
5
- */
6
- module.exports = {
7
- extends: ['eslint:recommended', 'plugin:github/recommended'],
8
- parserOptions: {
9
- ecmaVersion: 'latest',
10
- },
11
- env: {
12
- commonjs: true,
13
- node: true,
14
- },
15
- rules: {
16
- 'import/no-commonjs': 'off',
17
- 'no-shadow': 'off',
18
- 'no-unused-vars': [
19
- 'error',
20
- {
21
- varsIgnorePattern: '^_',
22
- },
23
- ],
24
- },
25
- overrides: [
26
- {
27
- files: ['**/*.test.js'],
28
- env: {
29
- jest: true,
30
- },
31
- },
32
- {
33
- files: ['.eslintrc.js'],
34
- rules: {
35
- 'filenames/match-regex': 'off',
36
- },
37
- },
38
- ],
39
- }